shepherd-core 6.6.0

The harness-agnostic shepherd engine: domain types, configuration schema, and run state. Knows nothing about any CLI, harness, or process.
//! Closed nine-role registry and stable semantic carriers.

#[cfg(feature = "alloc")]
use alloc::{format, string::String, vec, vec::Vec};

use super::{CapabilityContract, DispatchError, DispatchResult};

#[derive(
    Clone,
    Copy,
    Debug,
    Eq,
    Hash,
    Ord,
    PartialEq,
    PartialOrd,
    strum::AsRefStr,
    strum::Display,
    strum::EnumCount,
    strum::EnumIs,
    strum::EnumString,
    strum::IntoStaticStr,
    strum::VariantArray,
    strum::VariantNames,
)]
// Deliberately NOT `ascii_case_insensitive`, unlike the config-facing enums.
// `Role` is never typed by an operator -- it has no clap flag -- and every
// `from_name` caller is a machine boundary: the WIT bridge, the broker, the
// hook payload, and the registry claim. Leniency there bought nothing and cost
// byte-stability on the field the whole flock's authorization keys on: a
// record stored as `"eNgInEeR"` deserialized fine and rewrote itself as
// `"engineer"`.
#[strum(serialize_all = "snake_case")]
pub enum Role {
    Auditor,
    Coder,
    Conductor,
    Critic,
    Discovery,
    Engineer,
    Planter,
    Shepherd,
    Worker,
}

