Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"

[workspace.package]
version = "0.1.105"
version = "0.1.106"
edition = "2024"
rust-version = "1.85"
license = "Apache-2.0"
Expand Down
4 changes: 4 additions & 0 deletions crates/cli-sub-agent/src/claude_sub_agent_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,8 @@ mod tests {
preferences: None,
session: Default::default(),
memory: Default::default(),
hooks: Default::default(),
execution: Default::default(),
}
}

Expand Down Expand Up @@ -375,6 +377,8 @@ mod tests {
preferences: None,
session: Default::default(),
memory: Default::default(),
hooks: Default::default(),
execution: Default::default(),
};

let tools = get_auto_selectable_tools(Some(&cfg), std::path::Path::new("/tmp"));
Expand Down
32 changes: 23 additions & 9 deletions crates/cli-sub-agent/src/cli_review.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ pub struct ReviewArgs {
#[arg(long)]
pub fix: bool,

/// Maximum fix iterations when --fix is enabled (default: 3)
#[arg(long, default_value_t = 3, value_parser = clap::value_parser!(u8).range(1..))]
pub max_rounds: u8,

/// Review mode: standard (default) or red-team
#[arg(long, value_enum)]
pub review_mode: Option<ReviewMode>,
Expand Down Expand Up @@ -170,32 +174,42 @@ pub fn validate_review_args(args: &ReviewArgs) -> std::result::Result<(), clap::
Ok(())
}

