Skip to main content

agentstatedeveloper_cli/commands/
prepare_change.rs

1//! `asd prepare-change "<description>"` — agent-ready context in one call.
2//!
3//! Composes investigate + impact + checklist into a single compact JSON package:
4//!   design_invariants  — constraints that must hold across the change
5//!   known_hazards      — ledger hazards from matching symbols
6//!   entry_points       — by_layer grouped map of top matching symbols
7//!   likely_edit_files  — ranked unique files with recency annotation
8//!   affected_tests     — test callers within BFS depth of top entry point
9//!   effects_summary    — declared effects from top entry points
10//!   recently_touched   — per-file git log from the top entry point's file
11
12use std::collections::{HashMap, HashSet, VecDeque};
13
14use anyhow::Result;
15use clap::Args;
16use serde_json::{Value, json};
17
18use agentstatedeveloper_core::{
19    AsgEffectStore, AsgFeedbackStore, AsgIndexStore, AsgLedgerStore, CandidateAggregates,
20    EffectStore, Engine, FeedbackStore, FtsFilters, IndexStore, LedgerKind, LedgerStore,
21    SearchFtsDb, Symbol, aggregate_candidate_data, apply_feedback_adjustments, classify_file_role,
22    classify_layer_sym, compute_trust_score, compute_uncertainty, confidence_scores,
23    derive_cold_hints, detect_ambiguous_tokens, detect_possible_misses, estimate_tokens,
24    explain_match, extract_summary, fetch_all_test_file_paths, finalize_file_scores,
25    find_candidates, gather_recency, git_dirty_files, glob_match, intent_focus, intent_layer_order,
26    load_layer_overrides, parse_intent, parse_query, propagate_caller_invariants,
27    propose_test_path, propose_test_stub, resolve_scope, result_bucket, stale_warning, symbol_tier,
28    test_files_for_source, trim_for_agent,
29};
30
31use crate::commands::impact::git_recent_touches_pub;
32use crate::config::Config;
33
34#[derive(Debug, Args)]
35pub struct PrepareChangeArgs {
36    /// Free-form description of the intended change (treated as a search query).
37    pub description: String,
38
39    /// Number of top entry-point symbols to expand (default: 10).
40    #[arg(long, default_value = "10")]
41    pub depth: usize,
42
43    /// Filter by symbol kind.
44    #[arg(long)]
45    pub kind: Option<String>,
46
47    /// Filter by language.
48    #[arg(long)]
49    pub language: Option<String>,
50
51    /// Include test-file symbols as entry-point candidates.
52    #[arg(long)]
53    pub include_tests: bool,
54
55    /// Suppress the stale-index warning.
56    #[arg(long)]
57    pub quiet: bool,
58
59    /// Adjust output for a specific intent.
60    /// Values: bugfix, feature, refactor, test, architecture, ui.
61    #[arg(long)]
62    pub intent: Option<String>,
63
64    /// BFS depth for finding affected tests from the top entry point (default: 2).
65    #[arg(long, default_value = "2")]
66    pub test_depth: usize,
67
68    /// Number of recent git commits to scan per file (default: 10).
69    #[arg(long, default_value = "10")]
70    pub git_depth: usize,
71
72    /// Emit token-budgeted JSON for LLM consumption. Trims bodies,
73    /// collapses low-signal fields, adds token_estimate.
74    #[arg(long)]
75    pub agent: bool,
76
77    /// Token budget when --agent is set (default: 8000).
78    #[arg(long, default_value = "8000")]
79    pub agent_budget: usize,
80
81    /// Comma-separated terms to exclude (alias: --avoid). Fights lexical
82    /// over-matching, e.g. --avoid MIDI when "connect" pulls in MIDI code. Also
83    /// supports inline minus-prefix syntax in the description, e.g. "drift
84    /// playhead -sample -waveform".
85    #[arg(long, visible_alias = "avoid")]
86    pub exclude: Option<String>,
87
88    /// Comma-separated glob patterns to restrict results to specific paths,
89    /// e.g. --paths 'AcmeFlowCore/*Bus*'. This is the path-scope hint; use
90    /// --scope for a named alias from .asd/scopes.toml.
91    #[arg(long)]
92    pub paths: Option<String>,
93
94    /// Named scope alias from .asd/scopes.toml, e.g. --scope drift-pad.
95    #[arg(long)]
96    pub scope: Option<String>,
97
98    /// Inject active task context to enrich the search query.
99    /// Pass the description of the current CTX task (or any relevant context)
100    /// and its tokens are appended to the description before candidate scoring.
101    /// Example: --task-context "$(ctx task show --format plain)"
102    #[arg(long)]
103    pub task_context: Option<String>,
104
105    /// Print per-file classification reasoning: file_role, surface_demoted,
106    /// domain_anchor_retained, matched stem words, and the rule that won.
107    #[arg(long)]
108    pub debug_classification: bool,
109
110    /// Compare predictions against the actual files changed in this commit.
111    /// Emits edit_precision_metrics: precision, recall, F1, true/false positives/negatives.
112    /// Example: --check-commit HEAD or --check-commit abc123f
113    #[arg(long)]
114    pub check_commit: Option<String>,
115
116    /// AcmeFlow refinement (1.0.76): minimum confidence for a
117    /// Hypothesis to surface in `prior_thinking`. Defaults to 0.3
118    /// (matches `core::thinking::DEFAULT_CONFIDENCE_FLOOR`). Lower
119    /// to see speculative hypotheses; raise to suppress noise.
120    /// Hypotheses below the floor still appear in
121    /// `thinking_summary.by_kind_dropped` for visibility.
122    #[arg(long)]
123    pub thinking_floor: Option<f64>,
124}
125
126pub fn run(cfg: &Config, args: PrepareChangeArgs) -> Result<()> {
127    // AcmeFlow refinement (1.0.77): use the 24h soft threshold +
128    // classify severity. Only print Critical to stderr unconditionally;
129    // Soft warnings (just-past-threshold but FTS healthy) are demoted
130    // into the JSON output's `stale_severity` field, where downstream
131    // UIs can render them quietly — or skip rendering if the query
132    // resolved successfully.
133    if !args.quiet {
134        if let Some(warn) = agentstatedeveloper_core::stale_warning_classified(
135            &cfg.db_path,
136            agentstatedeveloper_core::SOFT_STALE_THRESHOLD_SECS,
137        ) {
138            if warn.severity == agentstatedeveloper_core::StaleSeverity::Critical {
139                eprintln!("{}", warn.message);
140            }
141            // Soft severity: suppressed from stderr; surfaces in the
142            // response JSON's `stale` + `stale_severity` fields so the
143            // agent can read it without it bullhorning every run.
144        }
145    }
146    let intent = args.intent.as_deref().and_then(parse_intent).unwrap_or("");
147    let layer_overrides = load_layer_overrides(&cfg.db_path);
148    let engine = Engine::open_sqlite(&cfg.db_path)?;
149    let index_store = AsgIndexStore::from_engine(&engine);
150    let ledger_store = AsgLedgerStore::from_engine(&engine);
151    let effect_store = AsgEffectStore::from_engine(&engine);
152    let id_map = index_store.build_id_map(&engine);
153
154    let (mut tokens, mut exclusions) = parse_query(&args.description);
155    if let Some(ref excl) = args.exclude {
156        for term in excl
157            .split(',')
158            .map(|t| t.trim().to_lowercase())
159            .filter(|t| !t.is_empty())
160        {
161            exclusions.push(term);
162        }
163    }
164    // Enrich query with active task context (CTX task description, etc.).
165    // Auto-loads from CTXONE_PLAN / CTXONE_TASK env vars when --task-context is absent.
166    let auto_ctx_plan = std::env::var("CTXONE_PLAN").ok().filter(|s| !s.is_empty());
167    let auto_ctx_task = std::env::var("CTXONE_TASK").ok().filter(|s| !s.is_empty());
168    let ctx_text = args.task_context.clone().or_else(|| {
169        let parts: Vec<&str> = [auto_ctx_plan.as_deref(), auto_ctx_task.as_deref()]
170            .iter()
171            .filter_map(|x| *x)
172            .collect();
173        if parts.is_empty() {
174            None
175        } else {
176            Some(parts.join(" "))
177        }
178    });
179    if let Some(ref ctx) = ctx_text {
180        let (ctx_tokens, _) = parse_query(ctx);
181        for t in ctx_tokens {
182            if !tokens.contains(&t) {
183                tokens.push(t);
184            }
185        }
186    }
187    if tokens.is_empty() {
188        println!(
189            "{}",
190            json!({"description": args.description, "entry_points": {}})
191        );
192        return Ok(());
193    }
194
195    let mut paths_filter: Vec<String> = Vec::new();
196    if let Some(ref scope) = args.scope {
197        paths_filter.extend(resolve_scope(scope, &cfg.db_path));
198    }
199    if let Some(ref paths) = args.paths {
200        paths_filter.extend(
201            paths
202                .split(',')
203                .map(|p| p.trim().to_string())
204                .filter(|p| !p.is_empty()),
205        );
206    }
207    let _has_paths_filter = !paths_filter.is_empty();
208    let filters = FtsFilters {
209        kind: args.kind.as_deref().map(|k| k.to_lowercase()),
210        language: args.language.as_deref().map(|l| l.to_lowercase()),
211        include_tests: args.include_tests,
212        tests_only: false,
213        exclude_terms: exclusions,
214        paths_filter,
215        exclude_paths: Vec::new(),
216        exclude_languages: Vec::new(),
217    };
218
219    let mut candidates = find_candidates(
220        &engine,
221        &args.description,
222        &tokens,
223        &filters,
224        &ledger_store,
225        &index_store,
226        args.depth,
227    );
228
229    // Apply durable feedback adjustments (Useful/Noisy/WrongLayer verdicts).
230    // list_all() is hoisted here and reused for wrong_layer_files below — avoids two separate
231    // git-object reads for the same feedback data.
232    let feedback_store = AsgFeedbackStore::from_engine(&engine);
233    let all_fb = feedback_store
234        .list_all(&engine.ref_name)
235        .unwrap_or_default();
236    // Derive flat_verdicts from the hoisted list (same logic as the
237    // FeedbackStore default impl). Plan J t-016: tuple includes
238    // created_at so the boost arithmetic can decay by age.
239    let feedback_verdicts: Vec<(
240        String,
241        String,
242        agentstatedeveloper_core::FeedbackVerdict,
243        chrono::DateTime<chrono::Utc>,
244    )> = all_fb
245        .iter()
246        .filter(|e| e.file_scope.is_none())
247        .map(|e| {
248            (
249                e.symbol_id.clone(),
250                e.query.clone(),
251                e.verdict,
252                e.created_at,
253            )
254        })
255        .collect();
256    let feedback_metrics = apply_feedback_adjustments(
257        &engine,
258        &index_store,
259        &args.description,
260        &mut candidates,
261        &feedback_verdicts,
262    );
263
264    // Recency pass (one git call for all files).
265    let recency = gather_recency(200, 14.0);
266
267    // ---- Build entry points + aggregate data ----------------------------
268    // Plan M t-003 (1.0.96): main candidate loop extracted to
269    // aggregate_candidate_data(). The 6 parallel accumulators (by_layer,
270    // design_invariants, known_hazards, validation_scenarios_ledger,
271    // effects_summary, file_scores) plus top_sym_id and seen_inv now live
272    // in CandidateAggregates. seen_inv is returned so the subsequent
273    // caller-invariant propagation pass dedups against the main loop.
274    let layer_order = intent_layer_order(intent);
275    let CandidateAggregates {
276        mut by_layer,
277        mut design_invariants,
278        known_hazards,
279        validation_scenarios_ledger,
280        effects_summary,
281        mut file_scores,
282        top_sym_id,
283        mut seen_inv,
284    } = aggregate_candidate_data(
285        &engine,
286        &index_store,
287        &ledger_store,
288        &effect_store,
289        &candidates,
290        &tokens,
291        &recency,
292        &layer_overrides,
293    );
294
295    // Plan J t-001: invariant propagation from callers.
296    //
297    // The original gap (field note: "invariants are silently dropped
298    // when you query the callee"): an agent changing function B
299    // should see invariants attached to functions A that call B —
300    // because a contract A relies on could be broken by B's change.
301    //
302    // The candidate loop above only collects invariants from the
303    // matched symbols themselves. Here we walk each candidate's
304    // direct callers (depth=1) and add their invariants too,
305    // deduplicating against the same `seen_inv` set so duplicates
306    // don't accumulate. Each propagated entry is tagged
307    // `from_caller: <caller_qname>` so the agent can tell which
308    // upstream contract is at stake.
309    //
310    // Depth=1 is conservative — depth=2+ tends to surface invariants
311    // too far removed from the planned change to be actionable.
312    // `impact` (which does walk transitive callers) is the right
313    // tool for full blast-radius reasoning; prepare_change stays
314    // focused on edit-time signal.
315    // Plan M t-003 (1.0.95): extracted to propagate_caller_invariants().
316    let propagated = propagate_caller_invariants(
317        &engine,
318        &index_store,
319        &ledger_store,
320        &cfg.db_path,
321        &candidates,
322        &mut seen_inv,
323    );
324    design_invariants.extend(propagated);
325
326    // Reorder by_layer keys according to layer_order.
327    let mut ordered_by_layer: serde_json::Map<String, Value> = serde_json::Map::new();
328    for lk in layer_order {
329        if let Some(v) = by_layer.remove(*lk) {
330            ordered_by_layer.insert(lk.to_string(), v);
331        }
332    }
333
334    // Hoist git_dirty_files() — reused for conflict_risk below and stale_symbols further down.
335    let dirty_files = git_dirty_files();
336    // Plan M t-003 (1.0.97): sort + cliff cut + likely_edit_files build
337    // extracted to finalize_file_scores(). Mutates file_scores in place
338    // (sorts hot-first, applies cliff retain) so downstream stale_symbols
339    // sees the same filtered set.
340    let likely_edit_files = finalize_file_scores(&mut file_scores, &dirty_files, true);
341
342    // ---- Affected tests via BFS from the top entry point ----------------
343    // Plan M t-003 (1.0.93): extracted to gather_affected_tests().
344    let affected_tests = gather_affected_tests(
345        &engine,
346        &index_store,
347        &id_map,
348        top_sym_id.as_deref(),
349        args.test_depth,
350        &design_invariants,
351    );
352
353    // ---- Blast-radius: caller/callee layer distribution + concrete call chains ----
354    // Plan M t-003 (1.0.93): extracted to compute_blast_radius().
355    let blast_radius = compute_blast_radius(
356        &engine,
357        &index_store,
358        &id_map,
359        &candidates,
360        &layer_overrides,
361    );
362
363    // ---- Recent git touches for the top files (up to 3) ----------------
364    let top_files: Vec<(String, usize)> = file_scores
365        .iter()
366        .take(3)
367        .map(|(_, f, _, _, _, _, _)| (f.clone(), 0))
368        .collect();
369    let recently_touched = git_recent_touches_pub(&top_files, args.git_depth);
370
371    // --- Staleness warnings -----------------------------------------------
372    // Reuse dirty_files hoisted above — no second git status run.
373    let stale_symbols: Vec<&str> = file_scores
374        .iter()
375        .filter(|(_, f, _, _, _, _, _)| dirty_files.contains(f.as_str()))
376        .map(|(_, f, _, _, _, _, _)| f.as_str())
377        .collect();
378
379    // --- Test-gap detection -----------------------------------------------
380    // Pre-fetch all indexed test file paths once — reused for proposed_test_path
381    // and the recipe_edit per-file coverage lookup, avoiding N separate DB opens.
382    let all_test_file_paths = fetch_all_test_file_paths(&cfg.db_path);
383    // Plan M t-003 (1.0.94): extracted to detect_test_gap().
384    let (test_gap, proposed_test_path, proposed_test_stub) =
385        detect_test_gap(&file_scores, &affected_tests, &all_test_file_paths);
386    // 1.0.86: suggested_test_coverage was emitting bare summary
387    // strings duplicated from design_invariants. AcmeFlow probe
388    // 2 confirmed the overlap. Now emits structured entries:
389    //   { ref: "<entry_id>" }   for invariant-derived hints
390    //   { hint: "<text>" }      for effect-derived / cold-start hints
391    // Agent resolves refs against design_invariants. Saves the
392    // summary-text duplication (~200-800 chars on rich-ledger
393    // responses) while preserving the cold-start + effect-derived
394    // semantics that AREN'T duplicates of anything.
395    let suggested_test_coverage: Vec<Value> = if test_gap {
396        let mut out: Vec<Value> = Vec::new();
397        let mut seen: HashSet<String> = HashSet::new();
398        for inv in &design_invariants {
399            if let Some(eid) = inv.get("entry_id").and_then(Value::as_str) {
400                if seen.insert(format!("ref:{eid}")) {
401                    out.push(json!({ "ref": eid }));
402                }
403            }
404        }
405        for eff in &effects_summary {
406            if let Some(cat) = eff.get("category").and_then(Value::as_str) {
407                let hint = format!("verify {} after change", cat.to_lowercase());
408                if seen.insert(format!("hint:{hint}")) {
409                    out.push(json!({ "hint": hint }));
410                }
411            }
412        }
413        // Cold-start fallback: derive hints from the top candidate
414        // symbol when no invariants exist. These are genuinely new
415        // (not duplicates), emit as text hints.
416        if design_invariants.is_empty() {
417            if let Some((_, qname)) = candidates.first() {
418                if let Ok(Some(sym)) = index_store.get_symbol_by_qname(&engine.ref_name, qname) {
419                    for h in
420                        derive_cold_hints(&sym.qname, sym.signature.as_deref(), sym.doc.as_deref())
421                    {
422                        if seen.insert(format!("hint:{h}")) {
423                            out.push(json!({ "hint": h }));
424                        }
425                    }
426                }
427            }
428        }
429        out
430    } else {
431        vec![]
432    };
433
434    const CONSTRAINT_WORDS: &[&str] = &[
435        "must",
436        "never",
437        "shall",
438        "always",
439        "only",
440        "cannot",
441        "no ",
442        "not ",
443        "require",
444        "ensure",
445        "prevent",
446        "guarantee",
447        "invariant",
448        "forbidden",
449    ];
450    // 1.0.86: scenario_tests was emitting bare summary strings
451    // (a filtered subset of design_invariants[].summary). Now
452    // emits { ref: "<entry_id>" } references — agent resolves
453    // against design_invariants. The CONSTRAINT_WORDS filter is
454    // preserved: only invariants phrased as constraints
455    // (must/never/shall/...) become scenario_tests.
456    let scenario_tests: Vec<Value> = design_invariants
457        .iter()
458        .filter_map(|inv| {
459            let summary = inv.get("summary").and_then(Value::as_str)?;
460            let entry_id = inv.get("entry_id").and_then(Value::as_str)?;
461            let sl = summary.to_lowercase();
462            if CONSTRAINT_WORDS.iter().any(|w| sl.contains(w)) {
463                Some(json!({ "ref": entry_id }))
464            } else {
465                None
466            }
467        })
468        .collect();
469
470    let ambiguous_terms = detect_ambiguous_tokens(&tokens, engine.fts.as_ref(), &filters);
471    // t-001: broad_query = at least half the query tokens are flagged as ambiguous.
472    let broad_query = !ambiguous_terms.is_empty() && {
473        let amb_set: HashSet<&str> = ambiguous_terms.iter().map(|s| s.as_str()).collect();
474        let amb_count = tokens
475            .iter()
476            .filter(|t| amb_set.contains(t.as_str()))
477            .count();
478        amb_count * 2 >= tokens.len().max(1)
479    };
480
481    let layers_present: std::collections::HashSet<&str> = file_scores
482        .iter()
483        .map(|(_, _, layer, _, _, _, _)| layer.as_str())
484        .collect();
485    // Suppress possible-miss warnings when the user explicitly narrowed scope.
486    let scope_narrowed = !filters.paths_filter.is_empty() || !filters.exclude_terms.is_empty();
487    let possible_misses = if scope_narrowed {
488        vec![]
489    } else {
490        detect_possible_misses(&args.description, &layers_present, file_scores.len())
491    };
492
493    // Likely omitted: files that score well in an unscoped search but were filtered
494    // by the active scope/path filter. We run a quick unscoped FTS pass to find them,
495    // then merge with caller/callee-based omissions from the graph.
496    let fts_omitted_files: Vec<Value> = if scope_narrowed && !filters.paths_filter.is_empty() {
497        let unscoped_filters = FtsFilters {
498            tests_only: false,
499            kind: filters.kind.clone(),
500            language: filters.language.clone(),
501            include_tests: filters.include_tests,
502            exclude_terms: filters.exclude_terms.clone(),
503            paths_filter: vec![], // no path filter
504            exclude_paths: filters.exclude_paths.clone(),
505            exclude_languages: filters.exclude_languages.clone(),
506        };
507        let unscoped_hits = SearchFtsDb::open(&cfg.db_path)
508            .ok()
509            .filter(|fts| fts.has_data())
510            .and_then(|fts| fts.search(&args.description, &unscoped_filters, 20).ok())
511            .unwrap_or_default();
512        let scoped_file_set: std::collections::HashSet<&str> = file_scores
513            .iter()
514            .map(|(_, f, _, _, _, _, _)| f.as_str())
515            .collect();
516        unscoped_hits
517            .iter()
518            .filter(|h| !filters.paths_filter.iter().any(|p| glob_match(p, &h.file)))
519            .filter(|h| !scoped_file_set.contains(h.file.as_str()))
520            .take(5)
521            .map(|h| {
522                let dir = std::path::Path::new(&h.file)
523                    .parent()
524                    .map(|d| format!("{}/**", d.to_string_lossy()))
525                    .unwrap_or_else(|| h.file.clone());
526                json!({
527                    "file": h.file,
528                    "relation": "unscoped_fts_match",
529                    "score": h.bm25_score,
530                    "suggested_paths": dir,
531                    "note": "matched query in broad search but excluded by current scope",
532                })
533            })
534            .collect()
535    } else {
536        vec![]
537    };
538
539    // Graph-based omissions: callers/callees outside scope.
540    let likely_omitted_files: Vec<Value> = if scope_narrowed && !filters.paths_filter.is_empty() {
541        let top_sids_omit: Vec<String> = candidates
542            .iter()
543            .take(3)
544            .filter_map(|(_, q)| {
545                index_store
546                    .get_symbol_by_qname(&engine.ref_name, q)
547                    .ok()
548                    .flatten()
549            })
550            .map(|s| s.symbol_id)
551            .collect();
552        let mut omitted: Vec<Value> = Vec::new();
553        let mut seen_files: HashSet<String> = HashSet::new();
554        for sid in &top_sids_omit {
555            let anchor_qname = id_map.get(sid).map(|s| s.qname.clone()).unwrap_or_default();
556            // Check callers outside scope.
557            for caller_id in index_store
558                .get_callers(&engine.ref_name, sid)
559                .unwrap_or_default()
560            {
561                if let Some(sym) = id_map.get(&caller_id) {
562                    let in_scope = filters
563                        .paths_filter
564                        .iter()
565                        .any(|p| glob_match(p, &sym.file));
566                    if !in_scope && seen_files.insert(sym.file.clone()) {
567                        let dir = std::path::Path::new(&sym.file)
568                            .parent()
569                            .map(|d| format!("{}/**", d.to_string_lossy()))
570                            .unwrap_or_else(|| sym.file.clone());
571                        omitted.push(json!({
572                            "file": sym.file,
573                            "relation": "caller",
574                            "of_symbol": anchor_qname,
575                            "suggested_paths": dir,
576                        }));
577                    }
578                }
579            }
580            // Check callees outside scope.
581            for callee_id in index_store
582                .get_callees(&engine.ref_name, sid)
583                .unwrap_or_default()
584            {
585                if let Some(sym) = id_map.get(&callee_id) {
586                    let in_scope = filters
587                        .paths_filter
588                        .iter()
589                        .any(|p| glob_match(p, &sym.file));
590                    if !in_scope && seen_files.insert(sym.file.clone()) {
591                        let dir = std::path::Path::new(&sym.file)
592                            .parent()
593                            .map(|d| format!("{}/**", d.to_string_lossy()))
594                            .unwrap_or_else(|| sym.file.clone());
595                        omitted.push(json!({
596                            "file": sym.file,
597                            "relation": "callee",
598                            "of_symbol": anchor_qname,
599                            "suggested_paths": dir,
600                        }));
601                    }
602                }
603            }
604        }
605        omitted
606    } else {
607        vec![]
608    };
609    // Merge graph-based and FTS-based omissions, deduplicating by file.
610    let mut seen_omit: std::collections::HashSet<String> = std::collections::HashSet::new();
611    let mut likely_omitted_files = likely_omitted_files;
612    for item in fts_omitted_files {
613        if let Some(f) = item["file"].as_str() {
614            if seen_omit.insert(f.to_string()) {
615                likely_omitted_files.push(item);
616            }
617        }
618    }
619    for item in &likely_omitted_files {
620        if let Some(f) = item["file"].as_str() {
621            seen_omit.insert(f.to_string());
622        }
623    }
624
625    // T1: safe-change recipe — actionable sections for an agent or developer.
626    // T4: manually_validate includes concrete ValidationScenario entries.
627    let recipe_inspect: Vec<Value> = file_scores
628        .iter()
629        .map(|(score, file, layer, days, hot, top_symbol, why)| {
630            json!({
631                "file": file, "layer": layer, "score": score,
632                "last_touched_days": days, "hot": hot,
633                "top_symbol": top_symbol, "why": why,
634            })
635        })
636        .collect();
637    // 1.0.86: recipe_preserve emitted `constraint` (== invariant
638    // summary) + source + kind. The constraint text was identical
639    // to design_invariants[].summary. Now emits refs for
640    // invariants; hazards stay inline because they're sourced from
641    // known_hazards (a separate canonical list — no dedupe target
642    // there yet).
643    let recipe_preserve: Vec<Value> = design_invariants
644        .iter()
645        .filter_map(|inv| {
646            inv.get("entry_id")
647                .and_then(Value::as_str)
648                .map(|eid| json!({ "ref": eid, "kind": "invariant" }))
649        })
650        .chain(known_hazards.iter().map(|h| {
651            json!({
652                "constraint": h["summary"],
653                "source": h["source"],
654                "kind": "hazard",
655            })
656        }))
657        .collect();
658    // t-005: Find files where a matching symbol has a WrongLayer verdict for
659    // the current query family. Those impl files are demoted to recipe_reference.
660    // Uses all_fb hoisted above — no second list_all() call needed.
661    use std::collections::HashSet as _HSet;
662    let wrong_layer_files: _HSet<String> = {
663        let desc_norm = args.description.to_lowercase();
664        let desc_tokens: std::collections::HashSet<String> = desc_norm
665            .split(|c: char| !c.is_alphabetic())
666            .filter(|t: &&str| t.len() > 2)
667            .map(|t| t.to_string())
668            .collect();
669        let mut wl_files = _HSet::new();
670        for entry in &all_fb {
671            if !matches!(
672                entry.verdict,
673                agentstatedeveloper_core::FeedbackVerdict::WrongLayer
674            ) {
675                continue;
676            }
677            // Query-family match: share at least one token.
678            let fb_tokens: std::collections::HashSet<String> = entry
679                .query
680                .split(|c: char| !c.is_alphabetic())
681                .filter(|t: &&str| t.len() > 2)
682                .map(|t: &str| t.to_string())
683                .collect();
684            let overlaps = desc_tokens.iter().any(|t| fb_tokens.contains(t));
685            if !overlaps {
686                continue;
687            }
688            // Look up the symbol's file via qname.
689            if let Ok(Some(sym)) =
690                index_store.get_symbol_by_qname(&engine.ref_name, &entry.symbol_qname)
691            {
692                wl_files.insert(sym.file);
693            }
694        }
695        wl_files
696    };
697    // t-005: Build a map of file → test files that cover it (from affected_tests).
698    let mut file_to_tests: HashMap<String, Vec<String>> = HashMap::new();
699    for test in &affected_tests {
700        if let Some(test_file) = test["file"].as_str() {
701            for (_, file, _, _, _, _, _) in &file_scores {
702                let entry = file_to_tests.entry(file.clone()).or_default();
703                let tf = test_file.to_string();
704                if !entry.contains(&tf) {
705                    entry.push(tf);
706                }
707            }
708        }
709    }
710
711    // edit: only impl files not flagged as wrong-layer or view-only-on-broad-query.
712    // Rendering surfaces (Canvas, Overlay, Layer etc.) are demoted unconditionally
713    // unless the query explicitly names them. Other view-like files are demoted only
714    // on broad queries. A file is retained when ≥2 of its stem words appear in the
715    // query tokens (generalised domain anchor — no hardcoded token list needed).
716    let recipe_edit: Vec<Value> = likely_edit_files
717        .iter()
718        .filter(|f| {
719            let file = f["file"].as_str().unwrap_or("");
720            let layer = f["layer"].as_str().unwrap_or("");
721            let names_file = query_names_file(&tokens, file);
722            // Domain anchor: retain when query shares ≥2 stem words with the file.
723            let stem_words = split_camel_lower(
724                std::path::Path::new(file)
725                    .file_stem()
726                    .and_then(|n| n.to_str())
727                    .unwrap_or(""),
728            );
729            let domain_overlap = stem_words
730                .iter()
731                .filter(|w| tokens.iter().any(|t| t == *w))
732                .count();
733            let has_domain_anchor = domain_overlap >= 2;
734            // Rendering surfaces: unconditional demotion unless query names them.
735            let surface_demote = is_rendering_surface(file) && !names_file;
736            // View-like: demote on broad queries unless domain anchor present.
737            let broad_demote =
738                broad_query && is_view_like_file(file, layer) && !names_file && !has_domain_anchor;
739            // Anchor-missing: view-like file with zero domain overlap is always
740            // reference-only regardless of broad_query. A drift/playhead query should
741            // never put PianoRollView or SheetMusicView (notation views, zero overlap)
742            // into edit — they are unrelated surfaces even when the query is specific.
743            let anchor_missing_demote =
744                is_view_like_file(file, layer) && !names_file && domain_overlap == 0;
745            let demote = !wrong_layer_files.contains(file)
746                && (surface_demote || broad_demote || anchor_missing_demote);
747            f["file_role"].as_str() == Some("impl") && !wrong_layer_files.contains(file) && !demote
748        })
749        .map(|f| {
750            // t-005: attach covering tests and run command to each edit entry.
751            let file = f["file"].as_str().unwrap_or("");
752            let mut indexed = test_files_for_source(&all_test_file_paths, file);
753            if let Some(extra) = file_to_tests.get(file) {
754                for t in extra {
755                    if !indexed.contains(t) {
756                        indexed.push(t.clone());
757                    }
758                }
759            }
760            let run_cmd = detect_test_command(file);
761            let mut entry = f.clone();
762            if let Some(obj) = entry.as_object_mut() {
763                // Use explicit none-found metadata instead of empty arrays/null
764                // so agents can distinguish "no tests found" from "not checked".
765                let covered = if indexed.is_empty() {
766                    json!([{"note": "none found — no test callers discovered for this file"}])
767                } else {
768                    json!(indexed)
769                };
770                let run = if run_cmd.is_none() {
771                    json!("none found — add test targets for this file")
772                } else {
773                    json!(run_cmd)
774                };
775                obj.insert("covered_by_tests".into(), covered);
776                obj.insert("run_command".into(), run);
777            }
778            entry
779        })
780        .collect();
781    // reference: example/doc files + WrongLayer demotions + view/surface demotions.
782    let recipe_reference: Vec<Value> = likely_edit_files
783        .iter()
784        .filter(|f| {
785            let file = f["file"].as_str().unwrap_or("");
786            let layer = f["layer"].as_str().unwrap_or("");
787            let names_file = query_names_file(&tokens, file);
788            let stem_words = split_camel_lower(
789                std::path::Path::new(file)
790                    .file_stem()
791                    .and_then(|n| n.to_str())
792                    .unwrap_or(""),
793            );
794            let domain_overlap = stem_words
795                .iter()
796                .filter(|w| tokens.iter().any(|t| t == *w))
797                .count();
798            let has_domain_anchor = domain_overlap >= 2;
799            let surface_demote = is_rendering_surface(file) && !names_file;
800            let broad_demote =
801                broad_query && is_view_like_file(file, layer) && !names_file && !has_domain_anchor;
802            let anchor_missing_demote =
803                is_view_like_file(file, layer) && !names_file && domain_overlap == 0;
804            let view_demote = surface_demote || broad_demote || anchor_missing_demote;
805            matches!(f["file_role"].as_str(), Some("example") | Some("reference"))
806                || (f["file_role"].as_str() == Some("impl") && wrong_layer_files.contains(file))
807                || view_demote
808        })
809        .cloned()
810        .collect();
811    // Rebuild likely_edit_files to only include files that made it into recipe_edit.
812    // Files demoted to reference_only must not appear in likely_edit_files — the raw
813    // file_scores list is built before the edit/reference split, so without this step
814    // surface-demoted files (WaveformCanvas etc.) would linger in the raw list.
815    //
816    // Keep the full pre-split list for classification_debug + rationale computation
817    // (we need rationale for reference_only files too).
818    let all_candidate_files: Vec<Value> = likely_edit_files.clone();
819    let edit_file_set: HashSet<&str> = recipe_edit
820        .iter()
821        .filter_map(|e| e["file"].as_str())
822        .collect();
823    let likely_edit_files: Vec<Value> = likely_edit_files
824        .into_iter()
825        .filter(|e| {
826            e["file"]
827                .as_str()
828                .map_or(false, |f| edit_file_set.contains(f))
829        })
830        .collect();
831
832    // 1.0.88: cliff cut at the FINAL rebuilt list. The earlier
833    // 1.0.87 cliff (on file_scores pre-recipe-split) missed cases
834    // where intermediate scores from soon-to-be-demoted files
835    // smoothed the gradient. After recipe_edit demotes
836    // reference-only files, the remaining edit-list often shows the
837    // cliff cleanly. AcmeFlow case: file_scores might have
838    // 42/31/29/27/25/19/18 (no cliff at file-scores time), but
839    // post-demotion only the impl-layer files remain — 42/31/19/18
840    // with the 19/31=0.61 cliff visible.
841    let mut likely_edit_files = likely_edit_files;
842    let scores: Vec<f64> = likely_edit_files
843        .iter()
844        .map(|e| e["score"].as_f64().unwrap_or(0.0))
845        .collect();
846    let mut sorted_desc = scores.clone();
847    sorted_desc.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
848    let cliff_cut2 = agentstatedeveloper_core::cliff_cutoff_index(sorted_desc.iter().copied());
849    if cliff_cut2 < likely_edit_files.len() {
850        let cutoff = sorted_desc[cliff_cut2 - 1];
851        likely_edit_files.retain(|e| e["score"].as_f64().unwrap_or(0.0) >= cutoff);
852    }
853
854    // t-002: include exact build/test commands for each affected test file.
855    let mut recipe_run: Vec<Value> = affected_tests
856        .iter()
857        .map(|t| {
858            let file = t["file"].as_str().unwrap_or("");
859            let cmd = detect_test_command(file);
860            json!({
861                "qname": t["qname"],
862                "file": file,
863                "covers_invariants": t["covers_invariants"],
864                "run_command": cmd,
865            })
866        })
867        .collect();
868    // If no test files discovered, still emit a command for the top impl file.
869    if recipe_run.is_empty() {
870        if let Some((_, top_file, _, _, _, _, _)) = file_scores.first() {
871            if let Some(cmd) = detect_test_command(top_file) {
872                recipe_run.push(json!({
873                    "qname": Value::Null,
874                    "file": top_file,
875                    "covers_invariants": [],
876                    "run_command": cmd,
877                }));
878            }
879        }
880    }
881    // t-005: manually_validate with concrete step/expected pairs.
882    let mut recipe_manually_validate: Vec<Value> = validation_scenarios_ledger
883        .iter()
884        .map(|vs| {
885            let text = vs["scenario"].as_str().unwrap_or("");
886            let scenario = invariant_to_scenario(text);
887            json!({
888                "source": vs["source"],
889                "kind": "validation_scenario",
890                "step": scenario["step"],
891                "expected": scenario["expected"],
892                "raw": text,
893            })
894        })
895        .collect();
896    for inv in &design_invariants {
897        let text = inv["summary"].as_str().unwrap_or("");
898        let sl = text.to_lowercase();
899        if CONSTRAINT_WORDS.iter().any(|w| sl.contains(w)) {
900            let scenario = invariant_to_scenario(text);
901            recipe_manually_validate.push(json!({
902                "source": inv["source"],
903                "kind": "constraint_check",
904                "step": scenario["step"],
905                "expected": scenario["expected"],
906                "raw": text,
907            }));
908        }
909    }
910    for eff in &effects_summary {
911        let cat = eff["category"].as_str().unwrap_or("").to_lowercase();
912        recipe_manually_validate.push(json!({
913            "source": eff["source"],
914            "kind": "effect_check",
915            "step": format!("Execute the code path that triggers {}", cat),
916            "expected": format!("{} side-effect behaves correctly after change", cat),
917            "raw": format!("verify {} effect", cat),
918        }));
919    }
920    // Plan J t-002: surface test-gap warning directly inside the
921    // safe_change_recipe so an agent reading only the recipe gets
922    // the signal — without having to cross-reference the top-level
923    // `test_gap` field. Suggests the proposed_test_path target when
924    // we have one.
925    if test_gap {
926        let top_impl_source = file_scores
927            .first()
928            .map(|(_, f, _, _, _, _, _)| f.as_str())
929            .unwrap_or("");
930        let suggestion = proposed_test_path
931            .as_deref()
932            .unwrap_or("(no proposed path; see proposed_test_path)");
933        recipe_manually_validate.push(json!({
934            "source": top_impl_source,
935            "kind": "missing_test",
936            "step": format!(
937                "No test currently exercises this change set. Add a test \
938                 covering the planned edit; suggested target: {suggestion}"
939            ),
940            "expected": "A new (or extended) test that exercises the changed code path and fails before the edit, passes after.",
941            "raw": "test_gap: affected_tests is empty",
942        }));
943    }
944    // Always compute classification_debug — used for classification_summary rollup
945    // even when --debug-classification is not set.  The full array is only emitted
946    // in the JSON when --debug-classification is explicitly requested.
947    // Uses all_candidate_files (edit + reference) so the rationale map covers every file.
948    let classification_debug: Vec<Value> = all_candidate_files
949        .iter()
950        .map(|f| {
951            let file = f["file"].as_str().unwrap_or("");
952            let layer = f["layer"].as_str().unwrap_or("");
953            let file_role = f["file_role"].as_str().unwrap_or("unknown");
954            let names_file = query_names_file(&tokens, file);
955            let stem_words = split_camel_lower(
956                std::path::Path::new(file)
957                    .file_stem()
958                    .and_then(|n| n.to_str())
959                    .unwrap_or(""),
960            );
961            let matched_stem_words: Vec<&str> = stem_words
962                .iter()
963                .filter(|w| tokens.iter().any(|t| t == *w))
964                .map(|w| w.as_str())
965                .collect();
966            let domain_overlap = matched_stem_words.len();
967            let has_domain_anchor = domain_overlap >= 2;
968            let surface_demoted = is_rendering_surface(file) && !names_file;
969            let broad_demoted =
970                broad_query && is_view_like_file(file, layer) && !names_file && !has_domain_anchor;
971            let anchor_missing_demoted =
972                is_view_like_file(file, layer) && !names_file && domain_overlap == 0;
973            let is_wrong_layer = wrong_layer_files.contains(file);
974            let rule_that_won = if file_role == "test" {
975                "test"
976            } else if is_wrong_layer {
977                "wrong-layer → reference"
978            } else if surface_demoted {
979                "surface → reference"
980            } else if broad_demoted {
981                "broad-query view → reference"
982            } else if anchor_missing_demoted {
983                "anchor-missing view → reference"
984            } else if file_role == "impl" {
985                "edit"
986            } else {
987                file_role
988            };
989            let rationale = make_file_rationale(
990                rule_that_won,
991                surface_demoted,
992                has_domain_anchor,
993                &matched_stem_words,
994                names_file,
995                broad_query,
996            );
997            json!({
998                "file": file,
999                "file_role": file_role,
1000                "surface_demoted": surface_demoted,
1001                "domain_anchor_retained": has_domain_anchor,
1002                "matched_stem_words": matched_stem_words,
1003                "domain_overlap": domain_overlap,
1004                "names_file": names_file,
1005                "is_wrong_layer": is_wrong_layer,
1006                "broad_query": broad_query,
1007                "rule_that_won": rule_that_won,
1008                "rationale": rationale,
1009            })
1010        })
1011        .collect();
1012
1013    // Build file → rationale map from classification_debug.
1014    let file_rationale: HashMap<String, String> = classification_debug
1015        .iter()
1016        .filter_map(|e| {
1017            let file = e["file"].as_str()?.to_string();
1018            let rat = e["rationale"].as_str()?.to_string();
1019            Some((file, rat))
1020        })
1021        .collect();
1022
1023    // Classification summary: rule_that_won counts aggregated across all files.
1024    // Always emitted — lets dashboards answer "is ASD classifying on real domain
1025    // anchors or weaker heuristics?" without needing --debug-classification.
1026    let mut rule_counts: std::collections::BTreeMap<String, usize> =
1027        std::collections::BTreeMap::new();
1028    for entry in &classification_debug {
1029        let rule = entry
1030            .get("rule_that_won")
1031            .and_then(Value::as_str)
1032            .unwrap_or("unknown");
1033        *rule_counts.entry(rule.to_string()).or_default() += 1;
1034    }
1035
1036    // Edit confidence: lower when workspace has no annotation data to boost signals.
1037    let trust_dq = compute_trust_score(&cfg.db_path);
1038    let (edit_confidence, edit_confidence_note) = match trust_dq.data_quality.state.as_str() {
1039        "clean_room" => (
1040            "reduced",
1041            "fresh workspace — classification relies on structural signals only; run `asd annotate-commit` after each commit to improve accuracy",
1042        ),
1043        "unannotated" => (
1044            "reduced",
1045            "no annotation data — classification uses structural signals only; ledger annotations boost accuracy",
1046        ),
1047        "degraded" => (
1048            "low",
1049            "possible state loss — ledger signals may be incomplete; verify with `asd trust`",
1050        ),
1051        _ => ("normal", ""),
1052    };
1053
1054    let mut classification_summary = serde_json::Map::new();
1055    for (k, v) in &rule_counts {
1056        classification_summary.insert(k.clone(), json!(*v));
1057    }
1058    classification_summary.insert("edit_confidence".into(), json!(edit_confidence));
1059    if !edit_confidence_note.is_empty() {
1060        classification_summary.insert("edit_confidence_note".into(), json!(edit_confidence_note));
1061    }
1062    let classification_summary = Value::Object(classification_summary);
1063
1064    // Enrich likely_edit_files with per-file rationale.
1065    let likely_edit_files: Vec<Value> = likely_edit_files
1066        .into_iter()
1067        .map(|mut f| {
1068            if let Some(obj) = f.as_object_mut() {
1069                let file = obj
1070                    .get("file")
1071                    .and_then(Value::as_str)
1072                    .unwrap_or("")
1073                    .to_string();
1074                if let Some(rat) = file_rationale.get(&file) {
1075                    obj.insert("rationale".into(), json!(rat));
1076                }
1077            }
1078            f
1079        })
1080        .collect();
1081
1082    // Enrich recipe_edit and recipe_reference with per-file rationale.
1083    let recipe_edit: Vec<Value> = recipe_edit
1084        .into_iter()
1085        .map(|mut f| {
1086            if let Some(obj) = f.as_object_mut() {
1087                let file = obj
1088                    .get("file")
1089                    .and_then(Value::as_str)
1090                    .unwrap_or("")
1091                    .to_string();
1092                if let Some(rat) = file_rationale.get(&file) {
1093                    obj.entry("rationale".to_string())
1094                        .or_insert_with(|| json!(rat));
1095                }
1096            }
1097            f
1098        })
1099        .collect();
1100    let recipe_reference: Vec<Value> = recipe_reference
1101        .into_iter()
1102        .map(|mut f| {
1103            if let Some(obj) = f.as_object_mut() {
1104                let file = obj
1105                    .get("file")
1106                    .and_then(Value::as_str)
1107                    .unwrap_or("")
1108                    .to_string();
1109                if let Some(rat) = file_rationale.get(&file) {
1110                    obj.entry("rationale".to_string())
1111                        .or_insert_with(|| json!(rat));
1112                }
1113            }
1114            f
1115        })
1116        .collect();
1117
1118    // AcmeFlow refinement #1 (1.0.84): recursively drop empty
1119    // sub-fields (preserve:[], reference_only:[],
1120    // likely_omitted_files:[], nested empty layer_distribution maps,
1121    // etc). This is a NESTED clean — top-level drop_empty in the
1122    // outer json! block can't reach into safe_change_recipe's
1123    // children. On a typical query without ledger annotations,
1124    // strips ~200 chars of empty-array clutter from the recipe.
1125    let safe_change_recipe = agentstatedeveloper_core::drop_empty_recursive(json!({
1126        "inspect": recipe_inspect,
1127        "preserve": recipe_preserve,
1128        "edit": recipe_edit,
1129        "reference_only": recipe_reference,
1130        "run": recipe_run,
1131        "manually_validate": recipe_manually_validate,
1132        "blast_radius": blast_radius,
1133        "likely_omitted_files": likely_omitted_files,
1134    }));
1135
1136    // Scoped suggestions for prepare-change: use edit files as top_qnames proxy.
1137    let edit_file_names: Vec<String> = likely_edit_files
1138        .iter()
1139        .filter_map(|v| v.get("file").and_then(Value::as_str).map(|s| s.to_string()))
1140        .collect();
1141    let scoped_suggestions_pc: Vec<String> = if !ambiguous_terms.is_empty() {
1142        agentstatedeveloper_core::suggest_scoped_queries(
1143            &tokens,
1144            &ambiguous_terms,
1145            &edit_file_names,
1146        )
1147    } else {
1148        vec![]
1149    };
1150    // Re-use the db_state already computed for edit_confidence (trust score above).
1151    let uncertainty = compute_uncertainty(
1152        &tokens,
1153        &ambiguous_terms,
1154        &possible_misses,
1155        file_scores.len(),
1156        &scoped_suggestions_pc,
1157        engine.fts.as_ref(),
1158        Some(trust_dq.data_quality.state.as_str()),
1159    );
1160
1161    let focus = intent_focus(intent);
1162    let ctx_context_val = match (auto_ctx_plan.as_deref(), auto_ctx_task.as_deref()) {
1163        (None, None) => Value::Null,
1164        _ => json!({
1165            "plan": auto_ctx_plan,
1166            "task": auto_ctx_task,
1167            "injected": ctx_text.is_some(),
1168        }),
1169    };
1170    // AcmeFlow refinement (1.0.76): surface captured thinking on
1171    // the symbols that matter for this query. Mirrors the MCP handler
1172    // (mcp_server.rs:4189-4201). Pull top_symbol off each
1173    // likely_edit_files entry; gather_prior_thinking walks the ledger
1174    // and projects to the compact `prior_thinking` shape, plus a
1175    // metadata summary that always emits even when entries is Null.
1176    let thinking_qnames: Vec<String> = likely_edit_files
1177        .iter()
1178        .filter_map(|f| {
1179            f.get("top_symbol")
1180                .and_then(Value::as_str)
1181                .map(String::from)
1182        })
1183        .collect();
1184    let thinking_floor = args
1185        .thinking_floor
1186        .unwrap_or(agentstatedeveloper_core::thinking::DEFAULT_CONFIDENCE_FLOOR);
1187    let pt = agentstatedeveloper_core::thinking::gather_prior_thinking(
1188        &engine,
1189        &thinking_qnames,
1190        thinking_floor,
1191    );
1192    let prior_thinking = pt.entries;
1193    let thinking_summary = serde_json::to_value(&pt.summary).unwrap_or(Value::Null);
1194
1195    // AcmeFlow refinement: compute once (cheap) for both fields.
1196    let stale_classified = agentstatedeveloper_core::stale_warning_classified(
1197        &cfg.db_path,
1198        agentstatedeveloper_core::SOFT_STALE_THRESHOLD_SECS,
1199    );
1200    let stale_msg = stale_classified
1201        .as_ref()
1202        .map(|w| Value::String(w.message.clone()))
1203        .unwrap_or(Value::Null);
1204    let stale_severity = stale_classified
1205        .as_ref()
1206        .map(|w| serde_json::to_value(w.severity).unwrap_or(Value::Null))
1207        .unwrap_or(Value::Null);
1208
1209    // Token economy (1.0.78): build feedback_summary as a Map so
1210    // all-zero counts skip serialization. On a call with no
1211    // feedback activity (the common case), the entire block
1212    // collapses to `{}` and the json! macro below would still emit
1213    // it as `{}` — so we conditionally insert only when non-empty.
1214    let feedback_summary = {
1215        let mut m = serde_json::Map::new();
1216        if feedback_metrics.entries_applied > 0 {
1217            m.insert(
1218                "entries_applied".into(),
1219                json!(feedback_metrics.entries_applied),
1220            );
1221        }
1222        if feedback_metrics.suppressed > 0 {
1223            m.insert("suppressed".into(), json!(feedback_metrics.suppressed));
1224        }
1225        if feedback_metrics.preserved_useful_siblings > 0 {
1226            m.insert(
1227                "preserved_useful_siblings".into(),
1228                json!(feedback_metrics.preserved_useful_siblings),
1229            );
1230        }
1231        if feedback_metrics.boosted > 0 {
1232            m.insert("boosted".into(), json!(feedback_metrics.boosted));
1233        }
1234        if feedback_metrics.recurring_fp_suppressed > 0 {
1235            m.insert(
1236                "recurring_fp_suppressed".into(),
1237                json!(feedback_metrics.recurring_fp_suppressed),
1238            );
1239        }
1240        if !feedback_metrics.rules_applied.is_empty() {
1241            m.insert(
1242                "rules_applied".into(),
1243                json!(feedback_metrics.rules_applied),
1244            );
1245        }
1246        Value::Object(m)
1247    };
1248
1249    // t-004: stage-dump on low-confidence / empty results. The agent normally
1250    // sees only `likely_edit_files` (the final, stage-3 list). When confidence
1251    // is low or that list is empty, surface what the earlier filtering stages
1252    // ranked and cut — so a missing-but-expected file is diagnosable instead of
1253    // silently filtered. Reuses already-computed collections; null (and dropped
1254    // by drop_empty_top_level) when not triggered. See CLAUDE.md "Multi-stage
1255    // filtering: cut at the stage the agent sees".
1256    let round3 = |x: f64| (x * 1000.0).round() / 1000.0;
1257    let stage_dump = {
1258        let low_conf = matches!(uncertainty.level.as_str(), "high" | "critical");
1259        if low_conf || likely_edit_files.is_empty() {
1260            let stage1: Vec<Value> = candidates
1261                .iter()
1262                .map(|(score, qname)| json!({ "score": round3(*score), "qname": qname }))
1263                .collect();
1264            let stage2: Vec<Value> = file_scores
1265                .iter()
1266                .map(|(score, file, layer, _days, _hot, sym, why)| {
1267                    json!({
1268                        "file": file,
1269                        "score": round3(*score),
1270                        "layer": layer,
1271                        "top_symbol": sym,
1272                        "why": why,
1273                    })
1274                })
1275                .collect();
1276            let demoted = safe_change_recipe
1277                .get("reference_only")
1278                .cloned()
1279                .unwrap_or_else(|| json!([]));
1280            json!({
1281                "triggered_by": if likely_edit_files.is_empty() {
1282                    "empty_edit_files"
1283                } else {
1284                    "low_confidence"
1285                },
1286                "uncertainty_level": uncertainty.level,
1287                "note": "Agents normally see only likely_edit_files (the final stage-3 list). \
1288                         This result is low-confidence or empty, so here is what the earlier \
1289                         filtering stages ranked and cut. A file missing from likely_edit_files \
1290                         but present below may have been demoted, not absent.",
1291                "stage1_symbol_candidates": { "count": candidates.len(), "ranked": stage1 },
1292                "stage2_surviving_files": stage2,
1293                "stage3_demoted_to_reference_only": demoted,
1294            })
1295        } else {
1296            Value::Null
1297        }
1298    };
1299
1300    let out = json!({
1301        "description": args.description,
1302        "task_context": args.task_context,
1303        "ctx_context": ctx_context_val,
1304        "stale": stale_msg,
1305        "stale_severity": stale_severity,
1306        "intent": if intent.is_empty() { Value::Null } else { json!(intent) },
1307        "focus": if focus.is_empty() { Value::Null } else { json!(focus) },
1308        "uncertainty": uncertainty.to_json(),
1309        "ambiguous_terms": ambiguous_terms,
1310        "possible_misses": possible_misses,
1311        "scope_narrowed": scope_narrowed,
1312        "feedback_summary": feedback_summary,
1313        "safe_change_recipe": safe_change_recipe,
1314        "design_invariants": design_invariants,
1315        "known_hazards": known_hazards,
1316        "prior_thinking": prior_thinking,
1317        "thinking_summary": thinking_summary,
1318        "validation_scenarios": validation_scenarios_ledger,
1319        "entry_points": { "by_layer": ordered_by_layer },
1320        "likely_edit_files": likely_edit_files,
1321        "stage_dump": stage_dump,
1322        "affected_tests": affected_tests,
1323        "test_gap": test_gap,
1324        "proposed_test_path": proposed_test_path,
1325        "proposed_test_stub": proposed_test_stub,
1326        "suggested_test_coverage": suggested_test_coverage,
1327        "scenario_tests": scenario_tests,
1328        "stale_symbols": stale_symbols,
1329        "effects_summary": effects_summary,
1330        "recently_touched": recently_touched,
1331        "classification_summary": classification_summary,
1332        "classification_debug": if args.debug_classification { json!(classification_debug) } else { Value::Null },
1333        "edit_precision_metrics": if let Some(ref sha) = args.check_commit {
1334            compute_edit_precision(&likely_edit_files, sha)
1335        } else {
1336            Value::Null
1337        },
1338    });
1339    // Token economy:
1340    //   1.0.78: agent mode emits compact JSON (no pretty-print
1341    //           whitespace). Token estimate matches the compact form.
1342    //   1.0.79: drop top-level empty/null fields + input echoes +
1343    //           redundant stale string in agent mode.
1344    //   1.0.81: AcmeFlow field-eval (2026-06-04) caught that
1345    //           drop_empty_top_level was --agent-only. Agents
1346    //           consuming the default JSON path were still getting
1347    //           feedback_summary:{}, intent:null, etc. Fix: apply
1348    //           drop_empty_top_level UNCONDITIONALLY — it strips
1349    //           null/[]/{} which is signal-free for both humans
1350    //           and agents. Input-echo fields are still
1351    //           agent-mode-only since human terminal users like
1352    //           seeing the description they typed.
1353    let out = agentstatedeveloper_core::drop_empty_top_level(out);
1354
1355    let (out, compact_for_agent) = if args.agent {
1356        let max_list = (args.agent_budget / 500).max(3).min(20);
1357        let mut trimmed = trim_for_agent(&out, max_list);
1358
1359        // 1.0.79: dedupe stale string vs stale_severity — keep only
1360        // the structured severity (carries enough for the agent to
1361        // render its own message; the string was just humanization).
1362        if let Some(obj) = trimmed.as_object_mut() {
1363            obj.remove("stale");
1364            // Also drop input echoes — the agent already has them.
1365            obj.remove("description");
1366            obj.remove("task_context");
1367            obj.remove("ctx_context");
1368        }
1369
1370        // Re-strip after agent-mode-specific removals (an empty
1371        // map that survived the unconditional pass above might
1372        // have been re-emptied here).
1373        let trimmed = agentstatedeveloper_core::drop_empty_top_level(trimmed);
1374
1375        let compact = serde_json::to_string(&trimmed)?;
1376        let token_est = estimate_tokens(&compact);
1377        let mut v = trimmed;
1378        if let Some(obj) = v.as_object_mut() {
1379            obj.insert("token_estimate".into(), json!(token_est));
1380        }
1381        (v, true)
1382    } else {
1383        (out, false)
1384    };
1385    let json_str = if compact_for_agent {
1386        serde_json::to_string(&out)?
1387    } else {
1388        serde_json::to_string_pretty(&out)?
1389    };
1390    println!("{}", json_str);
1391    Ok(())
1392}
1393
1394// ---------------------------------------------------------------------------
1395// t-001: expanded file role classification
1396// ---------------------------------------------------------------------------
1397
1398// Plan J t-003: classify_file_role lifted to
1399// `agentstatedeveloper_core::classify_file_role` so CLI and MCP
1400// prepare_change paths share one canonical impl. The new shared
1401// version also recognizes `view` and `viewmodel` roles.
1402
1403// ---------------------------------------------------------------------------
1404// t-002: detect the test command for a given source file
1405// ---------------------------------------------------------------------------
1406
1407fn detect_test_command(file: &str) -> Option<String> {
1408    use std::path::Path;
1409    let p = Path::new(file);
1410    // Walk up to find a recognisable project root marker.
1411    let mut dir = p.parent()?;
1412    loop {
1413        if dir.join("Cargo.toml").exists() {
1414            // Try to extract the package name for a precise `cargo test -p` command.
1415            let pkg_name = std::fs::read_to_string(dir.join("Cargo.toml"))
1416                .ok()
1417                .and_then(|s| {
1418                    s.lines()
1419                        .skip_while(|l| !l.trim_start().starts_with("[package]"))
1420                        .find(|l| l.trim_start().starts_with("name"))
1421                        .and_then(|l| l.splitn(2, '=').nth(1))
1422                        .map(|v| v.trim().trim_matches('"').to_string())
1423                });
1424            return Some(match pkg_name {
1425                Some(name) => format!("cargo test -p {}", name),
1426                None => "cargo test".to_string(),
1427            });
1428        }
1429        if dir.join("package.json").exists() {
1430            let has_yarn = dir.join("yarn.lock").exists();
1431            let has_pnpm = dir.join("pnpm-lock.yaml").exists();
1432            return Some(if has_pnpm {
1433                "pnpm test".to_string()
1434            } else if has_yarn {
1435                "yarn test".to_string()
1436            } else {
1437                "npm test".to_string()
1438            });
1439        }
1440        if dir.join("pyproject.toml").exists()
1441            || dir.join("setup.py").exists()
1442            || dir.join("pytest.ini").exists()
1443            || dir.join("setup.cfg").exists()
1444        {
1445            let rel = p.strip_prefix(dir).unwrap_or(p).to_string_lossy();
1446            return Some(format!("pytest {}", rel));
1447        }
1448        if dir.join("Gemfile").exists() {
1449            let rel = p.strip_prefix(dir).unwrap_or(p).to_string_lossy();
1450            return Some(format!("bundle exec rspec {}", rel));
1451        }
1452        if dir.join("build.gradle").exists() || dir.join("build.gradle.kts").exists() {
1453            return Some("./gradlew test".to_string());
1454        }
1455        if dir.join("pom.xml").exists() {
1456            return Some("mvn test".to_string());
1457        }
1458        if dir.join("go.mod").exists() {
1459            let pkg_dir = p
1460                .parent()
1461                .map(|d| d.to_string_lossy().to_string())
1462                .unwrap_or_else(|| ".".to_string());
1463            return Some(format!("go test {}/...", pkg_dir));
1464        }
1465        if dir.join("Makefile").exists() || dir.join("makefile").exists() {
1466            return Some("make test".to_string());
1467        }
1468        match dir.parent() {
1469            Some(parent) => dir = parent,
1470            None => break,
1471        }
1472    }
1473    None
1474}
1475
1476// Plan M t-004 (1.0.98): explain_conflict_risk lifted to
1477// agentstatedeveloper_core::prepare_change so MCP can use it too.
1478
1479// ---------------------------------------------------------------------------
1480// t-001: detect view/UI files for broad-query demotion
1481// ---------------------------------------------------------------------------
1482
1483/// Generic tokens that appear in many UI files but don't anchor a specific domain concept.
1484/// Files whose stem is composed entirely of these tokens + "state"/"view"/"model" are
1485/// demoted to reference_only on broad queries unless the query names them directly.
1486const GENERIC_FILE_STEMS: &[&str] = &[
1487    "playhead",
1488    "state",
1489    "update",
1490    "position",
1491    "value",
1492    "cursor",
1493    "progress",
1494    "indicator",
1495    "status",
1496    "mode",
1497    "flag",
1498    "current",
1499    "local",
1500];
1501
1502/// Returns true for files that are pure rendering surfaces regardless of query.
1503/// These are demoted unconditionally unless the query explicitly names them.
1504fn is_rendering_surface(file: &str) -> bool {
1505    let name = std::path::Path::new(file)
1506        .file_stem()
1507        .and_then(|n| n.to_str())
1508        .unwrap_or(file);
1509    name.ends_with("Canvas")
1510        || name.ends_with("Overlay")
1511        || name.ends_with("Surface")
1512        || name.ends_with("Roll")
1513        || name.ends_with("Sheet")
1514        || name.ends_with("Layer")
1515        || name.ends_with("Renderer")
1516        || name.ends_with("Drawable")
1517}
1518
1519fn is_view_like_file(file: &str, layer: &str) -> bool {
1520    if layer == "view" {
1521        return true;
1522    }
1523    let name = std::path::Path::new(file)
1524        .file_stem()
1525        .and_then(|n| n.to_str())
1526        .unwrap_or(file);
1527    // Common view/UI naming conventions in iOS/macOS/web/Android.
1528    if name.ends_with("View")
1529        || name.ends_with("ViewController")
1530        || name.ends_with("Screen")
1531        || name.ends_with("Widget")
1532        || name.ends_with("Panel")
1533        || name.ends_with("Cell")
1534        || name.ends_with("Button")
1535        || name.ends_with("Label")
1536        || name.ends_with("Row")
1537        || name.ends_with("Canvas")
1538        || name.ends_with("Overlay")
1539        || name.ends_with("Surface")
1540        || name.ends_with("Roll")
1541        || name.ends_with("Sheet")
1542        || name.ends_with("Layer")
1543        || name.ends_with("Renderer")
1544        || name.ends_with("Drawable")
1545        || name.contains("ViewController")
1546        || name.contains("Renderer")
1547        || name.ends_with("Page")
1548        || name.ends_with("Fragment")
1549    {
1550        return true;
1551    }
1552    // Demote generic-stem files (PlayheadState.swift, UpdateView.swift, etc.)
1553    // that are in a UI-adjacent layer.
1554    if matches!(layer, "view" | "viewmodel" | "ui") {
1555        let name_lower = name.to_lowercase();
1556        let stem_words = split_camel_lower(&name_lower);
1557        if !stem_words.is_empty()
1558            && stem_words
1559                .iter()
1560                .all(|w| GENERIC_FILE_STEMS.iter().any(|g| *g == w.as_str()))
1561        {
1562            return true;
1563        }
1564    }
1565    false
1566}
1567
1568/// Returns true when the query tokens explicitly name the file's stem, preventing
1569/// demotion when the user is specifically asking about that file.
1570fn query_names_file(query_tokens: &[String], file: &str) -> bool {
1571    let name = std::path::Path::new(file)
1572        .file_stem()
1573        .and_then(|n| n.to_str())
1574        .unwrap_or("")
1575        .to_lowercase();
1576    if name.is_empty() {
1577        return false;
1578    }
1579    let stem_words = split_camel_lower(&name);
1580    let matches = stem_words
1581        .iter()
1582        .filter(|w| query_tokens.iter().any(|t| t == *w))
1583        .count();
1584    matches >= 2.min(stem_words.len())
1585}
1586
1587/// Split a camelCase/PascalCase identifier into lowercase words.
1588fn split_camel_lower(s: &str) -> Vec<String> {
1589    let mut words: Vec<String> = Vec::new();
1590    let mut current = String::new();
1591    for ch in s.chars() {
1592        if ch.is_uppercase() && !current.is_empty() {
1593            words.push(current.to_lowercase());
1594            current = ch.to_string();
1595        } else if ch == '_' || ch == '-' || ch == '.' {
1596            if !current.is_empty() {
1597                words.push(current.to_lowercase());
1598                current = String::new();
1599            }
1600        } else {
1601            current.push(ch);
1602        }
1603    }
1604    if !current.is_empty() {
1605        words.push(current.to_lowercase());
1606    }
1607    words
1608}
1609
1610// ---------------------------------------------------------------------------
1611// t-004: convert invariant / constraint text into a step + expected pair
1612// ---------------------------------------------------------------------------
1613
1614/// Extract the key subject noun phrase from an invariant sentence.
1615/// Returns the first 3–6 words after stripping leading modal/constraint words.
1616fn extract_subject(text: &str) -> String {
1617    const SKIP: &[&str] = &[
1618        "the",
1619        "a",
1620        "an",
1621        "this",
1622        "that",
1623        "it",
1624        "must",
1625        "never",
1626        "cannot",
1627        "shall",
1628        "always",
1629        "only",
1630        "not",
1631        "no",
1632        "should",
1633        "will",
1634        "would",
1635        "is",
1636        "are",
1637        "be",
1638        "been",
1639        "require",
1640        "ensure",
1641        "prevent",
1642        "guarantee",
1643        "invariant",
1644    ];
1645    text.split_whitespace()
1646        .filter(|w| {
1647            let wl = w.to_lowercase();
1648            let base: String = wl.chars().filter(|c| c.is_alphabetic()).collect();
1649            !SKIP.contains(&base.as_str())
1650        })
1651        .take(5)
1652        .collect::<Vec<_>>()
1653        .join(" ")
1654}
1655
1656fn invariant_to_scenario(text: &str) -> Value {
1657    let tl = text.to_lowercase();
1658    let subject = extract_subject(text);
1659    let subj = if subject.is_empty() {
1660        text.to_string()
1661    } else {
1662        subject
1663    };
1664
1665    let (step, expected) = if tl.contains("must not")
1666        || tl.contains("never")
1667        || tl.contains("cannot")
1668        || tl.contains("forbidden")
1669    {
1670        (
1671            format!("Trigger conditions that would violate: {}", subj),
1672            format!(
1673                "System rejects or prevents the violation — «{}» holds",
1674                text
1675            ),
1676        )
1677    } else if tl.contains("must") || tl.contains("shall") || tl.contains("always") {
1678        (
1679            format!("Exercise the happy path for: {}", subj),
1680            format!("Observe that «{}» is satisfied after execution", text),
1681        )
1682    } else if tl.contains("ensure") || tl.contains("guarantee") {
1683        (
1684            format!("Run the scenario that exercises: {}", subj),
1685            format!("Post-condition confirmed: «{}»", text),
1686        )
1687    } else if tl.contains("require") {
1688        (
1689            format!(
1690                "Attempt to call without satisfying the precondition for: {}",
1691                subj
1692            ),
1693            format!("Guard fires or error raised before violating «{}»", text),
1694        )
1695    } else if tl.contains("only") || tl.contains("prevent") {
1696        (
1697            format!("Attempt a bypass around: {}", subj),
1698            format!("Prevention still enforced — «{}»", text),
1699        )
1700    } else {
1701        (
1702            format!("Verify the observable behaviour of: {}", subj),
1703            format!("Constraint «{}» holds after the change", text),
1704        )
1705    };
1706    json!({ "step": step, "expected": expected })
1707}
1708
1709// ---------------------------------------------------------------------------
1710// Change Model: per-file classification rationale
1711// ---------------------------------------------------------------------------
1712
1713/// Convert classification signals into a plain-English rationale string.
1714///
1715/// Used to enrich `likely_edit_files` and `safe_change_recipe.reference_only`
1716/// so agents understand *why* each file was classified as edit or reference.
1717fn make_file_rationale(
1718    rule: &str,
1719    surface_demoted: bool,
1720    domain_anchor: bool,
1721    matched_stems: &[&str],
1722    names_file: bool,
1723    broad_query: bool,
1724) -> String {
1725    match rule {
1726        "edit" if names_file => "edit: query names this file directly".to_string(),
1727        "edit" if domain_anchor => {
1728            format!(
1729                "edit: domain-anchored — {} query token(s) match file name ({})",
1730                matched_stems.len(),
1731                matched_stems.join(", ")
1732            )
1733        }
1734        "edit" if !matched_stems.is_empty() => {
1735            format!(
1736                "edit: impl file matching query token '{}'",
1737                matched_stems[0]
1738            )
1739        }
1740        "edit" => "edit: impl file in query scope".to_string(),
1741
1742        r if r.contains("surface") => {
1743            "reference: rendering surface — read-only layer (WaveformCanvas, SheetMusicView, etc.)"
1744                .to_string()
1745        }
1746        r if r.contains("broad-query view") => {
1747            if domain_anchor {
1748                format!(
1749                    "reference: view layer but domain-anchored ({})",
1750                    matched_stems.join(", ")
1751                )
1752            } else {
1753                format!(
1754                    "reference: view layer on broad query — needs ≥2 matching tokens to enter edit (matched: {})",
1755                    matched_stems.len()
1756                )
1757            }
1758        }
1759        r if r.contains("anchor-missing") => {
1760            "reference: view/surface layer with no domain overlap — unrelated rendering file"
1761                .to_string()
1762        }
1763        r if r.contains("wrong-layer") => {
1764            "reference: wrong layer for this change type — structural mismatch".to_string()
1765        }
1766
1767        "example" | "reference" => "reference: documentation or example file".to_string(),
1768        "test" => "test: run to validate this change".to_string(),
1769
1770        _ => {
1771            // Fallback: surface what we know from remaining signals
1772            if broad_query {
1773                format!("reference: broad query heuristic — {}", rule)
1774            } else {
1775                format!(
1776                    "classified as: {} (surface_demoted={}, domain_anchor={})",
1777                    rule, surface_demoted, domain_anchor
1778                )
1779            }
1780        }
1781    }
1782}
1783
1784// ---------------------------------------------------------------------------
1785// Change Model: edit precision metrics
1786// ---------------------------------------------------------------------------
1787
1788/// Compare `likely_edit_files` (JSON objects with a "file" key) against the
1789/// actual files changed in `sha` (via git diff-tree). Returns a JSON object with
1790/// precision, recall, F1, and TP/FP/FN file lists, or null if git fails.
1791fn compute_edit_precision(likely_edit_files: &[Value], sha: &str) -> Value {
1792    let predicted_files: Vec<String> = likely_edit_files
1793        .iter()
1794        .filter_map(|v| v.get("file").and_then(Value::as_str).map(|s| s.to_string()))
1795        .collect();
1796    let predicted_files = &predicted_files;
1797    let output = std::process::Command::new("git")
1798        .args(["diff-tree", "--no-commit-id", "-r", "--name-only", sha])
1799        .output();
1800    let output = match output {
1801        Ok(o) if o.status.success() => o,
1802        Ok(o) => {
1803            let err = String::from_utf8_lossy(&o.stderr);
1804            return json!({ "error": format!("git diff-tree failed: {}", err.trim()) });
1805        }
1806        Err(e) => return json!({ "error": format!("git not available: {}", e) }),
1807    };
1808    let actual_files: Vec<String> = String::from_utf8_lossy(&output.stdout)
1809        .lines()
1810        .map(|l| l.trim().to_string())
1811        .filter(|l| !l.is_empty())
1812        .collect();
1813
1814    if actual_files.is_empty() {
1815        return json!({
1816            "sha": sha,
1817            "error": "no files changed in commit (or commit not found)",
1818        });
1819    }
1820
1821    // Use suffix matching so that repo-relative paths match index-relative paths.
1822    // predicted: "Sources/AcmeProj/Foo.swift"  actual: "AcmeProj/Sources/AcmeProj/Foo.swift"
1823    let is_match = |pred: &str, actual: &str| -> bool {
1824        actual.ends_with(pred) || pred.ends_with(actual) || actual == pred
1825    };
1826
1827    let mut tp: Vec<String> = Vec::new();
1828    let mut fp: Vec<String> = Vec::new();
1829    let mut fn_: Vec<String> = Vec::new();
1830
1831    for pred in predicted_files {
1832        if actual_files.iter().any(|a| is_match(pred, a)) {
1833            tp.push(pred.clone());
1834        } else {
1835            fp.push(pred.clone());
1836        }
1837    }
1838    for actual in &actual_files {
1839        if !predicted_files.iter().any(|p| is_match(p, actual)) {
1840            fn_.push(actual.clone());
1841        }
1842    }
1843
1844    let precision = if tp.len() + fp.len() > 0 {
1845        tp.len() as f64 / (tp.len() + fp.len()) as f64
1846    } else {
1847        0.0
1848    };
1849    let recall = if tp.len() + fn_.len() > 0 {
1850        tp.len() as f64 / (tp.len() + fn_.len()) as f64
1851    } else {
1852        0.0
1853    };
1854    let f1 = if precision + recall > 0.0 {
1855        2.0 * precision * recall / (precision + recall)
1856    } else {
1857        0.0
1858    };
1859
1860    json!({
1861        "sha": sha,
1862        "predicted_count": predicted_files.len(),
1863        "actual_count": actual_files.len(),
1864        "true_positives": tp,
1865        "false_positives": fp,
1866        "false_negatives": fn_,
1867        "precision": (precision * 1000.0).round() / 1000.0,
1868        "recall": (recall * 1000.0).round() / 1000.0,
1869        "f1": (f1 * 1000.0).round() / 1000.0,
1870    })
1871}
1872
1873// ---------------------------------------------------------------------------
1874// Plan M t-003 (1.0.93): stage helpers extracted from run() body.
1875//
1876// Each fn here was inlined inside run() prior to 1.0.93. Extraction
1877// keeps the orchestrator readable without changing behavior. State
1878// flows through explicit args — no shared context struct (yet); each
1879// helper takes only the inputs it needs and returns only the outputs
1880// the orchestrator consumes.
1881// ---------------------------------------------------------------------------
1882
1883/// BFS upward from the top entry-point symbol to collect test
1884/// callers. For each tier-2 (test) symbol discovered, compute which
1885/// design invariants the test's name + doc tokens cover.
1886///
1887/// Returns a Vec of `{qname, file, line, covers_invariants}` entries.
1888fn gather_affected_tests(
1889    engine: &Engine,
1890    index_store: &AsgIndexStore,
1891    id_map: &HashMap<String, Symbol>,
1892    top_sym_id: Option<&str>,
1893    test_depth: usize,
1894    design_invariants: &[Value],
1895) -> Vec<Value> {
1896    let mut out: Vec<Value> = Vec::new();
1897    let Some(start_id) = top_sym_id else {
1898        return out;
1899    };
1900    let mut visited: HashSet<String> = HashSet::new();
1901    let mut queue: VecDeque<(String, usize)> = VecDeque::new();
1902    let mut seen_test_names: HashSet<String> = HashSet::new();
1903    visited.insert(start_id.to_string());
1904    queue.push_back((start_id.to_string(), 0));
1905    while let Some((sid, depth)) = queue.pop_front() {
1906        if depth >= test_depth {
1907            continue;
1908        }
1909        let callers = index_store
1910            .get_callers(&engine.ref_name, &sid)
1911            .unwrap_or_default();
1912        for cid in callers {
1913            if visited.contains(&cid) {
1914                continue;
1915            }
1916            visited.insert(cid.clone());
1917            if let Some(s) = id_map.get(&cid) {
1918                if symbol_tier(&s.file) == 2 && seen_test_names.insert(s.qname.clone()) {
1919                    // Use both qname words and doc comment words for
1920                    // behavioral matching so a test named
1921                    // "test_plays_silence_at_loop_end" AND a test
1922                    // doc'd "verifies loop boundary" both surface
1923                    // the relevant invariant.
1924                    let qname_words: Vec<String> = s
1925                        .qname
1926                        .split(|c: char| !c.is_alphabetic())
1927                        .filter(|t| t.len() > 2)
1928                        .map(|t| t.to_lowercase())
1929                        .collect();
1930                    let doc_words: Vec<String> = s
1931                        .doc
1932                        .as_deref()
1933                        .unwrap_or("")
1934                        .split(|c: char| !c.is_alphabetic())
1935                        .filter(|t| t.len() > 2)
1936                        .map(|t| t.to_lowercase())
1937                        .collect();
1938                    let test_tokens: Vec<&str> = qname_words
1939                        .iter()
1940                        .chain(doc_words.iter())
1941                        .map(|s| s.as_str())
1942                        .collect();
1943                    let covers: Vec<&str> = design_invariants
1944                        .iter()
1945                        .filter_map(|inv| inv.get("summary").and_then(Value::as_str))
1946                        .filter(|summary| {
1947                            let sl = summary.to_lowercase();
1948                            test_tokens.iter().any(|t| sl.contains(*t))
1949                        })
1950                        .collect();
1951                    out.push(json!({
1952                        "qname": s.qname,
1953                        "file": s.file,
1954                        "line": s.start.line,
1955                        "covers_invariants": covers,
1956                    }));
1957                }
1958                if depth + 1 < test_depth {
1959                    queue.push_back((cid, depth + 1));
1960                }
1961            }
1962        }
1963    }
1964    out
1965}
1966
1967/// BFS upward from the top 5 candidate symbols to compute blast
1968/// radius: caller/callee layer distribution + concrete top-5 caller
1969/// chains. Path length capped at 4 to avoid runaway BFS on
1970/// highly-connected modules.
1971///
1972/// Returns a single JSON object — embedded as `safe_change_recipe.
1973/// blast_radius` in the prepare-change response.
1974fn compute_blast_radius(
1975    engine: &Engine,
1976    index_store: &AsgIndexStore,
1977    id_map: &HashMap<String, Symbol>,
1978    candidates: &[(f64, String)],
1979    layer_overrides: &[(String, String)],
1980) -> Value {
1981    let mut caller_layers: HashMap<String, usize> = HashMap::new();
1982    let mut callee_layers: HashMap<String, usize> = HashMap::new();
1983    let mut total_callers = 0usize;
1984    let mut total_callees = 0usize;
1985    let top_sids: Vec<String> = candidates
1986        .iter()
1987        .take(5)
1988        .filter_map(|(_, q)| {
1989            index_store
1990                .get_symbol_by_qname(&engine.ref_name, q)
1991                .ok()
1992                .flatten()
1993        })
1994        .map(|s| s.symbol_id)
1995        .collect();
1996
1997    // BFS tracking paths so we can emit concrete caller chains.
1998    // Each path is root-first: [outer_caller, ..., direct_caller, our_symbol].
1999    let mut top_caller_chains: Vec<Vec<String>> = Vec::new();
2000
2001    for sid in &top_sids {
2002        let anchor_qname = id_map.get(sid).map(|s| s.qname.clone()).unwrap_or_default();
2003        let mut visited: HashSet<String> = HashSet::new();
2004        let mut q: VecDeque<(String, Vec<String>)> = VecDeque::new();
2005        visited.insert(sid.clone());
2006        q.push_back((sid.clone(), vec![anchor_qname.clone()]));
2007        while let Some((cid, path)) = q.pop_front() {
2008            if path.len() > 4 {
2009                continue;
2010            }
2011            for caller_id in index_store
2012                .get_callers(&engine.ref_name, &cid)
2013                .unwrap_or_default()
2014            {
2015                if visited.insert(caller_id.clone()) {
2016                    if let Some(sym) = id_map.get(&caller_id) {
2017                        let tier = symbol_tier(&sym.file);
2018                        let layer =
2019                            classify_layer_sym(&sym.file, &sym.qname, tier, layer_overrides);
2020                        *caller_layers.entry(layer.to_string()).or_default() += 1;
2021                        total_callers += 1;
2022                        let mut new_path = vec![sym.qname.clone()];
2023                        new_path.extend_from_slice(&path);
2024                        if top_caller_chains.len() < 5 {
2025                            top_caller_chains.push(new_path.clone());
2026                        }
2027                        q.push_back((caller_id, new_path));
2028                    }
2029                }
2030            }
2031        }
2032        for callee_id in index_store
2033            .get_callees(&engine.ref_name, sid)
2034            .unwrap_or_default()
2035        {
2036            if let Some(sym) = id_map.get(&callee_id) {
2037                let tier = symbol_tier(&sym.file);
2038                let layer = classify_layer_sym(&sym.file, &sym.qname, tier, layer_overrides);
2039                *callee_layers.entry(layer.to_string()).or_default() += 1;
2040                total_callees += 1;
2041            }
2042        }
2043    }
2044    json!({
2045        "total_callers": total_callers,
2046        "total_callees": total_callees,
2047        "caller_layer_distribution": caller_layers,
2048        "callee_layer_distribution": callee_layers,
2049        "top_caller_chains": top_caller_chains,
2050    })
2051}
2052
2053/// Plan M t-003 (1.0.94): test-gap detection extracted from run().
2054/// Given current file_scores + affected_tests, returns:
2055///   - test_gap: true when no covering tests were found
2056///   - proposed_test_path: real indexed test file if any, else suggested path
2057///   - proposed_test_stub: language-aware test-framework body shape
2058fn detect_test_gap(
2059    file_scores: &[(f64, String, String, Option<f64>, bool, String, String)],
2060    affected_tests: &[Value],
2061    all_test_file_paths: &[String],
2062) -> (bool, Option<String>, Option<String>) {
2063    let test_gap = affected_tests.is_empty();
2064    if !test_gap {
2065        return (false, None, None);
2066    }
2067    let source = file_scores
2068        .first()
2069        .map(|(_, f, _, _, _, _, _)| f.as_str())
2070        .unwrap_or("");
2071    if source.is_empty() {
2072        return (true, None, None);
2073    }
2074    let real = test_files_for_source(all_test_file_paths, source);
2075    let proposed_test_path = if real.is_empty() {
2076        Some(format!(
2077            "no known test target (suggested: {})",
2078            propose_test_path(source)
2079        ))
2080    } else {
2081        Some(real.join(", "))
2082    };
2083    // file_scores tuple shape: (score, file, layer, days, hot, qname, why)
2084    let symbol = file_scores
2085        .first()
2086        .map(|(_, _, _, _, _, qname, _)| qname.as_str())
2087        .unwrap_or("change");
2088    let proposed_test_stub = Some(propose_test_stub(source, symbol));
2089    (true, proposed_test_path, proposed_test_stub)
2090}