diff --git a/Cargo.lock b/Cargo.lock index b22d6d67..3d69734c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -515,7 +515,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cli-sub-agent" -version = "0.1.201" +version = "0.1.202" dependencies = [ "anyhow", "chrono", @@ -704,7 +704,7 @@ dependencies = [ [[package]] name = "csa-acp" -version = "0.1.201" +version = "0.1.202" dependencies = [ "agent-client-protocol", "anyhow", @@ -724,7 +724,7 @@ dependencies = [ [[package]] name = "csa-config" -version = "0.1.201" +version = "0.1.202" dependencies = [ "anyhow", "chrono", @@ -740,7 +740,7 @@ dependencies = [ [[package]] name = "csa-core" -version = "0.1.201" +version = "0.1.202" dependencies = [ "agent-teams", "chrono", @@ -755,7 +755,7 @@ dependencies = [ [[package]] name = "csa-eval" -version = "0.1.201" +version = "0.1.202" dependencies = [ "anyhow", "chrono", @@ -769,7 +769,7 @@ dependencies = [ [[package]] name = "csa-executor" -version = "0.1.201" +version = "0.1.202" dependencies = [ "agent-teams", "anyhow", @@ -795,7 +795,7 @@ dependencies = [ [[package]] name = "csa-hooks" -version = "0.1.201" +version = "0.1.202" dependencies = [ "anyhow", "chrono", @@ -812,7 +812,7 @@ dependencies = [ [[package]] name = "csa-lock" -version = "0.1.201" +version = "0.1.202" dependencies = [ "anyhow", "chrono", @@ -824,7 +824,7 @@ dependencies = [ [[package]] name = "csa-mcp-hub" -version = "0.1.201" +version = "0.1.202" dependencies = [ "anyhow", "axum", @@ -846,7 +846,7 @@ dependencies = [ [[package]] name = "csa-memory" -version = "0.1.201" +version = "0.1.202" dependencies = [ "anyhow", "async-trait", @@ -864,7 +864,7 @@ dependencies = [ [[package]] name = "csa-process" -version = "0.1.201" +version = "0.1.202" dependencies = [ "anyhow", "chrono", @@ -882,7 +882,7 @@ dependencies = [ [[package]] name = "csa-resource" -version = "0.1.201" +version = "0.1.202" dependencies = [ "anyhow", "csa-core", @@ -898,7 +898,7 @@ dependencies = [ [[package]] name = "csa-scheduler" -version = "0.1.201" +version = "0.1.202" dependencies = [ "anyhow", "chrono", @@ -916,7 +916,7 @@ dependencies = [ [[package]] name = "csa-session" -version = "0.1.201" +version = "0.1.202" dependencies = [ "anyhow", "chrono", @@ -937,7 +937,7 @@ dependencies = [ [[package]] name = "csa-todo" -version = "0.1.201" +version = "0.1.202" dependencies = [ "anyhow", "chrono", @@ -4367,7 +4367,7 @@ dependencies = [ [[package]] name = "weave" -version = "0.1.201" +version = "0.1.202" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 241faae1..920e7d98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.1.201" +version = "0.1.202" edition = "2024" rust-version = "1.88" license = "Apache-2.0" diff --git a/crates/cli-sub-agent/src/plan_cmd_steps.rs b/crates/cli-sub-agent/src/plan_cmd_steps.rs index eadc5145..e9a5e261 100644 --- a/crates/cli-sub-agent/src/plan_cmd_steps.rs +++ b/crates/cli-sub-agent/src/plan_cmd_steps.rs @@ -8,6 +8,7 @@ use tracing::{error, info, warn}; use csa_config::ProjectConfig; use csa_core::types::ToolName; use csa_executor::ModelSpec; +use csa_hooks::format_next_step_directive; use weave::compiler::{ExecutionPlan, FailAction, PlanStep}; use super::plan_cmd_exec::{ @@ -267,6 +268,21 @@ pub(super) async fn execute_plan_with_journal( persist_plan_journal(path, run_ctx.journal)?; } + // Emit CSA:NEXT_STEP directive for pipeline chaining. + // On success: point to the next step in the plan. + // On failure: no directive (pipeline stops on abort). + if !is_failure + && !result.skipped + && let Some(next_step) = find_next_step(step, &plan.steps) + { + let cmd = format!( + "csa plan run --step {} \"{}\"", + next_step.id, next_step.title + ); + let required = matches!(next_step.on_fail, FailAction::Abort); + eprintln!("{}", format_next_step_directive(&cmd, required)); + } + // Abort on failure when: on_fail=abort, or retry exhausted (retries // already happened inside execute_step; reaching here means all failed), // or delegate (unsupported in v1 — treated as abort). @@ -615,3 +631,11 @@ pub(crate) fn should_inject_assignment_markers(step: &PlanStep) -> bool { pub(crate) fn is_assignment_marker_key(key: &str) -> bool { validate_variable_name(key).is_ok() } + +/// Find the next step in the plan after the current step. +/// +/// Returns the first step with an ID greater than the current step's ID, +/// which is the sequential successor in a linear workflow. +fn find_next_step<'a>(current: &PlanStep, steps: &'a [PlanStep]) -> Option<&'a PlanStep> { + steps.iter().find(|s| s.id > current.id) +} diff --git a/crates/cli-sub-agent/src/review_cmd.rs b/crates/cli-sub-agent/src/review_cmd.rs index c1beeac7..2fafe2d0 100644 --- a/crates/cli-sub-agent/src/review_cmd.rs +++ b/crates/cli-sub-agent/src/review_cmd.rs @@ -21,6 +21,9 @@ use csa_core::consensus::AgentResponse; use csa_core::types::{OutputFormat, ToolName}; use csa_session::state::ReviewSessionMeta; +/// Next-step command emitted after a clean review verdict for pipeline chaining. +const NEXT_STEP_PR_BOT_CMD: &str = "csa plan run patterns/dev2merge/workflow.toml --step pr-bot"; + #[path = "review_cmd_output.rs"] mod output; use output::{ @@ -374,6 +377,16 @@ pub(crate) async fn handle_review(args: ReviewArgs, current_depth: u32) -> Resul ], &project_root, ); + + // Emit CSA:NEXT_STEP directive for pipeline chaining. + // Orchestrators can parse this to mechanically chain review → pr-bot. + if verdict == CLEAN { + eprintln!( + "{}", + csa_hooks::format_next_step_directive(NEXT_STEP_PR_BOT_CMD, true,) + ); + } + return Ok(effective_exit_code); } @@ -430,6 +443,14 @@ pub(crate) async fn handle_review(args: ReviewArgs, current_depth: u32) -> Resul &project_root, ); + // Emit CSA:NEXT_STEP directive for pipeline chaining after fix loop. + if fix_passed { + eprintln!( + "{}", + csa_hooks::format_next_step_directive(NEXT_STEP_PR_BOT_CMD, true,) + ); + } + return fix_exit_code; } diff --git a/crates/csa-hooks/src/directive.rs b/crates/csa-hooks/src/directive.rs index cfd90da5..decd5b0a 100644 --- a/crates/csa-hooks/src/directive.rs +++ b/crates/csa-hooks/src/directive.rs @@ -1,13 +1,23 @@ //! CSA directive parsing from hook/step output. //! -//! Directives are HTML-comment-style markers embedded in stdout: -//! `` — instruct weave to jump to a step. +//! Directives are HTML-comment-style markers embedded in stdout/stderr: +//! +//! - `` — instruct weave to jump to a step. +//! - `` — suggest a +//! follow-up command for orchestrators to chain steps mechanically. +//! +//! Both forms can coexist; the richer `cmd=` form is preferred for pipeline +//! enforcement while `step_id=` is used for intra-workflow jumps. -/// A parsed CSA directive from hook or step output. +/// A parsed CSA next-step directive from hook or step output. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum CsaDirective { - /// Jump to the named weave step. - NextStep { step_id: String }, +pub struct NextStepDirective { + /// Intra-workflow step ID to jump to (used by weave executor). + pub step_id: Option, + /// Shell command for the orchestrator to run next. + pub cmd: Option, + /// Whether this next step is required (pipeline enforcement). + pub required: bool, } /// Parse all `CSA:NEXT_STEP` directives from text output. @@ -15,25 +25,126 @@ pub enum CsaDirective { /// Returns the **last** `NEXT_STEP` directive found (later directives /// override earlier ones, matching the "last writer wins" convention). pub fn parse_next_step(output: &str) -> Option { - let mut last_step_id = None; + parse_next_step_directive(output).and_then(|d| d.step_id) +} + +/// Parse a rich `NextStepDirective` from text output. +/// +/// Supports both legacy `step_id=` and extended `cmd="..." required=true|false`. +/// Returns the **last** directive found. +pub fn parse_next_step_directive(output: &str) -> Option { + let mut last: Option = None; for line in output.lines() { let trimmed = line.trim(); - // Match: + // Match: if let Some(rest) = trimmed.strip_prefix("") { let rest = rest.trim(); - if let Some(value) = rest.strip_prefix("step_id=") { - let id = value.trim().trim_matches('"').trim(); - if !id.is_empty() { - last_step_id = Some(id.to_string()); + if rest.is_empty() { + continue; + } + + let mut step_id = None; + let mut cmd = None; + let mut required = false; + + // Parse key=value pairs from the directive body. + // Handles both quoted ("value") and unquoted (value) forms. + let mut remaining = rest; + while !remaining.is_empty() { + remaining = remaining.trim_start(); + if remaining.is_empty() { + break; + } + // Find key + let eq_pos = match remaining.find('=') { + Some(pos) => pos, + None => break, + }; + let key = remaining[..eq_pos].trim(); + remaining = &remaining[eq_pos + 1..]; + + // Parse value (quoted or bare) + let value_owned; + let value = if remaining.starts_with('"') { + // Quoted value: find unescaped closing quote + remaining = &remaining[1..]; + let mut end = 0; + let bytes = remaining.as_bytes(); + while end < bytes.len() { + if bytes[end] == b'"' && (end == 0 || bytes[end - 1] != b'\\') { + break; + } + end += 1; + } + let raw = &remaining[..end]; + remaining = if end < remaining.len() { + &remaining[end + 1..] + } else { + "" + }; + // Unescape escaped double quotes + if raw.contains(r#"\""#) { + value_owned = raw.replace(r#"\""#, "\""); + value_owned.as_str() + } else { + raw + } + } else { + // Bare value: ends at whitespace + let end = remaining + .find(char::is_whitespace) + .unwrap_or(remaining.len()); + let val = &remaining[..end]; + remaining = &remaining[end..]; + val + }; + + match key { + "step_id" => { + let v = value.trim(); + if !v.is_empty() { + step_id = Some(v.to_string()); + } + } + "cmd" => { + let v = value.trim(); + if !v.is_empty() { + cmd = Some(v.to_string()); + } + } + "required" => { + required = value.trim().eq_ignore_ascii_case("true"); + } + _ => {} // Ignore unknown keys for forward-compat } } + + if step_id.is_some() || cmd.is_some() { + last = Some(NextStepDirective { + step_id, + cmd, + required, + }); + } } } - last_step_id + last +} + +/// Format a `CSA:NEXT_STEP` directive for emission to stderr. +/// +/// This is the canonical way for weave steps and hooks to emit next-step +/// directives that orchestrators can parse mechanically. +pub fn format_next_step_directive(cmd: &str, required: bool) -> String { + let escaped = cmd.replace('"', r#"\""#); + format!( + "", + escaped, required + ) } #[cfg(test)] @@ -67,4 +178,80 @@ mod tests { fn parse_next_step_empty_id() { assert_eq!(parse_next_step(""), None); } + + #[test] + fn parse_directive_cmd_and_required() { + let output = r#""#; + let d = parse_next_step_directive(output).unwrap(); + assert_eq!( + d.cmd.as_deref(), + Some("csa plan run patterns/pr-bot/workflow.toml") + ); + assert!(d.required); + assert!(d.step_id.is_none()); + } + + #[test] + fn parse_directive_cmd_required_false() { + let output = r#""#; + let d = parse_next_step_directive(output).unwrap(); + assert_eq!(d.cmd.as_deref(), Some("echo done")); + assert!(!d.required); + } + + #[test] + fn parse_directive_mixed_step_id_and_cmd() { + let output = r#""#; + let d = parse_next_step_directive(output).unwrap(); + assert_eq!(d.step_id.as_deref(), Some("merge")); + assert_eq!(d.cmd.as_deref(), Some("gh pr merge")); + assert!(d.required); + } + + #[test] + fn parse_directive_last_wins() { + let output = "\n"; + let d = parse_next_step_directive(output).unwrap(); + assert_eq!(d.cmd.as_deref(), Some("second")); + assert!(d.required); + } + + #[test] + fn parse_directive_none_on_empty() { + assert!(parse_next_step_directive("no directives here").is_none()); + } + + #[test] + fn format_directive_required() { + let s = format_next_step_directive("csa plan run workflow.toml", true); + assert_eq!( + s, + "" + ); + } + + #[test] + fn format_directive_not_required() { + let s = format_next_step_directive("echo done", false); + assert_eq!(s, ""); + } + + #[test] + fn format_roundtrip() { + let cmd = "csa review --diff"; + let directive_str = format_next_step_directive(cmd, true); + let parsed = parse_next_step_directive(&directive_str).unwrap(); + assert_eq!(parsed.cmd.as_deref(), Some(cmd)); + assert!(parsed.required); + } + + #[test] + fn format_roundtrip_with_quotes() { + let cmd = r#"echo "hello world""#; + let directive_str = format_next_step_directive(cmd, false); + assert!(directive_str.contains(r#"echo \"hello world\""#)); + let parsed = parse_next_step_directive(&directive_str).unwrap(); + assert_eq!(parsed.cmd.as_deref(), Some(cmd)); + assert!(!parsed.required); + } } diff --git a/crates/csa-hooks/src/event.rs b/crates/csa-hooks/src/event.rs index e4bc99f4..908d4f9c 100644 --- a/crates/csa-hooks/src/event.rs +++ b/crates/csa-hooks/src/event.rs @@ -135,7 +135,6 @@ mod tests { fn test_builtin_command() { // Events with built-in commands assert!(HookEvent::SessionComplete.builtin_command().is_some()); - assert!(HookEvent::PostEdit.builtin_command().is_some()); // Events without built-in commands (TodoCreate/TodoSave have no builtins diff --git a/crates/csa-hooks/src/lib.rs b/crates/csa-hooks/src/lib.rs index b5ebbd29..3194c2dc 100644 --- a/crates/csa-hooks/src/lib.rs +++ b/crates/csa-hooks/src/lib.rs @@ -54,7 +54,9 @@ pub mod waiver; // Re-export key types pub use config::{HookConfig, HooksConfig, global_hooks_path, load_hooks_config}; -pub use directive::parse_next_step; +pub use directive::{ + NextStepDirective, format_next_step_directive, parse_next_step, parse_next_step_directive, +}; pub use event::HookEvent; #[cfg(feature = "async-hooks")] pub use event_bus::AsyncEventBus; diff --git a/patterns/dev2merge/PATTERN.md b/patterns/dev2merge/PATTERN.md index f4065bf3..c8b571d2 100644 --- a/patterns/dev2merge/PATTERN.md +++ b/patterns/dev2merge/PATTERN.md @@ -1,6 +1,6 @@ --- name = "dev2merge" -description = "Deterministic development pipeline: branch validation, planning, N*(implement+commit), pre-PR review, push, PR, codex-bot merge" +description = "Deterministic development pipeline: branch validation, planning, N*(implement+commit), pre-PR review, push, PR creation, pr-bot hard gate, post-merge sync" allowed-tools = "Bash, Read, Edit, Write, Grep, Glob, Task, TaskCreate, TaskUpdate, TaskList, TaskGet" tier = "tier-3-complex" version = "0.4.0" diff --git a/weave.lock b/weave.lock index 9eb3ec93..a1630d5c 100644 --- a/weave.lock +++ b/weave.lock @@ -1,9 +1,9 @@ package = [] [versions] -csa = "0.1.200" +csa = "0.1.201" last_migrated_at = "2026-03-08T12:08:01.820964091Z" -weave = "0.1.200" +weave = "0.1.201" [migrations] applied = [