Skip to main content

edict/commands/
sync.rs

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    /// Project root directory
16    #[arg(long)]
17    pub project_root: Option<PathBuf>,
18    /// Check mode: exit non-zero if anything is stale, without making changes
19    #[arg(long)]
20    pub check: bool,
21    /// Disable auto-commit (default: enabled)
22    #[arg(long)]
23    pub no_commit: bool,
24}
25
26/// Embedded workflow docs
27pub(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
75/// Embedded design docs
76pub(crate) const DESIGN_DOCS: &[(&str, &str)] = &[(
77    "cli-conventions.md",
78    include_str!("../templates/design/cli-conventions.md"),
79)];
80
81/// Embedded reviewer prompts
82pub(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        // Detect maw v2 bare repo
101        if crate::config::find_config(&project_root.join("ws/default")).is_some() {
102            return self.handle_bare_repo(&project_root);
103        }
104
105        // Check for agents dir — accept new (.agents/edict/) or legacy (.agents/botbox/)
106        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        // Load config (.edict.toml preferred, legacy names as fallback)
116        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        // Migrate .botbox.json -> .edict.toml if needed (JSON is oldest legacy)
123        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        // Migrate .botbox.toml -> .edict.toml (botbox era → edict era)
140        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        // Migrate .agents/botbox/ -> .agents/edict/ (botbox era → edict era)
149        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        // Resolved agents dir (after any migration above)
157        let agents_dir = if agents_dir_edict.exists() {
158            agents_dir_edict
159        } else {
160            agents_dir_legacy
161        };
162
163        // Check staleness for each component
164        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        // Clean up per-repo hooks (now managed globally)
196        self.cleanup_per_repo_hooks(&project_root)?;
197
198        // Perform updates
199        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        // Clean up legacy JS artifacts (scripts, shell hooks)
226        self.cleanup_legacy_artifacts(&agents_dir, &mut changed_files);
227
228        // Migrate bus hooks from bun .mjs to edict run
229        migrate_bus_hooks(&config);
230
231        // Migrate bus hooks from botbox: descriptions to edict: descriptions
232        migrate_botbox_bus_hooks_to_edict(&config, &project_root);
233
234        // Fix hook --cwd for maw v2 (ws/default → repo root)
235        migrate_hook_cwd(&config, &project_root);
236
237        // Migrate router hook claim from agent://{name}-router → agent://{name}-dev
238        migrate_router_hook_claim(&config, &project_root);
239
240        // Migrate beads → bones (config, data, tooling files)
241        if !self.check {
242            migrate_beads_to_bones(&project_root, &config_path)?;
243        }
244
245        // Auto-commit if changes were made
246        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        // Canonicalize project_root to prevent path traversal
256        let project_root = project_root
257            .canonicalize()
258            .context("canonicalizing project root")?;
259
260        // Validate this is actually an edict project
261        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        // Clean up stale legacy config files at bare repo root.
281        //
282        // After migration runs inside ws/default/, the bare root may still have stale
283        // .botbox.json or .botbox.toml files. Agents resolving config from the project root
284        // would find these before the authoritative ws/default/.edict.toml.
285        //
286        // Only remove when ws/default has a config, ensuring the authoritative config is in place.
287        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        // Create stubs at bare root
311        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        // Symlink .claude directory — use atomic approach to avoid TOCTOU
320        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            // Check if already a correct symlink
325            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                // Use atomic rename pattern: create temp symlink, then rename over target
332                let tmp_link = project_root.join(".claude.tmp");
333                let _ = fs::remove_file(&tmp_link); // clean up any stale temp
334                #[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                // Atomic rename (on same filesystem)
340                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        // Symlink .pi directory
349        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    /// Remove legacy JS-era artifacts that are no longer needed.
378    /// The Rust rewrite builds loops into the binary, so .mjs scripts and
379    /// shell hook wrappers are dead weight.
380    fn cleanup_legacy_artifacts(&self, agents_dir: &Path, changed_files: &mut Vec<&str>) {
381        // Remove .agents/botbox/scripts/ (JS loop scripts)
382        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        // Remove .agents/botbox/hooks/ (shell hook scripts — now built into botbox binary)
398        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        // Remove stale version markers from JS era
414        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); // No AGENTS.md to update
442        }
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    /// Clean up per-repo hooks that are now managed globally.
464    /// Removes botbox hooks from per-repo .claude/settings.json and .pi/extensions/.
465    fn cleanup_per_repo_hooks(&self, project_root: &Path) -> Result<()> {
466        if self.check {
467            return Ok(());
468        }
469
470        // Clean up per-repo .claude/settings.json botbox hooks
471        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                    // Remove empty event arrays
505                    hooks.retain(|_, v| {
506                        v.as_array().map(|a| !a.is_empty()).unwrap_or(true)
507                    });
508                }
509
510                if changed {
511                    // Remove hooks key entirely if empty
512                    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                    // Only write back if there's other content; delete if empty
521                    if settings.as_object().is_some_and(|o| o.is_empty()) {
522                        fs::remove_file(&settings_path)?;
523                        // Also remove .claude dir if empty
524                        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        // Clean up per-repo Pi extension
537        let pi_ext = project_root.join(".pi/extensions/botbox-hooks.ts");
538        if pi_ext.exists() {
539            fs::remove_file(&pi_ext)?;
540            // Clean up empty dirs
541            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(()); // Skip if no AGENTS.md
584        }
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    // sync_hooks removed — hooks are now installed globally via `botbox hooks install`
611
612    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        // Detect VCS: prefer jj if available, fall back to git
630        let vcs = detect_vcs(project_root);
631        if vcs == Vcs::None {
632            return Ok(()); // No VCS found, skip commit
633        }
634
635        // All paths that botbox sync may touch — git add is a no-op for unchanged files
636        let managed_paths: &[&str] = &[
637            ".agents/botbox/",
638            "AGENTS.md",
639            ".critignore",
640            ".botbox.toml",
641            ".botbox.json",
642            ".gitignore",
643        ];
644
645        // Build a human-readable summary from the caller's changed_files list
646        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                // Finalize: create new empty commit and advance main bookmark
657                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                // Stage managed paths that exist — git add errors on missing pathspecs
666                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                // Only commit if there are staged changes
679                let status = run_command(
680                    "git",
681                    &["diff", "--cached", "--quiet"],
682                    Some(project_root),
683                );
684                if status.is_err() {
685                    // diff --cached --quiet exits 1 when there are staged changes
686                    run_command("git", &["commit", "-m", &message], Some(project_root))?;
687                }
688            }
689            Vcs::None => unreachable!(),
690        }
691
692        Ok(())
693    }
694}
695
696/// Migrate bus hooks from `botbox:` descriptions to `edict:` descriptions.
697///
698/// Finds hooks with `botbox:{name}:responder` or `botbox:{name}:reviewer-*` descriptions,
699/// removes them, and re-registers with `edict:` prefix and `edict run` commands.
700/// Called during `edict sync` on projects that were previously set up with `botbox`.
701fn 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    // Resolve the correct cwd (bare root or project root)
723    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        // Only process botbox-era hooks for this project
744        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        // Remove old botbox hook
754        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
792/// Migrate bus hooks from legacy formats to current `edict run` commands with descriptions.
793///
794/// Lists all hooks for this project's channel, identifies legacy hooks
795/// (bun-based, old naming, missing descriptions), removes them, and
796/// re-registers via `ensure_bus_hook` with proper descriptions for
797/// future idempotent management.
798fn 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, // bus not available, skip silently
805    };
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        // Only migrate hooks for this project's channel
830        if channel != name {
831            continue;
832        }
833
834        // Skip hooks that already have an edict: or botbox: description (already migrated by
835        // migrate_bus_hooks or migrate_botbox_bus_hooks_to_edict respectively)
836        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        // Determine what kind of hook this is
853        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        // Remove old hook (ensure_bus_hook handles dedup by description,
871        // but these legacy hooks have no description so we remove manually)
872        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
1001/// Fix hook --cwd for maw v2 bare repos.
1002///
1003/// Earlier versions of `detect_hook_paths` checked for `.jj` to identify bare repos,
1004/// which broke after the migration to Git+manifold. This re-registers hooks that have
1005/// `--cwd .../ws/default` with `--cwd .../` (the repo root) instead.
1006fn migrate_hook_cwd(config: &Config, project_root: &Path) {
1007    // Detect maw v2: project_root may be ws/default/ (inner sync) or the bare root
1008    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        // Accept both current and legacy description prefixes
1061        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        // Check if any --cwd arg still points to ws/default
1074        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        // Re-register with the correct cwd via the init helpers
1082        let id = match hook.get("id").and_then(|i| i.as_str()) {
1083            Some(id) => id,
1084            None => continue,
1085        };
1086
1087        // Remove old hook first
1088        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            // Find which reviewer this is for
1112            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
1125/// Migrate router hook claim pattern from `agent://{name}-router` to `agent://{name}-dev`
1126/// and spawn name from `{name}-router` to `{name}-responder`.
1127///
1128/// Earlier versions used a vestigial `-router` claim that nobody actually staked.
1129/// The new pattern uses `-dev` which matches the responder's own agent claim,
1130/// preventing re-trigger while processing.
1131fn 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        // Check if the hook still uses the old claim pattern
1163        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        // Remove old hook and re-register with new claim pattern
1178        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        // Resolve hook paths the same way migrate_hook_cwd does
1188        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
1211/// Migrate beads → bones: config key, data directory, .maw.toml, .critignore, .gitignore.
1212///
1213/// This is idempotent — checks each step before acting.
1214fn 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    // 1. If config has `tools.beads` (in TOML), rename to `tools.bones`
1219    //    The serde alias handles deserialization, but we want the file itself updated.
1220    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    // 2. If .beads/ exists and .bones/ doesn't → run `bn init` + migrate data
1230    if beads_dir.exists() && !bones_dir.exists() {
1231        let beads_db = beads_dir.join("beads.db");
1232        // Initialize bones first
1233        match run_command("bn", &["init"], Some(project_root)) {
1234            Ok(_) => println!("Initialized bones"),
1235            Err(e) => tracing::warn!("bn init failed: {e}"),
1236        }
1237        // Migrate data if beads.db exists
1238        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    // 3. Update .maw.toml: remove .beads/** entry (set auto_resolve_from_main to empty)
1252    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            // Remove the .beads/** line and set to empty array if it was the only entry
1257            let updated = content
1258                .lines()
1259                .map(|line| {
1260                    if line.contains(".beads/") {
1261                        // Skip this line
1262                        None
1263                    } else {
1264                        Some(line)
1265                    }
1266                })
1267                .flatten()
1268                .collect::<Vec<_>>()
1269                .join("\n");
1270            // If the array is now effectively empty, replace with empty
1271            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    // 4. Update .critignore: remove .beads/ line (bones handles its own critignore)
1281    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    // 5. Update .gitignore: remove .bv/ line (bones is tracked, not ignored)
1301    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            // Preserve trailing newline if original had one
1311            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/// Version control system detected in a project.
1325#[derive(Debug, PartialEq, Eq)]
1326enum Vcs {
1327    Jj,
1328    Git,
1329    None,
1330}
1331
1332/// Detect which VCS manages this project root.
1333/// Prefers jj if found (searches ancestors for `.jj/`), falls back to git
1334/// (`.git` file or directory at `project_root` or ancestors).
1335fn detect_vcs(project_root: &Path) -> Vcs {
1336    if find_jj_root(project_root).is_some() {
1337        return Vcs::Jj;
1338    }
1339    // Check for .git file (worktree/maw) or .git directory (regular repo)
1340    if project_root
1341        .ancestors()
1342        .any(|p| p.join(".git").exists())
1343    {
1344        return Vcs::Git;
1345    }
1346    Vcs::None
1347}
1348
1349/// Search up the directory tree for a .jj directory (like jj itself does).
1350/// Returns the repo root if found, or None if not a jj repo.
1351fn 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
1357/// Compute SHA-256 hash of all workflow docs
1358fn 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
1367/// Compute SHA-256 hash of all reviewer prompts
1368fn 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
1377/// Compute SHA-256 hash of all design docs
1378fn 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        // Should find .jj right at `from`
1397        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        // Should find .jj at the ancestor
1408        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        // No .jj anywhere
1415        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}