1use std::io::IsTerminal;
2use std::path::PathBuf;
3
4use clap::Args;
5use serde::{Deserialize, Serialize};
6
7use super::doctor::OutputFormat;
8use super::protocol::context::ProtocolContext;
9use super::protocol::review_gate;
10use crate::config::Config;
11use crate::subprocess::Tool;
12
13fn is_valid_bone_id(id: &str) -> bool {
15 (id.starts_with("bn-") || id.starts_with("bd-"))
16 && id.len() <= 20
17 && id[3..].chars().all(|c| c.is_ascii_alphanumeric())
18}
19
20fn is_valid_workspace_name(name: &str) -> bool {
22 !name.is_empty()
23 && name.len() <= 64
24 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
25}
26
27#[derive(Debug, Args)]
28pub struct StatusArgs {
29 #[arg(long)]
31 pub project: Option<String>,
32 #[arg(long)]
34 pub agent: Option<String>,
35 #[arg(long, value_enum)]
37 pub format: Option<OutputFormat>,
38}
39
40#[derive(Debug, Serialize, Deserialize)]
41pub struct Advice {
42 pub severity: String,
44 pub message: String,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub command: Option<String>,
49}
50
51#[derive(Debug, Serialize, Deserialize)]
52pub struct StatusReport {
53 pub ready_bones: ReadyBones,
54 pub workspaces: WorkspaceSummary,
55 pub inbox: InboxSummary,
56 pub agents: AgentsSummary,
57 pub claims: ClaimsSummary,
58 #[serde(skip_serializing_if = "Vec::is_empty")]
60 pub advice: Vec<Advice>,
61}
62
63#[derive(Debug, Serialize, Deserialize)]
64pub struct ReadyBones {
65 pub count: usize,
66 pub items: Vec<BoneSummary>,
67}
68
69#[derive(Debug, Serialize, Deserialize)]
70pub struct BoneSummary {
71 pub id: String,
72 pub title: String,
73}
74
75#[derive(Debug, Serialize, Deserialize)]
76pub struct WorkspaceSummary {
77 pub total: usize,
78 pub active: usize,
79 pub stale: usize,
80}
81
82#[derive(Debug, Serialize, Deserialize)]
83pub struct InboxSummary {
84 pub unread: usize,
85}
86
87#[derive(Debug, Serialize, Deserialize)]
88pub struct AgentsSummary {
89 pub running: usize,
90}
91
92#[derive(Debug, Serialize, Deserialize)]
93pub struct ClaimsSummary {
94 pub active: usize,
95}
96
97impl StatusArgs {
98 pub fn execute(&self) -> anyhow::Result<()> {
99 let format = self.format.unwrap_or_else(|| {
100 if std::io::stdout().is_terminal() {
101 OutputFormat::Pretty
102 } else {
103 OutputFormat::Text
104 }
105 });
106
107 let config = crate::config::find_config_in_project(&PathBuf::from("."))
109 .ok()
110 .and_then(|(p, _)| Config::load(&p).ok());
111 let project = self
112 .project
113 .clone()
114 .or_else(|| std::env::var("EDICT_PROJECT").ok())
115 .or_else(|| config.as_ref().map(|c| c.project.name.clone()))
116 .unwrap_or_else(|| "edict".to_string());
117
118 let agent = self
119 .agent
120 .clone()
121 .or_else(|| std::env::var("EDICT_AGENT").ok())
122 .or_else(|| config.as_ref().map(|c| c.default_agent()))
123 .unwrap_or_else(|| format!("{project}-dev"));
124
125 let required_reviewers: Vec<String> = config
127 .as_ref()
128 .filter(|c| c.review.enabled)
129 .map(|c| {
130 c.review
131 .reviewers
132 .iter()
133 .map(|r| format!("{project}-{r}"))
134 .collect()
135 })
136 .unwrap_or_else(|| vec![format!("{project}-security")]);
137
138 let mut report = StatusReport {
139 ready_bones: ReadyBones {
140 count: 0,
141 items: vec![],
142 },
143 workspaces: WorkspaceSummary {
144 total: 0,
145 active: 0,
146 stale: 0,
147 },
148 inbox: InboxSummary { unread: 0 },
149 agents: AgentsSummary { running: 0 },
150 claims: ClaimsSummary { active: 0 },
151 advice: Vec::new(),
152 };
153
154 let ctx = ProtocolContext::collect(&project, &agent).ok();
156
157 if let Ok(output) = Tool::new("bn")
159 .arg("next")
160 .arg("--format")
161 .arg("json")
162 .run()
163 && let Ok(bones_json) = serde_json::from_str::<serde_json::Value>(&output.stdout)
164 && let Some(items) = bones_json.get("items").and_then(|v| v.as_array())
165 {
166 report.ready_bones.count = items.len();
167 for item in items.iter().take(5) {
168 if let (Some(id), Some(title)) = (
169 item.get("id").and_then(|v| v.as_str()),
170 item.get("title").and_then(|v| v.as_str()),
171 ) {
172 report.ready_bones.items.push(BoneSummary {
173 id: id.to_string(),
174 title: title.to_string(),
175 });
176 }
177 }
178 }
179
180 if let Ok(output) = Tool::new("maw")
182 .arg("ws")
183 .arg("list")
184 .arg("--format")
185 .arg("json")
186 .run()
187 && let Ok(ws_json) = serde_json::from_str::<serde_json::Value>(&output.stdout)
188 {
189 if let Some(workspaces) = ws_json.get("workspaces").and_then(|v| v.as_array()) {
190 report.workspaces.total = workspaces.len();
191 for ws in workspaces {
192 if ws
193 .get("is_default")
194 .and_then(|v| v.as_bool())
195 .unwrap_or(false)
196 {
197 continue;
198 }
199 report.workspaces.active += 1;
200 }
201 }
202 if let Some(ws_advice) = ws_json.get("advice").and_then(|v| v.as_array()) {
203 report.workspaces.stale = ws_advice
204 .iter()
205 .filter(|a| {
206 a.get("message")
207 .and_then(|v| v.as_str())
208 .map(|s| s.contains("stale"))
209 .unwrap_or(false)
210 })
211 .count();
212 }
213 }
214
215 if let Ok(output) = Tool::new("bus")
217 .arg("inbox")
218 .arg("--format")
219 .arg("json")
220 .run()
221 && let Ok(inbox_json) = serde_json::from_str::<serde_json::Value>(&output.stdout)
222 && let Some(messages) = inbox_json.get("messages").and_then(|v| v.as_array())
223 {
224 report.inbox.unread = messages.len();
225 }
226
227 if let Ok(output) = Tool::new("vessel")
229 .arg("list")
230 .arg("--format")
231 .arg("json")
232 .run()
233 && let Ok(agents_json) = serde_json::from_str::<serde_json::Value>(&output.stdout)
234 && let Some(agents) = agents_json.get("agents").and_then(|v| v.as_array())
235 {
236 report.agents.running = agents.len();
237 }
238
239 if let Ok(output) = Tool::new("bus")
241 .arg("claims")
242 .arg("list")
243 .arg("--format")
244 .arg("json")
245 .run()
246 && let Ok(claims_json) = serde_json::from_str::<serde_json::Value>(&output.stdout)
247 && let Some(claims) = claims_json.get("claims").and_then(|v| v.as_array())
248 {
249 report.claims.active = claims.len();
250 }
251
252 if let Some(ref context) = ctx {
254 self.generate_advice(&mut report, context, &required_reviewers)?;
255 }
256
257 match format {
258 OutputFormat::Pretty => {
259 self.print_pretty(&report);
260 }
261 OutputFormat::Text => {
262 self.print_text(&report);
263 }
264 OutputFormat::Json => {
265 println!("{}", serde_json::to_string_pretty(&report)?);
266 }
267 }
268
269 Ok(())
270 }
271
272 fn generate_advice(
274 &self,
275 report: &mut StatusReport,
276 ctx: &ProtocolContext,
277 required_reviewers: &[String],
278 ) -> anyhow::Result<()> {
279 for (bone_id, _pattern) in ctx.held_bone_claims() {
281 if !is_valid_bone_id(bone_id) {
282 continue; }
284 if let Ok(bone) = ctx.bone_status(bone_id) {
285 if bone.state == "done" || bone.state == "archived" {
286 report.advice.push(Advice {
287 severity: "CRITICAL".to_string(),
288 message: format!(
289 "Orphaned claim: bone {} is closed but claim still active → cleanup required",
290 bone_id
291 ),
292 command: Some(format!("edict protocol cleanup {}", bone_id)),
293 });
294 }
295 }
296 }
297
298 for (bone_id, _pattern) in ctx.held_bone_claims() {
300 if !is_valid_bone_id(bone_id) {
301 continue;
302 }
303 if let Some(ws_name) = ctx.workspace_for_bone(bone_id) {
304 if let Ok(reviews) = ctx.reviews_in_workspace(ws_name) {
305 for review_summary in reviews {
306 if let Ok(review_detail) =
307 ctx.review_status(&review_summary.review_id, ws_name)
308 {
309 let gate = review_gate::evaluate_review_gate(
310 &review_detail,
311 required_reviewers,
312 );
313
314 if gate.status == review_gate::ReviewGateStatus::Approved {
315 report.advice.push(Advice {
316 severity: "HIGH".to_string(),
317 message: format!(
318 "Review {} approved (LGTM) → ready to finish bone {}",
319 review_detail.review_id, bone_id
320 ),
321 command: Some(format!("edict protocol finish {}", bone_id)),
322 });
323 }
324 }
325 }
326 }
327 }
328 }
329
330 for (bone_id, _pattern) in ctx.held_bone_claims() {
332 if !is_valid_bone_id(bone_id) {
333 continue;
334 }
335 if let Some(ws_name) = ctx.workspace_for_bone(bone_id) {
336 if let Ok(reviews) = ctx.reviews_in_workspace(ws_name) {
337 for review_summary in reviews {
338 if let Ok(review_detail) =
339 ctx.review_status(&review_summary.review_id, ws_name)
340 {
341 let gate = review_gate::evaluate_review_gate(
342 &review_detail,
343 required_reviewers,
344 );
345
346 if gate.status == review_gate::ReviewGateStatus::Blocked {
347 let blocked_by = gate.blocked_by.join(", ");
348 report.advice.push(Advice {
349 severity: "HIGH".to_string(),
350 message: format!(
351 "Review {} blocked by {} → address feedback on bone {}",
352 review_detail.review_id, blocked_by, bone_id
353 ),
354 command: Some(format!("bn show {}", bone_id)),
355 });
356 }
357 }
358 }
359 }
360 }
361 }
362
363 for (bone_id, _pattern) in ctx.held_bone_claims() {
365 if !is_valid_bone_id(bone_id) {
366 continue;
367 }
368 if let Ok(bone) = ctx.bone_status(bone_id) {
369 if bone.state == "doing" && ctx.workspace_for_bone(bone_id).is_none() {
370 report.advice.push(Advice {
371 severity: "MEDIUM".to_string(),
372 message: format!(
373 "In-progress bone {} has no workspace → possible crash recovery needed",
374 bone_id
375 ),
376 command: Some(format!("bn show {}", bone_id)),
377 });
378 }
379 }
380 }
381
382 for ws in ctx.workspaces() {
384 if ws.is_default {
385 continue;
386 }
387 let has_claim = ctx
388 .held_workspace_claims()
389 .iter()
390 .any(|(name, _)| name == &ws.name);
391
392 if !has_claim {
393 let command = if is_valid_workspace_name(&ws.name) {
394 Some(format!("maw ws destroy {}", ws.name))
395 } else {
396 None };
398 report.advice.push(Advice {
399 severity: "MEDIUM".to_string(),
400 message: format!(
401 "Workspace {} has no bone claim → investigate or clean up",
402 ws.name
403 ),
404 command,
405 });
406 }
407 }
408
409 if report.ready_bones.count > 0 {
411 report.advice.push(Advice {
412 severity: "LOW".to_string(),
413 message: format!(
414 "{} ready bone(s) available → run triage",
415 report.ready_bones.count
416 ),
417 command: Some("maw exec default -- bn next".to_string()),
418 });
419 }
420
421 if ctx.held_bone_claims().is_empty() && report.ready_bones.count == 0 {
423 report.advice.push(Advice {
424 severity: "INFO".to_string(),
425 message: "No held bones and no ready work → check inbox or create bones from tasks"
426 .to_string(),
427 command: Some("bus inbox --agent $AGENT".to_string()),
428 });
429 }
430
431 Ok(())
432 }
433
434 fn print_pretty(&self, report: &StatusReport) {
435 println!("=== Botbox Status ===\n");
436
437 println!("Ready Bones: {}", report.ready_bones.count);
438 for bone in report.ready_bones.items.iter().take(5) {
439 println!(" • {} — {}", bone.id, bone.title);
440 }
441 if report.ready_bones.count > 5 {
442 println!(" ... and {} more", report.ready_bones.count - 5);
443 }
444
445 println!("\nWorkspaces:");
446 println!(
447 " Total: {} (Active: {}, Stale: {})",
448 report.workspaces.total, report.workspaces.active, report.workspaces.stale
449 );
450
451 println!("\nInbox: {} unread", report.inbox.unread);
452 println!("Running Agents: {}", report.agents.running);
453 println!("Active Claims: {}", report.claims.active);
454
455 if !report.advice.is_empty() {
456 println!("\nAdvice:");
457 for adv in &report.advice {
458 println!(" [{}] {}", adv.severity, adv.message);
459 if let Some(ref cmd) = adv.command {
460 println!(" → {}", cmd);
461 }
462 }
463 }
464 }
465
466 fn print_text(&self, report: &StatusReport) {
467 println!("edict-status");
468 println!("ready-bones count={}", report.ready_bones.count);
469 for bone in report.ready_bones.items.iter().take(5) {
470 println!("ready-bone id={} title={}", bone.id, bone.title);
471 }
472 println!(
473 "workspaces total={} active={} stale={}",
474 report.workspaces.total, report.workspaces.active, report.workspaces.stale
475 );
476 println!("inbox unread={}", report.inbox.unread);
477 println!("agents running={}", report.agents.running);
478 println!("claims active={}", report.claims.active);
479
480 if !report.advice.is_empty() {
481 println!("advice count={}", report.advice.len());
482 for adv in &report.advice {
483 println!(
484 "advice-item severity={} message={}",
485 adv.severity, adv.message
486 );
487 if let Some(ref cmd) = adv.command {
488 println!("advice-command {}", cmd);
489 }
490 }
491 }
492 }
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498
499 #[test]
500 fn advice_structure_is_serializable() {
501 let adv = Advice {
502 severity: "HIGH".to_string(),
503 message: "Test advice".to_string(),
504 command: Some("test-command".to_string()),
505 };
506 let json = serde_json::to_string(&adv).expect("should serialize");
507 assert!(json.contains("\"severity\""));
508 assert!(json.contains("\"message\""));
509 assert!(json.contains("\"command\""));
510 }
511
512 #[test]
513 fn status_report_with_empty_advice() {
514 let report = StatusReport {
515 ready_bones: ReadyBones {
516 count: 0,
517 items: vec![],
518 },
519 workspaces: WorkspaceSummary {
520 total: 0,
521 active: 0,
522 stale: 0,
523 },
524 inbox: InboxSummary { unread: 0 },
525 agents: AgentsSummary { running: 0 },
526 claims: ClaimsSummary { active: 0 },
527 advice: vec![],
528 };
529
530 let json = serde_json::to_string_pretty(&report).expect("should serialize");
531 let parsed: serde_json::Value = serde_json::from_str(&json).expect("should parse");
533 if let Some(advice) = parsed.get("advice") {
536 assert!(advice.is_array());
537 assert_eq!(advice.as_array().unwrap().len(), 0);
538 }
539 }
540
541 #[test]
542 fn status_report_with_advice() {
543 let report = StatusReport {
544 ready_bones: ReadyBones {
545 count: 2,
546 items: vec![BoneSummary {
547 id: "bd-abc".to_string(),
548 title: "test bone 1".to_string(),
549 }],
550 },
551 workspaces: WorkspaceSummary {
552 total: 1,
553 active: 1,
554 stale: 0,
555 },
556 inbox: InboxSummary { unread: 1 },
557 agents: AgentsSummary { running: 1 },
558 claims: ClaimsSummary { active: 1 },
559 advice: vec![
560 Advice {
561 severity: "HIGH".to_string(),
562 message: "Test high priority".to_string(),
563 command: Some("test-cmd".to_string()),
564 },
565 Advice {
566 severity: "INFO".to_string(),
567 message: "Test info".to_string(),
568 command: None,
569 },
570 ],
571 };
572
573 let json = serde_json::to_string_pretty(&report).expect("should serialize");
574 let parsed: serde_json::Value = serde_json::from_str(&json).expect("should parse");
575
576 assert!(parsed.get("advice").is_some());
578 let advice_array = parsed
579 .get("advice")
580 .unwrap()
581 .as_array()
582 .expect("should be array");
583 assert_eq!(advice_array.len(), 2);
584 assert_eq!(advice_array[0]["severity"], "HIGH");
585 assert_eq!(advice_array[1]["severity"], "INFO");
586 }
587
588 #[test]
589 fn advice_command_is_optional() {
590 let adv_with_cmd = Advice {
591 severity: "CRITICAL".to_string(),
592 message: "Action required".to_string(),
593 command: Some("cleanup-command".to_string()),
594 };
595
596 let adv_without_cmd = Advice {
597 severity: "INFO".to_string(),
598 message: "Informational only".to_string(),
599 command: None,
600 };
601
602 let json_with = serde_json::to_value(&adv_with_cmd).expect("should serialize");
603 let json_without = serde_json::to_value(&adv_without_cmd).expect("should serialize");
604
605 assert!(json_with.get("command").is_some());
606 match json_without.get("command") {
609 Some(cmd) => assert!(cmd.is_null()),
610 None => {} }
612 }
613}