pub(crate) mod cli;
pub mod constitution;
pub mod core;
pub mod plugins;
pub(crate) mod subsystems;
use cli::*;
use core::{
db, docs, docs_cli, error, flight_recorder, migration, obligation, plan_governance, proof,
repomap, scaffold, state_commit,
store::{Store, StoreKind},
todo, trace, validate, workspace,
};
use plugins::{
aptitude, archive, container, context, cron, decide, doctor, eval, federation, feedback,
health, internalize, knowledge, lcm, map_ops, policy, primitives, reflex, verify, watcher,
workflow,
};
use clap::{CommandFactory, Parser};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::io::IsTerminal;
use std::io::Read;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::OnceLock;
use std::sync::mpsc;
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};
fn find_decapod_project_root(start_dir: &Path) -> Result<PathBuf, error::DecapodError> {
let mut current_dir = PathBuf::from(start_dir);
loop {
if current_dir.join(".decapod").exists() {
return Ok(current_dir);
}
if !current_dir.pop() {
return Err(error::DecapodError::NotFound(
"'.decapod' directory not found in current or parent directories. Run `decapod init` first.".to_string(),
));
}
}
}
static SESSION_PASSWORD: OnceLock<String> = OnceLock::new();
fn clean_project(dir: Option<PathBuf>) -> Result<(), error::DecapodError> {
let raw_dir = match dir {
Some(d) => d,
None => std::env::current_dir()?,
};
let target_dir = std::fs::canonicalize(&raw_dir).map_err(error::DecapodError::IoError)?;
let decapod_root = target_dir.join(".decapod");
if decapod_root.exists() {
println!("Removing directory: {}", decapod_root.display());
fs::remove_dir_all(&decapod_root).map_err(error::DecapodError::IoError)?;
}
for file in ["AGENTS.md", "CLAUDE.md", "GEMINI.md", "CODEX.md"] {
let path = target_dir.join(file);
if path.exists() {
println!("Removing file: {}", path.display());
fs::remove_file(&path).map_err(error::DecapodError::IoError)?;
}
}
println!("Decapod files cleaned from {}", target_dir.display());
Ok(())
}
fn decapod_config_path(target_dir: &Path) -> PathBuf {
target_dir.join(".decapod").join("config.toml")
}
fn load_project_config_if_present(
target_dir: &Path,
) -> Result<Option<DecapodProjectConfig>, error::DecapodError> {
let config_path = decapod_config_path(target_dir);
if !config_path.exists() {
return Ok(None);
}
let raw = fs::read_to_string(&config_path).map_err(error::DecapodError::IoError)?;
let cfg: DecapodProjectConfig = toml::from_str(&raw).map_err(|e| {
error::DecapodError::ValidationError(format!("AUTOREMEDIABLE_VALIDATION_ERROR code=INVALID_CONFIG_SCHEMA severity=transient auto_remediable=true audience=agent agent_action=\"fix the .decapod/config.toml schema to be valid TOML\" user_note=\"Configuration file schema is invalid; the agent should correct the file or report the issue.\"\nInvalid .decapod/config.toml schema: {}", e))
})?;
Ok(Some(cfg))
}
fn write_project_config(
target_dir: &Path,
config: &DecapodProjectConfig,
dry_run: bool,
) -> Result<(), error::DecapodError> {
if dry_run {
return Ok(());
}
let config_path = decapod_config_path(target_dir);
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent).map_err(error::DecapodError::IoError)?;
}
let serialized = toml::to_string_pretty(config).map_err(|e| {
error::DecapodError::ValidationError(format!("AUTOREMEDIABLE_VALIDATION_ERROR code=CONFIG_SERIALIZE_FAILED severity=transient auto_remediable=true audience=agent agent_action=\"ensure the .decapod/config.toml data can be serialized (e.g., fix data types)\" user_note=\"Failed to serialize configuration; the agent should adjust the config content or report the issue.\"\nFailed to serialize config.toml: {}", e))
})?;
fs::write(config_path, serialized).map_err(error::DecapodError::IoError)?;
Ok(())
}
fn seed_init_generated_state(target_dir: &Path, dry_run: bool) -> Result<(), error::DecapodError> {
if dry_run {
return Ok(());
}
let _ = docs_cli::sync_override_checksum(target_dir, false)?;
Ok(())
}
fn is_not_git_repository_error(err: &error::DecapodError) -> bool {
matches!(
err,
error::DecapodError::ValidationError(message)
if message.contains("Not in a git repository")
)
}
fn infer_repo_context(target_dir: &Path) -> RepoContext {
let mut ctx = RepoContext {
product_name: target_dir
.file_name()
.and_then(|s| s.to_str())
.map(|s| s.to_string()),
..RepoContext::default()
};
if target_dir.join("Cargo.toml").exists() {
ctx.primary_languages.push("rust".to_string());
ctx.detected_surfaces.push("cargo".to_string());
if let Ok(raw) = fs::read_to_string(target_dir.join("Cargo.toml"))
&& let Ok(v) = toml::from_str::<toml::Value>(&raw)
&& let Some(name) = v
.get("package")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
{
ctx.product_name = Some(name.to_string());
}
}
if target_dir.join("package.json").exists() {
ctx.primary_languages
.push("typescript/javascript".to_string());
ctx.detected_surfaces.push("npm".to_string());
}
if target_dir.join("pyproject.toml").exists() || target_dir.join("requirements.txt").exists() {
ctx.primary_languages.push("python".to_string());
ctx.detected_surfaces.push("python".to_string());
}
if target_dir.join("go.mod").exists() {
ctx.primary_languages.push("go".to_string());
ctx.detected_surfaces.push("go".to_string());
}
infer_languages_from_source_files(target_dir, &mut ctx);
if target_dir.join("frontend").exists() || target_dir.join("web").exists() {
ctx.detected_surfaces.push("frontend".to_string());
}
if target_dir.join("api").exists()
|| target_dir.join("server").exists()
|| target_dir.join("backend").exists()
{
ctx.detected_surfaces.push("backend".to_string());
}
if ctx.detected_surfaces.iter().any(|s| s == "frontend") {
ctx.product_type = Some("application".to_string());
} else if !ctx.detected_surfaces.is_empty() || !ctx.primary_languages.is_empty() {
ctx.product_type = Some("service_or_library".to_string());
}
let intent_path = target_dir.join(core::project_specs::LOCAL_PROJECT_SPECS_INTENT);
if intent_path.exists()
&& let Ok(intent) = fs::read_to_string(intent_path)
&& let Some(summary) = core::project_specs::first_markdown_content_line(&intent)
{
ctx.product_summary = Some(summary);
}
let architecture_path = target_dir.join(core::project_specs::LOCAL_PROJECT_SPECS_ARCHITECTURE);
if architecture_path.exists()
&& let Ok(arch) = fs::read_to_string(architecture_path)
&& let Some(direction) = core::project_specs::first_markdown_content_line(&arch)
{
ctx.architecture_direction = Some(direction);
}
if ctx.product_summary.is_none() {
let readme_path = target_dir.join("README.md");
if readme_path.exists()
&& let Ok(readme) = fs::read_to_string(readme_path)
&& let Some(summary) = core::project_specs::first_markdown_content_line(&readme)
{
ctx.product_summary = Some(summary);
}
}
if ctx.product_summary.is_none() {
ctx.product_summary = Some(match ctx.product_name.as_deref() {
Some(name) => format!("Deliver {} against explicit user intent with proof-backed completion.", name),
None => "Deliver the repository outcome against explicit user intent with proof-backed completion.".to_string(),
});
}
if ctx.architecture_direction.is_none() {
let has_frontend = ctx.detected_surfaces.iter().any(|s| s == "frontend");
let has_backend = ctx.detected_surfaces.iter().any(|s| s == "backend");
let inferred = match (has_frontend, has_backend) {
(true, true) => {
"Layered frontend/backend system with explicit contracts, isolated mutation boundaries, and proof-gated promotion."
}
(true, false) => {
"Frontend-first architecture with explicit API boundaries and deterministic validation gates."
}
(false, true) => {
"Service-oriented backend with clear interface boundaries, durable state ownership, and proof-gated releases."
}
(false, false) => {
"Composable repository architecture with explicit boundaries and proof-backed delivery invariants."
}
};
ctx.architecture_direction = Some(inferred.to_string());
}
if ctx.done_criteria.is_none() {
ctx.done_criteria = Some(
"Decapod validate passes, required tests pass, and promotion-relevant artifacts are present."
.to_string(),
);
}
ctx.primary_languages.sort();
ctx.primary_languages.dedup();
ctx.detected_surfaces.sort();
ctx.detected_surfaces.dedup();
ctx
}
fn infer_languages_from_source_files(target_dir: &Path, ctx: &mut RepoContext) {
fn visit(dir: &Path, remaining: &mut usize, ctx: &mut RepoContext) {
if *remaining == 0 {
return;
}
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
if *remaining == 0 {
return;
}
let path = entry.path();
let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
if path.is_dir() {
if matches!(
name,
".git" | ".decapod" | "target" | "node_modules" | "dist" | "build"
) {
continue;
}
visit(&path, remaining, ctx);
continue;
}
*remaining -= 1;
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
match ext {
"py" => {
ctx.primary_languages.push("python".to_string());
ctx.detected_surfaces.push("python".to_string());
}
"ts" | "tsx" => {
ctx.primary_languages.push("typescript".to_string());
ctx.detected_surfaces.push("typescript".to_string());
}
"js" | "jsx" | "mjs" | "cjs" => {
ctx.primary_languages.push("javascript".to_string());
ctx.detected_surfaces.push("javascript".to_string());
}
"go" => {
ctx.primary_languages.push("go".to_string());
ctx.detected_surfaces.push("go".to_string());
}
"rs" => {
ctx.primary_languages.push("rust".to_string());
ctx.detected_surfaces.push("rust".to_string());
}
"sh" | "bash" | "zsh" => {
ctx.primary_languages.push("shell".to_string());
ctx.detected_surfaces.push("shell".to_string());
}
_ => {}
}
}
}
let mut remaining = 512;
visit(target_dir, &mut remaining, ctx);
}
fn read_seed_list_env(var: &str) -> Vec<String> {
std::env::var(var)
.ok()
.map(|v| {
v.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
fn dedupe_sorted(list: &mut Vec<String>) {
list.sort();
list.dedup();
}
fn apply_substrate_adoption(ctx: &mut RepoContext, target_dir: &Path) {
if let Some(override_intent) = core::assets::get_override_doc(target_dir, "specs/INTENT")
&& let Some(summary) = core::project_specs::first_markdown_content_line(&override_intent)
{
ctx.product_summary = Some(summary);
}
if ctx.product_summary.is_none() {
let intent_path = target_dir.join(core::project_specs::LOCAL_PROJECT_SPECS_INTENT);
if intent_path.exists()
&& let Ok(intent) = fs::read_to_string(intent_path)
&& let Some(summary) = core::project_specs::first_markdown_content_line(&intent)
{
ctx.product_summary = Some(summary);
}
}
if ctx.product_summary.is_none() {
let readme_path = target_dir.join("README.md");
if readme_path.exists()
&& let Ok(readme) = fs::read_to_string(readme_path)
&& let Some(summary) = core::project_specs::first_markdown_content_line(&readme)
{
ctx.product_summary = Some(summary);
}
}
if let Some(override_arch) = core::assets::get_override_doc(target_dir, "specs/ARCHITECTURE")
&& let Some(direction) = core::project_specs::first_markdown_content_line(&override_arch)
{
ctx.architecture_direction = Some(direction);
}
if ctx.architecture_direction.is_none() {
let architecture_path =
target_dir.join(core::project_specs::LOCAL_PROJECT_SPECS_ARCHITECTURE);
if architecture_path.exists()
&& let Ok(arch) = fs::read_to_string(architecture_path)
&& let Some(direction) = core::project_specs::first_markdown_content_line(&arch)
{
ctx.architecture_direction = Some(direction);
}
}
}
fn apply_repo_context_env_overrides(ctx: &mut RepoContext) {
if let Ok(v) = std::env::var("DECAPOD_INIT_PRODUCT_NAME") {
let trimmed = v.trim();
if !trimmed.is_empty() {
ctx.product_name = Some(trimmed.to_string());
}
}
if let Ok(v) = std::env::var("DECAPOD_INIT_PRODUCT_SUMMARY") {
let trimmed = v.trim();
if !trimmed.is_empty() {
ctx.product_summary = Some(trimmed.to_string());
}
}
if let Ok(v) = std::env::var("DECAPOD_INIT_ARCHITECTURE_DIRECTION") {
let trimmed = v.trim();
if !trimmed.is_empty() {
ctx.architecture_direction = Some(trimmed.to_string());
}
}
if let Ok(v) = std::env::var("DECAPOD_INIT_PRODUCT_TYPE") {
let trimmed = v.trim();
if !trimmed.is_empty() {
ctx.product_type = Some(trimmed.to_string());
}
}
if let Ok(v) = std::env::var("DECAPOD_INIT_DONE_CRITERIA") {
let trimmed = v.trim();
if !trimmed.is_empty() {
ctx.done_criteria = Some(trimmed.to_string());
}
}
if std::env::var("DECAPOD_INIT_PRIMARY_LANGUAGES").is_ok() {
ctx.primary_languages = read_seed_list_env("DECAPOD_INIT_PRIMARY_LANGUAGES");
}
if std::env::var("DECAPOD_INIT_SURFACES").is_ok() {
ctx.detected_surfaces = read_seed_list_env("DECAPOD_INIT_SURFACES");
}
dedupe_sorted(&mut ctx.primary_languages);
dedupe_sorted(&mut ctx.detected_surfaces);
}
fn apply_repo_context_cli_overrides(ctx: &mut RepoContext, init_with: &InitWithCli) {
if let Some(v) = init_with.product_name.as_ref() {
let trimmed = v.trim();
if !trimmed.is_empty() {
ctx.product_name = Some(trimmed.to_string());
}
}
if let Some(v) = init_with.product_summary.as_ref() {
let trimmed = v.trim();
if !trimmed.is_empty() {
ctx.product_summary = Some(trimmed.to_string());
}
}
if let Some(v) = init_with.architecture_direction.as_ref() {
let trimmed = v.trim();
if !trimmed.is_empty() {
ctx.architecture_direction = Some(trimmed.to_string());
}
}
if let Some(v) = init_with.product_type.as_ref() {
let trimmed = v.trim();
if !trimmed.is_empty() {
ctx.product_type = Some(trimmed.to_string());
}
}
if let Some(v) = init_with.done_criteria.as_ref() {
let trimmed = v.trim();
if !trimmed.is_empty() {
ctx.done_criteria = Some(trimmed.to_string());
}
}
if !init_with.primary_languages.is_empty() {
ctx.primary_languages = init_with
.primary_languages
.iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
if !init_with.detected_surfaces.is_empty() {
ctx.detected_surfaces = init_with
.detected_surfaces
.iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
dedupe_sorted(&mut ctx.primary_languages);
dedupe_sorted(&mut ctx.detected_surfaces);
}
fn prompt_line(prompt: &str) -> Result<String, error::DecapodError> {
print!("{}", prompt);
io::stdout().flush().map_err(error::DecapodError::IoError)?;
let mut buf = String::new();
io::stdin()
.read_line(&mut buf)
.map_err(error::DecapodError::IoError)?;
Ok(strip_ansi_escape_sequences(buf.trim()).trim().to_string())
}
fn strip_ansi_escape_sequences(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if ch != '\x1b' {
out.push(ch);
continue;
}
if chars.peek() == Some(&'[') {
chars.next();
for next in chars.by_ref() {
if ('@'..='~').contains(&next) {
break;
}
}
}
}
out
}
const LANGUAGES: &[&str] = &[
"Rust",
"TypeScript",
"JavaScript",
"Python",
"Go",
"Java",
"Kotlin",
"Swift",
"C",
"C++",
"C#",
"Zig",
"Ruby",
"PHP",
"Elixir",
"Erlang",
"Scala",
"Clojure",
"Dart",
"Haskell",
"OCaml",
"F#",
"Lua",
"R",
"Julia",
"SQL",
"HCL",
"Shell",
"PowerShell",
"Other",
];
const ARCH_DIRECTIONS: &[(&str, &str)] = &[
("webapp", "Web application (TypeScript, React/Vue/Svelte)"),
("microservice", "Microservice (Go, Rust, or Java)"),
("library", "Library/SDK (language-agnostic)"),
("cli", "Command-line tool (Rust, Go, Python)"),
("lambda", "Lambda/Serverless (Python, TypeScript, Go)"),
("mobile-android", "Android (Kotlin, Java)"),
("mobile-ios", "iOS (Swift)"),
("multiarch", "Multi-platform (Rust, C/C++)"),
("infra", "Infrastructure/Terraform (HCL, Python)"),
("data", "Data pipeline (Python, SQL)"),
];
const DIAGRAM_NOTATION_OPTIONS: &[&str] = &["ascii", "mermaid"];
const DIAGRAM_NOTATION_DESCRIPTIONS: &[&str] = &[
"ASCII/text blocks readable in terminals and plain Markdown",
"Mermaid diagrams rendered by GitHub Markdown from readable text source",
];
const SELECTOR_VISIBLE_OPTIONS: usize = 10;
fn normalize_language(input: &str) -> String {
match input.trim().to_lowercase().as_str() {
"ts" | "typescript" => "TypeScript".to_string(),
"js" | "javascript" => "JavaScript".to_string(),
"py" | "python" => "Python".to_string(),
"rs" | "rust" => "Rust".to_string(),
"golang" | "go" => "Go".to_string(),
"kt" | "kotlin" => "Kotlin".to_string(),
"swift" => "Swift".to_string(),
"c" => "C".to_string(),
"cpp" | "c++" | "cplusplus" => "C++".to_string(),
"csharp" | "c#" => "C#".to_string(),
"zig" => "Zig".to_string(),
"rb" | "ruby" => "Ruby".to_string(),
"php" => "PHP".to_string(),
"ex" | "elixir" => "Elixir".to_string(),
"erl" | "erlang" => "Erlang".to_string(),
"scala" => "Scala".to_string(),
"clj" | "clojure" => "Clojure".to_string(),
"dart" => "Dart".to_string(),
"hs" | "haskell" => "Haskell".to_string(),
"ml" | "ocaml" => "OCaml".to_string(),
"fs" | "fsharp" | "f#" => "F#".to_string(),
"lua" => "Lua".to_string(),
"r" => "R".to_string(),
"jl" | "julia" => "Julia".to_string(),
"sql" => "SQL".to_string(),
"terraform" | "tf" | "hcl" => "HCL".to_string(),
"bash" | "sh" | "shell" => "Shell".to_string(),
"pwsh" | "powershell" => "PowerShell".to_string(),
"other" => "Other".to_string(),
_ => input.trim().to_string(),
}
}
fn language_choice_seed(current: &[String], recommendation: &[String]) -> Vec<String> {
if !current.is_empty() {
return current.iter().map(|s| normalize_language(s)).collect();
}
recommendation
.iter()
.map(|s| normalize_language(s))
.collect()
}
fn apply_architecture_language_recommendation(ctx: &mut RepoContext) {
if !ctx.primary_languages.is_empty() {
return;
}
if let Some(arch) = ctx.architecture_direction.as_deref() {
ctx.primary_languages = infer_language_from_architecture(arch);
}
}
struct TerminalModeGuard {
saved_mode: String,
tty: fs::File,
}
impl Drop for TerminalModeGuard {
fn drop(&mut self) {
if let Ok(tty) = self.tty.try_clone() {
let _ = std::process::Command::new("stty")
.arg(&self.saved_mode)
.stdin(Stdio::from(tty))
.status();
}
print!("\x1b[?25h");
println!();
}
}
fn enter_raw_terminal_mode() -> Option<(TerminalModeGuard, fs::File)> {
let tty = fs::OpenOptions::new()
.read(true)
.write(true)
.open("/dev/tty")
.ok()?;
let output = std::process::Command::new("stty")
.arg("-g")
.stdin(Stdio::from(tty.try_clone().ok()?))
.output()
.ok()?;
if !output.status.success() {
return None;
}
let saved_mode = String::from_utf8(output.stdout).ok()?.trim().to_string();
let status = std::process::Command::new("stty")
.args(["raw", "-echo"])
.stdin(Stdio::from(tty.try_clone().ok()?))
.status()
.ok()?;
if !status.success() {
return None;
}
let input = tty.try_clone().ok()?;
Some((TerminalModeGuard { saved_mode, tty }, input))
}
fn terminal_selector_available(_default: &[String]) -> bool {
io::stdin().is_terminal()
}
fn find_selector_match(options: &[&str], typed: &str) -> Option<usize> {
let typed = typed.trim();
if typed.is_empty() {
return None;
}
if let Ok(index) = typed.parse::<usize>() {
return options.get(index.saturating_sub(1)).map(|_| index - 1);
}
let typed = typed.to_lowercase();
options
.iter()
.position(|option| option.eq_ignore_ascii_case(&typed))
.or_else(|| {
options
.iter()
.position(|option| option.to_lowercase().starts_with(&typed))
})
}
fn selector_shown(options: &[&str], selected: usize, typed: &str) -> String {
if typed.is_empty() {
return options[selected].to_string();
}
find_selector_match(options, typed)
.and_then(|index| options.get(index))
.map(|option| (*option).to_string())
.unwrap_or_else(|| typed.to_string())
}
fn selector_default_index(options: &[&str], default: &[String]) -> Option<usize> {
default
.first()
.and_then(|d| options.iter().position(|o| d.eq_ignore_ascii_case(o)))
}
fn selector_window_start(options_len: usize, selected: usize) -> usize {
if options_len <= SELECTOR_VISIBLE_OPTIONS {
0
} else {
selected.min(options_len - SELECTOR_VISIBLE_OPTIONS)
}
}
fn selector_render_lines(
options: &[&str],
descriptions: Option<&[&str]>,
selected: usize,
default_idx: Option<usize>,
typed: &str,
prompt: &str,
) -> Vec<String> {
let shown = selector_shown(options, selected, typed);
let input = if typed.is_empty() {
shown.clone()
} else {
typed.to_string()
};
let mut lines = vec![format!("{prompt}{input}")];
let window_start = selector_window_start(options.len(), selected);
let window_end = (window_start + SELECTOR_VISIBLE_OPTIONS).min(options.len());
if window_start > 0 {
lines.push(" ↑ more".to_string());
}
for (i, option) in options
.iter()
.enumerate()
.skip(window_start)
.take(window_end.saturating_sub(window_start))
{
let cursor = if i == selected { ">" } else { " " };
let marker = if default_idx == Some(i) { "✓" } else { " " };
let suffix = descriptions
.and_then(|items| items.get(i))
.map(|description| format!(" -> {description}"))
.unwrap_or_default();
lines.push(format!(
" {cursor} {marker} {:>2}. {option}{suffix}",
i + 1
));
}
if window_end < options.len() {
lines.push(" ↓ more".to_string());
} else if options.len() > SELECTOR_VISIBLE_OPTIONS {
lines.push(" ↓ wraps to 1".to_string());
}
lines
}
fn update_selector_from_byte(options: &[&str], selected: &mut usize, typed: &mut String, byte: u8) {
match byte {
8 | 127 => {
typed.pop();
if let Some(index) = find_selector_match(options, typed) {
*selected = index;
}
}
byte if byte.is_ascii_graphic() || byte == b' ' => {
typed.push(byte as char);
if let Some(index) = find_selector_match(options, typed) {
*selected = index;
}
}
_ => {}
}
}
#[cfg(test)]
fn selector_result_for_input(options: &[&str], default: &[String], input: &[u8]) -> String {
let mut selected = default
.first()
.and_then(|d| {
options
.iter()
.position(|option| d.eq_ignore_ascii_case(option))
})
.unwrap_or(0);
let mut typed = String::new();
let mut index = 0;
while index < input.len() {
match input[index] {
b'\r' | b'\n' => return selector_shown(options, selected, &typed),
27 if input.get(index + 1) == Some(&b'[') => {
match input.get(index + 2).copied() {
Some(b'A') => {
typed.clear();
selected = selected.checked_sub(1).unwrap_or_else(|| options.len() - 1);
}
Some(b'B') => {
typed.clear();
selected = (selected + 1) % options.len();
}
_ => {}
}
index += 3;
continue;
}
byte => update_selector_from_byte(options, &mut selected, &mut typed, byte),
}
index += 1;
}
selector_shown(options, selected, &typed)
}
#[cfg(test)]
fn selector_render_for_input(
options: &[&str],
descriptions: Option<&[&str]>,
default: &[String],
input: &[u8],
) -> Vec<String> {
let mut selected = selector_default_index(options, default).unwrap_or(0);
let mut typed = String::new();
let mut index = 0;
while index < input.len() {
match input[index] {
b'\r' | b'\n' => break,
27 if input.get(index + 1) == Some(&b'[') => {
match input.get(index + 2).copied() {
Some(b'A') => {
typed.clear();
selected = selected.checked_sub(1).unwrap_or_else(|| options.len() - 1);
}
Some(b'B') => {
typed.clear();
selected = (selected + 1) % options.len();
}
_ => {}
}
index += 3;
continue;
}
byte => update_selector_from_byte(options, &mut selected, &mut typed, byte),
}
index += 1;
}
selector_render_lines(
options,
descriptions,
selected,
selector_default_index(options, default),
&typed,
" choice: ",
)
}
fn prompt_select_fallback(
options: &[&str],
default: &[String],
prompt: &str,
) -> Result<Option<String>, error::DecapodError> {
if options.is_empty() {
return Ok(None);
}
let default_idx = default
.first()
.and_then(|d| options.iter().position(|o| d.eq_ignore_ascii_case(o)));
for (i, opt) in options.iter().enumerate() {
let marker = if default_idx == Some(i) { "✓" } else { " " };
println!(" {} {:>2}. {}", marker, i + 1, opt);
}
let default_val = default_idx.map(|i| i + 1).unwrap_or(1);
loop {
println!();
let line = prompt_line(&format!(
"{prompt}[1-{}, default={default_val}]: ",
options.len()
))?;
let trimmed = line.trim();
if trimmed.is_empty() {
return default_idx
.map(|i| Ok(Some(options[i].to_string())))
.unwrap_or(Ok(None));
}
if let Ok(n) = trimmed.parse::<usize>()
&& (1..=options.len()).contains(&n)
{
return Ok(Some(options[n - 1].to_string()));
}
if let Some(pos) = options.iter().position(|o| o.eq_ignore_ascii_case(trimmed)) {
return Ok(Some(options[pos].to_string()));
}
println!(
" Invalid choice. Enter a number (1-{}) or name.",
options.len()
);
}
}
fn prompt_terminal_selector(
options: &[&str],
descriptions: Option<&[&str]>,
default: &[String],
prompt: &str,
) -> Result<Option<String>, error::DecapodError> {
if options.is_empty() {
return Ok(None);
}
if !io::stdin().is_terminal() {
return prompt_select_fallback(options, default, prompt);
}
let mut selected = selector_default_index(options, default).unwrap_or(0);
let default_idx = selector_default_index(options, default);
let Some((_guard, mut input)) = enter_raw_terminal_mode() else {
return prompt_select_fallback(options, default, prompt);
};
let mut typed = String::new();
let mut rendered_lines = 0;
print!("\x1b[?25l");
loop {
if rendered_lines > 0 {
print!("\x1b[{rendered_lines}F");
}
let lines =
selector_render_lines(options, descriptions, selected, default_idx, &typed, prompt);
for line in &lines {
print!("\r\x1b[K{line}\n");
}
rendered_lines = lines.len();
io::stdout().flush().map_err(error::DecapodError::IoError)?;
let mut byte = [0_u8; 1];
input
.read_exact(&mut byte)
.map_err(error::DecapodError::IoError)?;
match byte[0] {
b'\r' | b'\n' => {
let shown = selector_shown(options, selected, &typed);
print!("\x1b[{rendered_lines}F");
print!("\r\x1b[K{prompt}{shown}\n");
for _ in 1..rendered_lines {
print!("\r\x1b[K\n");
}
print!(
"\x1b[{lines_up}F",
lines_up = rendered_lines.saturating_sub(1)
);
io::stdout().flush().map_err(error::DecapodError::IoError)?;
return Ok(Some(shown));
}
3 => {
return Err(error::DecapodError::ValidationError(
"init prompt interrupted".to_string(),
));
}
8 | 127 => {
update_selector_from_byte(options, &mut selected, &mut typed, byte[0]);
}
27 => {
let mut seq = [0_u8; 2];
if input.read_exact(&mut seq).is_ok() && seq[0] == b'[' {
match seq[1] {
b'A' => {
typed.clear();
selected = selected.checked_sub(1).unwrap_or_else(|| options.len() - 1);
}
b'B' => {
typed.clear();
selected = (selected + 1) % options.len();
}
_ => {}
}
}
}
byte => update_selector_from_byte(options, &mut selected, &mut typed, byte),
}
}
}
fn prompt_language_choice(
current: &[String],
recommendation: &[String],
) -> Result<Vec<String>, error::DecapodError> {
use crate::core::ansi::AnsiExt;
let inferred = if current.is_empty() {
"None".to_string()
} else {
current.join(", ")
};
let default = language_choice_seed(current, recommendation);
let default_label = if default.is_empty() {
"None".to_string()
} else {
default.join(", ")
};
let recommendation_label = if recommendation.is_empty() {
"None".to_string()
} else {
recommendation
.iter()
.map(|s| normalize_language(s))
.collect::<Vec<_>>()
.join(", ")
};
let supports_arrows = terminal_selector_available(&default);
println!();
println!("{}", " Primary language(s)".bright_white().bold());
println!(" inferred from files: {}", inferred);
println!(" recommended for architecture: {}", recommendation_label);
println!(" current selection/default: {}", default_label);
if supports_arrows {
println!(" options: up/down, type name or number, comma-separated for multiple");
} else {
println!(" options: type name or number, comma-separated for multiple");
}
println!(" press Enter to use the current selection/default");
let choice = match prompt_terminal_selector(LANGUAGES, None, &default, " choice: ")? {
Some(choice) => choice,
None => prompt_line(" choice: ")?,
};
if choice.is_empty() {
return Ok(default);
}
Ok(parse_language_choice(&choice))
}
fn parse_language_choice(choice: &str) -> Vec<String> {
choice
.split(',')
.map(|s| {
let trimmed = s.trim();
trimmed
.parse::<usize>()
.ok()
.and_then(|n| LANGUAGES.get(n.saturating_sub(1)))
.map(|lang| (*lang).to_string())
.unwrap_or_else(|| normalize_language(trimmed))
})
.filter(|s| !s.is_empty())
.collect()
}
fn infer_language_from_architecture(arch: &str) -> Vec<String> {
let arch = arch.to_lowercase();
match arch.as_str() {
"webapp" => vec!["TypeScript".to_string()],
"microservice" => vec!["Go".to_string()],
"library" => vec!["Rust".to_string()],
"cli" => vec!["Rust".to_string()],
"lambda" => vec!["Python".to_string()],
"mobile-android" => vec!["Kotlin".to_string()],
"mobile-ios" => vec!["Swift".to_string()],
"multiarch" => vec!["Rust".to_string()],
"infra" => vec!["HCL".to_string()],
"data" => vec!["Python".to_string()],
_ if arch.contains("web") || arch.contains("frontend") => vec!["TypeScript".to_string()],
_ if arch.contains("microservice") || arch.contains("backend") || arch.contains("api") => {
vec!["Go".to_string()]
}
_ if arch.contains("cli") || arch.contains("command-line") => vec!["Rust".to_string()],
_ if arch.contains("serverless") || arch.contains("lambda") => vec!["Python".to_string()],
_ if arch.contains("android") => vec!["Kotlin".to_string()],
_ if arch.contains("ios") => vec!["Swift".to_string()],
_ if arch.contains("embedded") || arch.contains("systems") => vec!["Zig".to_string()],
_ if arch.contains("infra") || arch.contains("terraform") => vec!["HCL".to_string()],
_ if arch.contains("data") || arch.contains("ml") => vec!["Python".to_string()],
_ => vec![],
}
}
fn prompt_architecture_choice(
current: Option<&str>,
) -> Result<Option<String>, error::DecapodError> {
use crate::core::ansi::AnsiExt;
let inferred = current.unwrap_or("None");
let current_matches_common = current.is_some_and(|c| {
ARCH_DIRECTIONS
.iter()
.any(|(arch, _)| c.eq_ignore_ascii_case(arch))
});
let default = if current_matches_common {
current.map(|s| vec![s.to_string()]).unwrap_or_default()
} else {
Vec::new()
};
let supports_arrows = terminal_selector_available(&default);
println!();
println!("{}", " Architecture".bright_white().bold());
println!(" inferred: {}", inferred);
println!(" current selection/default: {}", inferred);
println!(" common approaches:");
if supports_arrows {
println!(" options: up/down, type name or number, or type your architecture");
} else {
println!(" options: type name or number, or type your architecture");
}
let arch_options = ARCH_DIRECTIONS
.iter()
.map(|(arch, _)| *arch)
.collect::<Vec<_>>();
let arch_descriptions = ARCH_DIRECTIONS
.iter()
.map(|(_, description)| *description)
.collect::<Vec<_>>();
let choice = match prompt_terminal_selector(
&arch_options,
Some(&arch_descriptions),
&default,
" choice: ",
)? {
Some(choice) => choice,
None => prompt_line(" choice: ")?,
};
if choice.is_empty() {
return Ok(current.map(|s| s.to_string()));
}
if let Ok(index) = choice.parse::<usize>()
&& let Some((arch, _)) = ARCH_DIRECTIONS.get(index.saturating_sub(1))
{
return Ok(Some((*arch).to_string()));
}
Ok(Some(choice.trim().to_string()))
}
fn print_init_block(title: &str, subtitle: &str) {
use crate::core::ansi::AnsiExt;
println!();
println!("{}", format!("◢ {}", title).bright_cyan().bold());
println!("{}", format!(" {}", subtitle).bright_black());
}
fn prompt_text_field(
label: &str,
helper: &str,
default_value: &str,
) -> Result<String, error::DecapodError> {
use crate::core::ansi::AnsiExt;
println!();
println!("{}", format!(" {}", label).bright_white().bold());
println!("{}", format!(" {}", helper).bright_black());
println!(
"{}",
format!(" inferred: {}", default_value).bright_black()
);
let line = prompt_line(&format!("{}", " input: ".bright_cyan().bold()))?;
if line.trim().is_empty() {
Ok(default_value.to_string())
} else {
Ok(line)
}
}
fn prompt_line_default(prompt: &str, default_value: &str) -> Result<String, error::DecapodError> {
prompt_text_field(
prompt,
"Press Enter to keep inferred context.",
default_value,
)
}
fn prompt_yes_no(prompt: &str, default_yes: bool) -> Result<bool, error::DecapodError> {
use crate::core::ansi::AnsiExt;
let suffix = if default_yes { "[Y/n]" } else { "[y/N]" };
println!();
println!("{}", format!(" {}", prompt).bright_white().bold());
let line = prompt_line(&format!(
"{} {} ",
" choice:".bright_cyan().bold(),
suffix.bright_black()
))?;
if line.is_empty() {
return Ok(default_yes);
}
let normalized = line.to_ascii_lowercase();
Ok(matches!(normalized.as_str(), "y" | "yes"))
}
fn resolve_existing_init_dir(raw: &Path) -> Result<PathBuf, error::DecapodError> {
std::fs::canonicalize(raw).map_err(error::DecapodError::IoError)
}
fn resolve_or_create_project_dir(
current_dir: &Path,
raw: &Path,
dry_run: bool,
) -> Result<PathBuf, error::DecapodError> {
let candidate = if raw.is_absolute() {
raw.to_path_buf()
} else {
current_dir.join(raw)
};
if candidate.exists() && !candidate.is_dir() {
return Err(error::DecapodError::ValidationError(format!(
"project directory target '{}' exists but is not a directory",
candidate.display()
)));
}
if !dry_run {
std::fs::create_dir_all(&candidate).map_err(error::DecapodError::IoError)?;
}
if candidate.exists() {
std::fs::canonicalize(&candidate).map_err(error::DecapodError::IoError)
} else {
Ok(candidate)
}
}
fn prompt_init_target_dir(current_dir: &Path) -> Result<PathBuf, error::DecapodError> {
if prompt_yes_no("Initialize the existing current directory?", true)? {
return resolve_existing_init_dir(current_dir);
}
let project_name = prompt_text_field(
"Project directory name",
"Decapod will create this directory and initialize inside it.",
"my-project",
)?;
let project_name = project_name.trim();
if project_name.is_empty() {
return Err(error::DecapodError::ValidationError(
"Project directory name cannot be empty".to_string(),
));
}
resolve_or_create_project_dir(current_dir, Path::new(project_name), false)
}
fn diagram_style_choice(style: InitDiagramStyle) -> &'static str {
match style {
InitDiagramStyle::Ascii => "ascii",
InitDiagramStyle::Mermaid => "mermaid",
}
}
fn diagram_style_label(style: InitDiagramStyle) -> &'static str {
match style {
InitDiagramStyle::Ascii => "ascii/text",
InitDiagramStyle::Mermaid => "mermaid",
}
}
fn parse_diagram_style_choice(
raw: &str,
default_style: InitDiagramStyle,
) -> Result<InitDiagramStyle, error::DecapodError> {
let choice = raw.trim();
if choice.is_empty() {
return Ok(default_style);
}
match choice.to_ascii_lowercase().as_str() {
"1" | "ascii" | "ascii/text" | "ascii-text" | "text" | "plain" | "plain-text"
| "plaintext" => Ok(InitDiagramStyle::Ascii),
"2" | "mermaid" | "mmd" => Ok(InitDiagramStyle::Mermaid),
_ => Err(error::DecapodError::ValidationError(
"Invalid diagram notation; expected ascii/text or mermaid".to_string(),
)),
}
}
fn prompt_diagram_style(
default_style: InitDiagramStyle,
) -> Result<InitDiagramStyle, error::DecapodError> {
print_init_block(
"Diagram Notation",
"Choose the README-visible diagram notation Decapod should generate.",
);
println!(
" current selection/default: {}",
diagram_style_label(default_style).bright_white()
);
println!(" options: up/down, type name or number");
let default = vec![diagram_style_choice(default_style).to_string()];
let choice = prompt_terminal_selector(
DIAGRAM_NOTATION_OPTIONS,
Some(DIAGRAM_NOTATION_DESCRIPTIONS),
&default,
" choice: ",
)?
.unwrap_or_else(|| String::from(diagram_style_choice(default_style)));
parse_diagram_style_choice(&choice, default_style)
}
fn init_with_from_config(
config: &DecapodProjectConfig,
target_dir: PathBuf,
force: bool,
dry_run: bool,
) -> InitWithCli {
let has = |name: &str| config.init.entrypoints.iter().any(|e| e == name);
let all_entrypoints =
has("AGENTS.md") && has("CLAUDE.md") && has("GEMINI.md") && has("CODEX.md");
InitWithCli {
dir: Some(target_dir),
project_dir: None,
force,
dry_run,
all: all_entrypoints,
claude: has("CLAUDE.md"),
gemini: has("GEMINI.md"),
agents: has("AGENTS.md"),
specs: config.init.specs,
diagram_style: config.init.diagram_style,
product_name: config.repo.product_name.clone(),
product_summary: config.repo.product_summary.clone(),
architecture_direction: config.repo.architecture_direction.clone(),
product_type: config.repo.product_type.clone(),
done_criteria: config.repo.done_criteria.clone(),
primary_languages: config.repo.primary_languages.clone(),
detected_surfaces: config.repo.detected_surfaces.clone(),
}
}
fn config_from_init_with(init: &InitWithCli, repo: RepoContext) -> DecapodProjectConfig {
let mut entrypoints = Vec::new();
let no_entrypoint_flags = !init.claude && !init.gemini && !init.agents;
if init.all || init.agents || no_entrypoint_flags {
entrypoints.push("AGENTS.md".to_string());
}
if init.all || init.claude || (!init.gemini && !init.agents) {
entrypoints.push("CLAUDE.md".to_string());
}
if init.all || init.gemini || (!init.claude && !init.agents) {
entrypoints.push("GEMINI.md".to_string());
}
if init.all || no_entrypoint_flags {
entrypoints.push("CODEX.md".to_string());
}
DecapodProjectConfig {
schema_version: "1.0.0".to_string(),
init: InitConfigSection {
specs: init.specs,
diagram_style: init.diagram_style,
entrypoints,
},
repo,
}
}
fn interactive_init_with(
config: &DecapodProjectConfig,
target_dir: PathBuf,
force: bool,
dry_run: bool,
) -> Result<InitWithCli, error::DecapodError> {
print_init_block(
"Decapod Setup",
"Existing .decapod/config.toml detected. Confirm your setup profile.",
);
let mut next = init_with_from_config(config, target_dir, force, dry_run);
if config.init.entrypoints.is_empty() {
let all_entrypoints = prompt_yes_no(
"Include all default agent entrypoints (AGENTS/CLAUDE/GEMINI/CODEX)?",
true,
)?;
if all_entrypoints {
next.all = true;
next.agents = true;
next.claude = true;
next.gemini = true;
}
}
if config.init.specs {
next.specs = true;
}
next.diagram_style = prompt_diagram_style(next.diagram_style)?;
Ok(next)
}
fn enrich_repo_context_interactive(repo: &mut RepoContext) -> Result<(), error::DecapodError> {
print_init_block(
"Repository Context",
"Review inferred intent before generating .decapod/generated/specs/.",
);
let current_summary = repo.product_summary.clone().unwrap_or_else(|| {
"Deliver the repository outcome against explicit user intent with proof-backed completion."
.to_string()
});
repo.product_summary = Some(prompt_line_default("Intent outcome", ¤t_summary)?);
repo.architecture_direction =
prompt_architecture_choice(repo.architecture_direction.as_deref())?;
let recommended_languages = repo
.architecture_direction
.as_deref()
.map(infer_language_from_architecture)
.unwrap_or_default();
repo.primary_languages =
prompt_language_choice(&repo.primary_languages, &recommended_languages)?;
let refine_now = prompt_yes_no(
"Refine done criteria now? (You can evolve .decapod/config.toml and .decapod/generated/specs/*.md later.)",
false,
)?;
if refine_now {
let current_done = repo.done_criteria.clone().unwrap_or_else(|| {
"Decapod validate passes, required tests pass, and promotion-relevant artifacts are present."
.to_string()
});
repo.done_criteria = Some(prompt_line_default("Done criteria", ¤t_done)?);
}
Ok(())
}
fn run_init_apply(
init_with: &InitWithCli,
current_dir: &Path,
repo_ctx: &RepoContext,
) -> Result<PathBuf, error::DecapodError> {
let target_dir = match &init_with.dir {
Some(d) => d.clone(),
None => current_dir.to_path_buf(),
};
let target_dir = if target_dir.exists() {
std::fs::canonicalize(&target_dir).map_err(error::DecapodError::IoError)?
} else {
target_dir
};
let setup_decapod_root = target_dir.join(".decapod");
if setup_decapod_root.exists() && !init_with.force {
use crate::core::ansi::AnsiExt;
println!(
"{} {}",
"init:".bright_yellow(),
"already initialized (.decapod exists); rerun with --force, or use `decapod init with --force`"
.bright_red()
);
return Ok(target_dir);
}
use sha2::{Digest, Sha256};
let mut existing_agent_files = vec![];
for file in ["AGENTS.md", "CLAUDE.md", "GEMINI.md", "CODEX.md"] {
if target_dir.join(file).exists() {
existing_agent_files.push(file);
}
}
let mut created_backups = false;
let mut backup_count = 0usize;
if !init_with.dry_run {
for file in &existing_agent_files {
let path = target_dir.join(file);
let template_content = core::assets::get_template(file).unwrap_or_default();
let mut hasher = Sha256::new();
hasher.update(template_content.as_bytes());
let template_hash = format!("{:x}", hasher.finalize());
let existing_content = fs::read_to_string(&path).unwrap_or_default();
let mut hasher = Sha256::new();
hasher.update(existing_content.as_bytes());
let existing_hash = format!("{:x}", hasher.finalize());
if template_hash != existing_hash {
created_backups = true;
backup_count += 1;
let backup_path = target_dir.join(format!("{}.bak", file));
fs::rename(&path, &backup_path).map_err(error::DecapodError::IoError)?;
}
}
}
let mut agent_files_to_generate = if init_with.claude || init_with.gemini || init_with.agents {
let mut files = vec![];
if init_with.claude {
files.push("CLAUDE.md".to_string());
}
if init_with.gemini {
files.push("GEMINI.md".to_string());
}
if init_with.agents {
files.push("AGENTS.md".to_string());
}
files
} else {
existing_agent_files
.into_iter()
.map(|s| s.to_string())
.collect()
};
if !agent_files_to_generate.is_empty()
&& !agent_files_to_generate.iter().any(|f| f == "AGENTS.md")
{
agent_files_to_generate.push("AGENTS.md".to_string());
}
let scaffold_summary = scaffold::scaffold_project_entrypoints(&scaffold::ScaffoldOptions {
target_dir: target_dir.clone(),
force: init_with.force,
dry_run: init_with.dry_run,
agent_files: agent_files_to_generate,
created_backups,
all: init_with.all,
generate_specs: init_with.specs,
diagram_style: match init_with.diagram_style {
InitDiagramStyle::Ascii => scaffold::DiagramStyle::Ascii,
InitDiagramStyle::Mermaid => scaffold::DiagramStyle::Mermaid,
},
specs_seed: Some(scaffold::SpecsSeed {
product_name: repo_ctx.product_name.clone(),
product_summary: repo_ctx.product_summary.clone(),
architecture_direction: repo_ctx.architecture_direction.clone(),
product_type: repo_ctx.product_type.clone(),
primary_languages: repo_ctx.primary_languages.clone(),
detected_surfaces: repo_ctx.detected_surfaces.clone(),
done_criteria: repo_ctx.done_criteria.clone(),
}),
})?;
let target_display = setup_decapod_root
.parent()
.unwrap_or(current_dir)
.display()
.to_string();
use crate::core::ansi::AnsiExt;
print_init_block(
"Decapod Init Summary",
"Scaffold completed with the following changes.",
);
println!(" Target: {}", target_display.bright_white());
println!(
" Mode: {}",
if init_with.dry_run {
"Dry Run".bright_yellow()
} else {
"Apply".bright_green()
}
);
println!(
" Entrypoints: created={}, unchanged={}, preserved={}",
scaffold_summary
.entrypoints_created
.to_string()
.bright_green(),
scaffold_summary
.entrypoints_unchanged
.to_string()
.bright_yellow(),
scaffold_summary
.entrypoints_preserved
.to_string()
.bright_white()
);
println!(
" Config: created={}, unchanged={}, preserved={}",
scaffold_summary.config_created.to_string().bright_green(),
scaffold_summary
.config_unchanged
.to_string()
.bright_yellow(),
scaffold_summary.config_preserved.to_string().bright_white()
);
println!(
" Specs: created={}, unchanged={}, preserved={}",
scaffold_summary.specs_created.to_string().bright_green(),
scaffold_summary.specs_unchanged.to_string().bright_yellow(),
scaffold_summary.specs_preserved.to_string().bright_white()
);
println!(" Backups: {}", backup_count.to_string().bright_magenta());
println!(
" Diagram Notation: {}",
diagram_style_label(init_with.diagram_style).bright_white()
);
println!(
"{} {}",
"✓".bright_green().bold(),
"Ready".bright_green().bold()
);
Ok(target_dir)
}
pub fn run() -> Result<(), error::DecapodError> {
let cli = Cli::parse();
let argv: Vec<String> = std::env::args().skip(1).collect();
let current_dir = std::env::current_dir()?;
let decapod_root_option = find_decapod_project_root(¤t_dir);
let store_root: PathBuf;
match cli.command {
Command::Version => {
println!("v{}", migration::DECAPOD_VERSION);
return Ok(());
}
Command::Init(init_group) => {
let base_init_invocation = init_group.command.is_none();
let init_with = match init_group.command {
Some(InitCommand::Clean { dir }) => {
clean_project(dir)?;
return Ok(());
}
Some(InitCommand::With(with)) => with,
None => {
if init_group.dir.is_some() && init_group.project_dir.is_some() {
return Err(error::DecapodError::ValidationError(
"Use either --dir for an existing directory or --project-dir to create/select a project directory, not both.".to_string(),
));
}
let target = if let Some(project_dir) = init_group.project_dir.as_ref() {
resolve_or_create_project_dir(
¤t_dir,
project_dir,
init_group.dry_run,
)?
} else if let Some(dir) = init_group.dir.as_ref() {
resolve_existing_init_dir(dir)?
} else if io::stdin().is_terminal() {
prompt_init_target_dir(¤t_dir)?
} else {
resolve_existing_init_dir(¤t_dir)?
};
let maybe_cfg = load_project_config_if_present(&target)?;
if let Some(cfg) = maybe_cfg {
let mut with = if io::stdin().is_terminal() {
interactive_init_with(
&cfg,
target.clone(),
init_group.force,
init_group.dry_run,
)?
} else {
init_with_from_config(
&cfg,
target.clone(),
init_group.force,
init_group.dry_run,
)
};
if init_group.all {
with.all = true;
with.agents = true;
with.claude = true;
with.gemini = true;
}
if init_group.agents {
with.agents = true;
}
if init_group.claude {
with.claude = true;
}
if init_group.gemini {
with.gemini = true;
}
if init_group.product_name.is_some() {
with.product_name = init_group.product_name.clone();
}
if init_group.product_summary.is_some() {
with.product_summary = init_group.product_summary.clone();
}
if init_group.architecture_direction.is_some() {
with.architecture_direction = init_group.architecture_direction.clone();
}
if init_group.product_type.is_some() {
with.product_type = init_group.product_type.clone();
}
if init_group.done_criteria.is_some() {
with.done_criteria = init_group.done_criteria.clone();
}
if !init_group.primary_languages.is_empty() {
with.primary_languages = init_group.primary_languages.clone();
}
if !init_group.detected_surfaces.is_empty() {
with.detected_surfaces = init_group.detected_surfaces.clone();
}
with
} else {
let diagram_style = if io::stdin().is_terminal() {
prompt_diagram_style(init_group.diagram_style)?
} else {
init_group.diagram_style
};
InitWithCli {
dir: Some(target),
project_dir: None,
force: init_group.force,
dry_run: init_group.dry_run,
all: init_group.all,
claude: init_group.claude,
gemini: init_group.gemini,
agents: init_group.agents,
specs: true,
diagram_style,
product_name: init_group.product_name.clone(),
product_summary: init_group.product_summary.clone(),
architecture_direction: init_group.architecture_direction.clone(),
product_type: init_group.product_type.clone(),
done_criteria: init_group.done_criteria.clone(),
primary_languages: init_group.primary_languages.clone(),
detected_surfaces: init_group.detected_surfaces.clone(),
}
}
}
};
if init_with.dir.is_some() && init_with.project_dir.is_some() {
return Err(error::DecapodError::ValidationError(
"Use either --dir for an existing directory or --project-dir to create/select a project directory, not both.".to_string(),
));
}
let init_target = if let Some(project_dir) = init_with.project_dir.as_ref() {
resolve_or_create_project_dir(¤t_dir, project_dir, init_with.dry_run)?
} else if let Some(dir) = init_with.dir.as_ref() {
resolve_existing_init_dir(dir)?
} else {
resolve_existing_init_dir(¤t_dir)?
};
let mut init_with = init_with;
init_with.dir = Some(init_target.clone());
init_with.project_dir = None;
let mut repo_ctx = infer_repo_context(&init_target);
apply_repo_context_env_overrides(&mut repo_ctx);
apply_repo_context_cli_overrides(&mut repo_ctx, &init_with);
apply_substrate_adoption(&mut repo_ctx, &init_target);
apply_architecture_language_recommendation(&mut repo_ctx);
if base_init_invocation && io::stdin().is_terminal() {
enrich_repo_context_interactive(&mut repo_ctx)?;
}
let target_dir = run_init_apply(&init_with, ¤t_dir, &repo_ctx)?;
let config = config_from_init_with(&init_with, repo_ctx);
write_project_config(&target_dir, &config, init_with.dry_run)?;
seed_init_generated_state(&target_dir, init_with.dry_run)?;
}
Command::Session(session_cli) => {
run_session_command(session_cli)?;
}
Command::Release(release_cli) => {
let project_root = decapod_root_option?;
run_release_command(release_cli, &project_root)?;
}
Command::Setup(setup_cli) => match setup_cli.command {
SetupCommand::Hook {
commit_msg,
pre_commit,
uninstall,
} => {
run_hook_install(commit_msg, pre_commit, uninstall)?;
}
},
_ => {
let project_root = decapod_root_option?;
let is_validate_cmd = matches!(&cli.command, Command::Validate(_));
if requires_session_token(&cli.command) {
ensure_session_valid()?;
}
enforce_worktree_requirement(&cli.command, &project_root)?;
let decapod_root_path = project_root.join(".decapod");
store_root = decapod_root_path.join("data");
std::fs::create_dir_all(&store_root).map_err(error::DecapodError::IoError)?;
if should_route_via_group_broker(&cli.command, &argv) {
match core::group_broker::maybe_route_mutation(&store_root, &argv) {
Err(e) => {
if !core::group_broker::is_internal_invocation() {
return Err(e);
}
}
Ok(routed) if routed && !core::group_broker::is_internal_invocation() => {
return Ok(());
}
Ok(routed) => {
if !routed
&& !core::group_broker::is_internal_invocation()
&& enforce_route_strict_mode()
{
return Err(error::DecapodError::ValidationError(
"BROKER_ROUTE_REQUIRED: routed mutator cannot bypass broker in strict mode"
.to_string(),
));
}
}
}
}
let migration_result =
migration::check_and_migrate_with_backup(&decapod_root_path, |data_root| {
subsystems::initialize_all_dbs(data_root)
});
match migration_result {
Ok(()) => {}
Err(e) if is_validate_cmd => {
let normalized = normalize_validate_error(e);
return Err(attach_validate_diagnostic_if_enabled(
normalized,
&project_root,
0,
validate_timeout_secs(),
));
}
Err(e) => return Err(e),
}
if let Err(e) = workspace::prune_stale_worktree_config(&project_root)
&& !is_not_git_repository_error(&e)
{
eprintln!("warn: worktree maintenance skipped: {e}");
}
let project_store = Store {
kind: StoreKind::Repo,
root: store_root.clone(),
};
if should_auto_clock_in(&cli.command)
&& let Err(e) =
retry_transient_sqlite(|| todo::clock_in_agent_presence(&project_store), 4)
{
if is_transient_sqlite_contention_error(&e) {
eprintln!(
"warn: presence clock-in skipped due transient sqlite contention: {e}"
);
} else {
return Err(e);
}
}
match cli.command {
Command::Activate => {
println!("decapod.activate: ok");
}
Command::Validate(validate_cli) => {
run_validate_command(validate_cli, &project_root, &project_store)?;
}
Command::Version => show_version_info()?,
Command::Docs(docs_cli) => {
let result = docs_cli::run_docs_cli(docs_cli)?;
if result.ingested_core_constitution {
mark_core_constitution_ingested(&project_root, "docs.ingest")?;
}
}
Command::Todo(todo_cli) => todo::run_todo_cli(&project_store, todo_cli)?,
Command::Obligation(obligation_cli) => {
obligation::run_obligation_cli(&project_store, obligation_cli)?
}
Command::Govern(govern_cli) => {
run_govern_command(govern_cli, &project_store, &store_root)?;
}
Command::Data(data_cli) => {
run_data_command(data_cli, &project_store, &project_root, &store_root)?;
}
Command::Auto(auto_cli) => run_auto_command(auto_cli, &project_store)?,
Command::Qa(qa_cli) => run_qa_command(qa_cli, &project_store, &project_root)?,
Command::Decide(decide_cli) => decide::run_decide_cli(&project_store, decide_cli)?,
Command::Workspace(workspace_cli) => {
run_workspace_command(workspace_cli, &project_root)?;
}
Command::Rpc(rpc_cli) => {
run_rpc_command(rpc_cli, &project_root)?;
}
Command::Handshake(handshake_cli) => {
run_handshake_command(handshake_cli, &project_root)?;
}
Command::Release(release_cli) => {
run_release_command(release_cli, &project_root)?;
}
Command::Capabilities(cap_cli) => {
run_capabilities_command(cap_cli)?;
}
Command::Internalize(internalize_cli) => {
internalize::run_internalize_cli(&project_store, &store_root, internalize_cli)?;
}
Command::Preflight(preflight_cli) => {
run_preflight_command(preflight_cli, &project_root)?;
}
Command::Impact(impact_cli) => {
run_impact_command(impact_cli, &project_root)?;
}
Command::Infer(infer_cli) => {
run_infer_command(infer_cli, &project_root)?;
}
Command::Trace(trace_cli) => {
run_trace_command(trace_cli, &project_root)?;
}
Command::Eval(eval_cli) => {
eval::run_eval_cli(&project_store, eval_cli)?;
}
Command::FlightRecorder(fr_cli) => {
flight_recorder::run_flight_recorder_cli(&project_store, fr_cli)?;
}
Command::StateCommit(sc_cli) => {
run_state_commit_command(sc_cli, &project_root)?;
}
Command::Doctor(doctor_cli) => {
doctor::run_doctor_cli(&project_store, &project_root, doctor_cli)?;
}
Command::Lcm(lcm_cli) => {
lcm::run_lcm_cli(&project_store, lcm_cli)?;
}
Command::Map(map_cli) => {
map_ops::run_map_cli(&project_store, map_cli)?;
}
Command::Demo(demo_cli) => {
run_demo_command(demo_cli, &project_root)?;
}
_ => unreachable!(),
}
}
}
Ok(())
}
fn should_route_via_group_broker(command: &Command, argv: &[String]) -> bool {
if core::group_broker::is_internal_invocation() {
return false;
}
match command {
Command::Todo(_) => todo_argv_is_mutating(argv),
Command::Decide(decide_cli) => decide_command_is_mutating(decide_cli),
Command::Data(data_cli) => match &data_cli.command {
DataCommand::Federation(_) => federation_argv_is_mutating(argv),
DataCommand::Knowledge(_) => knowledge_argv_is_mutating(argv),
_ => false,
},
_ => false,
}
}
fn enforce_route_strict_mode() -> bool {
std::env::var("DECAPOD_GROUP_BROKER_ENFORCE_ROUTE")
.ok()
.map(|v| v == "1")
.unwrap_or(false)
}
fn todo_argv_is_mutating(argv: &[String]) -> bool {
let Some(sub) = argv.get(1).map(|s| s.as_str()) else {
return false;
};
!matches!(
sub,
"list"
| "get"
| "show"
| "categories"
| "ownerships"
| "claim-status"
| "presence"
| "list-owners"
| "expertise"
)
}
fn decide_command_is_mutating(decide_cli: &decide::DecideCli) -> bool {
matches!(
decide_cli.command,
decide::DecideCommand::Start { .. }
| decide::DecideCommand::Record { .. }
| decide::DecideCommand::Complete { .. }
| decide::DecideCommand::Init
)
}
fn knowledge_argv_is_mutating(argv: &[String]) -> bool {
matches!(argv.get(2).map(|s| s.as_str()), Some("add" | "promote"))
}
fn federation_argv_is_mutating(argv: &[String]) -> bool {
matches!(
argv.get(2).map(|s| s.as_str()),
Some(
"add"
| "edit"
| "supersede"
| "deprecate"
| "dispute"
| "link"
| "unlink"
| "sources-add"
| "init"
| "rebuild"
)
)
}
fn should_auto_clock_in(command: &Command) -> bool {
match command {
Command::Todo(todo_cli) => !todo::is_heartbeat_command(todo_cli),
Command::Version
| Command::Activate
| Command::Init(_)
| Command::Setup(_)
| Command::Session(_)
| Command::Release(_)
| Command::StateCommit(_)
| Command::Doctor(_) => false,
_ => true,
}
}
fn command_requires_worktree(command: &Command) -> bool {
match command {
Command::Init(_)
| Command::Activate
| Command::Setup(_)
| Command::Session(_)
| Command::Version
| Command::Validate(_)
| Command::Workspace(_)
| Command::Capabilities(_)
| Command::Trace(_)
| Command::FlightRecorder(_)
| Command::Docs(_)
| Command::Handshake(_)
| Command::Release(_)
| Command::Todo(_)
| Command::Eval(_)
| Command::StateCommit(_)
| Command::Doctor(_) => false,
Command::Data(data_cli) => !matches!(data_cli.command, DataCommand::Schema(_)),
Command::Rpc(_) => false,
_ => true,
}
}
fn is_canonical_decapod_worktree_path(path: &Path) -> bool {
let mut saw_decapod = false;
for comp in path.components() {
let seg = comp.as_os_str().to_string_lossy();
if seg == ".decapod" {
saw_decapod = true;
continue;
}
if saw_decapod && seg == "workspaces" {
return true;
}
}
false
}
fn command_requires_todo_scoped_worktree(command: &Command) -> bool {
!matches!(
command,
Command::Validate(_)
| Command::Activate
| Command::Docs(_)
| Command::Release(_)
| Command::Trace(_)
| Command::Capabilities(_)
| Command::Doctor(_)
| Command::StateCommit(_)
| Command::Qa(_)
)
}
fn command_requires_canonical_worktree_path(command: &Command) -> bool {
!matches!(
command,
Command::Validate(_)
| Command::Activate
| Command::Docs(_)
| Command::Release(_)
| Command::Trace(_)
| Command::Capabilities(_)
| Command::Doctor(_)
| Command::StateCommit(_)
| Command::Qa(_)
)
}
fn branch_contains_todo_ticket_id(branch: &str) -> bool {
let branch = branch.to_ascii_lowercase();
if branch.contains("r_") {
return true;
}
if let Ok(hash_re) = fancy_regex::Regex::new(r"todo-[a-z0-9]{6}(\b|-|$)")
&& hash_re.is_match(&branch).unwrap_or(false)
{
return true;
}
let chars: Vec<char> = branch.chars().collect();
if chars.len() < 21 {
return false;
}
for i in 0..=(chars.len() - 21) {
let type_ok = chars[i..i + 4].iter().all(|c| c.is_ascii_lowercase());
let sep_ok = chars[i + 4] == '_';
let body_ok = chars[i + 5..i + 21]
.iter()
.all(|c| c.is_ascii_alphanumeric());
if type_ok && sep_ok && body_ok {
return true;
}
}
false
}
fn enforce_worktree_requirement(
command: &Command,
project_root: &Path,
) -> Result<(), error::DecapodError> {
if std::env::var("DECAPOD_VALIDATE_SKIP_GIT_GATES").is_ok() {
return Ok(());
}
if !command_requires_worktree(command) {
return Ok(());
}
let status = crate::core::workspace::get_workspace_status(project_root)?;
if status.git.in_worktree {
let worktree_path = status
.git
.worktree_path
.clone()
.unwrap_or_else(|| project_root.to_path_buf());
if command_requires_canonical_worktree_path(command)
&& !is_canonical_decapod_worktree_path(&worktree_path)
{
return Err(error::DecapodError::ValidationError(format!(
"SCOPE_VIOLATION: non-canonical worktree path '{}'. Decapod-managed work must run from '.decapod/workspaces/*'. Run `decapod workspace ensure --branch agent/<id>/<topic>` and execute from the returned path.",
worktree_path.display()
)));
}
if command_requires_todo_scoped_worktree(command)
&& !branch_contains_todo_ticket_id(&status.git.current_branch)
{
return Err(error::DecapodError::ValidationError(format!(
"SCOPE_VIOLATION: branch '{}' is not todo-scoped. Run `decapod todo add \"<task>\"`, `decapod todo claim --id <task-id>`, then `decapod workspace ensure`.",
status.git.current_branch
)));
}
return Ok(());
}
Err(error::DecapodError::ValidationError(format!(
"Command requires isolated git worktree under '.decapod/workspaces'; current checkout is not a worktree (branch='{}'). Run `decapod workspace ensure --branch agent/<id>/<topic>` and execute from the reported worktree path.",
status.git.current_branch
)))
}
fn rpc_op_requires_worktree(op: &str) -> bool {
!matches!(
op,
"agent.init"
| "workspace.status"
| "workspace.ensure"
| "assurance.evaluate"
| "mentor.obligations"
| "context.resolve"
| "context.scope"
| "context.capsule.query"
| "context.bindings"
| "constitution.get"
| "schema.get"
| "store.upsert"
| "store.query"
| "validate.run"
| "standards.resolve"
)
}
fn enforce_worktree_requirement_for_rpc(
op: &str,
project_root: &Path,
) -> Result<(), error::DecapodError> {
if std::env::var("DECAPOD_VALIDATE_SKIP_GIT_GATES").is_ok() {
return Ok(());
}
if !rpc_op_requires_worktree(op) {
return Ok(());
}
let status = crate::core::workspace::get_workspace_status(project_root)?;
if status.git.in_worktree {
let worktree_path = status
.git
.worktree_path
.clone()
.unwrap_or_else(|| project_root.to_path_buf());
if !matches!(
op,
"validate.run"
| "context.resolve"
| "context.scope"
| "context.capsule.query"
| "context.bindings"
| "schema.get"
) && !is_canonical_decapod_worktree_path(&worktree_path)
{
return Err(error::DecapodError::ValidationError(format!(
"SCOPE_VIOLATION: RPC op '{}' must execute from a Decapod-managed worktree under '.decapod/workspaces/*' (current '{}'). Run `decapod workspace ensure` and retry.",
op,
worktree_path.display()
)));
}
return Ok(());
}
Err(error::DecapodError::ValidationError(format!(
"RPC op '{}' requires isolated git worktree under '.decapod/workspaces'; current checkout is not a worktree (branch='{}'). Run `decapod workspace ensure --branch agent/<id>/<topic>` and execute from the reported worktree path.",
op, status.git.current_branch
)))
}
fn rpc_op_bypasses_session(op: &str) -> bool {
matches!(
op,
"agent.init"
| "context.resolve"
| "context.scope"
| "context.capsule.query"
| "context.bindings"
| "constitution.get"
| "schema.get"
| "store.upsert"
| "store.query"
| "validate.run"
| "workspace.status"
| "workspace.ensure"
| "standards.resolve"
)
}
fn requires_session_token(command: &Command) -> bool {
match command {
Command::Init(_)
| Command::Session(_)
| Command::Version
| Command::Activate
| Command::Docs(_)
| Command::Capabilities(_)
| Command::Release(_)
| Command::Trace(_)
| Command::FlightRecorder(_)
| Command::StateCommit(_)
| Command::Doctor(_) => false,
Command::Data(DataCli {
command: DataCommand::Schema(_),
}) => false,
Command::Rpc(rpc_cli) => {
if let Some(ref op) = rpc_cli.op {
!rpc_op_bypasses_session(op)
} else {
false
}
}
_ => true,
}
}
#[derive(Debug, Serialize, Deserialize)]
struct AgentSessionRecord {
agent_id: String,
token: String,
password_hash: String,
issued_at_epoch_secs: u64,
expires_at_epoch_secs: u64,
}
#[derive(Debug, Serialize, Deserialize)]
struct ConstitutionalAwarenessRecord {
agent_id: String,
session_token: Option<String>,
initialized_at_epoch_secs: u64,
validated_at_epoch_secs: Option<u64>,
core_constitution_ingested_at_epoch_secs: Option<u64>,
context_resolved_at_epoch_secs: Option<u64>,
source_ops: Vec<String>,
}
fn now_epoch_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn session_ttl_secs() -> u64 {
std::env::var("DECAPOD_SESSION_TTL_SECS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.filter(|v| *v > 0)
.unwrap_or(3600)
}
fn current_agent_id() -> String {
std::env::var("DECAPOD_AGENT_ID")
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.unwrap_or_else(|| "unknown".to_string())
}
fn sanitize_agent_component(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
out.push(ch.to_ascii_lowercase());
} else {
out.push('-');
}
}
out.trim_matches('-').to_string()
}
fn sessions_dir(project_root: &Path) -> PathBuf {
project_root
.join(".decapod")
.join("generated")
.join("sessions")
}
fn session_file_for_agent(project_root: &Path, agent_id: &str) -> PathBuf {
sessions_dir(project_root).join(format!("{}.json", sanitize_agent_component(agent_id)))
}
fn awareness_dir(project_root: &Path) -> PathBuf {
project_root
.join(".decapod")
.join("generated")
.join("awareness")
}
fn awareness_file_for_agent(project_root: &Path, agent_id: &str) -> PathBuf {
awareness_dir(project_root).join(format!("{}.json", sanitize_agent_component(agent_id)))
}
fn hash_password(password: &str, token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hasher.update(b":");
hasher.update(password.as_bytes());
let digest = hasher.finalize();
let mut out = String::with_capacity(digest.len() * 2);
for b in digest {
out.push_str(&format!("{:02x}", b));
}
out
}
fn generate_ephemeral_password() -> Result<String, error::DecapodError> {
let mut buf = vec![0u8; 24];
let mut urandom = fs::File::open("/dev/urandom").map_err(error::DecapodError::IoError)?;
urandom
.read_exact(&mut buf)
.map_err(error::DecapodError::IoError)?;
let mut out = String::with_capacity(buf.len() * 2);
for b in buf {
out.push_str(&format!("{:02x}", b));
}
Ok(out)
}
fn read_agent_session(
project_root: &Path,
agent_id: &str,
) -> Result<Option<AgentSessionRecord>, error::DecapodError> {
let path = session_file_for_agent(project_root, agent_id);
if !path.exists() {
return Ok(None);
}
let raw = fs::read_to_string(&path).map_err(error::DecapodError::IoError)?;
let rec: AgentSessionRecord = serde_json::from_str(&raw)
.map_err(|e| error::DecapodError::SessionError(format!("invalid session file: {}", e)))?;
Ok(Some(rec))
}
fn atomic_write_file(path: &Path, body: &str) -> Result<(), error::DecapodError> {
let parent = path.parent().ok_or_else(|| {
error::DecapodError::IoError(std::io::Error::other(
"target path is missing parent directory",
))
})?;
fs::create_dir_all(parent).map_err(error::DecapodError::IoError)?;
let file_name = path
.file_name()
.and_then(|v| v.to_str())
.unwrap_or("file")
.to_string();
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let tmp = parent.join(format!(
".{}.tmp-{}-{}",
file_name,
std::process::id(),
nonce
));
fs::write(&tmp, body).map_err(error::DecapodError::IoError)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&tmp)
.map_err(error::DecapodError::IoError)?
.permissions();
perms.set_mode(0o600);
fs::set_permissions(&tmp, perms).map_err(error::DecapodError::IoError)?;
}
fs::rename(&tmp, path).map_err(error::DecapodError::IoError)?;
Ok(())
}
fn write_agent_session(
project_root: &Path,
rec: &AgentSessionRecord,
) -> Result<(), error::DecapodError> {
let dir = sessions_dir(project_root);
fs::create_dir_all(&dir).map_err(error::DecapodError::IoError)?;
let path = session_file_for_agent(project_root, &rec.agent_id);
let body = serde_json::to_string_pretty(rec)
.map_err(|e| error::DecapodError::SessionError(format!("session encode error: {}", e)))?;
atomic_write_file(&path, &body)?;
Ok(())
}
fn clear_agent_awareness(project_root: &Path, agent_id: &str) -> Result<(), error::DecapodError> {
let path = awareness_file_for_agent(project_root, agent_id);
if path.exists() {
fs::remove_file(path).map_err(error::DecapodError::IoError)?;
}
Ok(())
}
fn read_awareness_record(
project_root: &Path,
agent_id: &str,
) -> Result<Option<ConstitutionalAwarenessRecord>, error::DecapodError> {
let path = awareness_file_for_agent(project_root, agent_id);
if !path.exists() {
return Ok(None);
}
let raw = fs::read_to_string(path).map_err(error::DecapodError::IoError)?;
let rec: ConstitutionalAwarenessRecord = serde_json::from_str(&raw).map_err(|e| {
error::DecapodError::ValidationError(format!(
"invalid constitutional awareness record: {}",
e
))
})?;
Ok(Some(rec))
}
fn write_awareness_record(
project_root: &Path,
rec: &ConstitutionalAwarenessRecord,
) -> Result<(), error::DecapodError> {
let dir = awareness_dir(project_root);
fs::create_dir_all(&dir).map_err(error::DecapodError::IoError)?;
let path = awareness_file_for_agent(project_root, &rec.agent_id);
let body = serde_json::to_string_pretty(rec).map_err(|e| {
error::DecapodError::ValidationError(format!("awareness encode error: {}", e))
})?;
atomic_write_file(&path, &body)?;
Ok(())
}
fn mark_constitution_initialized(project_root: &Path) -> Result<(), error::DecapodError> {
let agent_id = current_agent_id();
let session_token = read_agent_session(project_root, &agent_id)?.map(|s| s.token);
let now = now_epoch_secs();
let existing = read_awareness_record(project_root, &agent_id)?;
let mut source_ops = existing
.as_ref()
.map(|r| r.source_ops.clone())
.unwrap_or_default();
if !source_ops.iter().any(|op| op == "agent.init") {
source_ops.push("agent.init".to_string());
}
let rec = ConstitutionalAwarenessRecord {
agent_id,
session_token,
initialized_at_epoch_secs: now,
validated_at_epoch_secs: existing.as_ref().and_then(|r| r.validated_at_epoch_secs),
core_constitution_ingested_at_epoch_secs: existing
.as_ref()
.and_then(|r| r.core_constitution_ingested_at_epoch_secs),
context_resolved_at_epoch_secs: existing.and_then(|r| r.context_resolved_at_epoch_secs),
source_ops,
};
write_awareness_record(project_root, &rec)
}
fn mark_constitution_context_resolved(project_root: &Path) -> Result<(), error::DecapodError> {
let agent_id = current_agent_id();
let mut rec =
read_awareness_record(project_root, &agent_id)?.unwrap_or(ConstitutionalAwarenessRecord {
agent_id: agent_id.clone(),
session_token: read_agent_session(project_root, &agent_id)?.map(|s| s.token),
initialized_at_epoch_secs: now_epoch_secs(),
validated_at_epoch_secs: None,
core_constitution_ingested_at_epoch_secs: None,
context_resolved_at_epoch_secs: None,
source_ops: Vec::new(),
});
rec.context_resolved_at_epoch_secs = Some(now_epoch_secs());
if !rec.source_ops.iter().any(|op| op == "context.resolve") {
rec.source_ops.push("context.resolve".to_string());
}
write_awareness_record(project_root, &rec)
}
fn mark_validation_completed(project_root: &Path) -> Result<(), error::DecapodError> {
let agent_id = current_agent_id();
let mut rec =
read_awareness_record(project_root, &agent_id)?.unwrap_or(ConstitutionalAwarenessRecord {
agent_id: agent_id.clone(),
session_token: read_agent_session(project_root, &agent_id)?.map(|s| s.token),
initialized_at_epoch_secs: now_epoch_secs(),
validated_at_epoch_secs: None,
core_constitution_ingested_at_epoch_secs: None,
context_resolved_at_epoch_secs: None,
source_ops: Vec::new(),
});
rec.validated_at_epoch_secs = Some(now_epoch_secs());
if !rec.source_ops.iter().any(|op| op == "validate") {
rec.source_ops.push("validate".to_string());
}
write_awareness_record(project_root, &rec)
}
fn mark_core_constitution_ingested(
project_root: &Path,
source_op: &str,
) -> Result<(), error::DecapodError> {
let agent_id = current_agent_id();
let mut rec =
read_awareness_record(project_root, &agent_id)?.unwrap_or(ConstitutionalAwarenessRecord {
agent_id: agent_id.clone(),
session_token: read_agent_session(project_root, &agent_id)?.map(|s| s.token),
initialized_at_epoch_secs: now_epoch_secs(),
validated_at_epoch_secs: None,
core_constitution_ingested_at_epoch_secs: None,
context_resolved_at_epoch_secs: None,
source_ops: Vec::new(),
});
rec.core_constitution_ingested_at_epoch_secs = Some(now_epoch_secs());
if !rec.source_ops.iter().any(|op| op == source_op) {
rec.source_ops.push(source_op.to_string());
}
write_awareness_record(project_root, &rec)
}
fn cleanup_expired_sessions(
project_root: &Path,
store_root: &Path,
) -> Result<Vec<String>, error::DecapodError> {
let dir = sessions_dir(project_root);
if !dir.exists() {
return Ok(Vec::new());
}
let now = now_epoch_secs();
let mut expired_agents = Vec::new();
for entry in fs::read_dir(&dir).map_err(error::DecapodError::IoError)? {
let entry = entry.map_err(error::DecapodError::IoError)?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let raw = match fs::read_to_string(&path) {
Ok(v) => v,
Err(_) => {
let _ = fs::remove_file(&path);
continue;
}
};
let rec: AgentSessionRecord = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(_) => {
let _ = fs::remove_file(&path);
continue;
}
};
if rec.expires_at_epoch_secs <= now {
let _ = fs::remove_file(&path);
expired_agents.push(rec.agent_id);
}
}
if !expired_agents.is_empty() {
todo::cleanup_stale_agent_assignments(store_root, &expired_agents, "session.expired")?;
for agent_id in &expired_agents {
let _ = clear_agent_awareness(project_root, agent_id);
}
}
Ok(expired_agents)
}
fn ensure_session_valid() -> Result<(), error::DecapodError> {
let current_dir = std::env::current_dir()?;
let project_root = find_decapod_project_root(¤t_dir)?;
let store_root = project_root.join(".decapod").join("data");
fs::create_dir_all(&store_root).map_err(error::DecapodError::IoError)?;
let _ = cleanup_expired_sessions(&project_root, &store_root)?;
let agent_id = current_agent_id();
let session = read_agent_session(&project_root, &agent_id)?;
let Some(session) = session else {
return auto_acquire_session(&project_root, &agent_id);
};
if session.expires_at_epoch_secs <= now_epoch_secs() {
let _ = fs::remove_file(session_file_for_agent(&project_root, &agent_id));
let _ = todo::cleanup_stale_agent_assignments(
&store_root,
std::slice::from_ref(&agent_id),
"session.expired",
);
return auto_acquire_session(&project_root, &agent_id);
}
if agent_id == "unknown" {
return auto_acquire_session(&project_root, &agent_id);
}
let supplied_password = SESSION_PASSWORD
.get()
.cloned()
.or_else(|| std::env::var("DECAPOD_SESSION_PASSWORD").ok())
.inspect(|p| {
let _ = SESSION_PASSWORD.get_or_init(|| p.clone());
});
let supplied_password = match supplied_password {
Some(p) => p,
None => {
return auto_acquire_session(&project_root, &agent_id);
}
};
let supplied_hash = hash_password(&supplied_password, &session.token);
if supplied_hash != session.password_hash {
return auto_acquire_session(&project_root, &agent_id);
}
Ok(())
}
fn auto_acquire_session(project_root: &Path, agent_id: &str) -> Result<(), error::DecapodError> {
let issued = now_epoch_secs();
let expires = issued.saturating_add(session_ttl_secs());
let token = crate::core::ulid::new_ulid();
let password = generate_ephemeral_password()?;
let rec = AgentSessionRecord {
agent_id: agent_id.to_string(),
token: token.clone(),
password_hash: hash_password(&password, &token),
issued_at_epoch_secs: issued,
expires_at_epoch_secs: expires,
};
write_agent_session(project_root, &rec)?;
SESSION_PASSWORD.get_or_init(|| password);
eprintln!("session: auto-acquired for agent '{}'.", agent_id);
Ok(())
}
use crate::core::ansi::AnsiExt;
use crate::core::migration::DECAPOD_VERSION;
fn check_and_update_version() -> Result<bool, error::DecapodError> {
let current_version = DECAPOD_VERSION;
match std::process::Command::new("curl").arg("--version").output() {
Ok(o) if !o.status.success() => return Ok(false),
Err(_) => return Ok(false),
_ => {}
}
let latest_version = match fetch_latest_crates_version() {
Ok(v) => v,
Err(_) => return Ok(false),
};
if version_gt(&latest_version, current_version) {
eprintln!(
"{} Decapod v{} → v{}, updating...",
"⚠".bright_yellow(),
current_version,
latest_version
);
let _ = backup_decapod_state();
match std::process::Command::new("cargo")
.arg("--version")
.output()
{
Ok(o) if !o.status.success() => {
eprintln!(
"{} cargo not available, skipping update",
"⚠".bright_yellow()
);
return Ok(false);
}
Err(_) => return Ok(false),
_ => {}
}
if install_decapod().is_ok() {
eprintln!("{} Updated to v{}.", "✓".bright_green(), latest_version);
let project_root = std::env::current_dir()
.ok()
.and_then(|d| find_decapod_project_root(&d).ok());
if let Some(root) = project_root {
let config_path = root.join(".decapod").join("config.toml");
if config_path.exists() {
eprintln!(
"{} Check for new config fields in .decapod/config.toml",
"→".bright_cyan()
);
}
}
return Ok(true);
}
}
Ok(false)
}
fn fetch_latest_crates_version() -> Result<String, error::DecapodError> {
let output = std::process::Command::new("curl")
.args(["-s", "https://crates.io/api/v1/crates/decapod"])
.output()
.map_err(|e| {
error::DecapodError::ValidationError(format!("Failed to check version: {}", e))
})?;
if !output.status.success() {
return Err(error::DecapodError::ValidationError(
"Failed to fetch latest version".to_string(),
));
}
let json: serde_json::Value = serde_json::from_slice(&output.stdout)
.map_err(|e| error::DecapodError::ValidationError(format!("Invalid response: {}", e)))?;
json.get("version")
.and_then(|v| v.get("num"))
.and_then(|n| n.as_str())
.map(|s| s.to_string())
.ok_or_else(|| error::DecapodError::ValidationError("Could not parse version".to_string()))
}
fn version_gt(new: &str, current: &str) -> bool {
let new_parts: Vec<u32> = new.split('.').filter_map(|p| p.parse().ok()).collect();
let cur_parts: Vec<u32> = current.split('.').filter_map(|p| p.parse().ok()).collect();
for i in 0..new_parts.len().max(cur_parts.len()) {
let new_p = new_parts.get(i).unwrap_or(&0);
let cur_p = cur_parts.get(i).unwrap_or(&0);
if new_p > cur_p {
return true;
}
if new_p < cur_p {
return false;
}
}
false
}
fn backup_decapod_state() -> Result<(), error::DecapodError> {
let current_dir = std::env::current_dir()?;
let project_root = find_decapod_project_root(¤t_dir)?;
let decapod_dir = project_root.join(".decapod");
if !decapod_dir.exists() {
return Ok(());
}
let backup_dir = decapod_dir.join("backups");
fs::create_dir_all(&backup_dir).map_err(error::DecapodError::IoError)?;
let timestamp = std::time::SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let backup_name = format!("backup_{}_{}", DECAPOD_VERSION, timestamp);
let backup_path = backup_dir.join(&backup_name);
let mut backup_file = fs::File::create(&backup_path).map_err(error::DecapodError::IoError)?;
let override_path = decapod_dir.join("OVERRIDE.md");
let overrides = if override_path.exists() {
fs::read_to_string(&override_path).unwrap_or_default()
} else {
String::new()
};
let now = std::time::SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let content = format!(
"# Backup at {} v{}\n# OVERRIDE.md\n{}\n",
now, DECAPOD_VERSION, overrides
);
backup_file.write_all(content.as_bytes())?;
Ok(())
}
fn install_decapod() -> Result<(), error::DecapodError> {
let output = std::process::Command::new("cargo")
.args(["install", "decapod"])
.output()
.map_err(|e| error::DecapodError::ValidationError(format!("Failed to install: {}", e)))?;
if !output.status.success() {
let err = String::from_utf8_lossy(&output.stderr);
return Err(error::DecapodError::ValidationError(format!(
"Install failed: {}",
err
)));
}
Ok(())
}
fn run_session_command(session_cli: SessionCli) -> Result<(), error::DecapodError> {
let current_dir = std::env::current_dir()?;
let project_root = find_decapod_project_root(¤t_dir)?;
let store_root = project_root.join(".decapod").join("data");
fs::create_dir_all(&store_root).map_err(error::DecapodError::IoError)?;
let _ = cleanup_expired_sessions(&project_root, &store_root)?;
if matches!(session_cli.command, SessionCommand::Acquire)
&& let Ok(true) = check_and_update_version()
{
eprintln!(
"{} Restart Session: decapod session acquire",
"→".bright_cyan()
);
}
match session_cli.command {
SessionCommand::Acquire => {
let agent_id = current_agent_id();
if let Some(existing) = read_agent_session(&project_root, &agent_id)?
&& existing.expires_at_epoch_secs > now_epoch_secs()
{
println!(
"Session already active for agent '{}'. Use 'decapod session status' for details.",
agent_id
);
return Ok(());
}
let issued = now_epoch_secs();
let expires = issued.saturating_add(session_ttl_secs());
let token = crate::core::ulid::new_ulid();
let password = generate_ephemeral_password()?;
let rec = AgentSessionRecord {
agent_id: agent_id.clone(),
token: token.clone(),
password_hash: hash_password(&password, &token),
issued_at_epoch_secs: issued,
expires_at_epoch_secs: expires,
};
write_agent_session(&project_root, &rec)?;
clear_agent_awareness(&project_root, &agent_id)?;
println!("Session acquired successfully.");
println!("Agent: {}", agent_id);
println!("Token: {}", token);
println!("Password: {}", password);
println!("ExpiresAtEpoch: {}", expires);
println!(
"Export before running other commands: DECAPOD_AGENT_ID='{}' and DECAPOD_SESSION_PASSWORD='<password>'",
rec.agent_id
);
println!("\nYou may now use other decapod commands.");
Ok(())
}
SessionCommand::Status => {
let agent_id = current_agent_id();
if let Some(session) = read_agent_session(&project_root, &agent_id)? {
println!("Session active");
println!("Agent: {}", session.agent_id);
println!("Token: {}", session.token);
println!("IssuedAtEpoch: {}", session.issued_at_epoch_secs);
println!("ExpiresAtEpoch: {}", session.expires_at_epoch_secs);
} else {
println!("No active session");
println!("Run 'decapod session acquire' to start a session");
}
Ok(())
}
SessionCommand::Release => {
let agent_id = current_agent_id();
let session_path = session_file_for_agent(&project_root, &agent_id);
if session_path.exists() {
std::fs::remove_file(&session_path).map_err(error::DecapodError::IoError)?;
clear_agent_awareness(&project_root, &agent_id)?;
let _ = todo::cleanup_stale_agent_assignments(
&store_root,
std::slice::from_ref(&agent_id),
"session.release",
);
println!("Session released");
} else {
println!("No active session to release");
}
Ok(())
}
SessionCommand::Init {
scope,
mut proofs,
force,
} => {
if proofs.is_empty() {
proofs.push("decapod validate".to_string());
}
run_session_init(&project_root, &scope, &proofs, force)
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct HandshakeArtifact {
schema_version: String,
request_id: String,
agent_id: String,
repo_version: String,
scope: String,
proofs: Vec<String>,
declared_docs: Vec<String>,
doc_hashes: serde_json::Value,
artifact_hash: String,
}
fn hash_bytes_hex(input: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(input);
format!("{:x}", hasher.finalize())
}
fn required_handshake_docs() -> Vec<&'static str> {
vec![
"CLAUDE.md",
"AGENTS.md",
"core/DECAPOD",
"interfaces/CONTROL_PLANE",
]
}
fn build_handshake_artifact(
project_root: &Path,
scope: &str,
proofs: &[String],
) -> Result<HandshakeArtifact, error::DecapodError> {
let mut doc_hashes = serde_json::Map::new();
let required_docs = required_handshake_docs();
for rel in &required_docs {
let hash = if rel.ends_with(".md") && !rel.contains('/') {
let abs = project_root.join(rel);
if !abs.exists() {
return Err(error::DecapodError::ValidationError(format!(
"Handshake requires `{}` to exist.",
rel
)));
}
let bytes = fs::read(&abs).map_err(error::DecapodError::IoError)?;
hash_bytes_hex(&bytes)
} else {
match crate::core::assets::get_embedded_doc(rel) {
Some(c) => hash_bytes_hex(c.as_bytes()),
None => {
return Err(error::DecapodError::ValidationError(format!(
"Handshake requires constitution doc `{}` (embedded) to be accessible.",
rel
)));
}
}
};
doc_hashes.insert((*rel).to_string(), serde_json::json!(hash));
}
let request_id = crate::core::ulid::new_ulid();
let mut unsigned = serde_json::json!({
"schema_version": "1.0.0",
"request_id": request_id,
"agent_id": current_agent_id(),
"repo_version": migration::DECAPOD_VERSION,
"scope": scope,
"proofs": proofs,
"declared_docs": required_docs,
"doc_hashes": doc_hashes,
});
let canonical = serde_json::to_vec(&unsigned).map_err(|e| {
error::DecapodError::ValidationError(format!("Failed to encode handshake artifact: {e}"))
})?;
let artifact_hash = hash_bytes_hex(&canonical);
unsigned["artifact_hash"] = serde_json::json!(artifact_hash);
serde_json::from_value(unsigned).map_err(|e| {
error::DecapodError::ValidationError(format!("Failed to finalize handshake artifact: {e}"))
})
}
fn write_handshake_artifact(
project_root: &Path,
artifact: &HandshakeArtifact,
) -> Result<PathBuf, error::DecapodError> {
let dir = project_root
.join(".decapod")
.join("records")
.join("handshakes");
fs::create_dir_all(&dir).map_err(error::DecapodError::IoError)?;
let file = format!(
"{}-{}.json",
crate::core::time::now_epoch_z(),
artifact.agent_id.replace('/', "_")
);
let path = dir.join(file);
let pretty = serde_json::to_vec_pretty(artifact).map_err(|e| {
error::DecapodError::ValidationError(format!("Failed to serialize handshake record: {e}"))
})?;
fs::write(&path, pretty).map_err(error::DecapodError::IoError)?;
Ok(path)
}
fn run_handshake_command(
cli: HandshakeCli,
project_root: &Path,
) -> Result<(), error::DecapodError> {
if cli.proofs.is_empty() {
return Err(error::DecapodError::ValidationError(
"Handshake requires at least one `--proof` declaration.".to_string(),
));
}
let artifact = build_handshake_artifact(project_root, &cli.scope, &cli.proofs)?;
let path = write_handshake_artifact(project_root, &artifact)?;
println!(
"{}",
serde_json::json!({
"cmd": "handshake",
"status": "ok",
"path": path,
"artifact_hash": artifact.artifact_hash,
"repo_version": artifact.repo_version,
"scope": artifact.scope,
"proofs": artifact.proofs,
})
);
Ok(())
}
fn run_session_init(
project_root: &Path,
scope: &str,
proofs: &[String],
force: bool,
) -> Result<(), error::DecapodError> {
let mut created = Vec::new();
let mut skipped = Vec::new();
let tasks_dir = project_root.join("tasks");
fs::create_dir_all(&tasks_dir).map_err(error::DecapodError::IoError)?;
let todo_path = tasks_dir.join("todo.md");
let todo_stub = "\
# Work Session Plan
- Task: <replace-with-task-id-and-title>
- Scope: <replace-with-scope>
- Constraints: keep daemonless, repo-native, proof-gated
## Required Constitution Links
- core/DECAPOD
- interfaces/CONTROL_PLANE
- specs/SECURITY
## Proof Plan
- decapod validate
";
write_stub(&todo_path, todo_stub, force, &mut created, &mut skipped)?;
let intent_path = project_root.join("INTENT.md");
let intent_stub = "\
# INTENT
## Problem
<what outcome is required>
## Constraints
- daemonless
- repo-native canonical state
- deterministic reducers and proof gates
## Acceptance Proofs
- decapod validate
";
write_stub(&intent_path, intent_stub, force, &mut created, &mut skipped)?;
let handshake_path = project_root.join("HANDSHAKE.md");
let handshake_stub = "\
# HANDSHAKE
- Agent: <agent-id>
- Scope: <scope>
- Proofs: <proof-list>
- Record: `.decapod/records/handshakes/<latest>.json`
";
write_stub(
&handshake_path,
handshake_stub,
force,
&mut created,
&mut skipped,
)?;
let artifact = build_handshake_artifact(project_root, scope, proofs)?;
let artifact_path = write_handshake_artifact(project_root, &artifact)?;
println!(
"{}",
serde_json::json!({
"cmd": "session.init",
"status": "ok",
"created": created,
"skipped": skipped,
"handshake_record": artifact_path,
"template_refs": [
"Embedded: templates now in Rust via template_agents(), template_named_agent(), template_readme()"
]
})
);
Ok(())
}
fn write_stub(
path: &Path,
content: &str,
force: bool,
created: &mut Vec<String>,
skipped: &mut Vec<String>,
) -> Result<(), error::DecapodError> {
if path.exists() && !force {
skipped.push(path.display().to_string());
return Ok(());
}
fs::write(path, content).map_err(error::DecapodError::IoError)?;
created.push(path.display().to_string());
Ok(())
}
fn run_release_command(cli: ReleaseCli, project_root: &Path) -> Result<(), error::DecapodError> {
match cli.command {
ReleaseCommand::Check => run_release_check(project_root),
ReleaseCommand::Inventory => run_release_inventory(project_root),
ReleaseCommand::LineageSync => run_release_lineage_sync(project_root),
}
}
fn run_release_check(project_root: &Path) -> Result<(), error::DecapodError> {
let mut failures = Vec::new();
let mut lineage_records: Vec<(String, PolicyLineage)> = Vec::new();
let mut changelog_raw: Option<String> = None;
let changelog = project_root.join("CHANGELOG.md");
let cargo_lock = project_root.join("Cargo.lock");
let cargo_toml = project_root.join("Cargo.toml");
let rpc_golden_req = project_root.join("tests/golden/rpc/v1/agent_init.request.json");
let rpc_golden_res = project_root.join("tests/golden/rpc/v1/agent_init.response.json");
let artifact_manifest =
project_root.join(".decapod/generated/artifacts/provenance/artifact_manifest.json");
let proof_manifest =
project_root.join(".decapod/generated/artifacts/provenance/proof_manifest.json");
let intent_convergence_manifest = project_root
.join(".decapod/generated/artifacts/provenance/intent_convergence_checklist.json");
if !changelog.exists() {
failures.push("CHANGELOG.md missing".to_string());
} else {
let raw = fs::read_to_string(&changelog).map_err(error::DecapodError::IoError)?;
changelog_raw = Some(raw.clone());
if !raw.contains("## [Unreleased]") {
failures.push("CHANGELOG.md missing `## [Unreleased]` section".to_string());
}
}
if crate::core::assets::get_embedded_doc("docs/MIGRATIONS").is_none() {
failures.push("docs/MIGRATIONS missing from embedded assets".to_string());
}
if !cargo_lock.exists() {
failures.push("Cargo.lock missing (locked builds required)".to_string());
}
if !cargo_toml.exists() {
failures.push("Cargo.toml missing".to_string());
}
if !rpc_golden_req.exists() || !rpc_golden_res.exists() {
failures.push("RPC golden vectors missing under tests/golden/rpc/v1".to_string());
}
if !artifact_manifest.exists() {
failures.push(
"artifact provenance manifest missing: .decapod/generated/artifacts/provenance/artifact_manifest.json"
.to_string(),
);
}
if !proof_manifest.exists() {
failures.push(
"proof provenance manifest missing: .decapod/generated/artifacts/provenance/proof_manifest.json"
.to_string(),
);
}
if !intent_convergence_manifest.exists() {
failures.push(
"intent convergence manifest missing: .decapod/generated/artifacts/provenance/intent_convergence_checklist.json"
.to_string(),
);
}
if artifact_manifest.exists() && proof_manifest.exists() && intent_convergence_manifest.exists()
{
match stamp_release_policy_lineage(
project_root,
[
&artifact_manifest,
&proof_manifest,
&intent_convergence_manifest,
],
) {
Ok(lineage) => lineage_records.push(("lineage stamp baseline".to_string(), lineage)),
Err(e) => failures.push(format!("provenance lineage stamping failed: {}", e)),
}
}
if artifact_manifest.exists() {
match validate_artifact_manifest(project_root, &artifact_manifest) {
Ok(lineage) => lineage_records.push(("artifact manifest".to_string(), lineage)),
Err(e) => failures.push(format!("artifact manifest invalid: {}", e)),
}
}
if proof_manifest.exists() {
match validate_proof_manifest(project_root, &proof_manifest) {
Ok(lineage) => lineage_records.push(("proof manifest".to_string(), lineage)),
Err(e) => failures.push(format!("proof manifest invalid: {}", e)),
}
}
if intent_convergence_manifest.exists() {
match validate_intent_convergence_manifest(project_root, &intent_convergence_manifest) {
Ok(lineage) => {
lineage_records.push(("intent convergence manifest".to_string(), lineage))
}
Err(e) => failures.push(format!("intent convergence manifest invalid: {}", e)),
}
}
if let Some((baseline_name, baseline)) = lineage_records.first() {
for (name, lineage) in lineage_records.iter().skip(1) {
if lineage != baseline {
failures.push(format!(
"policy lineage mismatch: '{}' differs from '{}' ({:?} != {:?})",
name, baseline_name, lineage, baseline
));
}
}
}
let changed_paths = git_changed_paths(project_root);
if has_schema_or_interface_changes(&changed_paths) {
if let Some(changelog_text) = changelog_raw {
if !changelog_mentions_schema_or_interface(&changelog_text) {
failures.push(
"schema/interface files changed but CHANGELOG.md [Unreleased] has no schema/interface entry"
.to_string(),
);
}
} else {
failures.push(
"schema/interface files changed but CHANGELOG.md could not be read".to_string(),
);
}
}
if !failures.is_empty() {
return Err(error::DecapodError::ValidationError(format!(
"release.check failed:\n- {}",
failures.join("\n- ")
)));
}
println!(
"{}",
serde_json::json!({
"cmd": "release.check",
"status": "ok",
"checks": [
"changelog.unreleased",
"migrations.doc",
"cargo.lock.present",
"rpc.golden_vectors.present",
"provenance.manifests.verified",
"intent_convergence.manifest.verified",
"schema_interface.changelog.policy"
]
})
);
Ok(())
}
fn run_release_inventory(project_root: &Path) -> Result<(), error::DecapodError> {
let inventory = build_release_inventory(project_root)?;
let out_dir = project_root
.join(".decapod")
.join("generated")
.join("artifacts")
.join("inventory");
fs::create_dir_all(&out_dir).map_err(error::DecapodError::IoError)?;
let out_path = out_dir.join("repo_inventory.json");
let payload = serde_json::to_vec_pretty(&inventory).map_err(|e| {
error::DecapodError::ValidationError(format!(
"failed to serialize release inventory artifact: {e}"
))
})?;
fs::write(&out_path, payload).map_err(error::DecapodError::IoError)?;
println!(
"{}",
serde_json::json!({
"cmd": "release.inventory",
"status": "ok",
"artifact": ".decapod/generated/artifacts/inventory/repo_inventory.json",
"summary": inventory["totals"]
})
);
Ok(())
}
fn run_release_lineage_sync(project_root: &Path) -> Result<(), error::DecapodError> {
let artifact_manifest =
project_root.join(".decapod/generated/artifacts/provenance/artifact_manifest.json");
let proof_manifest =
project_root.join(".decapod/generated/artifacts/provenance/proof_manifest.json");
let intent_convergence_manifest = project_root
.join(".decapod/generated/artifacts/provenance/intent_convergence_checklist.json");
let mut missing = Vec::new();
if !artifact_manifest.exists() {
missing.push(".decapod/generated/artifacts/provenance/artifact_manifest.json");
}
if !proof_manifest.exists() {
missing.push(".decapod/generated/artifacts/provenance/proof_manifest.json");
}
if !intent_convergence_manifest.exists() {
missing.push(".decapod/generated/artifacts/provenance/intent_convergence_checklist.json");
}
if !missing.is_empty() {
return Err(error::DecapodError::ValidationError(format!(
"release.lineage_sync missing required provenance manifests: {}",
missing.join(", ")
)));
}
let lineage = stamp_release_policy_lineage(
project_root,
[
&artifact_manifest,
&proof_manifest,
&intent_convergence_manifest,
],
)?;
println!(
"{}",
serde_json::json!({
"cmd": "release.lineage_sync",
"status": "ok",
"policy_lineage": {
"policy_hash": lineage.policy_hash,
"policy_revision": lineage.policy_revision,
"risk_tier": lineage.risk_tier,
"capsule_path": lineage.capsule_path,
"capsule_hash": lineage.capsule_hash
},
"manifests": [
".decapod/generated/artifacts/provenance/artifact_manifest.json",
".decapod/generated/artifacts/provenance/proof_manifest.json",
".decapod/generated/artifacts/provenance/intent_convergence_checklist.json"
]
})
);
Ok(())
}
fn sha256_file(path: &Path) -> Result<String, error::DecapodError> {
let bytes = fs::read(path).map_err(error::DecapodError::IoError)?;
let mut hasher = Sha256::new();
hasher.update(bytes);
Ok(format!("{:x}", hasher.finalize()))
}
fn sha256_text(input: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
format!("{:x}", hasher.finalize())
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PolicyLineage {
policy_hash: String,
policy_revision: String,
risk_tier: String,
capsule_path: String,
capsule_hash: String,
}
fn resolve_release_risk_tier() -> Result<String, error::DecapodError> {
let tier = std::env::var("DECAPOD_RELEASE_RISK_TIER").unwrap_or_else(|_| "medium".to_string());
let normalized = tier.trim().to_ascii_lowercase();
if !matches!(normalized.as_str(), "low" | "medium" | "high" | "critical") {
return Err(error::DecapodError::ValidationError(format!(
"invalid DECAPOD_RELEASE_RISK_TIER '{}': expected low|medium|high|critical",
tier
)));
}
Ok(normalized)
}
fn resolve_release_capsule(project_root: &Path) -> Result<(String, String), error::DecapodError> {
let fallback = core::context_capsule::query_embedded_capsule(
project_root,
"release provenance",
"interfaces",
Some("R_releasecheck"),
None,
8,
)?;
let fallback_path = core::context_capsule::context_capsule_path(project_root, &fallback);
let capsule = if fallback_path.exists() {
let raw = fs::read_to_string(&fallback_path).map_err(error::DecapodError::IoError)?;
if raw.trim().is_empty() {
fallback
} else {
let parsed: core::context_capsule::DeterministicContextCapsule =
serde_json::from_str(&raw).map_err(|e| {
error::DecapodError::ValidationError(format!(
"invalid release capsule JSON at '{}': {}",
fallback_path.display(),
e
))
})?;
parsed.with_recomputed_hash().map_err(|e| {
error::DecapodError::ValidationError(format!(
"failed to recompute release capsule hash at '{}': {}",
fallback_path.display(),
e
))
})?
}
} else {
fallback
};
let path = core::context_capsule::write_context_capsule(project_root, &capsule)?;
let rel_path = path
.strip_prefix(project_root)
.map_err(|_| {
error::DecapodError::ValidationError(format!(
"release capsule path '{}' is outside project root",
path.display()
))
})?
.to_string_lossy()
.replace('\\', "/");
Ok((rel_path, capsule.capsule_hash))
}
fn stamp_release_policy_lineage<const N: usize>(
project_root: &Path,
manifest_paths: [&Path; N],
) -> Result<PolicyLineage, error::DecapodError> {
let policy_revision = "policy.release@v1".to_string();
let risk_tier = resolve_release_risk_tier()?;
let (capsule_path, capsule_hash) = resolve_release_capsule(project_root)?;
let policy_hash = sha256_text(&format!(
"{}|{}|{}",
policy_revision, risk_tier, capsule_hash
));
let lineage_json = serde_json::json!({
"policy_hash": policy_hash,
"policy_revision": policy_revision,
"risk_tier": risk_tier,
"capsule_path": capsule_path,
"capsule_hash": capsule_hash
});
for manifest_path in manifest_paths {
let raw = fs::read_to_string(manifest_path).map_err(error::DecapodError::IoError)?;
let mut v: serde_json::Value = serde_json::from_str(&raw).map_err(|e| {
error::DecapodError::ValidationError(format!(
"failed to parse JSON manifest '{}': {}",
manifest_path.display(),
e
))
})?;
let obj = v.as_object_mut().ok_or_else(|| {
error::DecapodError::ValidationError(format!(
"manifest '{}' must be a JSON object",
manifest_path.display()
))
})?;
obj.insert("policy_lineage".to_string(), lineage_json.clone());
let updated = serde_json::to_vec_pretty(&v).map_err(|e| {
error::DecapodError::ValidationError(format!(
"failed to serialize stamped manifest '{}': {}",
manifest_path.display(),
e
))
})?;
fs::write(manifest_path, updated).map_err(error::DecapodError::IoError)?;
}
Ok(PolicyLineage {
policy_hash: lineage_json["policy_hash"]
.as_str()
.unwrap_or("")
.to_string(),
policy_revision: lineage_json["policy_revision"]
.as_str()
.unwrap_or("")
.to_string(),
risk_tier: lineage_json["risk_tier"].as_str().unwrap_or("").to_string(),
capsule_path: lineage_json["capsule_path"]
.as_str()
.unwrap_or("")
.to_string(),
capsule_hash: lineage_json["capsule_hash"]
.as_str()
.unwrap_or("")
.to_string(),
})
}
fn validate_policy_lineage(
project_root: &Path,
v: &serde_json::Value,
manifest_label: &str,
) -> Result<PolicyLineage, error::DecapodError> {
let lineage = v
.get("policy_lineage")
.and_then(|x| x.as_object())
.ok_or_else(|| {
error::DecapodError::ValidationError(format!(
"{manifest_label} missing policy_lineage object"
))
})?;
let required = [
"policy_hash",
"policy_revision",
"risk_tier",
"capsule_path",
"capsule_hash",
];
for key in required {
let value = lineage.get(key).and_then(|x| x.as_str()).unwrap_or("");
if value.is_empty() || value.contains("TO_BE_FILLED") {
return Err(error::DecapodError::ValidationError(format!(
"{manifest_label} policy_lineage.{key} must be non-empty and non-placeholder"
)));
}
}
let policy_hash = lineage
.get("policy_hash")
.and_then(|x| x.as_str())
.unwrap_or("");
if policy_hash.len() != 64 || !policy_hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(error::DecapodError::ValidationError(format!(
"{manifest_label} policy_lineage.policy_hash must be a 64-char hex digest"
)));
}
let capsule_hash = lineage
.get("capsule_hash")
.and_then(|x| x.as_str())
.unwrap_or("");
if capsule_hash.len() != 64 || !capsule_hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(error::DecapodError::ValidationError(format!(
"{manifest_label} policy_lineage.capsule_hash must be a 64-char hex digest"
)));
}
let risk_tier = lineage
.get("risk_tier")
.and_then(|x| x.as_str())
.unwrap_or("");
if !matches!(risk_tier, "low" | "medium" | "high" | "critical") {
return Err(error::DecapodError::ValidationError(format!(
"{manifest_label} policy_lineage.risk_tier invalid: expected low|medium|high|critical"
)));
}
let capsule_path = lineage
.get("capsule_path")
.and_then(|x| x.as_str())
.unwrap_or("");
let abs = project_root.join(capsule_path);
if !abs.exists() {
return Err(error::DecapodError::ValidationError(format!(
"{manifest_label} policy_lineage.capsule_path '{}' does not exist",
capsule_path
)));
}
let raw_capsule = fs::read_to_string(&abs).map_err(error::DecapodError::IoError)?;
if !raw_capsule.trim().is_empty() {
let parsed: core::context_capsule::DeterministicContextCapsule =
serde_json::from_str(&raw_capsule).map_err(|e| {
error::DecapodError::ValidationError(format!(
"{manifest_label} policy_lineage capsule at '{}' is not valid deterministic capsule JSON: {}",
capsule_path, e
))
})?;
let normalized = parsed.with_recomputed_hash().map_err(|e| {
error::DecapodError::ValidationError(format!(
"{manifest_label} policy_lineage capsule hash computation failed for '{}': {}",
capsule_path, e
))
})?;
if parsed.capsule_hash != normalized.capsule_hash {
return Err(error::DecapodError::ValidationError(format!(
"{manifest_label} policy_lineage capsule file '{}' has internal hash mismatch",
capsule_path
)));
}
if capsule_hash != normalized.capsule_hash {
return Err(error::DecapodError::ValidationError(format!(
"{manifest_label} policy_lineage capsule_hash mismatch for '{}'",
capsule_path
)));
}
}
Ok(PolicyLineage {
policy_hash: policy_hash.to_string(),
policy_revision: lineage
.get("policy_revision")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string(),
risk_tier: risk_tier.to_string(),
capsule_path: capsule_path.to_string(),
capsule_hash: capsule_hash.to_string(),
})
}
fn validate_artifact_manifest(
project_root: &Path,
manifest_path: &Path,
) -> Result<PolicyLineage, error::DecapodError> {
let raw = fs::read_to_string(manifest_path).map_err(error::DecapodError::IoError)?;
let v: serde_json::Value = serde_json::from_str(&raw).map_err(|e| {
error::DecapodError::ValidationError(format!("artifact manifest is not valid JSON: {e}"))
})?;
if v.get("schema_version").and_then(|x| x.as_str()) != Some("1.0.0") {
return Err(error::DecapodError::ValidationError(
"artifact manifest schema_version must be 1.0.0".to_string(),
));
}
if v.get("kind").and_then(|x| x.as_str()) != Some("artifact_manifest") {
return Err(error::DecapodError::ValidationError(
"artifact manifest kind must be artifact_manifest".to_string(),
));
}
let lineage = validate_policy_lineage(project_root, &v, "artifact manifest")?;
let artifacts = v
.get("artifacts")
.and_then(|x| x.as_array())
.ok_or_else(|| {
error::DecapodError::ValidationError(
"artifact manifest artifacts[] required".to_string(),
)
})?;
if artifacts.is_empty() {
return Err(error::DecapodError::ValidationError(
"artifact manifest artifacts[] must not be empty".to_string(),
));
}
for entry in artifacts {
let path = entry.get("path").and_then(|x| x.as_str()).ok_or_else(|| {
error::DecapodError::ValidationError("artifact entry missing path".to_string())
})?;
let sha = entry
.get("sha256")
.and_then(|x| x.as_str())
.ok_or_else(|| {
error::DecapodError::ValidationError("artifact entry missing sha256".to_string())
})?;
if sha.is_empty() || sha.contains("TO_BE_FILLED") {
return Err(error::DecapodError::ValidationError(format!(
"artifact entry '{}' has placeholder sha256",
path
)));
}
let abs = project_root.join(path);
if !abs.exists() {
return Err(error::DecapodError::ValidationError(format!(
"artifact entry '{}' does not exist",
path
)));
}
let actual = sha256_file(&abs)?;
if actual != sha {
return Err(error::DecapodError::ValidationError(format!(
"artifact entry '{}' sha256 mismatch",
path
)));
}
}
Ok(lineage)
}
fn validate_proof_manifest(
project_root: &Path,
manifest_path: &Path,
) -> Result<PolicyLineage, error::DecapodError> {
let raw = fs::read_to_string(manifest_path).map_err(error::DecapodError::IoError)?;
let v: serde_json::Value = serde_json::from_str(&raw).map_err(|e| {
error::DecapodError::ValidationError(format!("proof manifest is not valid JSON: {e}"))
})?;
if v.get("schema_version").and_then(|x| x.as_str()) != Some("1.0.0") {
return Err(error::DecapodError::ValidationError(
"proof manifest schema_version must be 1.0.0".to_string(),
));
}
if v.get("kind").and_then(|x| x.as_str()) != Some("proof_manifest") {
return Err(error::DecapodError::ValidationError(
"proof manifest kind must be proof_manifest".to_string(),
));
}
let lineage = validate_policy_lineage(project_root, &v, "proof manifest")?;
let proofs = v.get("proofs").and_then(|x| x.as_array()).ok_or_else(|| {
error::DecapodError::ValidationError("proof manifest proofs[] required".to_string())
})?;
if proofs.is_empty() {
return Err(error::DecapodError::ValidationError(
"proof manifest proofs[] must not be empty".to_string(),
));
}
for p in proofs {
let command = p.get("command").and_then(|x| x.as_str()).unwrap_or("");
let result = p.get("result").and_then(|x| x.as_str()).unwrap_or("");
if command.is_empty() || command.contains("TO_BE_FILLED") {
return Err(error::DecapodError::ValidationError(
"proof manifest command must be non-empty and non-placeholder".to_string(),
));
}
if result.is_empty() || result.contains("TO_BE_FILLED") {
return Err(error::DecapodError::ValidationError(
"proof manifest result must be non-empty and non-placeholder".to_string(),
));
}
}
let env = v
.get("environment")
.and_then(|x| x.as_object())
.ok_or_else(|| {
error::DecapodError::ValidationError("proof manifest environment required".to_string())
})?;
for key in ["os", "rust"] {
let value = env.get(key).and_then(|x| x.as_str()).unwrap_or("");
if value.is_empty() || value.contains("TO_BE_FILLED") {
return Err(error::DecapodError::ValidationError(format!(
"proof manifest environment.{} must be non-empty and non-placeholder",
key
)));
}
}
Ok(lineage)
}
fn validate_intent_convergence_manifest(
project_root: &Path,
manifest_path: &Path,
) -> Result<PolicyLineage, error::DecapodError> {
let raw = fs::read_to_string(manifest_path).map_err(error::DecapodError::IoError)?;
let v: serde_json::Value = serde_json::from_str(&raw).map_err(|e| {
error::DecapodError::ValidationError(format!(
"intent convergence manifest is not valid JSON: {e}"
))
})?;
if v.get("schema_version").and_then(|x| x.as_str()) != Some("1.0.0") {
return Err(error::DecapodError::ValidationError(
"intent convergence manifest schema_version must be 1.0.0".to_string(),
));
}
if v.get("kind").and_then(|x| x.as_str()) != Some("intent_convergence_checklist") {
return Err(error::DecapodError::ValidationError(
"intent convergence manifest kind must be intent_convergence_checklist".to_string(),
));
}
let lineage = validate_policy_lineage(project_root, &v, "intent convergence manifest")?;
for key in ["pr", "intent", "scope", "checklist"] {
if v.get(key).is_none() {
return Err(error::DecapodError::ValidationError(format!(
"intent convergence manifest missing '{}' field",
key
)));
}
}
let checklist = v
.get("checklist")
.and_then(|x| x.as_array())
.ok_or_else(|| {
error::DecapodError::ValidationError(
"intent convergence manifest checklist[] required".to_string(),
)
})?;
if checklist.is_empty() {
return Err(error::DecapodError::ValidationError(
"intent convergence manifest checklist[] must not be empty".to_string(),
));
}
for item in checklist {
let name = item.get("name").and_then(|x| x.as_str()).unwrap_or("");
let status = item.get("status").and_then(|x| x.as_str()).unwrap_or("");
let evidence = item.get("evidence").and_then(|x| x.as_str()).unwrap_or("");
if name.is_empty() || status.is_empty() || evidence.is_empty() {
return Err(error::DecapodError::ValidationError(
"intent convergence checklist entries require name/status/evidence".to_string(),
));
}
if matches!(status, "pending" | "unknown") {
return Err(error::DecapodError::ValidationError(format!(
"intent convergence checklist item '{}' must be resolved (status={})",
name, status
)));
}
}
Ok(lineage)
}
fn build_release_inventory(project_root: &Path) -> Result<serde_json::Value, error::DecapodError> {
let mut paths = Vec::new();
let mut roots = vec!["src", "tests"];
if project_root.join("constitution").exists() {
roots.push("constitution");
}
for root in roots {
let p = project_root.join(root);
if p.is_dir() {
collect_files_recursive(&p, &mut paths)?;
}
}
if project_root.join("assets/constitution.json").exists() {
paths.push(PathBuf::from("assets/constitution.json"));
}
paths.sort();
let mut top_files = Vec::new();
let mut totals_by_root: BTreeMap<&'static str, u64> = BTreeMap::new();
let mut rust_files = 0u64;
let mut test_files = 0u64;
for path in paths {
let rel = match path.strip_prefix(project_root) {
Ok(p) => p.to_path_buf(),
Err(_) => continue,
};
let rel_s = rel.to_string_lossy().replace('\\', "/");
let raw = fs::read_to_string(&path).unwrap_or_default();
let loc = raw.lines().count() as u64;
if rel_s.starts_with("src/") {
*totals_by_root.entry("src_loc").or_insert(0) += loc;
} else if rel_s.starts_with("tests/") {
*totals_by_root.entry("tests_loc").or_insert(0) += loc;
} else if rel_s.starts_with("constitution/") || rel_s == "assets/constitution.json" {
*totals_by_root.entry("constitution_loc").or_insert(0) += loc;
}
if rel_s.ends_with(".rs") {
rust_files += 1;
}
if rel_s.starts_with("tests/") {
test_files += 1;
}
top_files.push((rel_s, loc));
}
top_files.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
let top_files: Vec<serde_json::Value> = top_files
.into_iter()
.take(25)
.map(|(path, loc)| serde_json::json!({ "path": path, "loc": loc }))
.collect();
let src_loc = *totals_by_root.get("src_loc").unwrap_or(&0);
let tests_loc = *totals_by_root.get("tests_loc").unwrap_or(&0);
let constitution_loc = *totals_by_root.get("constitution_loc").unwrap_or(&0);
Ok(serde_json::json!({
"schema_version": "1.0.0",
"kind": "repo_inventory",
"scope": ["src", "tests", "assets/constitution.json"],
"totals": {
"src_loc": src_loc,
"tests_loc": tests_loc,
"constitution_loc": constitution_loc,
"total_loc": src_loc + tests_loc + constitution_loc,
"rust_files": rust_files,
"test_files": test_files
},
"top_files_by_loc": top_files
}))
}
fn collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) -> Result<(), error::DecapodError> {
if !root.exists() {
return Ok(());
}
for entry in fs::read_dir(root).map_err(error::DecapodError::IoError)? {
let entry = entry.map_err(error::DecapodError::IoError)?;
let path = entry.path();
if path.is_dir() {
collect_files_recursive(&path, out)?;
} else if path.is_file() {
out.push(path);
}
}
Ok(())
}
fn git_changed_paths(project_root: &Path) -> Vec<String> {
let output = std::process::Command::new("git")
.current_dir(project_root)
.args(["status", "--porcelain"])
.output();
let Ok(output) = output else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
let raw = String::from_utf8_lossy(&output.stdout);
let mut paths = Vec::new();
for line in raw.lines() {
if line.len() < 4 {
continue;
}
let candidate = line[3..].trim();
if let Some((_, to)) = candidate.split_once(" -> ") {
paths.push(to.trim().to_string());
} else {
paths.push(candidate.to_string());
}
}
paths
}
fn has_schema_or_interface_changes(paths: &[String]) -> bool {
paths.iter().any(|path| {
path == "assets/constitution.json"
|| path == "src/core/schemas.rs"
|| path == "src/core/rpc.rs"
|| path.starts_with("tests/golden/rpc/")
})
}
fn changelog_mentions_schema_or_interface(changelog_raw: &str) -> bool {
let lower = changelog_raw.to_ascii_lowercase();
let Some(start) = lower.find("## [unreleased]") else {
return false;
};
let section = &lower[start..];
let next_heading = section[14..]
.find("\n## ")
.map(|idx| idx + 14)
.unwrap_or(section.len());
let unreleased = §ion[..next_heading];
unreleased.contains("schema") || unreleased.contains("interface")
}
#[derive(Debug, Clone, Serialize)]
struct ValidationHealAction {
action: String,
outcome: String,
detail: String,
}
fn should_scaffold_validation_surfaces(project_root: &Path) -> bool {
let required = [
"AGENTS.md",
".decapod/README.md",
".decapod/generated/Dockerfile",
".decapod/generated/specs/README.md",
".decapod/generated/specs/INTENT.md",
".decapod/generated/specs/ARCHITECTURE.md",
".decapod/generated/specs/INTERFACES.md",
".decapod/generated/specs/VALIDATION.md",
".decapod/generated/specs/.manifest.json",
".decapod/generated/policy/context_capsule_policy.json",
];
required.iter().any(|rel| !project_root.join(rel).exists())
}
fn heal_agents_contract(
project_root: &Path,
) -> Result<Option<ValidationHealAction>, error::DecapodError> {
let path = project_root.join("AGENTS.md");
if !path.exists() {
let content = core::assets::get_template("AGENTS.md").ok_or_else(|| {
error::DecapodError::ValidationError("Missing AGENTS.md template".to_string())
})?;
atomic_write_file(&path, &content)?;
return Ok(Some(ValidationHealAction {
action: "heal_agents_contract".to_string(),
outcome: "recreated".to_string(),
detail: "Restored missing AGENTS.md from the canonical Decapod template.".to_string(),
}));
}
let mut content = fs::read_to_string(&path).map_err(error::DecapodError::IoError)?;
let mut anchors = Vec::new();
for marker in [
"Stop if",
"via decapod CLI",
"interface abstraction boundary",
"Strict Dependency: You are strictly bound to the Decapod governance kernel",
] {
if !content.contains(marker) {
anchors.push(marker);
}
}
if anchors.is_empty() {
return Ok(None);
}
content.push_str("\n\n<!-- decapod-validator-anchors\n");
for anchor in &anchors {
content.push_str(anchor);
content.push('\n');
}
content.push_str("-->\n");
atomic_write_file(&path, &content)?;
Ok(Some(ValidationHealAction {
action: "heal_agents_contract".to_string(),
outcome: "updated".to_string(),
detail: format!(
"Added {} missing validator anchor(s) to AGENTS.md.",
anchors.len()
),
}))
}
fn heal_validation_scaffold(
project_root: &Path,
) -> Result<Option<ValidationHealAction>, error::DecapodError> {
if !should_scaffold_validation_surfaces(project_root) {
return Ok(None);
}
let repo_ctx = infer_repo_context(project_root);
let summary = scaffold::scaffold_project_entrypoints(&scaffold::ScaffoldOptions {
target_dir: project_root.to_path_buf(),
force: false,
dry_run: false,
agent_files: Vec::new(),
created_backups: false,
all: false,
generate_specs: true,
diagram_style: scaffold::DiagramStyle::Ascii,
specs_seed: Some(scaffold::SpecsSeed {
product_name: repo_ctx.product_name,
product_summary: repo_ctx.product_summary,
architecture_direction: repo_ctx.architecture_direction,
product_type: repo_ctx.product_type,
primary_languages: repo_ctx.primary_languages,
detected_surfaces: repo_ctx.detected_surfaces,
done_criteria: repo_ctx.done_criteria,
}),
})?;
Ok(Some(ValidationHealAction {
action: "heal_validation_scaffold".to_string(),
outcome: "updated".to_string(),
detail: format!(
"Scaffolded missing validation surfaces (entrypoints_created={}, config_created={}, specs_created={}).",
summary.entrypoints_created, summary.config_created, summary.specs_created
),
}))
}
fn heal_override_checksum(
project_root: &Path,
) -> Result<Option<ValidationHealAction>, error::DecapodError> {
match docs_cli::sync_override_checksum(project_root, false)? {
docs_cli::OverrideChecksumStatus::MissingOverride
| docs_cli::OverrideChecksumStatus::Unchanged => Ok(None),
docs_cli::OverrideChecksumStatus::Cached => Ok(Some(ValidationHealAction {
action: "heal_override_checksum".to_string(),
outcome: "cached".to_string(),
detail: "Cached OVERRIDE.md checksum for deterministic governance reads.".to_string(),
})),
docs_cli::OverrideChecksumStatus::Updated => Ok(Some(ValidationHealAction {
action: "heal_override_checksum".to_string(),
outcome: "refreshed".to_string(),
detail: "Refreshed OVERRIDE.md checksum after local override drift.".to_string(),
})),
}
}
fn heal_container_runtime_override(
project_root: &Path,
) -> Result<Option<ValidationHealAction>, error::DecapodError> {
match container::heal_container_runtime_override(project_root)? {
container::ContainerRuntimeOverrideHeal::Cleared => Ok(Some(ValidationHealAction {
action: "heal_container_runtime_override".to_string(),
outcome: "cleared".to_string(),
detail: "Removed stale container-runtime override because Docker/Podman support is available.".to_string(),
})),
container::ContainerRuntimeOverrideHeal::Unchanged => Ok(None),
}
}
fn attempt_validation_failure_heal(
report: &validate::ValidationReport,
project_root: &Path,
store: &Store,
) -> Result<Vec<ValidationHealAction>, error::DecapodError> {
let mut actions = Vec::new();
if report.failures.iter().any(|msg| {
msg.contains("Repo store missing todo.db")
|| msg.contains("Repo todo.db does NOT match rebuild from todo.events.jsonl")
}) {
let rebuild = todo::rebuild_from_events(&store.root)?;
actions.push(ValidationHealAction {
action: "todo.rebuild".to_string(),
outcome: "repaired".to_string(),
detail: format!("Rebuilt todo.db from event log: {}", rebuild),
});
}
if report
.failures
.iter()
.any(|msg| msg.contains("AGENTS.md missing") || msg.contains("Invariant missing:"))
&& let Some(action) = heal_agents_contract(project_root)?
{
actions.push(action);
}
if report.failures.iter().any(|msg| {
msg.contains("Missing required project specs file:")
|| msg.contains("Context capsule policy schema mismatch")
}) && let Some(action) = heal_validation_scaffold(project_root)?
{
actions.push(action);
}
if report
.failures
.iter()
.any(|msg| msg.contains("claim.git.container_workspace_required"))
&& let Some(action) = heal_container_runtime_override(project_root)?
{
actions.push(action);
}
Ok(actions)
}
fn render_validation_text(
report: &validate::ValidationReport,
actions: &[ValidationHealAction],
verbose: bool,
) {
use crate::core::ansi::AnsiExt;
validate::render_validation_report(report, verbose);
if !actions.is_empty() {
if verbose {
println!(
" {} {}",
"repair".bright_blue().bold(),
format!("{} action(s)", actions.len()).bright_white()
);
for action in actions {
println!(
" {} {} {}",
"↺".bright_blue(),
action.action.bright_cyan(),
action.detail
);
}
} else {
println!(
" {} {} action(s) applied; use `-v` for repair details",
"repair".bright_blue().bold(),
actions.len().to_string().bright_white()
);
}
}
}
fn run_validate_command(
validate_cli: ValidateCli,
project_root: &Path,
project_store: &Store,
) -> Result<(), error::DecapodError> {
use crate::core::workspace;
if std::env::var("DECAPOD_VALIDATE_SKIP_GIT_GATES").is_ok() {
} else {
let workspace_status = workspace::get_workspace_status(project_root)?;
if !workspace_status.can_work {
let blocker = workspace_status
.blockers
.first()
.expect("Workspace should have a blocker if can_work is false");
let response = serde_json::json!({
"success": false,
"gate": "workspace_protection",
"error": blocker.message,
"resolve_hint": blocker.resolve_hint,
"branch": workspace_status.git.current_branch,
"is_protected": workspace_status.git.is_protected,
"in_container": workspace_status.container.in_container,
});
if validate_cli.format == "json" {
println!("{}", serde_json::to_string_pretty(&response).unwrap());
} else {
eprintln!("validation needs attention: workspace protection");
eprintln!(" branch: {}", workspace_status.git.current_branch);
eprintln!(" reason: {}", blocker.message);
eprintln!(" next: {}", blocker.resolve_hint);
}
std::process::exit(1);
}
}
let decapod_root = project_root.to_path_buf();
let store = match validate_cli.store.as_str() {
"user" => {
let tmp_root = std::env::temp_dir().join(format!(
"decapod_validate_user_{}",
crate::core::ulid::new_ulid()
));
std::fs::create_dir_all(&tmp_root).map_err(error::DecapodError::IoError)?;
Store {
kind: StoreKind::User,
root: tmp_root,
}
}
_ => project_store.clone(),
};
let mut heal_actions = Vec::new();
if let Some(action) = heal_override_checksum(project_root)? {
heal_actions.push(action);
}
if let Some(action) = heal_validation_scaffold(project_root)? {
heal_actions.push(action);
}
if let Some(action) = heal_agents_contract(project_root)? {
heal_actions.push(action);
}
if let Some(action) = heal_container_runtime_override(project_root)? {
heal_actions.push(action);
}
let mut report = run_validation_bounded(&store, &decapod_root, validate_cli.verbose)?;
for _ in 0..2 {
if report.fail_count == 0 {
break;
}
let mut round_actions = attempt_validation_failure_heal(&report, project_root, &store)?;
if round_actions.is_empty() {
break;
}
heal_actions.append(&mut round_actions);
report = run_validation_bounded(&store, &decapod_root, validate_cli.verbose)?;
}
if validate_cli.format == "json" {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"status": report.status,
"self_heal": heal_actions,
"report": report,
}))
.map_err(|e| error::DecapodError::ValidationError(format!(
"validate JSON encode failed: {e}"
)))?,
);
} else {
render_validation_text(&report, &heal_actions, validate_cli.verbose);
}
if report.fail_count > 0 {
std::process::exit(1);
}
mark_validation_completed(project_root)?;
Ok(())
}
fn validate_timeout_secs() -> u64 {
std::env::var("DECAPOD_VALIDATE_TIMEOUT_SECS")
.ok()
.or_else(|| std::env::var("DECAPOD_VALIDATE_TIMEOUT_SECONDS").ok())
.and_then(|v| v.parse::<u64>().ok())
.filter(|v| *v > 0)
.unwrap_or(120)
}
fn validate_diagnostics_enabled() -> bool {
std::env::var("DECAPOD_DIAGNOSTICS")
.ok()
.map(|v| matches!(v.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
.unwrap_or(false)
}
fn classify_validate_failure_reason(message: &str) -> &'static str {
let lower = message.to_ascii_lowercase();
if lower.contains("sqlite contention") || lower.contains("database is locked") {
return "timeout_acquiring_lock";
}
if lower.contains("exceeded timeout") {
return "timeout_running_validations";
}
if lower.contains("worker disconnected") {
return "worker_disconnected";
}
"validate_failure"
}
fn lock_age_ms(project_root: &Path) -> Option<u64> {
let data_dir = project_root.join(".decapod").join("data");
let entries = fs::read_dir(data_dir).ok()?;
let now = SystemTime::now();
let mut max_age_ms: Option<u64> = None;
for entry in entries.flatten() {
let file_name = entry.file_name();
let file_name = file_name.to_string_lossy();
if !(file_name.ends_with("-wal")
|| file_name.ends_with("-shm")
|| file_name.ends_with("-journal"))
{
continue;
}
let Ok(meta) = entry.metadata() else {
continue;
};
let Ok(modified) = meta.modified() else {
continue;
};
let Ok(age) = now.duration_since(modified) else {
continue;
};
let age_ms = age.as_millis() as u64;
max_age_ms = Some(max_age_ms.map_or(age_ms, |existing| existing.max(age_ms)));
}
max_age_ms
}
fn write_validate_diagnostic_artifact(
project_root: &Path,
reason_code: &str,
elapsed_ms: u64,
timeout_secs: u64,
) -> Result<PathBuf, error::DecapodError> {
let mut run_id_hasher = Sha256::new();
run_id_hasher.update(crate::core::ulid::new_ulid().as_bytes());
let run_id = hash_bytes_hex(&run_id_hasher.finalize())[..32].to_string();
let diagnostics_dir = project_root.join(".decapod/generated/artifacts/diagnostics/validate");
fs::create_dir_all(&diagnostics_dir).map_err(error::DecapodError::IoError)?;
let mut payload = serde_json::json!({
"schema_version": "1.0.0",
"kind": "validate_diagnostic",
"run_id": run_id,
"op": "validate",
"reason_code": reason_code,
"elapsed_ms": elapsed_ms,
"timeout_secs": timeout_secs,
"lock_age_ms": lock_age_ms(project_root),
"stale_lock_recovery_triggered": false
});
let payload_bytes = serde_json::to_vec(&payload).map_err(|e| {
error::DecapodError::ValidationError(format!("Failed to encode validate diagnostics: {e}"))
})?;
let mut hasher = Sha256::new();
hasher.update(payload_bytes);
let artifact_hash = hash_bytes_hex(&hasher.finalize());
payload["artifact_hash"] = serde_json::json!(artifact_hash);
let relative_path = PathBuf::from(format!(
".decapod/generated/artifacts/diagnostics/validate/{run_id}.json"
));
let artifact_path = project_root.join(&relative_path);
let pretty = serde_json::to_vec_pretty(&payload).map_err(|e| {
error::DecapodError::ValidationError(format!(
"Failed to serialize validate diagnostics artifact: {e}"
))
})?;
fs::write(&artifact_path, pretty).map_err(error::DecapodError::IoError)?;
Ok(relative_path)
}
fn attach_validate_diagnostic_if_enabled(
err: error::DecapodError,
project_root: &Path,
elapsed_ms: u64,
timeout_secs: u64,
) -> error::DecapodError {
if !validate_diagnostics_enabled() {
return err;
}
let error::DecapodError::ValidationError(message) = err else {
return err;
};
if !message.contains("VALIDATE_TIMEOUT_OR_LOCK") {
return error::DecapodError::ValidationError(message);
}
let reason_code = classify_validate_failure_reason(&message);
match write_validate_diagnostic_artifact(project_root, reason_code, elapsed_ms, timeout_secs) {
Ok(relative_path) => error::DecapodError::ValidationError(format!(
"{} Diagnostics: {}",
message,
relative_path.display()
)),
Err(diag_err) => error::DecapodError::ValidationError(format!(
"{} DiagnosticsWriteError: {}",
message, diag_err
)),
}
}
fn normalize_validate_error(err: error::DecapodError) -> error::DecapodError {
match err {
error::DecapodError::RusqliteError(rusqlite::Error::SqliteFailure(code, msg)) => {
let is_lock = code.code == rusqlite::ErrorCode::DatabaseBusy
|| code.extended_code == 522
|| msg
.as_deref()
.unwrap_or_default()
.to_ascii_lowercase()
.contains("locked");
if is_lock {
return error::DecapodError::ValidationError(
"VALIDATE_TIMEOUT_OR_LOCK: SQLite contention detected. Retry with backoff or inspect concurrent decapod processes.".to_string(),
);
}
error::DecapodError::RusqliteError(rusqlite::Error::SqliteFailure(code, msg))
}
error::DecapodError::ValidationError(message) => {
let lower = message.to_ascii_lowercase();
if lower.contains("database is locked")
|| lower.contains("databasebusy")
|| lower.contains("sqlite_code=databasebusy")
{
return error::DecapodError::ValidationError(
"VALIDATE_TIMEOUT_OR_LOCK: SQLite contention detected. Retry with backoff or inspect concurrent decapod processes.".to_string(),
);
}
error::DecapodError::ValidationError(message)
}
other => other,
}
}
fn retry_transient_sqlite<T, F>(mut op: F, max_attempts: u32) -> Result<T, error::DecapodError>
where
F: FnMut() -> Result<T, error::DecapodError>,
{
let mut attempt = 0u32;
loop {
match op() {
Ok(v) => return Ok(v),
Err(e) if is_transient_sqlite_contention_error(&e) && attempt + 1 < max_attempts => {
let delay_ms = (50u64 * 2u64.pow(attempt)).min(800);
attempt += 1;
thread::sleep(std::time::Duration::from_millis(delay_ms));
}
Err(e) => return Err(e),
}
}
}
fn is_transient_sqlite_contention_error(err: &error::DecapodError) -> bool {
match err {
error::DecapodError::RusqliteError(rusqlite::Error::SqliteFailure(code, msg)) => {
if matches!(
code.code,
rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
) || code.extended_code == 522
{
return true;
}
let lower = msg.as_deref().unwrap_or_default().to_ascii_lowercase();
lower.contains("locked") || lower.contains("disk i/o error")
}
error::DecapodError::ValidationError(message) => {
let lower = message.to_ascii_lowercase();
lower.contains("database is locked")
|| lower.contains("databasebusy")
|| lower.contains("sqlite contention")
|| lower.contains("disk i/o error")
|| lower.contains("extended_code: 522")
}
other => {
let lower = other.to_string().to_ascii_lowercase();
lower.contains("database is locked")
|| lower.contains("databasebusy")
|| lower.contains("disk i/o error")
|| lower.contains("extended_code: 522")
}
}
}
fn run_validation_bounded(
store: &Store,
project_root: &Path,
verbose: bool,
) -> Result<validate::ValidationReport, error::DecapodError> {
let timeout_secs = validate_timeout_secs();
let started = std::time::Instant::now();
let (tx, rx) = mpsc::channel();
let store_cloned = store.clone();
let root = project_root.to_path_buf();
std::thread::spawn(move || {
let mut result = validate::run_validation(&store_cloned, &root, &root, verbose);
for attempt in 1..=2 {
let should_retry = match &result {
Err(error::DecapodError::RusqliteError(err)) => {
format!("{err}").to_ascii_lowercase().contains("locked")
}
Err(error::DecapodError::ValidationError(msg)) => {
let lower = msg.to_ascii_lowercase();
lower.contains("database is locked")
|| lower.contains("databasebusy")
|| lower.contains("sqlite_code=databasebusy")
}
_ => false,
};
if !should_retry {
break;
}
let backoff_ms = 200_u64 * attempt as u64;
std::thread::sleep(std::time::Duration::from_millis(backoff_ms));
result = validate::run_validation(&store_cloned, &root, &root, verbose);
}
let _ = tx.send(result);
});
let result = match rx.recv_timeout(std::time::Duration::from_secs(timeout_secs)) {
Ok(result) => result.map_err(normalize_validate_error),
Err(mpsc::RecvTimeoutError::Timeout) => Err(error::DecapodError::ValidationError(format!(
"VALIDATE_TIMEOUT_OR_LOCK: validate exceeded timeout ({}s). Terminated to preserve proof-gate liveness.",
timeout_secs
))),
Err(mpsc::RecvTimeoutError::Disconnected) => Err(error::DecapodError::ValidationError(
"VALIDATE_TIMEOUT_OR_LOCK: validate worker disconnected unexpectedly.".to_string(),
)),
};
result.map_err(|err| {
attach_validate_diagnostic_if_enabled(
err,
project_root,
started.elapsed().as_millis() as u64,
timeout_secs,
)
})
}
fn rpc_op_requires_constitutional_awareness(op: &str) -> bool {
matches!(
op,
"workspace.publish"
| "store.upsert"
| "scaffold.apply_answer"
| "scaffold.generate_artifacts"
)
}
fn rpc_op_skips_mandate_enforcement(op: &str) -> bool {
matches!(
op,
"context.resolve"
| "context.scope"
| "context.bindings"
| "context.capsule.query"
| "constitution.get"
| "schema.get"
)
}
fn enforce_constitutional_awareness_for_rpc(
op: &str,
project_root: &Path,
) -> Result<(), error::DecapodError> {
if !rpc_op_requires_constitutional_awareness(op) {
return Ok(());
}
let agent_id = current_agent_id();
let rec = read_awareness_record(project_root, &agent_id)?;
let Some(rec) = rec else {
return Err(error::DecapodError::ValidationError(
r#"Constitutional awareness required before mutating operations. Run `decapod validate`, then `decapod session acquire`, `decapod rpc --op agent.init`, `decapod rpc --op constitution.get --params '{"section":"core/DECAPOD"}'`, and `decapod rpc --op context.resolve`."#
.to_string(),
));
};
if rec.validated_at_epoch_secs.is_none() {
return Err(error::DecapodError::ValidationError(
"Constitutional awareness incomplete: `decapod validate` has not completed for this agent context. Run `decapod validate` first."
.to_string(),
));
}
if rec.core_constitution_ingested_at_epoch_secs.is_none() {
return Err(error::DecapodError::ValidationError(
r#"Constitutional awareness incomplete: core constitution RPC resolution missing. Run `decapod rpc --op constitution.get --params '{"section":"core/DECAPOD"}'` before mutating operations."#
.to_string(),
));
}
if rec.context_resolved_at_epoch_secs.is_none() {
return Err(error::DecapodError::ValidationError(
"Constitutional awareness incomplete: `context.resolve` has not been executed after initialization. Run `decapod rpc --op context.resolve`."
.to_string(),
));
}
if let Some(session) = read_agent_session(project_root, &agent_id)?
&& rec.session_token.as_deref() != Some(session.token.as_str())
{
return Err(error::DecapodError::ValidationError(
"Constitutional awareness is stale for the active session. Re-run `decapod rpc --op agent.init` and `decapod rpc --op context.resolve`."
.to_string(),
));
}
Ok(())
}
fn run_govern_command(
govern_cli: GovernCli,
project_store: &Store,
store_root: &Path,
) -> Result<(), error::DecapodError> {
match govern_cli.command {
GovernCommand::Policy(policy_cli) => policy::run_policy_cli(project_store, policy_cli)?,
GovernCommand::Health(health_cli) => health::run_health_cli(project_store, health_cli)?,
GovernCommand::Proof(proof_cli) => proof::execute_proof_cli(&proof_cli, store_root)?,
GovernCommand::Watcher(watcher_cli) => match watcher_cli.command {
WatcherCommand::Run => {
let report = watcher::run_watcher(project_store)?;
println!("{}", serde_json::to_string_pretty(&report).unwrap());
}
},
GovernCommand::Feedback(feedback_cli) => {
feedback::initialize_feedback_db(store_root)?;
match feedback_cli.command {
FeedbackCommand::Add {
source,
text,
links,
} => {
let id =
feedback::add_feedback(project_store, &source, &text, links.as_deref())?;
println!("Feedback recorded: {}", id);
}
FeedbackCommand::Propose => {
let proposal = feedback::propose_prefs(project_store)?;
println!("{}", proposal);
}
}
}
GovernCommand::Gatekeeper(gk_cli) => match gk_cli.command {
GatekeeperCommand::Check {
paths,
max_diff_bytes,
no_secrets,
no_dangerous,
} => {
use crate::core::gatekeeper;
let repo_root = project_store
.root
.parent()
.and_then(|p| p.parent())
.unwrap_or(&project_store.root);
let check_paths: Vec<std::path::PathBuf> = if let Some(explicit) = paths {
explicit.into_iter().map(std::path::PathBuf::from).collect()
} else {
let output = std::process::Command::new("git")
.args(["diff", "--cached", "--name-only"])
.current_dir(repo_root)
.output()
.map_err(error::DecapodError::IoError)?;
String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(std::path::PathBuf::from)
.collect()
};
let diff_output = std::process::Command::new("git")
.args(["diff", "--cached", "--stat"])
.current_dir(repo_root)
.output()
.map_err(error::DecapodError::IoError)?;
let diff_bytes = diff_output.stdout.len() as u64;
let mut config = gatekeeper::GatekeeperConfig::default();
if let Some(max) = max_diff_bytes {
config.max_diff_bytes = max;
}
config.scan_secrets = !no_secrets;
config.scan_dangerous_patterns = !no_dangerous;
let result =
gatekeeper::run_gatekeeper(repo_root, &check_paths, diff_bytes, &config)?;
if result.passed {
println!(
"Gatekeeper: all checks passed ({} files scanned)",
check_paths.len()
);
} else {
println!(
"Gatekeeper: {} violation(s) found:",
result.violations.len()
);
for v in &result.violations {
let loc = v.line.map(|l| format!(":{}", l)).unwrap_or_default();
println!(" [{}] {}{}: {}", v.kind, v.path.display(), loc, v.message);
}
return Err(error::DecapodError::ValidationError(format!(
"Gatekeeper: {} violation(s)",
result.violations.len()
)));
}
}
},
GovernCommand::Plan(plan_cli) => run_plan_command(plan_cli, project_store)?,
GovernCommand::Workunit(workunit_cli) => run_workunit_command(workunit_cli, project_store)?,
GovernCommand::Capsule(capsule_cli) => run_capsule_command(capsule_cli, project_store)?,
}
Ok(())
}
fn run_capsule_command(
capsule_cli: CapsuleCli,
project_store: &Store,
) -> Result<(), error::DecapodError> {
let project_root = project_store
.root
.parent()
.and_then(|p| p.parent())
.ok_or_else(|| {
error::DecapodError::ValidationError(
"unable to resolve project root from store root".to_string(),
)
})?;
match capsule_cli.command {
CapsuleCommand::Query {
topic,
scope,
risk_tier,
task_id,
workunit_id,
limit,
write,
} => {
let resolved_policy = core::capsule_policy::resolve_capsule_policy(
project_root,
&scope,
risk_tier.as_deref(),
limit,
write,
)?;
let capsule = core::context_capsule::query_embedded_capsule_governed(
project_root,
&topic,
&scope,
task_id.as_deref(),
workunit_id.as_deref(),
resolved_policy.effective_limit,
resolved_policy.binding,
)?;
if write {
let path = core::context_capsule::write_context_capsule(project_root, &capsule)?;
let workunit_binding = maybe_bind_capsule_to_workunit_state_ref(
project_root,
task_id.as_deref().or(workunit_id.as_deref()),
&path,
)?;
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"status": "ok",
"path": path,
"workunit_state_ref_binding": workunit_binding,
"capsule": capsule,
}))
.unwrap()
);
} else {
println!("{}", serde_json::to_string_pretty(&capsule).unwrap());
}
}
}
Ok(())
}
fn run_workunit_command(
workunit_cli: WorkunitCli,
project_store: &Store,
) -> Result<(), error::DecapodError> {
let project_root = project_store
.root
.parent()
.and_then(|p| p.parent())
.ok_or_else(|| {
error::DecapodError::ValidationError(
"unable to resolve project root from store root".to_string(),
)
})?;
match workunit_cli.command {
WorkunitCommand::Init {
task_id,
intent_ref,
} => {
let manifest = core::workunit::init_workunit(project_root, &task_id, &intent_ref)?;
let path = core::workunit::workunit_path(project_root, &task_id)?;
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"status": "ok",
"marker": "WORKUNIT_INITIALIZED",
"path": path,
"workunit": manifest,
}))
.unwrap()
);
}
WorkunitCommand::Get { task_id } => {
let manifest = core::workunit::load_workunit(project_root, &task_id)?;
println!("{}", serde_json::to_string_pretty(&manifest).unwrap());
}
WorkunitCommand::Status { task_id } => {
let manifest = core::workunit::load_workunit(project_root, &task_id)?;
let path = core::workunit::workunit_path(project_root, &task_id)?;
let hash = manifest.canonical_hash_hex().map_err(|e| {
error::DecapodError::ValidationError(format!(
"failed to compute workunit hash: {}",
e
))
})?;
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"status": "ok",
"task_id": manifest.task_id,
"workunit_status": manifest.status,
"manifest_hash": hash,
"path": path,
}))
.unwrap()
);
}
WorkunitCommand::AttachSpec { task_id, reference } => {
let manifest = core::workunit::add_spec_ref(project_root, &task_id, &reference)?;
println!("{}", serde_json::to_string_pretty(&manifest).unwrap());
}
WorkunitCommand::AttachState { task_id, reference } => {
let manifest = core::workunit::add_state_ref(project_root, &task_id, &reference)?;
println!("{}", serde_json::to_string_pretty(&manifest).unwrap());
}
WorkunitCommand::SetProofPlan { task_id, gates } => {
let manifest = core::workunit::set_proof_plan(project_root, &task_id, &gates)?;
println!("{}", serde_json::to_string_pretty(&manifest).unwrap());
}
WorkunitCommand::RecordProof {
task_id,
gate,
status,
artifact,
} => {
let manifest = core::workunit::record_proof_result(
project_root,
&task_id,
&gate,
&status,
artifact,
)?;
println!("{}", serde_json::to_string_pretty(&manifest).unwrap());
}
WorkunitCommand::Transition { task_id, to } => {
let manifest = core::workunit::transition_status(project_root, &task_id, to.into())?;
println!("{}", serde_json::to_string_pretty(&manifest).unwrap());
}
}
Ok(())
}
fn run_plan_command(plan_cli: PlanCli, project_store: &Store) -> Result<(), error::DecapodError> {
let project_root = project_store
.root
.parent()
.and_then(|p| p.parent())
.ok_or_else(|| {
error::DecapodError::ValidationError(
"unable to resolve project root from store root".to_string(),
)
})?;
match plan_cli.command {
PlanCommand::Init {
title,
intent,
todo_ids,
proof_hooks,
unknowns,
human_questions,
forbidden_paths,
file_touch_budget,
} => {
let plan = plan_governance::init_plan(
project_root,
plan_governance::InitPlanInput {
title,
intent,
todo_ids,
proof_hooks,
unknowns,
human_questions,
constraints: plan_governance::ScopeConstraints {
forbidden_paths,
file_touch_budget,
},
},
)?;
println!("{}", serde_json::to_string_pretty(&plan).unwrap());
}
PlanCommand::Update {
title,
intent,
todo_ids,
proof_hooks,
unknowns,
human_questions,
clear_unknowns,
clear_questions,
forbidden_paths,
file_touch_budget,
} => {
let plan = plan_governance::patch_plan(
project_root,
plan_governance::PlanPatch {
title,
intent,
state: None,
todo_ids: if todo_ids.is_empty() {
None
} else {
Some(todo_ids)
},
proof_hooks: if proof_hooks.is_empty() {
None
} else {
Some(proof_hooks)
},
unknowns: if clear_unknowns {
Some(vec![])
} else if unknowns.is_empty() {
None
} else {
Some(unknowns)
},
human_questions: if clear_questions {
Some(vec![])
} else if human_questions.is_empty() {
None
} else {
Some(human_questions)
},
constraints: if forbidden_paths.is_empty() && file_touch_budget.is_none() {
None
} else {
Some(plan_governance::ScopeConstraints {
forbidden_paths,
file_touch_budget,
})
},
},
)?;
println!("{}", serde_json::to_string_pretty(&plan).unwrap());
}
PlanCommand::SetState { state } => {
let plan = plan_governance::patch_plan(
project_root,
plan_governance::PlanPatch {
state: Some(state.into()),
..Default::default()
},
)?;
println!("{}", serde_json::to_string_pretty(&plan).unwrap());
}
PlanCommand::Approve => {
let plan = plan_governance::patch_plan(
project_root,
plan_governance::PlanPatch {
state: Some(plan_governance::PlanState::Approved),
..Default::default()
},
)?;
println!("{}", serde_json::to_string_pretty(&plan).unwrap());
}
PlanCommand::Status => {
let plan = plan_governance::load_plan(project_root)?;
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"status": if plan.is_some() { "ok" } else { "missing" },
"plan": plan
}))
.unwrap()
);
}
PlanCommand::CheckExecute { todo_id } => {
let plan = plan_governance::ensure_execute_ready(plan_governance::ExecuteCheckInput {
project_root,
store_root: &project_store.root,
todo_id: todo_id.as_deref(),
})?;
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"status": "ok",
"marker": "EXECUTION_READY",
"state": format!("{:?}", plan.state).to_uppercase(),
"todo_ids": plan.todo_ids,
"proof_hooks": plan.proof_hooks,
}))
.unwrap()
);
}
}
Ok(())
}
fn run_data_command(
data_cli: DataCli,
project_store: &Store,
project_root: &Path,
store_root: &Path,
) -> Result<(), error::DecapodError> {
match data_cli.command {
DataCommand::Archive(archive_cli) => {
archive::initialize_archive_db(store_root)?;
match archive_cli.command {
ArchiveCommand::List => {
let items = archive::list_archives(project_store)?;
println!("{}", serde_json::to_string_pretty(&items).unwrap());
}
ArchiveCommand::Verify => {
let failures = archive::verify_archives(project_store)?;
if failures.is_empty() {
println!("All archives verified successfully.");
} else {
println!("Archive verification failed:");
for f in failures {
println!("- {}", f);
}
}
}
}
}
DataCommand::Knowledge(knowledge_cli) => {
db::initialize_knowledge_db(store_root)?;
match knowledge_cli.command {
KnowledgeCommand::Add {
id,
title,
text,
provenance,
claim_id,
} => {
let result = knowledge::add_knowledge(
project_store,
knowledge::AddKnowledgeParams {
id: &id,
title: &title,
content: &text,
provenance: &provenance,
claim_id: claim_id.as_deref(),
merge_key: None,
conflict_policy: knowledge::KnowledgeConflictPolicy::Merge,
status: "active",
ttl_policy: "persistent",
expires_ts: None,
},
)?;
println!(
"Knowledge entry {}: {} (action: {})",
result.id, id, result.action
);
}
KnowledgeCommand::Search { query } => {
let results = knowledge::search_knowledge(
project_store,
&query,
knowledge::SearchOptions {
as_of: None,
window_days: None,
rank: "relevance",
},
)?;
println!("{}", serde_json::to_string_pretty(&results).unwrap());
}
KnowledgeCommand::Promote {
source_entry_id,
evidence_refs,
approved_by,
reason,
} => {
let actor = current_agent_id();
let event = knowledge::record_promotion_event(
project_store,
knowledge::KnowledgePromotionEventInput {
source_entry_id: &source_entry_id,
evidence_refs: &evidence_refs,
approved_by: &approved_by,
actor: &actor,
reason: &reason,
},
)?;
println!("{}", serde_json::to_string_pretty(&event).unwrap());
}
}
}
DataCommand::Context(context_cli) => {
let manager = context::ContextManager::new(store_root)?;
match context_cli.command {
ContextCommand::Audit { profile, files } => {
let total = manager.audit_session(&files)?;
match manager.get_profile(&profile) {
Some(p) => {
println!(
"Total tokens for profile '{}': {} / {} (budget)",
profile, total, p.budget_tokens
);
if total > p.budget_tokens {
println!("⚠ OVER BUDGET");
}
}
None => {
println!("Total tokens: {} (Profile '{}' not found)", total, profile);
}
}
}
ContextCommand::Pack { path, summary } => {
let archive_path = manager
.pack_and_archive(project_store, &path, &summary)
.map_err(|err| match err {
error::DecapodError::ContextPackError(msg) => {
error::DecapodError::ContextPackError(format!(
"Context pack failed: {}",
msg
))
}
other => other,
})?;
println!("Session archived to: {}", archive_path.display());
}
ContextCommand::Restore {
id,
profile,
current_files,
} => {
let content = manager.restore_archive(&id, &profile, ¤t_files)?;
println!(
"--- RESTORED CONTENT (Archive: {}) ---\n{}\n--- END RESTORED ---",
id, content
);
}
}
}
DataCommand::Schema(schema_cli) => {
let schemas = schema_catalog();
let output = if let Some(sub) = schema_cli.subsystem {
schemas
.get(sub.as_str())
.cloned()
.unwrap_or(serde_json::json!({ "error": "subsystem not found" }))
} else {
let mut envelope = deterministic_schema_envelope();
if !schema_cli.deterministic {
envelope.as_object_mut().unwrap().insert(
"generated_at".to_string(),
serde_json::json!(format!("{:?}", std::time::SystemTime::now())),
);
}
envelope
};
match schema_cli.format.as_str() {
"json" => println!("{}", serde_json::to_string_pretty(&output).unwrap()),
"md" => {
println!("{}", schema_to_markdown(&output));
}
other => {
return Err(error::DecapodError::ValidationError(format!(
"Unsupported schema format '{}'. Use 'json' or 'md'.",
other
)));
}
}
}
DataCommand::Repo(repo_cli) => match repo_cli.command {
RepoCommand::Map => {
let map = repomap::generate_map(project_root);
println!("{}", serde_json::to_string_pretty(&map).unwrap());
}
RepoCommand::Graph => {
let graph = repomap::generate_doc_graph(project_root);
println!("{}", graph.mermaid);
}
},
DataCommand::Broker(broker_cli) => match broker_cli.command {
BrokerCommand::Audit => {
let audit_log = store_root.join("broker.events.jsonl");
if audit_log.exists() {
let content = std::fs::read_to_string(audit_log)?;
println!("{}", content);
} else {
println!("No audit log found.");
}
}
BrokerCommand::Verify => {
let broker = core::broker::DbBroker::new(store_root);
let report = broker.verify_replay()?;
println!("{}", serde_json::to_string_pretty(&report).unwrap());
if !report.divergences.is_empty() {
return Err(error::DecapodError::ValidationError(format!(
"Audit log integrity check failed: {} divergence(s) detected",
report.divergences.len()
)));
}
}
},
DataCommand::Aptitude(aptitude_cli) => {
aptitude::run_aptitude_cli(project_store, aptitude_cli)?;
}
DataCommand::Federation(federation_cli) => {
federation::run_federation_cli(project_store, federation_cli)?;
}
DataCommand::Primitives(primitives_cli) => {
primitives::run_primitives_cli(project_store, primitives_cli)?;
}
}
Ok(())
}
fn schema_to_markdown(schema: &serde_json::Value) -> String {
fn render_value(v: &serde_json::Value) -> String {
match v {
serde_json::Value::Object(map) => {
let mut keys: Vec<_> = map.keys().cloned().collect();
keys.sort();
let mut out = String::new();
for key in keys {
let value = &map[&key];
match value {
serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
out.push_str(&format!("- **{}**:\n", key));
for line in render_value(value).lines() {
out.push_str(&format!(" {}\n", line));
}
}
_ => out.push_str(&format!("- **{}**: `{}`\n", key, value)),
}
}
out
}
serde_json::Value::Array(items) => {
let mut out = String::new();
for item in items {
match item {
serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
out.push_str("- item:\n");
for line in render_value(item).lines() {
out.push_str(&format!(" {}\n", line));
}
}
_ => out.push_str(&format!("- `{}`\n", item)),
}
}
out
}
_ => format!("- `{}`\n", v),
}
}
let mut out = String::from("# Decapod Schema\n\n");
out.push_str(&render_value(schema));
out
}
pub(crate) fn deterministic_schema_envelope() -> serde_json::Value {
let root = cli_command_registry();
let command_registry = root
.get("subcommands")
.cloned()
.unwrap_or(serde_json::Value::Array(vec![]));
serde_json::json!({
"schema_version": "1.0.0",
"subsystems": schema_catalog(),
"deprecations": deprecation_metadata(),
"command_registry": command_registry
})
}
fn schema_catalog() -> std::collections::BTreeMap<&'static str, serde_json::Value> {
let mut schemas = std::collections::BTreeMap::new();
schemas.insert("todo", todo::schema());
schemas.insert("cron", cron::schema());
schemas.insert("reflex", reflex::schema());
schemas.insert("workflow", workflow::schema());
schemas.insert("container", container::schema());
schemas.insert("health", health::health_schema());
schemas.insert("broker", core::broker::schema());
schemas.insert("external_action", core::external_action::schema());
schemas.insert("context", context::schema());
schemas.insert("policy", policy::schema());
schemas.insert("knowledge", knowledge::schema());
schemas.insert("repomap", repomap::schema());
schemas.insert("watcher", watcher::schema());
schemas.insert("archive", archive::schema());
schemas.insert("feedback", feedback::schema());
schemas.insert("aptitude", aptitude::schema());
schemas.insert("memory", aptitude::schema());
schemas.insert("federation", federation::schema());
schemas.insert("primitives", primitives::schema());
schemas.insert("decide", decide::schema());
schemas.insert("docs", docs_cli::schema());
schemas.insert("deprecations", deprecation_metadata());
schemas.insert("lcm", lcm::schema());
schemas.insert("map", map_ops::schema());
schemas.insert("eval", eval::schema());
schemas.insert("internalize", internalize::schema());
schemas.insert(
"command_registry",
serde_json::json!({
"name": "command_registry",
"version": "0.1.0",
"description": "Machine-readable CLI command registry generated from clap command definitions",
"root": cli_command_registry()
}),
);
schemas
}
fn deprecation_metadata() -> serde_json::Value {
serde_json::json!({
"name": "deprecations",
"version": "0.1.0",
"description": "Deprecated command surfaces and replacement pointers",
"entries": [
{
"surface": "command",
"path": "decapod heartbeat",
"status": "deprecated",
"replacement": "decapod govern health summary",
"notes": "Heartbeat command family was consolidated into govern health"
},
{
"surface": "command",
"path": "decapod trust",
"status": "deprecated",
"replacement": "decapod govern health autonomy",
"notes": "Trust command family was consolidated into govern health"
},
{
"surface": "module",
"path": "src/plugins/heartbeat.rs",
"status": "deprecated",
"replacement": "src/plugins/health.rs"
}
]
})
}
fn cli_command_registry() -> serde_json::Value {
let command = Cli::command();
command_to_registry(&command)
}
fn command_to_registry(command: &clap::Command) -> serde_json::Value {
let mut subcommands: Vec<serde_json::Value> = command
.get_subcommands()
.filter(|sub| !sub.is_hide_set())
.map(command_to_registry)
.collect();
subcommands.sort_by(|a, b| {
let a_name = a
.get("name")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let b_name = b
.get("name")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
a_name.cmp(b_name)
});
let mut options: Vec<serde_json::Value> = command
.get_arguments()
.filter(|arg| !arg.is_hide_set())
.map(|arg| {
let mut flags = Vec::new();
if let Some(long) = arg.get_long() {
flags.push(format!("--{}", long));
}
if let Some(short) = arg.get_short() {
flags.push(format!("-{}", short));
}
if flags.is_empty() {
flags.push(arg.get_id().to_string());
}
let value_names = arg
.get_value_names()
.map(|values| values.iter().map(|v| v.to_string()).collect::<Vec<_>>())
.unwrap_or_default();
serde_json::json!({
"id": arg.get_id().to_string(),
"flags": flags,
"required": arg.is_required_set(),
"help": arg.get_help().map(|help| help.to_string()),
"value_names": value_names
})
})
.collect();
options.sort_by(|a, b| {
let a_id = a
.get("id")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let b_id = b
.get("id")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
a_id.cmp(b_id)
});
let aliases: Vec<String> = command.get_all_aliases().map(str::to_string).collect();
serde_json::json!({
"name": command.get_name(),
"about": command.get_about().map(|about| about.to_string()),
"aliases": aliases,
"options": options,
"subcommands": subcommands
})
}
fn run_auto_command(auto_cli: AutoCli, project_store: &Store) -> Result<(), error::DecapodError> {
match auto_cli.command {
AutoCommand::Cron(cron_cli) => cron::run_cron_cli(project_store, cron_cli)?,
AutoCommand::Reflex(reflex_cli) => reflex::run_reflex_cli(project_store, reflex_cli),
AutoCommand::Workflow(workflow_cli) => {
workflow::run_workflow_cli(project_store, workflow_cli)?
}
AutoCommand::Container(container_cli) => {
container::run_container_cli(project_store, container_cli)?
}
}
Ok(())
}
fn run_qa_command(
qa_cli: QaCli,
project_store: &Store,
project_root: &Path,
) -> Result<(), error::DecapodError> {
match qa_cli.command {
QaCommand::Verify(verify_cli) => {
verify::run_verify_cli(project_store, project_root, verify_cli)?
}
QaCommand::Check {
crate_description,
commands,
all,
} => run_check(crate_description, commands, all)?,
QaCommand::Gatling(ref gatling_cli) => plugins::gatling::run_gatling_cli(gatling_cli)?,
}
Ok(())
}
fn run_hook_install(
commit_msg: bool,
pre_commit: bool,
uninstall: bool,
) -> Result<(), error::DecapodError> {
let git_dir_output = std::process::Command::new("git")
.args(["rev-parse", "--git-dir"])
.output()
.map_err(error::DecapodError::IoError)?;
if !git_dir_output.status.success() {
return Err(error::DecapodError::ValidationError(
"Not in a git repository".to_string(),
));
}
let git_dir = String::from_utf8_lossy(&git_dir_output.stdout)
.trim()
.to_string();
let hooks_dir = PathBuf::from(git_dir).join("hooks");
fs::create_dir_all(&hooks_dir).map_err(error::DecapodError::IoError)?;
if uninstall {
let commit_msg_path = hooks_dir.join("commit-msg");
let pre_commit_path = hooks_dir.join("pre-commit");
let mut removed_any = false;
if commit_msg_path.exists() {
fs::remove_file(&commit_msg_path).map_err(error::DecapodError::IoError)?;
println!("✓ Removed commit-msg hook");
removed_any = true;
}
if pre_commit_path.exists() {
fs::remove_file(&pre_commit_path).map_err(error::DecapodError::IoError)?;
println!("✓ Removed pre-commit hook");
removed_any = true;
}
if !removed_any {
println!("No hooks found to remove");
}
return Ok(());
}
if commit_msg {
let hook_content = r#"#!/bin/sh
MSG_FILE="$1"
SUBJECT="$(head -n1 "$MSG_FILE")"
if printf '%s' "$SUBJECT" | grep -Eq '^(feat|fix|docs|style|refactor|test|chore|ci|build|perf|revert)(\([^)]+\))?: .+'; then
exit 0
fi
echo "commit-msg hook: expected conventional commit subject"
echo "got: $SUBJECT"
exit 1
"#;
let hook_path = hooks_dir.join("commit-msg");
let mut file = fs::File::create(&hook_path).map_err(error::DecapodError::IoError)?;
file.write_all(hook_content.as_bytes())
.map_err(error::DecapodError::IoError)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&hook_path)
.map_err(error::DecapodError::IoError)?
.permissions();
perms.set_mode(0o755);
fs::set_permissions(&hook_path, perms).map_err(error::DecapodError::IoError)?;
}
println!("✓ Installed commit-msg hook for conventional commits");
}
if pre_commit {
let hook_content = r#"#!/bin/sh
set -e
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
"#;
let hook_path = hooks_dir.join("pre-commit");
let mut file = fs::File::create(&hook_path).map_err(error::DecapodError::IoError)?;
file.write_all(hook_content.as_bytes())
.map_err(error::DecapodError::IoError)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&hook_path)
.map_err(error::DecapodError::IoError)?
.permissions();
perms.set_mode(0o755);
fs::set_permissions(&hook_path, perms).map_err(error::DecapodError::IoError)?;
}
println!("✓ Installed pre-commit hook (fmt + clippy)");
}
if !commit_msg && !pre_commit {
println!("No hooks specified. Use --commit-msg and/or --pre-commit");
}
Ok(())
}
fn run_check(
crate_description: bool,
commands: bool,
all: bool,
) -> Result<(), error::DecapodError> {
if crate_description || all {
let expected = "Decapod is a Rust-built governance runtime for AI agents: repo-native state, enforced workflow, proof gates, safe coordination.";
let output = std::process::Command::new("cargo")
.args(["metadata", "--no-deps", "--format-version", "1"])
.output()
.map_err(|e| error::DecapodError::IoError(std::io::Error::other(e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(error::DecapodError::ValidationError(format!(
"cargo metadata failed: {}",
stderr.trim()
)));
}
let json_str = String::from_utf8_lossy(&output.stdout);
if json_str.contains(expected) {
println!("✓ Crate description matches");
} else {
println!("✗ Crate description mismatch!");
println!(" Expected: {}", expected);
return Err(error::DecapodError::ValidationError(
"Crate description check failed".into(),
));
}
}
if commands || all {
run_command_help_smoke()?;
println!("✓ Command help surfaces are valid");
}
if all && !(crate_description || commands) {
println!("Note: --all enables all checks");
}
Ok(())
}
fn run_command_help_smoke() -> Result<(), error::DecapodError> {
fn walk(cmd: &clap::Command, prefix: Vec<String>, all_paths: &mut Vec<Vec<String>>) {
if cmd.get_name() != "help" {
all_paths.push(prefix.clone());
}
for sub in cmd.get_subcommands().filter(|sub| !sub.is_hide_set()) {
let mut next = prefix.clone();
next.push(sub.get_name().to_string());
walk(sub, next, all_paths);
}
}
let exe = std::env::current_exe().map_err(error::DecapodError::IoError)?;
let mut command_paths = Vec::new();
walk(&Cli::command(), Vec::new(), &mut command_paths);
command_paths.sort();
command_paths.dedup();
let mut handles = Vec::new();
for path in &command_paths {
handles.push(std::thread::spawn({
let path = path.clone();
let exe = exe.clone();
move || {
let mut args = path.clone();
args.push("--help".to_string());
let output = std::process::Command::new(&exe)
.args(&args)
.output()
.map_err(error::DecapodError::IoError)?;
if !output.status.success() {
return Err(error::DecapodError::ValidationError(format!(
"help smoke failed for `decapod {}`: {}",
path.join(" "),
String::from_utf8_lossy(&output.stderr).trim()
)));
}
Ok(())
}
}));
}
for handle in handles {
handle
.join()
.map_err(|_| error::DecapodError::ValidationError("thread panicked".into()))??;
}
Ok(())
}
fn show_version_info() -> Result<(), error::DecapodError> {
println!("Decapod version: {}", migration::DECAPOD_VERSION);
println!(" Update: cargo install decapod");
Ok(())
}
fn run_workspace_command(
cli: WorkspaceCli,
project_root: &Path,
) -> Result<(), error::DecapodError> {
use crate::core::workspace;
match cli.command {
WorkspaceCommand::Ensure { branch, container } => {
let agent_id =
std::env::var("DECAPOD_AGENT_ID").unwrap_or_else(|_| "unknown".to_string());
let config = if branch.is_some() || container {
Some(workspace::WorkspaceConfig {
branch,
use_container: container,
base_image: if container {
Some("rust:1.91-slim".to_string())
} else {
None
},
})
} else {
None
};
let status = workspace::ensure_workspace(project_root, config, &agent_id)?;
println!(
"{}",
serde_json::json!({
"status": if status.can_work { "ok" } else { "pending" },
"branch": status.git.current_branch,
"is_protected": status.git.is_protected,
"can_work": status.can_work,
"in_container": status.container.in_container,
"docker_available": status.container.docker_available,
"worktree_path": status.git.worktree_path,
"required_actions": status.required_actions,
})
);
}
WorkspaceCommand::Status => {
let status = workspace::get_workspace_status(project_root)?;
println!(
"{}",
serde_json::json!({
"can_work": status.can_work,
"git_branch": status.git.current_branch,
"git_is_protected": status.git.is_protected,
"git_has_local_mods": status.git.has_local_mods,
"in_container": status.container.in_container,
"container_image": status.container.image,
"docker_available": status.container.docker_available,
"blockers": status.blockers.len(),
"required_actions": status.required_actions,
})
);
}
WorkspaceCommand::Publish { title, description } => {
let project_store = Store {
kind: StoreKind::Repo,
root: project_root.join(".decapod").join("data"),
};
plan_governance::ensure_execute_ready(plan_governance::ExecuteCheckInput {
project_root,
store_root: &project_store.root,
todo_id: None,
})?;
let report = run_validation_bounded(&project_store, project_root, false)?;
if report.fail_count > 0 {
return Err(error::DecapodError::ValidationError(format!(
"{} test(s) failed before workspace publish.",
report.fail_count
)));
}
let result = workspace::publish_workspace(project_root, title, description)?;
println!(
"{}",
serde_json::json!({
"status": "ok",
"branch": result.branch,
"commit_hash": result.commit_hash,
"remote_url": result.remote_url,
"pr_url": result.pr_url,
})
);
}
}
Ok(())
}
fn run_state_commit_command(
cli: StateCommitCli,
project_root: &Path,
) -> Result<(), error::DecapodError> {
match cli.command {
StateCommitCommand::Prove { base, head, output } => {
let head = head.unwrap_or_else(|| {
state_commit::run_git(project_root, &["rev-parse", "HEAD"])
.unwrap_or_else(|_| "HEAD".to_string())
});
println!("Computing STATE_COMMIT:");
println!(" base: {}", base);
println!(" head: {}", head);
let input = state_commit::StateCommitInput {
base_sha: base,
head_sha: head.clone(),
ignore_policy_hash: "da39a3ee5e6b4b0d3255bfef95601890afd80709".to_string(), };
let result = state_commit::prove(&input, project_root)
.map_err(error::DecapodError::ValidationError)?;
println!(" files: {}", result.entries.len());
std::fs::write(&output, &result.scope_record_bytes)
.map_err(error::DecapodError::IoError)?;
println!(" scope_record_hash: {}", result.scope_record_hash);
println!(" state_commit_root: {}", result.state_commit_root);
println!(" output: {}", output.display());
Ok(())
}
StateCommitCommand::Verify {
scope_record,
expected_root,
} => {
let cbor_bytes = std::fs::read(&scope_record).map_err(error::DecapodError::IoError)?;
let record_hash = if let Some(ref exp) = expected_root {
match state_commit::verify(&cbor_bytes, exp) {
Ok(h) => h,
Err(e) => {
println!("STATE_COMMIT verification:");
println!(" scope_record: {}", scope_record.display());
println!(" ❌ MISMATCH: {}", e);
return Err(error::DecapodError::ValidationError(e));
}
}
} else {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(&cbor_bytes);
format!("{:x}", hasher.finalize())
};
println!("STATE_COMMIT verification:");
println!(" scope_record: {}", scope_record.display());
println!(" scope_record_hash: {}", record_hash);
println!(" ✅ VERIFIED");
Ok(())
}
StateCommitCommand::Explain { scope_record } => {
let cbor_bytes = std::fs::read(&scope_record).map_err(error::DecapodError::IoError)?;
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(&cbor_bytes);
let scope_record_hash = format!("{:x}", hasher.finalize());
let content = String::from_utf8_lossy(&cbor_bytes);
println!("STATE_COMMIT Explanation:");
println!(" File: {}", scope_record.display());
println!(" Size: {} bytes", cbor_bytes.len());
println!(" scope_record_hash: {}", scope_record_hash);
println!();
if let Some(version_pos) = content.find("state_commit.")
&& let Some(end_pos) = content[version_pos..].find('\0')
{
println!(
" algo_version: {}",
&content[version_pos..version_pos + end_pos]
);
}
let entry_count = content.matches("kind=").count();
println!(" Estimated entries: {}", entry_count);
println!();
println!("Note: scope_record_hash is sha256(scope_record_bytes)");
println!(" state_commit_root is the Merkle root of entry hashes");
Ok(())
}
}
}
struct RpcCtx<'a> {
project_root: &'a Path,
store: &'a Store,
request: &'a crate::core::rpc::RpcRequest,
mandates: Vec<crate::core::docs::Mandate>,
}
mod rpc_handlers {
use super::RpcCtx;
use super::*;
use crate::core::assurance::{AssuranceEngine, AssuranceEvaluateInput};
use crate::core::interview;
use crate::core::mentor;
use crate::core::rpc::*;
use crate::core::standards;
use crate::core::workspace;
pub(crate) fn handle_agent_init(ctx: &RpcCtx) -> Result<RpcResponse, error::DecapodError> {
let _params: AgentInitParams = serde_json::from_value(ctx.request.params.clone())
.map_err(|e| error::DecapodError::ValidationError(format!("Invalid params: {}", e)))?;
let workspace_status = workspace::get_workspace_status(ctx.project_root)?;
let mut allowed_ops = workspace::get_allowed_ops(&workspace_status);
let agent_id = current_agent_id();
if agent_id != "unknown"
&& let Ok(mut tasks) = todo::list_tasks(
&ctx.store.root,
Some("open".to_string()),
None,
None,
None,
None,
)
{
tasks.retain(|t| t.assigned_to == agent_id);
if tasks.is_empty() {
allowed_ops.insert(
0,
AllowedOp {
op: "todo.add".to_string(),
reason: "MANDATORY: Create a task for your work".to_string(),
required_params: vec!["title".to_string()],
},
);
} else if tasks.iter().any(|t| t.assigned_to.is_empty()) {
allowed_ops.insert(
0,
AllowedOp {
op: "todo.claim".to_string(),
reason: "MANDATORY: Claim your assigned task".to_string(),
required_params: vec!["id".to_string()],
},
);
}
}
let context_capsule = if workspace_status.can_work {
Some(ContextCapsule {
fragments: vec![],
spec: Some("Agent initialized successfully".to_string()),
architecture: None,
security: None,
standards: Some({
let resolved = standards::resolve_standards(ctx.project_root)?;
let mut map = std::collections::HashMap::new();
map.insert(
"project_name".to_string(),
serde_json::json!(resolved.project_name),
);
map
}),
})
} else {
None
};
let _blocked_by = if !workspace_status.can_work {
workspace_status.blockers.clone()
} else {
vec![]
};
let result = AgentInitResult {
environment_context: EnvironmentContext {
repo_root: ctx.project_root.to_string_lossy().to_string(),
workspace_path: ctx.project_root.to_string_lossy().to_string(),
tool_summary: ToolSummary {
docker_available: workspace_status.container.docker_available,
in_container: workspace_status.container.in_container,
},
done_means: "decapod validate passes".to_string(),
},
};
let response = success_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
Some(serde_json::to_value(result).unwrap()),
vec![],
context_capsule,
allowed_ops,
ctx.mandates.clone(),
);
mark_constitution_initialized(ctx.project_root)?;
Ok(response)
}
pub(crate) fn handle_workspace_status(
ctx: &RpcCtx,
) -> Result<RpcResponse, error::DecapodError> {
let _params: WorkspaceStatusParams = serde_json::from_value(ctx.request.params.clone())
.map_err(|e| error::DecapodError::ValidationError(format!("Invalid params: {}", e)))?;
let status = workspace::get_workspace_status(ctx.project_root)?;
let blocked_by = status.blockers.clone();
let allowed_ops = workspace::get_allowed_ops(&status);
let result = WorkspaceStatusResult {
git_branch: status.git.current_branch,
git_is_protected: status.git.is_protected,
in_container: status.container.in_container,
can_work: status.can_work,
};
let mut response = success_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
Some(serde_json::to_value(result).unwrap()),
vec![],
None,
allowed_ops,
ctx.mandates.clone(),
);
response.blocked_by = blocked_by;
Ok(response)
}
pub(crate) fn handle_workspace_ensure(
ctx: &RpcCtx,
) -> Result<RpcResponse, error::DecapodError> {
let params: WorkspaceEnsureParams = serde_json::from_value(ctx.request.params.clone())
.map_err(|e| error::DecapodError::ValidationError(format!("Invalid params: {}", e)))?;
let agent_id = std::env::var("DECAPOD_AGENT_ID").unwrap_or_else(|_| "unknown".to_string());
let config = params.branch.map(|b| workspace::WorkspaceConfig {
branch: Some(b),
use_container: false,
base_image: None,
});
let status = workspace::ensure_workspace(ctx.project_root, config, &agent_id)?;
let allowed_ops = workspace::get_allowed_ops(&status);
let result = WorkspaceEnsureResult {
branch: status.git.current_branch.clone(),
worktree_path: status
.git
.worktree_path
.clone()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
};
Ok(success_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
Some(serde_json::to_value(result).unwrap()),
vec![format!(".git/refs/heads/{}", status.git.current_branch)],
None,
allowed_ops,
ctx.mandates.clone(),
))
}
pub(crate) fn handle_workspace_publish(
ctx: &RpcCtx,
) -> Result<RpcResponse, error::DecapodError> {
let params: WorkspacePublishParams = serde_json::from_value(ctx.request.params.clone())
.map_err(|e| error::DecapodError::ValidationError(format!("Invalid params: {}", e)))?;
let store_root = ctx.project_root.join(".decapod").join("data");
plan_governance::ensure_execute_ready(plan_governance::ExecuteCheckInput {
project_root: ctx.project_root,
store_root: &store_root,
todo_id: None,
})?;
let result =
workspace::publish_workspace(ctx.project_root, params.title, params.description)?;
let rpc_result = WorkspacePublishResult {
branch: result.branch.clone(),
commit_hash: result.commit_hash,
remote_url: result.remote_url,
pr_url: result.pr_url,
};
Ok(success_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
Some(serde_json::to_value(rpc_result).unwrap()),
vec![format!(".git/refs/heads/{}", result.branch)],
None,
vec![AllowedOp {
op: "validate".to_string(),
reason: "Publish complete - run validation".to_string(),
required_params: vec![],
}],
ctx.mandates.clone(),
))
}
pub(crate) fn handle_context_resolve(ctx: &RpcCtx) -> Result<RpcResponse, error::DecapodError> {
let params: ContextResolveParams = serde_json::from_value(ctx.request.params.clone())
.map_err(|e| error::DecapodError::ValidationError(format!("Invalid params: {}", e)))?;
let limit = params.limit.unwrap_or(5);
let mut fragments = Vec::new();
let bindings = docs::get_bindings(ctx.project_root);
if let Some(o) = ¶ms.op
&& let Some(doc_ref) = bindings.ops.get(o)
{
let parts: Vec<&str> = doc_ref.split('#').collect();
let path = parts[0];
let anchor = parts.get(1).copied();
if let Some(f) = docs::get_fragment(ctx.project_root, path, anchor) {
fragments.push(f);
}
}
if let Some(paths) = ¶ms.touched_paths {
for p in paths {
for (prefix, doc_ref) in &bindings.paths {
if p.contains(prefix) {
let parts: Vec<&str> = doc_ref.split('#').collect();
let path = parts[0];
let anchor = parts.get(1).copied();
if let Some(f) = docs::get_fragment(ctx.project_root, path, anchor) {
fragments.push(f);
}
}
}
}
}
if let Some(tags) = ¶ms.intent_tags {
for t in tags {
if let Some(doc_ref) = bindings.tags.get(t) {
let parts: Vec<&str> = doc_ref.split('#').collect();
let path = parts[0];
let anchor = parts.get(1).copied();
if let Some(f) = docs::get_fragment(ctx.project_root, path, anchor) {
fragments.push(f);
}
}
}
}
fragments.sort_by(|a, b| a.r#ref.cmp(&b.r#ref));
fragments.dedup_by(|a, b| a.r#ref == b.r#ref);
let touched_vec = params.touched_paths.clone().unwrap_or_default();
let tags_vec = params.intent_tags.clone().unwrap_or_default();
let scoped_fragments = docs::resolve_scoped_fragments(
ctx.project_root,
params.query.as_deref(),
params.op.as_deref(),
&touched_vec,
&tags_vec,
limit,
);
fragments.extend(scoped_fragments.clone());
fragments.sort_by(|a, b| a.r#ref.cmp(&b.r#ref));
fragments.dedup_by(|a, b| a.r#ref == b.r#ref);
fragments.truncate(limit.max(1));
let local_specs = core::project_specs::local_project_specs_context(ctx.project_root);
let result = ContextResolveResult {
fragments: fragments.clone(),
scoped_fragments,
local_project_specs: LocalProjectSpecs {
canonical_paths: local_specs.canonical_paths.clone(),
constitution_refs: local_specs.constitution_refs.clone(),
intent: local_specs.intent.clone(),
architecture: local_specs.architecture.clone(),
interfaces: local_specs.interfaces.clone(),
validation: local_specs.validation.clone(),
update_guidance: Some(local_specs.update_guidance.clone()),
},
};
mark_constitution_context_resolved(ctx.project_root)?;
Ok(success_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
Some(serde_json::to_value(result).unwrap()),
vec![],
Some(ContextCapsule {
fragments,
spec: local_specs.intent.clone(),
architecture: local_specs.architecture.clone(),
security: None,
standards: Some({
let mut m = std::collections::HashMap::new();
m.insert(
"local_project_specs".to_string(),
serde_json::json!({
"canonical_paths": local_specs.canonical_paths,
"constitution_refs": local_specs.constitution_refs,
"interfaces": local_specs.interfaces,
"validation": local_specs.validation,
"update_guidance": local_specs.update_guidance
}),
);
m
}),
}),
vec![
AllowedOp {
op: "store.upsert".to_string(),
reason: "Persist significant decisions for audit trail before proceeding"
.to_string(),
required_params: vec!["kind".to_string(), "data".to_string()],
},
AllowedOp {
op: "validate.run".to_string(),
reason: "Validate your changes against constitution before claiming done"
.to_string(),
required_params: vec![],
},
AllowedOp {
op: "store.query".to_string(),
reason: "Retrieve prior decisions and knowledge relevant to current task"
.to_string(),
required_params: vec!["kind".to_string()],
},
],
ctx.mandates.clone(),
))
}
pub(crate) fn handle_context_capsule_query(
ctx: &RpcCtx,
) -> Result<RpcResponse, error::DecapodError> {
let params: ContextCapsuleQueryParams = serde_json::from_value(ctx.request.params.clone())
.map_err(|e| error::DecapodError::ValidationError(format!("Invalid params: {}", e)))?;
let limit = params.limit.unwrap_or(6);
let write = params.write.unwrap_or(false);
let resolved_policy = core::capsule_policy::resolve_capsule_policy(
ctx.project_root,
¶ms.scope,
params.risk_tier.as_deref(),
limit,
write,
)?;
let capsule = core::context_capsule::query_embedded_capsule_governed(
ctx.project_root,
¶ms.topic,
¶ms.scope,
params.task_id.as_deref(),
params.workunit_id.as_deref(),
resolved_policy.effective_limit,
resolved_policy.binding,
)?;
let mut touched = Vec::new();
if write {
let path = core::context_capsule::write_context_capsule(ctx.project_root, &capsule)?;
touched.push(path.to_string_lossy().to_string());
if let Some(workunit_path) = maybe_bind_capsule_to_workunit_state_ref(
ctx.project_root,
params.task_id.as_deref().or(params.workunit_id.as_deref()),
&path,
)? {
touched.push(workunit_path.to_string_lossy().to_string());
}
}
Ok(success_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
Some(serde_json::to_value(&capsule).unwrap()),
touched,
Some(ContextCapsule {
fragments: vec![],
spec: Some("Deterministic context capsule query completed".to_string()),
architecture: None,
security: None,
standards: None,
}),
vec![],
ctx.mandates.clone(),
))
}
pub(crate) fn handle_context_bindings(
ctx: &RpcCtx,
) -> Result<RpcResponse, error::DecapodError> {
let _params: ContextBindingsParams = serde_json::from_value(ctx.request.params.clone())
.map_err(|e| error::DecapodError::ValidationError(format!("Invalid params: {}", e)))?;
let bindings = docs::get_bindings(ctx.project_root);
Ok(success_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
Some(serde_json::to_value(bindings).unwrap()),
vec![],
None,
vec![],
ctx.mandates.clone(),
))
}
pub(crate) fn handle_constitution_get(
ctx: &RpcCtx,
) -> Result<RpcResponse, error::DecapodError> {
let params: ConstitutionGetParams = serde_json::from_value(ctx.request.params.clone())
.map_err(|e| error::DecapodError::ValidationError(format!("Invalid params: {}", e)))?;
let Some(raw_content) = core::assets::get_embedded_doc(¶ms.section) else {
return Ok(error_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
"unknown_section".to_string(),
format!("Unknown constitution section: {}", params.section),
None,
ctx.mandates.clone(),
));
};
let mut content: serde_json::Value = serde_json::from_str(&raw_content)
.unwrap_or_else(|_| serde_json::json!({ "text": raw_content }));
if let Some(subsection) = params.subsection.as_deref() {
let selected = content
.get("sections")
.and_then(|sections| sections.get(subsection))
.cloned();
match selected {
Some(section) => {
content = serde_json::json!({
"subsection": subsection,
"value": section
});
}
None => {
return Ok(error_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
"unknown_subsection".to_string(),
format!(
"Unknown subsection '{}' in constitution section '{}'",
subsection, params.section
),
None,
ctx.mandates.clone(),
));
}
}
}
let (category, title, dependencies) = core::assets::get_doc_metadata(¶ms.section)
.unwrap_or(("unknown".to_string(), params.section.clone(), Vec::new()));
mark_core_constitution_ingested(ctx.project_root, "constitution.get")?;
Ok(success_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
Some(
serde_json::to_value(ConstitutionGetResult {
section: params.section,
title,
category,
dependencies,
content,
})
.unwrap(),
),
vec![],
None,
vec![],
ctx.mandates.clone(),
))
}
pub(crate) fn handle_schema_get(ctx: &RpcCtx) -> Result<RpcResponse, error::DecapodError> {
let params: SchemaGetParams = serde_json::from_value(ctx.request.params.clone())
.map_err(|e| error::DecapodError::ValidationError(format!("Invalid params: {}", e)))?;
match params.entity.as_deref() {
Some("todo") => Ok(success_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
Some(serde_json::to_value(SchemaGetResult {
schema_version: "v1".to_string(),
json_schema: serde_json::json!({
"type": "object",
"properties": {
"title": { "type": "string" },
"description": { "type": "string" },
"priority": { "type": "string", "enum": ["low", "medium", "high", "critical"] },
"tags": { "type": "string" }
},
"required": ["title"]
}),
}).unwrap()),
vec![],
None,
vec![],
ctx.mandates.clone(),
)),
Some("knowledge") => Ok(success_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
Some(serde_json::to_value(SchemaGetResult {
schema_version: "v1".to_string(),
json_schema: serde_json::json!({
"type": "object",
"properties": {
"id": { "type": "string" },
"title": { "type": "string" },
"text": { "type": "string" },
"provenance": { "type": "string" }
},
"required": ["id", "title", "text", "provenance"]
}),
}).unwrap()),
vec![],
None,
vec![],
ctx.mandates.clone(),
)),
Some("decision") => Ok(success_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
Some(serde_json::to_value(SchemaGetResult {
schema_version: "v1".to_string(),
json_schema: serde_json::json!({
"type": "object",
"properties": {
"title": { "type": "string" },
"rationale": { "type": "string" },
"options": { "type": "array", "items": { "type": "string" } },
"chosen": { "type": "string" }
},
"required": ["title", "rationale", "chosen"]
}),
}).unwrap()),
vec![],
None,
vec![],
ctx.mandates.clone(),
)),
_ => Ok(error_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
"invalid_entity".to_string(),
format!("Invalid or missing entity: {:?}", params.entity),
None,
ctx.mandates.clone(),
)),
}
}
pub(crate) fn handle_store_upsert(ctx: &RpcCtx) -> Result<RpcResponse, error::DecapodError> {
let params: StoreUpsertParams = serde_json::from_value(ctx.request.params.clone())
.map_err(|e| error::DecapodError::ValidationError(format!("Invalid params: {}", e)))?;
let payload = params.payload.as_ref();
match params.entity.as_deref() {
Some("todo") => {
let title = payload
.and_then(|p| p.get("title"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let description = payload
.and_then(|p| p.get("description"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let priority = payload
.and_then(|p| p.get("priority"))
.and_then(|v| v.as_str())
.unwrap_or("medium")
.to_string();
let tags = payload
.and_then(|p| p.get("tags"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let args = todo::TodoCommand::Add {
title,
description,
priority,
tags,
owner: "".to_string(),
due: None,
r#ref: "".to_string(),
scope: "".to_string(),
dir: None,
depends_on: "".to_string(),
blocks: "".to_string(),
parent: None,
one_shot: 0,
};
let res = todo::add_task(&ctx.store.root, &args)?;
let result = StoreUpsertResult {
id: res
.get("id")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
status: None,
stored: Some(true),
action: None,
};
Ok(success_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
Some(serde_json::to_value(result).unwrap()),
vec![],
None,
vec![],
ctx.mandates.clone(),
))
}
Some("knowledge") => {
let id = payload
.and_then(|p| p.get("id"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let title = payload
.and_then(|p| p.get("title"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let text = payload
.and_then(|p| p.get("text"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let provenance = payload
.and_then(|p| p.get("provenance"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
db::initialize_knowledge_db(&ctx.store.root)?;
let res = knowledge::add_knowledge(
ctx.store,
knowledge::AddKnowledgeParams {
id: &id,
title: &title,
content: &text,
provenance: &provenance,
claim_id: None,
merge_key: None,
conflict_policy: knowledge::KnowledgeConflictPolicy::Merge,
status: "active",
ttl_policy: "persistent",
expires_ts: None,
},
)?;
let result = StoreUpsertResult {
id: res.id,
status: None,
stored: Some(true),
action: Some(res.action),
};
Ok(success_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
Some(serde_json::to_value(result).unwrap()),
vec![],
None,
vec![],
ctx.mandates.clone(),
))
}
Some("decision") => {
let title = payload
.and_then(|p| p.get("title"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let rationale = payload
.and_then(|p| p.get("rationale"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let chosen = payload
.and_then(|p| p.get("chosen"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let content = format!("Decision: {}\nRationale: {}", chosen, rationale);
let node = federation::add_node(
ctx.store,
&title,
"decision",
"notable",
"agent_inferred",
&content,
"rpc:store.upsert",
"",
"repo",
None,
"agent",
)?;
let result = StoreUpsertResult {
id: node.id,
status: None,
stored: Some(true),
action: None,
};
Ok(success_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
Some(serde_json::to_value(result).unwrap()),
vec![],
None,
vec![],
ctx.mandates.clone(),
))
}
_ => Ok(error_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
"invalid_entity".to_string(),
format!("Invalid or missing entity: {:?}", params.entity),
None,
ctx.mandates.clone(),
)),
}
}
pub(crate) fn handle_store_query(ctx: &RpcCtx) -> Result<RpcResponse, error::DecapodError> {
let params: StoreQueryParams = serde_json::from_value(ctx.request.params.clone())
.map_err(|e| error::DecapodError::ValidationError(format!("Invalid params: {}", e)))?;
match params.entity.as_deref() {
Some("todo") => {
let status = params
.query
.as_ref()
.and_then(|q| q.get("status"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let tasks = todo::list_tasks(&ctx.store.root, status, None, None, None, None)?;
let result = StoreQueryResult {
items: tasks
.into_iter()
.map(|t| serde_json::to_value(t).unwrap())
.collect(),
next_page: None,
};
Ok(success_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
Some(serde_json::to_value(result).unwrap()),
vec![],
None,
vec![],
ctx.mandates.clone(),
))
}
Some("knowledge") => {
let text = params
.query
.as_ref()
.and_then(|q| q.get("text"))
.and_then(|v| v.as_str())
.unwrap_or("");
db::initialize_knowledge_db(&ctx.store.root)?;
let entries = knowledge::search_knowledge(
ctx.store,
text,
knowledge::SearchOptions {
as_of: None,
window_days: None,
rank: "relevance",
},
)?;
let result = StoreQueryResult {
items: entries
.into_iter()
.map(|e| serde_json::to_value(e).unwrap())
.collect(),
next_page: None,
};
Ok(success_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
Some(serde_json::to_value(result).unwrap()),
vec![],
None,
vec![],
ctx.mandates.clone(),
))
}
Some("decision") => {
let nodes = plugins::federation_ext::list_nodes(
&ctx.store.root,
Some("decision".to_string()),
None,
None,
None,
)?;
let result = StoreQueryResult {
items: nodes
.into_iter()
.map(|n| serde_json::to_value(n).unwrap())
.collect(),
next_page: None,
};
Ok(success_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
Some(serde_json::to_value(result).unwrap()),
vec![],
None,
vec![],
ctx.mandates.clone(),
))
}
_ => Ok(error_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
"invalid_entity".to_string(),
format!("Invalid or missing entity: {:?}", params.entity),
None,
ctx.mandates.clone(),
)),
}
}
pub(crate) fn handle_validate_run(ctx: &RpcCtx) -> Result<RpcResponse, error::DecapodError> {
let _params: ValidateRunParams = serde_json::from_value(ctx.request.params.clone())
.map_err(|e| error::DecapodError::ValidationError(format!("Invalid params: {}", e)))?;
let project_store = Store {
kind: StoreKind::Repo,
root: ctx.project_root.join(".decapod").join("data"),
};
let res = run_validation_bounded(&project_store, ctx.project_root, false);
match res {
Ok(report) if report.fail_count == 0 => {
let result = ValidateRunResult {
success: true,
report: "All validation gates passed".to_string(),
};
Ok(success_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
Some(serde_json::to_value(result).unwrap()),
vec![],
None,
vec![],
ctx.mandates.clone(),
))
}
Ok(report) => Ok(error_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
"validation_failed".to_string(),
format!("{} validation gate(s) failed", report.fail_count),
None,
ctx.mandates.clone(),
)),
Err(e) => Ok(error_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
"validation_failed".to_string(),
e.to_string(),
None,
ctx.mandates.clone(),
)),
}
}
pub(crate) fn handle_scaffold_next_question(
ctx: &RpcCtx,
) -> Result<RpcResponse, error::DecapodError> {
let params: ScaffoldNextQuestionParams = serde_json::from_value(ctx.request.params.clone())
.map_err(|e| error::DecapodError::ValidationError(format!("Invalid params: {}", e)))?;
let project_name = params
.project_name
.unwrap_or_else(|| "Untitled".to_string());
let interview_state = interview::init_interview(project_name);
let question = interview::next_question(&interview_state);
let mut response = success_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
None,
vec![],
None,
vec![AllowedOp {
op: "scaffold.apply_answer".to_string(),
reason: "Provide answer to continue interview".to_string(),
required_params: vec!["question_id".to_string(), "value".to_string()],
}],
ctx.mandates.clone(),
);
let is_complete = question.is_none();
let result = ScaffoldNextQuestionResult {
interview_id: interview_state.id,
question,
complete: if is_complete { Some(true) } else { None },
};
response.result = Some(serde_json::to_value(result).unwrap());
Ok(response)
}
pub(crate) fn handle_scaffold_apply_answer(
ctx: &RpcCtx,
) -> Result<RpcResponse, error::DecapodError> {
let params: ScaffoldApplyAnswerParams = serde_json::from_value(ctx.request.params.clone())
.map_err(|e| error::DecapodError::ValidationError(format!("Invalid params: {}", e)))?;
let mut interview_state = interview::init_interview("project".to_string());
interview::apply_answer(&mut interview_state, ¶ms.question_id, params.value)?;
let next_q = interview::next_question(&interview_state);
let mut response = success_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
None,
vec![],
None,
vec![AllowedOp {
op: if next_q.is_some() {
"scaffold.next_question".to_string()
} else {
"scaffold.generate_artifacts".to_string()
},
reason: if next_q.is_some() {
"Continue interview".to_string()
} else {
"Interview complete - generate artifacts".to_string()
},
required_params: vec![],
}],
ctx.mandates.clone(),
);
let result = ScaffoldApplyAnswerResult {
answers_count: interview_state.answers.len(),
is_complete: interview_state.is_complete,
};
response.result = Some(serde_json::to_value(result).unwrap());
Ok(response)
}
pub(crate) fn handle_scaffold_generate_artifacts(
ctx: &RpcCtx,
) -> Result<RpcResponse, error::DecapodError> {
let _params: ScaffoldGenerateArtifactsParams =
serde_json::from_value(ctx.request.params.clone()).map_err(|e| {
error::DecapodError::ValidationError(format!("Invalid params: {}", e))
})?;
let interview_state = interview::init_interview("project".to_string());
let output_dir = ctx.project_root.to_path_buf();
let artifacts = interview::generate_artifacts(&interview_state, &output_dir)?;
let touched_paths: Vec<String> = artifacts
.iter()
.map(|a| a.path.to_string_lossy().to_string())
.collect();
Ok(success_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
None,
touched_paths,
None,
vec![AllowedOp {
op: "validate".to_string(),
reason: "Artifacts generated - validate before claiming done".to_string(),
required_params: vec![],
}],
ctx.mandates.clone(),
))
}
pub(crate) fn handle_standards_resolve(
ctx: &RpcCtx,
) -> Result<RpcResponse, error::DecapodError> {
let _params: StandardsResolveParams = serde_json::from_value(ctx.request.params.clone())
.map_err(|e| error::DecapodError::ValidationError(format!("Invalid params: {}", e)))?;
let resolved = standards::resolve_standards(ctx.project_root)?;
let mut standards_map = std::collections::HashMap::new();
standards_map.insert(
"project_name".to_string(),
serde_json::json!(resolved.project_name),
);
for (k, v) in &resolved.standards {
standards_map.insert(k.clone(), v.clone());
}
let context_capsule = ContextCapsule {
fragments: vec![],
spec: None,
architecture: None,
security: None,
standards: Some(standards_map),
};
Ok(success_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
None,
vec![],
Some(context_capsule),
vec![],
ctx.mandates.clone(),
))
}
pub(crate) fn handle_mentor_obligations(
ctx: &RpcCtx,
) -> Result<RpcResponse, error::DecapodError> {
use crate::core::mentor::{MentorEngine, ObligationsContext};
let params: MentorObligationsParams = serde_json::from_value(ctx.request.params.clone())
.map_err(|e| error::DecapodError::ValidationError(format!("Invalid params: {}", e)))?;
let engine = MentorEngine::new(ctx.project_root);
let obligations_ctx = ObligationsContext {
op: params.op.unwrap_or_else(|| "unknown".to_string()),
params: params.params.unwrap_or_else(|| serde_json::json!({})),
touched_paths: params.touched_paths.unwrap_or_default(),
diff_summary: params.diff_summary,
project_profile_id: params.project_profile_id,
session_id: params.session_id,
high_risk: params.high_risk.unwrap_or(false),
};
let obligations = engine.compute_obligations(&obligations_ctx)?;
let context_capsule = ContextCapsule {
fragments: vec![],
spec: None,
architecture: None,
security: None,
standards: None,
};
let mut response = success_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
Some(
serde_json::to_value(MentorObligationsResult {
obligations: obligations.clone(),
})
.unwrap(),
),
vec![],
Some(context_capsule),
vec![AllowedOp {
op: "mentor.obligations".to_string(),
reason: "Obligations computed - review must list before proceeding".to_string(),
required_params: vec![],
}],
ctx.mandates.clone(),
);
if !obligations.contradictions.is_empty() {
response.blocked_by = mentor::contradictions_to_blockers(&obligations.contradictions);
}
Ok(response)
}
pub(crate) fn handle_assurance_evaluate(
ctx: &RpcCtx,
) -> Result<RpcResponse, error::DecapodError> {
let params: AssuranceEvaluateParams = serde_json::from_value(ctx.request.params.clone())
.map_err(|e| error::DecapodError::ValidationError(format!("Invalid params: {}", e)))?;
let input = AssuranceEvaluateInput {
op: params.op.unwrap_or_else(|| "unknown".to_string()),
params: params.params.unwrap_or_else(|| serde_json::json!({})),
touched_paths: params.touched_paths.unwrap_or_default(),
diff_summary: params.diff_summary,
session_id: params.session_id,
phase: params.phase,
time_budget_s: params.time_budget_s,
};
let engine = AssuranceEngine::new(ctx.project_root);
let evaluated = engine.evaluate(&input)?;
let mut response = success_response(
ctx.request.id.clone(),
ctx.request.op.clone(),
ctx.request.params.clone(),
Some(
serde_json::to_value(AssuranceEvaluateResult {
assurance_evaluated: true,
interlock_code: evaluated.interlock.as_ref().map(|i| i.code.clone()),
})
.unwrap(),
),
input.touched_paths.clone(),
None,
if let Some(interlock) = &evaluated.interlock {
interlock
.unblock_ops
.iter()
.map(|op| AllowedOp {
op: op.clone(),
reason: format!("Unblock path for {}", interlock.code),
required_params: vec![],
})
.collect()
} else {
vec![AllowedOp {
op: "assurance.evaluate".to_string(),
reason: "Re-evaluate after meaningful context changes".to_string(),
required_params: vec![],
}]
},
ctx.mandates.clone(),
);
response.interlock = evaluated.interlock.clone();
response.advisory = Some(evaluated.advisory.clone());
response.attestation = Some(evaluated.attestation.clone());
if let Some(interlock) = evaluated.interlock {
response.blocked_by = vec![Blocker {
kind: match interlock.code.as_str() {
"workspace_required" => BlockerKind::WorkspaceRequired,
"verification_required" => BlockerKind::MissingProof,
"store_boundary_violation" => BlockerKind::Unauthorized,
"decision_required" => BlockerKind::MissingAnswer,
_ => BlockerKind::ValidationFailed,
},
message: interlock.code,
resolve_hint: interlock.message,
}];
}
Ok(response)
}
}
fn run_rpc_command(cli: RpcCli, project_root: &Path) -> Result<(), error::DecapodError> {
use crate::core::rpc::*;
let request: RpcRequest = if cli.stdin {
let mut buffer = String::new();
std::io::stdin()
.read_to_string(&mut buffer)
.map_err(error::DecapodError::IoError)?;
serde_json::from_str(&buffer)
.map_err(|e| error::DecapodError::ValidationError(format!("Invalid JSON: {}", e)))?
} else {
let op = cli.op.ok_or_else(|| {
error::DecapodError::ValidationError("Operation required".to_string())
})?;
let params = cli
.params
.as_ref()
.and_then(|p| serde_json::from_str(p).ok())
.unwrap_or(serde_json::json!({}));
RpcRequest {
op,
params,
id: default_request_id(),
session: None,
}
};
enforce_worktree_requirement_for_rpc(&request.op, project_root)?;
if !rpc_op_bypasses_session(&request.op) {
ensure_session_valid()?;
}
enforce_constitutional_awareness_for_rpc(&request.op, project_root)?;
let project_store = Store {
kind: StoreKind::Repo,
root: project_root.join(".decapod").join("data"),
};
let mandates = docs::resolve_mandates(project_root, &request.op);
let mandate_blockers = if rpc_op_skips_mandate_enforcement(&request.op) {
Vec::new()
} else {
validate::evaluate_mandates(project_root, &project_store, &mandates)
};
let blocked_mandate = mandates.iter().find(|m| {
mandate_blockers
.iter()
.any(|b| b.message.contains(&m.fragment.title))
});
if let Some(mandate) = blocked_mandate {
let blocker = mandate_blockers
.iter()
.find(|b| b.message.contains(&mandate.fragment.title))
.unwrap();
let response = error_response(
request.id.clone(),
request.op.clone(),
request.params.clone(),
"mandate_violation".to_string(),
blocker.message.clone(),
Some(blocker.clone()),
mandates,
);
println!("{}", serde_json::to_string_pretty(&response).unwrap());
return Ok(());
}
let rpc_ctx = RpcCtx {
project_root,
store: &project_store,
request: &request,
mandates: mandates.clone(),
};
let response = match request.op.as_str() {
"agent.init" => rpc_handlers::handle_agent_init(&rpc_ctx)?,
"workspace.status" => rpc_handlers::handle_workspace_status(&rpc_ctx)?,
"workspace.ensure" => rpc_handlers::handle_workspace_ensure(&rpc_ctx)?,
"workspace.publish" => rpc_handlers::handle_workspace_publish(&rpc_ctx)?,
"context.resolve" | "context.scope" => rpc_handlers::handle_context_resolve(&rpc_ctx)?,
"context.capsule.query" => rpc_handlers::handle_context_capsule_query(&rpc_ctx)?,
"context.bindings" => rpc_handlers::handle_context_bindings(&rpc_ctx)?,
"constitution.get" => rpc_handlers::handle_constitution_get(&rpc_ctx)?,
"schema.get" => rpc_handlers::handle_schema_get(&rpc_ctx)?,
"store.upsert" => rpc_handlers::handle_store_upsert(&rpc_ctx)?,
"store.query" => rpc_handlers::handle_store_query(&rpc_ctx)?,
"validate.run" => rpc_handlers::handle_validate_run(&rpc_ctx)?,
"scaffold.next_question" => rpc_handlers::handle_scaffold_next_question(&rpc_ctx)?,
"scaffold.apply_answer" => rpc_handlers::handle_scaffold_apply_answer(&rpc_ctx)?,
"scaffold.generate_artifacts" => {
rpc_handlers::handle_scaffold_generate_artifacts(&rpc_ctx)?
}
"standards.resolve" => rpc_handlers::handle_standards_resolve(&rpc_ctx)?,
"mentor.obligations" => rpc_handlers::handle_mentor_obligations(&rpc_ctx)?,
"assurance.evaluate" => rpc_handlers::handle_assurance_evaluate(&rpc_ctx)?,
_ => error_response(
request.id.clone(),
request.op.clone(),
request.params.clone(),
"unknown_op".to_string(),
format!("Unknown operation: {}", request.op),
None,
mandates.clone(),
),
};
let trace_event = trace::TraceEvent {
trace_id: request.id.clone(),
ts: crate::core::time::now_epoch_z(),
actor: current_agent_id(),
op: request.op.clone(),
request: serde_json::to_value(&request).unwrap_or(serde_json::Value::Null),
response: serde_json::to_value(&response).unwrap_or(serde_json::Value::Null),
};
let _ = trace::append_trace(project_root, trace_event);
println!("{}", serde_json::to_string_pretty(&response).unwrap());
Ok(())
}
fn maybe_bind_capsule_to_workunit_state_ref(
project_root: &Path,
workunit_task_id: Option<&str>,
capsule_path: &Path,
) -> Result<Option<PathBuf>, error::DecapodError> {
let Some(task_id) = workunit_task_id else {
return Ok(None);
};
match core::workunit::load_workunit(project_root, task_id) {
Ok(_) => {
let state_ref = capsule_path
.strip_prefix(project_root)
.unwrap_or(capsule_path)
.to_string_lossy()
.replace('\\', "/");
core::workunit::add_state_ref(project_root, task_id, &state_ref)?;
let path = core::workunit::workunit_path(project_root, task_id)?;
Ok(Some(path))
}
Err(error::DecapodError::NotFound(_)) => Ok(None),
Err(e) => Err(e),
}
}
fn run_capabilities_command(cli: CapabilitiesCli) -> Result<(), error::DecapodError> {
use crate::core::rpc::generate_capabilities;
let report = generate_capabilities();
match cli.format.as_str() {
"json" => {
println!("{}", serde_json::to_string_pretty(&report).unwrap());
}
_ => {
println!("Decapod {}", report.version);
println!("==================\n");
println!("Capabilities:");
for cap in &report.capabilities {
println!(" {} [{}] - {}", cap.name, cap.stability, cap.description);
}
println!("\nSubsystems:");
for sub in &report.subsystems {
println!(" {} [{}]", sub.name, sub.status);
println!(" Ops: {}", sub.ops.join(", "));
}
println!("\nWorkspace:");
println!(
" Enforcement: {}",
if report.workspace.enforcement_available {
"available"
} else {
"unavailable"
}
);
println!(
" Docker: {}",
if report.workspace.docker_available {
"available"
} else {
"unavailable"
}
);
println!(
" Protected: {}",
report.workspace.protected_patterns.join(", ")
);
println!("\nInterview:");
println!(
" Available: {}",
if report.interview.available {
"yes"
} else {
"no"
}
);
println!(
" Artifacts: {}",
report.interview.artifact_types.join(", ")
);
println!("\nInterlocks:");
println!(" Codes: {}", report.interlock_codes.join(", "));
}
}
Ok(())
}
fn run_trace_command(cli: TraceCli, project_root: &Path) -> Result<(), error::DecapodError> {
match cli.command {
TraceCommand::Export { last } => {
let traces = trace::get_last_traces(project_root, last)?;
for t in traces {
println!("{}", t);
}
}
}
Ok(())
}
fn run_preflight_command(
cli: PreflightCli,
project_root: &Path,
) -> Result<(), error::DecapodError> {
use crate::core::workspace;
let op = cli.op.unwrap_or_else(|| "unknown".to_string());
let workspace_status = match workspace::get_workspace_status(project_root) {
Ok(status) => status,
Err(_) => {
return Ok(());
}
};
let mut risk_flags = Vec::new();
let mut likely_failures = Vec::new();
let mut required_capsules = Vec::new();
let mut next_best_actions = Vec::new();
if workspace_status.git.is_protected {
risk_flags.push("protected_branch");
likely_failures.push(serde_json::json!({
"code": "WORKSPACE_REQUIRED",
"message": "Cannot operate on protected branch",
"current_branch": workspace_status.git.current_branch,
}));
next_best_actions.push("Run: decapod workspace ensure");
}
if !workspace_status.can_work {
risk_flags.push("workspace_blocked");
for blocker in &workspace_status.blockers {
likely_failures.push(serde_json::json!({
"code": "WORKSPACE_BLOCKED",
"message": blocker.message,
"resolve_hint": blocker.resolve_hint,
}));
}
}
match op.as_str() {
"todo.add" | "todo.claim" | "todo.done" => {
required_capsules.push("plugins/TODO");
required_capsules.push("interfaces/STORE_MODEL");
}
"validate" => {
required_capsules.push("plugins/VERIFY");
required_capsules.push("interfaces/TESTING");
if workspace_status.git.is_protected {}
}
"workspace.ensure" | "workspace.status" => {
required_capsules.push("core/DECAPOD");
required_capsules.push("core/PLUGINS");
}
"rpc" | "agent.init" => {
required_capsules.push("core/INTERFACES");
required_capsules.push("specs/INTENT");
}
_ => {
required_capsules.push("core/DECAPOD");
}
}
if risk_flags.is_empty() {
next_best_actions.push("Proceed with operation");
}
let response = serde_json::json!({
"op": op,
"session_id": cli.session,
"risk_flags": risk_flags,
"likely_failures": likely_failures,
"required_capsules": required_capsules,
"next_best_actions": next_best_actions,
"workspace": {
"git_branch": workspace_status.git.current_branch,
"git_is_protected": workspace_status.git.is_protected,
"can_work": workspace_status.can_work,
}
});
if cli.format == "json" {
println!("{}", serde_json::to_string_pretty(&response).unwrap());
} else {
println!("Preflight Check for: {}", op);
if risk_flags.is_empty() {
println!("✓ No risks detected");
} else {
println!("⚠ Risks: {:?}", risk_flags);
println!("Likely failures:");
for failure in &likely_failures {
println!(" - {}: {}", failure["code"], failure["message"]);
}
}
println!("Required capsules: {:?}", required_capsules);
}
Ok(())
}
fn run_impact_command(cli: ImpactCli, project_root: &Path) -> Result<(), error::DecapodError> {
use crate::core::workspace;
let changed_files: Vec<String> = cli
.changed_files
.as_ref()
.map(|s| s.split(',').map(|s| s.trim().to_string()).collect())
.unwrap_or_default();
let workspace_status = match workspace::get_workspace_status(project_root) {
Ok(status) => status,
Err(_) => {
let response = serde_json::json!({
"changed_files": changed_files,
"will_fail_validate": false,
"predicted_failures": [],
"validation_predictions": [],
"workspace": {
"git_branch": "unknown",
"git_is_protected": false,
"can_work": true,
},
"recommendation": "Could not determine workspace status"
});
println!("{}", serde_json::to_string_pretty(&response).unwrap());
return Ok(());
}
};
let mut predicted_failures = Vec::new();
let mut validation_predictions = Vec::new();
if workspace_status.git.is_protected {
predicted_failures.push(serde_json::json!({
"gate": "workspace_isolation",
"status": "fail",
"code": "WORKSPACE_REQUIRED",
"message": "Operating on protected branch",
}));
} else {
validation_predictions.push(serde_json::json!({
"gate": "workspace_isolation",
"status": "pass",
}));
}
if !changed_files.is_empty() {
validation_predictions.push(serde_json::json!({
"gate": "file_changes_detected",
"status": "pass",
"changed_count": changed_files.len(),
}));
}
let will_fail_validate = !predicted_failures.is_empty();
let response = serde_json::json!({
"changed_files": changed_files,
"will_fail_validate": will_fail_validate,
"predicted_failures": predicted_failures,
"validation_predictions": validation_predictions,
"workspace": {
"git_branch": workspace_status.git.current_branch,
"git_is_protected": workspace_status.git.is_protected,
"can_work": workspace_status.can_work,
},
"recommendation": if will_fail_validate {
"Fix workspace issues before running validate"
} else if changed_files.is_empty() {
"No changes detected - nothing to validate"
} else {
"Safe to run validate"
}
});
if cli.format == "json" {
println!("{}", serde_json::to_string_pretty(&response).unwrap());
} else {
println!("Impact Analysis");
if will_fail_validate {
println!("⚠ Validate will FAIL");
for failure in &predicted_failures {
println!(" - {}: {}", failure["code"], failure["message"]);
}
} else {
println!("✓ Validate should pass");
}
if !changed_files.is_empty() {
println!("Changed files: {:?}", changed_files);
}
}
Ok(())
}
fn run_infer_command(cli: InferCli, project_root: &Path) -> Result<(), error::DecapodError> {
let project_root = project_root.to_path_buf();
match cli.command {
InferCommand::Init(init_cli) => run_infer_init(init_cli, &project_root)?,
InferCommand::Orientation(orientation_cli) => {
run_infer_orientation(orientation_cli, &project_root)?
}
InferCommand::Validate(validate_cli) => run_infer_validate(validate_cli)?,
InferCommand::Budget(budget_cli) => run_infer_budget(budget_cli, &project_root)?,
}
Ok(())
}
fn run_infer_init(cli: InferInitCli, project_root: &Path) -> Result<(), error::DecapodError> {
use std::fs;
let intent = cli.intent.trim().to_lowercase();
let context_files: Vec<String> = cli
.context
.as_ref()
.map(|s| s.split(',').map(|s| s.trim().to_string()).collect())
.unwrap_or_default();
let mut selected_context = Vec::new();
let mut excluded_context = Vec::new();
let excluded_extensions = ["md", "lock", "toml", "json", "yml", "yaml", "git"];
let critical_keywords = ["fix", "bug", "error", "panic", "crash"];
let docs_keywords = ["docs", "readme", "documentation", "guide"];
let refactor_keywords = ["refactor", "rename", "restructure", "cleanup"];
let intent_type = if critical_keywords.iter().any(|k| intent.contains(*k)) {
"fix"
} else if refactor_keywords.iter().any(|k| intent.contains(*k)) {
"refactor"
} else if docs_keywords.iter().any(|k| intent.contains(*k)) {
"docs"
} else {
"unknown"
};
for file in &context_files {
let path = project_root.join(file);
if path.exists() {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if excluded_extensions.contains(&ext) && intent_type != "docs" {
excluded_context.push(file.clone());
continue;
}
if file.contains("/tests/") && !intent.contains("test") {
excluded_context.push(file.clone());
continue;
}
selected_context.push(file.clone());
}
}
if context_files.is_empty() {
if let Ok(entries) = fs::read_dir(project_root.join("src")) {
for entry in entries.flatten() {
if let Ok(name) = entry.file_name().into_string()
&& name.ends_with(".rs")
&& !name.contains("_test")
{
selected_context.push(format!("src/{}", name));
}
}
}
excluded_context = vec![
"target/".to_string(),
"build/".to_string(),
".git/".to_string(),
];
}
let token_budget = (selected_context.len() as u64 * 500).min(100_000);
let clarification_required = intent.len() < 20 || intent_type == "unknown";
let response = serde_json::json!({
"intent": cli.intent,
"intent_type": intent_type,
"confidence": if clarification_required { "low" } else { "high" },
"clarification_required": clarification_required,
"clarification_question": if clarification_required {
Some("Could you clarify what you'd like me to do?".to_string())
} else { None },
"selected_context": selected_context,
"excluded_context": excluded_context,
"selected_policies": ["default"],
"token_budget": token_budget,
"proof_required": intent_type == "fix",
"boundaries": { "max_tokens": 100000, "context_files_limit": 20 }
});
if cli.format == "json" {
println!("{}", serde_json::to_string_pretty(&response).unwrap());
} else {
println!("=== Inference Context ===");
println!("Intent: {}", cli.intent);
println!("Type: {}", intent_type);
if clarification_required {
println!("⚠ Clarification needed");
}
println!(
"Selected files: {}",
response["selected_context"]
.as_array()
.map(|a| a.len())
.unwrap_or(0)
);
println!("Token budget: ~{}", token_budget);
}
Ok(())
}
fn run_infer_orientation(
cli: InferOrientationCli,
project_root: &Path,
) -> Result<(), error::DecapodError> {
use crate::core::rpc::{DecisionGate, DecisionOption, OrientationPacket};
let mut packet = OrientationPacket {
user_goal: cli.intent.clone().unwrap_or_else(|| "Unknown".to_string()),
task_id: cli.task_id.clone(),
constraints: vec!["Strict adherence to AGENTS.md".to_string()],
allowed_scope: vec![],
forbidden_scope: vec![".decapod/".to_string()],
relevant_areas: vec![],
proof_required: vec!["decapod validate passes".to_string()],
known_unknowns: vec![],
decision_gates: vec![],
next_action: "Perform research to map relevant files and symbols.".to_string(),
};
if let Some(ref id) = cli.task_id {
let store_root = project_root.join(".decapod").join("data");
if let Some(task) = todo::get_task(&store_root, id)? {
packet.user_goal = task.title.clone();
if !task.description.is_empty() {
packet
.known_unknowns
.push(format!("Task detail: {}", task.description));
}
if !task.scope.is_empty() {
packet.allowed_scope = task
.scope
.split(',')
.map(|s| s.trim().to_string())
.collect();
}
}
}
let intent_lower = packet.user_goal.to_lowercase();
if intent_lower.contains("fix") || intent_lower.contains("bug") {
packet
.proof_required
.push("Reproduction test case".to_string());
packet
.constraints
.push("Do not introduce regressions".to_string());
}
if intent_lower.contains("refactor")
|| intent_lower.contains("architecture")
|| intent_lower.contains("interface")
{
packet.decision_gates.push(DecisionGate {
decision: "Architectural alignment".to_string(),
rationale: "Changes to core structures or interfaces require human alignment on long-term maintainability.".to_string(),
options: vec![
DecisionOption {
label: "Conservative (wrapper/adapter)".to_string(),
impact: "Minimizes immediate breakage".to_string(),
},
DecisionOption {
label: "Aggressive (breaking change)".to_string(),
impact: "Cleaner long-term architecture, requires migration".to_string(),
},
],
recommendation: "Prefer conservative adaptation unless the current interface is fundamentally broken.".to_string(),
validation_proof: "Contract conformance tests and migration guide".to_string(),
});
packet.next_action =
"STOP: A decision gate is active. Present options to the human before proceeding."
.to_string();
}
if intent_lower.contains("test") {
packet.relevant_areas.push("tests/fixtures".to_string());
}
if packet.allowed_scope.is_empty() {
if intent_lower.contains("test") {
packet.allowed_scope = vec!["tests/".to_string()];
} else {
packet.allowed_scope = vec!["src/".to_string(), "tests/".to_string()];
}
}
if cli.format == "json" {
println!("{}", serde_json::to_string_pretty(&packet).unwrap());
} else {
println!("=== ORIENTATION PACKET ===");
println!("Goal: {}", packet.user_goal);
if let Some(ref id) = packet.task_id {
println!("Task: {}", id);
}
println!("Next: {}", packet.next_action);
println!(
"Scope: +{:?} -{:?}",
packet.allowed_scope, packet.forbidden_scope
);
println!("Proof: {:?}", packet.proof_required);
if !packet.decision_gates.is_empty() {
println!("\n⚠ DECISION GATES REQUIRED:");
for gate in &packet.decision_gates {
println!(" - DECISION: {}", gate.decision);
println!(" RATIONALE: {}", gate.rationale);
println!(" RECOMMENDATION: {}", gate.recommendation);
println!(" OPTIONS:");
for opt in &gate.options {
println!(" * {}: {}", opt.label, opt.impact);
}
}
}
}
Ok(())
}
fn run_infer_validate(cli: InferValidateCli) -> Result<(), error::DecapodError> {
let result = cli.result.trim();
let intent = cli.intent.trim().to_lowercase();
let proof_provided =
result.contains("fn ") || result.contains("struct ") || result.contains("impl ");
let mut issues = Vec::new();
if result.contains("error") || result.contains("panic") {
issues.push("Potential error/panic in output");
}
let intent_match = if intent.contains("fix") || intent.contains("bug") {
result.contains("fix") || result.contains("change")
} else {
true
};
let response = serde_json::json!({
"intent": cli.intent,
"intent_match": intent_match,
"proof_provided": proof_provided,
"issues": issues,
"advisory": if issues.is_empty() { "ok" } else { "review recommended" }
});
if cli.format == "json" {
println!("{}", serde_json::to_string_pretty(&response).unwrap());
} else {
println!("=== Validation ===");
println!("Intent match: {}", if intent_match { "✓" } else { "✗" });
println!("Proof provided: {}", if proof_provided { "✓" } else { "✗" });
}
Ok(())
}
fn run_infer_budget(cli: InferBudgetCli, project_root: &Path) -> Result<(), error::DecapodError> {
use std::fs;
let context_files: Vec<String> = cli
.context
.as_ref()
.map(|s| s.split(',').map(|s| s.trim().to_string()).collect())
.unwrap_or_default();
let mut total_tokens = 0u64;
for file in &context_files {
let path = project_root.join(file);
if let Ok(content) = fs::read_to_string(&path) {
total_tokens += content.lines().count() as u64 * 8;
}
}
let base_tokens = 500u64;
let response = serde_json::json!({
"intent": cli.intent,
"context_tokens": total_tokens,
"base_tokens": base_tokens,
"estimated_total": total_tokens + base_tokens,
"within_budget": total_tokens + base_tokens < 100000,
"token_budget": { "soft_limit": 100000, "recommended": 80000 }
});
if cli.format == "json" {
println!("{}", serde_json::to_string_pretty(&response).unwrap());
} else {
println!("=== Token Budget ===");
println!("Context: ~{} tokens", total_tokens);
println!("Total: ~{} tokens", total_tokens + base_tokens);
println!(
"Within 100k: {}",
if total_tokens + base_tokens < 100000 {
"✓"
} else {
"⚠"
}
);
}
Ok(())
}
fn run_demo_command(cli: DemoCli, project_root: &Path) -> Result<(), error::DecapodError> {
use crate::core::workspace;
println!("==============================================");
println!("Decapod Interlock Demo: Predict Before You Fail");
println!("==============================================\n");
match cli.demo.as_str() {
"interlock" => {
println!("Step 1: Check workspace status");
let status = workspace::get_workspace_status(project_root)?;
println!(" Branch: {}", status.git.current_branch);
println!(" Protected: {}", status.git.is_protected);
println!(" Can work: {}\n", status.can_work);
println!("Step 2: Run preflight to predict validate outcome");
run_preflight_command(
PreflightCli {
op: Some("validate".to_string()),
format: "json".to_string(),
session: None,
},
project_root,
)?;
println!();
println!("Step 3: Run impact to predict what will happen with changes");
run_impact_command(
ImpactCli {
changed_files: Some("src/core/validate.rs,src/lib.rs".to_string()),
format: "json".to_string(),
predict: true,
},
project_root,
)?;
println!();
println!("Step 4: Verify prediction matches reality");
println!(" (Running validate would show WORKSPACE_REQUIRED on protected branch)\n");
println!("==============================================");
println!("Key insight: preflight told us:");
println!(" - risk_flags: [protected_branch]");
println!(" - likely_failures: [WORKSPACE_REQUIRED]");
println!(" - next_best_actions: [Run: decapod workspace ensure]");
println!();
println!("Following that guidance prevents the failure instead of reacting to it.");
println!("==============================================");
Ok(())
}
_ => {
println!("Available demos:");
println!(" interlock - Shows preflight + impact prediction");
Ok(())
}
}
}
#[cfg(test)]
mod init_prompt_tests {
use super::*;
use tempfile::tempdir;
#[test]
fn arrow_keys_move_selection_without_entering_input_text() {
let default = vec!["Rust".to_string()];
let selected = selector_result_for_input(LANGUAGES, &default, b"\x1b[B\x1b[B\x1b[A\n");
assert_eq!(selected, "TypeScript");
assert!(!selected.contains("\x1b"));
assert!(!selected.contains("[B"));
}
#[test]
fn selector_render_tracks_current_selection() {
let default = vec!["Python".to_string()];
let default_lines = selector_render_for_input(LANGUAGES, None, &default, b"\n");
assert_eq!(default_lines[0], " choice: Python");
assert!(
default_lines
.iter()
.any(|line| line == " > ✓ 4. Python")
);
let typed_lines = selector_render_for_input(LANGUAGES, None, &default, b"go\n");
assert_eq!(typed_lines[0], " choice: go");
assert!(typed_lines.iter().any(|line| line == " > 5. Go"));
let custom_lines =
selector_render_for_input(LANGUAGES, None, &default, b"not-a-language\n");
assert_eq!(custom_lines[0], " choice: not-a-language");
}
#[test]
fn language_selector_limits_visible_options_and_marks_more_below() {
let default = vec!["Rust".to_string()];
let lines = selector_render_for_input(LANGUAGES, None, &default, b"\n");
let option_lines = lines
.iter()
.filter(|line| line.contains(". "))
.collect::<Vec<_>>();
assert_eq!(option_lines.len(), SELECTOR_VISIBLE_OPTIONS);
assert!(lines.iter().any(|line| line == " ↓ more"));
assert!(
!lines
.iter()
.any(|line| line.contains(" 11. ") || line.contains(" 30. "))
);
}
#[test]
fn language_selector_wraps_from_bottom_to_top() {
let default = vec!["Other".to_string()];
let selected = selector_result_for_input(LANGUAGES, &default, b"\x1b[B\n");
assert_eq!(selected, "Rust");
}
#[test]
fn language_selector_shows_wrap_hint_at_bottom() {
let default = vec!["Other".to_string()];
let lines = selector_render_for_input(LANGUAGES, None, &default, b"\n");
assert!(lines.iter().any(|line| line == " ↑ more"));
assert!(lines.iter().any(|line| line == " ↓ wraps to 1"));
assert!(lines.iter().any(|line| line == " > ✓ 30. Other"));
}
#[test]
fn language_selector_numeric_typing_moves_selection_into_view() {
let default = vec!["Rust".to_string()];
let lines = selector_render_for_input(LANGUAGES, None, &default, b"30\n");
assert_eq!(lines[0], " choice: 30");
assert!(lines.iter().any(|line| line == " > 30. Other"));
assert!(!lines.iter().any(|line| line.contains(" 1. Rust")));
}
#[test]
fn language_selector_text_typing_moves_selection_into_view() {
let default = vec!["Rust".to_string()];
let lines = selector_render_for_input(LANGUAGES, None, &default, b"powershell\n");
assert_eq!(lines[0], " choice: powershell");
assert!(lines.iter().any(|line| line == " > 29. PowerShell"));
assert!(!lines.iter().any(|line| line.contains(" 1. Rust")));
}
#[test]
fn enter_accepts_inferred_default_language() {
let default = vec!["Python".to_string()];
let selected = selector_result_for_input(LANGUAGES, &default, b"\n");
assert_eq!(selected, "Python");
}
#[test]
fn numeric_language_selection_targets_numbered_option() {
let default = vec!["Rust".to_string()];
let selected = selector_result_for_input(LANGUAGES, &default, b"4\n");
assert_eq!(selected, "Python");
assert_eq!(parse_language_choice(&selected), vec!["Python".to_string()]);
}
#[test]
fn typed_language_selection_targets_matching_language() {
let default = vec!["Rust".to_string()];
let selected = selector_result_for_input(LANGUAGES, &default, b"python\n");
assert_eq!(selected, "Python");
assert_eq!(parse_language_choice("python"), vec!["Python".to_string()]);
}
#[test]
fn selector_render_shows_one_navigable_option_list() {
let default = vec!["cli".to_string()];
let options = ARCH_DIRECTIONS
.iter()
.map(|(arch, _)| *arch)
.collect::<Vec<_>>();
let descriptions = ARCH_DIRECTIONS
.iter()
.map(|(_, description)| *description)
.collect::<Vec<_>>();
let lines = selector_render_for_input(&options, Some(&descriptions), &default, b"\x1b[B\n");
assert_eq!(lines[0], " choice: lambda");
assert_eq!(
lines
.iter()
.filter(|line| line.contains("webapp -> Web application"))
.count(),
1
);
assert!(lines.iter().any(|line| line.starts_with(" >")));
}
#[test]
fn diagram_notation_selector_uses_terminal_readable_options() {
let default = vec![String::from("ascii")];
let lines = selector_render_for_input(
DIAGRAM_NOTATION_OPTIONS,
Some(DIAGRAM_NOTATION_DESCRIPTIONS),
&default,
b"\x1b[B\n",
);
assert_eq!(lines[0], " choice: mermaid");
assert_eq!(
lines
.iter()
.filter(|line| line.contains("ascii -> ASCII/text blocks"))
.count(),
1
);
assert!(
lines
.iter()
.any(|line| line.contains("mermaid -> Mermaid diagrams"))
);
}
#[test]
fn diagram_notation_choice_accepts_text_aliases() {
assert_eq!(
parse_diagram_style_choice("", InitDiagramStyle::Mermaid).unwrap(),
InitDiagramStyle::Mermaid
);
assert_eq!(
parse_diagram_style_choice("text", InitDiagramStyle::Mermaid).unwrap(),
InitDiagramStyle::Ascii
);
assert_eq!(
parse_diagram_style_choice("ascii/text", InitDiagramStyle::Mermaid).unwrap(),
InitDiagramStyle::Ascii
);
assert_eq!(
parse_diagram_style_choice("2", InitDiagramStyle::Ascii).unwrap(),
InitDiagramStyle::Mermaid
);
assert!(parse_diagram_style_choice("plantuml", InitDiagramStyle::Ascii).is_err());
}
#[test]
fn comma_separated_language_selection_is_preserved() {
assert_eq!(
parse_language_choice("4, shell, typescript"),
vec![
"Python".to_string(),
"Shell".to_string(),
"TypeScript".to_string()
]
);
}
#[test]
fn line_prompt_escape_backstop_strips_raw_ansi_sequences() {
assert_eq!(
strip_ansi_escape_sequences("^[[B\x1b[Bpython\x1b[A"),
"^[[Bpython"
);
}
#[test]
fn inferred_language_wins_over_architecture_recommendation_for_default() {
assert_eq!(
language_choice_seed(&["Python".to_string()], &["Rust".to_string()]),
vec!["Python".to_string()]
);
}
#[test]
fn mixed_scripts_repo_infers_multiple_languages_without_compiled_bias() {
let tmp = tempdir().expect("tempdir");
fs::write(tmp.path().join("task.py"), "print('ok')\n").expect("python fixture");
fs::write(tmp.path().join("deploy.sh"), "#!/usr/bin/env bash\n").expect("shell fixture");
fs::write(tmp.path().join("env.zsh"), "printenv\n").expect("zsh fixture");
fs::write(tmp.path().join("tool.ts"), "export const ok = true;\n").expect("ts fixture");
fs::write(tmp.path().join("probe.go"), "package main\n").expect("go fixture");
let ctx = infer_repo_context(tmp.path());
assert!(ctx.primary_languages.contains(&"go".to_string()));
assert!(ctx.primary_languages.contains(&"python".to_string()));
assert!(ctx.primary_languages.contains(&"shell".to_string()));
assert!(ctx.primary_languages.contains(&"typescript".to_string()));
assert_ne!(ctx.primary_languages, vec!["rust".to_string()]);
}
}