nornir 0.5.0

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! Idea-intake funnel — agentic-planning surface for nornir.
//!
//! See plan.md §0 (and the "note · funnel-dag-handoff" legacy entry) for the
//! design discussion.
//!
//! Quick start (events are stored in the Iceberg `funnel_events` table under
//! the warehouse root, not a flat file):
//! ```no_run
//! use nornir::funnel::{Store, Event, IdeaId};
//! use chrono::Utc;
//! let mut store = Store::open(".nornir/warehouse").unwrap();
//! store.record(Event::IdeaSubmitted {
//!     id: IdeaId::seq(store.funnel.next_idea + 1),
//!     source: "human:rickard".into(),
//!     text: "build the funnel itself".into(),
//!     refs: vec![],
//!     item_kind: nornir::funnel::ItemKind::Idea,
//!     ts: Utc::now(),
//! }).unwrap();
//! let next = nornir::funnel::topo_ready(&mut store.funnel);
//! ```

pub mod decompose;
pub mod demo;
pub mod event;
pub mod history;
pub mod ids;
pub mod planner;
pub mod state;
pub mod store;
pub mod topo;

pub use decompose::{
    apply_verdicts, decompose as decompose_task, retrieve, run_subtask_loop, simulate,
    store_subtasks, Anchor, CodeIndexRetriever, Decomposition, RetrievedContext, Retriever, Round,
    SimulationReport, Subtask, SubtaskVerdict, DEFAULT_MAX_ROUNDS, DEFAULT_TOP_K, MAX_SUBTASKS,
};
pub use event::{CommitRef, Event, ItemKind, NodeStatus, PlanStatus, RunOutcome, TriageDecision};
pub use history::{history, history_to_json, HistoryItem, HistoryStatus};
pub use ids::{IdeaId, NodeId, PlanId, RunId};
pub use planner::{
    affected_components, component_list, derive_plan, plan_from_component, propose_components,
    ComponentProposal, DerivedPlan, ProposalSource,
};
pub use state::{Funnel, Idea, Plan, PlanNode};
pub use store::Store;
pub use topo::{NextStep, topo_ready, topo_ready_for_plan};

// ───────────────────────── CommandOutcome builders (AUT9) ──────────────────
// The CLI twin of the viz Funnel tab's `state_json`. These SHAPE already-fetched
// funnel state into the uniform `CommandOutcome{command, ok, data, human}`; they
// NEVER fetch (no warehouse/RPC inside — CommandOutcome is presentation, not a
// transport). The fat CLI path (`run_funnel`) opens the local funnel store and
// passes the in-memory `Funnel`/`Vec<NextStep>`; the thin path converts the
// `Funnel.Show`/`Funnel.Next` gRPC reply into the SAME inputs, so fat & thin emit
// one shape (parity by construction). `ok ⟺ meaningful non-empty data`
// (RAGNARÖK: an empty funnel / no ready step is RED, never silently green).
// See `.nornir/cli-command-contract.md`.

/// `funnel show` rendered as a [`crate::cli_outcome::CommandOutcome`] — the whole
/// funnel (ideas → plans → nodes + edge counts). `ok ⟺ ≥1 idea` (an empty funnel
/// is RED). The `human` form is the same `ideas: N, plans: M` tree the handler
/// printed inline; `data` carries the structured ideas/plans/nodes for the
/// autopilot check + viz-parity comparison.
pub fn show_outcome(funnel: &Funnel) -> crate::cli_outcome::CommandOutcome {
    use crate::cli_outcome::CommandOutcome;
    use std::fmt::Write as _;

    if funnel.ideas.is_empty() && funnel.plans.is_empty() {
        return CommandOutcome::fail("funnel show", "funnel is empty — no ideas or plans");
    }

    let mut human = format!("ideas: {}, plans: {}", funnel.ideas.len(), funnel.plans.len());
    let ideas_json: Vec<serde_json::Value> = funnel
        .ideas
        .iter()
        .map(|(iid, idea)| {
            let _ = write!(human, "\n  {} [{}] {}", iid.as_str(), idea.source, idea.text);
            serde_json::json!({
                "id": iid.as_str(),
                "source": idea.source,
                "item_kind": idea.item_kind,
                "text": idea.text,
            })
        })
        .collect();

    let plans_json: Vec<serde_json::Value> = funnel
        .plans
        .iter()
        .map(|(pid, plan)| {
            let _ = write!(
                human,
                "\n  {} (idea {}) [{:?}] {}{} nodes, {} edges",
                pid.as_str(),
                plan.idea_id.as_str(),
                plan.status,
                plan.summary,
                plan.nodes.len(),
                plan.edges.len(),
            );
            let nodes_json: Vec<serde_json::Value> = plan
                .nodes
                .iter()
                .map(|(nid, n)| {
                    let title = n.params.get("title").and_then(|v| v.as_str()).unwrap_or("");
                    let _ = write!(human, "\n    {} [{:?}] {} {}", nid.as_str(), n.status, n.kind, title);
                    serde_json::json!({
                        "id": nid.as_str(),
                        "kind": n.kind,
                        "title": title,
                        "status": n.status,
                    })
                })
                .collect();
            serde_json::json!({
                "id": pid.as_str(),
                "idea_id": plan.idea_id.as_str(),
                "summary": plan.summary,
                "status": plan.status,
                "nodes": nodes_json,
                "edges": plan.edges.len(),
            })
        })
        .collect();

    let data = serde_json::json!({
        "ideas": ideas_json,
        "plans": plans_json,
        "counts": { "ideas": funnel.ideas.len(), "plans": funnel.plans.len() },
    });
    CommandOutcome::ok("funnel show", data, human)
}

