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