@team-agent/installer 0.5.94 → 0.5.97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Cargo.lock CHANGED
@@ -581,7 +581,7 @@ dependencies = [
581
581
 
582
582
  [[package]]
583
583
  name = "team-agent"
584
- version = "0.5.94"
584
+ version = "0.5.97"
585
585
  dependencies = [
586
586
  "anyhow",
587
587
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.94"
12
+ version = "0.5.97"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -482,100 +482,22 @@ pub fn cmd_allow_peer_talk(args: &AllowPeerTalkArgs) -> Result<CmdResult, CliErr
482
482
  Ok(CmdResult::from_json(value, args.json))
483
483
  }
484
484
 
485
- /// `cmd_diagnose`(`parser.py:298`)。
485
+ /// `diagnose` is the permanent compatibility spelling for `doctor`.
486
+ /// Keep this wrapper free of probing/reporting logic so direct Rust callers and
487
+ /// the CLI both converge on the canonical handler.
486
488
  pub fn cmd_diagnose(args: &DiagnoseArgs) -> Result<CmdResult, CliError> {
487
- let team_hint = args
488
- .team
489
- .as_deref()
490
- .filter(|team| !team.is_empty())
491
- .unwrap_or("current");
492
- // Check the caller-supplied workspace before RuntimeOnly synthesis or
493
- // transport setup, which can create `.team` paths and make an empty
494
- // directory look like an unbound team.
495
- if !crate::cli::diagnose::workspace_has_existing_team_runtime(&args.workspace, team_hint) {
496
- let (issues, suggested_repairs) =
497
- crate::cli::diagnose::missing_team_runtime_issues_and_repairs(team_hint);
498
- let event_log = args
499
- .workspace
500
- .join(".team")
501
- .join("logs")
502
- .join("events.jsonl");
503
- return Ok(crate::cli::triage::report(
504
- json!({
505
- "event_log": event_log.to_string_lossy().to_string(),
506
- "issues": issues,
507
- "ok": false,
508
- "providers": provider_doctor_checks(),
509
- "runtime": {
510
- "workspace": args.workspace.to_string_lossy().to_string(),
511
- "team_key": team_hint,
512
- "session_name": Value::Null,
513
- "leader_receiver": Value::Null,
514
- "agent_count": 0,
515
- "message_count": 0,
516
- "result_count": 0,
517
- },
518
- "suggested_repairs": suggested_repairs,
519
- }),
520
- args.json,
521
- "diagnose",
522
- ));
523
- }
524
- let selected = crate::state::selector::resolve_active_team_readonly(
525
- &args.workspace,
526
- args.team.as_deref(),
527
- crate::state::selector::SelectorMode::RuntimeOnly,
528
- )
529
- .map_err(|e| CliError::Runtime(e.to_string()))?;
530
- let state = selected.state;
531
- let event_log = selected
532
- .run_workspace
533
- .join(".team")
534
- .join("logs")
535
- .join("events.jsonl");
536
- // 0.5.x Phase 1d Batch 3: factory-routed diagnose backend so
537
- // conpty teams get accurate `has_session` / capture from the shim
538
- // rather than a workspace tmux fallback (which would always miss
539
- // the ConPTY pane universe).
540
- let backend: Box<dyn crate::transport::Transport> =
541
- match crate::transport_factory::resolve_read_only_transport(
542
- &selected.run_workspace,
543
- Some(&state),
544
- crate::transport_factory::TransportPurpose::Diagnose,
545
- ) {
546
- Ok(r) => r.backend,
547
- Err(_) => Box::new(crate::tmux_backend::TmuxBackend::for_workspace(
548
- &selected.run_workspace,
549
- )),
550
- };
551
- let (issues, suggested_repairs) =
552
- diagnose_runtime_for_workspace(
553
- &selected.run_workspace,
554
- &state,
555
- backend.as_ref(),
556
- Some(selected.team_key.as_str()),
557
- );
558
- let ok = issues.as_array().is_some_and(Vec::is_empty);
559
- Ok(crate::cli::triage::report(
560
- json!({
561
- "event_log": event_log.to_string_lossy().to_string(),
562
- "issues": issues,
563
- "ok": ok,
564
- "providers": provider_doctor_checks(),
565
- "runtime": {
566
- "workspace": args.workspace.to_string_lossy().to_string(),
567
- "team_key": selected.team_key,
568
- "session_name": state.get("session_name").cloned().unwrap_or(Value::Null),
569
- "leader_receiver": state.get("leader_receiver").cloned().unwrap_or(Value::Null),
570
- "agent_count": state.get("agents").and_then(Value::as_object).map_or(0, serde_json::Map::len),
571
- "message_count": count_dir_entries(&selected.run_workspace.join(".team").join("messages")),
572
- "result_count": count_dir_entries(&selected.run_workspace.join(".team").join("results")),
573
- },
574
- "suggested_repairs": suggested_repairs,
575
- }),
576
- args.json,
577
- "diagnose",
578
- ))
489
+ cmd_doctor(&DoctorArgs {
490
+ spec: None,
491
+ workspace: args.workspace.clone(),
492
+ gate: None,
493
+ comms: false,
494
+ team: args.team.clone(),
495
+ fix: false,
496
+ fix_schema: false,
497
+ cleanup_orphans: false,
498
+ confirm: false,
499
+ json: args.json,
500
+ })
579
501
  }
580
502
 
581
503
  /// `cmd_preflight`(`parser.py:160`)。
@@ -1493,88 +1415,390 @@ pub fn cmd_acknowledge_idle(args: &AcknowledgeIdleArgs) -> Result<CmdResult, Cli
1493
1415
  ))
