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    let root = tsift_quality::lint::resolve_harness_root_or_canonical_path(target)?;
1133    let kind = if canonical_target.is_dir() {
1134        TargetKind::Directory
1135    } else if canonical_target.is_file() {
1136        TargetKind::File
1137    } else {
1138        bail!(
1139            "target `{}` is neither a file nor a directory",
1140            canonical_target.display()
1141        );
1142    };
1143
1144    let relative_target = canonical_target
1145        .strip_prefix(&root)
1146        .ok()
1147        .map(|path| path.to_string_lossy().replace('\\', "/"));
1148    let agent_doc_session = (kind == TargetKind::File)
1149        .then(|| session_markdown::session_id_from_path(&canonical_target))
1150        .transpose()?
1151        .flatten();
1152
1153    let mut path_aliases = BTreeSet::new();
1154    path_aliases.insert(canonical_target.display().to_string());
1155    if let Some(relative) = &relative_target {
1156        path_aliases.insert(relative.clone());
1157    }
1158    let mut session_aliases = BTreeSet::new();
1159    if let Some(session) = &agent_doc_session {
1160        session_aliases.insert(session.clone());
1161    }
1162
1163    Ok(TargetContext {
1164        root,
1165        canonical_target,
1166        relative_target,
1167        kind,
1168        agent_doc_session,
1169        path_aliases,
1170        session_aliases,
1171    })
1172}
1173
1174fn build_next_context(input: NextContextBuildInput<'_>) -> SessionReviewNextContext {
1175    let NextContextBuildInput {
1176        context,
1177        active_prompt_targets,
1178        touched_files,
1179        touched_symbols,
1180        failures,
1181        guardrails,
1182        last_verification,
1183        agent_doc_queue,
1184        cached_input_ratio,
1185        top_prompt_cache_roi,
1186    } = input;
1187    let prompt_cache_health = build_prompt_cache_health(cached_input_ratio, top_prompt_cache_roi);
1188    let target = context
1189        .relative_target
1190        .clone()
1191        .unwrap_or_else(|| context.canonical_target.display().to_string());
1192    let session_target = match context.kind {
1193        TargetKind::Directory => ".".to_string(),
1194        TargetKind::File => target.clone(),
1195    };
1196
1197    let mut unresolved_failures = failures.to_vec();
1198    unresolved_failures.extend(guardrail_next_context_failures(guardrails));
1199    let mut next_digest_commands = vec![
1200        format!(
1201            "tsift session-review --next-context {}",
1202            shell_quote(&session_target)
1203        ),
1204        "tsift diff-digest .".to_string(),
1205        "tsift test-digest --path . < test.log".to_string(),
1206        "tsift log-digest --path . < build.log".to_string(),
1207    ];
1208    let graph_targets = extract_backlog_refs(&active_prompt_targets);
1209    for target in &graph_targets {
1210        next_digest_commands.push(format!(
1211            "tsift graph-db --path . evidence {} --depth 3 --limit 8 --json",
1212            shell_quote(target)
1213        ));
1214    }
1215    if !graph_targets.is_empty() {
1216        next_digest_commands.push(format!(
1217            "tsift conflict-matrix --path {} {} --json",
1218            shell_quote(&session_target),
1219            graph_targets
1220                .iter()
1221                .map(|target| shell_quote(target))
1222                .collect::<Vec<_>>()
1223                .join(" ")
1224        ));
1225    }
1226
1227    SessionReviewNextContext {
1228        target,
1229        active_prompt_targets,
1230        last_verification,
1231        touched_files: touched_files
1232            .iter()
1233            .map(|entry| entry.path.clone())
1234            .collect(),
1235        touched_symbols: touched_symbols
1236            .iter()
1237            .map(|entry| entry.symbol.clone())
1238            .collect(),
1239        unresolved_failures,
1240        agent_doc_queue,
1241        prompt_cache_health,
1242        next_digest_commands,
1243    }
1244}
1245
1246fn extract_backlog_refs(inputs: &[String]) -> Vec<String> {
1247    let mut refs = Vec::new();
1248    let mut seen = BTreeSet::new();
1249    for input in inputs {
1250        for token in input.split(|ch: char| {
1251            !(ch.is_ascii_alphanumeric()
1252                || ch == '#'
1253                || ch == '_'
1254                || ch == '-'
1255                || ch == '['
1256                || ch == ']')
1257        }) {
1258            let Some(hash) = token.find('#') else {
1259                continue;
1260            };
1261            let normalized = token[hash + 1..]
1262                .trim()
1263                .trim_matches(|ch: char| matches!(ch, '[' | ']'))
1264                .trim();
1265            if !normalized.is_empty() && seen.insert(normalized.to_string()) {
1266                refs.push(normalized.to_string());
1267            }
1268        }
1269    }
1270    refs
1271}
1272
1273fn guardrail_next_context_failures(
1274    guardrails: &[SessionCostGuardrail],
1275) -> impl Iterator<Item = SessionReviewFailure> + '_ {
1276    guardrails.iter().map(|guardrail| SessionReviewFailure {
1277        kind: format!("guardrail:{}", guardrail.kind),
1278        message: format!("{} Guidance: {}", guardrail.message, guardrail.guidance),
1279        occurrences: 1,
1280        command: None,
1281        session_path: None,
1282    })
1283}
1284
1285fn collect_document_active_context(context: &TargetContext) -> Result<DocumentActiveContext> {
1286    if context.kind != TargetKind::File {
1287        return Ok(DocumentActiveContext::default());
1288    }
1289    let content = fs::read_to_string(&context.canonical_target).with_context(|| {
1290        format!(
1291            "reading target document {}",
1292            context.canonical_target.display()
1293        )
1294    })?;
1295    let tail = extract_agent_component(&content, "exchange")
1296        .map(active_exchange_tail)
1297        .unwrap_or_default();
1298    let agent_doc_queue = collect_agent_doc_queue_profile(&content, context, &tail);
1299    let has_live_tail = has_meaningful_live_tail(&tail);
1300    if !has_live_tail {
1301        let queue_prompt_target = agent_doc_queue
1302            .as_ref()
1303            .and_then(|profile| profile.active_queue_prompt.clone())
1304            .into_iter()
1305            .collect();
1306        return Ok(DocumentActiveContext {
1307            has_live_tail,
1308            prompt_targets: queue_prompt_target,
1309            touched_files: Vec::new(),
1310            touched_symbols: Vec::new(),
1311            failures: Vec::new(),
1312            agent_doc_queue,
1313        });
1314    }
1315    let digest = session_digest::compute(&context.root, &tail, Some("markdown"))?;
1316    let fallback_prompt_targets = if digest.prompt_targets.is_empty() {
1317        collect_live_tail_prompt_lines(&tail)
1318    } else {
1319        Vec::new()
1320    };
1321    let queue_prompt_target =
1322        if digest.prompt_targets.is_empty() && fallback_prompt_targets.is_empty() {
1323            agent_doc_queue
1324                .as_ref()
1325                .and_then(|profile| profile.active_queue_prompt.clone())
1326                .into_iter()
1327                .collect()
1328        } else {
1329            Vec::new()
1330        };
1331    Ok(DocumentActiveContext {
1332        has_live_tail,
1333        prompt_targets: if digest.prompt_targets.is_empty() {
1334            if fallback_prompt_targets.is_empty() {
1335                queue_prompt_target
1336            } else {
1337                fallback_prompt_targets
1338            }
1339        } else {
1340            digest.prompt_targets
1341        },
1342        touched_files: digest
1343            .touched_files
1344            .into_iter()
1345            .map(|entry| SessionReviewFileRef {
1346                path: entry.path,
1347                occurrences: entry.occurrences,
1348            })
1349            .collect(),
1350        touched_symbols: digest
1351            .touched_symbols
1352            .into_iter()
1353            .map(|entry| SessionReviewSymbolRef {
1354                symbol: entry.symbol,
1355                occurrences: entry.occurrences,
1356            })
1357            .collect(),
1358        failures: digest
1359            .failures
1360            .into_iter()
1361            .map(|entry| SessionReviewFailure {
1362                kind: entry.kind,
1363                message: entry.message,
1364                occurrences: entry.occurrences,
1365                command: entry.command,
1366                session_path: context
1367                    .relative_target
1368                    .clone()
1369                    .or_else(|| Some(context.canonical_target.display().to_string())),
1370            })
1371            .collect(),
1372        agent_doc_queue,
1373    })
1374}
1375
1376fn collect_live_tail_prompt_lines(tail: &str) -> Vec<String> {
1377    let mut prompts = Vec::new();
1378    let mut buffer = Vec::new();
1379    for raw_line in tail.lines() {
1380        let Some(line) = meaningful_live_tail_line(raw_line) else {
1381            if !buffer.is_empty() {
1382                prompts.push(buffer.join(" "));
1383                buffer.clear();
1384            }
1385            continue;
1386        };
1387        buffer.push(line.to_string());
1388    }
1389    if !buffer.is_empty() {
1390        prompts.push(buffer.join(" "));
1391    }
1392    prompts
1393}
1394
1395fn has_meaningful_live_tail(tail: &str) -> bool {
1396    tail.lines()
1397        .any(|line| meaningful_live_tail_line(line).is_some())
1398}
1399
1400fn meaningful_live_tail_line(line: &str) -> Option<&str> {
1401    let trimmed = line
1402        .trim()
1403        .strip_prefix("❯ ")
1404        .or_else(|| line.trim().strip_prefix("> "))
1405        .unwrap_or_else(|| line.trim())
1406        .trim();
1407    if trimmed.is_empty()
1408        || trimmed.starts_with("<!--")
1409        || trimmed.starts_with("###")
1410        || trimmed == "#"
1411        || trimmed == "---"
1412    {
1413        return None;
1414    }
1415    Some(trimmed)
1416}
1417
1418fn extract_agent_component<'a>(content: &'a str, name: &str) -> Option<&'a str> {
1419    let open_prefix = format!("<!-- agent:{name}");
1420    let close_marker = format!("<!-- /agent:{name} -->");
1421    let open_start = content.find(&open_prefix)?;
1422    let after_open = content[open_start..].find("-->")? + open_start + 3;
1423    let close_start = content[after_open..].find(&close_marker)? + after_open;
1424    Some(&content[after_open..close_start])
1425}
1426
1427fn active_exchange_tail(exchange: &str) -> String {
1428    let mut start = 0;
1429    for (index, _) in exchange.match_indices("<!-- agent:boundary:") {
1430        let marker_tail = &exchange[index..];
1431        let marker_end = marker_tail
1432            .find("-->")
1433            .map(|offset| index + offset + 3)
1434            .unwrap_or(index);
1435        start = marker_end;
1436    }
1437    let after_boundary = &exchange[start..];
1438    let mut response_seen = false;
1439    let mut prompt_region = String::new();
1440    for line in after_boundary.lines() {
1441        if line.trim_start().starts_with("### Re:") {
1442            response_seen = true;
1443            prompt_region.clear();
1444            continue;
1445        }
1446        if !response_seen
1447            || line.trim_start().starts_with("❯ ")
1448            || line.trim_start().starts_with("> ")
1449        {
1450            prompt_region.push_str(line);
1451            prompt_region.push('\n');
1452        }
1453    }
1454    prompt_region
1455}
1456
1457fn collect_agent_doc_queue_profile(
1458    content: &str,
1459    context: &TargetContext,
1460    live_tail: &str,
1461) -> Option<SessionReviewAgentDocQueueProfile> {
1462    let queue_rows = extract_agent_component(content, "queue")
1463        .map(collect_agent_doc_component_rows)
1464        .unwrap_or_default();
1465    let backlog_rows = extract_agent_component(content, "backlog")
1466        .map(collect_agent_doc_component_rows)
1467        .unwrap_or_default();
1468    let review_rows = extract_agent_component(content, "review")
1469        .map(collect_agent_doc_component_rows)
1470        .unwrap_or_default();
1471    let prompt_presets = collect_agent_doc_prompt_presets(content);
1472    let live_exchange_tail = collect_meaningful_live_tail_lines(live_tail);
1473
1474    let backlog_by_ref = backlog_rows
1475        .iter()
1476        .filter_map(|row| extract_first_backlog_ref(row).map(|id| (id, row.clone())))
1477        .collect::<BTreeMap<_, _>>();
1478    let active_queue_prompt = queue_rows.first().map(|queue_row| {
1479        extract_first_backlog_ref(queue_row)
1480            .and_then(|id| backlog_by_ref.get(&id).cloned())
1481            .unwrap_or_else(|| queue_row.clone())
1482    });
1483
1484    let mut profile = SessionReviewAgentDocQueueProfile {
1485        active_queue_prompt,
1486        live_exchange_tail,
1487        backlog_rows,
1488        review_rows,
1489        prompt_presets,
1490        expansion_handles: Vec::new(),
1491    };
1492    if profile.is_empty() {
1493        return None;
1494    }
1495    profile.expansion_handles = agent_doc_queue_expansion_handles(context);
1496    Some(profile)
1497}
1498
1499fn collect_agent_doc_component_rows(component: &str) -> Vec<String> {
1500    component
1501        .lines()
1502        .filter_map(normalize_agent_doc_component_row)
1503        .take(MAX_AGENT_DOC_QUEUE_PROFILE_ROWS)
1504        .collect()
1505}
1506
1507fn normalize_agent_doc_component_row(raw_line: &str) -> Option<String> {
1508    let mut line = raw_line.trim();
1509    if line.is_empty() || line.starts_with("<!--") {
1510        return None;
1511    }
1512    if let Some(rest) = line.strip_prefix("- ") {
1513        line = rest.trim();
1514    }
1515    if line.starts_with("~~") || line.ends_with("~~") {
1516        return None;
1517    }
1518    if let Some(rest) = line.strip_prefix("[ ]") {
1519        line = rest.trim();
1520    } else if line.starts_with("[x]") || line.starts_with("[X]") {
1521        return None;
1522    }
1523    if line.is_empty() || line.starts_with("~~") {
1524        return None;
1525    }
1526    Some(collapse_inline_whitespace(line))
1527}
1528
1529fn collect_meaningful_live_tail_lines(tail: &str) -> Vec<String> {
1530    tail.lines()
1531        .filter_map(meaningful_live_tail_line)
1532        .map(collapse_inline_whitespace)
1533        .take(MAX_AGENT_DOC_QUEUE_PROFILE_ROWS)
1534        .collect()
1535}
1536
1537fn collect_agent_doc_prompt_presets(content: &str) -> Vec<String> {
1538    let Some(frontmatter) = extract_frontmatter(content) else {
1539        return Vec::new();
1540    };
1541    let mut in_prompt_presets = false;
1542    let mut presets = Vec::new();
1543    for raw_line in frontmatter.lines() {
1544        let trimmed = raw_line.trim();
1545        if trimmed == "prompt_presets:" {
1546            in_prompt_presets = true;
1547            continue;
1548        }
1549        if !in_prompt_presets {
1550            continue;
1551        }
1552        if trimmed.is_empty() {
1553            continue;
1554        }
1555        if !raw_line.starts_with(char::is_whitespace) {
1556            break;
1557        }
1558        let Some((key, value)) = trimmed.split_once(':') else {
1559            continue;
1560        };
1561        let key = key.trim().trim_matches('\'').trim_matches('"');
1562        if !key.starts_with('#') {
1563            continue;
1564        }
1565        let value = value.trim().trim_matches('\'').trim_matches('"');
1566        let preset = if value.is_empty() {
1567            key.to_string()
1568        } else {
1569            format!("{key}: {}", collapse_inline_whitespace(value))
1570        };
1571        presets.push(preset);
1572        if presets.len() >= MAX_AGENT_DOC_QUEUE_PROFILE_ROWS {
1573            break;
1574        }
1575    }
1576    presets
1577}
1578
1579fn extract_frontmatter(content: &str) -> Option<&str> {
1580    let rest = content.strip_prefix("---\n")?;
1581    let end = rest.find("\n---")?;
1582    Some(&rest[..end])
1583}
1584
1585fn extract_first_backlog_ref(text: &str) -> Option<String> {
1586    extract_backlog_refs(&[text.to_string()]).into_iter().next()
1587}
1588
1589fn agent_doc_queue_expansion_handles(
1590    context: &TargetContext,
1591) -> Vec<SessionReviewAgentDocExpansionHandle> {
1592    let target = context
1593        .relative_target
1594        .clone()
1595        .unwrap_or_else(|| context.canonical_target.display().to_string());
1596    vec![
1597        SessionReviewAgentDocExpansionHandle {
1598            handle: "adq-next-context".to_string(),
1599            label: "refresh next-context".to_string(),
1600            expand: format!(
1601                "tsift --envelope session-review {} --next-context --budget normal",
1602                shell_quote(&target)
1603            ),
1604        },
1605        SessionReviewAgentDocExpansionHandle {
1606            handle: "adq-context-pack".to_string(),
1607            label: "refresh context-pack".to_string(),
1608            expand: format!(
1609                "tsift --envelope context-pack {} --budget normal",
1610                shell_quote(&target)
1611            ),
1612        },
1613        SessionReviewAgentDocExpansionHandle {
1614            handle: "adq-document".to_string(),
1615            label: "expand document".to_string(),
1616            expand: format!(
1617                "tsift --envelope source-read {} --budget normal",
1618                shell_quote(&target)
1619            ),
1620        },
1621    ]
1622}
1623
1624fn collapse_inline_whitespace(text: &str) -> String {
1625    text.split_whitespace().collect::<Vec<_>>().join(" ")
1626}
1627
1628fn resolve_claude_projects_dir(root: &Path, options: &SessionReviewOptions) -> PathBuf {
1629    options
1630        .claude_projects_dir
1631        .clone()
1632        .or_else(|| home_dir(root).map(|home| home.join(".claude/projects")))
1633        .unwrap_or_else(|| PathBuf::from(".claude/projects"))
1634}
1635
1636fn resolve_codex_sessions_dir(root: &Path, options: &SessionReviewOptions) -> PathBuf {
1637    options
1638        .codex_sessions_dir
1639        .clone()
1640        .or_else(|| home_dir(root).map(|home| home.join(".codex/sessions")))
1641        .unwrap_or_else(|| PathBuf::from(".codex/sessions"))
1642}
1643
1644fn resolve_agent_doc_logs_dir(root: &Path, options: &SessionReviewOptions) -> PathBuf {
1645    options
1646        .agent_doc_logs_dir
1647        .clone()
1648        .unwrap_or_else(|| root.join(".agent-doc/logs"))
1649}
1650
1651fn home_dir(root: &Path) -> Option<PathBuf> {
1652    std::env::var_os("HOME").map(PathBuf::from).or_else(|| {
1653        let root_home = root.components().take(3).collect::<PathBuf>();
1654        root_home.starts_with("/home").then_some(root_home)
1655    })
1656}
1657
1658fn claude_project_slug(root: &Path) -> String {
1659    root.display().to_string().replace('/', "-")
1660}
1661
1662fn collect_agent_doc_aliases(text: &str, root: &Path) -> AgentDocAliases {
1663    let mut aliases = AgentDocAliases::default();
1664    for line in text.lines() {
1665        let Some((_, detail)) = line.split_once("] ") else {
1666            continue;
1667        };
1668        if let Some(raw) = extract_field(detail, "file") {
1669            let normalized = normalize_relative_path(raw, root);
1670            aliases.path_aliases.insert(normalized);
1671        }
1672        if let Some(raw) = extract_field(detail, "session") {
1673            let session = raw.trim_matches('"');
1674            if !session.is_empty() {
1675                aliases.session_aliases.insert(session.to_string());
1676            }
1677        }
1678    }
1679    aliases
1680}
1681
1682fn maybe_add_agent_doc_candidate(
1683    candidates: &mut BTreeMap<String, PendingSession>,
1684    context: &TargetContext,
1685    path: &Path,
1686) -> Result<()> {
1687    let text = fs::read_to_string(path)
1688        .with_context(|| format!("reading agent-doc log {}", path.display()))?;
1689    let mut matched_by = Vec::new();
1690    if let Some(session_name) = &context.agent_doc_session
1691        && path.file_stem().and_then(|value| value.to_str()) == Some(session_name.as_str())
1692    {
1693        matched_by.push("agent_doc_session".to_string());
1694    }
1695    if context.kind == TargetKind::Directory {
1696        if text.contains(&format!("cwd_resolved path={}", context.root.display())) {
1697            matched_by.push("cwd_resolved".to_string());
1698        }
1699    } else {
1700        for alias in &context.path_aliases {
1701            if text.contains(&format!("file={alias}")) {
1702                matched_by.push(format!("path:{alias}"));
1703            }
1704        }
1705    }
1706    if matched_by.is_empty() {
1707        return Ok(());
1708    }
1709    let modified_unix_secs = file_modified_unix_secs(path)?;
1710    insert_candidate(
1711        candidates,
1712        PendingSession::new(
1713            ReviewSource::AgentDocLog,
1714            path.to_path_buf(),
1715            matched_by,
1716            modified_unix_secs,
1717            text,
1718        ),
1719    );
1720    Ok(())
1721}
1722
1723fn maybe_add_claude_candidate(
1724    candidates: &mut BTreeMap<String, PendingSession>,
1725    context: &TargetContext,
1726    path: &Path,
1727) -> Result<()> {
1728    let Some(text) = read_jsonl_session_text_if_cwd_matches(
1729        path,
1730        context,
1731        "Claude session",
1732        extract_claude_cwd_from_text,
1733    )?
1734    else {
1735        return Ok(());
1736    };
1737    let signals = extract_claude_match_signals(&text);
1738    if !cwd_matches_target(context, signals.cwd.as_deref()) {
1739        return Ok(());
1740    }
1741    let matched_by = match_reasons(context, &signals, signals.cwd.as_deref());
1742    if matched_by.is_empty() {
1743        return Ok(());
1744    }
1745    let modified_unix_secs = file_modified_unix_secs(path)?;
1746    insert_candidate(
1747        candidates,
1748        PendingSession::new(
1749            ReviewSource::ClaudeJsonl,
1750            path.to_path_buf(),
1751            matched_by,
1752            modified_unix_secs,
1753            text,
1754        ),
1755    );
1756    Ok(())
1757}
1758
1759fn maybe_add_codex_candidate(
1760    candidates: &mut BTreeMap<String, PendingSession>,
1761    context: &TargetContext,
1762    path: &Path,
1763) -> Result<()> {
1764    let Some(text) = read_jsonl_session_text_if_cwd_matches(
1765        path,
1766        context,
1767        "Codex session",
1768        extract_codex_cwd_from_text,
1769    )?
1770    else {
1771        return Ok(());
1772    };
1773    let signals = extract_codex_match_signals(&text);
1774    if !cwd_matches_target(context, signals.cwd.as_deref()) {
1775        return Ok(());
1776    }
1777    let matched_by = match_reasons(context, &signals, signals.cwd.as_deref());
1778    if matched_by.is_empty() {
1779        return Ok(());
1780    }
1781    let modified_unix_secs = file_modified_unix_secs(path)?;
1782    insert_candidate(
1783        candidates,
1784        PendingSession::new(
1785            ReviewSource::CodexJsonl,
1786            path.to_path_buf(),
1787            matched_by,
1788            modified_unix_secs,
1789            text,
1790        ),
1791    );
1792    Ok(())
1793}
1794
1795fn extract_claude_match_signals(text: &str) -> MatchSignals {
1796    let mut signals = MatchSignals::default();
1797    for line in text.lines() {
1798        let trimmed = line.trim();
1799        if trimmed.is_empty() {
1800            continue;
1801        }
1802        let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
1803            continue;
1804        };
1805        if signals.cwd.is_none()
1806            && let Some(cwd) = value.get("cwd").and_then(serde_json::Value::as_str)
1807        {
1808            signals.cwd = Some(PathBuf::from(cwd));
1809        }
1810        collect_claude_match_snippets(&value, &mut signals.snippets);
1811    }
1812    signals
1813}
1814
1815fn extract_codex_match_signals(text: &str) -> MatchSignals {
1816    let mut signals = MatchSignals::default();
1817    for line in text.lines() {
1818        let trimmed = line.trim();
1819        if trimmed.is_empty() {
1820            continue;
1821        }
1822        let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
1823            continue;
1824        };
1825        match value.get("type").and_then(serde_json::Value::as_str) {
1826            Some("session_meta") if signals.cwd.is_none() => {
1827                signals.cwd = value
1828                    .get("payload")
1829                    .and_then(|payload| payload.get("cwd"))
1830                    .and_then(serde_json::Value::as_str)
1831                    .map(PathBuf::from);
1832            }
1833            Some("event_msg") => {
1834                if let Some(payload) = value.get("payload")
1835                    && payload.get("type").and_then(serde_json::Value::as_str)
1836                        == Some("user_message")
1837                    && let Some(message) =
1838                        payload.get("message").and_then(serde_json::Value::as_str)
1839                {
1840                    signals.snippets.push(message.to_string());
1841                }
1842            }
1843            Some("response_item") => {
1844                if let Some(payload) = value.get("payload") {
1845                    match payload.get("type").and_then(serde_json::Value::as_str) {
1846                        Some("function_call") => {
1847                            if let Some(arguments) =
1848                                payload.get("arguments").and_then(serde_json::Value::as_str)
1849                            {
1850                                signals.snippets.push(arguments.to_string());
1851                            }
1852                        }
1853                        Some("message") => {
1854                            if payload.get("role").and_then(serde_json::Value::as_str)
1855                                == Some("user")
1856                                && let Some(content) =
1857                                    payload.get("content").and_then(serde_json::Value::as_array)
1858                            {
1859                                for item in content {
1860                                    if let Some(text) = item
1861                                        .get("text")
1862                                        .and_then(serde_json::Value::as_str)
1863                                        .or_else(|| {
1864                                            item.get("content").and_then(serde_json::Value::as_str)
1865                                        })
1866                                    {
1867                                        signals.snippets.push(text.to_string());
1868                                    }
1869                                }
1870                            }
1871                        }
1872                        _ => {}
1873                    }
1874                }
1875            }
1876            _ => {}
1877        }
1878    }
1879    signals
1880}
1881
1882fn cwd_matches_target(context: &TargetContext, cwd: Option<&Path>) -> bool {
1883    let Some(cwd) = cwd else {
1884        return false;
1885    };
1886    let Ok(canonical_cwd) = cwd.canonicalize() else {
1887        return false;
1888    };
1889    canonical_cwd.starts_with(&context.root) || context.root.starts_with(canonical_cwd)
1890}
1891
1892fn match_reasons(
1893    context: &TargetContext,
1894    signals: &MatchSignals,
1895    cwd: Option<&Path>,
1896) -> Vec<String> {
1897    let mut reasons = BTreeSet::new();
1898    match context.kind {
1899        TargetKind::Directory => {
1900            if cwd_matches_target(context, cwd) {
1901                reasons.insert("cwd".to_string());
1902            }
1903        }
1904        TargetKind::File => {
1905            for snippet in &signals.snippets {
1906                for alias in &context.path_aliases {
1907                    if snippet.contains(alias) {
1908                        reasons.insert(format!("path:{alias}"));
1909                    }
1910                }
1911                for session_alias in &context.session_aliases {
1912                    if snippet.contains(session_alias) {
1913                        reasons.insert("agent_doc_session".to_string());
1914                    }
1915                }
1916            }
1917            if reasons.is_empty() {
1918                return Vec::new();
1919            }
1920            if cwd_matches_target(context, cwd) {
1921                reasons.insert("cwd".to_string());
1922            }
1923        }
1924    }
1925    reasons.into_iter().collect()
1926}
1927
1928fn collect_claude_match_snippets(value: &serde_json::Value, out: &mut Vec<String>) {
1929    if let Some(message) = value.get("message") {
1930        collect_claude_message_snippets(message, out);
1931        return;
1932    }
1933    if value.get("attachment").is_some() {
1934        return;
1935    }
1936    collect_claude_message_snippets(value, out);
1937}
1938
1939fn collect_claude_message_snippets(value: &serde_json::Value, out: &mut Vec<String>) {
1940    if let Some(content) = value.get("content") {
1941        match content {
1942            serde_json::Value::String(text) => out.push(text.to_string()),
1943            serde_json::Value::Array(items) => {
1944                for item in items {
1945                    match item.get("type").and_then(serde_json::Value::as_str) {
1946                        Some("text") => {
1947                            if let Some(text) = item
1948                                .get("text")
1949                                .and_then(serde_json::Value::as_str)
1950                                .or_else(|| item.get("content").and_then(serde_json::Value::as_str))
1951                            {
1952                                out.push(text.to_string());
1953                            }
1954                        }
1955                        Some("tool_use") => {
1956                            if let Some(command) = item
1957                                .get("input")
1958                                .and_then(|input| input.get("command"))
1959                                .and_then(serde_json::Value::as_str)
1960                            {
1961                                out.push(command.to_string());
1962                            }
1963                        }
1964                        _ => {}
1965                    }
1966                }
1967            }
1968            _ => {}
1969        }
1970    } else if let Some(text) = value.get("text").and_then(serde_json::Value::as_str) {
1971        out.push(text.to_string());
1972    }
1973}
1974
1975fn insert_candidate(candidates: &mut BTreeMap<String, PendingSession>, pending: PendingSession) {
1976    let key = pending.path.display().to_string();
1977    if let Some(existing) = candidates.get_mut(&key) {
1978        existing.matched_by.extend(pending.matched_by);
1979        existing.modified_unix_secs = existing.modified_unix_secs.max(pending.modified_unix_secs);
1980        return;
1981    }
1982    candidates.insert(key, pending);
1983}
1984
1985fn normalize_relative_path(raw: &str, root: &Path) -> String {
1986    let path = PathBuf::from(raw);
1987    let joined = if path.is_absolute() {
1988        path
1989    } else {
1990        root.join(path)
1991    };
1992    joined
1993        .strip_prefix(root)
1994        .ok()
1995        .unwrap_or(joined.as_path())
1996        .to_string_lossy()
1997        .replace('\\', "/")
1998}
1999
2000fn extract_claude_cwd_from_text(text: &str) -> Option<PathBuf> {
2001    for line in text.lines() {
2002        let trimmed = line.trim();
2003        if trimmed.is_empty() {
2004            continue;
2005        }
2006        let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
2007            continue;
2008        };
2009        if let Some(cwd) = value.get("cwd").and_then(serde_json::Value::as_str) {
2010            return Some(PathBuf::from(cwd));
2011        }
2012    }
2013    None
2014}
2015
2016fn extract_codex_cwd_from_text(text: &str) -> Option<PathBuf> {
2017    for line in text.lines() {
2018        let trimmed = line.trim();
2019        if trimmed.is_empty() {
2020            continue;
2021        }
2022        let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
2023            continue;
2024        };
2025        if value.get("type").and_then(serde_json::Value::as_str) == Some("session_meta")
2026            && let Some(cwd) = value
2027                .get("payload")
2028                .and_then(|payload| payload.get("cwd"))
2029                .and_then(serde_json::Value::as_str)
2030        {
2031            return Some(PathBuf::from(cwd));
2032        }
2033    }
2034    None
2035}
2036
2037fn read_jsonl_session_text_if_cwd_matches(
2038    path: &Path,
2039    context: &TargetContext,
2040    label: &str,
2041    extract_cwd: fn(&str) -> Option<PathBuf>,
2042) -> Result<Option<String>> {
2043    let file =
2044        fs::File::open(path).with_context(|| format!("reading {label} {}", path.display()))?;
2045    let mut reader = BufReader::new(file);
2046    let mut header = String::new();
2047    let mut line = String::new();
2048    let mut cwd: Option<PathBuf> = None;
2049    loop {
2050        line.clear();
2051        let bytes = reader
2052            .read_line(&mut line)
2053            .with_context(|| format!("reading {label} {}", path.display()))?;
2054        if bytes == 0 {
2055            break;
2056        }
2057        header.push_str(&line);
2058        cwd = extract_cwd(&header);
2059        if cwd.is_some() || header.len() >= SESSION_HEADER_PROBE_BUDGET_BYTES {
2060            break;
2061        }
2062    }
2063    if !cwd_matches_target(context, cwd.as_deref()) {
2064        return Ok(None);
2065    }
2066    let mut rest = String::new();
2067    reader
2068        .read_to_string(&mut rest)
2069        .with_context(|| format!("reading {label} {}", path.display()))?;
2070    header.push_str(&rest);
2071    Ok(Some(header))
2072}
2073
2074fn collect_files_with_extension(root: &Path, extension: &str) -> Result<Vec<PathBuf>> {
2075    let mut files = Vec::new();
2076    collect_files_with_extension_inner(root, extension, &mut files)?;
2077    Ok(files)
2078}
2079
2080fn collect_recent_files_with_extension(
2081    root: &Path,
2082    extension: &str,
2083    limit: usize,
2084) -> Result<Vec<PathBuf>> {
2085    let mut entries: Vec<(Option<u64>, PathBuf)> = Vec::new();
2086    collect_recent_files_with_extension_inner(root, extension, &mut entries)?;
2087    entries.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
2088    entries.truncate(limit);
2089    Ok(entries.into_iter().map(|(_, path)| path).collect())
2090}
2091
2092fn collect_recent_files_with_extension_inner(
2093    root: &Path,
2094    extension: &str,
2095    entries: &mut Vec<(Option<u64>, PathBuf)>,
2096) -> Result<()> {
2097    for entry in fs::read_dir(root).with_context(|| format!("reading {}", root.display()))? {
2098        let entry = entry?;
2099        let path = entry.path();
2100        if path.is_dir() {
2101            collect_recent_files_with_extension_inner(&path, extension, entries)?;
2102        } else if path.extension().and_then(|value| value.to_str()) == Some(extension) {
2103            let modified = file_modified_unix_secs(&path).unwrap_or(None);
2104            entries.push((modified, path));
2105        }
2106    }
2107    Ok(())
2108}
2109
2110fn collect_files_with_extension_inner(
2111    root: &Path,
2112    extension: &str,
2113    files: &mut Vec<PathBuf>,
2114) -> Result<()> {
2115    for entry in fs::read_dir(root).with_context(|| format!("reading {}", root.display()))? {
2116        let entry = entry?;
2117        let path = entry.path();
2118        if path.is_dir() {
2119            collect_files_with_extension_inner(&path, extension, files)?;
2120        } else if path.extension().and_then(|value| value.to_str()) == Some(extension) {
2121            files.push(path);
2122        }
2123    }
2124    Ok(())
2125}
2126
2127fn file_modified_unix_secs(path: &Path) -> Result<Option<u64>> {
2128    let modified = fs::metadata(path)
2129        .with_context(|| format!("reading metadata for {}", path.display()))?
2130        .modified()
2131        .ok();
2132    Ok(modified
2133        .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
2134        .map(|duration| duration.as_secs()))
2135}
2136
2137fn extract_field<'a>(detail: &'a str, key: &str) -> Option<&'a str> {
2138    let needle = format!("{key}=");
2139    let start = detail.find(&needle)? + needle.len();
2140    let remainder = &detail[start..];
2141    let end = remainder
2142        .find(char::is_whitespace)
2143        .unwrap_or(remainder.len());
2144    Some(remainder[..end].trim_matches('"'))
2145}
2146
2147fn collect_strings<T, F>(entries: BTreeMap<String, usize>, max_items: usize, build: F) -> Vec<T>
2148where
2149    F: Fn(String, usize) -> T,
2150{
2151    let mut rows = entries.into_iter().collect::<Vec<_>>();
2152    rows.sort_by(|left, right| right.1.cmp(&left.1).then(left.0.cmp(&right.0)));
2153    rows.truncate(max_items);
2154    rows.into_iter()
2155        .map(|(value, count)| build(value, count))
2156        .collect()
2157}
2158
2159fn collect_pairs<K, T, F>(entries: BTreeMap<K, usize>, max_items: usize, build: F) -> Vec<T>
2160where
2161    K: Ord,
2162    F: Fn(K, usize) -> T,
2163{
2164    let mut rows = entries.into_iter().collect::<Vec<_>>();
2165    rows.sort_by(|left, right| right.1.cmp(&left.1).then(left.0.cmp(&right.0)));
2166    rows.truncate(max_items);
2167    rows.into_iter()
2168        .map(|(value, count)| build(value, count))
2169        .collect()
2170}
2171
2172fn collect_restart_churn(
2173    entries: BTreeMap<String, RestartChurnSummary>,
2174    max_items: usize,
2175) -> Vec<RestartChurnSummary> {
2176    let mut rows = entries.into_values().collect::<Vec<_>>();
2177    rows.sort_by(|left, right| {
2178        right
2179            .occurrences
2180            .cmp(&left.occurrences)
2181            .then(left.family.cmp(&right.family))
2182    });
2183    rows.truncate(max_items);
2184    rows
2185}
2186
2187fn collect_loop_clusters(
2188    entries: BTreeMap<(String, String), (usize, usize)>,
2189    max_items: usize,
2190) -> Vec<SessionCostLoopCluster> {
2191    let mut rows = entries
2192        .into_iter()
2193        .map(
2194            |((kind, label), (occurrences, max_consecutive))| SessionCostLoopCluster {
2195                kind,
2196                label,
2197                occurrences,
2198                max_consecutive,
2199            },
2200        )
2201        .collect::<Vec<_>>();
2202    rows.sort_by(|left, right| {
2203        right
2204            .occurrences
2205            .cmp(&left.occurrences)
2206            .then(right.max_consecutive.cmp(&left.max_consecutive))
2207            .then(left.kind.cmp(&right.kind))
2208            .then(left.label.cmp(&right.label))
2209    });
2210    rows.truncate(max_items);
2211    rows
2212}
2213
2214fn collect_file_read_diagnostics(
2215    entries: BTreeMap<(String, String), FileReadDiagnosticAggregate>,
2216    max_items: usize,
2217) -> Vec<SessionCostFileReadDiagnostic> {
2218    let mut rows = entries
2219        .into_values()
2220        .map(|entry| SessionCostFileReadDiagnostic {
2221            path: entry.path,
2222            range: entry.range,
2223            occurrences: entry.occurrences,
2224            estimated_tokens: entry.estimated_tokens,
2225            duplicate_estimated_tokens: entry.duplicate_estimated_tokens,
2226            follow_up_commands: entry.follow_up_commands.into_iter().collect(),
2227        })
2228        .collect::<Vec<_>>();
2229    rows.sort_by(|left, right| {
2230        right
2231            .duplicate_estimated_tokens
2232            .cmp(&left.duplicate_estimated_tokens)
2233            .then(right.occurrences.cmp(&left.occurrences))
2234            .then(left.path.cmp(&right.path))
2235            .then(left.range.cmp(&right.range))
2236    });
2237    rows.truncate(max_items);
2238    rows
2239}
2240
2241fn shell_quote(text: &str) -> String {
2242    if text.chars().any(char::is_whitespace) {
2243        format!("{text:?}")
2244    } else {
2245        text.to_string()
2246    }
2247}
2248
2249#[cfg(test)]
2250mod tests {
2251    use super::*;
2252
2253    #[test]
2254    fn collect_recent_files_with_extension_caps_and_sorts_by_mtime() {
2255        let dir = tempfile::tempdir().unwrap();
2256        for i in 0..10 {
2257            let path = dir.path().join(format!("session-{i:02}.jsonl"));
2258            fs::write(&path, format!("{{\"i\":{i}}}\n")).unwrap();
2259            let file = fs::OpenOptions::new().write(true).open(&path).unwrap();
2260            let modified = std::time::SystemTime::UNIX_EPOCH
2261                + std::time::Duration::from_secs(1_700_000_000 + i as u64 * 60);
2262            file.set_modified(modified).unwrap();
2263        }
2264        fs::write(dir.path().join("ignored.txt"), "skip me").unwrap();
2265
2266        let recent = collect_recent_files_with_extension(dir.path(), "jsonl", 3).unwrap();
2267        assert_eq!(recent.len(), 3, "should cap at 3 entries");
2268        let names: Vec<String> = recent
2269            .iter()
2270            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
2271            .collect();
2272        assert_eq!(
2273            names,
2274            vec![
2275                "session-09.jsonl".to_string(),
2276                "session-08.jsonl".to_string(),
2277                "session-07.jsonl".to_string(),
2278            ],
2279            "should return newest-first by mtime"
2280        );
2281
2282        let all = collect_recent_files_with_extension(dir.path(), "jsonl", 100).unwrap();
2283        assert_eq!(
2284            all.len(),
2285            10,
2286            "limit above population should return everything"
2287        );
2288        assert!(
2289            !all.iter()
2290                .any(|p| p.extension().and_then(|s| s.to_str()) == Some("txt")),
2291            "non-matching extensions must be filtered: {all:?}"
2292        );
2293    }
2294
2295    #[test]
2296    fn read_jsonl_session_text_if_cwd_matches_skips_non_matching_files_without_full_read() {
2297        let dir = tempfile::tempdir().unwrap();
2298        let target_root = dir.path().canonicalize().unwrap();
2299        let target = target_root.join("plan.md");
2300        fs::create_dir(target_root.join(".git")).unwrap();
2301        fs::write(&target, "---\nagent_doc_session: x\n---\n").unwrap();
2302        let context = build_target_context(&target).unwrap();
2303
2304        let matching = dir.path().join("matching.jsonl");
2305        let matching_cwd = target_root.display().to_string();
2306        let matching_body = format!(
2307            "{{\"cwd\":\"{matching_cwd}\"}}\n{}\n",
2308            "x".repeat(64 * 1024)
2309        );
2310        fs::write(&matching, &matching_body).unwrap();
2311
2312        let other = dir.path().join("other.jsonl");
2313        fs::write(
2314            &other,
2315            format!(
2316                "{{\"cwd\":\"/tmp/other-project-{}\"}}\n{}\n",
2317                std::process::id(),
2318                "y".repeat(64 * 1024)
2319            ),
2320        )
2321        .unwrap();
2322
2323        let matched = read_jsonl_session_text_if_cwd_matches(
2324            &matching,
2325            &context,
2326            "test",
2327            extract_claude_cwd_from_text,
2328        )
2329        .unwrap();
2330        assert!(
2331            matched.is_some(),
2332            "file with matching cwd should return Some(text)"
2333        );
2334        let skipped = read_jsonl_session_text_if_cwd_matches(
2335            &other,
2336            &context,
2337            "test",
2338            extract_claude_cwd_from_text,
2339        )
2340        .unwrap();
2341        assert!(
2342            skipped.is_none(),
2343            "file with non-matching cwd should return None"
2344        );
2345    }
2346
2347    #[test]
2348    fn session_review_discovers_cross_harness_logs_for_doc_target() {
2349        let root = tempfile::tempdir().unwrap();
2350        let home = tempfile::tempdir().unwrap();
2351        let target = root.path().join("tasks/software/tsift.md");
2352        fs::create_dir(root.path().join(".git")).unwrap();
2353        fs::create_dir_all(target.parent().unwrap()).unwrap();
2354        fs::write(
2355            &target,
2356            "---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n",
2357        )
2358        .unwrap();
2359
2360        let agent_doc_logs = root.path().join(".agent-doc/logs");
2361        fs::create_dir_all(&agent_doc_logs).unwrap();
2362        fs::write(
2363            agent_doc_logs.join("tsift-v0.1.log"),
2364            concat!(
2365                "[1776712372] session_start file=tasks/software/tsift.md pane=%77 session=tsift-v0.1\n",
2366                "[1776712373] cwd_resolved path=/tmp/replace-me source=project_root\n",
2367                "[1776712374] codex_start mode=fresh restart_count=0\n",
2368                "[1776712375] auto_trigger_timeout harness=codex reason=no_prompt_after_30s\n"
2369            )
2370            .replace("/tmp/replace-me", &root.path().display().to_string()),
2371        )
2372        .unwrap();
2373
2374        let claude_dir = home
2375            .path()
2376            .join(".claude/projects")
2377            .join(claude_project_slug(root.path()));
2378        fs::create_dir_all(&claude_dir).unwrap();
2379        fs::write(
2380            claude_dir.join("claude.jsonl"),
2381            concat!(
2382                r#"{"cwd":"/tmp/replace-me","message":{"role":"user","content":"agent-doc /tmp/replace-me/tasks/software/tsift.md"}}"#,
2383                "\n",
2384                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"}}]}}"#,
2385                "\n"
2386            )
2387            .replace("/tmp/replace-me", &root.path().display().to_string()),
2388        )
2389        .unwrap();
2390
2391        let codex_dir = home.path().join(".codex/sessions/2026/05/05");
2392        fs::create_dir_all(&codex_dir).unwrap();
2393        fs::write(
2394            codex_dir.join("rollout-1.jsonl"),
2395            concat!(
2396                r#"{"type":"session_meta","payload":{"cwd":"/tmp/replace-me"}}"#,
2397                "\n",
2398                r#"{"type":"event_msg","payload":{"type":"user_message","message":"agent-doc /tmp/replace-me/tasks/software/tsift.md"}}"#,
2399                "\n",
2400                r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo build --release\"}"}}"#,
2401                "\n",
2402                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}}}}"#,
2403                "\n"
2404            )
2405            .replace("/tmp/replace-me", &root.path().display().to_string()),
2406        )
2407        .unwrap();
2408
2409        let report = compute_with_options(
2410            &target,
2411            &SessionReviewOptions {
2412                claude_projects_dir: Some(home.path().join(".claude/projects")),
2413                codex_sessions_dir: Some(home.path().join(".codex/sessions")),
2414                agent_doc_logs_dir: Some(agent_doc_logs),
2415            },
2416        )
2417        .unwrap();
2418
2419        assert_eq!(report.target_kind, "file");
2420        assert_eq!(report.sessions_matched, 3);
2421        assert_eq!(report.claude_sessions, 1);
2422        assert_eq!(report.codex_sessions, 1);
2423        assert_eq!(report.agent_doc_logs, 1);
2424        assert!(report.prompt_tokens >= 1200);
2425        assert!(
2426            report
2427                .guardrails
2428                .iter()
2429                .any(|guardrail| guardrail.kind == "restart_loop")
2430        );
2431        assert!(
2432            report
2433                .next_context
2434                .unresolved_failures
2435                .iter()
2436                .any(|failure| failure.kind == "guardrail:restart_loop"
2437                    && failure.message.contains("restart churn detected"))
2438        );
2439        assert!(
2440            report
2441                .commands
2442                .iter()
2443                .any(|command| command.command == "cargo test")
2444        );
2445        assert!(
2446            report
2447                .commands
2448                .iter()
2449                .any(|command| command.command == "cargo build --release")
2450        );
2451        assert!(report.sessions.iter().any(|session| {
2452            session
2453                .matched_by
2454                .iter()
2455                .any(|reason| reason == "agent_doc_session")
2456        }));
2457        assert_eq!(
2458            report.next_context.active_prompt_targets,
2459            Vec::<String>::new()
2460        );
2461        assert_eq!(report.next_context.last_verification.status, "missing");
2462        assert!(report.next_context.next_digest_commands.iter().any(
2463            |command| command == "tsift session-review --next-context tasks/software/tsift.md"
2464        ));
2465    }
2466
2467    #[test]
2468    fn session_review_next_context_tracks_prompts_verification_and_failures() {
2469        let root = tempfile::tempdir().unwrap();
2470        let home = tempfile::tempdir().unwrap();
2471        let target = root.path().join("tasks/software/tsift.md");
2472        fs::create_dir(root.path().join(".git")).unwrap();
2473        fs::create_dir_all(target.parent().unwrap()).unwrap();
2474        fs::create_dir_all(root.path().join("src")).unwrap();
2475        fs::write(root.path().join("src/lib.rs"), "fn run_sync() {}\n").unwrap();
2476        fs::write(
2477            &target,
2478            "---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n",
2479        )
2480        .unwrap();
2481
2482        let agent_doc_logs = root.path().join(".agent-doc/logs");
2483        fs::create_dir_all(&agent_doc_logs).unwrap();
2484        fs::write(
2485            agent_doc_logs.join("tsift-v0.1.log"),
2486            concat!(
2487                "[1776712372] session_start file=tasks/software/tsift.md pane=%77 session=tsift-v0.1\n",
2488                "[1776712373] cwd_resolved path=/tmp/replace-me source=project_root\n"
2489            )
2490            .replace("/tmp/replace-me", &root.path().display().to_string()),
2491        )
2492        .unwrap();
2493
2494        let claude_dir = home
2495            .path()
2496            .join(".claude/projects")
2497            .join(claude_project_slug(root.path()));
2498        fs::create_dir_all(&claude_dir).unwrap();
2499        fs::write(
2500            claude_dir.join("claude.jsonl"),
2501            concat!(
2502                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"}}"#,
2503                "\n",
2504                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"}]}}"#,
2505                "\n"
2506            )
2507            .replace("/tmp/replace-me", &root.path().display().to_string()),
2508        )
2509        .unwrap();
2510
2511        let codex_dir = home.path().join(".codex/sessions/2026/05/05");
2512        fs::create_dir_all(&codex_dir).unwrap();
2513        fs::write(
2514            codex_dir.join("rollout-1.jsonl"),
2515            concat!(
2516                r#"{"type":"session_meta","payload":{"cwd":"/tmp/replace-me"}}"#,
2517                "\n",
2518                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"}}"#,
2519                "\n"
2520            )
2521            .replace("/tmp/replace-me", &root.path().display().to_string()),
2522        )
2523        .unwrap();
2524
2525        let report = compute_with_options(
2526            &target,
2527            &SessionReviewOptions {
2528                claude_projects_dir: Some(home.path().join(".claude/projects")),
2529                codex_sessions_dir: Some(home.path().join(".codex/sessions")),
2530                agent_doc_logs_dir: Some(agent_doc_logs),
2531            },
2532        )
2533        .unwrap();
2534
2535        assert_eq!(
2536            report.next_context.active_prompt_targets,
2537            vec!["do [#ctxpack]. spec-test-build-install-commit-push".to_string()]
2538        );
2539        assert_eq!(report.next_context.last_verification.status, "passed");
2540        assert!(
2541            report
2542                .next_context
2543                .last_verification
2544                .detail
2545                .contains("Verification in `src/tsift`")
2546        );
2547        assert!(
2548            report
2549                .next_context
2550                .touched_files
2551                .iter()
2552                .any(|path| path == "Cargo.toml")
2553        );
2554        assert!(
2555            report
2556                .next_context
2557                .touched_symbols
2558                .iter()
2559                .any(|symbol| symbol == "run_sync")
2560        );
2561        assert!(
2562            report
2563                .next_context
2564                .unresolved_failures
2565                .iter()
2566                .any(|failure| failure.kind == "missing" || failure.kind == "error")
2567        );
2568    }
2569
2570    #[test]
2571    fn session_review_next_context_prefers_live_exchange_prompt_targets() {
2572        let root = tempfile::tempdir().unwrap();
2573        let home = tempfile::tempdir().unwrap();
2574        let target = root.path().join("tasks/software/tsift.md");
2575        fs::create_dir(root.path().join(".git")).unwrap();
2576        fs::create_dir_all(target.parent().unwrap()).unwrap();
2577        fs::write(
2578            &target,
2579            "\
2580---
2581agent_doc_session: tsift-v0.1
2582agent_doc_format: template
2583prompt_presets:
2584  '#spec-test-build-install-commit-push': update spec + tests. build + install for local testing. commit + push
2585---
2586
2587## Exchange
2588
2589<!-- agent:exchange patch=append -->
2590### Session Summary
2591
2592Compacted content:
2593- Archived 2 response topic(s): #old1 search workflow; #old2 build workflow
2594<!-- agent:boundary:abc123 -->
2595do [#active]. spec-test-build-install-commit-push
2596<!-- /agent:exchange -->
2597
2598## Queue
2599
2600<!-- agent:queue preset=\"#spec-test-build-install-commit-push\" go -->
2601- ~~[#done]~~
2602- [#active]
2603- [#later]
2604<!-- /agent:queue -->
2605
2606## Backlog
2607
2608<!-- agent:backlog priority queue -->
2609- [ ] [#active] Add the active queue profile to context-pack.
2610- [ ] [#later] Later prompt should remain queued.
2611- [x] [#done] Completed prompt should stay out of the active profile.
2612<!-- /agent:backlog -->
2613
2614## Review
2615
2616<!-- agent:review -->
2617- [ ] [#review] Verify the queue profile output.
2618<!-- /agent:review -->
2619
2620## Completed / Reaped
2621
2622<!-- agent:done -->
2623- 2026-05-12 [#old1] do [#old1]. spec-test-build-install-commit-push
2624<!-- /agent:done -->
2625",
2626        )
2627        .unwrap();
2628
2629        let agent_doc_logs = root.path().join(".agent-doc/logs");
2630        fs::create_dir_all(&agent_doc_logs).unwrap();
2631        fs::write(
2632            agent_doc_logs.join("tsift-v0.1.log"),
2633            concat!(
2634                "[1776712372] session_start file=tasks/software/tsift.md pane=%77 session=tsift-v0.1\n",
2635                "[1776712373] cwd_resolved path=/tmp/replace-me source=project_root\n"
2636            )
2637            .replace("/tmp/replace-me", &root.path().display().to_string()),
2638        )
2639        .unwrap();
2640
2641        let codex_dir = home.path().join(".codex/sessions/2026/05/05");
2642        fs::create_dir_all(&codex_dir).unwrap();
2643        fs::write(
2644            codex_dir.join("rollout-old.jsonl"),
2645            concat!(
2646                r#"{"type":"session_meta","payload":{"cwd":"/tmp/replace-me"}}"#,
2647                "\n",
2648                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"}}"#,
2649                "\n",
2650                r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#old1]. spec-test-build-install-commit-push"}}"#,
2651                "\n",
2652                r####"{"type":"event_msg","payload":{"type":"agent_message","message":"### Re: old work\nError: stale failure at /!\n`/!` should not become active handoff context"}}"####,
2653                "\n"
2654            )
2655            .replace("/tmp/replace-me", &root.path().display().to_string()),
2656        )
2657        .unwrap();
2658
2659        let report = compute_with_options(
2660            &target,
2661            &SessionReviewOptions {
2662                claude_projects_dir: Some(home.path().join(".claude/projects")),
2663                codex_sessions_dir: Some(home.path().join(".codex/sessions")),
2664                agent_doc_logs_dir: Some(agent_doc_logs),
2665            },
2666        )
2667        .unwrap();
2668
2669        assert!(
2670            report
2671                .prompt_targets
2672                .iter()
2673                .any(|prompt| { prompt.text == "do [#old1]. spec-test-build-install-commit-push" })
2674        );
2675        assert_eq!(
2676            report.next_context.active_prompt_targets,
2677            vec!["do [#active]. spec-test-build-install-commit-push".to_string()]
2678        );
2679        let queue_profile = report
2680            .next_context
2681            .agent_doc_queue
2682            .as_ref()
2683            .expect("agent-doc queue profile should be present");
2684        assert_eq!(
2685            queue_profile.active_queue_prompt.as_deref(),
2686            Some("[#active] Add the active queue profile to context-pack.")
2687        );
2688        assert_eq!(
2689            queue_profile.live_exchange_tail,
2690            vec!["do [#active]. spec-test-build-install-commit-push".to_string()]
2691        );
2692        assert!(
2693            queue_profile
2694                .backlog_rows
2695                .iter()
2696                .any(|row| row == "[#later] Later prompt should remain queued.")
2697        );
2698        assert!(
2699            queue_profile
2700                .backlog_rows
2701                .iter()
2702                .all(|row| !row.contains("#done"))
2703        );
2704        assert_eq!(
2705            queue_profile.review_rows,
2706            vec!["[#review] Verify the queue profile output.".to_string()]
2707        );
2708        assert!(
2709            queue_profile
2710                .prompt_presets
2711                .iter()
2712                .any(|preset| preset.starts_with("#spec-test-build-install-commit-push:"))
2713        );
2714        assert!(
2715            queue_profile
2716                .expansion_handles
2717                .iter()
2718                .any(|handle| handle.expand.contains("context-pack"))
2719        );
2720        assert!(
2721            report
2722                .touched_files
2723                .iter()
2724                .all(|file_ref| file_ref.path != "/!")
2725        );
2726        assert!(
2727            report
2728                .failures
2729                .iter()
2730                .any(|failure| failure.message.contains("stale failure"))
2731        );
2732        assert!(
2733            report
2734                .next_context
2735                .touched_files
2736                .iter()
2737                .all(|path| path != "/!")
2738        );
2739        assert!(report.next_context.unresolved_failures.is_empty());
2740    }
2741
2742    #[test]
2743    fn session_review_next_context_scopes_freeform_live_exchange_tail() {
2744        let root = tempfile::tempdir().unwrap();
2745        let home = tempfile::tempdir().unwrap();
2746        let target = root.path().join("tasks/software/tsift.md");
2747        fs::create_dir(root.path().join(".git")).unwrap();
2748        fs::create_dir_all(target.parent().unwrap()).unwrap();
2749        fs::write(
2750            &target,
2751            "\
2752---
2753agent_doc_session: tsift-v0.1
2754agent_doc_format: template
2755---
2756
2757## Exchange
2758
2759<!-- agent:exchange patch=append -->
2760### Session Summary
2761
2762*Compacted. Content archived to `/tmp/archive.md`*
2763
2764Compacted content:
2765- Archived 1 response topic(s): prior review
2766<!-- agent:boundary:freeform -->
2767Evaluate the logs for tsift effectiveness and bugs. #next-steps
2768<!-- /agent:exchange -->
2769",
2770        )
2771        .unwrap();
2772
2773        let agent_doc_logs = root.path().join(".agent-doc/logs");
2774        fs::create_dir_all(&agent_doc_logs).unwrap();
2775        fs::write(
2776            agent_doc_logs.join("tsift-v0.1.log"),
2777            concat!(
2778                "[1776712372] session_start file=tasks/software/tsift.md pane=%77 session=tsift-v0.1\n",
2779                "[1776712373] cwd_resolved path=/tmp/replace-me source=project_root\n"
2780            )
2781            .replace("/tmp/replace-me", &root.path().display().to_string()),
2782        )
2783        .unwrap();
2784
2785        let codex_dir = home.path().join(".codex/sessions/2026/05/05");
2786        fs::create_dir_all(&codex_dir).unwrap();
2787        fs::write(
2788            codex_dir.join("rollout-stale.jsonl"),
2789            concat!(
2790                r#"{"type":"session_meta","payload":{"cwd":"/tmp/replace-me"}}"#,
2791                "\n",
2792                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"}}"#,
2793                "\n",
2794                r####"{"type":"event_msg","payload":{"type":"agent_message","message":"### Re: stale work\nError: old unresolved failure at /!\n`/!` should not be active context"}}"####,
2795                "\n"
2796            )
2797            .replace("/tmp/replace-me", &root.path().display().to_string()),
2798        )
2799        .unwrap();
2800
2801        let report = compute_with_options(
2802            &target,
2803            &SessionReviewOptions {
2804                claude_projects_dir: Some(home.path().join(".claude/projects")),
2805                codex_sessions_dir: Some(home.path().join(".codex/sessions")),
2806                agent_doc_logs_dir: Some(agent_doc_logs),
2807            },
2808        )
2809        .unwrap();
2810
2811        assert_eq!(
2812            report.next_context.active_prompt_targets,
2813            vec!["Evaluate the logs for tsift effectiveness and bugs. #next-steps".to_string()]
2814        );
2815        assert!(report.next_context.touched_files.is_empty());
2816        assert!(report.next_context.unresolved_failures.is_empty());
2817    }
2818
2819    #[test]
2820    fn session_review_ignores_assistant_failure_meta_progress() {
2821        let root = tempfile::tempdir().unwrap();
2822        let home = tempfile::tempdir().unwrap();
2823        let target = root.path().join("tasks/software/tsift.md");
2824        fs::create_dir(root.path().join(".git")).unwrap();
2825        fs::create_dir_all(target.parent().unwrap()).unwrap();
2826        fs::write(
2827            &target,
2828            "\
2829---
2830agent_doc_session: tsift-v0.1
2831agent_doc_format: template
2832---
2833
2834## Exchange
2835
2836<!-- agent:exchange patch=append -->
2837### Session Summary
2838
2839Prior summary without active failures.
2840<!-- agent:boundary:abc123 -->
2841<!-- /agent:exchange -->
2842",
2843        )
2844        .unwrap();
2845
2846        let agent_doc_logs = root.path().join(".agent-doc/logs");
2847        fs::create_dir_all(&agent_doc_logs).unwrap();
2848        fs::write(
2849            agent_doc_logs.join("tsift-v0.1.log"),
2850            concat!(
2851                "[1776712372] session_start file=tasks/software/tsift.md pane=%77 session=tsift-v0.1\n",
2852                "[1776712373] cwd_resolved path=/tmp/replace-me source=project_root\n"
2853            )
2854            .replace("/tmp/replace-me", &root.path().display().to_string()),
2855        )
2856        .unwrap();
2857
2858        let codex_dir = home.path().join(".codex/sessions/2026/05/05");
2859        fs::create_dir_all(&codex_dir).unwrap();
2860        fs::write(
2861            codex_dir.join("rollout-progress.jsonl"),
2862            concat!(
2863                r#"{"type":"session_meta","payload":{"cwd":"/tmp/replace-me"}}"#,
2864                "\n",
2865                r#"{"type":"event_msg","payload":{"type":"user_message","message":"agent-doc /tmp/replace-me/tasks/software/tsift.md"}}"#,
2866                "\n",
2867                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."}}"#,
2868                "\n"
2869            )
2870            .replace("/tmp/replace-me", &root.path().display().to_string()),
2871        )
2872        .unwrap();
2873
2874        let report = compute_with_options(
2875            &target,
2876            &SessionReviewOptions {
2877                claude_projects_dir: Some(home.path().join(".claude/projects")),
2878                codex_sessions_dir: Some(home.path().join(".codex/sessions")),
2879                agent_doc_logs_dir: Some(agent_doc_logs),
2880            },
2881        )
2882        .unwrap();
2883
2884        assert_eq!(report.sessions_matched, 2);
2885        assert!(report.failures.is_empty());
2886        assert!(report.next_context.unresolved_failures.is_empty());
2887    }
2888
2889    #[test]
2890    fn session_review_failure_rows_keep_command_and_session_anchors() {
2891        let root = tempfile::tempdir().unwrap();
2892        let home = tempfile::tempdir().unwrap();
2893        let target = root.path().join("tasks/software/tsift.md");
2894        fs::create_dir(root.path().join(".git")).unwrap();
2895        fs::create_dir_all(target.parent().unwrap()).unwrap();
2896        fs::write(
2897            &target,
2898            "---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n",
2899        )
2900        .unwrap();
2901
2902        let agent_doc_logs = root.path().join(".agent-doc/logs");
2903        fs::create_dir_all(&agent_doc_logs).unwrap();
2904        fs::write(
2905            agent_doc_logs.join("tsift-v0.1.log"),
2906            concat!(
2907                "[1776712372] session_start file=tasks/software/tsift.md pane=%77 session=tsift-v0.1\n",
2908                "[1776712373] cwd_resolved path=/tmp/replace-me source=project_root\n"
2909            )
2910            .replace("/tmp/replace-me", &root.path().display().to_string()),
2911        )
2912        .unwrap();
2913
2914        let codex_dir = home.path().join(".codex/sessions/2026/05/05");
2915        fs::create_dir_all(&codex_dir).unwrap();
2916        let rollout_path = codex_dir.join("rollout-failure.jsonl");
2917        fs::write(
2918            &rollout_path,
2919            concat!(
2920                r#"{"type":"session_meta","payload":{"cwd":"/tmp/replace-me"}}"#,
2921                "\n",
2922                r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#sfail]. Tighten failure extraction.\nagent-doc /tmp/replace-me/tasks/software/tsift.md"}}"#,
2923                "\n",
2924                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"}]}}"#,
2925                "\n"
2926            )
2927            .replace("/tmp/replace-me", &root.path().display().to_string()),
2928        )
2929        .unwrap();
2930
2931        let report = compute_with_options(
2932            &target,
2933            &SessionReviewOptions {
2934                claude_projects_dir: Some(home.path().join(".claude/projects")),
2935                codex_sessions_dir: Some(home.path().join(".codex/sessions")),
2936                agent_doc_logs_dir: Some(agent_doc_logs),
2937            },
2938        )
2939        .unwrap();
2940
2941        assert!(
2942            report
2943                .failures
2944                .iter()
2945                .all(|failure| !failure.message.contains("After finalize")
2946                    && !failure.message.contains("panic!(")
2947                    && failure.message != "command exited with code 1")
2948        );
2949        assert!(report.failures.iter().any(|failure| {
2950            failure.message == "cargo test exited with code 1"
2951                && failure.command.as_deref() == Some("cargo test")
2952                && failure.session_path.as_deref() == Some(rollout_path.to_str().unwrap())
2953        }));
2954        assert!(report.failures.iter().any(|failure| {
2955            failure.message.contains("assertion failed")
2956                && failure.command.as_deref() == Some("cargo test")
2957                && failure.session_path.as_deref() == Some(rollout_path.to_str().unwrap())
2958        }));
2959    }
2960
2961    #[test]
2962    fn session_review_aggregates_loop_clusters() {
2963        let root = tempfile::tempdir().unwrap();
2964        let home = tempfile::tempdir().unwrap();
2965        let target = root.path().join("tasks/software/tsift.md");
2966        fs::create_dir(root.path().join(".git")).unwrap();
2967        fs::create_dir_all(target.parent().unwrap()).unwrap();
2968        fs::write(
2969            &target,
2970            "---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n",
2971        )
2972        .unwrap();
2973
2974        let agent_doc_logs = root.path().join(".agent-doc/logs");
2975        fs::create_dir_all(&agent_doc_logs).unwrap();
2976        fs::write(
2977            agent_doc_logs.join("tsift-v0.1.log"),
2978            concat!(
2979                "[1776712372] session_start file=tasks/software/tsift.md pane=%77 session=tsift-v0.1\n",
2980                "[1776712373] cwd_resolved path=/tmp/replace-me source=project_root\n",
2981                "[1776712374] commit_already_current file=tasks/software/tsift.md basis=head\n",
2982                "[1776712375] commit_already_current file=tasks/software/tsift.md basis=head\n",
2983                "[1776712376] commit_already_current file=tasks/software/tsift.md basis=head\n"
2984            )
2985            .replace("/tmp/replace-me", &root.path().display().to_string()),
2986        )
2987        .unwrap();
2988
2989        let codex_dir = home.path().join(".codex/sessions/2026/05/05");
2990        fs::create_dir_all(&codex_dir).unwrap();
2991        fs::write(
2992            codex_dir.join("rollout-1.jsonl"),
2993            concat!(
2994                r#"{"type":"session_meta","payload":{"cwd":"/tmp/replace-me"}}"#,
2995                "\n",
2996                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"}}"#,
2997                "\n",
2998                r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo test\"}"}}"#,
2999                "\n",
3000                r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo build --release\"}"}}"#,
3001                "\n",
3002                r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"sed -n '1,80p' src/session_review.rs\"}"}}"#,
3003                "\n",
3004                r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"sed -n '1,80p' src/session_review.rs\"}"}}"#,
3005                "\n",
3006                r#"{"type":"event_msg","payload":{"type":"agent_message","message":"Committed and pushed in `src/tsift` as `abc123`."}}"#,
3007                "\n",
3008                r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#looprank]. spec-test-build-install-commit-push"}}"#,
3009                "\n",
3010                r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo test\"}"}}"#,
3011                "\n",
3012                r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo build --release\"}"}}"#,
3013                "\n",
3014                r#"{"type":"event_msg","payload":{"type":"agent_message","message":"Committed and pushed in `src/tsift` as `abc123`."}}"#,
3015                "\n"
3016            )
3017            .replace("/tmp/replace-me", &root.path().display().to_string()),
3018        )
3019        .unwrap();
3020
3021        let report = compute_with_options(
3022            &target,
3023            &SessionReviewOptions {
3024                claude_projects_dir: Some(home.path().join(".claude/projects")),
3025                codex_sessions_dir: Some(home.path().join(".codex/sessions")),
3026                agent_doc_logs_dir: Some(agent_doc_logs),
3027            },
3028        )
3029        .unwrap();
3030
3031        assert!(
3032            report
3033                .loop_clusters
3034                .iter()
3035                .any(|cluster| cluster.kind == "prompt_repeat"
3036                    && cluster.label == "do [#looprank]. spec-test-build-install-commit-push"
3037                    && cluster.occurrences == 2)
3038        );
3039        assert!(
3040            report
3041                .loop_clusters
3042                .iter()
3043                .any(|cluster| cluster.kind == "command_bundle"
3044                    && cluster.label == "cargo test -> cargo build --release"
3045                    && cluster.occurrences == 2)
3046        );
3047        assert!(
3048            report
3049                .loop_clusters
3050                .iter()
3051                .any(|cluster| cluster.kind == "closeout_churn"
3052                    && cluster.label == "commit_already_current"
3053                    && cluster.occurrences == 3)
3054        );
3055        assert!(
3056            report
3057                .file_read_diagnostics
3058                .iter()
3059                .any(|diagnostic| diagnostic.path == "src/session_review.rs"
3060                    && diagnostic.range == "1-80"
3061                    && diagnostic.occurrences == 2
3062                    && diagnostic.duplicate_estimated_tokens == 1_440
3063                    && diagnostic.follow_up_commands.iter().any(|command| {
3064                        command
3065                            == "tsift source-read src/session_review.rs --start 1 --lines 80 --budget normal"
3066                    }))
3067        );
3068    }
3069
3070    #[test]
3071    fn session_review_skips_cwd_only_harness_logs_for_doc_target() {
3072        let root = tempfile::tempdir().unwrap();
3073        let home = tempfile::tempdir().unwrap();
3074        let target = root.path().join("tasks/software/tsift.md");
3075        fs::create_dir(root.path().join(".git")).unwrap();
3076        fs::create_dir_all(target.parent().unwrap()).unwrap();
3077        fs::write(
3078            &target,
3079            "---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n",
3080        )
3081        .unwrap();
3082
3083        let agent_doc_logs = root.path().join(".agent-doc/logs");
3084        fs::create_dir_all(&agent_doc_logs).unwrap();
3085        fs::write(
3086            agent_doc_logs.join("tsift-v0.1.log"),
3087            concat!(
3088                "[1776712372] session_start file=tasks/software/tsift.md pane=%77 session=tsift-v0.1\n",
3089                "[1776712373] cwd_resolved path=/tmp/replace-me source=project_root\n"
3090            )
3091            .replace("/tmp/replace-me", &root.path().display().to_string()),
3092        )
3093        .unwrap();
3094
3095        let claude_dir = home
3096            .path()
3097            .join(".claude/projects")
3098            .join(claude_project_slug(root.path()));
3099        fs::create_dir_all(&claude_dir).unwrap();
3100        fs::write(
3101            claude_dir.join("claude-target.jsonl"),
3102            concat!(
3103                r#"{"cwd":"/tmp/replace-me","message":{"role":"user","content":"agent-doc /tmp/replace-me/tasks/software/tsift.md"}}"#,
3104                "\n"
3105            )
3106            .replace("/tmp/replace-me", &root.path().display().to_string()),
3107        )
3108        .unwrap();
3109        fs::write(
3110            claude_dir.join("claude-cwd-only.jsonl"),
3111            concat!(
3112                r#"{"cwd":"/tmp/replace-me","message":{"role":"user","content":"help me inspect another task"}}"#,
3113                "\n"
3114            )
3115            .replace("/tmp/replace-me", &root.path().display().to_string()),
3116        )
3117        .unwrap();
3118
3119        let codex_dir = home.path().join(".codex/sessions/2026/05/05");
3120        fs::create_dir_all(&codex_dir).unwrap();
3121        fs::write(
3122            codex_dir.join("codex-target.jsonl"),
3123            concat!(
3124                r#"{"type":"session_meta","payload":{"cwd":"/tmp/replace-me"}}"#,
3125                "\n",
3126                r#"{"type":"event_msg","payload":{"type":"user_message","message":"agent-doc /tmp/replace-me/tasks/software/tsift.md"}}"#,
3127                "\n"
3128            )
3129            .replace("/tmp/replace-me", &root.path().display().to_string()),
3130        )
3131        .unwrap();
3132        fs::write(
3133            codex_dir.join("codex-cwd-only.jsonl"),
3134            concat!(
3135                r#"{"type":"session_meta","payload":{"cwd":"/tmp/replace-me"}}"#,
3136                "\n",
3137                r#"{"type":"event_msg","payload":{"type":"user_message","message":"open a different issue from this repo"}}"#,
3138                "\n"
3139            )
3140            .replace("/tmp/replace-me", &root.path().display().to_string()),
3141        )
3142        .unwrap();
3143
3144        let report = compute_with_options(
3145            &target,
3146            &SessionReviewOptions {
3147                claude_projects_dir: Some(home.path().join(".claude/projects")),
3148                codex_sessions_dir: Some(home.path().join(".codex/sessions")),
3149                agent_doc_logs_dir: Some(agent_doc_logs),
3150            },
3151        )
3152        .unwrap();
3153
3154        assert_eq!(report.sessions_considered, 5);
3155        assert_eq!(report.sessions_matched, 3);
3156        assert_eq!(report.claude_sessions, 1);
3157        assert_eq!(report.codex_sessions, 1);
3158        assert_eq!(report.agent_doc_logs, 1);
3159        assert!(report.sessions.iter().all(|session| {
3160            session.source == "agent_doc_log"
3161                || session
3162                    .matched_by
3163                    .iter()
3164                    .any(|reason| reason == "agent_doc_session" || reason.starts_with("path:"))
3165        }));
3166    }
3167
3168    #[test]
3169    fn session_review_uses_historical_aliases_and_skips_noisy_transcript_records() {
3170        let root = tempfile::tempdir().unwrap();
3171        let home = tempfile::tempdir().unwrap();
3172        let target = root.path().join("tasks/software/tsift.md");
3173        fs::create_dir(root.path().join(".git")).unwrap();
3174        fs::create_dir_all(target.parent().unwrap()).unwrap();
3175        fs::write(
3176            &target,
3177            "---\nagent_doc_session: tsift-v0.1\n---\n\n## Exchange\n",
3178        )
3179        .unwrap();
3180
3181        let agent_doc_logs = root.path().join(".agent-doc/logs");
3182        fs::create_dir_all(&agent_doc_logs).unwrap();
3183        fs::write(
3184            agent_doc_logs.join("tsift-v0.1.log"),
3185            concat!(
3186                "[1776712372] session_start file=tasks/tsift.md pane=%77 session=tsift-v0\n",
3187                "[1776712373] session_start file=tasks/software/tsift.md pane=%78 session=tsift-v0.1\n",
3188                "[1776712374] cwd_resolved path=/tmp/replace-me source=project_root\n"
3189            )
3190            .replace("/tmp/replace-me", &root.path().display().to_string()),
3191        )
3192        .unwrap();
3193
3194        let claude_dir = home
3195            .path()
3196            .join(".claude/projects")
3197            .join(claude_project_slug(root.path()));
3198        fs::create_dir_all(&claude_dir).unwrap();
3199        fs::write(
3200            claude_dir.join("claude-target.jsonl"),
3201            concat!(
3202                "not-json\n",
3203                r#"{"cwd":"/tmp/replace-me","message":{"role":"user","content":"resume session tsift-v0\nagent-doc tasks/tsift.md"}}"#,
3204                "\n",
3205                r#"{"attachment":{"type":"hook_success","content":"tasks/software/tsift.md from context index only"}}"#,
3206                "\n"
3207            )
3208            .replace("/tmp/replace-me", &root.path().display().to_string()),
3209        )
3210        .unwrap();
3211        fs::write(
3212            claude_dir.join("claude-noisy.jsonl"),
3213            concat!(
3214                r#"{"cwd":"/tmp/replace-me","attachment":{"type":"hook_success","content":"tasks/software/tsift.md only in hook output"}}"#,
3215                "\n"
3216            )
3217            .replace("/tmp/replace-me", &root.path().display().to_string()),
3218        )
3219        .unwrap();
3220
3221        let codex_dir = home.path().join(".codex/sessions/2026/05/05");
3222        fs::create_dir_all(&codex_dir).unwrap();
3223        fs::write(
3224            codex_dir.join("codex-target.jsonl"),
3225            concat!(
3226                "not-json\n",
3227                r#"{"type":"session_meta","payload":{"cwd":"/tmp/replace-me"}}"#,
3228                "\n",
3229                r#"{"type":"event_msg","payload":{"type":"user_message","message":"resume tsift-v0\nagent-doc tasks/tsift.md"}}"#,
3230                "\n",
3231                r#"{"type":"response_item","payload":{"type":"function_call_output","output":"tasks/software/tsift.md from stdout"}}"#,
3232                "\n"
3233            )
3234            .replace("/tmp/replace-me", &root.path().display().to_string()),
3235        )
3236        .unwrap();
3237        fs::write(
3238            codex_dir.join("codex-noisy.jsonl"),
3239            concat!(
3240                r#"{"type":"session_meta","payload":{"cwd":"/tmp/replace-me"}}"#,
3241                "\n",
3242                r#"{"type":"response_item","payload":{"type":"function_call_output","output":"tasks/software/tsift.md only in output"}}"#,
3243                "\n"
3244            )
3245            .replace("/tmp/replace-me", &root.path().display().to_string()),
3246        )
3247        .unwrap();
3248
3249        let report = compute_with_options(
3250            &target,
3251            &SessionReviewOptions {
3252                claude_projects_dir: Some(home.path().join(".claude/projects")),
3253                codex_sessions_dir: Some(home.path().join(".codex/sessions")),
3254                agent_doc_logs_dir: Some(agent_doc_logs),
3255            },
3256        )
3257        .unwrap();
3258
3259        assert_eq!(report.sessions_considered, 5);
3260        assert_eq!(report.sessions_matched, 3);
3261        assert_eq!(report.claude_sessions, 1);
3262        assert_eq!(report.codex_sessions, 1);
3263        assert_eq!(report.agent_doc_logs, 1);
3264        assert!(report.sessions.iter().any(|session| {
3265            session.path.ends_with("claude-target.jsonl")
3266                && session
3267                    .matched_by
3268                    .iter()
3269                    .any(|reason| reason == "agent_doc_session" || reason == "path:tasks/tsift.md")
3270        }));
3271        assert!(report.sessions.iter().any(|session| {
3272            session.path.ends_with("codex-target.jsonl")
3273                && session
3274                    .matched_by
3275                    .iter()
3276                    .any(|reason| reason == "agent_doc_session" || reason == "path:tasks/tsift.md")
3277        }));
3278        assert!(
3279            report.warnings.iter().any(
3280                |warning| warning.contains("skipping malformed Claude transcript jsonl line 1")
3281            )
3282        );
3283        assert!(
3284            report
3285                .warnings
3286                .iter()
3287                .any(|warning| warning.contains("skipping malformed Codex transcript jsonl line 1"))
3288        );
3289    }
3290
3291    fn roi_row(
3292        net: i64,
3293        ratio: &str,
3294        trend: &str,
3295        cause: &str,
3296    ) -> SessionCostPromptCacheRoiScorecard {
3297        SessionCostPromptCacheRoiScorecard {
3298            session_source: Some("codex_jsonl".to_string()),
3299            session_path: Some("/proj/session.jsonl".to_string()),
3300            provider: "anthropic".to_string(),
3301            sample_count: 3,
3302            net_cached_read_tokens: net,
3303            read_create_ratio: ratio.to_string(),
3304            trend: trend.to_string(),
3305            suspected_invalidation_cause: cause.to_string(),
3306            next_command: "tsift session-cost --source codex --input s.jsonl --json".to_string(),
3307        }
3308    }
3309
3310    #[test]
3311    fn prompt_cache_health_none_without_any_signal() {
3312        assert!(build_prompt_cache_health(None, None).is_none());
3313    }
3314
3315    #[test]
3316    fn prompt_cache_health_healthy_from_ratio_only() {
3317        let health = build_prompt_cache_health(Some(72.5), None).unwrap();
3318        assert_eq!(health.status, "healthy");
3319        assert!(health.summary_line.contains("ratio 72.50%"));
3320        assert!(health.top_drift_attribution.is_none());
3321    }
3322
3323    #[test]
3324    fn prompt_cache_health_watch_when_drift_cause_present() {
3325        let roi = roi_row(5_000, "5.00", "steady", "stable_prefix changed");
3326        let health = build_prompt_cache_health(Some(60.0), Some(&roi)).unwrap();
3327        assert_eq!(health.status, "watch");
3328        assert_eq!(
3329            health.top_drift_attribution.as_deref(),
3330            Some("stable_prefix changed")
3331        );
3332        assert!(health.summary_line.contains("drift: stable_prefix changed"));
3333    }
3334
3335    #[test]
3336    fn prompt_cache_health_regressed_when_net_negative() {
3337        let roi = roi_row(-2_000, "0.50", "declining", "none");
3338        let health = build_prompt_cache_health(Some(20.0), Some(&roi)).unwrap();
3339        assert_eq!(health.status, "regressed");
3340        // "none" cause is suppressed as attribution.
3341        assert!(health.top_drift_attribution.is_none());
3342        assert!(health.summary_line.contains("net_cached -2000"));
3343    }
3344
3345    #[test]
3346    fn enrich_with_cross_run_escalates_to_regressed() {
3347        let base = build_prompt_cache_health(Some(60.0), None);
3348        let enriched = enrich_prompt_cache_health_with_cross_run(
3349            base,
3350            &["cached_input_ratio fell 8.00 points (68.00% -> 60.00%)".to_string()],
3351        )
3352        .unwrap();
3353        assert_eq!(enriched.status, "regressed");
3354        assert_eq!(enriched.cross_run_regressions.len(), 1);
3355        assert!(enriched.summary_line.starts_with("prompt-cache regressed:"));
3356        assert!(
3357            enriched
3358                .summary_line
3359                .contains("cross-run: cached_input_ratio fell")
3360        );
3361    }
3362
3363    #[test]
3364    fn enrich_with_no_cross_run_is_passthrough() {
3365        let base = build_prompt_cache_health(Some(60.0), None);
3366        let enriched = enrich_prompt_cache_health_with_cross_run(base.clone(), &[]);
3367        assert_eq!(enriched, base);
3368    }
3369
3370    #[test]
3371    fn enrich_with_cross_run_creates_health_when_base_missing() {
3372        let enriched = enrich_prompt_cache_health_with_cross_run(
3373            None,
3374            &["net_cached_input_tokens went negative (100 -> -50)".to_string()],
3375        )
3376        .unwrap();
3377        assert_eq!(enriched.status, "regressed");
3378        assert!(
3379            enriched
3380                .summary_line
3381                .contains("cross-run: net_cached_input_tokens went negative")
3382        );
3383    }
3384}