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