1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5use clap::Args;
6use sha2::{Digest, Sha256};
7
8use crate::config::Config;
9use crate::error::ExitError;
10use crate::subprocess::{Tool, run_command};
11use crate::template::{TemplateContext, update_managed_section};
12
13#[derive(Debug, Args)]
14pub struct SyncArgs {
15 #[arg(long)]
17 pub project_root: Option<PathBuf>,
18 #[arg(long)]
20 pub check: bool,
21 #[arg(long)]
23 pub no_commit: bool,
24}
25
26pub(crate) const WORKFLOW_DOCS: &[(&str, &str)] = &[
28 ("triage.md", include_str!("../templates/docs/triage.md")),
29 ("start.md", include_str!("../templates/docs/start.md")),
30 ("update.md", include_str!("../templates/docs/update.md")),
31 ("finish.md", include_str!("../templates/docs/finish.md")),
32 (
33 "worker-loop.md",
34 include_str!("../templates/docs/worker-loop.md"),
35 ),
36 ("planning.md", include_str!("../templates/docs/planning.md")),
37 ("scout.md", include_str!("../templates/docs/scout.md")),
38 ("proposal.md", include_str!("../templates/docs/proposal.md")),
39 (
40 "review-request.md",
41 include_str!("../templates/docs/review-request.md"),
42 ),
43 (
44 "review-response.md",
45 include_str!("../templates/docs/review-response.md"),
46 ),
47 (
48 "review-loop.md",
49 include_str!("../templates/docs/review-loop.md"),
50 ),
51 (
52 "merge-check.md",
53 include_str!("../templates/docs/merge-check.md"),
54 ),
55 (
56 "preflight.md",
57 include_str!("../templates/docs/preflight.md"),
58 ),
59 (
60 "cross-channel.md",
61 include_str!("../templates/docs/cross-channel.md"),
62 ),
63 (
64 "report-issue.md",
65 include_str!("../templates/docs/report-issue.md"),
66 ),
67 ("groom.md", include_str!("../templates/docs/groom.md")),
68 ("mission.md", include_str!("../templates/docs/mission.md")),
69 (
70 "coordination.md",
71 include_str!("../templates/docs/coordination.md"),
72 ),
73];
74
75pub(crate) const DESIGN_DOCS: &[(&str, &str)] = &[(
77 "cli-conventions.md",
78 include_str!("../templates/design/cli-conventions.md"),
79)];
80
81pub(crate) const REVIEWER_PROMPTS: &[(&str, &str)] = &[
83 (
84 "reviewer.md",
85 include_str!("../templates/reviewer.md.jinja"),
86 ),
87 (
88 "reviewer-security.md",
89 include_str!("../templates/reviewer-security.md.jinja"),
90 ),
91];
92
93impl SyncArgs {
94 pub fn execute(&self) -> Result<()> {
95 let project_root = self
96 .project_root
97 .clone()
98 .unwrap_or_else(|| std::env::current_dir().expect("Failed to get current dir"));
99
100 if crate::config::find_config(&project_root.join("ws/default")).is_some() {
102 return self.handle_bare_repo(&project_root);
103 }
104
105 let agents_dir_edict = project_root.join(".agents/edict");
107 let agents_dir_legacy = project_root.join(".agents/botbox");
108 if !agents_dir_edict.exists() && !agents_dir_legacy.exists() {
109 return Err(ExitError::Other(
110 "No .agents/edict/ found. Run `edict init` first.".to_string(),
111 )
112 .into());
113 }
114
115 let config_path = crate::config::find_config(&project_root).ok_or_else(|| {
117 ExitError::Config("No .edict.toml or .botbox.toml found".to_string())
118 })?;
119 let config = Config::load(&config_path)
120 .with_context(|| format!("Failed to parse {}", config_path.display()))?;
121
122 let json_path = project_root.join(crate::config::CONFIG_JSON);
124 let toml_path = project_root.join(crate::config::CONFIG_TOML);
125 if json_path.exists() && !toml_path.exists() {
126 let json_content = fs::read_to_string(&json_path)?;
127 match crate::config::json_to_toml(&json_content) {
128 Ok(toml_content) => {
129 fs::write(&toml_path, &toml_content)?;
130 fs::remove_file(&json_path)?;
131 println!("Migrated .botbox.json -> .edict.toml");
132 }
133 Err(e) => {
134 tracing::warn!("failed to migrate .botbox.json to .edict.toml: {e}");
135 }
136 }
137 }
138
139 let legacy_toml_path = project_root.join(crate::config::CONFIG_TOML_LEGACY);
141 if legacy_toml_path.exists() && !toml_path.exists() {
142 match fs::rename(&legacy_toml_path, &toml_path) {
143 Ok(()) => println!("Migrated .botbox.toml -> .edict.toml"),
144 Err(e) => tracing::warn!("failed to rename .botbox.toml to .edict.toml: {e}"),
145 }
146 }
147
148 if agents_dir_legacy.exists() && !agents_dir_edict.exists() {
150 match fs::rename(&agents_dir_legacy, &agents_dir_edict) {
151 Ok(()) => println!("Migrated .agents/botbox/ -> .agents/edict/"),
152 Err(e) => tracing::warn!("failed to rename .agents/botbox/ to .agents/edict/: {e}"),
153 }
154 }
155
156 let agents_dir = if agents_dir_edict.exists() {
158 agents_dir_edict
159 } else {
160 agents_dir_legacy
161 };
162
163 let docs_stale = self.check_docs_staleness(&agents_dir)?;
165 let managed_stale = self.check_managed_section_staleness(&project_root, &config)?;
166 let prompts_stale = self.check_prompts_staleness(&agents_dir)?;
167 let design_docs_stale = self.check_design_docs_staleness(&agents_dir)?;
168
169 let any_stale =
170 docs_stale || managed_stale || prompts_stale || design_docs_stale;
171
172 if self.check {
173 if any_stale {
174 let mut parts = Vec::new();
175 if docs_stale {
176 parts.push("workflow docs");
177 }
178 if managed_stale {
179 parts.push("AGENTS.md managed section");
180 }
181 if prompts_stale {
182 parts.push("reviewer prompts");
183 }
184 if design_docs_stale {
185 parts.push("design docs");
186 }
187 tracing::warn!(components = %parts.join(", "), "stale components detected");
188 return Err(ExitError::new(1, "Project is out of sync".to_string()).into());
189 } else {
190 println!("All components up to date");
191 return Ok(());
192 }
193 }
194
195 self.cleanup_per_repo_hooks(&project_root)?;
197
198 let mut changed_files = Vec::new();
200
201 if docs_stale {
202 self.sync_workflow_docs(&agents_dir)?;
203 changed_files.push(".agents/edict/*.md");
204 println!("Updated workflow docs");
205 }
206
207 if managed_stale {
208 self.sync_managed_section(&project_root, &config)?;
209 changed_files.push("AGENTS.md");
210 println!("Updated AGENTS.md managed section");
211 }
212
213 if prompts_stale {
214 self.sync_prompts(&agents_dir)?;
215 changed_files.push(".agents/edict/prompts/*.md");
216 println!("Updated reviewer prompts");
217 }
218
219 if design_docs_stale {
220 self.sync_design_docs(&agents_dir)?;
221 changed_files.push(".agents/edict/design/*.md");
222 println!("Updated design docs");
223 }
224
225 self.cleanup_legacy_artifacts(&agents_dir, &mut changed_files);
227
228 migrate_bus_hooks(&config);
230
231 migrate_botbox_bus_hooks_to_edict(&config, &project_root);
233
234 migrate_hook_cwd(&config, &project_root);
236
237 migrate_router_hook_claim(&config, &project_root);
239
240 if !self.check {
242 migrate_vessel_hooks(&config, &project_root, &config_path);
243 }
244
245 if !self.check {
247 migrate_beads_to_bones(&project_root, &config_path)?;
248 }
249
250 if !changed_files.is_empty() && !self.no_commit {
252 self.auto_commit(&project_root, &changed_files)?;
253 }
254
255 println!("Sync complete");
256 Ok(())
257 }
258
259 fn handle_bare_repo(&self, project_root: &Path) -> Result<()> {
260 let project_root = project_root
262 .canonicalize()
263 .context("canonicalizing project root")?;
264
265 if crate::config::find_config(&project_root).is_none()
267 && crate::config::find_config(&project_root.join("ws/default")).is_none()
268 {
269 anyhow::bail!(
270 "not an edict project: no .edict.toml or .botbox.toml found in {}",
271 project_root.display()
272 );
273 }
274
275 let mut args = vec!["exec", "default", "--", "edict", "sync"];
276 if self.check {
277 args.push("--check");
278 }
279 if self.no_commit {
280 args.push("--no-commit");
281 }
282
283 run_command("maw", &args, Some(&project_root))?;
284
285 let ws_has_config = crate::config::find_config(&project_root.join("ws/default")).is_some();
293 for stale_name in &[crate::config::CONFIG_JSON, crate::config::CONFIG_TOML_LEGACY] {
294 let stale_path = project_root.join(stale_name);
295 if stale_path.exists() && ws_has_config {
296 if self.check {
297 tracing::warn!("stale {stale_name} at bare repo root (will be removed on sync)");
298 return Err(
299 ExitError::new(1, format!("Stale {stale_name} at bare repo root")).into(),
300 );
301 } else {
302 match fs::remove_file(&stale_path) {
303 Ok(()) => println!(
304 "Removed stale {stale_name} from bare repo root \
305 (authoritative config lives in ws/default/)"
306 ),
307 Err(e) => {
308 tracing::warn!("failed to remove stale {stale_name} at bare root: {e}")
309 }
310 }
311 }
312 }
313 }
314
315 let stub_agents = project_root.join("AGENTS.md");
317 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";
318
319 if !stub_agents.exists() {
320 fs::write(&stub_agents, stub_content)?;
321 println!("Created bare-root AGENTS.md stub");
322 }
323
324 let root_claude_dir = project_root.join(".claude");
326 let ws_claude_dir = project_root.join("ws/default/.claude");
327
328 if ws_claude_dir.exists() {
329 let needs_symlink = match fs::read_link(&root_claude_dir) {
331 Ok(target) => target != Path::new("ws/default/.claude"),
332 Err(_) => true,
333 };
334
335 if needs_symlink {
336 let tmp_link = project_root.join(".claude.tmp");
338 let _ = fs::remove_file(&tmp_link); #[cfg(unix)]
340 std::os::unix::fs::symlink("ws/default/.claude", &tmp_link)?;
341 #[cfg(windows)]
342 std::os::windows::fs::symlink_dir("ws/default/.claude", &tmp_link)?;
343
344 if let Err(e) = fs::rename(&tmp_link, &root_claude_dir) {
346 let _ = fs::remove_file(&tmp_link);
347 return Err(e).context("creating .claude symlink");
348 }
349 println!("Symlinked .claude → ws/default/.claude");
350 }
351 }
352
353 let root_pi_dir = project_root.join(".pi");
355 let ws_pi_dir = project_root.join("ws/default/.pi");
356
357 if ws_pi_dir.exists() {
358 let needs_symlink = match fs::read_link(&root_pi_dir) {
359 Ok(target) => target != Path::new("ws/default/.pi"),
360 Err(_) => true,
361 };
362
363 if needs_symlink {
364 let tmp_link = project_root.join(".pi.tmp");
365 let _ = fs::remove_file(&tmp_link);
366 #[cfg(unix)]
367 std::os::unix::fs::symlink("ws/default/.pi", &tmp_link)?;
368 #[cfg(windows)]
369 std::os::windows::fs::symlink_dir("ws/default/.pi", &tmp_link)?;
370
371 if let Err(e) = fs::rename(&tmp_link, &root_pi_dir) {
372 let _ = fs::remove_file(&tmp_link);
373 return Err(e).context("creating .pi symlink");
374 }
375 println!("Symlinked .pi → ws/default/.pi");
376 }
377 }
378
379 Ok(())
380 }
381
382 fn cleanup_legacy_artifacts(&self, agents_dir: &Path, changed_files: &mut Vec<&str>) {
386 let scripts_dir = agents_dir.join("scripts");
388 if scripts_dir.is_dir() {
389 if self.check {
390 tracing::warn!("legacy scripts/ directory exists (will be removed on sync)");
391 } else {
392 match fs::remove_dir_all(&scripts_dir) {
393 Ok(_) => {
394 println!("Removed legacy scripts/ directory");
395 changed_files.push(".agents/botbox/scripts/");
396 }
397 Err(e) => tracing::warn!("failed to remove legacy scripts/: {e}"),
398 }
399 }
400 }
401
402 let hooks_dir = agents_dir.join("hooks");
404 if hooks_dir.is_dir() {
405 if self.check {
406 tracing::warn!("legacy hooks/ directory exists (will be removed on sync)");
407 } else {
408 match fs::remove_dir_all(&hooks_dir) {
409 Ok(_) => {
410 println!("Removed legacy hooks/ directory");
411 changed_files.push(".agents/botbox/hooks/");
412 }
413 Err(e) => tracing::warn!("failed to remove legacy hooks/: {e}"),
414 }
415 }
416 }
417
418 for marker in &[".scripts-version", ".hooks-version"] {
420 let path = agents_dir.join(marker);
421 if path.exists() && !self.check {
422 let _ = fs::remove_file(&path);
423 }
424 }
425 }
426
427 fn check_docs_staleness(&self, agents_dir: &Path) -> Result<bool> {
428 let version_file = agents_dir.join(".version");
429 let current = compute_docs_version();
430
431 if !version_file.exists() {
432 return Ok(true);
433 }
434
435 let installed = fs::read_to_string(&version_file)?.trim().to_string();
436 Ok(installed != current)
437 }
438
439 fn check_managed_section_staleness(
440 &self,
441 project_root: &Path,
442 config: &Config,
443 ) -> Result<bool> {
444 let agents_md = project_root.join("AGENTS.md");
445 if !agents_md.exists() {
446 return Ok(false); }
448
449 let content = fs::read_to_string(&agents_md)?;
450 let ctx = TemplateContext::from_config(config);
451 let updated = update_managed_section(&content, &ctx)?;
452
453 Ok(content != updated)
454 }
455
456 fn check_prompts_staleness(&self, agents_dir: &Path) -> Result<bool> {
457 let version_file = agents_dir.join("prompts/.prompts-version");
458 let current = compute_prompts_version();
459
460 if !version_file.exists() {
461 return Ok(true);
462 }
463
464 let installed = fs::read_to_string(&version_file)?.trim().to_string();
465 Ok(installed != current)
466 }
467
468 fn cleanup_per_repo_hooks(&self, project_root: &Path) -> Result<()> {
471 if self.check {
472 return Ok(());
473 }
474
475 let settings_path = project_root.join(".claude/settings.json");
477 if settings_path.exists() {
478 let content = fs::read_to_string(&settings_path)?;
479 if let Ok(mut settings) = serde_json::from_str::<serde_json::Value>(&content) {
480 let mut changed = false;
481 if let Some(hooks) = settings.get_mut("hooks").and_then(|h| h.as_object_mut()) {
482 for (_event, entries) in hooks.iter_mut() {
483 if let Some(arr) = entries.as_array_mut() {
484 let before = arr.len();
485 arr.retain(|entry| {
486 !entry["hooks"]
487 .as_array()
488 .is_some_and(|hooks| {
489 hooks.iter().any(|h| {
490 let cmd = &h["command"];
491 if let Some(s) = cmd.as_str() {
492 s.contains("botbox hooks run")
493 } else if let Some(a) = cmd.as_array() {
494 a.len() >= 3
495 && a[0].as_str() == Some("botbox")
496 && a[1].as_str() == Some("hooks")
497 && a[2].as_str() == Some("run")
498 } else {
499 false
500 }
501 })
502 })
503 });
504 if arr.len() != before {
505 changed = true;
506 }
507 }
508 }
509 hooks.retain(|_, v| {
511 v.as_array().map(|a| !a.is_empty()).unwrap_or(true)
512 });
513 }
514
515 if changed {
516 if settings
518 .get("hooks")
519 .and_then(|h| h.as_object())
520 .is_some_and(|h| h.is_empty())
521 {
522 settings.as_object_mut().unwrap().remove("hooks");
523 }
524
525 if settings.as_object().is_some_and(|o| o.is_empty()) {
527 fs::remove_file(&settings_path)?;
528 let claude_dir = project_root.join(".claude");
530 if claude_dir.exists() && fs::read_dir(&claude_dir)?.next().is_none() {
531 fs::remove_dir(&claude_dir)?;
532 }
533 } else {
534 fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
535 }
536 println!("Cleaned up per-repo botbox hooks from .claude/settings.json (now managed globally via `botbox hooks install`)");
537 }
538 }
539 }
540
541 let pi_ext = project_root.join(".pi/extensions/botbox-hooks.ts");
543 if pi_ext.exists() {
544 fs::remove_file(&pi_ext)?;
545 let pi_ext_dir = project_root.join(".pi/extensions");
547 if pi_ext_dir.exists() && fs::read_dir(&pi_ext_dir)?.next().is_none() {
548 fs::remove_dir(&pi_ext_dir)?;
549 }
550 let pi_dir = project_root.join(".pi");
551 if pi_dir.exists() && fs::read_dir(&pi_dir)?.next().is_none() {
552 fs::remove_dir(&pi_dir)?;
553 }
554 println!("Cleaned up per-repo Pi extension (now managed globally via `botbox hooks install`)");
555 }
556
557 Ok(())
558 }
559
560 fn check_design_docs_staleness(&self, agents_dir: &Path) -> Result<bool> {
561 let version_file = agents_dir.join("design/.design-docs-version");
562 let current = compute_design_docs_version();
563
564 if !version_file.exists() {
565 return Ok(true);
566 }
567
568 let installed = fs::read_to_string(&version_file)?.trim().to_string();
569 Ok(installed != current)
570 }
571
572 fn sync_workflow_docs(&self, agents_dir: &Path) -> Result<()> {
573 for (name, content) in WORKFLOW_DOCS {
574 let path = agents_dir.join(name);
575 fs::write(&path, content)
576 .with_context(|| format!("Failed to write {}", path.display()))?;
577 }
578
579 let version = compute_docs_version();
580 fs::write(agents_dir.join(".version"), version)?;
581
582 Ok(())
583 }
584
585 fn sync_managed_section(&self, project_root: &Path, config: &Config) -> Result<()> {
586 let agents_md = project_root.join("AGENTS.md");
587 if !agents_md.exists() {
588 return Ok(()); }
590
591 let content = fs::read_to_string(&agents_md)?;
592 let ctx = TemplateContext::from_config(config);
593 let updated = update_managed_section(&content, &ctx)?;
594
595 fs::write(&agents_md, updated)?;
596 Ok(())
597 }
598
599 fn sync_prompts(&self, agents_dir: &Path) -> Result<()> {
600 let prompts_dir = agents_dir.join("prompts");
601 fs::create_dir_all(&prompts_dir)?;
602
603 for (name, content) in REVIEWER_PROMPTS {
604 let path = prompts_dir.join(name);
605 fs::write(&path, content)
606 .with_context(|| format!("Failed to write {}", path.display()))?;
607 }
608
609 let version = compute_prompts_version();
610 fs::write(prompts_dir.join(".prompts-version"), version)?;
611
612 Ok(())
613 }
614
615 fn sync_design_docs(&self, agents_dir: &Path) -> Result<()> {
618 let design_dir = agents_dir.join("design");
619 fs::create_dir_all(&design_dir)?;
620
621 for (name, content) in DESIGN_DOCS {
622 let path = design_dir.join(name);
623 fs::write(&path, content)
624 .with_context(|| format!("Failed to write {}", path.display()))?;
625 }
626
627 let version = compute_design_docs_version();
628 fs::write(design_dir.join(".design-docs-version"), version)?;
629
630 Ok(())
631 }
632
633 fn auto_commit(&self, project_root: &Path, changed_files: &[&str]) -> Result<()> {
634 let vcs = detect_vcs(project_root);
636 if vcs == Vcs::None {
637 return Ok(()); }
639
640 let managed_paths: &[&str] = &[
642 ".agents/botbox/",
643 "AGENTS.md",
644 ".critignore",
645 ".botbox.toml",
646 ".botbox.json",
647 ".gitignore",
648 ];
649
650 let files_str: String = changed_files
652 .join(", ")
653 .chars()
654 .filter(|c| !c.is_control())
655 .collect();
656 let message = format!("chore: edict sync (updated {})", files_str);
657
658 match vcs {
659 Vcs::Jj => {
660 run_command("jj", &["describe", "-m", &message], Some(project_root))?;
661 run_command("jj", &["new", "-m", ""], Some(project_root))?;
663 run_command(
664 "jj",
665 &["bookmark", "set", "main", "-r", "@-"],
666 Some(project_root),
667 )?;
668 }
669 Vcs::Git => {
670 let existing: Vec<&str> = managed_paths
672 .iter()
673 .copied()
674 .filter(|p| project_root.join(p).exists())
675 .collect();
676 if existing.is_empty() {
677 return Ok(());
678 }
679 let mut args = vec!["add", "--"];
680 args.extend_from_slice(&existing);
681 run_command("git", &args, Some(project_root))?;
682
683 let status = run_command(
685 "git",
686 &["diff", "--cached", "--quiet"],
687 Some(project_root),
688 );
689 if status.is_err() {
690 run_command("git", &["commit", "-m", &message], Some(project_root))?;
692 }
693 }
694 Vcs::None => unreachable!(),
695 }
696
697 Ok(())
698 }
699}
700
701fn migrate_botbox_bus_hooks_to_edict(config: &Config, project_root: &Path) {
707 let output = match Tool::new("bus")
708 .args(&["hooks", "list", "--format", "json"])
709 .run()
710 {
711 Ok(o) if o.success() => o,
712 _ => return,
713 };
714
715 let parsed: serde_json::Value = match serde_json::from_str(&output.stdout) {
716 Ok(v) => v,
717 Err(_) => return,
718 };
719
720 let hooks = match parsed.get("hooks").and_then(|h| h.as_array()) {
721 Some(h) => h,
722 None => return,
723 };
724
725 let name = &config.project.name;
726
727 let bare_root = if project_root.ends_with("ws/default") {
729 project_root
730 .parent()
731 .and_then(Path::parent)
732 .filter(|r| r.join(".manifold").exists())
733 } else if project_root.join(".manifold").exists() {
734 Some(project_root)
735 } else {
736 None
737 };
738 let root_str = bare_root
739 .map(|r| r.display().to_string())
740 .unwrap_or_else(|| project_root.display().to_string());
741
742 for hook in hooks {
743 let desc = hook
744 .get("description")
745 .and_then(|d| d.as_str())
746 .unwrap_or("");
747
748 if !desc.starts_with(&format!("botbox:{name}:")) {
750 continue;
751 }
752
753 let id = match hook.get("id").and_then(|i| i.as_str()) {
754 Some(id) => id,
755 None => continue,
756 };
757
758 if Tool::new("bus")
760 .args(&["hooks", "remove", id])
761 .run()
762 .is_err()
763 {
764 tracing::warn!(hook_id = %id, "failed to remove botbox-era hook during edict migration");
765 continue;
766 }
767
768 let agent = config.default_agent();
769 if desc.ends_with(":responder") {
770 let responder_ml = config
771 .agents
772 .responder
773 .as_ref()
774 .and_then(|r| r.memory_limit.as_deref());
775 super::init::register_router_hook(&root_str, &root_str, name, &agent, responder_ml);
776 println!(" Migrated hook {desc} → edict:{name}:responder");
777 } else if let Some(role) = desc.strip_prefix(&format!("botbox:{name}:reviewer-")) {
778 let reviewer_agent = format!("{name}-{role}");
779 let reviewer_ml = config
780 .agents
781 .reviewer
782 .as_ref()
783 .and_then(|r| r.memory_limit.as_deref());
784 super::init::register_reviewer_hook(
785 &root_str,
786 &root_str,
787 name,
788 &agent,
789 &reviewer_agent,
790 reviewer_ml,
791 );
792 println!(" Migrated hook {desc} → edict:{name}:reviewer-{role}");
793 }
794 }
795}
796
797fn migrate_bus_hooks(config: &Config) {
804 let output = match Tool::new("bus")
805 .args(&["hooks", "list", "--format", "json"])
806 .run()
807 {
808 Ok(o) if o.success() => o,
809 _ => return, };
811
812 let parsed: serde_json::Value = match serde_json::from_str(&output.stdout) {
813 Ok(v) => v,
814 Err(_) => return,
815 };
816
817 let hooks = match parsed.get("hooks").and_then(|h| h.as_array()) {
818 Some(h) => h,
819 None => return,
820 };
821
822 let name = &config.project.name;
823 let agent = config.default_agent();
824 let env_inherit = "BOTBUS_CHANNEL,BOTBUS_MESSAGE_ID,BOTBUS_HOOK_ID,SSH_AUTH_SOCK,OTEL_EXPORTER_OTLP_ENDPOINT,TRACEPARENT";
825
826 for hook in hooks {
827 let id = match hook.get("id").and_then(|i| i.as_str()) {
828 Some(id) => id.to_string(),
829 None => continue,
830 };
831
832 let channel = hook.get("channel").and_then(|c| c.as_str()).unwrap_or("");
833
834 if channel != name {
836 continue;
837 }
838
839 let existing_desc = hook
842 .get("description")
843 .and_then(|d| d.as_str())
844 .unwrap_or("");
845 if existing_desc.starts_with("edict:") || existing_desc.starts_with("botbox:") {
846 continue;
847 }
848
849 let cmd = hook.get("command").and_then(|c| c.as_array());
850 let cmd = match cmd {
851 Some(c) => c,
852 None => continue,
853 };
854
855 let cmd_strs: Vec<&str> = cmd.iter().filter_map(|v| v.as_str()).collect();
856
857 let is_router = cmd_strs.iter().any(|s| {
859 s.contains("responder") || s.contains("respond.mjs") || s.contains("router.mjs")
860 });
861 let is_reviewer = cmd_strs
862 .iter()
863 .any(|s| s.contains("reviewer-loop") || s.contains("reviewer-loop.mjs"));
864
865 if !is_router && !is_reviewer {
866 continue;
867 }
868
869 let spawn_cwd = cmd_strs
870 .windows(2)
871 .find(|w| w[0] == "--cwd")
872 .map(|w| w[1])
873 .unwrap_or(".");
874
875 let remove = Tool::new("bus").args(&["hooks", "remove", &id]).run();
878
879 if remove.is_err() || !remove.as_ref().unwrap().success() {
880 tracing::warn!(hook_id = %id, "failed to remove legacy hook");
881 continue;
882 }
883
884 if is_router {
885 let claim_uri = format!("agent://{name}-dev");
886 let spawn_name = format!("{name}-responder");
887 let description = format!("edict:{name}:responder");
888 let responder_ml = config
889 .agents
890 .responder
891 .as_ref()
892 .and_then(|r| r.memory_limit.as_deref());
893
894 let mut router_args: Vec<&str> = vec![
895 "--agent",
896 &agent,
897 "--channel",
898 name,
899 "--claim",
900 &claim_uri,
901 "--claim-owner",
902 &agent,
903 "--cwd",
904 spawn_cwd,
905 "--ttl",
906 "600",
907 "--",
908 "vessel",
909 "spawn",
910 "--env-inherit",
911 env_inherit,
912 ];
913 if let Some(limit) = responder_ml {
914 router_args.push("--memory-limit");
915 router_args.push(limit);
916 }
917 router_args.extend_from_slice(&[
918 "--name",
919 &spawn_name,
920 "--cwd",
921 spawn_cwd,
922 "--",
923 "edict",
924 "run",
925 "responder",
926 ]);
927
928 match crate::subprocess::ensure_bus_hook(&description, &router_args) {
929 Ok(_) => println!(" Migrated router hook {id} → edict run responder"),
930 Err(e) => tracing::warn!("failed to re-register router hook: {e}"),
931 }
932 } else if is_reviewer {
933 let reviewer_agent = hook
934 .get("condition")
935 .and_then(|c| c.get("agent"))
936 .and_then(|a| a.as_str())
937 .unwrap_or("")
938 .to_string();
939
940 if reviewer_agent.is_empty() {
941 tracing::warn!(hook_id = %id, "could not determine reviewer agent for hook");
942 continue;
943 }
944
945 let role = reviewer_agent
946 .strip_prefix(&format!("{name}-"))
947 .unwrap_or(&reviewer_agent);
948 let claim_uri = format!("agent://{reviewer_agent}");
949 let description = format!("edict:{name}:reviewer-{role}");
950 let reviewer_ml = config
951 .agents
952 .reviewer
953 .as_ref()
954 .and_then(|r| r.memory_limit.as_deref());
955
956 let mut reviewer_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 spawn_cwd,
973 "--",
974 "vessel",
975 "spawn",
976 "--env-inherit",
977 env_inherit,
978 ];
979 if let Some(limit) = reviewer_ml {
980 reviewer_args.push("--memory-limit");
981 reviewer_args.push(limit);
982 }
983 reviewer_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, &reviewer_args) {
997 Ok(_) => println!(
998 " Migrated reviewer hook {id} → edict run reviewer-loop --agent {reviewer_agent}"
999 ),
1000 Err(e) => tracing::warn!(agent = %reviewer_agent, "failed to re-register reviewer hook: {e}"),
1001 }
1002 }
1003 }
1004}
1005
1006fn migrate_hook_cwd(config: &Config, project_root: &Path) {
1012 let bare_root = if project_root.ends_with("ws/default") {
1014 project_root.parent().and_then(Path::parent)
1015 } else if project_root.join(".manifold").exists() {
1016 Some(project_root)
1017 } else {
1018 None
1019 };
1020
1021 let bare_root = match bare_root {
1022 Some(r) if r.join(".manifold").exists() => r,
1023 _ => return,
1024 };
1025
1026 let ws_default_str = bare_root
1027 .join("ws")
1028 .join("default")
1029 .display()
1030 .to_string();
1031 let root_str = bare_root.display().to_string();
1032
1033 let output = match Tool::new("bus")
1034 .args(&["hooks", "list", "--format", "json"])
1035 .run()
1036 {
1037 Ok(o) if o.success() => o,
1038 _ => return,
1039 };
1040
1041 let parsed: serde_json::Value = match serde_json::from_str(&output.stdout) {
1042 Ok(v) => v,
1043 Err(_) => return,
1044 };
1045
1046 let hooks = match parsed.get("hooks").and_then(|h| h.as_array()) {
1047 Some(h) => h,
1048 None => return,
1049 };
1050
1051 let name = &config.project.name;
1052 let agent = config.default_agent();
1053 let reviewers: Vec<String> = config
1054 .review
1055 .reviewers
1056 .iter()
1057 .map(|r| format!("{name}-{r}"))
1058 .collect();
1059
1060 for hook in hooks {
1061 let desc = hook
1062 .get("description")
1063 .and_then(|d| d.as_str())
1064 .unwrap_or("");
1065 let is_ours = desc.starts_with(&format!("edict:{name}:"))
1067 || desc.starts_with(&format!("botbox:{name}:"));
1068 if !is_ours {
1069 continue;
1070 }
1071
1072 let cmd = match hook.get("command").and_then(|c| c.as_array()) {
1073 Some(c) => c,
1074 None => continue,
1075 };
1076 let cmd_strs: Vec<&str> = cmd.iter().filter_map(|v| v.as_str()).collect();
1077
1078 let has_stale_cwd = cmd_strs
1080 .windows(2)
1081 .any(|w| w[0] == "--cwd" && w[1] == ws_default_str);
1082 if !has_stale_cwd {
1083 continue;
1084 }
1085
1086 let id = match hook.get("id").and_then(|i| i.as_str()) {
1088 Some(id) => id,
1089 None => continue,
1090 };
1091
1092 if Tool::new("bus")
1094 .args(&["hooks", "remove", id])
1095 .run()
1096 .is_err()
1097 {
1098 continue;
1099 }
1100
1101 let is_router = desc.ends_with(":responder");
1102 if is_router {
1103 let responder_ml = config
1104 .agents
1105 .responder
1106 .as_ref()
1107 .and_then(|r| r.memory_limit.as_deref());
1108 super::init::register_router_hook(&root_str, &root_str, name, &agent, responder_ml);
1109 println!(" Fixed hook --cwd: {desc} → repo root");
1110 } else {
1111 let reviewer_ml = config
1112 .agents
1113 .reviewer
1114 .as_ref()
1115 .and_then(|r| r.memory_limit.as_deref());
1116 for reviewer in &reviewers {
1118 if desc.contains(&reviewer.replace(&format!("{name}-"), "")) {
1119 super::init::register_reviewer_hook(
1120 &root_str, &root_str, name, &agent, reviewer, reviewer_ml,
1121 );
1122 println!(" Fixed hook --cwd: {desc} → repo root");
1123 break;
1124 }
1125 }
1126 }
1127 }
1128}
1129
1130fn migrate_router_hook_claim(config: &Config, project_root: &Path) {
1137 let output = match Tool::new("bus")
1138 .args(&["hooks", "list", "--format", "json"])
1139 .run()
1140 {
1141 Ok(o) if o.success() => o,
1142 _ => return,
1143 };
1144
1145 let parsed: serde_json::Value = match serde_json::from_str(&output.stdout) {
1146 Ok(v) => v,
1147 Err(_) => return,
1148 };
1149
1150 let hooks = match parsed.get("hooks").and_then(|h| h.as_array()) {
1151 Some(h) => h,
1152 None => return,
1153 };
1154
1155 let name = &config.project.name;
1156 let old_claim = format!("agent://{name}-router");
1157
1158 for hook in hooks {
1159 let desc = hook
1160 .get("description")
1161 .and_then(|d| d.as_str())
1162 .unwrap_or("");
1163 if desc != format!("edict:{name}:responder") && desc != format!("botbox:{name}:responder") {
1164 continue;
1165 }
1166
1167 let claim = hook
1169 .get("condition")
1170 .and_then(|c| c.get("pattern"))
1171 .and_then(|p| p.as_str())
1172 .unwrap_or("");
1173 if claim != old_claim {
1174 continue;
1175 }
1176
1177 let id = match hook.get("id").and_then(|i| i.as_str()) {
1178 Some(id) => id,
1179 None => continue,
1180 };
1181
1182 if Tool::new("bus")
1184 .args(&["hooks", "remove", id])
1185 .run()
1186 .is_err()
1187 {
1188 continue;
1189 }
1190
1191 let agent = config.default_agent();
1192 let bare_root = if project_root.ends_with("ws/default") {
1194 project_root
1195 .parent()
1196 .and_then(Path::parent)
1197 .filter(|r| r.join(".manifold").exists())
1198 } else if project_root.join(".manifold").exists() {
1199 Some(project_root)
1200 } else {
1201 None
1202 };
1203 let root_str = bare_root
1204 .map(|r| r.display().to_string())
1205 .unwrap_or_else(|| project_root.display().to_string());
1206 let responder_ml = config
1207 .agents
1208 .responder
1209 .as_ref()
1210 .and_then(|r| r.memory_limit.as_deref());
1211 super::init::register_router_hook(&root_str, &root_str, name, &agent, responder_ml);
1212 println!(" Migrated router hook claim: agent://{name}-router → agent://{name}-dev");
1213 }
1214}
1215
1216fn migrate_vessel_hooks(config: &Config, project_root: &Path, config_path: &Path) {
1220 if let Ok(content) = fs::read_to_string(config_path) {
1222 if content.contains("botty = ") {
1223 let updated = content.replace("botty = ", "vessel = ");
1224 if let Err(e) = fs::write(config_path, updated) {
1225 tracing::warn!("failed to update config botty→vessel: {e}");
1226 } else {
1227 println!("Migrated config: tools.botty → tools.vessel");
1228 }
1229 }
1230 }
1231
1232 let output = match Tool::new("bus")
1236 .args(&["hooks", "list", "--format", "json"])
1237 .run()
1238 {
1239 Ok(o) if o.success() => o,
1240 _ => return,
1241 };
1242
1243 let parsed: serde_json::Value = match serde_json::from_str(&output.stdout) {
1244 Ok(v) => v,
1245 Err(_) => return,
1246 };
1247
1248 let hooks = match parsed.get("hooks").and_then(|h| h.as_array()) {
1249 Some(h) => h.to_vec(),
1250 None => return,
1251 };
1252
1253 let name = &config.project.name;
1254
1255 let bare_root = if project_root.ends_with("ws/default") {
1257 project_root
1258 .parent()
1259 .and_then(Path::parent)
1260 .filter(|r| r.join(".manifold").exists())
1261 } else if project_root.join(".manifold").exists() {
1262 Some(project_root)
1263 } else {
1264 None
1265 };
1266 let root_str = bare_root
1267 .map(|r| r.display().to_string())
1268 .unwrap_or_else(|| project_root.display().to_string());
1269 let agent = config.default_agent();
1270
1271 for hook in &hooks {
1272 let uses_botty = hook
1274 .get("command")
1275 .and_then(|c| c.as_array())
1276 .map(|arr| arr.iter().any(|v| v.as_str() == Some("botty")))
1277 .unwrap_or(false);
1278 if !uses_botty {
1279 continue;
1280 }
1281
1282 let desc = hook
1283 .get("description")
1284 .and_then(|d| d.as_str())
1285 .unwrap_or("");
1286
1287 if desc == format!("edict:{name}:responder") {
1288 let ml = config
1289 .agents
1290 .responder
1291 .as_ref()
1292 .and_then(|r| r.memory_limit.as_deref());
1293 super::init::register_router_hook(&root_str, &root_str, name, &agent, ml);
1294 println!(" Migrated router hook: vessel spawn (was botty)");
1295 } else if let Some(role) = desc
1296 .strip_prefix(&format!("edict:{name}:reviewer-"))
1297 .filter(|r| !r.is_empty())
1298 {
1299 let reviewer_agent = format!("{name}-{role}");
1300 let ml = config
1301 .agents
1302 .reviewer
1303 .as_ref()
1304 .and_then(|r| r.memory_limit.as_deref());
1305 super::init::register_reviewer_hook(&root_str, &root_str, name, &agent, &reviewer_agent, ml);
1306 println!(" Migrated reviewer hook {role}: vessel spawn (was botty)");
1307 }
1308 }
1309}
1310
1311fn migrate_beads_to_bones(project_root: &Path, config_path: &Path) -> Result<()> {
1315 let beads_dir = project_root.join(".beads");
1316 let bones_dir = project_root.join(".bones");
1317
1318 if config_path.exists() {
1321 let content = fs::read_to_string(config_path)?;
1322 if content.contains("beads") && !content.contains("bones") {
1323 let updated = content.replace("beads = ", "bones = ");
1324 fs::write(config_path, updated)?;
1325 println!("Migrated config: tools.beads → tools.bones");
1326 }
1327 }
1328
1329 if beads_dir.exists() && !bones_dir.exists() {
1331 let beads_db = beads_dir.join("beads.db");
1332 match run_command("bn", &["init"], Some(project_root)) {
1334 Ok(_) => println!("Initialized bones"),
1335 Err(e) => tracing::warn!("bn init failed: {e}"),
1336 }
1337 if beads_db.exists() {
1339 let db_path = beads_db.to_string_lossy().to_string();
1340 match run_command(
1341 "bn",
1342 &["data", "migrate-from-beads", "--beads-db", &db_path],
1343 Some(project_root),
1344 ) {
1345 Ok(_) => println!("Migrated beads data to bones"),
1346 Err(e) => tracing::warn!("beads data migration failed: {e}"),
1347 }
1348 }
1349 }
1350
1351 let maw_toml = project_root.join(".maw.toml");
1353 if maw_toml.exists() {
1354 let content = fs::read_to_string(&maw_toml)?;
1355 if content.contains(".beads/") {
1356 let updated = content
1358 .lines()
1359 .map(|line| {
1360 if line.contains(".beads/") {
1361 None
1363 } else {
1364 Some(line)
1365 }
1366 })
1367 .flatten()
1368 .collect::<Vec<_>>()
1369 .join("\n");
1370 let updated = updated.replace(
1372 "auto_resolve_from_main = [\n]",
1373 "auto_resolve_from_main = []",
1374 );
1375 fs::write(&maw_toml, format!("{updated}\n"))?;
1376 println!("Updated .maw.toml: removed .beads/** entry");
1377 }
1378 }
1379
1380 let critignore = project_root.join(".critignore");
1382 if critignore.exists() {
1383 let content = fs::read_to_string(&critignore)?;
1384 if content.contains(".beads/") {
1385 let updated: String = content
1386 .lines()
1387 .filter(|line| line.trim() != ".beads/")
1388 .collect::<Vec<_>>()
1389 .join("\n");
1390 let updated = if content.ends_with('\n') {
1391 format!("{updated}\n")
1392 } else {
1393 updated
1394 };
1395 fs::write(&critignore, updated)?;
1396 println!("Updated .critignore: removed .beads/ entry");
1397 }
1398 }
1399
1400 let gitignore = project_root.join(".gitignore");
1402 if gitignore.exists() {
1403 let content = fs::read_to_string(&gitignore)?;
1404 if content.contains(".bv/") {
1405 let updated: String = content
1406 .lines()
1407 .filter(|line| line.trim() != ".bv/")
1408 .collect::<Vec<_>>()
1409 .join("\n");
1410 let updated = if content.ends_with('\n') {
1412 format!("{updated}\n")
1413 } else {
1414 updated
1415 };
1416 fs::write(&gitignore, updated)?;
1417 println!("Updated .gitignore: removed .bv/ entry");
1418 }
1419 }
1420
1421 Ok(())
1422}
1423
1424#[derive(Debug, PartialEq, Eq)]
1426enum Vcs {
1427 Jj,
1428 Git,
1429 None,
1430}
1431
1432fn detect_vcs(project_root: &Path) -> Vcs {
1436 if find_jj_root(project_root).is_some() {
1437 return Vcs::Jj;
1438 }
1439 if project_root
1441 .ancestors()
1442 .any(|p| p.join(".git").exists())
1443 {
1444 return Vcs::Git;
1445 }
1446 Vcs::None
1447}
1448
1449fn find_jj_root(from: &Path) -> Option<PathBuf> {
1452 from.ancestors()
1453 .find(|p| p.join(".jj").is_dir())
1454 .map(|p| p.to_path_buf())
1455}
1456
1457fn compute_docs_version() -> String {
1459 let mut hasher = Sha256::new();
1460 for (name, content) in WORKFLOW_DOCS {
1461 hasher.update(name.as_bytes());
1462 hasher.update(content.as_bytes());
1463 }
1464 format!("{:x}", hasher.finalize())[..32].to_string()
1465}
1466
1467fn compute_prompts_version() -> String {
1469 let mut hasher = Sha256::new();
1470 for (name, content) in REVIEWER_PROMPTS {
1471 hasher.update(name.as_bytes());
1472 hasher.update(content.as_bytes());
1473 }
1474 format!("{:x}", hasher.finalize())[..32].to_string()
1475}
1476
1477fn compute_design_docs_version() -> String {
1479 let mut hasher = Sha256::new();
1480 for (name, content) in DESIGN_DOCS {
1481 hasher.update(name.as_bytes());
1482 hasher.update(content.as_bytes());
1483 }
1484 format!("{:x}", hasher.finalize())[..32].to_string()
1485}
1486
1487#[cfg(test)]
1488mod tests {
1489 use super::*;
1490
1491 #[test]
1492 fn test_find_jj_root_direct() {
1493 let dir = tempfile::tempdir().unwrap();
1494 let jj = dir.path().join(".jj");
1495 fs::create_dir(&jj).unwrap();
1496 assert_eq!(find_jj_root(dir.path()), Some(dir.path().to_path_buf()));
1498 }
1499
1500 #[test]
1501 fn test_find_jj_root_ancestor() {
1502 let dir = tempfile::tempdir().unwrap();
1503 let jj = dir.path().join(".jj");
1504 fs::create_dir(&jj).unwrap();
1505 let ws = dir.path().join("ws/default");
1506 fs::create_dir_all(&ws).unwrap();
1507 assert_eq!(find_jj_root(&ws), Some(dir.path().to_path_buf()));
1509 }
1510
1511 #[test]
1512 fn test_find_jj_root_missing() {
1513 let dir = tempfile::tempdir().unwrap();
1514 assert_eq!(find_jj_root(dir.path()), None);
1516 }
1517
1518 #[test]
1519 fn test_version_hashes() {
1520 let docs_ver = compute_docs_version();
1521 assert_eq!(docs_ver.len(), 32);
1522 assert!(docs_ver.chars().all(|c| c.is_ascii_hexdigit()));
1523
1524 let prompts_ver = compute_prompts_version();
1525 assert_eq!(prompts_ver.len(), 32);
1526 assert!(prompts_ver.chars().all(|c| c.is_ascii_hexdigit()));
1527
1528 let design_ver = compute_design_docs_version();
1529 assert_eq!(design_ver.len(), 32);
1530 assert!(design_ver.chars().all(|c| c.is_ascii_hexdigit()));
1531 }
1532
1533 #[test]
1534 fn test_workflow_docs_embedded() {
1535 assert!(!WORKFLOW_DOCS.is_empty());
1536 for (name, content) in WORKFLOW_DOCS {
1537 assert!(!name.is_empty());
1538 assert!(!content.is_empty());
1539 }
1540 }
1541
1542 #[test]
1543 fn test_design_docs_embedded() {
1544 assert!(!DESIGN_DOCS.is_empty());
1545 for (name, content) in DESIGN_DOCS {
1546 assert!(!name.is_empty());
1547 assert!(!content.is_empty());
1548 }
1549 }
1550
1551 #[test]
1552 fn test_reviewer_prompts_embedded() {
1553 assert_eq!(REVIEWER_PROMPTS.len(), 2);
1554 assert!(REVIEWER_PROMPTS.iter().any(|(n, _)| *n == "reviewer.md"));
1555 assert!(
1556 REVIEWER_PROMPTS
1557 .iter()
1558 .any(|(n, _)| *n == "reviewer-security.md")
1559 );
1560 }
1561}