csift 0.10.1

ripgrep for Claude Code session transcripts: fast regex list/search over ~/.claude/projects/**/*.jsonl
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
//! The six-verdict join: registry -> tail -> pid, sidecars covering HITL.

use super::*;

/// The closed verdict set. Wire slugs are the kebab forms.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Verdict {
    Running,
    WaitingChildren,
    WaitingHitl,
    /// The turn ended (a clean end_turn) but N background task(s) the lens counts have
    /// not returned - neither running nor stopped: by design (a dev server, a watcher)
    /// or not, csift cannot tell. Never satisfies `--until stop`.
    IdleBackgroundOpen,
    IdleEot,
    StaleDead,
    Unknown,
}

impl Verdict {
    #[must_use]
    pub(crate) fn slug(self) -> &'static str {
        match self {
            Verdict::Running => "running",
            Verdict::WaitingChildren => "waiting-children",
            Verdict::WaitingHitl => "waiting-hitl",
            Verdict::IdleBackgroundOpen => "idle-background-open",
            Verdict::IdleEot => "idle-eot",
            Verdict::StaleDead => "stale-dead",
            Verdict::Unknown => "unknown",
        }
    }
}

/// One evidence row: which surface said what, and how old that statement is.
#[derive(Debug, Clone)]
pub(crate) struct Evidence {
    pub(crate) surface: &'static str,
    pub(crate) value: String,
    pub(crate) age_secs: Option<i64>,
}

/// Everything the join produced: the verdict plus every row that fed it.
#[derive(Debug, Clone)]
pub(crate) struct Assessment {
    pub(crate) verdict: Verdict,
    pub(crate) evidence: Vec<Evidence>,
    pub(crate) children: Vec<ChildState>,
    pub(crate) pending: Vec<String>,
    pub(crate) notes: Vec<String>,
    /// The harness task list (attached by `assess_path`; empty default in the pure join).
    pub(crate) tasks: TasksReport,
    /// Background launches and their fate (the lens already applied).
    pub(crate) background: BackgroundReport,
    /// The newest prompt + assistant message (attached by `assess_path`).
    pub(crate) last: LastMessages,
    /// What the main lane is doing at this instant, in words (`in a Bash call for
    /// 34s` / `generating (last record 12s ago)` / `idle (last stop_reason end_turn, 5m
    /// 2s ago)`).
    pub(crate) tail_state: String,
}

/// The six-surface join without a background report (test-only convenience; production
/// goes through [`assess_full`]).
#[cfg(test)]
pub(crate) fn assess(
    registry: Option<&RegistryRow>,
    liveness: Option<&PidLiveness>,
    main_tail: &TailShape,
    children: &ChildrenReport,
    pending_elicitations: &[String],
    is_subagent_target: bool,
) -> Assessment {
    assess_full(
        registry,
        liveness,
        main_tail,
        children,
        pending_elicitations,
        is_subagent_target,
        &BackgroundReport::default(),
    )
}

/// Join the surfaces into one verdict. Precedence (each step names its evidence):
/// dead process > blocked-on-human > tool-in-flight > live children > open background
/// task under the lens > clean EoT > unknown. Never guesses: a shape the rules cannot
/// rank is `unknown` with the disagreement in the evidence rows.
pub(crate) fn assess_full(
    registry: Option<&RegistryRow>,
    liveness: Option<&PidLiveness>,
    main_tail: &TailShape,
    children: &ChildrenReport,
    pending_elicitations: &[String],
    is_subagent_target: bool,
    background: &BackgroundReport,
) -> Assessment {
    let (evidence, mut notes) = collect_evidence(
        registry,
        liveness,
        main_tail,
        children,
        pending_elicitations,
        is_subagent_target,
        background,
    );
    let verdict = rank_verdict(
        registry,
        liveness,
        main_tail,
        children,
        pending_elicitations,
        background,
        &mut notes,
    );

    // F7 honesty: a pending PERMISSION prompt leaves no transcript trace and would
    // masquerade exactly as these verdicts; only a CURRENT registry row (status
    // `waiting`) can show one, and that row is transition-written, never a heartbeat.
    if matches!(
        verdict,
        Verdict::IdleEot | Verdict::WaitingChildren | Verdict::IdleBackgroundOpen
    ) {
        notes.push(match registry.and_then(|r| r.status.as_deref()) {
            Some(s) => format!(
                "a pending permission prompt leaves no transcript trace - it would \
                 masquerade as idle; the registry row (status {s}) would read `waiting` \
                 while one is up, and that row is transition-written, so trust it only \
                 while fresh"
            ),
            None => "a pending permission prompt lives only in Claude Code process memory \
                     and is invisible to this instrument - it would masquerade as idle"
                .to_string(),
        });
    }

    Assessment {
        verdict,
        evidence,
        children: children.children.clone(),
        pending: pending_elicitations.to_vec(),
        notes,
        tasks: TasksReport::default(),
        background: background.clone(),
        last: LastMessages::default(),
        tail_state: tail_state_words(main_tail),
    }
}

