Skip to main content

decapod/
lib.rs

1//! Decapod library crate.
2//!
3//! Exposes the core control-plane runtime (`core`), embedded constitution/document
4//! access (`constitution`), and plugin subsystems (`plugins`).
5//!
6//! Runtime operational contracts for agents are defined in repository entrypoint
7//! docs and constitution documents, not in Rust source comments.
8
9pub(crate) mod cli;
10pub mod constitution;
11pub mod core;
12pub mod plugins;
13pub(crate) mod subsystems;
14
15use cli::*;
16
17use core::{
18    db, docs, docs_cli, error, flight_recorder, migration, obligation, plan_governance, proof,
19    repomap, scaffold, state_commit,
20    store::{Store, StoreKind},
21    todo, trace, validate, workspace,
22};
23use plugins::{
24    aptitude, archive, container, context, cron, decide, doctor, eval, federation, feedback,
25    health, internalize, knowledge, lcm, map_ops, policy, primitives, reflex, verify, watcher,
26    workflow,
27};
28
29use clap::{CommandFactory, Parser};
30use serde::{Deserialize, Serialize};
31use sha2::{Digest, Sha256};
32use std::collections::BTreeMap;
33use std::fs;
34use std::io;
35use std::io::IsTerminal;
36use std::io::Read;
37use std::io::Write;
38use std::path::{Path, PathBuf};
39use std::sync::OnceLock;
40use std::sync::mpsc;
41use std::thread;
42use std::time::{SystemTime, UNIX_EPOCH};
43
44// CLI struct definitions have been moved to src/cli.rs
45
46// (remaining CLI struct definitions removed — now in src/cli.rs)
47
48fn find_decapod_project_root(start_dir: &Path) -> Result<PathBuf, error::DecapodError> {
49    let mut current_dir = PathBuf::from(start_dir);
50    loop {
51        if current_dir.join(".decapod").exists() {
52            return Ok(current_dir);
53        }
54        if !current_dir.pop() {
55            return Err(error::DecapodError::NotFound(
56                "'.decapod' directory not found in current or parent directories. Run `decapod init` first.".to_string(),
57            ));
58        }
59    }
60}
61
62// Process-local session password - eliminates unsafe env::set_var
63static SESSION_PASSWORD: OnceLock<String> = OnceLock::new();
64
65fn clean_project(dir: Option<PathBuf>) -> Result<(), error::DecapodError> {
66    let raw_dir = match dir {
67        Some(d) => d,
68        None => std::env::current_dir()?,
69    };
70    let target_dir = std::fs::canonicalize(&raw_dir).map_err(error::DecapodError::IoError)?;
71
72    let decapod_root = target_dir.join(".decapod");
73    if decapod_root.exists() {
74        println!("Removing directory: {}", decapod_root.display());
75        fs::remove_dir_all(&decapod_root).map_err(error::DecapodError::IoError)?;
76    }
77
78    for file in ["AGENTS.md", "CLAUDE.md", "GEMINI.md", "CODEX.md"] {
79        let path = target_dir.join(file);
80        if path.exists() {
81            println!("Removing file: {}", path.display());
82            fs::remove_file(&path).map_err(error::DecapodError::IoError)?;
83        }
84    }
85    println!("Decapod files cleaned from {}", target_dir.display());
86    Ok(())
87}
88
89fn decapod_config_path(target_dir: &Path) -> PathBuf {
90    target_dir.join(".decapod").join("config.toml")
91}
92
93fn load_project_config_if_present(
94    target_dir: &Path,
95) -> Result<Option<DecapodProjectConfig>, error::DecapodError> {
96    let config_path = decapod_config_path(target_dir);
97    if !config_path.exists() {
98        return Ok(None);
99    }
100    let raw = fs::read_to_string(&config_path).map_err(error::DecapodError::IoError)?;
101    let cfg: DecapodProjectConfig = toml::from_str(&raw).map_err(|e| {
102        error::DecapodError::ValidationError(format!("Invalid .decapod/config.toml schema: {}", e))
103    })?;
104    Ok(Some(cfg))
105}
106
107fn write_project_config(
108    target_dir: &Path,
109    config: &DecapodProjectConfig,
110    dry_run: bool,
111) -> Result<(), error::DecapodError> {
112    if dry_run {
113        return Ok(());
114    }
115    let config_path = decapod_config_path(target_dir);
116    if let Some(parent) = config_path.parent() {
117        fs::create_dir_all(parent).map_err(error::DecapodError::IoError)?;
118    }
119    let serialized = toml::to_string_pretty(config).map_err(|e| {
120        error::DecapodError::ValidationError(format!("Failed to serialize config.toml: {}", e))
121    })?;
122    fs::write(config_path, serialized).map_err(error::DecapodError::IoError)?;
123    Ok(())
124}
125
126fn seed_init_generated_state(target_dir: &Path, dry_run: bool) -> Result<(), error::DecapodError> {
127    if dry_run {
128        return Ok(());
129    }
130
131    let _ = docs_cli::sync_override_checksum(target_dir, false)?;
132    Ok(())
133}
134
135fn is_not_git_repository_error(err: &error::DecapodError) -> bool {
136    matches!(
137        err,
138        error::DecapodError::ValidationError(message)
139            if message.contains("Not in a git repository")
140    )
141}
142
143fn infer_repo_context(target_dir: &Path) -> RepoContext {
144    let mut ctx = RepoContext {
145        product_name: target_dir
146            .file_name()
147            .and_then(|s| s.to_str())
148            .map(|s| s.to_string()),
149        ..RepoContext::default()
150    };
151
152    if target_dir.join("Cargo.toml").exists() {
153        ctx.primary_languages.push("rust".to_string());
154        ctx.detected_surfaces.push("cargo".to_string());
155        if let Ok(raw) = fs::read_to_string(target_dir.join("Cargo.toml"))
156            && let Ok(v) = toml::from_str::<toml::Value>(&raw)
157            && let Some(name) = v
158                .get("package")
159                .and_then(|p| p.get("name"))
160                .and_then(|n| n.as_str())
161        {
162            ctx.product_name = Some(name.to_string());
163        }
164    }
165    if target_dir.join("package.json").exists() {
166        ctx.primary_languages
167            .push("typescript/javascript".to_string());
168        ctx.detected_surfaces.push("npm".to_string());
169    }
170    if target_dir.join("pyproject.toml").exists() || target_dir.join("requirements.txt").exists() {
171        ctx.primary_languages.push("python".to_string());
172        ctx.detected_surfaces.push("python".to_string());
173    }
174    if target_dir.join("go.mod").exists() {
175        ctx.primary_languages.push("go".to_string());
176        ctx.detected_surfaces.push("go".to_string());
177    }
178
179    if target_dir.join("frontend").exists() || target_dir.join("web").exists() {
180        ctx.detected_surfaces.push("frontend".to_string());
181    }
182    if target_dir.join("api").exists()
183        || target_dir.join("server").exists()
184        || target_dir.join("backend").exists()
185    {
186        ctx.detected_surfaces.push("backend".to_string());
187    }
188
189    if ctx.detected_surfaces.iter().any(|s| s == "frontend") {
190        ctx.product_type = Some("application".to_string());
191    } else if !ctx.detected_surfaces.is_empty() || !ctx.primary_languages.is_empty() {
192        ctx.product_type = Some("service_or_library".to_string());
193    }
194
195    let intent_path = target_dir.join(core::project_specs::LOCAL_PROJECT_SPECS_INTENT);
196    if intent_path.exists()
197        && let Ok(intent) = fs::read_to_string(intent_path)
198    {
199        for line in intent.lines() {
200            let trimmed = line.trim();
201            if !trimmed.is_empty() && !trimmed.starts_with('#') && !trimmed.starts_with('-') {
202                ctx.product_summary = Some(trimmed.to_string());
203                break;
204            }
205        }
206    }
207    let architecture_path = target_dir.join(core::project_specs::LOCAL_PROJECT_SPECS_ARCHITECTURE);
208    if architecture_path.exists()
209        && let Ok(arch) = fs::read_to_string(architecture_path)
210    {
211        for line in arch.lines() {
212            let trimmed = line.trim();
213            if !trimmed.is_empty() && !trimmed.starts_with('#') && !trimmed.starts_with('-') {
214                ctx.architecture_direction = Some(trimmed.to_string());
215                break;
216            }
217        }
218    }
219
220    if ctx.product_summary.is_none() {
221        let readme_path = target_dir.join("README.md");
222        if readme_path.exists()
223            && let Ok(readme) = fs::read_to_string(readme_path)
224        {
225            for line in readme.lines() {
226                let trimmed = line.trim();
227                if trimmed.is_empty()
228                    || trimmed.starts_with('#')
229                    || trimmed.starts_with("<")
230                    || trimmed.starts_with("![")
231                {
232                    continue;
233                }
234                ctx.product_summary = Some(trimmed.to_string());
235                break;
236            }
237        }
238    }
239
240    if ctx.product_summary.is_none() {
241        ctx.product_summary = Some(match ctx.product_name.as_deref() {
242            Some(name) => format!("Deliver {} against explicit user intent with proof-backed completion.", name),
243            None => "Deliver the repository outcome against explicit user intent with proof-backed completion.".to_string(),
244        });
245    }
246    if ctx.architecture_direction.is_none() {
247        let has_frontend = ctx.detected_surfaces.iter().any(|s| s == "frontend");
248        let has_backend = ctx.detected_surfaces.iter().any(|s| s == "backend");
249        let inferred = match (has_frontend, has_backend) {
250            (true, true) => {
251                "Layered frontend/backend system with explicit contracts, isolated mutation boundaries, and proof-gated promotion."
252            }
253            (true, false) => {
254                "Frontend-first architecture with explicit API boundaries and deterministic validation gates."
255            }
256            (false, true) => {
257                "Service-oriented backend with clear interface boundaries, durable state ownership, and proof-gated releases."
258            }
259            (false, false) => {
260                "Composable repository architecture with explicit boundaries and proof-backed delivery invariants."
261            }
262        };
263        ctx.architecture_direction = Some(inferred.to_string());
264    }
265    if ctx.done_criteria.is_none() {
266        ctx.done_criteria = Some(
267            "Decapod validate passes, required tests pass, and promotion-relevant artifacts are present."
268                .to_string(),
269        );
270    }
271
272    ctx.primary_languages.sort();
273    ctx.primary_languages.dedup();
274    ctx.detected_surfaces.sort();
275    ctx.detected_surfaces.dedup();
276    ctx
277}
278
279fn read_seed_list_env(var: &str) -> Vec<String> {
280    std::env::var(var)
281        .ok()
282        .map(|v| {
283            v.split(',')
284                .map(|s| s.trim().to_string())
285                .filter(|s| !s.is_empty())
286                .collect::<Vec<_>>()
287        })
288        .unwrap_or_default()
289}
290
291fn dedupe_sorted(list: &mut Vec<String>) {
292    list.sort();
293    list.dedup();
294}
295
296fn apply_repo_context_env_overrides(ctx: &mut RepoContext) {
297    if let Ok(v) = std::env::var("DECAPOD_INIT_PRODUCT_NAME") {
298        let trimmed = v.trim();
299        if !trimmed.is_empty() {
300            ctx.product_name = Some(trimmed.to_string());
301        }
302    }
303    if let Ok(v) = std::env::var("DECAPOD_INIT_PRODUCT_SUMMARY") {
304        let trimmed = v.trim();
305        if !trimmed.is_empty() {
306            ctx.product_summary = Some(trimmed.to_string());
307        }
308    }
309    if let Ok(v) = std::env::var("DECAPOD_INIT_ARCHITECTURE_DIRECTION") {
310        let trimmed = v.trim();
311        if !trimmed.is_empty() {
312            ctx.architecture_direction = Some(trimmed.to_string());
313        }
314    }
315    if let Ok(v) = std::env::var("DECAPOD_INIT_PRODUCT_TYPE") {
316        let trimmed = v.trim();
317        if !trimmed.is_empty() {
318            ctx.product_type = Some(trimmed.to_string());
319        }
320    }
321    if let Ok(v) = std::env::var("DECAPOD_INIT_DONE_CRITERIA") {
322        let trimmed = v.trim();
323        if !trimmed.is_empty() {
324            ctx.done_criteria = Some(trimmed.to_string());
325        }
326    }
327    if std::env::var("DECAPOD_INIT_PRIMARY_LANGUAGES").is_ok() {
328        ctx.primary_languages = read_seed_list_env("DECAPOD_INIT_PRIMARY_LANGUAGES");
329    }
330    if std::env::var("DECAPOD_INIT_SURFACES").is_ok() {
331        ctx.detected_surfaces = read_seed_list_env("DECAPOD_INIT_SURFACES");
332    }
333    dedupe_sorted(&mut ctx.primary_languages);
334    dedupe_sorted(&mut ctx.detected_surfaces);
335}
336
337fn apply_repo_context_cli_overrides(ctx: &mut RepoContext, init_with: &InitWithCli) {
338    if let Some(v) = init_with.product_name.as_ref() {
339        let trimmed = v.trim();
340        if !trimmed.is_empty() {
341            ctx.product_name = Some(trimmed.to_string());
342        }
343    }
344    if let Some(v) = init_with.product_summary.as_ref() {
345        let trimmed = v.trim();
346        if !trimmed.is_empty() {
347            ctx.product_summary = Some(trimmed.to_string());
348        }
349    }
350    if let Some(v) = init_with.architecture_direction.as_ref() {
351        let trimmed = v.trim();
352        if !trimmed.is_empty() {
353            ctx.architecture_direction = Some(trimmed.to_string());
354        }
355    }
356    if let Some(v) = init_with.product_type.as_ref() {
357        let trimmed = v.trim();
358        if !trimmed.is_empty() {
359            ctx.product_type = Some(trimmed.to_string());
360        }
361    }
362    if let Some(v) = init_with.done_criteria.as_ref() {
363        let trimmed = v.trim();
364        if !trimmed.is_empty() {
365            ctx.done_criteria = Some(trimmed.to_string());
366        }
367    }
368    if !init_with.primary_languages.is_empty() {
369        ctx.primary_languages = init_with
370            .primary_languages
371            .iter()
372            .map(|s| s.trim().to_string())
373            .filter(|s| !s.is_empty())
374            .collect();
375    }
376    if !init_with.detected_surfaces.is_empty() {
377        ctx.detected_surfaces = init_with
378            .detected_surfaces
379            .iter()
380            .map(|s| s.trim().to_string())
381            .filter(|s| !s.is_empty())
382            .collect();
383    }
384    dedupe_sorted(&mut ctx.primary_languages);
385    dedupe_sorted(&mut ctx.detected_surfaces);
386}
387
388fn prompt_line(prompt: &str) -> Result<String, error::DecapodError> {
389    print!("{}", prompt);
390    io::stdout().flush().map_err(error::DecapodError::IoError)?;
391    let mut buf = String::new();
392    io::stdin()
393        .read_line(&mut buf)
394        .map_err(error::DecapodError::IoError)?;
395    Ok(buf.trim().to_string())
396}
397
398const LANGUAGES: &[&str] = &[
399    "Rust",
400    "TypeScript",
401    "JavaScript",
402    "Python",
403    "Go",
404    "Java",
405    "Kotlin",
406    "Swift",
407    "C",
408    "C++",
409    "C#",
410    "Zig",
411    "Ruby",
412    "PHP",
413    "Elixir",
414    "Erlang",
415    "Scala",
416    "Clojure",
417    "Dart",
418    "Haskell",
419    "OCaml",
420    "F#",
421    "Lua",
422    "R",
423    "Julia",
424    "SQL",
425    "HCL",
426    "Shell",
427    "PowerShell",
428    "Other",
429];
430
431const ARCH_DIRECTIONS: &[(&str, &str)] = &[
432    ("webapp", "Web application (TypeScript, React/Vue/Svelte)"),
433    ("microservice", "Microservice (Go, Rust, or Java)"),
434    ("library", "Library/SDK (language-agnostic)"),
435    ("cli", "Command-line tool (Rust, Go, Python)"),
436    ("lambda", "Lambda/Serverless (Python, TypeScript, Go)"),
437    ("mobile-android", "Android (Kotlin, Java)"),
438    ("mobile-ios", "iOS (Swift)"),
439    ("multiarch", "Multi-platform (Rust, C/C++)"),
440    ("infra", "Infrastructure/Terraform (HCL, Python)"),
441    ("data", "Data pipeline (Python, SQL)"),
442];
443
444fn normalize_language(input: &str) -> String {
445    match input.trim().to_lowercase().as_str() {
446        "ts" | "typescript" => "TypeScript".to_string(),
447        "js" | "javascript" => "JavaScript".to_string(),
448        "py" | "python" => "Python".to_string(),
449        "rs" | "rust" => "Rust".to_string(),
450        "golang" | "go" => "Go".to_string(),
451        "kt" | "kotlin" => "Kotlin".to_string(),
452        "swift" => "Swift".to_string(),
453        "c" => "C".to_string(),
454        "cpp" | "c++" | "cplusplus" => "C++".to_string(),
455        "csharp" | "c#" => "C#".to_string(),
456        "zig" => "Zig".to_string(),
457        "rb" | "ruby" => "Ruby".to_string(),
458        "php" => "PHP".to_string(),
459        "ex" | "elixir" => "Elixir".to_string(),
460        "erl" | "erlang" => "Erlang".to_string(),
461        "scala" => "Scala".to_string(),
462        "clj" | "clojure" => "Clojure".to_string(),
463        "dart" => "Dart".to_string(),
464        "hs" | "haskell" => "Haskell".to_string(),
465        "ml" | "ocaml" => "OCaml".to_string(),
466        "fs" | "fsharp" | "f#" => "F#".to_string(),
467        "lua" => "Lua".to_string(),
468        "r" => "R".to_string(),
469        "jl" | "julia" => "Julia".to_string(),
470        "sql" => "SQL".to_string(),
471        "terraform" | "tf" | "hcl" => "HCL".to_string(),
472        "bash" | "sh" | "shell" => "Shell".to_string(),
473        "pwsh" | "powershell" => "PowerShell".to_string(),
474        "other" => "Other".to_string(),
475        _ => input.trim().to_string(),
476    }
477}
478
479fn language_choice_seed(current: &[String], recommendation: &[String]) -> Vec<String> {
480    if !recommendation.is_empty() {
481        return recommendation
482            .iter()
483            .map(|s| normalize_language(s))
484            .collect();
485    }
486    current.iter().map(|s| normalize_language(s)).collect()
487}
488
489fn apply_architecture_language_recommendation(ctx: &mut RepoContext) {
490    if !ctx.primary_languages.is_empty() {
491        return;
492    }
493    if let Some(arch) = ctx.architecture_direction.as_deref() {
494        ctx.primary_languages = infer_language_from_architecture(arch);
495    }
496}
497
498struct TerminalModeGuard {
499    saved_mode: String,
500}
501
502impl Drop for TerminalModeGuard {
503    fn drop(&mut self) {
504        let _ = std::process::Command::new("stty")
505            .arg(&self.saved_mode)
506            .status();
507        println!();
508    }
509}
510
511fn enter_raw_terminal_mode() -> Option<TerminalModeGuard> {
512    let output = std::process::Command::new("stty").arg("-g").output().ok()?;
513    if !output.status.success() {
514        return None;
515    }
516    let saved_mode = String::from_utf8(output.stdout).ok()?.trim().to_string();
517    let status = std::process::Command::new("stty")
518        .args(["raw", "-echo"])
519        .status()
520        .ok()?;
521    if !status.success() {
522        return None;
523    }
524    Some(TerminalModeGuard { saved_mode })
525}
526
527fn prompt_language_terminal_choice(
528    default: &[String],
529) -> Result<Option<String>, error::DecapodError> {
530    use crate::core::ansi::AnsiExt;
531
532    if !io::stdin().is_terminal() || default.is_empty() {
533        return Ok(None);
534    }
535    let mut selected = default
536        .first()
537        .and_then(|d| {
538            LANGUAGES
539                .iter()
540                .position(|lang| d.eq_ignore_ascii_case(lang))
541        })
542        .unwrap_or(0);
543    let Some(_guard) = enter_raw_terminal_mode() else {
544        return Ok(None);
545    };
546    let mut typed = String::new();
547    let mut stdin = io::stdin();
548    loop {
549        let shown = if typed.is_empty() {
550            LANGUAGES[selected].to_string()
551        } else {
552            typed.clone()
553        };
554        print!("\r{}", format!("    choice: {shown}").bright_cyan().bold());
555        print!("\x1b[K");
556        io::stdout().flush().map_err(error::DecapodError::IoError)?;
557
558        let mut byte = [0_u8; 1];
559        stdin
560            .read_exact(&mut byte)
561            .map_err(error::DecapodError::IoError)?;
562        match byte[0] {
563            b'\r' | b'\n' => return Ok(Some(shown)),
564            3 => {
565                return Err(error::DecapodError::ValidationError(
566                    "init prompt interrupted".to_string(),
567                ));
568            }
569            8 | 127 => {
570                typed.pop();
571            }
572            27 => {
573                let mut seq = [0_u8; 2];
574                if stdin.read_exact(&mut seq).is_ok() && seq[0] == b'[' {
575                    match seq[1] {
576                        b'A' => {
577                            typed.clear();
578                            selected = selected
579                                .checked_sub(1)
580                                .unwrap_or_else(|| LANGUAGES.len() - 1);
581                        }
582                        b'B' => {
583                            typed.clear();
584                            selected = (selected + 1) % LANGUAGES.len();
585                        }
586                        _ => {}
587                    }
588                }
589            }
590            byte if byte.is_ascii_graphic() || byte == b' ' => {
591                typed.push(byte as char);
592            }
593            _ => {}
594        }
595    }
596}
597
598fn prompt_language_choice(
599    current: &[String],
600    recommendation: &[String],
601) -> Result<Vec<String>, error::DecapodError> {
602    use crate::core::ansi::AnsiExt;
603    let inferred = if current.is_empty() {
604        "None".to_string()
605    } else {
606        current.join(", ")
607    };
608    let default = language_choice_seed(current, recommendation);
609    let default_label = if default.is_empty() {
610        "None".to_string()
611    } else {
612        default.join(", ")
613    };
614
615    println!();
616    println!("{}", "  Primary language(s)".bright_white().bold());
617    println!("    inferred: {} (from project files)", inferred);
618    println!("    ideal for architecture: {}", default_label);
619    println!("    options: up/down, type name or number, comma-separated for multiple");
620    println!();
621
622    for (i, lang) in LANGUAGES.iter().enumerate() {
623        let marker = if default.iter().any(|c| c.eq_ignore_ascii_case(lang)) {
624            "✓"
625        } else {
626            " "
627        };
628        println!("    {} {:>2}. {}", marker, i + 1, lang);
629    }
630    println!();
631    println!("    press Enter to use the ideal/default language(s)");
632
633    let choice = match prompt_language_terminal_choice(&default)? {
634        Some(choice) => choice,
635        None => prompt_line("    choice: ")?,
636    };
637
638    if choice.is_empty() {
639        return Ok(default);
640    }
641
642    let selected: Vec<String> = choice
643        .split(',')
644        .map(|s| {
645            let trimmed = s.trim();
646            trimmed
647                .parse::<usize>()
648                .ok()
649                .and_then(|n| LANGUAGES.get(n.saturating_sub(1)))
650                .map(|lang| (*lang).to_string())
651                .unwrap_or_else(|| normalize_language(trimmed))
652        })
653        .filter(|s| !s.is_empty())
654        .collect();
655
656    Ok(selected)
657}
658
659fn infer_language_from_architecture(arch: &str) -> Vec<String> {
660    let arch = arch.to_lowercase();
661    match arch.as_str() {
662        "webapp" => vec!["TypeScript".to_string()],
663        "microservice" => vec!["Go".to_string()],
664        "library" => vec!["Rust".to_string()],
665        "cli" => vec!["Rust".to_string()],
666        "lambda" => vec!["Python".to_string()],
667        "mobile-android" => vec!["Kotlin".to_string()],
668        "mobile-ios" => vec!["Swift".to_string()],
669        "multiarch" => vec!["Rust".to_string()],
670        "infra" => vec!["HCL".to_string()],
671        "data" => vec!["Python".to_string()],
672        _ if arch.contains("web") || arch.contains("frontend") => vec!["TypeScript".to_string()],
673        _ if arch.contains("microservice") || arch.contains("backend") || arch.contains("api") => {
674            vec!["Go".to_string()]
675        }
676        _ if arch.contains("cli") || arch.contains("command-line") => vec!["Rust".to_string()],
677        _ if arch.contains("serverless") || arch.contains("lambda") => vec!["Python".to_string()],
678        _ if arch.contains("android") => vec!["Kotlin".to_string()],
679        _ if arch.contains("ios") => vec!["Swift".to_string()],
680        _ if arch.contains("embedded") || arch.contains("systems") => vec!["Zig".to_string()],
681        _ if arch.contains("infra") || arch.contains("terraform") => vec!["HCL".to_string()],
682        _ if arch.contains("data") || arch.contains("ml") => vec!["Python".to_string()],
683        _ => vec![],
684    }
685}
686
687fn prompt_architecture_choice(
688    current: Option<&str>,
689) -> Result<Option<String>, error::DecapodError> {
690    use crate::core::ansi::AnsiExt;
691    let inferred = current.unwrap_or("None");
692
693    println!();
694    println!("{}", "  Architecture".bright_white().bold());
695    println!("    inferred: {}", inferred);
696    println!("    common approaches:");
697    println!();
698
699    for (i, (arch, desc)) in ARCH_DIRECTIONS.iter().enumerate() {
700        let marker = if current.is_some_and(|c| c.eq_ignore_ascii_case(arch)) {
701            "✓"
702        } else {
703            " "
704        };
705        println!(
706            "    {}{} {} -> {}",
707            marker,
708            if i == 0 { " >" } else { "  " },
709            arch,
710            desc
711        );
712    }
713    println!();
714    println!("    or type your architecture");
715
716    let choice = prompt_line("    choice: ")?;
717
718    if choice.is_empty() {
719        return Ok(current.map(|s| s.to_string()));
720    }
721
722    if let Ok(index) = choice.parse::<usize>()
723        && let Some((arch, _)) = ARCH_DIRECTIONS.get(index.saturating_sub(1))
724    {
725        return Ok(Some((*arch).to_string()));
726    }
727
728    Ok(Some(choice.trim().to_string()))
729}
730
731fn print_init_block(title: &str, subtitle: &str) {
732    use crate::core::ansi::AnsiExt;
733    println!();
734    println!("{}", format!("◢ {}", title).bright_cyan().bold());
735    println!("{}", format!("  {}", subtitle).bright_black());
736}
737
738fn prompt_text_field(
739    label: &str,
740    helper: &str,
741    default_value: &str,
742) -> Result<String, error::DecapodError> {
743    use crate::core::ansi::AnsiExt;
744    println!();
745    println!("{}", format!("  {}", label).bright_white().bold());
746    println!("{}", format!("    {}", helper).bright_black());
747    println!(
748        "{}",
749        format!("    inferred: {}", default_value).bright_black()
750    );
751    let line = prompt_line(&format!("{}", "    input: ".bright_cyan().bold()))?;
752    if line.trim().is_empty() {
753        Ok(default_value.to_string())
754    } else {
755        Ok(line)
756    }
757}
758
759fn prompt_line_default(prompt: &str, default_value: &str) -> Result<String, error::DecapodError> {
760    prompt_text_field(
761        prompt,
762        "Press Enter to keep inferred context.",
763        default_value,
764    )
765}
766
767fn prompt_yes_no(prompt: &str, default_yes: bool) -> Result<bool, error::DecapodError> {
768    use crate::core::ansi::AnsiExt;
769    let suffix = if default_yes { "[Y/n]" } else { "[y/N]" };
770    println!();
771    println!("{}", format!("  {}", prompt).bright_white().bold());
772    let line = prompt_line(&format!(
773        "{} {} ",
774        "    choice:".bright_cyan().bold(),
775        suffix.bright_black()
776    ))?;
777    if line.is_empty() {
778        return Ok(default_yes);
779    }
780    let normalized = line.to_ascii_lowercase();
781    Ok(matches!(normalized.as_str(), "y" | "yes"))
782}
783
784fn resolve_existing_init_dir(raw: &Path) -> Result<PathBuf, error::DecapodError> {
785    std::fs::canonicalize(raw).map_err(error::DecapodError::IoError)
786}
787
788fn resolve_or_create_project_dir(
789    current_dir: &Path,
790    raw: &Path,
791    dry_run: bool,
792) -> Result<PathBuf, error::DecapodError> {
793    let candidate = if raw.is_absolute() {
794        raw.to_path_buf()
795    } else {
796        current_dir.join(raw)
797    };
798    if candidate.exists() && !candidate.is_dir() {
799        return Err(error::DecapodError::ValidationError(format!(
800            "project directory target '{}' exists but is not a directory",
801            candidate.display()
802        )));
803    }
804    if !dry_run {
805        std::fs::create_dir_all(&candidate).map_err(error::DecapodError::IoError)?;
806    }
807    if candidate.exists() {
808        std::fs::canonicalize(&candidate).map_err(error::DecapodError::IoError)
809    } else {
810        Ok(candidate)
811    }
812}
813
814fn prompt_init_target_dir(current_dir: &Path) -> Result<PathBuf, error::DecapodError> {
815    if prompt_yes_no("Initialize the existing current directory?", true)? {
816        return resolve_existing_init_dir(current_dir);
817    }
818    let project_name = prompt_text_field(
819        "Project directory name",
820        "Decapod will create this directory and initialize inside it.",
821        "my-project",
822    )?;
823    let project_name = project_name.trim();
824    if project_name.is_empty() {
825        return Err(error::DecapodError::ValidationError(
826            "Project directory name cannot be empty".to_string(),
827        ));
828    }
829    resolve_or_create_project_dir(current_dir, Path::new(project_name), false)
830}
831
832fn prompt_diagram_style(
833    default_style: InitDiagramStyle,
834) -> Result<InitDiagramStyle, error::DecapodError> {
835    let default_label = match default_style {
836        InitDiagramStyle::Ascii => "ascii",
837        InitDiagramStyle::Mermaid => "mermaid",
838    };
839    let line = prompt_line(&format!(
840        "Architecture diagram style [ascii/mermaid] (default: {}): ",
841        default_label
842    ))?;
843    if line.is_empty() {
844        return Ok(default_style);
845    }
846    match line.to_ascii_lowercase().as_str() {
847        "ascii" => Ok(InitDiagramStyle::Ascii),
848        "mermaid" => Ok(InitDiagramStyle::Mermaid),
849        _ => Err(error::DecapodError::ValidationError(
850            "Invalid diagram style; expected ascii or mermaid".to_string(),
851        )),
852    }
853}
854
855fn init_with_from_config(
856    config: &DecapodProjectConfig,
857    target_dir: PathBuf,
858    force: bool,
859    dry_run: bool,
860) -> InitWithCli {
861    let has = |name: &str| config.init.entrypoints.iter().any(|e| e == name);
862    let all_entrypoints =
863        has("AGENTS.md") && has("CLAUDE.md") && has("GEMINI.md") && has("CODEX.md");
864    InitWithCli {
865        dir: Some(target_dir),
866        project_dir: None,
867        force,
868        dry_run,
869        all: all_entrypoints,
870        claude: has("CLAUDE.md"),
871        gemini: has("GEMINI.md"),
872        agents: has("AGENTS.md"),
873        specs: config.init.specs,
874        diagram_style: config.init.diagram_style,
875        product_name: None,
876        product_summary: None,
877        architecture_direction: None,
878        product_type: None,
879        done_criteria: None,
880        primary_languages: Vec::new(),
881        detected_surfaces: Vec::new(),
882    }
883}
884
885fn config_from_init_with(init: &InitWithCli, repo: RepoContext) -> DecapodProjectConfig {
886    let mut entrypoints = Vec::new();
887    let no_entrypoint_flags = !init.claude && !init.gemini && !init.agents;
888    if init.all || init.agents || no_entrypoint_flags {
889        entrypoints.push("AGENTS.md".to_string());
890    }
891    if init.all || init.claude || (!init.gemini && !init.agents) {
892        entrypoints.push("CLAUDE.md".to_string());
893    }
894    if init.all || init.gemini || (!init.claude && !init.agents) {
895        entrypoints.push("GEMINI.md".to_string());
896    }
897    if init.all || no_entrypoint_flags {
898        entrypoints.push("CODEX.md".to_string());
899    }
900    DecapodProjectConfig {
901        schema_version: "1.0.0".to_string(),
902        init: InitConfigSection {
903            specs: init.specs,
904            diagram_style: init.diagram_style,
905            entrypoints,
906        },
907        repo,
908    }
909}
910
911fn interactive_init_with(
912    config: &DecapodProjectConfig,
913    target_dir: PathBuf,
914    force: bool,
915    dry_run: bool,
916) -> Result<InitWithCli, error::DecapodError> {
917    print_init_block(
918        "Decapod Setup",
919        "Existing .decapod/config.toml detected. Confirm your setup profile.",
920    );
921    let mut next = init_with_from_config(config, target_dir, force, dry_run);
922    if config.init.entrypoints.is_empty() {
923        let all_entrypoints = prompt_yes_no(
924            "Include all default agent entrypoints (AGENTS/CLAUDE/GEMINI/CODEX)?",
925            true,
926        )?;
927        if all_entrypoints {
928            next.all = true;
929            next.agents = true;
930            next.claude = true;
931            next.gemini = true;
932        }
933    }
934    if config.init.specs {
935        next.specs = true;
936    }
937    if next.diagram_style != InitDiagramStyle::Ascii
938        && next.diagram_style != InitDiagramStyle::Mermaid
939    {
940        next.diagram_style = prompt_diagram_style(InitDiagramStyle::Ascii)?;
941    }
942    Ok(next)
943}
944
945fn enrich_repo_context_interactive(repo: &mut RepoContext) -> Result<(), error::DecapodError> {
946    print_init_block(
947        "Repository Context",
948        "Review inferred intent before generating .decapod/generated/specs/.",
949    );
950
951    let current_summary = repo.product_summary.clone().unwrap_or_else(|| {
952        "Deliver the repository outcome against explicit user intent with proof-backed completion."
953            .to_string()
954    });
955    repo.product_summary = Some(prompt_line_default("Intent outcome", &current_summary)?);
956
957    repo.architecture_direction =
958        prompt_architecture_choice(repo.architecture_direction.as_deref())?;
959
960    let recommended_languages = repo
961        .architecture_direction
962        .as_deref()
963        .map(infer_language_from_architecture)
964        .unwrap_or_default();
965    repo.primary_languages =
966        prompt_language_choice(&repo.primary_languages, &recommended_languages)?;
967
968    let refine_now = prompt_yes_no(
969        "Refine done criteria now? (You can evolve .decapod/config.toml and .decapod/generated/specs/*.md later.)",
970        false,
971    )?;
972    if refine_now {
973        let current_done = repo.done_criteria.clone().unwrap_or_else(|| {
974            "Decapod validate passes, required tests pass, and promotion-relevant artifacts are present."
975                .to_string()
976        });
977        repo.done_criteria = Some(prompt_line_default("Done criteria", &current_done)?);
978    }
979    Ok(())
980}
981
982fn run_init_apply(
983    init_with: &InitWithCli,
984    current_dir: &Path,
985    repo_ctx: &RepoContext,
986) -> Result<PathBuf, error::DecapodError> {
987    let target_dir = match &init_with.dir {
988        Some(d) => d.clone(),
989        None => current_dir.to_path_buf(),
990    };
991    let target_dir = if target_dir.exists() {
992        std::fs::canonicalize(&target_dir).map_err(error::DecapodError::IoError)?
993    } else {
994        target_dir
995    };
996
997    let setup_decapod_root = target_dir.join(".decapod");
998    if setup_decapod_root.exists() && !init_with.force {
999        use crate::core::ansi::AnsiExt;
1000        println!(
1001            "{} {}",
1002            "init:".bright_yellow(),
1003            "already initialized (.decapod exists); rerun with --force, or use `decapod init with --force`"
1004                .bright_red()
1005        );
1006        return Ok(target_dir);
1007    }
1008
1009    use sha2::{Digest, Sha256};
1010    let mut existing_agent_files = vec![];
1011    for file in ["AGENTS.md", "CLAUDE.md", "GEMINI.md", "CODEX.md"] {
1012        if target_dir.join(file).exists() {
1013            existing_agent_files.push(file);
1014        }
1015    }
1016
1017    let mut created_backups = false;
1018    let mut backup_count = 0usize;
1019    if !init_with.dry_run {
1020        for file in &existing_agent_files {
1021            let path = target_dir.join(file);
1022            let template_content = core::assets::get_template(file).unwrap_or_default();
1023            let mut hasher = Sha256::new();
1024            hasher.update(template_content.as_bytes());
1025            let template_hash = format!("{:x}", hasher.finalize());
1026            let existing_content = fs::read_to_string(&path).unwrap_or_default();
1027            let mut hasher = Sha256::new();
1028            hasher.update(existing_content.as_bytes());
1029            let existing_hash = format!("{:x}", hasher.finalize());
1030            if template_hash != existing_hash {
1031                created_backups = true;
1032                backup_count += 1;
1033                let backup_path = target_dir.join(format!("{}.bak", file));
1034                fs::rename(&path, &backup_path).map_err(error::DecapodError::IoError)?;
1035            }
1036        }
1037    }
1038
1039    if !init_with.dry_run {
1040        scaffold::blend_legacy_entrypoints(&target_dir)?;
1041    }
1042
1043    let mut agent_files_to_generate = if init_with.claude || init_with.gemini || init_with.agents {
1044        let mut files = vec![];
1045        if init_with.claude {
1046            files.push("CLAUDE.md".to_string());
1047        }
1048        if init_with.gemini {
1049            files.push("GEMINI.md".to_string());
1050        }
1051        if init_with.agents {
1052            files.push("AGENTS.md".to_string());
1053        }
1054        files
1055    } else {
1056        existing_agent_files
1057            .into_iter()
1058            .map(|s| s.to_string())
1059            .collect()
1060    };
1061
1062    if !agent_files_to_generate.is_empty()
1063        && !agent_files_to_generate.iter().any(|f| f == "AGENTS.md")
1064    {
1065        agent_files_to_generate.push("AGENTS.md".to_string());
1066    }
1067
1068    let scaffold_summary = scaffold::scaffold_project_entrypoints(&scaffold::ScaffoldOptions {
1069        target_dir: target_dir.clone(),
1070        force: init_with.force,
1071        dry_run: init_with.dry_run,
1072        agent_files: agent_files_to_generate,
1073        created_backups,
1074        all: init_with.all,
1075        generate_specs: init_with.specs,
1076        diagram_style: match init_with.diagram_style {
1077            InitDiagramStyle::Ascii => scaffold::DiagramStyle::Ascii,
1078            InitDiagramStyle::Mermaid => scaffold::DiagramStyle::Mermaid,
1079        },
1080        specs_seed: Some(scaffold::SpecsSeed {
1081            product_name: repo_ctx.product_name.clone(),
1082            product_summary: repo_ctx.product_summary.clone(),
1083            architecture_direction: repo_ctx.architecture_direction.clone(),
1084            product_type: repo_ctx.product_type.clone(),
1085            primary_languages: repo_ctx.primary_languages.clone(),
1086            detected_surfaces: repo_ctx.detected_surfaces.clone(),
1087            done_criteria: repo_ctx.done_criteria.clone(),
1088        }),
1089    })?;
1090
1091    let target_display = setup_decapod_root
1092        .parent()
1093        .unwrap_or(current_dir)
1094        .display()
1095        .to_string();
1096    use crate::core::ansi::AnsiExt;
1097    print_init_block(
1098        "Decapod Init Summary",
1099        "Scaffold completed with the following changes.",
1100    );
1101    println!("  Target: {}", target_display.bright_white());
1102    println!(
1103        "  Mode: {}",
1104        if init_with.dry_run {
1105            "Dry Run".bright_yellow()
1106        } else {
1107            "Apply".bright_green()
1108        }
1109    );
1110    println!(
1111        "  Entrypoints: created={}, unchanged={}, preserved={}",
1112        scaffold_summary
1113            .entrypoints_created
1114            .to_string()
1115            .bright_green(),
1116        scaffold_summary
1117            .entrypoints_unchanged
1118            .to_string()
1119            .bright_yellow(),
1120        scaffold_summary
1121            .entrypoints_preserved
1122            .to_string()
1123            .bright_white()
1124    );
1125    println!(
1126        "  Config: created={}, unchanged={}, preserved={}",
1127        scaffold_summary.config_created.to_string().bright_green(),
1128        scaffold_summary
1129            .config_unchanged
1130            .to_string()
1131            .bright_yellow(),
1132        scaffold_summary.config_preserved.to_string().bright_white()
1133    );
1134    println!(
1135        "  Specs: created={}, unchanged={}, preserved={}",
1136        scaffold_summary.specs_created.to_string().bright_green(),
1137        scaffold_summary.specs_unchanged.to_string().bright_yellow(),
1138        scaffold_summary.specs_preserved.to_string().bright_white()
1139    );
1140    println!("  Backups: {}", backup_count.to_string().bright_magenta());
1141    println!(
1142        "  Diagram Style: {}",
1143        match init_with.diagram_style {
1144            InitDiagramStyle::Ascii => "ascii".bright_white(),
1145            InitDiagramStyle::Mermaid => "mermaid".bright_white(),
1146        }
1147    );
1148    println!(
1149        "{} {}",
1150        "✓".bright_green().bold(),
1151        "Ready".bright_green().bold()
1152    );
1153
1154    Ok(target_dir)
1155}
1156
1157pub fn run() -> Result<(), error::DecapodError> {
1158    let cli = Cli::parse();
1159    let argv: Vec<String> = std::env::args().skip(1).collect();
1160    let current_dir = std::env::current_dir()?;
1161    let decapod_root_option = find_decapod_project_root(&current_dir);
1162    let store_root: PathBuf;
1163
1164    match cli.command {
1165        Command::Version => {
1166            // Version command - simple output for scripts/parsing
1167            println!("v{}", migration::DECAPOD_VERSION);
1168            return Ok(());
1169        }
1170        Command::Init(init_group) => {
1171            let base_init_invocation = init_group.command.is_none();
1172            let init_with = match init_group.command {
1173                Some(InitCommand::Clean { dir }) => {
1174                    clean_project(dir)?;
1175                    return Ok(());
1176                }
1177                Some(InitCommand::With(with)) => with,
1178                None => {
1179                    if init_group.dir.is_some() && init_group.project_dir.is_some() {
1180                        return Err(error::DecapodError::ValidationError(
1181                            "Use either --dir for an existing directory or --project-dir to create/select a project directory, not both.".to_string(),
1182                        ));
1183                    }
1184                    let target = if let Some(project_dir) = init_group.project_dir.as_ref() {
1185                        resolve_or_create_project_dir(
1186                            &current_dir,
1187                            project_dir,
1188                            init_group.dry_run,
1189                        )?
1190                    } else if let Some(dir) = init_group.dir.as_ref() {
1191                        resolve_existing_init_dir(dir)?
1192                    } else if io::stdin().is_terminal() {
1193                        prompt_init_target_dir(&current_dir)?
1194                    } else {
1195                        resolve_existing_init_dir(&current_dir)?
1196                    };
1197                    let maybe_cfg = load_project_config_if_present(&target)?;
1198                    if let Some(cfg) = maybe_cfg {
1199                        let mut with = if io::stdin().is_terminal() {
1200                            interactive_init_with(
1201                                &cfg,
1202                                target.clone(),
1203                                init_group.force,
1204                                init_group.dry_run,
1205                            )?
1206                        } else {
1207                            init_with_from_config(
1208                                &cfg,
1209                                target.clone(),
1210                                init_group.force,
1211                                init_group.dry_run,
1212                            )
1213                        };
1214                        // Keep base command flags as explicit runtime overrides.
1215                        if init_group.all {
1216                            with.all = true;
1217                            with.agents = true;
1218                            with.claude = true;
1219                            with.gemini = true;
1220                        }
1221                        if init_group.agents {
1222                            with.agents = true;
1223                        }
1224                        if init_group.claude {
1225                            with.claude = true;
1226                        }
1227                        if init_group.gemini {
1228                            with.gemini = true;
1229                        }
1230                        if init_group.product_name.is_some() {
1231                            with.product_name = init_group.product_name.clone();
1232                        }
1233                        if init_group.product_summary.is_some() {
1234                            with.product_summary = init_group.product_summary.clone();
1235                        }
1236                        if init_group.architecture_direction.is_some() {
1237                            with.architecture_direction = init_group.architecture_direction.clone();
1238                        }
1239                        if init_group.product_type.is_some() {
1240                            with.product_type = init_group.product_type.clone();
1241                        }
1242                        if init_group.done_criteria.is_some() {
1243                            with.done_criteria = init_group.done_criteria.clone();
1244                        }
1245                        if !init_group.primary_languages.is_empty() {
1246                            with.primary_languages = init_group.primary_languages.clone();
1247                        }
1248                        if !init_group.detected_surfaces.is_empty() {
1249                            with.detected_surfaces = init_group.detected_surfaces.clone();
1250                        }
1251                        with
1252                    } else {
1253                        InitWithCli {
1254                            dir: Some(target),
1255                            project_dir: None,
1256                            force: init_group.force,
1257                            dry_run: init_group.dry_run,
1258                            all: init_group.all,
1259                            claude: init_group.claude,
1260                            gemini: init_group.gemini,
1261                            agents: init_group.agents,
1262                            specs: true,
1263                            diagram_style: InitDiagramStyle::Ascii,
1264                            product_name: init_group.product_name.clone(),
1265                            product_summary: init_group.product_summary.clone(),
1266                            architecture_direction: init_group.architecture_direction.clone(),
1267                            product_type: init_group.product_type.clone(),
1268                            done_criteria: init_group.done_criteria.clone(),
1269                            primary_languages: init_group.primary_languages.clone(),
1270                            detected_surfaces: init_group.detected_surfaces.clone(),
1271                        }
1272                    }
1273                }
1274            };
1275
1276            if init_with.dir.is_some() && init_with.project_dir.is_some() {
1277                return Err(error::DecapodError::ValidationError(
1278                    "Use either --dir for an existing directory or --project-dir to create/select a project directory, not both.".to_string(),
1279                ));
1280            }
1281            let init_target = if let Some(project_dir) = init_with.project_dir.as_ref() {
1282                resolve_or_create_project_dir(&current_dir, project_dir, init_with.dry_run)?
1283            } else if let Some(dir) = init_with.dir.as_ref() {
1284                resolve_existing_init_dir(dir)?
1285            } else {
1286                resolve_existing_init_dir(&current_dir)?
1287            };
1288            let mut init_with = init_with;
1289            init_with.dir = Some(init_target.clone());
1290            init_with.project_dir = None;
1291            let mut repo_ctx = infer_repo_context(&init_target);
1292            apply_repo_context_env_overrides(&mut repo_ctx);
1293            apply_repo_context_cli_overrides(&mut repo_ctx, &init_with);
1294            apply_architecture_language_recommendation(&mut repo_ctx);
1295            if base_init_invocation && io::stdin().is_terminal() {
1296                enrich_repo_context_interactive(&mut repo_ctx)?;
1297            }
1298            let target_dir = run_init_apply(&init_with, &current_dir, &repo_ctx)?;
1299            let config = config_from_init_with(&init_with, repo_ctx);
1300            write_project_config(&target_dir, &config, init_with.dry_run)?;
1301            seed_init_generated_state(&target_dir, init_with.dry_run)?;
1302        }
1303        Command::Session(session_cli) => {
1304            run_session_command(session_cli)?;
1305        }
1306        Command::Release(release_cli) => {
1307            let project_root = decapod_root_option?;
1308            run_release_command(release_cli, &project_root)?;
1309        }
1310        Command::Setup(setup_cli) => match setup_cli.command {
1311            SetupCommand::Hook {
1312                commit_msg,
1313                pre_commit,
1314                uninstall,
1315            } => {
1316                run_hook_install(commit_msg, pre_commit, uninstall)?;
1317            }
1318        },
1319        _ => {
1320            let project_root = decapod_root_option?;
1321            let is_validate_cmd = matches!(&cli.command, Command::Validate(_));
1322            if requires_session_token(&cli.command) {
1323                ensure_session_valid()?;
1324            }
1325            enforce_worktree_requirement(&cli.command, &project_root)?;
1326
1327            // For other commands, ensure .decapod exists
1328            let decapod_root_path = project_root.join(".decapod");
1329            store_root = decapod_root_path.join("data");
1330            std::fs::create_dir_all(&store_root).map_err(error::DecapodError::IoError)?;
1331            if should_route_via_group_broker(&cli.command, &argv) {
1332                match core::group_broker::maybe_route_mutation(&store_root, &argv) {
1333                    Err(e) => {
1334                        if !core::group_broker::is_internal_invocation() {
1335                            return Err(e);
1336                        }
1337                    }
1338                    Ok(routed) if routed && !core::group_broker::is_internal_invocation() => {
1339                        // Routed mutation completed via broker path.
1340                        return Ok(());
1341                    }
1342                    Ok(routed) => {
1343                        if !routed
1344                            && !core::group_broker::is_internal_invocation()
1345                            && enforce_route_strict_mode()
1346                        {
1347                            return Err(error::DecapodError::ValidationError(
1348                                "BROKER_ROUTE_REQUIRED: routed mutator cannot bypass broker in strict mode"
1349                                    .to_string(),
1350                            ));
1351                        }
1352                    }
1353                }
1354            }
1355
1356            // Check for version/schema changes and run protected migrations if needed.
1357            // Backups are auto-created in .decapod/data only when schema upgrades are pending.
1358            let migration_result =
1359                migration::check_and_migrate_with_backup(&decapod_root_path, |data_root| {
1360                    subsystems::initialize_all_dbs(data_root)
1361                });
1362            match migration_result {
1363                Ok(()) => {}
1364                Err(e) if is_validate_cmd => {
1365                    let normalized = normalize_validate_error(e);
1366                    return Err(attach_validate_diagnostic_if_enabled(
1367                        normalized,
1368                        &project_root,
1369                        0,
1370                        validate_timeout_secs(),
1371                    ));
1372                }
1373                Err(e) => return Err(e),
1374            }
1375
1376            // Best-effort hygiene: routinely scrub stale git worktree metadata/config.
1377            // This must not block primary command execution.
1378            if let Err(e) = workspace::prune_stale_worktree_config(&project_root)
1379                && !is_not_git_repository_error(&e)
1380            {
1381                eprintln!("warn: worktree maintenance skipped: {e}");
1382            }
1383
1384            let project_store = Store {
1385                kind: StoreKind::Repo,
1386                root: store_root.clone(),
1387            };
1388
1389            if should_auto_clock_in(&cli.command)
1390                && let Err(e) =
1391                    retry_transient_sqlite(|| todo::clock_in_agent_presence(&project_store), 4)
1392            {
1393                if is_transient_sqlite_contention_error(&e) {
1394                    eprintln!(
1395                        "warn: presence clock-in skipped due transient sqlite contention: {e}"
1396                    );
1397                } else {
1398                    return Err(e);
1399                }
1400            }
1401
1402            match cli.command {
1403                Command::Activate => {
1404                    println!("decapod.activate: ok");
1405                }
1406                Command::Validate(validate_cli) => {
1407                    run_validate_command(validate_cli, &project_root, &project_store)?;
1408                }
1409                Command::Version => show_version_info()?,
1410                Command::Docs(docs_cli) => {
1411                    let result = docs_cli::run_docs_cli(docs_cli)?;
1412                    if result.ingested_core_constitution {
1413                        mark_core_constitution_ingested(&project_root)?;
1414                    }
1415                }
1416                Command::Todo(todo_cli) => todo::run_todo_cli(&project_store, todo_cli)?,
1417                Command::Obligation(obligation_cli) => {
1418                    obligation::run_obligation_cli(&project_store, obligation_cli)?
1419                }
1420                Command::Govern(govern_cli) => {
1421                    run_govern_command(govern_cli, &project_store, &store_root)?;
1422                }
1423                Command::Data(data_cli) => {
1424                    run_data_command(data_cli, &project_store, &project_root, &store_root)?;
1425                }
1426                Command::Auto(auto_cli) => run_auto_command(auto_cli, &project_store)?,
1427                Command::Qa(qa_cli) => run_qa_command(qa_cli, &project_store, &project_root)?,
1428                Command::Decide(decide_cli) => decide::run_decide_cli(&project_store, decide_cli)?,
1429                Command::Workspace(workspace_cli) => {
1430                    run_workspace_command(workspace_cli, &project_root)?;
1431                }
1432                Command::Rpc(rpc_cli) => {
1433                    run_rpc_command(rpc_cli, &project_root)?;
1434                }
1435                Command::Handshake(handshake_cli) => {
1436                    run_handshake_command(handshake_cli, &project_root)?;
1437                }
1438                Command::Release(release_cli) => {
1439                    run_release_command(release_cli, &project_root)?;
1440                }
1441                Command::Capabilities(cap_cli) => {
1442                    run_capabilities_command(cap_cli)?;
1443                }
1444                Command::Internalize(internalize_cli) => {
1445                    internalize::run_internalize_cli(&project_store, &store_root, internalize_cli)?;
1446                }
1447                Command::Preflight(preflight_cli) => {
1448                    run_preflight_command(preflight_cli, &project_root)?;
1449                }
1450                Command::Impact(impact_cli) => {
1451                    run_impact_command(impact_cli, &project_root)?;
1452                }
1453                Command::Infer(infer_cli) => {
1454                    run_infer_command(infer_cli, &project_root)?;
1455                }
1456                Command::Trace(trace_cli) => {
1457                    run_trace_command(trace_cli, &project_root)?;
1458                }
1459                Command::Eval(eval_cli) => {
1460                    eval::run_eval_cli(&project_store, eval_cli)?;
1461                }
1462                Command::FlightRecorder(fr_cli) => {
1463                    flight_recorder::run_flight_recorder_cli(&project_store, fr_cli)?;
1464                }
1465                Command::StateCommit(sc_cli) => {
1466                    run_state_commit_command(sc_cli, &project_root)?;
1467                }
1468                Command::Doctor(doctor_cli) => {
1469                    doctor::run_doctor_cli(&project_store, &project_root, doctor_cli)?;
1470                }
1471                Command::Lcm(lcm_cli) => {
1472                    lcm::run_lcm_cli(&project_store, lcm_cli)?;
1473                }
1474                Command::Map(map_cli) => {
1475                    map_ops::run_map_cli(&project_store, map_cli)?;
1476                }
1477                Command::Demo(demo_cli) => {
1478                    run_demo_command(demo_cli, &project_root)?;
1479                }
1480                _ => unreachable!(),
1481            }
1482        }
1483    }
1484    Ok(())
1485}
1486
1487fn should_route_via_group_broker(command: &Command, argv: &[String]) -> bool {
1488    if core::group_broker::is_internal_invocation() {
1489        return false;
1490    }
1491    match command {
1492        Command::Todo(_) => todo_argv_is_mutating(argv),
1493        Command::Decide(decide_cli) => decide_command_is_mutating(decide_cli),
1494        Command::Data(data_cli) => match &data_cli.command {
1495            DataCommand::Federation(_) => federation_argv_is_mutating(argv),
1496            DataCommand::Knowledge(_) => knowledge_argv_is_mutating(argv),
1497            _ => false,
1498        },
1499        _ => false,
1500    }
1501}
1502
1503fn enforce_route_strict_mode() -> bool {
1504    std::env::var("DECAPOD_GROUP_BROKER_ENFORCE_ROUTE")
1505        .ok()
1506        .map(|v| v == "1")
1507        .unwrap_or(false)
1508}
1509
1510fn todo_argv_is_mutating(argv: &[String]) -> bool {
1511    let Some(sub) = argv.get(1).map(|s| s.as_str()) else {
1512        return false;
1513    };
1514    !matches!(
1515        sub,
1516        "list"
1517            | "get"
1518            | "show"
1519            | "categories"
1520            | "ownerships"
1521            | "claim-status"
1522            | "presence"
1523            | "list-owners"
1524            | "expertise"
1525    )
1526}
1527
1528fn decide_command_is_mutating(decide_cli: &decide::DecideCli) -> bool {
1529    matches!(
1530        decide_cli.command,
1531        decide::DecideCommand::Start { .. }
1532            | decide::DecideCommand::Record { .. }
1533            | decide::DecideCommand::Complete { .. }
1534            | decide::DecideCommand::Init
1535    )
1536}
1537
1538fn knowledge_argv_is_mutating(argv: &[String]) -> bool {
1539    matches!(argv.get(2).map(|s| s.as_str()), Some("add" | "promote"))
1540}
1541
1542fn federation_argv_is_mutating(argv: &[String]) -> bool {
1543    matches!(
1544        argv.get(2).map(|s| s.as_str()),
1545        Some(
1546            "add"
1547                | "edit"
1548                | "supersede"
1549                | "deprecate"
1550                | "dispute"
1551                | "link"
1552                | "unlink"
1553                | "sources-add"
1554                | "init"
1555                | "rebuild"
1556        )
1557    )
1558}
1559
1560fn should_auto_clock_in(command: &Command) -> bool {
1561    match command {
1562        Command::Todo(todo_cli) => !todo::is_heartbeat_command(todo_cli),
1563        Command::Version
1564        | Command::Activate
1565        | Command::Init(_)
1566        | Command::Setup(_)
1567        | Command::Session(_)
1568        | Command::Release(_)
1569        | Command::StateCommit(_)
1570        | Command::Doctor(_) => false,
1571        _ => true,
1572    }
1573}
1574
1575fn command_requires_worktree(command: &Command) -> bool {
1576    match command {
1577        Command::Init(_)
1578        | Command::Activate
1579        | Command::Setup(_)
1580        | Command::Session(_)
1581        | Command::Version
1582        | Command::Validate(_)
1583        | Command::Workspace(_)
1584        | Command::Capabilities(_)
1585        | Command::Trace(_)
1586        | Command::FlightRecorder(_)
1587        | Command::Docs(_)
1588        | Command::Handshake(_)
1589        | Command::Release(_)
1590        | Command::Todo(_)
1591        | Command::Eval(_)
1592        | Command::StateCommit(_)
1593        | Command::Doctor(_) => false,
1594        Command::Data(data_cli) => !matches!(data_cli.command, DataCommand::Schema(_)),
1595        Command::Rpc(_) => false,
1596        _ => true,
1597    }
1598}
1599
1600fn is_canonical_decapod_worktree_path(path: &Path) -> bool {
1601    let mut saw_decapod = false;
1602    for comp in path.components() {
1603        let seg = comp.as_os_str().to_string_lossy();
1604        if seg == ".decapod" {
1605            saw_decapod = true;
1606            continue;
1607        }
1608        if saw_decapod && seg == "workspaces" {
1609            return true;
1610        }
1611    }
1612    false
1613}
1614
1615fn command_requires_todo_scoped_worktree(command: &Command) -> bool {
1616    !matches!(
1617        command,
1618        Command::Validate(_)
1619            | Command::Activate
1620            | Command::Docs(_)
1621            | Command::Release(_)
1622            | Command::Trace(_)
1623            | Command::Capabilities(_)
1624            | Command::Doctor(_)
1625            | Command::StateCommit(_)
1626            | Command::Qa(_)
1627    )
1628}
1629
1630fn command_requires_canonical_worktree_path(command: &Command) -> bool {
1631    !matches!(
1632        command,
1633        Command::Validate(_)
1634            | Command::Activate
1635            | Command::Docs(_)
1636            | Command::Release(_)
1637            | Command::Trace(_)
1638            | Command::Capabilities(_)
1639            | Command::Doctor(_)
1640            | Command::StateCommit(_)
1641            | Command::Qa(_)
1642    )
1643}
1644
1645fn branch_contains_todo_ticket_id(branch: &str) -> bool {
1646    let branch = branch.to_ascii_lowercase();
1647    if branch.contains("r_") {
1648        return true;
1649    }
1650    if let Ok(hash_re) = fancy_regex::Regex::new(r"todo-[a-z0-9]{6}(\b|-|$)")
1651        && hash_re.is_match(&branch).unwrap_or(false)
1652    {
1653        return true;
1654    }
1655    let chars: Vec<char> = branch.chars().collect();
1656    if chars.len() < 21 {
1657        return false;
1658    }
1659    for i in 0..=(chars.len() - 21) {
1660        let type_ok = chars[i..i + 4].iter().all(|c| c.is_ascii_lowercase());
1661        let sep_ok = chars[i + 4] == '_';
1662        let body_ok = chars[i + 5..i + 21]
1663            .iter()
1664            .all(|c| c.is_ascii_alphanumeric());
1665        if type_ok && sep_ok && body_ok {
1666            return true;
1667        }
1668    }
1669    false
1670}
1671
1672fn enforce_worktree_requirement(
1673    command: &Command,
1674    project_root: &Path,
1675) -> Result<(), error::DecapodError> {
1676    if std::env::var("DECAPOD_VALIDATE_SKIP_GIT_GATES").is_ok() {
1677        return Ok(());
1678    }
1679    if !command_requires_worktree(command) {
1680        return Ok(());
1681    }
1682
1683    let status = crate::core::workspace::get_workspace_status(project_root)?;
1684    if status.git.in_worktree {
1685        let worktree_path = status
1686            .git
1687            .worktree_path
1688            .clone()
1689            .unwrap_or_else(|| project_root.to_path_buf());
1690        if command_requires_canonical_worktree_path(command)
1691            && !is_canonical_decapod_worktree_path(&worktree_path)
1692        {
1693            return Err(error::DecapodError::ValidationError(format!(
1694                "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.",
1695                worktree_path.display()
1696            )));
1697        }
1698
1699        if command_requires_todo_scoped_worktree(command)
1700            && !branch_contains_todo_ticket_id(&status.git.current_branch)
1701        {
1702            return Err(error::DecapodError::ValidationError(format!(
1703                "SCOPE_VIOLATION: branch '{}' is not todo-scoped. Run `decapod todo add \"<task>\"`, `decapod todo claim --id <task-id>`, then `decapod workspace ensure`.",
1704                status.git.current_branch
1705            )));
1706        }
1707        return Ok(());
1708    }
1709
1710    Err(error::DecapodError::ValidationError(format!(
1711        "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.",
1712        status.git.current_branch
1713    )))
1714}
1715
1716fn rpc_op_requires_worktree(op: &str) -> bool {
1717    !matches!(
1718        op,
1719        "agent.init"
1720            | "workspace.status"
1721            | "workspace.ensure"
1722            | "assurance.evaluate"
1723            | "mentor.obligations"
1724            | "context.resolve"
1725            | "context.scope"
1726            | "context.capsule.query"
1727            | "context.bindings"
1728            | "schema.get"
1729            | "store.upsert"
1730            | "store.query"
1731            | "validate.run"
1732            | "standards.resolve"
1733    )
1734}
1735
1736fn enforce_worktree_requirement_for_rpc(
1737    op: &str,
1738    project_root: &Path,
1739) -> Result<(), error::DecapodError> {
1740    if std::env::var("DECAPOD_VALIDATE_SKIP_GIT_GATES").is_ok() {
1741        return Ok(());
1742    }
1743    if !rpc_op_requires_worktree(op) {
1744        return Ok(());
1745    }
1746
1747    let status = crate::core::workspace::get_workspace_status(project_root)?;
1748    if status.git.in_worktree {
1749        let worktree_path = status
1750            .git
1751            .worktree_path
1752            .clone()
1753            .unwrap_or_else(|| project_root.to_path_buf());
1754        if !matches!(
1755            op,
1756            "validate.run"
1757                | "context.resolve"
1758                | "context.scope"
1759                | "context.capsule.query"
1760                | "context.bindings"
1761                | "schema.get"
1762        ) && !is_canonical_decapod_worktree_path(&worktree_path)
1763        {
1764            return Err(error::DecapodError::ValidationError(format!(
1765                "SCOPE_VIOLATION: RPC op '{}' must execute from a Decapod-managed worktree under '.decapod/workspaces/*' (current '{}'). Run `decapod workspace ensure` and retry.",
1766                op,
1767                worktree_path.display()
1768            )));
1769        }
1770        return Ok(());
1771    }
1772
1773    Err(error::DecapodError::ValidationError(format!(
1774        "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.",
1775        op, status.git.current_branch
1776    )))
1777}
1778
1779fn rpc_op_bypasses_session(op: &str) -> bool {
1780    matches!(
1781        op,
1782        "agent.init"
1783            | "context.resolve"
1784            | "context.scope"
1785            | "context.capsule.query"
1786            | "context.bindings"
1787            | "schema.get"
1788            | "store.upsert"
1789            | "store.query"
1790            | "validate.run"
1791            | "workspace.status"
1792            | "workspace.ensure"
1793            | "standards.resolve"
1794    )
1795}
1796
1797fn requires_session_token(command: &Command) -> bool {
1798    match command {
1799        // Bootstrap/session lifecycle + version + capabilities are sessionless.
1800        Command::Init(_)
1801        | Command::Session(_)
1802        | Command::Version
1803        | Command::Activate
1804        | Command::Docs(_)
1805        | Command::Capabilities(_)
1806        | Command::Release(_)
1807        | Command::Trace(_)
1808        | Command::FlightRecorder(_)
1809        | Command::StateCommit(_)
1810        | Command::Doctor(_) => false,
1811        Command::Data(DataCli {
1812            command: DataCommand::Schema(_),
1813        }) => false,
1814        Command::Rpc(rpc_cli) => {
1815            if let Some(ref op) = rpc_cli.op {
1816                !rpc_op_bypasses_session(op)
1817            } else {
1818                // If op is not provided via flag, we'll check it after parsing JSON in run_rpc_command
1819                false
1820            }
1821        }
1822        _ => true,
1823    }
1824}
1825
1826#[derive(Debug, Serialize, Deserialize)]
1827struct AgentSessionRecord {
1828    agent_id: String,
1829    token: String,
1830    password_hash: String,
1831    issued_at_epoch_secs: u64,
1832    expires_at_epoch_secs: u64,
1833}
1834
1835#[derive(Debug, Serialize, Deserialize)]
1836struct ConstitutionalAwarenessRecord {
1837    agent_id: String,
1838    session_token: Option<String>,
1839    initialized_at_epoch_secs: u64,
1840    validated_at_epoch_secs: Option<u64>,
1841    core_constitution_ingested_at_epoch_secs: Option<u64>,
1842    context_resolved_at_epoch_secs: Option<u64>,
1843    source_ops: Vec<String>,
1844}
1845
1846fn now_epoch_secs() -> u64 {
1847    SystemTime::now()
1848        .duration_since(UNIX_EPOCH)
1849        .map(|d| d.as_secs())
1850        .unwrap_or(0)
1851}
1852
1853fn session_ttl_secs() -> u64 {
1854    std::env::var("DECAPOD_SESSION_TTL_SECS")
1855        .ok()
1856        .and_then(|v| v.parse::<u64>().ok())
1857        .filter(|v| *v > 0)
1858        .unwrap_or(3600)
1859}
1860
1861fn current_agent_id() -> String {
1862    std::env::var("DECAPOD_AGENT_ID")
1863        .ok()
1864        .map(|v| v.trim().to_string())
1865        .filter(|v| !v.is_empty())
1866        .unwrap_or_else(|| "unknown".to_string())
1867}
1868
1869fn sanitize_agent_component(s: &str) -> String {
1870    let mut out = String::with_capacity(s.len());
1871    for ch in s.chars() {
1872        if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
1873            out.push(ch.to_ascii_lowercase());
1874        } else {
1875            out.push('-');
1876        }
1877    }
1878    out.trim_matches('-').to_string()
1879}
1880
1881fn sessions_dir(project_root: &Path) -> PathBuf {
1882    project_root
1883        .join(".decapod")
1884        .join("generated")
1885        .join("sessions")
1886}
1887
1888fn session_file_for_agent(project_root: &Path, agent_id: &str) -> PathBuf {
1889    sessions_dir(project_root).join(format!("{}.json", sanitize_agent_component(agent_id)))
1890}
1891
1892fn awareness_dir(project_root: &Path) -> PathBuf {
1893    project_root
1894        .join(".decapod")
1895        .join("generated")
1896        .join("awareness")
1897}
1898
1899fn awareness_file_for_agent(project_root: &Path, agent_id: &str) -> PathBuf {
1900    awareness_dir(project_root).join(format!("{}.json", sanitize_agent_component(agent_id)))
1901}
1902
1903fn hash_password(password: &str, token: &str) -> String {
1904    let mut hasher = Sha256::new();
1905    hasher.update(token.as_bytes());
1906    hasher.update(b":");
1907    hasher.update(password.as_bytes());
1908    let digest = hasher.finalize();
1909    let mut out = String::with_capacity(digest.len() * 2);
1910    for b in digest {
1911        out.push_str(&format!("{:02x}", b));
1912    }
1913    out
1914}
1915
1916fn generate_ephemeral_password() -> Result<String, error::DecapodError> {
1917    let mut buf = vec![0u8; 24];
1918    let mut urandom = fs::File::open("/dev/urandom").map_err(error::DecapodError::IoError)?;
1919    urandom
1920        .read_exact(&mut buf)
1921        .map_err(error::DecapodError::IoError)?;
1922    let mut out = String::with_capacity(buf.len() * 2);
1923    for b in buf {
1924        out.push_str(&format!("{:02x}", b));
1925    }
1926    Ok(out)
1927}
1928
1929fn read_agent_session(
1930    project_root: &Path,
1931    agent_id: &str,
1932) -> Result<Option<AgentSessionRecord>, error::DecapodError> {
1933    let path = session_file_for_agent(project_root, agent_id);
1934    if !path.exists() {
1935        return Ok(None);
1936    }
1937    let raw = fs::read_to_string(&path).map_err(error::DecapodError::IoError)?;
1938    let rec: AgentSessionRecord = serde_json::from_str(&raw)
1939        .map_err(|e| error::DecapodError::SessionError(format!("invalid session file: {}", e)))?;
1940    Ok(Some(rec))
1941}
1942
1943fn atomic_write_file(path: &Path, body: &str) -> Result<(), error::DecapodError> {
1944    let parent = path.parent().ok_or_else(|| {
1945        error::DecapodError::IoError(std::io::Error::other(
1946            "target path is missing parent directory",
1947        ))
1948    })?;
1949    fs::create_dir_all(parent).map_err(error::DecapodError::IoError)?;
1950
1951    let file_name = path
1952        .file_name()
1953        .and_then(|v| v.to_str())
1954        .unwrap_or("file")
1955        .to_string();
1956    let nonce = SystemTime::now()
1957        .duration_since(UNIX_EPOCH)
1958        .map(|d| d.as_nanos())
1959        .unwrap_or(0);
1960    let tmp = parent.join(format!(
1961        ".{}.tmp-{}-{}",
1962        file_name,
1963        std::process::id(),
1964        nonce
1965    ));
1966    fs::write(&tmp, body).map_err(error::DecapodError::IoError)?;
1967    #[cfg(unix)]
1968    {
1969        use std::os::unix::fs::PermissionsExt;
1970        let mut perms = fs::metadata(&tmp)
1971            .map_err(error::DecapodError::IoError)?
1972            .permissions();
1973        perms.set_mode(0o600);
1974        fs::set_permissions(&tmp, perms).map_err(error::DecapodError::IoError)?;
1975    }
1976    fs::rename(&tmp, path).map_err(error::DecapodError::IoError)?;
1977    Ok(())
1978}
1979
1980fn write_agent_session(
1981    project_root: &Path,
1982    rec: &AgentSessionRecord,
1983) -> Result<(), error::DecapodError> {
1984    let dir = sessions_dir(project_root);
1985    fs::create_dir_all(&dir).map_err(error::DecapodError::IoError)?;
1986    let path = session_file_for_agent(project_root, &rec.agent_id);
1987    let body = serde_json::to_string_pretty(rec)
1988        .map_err(|e| error::DecapodError::SessionError(format!("session encode error: {}", e)))?;
1989    atomic_write_file(&path, &body)?;
1990    Ok(())
1991}
1992
1993fn clear_agent_awareness(project_root: &Path, agent_id: &str) -> Result<(), error::DecapodError> {
1994    let path = awareness_file_for_agent(project_root, agent_id);
1995    if path.exists() {
1996        fs::remove_file(path).map_err(error::DecapodError::IoError)?;
1997    }
1998    Ok(())
1999}
2000
2001fn read_awareness_record(
2002    project_root: &Path,
2003    agent_id: &str,
2004) -> Result<Option<ConstitutionalAwarenessRecord>, error::DecapodError> {
2005    let path = awareness_file_for_agent(project_root, agent_id);
2006    if !path.exists() {
2007        return Ok(None);
2008    }
2009    let raw = fs::read_to_string(path).map_err(error::DecapodError::IoError)?;
2010    let rec: ConstitutionalAwarenessRecord = serde_json::from_str(&raw).map_err(|e| {
2011        error::DecapodError::ValidationError(format!(
2012            "invalid constitutional awareness record: {}",
2013            e
2014        ))
2015    })?;
2016    Ok(Some(rec))
2017}
2018
2019fn write_awareness_record(
2020    project_root: &Path,
2021    rec: &ConstitutionalAwarenessRecord,
2022) -> Result<(), error::DecapodError> {
2023    let dir = awareness_dir(project_root);
2024    fs::create_dir_all(&dir).map_err(error::DecapodError::IoError)?;
2025    let path = awareness_file_for_agent(project_root, &rec.agent_id);
2026    let body = serde_json::to_string_pretty(rec).map_err(|e| {
2027        error::DecapodError::ValidationError(format!("awareness encode error: {}", e))
2028    })?;
2029    atomic_write_file(&path, &body)?;
2030    Ok(())
2031}
2032
2033fn mark_constitution_initialized(project_root: &Path) -> Result<(), error::DecapodError> {
2034    let agent_id = current_agent_id();
2035    let session_token = read_agent_session(project_root, &agent_id)?.map(|s| s.token);
2036    let now = now_epoch_secs();
2037    let existing = read_awareness_record(project_root, &agent_id)?;
2038    let mut source_ops = existing
2039        .as_ref()
2040        .map(|r| r.source_ops.clone())
2041        .unwrap_or_default();
2042    if !source_ops.iter().any(|op| op == "agent.init") {
2043        source_ops.push("agent.init".to_string());
2044    }
2045    let rec = ConstitutionalAwarenessRecord {
2046        agent_id,
2047        session_token,
2048        initialized_at_epoch_secs: now,
2049        validated_at_epoch_secs: existing.as_ref().and_then(|r| r.validated_at_epoch_secs),
2050        core_constitution_ingested_at_epoch_secs: existing
2051            .as_ref()
2052            .and_then(|r| r.core_constitution_ingested_at_epoch_secs),
2053        context_resolved_at_epoch_secs: existing.and_then(|r| r.context_resolved_at_epoch_secs),
2054        source_ops,
2055    };
2056    write_awareness_record(project_root, &rec)
2057}
2058
2059fn mark_constitution_context_resolved(project_root: &Path) -> Result<(), error::DecapodError> {
2060    let agent_id = current_agent_id();
2061    let mut rec =
2062        read_awareness_record(project_root, &agent_id)?.unwrap_or(ConstitutionalAwarenessRecord {
2063            agent_id: agent_id.clone(),
2064            session_token: read_agent_session(project_root, &agent_id)?.map(|s| s.token),
2065            initialized_at_epoch_secs: now_epoch_secs(),
2066            validated_at_epoch_secs: None,
2067            core_constitution_ingested_at_epoch_secs: None,
2068            context_resolved_at_epoch_secs: None,
2069            source_ops: Vec::new(),
2070        });
2071    rec.context_resolved_at_epoch_secs = Some(now_epoch_secs());
2072    if !rec.source_ops.iter().any(|op| op == "context.resolve") {
2073        rec.source_ops.push("context.resolve".to_string());
2074    }
2075    write_awareness_record(project_root, &rec)
2076}
2077
2078fn mark_validation_completed(project_root: &Path) -> Result<(), error::DecapodError> {
2079    let agent_id = current_agent_id();
2080    let mut rec =
2081        read_awareness_record(project_root, &agent_id)?.unwrap_or(ConstitutionalAwarenessRecord {
2082            agent_id: agent_id.clone(),
2083            session_token: read_agent_session(project_root, &agent_id)?.map(|s| s.token),
2084            initialized_at_epoch_secs: now_epoch_secs(),
2085            validated_at_epoch_secs: None,
2086            core_constitution_ingested_at_epoch_secs: None,
2087            context_resolved_at_epoch_secs: None,
2088            source_ops: Vec::new(),
2089        });
2090    rec.validated_at_epoch_secs = Some(now_epoch_secs());
2091    if !rec.source_ops.iter().any(|op| op == "validate") {
2092        rec.source_ops.push("validate".to_string());
2093    }
2094    write_awareness_record(project_root, &rec)
2095}
2096
2097fn mark_core_constitution_ingested(project_root: &Path) -> Result<(), error::DecapodError> {
2098    let agent_id = current_agent_id();
2099    let mut rec =
2100        read_awareness_record(project_root, &agent_id)?.unwrap_or(ConstitutionalAwarenessRecord {
2101            agent_id: agent_id.clone(),
2102            session_token: read_agent_session(project_root, &agent_id)?.map(|s| s.token),
2103            initialized_at_epoch_secs: now_epoch_secs(),
2104            validated_at_epoch_secs: None,
2105            core_constitution_ingested_at_epoch_secs: None,
2106            context_resolved_at_epoch_secs: None,
2107            source_ops: Vec::new(),
2108        });
2109    rec.core_constitution_ingested_at_epoch_secs = Some(now_epoch_secs());
2110    if !rec.source_ops.iter().any(|op| op == "docs.ingest") {
2111        rec.source_ops.push("docs.ingest".to_string());
2112    }
2113    write_awareness_record(project_root, &rec)
2114}
2115
2116fn cleanup_expired_sessions(
2117    project_root: &Path,
2118    store_root: &Path,
2119) -> Result<Vec<String>, error::DecapodError> {
2120    let dir = sessions_dir(project_root);
2121    if !dir.exists() {
2122        return Ok(Vec::new());
2123    }
2124    let now = now_epoch_secs();
2125    let mut expired_agents = Vec::new();
2126    for entry in fs::read_dir(&dir).map_err(error::DecapodError::IoError)? {
2127        let entry = entry.map_err(error::DecapodError::IoError)?;
2128        let path = entry.path();
2129        if path.extension().and_then(|s| s.to_str()) != Some("json") {
2130            continue;
2131        }
2132        let raw = match fs::read_to_string(&path) {
2133            Ok(v) => v,
2134            Err(_) => {
2135                let _ = fs::remove_file(&path);
2136                continue;
2137            }
2138        };
2139        let rec: AgentSessionRecord = match serde_json::from_str(&raw) {
2140            Ok(v) => v,
2141            Err(_) => {
2142                let _ = fs::remove_file(&path);
2143                continue;
2144            }
2145        };
2146        if rec.expires_at_epoch_secs <= now {
2147            let _ = fs::remove_file(&path);
2148            expired_agents.push(rec.agent_id);
2149        }
2150    }
2151
2152    if !expired_agents.is_empty() {
2153        todo::cleanup_stale_agent_assignments(store_root, &expired_agents, "session.expired")?;
2154        for agent_id in &expired_agents {
2155            let _ = clear_agent_awareness(project_root, agent_id);
2156        }
2157    }
2158
2159    Ok(expired_agents)
2160}
2161
2162fn ensure_session_valid() -> Result<(), error::DecapodError> {
2163    let current_dir = std::env::current_dir()?;
2164    let project_root = find_decapod_project_root(&current_dir)?;
2165    let store_root = project_root.join(".decapod").join("data");
2166    fs::create_dir_all(&store_root).map_err(error::DecapodError::IoError)?;
2167    let _ = cleanup_expired_sessions(&project_root, &store_root)?;
2168
2169    let agent_id = current_agent_id();
2170    let session = read_agent_session(&project_root, &agent_id)?;
2171    let Some(session) = session else {
2172        // Auto-acquire session if none exists (entrypoint funnel behavior)
2173        return auto_acquire_session(&project_root, &agent_id);
2174    };
2175
2176    if session.expires_at_epoch_secs <= now_epoch_secs() {
2177        let _ = fs::remove_file(session_file_for_agent(&project_root, &agent_id));
2178        let _ = todo::cleanup_stale_agent_assignments(
2179            &store_root,
2180            std::slice::from_ref(&agent_id),
2181            "session.expired",
2182        );
2183        // Auto-acquire session if expired (entrypoint funnel behavior)
2184        return auto_acquire_session(&project_root, &agent_id);
2185    }
2186
2187    if agent_id == "unknown" {
2188        // Force session instantiation for unknown agents (required for validate)
2189        return auto_acquire_session(&project_root, &agent_id);
2190    }
2191
2192    // Read from OnceLock first (process-local), fall back to env if not set
2193    let supplied_password = SESSION_PASSWORD
2194        .get()
2195        .cloned()
2196        .or_else(|| std::env::var("DECAPOD_SESSION_PASSWORD").ok())
2197        .inspect(|p| {
2198            // Cache it in OnceLock if found in env
2199            let _ = SESSION_PASSWORD.get_or_init(|| p.clone());
2200        });
2201
2202    let supplied_password = match supplied_password {
2203        Some(p) => p,
2204        None => {
2205            // No password in env - auto-acquire new session (entrypoint funnel)
2206            return auto_acquire_session(&project_root, &agent_id);
2207        }
2208    };
2209    let supplied_hash = hash_password(&supplied_password, &session.token);
2210    if supplied_hash != session.password_hash {
2211        // Password invalid - auto-acquire new session (entrypoint funnel)
2212        return auto_acquire_session(&project_root, &agent_id);
2213    }
2214    Ok(())
2215}
2216
2217fn auto_acquire_session(project_root: &Path, agent_id: &str) -> Result<(), error::DecapodError> {
2218    let issued = now_epoch_secs();
2219    let expires = issued.saturating_add(session_ttl_secs());
2220    let token = crate::core::ulid::new_ulid();
2221    let password = generate_ephemeral_password()?;
2222    let rec = AgentSessionRecord {
2223        agent_id: agent_id.to_string(),
2224        token: token.clone(),
2225        password_hash: hash_password(&password, &token),
2226        issued_at_epoch_secs: issued,
2227        expires_at_epoch_secs: expires,
2228    };
2229    write_agent_session(project_root, &rec)?;
2230
2231    // Set the password for subsequent operations in this process using process-local OnceLock
2232    // Eliminates unsafe env::set_var and multi-threading UB
2233    SESSION_PASSWORD.get_or_init(|| password);
2234
2235    eprintln!("session: auto-acquired for agent '{}'.", agent_id);
2236
2237    Ok(())
2238}
2239
2240use crate::core::ansi::AnsiExt;
2241use crate::core::migration::DECAPOD_VERSION;
2242
2243fn check_and_update_version() -> Result<bool, error::DecapodError> {
2244    let current_version = DECAPOD_VERSION;
2245
2246    // Skip check if curl not available (treat all errors as skip)
2247    match std::process::Command::new("curl").arg("--version").output() {
2248        Ok(o) if !o.status.success() => return Ok(false),
2249        Err(_) => return Ok(false),
2250        _ => {}
2251    }
2252
2253    let latest_version = match fetch_latest_crates_version() {
2254        Ok(v) => v,
2255        Err(_) => return Ok(false),
2256    };
2257
2258    if version_gt(&latest_version, current_version) {
2259        eprintln!(
2260            "{} Decapod v{} → v{}, updating...",
2261            "⚠".bright_yellow(),
2262            current_version,
2263            latest_version
2264        );
2265
2266        let _ = backup_decapod_state(); // Best effort
2267
2268        // Skip if cargo not available
2269        match std::process::Command::new("cargo")
2270            .arg("--version")
2271            .output()
2272        {
2273            Ok(o) if !o.status.success() => {
2274                eprintln!(
2275                    "{} cargo not available, skipping update",
2276                    "⚠".bright_yellow()
2277                );
2278                return Ok(false);
2279            }
2280            Err(_) => return Ok(false),
2281            _ => {}
2282        }
2283
2284        if install_decapod().is_ok() {
2285            eprintln!("{} Updated to v{}.", "✓".bright_green(), latest_version);
2286
2287            // Prompt to migrate/refresh config for new fields
2288            let project_root = std::env::current_dir()
2289                .ok()
2290                .and_then(|d| find_decapod_project_root(&d).ok());
2291
2292            if let Some(root) = project_root {
2293                let config_path = root.join(".decapod").join("config.toml");
2294                if config_path.exists() {
2295                    eprintln!(
2296                        "{} Check for new config fields in .decapod/config.toml",
2297                        "→".bright_cyan()
2298                    );
2299                }
2300            }
2301
2302            return Ok(true);
2303        }
2304    }
2305
2306    Ok(false)
2307}
2308
2309fn fetch_latest_crates_version() -> Result<String, error::DecapodError> {
2310    let output = std::process::Command::new("curl")
2311        .args(["-s", "https://crates.io/api/v1/crates/decapod"])
2312        .output()
2313        .map_err(|e| {
2314            error::DecapodError::ValidationError(format!("Failed to check version: {}", e))
2315        })?;
2316
2317    if !output.status.success() {
2318        return Err(error::DecapodError::ValidationError(
2319            "Failed to fetch latest version".to_string(),
2320        ));
2321    }
2322
2323    let json: serde_json::Value = serde_json::from_slice(&output.stdout)
2324        .map_err(|e| error::DecapodError::ValidationError(format!("Invalid response: {}", e)))?;
2325
2326    json.get("version")
2327        .and_then(|v| v.get("num"))
2328        .and_then(|n| n.as_str())
2329        .map(|s| s.to_string())
2330        .ok_or_else(|| error::DecapodError::ValidationError("Could not parse version".to_string()))
2331}
2332
2333fn version_gt(new: &str, current: &str) -> bool {
2334    let new_parts: Vec<u32> = new.split('.').filter_map(|p| p.parse().ok()).collect();
2335    let cur_parts: Vec<u32> = current.split('.').filter_map(|p| p.parse().ok()).collect();
2336
2337    for i in 0..new_parts.len().max(cur_parts.len()) {
2338        let new_p = new_parts.get(i).unwrap_or(&0);
2339        let cur_p = cur_parts.get(i).unwrap_or(&0);
2340        if new_p > cur_p {
2341            return true;
2342        }
2343        if new_p < cur_p {
2344            return false;
2345        }
2346    }
2347    false
2348}
2349
2350fn backup_decapod_state() -> Result<(), error::DecapodError> {
2351    let current_dir = std::env::current_dir()?;
2352    let project_root = find_decapod_project_root(&current_dir)?;
2353    let decapod_dir = project_root.join(".decapod");
2354
2355    if !decapod_dir.exists() {
2356        return Ok(());
2357    }
2358
2359    let backup_dir = decapod_dir.join("backups");
2360    fs::create_dir_all(&backup_dir).map_err(error::DecapodError::IoError)?;
2361
2362    let timestamp = std::time::SystemTime::now()
2363        .duration_since(UNIX_EPOCH)
2364        .map(|d| d.as_secs())
2365        .unwrap_or(0);
2366    let backup_name = format!("backup_{}_{}", DECAPOD_VERSION, timestamp);
2367    let backup_path = backup_dir.join(&backup_name);
2368
2369    let mut backup_file = fs::File::create(&backup_path).map_err(error::DecapodError::IoError)?;
2370
2371    let override_path = decapod_dir.join("OVERRIDE.md");
2372    let overrides = if override_path.exists() {
2373        fs::read_to_string(&override_path).unwrap_or_default()
2374    } else {
2375        String::new()
2376    };
2377
2378    let now = std::time::SystemTime::now()
2379        .duration_since(UNIX_EPOCH)
2380        .map(|d| d.as_secs())
2381        .unwrap_or(0);
2382    let content = format!(
2383        "# Backup at {} v{}\n# OVERRIDE.md\n{}\n",
2384        now, DECAPOD_VERSION, overrides
2385    );
2386
2387    backup_file.write_all(content.as_bytes())?;
2388
2389    Ok(())
2390}
2391
2392fn install_decapod() -> Result<(), error::DecapodError> {
2393    let output = std::process::Command::new("cargo")
2394        .args(["install", "decapod"])
2395        .output()
2396        .map_err(|e| error::DecapodError::ValidationError(format!("Failed to install: {}", e)))?;
2397
2398    if !output.status.success() {
2399        let err = String::from_utf8_lossy(&output.stderr);
2400        return Err(error::DecapodError::ValidationError(format!(
2401            "Install failed: {}",
2402            err
2403        )));
2404    }
2405
2406    Ok(())
2407}
2408
2409fn run_session_command(session_cli: SessionCli) -> Result<(), error::DecapodError> {
2410    let current_dir = std::env::current_dir()?;
2411    let project_root = find_decapod_project_root(&current_dir)?;
2412    let store_root = project_root.join(".decapod").join("data");
2413    fs::create_dir_all(&store_root).map_err(error::DecapodError::IoError)?;
2414    let _ = cleanup_expired_sessions(&project_root, &store_root)?;
2415
2416    // Check and update version on session acquire
2417    if matches!(session_cli.command, SessionCommand::Acquire)
2418        && let Ok(true) = check_and_update_version()
2419    {
2420        eprintln!(
2421            "{} Restart Session: decapod session acquire",
2422            "→".bright_cyan()
2423        );
2424    }
2425
2426    match session_cli.command {
2427        SessionCommand::Acquire => {
2428            let agent_id = current_agent_id();
2429            if let Some(existing) = read_agent_session(&project_root, &agent_id)?
2430                && existing.expires_at_epoch_secs > now_epoch_secs()
2431            {
2432                println!(
2433                    "Session already active for agent '{}'. Use 'decapod session status' for details.",
2434                    agent_id
2435                );
2436                return Ok(());
2437            }
2438
2439            let issued = now_epoch_secs();
2440            let expires = issued.saturating_add(session_ttl_secs());
2441            let token = crate::core::ulid::new_ulid();
2442            let password = generate_ephemeral_password()?;
2443            let rec = AgentSessionRecord {
2444                agent_id: agent_id.clone(),
2445                token: token.clone(),
2446                password_hash: hash_password(&password, &token),
2447                issued_at_epoch_secs: issued,
2448                expires_at_epoch_secs: expires,
2449            };
2450            write_agent_session(&project_root, &rec)?;
2451            clear_agent_awareness(&project_root, &agent_id)?;
2452
2453            println!("Session acquired successfully.");
2454            println!("Agent: {}", agent_id);
2455            println!("Token: {}", token);
2456            println!("Password: {}", password);
2457            println!("ExpiresAtEpoch: {}", expires);
2458            println!(
2459                "Export before running other commands: DECAPOD_AGENT_ID='{}' and DECAPOD_SESSION_PASSWORD='<password>'",
2460                rec.agent_id
2461            );
2462            println!("\nYou may now use other decapod commands.");
2463            Ok(())
2464        }
2465        SessionCommand::Status => {
2466            let agent_id = current_agent_id();
2467            if let Some(session) = read_agent_session(&project_root, &agent_id)? {
2468                println!("Session active");
2469                println!("Agent: {}", session.agent_id);
2470                println!("Token: {}", session.token);
2471                println!("IssuedAtEpoch: {}", session.issued_at_epoch_secs);
2472                println!("ExpiresAtEpoch: {}", session.expires_at_epoch_secs);
2473            } else {
2474                println!("No active session");
2475                println!("Run 'decapod session acquire' to start a session");
2476            }
2477            Ok(())
2478        }
2479        SessionCommand::Release => {
2480            let agent_id = current_agent_id();
2481            let session_path = session_file_for_agent(&project_root, &agent_id);
2482            if session_path.exists() {
2483                std::fs::remove_file(&session_path).map_err(error::DecapodError::IoError)?;
2484                clear_agent_awareness(&project_root, &agent_id)?;
2485                let _ = todo::cleanup_stale_agent_assignments(
2486                    &store_root,
2487                    std::slice::from_ref(&agent_id),
2488                    "session.release",
2489                );
2490                println!("Session released");
2491            } else {
2492                println!("No active session to release");
2493            }
2494            Ok(())
2495        }
2496        SessionCommand::Init {
2497            scope,
2498            mut proofs,
2499            force,
2500        } => {
2501            if proofs.is_empty() {
2502                proofs.push("decapod validate".to_string());
2503            }
2504            run_session_init(&project_root, &scope, &proofs, force)
2505        }
2506    }
2507}
2508
2509#[derive(Debug, Clone, Serialize, Deserialize)]
2510struct HandshakeArtifact {
2511    schema_version: String,
2512    request_id: String,
2513    agent_id: String,
2514    repo_version: String,
2515    scope: String,
2516    proofs: Vec<String>,
2517    declared_docs: Vec<String>,
2518    doc_hashes: serde_json::Value,
2519    artifact_hash: String,
2520}
2521
2522fn hash_bytes_hex(input: &[u8]) -> String {
2523    let mut hasher = Sha256::new();
2524    hasher.update(input);
2525    format!("{:x}", hasher.finalize())
2526}
2527
2528fn required_handshake_docs() -> Vec<&'static str> {
2529    vec![
2530        "CLAUDE.md",
2531        "AGENTS.md",
2532        "constitution/core/DECAPOD.md",
2533        "constitution/interfaces/CONTROL_PLANE.md",
2534    ]
2535}
2536
2537fn build_handshake_artifact(
2538    project_root: &Path,
2539    scope: &str,
2540    proofs: &[String],
2541) -> Result<HandshakeArtifact, error::DecapodError> {
2542    let mut doc_hashes = serde_json::Map::new();
2543    let required_docs = required_handshake_docs();
2544    for rel in &required_docs {
2545        let abs = project_root.join(rel);
2546        if !abs.exists() {
2547            return Err(error::DecapodError::ValidationError(format!(
2548                "Handshake requires `{}` to exist.",
2549                rel
2550            )));
2551        }
2552        let bytes = fs::read(&abs).map_err(error::DecapodError::IoError)?;
2553        doc_hashes.insert(
2554            (*rel).to_string(),
2555            serde_json::json!(hash_bytes_hex(&bytes)),
2556        );
2557    }
2558
2559    let request_id = crate::core::ulid::new_ulid();
2560    let mut unsigned = serde_json::json!({
2561        "schema_version": "1.0.0",
2562        "request_id": request_id,
2563        "agent_id": current_agent_id(),
2564        "repo_version": migration::DECAPOD_VERSION,
2565        "scope": scope,
2566        "proofs": proofs,
2567        "declared_docs": required_docs,
2568        "doc_hashes": doc_hashes,
2569    });
2570    let canonical = serde_json::to_vec(&unsigned).map_err(|e| {
2571        error::DecapodError::ValidationError(format!("Failed to encode handshake artifact: {e}"))
2572    })?;
2573    let artifact_hash = hash_bytes_hex(&canonical);
2574    unsigned["artifact_hash"] = serde_json::json!(artifact_hash);
2575
2576    serde_json::from_value(unsigned).map_err(|e| {
2577        error::DecapodError::ValidationError(format!("Failed to finalize handshake artifact: {e}"))
2578    })
2579}
2580
2581fn write_handshake_artifact(
2582    project_root: &Path,
2583    artifact: &HandshakeArtifact,
2584) -> Result<PathBuf, error::DecapodError> {
2585    let dir = project_root
2586        .join(".decapod")
2587        .join("records")
2588        .join("handshakes");
2589    fs::create_dir_all(&dir).map_err(error::DecapodError::IoError)?;
2590    let file = format!(
2591        "{}-{}.json",
2592        crate::core::time::now_epoch_z(),
2593        artifact.agent_id.replace('/', "_")
2594    );
2595    let path = dir.join(file);
2596    let pretty = serde_json::to_vec_pretty(artifact).map_err(|e| {
2597        error::DecapodError::ValidationError(format!("Failed to serialize handshake record: {e}"))
2598    })?;
2599    fs::write(&path, pretty).map_err(error::DecapodError::IoError)?;
2600    Ok(path)
2601}
2602
2603fn run_handshake_command(
2604    cli: HandshakeCli,
2605    project_root: &Path,
2606) -> Result<(), error::DecapodError> {
2607    if cli.proofs.is_empty() {
2608        return Err(error::DecapodError::ValidationError(
2609            "Handshake requires at least one `--proof` declaration.".to_string(),
2610        ));
2611    }
2612    let artifact = build_handshake_artifact(project_root, &cli.scope, &cli.proofs)?;
2613    let path = write_handshake_artifact(project_root, &artifact)?;
2614    println!(
2615        "{}",
2616        serde_json::json!({
2617            "cmd": "handshake",
2618            "status": "ok",
2619            "path": path,
2620            "artifact_hash": artifact.artifact_hash,
2621            "repo_version": artifact.repo_version,
2622            "scope": artifact.scope,
2623            "proofs": artifact.proofs,
2624        })
2625    );
2626    Ok(())
2627}
2628
2629fn run_session_init(
2630    project_root: &Path,
2631    scope: &str,
2632    proofs: &[String],
2633    force: bool,
2634) -> Result<(), error::DecapodError> {
2635    let mut created = Vec::new();
2636    let mut skipped = Vec::new();
2637
2638    let tasks_dir = project_root.join("tasks");
2639    fs::create_dir_all(&tasks_dir).map_err(error::DecapodError::IoError)?;
2640
2641    let todo_path = tasks_dir.join("todo.md");
2642    let todo_stub = "\
2643# Work Session Plan
2644
2645- Task: <replace-with-task-id-and-title>
2646- Scope: <replace-with-scope>
2647- Constraints: keep daemonless, repo-native, proof-gated
2648
2649## Required Constitution Links
2650- constitution/core/DECAPOD.md
2651- constitution/interfaces/CONTROL_PLANE.md
2652- constitution/specs/SECURITY.md
2653
2654## Proof Plan
2655- decapod validate
2656";
2657    write_stub(&todo_path, todo_stub, force, &mut created, &mut skipped)?;
2658
2659    let intent_path = project_root.join("INTENT.md");
2660    let intent_stub = "\
2661# INTENT
2662
2663## Problem
2664<what outcome is required>
2665
2666## Constraints
2667- daemonless
2668- repo-native canonical state
2669- deterministic reducers and proof gates
2670
2671## Acceptance Proofs
2672- decapod validate
2673";
2674    write_stub(&intent_path, intent_stub, force, &mut created, &mut skipped)?;
2675
2676    let handshake_path = project_root.join("HANDSHAKE.md");
2677    let handshake_stub = "\
2678# HANDSHAKE
2679
2680- Agent: <agent-id>
2681- Scope: <scope>
2682- Proofs: <proof-list>
2683- Record: `.decapod/records/handshakes/<latest>.json`
2684";
2685    write_stub(
2686        &handshake_path,
2687        handshake_stub,
2688        force,
2689        &mut created,
2690        &mut skipped,
2691    )?;
2692
2693    let artifact = build_handshake_artifact(project_root, scope, proofs)?;
2694    let artifact_path = write_handshake_artifact(project_root, &artifact)?;
2695
2696    println!(
2697        "{}",
2698        serde_json::json!({
2699            "cmd": "session.init",
2700            "status": "ok",
2701            "created": created,
2702            "skipped": skipped,
2703            "handshake_record": artifact_path,
2704            "template_refs": [
2705                "Embedded: templates now in Rust via template_agents(), template_named_agent(), template_readme()"
2706            ]
2707        })
2708    );
2709    Ok(())
2710}
2711
2712fn write_stub(
2713    path: &Path,
2714    content: &str,
2715    force: bool,
2716    created: &mut Vec<String>,
2717    skipped: &mut Vec<String>,
2718) -> Result<(), error::DecapodError> {
2719    if path.exists() && !force {
2720        skipped.push(path.display().to_string());
2721        return Ok(());
2722    }
2723    fs::write(path, content).map_err(error::DecapodError::IoError)?;
2724    created.push(path.display().to_string());
2725    Ok(())
2726}
2727
2728fn run_release_command(cli: ReleaseCli, project_root: &Path) -> Result<(), error::DecapodError> {
2729    match cli.command {
2730        ReleaseCommand::Check => run_release_check(project_root),
2731        ReleaseCommand::Inventory => run_release_inventory(project_root),
2732        ReleaseCommand::LineageSync => run_release_lineage_sync(project_root),
2733    }
2734}
2735
2736fn run_release_check(project_root: &Path) -> Result<(), error::DecapodError> {
2737    let mut failures = Vec::new();
2738    let mut lineage_records: Vec<(String, PolicyLineage)> = Vec::new();
2739    let mut changelog_raw: Option<String> = None;
2740    let changelog = project_root.join("CHANGELOG.md");
2741    let migrations = project_root
2742        .join("constitution")
2743        .join("docs")
2744        .join("MIGRATIONS.md");
2745    let cargo_lock = project_root.join("Cargo.lock");
2746    let cargo_toml = project_root.join("Cargo.toml");
2747    let rpc_golden_req = project_root.join("tests/golden/rpc/v1/agent_init.request.json");
2748    let rpc_golden_res = project_root.join("tests/golden/rpc/v1/agent_init.response.json");
2749    let artifact_manifest =
2750        project_root.join(".decapod/generated/artifacts/provenance/artifact_manifest.json");
2751    let proof_manifest =
2752        project_root.join(".decapod/generated/artifacts/provenance/proof_manifest.json");
2753    let intent_convergence_manifest = project_root
2754        .join(".decapod/generated/artifacts/provenance/intent_convergence_checklist.json");
2755
2756    if !changelog.exists() {
2757        failures.push("CHANGELOG.md missing".to_string());
2758    } else {
2759        let raw = fs::read_to_string(&changelog).map_err(error::DecapodError::IoError)?;
2760        changelog_raw = Some(raw.clone());
2761        if !raw.contains("## [Unreleased]") {
2762            failures.push("CHANGELOG.md missing `## [Unreleased]` section".to_string());
2763        }
2764    }
2765    if !migrations.exists() {
2766        failures.push("constitution/docs/MIGRATIONS.md missing".to_string());
2767    }
2768    if !cargo_lock.exists() {
2769        failures.push("Cargo.lock missing (locked builds required)".to_string());
2770    }
2771    if !cargo_toml.exists() {
2772        failures.push("Cargo.toml missing".to_string());
2773    }
2774    if !rpc_golden_req.exists() || !rpc_golden_res.exists() {
2775        failures.push("RPC golden vectors missing under tests/golden/rpc/v1".to_string());
2776    }
2777    if !artifact_manifest.exists() {
2778        failures.push(
2779            "artifact provenance manifest missing: .decapod/generated/artifacts/provenance/artifact_manifest.json"
2780                .to_string(),
2781        );
2782    }
2783    if !proof_manifest.exists() {
2784        failures.push(
2785            "proof provenance manifest missing: .decapod/generated/artifacts/provenance/proof_manifest.json"
2786                .to_string(),
2787        );
2788    }
2789    if !intent_convergence_manifest.exists() {
2790        failures.push(
2791            "intent convergence manifest missing: .decapod/generated/artifacts/provenance/intent_convergence_checklist.json"
2792                .to_string(),
2793        );
2794    }
2795    if artifact_manifest.exists() && proof_manifest.exists() && intent_convergence_manifest.exists()
2796    {
2797        match stamp_release_policy_lineage(
2798            project_root,
2799            [
2800                &artifact_manifest,
2801                &proof_manifest,
2802                &intent_convergence_manifest,
2803            ],
2804        ) {
2805            Ok(lineage) => lineage_records.push(("lineage stamp baseline".to_string(), lineage)),
2806            Err(e) => failures.push(format!("provenance lineage stamping failed: {}", e)),
2807        }
2808    }
2809    if artifact_manifest.exists() {
2810        match validate_artifact_manifest(project_root, &artifact_manifest) {
2811            Ok(lineage) => lineage_records.push(("artifact manifest".to_string(), lineage)),
2812            Err(e) => failures.push(format!("artifact manifest invalid: {}", e)),
2813        }
2814    }
2815    if proof_manifest.exists() {
2816        match validate_proof_manifest(project_root, &proof_manifest) {
2817            Ok(lineage) => lineage_records.push(("proof manifest".to_string(), lineage)),
2818            Err(e) => failures.push(format!("proof manifest invalid: {}", e)),
2819        }
2820    }
2821    if intent_convergence_manifest.exists() {
2822        match validate_intent_convergence_manifest(project_root, &intent_convergence_manifest) {
2823            Ok(lineage) => {
2824                lineage_records.push(("intent convergence manifest".to_string(), lineage))
2825            }
2826            Err(e) => failures.push(format!("intent convergence manifest invalid: {}", e)),
2827        }
2828    }
2829
2830    if let Some((baseline_name, baseline)) = lineage_records.first() {
2831        for (name, lineage) in lineage_records.iter().skip(1) {
2832            if lineage != baseline {
2833                failures.push(format!(
2834                    "policy lineage mismatch: '{}' differs from '{}' ({:?} != {:?})",
2835                    name, baseline_name, lineage, baseline
2836                ));
2837            }
2838        }
2839    }
2840
2841    let changed_paths = git_changed_paths(project_root);
2842    if has_schema_or_interface_changes(&changed_paths) {
2843        if let Some(changelog_text) = changelog_raw {
2844            if !changelog_mentions_schema_or_interface(&changelog_text) {
2845                failures.push(
2846                    "schema/interface files changed but CHANGELOG.md [Unreleased] has no schema/interface entry"
2847                        .to_string(),
2848                );
2849            }
2850        } else {
2851            failures.push(
2852                "schema/interface files changed but CHANGELOG.md could not be read".to_string(),
2853            );
2854        }
2855    }
2856
2857    if !failures.is_empty() {
2858        return Err(error::DecapodError::ValidationError(format!(
2859            "release.check failed:\n- {}",
2860            failures.join("\n- ")
2861        )));
2862    }
2863
2864    println!(
2865        "{}",
2866        serde_json::json!({
2867            "cmd": "release.check",
2868            "status": "ok",
2869            "checks": [
2870                "changelog.unreleased",
2871                "migrations.doc",
2872                "cargo.lock.present",
2873                "rpc.golden_vectors.present",
2874                "provenance.manifests.verified",
2875                "intent_convergence.manifest.verified",
2876                "schema_interface.changelog.policy"
2877            ]
2878        })
2879    );
2880    Ok(())
2881}
2882
2883fn run_release_inventory(project_root: &Path) -> Result<(), error::DecapodError> {
2884    let inventory = build_release_inventory(project_root)?;
2885    let out_dir = project_root
2886        .join(".decapod")
2887        .join("generated")
2888        .join("artifacts")
2889        .join("inventory");
2890    fs::create_dir_all(&out_dir).map_err(error::DecapodError::IoError)?;
2891    let out_path = out_dir.join("repo_inventory.json");
2892    let payload = serde_json::to_vec_pretty(&inventory).map_err(|e| {
2893        error::DecapodError::ValidationError(format!(
2894            "failed to serialize release inventory artifact: {e}"
2895        ))
2896    })?;
2897    fs::write(&out_path, payload).map_err(error::DecapodError::IoError)?;
2898
2899    println!(
2900        "{}",
2901        serde_json::json!({
2902            "cmd": "release.inventory",
2903            "status": "ok",
2904            "artifact": ".decapod/generated/artifacts/inventory/repo_inventory.json",
2905            "summary": inventory["totals"]
2906        })
2907    );
2908    Ok(())
2909}
2910
2911fn run_release_lineage_sync(project_root: &Path) -> Result<(), error::DecapodError> {
2912    let artifact_manifest =
2913        project_root.join(".decapod/generated/artifacts/provenance/artifact_manifest.json");
2914    let proof_manifest =
2915        project_root.join(".decapod/generated/artifacts/provenance/proof_manifest.json");
2916    let intent_convergence_manifest = project_root
2917        .join(".decapod/generated/artifacts/provenance/intent_convergence_checklist.json");
2918
2919    let mut missing = Vec::new();
2920    if !artifact_manifest.exists() {
2921        missing.push(".decapod/generated/artifacts/provenance/artifact_manifest.json");
2922    }
2923    if !proof_manifest.exists() {
2924        missing.push(".decapod/generated/artifacts/provenance/proof_manifest.json");
2925    }
2926    if !intent_convergence_manifest.exists() {
2927        missing.push(".decapod/generated/artifacts/provenance/intent_convergence_checklist.json");
2928    }
2929    if !missing.is_empty() {
2930        return Err(error::DecapodError::ValidationError(format!(
2931            "release.lineage_sync missing required provenance manifests: {}",
2932            missing.join(", ")
2933        )));
2934    }
2935
2936    let lineage = stamp_release_policy_lineage(
2937        project_root,
2938        [
2939            &artifact_manifest,
2940            &proof_manifest,
2941            &intent_convergence_manifest,
2942        ],
2943    )?;
2944    println!(
2945        "{}",
2946        serde_json::json!({
2947            "cmd": "release.lineage_sync",
2948            "status": "ok",
2949            "policy_lineage": {
2950                "policy_hash": lineage.policy_hash,
2951                "policy_revision": lineage.policy_revision,
2952                "risk_tier": lineage.risk_tier,
2953                "capsule_path": lineage.capsule_path,
2954                "capsule_hash": lineage.capsule_hash
2955            },
2956            "manifests": [
2957                ".decapod/generated/artifacts/provenance/artifact_manifest.json",
2958                ".decapod/generated/artifacts/provenance/proof_manifest.json",
2959                ".decapod/generated/artifacts/provenance/intent_convergence_checklist.json"
2960            ]
2961        })
2962    );
2963    Ok(())
2964}
2965
2966fn sha256_file(path: &Path) -> Result<String, error::DecapodError> {
2967    let bytes = fs::read(path).map_err(error::DecapodError::IoError)?;
2968    let mut hasher = Sha256::new();
2969    hasher.update(bytes);
2970    Ok(format!("{:x}", hasher.finalize()))
2971}
2972
2973fn sha256_text(input: &str) -> String {
2974    let mut hasher = Sha256::new();
2975    hasher.update(input.as_bytes());
2976    format!("{:x}", hasher.finalize())
2977}
2978
2979#[derive(Debug, Clone, PartialEq, Eq)]
2980struct PolicyLineage {
2981    policy_hash: String,
2982    policy_revision: String,
2983    risk_tier: String,
2984    capsule_path: String,
2985    capsule_hash: String,
2986}
2987
2988fn resolve_release_risk_tier() -> Result<String, error::DecapodError> {
2989    let tier = std::env::var("DECAPOD_RELEASE_RISK_TIER").unwrap_or_else(|_| "medium".to_string());
2990    let normalized = tier.trim().to_ascii_lowercase();
2991    if !matches!(normalized.as_str(), "low" | "medium" | "high" | "critical") {
2992        return Err(error::DecapodError::ValidationError(format!(
2993            "invalid DECAPOD_RELEASE_RISK_TIER '{}': expected low|medium|high|critical",
2994            tier
2995        )));
2996    }
2997    Ok(normalized)
2998}
2999
3000fn resolve_release_capsule(project_root: &Path) -> Result<(String, String), error::DecapodError> {
3001    let fallback = core::context_capsule::query_embedded_capsule(
3002        project_root,
3003        "release provenance",
3004        "interfaces",
3005        Some("R_releasecheck"),
3006        None,
3007        8,
3008    )?;
3009    let fallback_path = core::context_capsule::context_capsule_path(project_root, &fallback);
3010    let capsule = if fallback_path.exists() {
3011        let raw = fs::read_to_string(&fallback_path).map_err(error::DecapodError::IoError)?;
3012        // If the on-disk capsule is empty (e.g. after a shallow checkout or
3013        // force-push that left a zero-length tracked file), fall through to
3014        // the freshly-generated capsule instead of failing with a parse error.
3015        if raw.trim().is_empty() {
3016            fallback
3017        } else {
3018            let parsed: core::context_capsule::DeterministicContextCapsule =
3019                serde_json::from_str(&raw).map_err(|e| {
3020                    error::DecapodError::ValidationError(format!(
3021                        "invalid release capsule JSON at '{}': {}",
3022                        fallback_path.display(),
3023                        e
3024                    ))
3025                })?;
3026            parsed.with_recomputed_hash().map_err(|e| {
3027                error::DecapodError::ValidationError(format!(
3028                    "failed to recompute release capsule hash at '{}': {}",
3029                    fallback_path.display(),
3030                    e
3031                ))
3032            })?
3033        }
3034    } else {
3035        fallback
3036    };
3037    let path = core::context_capsule::write_context_capsule(project_root, &capsule)?;
3038    let rel_path = path
3039        .strip_prefix(project_root)
3040        .map_err(|_| {
3041            error::DecapodError::ValidationError(format!(
3042                "release capsule path '{}' is outside project root",
3043                path.display()
3044            ))
3045        })?
3046        .to_string_lossy()
3047        .replace('\\', "/");
3048    Ok((rel_path, capsule.capsule_hash))
3049}
3050
3051fn stamp_release_policy_lineage<const N: usize>(
3052    project_root: &Path,
3053    manifest_paths: [&Path; N],
3054) -> Result<PolicyLineage, error::DecapodError> {
3055    let policy_revision = "policy.release@v1".to_string();
3056    let risk_tier = resolve_release_risk_tier()?;
3057    let (capsule_path, capsule_hash) = resolve_release_capsule(project_root)?;
3058    let policy_hash = sha256_text(&format!(
3059        "{}|{}|{}",
3060        policy_revision, risk_tier, capsule_hash
3061    ));
3062    let lineage_json = serde_json::json!({
3063        "policy_hash": policy_hash,
3064        "policy_revision": policy_revision,
3065        "risk_tier": risk_tier,
3066        "capsule_path": capsule_path,
3067        "capsule_hash": capsule_hash
3068    });
3069
3070    for manifest_path in manifest_paths {
3071        let raw = fs::read_to_string(manifest_path).map_err(error::DecapodError::IoError)?;
3072        let mut v: serde_json::Value = serde_json::from_str(&raw).map_err(|e| {
3073            error::DecapodError::ValidationError(format!(
3074                "failed to parse JSON manifest '{}': {}",
3075                manifest_path.display(),
3076                e
3077            ))
3078        })?;
3079        let obj = v.as_object_mut().ok_or_else(|| {
3080            error::DecapodError::ValidationError(format!(
3081                "manifest '{}' must be a JSON object",
3082                manifest_path.display()
3083            ))
3084        })?;
3085        obj.insert("policy_lineage".to_string(), lineage_json.clone());
3086        let updated = serde_json::to_vec_pretty(&v).map_err(|e| {
3087            error::DecapodError::ValidationError(format!(
3088                "failed to serialize stamped manifest '{}': {}",
3089                manifest_path.display(),
3090                e
3091            ))
3092        })?;
3093        fs::write(manifest_path, updated).map_err(error::DecapodError::IoError)?;
3094    }
3095
3096    Ok(PolicyLineage {
3097        policy_hash: lineage_json["policy_hash"]
3098            .as_str()
3099            .unwrap_or("")
3100            .to_string(),
3101        policy_revision: lineage_json["policy_revision"]
3102            .as_str()
3103            .unwrap_or("")
3104            .to_string(),
3105        risk_tier: lineage_json["risk_tier"].as_str().unwrap_or("").to_string(),
3106        capsule_path: lineage_json["capsule_path"]
3107            .as_str()
3108            .unwrap_or("")
3109            .to_string(),
3110        capsule_hash: lineage_json["capsule_hash"]
3111            .as_str()
3112            .unwrap_or("")
3113            .to_string(),
3114    })
3115}
3116
3117fn validate_policy_lineage(
3118    project_root: &Path,
3119    v: &serde_json::Value,
3120    manifest_label: &str,
3121) -> Result<PolicyLineage, error::DecapodError> {
3122    let lineage = v
3123        .get("policy_lineage")
3124        .and_then(|x| x.as_object())
3125        .ok_or_else(|| {
3126            error::DecapodError::ValidationError(format!(
3127                "{manifest_label} missing policy_lineage object"
3128            ))
3129        })?;
3130
3131    let required = [
3132        "policy_hash",
3133        "policy_revision",
3134        "risk_tier",
3135        "capsule_path",
3136        "capsule_hash",
3137    ];
3138    for key in required {
3139        let value = lineage.get(key).and_then(|x| x.as_str()).unwrap_or("");
3140        if value.is_empty() || value.contains("TO_BE_FILLED") {
3141            return Err(error::DecapodError::ValidationError(format!(
3142                "{manifest_label} policy_lineage.{key} must be non-empty and non-placeholder"
3143            )));
3144        }
3145    }
3146
3147    let policy_hash = lineage
3148        .get("policy_hash")
3149        .and_then(|x| x.as_str())
3150        .unwrap_or("");
3151    if policy_hash.len() != 64 || !policy_hash.chars().all(|c| c.is_ascii_hexdigit()) {
3152        return Err(error::DecapodError::ValidationError(format!(
3153            "{manifest_label} policy_lineage.policy_hash must be a 64-char hex digest"
3154        )));
3155    }
3156
3157    let capsule_hash = lineage
3158        .get("capsule_hash")
3159        .and_then(|x| x.as_str())
3160        .unwrap_or("");
3161    if capsule_hash.len() != 64 || !capsule_hash.chars().all(|c| c.is_ascii_hexdigit()) {
3162        return Err(error::DecapodError::ValidationError(format!(
3163            "{manifest_label} policy_lineage.capsule_hash must be a 64-char hex digest"
3164        )));
3165    }
3166
3167    let risk_tier = lineage
3168        .get("risk_tier")
3169        .and_then(|x| x.as_str())
3170        .unwrap_or("");
3171    if !matches!(risk_tier, "low" | "medium" | "high" | "critical") {
3172        return Err(error::DecapodError::ValidationError(format!(
3173            "{manifest_label} policy_lineage.risk_tier invalid: expected low|medium|high|critical"
3174        )));
3175    }
3176
3177    let capsule_path = lineage
3178        .get("capsule_path")
3179        .and_then(|x| x.as_str())
3180        .unwrap_or("");
3181    let abs = project_root.join(capsule_path);
3182    if !abs.exists() {
3183        return Err(error::DecapodError::ValidationError(format!(
3184            "{manifest_label} policy_lineage.capsule_path '{}' does not exist",
3185            capsule_path
3186        )));
3187    }
3188
3189    let raw_capsule = fs::read_to_string(&abs).map_err(error::DecapodError::IoError)?;
3190    // If the on-disk capsule is empty (e.g. shallow checkout or force-push
3191    // left a zero-length tracked file), skip integrity checks — the capsule
3192    // will be regenerated on the next resolve pass.
3193    if !raw_capsule.trim().is_empty() {
3194        let parsed: core::context_capsule::DeterministicContextCapsule =
3195            serde_json::from_str(&raw_capsule).map_err(|e| {
3196                error::DecapodError::ValidationError(format!(
3197                    "{manifest_label} policy_lineage capsule at '{}' is not valid deterministic capsule JSON: {}",
3198                    capsule_path, e
3199                ))
3200            })?;
3201        let normalized = parsed.with_recomputed_hash().map_err(|e| {
3202            error::DecapodError::ValidationError(format!(
3203                "{manifest_label} policy_lineage capsule hash computation failed for '{}': {}",
3204                capsule_path, e
3205            ))
3206        })?;
3207
3208        if parsed.capsule_hash != normalized.capsule_hash {
3209            return Err(error::DecapodError::ValidationError(format!(
3210                "{manifest_label} policy_lineage capsule file '{}' has internal hash mismatch",
3211                capsule_path
3212            )));
3213        }
3214        if capsule_hash != normalized.capsule_hash {
3215            return Err(error::DecapodError::ValidationError(format!(
3216                "{manifest_label} policy_lineage capsule_hash mismatch for '{}'",
3217                capsule_path
3218            )));
3219        }
3220    }
3221
3222    Ok(PolicyLineage {
3223        policy_hash: policy_hash.to_string(),
3224        policy_revision: lineage
3225            .get("policy_revision")
3226            .and_then(|x| x.as_str())
3227            .unwrap_or("")
3228            .to_string(),
3229        risk_tier: risk_tier.to_string(),
3230        capsule_path: capsule_path.to_string(),
3231        capsule_hash: capsule_hash.to_string(),
3232    })
3233}
3234
3235fn validate_artifact_manifest(
3236    project_root: &Path,
3237    manifest_path: &Path,
3238) -> Result<PolicyLineage, error::DecapodError> {
3239    let raw = fs::read_to_string(manifest_path).map_err(error::DecapodError::IoError)?;
3240    let v: serde_json::Value = serde_json::from_str(&raw).map_err(|e| {
3241        error::DecapodError::ValidationError(format!("artifact manifest is not valid JSON: {e}"))
3242    })?;
3243    if v.get("schema_version").and_then(|x| x.as_str()) != Some("1.0.0") {
3244        return Err(error::DecapodError::ValidationError(
3245            "artifact manifest schema_version must be 1.0.0".to_string(),
3246        ));
3247    }
3248    if v.get("kind").and_then(|x| x.as_str()) != Some("artifact_manifest") {
3249        return Err(error::DecapodError::ValidationError(
3250            "artifact manifest kind must be artifact_manifest".to_string(),
3251        ));
3252    }
3253    let lineage = validate_policy_lineage(project_root, &v, "artifact manifest")?;
3254
3255    let artifacts = v
3256        .get("artifacts")
3257        .and_then(|x| x.as_array())
3258        .ok_or_else(|| {
3259            error::DecapodError::ValidationError(
3260                "artifact manifest artifacts[] required".to_string(),
3261            )
3262        })?;
3263    if artifacts.is_empty() {
3264        return Err(error::DecapodError::ValidationError(
3265            "artifact manifest artifacts[] must not be empty".to_string(),
3266        ));
3267    }
3268
3269    for entry in artifacts {
3270        let path = entry.get("path").and_then(|x| x.as_str()).ok_or_else(|| {
3271            error::DecapodError::ValidationError("artifact entry missing path".to_string())
3272        })?;
3273        let sha = entry
3274            .get("sha256")
3275            .and_then(|x| x.as_str())
3276            .ok_or_else(|| {
3277                error::DecapodError::ValidationError("artifact entry missing sha256".to_string())
3278            })?;
3279        if sha.is_empty() || sha.contains("TO_BE_FILLED") {
3280            return Err(error::DecapodError::ValidationError(format!(
3281                "artifact entry '{}' has placeholder sha256",
3282                path
3283            )));
3284        }
3285        let abs = project_root.join(path);
3286        if !abs.exists() {
3287            return Err(error::DecapodError::ValidationError(format!(
3288                "artifact entry '{}' does not exist",
3289                path
3290            )));
3291        }
3292        let actual = sha256_file(&abs)?;
3293        if actual != sha {
3294            return Err(error::DecapodError::ValidationError(format!(
3295                "artifact entry '{}' sha256 mismatch",
3296                path
3297            )));
3298        }
3299    }
3300    Ok(lineage)
3301}
3302
3303fn validate_proof_manifest(
3304    project_root: &Path,
3305    manifest_path: &Path,
3306) -> Result<PolicyLineage, error::DecapodError> {
3307    let raw = fs::read_to_string(manifest_path).map_err(error::DecapodError::IoError)?;
3308    let v: serde_json::Value = serde_json::from_str(&raw).map_err(|e| {
3309        error::DecapodError::ValidationError(format!("proof manifest is not valid JSON: {e}"))
3310    })?;
3311    if v.get("schema_version").and_then(|x| x.as_str()) != Some("1.0.0") {
3312        return Err(error::DecapodError::ValidationError(
3313            "proof manifest schema_version must be 1.0.0".to_string(),
3314        ));
3315    }
3316    if v.get("kind").and_then(|x| x.as_str()) != Some("proof_manifest") {
3317        return Err(error::DecapodError::ValidationError(
3318            "proof manifest kind must be proof_manifest".to_string(),
3319        ));
3320    }
3321    let lineage = validate_policy_lineage(project_root, &v, "proof manifest")?;
3322    let proofs = v.get("proofs").and_then(|x| x.as_array()).ok_or_else(|| {
3323        error::DecapodError::ValidationError("proof manifest proofs[] required".to_string())
3324    })?;
3325    if proofs.is_empty() {
3326        return Err(error::DecapodError::ValidationError(
3327            "proof manifest proofs[] must not be empty".to_string(),
3328        ));
3329    }
3330    for p in proofs {
3331        let command = p.get("command").and_then(|x| x.as_str()).unwrap_or("");
3332        let result = p.get("result").and_then(|x| x.as_str()).unwrap_or("");
3333        if command.is_empty() || command.contains("TO_BE_FILLED") {
3334            return Err(error::DecapodError::ValidationError(
3335                "proof manifest command must be non-empty and non-placeholder".to_string(),
3336            ));
3337        }
3338        if result.is_empty() || result.contains("TO_BE_FILLED") {
3339            return Err(error::DecapodError::ValidationError(
3340                "proof manifest result must be non-empty and non-placeholder".to_string(),
3341            ));
3342        }
3343    }
3344    let env = v
3345        .get("environment")
3346        .and_then(|x| x.as_object())
3347        .ok_or_else(|| {
3348            error::DecapodError::ValidationError("proof manifest environment required".to_string())
3349        })?;
3350    for key in ["os", "rust"] {
3351        let value = env.get(key).and_then(|x| x.as_str()).unwrap_or("");
3352        if value.is_empty() || value.contains("TO_BE_FILLED") {
3353            return Err(error::DecapodError::ValidationError(format!(
3354                "proof manifest environment.{} must be non-empty and non-placeholder",
3355                key
3356            )));
3357        }
3358    }
3359    Ok(lineage)
3360}
3361
3362fn validate_intent_convergence_manifest(
3363    project_root: &Path,
3364    manifest_path: &Path,
3365) -> Result<PolicyLineage, error::DecapodError> {
3366    let raw = fs::read_to_string(manifest_path).map_err(error::DecapodError::IoError)?;
3367    let v: serde_json::Value = serde_json::from_str(&raw).map_err(|e| {
3368        error::DecapodError::ValidationError(format!(
3369            "intent convergence manifest is not valid JSON: {e}"
3370        ))
3371    })?;
3372    if v.get("schema_version").and_then(|x| x.as_str()) != Some("1.0.0") {
3373        return Err(error::DecapodError::ValidationError(
3374            "intent convergence manifest schema_version must be 1.0.0".to_string(),
3375        ));
3376    }
3377    if v.get("kind").and_then(|x| x.as_str()) != Some("intent_convergence_checklist") {
3378        return Err(error::DecapodError::ValidationError(
3379            "intent convergence manifest kind must be intent_convergence_checklist".to_string(),
3380        ));
3381    }
3382    let lineage = validate_policy_lineage(project_root, &v, "intent convergence manifest")?;
3383
3384    for key in ["pr", "intent", "scope", "checklist"] {
3385        if v.get(key).is_none() {
3386            return Err(error::DecapodError::ValidationError(format!(
3387                "intent convergence manifest missing '{}' field",
3388                key
3389            )));
3390        }
3391    }
3392
3393    let checklist = v
3394        .get("checklist")
3395        .and_then(|x| x.as_array())
3396        .ok_or_else(|| {
3397            error::DecapodError::ValidationError(
3398                "intent convergence manifest checklist[] required".to_string(),
3399            )
3400        })?;
3401    if checklist.is_empty() {
3402        return Err(error::DecapodError::ValidationError(
3403            "intent convergence manifest checklist[] must not be empty".to_string(),
3404        ));
3405    }
3406
3407    for item in checklist {
3408        let name = item.get("name").and_then(|x| x.as_str()).unwrap_or("");
3409        let status = item.get("status").and_then(|x| x.as_str()).unwrap_or("");
3410        let evidence = item.get("evidence").and_then(|x| x.as_str()).unwrap_or("");
3411        if name.is_empty() || status.is_empty() || evidence.is_empty() {
3412            return Err(error::DecapodError::ValidationError(
3413                "intent convergence checklist entries require name/status/evidence".to_string(),
3414            ));
3415        }
3416        if matches!(status, "pending" | "unknown") {
3417            return Err(error::DecapodError::ValidationError(format!(
3418                "intent convergence checklist item '{}' must be resolved (status={})",
3419                name, status
3420            )));
3421        }
3422    }
3423    Ok(lineage)
3424}
3425
3426fn build_release_inventory(project_root: &Path) -> Result<serde_json::Value, error::DecapodError> {
3427    let mut paths = Vec::new();
3428    for root in ["src", "tests", "constitution"] {
3429        collect_files_recursive(&project_root.join(root), &mut paths)?;
3430    }
3431    paths.sort();
3432
3433    let mut top_files = Vec::new();
3434    let mut totals_by_root: BTreeMap<&'static str, u64> = BTreeMap::new();
3435    let mut rust_files = 0u64;
3436    let mut test_files = 0u64;
3437
3438    for path in paths {
3439        let rel = match path.strip_prefix(project_root) {
3440            Ok(p) => p.to_path_buf(),
3441            Err(_) => continue,
3442        };
3443        let rel_s = rel.to_string_lossy().replace('\\', "/");
3444        let raw = fs::read_to_string(&path).unwrap_or_default();
3445        let loc = raw.lines().count() as u64;
3446        if rel_s.starts_with("src/") {
3447            *totals_by_root.entry("src_loc").or_insert(0) += loc;
3448        } else if rel_s.starts_with("tests/") {
3449            *totals_by_root.entry("tests_loc").or_insert(0) += loc;
3450        } else if rel_s.starts_with("constitution/") {
3451            *totals_by_root.entry("constitution_loc").or_insert(0) += loc;
3452        }
3453        if rel_s.ends_with(".rs") {
3454            rust_files += 1;
3455        }
3456        if rel_s.starts_with("tests/") {
3457            test_files += 1;
3458        }
3459        top_files.push((rel_s, loc));
3460    }
3461
3462    top_files.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
3463    let top_files: Vec<serde_json::Value> = top_files
3464        .into_iter()
3465        .take(25)
3466        .map(|(path, loc)| serde_json::json!({ "path": path, "loc": loc }))
3467        .collect();
3468
3469    let src_loc = *totals_by_root.get("src_loc").unwrap_or(&0);
3470    let tests_loc = *totals_by_root.get("tests_loc").unwrap_or(&0);
3471    let constitution_loc = *totals_by_root.get("constitution_loc").unwrap_or(&0);
3472
3473    Ok(serde_json::json!({
3474        "schema_version": "1.0.0",
3475        "kind": "repo_inventory",
3476        "scope": ["src", "tests", "constitution"],
3477        "totals": {
3478            "src_loc": src_loc,
3479            "tests_loc": tests_loc,
3480            "constitution_loc": constitution_loc,
3481            "total_loc": src_loc + tests_loc + constitution_loc,
3482            "rust_files": rust_files,
3483            "test_files": test_files
3484        },
3485        "top_files_by_loc": top_files
3486    }))
3487}
3488
3489fn collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) -> Result<(), error::DecapodError> {
3490    if !root.exists() {
3491        return Ok(());
3492    }
3493    for entry in fs::read_dir(root).map_err(error::DecapodError::IoError)? {
3494        let entry = entry.map_err(error::DecapodError::IoError)?;
3495        let path = entry.path();
3496        if path.is_dir() {
3497            collect_files_recursive(&path, out)?;
3498        } else if path.is_file() {
3499            out.push(path);
3500        }
3501    }
3502    Ok(())
3503}
3504
3505fn git_changed_paths(project_root: &Path) -> Vec<String> {
3506    let output = std::process::Command::new("git")
3507        .current_dir(project_root)
3508        .args(["status", "--porcelain"])
3509        .output();
3510    let Ok(output) = output else {
3511        return Vec::new();
3512    };
3513    if !output.status.success() {
3514        return Vec::new();
3515    }
3516    let raw = String::from_utf8_lossy(&output.stdout);
3517    let mut paths = Vec::new();
3518    for line in raw.lines() {
3519        if line.len() < 4 {
3520            continue;
3521        }
3522        let candidate = line[3..].trim();
3523        if let Some((_, to)) = candidate.split_once(" -> ") {
3524            paths.push(to.trim().to_string());
3525        } else {
3526            paths.push(candidate.to_string());
3527        }
3528    }
3529    paths
3530}
3531
3532fn has_schema_or_interface_changes(paths: &[String]) -> bool {
3533    paths.iter().any(|path| {
3534        path.starts_with("constitution/interfaces/")
3535            || path == "src/core/schemas.rs"
3536            || path == "src/core/rpc.rs"
3537            || path.starts_with("tests/golden/rpc/")
3538    })
3539}
3540
3541fn changelog_mentions_schema_or_interface(changelog_raw: &str) -> bool {
3542    let lower = changelog_raw.to_ascii_lowercase();
3543    let Some(start) = lower.find("## [unreleased]") else {
3544        return false;
3545    };
3546    let section = &lower[start..];
3547    let next_heading = section[14..]
3548        .find("\n## ")
3549        .map(|idx| idx + 14)
3550        .unwrap_or(section.len());
3551    let unreleased = &section[..next_heading];
3552    unreleased.contains("schema") || unreleased.contains("interface")
3553}
3554
3555#[derive(Debug, Clone, Serialize)]
3556struct ValidationHealAction {
3557    action: String,
3558    outcome: String,
3559    detail: String,
3560}
3561
3562fn should_scaffold_validation_surfaces(project_root: &Path) -> bool {
3563    let required = [
3564        "AGENTS.md",
3565        ".decapod/README.md",
3566        ".decapod/generated/Dockerfile",
3567        ".decapod/generated/specs/README.md",
3568        ".decapod/generated/specs/INTENT.md",
3569        ".decapod/generated/specs/ARCHITECTURE.md",
3570        ".decapod/generated/specs/INTERFACES.md",
3571        ".decapod/generated/specs/VALIDATION.md",
3572        ".decapod/generated/specs/.manifest.json",
3573        ".decapod/generated/policy/context_capsule_policy.json",
3574    ];
3575    required.iter().any(|rel| !project_root.join(rel).exists())
3576}
3577
3578fn heal_agents_contract(
3579    project_root: &Path,
3580) -> Result<Option<ValidationHealAction>, error::DecapodError> {
3581    let path = project_root.join("AGENTS.md");
3582    if !path.exists() {
3583        let content = core::assets::get_template("AGENTS.md").ok_or_else(|| {
3584            error::DecapodError::ValidationError("Missing AGENTS.md template".to_string())
3585        })?;
3586        atomic_write_file(&path, &content)?;
3587        return Ok(Some(ValidationHealAction {
3588            action: "heal_agents_contract".to_string(),
3589            outcome: "recreated".to_string(),
3590            detail: "Restored missing AGENTS.md from the canonical Decapod template.".to_string(),
3591        }));
3592    }
3593
3594    let mut content = fs::read_to_string(&path).map_err(error::DecapodError::IoError)?;
3595    let mut anchors = Vec::new();
3596    for marker in [
3597        "stop if",
3598        "via decapod CLI",
3599        "Interface abstraction boundary",
3600        "Strict Dependency: You are strictly bound to the Decapod control plane",
3601    ] {
3602        if !content.contains(marker) {
3603            anchors.push(marker);
3604        }
3605    }
3606    if anchors.is_empty() {
3607        return Ok(None);
3608    }
3609
3610    content.push_str("\n\n<!-- decapod-validator-anchors\n");
3611    for anchor in &anchors {
3612        content.push_str(anchor);
3613        content.push('\n');
3614    }
3615    content.push_str("-->\n");
3616    atomic_write_file(&path, &content)?;
3617    Ok(Some(ValidationHealAction {
3618        action: "heal_agents_contract".to_string(),
3619        outcome: "updated".to_string(),
3620        detail: format!(
3621            "Added {} missing validator anchor(s) to AGENTS.md.",
3622            anchors.len()
3623        ),
3624    }))
3625}
3626
3627fn heal_validation_scaffold(
3628    project_root: &Path,
3629) -> Result<Option<ValidationHealAction>, error::DecapodError> {
3630    if !should_scaffold_validation_surfaces(project_root) {
3631        return Ok(None);
3632    }
3633
3634    let repo_ctx = infer_repo_context(project_root);
3635    let summary = scaffold::scaffold_project_entrypoints(&scaffold::ScaffoldOptions {
3636        target_dir: project_root.to_path_buf(),
3637        force: false,
3638        dry_run: false,
3639        agent_files: Vec::new(),
3640        created_backups: false,
3641        all: false,
3642        generate_specs: true,
3643        diagram_style: scaffold::DiagramStyle::Ascii,
3644        specs_seed: Some(scaffold::SpecsSeed {
3645            product_name: repo_ctx.product_name,
3646            product_summary: repo_ctx.product_summary,
3647            architecture_direction: repo_ctx.architecture_direction,
3648            product_type: repo_ctx.product_type,
3649            primary_languages: repo_ctx.primary_languages,
3650            detected_surfaces: repo_ctx.detected_surfaces,
3651            done_criteria: repo_ctx.done_criteria,
3652        }),
3653    })?;
3654
3655    Ok(Some(ValidationHealAction {
3656        action: "heal_validation_scaffold".to_string(),
3657        outcome: "updated".to_string(),
3658        detail: format!(
3659            "Scaffolded missing validation surfaces (entrypoints_created={}, config_created={}, specs_created={}).",
3660            summary.entrypoints_created, summary.config_created, summary.specs_created
3661        ),
3662    }))
3663}
3664
3665fn heal_override_checksum(
3666    project_root: &Path,
3667) -> Result<Option<ValidationHealAction>, error::DecapodError> {
3668    match docs_cli::sync_override_checksum(project_root, false)? {
3669        docs_cli::OverrideChecksumStatus::MissingOverride
3670        | docs_cli::OverrideChecksumStatus::Unchanged => Ok(None),
3671        docs_cli::OverrideChecksumStatus::Cached => Ok(Some(ValidationHealAction {
3672            action: "heal_override_checksum".to_string(),
3673            outcome: "cached".to_string(),
3674            detail: "Cached OVERRIDE.md checksum for deterministic governance reads.".to_string(),
3675        })),
3676        docs_cli::OverrideChecksumStatus::Updated => Ok(Some(ValidationHealAction {
3677            action: "heal_override_checksum".to_string(),
3678            outcome: "refreshed".to_string(),
3679            detail: "Refreshed OVERRIDE.md checksum after local override drift.".to_string(),
3680        })),
3681    }
3682}
3683
3684fn heal_container_runtime_override(
3685    project_root: &Path,
3686) -> Result<Option<ValidationHealAction>, error::DecapodError> {
3687    match container::heal_container_runtime_override(project_root)? {
3688        container::ContainerRuntimeOverrideHeal::Cleared => Ok(Some(ValidationHealAction {
3689            action: "heal_container_runtime_override".to_string(),
3690            outcome: "cleared".to_string(),
3691            detail: "Removed stale container-runtime override because Docker/Podman support is available.".to_string(),
3692        })),
3693        container::ContainerRuntimeOverrideHeal::Unchanged => Ok(None),
3694    }
3695}
3696
3697fn attempt_validation_failure_heal(
3698    report: &validate::ValidationReport,
3699    project_root: &Path,
3700    store: &Store,
3701) -> Result<Vec<ValidationHealAction>, error::DecapodError> {
3702    let mut actions = Vec::new();
3703
3704    if report.failures.iter().any(|msg| {
3705        msg.contains("Repo store missing todo.db")
3706            || msg.contains("Repo todo.db does NOT match rebuild from todo.events.jsonl")
3707    }) {
3708        let rebuild = todo::rebuild_from_events(&store.root)?;
3709        actions.push(ValidationHealAction {
3710            action: "todo.rebuild".to_string(),
3711            outcome: "repaired".to_string(),
3712            detail: format!("Rebuilt todo.db from event log: {}", rebuild),
3713        });
3714    }
3715
3716    if report
3717        .failures
3718        .iter()
3719        .any(|msg| msg.contains("AGENTS.md missing") || msg.contains("Invariant missing:"))
3720        && let Some(action) = heal_agents_contract(project_root)?
3721    {
3722        actions.push(action);
3723    }
3724
3725    if report.failures.iter().any(|msg| {
3726        msg.contains("Missing required project specs file:")
3727            || msg.contains("Context capsule policy schema mismatch")
3728    }) && let Some(action) = heal_validation_scaffold(project_root)?
3729    {
3730        actions.push(action);
3731    }
3732
3733    if report
3734        .failures
3735        .iter()
3736        .any(|msg| msg.contains("claim.git.container_workspace_required"))
3737        && let Some(action) = heal_container_runtime_override(project_root)?
3738    {
3739        actions.push(action);
3740    }
3741
3742    Ok(actions)
3743}
3744
3745fn render_validation_text(
3746    report: &validate::ValidationReport,
3747    actions: &[ValidationHealAction],
3748    verbose: bool,
3749) {
3750    use crate::core::ansi::AnsiExt;
3751
3752    validate::render_validation_report(report, verbose);
3753    if !actions.is_empty() {
3754        if verbose {
3755            println!(
3756                "  {} {}",
3757                "repair".bright_blue().bold(),
3758                format!("{} action(s)", actions.len()).bright_white()
3759            );
3760            for action in actions {
3761                println!(
3762                    "  {} {} {}",
3763                    "↺".bright_blue(),
3764                    action.action.bright_cyan(),
3765                    action.detail
3766                );
3767            }
3768        } else {
3769            println!(
3770                "  {} {} action(s) applied; use `-v` for repair details",
3771                "repair".bright_blue().bold(),
3772                actions.len().to_string().bright_white()
3773            );
3774        }
3775    }
3776}
3777
3778fn run_validate_command(
3779    validate_cli: ValidateCli,
3780    project_root: &Path,
3781    project_store: &Store,
3782) -> Result<(), error::DecapodError> {
3783    use crate::core::workspace;
3784
3785    if std::env::var("DECAPOD_VALIDATE_SKIP_GIT_GATES").is_ok() {
3786        // Skip workspace check if gates are explicitly skipped
3787    } else {
3788        // FIRST: Check workspace enforcement (non-negotiable)
3789        let workspace_status = workspace::get_workspace_status(project_root)?;
3790
3791        if !workspace_status.can_work {
3792            let blocker = workspace_status
3793                .blockers
3794                .first()
3795                .expect("Workspace should have a blocker if can_work is false");
3796
3797            let response = serde_json::json!({
3798                "success": false,
3799                "gate": "workspace_protection",
3800                "error": blocker.message,
3801                "resolve_hint": blocker.resolve_hint,
3802                "branch": workspace_status.git.current_branch,
3803                "is_protected": workspace_status.git.is_protected,
3804                "in_container": workspace_status.container.in_container,
3805            });
3806
3807            if validate_cli.format == "json" {
3808                println!("{}", serde_json::to_string_pretty(&response).unwrap());
3809            } else {
3810                eprintln!("validation needs attention: workspace protection");
3811                eprintln!("  branch: {}", workspace_status.git.current_branch);
3812                eprintln!("  reason: {}", blocker.message);
3813                eprintln!("  next: {}", blocker.resolve_hint);
3814            }
3815
3816            std::process::exit(1);
3817        }
3818    }
3819
3820    let decapod_root = project_root.to_path_buf();
3821    let store = match validate_cli.store.as_str() {
3822        "user" => {
3823            // User store uses a temp directory for blank-slate validation
3824            let tmp_root = std::env::temp_dir().join(format!(
3825                "decapod_validate_user_{}",
3826                crate::core::ulid::new_ulid()
3827            ));
3828            std::fs::create_dir_all(&tmp_root).map_err(error::DecapodError::IoError)?;
3829            Store {
3830                kind: StoreKind::User,
3831                root: tmp_root,
3832            }
3833        }
3834        _ => project_store.clone(),
3835    };
3836
3837    let mut heal_actions = Vec::new();
3838    if let Some(action) = heal_override_checksum(project_root)? {
3839        heal_actions.push(action);
3840    }
3841    if let Some(action) = heal_validation_scaffold(project_root)? {
3842        heal_actions.push(action);
3843    }
3844    if let Some(action) = heal_agents_contract(project_root)? {
3845        heal_actions.push(action);
3846    }
3847    if let Some(action) = heal_container_runtime_override(project_root)? {
3848        heal_actions.push(action);
3849    }
3850
3851    let mut report = run_validation_bounded(&store, &decapod_root, validate_cli.verbose)?;
3852    for _ in 0..2 {
3853        if report.fail_count == 0 {
3854            break;
3855        }
3856        let mut round_actions = attempt_validation_failure_heal(&report, project_root, &store)?;
3857        if round_actions.is_empty() {
3858            break;
3859        }
3860        heal_actions.append(&mut round_actions);
3861        report = run_validation_bounded(&store, &decapod_root, validate_cli.verbose)?;
3862    }
3863
3864    if validate_cli.format == "json" {
3865        println!(
3866            "{}",
3867            serde_json::to_string_pretty(&serde_json::json!({
3868                "status": report.status,
3869                "self_heal": heal_actions,
3870                "report": report,
3871            }))
3872            .map_err(|e| error::DecapodError::ValidationError(format!(
3873                "validate JSON encode failed: {e}"
3874            )))?,
3875        );
3876    } else {
3877        render_validation_text(&report, &heal_actions, validate_cli.verbose);
3878    }
3879
3880    if report.fail_count > 0 {
3881        std::process::exit(1);
3882    }
3883    mark_validation_completed(project_root)?;
3884    Ok(())
3885}
3886
3887fn validate_timeout_secs() -> u64 {
3888    std::env::var("DECAPOD_VALIDATE_TIMEOUT_SECS")
3889        .ok()
3890        .or_else(|| std::env::var("DECAPOD_VALIDATE_TIMEOUT_SECONDS").ok())
3891        .and_then(|v| v.parse::<u64>().ok())
3892        .filter(|v| *v > 0)
3893        .unwrap_or(120)
3894}
3895
3896fn validate_diagnostics_enabled() -> bool {
3897    std::env::var("DECAPOD_DIAGNOSTICS")
3898        .ok()
3899        .map(|v| matches!(v.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
3900        .unwrap_or(false)
3901}
3902
3903fn classify_validate_failure_reason(message: &str) -> &'static str {
3904    let lower = message.to_ascii_lowercase();
3905    if lower.contains("sqlite contention") || lower.contains("database is locked") {
3906        return "timeout_acquiring_lock";
3907    }
3908    if lower.contains("exceeded timeout") {
3909        return "timeout_running_validations";
3910    }
3911    if lower.contains("worker disconnected") {
3912        return "worker_disconnected";
3913    }
3914    "validate_failure"
3915}
3916
3917fn lock_age_ms(project_root: &Path) -> Option<u64> {
3918    let data_dir = project_root.join(".decapod").join("data");
3919    let entries = fs::read_dir(data_dir).ok()?;
3920    let now = SystemTime::now();
3921    let mut max_age_ms: Option<u64> = None;
3922    for entry in entries.flatten() {
3923        let file_name = entry.file_name();
3924        let file_name = file_name.to_string_lossy();
3925        if !(file_name.ends_with("-wal")
3926            || file_name.ends_with("-shm")
3927            || file_name.ends_with("-journal"))
3928        {
3929            continue;
3930        }
3931        let Ok(meta) = entry.metadata() else {
3932            continue;
3933        };
3934        let Ok(modified) = meta.modified() else {
3935            continue;
3936        };
3937        let Ok(age) = now.duration_since(modified) else {
3938            continue;
3939        };
3940        let age_ms = age.as_millis() as u64;
3941        max_age_ms = Some(max_age_ms.map_or(age_ms, |existing| existing.max(age_ms)));
3942    }
3943    max_age_ms
3944}
3945
3946fn write_validate_diagnostic_artifact(
3947    project_root: &Path,
3948    reason_code: &str,
3949    elapsed_ms: u64,
3950    timeout_secs: u64,
3951) -> Result<PathBuf, error::DecapodError> {
3952    let mut run_id_hasher = Sha256::new();
3953    run_id_hasher.update(crate::core::ulid::new_ulid().as_bytes());
3954    let run_id = hash_bytes_hex(&run_id_hasher.finalize())[..32].to_string();
3955    let diagnostics_dir = project_root.join(".decapod/generated/artifacts/diagnostics/validate");
3956    fs::create_dir_all(&diagnostics_dir).map_err(error::DecapodError::IoError)?;
3957
3958    let mut payload = serde_json::json!({
3959        "schema_version": "1.0.0",
3960        "kind": "validate_diagnostic",
3961        "run_id": run_id,
3962        "op": "validate",
3963        "reason_code": reason_code,
3964        "elapsed_ms": elapsed_ms,
3965        "timeout_secs": timeout_secs,
3966        "lock_age_ms": lock_age_ms(project_root),
3967        "stale_lock_recovery_triggered": false
3968    });
3969
3970    let payload_bytes = serde_json::to_vec(&payload).map_err(|e| {
3971        error::DecapodError::ValidationError(format!("Failed to encode validate diagnostics: {e}"))
3972    })?;
3973    let mut hasher = Sha256::new();
3974    hasher.update(payload_bytes);
3975    let artifact_hash = hash_bytes_hex(&hasher.finalize());
3976    payload["artifact_hash"] = serde_json::json!(artifact_hash);
3977
3978    let relative_path = PathBuf::from(format!(
3979        ".decapod/generated/artifacts/diagnostics/validate/{run_id}.json"
3980    ));
3981    let artifact_path = project_root.join(&relative_path);
3982    let pretty = serde_json::to_vec_pretty(&payload).map_err(|e| {
3983        error::DecapodError::ValidationError(format!(
3984            "Failed to serialize validate diagnostics artifact: {e}"
3985        ))
3986    })?;
3987    fs::write(&artifact_path, pretty).map_err(error::DecapodError::IoError)?;
3988    Ok(relative_path)
3989}
3990
3991fn attach_validate_diagnostic_if_enabled(
3992    err: error::DecapodError,
3993    project_root: &Path,
3994    elapsed_ms: u64,
3995    timeout_secs: u64,
3996) -> error::DecapodError {
3997    if !validate_diagnostics_enabled() {
3998        return err;
3999    }
4000    let error::DecapodError::ValidationError(message) = err else {
4001        return err;
4002    };
4003    if !message.contains("VALIDATE_TIMEOUT_OR_LOCK") {
4004        return error::DecapodError::ValidationError(message);
4005    }
4006    let reason_code = classify_validate_failure_reason(&message);
4007    match write_validate_diagnostic_artifact(project_root, reason_code, elapsed_ms, timeout_secs) {
4008        Ok(relative_path) => error::DecapodError::ValidationError(format!(
4009            "{} Diagnostics: {}",
4010            message,
4011            relative_path.display()
4012        )),
4013        Err(diag_err) => error::DecapodError::ValidationError(format!(
4014            "{} DiagnosticsWriteError: {}",
4015            message, diag_err
4016        )),
4017    }
4018}
4019
4020fn normalize_validate_error(err: error::DecapodError) -> error::DecapodError {
4021    match err {
4022        error::DecapodError::RusqliteError(rusqlite::Error::SqliteFailure(code, msg)) => {
4023            let is_lock = code.code == rusqlite::ErrorCode::DatabaseBusy
4024                || code.extended_code == 522
4025                || msg
4026                    .as_deref()
4027                    .unwrap_or_default()
4028                    .to_ascii_lowercase()
4029                    .contains("locked");
4030            if is_lock {
4031                return error::DecapodError::ValidationError(
4032                    "VALIDATE_TIMEOUT_OR_LOCK: SQLite contention detected. Retry with backoff or inspect concurrent decapod processes.".to_string(),
4033                );
4034            }
4035            error::DecapodError::RusqliteError(rusqlite::Error::SqliteFailure(code, msg))
4036        }
4037        error::DecapodError::ValidationError(message) => {
4038            let lower = message.to_ascii_lowercase();
4039            if lower.contains("database is locked")
4040                || lower.contains("databasebusy")
4041                || lower.contains("sqlite_code=databasebusy")
4042            {
4043                return error::DecapodError::ValidationError(
4044                    "VALIDATE_TIMEOUT_OR_LOCK: SQLite contention detected. Retry with backoff or inspect concurrent decapod processes.".to_string(),
4045                );
4046            }
4047            error::DecapodError::ValidationError(message)
4048        }
4049        other => other,
4050    }
4051}
4052
4053fn retry_transient_sqlite<T, F>(mut op: F, max_attempts: u32) -> Result<T, error::DecapodError>
4054where
4055    F: FnMut() -> Result<T, error::DecapodError>,
4056{
4057    let mut attempt = 0u32;
4058    loop {
4059        match op() {
4060            Ok(v) => return Ok(v),
4061            Err(e) if is_transient_sqlite_contention_error(&e) && attempt + 1 < max_attempts => {
4062                let delay_ms = (50u64 * 2u64.pow(attempt)).min(800);
4063                attempt += 1;
4064                thread::sleep(std::time::Duration::from_millis(delay_ms));
4065            }
4066            Err(e) => return Err(e),
4067        }
4068    }
4069}
4070
4071fn is_transient_sqlite_contention_error(err: &error::DecapodError) -> bool {
4072    match err {
4073        error::DecapodError::RusqliteError(rusqlite::Error::SqliteFailure(code, msg)) => {
4074            if matches!(
4075                code.code,
4076                rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
4077            ) || code.extended_code == 522
4078            {
4079                return true;
4080            }
4081            let lower = msg.as_deref().unwrap_or_default().to_ascii_lowercase();
4082            lower.contains("locked") || lower.contains("disk i/o error")
4083        }
4084        error::DecapodError::ValidationError(message) => {
4085            let lower = message.to_ascii_lowercase();
4086            lower.contains("database is locked")
4087                || lower.contains("databasebusy")
4088                || lower.contains("sqlite contention")
4089                || lower.contains("disk i/o error")
4090                || lower.contains("extended_code: 522")
4091        }
4092        other => {
4093            let lower = other.to_string().to_ascii_lowercase();
4094            lower.contains("database is locked")
4095                || lower.contains("databasebusy")
4096                || lower.contains("disk i/o error")
4097                || lower.contains("extended_code: 522")
4098        }
4099    }
4100}
4101
4102fn run_validation_bounded(
4103    store: &Store,
4104    project_root: &Path,
4105    verbose: bool,
4106) -> Result<validate::ValidationReport, error::DecapodError> {
4107    let timeout_secs = validate_timeout_secs();
4108    let started = std::time::Instant::now();
4109    let (tx, rx) = mpsc::channel();
4110    let store_cloned = store.clone();
4111    let root = project_root.to_path_buf();
4112
4113    std::thread::spawn(move || {
4114        let mut result = validate::run_validation(&store_cloned, &root, &root, verbose);
4115        for attempt in 1..=2 {
4116            let should_retry = match &result {
4117                Err(error::DecapodError::RusqliteError(err)) => {
4118                    format!("{err}").to_ascii_lowercase().contains("locked")
4119                }
4120                Err(error::DecapodError::ValidationError(msg)) => {
4121                    let lower = msg.to_ascii_lowercase();
4122                    lower.contains("database is locked")
4123                        || lower.contains("databasebusy")
4124                        || lower.contains("sqlite_code=databasebusy")
4125                }
4126                _ => false,
4127            };
4128            if !should_retry {
4129                break;
4130            }
4131            let backoff_ms = 200_u64 * attempt as u64;
4132            std::thread::sleep(std::time::Duration::from_millis(backoff_ms));
4133            result = validate::run_validation(&store_cloned, &root, &root, verbose);
4134        }
4135        let _ = tx.send(result);
4136    });
4137
4138    let result = match rx.recv_timeout(std::time::Duration::from_secs(timeout_secs)) {
4139        Ok(result) => result.map_err(normalize_validate_error),
4140        Err(mpsc::RecvTimeoutError::Timeout) => Err(error::DecapodError::ValidationError(format!(
4141            "VALIDATE_TIMEOUT_OR_LOCK: validate exceeded timeout ({}s). Terminated to preserve proof-gate liveness.",
4142            timeout_secs
4143        ))),
4144        Err(mpsc::RecvTimeoutError::Disconnected) => Err(error::DecapodError::ValidationError(
4145            "VALIDATE_TIMEOUT_OR_LOCK: validate worker disconnected unexpectedly.".to_string(),
4146        )),
4147    };
4148    result.map_err(|err| {
4149        attach_validate_diagnostic_if_enabled(
4150            err,
4151            project_root,
4152            started.elapsed().as_millis() as u64,
4153            timeout_secs,
4154        )
4155    })
4156}
4157
4158fn rpc_op_requires_constitutional_awareness(op: &str) -> bool {
4159    matches!(
4160        op,
4161        "workspace.publish"
4162            | "store.upsert"
4163            | "scaffold.apply_answer"
4164            | "scaffold.generate_artifacts"
4165    )
4166}
4167
4168fn rpc_op_skips_mandate_enforcement(op: &str) -> bool {
4169    matches!(
4170        op,
4171        "context.resolve"
4172            | "context.scope"
4173            | "context.bindings"
4174            | "context.capsule.query"
4175            | "schema.get"
4176    )
4177}
4178
4179fn enforce_constitutional_awareness_for_rpc(
4180    op: &str,
4181    project_root: &Path,
4182) -> Result<(), error::DecapodError> {
4183    if !rpc_op_requires_constitutional_awareness(op) {
4184        return Ok(());
4185    }
4186
4187    let agent_id = current_agent_id();
4188    let rec = read_awareness_record(project_root, &agent_id)?;
4189    let Some(rec) = rec else {
4190        return Err(error::DecapodError::ValidationError(
4191            "Constitutional awareness required before mutating operations. Run `decapod validate`, then `decapod docs ingest`, then `decapod session acquire`, `decapod rpc --op agent.init`, and `decapod rpc --op context.resolve`."
4192                .to_string(),
4193        ));
4194    };
4195
4196    if rec.validated_at_epoch_secs.is_none() {
4197        return Err(error::DecapodError::ValidationError(
4198            "Constitutional awareness incomplete: `decapod validate` has not completed for this agent context. Run `decapod validate` first."
4199                .to_string(),
4200        ));
4201    }
4202
4203    if rec.core_constitution_ingested_at_epoch_secs.is_none() {
4204        return Err(error::DecapodError::ValidationError(
4205            "Constitutional awareness incomplete: core constitution ingestion missing. Run `decapod docs ingest` to ingest `constitution/core/*.md` before mutating operations."
4206                .to_string(),
4207        ));
4208    }
4209
4210    if rec.context_resolved_at_epoch_secs.is_none() {
4211        return Err(error::DecapodError::ValidationError(
4212            "Constitutional awareness incomplete: `context.resolve` has not been executed after initialization. Run `decapod rpc --op context.resolve`."
4213                .to_string(),
4214        ));
4215    }
4216
4217    if let Some(session) = read_agent_session(project_root, &agent_id)?
4218        && rec.session_token.as_deref() != Some(session.token.as_str())
4219    {
4220        return Err(error::DecapodError::ValidationError(
4221            "Constitutional awareness is stale for the active session. Re-run `decapod rpc --op agent.init` and `decapod rpc --op context.resolve`."
4222                .to_string(),
4223        ));
4224    }
4225
4226    Ok(())
4227}
4228
4229fn run_govern_command(
4230    govern_cli: GovernCli,
4231    project_store: &Store,
4232    store_root: &Path,
4233) -> Result<(), error::DecapodError> {
4234    match govern_cli.command {
4235        GovernCommand::Policy(policy_cli) => policy::run_policy_cli(project_store, policy_cli)?,
4236        GovernCommand::Health(health_cli) => health::run_health_cli(project_store, health_cli)?,
4237        GovernCommand::Proof(proof_cli) => proof::execute_proof_cli(&proof_cli, store_root)?,
4238        GovernCommand::Watcher(watcher_cli) => match watcher_cli.command {
4239            WatcherCommand::Run => {
4240                let report = watcher::run_watcher(project_store)?;
4241                println!("{}", serde_json::to_string_pretty(&report).unwrap());
4242            }
4243        },
4244        GovernCommand::Feedback(feedback_cli) => {
4245            feedback::initialize_feedback_db(store_root)?;
4246            match feedback_cli.command {
4247                FeedbackCommand::Add {
4248                    source,
4249                    text,
4250                    links,
4251                } => {
4252                    let id =
4253                        feedback::add_feedback(project_store, &source, &text, links.as_deref())?;
4254                    println!("Feedback recorded: {}", id);
4255                }
4256                FeedbackCommand::Propose => {
4257                    let proposal = feedback::propose_prefs(project_store)?;
4258                    println!("{}", proposal);
4259                }
4260            }
4261        }
4262        GovernCommand::Gatekeeper(gk_cli) => match gk_cli.command {
4263            GatekeeperCommand::Check {
4264                paths,
4265                max_diff_bytes,
4266                no_secrets,
4267                no_dangerous,
4268            } => {
4269                use crate::core::gatekeeper;
4270
4271                let repo_root = project_store
4272                    .root
4273                    .parent()
4274                    .and_then(|p| p.parent())
4275                    .unwrap_or(&project_store.root);
4276
4277                // Collect paths: explicit or git staged files
4278                let check_paths: Vec<std::path::PathBuf> = if let Some(explicit) = paths {
4279                    explicit.into_iter().map(std::path::PathBuf::from).collect()
4280                } else {
4281                    // Get staged files from git
4282                    let output = std::process::Command::new("git")
4283                        .args(["diff", "--cached", "--name-only"])
4284                        .current_dir(repo_root)
4285                        .output()
4286                        .map_err(error::DecapodError::IoError)?;
4287                    String::from_utf8_lossy(&output.stdout)
4288                        .lines()
4289                        .filter(|l| !l.is_empty())
4290                        .map(std::path::PathBuf::from)
4291                        .collect()
4292                };
4293
4294                // Get diff size
4295                let diff_output = std::process::Command::new("git")
4296                    .args(["diff", "--cached", "--stat"])
4297                    .current_dir(repo_root)
4298                    .output()
4299                    .map_err(error::DecapodError::IoError)?;
4300                let diff_bytes = diff_output.stdout.len() as u64;
4301
4302                let mut config = gatekeeper::GatekeeperConfig::default();
4303                if let Some(max) = max_diff_bytes {
4304                    config.max_diff_bytes = max;
4305                }
4306                config.scan_secrets = !no_secrets;
4307                config.scan_dangerous_patterns = !no_dangerous;
4308
4309                let result =
4310                    gatekeeper::run_gatekeeper(repo_root, &check_paths, diff_bytes, &config)?;
4311
4312                if result.passed {
4313                    println!(
4314                        "Gatekeeper: all checks passed ({} files scanned)",
4315                        check_paths.len()
4316                    );
4317                } else {
4318                    println!(
4319                        "Gatekeeper: {} violation(s) found:",
4320                        result.violations.len()
4321                    );
4322                    for v in &result.violations {
4323                        let loc = v.line.map(|l| format!(":{}", l)).unwrap_or_default();
4324                        println!("  [{}] {}{}: {}", v.kind, v.path.display(), loc, v.message);
4325                    }
4326                    return Err(error::DecapodError::ValidationError(format!(
4327                        "Gatekeeper: {} violation(s)",
4328                        result.violations.len()
4329                    )));
4330                }
4331            }
4332        },
4333        GovernCommand::Plan(plan_cli) => run_plan_command(plan_cli, project_store)?,
4334        GovernCommand::Workunit(workunit_cli) => run_workunit_command(workunit_cli, project_store)?,
4335        GovernCommand::Capsule(capsule_cli) => run_capsule_command(capsule_cli, project_store)?,
4336    }
4337
4338    Ok(())
4339}
4340
4341fn run_capsule_command(
4342    capsule_cli: CapsuleCli,
4343    project_store: &Store,
4344) -> Result<(), error::DecapodError> {
4345    let project_root = project_store
4346        .root
4347        .parent()
4348        .and_then(|p| p.parent())
4349        .ok_or_else(|| {
4350            error::DecapodError::ValidationError(
4351                "unable to resolve project root from store root".to_string(),
4352            )
4353        })?;
4354
4355    match capsule_cli.command {
4356        CapsuleCommand::Query {
4357            topic,
4358            scope,
4359            risk_tier,
4360            task_id,
4361            workunit_id,
4362            limit,
4363            write,
4364        } => {
4365            let resolved_policy = core::capsule_policy::resolve_capsule_policy(
4366                project_root,
4367                &scope,
4368                risk_tier.as_deref(),
4369                limit,
4370                write,
4371            )?;
4372            let capsule = core::context_capsule::query_embedded_capsule_governed(
4373                project_root,
4374                &topic,
4375                &scope,
4376                task_id.as_deref(),
4377                workunit_id.as_deref(),
4378                resolved_policy.effective_limit,
4379                resolved_policy.binding,
4380            )?;
4381            if write {
4382                let path = core::context_capsule::write_context_capsule(project_root, &capsule)?;
4383                let workunit_binding = maybe_bind_capsule_to_workunit_state_ref(
4384                    project_root,
4385                    task_id.as_deref().or(workunit_id.as_deref()),
4386                    &path,
4387                )?;
4388                println!(
4389                    "{}",
4390                    serde_json::to_string_pretty(&serde_json::json!({
4391                        "status": "ok",
4392                        "path": path,
4393                        "workunit_state_ref_binding": workunit_binding,
4394                        "capsule": capsule,
4395                    }))
4396                    .unwrap()
4397                );
4398            } else {
4399                println!("{}", serde_json::to_string_pretty(&capsule).unwrap());
4400            }
4401        }
4402    }
4403
4404    Ok(())
4405}
4406
4407fn run_workunit_command(
4408    workunit_cli: WorkunitCli,
4409    project_store: &Store,
4410) -> Result<(), error::DecapodError> {
4411    let project_root = project_store
4412        .root
4413        .parent()
4414        .and_then(|p| p.parent())
4415        .ok_or_else(|| {
4416            error::DecapodError::ValidationError(
4417                "unable to resolve project root from store root".to_string(),
4418            )
4419        })?;
4420
4421    match workunit_cli.command {
4422        WorkunitCommand::Init {
4423            task_id,
4424            intent_ref,
4425        } => {
4426            let manifest = core::workunit::init_workunit(project_root, &task_id, &intent_ref)?;
4427            let path = core::workunit::workunit_path(project_root, &task_id)?;
4428            println!(
4429                "{}",
4430                serde_json::to_string_pretty(&serde_json::json!({
4431                    "status": "ok",
4432                    "marker": "WORKUNIT_INITIALIZED",
4433                    "path": path,
4434                    "workunit": manifest,
4435                }))
4436                .unwrap()
4437            );
4438        }
4439        WorkunitCommand::Get { task_id } => {
4440            let manifest = core::workunit::load_workunit(project_root, &task_id)?;
4441            println!("{}", serde_json::to_string_pretty(&manifest).unwrap());
4442        }
4443        WorkunitCommand::Status { task_id } => {
4444            let manifest = core::workunit::load_workunit(project_root, &task_id)?;
4445            let path = core::workunit::workunit_path(project_root, &task_id)?;
4446            let hash = manifest.canonical_hash_hex().map_err(|e| {
4447                error::DecapodError::ValidationError(format!(
4448                    "failed to compute workunit hash: {}",
4449                    e
4450                ))
4451            })?;
4452            println!(
4453                "{}",
4454                serde_json::to_string_pretty(&serde_json::json!({
4455                    "status": "ok",
4456                    "task_id": manifest.task_id,
4457                    "workunit_status": manifest.status,
4458                    "manifest_hash": hash,
4459                    "path": path,
4460                }))
4461                .unwrap()
4462            );
4463        }
4464        WorkunitCommand::AttachSpec { task_id, reference } => {
4465            let manifest = core::workunit::add_spec_ref(project_root, &task_id, &reference)?;
4466            println!("{}", serde_json::to_string_pretty(&manifest).unwrap());
4467        }
4468        WorkunitCommand::AttachState { task_id, reference } => {
4469            let manifest = core::workunit::add_state_ref(project_root, &task_id, &reference)?;
4470            println!("{}", serde_json::to_string_pretty(&manifest).unwrap());
4471        }
4472        WorkunitCommand::SetProofPlan { task_id, gates } => {
4473            let manifest = core::workunit::set_proof_plan(project_root, &task_id, &gates)?;
4474            println!("{}", serde_json::to_string_pretty(&manifest).unwrap());
4475        }
4476        WorkunitCommand::RecordProof {
4477            task_id,
4478            gate,
4479            status,
4480            artifact,
4481        } => {
4482            let manifest = core::workunit::record_proof_result(
4483                project_root,
4484                &task_id,
4485                &gate,
4486                &status,
4487                artifact,
4488            )?;
4489            println!("{}", serde_json::to_string_pretty(&manifest).unwrap());
4490        }
4491        WorkunitCommand::Transition { task_id, to } => {
4492            let manifest = core::workunit::transition_status(project_root, &task_id, to.into())?;
4493            println!("{}", serde_json::to_string_pretty(&manifest).unwrap());
4494        }
4495    }
4496
4497    Ok(())
4498}
4499
4500fn run_plan_command(plan_cli: PlanCli, project_store: &Store) -> Result<(), error::DecapodError> {
4501    let project_root = project_store
4502        .root
4503        .parent()
4504        .and_then(|p| p.parent())
4505        .ok_or_else(|| {
4506            error::DecapodError::ValidationError(
4507                "unable to resolve project root from store root".to_string(),
4508            )
4509        })?;
4510
4511    match plan_cli.command {
4512        PlanCommand::Init {
4513            title,
4514            intent,
4515            todo_ids,
4516            proof_hooks,
4517            unknowns,
4518            human_questions,
4519            forbidden_paths,
4520            file_touch_budget,
4521        } => {
4522            let plan = plan_governance::init_plan(
4523                project_root,
4524                plan_governance::InitPlanInput {
4525                    title,
4526                    intent,
4527                    todo_ids,
4528                    proof_hooks,
4529                    unknowns,
4530                    human_questions,
4531                    constraints: plan_governance::ScopeConstraints {
4532                        forbidden_paths,
4533                        file_touch_budget,
4534                    },
4535                },
4536            )?;
4537            println!("{}", serde_json::to_string_pretty(&plan).unwrap());
4538        }
4539        PlanCommand::Update {
4540            title,
4541            intent,
4542            todo_ids,
4543            proof_hooks,
4544            unknowns,
4545            human_questions,
4546            clear_unknowns,
4547            clear_questions,
4548            forbidden_paths,
4549            file_touch_budget,
4550        } => {
4551            let plan = plan_governance::patch_plan(
4552                project_root,
4553                plan_governance::PlanPatch {
4554                    title,
4555                    intent,
4556                    state: None,
4557                    todo_ids: if todo_ids.is_empty() {
4558                        None
4559                    } else {
4560                        Some(todo_ids)
4561                    },
4562                    proof_hooks: if proof_hooks.is_empty() {
4563                        None
4564                    } else {
4565                        Some(proof_hooks)
4566                    },
4567                    unknowns: if clear_unknowns {
4568                        Some(vec![])
4569                    } else if unknowns.is_empty() {
4570                        None
4571                    } else {
4572                        Some(unknowns)
4573                    },
4574                    human_questions: if clear_questions {
4575                        Some(vec![])
4576                    } else if human_questions.is_empty() {
4577                        None
4578                    } else {
4579                        Some(human_questions)
4580                    },
4581                    constraints: if forbidden_paths.is_empty() && file_touch_budget.is_none() {
4582                        None
4583                    } else {
4584                        Some(plan_governance::ScopeConstraints {
4585                            forbidden_paths,
4586                            file_touch_budget,
4587                        })
4588                    },
4589                },
4590            )?;
4591            println!("{}", serde_json::to_string_pretty(&plan).unwrap());
4592        }
4593        PlanCommand::SetState { state } => {
4594            let plan = plan_governance::patch_plan(
4595                project_root,
4596                plan_governance::PlanPatch {
4597                    state: Some(state.into()),
4598                    ..Default::default()
4599                },
4600            )?;
4601            println!("{}", serde_json::to_string_pretty(&plan).unwrap());
4602        }
4603        PlanCommand::Approve => {
4604            let plan = plan_governance::patch_plan(
4605                project_root,
4606                plan_governance::PlanPatch {
4607                    state: Some(plan_governance::PlanState::Approved),
4608                    ..Default::default()
4609                },
4610            )?;
4611            println!("{}", serde_json::to_string_pretty(&plan).unwrap());
4612        }
4613        PlanCommand::Status => {
4614            let plan = plan_governance::load_plan(project_root)?;
4615            println!(
4616                "{}",
4617                serde_json::to_string_pretty(&serde_json::json!({
4618                    "status": if plan.is_some() { "ok" } else { "missing" },
4619                    "plan": plan
4620                }))
4621                .unwrap()
4622            );
4623        }
4624        PlanCommand::CheckExecute { todo_id } => {
4625            let plan = plan_governance::ensure_execute_ready(plan_governance::ExecuteCheckInput {
4626                project_root,
4627                store_root: &project_store.root,
4628                todo_id: todo_id.as_deref(),
4629            })?;
4630            println!(
4631                "{}",
4632                serde_json::to_string_pretty(&serde_json::json!({
4633                    "status": "ok",
4634                    "marker": "EXECUTION_READY",
4635                    "state": format!("{:?}", plan.state).to_uppercase(),
4636                    "todo_ids": plan.todo_ids,
4637                    "proof_hooks": plan.proof_hooks,
4638                }))
4639                .unwrap()
4640            );
4641        }
4642    }
4643
4644    Ok(())
4645}
4646
4647fn run_data_command(
4648    data_cli: DataCli,
4649    project_store: &Store,
4650    project_root: &Path,
4651    store_root: &Path,
4652) -> Result<(), error::DecapodError> {
4653    match data_cli.command {
4654        DataCommand::Archive(archive_cli) => {
4655            archive::initialize_archive_db(store_root)?;
4656            match archive_cli.command {
4657                ArchiveCommand::List => {
4658                    let items = archive::list_archives(project_store)?;
4659                    println!("{}", serde_json::to_string_pretty(&items).unwrap());
4660                }
4661                ArchiveCommand::Verify => {
4662                    let failures = archive::verify_archives(project_store)?;
4663                    if failures.is_empty() {
4664                        println!("All archives verified successfully.");
4665                    } else {
4666                        println!("Archive verification failed:");
4667                        for f in failures {
4668                            println!("- {}", f);
4669                        }
4670                    }
4671                }
4672            }
4673        }
4674        DataCommand::Knowledge(knowledge_cli) => {
4675            db::initialize_knowledge_db(store_root)?;
4676            match knowledge_cli.command {
4677                KnowledgeCommand::Add {
4678                    id,
4679                    title,
4680                    text,
4681                    provenance,
4682                    claim_id,
4683                } => {
4684                    let result = knowledge::add_knowledge(
4685                        project_store,
4686                        knowledge::AddKnowledgeParams {
4687                            id: &id,
4688                            title: &title,
4689                            content: &text,
4690                            provenance: &provenance,
4691                            claim_id: claim_id.as_deref(),
4692                            merge_key: None,
4693                            conflict_policy: knowledge::KnowledgeConflictPolicy::Merge,
4694                            status: "active",
4695                            ttl_policy: "persistent",
4696                            expires_ts: None,
4697                        },
4698                    )?;
4699                    println!(
4700                        "Knowledge entry {}: {} (action: {})",
4701                        result.id, id, result.action
4702                    );
4703                }
4704                KnowledgeCommand::Search { query } => {
4705                    let results = knowledge::search_knowledge(
4706                        project_store,
4707                        &query,
4708                        knowledge::SearchOptions {
4709                            as_of: None,
4710                            window_days: None,
4711                            rank: "relevance",
4712                        },
4713                    )?;
4714                    println!("{}", serde_json::to_string_pretty(&results).unwrap());
4715                }
4716                KnowledgeCommand::Promote {
4717                    source_entry_id,
4718                    evidence_refs,
4719                    approved_by,
4720                    reason,
4721                } => {
4722                    let actor = current_agent_id();
4723                    let event = knowledge::record_promotion_event(
4724                        project_store,
4725                        knowledge::KnowledgePromotionEventInput {
4726                            source_entry_id: &source_entry_id,
4727                            evidence_refs: &evidence_refs,
4728                            approved_by: &approved_by,
4729                            actor: &actor,
4730                            reason: &reason,
4731                        },
4732                    )?;
4733                    println!("{}", serde_json::to_string_pretty(&event).unwrap());
4734                }
4735            }
4736        }
4737        DataCommand::Context(context_cli) => {
4738            let manager = context::ContextManager::new(store_root)?;
4739            match context_cli.command {
4740                ContextCommand::Audit { profile, files } => {
4741                    let total = manager.audit_session(&files)?;
4742                    match manager.get_profile(&profile) {
4743                        Some(p) => {
4744                            println!(
4745                                "Total tokens for profile '{}': {} / {} (budget)",
4746                                profile, total, p.budget_tokens
4747                            );
4748                            if total > p.budget_tokens {
4749                                println!("⚠ OVER BUDGET");
4750                            }
4751                        }
4752                        None => {
4753                            println!("Total tokens: {} (Profile '{}' not found)", total, profile);
4754                        }
4755                    }
4756                }
4757                ContextCommand::Pack { path, summary } => {
4758                    let archive_path = manager
4759                        .pack_and_archive(project_store, &path, &summary)
4760                        .map_err(|err| match err {
4761                            error::DecapodError::ContextPackError(msg) => {
4762                                error::DecapodError::ContextPackError(format!(
4763                                    "Context pack failed: {}",
4764                                    msg
4765                                ))
4766                            }
4767                            other => other,
4768                        })?;
4769                    println!("Session archived to: {}", archive_path.display());
4770                }
4771                ContextCommand::Restore {
4772                    id,
4773                    profile,
4774                    current_files,
4775                } => {
4776                    let content = manager.restore_archive(&id, &profile, &current_files)?;
4777                    println!(
4778                        "--- RESTORED CONTENT (Archive: {}) ---\n{}\n--- END RESTORED ---",
4779                        id, content
4780                    );
4781                }
4782            }
4783        }
4784        DataCommand::Schema(schema_cli) => {
4785            let schemas = schema_catalog();
4786
4787            let output = if let Some(sub) = schema_cli.subsystem {
4788                schemas
4789                    .get(sub.as_str())
4790                    .cloned()
4791                    .unwrap_or(serde_json::json!({ "error": "subsystem not found" }))
4792            } else {
4793                let mut envelope = deterministic_schema_envelope();
4794                if !schema_cli.deterministic {
4795                    envelope.as_object_mut().unwrap().insert(
4796                        "generated_at".to_string(),
4797                        serde_json::json!(format!("{:?}", std::time::SystemTime::now())),
4798                    );
4799                }
4800                envelope
4801            };
4802
4803            match schema_cli.format.as_str() {
4804                "json" => println!("{}", serde_json::to_string_pretty(&output).unwrap()),
4805                "md" => {
4806                    println!("{}", schema_to_markdown(&output));
4807                }
4808                other => {
4809                    return Err(error::DecapodError::ValidationError(format!(
4810                        "Unsupported schema format '{}'. Use 'json' or 'md'.",
4811                        other
4812                    )));
4813                }
4814            }
4815        }
4816        DataCommand::Repo(repo_cli) => match repo_cli.command {
4817            RepoCommand::Map => {
4818                let map = repomap::generate_map(project_root);
4819                println!("{}", serde_json::to_string_pretty(&map).unwrap());
4820            }
4821            RepoCommand::Graph => {
4822                let graph = repomap::generate_doc_graph(project_root);
4823                println!("{}", graph.mermaid);
4824            }
4825        },
4826        DataCommand::Broker(broker_cli) => match broker_cli.command {
4827            BrokerCommand::Audit => {
4828                let audit_log = store_root.join("broker.events.jsonl");
4829                if audit_log.exists() {
4830                    let content = std::fs::read_to_string(audit_log)?;
4831                    println!("{}", content);
4832                } else {
4833                    println!("No audit log found.");
4834                }
4835            }
4836            BrokerCommand::Verify => {
4837                let broker = core::broker::DbBroker::new(store_root);
4838                let report = broker.verify_replay()?;
4839                println!("{}", serde_json::to_string_pretty(&report).unwrap());
4840                if !report.divergences.is_empty() {
4841                    return Err(error::DecapodError::ValidationError(format!(
4842                        "Audit log integrity check failed: {} divergence(s) detected",
4843                        report.divergences.len()
4844                    )));
4845                }
4846            }
4847        },
4848        DataCommand::Aptitude(aptitude_cli) => {
4849            aptitude::run_aptitude_cli(project_store, aptitude_cli)?;
4850        }
4851        DataCommand::Federation(federation_cli) => {
4852            federation::run_federation_cli(project_store, federation_cli)?;
4853        }
4854        DataCommand::Primitives(primitives_cli) => {
4855            primitives::run_primitives_cli(project_store, primitives_cli)?;
4856        }
4857    }
4858
4859    Ok(())
4860}
4861
4862fn schema_to_markdown(schema: &serde_json::Value) -> String {
4863    fn render_value(v: &serde_json::Value) -> String {
4864        match v {
4865            serde_json::Value::Object(map) => {
4866                let mut keys: Vec<_> = map.keys().cloned().collect();
4867                keys.sort();
4868                let mut out = String::new();
4869                for key in keys {
4870                    let value = &map[&key];
4871                    match value {
4872                        serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
4873                            out.push_str(&format!("- **{}**:\n", key));
4874                            for line in render_value(value).lines() {
4875                                out.push_str(&format!("  {}\n", line));
4876                            }
4877                        }
4878                        _ => out.push_str(&format!("- **{}**: `{}`\n", key, value)),
4879                    }
4880                }
4881                out
4882            }
4883            serde_json::Value::Array(items) => {
4884                let mut out = String::new();
4885                for item in items {
4886                    match item {
4887                        serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
4888                            out.push_str("- item:\n");
4889                            for line in render_value(item).lines() {
4890                                out.push_str(&format!("  {}\n", line));
4891                            }
4892                        }
4893                        _ => out.push_str(&format!("- `{}`\n", item)),
4894                    }
4895                }
4896                out
4897            }
4898            _ => format!("- `{}`\n", v),
4899        }
4900    }
4901
4902    let mut out = String::from("# Decapod Schema\n\n");
4903    out.push_str(&render_value(schema));
4904    out
4905}
4906
4907pub(crate) fn deterministic_schema_envelope() -> serde_json::Value {
4908    let root = cli_command_registry();
4909    let command_registry = root
4910        .get("subcommands")
4911        .cloned()
4912        .unwrap_or(serde_json::Value::Array(vec![]));
4913    serde_json::json!({
4914        "schema_version": "1.0.0",
4915        "subsystems": schema_catalog(),
4916        "deprecations": deprecation_metadata(),
4917        "command_registry": command_registry
4918    })
4919}
4920
4921fn schema_catalog() -> std::collections::BTreeMap<&'static str, serde_json::Value> {
4922    let mut schemas = std::collections::BTreeMap::new();
4923    schemas.insert("todo", todo::schema());
4924    schemas.insert("cron", cron::schema());
4925    schemas.insert("reflex", reflex::schema());
4926    schemas.insert("workflow", workflow::schema());
4927    schemas.insert("container", container::schema());
4928    schemas.insert("health", health::health_schema());
4929    schemas.insert("broker", core::broker::schema());
4930    schemas.insert("external_action", core::external_action::schema());
4931    schemas.insert("context", context::schema());
4932    schemas.insert("policy", policy::schema());
4933    schemas.insert("knowledge", knowledge::schema());
4934    schemas.insert("repomap", repomap::schema());
4935    schemas.insert("watcher", watcher::schema());
4936    schemas.insert("archive", archive::schema());
4937    schemas.insert("feedback", feedback::schema());
4938    schemas.insert("aptitude", aptitude::schema());
4939    schemas.insert("memory", aptitude::schema());
4940    schemas.insert("federation", federation::schema());
4941    schemas.insert("primitives", primitives::schema());
4942    schemas.insert("decide", decide::schema());
4943    schemas.insert("docs", docs_cli::schema());
4944    schemas.insert("deprecations", deprecation_metadata());
4945    schemas.insert("lcm", lcm::schema());
4946    schemas.insert("map", map_ops::schema());
4947    schemas.insert("eval", eval::schema());
4948    schemas.insert("internalize", internalize::schema());
4949    schemas.insert(
4950        "command_registry",
4951        serde_json::json!({
4952            "name": "command_registry",
4953            "version": "0.1.0",
4954            "description": "Machine-readable CLI command registry generated from clap command definitions",
4955            "root": cli_command_registry()
4956        }),
4957    );
4958    schemas
4959}
4960
4961fn deprecation_metadata() -> serde_json::Value {
4962    serde_json::json!({
4963        "name": "deprecations",
4964        "version": "0.1.0",
4965        "description": "Deprecated command surfaces and replacement pointers",
4966        "entries": [
4967            {
4968                "surface": "command",
4969                "path": "decapod heartbeat",
4970                "status": "deprecated",
4971                "replacement": "decapod govern health summary",
4972                "notes": "Heartbeat command family was consolidated into govern health"
4973            },
4974            {
4975                "surface": "command",
4976                "path": "decapod trust",
4977                "status": "deprecated",
4978                "replacement": "decapod govern health autonomy",
4979                "notes": "Trust command family was consolidated into govern health"
4980            },
4981            {
4982                "surface": "module",
4983                "path": "src/plugins/heartbeat.rs",
4984                "status": "deprecated",
4985                "replacement": "src/plugins/health.rs"
4986            }
4987        ]
4988    })
4989}
4990
4991fn cli_command_registry() -> serde_json::Value {
4992    let command = Cli::command();
4993    command_to_registry(&command)
4994}
4995
4996fn command_to_registry(command: &clap::Command) -> serde_json::Value {
4997    let mut subcommands: Vec<serde_json::Value> = command
4998        .get_subcommands()
4999        .filter(|sub| !sub.is_hide_set())
5000        .map(command_to_registry)
5001        .collect();
5002    subcommands.sort_by(|a, b| {
5003        let a_name = a
5004            .get("name")
5005            .and_then(serde_json::Value::as_str)
5006            .unwrap_or_default();
5007        let b_name = b
5008            .get("name")
5009            .and_then(serde_json::Value::as_str)
5010            .unwrap_or_default();
5011        a_name.cmp(b_name)
5012    });
5013
5014    let mut options: Vec<serde_json::Value> = command
5015        .get_arguments()
5016        .filter(|arg| !arg.is_hide_set())
5017        .map(|arg| {
5018            let mut flags = Vec::new();
5019            if let Some(long) = arg.get_long() {
5020                flags.push(format!("--{}", long));
5021            }
5022            if let Some(short) = arg.get_short() {
5023                flags.push(format!("-{}", short));
5024            }
5025            if flags.is_empty() {
5026                flags.push(arg.get_id().to_string());
5027            }
5028
5029            let value_names = arg
5030                .get_value_names()
5031                .map(|values| values.iter().map(|v| v.to_string()).collect::<Vec<_>>())
5032                .unwrap_or_default();
5033
5034            serde_json::json!({
5035                "id": arg.get_id().to_string(),
5036                "flags": flags,
5037                "required": arg.is_required_set(),
5038                "help": arg.get_help().map(|help| help.to_string()),
5039                "value_names": value_names
5040            })
5041        })
5042        .collect();
5043
5044    options.sort_by(|a, b| {
5045        let a_id = a
5046            .get("id")
5047            .and_then(serde_json::Value::as_str)
5048            .unwrap_or_default();
5049        let b_id = b
5050            .get("id")
5051            .and_then(serde_json::Value::as_str)
5052            .unwrap_or_default();
5053        a_id.cmp(b_id)
5054    });
5055
5056    let aliases: Vec<String> = command.get_all_aliases().map(str::to_string).collect();
5057
5058    serde_json::json!({
5059        "name": command.get_name(),
5060        "about": command.get_about().map(|about| about.to_string()),
5061        "aliases": aliases,
5062        "options": options,
5063        "subcommands": subcommands
5064    })
5065}
5066
5067fn run_auto_command(auto_cli: AutoCli, project_store: &Store) -> Result<(), error::DecapodError> {
5068    match auto_cli.command {
5069        AutoCommand::Cron(cron_cli) => cron::run_cron_cli(project_store, cron_cli)?,
5070        AutoCommand::Reflex(reflex_cli) => reflex::run_reflex_cli(project_store, reflex_cli),
5071        AutoCommand::Workflow(workflow_cli) => {
5072            workflow::run_workflow_cli(project_store, workflow_cli)?
5073        }
5074        AutoCommand::Container(container_cli) => {
5075            container::run_container_cli(project_store, container_cli)?
5076        }
5077    }
5078
5079    Ok(())
5080}
5081
5082fn run_qa_command(
5083    qa_cli: QaCli,
5084    project_store: &Store,
5085    project_root: &Path,
5086) -> Result<(), error::DecapodError> {
5087    match qa_cli.command {
5088        QaCommand::Verify(verify_cli) => {
5089            verify::run_verify_cli(project_store, project_root, verify_cli)?
5090        }
5091        QaCommand::Check {
5092            crate_description,
5093            commands,
5094            all,
5095        } => run_check(crate_description, commands, all)?,
5096        QaCommand::Gatling(ref gatling_cli) => plugins::gatling::run_gatling_cli(gatling_cli)?,
5097    }
5098
5099    Ok(())
5100}
5101
5102fn run_hook_install(
5103    commit_msg: bool,
5104    pre_commit: bool,
5105    uninstall: bool,
5106) -> Result<(), error::DecapodError> {
5107    let git_dir_output = std::process::Command::new("git")
5108        .args(["rev-parse", "--git-dir"])
5109        .output()
5110        .map_err(error::DecapodError::IoError)?;
5111
5112    if !git_dir_output.status.success() {
5113        return Err(error::DecapodError::ValidationError(
5114            "Not in a git repository".to_string(),
5115        ));
5116    }
5117
5118    let git_dir = String::from_utf8_lossy(&git_dir_output.stdout)
5119        .trim()
5120        .to_string();
5121    let hooks_dir = PathBuf::from(git_dir).join("hooks");
5122    fs::create_dir_all(&hooks_dir).map_err(error::DecapodError::IoError)?;
5123
5124    if uninstall {
5125        let commit_msg_path = hooks_dir.join("commit-msg");
5126        let pre_commit_path = hooks_dir.join("pre-commit");
5127        let mut removed_any = false;
5128
5129        if commit_msg_path.exists() {
5130            fs::remove_file(&commit_msg_path).map_err(error::DecapodError::IoError)?;
5131            println!("✓ Removed commit-msg hook");
5132            removed_any = true;
5133        }
5134        if pre_commit_path.exists() {
5135            fs::remove_file(&pre_commit_path).map_err(error::DecapodError::IoError)?;
5136            println!("✓ Removed pre-commit hook");
5137            removed_any = true;
5138        }
5139        if !removed_any {
5140            println!("No hooks found to remove");
5141        }
5142        return Ok(());
5143    }
5144
5145    if commit_msg {
5146        let hook_content = r#"#!/bin/sh
5147MSG_FILE="$1"
5148SUBJECT="$(head -n1 "$MSG_FILE")"
5149if printf '%s' "$SUBJECT" | grep -Eq '^(feat|fix|docs|style|refactor|test|chore|ci|build|perf|revert)(\([^)]+\))?: .+'; then
5150  exit 0
5151fi
5152echo "commit-msg hook: expected conventional commit subject"
5153echo "got: $SUBJECT"
5154exit 1
5155"#;
5156        let hook_path = hooks_dir.join("commit-msg");
5157        let mut file = fs::File::create(&hook_path).map_err(error::DecapodError::IoError)?;
5158        file.write_all(hook_content.as_bytes())
5159            .map_err(error::DecapodError::IoError)?;
5160        #[cfg(unix)]
5161        {
5162            use std::os::unix::fs::PermissionsExt;
5163            let mut perms = fs::metadata(&hook_path)
5164                .map_err(error::DecapodError::IoError)?
5165                .permissions();
5166            perms.set_mode(0o755);
5167            fs::set_permissions(&hook_path, perms).map_err(error::DecapodError::IoError)?;
5168        }
5169        println!("✓ Installed commit-msg hook for conventional commits");
5170    }
5171
5172    if pre_commit {
5173        let hook_content = r#"#!/bin/sh
5174set -e
5175cargo fmt --check
5176cargo clippy --all-targets --all-features -- -D warnings
5177"#;
5178        let hook_path = hooks_dir.join("pre-commit");
5179        let mut file = fs::File::create(&hook_path).map_err(error::DecapodError::IoError)?;
5180        file.write_all(hook_content.as_bytes())
5181            .map_err(error::DecapodError::IoError)?;
5182        #[cfg(unix)]
5183        {
5184            use std::os::unix::fs::PermissionsExt;
5185            let mut perms = fs::metadata(&hook_path)
5186                .map_err(error::DecapodError::IoError)?
5187                .permissions();
5188            perms.set_mode(0o755);
5189            fs::set_permissions(&hook_path, perms).map_err(error::DecapodError::IoError)?;
5190        }
5191        println!("✓ Installed pre-commit hook (fmt + clippy)");
5192    }
5193
5194    if !commit_msg && !pre_commit {
5195        println!("No hooks specified. Use --commit-msg and/or --pre-commit");
5196    }
5197
5198    Ok(())
5199}
5200
5201fn run_check(
5202    crate_description: bool,
5203    commands: bool,
5204    all: bool,
5205) -> Result<(), error::DecapodError> {
5206    if crate_description || all {
5207        let expected = "Decapod is a Rust-built governance runtime for AI agents: repo-native state, enforced workflow, proof gates, safe coordination.";
5208
5209        let output = std::process::Command::new("cargo")
5210            .args(["metadata", "--no-deps", "--format-version", "1"])
5211            .output()
5212            .map_err(|e| error::DecapodError::IoError(std::io::Error::other(e)))?;
5213
5214        if !output.status.success() {
5215            let stderr = String::from_utf8_lossy(&output.stderr);
5216            return Err(error::DecapodError::ValidationError(format!(
5217                "cargo metadata failed: {}",
5218                stderr.trim()
5219            )));
5220        }
5221
5222        let json_str = String::from_utf8_lossy(&output.stdout);
5223
5224        if json_str.contains(expected) {
5225            println!("✓ Crate description matches");
5226        } else {
5227            println!("✗ Crate description mismatch!");
5228            println!("  Expected: {}", expected);
5229            return Err(error::DecapodError::ValidationError(
5230                "Crate description check failed".into(),
5231            ));
5232        }
5233    }
5234
5235    if commands || all {
5236        run_command_help_smoke()?;
5237        println!("✓ Command help surfaces are valid");
5238    }
5239
5240    if all && !(crate_description || commands) {
5241        println!("Note: --all enables all checks");
5242    }
5243
5244    Ok(())
5245}
5246
5247fn run_command_help_smoke() -> Result<(), error::DecapodError> {
5248    fn walk(cmd: &clap::Command, prefix: Vec<String>, all_paths: &mut Vec<Vec<String>>) {
5249        if cmd.get_name() != "help" {
5250            all_paths.push(prefix.clone());
5251        }
5252        for sub in cmd.get_subcommands().filter(|sub| !sub.is_hide_set()) {
5253            let mut next = prefix.clone();
5254            next.push(sub.get_name().to_string());
5255            walk(sub, next, all_paths);
5256        }
5257    }
5258
5259    let exe = std::env::current_exe().map_err(error::DecapodError::IoError)?;
5260    let mut command_paths = Vec::new();
5261    walk(&Cli::command(), Vec::new(), &mut command_paths);
5262    command_paths.sort();
5263    command_paths.dedup();
5264
5265    let mut handles = Vec::new();
5266    for path in &command_paths {
5267        handles.push(std::thread::spawn({
5268            let path = path.clone();
5269            let exe = exe.clone();
5270            move || {
5271                let mut args = path.clone();
5272                args.push("--help".to_string());
5273                let output = std::process::Command::new(&exe)
5274                    .args(&args)
5275                    .output()
5276                    .map_err(error::DecapodError::IoError)?;
5277                if !output.status.success() {
5278                    return Err(error::DecapodError::ValidationError(format!(
5279                        "help smoke failed for `decapod {}`: {}",
5280                        path.join(" "),
5281                        String::from_utf8_lossy(&output.stderr).trim()
5282                    )));
5283                }
5284                Ok(())
5285            }
5286        }));
5287    }
5288    for handle in handles {
5289        handle
5290            .join()
5291            .map_err(|_| error::DecapodError::ValidationError("thread panicked".into()))??;
5292    }
5293    Ok(())
5294}
5295
5296/// Show version information
5297fn show_version_info() -> Result<(), error::DecapodError> {
5298    println!("Decapod version: {}", migration::DECAPOD_VERSION);
5299    println!("  Update: cargo install decapod");
5300
5301    Ok(())
5302}
5303
5304/// Run workspace command
5305fn run_workspace_command(
5306    cli: WorkspaceCli,
5307    project_root: &Path,
5308) -> Result<(), error::DecapodError> {
5309    use crate::core::workspace;
5310
5311    match cli.command {
5312        WorkspaceCommand::Ensure { branch, container } => {
5313            let agent_id =
5314                std::env::var("DECAPOD_AGENT_ID").unwrap_or_else(|_| "unknown".to_string());
5315            let config = branch.map(|b| workspace::WorkspaceConfig {
5316                branch: b,
5317                use_container: container,
5318                base_image: if container {
5319                    Some("rust:1.75-slim".to_string())
5320                } else {
5321                    None
5322                },
5323            });
5324            let status = workspace::ensure_workspace(project_root, config, &agent_id)?;
5325
5326            println!(
5327                "{}",
5328                serde_json::json!({
5329                    "status": if status.can_work { "ok" } else { "pending" },
5330                    "branch": status.git.current_branch,
5331                    "is_protected": status.git.is_protected,
5332                    "can_work": status.can_work,
5333                    "in_container": status.container.in_container,
5334                    "docker_available": status.container.docker_available,
5335                    "worktree_path": status.git.worktree_path,
5336                    "required_actions": status.required_actions,
5337                })
5338            );
5339        }
5340        WorkspaceCommand::Status => {
5341            let status = workspace::get_workspace_status(project_root)?;
5342
5343            println!(
5344                "{}",
5345                serde_json::json!({
5346                    "can_work": status.can_work,
5347                    "git_branch": status.git.current_branch,
5348                    "git_is_protected": status.git.is_protected,
5349                    "git_has_local_mods": status.git.has_local_mods,
5350                    "in_container": status.container.in_container,
5351                    "container_image": status.container.image,
5352                    "docker_available": status.container.docker_available,
5353                    "blockers": status.blockers.len(),
5354                    "required_actions": status.required_actions,
5355                })
5356            );
5357        }
5358        WorkspaceCommand::Publish { title, description } => {
5359            let project_store = Store {
5360                kind: StoreKind::Repo,
5361                root: project_root.join(".decapod").join("data"),
5362            };
5363            plan_governance::ensure_execute_ready(plan_governance::ExecuteCheckInput {
5364                project_root,
5365                store_root: &project_store.root,
5366                todo_id: None,
5367            })?;
5368            let report = run_validation_bounded(&project_store, project_root, false)?;
5369            if report.fail_count > 0 {
5370                return Err(error::DecapodError::ValidationError(format!(
5371                    "{} test(s) failed before workspace publish.",
5372                    report.fail_count
5373                )));
5374            }
5375            let result = workspace::publish_workspace(project_root, title, description)?;
5376            println!(
5377                "{}",
5378                serde_json::json!({
5379                    "status": "ok",
5380                    "branch": result.branch,
5381                    "commit_hash": result.commit_hash,
5382                    "remote_url": result.remote_url,
5383                    "pr_url": result.pr_url,
5384                })
5385            );
5386        }
5387    }
5388
5389    Ok(())
5390}
5391
5392/// Run STATE_COMMIT commands (prove/verify)
5393fn run_state_commit_command(
5394    cli: StateCommitCli,
5395    project_root: &Path,
5396) -> Result<(), error::DecapodError> {
5397    match cli.command {
5398        StateCommitCommand::Prove { base, head, output } => {
5399            let head = head.unwrap_or_else(|| {
5400                state_commit::run_git(project_root, &["rev-parse", "HEAD"])
5401                    .unwrap_or_else(|_| "HEAD".to_string())
5402            });
5403
5404            println!("Computing STATE_COMMIT:");
5405            println!("  base: {}", base);
5406            println!("  head: {}", head);
5407
5408            // Use library function
5409            let input = state_commit::StateCommitInput {
5410                base_sha: base,
5411                head_sha: head.clone(),
5412                ignore_policy_hash: "da39a3ee5e6b4b0d3255bfef95601890afd80709".to_string(), // empty
5413            };
5414
5415            let result = state_commit::prove(&input, project_root)
5416                .map_err(error::DecapodError::ValidationError)?;
5417
5418            println!("  files: {}", result.entries.len());
5419
5420            // Write output
5421            std::fs::write(&output, &result.scope_record_bytes)
5422                .map_err(error::DecapodError::IoError)?;
5423
5424            println!("  scope_record_hash: {}", result.scope_record_hash);
5425            println!("  state_commit_root: {}", result.state_commit_root);
5426            println!("  output: {}", output.display());
5427
5428            Ok(())
5429        }
5430        StateCommitCommand::Verify {
5431            scope_record,
5432            expected_root,
5433        } => {
5434            // Read scope record
5435            let cbor_bytes = std::fs::read(&scope_record).map_err(error::DecapodError::IoError)?;
5436
5437            // Use library function for verification
5438            let record_hash = if let Some(ref exp) = expected_root {
5439                match state_commit::verify(&cbor_bytes, exp) {
5440                    Ok(h) => h,
5441                    Err(e) => {
5442                        println!("STATE_COMMIT verification:");
5443                        println!("  scope_record: {}", scope_record.display());
5444                        println!("  ❌ MISMATCH: {}", e);
5445                        return Err(error::DecapodError::ValidationError(e));
5446                    }
5447                }
5448            } else {
5449                use sha2::{Digest, Sha256};
5450                let mut hasher = Sha256::new();
5451                hasher.update(&cbor_bytes);
5452                format!("{:x}", hasher.finalize())
5453            };
5454
5455            println!("STATE_COMMIT verification:");
5456            println!("  scope_record: {}", scope_record.display());
5457            println!("  scope_record_hash: {}", record_hash);
5458            println!("  ✅ VERIFIED");
5459
5460            Ok(())
5461        }
5462        StateCommitCommand::Explain { scope_record } => {
5463            // Read and parse scope_record
5464            let cbor_bytes = std::fs::read(&scope_record).map_err(error::DecapodError::IoError)?;
5465
5466            // Compute hashes
5467            use sha2::{Digest, Sha256};
5468            let mut hasher = Sha256::new();
5469            hasher.update(&cbor_bytes);
5470            let scope_record_hash = format!("{:x}", hasher.finalize());
5471
5472            // Parse basic structure (simplified - looks for embedded strings)
5473            let content = String::from_utf8_lossy(&cbor_bytes);
5474
5475            println!("STATE_COMMIT Explanation:");
5476            println!("  File: {}", scope_record.display());
5477            println!("  Size: {} bytes", cbor_bytes.len());
5478            println!("  scope_record_hash: {}", scope_record_hash);
5479            println!();
5480
5481            // Try to extract version and SHAs from the CBOR structure
5482            if let Some(version_pos) = content.find("state_commit.")
5483                && let Some(end_pos) = content[version_pos..].find('\0')
5484            {
5485                println!(
5486                    "  algo_version: {}",
5487                    &content[version_pos..version_pos + end_pos]
5488                );
5489            }
5490
5491            // Count entries (looking for patterns in the binary data)
5492            let entry_count = content.matches("kind=").count();
5493            println!("  Estimated entries: {}", entry_count);
5494            println!();
5495
5496            println!("Note: scope_record_hash is sha256(scope_record_bytes)");
5497            println!("      state_commit_root is the Merkle root of entry hashes");
5498
5499            Ok(())
5500        }
5501    }
5502}
5503
5504// --- RPC Handler Context and Extracted Handlers ---
5505
5506/// Shared context threaded through all RPC handlers.
5507struct RpcCtx<'a> {
5508    project_root: &'a Path,
5509    store: &'a Store,
5510    request: &'a crate::core::rpc::RpcRequest,
5511    mandates: Vec<crate::core::docs::Mandate>,
5512}
5513
5514mod rpc_handlers {
5515    use super::RpcCtx;
5516    use super::*;
5517    use crate::core::assurance::{AssuranceEngine, AssuranceEvaluateInput};
5518    use crate::core::interview;
5519    use crate::core::mentor;
5520    use crate::core::rpc::*;
5521    use crate::core::standards;
5522    use crate::core::workspace;
5523
5524    pub(crate) fn handle_agent_init(ctx: &RpcCtx) -> Result<RpcResponse, error::DecapodError> {
5525        let workspace_status = workspace::get_workspace_status(ctx.project_root)?;
5526        let mut allowed_ops = workspace::get_allowed_ops(&workspace_status);
5527
5528        let agent_id = current_agent_id();
5529        if agent_id != "unknown"
5530            && let Ok(mut tasks) = todo::list_tasks(
5531                &ctx.store.root,
5532                Some("open".to_string()),
5533                None,
5534                None,
5535                None,
5536                None,
5537            )
5538        {
5539            tasks.retain(|t| t.assigned_to == agent_id);
5540            if tasks.is_empty() {
5541                allowed_ops.insert(
5542                    0,
5543                    AllowedOp {
5544                        op: "todo.add".to_string(),
5545                        reason: "MANDATORY: Create a task for your work".to_string(),
5546                        required_params: vec!["title".to_string()],
5547                    },
5548                );
5549            } else if tasks.iter().any(|t| t.assigned_to.is_empty()) {
5550                allowed_ops.insert(
5551                    0,
5552                    AllowedOp {
5553                        op: "todo.claim".to_string(),
5554                        reason: "MANDATORY: Claim your assigned task".to_string(),
5555                        required_params: vec!["id".to_string()],
5556                    },
5557                );
5558            }
5559        }
5560
5561        let context_capsule = if workspace_status.can_work {
5562            Some(ContextCapsule {
5563                fragments: vec![],
5564                spec: Some("Agent initialized successfully".to_string()),
5565                architecture: None,
5566                security: None,
5567                standards: Some({
5568                    let resolved = standards::resolve_standards(ctx.project_root)?;
5569                    let mut map = std::collections::HashMap::new();
5570                    map.insert(
5571                        "project_name".to_string(),
5572                        serde_json::json!(resolved.project_name),
5573                    );
5574                    map
5575                }),
5576            })
5577        } else {
5578            None
5579        };
5580
5581        let _blocked_by = if !workspace_status.can_work {
5582            workspace_status.blockers.clone()
5583        } else {
5584            vec![]
5585        };
5586
5587        let mut response = success_response(
5588            ctx.request.id.clone(),
5589            ctx.request.op.clone(),
5590            ctx.request.params.clone(),
5591            None,
5592            vec![],
5593            context_capsule,
5594            allowed_ops,
5595            ctx.mandates.clone(),
5596        );
5597        response.result = Some(serde_json::json!({
5598            "environment_context": {
5599                "repo_root": ctx.project_root.to_string_lossy(),
5600                "workspace_path": ctx.project_root.to_string_lossy(),
5601                "tool_summary": {
5602                    "docker_available": workspace_status.container.docker_available,
5603                    "in_container": workspace_status.container.in_container,
5604                },
5605                "done_means": "decapod validate passes"
5606            }
5607        }));
5608        mark_constitution_initialized(ctx.project_root)?;
5609        Ok(response)
5610    }
5611
5612    pub(crate) fn handle_workspace_status(
5613        ctx: &RpcCtx,
5614    ) -> Result<RpcResponse, error::DecapodError> {
5615        let status = workspace::get_workspace_status(ctx.project_root)?;
5616        let blocked_by = status.blockers.clone();
5617        let allowed_ops = workspace::get_allowed_ops(&status);
5618
5619        let mut response = success_response(
5620            ctx.request.id.clone(),
5621            ctx.request.op.clone(),
5622            ctx.request.params.clone(),
5623            None,
5624            vec![],
5625            None,
5626            allowed_ops,
5627            ctx.mandates.clone(),
5628        );
5629        response.result = Some(serde_json::json!({
5630            "git_branch": status.git.current_branch,
5631            "git_is_protected": status.git.is_protected,
5632            "in_container": status.container.in_container,
5633            "can_work": status.can_work,
5634        }));
5635        response.blocked_by = blocked_by;
5636        Ok(response)
5637    }
5638
5639    pub(crate) fn handle_workspace_ensure(
5640        ctx: &RpcCtx,
5641    ) -> Result<RpcResponse, error::DecapodError> {
5642        let agent_id = std::env::var("DECAPOD_AGENT_ID").unwrap_or_else(|_| "unknown".to_string());
5643        let branch = ctx
5644            .request
5645            .params
5646            .get("branch")
5647            .and_then(|v| v.as_str())
5648            .map(|s| s.to_string());
5649
5650        let config = branch.map(|b| workspace::WorkspaceConfig {
5651            branch: b,
5652            use_container: false,
5653            base_image: None,
5654        });
5655
5656        let status = workspace::ensure_workspace(ctx.project_root, config, &agent_id)?;
5657        let allowed_ops = workspace::get_allowed_ops(&status);
5658
5659        Ok(success_response(
5660            ctx.request.id.clone(),
5661            ctx.request.op.clone(),
5662            ctx.request.params.clone(),
5663            None,
5664            vec![format!(".git/refs/heads/{}", status.git.current_branch)],
5665            None,
5666            allowed_ops,
5667            ctx.mandates.clone(),
5668        ))
5669    }
5670
5671    pub(crate) fn handle_workspace_publish(
5672        ctx: &RpcCtx,
5673    ) -> Result<RpcResponse, error::DecapodError> {
5674        let store_root = ctx.project_root.join(".decapod").join("data");
5675        plan_governance::ensure_execute_ready(plan_governance::ExecuteCheckInput {
5676            project_root: ctx.project_root,
5677            store_root: &store_root,
5678            todo_id: None,
5679        })?;
5680        let title = ctx
5681            .request
5682            .params
5683            .get("title")
5684            .and_then(|v| v.as_str())
5685            .map(|s| s.to_string());
5686        let description = ctx
5687            .request
5688            .params
5689            .get("description")
5690            .and_then(|v| v.as_str())
5691            .map(|s| s.to_string());
5692
5693        let result = workspace::publish_workspace(ctx.project_root, title, description)?;
5694
5695        Ok(success_response(
5696            ctx.request.id.clone(),
5697            ctx.request.op.clone(),
5698            ctx.request.params.clone(),
5699            Some(serde_json::json!({
5700                "branch": result.branch,
5701                "commit_hash": result.commit_hash,
5702                "remote_url": result.remote_url,
5703                "pr_url": result.pr_url,
5704            })),
5705            vec![format!(".git/refs/heads/{}", result.branch)],
5706            None,
5707            vec![AllowedOp {
5708                op: "validate".to_string(),
5709                reason: "Publish complete - run validation".to_string(),
5710                required_params: vec![],
5711            }],
5712            ctx.mandates.clone(),
5713        ))
5714    }
5715
5716    pub(crate) fn handle_context_resolve(ctx: &RpcCtx) -> Result<RpcResponse, error::DecapodError> {
5717        let params = &ctx.request.params;
5718        let op = params.get("op").and_then(|v| v.as_str());
5719        let touched_paths = params.get("touched_paths").and_then(|v| v.as_array());
5720        let intent_tags = params.get("intent_tags").and_then(|v| v.as_array());
5721        let query = params.get("query").and_then(|v| v.as_str());
5722        let limit = params.get("limit").and_then(|v| v.as_u64()).unwrap_or(5) as usize;
5723
5724        let mut fragments = Vec::new();
5725        let bindings = docs::get_bindings(ctx.project_root);
5726
5727        if let Some(o) = op
5728            && let Some(doc_ref) = bindings.ops.get(o)
5729        {
5730            let parts: Vec<&str> = doc_ref.split('#').collect();
5731            let path = parts[0];
5732            let anchor = parts.get(1).copied();
5733            if let Some(f) = docs::get_fragment(ctx.project_root, path, anchor) {
5734                fragments.push(f);
5735            }
5736        }
5737
5738        if let Some(paths) = touched_paths {
5739            for p in paths.iter().filter_map(|v| v.as_str()) {
5740                for (prefix, doc_ref) in &bindings.paths {
5741                    if p.contains(prefix) {
5742                        let parts: Vec<&str> = doc_ref.split('#').collect();
5743                        let path = parts[0];
5744                        let anchor = parts.get(1).copied();
5745                        if let Some(f) = docs::get_fragment(ctx.project_root, path, anchor) {
5746                            fragments.push(f);
5747                        }
5748                    }
5749                }
5750            }
5751        }
5752
5753        if let Some(tags) = intent_tags {
5754            for t in tags.iter().filter_map(|v| v.as_str()) {
5755                if let Some(doc_ref) = bindings.tags.get(t) {
5756                    let parts: Vec<&str> = doc_ref.split('#').collect();
5757                    let path = parts[0];
5758                    let anchor = parts.get(1).copied();
5759                    if let Some(f) = docs::get_fragment(ctx.project_root, path, anchor) {
5760                        fragments.push(f);
5761                    }
5762                }
5763            }
5764        }
5765
5766        fragments.sort_by(|a, b| a.r#ref.cmp(&b.r#ref));
5767        fragments.dedup_by(|a, b| a.r#ref == b.r#ref);
5768        let touched_vec = touched_paths
5769            .map(|arr| {
5770                arr.iter()
5771                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
5772                    .collect::<Vec<_>>()
5773            })
5774            .unwrap_or_default();
5775        let tags_vec = intent_tags
5776            .map(|arr| {
5777                arr.iter()
5778                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
5779                    .collect::<Vec<_>>()
5780            })
5781            .unwrap_or_default();
5782        let scoped_fragments = docs::resolve_scoped_fragments(
5783            ctx.project_root,
5784            query,
5785            op,
5786            &touched_vec,
5787            &tags_vec,
5788            limit,
5789        );
5790        fragments.extend(scoped_fragments.clone());
5791        fragments.sort_by(|a, b| a.r#ref.cmp(&b.r#ref));
5792        fragments.dedup_by(|a, b| a.r#ref == b.r#ref);
5793        fragments.truncate(limit.max(1));
5794
5795        let local_specs = core::project_specs::local_project_specs_context(ctx.project_root);
5796        let canonical_paths = local_specs.canonical_paths.clone();
5797        let constitution_refs = local_specs.constitution_refs.clone();
5798        let local_intent = local_specs.intent.clone();
5799        let local_architecture = local_specs.architecture.clone();
5800        let local_interfaces = local_specs.interfaces.clone();
5801        let local_validation = local_specs.validation.clone();
5802        let local_update_guidance = local_specs.update_guidance.clone();
5803
5804        let result = serde_json::json!({
5805            "fragments": fragments,
5806            "scoped_fragments": scoped_fragments,
5807            "local_project_specs": {
5808                "canonical_paths": canonical_paths,
5809                "constitution_refs": constitution_refs,
5810                "intent": local_intent,
5811                "architecture": local_architecture,
5812                "interfaces": local_interfaces,
5813                "validation": local_validation,
5814                "update_guidance": local_update_guidance
5815            }
5816        });
5817        mark_constitution_context_resolved(ctx.project_root)?;
5818
5819        Ok(success_response(
5820            ctx.request.id.clone(),
5821            ctx.request.op.clone(),
5822            ctx.request.params.clone(),
5823            Some(result),
5824            vec![],
5825            Some(ContextCapsule {
5826                fragments,
5827                spec: local_specs.intent.clone(),
5828                architecture: local_specs.architecture.clone(),
5829                security: None,
5830                standards: Some({
5831                    let mut m = std::collections::HashMap::new();
5832                    m.insert(
5833                        "local_project_specs".to_string(),
5834                        serde_json::json!({
5835                            "canonical_paths": local_specs.canonical_paths,
5836                            "constitution_refs": local_specs.constitution_refs,
5837                            "interfaces": local_specs.interfaces,
5838                            "validation": local_specs.validation,
5839                            "update_guidance": local_specs.update_guidance
5840                        }),
5841                    );
5842                    m
5843                }),
5844            }),
5845            vec![
5846                AllowedOp {
5847                    op: "store.upsert".to_string(),
5848                    reason: "Persist significant decisions for audit trail before proceeding"
5849                        .to_string(),
5850                    required_params: vec!["kind".to_string(), "data".to_string()],
5851                },
5852                AllowedOp {
5853                    op: "validate.run".to_string(),
5854                    reason: "Validate your changes against constitution before claiming done"
5855                        .to_string(),
5856                    required_params: vec![],
5857                },
5858                AllowedOp {
5859                    op: "store.query".to_string(),
5860                    reason: "Retrieve prior decisions and knowledge relevant to current task"
5861                        .to_string(),
5862                    required_params: vec!["kind".to_string()],
5863                },
5864            ],
5865            ctx.mandates.clone(),
5866        ))
5867    }
5868
5869    pub(crate) fn handle_context_capsule_query(
5870        ctx: &RpcCtx,
5871    ) -> Result<RpcResponse, error::DecapodError> {
5872        let params = &ctx.request.params;
5873        let topic = params
5874            .get("topic")
5875            .and_then(|v| v.as_str())
5876            .ok_or_else(|| {
5877                error::DecapodError::ValidationError(
5878                    "context.capsule.query requires 'topic'".to_string(),
5879                )
5880            })?;
5881        let scope = params
5882            .get("scope")
5883            .and_then(|v| v.as_str())
5884            .ok_or_else(|| {
5885                error::DecapodError::ValidationError(
5886                    "context.capsule.query requires 'scope'".to_string(),
5887                )
5888            })?;
5889        let task_id = params.get("task_id").and_then(|v| v.as_str());
5890        let workunit_id = params.get("workunit_id").and_then(|v| v.as_str());
5891        let limit = params.get("limit").and_then(|v| v.as_u64()).unwrap_or(6) as usize;
5892        let risk_tier = params.get("risk_tier").and_then(|v| v.as_str());
5893        let write = params
5894            .get("write")
5895            .and_then(|v| v.as_bool())
5896            .unwrap_or(false);
5897
5898        let resolved_policy = core::capsule_policy::resolve_capsule_policy(
5899            ctx.project_root,
5900            scope,
5901            risk_tier,
5902            limit,
5903            write,
5904        )?;
5905        let capsule = core::context_capsule::query_embedded_capsule_governed(
5906            ctx.project_root,
5907            topic,
5908            scope,
5909            task_id,
5910            workunit_id,
5911            resolved_policy.effective_limit,
5912            resolved_policy.binding,
5913        )?;
5914
5915        let mut touched = Vec::new();
5916        if write {
5917            let path = core::context_capsule::write_context_capsule(ctx.project_root, &capsule)?;
5918            touched.push(path.to_string_lossy().to_string());
5919            if let Some(workunit_path) = maybe_bind_capsule_to_workunit_state_ref(
5920                ctx.project_root,
5921                task_id.or(workunit_id),
5922                &path,
5923            )? {
5924                touched.push(workunit_path.to_string_lossy().to_string());
5925            }
5926        }
5927
5928        Ok(success_response(
5929            ctx.request.id.clone(),
5930            ctx.request.op.clone(),
5931            ctx.request.params.clone(),
5932            Some(serde_json::to_value(&capsule).unwrap()),
5933            touched,
5934            Some(ContextCapsule {
5935                fragments: vec![],
5936                spec: Some("Deterministic context capsule query completed".to_string()),
5937                architecture: None,
5938                security: None,
5939                standards: None,
5940            }),
5941            vec![],
5942            ctx.mandates.clone(),
5943        ))
5944    }
5945
5946    pub(crate) fn handle_context_bindings(
5947        ctx: &RpcCtx,
5948    ) -> Result<RpcResponse, error::DecapodError> {
5949        let bindings = docs::get_bindings(ctx.project_root);
5950        Ok(success_response(
5951            ctx.request.id.clone(),
5952            ctx.request.op.clone(),
5953            ctx.request.params.clone(),
5954            Some(serde_json::to_value(bindings).unwrap()),
5955            vec![],
5956            None,
5957            vec![],
5958            ctx.mandates.clone(),
5959        ))
5960    }
5961
5962    pub(crate) fn handle_schema_get(ctx: &RpcCtx) -> Result<RpcResponse, error::DecapodError> {
5963        let entity = ctx.request.params.get("entity").and_then(|v| v.as_str());
5964        match entity {
5965            Some("todo") => Ok(success_response(
5966                ctx.request.id.clone(),
5967                ctx.request.op.clone(),
5968                ctx.request.params.clone(),
5969                Some(serde_json::json!({
5970                    "schema_version": "v1",
5971                    "json_schema": {
5972                        "type": "object",
5973                        "properties": {
5974                            "title": { "type": "string" },
5975                            "description": { "type": "string" },
5976                            "priority": { "type": "string", "enum": ["low", "medium", "high", "critical"] },
5977                            "tags": { "type": "string" }
5978                        },
5979                        "required": ["title"]
5980                    }
5981                })),
5982                vec![],
5983                None,
5984                vec![],
5985                ctx.mandates.clone(),
5986            )),
5987            Some("knowledge") => Ok(success_response(
5988                ctx.request.id.clone(),
5989                ctx.request.op.clone(),
5990                ctx.request.params.clone(),
5991                Some(serde_json::json!({
5992                    "schema_version": "v1",
5993                    "json_schema": {
5994                        "type": "object",
5995                        "properties": {
5996                            "id": { "type": "string" },
5997                            "title": { "type": "string" },
5998                            "text": { "type": "string" },
5999                            "provenance": { "type": "string" }
6000                        },
6001                        "required": ["id", "title", "text", "provenance"]
6002                    }
6003                })),
6004                vec![],
6005                None,
6006                vec![],
6007                ctx.mandates.clone(),
6008            )),
6009            Some("decision") => Ok(success_response(
6010                ctx.request.id.clone(),
6011                ctx.request.op.clone(),
6012                ctx.request.params.clone(),
6013                Some(serde_json::json!({
6014                    "schema_version": "v1",
6015                    "json_schema": {
6016                        "type": "object",
6017                        "properties": {
6018                            "title": { "type": "string" },
6019                            "rationale": { "type": "string" },
6020                            "options": { "type": "array", "items": { "type": "string" } },
6021                            "chosen": { "type": "string" }
6022                        },
6023                        "required": ["title", "rationale", "chosen"]
6024                    }
6025                })),
6026                vec![],
6027                None,
6028                vec![],
6029                ctx.mandates.clone(),
6030            )),
6031            _ => Ok(error_response(
6032                ctx.request.id.clone(),
6033                ctx.request.op.clone(),
6034                ctx.request.params.clone(),
6035                "invalid_entity".to_string(),
6036                format!("Invalid or missing entity: {:?}", entity),
6037                None,
6038                ctx.mandates.clone(),
6039            )),
6040        }
6041    }
6042
6043    pub(crate) fn handle_store_upsert(ctx: &RpcCtx) -> Result<RpcResponse, error::DecapodError> {
6044        let params = &ctx.request.params;
6045        let entity = params.get("entity").and_then(|v| v.as_str());
6046        let payload = params.get("payload");
6047        let _provenance = params.get("provenance");
6048
6049        match entity {
6050            Some("todo") => {
6051                let title = payload
6052                    .and_then(|p| p.get("title"))
6053                    .and_then(|v| v.as_str())
6054                    .unwrap_or("")
6055                    .to_string();
6056                let description = payload
6057                    .and_then(|p| p.get("description"))
6058                    .and_then(|v| v.as_str())
6059                    .unwrap_or("")
6060                    .to_string();
6061                let priority = payload
6062                    .and_then(|p| p.get("priority"))
6063                    .and_then(|v| v.as_str())
6064                    .unwrap_or("medium")
6065                    .to_string();
6066                let tags = payload
6067                    .and_then(|p| p.get("tags"))
6068                    .and_then(|v| v.as_str())
6069                    .unwrap_or("")
6070                    .to_string();
6071
6072                let args = todo::TodoCommand::Add {
6073                    title,
6074                    description,
6075                    priority,
6076                    tags,
6077                    owner: "".to_string(),
6078                    due: None,
6079                    r#ref: "".to_string(),
6080                    dir: None,
6081                    depends_on: "".to_string(),
6082                    blocks: "".to_string(),
6083                    parent: None,
6084                    one_shot: 0,
6085                };
6086                let res = todo::add_task(&ctx.store.root, &args)?;
6087                Ok(success_response(
6088                    ctx.request.id.clone(),
6089                    ctx.request.op.clone(),
6090                    ctx.request.params.clone(),
6091                    Some(serde_json::json!({ "id": res.get("id"), "stored": true })),
6092                    vec![],
6093                    None,
6094                    vec![],
6095                    ctx.mandates.clone(),
6096                ))
6097            }
6098            Some("knowledge") => {
6099                let id = payload
6100                    .and_then(|p| p.get("id"))
6101                    .and_then(|v| v.as_str())
6102                    .unwrap_or("")
6103                    .to_string();
6104                let title = payload
6105                    .and_then(|p| p.get("title"))
6106                    .and_then(|v| v.as_str())
6107                    .unwrap_or("")
6108                    .to_string();
6109                let text = payload
6110                    .and_then(|p| p.get("text"))
6111                    .and_then(|v| v.as_str())
6112                    .unwrap_or("")
6113                    .to_string();
6114                let provenance = payload
6115                    .and_then(|p| p.get("provenance"))
6116                    .and_then(|v| v.as_str())
6117                    .unwrap_or("")
6118                    .to_string();
6119
6120                db::initialize_knowledge_db(&ctx.store.root)?;
6121                let result = knowledge::add_knowledge(
6122                    ctx.store,
6123                    knowledge::AddKnowledgeParams {
6124                        id: &id,
6125                        title: &title,
6126                        content: &text,
6127                        provenance: &provenance,
6128                        claim_id: None,
6129                        merge_key: None,
6130                        conflict_policy: knowledge::KnowledgeConflictPolicy::Merge,
6131                        status: "active",
6132                        ttl_policy: "persistent",
6133                        expires_ts: None,
6134                    },
6135                )?;
6136                Ok(success_response(
6137                    ctx.request.id.clone(),
6138                    ctx.request.op.clone(),
6139                    ctx.request.params.clone(),
6140                    Some(
6141                        serde_json::json!({ "id": result.id, "stored": true, "action": result.action }),
6142                    ),
6143                    vec![],
6144                    None,
6145                    vec![],
6146                    ctx.mandates.clone(),
6147                ))
6148            }
6149            Some("decision") => {
6150                let title = payload
6151                    .and_then(|p| p.get("title"))
6152                    .and_then(|v| v.as_str())
6153                    .unwrap_or("")
6154                    .to_string();
6155                let rationale = payload
6156                    .and_then(|p| p.get("rationale"))
6157                    .and_then(|v| v.as_str())
6158                    .unwrap_or("")
6159                    .to_string();
6160                let chosen = payload
6161                    .and_then(|p| p.get("chosen"))
6162                    .and_then(|v| v.as_str())
6163                    .unwrap_or("")
6164                    .to_string();
6165
6166                let content = format!("Decision: {}\nRationale: {}", chosen, rationale);
6167                let node_id = federation::add_node(
6168                    ctx.store,
6169                    &title,
6170                    "decision",
6171                    "notable",
6172                    "agent_inferred",
6173                    &content,
6174                    "rpc:store.upsert",
6175                    "",
6176                    "repo",
6177                    None,
6178                    "agent",
6179                )?;
6180                Ok(success_response(
6181                    ctx.request.id.clone(),
6182                    ctx.request.op.clone(),
6183                    ctx.request.params.clone(),
6184                    Some(serde_json::json!({ "id": node_id, "stored": true })),
6185                    vec![],
6186                    None,
6187                    vec![],
6188                    ctx.mandates.clone(),
6189                ))
6190            }
6191            _ => Ok(error_response(
6192                ctx.request.id.clone(),
6193                ctx.request.op.clone(),
6194                ctx.request.params.clone(),
6195                "invalid_entity".to_string(),
6196                format!("Invalid or missing entity: {:?}", entity),
6197                None,
6198                ctx.mandates.clone(),
6199            )),
6200        }
6201    }
6202
6203    pub(crate) fn handle_store_query(ctx: &RpcCtx) -> Result<RpcResponse, error::DecapodError> {
6204        let params = &ctx.request.params;
6205        let entity = params.get("entity").and_then(|v| v.as_str());
6206        let query = params.get("query");
6207
6208        match entity {
6209            Some("todo") => {
6210                let status = query
6211                    .and_then(|q| q.get("status"))
6212                    .and_then(|v| v.as_str())
6213                    .map(|s| s.to_string());
6214                let tasks = todo::list_tasks(&ctx.store.root, status, None, None, None, None)?;
6215                Ok(success_response(
6216                    ctx.request.id.clone(),
6217                    ctx.request.op.clone(),
6218                    ctx.request.params.clone(),
6219                    Some(serde_json::json!({ "items": tasks, "next_page": null })),
6220                    vec![],
6221                    None,
6222                    vec![],
6223                    ctx.mandates.clone(),
6224                ))
6225            }
6226            Some("knowledge") => {
6227                let text = query
6228                    .and_then(|q| q.get("text"))
6229                    .and_then(|v| v.as_str())
6230                    .unwrap_or("");
6231                db::initialize_knowledge_db(&ctx.store.root)?;
6232                let entries = knowledge::search_knowledge(
6233                    ctx.store,
6234                    text,
6235                    knowledge::SearchOptions {
6236                        as_of: None,
6237                        window_days: None,
6238                        rank: "relevance",
6239                    },
6240                )?;
6241                Ok(success_response(
6242                    ctx.request.id.clone(),
6243                    ctx.request.op.clone(),
6244                    ctx.request.params.clone(),
6245                    Some(serde_json::json!({ "items": entries, "next_page": null })),
6246                    vec![],
6247                    None,
6248                    vec![],
6249                    ctx.mandates.clone(),
6250                ))
6251            }
6252            Some("decision") => {
6253                let nodes = plugins::federation_ext::list_nodes(
6254                    &ctx.store.root,
6255                    Some("decision".to_string()),
6256                    None,
6257                    None,
6258                    None,
6259                )?;
6260                Ok(success_response(
6261                    ctx.request.id.clone(),
6262                    ctx.request.op.clone(),
6263                    ctx.request.params.clone(),
6264                    Some(serde_json::json!({ "items": nodes, "next_page": null })),
6265                    vec![],
6266                    None,
6267                    vec![],
6268                    ctx.mandates.clone(),
6269                ))
6270            }
6271            _ => Ok(error_response(
6272                ctx.request.id.clone(),
6273                ctx.request.op.clone(),
6274                ctx.request.params.clone(),
6275                "invalid_entity".to_string(),
6276                format!("Invalid or missing entity: {:?}", entity),
6277                None,
6278                ctx.mandates.clone(),
6279            )),
6280        }
6281    }
6282
6283    pub(crate) fn handle_validate_run(ctx: &RpcCtx) -> Result<RpcResponse, error::DecapodError> {
6284        let project_store = Store {
6285            kind: StoreKind::Repo,
6286            root: ctx.project_root.join(".decapod").join("data"),
6287        };
6288        let res = run_validation_bounded(&project_store, ctx.project_root, false);
6289        match res {
6290            Ok(report) if report.fail_count == 0 => Ok(success_response(
6291                ctx.request.id.clone(),
6292                ctx.request.op.clone(),
6293                ctx.request.params.clone(),
6294                Some(serde_json::json!({ "success": true })),
6295                vec![],
6296                None,
6297                vec![],
6298                ctx.mandates.clone(),
6299            )),
6300            Ok(report) => Ok(error_response(
6301                ctx.request.id.clone(),
6302                ctx.request.op.clone(),
6303                ctx.request.params.clone(),
6304                "validation_failed".to_string(),
6305                format!("{} validation gate(s) failed", report.fail_count),
6306                None,
6307                ctx.mandates.clone(),
6308            )),
6309            Err(e) => Ok(error_response(
6310                ctx.request.id.clone(),
6311                ctx.request.op.clone(),
6312                ctx.request.params.clone(),
6313                "validation_failed".to_string(),
6314                e.to_string(),
6315                None,
6316                ctx.mandates.clone(),
6317            )),
6318        }
6319    }
6320
6321    pub(crate) fn handle_scaffold_next_question(
6322        ctx: &RpcCtx,
6323    ) -> Result<RpcResponse, error::DecapodError> {
6324        let project_name = ctx
6325            .request
6326            .params
6327            .get("project_name")
6328            .and_then(|v| v.as_str())
6329            .unwrap_or("Untitled")
6330            .to_string();
6331
6332        let interview_state = interview::init_interview(project_name);
6333        let question = interview::next_question(&interview_state);
6334
6335        let mut response = success_response(
6336            ctx.request.id.clone(),
6337            ctx.request.op.clone(),
6338            ctx.request.params.clone(),
6339            None,
6340            vec![],
6341            None,
6342            vec![AllowedOp {
6343                op: "scaffold.apply_answer".to_string(),
6344                reason: "Provide answer to continue interview".to_string(),
6345                required_params: vec!["question_id".to_string(), "value".to_string()],
6346            }],
6347            ctx.mandates.clone(),
6348        );
6349
6350        if let Some(q) = question {
6351            response.result = Some(serde_json::json!({
6352                "interview_id": interview_state.id,
6353                "question": q,
6354            }));
6355        } else {
6356            response.result = Some(serde_json::json!({
6357                "interview_id": interview_state.id,
6358                "complete": true,
6359            }));
6360        }
6361
6362        Ok(response)
6363    }
6364
6365    pub(crate) fn handle_scaffold_apply_answer(
6366        ctx: &RpcCtx,
6367    ) -> Result<RpcResponse, error::DecapodError> {
6368        let question_id = ctx
6369            .request
6370            .params
6371            .get("question_id")
6372            .and_then(|v| v.as_str())
6373            .ok_or_else(|| {
6374                error::DecapodError::ValidationError("question_id required".to_string())
6375            })?;
6376        let value = ctx
6377            .request
6378            .params
6379            .clone()
6380            .get("value")
6381            .cloned()
6382            .ok_or_else(|| error::DecapodError::ValidationError("value required".to_string()))?;
6383
6384        let mut interview_state = interview::init_interview("project".to_string());
6385        interview::apply_answer(&mut interview_state, question_id, value)?;
6386
6387        let next_q = interview::next_question(&interview_state);
6388
6389        let mut response = success_response(
6390            ctx.request.id.clone(),
6391            ctx.request.op.clone(),
6392            ctx.request.params.clone(),
6393            None,
6394            vec![],
6395            None,
6396            vec![AllowedOp {
6397                op: if next_q.is_some() {
6398                    "scaffold.next_question".to_string()
6399                } else {
6400                    "scaffold.generate_artifacts".to_string()
6401                },
6402                reason: if next_q.is_some() {
6403                    "Continue interview".to_string()
6404                } else {
6405                    "Interview complete - generate artifacts".to_string()
6406                },
6407                required_params: vec![],
6408            }],
6409            ctx.mandates.clone(),
6410        );
6411
6412        response.result = Some(serde_json::json!({
6413            "answers_count": interview_state.answers.len(),
6414            "is_complete": interview_state.is_complete,
6415        }));
6416
6417        Ok(response)
6418    }
6419
6420    pub(crate) fn handle_scaffold_generate_artifacts(
6421        ctx: &RpcCtx,
6422    ) -> Result<RpcResponse, error::DecapodError> {
6423        let interview_state = interview::init_interview("project".to_string());
6424        let output_dir = ctx.project_root.to_path_buf();
6425
6426        let artifacts = interview::generate_artifacts(&interview_state, &output_dir)?;
6427        let touched_paths: Vec<String> = artifacts
6428            .iter()
6429            .map(|a| a.path.to_string_lossy().to_string())
6430            .collect();
6431
6432        Ok(success_response(
6433            ctx.request.id.clone(),
6434            ctx.request.op.clone(),
6435            ctx.request.params.clone(),
6436            None,
6437            touched_paths,
6438            None,
6439            vec![AllowedOp {
6440                op: "validate".to_string(),
6441                reason: "Artifacts generated - validate before claiming done".to_string(),
6442                required_params: vec![],
6443            }],
6444            ctx.mandates.clone(),
6445        ))
6446    }
6447
6448    pub(crate) fn handle_standards_resolve(
6449        ctx: &RpcCtx,
6450    ) -> Result<RpcResponse, error::DecapodError> {
6451        let resolved = standards::resolve_standards(ctx.project_root)?;
6452
6453        let mut standards_map = std::collections::HashMap::new();
6454        standards_map.insert(
6455            "project_name".to_string(),
6456            serde_json::json!(resolved.project_name),
6457        );
6458        for (k, v) in &resolved.standards {
6459            standards_map.insert(k.clone(), v.clone());
6460        }
6461
6462        let context_capsule = ContextCapsule {
6463            fragments: vec![],
6464            spec: None,
6465            architecture: None,
6466            security: None,
6467            standards: Some(standards_map),
6468        };
6469
6470        Ok(success_response(
6471            ctx.request.id.clone(),
6472            ctx.request.op.clone(),
6473            ctx.request.params.clone(),
6474            None,
6475            vec![],
6476            Some(context_capsule),
6477            vec![],
6478            ctx.mandates.clone(),
6479        ))
6480    }
6481
6482    pub(crate) fn handle_mentor_obligations(
6483        ctx: &RpcCtx,
6484    ) -> Result<RpcResponse, error::DecapodError> {
6485        use crate::core::mentor::{MentorEngine, ObligationsContext};
6486
6487        let engine = MentorEngine::new(ctx.project_root);
6488        let obligations_ctx = ObligationsContext {
6489            op: ctx
6490                .request
6491                .params
6492                .get("op")
6493                .and_then(|v| v.as_str())
6494                .unwrap_or("unknown")
6495                .to_string(),
6496            params: ctx
6497                .request
6498                .params
6499                .get("params")
6500                .cloned()
6501                .unwrap_or(serde_json::json!({})),
6502            touched_paths: ctx
6503                .request
6504                .params
6505                .get("touched_paths")
6506                .and_then(|v| v.as_array())
6507                .map(|arr| {
6508                    arr.iter()
6509                        .filter_map(|v| v.as_str().map(|s| s.to_string()))
6510                        .collect()
6511                })
6512                .unwrap_or_default(),
6513            diff_summary: ctx
6514                .request
6515                .params
6516                .get("diff_summary")
6517                .and_then(|v| v.as_str())
6518                .map(|s| s.to_string()),
6519            project_profile_id: ctx
6520                .request
6521                .params
6522                .get("project_profile_id")
6523                .and_then(|v| v.as_str())
6524                .map(|s| s.to_string()),
6525            session_id: ctx
6526                .request
6527                .params
6528                .get("session_id")
6529                .and_then(|v| v.as_str())
6530                .map(|s| s.to_string()),
6531            high_risk: ctx
6532                .request
6533                .params
6534                .get("high_risk")
6535                .and_then(|v| v.as_bool())
6536                .unwrap_or(false),
6537        };
6538
6539        let obligations = engine.compute_obligations(&obligations_ctx)?;
6540
6541        let context_capsule = ContextCapsule {
6542            fragments: vec![],
6543            spec: None,
6544            architecture: None,
6545            security: None,
6546            standards: None,
6547        };
6548
6549        let mut response = success_response(
6550            ctx.request.id.clone(),
6551            ctx.request.op.clone(),
6552            ctx.request.params.clone(),
6553            None,
6554            vec![],
6555            Some(context_capsule),
6556            vec![AllowedOp {
6557                op: "mentor.obligations".to_string(),
6558                reason: "Obligations computed - review must list before proceeding".to_string(),
6559                required_params: vec![],
6560            }],
6561            ctx.mandates.clone(),
6562        );
6563
6564        response.result = Some(serde_json::json!({ "obligations": obligations }));
6565
6566        if !obligations.contradictions.is_empty() {
6567            response.blocked_by = mentor::contradictions_to_blockers(&obligations.contradictions);
6568        }
6569
6570        Ok(response)
6571    }
6572
6573    pub(crate) fn handle_assurance_evaluate(
6574        ctx: &RpcCtx,
6575    ) -> Result<RpcResponse, error::DecapodError> {
6576        let input = AssuranceEvaluateInput {
6577            op: ctx
6578                .request
6579                .params
6580                .get("op")
6581                .and_then(|v| v.as_str())
6582                .unwrap_or("unknown")
6583                .to_string(),
6584            params: ctx
6585                .request
6586                .params
6587                .get("params")
6588                .cloned()
6589                .unwrap_or(serde_json::json!({})),
6590            touched_paths: ctx
6591                .request
6592                .params
6593                .get("touched_paths")
6594                .and_then(|v| v.as_array())
6595                .map(|arr| {
6596                    arr.iter()
6597                        .filter_map(|v| v.as_str().map(|s| s.to_string()))
6598                        .collect()
6599                })
6600                .unwrap_or_default(),
6601            diff_summary: ctx
6602                .request
6603                .params
6604                .get("diff_summary")
6605                .and_then(|v| v.as_str())
6606                .map(|s| s.to_string()),
6607            session_id: ctx
6608                .request
6609                .params
6610                .get("session_id")
6611                .and_then(|v| v.as_str())
6612                .map(|s| s.to_string()),
6613            phase: ctx
6614                .request
6615                .params
6616                .get("phase")
6617                .cloned()
6618                .and_then(|v| serde_json::from_value(v).ok()),
6619            time_budget_s: ctx
6620                .request
6621                .params
6622                .clone()
6623                .get("time_budget_s")
6624                .and_then(|v| v.as_u64()),
6625        };
6626
6627        let engine = AssuranceEngine::new(ctx.project_root);
6628        let evaluated = engine.evaluate(&input)?;
6629        let mut response = success_response(
6630            ctx.request.id.clone(),
6631            ctx.request.op.clone(),
6632            ctx.request.params.clone(),
6633            None,
6634            input.touched_paths.clone(),
6635            None,
6636            if let Some(interlock) = &evaluated.interlock {
6637                interlock
6638                    .unblock_ops
6639                    .iter()
6640                    .map(|op| AllowedOp {
6641                        op: op.clone(),
6642                        reason: format!("Unblock path for {}", interlock.code),
6643                        required_params: vec![],
6644                    })
6645                    .collect()
6646            } else {
6647                vec![AllowedOp {
6648                    op: "assurance.evaluate".to_string(),
6649                    reason: "Re-evaluate after meaningful context changes".to_string(),
6650                    required_params: vec![],
6651                }]
6652            },
6653            ctx.mandates.clone(),
6654        );
6655        response.interlock = evaluated.interlock.clone();
6656        response.advisory = Some(evaluated.advisory.clone());
6657        response.attestation = Some(evaluated.attestation.clone());
6658        response.result = Some(serde_json::json!({
6659            "assurance_evaluated": true,
6660            "interlock_code": evaluated.interlock.as_ref().map(|i| i.code.clone()),
6661        }));
6662        if let Some(interlock) = evaluated.interlock {
6663            response.blocked_by = vec![Blocker {
6664                kind: match interlock.code.as_str() {
6665                    "workspace_required" => BlockerKind::WorkspaceRequired,
6666                    "verification_required" => BlockerKind::MissingProof,
6667                    "store_boundary_violation" => BlockerKind::Unauthorized,
6668                    "decision_required" => BlockerKind::MissingAnswer,
6669                    _ => BlockerKind::ValidationFailed,
6670                },
6671                message: interlock.code,
6672                resolve_hint: interlock.message,
6673            }];
6674        }
6675        Ok(response)
6676    }
6677}
6678
6679/// Run RPC command
6680fn run_rpc_command(cli: RpcCli, project_root: &Path) -> Result<(), error::DecapodError> {
6681    use crate::core::rpc::*;
6682
6683    let request: RpcRequest = if cli.stdin {
6684        let mut buffer = String::new();
6685        std::io::stdin()
6686            .read_to_string(&mut buffer)
6687            .map_err(error::DecapodError::IoError)?;
6688        serde_json::from_str(&buffer)
6689            .map_err(|e| error::DecapodError::ValidationError(format!("Invalid JSON: {}", e)))?
6690    } else {
6691        let op = cli.op.ok_or_else(|| {
6692            error::DecapodError::ValidationError("Operation required".to_string())
6693        })?;
6694        let params = cli
6695            .params
6696            .as_ref()
6697            .and_then(|p| serde_json::from_str(p).ok())
6698            .unwrap_or(serde_json::json!({}));
6699
6700        RpcRequest {
6701            op,
6702            params,
6703            id: default_request_id(),
6704            session: None,
6705        }
6706    };
6707
6708    enforce_worktree_requirement_for_rpc(&request.op, project_root)?;
6709
6710    if !rpc_op_bypasses_session(&request.op) {
6711        ensure_session_valid()?;
6712    }
6713    enforce_constitutional_awareness_for_rpc(&request.op, project_root)?;
6714
6715    let project_store = Store {
6716        kind: StoreKind::Repo,
6717        root: project_root.join(".decapod").join("data"),
6718    };
6719
6720    let mandates = docs::resolve_mandates(project_root, &request.op);
6721    let mandate_blockers = if rpc_op_skips_mandate_enforcement(&request.op) {
6722        Vec::new()
6723    } else {
6724        validate::evaluate_mandates(project_root, &project_store, &mandates)
6725    };
6726
6727    // If any mandate is blocked, we fail the operation
6728    let blocked_mandate = mandates.iter().find(|m| {
6729        mandate_blockers
6730            .iter()
6731            .any(|b| b.message.contains(&m.fragment.title))
6732    });
6733
6734    if let Some(mandate) = blocked_mandate {
6735        let blocker = mandate_blockers
6736            .iter()
6737            .find(|b| b.message.contains(&mandate.fragment.title))
6738            .unwrap();
6739        let response = error_response(
6740            request.id.clone(),
6741            request.op.clone(),
6742            request.params.clone(),
6743            "mandate_violation".to_string(),
6744            blocker.message.clone(),
6745            Some(blocker.clone()),
6746            mandates,
6747        );
6748        println!("{}", serde_json::to_string_pretty(&response).unwrap());
6749        return Ok(());
6750    }
6751
6752    let rpc_ctx = RpcCtx {
6753        project_root,
6754        store: &project_store,
6755        request: &request,
6756        mandates: mandates.clone(),
6757    };
6758
6759    let response = match request.op.as_str() {
6760        "agent.init" => rpc_handlers::handle_agent_init(&rpc_ctx)?,
6761        "workspace.status" => rpc_handlers::handle_workspace_status(&rpc_ctx)?,
6762        "workspace.ensure" => rpc_handlers::handle_workspace_ensure(&rpc_ctx)?,
6763        "workspace.publish" => rpc_handlers::handle_workspace_publish(&rpc_ctx)?,
6764        "context.resolve" | "context.scope" => rpc_handlers::handle_context_resolve(&rpc_ctx)?,
6765        "context.capsule.query" => rpc_handlers::handle_context_capsule_query(&rpc_ctx)?,
6766        "context.bindings" => rpc_handlers::handle_context_bindings(&rpc_ctx)?,
6767        "schema.get" => rpc_handlers::handle_schema_get(&rpc_ctx)?,
6768        "store.upsert" => rpc_handlers::handle_store_upsert(&rpc_ctx)?,
6769        "store.query" => rpc_handlers::handle_store_query(&rpc_ctx)?,
6770        "validate.run" => rpc_handlers::handle_validate_run(&rpc_ctx)?,
6771        "scaffold.next_question" => rpc_handlers::handle_scaffold_next_question(&rpc_ctx)?,
6772        "scaffold.apply_answer" => rpc_handlers::handle_scaffold_apply_answer(&rpc_ctx)?,
6773        "scaffold.generate_artifacts" => {
6774            rpc_handlers::handle_scaffold_generate_artifacts(&rpc_ctx)?
6775        }
6776        "standards.resolve" => rpc_handlers::handle_standards_resolve(&rpc_ctx)?,
6777        "mentor.obligations" => rpc_handlers::handle_mentor_obligations(&rpc_ctx)?,
6778        "assurance.evaluate" => rpc_handlers::handle_assurance_evaluate(&rpc_ctx)?,
6779        _ => error_response(
6780            request.id.clone(),
6781            request.op.clone(),
6782            request.params.clone(),
6783            "unknown_op".to_string(),
6784            format!("Unknown operation: {}", request.op),
6785            None,
6786            mandates.clone(),
6787        ),
6788    };
6789
6790    // Trace the RPC call
6791    let trace_event = trace::TraceEvent {
6792        trace_id: request.id.clone(),
6793        ts: crate::core::time::now_epoch_z(),
6794        actor: current_agent_id(),
6795        op: request.op.clone(),
6796        request: serde_json::to_value(&request).unwrap_or(serde_json::Value::Null),
6797        response: serde_json::to_value(&response).unwrap_or(serde_json::Value::Null),
6798    };
6799    let _ = trace::append_trace(project_root, trace_event);
6800
6801    println!("{}", serde_json::to_string_pretty(&response).unwrap());
6802    Ok(())
6803}
6804
6805fn maybe_bind_capsule_to_workunit_state_ref(
6806    project_root: &Path,
6807    workunit_task_id: Option<&str>,
6808    capsule_path: &Path,
6809) -> Result<Option<PathBuf>, error::DecapodError> {
6810    let Some(task_id) = workunit_task_id else {
6811        return Ok(None);
6812    };
6813    match core::workunit::load_workunit(project_root, task_id) {
6814        Ok(_) => {
6815            let state_ref = capsule_path
6816                .strip_prefix(project_root)
6817                .unwrap_or(capsule_path)
6818                .to_string_lossy()
6819                .replace('\\', "/");
6820            core::workunit::add_state_ref(project_root, task_id, &state_ref)?;
6821            let path = core::workunit::workunit_path(project_root, task_id)?;
6822            Ok(Some(path))
6823        }
6824        Err(error::DecapodError::NotFound(_)) => Ok(None),
6825        Err(e) => Err(e),
6826    }
6827}
6828
6829/// Run capabilities command
6830fn run_capabilities_command(cli: CapabilitiesCli) -> Result<(), error::DecapodError> {
6831    use crate::core::rpc::generate_capabilities;
6832
6833    let report = generate_capabilities();
6834
6835    match cli.format.as_str() {
6836        "json" => {
6837            println!("{}", serde_json::to_string_pretty(&report).unwrap());
6838        }
6839        _ => {
6840            println!("Decapod {}", report.version);
6841            println!("==================\n");
6842
6843            println!("Capabilities:");
6844            for cap in &report.capabilities {
6845                println!("  {} [{}] - {}", cap.name, cap.stability, cap.description);
6846            }
6847
6848            println!("\nSubsystems:");
6849            for sub in &report.subsystems {
6850                println!("  {} [{}]", sub.name, sub.status);
6851                println!("    Ops: {}", sub.ops.join(", "));
6852            }
6853
6854            println!("\nWorkspace:");
6855            println!(
6856                "  Enforcement: {}",
6857                if report.workspace.enforcement_available {
6858                    "available"
6859                } else {
6860                    "unavailable"
6861                }
6862            );
6863            println!(
6864                "  Docker: {}",
6865                if report.workspace.docker_available {
6866                    "available"
6867                } else {
6868                    "unavailable"
6869                }
6870            );
6871            println!(
6872                "  Protected: {}",
6873                report.workspace.protected_patterns.join(", ")
6874            );
6875
6876            println!("\nInterview:");
6877            println!(
6878                "  Available: {}",
6879                if report.interview.available {
6880                    "yes"
6881                } else {
6882                    "no"
6883                }
6884            );
6885            println!(
6886                "  Artifacts: {}",
6887                report.interview.artifact_types.join(", ")
6888            );
6889            println!("\nInterlocks:");
6890            println!("  Codes: {}", report.interlock_codes.join(", "));
6891        }
6892    }
6893
6894    Ok(())
6895}
6896
6897fn run_trace_command(cli: TraceCli, project_root: &Path) -> Result<(), error::DecapodError> {
6898    match cli.command {
6899        TraceCommand::Export { last } => {
6900            let traces = trace::get_last_traces(project_root, last)?;
6901            for t in traces {
6902                println!("{}", t);
6903            }
6904        }
6905    }
6906    Ok(())
6907}
6908
6909fn run_preflight_command(
6910    cli: PreflightCli,
6911    project_root: &Path,
6912) -> Result<(), error::DecapodError> {
6913    use crate::core::workspace;
6914
6915    let op = cli.op.unwrap_or_else(|| "unknown".to_string());
6916
6917    let workspace_status = match workspace::get_workspace_status(project_root) {
6918        Ok(status) => status,
6919        Err(_) => {
6920            return Ok(());
6921        }
6922    };
6923
6924    let mut risk_flags = Vec::new();
6925    let mut likely_failures = Vec::new();
6926    let mut required_capsules = Vec::new();
6927    let mut next_best_actions = Vec::new();
6928
6929    if workspace_status.git.is_protected {
6930        risk_flags.push("protected_branch");
6931        likely_failures.push(serde_json::json!({
6932            "code": "WORKSPACE_REQUIRED",
6933            "message": "Cannot operate on protected branch",
6934            "current_branch": workspace_status.git.current_branch,
6935        }));
6936        next_best_actions.push("Run: decapod workspace ensure");
6937    }
6938
6939    if !workspace_status.can_work {
6940        risk_flags.push("workspace_blocked");
6941        for blocker in &workspace_status.blockers {
6942            likely_failures.push(serde_json::json!({
6943                "code": "WORKSPACE_BLOCKED",
6944                "message": blocker.message,
6945                "resolve_hint": blocker.resolve_hint,
6946            }));
6947        }
6948    }
6949
6950    match op.as_str() {
6951        "todo.add" | "todo.claim" | "todo.done" => {
6952            required_capsules.push("plugins/TODO.md");
6953            required_capsules.push("interfaces/STORE_MODEL.md");
6954        }
6955        "validate" => {
6956            required_capsules.push("plugins/VERIFY.md");
6957            required_capsules.push("interfaces/TESTING.md");
6958            if workspace_status.git.is_protected {}
6959        }
6960        "workspace.ensure" | "workspace.status" => {
6961            required_capsules.push("core/DECAPOD.md");
6962            required_capsules.push("core/PLUGINS.md");
6963        }
6964        "rpc" | "agent.init" => {
6965            required_capsules.push("core/INTERFACES.md");
6966            required_capsules.push("specs/INTENT.md");
6967        }
6968        _ => {
6969            required_capsules.push("core/DECAPOD.md");
6970        }
6971    }
6972
6973    if risk_flags.is_empty() {
6974        next_best_actions.push("Proceed with operation");
6975    }
6976
6977    let response = serde_json::json!({
6978        "op": op,
6979        "session_id": cli.session,
6980        "risk_flags": risk_flags,
6981        "likely_failures": likely_failures,
6982        "required_capsules": required_capsules,
6983        "next_best_actions": next_best_actions,
6984        "workspace": {
6985            "git_branch": workspace_status.git.current_branch,
6986            "git_is_protected": workspace_status.git.is_protected,
6987            "can_work": workspace_status.can_work,
6988        }
6989    });
6990
6991    if cli.format == "json" {
6992        println!("{}", serde_json::to_string_pretty(&response).unwrap());
6993    } else {
6994        println!("Preflight Check for: {}", op);
6995        if risk_flags.is_empty() {
6996            println!("✓ No risks detected");
6997        } else {
6998            println!("⚠ Risks: {:?}", risk_flags);
6999            println!("Likely failures:");
7000            for failure in &likely_failures {
7001                println!("  - {}: {}", failure["code"], failure["message"]);
7002            }
7003        }
7004        println!("Required capsules: {:?}", required_capsules);
7005    }
7006
7007    Ok(())
7008}
7009
7010fn run_impact_command(cli: ImpactCli, project_root: &Path) -> Result<(), error::DecapodError> {
7011    use crate::core::workspace;
7012
7013    let changed_files: Vec<String> = cli
7014        .changed_files
7015        .as_ref()
7016        .map(|s| s.split(',').map(|s| s.trim().to_string()).collect())
7017        .unwrap_or_default();
7018
7019    let workspace_status = match workspace::get_workspace_status(project_root) {
7020        Ok(status) => status,
7021        Err(_) => {
7022            let response = serde_json::json!({
7023                "changed_files": changed_files,
7024                "will_fail_validate": false,
7025                "predicted_failures": [],
7026                "validation_predictions": [],
7027                "workspace": {
7028                    "git_branch": "unknown",
7029                    "git_is_protected": false,
7030                    "can_work": true,
7031                },
7032                "recommendation": "Could not determine workspace status"
7033            });
7034            println!("{}", serde_json::to_string_pretty(&response).unwrap());
7035            return Ok(());
7036        }
7037    };
7038
7039    let mut predicted_failures = Vec::new();
7040    let mut validation_predictions = Vec::new();
7041
7042    if workspace_status.git.is_protected {
7043        predicted_failures.push(serde_json::json!({
7044            "gate": "workspace_isolation",
7045            "status": "fail",
7046            "code": "WORKSPACE_REQUIRED",
7047            "message": "Operating on protected branch",
7048        }));
7049    } else {
7050        validation_predictions.push(serde_json::json!({
7051            "gate": "workspace_isolation",
7052            "status": "pass",
7053        }));
7054    }
7055
7056    if !changed_files.is_empty() {
7057        validation_predictions.push(serde_json::json!({
7058            "gate": "file_changes_detected",
7059            "status": "pass",
7060            "changed_count": changed_files.len(),
7061        }));
7062    }
7063
7064    let will_fail_validate = !predicted_failures.is_empty();
7065
7066    let response = serde_json::json!({
7067        "changed_files": changed_files,
7068        "will_fail_validate": will_fail_validate,
7069        "predicted_failures": predicted_failures,
7070        "validation_predictions": validation_predictions,
7071        "workspace": {
7072            "git_branch": workspace_status.git.current_branch,
7073            "git_is_protected": workspace_status.git.is_protected,
7074            "can_work": workspace_status.can_work,
7075        },
7076        "recommendation": if will_fail_validate {
7077            "Fix workspace issues before running validate"
7078        } else if changed_files.is_empty() {
7079            "No changes detected - nothing to validate"
7080        } else {
7081            "Safe to run validate"
7082        }
7083    });
7084
7085    if cli.format == "json" {
7086        println!("{}", serde_json::to_string_pretty(&response).unwrap());
7087    } else {
7088        println!("Impact Analysis");
7089        if will_fail_validate {
7090            println!("⚠ Validate will FAIL");
7091            for failure in &predicted_failures {
7092                println!("  - {}: {}", failure["code"], failure["message"]);
7093            }
7094        } else {
7095            println!("✓ Validate should pass");
7096        }
7097        if !changed_files.is_empty() {
7098            println!("Changed files: {:?}", changed_files);
7099        }
7100    }
7101
7102    Ok(())
7103}
7104
7105fn run_infer_command(cli: InferCli, project_root: &Path) -> Result<(), error::DecapodError> {
7106    let project_root = project_root.to_path_buf();
7107
7108    match cli.command {
7109        InferCommand::Init(init_cli) => run_infer_init(init_cli, &project_root)?,
7110        InferCommand::Validate(validate_cli) => run_infer_validate(validate_cli)?,
7111        InferCommand::Budget(budget_cli) => run_infer_budget(budget_cli, &project_root)?,
7112    }
7113
7114    Ok(())
7115}
7116
7117fn run_infer_init(cli: InferInitCli, project_root: &Path) -> Result<(), error::DecapodError> {
7118    use std::fs;
7119
7120    let intent = cli.intent.trim().to_lowercase();
7121    let context_files: Vec<String> = cli
7122        .context
7123        .as_ref()
7124        .map(|s| s.split(',').map(|s| s.trim().to_string()).collect())
7125        .unwrap_or_default();
7126
7127    let mut selected_context = Vec::new();
7128    let mut excluded_context = Vec::new();
7129    let excluded_extensions = ["md", "lock", "toml", "json", "yml", "yaml", "git"];
7130
7131    let critical_keywords = ["fix", "bug", "error", "panic", "crash"];
7132    let docs_keywords = ["docs", "readme", "documentation", "guide"];
7133    let refactor_keywords = ["refactor", "rename", "restructure", "cleanup"];
7134
7135    let intent_type = if critical_keywords.iter().any(|k| intent.contains(*k)) {
7136        "fix"
7137    } else if refactor_keywords.iter().any(|k| intent.contains(*k)) {
7138        "refactor"
7139    } else if docs_keywords.iter().any(|k| intent.contains(*k)) {
7140        "docs"
7141    } else {
7142        "unknown"
7143    };
7144
7145    for file in &context_files {
7146        let path = project_root.join(file);
7147        if path.exists() {
7148            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
7149            if excluded_extensions.contains(&ext) && intent_type != "docs" {
7150                excluded_context.push(file.clone());
7151                continue;
7152            }
7153            if file.contains("/tests/") && !intent.contains("test") {
7154                excluded_context.push(file.clone());
7155                continue;
7156            }
7157            selected_context.push(file.clone());
7158        }
7159    }
7160
7161    if context_files.is_empty() {
7162        if let Ok(entries) = fs::read_dir(project_root.join("src")) {
7163            for entry in entries.flatten() {
7164                if let Ok(name) = entry.file_name().into_string()
7165                    && name.ends_with(".rs")
7166                    && !name.contains("_test")
7167                {
7168                    selected_context.push(format!("src/{}", name));
7169                }
7170            }
7171        }
7172        excluded_context = vec![
7173            "target/".to_string(),
7174            "build/".to_string(),
7175            ".git/".to_string(),
7176        ];
7177    }
7178
7179    let token_budget = (selected_context.len() as u64 * 500).min(100_000);
7180    let clarification_required = intent.len() < 20 || intent_type == "unknown";
7181
7182    let response = serde_json::json!({
7183        "intent": cli.intent,
7184        "intent_type": intent_type,
7185        "confidence": if clarification_required { "low" } else { "high" },
7186        "clarification_required": clarification_required,
7187        "clarification_question": if clarification_required {
7188            Some("Could you clarify what you'd like me to do?".to_string())
7189        } else { None },
7190        "selected_context": selected_context,
7191        "excluded_context": excluded_context,
7192        "selected_policies": ["default"],
7193        "token_budget": token_budget,
7194        "proof_required": intent_type == "fix",
7195        "boundaries": { "max_tokens": 100000, "context_files_limit": 20 }
7196    });
7197
7198    if cli.format == "json" {
7199        println!("{}", serde_json::to_string_pretty(&response).unwrap());
7200    } else {
7201        println!("=== Inference Context ===");
7202        println!("Intent: {}", cli.intent);
7203        println!("Type: {}", intent_type);
7204        if clarification_required {
7205            println!("⚠ Clarification needed");
7206        }
7207        println!(
7208            "Selected files: {}",
7209            response["selected_context"]
7210                .as_array()
7211                .map(|a| a.len())
7212                .unwrap_or(0)
7213        );
7214        println!("Token budget: ~{}", token_budget);
7215    }
7216
7217    Ok(())
7218}
7219
7220fn run_infer_validate(cli: InferValidateCli) -> Result<(), error::DecapodError> {
7221    let result = cli.result.trim();
7222    let intent = cli.intent.trim().to_lowercase();
7223
7224    let proof_provided =
7225        result.contains("fn ") || result.contains("struct ") || result.contains("impl ");
7226    let mut issues = Vec::new();
7227
7228    if result.contains("error") || result.contains("panic") {
7229        issues.push("Potential error/panic in output");
7230    }
7231
7232    let intent_match = if intent.contains("fix") || intent.contains("bug") {
7233        result.contains("fix") || result.contains("change")
7234    } else {
7235        true
7236    };
7237
7238    let response = serde_json::json!({
7239        "intent": cli.intent,
7240        "intent_match": intent_match,
7241        "proof_provided": proof_provided,
7242        "issues": issues,
7243        "advisory": if issues.is_empty() { "ok" } else { "review recommended" }
7244    });
7245
7246    if cli.format == "json" {
7247        println!("{}", serde_json::to_string_pretty(&response).unwrap());
7248    } else {
7249        println!("=== Validation ===");
7250        println!("Intent match: {}", if intent_match { "✓" } else { "✗" });
7251        println!("Proof provided: {}", if proof_provided { "✓" } else { "✗" });
7252    }
7253
7254    Ok(())
7255}
7256
7257fn run_infer_budget(cli: InferBudgetCli, project_root: &Path) -> Result<(), error::DecapodError> {
7258    use std::fs;
7259
7260    let context_files: Vec<String> = cli
7261        .context
7262        .as_ref()
7263        .map(|s| s.split(',').map(|s| s.trim().to_string()).collect())
7264        .unwrap_or_default();
7265
7266    let mut total_tokens = 0u64;
7267    for file in &context_files {
7268        let path = project_root.join(file);
7269        if let Ok(content) = fs::read_to_string(&path) {
7270            total_tokens += content.lines().count() as u64 * 8;
7271        }
7272    }
7273
7274    let base_tokens = 500u64;
7275    let response = serde_json::json!({
7276        "intent": cli.intent,
7277        "context_tokens": total_tokens,
7278        "base_tokens": base_tokens,
7279        "estimated_total": total_tokens + base_tokens,
7280        "within_budget": total_tokens + base_tokens < 100000,
7281        "token_budget": { "soft_limit": 100000, "recommended": 80000 }
7282    });
7283
7284    if cli.format == "json" {
7285        println!("{}", serde_json::to_string_pretty(&response).unwrap());
7286    } else {
7287        println!("=== Token Budget ===");
7288        println!("Context: ~{} tokens", total_tokens);
7289        println!("Total: ~{} tokens", total_tokens + base_tokens);
7290        println!(
7291            "Within 100k: {}",
7292            if total_tokens + base_tokens < 100000 {
7293                "✓"
7294            } else {
7295                "⚠"
7296            }
7297        );
7298    }
7299
7300    Ok(())
7301}
7302
7303fn run_demo_command(cli: DemoCli, project_root: &Path) -> Result<(), error::DecapodError> {
7304    use crate::core::workspace;
7305
7306    println!("==============================================");
7307    println!("Decapod Interlock Demo: Predict Before You Fail");
7308    println!("==============================================\n");
7309
7310    match cli.demo.as_str() {
7311        "interlock" => {
7312            println!("Step 1: Check workspace status");
7313            let status = workspace::get_workspace_status(project_root)?;
7314            println!("  Branch: {}", status.git.current_branch);
7315            println!("  Protected: {}", status.git.is_protected);
7316            println!("  Can work: {}\n", status.can_work);
7317
7318            println!("Step 2: Run preflight to predict validate outcome");
7319            run_preflight_command(
7320                PreflightCli {
7321                    op: Some("validate".to_string()),
7322                    format: "json".to_string(),
7323                    session: None,
7324                },
7325                project_root,
7326            )?;
7327            println!();
7328
7329            println!("Step 3: Run impact to predict what will happen with changes");
7330            run_impact_command(
7331                ImpactCli {
7332                    changed_files: Some("src/core/validate.rs,src/lib.rs".to_string()),
7333                    format: "json".to_string(),
7334                    predict: true,
7335                },
7336                project_root,
7337            )?;
7338            println!();
7339
7340            println!("Step 4: Verify prediction matches reality");
7341            println!("  (Running validate would show WORKSPACE_REQUIRED on protected branch)\n");
7342
7343            println!("==============================================");
7344            println!("Key insight: preflight told us:");
7345            println!("  - risk_flags: [protected_branch]");
7346            println!("  - likely_failures: [WORKSPACE_REQUIRED]");
7347            println!("  - next_best_actions: [Run: decapod workspace ensure]");
7348            println!();
7349            println!("Following that guidance prevents the failure instead of reacting to it.");
7350            println!("==============================================");
7351
7352            Ok(())
7353        }
7354        _ => {
7355            println!("Available demos:");
7356            println!("  interlock  - Shows preflight + impact prediction");
7357            Ok(())
7358        }
7359    }
7360}