Skip to main content

decapod/
lib.rs

1//! Decapod: A Project OS for AI Agents
2//!
3//! **Decapod is a local-first control plane for agentic software engineering.**
4//!
5//! This is NOT a tool for humans to orchestrate. This IS a tool for AI agents to coordinate.
6//! Humans steer via intent; agents execute via this orchestration layer.
7//!
8//! # Core Principles
9//!
10//! - **Local-first**: All state is local, versioned, and auditable
11//! - **Deterministic**: Event-sourced stores enable reproducible replay
12//! - **Agent-first**: Designed for machine consumption, not human UX
13//! - **Constitution-driven**: Embedded methodology enforces contracts
14//! - **Proof-gated**: Validation harness ensures methodology adherence
15//!
16//! # For AI Agents
17//!
18//! **You MUST:**
19//! 1. Read the constitution first: `decapod docs show core/DECAPOD.md`
20//! 2. Use the CLI exclusively: Never bypass `decapod` commands
21//! 3. Validate before completion: `decapod validate` must pass
22//! 4. Record proofs: `decapod proof run` for executable claims
23//! 5. Track work: `decapod todo add` before multi-step tasks
24//!
25//! # Architecture
26//!
27//! ## Dual-Store Model
28//!
29//! - **User Store** (`~/.decapod/data/`): Agent-local, blank-slate semantics
30//! - **Repo Store** (`<repo>/.decapod/data/`): Project-scoped, event-sourced, deterministic
31//!
32//! ## The Thin Waist
33//!
34//! All state mutations route through `DbBroker` for:
35//! - Serialization (in-process lock)
36//! - Audit logging (`broker.events.jsonl`)
37//! - Intent tracking
38//!
39//! ## Subsystems (Plugins)
40//!
41//! - `todo`: Task tracking with event sourcing
42//! - `health`: Proof-based claim status tracking
43//! - `knowledge`: Structured knowledge with provenance
44//! - `policy`: Approval gates for high-risk operations
45//! - `watcher`: Read-only constitution compliance monitoring
46//! - `archive`: Session archival with hash verification
47//! - `context`: Multi-modal context packing for agents
48//! - `cron`: Scheduled recurring tasks
49//! - `reflex`: Event-triggered automation
50//! - `feedback`: Agent-to-human proposal system
51//! - `trust`: Trust score tracking for agents
52//! - `heartbeat`: Liveness monitoring
53//!
54//! # Examples
55//!
56//! ```bash
57//! # Initialize a Decapod project
58//! decapod init
59//!
60//! # Read the methodology
61//! decapod docs show core/DECAPOD.md
62//!
63//! # Add a task
64//! decapod todo add "Implement feature X"
65//!
66//! # Run validation harness
67//! decapod validate
68//!
69//! # Run proof checks
70//! decapod proof run
71//! ```
72//!
73//! # Crate Structure
74//!
75//! - [`core`]: Fundamental types and control plane (store, broker, proof, validate)
76//! - [`plugins`]: Subsystem implementations (TODO, health, knowledge, etc.)
77
78pub mod core;
79pub mod plugins;
80
81use core::{
82    db, docs_cli, error, migration, proof, repomap, scaffold,
83    store::{Store, StoreKind},
84    tui, validate,
85};
86use plugins::{
87    archive, context, cron, feedback, health, knowledge, policy, reflex, teammate, todo, verify,
88    watcher,
89};
90
91use clap::{Parser, Subcommand};
92use std::fs;
93use std::path::{Path, PathBuf};
94
95#[derive(Parser, Debug)]
96#[clap(
97    name = "decapod",
98    version = env!("CARGO_PKG_VERSION"),
99    about = "The Intent-Driven Engineering System"
100)]
101struct Cli {
102    #[clap(subcommand)]
103    command: Command,
104}
105
106#[derive(clap::Args, Debug)]
107struct ValidateCli {
108    /// Store to validate: 'user' (blank-slate semantics) or 'repo' (dogfood backlog).
109    #[clap(long, default_value = "repo")]
110    store: String,
111    /// Output format: 'text' or 'json'.
112    #[clap(long, default_value = "text")]
113    format: String,
114}
115
116// ===== Grouped Command Structures =====
117
118#[derive(clap::Args, Debug)]
119struct InitGroupCli {
120    #[clap(subcommand)]
121    command: Option<InitCommand>,
122    /// Directory to initialize (defaults to current working directory).
123    #[clap(short, long)]
124    dir: Option<PathBuf>,
125    /// Overwrite existing files by archiving them under `<dir>/.decapod_archive/`.
126    #[clap(long)]
127    force: bool,
128    /// Show what would change without writing files.
129    #[clap(long)]
130    dry_run: bool,
131    /// Force creation of all 3 entrypoint files (GEMINI.md, AGENTS.md, CLAUDE.md).
132    #[clap(long)]
133    all: bool,
134    /// Create only CLAUDE.md entrypoint file.
135    #[clap(long)]
136    claude: bool,
137    /// Create only GEMINI.md entrypoint file.
138    #[clap(long)]
139    gemini: bool,
140    /// Create only AGENTS.md entrypoint file.
141    #[clap(long)]
142    agents: bool,
143}
144
145#[derive(Subcommand, Debug)]
146enum InitCommand {
147    /// Remove all Decapod files from repository
148    Clean {
149        /// Directory to clean (defaults to current working directory).
150        #[clap(short, long)]
151        dir: Option<PathBuf>,
152    },
153}
154
155#[derive(clap::Args, Debug)]
156struct SetupCli {
157    #[clap(subcommand)]
158    command: SetupCommand,
159}
160
161#[derive(Subcommand, Debug)]
162enum SetupCommand {
163    /// Git hooks for commit validation
164    Hook {
165        /// Install commit-msg hook for conventional commits
166        #[clap(long, default_value = "true")]
167        commit_msg: bool,
168        /// Install pre-commit hook (fmt, clippy)
169        #[clap(long)]
170        pre_commit: bool,
171        /// Uninstall hooks
172        #[clap(long)]
173        uninstall: bool,
174    },
175}
176
177#[derive(clap::Args, Debug)]
178struct GovernCli {
179    #[clap(subcommand)]
180    command: GovernCommand,
181}
182
183#[derive(Subcommand, Debug)]
184enum GovernCommand {
185    /// Risk classification and approvals
186    Policy(policy::PolicyCli),
187
188    /// Claims, proofs, and system health
189    Health(health::HealthCli),
190
191    /// Execute verification proofs
192    Proof(ProofCommandCli),
193
194    /// Run integrity watchlist checks
195    Watcher(WatcherCli),
196
197    /// Operator feedback and preferences
198    Feedback(FeedbackCli),
199}
200
201#[derive(clap::Args, Debug)]
202struct DataCli {
203    #[clap(subcommand)]
204    command: DataCommand,
205}
206
207#[derive(Subcommand, Debug)]
208enum DataCommand {
209    /// Session archives (MOVE-not-TRIM)
210    Archive(ArchiveCli),
211
212    /// Repository knowledge base
213    Knowledge(KnowledgeCli),
214
215    /// Token budgets and context packing
216    Context(ContextCli),
217
218    /// Subsystem schemas and discovery
219    Schema(SchemaCli),
220
221    /// Repository structure and dependencies
222    Repo(RepoCli),
223
224    /// Audit log access (The Thin Waist)
225    Broker(BrokerCli),
226
227    /// Teammate preferences and patterns
228    Teammate(teammate::TeammateCli),
229}
230
231#[derive(clap::Args, Debug)]
232struct AutoCli {
233    #[clap(subcommand)]
234    command: AutoCommand,
235}
236
237#[derive(Subcommand, Debug)]
238enum AutoCommand {
239    /// Scheduled tasks (time-based)
240    Cron(cron::CronCli),
241
242    /// Event-driven automation
243    Reflex(reflex::ReflexCli),
244}
245
246#[derive(clap::Args, Debug)]
247struct QaCli {
248    #[clap(subcommand)]
249    command: QaCommand,
250}
251
252#[derive(Subcommand, Debug)]
253enum QaCommand {
254    /// Verify previously completed work (proof replay + drift checks)
255    Verify(verify::VerifyCli),
256
257    /// CI validation checks
258    Check {
259        /// Check crate description matches expected
260        #[clap(long)]
261        crate_description: bool,
262        /// Run all checks
263        #[clap(long)]
264        all: bool,
265    },
266
267    /// Run gatling regression test across all CLI code paths
268    Gatling(plugins::gatling::GatlingCli),
269}
270
271// ===== Main Command Enum =====
272
273#[derive(Subcommand, Debug)]
274enum Command {
275    /// Bootstrap system and manage lifecycle
276    #[clap(name = "init", visible_alias = "i")]
277    Init(InitGroupCli),
278
279    /// Configure repository (hooks, settings)
280    #[clap(name = "setup")]
281    Setup(SetupCli),
282
283    /// Access methodology documentation
284    #[clap(name = "docs", visible_alias = "d")]
285    Docs(docs_cli::DocsCli),
286
287    /// Track tasks and work items
288    #[clap(name = "todo", visible_alias = "t")]
289    Todo(todo::TodoCli),
290
291    /// Validate methodology compliance
292    #[clap(name = "validate", visible_alias = "v")]
293    Validate(ValidateCli),
294
295    /// Update decapod binary to latest version in current directory
296    #[clap(name = "update")]
297    Update,
298
299    /// Show version information
300    #[clap(name = "version")]
301    Version,
302
303    /// Governance: policy, health, proofs, audits
304    #[clap(name = "govern", visible_alias = "g")]
305    Govern(GovernCli),
306
307    /// Data: archives, knowledge, context, schemas
308    #[clap(name = "data")]
309    Data(DataCli),
310
311    /// Automation: scheduled and event-driven
312    #[clap(name = "auto", visible_alias = "a")]
313    Auto(AutoCli),
314
315    /// Quality assurance: verification and checks
316    #[clap(name = "qa", visible_alias = "q")]
317    Qa(QaCli),
318}
319
320#[derive(clap::Args, Debug)]
321struct BrokerCli {
322    #[clap(subcommand)]
323    command: BrokerCommand,
324}
325
326#[derive(Subcommand, Debug)]
327enum BrokerCommand {
328    /// Show the audit log of brokered mutations.
329    Audit,
330}
331
332#[derive(clap::Args, Debug)]
333struct KnowledgeCli {
334    #[clap(subcommand)]
335    command: KnowledgeCommand,
336}
337
338#[derive(Subcommand, Debug)]
339enum KnowledgeCommand {
340    /// Add an entry to project knowledge
341    Add {
342        #[clap(long)]
343        id: String,
344        #[clap(long)]
345        title: String,
346        #[clap(long)]
347        text: String,
348        #[clap(long)]
349        provenance: String,
350        #[clap(long)]
351        claim_id: Option<String>,
352    },
353    /// Search project knowledge
354    Search {
355        #[clap(long)]
356        query: String,
357    },
358}
359
360#[derive(clap::Args, Debug)]
361struct RepoCli {
362    #[clap(subcommand)]
363    command: RepoCommand,
364}
365
366#[derive(Subcommand, Debug)]
367enum RepoCommand {
368    /// Generate a deterministic summary of the repo
369    Map,
370    /// Generate a Markdown dependency graph (Mermaid format)
371    Graph,
372}
373
374#[derive(clap::Args, Debug)]
375struct WatcherCli {
376    #[clap(subcommand)]
377    command: WatcherCommand,
378}
379
380#[derive(Subcommand, Debug)]
381enum WatcherCommand {
382    /// Run all checks in the watchlist
383    Run,
384}
385
386#[derive(clap::Args, Debug)]
387struct ArchiveCli {
388    #[clap(subcommand)]
389    command: ArchiveCommand,
390}
391
392#[derive(Subcommand, Debug)]
393enum ArchiveCommand {
394    /// List all session archives
395    List,
396    /// Verify archive integrity (hashes and presence)
397    Verify,
398}
399
400#[derive(clap::Args, Debug)]
401struct FeedbackCli {
402    #[clap(subcommand)]
403    command: FeedbackCommand,
404}
405
406#[derive(Subcommand, Debug)]
407enum FeedbackCommand {
408    /// Add operator feedback to the ledger
409    Add {
410        #[clap(long)]
411        source: String,
412        #[clap(long)]
413        text: String,
414        #[clap(long)]
415        links: Option<String>,
416    },
417    /// Propose preference updates based on feedback
418    Propose,
419}
420
421#[derive(clap::Args, Debug)]
422pub struct ProofCommandCli {
423    #[clap(subcommand)]
424    pub command: ProofSubCommand,
425}
426
427#[derive(Subcommand, Debug)]
428pub enum ProofSubCommand {
429    /// Run all configured proofs
430    Run,
431    /// Run a specific proof by name
432    Test {
433        #[clap(long)]
434        name: String,
435    },
436    /// Show proof configuration and results
437    List,
438}
439
440#[derive(clap::Args, Debug)]
441struct ContextCli {
442    #[clap(subcommand)]
443    command: ContextCommand,
444}
445
446#[derive(Subcommand, Debug)]
447enum ContextCommand {
448    /// Audit current session token usage against profiles.
449    Audit {
450        #[clap(long)]
451        profile: String,
452        #[clap(long)]
453        files: Vec<PathBuf>,
454    },
455    /// Perform MOVE-not-TRIM archival of a session file.
456    Pack {
457        #[clap(long)]
458        path: PathBuf,
459        #[clap(long)]
460        summary: String,
461    },
462    /// Restore content from an archive (budget-gated)
463    Restore {
464        #[clap(long)]
465        id: String,
466        #[clap(long, default_value = "main")]
467        profile: String,
468        #[clap(long)]
469        current_files: Vec<PathBuf>,
470    },
471}
472
473#[derive(clap::Args, Debug)]
474struct SchemaCli {
475    /// Format: json | md
476    #[clap(long, default_value = "json")]
477    format: String,
478    /// Optional: filter by subsystem name
479    #[clap(long)]
480    subsystem: Option<String>,
481    /// Force deterministic output (removes volatile timestamps)
482    #[clap(long)]
483    deterministic: bool,
484}
485
486fn find_decapod_project_root(start_dir: &Path) -> Result<PathBuf, error::DecapodError> {
487    let mut current_dir = PathBuf::from(start_dir);
488    loop {
489        if current_dir.join(".decapod").exists() {
490            return Ok(current_dir);
491        }
492        if !current_dir.pop() {
493            return Err(error::DecapodError::NotFound(
494                "'.decapod' directory not found in current or parent directories. Run `decapod init` first.".to_string(),
495            ));
496        }
497    }
498}
499
500fn clean_project(dir: Option<PathBuf>) -> Result<(), error::DecapodError> {
501    let raw_dir = match dir {
502        Some(d) => d,
503        None => std::env::current_dir()?,
504    };
505    let target_dir = std::fs::canonicalize(&raw_dir).map_err(error::DecapodError::IoError)?;
506
507    let decapod_root = target_dir.join(".decapod");
508    if decapod_root.exists() {
509        println!("Removing directory: {}", decapod_root.display());
510        fs::remove_dir_all(&decapod_root).map_err(error::DecapodError::IoError)?;
511    }
512
513    for file in ["AGENTS.md", "CLAUDE.md", "GEMINI.md", "CODEX.md"] {
514        let path = target_dir.join(file);
515        if path.exists() {
516            println!("Removing file: {}", path.display());
517            fs::remove_file(&path).map_err(error::DecapodError::IoError)?;
518        }
519    }
520    println!("Decapod files cleaned from {}", target_dir.display());
521    Ok(())
522}
523
524pub fn run() -> Result<(), error::DecapodError> {
525    let cli = Cli::parse();
526    let current_dir = std::env::current_dir()?;
527    let decapod_root_option = find_decapod_project_root(&current_dir);
528    let store_root: PathBuf;
529
530    match cli.command {
531        Command::Version => {
532            // Version command - simple output for scripts/parsing
533            println!("v{}", migration::DECAPOD_VERSION);
534            return Ok(());
535        }
536        Command::Init(init_group) => {
537            // Handle subcommands (clean)
538            if let Some(subcmd) = init_group.command {
539                match subcmd {
540                    InitCommand::Clean { dir } => {
541                        clean_project(dir)?;
542                        return Ok(());
543                    }
544                }
545            }
546
547            // Base init command
548            use colored::Colorize;
549
550            // Clear screen and position cursor for pristine alien output
551            print!("\x1B[2J\x1B[1;1H");
552
553            let _width = tui::terminal_width();
554
555            // 🛸 ALIEN SPACESHIP BANNER 🛸
556            println!();
557            println!();
558            println!(
559                "{}",
560                "              ▗▄▄▄▄▖  ▗▄▄▄▄▄▄▄▄▄▄▄▄▖  ▗▄▄▄▄▖"
561                    .bright_magenta()
562                    .bold()
563            );
564            println!(
565                "{}",
566                "            ▗▀▀      ▝▀              ▀▘      ▀▀▖"
567                    .bright_magenta()
568                    .bold()
569            );
570            println!(
571                "          {}   {}   {}",
572                "▗▀".bright_magenta().bold(),
573                "🦀 D E C A P O D 🦀".bright_white().bold().underline(),
574                "▀▖".bright_magenta().bold()
575            );
576            println!(
577                "{}",
578                "         ▐                                        ▌"
579                    .bright_cyan()
580                    .bold()
581            );
582            println!(
583                "         {} {} {}",
584                "▐".bright_cyan().bold(),
585                "C O N T R O L   P L A N E".bright_cyan().bold(),
586                "▌".bright_cyan().bold()
587            );
588            println!(
589                "{}",
590                "         ▐                                        ▌"
591                    .bright_cyan()
592                    .bold()
593            );
594            println!(
595                "{}",
596                "          ▝▖                                    ▗▘"
597                    .bright_magenta()
598                    .bold()
599            );
600            println!(
601                "{}",
602                "            ▝▄▄                              ▄▄▘"
603                    .bright_magenta()
604                    .bold()
605            );
606            println!(
607                "{}",
608                "              ▝▀▀▀▀▖  ▝▀▀▀▀▀▀▀▀▀▀▀▀▘  ▗▀▀▀▀▘"
609                    .bright_magenta()
610                    .bold()
611            );
612            println!();
613            println!();
614
615            let target_dir = match init_group.dir {
616                Some(d) => d,
617                None => current_dir.clone(),
618            };
619            let target_dir =
620                std::fs::canonicalize(&target_dir).map_err(error::DecapodError::IoError)?;
621
622            // Check if .decapod exists and skip if it does, unless --force
623            let setup_decapod_root = target_dir.join(".decapod");
624            if setup_decapod_root.exists() && !init_group.force {
625                tui::render_box(
626                    "⚠  SYSTEM ALREADY INITIALIZED",
627                    "Use --force to override",
628                    tui::BoxStyle::Warning,
629                );
630                println!();
631                println!("  {} Detected existing control plane", "▸".bright_yellow());
632                println!(
633                    "  {} Use {} flag to override",
634                    "▸".bright_yellow(),
635                    "--force".bright_cyan().bold()
636                );
637                println!();
638                return Ok(());
639            }
640
641            // Check which agent files exist and track which ones to generate
642            use sha2::{Digest, Sha256};
643            let mut existing_agent_files = vec![];
644            for file in ["AGENTS.md", "CLAUDE.md", "GEMINI.md", "CODEX.md"] {
645                if target_dir.join(file).exists() {
646                    existing_agent_files.push(file);
647                }
648            }
649
650            // Safely backup root agent entrypoint files if they exist and differ from templates
651            let mut created_backups = false;
652            if !init_group.dry_run {
653                let mut backed_up = false;
654                for file in &existing_agent_files {
655                    let path = target_dir.join(file);
656
657                    // Get template content for this file
658                    let template_content = core::assets::get_template(file).unwrap_or_default();
659
660                    // Compute template checksum
661                    let mut hasher = Sha256::new();
662                    hasher.update(template_content.as_bytes());
663                    let template_hash = format!("{:x}", hasher.finalize());
664
665                    // Compute existing file checksum
666                    let existing_content = fs::read_to_string(&path).unwrap_or_default();
667                    let mut hasher = Sha256::new();
668                    hasher.update(existing_content.as_bytes());
669                    let existing_hash = format!("{:x}", hasher.finalize());
670
671                    // Only backup if checksums differ
672                    if template_hash != existing_hash {
673                        if !backed_up {
674                            println!(
675                                "        {}",
676                                "▼▼▼ PRESERVATION PROTOCOL ACTIVATED ▼▼▼"
677                                    .bright_yellow()
678                                    .bold()
679                            );
680                            println!();
681                            backed_up = true;
682                            created_backups = true;
683                        }
684                        let backup_path = target_dir.join(format!("{}.bak", file));
685                        fs::rename(&path, &backup_path).map_err(error::DecapodError::IoError)?;
686                        println!(
687                            "          {} {} {} {}",
688                            "◆".bright_cyan(),
689                            file.bright_white().bold(),
690                            "⟿".bright_yellow(),
691                            format!("{}.bak", file.strip_suffix(".md").unwrap_or(file))
692                                .bright_black()
693                        );
694                    }
695                }
696                if backed_up {
697                    println!();
698                }
699            }
700
701            // Create .decapod/data for init
702            let setup_store_root = setup_decapod_root.join("data");
703            if !init_group.dry_run {
704                std::fs::create_dir_all(&setup_store_root).map_err(error::DecapodError::IoError)?;
705            }
706
707            // `--dry-run` should not perform any mutations.
708            if !init_group.dry_run {
709                // Databases setup section - TUI styled box
710                tui::render_box(
711                    "⚡ SUBSYSTEM INITIALIZATION",
712                    "Database & State Management",
713                    tui::BoxStyle::Cyan,
714                );
715                println!();
716
717                // Initialize all store DBs in the resolved store root (preserve existing)
718                let dbs = [
719                    ("todo.db", setup_store_root.join("todo.db")),
720                    ("knowledge.db", setup_store_root.join("knowledge.db")),
721                    ("cron.db", setup_store_root.join("cron.db")),
722                    ("reflex.db", setup_store_root.join("reflex.db")),
723                    ("health.db", setup_store_root.join("health.db")),
724                    ("policy.db", setup_store_root.join("policy.db")),
725                    ("archive.db", setup_store_root.join("archive.db")),
726                    ("feedback.db", setup_store_root.join("feedback.db")),
727                    ("teammate.db", setup_store_root.join("teammate.db")),
728                ];
729
730                for (db_name, db_path) in dbs {
731                    if db_path.exists() {
732                        println!(
733                            "    {} {} {}",
734                            "✓".bright_green(),
735                            db_name.bright_white(),
736                            "(preserved - existing data kept)".bright_black()
737                        );
738                    } else {
739                        match db_name {
740                            "todo.db" => todo::initialize_todo_db(&setup_store_root)?,
741                            "knowledge.db" => db::initialize_knowledge_db(&setup_store_root)?,
742                            "cron.db" => cron::initialize_cron_db(&setup_store_root)?,
743                            "reflex.db" => reflex::initialize_reflex_db(&setup_store_root)?,
744                            "health.db" => health::initialize_health_db(&setup_store_root)?,
745                            "policy.db" => policy::initialize_policy_db(&setup_store_root)?,
746                            "archive.db" => archive::initialize_archive_db(&setup_store_root)?,
747                            "feedback.db" => feedback::initialize_feedback_db(&setup_store_root)?,
748                            "teammate.db" => teammate::initialize_teammate_db(&setup_store_root)?,
749                            _ => unreachable!(),
750                        }
751                        println!("    {} {}", "●".bright_green(), db_name.bright_white());
752                    }
753                }
754
755                println!();
756
757                // Create empty todo events file for validation (preserve existing)
758                let events_path = setup_store_root.join("todo.events.jsonl");
759                if events_path.exists() {
760                    println!(
761                        "    {} {} {}",
762                        "✓".bright_green(),
763                        "todo.events.jsonl".bright_white(),
764                        "(preserved - event history kept)".bright_black()
765                    );
766                } else {
767                    std::fs::write(&events_path, "").map_err(error::DecapodError::IoError)?;
768                    println!(
769                        "    {} {}",
770                        "●".bright_green(),
771                        "todo.events.jsonl".bright_white()
772                    );
773                }
774
775                // Create generated directory for derived files (checksums, caches, etc.)
776                let generated_dir = setup_decapod_root.join("generated");
777                if generated_dir.exists() {
778                    println!(
779                        "    {} {} {}",
780                        "✓".bright_green(),
781                        "generated/".bright_white(),
782                        "(preserved - existing files kept)".bright_black()
783                    );
784                } else {
785                    std::fs::create_dir_all(&generated_dir)
786                        .map_err(error::DecapodError::IoError)?;
787                    println!("    {} {}", "●".bright_green(), "generated/".bright_white());
788                }
789
790                println!();
791            }
792
793            // Determine which agent files to generate based on flags
794            // Individual flags override existing files list
795            let agent_files_to_generate =
796                if init_group.claude || init_group.gemini || init_group.agents {
797                    let mut files = vec![];
798                    if init_group.claude {
799                        files.push("CLAUDE.md".to_string());
800                    }
801                    if init_group.gemini {
802                        files.push("GEMINI.md".to_string());
803                    }
804                    if init_group.agents {
805                        files.push("AGENTS.md".to_string());
806                    }
807                    files
808                } else {
809                    existing_agent_files
810                        .into_iter()
811                        .map(|s| s.to_string())
812                        .collect()
813                };
814
815            scaffold::scaffold_project_entrypoints(&scaffold::ScaffoldOptions {
816                target_dir,
817                force: init_group.force,
818                dry_run: init_group.dry_run,
819                agent_files: agent_files_to_generate,
820                created_backups,
821                all: init_group.all,
822            })?;
823
824            // Write version file for migration tracking
825            if !init_group.dry_run {
826                migration::write_version(&setup_decapod_root)?;
827            }
828        }
829        Command::Setup(setup_cli) => match setup_cli.command {
830            SetupCommand::Hook {
831                commit_msg,
832                pre_commit,
833                uninstall,
834            } => {
835                run_hook_install(commit_msg, pre_commit, uninstall)?;
836            }
837        },
838        _ => {
839            // For other commands, ensure .decapod exists
840            let project_root = decapod_root_option?;
841            let decapod_root_path = project_root.join(".decapod");
842            store_root = decapod_root_path.join("data");
843            std::fs::create_dir_all(&store_root).map_err(error::DecapodError::IoError)?;
844
845            // Check version compatibility FIRST (before migration updates the version file)
846            check_version_compatibility(&decapod_root_path)?;
847
848            // Check for version changes and run migrations if needed
849            migration::check_and_migrate(&decapod_root_path)?;
850
851            let project_store = Store {
852                kind: StoreKind::Repo,
853                root: store_root.clone(),
854            };
855
856            match cli.command {
857                Command::Validate(validate_cli) => {
858                    let decapod_root = project_root.clone();
859                    let store = match validate_cli.store.as_str() {
860                        "user" => {
861                            // User store uses a temp directory for blank-slate validation
862                            let tmp_root = std::env::temp_dir()
863                                .join(format!("decapod_validate_user_{}", ulid::Ulid::new()));
864                            std::fs::create_dir_all(&tmp_root)
865                                .map_err(error::DecapodError::IoError)?;
866                            Store {
867                                kind: StoreKind::User,
868                                root: tmp_root,
869                            }
870                        }
871                        _ => project_store.clone(),
872                    };
873                    validate::run_validation(&store, &decapod_root, &decapod_root)?;
874                }
875                Command::Update => {
876                    run_self_update(&project_root)?;
877                }
878                Command::Version => {
879                    show_version_info(&project_root)?;
880                }
881                Command::Docs(docs_cli) => {
882                    docs_cli::run_docs_cli(docs_cli)?;
883                }
884                Command::Todo(todo_cli) => {
885                    todo::run_todo_cli(&project_store, todo_cli)?;
886                }
887                Command::Govern(govern_cli) => match govern_cli.command {
888                    GovernCommand::Policy(policy_cli) => {
889                        policy::run_policy_cli(&project_store, policy_cli)?;
890                    }
891                    GovernCommand::Health(health_cli) => {
892                        health::run_health_cli(&project_store, health_cli)?;
893                    }
894                    GovernCommand::Proof(proof_cli) => {
895                        proof::execute_proof_cli(&proof_cli, &store_root)?;
896                    }
897                    GovernCommand::Watcher(watcher_cli) => match watcher_cli.command {
898                        WatcherCommand::Run => {
899                            let report = watcher::run_watcher(&project_store)?;
900                            println!("{}", serde_json::to_string_pretty(&report).unwrap());
901                        }
902                    },
903                    GovernCommand::Feedback(feedback_cli) => {
904                        feedback::initialize_feedback_db(&store_root)?;
905                        match feedback_cli.command {
906                            FeedbackCommand::Add {
907                                source,
908                                text,
909                                links,
910                            } => {
911                                let id = feedback::add_feedback(
912                                    &project_store,
913                                    &source,
914                                    &text,
915                                    links.as_deref(),
916                                )?;
917                                println!("Feedback recorded: {}", id);
918                            }
919                            FeedbackCommand::Propose => {
920                                let proposal = feedback::propose_prefs(&project_store)?;
921                                println!("{}", proposal);
922                            }
923                        }
924                    }
925                },
926                Command::Data(data_cli) => match data_cli.command {
927                    DataCommand::Archive(archive_cli) => {
928                        archive::initialize_archive_db(&store_root)?;
929                        match archive_cli.command {
930                            ArchiveCommand::List => {
931                                let items = archive::list_archives(&project_store)?;
932                                println!("{}", serde_json::to_string_pretty(&items).unwrap());
933                            }
934                            ArchiveCommand::Verify => {
935                                let failures = archive::verify_archives(&project_store)?;
936                                if failures.is_empty() {
937                                    println!("All archives verified successfully.");
938                                } else {
939                                    println!("Archive verification failed:");
940                                    for f in failures {
941                                        println!("- {}", f);
942                                    }
943                                }
944                            }
945                        }
946                    }
947                    DataCommand::Knowledge(knowledge_cli) => {
948                        db::initialize_knowledge_db(&store_root)?;
949                        match knowledge_cli.command {
950                            KnowledgeCommand::Add {
951                                id,
952                                title,
953                                text,
954                                provenance,
955                                claim_id,
956                            } => {
957                                knowledge::add_knowledge(
958                                    &project_store,
959                                    &id,
960                                    &title,
961                                    &text,
962                                    &provenance,
963                                    claim_id.as_deref(),
964                                )?;
965                                println!("Knowledge entry added: {}", id);
966                            }
967                            KnowledgeCommand::Search { query } => {
968                                let results = knowledge::search_knowledge(&project_store, &query)?;
969                                println!("{}", serde_json::to_string_pretty(&results).unwrap());
970                            }
971                        }
972                    }
973                    DataCommand::Context(context_cli) => {
974                        let manager = context::ContextManager::new(&store_root)?;
975                        match context_cli.command {
976                            ContextCommand::Audit { profile, files } => {
977                                let total = manager.audit_session(&files)?;
978                                match manager.get_profile(&profile) {
979                                    Some(p) => {
980                                        println!(
981                                            "Total tokens for profile '{}': {} / {} (budget)",
982                                            profile, total, p.budget_tokens
983                                        );
984                                        if total > p.budget_tokens {
985                                            println!("⚠ OVER BUDGET");
986                                        }
987                                    }
988                                    None => {
989                                        println!(
990                                            "Total tokens: {} (Profile '{}' not found)",
991                                            total, profile
992                                        );
993                                    }
994                                }
995                            }
996                            ContextCommand::Pack { path, summary } => {
997                                match manager.pack_and_archive(&project_store, &path, &summary) {
998                                    Ok(archive_path) => {
999                                        println!("Session archived to: {}", archive_path.display());
1000                                    }
1001                                    Err(error::DecapodError::ContextPackError(msg)) => {
1002                                        eprintln!("Context pack failed: {}", msg);
1003                                    }
1004                                    Err(e) => {
1005                                        eprintln!("Unexpected error during context pack: {}", e);
1006                                    }
1007                                }
1008                            }
1009                            ContextCommand::Restore {
1010                                id,
1011                                profile,
1012                                current_files,
1013                            } => {
1014                                let content =
1015                                    manager.restore_archive(&id, &profile, &current_files)?;
1016                                println!(
1017                                    "--- RESTORED CONTENT (Archive: {}) ---\n{}\n--- END RESTORED ---",
1018                                    id, content
1019                                );
1020                            }
1021                        }
1022                    }
1023                    DataCommand::Schema(schema_cli) => {
1024                        let mut schemas = std::collections::BTreeMap::new();
1025                        schemas.insert("todo", todo::schema());
1026                        schemas.insert("cron", cron::schema());
1027                        schemas.insert("reflex", reflex::schema());
1028                        schemas.insert("health", health::health_schema());
1029                        schemas.insert("broker", core::broker::schema());
1030                        schemas.insert("context", context::schema());
1031                        schemas.insert("policy", policy::schema());
1032                        schemas.insert("knowledge", knowledge::schema());
1033                        schemas.insert("repomap", repomap::schema());
1034                        schemas.insert("watcher", watcher::schema());
1035                        schemas.insert("archive", archive::schema());
1036                        schemas.insert("feedback", feedback::schema());
1037                        schemas.insert("teammate", teammate::schema());
1038                        schemas.insert("docs", docs_cli::schema());
1039
1040                        let output = if let Some(sub) = schema_cli.subsystem {
1041                            schemas
1042                                .get(sub.as_str())
1043                                .cloned()
1044                                .unwrap_or(serde_json::json!({ "error": "subsystem not found" }))
1045                        } else {
1046                            let mut envelope = serde_json::json!({
1047                                "schema_version": "1.0.0",
1048                                "subsystems": schemas
1049                            });
1050                            if !schema_cli.deterministic {
1051                                envelope.as_object_mut().unwrap().insert(
1052                                    "generated_at".to_string(),
1053                                    serde_json::json!(format!(
1054                                        "{:?}",
1055                                        std::time::SystemTime::now()
1056                                    )),
1057                                );
1058                            }
1059                            envelope
1060                        };
1061
1062                        if schema_cli.format == "json" {
1063                            println!("{}", serde_json::to_string_pretty(&output).unwrap());
1064                        } else {
1065                            println!(
1066                                "Markdown schema format not yet implemented. Defaulting to JSON."
1067                            );
1068                            println!("{}", serde_json::to_string_pretty(&output).unwrap());
1069                        }
1070                    }
1071                    DataCommand::Repo(repo_cli) => match repo_cli.command {
1072                        RepoCommand::Map => {
1073                            let map = repomap::generate_map(&project_root);
1074                            println!("{}", serde_json::to_string_pretty(&map).unwrap());
1075                        }
1076                        RepoCommand::Graph => {
1077                            let graph = repomap::generate_doc_graph(&project_root);
1078                            println!("{}", graph.mermaid);
1079                        }
1080                    },
1081                    DataCommand::Broker(broker_cli) => match broker_cli.command {
1082                        BrokerCommand::Audit => {
1083                            let audit_log = store_root.join("broker.events.jsonl");
1084                            if audit_log.exists() {
1085                                let content = std::fs::read_to_string(audit_log)?;
1086                                println!("{}", content);
1087                            } else {
1088                                println!("No audit log found.");
1089                            }
1090                        }
1091                    },
1092                    DataCommand::Teammate(teammate_cli) => {
1093                        teammate::run_teammate_cli(&project_store, teammate_cli)?;
1094                    }
1095                },
1096                Command::Auto(auto_cli) => match auto_cli.command {
1097                    AutoCommand::Cron(cron_cli) => {
1098                        cron::run_cron_cli(&project_store, cron_cli)?;
1099                    }
1100                    AutoCommand::Reflex(reflex_cli) => {
1101                        reflex::run_reflex_cli(&project_store, reflex_cli);
1102                    }
1103                },
1104                Command::Qa(qa_cli) => match qa_cli.command {
1105                    QaCommand::Verify(verify_cli) => {
1106                        verify::run_verify_cli(&project_store, &project_root, verify_cli)?;
1107                    }
1108                    QaCommand::Check {
1109                        crate_description,
1110                        all,
1111                    } => {
1112                        run_check(crate_description, all)?;
1113                    }
1114                    QaCommand::Gatling(ref gatling_cli) => {
1115                        plugins::gatling::run_gatling_cli(gatling_cli)?;
1116                    }
1117                },
1118                _ => unreachable!(),
1119            }
1120        }
1121    }
1122    Ok(())
1123}
1124
1125fn run_hook_install(
1126    commit_msg: bool,
1127    pre_commit: bool,
1128    uninstall: bool,
1129) -> Result<(), error::DecapodError> {
1130    use std::fs;
1131    use std::io::Write;
1132
1133    let git_dir = Path::new(".git");
1134    if !git_dir.exists() {
1135        return Err(error::DecapodError::ValidationError(
1136            ".git directory not found. Are you in the root of the project?".into(),
1137        ));
1138    }
1139
1140    let hooks_dir = git_dir.join("hooks");
1141    fs::create_dir_all(&hooks_dir).map_err(error::DecapodError::IoError)?;
1142
1143    if uninstall {
1144        let commit_msg_path = hooks_dir.join("commit-msg");
1145        let pre_commit_path = hooks_dir.join("pre-commit");
1146
1147        let mut removed = false;
1148        if commit_msg_path.exists() {
1149            fs::remove_file(&commit_msg_path)?;
1150            println!("✓ Removed commit-msg hook");
1151            removed = true;
1152        }
1153        if pre_commit_path.exists() {
1154            fs::remove_file(&pre_commit_path)?;
1155            println!("✓ Removed pre-commit hook");
1156            removed = true;
1157        }
1158        if !removed {
1159            println!("No hooks found to remove");
1160        }
1161        return Ok(());
1162    }
1163
1164    // Install commit-msg hook
1165    if commit_msg {
1166        let hook_content = r#"#!/bin/sh
1167# Conventional commit validation hook
1168# Installed by Decapod
1169
1170MSG=$(cat "$1")
1171REGEX="^(feat|fix|chore|ci|docs|style|refactor|perf|test)(\(.*\))?!?: .+"
1172
1173if ! echo "$MSG" | grep -qE "$REGEX"; then
1174    echo "Error: Invalid commit message format."
1175    echo "  Commit messages must follow the Conventional Commits format."
1176    echo "  Example: 'feat: add login functionality'"
1177    echo "  Allowed prefixes: feat, fix, chore, ci, docs, style, refactor, perf, test"
1178    exit 1
1179fi
1180"#;
1181
1182        let hook_path = hooks_dir.join("commit-msg");
1183        let mut file = fs::File::create(&hook_path).map_err(error::DecapodError::IoError)?;
1184        file.write_all(hook_content.as_bytes())
1185            .map_err(error::DecapodError::IoError)?;
1186        drop(file);
1187
1188        #[cfg(unix)]
1189        {
1190            use std::os::unix::fs::PermissionsExt;
1191            let mut perms = fs::metadata(&hook_path)
1192                .map_err(error::DecapodError::IoError)?
1193                .permissions();
1194            perms.set_mode(0o755);
1195            fs::set_permissions(&hook_path, perms).map_err(error::DecapodError::IoError)?;
1196        }
1197
1198        println!("✓ Installed commit-msg hook for conventional commits");
1199    }
1200
1201    // Install pre-commit hook (pure Rust - runs fmt and clippy)
1202    if pre_commit {
1203        // Use a simple shell wrapper that calls cargo
1204        let hook_content = r#"#!/bin/sh
1205# Pre-commit hook - runs cargo fmt and clippy
1206# Installed by Decapod
1207
1208echo "Running pre-commit checks..."
1209
1210# Run cargo fmt
1211if ! cargo fmt --all -- --check 2>/dev/null; then
1212    echo "Formatting check failed. Run 'cargo fmt --all' to fix."
1213    exit 1
1214fi
1215
1216# Run cargo clippy
1217if ! cargo clippy --all-targets --all-features -- -D warnings 2>/dev/null; then
1218    echo "Clippy check failed."
1219    exit 1
1220fi
1221
1222echo "Pre-commit checks passed!"
1223exit 0
1224"#;
1225
1226        let hook_path = hooks_dir.join("pre-commit");
1227        let mut file = fs::File::create(&hook_path).map_err(error::DecapodError::IoError)?;
1228        file.write_all(hook_content.as_bytes())
1229            .map_err(error::DecapodError::IoError)?;
1230        drop(file);
1231
1232        #[cfg(unix)]
1233        {
1234            use std::os::unix::fs::PermissionsExt;
1235            let mut perms = fs::metadata(&hook_path)
1236                .map_err(error::DecapodError::IoError)?
1237                .permissions();
1238            perms.set_mode(0o755);
1239            fs::set_permissions(&hook_path, perms).map_err(error::DecapodError::IoError)?;
1240        }
1241
1242        println!("✓ Installed pre-commit hook (fmt + clippy)");
1243    }
1244
1245    if !commit_msg && !pre_commit {
1246        println!("No hooks specified. Use --commit-msg and/or --pre-commit");
1247    }
1248
1249    Ok(())
1250}
1251
1252fn run_check(crate_description: bool, all: bool) -> Result<(), error::DecapodError> {
1253    if crate_description || all {
1254        let expected = "Decapod is a Rust-built governance runtime for AI agents: repo-native state, enforced workflow, proof gates, safe coordination.";
1255
1256        let output = std::process::Command::new("cargo")
1257            .args(["metadata", "--no-deps", "--format-version", "1"])
1258            .output()
1259            .map_err(|e| error::DecapodError::IoError(std::io::Error::other(e)))?;
1260
1261        let json_str = String::from_utf8_lossy(&output.stdout);
1262
1263        if json_str.contains(expected) {
1264            println!("✓ Crate description matches");
1265        } else {
1266            println!("✗ Crate description mismatch!");
1267            println!("  Expected: {}", expected);
1268            return Err(error::DecapodError::ValidationError(
1269                "Crate description check failed".into(),
1270            ));
1271        }
1272    }
1273
1274    if all && !crate_description {
1275        println!("Note: --all requires --crate-description");
1276    }
1277
1278    Ok(())
1279}
1280
1281fn run_self_update(project_root: &Path) -> Result<(), error::DecapodError> {
1282    use std::process::Command;
1283
1284    println!("Updating decapod binary from current directory...");
1285    println!("Running: cargo install --path . --locked");
1286    println!();
1287
1288    let status = Command::new("cargo")
1289        .args(["install", "--path", ".", "--locked"])
1290        .current_dir(project_root)
1291        .status()
1292        .map_err(error::DecapodError::IoError)?;
1293
1294    if !status.success() {
1295        return Err(error::DecapodError::ValidationError(
1296            "cargo install failed - see output above for details".into(),
1297        ));
1298    }
1299
1300    println!();
1301    println!("✓ Decapod binary updated successfully");
1302    println!("  Run 'decapod --version' to verify the new version");
1303
1304    // Update the version file to match the new binary version
1305    let decapod_root = project_root.join(".decapod");
1306    if decapod_root.exists() {
1307        migration::write_version(&decapod_root)?;
1308    }
1309
1310    Ok(())
1311}
1312
1313/// Show version information and compare with repo version
1314fn show_version_info(project_root: &Path) -> Result<(), error::DecapodError> {
1315    use colored::Colorize;
1316
1317    let binary_version = migration::DECAPOD_VERSION;
1318
1319    println!(
1320        "{} {}",
1321        "Decapod version:".bright_white(),
1322        binary_version.bright_green()
1323    );
1324
1325    let decapod_root = project_root.join(".decapod");
1326
1327    // Check if .decapod directory exists
1328    if !decapod_root.exists() {
1329        println!(
1330            "{} No .decapod directory found in {}",
1331            "ℹ".bright_blue(),
1332            project_root.display()
1333        );
1334        println!("  Run 'decapod init' to initialize the project");
1335        return Ok(());
1336    }
1337
1338    let version_file = decapod_root.join("generated/decapod.version");
1339
1340    if version_file.exists() {
1341        let repo_version = std::fs::read_to_string(&version_file)
1342            .map_err(error::DecapodError::IoError)?
1343            .trim()
1344            .to_string();
1345
1346        if repo_version.is_empty() {
1347            println!(
1348                "{} Repo version file exists but is empty",
1349                "⚠".bright_yellow()
1350            );
1351        } else if repo_version == binary_version {
1352            println!("{} Repo version matches binary version", "✓".bright_green());
1353        } else {
1354            // Compare versions to determine which is newer
1355            match compare_versions(binary_version, &repo_version) {
1356                std::cmp::Ordering::Less => {
1357                    println!(
1358                        "{} Repo version ({}) is newer than binary version",
1359                        "⚠".bright_yellow(),
1360                        repo_version.bright_yellow()
1361                    );
1362                    println!(
1363                        "  Consider running: {} to update the binary",
1364                        "decapod update".bright_cyan()
1365                    );
1366                }
1367                std::cmp::Ordering::Greater => {
1368                    println!(
1369                        "{} Binary version is newer than repo version ({})",
1370                        "✓".bright_green(),
1371                        repo_version.bright_yellow()
1372                    );
1373                    println!("  Migration will run on next command to update repo");
1374                }
1375                _ => {} // Equal case already handled above
1376            }
1377        }
1378    } else {
1379        println!(
1380            "{} No version file found in .decapod/generated/decapod.version",
1381            "ℹ".bright_blue()
1382        );
1383        println!("  Run 'decapod init' to set up version tracking");
1384    }
1385
1386    Ok(())
1387}
1388
1389/// Compare two version strings (simplified semver comparison)
1390fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
1391    let parse_version =
1392        |v: &str| -> Vec<u32> { v.split('.').filter_map(|s| s.parse::<u32>().ok()).collect() };
1393
1394    let a_parts = parse_version(a);
1395    let b_parts = parse_version(b);
1396
1397    for (a_part, b_part) in a_parts.iter().zip(b_parts.iter()) {
1398        match a_part.cmp(b_part) {
1399            std::cmp::Ordering::Equal => continue,
1400            other => return other,
1401        }
1402    }
1403
1404    a_parts.len().cmp(&b_parts.len())
1405}
1406
1407/// Check if binary version is compatible with repo version and warn if outdated
1408fn check_version_compatibility(decapod_root: &Path) -> Result<(), error::DecapodError> {
1409    use colored::Colorize;
1410
1411    let version_file = decapod_root.join("generated/decapod.version");
1412
1413    if !version_file.exists() {
1414        return Ok(()); // No version file yet, nothing to check
1415    }
1416
1417    let repo_version = std::fs::read_to_string(&version_file)
1418        .map_err(error::DecapodError::IoError)?
1419        .trim()
1420        .to_string();
1421
1422    if repo_version.is_empty() {
1423        return Ok(()); // Empty version file, skip check
1424    }
1425
1426    let binary_version = migration::DECAPOD_VERSION;
1427
1428    // Only warn if binary is OLDER than repo version
1429    if compare_versions(binary_version, &repo_version) == std::cmp::Ordering::Less {
1430        eprintln!();
1431        eprintln!(
1432            "{} {} {}",
1433            "⚠ WARNING:".bright_yellow().bold(),
1434            "Binary version".bright_white(),
1435            binary_version.bright_yellow()
1436        );
1437        eprintln!(
1438            "  {} {} {}",
1439            "is older than repo version".bright_white(),
1440            repo_version.bright_yellow(),
1441            "- some features may not work correctly".bright_white()
1442        );
1443        eprintln!(
1444            "  {} {}",
1445            "Run:".bright_white(),
1446            "decapod update".bright_cyan().bold()
1447        );
1448        eprintln!();
1449    }
1450
1451    Ok(())
1452}