pushkin-core 0.2.0

Core envelope, manifest, pipeline, and waiver types for the pushkin write-gate
Documentation
//! Legacy (pre-rename) Sentinel naming — the ONE place old names live
//! (remediation pass 3, PART B2). The 2026-08-14 naming directive retired
//! the sentinel names; every remaining old-name string in the codebase is
//! a constant here, kept solely to RECOGNIZE or REMOVE artifacts a
//! sentinel-era binary left behind. The naming sweep's exclusion list is
//! auditable against this module: any `sentinel` outside it (and the
//! append-only history) is a straggler.

use std::borrow::Cow;
use std::path::{Path, PathBuf};
use thiserror::Error;

/// Marker key a sentinel-era install wrote into agent hook configs.
/// Any entry carrying it — whatever the value — is OURS.
pub const MARKER_KEY: &str = "_sentinel";

/// Repo-local state dir a sentinel-era binary owned (events, consent,
/// instructions, board, daemon socket).
pub const STATE_DIR: &str = ".sentinel";

/// Git-tracked waiver dir a sentinel-era binary owned. Only treated as
/// ours when it actually carries the waiver record below.
pub const WAIVER_DIR: &str = "sentinel";

/// The waiver record inside [`WAIVER_DIR`].
pub const WAIVERS_FILE: &str = "sentinel/waivers.toml";

/// Rule-id prefix of events and waivers recorded pre-rename. Historical
/// rows keep it (the log is append-only); aggregation and waiver matching
/// normalize through [`modern_rule_id`].
pub const RULE_PREFIX: &str = "sentinel.";

/// The prefix new events carry.
const MODERN_RULE_PREFIX: &str = "pushkin.";

/// Adapter artifacts a sentinel-era install wrote; `--remove-agent`
/// strips these alongside their pushkin-named successors.
pub const CODEX_RULES: &str = ".codex/rules/sentinel.rules";
pub const AUGGIE_SCRIPT: &str = ".augment/hooks/sentinel.sh";
pub const OPENCODE_PLUGIN: &str = ".opencode/plugin/sentinel.ts";
pub const HERMES_HOOK: &str = ".hermes/hooks/sentinel.json";

/// Hermes plugin dir name a sentinel-era install created under
/// `$HERMES_HOME/plugins/`.
pub const HERMES_PLUGIN_DIR: &str = "sentinel-gate";

/// The manifest-less Phase 2 draft location (predates even the plugin
/// manifest), cleaned up best-effort on every hermes install.
pub const HERMES_DRAFT_DIR: &str = "sentinel_gate";

/// Maps a legacy `sentinel.*` rule id to its `pushkin.*` spelling;
/// anything else passes through. Both sides of a comparison go through
/// this so mixed-population aggregation groups one rule, not two.
#[must_use]
pub fn modern_rule_id(rule: &str) -> Cow<'_, str> {
    match rule.strip_prefix(RULE_PREFIX) {
        Some(tail) => Cow::Owned(format!("{MODERN_RULE_PREFIX}{tail}")),
        None => Cow::Borrowed(rule),
    }
}

/// The legacy spelling of a modern rule id (for SQL `IN` filters over the
/// mixed population); non-`pushkin.*` ids pass through unchanged.
#[must_use]
pub fn legacy_rule_id(rule: &str) -> Cow<'_, str> {
    match rule.strip_prefix(MODERN_RULE_PREFIX) {
        Some(tail) => Cow::Owned(format!("{RULE_PREFIX}{tail}")),
        None => Cow::Borrowed(rule),
    }
}

#[derive(Debug, Error)]
pub enum MigrationError {
    #[error(
        "both {old} and {new} exist — refusing to guess which is current. \
         A human must reconcile them (keep one, remove or merge the other), \
         then re-run."
    )]
    BothExist { old: String, new: String },
    #[error("cannot rename {old} to {new}: {source}")]
    Rename {
        old: String,
        new: String,
        source: std::io::Error,
    },
}

/// One directory rename performed by [`migrate_dirs`].
#[derive(Debug)]
pub struct MigratedDir {
    pub old: PathBuf,
    pub new: PathBuf,
}

/// One-time footprint migration: `.sentinel/` → `.pushkin/` and
/// `sentinel/` → `pushkin/` (the waiver dir, recognized by its
/// `waivers.toml` — signed history survives the move). Old and new both
/// present is a loud refusal, never a guess. Idempotent: with no legacy
/// dirs this is a no-op.
///
/// # Errors
/// Returns [`MigrationError`] when a pair coexists or a rename fails.
pub fn migrate_dirs(root: &Path) -> Result<Vec<MigratedDir>, MigrationError> {
    let mut renamed = Vec::new();
    let state_pair = (root.join(STATE_DIR), root.join(".pushkin"));
    if state_pair.0.is_dir() {
        rename_pair(&state_pair.0, &state_pair.1, &mut renamed)?;
    }
    let waiver_pair = (root.join(WAIVER_DIR), root.join("pushkin"));
    if root.join(WAIVERS_FILE).is_file() {
        rename_pair(&waiver_pair.0, &waiver_pair.1, &mut renamed)?;
    }
    Ok(renamed)
}

fn rename_pair(
    old: &Path,
    new: &Path,
    renamed: &mut Vec<MigratedDir>,
) -> Result<(), MigrationError> {
    if new.exists() {
        // Trailing slashes: these are directories, and the refusal must
        // name them unambiguously.
        return Err(MigrationError::BothExist {
            old: format!("{}/", old.display()),
            new: format!("{}/", new.display()),
        });
    }
    std::fs::rename(old, new).map_err(|source| MigrationError::Rename {
        old: old.display().to_string(),
        new: new.display().to_string(),
        source,
    })?;
    renamed.push(MigratedDir {
        old: old.to_path_buf(),
        new: new.to_path_buf(),
    });
    Ok(())
}