Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

src/funnel/prompt.rs

//! `nornir funnel prompt` — the funnel-prompt-planner keystone's entry point.
//!
//! Wires together three pieces that already exist, per the spec's thesis
//! (`.nornir/funnel-prompt-planner-spec.md` §0):
//!   1. **Prompt-as-mother** (§1) — the prompt text is persisted as the root
//!      `Idea`; `decompose`/`store_subtasks` (`super::decompose`) build the
//!      anchored, dep-linked child task DAG beneath it. 100% model-free/
//!      offline — only the RAG/code-index leg of `decompose` runs; the
//!      generate→judge codegen loop (`run_subtask_loop`) is ORTHOGONAL and is
//!      never invoked here.
//!   2. **The mode gate** (§2) — `FunnelMode::Auto` stamps every child node
//!      `Planned` (straight into the live plan); `FunnelMode::Manual` stamps
//!      `Proposed` (a staging draft `nornir funnel accept` promotes).
//!   3. **Real-person assignment** (§3) — `AssignSpec::Auto` allocates by
//!      role↔task-kind + `Team` capacity (`super::team`); `AssignSpec::Member`
//!      blanket-assigns; `AssignSpec::None` leaves everything unassigned (the
//!      pull model).
//!
//! Every effect is an ordinary migration-safe funnel event
//! (`IdeaSubmitted`/`IdeaModeSet`/`NodeAdded`/`EdgeAdded`/`CardStateChanged`/
//! `CardAssigned`), so the WHOLE run is historized/reviewable/time-travelable
//! like anything else in the funnel — Auto is safe because it's still
//! event-sourced, not because it skips review (spec §2 "Safety").

use anyhow::Result;
use chrono::Utc;
use serde::{Deserialize, Serialize};

use super::decompose::{Decomposition, Retriever, decompose, store_subtasks};
use super::store::Store;
use super::team::{Role, Team};
use super::{Event, IdeaId, ItemKind, NodeId, PlanId, card_from_history, history};

/// **Manual** (LLM/structural suggestion lands in `Proposed` for human review)
/// vs **Auto** (populated straight into the live `Planned` lane) — the mode
/// gate, spec §2. Both are event-sourced; Manual just front-loads the review.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FunnelMode {
    Manual,
    Auto,
}

impl FunnelMode {
    #[must_use]
    pub fn as_str(&self) -> &'static str {
        match self {
            FunnelMode::Manual => "manual",
            FunnelMode::Auto => "auto",
        }
    }

    #[must_use]
    pub fn parse(s: &str) -> Option<Self> {
        match s.to_ascii_lowercase().as_str() {
            "manual" => Some(FunnelMode::Manual),
            "auto" => Some(FunnelMode::Auto),
            _ => None,
        }
    }

    /// The [`super::Lane`] wire token every child task node is stamped with
    /// immediately after `store_subtasks` — see [`run_prompt`] step 4.
    #[must_use]
    pub fn child_lane(&self) -> &'static str {
        match self {
            FunnelMode::Auto => "planned",
            FunnelMode::Manual => "proposed",
        }
    }
}

/// `--assign auto|<member>|none` (spec §3).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AssignSpec {
    /// Unassigned — the pull model (default).
    None,
    /// Allocate by role↔task-kind + `Team` capacity.
    Auto,
    /// Blanket-assign every produced card (mother + children) to this member.
    Member(String),
}

impl AssignSpec {
    #[must_use]
    pub fn parse(s: &str) -> Self {
        match s.trim() {
            "" | "none" => AssignSpec::None,
            "auto" => AssignSpec::Auto,
            other => AssignSpec::Member(other.to_string()),
        }
    }
}

/// Everything `run_prompt` produced — the ids the CLI renders (human view +
/// `--json`, LAW 6).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PromptResult {
    pub idea_id: String,
    pub mode: String,
    pub plan_id: String,
    pub node_ids: Vec<String>,
    pub decomposition: Decomposition,
}

