use super::*;
use crate::model::{Block, Chain, Kind, Record, Survival};
use serde_json::json;
#[derive(Debug, Clone, Copy)]
enum Row<'a> {
Full(usize, &'a Record),
Spine(&'a crate::parse::SpineRow),
}
impl<'a> Row<'a> {
fn line(self) -> usize {
match self {
Row::Full(l, _) => l,
Row::Spine(s) => s.line(),
}
}
fn node(self) -> crate::model::ChainNode<'a> {
match self {
Row::Full(_, r) => crate::model::ChainNode::Full(r),
Row::Spine(s) => crate::model::ChainNode::Spine(s),
}
}
fn full(self) -> Option<&'a Record> {
match self {
Row::Full(_, r) => Some(r),
Row::Spine(_) => None,
}
}
}
#[derive(Debug)]
struct Child {
line: usize,
uuid: Option<String>,
ts_utc: Option<String>,
record_type: String,
survival: &'static str,
verdict: &'static str,
}
#[derive(Debug)]
struct BranchPoint {
uuid: String,
parent: Option<(usize, String)>,
children: Vec<Child>,
live_child_lines: Vec<usize>,
widest_gap_secs: Option<i64>,
}
fn is_conversation_record(rec: &Record) -> bool {
match rec.r#type.as_deref() {
Some("assistant") => true,
Some("user") => {
!rec.is_meta.unwrap_or(false)
&& !rec.is_compact_summary.unwrap_or(false)
&& !rec
.blocks()
.is_some_and(|bs| bs.iter().any(|b| matches!(b, Block::ToolResult { .. })))
}
_ => false,
}
}
fn parse_ts(raw: &str) -> Option<jiff::Timestamp> {
raw.parse().ok()
}
fn gap_label(secs: i64) -> String {
let h = secs / 3600;
let m = (secs % 3600) / 60;
let s = secs % 60;
if h > 0 {
format!("{h}h{m:02}m{s:02}s")
} else if m > 0 {
format!("{m}m{s:02}s")
} else {
format!("{s}s")
}
}
fn survival_verdict(chain: &Chain, i: usize) -> (&'static str, &'static str) {
let survival = chain.survival(i);
let verdict = match survival {
Survival::Live => "live",
Survival::PreCut => "pre-cut",
Survival::Abandoned { .. } => match chain.kind(i) {
Some(Kind::Rewound { .. }) => "rewound",
Some(Kind::Draft { .. }) => "draft",
None => "abandoned",
},
};
(survival.as_str(), verdict)
}
fn widest_gap(children: &[Child]) -> Option<i64> {
let mut widest: Option<i64> = None;
for pair in children.windows(2) {
let (Some(a), Some(b)) = (pair[0].ts_utc.as_deref(), pair[1].ts_utc.as_deref()) else {
return None;
};
let (Some(ta), Some(tb)) = (parse_ts(a), parse_ts(b)) else {
return None;
};
let g = (tb.as_second() - ta.as_second()).unsigned_abs() as i64;
widest = Some(widest.map_or(g, |w: i64| w.max(g)));
}
widest
}
pub(crate) fn run_branch_points(file: &std::path::Path, format: OutputFormat) -> Result<()> {
let session_id = crate::subagent::session_id_from_path(file);
let is_subagent = crate::subagent::is_subagent_path(file);
let parent_session_id =
crate::subagent::parent_session_id_from_path(file).unwrap_or_else(|| session_id.clone());
let mut full: Vec<(usize, Record)> = Vec::new();
let mut spine: Vec<crate::parse::SpineRow> = Vec::new();
let mut skipped = 0usize;
if let Some(mmap) = mmap_bytes(file)? {
let bytes: &[u8] = &mmap;
let (f, sp, s) = crate::parse::scan_lines_parallel_split(bytes, |line, line_no| {
if !crate::parse::line_has_role_marker(line) {
if let Some(row) = crate::parse::spine_record(line_no, line) {
return crate::parse::SplitVerdict::Second(row);
}
return crate::parse::non_candidate_split(line);
}
match crate::parse::parse_line(line) {
Ok(Some(rec)) => crate::parse::SplitVerdict::First((line_no, rec)),
Ok(None) => crate::parse::SplitVerdict::Ignore,
Err(_) => crate::parse::SplitVerdict::Skip,
}
});
full = f;
spine = sp;
skipped = s;
}
let mut rows: Vec<Row<'_>> = Vec::with_capacity(full.len() + spine.len());
{
let (mut a, mut b) = (0usize, 0usize);
while a < full.len() || b < spine.len() {
let take_full = match (full.get(a), spine.get(b)) {
(Some((la, _)), Some(sb)) => *la <= sb.line(),
(Some(_), None) => true,
_ => false,
};
if take_full {
rows.push(Row::Full(full[a].0, &full[a].1));
a += 1;
} else {
rows.push(Row::Spine(&spine[b]));
b += 1;
}
}
}
let chain = Chain::build_by(&rows, |r| r.node(), None);
let line_of: std::collections::HashMap<&str, (usize, &str)> = rows
.iter()
.filter_map(|r| {
let n = r.node();
n.uuid()
.map(|u| (u, (r.line(), n.kind().unwrap_or("(untyped)"))))
})
.collect();
let mut children_of: std::collections::HashMap<String, Vec<Child>> =
std::collections::HashMap::new();
let mut conversation_records = 0usize;
for (i, row) in rows.iter().enumerate() {
let Some(rec) = row.full().filter(|r| is_conversation_record(r)) else {
continue;
};
conversation_records += 1;
let Some(parent) = rec.parent_uuid.as_deref() else {
continue;
};
let (survival, verdict) = survival_verdict(&chain, i);
children_of
.entry(parent.to_string())
.or_default()
.push(Child {
line: row.line(),
uuid: rec.uuid.clone(),
ts_utc: rec.timestamp.clone(),
record_type: rec
.r#type
.clone()
.unwrap_or_else(|| "(untyped)".to_string()),
survival,
verdict,
});
}
let mut points: Vec<BranchPoint> = children_of
.into_iter()
.filter(|(_, ch)| ch.len() >= 2)
.map(|(uuid, mut children)| {
children.sort_by_key(|c| c.line);
let widest = widest_gap(&children);
let live_child_lines = children
.iter()
.filter(|c| c.survival == "live")
.map(|c| c.line)
.collect();
BranchPoint {
parent: line_of
.get(uuid.as_str())
.map(|(l, t)| (*l, (*t).to_string())),
uuid,
children,
live_child_lines,
widest_gap_secs: widest,
}
})
.collect();
points.sort_by(|a, b| {
let key = |p: &BranchPoint| {
(
p.widest_gap_secs.is_none(),
std::cmp::Reverse(p.widest_gap_secs.unwrap_or(0)),
p.children.first().map_or(0, |c| c.line),
)
};
key(a).cmp(&key(b))
});
match format {
OutputFormat::Text => {
render_branch_text(&session_id, conversation_records, &points, skipped);
}
OutputFormat::Json => render_branch_json(
&session_id,
is_subagent,
&parent_session_id,
conversation_records,
&points,
skipped,
)?,
}
Ok(())
}
fn live_child_label(lines: &[usize]) -> String {
match lines {
[] => "live child: none (this fork is off the surviving conversation)".to_string(),
[one] => format!("live child: L{one}"),
many => format!(
"live children: {}",
many.iter()
.map(|l| format!("L{l}"))
.collect::<Vec<_>>()
.join(" ")
),
}
}
fn render_branch_text(
session_id: &str,
conversation_records: usize,
points: &[BranchPoint],
skipped: usize,
) {
println!("BRANCH POINTS {session_id}");
println!(
" {conversation_records} conversation record(s) · {} branch point(s) (a record \
with 2+ conversation children; tool-result carriers, isMeta records, and \
compaction summaries never count)",
points.len()
);
if points.is_empty() {
println!(" no forks: every conversation record has at most one conversation child");
}
for (i, p) in points.iter().enumerate() {
let loc = p.parent.as_ref().map_or_else(
|| "parent uuid not in this file".to_string(),
|(l, t)| format!("L{l} {t}"),
);
let gap = p
.widest_gap_secs
.map_or_else(|| "unknown (missing timestamps)".to_string(), gap_label);
println!();
println!(
" #{} uuid {} {loc} children {} · widest gap {gap} · {}",
i + 1,
p.uuid,
p.children.len(),
live_child_label(&p.live_child_lines)
);
for c in &p.children {
println!(
" L{} {} {} {}",
c.line,
crate::timez::format_timestamp(c.ts_utc.as_deref()),
c.record_type,
c.verdict
);
}
if let Some(last) = p.children.last() {
println!(" ↳ csift show @{session_id} --line {}", last.line);
}
}
if points.iter().any(|p| p.widest_gap_secs.is_some()) {
println!();
println!(
" ranked by widest inter-child gap: a rewind or retry fork usually shows a \
wide gap, a parallel lane a near-zero one. csift reports fork FACTS - the \
live child is one of them, decided by Claude Code's own conversation chain, \
and beyond what that chain resolves csift does not guess."
);
}
if skipped > 0 {
println!(" ({})", crate::text::malformed_note(skipped));
}
}
fn render_branch_json(
session_id: &str,
is_subagent: bool,
parent_session_id: &str,
conversation_records: usize,
points: &[BranchPoint],
skipped: usize,
) -> Result<()> {
let header = crate::text::envelope_header(
"show",
json!({
"mode": "branch-points",
"session_id": session_id,
"is_subagent": is_subagent,
"parent_session_id": parent_session_id,
}),
);
println!("{}", serde_json::to_string(&header)?);
for p in points {
let children: Vec<serde_json::Value> = p
.children
.iter()
.map(|c| {
json!({
"line": c.line,
"uuid": c.uuid,
"record_type": c.record_type,
"survival": c.survival,
"verdict": c.verdict,
"ts_utc": c.ts_utc,
"ts_local": c.ts_utc.as_deref().and_then(crate::timez::local_iso),
})
})
.collect();
let parent_line = p.parent.as_ref().map(|(l, _)| *l);
let obj = json!({
"kind": "branch-point",
"uuid": p.uuid,
"line": parent_line,
"parent_line": parent_line,
"parent_type": p.parent.as_ref().map(|(_, t)| t.clone()),
"live_child_line": match p.live_child_lines.as_slice() {
[one] => Some(*one),
_ => None,
},
"children": children,
"widest_gap_seconds": p.widest_gap_secs,
});
println!("{}", serde_json::to_string(&obj)?);
}
let summary = crate::text::envelope_summary(json!({
"branch_points": points.len(),
"conversation_records": conversation_records,
"skipped_lines": skipped,
}));
println!("{}", serde_json::to_string(&summary)?);
Ok(())
}