use super::*;
use serde_json::json;
#[derive(Debug)]
struct EditRow {
owner: String,
path: String,
mutations: usize,
bound_by_owner: bool,
binder: Option<PlanRef>,
}
fn line_is_structured_mutation_candidate(line: &[u8]) -> bool {
static FILE_PATH: std::sync::LazyLock<memchr::memmem::Finder<'static>> =
std::sync::LazyLock::new(|| memchr::memmem::Finder::new(b"\"file_path\""));
static NOTEBOOK_PATH: std::sync::LazyLock<memchr::memmem::Finder<'static>> =
std::sync::LazyLock::new(|| memchr::memmem::Finder::new(b"\"notebook_path\""));
FILE_PATH.find(line).is_some() || NOTEBOOK_PATH.find(line).is_some()
}
pub(crate) fn run_plan_audit(args: &PlanArgs, session_files: &[PathBuf]) -> Result<()> {
let mut edits: std::collections::BTreeMap<(String, String), usize> =
std::collections::BTreeMap::new();
let mut skipped = 0usize;
for p in session_files {
let owner = crate::subagent::parent_session_id_from_path(p)
.unwrap_or_else(|| crate::subagent::session_id_from_path(p));
let Some(mmap) = mmap_bytes(p)? else {
continue;
};
let (records, s) =
crate::parse::parse_candidates_parallel(&mmap, line_is_structured_mutation_candidate);
skipped += s;
for (_line, rec) in &records {
for m in rec.structured_tool_mutations() {
*edits.entry((owner.clone(), m.path)).or_insert(0) += 1;
}
}
}
let audited: Vec<Audited> = session_files
.par_iter()
.map(|p| -> Result<Audited> {
Ok(Audited {
session_id: crate::subagent::session_id_from_path(p),
binding: resolve_session_plan(p)?,
facts: binding_facts(p)?,
})
})
.collect::<Result<Vec<_>>>()?;
let owners: BTreeSet<String> = session_files
.iter()
.map(|p| {
crate::subagent::parent_session_id_from_path(p)
.unwrap_or_else(|| crate::subagent::session_id_from_path(p))
})
.collect();
let mut binder_map: std::collections::BTreeMap<String, Vec<PlanRef>> =
std::collections::BTreeMap::new();
if !edits.is_empty() {
let all = path::resolve_session_files(
&[],
crate::path::SubagentScope::WithSubagents,
path::Caller::Other,
)?;
let all_refs: Vec<PlanRef> = all
.par_iter()
.map(|p| resolve_session_plan(p))
.collect::<Result<Vec<_>>>()?
.into_iter()
.flatten()
.collect();
for r in all_refs {
binder_map.entry(r.plan_file.clone()).or_default().push(r);
}
for binders in binder_map.values_mut() {
binders.sort_by(|a, b| {
a.is_subagent
.cmp(&b.is_subagent)
.then_with(|| a.parent_session_id.cmp(&b.parent_session_id))
});
}
}
let rows: Vec<EditRow> = edits
.into_iter()
.filter_map(|((owner, path), mutations)| {
let binders = binder_map.get(&path)?;
let bound_by_owner = binders.iter().any(|b| b.parent_session_id == owner);
let binder = binders
.iter()
.find(|b| b.parent_session_id != owner)
.or_else(|| binders.first())
.cloned();
Some(EditRow {
owner,
path,
mutations,
bound_by_owner,
binder,
})
})
.collect();
match args.format {
OutputFormat::Text => render_audit_text(&audited, &owners, &rows, skipped),
OutputFormat::Json => render_audit_json(&audited, &rows, skipped)?,
}
Ok(())
}
#[derive(Debug)]
pub(crate) struct Audited {
pub(crate) session_id: String,
pub(crate) binding: Option<PlanRef>,
pub(crate) facts: BindingFacts,
}
fn render_audit_text(
audited: &[Audited],
owners: &BTreeSet<String>,
rows: &[EditRow],
skipped: usize,
) {
println!("PLAN AUDIT");
if audited.iter().all(|a| a.binding.is_none()) {
println!("binds none (no Plan Mode in the resolved scope)");
}
for a in audited {
if let Some(r) = &a.binding {
render_binding_text(r, &a.facts);
}
render_unbound_text(a);
}
if rows.is_empty() {
println!(
"edits none: no structured mutation in scope touches any session's bound \
plan file (bash-side edits are outside this audit)"
);
}
let mut warnings = 0usize;
for r in rows {
let owner = if owners.len() > 1 {
format!(" by {}", r.owner)
} else {
String::new()
};
let verdict = if r.bound_by_owner {
"bound by this session"
} else {
warnings += 1;
"NOT bound by the mutating session"
};
println!(
"edits {} {} mutation(s){owner} [{verdict}]",
r.path, r.mutations
);
}
for r in rows.iter().filter(|r| !r.bound_by_owner) {
if let Some(b) = &r.binder {
println!(
"warning: {} mutation(s) to {} by session {}, which does NOT bind it \
(bound by {}, L{}). Only the BOUND plan is re-injected in full after a \
compaction.",
r.mutations, r.path, r.owner, b.parent_session_id, b.line_no
);
}
}
if warnings == 0 && !rows.is_empty() {
println!("ok: every audited plan-file edit targets the mutating session's own plan");
}
if skipped > 0 {
println!("({})", crate::text::malformed_note(skipped));
}
}
fn render_binding_text(r: &PlanRef, facts: &BindingFacts) {
let slug = r
.slug
.as_deref()
.map(|s| format!(", slug {s}"))
.unwrap_or_default();
println!(
"binds {} -> {} [{}] (L{}{slug})",
r.session_id,
r.plan_file,
if r.plan_exists { "exists" } else { "missing" },
r.line_no
);
match facts.changes.as_slice() {
[] => println!(
"slug none carried by this transcript's records (the binding is the \
attachment's, not a slug's)"
),
changes => {
for c in changes {
println!("slug L{} {}", c.line, change_arrow(c));
}
}
}
let v = slug_vs_plan_file(facts.first_slug_utc.as_deref(), &r.plan_file);
let tail = match v.reason() {
Some(why) => format!("unknown - {why}"),
None => format!(
"the first slug-carrying record is {} the plan file's birth instant",
v.token()
),
};
println!("birth {tail}");
}
fn render_unbound_text(a: &Audited) {
if a.facts.plan_ref_lines.is_empty() || !a.facts.no_slug() {
return;
}
println!(
"warning: {} holds a plan_file_reference attachment (L{}) while NO record carries a \
slug - plan text without a binding. Only the BOUND plan is re-injected in full after \
a compaction, so this text is not coming back.",
a.session_id,
a.facts
.plan_ref_lines
.iter()
.map(usize::to_string)
.collect::<Vec<_>>()
.join(", L")
);
}
fn change_arrow(c: &SlugChange) -> String {
let to = c.to.as_deref().unwrap_or("none");
match c.from.as_deref() {
None => format!("none -> {to} (the slug is minted here)"),
Some(from) => format!("{from} -> {to} (the binding MOVED here)"),
}
}
fn render_audit_json(audited: &[Audited], rows: &[EditRow], skipped: usize) -> Result<()> {
let header = crate::text::envelope_header("plan", json!({"mode": "audit"}));
println!("{}", serde_json::to_string(&header)?);
for a in audited {
if let Some(r) = &a.binding {
println!("{}", serde_json::to_string(&binding_json(r, &a.facts))?);
}
if !a.facts.plan_ref_lines.is_empty() && a.facts.no_slug() {
let obj = json!({
"kind": "plan-unbound-text",
"session_id": a.session_id,
"plan_file_reference_lines": a.facts.plan_ref_lines,
});
println!("{}", serde_json::to_string(&obj)?);
}
}
for r in rows {
let obj = json!({
"kind": "plan-edit",
"owner_session_id": r.owner,
"path": r.path,
"mutations": r.mutations,
"bound_by_owner": r.bound_by_owner,
"binder_session_id": r.binder.as_ref().map(|b| b.parent_session_id.clone()),
"binder_line": r.binder.as_ref().map(|b| b.line_no),
});
println!("{}", serde_json::to_string(&obj)?);
}
let unbound = audited
.iter()
.filter(|a| !a.facts.plan_ref_lines.is_empty() && a.facts.no_slug())
.count();
let summary = crate::text::envelope_summary(json!({
"bindings": audited.iter().filter(|a| a.binding.is_some()).count(),
"plan_files_touched": rows.len(),
"warnings": rows.iter().filter(|r| !r.bound_by_owner).count() + unbound,
"unbound_plan_text": unbound,
"skipped_lines": skipped,
}));
println!("{}", serde_json::to_string(&summary)?);
Ok(())
}
fn binding_json(r: &PlanRef, facts: &BindingFacts) -> serde_json::Value {
let v = slug_vs_plan_file(facts.first_slug_utc.as_deref(), &r.plan_file);
json!({
"kind": "binding",
"session_id": r.session_id,
"is_subagent": r.is_subagent,
"parent_session_id": r.parent_session_id,
"plan_file": r.plan_file,
"line": r.line_no,
"slug": r.slug,
"plan_exists": r.plan_exists,
"slug_changes": facts.changes.iter().map(|c| json!({
"line": c.line,
"from": c.from,
"to": c.to,
})).collect::<Vec<_>>(),
"first_slug_line": facts.first_slug_line,
"first_slug_utc": facts.first_slug_utc,
"first_slug_local": facts.first_slug_utc.as_deref().and_then(crate::timez::local_iso),
"slug_vs_plan_file": v.token(),
"slug_vs_plan_file_reason": v.reason(),
})
}