/// Run the whole prompt-as-mother pipeline (spec §1-§3). `retrievers` grounds
/// the decomposition in the real code index (empty/no-scan-yet is fine —
/// `decompose` falls back to one whole-task subtask so the pipeline always
/// produces something runnable). `team` drives `AssignSpec::Auto`.
#[allow(clippy::too_many_arguments)]
pub fn run_prompt(
    store: &mut Store,
    text: &str,
    source: &str,
    mode: FunnelMode,
    retrievers: &[&dyn Retriever],
    top_k: usize,
    max_subtasks: usize,
    assign: &AssignSpec,
    team: &Team,
) -> Result<PromptResult> {
    run_prompt_inner(
        store,
        text,
        source,
        mode,
        retrievers,
        top_k,
        max_subtasks,
        assign,
        team,
        None,
    )
}

/// Like [`run_prompt`] but weaves a **pre-computed** decomposition (e.g. one an
/// LLM produced from the raw prompt) instead of the model-free `decompose`. The
/// mother idea, mode gate, and assignment are identical — only the source of the
/// child subtasks differs.
#[allow(clippy::too_many_arguments)]
pub fn run_prompt_with_decomposition(
    store: &mut Store,
    text: &str,
    source: &str,
    mode: FunnelMode,
    retrievers: &[&dyn Retriever],
    top_k: usize,
    max_subtasks: usize,
    assign: &AssignSpec,
    team: &Team,
    decomposition: Decomposition,
) -> Result<PromptResult> {
    run_prompt_inner(
        store,
        text,
        source,
        mode,
        retrievers,
        top_k,
        max_subtasks,
        assign,
        team,
        Some(decomposition),
    )
}

#[allow(clippy::too_many_arguments)]
fn run_prompt_inner(
    store: &mut Store,
    text: &str,
    source: &str,
    mode: FunnelMode,
    retrievers: &[&dyn Retriever],
    top_k: usize,
    max_subtasks: usize,
    assign: &AssignSpec,
    team: &Team,
    decomposition: Option<Decomposition>,
) -> Result<PromptResult> {
    let text = text.trim();
    anyhow::ensure!(!text.is_empty(), "empty prompt text");

    // 1. The mother: an Idea, its full text = the raw prompt verbatim (spec
    //    §1: "title = short form, body = the raw prompt" — `Card::title` is
    //    already derived as the first line of `Idea::text`, so one field
    //    carries both).
    let idea_id = IdeaId::seq(store.funnel.next_idea.max(1));
    store.record(Event::IdeaSubmitted {
        id: idea_id.clone(),
        source: source.to_string(),
        text: text.to_string(),
        refs: vec![],
        item_kind: ItemKind::Idea,
        ts: Utc::now(),
    })?;
    // The mode "rides the prompt intake" (spec §2) as its own migration-safe
    // event/column, exactly like `item_kind` — `Idea` has no generic `params`
    // map to piggyback on (unlike `PlanNode`), so `IdeaModeSet` is the
    // additive equivalent.
    store.record(Event::IdeaModeSet {
        idea_id: idea_id.clone(),
        mode: mode.as_str().to_string(),
        ts: Utc::now(),
    })?;

    // 2. Ground + decompose. Default is model-free (RAG/code-index leg only;
    //    the generate→judge codegen loop is ORTHOGONAL, never invoked here); a
    //    caller may inject a pre-computed decomposition (e.g. LLM-produced).
    let decomposition =
        decomposition.unwrap_or_else(|| decompose(text, retrievers, top_k, max_subtasks));

    // 3. Weave the child task DAG under the mother (existing parent→child
    //    machinery, unchanged).
    let (plan_id, node_ids) = store_subtasks(store, &idea_id, &decomposition)?;

    // 4. The mode gate: stamp every child node's lane (Planned for Auto,
    //    Proposed for Manual — spec §2).
    let to_lane = mode.child_lane();
    for node_id in &node_ids {
        store.record(Event::CardStateChanged {
            node: node_id.as_str().to_string(),
            from_lane: None,
            to_lane: to_lane.to_string(),
            why: Some(format!("{} prompt decompose", mode.as_str())),
            ts: Utc::now(),
        })?;
    }

    // 5. Assignment (spec §3).
    assign_produced(store, &idea_id, &plan_id, &node_ids, assign, team)?;

    Ok(PromptResult {
        idea_id: idea_id.as_str().to_string(),
        mode: mode.as_str().to_string(),
        plan_id: plan_id.as_str().to_string(),
        node_ids: node_ids.iter().map(|n| n.as_str().to_string()).collect(),
        decomposition,
    })
}

