src/funnel/mod.rs
//! Idea-intake funnel — agentic-planning surface for nornir.
//!
//! **Doctrine 2026-07-15 (PLAN #5): the funnel ACTION moved to dwarves.** The
//! LLM-driven ACTION (decompose / plan-drive / prompt-as-mother / executor-drive /
//! autonomous kick) now lives in `dwarves` (`dwarves-mcp` — ClaudeCaller +
//! decompose + autonom_kick). nornir's ACTION verbs are retained as still-working
//! shims that warn via [`funnel_action_moved_banner`]; the QUERY / READ surface
//! (show / next / history / cards / board + the viz Funnel tab, all warehouse-
//! backed) stays here and is unchanged — **nornir stays queryable**.
//!
//! 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:alice".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);
//! ```
// The pure DAG core (event/ids/state/topo/history/card) was extracted into the
// `funnel-core` crate in the #22 wave. Re-export it here — both the modules and
// their symbols — so every existing `crate::funnel::{…}` and `super::event`/
// `super::state`/… path across the impure halves (decompose/demo/planner/store)
// keeps resolving exactly as before. The impure, nornir-coupled halves stay here.
pub use funnel_core::*;
pub use funnel_core::{card, event, history, ids, state, topo};
pub mod decompose;
pub mod demo;
pub mod executor;
pub mod planner;
pub mod prompt;
pub mod store;
pub mod team;
pub use decompose::{
Anchor, CodeIndexRetriever, DEFAULT_MAX_ROUNDS, DEFAULT_TOP_K, Decomposition, MAX_SUBTASKS,
RetrievedContext, Retriever, Round, SimulationReport, Subtask, SubtaskVerdict, apply_verdicts,
decompose as decompose_task, retrieve, run_subtask_loop, simulate, store_subtasks,
};
pub use planner::{
ComponentProposal, DerivedPlan, ProposalSource, affected_components, component_list,
derive_plan, plan_from_component, propose_components,
};
pub use prompt::{
AssignSpec, FunnelMode, PromptResult, current_lane, prompt_result_render,
prompt_result_to_json, run_prompt, run_prompt_with_decomposition,
};
pub use store::Store;
pub use team::{Member, Role, Team};
/// Loud deprecation banner for the funnel **ACTION** verbs (the LLM-driven
/// decompose / plan-drive / prompt-as-mother / executor-drive / autonomous kick).
/// Per ratified doctrine (owner 2026-07-15, PLAN #5 — *no funnel ACTION in
/// nornir anymore*) the funnel-ACTION replacement now LIVES IN DWARVES (shipped:
/// `dwarves/crates/dwarves/src/funnel_action.rs` + `dwarves-mcp` — ClaudeCaller +
/// decompose + autonom_kick). The nornir funnel ACTION verbs are retained as
/// still-working shims that warn on use; the QUERY / READ verbs (show / next /
/// history / cards / board — the warehouse-backed views + the viz Funnel tab)
/// stay in nornir and do NOT warn: **nornir stays queryable**.
///
/// Factored out here (a pure `&'static str`) so the banner is unit-testable
/// without running a real decompose/LLM. Must always mention `dwarves` + `moved`.
pub fn funnel_action_moved_banner() -> &'static str {
"⚠ DEPRECATED: the funnel ACTION (LLM decompose / plan-drive / prompt-as-mother / \
executor-drive / autonomous kick) has MOVED to dwarves (doctrine 2026-07-15, PLAN #5). \
Use: dwarves funnel-action (or the dwarves-mcp ClaudeCaller / decompose / autonom_kick). \
This nornir funnel command still runs for now — nornir keeps the QUERYABLE funnel \
(show / next / history / cards / board + the viz Funnel tab) — but the ACTION path \
will be removed."
}
// ───────────────────────── 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:alice".into(),
text: text.into(),
refs: Vec::new(),
item_kind: ItemKind::Idea,
submitted_at: Utc::now(),
triage: None,
mode: 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()"));
}
}