Skip to main content

tsift_agent_doc/
session_review.rs

1use anyhow::{Context, Result, bail};
2use serde::Serialize;
3use std::collections::{BTreeMap, BTreeSet};
4use std::fs;
5use std::io::{BufRead, BufReader, Read};
6use std::path::{Path, PathBuf};
7use std::time::{Instant, UNIX_EPOCH};
8
9const SESSION_HEADER_PROBE_BUDGET_BYTES: usize = 256 * 1024;
10
11use crate::{
12    session_cost::{
13        self, SessionCostFileReadDiagnostic, SessionCostGuardrail, SessionCostGuardrailInput,
14        SessionCostLoopCluster,
15    },
16    session_digest,
17};
18use tsift_quality::runtime_churn::RestartChurnSummary;
19
20const MAX_SESSIONS: usize = 12;
21const MAX_AGGREGATE_ITEMS: usize = 12;
22const MAX_LARGEST_TURNS: usize = 8;
23const MAX_WARNINGS: usize = 16;
24const MAX_LOOP_CLUSTERS: usize = 12;
25/// Per-source candidate budget for session discovery. Each source can collect at
26/// most this many most-recent files before content reads. Set generously above
27/// `MAX_SESSIONS` so the global top-N after cross-source merge still comes from
28/// the genuinely most recent matches even when a source has many rejected
29/// candidates near the head.
30const MAX_RECENT_CANDIDATES_PER_SOURCE: usize = 64;
31
32#[derive(Debug, Clone, Serialize)]
33pub struct SessionReviewPhaseTiming {
34    pub name: String,
35    pub duration_micros: u128,
36    pub detail: String,
37}
38
39#[derive(Debug, Clone, Serialize)]
40pub struct SessionReviewSession {
41    pub source: String,
42    pub path: String,
43    pub matched_by: Vec<String>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub modified_unix_secs: Option<u64>,
46    pub prompt_target_count: usize,
47    pub command_groups: usize,
48    pub file_groups: usize,
49    pub symbol_groups: usize,
50    pub failure_groups: usize,
51    pub runtime_event_groups: usize,
52    pub restart_churn_groups: usize,
53    pub closeout_groups: usize,
54    pub usage_samples: usize,
55    pub prompt_tokens: u64,
56    pub cached_input_tokens: u64,
57    pub cache_creation_input_tokens: u64,
58    pub output_tokens: u64,
59    pub reasoning_output_tokens: u64,
60    pub total_tokens: u64,
61    pub largest_turn_total_tokens: u64,
62}
63
64#[derive(Debug, Clone, PartialEq, Serialize)]
65pub struct SessionReviewCostSummary {
66    pub scope: String,
67    pub sessions: usize,
68    pub usage_samples: usize,
69    pub prompt_tokens: u64,
70    pub cached_input_tokens: u64,
71    pub cache_creation_input_tokens: u64,
72    pub output_tokens: u64,
73    pub reasoning_output_tokens: u64,
74    pub total_tokens: u64,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub cached_input_ratio: Option<f64>,
77    pub largest_turn_total_tokens: u64,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
81pub struct SessionReviewPromptTarget {
82    pub text: String,
83    pub occurrences: usize,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
87pub struct SessionReviewCommand {
88    pub command: String,
89    pub occurrences: usize,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
93pub struct SessionReviewFileRef {
94    pub path: String,
95    pub occurrences: usize,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
99pub struct SessionReviewSymbolRef {
100    pub symbol: String,
101    pub occurrences: usize,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
105pub struct SessionReviewFailure {
106    pub kind: String,
107    pub message: String,
108    pub occurrences: usize,
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub command: Option<String>,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub session_path: Option<String>,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
116pub struct SessionReviewRuntimeEvent {
117    pub event: String,
118    pub occurrences: usize,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
122pub struct SessionReviewCloseout {
123    pub kind: String,
124    pub detail: String,
125    pub occurrences: usize,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
129pub struct SessionReviewLargestTurn {
130    pub source: String,
131    pub session_path: String,
132    pub label: String,
133    pub prompt_tokens: u64,
134    pub cached_input_tokens: u64,
135    pub cache_creation_input_tokens: u64,
136    pub output_tokens: u64,
137    pub reasoning_output_tokens: u64,
138    pub total_tokens: u64,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
142pub struct SessionReviewVerificationState {
143    pub status: String,
144    pub detail: String,
145}
146
147#[derive(Debug, Clone, Serialize)]
148pub struct SessionReviewNextContext {
149    pub target: String,
150    pub active_prompt_targets: Vec<String>,
151    pub last_verification: SessionReviewVerificationState,
152    pub touched_files: Vec<String>,
153    pub touched_symbols: Vec<String>,
154    pub unresolved_failures: Vec<SessionReviewFailure>,
155    pub next_digest_commands: Vec<String>,
156}
157
158#[derive(Debug, Clone, Serialize)]
159pub struct SessionReviewReport {
160    pub root: String,
161    pub target: String,
162    pub target_kind: String,
163    pub sessions_considered: usize,
164    pub sessions_matched: usize,
165    pub claude_sessions: usize,
166    pub codex_sessions: usize,
167    pub agent_doc_logs: usize,
168    pub prompt_target_count: usize,
169    pub command_groups: usize,
170    pub file_groups: usize,
171    pub symbol_groups: usize,
172    pub failure_groups: usize,
173    pub runtime_event_groups: usize,
174    pub restart_churn_groups: usize,
175    pub closeout_groups: usize,
176    pub usage_samples: usize,
177    pub prompt_tokens: u64,
178    pub cached_input_tokens: u64,
179    pub cache_creation_input_tokens: u64,
180    pub output_tokens: u64,
181    pub reasoning_output_tokens: u64,
182    pub total_tokens: u64,
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub cached_input_ratio: Option<f64>,
185    pub largest_turn_total_tokens: u64,
186    pub aggregate_cost: SessionReviewCostSummary,
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub latest_session_cost: Option<SessionReviewCostSummary>,
189    #[serde(skip_serializing_if = "Vec::is_empty", default)]
190    pub guardrails: Vec<SessionCostGuardrail>,
191    #[serde(skip_serializing_if = "Vec::is_empty", default)]
192    pub loop_clusters: Vec<SessionCostLoopCluster>,
193    #[serde(skip_serializing_if = "Vec::is_empty", default)]
194    pub file_read_diagnostics: Vec<SessionCostFileReadDiagnostic>,
195    pub prompt_targets: Vec<SessionReviewPromptTarget>,
196    pub commands: Vec<SessionReviewCommand>,
197    pub touched_files: Vec<SessionReviewFileRef>,
198    pub touched_symbols: Vec<SessionReviewSymbolRef>,
199    pub failures: Vec<SessionReviewFailure>,
200    pub runtime_events: Vec<SessionReviewRuntimeEvent>,
201    #[serde(skip_serializing_if = "Vec::is_empty", default)]
202    pub restart_churn: Vec<RestartChurnSummary>,
203    pub closeout: Vec<SessionReviewCloseout>,
204    pub largest_turns: Vec<SessionReviewLargestTurn>,
205    pub sessions: Vec<SessionReviewSession>,
206    pub next_context: SessionReviewNextContext,
207    #[serde(skip_serializing_if = "Vec::is_empty", default)]
208    pub warnings: Vec<String>,
209}
210
211#[derive(Debug, Clone, Default)]
212pub struct SessionReviewOptions {
213    pub claude_projects_dir: Option<PathBuf>,
214    pub codex_sessions_dir: Option<PathBuf>,
215    pub agent_doc_logs_dir: Option<PathBuf>,
216}
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219enum ReviewSource {
220    ClaudeJsonl,
221    CodexJsonl,
222    AgentDocLog,
223}
224
225impl ReviewSource {
226    fn as_str(self) -> &'static str {
227        match self {
228            Self::ClaudeJsonl => "claude_jsonl",
229            Self::CodexJsonl => "codex_jsonl",
230            Self::AgentDocLog => "agent_doc_log",
231        }
232    }
233
234    fn digest_source(self) -> &'static str {
235        match self {
236            Self::ClaudeJsonl => "claude-jsonl",
237            Self::CodexJsonl => "codex-jsonl",
238            Self::AgentDocLog => "agent-doc-log",
239        }
240    }
241
242    fn supports_cost(self) -> bool {
243        true
244    }
245}
246
247#[derive(Debug, Clone, Copy, PartialEq, Eq)]
248enum TargetKind {
249    File,
250    Directory,
251}
252
253impl TargetKind {
254    fn as_str(self) -> &'static str {
255        match self {
256            Self::File => "file",
257            Self::Directory => "directory",
258        }
259    }
260}
261
262#[derive(Debug, Clone)]
263struct TargetContext {
264    root: PathBuf,
265    canonical_target: PathBuf,
266    relative_target: Option<String>,
267    kind: TargetKind,
268    agent_doc_session: Option<String>,
269    path_aliases: BTreeSet<String>,
270    session_aliases: BTreeSet<String>,
271}
272
273#[derive(Debug, Clone, Default)]
274struct AgentDocAliases {
275    path_aliases: BTreeSet<String>,
276    session_aliases: BTreeSet<String>,
277}
278
279#[derive(Debug, Clone, Default)]
280struct MatchSignals {
281    cwd: Option<PathBuf>,
282    snippets: Vec<String>,
283}
284
285#[derive(Debug, Clone, Default)]
286struct DocumentActiveContext {
287    has_live_tail: bool,
288    prompt_targets: Vec<String>,
289    touched_files: Vec<SessionReviewFileRef>,
290    touched_symbols: Vec<SessionReviewSymbolRef>,
291    failures: Vec<SessionReviewFailure>,
292}
293
294impl DocumentActiveContext {
295    fn should_scope_next_context(&self) -> bool {
296        self.has_live_tail
297            || !self.prompt_targets.is_empty()
298            || !self.touched_files.is_empty()
299            || !self.touched_symbols.is_empty()
300            || !self.failures.is_empty()
301    }
302}
303
304#[derive(Debug, Clone)]
305struct PendingSession {
306    source: ReviewSource,
307    path: PathBuf,
308    matched_by: BTreeSet<String>,
309    modified_unix_secs: Option<u64>,
310    text: String,
311}
312
313impl PendingSession {
314    fn new(
315        source: ReviewSource,
316        path: PathBuf,
317        matched_by: Vec<String>,
318        modified_unix_secs: Option<u64>,
319        text: String,
320    ) -> Self {
321        Self {
322            source,
323            path,
324            matched_by: matched_by.into_iter().collect(),
325            modified_unix_secs,
326            text,
327        }
328    }
329}
330
331#[derive(Debug, Clone)]
332struct FileReadDiagnosticAggregate {
333    path: String,
334    range: String,
335    occurrences: usize,
336    estimated_tokens: u64,
337    duplicate_estimated_tokens: u64,
338    follow_up_commands: BTreeSet<String>,
339}
340
341pub fn compute(target: &Path) -> Result<SessionReviewReport> {
342    compute_with_options(target, &SessionReviewOptions::default())
343}
344
345pub fn compute_with_phases(
346    target: &Path,
347) -> Result<(SessionReviewReport, Vec<SessionReviewPhaseTiming>)> {
348    compute_with_options_and_phases(target, &SessionReviewOptions::default())
349}
350
351pub fn compute_with_options(
352    target: &Path,
353    options: &SessionReviewOptions,
354) -> Result<SessionReviewReport> {
355    compute_with_options_and_phases(target, options).map(|(report, _phases)| report)
356}
357
358pub fn compute_with_options_and_phases(
359    target: &Path,
360    options: &SessionReviewOptions,
361) -> Result<(SessionReviewReport, Vec<SessionReviewPhaseTiming>)> {
362    let mut phases: Vec<SessionReviewPhaseTiming> = Vec::with_capacity(6);
363
364    let target_context_started = Instant::now();
365    let mut context = build_target_context(target)?;
366    let target_context_micros = target_context_started.elapsed().as_micros();
367
368    let session_discovery_started = Instant::now();
369    let mut candidates = BTreeMap::<String, PendingSession>::new();
370    let mut sessions_considered = 0_usize;
371    let mut warnings = Vec::new();
372
373    let agent_doc_logs_dir = resolve_agent_doc_logs_dir(&context.root, options);
374    if let Some(session_name) = &context.agent_doc_session {
375        let session_log = agent_doc_logs_dir.join(format!("{session_name}.log"));
376        if session_log.is_file()
377            && let Ok(text) = fs::read_to_string(&session_log)
378        {
379            let aliases = collect_agent_doc_aliases(&text, &context.root);
380            context.path_aliases.extend(aliases.path_aliases);
381            context.session_aliases.extend(aliases.session_aliases);
382        }
383    }
384
385    if agent_doc_logs_dir.is_dir() {
386        for path in collect_files_with_extension(&agent_doc_logs_dir, "log")? {
387            sessions_considered += 1;
388            maybe_add_agent_doc_candidate(&mut candidates, &context, &path)?;
389        }
390    }
391
392    let claude_projects_dir = resolve_claude_projects_dir(&context.root, options);
393    let claude_project_dir = claude_projects_dir.join(claude_project_slug(&context.root));
394    if claude_project_dir.is_dir() {
395        for path in collect_recent_files_with_extension(
396            &claude_project_dir,
397            "jsonl",
398            MAX_RECENT_CANDIDATES_PER_SOURCE,
399        )? {
400            sessions_considered += 1;
401            maybe_add_claude_candidate(&mut candidates, &context, &path)?;
402        }
403    }
404
405    let codex_sessions_dir = resolve_codex_sessions_dir(&context.root, options);
406    if codex_sessions_dir.is_dir() {
407        for path in collect_recent_files_with_extension(
408            &codex_sessions_dir,
409            "jsonl",
410            MAX_RECENT_CANDIDATES_PER_SOURCE,
411        )? {
412            sessions_considered += 1;
413            maybe_add_codex_candidate(&mut candidates, &context, &path)?;
414        }
415    }
416
417    let mut sessions = candidates.into_values().collect::<Vec<_>>();
418    sessions.sort_by(|left, right| {
419        right
420            .modified_unix_secs
421            .cmp(&left.modified_unix_secs)
422            .then_with(|| left.path.cmp(&right.path))
423    });
424    sessions.truncate(MAX_SESSIONS);
425    let session_discovery_micros = session_discovery_started.elapsed().as_micros();
426
427    let mut session_digest_micros: u128 = 0;
428    let mut session_cost_micros: u128 = 0;
429    let session_loop_started = Instant::now();
430
431    let mut prompt_targets = BTreeMap::<String, usize>::new();
432    let mut commands = BTreeMap::<String, usize>::new();
433    let mut touched_files = BTreeMap::<String, usize>::new();
434    let mut touched_symbols = BTreeMap::<String, usize>::new();
435    let mut failures = BTreeMap::<(String, String, Option<String>, Option<String>), usize>::new();
436    let mut runtime_events = BTreeMap::<String, usize>::new();
437    let mut closeout = BTreeMap::<(String, String), usize>::new();
438    let mut restart_churn = BTreeMap::<String, RestartChurnSummary>::new();
439    let mut aggregate_runtime_events = BTreeMap::<String, usize>::new();
440    let mut loop_clusters = BTreeMap::<(String, String), (usize, usize)>::new();
441    let mut file_read_diagnostics =
442        BTreeMap::<(String, String), FileReadDiagnosticAggregate>::new();
443    let mut largest_turns = Vec::<SessionReviewLargestTurn>::new();
444    let mut session_rows = Vec::<SessionReviewSession>::new();
445
446    let mut claude_sessions = 0_usize;
447    let mut codex_sessions = 0_usize;
448    let mut agent_doc_logs = 0_usize;
449    let mut prompt_target_count = 0_usize;
450    let mut command_groups = 0_usize;
451    let mut file_groups = 0_usize;
452    let mut symbol_groups = 0_usize;
453    let mut failure_groups = 0_usize;
454    let mut runtime_event_groups = 0_usize;
455    let mut restart_churn_groups = 0_usize;
456    let mut closeout_groups = 0_usize;
457    let mut usage_samples = 0_usize;
458    let mut prompt_tokens = 0_u64;
459    let mut cached_input_tokens = 0_u64;
460    let mut cache_creation_input_tokens = 0_u64;
461    let mut output_tokens = 0_u64;
462    let mut reasoning_output_tokens = 0_u64;
463    let mut total_tokens = 0_u64;
464    let mut largest_turn_total_tokens = 0_u64;
465    let mut last_verification = None::<SessionReviewVerificationState>;
466
467    for pending in sessions {
468        let digest_started = Instant::now();
469        let digest = session_digest::compute(
470            &context.root,
471            &pending.text,
472            Some(pending.source.digest_source()),
473        )
474        .with_context(|| format!("digesting {}", pending.path.display()))?;
475        session_digest_micros += digest_started.elapsed().as_micros();
476        let cost_started = Instant::now();
477        let cost = if pending.source.supports_cost() {
478            Some(
479                session_cost::compute(&pending.text, Some(pending.source.digest_source()))
480                    .with_context(|| format!("costing {}", pending.path.display()))?,
481            )
482        } else {
483            None
484        };
485        session_cost_micros += cost_started.elapsed().as_micros();
486
487        match pending.source {
488            ReviewSource::ClaudeJsonl => claude_sessions += 1,
489            ReviewSource::CodexJsonl => codex_sessions += 1,
490            ReviewSource::AgentDocLog => agent_doc_logs += 1,
491        }
492
493        if last_verification.is_none()
494            && let Some(entry) = digest
495                .closeout
496                .iter()
497                .find(|entry| entry.kind == "verification")
498        {
499            last_verification = Some(SessionReviewVerificationState {
500                status: "passed".to_string(),
501                detail: entry.detail.clone(),
502            });
503        }
504
505        prompt_target_count += digest.prompt_target_count;
506        command_groups += digest.command_groups;
507        file_groups += digest.file_groups;
508        symbol_groups += digest.symbol_groups;
509        failure_groups += digest.failure_groups;
510        runtime_event_groups += digest.runtime_event_groups;
511        restart_churn_groups += digest.restart_churn_groups;
512        closeout_groups += digest.closeout_groups;
513
514        for prompt in &digest.prompt_targets {
515            *prompt_targets.entry(prompt.clone()).or_default() += 1;
516        }
517        for command in &digest.commands {
518            *commands.entry(command.command.clone()).or_default() += command.occurrences;
519        }
520        for file_ref in &digest.touched_files {
521            *touched_files.entry(file_ref.path.clone()).or_default() += file_ref.occurrences;
522        }
523        for symbol_ref in &digest.touched_symbols {
524            *touched_symbols
525                .entry(symbol_ref.symbol.clone())
526                .or_default() += symbol_ref.occurrences;
527        }
528        for failure in &digest.failures {
529            *failures
530                .entry((
531                    failure.kind.clone(),
532                    failure.message.clone(),
533                    failure.command.clone(),
534                    Some(pending.path.display().to_string()),
535                ))
536                .or_default() += failure.occurrences;
537        }
538        for event in &digest.runtime_events {
539            *runtime_events.entry(event.event.clone()).or_default() += event.occurrences;
540            *aggregate_runtime_events
541                .entry(event.event.clone())
542                .or_default() += event.occurrences;
543        }
544        for entry in &digest.closeout {
545            *closeout
546                .entry((entry.kind.clone(), entry.detail.clone()))
547                .or_default() += entry.occurrences;
548        }
549        for churn in &digest.restart_churn {
550            restart_churn
551                .entry(churn.family.clone())
552                .and_modify(|existing| {
553                    existing.occurrences += churn.occurrences;
554                    if let Some(churn_max) = churn.max_restart_count {
555                        existing.max_restart_count = Some(
556                            existing
557                                .max_restart_count
558                                .map_or(churn_max, |current| current.max(churn_max)),
559                        );
560                    }
561                    if churn.sample.len() > existing.sample.len() {
562                        existing.sample = churn.sample.clone();
563                    }
564                })
565                .or_insert_with(|| churn.clone());
566        }
567
568        if let Some(cost) = &cost {
569            usage_samples += cost.usage_samples;
570            prompt_tokens += cost.prompt_tokens;
571            cached_input_tokens += cost.cached_input_tokens;
572            cache_creation_input_tokens += cost.cache_creation_input_tokens;
573            output_tokens += cost.output_tokens;
574            reasoning_output_tokens += cost.reasoning_output_tokens;
575            total_tokens += cost.total_tokens;
576            largest_turn_total_tokens =
577                largest_turn_total_tokens.max(cost.largest_turn_total_tokens);
578            for turn in &cost.largest_turns {
579                largest_turns.push(SessionReviewLargestTurn {
580                    source: pending.source.as_str().to_string(),
581                    session_path: pending.path.display().to_string(),
582                    label: turn.label.clone(),
583                    prompt_tokens: turn.prompt_tokens,
584                    cached_input_tokens: turn.cached_input_tokens,
585                    cache_creation_input_tokens: turn.cache_creation_input_tokens,
586                    output_tokens: turn.output_tokens,
587                    reasoning_output_tokens: turn.reasoning_output_tokens,
588                    total_tokens: turn.total_tokens,
589                });
590            }
591            for cluster in &cost.loop_clusters {
592                let entry = loop_clusters
593                    .entry((cluster.kind.clone(), cluster.label.clone()))
594                    .or_insert((0, 0));
595                entry.0 += cluster.occurrences;
596                entry.1 = entry.1.max(cluster.max_consecutive);
597            }
598            for diagnostic in &cost.file_read_diagnostics {
599                let entry = file_read_diagnostics
600                    .entry((diagnostic.path.clone(), diagnostic.range.clone()))
601                    .or_insert_with(|| FileReadDiagnosticAggregate {
602                        path: diagnostic.path.clone(),
603                        range: diagnostic.range.clone(),
604                        occurrences: 0,
605                        estimated_tokens: 0,
606                        duplicate_estimated_tokens: 0,
607                        follow_up_commands: BTreeSet::new(),
608                    });
609                entry.occurrences += diagnostic.occurrences;
610                entry.estimated_tokens = entry
611                    .estimated_tokens
612                    .saturating_add(diagnostic.estimated_tokens);
613                entry.duplicate_estimated_tokens = entry
614                    .duplicate_estimated_tokens
615                    .saturating_add(diagnostic.duplicate_estimated_tokens);
616                entry
617                    .follow_up_commands
618                    .extend(diagnostic.follow_up_commands.iter().cloned());
619            }
620        }
621
622        for warning in digest.warnings.iter().chain(
623            cost.as_ref()
624                .map(|report| report.warnings.iter())
625                .into_iter()
626                .flatten(),
627        ) {
628            warnings.push(format!("{}: {}", pending.path.display(), warning));
629        }
630
631        session_rows.push(SessionReviewSession {
632            source: pending.source.as_str().to_string(),
633            path: pending.path.display().to_string(),
634            matched_by: pending.matched_by.into_iter().collect(),
635            modified_unix_secs: pending.modified_unix_secs,
636            prompt_target_count: digest.prompt_target_count,
637            command_groups: digest.command_groups,
638            file_groups: digest.file_groups,
639            symbol_groups: digest.symbol_groups,
640            failure_groups: digest.failure_groups,
641            runtime_event_groups: digest.runtime_event_groups,
642            restart_churn_groups: digest.restart_churn_groups,
643            closeout_groups: digest.closeout_groups,
644            usage_samples: cost.as_ref().map_or(0, |report| report.usage_samples),
645            prompt_tokens: cost.as_ref().map_or(0, |report| report.prompt_tokens),
646            cached_input_tokens: cost.as_ref().map_or(0, |report| report.cached_input_tokens),
647            cache_creation_input_tokens: cost
648                .as_ref()
649                .map_or(0, |report| report.cache_creation_input_tokens),
650            output_tokens: cost.as_ref().map_or(0, |report| report.output_tokens),
651            reasoning_output_tokens: cost
652                .as_ref()
653                .map_or(0, |report| report.reasoning_output_tokens),
654            total_tokens: cost.as_ref().map_or(0, |report| report.total_tokens),
655            largest_turn_total_tokens: cost
656                .as_ref()
657                .map_or(0, |report| report.largest_turn_total_tokens),
658        });
659    }
660    let session_loop_total_micros = session_loop_started.elapsed().as_micros();
661    let session_aggregation_micros = session_loop_total_micros
662        .saturating_sub(session_digest_micros)
663        .saturating_sub(session_cost_micros);
664    let report_assembly_started = Instant::now();
665
666    let cached_input_ratio = (prompt_tokens > 0).then_some(
667        ((cached_input_tokens as f64) / (prompt_tokens as f64) * 10_000.0).round() / 100.0,
668    );
669    let largest_prompt_turn = largest_turns
670        .iter()
671        .max_by(|left, right| {
672            left.prompt_tokens
673                .cmp(&right.prompt_tokens)
674                .then(left.label.cmp(&right.label))
675        })
676        .cloned();
677    let guardrails = session_cost::derive_guardrails(&SessionCostGuardrailInput {
678        largest_prompt_turn_tokens: largest_prompt_turn
679            .as_ref()
680            .map_or(0, |turn| turn.prompt_tokens),
681        largest_prompt_turn_label: largest_prompt_turn.as_ref().map(|turn| turn.label.clone()),
682        prompt_tokens,
683        cached_input_ratio,
684        fresh_restart_occurrences: restart_churn
685            .get("fresh_restart")
686            .map_or(0, |entry| entry.occurrences),
687        auto_trigger_timeout_occurrences: restart_churn
688            .get("auto_trigger_timeout")
689            .map_or(0, |entry| entry.occurrences),
690        ctrl_d_restart_loop_occurrences: restart_churn
691            .get("ctrl_d_restart_loop")
692            .map_or(0, |entry| entry.occurrences),
693        noop_closeout_occurrences: aggregate_runtime_events
694            .get("commit_already_current")
695            .copied()
696            .unwrap_or(0),
697        max_restart_count: restart_churn
698            .values()
699            .filter_map(|entry| entry.max_restart_count)
700            .max(),
701    });
702
703    largest_turns.sort_by(|left, right| {
704        right
705            .total_tokens
706            .cmp(&left.total_tokens)
707            .then(right.prompt_tokens.cmp(&left.prompt_tokens))
708            .then(left.session_path.cmp(&right.session_path))
709            .then(left.label.cmp(&right.label))
710    });
711    largest_turns.truncate(MAX_LARGEST_TURNS);
712
713    session_rows.truncate(MAX_SESSIONS);
714    let prompt_targets =
715        collect_strings(prompt_targets, MAX_AGGREGATE_ITEMS, |text, occurrences| {
716            SessionReviewPromptTarget { text, occurrences }
717        });
718    let commands = collect_strings(commands, MAX_AGGREGATE_ITEMS, |command, occurrences| {
719        SessionReviewCommand {
720            command,
721            occurrences,
722        }
723    });
724    let touched_files = collect_strings(touched_files, MAX_AGGREGATE_ITEMS, |path, occurrences| {
725        SessionReviewFileRef { path, occurrences }
726    });
727    let touched_symbols = collect_strings(
728        touched_symbols,
729        MAX_AGGREGATE_ITEMS,
730        |symbol, occurrences| SessionReviewSymbolRef {
731            symbol,
732            occurrences,
733        },
734    );
735    let failures = collect_pairs(
736        failures,
737        MAX_AGGREGATE_ITEMS,
738        |(kind, message, command, session_path), occurrences| SessionReviewFailure {
739            kind,
740            message,
741            occurrences,
742            command,
743            session_path,
744        },
745    );
746    let runtime_events =
747        collect_strings(runtime_events, MAX_AGGREGATE_ITEMS, |event, occurrences| {
748            SessionReviewRuntimeEvent { event, occurrences }
749        });
750    let restart_churn = collect_restart_churn(restart_churn, MAX_AGGREGATE_ITEMS);
751    let closeout = collect_pairs(
752        closeout,
753        MAX_AGGREGATE_ITEMS,
754        |(kind, detail), occurrences| SessionReviewCloseout {
755            kind,
756            detail,
757            occurrences,
758        },
759    );
760    let loop_clusters = collect_loop_clusters(loop_clusters, MAX_LOOP_CLUSTERS);
761    let file_read_diagnostics =
762        collect_file_read_diagnostics(file_read_diagnostics, MAX_AGGREGATE_ITEMS);
763    let aggregate_cost = SessionReviewCostSummary {
764        scope: "bounded_matched_sessions".to_string(),
765        sessions: session_rows.len(),
766        usage_samples,
767        prompt_tokens,
768        cached_input_tokens,
769        cache_creation_input_tokens,
770        output_tokens,
771        reasoning_output_tokens,
772        total_tokens,
773        cached_input_ratio,
774        largest_turn_total_tokens,
775    };
776    let latest_session_cost = session_rows
777        .first()
778        .map(|session| SessionReviewCostSummary {
779            scope: "latest_matched_session".to_string(),
780            sessions: 1,
781            usage_samples: session.usage_samples,
782            prompt_tokens: session.prompt_tokens,
783            cached_input_tokens: session.cached_input_tokens,
784            cache_creation_input_tokens: session.cache_creation_input_tokens,
785            output_tokens: session.output_tokens,
786            reasoning_output_tokens: session.reasoning_output_tokens,
787            total_tokens: session.total_tokens,
788            cached_input_ratio: (session.prompt_tokens > 0).then_some(
789                ((session.cached_input_tokens as f64) / (session.prompt_tokens as f64) * 10_000.0)
790                    .round()
791                    / 100.0,
792            ),
793            largest_turn_total_tokens: session.largest_turn_total_tokens,
794        });
795    let document_active_context = match collect_document_active_context(&context) {
796        Ok(active_context) => active_context,
797        Err(error) => {
798            warnings.push(format!(
799                "{}: could not extract live document active context: {error:#}",
800                context.canonical_target.display()
801            ));
802            DocumentActiveContext::default()
803        }
804    };
805    let (active_prompt_targets, next_context_files, next_context_symbols, next_context_failures) =
806        if document_active_context.should_scope_next_context() {
807            (
808                document_active_context.prompt_targets,
809                document_active_context.touched_files,
810                document_active_context.touched_symbols,
811                document_active_context.failures,
812            )
813        } else {
814            (
815                prompt_targets
816                    .iter()
817                    .map(|entry| entry.text.clone())
818                    .collect(),
819                touched_files.clone(),
820                touched_symbols.clone(),
821                failures.clone(),
822            )
823        };
824    let next_context = build_next_context(
825        &context,
826        active_prompt_targets,
827        &next_context_files,
828        &next_context_symbols,
829        &next_context_failures,
830        &guardrails,
831        last_verification.unwrap_or_else(|| SessionReviewVerificationState {
832            status: "missing".to_string(),
833            detail: "no verification closeout found in matched sessions".to_string(),
834        }),
835    );
836    warnings.sort();
837    warnings.truncate(MAX_WARNINGS);
838
839    let report = SessionReviewReport {
840        root: context.root.display().to_string(),
841        target: context.canonical_target.display().to_string(),
842        target_kind: context.kind.as_str().to_string(),
843        sessions_considered,
844        sessions_matched: session_rows.len(),
845        claude_sessions,
846        codex_sessions,
847        agent_doc_logs,
848        prompt_target_count,
849        command_groups,
850        file_groups,
851        symbol_groups,
852        failure_groups,
853        runtime_event_groups,
854        restart_churn_groups,
855        closeout_groups,
856        usage_samples,
857        prompt_tokens,
858        cached_input_tokens,
859        cache_creation_input_tokens,
860        output_tokens,
861        reasoning_output_tokens,
862        total_tokens,
863        cached_input_ratio,
864        largest_turn_total_tokens,
865        aggregate_cost,
866        latest_session_cost,
867        guardrails,
868        loop_clusters,
869        file_read_diagnostics,
870        prompt_targets,
871        commands,
872        touched_files,
873        touched_symbols,
874        failures,
875        runtime_events,
876        restart_churn,
877        closeout,
878        largest_turns,
879        sessions: session_rows,
880        next_context,
881        warnings,
882    };
883    let report_assembly_micros = report_assembly_started.elapsed().as_micros();
884
885    phases.push(SessionReviewPhaseTiming {
886        name: "target_context_build".to_string(),
887        duration_micros: target_context_micros,
888        detail:
889            "build target context (root, canonical target, kind, aliases) before session discovery"
890                .to_string(),
891    });
892    phases.push(SessionReviewPhaseTiming {
893        name: "session_discovery".to_string(),
894        duration_micros: session_discovery_micros,
895        detail: "agent-doc + Claude JSONL + Codex JSONL session candidate discovery and ranking"
896            .to_string(),
897    });
898    phases.push(SessionReviewPhaseTiming {
899        name: "session_digest_total".to_string(),
900        duration_micros: session_digest_micros,
901        detail: "sum of session_digest::compute across matched sessions".to_string(),
902    });
903    phases.push(SessionReviewPhaseTiming {
904        name: "session_cost_total".to_string(),
905        duration_micros: session_cost_micros,
906        detail: "sum of session_cost::compute across matched sessions".to_string(),
907    });
908    phases.push(SessionReviewPhaseTiming {
909        name: "session_aggregation".to_string(),
910        duration_micros: session_aggregation_micros,
911        detail: "per-session prompt/file/symbol/failure aggregation into bounded BTreeMaps"
912            .to_string(),
913    });
914    phases.push(SessionReviewPhaseTiming {
915        name: "report_assembly".to_string(),
916        duration_micros: report_assembly_micros,
917        detail: "post-loop collect_strings + sort + next-context derivation + report construction"
918            .to_string(),
919    });
920
921    Ok((report, phases))
922}
923
924fn build_target_context(target: &Path) -> Result<TargetContext> {
925    let canonical_target = target
926        .canonicalize()
927        .with_context(|| format!("canonicalizing {}", target.display()))?;
928    let root = tsift_quality::lint::resolve_harness_root_or_canonical_path(target)?;
929    let kind = if canonical_target.is_dir() {
930        TargetKind::Directory
931    } else if canonical_target.is_file() {
932        TargetKind::File
933    } else {
934        bail!(
935            "target `{}` is neither a file nor a directory",
936            canonical_target.display()
937        );
938    };
939
940    let relative_target = canonical_target
941        .strip_prefix(&root)
942        .ok()
943        .map(|path| path.to_string_lossy().replace('\\', "/"));
944    let agent_doc_session = (kind == TargetKind::File)
945        .then(|| parse_agent_doc_session(&canonical_target))
946        .transpose()?
947        .flatten();
948
949    let mut path_aliases = BTreeSet::new();
950    path_aliases.insert(canonical_target.display().to_string());
951    if let Some(relative) = &relative_target {
952        path_aliases.insert(relative.clone());
953    }
954    let mut session_aliases = BTreeSet::new();
955    if let Some(session) = &agent_doc_session {
956        session_aliases.insert(session.clone());
957    }
958
959    Ok(TargetContext {
960        root,
961        canonical_target,
962        relative_target,
963        kind,
964        agent_doc_session,
965        path_aliases,
966        session_aliases,
967    })
968}
969
970fn build_next_context(
971    context: &TargetContext,
972    active_prompt_targets: Vec<String>,
973    touched_files: &[SessionReviewFileRef],
974    touched_symbols: &[SessionReviewSymbolRef],
975    failures: &[SessionReviewFailure],
976    guardrails: &[SessionCostGuardrail],
977    last_verification: SessionReviewVerificationState,
978) -> SessionReviewNextContext {
979    let target = context
980        .relative_target
981        .clone()
982        .unwrap_or_else(|| context.canonical_target.display().to_string());
983    let session_target = match context.kind {
984        TargetKind::Directory => ".".to_string(),
985        TargetKind::File => target.clone(),
986    };
987
988    let mut unresolved_failures = failures.to_vec();
989    unresolved_failures.extend(guardrail_next_context_failures(guardrails));
990    let mut next_digest_commands = vec![
991        format!(
992            "tsift session-review --next-context {}",
993            shell_quote(&session_target)
994        ),
995        "tsift diff-digest .".to_string(),
996        "tsift test-digest --path . < test.log".to_string(),
997        "tsift log-digest --path . < build.log".to_string(),
998    ];
999    let graph_targets = extract_backlog_refs(&active_prompt_targets);
1000    for target in &graph_targets {
1001        next_digest_commands.push(format!(
1002            "tsift graph-db --path . evidence {} --depth 3 --limit 8 --json",
1003            shell_quote(target)
1004        ));
1005    }
1006    if !graph_targets.is_empty() {
1007        next_digest_commands.push(format!(
1008            "tsift conflict-matrix --path {} {} --json",
1009            shell_quote(&session_target),
1010            graph_targets
1011                .iter()
1012                .map(|target| shell_quote(target))
1013                .collect::<Vec<_>>()
1014                .join(" ")
1015        ));
1016    }
1017
1018    SessionReviewNextContext {
1019        target,
1020        active_prompt_targets,
1021        last_verification,
1022        touched_files: touched_files
1023            .iter()
1024            .map(|entry| entry.path.clone())
1025            .collect(),
1026        touched_symbols: touched_symbols
1027            .iter()
1028            .map(|entry| entry.symbol.clone())
1029            .collect(),
1030        unresolved_failures,
1031        next_digest_commands,
1032    }
1033}
1034
1035fn extract_backlog_refs(inputs: &[String]) -> Vec<String> {
1036    let mut refs = Vec::new();
1037    let mut seen = BTreeSet::new();
1038    for input in inputs {
1039        for token in input.split(|ch: char| {
1040            !(ch.is_ascii_alphanumeric()
1041                || ch == '#'
1042                || ch == '_'
1043                || ch == '-'
1044                || ch == '['
1045                || ch == ']')
1046        }) {
1047            let Some(hash) = token.find('#') else {
1048                continue;
1049            };
1050            let normalized = token[hash + 1..]
1051                .trim()
1052                .trim_matches(|ch: char| matches!(ch, '[' | ']'))
1053                .trim();
1054            if !normalized.is_empty() && seen.insert(normalized.to_string()) {
1055                refs.push(normalized.to_string());
1056            }
1057        }
1058    }
1059    refs
1060}
1061
1062fn guardrail_next_context_failures(
1063    guardrails: &[SessionCostGuardrail],
1064) -> impl Iterator<Item = SessionReviewFailure> + '_ {
1065    guardrails.iter().map(|guardrail| SessionReviewFailure {
1066        kind: format!("guardrail:{}", guardrail.kind),
1067        message: format!("{} Guidance: {}", guardrail.message, guardrail.guidance),
1068        occurrences: 1,
1069        command: None,
1070        session_path: None,
1071    })
1072}
1073
1074fn collect_document_active_context(context: &TargetContext) -> Result<DocumentActiveContext> {
1075    if context.kind != TargetKind::File {
1076        return Ok(DocumentActiveContext::default());
1077    }
1078    let content = fs::read_to_string(&context.canonical_target).with_context(|| {
1079        format!(
1080            "reading target document {}",
1081            context.canonical_target.display()
1082        )
1083    })?;
1084    let Some(exchange) = extract_agent_component(&content, "exchange") else {
1085        return Ok(DocumentActiveContext::default());
1086    };
1087    let tail = active_exchange_tail(exchange);
1088    let digest = session_digest::compute(&context.root, &tail, Some("markdown"))?;
1089    let fallback_prompt_targets = if digest.prompt_targets.is_empty() {
1090        collect_live_tail_prompt_lines(&tail)
1091    } else {
1092        Vec::new()
1093    };
1094    Ok(DocumentActiveContext {
1095        has_live_tail: has_meaningful_live_tail(&tail),
1096        prompt_targets: if digest.prompt_targets.is_empty() {
1097            fallback_prompt_targets
1098        } else {
1099            digest.prompt_targets
1100        },
1101        touched_files: digest
1102            .touched_files
1103            .into_iter()
1104            .map(|entry| SessionReviewFileRef {
1105                path: entry.path,
1106                occurrences: entry.occurrences,
1107            })
1108            .collect(),
1109        touched_symbols: digest
1110            .touched_symbols
1111            .into_iter()
1112            .map(|entry| SessionReviewSymbolRef {
1113                symbol: entry.symbol,
1114                occurrences: entry.occurrences,
1115            })
1116            .collect(),
1117        failures: digest
1118            .failures
1119            .into_iter()
1120            .map(|entry| SessionReviewFailure {
1121                kind: entry.kind,
1122                message: entry.message,
1123                occurrences: entry.occurrences,
1124                command: entry.command,
1125                session_path: context
1126                    .relative_target
1127                    .clone()
1128                    .or_else(|| Some(context.canonical_target.display().to_string())),
1129            })
1130            .collect(),
1131    })
1132}
1133
1134fn collect_live_tail_prompt_lines(tail: &str) -> Vec<String> {
1135    let mut prompts = Vec::new();
1136    let mut buffer = Vec::new();
1137    for raw_line in tail.lines() {
1138        let Some(line) = meaningful_live_tail_line(raw_line) else {
1139            if !buffer.is_empty() {
1140                prompts.push(buffer.join(" "));
1141                buffer.clear();
1142            }
1143            continue;
1144        };
1145        buffer.push(line.to_string());
1146    }
1147    if !buffer.is_empty() {
1148        prompts.push(buffer.join(" "));
1149    }
1150    prompts
1151}
1152
1153fn has_meaningful_live_tail(tail: &str) -> bool {
1154    tail.lines()
1155        .any(|line| meaningful_live_tail_line(line).is_some())
1156}
1157
1158fn meaningful_live_tail_line(line: &str) -> Option<&str> {
1159    let trimmed = line
1160        .trim()
1161        .strip_prefix("❯ ")
1162        .or_else(|| line.trim().strip_prefix("> "))
1163        .unwrap_or_else(|| line.trim())
1164        .trim();
1165    if trimmed.is_empty()
1166        || trimmed.starts_with("<!--")
1167        || trimmed.starts_with("###")
1168        || trimmed == "#"
1169        || trimmed == "---"
1170    {
1171        return None;
1172    }
1173    Some(trimmed)
1174}
1175
1176fn extract_agent_component<'a>(content: &'a str, name: &str) -> Option<&'a str> {
1177    let open_prefix = format!("<!-- agent:{name}");
1178    let close_marker = format!("<!-- /agent:{name} -->");
1179    let open_start = content.find(&open_prefix)?;
1180    let after_open = content[open_start..].find("-->")? + open_start + 3;
1181    let close_start = content[after_open..].find(&close_marker)? + after_open;
1182    Some(&content[after_open..close_start])
1183}
1184
1185fn active_exchange_tail(exchange: &str) -> String {
1186    let mut start = 0;
1187    for (index, _) in exchange.match_indices("<!-- agent:boundary:") {
1188        let marker_tail = &exchange[index..];
1189        let marker_end = marker_tail
1190            .find("-->")
1191            .map(|offset| index + offset + 3)
1192            .unwrap_or(index);
1193        start = marker_end;
1194    }
1195    let after_boundary = &exchange[start..];
1196    let mut response_seen = false;
1197    let mut prompt_region = String::new();
1198    for line in after_boundary.lines() {
1199        if line.trim_start().starts_with("### Re:") {
1200            response_seen = true;
1201            prompt_region.clear();
1202            continue;
1203        }
1204        if !response_seen
1205            || line.trim_start().starts_with("❯ ")
1206            || line.trim_start().starts_with("> ")
1207        {
1208            prompt_region.push_str(line);
1209            prompt_region.push('\n');
1210        }
1211    }
1212    prompt_region
1213}
1214
1215fn parse_agent_doc_session(path: &Path) -> Result<Option<String>> {
1216    let content = fs::read_to_string(path)
1217        .with_context(|| format!("reading target document {}", path.display()))?;
1218    let mut lines = content.lines();
1219    if lines.next().map(str::trim) != Some("---") {
1220        return Ok(None);
1221    }
1222    for line in lines {
1223        let trimmed = line.trim();
1224        if trimmed == "---" {
1225            break;
1226        }
1227        if let Some(value) = trimmed.strip_prefix("agent_doc_session:") {
1228            let session = value.trim().trim_matches('"').trim_matches('\'');
1229            if !session.is_empty() {
1230                return Ok(Some(session.to_string()));
1231            }
1232        }
1233    }
1234    Ok(None)
1235}
1236
1237fn resolve_claude_projects_dir(root: &Path, options: &SessionReviewOptions) -> PathBuf {
1238    options
1239        .claude_projects_dir
1240        .clone()
1241        .or_else(|| home_dir(root).map(|home| home.join(".claude/projects")))
1242        .unwrap_or_else(|| PathBuf::from(".claude/projects"))
1243}
1244
1245fn resolve_codex_sessions_dir(root: &Path, options: &SessionReviewOptions) -> PathBuf {
1246    options
1247        .codex_sessions_dir
1248        .clone()
1249        .or_else(|| home_dir(root).map(|home| home.join(".codex/sessions")))
1250        .unwrap_or_else(|| PathBuf::from(".codex/sessions"))
1251}
1252
1253fn resolve_agent_doc_logs_dir(root: &Path, options: &SessionReviewOptions) -> PathBuf {
1254    options
1255        .agent_doc_logs_dir
1256        .clone()
1257        .unwrap_or_else(|| root.join(".agent-doc/logs"))
1258}
1259
1260fn home_dir(root: &Path) -> Option<PathBuf> {
1261    std::env::var_os("HOME").map(PathBuf::from).or_else(|| {
1262        let root_home = root.components().take(3).collect::<PathBuf>();
1263        root_home.starts_with("/home").then_some(root_home)
1264    })
1265}
1266
1267fn claude_project_slug(root: &Path) -> String {
1268    root.display().to_string().replace('/', "-")
1269}
1270
1271fn collect_agent_doc_aliases(text: &str, root: &Path) -> AgentDocAliases {
1272    let mut aliases = AgentDocAliases::default();
1273    for line in text.lines() {
1274        let Some((_, detail)) = line.split_once("] ") else {
1275            continue;
1276        };
1277        if let Some(raw) = extract_field(detail, "file") {
1278            let normalized = normalize_relative_path(raw, root);
1279            aliases.path_aliases.insert(normalized);
1280        }
1281        if let Some(raw) = extract_field(detail, "session") {
1282            let session = raw.trim_matches('"');
1283            if !session.is_empty() {
1284                aliases.session_aliases.insert(session.to_string());
1285            }
1286        }
1287    }
1288    aliases
1289}
1290
1291fn maybe_add_agent_doc_candidate(
1292    candidates: &mut BTreeMap<String, PendingSession>,
1293    context: &TargetContext,
1294    path: &Path,
1295) -> Result<()> {
1296    let text = fs::read_to_string(path)
1297        .with_context(|| format!("reading agent-doc log {}", path.display()))?;
1298    let mut matched_by = Vec::new();
1299    if let Some(session_name) = &context.agent_doc_session
1300        && path.file_stem().and_then(|value| value.to_str()) == Some(session_name.as_str())
1301    {
1302        matched_by.push("agent_doc_session".to_string());
1303    }
1304    if context.kind == TargetKind::Directory {
1305        if text.contains(&format!("cwd_resolved path={}", context.root.display())) {
1306            matched_by.push("cwd_resolved".to_string());
1307        }
1308    } else {
1309        for alias in &context.path_aliases {
1310            if text.contains(&format!("file={alias}")) {
1311                matched_by.push(format!("path:{alias}"));
1312            }
1313        }
1314    }
1315    if matched_by.is_empty() {
1316        return Ok(());
1317    }
1318    let modified_unix_secs = file_modified_unix_secs(path)?;
1319    insert_candidate(
1320        candidates,
1321        PendingSession::new(
1322            ReviewSource::AgentDocLog,
1323            path.to_path_buf(),
1324            matched_by,
1325            modified_unix_secs,
1326            text,
1327        ),
1328    );
1329    Ok(())
1330}
1331
1332fn maybe_add_claude_candidate(
1333    candidates: &mut BTreeMap<String, PendingSession>,
1334    context: &TargetContext,
1335    path: &Path,
1336) -> Result<()> {
1337    let Some(text) = read_jsonl_session_text_if_cwd_matches(
1338        path,
1339        context,
1340        "Claude session",
1341        extract_claude_cwd_from_text,
1342    )?
1343    else {
1344        return Ok(());
1345    };
1346    let signals = extract_claude_match_signals(&text);
1347    if !cwd_matches_target(context, signals.cwd.as_deref()) {
1348        return Ok(());
1349    }
1350    let matched_by = match_reasons(context, &signals, signals.cwd.as_deref());
1351    if matched_by.is_empty() {
1352        return Ok(());
1353    }
1354    let modified_unix_secs = file_modified_unix_secs(path)?;
1355    insert_candidate(
1356        candidates,
1357        PendingSession::new(
1358            ReviewSource::ClaudeJsonl,
1359            path.to_path_buf(),
1360            matched_by,
1361            modified_unix_secs,
1362            text,
1363        ),
1364    );
1365    Ok(())
1366}
1367
1368fn maybe_add_codex_candidate(
1369    candidates: &mut BTreeMap<String, PendingSession>,
1370    context: &TargetContext,
1371    path: &Path,
1372) -> Result<()> {
1373    let Some(text) = read_jsonl_session_text_if_cwd_matches(
1374        path,
1375        context,
1376        "Codex session",
1377        extract_codex_cwd_from_text,
1378    )?
1379    else {
1380        return Ok(());
1381    };
1382    let signals = extract_codex_match_signals(&text);
1383    if !cwd_matches_target(context, signals.cwd.as_deref()) {
1384        return Ok(());
1385    }
1386    let matched_by = match_reasons(context, &signals, signals.cwd.as_deref());
1387    if matched_by.is_empty() {
1388        return Ok(());
1389    }
1390    let modified_unix_secs = file_modified_unix_secs(path)?;
1391    insert_candidate(
1392        candidates,
1393        PendingSession::new(
1394            ReviewSource::CodexJsonl,
1395            path.to_path_buf(),
1396            matched_by,
1397            modified_unix_secs,
1398            text,
1399        ),
1400    );
1401    Ok(())
1402}
1403
1404fn extract_claude_match_signals(text: &str) -> MatchSignals {
1405    let mut signals = MatchSignals::default();
1406    for line in text.lines() {
1407        let trimmed = line.trim();
1408        if trimmed.is_empty() {
1409            continue;
1410        }
1411        let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
1412            continue;
1413        };
1414        if signals.cwd.is_none()
1415            && let Some(cwd) = value.get("cwd").and_then(serde_json::Value::as_str)
1416        {
1417            signals.cwd = Some(PathBuf::from(cwd));
1418        }
1419        collect_claude_match_snippets(&value, &mut signals.snippets);
1420    }
1421    signals
1422}
1423
1424fn extract_codex_match_signals(text: &str) -> MatchSignals {
1425    let mut signals = MatchSignals::default();
1426    for line in text.lines() {
1427        let trimmed = line.trim();
1428        if trimmed.is_empty() {
1429            continue;
1430        }
1431        let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
1432            continue;
1433        };
1434        match value.get("type").and_then(serde_json::Value::as_str) {
1435            Some("session_meta") if signals.cwd.is_none() => {
1436                signals.cwd = value
1437                    .get("payload")
1438                    .and_then(|payload| payload.get("cwd"))
1439                    .and_then(serde_json::Value::as_str)
1440                    .map(PathBuf::from);
1441            }
1442            Some("event_msg") => {
1443                if let Some(payload) = value.get("payload")
1444                    && payload.get("type").and_then(serde_json::Value::as_str)
1445                        == Some("user_message")
1446                    && let Some(message) =
1447                        payload.get("message").and_then(serde_json::Value::as_str)
1448                {
1449                    signals.snippets.push(message.to_string());
1450                }
1451            }
1452            Some("response_item") => {
1453                if let Some(payload) = value.get("payload") {
1454                    match payload.get("type").and_then(serde_json::Value::as_str) {
1455                        Some("function_call") => {
1456                            if let Some(arguments) =
1457                                payload.get("arguments").and_then(serde_json::Value::as_str)
1458                            {
1459                                signals.snippets.push(arguments.to_string());
1460                            }
1461                        }
1462                        Some("message") => {
1463                            if payload.get("role").and_then(serde_json::Value::as_str)
1464                                == Some("user")
1465                                && let Some(content) =
1466                                    payload.get("content").and_then(serde_json::Value::as_array)
1467                            {
1468                                for item in content {
1469                                    if let Some(text) = item
1470                                        .get("text")
1471                                        .and_then(serde_json::Value::as_str)
1472                                        .or_else(|| {
1473                                            item.get("content").and_then(serde_json::Value::as_str)
1474                                        })
1475                                    {
1476                                        signals.snippets.push(text.to_string());
1477                                    }
1478                                }
1479                            }
1480                        }
1481                        _ => {}
1482                    }
1483                }
1484            }
1485            _ => {}
1486        }
1487    }
1488    signals
1489}
1490
1491fn cwd_matches_target(context: &TargetContext, cwd: Option<&Path>) -> bool {
1492    let Some(cwd) = cwd else {
1493        return false;
1494    };
1495    let Ok(canonical_cwd) = cwd.canonicalize() else {
1496        return false;
1497    };
1498    canonical_cwd.starts_with(&context.root) || context.root.starts_with(canonical_cwd)
1499}
1500
1501fn match_reasons(
1502    context: &TargetContext,
1503    signals: &MatchSignals,
1504    cwd: Option<&Path>,
1505) -> Vec<String> {
1506    let mut reasons = BTreeSet::new();
1507    match context.kind {
1508        TargetKind::Directory => {
1509            if cwd_matches_target(context, cwd) {
1510                reasons.insert("cwd".to_string());
1511            }
1512        }
1513        TargetKind::File => {
1514            for snippet in &signals.snippets {
1515                for alias in &context.path_aliases {
1516                    if snippet.contains(alias) {
1517                        reasons.insert(format!("path:{alias}"));
1518                    }
1519                }
1520                for session_alias in &context.session_aliases {
1521                    if snippet.contains(session_alias) {
1522                        reasons.insert("agent_doc_session".to_string());
1523                    }
1524                }
1525            }
1526            if reasons.is_empty() {
1527                return Vec::new();
1528            }
1529            if cwd_matches_target(context, cwd) {
1530                reasons.insert("cwd".to_string());
1531            }
1532        }
1533    }
1534    reasons.into_iter().collect()
1535}
1536
1537fn collect_claude_match_snippets(value: &serde_json::Value, out: &mut Vec<String>) {
1538    if let Some(message) = value.get("message") {
1539        collect_claude_message_snippets(message, out);
1540        return;
1541    }
1542    if value.get("attachment").is_some() {
1543        return;
1544    }
1545    collect_claude_message_snippets(value, out);
1546}
1547
1548fn collect_claude_message_snippets(value: &serde_json::Value, out: &mut Vec<String>) {
1549    if let Some(content) = value.get("content") {
1550        match content {
1551            serde_json::Value::String(text) => out.push(text.to_string()),
1552            serde_json::Value::Array(items) => {
1553                for item in items {
1554                    match item.get("type").and_then(serde_json::Value::as_str) {
1555                        Some("text") => {
1556                            if let Some(text) = item
1557                                .get("text")
1558                                .and_then(serde_json::Value::as_str)
1559                                .or_else(|| item.get("content").and_then(serde_json::Value::as_str))
1560                            {
1561                                out.push(text.to_string());
1562                            }
1563                        }
1564                        Some("tool_use") => {
1565                            if let Some(command) = item
1566                                .get("input")
1567                                .and_then(|input| input.get("command"))
1568                                .and_then(serde_json::Value::as_str)
1569                            {
1570                                out.push(command.to_string());
1571                            }
1572                        }
1573                        _ => {}
1574                    }
1575                }
1576            }
1577            _ => {}
1578        }
1579    } else if let Some(text) = value.get("text").and_then(serde_json::Value::as_str) {
1580        out.push(text.to_string());
1581    }
1582}
1583
1584fn insert_candidate(candidates: &mut BTreeMap<String, PendingSession>, pending: PendingSession) {
1585    let key = pending.path.display().to_string();
1586    if let Some(existing) = candidates.get_mut(&key) {
1587        existing.matched_by.extend(pending.matched_by);
1588        existing.modified_unix_secs = existing.modified_unix_secs.max(pending.modified_unix_secs);
1589        return;
1590    }
1591    candidates.insert(key, pending);
1592}
1593
1594fn normalize_relative_path(raw: &str, root: &Path) -> String {
1595    let path = PathBuf::from(raw);
1596    let joined = if path.is_absolute() {
1597        path
1598    } else {
1599        root.join(path)
1600    };
1601    joined
1602        .strip_prefix(root)
1603        .ok()
1604        .unwrap_or(joined.as_path())
1605        .to_string_lossy()
1606        .replace('\\', "/")
1607}
1608
1609fn extract_claude_cwd_from_text(text: &str) -> Option<PathBuf> {
1610    for line in text.lines() {
1611        let trimmed = line.trim();
1612        if trimmed.is_empty() {
1613            continue;
1614        }
1615        let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
1616            continue;
1617        };
1618        if let Some(cwd) = value.get("cwd").and_then(serde_json::Value::as_str) {
1619            return Some(PathBuf::from(cwd));
1620        }
1621    }
1622    None
1623}
1624
1625fn extract_codex_cwd_from_text(text: &str) -> Option<PathBuf> {
1626    for line in text.lines() {
1627        let trimmed = line.trim();
1628        if trimmed.is_empty() {
1629            continue;
1630        }
1631        let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
1632            continue;
1633        };
1634        if value.get("type").and_then(serde_json::Value::as_str) == Some("session_meta")
1635            && let Some(cwd) = value
1636                .get("payload")
1637                .and_then(|payload| payload.get("cwd"))
1638                .and_then(serde_json::Value::as_str)
1639        {
1640            return Some(PathBuf::from(cwd));
1641        }
1642    }
1643    None
1644}
1645
1646fn read_jsonl_session_text_if_cwd_matches(
1647    path: &Path,
1648    context: &TargetContext,
1649    label: &str,
1650    extract_cwd: fn(&str) -> Option<PathBuf>,
1651) -> Result<Option<String>> {
1652    let file =
1653        fs::File::open(path).with_context(|| format!("reading {label} {}", path.display()))?;
1654    let mut reader = BufReader::new(file);
1655    let mut header = String::new();
1656    let mut line = String::new();
1657    let mut cwd: Option<PathBuf> = None;
1658    loop {
1659        line.clear();
1660        let bytes = reader
1661            .read_line(&mut line)
1662            .with_context(|| format!("reading {label} {}", path.display()))?;
1663        if bytes == 0 {
1664            break;
1665        }
1666        header.push_str(&line);
1667        cwd = extract_cwd(&header);
1668        if cwd.is_some() || header.len() >= SESSION_HEADER_PROBE_BUDGET_BYTES {
1669            break;
1670        }
1671    }
1672    if !cwd_matches_target(context, cwd.as_deref()) {
1673        return Ok(None);
1674    }
1675    let mut rest = String::new();
1676    reader
1677        .read_to_string(&mut rest)
1678        .with_context(|| format!("reading {label} {}", path.display()))?;
1679    header.push_str(&rest);
1680    Ok(Some(header))
1681}
1682
1683fn collect_files_with_extension(root: &Path, extension: &str) -> Result<Vec<PathBuf>> {
1684    let mut files = Vec::new();
1685    collect_files_with_extension_inner(root, extension, &mut files)?;
1686    Ok(files)
1687}
1688
1689fn collect_recent_files_with_extension(
1690    root: &Path,
1691    extension: &str,
1692    limit: usize,
1693) -> Result<Vec<PathBuf>> {
1694    let mut entries: Vec<(Option<u64>, PathBuf)> = Vec::new();
1695    collect_recent_files_with_extension_inner(root, extension, &mut entries)?;
1696    entries.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
1697    entries.truncate(limit);
1698    Ok(entries.into_iter().map(|(_, path)| path).collect())
1699}
1700
1701fn collect_recent_files_with_extension_inner(
1702    root: &Path,
1703    extension: &str,
1704    entries: &mut Vec<(Option<u64>, PathBuf)>,
1705) -> Result<()> {
1706    for entry in fs::read_dir(root).with_context(|| format!("reading {}", root.display()))? {
1707        let entry = entry?;
1708        let path = entry.path();
1709        if path.is_dir() {
1710            collect_recent_files_with_extension_inner(&path, extension, entries)?;
1711        } else if path.extension().and_then(|value| value.to_str()) == Some(extension) {
1712            let modified = file_modified_unix_secs(&path).unwrap_or(None);
1713            entries.push((modified, path));
1714        }
1715    }
1716    Ok(())
1717}
1718
1719fn collect_files_with_extension_inner(
1720    root: &Path,
1721    extension: &str,
1722    files: &mut Vec<PathBuf>,
1723) -> Result<()> {
1724    for entry in fs::read_dir(root).with_context(|| format!("reading {}", root.display()))? {
1725        let entry = entry?;
1726        let path = entry.path();
1727        if path.is_dir() {
1728            collect_files_with_extension_inner(&path, extension, files)?;
1729        } else if path.extension().and_then(|value| value.to_str()) == Some(extension) {
1730            files.push(path);
1731        }
1732    }
1733    Ok(())
1734}
1735
1736fn file_modified_unix_secs(path: &Path) -> Result<Option<u64>> {
1737    let modified = fs::metadata(path)
1738        .with_context(|| format!("reading metadata for {}", path.display()))?
1739        .modified()
1740        .ok();
1741    Ok(modified
1742        .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
1743        .map(|duration| duration.as_secs()))
1744}
1745
1746fn extract_field<'a>(detail: &'a str, key: &str) -> Option<&'a str> {
1747    let needle = format!("{key}=");
1748    let start = detail.find(&needle)? + needle.len();
1749    let remainder = &detail[start..];
1750    let end = remainder
1751        .find(char::is_whitespace)
1752        .unwrap_or(remainder.len());
1753    Some(remainder[..end].trim_matches('"'))
1754}
1755
1756fn collect_strings<T, F>(entries: BTreeMap<String, usize>, max_items: usize, build: F) -> Vec<T>
1757where
1758    F: Fn(String, usize) -> T,
1759{
1760    let mut rows = entries.into_iter().collect::<Vec<_>>();
1761    rows.sort_by(|left, right| right.1.cmp(&left.1).then(left.0.cmp(&right.0)));
1762    rows.truncate(max_items);
1763    rows.into_iter()
1764        .map(|(value, count)| build(value, count))
1765        .collect()
1766}
1767
1768fn collect_pairs<K, T, F>(entries: BTreeMap<K, usize>, max_items: usize, build: F) -> Vec<T>
1769where
1770    K: Ord,
1771    F: Fn(K, usize) -> T,
1772{
1773    let mut rows = entries.into_iter().collect::<Vec<_>>();
1774    rows.sort_by(|left, right| right.1.cmp(&left.1).then(left.0.cmp(&right.0)));
1775    rows.truncate(max_items);
1776    rows.into_iter()
1777        .map(|(value, count)| build(value, count))
1778        .collect()
1779}
1780
1781fn collect_restart_churn(
1782    entries: BTreeMap<String, RestartChurnSummary>,
1783    max_items: usize,
1784) -> Vec<RestartChurnSummary> {
1785    let mut rows = entries.into_values().collect::<Vec<_>>();
1786    rows.sort_by(|left, right| {
1787        right
1788            .occurrences
1789            .cmp(&left.occurrences)
1790            .then(left.family.cmp(&right.family))
1791    });
1792    rows.truncate(max_items);
1793    rows
1794}
1795
1796fn collect_loop_clusters(
1797    entries: BTreeMap<(String, String), (usize, usize)>,
1798    max_items: usize,
1799) -> Vec<SessionCostLoopCluster> {
1800    let mut rows = entries
1801        .into_iter()
1802        .map(
1803            |((kind, label), (occurrences, max_consecutive))| SessionCostLoopCluster {
1804                kind,
1805                label,
1806                occurrences,
1807                max_consecutive,
1808            },
1809        )
1810        .collect::<Vec<_>>();
1811    rows.sort_by(|left, right| {
1812        right
1813            .occurrences
1814            .cmp(&left.occurrences)
1815            .then(right.max_consecutive.cmp(&left.max_consecutive))
1816            .then(left.kind.cmp(&right.kind))
1817            .then(left.label.cmp(&right.label))
1818    });
1819    rows.truncate(max_items);
1820    rows
1821}
1822
1823fn collect_file_read_diagnostics(
1824    entries: BTreeMap<(String, String), FileReadDiagnosticAggregate>,
1825    max_items: usize,
1826) -> Vec<SessionCostFileReadDiagnostic> {
1827    let mut rows = entries
1828        .into_values()
1829        .map(|entry| SessionCostFileReadDiagnostic {
1830            path: entry.path,
1831            range: entry.range,
1832            occurrences: entry.occurrences,
1833            estimated_tokens: entry.estimated_tokens,
1834            duplicate_estimated_tokens: entry.duplicate_estimated_tokens,
1835            follow_up_commands: entry.follow_up_commands.into_iter().collect(),
1836        })
1837        .collect::<Vec<_>>();
1838    rows.sort_by(|left, right| {
1839        right
1840            .duplicate_estimated_tokens
1841            .cmp(&left.duplicate_estimated_tokens)
1842            .then(right.occurrences.cmp(&left.occurrences))
1843            .then(left.path.cmp(&right.path))
1844            .then(left.range.cmp(&right.range))
1845    });
1846    rows.truncate(max_items);
1847    rows
1848}
1849
1850fn shell_quote(text: &str) -> String {
1851    if text.chars().any(char::is_whitespace) {
1852        format!("{text:?}")
1853    } else {
1854        text.to_string()
1855    }
1856}
1857
1858#[cfg(test)]
1859mod tests {
1860    use super::*;
1861
1862    #[test]
1863    fn collect_recent_files_with_extension_caps_and_sorts_by_mtime() {
1864        let dir = tempfile::tempdir().unwrap();
1865        for i in 0..10 {
1866            let path = dir.path().join(format!("session-{i:02}.jsonl"));
1867            fs::write(&path, format!("{{\"i\":{i}}}\n")).unwrap();
1868            let file = fs::OpenOptions::new().write(true).open(&path).unwrap();
1869            let modified = std::time::SystemTime::UNIX_EPOCH
1870                + std::time::Duration::from_secs(1_700_000_000 + i as u64 * 60);
1871            file.set_modified(modified).unwrap();
1872        }
1873        fs::write(dir.path().join("ignored.txt"), "skip me").unwrap();
1874
1875        let recent = collect_recent_files_with_extension(dir.path(), "jsonl", 3).unwrap();
1876        assert_eq!(recent.len(), 3, "should cap at 3 entries");
1877        let names: Vec<String> = recent
1878            .iter()
1879            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
1880            .collect();
1881        assert_eq!(
1882            names,
1883            vec![
1884                "session-09.jsonl".to_string(),
1885                "session-08.jsonl".to_string(),
1886                "session-07.jsonl".to_string(),
1887            ],
1888            "should return newest-first by mtime"
1889        );
1890
1891        let all = collect_recent_files_with_extension(dir.path(), "jsonl", 100).unwrap();
1892        assert_eq!(
1893            all.len(),
1894            10,
1895            "limit above population should return everything"
1896        );
1897        assert!(
1898            !all.iter()
1899                .any(|p| p.extension().and_then(|s| s.to_str()) == Some("txt")),
1900            "non-matching extensions must be filtered: {all:?}"
1901        );
1902    }
1903
1904    #[test]
1905    fn read_jsonl_session_text_if_cwd_matches_skips_non_matching_files_without_full_read() {
1906        let dir = tempfile::tempdir().unwrap();
1907        let target_root = dir.path().canonicalize().unwrap();
1908        let target = target_root.join("plan.md");
1909        fs::create_dir(target_root.join(".git")).unwrap();
1910        fs::write(&target, "---\nagent_doc_session: x\n---\n").unwrap();
1911        let context = build_target_context(&target).unwrap();
1912
1913        let matching = dir.path().join("matching.jsonl");
1914        let matching_cwd = target_root.display().to_string();
1915        let matching_body = format!(
1916            "{{\"cwd\":\"{matching_cwd}\"}}\n{}\n",
1917            "x".repeat(64 * 1024)
1918        );
1919        fs::write(&matching, &matching_body).unwrap();
1920
1921        let other = dir.path().join("other.jsonl");
1922        fs::write(
1923            &other,
1924            format!(
1925                "{{\"cwd\":\"/tmp/other-project-{}\"}}\n{}\n",
1926                std::process::id(),
1927                "y".repeat(64 * 1024)
1928            ),
1929        )
1930        .unwrap();
1931
1932        let matched = read_jsonl_session_text_if_cwd_matches(
1933            &matching,
1934            &context,
1935            "test",
1936            extract_claude_cwd_from_text,
1937        )
1938        .unwrap();
1939        assert!(
1940            matched.is_some(),
1941            "file with matching cwd should return Some(text)"
1942        );
1943        let skipped = read_jsonl_session_text_if_cwd_matches(
1944            &other,
1945            &context,
1946            "test",
1947            extract_claude_cwd_from_text,
1948        )
1949        .unwrap();
1950        assert!(
1951            skipped.is_none(),
1952            "file with non-matching cwd should return None"
1953        );
1954    }
1955
1956    #[test]
1957    fn session_review_discovers_cross_harness_logs_for_doc_target() {
1958        let root = tempfile::tempdir().unwrap();
1959        let home = tempfile::tempdir().unwrap();
1960        let target = root.path().join("tasks/software/tsift.md");
1961        fs::create_dir(root.path().join(".git")).unwrap();
1962        fs::create_dir_all(target.parent().unwrap()).unwrap();
1963        fs::write(
1964            &target,
1965            "---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n",
1966        )
1967        .unwrap();
1968
1969        let agent_doc_logs = root.path().join(".agent-doc/logs");
1970        fs::create_dir_all(&agent_doc_logs).unwrap();
1971        fs::write(
1972            agent_doc_logs.join("tsift-v0.1.log"),
1973            concat!(
1974                "[1776712372] session_start file=tasks/software/tsift.md pane=%77 session=tsift-v0.1\n",
1975                "[1776712373] cwd_resolved path=/tmp/replace-me source=project_root\n",
1976                "[1776712374] codex_start mode=fresh restart_count=0\n",
1977                "[1776712375] auto_trigger_timeout harness=codex reason=no_prompt_after_30s\n"
1978            )
1979            .replace("/tmp/replace-me", &root.path().display().to_string()),
1980        )
1981        .unwrap();
1982
1983        let claude_dir = home
1984            .path()
1985            .join(".claude/projects")
1986            .join(claude_project_slug(root.path()));
1987        fs::create_dir_all(&claude_dir).unwrap();
1988        fs::write(
1989            claude_dir.join("claude.jsonl"),
1990            concat!(
1991                r#"{"cwd":"/tmp/replace-me","message":{"role":"user","content":"agent-doc /tmp/replace-me/tasks/software/tsift.md"}}"#,
1992                "\n",
1993                r#"{"message":{"role":"assistant","id":"msg-1","usage":{"input_tokens":200,"cache_creation_input_tokens":20,"cache_read_input_tokens":180,"output_tokens":15},"content":[{"type":"tool_use","name":"Bash","input":{"command":"cargo test"}}]}}"#,
1994                "\n"
1995            )
1996            .replace("/tmp/replace-me", &root.path().display().to_string()),
1997        )
1998        .unwrap();
1999
2000        let codex_dir = home.path().join(".codex/sessions/2026/05/05");
2001        fs::create_dir_all(&codex_dir).unwrap();
2002        fs::write(
2003            codex_dir.join("rollout-1.jsonl"),
2004            concat!(
2005                r#"{"type":"session_meta","payload":{"cwd":"/tmp/replace-me"}}"#,
2006                "\n",
2007                r#"{"type":"event_msg","payload":{"type":"user_message","message":"agent-doc /tmp/replace-me/tasks/software/tsift.md"}}"#,
2008                "\n",
2009                r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo build --release\"}"}}"#,
2010                "\n",
2011                r#"{"timestamp":"2026-05-05T00:00:01Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1000,"cached_input_tokens":900,"output_tokens":50,"reasoning_output_tokens":10,"total_tokens":1050}}}}"#,
2012                "\n"
2013            )
2014            .replace("/tmp/replace-me", &root.path().display().to_string()),
2015        )
2016        .unwrap();
2017
2018        let report = compute_with_options(
2019            &target,
2020            &SessionReviewOptions {
2021                claude_projects_dir: Some(home.path().join(".claude/projects")),
2022                codex_sessions_dir: Some(home.path().join(".codex/sessions")),
2023                agent_doc_logs_dir: Some(agent_doc_logs),
2024            },
2025        )
2026        .unwrap();
2027
2028        assert_eq!(report.target_kind, "file");
2029        assert_eq!(report.sessions_matched, 3);
2030        assert_eq!(report.claude_sessions, 1);
2031        assert_eq!(report.codex_sessions, 1);
2032        assert_eq!(report.agent_doc_logs, 1);
2033        assert!(report.prompt_tokens >= 1200);
2034        assert!(
2035            report
2036                .guardrails
2037                .iter()
2038                .any(|guardrail| guardrail.kind == "restart_loop")
2039        );
2040        assert!(
2041            report
2042                .next_context
2043                .unresolved_failures
2044                .iter()
2045                .any(|failure| failure.kind == "guardrail:restart_loop"
2046                    && failure.message.contains("restart churn detected"))
2047        );
2048        assert!(
2049            report
2050                .commands
2051                .iter()
2052                .any(|command| command.command == "cargo test")
2053        );
2054        assert!(
2055            report
2056                .commands
2057                .iter()
2058                .any(|command| command.command == "cargo build --release")
2059        );
2060        assert!(report.sessions.iter().any(|session| {
2061            session
2062                .matched_by
2063                .iter()
2064                .any(|reason| reason == "agent_doc_session")
2065        }));
2066        assert_eq!(
2067            report.next_context.active_prompt_targets,
2068            Vec::<String>::new()
2069        );
2070        assert_eq!(report.next_context.last_verification.status, "missing");
2071        assert!(report.next_context.next_digest_commands.iter().any(
2072            |command| command == "tsift session-review --next-context tasks/software/tsift.md"
2073        ));
2074    }
2075
2076    #[test]
2077    fn session_review_next_context_tracks_prompts_verification_and_failures() {
2078        let root = tempfile::tempdir().unwrap();
2079        let home = tempfile::tempdir().unwrap();
2080        let target = root.path().join("tasks/software/tsift.md");
2081        fs::create_dir(root.path().join(".git")).unwrap();
2082        fs::create_dir_all(target.parent().unwrap()).unwrap();
2083        fs::create_dir_all(root.path().join("src")).unwrap();
2084        fs::write(root.path().join("src/lib.rs"), "fn run_sync() {}\n").unwrap();
2085        fs::write(
2086            &target,
2087            "---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n",
2088        )
2089        .unwrap();
2090
2091        let agent_doc_logs = root.path().join(".agent-doc/logs");
2092        fs::create_dir_all(&agent_doc_logs).unwrap();
2093        fs::write(
2094            agent_doc_logs.join("tsift-v0.1.log"),
2095            concat!(
2096                "[1776712372] session_start file=tasks/software/tsift.md pane=%77 session=tsift-v0.1\n",
2097                "[1776712373] cwd_resolved path=/tmp/replace-me source=project_root\n"
2098            )
2099            .replace("/tmp/replace-me", &root.path().display().to_string()),
2100        )
2101        .unwrap();
2102
2103        let claude_dir = home
2104            .path()
2105            .join(".claude/projects")
2106            .join(claude_project_slug(root.path()));
2107        fs::create_dir_all(&claude_dir).unwrap();
2108        fs::write(
2109            claude_dir.join("claude.jsonl"),
2110            concat!(
2111                r#"{"cwd":"/tmp/replace-me","message":{"role":"user","content":"do [#ctxpack]. spec-test-build-install-commit-push\nagent-doc /tmp/replace-me/tasks/software/tsift.md"}}"#,
2112                "\n",
2113                r#"{"message":{"role":"assistant","id":"msg-1","usage":{"input_tokens":300,"cache_creation_input_tokens":30,"cache_read_input_tokens":250,"output_tokens":25},"content":[{"type":"tool_use","name":"Bash","input":{"command":"cargo test --manifest-path Cargo.toml"}},{"type":"text","text":"Verification in `src/tsift`: `cargo test`\nError: Symbol `run_sync` not found in src/lib.rs:7:9"}]}}"#,
2114                "\n"
2115            )
2116            .replace("/tmp/replace-me", &root.path().display().to_string()),
2117        )
2118        .unwrap();
2119
2120        let codex_dir = home.path().join(".codex/sessions/2026/05/05");
2121        fs::create_dir_all(&codex_dir).unwrap();
2122        fs::write(
2123            codex_dir.join("rollout-1.jsonl"),
2124            concat!(
2125                r#"{"type":"session_meta","payload":{"cwd":"/tmp/replace-me"}}"#,
2126                "\n",
2127                r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#ctxpack]. spec-test-build-install-commit-push\nagent-doc /tmp/replace-me/tasks/software/tsift.md"}}"#,
2128                "\n"
2129            )
2130            .replace("/tmp/replace-me", &root.path().display().to_string()),
2131        )
2132        .unwrap();
2133
2134        let report = compute_with_options(
2135            &target,
2136            &SessionReviewOptions {
2137                claude_projects_dir: Some(home.path().join(".claude/projects")),
2138                codex_sessions_dir: Some(home.path().join(".codex/sessions")),
2139                agent_doc_logs_dir: Some(agent_doc_logs),
2140            },
2141        )
2142        .unwrap();
2143
2144        assert_eq!(
2145            report.next_context.active_prompt_targets,
2146            vec!["do [#ctxpack]. spec-test-build-install-commit-push".to_string()]
2147        );
2148        assert_eq!(report.next_context.last_verification.status, "passed");
2149        assert!(
2150            report
2151                .next_context
2152                .last_verification
2153                .detail
2154                .contains("Verification in `src/tsift`")
2155        );
2156        assert!(
2157            report
2158                .next_context
2159                .touched_files
2160                .iter()
2161                .any(|path| path == "Cargo.toml")
2162        );
2163        assert!(
2164            report
2165                .next_context
2166                .touched_symbols
2167                .iter()
2168                .any(|symbol| symbol == "run_sync")
2169        );
2170        assert!(
2171            report
2172                .next_context
2173                .unresolved_failures
2174                .iter()
2175                .any(|failure| failure.kind == "missing" || failure.kind == "error")
2176        );
2177    }
2178
2179    #[test]
2180    fn session_review_next_context_prefers_live_exchange_prompt_targets() {
2181        let root = tempfile::tempdir().unwrap();
2182        let home = tempfile::tempdir().unwrap();
2183        let target = root.path().join("tasks/software/tsift.md");
2184        fs::create_dir(root.path().join(".git")).unwrap();
2185        fs::create_dir_all(target.parent().unwrap()).unwrap();
2186        fs::write(
2187            &target,
2188            "\
2189---
2190agent_doc_session: tsift-v0.1
2191agent_doc_format: template
2192prompt_presets:
2193  '#spec-test-build-install-commit-push': update spec + tests. build + install for local testing. commit + push
2194---
2195
2196## Exchange
2197
2198<!-- agent:exchange patch=append -->
2199### Session Summary
2200
2201Compacted content:
2202- Archived 2 response topic(s): #old1 search workflow; #old2 build workflow
2203<!-- agent:boundary:abc123 -->
2204do [#active]. spec-test-build-install-commit-push
2205<!-- /agent:exchange -->
2206
2207## Completed / Reaped
2208
2209<!-- agent:done -->
2210- 2026-05-12 [#old1] do [#old1]. spec-test-build-install-commit-push
2211<!-- /agent:done -->
2212",
2213        )
2214        .unwrap();
2215
2216        let agent_doc_logs = root.path().join(".agent-doc/logs");
2217        fs::create_dir_all(&agent_doc_logs).unwrap();
2218        fs::write(
2219            agent_doc_logs.join("tsift-v0.1.log"),
2220            concat!(
2221                "[1776712372] session_start file=tasks/software/tsift.md pane=%77 session=tsift-v0.1\n",
2222                "[1776712373] cwd_resolved path=/tmp/replace-me source=project_root\n"
2223            )
2224            .replace("/tmp/replace-me", &root.path().display().to_string()),
2225        )
2226        .unwrap();
2227
2228        let codex_dir = home.path().join(".codex/sessions/2026/05/05");
2229        fs::create_dir_all(&codex_dir).unwrap();
2230        fs::write(
2231            codex_dir.join("rollout-old.jsonl"),
2232            concat!(
2233                r#"{"type":"session_meta","payload":{"cwd":"/tmp/replace-me"}}"#,
2234                "\n",
2235                r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#old1]. spec-test-build-install-commit-push\nagent-doc /tmp/replace-me/tasks/software/tsift.md"}}"#,
2236                "\n",
2237                r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#old1]. spec-test-build-install-commit-push"}}"#,
2238                "\n",
2239                r####"{"type":"event_msg","payload":{"type":"agent_message","message":"### Re: old work\nError: stale failure at /!\n`/!` should not become active handoff context"}}"####,
2240                "\n"
2241            )
2242            .replace("/tmp/replace-me", &root.path().display().to_string()),
2243        )
2244        .unwrap();
2245
2246        let report = compute_with_options(
2247            &target,
2248            &SessionReviewOptions {
2249                claude_projects_dir: Some(home.path().join(".claude/projects")),
2250                codex_sessions_dir: Some(home.path().join(".codex/sessions")),
2251                agent_doc_logs_dir: Some(agent_doc_logs),
2252            },
2253        )
2254        .unwrap();
2255
2256        assert!(
2257            report
2258                .prompt_targets
2259                .iter()
2260                .any(|prompt| { prompt.text == "do [#old1]. spec-test-build-install-commit-push" })
2261        );
2262        assert_eq!(
2263            report.next_context.active_prompt_targets,
2264            vec!["do [#active]. spec-test-build-install-commit-push".to_string()]
2265        );
2266        assert!(
2267            report
2268                .touched_files
2269                .iter()
2270                .all(|file_ref| file_ref.path != "/!")
2271        );
2272        assert!(
2273            report
2274                .failures
2275                .iter()
2276                .any(|failure| failure.message.contains("stale failure"))
2277        );
2278        assert!(
2279            report
2280                .next_context
2281                .touched_files
2282                .iter()
2283                .all(|path| path != "/!")
2284        );
2285        assert!(report.next_context.unresolved_failures.is_empty());
2286    }
2287
2288    #[test]
2289    fn session_review_next_context_scopes_freeform_live_exchange_tail() {
2290        let root = tempfile::tempdir().unwrap();
2291        let home = tempfile::tempdir().unwrap();
2292        let target = root.path().join("tasks/software/tsift.md");
2293        fs::create_dir(root.path().join(".git")).unwrap();
2294        fs::create_dir_all(target.parent().unwrap()).unwrap();
2295        fs::write(
2296            &target,
2297            "\
2298---
2299agent_doc_session: tsift-v0.1
2300agent_doc_format: template
2301---
2302
2303## Exchange
2304
2305<!-- agent:exchange patch=append -->
2306### Session Summary
2307
2308*Compacted. Content archived to `/tmp/archive.md`*
2309
2310Compacted content:
2311- Archived 1 response topic(s): prior review
2312<!-- agent:boundary:freeform -->
2313Evaluate the logs for tsift effectiveness and bugs. #next-steps
2314<!-- /agent:exchange -->
2315",
2316        )
2317        .unwrap();
2318
2319        let agent_doc_logs = root.path().join(".agent-doc/logs");
2320        fs::create_dir_all(&agent_doc_logs).unwrap();
2321        fs::write(
2322            agent_doc_logs.join("tsift-v0.1.log"),
2323            concat!(
2324                "[1776712372] session_start file=tasks/software/tsift.md pane=%77 session=tsift-v0.1\n",
2325                "[1776712373] cwd_resolved path=/tmp/replace-me source=project_root\n"
2326            )
2327            .replace("/tmp/replace-me", &root.path().display().to_string()),
2328        )
2329        .unwrap();
2330
2331        let codex_dir = home.path().join(".codex/sessions/2026/05/05");
2332        fs::create_dir_all(&codex_dir).unwrap();
2333        fs::write(
2334            codex_dir.join("rollout-stale.jsonl"),
2335            concat!(
2336                r#"{"type":"session_meta","payload":{"cwd":"/tmp/replace-me"}}"#,
2337                "\n",
2338                r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#stale]. spec-test-build-install-commit-push\nagent-doc /tmp/replace-me/tasks/software/tsift.md"}}"#,
2339                "\n",
2340                r####"{"type":"event_msg","payload":{"type":"agent_message","message":"### Re: stale work\nError: old unresolved failure at /!\n`/!` should not be active context"}}"####,
2341                "\n"
2342            )
2343            .replace("/tmp/replace-me", &root.path().display().to_string()),
2344        )
2345        .unwrap();
2346
2347        let report = compute_with_options(
2348            &target,
2349            &SessionReviewOptions {
2350                claude_projects_dir: Some(home.path().join(".claude/projects")),
2351                codex_sessions_dir: Some(home.path().join(".codex/sessions")),
2352                agent_doc_logs_dir: Some(agent_doc_logs),
2353            },
2354        )
2355        .unwrap();
2356
2357        assert_eq!(
2358            report.next_context.active_prompt_targets,
2359            vec!["Evaluate the logs for tsift effectiveness and bugs. #next-steps".to_string()]
2360        );
2361        assert!(report.next_context.touched_files.is_empty());
2362        assert!(report.next_context.unresolved_failures.is_empty());
2363    }
2364
2365    #[test]
2366    fn session_review_ignores_assistant_failure_meta_progress() {
2367        let root = tempfile::tempdir().unwrap();
2368        let home = tempfile::tempdir().unwrap();
2369        let target = root.path().join("tasks/software/tsift.md");
2370        fs::create_dir(root.path().join(".git")).unwrap();
2371        fs::create_dir_all(target.parent().unwrap()).unwrap();
2372        fs::write(
2373            &target,
2374            "\
2375---
2376agent_doc_session: tsift-v0.1
2377agent_doc_format: template
2378---
2379
2380## Exchange
2381
2382<!-- agent:exchange patch=append -->
2383### Session Summary
2384
2385Prior summary without active failures.
2386<!-- agent:boundary:abc123 -->
2387<!-- /agent:exchange -->
2388",
2389        )
2390        .unwrap();
2391
2392        let agent_doc_logs = root.path().join(".agent-doc/logs");
2393        fs::create_dir_all(&agent_doc_logs).unwrap();
2394        fs::write(
2395            agent_doc_logs.join("tsift-v0.1.log"),
2396            concat!(
2397                "[1776712372] session_start file=tasks/software/tsift.md pane=%77 session=tsift-v0.1\n",
2398                "[1776712373] cwd_resolved path=/tmp/replace-me source=project_root\n"
2399            )
2400            .replace("/tmp/replace-me", &root.path().display().to_string()),
2401        )
2402        .unwrap();
2403
2404        let codex_dir = home.path().join(".codex/sessions/2026/05/05");
2405        fs::create_dir_all(&codex_dir).unwrap();
2406        fs::write(
2407            codex_dir.join("rollout-progress.jsonl"),
2408            concat!(
2409                r#"{"type":"session_meta","payload":{"cwd":"/tmp/replace-me"}}"#,
2410                "\n",
2411                r#"{"type":"event_msg","payload":{"type":"user_message","message":"agent-doc /tmp/replace-me/tasks/software/tsift.md"}}"#,
2412                "\n",
2413                r#"{"type":"event_msg","payload":{"type":"agent_message","message":"I’m checking the session-review failure groups because --next-context reports zero unresolved failures.\nThe previous assessment sentence mentioned failure false positives and prior status updates around red CI checks.\nCI status prose from the progress update should not become a failure row."}}"#,
2414                "\n"
2415            )
2416            .replace("/tmp/replace-me", &root.path().display().to_string()),
2417        )
2418        .unwrap();
2419
2420        let report = compute_with_options(
2421            &target,
2422            &SessionReviewOptions {
2423                claude_projects_dir: Some(home.path().join(".claude/projects")),
2424                codex_sessions_dir: Some(home.path().join(".codex/sessions")),
2425                agent_doc_logs_dir: Some(agent_doc_logs),
2426            },
2427        )
2428        .unwrap();
2429
2430        assert_eq!(report.sessions_matched, 2);
2431        assert!(report.failures.is_empty());
2432        assert!(report.next_context.unresolved_failures.is_empty());
2433    }
2434
2435    #[test]
2436    fn session_review_failure_rows_keep_command_and_session_anchors() {
2437        let root = tempfile::tempdir().unwrap();
2438        let home = tempfile::tempdir().unwrap();
2439        let target = root.path().join("tasks/software/tsift.md");
2440        fs::create_dir(root.path().join(".git")).unwrap();
2441        fs::create_dir_all(target.parent().unwrap()).unwrap();
2442        fs::write(
2443            &target,
2444            "---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n",
2445        )
2446        .unwrap();
2447
2448        let agent_doc_logs = root.path().join(".agent-doc/logs");
2449        fs::create_dir_all(&agent_doc_logs).unwrap();
2450        fs::write(
2451            agent_doc_logs.join("tsift-v0.1.log"),
2452            concat!(
2453                "[1776712372] session_start file=tasks/software/tsift.md pane=%77 session=tsift-v0.1\n",
2454                "[1776712373] cwd_resolved path=/tmp/replace-me source=project_root\n"
2455            )
2456            .replace("/tmp/replace-me", &root.path().display().to_string()),
2457        )
2458        .unwrap();
2459
2460        let codex_dir = home.path().join(".codex/sessions/2026/05/05");
2461        fs::create_dir_all(&codex_dir).unwrap();
2462        let rollout_path = codex_dir.join("rollout-failure.jsonl");
2463        fs::write(
2464            &rollout_path,
2465            concat!(
2466                r#"{"type":"session_meta","payload":{"cwd":"/tmp/replace-me"}}"#,
2467                "\n",
2468                r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#sfail]. Tighten failure extraction.\nagent-doc /tmp/replace-me/tasks/software/tsift.md"}}"#,
2469                "\n",
2470                r#"{"type":"event_msg","payload":{"type":"exec_command_end","exit_code":1,"aggregated_output":"After finalize, panic snippets and generic command exited with code 1 should not become failures.\npanic!(\"expected simulated swap failure\");\nthread 'suite::alpha_failure' panicked at src/lib.rs:3:5:\nassertion failed: left == right\n","parsed_cmd":[{"type":"unknown","cmd":"cargo test"}]}}"#,
2471                "\n"
2472            )
2473            .replace("/tmp/replace-me", &root.path().display().to_string()),
2474        )
2475        .unwrap();
2476
2477        let report = compute_with_options(
2478            &target,
2479            &SessionReviewOptions {
2480                claude_projects_dir: Some(home.path().join(".claude/projects")),
2481                codex_sessions_dir: Some(home.path().join(".codex/sessions")),
2482                agent_doc_logs_dir: Some(agent_doc_logs),
2483            },
2484        )
2485        .unwrap();
2486
2487        assert!(
2488            report
2489                .failures
2490                .iter()
2491                .all(|failure| !failure.message.contains("After finalize")
2492                    && !failure.message.contains("panic!(")
2493                    && failure.message != "command exited with code 1")
2494        );
2495        assert!(report.failures.iter().any(|failure| {
2496            failure.message == "cargo test exited with code 1"
2497                && failure.command.as_deref() == Some("cargo test")
2498                && failure.session_path.as_deref() == Some(rollout_path.to_str().unwrap())
2499        }));
2500        assert!(report.failures.iter().any(|failure| {
2501            failure.message.contains("assertion failed")
2502                && failure.command.as_deref() == Some("cargo test")
2503                && failure.session_path.as_deref() == Some(rollout_path.to_str().unwrap())
2504        }));
2505    }
2506
2507    #[test]
2508    fn session_review_aggregates_loop_clusters() {
2509        let root = tempfile::tempdir().unwrap();
2510        let home = tempfile::tempdir().unwrap();
2511        let target = root.path().join("tasks/software/tsift.md");
2512        fs::create_dir(root.path().join(".git")).unwrap();
2513        fs::create_dir_all(target.parent().unwrap()).unwrap();
2514        fs::write(
2515            &target,
2516            "---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n",
2517        )
2518        .unwrap();
2519
2520        let agent_doc_logs = root.path().join(".agent-doc/logs");
2521        fs::create_dir_all(&agent_doc_logs).unwrap();
2522        fs::write(
2523            agent_doc_logs.join("tsift-v0.1.log"),
2524            concat!(
2525                "[1776712372] session_start file=tasks/software/tsift.md pane=%77 session=tsift-v0.1\n",
2526                "[1776712373] cwd_resolved path=/tmp/replace-me source=project_root\n",
2527                "[1776712374] commit_already_current file=tasks/software/tsift.md basis=head\n",
2528                "[1776712375] commit_already_current file=tasks/software/tsift.md basis=head\n",
2529                "[1776712376] commit_already_current file=tasks/software/tsift.md basis=head\n"
2530            )
2531            .replace("/tmp/replace-me", &root.path().display().to_string()),
2532        )
2533        .unwrap();
2534
2535        let codex_dir = home.path().join(".codex/sessions/2026/05/05");
2536        fs::create_dir_all(&codex_dir).unwrap();
2537        fs::write(
2538            codex_dir.join("rollout-1.jsonl"),
2539            concat!(
2540                r#"{"type":"session_meta","payload":{"cwd":"/tmp/replace-me"}}"#,
2541                "\n",
2542                r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#looprank]. spec-test-build-install-commit-push\nagent-doc /tmp/replace-me/tasks/software/tsift.md"}}"#,
2543                "\n",
2544                r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo test\"}"}}"#,
2545                "\n",
2546                r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo build --release\"}"}}"#,
2547                "\n",
2548                r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"sed -n '1,80p' src/session_review.rs\"}"}}"#,
2549                "\n",
2550                r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"sed -n '1,80p' src/session_review.rs\"}"}}"#,
2551                "\n",
2552                r#"{"type":"event_msg","payload":{"type":"agent_message","message":"Committed and pushed in `src/tsift` as `abc123`."}}"#,
2553                "\n",
2554                r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#looprank]. spec-test-build-install-commit-push"}}"#,
2555                "\n",
2556                r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo test\"}"}}"#,
2557                "\n",
2558                r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo build --release\"}"}}"#,
2559                "\n",
2560                r#"{"type":"event_msg","payload":{"type":"agent_message","message":"Committed and pushed in `src/tsift` as `abc123`."}}"#,
2561                "\n"
2562            )
2563            .replace("/tmp/replace-me", &root.path().display().to_string()),
2564        )
2565        .unwrap();
2566
2567        let report = compute_with_options(
2568            &target,
2569            &SessionReviewOptions {
2570                claude_projects_dir: Some(home.path().join(".claude/projects")),
2571                codex_sessions_dir: Some(home.path().join(".codex/sessions")),
2572                agent_doc_logs_dir: Some(agent_doc_logs),
2573            },
2574        )
2575        .unwrap();
2576
2577        assert!(
2578            report
2579                .loop_clusters
2580                .iter()
2581                .any(|cluster| cluster.kind == "prompt_repeat"
2582                    && cluster.label == "do [#looprank]. spec-test-build-install-commit-push"
2583                    && cluster.occurrences == 2)
2584        );
2585        assert!(
2586            report
2587                .loop_clusters
2588                .iter()
2589                .any(|cluster| cluster.kind == "command_bundle"
2590                    && cluster.label == "cargo test -> cargo build --release"
2591                    && cluster.occurrences == 2)
2592        );
2593        assert!(
2594            report
2595                .loop_clusters
2596                .iter()
2597                .any(|cluster| cluster.kind == "closeout_churn"
2598                    && cluster.label == "commit_already_current"
2599                    && cluster.occurrences == 3)
2600        );
2601        assert!(
2602            report
2603                .file_read_diagnostics
2604                .iter()
2605                .any(|diagnostic| diagnostic.path == "src/session_review.rs"
2606                    && diagnostic.range == "1-80"
2607                    && diagnostic.occurrences == 2
2608                    && diagnostic.duplicate_estimated_tokens == 1_440
2609                    && diagnostic.follow_up_commands.iter().any(|command| {
2610                        command
2611                            == "tsift source-read src/session_review.rs --start 1 --lines 80 --budget normal"
2612                    }))
2613        );
2614    }
2615
2616    #[test]
2617    fn session_review_skips_cwd_only_harness_logs_for_doc_target() {
2618        let root = tempfile::tempdir().unwrap();
2619        let home = tempfile::tempdir().unwrap();
2620        let target = root.path().join("tasks/software/tsift.md");
2621        fs::create_dir(root.path().join(".git")).unwrap();
2622        fs::create_dir_all(target.parent().unwrap()).unwrap();
2623        fs::write(
2624            &target,
2625            "---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n",
2626        )
2627        .unwrap();
2628
2629        let agent_doc_logs = root.path().join(".agent-doc/logs");
2630        fs::create_dir_all(&agent_doc_logs).unwrap();
2631        fs::write(
2632            agent_doc_logs.join("tsift-v0.1.log"),
2633            concat!(
2634                "[1776712372] session_start file=tasks/software/tsift.md pane=%77 session=tsift-v0.1\n",
2635                "[1776712373] cwd_resolved path=/tmp/replace-me source=project_root\n"
2636            )
2637            .replace("/tmp/replace-me", &root.path().display().to_string()),
2638        )
2639        .unwrap();
2640
2641        let claude_dir = home
2642            .path()
2643            .join(".claude/projects")
2644            .join(claude_project_slug(root.path()));
2645        fs::create_dir_all(&claude_dir).unwrap();
2646        fs::write(
2647            claude_dir.join("claude-target.jsonl"),
2648            concat!(
2649                r#"{"cwd":"/tmp/replace-me","message":{"role":"user","content":"agent-doc /tmp/replace-me/tasks/software/tsift.md"}}"#,
2650                "\n"
2651            )
2652            .replace("/tmp/replace-me", &root.path().display().to_string()),
2653        )
2654        .unwrap();
2655        fs::write(
2656            claude_dir.join("claude-cwd-only.jsonl"),
2657            concat!(
2658                r#"{"cwd":"/tmp/replace-me","message":{"role":"user","content":"help me inspect another task"}}"#,
2659                "\n"
2660            )
2661            .replace("/tmp/replace-me", &root.path().display().to_string()),
2662        )
2663        .unwrap();
2664
2665        let codex_dir = home.path().join(".codex/sessions/2026/05/05");
2666        fs::create_dir_all(&codex_dir).unwrap();
2667        fs::write(
2668            codex_dir.join("codex-target.jsonl"),
2669            concat!(
2670                r#"{"type":"session_meta","payload":{"cwd":"/tmp/replace-me"}}"#,
2671                "\n",
2672                r#"{"type":"event_msg","payload":{"type":"user_message","message":"agent-doc /tmp/replace-me/tasks/software/tsift.md"}}"#,
2673                "\n"
2674            )
2675            .replace("/tmp/replace-me", &root.path().display().to_string()),
2676        )
2677        .unwrap();
2678        fs::write(
2679            codex_dir.join("codex-cwd-only.jsonl"),
2680            concat!(
2681                r#"{"type":"session_meta","payload":{"cwd":"/tmp/replace-me"}}"#,
2682                "\n",
2683                r#"{"type":"event_msg","payload":{"type":"user_message","message":"open a different issue from this repo"}}"#,
2684                "\n"
2685            )
2686            .replace("/tmp/replace-me", &root.path().display().to_string()),
2687        )
2688        .unwrap();
2689
2690        let report = compute_with_options(
2691            &target,
2692            &SessionReviewOptions {
2693                claude_projects_dir: Some(home.path().join(".claude/projects")),
2694                codex_sessions_dir: Some(home.path().join(".codex/sessions")),
2695                agent_doc_logs_dir: Some(agent_doc_logs),
2696            },
2697        )
2698        .unwrap();
2699
2700        assert_eq!(report.sessions_considered, 5);
2701        assert_eq!(report.sessions_matched, 3);
2702        assert_eq!(report.claude_sessions, 1);
2703        assert_eq!(report.codex_sessions, 1);
2704        assert_eq!(report.agent_doc_logs, 1);
2705        assert!(report.sessions.iter().all(|session| {
2706            session.source == "agent_doc_log"
2707                || session
2708                    .matched_by
2709                    .iter()
2710                    .any(|reason| reason == "agent_doc_session" || reason.starts_with("path:"))
2711        }));
2712    }
2713
2714    #[test]
2715    fn session_review_uses_historical_aliases_and_skips_noisy_transcript_records() {
2716        let root = tempfile::tempdir().unwrap();
2717        let home = tempfile::tempdir().unwrap();
2718        let target = root.path().join("tasks/software/tsift.md");
2719        fs::create_dir(root.path().join(".git")).unwrap();
2720        fs::create_dir_all(target.parent().unwrap()).unwrap();
2721        fs::write(
2722            &target,
2723            "---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n",
2724        )
2725        .unwrap();
2726
2727        let agent_doc_logs = root.path().join(".agent-doc/logs");
2728        fs::create_dir_all(&agent_doc_logs).unwrap();
2729        fs::write(
2730            agent_doc_logs.join("tsift-v0.1.log"),
2731            concat!(
2732                "[1776712372] session_start file=tasks/tsift.md pane=%77 session=tsift-v0\n",
2733                "[1776712373] session_start file=tasks/software/tsift.md pane=%78 session=tsift-v0.1\n",
2734                "[1776712374] cwd_resolved path=/tmp/replace-me source=project_root\n"
2735            )
2736            .replace("/tmp/replace-me", &root.path().display().to_string()),
2737        )
2738        .unwrap();
2739
2740        let claude_dir = home
2741            .path()
2742            .join(".claude/projects")
2743            .join(claude_project_slug(root.path()));
2744        fs::create_dir_all(&claude_dir).unwrap();
2745        fs::write(
2746            claude_dir.join("claude-target.jsonl"),
2747            concat!(
2748                "not-json\n",
2749                r#"{"cwd":"/tmp/replace-me","message":{"role":"user","content":"resume session tsift-v0\nagent-doc tasks/tsift.md"}}"#,
2750                "\n",
2751                r#"{"attachment":{"type":"hook_success","content":"tasks/software/tsift.md from context index only"}}"#,
2752                "\n"
2753            )
2754            .replace("/tmp/replace-me", &root.path().display().to_string()),
2755        )
2756        .unwrap();
2757        fs::write(
2758            claude_dir.join("claude-noisy.jsonl"),
2759            concat!(
2760                r#"{"cwd":"/tmp/replace-me","attachment":{"type":"hook_success","content":"tasks/software/tsift.md only in hook output"}}"#,
2761                "\n"
2762            )
2763            .replace("/tmp/replace-me", &root.path().display().to_string()),
2764        )
2765        .unwrap();
2766
2767        let codex_dir = home.path().join(".codex/sessions/2026/05/05");
2768        fs::create_dir_all(&codex_dir).unwrap();
2769        fs::write(
2770            codex_dir.join("codex-target.jsonl"),
2771            concat!(
2772                "not-json\n",
2773                r#"{"type":"session_meta","payload":{"cwd":"/tmp/replace-me"}}"#,
2774                "\n",
2775                r#"{"type":"event_msg","payload":{"type":"user_message","message":"resume tsift-v0\nagent-doc tasks/tsift.md"}}"#,
2776                "\n",
2777                r#"{"type":"response_item","payload":{"type":"function_call_output","output":"tasks/software/tsift.md from stdout"}}"#,
2778                "\n"
2779            )
2780            .replace("/tmp/replace-me", &root.path().display().to_string()),
2781        )
2782        .unwrap();
2783        fs::write(
2784            codex_dir.join("codex-noisy.jsonl"),
2785            concat!(
2786                r#"{"type":"session_meta","payload":{"cwd":"/tmp/replace-me"}}"#,
2787                "\n",
2788                r#"{"type":"response_item","payload":{"type":"function_call_output","output":"tasks/software/tsift.md only in output"}}"#,
2789                "\n"
2790            )
2791            .replace("/tmp/replace-me", &root.path().display().to_string()),
2792        )
2793        .unwrap();
2794
2795        let report = compute_with_options(
2796            &target,
2797            &SessionReviewOptions {
2798                claude_projects_dir: Some(home.path().join(".claude/projects")),
2799                codex_sessions_dir: Some(home.path().join(".codex/sessions")),
2800                agent_doc_logs_dir: Some(agent_doc_logs),
2801            },
2802        )
2803        .unwrap();
2804
2805        assert_eq!(report.sessions_considered, 5);
2806        assert_eq!(report.sessions_matched, 3);
2807        assert_eq!(report.claude_sessions, 1);
2808        assert_eq!(report.codex_sessions, 1);
2809        assert_eq!(report.agent_doc_logs, 1);
2810        assert!(report.sessions.iter().any(|session| {
2811            session.path.ends_with("claude-target.jsonl")
2812                && session
2813                    .matched_by
2814                    .iter()
2815                    .any(|reason| reason == "agent_doc_session" || reason == "path:tasks/tsift.md")
2816        }));
2817        assert!(report.sessions.iter().any(|session| {
2818            session.path.ends_with("codex-target.jsonl")
2819                && session
2820                    .matched_by
2821                    .iter()
2822                    .any(|reason| reason == "agent_doc_session" || reason == "path:tasks/tsift.md")
2823        }));
2824        assert!(
2825            report.warnings.iter().any(
2826                |warning| warning.contains("skipping malformed Claude transcript jsonl line 1")
2827            )
2828        );
2829        assert!(
2830            report
2831                .warnings
2832                .iter()
2833                .any(|warning| warning.contains("skipping malformed Codex transcript jsonl line 1"))
2834        );
2835    }
2836}