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