Skip to main content

lean_ctx/core/session/
state.rs

1use chrono::Utc;
2
3use crate::core::intent_protocol::{IntentRecord, IntentSource};
4
5use super::paths::{extract_cd_target, generate_session_id};
6#[allow(clippy::wildcard_imports)]
7use super::types::*;
8
9const MAX_FINDINGS: usize = 20;
10const MAX_DECISIONS: usize = 10;
11const MAX_FILES: usize = 50;
12const MAX_EVIDENCE: usize = 500;
13pub(crate) const BATCH_SAVE_INTERVAL: u32 = 5;
14/// #717: max time an unsaved change may linger before it is flushed even
15/// below the batch threshold — the dashboard reads session JSONs from disk,
16/// so a slow trickle of tool calls must still become visible promptly.
17pub(crate) const SESSION_FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_mins(1);
18
19/// `TaskInfo.intent` of a task `auto_infer_task` built from touched-file
20/// patterns. A label, never a stated task (#1590).
21pub(crate) const INFERRED_TASK_INTENT: &str = "inferred";
22/// `TaskInfo.intent` of a task `auto_infer_task` built from the working tree's
23/// uncommitted files. Describes state, never a stated task.
24pub(crate) const GIT_TASK_INTENT: &str = "git";
25
26/// Whether a task intent marks an `auto_infer_task` label (re-derivable, never
27/// a stated task). A plan title is a stated task and stays authoritative.
28pub fn is_auto_task_intent(intent: Option<&str>) -> bool {
29    matches!(intent, Some(INFERRED_TASK_INTENT | GIT_TASK_INTENT))
30}
31
32/// How many file names a git-derived task description lists before `+N more`.
33const GIT_TASK_MAX_FILES: usize = 3;
34
35/// Describe uncommitted files by name — `Uncommitted changes: entropy.rs,
36/// call_graph.rs (+2 more)` — instead of a `git diff --stat` summary whose only
37/// words were "files changed, insertions, deletions". `None` when clean.
38pub(super) fn describe_uncommitted_files<'a>(
39    paths: impl IntoIterator<Item = &'a str>,
40) -> Option<String> {
41    let names: Vec<&str> = paths
42        .into_iter()
43        .map(str::trim)
44        .filter(|p| !p.is_empty())
45        .map(|p| p.rsplit('/').next().unwrap_or(p))
46        .collect();
47    if names.is_empty() {
48        return None;
49    }
50    let listed = names[..names.len().min(GIT_TASK_MAX_FILES)].join(", ");
51    let more = names.len().saturating_sub(GIT_TASK_MAX_FILES);
52    Some(if more > 0 {
53        format!("Uncommitted changes: {listed} (+{more} more)")
54    } else {
55        format!("Uncommitted changes: {listed}")
56    })
57}
58
59/// Converts durable session signals into cross-session knowledge facts.
60pub fn extract_session_facts(session: &SessionState) -> Vec<crate::core::knowledge::KnowledgeFact> {
61    session
62        .derived_facts()
63        .into_iter()
64        .map(|(fact, _)| fact)
65        .collect()
66}
67
68impl SessionState {
69    /// [`extract_session_facts`], each fact with the finding it is built from
70    /// when it is built from exactly one: the `auto:blocker` facts. A store
71    /// that keeps such a fact carries the finding's source binding over to it
72    /// (`consolidation_engine::import_session_into`). A decision and a
73    /// touched-file record have no source binding to carry: `None`.
74    pub(crate) fn derived_facts(
75        &self,
76    ) -> Vec<(crate::core::knowledge::KnowledgeFact, Option<&Finding>)> {
77        derive_session_facts(self)
78    }
79}
80
81fn derive_session_facts(
82    session: &SessionState,
83) -> Vec<(crate::core::knowledge::KnowledgeFact, Option<&Finding>)> {
84    let mut facts = Vec::new();
85
86    facts.extend(session.decisions.iter().filter_map(|decision| {
87        let summary = decision.summary.trim();
88        if summary.is_empty() {
89            return None;
90        }
91        let value = decision
92            .rationale
93            .as_deref()
94            .filter(|rationale| !rationale.trim().is_empty())
95            .map_or_else(
96                || summary.to_owned(),
97                |rationale| format!("{summary} — {rationale}"),
98            );
99        Some((
100            auto_session_fact("auto:decision", value, session, decision.timestamp, 0.9),
101            None,
102        ))
103    }));
104
105    facts.extend(
106        session
107            .files_touched
108            .iter()
109            .filter(|file| file.modified)
110            .map(|file| {
111                let summary = file
112                    .summary
113                    .as_deref()
114                    .filter(|summary| !summary.trim().is_empty())
115                    .map_or_else(String::new, |summary| format!("; {summary}"));
116                let fact = auto_session_fact(
117                    "auto:pattern",
118                    format!(
119                        "Modified {} (mode: {}, reads: {}){summary}",
120                        file.path, file.last_mode, file.read_count
121                    ),
122                    session,
123                    session.updated_at,
124                    0.8,
125                );
126                (fact, None)
127            }),
128    );
129
130    facts.extend(session.findings.iter().filter_map(|finding| {
131        let summary = finding.summary.trim();
132        if summary.is_empty() || crate::core::auto_findings::is_activity_record(summary) {
133            return None;
134        }
135        let normalized = summary.to_ascii_lowercase();
136        let label = if ["resolved", "fixed", "solved", "unblocked", "passed"]
137            .iter()
138            .any(|keyword| normalized.contains(keyword))
139        {
140            "Resolution"
141        } else if [
142            "block", "fail", "error", "unable", "cannot", "missing", "timeout", "denied",
143        ]
144        .iter()
145        .any(|keyword| normalized.contains(keyword))
146        {
147            "Blocker"
148        } else {
149            "Finding"
150        };
151        let location = finding.file.as_deref().map_or_else(String::new, |file| {
152            finding
153                .line
154                .map_or_else(|| format!("{file}: "), |line| format!("{file}:{line}: "))
155        });
156        let fact = auto_session_fact(
157            "auto:blocker",
158            format!("{label}: {location}{summary}"),
159            session,
160            finding.timestamp,
161            0.75,
162        );
163        Some((fact, Some(finding)))
164    }));
165
166    facts
167}
168
169fn auto_session_fact(
170    category: &str,
171    value: String,
172    session: &SessionState,
173    timestamp: chrono::DateTime<Utc>,
174    confidence: f32,
175) -> crate::core::knowledge::KnowledgeFact {
176    let key = value.clone();
177    crate::core::knowledge::KnowledgeFact {
178        anchors: Vec::new(),
179        review: None,
180        capture: Default::default(),
181        origin: crate::core::knowledge::FactOrigin::Unverified,
182        category: category.to_owned(),
183        key,
184        value,
185        source_session: session.id.clone(),
186        confidence,
187        created_at: timestamp,
188        last_confirmed: timestamp,
189        retrieval_count: 0,
190        last_retrieved: None,
191        valid_from: None,
192        valid_until: None,
193        supersedes: None,
194        confirmation_count: 1,
195        feedback_up: 0,
196        feedback_down: 0,
197        last_feedback: None,
198        privacy: Default::default(),
199        sensitivity: Default::default(),
200        imported_from: None,
201        archetype: Default::default(),
202        fidelity: None,
203        revision_count: 0,
204        status: None,
205        validity: None,
206    }
207}
208
209impl Default for SessionState {
210    fn default() -> Self {
211        Self::new()
212    }
213}
214
215impl SessionState {
216    /// Creates a new session with a unique ID and current timestamp.
217    pub fn new() -> Self {
218        let now = Utc::now();
219        Self {
220            canonical_checkpoint: None,
221            save_gate: Default::default(),
222            last_save_failed: false,
223            id: generate_session_id(),
224            app_session_id: crate::core::activity::session_id_from_env(),
225            version: 0,
226            started_at: now,
227            updated_at: now,
228            project_root: None,
229            shell_cwd: None,
230            task: None,
231            findings: Vec::new(),
232            decisions: Vec::new(),
233            handoff_context: Vec::new(),
234            files_touched: Vec::new(),
235            test_results: None,
236            progress: Vec::new(),
237            next_steps: Vec::new(),
238            evidence: Vec::new(),
239            intents: Vec::new(),
240            active_structured_intent: None,
241            stats: SessionStats::default(),
242            terse_mode: false,
243            compression_level: String::new(),
244            last_consolidate_ts: None,
245            last_aaak_hash: None,
246            extra_roots: Vec::new(),
247            live_zone: super::types::LiveZoneSessionState::default(),
248            playbook: super::playbook::Playbook::default(),
249            last_semantic_query: None,
250            taint: Default::default(),
251            last_flush: None,
252        }
253        .with_compression_from_config()
254    }
255
256    pub fn refresh_handoff_context(&mut self) {
257        let Some(project_root) = self.project_root.clone() else {
258            self.handoff_context.clear();
259            return;
260        };
261
262        let session_id = self.id.clone();
263        self.handoff_context = crate::core::knowledge::ProjectKnowledge::load(&project_root)
264            .map(|knowledge| {
265                // The handoff context is printed with its fact values: only
266                // facts that may be delivered now are copied into it
267                // (`ProjectKnowledge::delivery_filter`).
268                let deliverable = knowledge.delivery_filter();
269                knowledge
270                    .recent_decisions(10)
271                    .into_iter()
272                    .filter(|fact| fact.source_session != session_id && deliverable(fact))
273                    .collect()
274            })
275            .unwrap_or_default();
276    }
277
278    fn with_compression_from_config(mut self) -> Self {
279        let cfg = crate::core::config::Config::load();
280        let level = crate::core::config::CompressionLevel::effective(&cfg);
281        self.compression_level = level.label().to_string();
282        self.terse_mode = level.is_active();
283        self
284    }
285
286    /// Bumps the version counter and marks the session as dirty.
287    pub fn increment(&mut self) {
288        self.version += 1;
289        self.updated_at = Utc::now();
290        self.stats.unsaved_changes += 1;
291    }
292
293    /// Returns `true` if enough changes have accumulated to warrant a disk
294    /// save — or, since #717, if any change has waited longer than
295    /// `SESSION_FLUSH_INTERVAL`: the 5-change batch alone left slow
296    /// sessions invisible to the dashboard (stuck "idle") for the whole
297    /// batch window. A fresh in-memory session flushes its first change
298    /// immediately so new activity surfaces at once.
299    pub fn should_save(&self) -> bool {
300        if self.stats.unsaved_changes == 0 {
301            return false;
302        }
303        if self.stats.unsaved_changes >= BATCH_SAVE_INTERVAL {
304            return true;
305        }
306        match self.last_flush {
307            Some(t) => t.elapsed() >= SESSION_FLUSH_INTERVAL,
308            None => true,
309        }
310    }
311
312    /// Sets the active task and infers a structured intent from the description.
313    pub fn set_task(&mut self, description: &str, intent: Option<&str>) {
314        let (description_clean, _) =
315            crate::core::secret_detection::scan_and_redact_from_config(description);
316        // A canonical title edit continues the admitted task, not a new task ID.
317        let progress_pct = if self.canonical_checkpoint.is_some() {
318            self.task.as_ref().and_then(|task| task.progress_pct)
319        } else {
320            None
321        };
322        self.task = Some(TaskInfo {
323            description: description_clean.clone(),
324            intent: intent.map(std::string::ToString::to_string),
325            progress_pct,
326        });
327        // An auto-inferred label is not a stated task: its words must not
328        // become the structured intent that steers entropy/IB reads.
329        if is_auto_task_intent(intent) {
330            self.increment();
331            return;
332        }
333
334        let touched: Vec<String> = self.files_touched.iter().map(|f| f.path.clone()).collect();
335        let si = if touched.is_empty() {
336            crate::core::intent_engine::StructuredIntent::from_query(&description_clean)
337        } else {
338            crate::core::intent_engine::StructuredIntent::from_query_with_session(
339                &description_clean,
340                &touched,
341            )
342        };
343        if si.confidence >= 0.7 {
344            self.active_structured_intent = Some(si);
345        }
346
347        self.increment();
348    }
349
350    /// Auto-infers the task from available context (plans, git diff, file patterns).
351    /// Never overwrites a stated task; an earlier *auto-inferred* one is
352    /// re-derived, so a label from a past session or a since-committed working
353    /// tree does not linger as "the task" forever.
354    pub fn auto_infer_task(&mut self) {
355        if self
356            .task
357            .as_ref()
358            .is_some_and(|t| !is_auto_task_intent(t.intent.as_deref()))
359        {
360            return;
361        }
362        let inferred = self.infer_task();
363        let unchanged = match (&self.task, &inferred) {
364            (Some(t), Some((desc, intent))) => {
365                t.description == *desc && t.intent.as_deref() == Some(*intent)
366            }
367            (None, None) => true,
368            _ => false,
369        };
370        if unchanged {
371            return;
372        }
373        if let Some((desc, intent)) = inferred {
374            self.set_task(&desc, Some(intent));
375        } else {
376            // Nothing to infer any more (plan done, tree committed): a stale
377            // auto label must not outlive the state it described.
378            self.task = None;
379            self.increment();
380        }
381    }
382
383    /// The first available auto source: active plan, uncommitted files,
384    /// touched-file patterns. `(description, intent)`.
385    fn infer_task(&self) -> Option<(String, &'static str)> {
386        if let Some(task_from_plan) = Self::infer_task_from_plans() {
387            return Some((task_from_plan, "plan"));
388        }
389        if let Some(ref root) = self.project_root
390            && let Some(task_from_git) = Self::infer_task_from_git(root)
391        {
392            return Some((task_from_git, GIT_TASK_INTENT));
393        }
394        if self.files_touched.len() < 3 {
395            return None;
396        }
397        let touched: Vec<String> = self.files_touched.iter().map(|f| f.path.clone()).collect();
398        let intent = crate::core::intent_engine::StructuredIntent::from_file_patterns(&touched);
399        if intent.confidence < 0.5 {
400            return None;
401        }
402        let dirs: std::collections::BTreeSet<&str> = touched
403            .iter()
404            .filter_map(|f| std::path::Path::new(f).parent()?.to_str())
405            .collect();
406        let primary_dir = dirs.iter().next().unwrap_or(&".");
407        Some((
408            format!("Working on {} ({})", primary_dir, intent.task_type.as_str()),
409            INFERRED_TASK_INTENT,
410        ))
411    }
412
413    fn infer_task_from_plans() -> Option<String> {
414        let plans_dir = std::path::Path::new(".cursor/plans");
415        if !plans_dir.exists() {
416            return None;
417        }
418
419        let mut newest: Option<(std::time::SystemTime, String)> = None;
420        if let Ok(entries) = std::fs::read_dir(plans_dir) {
421            for entry in entries.flatten() {
422                let path = entry.path();
423                if !path.to_string_lossy().ends_with(".plan.md") {
424                    continue;
425                }
426                let mtime = entry.metadata().ok()?.modified().ok()?;
427                let content = std::fs::read_to_string(&path).ok()?;
428
429                // Check if plan has active (pending/in_progress) todos
430                let has_active =
431                    content.contains("status: pending") || content.contains("status: in_progress");
432                if !has_active {
433                    continue;
434                }
435
436                // Extract plan name from frontmatter
437                let name = content
438                    .lines()
439                    .find(|l| l.starts_with("name:"))
440                    .map_or("Unknown Plan", |l| {
441                        l.trim_start_matches("name:").trim().trim_matches('"')
442                    });
443
444                let better = newest.as_ref().is_none_or(|(t, _)| mtime > *t);
445                if better {
446                    newest = Some((mtime, name.to_string()));
447                }
448            }
449        }
450
451        newest.map(|(_, name)| name)
452    }
453
454    fn infer_task_from_git(project_root: &str) -> Option<String> {
455        let output = std::process::Command::new("git")
456            .args(["diff", "--name-only", "--no-color"])
457            .current_dir(project_root)
458            .output()
459            .ok()?;
460        if !output.status.success() {
461            return None;
462        }
463        describe_uncommitted_files(String::from_utf8_lossy(&output.stdout).lines())
464    }
465
466    /// Records a finding (discovery or observation) in the session log.
467    pub fn add_finding(&mut self, file: Option<&str>, line: Option<u32>, summary: &str) {
468        let (summary_clean, _) =
469            crate::core::secret_detection::scan_and_redact_from_config(summary);
470        self.findings.push(Finding {
471            file: file.map(std::string::ToString::to_string),
472            line,
473            summary: summary_clean,
474            timestamp: Utc::now(),
475        });
476        while self.findings.len() > MAX_FINDINGS {
477            self.findings.remove(0);
478        }
479        self.increment();
480    }
481
482    /// Records a design or implementation decision with optional rationale.
483    pub fn add_decision(&mut self, summary: &str, rationale: Option<&str>) {
484        let (summary_clean, _) =
485            crate::core::secret_detection::scan_and_redact_from_config(summary);
486        let rationale_clean =
487            rationale.map(|r| crate::core::secret_detection::scan_and_redact_from_config(r).0);
488        self.decisions.push(Decision {
489            summary: summary_clean,
490            rationale: rationale_clean,
491            timestamp: Utc::now(),
492        });
493        while self.decisions.len() > MAX_DECISIONS {
494            self.decisions.remove(0);
495        }
496        self.increment();
497    }
498
499    /// Records a file read/access in the session, incrementing its read count.
500    pub fn touch_file(&mut self, path: &str, file_ref: Option<&str>, mode: &str, tokens: usize) {
501        if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
502            existing.read_count += 1;
503            existing.last_mode = mode.to_string();
504            existing.tokens = tokens;
505            if let Some(r) = file_ref {
506                existing.file_ref = Some(r.to_string());
507            }
508        } else {
509            let item_id = crate::core::context_field::ContextItemId::from_file(path);
510            self.files_touched.push(FileTouched {
511                path: path.to_string(),
512                file_ref: file_ref.map(std::string::ToString::to_string),
513                read_count: 1,
514                modified: false,
515                last_mode: mode.to_string(),
516                tokens,
517                stale: false,
518                context_item_id: Some(item_id.to_string()),
519                summary: None,
520            });
521            while self.files_touched.len() > MAX_FILES {
522                self.files_touched.remove(0);
523            }
524        }
525        self.stats.files_read += 1;
526        self.increment();
527    }
528
529    /// Marks a previously touched file as modified (written to).
530    pub fn mark_modified(&mut self, path: &str) {
531        if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
532            existing.modified = true;
533        }
534        self.increment();
535    }
536
537    /// Sets a one-line content summary for a touched file (max 80 chars).
538    pub fn set_file_summary(&mut self, path: &str, summary: &str) {
539        if let Some(existing) = self.files_touched.iter_mut().find(|f| f.path == path) {
540            let truncated = if summary.len() > 80 {
541                format!("{}…", &summary[..79])
542            } else {
543                summary.to_string()
544            };
545            existing.summary = Some(truncated);
546        }
547    }
548
549    /// Increments the tool call counter and accumulates token savings.
550    pub fn record_tool_call(&mut self, tokens_saved: u64, tokens_input: u64) {
551        self.stats.total_tool_calls += 1;
552        self.stats.total_tokens_saved += tokens_saved;
553        self.stats.total_tokens_input += tokens_input;
554        self.stats.day_mut(Utc::now()).tool_calls += 1;
555        if self.stats.client.is_none() {
556            let client = crate::core::client_capabilities::current().client_id;
557            if client != "unknown" {
558                self.stats.client = Some(client);
559            }
560        }
561    }
562
563    /// Records an inferred or explicit intent, coalescing consecutive duplicates.
564    pub fn record_intent(&mut self, mut intent: IntentRecord) {
565        if intent.occurrences == 0 {
566            intent.occurrences = 1;
567        }
568
569        if let Some(last) = self.intents.last_mut()
570            && last.fingerprint() == intent.fingerprint()
571        {
572            last.occurrences = last.occurrences.saturating_add(intent.occurrences);
573            last.timestamp = intent.timestamp;
574            match intent.source {
575                IntentSource::Inferred => self.stats.intents_inferred += 1,
576                IntentSource::Explicit => self.stats.intents_explicit += 1,
577            }
578            self.increment();
579            return;
580        }
581
582        match intent.source {
583            IntentSource::Inferred => self.stats.intents_inferred += 1,
584            IntentSource::Explicit => self.stats.intents_explicit += 1,
585        }
586
587        self.intents.push(intent);
588        while self.intents.len() > crate::core::budgets::INTENTS_PER_SESSION_LIMIT {
589            self.intents.remove(0);
590        }
591        self.increment();
592    }
593
594    /// Appends an auditable evidence record for a tool invocation.
595    pub fn record_tool_receipt(
596        &mut self,
597        tool: &str,
598        action: Option<&str>,
599        input_md5: &str,
600        output_md5: &str,
601        agent_id: Option<&str>,
602        client_name: Option<&str>,
603    ) {
604        self.record_tool_receipt_with_task(
605            tool,
606            action,
607            input_md5,
608            output_md5,
609            agent_id,
610            client_name,
611            None,
612        );
613    }
614
615    /// Records a tool receipt with optional MCP task lineage.
616    pub fn record_tool_receipt_with_task(
617        &mut self,
618        tool: &str,
619        action: Option<&str>,
620        input_md5: &str,
621        output_md5: &str,
622        agent_id: Option<&str>,
623        client_name: Option<&str>,
624        task_id: Option<&str>,
625    ) {
626        let now = Utc::now();
627        let mut push = |key: String| {
628            self.evidence.push(EvidenceRecord {
629                kind: EvidenceKind::ToolCall,
630                key,
631                value: None,
632                tool: Some(tool.to_string()),
633                input_md5: Some(input_md5.to_string()),
634                output_md5: Some(output_md5.to_string()),
635                agent_id: agent_id.map(std::string::ToString::to_string),
636                client_name: client_name.map(std::string::ToString::to_string),
637                task_id: task_id.map(std::string::ToString::to_string),
638                timestamp: now,
639            });
640        };
641
642        push(format!("tool:{tool}"));
643        if let Some(a) = action {
644            push(format!("tool:{tool}:{a}"));
645        }
646        while self.evidence.len() > MAX_EVIDENCE {
647            self.evidence.remove(0);
648        }
649        self.increment();
650    }
651
652    /// Appends a manual (non-tool) evidence record to the audit log.
653    pub fn record_manual_evidence(&mut self, key: &str, value: Option<&str>) {
654        self.evidence.push(EvidenceRecord {
655            kind: EvidenceKind::Manual,
656            key: key.to_string(),
657            value: value.map(std::string::ToString::to_string),
658            tool: None,
659            input_md5: None,
660            output_md5: None,
661            agent_id: None,
662            client_name: None,
663            task_id: None,
664            timestamp: Utc::now(),
665        });
666        while self.evidence.len() > MAX_EVIDENCE {
667            self.evidence.remove(0);
668        }
669        self.increment();
670    }
671
672    /// Returns `true` if an evidence record with the given key exists.
673    pub fn has_evidence_key(&self, key: &str) -> bool {
674        self.evidence.iter().any(|e| e.key == key)
675    }
676
677    /// Increments the session-level cache hit counter.
678    pub fn record_cache_hit(&mut self) {
679        self.stats.cache_hits += 1;
680        self.stats.day_mut(Utc::now()).cache_hits += 1;
681    }
682
683    /// Increments the session-level command counter.
684    pub fn record_command(&mut self) {
685        self.stats.commands_run += 1;
686    }
687
688    /// Returns the effective working directory for shell commands.
689    /// Priority: explicit cwd arg > session shell_cwd > project_root > process cwd.
690    /// Explicit CWD and stored shell_cwd are jail-checked against the project root
691    /// to prevent MCP clients from escaping the workspace.
692    pub fn effective_cwd(&self, explicit_cwd: Option<&str>) -> String {
693        self.effective_cwd_checked(explicit_cwd).0
694    }
695
696    /// Like [`Self::effective_cwd`], but also reports *why* an explicit `cwd`
697    /// request was rejected by the project-root jail and silently replaced with
698    /// the project root (#629).
699    ///
700    /// The jail itself is deliberate sandboxing (it stops MCP clients escaping
701    /// the workspace) and must stay — the only gap was that the substitution was
702    /// silent, so a caller running `pwd && ls` in what they think is dir A
703    /// actually ran in the project root with no indication why. Callers that
704    /// surface output to a human/agent (e.g. `ctx_shell`) use the returned
705    /// `Option<String>` reason to append a one-line hint instead of letting the
706    /// swap pass unnoticed; `effective_cwd` keeps the original lossless behaviour.
707    pub fn effective_cwd_checked(&self, explicit_cwd: Option<&str>) -> (String, Option<String>) {
708        let root = self.project_root.as_deref().unwrap_or(".");
709        if let Some(cwd) = explicit_cwd
710            && !cwd.is_empty()
711            && cwd != "."
712        {
713            return Self::jail_cwd(cwd, root);
714        }
715        if let Some(ref cwd) = self.shell_cwd {
716            return (cwd.clone(), None);
717        }
718        if let Some(ref r) = self.project_root {
719            return (r.clone(), None);
720        }
721        (
722            std::env::current_dir()
723                .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string()),
724            None,
725        )
726    }
727
728    /// The directory a command is to RUN in, or the reason there is none.
729    ///
730    /// Unlike [`Self::effective_cwd_checked`] nothing is substituted: an
731    /// explicit `cwd` the project jail rejects is an error, so the command is
732    /// not executed, backgrounded or cached under a directory the caller did
733    /// not name. The stored shell cwd is held to the same jail — it was
734    /// accepted when it was stored, but the project root may have changed
735    /// since. Without a project root there is no jail to hold it to, and the
736    /// stored value is used as before.
737    pub fn execution_cwd(&self, explicit_cwd: Option<&str>) -> Result<String, String> {
738        let jailed = |candidate: &str, root: &str| {
739            crate::core::pathjail::jail_path(
740                std::path::Path::new(candidate),
741                std::path::Path::new(root),
742            )
743            .map(|path| path.to_string_lossy().to_string())
744            .map_err(|reason| reason.to_string())
745        };
746        if let Some(cwd) = explicit_cwd
747            && !cwd.is_empty()
748            && cwd != "."
749        {
750            return jailed(cwd, self.project_root.as_deref().unwrap_or("."));
751        }
752        match (self.shell_cwd.as_deref(), self.project_root.as_deref()) {
753            (Some(cwd), Some(root)) => jailed(cwd, root),
754            (Some(cwd), None) => Ok(cwd.to_string()),
755            (None, Some(root)) => Ok(root.to_string()),
756            (None, None) => Ok(std::env::current_dir()
757                .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())),
758        }
759    }
760
761    /// Verifies that `candidate` is within the project jail.
762    ///
763    /// Falls back to `fallback_root` if the candidate escapes, returning the
764    /// jail-rejection reason as the second tuple element so callers can surface
765    /// it instead of silently substituting the root (#629). `None` means the
766    /// candidate was accepted as-is.
767    fn jail_cwd(candidate: &str, fallback_root: &str) -> (String, Option<String>) {
768        let p = std::path::Path::new(candidate);
769        match crate::core::pathjail::jail_path(p, std::path::Path::new(fallback_root)) {
770            Ok(jailed) => (jailed.to_string_lossy().to_string(), None),
771            Err(reason) => (fallback_root.to_string(), Some(reason.to_string())),
772        }
773    }
774
775    /// Persist an explicit, jail-accepted `cwd` argument as the live shell
776    /// cwd (#707). `update_shell_cwd` only tracks `cd` inside the command
777    /// text, but clients that switch checkouts mid-session (Claude Code
778    /// after `EnterWorktree`) pass the new directory as the `cwd` *param* of
779    /// every subsequent call — without persisting it, the worktree-divergence
780    /// detection in path resolution never sees the switch. Callers must pass
781    /// a cwd that already passed the project-root jail.
782    pub fn note_explicit_cwd(&mut self, cwd: &str) {
783        let path = std::path::Path::new(cwd);
784        if path.is_absolute() && path.is_dir() {
785            self.shell_cwd = Some(
786                crate::core::pathutil::safe_canonicalize_or_self(path)
787                    .to_string_lossy()
788                    .to_string(),
789            );
790        }
791    }
792
793    /// Updates shell_cwd by detecting `cd` in the command.
794    /// Handles: `cd /abs/path`, `cd rel/path` (relative to current cwd),
795    /// `cd ..`, and chained commands like `cd foo && ...`.
796    /// The new CWD is jail-checked against the project root.
797    pub fn update_shell_cwd(&mut self, command: &str) {
798        let base = self.effective_cwd(None);
799        if let Some(new_cwd) = extract_cd_target(command, &base) {
800            let path = std::path::Path::new(&new_cwd);
801            if path.exists() && path.is_dir() {
802                let canonical = crate::core::pathutil::safe_canonicalize_or_self(path)
803                    .to_string_lossy()
804                    .to_string();
805                let root = self.project_root.as_deref().unwrap_or(".");
806                if crate::core::pathjail::jail_path(
807                    std::path::Path::new(&canonical),
808                    std::path::Path::new(root),
809                )
810                .is_ok()
811                {
812                    self.shell_cwd = Some(canonical);
813                }
814            }
815        }
816    }
817}