1use anyhow::{Result, bail};
2use serde::Serialize;
3use serde_json::Value;
4use std::collections::{BTreeMap, BTreeSet};
5use std::path::{Path, PathBuf};
6
7use tsift_quality::runtime_churn::{RestartChurnState, RestartChurnSummary};
8
9const MAX_PROMPT_TARGETS: usize = 8;
10const MAX_COMMANDS: usize = 12;
11const MAX_FILES: usize = 12;
12const MAX_SYMBOLS: usize = 12;
13const MAX_FAILURES: usize = 12;
14const MAX_CLOSEOUT: usize = 10;
15const MAX_RUNTIME_EVENTS: usize = 10;
16const MAX_GRAPH_EVIDENCE: usize = 8;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "snake_case")]
22pub enum SessionDigestSource {
23 Markdown,
24 ClaudeJsonl,
25 CodexJsonl,
26 AgentDocLog,
27}
28
29impl SessionDigestSource {
30 pub fn parse(raw: &str) -> Result<Self> {
31 match raw.trim().to_ascii_lowercase().as_str() {
32 "markdown" | "md" => Ok(Self::Markdown),
33 "jsonl" | "json-lines" | "claude" | "claude-jsonl" => Ok(Self::ClaudeJsonl),
34 "codex" | "codex-jsonl" => Ok(Self::CodexJsonl),
35 "agent-doc-log" | "agent_doc_log" | "log" => Ok(Self::AgentDocLog),
36 other => bail!(
37 "unsupported session source `{other}`; expected markdown, claude-jsonl, codex-jsonl, or agent-doc-log"
38 ),
39 }
40 }
41
42 pub fn as_str(self) -> &'static str {
43 match self {
44 Self::Markdown => "markdown",
45 Self::ClaudeJsonl => "claude_jsonl",
46 Self::CodexJsonl => "codex_jsonl",
47 Self::AgentDocLog => "agent_doc_log",
48 }
49 }
50
51 pub fn cli_arg(self) -> &'static str {
52 match self {
53 Self::Markdown => "markdown",
54 Self::ClaudeJsonl => "claude-jsonl",
55 Self::CodexJsonl => "codex-jsonl",
56 Self::AgentDocLog => "agent-doc-log",
57 }
58 }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
62pub struct SessionDigestCommand {
63 pub command: String,
64 pub occurrences: usize,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
68pub struct SessionDigestFileRef {
69 pub path: String,
70 pub occurrences: usize,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
74pub struct SessionDigestSymbolRef {
75 pub symbol: String,
76 pub occurrences: usize,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
80pub struct SessionDigestFailure {
81 pub kind: String,
82 pub message: String,
83 pub occurrences: usize,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub command: Option<String>,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
89pub struct SessionDigestCloseout {
90 pub kind: String,
91 pub detail: String,
92 pub occurrences: usize,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
96pub struct SessionDigestRuntimeEvent {
97 pub event: String,
98 pub occurrences: usize,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
103pub struct SessionDigestGraphEntity {
104 pub id: String,
105 pub kind: String,
106 pub label: String,
107 pub incident_edge_count: usize,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
119pub struct SessionDigestGraphEvidence {
120 pub graph_db: String,
121 pub scanned: bool,
122 pub total_nodes: usize,
123 pub total_edges: usize,
124 #[serde(skip_serializing_if = "Option::is_none")]
125 pub symbol_filter: Option<String>,
126 pub entities: Vec<SessionDigestGraphEntity>,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
130pub struct SessionDigestReport {
131 pub root: String,
132 pub source: String,
133 pub total_lines: usize,
134 pub transcript_items: usize,
135 pub prompt_target_count: usize,
136 pub command_groups: usize,
137 pub file_groups: usize,
138 pub symbol_groups: usize,
139 pub failure_groups: usize,
140 pub runtime_event_groups: usize,
141 pub restart_churn_groups: usize,
142 pub closeout_groups: usize,
143 pub prompt_targets: Vec<String>,
144 pub commands: Vec<SessionDigestCommand>,
145 pub touched_files: Vec<SessionDigestFileRef>,
146 pub touched_symbols: Vec<SessionDigestSymbolRef>,
147 pub failures: Vec<SessionDigestFailure>,
148 pub runtime_events: Vec<SessionDigestRuntimeEvent>,
149 #[serde(skip_serializing_if = "Vec::is_empty", default)]
150 pub restart_churn: Vec<RestartChurnSummary>,
151 pub closeout: Vec<SessionDigestCloseout>,
152 #[serde(skip_serializing_if = "Option::is_none", default)]
153 pub graph_evidence: Option<SessionDigestGraphEvidence>,
154 #[serde(skip_serializing_if = "Vec::is_empty", default)]
155 pub warnings: Vec<String>,
156}
157
158#[derive(Debug, Default)]
159struct DigestState {
160 prompt_targets: Vec<String>,
161 commands: BTreeMap<String, usize>,
162 files: BTreeMap<String, usize>,
163 symbols: BTreeMap<String, usize>,
164 failures: BTreeMap<(String, String, Option<String>), usize>,
165 runtime_events: BTreeMap<String, usize>,
166 seen_document_cycle_events: BTreeSet<(String, String)>,
167 seen_document_cycle_closeout: BTreeSet<(String, String, String)>,
168 restart_churn: RestartChurnState,
169 closeout: BTreeMap<(String, String), usize>,
170 warnings: Vec<String>,
171 transcript_items: usize,
172}
173
174#[derive(Debug, Clone)]
175enum TranscriptBlock {
176 Text { role: Option<String>, text: String },
177 ToolResult { text: String },
178 ToolUse { name: String, input: Value },
179}
180
181pub fn compute(path: &Path, input: &str, source_hint: Option<&str>) -> Result<SessionDigestReport> {
182 if input.trim().is_empty() {
183 bail!("no session input provided; pass --input <file> or pipe transcript on stdin");
184 }
185
186 let root = tsift_quality::lint::resolve_harness_root_or_canonical_path(path)?;
187 let source = resolve_source(input, source_hint)?;
188 let total_lines = input.lines().count();
189 let mut state = DigestState::default();
190
191 match source {
192 SessionDigestSource::Markdown => ingest_markdown(&root, input, &mut state)?,
193 SessionDigestSource::ClaudeJsonl => ingest_claude_jsonl(&root, input, &mut state)?,
194 SessionDigestSource::CodexJsonl => ingest_codex_jsonl(&root, input, &mut state)?,
195 SessionDigestSource::AgentDocLog => ingest_agent_doc_log(&root, input, &mut state),
196 }
197
198 let prompt_target_count = state.prompt_targets.len();
199
200 let mut commands = state
201 .commands
202 .into_iter()
203 .map(|(command, occurrences)| SessionDigestCommand {
204 command,
205 occurrences,
206 })
207 .collect::<Vec<_>>();
208 commands.sort_by(|left, right| {
209 right
210 .occurrences
211 .cmp(&left.occurrences)
212 .then(left.command.cmp(&right.command))
213 });
214 let command_groups = commands.len();
215 commands.truncate(MAX_COMMANDS);
216
217 let mut touched_files = state
218 .files
219 .into_iter()
220 .map(|(path, occurrences)| SessionDigestFileRef { path, occurrences })
221 .collect::<Vec<_>>();
222 touched_files.sort_by(|left, right| {
223 right
224 .occurrences
225 .cmp(&left.occurrences)
226 .then(left.path.cmp(&right.path))
227 });
228 let file_groups = touched_files.len();
229 touched_files.truncate(MAX_FILES);
230
231 let mut touched_symbols = state
232 .symbols
233 .into_iter()
234 .map(|(symbol, occurrences)| SessionDigestSymbolRef {
235 symbol,
236 occurrences,
237 })
238 .collect::<Vec<_>>();
239 touched_symbols.sort_by(|left, right| {
240 right
241 .occurrences
242 .cmp(&left.occurrences)
243 .then(left.symbol.cmp(&right.symbol))
244 });
245 let symbol_groups = touched_symbols.len();
246 touched_symbols.truncate(MAX_SYMBOLS);
247
248 let mut failures = state
249 .failures
250 .into_iter()
251 .map(
252 |((kind, message, command), occurrences)| SessionDigestFailure {
253 kind,
254 message,
255 occurrences,
256 command,
257 },
258 )
259 .collect::<Vec<_>>();
260 failures.sort_by(|left, right| {
261 right
262 .occurrences
263 .cmp(&left.occurrences)
264 .then(left.kind.cmp(&right.kind))
265 .then(left.message.cmp(&right.message))
266 });
267 let failure_groups = failures.len();
268 failures.truncate(MAX_FAILURES);
269
270 let mut runtime_events = state
271 .runtime_events
272 .into_iter()
273 .map(|(event, occurrences)| SessionDigestRuntimeEvent { event, occurrences })
274 .collect::<Vec<_>>();
275 runtime_events.sort_by(|left, right| {
276 right
277 .occurrences
278 .cmp(&left.occurrences)
279 .then(left.event.cmp(&right.event))
280 });
281 let runtime_event_groups = runtime_events.len();
282 runtime_events.truncate(MAX_RUNTIME_EVENTS);
283 let restart_churn_groups = state.restart_churn.groups();
284 let restart_churn = state.restart_churn.summaries();
285
286 let mut closeout = state
287 .closeout
288 .into_iter()
289 .map(|((kind, detail), occurrences)| SessionDigestCloseout {
290 kind,
291 detail,
292 occurrences,
293 })
294 .collect::<Vec<_>>();
295 closeout.sort_by(|left, right| {
296 right
297 .occurrences
298 .cmp(&left.occurrences)
299 .then(left.kind.cmp(&right.kind))
300 .then(left.detail.cmp(&right.detail))
301 });
302 let closeout_groups = closeout.len();
303 closeout.truncate(MAX_CLOSEOUT);
304
305 let top_symbol = touched_symbols.first().map(|s| s.symbol.clone());
309 let graph_evidence = collect_graph_evidence(&root, top_symbol, &mut state.warnings);
310
311 Ok(SessionDigestReport {
312 root: root.display().to_string(),
313 source: source.as_str().to_string(),
314 total_lines,
315 transcript_items: state.transcript_items,
316 prompt_target_count,
317 command_groups,
318 file_groups,
319 symbol_groups,
320 failure_groups,
321 runtime_event_groups,
322 restart_churn_groups,
323 closeout_groups,
324 prompt_targets: state.prompt_targets,
325 commands,
326 touched_files,
327 touched_symbols,
328 failures,
329 runtime_events,
330 restart_churn,
331 closeout,
332 graph_evidence,
333 warnings: state.warnings,
334 })
335}
336
337fn collect_graph_evidence(
344 root: &Path,
345 top_symbol: Option<String>,
346 warnings: &mut Vec<String>,
347) -> Option<SessionDigestGraphEvidence> {
348 use crate::graph_evidence::{
349 DEFAULT_EVIDENCE_MAX_SCAN_NODES, GraphEvidenceQuery, read_graph_evidence_bounded,
350 DEFAULT_GRAPH_DB_RELATIVE,
351 };
352
353 let db_path = root.join(DEFAULT_GRAPH_DB_RELATIVE);
354 if !db_path.exists() {
355 return None;
356 }
357
358 let mut query = GraphEvidenceQuery::default().with_limit(MAX_GRAPH_EVIDENCE);
359 if let Some(symbol) = top_symbol.as_deref() {
360 query = query.with_symbol(symbol);
361 }
362
363 let report =
364 match read_graph_evidence_bounded(&db_path, &query, DEFAULT_EVIDENCE_MAX_SCAN_NODES) {
365 Ok(report) => report,
366 Err(err) => {
367 warnings.push(format!("kg evidence skipped: {err}"));
368 return None;
369 }
370 };
371
372 if !report.exists {
373 return None;
374 }
375
376 let entities = report
377 .matched_nodes
378 .iter()
379 .map(|node| SessionDigestGraphEntity {
380 id: node.id.clone(),
381 kind: node.kind.clone(),
382 label: node.label.clone(),
383 incident_edge_count: node.incident_edge_count,
384 })
385 .collect();
386
387 Some(SessionDigestGraphEvidence {
388 graph_db: report.graph_db,
389 scanned: report.scanned,
390 total_nodes: report.total_nodes_in_db,
391 total_edges: report.total_edges_in_db,
392 symbol_filter: report.query.symbol.clone(),
393 entities,
394 })
395}
396
397fn resolve_source(input: &str, source_hint: Option<&str>) -> Result<SessionDigestSource> {
398 match source_hint {
399 Some(raw) => SessionDigestSource::parse(raw),
400 None => {
401 let non_empty = input
402 .lines()
403 .map(str::trim)
404 .filter(|line| !line.is_empty())
405 .collect::<Vec<_>>();
406 if !non_empty.is_empty()
407 && non_empty.iter().all(|line| {
408 line.starts_with('{') && serde_json::from_str::<Value>(line).is_ok()
409 })
410 {
411 for line in &non_empty {
412 let value = serde_json::from_str::<Value>(line).unwrap_or(Value::Null);
413 if value
414 .get("message")
415 .and_then(|message| message.get("content"))
416 .is_some()
417 || value
418 .get("message")
419 .and_then(|message| message.get("usage"))
420 .is_some()
421 {
422 return Ok(SessionDigestSource::ClaudeJsonl);
423 }
424 if value.get("type").and_then(Value::as_str) == Some("response_item")
425 || value.get("type").and_then(Value::as_str) == Some("event_msg")
426 {
427 return Ok(SessionDigestSource::CodexJsonl);
428 }
429 }
430 Ok(SessionDigestSource::ClaudeJsonl)
431 } else if !non_empty.is_empty()
432 && non_empty
433 .iter()
434 .all(|line| line.starts_with('[') && line.contains(']'))
435 {
436 Ok(SessionDigestSource::AgentDocLog)
437 } else {
438 Ok(SessionDigestSource::Markdown)
439 }
440 }
441 }
442}
443
444fn ingest_markdown(root: &Path, input: &str, state: &mut DigestState) -> Result<()> {
445 let mut in_frontmatter = false;
446 let mut first_line = true;
447 for line in input.lines() {
448 let trimmed = line.trim();
449 if first_line {
450 first_line = false;
451 if trimmed == "---" {
452 in_frontmatter = true;
453 state.transcript_items += 1;
454 continue;
455 }
456 } else if in_frontmatter {
457 state.transcript_items += 1;
458 if trimmed == "---" {
459 in_frontmatter = false;
460 }
461 continue;
462 }
463 state.transcript_items += 1;
464 ingest_text_line(root, line, false, None, state)?;
465 }
466 Ok(())
467}
468
469fn ingest_claude_jsonl(root: &Path, input: &str, state: &mut DigestState) -> Result<()> {
470 for (index, raw_line) in input.lines().enumerate() {
471 let trimmed = raw_line.trim();
472 if trimmed.is_empty() {
473 continue;
474 }
475 let value = match serde_json::from_str::<Value>(trimmed) {
476 Ok(value) => value,
477 Err(_) => {
478 state.warnings.push(format!(
479 "skipping malformed Claude transcript jsonl line {}",
480 index + 1
481 ));
482 continue;
483 }
484 };
485 let mut blocks = Vec::new();
486 collect_transcript_blocks(&value, &mut blocks);
487 if blocks.is_empty() {
488 if !is_ignorable_claude_record(&value) {
489 state.warnings.push(format!(
490 "jsonl line {} did not contain message content or tool_use blocks",
491 index + 1
492 ));
493 }
494 continue;
495 }
496 let mut last_tool_command = None::<String>;
497 for block in blocks {
498 match block {
499 TranscriptBlock::Text { role, text } => {
500 let user_bias = role
501 .as_deref()
502 .is_some_and(|value| value.eq_ignore_ascii_case("user"));
503 ingest_text_block(root, &text, user_bias, None, state)?;
504 }
505 TranscriptBlock::ToolResult { text } => {
506 ingest_text_block(root, &text, false, last_tool_command.as_deref(), state)?;
507 last_tool_command = None;
508 }
509 TranscriptBlock::ToolUse { name, input } => {
510 state.transcript_items += 1;
511 last_tool_command = ingest_tool_use(root, &name, &input, state)?;
512 }
513 }
514 }
515 }
516 Ok(())
517}
518
519fn ingest_codex_jsonl(root: &Path, input: &str, state: &mut DigestState) -> Result<()> {
520 for (index, raw_line) in input.lines().enumerate() {
521 let trimmed = raw_line.trim();
522 if trimmed.is_empty() {
523 continue;
524 }
525 let value = match serde_json::from_str::<Value>(trimmed) {
526 Ok(value) => value,
527 Err(_) => {
528 state.warnings.push(format!(
529 "skipping malformed Codex transcript jsonl line {}",
530 index + 1
531 ));
532 continue;
533 }
534 };
535 match value.get("type").and_then(Value::as_str) {
536 Some("response_item") => ingest_codex_response_item(root, &value, index + 1, state)?,
537 Some("event_msg") => ingest_codex_event_msg(root, &value, index + 1, state)?,
538 _ => {}
539 }
540 }
541 Ok(())
542}
543
544fn ingest_agent_doc_log(root: &Path, input: &str, state: &mut DigestState) {
545 for raw_line in input.lines() {
546 let trimmed = raw_line.trim();
547 if trimmed.is_empty() {
548 continue;
549 }
550 let Some((_, after_bracket)) = trimmed.split_once("] ") else {
551 continue;
552 };
553 let detail = after_bracket.trim();
554 let Some(event_name) = detail.split_whitespace().next() else {
555 continue;
556 };
557
558 state.transcript_items += 1;
559 let normalized_event = normalize_runtime_event(event_name, detail);
560 if should_count_runtime_event(event_name, detail, &normalized_event, state) {
561 *state.runtime_events.entry(normalized_event).or_default() += 1;
562 }
563 state.restart_churn.observe(event_name, detail);
564
565 for key in ["file", "path", "project_root"] {
566 if let Some(path) = extract_field(detail, key) {
567 for normalized in extract_file_refs(path, root) {
568 *state.files.entry(normalized).or_default() += 1;
569 }
570 }
571 }
572
573 if matches!(event_name, "claude_exit" | "codex_exit")
574 && extract_field(detail, "code").is_some_and(|code| code != "0")
575 {
576 let message = truncate_detail(
577 &format!(
578 "{} exited with code {}",
579 event_name,
580 extract_field(detail, "code").unwrap_or("?")
581 ),
582 220,
583 );
584 *state
585 .failures
586 .entry(("exit".to_string(), message, None))
587 .or_default() += 1;
588 }
589
590 if event_name.contains("timeout") {
591 *state
592 .failures
593 .entry(("timeout".to_string(), truncate_detail(detail, 220), None))
594 .or_default() += 1;
595 }
596
597 for (kind, closeout) in detect_closeout(detail) {
598 if should_count_closeout(event_name, detail, &kind, &closeout, state) {
599 *state.closeout.entry((kind, closeout)).or_default() += 1;
600 }
601 }
602 }
603}
604
605fn is_ignorable_claude_record(value: &Value) -> bool {
606 value.get("attachment").is_some()
607 || value.get("toolUseResult").is_some()
608 || (value.get("message").is_none()
609 && value.get("content").is_none()
610 && value.get("text").is_none())
611}
612
613fn collect_transcript_blocks(value: &Value, out: &mut Vec<TranscriptBlock>) {
614 if let Some(message) = value.get("message") {
615 collect_message_blocks(message, out);
616 return;
617 }
618 collect_message_blocks(value, out);
619}
620
621fn collect_message_blocks(value: &Value, out: &mut Vec<TranscriptBlock>) {
622 let role = value
623 .get("role")
624 .and_then(Value::as_str)
625 .map(|value| value.to_string());
626 if let Some(content) = value.get("content") {
627 match content {
628 Value::String(text) => out.push(TranscriptBlock::Text {
629 role,
630 text: text.to_string(),
631 }),
632 Value::Array(items) => {
633 for item in items {
634 collect_content_block(role.clone(), item, out);
635 }
636 }
637 _ => {}
638 }
639 } else if let Some(text) = value.get("text").and_then(Value::as_str) {
640 out.push(TranscriptBlock::Text {
641 role,
642 text: text.to_string(),
643 });
644 }
645}
646
647fn ingest_codex_response_item(
648 root: &Path,
649 value: &Value,
650 line_number: usize,
651 state: &mut DigestState,
652) -> Result<()> {
653 let Some(payload) = value.get("payload") else {
654 return Ok(());
655 };
656 match payload.get("type").and_then(Value::as_str) {
657 Some("message") => {
658 let role = payload
659 .get("role")
660 .and_then(Value::as_str)
661 .unwrap_or_default();
662 if role != "assistant" {
663 return Ok(());
664 }
665 let Some(content) = payload.get("content").and_then(Value::as_array) else {
666 return Ok(());
667 };
668 for item in content {
669 let Some(text) = item
670 .get("text")
671 .and_then(Value::as_str)
672 .or_else(|| item.get("content").and_then(Value::as_str))
673 else {
674 continue;
675 };
676 ingest_text_block(root, text, false, None, state)?;
677 }
678 }
679 Some("function_call") => {
680 let name = payload
681 .get("name")
682 .and_then(Value::as_str)
683 .unwrap_or("function_call");
684 let Some(arguments) = payload.get("arguments").and_then(Value::as_str) else {
685 return Ok(());
686 };
687 let input = serde_json::from_str::<Value>(arguments).unwrap_or_else(|_| {
688 state.warnings.push(format!(
689 "codex function_call arguments on line {} were not valid JSON; command extraction may be incomplete",
690 line_number
691 ));
692 Value::String(arguments.to_string())
693 });
694 state.transcript_items += 1;
695 let _ = ingest_tool_use(root, name, &input, state)?;
696 }
697 _ => {}
698 }
699 Ok(())
700}
701
702fn ingest_codex_event_msg(
703 root: &Path,
704 value: &Value,
705 _line_number: usize,
706 state: &mut DigestState,
707) -> Result<()> {
708 let Some(payload) = value.get("payload") else {
709 return Ok(());
710 };
711 match payload.get("type").and_then(Value::as_str) {
712 Some("user_message") => {
713 if let Some(message) = payload.get("message").and_then(Value::as_str) {
714 ingest_text_block(root, message, true, None, state)?;
715 }
716 }
717 Some("agent_message") => {
718 if let Some(message) = payload.get("message").and_then(Value::as_str) {
719 ingest_text_block(root, message, false, None, state)?;
720 }
721 }
722 Some("exec_command_end") => {
723 state.transcript_items += 1;
724 let command = extract_codex_exec_command(payload);
725 if let Some(command) = &command {
726 *state.commands.entry(command.clone()).or_default() += 1;
727 for path in extract_file_refs(command, root) {
728 *state.files.entry(path).or_default() += 1;
729 }
730 for symbol in extract_symbol_refs(command) {
731 *state.symbols.entry(symbol).or_default() += 1;
732 }
733 }
734 if let Some(output) = payload
735 .get("aggregated_output")
736 .and_then(Value::as_str)
737 .or_else(|| payload.get("stdout").and_then(Value::as_str))
738 {
739 for line in output.lines() {
740 ingest_text_line(root, line, false, command.as_deref(), state)?;
741 }
742 }
743 if payload
744 .get("exit_code")
745 .and_then(Value::as_i64)
746 .unwrap_or(0)
747 != 0
748 && command.is_some()
749 {
750 let command = command.as_deref().unwrap();
751 let message = truncate_detail(
752 &format!(
753 "{} exited with code {}",
754 command,
755 payload
756 .get("exit_code")
757 .and_then(Value::as_i64)
758 .unwrap_or_default()
759 ),
760 220,
761 );
762 *state
763 .failures
764 .entry(("exit".to_string(), message, Some(command.to_string())))
765 .or_default() += 1;
766 }
767 }
768 _ => {}
769 }
770 Ok(())
771}
772
773fn collect_content_block(role: Option<String>, value: &Value, out: &mut Vec<TranscriptBlock>) {
774 let block_type = value.get("type").and_then(Value::as_str);
775 match block_type {
776 Some("text") => {
777 if let Some(text) = value.get("text").and_then(Value::as_str) {
778 out.push(TranscriptBlock::Text {
779 role,
780 text: text.to_string(),
781 });
782 }
783 }
784 Some("tool_use") => {
785 let name = value
786 .get("name")
787 .and_then(Value::as_str)
788 .unwrap_or("tool_use")
789 .to_string();
790 let input = value.get("input").cloned().unwrap_or(Value::Null);
791 out.push(TranscriptBlock::ToolUse { name, input });
792 }
793 Some("tool_result") => match value.get("content") {
794 Some(Value::String(text)) => out.push(TranscriptBlock::ToolResult {
795 text: text.to_string(),
796 }),
797 Some(Value::Array(items)) => {
798 for item in items {
799 collect_tool_result_block(item, out);
800 }
801 }
802 _ => {}
803 },
804 _ => {
805 if let Some(text) = value.get("text").and_then(Value::as_str) {
806 out.push(TranscriptBlock::Text {
807 role,
808 text: text.to_string(),
809 });
810 }
811 }
812 }
813}
814
815fn collect_tool_result_block(value: &Value, out: &mut Vec<TranscriptBlock>) {
816 if let Some(text) = value
817 .get("text")
818 .and_then(Value::as_str)
819 .or_else(|| value.get("content").and_then(Value::as_str))
820 {
821 out.push(TranscriptBlock::ToolResult {
822 text: text.to_string(),
823 });
824 }
825}
826
827fn ingest_tool_use(
828 root: &Path,
829 name: &str,
830 input: &Value,
831 state: &mut DigestState,
832) -> Result<Option<String>> {
833 let command = extract_tool_command(name, input);
834 if let Some(command) = &command {
835 *state.commands.entry(command.clone()).or_default() += 1;
836 for path in extract_file_refs(command, root) {
837 *state.files.entry(path).or_default() += 1;
838 }
839 for symbol in extract_symbol_refs(command) {
840 *state.symbols.entry(symbol).or_default() += 1;
841 }
842 }
843
844 if let Some(text) = extract_tool_text(input) {
845 for line in text.lines() {
846 ingest_text_line(root, line, false, command.as_deref(), state)?;
847 }
848 }
849 Ok(command)
850}
851
852fn ingest_text_block(
853 root: &Path,
854 text: &str,
855 user_bias: bool,
856 command_anchor: Option<&str>,
857 state: &mut DigestState,
858) -> Result<()> {
859 state.transcript_items += 1;
860 for line in text.lines() {
861 ingest_text_line(root, line, user_bias, command_anchor, state)?;
862 }
863 Ok(())
864}
865
866fn extract_tool_command(name: &str, input: &Value) -> Option<String> {
867 if !matches!(
868 name.to_ascii_lowercase().as_str(),
869 "bash" | "exec_command" | "shell" | "terminal" | "sh"
870 ) {
871 return None;
872 }
873
874 match input {
875 Value::Object(map) => {
876 for key in ["command", "cmd", "shell_command"] {
877 if let Some(raw) = map.get(key).and_then(Value::as_str) {
878 let normalized = normalize_whitespace(raw);
879 if looks_like_command(&normalized) {
880 return Some(normalized);
881 }
882 }
883 }
884 None
885 }
886 Value::String(raw) => {
887 let normalized = normalize_whitespace(raw);
888 looks_like_command(&normalized).then_some(normalized)
889 }
890 _ => None,
891 }
892}
893
894fn extract_tool_text(input: &Value) -> Option<String> {
895 match input {
896 Value::Object(map) => {
897 for key in ["text", "output", "stderr", "stdout", "content", "message"] {
898 if let Some(raw) = map.get(key).and_then(Value::as_str) {
899 return Some(raw.to_string());
900 }
901 }
902 None
903 }
904 Value::String(raw) => Some(raw.to_string()),
905 _ => None,
906 }
907}
908
909fn extract_codex_exec_command(payload: &Value) -> Option<String> {
910 if let Some(parsed) = payload.get("parsed_cmd").and_then(Value::as_array) {
911 for item in parsed {
912 if let Some(command) = item.get("cmd").and_then(Value::as_str) {
913 let normalized = normalize_whitespace(command);
914 if looks_like_command(&normalized) {
915 return Some(normalized);
916 }
917 }
918 }
919 }
920
921 if let Some(command) = payload.get("command").and_then(Value::as_array)
922 && let Some(last) = command.last().and_then(Value::as_str)
923 {
924 let normalized = normalize_whitespace(last);
925 if looks_like_command(&normalized) {
926 return Some(normalized);
927 }
928 }
929 None
930}
931
932fn ingest_text_line(
933 root: &Path,
934 raw_line: &str,
935 user_bias: bool,
936 command_anchor: Option<&str>,
937 state: &mut DigestState,
938) -> Result<()> {
939 let trimmed = raw_line.trim();
940 if trimmed.is_empty() {
941 return Ok(());
942 }
943 if looks_like_instruction_ballast(trimmed) {
944 return Ok(());
945 }
946
947 let prompt_candidate = trimmed
948 .strip_prefix("❯ ")
949 .or_else(|| trimmed.strip_prefix("> "))
950 .unwrap_or(trimmed)
951 .trim();
952 let is_prompt_target =
953 looks_like_prompt_target(prompt_candidate, user_bias || prompt_candidate != trimmed);
954 if is_prompt_target {
955 push_prompt_target(prompt_candidate, &mut state.prompt_targets);
956 }
957
958 for command in extract_commands(trimmed) {
959 *state.commands.entry(command.clone()).or_default() += 1;
960 for path in extract_file_refs(&command, root) {
961 *state.files.entry(path).or_default() += 1;
962 }
963 for symbol in extract_symbol_refs(&command) {
964 *state.symbols.entry(symbol).or_default() += 1;
965 }
966 }
967
968 for path in extract_file_refs(trimmed, root) {
969 *state.files.entry(path).or_default() += 1;
970 }
971 for symbol in extract_symbol_refs(trimmed) {
972 *state.symbols.entry(symbol).or_default() += 1;
973 }
974
975 if !is_prompt_target
976 && !user_bias
977 && let Some((kind, message)) = classify_failure(trimmed)
978 {
979 let command = command_anchor.map(normalize_whitespace);
980 *state.failures.entry((kind, message, command)).or_default() += 1;
981 }
982 for (kind, detail) in detect_closeout(trimmed) {
983 *state.closeout.entry((kind, detail)).or_default() += 1;
984 }
985
986 Ok(())
987}
988
989fn push_prompt_target(prompt: &str, targets: &mut Vec<String>) {
990 let normalized = normalize_whitespace(prompt);
991 if normalized.is_empty() || targets.iter().any(|existing| existing == &normalized) {
992 return;
993 }
994 if targets.len() < MAX_PROMPT_TARGETS {
995 targets.push(normalized);
996 }
997}
998
999#[cfg(any(test, feature = "test-support"))]
1003pub fn extract_prompt_targets_from_text_block(input: &str, user_bias: bool) -> Vec<String> {
1004 let mut targets = Vec::new();
1005 for raw_line in input.lines() {
1006 let trimmed = raw_line.trim();
1007 if trimmed.is_empty() || looks_like_instruction_ballast(trimmed) {
1008 continue;
1009 }
1010 let prompt_candidate = trimmed
1011 .strip_prefix("❯ ")
1012 .or_else(|| trimmed.strip_prefix("> "))
1013 .unwrap_or(trimmed)
1014 .trim();
1015 if looks_like_prompt_target(prompt_candidate, user_bias || prompt_candidate != trimmed) {
1016 push_prompt_target(prompt_candidate, &mut targets);
1017 }
1018 }
1019 targets
1020}
1021
1022fn looks_like_prompt_target(text: &str, user_bias: bool) -> bool {
1023 let trimmed = text.trim();
1024 if trimmed.is_empty()
1025 || looks_like_markdown_heading(trimmed)
1026 || looks_like_slash_command_example(trimmed)
1027 || trimmed == "#"
1028 || trimmed.starts_with("#!")
1029 || trimmed.starts_with("#[")
1030 || trimmed.starts_with("/**")
1031 || trimmed.starts_with("*/")
1032 || trimmed.starts_with("//")
1033 || trimmed.starts_with("###")
1034 || trimmed.starts_with("<!--")
1035 || trimmed.starts_with("- [")
1036 || trimmed == "###"
1037 {
1038 return false;
1039 }
1040
1041 if trimmed.starts_with("do ")
1042 || trimmed.starts_with('#')
1043 || looks_like_slash_prompt_target(trimmed)
1044 || trimmed.ends_with('?')
1045 {
1046 return true;
1047 }
1048
1049 if user_bias
1050 && (trimmed.contains("commit + push")
1051 || trimmed.contains("run tests")
1052 || trimmed.contains("build + install")
1053 || trimmed.contains("#spec-test"))
1054 {
1055 return true;
1056 }
1057
1058 false
1059}
1060
1061fn looks_like_instruction_ballast(text: &str) -> bool {
1062 let trimmed = strip_common_prefixes(text.trim());
1063 if trimmed.is_empty() {
1064 return false;
1065 }
1066
1067 looks_like_markdown_heading(trimmed)
1068 || looks_like_slash_command_example(trimmed)
1069 || looks_like_frontmatter_prompt_preset(trimmed)
1070 || looks_like_completed_backlog_archive(trimmed)
1071 || trimmed.starts_with("<!-- tsift:")
1072 || trimmed.starts_with("<!-- /tsift:")
1073 || looks_like_instruction_label(trimmed)
1074}
1075
1076fn looks_like_markdown_heading(text: &str) -> bool {
1077 let trimmed = text.trim_start();
1078 let heading_level = trimmed.chars().take_while(|ch| *ch == '#').count();
1079 heading_level > 0
1080 && heading_level <= 6
1081 && trimmed
1082 .chars()
1083 .nth(heading_level)
1084 .is_some_and(|ch| ch.is_whitespace())
1085}
1086
1087fn looks_like_slash_command_example(text: &str) -> bool {
1088 let trimmed = text.trim();
1089 trimmed.starts_with('/')
1090 && trimmed.contains('<')
1091 && trimmed.contains('>')
1092 && !trimmed.contains('`')
1093}
1094
1095fn looks_like_frontmatter_prompt_preset(text: &str) -> bool {
1096 let trimmed = strip_common_prefixes(text.trim());
1097 if trimmed == "prompt_presets:" || trimmed.starts_with("prompt_presets:") {
1098 return true;
1099 }
1100 let Some((key, _)) = trimmed.split_once(':') else {
1101 return false;
1102 };
1103 let key = key.trim().trim_matches(['"', '\'']);
1104 key.starts_with('#') && key.len() > 1 && key[1..].chars().all(is_prompt_preset_char)
1105}
1106
1107fn is_prompt_preset_char(ch: char) -> bool {
1108 ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')
1109}
1110
1111fn looks_like_completed_backlog_archive(text: &str) -> bool {
1112 let stripped = strip_common_prefixes(text.trim());
1113 let Some(date) = stripped.get(..10) else {
1114 return false;
1115 };
1116 date.chars().enumerate().all(|(index, ch)| match index {
1117 4 | 7 => ch == '-',
1118 _ => ch.is_ascii_digit(),
1119 }) && stripped[10..].contains("[#")
1120}
1121
1122fn looks_like_slash_prompt_target(text: &str) -> bool {
1123 let Some(first_token) = text.split_whitespace().next() else {
1124 return false;
1125 };
1126 first_token.starts_with('/') && !first_token[1..].contains('/')
1127}
1128
1129fn looks_like_instruction_label(text: &str) -> bool {
1130 let trimmed = text.trim();
1131 if !trimmed.starts_with("**") {
1132 return false;
1133 }
1134 let Some(label_end) = trimmed[2..].find("**") else {
1135 return false;
1136 };
1137 let label = &trimmed[..label_end + 4];
1138 if label.len() <= 4 {
1139 return false;
1140 }
1141 let remainder = trimmed[label_end + 4..]
1142 .trim_start_matches([' ', ':', '-', '—'])
1143 .trim_start();
1144 if remainder.is_empty() {
1145 return false;
1146 }
1147 let lower = remainder.to_ascii_lowercase();
1148 matches!(
1149 lower.split_whitespace().next(),
1150 Some("run")
1151 | Some("use")
1152 | Some("treat")
1153 | Some("respond")
1154 | Some("print")
1155 | Some("prefer")
1156 | Some("preserve")
1157 | Some("show")
1158 | Some("complete")
1159 | Some("append")
1160 | Some("when")
1161 | Some("if")
1162 )
1163}
1164
1165fn extract_commands(text: &str) -> Vec<String> {
1166 let mut commands = BTreeSet::new();
1167 for span in extract_backtick_spans(text) {
1168 let normalized = normalize_whitespace(&span);
1169 if looks_like_command(&normalized) {
1170 commands.insert(normalized);
1171 }
1172 }
1173
1174 let stripped = strip_common_prefixes(text.trim());
1175 let normalized = normalize_whitespace(stripped);
1176 if looks_like_command(&normalized) {
1177 commands.insert(normalized);
1178 }
1179
1180 commands.into_iter().collect()
1181}
1182
1183fn extract_backtick_spans(text: &str) -> Vec<String> {
1184 let mut spans = Vec::new();
1185 let mut start = None;
1186 for (index, ch) in text.char_indices() {
1187 if ch != '`' {
1188 continue;
1189 }
1190 match start {
1191 Some(span_start) => {
1192 if index > span_start + 1 {
1193 spans.push(text[span_start + 1..index].to_string());
1194 }
1195 start = None;
1196 }
1197 None => start = Some(index),
1198 }
1199 }
1200 spans
1201}
1202
1203fn strip_common_prefixes(text: &str) -> &str {
1204 text.strip_prefix("❯ ")
1205 .or_else(|| text.strip_prefix("- "))
1206 .or_else(|| text.strip_prefix("* "))
1207 .or_else(|| text.strip_prefix("> "))
1208 .unwrap_or(text)
1209 .trim()
1210}
1211
1212fn looks_like_command(text: &str) -> bool {
1213 if text.is_empty()
1214 || text.contains('\n')
1215 || text.contains("://")
1216 || text.starts_with('/')
1217 || text.starts_with("###")
1218 {
1219 return false;
1220 }
1221
1222 let head = text.split_whitespace().next().unwrap_or_default();
1223 matches!(
1224 head,
1225 "agent-doc"
1226 | "cargo"
1227 | "git"
1228 | "make"
1229 | "pytest"
1230 | "python"
1231 | "uv"
1232 | "tsift"
1233 | "npm"
1234 | "pnpm"
1235 | "yarn"
1236 | "bash"
1237 | "zsh"
1238 | "rg"
1239 | "grep"
1240 | "./scripts/run_benchmark.sh"
1241 ) || head.starts_with("./")
1242}
1243
1244fn extract_file_refs(text: &str, root: &Path) -> Vec<String> {
1245 let mut paths = BTreeSet::new();
1246 for raw in text.split_whitespace() {
1247 if let Some(path) = normalize_file_token(raw, root) {
1248 paths.insert(path);
1249 }
1250 }
1251 paths.into_iter().collect()
1252}
1253
1254fn normalize_file_token(raw: &str, root: &Path) -> Option<String> {
1255 let trimmed = raw.trim_matches(|ch: char| {
1256 matches!(
1257 ch,
1258 '`' | '"' | '\'' | ',' | ';' | '(' | ')' | '[' | ']' | '<' | '>' | '{' | '}' | '*'
1259 )
1260 });
1261 if trimmed.is_empty() || trimmed == "." || trimmed == "-" || trimmed.contains("://") {
1262 return None;
1263 }
1264
1265 let value = trimmed
1266 .split_once('=')
1267 .map(|(_, value)| value)
1268 .unwrap_or(trimmed);
1269 let without_line = strip_line_suffix(value);
1270 let candidate = without_line.trim_end_matches('/');
1271 if candidate.is_empty() || candidate == "." {
1272 return None;
1273 }
1274 if contains_shell_redirection(candidate) || !looks_like_file_path(candidate, root) {
1275 return None;
1276 }
1277 if path_points_to_existing_directory(root, candidate) {
1278 return None;
1279 }
1280
1281 let display_path = normalize_display_path(root, candidate);
1282 if display_path.is_empty() {
1283 return None;
1284 }
1285 Some(display_path)
1286}
1287
1288fn contains_shell_redirection(token: &str) -> bool {
1289 token.contains('>') || token.contains('<')
1290}
1291
1292fn strip_line_suffix(token: &str) -> &str {
1293 let bytes = token.as_bytes();
1294 let mut cut = token.len();
1295 let mut colon_segments = 0;
1296 while let Some(colon_index) = token[..cut].rfind(':') {
1297 let suffix = &token[colon_index + 1..cut];
1298 if suffix.is_empty() || !suffix.chars().all(|ch| ch.is_ascii_digit()) {
1299 break;
1300 }
1301 colon_segments += 1;
1302 cut = colon_index;
1303 if colon_segments == 2 {
1304 break;
1305 }
1306 if colon_index == 0 || bytes[colon_index - 1] == b'/' {
1307 continue;
1308 }
1309 }
1310 &token[..cut]
1311}
1312
1313fn looks_like_file_path(token: &str, root: &Path) -> bool {
1314 if token.starts_with("--") || token.starts_with('#') {
1315 return false;
1316 }
1317
1318 if token.contains('/') {
1319 return path_points_to_existing_file(root, token)
1320 || token_file_name(token)
1321 .is_some_and(|name| is_known_file_name(name) || has_known_file_extension(name));
1322 }
1323
1324 let lower = token.to_ascii_lowercase();
1325 is_known_file_name(&lower) || has_known_file_extension(&lower)
1326}
1327
1328fn token_file_name(token: &str) -> Option<&str> {
1329 token.rsplit('/').find(|part| !part.is_empty())
1330}
1331
1332fn is_known_file_name(lower_name: &str) -> bool {
1333 matches!(
1334 lower_name,
1335 "cargo.toml"
1336 | "cargo.lock"
1337 | "makefile"
1338 | "dockerfile"
1339 | "readme.md"
1340 | "agents.md"
1341 | "claude.md"
1342 | "spec.md"
1343 | "versions.md"
1344 )
1345}
1346
1347fn has_known_file_extension(lower_name: &str) -> bool {
1348 [
1349 ".rs", ".md", ".toml", ".json", ".jsonl", ".yaml", ".yml", ".txt", ".py", ".ts", ".tsx",
1350 ".js", ".jsx", ".sh", ".zsh", ".sql", ".db", ".log",
1351 ]
1352 .iter()
1353 .any(|suffix| lower_name.ends_with(suffix))
1354}
1355
1356fn path_points_to_existing_file(root: &Path, raw_path: &str) -> bool {
1357 let path = Path::new(raw_path);
1358 let candidate = if path.is_absolute() {
1359 path.to_path_buf()
1360 } else {
1361 root.join(path)
1362 };
1363 candidate.is_file()
1364}
1365
1366fn path_points_to_existing_directory(root: &Path, raw_path: &str) -> bool {
1367 let path = Path::new(raw_path);
1368 let candidate = if path.is_absolute() {
1369 path.to_path_buf()
1370 } else {
1371 root.join(path)
1372 };
1373 candidate.is_dir()
1374}
1375
1376fn normalize_display_path(root: &Path, raw: &str) -> String {
1377 let path = Path::new(raw);
1378 if path.is_absolute() {
1379 if let Ok(relative) = path.strip_prefix(root) {
1380 return normalize_path_string(relative);
1381 }
1382 return normalize_path_string(path);
1383 }
1384 normalize_path_string(path)
1385}
1386
1387fn normalize_path_string(path: &Path) -> String {
1388 path.components()
1389 .fold(PathBuf::new(), |mut acc, component| {
1390 acc.push(component.as_os_str());
1391 acc
1392 })
1393 .display()
1394 .to_string()
1395 .replace('\\', "/")
1396 .trim_start_matches("./")
1397 .to_string()
1398}
1399
1400fn extract_symbol_refs(text: &str) -> Vec<String> {
1401 let mut symbols = BTreeSet::new();
1402 for span in extract_backtick_spans(text) {
1403 let candidate = span.trim().trim_end_matches("()");
1404 if looks_like_symbol(candidate) {
1405 symbols.insert(candidate.to_string());
1406 }
1407 }
1408
1409 for raw in text.split(|ch: char| !matches!(ch, 'a'..='z' | 'A'..='Z' | '0'..='9' | '_' | ':')) {
1410 let candidate = raw.trim().trim_end_matches("()");
1411 if looks_like_symbol(candidate) {
1412 symbols.insert(candidate.to_string());
1413 }
1414 }
1415
1416 symbols.into_iter().collect()
1417}
1418
1419fn looks_like_symbol(candidate: &str) -> bool {
1420 if candidate.len() < 3
1421 || candidate.contains('/')
1422 || candidate.contains('.')
1423 || candidate.starts_with('#')
1424 || matches!(
1425 candidate,
1426 "Error" | "FAILED" | "cargo" | "pytest" | "agent" | "commit" | "push"
1427 )
1428 {
1429 return false;
1430 }
1431
1432 let lower = candidate.to_ascii_lowercase();
1433 if matches!(
1434 lower.as_str(),
1435 "none"
1436 | "error"
1437 | "failed"
1438 | "warning"
1439 | "commit"
1440 | "pushed"
1441 | "status"
1442 | "stdout"
1443 | "stderr"
1444 ) {
1445 return false;
1446 }
1447
1448 candidate.contains('_') || candidate.contains("::")
1449}
1450
1451fn classify_failure(text: &str) -> Option<(String, String)> {
1452 let normalized = normalize_whitespace(strip_common_prefixes(text));
1453 if is_non_failure_summary(&normalized)
1454 || looks_like_failure_instruction(&normalized)
1455 || looks_like_failure_meta_discussion(&normalized)
1456 || looks_like_source_code_snippet(&normalized)
1457 {
1458 return None;
1459 }
1460 let lower = normalized.to_ascii_lowercase();
1461 let kind = if lower.contains("timed out") {
1462 "timeout"
1463 } else if lower.starts_with("error") || lower.contains(" error:") || lower.contains("error:") {
1464 "error"
1465 } else if lower.contains("panicked")
1466 || lower.starts_with("panic:")
1467 || lower.contains(" panic:")
1468 || lower.contains("panic at")
1469 {
1470 "panic"
1471 } else if lower.contains("not found")
1472 || lower.contains(" is missing")
1473 || lower.contains(" missing ")
1474 {
1475 "missing"
1476 } else if lower.contains("failed") || lower.contains("failure") {
1477 "failure"
1478 } else {
1479 return None;
1480 };
1481 Some((kind.to_string(), truncate_detail(&normalized, 220)))
1482}
1483
1484fn looks_like_failure_instruction(text: &str) -> bool {
1485 let lower = text.to_ascii_lowercase();
1486 let first = lower.split_whitespace().next().unwrap_or_default();
1487 if matches!(
1488 first,
1489 "after" | "before" | "when" | "while" | "if" | "preserve" | "report" | "tighten" | "avoid"
1490 ) && (lower.contains(" should ")
1491 || lower.contains(" must ")
1492 || lower.contains(" not ")
1493 || lower.contains(" preserve ")
1494 || lower.contains(" reports "))
1495 {
1496 return true;
1497 }
1498 if lower.contains(" should not ") && lower.contains("failure") {
1499 return true;
1500 }
1501 false
1502}
1503
1504fn looks_like_failure_meta_discussion(text: &str) -> bool {
1505 let lower = text.to_ascii_lowercase();
1506 let mentions_failure = lower.contains("failure") || lower.contains("failed");
1507 if !mentions_failure {
1508 return false;
1509 }
1510
1511 if lower.contains("false positive")
1512 || lower.contains("failure group")
1513 || lower.contains("failure classifier")
1514 || lower.contains("failure classification")
1515 || lower.contains("failure extraction")
1516 || lower.contains("unresolved failure")
1517 || lower.contains("next-context")
1518 {
1519 return true;
1520 }
1521
1522 if lower.contains("ci")
1523 && (lower.contains("status") || lower.contains("check") || lower.contains("red"))
1524 && (lower.contains("prose") || lower.contains("progress") || lower.contains("prior status"))
1525 {
1526 return true;
1527 }
1528
1529 let first = lower.split_whitespace().next().unwrap_or_default();
1530 matches!(
1531 first,
1532 "i'm" | "i’m" | "i" | "i'll" | "i’ll" | "the" | "this" | "current" | "previous"
1533 ) && (lower.contains("checking")
1534 || lower.contains("inspecting")
1535 || lower.contains("reviewing")
1536 || lower.contains("classified")
1537 || lower.contains("classifier")
1538 || lower.contains("assessment")
1539 || lower.contains("progress"))
1540}
1541
1542fn looks_like_source_code_snippet(text: &str) -> bool {
1543 let trimmed = text.trim();
1544 let lower = trimmed.to_ascii_lowercase();
1545 if lower.contains("panic!(") || lower.contains("bail!(") || lower.contains("anyhow!(") {
1546 return true;
1547 }
1548 matches!(
1549 lower.split_whitespace().next(),
1550 Some("fn")
1551 | Some("pub")
1552 | Some("impl")
1553 | Some("let")
1554 | Some("return")
1555 | Some("assert!")
1556 | Some("assert_eq!")
1557 | Some("assert_ne!")
1558 | Some("debug_assert!")
1559 ) && (trimmed.contains('{') || trimmed.contains(';') || trimmed.contains("=>"))
1560}
1561
1562fn is_non_failure_summary(text: &str) -> bool {
1563 let normalized = text.trim();
1564 if normalized.is_empty() {
1565 return false;
1566 }
1567 let lower = normalized.to_ascii_lowercase();
1568 let compact = lower.trim_matches(['.', ':', ';', ',']).trim();
1569 if matches!(
1570 compact,
1571 "failure" | "failures" | "failure summary" | "failure summaries"
1572 ) {
1573 return true;
1574 }
1575 if lower.starts_with("no failures detected")
1576 || lower.starts_with("no failure detected")
1577 || lower.starts_with("no unresolved failures")
1578 {
1579 return true;
1580 }
1581 if lower.starts_with("test result: ok.") || lower.starts_with("test result: ok;") {
1582 return true;
1583 }
1584 if lower.contains("0 failed")
1585 && (lower.contains(" passed") || lower.contains(" ok") || lower.contains("filtered out"))
1586 && !lower.contains("failed to")
1587 && !lower.contains("assertion failed")
1588 && !lower.contains("test result: failed")
1589 {
1590 return true;
1591 }
1592 false
1593}
1594
1595fn detect_closeout(text: &str) -> Vec<(String, String)> {
1596 let mut out = Vec::new();
1597 let normalized = normalize_whitespace(strip_common_prefixes(text));
1598 let lower = normalized.to_ascii_lowercase();
1599
1600 if normalized.starts_with("document_cycle ") {
1601 let phase = extract_field(&normalized, "phase");
1602 let event = extract_field(&normalized, "event");
1603 if phase == Some("committed")
1604 && let Some(event) = event
1605 {
1606 out.push((
1607 "commit".to_string(),
1608 format!("document_cycle phase=committed event={event}"),
1609 ));
1610 }
1611 return dedupe_pairs(out);
1612 }
1613
1614 if lower.contains("verification passed") || lower.starts_with("verification in ") {
1615 out.push((
1616 "verification".to_string(),
1617 truncate_detail(&normalized, 220),
1618 ));
1619 }
1620 if lower.contains("cargo build")
1621 || lower.contains("make check")
1622 || lower.contains("cargo test")
1623 || lower.contains("pytest")
1624 {
1625 out.push((
1626 "verification".to_string(),
1627 truncate_detail(&normalized, 220),
1628 ));
1629 }
1630 if lower.contains("cargo install") || lower.contains("installed") {
1631 out.push(("install".to_string(), truncate_detail(&normalized, 220)));
1632 }
1633 if lower.contains("committed and pushed") {
1634 out.push(("push".to_string(), truncate_detail(&normalized, 220)));
1635 } else if lower.contains("committed") {
1636 out.push(("commit".to_string(), truncate_detail(&normalized, 220)));
1637 }
1638 if lower.contains("tsift --version") || lower.contains("tsift v0.") {
1639 out.push(("version".to_string(), truncate_detail(&normalized, 220)));
1640 }
1641 if lower.contains("agent-doc finalize") || lower.contains("session-check") {
1642 out.push(("closeout".to_string(), truncate_detail(&normalized, 220)));
1643 }
1644
1645 dedupe_pairs(out)
1646}
1647
1648fn normalize_runtime_event(event_name: &str, detail: &str) -> String {
1649 if event_name == "document_cycle"
1650 && let Some(document_event) = extract_field(detail, "event")
1651 {
1652 return document_event.to_string();
1653 }
1654 if matches!(
1655 event_name,
1656 "claude_start" | "codex_start" | "claude_restart" | "codex_restart"
1657 ) && let Some(mode) = extract_field(detail, "mode")
1658 {
1659 return format!("{event_name}:{mode}");
1660 }
1661 event_name.to_string()
1662}
1663
1664fn should_count_runtime_event(
1665 event_name: &str,
1666 detail: &str,
1667 normalized: &str,
1668 state: &mut DigestState,
1669) -> bool {
1670 if event_name == "document_cycle"
1671 && let Some(cycle) = extract_field(detail, "cycle")
1672 {
1673 return state
1674 .seen_document_cycle_events
1675 .insert((cycle.to_string(), normalized.to_string()));
1676 }
1677 true
1678}
1679
1680fn should_count_closeout(
1681 event_name: &str,
1682 detail: &str,
1683 kind: &str,
1684 closeout: &str,
1685 state: &mut DigestState,
1686) -> bool {
1687 if event_name == "document_cycle"
1688 && let Some(cycle) = extract_field(detail, "cycle")
1689 {
1690 return state.seen_document_cycle_closeout.insert((
1691 cycle.to_string(),
1692 kind.to_string(),
1693 closeout.to_string(),
1694 ));
1695 }
1696 true
1697}
1698
1699fn extract_field<'a>(detail: &'a str, key: &str) -> Option<&'a str> {
1700 let needle = format!("{key}=");
1701 let start = detail.find(&needle)? + needle.len();
1702 let remainder = &detail[start..];
1703 let end = remainder
1704 .find(char::is_whitespace)
1705 .unwrap_or(remainder.len());
1706 Some(remainder[..end].trim_matches('"'))
1707}
1708
1709fn dedupe_pairs(items: Vec<(String, String)>) -> Vec<(String, String)> {
1710 let mut seen = BTreeSet::new();
1711 let mut deduped = Vec::new();
1712 for item in items {
1713 if seen.insert(item.clone()) {
1714 deduped.push(item);
1715 }
1716 }
1717 deduped
1718}
1719
1720fn normalize_whitespace(raw: &str) -> String {
1721 raw.split_whitespace().collect::<Vec<_>>().join(" ")
1722}
1723
1724fn truncate_detail(text: &str, max_chars: usize) -> String {
1725 if text.chars().count() <= max_chars {
1726 return text.to_string();
1727 }
1728 let mut truncated = String::new();
1729 for ch in text.chars().take(max_chars.saturating_sub(1)) {
1730 truncated.push(ch);
1731 }
1732 truncated.push('…');
1733 truncated
1734}
1735
1736#[cfg(test)]
1737mod tests {
1738 use super::*;
1739
1740 #[test]
1741 fn markdown_digest_extracts_prompt_commands_failures_and_closeout() {
1742 let dir = tempfile::tempdir().unwrap();
1743 std::fs::create_dir_all(dir.path().join("src")).unwrap();
1744 std::fs::write(dir.path().join("src/lib.rs"), "fn run_sync() {}\n").unwrap();
1745
1746 let input = "\
1747❯ Why was this symbol search attempted?
1748Symbol `run_sync` not found in index.
1749Error: tsift search timed out after 30s at src/lib.rs:7:9
1750Verification in `src/tsift`: `cargo test`, `make check`, `cargo build --release`, `cargo install --path . --force`
1751Committed and pushed in `src/tsift` as `1af09d3` (`feat: add metric run digest`).
1752do [#sessiondigest]. spec-test-build-install-commit-push
1753";
1754
1755 let report = compute(dir.path(), input, None).unwrap();
1756 assert_eq!(report.source, "markdown");
1757 assert!(
1758 report
1759 .prompt_targets
1760 .iter()
1761 .any(|target| target.contains("Why was this symbol search attempted?"))
1762 );
1763 assert!(
1764 report
1765 .prompt_targets
1766 .iter()
1767 .any(|target| target.contains("[#sessiondigest]"))
1768 );
1769 assert!(
1770 report
1771 .commands
1772 .iter()
1773 .any(|command| command.command == "cargo test")
1774 );
1775 assert!(
1776 report
1777 .touched_files
1778 .iter()
1779 .any(|path| path.path == "src/lib.rs")
1780 );
1781 assert!(
1782 report
1783 .touched_symbols
1784 .iter()
1785 .any(|symbol| symbol.symbol == "run_sync")
1786 );
1787 assert!(
1788 report
1789 .failures
1790 .iter()
1791 .any(|failure| failure.kind == "timeout")
1792 );
1793 assert!(
1794 report
1795 .closeout
1796 .iter()
1797 .any(|entry| entry.kind == "verification")
1798 );
1799 assert!(report.closeout.iter().any(|entry| entry.kind == "push"));
1800 assert!(report.graph_evidence.is_none());
1802 }
1803
1804 #[test]
1805 fn markdown_digest_surfaces_graph_evidence_when_graph_db_present() {
1806 use tsift_core::{GraphEdge, GraphNode, GraphProjection};
1807 use tsift_sqlite::SqliteGraphStore;
1808
1809 let dir = tempfile::tempdir().unwrap();
1810 std::fs::create_dir_all(dir.path().join("src")).unwrap();
1811 std::fs::write(dir.path().join("src/lib.rs"), "fn run_sync() {}\n").unwrap();
1812
1813 std::fs::create_dir_all(dir.path().join(".tsift")).unwrap();
1815 let db_path = dir.path().join(".tsift/graph.db");
1816 let mut store = SqliteGraphStore::open(&db_path).unwrap();
1817 let mut projection = GraphProjection::default();
1818 projection
1819 .nodes
1820 .push(GraphNode::new("n:run_sync", "function", "run_sync"));
1821 projection
1822 .nodes
1823 .push(GraphNode::new("n:helper", "function", "helper"));
1824 projection
1825 .edges
1826 .push(GraphEdge::new("n:run_sync", "n:helper", "calls"));
1827 store.upsert_projection(&projection).unwrap();
1828 drop(store);
1829
1830 let input = "\
1831Error: tsift search timed out after 30s at src/lib.rs:7:9
1832Symbol `run_sync` not found in index.
1833";
1834 let report = compute(dir.path(), input, None).unwrap();
1835 let evidence = report
1836 .graph_evidence
1837 .expect("graph.db present → evidence section");
1838 assert!(evidence.scanned);
1839 assert_eq!(evidence.total_nodes, 2);
1840 assert_eq!(evidence.symbol_filter.as_deref(), Some("run_sync"));
1842 assert!(
1843 evidence
1844 .entities
1845 .iter()
1846 .any(|entity| entity.label == "run_sync")
1847 );
1848 }
1849
1850 #[test]
1851 fn jsonl_digest_extracts_user_prompt_and_shell_command() {
1852 let dir = tempfile::tempdir().unwrap();
1853 std::fs::create_dir_all(dir.path().join("src")).unwrap();
1854 std::fs::write(dir.path().join("src/lib.rs"), "fn run_sync() {}\n").unwrap();
1855
1856 let input = concat!(
1857 r#"{"message":{"role":"user","content":"do [#sessiondigest]. spec-test-build-install-commit-push"}}"#,
1858 "\n",
1859 r#"{"message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{"command":"cargo test --release --manifest-path Cargo.toml"}},{"type":"text","text":"Symbol `run_sync` not found in index.\nCommitted and pushed in `src/tsift` as `1af09d3`."}]}}"#,
1860 "\n"
1861 );
1862
1863 let report = compute(dir.path(), input, None).unwrap();
1864 assert_eq!(report.source, "claude_jsonl");
1865 assert!(
1866 report
1867 .prompt_targets
1868 .iter()
1869 .any(|target| target.contains("[#sessiondigest]"))
1870 );
1871 assert!(report
1872 .commands
1873 .iter()
1874 .any(|command| command.command == "cargo test --release --manifest-path Cargo.toml"));
1875 assert!(
1876 report
1877 .touched_files
1878 .iter()
1879 .any(|path| path.path == "Cargo.toml")
1880 );
1881 assert!(
1882 report
1883 .touched_symbols
1884 .iter()
1885 .any(|symbol| symbol.symbol == "run_sync")
1886 );
1887 assert!(
1888 report
1889 .failures
1890 .iter()
1891 .any(|failure| matches!(failure.kind.as_str(), "error" | "missing"))
1892 );
1893 assert!(report.closeout.iter().any(|entry| entry.kind == "push"));
1894 }
1895
1896 #[test]
1897 fn codex_jsonl_digest_extracts_prompt_command_failures_and_closeout() {
1898 let dir = tempfile::tempdir().unwrap();
1899 std::fs::create_dir_all(dir.path().join("src")).unwrap();
1900 std::fs::write(dir.path().join("src/lib.rs"), "fn run_sync() {}\n").unwrap();
1901
1902 let input = concat!(
1903 r#"{"type":"response_item","payload":{"type":"message","role":"developer","content":[{"type":"input_text","text":"ignore this instruction blob"}]}}"#,
1904 "\n",
1905 r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#cdxlog]. spec-test-build-install-commit-push"}}"#,
1906 "\n",
1907 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo test --manifest-path Cargo.toml\"}","call_id":"call_1"}}"#,
1908 "\n",
1909 r#"{"type":"event_msg","payload":{"type":"exec_command_end","exit_code":1,"aggregated_output":"Error: Symbol `run_sync` not found in src/lib.rs:7:9\nVerification in `src/tsift`: `cargo test`\nCommitted and pushed in `src/tsift` as `943d77d`.","parsed_cmd":[{"type":"unknown","cmd":"cargo test --manifest-path Cargo.toml"}]}}"#,
1910 "\n",
1911 r#"{"type":"event_msg","payload":{"type":"agent_message","message":"I’m checking `src/tsift/SPEC.md` next."}}"#,
1912 "\n"
1913 );
1914
1915 let report = compute(dir.path(), input, Some("codex-jsonl")).unwrap();
1916 assert_eq!(report.source, "codex_jsonl");
1917 assert!(
1918 report
1919 .prompt_targets
1920 .iter()
1921 .any(|target| target.contains("[#cdxlog]"))
1922 );
1923 assert!(
1924 report
1925 .commands
1926 .iter()
1927 .any(|command| command.command == "cargo test --manifest-path Cargo.toml")
1928 );
1929 assert!(
1930 report
1931 .touched_files
1932 .iter()
1933 .any(|path| path.path == "Cargo.toml")
1934 );
1935 assert!(
1936 report
1937 .touched_files
1938 .iter()
1939 .any(|path| path.path == "src/lib.rs")
1940 );
1941 assert!(
1942 report
1943 .touched_symbols
1944 .iter()
1945 .any(|symbol| symbol.symbol == "run_sync")
1946 );
1947 assert!(
1948 report
1949 .failures
1950 .iter()
1951 .any(|failure| matches!(failure.kind.as_str(), "error" | "missing"))
1952 );
1953 assert!(report.failures.iter().any(|failure| failure.kind == "exit"));
1954 assert!(report.failures.iter().any(|failure| {
1955 failure.command.as_deref() == Some("cargo test --manifest-path Cargo.toml")
1956 }));
1957 assert!(report.closeout.iter().any(|entry| entry.kind == "push"));
1958 }
1959
1960 #[test]
1961 fn codex_jsonl_digest_anchors_command_failures_and_filters_instruction_snippets() {
1962 let dir = tempfile::tempdir().unwrap();
1963 let input = concat!(
1964 r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#sfail]. Tighten failure extraction so it reports command failures, not instruction text or panic snippets."}}"#,
1965 "\n",
1966 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"}]}}"#,
1967 "\n",
1968 r#"{"type":"event_msg","payload":{"type":"exec_command_end","exit_code":1,"aggregated_output":"opaque wrapper failed without a parsed command"}}"#,
1969 "\n"
1970 );
1971
1972 let report = compute(dir.path(), input, Some("codex-jsonl")).unwrap();
1973 assert!(
1974 report
1975 .failures
1976 .iter()
1977 .all(|failure| !failure.message.contains("After finalize")
1978 && !failure.message.contains("panic!(")
1979 && failure.message != "command exited with code 1")
1980 );
1981 assert!(report.failures.iter().any(|failure| {
1982 failure.kind == "panic" && failure.command.as_deref() == Some("cargo test")
1983 }));
1984 assert!(report.failures.iter().any(|failure| {
1985 failure.message.contains("assertion failed")
1986 && failure.command.as_deref() == Some("cargo test")
1987 }));
1988 assert!(report.failures.iter().any(|failure| {
1989 failure.message == "cargo test exited with code 1"
1990 && failure.command.as_deref() == Some("cargo test")
1991 }));
1992 }
1993
1994 #[test]
1995 fn codex_jsonl_digest_filters_conversational_file_fragments_and_shell_syntax() {
1996 let dir = tempfile::tempdir().unwrap();
1997 std::fs::create_dir_all(dir.path().join("src")).unwrap();
1998 std::fs::write(dir.path().join("src/lib.rs"), "fn run_sync() {}\n").unwrap();
1999 std::fs::write(dir.path().join("SPEC.md"), "# spec\n").unwrap();
2000
2001 let input = concat!(
2002 r#"{"type":"event_msg","payload":{"type":"agent_message","message":"I checked agent-doc/tsift, digest/session, progress/CI-status, and version/preflight while running a shell fallback like 2>/dev/null."}}"#,
2003 "\n",
2004 r#"{"type":"event_msg","payload":{"type":"exec_command_end","exit_code":0,"aggregated_output":"ok: src/lib.rs:1 and SPEC.md were inspected; noisy shell syntax 2>/dev/null was not a file","parsed_cmd":[{"type":"unknown","cmd":"sed -n '1,20p' src/lib.rs 2>/dev/null"}]}}"#,
2005 "\n"
2006 );
2007
2008 let report = compute(dir.path(), input, Some("codex-jsonl")).unwrap();
2009 let paths = report
2010 .touched_files
2011 .iter()
2012 .map(|file| file.path.as_str())
2013 .collect::<BTreeSet<_>>();
2014
2015 assert!(paths.contains("src/lib.rs"));
2016 assert!(paths.contains("SPEC.md"));
2017 for bogus in [
2018 "2>/dev/null",
2019 "agent-doc/tsift",
2020 "digest/session",
2021 "progress/CI-status",
2022 "version/preflight",
2023 ] {
2024 assert!(
2025 !paths.contains(bogus),
2026 "conversational fragment `{bogus}` should not be a touched file"
2027 );
2028 }
2029 }
2030
2031 #[test]
2032 fn markdown_digest_ignores_copied_instruction_ballast() {
2033 let dir = tempfile::tempdir().unwrap();
2034 let input = "\
2035# agent-doc
2036## Invocation
2037/agent-doc <FILE>
2038**Auto-update skill:** Run `agent-doc --version` and compare against `agent-doc-version`.
2039- **Imperative edits are executable directives** — when the user writes `do #id`, `run tests`, `build + install`, or `commit + push`
2040**Compound task steering:** if one directive mixes commit + push, normalize it before execution.
2041/workspace/agent-loop/src/boost-client
2042#[test]
2043//!
2044/**
2045#!/usr/bin/env bash
2046#
2047do [#sessiondigest]. spec-test-build-install-commit-push
2048";
2049
2050 let report = compute(dir.path(), input, None).unwrap();
2051 assert_eq!(
2052 report.prompt_targets,
2053 vec!["do [#sessiondigest]. spec-test-build-install-commit-push".to_string()]
2054 );
2055 assert!(report.failures.is_empty());
2056 }
2057
2058 #[test]
2059 fn prompt_target_digest_ignores_frontmatter_presets_and_completed_archives() {
2060 let dir = tempfile::tempdir().unwrap();
2061 let input = "\
2062---
2063agent_doc_format: template
2064prompt_presets:
2065 '#agent-doc-bug': Please create a plan for agent-doc to fix this issue.
2066 '#spec-test-build-install-commit-push': update spec + tests. build + install for local testing. commit + push
2067---
2068
2069## Exchange
2070
2071<!-- agent:exchange patch=append -->
2072do [#active]. spec-test-build-install-commit-push
2073<!-- /agent:exchange -->
2074
2075## Completed / Reaped
2076
2077<!-- agent:done -->
2078- 2026-05-12 [#old1] Add an old completed task.
2079- 2026-05-12 [#old2] do [#old2]. spec-test-build-install-commit-push
2080<!-- /agent:done -->
2081";
2082
2083 let report = compute(dir.path(), input, None).unwrap();
2084 assert_eq!(
2085 report.prompt_targets,
2086 vec!["do [#active]. spec-test-build-install-commit-push".to_string()]
2087 );
2088 }
2089
2090 #[test]
2091 fn codex_digest_ignores_copied_frontmatter_prompt_presets() {
2092 let dir = tempfile::tempdir().unwrap();
2093 let input = concat!(
2094 r##"{"type":"event_msg","payload":{"type":"user_message","message":"---\nprompt_presets:\n '#spec-test-build-install-commit-push': update spec + tests. build + install for local testing. commit + push\n---\n/agent-doc <FILE>\ndo [#cdxactive]. spec-test-build-install-commit-push"}}"##,
2095 "\n"
2096 );
2097
2098 let report = compute(dir.path(), input, Some("codex-jsonl")).unwrap();
2099 assert_eq!(
2100 report.prompt_targets,
2101 vec!["do [#cdxactive]. spec-test-build-install-commit-push".to_string()]
2102 );
2103 }
2104
2105 #[test]
2106 fn markdown_digest_ignores_successful_test_summaries_and_failure_labels() {
2107 let dir = tempfile::tempdir().unwrap();
2108 let input = "\
2109failures:
2110No failures detected (runner: cargo).
2111test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out
2112pytest summary: 4 passed, 0 failed in 0.02s
2113";
2114
2115 let report = compute(dir.path(), input, None).unwrap();
2116 assert!(report.failures.is_empty());
2117 }
2118
2119 #[test]
2120 fn codex_jsonl_digest_ignores_assistant_failure_meta_progress() {
2121 let dir = tempfile::tempdir().unwrap();
2122 let input = concat!(
2123 r#"{"type":"event_msg","payload":{"type":"user_message","message":"agent-doc /tmp/tasks/software/tsift.md"}}"#,
2124 "\n",
2125 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."}}"#,
2126 "\n"
2127 );
2128
2129 let report = compute(dir.path(), input, Some("codex-jsonl")).unwrap();
2130 assert!(report.failures.is_empty());
2131 }
2132
2133 #[test]
2134 fn markdown_digest_keeps_real_failure_lines() {
2135 let dir = tempfile::tempdir().unwrap();
2136 let input = "\
2137thread 'suite::alpha_failure' panicked at src/lib.rs:3:5:
2138assertion failed: left == right
2139test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out
2140";
2141
2142 let report = compute(dir.path(), input, None).unwrap();
2143 assert!(
2144 report
2145 .failures
2146 .iter()
2147 .any(|failure| failure.kind == "panic")
2148 );
2149 assert!(
2150 report
2151 .failures
2152 .iter()
2153 .any(|failure| failure.message.contains("assertion failed"))
2154 );
2155 assert!(
2156 report
2157 .failures
2158 .iter()
2159 .any(|failure| failure.message.contains("test result: FAILED"))
2160 );
2161 }
2162
2163 #[test]
2164 fn codex_jsonl_digest_filters_instruction_blob_lines_but_keeps_user_directive() {
2165 let dir = tempfile::tempdir().unwrap();
2166 let input = concat!(
2167 r##"{"type":"event_msg","payload":{"type":"user_message","message":"# agent-doc\n## Workflow\n/agent-doc <FILE>\n**Auto-update skill:** Run `agent-doc --version` and compare against `agent-doc-version`.\ndo [#cdxlog]. spec-test-build-install-commit-push"}}"##,
2168 "\n"
2169 );
2170
2171 let report = compute(dir.path(), input, Some("codex-jsonl")).unwrap();
2172 assert_eq!(
2173 report.prompt_targets,
2174 vec!["do [#cdxlog]. spec-test-build-install-commit-push".to_string()]
2175 );
2176 assert!(report.failures.is_empty());
2177 }
2178
2179 #[test]
2180 fn agent_doc_log_digest_extracts_runtime_events_and_paths() {
2181 let dir = tempfile::tempdir().unwrap();
2182 std::fs::create_dir_all(dir.path().join("tasks/software")).unwrap();
2183 std::fs::write(dir.path().join("tasks/software/tsift.md"), "# tsift\n").unwrap();
2184
2185 let input = format!(
2186 "\
2187[1776452736] session_start file=tasks/software/tsift.md pane=%141 session=tsift-v0
2188[1776452737] cwd_resolved path={} source=project_root
2189[1776528398] claude_start mode=fresh_restart restart_count=1
2190[1776528446] auto_trigger_timeout (no prompt after 30s)
2191[1776528450] ctrl_d_restart_fresh restart_count=2
2192[1776528532] claude_exit code=1 restart_count=0
2193[1776528534] user_quit_after_ctrl_d
2194",
2195 dir.path().display()
2196 );
2197
2198 let report = compute(dir.path(), &input, Some("agent-doc-log")).unwrap();
2199 assert_eq!(report.source, "agent_doc_log");
2200 assert_eq!(report.runtime_event_groups, 7);
2201 assert_eq!(report.restart_churn_groups, 4);
2202 assert!(
2203 report
2204 .runtime_events
2205 .iter()
2206 .any(|event| event.event == "claude_start:fresh_restart")
2207 );
2208 assert!(
2209 report
2210 .touched_files
2211 .iter()
2212 .any(|path| path.path == "tasks/software/tsift.md")
2213 );
2214 assert_eq!(report.file_groups, 1);
2215 assert!(!report.touched_files.iter().any(|path| path.path.is_empty()));
2216 assert!(
2217 report
2218 .failures
2219 .iter()
2220 .any(|failure| failure.kind == "timeout")
2221 );
2222 assert!(report.failures.iter().any(|failure| failure.kind == "exit"));
2223 assert!(
2224 report
2225 .restart_churn
2226 .iter()
2227 .any(|entry| entry.family == "fresh_restart" && entry.occurrences == 2)
2228 );
2229 assert!(
2230 report
2231 .restart_churn
2232 .iter()
2233 .any(|entry| entry.family == "ctrl_d_restart_loop" && entry.occurrences == 1)
2234 );
2235 assert!(
2236 report
2237 .restart_churn
2238 .iter()
2239 .any(|entry| entry.family == "quit_after_eof" && entry.occurrences == 1)
2240 );
2241 }
2242
2243 #[test]
2244 fn agent_doc_log_digest_dedupes_document_cycle_closeouts_by_cycle() {
2245 let dir = tempfile::tempdir().unwrap();
2246 let input = "\
2247[1777603275] document_cycle phase=response_captured cycle=cycle-1 event=response_captured capture_id=cycle-1
2248[1777603276] document_cycle phase=committed cycle=cycle-1 event=commit_success capture_id=cycle-1
2249[1777603403] document_cycle phase=committed cycle=cycle-1 event=commit_already_current capture_id=cycle-1
2250[1777603404] document_cycle phase=committed cycle=cycle-1 event=commit_already_current capture_id=cycle-1
2251[1777603600] document_cycle phase=committed cycle=cycle-2 event=commit_already_current
2252[1777603601] document_cycle phase=committed cycle=cycle-2 event=commit_already_current
2253[1777603700] document_cycle phase=committed cycle=cycle-3 event=commit_already_current
2254";
2255
2256 let report = compute(dir.path(), input, Some("agent-doc-log")).unwrap();
2257
2258 assert!(
2259 report
2260 .runtime_events
2261 .iter()
2262 .any(|event| event.event == "commit_already_current" && event.occurrences == 3)
2263 );
2264 assert!(report.closeout.iter().any(|entry| {
2265 entry.kind == "commit"
2266 && entry.detail == "document_cycle phase=committed event=commit_already_current"
2267 && entry.occurrences == 3
2268 }));
2269 assert!(report.closeout.iter().any(|entry| {
2270 entry.kind == "commit"
2271 && entry.detail == "document_cycle phase=committed event=commit_success"
2272 && entry.occurrences == 1
2273 }));
2274 }
2275}