Skip to main content

tsift_agent_doc/
session_digest.rs

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;
16/// Cap on KG entities surfaced into a digest's graph-evidence section. Kept
17/// small so the section stays a quick orientation pointer, not a graph dump.
18const 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/// One KG entity surfaced into the digest from `.tsift/graph.db` (#kgwiring).
102#[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/// Knowledge-graph orientation pulled from `.tsift/graph.db` for the workspace
111/// the session ran in (#kgwiring). This is the active-workflow consumer of the
112/// `graph_evidence` read seam: every session digest that runs in a workspace
113/// with a populated graph now surfaces the most connected entities (optionally
114/// scoped to the session's top touched symbol) so planning has KG context
115/// without a separate `tsift kg evidence` call. `scanned: false` with a node
116/// total set means the graph exists but exceeded the bounded-scan cap, so only
117/// counts are reported.
118#[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    // #kgwiring: surface KG orientation from the workspace graph.db, scoped to
306    // the session's most-touched symbol when one exists. Failures degrade to a
307    // warning — the digest must never fail because the KG is unreadable.
308    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
337/// Collect bounded KG evidence for a digest (#kgwiring). Returns `None` when the
338/// workspace has no `.tsift/graph.db` (the common case — no clutter), `Some`
339/// otherwise. A read error is downgraded to a `warnings` entry and `None`; the
340/// digest is best-effort orientation and must never fail on KG state. When the
341/// graph exceeds the bounded-scan cap the report is still returned with
342/// `scanned: false` and node/edge totals so callers know the KG is present.
343fn 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/// Exposed under the `test-support` feature (and in-crate `test` builds) so the
1000/// `tsift-sim-world` harness crate can exercise prompt-target extraction without
1001/// reaching into crate-private internals.
1002#[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        // No graph.db in this workspace → no KG evidence section (#kgwiring).
1801        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        // Seed a populated workspace graph.db at the conventional path.
1814        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        // Top touched symbol is run_sync → used as the evidence symbol filter.
1841        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}