pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Agent families — the verdict surface (R2, charter 2026-08-20-r2-agent-families).
//!
//! One home for the two per-family verdict concerns that used to be scattered:
//! payload normalization (the `normalize_*` fns, re-exported from the family
//! submodules for the single `super::normalize` dispatch) and the three native
//! verdict encoders (moved down here from `verbs/hook.rs`, which is the verbs
//! layer — these depend only on `Agent` + `serde_json`, so they belong in the
//! leaf `agents` layer).
//!
//! The families are the GENUINE boundaries, not one-per-agent: Claude, Codex,
//! and Auggie form a lineage (`claude_lineage`) that shares the payload shape
//! and shares verdict encodings in DIFFERENT groupings per surface — see §3a of
//! the charter and the grouped `match` arms below, which are preserved verbatim
//! so a divergence between lineage members stays a one-line, reviewable edit.

use super::Agent;

pub mod claude_lineage;
pub mod hermes;
pub mod opencode;

pub(crate) use claude_lineage::{
    claude_replacements, normalize_auggie, normalize_claude_family, normalize_codex,
};
pub(crate) use hermes::normalize_hermes;
pub(crate) use opencode::normalize_opencode;

/// Agent-native allow encodings (addendum §4 verdict matrix).
pub fn encode_allow(agent: Agent) -> String {
    match agent {
        // Claude family: silence = allow.
        Agent::Claude | Agent::Codex | Agent::Auggie => String::new(),
        // Hermes: empty JSON object = no objection.
        Agent::Hermes => "{}".to_owned(),
        // opencode plugin expects an explicit verdict to act on.
        Agent::Opencode => serde_json::json!({ "decision": "allow" }).to_string(),
    }
}

/// Agent-native deny encodings for a **Stop** event (F68).
///
/// Separate from `encode_deny` because Stop is a different hook event with a
/// different documented schema on every host — not a variation on `PreToolUse`.
/// Rendering a Stop verdict in `PreToolUse`'s dialect is why Stop findings were
/// silently discarded: the host does not recognise the shape, so the agent
/// stopped with violations on the floor.
///
/// The three dialects, as documented (checked 2026-08-18):
///
/// - **Claude** — top-level `{"decision":"block","reason":…}`. The docs state
///   Stop/SubagentStop do NOT use `hookSpecificOutput` or `permissionDecision`.
/// - **Codex** — top-level `{"decision":"block","reason":…}`. `hookSpecificOutput`
///   is not a supported structure at Stop, and an unsupported field invalidates
///   the payload outright (openai/codex#18887), so the legacy double-encoding
///   `encode_deny` uses for `PreToolUse` would be worse than redundant here.
/// - **Auggie** — `{"`hookSpecificOutput`":{"`hookEventName`":"Stop",…}}`, with
///   `decision`/`reason` rather than `permissionDecision`. **Not** Claude's
///   shape, despite the two sharing a `PreToolUse` deny dialect.
///
/// Hermes and opencode return `None`: neither normalizer produces `is_stop`, so
/// there is no Stop traffic to encode and inventing a dialect for one would be
/// N13 groundwork. `None` means "keep the existing deny encoding", which is the
/// honest no-op rather than a silent drop.
///
/// Every host documents JSON-on-stdout with **exit 0** as Stop's structured
/// control channel; exit 2 is `PreToolUse`'s and carries no reason.
pub fn encode_stop_deny(agent: Agent, prose: &str) -> Option<String> {
    match agent {
        Agent::Claude | Agent::Codex => Some(
            serde_json::json!({
                "decision": "block",
                "reason": prose,
            })
            .to_string(),
        ),
        // Reachable since F70 (`63cc713`): `normalize_auggie` now yields
        // `is_stop` for a payload whose `hook_event_name` is `"Stop"` — Auggie's
        // own marker. It is NOT `stop_hook_active`; that is Claude's field and
        // Auggie has no such key, which is the error F70 exists to stop being
        // inherited. Exercised by `tests/stop_encoding.rs`.
        Agent::Auggie => Some(
            serde_json::json!({
                "hookSpecificOutput": {
                    "hookEventName": "Stop",
                    "decision": "block",
                    "reason": prose,
                }
            })
            .to_string(),
        ),
        Agent::Hermes | Agent::Opencode => None,
    }
}

/// Agent-native deny encodings (addendum §4 verdict matrix).
pub fn encode_deny(agent: Agent, prose: &str) -> String {
    match agent {
        // Claude and Auggie speak the identical deny dialect; Auggie simply
        // has no exit-code fallback channel (addendum §3.4). NOTE: this holds
        // for PreToolUse only — at Stop the two diverge (see `encode_stop_deny`).
        Agent::Claude | Agent::Auggie => serde_json::json!({
            "hookSpecificOutput": {
                "hookEventName": "PreToolUse",
                "permissionDecision": "deny",
                "permissionDecisionReason": prose,
            }
        })
        .to_string(),
        // Codex: modern shape plus the legacy decision field (addendum §3.2).
        Agent::Codex => serde_json::json!({
            "decision": "block",
            "reason": prose,
            "hookSpecificOutput": {
                "hookEventName": "PreToolUse",
                "permissionDecision": "deny",
                "permissionDecisionReason": prose,
            }
        })
        .to_string(),
        Agent::Hermes => serde_json::json!({
            "action": "block",
            "message": prose,
        })
        .to_string(),
        Agent::Opencode => serde_json::json!({
            "decision": "deny",
            "reason": prose,
        })
        .to_string(),
    }
}