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