pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! The Claude lineage: Claude, Codex, and Auggie (R2, charter §3a/§6).
//!
//! Moved verbatim from the pre-R2 `agents.rs`. These three share the Claude
//! hook payload shape — Codex falls back to `normalize_claude_family` for the
//! recorded shape and adds `apply_patch`; Auggie carries its own field names but
//! the same lineage deny dialect at `PreToolUse` (they diverge at Stop; see the
//! encoders in `families::mod`). Their edit-reconstruction helpers travel with
//! them.

use pushkin_core::edits::Replacement;
use serde_json::Value;

use super::super::shared::{patch_action, relativize, session_from};
use super::super::{FileWrite, Intent, ParseOutcome, ToolAction};

/// Claude, Codex, and Auggie share the Claude hook payload shape
/// (addendum §3.2/§3.4: `tool_name` + `tool_input.file_path`/`content`).
pub(crate) fn normalize_claude_family(value: &Value) -> ParseOutcome {
    let session = session_from(value, "session_id");
    let Some(tool_input) = value.get("tool_input") else {
        if value.get("stop_hook_active").is_some() {
            return Ok(ToolAction {
                session,
                files: vec![],
                is_stop: true,
                intent: Intent::Write,
                command: None,
            });
        }
        return Err(None);
    };
    let tool_name = value.get("tool_name").and_then(Value::as_str);

    // Option C (hook-matcher-gap charter) — a shell call carries `command`,
    // never `file_path`, so it must be recognized BEFORE the path extraction
    // below, which treats a payload naming no target as unparseable. The
    // command travels intact: matching it against the gated globs needs the
    // manifest, which the normalizer does not have and should not grow.
    if tool_name == Some("Bash") {
        if let Some(command) = tool_input.get("command").and_then(Value::as_str) {
            return Ok(ToolAction {
                session,
                files: vec![],
                is_stop: false,
                intent: Intent::Shell,
                command: Some(command.to_owned()),
            });
        }
    }

    let path = tool_input
        .get("file_path")
        .and_then(Value::as_str)
        .map(relativize)
        .ok_or(None)?;

    // SPIKE — a read carries no `content`, so it must be recognized by name
    // BEFORE the write parse, which treats missing content as malformed.
    if matches!(tool_name, Some("Read" | "NotebookRead")) {
        let bounded = tool_input.get("offset").is_some() || tool_input.get("limit").is_some();
        return Ok(ToolAction {
            session,
            files: vec![FileWrite {
                path,
                content: String::new(),
                edits: Vec::new(),
            }],
            is_stop: false,
            intent: if bounded {
                Intent::ReadRange
            } else {
                Intent::ReadWhole
            },
            command: None,
        });
    }

    // F48 Phase A — the same ordering rule as the read arm above, for the same
    // reason. `Edit` and `MultiEdit` name a target and carry no `content`, so
    // the write parse below would call them malformed; the malformed fallback
    // then consults only `is_protected`, and every other rule falls through to
    // a silent allow. Recognized here as what they are: mutations whose content
    // is ABSENT, not payloads that failed to parse.
    let mutation_tool = match tool_name {
        Some("Edit") => Some("Edit"),
        Some("MultiEdit") => Some("MultiEdit"),
        _ => None,
    };
    if let Some(tool) = mutation_tool {
        // F48 Phase B — carry the edit operations through so the gate can
        // reconstruct the post-edit file. An empty list is not an error here:
        // it means no faithful reconstruction is possible, and the gate falls
        // back to Phase A's interim refusal.
        return Ok(ToolAction {
            session,
            files: vec![FileWrite {
                path,
                content: String::new(),
                edits: claude_replacements(tool_input),
            }],
            is_stop: false,
            intent: Intent::MutateNoContent(tool),
            command: None,
        });
    }

    let content = tool_input.get("content").and_then(Value::as_str);
    match (tool_name.is_some(), content) {
        (true, Some(content)) => Ok(ToolAction {
            session,
            files: vec![FileWrite {
                path,
                content: content.to_owned(),
                edits: Vec::new(),
            }],
            is_stop: false,
            intent: Intent::Write,
            command: None,
        }),
        _ => Err(Some(path)),
    }
}

/// F48 Phase B — the replacements a Claude `Edit` or `MultiEdit` describes.
///
/// `Edit` carries one `old_string`/`new_string` pair with an optional
/// `replace_all`; `MultiEdit` carries `edits[]` of the same shape, applied in
/// order. Anything that does not parse yields an empty list, which the gate
/// reads as "cannot reconstruct" and refuses on — never as "no changes".
pub fn claude_replacements(tool_input: &Value) -> Vec<Replacement> {
    if let Some(edits) = tool_input.get("edits").and_then(Value::as_array) {
        return edits.iter().filter_map(one_replacement).collect();
    }
    one_replacement(tool_input).into_iter().collect()
}

