Skip to main content

edict/commands/protocol/
mod.rs

1pub mod adapters;
2pub mod cleanup;
3pub mod context;
4pub mod executor;
5pub mod exit_policy;
6pub mod finish;
7pub mod merge;
8pub mod render;
9pub mod resume;
10pub mod review;
11pub mod review_gate;
12pub mod shell;
13
14use std::io::IsTerminal;
15use std::path::PathBuf;
16
17use anyhow::Context;
18use clap::Subcommand;
19
20use super::doctor::OutputFormat;
21use crate::config::Config;
22
23/// Shared flags for all protocol subcommands.
24#[derive(Debug, clap::Args)]
25pub struct ProtocolArgs {
26    /// Agent name (default: $AGENT or config defaultAgent)
27    #[arg(long)]
28    pub agent: Option<String>,
29    /// Project name (default: from .edict.toml)
30    #[arg(long)]
31    pub project: Option<String>,
32    /// Project root directory
33    #[arg(long)]
34    pub project_root: Option<PathBuf>,
35    /// Output format
36    #[arg(long, value_enum)]
37    pub format: Option<OutputFormat>,
38}
39
40impl ProtocolArgs {
41    /// Resolve the effective agent name from flag, env, or config.
42    pub fn resolve_agent(&self, config: &crate::config::Config) -> String {
43        if let Some(ref agent) = self.agent {
44            return agent.clone();
45        }
46        if let Ok(agent) = std::env::var("AGENT") {
47            return agent;
48        }
49        if let Ok(agent) = std::env::var("BOTBUS_AGENT") {
50            return agent;
51        }
52        config.default_agent()
53    }
54
55    /// Resolve the effective project name from flag or config.
56    pub fn resolve_project(&self, config: &crate::config::Config) -> String {
57        if let Some(ref project) = self.project {
58            return project.clone();
59        }
60        config.project.name.clone()
61    }
62
63    /// Resolve the effective output format from flag or TTY detection.
64    pub fn resolve_format(&self) -> OutputFormat {
65        self.format.unwrap_or_else(|| {
66            if std::io::stdout().is_terminal() {
67                OutputFormat::Pretty
68            } else {
69                OutputFormat::Text
70            }
71        })
72    }
73}
74
75#[derive(Debug, Subcommand)]
76pub enum ProtocolCommand {
77    /// Check state and output commands to start working on a bone
78    Start {
79        /// Bone ID to start working on
80        bone_id: String,
81        /// Omit bus send announcement (for dispatched workers)
82        #[arg(long)]
83        dispatched: bool,
84        /// Execute the steps immediately instead of outputting guidance
85        #[arg(long)]
86        execute: bool,
87        #[command(flatten)]
88        args: ProtocolArgs,
89    },
90    /// Check state and output commands to finish a bone
91    Finish {
92        /// Bone ID to finish
93        bone_id: String,
94        /// Omit maw ws merge step (for dispatched workers whose lead handles merge)
95        #[arg(long)]
96        no_merge: bool,
97        /// Output finish commands even without review approval
98        #[arg(long)]
99        force: bool,
100        /// Execute finish commands directly instead of outputting them
101        #[arg(long)]
102        execute: bool,
103        #[command(flatten)]
104        args: ProtocolArgs,
105    },
106    /// Check state and output commands to request review
107    Review {
108        /// Bone ID to review
109        bone_id: String,
110        /// Override reviewer list (comma-separated)
111        #[arg(long)]
112        reviewers: Option<String>,
113        /// Reference an existing review ID (skip creation)
114        #[arg(long)]
115        review_id: Option<String>,
116        /// Execute the review commands instead of just outputting them
117        #[arg(long)]
118        execute: bool,
119        #[command(flatten)]
120        args: ProtocolArgs,
121    },
122    /// Check for held resources and output cleanup commands
123    Cleanup {
124        /// Execute cleanup steps instead of outputting them
125        #[arg(long)]
126        execute: bool,
127        #[command(flatten)]
128        args: ProtocolArgs,
129    },
130    /// Check for in-progress work from a previous session
131    Resume {
132        #[command(flatten)]
133        args: ProtocolArgs,
134    },
135    /// Check preconditions and output commands to merge a worker's completed workspace
136    Merge {
137        /// Workspace name to merge
138        workspace: String,
139        /// Commit message for the merge (e.g. "feat: add login flow"). Use conventional commit
140        /// prefix: feat:, fix:, chore:, etc. Required; opens $EDITOR on TTY if omitted.
141        #[arg(long, short = 'm')]
142        message: Option<String>,
143        /// Merge even if bone is not closed or review is not approved
144        #[arg(long)]
145        force: bool,
146        /// Execute merge commands directly instead of outputting them
147        #[arg(long)]
148        execute: bool,
149        #[command(flatten)]
150        args: ProtocolArgs,
151    },
152}
153
154impl ProtocolCommand {
155    pub fn execute(&self) -> anyhow::Result<()> {
156        match self {
157            ProtocolCommand::Start {
158                bone_id,
159                dispatched,
160                execute,
161                args,
162            } => Self::execute_start(bone_id, *dispatched, *execute, args),
163            ProtocolCommand::Finish {
164                bone_id,
165                no_merge,
166                force,
167                execute,
168                args,
169            } => {
170                let project_root = match args.project_root.clone() {
171                    Some(p) => p,
172                    None => {
173                        std::env::current_dir().context("could not determine current directory")?
174                    }
175                };
176
177                let (config_path, _) = crate::config::find_config_in_project(&project_root)?;
178                let config = Config::load(&config_path)?;
179
180                let project = args.resolve_project(&config);
181                let agent = args.resolve_agent(&config);
182                let format = args.resolve_format();
183
184                finish::execute(
185                    bone_id, *no_merge, *force, *execute, &agent, &project, &config, format,
186                )
187            }
188            ProtocolCommand::Review {
189                bone_id,
190                reviewers,
191                review_id,
192                execute,
193                args,
194            } => {
195                let project_root = match args.project_root.clone() {
196                    Some(p) => p,
197                    None => {
198                        std::env::current_dir().context("could not determine current directory")?
199                    }
200                };
201
202                let (config_path, _) = crate::config::find_config_in_project(&project_root)?;
203                let config = Config::load(&config_path)?;
204
205                let agent = args.resolve_agent(&config);
206                let project = args.resolve_project(&config);
207                let format = args.resolve_format();
208
209                review::execute(
210                    bone_id,
211                    reviewers.as_deref(),
212                    review_id.as_deref(),
213                    *execute,
214                    &agent,
215                    &project,
216                    &config,
217                    format,
218                )
219            }
220            ProtocolCommand::Cleanup { execute, args } => {
221                let project_root = match args.project_root.clone() {
222                    Some(p) => p,
223                    None => {
224                        std::env::current_dir().context("could not determine current directory")?
225                    }
226                };
227
228                let (config_path, _) = crate::config::find_config_in_project(&project_root)?;
229                let config = crate::config::Config::load(&config_path)?;
230
231                let agent = args.resolve_agent(&config);
232                let project = args.resolve_project(&config);
233                let format = args.resolve_format();
234                cleanup::execute(*execute, &agent, &project, format)
235            }
236            ProtocolCommand::Merge {
237                workspace,
238                message,
239                force,
240                execute,
241                args,
242            } => {
243                let project_root = match args.project_root.clone() {
244                    Some(p) => p,
245                    None => {
246                        std::env::current_dir().context("could not determine current directory")?
247                    }
248                };
249
250                let (config_path, _) = crate::config::find_config_in_project(&project_root)?;
251                let config = Config::load(&config_path)?;
252
253                let project = args.resolve_project(&config);
254                let agent = args.resolve_agent(&config);
255                let format = args.resolve_format();
256
257                let resolved_message = merge::resolve_message(message.as_deref())?;
258
259                merge::execute(
260                    workspace,
261                    &resolved_message,
262                    *force,
263                    *execute,
264                    &agent,
265                    &project,
266                    &config,
267                    format,
268                )
269            }
270            ProtocolCommand::Resume { args } => {
271                let project_root = match args.project_root.clone() {
272                    Some(p) => p,
273                    None => {
274                        std::env::current_dir().context("could not determine current directory")?
275                    }
276                };
277
278                let (config_path, _) = crate::config::find_config_in_project(&project_root)?;
279                let config = crate::config::Config::load(&config_path)?;
280
281                let agent = args.resolve_agent(&config);
282                let project = args.resolve_project(&config);
283                let format = args.resolve_format();
284                resume::execute(&agent, &project, &config, format)
285            }
286        }
287    }
288
289    /// Execute the `edict protocol start <bone-id>` command.
290    ///
291    /// Analyzes bone status and outputs shell commands to start work.
292    /// All status outcomes (ready, blocked, resumable) exit 0 with status in stdout.
293    /// Operational failures (config missing, tool unavailable) exit 1 via ProtocolExitError.
294    ///
295    /// If `execute` is true and status is Ready, runs the steps directly via the executor.
296    fn execute_start(
297        bone_id: &str,
298        dispatched: bool,
299        execute: bool,
300        args: &ProtocolArgs,
301    ) -> anyhow::Result<()> {
302        // Determine project root and load config
303        let project_root = match args.project_root.clone() {
304            Some(p) => p,
305            None => std::env::current_dir().context("could not determine current directory")?,
306        };
307
308        let config = match crate::config::find_config_in_project(&project_root) {
309            Ok((config_path, _)) => Config::load(&config_path)?,
310            Err(_) => {
311                return Err(exit_policy::ProtocolExitError::operational(
312                    "start",
313                    format!(
314                        "no .edict.toml or .botbox.toml found in {} or {}/ws/default",
315                        project_root.display(),
316                        project_root.display()
317                    ),
318                )
319                .into_exit_error()
320                .into());
321            }
322        };
323
324        let project = args.resolve_project(&config);
325        let agent = args.resolve_agent(&config);
326        let format = args.resolve_format();
327
328        // Collect state from bus and maw
329        let ctx = context::ProtocolContext::collect(&project, &agent)?;
330
331        // Check if bone exists and get its status
332        let bone_info = match ctx.bone_status(bone_id) {
333            Ok(bone) => bone,
334            Err(_) => {
335                let mut guidance = render::ProtocolGuidance::new("start");
336                guidance.blocked(format!(
337                    "bone {} not found. Check the ID with: maw exec default -- bn show {}",
338                    bone_id, bone_id
339                ));
340                return exit_policy::render_guidance(&guidance, format);
341            }
342        };
343
344        let mut guidance = render::ProtocolGuidance::new("start");
345        guidance.bone = Some(render::BoneRef {
346            id: bone_id.to_string(),
347            title: bone_info.title.clone(),
348        });
349
350        // Status check: is bone done?
351        if bone_info.state == "done" {
352            guidance.blocked("bone is already done".to_string());
353            return exit_policy::render_guidance(&guidance, format);
354        }
355
356        // Check for claim conflicts
357        match ctx.check_bone_claim_conflict(bone_id) {
358            Ok(Some(other_agent)) => {
359                guidance.blocked(format!("bone already claimed by agent '{}'", other_agent));
360                guidance.diagnostic(
361                    "Check current claims with: bus claims list --format json".to_string(),
362                );
363                return exit_policy::render_guidance(&guidance, format);
364            }
365            Err(e) => {
366                guidance.blocked(format!("failed to check claim conflict: {}", e));
367                return exit_policy::render_guidance(&guidance, format);
368            }
369            Ok(None) => {
370                // No conflict, proceed
371            }
372        }
373
374        // Check if agent already holds a bone claim for this ID
375        let held_workspace = ctx.workspace_for_bone(bone_id);
376
377        if let Some(ws_name) = held_workspace {
378            // RESUMABLE: agent already has this bone and workspace
379            guidance.status = render::ProtocolStatus::Resumable;
380            guidance.workspace = Some(ws_name.to_string());
381            guidance.advise(format!(
382                "Resume work in workspace {} with: edict protocol resume",
383                ws_name
384            ));
385            return exit_policy::render_guidance(&guidance, format);
386        }
387
388        // READY: generate start commands
389        guidance.status = render::ProtocolStatus::Ready;
390
391        // Build command steps: claim, create workspace, announce
392        let mut steps = Vec::new();
393
394        // 1. Stake bone claim
395        steps.push(shell::claims_stake_cmd(
396            &agent,
397            &format!("bone://{}/{}", project, bone_id),
398            bone_id,
399        ));
400
401        // 2. Create workspace
402        steps.push(shell::ws_create_cmd());
403
404        // 3. Capture workspace name (comment for human)
405        steps.push(
406            "# Capture workspace name from output above, then stake workspace claim:".to_string(),
407        );
408
409        // 4. Stake workspace claim (template with $WS placeholder - $WS is runtime-resolved)
410        steps.push(shell::claims_stake_cmd(
411            &agent,
412            &format!("workspace://{}/$WS", project),
413            bone_id,
414        ));
415
416        // 5. Update bone status
417        steps.push(shell::bn_do_cmd(bone_id));
418
419        // 6. Comment bone with workspace info
420        steps.push(shell::bn_comment_cmd(bone_id, "Started in workspace $WS"));
421
422        // 7. Announce on bus (unless --dispatched)
423        if !dispatched {
424            steps.push(shell::bus_send_cmd(
425                &agent,
426                &project,
427                &format!("Working on {}: {}", bone_id, &bone_info.title),
428                "task-claim",
429            ));
430        }
431
432        guidance.steps(steps);
433        guidance.advise(
434            "Stake bone claim first, then create workspace, stake workspace claim, update bone status, and announce on bus.".to_string()
435        );
436
437        // If --execute is set and status is Ready, execute the steps
438        if execute && guidance.status == render::ProtocolStatus::Ready {
439            let report = executor::execute_steps(&guidance.steps)
440                .map_err(|e| anyhow::anyhow!("step execution failed: {}", e))?;
441
442            let output = executor::render_report(&report, format);
443            println!("{}", output);
444
445            // Return error if any step failed
446            if !report.remaining.is_empty() || report.results.iter().any(|r| !r.success) {
447                return Err(exit_policy::ProtocolExitError::operational(
448                    "start",
449                    "one or more steps failed during execution".to_string(),
450                )
451                .into_exit_error()
452                .into());
453            }
454
455            Ok(())
456        } else {
457            // Otherwise, render guidance as usual
458            exit_policy::render_guidance(&guidance, format)
459        }
460    }
461}