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#[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}