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