diff --git a/README.md b/README.md index d8499d8..a48d549 100644 --- a/README.md +++ b/README.md @@ -137,13 +137,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. @@ -162,6 +163,7 @@ p0_count: 1 corrections: 3 errors: 2 workarounds: 2 +deferrals: 0 --- # Learning Digest — 2026-05-09 @@ -185,6 +187,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. @@ -224,6 +230,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 5afd44e..d1ed771 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, signal_title}; 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 { @@ -149,6 +155,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. @@ -161,6 +174,7 @@ pub fn run_review( &all_corrections, &all_errors, &all_workarounds, + &all_deferrals, &p0_alerts, &args.digest_dir, &issue_index, @@ -179,6 +193,7 @@ pub fn run_review( corrections: all_corrections, errors: all_errors, workarounds: all_workarounds, + deferrals: all_deferrals, p0_alerts, digest_path, created_issues, @@ -205,10 +220,11 @@ pub fn render_digest( corrections: &[Correction], errors: &[ErrorSignal], workarounds: &[Workaround], + deferrals: &[DeferralSignal], p0_alerts: &HashMap>, issue_index: &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)); @@ -217,6 +233,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)); @@ -301,6 +318,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 } @@ -317,13 +345,14 @@ pub fn write_digest( corrections: &[Correction], errors: &[ErrorSignal], workarounds: &[Workaround], + deferrals: &[DeferralSignal], p0_alerts: &HashMap>, digest_dir: &Path, issue_index: &HashMap, ) -> 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) } @@ -373,21 +402,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(), &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")); 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(), &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._")); assert!(body.contains("_No workarounds detected._")); + assert!(body.contains("_No deferrals detected._")); } #[test] @@ -398,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")); } @@ -409,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); } @@ -421,14 +452,25 @@ 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]")); } + #[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(), &no_issues()); + 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, &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); @@ -454,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)"), @@ -470,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); } @@ -495,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. 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",