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_beads_to_bones(&project_root, &config_path)?;
243 }
244
245 if !changed_files.is_empty() && !self.no_commit {
247 self.auto_commit(&project_root, &changed_files)?;
248 }
249
250 println!("Sync complete");
251 Ok(())
252 }
253
254 fn handle_bare_repo(&self, project_root: &Path) -> Result<()> {
255 let project_root = project_root
257 .canonicalize()
258 .context("canonicalizing project root")?;
259
260 if crate::config::find_config(&project_root).is_none()
262 && crate::config::find_config(&project_root.join("ws/default")).is_none()
263 {
264 anyhow::bail!(
265 "not an edict project: no .edict.toml or .botbox.toml found in {}",
266 project_root.display()
267 );
268 }
269
270 let mut args = vec!["exec", "default", "--", "edict", "sync"];
271 if self.check {
272 args.push("--check");
273 }
274 if self.no_commit {
275 args.push("--no-commit");
276 }
277
278 run_command("maw", &args, Some(&project_root))?;
279
280 let ws_has_config = crate::config::find_config(&project_root.join("ws/default")).is_some();
288 for stale_name in &[crate::config::CONFIG_JSON, crate::config::CONFIG_TOML_LEGACY] {
289 let stale_path = project_root.join(stale_name);
290 if stale_path.exists() && ws_has_config {
291 if self.check {
292 tracing::warn!("stale {stale_name} at bare repo root (will be removed on sync)");
293 return Err(
294 ExitError::new(1, format!("Stale {stale_name} at bare repo root")).into(),
295 );
296 } else {
297 match fs::remove_file(&stale_path) {
298 Ok(()) => println!(
299 "Removed stale {stale_name} from bare repo root \
300 (authoritative config lives in ws/default/)"
301 ),
302 Err(e) => {
303 tracing::warn!("failed to remove stale {stale_name} at bare root: {e}")
304 }
305 }
306 }
307 }
308 }
309
310 let stub_agents = project_root.join("AGENTS.md");
312 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";
313
314 if !stub_agents.exists() {
315 fs::write(&stub_agents, stub_content)?;
316 println!("Created bare-root AGENTS.md stub");
317 }
318
319 let root_claude_dir = project_root.join(".claude");
321 let ws_claude_dir = project_root.join("ws/default/.claude");
322
323 if ws_claude_dir.exists() {
324 let needs_symlink = match fs::read_link(&root_claude_dir) {
326 Ok(target) => target != Path::new("ws/default/.claude"),
327 Err(_) => true,
328 };
329
330 if needs_symlink {
331 let tmp_link = project_root.join(".claude.tmp");
333 let _ = fs::remove_file(&tmp_link); #[cfg(unix)]
335 std::os::unix::fs::symlink("ws/default/.claude", &tmp_link)?;
336 #[cfg(windows)]
337 std::os::windows::fs::symlink_dir("ws/default/.claude", &tmp_link)?;
338
339 if let Err(e) = fs::rename(&tmp_link, &root_claude_dir) {
341 let _ = fs::remove_file(&tmp_link);
342 return Err(e).context("creating .claude symlink");
343 }
344 println!("Symlinked .claude → ws/default/.claude");
345 }
346 }
347
348 let root_pi_dir = project_root.join(".pi");
350 let ws_pi_dir = project_root.join("ws/default/.pi");
351
352 if ws_pi_dir.exists() {
353 let needs_symlink = match fs::read_link(&root_pi_dir) {
354 Ok(target) => target != Path::new("ws/default/.pi"),
355 Err(_) => true,
356 };
357
358 if needs_symlink {
359 let tmp_link = project_root.join(".pi.tmp");
360 let _ = fs::remove_file(&tmp_link);
361 #[cfg(unix)]
362 std::os::unix::fs::symlink("ws/default/.pi", &tmp_link)?;
363 #[cfg(windows)]
364 std::os::windows::fs::symlink_dir("ws/default/.pi", &tmp_link)?;
365
366 if let Err(e) = fs::rename(&tmp_link, &root_pi_dir) {
367 let _ = fs::remove_file(&tmp_link);
368 return Err(e).context("creating .pi symlink");
369 }
370 println!("Symlinked .pi → ws/default/.pi");
371 }
372 }
373
374 Ok(())
375 }
376
377 fn cleanup_legacy_artifacts(&self, agents_dir: &Path, changed_files: &mut Vec<&str>) {
381 let scripts_dir = agents_dir.join("scripts");
383 if scripts_dir.is_dir() {
384 if self.check {
385 tracing::warn!("legacy scripts/ directory exists (will be removed on sync)");
386 } else {
387 match fs::remove_dir_all(&scripts_dir) {
388 Ok(_) => {
389 println!("Removed legacy scripts/ directory");
390 changed_files.push(".agents/botbox/scripts/");
391 }
392 Err(e) => tracing::warn!("failed to remove legacy scripts/: {e}"),
393 }
394 }
395 }
396
397 let hooks_dir = agents_dir.join("hooks");
399 if hooks_dir.is_dir() {
400 if self.check {
401 tracing::warn!("legacy hooks/ directory exists (will be removed on sync)");
402 } else {
403 match fs::remove_dir_all(&hooks_dir) {
404 Ok(_) => {
405 println!("Removed legacy hooks/ directory");
406 changed_files.push(".agents/botbox/hooks/");
407 }
408 Err(e) => tracing::warn!("failed to remove legacy hooks/: {e}"),
409 }
410 }
411 }
412
413 for marker in &[".scripts-version", ".hooks-version"] {
415 let path = agents_dir.join(marker);
416 if path.exists() && !self.check {
417 let _ = fs::remove_file(&path);
418 }
419 }
420 }
421
422 fn check_docs_staleness(&self, agents_dir: &Path) -> Result<bool> {
423 let version_file = agents_dir.join(".version");
424 let current = compute_docs_version();
425
426 if !version_file.exists() {
427 return Ok(true);
428 }
429
430 let installed = fs::read_to_string(&version_file)?.trim().to_string();
431 Ok(installed != current)
432 }
433
434 fn check_managed_section_staleness(
435 &self,
436 project_root: &Path,
437 config: &Config,
438 ) -> Result<bool> {
439 let agents_md = project_root.join("AGENTS.md");
440 if !agents_md.exists() {
441 return Ok(false); }
443
444 let content = fs::read_to_string(&agents_md)?;
445 let ctx = TemplateContext::from_config(config);
446 let updated = update_managed_section(&content, &ctx)?;
447
448 Ok(content != updated)
449 }
450
451 fn check_prompts_staleness(&self, agents_dir: &Path) -> Result<bool> {
452 let version_file = agents_dir.join("prompts/.prompts-version");
453 let current = compute_prompts_version();
454
455 if !version_file.exists() {
456 return Ok(true);
457 }
458
459 let installed = fs::read_to_string(&version_file)?.trim().to_string();
460 Ok(installed != current)
461 }
462
463 fn cleanup_per_repo_hooks(&self, project_root: &Path) -> Result<()> {
466 if self.check {
467 return Ok(());
468 }
469
470 let settings_path = project_root.join(".claude/settings.json");
472 if settings_path.exists() {
473 let content = fs::read_to_string(&settings_path)?;
474 if let Ok(mut settings) = serde_json::from_str::<serde_json::Value>(&content) {
475 let mut changed = false;
476 if let Some(hooks) = settings.get_mut("hooks").and_then(|h| h.as_object_mut()) {
477 for (_event, entries) in hooks.iter_mut() {
478 if let Some(arr) = entries.as_array_mut() {
479 let before = arr.len();
480 arr.retain(|entry| {
481 !entry["hooks"]
482 .as_array()
483 .is_some_and(|hooks| {
484 hooks.iter().any(|h| {
485 let cmd = &h["command"];
486 if let Some(s) = cmd.as_str() {
487 s.contains("botbox hooks run")
488 } else if let Some(a) = cmd.as_array() {
489 a.len() >= 3
490 && a[0].as_str() == Some("botbox")
491 && a[1].as_str() == Some("hooks")
492 && a[2].as_str() == Some("run")
493 } else {
494 false
495 }
496 })
497 })
498 });
499 if arr.len() != before {
500 changed = true;
501 }
502 }
503 }
504 hooks.retain(|_, v| {
506 v.as_array().map(|a| !a.is_empty()).unwrap_or(true)
507 });
508 }
509
510 if changed {
511 if settings
513 .get("hooks")
514 .and_then(|h| h.as_object())
515 .is_some_and(|h| h.is_empty())
516 {
517 settings.as_object_mut().unwrap().remove("hooks");
518 }
519
520 if settings.as_object().is_some_and(|o| o.is_empty()) {
522 fs::remove_file(&settings_path)?;
523 let claude_dir = project_root.join(".claude");
525 if claude_dir.exists() && fs::read_dir(&claude_dir)?.next().is_none() {
526 fs::remove_dir(&claude_dir)?;
527 }
528 } else {
529 fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
530 }
531 println!("Cleaned up per-repo botbox hooks from .claude/settings.json (now managed globally via `botbox hooks install`)");
532 }
533 }
534 }
535
536 let pi_ext = project_root.join(".pi/extensions/botbox-hooks.ts");
538 if pi_ext.exists() {
539 fs::remove_file(&pi_ext)?;
540 let pi_ext_dir = project_root.join(".pi/extensions");
542 if pi_ext_dir.exists() && fs::read_dir(&pi_ext_dir)?.next().is_none() {
543 fs::remove_dir(&pi_ext_dir)?;
544 }
545 let pi_dir = project_root.join(".pi");
546 if pi_dir.exists() && fs::read_dir(&pi_dir)?.next().is_none() {
547 fs::remove_dir(&pi_dir)?;
548 }
549 println!("Cleaned up per-repo Pi extension (now managed globally via `botbox hooks install`)");
550 }
551
552 Ok(())
553 }
554
555 fn check_design_docs_staleness(&self, agents_dir: &Path) -> Result<bool> {
556 let version_file = agents_dir.join("design/.design-docs-version");
557 let current = compute_design_docs_version();
558
559 if !version_file.exists() {
560 return Ok(true);
561 }
562
563 let installed = fs::read_to_string(&version_file)?.trim().to_string();
564 Ok(installed != current)
565 }
566
567 fn sync_workflow_docs(&self, agents_dir: &Path) -> Result<()> {
568 for (name, content) in WORKFLOW_DOCS {
569 let path = agents_dir.join(name);
570 fs::write(&path, content)
571 .with_context(|| format!("Failed to write {}", path.display()))?;
572 }
573
574 let version = compute_docs_version();
575 fs::write(agents_dir.join(".version"), version)?;
576
577 Ok(())
578 }
579
580 fn sync_managed_section(&self, project_root: &Path, config: &Config) -> Result<()> {
581 let agents_md = project_root.join("AGENTS.md");
582 if !agents_md.exists() {
583 return Ok(()); }
585
586 let content = fs::read_to_string(&agents_md)?;
587 let ctx = TemplateContext::from_config(config);
588 let updated = update_managed_section(&content, &ctx)?;
589
590 fs::write(&agents_md, updated)?;
591 Ok(())
592 }
593
594 fn sync_prompts(&self, agents_dir: &Path) -> Result<()> {
595 let prompts_dir = agents_dir.join("prompts");
596 fs::create_dir_all(&prompts_dir)?;
597
598 for (name, content) in REVIEWER_PROMPTS {
599 let path = prompts_dir.join(name);
600 fs::write(&path, content)
601 .with_context(|| format!("Failed to write {}", path.display()))?;
602 }
603
604 let version = compute_prompts_version();
605 fs::write(prompts_dir.join(".prompts-version"), version)?;
606
607 Ok(())
608 }
609
610 fn sync_design_docs(&self, agents_dir: &Path) -> Result<()> {
613 let design_dir = agents_dir.join("design");
614 fs::create_dir_all(&design_dir)?;
615
616 for (name, content) in DESIGN_DOCS {
617 let path = design_dir.join(name);
618 fs::write(&path, content)
619 .with_context(|| format!("Failed to write {}", path.display()))?;
620 }
621
622 let version = compute_design_docs_version();
623 fs::write(design_dir.join(".design-docs-version"), version)?;
624
625 Ok(())
626 }
627
628 fn auto_commit(&self, project_root: &Path, changed_files: &[&str]) -> Result<()> {
629 let vcs = detect_vcs(project_root);
631 if vcs == Vcs::None {
632 return Ok(()); }
634
635 let managed_paths: &[&str] = &[
637 ".agents/botbox/",
638 "AGENTS.md",
639 ".critignore",
640 ".botbox.toml",
641 ".botbox.json",
642 ".gitignore",
643 ];
644
645 let files_str: String = changed_files
647 .join(", ")
648 .chars()
649 .filter(|c| !c.is_control())
650 .collect();
651 let message = format!("chore: edict sync (updated {})", files_str);
652
653 match vcs {
654 Vcs::Jj => {
655 run_command("jj", &["describe", "-m", &message], Some(project_root))?;
656 run_command("jj", &["new", "-m", ""], Some(project_root))?;
658 run_command(
659 "jj",
660 &["bookmark", "set", "main", "-r", "@-"],
661 Some(project_root),
662 )?;
663 }
664 Vcs::Git => {
665 let existing: Vec<&str> = managed_paths
667 .iter()
668 .copied()
669 .filter(|p| project_root.join(p).exists())
670 .collect();
671 if existing.is_empty() {
672 return Ok(());
673 }
674 let mut args = vec!["add", "--"];
675 args.extend_from_slice(&existing);
676 run_command("git", &args, Some(project_root))?;
677
678 let status = run_command(
680 "git",
681 &["diff", "--cached", "--quiet"],
682 Some(project_root),
683 );
684 if status.is_err() {
685 run_command("git", &["commit", "-m", &message], Some(project_root))?;
687 }
688 }
689 Vcs::None => unreachable!(),
690 }
691
692 Ok(())
693 }
694}
695
696fn migrate_botbox_bus_hooks_to_edict(config: &Config, project_root: &Path) {
702 let output = match Tool::new("bus")
703 .args(&["hooks", "list", "--format", "json"])
704 .run()
705 {
706 Ok(o) if o.success() => o,
707 _ => return,
708 };
709
710 let parsed: serde_json::Value = match serde_json::from_str(&output.stdout) {
711 Ok(v) => v,
712 Err(_) => return,
713 };
714
715 let hooks = match parsed.get("hooks").and_then(|h| h.as_array()) {
716 Some(h) => h,
717 None => return,
718 };
719
720 let name = &config.project.name;
721
722 let bare_root = if project_root.ends_with("ws/default") {
724 project_root
725 .parent()
726 .and_then(Path::parent)
727 .filter(|r| r.join(".manifold").exists())
728 } else if project_root.join(".manifold").exists() {
729 Some(project_root)
730 } else {
731 None
732 };
733 let root_str = bare_root
734 .map(|r| r.display().to_string())
735 .unwrap_or_else(|| project_root.display().to_string());
736
737 for hook in hooks {
738 let desc = hook
739 .get("description")
740 .and_then(|d| d.as_str())
741 .unwrap_or("");
742
743 if !desc.starts_with(&format!("botbox:{name}:")) {
745 continue;
746 }
747
748 let id = match hook.get("id").and_then(|i| i.as_str()) {
749 Some(id) => id,
750 None => continue,
751 };
752
753 if Tool::new("bus")
755 .args(&["hooks", "remove", id])
756 .run()
757 .is_err()
758 {
759 tracing::warn!(hook_id = %id, "failed to remove botbox-era hook during edict migration");
760 continue;
761 }
762
763 let agent = config.default_agent();
764 if desc.ends_with(":responder") {
765 let responder_ml = config
766 .agents
767 .responder
768 .as_ref()
769 .and_then(|r| r.memory_limit.as_deref());
770 super::init::register_router_hook(&root_str, &root_str, name, &agent, responder_ml);
771 println!(" Migrated hook {desc} → edict:{name}:responder");
772 } else if let Some(role) = desc.strip_prefix(&format!("botbox:{name}:reviewer-")) {
773 let reviewer_agent = format!("{name}-{role}");
774 let reviewer_ml = config
775 .agents
776 .reviewer
777 .as_ref()
778 .and_then(|r| r.memory_limit.as_deref());
779 super::init::register_reviewer_hook(
780 &root_str,
781 &root_str,
782 name,
783 &agent,
784 &reviewer_agent,
785 reviewer_ml,
786 );
787 println!(" Migrated hook {desc} → edict:{name}:reviewer-{role}");
788 }
789 }
790}
791
792fn migrate_bus_hooks(config: &Config) {
799 let output = match Tool::new("bus")
800 .args(&["hooks", "list", "--format", "json"])
801 .run()
802 {
803 Ok(o) if o.success() => o,
804 _ => return, };
806
807 let parsed: serde_json::Value = match serde_json::from_str(&output.stdout) {
808 Ok(v) => v,
809 Err(_) => return,
810 };
811
812 let hooks = match parsed.get("hooks").and_then(|h| h.as_array()) {
813 Some(h) => h,
814 None => return,
815 };
816
817 let name = &config.project.name;
818 let agent = config.default_agent();
819 let env_inherit = "BOTBUS_CHANNEL,BOTBUS_MESSAGE_ID,BOTBUS_HOOK_ID,SSH_AUTH_SOCK,OTEL_EXPORTER_OTLP_ENDPOINT,TRACEPARENT";
820
821 for hook in hooks {
822 let id = match hook.get("id").and_then(|i| i.as_str()) {
823 Some(id) => id.to_string(),
824 None => continue,
825 };
826
827 let channel = hook.get("channel").and_then(|c| c.as_str()).unwrap_or("");
828
829 if channel != name {
831 continue;
832 }
833
834 let existing_desc = hook
837 .get("description")
838 .and_then(|d| d.as_str())
839 .unwrap_or("");
840 if existing_desc.starts_with("edict:") || existing_desc.starts_with("botbox:") {
841 continue;
842 }
843
844 let cmd = hook.get("command").and_then(|c| c.as_array());
845 let cmd = match cmd {
846 Some(c) => c,
847 None => continue,
848 };
849
850 let cmd_strs: Vec<&str> = cmd.iter().filter_map(|v| v.as_str()).collect();
851
852 let is_router = cmd_strs.iter().any(|s| {
854 s.contains("responder") || s.contains("respond.mjs") || s.contains("router.mjs")
855 });
856 let is_reviewer = cmd_strs
857 .iter()
858 .any(|s| s.contains("reviewer-loop") || s.contains("reviewer-loop.mjs"));
859
860 if !is_router && !is_reviewer {
861 continue;
862 }
863
864 let spawn_cwd = cmd_strs
865 .windows(2)
866 .find(|w| w[0] == "--cwd")
867 .map(|w| w[1])
868 .unwrap_or(".");
869
870 let remove = Tool::new("bus").args(&["hooks", "remove", &id]).run();
873
874 if remove.is_err() || !remove.as_ref().unwrap().success() {
875 tracing::warn!(hook_id = %id, "failed to remove legacy hook");
876 continue;
877 }
878
879 if is_router {
880 let claim_uri = format!("agent://{name}-dev");
881 let spawn_name = format!("{name}-responder");
882 let description = format!("edict:{name}:responder");
883 let responder_ml = config
884 .agents
885 .responder
886 .as_ref()
887 .and_then(|r| r.memory_limit.as_deref());
888
889 let mut router_args: Vec<&str> = vec![
890 "--agent",
891 &agent,
892 "--channel",
893 name,
894 "--claim",
895 &claim_uri,
896 "--claim-owner",
897 &agent,
898 "--cwd",
899 spawn_cwd,
900 "--ttl",
901 "600",
902 "--",
903 "botty",
904 "spawn",
905 "--env-inherit",
906 env_inherit,
907 ];
908 if let Some(limit) = responder_ml {
909 router_args.push("--memory-limit");
910 router_args.push(limit);
911 }
912 router_args.extend_from_slice(&[
913 "--name",
914 &spawn_name,
915 "--cwd",
916 spawn_cwd,
917 "--",
918 "edict",
919 "run",
920 "responder",
921 ]);
922
923 match crate::subprocess::ensure_bus_hook(&description, &router_args) {
924 Ok(_) => println!(" Migrated router hook {id} → edict run responder"),
925 Err(e) => tracing::warn!("failed to re-register router hook: {e}"),
926 }
927 } else if is_reviewer {
928 let reviewer_agent = hook
929 .get("condition")
930 .and_then(|c| c.get("agent"))
931 .and_then(|a| a.as_str())
932 .unwrap_or("")
933 .to_string();
934
935 if reviewer_agent.is_empty() {
936 tracing::warn!(hook_id = %id, "could not determine reviewer agent for hook");
937 continue;
938 }
939
940 let role = reviewer_agent
941 .strip_prefix(&format!("{name}-"))
942 .unwrap_or(&reviewer_agent);
943 let claim_uri = format!("agent://{reviewer_agent}");
944 let description = format!("edict:{name}:reviewer-{role}");
945 let reviewer_ml = config
946 .agents
947 .reviewer
948 .as_ref()
949 .and_then(|r| r.memory_limit.as_deref());
950
951 let mut reviewer_args: Vec<&str> = vec![
952 "--agent",
953 &agent,
954 "--channel",
955 name,
956 "--mention",
957 &reviewer_agent,
958 "--claim",
959 &claim_uri,
960 "--claim-owner",
961 &reviewer_agent,
962 "--ttl",
963 "600",
964 "--priority",
965 "1",
966 "--cwd",
967 spawn_cwd,
968 "--",
969 "botty",
970 "spawn",
971 "--env-inherit",
972 env_inherit,
973 ];
974 if let Some(limit) = reviewer_ml {
975 reviewer_args.push("--memory-limit");
976 reviewer_args.push(limit);
977 }
978 reviewer_args.extend_from_slice(&[
979 "--name",
980 &reviewer_agent,
981 "--cwd",
982 spawn_cwd,
983 "--",
984 "edict",
985 "run",
986 "reviewer-loop",
987 "--agent",
988 &reviewer_agent,
989 ]);
990
991 match crate::subprocess::ensure_bus_hook(&description, &reviewer_args) {
992 Ok(_) => println!(
993 " Migrated reviewer hook {id} → edict run reviewer-loop --agent {reviewer_agent}"
994 ),
995 Err(e) => tracing::warn!(agent = %reviewer_agent, "failed to re-register reviewer hook: {e}"),
996 }
997 }
998 }
999}
1000
1001fn migrate_hook_cwd(config: &Config, project_root: &Path) {
1007 let bare_root = if project_root.ends_with("ws/default") {
1009 project_root.parent().and_then(Path::parent)
1010 } else if project_root.join(".manifold").exists() {
1011 Some(project_root)
1012 } else {
1013 None
1014 };
1015
1016 let bare_root = match bare_root {
1017 Some(r) if r.join(".manifold").exists() => r,
1018 _ => return,
1019 };
1020
1021 let ws_default_str = bare_root
1022 .join("ws")
1023 .join("default")
1024 .display()
1025 .to_string();
1026 let root_str = bare_root.display().to_string();
1027
1028 let output = match Tool::new("bus")
1029 .args(&["hooks", "list", "--format", "json"])
1030 .run()
1031 {
1032 Ok(o) if o.success() => o,
1033 _ => return,
1034 };
1035
1036 let parsed: serde_json::Value = match serde_json::from_str(&output.stdout) {
1037 Ok(v) => v,
1038 Err(_) => return,
1039 };
1040
1041 let hooks = match parsed.get("hooks").and_then(|h| h.as_array()) {
1042 Some(h) => h,
1043 None => return,
1044 };
1045
1046 let name = &config.project.name;
1047 let agent = config.default_agent();
1048 let reviewers: Vec<String> = config
1049 .review
1050 .reviewers
1051 .iter()
1052 .map(|r| format!("{name}-{r}"))
1053 .collect();
1054
1055 for hook in hooks {
1056 let desc = hook
1057 .get("description")
1058 .and_then(|d| d.as_str())
1059 .unwrap_or("");
1060 let is_ours = desc.starts_with(&format!("edict:{name}:"))
1062 || desc.starts_with(&format!("botbox:{name}:"));
1063 if !is_ours {
1064 continue;
1065 }
1066
1067 let cmd = match hook.get("command").and_then(|c| c.as_array()) {
1068 Some(c) => c,
1069 None => continue,
1070 };
1071 let cmd_strs: Vec<&str> = cmd.iter().filter_map(|v| v.as_str()).collect();
1072
1073 let has_stale_cwd = cmd_strs
1075 .windows(2)
1076 .any(|w| w[0] == "--cwd" && w[1] == ws_default_str);
1077 if !has_stale_cwd {
1078 continue;
1079 }
1080
1081 let id = match hook.get("id").and_then(|i| i.as_str()) {
1083 Some(id) => id,
1084 None => continue,
1085 };
1086
1087 if Tool::new("bus")
1089 .args(&["hooks", "remove", id])
1090 .run()
1091 .is_err()
1092 {
1093 continue;
1094 }
1095
1096 let is_router = desc.ends_with(":responder");
1097 if is_router {
1098 let responder_ml = config
1099 .agents
1100 .responder
1101 .as_ref()
1102 .and_then(|r| r.memory_limit.as_deref());
1103 super::init::register_router_hook(&root_str, &root_str, name, &agent, responder_ml);
1104 println!(" Fixed hook --cwd: {desc} → repo root");
1105 } else {
1106 let reviewer_ml = config
1107 .agents
1108 .reviewer
1109 .as_ref()
1110 .and_then(|r| r.memory_limit.as_deref());
1111 for reviewer in &reviewers {
1113 if desc.contains(&reviewer.replace(&format!("{name}-"), "")) {
1114 super::init::register_reviewer_hook(
1115 &root_str, &root_str, name, &agent, reviewer, reviewer_ml,
1116 );
1117 println!(" Fixed hook --cwd: {desc} → repo root");
1118 break;
1119 }
1120 }
1121 }
1122 }
1123}
1124
1125fn migrate_router_hook_claim(config: &Config, project_root: &Path) {
1132 let output = match Tool::new("bus")
1133 .args(&["hooks", "list", "--format", "json"])
1134 .run()
1135 {
1136 Ok(o) if o.success() => o,
1137 _ => return,
1138 };
1139
1140 let parsed: serde_json::Value = match serde_json::from_str(&output.stdout) {
1141 Ok(v) => v,
1142 Err(_) => return,
1143 };
1144
1145 let hooks = match parsed.get("hooks").and_then(|h| h.as_array()) {
1146 Some(h) => h,
1147 None => return,
1148 };
1149
1150 let name = &config.project.name;
1151 let old_claim = format!("agent://{name}-router");
1152
1153 for hook in hooks {
1154 let desc = hook
1155 .get("description")
1156 .and_then(|d| d.as_str())
1157 .unwrap_or("");
1158 if desc != format!("edict:{name}:responder") && desc != format!("botbox:{name}:responder") {
1159 continue;
1160 }
1161
1162 let claim = hook
1164 .get("condition")
1165 .and_then(|c| c.get("pattern"))
1166 .and_then(|p| p.as_str())
1167 .unwrap_or("");
1168 if claim != old_claim {
1169 continue;
1170 }
1171
1172 let id = match hook.get("id").and_then(|i| i.as_str()) {
1173 Some(id) => id,
1174 None => continue,
1175 };
1176
1177 if Tool::new("bus")
1179 .args(&["hooks", "remove", id])
1180 .run()
1181 .is_err()
1182 {
1183 continue;
1184 }
1185
1186 let agent = config.default_agent();
1187 let bare_root = if project_root.ends_with("ws/default") {
1189 project_root
1190 .parent()
1191 .and_then(Path::parent)
1192 .filter(|r| r.join(".manifold").exists())
1193 } else if project_root.join(".manifold").exists() {
1194 Some(project_root)
1195 } else {
1196 None
1197 };
1198 let root_str = bare_root
1199 .map(|r| r.display().to_string())
1200 .unwrap_or_else(|| project_root.display().to_string());
1201 let responder_ml = config
1202 .agents
1203 .responder
1204 .as_ref()
1205 .and_then(|r| r.memory_limit.as_deref());
1206 super::init::register_router_hook(&root_str, &root_str, name, &agent, responder_ml);
1207 println!(" Migrated router hook claim: agent://{name}-router → agent://{name}-dev");
1208 }
1209}
1210
1211fn migrate_beads_to_bones(project_root: &Path, config_path: &Path) -> Result<()> {
1215 let beads_dir = project_root.join(".beads");
1216 let bones_dir = project_root.join(".bones");
1217
1218 if config_path.exists() {
1221 let content = fs::read_to_string(config_path)?;
1222 if content.contains("beads") && !content.contains("bones") {
1223 let updated = content.replace("beads = ", "bones = ");
1224 fs::write(config_path, updated)?;
1225 println!("Migrated config: tools.beads → tools.bones");
1226 }
1227 }
1228
1229 if beads_dir.exists() && !bones_dir.exists() {
1231 let beads_db = beads_dir.join("beads.db");
1232 match run_command("bn", &["init"], Some(project_root)) {
1234 Ok(_) => println!("Initialized bones"),
1235 Err(e) => tracing::warn!("bn init failed: {e}"),
1236 }
1237 if beads_db.exists() {
1239 let db_path = beads_db.to_string_lossy().to_string();
1240 match run_command(
1241 "bn",
1242 &["data", "migrate-from-beads", "--beads-db", &db_path],
1243 Some(project_root),
1244 ) {
1245 Ok(_) => println!("Migrated beads data to bones"),
1246 Err(e) => tracing::warn!("beads data migration failed: {e}"),
1247 }
1248 }
1249 }
1250
1251 let maw_toml = project_root.join(".maw.toml");
1253 if maw_toml.exists() {
1254 let content = fs::read_to_string(&maw_toml)?;
1255 if content.contains(".beads/") {
1256 let updated = content
1258 .lines()
1259 .map(|line| {
1260 if line.contains(".beads/") {
1261 None
1263 } else {
1264 Some(line)
1265 }
1266 })
1267 .flatten()
1268 .collect::<Vec<_>>()
1269 .join("\n");
1270 let updated = updated.replace(
1272 "auto_resolve_from_main = [\n]",
1273 "auto_resolve_from_main = []",
1274 );
1275 fs::write(&maw_toml, format!("{updated}\n"))?;
1276 println!("Updated .maw.toml: removed .beads/** entry");
1277 }
1278 }
1279
1280 let critignore = project_root.join(".critignore");
1282 if critignore.exists() {
1283 let content = fs::read_to_string(&critignore)?;
1284 if content.contains(".beads/") {
1285 let updated: String = content
1286 .lines()
1287 .filter(|line| line.trim() != ".beads/")
1288 .collect::<Vec<_>>()
1289 .join("\n");
1290 let updated = if content.ends_with('\n') {
1291 format!("{updated}\n")
1292 } else {
1293 updated
1294 };
1295 fs::write(&critignore, updated)?;
1296 println!("Updated .critignore: removed .beads/ entry");
1297 }
1298 }
1299
1300 let gitignore = project_root.join(".gitignore");
1302 if gitignore.exists() {
1303 let content = fs::read_to_string(&gitignore)?;
1304 if content.contains(".bv/") {
1305 let updated: String = content
1306 .lines()
1307 .filter(|line| line.trim() != ".bv/")
1308 .collect::<Vec<_>>()
1309 .join("\n");
1310 let updated = if content.ends_with('\n') {
1312 format!("{updated}\n")
1313 } else {
1314 updated
1315 };
1316 fs::write(&gitignore, updated)?;
1317 println!("Updated .gitignore: removed .bv/ entry");
1318 }
1319 }
1320
1321 Ok(())
1322}
1323
1324#[derive(Debug, PartialEq, Eq)]
1326enum Vcs {
1327 Jj,
1328 Git,
1329 None,
1330}
1331
1332fn detect_vcs(project_root: &Path) -> Vcs {
1336 if find_jj_root(project_root).is_some() {
1337 return Vcs::Jj;
1338 }
1339 if project_root
1341 .ancestors()
1342 .any(|p| p.join(".git").exists())
1343 {
1344 return Vcs::Git;
1345 }
1346 Vcs::None
1347}
1348
1349fn find_jj_root(from: &Path) -> Option<PathBuf> {
1352 from.ancestors()
1353 .find(|p| p.join(".jj").is_dir())
1354 .map(|p| p.to_path_buf())
1355}
1356
1357fn compute_docs_version() -> String {
1359 let mut hasher = Sha256::new();
1360 for (name, content) in WORKFLOW_DOCS {
1361 hasher.update(name.as_bytes());
1362 hasher.update(content.as_bytes());
1363 }
1364 format!("{:x}", hasher.finalize())[..32].to_string()
1365}
1366
1367fn compute_prompts_version() -> String {
1369 let mut hasher = Sha256::new();
1370 for (name, content) in REVIEWER_PROMPTS {
1371 hasher.update(name.as_bytes());
1372 hasher.update(content.as_bytes());
1373 }
1374 format!("{:x}", hasher.finalize())[..32].to_string()
1375}
1376
1377fn compute_design_docs_version() -> String {
1379 let mut hasher = Sha256::new();
1380 for (name, content) in DESIGN_DOCS {
1381 hasher.update(name.as_bytes());
1382 hasher.update(content.as_bytes());
1383 }
1384 format!("{:x}", hasher.finalize())[..32].to_string()
1385}
1386
1387#[cfg(test)]
1388mod tests {
1389 use super::*;
1390
1391 #[test]
1392 fn test_find_jj_root_direct() {
1393 let dir = tempfile::tempdir().unwrap();
1394 let jj = dir.path().join(".jj");
1395 fs::create_dir(&jj).unwrap();
1396 assert_eq!(find_jj_root(dir.path()), Some(dir.path().to_path_buf()));
1398 }
1399
1400 #[test]
1401 fn test_find_jj_root_ancestor() {
1402 let dir = tempfile::tempdir().unwrap();
1403 let jj = dir.path().join(".jj");
1404 fs::create_dir(&jj).unwrap();
1405 let ws = dir.path().join("ws/default");
1406 fs::create_dir_all(&ws).unwrap();
1407 assert_eq!(find_jj_root(&ws), Some(dir.path().to_path_buf()));
1409 }
1410
1411 #[test]
1412 fn test_find_jj_root_missing() {
1413 let dir = tempfile::tempdir().unwrap();
1414 assert_eq!(find_jj_root(dir.path()), None);
1416 }
1417
1418 #[test]
1419 fn test_version_hashes() {
1420 let docs_ver = compute_docs_version();
1421 assert_eq!(docs_ver.len(), 32);
1422 assert!(docs_ver.chars().all(|c| c.is_ascii_hexdigit()));
1423
1424 let prompts_ver = compute_prompts_version();
1425 assert_eq!(prompts_ver.len(), 32);
1426 assert!(prompts_ver.chars().all(|c| c.is_ascii_hexdigit()));
1427
1428 let design_ver = compute_design_docs_version();
1429 assert_eq!(design_ver.len(), 32);
1430 assert!(design_ver.chars().all(|c| c.is_ascii_hexdigit()));
1431 }
1432
1433 #[test]
1434 fn test_workflow_docs_embedded() {
1435 assert!(!WORKFLOW_DOCS.is_empty());
1436 for (name, content) in WORKFLOW_DOCS {
1437 assert!(!name.is_empty());
1438 assert!(!content.is_empty());
1439 }
1440 }
1441
1442 #[test]
1443 fn test_design_docs_embedded() {
1444 assert!(!DESIGN_DOCS.is_empty());
1445 for (name, content) in DESIGN_DOCS {
1446 assert!(!name.is_empty());
1447 assert!(!content.is_empty());
1448 }
1449 }
1450
1451 #[test]
1452 fn test_reviewer_prompts_embedded() {
1453 assert_eq!(REVIEWER_PROMPTS.len(), 2);
1454 assert!(REVIEWER_PROMPTS.iter().any(|(n, _)| *n == "reviewer.md"));
1455 assert!(
1456 REVIEWER_PROMPTS
1457 .iter()
1458 .any(|(n, _)| *n == "reviewer-security.md")
1459 );
1460 }
1461}