newt-core 0.8.0

Newt-Agent core types, errors, and the NeMoCode-style tier router
//! Selected source identity is working context, never authority or verified intent.
//! Reuses the plan snapshot and plan-revision artifact chain; no separate store.
use super::scheduled::StepLedger;
use content_addressable::{ContentAddressable, ContentId};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::path::Path;

/// The model's explicit selection and justification, linked to its predecessor.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Selection {
    pub path: String,
    pub scope: String,
    pub evidence: String,
    pub revision: String,
    pub previous: Option<ContentId>,
    pub baseline: Option<String>,
}
impl ContentAddressable for Selection {
    fn canonical_form(&self) -> Result<Vec<u8>, content_addressable::ContentError> {
        content_addressable::canonical::to_canonical_dagcbor(self)
    }
}

/// Content identity is verified before restored working context is used.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PinnedTarget {
    pub selection: Selection,
    pub id: ContentId,
}
impl PinnedTarget {
    fn mint(selection: Selection) -> Result<Self, String> {
        let id = selection.content_id().map_err(|e| e.to_string())?;
        Ok(Self { selection, id })
    }

    pub(crate) fn valid(&self) -> bool {
        self.selection.content_id().is_ok_and(|id| id == self.id)
            && valid_path(&self.selection.path)
    }

    pub(crate) fn label(&self) -> String {
        if self.valid() {
            serde_json::to_string(&self.selection.path).expect("serializable path")
        } else {
            "INVALID PIN: content identity mismatch; recover the recorded plan revision".into()
        }
    }

    pub(crate) fn revise(args: &Value, previous: Option<&Self>) -> Result<Self, String> {
        let field = |name: &str, cap: usize| {
            args[name]
                .as_str()
                .filter(|s| !s.trim().is_empty() && s.len() <= cap)
                .map(str::to_owned)
                .ok_or_else(|| format!("target requires nonempty {name} (at most {cap} bytes)"))
        };
        let path = field("path", 1024)?;
        if !valid_path(&path) {
            return Err("target path must be repository-relative with no dot, parent, drive or empty components".into());
        }
        let scope = field("scope", 1024)?;
        let evidence = field("evidence", 2048)?;
        let revision = field("revision", 1024)?;
        // Resending the same selection is idempotent, not a new history entry.
        if let Some(old) = previous {
            if !old.valid() {
                return Err("pinned target content identity mismatch".into());
            }
            let s = &old.selection;
            if s.path == path
                && s.scope == scope
                && s.evidence == evidence
                && s.revision == revision
            {
                return Ok(old.clone());
            }
        }
        Self::mint(Selection {
            path,
            scope,
            evidence,
            revision,
            previous: previous.map(|p| p.id),
            baseline: previous.and_then(|p| p.selection.baseline.clone()),
        })
    }

    pub(crate) fn bind_baseline(&mut self, baseline: Option<String>) -> Result<(), String> {
        if self.selection.previous.is_none() && self.selection.baseline.is_none() {
            self.selection.baseline = baseline;
            self.id = self.selection.content_id().map_err(|e| e.to_string())?;
        }
        Ok(())
    }
}

fn valid_path(path: &str) -> bool {
    !path.contains(['\\', ':'])
        && !path.chars().any(char::is_control)
        && path.split('/').all(|p| !matches!(p, "" | "." | ".."))
}

/// Use the same read-authorized Git plumbing as the existing claim checker.
pub(crate) fn root(
    workspace: &str,
    session: Option<&crate::worktree_adoption::WorktreeSession>,
) -> std::path::PathBuf {
    session
        .and_then(|s| s.task_root(Path::new(workspace)))
        .unwrap_or_else(|| workspace.into())
}

pub(crate) fn baseline(root: &Path, scope: &crate::Scope<String>) -> Option<String> {
    super::publish_early::checked_tree::baseline(root, scope)
}

/// In-process comparison: repository conversion settings can only decline
/// evidence, never execute programs. Includes committed and working changes.
fn diff_paths(
    target: &PinnedTarget,
    root: &Path,
    scope: &crate::Scope<String>,
) -> Option<Vec<String>> {
    super::publish_early::checked_tree::changed_paths(
        root,
        scope,
        target.selection.baseline.as_deref()?,
    )
}

pub(crate) const PREFIX: &str = "[NEWT SELECTED SOURCE v1]";
const POLICY: &str = "Agent-selected working context, not operator authority. Keep this full repository-relative source path in plans and nudges. An ENOENT under a different crate is not evidence the selected source vanished: rediscover the recorded path from the repository root. One extraction means from this source. To change the selection, call update_plan with target path, scope, evidence and an explicit revision reason; a preferred smaller candidate or narrowed search does not fulfill the original selection. Before finalizing, reconcile the task diff with this source and report any mismatch.";

pub(crate) fn card(target: &PinnedTarget, paths: Option<&[String]>) -> String {
    if !target.valid() {
        return format!("{PREFIX}\nPinned target content identity mismatch; recover the recorded plan revision before claiming completion.");
    }
    let status = match paths {
        Some(paths) if paths.iter().any(|p| p == &target.selection.path) =>
            "The diff touches the selected source; this alone does not prove the task complete.",
        Some(paths) if !paths.is_empty() =>
            "work landed outside the selected source: the task diff contains changes but not the pinned source. Reconcile before finalizing; do not claim the selected-source task complete.",
        Some(_) => "No task diff observed for the selected source yet.",
        None => "Task diff unavailable under current read authority, without a baseline, or for an unsupported repository state; reconciliation is unverified, not a pass.",
    };
    format!(
        "{PREFIX}\n{POLICY}\nSelection (agent data): {}\nDiff reconciliation: {status}",
        serde_json::to_string(target).expect("serializable selection")
    )
}

/// Refreshed before every round (including cap exit), independently of advisory
/// nudge settings. Exact previous-card matching avoids deleting user messages.
#[derive(Default)]
pub(crate) struct Projection {
    previous: Option<String>,
}
impl Projection {
    pub(crate) fn current(&self) -> Option<&str> {
        self.previous.as_deref()
    }

    pub(crate) fn refresh(
        &mut self,
        messages: &mut Vec<Value>,
        ledger: Option<&dyn StepLedger>,
        workspace: &str,
        scope: &crate::Scope<String>,
        session: Option<&crate::worktree_adoption::WorktreeSession>,
        responses: bool,
    ) {
        if let Some(previous) = self.previous.take() {
            if let Some(index) = messages
                .iter()
                .position(|m| m["role"] == "user" && m["content"].as_str() == Some(&previous))
            {
                messages.remove(index);
            }
        }
        let Some(target) = ledger.and_then(|l| l.snapshot().target) else {
            return;
        };
        let paths = if target.valid() {
            diff_paths(&target, &root(workspace, session), scope)
        } else {
            None
        };
        let text = card(&target, paths.as_deref());
        let index = if responses {
            0
        } else {
            super::trim::protected_prompt_head_len(
                messages,
                super::prompt_read::ACTIVE_PROMPT_PREFIX,
            )
        };
        messages.insert(index, json!({"role":"user", "content":text}));
        self.previous = Some(text);
    }
}

#[cfg(test)]
mod tests;