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