Skip to main content

edict/commands/
status.rs

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
13/// Validate that a bone ID matches the expected pattern (e.g., bn-xxxx).
14fn 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
20/// Validate that a workspace name is safe (alphanumeric + hyphens only).
21fn 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    /// Project name for scoping (defaults to project.name in config)
30    #[arg(long)]
31    pub project: Option<String>,
32    /// Agent name for filtering (defaults to EDICT_AGENT or defaultAgent in config)
33    #[arg(long)]
34    pub agent: Option<String>,
35    /// Output format
36    #[arg(long, value_enum)]
37    pub format: Option<OutputFormat>,
38}
39
40#[derive(Debug, Serialize, Deserialize)]
41pub struct Advice {
42    /// Priority level: CRITICAL, HIGH, MEDIUM, LOW, INFO
43    pub severity: String,
44    /// Human-readable advice message
45    pub message: String,
46    /// Suggested shell command (if applicable)
47    #[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    /// Actionable advice based on cross-tool state
59    #[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        // Get project and agent from args → env → config → hardcoded fallback
108        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        // Get required reviewers from config (format: ["security"] → ["<project>-security"])
126        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        // Try to collect ProtocolContext for advice generation
155        let ctx = ProtocolContext::collect(&project, &agent).ok();
156
157        // 1. Ready bones
158        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        // 2. Active workspaces
181        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        // 3. Pending inbox
216        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        // 4. Running agents
228        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        // 5. Active claims
240        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        // 6. Generate advice based on cross-tool state
253        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    /// Generate actionable advice from cross-tool state analysis.
273    fn generate_advice(
274        &self,
275        report: &mut StatusReport,
276        ctx: &ProtocolContext,
277        required_reviewers: &[String],
278    ) -> anyhow::Result<()> {
279        // Priority 1: CRITICAL - orphaned claims (bone closed but claim still active)
280        for (bone_id, _pattern) in ctx.held_bone_claims() {
281            if !is_valid_bone_id(bone_id) {
282                continue; // Skip malformed claim URIs
283            }
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        // Priority 2: HIGH - LGTM review with no finish action
299        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        // Priority 3: HIGH - BLOCK review needing response
331        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        // Priority 4: MEDIUM - in-progress bone with no workspace
364        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        // Priority 5: MEDIUM - workspace with no bone claim
383        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 // Don't suggest a command with an unsafe name
397                };
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        // Priority 6: LOW - ready bones available (informational)
410        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        // Priority 7: INFO - agent idle with no work
422        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        // Empty advice array should not be included due to skip_serializing_if
532        let parsed: serde_json::Value = serde_json::from_str(&json).expect("should parse");
533        // The advice key may or may not exist if empty due to skip_serializing_if
534        // but if present, it should be an empty array
535        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        // Verify advice array structure
577        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        // The command field may or may not be serialized when None due to skip_serializing_if
607        // but if present should be null or omitted
608        match json_without.get("command") {
609            Some(cmd) => assert!(cmd.is_null()),
610            None => {} // Also acceptable if field is completely omitted
611        }
612    }
613}