ai-dispatch 11.2.0

Multi-AI CLI team orchestrator
// Prompt resolution, context flags, skills, worktree paths, omission events.
// Deps: RunArgs, explicit project config, agents, templates, and store.
use anyhow::{Context, Result};

use crate::{
    agent, prompt::skills, store::Store, prompt::templates, types::*,
};
use crate::cmd::run::{RunArgs, NO_SKILL_SENTINEL};

use super::run_process::current_branch;

pub(crate) fn resolve_prompt(prompt: &str, template: Option<&str>) -> Result<String> {
    let raw = prompt.to_string();
    if let Some(template) = template {
        let template_content = templates::load_template(template)?;
        Ok(templates::apply_template(&template_content, &raw))
    } else { Ok(raw) }
}

const RUST_CACHE_PROMPT_LINE: &str =
    "Rust project: CARGO_TARGET_DIR points at a warm shared target; do not override. Use 'aid build' for cargo check/clippy (clean, deduplicated compiler errors). Use 'aid test' for tests — zero-match filters fail and digests name which tests ran.";
const BATCH_SIBLING_LIMIT: usize = 10;
const BATCH_SIBLING_PROMPT_LIMIT: usize = 80;

pub(crate) fn sanitize_injected_text(text: &str) -> String {
    let mut result = Vec::new();
    let mut inside = false;
    for line in text.lines() {
        let trimmed = line.trim_start();
        if trimmed.starts_with("<aid-") && !trimmed.starts_with("</aid-") {
            inside = true;
            continue;
        }
        if trimmed.starts_with("</aid-") {
            inside = false;
            continue;
        }
        if !inside {
            result.push(line);
        }
    }
    result.join("\n")
}

fn truncate_batch_sibling_prompt(prompt: &str, omissions: &mut Vec<String>) -> String {
    let mut preview: String = prompt.chars().take(BATCH_SIBLING_PROMPT_LIMIT).collect();
    if prompt.chars().count() > BATCH_SIBLING_PROMPT_LIMIT {
        let count = prompt.chars().count() - BATCH_SIBLING_PROMPT_LIMIT;
        let note = format!("Batch Siblings: {count} characters of entry omitted");
        omissions.push(note.clone());
        preview.push_str(&format!("... [{note}]"));
    }
    preview
}

pub(super) fn format_batch_siblings(
    siblings: &[(String, String, String)], omissions: &mut Vec<String>,
) -> String {
    let shown = siblings
        .iter()
        .take(BATCH_SIBLING_LIMIT)
        .map(|(name, agent, prompt)| {
            format!(
                "- \"{}\" ({}): {}",
                name,
                agent,
                truncate_batch_sibling_prompt(prompt, omissions)
            )
        })
        .collect::<Vec<_>>();
    let remaining = siblings.len().saturating_sub(BATCH_SIBLING_LIMIT);
    let mut lines = vec![
        "<aid-batch-siblings>".to_string(),
        "Other tasks running in this batch:".to_string(),
    ];
    lines.extend(shown);
    if remaining > 0 {
        let note = format!("Batch Siblings: {remaining} siblings omitted");
        omissions.push(note.clone());
        lines.push(format!("+ {remaining} more [{note}]"));
    }
    lines.push("</aid-batch-siblings>".to_string());
    crate::task::workgroup::bound_auxiliary(&lines.join("\n"), "Batch Siblings", omissions)
}

pub(crate) fn inject_skill(
    prompt: &str,
    agent_kind: &AgentKind,
    requested_skills: &[String],
    required: bool,
    omissions: &mut Vec<String>,
) -> Result<String> {
    if requested_skills.is_empty() { return Ok(prompt.to_string()); }
    let mut sections = Vec::new();
    for name in requested_skills {
        let skill_text = match skills::load_skill(name) {
            Ok(text) => text,
            Err(error) if !required => {
                aid_warn!("[aid] Project default skill unavailable: {name} ({error})");
                continue;
            }
            Err(error) => return Err(error),
        };
        if let Some(gotchas) = skills::load_skill_gotchas(name, agent_kind) {
            sections.push(format!("--- Gotchas ---\n{gotchas}"));
        }
        sections.push(format!("--- Methodology ---\n{skill_text}"));
        append_skill_resources(name, &mut sections);
    }
    let context = crate::task::workgroup::bound_auxiliary(
        &sections.join("\n\n"), "Methodology", omissions,
    );
    Ok(format!("{prompt}\n\n{context}"))
}

