From 2888914e02ee5d3c102d30c3f17fb62b9feac372 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 10 May 2026 06:42:00 -0700 Subject: [PATCH 1/2] feat: implement Deferral signal detector The README's Signal anatomy section calls out Pattern and Deferral as reserved enum variants 'no detector produces them yet.' This wires up the Deferral detector and renders deferrals through the digest pipeline. - detect_deferrals mirrors detect_workarounds: OnceLock with a parallel label table, first-match-wins per assistant message. Patterns cover come-back-to, deferring, defer, punt, leave-for-later, skipping-for-now, park-for-now, next-session, circle-back. - detect_workarounds and detect_deferrals are independent. A message like 'skipping for now' fires both and the tracker files them under separate titles. - DigestReport gains a deferrals: Vec field; run_review populates it. render_digest and write_digest gain a deferrals param inserted between workarounds and p0_alerts; existing tests pass []. - Digest YAML frontmatter always carries deferrals: N (zero acceptable). ## Deferrals section is unconditional with _No deferrals detected._ placeholder, mirroring the existing Corrections/Errors/Workarounds pattern. - The CLI summary println in commands/review.rs and the JSON output (--json from feat/review-nightly-json-since) both include the count. - README Signal anatomy table gains a Deferral row between Workaround and P0 Alert; the prose drops Deferral from the reserved list. - 8 deferrals_* tests in detectors.rs plus a digest render test. --- README.md | 9 +- crates/jilog-review/src/detectors.rs | 145 ++++++++++++++++++++++++++- crates/jilog-review/src/digest.rs | 69 ++++++++++--- crates/jilog-review/src/lib.rs | 4 +- crates/jilog-review/src/signal.rs | 7 +- crates/jilog/src/commands/review.rs | 6 +- 6 files changed, 215 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index ae38964..b10e315 100644 --- a/README.md +++ b/README.md @@ -125,13 +125,14 @@ See the `Tracker` trait in `crates/jilog-review/src/tracker.rs` to implement you ## Signal anatomy -Four signal types are detected today. Two more (`Pattern`, `Deferral`) are reserved in the enum for forward compatibility but no detector produces them yet. +Five signal types are detected today. One more (`Pattern`) is reserved in the enum for forward compatibility but no detector produces it yet. | Signal | What triggers it | Detector heuristic | |---------------|---------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------| | `Correction` | User pushes back on an assistant turn in 15–200 chars | `assistant → user → assistant` window with a short user message in the middle | | `Error` | A tool call returned a structured failure | A `role: tool` message whose JSON content has `success: false` | | `Workaround` | Assistant text admits a temporary or hacky path | First-match across `for now`, `temporary`, `workaround`, `hardcoded`, `TODO`, `FIXME`, `quick fix`, `hack` | +| `Deferral` | Assistant text postpones work to a later session | First-match across `come back to`, `defer`, `punt on`, `leave for later`, `skipping for now`, `park for now`, `next session`, `circle back` | | `P0 Alert` | The same tool failed in **3+ distinct root sessions** in the window | Aggregation pass over `Error` signals (sub-agent sessions with the all-zero prefix are excluded) | Each emitted signal carries the `session_id` it came from, so digest and tracker entries always link back to the conversation that produced them. @@ -150,6 +151,7 @@ p0_count: 1 corrections: 3 errors: 2 workarounds: 2 +deferrals: 0 --- # Learning Digest — 2026-05-09 @@ -173,6 +175,10 @@ workarounds: 2 - `2f1c8d77-91ab-4c5d-8e6f-1a2b3c4d5e6f` pattern=`for now`: 'Hardcoding the retry count to 3 for now until we wire it through config.' - `8a4b1e09-3344-4abc-9def-0123456789ab` pattern=`TODO`: 'TODO: replace this fallback path once the upstream module exposes the new API.' + +## Deferrals + +_No deferrals detected._ ```` The frontmatter is machine-parseable. A common downstream check is "did yesterday's digest go silent on P0?" — `yq '.p0_count' learning-digest-*.md` gives a per-day series. @@ -212,6 +218,7 @@ The issue title is built by `signal_title()` (see `crates/jilog-review/src/track [jilog/correction] : [jilog/error] : [jilog/workaround] : +[jilog/deferral] : ``` A run that produced the digest above would land entries like these in `.beads/issues.jsonl` with `tracker.type = "beads"` (one JSON object per line, shown pretty-printed): diff --git a/crates/jilog-review/src/detectors.rs b/crates/jilog-review/src/detectors.rs index 0c027a0..4b59f52 100644 --- a/crates/jilog-review/src/detectors.rs +++ b/crates/jilog-review/src/detectors.rs @@ -1,6 +1,6 @@ //! Signal detectors — heuristic analysis of transcript messages. //! -//! All four detectors are pure functions over `&[Message]`. +//! All five detectors are pure functions over `&[Message]`. //! Ported verbatim from opsctl/crates/opsctl/src/review_nightly.rs. use std::collections::{BTreeSet, HashMap}; @@ -9,7 +9,7 @@ use std::sync::OnceLock; use regex::RegexSet; use crate::reader::Message; -use crate::signal::{Correction, ErrorSignal, Workaround}; +use crate::signal::{Correction, DeferralSignal, ErrorSignal, Workaround}; // --------------------------------------------------------------------------- // Constants @@ -51,6 +51,32 @@ const WORKAROUND_LABELS: &[&str] = &[ "hack", ]; +/// Deferral pattern regexes — at most ONE deferral per message; first-match wins. +const DEFERRAL_PATTERNS: &[&str] = &[ + r"(?i)\bI'?ll come back to (this|that|it)", + r"(?i)\bdeferr?ing (this|that|it|until)", + r"(?i)\bdefer (this|that|it)(?: (to|until|for))?", + r"(?i)\bpunt(ing)? on (this|that|it)", + r"(?i)\bleav(e|ing) (this|that|it) for (later|now|next)", + r"(?i)\bskipping for now", + r"(?i)\bpark(ing)? (this|that|it) for now", + r"(?i)\bnext session", + r"(?i)\bcircle back (to|on)", +]; + +/// Human-readable labels (parallel to DEFERRAL_PATTERNS by index). +const DEFERRAL_LABELS: &[&str] = &[ + "come back later", + "deferring", + "defer", + "punt", + "leave for later", + "skipping for now", + "park for now", + "next session", + "circle back", +]; + /// Sub-agent session prefix (16 zeros). Sessions whose ID starts with /// this are excluded from P0 alert counting. const SUB_AGENT_PREFIX: &str = "0000000000000000"; @@ -254,7 +280,46 @@ pub(crate) fn extract_assistant_text(content: &Option) -> Str } // --------------------------------------------------------------------------- -// Heuristic 4: P0 alert detection +// Heuristic 4: Deferral detection +// --------------------------------------------------------------------------- + +/// Compile the deferral regex set once. +fn deferral_regex() -> &'static RegexSet { + static SET: OnceLock = OnceLock::new(); + SET.get_or_init(|| RegexSet::new(DEFERRAL_PATTERNS).expect("deferral patterns must compile")) +} + +/// Detect deferral language in assistant messages. At most one +/// deferral per message (first-match wins, in declaration order). +pub fn detect_deferrals(messages: &[Message], session_id: &str) -> Vec { + let regex = deferral_regex(); + let mut out = Vec::new(); + for msg in messages { + if msg.role.as_deref() != Some("assistant") { + continue; + } + let text = extract_assistant_text(&msg.content); + if text.is_empty() { + continue; + } + let matches = regex.matches(&text); + if !matches.matched_any() { + continue; + } + // Find the FIRST matched pattern in declaration order. + let first_idx = matches.iter().next().expect("matched_any is true"); + let label = DEFERRAL_LABELS.get(first_idx).copied().unwrap_or("unknown"); + + out.push(DeferralSignal { + session_id: session_id.to_string(), + item: label.to_string(), + }); + } + out +} + +// --------------------------------------------------------------------------- +// Heuristic 5: P0 alert detection // --------------------------------------------------------------------------- /// Group errors by tool, count distinct ROOT sessions (skip sub-agents). @@ -511,6 +576,80 @@ mod tests { assert_eq!(out[0].context.chars().count(), 200); } + // ---------- deferrals ---------- + + #[test] + fn deferrals_basic_match() { + let msgs = vec![assistant("I'll come back to this after the tests pass.")]; + let out = detect_deferrals(&msgs, "s1"); + assert_eq!(out.len(), 1); + assert_eq!(out[0].session_id, "s1"); + assert_eq!(out[0].item, "come back later"); + } + + #[test] + fn deferrals_short_text_detected() { + let msgs = vec![assistant("next session")]; + let out = detect_deferrals(&msgs, "s1"); + assert_eq!(out.len(), 1); + assert_eq!(out[0].item, "next session"); + } + + #[test] + fn deferrals_first_match_wins() { + let msgs = vec![assistant("I'll come back to this next session.")]; + let out = detect_deferrals(&msgs, "s1"); + assert_eq!(out.len(), 1); + assert_eq!(out[0].item, "come back later"); + } + + #[test] + fn deferrals_user_role_skipped() { + let msgs = vec![user("please punt on this until next session")]; + assert!(detect_deferrals(&msgs, "s1").is_empty()); + } + + #[test] + fn deferrals_tool_blocks_excluded() { + let msgs = vec![Message { + role: Some("assistant".into()), + content: Some(json!([ + {"type": "tool_use", "name": "bash", "input": {"note": "next session"}}, + {"type": "text", "text": "I ran the command."}, + ])), + name: None, + }]; + let out = detect_deferrals(&msgs, "s1"); + assert!(out.is_empty(), "tool_use blocks must be skipped"); + } + + #[test] + fn deferrals_no_match_returns_empty() { + let msgs = vec![assistant("All requested work is complete.")]; + assert!(detect_deferrals(&msgs, "s1").is_empty()); + } + + #[test] + fn deferrals_label_emitted_correctly() { + let msgs = vec![assistant("Let me defer that until the next pass.")]; + let out = detect_deferrals(&msgs, "s1"); + assert_eq!(out.len(), 1); + assert_eq!(out[0].item, "defer"); + } + + #[test] + fn deferrals_overlap_with_workaround_both_fire() { + let msgs = vec![assistant( + "Skipping for now while we validate the migration.", + )]; + let workarounds = detect_workarounds(&msgs, "s1"); + let deferrals = detect_deferrals(&msgs, "s1"); + assert_eq!(workarounds.len(), 1); + assert_eq!(workarounds[0].pattern, "for now"); + assert_eq!(deferrals.len(), 1); + assert_eq!(deferrals[0].item, "skipping for now"); + } + // ---------- p0 alerts ---------- #[test] diff --git a/crates/jilog-review/src/digest.rs b/crates/jilog-review/src/digest.rs index 12ca9ea..4e63dbb 100644 --- a/crates/jilog-review/src/digest.rs +++ b/crates/jilog-review/src/digest.rs @@ -5,11 +5,13 @@ use std::path::{Path, PathBuf}; use chrono::{NaiveDate, DateTime, Utc}; -use crate::detectors::{detect_corrections, detect_errors, detect_workarounds, detect_p0_alerts}; use crate::detectors::MAX_ERROR_MESSAGE_LENGTH; +use crate::detectors::{ + detect_corrections, detect_deferrals, detect_errors, detect_p0_alerts, detect_workarounds, +}; use crate::error::JilogReviewError; use crate::reader::{ProcessedSessions, Reader}; -use crate::signal::{Correction, ErrorSignal, Signal, Workaround}; +use crate::signal::{Correction, DeferralSignal, ErrorSignal, Signal, Workaround}; use crate::tracker::{IssueRef, Tracker}; use crate::util::{python_repr, truncate_with_marker}; @@ -39,6 +41,7 @@ pub struct DigestReport { pub corrections: Vec, pub errors: Vec, pub workarounds: Vec, + pub deferrals: Vec, pub p0_alerts: HashMap>, pub digest_path: PathBuf, pub created_issues: Vec, @@ -53,8 +56,8 @@ pub struct DigestReport { /// iterate readers → discover transcripts → load messages → /// run detectors → dedup against tracker → create issues → render digest. /// -/// Note: Pattern and Deferral signals are produced by NO detector at this -/// time. They are in the Signal enum for forward-compatibility only. +/// Note: Pattern signals are produced by NO detector at this time. They are in +/// the Signal enum for forward-compatibility only. pub fn run_review( readers: &[Box], tracker: &dyn Tracker, @@ -63,6 +66,7 @@ pub fn run_review( let mut all_corrections: Vec = Vec::new(); let mut all_errors: Vec = Vec::new(); let mut all_workarounds: Vec = Vec::new(); + let mut all_deferrals: Vec = Vec::new(); let mut sessions_scanned: usize = 0; let mut created_issues: Vec = Vec::new(); @@ -101,10 +105,12 @@ pub fn run_review( let corrections = detect_corrections(&messages, &handle.session_id); let errors = detect_errors(&messages, &handle.session_id); let workarounds = detect_workarounds(&messages, &handle.session_id); + let deferrals = detect_deferrals(&messages, &handle.session_id); all_corrections.extend(corrections); all_errors.extend(errors); all_workarounds.extend(workarounds); + all_deferrals.extend(deferrals); sessions_scanned += 1; if let Some(ref mut ps) = processed { @@ -138,6 +144,13 @@ pub fn run_review( Err(e) => tracing::warn!("tracker.create failed: {}", e), } } + for deferral in &all_deferrals { + let signal = Signal::Deferral(deferral.clone()); + match tracker.create(&signal) { + Ok(issue_ref) => created_issues.push(issue_ref), + Err(e) => tracing::warn!("tracker.create failed: {}", e), + } + } } // Render and write digest. @@ -150,6 +163,7 @@ pub fn run_review( &all_corrections, &all_errors, &all_workarounds, + &all_deferrals, &p0_alerts, &args.digest_dir, )?; @@ -167,6 +181,7 @@ pub fn run_review( corrections: all_corrections, errors: all_errors, workarounds: all_workarounds, + deferrals: all_deferrals, p0_alerts, digest_path, created_issues, @@ -175,21 +190,21 @@ pub fn run_review( } // --------------------------------------------------------------------------- -// render_digest — byte-for-byte compatible with opsctl +// render_digest — stable markdown digest format // --------------------------------------------------------------------------- /// Render a learning-digest markdown string. /// -/// Output format is byte-compatible with the Python script and opsctl. /// Consumers that grep these digests depend on this exact format. pub fn render_digest( date: &str, corrections: &[Correction], errors: &[ErrorSignal], workarounds: &[Workaround], + deferrals: &[DeferralSignal], p0_alerts: &HashMap>, ) -> String { - let signals = corrections.len() + errors.len() + workarounds.len(); + let signals = corrections.len() + errors.len() + workarounds.len() + deferrals.len(); let mut buf = String::new(); buf.push_str("---\n"); buf.push_str(&format!("date: {}\n", date)); @@ -198,6 +213,7 @@ pub fn render_digest( buf.push_str(&format!("corrections: {}\n", corrections.len())); buf.push_str(&format!("errors: {}\n", errors.len())); buf.push_str(&format!("workarounds: {}\n", workarounds.len())); + buf.push_str(&format!("deferrals: {}\n", deferrals.len())); buf.push_str("---\n\n"); buf.push_str(&format!("# Learning Digest — {}\n\n", date)); @@ -268,6 +284,17 @@ pub fn render_digest( buf.push('\n'); } + // Deferrals + buf.push_str("## Deferrals\n\n"); + if deferrals.is_empty() { + buf.push_str("_No deferrals detected._\n\n"); + } else { + for d in deferrals { + buf.push_str(&format!("- `{}` pattern=`{}`\n", d.session_id, d.item)); + } + buf.push('\n'); + } + buf } @@ -281,12 +308,13 @@ pub fn write_digest( corrections: &[Correction], errors: &[ErrorSignal], workarounds: &[Workaround], + deferrals: &[DeferralSignal], p0_alerts: &HashMap>, digest_dir: &Path, ) -> Result { std::fs::create_dir_all(digest_dir)?; let path = digest_dir.join(format!("learning-digest-{}.md", date)); - let body = render_digest(date, corrections, errors, workarounds, p0_alerts); + let body = render_digest(date, corrections, errors, workarounds, deferrals, p0_alerts); std::fs::write(&path, body)?; Ok(path) } @@ -312,21 +340,23 @@ mod tests { #[test] fn digest_frontmatter_has_counts() { let corrections = vec![Correction { session_id: "a".into(), context: "fix it".into() }]; - let body = render_digest("2026-04-30", &corrections, &[], &[], &HashMap::new()); + let body = render_digest("2026-04-30", &corrections, &[], &[], &[], &HashMap::new()); assert!(body.starts_with("---\n")); assert!(body.contains("date: 2026-04-30")); assert!(body.contains("signals_captured: 1")); assert!(body.contains("corrections: 1")); assert!(body.contains("errors: 0")); + assert!(body.contains("deferrals: 0")); } #[test] fn digest_empty_sections_use_placeholder() { - let body = render_digest("2026-04-30", &[], &[], &[], &HashMap::new()); + let body = render_digest("2026-04-30", &[], &[], &[], &[], &HashMap::new()); assert!(body.contains("_No P0 alerts._")); assert!(body.contains("_No corrections detected._")); assert!(body.contains("_No errors detected._")); assert!(body.contains("_No workarounds detected._")); + assert!(body.contains("_No deferrals detected._")); } #[test] @@ -337,7 +367,7 @@ mod tests { sessions.insert("bbb".into()); sessions.insert("ccc".into()); p0.insert("bash".into(), sessions); - let body = render_digest("2026-04-30", &[], &[], &[], &p0); + let body = render_digest("2026-04-30", &[], &[], &[], &[], &p0); assert!(body.contains("`bash` failed in 3 distinct sessions")); assert!(body.contains("aaa, bbb, ccc")); } @@ -348,7 +378,7 @@ mod tests { session_id: "abc".into(), context: "don't do that".into(), }]; - let body = render_digest("2026-04-30", &corrections, &[], &[], &HashMap::new()); + let body = render_digest("2026-04-30", &corrections, &[], &[], &[], &HashMap::new()); // Single quote inside should be escaped: \' assert!(body.contains("'don\\'t do that'"), "digest body: {}", body); } @@ -360,14 +390,25 @@ mod tests { tool_name: "bash".into(), message: "x".repeat(600), }]; - let body = render_digest("2026-04-30", &[], &errors, &[], &HashMap::new()); + let body = render_digest("2026-04-30", &[], &errors, &[], &[], &HashMap::new()); assert!(body.contains("[truncated]")); } + #[test] + fn digest_deferrals_render_item() { + let deferrals = vec![DeferralSignal { + session_id: "s1".into(), + item: "next session".into(), + }]; + let body = render_digest("2026-04-30", &[], &[], &[], &deferrals, &HashMap::new()); + assert!(body.contains("signals_captured: 1")); + assert!(body.contains("- `s1` pattern=`next session`")); + } + #[test] fn write_digest_creates_file() { let dir = test_dir("digest-write"); - let path = write_digest("2026-04-30", &[], &[], &[], &HashMap::new(), &dir).unwrap(); + let path = write_digest("2026-04-30", &[], &[], &[], &[], &HashMap::new(), &dir).unwrap(); assert!(path.exists()); assert_eq!(path.file_name().unwrap(), "learning-digest-2026-04-30.md"); let _ = fs::remove_dir_all(&dir); diff --git a/crates/jilog-review/src/lib.rs b/crates/jilog-review/src/lib.rs index 0aaea7c..7abb08c 100644 --- a/crates/jilog-review/src/lib.rs +++ b/crates/jilog-review/src/lib.rs @@ -7,7 +7,7 @@ //! - [`Reader`] trait + [`Message`], [`TranscriptHandle`], [`ProcessedSessions`] //! - [`Tracker`] trait + [`IssueRef`], [`signal_title`] //! - Detectors: [`detect_corrections`], [`detect_errors`], [`detect_workarounds`], -//! [`detect_p0_alerts`] +//! [`detect_deferrals`], [`detect_p0_alerts`] //! - [`run_review`] — top-level orchestrator //! - Built-in readers via [`readers`] module //! - Built-in trackers via [`trackers`] module @@ -26,5 +26,5 @@ pub use error::JilogReviewError; pub use signal::{Signal, Correction, ErrorSignal, Workaround, PatternSignal, DeferralSignal}; pub use reader::{Reader, Message, TranscriptHandle, ProcessedSessions}; pub use tracker::{Tracker, IssueRef, signal_title}; -pub use detectors::{detect_corrections, detect_errors, detect_workarounds, detect_p0_alerts}; +pub use detectors::{detect_corrections, detect_errors, detect_workarounds, detect_deferrals, detect_p0_alerts}; pub use digest::{run_review, render_digest, write_digest, ReviewArgs, DigestReport}; diff --git a/crates/jilog-review/src/signal.rs b/crates/jilog-review/src/signal.rs index 19b3c58..00dd30b 100644 --- a/crates/jilog-review/src/signal.rs +++ b/crates/jilog-review/src/signal.rs @@ -4,8 +4,8 @@ use serde::{Deserialize, Serialize}; /// A learning signal extracted from session transcripts. /// -/// `Pattern` and `Deferral` are reserved for future detectors; no current -/// detector produces them. They appear in the enum for forward-compat only. +/// `Pattern` is reserved for a future detector. It appears in the enum for +/// forward-compat only. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum Signal { @@ -14,7 +14,6 @@ pub enum Signal { Workaround(Workaround), /// Reserved: pattern detector not yet implemented. Pattern(PatternSignal), - /// Reserved: deferral detector not yet implemented. Deferral(DeferralSignal), } @@ -76,7 +75,7 @@ pub struct PatternSignal { pub description: String, } -/// Reserved: detected deferral. No detector produces this yet. +/// Assistant text postponing work to a later session. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct DeferralSignal { pub session_id: String, diff --git a/crates/jilog/src/commands/review.rs b/crates/jilog/src/commands/review.rs index 2e7e54e..db2bdbc 100644 --- a/crates/jilog/src/commands/review.rs +++ b/crates/jilog/src/commands/review.rs @@ -112,10 +112,11 @@ fn run_nightly(cfg: &JilogConfig, args: &NightlyArgs) -> anyhow::Result<()> { ); } else { println!( - "{} corrections, {} errors, {} workarounds, {} P0 alert(s), {} session(s) scanned", + "{} corrections, {} errors, {} workarounds, {} deferrals, {} P0 alert(s), {} session(s) scanned", report.corrections.len(), report.errors.len(), report.workarounds.len(), + report.deferrals.len(), report.p0_alerts.len(), report.sessions_scanned, ); @@ -180,6 +181,7 @@ fn digest_report_json(report: &DigestReport, dry_run: bool) -> serde_json::Value "corrections": report.corrections.len(), "errors": report.errors.len(), "workarounds": report.workarounds.len(), + "deferrals": report.deferrals.len(), "p0_alerts": serde_json::Value::Object(p0_alerts), "digest_path": digest_path, "created_issues": serde_json::Value::Array(created_issues), @@ -231,6 +233,7 @@ mod tests { corrections: Vec::new(), errors: Vec::new(), workarounds: Vec::new(), + deferrals: Vec::new(), p0_alerts, digest_path: PathBuf::from("/tmp/learning-digest-2026-05-10.md"), created_issues: vec![IssueRef { @@ -298,6 +301,7 @@ mod tests { "corrections", "errors", "workarounds", + "deferrals", "p0_alerts", "digest_path", "created_issues", From 873a9713ee98d136359427400863b797a33e58e4 Mon Sep 17 00:00:00 2001 From: Joichi Ito Date: Mon, 11 May 2026 09:34:11 +0600 Subject: [PATCH 2/2] Merge: resolve digest.rs conflict between PR#2 (deferrals) and main (issue_index) --- crates/jilog-review/src/digest.rs | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/crates/jilog-review/src/digest.rs b/crates/jilog-review/src/digest.rs index 584250f..d1ed771 100644 --- a/crates/jilog-review/src/digest.rs +++ b/crates/jilog-review/src/digest.rs @@ -11,7 +11,7 @@ use crate::detectors::{ }; use crate::error::JilogReviewError; use crate::reader::{ProcessedSessions, Reader}; -use crate::signal::{Correction, ErrorSignal, Signal, Workaround}; +use crate::signal::{Correction, DeferralSignal, ErrorSignal, Signal, Workaround}; use crate::tracker::{IssueRef, Tracker, signal_title}; use crate::util::{python_repr, truncate_with_marker}; @@ -352,7 +352,7 @@ pub fn write_digest( ) -> Result { std::fs::create_dir_all(digest_dir)?; let path = digest_dir.join(format!("learning-digest-{}.md", date)); - let body = render_digest(date, corrections, errors, workarounds, p0_alerts, issue_index); + let body = render_digest(date, corrections, errors, workarounds, deferrals, p0_alerts, issue_index); std::fs::write(&path, body)?; Ok(path) } @@ -402,7 +402,7 @@ mod tests { #[test] fn digest_frontmatter_has_counts() { let corrections = vec![Correction { session_id: "a".into(), context: "fix it".into() }]; - let body = render_digest("2026-04-30", &corrections, &[], &[], &HashMap::new(), &no_issues()); + let body = render_digest("2026-04-30", &corrections, &[], &[], &[], &HashMap::new(), &no_issues()); assert!(body.starts_with("---\n")); assert!(body.contains("date: 2026-04-30")); assert!(body.contains("signals_captured: 1")); @@ -413,7 +413,7 @@ mod tests { #[test] fn digest_empty_sections_use_placeholder() { - let body = render_digest("2026-04-30", &[], &[], &[], &HashMap::new(), &no_issues()); + let body = render_digest("2026-04-30", &[], &[], &[], &[], &HashMap::new(), &no_issues()); assert!(body.contains("_No P0 alerts._")); assert!(body.contains("_No corrections detected._")); assert!(body.contains("_No errors detected._")); @@ -429,7 +429,7 @@ mod tests { sessions.insert("bbb".into()); sessions.insert("ccc".into()); p0.insert("bash".into(), sessions); - let body = render_digest("2026-04-30", &[], &[], &[], &p0, &no_issues()); + let body = render_digest("2026-04-30", &[], &[], &[], &[], &p0, &no_issues()); assert!(body.contains("`bash` failed in 3 distinct sessions")); assert!(body.contains("aaa, bbb, ccc")); } @@ -440,7 +440,7 @@ mod tests { session_id: "abc".into(), context: "don't do that".into(), }]; - let body = render_digest("2026-04-30", &corrections, &[], &[], &HashMap::new(), &no_issues()); + let body = render_digest("2026-04-30", &corrections, &[], &[], &[], &HashMap::new(), &no_issues()); // Single quote inside should be escaped: \' assert!(body.contains("'don\\'t do that'"), "digest body: {}", body); } @@ -452,7 +452,7 @@ mod tests { tool_name: "bash".into(), message: "x".repeat(600), }]; - let body = render_digest("2026-04-30", &[], &errors, &[], &HashMap::new(), &no_issues()); + let body = render_digest("2026-04-30", &[], &errors, &[], &[], &HashMap::new(), &no_issues()); assert!(body.contains("[truncated]")); } @@ -462,7 +462,7 @@ mod tests { session_id: "s1".into(), item: "next session".into(), }]; - let body = render_digest("2026-04-30", &[], &[], &[], &deferrals, &HashMap::new()); + let body = render_digest("2026-04-30", &[], &[], &[], &deferrals, &HashMap::new(), &no_issues()); assert!(body.contains("signals_captured: 1")); assert!(body.contains("- `s1` pattern=`next session`")); } @@ -470,7 +470,7 @@ mod tests { #[test] fn write_digest_creates_file() { let dir = test_dir("digest-write"); - let path = write_digest("2026-04-30", &[], &[], &[], &HashMap::new(), &dir, &no_issues()).unwrap(); + let path = write_digest("2026-04-30", &[], &[], &[], &[], &HashMap::new(), &dir, &no_issues()).unwrap(); assert!(path.exists()); assert_eq!(path.file_name().unwrap(), "learning-digest-2026-04-30.md"); let _ = fs::remove_dir_all(&dir); @@ -496,7 +496,7 @@ mod tests { let mut index = HashMap::new(); index.insert(signal_title(&signal), issue_ref); - let body = render_digest("2026-05-11", &[correction], &[], &[], &HashMap::new(), &index); + let body = render_digest("2026-05-11", &[correction], &[], &[], &[], &HashMap::new(), &index); // Annotation must appear at end of bullet line, before newline. assert!( body.contains("(→ kata#7)"), @@ -512,7 +512,7 @@ mod tests { session_id: "abc".into(), context: "fix it".into(), }; - let body = render_digest("2026-05-11", &[correction], &[], &[], &HashMap::new(), &no_issues()); + let body = render_digest("2026-05-11", &[correction], &[], &[], &[], &HashMap::new(), &no_issues()); // Line must end with content then newline — no trailing annotation. assert!(!body.contains("(→"), "unexpected annotation in:\n{}", body); } @@ -537,12 +537,12 @@ mod tests { let body_with = render_digest( "2026-05-11", &[c_annotated.clone(), c_plain.clone()], - &[], &[], &HashMap::new(), &index, + &[], &[], &[], &HashMap::new(), &index, ); let body_without = render_digest( "2026-05-11", &[c_annotated.clone(), c_plain.clone()], - &[], &[], &HashMap::new(), &no_issues(), + &[], &[], &[], &HashMap::new(), &no_issues(), ); // The plain line must be identical in both renders.