pub(crate) fn one_replacement(value: &Value) -> Option<Replacement> {
    Some(Replacement {
        old: value.get("old_string").and_then(Value::as_str)?.to_owned(),
        new: value.get("new_string").and_then(Value::as_str)?.to_owned(),
        replace_all: value
            .get("replace_all")
            .and_then(Value::as_bool)
            .unwrap_or(false),
        // Claude's Edit locates purely by string search — it sends no line
        // numbers, so a repeated target is genuinely ambiguous here.
        anchor: None,
    })
}

/// F48 Phase B, auggie arm — the replacements an `str-replace-editor` call
/// describes.
///
/// The fields are NUMBERED (`old_str_1`, `new_str_1`, `old_str_2`, …) rather
/// than arrayed, so collection walks 1, 2, 3 … explicitly. Iterating the JSON
/// object instead would take the map's key order, which is not the edit order
/// and is not guaranteed to be anything in particular.
///
/// Each fragment also carries `old_str_start_line_number_N` /
/// `old_str_end_line_number_N`, which become the edit's ANCHOR. That is the
/// difference from the Claude arm and it is a real one: a fragment occurring
/// many times in the file occurs once inside its anchored lines, so edits the
/// Claude arm must refuse as ambiguous resolve exactly here.
///
/// Returns an empty list — which the gate reads as "cannot reconstruct", never
/// as "no changes" — for any payload that is not the shape above.
pub fn auggie_replacements(tool_input: &Value) -> Vec<Replacement> {
    let mut replacements = Vec::new();
    let mut index = 1;
    while let Some(old) = string_field(tool_input, "old_str", index) {
        let Some(new) = string_field(tool_input, "new_str", index) else {
            return Vec::new();
        };
        replacements.push(Replacement {
            old,
            new,
            replace_all: false,
            anchor: line_anchor(tool_input, index),
        });
        index += 1;
    }
    // A GAP in the numbering (`old_str_1` and `old_str_3`, no `old_str_2`)
    // stops the walk above with a fragment still unaccounted for. Returning the
    // prefix would synthesize a file missing one of the edits and then judge it
    // confidently — a false verdict, not a partial one.
    if names_fragment_at_or_beyond(tool_input, index) {
        return Vec::new();
    }
    replacements
}

pub(crate) fn string_field(tool_input: &Value, prefix: &str, index: usize) -> Option<String> {
    tool_input
        .get(format!("{prefix}_{index}"))
        .and_then(Value::as_str)
        .map(str::to_owned)
}

/// Both bounds or neither: half an anchor is a payload we do not recognize, and
/// silently treating it as unanchored would hand the edit to a string search
/// the family never intended.
pub(crate) fn line_anchor(
    tool_input: &Value,
    index: usize,
) -> Option<pushkin_core::edits::LineSpan> {
    let bound = |name: &str| {
        tool_input
            .get(format!("old_str_{name}_line_number_{index}"))
            .and_then(Value::as_u64)
            .and_then(|value| usize::try_from(value).ok())
    };
    Some(pushkin_core::edits::LineSpan {
        start: bound("start")?,
        end: bound("end")?,
    })
}

/// Whether any `old_str_<n>` with `n >= from` is present. Deliberately parses
/// the suffix rather than matching a prefix: `old_str_start_line_number_1`
/// also begins with `old_str_` and is not a fragment.
pub(crate) fn names_fragment_at_or_beyond(tool_input: &Value, from: usize) -> bool {
    tool_input
        .as_object()
        .into_iter()
        .flatten()
        .filter_map(|(key, _)| key.strip_prefix("old_str_"))
        .filter_map(|suffix| suffix.parse::<usize>().ok())
        .any(|index| index >= from)
}

/// Codex `PreToolUse` (learn.chatgpt.com/docs/hooks): `apply_patch` carries
/// the patch text in `tool_input.command`; each `*** Add File:` /
/// `*** Update File:` section is one gated write. The recorded-payload
/// shape (`tool_input.file_path`/`content`) is accepted first for
/// conformance-suite compatibility.
pub(crate) fn normalize_codex(value: &Value) -> ParseOutcome {
    if value
        .get("tool_input")
        .and_then(|input| input.get("file_path"))
        .is_some()
    {
        return normalize_claude_family(value);
    }
    let session = session_from(value, "session_id");
    let Some(tool_input) = value.get("tool_input") else {
        if value.get("stop_hook_active").is_some() {
            return Ok(ToolAction {
                session,
                files: vec![],
                is_stop: true,
                intent: Intent::Write,
                command: None,
            });
        }
        return Err(None);
    };
    let command = tool_input
        .get("command")
        .and_then(Value::as_str)
        .ok_or(None)?;
    patch_action(session, command)
}

