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