/// Assign the mother + every produced child node per `assign` (spec §3):
/// `Member(m)` blanket-assigns all of them to `m`; `Auto` allocates the
/// mother to an Architect and each child by its `node_kind`'s role, via
/// `team.allocate` (round-robin + capacity, deterministic tie-break);
/// `None` assigns nothing.
fn assign_produced(
    store: &mut Store,
    idea_id: &IdeaId,
    plan_id: &PlanId,
    node_ids: &[NodeId],
    assign: &AssignSpec,
    team: &Team,
) -> Result<()> {
    match assign {
        AssignSpec::None => Ok(()),
        AssignSpec::Member(member) => {
            store.record(Event::CardAssigned {
                node: idea_id.as_str().to_string(),
                member: member.clone(),
                role: None,
                why: Some("explicit --assign".into()),
                ts: Utc::now(),
            })?;
            for node_id in node_ids {
                store.record(Event::CardAssigned {
                    node: node_id.as_str().to_string(),
                    member: member.clone(),
                    role: None,
                    why: Some("explicit --assign".into()),
                    ts: Utc::now(),
                })?;
            }
            Ok(())
        }
        AssignSpec::Auto => {
            let mut load: std::collections::BTreeMap<String, u32> =
                std::collections::BTreeMap::new();
            if let Some(member) = team.allocate(Role::for_parent(), &mut load) {
                store.record(Event::CardAssigned {
                    node: idea_id.as_str().to_string(),
                    member,
                    role: Some(Role::for_parent().as_str().to_string()),
                    why: Some("auto-assign: decomposition parent -> Architect".into()),
                    ts: Utc::now(),
                })?;
            }
            // Node kind is uniform across one decomposition (stamped from the
            // parent item kind by `store_subtasks`), but look it up per-node
            // from the stored plan so this stays correct if that ever changes.
            let kinds: std::collections::BTreeMap<NodeId, String> = store
                .funnel
                .plans
                .get(plan_id)
                .map(|p| {
                    p.nodes
                        .iter()
                        .map(|(id, n)| (id.clone(), n.kind.clone()))
                        .collect()
                })
                .unwrap_or_default();
            for node_id in node_ids {
                let kind = kinds
                    .get(node_id)
                    .cloned()
                    .unwrap_or_else(|| "code:write".to_string());
                let role = Role::for_node_kind(&kind);
                if let Some(member) = team.allocate(role, &mut load) {
                    store.record(Event::CardAssigned {
                        node: node_id.as_str().to_string(),
                        member,
                        role: Some(role.as_str().to_string()),
                        why: Some(format!("auto-assign: {kind} -> {}", role.as_str())),
                        ts: Utc::now(),
                    })?;
                }
            }
            Ok(())
        }
    }
}

/// Render `result` as `state_json` (LAW 6): mode on the mother, and
/// lane+assignee for the mother + every child, sourced from
/// `funnel_core::cards_from_funnel` (the single projection every other funnel
/// surface — `funnel cards`/`board`, the smart query — reads too, so this
/// never disagrees with them).
#[must_use]
pub fn prompt_result_to_json(result: &PromptResult, funnel: &super::Funnel) -> serde_json::Value {
    let cards = super::cards_from_funnel(funnel);
    let find = |id: &str| cards.iter().find(|c| c.id == id);
    let card_json = |id: &str| {
        find(id).map(|c| {
            serde_json::json!({
                "id": c.id,
                "title": c.title,
                "lane": c.lane.as_str(),
                "assignee": c.assignee,
                "card_type": c.card_type.as_str(),
            })
        })
    };
    serde_json::json!({
        "mode": result.mode,
        "idea_id": result.idea_id,
        "plan_id": result.plan_id,
        "mother": card_json(&result.idea_id),
        "children": result.node_ids.iter().map(|id| card_json(id)).collect::<Vec<_>>(),
        "decomposition": result.decomposition.to_json(),
    })
}

