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 own: Vec<PlanRef> = session_files
.par_iter()
.map(|p| resolve_session_plan(p))
.collect::<Result<Vec<_>>>()?
.into_iter()
.flatten()
.collect();
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(&own, &owners, &rows, skipped),
OutputFormat::Json => render_audit_json(&own, &rows, skipped)?,
}
Ok(())
}
fn render_audit_text(own: &[PlanRef], owners: &BTreeSet<String>, rows: &[EditRow], skipped: usize) {
println!("PLAN AUDIT");
if own.is_empty() {
println!("binds none (no Plan Mode in the resolved scope)");
}
for r in own {
let slug = r
.slug
.as_deref()
.map(|s| format!(", slug {s}"))
.unwrap_or_default();
println!(
"binds {} -> {} (L{}{slug})",
r.session_id, r.plan_file, r.line_no
);
}
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_audit_json(own: &[PlanRef], rows: &[EditRow], skipped: usize) -> Result<()> {
let header = crate::text::envelope_header("plan", json!({"mode": "audit"}));
println!("{}", serde_json::to_string(&header)?);
for r in own {
let obj = 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,
});
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 summary = crate::text::envelope_summary(json!({
"bindings": own.len(),
"plan_files_touched": rows.len(),
"warnings": rows.iter().filter(|r| !r.bound_by_owner).count(),
"skipped_lines": skipped,
}));
println!("{}", serde_json::to_string(&summary)?);
Ok(())
}