cueloop 0.6.0

A Rust CLI for managing AI agent loops with a structured JSON task queue
Documentation
//! Task decomposition command handler for `cueloop task decompose`.
//!
//! Purpose:
//! - Task decomposition command handler for `cueloop task decompose`.
//!
//! Responsibilities:
//! - Read source text from CLI args or stdin.
//! - Resolve runner overrides and delegate planning/materialization to command helpers.
//! - Render deterministic preview and write summaries for users or JSON consumers.
//! - Narrate decomposition as a continuation workflow with explicit next steps from the shared machine document.
//!
//! Not handled here:
//! - Planner prompt rendering or runner execution details.
//! - Queue mutation internals.
//!
//!
//! Usage:
//! - Used through the crate module tree or integration test harness.
//!
//! Invariants/assumptions:
//! - Preview is always shown before any optional write summary.
//! - `--write` is the only mutating mode.

use anyhow::{Result, bail};

use crate::agent;
use crate::cli::task::args::{
    TaskDecomposeArgs, TaskDecomposeChildPolicyArg, TaskDecomposeFormatArg,
};
use crate::commands::task as task_cmd;
use crate::config;
use crate::contracts::MachineDecomposeDocument;

pub fn handle(args: &TaskDecomposeArgs, force: bool, resolved: &config::Resolved) -> Result<()> {
    if let Some(checkpoint_id) = args.from_preview.as_deref() {
        validate_from_preview_args(args)?;
        let (preview, checkpoint) =
            task_cmd::load_decomposition_preview_checkpoint(resolved, checkpoint_id)?;
        let write_result = Some(task_cmd::write_task_decomposition(
            resolved, &preview, force,
        )?);
        let document = crate::cli::machine::build_task_decompose_document(
            &preview,
            write_result.as_ref(),
            Some(&checkpoint),
        );
        match args.format {
            TaskDecomposeFormatArg::Text => {
                print_text_output(&preview, write_result.as_ref(), &document)
            }
            TaskDecomposeFormatArg::Json => {
                println!("{}", serde_json::to_string_pretty(&document)?)
            }
        }
        return Ok(());
    }

    let source = source_from_args(resolved, &args.source, args.from_file.as_deref())?;
    let overrides = agent::resolve_agent_overrides(&agent::AgentArgs {
        runner: args.runner.clone(),
        model: args.model.clone(),
        effort: args.effort.clone(),
        repo_prompt: args.repo_prompt,
        runner_cli: args.runner_cli.clone(),
    })?;

    let status = args.status.into();
    let parent_status = args.parent_status.map(Into::into).unwrap_or(status);
    let leaf_status = args.leaf_status.map(Into::into).unwrap_or(status);
    let preview = task_cmd::plan_task_decomposition(
        resolved,
        &task_cmd::TaskDecomposeOptions {
            source,
            attach_to_task_id: args.attach_to.clone(),
            max_depth: args.max_depth,
            max_children: usize::from(args.max_children),
            max_nodes: usize::from(args.max_nodes),
            status,
            parent_status,
            leaf_status,
            child_policy: child_policy(args.child_policy),
            with_dependencies: args.with_dependencies,
            runner_override: overrides.runner,
            model_override: overrides.model,
            reasoning_effort_override: overrides.reasoning_effort,
            runner_cli_overrides: overrides.runner_cli,
            repoprompt_tool_injection: agent::resolve_rp_required(args.repo_prompt, resolved),
            stream_planner_output: args.format == TaskDecomposeFormatArg::Text,
            force,
        },
    )?;

    let write_result = if args.write {
        Some(task_cmd::write_task_decomposition(
            resolved, &preview, force,
        )?)
    } else {
        None
    };
    let checkpoint = if args.write {
        None
    } else {
        Some(task_cmd::save_decomposition_preview_checkpoint(
            resolved, &preview,
        )?)
    };
    let document = crate::cli::machine::build_task_decompose_document(
        &preview,
        write_result.as_ref(),
        checkpoint.as_ref(),
    );

    match args.format {
        TaskDecomposeFormatArg::Text => {
            print_text_output(&preview, write_result.as_ref(), &document)
        }
        TaskDecomposeFormatArg::Json => println!("{}", serde_json::to_string_pretty(&document)?),
    }

    Ok(())
}

fn validate_from_preview_args(args: &TaskDecomposeArgs) -> Result<()> {
    if !args.write {
        bail!("`cueloop task decompose --from-preview` requires --write for queue mutation.");
    }
    if args.preview {
        bail!("`cueloop task decompose --from-preview` cannot be combined with --preview.");
    }
    if !args.source.is_empty() || args.from_file.is_some() {
        bail!(
            "`cueloop task decompose --from-preview` cannot be combined with SOURCE text or --from-file."
        );
    }
    if args.attach_to.is_some()
        || args.with_dependencies
        || args.max_depth != 3
        || args.max_children != 5
        || args.max_nodes != 50
        || args.status != crate::cli::task::args::TaskStatusArg::Draft
        || args.parent_status.is_some()
        || args.leaf_status.is_some()
        || args.child_policy != TaskDecomposeChildPolicyArg::Fail
        || args.runner.is_some()
        || args.model.is_some()
        || args.effort.is_some()
        || args.repo_prompt.is_some()
    {
        bail!(
            "`cueloop task decompose --from-preview` replays saved preview options and cannot be combined with planner/status flags. Do not add --leaf-status, --parent-status, --with-dependencies, or other planner options; the preview already captured them."
        );
    }
    Ok(())
}