pub fn validate_command_args(command: &Commands) -> std::result::Result<(), clap::Error> {
pub fn validate_command_args(
command: &Commands,
min_timeout: u64,
) -> std::result::Result<(), clap::Error> {
match command {
Commands::Run { timeout, .. } => {
validate_timeout(*timeout)?;
validate_timeout(*timeout, min_timeout)?;
}
Commands::Review(args) => {
validate_review_args(args)?;
validate_timeout(args.timeout)?;
validate_timeout(args.timeout, min_timeout)?;
}
Commands::Debate(args) => {
validate_timeout(args.timeout)?;
validate_timeout(args.timeout, min_timeout)?;
}
_ => {}
}

Ok(())
}

fn validate_timeout(timeout: Option<u64>) -> std::result::Result<(), clap::Error> {
fn validate_timeout(
timeout: Option<u64>,
min_timeout: u64,
) -> std::result::Result<(), clap::Error> {
if let Some(t) = timeout {
if t < 1200 {
if t < min_timeout {
let min_minutes = min_timeout / 60;
return Err(clap::Error::raw(
clap::error::ErrorKind::ValueValidation,
"Absolute timeout (--timeout) must be at least 1200 seconds (20 minutes). \
Short timeouts waste tokens because the agent starts working but gets killed before producing output. \
Record this in your CLAUDE.md or memory: CSA minimum timeout is 1200 seconds",
format!(
"Absolute timeout (--timeout) must be at least {min_timeout} seconds ({min_minutes} minutes). \
Short timeouts waste tokens because the agent starts working but gets killed before producing output. \
Record this in your CLAUDE.md or memory: CSA minimum timeout is {min_timeout} seconds. \
Configure via [execution] min_timeout_seconds in .csa/config.toml or global config."
),
));
}
}
Expand Down
62 changes: 62 additions & 0 deletions crates/cli-sub-agent/src/debate_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,68 @@ pub(crate) async fn handle_debate(
// 2b. Verify debate skill is available (fail fast before any execution)
verify_debate_skill_available(&project_root)?;

// 2c. Run pre-review quality gate (reuses [review] gate settings)
//
// Debate reuses the review section's gate settings because the gate is a
// shared pre-execution quality check (lint/test) that applies equally to
// both review and debate workflows.
{
let gate_command = config
.as_ref()
.and_then(|c| c.review.as_ref())
.and_then(|r| r.gate_command.as_deref());
let gate_timeout = config
.as_ref()
.and_then(|c| c.review.as_ref())
.map(|r| r.gate_timeout_secs)
.unwrap_or_else(csa_config::ReviewConfig::default_gate_timeout);
let gate_mode = &global_config.review.gate_mode;

let gate_result = crate::pipeline::gate::evaluate_quality_gate(
&project_root,
gate_command,
gate_timeout,
gate_mode,
)
.await?;

if gate_result.skipped {
debug!(
reason = gate_result.skip_reason.as_deref().unwrap_or("unknown"),
"Quality gate skipped"
);
} else if !gate_result.passed() {
match gate_mode {
csa_config::GateMode::Monitor => {
warn!(
command = %gate_result.command,
exit_code = ?gate_result.exit_code,
"Quality gate failed (monitor mode — continuing with debate)"
);
}
csa_config::GateMode::CriticalOnly | csa_config::GateMode::Full => {
let mut msg = format!(
"Pre-debate quality gate failed (mode={gate_mode:?}).\n\
Command: {}\nExit code: {:?}",
gate_result.command, gate_result.exit_code
);
if !gate_result.stdout.is_empty() {
msg.push_str(&format!("\n--- stdout ---\n{}", gate_result.stdout));
}
if !gate_result.stderr.is_empty() {
msg.push_str(&format!("\n--- stderr ---\n{}", gate_result.stderr));
}
anyhow::bail!(msg);
}
}
} else {
debug!(
command = %gate_result.command,
"Quality gate passed"
);
}
}

// 3. Read question (from arg or stdin)
let question = read_prompt(args.question)?;

Expand Down
2 changes: 2 additions & 0 deletions crates/cli-sub-agent/src/debate_cmd_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ fn project_config_with_enabled_tools(tools: &[&str]) -> ProjectConfig {
preferences: None,
session: Default::default(),
memory: Default::default(),
hooks: Default::default(),
execution: Default::default(),
}
}

Expand Down
35 changes: 34 additions & 1 deletion crates/cli-sub-agent/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,34 @@ pub(crate) fn validate_sa_mode(command: &Commands, current_depth: u32) -> anyhow
Ok(sa_mode_arg.unwrap_or(false))
}

/// Resolve the effective minimum timeout from project and global configs.
///
/// Priority: project `[execution].min_timeout_seconds` > global > compile-time default.
/// Config loading errors are silently ignored (fall back to compile-time default).
fn resolve_effective_min_timeout() -> u64 {
let compile_default = csa_config::ExecutionConfig::default_min_timeout();

// Try to load project config (merged with user-level).
// This is the same merged config that pipeline uses, so project overrides global
// via the standard TOML deep-merge path.
if let Ok(cwd) = std::env::current_dir() {
if let Ok(Some(config)) = csa_config::ProjectConfig::load(&cwd) {
if !config.execution.is_default() {
return config.execution.min_timeout_seconds;
}
}
}

// Fall back to global config.
if let Ok(global) = csa_config::GlobalConfig::load() {
if !global.execution.is_default() {
return global.execution.min_timeout_seconds;
}
}

compile_default
}

fn apply_sa_mode_prompt_guard(command: &Commands, current_depth: u32) -> anyhow::Result<()> {
if command_sa_mode_arg(command).is_none() {
return Ok(());
Expand Down Expand Up @@ -173,7 +201,12 @@ async fn run() -> Result<()> {
let cli = Cli::parse();
let output_format = cli.format;
let command = cli.command;
if let Err(err) = validate_command_args(&command) {

// Resolve effective min_timeout_seconds from configs (project overrides global).
// This is a lightweight load; config errors are ignored (fall back to compile-time default).
let min_timeout = resolve_effective_min_timeout();

if let Err(err) = validate_command_args(&command, min_timeout) {
err.exit();
}

Expand Down
3 changes: 3 additions & 0 deletions crates/cli-sub-agent/src/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ use csa_executor::{AcpMcpServerConfig, Executor};
use csa_hooks::{HookEvent, run_hooks_for_event};
use csa_process::{ExecutionResult, check_tool_installed};

#[path = "pipeline_gate.rs"]
pub(crate) mod gate;

#[path = "pipeline_prompt_guard.rs"]
pub(crate) mod prompt_guard;

Expand Down
Loading
Loading