pushkin 0.1.0

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! `pushkin` CLI: `init` / `check` / `compile` / `doctor` / `hook` /
//! `instructions` (spec §12, Phase 2 subset). Thin shell over pushkin-core
//! and pushkin-compiler; every gate decision is appended to the `SQLite`
//! event log (charter N7).

mod agents;
mod verbs;

use anyhow::Result;
use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(name = "pushkin", version, about = "Pushkin")]
struct Cli {
    #[command(subcommand)]
    verb: Verb,
}

#[derive(Subcommand)]
enum Verb {
    /// Install hooks + record versioned consent in the current repo.
    Init {
        /// Install the named agent's adapter pack (claude, codex, auggie,
        /// hermes, opencode, lefthook).
        #[arg(long)]
        agent: Option<String>,
        /// Remove the named agent's pushkin entries (clean uninstall).
        #[arg(long)]
        remove_agent: Option<String>,
    },
    /// Read a hook payload from stdin and gate it (exit 2 = block).
    Check {
        /// Gate the git index instead of stdin: every staged file with a
        /// manifest mapping, checked on its STAGED content (spec §12).
        /// This is the pre-commit floor's scope.
        #[arg(long)]
        staged: bool,
        /// Print the uniform §8.3 result envelope as JSON on stdout
        /// instead of the prose retry prompt. Exit codes unchanged.
        #[arg(long)]
        json: bool,
    },
    /// Gate an agent-native hook payload; verdict in the agent's dialect.
    Hook {
        /// Agent whose payload shape and verdict dialect to use.
        agent: String,
    },
    /// Compile every manifest contract to its strict target bindings.
    Compile,
    /// Local gate telemetry: blocks by rule, compression savings, nudges.
    Stats,
    /// Warm-path daemon lifecycle (spec §4.3).
    Daemon {
        #[command(subcommand)]
        action: DaemonAction,
    },
    /// Verify hook install and binding epochs (exit 1 = unhealthy).
    Doctor {
        /// Repair what doctor found: reinstall missing/stale pushkin
        /// entries. Refuses on unreadable state.
        #[arg(long)]
        repair: bool,
    },
    /// Read-only report: denials by rule, mean-time-to-compliance,
    /// active waivers (integration doc §8).
    Report,
    /// Compact one-line segment for agent statuslines. Empty when no
    /// event log exists; escalation state wins.
    Statusline,
    /// DB drift gates: declarative schema diff and RLS tests (spec §5.3).
    Db {
        #[command(subcommand)]
        action: DbAction,
    },
    /// Worktree attach/detach policy (spec §9).
    Policy {
        #[command(subcommand)]
        action: PolicyAction,
    },
    /// Peer-board coordination for multi-agent runs (spec §9). Every verb
    /// is scoped by --run and --agent; the CLI is the subagent path.
    Board {
        #[command(subcommand)]
        verb: BoardCommand,
    },
    /// Map the current git diff to touched contracts, blast radius, and
    /// targeted checks (spec §12).
    Affected {
        /// Base ref for a committed range (<base>...HEAD). Default: staged.
        #[arg(long)]
        base: Option<String>,
    },
    /// Grant a scoped, expiring waiver for a rule (human plane, spec §7).
    Waive {
        /// Rule id to waive (as printed in the deny envelope).
        rule: String,
        /// Path glob the waiver covers (e.g. "app/api/**").
        #[arg(long)]
        path: String,
        /// Time to live, <number><s|m|h|d> (e.g. 2h). Temporary by default
        /// is the design; there is no --forever.
        #[arg(long)]
        ttl: String,
        /// Why this waiver exists — recorded verbatim in the audit trail.
        #[arg(long)]
        reason: String,
        /// Id of an earlier waiver this one replaces (decision log).
        #[arg(long)]
        supersedes: Option<String>,
        /// Id of an earlier waiver this one conflicts with (decision log).
        #[arg(long)]
        contradicts: Option<String>,
    },
    /// Print gate instructions generated from the manifest.
    Instructions {
        /// The compact ~150-200 word variant for subagent prompts.
        #[arg(long)]
        for_subagent: bool,
        /// The ≤2 KiB session-injection digest (override-aware: the
        /// `PUSHKIN_INSTRUCTIONS` env-named file, else
        /// `.pushkin/instructions.md`).
        #[arg(long)]
        digest: bool,
    },
}

#[derive(Subcommand)]
enum DbAction {
    /// Atlas declarative diff; db.direction picks the truth side.
    /// Exit 2 = drift detected (blocks).
    Drift,
    /// pgTAP RLS suite via `supabase test db`. Exit 2 = regression.
    Rls,
}

#[derive(Subcommand)]
enum PolicyAction {
    /// Dry-run the worktree policy: prints attached/detached and the
    /// deciding layer. Changes nothing.
    Test,
}

