use crate::core::broker::DbBroker;
use crate::core::error;
use crate::core::schemas;
use crate::core::store::Store;
use crate::plugins::federation;
use clap::{Parser, Subcommand};
use rusqlite::params;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize)]
pub struct DecisionTree {
pub id: &'static str,
pub name: &'static str,
pub description: &'static str,
pub keywords: &'static [&'static str],
pub questions: &'static [DecisionQuestion],
}
#[derive(Debug, Clone, Serialize)]
pub struct DecisionQuestion {
pub id: &'static str,
pub prompt: &'static str,
pub context: &'static str,
pub options: &'static [DecisionOption],
pub depends_on: Option<&'static str>,
pub depends_value: Option<&'static str>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DecisionOption {
pub value: &'static str,
pub label: &'static str,
pub rationale: &'static str,
}
static DECISION_TREES: [&DecisionTree; 4] = [
&TREE_WEB_APP,
&TREE_MICROSERVICE,
&TREE_CLI_TOOL,
&TREE_LIBRARY,
];
pub fn decision_trees() -> &'static [&'static DecisionTree] {
&DECISION_TREES
}
static TREE_WEB_APP: DecisionTree = DecisionTree {
id: "web-app",
name: "Web Application",
description: "Browser-based application (SPA, PWA, or traditional)",
keywords: &[
"web",
"app",
"website",
"frontend",
"spa",
"pwa",
"ui",
"dashboard",
"page",
],
questions: &[
DecisionQuestion {
id: "runtime",
prompt: "Which runtime target?",
context: "Determines the core language and execution model for your web app",
options: &[
DecisionOption {
value: "typescript",
label: "TypeScript",
rationale: "Broadest ecosystem, fastest iteration, largest talent pool",
},
DecisionOption {
value: "wasm",
label: "WebAssembly",
rationale: "Near-native performance, write in Rust/Go/C++, compile to browser",
},
DecisionOption {
value: "both",
label: "Hybrid (TS + WASM)",
rationale: "TypeScript app shell with WASM compute-heavy modules",
},
],
depends_on: None,
depends_value: None,
},
DecisionQuestion {
id: "framework",
prompt: "Which TypeScript framework?",
context: "Determines component model, state management patterns, and ecosystem",
options: &[
DecisionOption {
value: "react",
label: "React",
rationale: "Largest ecosystem, most third-party libraries, widest hiring pool",
},
DecisionOption {
value: "svelte",
label: "Svelte",
rationale: "Compile-time reactivity, minimal boilerplate, excellent DX",
},
DecisionOption {
value: "solid",
label: "SolidJS",
rationale: "Fine-grained reactivity, React-like API, excellent performance",
},
DecisionOption {
value: "vue",
label: "Vue",
rationale: "Progressive framework, gentle learning curve, strong tooling",
},
DecisionOption {
value: "vanilla",
label: "Vanilla (no framework)",
rationale: "Zero dependencies, full control, web standards only",
},
],
depends_on: Some("runtime"),
depends_value: Some("typescript"),
},
DecisionQuestion {
id: "framework_wasm",
prompt: "Which WASM framework?",
context: "Determines the Rust web framework for your WebAssembly application",
options: &[
DecisionOption {
value: "leptos",
label: "Leptos",
rationale: "Fine-grained reactivity, SSR support, active community",
},
DecisionOption {
value: "yew",
label: "Yew",
rationale: "React-like component model, mature ecosystem",
},
DecisionOption {
value: "dioxus",
label: "Dioxus",
rationale: "Cross-platform (web, desktop, mobile), React-like API",
},
],
depends_on: Some("runtime"),
depends_value: Some("wasm"),
},
DecisionQuestion {
id: "bundler",
prompt: "Which bundler?",
context: "Build tool that processes and bundles your source code for the browser",
options: &[
DecisionOption {
value: "vite",
label: "Vite",
rationale: "Fast dev server, Rollup-based production builds, framework-agnostic",
},
DecisionOption {
value: "rspack",
label: "Rspack",
rationale: "Rust-based webpack-compatible bundler, extremely fast builds",
},
DecisionOption {
value: "esbuild",
label: "esbuild",
rationale: "Go-based, fastest pure bundler, minimal configuration",
},
DecisionOption {
value: "none",
label: "None / framework default",
rationale: "Use framework's built-in build system (e.g., trunk for WASM)",
},
],
depends_on: None,
depends_value: None,
},
DecisionQuestion {
id: "styling",
prompt: "Styling approach?",
context: "How CSS is authored and organized in the project",
options: &[
DecisionOption {
value: "tailwind",
label: "Tailwind CSS",
rationale: "Utility-first, rapid prototyping, consistent design system",
},
DecisionOption {
value: "css_modules",
label: "CSS Modules",
rationale: "Scoped CSS, no runtime cost, works with any framework",
},
DecisionOption {
value: "styled_components",
label: "CSS-in-JS",
rationale: "Co-located styles, dynamic theming, component-scoped",
},
DecisionOption {
value: "vanilla_css",
label: "Vanilla CSS",
rationale: "Standard CSS, no build step, maximum browser compatibility",
},
],
depends_on: None,
depends_value: None,
},
DecisionQuestion {
id: "state_mgmt",
prompt: "State management approach?",
context: "How application state is managed across components",
options: &[
DecisionOption {
value: "built_in",
label: "Framework built-in",
rationale: "Use the framework's native state primitives (signals, stores, context)",
},
DecisionOption {
value: "zustand",
label: "Zustand",
rationale: "Minimal, hooks-based, no boilerplate (React ecosystem)",
},
DecisionOption {
value: "redux",
label: "Redux Toolkit",
rationale: "Predictable state container, time-travel debugging, mature ecosystem",
},
DecisionOption {
value: "none",
label: "None (local state only)",
rationale: "Component-local state only, no global store needed",
},
],
depends_on: None,
depends_value: None,
},
],
};
static TREE_MICROSERVICE: DecisionTree = DecisionTree {
id: "microservice",
name: "Microservice",
description: "Backend service exposing an API (REST, gRPC, or GraphQL)",
keywords: &[
"microservice",
"service",
"api",
"backend",
"server",
"rest",
"grpc",
"endpoint",
],
questions: &[
DecisionQuestion {
id: "language",
prompt: "Which language?",
context: "Primary language for the service implementation",
options: &[
DecisionOption {
value: "rust",
label: "Rust",
rationale: "Memory safety, excellent performance, strong type system (axum, actix)",
},
DecisionOption {
value: "go",
label: "Go",
rationale: "Fast compilation, built-in concurrency, simple deployment",
},
DecisionOption {
value: "typescript",
label: "TypeScript (Node.js)",
rationale: "Huge ecosystem, shared frontend/backend types, rapid iteration",
},
DecisionOption {
value: "python",
label: "Python",
rationale: "Rich ecosystem (FastAPI, Django), ML/data integration, rapid prototyping",
},
DecisionOption {
value: "java",
label: "Java / Kotlin",
rationale: "Enterprise ecosystem (Spring Boot), JVM performance, strong typing",
},
],
depends_on: None,
depends_value: None,
},
DecisionQuestion {
id: "api_style",
prompt: "API style?",
context: "Communication protocol and data exchange format",
options: &[
DecisionOption {
value: "rest",
label: "REST (HTTP/JSON)",
rationale: "Widest tooling support, human-readable, browser-friendly",
},
DecisionOption {
value: "grpc",
label: "gRPC (Protocol Buffers)",
rationale: "Strong typing, efficient binary format, streaming, code generation",
},
DecisionOption {
value: "graphql",
label: "GraphQL",
rationale: "Flexible queries, schema-first design, client-driven data fetching",
},
],
depends_on: None,
depends_value: None,
},
DecisionQuestion {
id: "container",
prompt: "Container runtime?",
context: "How the service is packaged for deployment",
options: &[
DecisionOption {
value: "docker",
label: "Docker",
rationale: "Industry standard, widest tooling and registry support",
},
DecisionOption {
value: "podman",
label: "Podman",
rationale: "Daemonless, rootless by default, Docker-compatible CLI",
},
DecisionOption {
value: "none",
label: "No container",
rationale: "Direct binary/process deployment, simpler for single-host setups",
},
],
depends_on: None,
depends_value: None,
},
DecisionQuestion {
id: "orchestration",
prompt: "Orchestration platform?",
context: "How containers are scheduled, scaled, and managed in production",
options: &[
DecisionOption {
value: "kubernetes",
label: "Kubernetes",
rationale: "Production-grade orchestration, auto-scaling, self-healing, complex",
},
DecisionOption {
value: "compose",
label: "Docker Compose",
rationale: "Simple multi-container orchestration, good for small deployments",
},
DecisionOption {
value: "none",
label: "None",
rationale: "Single binary deployment, systemd, or serverless platform",
},
],
depends_on: None,
depends_value: None,
},
DecisionQuestion {
id: "database",
prompt: "Primary database?",
context: "Main data persistence layer for the service",
options: &[
DecisionOption {
value: "postgres",
label: "PostgreSQL",
rationale: "Feature-rich relational DB, JSONB support, excellent extensions",
},
DecisionOption {
value: "mysql",
label: "MySQL / MariaDB",
rationale: "Widely deployed, good replication, strong hosting support",
},
DecisionOption {
value: "sqlite",
label: "SQLite",
rationale: "Embedded, zero-config, perfect for single-node services",
},
DecisionOption {
value: "mongodb",
label: "MongoDB",
rationale: "Document store, flexible schema, horizontal scaling",
},
DecisionOption {
value: "none",
label: "None / external",
rationale: "Stateless service, or database managed by another service",
},
],
depends_on: None,
depends_value: None,
},
DecisionQuestion {
id: "observability",
prompt: "Observability stack?",
context: "How the service exposes metrics, traces, and logs",
options: &[
DecisionOption {
value: "opentelemetry",
label: "OpenTelemetry",
rationale: "Vendor-neutral, unified traces/metrics/logs, CNCF standard",
},
DecisionOption {
value: "prometheus_grafana",
label: "Prometheus + Grafana",
rationale: "Pull-based metrics, mature dashboarding, wide adoption",
},
DecisionOption {
value: "custom",
label: "Custom / structured logging",
rationale: "JSON structured logs, custom metrics endpoint",
},
DecisionOption {
value: "none",
label: "None",
rationale: "Defer observability to a later phase",
},
],
depends_on: None,
depends_value: None,
},
],
};
static TREE_CLI_TOOL: DecisionTree = DecisionTree {
id: "cli-tool",
name: "CLI Tool",
description: "Command-line tool or utility",
keywords: &[
"cli", "command", "terminal", "shell", "tool", "utility", "script",
],
questions: &[
DecisionQuestion {
id: "language",
prompt: "Which language?",
context: "Primary language for the CLI tool",
options: &[
DecisionOption {
value: "rust",
label: "Rust",
rationale: "Fast single binary, clap ecosystem, excellent error handling",
},
DecisionOption {
value: "go",
label: "Go",
rationale: "Fast compilation, cobra/viper ecosystem, simple cross-compilation",
},
DecisionOption {
value: "typescript",
label: "TypeScript (Node.js)",
rationale: "Rich npm ecosystem, commander/yargs, rapid development",
},
DecisionOption {
value: "python",
label: "Python",
rationale: "argparse/click/typer, rapid prototyping, extensive standard library",
},
],
depends_on: None,
depends_value: None,
},
DecisionQuestion {
id: "distribution",
prompt: "Distribution strategy?",
context: "How users will install and update the tool",
options: &[
DecisionOption {
value: "binary",
label: "Static binary",
rationale: "Single file, no runtime deps (cargo install, go install, GH releases)",
},
DecisionOption {
value: "package_manager",
label: "Package manager",
rationale: "npm/pip/brew install, version management, dependency resolution",
},
DecisionOption {
value: "both",
label: "Both",
rationale: "Binary releases + package manager for maximum reach",
},
],
depends_on: None,
depends_value: None,
},
DecisionQuestion {
id: "config_format",
prompt: "Configuration file format?",
context: "Format for the tool's configuration files",
options: &[
DecisionOption {
value: "toml",
label: "TOML",
rationale: "Human-friendly, good for config files, Rust/cargo ecosystem standard",
},
DecisionOption {
value: "yaml",
label: "YAML",
rationale: "Widely used in DevOps, supports complex structures, comments",
},
DecisionOption {
value: "json",
label: "JSON",
rationale: "Universal, no ambiguity, easy machine generation",
},
DecisionOption {
value: "none",
label: "None (flags only)",
rationale: "All configuration via CLI flags and environment variables",
},
],
depends_on: None,
depends_value: None,
},
DecisionQuestion {
id: "output_format",
prompt: "Output format support?",
context: "How the tool presents results to users and other programs",
options: &[
DecisionOption {
value: "text_json",
label: "Text + JSON",
rationale: "Human-readable by default, --format json for machine consumption",
},
DecisionOption {
value: "text_only",
label: "Text only",
rationale: "Human-oriented output, simple implementation",
},
DecisionOption {
value: "json_only",
label: "JSON only",
rationale: "Machine-first tool, piped into other programs",
},
],
depends_on: None,
depends_value: None,
},
],
};
static TREE_LIBRARY: DecisionTree = DecisionTree {
id: "library",
name: "Library / Package",
description: "Reusable library, crate, or package published to a registry",
keywords: &[
"library",
"lib",
"crate",
"package",
"module",
"sdk",
"framework",
],
questions: &[
DecisionQuestion {
id: "language",
prompt: "Which language?",
context: "Primary language for the library",
options: &[
DecisionOption {
value: "rust",
label: "Rust",
rationale: "Zero-cost abstractions, crates.io ecosystem, strong type system",
},
DecisionOption {
value: "typescript",
label: "TypeScript",
rationale: "Largest package ecosystem (npm), broad reach, type safety",
},
DecisionOption {
value: "python",
label: "Python",
rationale: "PyPI distribution, wide adoption in data/ML/scripting",
},
DecisionOption {
value: "go",
label: "Go",
rationale: "Go modules, simple dependency management, fast compilation",
},
],
depends_on: None,
depends_value: None,
},
DecisionQuestion {
id: "packaging",
prompt: "Package registry?",
context: "Where the library will be published for consumption",
options: &[
DecisionOption {
value: "crates_io",
label: "crates.io",
rationale: "Rust's official registry, cargo integration",
},
DecisionOption {
value: "npm",
label: "npm",
rationale: "JavaScript/TypeScript registry, largest package count",
},
DecisionOption {
value: "pypi",
label: "PyPI",
rationale: "Python's official registry, pip/poetry integration",
},
DecisionOption {
value: "go_modules",
label: "Go Modules",
rationale: "Go's built-in module system, proxy.golang.org",
},
],
depends_on: None,
depends_value: None,
},
DecisionQuestion {
id: "doc_tooling",
prompt: "Documentation tooling?",
context: "How API documentation is generated and published",
options: &[
DecisionOption {
value: "rustdoc",
label: "rustdoc",
rationale: "Built into cargo, docs.rs hosting, inline doc comments",
},
DecisionOption {
value: "typedoc",
label: "TypeDoc",
rationale: "TypeScript API docs from JSDoc/TSDoc comments",
},
DecisionOption {
value: "sphinx",
label: "Sphinx",
rationale: "Python standard, reStructuredText/Markdown, ReadTheDocs hosting",
},
DecisionOption {
value: "mdbook",
label: "mdBook",
rationale: "Markdown-based book format, great for guides and tutorials",
},
],
depends_on: None,
depends_value: None,
},
DecisionQuestion {
id: "testing",
prompt: "Testing strategy?",
context: "Primary testing approach for the library",
options: &[
DecisionOption {
value: "unit_integration",
label: "Unit + Integration",
rationale: "Standard test pyramid: unit tests + integration tests",
},
DecisionOption {
value: "property_based",
label: "Property-based",
rationale: "Generative testing (proptest, hypothesis, rapid-check)",
},
DecisionOption {
value: "snapshot",
label: "Snapshot testing",
rationale: "Assert against recorded outputs (insta, jest snapshots)",
},
DecisionOption {
value: "all",
label: "All of the above",
rationale: "Comprehensive: unit + integration + property + snapshot",
},
],
depends_on: None,
depends_value: None,
},
],
};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DecisionSession {
pub id: String,
pub tree_id: String,
pub title: String,
pub description: String,
pub status: String,
pub federation_node_id: Option<String>,
pub created_at: String,
pub updated_at: String,
pub completed_at: Option<String>,
pub actor: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub decisions: Option<Vec<Decision>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Decision {
pub id: String,
pub session_id: String,
pub question_id: String,
pub tree_id: String,
pub question_text: String,
pub chosen_value: String,
pub chosen_label: String,
pub rationale: String,
pub user_note: String,
pub federation_node_id: Option<String>,
pub created_at: String,
pub actor: String,
}
#[derive(Debug, Serialize)]
pub struct TreeSuggestion {
pub tree_id: String,
pub tree_name: String,
pub score: f64,
pub matched_keywords: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct NextQuestionResult {
#[serde(skip_serializing_if = "Option::is_none")]
pub question: Option<serde_json::Value>,
pub complete: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
#[derive(Parser, Debug)]
#[clap(
name = "decide",
about = "Architecture decision prompting — curated engineering questions for AI agents."
)]
pub struct DecideCli {
#[clap(subcommand)]
pub command: DecideCommand,
}
#[derive(Subcommand, Debug)]
pub enum DecideCommand {
Start {
#[clap(long)]
tree: String,
#[clap(long)]
title: String,
#[clap(long, default_value = "")]
description: String,
#[clap(long, default_value = "decapod")]
actor: String,
},
Record {
#[clap(long)]
session: String,
#[clap(long)]
question: String,
#[clap(long)]
value: String,
#[clap(long, default_value = "")]
rationale: String,
#[clap(long, default_value = "decapod")]
actor: String,
},
Complete {
#[clap(long)]
session: String,
},
List {
#[clap(long)]
session: Option<String>,
#[clap(long)]
tree: Option<String>,
},
Get {
#[clap(long)]
id: String,
},
Session {
#[clap(subcommand)]
command: SessionSubCommand,
},
Trees,
Suggest {
#[clap(long)]
prompt: String,
},
Next {
#[clap(long)]
session: String,
},
Init,
Schema,
}
#[derive(Subcommand, Debug)]
pub enum SessionSubCommand {
List {
#[clap(long)]
status: Option<String>,
},
Get {
#[clap(long)]
id: String,
},
}
fn now_ts() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("{secs}Z")
}
fn decide_db_path(root: &Path) -> PathBuf {
root.join(schemas::MEMORY_DB_NAME)
}
pub fn initialize_decide_db(root: &Path) -> Result<(), error::DecapodError> {
let db_path = decide_db_path(root);
let broker = DbBroker::new(root);
broker.with_conn(&db_path, "decapod", None, "decide.init", |conn| {
conn.execute_batch(schemas::MEMORY_DB_SCHEMA_META)?;
conn.execute_batch(schemas::DECIDE_DB_SCHEMA_SESSIONS)?;
conn.execute_batch(schemas::DECIDE_DB_SCHEMA_DECISIONS)?;
conn.execute_batch(schemas::DECIDE_DB_INDEX_DECISIONS_SESSION)?;
conn.execute_batch(schemas::DECIDE_DB_INDEX_DECISIONS_TREE)?;
conn.execute_batch(schemas::DECIDE_DB_INDEX_SESSIONS_TREE)?;
conn.execute_batch(schemas::DECIDE_DB_INDEX_SESSIONS_STATUS)?;
Ok(())
})
}
fn find_tree(tree_id: &str) -> Result<&'static DecisionTree, error::DecapodError> {
decision_trees()
.iter()
.copied()
.find(|t| t.id == tree_id)
.ok_or_else(|| {
let valid: Vec<&str> = decision_trees().iter().map(|t| t.id).collect();
error::DecapodError::ValidationError(format!(
"Unknown tree '{}'. Available: {}",
tree_id,
valid.join(", ")
))
})
}
fn find_question<'a>(
tree: &'a DecisionTree,
question_id: &str,
) -> Result<&'a DecisionQuestion, error::DecapodError> {
tree.questions
.iter()
.find(|q| q.id == question_id)
.ok_or_else(|| {
error::DecapodError::ValidationError(format!(
"Unknown question '{}' in tree '{}'",
question_id, tree.id
))
})
}
fn find_option<'a>(
question: &'a DecisionQuestion,
value: &str,
) -> Result<&'a DecisionOption, error::DecapodError> {
question
.options
.iter()
.find(|o| o.value == value)
.ok_or_else(|| {
let valid: Vec<&str> = question.options.iter().map(|o| o.value).collect();
error::DecapodError::ValidationError(format!(
"Invalid value '{}' for question '{}'. Valid: {}",
value,
question.id,
valid.join(", ")
))
})
}
pub fn suggest_trees(prompt: &str) -> Vec<TreeSuggestion> {
let tokens: Vec<String> = prompt
.to_lowercase()
.split_whitespace()
.map(|s| s.trim_matches(|c: char| !c.is_alphanumeric()).to_string())
.filter(|s| s.len() >= 3)
.collect();
let mut suggestions: Vec<TreeSuggestion> = decision_trees()
.iter()
.map(|tree| {
let matched: Vec<String> = tree
.keywords
.iter()
.filter(|kw| {
tokens
.iter()
.any(|t| t.contains(*kw) || kw.contains(t.as_str()))
})
.map(|s| s.to_string())
.collect();
let score = if tree.keywords.is_empty() {
0.0
} else {
matched.len() as f64 / tree.keywords.len() as f64
};
TreeSuggestion {
tree_id: tree.id.to_string(),
tree_name: tree.name.to_string(),
score,
matched_keywords: matched,
}
})
.collect();
suggestions.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
suggestions.retain(|s| s.score > 0.0);
suggestions
}
fn resolve_next_question<'a>(
tree: &'a DecisionTree,
answered: &std::collections::HashMap<String, String>,
) -> Option<&'a DecisionQuestion> {
for question in tree.questions {
if answered.contains_key(question.id) {
continue;
}
if let (Some(dep_id), Some(dep_val)) = (question.depends_on, question.depends_value) {
match answered.get(dep_id) {
Some(val) if val == dep_val => {} _ => continue, }
}
return Some(question);
}
None
}
fn create_session_federation_node(
store: &Store,
session_id: &str,
tree_id: &str,
title: &str,
actor: &str,
) -> Result<String, error::DecapodError> {
federation::initialize_federation_db(&store.root)?;
let node = federation::add_node(
store,
&format!("Decision Session: {title}"),
"decision",
"notable",
"agent_inferred",
&format!("Architecture decision session for tree '{tree_id}'. Session ID: {session_id}"),
&format!("cmd:decide.session.start.{session_id}"),
&format!("decide,session,{tree_id}"),
"repo",
None,
actor,
)?;
Ok(node.id)
}
fn create_decision_federation_node(
store: &Store,
decision_id: &str,
session_fed_node_id: &str,
question_text: &str,
chosen_label: &str,
tree_id: &str,
actor: &str,
) -> Result<String, error::DecapodError> {
federation::initialize_federation_db(&store.root)?;
let node = federation::add_node(
store,
&format!("{question_text} -> {chosen_label}"),
"decision",
"background",
"agent_inferred",
&format!("Chose '{chosen_label}' for '{question_text}'. Decision ID: {decision_id}"),
&format!("cmd:decide.record.{decision_id}"),
&format!("decide,answer,{tree_id}"),
"repo",
None,
actor,
)?;
federation::add_edge(store, &node.id, session_fed_node_id, "depends_on")?;
Ok(node.id)
}
pub fn start_session(
store: &Store,
tree_id: &str,
title: &str,
description: &str,
actor: &str,
) -> Result<DecisionSession, error::DecapodError> {
let tree = find_tree(tree_id)?;
let _ = tree;
let broker = DbBroker::new(&store.root);
let db_path = decide_db_path(&store.root);
let now = now_ts();
let session_id = format!("DS_{}", crate::core::ulid::new_ulid());
let fed_node_id = create_session_federation_node(store, &session_id, tree_id, title, actor)?;
let session = broker.with_conn(&db_path, actor, None, "decide.start", |conn| {
conn.execute(
"INSERT INTO sessions(id, tree_id, title, description, status, federation_node_id, created_at, updated_at, dir_path, scope, actor)
VALUES(?1, ?2, ?3, ?4, 'active', ?5, ?6, ?7, ?8, 'repo', ?9)",
params![
session_id,
tree_id,
title,
description,
fed_node_id,
now,
now,
store.root.to_string_lossy().to_string(),
actor,
],
)?;
Ok(DecisionSession {
id: session_id.clone(),
tree_id: tree_id.to_string(),
title: title.to_string(),
description: description.to_string(),
status: "active".to_string(),
federation_node_id: Some(fed_node_id),
created_at: now.clone(),
updated_at: now,
completed_at: None,
actor: actor.to_string(),
decisions: None,
})
})?;
Ok(session)
}
pub fn record_decision(
store: &Store,
session_id: &str,
question_id: &str,
value: &str,
rationale: &str,
actor: &str,
) -> Result<Decision, error::DecapodError> {
let broker = DbBroker::new(&store.root);
let db_path = decide_db_path(&store.root);
let (tree_id, session_fed_node_id) =
broker.with_conn(&db_path, actor, None, "decide.record.lookup", |conn| {
let mut stmt = conn.prepare(
"SELECT tree_id, federation_node_id, status FROM sessions WHERE id = ?1",
)?;
let row = stmt
.query_row(params![session_id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, String>(2)?,
))
})
.map_err(|_| {
error::DecapodError::NotFound(format!("Session '{session_id}' not found"))
})?;
if row.2 != "active" {
return Err(error::DecapodError::ValidationError(format!(
"Session '{}' is '{}', not 'active'",
session_id, row.2
)));
}
Ok((row.0, row.1))
})?;
let tree = find_tree(&tree_id)?;
let question = find_question(tree, question_id)?;
let option = find_option(question, value)?;
let now = now_ts();
let decision_id = format!("DD_{}", crate::core::ulid::new_ulid());
let fed_node_id = if let Some(ref session_fed_id) = session_fed_node_id {
Some(create_decision_federation_node(
store,
&decision_id,
session_fed_id,
question.prompt,
option.label,
&tree_id,
actor,
)?)
} else {
None
};
let decision = broker.with_conn(&db_path, actor, None, "decide.record", |conn| {
let exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM decisions WHERE session_id = ?1 AND question_id = ?2",
params![session_id, question_id],
|row| row.get::<_, i64>(0),
)
.map(|c| c > 0)?;
if exists {
return Err(error::DecapodError::ValidationError(format!(
"Question '{question_id}' already answered in session '{session_id}'"
)));
}
conn.execute(
"INSERT INTO decisions(id, session_id, question_id, tree_id, question_text, chosen_value, chosen_label, rationale, federation_node_id, created_at, actor)
VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
params![
decision_id,
session_id,
question_id,
tree_id,
question.prompt,
value,
option.label,
rationale,
fed_node_id,
now,
actor,
],
)?;
conn.execute(
"UPDATE sessions SET updated_at = ?1 WHERE id = ?2",
params![now, session_id],
)?;
Ok(Decision {
id: decision_id,
session_id: session_id.to_string(),
question_id: question_id.to_string(),
tree_id: tree_id.clone(),
question_text: question.prompt.to_string(),
chosen_value: value.to_string(),
chosen_label: option.label.to_string(),
rationale: rationale.to_string(),
user_note: String::new(),
federation_node_id: fed_node_id,
created_at: now,
actor: actor.to_string(),
})
})?;
Ok(decision)
}
pub fn complete_session(store: &Store, session_id: &str) -> Result<(), error::DecapodError> {
let broker = DbBroker::new(&store.root);
let db_path = decide_db_path(&store.root);
let now = now_ts();
broker.with_conn(&db_path, "cli", None, "decide.complete", |conn| {
let updated = conn.execute(
"UPDATE sessions SET status = 'completed', completed_at = ?1, updated_at = ?1 WHERE id = ?2 AND status = 'active'",
params![now, session_id],
)?;
if updated == 0 {
return Err(error::DecapodError::NotFound(format!(
"Active session '{session_id}' not found"
)));
}
Ok(())
})?;
Ok(())
}
pub fn get_session(
store: &Store,
session_id: &str,
) -> Result<DecisionSession, error::DecapodError> {
let broker = DbBroker::new(&store.root);
let db_path = decide_db_path(&store.root);
broker.with_conn(&db_path, "cli", None, "decide.session.get", |conn| {
let mut stmt = conn.prepare(
"SELECT id, tree_id, title, description, status, federation_node_id, created_at, updated_at, completed_at, actor
FROM sessions WHERE id = ?1",
)?;
let session = stmt
.query_row(params![session_id], |row| {
Ok(DecisionSession {
id: row.get(0)?,
tree_id: row.get(1)?,
title: row.get(2)?,
description: row.get(3)?,
status: row.get(4)?,
federation_node_id: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
completed_at: row.get(8)?,
actor: row.get(9)?,
decisions: None,
})
})
.map_err(|_| {
error::DecapodError::NotFound(format!("Session '{session_id}' not found"))
})?;
let mut dstmt = conn.prepare(
"SELECT id, session_id, question_id, tree_id, question_text, chosen_value, chosen_label, rationale, user_note, federation_node_id, created_at, actor
FROM decisions WHERE session_id = ?1 ORDER BY created_at",
)?;
let decisions: Vec<Decision> = dstmt
.query_map(params![session_id], |row| {
Ok(Decision {
id: row.get(0)?,
session_id: row.get(1)?,
question_id: row.get(2)?,
tree_id: row.get(3)?,
question_text: row.get(4)?,
chosen_value: row.get(5)?,
chosen_label: row.get(6)?,
rationale: row.get(7)?,
user_note: row.get(8)?,
federation_node_id: row.get(9)?,
created_at: row.get(10)?,
actor: row.get(11)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(DecisionSession {
decisions: Some(decisions),
..session
})
})
}
pub fn list_sessions(
store: &Store,
status_filter: Option<&str>,
) -> Result<Vec<DecisionSession>, error::DecapodError> {
let broker = DbBroker::new(&store.root);
let db_path = decide_db_path(&store.root);
broker.with_conn(&db_path, "cli", None, "decide.session.list", |conn| {
let (sql, params_vec): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
if let Some(status) = status_filter {
(
"SELECT id, tree_id, title, description, status, federation_node_id, created_at, updated_at, completed_at, actor
FROM sessions WHERE status = ?1 ORDER BY created_at DESC".to_string(),
vec![Box::new(status.to_string())],
)
} else {
(
"SELECT id, tree_id, title, description, status, federation_node_id, created_at, updated_at, completed_at, actor
FROM sessions ORDER BY created_at DESC".to_string(),
vec![],
)
};
let mut stmt = conn.prepare(&sql)?;
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
params_vec.iter().map(|p| p.as_ref()).collect();
let sessions: Vec<DecisionSession> = stmt
.query_map(params_refs.as_slice(), |row| {
Ok(DecisionSession {
id: row.get(0)?,
tree_id: row.get(1)?,
title: row.get(2)?,
description: row.get(3)?,
status: row.get(4)?,
federation_node_id: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
completed_at: row.get(8)?,
actor: row.get(9)?,
decisions: None,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(sessions)
})
}
pub fn list_decisions(
store: &Store,
session_filter: Option<&str>,
tree_filter: Option<&str>,
) -> Result<Vec<Decision>, error::DecapodError> {
let broker = DbBroker::new(&store.root);
let db_path = decide_db_path(&store.root);
broker.with_conn(&db_path, "cli", None, "decide.list", |conn| {
let mut conditions: Vec<String> = vec![];
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = vec![];
if let Some(sid) = session_filter {
conditions.push(format!("session_id = ?{}", param_values.len() + 1));
param_values.push(Box::new(sid.to_string()));
}
if let Some(tid) = tree_filter {
conditions.push(format!("tree_id = ?{}", param_values.len() + 1));
param_values.push(Box::new(tid.to_string()));
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!(" WHERE {}", conditions.join(" AND "))
};
let sql = format!(
"SELECT id, session_id, question_id, tree_id, question_text, chosen_value, chosen_label, rationale, user_note, federation_node_id, created_at, actor
FROM decisions{where_clause} ORDER BY created_at"
);
let mut stmt = conn.prepare(&sql)?;
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
param_values.iter().map(|p| p.as_ref()).collect();
let decisions: Vec<Decision> = stmt
.query_map(params_refs.as_slice(), |row| {
Ok(Decision {
id: row.get(0)?,
session_id: row.get(1)?,
question_id: row.get(2)?,
tree_id: row.get(3)?,
question_text: row.get(4)?,
chosen_value: row.get(5)?,
chosen_label: row.get(6)?,
rationale: row.get(7)?,
user_note: row.get(8)?,
federation_node_id: row.get(9)?,
created_at: row.get(10)?,
actor: row.get(11)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(decisions)
})
}
pub fn get_decision(store: &Store, decision_id: &str) -> Result<Decision, error::DecapodError> {
let broker = DbBroker::new(&store.root);
let db_path = decide_db_path(&store.root);
broker.with_conn(&db_path, "cli", None, "decide.get", |conn| {
let mut stmt = conn.prepare(
"SELECT id, session_id, question_id, tree_id, question_text, chosen_value, chosen_label, rationale, user_note, federation_node_id, created_at, actor
FROM decisions WHERE id = ?1",
)?;
stmt.query_row(params![decision_id], |row| {
Ok(Decision {
id: row.get(0)?,
session_id: row.get(1)?,
question_id: row.get(2)?,
tree_id: row.get(3)?,
question_text: row.get(4)?,
chosen_value: row.get(5)?,
chosen_label: row.get(6)?,
rationale: row.get(7)?,
user_note: row.get(8)?,
federation_node_id: row.get(9)?,
created_at: row.get(10)?,
actor: row.get(11)?,
})
})
.map_err(|_| {
error::DecapodError::NotFound(format!("Decision '{decision_id}' not found"))
})
})
}
pub fn next_question(
store: &Store,
session_id: &str,
) -> Result<NextQuestionResult, error::DecapodError> {
let broker = DbBroker::new(&store.root);
let db_path = decide_db_path(&store.root);
let (tree_id, answered) =
broker.with_conn(&db_path, "cli", None, "decide.next.lookup", |conn| {
let tree_id: String = conn
.query_row(
"SELECT tree_id FROM sessions WHERE id = ?1 AND status = 'active'",
params![session_id],
|row| row.get(0),
)
.map_err(|_| {
error::DecapodError::NotFound(format!(
"Active session '{session_id}' not found"
))
})?;
let mut stmt = conn
.prepare("SELECT question_id, chosen_value FROM decisions WHERE session_id = ?1")?;
let answered: std::collections::HashMap<String, String> = stmt
.query_map(params![session_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?
.collect::<Result<_, _>>()?;
Ok((tree_id, answered))
})?;
let tree = find_tree(&tree_id)?;
match resolve_next_question(tree, &answered) {
Some(question) => {
let q_json = serde_json::json!({
"id": question.id,
"prompt": question.prompt,
"context": question.context,
"options": question.options.iter().map(|o| serde_json::json!({
"value": o.value,
"label": o.label,
"rationale": o.rationale,
})).collect::<Vec<_>>(),
});
Ok(NextQuestionResult {
question: Some(q_json),
complete: false,
message: None,
})
}
None => Ok(NextQuestionResult {
question: None,
complete: true,
message: Some("All questions answered for this session.".to_string()),
}),
}
}
pub fn schema() -> serde_json::Value {
serde_json::json!({
"name": "decide",
"version": "0.1.0",
"description": "Architecture decision prompting with curated engineering questions",
"commands": [
{ "name": "trees", "description": "List available decision trees" },
{ "name": "suggest", "description": "Suggest a tree for a given prompt" },
{ "name": "start", "description": "Start a new decision session" },
{ "name": "next", "description": "Get the next unanswered question" },
{ "name": "record", "description": "Record a decision answer" },
{ "name": "complete", "description": "Complete a decision session" },
{ "name": "list", "description": "List recorded decisions" },
{ "name": "get", "description": "Get a specific decision" },
{ "name": "session list", "description": "List decision sessions" },
{ "name": "session get", "description": "Get a session with all decisions" },
{ "name": "init", "description": "Initialize decisions database" },
{ "name": "schema", "description": "Print subsystem schema" }
],
"storage": ["decisions.db"],
"trees": decision_trees().iter().map(|t| serde_json::json!({
"id": t.id,
"name": t.name,
"question_count": t.questions.len(),
})).collect::<Vec<_>>(),
})
}
pub fn run_decide_cli(store: &Store, cli: DecideCli) -> Result<(), error::DecapodError> {
initialize_decide_db(&store.root)?;
match cli.command {
DecideCommand::Trees => {
let trees: Vec<serde_json::Value> = decision_trees()
.iter()
.map(|t| {
serde_json::json!({
"id": t.id,
"name": t.name,
"description": t.description,
"keywords": t.keywords,
"question_count": t.questions.len(),
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&trees).unwrap());
}
DecideCommand::Suggest { prompt } => {
let suggestions = suggest_trees(&prompt);
println!("{}", serde_json::to_string_pretty(&suggestions).unwrap());
}
DecideCommand::Start {
tree,
title,
description,
actor,
} => {
let session = start_session(store, &tree, &title, &description, &actor)?;
println!("{}", serde_json::to_string_pretty(&session).unwrap());
}
DecideCommand::Next { session } => {
let result = next_question(store, &session)?;
println!("{}", serde_json::to_string_pretty(&result).unwrap());
}
DecideCommand::Record {
session,
question,
value,
rationale,
actor,
} => {
let decision = record_decision(store, &session, &question, &value, &rationale, &actor)?;
println!("{}", serde_json::to_string_pretty(&decision).unwrap());
}
DecideCommand::Complete { session } => {
complete_session(store, &session)?;
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"session_id": session,
"status": "completed",
}))
.unwrap()
);
}
DecideCommand::List { session, tree } => {
let decisions = list_decisions(store, session.as_deref(), tree.as_deref())?;
println!("{}", serde_json::to_string_pretty(&decisions).unwrap());
}
DecideCommand::Get { id } => {
let decision = get_decision(store, &id)?;
println!("{}", serde_json::to_string_pretty(&decision).unwrap());
}
DecideCommand::Session { command } => match command {
SessionSubCommand::List { status } => {
let sessions = list_sessions(store, status.as_deref())?;
println!("{}", serde_json::to_string_pretty(&sessions).unwrap());
}
SessionSubCommand::Get { id } => {
let session = get_session(store, &id)?;
println!("{}", serde_json::to_string_pretty(&session).unwrap());
}
},
DecideCommand::Init => {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"status": "ok",
"message": "Decisions database initialized",
}))
.unwrap()
);
}
DecideCommand::Schema => {
println!("{}", serde_json::to_string_pretty(&schema()).unwrap());
}
}
Ok(())
}