1pub 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 #[clap(long, default_value = "repo")]
117 store: String,
118 #[clap(long, default_value = "text")]
120 format: String,
121}
122
123#[derive(clap::Args, Debug)]
124struct CapabilitiesCli {
125 #[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 {
140 #[clap(long)]
142 branch: Option<String>,
143 },
144 Status,
146 Publish {
148 #[clap(long)]
150 title: Option<String>,
151 #[clap(long)]
153 description: Option<String>,
154 },
155}
156
157#[derive(clap::Args, Debug)]
158struct RpcCli {
159 #[clap(long)]
161 op: Option<String>,
162 #[clap(long)]
164 params: Option<String>,
165 #[clap(long)]
167 stdin: bool,
168}
169
170#[derive(clap::Args, Debug)]
173struct InitGroupCli {
174 #[clap(subcommand)]
175 command: Option<InitCommand>,
176 #[clap(short, long)]
178 dir: Option<PathBuf>,
179 #[clap(long)]
181 force: bool,
182 #[clap(long)]
184 dry_run: bool,
185 #[clap(long)]
187 all: bool,
188 #[clap(long)]
190 claude: bool,
191 #[clap(long)]
193 gemini: bool,
194 #[clap(long)]
196 agents: bool,
197}
198
199#[derive(Subcommand, Debug)]
200enum InitCommand {
201 Clean {
203 #[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,
219 Status,
221 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 Hook {
235 #[clap(long)]
237 commit_msg: bool,
238 #[clap(long)]
240 pre_commit: bool,
241 #[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 Policy(policy::PolicyCli),
257
258 Health(health::HealthCli),
260
261 Proof(ProofCommandCli),
263
264 Watcher(WatcherCli),
266
267 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 Archive(ArchiveCli),
281
282 Knowledge(KnowledgeCli),
284
285 Context(ContextCli),
287
288 Schema(SchemaCli),
290
291 Repo(RepoCli),
293
294 Broker(BrokerCli),
296
297 Teammate(teammate::TeammateCli),
299
300 Federation(federation::FederationCli),
302
303 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 Cron(cron::CronCli),
317
318 Reflex(reflex::ReflexCli),
320
321 Workflow(workflow::WorkflowCli),
323
324 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(verify::VerifyCli),
338
339 Check {
341 #[clap(long)]
343 crate_description: bool,
344 #[clap(long)]
346 commands: bool,
347 #[clap(long)]
349 all: bool,
350 },
351
352 Gatling(plugins::gatling::GatlingCli),
354}
355
356#[derive(clap::Args, Debug)]
359struct TraceCli {
360 #[clap(subcommand)]
361 command: TraceCommand,
362}
363
364#[derive(Subcommand, Debug)]
365enum TraceCommand {
366 Export {
368 #[clap(long, default_value = "10")]
370 last: usize,
371 },
372}
373
374#[derive(Subcommand, Debug)]
375enum Command {
376 #[clap(name = "init", visible_alias = "i")]
378 Init(InitGroupCli),
379
380 #[clap(name = "setup")]
382 Setup(SetupCli),
383
384 #[clap(name = "session", visible_alias = "s")]
386 Session(SessionCli),
387
388 #[clap(name = "docs", visible_alias = "d")]
390 Docs(docs_cli::DocsCli),
391
392 #[clap(name = "todo", visible_alias = "t")]
394 Todo(todo::TodoCli),
395
396 #[clap(name = "validate", visible_alias = "v")]
398 Validate(ValidateCli),
399
400 #[clap(name = "version")]
402 Version,
403
404 #[clap(name = "govern", visible_alias = "g")]
406 Govern(GovernCli),
407
408 #[clap(name = "data")]
410 Data(DataCli),
411
412 #[clap(name = "auto", visible_alias = "a")]
414 Auto(AutoCli),
415
416 #[clap(name = "qa", visible_alias = "q")]
418 Qa(QaCli),
419
420 #[clap(name = "decide")]
422 Decide(decide::DecideCli),
423
424 #[clap(name = "workspace", visible_alias = "w")]
426 Workspace(WorkspaceCli),
427
428 #[clap(name = "rpc")]
430 Rpc(RpcCli),
431
432 #[clap(name = "capabilities")]
434 Capabilities(CapabilitiesCli),
435
436 #[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 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 {
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 {
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 Map,
491 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,
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,
517 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 {
531 #[clap(long)]
532 source: String,
533 #[clap(long)]
534 text: String,
535 #[clap(long)]
536 links: Option<String>,
537 },
538 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,
552 Test {
554 #[clap(long)]
555 name: String,
556 },
557 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 {
571 #[clap(long)]
572 profile: String,
573 #[clap(long)]
574 files: Vec<PathBuf>,
575 },
576 Pack {
578 #[clap(long)]
579 path: PathBuf,
580 #[clap(long)]
581 summary: String,
582 },
583 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 #[clap(long, default_value = "json")]
598 format: String,
599 #[clap(long)]
601 subsystem: Option<String>,
602 #[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(¤t_dir);
649 let store_root: PathBuf;
650
651 match cli.command {
652 Command::Version => {
653 println!("v{}", migration::DECAPOD_VERSION);
655 return Ok(());
656 }
657 Command::Init(init_group) => {
658 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 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 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 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 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 let template_content = core::assets::get_template(file).unwrap_or_default();
704
705 let mut hasher = Sha256::new();
707 hasher.update(template_content.as_bytes());
708 let template_hash = format!("{:x}", hasher.finalize());
709
710 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 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 if !init_group.dry_run {
728 scaffold::blend_legacy_entrypoints(&target_dir)?;
729 }
730
731 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 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 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 migration::check_and_migrate_with_backup(&decapod_root_path, |data_root| {
827 todo::initialize_todo_db(data_root)?;
829
830 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 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 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 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 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(¤t_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(¤t_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 } else {
1480 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 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, ¤t_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
2211fn 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
2229fn 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 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
2297fn 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 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 let workspace_status = workspace::get_workspace_status(project_root)?;
2375 let mut allowed_ops = workspace::get_allowed_ops(&workspace_status);
2376
2377 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 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 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 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 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 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 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
3339fn 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}