#[derive(Subcommand)]
enum BoardCommand {
    /// Register this agent in the run.
    Register(BoardScope),
    /// Record a free-text status line peers will see.
    Status {
        #[arg(long)]
        message: String,
        #[command(flatten)]
        scope: BoardScope,
    },
    /// List the other agents in this run (with last status).
    Peers(BoardScope),
    /// Queue a message to every agent in the run.
    Broadcast {
        #[arg(long)]
        message: String,
        #[command(flatten)]
        scope: BoardScope,
    },
    /// Queue a directed message to one agent.
    Send {
        #[arg(long)]
        to: String,
        #[arg(long)]
        message: String,
        #[command(flatten)]
        scope: BoardScope,
    },
    /// Read unread messages (auto-cursor: no redelivery).
    Read(BoardScope),
    /// Claim a path glob; other agents' writes to it are denied.
    Claim {
        #[arg(long)]
        path: String,
        #[command(flatten)]
        scope: BoardScope,
    },
    /// Release a claim you hold.
    Release {
        #[arg(long)]
        path: String,
        #[command(flatten)]
        scope: BoardScope,
    },
    /// List active claims in this run.
    Claims(BoardScope),
}

#[derive(clap::Args)]
struct BoardScope {
    /// Run ID — concurrent runs never intersect (spec §9).
    #[arg(long)]
    run: String,
    /// This agent's ID within the run.
    #[arg(long)]
    agent: String,
}

#[derive(Subcommand)]
enum DaemonAction {
    /// Run the daemon in the foreground (shims spawn this detached).
    Serve {
        /// Answer checks but refuse wire mutations (spec §8.4).
        #[arg(long)]
        read_only: bool,
        /// Explicit socket path (private-socket daemons).
        #[arg(long)]
        socket: Option<std::path::PathBuf>,
    },
    /// Start the daemon detached (canonical binary only, unless --read-only).
    Start {
        /// Start a read-only daemon on a private socket instead — the
        /// offer made to non-canonical binaries.
        #[arg(long)]
        read_only: bool,
    },
    /// Stop the daemon via a clean protocol shutdown (canonical only).
    Stop,
    /// Stop-if-running then start (canonical only).
    Restart,
    /// Report daemon health: pid, version, socket (exit 1 = not running).
    Status,
}

fn main() -> Result<()> {
    let cli = Cli::parse();
    let code = match cli.verb {
        Verb::Init {
            agent,
            remove_agent,
        } => verbs::init::run_with_args(agent.as_deref(), remove_agent.as_deref())?,
        Verb::Check { staged, json } => verbs::check::run(staged, json)?,
        Verb::Affected { base } => verbs::affected::run(base.as_deref())?,
        Verb::Db { action } => match action {
            DbAction::Drift => verbs::db::run_drift()?,
            DbAction::Rls => verbs::db::run_rls()?,
        },
        Verb::Policy { action } => match action {
            PolicyAction::Test => verbs::policy::run_test(),
        },
        Verb::Board { verb } => {
            use verbs::board::{BoardArgs, BoardVerb};
            let (verb, scope) = match verb {
                BoardCommand::Register(scope) => (BoardVerb::Register, scope),
                BoardCommand::Status { message, scope } => (BoardVerb::Status { message }, scope),
                BoardCommand::Peers(scope) => (BoardVerb::Peers, scope),
                BoardCommand::Broadcast { message, scope } => {
                    (BoardVerb::Broadcast { message }, scope)
                }
                BoardCommand::Send { to, message, scope } => {
                    (BoardVerb::Send { to, message }, scope)
                }
                BoardCommand::Read(scope) => (BoardVerb::Read, scope),
                BoardCommand::Claim { path, scope } => (BoardVerb::Claim { path }, scope),
                BoardCommand::Release { path, scope } => (BoardVerb::Release { path }, scope),
                BoardCommand::Claims(scope) => (BoardVerb::Claims, scope),
            };
            verbs::board::run(BoardArgs {
                verb,
                run: scope.run,
                agent: scope.agent,
            })?
        }
        Verb::Hook { agent } => verbs::hook::run(&agent)?,
        Verb::Compile => verbs::compile::run()?,
        Verb::Stats => verbs::stats::run()?,
        Verb::Daemon { action } => match action {
            DaemonAction::Serve { read_only, socket } => match socket {
                Some(path) => verbs::daemon::run_serve_at(&path, read_only)?,
                None => verbs::daemon::run_serve()?,
            },
            DaemonAction::Start { read_only } => verbs::daemon::run_start(read_only)?,
            DaemonAction::Stop => verbs::daemon::run_stop(),
            DaemonAction::Restart => verbs::daemon::run_restart(),
            DaemonAction::Status => verbs::daemon::run_status(),
        },
        Verb::Doctor { repair } => verbs::doctor::run_with_repair(repair)?,
        Verb::Report => verbs::report::run_report()?,
        Verb::Statusline => verbs::report::run_statusline(),
        Verb::Waive {
            rule,
            path,
            ttl,
            reason,
            supersedes,
            contradicts,
        } => verbs::waive::run(verbs::waive::WaiveArgs {
            rule,
            path,
            ttl,
            reason,
            supersedes,
            contradicts,
        })?,
        Verb::Instructions {
            for_subagent,
            digest,
        } => verbs::instructions::run(for_subagent, digest)?,
    };
    std::process::exit(code);
}