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"
);
}
}
}