1494
1416
  }
1495
1417
 
1496
- /// `cmd_doctor`(`commands.py:218`)。分派:`--fix` 缺 gate→Usage err;`--comms`/`gate==comms`→
1497
- /// `diagnose_port::comms_selftest`(+ COMMS_BOUNDARY_TEXT 人读前缀);`gate==orphans`→`orphan_gate`;
1498
- /// 非 gate:`--fix-schema`→`fix_schema`;schema drift→注入;`--cleanup-orphans`→`cleanup_orphans`;
1499
- /// else→`diagnose_port::doctor(spec)` + schema 注入。返回 `dict | str`(comms 人读 = boundary+json)。
1418
+ /// `doctor` is the single health/diagnostic pipeline. `diagnose` enters here
1419
+ /// through the compatibility wrapper above; it never gets a second facts engine.
1500
1420
  pub fn cmd_doctor(args: &DoctorArgs) -> Result<CmdResult, CliError> {
1501
- if args.fix && args.gate.is_none() {
1502
- return Err(CliError::Runtime("--fix requires --gate".to_string()));
1503
- }
1504
- // swallow batch 3 ①: an unknown gate refuses explicitly (Python commands.py:234-235
1505
- // `unknown doctor gate`), never an empty default-doctor green.
1506
1421
  if let Some(DoctorGate::Unknown(raw)) = &args.gate {
1507
- return Ok(CmdResult::from_json(
1508
- serde_json::json!({"ok": false, "status": "unknown_gate", "gate": raw}),
1509
- args.json,
1510
- ));
1422
+ let value = json!({
1423
+ "ok": false,
1424
+ "issues": ["unknown_gate"],
1425
+ "suggested_repairs": [{"action": "use --gate orphans or --gate comms", "issue": "unknown_gate"}],
1426
+ "status": "unknown_gate",
1427
+ "gate": raw,
1428
+ });
1429
+ return Ok(crate::cli::triage::report(value, args.json, "doctor"));
1430
+ }
1431
+ if args.fix && args.gate.is_none() {
1432
+ let value = json!({
1433
+ "ok": false,
1434
+ "error": "--fix requires --gate",
1435
+ "issues": ["fix_requires_gate"],
1436
+ "suggested_repairs": [{"issue": "fix_requires_gate", "action": "add --gate orphans|comms"}],
1437
+ });
1438
+ return Ok(crate::cli::triage::report(value, args.json, "doctor"));
1511
1439
  }
1512
- if args.comms || matches!(args.gate, Some(DoctorGate::Comms)) {
1513
- let value = crate::diagnose::comms::doctor_comms_json(
1440
+
1441
+ let explicit_comms = args.comms || matches!(args.gate, Some(DoctorGate::Comms));
1442
+ let default_report = !explicit_comms
1443
+ && args.gate.is_none()
1444
+ && !args.cleanup_orphans
1445
+ && !args.fix_schema;
1446
+ let mut value = if explicit_comms {
1447
+ crate::diagnose::comms::doctor_comms_json(
1514
1448
  &args.workspace,
1515
1449
  args.team.as_deref(),
1516
1450
  Some("comms"),
1517
- )?;
1518
- let result = CmdResult::from_json(value, args.json);
1519
- if !args.json {
1520
- let json_tail = match &result.output {
1521
- CmdOutput::Json(value) => serde_json::to_string_pretty(&sort_json(value))?,
1522
- _ => String::new(),
1523
- };
1524
- return Ok(CmdResult {
1525
- output: CmdOutput::Human(format!("{COMMS_BOUNDARY_TEXT}\n{json_tail}")),
1526
- ..result
1527
- });
1528
- }
1529
- return Ok(result);
1530
- }
1531
- let default_report = args.gate.is_none() && !args.cleanup_orphans && !args.fix_schema;
1532
- let mut value = if matches!(args.gate, Some(DoctorGate::Orphans)) {
1451
+ )?
1452
+ } else if matches!(args.gate, Some(DoctorGate::Orphans)) {
1533
1453
  crate::diagnose::orphans::orphan_gate_json(&args.workspace, args.fix, args.confirm)?
1534
1454
  } else if args.cleanup_orphans {
1535
1455
  crate::diagnose::orphans::cleanup_orphans_json(&args.workspace, args.confirm)?
1536
1456
  } else if args.fix_schema {
1537
1457
  diagnose_port::fix_schema(&args.workspace)?
1538
1458
  } else {
1539
- let mut value = diagnose_port::doctor(&args.workspace, args.spec.as_deref())?;
1540
- crate::cli::diagnose::append_selected_live_leader_workspace_mismatch(
1541
- &args.workspace,
1542
- args.team.as_deref(),
1543
- &mut value,
1544
- );
1545
- crate::cli::diagnose::append_registry_channel_unbound_to_report(
1546
- &args.workspace,
1547
- args.team.as_deref(),
1548
- &mut value,
1459
+ let value = diagnose_port::doctor(&args.workspace, args.spec.as_deref())?;
1460
+ unified_default_doctor_report(args, value)
1461
+ };
1462
+
1463
+ if explicit_comms {
1464
+ if let Some(object) = value.as_object_mut() {
1465
+ // The comms probe uses a random run id internally for its disposable
1466
+ // fixture; the public doctor/diagnose report must be alias-stable.
1467
+ object.insert("run_id".to_string(), Value::String("doctor".to_string()));
1468
+ }
1469
+ }
1470
+ if !default_report {
1471
+ finalize_doctor_report(&mut value, false);
1472
+ }
1473
+ let result = crate::cli::triage::report(value, args.json, "doctor");
1474
+ Ok(result)
1475
+ }
1476
+
1477
+ fn unified_default_doctor_report(args: &DoctorArgs, mut value: Value) -> Value {
1478
+ let team_key = args
1479
+ .team
1480
+ .as_deref()
1481
+ .filter(|team| !team.is_empty())
1482
+ .unwrap_or("current");
1483
+ let requested_workspace = args.workspace.to_string_lossy().to_string();
1484
+ let mut runtime = json!({
1485
+ "status": "not_present",
1486
+ "workspace": requested_workspace,
1487
+ "run_workspace": Value::Null,
1488
+ "team_key": team_key,
1489
+ "session_name": Value::Null,
1490
+ "leader_receiver": Value::Null,
1491
+ "agent_count": 0,
1492
+ "message_count": 0,
1493
+ "result_count": 0,
1494
+ });
1495
+ // Resolve explicit selectors and nested team directories before deciding
1496
+ // that runtime inspection is inapplicable.
1497
+ let selected = crate::state::selector::resolve_active_team_readonly(
1498
+ &args.workspace,
1499
+ args.team.as_deref(),
1500
+ crate::state::selector::SelectorMode::RuntimeOnly,
1501
+ );
1502
+ let run_workspace = selected.as_ref().map(|selected| selected.run_workspace.clone())
1503
+ .unwrap_or_else(|_| crate::model::paths::canonical_run_workspace(&args.workspace)
1504
+ .unwrap_or_else(|_| args.workspace.clone()));
1505
+ let runtime_present = selected.as_ref().is_ok_and(|selected| {
1506
+ crate::cli::diagnose::workspace_has_existing_team_runtime(
1507
+ &selected.run_workspace,
1508
+ &selected.team_key,
1509
+ )
1510
+ });
1511
+ let db_present = crate::model::paths::runtime_dir(&run_workspace).join("team.db").exists();
1512
+ // One read-only observation supplies both issue classification and details.
1513
+ // A physical database must be checked even before state.json is created.
1514
+ let health = (runtime_present || db_present).then(|| {
1515
+ crate::coordinator::coordinator_health_read_only(
1516
+ &crate::coordinator::WorkspacePath::new(run_workspace.clone()),
1517
+ )
1518
+ });
1519
+ match selected {
1520
+ Ok(selected) => {
1521
+ if let Some(health) = health.as_ref().filter(|_| runtime_present) {
1522
+ let state = selected.state;
1523
+ let backend: Box<dyn crate::transport::Transport> =
1524
+ match crate::transport_factory::resolve_read_only_transport(
1525
+ &selected.run_workspace,
1526
+ Some(&state),
1527
+ crate::transport_factory::TransportPurpose::Diagnose,
1528
+ ) {
1529
+ Ok(resolved) => resolved.backend,
1530
+ Err(_) => Box::new(crate::tmux_backend::TmuxBackend::for_workspace(
1531
+ &selected.run_workspace,
1532
+ )),
1533
+ };
1534
+ let (issues, repairs) = crate::cli::diagnose::diagnose_runtime_for_workspace(
1535
+ &selected.run_workspace,
1536
+ &state,
1537
+ backend.as_ref(),
1538
+ Some(selected.team_key.as_str()),
1539
+ health,
1540
+ );
1541
+ merge_report_array(&mut value, "issues", &issues);
1542
+ merge_report_array(&mut value, "suggested_repairs", &repairs);
1543
+ let run_workspace = selected.run_workspace.to_string_lossy().to_string();
1544
+ runtime = json!({
1545
+ "status": "present",
1546
+ "workspace": args.workspace.to_string_lossy().to_string(),
1547
+ "run_workspace": run_workspace,
1548
+ "team_key": selected.team_key,
1549
+ "session_name": state.get("session_name").cloned().unwrap_or(Value::Null),
1550
+ "leader_receiver": state.get("leader_receiver").cloned().unwrap_or(Value::Null),
1551
+ "agent_count": state.get("agents").and_then(Value::as_object).map_or(0, serde_json::Map::len),
1552
+ "message_count": count_dir_entries(&selected.run_workspace.join(".team").join("messages")),
1553
+ "result_count": count_dir_entries(&selected.run_workspace.join(".team").join("results")),
1554
+ });
1555
+ }
1556
+ }
1557
+ Err(error) => {
1558
+ runtime["status"] = Value::String("unresolved".to_string());
1559
+ append_issue_repair(
1560
+ &mut value,
1561
+ "runtime_selection_failed",
1562
+ format!("unable to select runtime team: {error}"),
1563
+ );
1564
+ }
1565
+ }
1566
+ if let Some(health) = health {
1567
+ if db_present && !health.schema.ok {
1568
+ append_issue_repair(
1569
+ &mut value,
1570
+ "coordinator_schema_incompatible",
1571
+ "team-agent doctor --fix-schema --json".to_string(),
1572
+ );
1573
+ }
1574
+ value["coordinator"] = diagnose_port::coordinator_health_value(health);
1575
+ }
1576
+ if let Some(object) = value.as_object_mut() {
1577
+ object.insert("runtime".to_string(), runtime);
1578
+ object.insert("event_log".to_string(), Value::String(
1579
+ run_workspace.join(".team").join("logs").join("events.jsonl")
1580
+ .to_string_lossy().to_string(),
1581
+ ));
1582
+ }
1583
+ finalize_doctor_report(&mut value, true);
1584
+ value
1585
+ }
1586
+
1587
+ fn merge_report_array(report: &mut Value, key: &str, incoming: &Value) {
1588
+ let Some(items) = incoming.as_array() else {
1589
+ return;
1590
+ };
1591
+ let Some(object) = report.as_object_mut() else {
1592
+ return;
1593
+ };
1594
+ let target = object
1595
+ .entry(key.to_string())
1596
+ .or_insert_with(|| Value::Array(Vec::new()));
1597
+ if !target.is_array() {
1598
+ *target = Value::Array(Vec::new());
1599
+ }
1600
+ let target = target
1601
+ .as_array_mut()
1602
+ .expect("report array normalized before merge");
1603
+ for item in items {
1604
+ let identity = report_item_identity(item);
1605
+ if !target.iter().any(|existing| report_item_identity(existing) == identity) {
1606
+ target.push(item.clone());
1607
+ }
1608
+ }
1609
+ }
1610
+
1611
+ fn report_item_identity(item: &Value) -> String {
1612
+ if let Some(text) = item.as_str() {
1613
+ return text.to_string();
1614
+ }
1615
+ let primary = ["id", "code", "issue", "action", "reason", "message"]
1616
+ .iter()
1617
+ .find_map(|key| item.get(*key).and_then(Value::as_str));
1618
+ let scope = ["workspace", "team", "agent", "path", "rule"]
1619
+ .iter()
1620
+ .filter_map(|key| item.get(*key).and_then(Value::as_str))
1621
+ .collect::<Vec<_>>();
1622
+ let line = item.get("line").and_then(Value::as_u64);
1623
+ if primary.is_some() || !scope.is_empty() || line.is_some() {
1624
+ if scope.is_empty() && line.is_none() {
1625
+ return primary.unwrap_or("object").to_string();
1626
+ }
1627
+ return format!(
1628
+ "{}|{}|{}",
1629
+ primary.unwrap_or("object"),
1630
+ scope.join("|"),
1631
+ line.map_or_else(String::new, |line| line.to_string())
1549
1632
  );
1550
- value
1633
+ }
1634
+ item.to_string()
1635
+ }
1636
+
1637
+ fn append_issue_repair(report: &mut Value, issue: &str, repair: String) {
1638
+ merge_report_array(report, "issues", &json!([issue]));
1639
+ merge_report_array(
1640
+ report,
1641
+ "suggested_repairs",
1642
+ &json!([{"issue": issue, "action": repair}]),
1643
+ );
1644
+ }
1645
+
1646
+ fn finalize_doctor_report(report: &mut Value, default_report: bool) {
1647
+ let Some(object) = report.as_object_mut() else {
1648
+ *report = json!({
1649
+ "ok": false,
1650
+ "issues": ["invalid_doctor_report"],
1651
+ "suggested_repairs": [{"issue": "invalid_doctor_report"}],
1652
+ });
1653
+ return;
1551
1654
  };
1552
- if default_report {
1553
- let base_ok = value.get("ok").and_then(Value::as_bool).unwrap_or(false);
1554
- let has_blocking_issues = value
1555
- .get("issues")
1556
- .and_then(Value::as_array)
1557
- .is_some_and(|issues| {
1558
- issues.iter().any(|issue| {
1559
- let id = issue
1560
- .as_str()
1561
- .or_else(|| issue.get("id").and_then(Value::as_str));
1562
- id != Some("leader_not_attached")
1563
- })
1655
+ let runtime_status = object
1656
+ .get("runtime")
1657
+ .and_then(|runtime| runtime.get("status"))
1658
+ .and_then(Value::as_str)
1659
+ .unwrap_or("not_run")
1660
+ .to_string();
1661
+ let mut issues = object
1662
+ .remove("issues")
1663
+ .and_then(|value| value.as_array().cloned())
1664
+ .unwrap_or_default();
1665
+ let mut repairs = object
1666
+ .remove("suggested_repairs")
1667
+ .and_then(|value| value.as_array().cloned())
1668
+ .unwrap_or_default();
1669
+ let mut add = |issue: Value, repair: Option<Value>| {
1670
+ if !issues.iter().any(|existing| report_item_identity(existing) == report_item_identity(&issue)) {
1671
+ issues.push(issue);
1672
+ }
1673
+ if let Some(repair) = repair {
1674
+ if !repairs.iter().any(|existing| report_item_identity(existing) == report_item_identity(&repair)) {
1675
+ repairs.push(repair);
1676
+ }
1677
+ }
1678
+ };
1679
+
1680
+ if !default_report && object.get("ok").and_then(Value::as_bool) == Some(false) {
1681
+ let status = object
1682
+ .get("status")
1683
+ .and_then(Value::as_str)
1684
+ .or_else(|| object.get("reason").and_then(Value::as_str))
1685
+ .unwrap_or("doctor_failed");
1686
+ let issue = if status == "failed" {
1687
+ object
1688
+ .get("gate")
1689
+ .and_then(Value::as_str)
1690
+ .map_or_else(|| "doctor_failed".to_string(), |gate| format!("{gate}_failed"))
1691
+ } else if status.ends_with("_failed") || status == "refused" {
1692
+ status.to_string()
1693
+ } else {
1694
+ format!("{status}_failed")
1695
+ };
1696
+ let repair = object
1697
+ .get("action")
1698
+ .or_else(|| object.get("next_action"))
1699
+ .or_else(|| object.get("reason"))
1700
+ .cloned();
1701
+ add(Value::String(issue), repair.map(|value| json!({"action": value})));
1702
+ }
1703
+ if object
1704
+ .get("workspace")
1705
+ .and_then(Value::as_str)
1706
+ .is_some_and(|workspace| !std::path::Path::new(workspace).is_dir())
1707
+ {
1708
+ add(
1709
+ json!("invalid_workspace"),
1710
+ Some(json!({"issue": "invalid_workspace", "action": "use an existing workspace"})),
1711
+ );
1712
+ }
1713
+ if let Some(profile) = object.get("profile_smoke") {
1714
+ if profile.get("ok").and_then(Value::as_bool) == Some(false)
1715
+ && profile.get("status").and_then(Value::as_str) != Some("legacy_team_invalid")
1716
+ {
1717
+ add(
1718
+ json!("profile_smoke_failed"),
1719
+ profile
1720
+ .get("next_action")
1721
+ .cloned()
1722
+ .map(|action| json!({"issue": "profile_smoke_failed", "action": action})),
1723
+ );
1724
+ }
1725
+ }
1726
+ if let Some(grok) = object.get("grok_slot") {
1727
+ if grok.get("readable").and_then(Value::as_bool) == Some(false)
1728
+ || grok.get("consistent").and_then(Value::as_bool) == Some(false)
1729
+ {
1730
+ add(
1731
+ json!("grok_slot_mismatch"),
1732
+ grok.get("reason").cloned().map(|reason| {
1733
+ json!({"issue": "grok_slot_mismatch", "action": reason})
1734
+ }),
1735
+ );
1736
+ }
1737
+ }
1738
+ if let Some(findings) = object
1739
+ .get("secret_scan")
1740
+ .and_then(|scan| scan.get("findings"))
1741
+ .and_then(Value::as_array)
1742
+ {
1743
+ for finding in findings {
1744
+ let Some(finding_object) = finding.as_object() else {
1745
+ continue;
1746
+ };
1747
+ let Some(rule) = finding_object.get("rule").and_then(Value::as_str) else {
1748
+ continue;
1749
+ };
1750
+ let path = finding_object
1751
+ .get("path")
1752
+ .and_then(Value::as_str)
1753
+ .unwrap_or("unknown");
1754
+ let line = finding_object
1755
+ .get("line")
1756
+ .and_then(Value::as_u64)
1757
+ .unwrap_or(0);
1758
+ let issue = json!({
1759
+ "id": "secret_scan_finding",
1760
+ "rule": rule,
1761
+ "path": path,
1762
+ "line": line,
1564
1763
  });
1565
- if let Some(object) = value.as_object_mut() {
1566
- object.insert(
1567
- "ok".to_string(),
1568
- Value::Bool(base_ok && !has_blocking_issues),
1764
+ add(
1765
+ issue,
1766
+ Some(json!({
1767
+ "issue": "secret_scan_finding",
1768
+ "rule": rule,
1769
+ "path": path,
1770
+ "line": line,
1771
+ "action": format!("remove secret finding at {path}:{line}"),
1772
+ })),
1569
1773
  );
1570
1774
  }
1571
1775
  }
1572
- let result = CmdResult::from_json(value, args.json);
1573
- Ok(if default_report && !args.json {
1574
- crate::cli::triage::human_result(result, "doctor")
1575
- } else {
1576
- result
1577
- })
1776
+ if runtime_status == "unresolved" {
1777
+ add(
1778
+ json!("runtime_selection_failed"),
1779
+ Some(json!({"issue": "runtime_selection_failed", "action": "select an existing Team runtime"})),
1780
+ );
1781
+ }
1782
+ if runtime_status == "present"
1783
+ && object
1784
+ .get("coordinator")
1785
+ .and_then(|coordinator| coordinator.get("ok"))
1786
+ .and_then(Value::as_bool)
1787
+ == Some(false)
1788
+ {
1789
+ let reason = object
1790
+ .get("coordinator")
1791
+ .and_then(|coordinator| coordinator.get("metadata_mismatch_reason"))
1792
+ .cloned()
1793
+ .unwrap_or_else(|| json!("coordinator unavailable"));
1794
+ add(
1795
+ json!("coordinator_unavailable"),
1796
+ Some(json!({"issue": "coordinator_unavailable", "action": reason})),
1797
+ );
1798
+ }
1799
+ object.insert("issues".to_string(), Value::Array(issues.clone()));
1800
+ object.insert("suggested_repairs".to_string(), Value::Array(repairs));
1801
+ object.insert("ok".to_string(), Value::Bool(issues.is_empty()));
1578
1802
  }
1579
1803
 
1580
1804
  #[cfg(test)]