diff --git a/PARITY.md b/PARITY.md index aa659fb4..d8af32b2 100644 --- a/PARITY.md +++ b/PARITY.md @@ -9,7 +9,7 @@ Last updated: 2026-04-03 - Current `main` HEAD: `ee31e00` (stub implementations replaced with real AskUserQuestion + RemoteTrigger). - Repository stats at this checkpoint: **292 commits on `main` / 293 across all branches**, **9 crates**, **48,599 tracked Rust LOC**, **2,568 test LOC**, **3 authors**, date range **2026-03-31 → 2026-04-03**. - Mock parity harness stats: **12 scripted scenarios**, **21 captured `/v1/messages` requests** in `rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs`. -- Claude Code v2.1.201 migration branch note: `TaskCreate`/`TaskUpdate` accept the structured `subject`/`activeForm`/`metadata` contract, `McpAuth` exposes login/logout host-flow payloads, and new host-facing contracts (`Workflow`, `Monitor`, `ScheduleWakeup`, `PushNotification`, `ReportFindings`, `ReadMcpResourceDir`, `Artifact`, `Projects`, `ClaudeDesign`, `ShowOnboardingRolePicker`) are registered with stable JSON responses. +- Claude Code v2.1.201 migration branch note: `TaskCreate`/`TaskUpdate` accept the structured `subject`/`activeForm`/`metadata` contract, `McpAuth` exposes login/logout host-flow payloads, workflows add `/workflows`, `/deep-research`, `ultracode` keyword, and `/effort ultracode` entry points, and the remaining host-facing contracts (`Monitor`, `ScheduleWakeup`, `PushNotification`, `ReportFindings`, `ReadMcpResourceDir`, `Artifact`, `Projects`, `ClaudeDesign`, `ShowOnboardingRolePicker`) are registered with stable JSON responses. ## Mock parity harness — milestone 1 @@ -153,13 +153,14 @@ Canonical scenario map: `rust/mock_parity_scenarios.json` - Agent view now uses the Claude Code v2.1.201 background-session layout: `CLAUDE_CONFIG_DIR` or `~/.claude`, `jobs//state.json`, `jobs//tmp/`, `daemon/roster.json`, and `daemon.log`. `claw agents`, `claw agents --json [--all] [--cwd ]`, `claw --bg`, `claw --bg --exec`, `claw attach/logs/stop/kill/respawn/rm `, and `claw daemon status/stop --any` are wired through `runtime::AgentSupervisor`; TTY `claw agents` opens a full-screen crossterm view with grouped rows, peek, dispatch, and lifecycle shortcuts. - The 9-lane push replaced pure fixed-payload stubs for `Task*`, `Team*`, `Cron*`, `LSP`, and MCP tools with registry-backed handlers on `main`; the v2.1.201 migration extends `TaskCreate`/`TaskUpdate` to the structured task-list payload shape. - The v2.1.201 host-facing contracts include `Workflow`, `Monitor`, `ScheduleWakeup`, `PushNotification`, `ReportFindings`, `ReadMcpResourceDir`, `Artifact`, `Projects`, `ClaudeDesign`, and `ShowOnboardingRolePicker`. +- `Workflow` now resolves inline scripts, `scriptPath`, saved scripts under `.claude/workflows` and `.claw/workflows`, user-level workflow roots, and the bundled `deep-research` workflow. Static `agent(...)` calls spawn background agents and write run manifests; `/workflows`, `claw workflows`, `/deep-research`, `claw deep-research`, `ultracode` keyword trigger, and session-only `/effort ultracode` are wired through the CLI. - `Brief` is handled as an execution alias in `execute_tool()`, but it is not a separately exposed tool spec in `mvp_tool_specs()`. ### Still limited or intentionally shallow - `AskUserQuestion` supports local stdin/stdout question flow, but it is not wired to an async host UI prompt surface. - `McpAuth` reports structured login/logout contract state and host-flow requirements, but it does not open a browser or complete OAuth by itself. -- `Workflow` and `Monitor` expose compatibility payloads, but local workflow JavaScript execution and live monitor scheduling are not implemented in this runtime. +- `Workflow` does not yet embed a full isolated JavaScript workflow VM. The current runner is a local parity layer for static `agent(...)` orchestration, saved workflow lookup, run registry, and disable switches; arbitrary `workflow`, `parallel`, `pipeline`, `Date`, and `Math.random` script behavior remains incomplete. `Monitor` still exposes only compatibility payloads and does not schedule live monitor loops. - `RemoteTrigger` performs direct HTTP requests, but it has not been validated against every upstream host-trigger edge case. - Agent view has a local supervisor, official state files, shell lifecycle commands, raw `agents --json` arrays, background worker spawning, a basic full-screen TUI, queued peek replies into resumable prompt sessions without live workers, row pin/rename/reorder/collapse shortcuts, directory/state grouping, and terminal attach once workers record their managed session id. Remaining gaps are live in-process peek reply routing, generated row summaries/PR status, notification hooks for background completion/needs-input, `/bg` or left-arrow handoff from an already-running foreground session, and a real supervisor socket/pre-warmed worker pool. - `TestingPermission` remains test-only. diff --git a/USAGE.md b/USAGE.md index a6f42bf4..d54febc4 100644 --- a/USAGE.md +++ b/USAGE.md @@ -572,6 +572,23 @@ Agent view follows the Claude Code background-session layout. `CLAUDE_CONFIG_DIR When stdout/stdin are attached to a terminal, `claw agents` opens a full-screen view. Type a prompt and press Enter to dispatch a new background session, use Up/Down to select rows, Space to peek at captured output, type while peek is open and press Enter to send a reply to a resumable session without a live worker, Enter or Right on an empty input to attach or fold a group header, `Ctrl+S` to group by directory/state, `Ctrl+T` to pin a row, `Ctrl+R` to rename it, `Shift+Up`/`Shift+Down` to reorder within a group, `Ctrl+X` to stop and press `Ctrl+X` again to remove, `s`/`k` to stop or kill, `r` to respawn, and `d` twice to remove a row. In JSON mode or non-TTY automation it keeps the structured list behavior. +## Run workflows + +`claw` supports Claude Code-style dynamic workflow entry points for local orchestration. Saved workflow scripts are discovered from `.claude/workflows/`, `.claw/workflows/`, `~/.claude/workflows/`, and `~/.claw/workflows/`. Inline scripts, `scriptPath`, saved workflow names, and the bundled `deep-research` workflow all route through the `Workflow` tool and record run manifests in `.clawd-workflows/` by default. + +```bash +./target/debug/claw workflows +./target/debug/claw workflows show +./target/debug/claw deep-research "what changed in Node.js permission model?" +./target/debug/claw /deep-research "compare these APIs" +``` + +In the REPL, `/workflows` lists recorded runs and `/deep-research ` launches the bundled research workflow in the background. `/effort ultracode` is session-scoped: it sets API reasoning to `xhigh` and enables workflow orchestration for substantive prompts until you change effort again or start a new session. The `ultracode` keyword in a normal prompt also triggers workflow orchestration when `workflowKeywordTriggerEnabled` is true. + +Workflow feature switches follow Claude Code-compatible settings: `disableWorkflows`, `enableWorkflows`, `workflowKeywordTriggerEnabled`, and `CLAUDE_CODE_DISABLE_WORKFLOWS=1`. When workflows are disabled, `/workflows`, `/deep-research`, and `ultracode` orchestration return a disabled workflow payload instead of starting agents. + +Current local parity is intentionally bounded: the runner executes scripts with static `agent(...)` calls, stores run state, and spawns background agents. It does not yet embed a full isolated JavaScript workflow VM for arbitrary `workflow`, `parallel`, `pipeline`, `Date`, or `Math.random` behavior. + ## Session management REPL turns are persisted under `.claw/sessions/` in the current workspace. diff --git a/rust/PARITY.md b/rust/PARITY.md index aa586699..c013f06a 100644 --- a/rust/PARITY.md +++ b/rust/PARITY.md @@ -86,7 +86,7 @@ Hashes below come from `git log --oneline`. Merge line counts come from `git sho | **StructuredOutput** | `tools` | passthrough JSON — **good parity** | | **REPL** | `tools` | subprocess code execution — **moderate parity** | | **PowerShell** | `tools` | Windows PowerShell execution — **moderate parity** | -| **Workflow / Monitor** | `tools` | v2.1.201 compatibility contracts with stable unsupported payloads; local workflow JS and live monitor scheduling are not implemented | +| **Workflow / Monitor** | `tools` | Workflow supports saved/inline/scriptPath lookup, bundled `deep-research`, static `agent(...)` orchestration, run manifests, `/workflows`, `ultracode` keyword, and `/effort ultracode`; full JS VM and live Monitor scheduling are not implemented | | **ScheduleWakeup / PushNotification / ReportFindings / Artifact / Projects / ClaudeDesign / ShowOnboardingRolePicker** | `tools` | host-facing v2.1.201 contract payloads — **contract parity** | ### Stubs Only (surface parity, no behavior) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 1990babd..f05ef071 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -244,6 +244,20 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ argument_hint: Some("[list|show |create |help]"), resume_supported: true, }, + SlashCommandSpec { + name: "workflows", + aliases: &[], + summary: "List or inspect dynamic workflow runs", + argument_hint: Some("[show ]"), + resume_supported: true, + }, + SlashCommandSpec { + name: "deep-research", + aliases: &[], + summary: "Run the bundled deep research workflow", + argument_hint: Some(""), + resume_supported: false, + }, SlashCommandSpec { name: "skills", aliases: &["skill"], @@ -1119,6 +1133,12 @@ pub enum SlashCommand { Agents { args: Option, }, + Workflows { + args: Option, + }, + DeepResearch { + question: Option, + }, Skills { args: Option, }, @@ -1266,6 +1286,10 @@ impl SlashCommand { Self::Permissions { .. } => "/permissions", Self::Session { .. } => "/session", Self::Plugins { .. } => "/plugins", + Self::Agents { .. } => "/agents", + Self::Workflows { .. } => "/workflows", + Self::DeepResearch { .. } => "/deep-research", + Self::Skills { .. } => "/skills", Self::Login => "/login", Self::Logout => "/logout", Self::Vim => "/vim", @@ -1408,6 +1432,10 @@ pub fn validate_slash_command_input( "agents" => SlashCommand::Agents { args: parse_list_or_help_args(command, remainder)?, }, + "workflows" => SlashCommand::Workflows { args: remainder }, + "deep-research" => SlashCommand::DeepResearch { + question: Some(require_remainder(command, remainder, "")?), + }, "skills" | "skill" => SlashCommand::Skills { args: parse_skills_args(remainder.as_deref())?, }, @@ -5805,6 +5833,8 @@ pub fn handle_slash_command( | SlashCommand::Session { .. } | SlashCommand::Plugins { .. } | SlashCommand::Agents { .. } + | SlashCommand::Workflows { .. } + | SlashCommand::DeepResearch { .. } | SlashCommand::Skills { .. } | SlashCommand::Doctor | SlashCommand::Login @@ -6015,6 +6045,26 @@ mod tests { task: Some("ship both features".to_string()) })) ); + assert_eq!( + SlashCommand::parse("/workflows show workflow-123"), + Ok(Some(SlashCommand::Workflows { + args: Some("show workflow-123".to_string()) + })) + ); + assert_eq!( + SlashCommand::parse("/deep-research Node.js permission model changes"), + Ok(Some(SlashCommand::DeepResearch { + question: Some("Node.js permission model changes".to_string()) + })) + ); + assert_eq!( + SlashCommand::parse("/ultracode implement login"), + Ok(Some(SlashCommand::Unknown("ultracode".to_string()))) + ); + assert!( + super::find_slash_command_spec("ultracode").is_none(), + "ultracode is a workflow keyword and /effort mode, not a slash command" + ); assert_eq!( SlashCommand::parse("/teleport conversation.rs"), Ok(Some(SlashCommand::Teleport { @@ -6470,6 +6520,8 @@ mod tests { )); assert!(help.contains("aliases: /plugins, /marketplace")); assert!(help.contains("/agents [list|show |create |help]")); + assert!(help.contains("/workflows [show ]")); + assert!(help.contains("/deep-research ")); assert!(help.contains( "/skills [list|show |install |uninstall |help| [args]]" )); @@ -6477,7 +6529,7 @@ mod tests { assert!(!help.contains("/login")); assert!(!help.contains("/logout")); assert!(help.contains("/setup")); - assert_eq!(slash_command_specs().len(), 142); + assert_eq!(slash_command_specs().len(), 144); assert!(resume_supported_slash_commands().len() >= 39); } diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index e2d378fd..ca87566a 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -158,6 +158,7 @@ pub struct RuntimeFeatureConfig { plugins: RuntimePluginConfig, mcp: McpConfigCollection, oauth: Option, + workflows: RuntimeWorkflowConfig, model: Option, aliases: BTreeMap, permission_mode: Option, @@ -170,6 +171,24 @@ pub struct RuntimeFeatureConfig { provider: RuntimeProviderConfig, } +/// Dynamic workflow feature switches from Claude Code-compatible settings. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeWorkflowConfig { + disable_workflows: bool, + enable_workflows: Option, + workflow_keyword_trigger_enabled: bool, +} + +impl Default for RuntimeWorkflowConfig { + fn default() -> Self { + Self { + disable_workflows: false, + enable_workflows: None, + workflow_keyword_trigger_enabled: true, + } + } +} + /// Controls which external AI coding framework rules are imported into the system prompt. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum RulesImportConfig { @@ -796,6 +815,7 @@ fn build_runtime_config( plugins: parse_optional_plugin_config(&merged_value)?, mcp, oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?, + workflows: parse_optional_workflow_config(&merged_value)?, model: parse_optional_model(&merged_value), aliases: parse_optional_aliases(&merged_value)?, permission_mode: parse_optional_permission_mode(&merged_value)?, @@ -880,6 +900,21 @@ impl RuntimeConfig { self.feature_config.oauth.as_ref() } + #[must_use] + pub fn workflows(&self) -> &RuntimeWorkflowConfig { + &self.feature_config.workflows + } + + #[must_use] + pub fn workflows_enabled(&self) -> bool { + self.feature_config.workflows_enabled() + } + + #[must_use] + pub fn workflow_keyword_trigger_enabled(&self) -> bool { + self.feature_config.workflow_keyword_trigger_enabled() + } + #[must_use] pub fn model(&self) -> Option<&str> { self.feature_config.model.as_deref() @@ -977,6 +1012,21 @@ impl RuntimeFeatureConfig { self.oauth.as_ref() } + #[must_use] + pub fn workflows(&self) -> &RuntimeWorkflowConfig { + &self.workflows + } + + #[must_use] + pub fn workflows_enabled(&self) -> bool { + self.workflows.enabled() + } + + #[must_use] + pub fn workflow_keyword_trigger_enabled(&self) -> bool { + self.workflows.keyword_trigger_enabled() && self.workflows_enabled() + } + #[must_use] pub fn model(&self) -> Option<&str> { self.model.as_deref() @@ -1024,6 +1074,34 @@ impl RuntimeFeatureConfig { } } +impl RuntimeWorkflowConfig { + #[must_use] + pub fn disable_workflows(&self) -> bool { + self.disable_workflows + } + + #[must_use] + pub fn enable_workflows(&self) -> Option { + self.enable_workflows + } + + #[must_use] + pub fn keyword_trigger_enabled(&self) -> bool { + self.workflow_keyword_trigger_enabled + } + + #[must_use] + pub fn enabled(&self) -> bool { + if env_flag_enabled("CLAUDE_CODE_DISABLE_WORKFLOWS") { + return false; + } + if self.disable_workflows { + return false; + } + self.enable_workflows.unwrap_or(true) + } +} + fn merge_trusted_roots(config_roots: &[String], per_call_roots: &[String]) -> Vec { let mut merged = Vec::with_capacity(config_roots.len() + per_call_roots.len()); for root in config_roots.iter().chain(per_call_roots.iter()) { @@ -1034,6 +1112,15 @@ fn merge_trusted_roots(config_roots: &[String], per_call_roots: &[String]) -> Ve merged } +fn env_flag_enabled(name: &str) -> bool { + std::env::var(name).is_ok_and(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) +} + impl ProviderFallbackConfig { #[must_use] pub fn new(primary: Option, fallbacks: Vec) -> Self { @@ -1749,6 +1836,24 @@ fn parse_optional_aliases(root: &JsonValue) -> Result, Ok(optional_string_map(object, "aliases", "merged settings")?.unwrap_or_default()) } +fn parse_optional_workflow_config(root: &JsonValue) -> Result { + let Some(object) = root.as_object() else { + return Ok(RuntimeWorkflowConfig::default()); + }; + + Ok(RuntimeWorkflowConfig { + disable_workflows: optional_bool(object, "disableWorkflows", "merged settings")? + .unwrap_or(false), + enable_workflows: optional_bool(object, "enableWorkflows", "merged settings")?, + workflow_keyword_trigger_enabled: optional_bool( + object, + "workflowKeywordTriggerEnabled", + "merged settings", + )? + .unwrap_or(true), + }) +} + fn parse_optional_hooks_config(root: &JsonValue) -> Result { let Some(object) = root.as_object() else { return Ok(RuntimeHookConfig::default()); @@ -3944,4 +4049,33 @@ mod tests { fs::remove_dir_all(root).expect("cleanup temp dir"); } + + #[test] + fn parses_workflow_feature_switches_and_env_disable() { + let root = temp_dir(); + let cwd = root.join("project"); + let home = root.join("home").join(".claw"); + fs::create_dir_all(&home).expect("home config dir"); + fs::create_dir_all(&cwd).expect("project dir"); + fs::write( + home.join("settings.json"), + r#"{"enableWorkflows": true, "workflowKeywordTriggerEnabled": false}"#, + ) + .expect("write settings"); + + std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS"); + let loaded = ConfigLoader::new(&cwd, &home) + .load() + .expect("workflow settings should load"); + assert!(loaded.workflows_enabled()); + assert!(!loaded.workflow_keyword_trigger_enabled()); + assert_eq!(loaded.workflows().enable_workflows(), Some(true)); + + std::env::set_var("CLAUDE_CODE_DISABLE_WORKFLOWS", "1"); + assert!(!loaded.workflows_enabled()); + assert!(!loaded.workflow_keyword_trigger_enabled()); + std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS"); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 43636ec4..95eaa176 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -74,11 +74,16 @@ use runtime::{ use serde::Deserialize; use serde_json::{json, Map, Value}; use tools::{ - canonical_allowed_tool_name, execute_tool, mvp_tool_specs, GlobalToolRegistry, - RuntimeToolDefinition, ToolSearchOutput, + canonical_allowed_tool_name, execute_tool, mvp_tool_specs, render_workflows_report, + workflows_report_json, GlobalToolRegistry, RuntimeToolDefinition, ToolSearchOutput, }; const DEFAULT_MODEL: &str = "anthropic/claude-sonnet-5"; +const API_REASONING_EFFORT_VALUES: &[&str] = &["low", "medium", "high", "xhigh", "max"]; +const SESSION_EFFORT_VALUES: &[&str] = + &["auto", "low", "medium", "high", "xhigh", "max", "ultracode"]; +const REASONING_EFFORT_FLAG_USAGE: &str = "--reasoning-effort low|medium|high|xhigh|max"; +const SESSION_EFFORT_USAGE: &str = "/effort auto|low|medium|high|xhigh|max|ultracode"; /// #148: Model provenance for `claw status` JSON/text output. Records where /// the resolved model string came from so claws don't have to re-read argv @@ -1037,6 +1042,14 @@ fn run() -> Result<(), Box> { reasoning_effort: reasoning_effort.as_deref(), agent: agent.as_deref(), })?, + CliAction::Workflows { + args, + output_format, + } => LiveCli::print_workflows(args.as_deref(), output_format)?, + CliAction::DeepResearch { + question, + output_format, + } => LiveCli::run_deep_research_command(&question, output_format)?, CliAction::Background { prompt, exec, @@ -1239,6 +1252,14 @@ enum CliAction { reasoning_effort: Option, agent: Option, }, + Workflows { + args: Option, + output_format: CliOutputFormat, + }, + DeepResearch { + question: String, + output_format: CliOutputFormat, + }, Background { prompt: Option, exec: Option, @@ -1795,22 +1816,14 @@ fn parse_args(args: &[String]) -> Result { "--reasoning-effort" => { let value = args .get(index + 1) - .ok_or_else(|| "missing_flag_value: missing value for --reasoning-effort.\nUsage: --reasoning-effort low|medium|high".to_string())?; - if !matches!(value.as_str(), "low" | "medium" | "high") { - return Err(format!( - "invalid_flag_value: invalid value for --reasoning-effort: '{value}'.\nUsage: --reasoning-effort low|medium|high" - )); - } + .ok_or_else(|| format!("missing_flag_value: missing value for --reasoning-effort.\nUsage: {REASONING_EFFORT_FLAG_USAGE}"))?; + validate_reasoning_effort_flag(value)?; reasoning_effort = Some(value.clone()); index += 2; } flag if flag.starts_with("--reasoning-effort=") => { let value = &flag[19..]; - if !matches!(value, "low" | "medium" | "high") { - return Err(format!( - "invalid_flag_value: invalid value for --reasoning-effort: '{value}'.\nUsage: --reasoning-effort low|medium|high" - )); - } + validate_reasoning_effort_flag(value)?; reasoning_effort = Some(value.to_string()); index += 1; } @@ -2188,6 +2201,22 @@ fn parse_args(args: &[String]) -> Result { reasoning_effort: reasoning_effort.clone(), agent: background_agent.clone(), }), + "workflows" => Ok(CliAction::Workflows { + args: join_optional_args(&rest[1..]), + output_format, + }), + "deep-research" => { + let Some(question) = join_optional_args(&rest[1..]) else { + return Err( + "missing_argument: deep-research requires a question.\nUsage: claw deep-research " + .to_string(), + ); + }; + Ok(CliAction::DeepResearch { + question, + output_format, + }) + } "mcp" => Ok(CliAction::Mcp { args: join_optional_args(&rest[1..]), output_format, @@ -2732,6 +2761,8 @@ fn bare_slash_command_guidance(command_name: &str) -> Option { | "remove" | "daemon" | "agents" + | "workflows" + | "deep-research" | "mcp" | "plugin" | "plugins" @@ -2970,6 +3001,28 @@ fn join_optional_args(args: &[String]) -> Option { (!trimmed.is_empty()).then(|| trimmed.to_string()) } +fn is_api_reasoning_effort(value: &str) -> bool { + API_REASONING_EFFORT_VALUES.contains(&value) +} + +fn is_session_effort(value: &str) -> bool { + SESSION_EFFORT_VALUES.contains(&value) +} + +fn validate_reasoning_effort_flag(value: &str) -> Result<(), String> { + if is_api_reasoning_effort(value) { + return Ok(()); + } + if value == "ultracode" { + return Err(format!( + "invalid_flag_value: ultracode is session-scoped; use /effort ultracode inside the REPL.\nUsage: {REASONING_EFFORT_FLAG_USAGE}" + )); + } + Err(format!( + "invalid_flag_value: invalid value for --reasoning-effort: '{value}'.\nUsage: {REASONING_EFFORT_FLAG_USAGE}" + )) +} + #[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)] fn parse_direct_slash_cli_action( rest: &[String], @@ -3007,6 +3060,17 @@ fn parse_direct_slash_cli_action( reasoning_effort, agent: None, }), + Ok(Some(SlashCommand::Workflows { args })) => Ok(CliAction::Workflows { + args, + output_format, + }), + Ok(Some(SlashCommand::DeepResearch { question })) => Ok(CliAction::DeepResearch { + question: question.unwrap_or_default(), + output_format, + }), + Ok(Some(SlashCommand::Effort { .. })) => Err(format!( + "interactive_only: /effort is session-scoped.\nStart `claw` and run `{SESSION_EFFORT_USAGE}` inside the REPL." + )), Ok(Some(SlashCommand::Mcp { action, target })) => Ok(CliAction::Mcp { args: match (action, target) { (None, None) => None, @@ -3172,6 +3236,8 @@ fn suggest_similar_subcommand(input: &str) -> Option> { "dump-manifests", "bootstrap-plan", "agents", + "workflows", + "deep-research", "mcp", "skills", "system-prompt", @@ -3217,6 +3283,8 @@ fn is_known_top_level_subcommand(value: &str) -> bool { | "bootstrap-plan" | "agents" | "agent" + | "workflows" + | "deep-research" | "mcp" | "skills" | "skill" @@ -7115,6 +7183,33 @@ fn run_resume_command( ), }) } + SlashCommand::Workflows { args } => Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(render_workflows_report(args.as_deref())?), + json: Some(workflows_report_json(args.as_deref())?), + }), + SlashCommand::DeepResearch { question } => { + let question = question.as_deref().unwrap_or_default(); + let output = execute_tool( + "Workflow", + &json!({ + "name": "deep-research", + "args": question, + }), + )?; + let value = serde_json::from_str::(&output).unwrap_or_else(|_| { + json!({ + "kind": "workflow", + "status": "error", + "message": output, + }) + }); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format_workflow_tool_result(&value)), + json: Some(value), + }) + } SlashCommand::Skills { args } => { if let SkillSlashDispatch::Invoke(_) = classify_skills_slash_command(args.as_deref()) { // #779: use interactive_only: prefix + \n hint so #776 classify/split emits @@ -7518,7 +7613,7 @@ fn run_live_cli_loop(cli: &mut LiveCli) -> Result<(), Box } editor.push_history(input); cli.record_prompt_history(&trimmed); - cli.run_turn(&trimmed)?; + cli.run_user_input(&trimmed)?; } input::ReadOutcome::Cancel => {} input::ReadOutcome::Exit => { @@ -7550,10 +7645,137 @@ struct ManagedSessionSummary { lifecycle: SessionLifecycleSummary, } +fn workflow_runtime_config_for_current_dir() -> runtime::RuntimeConfig { + let Ok(cwd) = env::current_dir() else { + return runtime::RuntimeConfig::empty(); + }; + ConfigLoader::default_for(&cwd) + .load() + .unwrap_or_else(|_| runtime::RuntimeConfig::empty()) +} + +fn workflows_enabled_for_current_dir() -> bool { + workflow_runtime_config_for_current_dir().workflows_enabled() +} + +fn workflow_keyword_trigger_enabled_for_current_dir() -> bool { + workflow_runtime_config_for_current_dir().workflow_keyword_trigger_enabled() +} + +fn workflow_disabled_value() -> Value { + json!({ + "kind": "workflow", + "status": "disabled", + "message": "Dynamic workflows are disabled by settings or CLAUDE_CODE_DISABLE_WORKFLOWS.", + }) +} + +fn contains_ultracode_keyword(input: &str) -> bool { + input + .split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_' && ch != '-') + .any(|token| token.eq_ignore_ascii_case("ultracode")) +} + +fn strip_ultracode_keyword(input: &str) -> String { + let stripped = input + .split_whitespace() + .filter(|token| { + !token + .trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_' && ch != '-') + .eq_ignore_ascii_case("ultracode") + }) + .collect::>() + .join(" "); + if stripped.trim().is_empty() { + input.trim().to_string() + } else { + stripped + } +} + +fn looks_like_substantive_workflow_task(input: &str) -> bool { + let lower = input.to_ascii_lowercase(); + let substantive_verbs = [ + "implement", + "fix", + "debug", + "migrate", + "upgrade", + "refactor", + "build", + "create", + "design", + "analyze", + "review", + "test", + "investigate", + "research", + "port", + "integrate", + ]; + input.chars().count() >= 80 + || substantive_verbs + .iter() + .any(|verb| lower.split_whitespace().any(|token| token == *verb)) +} + +fn escape_workflow_template_text(input: &str) -> String { + input + .replace('\\', "\\\\") + .replace('`', "\\`") + .replace("${", "\\${") +} + +fn ultracode_workflow_script(task: &str, trigger: &str) -> String { + let task = escape_workflow_template_text(task); + format!( + "export const meta = {{ name: 'ultracode', description: 'Session-scoped ultracode workflow orchestration.' }}\n\nconst result = await agent(`Use ultracode workflow orchestration for this substantive coding task. Inspect the workspace, plan the implementation, make the necessary changes, and report verification status. Trigger: {trigger}.\\n\\nTask:\\n{task}`, {{ label: 'ultracode', agentType: 'Explore', effort: 'xhigh' }})\n\nreturn result\n" + ) +} + +fn format_workflow_tool_result(value: &Value) -> String { + let mut lines = vec![String::from("Workflow")]; + if let Some(status) = value.get("status").and_then(Value::as_str) { + lines.push(format!(" Status {status}")); + } + if let Some(run_id) = value.get("runId").and_then(Value::as_str) { + lines.push(format!(" Run {run_id}")); + } + if let Some(name) = value.get("name").and_then(Value::as_str) { + lines.push(format!(" Name {name}")); + } + if let Some(source) = value.get("source").and_then(Value::as_str) { + lines.push(format!(" Source {source}")); + } + if let Some(script_path) = value.get("scriptPath").and_then(Value::as_str) { + lines.push(format!(" Script {script_path}")); + } + if let Some(agents) = value.get("agents").and_then(Value::as_array) { + lines.push(format!(" Agents {}", agents.len())); + for agent in agents.iter().take(5) { + let label = agent + .get("name") + .and_then(Value::as_str) + .unwrap_or("workflow-agent"); + let status = agent + .get("status") + .and_then(Value::as_str) + .unwrap_or("queued"); + lines.push(format!(" - {label} ({status})")); + } + } + if let Some(message) = value.get("message").and_then(Value::as_str) { + lines.push(format!(" Message {message}")); + } + lines.join("\n") +} + struct LiveCli { model: String, allowed_tools: Option, permission_mode: PermissionMode, + effort_mode: String, + reasoning_effort: Option, system_prompt: Vec, runtime: BuiltRuntime, session: SessionHandle, @@ -8960,6 +9182,8 @@ impl LiveCli { model, allowed_tools, permission_mode, + effort_mode: "auto".to_string(), + reasoning_effort: None, system_prompt, runtime, session, @@ -8970,9 +9194,9 @@ impl LiveCli { } fn set_reasoning_effort(&mut self, effort: Option) { - if let Some(rt) = self.runtime.runtime.as_mut() { - rt.api_client_mut().set_reasoning_effort(effort); - } + self.effort_mode = effort.clone().unwrap_or_else(|| "auto".to_string()); + self.reasoning_effort = effort; + Self::apply_reasoning_effort(&mut self.runtime, self.reasoning_effort.clone()); } fn startup_banner(&self) -> String { @@ -9035,7 +9259,7 @@ impl LiveCli { emit_output: bool, ) -> Result<(BuiltRuntime, HookAbortMonitor), Box> { let hook_abort_signal = runtime::HookAbortSignal::new(); - let runtime = build_runtime( + let mut runtime = build_runtime( self.runtime.session().clone(), &self.session.id, self.model.clone(), @@ -9047,17 +9271,28 @@ impl LiveCli { None, )? .with_hook_abort_signal(hook_abort_signal.clone()); + Self::apply_reasoning_effort(&mut runtime, self.reasoning_effort.clone()); let hook_abort_monitor = HookAbortMonitor::spawn(hook_abort_signal); Ok((runtime, hook_abort_monitor)) } - fn replace_runtime(&mut self, runtime: BuiltRuntime) -> Result<(), Box> { + fn replace_runtime( + &mut self, + mut runtime: BuiltRuntime, + ) -> Result<(), Box> { + Self::apply_reasoning_effort(&mut runtime, self.reasoning_effort.clone()); self.runtime.shutdown_plugins()?; self.runtime = runtime; Ok(()) } + fn apply_reasoning_effort(runtime: &mut BuiltRuntime, effort: Option) { + if let Some(rt) = runtime.runtime.as_mut() { + rt.api_client_mut().set_reasoning_effort(effort); + } + } + fn run_turn(&mut self, input: &str) -> Result<(), Box> { let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?; let mut spinner = Spinner::new(); @@ -9290,6 +9525,9 @@ impl LiveCli { output_format: CliOutputFormat, compact: bool, ) -> Result<(), Box> { + if let Some(trigger) = self.workflow_trigger_for_input(input) { + return self.run_ultracode_workflow_with_output(input, trigger, output_format); + } match output_format { CliOutputFormat::Json if compact => self.run_prompt_compact_json(input), CliOutputFormat::Text if compact => self.run_prompt_compact(input), @@ -9298,6 +9536,53 @@ impl LiveCli { } } + fn run_user_input(&mut self, input: &str) -> Result<(), Box> { + if let Some(trigger) = self.workflow_trigger_for_input(input) { + return self.run_ultracode_workflow_with_output(input, trigger, CliOutputFormat::Text); + } + self.run_turn(input) + } + + fn workflow_trigger_for_input(&self, input: &str) -> Option<&'static str> { + let trimmed = input.trim(); + if trimmed.is_empty() || trimmed.starts_with('/') || !workflows_enabled_for_current_dir() { + return None; + } + if contains_ultracode_keyword(trimmed) { + return workflow_keyword_trigger_enabled_for_current_dir().then_some("keyword"); + } + if self.effort_mode == "ultracode" && looks_like_substantive_workflow_task(trimmed) { + return Some("effort"); + } + None + } + + fn run_ultracode_workflow_with_output( + &mut self, + input: &str, + trigger: &'static str, + output_format: CliOutputFormat, + ) -> Result<(), Box> { + let task = if trigger == "keyword" { + strip_ultracode_keyword(input) + } else { + input.trim().to_string() + }; + let script = ultracode_workflow_script(&task, trigger); + let value = Self::execute_workflow_tool_json(&json!({ + "script": script, + "args": { + "prompt": task, + "trigger": trigger, + }, + }))?; + match output_format { + CliOutputFormat::Text => println!("{}", format_workflow_tool_result(&value)), + CliOutputFormat::Json => println!("{}", serde_json::to_string_pretty(&value)?), + } + Ok(()) + } + fn run_prompt_compact(&mut self, input: &str) -> Result<(), Box> { let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?; let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); @@ -9485,6 +9770,24 @@ impl LiveCli { } false } + SlashCommand::Workflows { args } => { + if let Err(error) = Self::print_workflows(args.as_deref(), CliOutputFormat::Text) { + eprintln!("{error}"); + } + false + } + SlashCommand::DeepResearch { question } => { + if let Some(question) = question { + if let Err(error) = + Self::run_deep_research_command(&question, CliOutputFormat::Text) + { + eprintln!("{error}"); + } + } else { + eprintln!("Usage: /deep-research "); + } + false + } SlashCommand::Skills { args } => { match classify_skills_slash_command(args.as_deref()) { SkillSlashDispatch::Invoke(prompt) => self.run_turn(&prompt)?, @@ -9524,6 +9827,7 @@ impl LiveCli { println!("{}", format_cost_report(usage)); false } + SlashCommand::Effort { level } => self.handle_effort_command(level.as_deref())?, SlashCommand::Review { scope } => { let scope = scope.unwrap_or_else(|| "medium".to_string()); self.run_turn(&format!("$code-review {scope}"))?; @@ -9559,7 +9863,6 @@ impl LiveCli { | SlashCommand::Hooks { .. } | SlashCommand::Context { .. } | SlashCommand::Color { .. } - | SlashCommand::Effort { .. } | SlashCommand::Branch { .. } | SlashCommand::Rewind { .. } | SlashCommand::Ide { .. } @@ -9882,6 +10185,59 @@ impl LiveCli { Ok(()) } + fn print_workflows( + args: Option<&str>, + output_format: CliOutputFormat, + ) -> Result<(), Box> { + if !workflows_enabled_for_current_dir() { + let value = workflow_disabled_value(); + match output_format { + CliOutputFormat::Text => println!("{}", format_workflow_tool_result(&value)), + CliOutputFormat::Json => println!("{}", serde_json::to_string_pretty(&value)?), + } + return Ok(()); + } + match output_format { + CliOutputFormat::Text => { + println!( + "{}", + render_workflows_report(args).map_err(io::Error::other)? + ) + } + CliOutputFormat::Json => { + let value = workflows_report_json(args).map_err(io::Error::other)?; + println!("{}", serde_json::to_string_pretty(&value)?); + } + } + Ok(()) + } + + fn run_deep_research_command( + question: &str, + output_format: CliOutputFormat, + ) -> Result<(), Box> { + let value = Self::execute_workflow_tool_json(&json!({ + "name": "deep-research", + "args": question, + }))?; + match output_format { + CliOutputFormat::Text => println!("{}", format_workflow_tool_result(&value)), + CliOutputFormat::Json => println!("{}", serde_json::to_string_pretty(&value)?), + } + Ok(()) + } + + fn execute_workflow_tool_json(input: &Value) -> Result> { + let output = execute_tool("Workflow", input).map_err(io::Error::other)?; + Ok(serde_json::from_str(&output).unwrap_or_else(|_| { + json!({ + "kind": "workflow", + "status": "error", + "message": output, + }) + })) + } + fn print_mcp( args: Option<&str>, output_format: CliOutputFormat, @@ -9936,6 +10292,57 @@ impl LiveCli { Ok(()) } + fn handle_effort_command( + &mut self, + level: Option<&str>, + ) -> Result> { + let Some(raw_level) = level.map(str::trim).filter(|value| !value.is_empty()) else { + println!( + "Effort\n Current mode {}\n API reasoning {}\n Usage {}", + self.effort_mode, + self.reasoning_effort.as_deref().unwrap_or("auto"), + SESSION_EFFORT_USAGE + ); + return Ok(false); + }; + let normalized = raw_level.to_ascii_lowercase(); + if !is_session_effort(&normalized) { + println!( + "Effort\n Status error\n Message unsupported effort level '{}'\n Usage {}", + raw_level, SESSION_EFFORT_USAGE + ); + return Ok(false); + } + if normalized == "ultracode" && !workflows_enabled_for_current_dir() { + println!( + "{}", + format_workflow_tool_result(&workflow_disabled_value()) + ); + return Ok(false); + } + + let previous = self.effort_mode.clone(); + self.effort_mode = normalized.clone(); + self.reasoning_effort = match normalized.as_str() { + "auto" => None, + "ultracode" => Some("xhigh".to_string()), + other => Some(other.to_string()), + }; + Self::apply_reasoning_effort(&mut self.runtime, self.reasoning_effort.clone()); + println!( + "Effort\n Previous mode {}\n Current mode {}\n API reasoning {}\n Workflow mode {}", + previous, + self.effort_mode, + self.reasoning_effort.as_deref().unwrap_or("auto"), + if self.effort_mode == "ultracode" { + "enabled" + } else { + "off" + } + ); + Ok(false) + } + fn print_plugins( action: Option<&str>, target: Option<&str>, @@ -10955,6 +11362,8 @@ fn run_attached_agent_session( model, allowed_tools: None, permission_mode, + effort_mode: "auto".to_string(), + reasoning_effort: None, system_prompt, runtime, session: SessionHandle { @@ -15217,7 +15626,6 @@ const STUB_COMMANDS: &[&str] = &[ "hooks", "context", "color", - "effort", "branch", "rewind", "ide", @@ -15325,6 +15733,9 @@ fn slash_command_completion_candidates_with_sessions( "/config hooks", "/config model", "/config plugins", + "/deep-research ", + "/effort ", + "/effort ultracode", "/mcp ", "/mcp list", "/mcp show ", @@ -15352,6 +15763,8 @@ fn slash_command_completion_candidates_with_sessions( "/session fork ", "/teleport ", "/ultraplan ", + "/workflows ", + "/workflows show ", "/agents help", "/mcp help", "/skills help", @@ -19113,6 +19526,31 @@ mod tests { output_format: CliOutputFormat::Json, } ); + assert_eq!( + parse_args(&[ + "--output-format=json".to_string(), + "/workflows".to_string(), + "show".to_string(), + "workflow-123".to_string(), + ]) + .expect("json /workflows show should parse"), + CliAction::Workflows { + args: Some("show workflow-123".to_string()), + output_format: CliOutputFormat::Json, + } + ); + assert_eq!( + parse_args(&[ + "--output-format=json".to_string(), + "/deep-research".to_string(), + "workflow docs".to_string(), + ]) + .expect("json /deep-research should parse"), + CliAction::DeepResearch { + question: "workflow docs".to_string(), + output_format: CliOutputFormat::Json, + } + ); } #[test] @@ -19693,6 +20131,9 @@ mod tests { assert!(completions.contains(&"/resume session-old".to_string())); assert!(completions.contains(&"/mcp list".to_string())); assert!(completions.contains(&"/ultraplan ".to_string())); + assert!(completions.contains(&"/workflows ".to_string())); + assert!(completions.contains(&"/deep-research ".to_string())); + assert!(completions.contains(&"/effort ultracode".to_string())); } #[test] @@ -21778,7 +22219,7 @@ UU conflicted.rs", #[test] fn accepts_valid_reasoning_effort_values() { - for value in ["low", "medium", "high"] { + for value in ["low", "medium", "high", "xhigh", "max"] { let result = parse_args(&[ "--reasoning-effort".to_string(), value.to_string(), @@ -21798,6 +22239,19 @@ UU conflicted.rs", } } + #[test] + fn rejects_ultracode_as_persistent_reasoning_effort_flag() { + let err = parse_args(&[ + "--reasoning-effort".to_string(), + "ultracode".to_string(), + "prompt".to_string(), + "hello".to_string(), + ]) + .unwrap_err(); + assert!(err.contains("session-scoped"), "unexpected error: {err}"); + assert!(err.contains("/effort ultracode"), "unexpected error: {err}"); + } + #[test] fn stub_commands_absent_from_repl_completions() { let candidates = diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index f95a7489..6cdd7b2c 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -1254,7 +1254,7 @@ pub fn mvp_tool_specs() -> Vec { }, ToolSpec { name: "Workflow", - description: "Run a deterministic multi-agent workflow script. v2.1.201 contract surface; local execution is reported as unsupported unless a workflow runner is configured.", + description: "Run a deterministic multi-agent workflow script by resolving inline script, saved workflow name, or scriptPath and spawning workflow agents in the background.", input_schema: json!({ "type": "object", "properties": { @@ -2255,18 +2255,687 @@ fn run_remote_trigger(input: RemoteTriggerInput) -> Result { #[allow(clippy::needless_pass_by_value)] fn run_workflow(input: WorkflowInput) -> Result { - to_pretty_json(json!({ - "status": "unsupported", - "kind": "workflow", - "message": "Workflow tool contract is registered for Claude Code v2.1.201 compatibility, but this local runtime does not execute workflow JavaScript yet.", - "name": input.name, - "has_script": input.script.as_ref().is_some_and(|script| !script.trim().is_empty()), - "scriptPath": input.script_path, - "resumeFromRunId": input.resume_from_run_id, - "args": input.args, + run_workflow_with_spawn(input, spawn_agent_job) +} + +fn run_workflow_with_spawn(input: WorkflowInput, spawn_fn: F) -> Result +where + F: Fn(AgentJob) -> Result<(), String> + Copy, +{ + if !workflow_feature_enabled() { + return to_pretty_json(json!({ + "status": "disabled", + "kind": "workflow", + "message": "Dynamic workflows are disabled by settings or CLAUDE_CODE_DISABLE_WORKFLOWS.", + "name": input.name, + "scriptPath": input.script_path, + "resumeFromRunId": input.resume_from_run_id, + "args": input.args, + })); + } + + if input.resume_from_run_id.is_some() { + return to_pretty_json(json!({ + "status": "resume_requested", + "kind": "workflow", + "message": "This local workflow runner records run state, but resumeFromRunId replay is not implemented yet. Relaunching requires the original scriptPath.", + "resumeFromRunId": input.resume_from_run_id, + "scriptPath": input.script_path, + "name": input.name, + "args": input.args, + })); + } + + let resolved = resolve_workflow_script(input)?; + let run = start_workflow_run(resolved, spawn_fn)?; + to_pretty_json(serde_json::to_value(run).map_err(|error| error.to_string())?) +} + +fn workflow_feature_enabled() -> bool { + if workflow_env_disabled() { + return false; + } + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + ConfigLoader::default_for(cwd) + .load() + .map_or(true, |config| config.workflows_enabled()) +} + +fn workflow_env_disabled() -> bool { + std::env::var("CLAUDE_CODE_DISABLE_WORKFLOWS").is_ok_and(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) +} + +fn resolve_workflow_script(input: WorkflowInput) -> Result { + let cwd = std::env::current_dir().map_err(|error| error.to_string())?; + let args = input.args; + let requested_name = input + .name + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(ToOwned::to_owned); + + if let Some(script) = input + .script + .as_deref() + .map(str::trim) + .filter(|script| !script.is_empty()) + { + let name = requested_name + .or_else(|| parse_workflow_meta_string(script, "name")) + .unwrap_or_else(|| String::from("inline-workflow")); + return Ok(ResolvedWorkflowScript { + name, + description: parse_workflow_meta_string(script, "description"), + script: script.to_string(), + script_path: None, + source: String::from("inline"), + args, + }); + } + + if let Some(script_path) = input + .script_path + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()) + { + let path = resolve_workflow_script_path(script_path, &cwd)?; + let script = read_workflow_script_file(&path)?; + let name = requested_name + .or_else(|| parse_workflow_meta_string(&script, "name")) + .or_else(|| { + path.file_stem() + .and_then(|stem| stem.to_str()) + .map(ToOwned::to_owned) + }) + .unwrap_or_else(|| String::from("workflow")); + return Ok(ResolvedWorkflowScript { + name, + description: parse_workflow_meta_string(&script, "description"), + script, + script_path: Some(path), + source: String::from("scriptPath"), + args, + }); + } + + let Some(name) = requested_name else { + return Err(String::from("Must provide script, name, or scriptPath")); + }; + + if name == "deep-research" { + let script = builtin_deep_research_script(args.as_ref()); + return Ok(ResolvedWorkflowScript { + name, + description: Some(String::from( + "Deep research harness that fans out source discovery and verification.", + )), + script, + script_path: None, + source: String::from("built-in"), + args, + }); + } + + let path = find_saved_workflow_script(&name, &cwd)?; + let script = read_workflow_script_file(&path)?; + Ok(ResolvedWorkflowScript { + name: parse_workflow_meta_string(&script, "name").unwrap_or(name), + description: parse_workflow_meta_string(&script, "description"), + script, + script_path: Some(path), + source: String::from("saved"), + args, + }) +} + +fn start_workflow_run( + workflow: ResolvedWorkflowScript, + spawn_fn: F, +) -> Result +where + F: Fn(AgentJob) -> Result<(), String> + Copy, +{ + let run_id = make_workflow_run_id(); + let created_at = iso8601_now(); + let store_dir = workflow_store_dir()?; + std::fs::create_dir_all(&store_dir).map_err(|error| error.to_string())?; + let script_path = persist_workflow_script_for_run(&store_dir, &run_id, &workflow)?; + let manifest_file = store_dir.join(format!("{run_id}.json")); + let calls = parse_workflow_agent_calls(&workflow.script); + + let mut agents = Vec::new(); + for (index, call) in calls.into_iter().enumerate() { + let description = call + .label + .clone() + .unwrap_or_else(|| format!("{} phase {}", workflow.name, index + 1)); + let manifest = execute_agent_with_spawn( + AgentInput { + description: description.clone(), + prompt: workflow_agent_prompt(&workflow, &call), + subagent_type: call + .agent_type + .or_else(|| Some(String::from("general-purpose"))), + name: Some(format!("{}-{}", workflow.name, index + 1)), + model: call.model, + effort: call.effort, + run_in_background: Some(true), + isolation: call.isolation, + }, + spawn_fn, + )?; + agents.push(WorkflowAgentRecord { + agent_id: manifest.agent_id, + name: manifest.name, + description, + status: manifest.status, + prompt_preview: preview_text(&call.prompt, 160), + manifest_file: manifest.manifest_file, + }); + } + + let (status, message) = if agents.is_empty() { + ( + String::from("script_error"), + String::from( + "Workflow script did not contain a static agent(...) call this local runner can execute.", + ), + ) + } else { + ( + String::from("running"), + format!( + "Running in background: {} agent(s). Use /workflows to monitor and save.", + agents.len() + ), + ) + }; + + let run = WorkflowRunOutput { + run_id, + kind: String::from("workflow"), + status, + name: workflow.name, + description: workflow.description, + source: workflow.source, + script_path: script_path.display().to_string(), + manifest_file: manifest_file.display().to_string(), + created_at, + agents, + args: workflow.args, + message, + }; + write_workflow_run_manifest(&run)?; + Ok(run) +} + +fn workflow_agent_prompt(workflow: &ResolvedWorkflowScript, call: &WorkflowAgentCall) -> String { + let args = workflow + .args + .as_ref() + .map(|value| format!("\n\nWorkflow args:\n{value}")) + .unwrap_or_default(); + format!( + "You are a subagent spawned by workflow `{}`. Complete this workflow step and return a concise result.\n\n{}{}", + workflow.name, call.prompt, args + ) +} + +fn resolve_workflow_script_path(raw: &str, cwd: &Path) -> Result { + if raw.starts_with("\\\\") { + return Err(format!( + "UNC paths are not allowed for workflow scriptPath: {raw}" + )); + } + let path = PathBuf::from(raw); + if path.is_absolute() { + Ok(path) + } else { + Ok(cwd.join(path)) + } +} + +const WORKFLOW_SCRIPT_MAX_BYTES: u64 = 1024 * 1024; + +fn read_workflow_script_file(path: &Path) -> Result { + let metadata = std::fs::metadata(path) + .map_err(|_| format!("Workflow script file not found: {}", path.display()))?; + if metadata.len() > WORKFLOW_SCRIPT_MAX_BYTES { + return Err(format!( + "Workflow script file {} exceeds {} bytes", + path.display(), + WORKFLOW_SCRIPT_MAX_BYTES + )); + } + let bytes = std::fs::read(path).map_err(|error| { + format!( + "Failed to read workflow script file {}: {error}", + path.display() + ) + })?; + String::from_utf8(bytes).map_err(|error| { + format!( + "Workflow script file {} is not utf-8: {error}", + path.display() + ) + }) +} + +fn persist_workflow_script_for_run( + store_dir: &Path, + run_id: &str, + workflow: &ResolvedWorkflowScript, +) -> Result { + if let Some(path) = &workflow.script_path { + return Ok(path.clone()); + } + let script_dir = store_dir.join("scripts"); + std::fs::create_dir_all(&script_dir).map_err(|error| error.to_string())?; + let path = script_dir.join(format!("{run_id}.js")); + std::fs::write(&path, &workflow.script).map_err(|error| { + format!( + "Failed to persist workflow script to {}: {error}", + path.display() + ) + })?; + Ok(path) +} + +fn write_workflow_run_manifest(run: &WorkflowRunOutput) -> Result<(), String> { + std::fs::write( + &run.manifest_file, + serde_json::to_string_pretty(run).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string()) +} + +fn make_workflow_run_id() -> String { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + format!("workflow-{nanos}") +} + +fn workflow_store_dir() -> Result { + if let Ok(path) = std::env::var("CLAWD_WORKFLOW_STORE") { + return Ok(PathBuf::from(path)); + } + let cwd = std::env::current_dir().map_err(|error| error.to_string())?; + if let Some(workspace_root) = cwd.ancestors().nth(2) { + return Ok(workspace_root.join(".clawd-workflows")); + } + Ok(cwd.join(".clawd-workflows")) +} + +fn parse_workflow_agent_calls(script: &str) -> Vec { + let mut calls = Vec::new(); + let mut offset = 0; + while let Some(relative) = script[offset..].find("agent(") { + let start = offset + relative + "agent(".len(); + let Some((prompt, consumed)) = extract_js_string_literal(&script[start..]) else { + offset = start; + continue; + }; + let options = &script[start + consumed..script.len().min(start + consumed + 1024)]; + calls.push(WorkflowAgentCall { + prompt, + label: parse_workflow_option_string(options, "label"), + model: parse_workflow_option_string(options, "model"), + effort: parse_workflow_option_string(options, "effort"), + agent_type: parse_workflow_option_string(options, "agentType") + .or_else(|| parse_workflow_option_string(options, "subagent_type")), + isolation: parse_workflow_option_string(options, "isolation"), + }); + offset = start + consumed; + } + calls +} + +fn extract_js_string_literal(input: &str) -> Option<(String, usize)> { + let trimmed_start = input.len() - input.trim_start().len(); + let mut chars = input[trimmed_start..].char_indices(); + let (_, quote) = chars.next()?; + if !matches!(quote, '\'' | '"' | '`') { + return None; + } + + let mut escaped = false; + let mut value = String::new(); + for (index, ch) in chars { + if escaped { + value.push(ch); + escaped = false; + continue; + } + if ch == '\\' { + escaped = true; + continue; + } + if ch == quote { + let consumed = trimmed_start + index + ch.len_utf8(); + return Some((normalize_workflow_template_literal(&value), consumed)); + } + value.push(ch); + } + None +} + +fn normalize_workflow_template_literal(value: &str) -> String { + value.replace("${", "{") +} + +fn parse_workflow_option_string(input: &str, key: &str) -> Option { + let mut offset = 0; + while let Some(relative) = input[offset..].find(key) { + let key_start = offset + relative; + let key_end = key_start + key.len(); + let before_ok = key_start == 0 + || !input[..key_start] + .chars() + .next_back() + .is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '_'); + let after_ok = input[key_end..] + .chars() + .next() + .is_none_or(|ch| !(ch.is_ascii_alphanumeric() || ch == '_')); + if before_ok && after_ok { + let after_key = &input[key_end..]; + let colon_index = after_key.find(':')?; + let after_colon = &after_key[colon_index + 1..]; + return extract_js_string_literal(after_colon).map(|(value, _)| value); + } + offset = key_end; + } + None +} + +fn parse_workflow_meta_string(script: &str, key: &str) -> Option { + parse_workflow_option_string(script, key) +} + +fn builtin_deep_research_script(args: Option<&Value>) -> String { + let question = + workflow_args_to_prompt(args).unwrap_or_else(|| String::from("the requested topic")); + let question = escape_workflow_template_text(&question); + format!( + "export const meta = {{ name: 'deep-research', description: 'Deep research harness - fan out source discovery, verification, and synthesis.' }}\n\nconst report = await agent(`Research this question deeply using available search and fetch tools, cross-check claims, and return a cited report: {question}`, {{ label: 'deep-research', agentType: 'Explore', effort: 'high' }})\n\nreturn report\n" + ) +} + +fn workflow_args_to_prompt(args: Option<&Value>) -> Option { + match args { + Some(Value::String(value)) if !value.trim().is_empty() => Some(value.trim().to_string()), + Some(value) if !value.is_null() => Some(value.to_string()), + _ => None, + } +} + +fn escape_workflow_template_text(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('`', "\\`") + .replace("${", "\\${") +} + +fn find_saved_workflow_script(name: &str, cwd: &Path) -> Result { + for root in workflow_lookup_roots(cwd) { + if let Some(path) = find_workflow_in_root(&root, name) { + return Ok(path); + } + } + let available = list_saved_workflow_names(cwd); + let available = if available.is_empty() { + String::from("(none)") + } else { + available.join(", ") + }; + Err(format!( + "Workflow \"{name}\" not found. Available: {available}" + )) +} + +fn workflow_lookup_roots(cwd: &Path) -> Vec { + let mut roots = Vec::new(); + for ancestor in cwd.ancestors() { + roots.push(ancestor.join(".claude").join("workflows")); + roots.push(ancestor.join(".claw").join("workflows")); + } + if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) { + let home = PathBuf::from(home); + roots.push(home.join(".claude").join("workflows")); + roots.push(home.join(".claw").join("workflows")); + } + roots +} + +fn find_workflow_in_root(root: &Path, requested: &str) -> Option { + let candidates = [ + root.join(format!("{requested}.js")), + root.join(format!("{requested}.mjs")), + root.join(requested), + ]; + for candidate in candidates { + if candidate.is_file() { + return Some(candidate); + } + } + + for entry in std::fs::read_dir(root).ok()?.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + let extension_ok = path + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| matches!(ext, "js" | "mjs")); + if !extension_ok { + continue; + } + let matches_stem = path + .file_stem() + .and_then(|stem| stem.to_str()) + .is_some_and(|stem| stem.eq_ignore_ascii_case(requested)); + if matches_stem { + return Some(path); + } + if read_workflow_script_file(&path) + .ok() + .and_then(|script| parse_workflow_meta_string(&script, "name")) + .is_some_and(|name| name.eq_ignore_ascii_case(requested)) + { + return Some(path); + } + } + None +} + +fn list_saved_workflow_names(cwd: &Path) -> Vec { + let mut names = BTreeSet::from([String::from("deep-research")]); + for root in workflow_lookup_roots(cwd) { + let Ok(entries) = std::fs::read_dir(root) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + let extension_ok = path + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| matches!(ext, "js" | "mjs")); + if !extension_ok { + continue; + } + if let Some(name) = read_workflow_script_file(&path) + .ok() + .and_then(|script| parse_workflow_meta_string(&script, "name")) + .or_else(|| { + path.file_stem() + .and_then(|stem| stem.to_str()) + .map(ToOwned::to_owned) + }) + { + names.insert(name); + } + } + } + names.into_iter().collect() +} + +pub fn render_workflows_report(args: Option<&str>) -> Result { + let value = workflows_report_json(args)?; + if value["status"] == "empty" { + return Ok(String::from( + "Workflows\n Status no workflow runs found\n Hint run /deep-research or ask with the ultracode keyword", + )); + } + if value["action"] == "show" { + return Ok(format_workflow_detail_report(&value)); + } + Ok(format_workflow_list_report(&value)) +} + +pub fn workflows_report_json(args: Option<&str>) -> Result { + let requested = args.map(str::trim).filter(|value| !value.is_empty()); + let runs = load_workflow_run_manifests()?; + if let Some(requested) = requested { + let requested = requested + .strip_prefix("show ") + .or_else(|| requested.strip_prefix("open ")) + .unwrap_or(requested) + .trim(); + let Some(run) = runs.iter().find(|run| run.run_id == requested) else { + return Ok(json!({ + "kind": "workflows", + "action": "show", + "status": "not_found", + "runId": requested, + "available": runs.iter().map(|run| run.run_id.clone()).collect::>(), + })); + }; + return Ok(json!({ + "kind": "workflows", + "action": "show", + "status": "ok", + "run": run, + })); + } + if runs.is_empty() { + return Ok(json!({ + "kind": "workflows", + "action": "list", + "status": "empty", + "runs": [], + })); + } + Ok(json!({ + "kind": "workflows", + "action": "list", + "status": "ok", + "count": runs.len(), + "runs": runs, })) } +fn load_workflow_run_manifests() -> Result, String> { + let store_dir = workflow_store_dir()?; + let Ok(entries) = std::fs::read_dir(store_dir) else { + return Ok(Vec::new()); + }; + let mut runs = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("json") { + continue; + } + if let Ok(contents) = std::fs::read_to_string(&path) { + if let Ok(run) = serde_json::from_str::(&contents) { + runs.push(run); + } + } + } + runs.sort_by(|left, right| right.created_at.cmp(&left.created_at)); + Ok(runs) +} + +fn format_workflow_list_report(value: &Value) -> String { + let mut lines = vec![format!( + "Workflows\n Count {}", + value["count"].as_u64().unwrap_or(0) + )]; + if let Some(runs) = value["runs"].as_array() { + for run in runs { + lines.push(format!( + " {} {} {} {} agent(s)", + run["runId"].as_str().unwrap_or(""), + run["status"].as_str().unwrap_or("unknown"), + run["name"].as_str().unwrap_or("workflow"), + run["agents"].as_array().map_or(0, Vec::len) + )); + } + } + lines.join("\n") +} + +fn format_workflow_detail_report(value: &Value) -> String { + if value["status"] == "not_found" { + return format!( + "Workflows\n Status not found\n Run {}", + value["runId"].as_str().unwrap_or("") + ); + } + let run = &value["run"]; + let mut lines = vec![ + String::from("Workflow"), + format!( + " Run {}", + run["runId"].as_str().unwrap_or("") + ), + format!( + " Name {}", + run["name"].as_str().unwrap_or("workflow") + ), + format!( + " Status {}", + run["status"].as_str().unwrap_or("unknown") + ), + format!( + " Source {}", + run["source"].as_str().unwrap_or("unknown") + ), + format!( + " Script {}", + run["scriptPath"].as_str().unwrap_or("") + ), + format!( + " Message {}", + run["message"].as_str().unwrap_or("") + ), + ]; + if let Some(agents) = run["agents"].as_array() { + lines.push(format!(" Agents {}", agents.len())); + for agent in agents { + lines.push(format!( + " {} {} {}", + agent["agentId"].as_str().unwrap_or(""), + agent["status"].as_str().unwrap_or("unknown"), + agent["description"].as_str().unwrap_or("") + )); + } + } + lines.join("\n") +} + #[allow(clippy::needless_pass_by_value)] fn run_monitor(input: MonitorInput) -> Result { to_pretty_json(json!({ @@ -3487,6 +4156,62 @@ struct WorkflowInput { args: Option, } +#[derive(Debug, Clone)] +struct ResolvedWorkflowScript { + name: String, + description: Option, + script: String, + script_path: Option, + source: String, + args: Option, +} + +#[derive(Debug, Clone)] +struct WorkflowAgentCall { + prompt: String, + label: Option, + model: Option, + effort: Option, + agent_type: Option, + isolation: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct WorkflowRunOutput { + #[serde(rename = "runId")] + run_id: String, + kind: String, + status: String, + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + source: String, + #[serde(rename = "scriptPath")] + script_path: String, + #[serde(rename = "manifestFile")] + manifest_file: String, + #[serde(rename = "createdAt")] + created_at: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + agents: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + args: Option, + message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct WorkflowAgentRecord { + #[serde(rename = "agentId")] + agent_id: String, + name: String, + description: String, + status: String, + #[serde(rename = "promptPreview")] + prompt_preview: String, + #[serde(rename = "manifestFile")] + manifest_file: String, +} + #[derive(Debug, Default, Deserialize)] #[serde(default)] struct MonitorInput { @@ -7382,9 +8107,9 @@ mod tests { classify_lane_failure, derive_agent_state, execute_agent_with_spawn, execute_tool, extract_recovery_outcome, final_assistant_text, global_cron_registry, global_mcp_registry, maybe_commit_provenance, mvp_tool_specs, permission_mode_from_plugin, - persist_agent_terminal_state, push_output_block, run_task_packet, AgentInput, AgentJob, - GlobalToolRegistry, LaneEventName, LaneFailureClass, ProviderRuntimeClient, - SubagentToolExecutor, + persist_agent_terminal_state, push_output_block, run_task_packet, run_workflow_with_spawn, + AgentInput, AgentJob, GlobalToolRegistry, LaneEventName, LaneFailureClass, + ProviderRuntimeClient, SubagentToolExecutor, WorkflowInput, }; use api::OutputContentBlock; use runtime::mcp_tool_bridge::{McpConnectionStatus, McpResourceInfo, McpToolInfo}; @@ -7550,6 +8275,13 @@ mod tests { #[test] fn v201_host_contract_tools_return_stable_payloads() { + let _guard = env_guard(); + let workflow_store = temp_path("workflow-contract-store"); + let original_workflow_store = std::env::var("CLAWD_WORKFLOW_STORE").ok(); + let original_disable_workflows = std::env::var("CLAUDE_CODE_DISABLE_WORKFLOWS").ok(); + std::env::set_var("CLAWD_WORKFLOW_STORE", &workflow_store); + std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS"); + let workflow = execute_tool( "Workflow", &json!({ @@ -7560,9 +8292,13 @@ mod tests { ) .expect("Workflow contract should be registered"); let workflow: serde_json::Value = serde_json::from_str(&workflow).expect("workflow json"); - assert_eq!(workflow["status"], "unsupported"); + assert_eq!(workflow["status"], "script_error"); assert_eq!(workflow["kind"], "workflow"); - assert_eq!(workflow["has_script"], true); + assert_eq!(workflow["name"], "release-check"); + assert_eq!(workflow["source"], "inline"); + let script_path = Path::new(workflow["scriptPath"].as_str().expect("script path")); + assert!(script_path.exists()); + assert!(script_path.starts_with(&workflow_store)); let monitor = execute_tool( "Monitor", @@ -7643,6 +8379,105 @@ mod tests { .as_array() .expect("roles") .contains(&json!("coding"))); + + match original_workflow_store { + Some(value) => std::env::set_var("CLAWD_WORKFLOW_STORE", value), + None => std::env::remove_var("CLAWD_WORKFLOW_STORE"), + } + match original_disable_workflows { + Some(value) => std::env::set_var("CLAUDE_CODE_DISABLE_WORKFLOWS", value), + None => std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS"), + } + let _ = fs::remove_dir_all(workflow_store); + } + + #[test] + fn workflow_static_agent_runner_records_run_and_agents() { + fn ok_spawn(_: AgentJob) -> Result<(), String> { + Ok(()) + } + + let _guard = env_guard(); + let root = temp_path("workflow-static-agent"); + let workflow_store = root.join("workflows"); + let agent_store = root.join("agents"); + let original_workflow_store = std::env::var("CLAWD_WORKFLOW_STORE").ok(); + let original_agent_store = std::env::var("CLAWD_AGENT_STORE").ok(); + let original_disable_workflows = std::env::var("CLAUDE_CODE_DISABLE_WORKFLOWS").ok(); + std::env::set_var("CLAWD_WORKFLOW_STORE", &workflow_store); + std::env::set_var("CLAWD_AGENT_STORE", &agent_store); + std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS"); + + let output = run_workflow_with_spawn( + WorkflowInput { + script: Some( + "export const meta = { name: 'release-check', description: 'Check release readiness' }\nconst result = await agent(`Check release readiness`, { label: 'research', agentType: 'Explore', effort: 'xhigh' })\nreturn result\n" + .to_string(), + ), + args: Some(json!({"target": "tools"})), + ..WorkflowInput::default() + }, + ok_spawn, + ) + .expect("static agent workflow should run"); + let output: serde_json::Value = serde_json::from_str(&output).expect("workflow json"); + assert_eq!(output["status"], "running"); + assert_eq!(output["kind"], "workflow"); + assert_eq!(output["name"], "release-check"); + assert_eq!(output["description"], "Check release readiness"); + assert_eq!(output["source"], "inline"); + assert_eq!(output["agents"].as_array().expect("agents").len(), 1); + assert_eq!(output["agents"][0]["description"], "research"); + assert_eq!(output["agents"][0]["status"], "running"); + assert!(output["message"] + .as_str() + .expect("message") + .contains("/workflows")); + assert!(Path::new(output["scriptPath"].as_str().expect("script path")).exists()); + + let report = super::workflows_report_json(None).expect("workflow report"); + assert_eq!(report["status"], "ok"); + assert_eq!(report["count"], 1); + assert_eq!(report["runs"][0]["name"], "release-check"); + + match original_workflow_store { + Some(value) => std::env::set_var("CLAWD_WORKFLOW_STORE", value), + None => std::env::remove_var("CLAWD_WORKFLOW_STORE"), + } + match original_agent_store { + Some(value) => std::env::set_var("CLAWD_AGENT_STORE", value), + None => std::env::remove_var("CLAWD_AGENT_STORE"), + } + match original_disable_workflows { + Some(value) => std::env::set_var("CLAUDE_CODE_DISABLE_WORKFLOWS", value), + None => std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS"), + } + let _ = fs::remove_dir_all(root); + } + + #[test] + fn workflow_tool_respects_disable_env() { + let _guard = env_guard(); + let original_disable_workflows = std::env::var("CLAUDE_CODE_DISABLE_WORKFLOWS").ok(); + std::env::set_var("CLAUDE_CODE_DISABLE_WORKFLOWS", "1"); + + let output = execute_tool( + "Workflow", + &json!({ + "name": "deep-research", + "args": "workflow docs" + }), + ) + .expect("disabled workflow should return structured payload"); + let output: serde_json::Value = serde_json::from_str(&output).expect("workflow json"); + assert_eq!(output["status"], "disabled"); + assert_eq!(output["kind"], "workflow"); + assert_eq!(output["name"], "deep-research"); + + match original_disable_workflows { + Some(value) => std::env::set_var("CLAUDE_CODE_DISABLE_WORKFLOWS", value), + None => std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS"), + } } #[test]