impl Role {
    /// The complete closed role registry in deterministic order.
    ///
    /// Kept as a `const` array rather than replaced by `VariantArray`: several
    /// callers need it in const context, and the flock being exactly nine is a
    /// contract worth stating literally. `role_registry_matches_the_derived_variants`
    /// proves the two never diverge.
    pub const ALL: [Self; 9] = [
        Self::Auditor,
        Self::Coder,
        Self::Conductor,
        Self::Critic,
        Self::Discovery,
        Self::Engineer,
        Self::Planter,
        Self::Shepherd,
        Self::Worker,
    ];

    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Auditor => "auditor",
            Self::Coder => "coder",
            Self::Conductor => "conductor",
            Self::Critic => "critic",
            Self::Discovery => "discovery",
            Self::Engineer => "engineer",
            Self::Planter => "planter",
            Self::Shepherd => "shepherd",
            Self::Worker => "worker",
        }
    }

    #[must_use]
    pub fn carrier(self) -> String {
        format!("shepherd:{}", self.as_str())
    }

    pub fn from_carrier(value: &str) -> DispatchResult<Self> {
        let Some(role) = value.strip_prefix("shepherd:") else {
            return Err(DispatchError::InvalidRole(value.into()));
        };
        Self::from_name(role)
    }

    pub fn from_name(value: &str) -> DispatchResult<Self> {
        // Was a linear scan of `ALL` comparing `as_str`. `EnumString` derives
        // the same lookup from the variant names, so the mapping is stated once.
        value
            .parse()
            .map_err(|_| DispatchError::InvalidRole(value.into()))
    }

    /// Whether this role is a profile the ROOT session adopts, rather than a
    /// subagent anyone dispatches.
    ///
    /// `Planter` and `Shepherd` are perspectives one session moves between --
    /// `RootSessionBinding::validate` already refuses a root binding that is
    /// neither. The other seven are true subagents. Nothing ever dispatches a
    /// root profile: doing so would mint a second root for a session that
    /// already has one, which is a different thing from the tier rules that
    /// stop a LANE LEAD reaching upward.
    #[must_use]
    pub const fn is_root_profile(self) -> bool {
        matches!(self, Self::Planter | Self::Shepherd)
    }

    /// The write scope a role is entitled to, as repository-relative globs.
    ///
    /// `write_scope` was a caller-supplied field with no default, so a dispatch
    /// that named none had no scope, and every write was refused. The scope a
    /// role needs is a property of the role, not of whoever dispatched it.
    ///
    /// Two roles write code. `Coder` and `Worker` are the implementers -- their
    /// lane brief narrows them further to file-disjoint paths, which is a
    /// per-lane decision this cannot make.
    ///
    /// The rest do not write code at all. `Engineer` authors the plan,
    /// `Conductor` the lane ledger, `Critic` and `Auditor` their findings,
    /// `Discovery` its report -- every one of those is Markdown inside the run
    /// they are bound to, so that is exactly what they get. A role that cannot
    /// write a `.rs` file cannot silently become an implementer.
    ///
    /// `Planter` holds the operator channel and owns two documents; this states
    /// as data what `PLANTER_WRITE_SCOPE` in the compiler has only ever said as
    /// prose. `Shepherd` performs integration under its own custody.
    #[must_use]
    pub fn default_write_scope(self, run: &str) -> Vec<String> {
        match self {
            Self::Coder | Self::Worker | Self::Shepherd => vec![String::from("**")],
            Self::Engineer | Self::Conductor | Self::Critic | Self::Auditor | Self::Discovery => {
                vec![format!(".shepherd/runs/{run}/**/*.md")]
            }
            Self::Planter => vec![
                format!(".shepherd/runs/{run}/seed.md"),
                format!(".shepherd/runs/{run}/mesh.md"),
            ],
        }
    }

    /// Closed native dispatch policy. Discovery is an Engineer-owned
    /// orientation capability; a Conductor may launch only its three lane
    /// specialists and cannot obtain research by changing the payload shape.
    #[must_use]
    pub const fn may_dispatch_to(self, target: Self) -> bool {
        match self {
            Self::Engineer => matches!(target, Self::Critic | Self::Auditor | Self::Discovery),
            Self::Conductor => matches!(target, Self::Coder | Self::Worker | Self::Auditor),
            _ => false,
        }
    }

    pub fn capability_contract(self) -> DispatchResult<CapabilityContract> {
        let (required, optional): (&[&str], &[&str]) = match self {
            Self::Auditor => (
                &[
                    "read",
                    "search",
                    "shell",
                    "code-intelligence",
                    "skill-load",
                    "report-write",
                ],
                &["tool-discovery"],
            ),
            Self::Coder | Self::Worker => (
                &["read", "search", "shell", "write", "skill-load"],
                &["tool-discovery"],
            ),
            Self::Conductor => (
                &[
                    "read",
                    "search",
                    "shell",
                    "skill-load",
                    "dispatch",
                    "message-peer",
                    "task-tracking",
                ],
                &["schedule-wakeup", "tool-discovery"],
            ),
            Self::Critic => (&["read", "search", "shell", "skill-load"], &[]),
            Self::Discovery => (
                &["read", "search", "shell", "skill-load", "report-write"],
                &["tool-discovery", "web-research"],
            ),
            Self::Engineer => (
                &[
                    "read",
                    "search",
                    "shell",
                    "write",
                    "skill-load",
                    "dispatch",
                    "message-peer",
                ],
                &["tool-discovery"],
            ),
            Self::Planter => (
                &[
                    "read",
                    "search",
                    "shell",
                    "write",
                    "skill-load",
                    "ask-operator",
                ],
                &["tool-discovery"],
            ),
            Self::Shepherd => (
                &[
                    "read",
                    "search",
                    "shell",
                    "write",
                    "skill-load",
                    "dispatch",
                    "message-peer",
                    "task-tracking",
                ],
                &["tool-discovery", "web-research"],
            ),
        };
        let forbidden: &[&str] = match self {
            Self::Auditor | Self::Critic => &[
                "admin",
                "sudo",
                "write",
                "edit",
                "dispatch",
                "web-research",
                "research-specialist",
            ],
            Self::Discovery => &[
                "admin",
                "sudo",
                "write",
                "edit",
                "dispatch",
                "research-specialist",
            ],
            Self::Coder | Self::Worker => &[
                "admin",
                "sudo",
                "dispatch",
                "web-research",
                "research-specialist",
            ],
            Self::Conductor => &[
                "admin",
                "sudo",
                "write",
                "edit",
                "web-research",
                "research-specialist",
            ],
            Self::Planter => &[
                "admin",
                "sudo",
                "dispatch",
                "task-tracking",
                "web-research",
                "research-specialist",
            ],
            Self::Engineer | Self::Shepherd => &["admin", "sudo"],
        };
        CapabilityContract::new(required, optional, forbidden)
    }

    pub fn dispatch_capability_contract(self) -> DispatchResult<CapabilityContract> {
        let mut contract = self.capability_contract()?;
        contract.required.insert("subagent-provider".into());
        contract.validate()?;
        Ok(contract)
    }
}

impl serde::Serialize for Role {
    fn serialize<S>(&self, serializer: S) -> core::result::Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        serializer.serialize_str(self.as_str())
    }
}

impl<'de> serde::Deserialize<'de> for Role {
    fn deserialize<D>(deserializer: D) -> core::result::Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let value = String::deserialize(deserializer)?;
        Self::from_name(&value).map_err(serde::de::Error::custom)
    }
}