1use std::fs;
2use std::io::IsTerminal;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result};
6use clap::Args;
7
8use crate::config::{
9 self, AgentsConfig, Config, DevAgentConfig, MissionsConfig, ProjectConfig, ReviewConfig,
10 ReviewerAgentConfig, ToolsConfig, WorkerAgentConfig,
11};
12use crate::error::ExitError;
13use crate::subprocess::{Tool, run_command};
14use crate::template::render_agents_md;
15
16const PROJECT_TYPES: &[&str] = &["api", "cli", "frontend", "library", "monorepo", "tui"];
17const AVAILABLE_TOOLS: &[&str] = &["bones", "maw", "crit", "botbus", "vessel"];
18const REVIEWER_ROLES: &[&str] = &["security"];
19const LANGUAGES: &[&str] = &["rust", "python", "node", "go", "typescript", "java"];
20const CONFIG_VERSION: &str = "1.0.16";
21
22fn infer_project_name() -> Option<String> {
26 let cwd = std::env::current_dir().ok()?;
27 let dir = if cwd.ends_with("ws/default") {
29 cwd.parent()?.parent()?
30 } else {
31 &cwd
32 };
33 let name = dir.file_name()?.to_str()?;
34 let sanitized: String = name
36 .chars()
37 .map(|c| {
38 if c.is_ascii_alphanumeric() {
39 c.to_ascii_lowercase()
40 } else {
41 '-'
42 }
43 })
44 .collect();
45 let trimmed = sanitized.trim_matches('-').to_string();
46 if trimmed.is_empty() || validate_name(&trimmed, "project name").is_err() {
47 return None;
48 }
49 Some(trimmed)
50}
51
52fn validate_name(name: &str, label: &str) -> Result<()> {
53 if name.is_empty() || name.len() > 64 {
54 anyhow::bail!("{label} must be 1-64 characters, got {}", name.len());
55 }
56 if !name
57 .bytes()
58 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
59 {
60 anyhow::bail!("{label} must match [a-z0-9-], got {name:?}");
61 }
62 if name.starts_with('-') || name.ends_with('-') {
63 anyhow::bail!("{label} must not start or end with '-', got {name:?}");
64 }
65 Ok(())
66}
67
68#[derive(Debug, Args)]
69pub struct InitArgs {
70 #[arg(long)]
72 pub name: Option<String>,
73 #[arg(long, value_delimiter = ',')]
75 pub r#type: Vec<String>,
76 #[arg(long, value_delimiter = ',')]
78 pub tools: Vec<String>,
79 #[arg(long, value_delimiter = ',')]
81 pub reviewers: Vec<String>,
82 #[arg(long, value_delimiter = ',')]
84 pub language: Vec<String>,
85 #[arg(long)]
87 pub install_command: Option<String>,
88 #[arg(long)]
90 pub check_command: Option<String>,
91 #[arg(long)]
93 pub no_interactive: bool,
94 #[arg(long, alias = "no-init-beads")]
96 pub no_init_bones: bool,
97 #[arg(long)]
99 pub no_seed_work: bool,
100 #[arg(long)]
102 pub force: bool,
103 #[arg(long)]
105 pub no_commit: bool,
106 #[arg(long)]
108 pub project_root: Option<PathBuf>,
109}
110
111struct InitChoices {
113 name: String,
114 types: Vec<String>,
115 tools: Vec<String>,
116 reviewers: Vec<String>,
117 languages: Vec<String>,
118 install_command: Option<String>,
119 check_command: Option<String>,
120 init_bones: bool,
121 seed_work: bool,
122}
123
124impl InitArgs {
125 pub fn execute(&self) -> Result<()> {
126 let project_dir = self
127 .project_root
128 .clone()
129 .unwrap_or_else(|| std::env::current_dir().expect("Failed to get current dir"));
130
131 let project_dir = project_dir.canonicalize().unwrap_or(project_dir);
133
134 let ws_default = project_dir.join("ws/default");
136 if config::find_config(&ws_default).is_some()
137 || (ws_default.exists() && !project_dir.join(".agents/edict").exists())
138 {
139 return self.handle_bare_repo(&project_dir);
140 }
141
142 let agents_dir = project_dir.join(".agents/edict");
143 let agents_md_path = project_dir.join("AGENTS.md");
144 let is_reinit = agents_dir.exists();
145
146 let detected = if is_reinit && agents_md_path.exists() {
148 let content = fs::read_to_string(&agents_md_path)?;
149 detect_from_agents_md(&content)
150 } else {
151 DetectedConfig::default()
152 };
153
154 let interactive = !self.no_interactive && std::io::stdin().is_terminal();
155 let choices = self.gather_choices(interactive, &detected)?;
156
157 fs::create_dir_all(&agents_dir)?;
159 println!("Created .agents/edict/");
160
161 let config = build_config(&choices);
164
165 let config_path = project_dir.join(config::CONFIG_TOML);
167 if !config_path.exists() || self.force {
168 let toml_str = config.to_toml()?;
169 fs::write(&config_path, &toml_str)?;
170 println!("Generated {}", config::CONFIG_TOML);
171 }
172
173 sync_workflow_docs(&agents_dir)?;
175 println!("Copied workflow docs");
176
177 sync_prompts(&agents_dir)?;
179 println!("Copied prompt templates");
180
181 sync_design_docs(&agents_dir)?;
183 println!("Copied design docs");
184
185 crate::commands::hooks::HooksCommand::Install {
187 project_root: Some(project_dir.clone()),
188 }
189 .execute()
190 .unwrap_or_else(|e| eprintln!("Warning: failed to install global hooks: {e}"));
191
192 if agents_md_path.exists() && !self.force {
194 tracing::warn!(
195 "AGENTS.md already exists. Use --force to overwrite, or run `edict sync` to update."
196 );
197 } else {
198 let content = render_agents_md(&config)?;
199 fs::write(&agents_md_path, content)?;
200 println!("Generated AGENTS.md");
201 }
202
203 if choices.init_bones && choices.tools.contains(&"bones".to_string()) {
205 match run_command("bn", &["init"], Some(&project_dir)) {
206 Ok(_) => println!("Initialized bones"),
207 Err(_) => tracing::warn!("bn init failed (is bones installed?)"),
208 }
209 }
210
211 if choices.tools.contains(&"maw".to_string()) {
213 match run_command("maw", &["init"], Some(&project_dir)) {
214 Ok(_) => println!("Initialized maw"),
215 Err(_) => tracing::warn!("maw init failed (is maw installed?)"),
216 }
217 }
218
219 if choices.tools.contains(&"crit".to_string()) {
221 match run_command("crit", &["init"], Some(&project_dir)) {
222 Ok(_) => println!("Initialized crit"),
223 Err(_) => tracing::warn!("crit init failed (is crit installed?)"),
224 }
225
226 let critignore_path = project_dir.join(".critignore");
228 if !critignore_path.exists() {
229 fs::write(
230 &critignore_path,
231 "# Ignore edict-managed files (prompts, scripts, hooks, journals)\n\
232 .agents/edict/\n\
233 \n\
234 # Ignore tool config and data files\n\
235 .crit/\n\
236 .maw.toml\n\
237 .edict.toml\n\
238 .botbox.json\n\
239 .claude/\n\
240 opencode.json\n",
241 )?;
242 println!("Created .critignore");
243 }
244 }
245
246 if choices.tools.contains(&"botbus".to_string()) && !is_reinit {
248 let abs_path = project_dir
249 .canonicalize()
250 .unwrap_or_else(|_| project_dir.clone());
251 let tools_list = choices.tools.join(", ");
252 let agent = format!("{}-dev", choices.name);
253 let msg = format!(
254 "project: {} repo: {} lead: {} tools: {}",
255 choices.name,
256 abs_path.display(),
257 agent,
258 tools_list
259 );
260 match Tool::new("bus")
261 .args(&[
262 "send",
263 "--agent",
264 &agent,
265 "projects",
266 &msg,
267 "-L",
268 "project-registry",
269 ])
270 .run()
271 {
272 Ok(output) if output.success() => {
273 println!("Registered project on #projects channel")
274 }
275 _ => tracing::warn!("failed to register on #projects (is bus installed?)"),
276 }
277 }
278
279 if choices.seed_work && choices.tools.contains(&"bones".to_string()) {
281 let count = seed_initial_bones(&project_dir, &choices.name, &choices.types);
282 if count > 0 {
283 let suffix = if count > 1 { "s" } else { "" };
284 println!("Created {count} seed bone{suffix}");
285 }
286 }
287
288 if choices.tools.contains(&"botbus".to_string()) {
290 register_spawn_hooks(&project_dir, &choices.name, &choices.reviewers, &config);
291 }
292
293 if !choices.languages.is_empty() {
295 let gitignore_path = project_dir.join(".gitignore");
296 if !gitignore_path.exists() {
297 match fetch_gitignore(&choices.languages) {
298 Ok(content) => {
299 fs::write(&gitignore_path, content)?;
300 println!("Generated .gitignore for: {}", choices.languages.join(", "));
301 }
302 Err(e) => tracing::warn!("failed to generate .gitignore: {e}"),
303 }
304 } else {
305 println!(".gitignore already exists, skipping generation");
306 }
307 }
308
309 if !is_reinit && !self.no_commit {
311 auto_commit(&project_dir, &config)?;
312 }
313
314 println!("Done.");
315 Ok(())
316 }
317
318 fn handle_bare_repo(&self, project_dir: &Path) -> Result<()> {
319 let project_dir = project_dir
320 .canonicalize()
321 .context("canonicalizing project root")?;
322
323 let ws_default = project_dir.join("ws/default");
326 let agents_md_path = ws_default.join("AGENTS.md");
327 let detected = if agents_md_path.exists() {
328 let content = fs::read_to_string(&agents_md_path)?;
329 detect_from_agents_md(&content)
330 } else {
331 DetectedConfig::default()
332 };
333
334 let interactive = !self.no_interactive && std::io::stdin().is_terminal();
335 let choices = self.gather_choices(interactive, &detected)?;
336
337 let mut args: Vec<String> = vec!["exec", "default", "--", "edict", "init"]
338 .into_iter()
339 .map(Into::into)
340 .collect();
341
342 args.push("--name".into());
345 args.push(choices.name.clone());
346 args.push("--type".into());
347 args.push(choices.types.join(","));
348 args.push("--tools".into());
349 args.push(choices.tools.join(","));
350 if !choices.reviewers.is_empty() {
351 args.push("--reviewers".into());
352 args.push(choices.reviewers.join(","));
353 }
354 if !choices.languages.is_empty() {
355 args.push("--language".into());
356 args.push(choices.languages.join(","));
357 }
358 if let Some(ref cmd) = choices.install_command {
359 args.push("--install-command".into());
360 args.push(cmd.clone());
361 }
362 if let Some(ref cmd) = choices.check_command {
363 args.push("--check-command".into());
364 args.push(cmd.clone());
365 }
366 if self.force {
367 args.push("--force".into());
368 }
369 args.push("--no-interactive".into());
371 if self.no_commit {
372 args.push("--no-commit".into());
373 }
374 if !choices.init_bones {
375 args.push("--no-init-bones".into());
376 }
377 if !choices.seed_work {
378 args.push("--no-seed-work".into());
379 }
380
381 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
382 run_command("maw", &arg_refs, Some(&project_dir))?;
383
384 let stub_content = "**Do not edit the root AGENTS.md for memories or instructions. Use the AGENTS.md in ws/default/.**\n@ws/default/AGENTS.md\n";
386 let stub_agents = project_dir.join("AGENTS.md");
387 if !stub_agents.exists() {
388 fs::write(&stub_agents, stub_content)?;
389 println!("Created bare-root AGENTS.md stub");
390 }
391
392 let root_claude_dir = project_dir.join(".claude");
394 let ws_claude_dir = project_dir.join("ws/default/.claude");
395 if ws_claude_dir.exists() {
396 let needs_symlink = match fs::read_link(&root_claude_dir) {
397 Ok(target) => target != Path::new("ws/default/.claude"),
398 Err(_) => true,
399 };
400 if needs_symlink {
401 let tmp_link = project_dir.join(".claude.tmp");
402 let _ = fs::remove_file(&tmp_link);
403 #[cfg(unix)]
404 std::os::unix::fs::symlink("ws/default/.claude", &tmp_link)?;
405 #[cfg(windows)]
406 std::os::windows::fs::symlink_dir("ws/default/.claude", &tmp_link)?;
407 if let Err(e) = fs::rename(&tmp_link, &root_claude_dir) {
408 let _ = fs::remove_file(&tmp_link);
409 return Err(e).context("creating .claude symlink");
410 }
411 println!("Symlinked .claude → ws/default/.claude");
412 }
413 }
414
415 let root_pi_dir = project_dir.join(".pi");
417 let ws_pi_dir = project_dir.join("ws/default/.pi");
418 if ws_pi_dir.exists() {
419 let needs_symlink = match fs::read_link(&root_pi_dir) {
420 Ok(target) => target != Path::new("ws/default/.pi"),
421 Err(_) => true,
422 };
423 if needs_symlink {
424 let tmp_link = project_dir.join(".pi.tmp");
425 let _ = fs::remove_file(&tmp_link);
426 #[cfg(unix)]
427 std::os::unix::fs::symlink("ws/default/.pi", &tmp_link)?;
428 #[cfg(windows)]
429 std::os::windows::fs::symlink_dir("ws/default/.pi", &tmp_link)?;
430 if let Err(e) = fs::rename(&tmp_link, &root_pi_dir) {
431 let _ = fs::remove_file(&tmp_link);
432 return Err(e).context("creating .pi symlink");
433 }
434 println!("Symlinked .pi → ws/default/.pi");
435 }
436 }
437
438 Ok(())
439 }
440
441 fn gather_choices(&self, interactive: bool, detected: &DetectedConfig) -> Result<InitChoices> {
442 let name = if let Some(ref n) = self.name {
444 validate_name(n, "project name")?;
445 n.clone()
446 } else if interactive {
447 let n = prompt_input("Project name", detected.name.as_deref())?;
448 validate_name(&n, "project name")?;
449 n
450 } else {
451 let n = detected
452 .name
453 .clone()
454 .or_else(|| infer_project_name())
455 .ok_or_else(|| {
456 ExitError::Other("--name is required in non-interactive mode".into())
457 })?;
458 validate_name(&n, "project name")?;
459 n
460 };
461
462 let types = if !self.r#type.is_empty() {
464 validate_values(&self.r#type, PROJECT_TYPES, "project type")?;
465 self.r#type.clone()
466 } else if interactive {
467 let defaults: Vec<bool> = PROJECT_TYPES
468 .iter()
469 .map(|t| detected.types.contains(&t.to_string()))
470 .collect();
471 prompt_multi_select(
472 "Project type (select one or more)",
473 PROJECT_TYPES,
474 &defaults,
475 )?
476 } else {
477 if detected.types.is_empty() {
478 vec!["cli".to_string()]
479 } else {
480 detected.types.clone()
481 }
482 };
483
484 let tools = if !self.tools.is_empty() {
486 validate_values(&self.tools, AVAILABLE_TOOLS, "tool")?;
487 self.tools.clone()
488 } else if interactive {
489 let defaults: Vec<bool> = AVAILABLE_TOOLS
490 .iter()
491 .map(|t| {
492 if detected.tools.is_empty() {
493 true } else {
495 detected.tools.contains(&t.to_string())
496 }
497 })
498 .collect();
499 prompt_multi_select("Tools to enable", AVAILABLE_TOOLS, &defaults)?
500 } else if detected.tools.is_empty() {
501 AVAILABLE_TOOLS.iter().map(|s| s.to_string()).collect()
502 } else {
503 detected.tools.clone()
504 };
505
506 let reviewers = if !self.reviewers.is_empty() {
508 validate_values(&self.reviewers, REVIEWER_ROLES, "reviewer role")?;
509 for r in &self.reviewers {
510 validate_name(r, "reviewer role")?;
511 }
512 self.reviewers.clone()
513 } else if interactive {
514 let defaults: Vec<bool> = REVIEWER_ROLES
515 .iter()
516 .map(|r| detected.reviewers.contains(&r.to_string()))
517 .collect();
518 prompt_multi_select("Reviewer roles", REVIEWER_ROLES, &defaults)?
519 } else {
520 detected.reviewers.clone()
521 };
522
523 let languages = if !self.language.is_empty() {
525 validate_values(&self.language, LANGUAGES, "language")?;
526 self.language.clone()
527 } else if interactive {
528 prompt_multi_select(
529 "Languages/frameworks (for .gitignore generation)",
530 LANGUAGES,
531 &vec![false; LANGUAGES.len()],
532 )?
533 } else {
534 Vec::new()
535 };
536
537 let init_bones = if self.no_init_bones {
539 false
540 } else if interactive {
541 prompt_confirm("Initialize bones?", true)?
542 } else {
543 false
544 };
545
546 let seed_work = if self.no_seed_work {
548 false
549 } else if interactive {
550 prompt_confirm("Seed initial work bones?", false)?
551 } else {
552 false
553 };
554
555 let install_command = if let Some(ref cmd) = self.install_command {
557 Some(cmd.clone())
558 } else if interactive {
559 if prompt_confirm("Install locally after releases? (for CLI tools)", false)? {
560 Some(prompt_input("Install command", Some("just install"))?)
561 } else {
562 None
563 }
564 } else {
565 None
566 };
567
568 let check_command = if let Some(ref cmd) = self.check_command {
570 Some(cmd.clone())
571 } else {
572 let auto = detect_check_command(&languages);
573 if interactive {
574 let default = auto.as_deref().unwrap_or("just check");
575 Some(prompt_input(
576 "Build/check command (run before merging)",
577 Some(default),
578 )?)
579 } else {
580 auto
581 }
582 };
583
584 Ok(InitChoices {
585 name,
586 types,
587 tools,
588 reviewers,
589 languages,
590 install_command,
591 check_command,
592 init_bones,
593 seed_work,
594 })
595 }
596}
597
598fn prompt_input(prompt: &str, default: Option<&str>) -> Result<String> {
601 let mut builder = dialoguer::Input::<String>::new().with_prompt(prompt);
602 if let Some(d) = default {
603 builder = builder.default(d.to_string());
604 }
605 builder.interact_text().context("reading user input")
606}
607
608fn prompt_multi_select(prompt: &str, items: &[&str], defaults: &[bool]) -> Result<Vec<String>> {
609 let selections = dialoguer::MultiSelect::new()
610 .with_prompt(prompt)
611 .items(items)
612 .defaults(defaults)
613 .interact()
614 .context("reading user selection")?;
615
616 Ok(selections
617 .into_iter()
618 .map(|i| items[i].to_string())
619 .collect())
620}
621
622fn prompt_confirm(prompt: &str, default: bool) -> Result<bool> {
623 dialoguer::Confirm::new()
624 .with_prompt(prompt)
625 .default(default)
626 .interact()
627 .context("reading user confirmation")
628}
629
630fn validate_values(values: &[String], valid: &[&str], label: &str) -> Result<()> {
633 let invalid: Vec<&String> = values
634 .iter()
635 .filter(|v| !valid.contains(&v.as_str()))
636 .collect();
637 if !invalid.is_empty() {
638 let inv = invalid
639 .iter()
640 .map(|s| s.as_str())
641 .collect::<Vec<_>>()
642 .join(", ");
643 let val = valid.join(", ");
644 return Err(ExitError::Other(format!("Unknown {label}: {inv}. Valid: {val}")).into());
645 }
646 Ok(())
647}
648
649#[derive(Debug, Default)]
652struct DetectedConfig {
653 name: Option<String>,
654 types: Vec<String>,
655 tools: Vec<String>,
656 reviewers: Vec<String>,
657}
658
659fn detect_from_agents_md(content: &str) -> DetectedConfig {
660 let mut config = DetectedConfig::default();
661
662 for line in content.lines().take(20) {
663 if line.starts_with("# ") && config.name.is_none() {
664 config.name = Some(line[2..].trim().to_string());
665 } else if let Some(rest) = line.strip_prefix("Project type: ") {
666 config.types = rest.split(',').map(|s| s.trim().to_string()).collect();
667 } else if let Some(rest) = line.strip_prefix("Tools: ") {
668 config.tools = rest
669 .split(',')
670 .map(|s| s.trim().trim_matches('`').to_string())
671 .collect();
672 } else if let Some(rest) = line.strip_prefix("Reviewer roles: ") {
673 config.reviewers = rest.split(',').map(|s| s.trim().to_string()).collect();
674 }
675 }
676
677 config
678}
679
680fn detect_check_command(languages: &[String]) -> Option<String> {
684 for lang in languages {
685 match lang.as_str() {
686 "rust" => return Some("cargo clippy && cargo test".to_string()),
687 "typescript" | "node" => return Some("npm run build && npm test".to_string()),
688 "python" => return Some("python -m pytest".to_string()),
689 "go" => return Some("go vet ./... && go test ./...".to_string()),
690 "java" => return Some("mvn verify".to_string()),
691 _ => {}
692 }
693 }
694 None
695}
696
697fn build_config(choices: &InitChoices) -> Config {
700 Config {
701 version: CONFIG_VERSION.to_string(),
702 project: ProjectConfig {
703 name: choices.name.clone(),
704 project_type: choices.types.clone(),
705 languages: choices.languages.clone(),
706 default_agent: Some(format!("{}-dev", choices.name)),
707 channel: Some(choices.name.clone()),
708 install_command: choices.install_command.clone(),
709 check_command: choices.check_command.clone(),
710 critical_approvers: None,
711 },
712 tools: ToolsConfig {
713 bones: choices.tools.contains(&"bones".to_string()),
714 maw: choices.tools.contains(&"maw".to_string()),
715 crit: choices.tools.contains(&"crit".to_string()),
716 botbus: choices.tools.contains(&"botbus".to_string()),
717 vessel: choices.tools.contains(&"vessel".to_string()),
718 },
719 review: ReviewConfig {
720 enabled: !choices.reviewers.is_empty(),
721 reviewers: choices.reviewers.clone(),
722 },
723 push_main: false,
724 agents: AgentsConfig {
725 dev: Some(DevAgentConfig {
726 model: "strong".into(),
727 max_loops: 100,
728 pause: 2,
729 timeout: 3600,
730 missions: Some(MissionsConfig {
731 enabled: true,
732 max_workers: 4,
733 max_children: 12,
734 checkpoint_interval_sec: 30,
735 }),
736 multi_lead: None,
737 memory_limit: None,
738 }),
739 worker: Some(WorkerAgentConfig {
740 model: "fast".into(),
741 timeout: 900,
742 memory_limit: None,
743 }),
744 reviewer: Some(ReviewerAgentConfig {
745 model: "strong".into(),
746 max_loops: 100,
747 pause: 2,
748 timeout: 900,
749 memory_limit: None,
750 }),
751 responder: None,
752 },
753 models: Default::default(),
754 env: build_default_env(&choices.languages),
755 }
756}
757
758fn build_default_env(languages: &[String]) -> std::collections::HashMap<String, String> {
760 let mut env = std::collections::HashMap::new();
761 let has_rust = languages.iter().any(|l| l.eq_ignore_ascii_case("rust"));
762 if has_rust {
763 env.insert("CARGO_BUILD_JOBS".into(), "2".into());
764 env.insert("RUSTC_WRAPPER".into(), "sccache".into());
765 env.insert("SCCACHE_DIR".into(), "$HOME/.cache/sccache".into());
766 }
767 env
768}
769
770use crate::commands::sync::{DESIGN_DOCS, REVIEWER_PROMPTS, WORKFLOW_DOCS};
774
775fn sync_workflow_docs(agents_dir: &Path) -> Result<()> {
776 for (name, content) in WORKFLOW_DOCS {
777 let path = agents_dir.join(name);
778 fs::write(&path, content).with_context(|| format!("writing {}", path.display()))?;
779 }
780
781 use sha2::{Digest, Sha256};
783 let mut hasher = Sha256::new();
784 for (name, content) in WORKFLOW_DOCS {
785 hasher.update(name.as_bytes());
786 hasher.update(content.as_bytes());
787 }
788 let version = format!("{:x}", hasher.finalize());
789 fs::write(agents_dir.join(".version"), &version[..32])?;
790
791 Ok(())
792}
793
794fn sync_prompts(agents_dir: &Path) -> Result<()> {
795 let prompts_dir = agents_dir.join("prompts");
796 fs::create_dir_all(&prompts_dir)?;
797
798 for (name, content) in REVIEWER_PROMPTS {
799 let path = prompts_dir.join(name);
800 fs::write(&path, content).with_context(|| format!("writing {}", path.display()))?;
801 }
802
803 use sha2::{Digest, Sha256};
804 let mut hasher = Sha256::new();
805 for (name, content) in REVIEWER_PROMPTS {
806 hasher.update(name.as_bytes());
807 hasher.update(content.as_bytes());
808 }
809 let version = format!("{:x}", hasher.finalize());
810 fs::write(prompts_dir.join(".prompts-version"), &version[..32])?;
811
812 Ok(())
813}
814
815fn sync_design_docs(agents_dir: &Path) -> Result<()> {
816 let design_dir = agents_dir.join("design");
817 fs::create_dir_all(&design_dir)?;
818
819 for (name, content) in DESIGN_DOCS {
820 let path = design_dir.join(name);
821 fs::write(&path, content).with_context(|| format!("writing {}", path.display()))?;
822 }
823
824 use sha2::{Digest, Sha256};
825 let mut hasher = Sha256::new();
826 for (name, content) in DESIGN_DOCS {
827 hasher.update(name.as_bytes());
828 hasher.update(content.as_bytes());
829 }
830 let version = format!("{:x}", hasher.finalize());
831 fs::write(design_dir.join(".design-docs-version"), &version[..32])?;
832
833 Ok(())
834}
835
836fn register_spawn_hooks(project_dir: &Path, name: &str, reviewers: &[String], config: &Config) {
841 let abs_path = project_dir
842 .canonicalize()
843 .unwrap_or_else(|_| project_dir.to_path_buf());
844 let agent = format!("{name}-dev");
845
846 let (hook_cwd, spawn_cwd) = detect_hook_paths(&abs_path);
848
849 if Tool::new("bus").arg("hooks").arg("list").run().is_err() {
851 return;
852 }
853
854 let responder_memory_limit = config
856 .agents
857 .responder
858 .as_ref()
859 .and_then(|r| r.memory_limit.as_deref());
860 register_router_hook(&hook_cwd, &spawn_cwd, name, &agent, responder_memory_limit);
861
862 let reviewer_memory_limit = config
864 .agents
865 .reviewer
866 .as_ref()
867 .and_then(|r| r.memory_limit.as_deref());
868 for role in reviewers {
869 let reviewer_agent = format!("{name}-{role}");
870 register_reviewer_hook(&hook_cwd, &spawn_cwd, name, &agent, &reviewer_agent, reviewer_memory_limit);
871 }
872}
873
874fn detect_hook_paths(abs_path: &Path) -> (String, String) {
875 let abs_str = abs_path.display().to_string();
877 if let Some(parent) = abs_path.parent()
878 && parent.file_name().is_some_and(|n| n == "ws")
879 && let Some(bare_root) = parent.parent()
880 && bare_root.join(".manifold").exists()
881 {
882 let bare_str = bare_root.display().to_string();
883 return (bare_str.clone(), bare_str);
884 }
885 (abs_str.clone(), abs_str)
886}
887
888pub(super) fn register_router_hook(
889 hook_cwd: &str,
890 spawn_cwd: &str,
891 name: &str,
892 agent: &str,
893 memory_limit: Option<&str>,
894) {
895 let env_inherit = "BOTBUS_CHANNEL,BOTBUS_MESSAGE_ID,BOTBUS_HOOK_ID,SSH_AUTH_SOCK,OTEL_EXPORTER_OTLP_ENDPOINT,TRACEPARENT";
896 let claim_uri = format!("agent://{name}-dev");
897 let spawn_name = format!("{name}-responder");
898 let description = format!("edict:{name}:responder");
899
900 let mut args: Vec<&str> = vec![
901 "--agent",
902 agent,
903 "--channel",
904 name,
905 "--claim",
906 &claim_uri,
907 "--claim-owner",
908 agent,
909 "--cwd",
910 hook_cwd,
911 "--ttl",
912 "600",
913 "--",
914 "vessel",
915 "spawn",
916 "--env-inherit",
917 env_inherit,
918 ];
919 if let Some(limit) = memory_limit {
920 args.push("--memory-limit");
921 args.push(limit);
922 }
923 args.extend_from_slice(&[
924 "--name",
925 &spawn_name,
926 "--cwd",
927 spawn_cwd,
928 "--",
929 "edict",
930 "run",
931 "responder",
932 ]);
933
934 match crate::subprocess::ensure_bus_hook(&description, &args) {
935 Ok((action, _id)) => println!("Router hook {action} for #{name}"),
936 Err(e) => eprintln!("Warning: Failed to register router hook: {e}"),
937 }
938}
939
940pub(super) fn register_reviewer_hook(
941 hook_cwd: &str,
942 spawn_cwd: &str,
943 name: &str,
944 agent: &str,
945 reviewer_agent: &str,
946 memory_limit: Option<&str>,
947) {
948 let env_inherit = "BOTBUS_CHANNEL,BOTBUS_MESSAGE_ID,BOTBUS_HOOK_ID,SSH_AUTH_SOCK,OTEL_EXPORTER_OTLP_ENDPOINT,TRACEPARENT";
949 let claim_uri = format!("agent://{reviewer_agent}");
950 let role = reviewer_agent
952 .strip_prefix(&format!("{name}-"))
953 .unwrap_or(reviewer_agent);
954 let description = format!("edict:{name}:reviewer-{role}");
955
956 let mut args: Vec<&str> = vec![
957 "--agent",
958 agent,
959 "--channel",
960 name,
961 "--mention",
962 reviewer_agent,
963 "--claim",
964 &claim_uri,
965 "--claim-owner",
966 reviewer_agent,
967 "--ttl",
968 "600",
969 "--priority",
970 "1",
971 "--cwd",
972 hook_cwd,
973 "--",
974 "vessel",
975 "spawn",
976 "--env-inherit",
977 env_inherit,
978 ];
979 if let Some(limit) = memory_limit {
980 args.push("--memory-limit");
981 args.push(limit);
982 }
983 args.extend_from_slice(&[
984 "--name",
985 reviewer_agent,
986 "--cwd",
987 spawn_cwd,
988 "--",
989 "edict",
990 "run",
991 "reviewer-loop",
992 "--agent",
993 reviewer_agent,
994 ]);
995
996 match crate::subprocess::ensure_bus_hook(&description, &args) {
997 Ok((action, _id)) => println!("Reviewer hook for @{reviewer_agent} {action}"),
998 Err(e) => eprintln!("Warning: Failed to register mention hook for @{reviewer_agent}: {e}"),
999 }
1000}
1001
1002fn seed_initial_bones(project_dir: &Path, _name: &str, types: &[String]) -> usize {
1005 let mut count = 0;
1006
1007 let create_bone = |title: &str, description: &str, urgency: &str| -> bool {
1008 Tool::new("bn")
1009 .args(&[
1010 "create",
1011 &format!("--title={title}"),
1012 &format!("--description={description}"),
1013 "--kind=task",
1014 &format!("--urgency={urgency}"),
1015 ])
1016 .run()
1017 .is_ok_and(|o| o.success())
1018 };
1019
1020 for spec in ["spec.md", "SPEC.md", "specification.md", "design.md"] {
1022 if project_dir.join(spec).exists()
1023 && create_bone(
1024 &format!("Review {spec} and create implementation bones"),
1025 &format!(
1026 "Read {spec}, understand requirements, and break down into actionable bones with acceptance criteria."
1027 ),
1028 "urgent",
1029 )
1030 {
1031 count += 1;
1032 }
1033 }
1034
1035 if project_dir.join("README.md").exists()
1037 && create_bone(
1038 "Review README and align project setup",
1039 "Read README.md for project goals, architecture decisions, and setup requirements. Create bones for any gaps.",
1040 "default",
1041 )
1042 {
1043 count += 1;
1044 }
1045
1046 if !project_dir.join("src").exists()
1048 && create_bone(
1049 "Create initial source structure",
1050 &format!(
1051 "Set up src/ directory and project scaffolding for project type: {}.",
1052 types.join(", ")
1053 ),
1054 "default",
1055 )
1056 {
1057 count += 1;
1058 }
1059
1060 if count == 0
1062 && create_bone(
1063 "Scout project and create initial bones",
1064 "Explore the repository, understand the project goals, and create actionable bones for initial implementation work.",
1065 "urgent",
1066 )
1067 {
1068 count += 1;
1069 }
1070
1071 count
1072}
1073
1074fn fetch_gitignore(languages: &[String]) -> Result<String> {
1077 for lang in languages {
1080 if !LANGUAGES.contains(&lang.as_str()) {
1081 anyhow::bail!("unknown language for .gitignore: {lang:?}. Valid: {LANGUAGES:?}");
1082 }
1083 }
1084 let langs = languages.join(",");
1085 let url = format!("https://www.toptal.com/developers/gitignore/api/{langs}");
1086 let body = ureq::get(&url).call()?.into_body().read_to_string()?;
1087 Ok(body)
1088}
1089
1090fn auto_commit(project_dir: &Path, config: &Config) -> Result<()> {
1093 let message = format!("chore: initialize edict v{}", config.version);
1094
1095 if project_dir.join(".git").exists()
1097 || project_dir
1098 .ancestors()
1099 .any(|p| p.join(".git").exists() || p.join("repo.git").exists())
1100 {
1101 let _ = run_command("git", &["add", "-A"], Some(project_dir));
1102 match run_command("git", &["commit", "-m", &message], Some(project_dir)) {
1103 Ok(_) => println!("Committed: {message}"),
1104 Err(_) => eprintln!("Warning: Failed to auto-commit (git error)"),
1105 }
1106 } else if project_dir.join(".jj").exists() {
1107 match run_command("jj", &["describe", "-m", &message], Some(project_dir)) {
1108 Ok(_) => println!("Committed: {message}"),
1109 Err(_) => eprintln!("Warning: Failed to auto-commit (jj error)"),
1110 }
1111 }
1112
1113 Ok(())
1114}
1115
1116#[cfg(test)]
1117mod tests {
1118 use super::*;
1119
1120 #[test]
1121 fn test_detect_from_agents_md() {
1122 let content = "# myproject\n\nProject type: cli, api\nTools: `bones`, `maw`, `crit`\nReviewer roles: security\n";
1123 let detected = detect_from_agents_md(content);
1124 assert_eq!(detected.name, Some("myproject".to_string()));
1125 assert_eq!(detected.types, vec!["cli", "api"]);
1126 assert_eq!(detected.tools, vec!["bones", "maw", "crit"]);
1127 assert_eq!(detected.reviewers, vec!["security"]);
1128 }
1129
1130 #[test]
1131 fn test_detect_from_empty_agents_md() {
1132 let detected = detect_from_agents_md("");
1133 assert!(detected.name.is_none());
1134 assert!(detected.types.is_empty());
1135 assert!(detected.tools.is_empty());
1136 assert!(detected.reviewers.is_empty());
1137 }
1138
1139 #[test]
1140 fn test_validate_values_ok() {
1141 let values = vec!["bones".to_string(), "maw".to_string()];
1142 assert!(validate_values(&values, AVAILABLE_TOOLS, "tool").is_ok());
1143 }
1144
1145 #[test]
1146 fn test_validate_values_invalid() {
1147 let values = vec!["bones".to_string(), "invalid".to_string()];
1148 let result = validate_values(&values, AVAILABLE_TOOLS, "tool");
1149 assert!(result.is_err());
1150 let err = result.unwrap_err().to_string();
1151 assert!(err.contains("invalid"));
1152 }
1153
1154 #[test]
1155 fn test_build_config() {
1156 let choices = InitChoices {
1157 name: "test".to_string(),
1158 types: vec!["cli".to_string()],
1159 tools: vec!["bones".to_string(), "maw".to_string()],
1160 reviewers: vec!["security".to_string()],
1161 languages: vec!["rust".to_string()],
1162 install_command: Some("just install".to_string()),
1163 check_command: Some("cargo test && cargo clippy -- -D warnings".to_string()),
1164 init_bones: true,
1165 seed_work: false,
1166 };
1167
1168 let config = build_config(&choices);
1169 assert_eq!(config.project.name, "test");
1170 assert_eq!(config.project.default_agent, Some("test-dev".to_string()));
1171 assert_eq!(config.project.channel, Some("test".to_string()));
1172 assert!(config.tools.bones);
1173 assert!(config.tools.maw);
1174 assert!(!config.tools.crit);
1175 assert!(config.review.enabled);
1176 assert_eq!(config.review.reviewers, vec!["security"]);
1177 assert_eq!(
1178 config.project.install_command,
1179 Some("just install".to_string())
1180 );
1181 assert_eq!(config.project.languages, vec!["rust"]);
1182
1183 let dev = config.agents.dev.unwrap();
1184 assert_eq!(dev.model, "strong");
1185 assert_eq!(dev.max_loops, 100);
1186 assert!(dev.missions.is_some());
1187
1188 assert_eq!(config.env.get("CARGO_BUILD_JOBS").unwrap(), "2");
1190 assert_eq!(config.env.get("RUSTC_WRAPPER").unwrap(), "sccache");
1191 assert_eq!(
1192 config.env.get("SCCACHE_DIR").unwrap(),
1193 "$HOME/.cache/sccache"
1194 );
1195 }
1196
1197 #[test]
1198 fn test_build_config_no_env_for_non_rust() {
1199 let choices = InitChoices {
1200 name: "jsapp".to_string(),
1201 types: vec!["web".to_string()],
1202 tools: vec!["bones".to_string()],
1203 reviewers: vec![],
1204 languages: vec!["typescript".to_string()],
1205 install_command: None,
1206 check_command: None,
1207 init_bones: false,
1208 seed_work: false,
1209 };
1210 let config = build_config(&choices);
1211 assert!(config.env.is_empty());
1212 }
1213
1214 #[test]
1215 fn test_config_version_matches() {
1216 assert!(CONFIG_VERSION.starts_with("1.0."));
1218 }
1219
1220 #[test]
1221 fn test_validate_name_valid() {
1222 assert!(validate_name("myproject", "test").is_ok());
1223 assert!(validate_name("my-project", "test").is_ok());
1224 assert!(validate_name("project123", "test").is_ok());
1225 assert!(validate_name("a", "test").is_ok());
1226 }
1227
1228 #[test]
1229 fn test_validate_name_invalid() {
1230 assert!(validate_name("", "test").is_err()); assert!(validate_name("-starts-dash", "test").is_err()); assert!(validate_name("ends-dash-", "test").is_err()); assert!(validate_name("Has Uppercase", "test").is_err()); assert!(validate_name("has space", "test").is_err()); assert!(validate_name("path/../traversal", "test").is_err()); assert!(validate_name("a;rm -rf /", "test").is_err()); assert!(validate_name(&"a".repeat(65), "test").is_err()); }
1239
1240 #[test]
1241 fn test_fetch_gitignore_validates_languages() {
1242 let result = fetch_gitignore(&["malicious/../../etc".to_string()]);
1244 assert!(result.is_err());
1245 assert!(result.unwrap_err().to_string().contains("unknown language"));
1246 }
1247}