fn append_skill_resources(name: &str, sections: &mut Vec<String>) {
    let scripts = skills::load_skill_scripts(name);
    if !scripts.is_empty() {
        sections.push(
            format!(
                "{}\n{}",
                skills::format_script_instructions(&scripts)
                    .replacen("--- Available Tools ---", "--- Available Scripts ---", 1),
                scripts
                    .iter()
                    .map(|script| format!("- {}", script.path.display()))
                    .collect::<Vec<_>>()
                    .join("\n")
            ),
        );
    }
    let references = skills::list_skill_references(name);
    if !references.is_empty() {
        sections.push(format!(
            "--- References (read on demand) ---\nFor detailed reference, read these files when needed:\n{}",
            references
                .iter()
                .map(|path| format!("- {path}"))
                .collect::<Vec<_>>()
                .join("\n")
        ));
    }
}

pub(crate) fn build_context_flags(agent_kind: &AgentKind, context_args: &[String]) -> Result<(Option<String>, Vec<String>)> {
    if context_args.is_empty() { return Ok((None, vec![])); }
    let specs = crate::prompt::context::parse_context_specs(context_args)?;
    let context_files = expand_context_paths(&specs);
    if *agent_kind == AgentKind::OpenCode || *agent_kind == AgentKind::Kilo || *agent_kind == AgentKind::MiMoCode {
        let hints: Vec<String> = specs.iter().filter_map(|spec| spec.items.as_ref().map(|items| format!("Focus on: {} in {}", items.join(", "), spec.file))).collect();
        let file_context = (!hints.is_empty()).then(|| hints.join("\n"));
        return Ok((file_context, context_files));
    }
    if agent::agent_has_fs_access(agent_kind) { return Ok((Some(crate::prompt::context::resolve_context_pointers(&specs)), vec![])); }
    let file_context = if specs.iter().all(|spec| spec.items.is_none()) {
        let mut blocks = Vec::new();
        for spec in &specs { let content = read_context_file(&spec.file)?; blocks.push(format_context_block(&spec.file, &content)); }
        blocks.join("\n\n")
    } else { crate::prompt::context::resolve_context(&specs)? };
    Ok((Some(file_context), vec![]))
}

pub(crate) fn rust_cache_prompt_line(dir: Option<&str>) -> Option<&'static str> {
    agent::is_rust_project(dir).then_some(RUST_CACHE_PROMPT_LINE)
}

pub(crate) fn expand_context_paths(specs: &[crate::prompt::context::ContextSpec]) -> Vec<String> { specs.iter().map(|spec| spec.file.clone()).collect() }

pub(crate) fn read_context_file(path: &str) -> Result<String> { std::fs::read_to_string(path).with_context(|| format!("Failed to read context file: {}", path)) }

pub(crate) fn format_context_block(path: &str, content: &str) -> String { format!("### {}\n```rust\n{}\n```", path, content.trim()) }

/// Explicit skills override defaults from the dispatch's resolved project;
/// read-only runs receive no project default.
pub(crate) fn effective_skills(args: &RunArgs, project: Option<&crate::config::project::ProjectConfig>) -> Vec<String> {
    let declared: Vec<String> = args
        .skills
        .iter()
        .filter(|skill| skill.as_str() != NO_SKILL_SENTINEL)
        .cloned()
        .collect();
    if !declared.is_empty() {
        return declared;
    }
    // An explicit "no skills" is a decision, not an omission, so it must not
    // fall through to the project default.
    // Project defaults are write methodologies; a read-only run gets none.
    if args.read_only || args.skills.iter().any(|skill| skill.as_str() == NO_SKILL_SENTINEL) {
        return Vec::new();
    }
    project.map(|config| config.skills.clone()).unwrap_or_default()
}