/// Every surface's evidence row (plus its degradation note), in display order.
fn collect_evidence(
    registry: Option<&RegistryRow>,
    liveness: Option<&PidLiveness>,
    main_tail: &TailShape,
    children: &ChildrenReport,
    pending_elicitations: &[String],
    is_subagent_target: bool,
    background: &BackgroundReport,
) -> (Vec<Evidence>, Vec<String>) {
    let mut evidence: Vec<Evidence> = Vec::new();
    let mut notes: Vec<String> = Vec::new();

    if let Some(r) = registry {
        evidence.push(Evidence {
            surface: "registry",
            value: format!(
                "status {} (pid {})",
                r.status.as_deref().unwrap_or("(absent)"),
                r.pid.map_or_else(|| "?".to_string(), |p| p.to_string())
            ),
            age_secs: r
                .status_updated_at_ms
                .map(|ms| (jiff::Timestamp::now().as_millisecond() - ms).max(0) / 1000),
        });
    } else if is_subagent_target {
        notes.push(
            "the registry covers top-level interactive sessions only - a subagent has no \
             row; verdict from tail + children evidence"
                .to_string(),
        );
    } else {
        notes.push(
            "no registry row for this session (not currently registered, or an older \
             Claude Code) - verdict from tail + children evidence"
                .to_string(),
        );
    }
    if let Some(l) = liveness {
        let (v, note): (String, Option<String>) = match l {
            PidLiveness::Alive {
                reuse_guard: ReuseGuard::Checked,
            } => ("alive (start-time guard matched)".to_string(), None),
            PidLiveness::Alive {
                reuse_guard: ReuseGuard::Skipped,
            } => (
                "alive (pid only)".to_string(),
                Some(
                    "the pid-reuse guard was skipped (procStart absent or unparseable on \
                     one side)"
                        .to_string(),
                ),
            ),
            PidLiveness::Dead => ("dead".to_string(), None),
            PidLiveness::Reused => ("pid reused by another process".to_string(), None),
            PidLiveness::ForeignDomain(d) => (
                format!("row from another pid domain ({d})"),
                Some(
                    "the registry row was written in another pid domain (another machine \
                     or OS): its pid means nothing here - stale-dead is undecidable"
                        .to_string(),
                ),
            ),
            PidLiveness::Unavailable => (
                "probe unavailable on this host".to_string(),
                Some("pid liveness cannot be checked here - stale-dead is undecidable".to_string()),
            ),
        };
        evidence.push(Evidence {
            surface: "pid",
            value: v,
            age_secs: None,
        });
        if let Some(n) = note {
            notes.push(n);
        }
    }
    match &main_tail.unreturned_use {
        Some((tool, ts)) => evidence.push(Evidence {
            surface: "tail",
            value: format!("unreturned {tool} call"),
            age_secs: age_secs(ts.as_deref()),
        }),
        None => evidence.push(Evidence {
            surface: "tail",
            value: format!(
                "no pending call; last stop_reason {}",
                main_tail.last_stop_reason.as_deref().unwrap_or("(none)")
            ),
            age_secs: age_secs(main_tail.last_ts_utc.as_deref()),
        }),
    }
    if children.live_count > 0 || !children.children.is_empty() {
        evidence.push(Evidence {
            surface: "children",
            value: format!(
                "{} lane(s), {} live{}",
                children.children.len(),
                children.live_count,
                if children.journal_in_flight > 0 {
                    format!(
                        " ({} workflow agent(s) in flight)",
                        children.journal_in_flight
                    )
                } else {
                    String::new()
                }
            ),
            age_secs: None,
        });
    }
    for p in pending_elicitations {
        evidence.push(Evidence {
            surface: "sidecar",
            value: format!("pending elicitation: {p}"),
            age_secs: None,
        });
    }
    if !background.tasks.is_empty() {
        evidence.push(Evidence {
            surface: "background",
            value: background.summary_line(),
            age_secs: None,
        });
    }

    (evidence, notes)
}

