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