pub(crate) fn resolve_repo_path(path: &str) -> Result<String> {
    crate::support::repo_root::resolve_git_root_string(path)
}

pub(crate) fn resolve_dir_in_target(base_dir: &str, dir: Option<&str>, repo_dir: Option<&str>) -> String {
    let Some(dir) = dir else { return base_dir.to_string() };
    let dir_path = std::path::Path::new(dir);
    if dir_path == std::path::Path::new(".") { return base_dir.to_string(); }
    if dir_path.is_absolute() && let Some(repo_dir) = repo_dir && let Ok(relative_dir) = dir_path.strip_prefix(repo_dir) {
        return std::path::Path::new(base_dir).join(relative_dir).to_string_lossy().to_string();
    }
    if dir_path.is_absolute() { return dir.to_string(); }
    std::path::Path::new(base_dir).join(dir_path).to_string_lossy().to_string()
}

/// Returns (wt_path, wt_branch, effective_dir, resolved_repo_path, fresh_worktree).
/// The resolved_repo_path is always populated when a worktree is created, even if --repo wasn't passed.
type WorktreePaths = (Option<String>, Option<String>, Option<String>, Option<String>, bool);
pub(crate) fn resolve_worktree_paths(args: &RunArgs, repo_path: Option<&str>) -> Result<WorktreePaths> {
    if let Some(ref branch) = args.worktree {
        let repo_dir = match repo_path {
            Some(path) => path.to_string(),
            None => resolve_repo_path(args.dir.as_deref().unwrap_or("."))?,
        };
        // Use explicit base_branch, or default to current branch (not just HEAD)
        // so worktrees inherit the latest state of whatever branch the user is on
        let default_base = args.base_branch.clone().or_else(|| current_branch(std::path::Path::new(&repo_dir)));
        let base = if args.base_branch.is_none() {
            crate::workspace::worktree::branch_tip_resume_base(std::path::Path::new(&repo_dir), branch)?
                .or(default_base)
        } else {
            default_base
        };
        let info = crate::workspace::worktree::create_worktree(std::path::Path::new(&repo_dir), branch, base.as_deref())?;
        let p = info.path.to_string_lossy().to_string();
        return Ok((Some(p.clone()), Some(info.branch), Some(resolve_dir_in_target(&p, args.dir.as_deref(), Some(&repo_dir))), Some(repo_dir), info.created));
    }
    if let Some(repo_dir) = repo_path {
        return Ok((None, None, Some(resolve_dir_in_target(repo_dir, args.dir.as_deref(), Some(repo_dir))), Some(repo_dir.to_string()), false));
    }
    Ok((None, None, args.dir.clone(), None, false))
}

pub(crate) fn load_workgroup(store: &Store, group_id: Option<&str>) -> Result<Option<Workgroup>> {
    let Some(group_id) = group_id else { return Ok(None) };
    if let Some(wg) = store.get_workgroup(group_id)? {
        return Ok(Some(wg));
    }
    println!("[aid] Auto-created workgroup '{}'", group_id);
    Ok(Some(store.create_workgroup(group_id, "", Some("auto"), Some(group_id))?))
}

pub(super) fn record_prompt_omissions(store: &Store, task_id: &str, omissions: &[String]) -> Result<()> {
    if omissions.is_empty() {
        return Ok(());
    }
    let detail = format!("Prompt context bounded: {}", omissions.join("; "));
    store.insert_event(&TaskEvent {
        task_id: TaskId(task_id.to_string()),
        timestamp: chrono::Local::now(),
        event_kind: EventKind::Setup,
        detail: detail.clone(),
        metadata: Some(serde_json::json!({"type": "prompt_context_bounded", "omissions": omissions})),
    })?;
    aid_warn!("[aid] Warning: {detail}");
    Ok(())
}