/// Human-readable render of `result` — the mother + its child task DAG with
/// lane/assignee per card (mirrors `SimulationReport::render`'s style).
#[must_use]
pub fn prompt_result_render(result: &PromptResult, funnel: &super::Funnel) -> String {
    use std::fmt::Write as _;
    let cards = super::cards_from_funnel(funnel);
    let find = |id: &str| cards.iter().find(|c| c.id == id);
    let mut out = String::new();
    let _ = writeln!(out, "mother {} [mode={}]", result.idea_id, result.mode);
    if let Some(c) = find(&result.idea_id) {
        let _ = writeln!(
            out,
            "  {} — lane={} assignee={}",
            c.title,
            c.lane.as_str(),
            c.assignee.as_deref().unwrap_or("-")
        );
    }
    let _ = writeln!(
        out,
        "plan {} — {} child task(s):",
        result.plan_id,
        result.node_ids.len()
    );
    for (id, sub) in result
        .node_ids
        .iter()
        .zip(result.decomposition.subtasks.iter())
    {
        if let Some(c) = find(id) {
            let _ = writeln!(
                out,
                "  {} [{}] {} — lane={} assignee={} target={}",
                id,
                c.card_type.as_str(),
                sub.title,
                c.lane.as_str(),
                c.assignee.as_deref().unwrap_or("-"),
                if sub.target_file.is_empty() {
                    "-"
                } else {
                    &sub.target_file
                },
            );
        }
    }
    out
}

