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;
28const 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#[derive(Debug, Clone, PartialEq, Serialize)]
183pub struct SessionReviewPromptCacheHealth {
184 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 #[serde(skip_serializing_if = "Option::is_none")]
197 pub top_drift_attribution: Option<String>,
198 #[serde(skip_serializing_if = "Vec::is_empty", default)]
201 pub cross_run_regressions: Vec<String>,
202 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
221pub 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 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
284pub 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 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 #[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 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}