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