use anyhow::{bail, Context, Result};
use std::path::Path;
use crate::db::Database;
use crate::shared_writer::SharedWriter;
use super::helpers::*;
use super::launch::*;
use super::prompt::*;
use super::types::*;
pub fn run(
crosslink_dir: &Path,
db: &Database,
writer: Option<&SharedWriter>,
opts: &KickoffOpts,
) -> Result<String> {
let preflight = if opts.dry_run {
None
} else {
Some(preflight_check(
&opts.container,
&opts.verify,
crosslink_dir,
)?)
};
let root = repo_root()?;
let base_slug = slugify(opts.description);
let slug = if base_slug.is_empty() {
rand_hex_suffix()
} else {
format!("{}-{}", base_slug, rand_hex_suffix())
};
let repo_id = crate::commands::init::read_repo_compact_id(crosslink_dir);
let agent_compact = crate::utils::generate_compact_id();
let compact_name = crate::utils::compose_compact_name(&repo_id, &agent_compact, &slug);
crate::utils::validate_compact_name(&compact_name)?;
let issue_id = if let Some(id) = opts.issue {
if db.get_issue(id)?.is_none() {
bail!("Issue {} not found", crate::utils::format_issue_id(id));
}
id
} else {
let id = if let Some(w) = writer {
w.create_issue(
db,
opts.description,
Some("Created by crosslink kickoff"),
"medium",
)?
} else {
db.create_issue(
opts.description,
Some("Created by crosslink kickoff"),
"medium",
)?
};
let label_err = writer.map_or_else(
|| db.add_label(id, "feature").err(),
|w| w.add_label(db, id, "feature").err(),
);
if let Some(e) = label_err {
tracing::warn!("could not label issue #{id} with 'feature': {e}");
}
if !opts.quiet {
println!("Created issue #{id}");
}
id
};
let (worktree_dir, branch_name) = if let Some(br) = opts.branch {
let wt_slug = br.strip_prefix("feature/").unwrap_or(br);
let worktree_dir = root.join(".worktrees").join(wt_slug);
if worktree_dir.exists() {
(worktree_dir, br.to_string())
} else {
create_worktree(&root, wt_slug, None)?
}
} else {
create_worktree(&root, &compact_name, None)?
};
std::fs::write(worktree_dir.join(".kickoff-slug"), &compact_name)
.context("Failed to write .kickoff-slug sentinel")?;
let conventions = detect_conventions(&root);
let prompt = build_prompt(opts, issue_id, &branch_name, &conventions);
std::fs::write(worktree_dir.join("KICKOFF.md"), &prompt)
.context("Failed to write KICKOFF.md")?;
if let Some(doc) = opts.design_doc {
if !doc.acceptance_criteria.is_empty() {
let source = opts.doc_path.unwrap_or("unknown");
let criteria_file = extract_criteria(doc, source);
let json = serde_json::to_string_pretty(&criteria_file)
.context("Failed to serialize criteria")?;
std::fs::write(worktree_dir.join(".kickoff-criteria.json"), &json)
.context("Failed to write .kickoff-criteria.json")?;
}
}
{
let metadata = KickoffMetadata {
started_at: chrono::Utc::now().to_rfc3339(),
timeout_secs: opts.timeout.as_secs(),
};
let json = serde_json::to_string_pretty(&metadata)
.context("Failed to serialize kickoff metadata")?;
std::fs::write(worktree_dir.join(".kickoff-metadata.json"), &json)
.context("Failed to write .kickoff-metadata.json")?;
}
exclude_kickoff_files(&worktree_dir)?;
if opts.dry_run {
println!("{prompt}");
println!("---");
println!("Worktree: {}", worktree_dir.display());
println!("Branch: {branch_name}");
println!("Agent: {compact_name}");
return Ok(compact_name);
}
let agent_id = init_worktree_agent(&worktree_dir, crosslink_dir, &compact_name)?;
let preflight = preflight.context("preflight check was skipped unexpectedly")?;
let allowed_tools = build_allowed_tools(&conventions, &opts.verify);
match &opts.container {
ContainerMode::None => {
let mut session_name = tmux_session_name(&compact_name);
if tmux_session_exists(&session_name) {
let suffix: u32 = rand_suffix();
session_name =
format!("{}-{}", &session_name[..session_name.len().min(58)], suffix);
}
launch_local(
&worktree_dir,
&session_name,
opts.model,
&allowed_tools,
opts.timeout,
preflight.timeout_cmd,
preflight.sandbox_command.as_deref(),
crosslink_dir,
opts.skip_permissions,
)?;
let _ = std::fs::write(worktree_dir.join(".kickoff-session"), &session_name);
if opts.quiet {
println!("{session_name}");
} else {
println!("Feature agent launched.");
println!();
println!(" Worktree: {}", worktree_dir.display());
println!(" Branch: {branch_name}");
println!(" Issue: #{issue_id}");
println!(" Agent: {agent_id}");
println!(" Session: {session_name}");
println!(" Verify: {:?}", opts.verify);
println!();
println!(" Approve trust: tmux attach -t {session_name}");
println!(" Check status: crosslink kickoff status {agent_id}");
if opts.verify == VerifyLevel::Ci || opts.verify == VerifyLevel::Thorough {
println!();
println!(" CI verification is enabled. The agent will push and open a draft PR after local tests pass.");
}
}
}
mode @ (ContainerMode::Docker | ContainerMode::Podman) => {
let container_id = launch_container(
mode,
&worktree_dir,
opts.image,
&agent_id,
opts.model,
&allowed_tools,
opts.timeout,
)?;
if opts.quiet {
println!("{container_id}");
} else {
let runtime = if *mode == ContainerMode::Docker {
"docker"
} else {
"podman"
};
println!("Feature agent launched in container.");
println!();
println!(" Worktree: {}", worktree_dir.display());
println!(" Branch: {branch_name}");
println!(" Issue: #{issue_id}");
println!(" Agent: {agent_id}");
println!(
" Container: {}",
&container_id[..12.min(container_id.len())]
);
println!(" Verify: {:?}", opts.verify);
println!();
println!(
" View logs: {} logs -f {}",
runtime,
&container_id[..12.min(container_id.len())]
);
println!(" Check status: crosslink kickoff status {agent_id}");
}
}
}
Ok(compact_name)
}