use anyhow::{bail, Context, Result};
use std::path::Path;
use super::io::*;
use super::types::*;
use crate::commands::design_doc::{self, DesignDoc};
use crate::sync::SyncManager;
pub fn init(crosslink_dir: &Path, doc_path: &Path) -> Result<()> {
let content = std::fs::read_to_string(doc_path)
.with_context(|| format!("Failed to read design doc: {}", doc_path.display()))?;
let doc = design_doc::parse_design_doc(&content);
if doc.title.is_empty() {
bail!("Design doc has no title (expected a # heading)");
}
let sync = SyncManager::new(crosslink_dir)?;
sync.init_cache()?;
sync.fetch()?;
let has_active = sync.cache_path().join("swarm/active.json").exists()
|| sync.cache_path().join("swarm/plan.json").exists();
if has_active {
bail!(
"A swarm plan already exists. Use `crosslink swarm status` to view it, \
or `crosslink swarm reset` to archive and start over."
);
}
let ctx = create_swarm_slot(&sync, &doc.title)?;
let mut phases = propose_phases(&doc);
let now = chrono::Utc::now().to_rfc3339();
let repo_root = crosslink_dir
.parent()
.ok_or_else(|| anyhow::anyhow!("Cannot determine repo root"))?;
let is_greenfield = !repo_root.join("Cargo.toml").exists()
&& !repo_root.join("package.json").exists()
&& !repo_root.join("go.mod").exists()
&& !repo_root.join("pyproject.toml").exists()
&& !repo_root.join("mix.exs").exists()
&& !repo_root.join("src").is_dir();
if is_greenfield && phases.len() > 1 {
let scaffold_phase = PhaseDefinition {
name: "Phase 0: Scaffold".to_string(),
status: PhaseStatus::Pending,
agents: vec![AgentEntry {
slug: "project-scaffold".to_string(),
description: format!(
"Create project skeleton for '{}': manifest files, directory structure, \
shared types/traits, and CI configuration. Subsequent phases depend on this.",
doc.title
),
issue_id: None,
agent_id: None,
branch: Some("feature/project-scaffold".to_string()),
status: AgentStatus::Planned,
started_at: None,
completed_at: None,
}],
gate: None,
depends_on: vec![],
checkpoint: None,
};
for phase in &mut phases {
if phase.depends_on.is_empty() {
phase.depends_on.push("Phase 0: Scaffold".to_string());
}
}
phases.insert(0, scaffold_phase);
println!(
"Note: Greenfield project detected — added Phase 0: Scaffold to create project skeleton first."
);
}
let phase_names: Vec<String> = phases.iter().map(|p| p.name.clone()).collect();
let plan = SwarmPlan {
schema_version: 1,
title: doc.title.clone(),
design_doc: Some(doc_path.display().to_string()),
created_at: now,
phases: phase_names,
};
let plan_path = ctx.plan_path();
write_hub_json(&sync, &plan_path, &plan)?;
let mut paths_to_commit: Vec<String> = vec!["swarm/active.json".to_string(), plan_path];
for phase in &phases {
let phase_path = ctx.phase_path(&phase.name);
write_hub_json(&sync, &phase_path, phase)?;
paths_to_commit.push(phase_path);
}
let path_refs: Vec<&str> = paths_to_commit.iter().map(String::as_str).collect();
commit_hub_files(&sync, &path_refs, "swarm: init plan from design doc")?;
println!("Swarm plan initialized: {}", doc.title);
println!();
for (i, phase) in phases.iter().enumerate() {
println!(
" Phase {}: {} ({} agent{})",
i + 1,
phase.name,
phase.agents.len(),
if phase.agents.len() == 1 { "" } else { "s" }
);
for agent in &phase.agents {
println!(" - {}: {}", agent.slug, agent.description);
}
}
println!();
println!("Edit phase files in the hub branch to refine agent assignments.");
println!("Then use `crosslink swarm status` to view the plan.");
Ok(())
}
pub(super) fn propose_phases(doc: &DesignDoc) -> Vec<PhaseDefinition> {
if !doc.requirement_groups.is_empty() {
return propose_phases_from_groups(doc);
}
let mut agents: Vec<AgentEntry> = Vec::new();
for req in &doc.requirements {
let slug = slugify_requirement(req);
agents.push(AgentEntry {
slug: slug.clone(),
description: req.clone(),
issue_id: None,
agent_id: None,
branch: Some(format!("feature/{slug}")),
status: AgentStatus::Planned,
started_at: None,
completed_at: None,
});
}
if agents.is_empty() {
for ac in &doc.acceptance_criteria {
let slug = slugify_requirement(ac);
agents.push(AgentEntry {
slug: slug.clone(),
description: ac.clone(),
issue_id: None,
agent_id: None,
branch: Some(format!("feature/{slug}")),
status: AgentStatus::Planned,
started_at: None,
completed_at: None,
});
}
}
if agents.is_empty() {
let slug = crate::commands::kickoff::slugify(&doc.title);
agents.push(AgentEntry {
slug: slug.clone(),
description: doc.title.clone(),
issue_id: None,
agent_id: None,
branch: Some(format!("feature/{slug}")),
status: AgentStatus::Planned,
started_at: None,
completed_at: None,
});
}
let max_per_phase = 8;
let mut phases = Vec::new();
let chunks: Vec<Vec<AgentEntry>> = agents
.chunks(max_per_phase)
.map(<[AgentEntry]>::to_vec)
.collect();
for (i, chunk) in chunks.into_iter().enumerate() {
let name = if phases.is_empty() && agents.len() <= max_per_phase {
"Phase 1".to_string()
} else {
format!("Phase {}", i + 1)
};
let depends_on = if i > 0 {
vec![format!("Phase {}", i)]
} else {
vec![]
};
phases.push(PhaseDefinition {
name,
status: PhaseStatus::Pending,
agents: chunk,
gate: None,
depends_on,
checkpoint: None,
});
}
phases
}
fn propose_phases_from_groups(doc: &DesignDoc) -> Vec<PhaseDefinition> {
let mut phases = Vec::new();
for (i, group) in doc.requirement_groups.iter().enumerate() {
let agents: Vec<AgentEntry> = group
.items
.iter()
.map(|req| {
let slug = slugify_requirement(req);
AgentEntry {
slug: slug.clone(),
description: req.clone(),
issue_id: None,
agent_id: None,
branch: Some(format!("feature/{slug}")),
status: AgentStatus::Planned,
started_at: None,
completed_at: None,
}
})
.collect();
let depends_on = if i > 0 && group.execution_hint != "parallel" {
vec![phases
.last()
.map(|p: &PhaseDefinition| p.name.clone())
.unwrap_or_default()]
.into_iter()
.filter(|s| !s.is_empty())
.collect()
} else if i > 0 {
phases
.last()
.map_or_else(Vec::new, |prev| vec![prev.name.clone()])
} else {
vec![]
};
let name = format!("Phase {}: {}", i + 1, group.name);
phases.push(PhaseDefinition {
name,
status: PhaseStatus::Pending,
agents,
gate: None,
depends_on,
checkpoint: None,
});
}
phases
}
pub(super) fn slugify_requirement(req: &str) -> String {
let mut text = req.trim_start_matches("- ").trim();
if let Some(colon_pos) = text.find(':') {
let prefix = &text[..colon_pos];
let looks_like_id = !prefix.is_empty()
&& prefix.len() <= 10
&& prefix
.chars()
.all(|c| c.is_ascii_uppercase() || c == '-' || c.is_ascii_digit());
if looks_like_id {
text = text[colon_pos + 1..].trim();
}
}
let text = if text.is_empty() { req.trim() } else { text };
crate::commands::kickoff::slugify(text)
}