/// Auggie `PreToolUse` (live-captured 0.35.0, docs.augmentcode.com/cli/hooks):
/// `conversation_id` + `tool_name` + `tool_input.path`/`file_content`
/// (`save-file`) or `tool_input.path` + edit fields (`str-replace-editor`).
pub(crate) fn normalize_auggie(value: &Value) -> ParseOutcome {
    let session = session_from(value, "conversation_id");
    let Some(tool_input) = value.get("tool_input") else {
        // F70 — Auggie's Stop payload carries no `tool_input` at all, so this
        // early return used to make it malformed before the encoder was ever
        // reached. F68 had already encoded Auggie's Stop dialect correctly; it
        // was simply unreachable. This is the reachability half.
        //
        // The marker is `hook_event_name == "Stop"`, a COMMON base field on
        // every Auggie event. It is NOT `stop_hook_active` — that is Claude's
        // field and Auggie has no such key, which is the exact error F70 exists
        // to stop being inherited. `agent_stop_cause` ("end_turn" |
        // "interrupted" | "max_iterations" | "error") is deliberately not used:
        // it says WHY the agent stopped, not WHAT the event is, and keying on it
        // would make the branch fire on a shape that never claimed to be a Stop.
        //
        // Narrow on purpose: only a payload positively identifying itself as
        // Stop takes this branch. Anything else still returns `Err(None)` and
        // still routes to the existing malformed handling, which
        // `gate_unreadable_payload` depends on.
        if value.get("hook_event_name").and_then(Value::as_str) == Some("Stop") {
            return Ok(ToolAction {
                session,
                files: vec![],
                is_stop: true,
                intent: Intent::Write,
                command: None,
            });
        }
        return Err(None);
    };
    let path = tool_input
        .get("path")
        .or_else(|| tool_input.get("file_path"))
        .and_then(Value::as_str)
        .map(relativize)
        .ok_or(None)?;
    let tool_name = value.get("tool_name").and_then(Value::as_str);

    // SPIKE — Auggie reads through `view`, which carries a path and no
    // content. Its range fields are `view_range`/`search_query_regex`: both
    // narrow the read, so either counts as the bounded shape.
    if tool_name == Some("view") {
        let bounded = tool_input.get("view_range").is_some()
            || tool_input.get("search_query_regex").is_some();
        return Ok(ToolAction {
            session,
            files: vec![FileWrite {
                path,
                content: String::new(),
                edits: Vec::new(),
            }],
            is_stop: false,
            intent: if bounded {
                Intent::ReadRange
            } else {
                Intent::ReadWhole
            },
            command: None,
        });
    }

    // F51 — `str-replace-editor` sends REPLACEMENT FRAGMENTS
    // (`old_str_N`/`new_str_N`), never a file. Treating `new_str_1` as
    // `content` (as this chain used to) made the gate judge a fragment as
    // though it were the whole file, and it was wrong in BOTH directions: a
    // fragment with no boundary-relevant line passed vacuously, and one that
    // had a line was denied on content that never existed as a file. That is a
    // FALSE VERDICT, a worse failure than F48's fail-open, because the gate
    // sounds certain.
    //
    // Recognized here as what it is — a mutation whose content is absent —
    // BEFORE the write parse below, the same ordering rule the `view` arm above
    // uses.
    //
    // F48 Phase B (2026-08-17) now carries the fragments through, so the gate
    // reconstructs the post-edit file instead of refusing. The field family was
    // enumerated by the E1(b) capture the earlier note called for
    // (`docs/e1b-capture/fixtures/auggie-20260817T230314Z-0011.json`); an empty
    // list still means "cannot reconstruct" and still routes to the refusal.
    if tool_name == Some("str-replace-editor") {
        return Ok(ToolAction {
            session,
            files: vec![FileWrite {
                path,
                content: String::new(),
                edits: auggie_replacements(tool_input),
            }],
            is_stop: false,
            intent: Intent::MutateNoContent("str-replace-editor"),
            command: None,
        });
    }

    let well_formed = tool_name.is_some();
    // `new_str_1` is deliberately NOT in this chain — see above.
    let content = tool_input
        .get("file_content")
        .or_else(|| tool_input.get("content"))
        .and_then(Value::as_str);
    match (well_formed, content) {
        (true, Some(content)) => Ok(ToolAction {
            session,
            files: vec![FileWrite {
                path,
                content: content.to_owned(),
                edits: Vec::new(),
            }],
            is_stop: false,
            intent: Intent::Write,
            command: None,
        }),
        _ => Err(Some(path)),
    }
}