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#[derive(Debug, clap::Args)]
25pub struct ProtocolArgs {
26 #[arg(long)]
28 pub agent: Option<String>,
29 #[arg(long)]
31 pub project: Option<String>,
32 #[arg(long)]
34 pub project_root: Option<PathBuf>,
35 #[arg(long, value_enum)]
37 pub format: Option<OutputFormat>,
38}
39
40impl ProtocolArgs {
41 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 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 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 Start {
79 bone_id: String,
81 #[arg(long)]
83 dispatched: bool,
84 #[arg(long)]
86 execute: bool,
87 #[command(flatten)]
88 args: ProtocolArgs,
89 },
90 Finish {
92 bone_id: String,
94 #[arg(long)]
96 no_merge: bool,
97 #[arg(long)]
99 force: bool,
100 #[arg(long)]
102 execute: bool,
103 #[command(flatten)]
104 args: ProtocolArgs,
105 },
106 Review {
108 bone_id: String,
110 #[arg(long)]
112 reviewers: Option<String>,
113 #[arg(long)]
115 review_id: Option<String>,
116 #[arg(long)]
118 execute: bool,
119 #[command(flatten)]
120 args: ProtocolArgs,
121 },
122 Cleanup {
124 #[arg(long)]
126 execute: bool,
127 #[command(flatten)]
128 args: ProtocolArgs,
129 },
130 Resume {
132 #[command(flatten)]
133 args: ProtocolArgs,
134 },
135 Merge {
137 workspace: String,
139 #[arg(long, short = 'm')]
142 message: Option<String>,
143 #[arg(long)]
145 force: bool,
146 #[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 fn execute_start(
297 bone_id: &str,
298 dispatched: bool,
299 execute: bool,
300 args: &ProtocolArgs,
301 ) -> anyhow::Result<()> {
302 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 let ctx = context::ProtocolContext::collect(&project, &agent)?;
330
331 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 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 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 }
372 }
373
374 let held_workspace = ctx.workspace_for_bone(bone_id);
376
377 if let Some(ws_name) = held_workspace {
378 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 guidance.status = render::ProtocolStatus::Ready;
390
391 let mut steps = Vec::new();
393
394 steps.push(shell::claims_stake_cmd(
396 &agent,
397 &format!("bone://{}/{}", project, bone_id),
398 bone_id,
399 ));
400
401 steps.push(shell::ws_create_cmd());
403
404 steps.push(
406 "# Capture workspace name from output above, then stake workspace claim:".to_string(),
407 );
408
409 steps.push(shell::claims_stake_cmd(
411 &agent,
412 &format!("workspace://{}/$WS", project),
413 bone_id,
414 ));
415
416 steps.push(shell::bn_do_cmd(bone_id));
418
419 steps.push(shell::bn_comment_cmd(bone_id, "Started in workspace $WS"));
421
422 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 && 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 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 exit_policy::render_guidance(&guidance, format)
459 }
460 }
461}