/// `funnel next` rendered as a [`crate::cli_outcome::CommandOutcome`] — the topo-
/// ordered ready PlanNodes across active plans. `ok ⟺ ≥1 ready step` (no ready
/// work is RED — the funnel has nothing actionable). `human` lists each step;
/// `data` carries the structured steps (which already `Serialize`).
pub fn next_outcome(steps: &[NextStep]) -> crate::cli_outcome::CommandOutcome {
    use crate::cli_outcome::CommandOutcome;
    use std::fmt::Write as _;

    if steps.is_empty() {
        return CommandOutcome::fail("funnel next", "no ready steps — nothing actionable in the funnel");
    }
    let mut human = format!("{} ready step(s):", steps.len());
    for s in steps {
        let prompt = s.prompt_excerpt.as_deref().unwrap_or("");
        let _ = write!(
            human,
            "\n  {} / {} · {}{}{}",
            s.plan_id.as_str(),
            s.node_id.as_str(),
            s.kind,
            s.summary,
            if prompt.is_empty() { String::new() } else { format!(" :: {prompt}") },
        );
    }
    let data = serde_json::json!({ "steps": steps });
    CommandOutcome::ok("funnel next", data, human)
}

#[cfg(test)]
mod outcome_tests {
    use super::*;
    use crate::funnel::ids::{IdeaId, NodeId, PlanId};
    use crate::funnel::state::{Idea, Plan, PlanNode};
    use chrono::Utc;

    fn idea(id: &str, text: &str) -> Idea {
        Idea {
            id: IdeaId::new(id),
            source: "human:rickard".into(),
            text: text.into(),
            refs: Vec::new(),
            item_kind: ItemKind::Idea,
            submitted_at: Utc::now(),
            triage: None,
        }
    }

    fn node(id: &str, kind: &str) -> PlanNode {
        PlanNode {
            id: NodeId::new(id),
            kind: kind.into(),
            params: serde_json::Map::new(),
            targets: Vec::new(),
            prompt_excerpt: None,
            created_at: Utc::now(),
            status: NodeStatus::Ready,
            last_status_change: Utc::now(),
        }
    }

    #[test]
    fn empty_funnel_show_is_red_not_silently_green() {
        let f = Funnel::default();
        let o = show_outcome(&f);
        assert_eq!(o.command, "funnel show");
        assert!(!o.is_sannr(), "an empty funnel must be RED (RAGNARÖK)");
        assert!(o.human.contains("empty"));
    }

    #[test]
    fn populated_funnel_show_is_sannr_with_real_data() {
        let mut f = Funnel::default();
        f.ideas.insert(IdeaId::new("i-001"), idea("i-001", "build the funnel"));
        let mut plan = Plan {
            id: PlanId::new("p-001"),
            idea_id: IdeaId::new("i-001"),
            summary: "refine the funnel".into(),
            planner: "cli".into(),
            created_at: Utc::now(),
            status: PlanStatus::Active,
            nodes: std::collections::BTreeMap::new(),
            edges: std::collections::BTreeSet::new(),
        };
        plan.nodes.insert(NodeId::new("n-001"), node("n-001", "code:write"));
        f.plans.insert(PlanId::new("p-001"), plan);

        let o = show_outcome(&f);
        assert!(o.is_sannr(), "a populated funnel is a true (sannr) outcome");
        assert_eq!(o.data["counts"]["ideas"], serde_json::json!(1));
        assert_eq!(o.data["counts"]["plans"], serde_json::json!(1));
        assert_eq!(o.data["ideas"][0]["id"], serde_json::json!("i-001"));
        assert_eq!(o.data["plans"][0]["nodes"][0]["kind"], serde_json::json!("code:write"));
        assert_eq!(o.data["plans"][0]["nodes"][0]["status"], serde_json::json!("ready"));
        assert!(o.human.contains("i-001"));
    }

    #[test]
    fn empty_next_is_red_not_silently_green() {
        let o = next_outcome(&[]);
        assert_eq!(o.command, "funnel next");
        assert!(!o.is_sannr(), "no ready step must be RED (RAGNARÖK)");
    }

    #[test]
    fn populated_next_is_sannr_with_real_steps() {
        let steps = vec![NextStep {
            plan_id: PlanId::new("p-001"),
            node_id: NodeId::new("n-001"),
            kind: "code:write".into(),
            targets: vec!["util".into()],
            summary: "refine the funnel".into(),
            prompt_excerpt: Some("write add()".into()),
        }];
        let o = next_outcome(&steps);
        assert!(o.is_sannr(), "a ready step is a true (sannr) outcome");
        let arr = o.data["steps"].as_array().unwrap();
        assert_eq!(arr.len(), 1);
        assert_eq!(arr[0]["node_id"], serde_json::json!("n-001"));
        assert_eq!(arr[0]["kind"], serde_json::json!("code:write"));
        assert!(o.human.contains("write add()"));
    }
}