Skip to main content

lean_ctx_server/server/
context_gate.rs

1// SPDX-License-Identifier: Apache-2.0
2use lean_ctx::core::context_field::{ContextItemId, ContextState};
3use lean_ctx::core::context_ledger::{ContextLedger, PressureAction};
4use lean_ctx::core::context_overlay::{OverlayOp, OverlayStore};
5
6/// #1570 P4: protected path arguments bypass every lossy output filter.
7pub(super) fn protected_path_requested(
8    args: Option<&serde_json::Map<String, serde_json::Value>>,
9) -> bool {
10    let Some(args) = args else {
11        return false;
12    };
13    let config = lean_ctx::core::config::Config::load();
14    if config.protection.file_patterns.is_empty() {
15        return false;
16    }
17    ["path", "file_path", "filePath"].iter().any(|key| {
18        args.get(*key)
19            .and_then(serde_json::Value::as_str)
20            .is_some_and(|path| config.protection.path_is_protected(path))
21    })
22}
23
24/// #843: precise, pinned reads (`diff`, `lines:N-M`, `anchored`/`anchored:N-M`)
25/// must pass through every mode-override path untouched — bounce-prevention
26/// and the graph/knowledge heuristics below must never
27/// silently reinterpret one of these to e.g. `full`, which would discard the
28/// exact window/anchors/delta the caller asked for. Classifies off the typed
29/// `ReadMode` (single source of truth, see `tools::ctx_read::mode`) rather than
30/// a hand-maintained string-prefix list, so a future precise mode has to be
31/// added to `ReadMode::is_precise_pinned_read` explicitly instead of silently
32/// falling through an allowlist. An unparseable mode is conservatively treated
33/// as *not* pinned, matching prior behaviour for anything outside this set.
34fn is_precise_pinned_mode(requested_mode: &str) -> bool {
35    requested_mode
36        .parse::<crate::tools::ctx_read::ReadMode>()
37        .is_ok_and(|m| m.is_precise_pinned_read())
38}
39
40#[derive(Debug, Clone)]
41pub struct PreDispatchResult {
42    pub overridden_mode: Option<String>,
43    pub reason: Option<&'static str>,
44    pub budget_blocked: bool,
45    pub budget_warning: Option<String>,
46    /// Triage-based output filter aggressiveness: 0=passthrough, 1=moderate, 2=aggressive.
47    pub triage_filter_level: u8,
48}
49
50#[derive(Debug, Clone)]
51pub struct PostDispatchResult {
52    pub eviction_hint: Option<String>,
53    pub elicitation_hint: Option<String>,
54    pub resource_changed: bool,
55}
56
57/// Best-effort KnowledgeRouter advice for the completed Context Gate flow.
58pub fn knowledge_advice(
59    query: &str,
60) -> lean_ctx::core::knowledge_router::gate_integration::KnowledgeAdvice {
61    let current = lean_ctx::core::context_kernel::ContextState::new(
62        Vec::new(),
63        lean_ctx::core::knowledge::KnowledgeQuery::default(),
64    );
65    lean_ctx::core::knowledge_router::gate_integration::KnowledgeGateAdvisor::advise(
66        query, &current,
67    )
68}
69
70pub fn pre_dispatch_read(
71    path: &str,
72    requested_mode: &str,
73    task: Option<&str>,
74    project_root: Option<&str>,
75) -> PreDispatchResult {
76    pre_dispatch_read_for_agent(path, requested_mode, task, project_root, None, false)
77}
78
79enum AgentBudgetDecision {
80    Allowed,
81    Warning(String),
82    Exceeded(PreDispatchResult),
83}
84
85fn no_change_result() -> PreDispatchResult {
86    PreDispatchResult {
87        overridden_mode: None,
88        reason: None,
89        budget_blocked: false,
90        budget_warning: None,
91        triage_filter_level: 0,
92    }
93}
94
95fn check_agent_budget(
96    path: &str,
97    requested_mode: &str,
98    agent_id: Option<&str>,
99) -> AgentBudgetDecision {
100    let Some(aid) = agent_id else {
101        return AgentBudgetDecision::Allowed;
102    };
103
104    let estimated_tokens = estimate_read_tokens(path, requested_mode);
105    match lean_ctx::core::agent_budget::check_budget(aid, estimated_tokens) {
106        lean_ctx::core::agent_budget::BudgetCheckResult::Exceeded { limit, consumed } => {
107            let mut result = no_change_result();
108            result.reason = Some("agent-budget-exceeded");
109            result.budget_blocked = true;
110            result.budget_warning = Some(format!(
111                "Agent budget exceeded: {consumed}/{limit} tokens consumed. Reset via ctx_session or set a higher limit."
112            ));
113            AgentBudgetDecision::Exceeded(result)
114        }
115        lean_ctx::core::agent_budget::BudgetCheckResult::Warning {
116            remaining,
117            percent_used,
118        } => AgentBudgetDecision::Warning(format!(
119            "[BUDGET WARNING] Agent '{aid}' at {:.0}% budget ({remaining} tokens remaining)",
120            percent_used * 100.0
121        )),
122        lean_ctx::core::agent_budget::BudgetCheckResult::Allowed { .. } => {
123            AgentBudgetDecision::Allowed
124        }
125    }
126}
127
128/// Pre-dispatch gate for an explicit caller-pinned mode.
129///
130/// Only the hard per-agent budget gate applies; advisory mode rewriting is
131/// intentionally bypassed so the requested concrete mode remains intact.
132pub fn pre_dispatch_pinned_read_for_agent(
133    path: &str,
134    requested_mode: &str,
135    agent_id: Option<&str>,
136) -> PreDispatchResult {
137    let no_change = no_change_result();
138    match check_agent_budget(path, requested_mode, agent_id) {
139        AgentBudgetDecision::Exceeded(result) => result,
140        AgentBudgetDecision::Warning(warning) => PreDispatchResult {
141            budget_warning: Some(warning),
142            ..no_change
143        },
144        AgentBudgetDecision::Allowed => no_change,
145    }
146}
147
148/// `fresh` is the caller's explicit escape hatch (#1588). Bounce-prevention and
149/// intent-target exist to stop an agent from *drifting* into a compressed view
150/// it will immediately re-read in full; neither should be able to pin a file to
151/// `full` forever. A caller who passes `fresh=true` has said, in the request
152/// itself, that they want this view recomputed — honour it, or those modes are
153/// simply unreachable for the rest of the session.
154pub fn pre_dispatch_read_for_agent(
155    path: &str,
156    requested_mode: &str,
157    task: Option<&str>,
158    project_root: Option<&str>,
159    agent_id: Option<&str>,
160    fresh: bool,
161) -> PreDispatchResult {
162    let no_change = no_change_result();
163
164    match check_agent_budget(path, requested_mode, agent_id) {
165        AgentBudgetDecision::Exceeded(result) => return result,
166        AgentBudgetDecision::Warning(warning) => {
167            let mut result = no_change.clone();
168            result.budget_warning = Some(warning);
169            if is_precise_pinned_mode(requested_mode) {
170                return result;
171            }
172            let rest = pre_dispatch_inner(path, requested_mode, task, project_root, fresh);
173            return PreDispatchResult {
174                budget_warning: result.budget_warning,
175                ..rest
176            };
177        }
178        AgentBudgetDecision::Allowed => {}
179    }
180
181    pre_dispatch_inner(path, requested_mode, task, project_root, fresh)
182}
183
184/// Does `norm` (a normalized path) actually name the intent target `target`?
185///
186/// The old rule was `norm.ends_with(t) || norm.contains(t)`, which matched on
187/// any substring: a task mentioning `src` or `read` pinned every path
188/// containing those letters to `full` (#1588), including matches that land in
189/// the middle of a longer component (`print` inside `printer.rs`). A target now
190/// has to line up with a whole path component or the file stem, and be long
191/// enough to be a name rather than noise.
192fn path_matches_target(norm: &str, target: &str) -> bool {
193    const MIN_TARGET_LEN: usize = 4;
194    let target = target.trim().trim_matches('/');
195    if target.len() < MIN_TARGET_LEN {
196        return false;
197    }
198    // A target that is itself a path suffix (`src/server/context_gate.rs`).
199    if norm == target || norm.ends_with(&format!("/{target}")) {
200        return true;
201    }
202    // Otherwise: a whole path component, with or without its extension.
203    norm.split('/').any(|component| {
204        component == target
205            || component
206                .rsplit_once('.')
207                .is_some_and(|(stem, _)| stem == target)
208    })
209}
210
211/// Explicitly requested views that drop content: the lossy summaries plus
212/// `reference` and `density:`. Lossless, pinned and `auto` requests are not.
213fn is_explicit_compressing_mode(mode: &str) -> bool {
214    use crate::tools::ctx_read::ReadMode;
215    mode.parse::<ReadMode>().is_ok_and(|parsed| {
216        parsed.is_lossy_summary() || matches!(parsed, ReadMode::Reference | ReadMode::Density(_))
217    })
218}
219
220fn pre_dispatch_inner(
221    path: &str,
222    requested_mode: &str,
223    task: Option<&str>,
224    project_root: Option<&str>,
225    fresh: bool,
226) -> PreDispatchResult {
227    let no_change = PreDispatchResult {
228        overridden_mode: None,
229        reason: None,
230        budget_blocked: false,
231        budget_warning: None,
232        triage_filter_level: 0,
233    };
234
235    if is_precise_pinned_mode(requested_mode) {
236        return no_change;
237    }
238
239    if let Some(root) = project_root {
240        let overlay = OverlayStore::load_project(&std::path::PathBuf::from(root));
241        if let Some(result) = check_overlay_mode_override(path, requested_mode, &overlay) {
242            return result;
243        }
244    }
245
246    // Explicit mode=full must not be rewritten by heuristics.
247    // Only overlays (user-explicit) above can override it.
248    if requested_mode == "full" {
249        return no_change;
250    }
251
252    // `fresh` is the documented way out (#1588): the caller re-requested this
253    // view deliberately, so the "you keep bouncing back to full" heuristic has
254    // nothing left to prevent.
255    if !fresh
256        && let Ok(bt) = lean_ctx::core::bounce_tracker::global().lock()
257        && bt.should_force_full(path)
258    {
259        return PreDispatchResult {
260            overridden_mode: Some("full".to_string()),
261            reason: Some("bounce-prevention"),
262            budget_blocked: false,
263            budget_warning: None,
264            triage_filter_level: 0,
265        };
266    }
267
268    // #496 learned per-path memory: a file the agent keeps re-reading in full
269    // after compressed views is delivered in full for explicit compressing
270    // modes too (`auto` consults the same memory in its resolver). The caller
271    // sees the stable `[mode overridden: … reason=learned-full …]` notice.
272    if !fresh
273        && is_explicit_compressing_mode(requested_mode)
274        && lean_ctx::core::path_mode_memory::should_force_full(path)
275    {
276        return PreDispatchResult {
277            overridden_mode: Some("full".to_string()),
278            reason: Some("learned-full"),
279            budget_blocked: false,
280            budget_warning: None,
281            triage_filter_level: 0,
282        };
283    }
284
285    if let Some(task_str) = task
286        && !fresh
287    {
288        let intent = lean_ctx::core::intent_engine::StructuredIntent::from_query(task_str);
289        let norm = lean_ctx::core::pathutil::normalize_tool_path(path);
290        let is_target = intent.targets.iter().any(|t| path_matches_target(&norm, t));
291        if is_target {
292            return PreDispatchResult {
293                overridden_mode: Some("full".to_string()),
294                reason: Some("intent-target"),
295                budget_blocked: false,
296                budget_warning: None,
297                triage_filter_level: 0,
298            };
299        }
300    }
301
302    if let Some(root) = project_root
303        && let Some(open) = try_load_graph(root)
304    {
305        let gp = &open.provider;
306        let related = gp.related(path, 1);
307        if let Some(task_str) = task {
308            let intent = lean_ctx::core::intent_engine::StructuredIntent::from_query(task_str);
309            for target in &intent.targets {
310                let target_related = gp.related(target, 1);
311                let norm = lean_ctx::core::pathutil::normalize_tool_path(path);
312                if target_related
313                    .iter()
314                    .any(|r| r.contains(&norm) || norm.contains(r))
315                {
316                    return PreDispatchResult {
317                        overridden_mode: Some("map".to_string()),
318                        reason: Some("graph-direct-import"),
319                        budget_blocked: false,
320                        budget_warning: None,
321                        triage_filter_level: 0,
322                    };
323                }
324            }
325        }
326        if !related.is_empty() && requested_mode == "auto" {
327            let reverse_deps = gp.dependents(path);
328            if reverse_deps.len() > 3 {
329                return PreDispatchResult {
330                    overridden_mode: Some("map".to_string()),
331                    reason: Some("graph-hub-file"),
332                    budget_blocked: false,
333                    budget_warning: None,
334                    triage_filter_level: 0,
335                };
336            }
337        }
338    }
339
340    if let Some(root) = project_root
341        && let Some(knowledge) = lean_ctx::core::knowledge::ProjectKnowledge::load(root)
342    {
343        let norm = lean_ctx::core::pathutil::normalize_tool_path(path);
344        let mentions = knowledge
345            .facts
346            .iter()
347            .filter(|f| f.value.contains(&norm) || f.key.contains(&norm))
348            .count();
349        if mentions >= 3 {
350            return PreDispatchResult {
351                overridden_mode: Some("map".to_string()),
352                reason: Some("knowledge-high-relevance"),
353                budget_blocked: false,
354                budget_warning: None,
355                triage_filter_level: 0,
356            };
357        }
358    }
359
360    no_change
361}
362
363fn estimate_read_tokens(path: &str, mode: &str) -> usize {
364    let file_size = std::fs::metadata(path).map_or(4000, |m| m.len() as usize);
365    let char_estimate = file_size;
366    let full_tokens = char_estimate / 4;
367    match mode {
368        "signatures" => full_tokens / 5,
369        "map" => full_tokens / 3,
370        "aggressive" | "entropy" => full_tokens / 4,
371        "diff" => full_tokens / 10,
372        _ if mode.starts_with("lines:") => {
373            if let Some(range) = mode.strip_prefix("lines:") {
374                // #971: comma is multi-select. Splitting the whole payload on
375                // '-' yields 3 parts for `620-622,1214-1218` and fell through to
376                // the whole-file tenth; sum the selected spans instead.
377                let mut selected = 0usize;
378                for part in range.split(',') {
379                    let part = part.trim();
380                    selected += if let Some((s, e)) = part.split_once('-') {
381                        let start = s.trim().parse::<usize>().unwrap_or(1);
382                        let end = e.trim().parse::<usize>().unwrap_or(start + 100);
383                        end.saturating_sub(start) + 1
384                    } else {
385                        1
386                    };
387                }
388                if selected == 0 {
389                    full_tokens / 10
390                } else {
391                    selected * 10
392                }
393            } else {
394                full_tokens / 10
395            }
396        }
397        _ => full_tokens,
398    }
399}
400
401fn check_overlay_mode_override(
402    path: &str,
403    requested_mode: &str,
404    overlay: &OverlayStore,
405) -> Option<PreDispatchResult> {
406    let item_id = ContextItemId::from_file(path);
407    let overlays = overlay.for_item(&item_id);
408
409    for ov in overlays.iter().rev() {
410        match &ov.operation {
411            OverlayOp::SetView(view) => {
412                let mode_str = view.as_str();
413                if mode_str != requested_mode {
414                    return Some(PreDispatchResult {
415                        overridden_mode: Some(mode_str.to_string()),
416                        reason: Some("overlay-set-view"),
417                        budget_blocked: false,
418                        budget_warning: None,
419                        triage_filter_level: 0,
420                    });
421                }
422            }
423            OverlayOp::Pin { .. } if requested_mode != "full" => {
424                return Some(PreDispatchResult {
425                    overridden_mode: Some("full".to_string()),
426                    reason: Some("pinned"),
427                    budget_blocked: false,
428                    budget_warning: None,
429                    triage_filter_level: 0,
430                });
431            }
432            OverlayOp::Exclude { .. } if requested_mode != "signatures" => {
433                return Some(PreDispatchResult {
434                    overridden_mode: Some("signatures".to_string()),
435                    reason: Some("excluded"),
436                    budget_blocked: false,
437                    budget_warning: None,
438                    triage_filter_level: 0,
439                });
440            }
441            _ => {}
442        }
443    }
444    None
445}
446
447pub fn post_dispatch_record(
448    path: &str,
449    mode: &str,
450    original_tokens: usize,
451    sent_tokens: usize,
452    ledger: &mut ContextLedger,
453    overlay: &OverlayStore,
454) -> PostDispatchResult {
455    post_dispatch_record_with_task(
456        path,
457        mode,
458        original_tokens,
459        sent_tokens,
460        ledger,
461        overlay,
462        None,
463        None,
464    )
465}
466
467pub fn post_dispatch_record_with_task(
468    path: &str,
469    mode: &str,
470    original_tokens: usize,
471    sent_tokens: usize,
472    ledger: &mut ContextLedger,
473    overlay: &OverlayStore,
474    task: Option<&str>,
475    project_root: Option<&str>,
476) -> PostDispatchResult {
477    let prev_count = ledger.entries.len();
478    let prev_pressure = ledger.pressure().recommendation;
479
480    ledger.record_with_task(path, mode, original_tokens, sent_tokens, task);
481
482    let item_id = ContextItemId::from_file(path);
483    let state = overlay.apply_to_state(&item_id, ContextState::Included);
484
485    if state == ContextState::Excluded {
486        return PostDispatchResult {
487            eviction_hint: Some(format!("File '{path}' is excluded by overlay.")),
488            elicitation_hint: None,
489            resource_changed: true,
490        };
491    }
492
493    let elicitation =
494        super::elicitation::check_elicitation_needed(ledger, Some(path), Some(sent_tokens))
495            .map(|s| s.format_fallback_hint());
496
497    let pressure = ledger.pressure();
498
499    // #6 Global-Workspace ignition: salience outliers are broadcast (pinned) into
500    // the working set BEFORE reinjection, so an ignited item keeps its view while
501    // the rest are downgraded under pressure. Deterministic z-score threshold.
502    let ignited = ledger.ignite_high_salience();
503
504    apply_reinjection_plan(ledger, &pressure.recommendation);
505
506    let new_entry = ledger.entries.len() != prev_count;
507    let pressure_shifted = pressure.recommendation != prev_pressure;
508    let resource_changed = new_entry || pressure_shifted || !ignited.is_empty();
509
510    if pressure.utilization > 0.9 {
511        let candidates = ledger.eviction_candidates_by_phi(3);
512        if !candidates.is_empty() {
513            // #715: emit targets the evict resolver can actually find —
514            // root-relative paths (or the full path), never display-shortened
515            // forms that used to produce "Evicted 0/N".
516            let names: Vec<_> = candidates
517                .iter()
518                .take(3)
519                .map(|p| eviction_target_display(p, project_root))
520                .collect();
521            return PostDispatchResult {
522                eviction_hint: Some(format!(
523                    "Context pressure {:.0}%. Evict: ctx_control(action=\"evict\", targets=\"{}\")",
524                    pressure.utilization * 100.0,
525                    names.join(", ")
526                )),
527                elicitation_hint: elicitation,
528                resource_changed,
529            };
530        }
531    }
532
533    PostDispatchResult {
534        eviction_hint: None,
535        elicitation_hint: elicitation,
536        resource_changed,
537    }
538}
539
540/// #715: a resolvable evict target for hint output — project-root-relative
541/// when the candidate lives under the root, otherwise the full canonical
542/// path. Both forms round-trip through `ContextLedger::resolve_entry`.
543fn eviction_target_display(path: &str, project_root: Option<&str>) -> String {
544    if let Some(root) = project_root.filter(|r| !r.is_empty()) {
545        let root_prefix = format!("{}/", root.trim_end_matches(['/', '\\']).replace('\\', "/"));
546        if let Some(rel) = path.strip_prefix(&root_prefix)
547            && !rel.is_empty()
548        {
549            return rel.to_string();
550        }
551    }
552    path.to_string()
553}
554
555fn apply_reinjection_plan(ledger: &mut ContextLedger, action: &PressureAction) {
556    if *action != PressureAction::ForceCompression && *action != PressureAction::EvictLeastRelevant
557    {
558        return;
559    }
560    for entry in &mut ledger.entries {
561        // #6: ignited / user-pinned items stay broadcast — never downgraded.
562        if entry.state == Some(ContextState::Pinned) {
563            continue;
564        }
565        if entry.mode == "full" {
566            entry.mode = "map".to_string();
567        }
568    }
569}
570
571/// Graph for the per-read mode decision. Only an already populated property
572/// graph: this runs before every `ctx_read`, and the JSON-index fallback of
573/// `open_best_effort` re-parsed the whole index on each read of a cold
574/// session (focus analysis 2026-10-15).
575fn try_load_graph(project_root: &str) -> Option<lean_ctx::core::graph_provider::OpenGraphProvider> {
576    lean_ctx::core::graph_provider::open_ready(project_root)
577}
578
579/// Determines the output-filtering aggressiveness from a task profile.
580///
581/// Fail-safe by construction (community reports on 3.9.19, "triage always
582/// level 2"): the original ladder inverted fail-safety — the empty-query
583/// fallback profile landed on the MOST aggressive level, and per-tool-call
584/// profiles almost always report a low context need (a single tool call
585/// classifies as SingleFile, base 250). A rules-derived profile can now
586/// select at most level 1; uncertainty degrades toward passthrough, never
587/// toward harder filtering.
588pub fn triage_filter_level(profile: &lean_ctx::core::triage::profile::TaskProfileLocal) -> u8 {
589    use lean_ctx::core::triage::confidence::ACTIONABLE_FLOOR_MILLI;
590    // An unset context need means "unknown", never "needs no context".
591    if profile.confidence_milli < ACTIONABLE_FLOOR_MILLI || profile.context_need_milli == 0 {
592        return 0;
593    }
594    // Level 2 removes declarations and leaves output that parses but no longer
595    // means what it says (#1484). Nothing in a rules-derived profile is precise
596    // enough to justify that: `confidence_milli` measures how sure the intent
597    // classification is, not how much information loss the output tolerates.
598    // The level stays reachable through `apply_triage_filter` for callers that
599    // ask for it — where it is additionally markdown-only, so source code and
600    // logs never lose non-comment lines — and should return here again only
601    // once a model backend can supply that prediction.
602    u8::from(profile.context_need_milli < 600)
603}
604
605/// Filters output according to the task profile and selected triage level.
606pub fn apply_triage_filter(
607    output: &str,
608    profile: &lean_ctx::core::triage::profile::TaskProfileLocal,
609    level: u8,
610) -> (String, usize) {
611    if level == 0 || output.chars().count() < 500 {
612        return (output.to_string(), 0);
613    }
614
615    // Never filter structured JSON output — the line-based heuristics destroy
616    // key-value pairs while keeping only braces, producing invalid JSON.
617    let trimmed = output.trim_start();
618    if (trimmed.starts_with('{') || trimmed.starts_with('['))
619        && serde_json::from_str::<serde_json::Value>(trimmed).is_ok()
620    {
621        return (output.to_string(), 0);
622    }
623
624    let lines: Vec<&str> = output.lines().collect();
625
626    // Also bypass when JSON is embedded after a short header (e.g. "ctx_handoff show\n path: …\n{…}")
627    if let Some(json_start) = lines.iter().position(|l| {
628        let t = l.trim_start();
629        t.starts_with('{') || t.starts_with('[')
630    }) {
631        let json_portion = lines[json_start..].join("\n");
632        if serde_json::from_str::<serde_json::Value>(&json_portion).is_ok() {
633            return (output.to_string(), 0);
634        }
635    }
636    // #1570 P4: an explicit <protect> span is a user contract — the whole
637    // output bypasses lossy line filtering (gated on [protection].tags).
638    // Lossless compression (archive digest + ctx_expand) is unaffected:
639    // protection means "never lossy", not "never compressed".
640    if output.contains("<protect>") && lean_ctx::core::config::Config::load().protection.tags {
641        return (output.to_string(), 0);
642    }
643    // Build a set of kept lines for O(1) lookup.
644    let keep: std::collections::HashSet<usize> = match level {
645        1 => lines
646            .iter()
647            .enumerate()
648            .filter(|(_, line)| !is_boilerplate_line(line.trim_start()))
649            .map(|(i, _)| i)
650            .collect(),
651        2 => {
652            let keywords = extract_task_keywords(&profile.task_class, &profile.intent);
653            if lean_ctx::core::triage::markdown::looks_like_markdown(&lines) {
654                lean_ctx::core::triage::markdown::keep_indices(&lines, &keywords)
655            } else {
656                // Community report (3.9.19, "abridged into something that
657                // still looks right"): the structural+keyword keep-set is
658                // provably unsafe for source code — plain `let` bindings
659                // vanished from a Rust fn while the survivors still parsed
660                // (21 of 36 lines dropped, output looked complete, bindings
661                // became undeclared). Line-level lossy triage is therefore
662                // markdown-only; every other content shape gets at most the
663                // level-1 boilerplate strip.
664                lines
665                    .iter()
666                    .enumerate()
667                    .filter(|(_, line)| !is_boilerplate_line(line.trim_start()))
668                    .map(|(i, _)| i)
669                    .collect()
670            }
671        }
672        _ => return (output.to_string(), 0),
673    };
674
675    // #1484: assemble output with inline elision markers so omissions are visible.
676    let removed = lines.len().saturating_sub(keep.len());
677    if removed == 0 {
678        return (output.to_string(), 0);
679    }
680    // #1493: total deletion (100% of lines removed) is never a useful
681    // compression outcome — the marker plus retry costs more than passing
682    // through the original content. Return unfiltered when nothing survives.
683    if keep.is_empty() {
684        return (output.to_string(), 0);
685    }
686    // Heading-only markdown is the same failure mode: ATX titles survive the
687    // markdown keep-set so `keep` is non-empty, but every body paragraph is
688    // gone. Pass through instead of returning a TOC.
689    if lean_ctx::core::triage::markdown::is_heading_only_collapse(&lines, &keep) {
690        return (output.to_string(), 0);
691    }
692    let mut result = String::with_capacity(output.len());
693    let mut consecutive_omitted: usize = 0;
694    for (i, line) in lines.iter().enumerate() {
695        if keep.contains(&i) {
696            if consecutive_omitted > 0 {
697                result.push_str(&format!(
698                    "  [...{consecutive_omitted} lines omitted by triage...]\n"
699                ));
700                consecutive_omitted = 0;
701            }
702            result.push_str(line);
703            result.push('\n');
704        } else {
705            consecutive_omitted += 1;
706        }
707    }
708    if consecutive_omitted > 0 {
709        result.push_str(&format!(
710            "  [...{consecutive_omitted} lines omitted by triage...]\n"
711        ));
712    }
713    result.push_str(&format!(
714        "[lean-ctx: {removed} lines filtered by triage (level {level}) — rerun with raw=true for the unfiltered output]"
715    ));
716    (result, removed)
717}
718
719fn extract_task_keywords(task_class: &str, intent: &str) -> Vec<String> {
720    let mut keywords = task_class
721        .split(|character: char| !character.is_alphanumeric())
722        .chain(intent.split(|character: char| !character.is_alphanumeric()))
723        .filter(|keyword| !keyword.is_empty())
724        .map(str::to_lowercase)
725        .collect::<Vec<_>>();
726    keywords.sort_unstable();
727    keywords.dedup();
728    keywords
729}
730
731fn is_boilerplate_line(line: &str) -> bool {
732    line.starts_with("//")
733        && !line.contains("TODO")
734        && !line.contains("FIXME")
735        && !line.contains("SAFETY")
736}
737
738#[cfg(test)]
739#[path = "context_gate_tests.rs"]
740mod tests;