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