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 constitution;
79pub mod core;
80pub mod plugins;
81
82use core::{
83    db, docs, docs_cli, error, migration, proof, repomap, scaffold,
84    store::{Store, StoreKind},
85    todo, trace, validate,
86};
87use plugins::{
88    archive, container, context, cron, decide, federation, feedback, health, knowledge, policy,
89    primitives, reflex, teammate, verify, watcher, workflow,
90};
91
92use clap::{CommandFactory, Parser, Subcommand};
93use serde::{Deserialize, Serialize};
94use sha2::{Digest, Sha256};
95use std::fs;
96use std::io::Read;
97use std::io::Write;
98use std::path::{Path, PathBuf};
99use std::time::{SystemTime, UNIX_EPOCH};
100
101#[derive(Parser, Debug)]
102#[clap(
103    name = "decapod",
104    version = env!("CARGO_PKG_VERSION"),
105    about = "The Intent-Driven Engineering System",
106    disable_version_flag = true
107)]
108struct Cli {
109    #[clap(subcommand)]
110    command: Command,
111}
112
113#[derive(clap::Args, Debug)]
114struct ValidateCli {
115    /// Store to validate: 'user' (blank-slate semantics) or 'repo' (dogfood backlog).
116    #[clap(long, default_value = "repo")]
117    store: String,
118    /// Output format: 'text' or 'json'.
119    #[clap(long, default_value = "text")]
120    format: String,
121}
122
123#[derive(clap::Args, Debug)]
124struct CapabilitiesCli {
125    /// Output format: 'json' or 'text'.
126    #[clap(long, default_value = "text")]
127    format: String,
128}
129
130#[derive(clap::Args, Debug)]
131struct WorkspaceCli {
132    #[clap(subcommand)]
133    command: WorkspaceCommand,
134}
135
136#[derive(Subcommand, Debug)]
137enum WorkspaceCommand {
138    /// Ensure an isolated workspace exists (create if needed)
139    Ensure {
140        /// Branch name (auto-generated if not provided)
141        #[clap(long)]
142        branch: Option<String>,
143    },
144    /// Show current workspace status
145    Status,
146    /// Publish workspace changes as a patch/PR bundle
147    Publish {
148        /// Title for the change
149        #[clap(long)]
150        title: Option<String>,
151        /// Description for the change
152        #[clap(long)]
153        description: Option<String>,
154    },
155}
156
157#[derive(clap::Args, Debug)]
158struct RpcCli {
159    /// Operation to perform
160    #[clap(long)]
161    op: Option<String>,
162    /// JSON parameters
163    #[clap(long)]
164    params: Option<String>,
165    /// Read request from stdin instead of command line
166    #[clap(long)]
167    stdin: bool,
168}
169
170// ===== Grouped Command Structures =====
171
172#[derive(clap::Args, Debug)]
173struct InitGroupCli {
174    #[clap(subcommand)]
175    command: Option<InitCommand>,
176    /// Directory to initialize (defaults to current working directory).
177    #[clap(short, long)]
178    dir: Option<PathBuf>,
179    /// Overwrite existing files by archiving them under `<dir>/.decapod_archive/`.
180    #[clap(long)]
181    force: bool,
182    /// Show what would change without writing files.
183    #[clap(long)]
184    dry_run: bool,
185    /// Force creation of all 3 entrypoint files (GEMINI.md, AGENTS.md, CLAUDE.md).
186    #[clap(long)]
187    all: bool,
188    /// Create only CLAUDE.md entrypoint file.
189    #[clap(long)]
190    claude: bool,
191    /// Create only GEMINI.md entrypoint file.
192    #[clap(long)]
193    gemini: bool,
194    /// Create only AGENTS.md entrypoint file.
195    #[clap(long)]
196    agents: bool,
197}
198
199#[derive(Subcommand, Debug)]
200enum InitCommand {
201    /// Remove all Decapod files from repository
202    Clean {
203        /// Directory to clean (defaults to current working directory).
204        #[clap(short, long)]
205        dir: Option<PathBuf>,
206    },
207}
208
209#[derive(clap::Args, Debug)]
210struct SessionCli {
211    #[clap(subcommand)]
212    command: SessionCommand,
213}
214
215#[derive(Subcommand, Debug)]
216enum SessionCommand {
217    /// Acquire a new session token (required before using other commands)
218    Acquire,
219    /// Show current session status
220    Status,
221    /// Release the current session token
222    Release,
223}
224
225#[derive(clap::Args, Debug)]
226struct SetupCli {
227    #[clap(subcommand)]
228    command: SetupCommand,
229}
230
231#[derive(Subcommand, Debug)]
232enum SetupCommand {
233    /// Install or uninstall repository git hooks
234    Hook {
235        /// Install conventional commit message validation hook
236        #[clap(long)]
237        commit_msg: bool,
238        /// Install Rust pre-commit hook (fmt + clippy)
239        #[clap(long)]
240        pre_commit: bool,
241        /// Remove installed hooks
242        #[clap(long)]
243        uninstall: bool,
244    },
245}
246
247#[derive(clap::Args, Debug)]
248struct GovernCli {
249    #[clap(subcommand)]
250    command: GovernCommand,
251}
252
253#[derive(Subcommand, Debug)]
254enum GovernCommand {
255    /// Risk classification and approvals
256    Policy(policy::PolicyCli),
257
258    /// Claims, proofs, and system health
259    Health(health::HealthCli),
260
261    /// Execute verification proofs
262    Proof(ProofCommandCli),
263
264    /// Run integrity watchlist checks
265    Watcher(WatcherCli),
266
267    /// Operator feedback and preferences
268    Feedback(FeedbackCli),
269}
270
271#[derive(clap::Args, Debug)]
272struct DataCli {
273    #[clap(subcommand)]
274    command: DataCommand,
275}
276
277#[derive(Subcommand, Debug)]
278enum DataCommand {
279    /// Session archives (MOVE-not-TRIM)
280    Archive(ArchiveCli),
281
282    /// Repository knowledge base
283    Knowledge(KnowledgeCli),
284
285    /// Token budgets and context packing
286    Context(ContextCli),
287
288    /// Subsystem schemas and discovery
289    Schema(SchemaCli),
290
291    /// Repository structure and dependencies
292    Repo(RepoCli),
293
294    /// Audit log access (The Thin Waist)
295    Broker(BrokerCli),
296
297    /// Teammate preferences and patterns
298    Teammate(teammate::TeammateCli),
299
300    /// Governed agent memory — typed knowledge graph
301    Federation(federation::FederationCli),
302
303    /// Markdown-native primitive layer
304    Primitives(primitives::PrimitivesCli),
305}
306
307#[derive(clap::Args, Debug)]
308struct AutoCli {
309    #[clap(subcommand)]
310    command: AutoCommand,
311}
312
313#[derive(Subcommand, Debug)]
314enum AutoCommand {
315    /// Scheduled tasks (time-based)
316    Cron(cron::CronCli),
317
318    /// Event-driven automation
319    Reflex(reflex::ReflexCli),
320
321    /// Workflow automation and discovery
322    Workflow(workflow::WorkflowCli),
323
324    /// Ephemeral isolated container execution
325    Container(container::ContainerCli),
326}
327
328#[derive(clap::Args, Debug)]
329struct QaCli {
330    #[clap(subcommand)]
331    command: QaCommand,
332}
333
334#[derive(Subcommand, Debug)]
335enum QaCommand {
336    /// Verify previously completed work (proof replay + drift checks)
337    Verify(verify::VerifyCli),
338
339    /// CI validation checks
340    Check {
341        /// Check crate description matches expected
342        #[clap(long)]
343        crate_description: bool,
344        /// Smoke-check all discoverable command help surfaces
345        #[clap(long)]
346        commands: bool,
347        /// Run all checks
348        #[clap(long)]
349        all: bool,
350    },
351
352    /// Run gatling regression test across all CLI code paths
353    Gatling(plugins::gatling::GatlingCli),
354}
355
356// ===== Main Command Enum =====
357
358#[derive(clap::Args, Debug)]
359struct TraceCli {
360    #[clap(subcommand)]
361    command: TraceCommand,
362}
363
364#[derive(Subcommand, Debug)]
365enum TraceCommand {
366    /// Export local traces
367    Export {
368        /// Number of last traces to export
369        #[clap(long, default_value = "10")]
370        last: usize,
371    },
372}
373
374#[derive(Subcommand, Debug)]
375enum Command {
376    /// Bootstrap system and manage lifecycle
377    #[clap(name = "init", visible_alias = "i")]
378    Init(InitGroupCli),
379
380    /// Configure repository (hooks, settings)
381    #[clap(name = "setup")]
382    Setup(SetupCli),
383
384    /// Session token management (required for agent operation)
385    #[clap(name = "session", visible_alias = "s")]
386    Session(SessionCli),
387
388    /// Access methodology documentation
389    #[clap(name = "docs", visible_alias = "d")]
390    Docs(docs_cli::DocsCli),
391
392    /// Track tasks and work items
393    #[clap(name = "todo", visible_alias = "t")]
394    Todo(todo::TodoCli),
395
396    /// Validate methodology compliance
397    #[clap(name = "validate", visible_alias = "v")]
398    Validate(ValidateCli),
399
400    /// Show version information
401    #[clap(name = "version")]
402    Version,
403
404    /// Governance: policy, health, proofs, audits
405    #[clap(name = "govern", visible_alias = "g")]
406    Govern(GovernCli),
407
408    /// Data: archives, knowledge, context, schemas
409    #[clap(name = "data")]
410    Data(DataCli),
411
412    /// Automation: scheduled and event-driven
413    #[clap(name = "auto", visible_alias = "a")]
414    Auto(AutoCli),
415
416    /// Quality assurance: verification and checks
417    #[clap(name = "qa", visible_alias = "q")]
418    Qa(QaCli),
419
420    /// Architecture decision prompting
421    #[clap(name = "decide")]
422    Decide(decide::DecideCli),
423
424    /// Agent workspace management
425    #[clap(name = "workspace", visible_alias = "w")]
426    Workspace(WorkspaceCli),
427
428    /// Structured JSON-RPC interface for agents
429    #[clap(name = "rpc")]
430    Rpc(RpcCli),
431
432    /// Show Decapod capabilities (for agent discovery)
433    #[clap(name = "capabilities")]
434    Capabilities(CapabilitiesCli),
435
436    /// Local trace management
437    #[clap(name = "trace")]
438    Trace(TraceCli),
439}
440
441#[derive(clap::Args, Debug)]
442struct BrokerCli {
443    #[clap(subcommand)]
444    command: BrokerCommand,
445}
446
447#[derive(Subcommand, Debug)]
448enum BrokerCommand {
449    /// Show the audit log of brokered mutations.
450    Audit,
451}
452
453#[derive(clap::Args, Debug)]
454struct KnowledgeCli {
455    #[clap(subcommand)]
456    command: KnowledgeCommand,
457}
458
459#[derive(Subcommand, Debug)]
460enum KnowledgeCommand {
461    /// Add an entry to project knowledge
462    Add {
463        #[clap(long)]
464        id: String,
465        #[clap(long)]
466        title: String,
467        #[clap(long)]
468        text: String,
469        #[clap(long)]
470        provenance: String,
471        #[clap(long)]
472        claim_id: Option<String>,
473    },
474    /// Search project knowledge
475    Search {
476        #[clap(long)]
477        query: String,
478    },
479}
480
481#[derive(clap::Args, Debug)]
482struct RepoCli {
483    #[clap(subcommand)]
484    command: RepoCommand,
485}
486
487#[derive(Subcommand, Debug)]
488enum RepoCommand {
489    /// Generate a deterministic summary of the repo
490    Map,
491    /// Generate a Markdown dependency graph (Mermaid format)
492    Graph,
493}
494
495#[derive(clap::Args, Debug)]
496struct WatcherCli {
497    #[clap(subcommand)]
498    command: WatcherCommand,
499}
500
501#[derive(Subcommand, Debug)]
502enum WatcherCommand {
503    /// Run all checks in the watchlist
504    Run,
505}
506
507#[derive(clap::Args, Debug)]
508struct ArchiveCli {
509    #[clap(subcommand)]
510    command: ArchiveCommand,
511}
512
513#[derive(Subcommand, Debug)]
514enum ArchiveCommand {
515    /// List all session archives
516    List,
517    /// Verify archive integrity (hashes and presence)
518    Verify,
519}
520
521#[derive(clap::Args, Debug)]
522struct FeedbackCli {
523    #[clap(subcommand)]
524    command: FeedbackCommand,
525}
526
527#[derive(Subcommand, Debug)]
528enum FeedbackCommand {
529    /// Add operator feedback to the ledger
530    Add {
531        #[clap(long)]
532        source: String,
533        #[clap(long)]
534        text: String,
535        #[clap(long)]
536        links: Option<String>,
537    },
538    /// Propose preference updates based on feedback
539    Propose,
540}
541
542#[derive(clap::Args, Debug)]
543pub struct ProofCommandCli {
544    #[clap(subcommand)]
545    pub command: ProofSubCommand,
546}
547
548#[derive(Subcommand, Debug)]
549pub enum ProofSubCommand {
550    /// Run all configured proofs
551    Run,
552    /// Run a specific proof by name
553    Test {
554        #[clap(long)]
555        name: String,
556    },
557    /// Show proof configuration and results
558    List,
559}
560
561#[derive(clap::Args, Debug)]
562struct ContextCli {
563    #[clap(subcommand)]
564    command: ContextCommand,
565}
566
567#[derive(Subcommand, Debug)]
568enum ContextCommand {
569    /// Audit current session token usage against profiles.
570    Audit {
571        #[clap(long)]
572        profile: String,
573        #[clap(long)]
574        files: Vec<PathBuf>,
575    },
576    /// Perform MOVE-not-TRIM archival of a session file.
577    Pack {
578        #[clap(long)]
579        path: PathBuf,
580        #[clap(long)]
581        summary: String,
582    },
583    /// Restore content from an archive (budget-gated)
584    Restore {
585        #[clap(long)]
586        id: String,
587        #[clap(long, default_value = "main")]
588        profile: String,
589        #[clap(long)]
590        current_files: Vec<PathBuf>,
591    },
592}
593
594#[derive(clap::Args, Debug)]
595struct SchemaCli {
596    /// Format: json | md
597    #[clap(long, default_value = "json")]
598    format: String,
599    /// Optional: filter by subsystem name
600    #[clap(long)]
601    subsystem: Option<String>,
602    /// Force deterministic output (removes volatile timestamps)
603    #[clap(long)]
604    deterministic: bool,
605}
606
607fn find_decapod_project_root(start_dir: &Path) -> Result<PathBuf, error::DecapodError> {
608    let mut current_dir = PathBuf::from(start_dir);
609    loop {
610        if current_dir.join(".decapod").exists() {
611            return Ok(current_dir);
612        }
613        if !current_dir.pop() {
614            return Err(error::DecapodError::NotFound(
615                "'.decapod' directory not found in current or parent directories. Run `decapod init` first.".to_string(),
616            ));
617        }
618    }
619}
620
621fn clean_project(dir: Option<PathBuf>) -> Result<(), error::DecapodError> {
622    let raw_dir = match dir {
623        Some(d) => d,
624        None => std::env::current_dir()?,
625    };
626    let target_dir = std::fs::canonicalize(&raw_dir).map_err(error::DecapodError::IoError)?;
627
628    let decapod_root = target_dir.join(".decapod");
629    if decapod_root.exists() {
630        println!("Removing directory: {}", decapod_root.display());
631        fs::remove_dir_all(&decapod_root).map_err(error::DecapodError::IoError)?;
632    }
633
634    for file in ["AGENTS.md", "CLAUDE.md", "GEMINI.md", "CODEX.md"] {
635        let path = target_dir.join(file);
636        if path.exists() {
637            println!("Removing file: {}", path.display());
638            fs::remove_file(&path).map_err(error::DecapodError::IoError)?;
639        }
640    }
641    println!("Decapod files cleaned from {}", target_dir.display());
642    Ok(())
643}
644
645pub fn run() -> Result<(), error::DecapodError> {
646    let cli = Cli::parse();
647    let current_dir = std::env::current_dir()?;
648    let decapod_root_option = find_decapod_project_root(&current_dir);
649    let store_root: PathBuf;
650
651    match cli.command {
652        Command::Version => {
653            // Version command - simple output for scripts/parsing
654            println!("v{}", migration::DECAPOD_VERSION);
655            return Ok(());
656        }
657        Command::Init(init_group) => {
658            // Handle subcommands (clean)
659            if let Some(subcmd) = init_group.command {
660                match subcmd {
661                    InitCommand::Clean { dir } => {
662                        clean_project(dir)?;
663                        return Ok(());
664                    }
665                }
666            }
667
668            // Base init command
669
670            let target_dir = match init_group.dir {
671                Some(d) => d,
672                None => current_dir.clone(),
673            };
674            let target_dir =
675                std::fs::canonicalize(&target_dir).map_err(error::DecapodError::IoError)?;
676
677            // Check if .decapod exists and skip if it does, unless --force
678            let setup_decapod_root = target_dir.join(".decapod");
679            if setup_decapod_root.exists() && !init_group.force {
680                println!(
681                    "init: already initialized (.decapod exists); rerun with --force to overwrite"
682                );
683                return Ok(());
684            }
685
686            // Check which agent files exist and track which ones to generate
687            use sha2::{Digest, Sha256};
688            let mut existing_agent_files = vec![];
689            for file in ["AGENTS.md", "CLAUDE.md", "GEMINI.md", "CODEX.md"] {
690                if target_dir.join(file).exists() {
691                    existing_agent_files.push(file);
692                }
693            }
694
695            // Safely backup root agent entrypoint files if they exist and differ from templates
696            let mut created_backups = false;
697            let mut backup_count = 0usize;
698            if !init_group.dry_run {
699                for file in &existing_agent_files {
700                    let path = target_dir.join(file);
701
702                    // Get template content for this file
703                    let template_content = core::assets::get_template(file).unwrap_or_default();
704
705                    // Compute template checksum
706                    let mut hasher = Sha256::new();
707                    hasher.update(template_content.as_bytes());
708                    let template_hash = format!("{:x}", hasher.finalize());
709
710                    // Compute existing file checksum
711                    let existing_content = fs::read_to_string(&path).unwrap_or_default();
712                    let mut hasher = Sha256::new();
713                    hasher.update(existing_content.as_bytes());
714                    let existing_hash = format!("{:x}", hasher.finalize());
715
716                    // Only backup if checksums differ
717                    if template_hash != existing_hash {
718                        created_backups = true;
719                        backup_count += 1;
720                        let backup_path = target_dir.join(format!("{}.bak", file));
721                        fs::rename(&path, &backup_path).map_err(error::DecapodError::IoError)?;
722                    }
723                }
724            }
725
726            // Blend legacy agent entrypoints into OVERRIDE.md
727            if !init_group.dry_run {
728                scaffold::blend_legacy_entrypoints(&target_dir)?;
729            }
730
731            // Databases are created lazily on first use by runtime commands.
732            // Init only generates the project structure files for speed.
733
734            // Determine which agent files to generate based on flags
735            // Individual flags override existing files list
736            let mut agent_files_to_generate =
737                if init_group.claude || init_group.gemini || init_group.agents {
738                    let mut files = vec![];
739                    if init_group.claude {
740                        files.push("CLAUDE.md".to_string());
741                    }
742                    if init_group.gemini {
743                        files.push("GEMINI.md".to_string());
744                    }
745                    if init_group.agents {
746                        files.push("AGENTS.md".to_string());
747                    }
748                    files
749                } else {
750                    existing_agent_files
751                        .into_iter()
752                        .map(|s| s.to_string())
753                        .collect()
754                };
755
756            // AGENTS.md is mandatory whenever we are doing selective entrypoint generation.
757            // Keep empty list semantics intact so scaffold can generate the full default set.
758            if !agent_files_to_generate.is_empty()
759                && !agent_files_to_generate.iter().any(|f| f == "AGENTS.md")
760            {
761                agent_files_to_generate.push("AGENTS.md".to_string());
762            }
763
764            let scaffold_summary =
765                scaffold::scaffold_project_entrypoints(&scaffold::ScaffoldOptions {
766                    target_dir,
767                    force: init_group.force,
768                    dry_run: init_group.dry_run,
769                    agent_files: agent_files_to_generate,
770                    created_backups,
771                    all: init_group.all,
772                })?;
773
774            let target_display = setup_decapod_root
775                .parent()
776                .unwrap_or(current_dir.as_path())
777                .display()
778                .to_string();
779            println!(
780                "init: ok target={} mode={}",
781                target_display,
782                if init_group.dry_run {
783                    "dry-run"
784                } else {
785                    "apply"
786                }
787            );
788            println!(
789                "init: files entry+{}={}~{} cfg+{}={}~{} backups={}",
790                scaffold_summary.entrypoints_created,
791                scaffold_summary.entrypoints_unchanged,
792                scaffold_summary.entrypoints_preserved,
793                scaffold_summary.config_created,
794                scaffold_summary.config_unchanged,
795                scaffold_summary.config_preserved,
796                backup_count
797            );
798            println!("init: status=ready");
799        }
800        Command::Session(session_cli) => {
801            run_session_command(session_cli)?;
802        }
803        Command::Setup(setup_cli) => match setup_cli.command {
804            SetupCommand::Hook {
805                commit_msg,
806                pre_commit,
807                uninstall,
808            } => {
809                run_hook_install(commit_msg, pre_commit, uninstall)?;
810            }
811        },
812        _ => {
813            let project_root = decapod_root_option?;
814            if requires_session_token(&cli.command) {
815                ensure_session_valid()?;
816            }
817            enforce_worktree_requirement(&cli.command, &project_root)?;
818
819            // For other commands, ensure .decapod exists
820            let decapod_root_path = project_root.join(".decapod");
821            store_root = decapod_root_path.join("data");
822            std::fs::create_dir_all(&store_root).map_err(error::DecapodError::IoError)?;
823
824            // Check for version/schema changes and run protected migrations if needed.
825            // Backups are auto-created in .decapod/data only when schema upgrades are pending.
826            migration::check_and_migrate_with_backup(&decapod_root_path, |data_root| {
827                // Bin 4: Transactional (TODO)
828                todo::initialize_todo_db(data_root)?;
829
830                // Bin 1: Governance
831                health::initialize_health_db(data_root)?;
832                policy::initialize_policy_db(data_root)?;
833                feedback::initialize_feedback_db(data_root)?;
834                archive::initialize_archive_db(data_root)?;
835
836                // Bin 2: Memory
837                db::initialize_knowledge_db(data_root)?;
838                teammate::initialize_teammate_db(data_root)?;
839                federation::initialize_federation_db(data_root)?;
840                decide::initialize_decide_db(data_root)?;
841
842                // Bin 3: Automation
843                cron::initialize_cron_db(data_root)?;
844                reflex::initialize_reflex_db(data_root)?;
845                Ok(())
846            })?;
847
848            let project_store = Store {
849                kind: StoreKind::Repo,
850                root: store_root.clone(),
851            };
852
853            if should_auto_clock_in(&cli.command) {
854                todo::clock_in_agent_presence(&project_store)?;
855            }
856
857            match cli.command {
858                Command::Validate(validate_cli) => {
859                    run_validate_command(validate_cli, &project_root, &project_store)?;
860                }
861                Command::Version => show_version_info()?,
862                Command::Docs(docs_cli) => {
863                    let result = docs_cli::run_docs_cli(docs_cli)?;
864                    if result.ingested_core_constitution {
865                        mark_core_constitution_ingested(&project_root)?;
866                    }
867                }
868                Command::Todo(todo_cli) => todo::run_todo_cli(&project_store, todo_cli)?,
869                Command::Govern(govern_cli) => {
870                    run_govern_command(govern_cli, &project_store, &store_root)?;
871                }
872                Command::Data(data_cli) => {
873                    run_data_command(data_cli, &project_store, &project_root, &store_root)?;
874                }
875                Command::Auto(auto_cli) => run_auto_command(auto_cli, &project_store)?,
876                Command::Qa(qa_cli) => run_qa_command(qa_cli, &project_store, &project_root)?,
877                Command::Decide(decide_cli) => decide::run_decide_cli(&project_store, decide_cli)?,
878                Command::Workspace(workspace_cli) => {
879                    run_workspace_command(workspace_cli, &project_root)?;
880                }
881                Command::Rpc(rpc_cli) => {
882                    run_rpc_command(rpc_cli, &project_root)?;
883                }
884                Command::Capabilities(cap_cli) => {
885                    run_capabilities_command(cap_cli)?;
886                }
887                Command::Trace(trace_cli) => {
888                    run_trace_command(trace_cli, &project_root)?;
889                }
890                _ => unreachable!(),
891            }
892        }
893    }
894    Ok(())
895}
896
897fn should_auto_clock_in(command: &Command) -> bool {
898    match command {
899        Command::Todo(todo_cli) => !todo::is_heartbeat_command(todo_cli),
900        Command::Version | Command::Init(_) | Command::Setup(_) | Command::Session(_) => false,
901        _ => true,
902    }
903}
904
905fn command_requires_worktree(command: &Command) -> bool {
906    match command {
907        Command::Init(_)
908        | Command::Setup(_)
909        | Command::Session(_)
910        | Command::Version
911        | Command::Workspace(_)
912        | Command::Capabilities(_)
913        | Command::Trace(_)
914        | Command::Docs(_)
915        | Command::Todo(_) => false,
916        Command::Data(data_cli) => !matches!(data_cli.command, DataCommand::Schema(_)),
917        Command::Rpc(_) => false,
918        _ => true,
919    }
920}
921
922fn enforce_worktree_requirement(
923    command: &Command,
924    project_root: &Path,
925) -> Result<(), error::DecapodError> {
926    if std::env::var("DECAPOD_VALIDATE_SKIP_GIT_GATES").is_ok() {
927        return Ok(());
928    }
929    if !command_requires_worktree(command) {
930        return Ok(());
931    }
932
933    let status = crate::core::workspace::get_workspace_status(project_root)?;
934    if status.git.in_worktree {
935        return Ok(());
936    }
937
938    Err(error::DecapodError::ValidationError(format!(
939        "Command requires isolated git worktree; current checkout is not a worktree (branch='{}'). Run `decapod workspace ensure --branch agent/<id>/<topic>` and execute from the reported worktree path.",
940        status.git.current_branch
941    )))
942}
943
944fn rpc_op_requires_worktree(op: &str) -> bool {
945    !matches!(
946        op,
947        "agent.init"
948            | "workspace.status"
949            | "workspace.ensure"
950            | "assurance.evaluate"
951            | "mentor.obligations"
952            | "context.resolve"
953            | "context.bindings"
954            | "schema.get"
955            | "store.upsert"
956            | "store.query"
957            | "validate.run"
958            | "standards.resolve"
959    )
960}
961
962fn enforce_worktree_requirement_for_rpc(
963    op: &str,
964    project_root: &Path,
965) -> Result<(), error::DecapodError> {
966    if std::env::var("DECAPOD_VALIDATE_SKIP_GIT_GATES").is_ok() {
967        return Ok(());
968    }
969    if !rpc_op_requires_worktree(op) {
970        return Ok(());
971    }
972
973    let status = crate::core::workspace::get_workspace_status(project_root)?;
974    if status.git.in_worktree {
975        return Ok(());
976    }
977
978    Err(error::DecapodError::ValidationError(format!(
979        "RPC op '{}' requires isolated git worktree; current checkout is not a worktree (branch='{}'). Run `decapod workspace ensure --branch agent/<id>/<topic>` and execute from the reported worktree path.",
980        op, status.git.current_branch
981    )))
982}
983
984fn rpc_op_bypasses_session(op: &str) -> bool {
985    matches!(
986        op,
987        "agent.init"
988            | "context.resolve"
989            | "context.bindings"
990            | "schema.get"
991            | "store.upsert"
992            | "store.query"
993            | "validate.run"
994            | "workspace.status"
995            | "workspace.ensure"
996            | "standards.resolve"
997    )
998}
999
1000fn requires_session_token(command: &Command) -> bool {
1001    match command {
1002        // Bootstrap/session lifecycle + version + capabilities are sessionless.
1003        Command::Init(_)
1004        | Command::Session(_)
1005        | Command::Version
1006        | Command::Validate(_)
1007        | Command::Docs(_)
1008        | Command::Capabilities(_)
1009        | Command::Trace(_) => false,
1010        Command::Data(DataCli {
1011            command: DataCommand::Schema(_),
1012        }) => false,
1013        Command::Rpc(rpc_cli) => {
1014            if let Some(ref op) = rpc_cli.op {
1015                !rpc_op_bypasses_session(op)
1016            } else {
1017                // If op is not provided via flag, we'll check it after parsing JSON in run_rpc_command
1018                false
1019            }
1020        }
1021        _ => true,
1022    }
1023}
1024
1025#[derive(Debug, Serialize, Deserialize)]
1026struct AgentSessionRecord {
1027    agent_id: String,
1028    token: String,
1029    password_hash: String,
1030    issued_at_epoch_secs: u64,
1031    expires_at_epoch_secs: u64,
1032}
1033
1034#[derive(Debug, Serialize, Deserialize)]
1035struct ConstitutionalAwarenessRecord {
1036    agent_id: String,
1037    session_token: Option<String>,
1038    initialized_at_epoch_secs: u64,
1039    validated_at_epoch_secs: Option<u64>,
1040    core_constitution_ingested_at_epoch_secs: Option<u64>,
1041    context_resolved_at_epoch_secs: Option<u64>,
1042    source_ops: Vec<String>,
1043}
1044
1045fn now_epoch_secs() -> u64 {
1046    SystemTime::now()
1047        .duration_since(UNIX_EPOCH)
1048        .map(|d| d.as_secs())
1049        .unwrap_or(0)
1050}
1051
1052fn session_ttl_secs() -> u64 {
1053    std::env::var("DECAPOD_SESSION_TTL_SECS")
1054        .ok()
1055        .and_then(|v| v.parse::<u64>().ok())
1056        .filter(|v| *v > 0)
1057        .unwrap_or(3600)
1058}
1059
1060fn current_agent_id() -> String {
1061    std::env::var("DECAPOD_AGENT_ID")
1062        .ok()
1063        .map(|v| v.trim().to_string())
1064        .filter(|v| !v.is_empty())
1065        .unwrap_or_else(|| "unknown".to_string())
1066}
1067
1068fn sanitize_agent_component(s: &str) -> String {
1069    let mut out = String::with_capacity(s.len());
1070    for ch in s.chars() {
1071        if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
1072            out.push(ch.to_ascii_lowercase());
1073        } else {
1074            out.push('-');
1075        }
1076    }
1077    out.trim_matches('-').to_string()
1078}
1079
1080fn sessions_dir(project_root: &Path) -> PathBuf {
1081    project_root
1082        .join(".decapod")
1083        .join("generated")
1084        .join("sessions")
1085}
1086
1087fn session_file_for_agent(project_root: &Path, agent_id: &str) -> PathBuf {
1088    sessions_dir(project_root).join(format!("{}.json", sanitize_agent_component(agent_id)))
1089}
1090
1091fn awareness_dir(project_root: &Path) -> PathBuf {
1092    project_root
1093        .join(".decapod")
1094        .join("generated")
1095        .join("awareness")
1096}
1097
1098fn awareness_file_for_agent(project_root: &Path, agent_id: &str) -> PathBuf {
1099    awareness_dir(project_root).join(format!("{}.json", sanitize_agent_component(agent_id)))
1100}
1101
1102fn hash_password(password: &str, token: &str) -> String {
1103    let mut hasher = Sha256::new();
1104    hasher.update(token.as_bytes());
1105    hasher.update(b":");
1106    hasher.update(password.as_bytes());
1107    let digest = hasher.finalize();
1108    let mut out = String::with_capacity(digest.len() * 2);
1109    for b in digest {
1110        out.push_str(&format!("{:02x}", b));
1111    }
1112    out
1113}
1114
1115fn generate_ephemeral_password() -> Result<String, error::DecapodError> {
1116    let mut buf = vec![0u8; 24];
1117    let mut urandom = fs::File::open("/dev/urandom").map_err(error::DecapodError::IoError)?;
1118    urandom
1119        .read_exact(&mut buf)
1120        .map_err(error::DecapodError::IoError)?;
1121    let mut out = String::with_capacity(buf.len() * 2);
1122    for b in buf {
1123        out.push_str(&format!("{:02x}", b));
1124    }
1125    Ok(out)
1126}
1127
1128fn read_agent_session(
1129    project_root: &Path,
1130    agent_id: &str,
1131) -> Result<Option<AgentSessionRecord>, error::DecapodError> {
1132    let path = session_file_for_agent(project_root, agent_id);
1133    if !path.exists() {
1134        return Ok(None);
1135    }
1136    let raw = fs::read_to_string(&path).map_err(error::DecapodError::IoError)?;
1137    let rec: AgentSessionRecord = serde_json::from_str(&raw)
1138        .map_err(|e| error::DecapodError::SessionError(format!("invalid session file: {}", e)))?;
1139    Ok(Some(rec))
1140}
1141
1142fn write_agent_session(
1143    project_root: &Path,
1144    rec: &AgentSessionRecord,
1145) -> Result<(), error::DecapodError> {
1146    let dir = sessions_dir(project_root);
1147    fs::create_dir_all(&dir).map_err(error::DecapodError::IoError)?;
1148    let path = session_file_for_agent(project_root, &rec.agent_id);
1149    let body = serde_json::to_string_pretty(rec)
1150        .map_err(|e| error::DecapodError::SessionError(format!("session encode error: {}", e)))?;
1151    fs::write(&path, body).map_err(error::DecapodError::IoError)?;
1152    #[cfg(unix)]
1153    {
1154        use std::os::unix::fs::PermissionsExt;
1155        let mut perms = fs::metadata(&path)
1156            .map_err(error::DecapodError::IoError)?
1157            .permissions();
1158        perms.set_mode(0o600);
1159        fs::set_permissions(&path, perms).map_err(error::DecapodError::IoError)?;
1160    }
1161    Ok(())
1162}
1163
1164fn clear_agent_awareness(project_root: &Path, agent_id: &str) -> Result<(), error::DecapodError> {
1165    let path = awareness_file_for_agent(project_root, agent_id);
1166    if path.exists() {
1167        fs::remove_file(path).map_err(error::DecapodError::IoError)?;
1168    }
1169    Ok(())
1170}
1171
1172fn read_awareness_record(
1173    project_root: &Path,
1174    agent_id: &str,
1175) -> Result<Option<ConstitutionalAwarenessRecord>, error::DecapodError> {
1176    let path = awareness_file_for_agent(project_root, agent_id);
1177    if !path.exists() {
1178        return Ok(None);
1179    }
1180    let raw = fs::read_to_string(path).map_err(error::DecapodError::IoError)?;
1181    let rec: ConstitutionalAwarenessRecord = serde_json::from_str(&raw).map_err(|e| {
1182        error::DecapodError::ValidationError(format!(
1183            "invalid constitutional awareness record: {}",
1184            e
1185        ))
1186    })?;
1187    Ok(Some(rec))
1188}
1189
1190fn write_awareness_record(
1191    project_root: &Path,
1192    rec: &ConstitutionalAwarenessRecord,
1193) -> Result<(), error::DecapodError> {
1194    let dir = awareness_dir(project_root);
1195    fs::create_dir_all(&dir).map_err(error::DecapodError::IoError)?;
1196    let path = awareness_file_for_agent(project_root, &rec.agent_id);
1197    let body = serde_json::to_string_pretty(rec).map_err(|e| {
1198        error::DecapodError::ValidationError(format!("awareness encode error: {}", e))
1199    })?;
1200    fs::write(&path, body).map_err(error::DecapodError::IoError)?;
1201    #[cfg(unix)]
1202    {
1203        use std::os::unix::fs::PermissionsExt;
1204        let mut perms = fs::metadata(&path)
1205            .map_err(error::DecapodError::IoError)?
1206            .permissions();
1207        perms.set_mode(0o600);
1208        fs::set_permissions(&path, perms).map_err(error::DecapodError::IoError)?;
1209    }
1210    Ok(())
1211}
1212
1213fn mark_constitution_initialized(project_root: &Path) -> Result<(), error::DecapodError> {
1214    let agent_id = current_agent_id();
1215    let session_token = read_agent_session(project_root, &agent_id)?.map(|s| s.token);
1216    let now = now_epoch_secs();
1217    let existing = read_awareness_record(project_root, &agent_id)?;
1218    let mut source_ops = existing
1219        .as_ref()
1220        .map(|r| r.source_ops.clone())
1221        .unwrap_or_default();
1222    if !source_ops.iter().any(|op| op == "agent.init") {
1223        source_ops.push("agent.init".to_string());
1224    }
1225    let rec = ConstitutionalAwarenessRecord {
1226        agent_id,
1227        session_token,
1228        initialized_at_epoch_secs: now,
1229        validated_at_epoch_secs: existing.as_ref().and_then(|r| r.validated_at_epoch_secs),
1230        core_constitution_ingested_at_epoch_secs: existing
1231            .as_ref()
1232            .and_then(|r| r.core_constitution_ingested_at_epoch_secs),
1233        context_resolved_at_epoch_secs: existing.and_then(|r| r.context_resolved_at_epoch_secs),
1234        source_ops,
1235    };
1236    write_awareness_record(project_root, &rec)
1237}
1238
1239fn mark_constitution_context_resolved(project_root: &Path) -> Result<(), error::DecapodError> {
1240    let agent_id = current_agent_id();
1241    let mut rec =
1242        read_awareness_record(project_root, &agent_id)?.unwrap_or(ConstitutionalAwarenessRecord {
1243            agent_id: agent_id.clone(),
1244            session_token: read_agent_session(project_root, &agent_id)?.map(|s| s.token),
1245            initialized_at_epoch_secs: now_epoch_secs(),
1246            validated_at_epoch_secs: None,
1247            core_constitution_ingested_at_epoch_secs: None,
1248            context_resolved_at_epoch_secs: None,
1249            source_ops: Vec::new(),
1250        });
1251    rec.context_resolved_at_epoch_secs = Some(now_epoch_secs());
1252    if !rec.source_ops.iter().any(|op| op == "context.resolve") {
1253        rec.source_ops.push("context.resolve".to_string());
1254    }
1255    write_awareness_record(project_root, &rec)
1256}
1257
1258fn mark_validation_completed(project_root: &Path) -> Result<(), error::DecapodError> {
1259    let agent_id = current_agent_id();
1260    let mut rec =
1261        read_awareness_record(project_root, &agent_id)?.unwrap_or(ConstitutionalAwarenessRecord {
1262            agent_id: agent_id.clone(),
1263            session_token: read_agent_session(project_root, &agent_id)?.map(|s| s.token),
1264            initialized_at_epoch_secs: now_epoch_secs(),
1265            validated_at_epoch_secs: None,
1266            core_constitution_ingested_at_epoch_secs: None,
1267            context_resolved_at_epoch_secs: None,
1268            source_ops: Vec::new(),
1269        });
1270    rec.validated_at_epoch_secs = Some(now_epoch_secs());
1271    if !rec.source_ops.iter().any(|op| op == "validate") {
1272        rec.source_ops.push("validate".to_string());
1273    }
1274    write_awareness_record(project_root, &rec)
1275}
1276
1277fn mark_core_constitution_ingested(project_root: &Path) -> Result<(), error::DecapodError> {
1278    let agent_id = current_agent_id();
1279    let mut rec =
1280        read_awareness_record(project_root, &agent_id)?.unwrap_or(ConstitutionalAwarenessRecord {
1281            agent_id: agent_id.clone(),
1282            session_token: read_agent_session(project_root, &agent_id)?.map(|s| s.token),
1283            initialized_at_epoch_secs: now_epoch_secs(),
1284            validated_at_epoch_secs: None,
1285            core_constitution_ingested_at_epoch_secs: None,
1286            context_resolved_at_epoch_secs: None,
1287            source_ops: Vec::new(),
1288        });
1289    rec.core_constitution_ingested_at_epoch_secs = Some(now_epoch_secs());
1290    if !rec.source_ops.iter().any(|op| op == "docs.ingest") {
1291        rec.source_ops.push("docs.ingest".to_string());
1292    }
1293    write_awareness_record(project_root, &rec)
1294}
1295
1296fn cleanup_expired_sessions(
1297    project_root: &Path,
1298    store_root: &Path,
1299) -> Result<Vec<String>, error::DecapodError> {
1300    let dir = sessions_dir(project_root);
1301    if !dir.exists() {
1302        return Ok(Vec::new());
1303    }
1304    let now = now_epoch_secs();
1305    let mut expired_agents = Vec::new();
1306    for entry in fs::read_dir(&dir).map_err(error::DecapodError::IoError)? {
1307        let entry = entry.map_err(error::DecapodError::IoError)?;
1308        let path = entry.path();
1309        if path.extension().and_then(|s| s.to_str()) != Some("json") {
1310            continue;
1311        }
1312        let raw = match fs::read_to_string(&path) {
1313            Ok(v) => v,
1314            Err(_) => {
1315                let _ = fs::remove_file(&path);
1316                continue;
1317            }
1318        };
1319        let rec: AgentSessionRecord = match serde_json::from_str(&raw) {
1320            Ok(v) => v,
1321            Err(_) => {
1322                let _ = fs::remove_file(&path);
1323                continue;
1324            }
1325        };
1326        if rec.expires_at_epoch_secs <= now {
1327            let _ = fs::remove_file(&path);
1328            expired_agents.push(rec.agent_id);
1329        }
1330    }
1331
1332    if !expired_agents.is_empty() {
1333        todo::cleanup_stale_agent_assignments(store_root, &expired_agents, "session.expired")?;
1334        for agent_id in &expired_agents {
1335            let _ = clear_agent_awareness(project_root, agent_id);
1336        }
1337    }
1338
1339    Ok(expired_agents)
1340}
1341
1342fn ensure_session_valid() -> Result<(), error::DecapodError> {
1343    let current_dir = std::env::current_dir()?;
1344    let project_root = find_decapod_project_root(&current_dir)?;
1345    let store_root = project_root.join(".decapod").join("data");
1346    fs::create_dir_all(&store_root).map_err(error::DecapodError::IoError)?;
1347    let _ = cleanup_expired_sessions(&project_root, &store_root)?;
1348
1349    let agent_id = current_agent_id();
1350    let session = read_agent_session(&project_root, &agent_id)?;
1351    let Some(session) = session else {
1352        return Err(error::DecapodError::SessionError(format!(
1353            "No active session for agent '{}'. Run 'decapod session acquire' first. Reminder: this CLI/API is not for humans.",
1354            agent_id
1355        )));
1356    };
1357
1358    if session.expires_at_epoch_secs <= now_epoch_secs() {
1359        let _ = fs::remove_file(session_file_for_agent(&project_root, &agent_id));
1360        let _ = todo::cleanup_stale_agent_assignments(
1361            &store_root,
1362            std::slice::from_ref(&agent_id),
1363            "session.expired",
1364        );
1365        return Err(error::DecapodError::SessionError(format!(
1366            "Session expired for agent '{}'. Run 'decapod session acquire' to rotate credentials.",
1367            agent_id
1368        )));
1369    }
1370
1371    if agent_id == "unknown" {
1372        return Ok(());
1373    }
1374
1375    let supplied_password = std::env::var("DECAPOD_SESSION_PASSWORD").map_err(|_| {
1376        error::DecapodError::SessionError(
1377            "Missing DECAPOD_SESSION_PASSWORD. Agent+password is required for session access."
1378                .to_string(),
1379        )
1380    })?;
1381    let supplied_hash = hash_password(&supplied_password, &session.token);
1382    if supplied_hash != session.password_hash {
1383        return Err(error::DecapodError::SessionError(
1384            "Invalid DECAPOD_SESSION_PASSWORD for current agent session.".to_string(),
1385        ));
1386    }
1387    Ok(())
1388}
1389
1390fn run_session_command(session_cli: SessionCli) -> Result<(), error::DecapodError> {
1391    let current_dir = std::env::current_dir()?;
1392    let project_root = find_decapod_project_root(&current_dir)?;
1393    let store_root = project_root.join(".decapod").join("data");
1394    fs::create_dir_all(&store_root).map_err(error::DecapodError::IoError)?;
1395    let _ = cleanup_expired_sessions(&project_root, &store_root)?;
1396
1397    match session_cli.command {
1398        SessionCommand::Acquire => {
1399            let agent_id = current_agent_id();
1400            if let Some(existing) = read_agent_session(&project_root, &agent_id)?
1401                && existing.expires_at_epoch_secs > now_epoch_secs()
1402            {
1403                println!(
1404                    "Session already active for agent '{}'. Use 'decapod session status' for details.",
1405                    agent_id
1406                );
1407                return Ok(());
1408            }
1409
1410            let issued = now_epoch_secs();
1411            let expires = issued.saturating_add(session_ttl_secs());
1412            let token = ulid::Ulid::to_string(&ulid::Ulid::new());
1413            let password = generate_ephemeral_password()?;
1414            let rec = AgentSessionRecord {
1415                agent_id: agent_id.clone(),
1416                token: token.clone(),
1417                password_hash: hash_password(&password, &token),
1418                issued_at_epoch_secs: issued,
1419                expires_at_epoch_secs: expires,
1420            };
1421            write_agent_session(&project_root, &rec)?;
1422            clear_agent_awareness(&project_root, &agent_id)?;
1423
1424            println!("Session acquired successfully.");
1425            println!("Agent: {}", agent_id);
1426            println!("Token: {}", token);
1427            println!("Password: {}", password);
1428            println!("ExpiresAtEpoch: {}", expires);
1429            println!(
1430                "Export before running other commands: DECAPOD_AGENT_ID='{}' and DECAPOD_SESSION_PASSWORD='<password>'",
1431                rec.agent_id
1432            );
1433            println!("\nYou may now use other decapod commands.");
1434            Ok(())
1435        }
1436        SessionCommand::Status => {
1437            let agent_id = current_agent_id();
1438            if let Some(session) = read_agent_session(&project_root, &agent_id)? {
1439                println!("Session active");
1440                println!("Agent: {}", session.agent_id);
1441                println!("Token: {}", session.token);
1442                println!("IssuedAtEpoch: {}", session.issued_at_epoch_secs);
1443                println!("ExpiresAtEpoch: {}", session.expires_at_epoch_secs);
1444            } else {
1445                println!("No active session");
1446                println!("Run 'decapod session acquire' to start a session");
1447            }
1448            Ok(())
1449        }
1450        SessionCommand::Release => {
1451            let agent_id = current_agent_id();
1452            let session_path = session_file_for_agent(&project_root, &agent_id);
1453            if session_path.exists() {
1454                std::fs::remove_file(&session_path).map_err(error::DecapodError::IoError)?;
1455                clear_agent_awareness(&project_root, &agent_id)?;
1456                let _ = todo::cleanup_stale_agent_assignments(
1457                    &store_root,
1458                    std::slice::from_ref(&agent_id),
1459                    "session.release",
1460                );
1461                println!("Session released");
1462            } else {
1463                println!("No active session to release");
1464            }
1465            Ok(())
1466        }
1467    }
1468}
1469
1470fn run_validate_command(
1471    validate_cli: ValidateCli,
1472    project_root: &Path,
1473    project_store: &Store,
1474) -> Result<(), error::DecapodError> {
1475    use crate::core::workspace;
1476
1477    if std::env::var("DECAPOD_VALIDATE_SKIP_GIT_GATES").is_ok() {
1478        // Skip workspace check if gates are explicitly skipped
1479    } else {
1480        // FIRST: Check workspace enforcement (non-negotiable)
1481        let workspace_status = workspace::get_workspace_status(project_root)?;
1482
1483        if !workspace_status.can_work {
1484            let blocker = workspace_status
1485                .blockers
1486                .first()
1487                .expect("Workspace should have a blocker if can_work is false");
1488
1489            let response = serde_json::json!({
1490                "success": false,
1491                "gate": "workspace_protection",
1492                "error": blocker.message,
1493                "resolve_hint": blocker.resolve_hint,
1494                "branch": workspace_status.git.current_branch,
1495                "is_protected": workspace_status.git.is_protected,
1496                "in_container": workspace_status.container.in_container,
1497            });
1498
1499            if validate_cli.format == "json" {
1500                println!("{}", serde_json::to_string_pretty(&response).unwrap());
1501            } else {
1502                eprintln!("❌ VALIDATION FAILED: Workspace Protection Gate");
1503                eprintln!("   Error: {}", blocker.message);
1504                eprintln!("   Hint: {}", blocker.resolve_hint);
1505            }
1506
1507            return Err(error::DecapodError::ValidationError(
1508                "Workspace protection gate failed".to_string(),
1509            ));
1510        }
1511    }
1512
1513    let decapod_root = project_root.to_path_buf();
1514    let store = match validate_cli.store.as_str() {
1515        "user" => {
1516            // User store uses a temp directory for blank-slate validation
1517            let tmp_root =
1518                std::env::temp_dir().join(format!("decapod_validate_user_{}", ulid::Ulid::new()));
1519            std::fs::create_dir_all(&tmp_root).map_err(error::DecapodError::IoError)?;
1520            Store {
1521                kind: StoreKind::User,
1522                root: tmp_root,
1523            }
1524        }
1525        _ => project_store.clone(),
1526    };
1527
1528    validate::run_validation(&store, &decapod_root, &decapod_root)?;
1529    mark_validation_completed(project_root)?;
1530    Ok(())
1531}
1532
1533fn rpc_op_requires_constitutional_awareness(op: &str) -> bool {
1534    matches!(
1535        op,
1536        "workspace.publish"
1537            | "store.upsert"
1538            | "scaffold.apply_answer"
1539            | "scaffold.generate_artifacts"
1540    )
1541}
1542
1543fn enforce_constitutional_awareness_for_rpc(
1544    op: &str,
1545    project_root: &Path,
1546) -> Result<(), error::DecapodError> {
1547    if !rpc_op_requires_constitutional_awareness(op) {
1548        return Ok(());
1549    }
1550
1551    let agent_id = current_agent_id();
1552    let rec = read_awareness_record(project_root, &agent_id)?;
1553    let Some(rec) = rec else {
1554        return Err(error::DecapodError::ValidationError(
1555            "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`."
1556                .to_string(),
1557        ));
1558    };
1559
1560    if rec.validated_at_epoch_secs.is_none() {
1561        return Err(error::DecapodError::ValidationError(
1562            "Constitutional awareness incomplete: `decapod validate` has not completed for this agent context. Run `decapod validate` first."
1563                .to_string(),
1564        ));
1565    }
1566
1567    if rec.core_constitution_ingested_at_epoch_secs.is_none() {
1568        return Err(error::DecapodError::ValidationError(
1569            "Constitutional awareness incomplete: core constitution ingestion missing. Run `decapod docs ingest` to ingest `constitution/core/*.md` before mutating operations."
1570                .to_string(),
1571        ));
1572    }
1573
1574    if rec.context_resolved_at_epoch_secs.is_none() {
1575        return Err(error::DecapodError::ValidationError(
1576            "Constitutional awareness incomplete: `context.resolve` has not been executed after initialization. Run `decapod rpc --op context.resolve`."
1577                .to_string(),
1578        ));
1579    }
1580
1581    if let Some(session) = read_agent_session(project_root, &agent_id)?
1582        && rec.session_token.as_deref() != Some(session.token.as_str())
1583    {
1584        return Err(error::DecapodError::ValidationError(
1585            "Constitutional awareness is stale for the active session. Re-run `decapod rpc --op agent.init` and `decapod rpc --op context.resolve`."
1586                .to_string(),
1587        ));
1588    }
1589
1590    Ok(())
1591}
1592
1593fn run_govern_command(
1594    govern_cli: GovernCli,
1595    project_store: &Store,
1596    store_root: &Path,
1597) -> Result<(), error::DecapodError> {
1598    match govern_cli.command {
1599        GovernCommand::Policy(policy_cli) => policy::run_policy_cli(project_store, policy_cli)?,
1600        GovernCommand::Health(health_cli) => health::run_health_cli(project_store, health_cli)?,
1601        GovernCommand::Proof(proof_cli) => proof::execute_proof_cli(&proof_cli, store_root)?,
1602        GovernCommand::Watcher(watcher_cli) => match watcher_cli.command {
1603            WatcherCommand::Run => {
1604                let report = watcher::run_watcher(project_store)?;
1605                println!("{}", serde_json::to_string_pretty(&report).unwrap());
1606            }
1607        },
1608        GovernCommand::Feedback(feedback_cli) => {
1609            feedback::initialize_feedback_db(store_root)?;
1610            match feedback_cli.command {
1611                FeedbackCommand::Add {
1612                    source,
1613                    text,
1614                    links,
1615                } => {
1616                    let id =
1617                        feedback::add_feedback(project_store, &source, &text, links.as_deref())?;
1618                    println!("Feedback recorded: {}", id);
1619                }
1620                FeedbackCommand::Propose => {
1621                    let proposal = feedback::propose_prefs(project_store)?;
1622                    println!("{}", proposal);
1623                }
1624            }
1625        }
1626    }
1627
1628    Ok(())
1629}
1630
1631fn run_data_command(
1632    data_cli: DataCli,
1633    project_store: &Store,
1634    project_root: &Path,
1635    store_root: &Path,
1636) -> Result<(), error::DecapodError> {
1637    match data_cli.command {
1638        DataCommand::Archive(archive_cli) => {
1639            archive::initialize_archive_db(store_root)?;
1640            match archive_cli.command {
1641                ArchiveCommand::List => {
1642                    let items = archive::list_archives(project_store)?;
1643                    println!("{}", serde_json::to_string_pretty(&items).unwrap());
1644                }
1645                ArchiveCommand::Verify => {
1646                    let failures = archive::verify_archives(project_store)?;
1647                    if failures.is_empty() {
1648                        println!("All archives verified successfully.");
1649                    } else {
1650                        println!("Archive verification failed:");
1651                        for f in failures {
1652                            println!("- {}", f);
1653                        }
1654                    }
1655                }
1656            }
1657        }
1658        DataCommand::Knowledge(knowledge_cli) => {
1659            db::initialize_knowledge_db(store_root)?;
1660            match knowledge_cli.command {
1661                KnowledgeCommand::Add {
1662                    id,
1663                    title,
1664                    text,
1665                    provenance,
1666                    claim_id,
1667                } => {
1668                    knowledge::add_knowledge(
1669                        project_store,
1670                        &id,
1671                        &title,
1672                        &text,
1673                        &provenance,
1674                        claim_id.as_deref(),
1675                    )?;
1676                    println!("Knowledge entry added: {}", id);
1677                }
1678                KnowledgeCommand::Search { query } => {
1679                    let results = knowledge::search_knowledge(project_store, &query)?;
1680                    println!("{}", serde_json::to_string_pretty(&results).unwrap());
1681                }
1682            }
1683        }
1684        DataCommand::Context(context_cli) => {
1685            let manager = context::ContextManager::new(store_root)?;
1686            match context_cli.command {
1687                ContextCommand::Audit { profile, files } => {
1688                    let total = manager.audit_session(&files)?;
1689                    match manager.get_profile(&profile) {
1690                        Some(p) => {
1691                            println!(
1692                                "Total tokens for profile '{}': {} / {} (budget)",
1693                                profile, total, p.budget_tokens
1694                            );
1695                            if total > p.budget_tokens {
1696                                println!("⚠ OVER BUDGET");
1697                            }
1698                        }
1699                        None => {
1700                            println!("Total tokens: {} (Profile '{}' not found)", total, profile);
1701                        }
1702                    }
1703                }
1704                ContextCommand::Pack { path, summary } => {
1705                    let archive_path = manager
1706                        .pack_and_archive(project_store, &path, &summary)
1707                        .map_err(|err| match err {
1708                            error::DecapodError::ContextPackError(msg) => {
1709                                error::DecapodError::ContextPackError(format!(
1710                                    "Context pack failed: {}",
1711                                    msg
1712                                ))
1713                            }
1714                            other => other,
1715                        })?;
1716                    println!("Session archived to: {}", archive_path.display());
1717                }
1718                ContextCommand::Restore {
1719                    id,
1720                    profile,
1721                    current_files,
1722                } => {
1723                    let content = manager.restore_archive(&id, &profile, &current_files)?;
1724                    println!(
1725                        "--- RESTORED CONTENT (Archive: {}) ---\n{}\n--- END RESTORED ---",
1726                        id, content
1727                    );
1728                }
1729            }
1730        }
1731        DataCommand::Schema(schema_cli) => {
1732            let schemas = schema_catalog();
1733
1734            let output = if let Some(sub) = schema_cli.subsystem {
1735                schemas
1736                    .get(sub.as_str())
1737                    .cloned()
1738                    .unwrap_or(serde_json::json!({ "error": "subsystem not found" }))
1739            } else {
1740                let mut envelope = serde_json::json!({
1741                    "schema_version": "1.0.0",
1742                    "subsystems": schemas,
1743                    "deprecations": deprecation_metadata(),
1744                    "command_registry": cli_command_registry()
1745                });
1746                if !schema_cli.deterministic {
1747                    envelope.as_object_mut().unwrap().insert(
1748                        "generated_at".to_string(),
1749                        serde_json::json!(format!("{:?}", std::time::SystemTime::now())),
1750                    );
1751                }
1752                envelope
1753            };
1754
1755            match schema_cli.format.as_str() {
1756                "json" => println!("{}", serde_json::to_string_pretty(&output).unwrap()),
1757                "md" => {
1758                    println!("{}", schema_to_markdown(&output));
1759                }
1760                other => {
1761                    return Err(error::DecapodError::ValidationError(format!(
1762                        "Unsupported schema format '{}'. Use 'json' or 'md'.",
1763                        other
1764                    )));
1765                }
1766            }
1767        }
1768        DataCommand::Repo(repo_cli) => match repo_cli.command {
1769            RepoCommand::Map => {
1770                let map = repomap::generate_map(project_root);
1771                println!("{}", serde_json::to_string_pretty(&map).unwrap());
1772            }
1773            RepoCommand::Graph => {
1774                let graph = repomap::generate_doc_graph(project_root);
1775                println!("{}", graph.mermaid);
1776            }
1777        },
1778        DataCommand::Broker(broker_cli) => match broker_cli.command {
1779            BrokerCommand::Audit => {
1780                let audit_log = store_root.join("broker.events.jsonl");
1781                if audit_log.exists() {
1782                    let content = std::fs::read_to_string(audit_log)?;
1783                    println!("{}", content);
1784                } else {
1785                    println!("No audit log found.");
1786                }
1787            }
1788        },
1789        DataCommand::Teammate(teammate_cli) => {
1790            teammate::run_teammate_cli(project_store, teammate_cli)?;
1791        }
1792        DataCommand::Federation(federation_cli) => {
1793            federation::run_federation_cli(project_store, federation_cli)?;
1794        }
1795        DataCommand::Primitives(primitives_cli) => {
1796            primitives::run_primitives_cli(project_store, primitives_cli)?;
1797        }
1798    }
1799
1800    Ok(())
1801}
1802
1803fn schema_to_markdown(schema: &serde_json::Value) -> String {
1804    fn render_value(v: &serde_json::Value) -> String {
1805        match v {
1806            serde_json::Value::Object(map) => {
1807                let mut keys: Vec<_> = map.keys().cloned().collect();
1808                keys.sort();
1809                let mut out = String::new();
1810                for key in keys {
1811                    let value = &map[&key];
1812                    match value {
1813                        serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
1814                            out.push_str(&format!("- **{}**:\n", key));
1815                            for line in render_value(value).lines() {
1816                                out.push_str(&format!("  {}\n", line));
1817                            }
1818                        }
1819                        _ => out.push_str(&format!("- **{}**: `{}`\n", key, value)),
1820                    }
1821                }
1822                out
1823            }
1824            serde_json::Value::Array(items) => {
1825                let mut out = String::new();
1826                for item in items {
1827                    match item {
1828                        serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
1829                            out.push_str("- item:\n");
1830                            for line in render_value(item).lines() {
1831                                out.push_str(&format!("  {}\n", line));
1832                            }
1833                        }
1834                        _ => out.push_str(&format!("- `{}`\n", item)),
1835                    }
1836                }
1837                out
1838            }
1839            _ => format!("- `{}`\n", v),
1840        }
1841    }
1842
1843    let mut out = String::from("# Decapod Schema\n\n");
1844    out.push_str(&render_value(schema));
1845    out
1846}
1847
1848fn schema_catalog() -> std::collections::BTreeMap<&'static str, serde_json::Value> {
1849    let mut schemas = std::collections::BTreeMap::new();
1850    schemas.insert("todo", todo::schema());
1851    schemas.insert("cron", cron::schema());
1852    schemas.insert("reflex", reflex::schema());
1853    schemas.insert("workflow", workflow::schema());
1854    schemas.insert("container", container::schema());
1855    schemas.insert("health", health::health_schema());
1856    schemas.insert("broker", core::broker::schema());
1857    schemas.insert("external_action", core::external_action::schema());
1858    schemas.insert("context", context::schema());
1859    schemas.insert("policy", policy::schema());
1860    schemas.insert("knowledge", knowledge::schema());
1861    schemas.insert("repomap", repomap::schema());
1862    schemas.insert("watcher", watcher::schema());
1863    schemas.insert("archive", archive::schema());
1864    schemas.insert("feedback", feedback::schema());
1865    schemas.insert("teammate", teammate::schema());
1866    schemas.insert("federation", federation::schema());
1867    schemas.insert("primitives", primitives::schema());
1868    schemas.insert("decide", decide::schema());
1869    schemas.insert("docs", docs_cli::schema());
1870    schemas.insert("deprecations", deprecation_metadata());
1871    schemas.insert(
1872        "command_registry",
1873        serde_json::json!({
1874            "name": "command_registry",
1875            "version": "0.1.0",
1876            "description": "Machine-readable CLI command registry generated from clap command definitions",
1877            "root": cli_command_registry()
1878        }),
1879    );
1880    schemas
1881}
1882
1883fn deprecation_metadata() -> serde_json::Value {
1884    serde_json::json!({
1885        "name": "deprecations",
1886        "version": "0.1.0",
1887        "description": "Deprecated command surfaces and replacement pointers",
1888        "entries": [
1889            {
1890                "surface": "command",
1891                "path": "decapod heartbeat",
1892                "status": "deprecated",
1893                "replacement": "decapod govern health summary",
1894                "notes": "Heartbeat command family was consolidated into govern health"
1895            },
1896            {
1897                "surface": "command",
1898                "path": "decapod trust",
1899                "status": "deprecated",
1900                "replacement": "decapod govern health autonomy",
1901                "notes": "Trust command family was consolidated into govern health"
1902            },
1903            {
1904                "surface": "module",
1905                "path": "src/plugins/heartbeat.rs",
1906                "status": "deprecated",
1907                "replacement": "src/plugins/health.rs"
1908            },
1909            {
1910                "surface": "module",
1911                "path": "src/plugins/trust.rs",
1912                "status": "deprecated",
1913                "replacement": "src/plugins/health.rs"
1914            }
1915        ]
1916    })
1917}
1918
1919fn cli_command_registry() -> serde_json::Value {
1920    let command = Cli::command();
1921    command_to_registry(&command)
1922}
1923
1924fn command_to_registry(command: &clap::Command) -> serde_json::Value {
1925    let mut subcommands: Vec<serde_json::Value> = command
1926        .get_subcommands()
1927        .filter(|sub| !sub.is_hide_set())
1928        .map(command_to_registry)
1929        .collect();
1930    subcommands.sort_by(|a, b| {
1931        let a_name = a
1932            .get("name")
1933            .and_then(serde_json::Value::as_str)
1934            .unwrap_or_default();
1935        let b_name = b
1936            .get("name")
1937            .and_then(serde_json::Value::as_str)
1938            .unwrap_or_default();
1939        a_name.cmp(b_name)
1940    });
1941
1942    let mut options: Vec<serde_json::Value> = command
1943        .get_arguments()
1944        .filter(|arg| !arg.is_hide_set())
1945        .map(|arg| {
1946            let mut flags = Vec::new();
1947            if let Some(long) = arg.get_long() {
1948                flags.push(format!("--{}", long));
1949            }
1950            if let Some(short) = arg.get_short() {
1951                flags.push(format!("-{}", short));
1952            }
1953            if flags.is_empty() {
1954                flags.push(arg.get_id().to_string());
1955            }
1956
1957            let value_names = arg
1958                .get_value_names()
1959                .map(|values| values.iter().map(|v| v.to_string()).collect::<Vec<_>>())
1960                .unwrap_or_default();
1961
1962            serde_json::json!({
1963                "id": arg.get_id().to_string(),
1964                "flags": flags,
1965                "required": arg.is_required_set(),
1966                "help": arg.get_help().map(|help| help.to_string()),
1967                "value_names": value_names
1968            })
1969        })
1970        .collect();
1971
1972    options.sort_by(|a, b| {
1973        let a_id = a
1974            .get("id")
1975            .and_then(serde_json::Value::as_str)
1976            .unwrap_or_default();
1977        let b_id = b
1978            .get("id")
1979            .and_then(serde_json::Value::as_str)
1980            .unwrap_or_default();
1981        a_id.cmp(b_id)
1982    });
1983
1984    let aliases: Vec<String> = command.get_all_aliases().map(str::to_string).collect();
1985
1986    serde_json::json!({
1987        "name": command.get_name(),
1988        "about": command.get_about().map(|about| about.to_string()),
1989        "aliases": aliases,
1990        "options": options,
1991        "subcommands": subcommands
1992    })
1993}
1994
1995fn run_auto_command(auto_cli: AutoCli, project_store: &Store) -> Result<(), error::DecapodError> {
1996    match auto_cli.command {
1997        AutoCommand::Cron(cron_cli) => cron::run_cron_cli(project_store, cron_cli)?,
1998        AutoCommand::Reflex(reflex_cli) => reflex::run_reflex_cli(project_store, reflex_cli),
1999        AutoCommand::Workflow(workflow_cli) => {
2000            workflow::run_workflow_cli(project_store, workflow_cli)?
2001        }
2002        AutoCommand::Container(container_cli) => {
2003            container::run_container_cli(project_store, container_cli)?
2004        }
2005    }
2006
2007    Ok(())
2008}
2009
2010fn run_qa_command(
2011    qa_cli: QaCli,
2012    project_store: &Store,
2013    project_root: &Path,
2014) -> Result<(), error::DecapodError> {
2015    match qa_cli.command {
2016        QaCommand::Verify(verify_cli) => {
2017            verify::run_verify_cli(project_store, project_root, verify_cli)?
2018        }
2019        QaCommand::Check {
2020            crate_description,
2021            commands,
2022            all,
2023        } => run_check(crate_description, commands, all)?,
2024        QaCommand::Gatling(ref gatling_cli) => plugins::gatling::run_gatling_cli(gatling_cli)?,
2025    }
2026
2027    Ok(())
2028}
2029
2030fn run_hook_install(
2031    commit_msg: bool,
2032    pre_commit: bool,
2033    uninstall: bool,
2034) -> Result<(), error::DecapodError> {
2035    let git_dir_output = std::process::Command::new("git")
2036        .args(["rev-parse", "--git-dir"])
2037        .output()
2038        .map_err(error::DecapodError::IoError)?;
2039
2040    if !git_dir_output.status.success() {
2041        return Err(error::DecapodError::ValidationError(
2042            "Not in a git repository".to_string(),
2043        ));
2044    }
2045
2046    let git_dir = String::from_utf8_lossy(&git_dir_output.stdout)
2047        .trim()
2048        .to_string();
2049    let hooks_dir = PathBuf::from(git_dir).join("hooks");
2050    fs::create_dir_all(&hooks_dir).map_err(error::DecapodError::IoError)?;
2051
2052    if uninstall {
2053        let commit_msg_path = hooks_dir.join("commit-msg");
2054        let pre_commit_path = hooks_dir.join("pre-commit");
2055        let mut removed_any = false;
2056
2057        if commit_msg_path.exists() {
2058            fs::remove_file(&commit_msg_path).map_err(error::DecapodError::IoError)?;
2059            println!("✓ Removed commit-msg hook");
2060            removed_any = true;
2061        }
2062        if pre_commit_path.exists() {
2063            fs::remove_file(&pre_commit_path).map_err(error::DecapodError::IoError)?;
2064            println!("✓ Removed pre-commit hook");
2065            removed_any = true;
2066        }
2067        if !removed_any {
2068            println!("No hooks found to remove");
2069        }
2070        return Ok(());
2071    }
2072
2073    if commit_msg {
2074        let hook_content = r#"#!/bin/sh
2075MSG_FILE="$1"
2076SUBJECT="$(head -n1 "$MSG_FILE")"
2077if printf '%s' "$SUBJECT" | grep -Eq '^(feat|fix|docs|style|refactor|test|chore|ci|build|perf|revert)(\([^)]+\))?: .+'; then
2078  exit 0
2079fi
2080echo "commit-msg hook: expected conventional commit subject"
2081echo "got: $SUBJECT"
2082exit 1
2083"#;
2084        let hook_path = hooks_dir.join("commit-msg");
2085        let mut file = fs::File::create(&hook_path).map_err(error::DecapodError::IoError)?;
2086        file.write_all(hook_content.as_bytes())
2087            .map_err(error::DecapodError::IoError)?;
2088        #[cfg(unix)]
2089        {
2090            use std::os::unix::fs::PermissionsExt;
2091            let mut perms = fs::metadata(&hook_path)
2092                .map_err(error::DecapodError::IoError)?
2093                .permissions();
2094            perms.set_mode(0o755);
2095            fs::set_permissions(&hook_path, perms).map_err(error::DecapodError::IoError)?;
2096        }
2097        println!("✓ Installed commit-msg hook for conventional commits");
2098    }
2099
2100    if pre_commit {
2101        let hook_content = r#"#!/bin/sh
2102set -e
2103cargo fmt --check
2104cargo clippy --all-targets --all-features -- -D warnings
2105"#;
2106        let hook_path = hooks_dir.join("pre-commit");
2107        let mut file = fs::File::create(&hook_path).map_err(error::DecapodError::IoError)?;
2108        file.write_all(hook_content.as_bytes())
2109            .map_err(error::DecapodError::IoError)?;
2110        #[cfg(unix)]
2111        {
2112            use std::os::unix::fs::PermissionsExt;
2113            let mut perms = fs::metadata(&hook_path)
2114                .map_err(error::DecapodError::IoError)?
2115                .permissions();
2116            perms.set_mode(0o755);
2117            fs::set_permissions(&hook_path, perms).map_err(error::DecapodError::IoError)?;
2118        }
2119        println!("✓ Installed pre-commit hook (fmt + clippy)");
2120    }
2121
2122    if !commit_msg && !pre_commit {
2123        println!("No hooks specified. Use --commit-msg and/or --pre-commit");
2124    }
2125
2126    Ok(())
2127}
2128
2129fn run_check(
2130    crate_description: bool,
2131    commands: bool,
2132    all: bool,
2133) -> Result<(), error::DecapodError> {
2134    if crate_description || all {
2135        let expected = "Decapod is a Rust-built governance runtime for AI agents: repo-native state, enforced workflow, proof gates, safe coordination.";
2136
2137        let output = std::process::Command::new("cargo")
2138            .args(["metadata", "--no-deps", "--format-version", "1"])
2139            .output()
2140            .map_err(|e| error::DecapodError::IoError(std::io::Error::other(e)))?;
2141
2142        if !output.status.success() {
2143            let stderr = String::from_utf8_lossy(&output.stderr);
2144            return Err(error::DecapodError::ValidationError(format!(
2145                "cargo metadata failed: {}",
2146                stderr.trim()
2147            )));
2148        }
2149
2150        let json_str = String::from_utf8_lossy(&output.stdout);
2151
2152        if json_str.contains(expected) {
2153            println!("✓ Crate description matches");
2154        } else {
2155            println!("✗ Crate description mismatch!");
2156            println!("  Expected: {}", expected);
2157            return Err(error::DecapodError::ValidationError(
2158                "Crate description check failed".into(),
2159            ));
2160        }
2161    }
2162
2163    if commands || all {
2164        run_command_help_smoke()?;
2165        println!("✓ Command help surfaces are valid");
2166    }
2167
2168    if all && !(crate_description || commands) {
2169        println!("Note: --all enables all checks");
2170    }
2171
2172    Ok(())
2173}
2174
2175fn run_command_help_smoke() -> Result<(), error::DecapodError> {
2176    fn walk(cmd: &clap::Command, prefix: Vec<String>, all_paths: &mut Vec<Vec<String>>) {
2177        if cmd.get_name() != "help" {
2178            all_paths.push(prefix.clone());
2179        }
2180        for sub in cmd.get_subcommands().filter(|sub| !sub.is_hide_set()) {
2181            let mut next = prefix.clone();
2182            next.push(sub.get_name().to_string());
2183            walk(sub, next, all_paths);
2184        }
2185    }
2186
2187    let exe = std::env::current_exe().map_err(error::DecapodError::IoError)?;
2188    let mut command_paths = Vec::new();
2189    walk(&Cli::command(), Vec::new(), &mut command_paths);
2190    command_paths.sort();
2191    command_paths.dedup();
2192
2193    for path in command_paths {
2194        let mut args = path.clone();
2195        args.push("--help".to_string());
2196        let output = std::process::Command::new(&exe)
2197            .args(&args)
2198            .output()
2199            .map_err(error::DecapodError::IoError)?;
2200        if !output.status.success() {
2201            return Err(error::DecapodError::ValidationError(format!(
2202                "help smoke failed for `decapod {}`: {}",
2203                path.join(" "),
2204                String::from_utf8_lossy(&output.stderr).trim()
2205            )));
2206        }
2207    }
2208    Ok(())
2209}
2210
2211/// Show version information
2212fn show_version_info() -> Result<(), error::DecapodError> {
2213    use colored::Colorize;
2214
2215    println!(
2216        "{} {}",
2217        "Decapod version:".bright_white(),
2218        migration::DECAPOD_VERSION.bright_green()
2219    );
2220    println!(
2221        "  {} {}",
2222        "Update:".bright_white(),
2223        "cargo install decapod".bright_cyan()
2224    );
2225
2226    Ok(())
2227}
2228
2229/// Run workspace command
2230fn run_workspace_command(
2231    cli: WorkspaceCli,
2232    project_root: &Path,
2233) -> Result<(), error::DecapodError> {
2234    use crate::core::workspace;
2235
2236    match cli.command {
2237        WorkspaceCommand::Ensure { branch } => {
2238            let agent_id =
2239                std::env::var("DECAPOD_AGENT_ID").unwrap_or_else(|_| "unknown".to_string());
2240            let config = branch.map(|b| workspace::WorkspaceConfig {
2241                branch: b,
2242                use_container: true,
2243                base_image: Some("rust:1.75-slim".to_string()),
2244            });
2245            let status = workspace::ensure_workspace(project_root, config, &agent_id)?;
2246
2247            println!(
2248                "{}",
2249                serde_json::json!({
2250                    "status": if status.can_work { "ok" } else { "pending" },
2251                    "branch": status.git.current_branch,
2252                    "is_protected": status.git.is_protected,
2253                    "can_work": status.can_work,
2254                    "in_container": status.container.in_container,
2255                    "docker_available": status.container.docker_available,
2256                    "worktree_path": status.git.worktree_path,
2257                    "required_actions": status.required_actions,
2258                })
2259            );
2260        }
2261        WorkspaceCommand::Status => {
2262            let status = workspace::get_workspace_status(project_root)?;
2263
2264            println!(
2265                "{}",
2266                serde_json::json!({
2267                    "can_work": status.can_work,
2268                    "git_branch": status.git.current_branch,
2269                    "git_is_protected": status.git.is_protected,
2270                    "git_has_local_mods": status.git.has_local_mods,
2271                    "in_container": status.container.in_container,
2272                    "container_image": status.container.image,
2273                    "docker_available": status.container.docker_available,
2274                    "blockers": status.blockers.len(),
2275                    "required_actions": status.required_actions,
2276                })
2277            );
2278        }
2279        WorkspaceCommand::Publish {
2280            title: _,
2281            description: _,
2282        } => {
2283            // TODO: Implement container-based publish
2284            println!(
2285                "{}",
2286                serde_json::json!({
2287                    "status": "error",
2288                    "message": "Publish not yet implemented in new workspace system"
2289                })
2290            );
2291        }
2292    }
2293
2294    Ok(())
2295}
2296
2297/// Run RPC command
2298fn run_rpc_command(cli: RpcCli, project_root: &Path) -> Result<(), error::DecapodError> {
2299    use crate::core::assurance::{AssuranceEngine, AssuranceEvaluateInput};
2300    use crate::core::interview;
2301    use crate::core::mentor;
2302    use crate::core::rpc::*;
2303    use crate::core::standards;
2304    use crate::core::workspace;
2305
2306    let request: RpcRequest = if cli.stdin {
2307        let mut buffer = String::new();
2308        std::io::stdin()
2309            .read_to_string(&mut buffer)
2310            .map_err(|e| error::DecapodError::IoError(e))?;
2311        serde_json::from_str(&buffer)
2312            .map_err(|e| error::DecapodError::ValidationError(format!("Invalid JSON: {}", e)))?
2313    } else {
2314        let op = cli.op.ok_or_else(|| {
2315            error::DecapodError::ValidationError("Operation required".to_string())
2316        })?;
2317        let params = cli
2318            .params
2319            .as_ref()
2320            .and_then(|p| serde_json::from_str(p).ok())
2321            .unwrap_or(serde_json::json!({}));
2322
2323        RpcRequest {
2324            op,
2325            params,
2326            id: default_request_id(),
2327            session: None,
2328        }
2329    };
2330
2331    enforce_worktree_requirement_for_rpc(&request.op, project_root)?;
2332
2333    if !rpc_op_bypasses_session(&request.op) {
2334        ensure_session_valid()?;
2335    }
2336    enforce_constitutional_awareness_for_rpc(&request.op, project_root)?;
2337
2338    let project_store = Store {
2339        kind: StoreKind::Repo,
2340        root: project_root.join(".decapod").join("data"),
2341    };
2342
2343    let mandates = docs::resolve_mandates(project_root, &request.op);
2344    let mandate_blockers = validate::evaluate_mandates(project_root, &project_store, &mandates);
2345
2346    // If any mandate is blocked, we fail the operation
2347    let blocked_mandate = mandates.iter().find(|m| {
2348        mandate_blockers
2349            .iter()
2350            .any(|b| b.message.contains(&m.fragment.title))
2351    });
2352
2353    if let Some(mandate) = blocked_mandate {
2354        let blocker = mandate_blockers
2355            .iter()
2356            .find(|b| b.message.contains(&mandate.fragment.title))
2357            .unwrap();
2358        let response = error_response(
2359            request.id.clone(),
2360            request.op.clone(),
2361            request.params.clone(),
2362            "mandate_violation".to_string(),
2363            blocker.message.clone(),
2364            Some(blocker.clone()),
2365            mandates,
2366        );
2367        println!("{}", serde_json::to_string_pretty(&response).unwrap());
2368        return Ok(());
2369    }
2370
2371    let response = match request.op.as_str() {
2372        "agent.init" => {
2373            // Session initialization with receipt
2374            let workspace_status = workspace::get_workspace_status(project_root)?;
2375            let mut allowed_ops = workspace::get_allowed_ops(&workspace_status);
2376
2377            // Add mandatory todo ops if no active tasks
2378            let agent_id = current_agent_id();
2379            if agent_id != "unknown" {
2380                if let Ok(mut tasks) = todo::list_tasks(
2381                    &project_store.root,
2382                    Some("open".to_string()),
2383                    None,
2384                    None,
2385                    None,
2386                    None,
2387                ) {
2388                    tasks.retain(|t| t.assigned_to == agent_id);
2389                    if tasks.is_empty() {
2390                        allowed_ops.insert(
2391                            0,
2392                            AllowedOp {
2393                                op: "todo.add".to_string(),
2394                                reason: "MANDATORY: Create a task for your work".to_string(),
2395                                required_params: vec!["title".to_string()],
2396                            },
2397                        );
2398                    } else if tasks.iter().any(|t| t.assigned_to.is_empty()) {
2399                        allowed_ops.insert(
2400                            0,
2401                            AllowedOp {
2402                                op: "todo.claim".to_string(),
2403                                reason: "MANDATORY: Claim your assigned task".to_string(),
2404                                required_params: vec!["id".to_string()],
2405                            },
2406                        );
2407                    }
2408                }
2409            }
2410
2411            let context_capsule = if workspace_status.can_work {
2412                Some(ContextCapsule {
2413                    fragments: vec![],
2414                    spec: Some("Agent initialized successfully".to_string()),
2415                    architecture: None,
2416                    security: None,
2417                    standards: Some({
2418                        let resolved = standards::resolve_standards(project_root)?;
2419                        let mut map = std::collections::HashMap::new();
2420                        map.insert(
2421                            "project_name".to_string(),
2422                            serde_json::json!(resolved.project_name),
2423                        );
2424                        map
2425                    }),
2426                })
2427            } else {
2428                None
2429            };
2430
2431            let _blocked_by = if !workspace_status.can_work {
2432                workspace_status.blockers.clone()
2433            } else {
2434                vec![]
2435            };
2436
2437            let mut response = success_response(
2438                request.id.clone(),
2439                request.op.clone(),
2440                request.params.clone(),
2441                None,
2442                vec![],
2443                context_capsule,
2444                allowed_ops,
2445                mandates.clone(),
2446            );
2447            response.result = Some(serde_json::json!({
2448                "environment_context": {
2449                    "repo_root": project_root.to_string_lossy(),
2450                    "workspace_path": project_root.to_string_lossy(),
2451                    "tool_summary": {
2452                        "docker_available": workspace_status.container.docker_available,
2453                        "in_container": workspace_status.container.in_container,
2454                    },
2455                    "done_means": "decapod validate passes"
2456                }
2457            }));
2458            mark_constitution_initialized(project_root)?;
2459            response
2460        }
2461        "workspace.status" => {
2462            let status = workspace::get_workspace_status(project_root)?;
2463            let blocked_by = status.blockers.clone();
2464            let allowed_ops = workspace::get_allowed_ops(&status);
2465
2466            let mut response = success_response(
2467                request.id.clone(),
2468                request.op.clone(),
2469                request.params.clone(),
2470                None,
2471                vec![],
2472                None,
2473                allowed_ops,
2474                mandates.clone(),
2475            );
2476            response.result = Some(serde_json::json!({
2477                "git_branch": status.git.current_branch,
2478                "git_is_protected": status.git.is_protected,
2479                "in_container": status.container.in_container,
2480                "can_work": status.can_work,
2481            }));
2482            response.blocked_by = blocked_by;
2483            response
2484        }
2485        "workspace.ensure" => {
2486            let agent_id =
2487                std::env::var("DECAPOD_AGENT_ID").unwrap_or_else(|_| "unknown".to_string());
2488            let branch = request
2489                .params
2490                .get("branch")
2491                .and_then(|v| v.as_str())
2492                .map(|s| s.to_string());
2493
2494            let config = branch.map(|b| workspace::WorkspaceConfig {
2495                branch: b,
2496                use_container: false,
2497                base_image: None,
2498            });
2499
2500            let status = workspace::ensure_workspace(project_root, config, &agent_id)?;
2501            let allowed_ops = workspace::get_allowed_ops(&status);
2502
2503            success_response(
2504                request.id.clone(),
2505                request.op.clone(),
2506                request.params.clone(),
2507                None,
2508                vec![format!(".git/refs/heads/{}", status.git.current_branch)],
2509                None,
2510                allowed_ops,
2511                mandates.clone(),
2512            )
2513        }
2514        "workspace.publish" => {
2515            // TODO: Implement container-based publish
2516            let _title = request
2517                .params
2518                .get("title")
2519                .and_then(|v| v.as_str())
2520                .map(|s| s.to_string());
2521            let _description = request
2522                .params
2523                .get("description")
2524                .and_then(|v| v.as_str())
2525                .map(|s| s.to_string());
2526
2527            success_response(
2528                request.id.clone(),
2529                request.op.clone(),
2530                request.params.clone(),
2531                None,
2532                vec![],
2533                None,
2534                vec![AllowedOp {
2535                    op: "validate".to_string(),
2536                    reason: "Publish complete - run validation".to_string(),
2537                    required_params: vec![],
2538                }],
2539                mandates.clone(),
2540            )
2541        }
2542        "context.resolve" => {
2543            let params = &request.params;
2544            let op = params.get("op").and_then(|v| v.as_str());
2545            let touched_paths = params.get("touched_paths").and_then(|v| v.as_array());
2546            let intent_tags = params.get("intent_tags").and_then(|v| v.as_array());
2547            let _limit = params.get("limit").and_then(|v| v.as_u64()).unwrap_or(5);
2548
2549            let mut fragments = Vec::new();
2550            let bindings = docs::get_bindings(project_root);
2551
2552            // Deterministic relevance mapping
2553            if let Some(o) = op {
2554                if let Some(doc_ref) = bindings.ops.get(o) {
2555                    let parts: Vec<&str> = doc_ref.split('#').collect();
2556                    let path = parts[0];
2557                    let anchor = parts.get(1).copied();
2558                    if let Some(f) = docs::get_fragment(project_root, path, anchor) {
2559                        fragments.push(f);
2560                    }
2561                }
2562            }
2563
2564            if let Some(paths) = touched_paths {
2565                for p in paths.iter().filter_map(|v| v.as_str()) {
2566                    for (prefix, doc_ref) in &bindings.paths {
2567                        if p.contains(prefix) {
2568                            let parts: Vec<&str> = doc_ref.split('#').collect();
2569                            let path = parts[0];
2570                            let anchor = parts.get(1).copied();
2571                            if let Some(f) = docs::get_fragment(project_root, path, anchor) {
2572                                fragments.push(f);
2573                            }
2574                        }
2575                    }
2576                }
2577            }
2578
2579            if let Some(tags) = intent_tags {
2580                for t in tags.iter().filter_map(|v| v.as_str()) {
2581                    if let Some(doc_ref) = bindings.tags.get(t) {
2582                        let parts: Vec<&str> = doc_ref.split('#').collect();
2583                        let path = parts[0];
2584                        let anchor = parts.get(1).copied();
2585                        if let Some(f) = docs::get_fragment(project_root, path, anchor) {
2586                            fragments.push(f);
2587                        }
2588                    }
2589                }
2590            }
2591
2592            fragments.sort_by(|a, b| a.r#ref.cmp(&b.r#ref));
2593            fragments.dedup_by(|a, b| a.r#ref == b.r#ref);
2594            fragments.truncate(5);
2595
2596            let result = serde_json::json!({
2597                "fragments": fragments
2598            });
2599            mark_constitution_context_resolved(project_root)?;
2600
2601            success_response(
2602                request.id.clone(),
2603                request.op.clone(),
2604                request.params.clone(),
2605                Some(result),
2606                vec![],
2607                Some(ContextCapsule {
2608                    fragments,
2609                    spec: None,
2610                    architecture: None,
2611                    security: None,
2612                    standards: None,
2613                }),
2614                vec![],
2615                mandates.clone(),
2616            )
2617        }
2618        "context.bindings" => {
2619            let bindings = docs::get_bindings(project_root);
2620            success_response(
2621                request.id.clone(),
2622                request.op.clone(),
2623                request.params.clone(),
2624                Some(serde_json::to_value(bindings).unwrap()),
2625                vec![],
2626                None,
2627                vec![],
2628                mandates.clone(),
2629            )
2630        }
2631        "schema.get" => {
2632            let params = &request.params;
2633            let entity = params.get("entity").and_then(|v| v.as_str());
2634            match entity {
2635                Some("todo") => success_response(
2636                    request.id.clone(),
2637                    request.op.clone(),
2638                    request.params.clone(),
2639                    Some(serde_json::json!({
2640                        "schema_version": "v1",
2641                        "json_schema": {
2642                            "type": "object",
2643                            "properties": {
2644                                "title": { "type": "string" },
2645                                "description": { "type": "string" },
2646                                "priority": { "type": "string", "enum": ["low", "medium", "high", "critical"] },
2647                                "tags": { "type": "string" }
2648                            },
2649                            "required": ["title"]
2650                        }
2651                    })),
2652                    vec![],
2653                    None,
2654                    vec![],
2655                    mandates.clone(),
2656                ),
2657                Some("knowledge") => success_response(
2658                    request.id.clone(),
2659                    request.op.clone(),
2660                    request.params.clone(),
2661                    Some(serde_json::json!({
2662                        "schema_version": "v1",
2663                        "json_schema": {
2664                            "type": "object",
2665                            "properties": {
2666                                "id": { "type": "string" },
2667                                "title": { "type": "string" },
2668                                "text": { "type": "string" },
2669                                "provenance": { "type": "string" }
2670                            },
2671                            "required": ["id", "title", "text", "provenance"]
2672                        }
2673                    })),
2674                    vec![],
2675                    None,
2676                    vec![],
2677                    mandates.clone(),
2678                ),
2679                Some("decision") => success_response(
2680                    request.id.clone(),
2681                    request.op.clone(),
2682                    request.params.clone(),
2683                    Some(serde_json::json!({
2684                        "schema_version": "v1",
2685                        "json_schema": {
2686                            "type": "object",
2687                            "properties": {
2688                                "title": { "type": "string" },
2689                                "rationale": { "type": "string" },
2690                                "options": { "type": "array", "items": { "type": "string" } },
2691                                "chosen": { "type": "string" }
2692                            },
2693                            "required": ["title", "rationale", "chosen"]
2694                        }
2695                    })),
2696                    vec![],
2697                    None,
2698                    vec![],
2699                    mandates.clone(),
2700                ),
2701                _ => error_response(
2702                    request.id.clone(),
2703                    request.op.clone(),
2704                    request.params.clone(),
2705                    "invalid_entity".to_string(),
2706                    format!("Invalid or missing entity: {:?}", entity),
2707                    None,
2708                    mandates.clone(),
2709                ),
2710            }
2711        }
2712        "store.upsert" => {
2713            let params = &request.params;
2714            let entity = params.get("entity").and_then(|v| v.as_str());
2715            let payload = params.get("payload");
2716            let _provenance = params.get("provenance");
2717
2718            match entity {
2719                Some("todo") => {
2720                    let title = payload
2721                        .and_then(|p| p.get("title"))
2722                        .and_then(|v| v.as_str())
2723                        .unwrap_or("")
2724                        .to_string();
2725                    let description = payload
2726                        .and_then(|p| p.get("description"))
2727                        .and_then(|v| v.as_str())
2728                        .unwrap_or("")
2729                        .to_string();
2730                    let priority = payload
2731                        .and_then(|p| p.get("priority"))
2732                        .and_then(|v| v.as_str())
2733                        .unwrap_or("medium")
2734                        .to_string();
2735                    let tags = payload
2736                        .and_then(|p| p.get("tags"))
2737                        .and_then(|v| v.as_str())
2738                        .unwrap_or("")
2739                        .to_string();
2740
2741                    let args = todo::TodoCommand::Add {
2742                        title,
2743                        description,
2744                        priority,
2745                        tags,
2746                        owner: "".to_string(),
2747                        due: None,
2748                        r#ref: "".to_string(),
2749                        dir: None,
2750                        depends_on: "".to_string(),
2751                        blocks: "".to_string(),
2752                        parent: None,
2753                    };
2754                    let res = todo::add_task(&project_store.root, &args)?;
2755                    success_response(
2756                        request.id.clone(),
2757                        request.op.clone(),
2758                        request.params.clone(),
2759                        Some(serde_json::json!({
2760                            "id": res.get("id"),
2761                            "stored": true
2762                        })),
2763                        vec![],
2764                        None,
2765                        vec![],
2766                        mandates.clone(),
2767                    )
2768                }
2769                Some("knowledge") => {
2770                    let id = payload
2771                        .and_then(|p| p.get("id"))
2772                        .and_then(|v| v.as_str())
2773                        .unwrap_or("")
2774                        .to_string();
2775                    let title = payload
2776                        .and_then(|p| p.get("title"))
2777                        .and_then(|v| v.as_str())
2778                        .unwrap_or("")
2779                        .to_string();
2780                    let text = payload
2781                        .and_then(|p| p.get("text"))
2782                        .and_then(|v| v.as_str())
2783                        .unwrap_or("")
2784                        .to_string();
2785                    let provenance = payload
2786                        .and_then(|p| p.get("provenance"))
2787                        .and_then(|v| v.as_str())
2788                        .unwrap_or("")
2789                        .to_string();
2790
2791                    db::initialize_knowledge_db(&project_store.root)?;
2792                    knowledge::add_knowledge(
2793                        &project_store,
2794                        &id,
2795                        &title,
2796                        &text,
2797                        &provenance,
2798                        None,
2799                    )?;
2800                    success_response(
2801                        request.id.clone(),
2802                        request.op.clone(),
2803                        request.params.clone(),
2804                        Some(serde_json::json!({
2805                            "id": id,
2806                            "stored": true
2807                        })),
2808                        vec![],
2809                        None,
2810                        vec![],
2811                        mandates.clone(),
2812                    )
2813                }
2814                Some("decision") => {
2815                    // Decisions land in federation for now as a common store
2816                    let title = payload
2817                        .and_then(|p| p.get("title"))
2818                        .and_then(|v| v.as_str())
2819                        .unwrap_or("")
2820                        .to_string();
2821                    let rationale = payload
2822                        .and_then(|p| p.get("rationale"))
2823                        .and_then(|v| v.as_str())
2824                        .unwrap_or("")
2825                        .to_string();
2826                    let chosen = payload
2827                        .and_then(|p| p.get("chosen"))
2828                        .and_then(|v| v.as_str())
2829                        .unwrap_or("")
2830                        .to_string();
2831
2832                    let content = format!("Decision: {}\nRationale: {}", chosen, rationale);
2833                    let node_id = federation::add_node(
2834                        &project_store,
2835                        &title,
2836                        "decision",
2837                        "notable",
2838                        "agent_inferred",
2839                        &content,
2840                        "rpc:store.upsert",
2841                        "",
2842                        "repo",
2843                        None,
2844                        "agent",
2845                    )?;
2846                    success_response(
2847                        request.id.clone(),
2848                        request.op.clone(),
2849                        request.params.clone(),
2850                        Some(serde_json::json!({
2851                            "id": node_id,
2852                            "stored": true
2853                        })),
2854                        vec![],
2855                        None,
2856                        vec![],
2857                        mandates.clone(),
2858                    )
2859                }
2860                _ => error_response(
2861                    request.id.clone(),
2862                    request.op.clone(),
2863                    request.params.clone(),
2864                    "invalid_entity".to_string(),
2865                    format!("Invalid or missing entity: {:?}", entity),
2866                    None,
2867                    mandates.clone(),
2868                ),
2869            }
2870        }
2871        "store.query" => {
2872            let params = &request.params;
2873            let entity = params.get("entity").and_then(|v| v.as_str());
2874            let query = params.get("query");
2875
2876            match entity {
2877                Some("todo") => {
2878                    let status = query
2879                        .and_then(|q| q.get("status"))
2880                        .and_then(|v| v.as_str())
2881                        .map(|s| s.to_string());
2882                    let tasks =
2883                        todo::list_tasks(&project_store.root, status, None, None, None, None)?;
2884                    success_response(
2885                        request.id.clone(),
2886                        request.op.clone(),
2887                        request.params.clone(),
2888                        Some(serde_json::json!({
2889                            "items": tasks,
2890                            "next_page": null
2891                        })),
2892                        vec![],
2893                        None,
2894                        vec![],
2895                        mandates.clone(),
2896                    )
2897                }
2898                Some("knowledge") => {
2899                    let text = query
2900                        .and_then(|q| q.get("text"))
2901                        .and_then(|v| v.as_str())
2902                        .unwrap_or("");
2903                    db::initialize_knowledge_db(&project_store.root)?;
2904                    let entries = knowledge::search_knowledge(&project_store, text)?;
2905                    success_response(
2906                        request.id.clone(),
2907                        request.op.clone(),
2908                        request.params.clone(),
2909                        Some(serde_json::json!({
2910                            "items": entries,
2911                            "next_page": null
2912                        })),
2913                        vec![],
2914                        None,
2915                        vec![],
2916                        mandates.clone(),
2917                    )
2918                }
2919                Some("decision") => {
2920                    let nodes = plugins::federation_ext::list_nodes(
2921                        &project_store.root,
2922                        Some("decision".to_string()),
2923                        None,
2924                        None,
2925                        None,
2926                    )?;
2927                    success_response(
2928                        request.id.clone(),
2929                        request.op.clone(),
2930                        request.params.clone(),
2931                        Some(serde_json::json!({
2932                            "items": nodes,
2933                            "next_page": null
2934                        })),
2935                        vec![],
2936                        None,
2937                        vec![],
2938                        mandates.clone(),
2939                    )
2940                }
2941                _ => error_response(
2942                    request.id.clone(),
2943                    request.op.clone(),
2944                    request.params.clone(),
2945                    "invalid_entity".to_string(),
2946                    format!("Invalid or missing entity: {:?}", entity),
2947                    None,
2948                    mandates.clone(),
2949                ),
2950            }
2951        }
2952        "validate.run" => {
2953            let project_store = Store {
2954                kind: StoreKind::Repo,
2955                root: project_root.join(".decapod").join("data"),
2956            };
2957
2958            // We need to capture the output of validate::run_validation
2959            // For now, we'll just run it and return a simple success result
2960            // as it currently prints to stdout and manages thread-local state.
2961            let res = validate::run_validation(&project_store, project_root, project_root);
2962
2963            match res {
2964                Ok(_) => success_response(
2965                    request.id.clone(),
2966                    request.op.clone(),
2967                    request.params.clone(),
2968                    Some(serde_json::json!({ "success": true })),
2969                    vec![],
2970                    None,
2971                    vec![],
2972                    mandates.clone(),
2973                ),
2974                Err(e) => error_response(
2975                    request.id.clone(),
2976                    request.op.clone(),
2977                    request.params.clone(),
2978                    "validation_failed".to_string(),
2979                    e.to_string(),
2980                    None,
2981                    mandates.clone(),
2982                ),
2983            }
2984        }
2985        "scaffold.next_question" => {
2986            let project_name = request
2987                .params
2988                .get("project_name")
2989                .and_then(|v| v.as_str())
2990                .unwrap_or("Untitled")
2991                .to_string();
2992
2993            let interview = interview::init_interview(project_name);
2994            let question = interview::next_question(&interview);
2995
2996            let mut response = success_response(
2997                request.id.clone(),
2998                request.op.clone(),
2999                request.params.clone(),
3000                None,
3001                vec![],
3002                None,
3003                vec![AllowedOp {
3004                    op: "scaffold.apply_answer".to_string(),
3005                    reason: "Provide answer to continue interview".to_string(),
3006                    required_params: vec!["question_id".to_string(), "value".to_string()],
3007                }],
3008                mandates.clone(),
3009            );
3010
3011            if let Some(q) = question {
3012                response.result = Some(serde_json::json!({
3013                    "interview_id": interview.id,
3014                    "question": q,
3015                }));
3016            } else {
3017                response.result = Some(serde_json::json!({
3018                    "interview_id": interview.id,
3019                    "complete": true,
3020                }));
3021            }
3022
3023            response
3024        }
3025        "scaffold.apply_answer" => {
3026            let question_id = request
3027                .params
3028                .get("question_id")
3029                .and_then(|v| v.as_str())
3030                .ok_or_else(|| {
3031                    error::DecapodError::ValidationError("question_id required".to_string())
3032                })?;
3033            let value = request
3034                .params
3035                .clone()
3036                .get("value")
3037                .cloned()
3038                .ok_or_else(|| {
3039                    error::DecapodError::ValidationError("value required".to_string())
3040                })?;
3041
3042            let mut interview = interview::init_interview("project".to_string());
3043            interview::apply_answer(&mut interview, question_id, value)?;
3044
3045            let next_q = interview::next_question(&interview);
3046
3047            let mut response = success_response(
3048                request.id.clone(),
3049                request.op.clone(),
3050                request.params.clone(),
3051                None,
3052                vec![],
3053                None,
3054                vec![AllowedOp {
3055                    op: if next_q.is_some() {
3056                        "scaffold.next_question".to_string()
3057                    } else {
3058                        "scaffold.generate_artifacts".to_string()
3059                    },
3060                    reason: if next_q.is_some() {
3061                        "Continue interview".to_string()
3062                    } else {
3063                        "Interview complete - generate artifacts".to_string()
3064                    },
3065                    required_params: vec![],
3066                }],
3067                mandates.clone(),
3068            );
3069
3070            response.result = Some(serde_json::json!({
3071                "answers_count": interview.answers.len(),
3072                "is_complete": interview.is_complete,
3073            }));
3074
3075            response
3076        }
3077        "scaffold.generate_artifacts" => {
3078            let interview = interview::init_interview("project".to_string());
3079            let output_dir = project_root.to_path_buf();
3080
3081            let artifacts = interview::generate_artifacts(&interview, &output_dir)?;
3082
3083            let touched_paths: Vec<String> = artifacts
3084                .iter()
3085                .map(|a| a.path.to_string_lossy().to_string())
3086                .collect();
3087
3088            success_response(
3089                request.id.clone(),
3090                request.op.clone(),
3091                request.params.clone(),
3092                None,
3093                touched_paths,
3094                None,
3095                vec![AllowedOp {
3096                    op: "validate".to_string(),
3097                    reason: "Artifacts generated - validate before claiming done".to_string(),
3098                    required_params: vec![],
3099                }],
3100                mandates.clone(),
3101            )
3102        }
3103        "standards.resolve" => {
3104            let resolved = standards::resolve_standards(project_root)?;
3105
3106            let mut standards_map = std::collections::HashMap::new();
3107            standards_map.insert(
3108                "project_name".to_string(),
3109                serde_json::json!(resolved.project_name),
3110            );
3111            for (k, v) in &resolved.standards {
3112                standards_map.insert(k.clone(), v.clone());
3113            }
3114
3115            let context_capsule = ContextCapsule {
3116                fragments: vec![],
3117                spec: None,
3118                architecture: None,
3119                security: None,
3120                standards: Some(standards_map),
3121            };
3122
3123            success_response(
3124                request.id.clone(),
3125                request.op.clone(),
3126                request.params.clone(),
3127                None,
3128                vec![],
3129                Some(context_capsule),
3130                vec![],
3131                mandates.clone(),
3132            )
3133        }
3134        "mentor.obligations" => {
3135            use crate::core::mentor::{MentorEngine, ObligationsContext};
3136
3137            let engine = MentorEngine::new(project_root);
3138            let ctx = ObligationsContext {
3139                op: request
3140                    .params
3141                    .get("op")
3142                    .and_then(|v| v.as_str())
3143                    .unwrap_or("unknown")
3144                    .to_string(),
3145                params: request
3146                    .params
3147                    .get("params")
3148                    .cloned()
3149                    .unwrap_or(serde_json::json!({})),
3150                touched_paths: request
3151                    .params
3152                    .get("touched_paths")
3153                    .and_then(|v| v.as_array())
3154                    .map(|arr| {
3155                        arr.iter()
3156                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
3157                            .collect()
3158                    })
3159                    .unwrap_or_default(),
3160                diff_summary: request
3161                    .params
3162                    .get("diff_summary")
3163                    .and_then(|v| v.as_str())
3164                    .map(|s| s.to_string()),
3165                project_profile_id: request
3166                    .params
3167                    .get("project_profile_id")
3168                    .and_then(|v| v.as_str())
3169                    .map(|s| s.to_string()),
3170                session_id: request
3171                    .params
3172                    .get("session_id")
3173                    .and_then(|v| v.as_str())
3174                    .map(|s| s.to_string()),
3175                high_risk: request
3176                    .params
3177                    .get("high_risk")
3178                    .and_then(|v| v.as_bool())
3179                    .unwrap_or(false),
3180            };
3181
3182            let obligations = engine.compute_obligations(&ctx)?;
3183
3184            let context_capsule = ContextCapsule {
3185                fragments: vec![],
3186                spec: None,
3187                architecture: None,
3188                security: None,
3189                standards: None,
3190            };
3191
3192            let mut response = success_response(
3193                request.id.clone(),
3194                request.op.clone(),
3195                request.params.clone(),
3196                None,
3197                vec![],
3198                Some(context_capsule),
3199                vec![AllowedOp {
3200                    op: "mentor.obligations".to_string(),
3201                    reason: "Obligations computed - review must list before proceeding".to_string(),
3202                    required_params: vec![],
3203                }],
3204                mandates.clone(),
3205            );
3206
3207            response.result = Some(serde_json::json!({
3208                "obligations": obligations,
3209            }));
3210
3211            // Add blockers for contradictions
3212            if !obligations.contradictions.is_empty() {
3213                response.blocked_by =
3214                    mentor::contradictions_to_blockers(&obligations.contradictions);
3215            }
3216
3217            response
3218        }
3219        "assurance.evaluate" => {
3220            let input = AssuranceEvaluateInput {
3221                op: request
3222                    .params
3223                    .get("op")
3224                    .and_then(|v| v.as_str())
3225                    .unwrap_or("unknown")
3226                    .to_string(),
3227                params: request
3228                    .params
3229                    .get("params")
3230                    .cloned()
3231                    .unwrap_or(serde_json::json!({})),
3232                touched_paths: request
3233                    .params
3234                    .get("touched_paths")
3235                    .and_then(|v| v.as_array())
3236                    .map(|arr| {
3237                        arr.iter()
3238                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
3239                            .collect()
3240                    })
3241                    .unwrap_or_default(),
3242                diff_summary: request
3243                    .params
3244                    .get("diff_summary")
3245                    .and_then(|v| v.as_str())
3246                    .map(|s| s.to_string()),
3247                session_id: request
3248                    .params
3249                    .get("session_id")
3250                    .and_then(|v| v.as_str())
3251                    .map(|s| s.to_string()),
3252                phase: request
3253                    .params
3254                    .get("phase")
3255                    .cloned()
3256                    .and_then(|v| serde_json::from_value(v).ok()),
3257                time_budget_s: request
3258                    .params
3259                    .clone()
3260                    .get("time_budget_s")
3261                    .and_then(|v| v.as_u64()),
3262            };
3263
3264            let engine = AssuranceEngine::new(project_root);
3265            let evaluated = engine.evaluate(&input)?;
3266            let mut response = success_response(
3267                request.id.clone(),
3268                request.op.clone(),
3269                request.params.clone(),
3270                None,
3271                input.touched_paths.clone(),
3272                None,
3273                if let Some(interlock) = &evaluated.interlock {
3274                    interlock
3275                        .unblock_ops
3276                        .iter()
3277                        .map(|op| AllowedOp {
3278                            op: op.clone(),
3279                            reason: format!("Unblock path for {}", interlock.code),
3280                            required_params: vec![],
3281                        })
3282                        .collect()
3283                } else {
3284                    vec![AllowedOp {
3285                        op: "assurance.evaluate".to_string(),
3286                        reason: "Re-evaluate after meaningful context changes".to_string(),
3287                        required_params: vec![],
3288                    }]
3289                },
3290                mandates.clone(),
3291            );
3292            response.interlock = evaluated.interlock.clone();
3293            response.advisory = Some(evaluated.advisory.clone());
3294            response.attestation = Some(evaluated.attestation.clone());
3295            response.result = Some(serde_json::json!({
3296                "assurance_evaluated": true,
3297                "interlock_code": evaluated.interlock.as_ref().map(|i| i.code.clone()),
3298            }));
3299            if let Some(interlock) = evaluated.interlock {
3300                response.blocked_by = vec![Blocker {
3301                    kind: match interlock.code.as_str() {
3302                        "workspace_required" => BlockerKind::WorkspaceRequired,
3303                        "verification_required" => BlockerKind::MissingProof,
3304                        "store_boundary_violation" => BlockerKind::Unauthorized,
3305                        "decision_required" => BlockerKind::MissingAnswer,
3306                        _ => BlockerKind::ValidationFailed,
3307                    },
3308                    message: interlock.code,
3309                    resolve_hint: interlock.message,
3310                }];
3311            }
3312            response
3313        }
3314        _ => error_response(
3315            request.id.clone(),
3316            request.op.clone(),
3317            request.params.clone(),
3318            "unknown_op".to_string(),
3319            format!("Unknown operation: {}", request.op),
3320            None,
3321            mandates.clone(),
3322        ),
3323    };
3324
3325    // Trace the RPC call
3326    let trace_event = trace::TraceEvent {
3327        trace_id: request.id.clone(),
3328        ts: chrono::Utc::now().to_rfc3339(),
3329        op: request.op.clone(),
3330        request: serde_json::to_value(&request).unwrap_or(serde_json::Value::Null),
3331        response: serde_json::to_value(&response).unwrap_or(serde_json::Value::Null),
3332    };
3333    let _ = trace::append_trace(project_root, trace_event);
3334
3335    println!("{}", serde_json::to_string_pretty(&response).unwrap());
3336    Ok(())
3337}
3338
3339/// Run capabilities command
3340fn run_capabilities_command(cli: CapabilitiesCli) -> Result<(), error::DecapodError> {
3341    use crate::core::rpc::generate_capabilities;
3342
3343    let report = generate_capabilities();
3344
3345    match cli.format.as_str() {
3346        "json" => {
3347            println!("{}", serde_json::to_string_pretty(&report).unwrap());
3348        }
3349        _ => {
3350            println!("Decapod {}", report.version);
3351            println!("==================\n");
3352
3353            println!("Capabilities:");
3354            for cap in &report.capabilities {
3355                println!("  {} [{}] - {}", cap.name, cap.stability, cap.description);
3356            }
3357
3358            println!("\nSubsystems:");
3359            for sub in &report.subsystems {
3360                println!("  {} [{}]", sub.name, sub.status);
3361                println!("    Ops: {}", sub.ops.join(", "));
3362            }
3363
3364            println!("\nWorkspace:");
3365            println!(
3366                "  Enforcement: {}",
3367                if report.workspace.enforcement_available {
3368                    "available"
3369                } else {
3370                    "unavailable"
3371                }
3372            );
3373            println!(
3374                "  Docker: {}",
3375                if report.workspace.docker_available {
3376                    "available"
3377                } else {
3378                    "unavailable"
3379                }
3380            );
3381            println!(
3382                "  Protected: {}",
3383                report.workspace.protected_patterns.join(", ")
3384            );
3385
3386            println!("\nInterview:");
3387            println!(
3388                "  Available: {}",
3389                if report.interview.available {
3390                    "yes"
3391                } else {
3392                    "no"
3393                }
3394            );
3395            println!(
3396                "  Artifacts: {}",
3397                report.interview.artifact_types.join(", ")
3398            );
3399            println!("\nInterlocks:");
3400            println!("  Codes: {}", report.interlock_codes.join(", "));
3401        }
3402    }
3403
3404    Ok(())
3405}
3406
3407fn run_trace_command(cli: TraceCli, project_root: &Path) -> Result<(), error::DecapodError> {
3408    match cli.command {
3409        TraceCommand::Export { last } => {
3410            let traces = trace::get_last_traces(project_root, last)?;
3411            for t in traces {
3412                println!("{}", t);
3413            }
3414        }
3415    }
3416    Ok(())
3417}