use anyhow::{bail, Context, Result};
use std::path::Path;
use super::io::*;
use super::status::{probe_agent_status, resolve_agents};
use super::types::*;
use crate::commands::kickoff::{self, ContainerMode, KickoffOpts, VerifyLevel};
use crate::db::Database;
use crate::shared_writer::SharedWriter;
use crate::sync::SyncManager;
pub fn archive(crosslink_dir: &Path) -> Result<()> {
let sync = SyncManager::new(crosslink_dir)?;
if !sync.is_initialized() {
bail!("Hub cache not initialized. Run `crosslink sync` first.");
}
sync.fetch()?;
let ctx = resolve_swarm(&sync)?;
let plan_path_str = ctx.plan_path();
let plan_path = sync.cache_path().join(&plan_path_str);
if !plan_path.exists() {
bail!("No active swarm plan to archive.");
}
let plan: SwarmPlan =
read_hub_json(&sync, &plan_path_str).context("Failed to read swarm plan")?;
let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S").to_string();
let archive_prefix = format!("swarm/archive/{timestamp}");
let plan_json = std::fs::read_to_string(&plan_path)?;
let archive_plan = sync
.cache_path()
.join(format!("{archive_prefix}/plan.json"));
if let Some(parent) = archive_plan.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&archive_plan, &plan_json)?;
let phases_dir = sync.cache_path().join(format!("{}/phases", ctx.base));
if phases_dir.is_dir() {
let archive_phases = sync.cache_path().join(format!("{archive_prefix}/phases"));
std::fs::create_dir_all(&archive_phases)?;
if let Ok(entries) = std::fs::read_dir(&phases_dir) {
for entry in entries.flatten() {
let name = entry.file_name();
let dest = archive_phases.join(&name);
let _ = std::fs::copy(entry.path(), dest);
}
}
}
let checkpoints_dir = sync.cache_path().join(ctx.checkpoints_dir());
if checkpoints_dir.is_dir() {
let archive_cp = sync
.cache_path()
.join(format!("{archive_prefix}/checkpoints"));
std::fs::create_dir_all(&archive_cp)?;
if let Ok(entries) = std::fs::read_dir(&checkpoints_dir) {
for entry in entries.flatten() {
let name = entry.file_name();
let dest = archive_cp.join(&name);
let _ = std::fs::copy(entry.path(), dest);
}
}
}
let _ = std::fs::remove_file(&plan_path);
let _ = std::fs::remove_dir_all(&phases_dir);
let _ = std::fs::remove_dir_all(&checkpoints_dir);
let _ = std::fs::remove_file(sync.cache_path().join("swarm/active.json"));
if !ctx.is_legacy {
let _ = std::fs::remove_dir_all(sync.cache_path().join(ctx.base));
}
let cache = sync.cache_path();
if let Ok(o) = std::process::Command::new("git")
.current_dir(cache)
.args(["add", "--all", "--", "swarm/"])
.output()
{
if !o.status.success() {
tracing::warn!(
"git add failed during swarm archive: {}",
String::from_utf8_lossy(&o.stderr).trim()
);
}
}
if let Ok(o) = std::process::Command::new("git")
.current_dir(cache)
.args([
"commit",
"-m",
&format!("swarm: archive '{}' to {}", plan.title, archive_prefix),
])
.output()
{
if !o.status.success() {
let msg = String::from_utf8_lossy(&o.stderr);
if !msg.contains("nothing to commit") {
tracing::warn!("git commit failed during swarm archive: {}", msg.trim());
}
}
}
let remote = sync.remote();
if let Ok(o) = std::process::Command::new("git")
.current_dir(cache)
.args(["push", remote, crate::sync::HUB_BRANCH])
.output()
{
if !o.status.success() {
tracing::warn!(
"could not push swarm archive to hub: {} — archive is saved locally",
String::from_utf8_lossy(&o.stderr).trim()
);
}
}
println!("Archived swarm '{}' to {}/", plan.title, archive_prefix);
println!("Active swarm slot is now clear. Run `crosslink swarm init` to start a new swarm.");
Ok(())
}
pub fn reset(crosslink_dir: &Path, no_archive: bool) -> Result<()> {
if !no_archive {
return archive(crosslink_dir);
}
let sync = SyncManager::new(crosslink_dir)?;
if !sync.is_initialized() {
bail!("Hub cache not initialized.");
}
sync.fetch()?;
let ctx = resolve_swarm(&sync)?;
let plan_path = sync.cache_path().join(ctx.plan_path());
if !plan_path.exists() {
bail!("No active swarm plan to reset.");
}
let _ = std::fs::remove_file(&plan_path);
let _ = std::fs::remove_dir_all(sync.cache_path().join(format!("{}/phases", ctx.base)));
let _ = std::fs::remove_dir_all(sync.cache_path().join(ctx.checkpoints_dir()));
let _ = std::fs::remove_file(sync.cache_path().join("swarm/active.json"));
if !ctx.is_legacy {
let _ = std::fs::remove_dir_all(sync.cache_path().join(ctx.base));
}
let cache = sync.cache_path();
if let Ok(o) = std::process::Command::new("git")
.current_dir(cache)
.args(["add", "--all", "--", "swarm/"])
.output()
{
if !o.status.success() {
tracing::warn!(
"git add failed during swarm reset: {}",
String::from_utf8_lossy(&o.stderr).trim()
);
}
}
if let Ok(o) = std::process::Command::new("git")
.current_dir(cache)
.args(["commit", "-m", "swarm: reset (no archive)"])
.output()
{
if !o.status.success() {
let msg = String::from_utf8_lossy(&o.stderr);
if !msg.contains("nothing to commit") {
tracing::warn!("git commit failed during swarm reset: {}", msg.trim());
}
}
}
let remote = sync.remote();
if let Ok(o) = std::process::Command::new("git")
.current_dir(cache)
.args(["push", remote, crate::sync::HUB_BRANCH])
.output()
{
if !o.status.success() {
tracing::warn!(
"could not push swarm reset to hub: {} — reset is saved locally",
String::from_utf8_lossy(&o.stderr).trim()
);
}
}
println!("Swarm plan deleted. Active slot is clear.");
Ok(())
}
pub fn list_swarms(crosslink_dir: &Path) -> Result<()> {
let sync = SyncManager::new(crosslink_dir)?;
if !sync.is_initialized() {
bail!("Hub cache not initialized. Run `crosslink sync` first.");
}
sync.fetch()?;
match resolve_swarm(&sync) {
Ok(ctx) => {
if let Ok(plan) = read_hub_json::<SwarmPlan>(&sync, &ctx.plan_path()) {
let mode = if ctx.is_legacy { " (legacy)" } else { "" };
println!(
"Active: {} (created {}){}",
plan.title, plan.created_at, mode
);
}
}
Err(_) => {
println!("No active swarm.");
}
}
let archive_dir = sync.cache_path().join("swarm/archive");
if archive_dir.is_dir() {
let mut archives: Vec<String> = Vec::new();
if let Ok(entries) = std::fs::read_dir(&archive_dir) {
for entry in entries.flatten() {
if entry.file_type().is_ok_and(|t| t.is_dir()) {
let name = entry.file_name().to_string_lossy().to_string();
let plan_file = entry.path().join("plan.json");
let title = std::fs::read_to_string(&plan_file)
.ok()
.and_then(|c| serde_json::from_str::<SwarmPlan>(&c).ok())
.map_or_else(|| "(unknown)".to_string(), |p| p.title);
archives.push(format!(" {name} — {title}"));
}
}
}
archives.sort();
if !archives.is_empty() {
println!("\nArchived swarms:");
for a in &archives {
println!("{a}");
}
}
}
Ok(())
}
pub fn launch_retry_failed(
crosslink_dir: &Path,
db: &Database,
writer: Option<&SharedWriter>,
phase_slug: &str,
quiet: bool,
) -> Result<()> {
let sync = SyncManager::new(crosslink_dir)?;
if !sync.is_initialized() {
bail!("Hub cache not initialized. Run `crosslink sync` first.");
}
let (mut phase, phase_file) = load_phase(&sync, phase_slug)?;
let root = crosslink_dir
.parent()
.ok_or_else(|| anyhow::anyhow!("Cannot determine repo root"))?;
let resolved = resolve_agents(&phase, root);
let mut retry_count = 0;
for agent in &mut phase.agents {
let live = resolved
.iter()
.find(|r| r.slug == agent.slug)
.map_or("planned", |r| r.live_status.as_str());
if agent.status == AgentStatus::Failed || live == "FAILED" || live == "failed" {
agent.status = AgentStatus::Planned;
agent.started_at = None;
agent.completed_at = None;
retry_count += 1;
}
}
if retry_count == 0 {
println!("No failed agents to retry in '{}'.", phase.name);
return Ok(());
}
write_hub_json(&sync, &phase_file, &phase)?;
commit_hub_files(
&sync,
&[&phase_file],
&format!("swarm: reset {retry_count} failed agents for retry"),
)?;
println!("Reset {retry_count} failed agent(s) to planned. Launching...");
launch(crosslink_dir, db, writer, phase_slug, quiet)
}
pub fn adopt(crosslink_dir: &Path, agent_slug: &str, slot_slug: &str) -> Result<()> {
let (sync, plan, mut phases) = load_plan_and_phases(crosslink_dir)?;
let mut found = false;
for (_path, phase) in &mut phases {
for agent in &mut phase.agents {
if agent.slug == slot_slug {
agent.status = AgentStatus::Running;
agent.branch = Some(format!("feature/{agent_slug}"));
agent.started_at = Some(chrono::Utc::now().to_rfc3339());
found = true;
break;
}
}
if found {
break;
}
}
if !found {
bail!(
"Slot '{}' not found in any phase. Available slots: {}",
slot_slug,
phases
.iter()
.flat_map(|(_, p)| p.agents.iter().map(|a| a.slug.as_str()))
.collect::<Vec<_>>()
.join(", ")
);
}
save_plan_and_phases(
&sync,
&plan,
&phases,
&format!("swarm: adopt agent '{agent_slug}' into slot '{slot_slug}'"),
)?;
println!("Adopted '{agent_slug}' into swarm slot '{slot_slug}' (branch: feature/{agent_slug})");
Ok(())
}
pub fn sync_status(crosslink_dir: &Path) -> Result<()> {
let sync = SyncManager::new(crosslink_dir)?;
if !sync.is_initialized() {
bail!("Hub cache not initialized. Run `crosslink sync` first.");
}
sync.fetch()?;
let ctx = resolve_swarm(&sync)?;
let plan: SwarmPlan = read_hub_json(&sync, &ctx.plan_path()).context("No swarm plan found.")?;
let root = crosslink_dir
.parent()
.ok_or_else(|| anyhow::anyhow!("Cannot determine repo root"))?;
let mut updated_count = 0;
let mut paths_to_commit: Vec<String> = Vec::new();
for phase_name in &plan.phases {
let phase_path = ctx.phase_path(phase_name);
let mut phase: PhaseDefinition = match read_hub_json(&sync, &phase_path) {
Ok(p) => p,
Err(_) => continue,
};
if phase.status == PhaseStatus::Completed {
continue;
}
let mut phase_changed = false;
for agent in &mut phase.agents {
let live = probe_agent_status(root, &agent.slug);
let new_status =
if live == "DONE" || live == "completed" || live.starts_with("completed") {
Some(AgentStatus::Completed)
} else if live == "FAILED" || live == "failed" || live.starts_with("failed") {
Some(AgentStatus::Failed)
} else if live.starts_with("running") {
Some(AgentStatus::Running)
} else {
None
};
if let Some(status) = new_status {
if agent.status != status {
let old = format!("{}", agent.status);
agent.status = status.clone();
if matches!(status, AgentStatus::Completed | AgentStatus::Failed)
&& agent.completed_at.is_none()
{
agent.completed_at = Some(chrono::Utc::now().to_rfc3339());
}
println!(" {} {} → {}", agent.slug, old, agent.status);
phase_changed = true;
updated_count += 1;
}
}
}
if phase_changed {
let all_done = phase.agents.iter().all(|a| {
matches!(
a.status,
AgentStatus::Completed | AgentStatus::Merged | AgentStatus::Failed
)
});
if all_done && phase.status == PhaseStatus::InProgress {
let any_failed = phase.agents.iter().any(|a| a.status == AgentStatus::Failed);
if any_failed {
phase.status = PhaseStatus::Failed;
println!(" Phase '{}' → failed", phase.name);
}
}
write_hub_json(&sync, &phase_path, &phase)?;
paths_to_commit.push(phase_path);
}
}
if paths_to_commit.is_empty() {
println!("All phase statuses are up to date.");
} else {
let refs: Vec<&str> = paths_to_commit.iter().map(String::as_str).collect();
commit_hub_files(
&sync,
&refs,
&format!("swarm: sync {updated_count} agent status(es) from live state"),
)?;
println!("Synced {updated_count} agent status update(s).");
}
Ok(())
}
pub fn resume(crosslink_dir: &Path) -> Result<()> {
let sync = SyncManager::new(crosslink_dir)?;
if !sync.is_initialized() {
bail!("Hub cache not initialized. Run `crosslink sync` first.");
}
let ctx = resolve_swarm(&sync)?;
let plan: SwarmPlan = read_hub_json(&sync, &ctx.plan_path())
.context("No swarm plan found. Run `crosslink swarm init --doc <file>` first.")?;
let root = crosslink_dir
.parent()
.ok_or_else(|| anyhow::anyhow!("Cannot determine repo root"))?;
let checkpoint_dir = sync.cache_path().join(ctx.checkpoints_dir());
let latest_checkpoint = find_latest_checkpoint(&checkpoint_dir);
if let Some(ref cp) = latest_checkpoint {
println!("Latest checkpoint: {} ({})", cp.phase, cp.created_at);
if let Some(ref notes) = cp.handoff_notes {
println!(" Notes: {notes}");
}
println!();
}
let mut active_phase: Option<PhaseDefinition> = None;
let mut active_phase_name: Option<String> = None;
let mut completed_count = 0;
for phase_name in &plan.phases {
let phase_file = ctx.phase_path(phase_name);
let phase: PhaseDefinition = match read_hub_json(&sync, &phase_file) {
Ok(p) => p,
Err(_) => continue,
};
if phase.status == PhaseStatus::Completed {
completed_count += 1;
continue;
}
active_phase = Some(phase);
active_phase_name = Some(phase_name.clone());
break;
}
let (Some(phase), Some(phase_name)) = (active_phase, active_phase_name) else {
println!(
"All {} phases completed. Swarm build is done.",
plan.phases.len()
);
return Ok(());
};
println!(
"Resume point: {} ({}/{})",
phase_name,
completed_count,
plan.phases.len()
);
println!();
let resolved = resolve_agents(&phase, root);
let mut actions: Vec<String> = Vec::new();
let mut action_num = 1;
let ready_to_merge: Vec<&ResolvedAgent> = resolved
.iter()
.filter(|a| a.live_status == "DONE" && a.defined_status != AgentStatus::Merged)
.collect();
if !ready_to_merge.is_empty() {
for agent in &ready_to_merge {
let branch = agent.branch.as_deref().unwrap_or_else(|| &agent.slug);
actions.push(format!(
"{}. Merge {}: review and merge {} to dev",
action_num, agent.slug, branch
));
action_num += 1;
}
}
let running: Vec<&ResolvedAgent> = resolved
.iter()
.filter(|a| a.live_status.starts_with("running"))
.collect();
if !running.is_empty() {
for agent in &running {
actions.push(format!(
"{}. Check {}: crosslink kickoff status {}",
action_num, agent.slug, agent.slug
));
action_num += 1;
}
}
let failed: Vec<&ResolvedAgent> = resolved
.iter()
.filter(|a| a.live_status == "FAILED" || a.live_status == "failed")
.collect();
if !failed.is_empty() {
for agent in &failed {
actions.push(format!(
"{}. Investigate {} failure: crosslink kickoff report {}",
action_num, agent.slug, agent.slug
));
action_num += 1;
}
}
let planned: Vec<&ResolvedAgent> = resolved
.iter()
.filter(|a| a.live_status == "planned")
.collect();
if !planned.is_empty() {
let slugs: Vec<&str> = planned.iter().map(|a| a.slug.as_str()).collect();
actions.push(format!(
"{}. Launch remaining agents: {}",
action_num,
slugs.join(", ")
));
action_num += 1;
}
let unknown: Vec<&ResolvedAgent> = resolved
.iter()
.filter(|a| a.live_status.starts_with("unknown"))
.collect();
if !unknown.is_empty() {
for agent in &unknown {
actions.push(format!(
"{}. Check stale agent {}: worktree exists but no active session",
action_num, agent.slug
));
action_num += 1;
}
}
let all_agents_resolved = ready_to_merge.is_empty()
&& running.is_empty()
&& failed.is_empty()
&& planned.is_empty()
&& unknown.is_empty();
let phase_slug = slugify_phase(&phase_name);
if all_agents_resolved {
actions.push(format!(
"{action_num}. All agents merged. Run gate: crosslink swarm gate {phase_slug}"
));
action_num += 1;
actions.push(format!(
"{action_num}. If gate passes: crosslink swarm checkpoint {phase_slug}"
));
} else if ready_to_merge.is_empty() && running.is_empty() && planned.is_empty() {
actions.push(format!(
"{action_num}. After resolving failures: crosslink swarm gate {phase_slug}"
));
} else {
actions.push(format!(
"{action_num}. After merges complete: crosslink swarm gate {phase_slug}"
));
action_num += 1;
if completed_count + 1 < plan.phases.len() {
actions.push(format!(
"{action_num}. If gate passes: crosslink swarm checkpoint {phase_slug}"
));
}
}
println!("Next actions:");
for action in &actions {
println!(" {action}");
}
Ok(())
}
pub fn launch(
crosslink_dir: &Path,
db: &Database,
writer: Option<&SharedWriter>,
phase_slug: &str,
quiet: bool,
) -> Result<()> {
let sync = SyncManager::new(crosslink_dir)?;
if !sync.is_initialized() {
bail!("Hub cache not initialized. Run `crosslink sync` first.");
}
let (mut phase, phase_file) = load_phase(&sync, phase_slug)?;
if phase.status == PhaseStatus::Completed {
bail!("Phase '{}' is already completed", phase.name);
}
check_dependencies(&sync, &phase)?;
let planned_agents: Vec<usize> = phase
.agents
.iter()
.enumerate()
.filter(|(_, a)| a.status == AgentStatus::Planned)
.map(|(i, _)| i)
.collect();
if planned_agents.is_empty() {
println!("No planned agents to launch in '{}'.", phase.name);
println!("Use `crosslink swarm status` to see current agent states.");
return Ok(());
}
let now = chrono::Utc::now().to_rfc3339();
if !quiet {
println!(
"Launching {} agent{} for {}...",
planned_agents.len(),
if planned_agents.len() == 1 { "" } else { "s" },
phase.name
);
println!();
}
for idx in &planned_agents {
let slug = phase.agents[*idx].slug.clone();
let description = phase.agents[*idx].description.clone();
let issue_id = phase.agents[*idx].issue_id;
let branch = phase.agents[*idx].branch.clone();
let opts = KickoffOpts {
description: &description,
issue: issue_id,
container: ContainerMode::None,
verify: VerifyLevel::Local,
model: "opus",
image: kickoff::DEFAULT_AGENT_IMAGE,
timeout: std::time::Duration::from_secs(3600),
dry_run: false,
branch: branch.as_deref(),
quiet,
design_doc: None,
doc_path: None,
skip_permissions: false,
};
match kickoff::run(crosslink_dir, db, writer, &opts) {
Ok(compact_name) => {
phase.agents[*idx].status = AgentStatus::Running;
phase.agents[*idx].started_at = Some(now.clone());
phase.agents[*idx].agent_id = Some(compact_name.clone());
phase.agents[*idx].branch = Some(format!("feature/{compact_name}"));
}
Err(e) => {
tracing::error!("Failed to launch {}: {}", slug, e);
phase.agents[*idx].status = AgentStatus::Failed;
}
}
}
phase.status = PhaseStatus::InProgress;
write_hub_json(&sync, &phase_file, &phase)?;
commit_hub_files(
&sync,
&[phase_file.as_str()],
&format!("swarm: launch {}", phase.name),
)?;
if !quiet {
let running = phase
.agents
.iter()
.filter(|a| a.status == AgentStatus::Running)
.count();
let failed = phase
.agents
.iter()
.filter(|a| a.status == AgentStatus::Failed)
.count();
println!();
println!(
"{} agent{} launched, {} failed.",
running,
if running == 1 { "" } else { "s" },
failed
);
println!();
println!("Monitor with: crosslink swarm status");
}
Ok(())
}
pub fn gate(crosslink_dir: &Path, phase_slug: &str) -> Result<()> {
if let Err(e) = sync_status(crosslink_dir) {
tracing::warn!("could not sync agent statuses: {}", e);
}
let sync = SyncManager::new(crosslink_dir)?;
if !sync.is_initialized() {
bail!("Hub cache not initialized. Run `crosslink sync` first.");
}
let (mut phase, phase_file) = load_phase(&sync, phase_slug)?;
let root = crosslink_dir
.parent()
.ok_or_else(|| anyhow::anyhow!("Cannot determine repo root"))?;
let unresolved: Vec<&AgentEntry> = phase
.agents
.iter()
.filter(|a| a.status == AgentStatus::Planned || a.status == AgentStatus::Running)
.collect();
if !unresolved.is_empty() {
let live_unresolved: Vec<&AgentEntry> = unresolved
.into_iter()
.filter(|a| {
let live = probe_agent_status(root, &a.slug);
live == "planned" || live.starts_with("running")
})
.collect();
if !live_unresolved.is_empty() {
let names: Vec<&str> = live_unresolved.iter().map(|a| a.slug.as_str()).collect();
bail!(
"Cannot gate: {} agent(s) still unresolved: {}",
live_unresolved.len(),
names.join(", ")
);
}
}
let conventions = kickoff::detect_conventions(root);
let test_cmd = conventions.test_command.as_deref().unwrap_or("cargo test");
println!("Running gate: {test_cmd}");
println!();
let cmd_parts: Vec<&str> = test_cmd.split_whitespace().collect();
let (program, args) = cmd_parts
.split_first()
.ok_or_else(|| anyhow::anyhow!("Empty gate test command"))?;
let output = std::process::Command::new(program)
.args(args)
.current_dir(root)
.output()
.with_context(|| format!("Failed to run gate command: {test_cmd}"))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let gate_passed = output.status.success();
let now = chrono::Utc::now().to_rfc3339();
let (tests_total, tests_passed) = parse_test_counts(&stdout, &stderr);
let gate_result = GateResult {
status: if gate_passed {
"passed".to_string()
} else {
"failed".to_string()
},
tests_total,
tests_passed,
ran_at: Some(now),
};
phase.gate = Some(gate_result);
write_hub_json(&sync, &phase_file, &phase)?;
commit_hub_files(
&sync,
&[phase_file.as_str()],
&format!(
"swarm: gate {} — {}",
phase.name,
if gate_passed { "passed" } else { "failed" }
),
)?;
if gate_passed {
let tests_info = tests_total
.map(|t| format!(" ({t} tests)"))
.unwrap_or_default();
println!("Gate passed{tests_info}");
println!();
println!(
"Next: crosslink swarm checkpoint {}",
slugify_phase(&phase.name)
);
} else {
println!("Gate FAILED.");
if !stderr.is_empty() {
let tail: Vec<&str> = stderr.lines().rev().take(20).collect();
for line in tail.iter().rev() {
println!(" {line}");
}
}
println!();
println!("Fix failures and re-run: crosslink swarm gate {phase_slug}");
}
Ok(())
}
pub(super) fn parse_test_counts(stdout: &str, stderr: &str) -> (Option<u64>, Option<u64>) {
for text in [stdout, stderr] {
for line in text.lines() {
if line.starts_with("test result:") {
let mut passed: Option<u64> = None;
let mut failed: Option<u64> = None;
for part in line.split(';') {
let part = part.trim();
if part.ends_with("passed") {
passed = part.split_whitespace().find_map(|w| w.parse::<u64>().ok());
} else if part.ends_with("failed") {
failed = part.split_whitespace().find_map(|w| w.parse::<u64>().ok());
}
}
if let (Some(p), Some(f)) = (passed, failed) {
return (Some(p + f), Some(p));
}
}
}
}
(None, None)
}
pub fn checkpoint(
crosslink_dir: &Path,
phase_slug: &str,
notes: Option<&str>,
force: bool,
) -> Result<()> {
let sync = SyncManager::new(crosslink_dir)?;
if !sync.is_initialized() {
bail!("Hub cache not initialized. Run `crosslink sync` first.");
}
let (mut phase, phase_file) = load_phase(&sync, phase_slug)?;
if !force {
match &phase.gate {
Some(g) if g.status == "passed" => {}
Some(g) => bail!(
"Gate status is '{}', not 'passed'. Use --force to checkpoint anyway.",
g.status
),
None => bail!(
"No gate result recorded. Run `crosslink swarm gate {phase_slug}` first, or use --force."
),
}
}
let now = chrono::Utc::now().to_rfc3339();
let dev_sha = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
let agents_merged: Vec<String> = phase
.agents
.iter()
.filter(|a| a.status == AgentStatus::Merged || a.status == AgentStatus::Completed)
.map(|a| a.slug.clone())
.collect();
let agents_pending: Vec<String> = phase
.agents
.iter()
.filter(|a| a.status != AgentStatus::Merged && a.status != AgentStatus::Completed)
.map(|a| a.slug.clone())
.collect();
let test_result = phase
.gate
.as_ref()
.and_then(|g| match (g.tests_total, g.tests_passed) {
(Some(total), Some(passed)) => Some(TestResult {
total,
passed,
failed: total.saturating_sub(passed),
}),
_ => None,
});
let cp = Checkpoint {
phase: phase.name.clone(),
created_at: now.clone(),
agents_merged,
agents_pending,
dev_branch_sha: dev_sha,
test_result,
handoff_notes: notes.map(ToString::to_string),
};
let ctx = resolve_swarm(&sync)?;
let cp_slug = slugify_phase(&phase.name);
let cp_path = ctx.checkpoint_path(&cp_slug);
write_hub_json(&sync, &cp_path, &cp)?;
phase.status = PhaseStatus::Completed;
phase.checkpoint = Some(cp_slug);
for agent in &mut phase.agents {
if agent.status == AgentStatus::Completed {
agent.status = AgentStatus::Merged;
agent.completed_at = Some(now.clone());
}
}
write_hub_json(&sync, &phase_file, &phase)?;
commit_hub_files(
&sync,
&[phase_file.as_str(), cp_path.as_str()],
&format!("swarm: checkpoint {}", phase.name),
)?;
println!("Checkpoint recorded for {}", phase.name);
if let Some(n) = notes {
println!(" Notes: {n}");
}
let plan: SwarmPlan = read_hub_json(&sync, &ctx.plan_path())?;
let current_idx = plan
.phases
.iter()
.position(|p| slugify_phase(p) == slugify_phase(&phase.name));
if let Some(idx) = current_idx {
if idx + 1 < plan.phases.len() {
let next = &plan.phases[idx + 1];
println!();
println!(
"Next phase: {} → crosslink swarm launch {}",
next,
slugify_phase(next)
);
} else {
println!();
println!("All phases completed. Swarm build is done.");
}
}
Ok(())
}
pub(super) fn find_latest_checkpoint(dir: &Path) -> Option<Checkpoint> {
if !dir.is_dir() {
return None;
}
let mut entries: Vec<_> = std::fs::read_dir(dir)
.ok()?
.filter_map(std::result::Result::ok)
.filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
.collect();
entries.sort_by_key(|e| e.metadata().ok().and_then(|m| m.modified().ok()));
if let Some(entry) = entries.last() {
let content = std::fs::read_to_string(entry.path()).ok()?;
serde_json::from_str(&content).ok()
} else {
None
}
}