/// Look up the [`super::Lane`] a card currently sits in (idea cards derive it
/// from `HistoryStatus`; task-node cards from `Funnel::card_lanes`) — the
/// shared read `accept`/`assign` use to validate their target before mutating.
#[must_use]
pub fn current_lane(funnel: &super::Funnel, card_id: &str) -> Option<super::Lane> {
    if let Some(node_str) = funnel.card_lanes.get(card_id) {
        return super::Lane::parse(node_str);
    }
    // Fall back to the idea-derived lane (covers a mother card, which never
    // gets a CardStateChanged in this design).
    history(funnel, None, None)
        .into_iter()
        .find(|it| it.id == card_id)
        .map(|it| card_from_history(&it).lane)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::funnel::decompose::CodeIndexRetriever;
    use crate::knowledge::query::KnowledgeView;
    use crate::knowledge::symbols::SymbolRow;

    fn view_with_symbols() -> KnowledgeView {
        let symbols = vec![
            SymbolRow {
                crate_name: "r".into(),
                module_path: "http".into(),
                item_kind: "fn".into(),
                item_name: "retry_fetch".into(),
                visibility: "pub".into(),
                file: "src/http.rs".into(),
                line: 10,
                doc_lines: 0,
                signature: Some("fn retry_fetch()".into()),
            },
            SymbolRow {
                crate_name: "r".into(),
                module_path: "http".into(),
                item_kind: "fn".into(),
                item_name: "retry_fetch_test".into(),
                visibility: "pub".into(),
                file: "src/http.rs".into(),
                line: 40,
                doc_lines: 0,
                signature: Some("fn retry_fetch_test()".into()),
            },
        ];
        KnowledgeView::new(symbols, vec![])
    }

    fn tmp_store() -> (tempfile::TempDir, Store) {
        let dir = tempfile::tempdir().unwrap();
        let store = Store::open(dir.path().join("wh")).unwrap();
        (dir, store)
    }

    /// The headline flow: a prompt creates a mother `Idea` + a chained child
    /// task DAG beneath it, anchored to REAL indexed symbols (never
    /// hallucinated), fully offline (no generator/judge involved).
    #[test]
    fn prompt_creates_mother_and_anchored_children() {
        let (_dir, mut store) = tmp_store();
        let view = view_with_symbols();
        let retriever = CodeIndexRetriever::new(&view);
        let retrievers: Vec<&dyn Retriever> = vec![&retriever];
        let team = Team::sample();

        let result = run_prompt(
            &mut store,
            "retry the http fetch",
            "human:alice",
            FunnelMode::Auto,
            &retrievers,
            5,
            8,
            &AssignSpec::None,
            &team,
        )
        .unwrap();

        assert_eq!(result.idea_id, "i-001");
        assert!(
            !result.node_ids.is_empty(),
            "at least one anchored child node"
        );
        assert_eq!(result.node_ids.len(), result.decomposition.subtasks.len());
        // Anchored to a REAL file from the index, never hallucinated.
        assert!(
            result
                .decomposition
                .subtasks
                .iter()
                .any(|s| s.target_file == "src/http.rs")
        );

        let idea = store
            .funnel
            .ideas
            .get(&IdeaId::new(&result.idea_id))
            .unwrap();
        assert_eq!(idea.mode.as_deref(), Some("auto"));

        let plan = store
            .funnel
            .plans
            .get(&PlanId::new(&result.plan_id))
            .unwrap();
        assert_eq!(
            plan.idea_id,
            IdeaId::new(&result.idea_id),
            "the plan hangs under the mother"
        );
        assert_eq!(plan.nodes.len(), result.node_ids.len());
    }

    /// Auto mode lands every child in `Planned`; Manual lands them in
    /// `Proposed` — the mode gate (spec §2), asserted via the SAME projection
    /// `funnel cards`/`board` use.
    #[test]
    fn auto_lands_planned_manual_lands_proposed() {
        let view = view_with_symbols();
        let retriever = CodeIndexRetriever::new(&view);
        let retrievers: Vec<&dyn Retriever> = vec![&retriever];
        let team = Team::sample();

        let (_dir_a, mut store_a) = tmp_store();
        let auto = run_prompt(
            &mut store_a,
            "retry the http fetch",
            "cli",
            FunnelMode::Auto,
            &retrievers,
            5,
            8,
            &AssignSpec::None,
            &team,
        )
        .unwrap();
        let cards_a = super::super::cards_from_funnel(&store_a.funnel);
        for id in &auto.node_ids {
            let c = cards_a.iter().find(|c| &c.id == id).unwrap();
            assert_eq!(c.lane, super::super::Lane::Planned, "auto -> Planned");
        }

        let (_dir_m, mut store_m) = tmp_store();
        let manual = run_prompt(
            &mut store_m,
            "retry the http fetch",
            "cli",
            FunnelMode::Manual,
            &retrievers,
            5,
            8,
            &AssignSpec::None,
            &team,
        )
        .unwrap();
        let cards_m = super::super::cards_from_funnel(&store_m.funnel);
        for id in &manual.node_ids {
            let c = cards_m.iter().find(|c| &c.id == id).unwrap();
            assert_eq!(c.lane, super::super::Lane::Proposed, "manual -> Proposed");
        }
    }

    /// `--assign auto` respects role (code:write -> Coder) + deterministically
    /// allocates the mother to an Architect.
    #[test]
    fn assign_auto_respects_role_and_is_deterministic() {
        let view = view_with_symbols();
        let retriever = CodeIndexRetriever::new(&view);
        let retrievers: Vec<&dyn Retriever> = vec![&retriever];
        let team = Team::sample();

        let (_dir, mut store) = tmp_store();
        let result = run_prompt(
            &mut store,
            "retry the http fetch",
            "cli",
            FunnelMode::Auto,
            &retrievers,
            5,
            8,
            &AssignSpec::Auto,
            &team,
        )
        .unwrap();

        let mother_assignment = store.funnel.assignments.get(&result.idea_id).unwrap();
        assert_eq!(mother_assignment.role.as_deref(), Some("architect"));
        assert!(
            team.members
                .iter()
                .any(|m| m.name == mother_assignment.member && m.can(Role::Architect))
        );

        for node_id in &result.node_ids {
            let a = store.funnel.assignments.get(node_id).unwrap();
            assert_eq!(
                a.role.as_deref(),
                Some("coder"),
                "code:write nodes -> Coder"
            );
        }
    }
}