/// The join: dead process > blocked-on-human > tool-in-flight > live children > open
/// background task under the lens > clean EoT > unknown, each step leaving its note.
fn rank_verdict(
    registry: Option<&RegistryRow>,
    liveness: Option<&PidLiveness>,
    main_tail: &TailShape,
    children: &ChildrenReport,
    pending_elicitations: &[String],
    background: &BackgroundReport,
    notes: &mut Vec<String>,
) -> Verdict {
    let dead = matches!(liveness, Some(PidLiveness::Dead | PidLiveness::Reused));
    // Two HITL legs beside the sidecar: the registry's `waiting` status (the binary sets
    // it whenever a dialog blocks the session: a question, a permission prompt, a plan
    // approval, a sandbox/worker request), and an unreturned AskUserQuestion/ExitPlanMode
    // at the tail (a MULTI-question ask is written at question time since CC 2.1.258;
    // a single-question ask stays buffered until answered - the sidecar's shape).
    let registry_waiting = registry
        .and_then(|r| r.status.as_deref())
        .is_some_and(|s| s == "waiting");
    let tail_dialog = main_tail
        .unreturned_use
        .as_ref()
        .is_some_and(|(tool, _)| matches!(tool.as_str(), "AskUserQuestion" | "ExitPlanMode"));
    // The registry's `shell` status is IDLE WITH A BACKGROUND SHELL RUNNING (the binary
    // computes `idle` and then relabels it `shell` while a local_bash task is open) -
    // never a running shape. `busy` is the only registry running signal.
    let registry_status = registry.and_then(|r| r.status.as_deref());
    let registry_shell = registry_status == Some("shell");
    let running_shape = main_tail.unreturned_use.is_some() || registry_status == Some("busy");
    let eot_shape = main_tail.unreturned_use.is_none()
        && (main_tail
            .last_stop_reason
            .as_deref()
            .is_some_and(|s| s == "end_turn")
            || registry_shell);
    if registry_shell && !dead {
        notes.push(if background.open_counted() > 0 {
            "registry status shell: idle at end of turn with a background shell still \
             running - the background section lists it"
                .to_string()
        } else {
            "registry status shell: the harness reports a background shell still running \
             that the background section does not count (launched in an unscanned lane, \
             or excluded by the lens)"
                .to_string()
        });
    }

    if dead {
        notes.push(if main_tail.unreturned_use.is_some() {
            "the tail shows a call still open: the process died MID-TOOL".to_string()
        } else {
            "the tail is settled: the process ended after its last turn".to_string()
        });
        Verdict::StaleDead
    } else if !pending_elicitations.is_empty() || registry_waiting || tail_dialog {
        if registry_waiting {
            notes.push(
                "registry status waiting: the session is blocked on a dialog (a question, a \
                 permission prompt, a plan approval or a sandbox/worker request); the row is \
                 transition-written, so a stale one from a dead session needs the pid probe \
                 to refute it"
                    .to_string(),
            );
        }
        if tail_dialog {
            notes.push(
                "the tail holds an unreturned AskUserQuestion/ExitPlanMode call: a \
                 multi-question ask is written at question time, a single-question ask \
                 stays buffered until answered (the sidecar covers that shape)"
                    .to_string(),
            );
        }
        Verdict::WaitingHitl
    } else if running_shape {
        Verdict::Running
    } else if children.live_count > 0 {
        Verdict::WaitingChildren
    } else if eot_shape && background.open_counted() > 0 {
        notes.push(
            "the turn ended, but background task(s) have not returned - by design (a dev \
             server, a watcher) or not, csift cannot tell: a UI stop, a Monitor timeout or \
             agent teardown leaves no transcript marker, and Claude Code reconciles only at \
             the next session start. `--background-since` / `--ignore-background` narrow \
             what counts; kill a dead one with the tool or the shell"
                .to_string(),
        );
        Verdict::IdleBackgroundOpen
    } else if eot_shape {
        Verdict::IdleEot
    } else if main_tail.records_seen == 0 {
        notes.push("no records readable at the tail".to_string());
        Verdict::Unknown
    } else {
        notes.push(format!(
            "tail shape unranked: no pending call, last stop_reason {} - not an end_turn, \
             not provably running",
            main_tail.last_stop_reason.as_deref().unwrap_or("(none)")
        ));
        Verdict::Unknown
    }
}

/// The main lane's instant state in words (the at-exit line of `wait`, the tail row
/// of `status`).
pub(crate) fn tail_state_words(tail: &TailShape) -> String {
    let ago = |secs: Option<i64>| {
        secs.map(|s| {
            format!(
                " {} ago",
                crate::text::fmt_secs(u64::try_from(s).unwrap_or(0))
            )
        })
        .unwrap_or_default()
    };
    if let Some((tool, ts)) = &tail.unreturned_use {
        let for_ = age_secs(ts.as_deref())
            .map(|s| {
                format!(
                    " for {}",
                    crate::text::fmt_secs(u64::try_from(s).unwrap_or(0))
                )
            })
            .unwrap_or_default();
        return format!("in a {tool} call{for_}");
    }
    let age = age_secs(tail.last_ts_utc.as_deref());
    let sr = tail.last_stop_reason.as_deref().unwrap_or("(none)");
    if sr != "end_turn" && age.is_some_and(|a| a <= CHILD_RECENT_SECS) && tail.records_seen > 0 {
        format!("generating (last record{}, stop_reason {sr})", ago(age))
    } else if tail.records_seen == 0 {
        "no records readable at the tail".to_string()
    } else {
        format!("idle (last stop_reason {sr}, last record{})", ago(age))
    }
}