fn source_from_args(
    resolved: &config::Resolved,
    source_args: &[String],
    from_file: Option<&std::path::Path>,
) -> Result<task_cmd::TaskDecomposeSourceInput> {
    if let Some(path) = from_file {
        if !source_args.is_empty() {
            bail!(
                "`cueloop task decompose --from-file` cannot be combined with positional SOURCE text."
            );
        }
        return task_cmd::read_plan_file_source(resolved, path);
    }
    Ok(task_cmd::TaskDecomposeSourceInput::Inline(
        task_cmd::read_request_from_args_or_stdin(source_args)?,
    ))
}

fn child_policy(value: TaskDecomposeChildPolicyArg) -> task_cmd::DecompositionChildPolicy {
    match value {
        TaskDecomposeChildPolicyArg::Fail => task_cmd::DecompositionChildPolicy::Fail,
        TaskDecomposeChildPolicyArg::Append => task_cmd::DecompositionChildPolicy::Append,
        TaskDecomposeChildPolicyArg::Replace => task_cmd::DecompositionChildPolicy::Replace,
    }
}

fn print_text_output(
    preview: &task_cmd::DecompositionPreview,
    write_result: Option<&task_cmd::TaskDecomposeWriteResult>,
    document: &MachineDecomposeDocument,
) {
    match &preview.source {
        task_cmd::DecompositionSource::Freeform { request } => {
            println!("Decompose preview for new request:");
            println!("  {}", request);
        }
        task_cmd::DecompositionSource::ExistingTask { task } => {
            println!("Decompose preview for existing task {}:", task.id);
            println!("  {}", task.title);
        }
        task_cmd::DecompositionSource::PlanFile { path, .. } => {
            println!("Decompose preview for plan file:");
            println!("  {}", path);
        }
    }

    if let Some(attach_target) = &preview.attach_target {
        println!("Attach target:");
        println!("  {}: {}", attach_target.task.id, attach_target.task.title);
        if attach_target.has_existing_children {
            println!(
                "  Existing child tasks detected; policy {:?} will govern write behavior.",
                preview.child_policy
            );
        }
    }

    println!();
    print_node(&preview.plan.root, 0);
    println!();
    println!(
        "Stats: {} node(s), {} leaf node(s).",
        preview.plan.total_nodes, preview.plan.leaf_nodes
    );
    let actionability = preview.plan.actionability();
    println!(
        "Root/group task: {} ({}, kind: {}).",
        actionability.root_group.title,
        actionability
            .root_group
            .planner_key
            .as_deref()
            .unwrap_or("root"),
        actionability.root_group.kind
    );
    if let Some(first_leaf) = actionability.first_actionable_leaf.as_ref() {
        println!(
            "First actionable leaf: {} ({}, kind: {}).",
            first_leaf.title,
            first_leaf.planner_key.as_deref().unwrap_or("leaf"),
            first_leaf.kind
        );
    }
    println!(
        "Planner options: child policy {:?}, sibling dependencies {}.",
        preview.child_policy,
        if preview.with_dependencies {
            "enabled"
        } else {
            "disabled"
        }
    );
    if !preview.plan.dependency_edges.is_empty() {
        println!("Dependency edges:");
        for edge in &preview.plan.dependency_edges {
            println!(
                "  - {} depends on {}",
                edge.task_title, edge.depends_on_title
            );
        }
    }
    if !preview.plan.warnings.is_empty() {
        println!("Decomposition notes:");
        for warning in &preview.plan.warnings {
            println!("  - {}", warning);
        }
    }

    if let Some(result) = write_result {
        println!();
        if let Some(root_id) = &result.root_task_id {
            println!("Wrote decomposition rooted at {}.", root_id);
        } else if let Some(parent_id) = &result.parent_task_id {
            println!("Wrote decomposition under existing parent {}.", parent_id);
        }
        if let Some(root_group_id) = &result.root_group_task_id {
            println!("Root/group task: {}.", root_group_id);
        }
        if let Some(first_leaf_id) = &result.first_actionable_leaf_task_id {
            println!("First actionable leaf task: {}.", first_leaf_id);
        }
        println!("Created {} task(s):", result.created_ids.len());
        for id in &result.created_ids {
            println!("  - {}", id);
        }
        if !result.replaced_ids.is_empty() {
            println!(
                "Replaced {} prior child task(s):",
                result.replaced_ids.len()
            );
            for id in &result.replaced_ids {
                println!("  - {}", id);
            }
        }
        if result.parent_annotated
            && let Some(parent_id) = &result.parent_task_id
        {
            println!(
                "Annotated parent task {} with a decomposition note.",
                parent_id
            );
        }
    }

    println!();
    println!("{}", document.continuation.headline);
    println!("{}", document.continuation.detail);
    if let Some(blocking) = document
        .blocking
        .as_ref()
        .or(document.continuation.blocking.as_ref())
    {
        println!();
        println!(
            "Operator state: {}",
            format!("{:?}", blocking.status).to_lowercase()
        );
        println!("{}", blocking.message);
        if !blocking.detail.is_empty() {
            println!("{}", blocking.detail);
        }
    }
    if !document.continuation.next_steps.is_empty() {
        println!();
        println!("Next:");
        for (index, step) in document.continuation.next_steps.iter().enumerate() {
            println!("  {}. {}{}", index + 1, step.command, step.detail);
        }
    }
}

fn print_node(node: &task_cmd::PlannedNode, depth: usize) {
    let deps = if node.depends_on_keys.is_empty() {
        String::new()
    } else {
        format!(" [depends_on: {}]", node.depends_on_keys.join(", "))
    };
    println!(
        "{}- {} ({}){}",
        "  ".repeat(depth),
        node.title,
        node.planner_key,
        deps
    );
    for child in &node.children {
        print_node(child, depth + 1);
    }
}