99import shutil
1010from datetime import date , datetime , timezone
1111from pathlib import Path
12- from typing import List , Tuple
12+ from typing import List , Sequence , Tuple
1313
1414from .budgets import (
1515 BOOTSTRAP_BUDGETS ,
2020from .station import DoctorContext
2121
2222CheckResult = Tuple [str , str , str ] # (status, name, detail)
23+ ScopedCheckResult = Tuple [str , str , str , str ] # (status, name, detail, scope)
2324OK = "OK"
2425WARN = "WARN"
2526FAIL = "FAIL"
2627MANUAL = "MANUAL"
2728INFO = "INFO"
28- DEFAULT_TEXT_CHECK_LIMIT = 50
29+ SCOPE_TARGET = "target"
30+ SCOPE_OPERATOR = "operator"
31+ ACTIONABLE_STATUSES = frozenset ({FAIL , WARN , MANUAL })
2932ADAPTER_CHECK_PREFIX = "adapter: "
3033
3134
@@ -34,6 +37,29 @@ def _adapter_check_name(name: str) -> str:
3437 return f"{ ADAPTER_CHECK_PREFIX } { name } "
3538
3639
40+ def _scoped_check (status : str , name : str , detail : str , scope : str = SCOPE_TARGET ) -> ScopedCheckResult :
41+ return (status , name , detail , scope )
42+
43+
44+ def _operator_check (status : str , name : str , detail : str ) -> ScopedCheckResult :
45+ return _scoped_check (status , name , detail , SCOPE_OPERATOR )
46+
47+
48+ def _normalize_scoped_check (
49+ check : CheckResult | ScopedCheckResult ,
50+ * ,
51+ default_scope : str = SCOPE_TARGET ,
52+ ) -> ScopedCheckResult :
53+ if len (check ) == 4 :
54+ return check # type: ignore[return-value]
55+ status , name , detail = check
56+ return _scoped_check (status , name , detail , default_scope )
57+
58+
59+ def _check_scope (check : CheckResult | ScopedCheckResult ) -> str :
60+ return check [3 ] if len (check ) == 4 else SCOPE_TARGET
61+
62+
3763def build_context (target : Path , harness : str = "generic" ) -> DoctorContext :
3864 target = target .expanduser ().resolve ()
3965 from .config import load_config
@@ -62,7 +88,7 @@ def core_station_checks(ctx: DoctorContext) -> List[CheckResult]:
6288 if check is not None :
6389 checks .append (check )
6490 if "openclaw" in ctx .harnesses :
65- checks .extend (_check_openclaw ())
91+ checks .extend (_operator_check ( status , name , detail ) for status , name , detail in _check_openclaw ())
6692 if "hermes" in ctx .harnesses :
6793 checks .extend (_check_hermes (ctx .target ))
6894 checks .extend (_check_orphan_inboxes (ctx .target , ctx .harnesses ))
@@ -255,35 +281,40 @@ def run(
255281 return _report (checks , full = full , target = ctx .target , operator = operator )
256282
257283
258- def _gather_checks (ctx : DoctorContext ) -> List [CheckResult ]:
284+ def _gather_checks (ctx : DoctorContext ) -> List [ScopedCheckResult ]:
259285 from . import component_report
260286 from .registry import all_stations
261287 from . import managed
262288
263- checks : List [CheckResult ] = []
264- checks .extend (component_report .doctor_checks ())
289+ checks : List [ScopedCheckResult ] = []
290+ for status , name , detail in component_report .doctor_checks ():
291+ checks .append (_operator_check (status , name , detail ))
265292 missing_tools : List [Tuple [str , str ]] = []
266293 for station in all_stations ():
267294 if station .doctor is not None :
268- checks .extend (station .doctor (ctx ))
295+ for check in station .doctor (ctx ):
296+ checks .append (_normalize_scoped_check (check ))
269297 for tool in managed .for_station (station .name ):
270298 if tool .detect ():
271- checks .extend (tool .doctor (ctx ))
299+ for check in tool .doctor (ctx ):
300+ checks .append (_operator_check (check [0 ], check [1 ], check [2 ]))
272301 else :
273302 missing_tools .append ((station .name , tool .name ))
274303 if len (missing_tools ) == 1 :
275304 station_name , tool_name = missing_tools [0 ]
276- checks .append ((MANUAL , f"{ station_name } : { tool_name } " , f"not installed; run `brigade add { station_name } `" ))
305+ checks .append (
306+ _operator_check (MANUAL , f"{ station_name } : { tool_name } " , f"not installed; run `brigade add { station_name } `" )
307+ )
277308 elif missing_tools :
278309 stations = sorted ({station for station , _ in missing_tools })
279310 checks .append (
280- (
311+ _operator_check (
281312 MANUAL ,
282313 "managed tools" ,
283314 f"{ len (missing_tools )} managed tools not installed ({ ', ' .join (stations )} ); optional, install with `brigade add <station>`" ,
284315 )
285316 )
286- checks .append (_check_receipts (ctx .target ))
317+ checks .append (_normalize_scoped_check ( _check_receipts (ctx .target ) ))
287318 return checks
288319
289320
@@ -813,9 +844,13 @@ def _check_publish_gate(target: Path) -> List[CheckResult]:
813844 scanner_dir = scrub .scanner_dir ()
814845 if scanner_dir .is_dir ():
815846 label = "external compatibility override" if os .environ .get ("CONTENT_GUARD_DIR" ) else "embedded content guard"
816- results .append ((OK , "guard: embedded content guard" , f"{ label } : { scanner_dir } " ))
847+ results .append (_operator_check (OK , "guard: embedded content guard" , f"{ label } : { scanner_dir } " ))
817848 else :
818- results .append ((MANUAL , "guard: embedded content guard" , f"not found at { scanner_dir } ; reinstall brigade-cli" ))
849+ results .append (
850+ _operator_check (
851+ MANUAL , "guard: embedded content guard" , f"not found at { scanner_dir } ; reinstall brigade-cli"
852+ )
853+ )
819854 return results
820855
821856
@@ -968,112 +1003,107 @@ def _doctor_hermes_result(item: dict) -> CheckResult:
9681003 INFO : " [info]" ,
9691004}
9701005
971- # Checks about host-global / operator state rather than the --target workspace.
972- # Hidden by default (#478); pass --operator to include them in the report.
973- _OPERATOR_SCOPED_PREFIXES = (
974- "openclaw:" ,
975- "components:" ,
976- "bootstrap-doctor" ,
977- "agentpantry" ,
978- "miseledger" ,
979- "usage-tracker" ,
980- "code-search-api" ,
981- "code-search-mcp" ,
982- "token-glace" ,
983- "agent-notify" ,
984- "plating" ,
985- )
986- _OPERATOR_SCOPED_EXACT = {"guard: embedded content guard" , "managed tools" }
987- _MANAGED_OPERATOR_TOOL_SLUGS = {
988- "bootstrap-doctor" ,
989- "token-glace" ,
990- "code-search-api" ,
991- "code-search-mcp" ,
992- "agentpantry" ,
993- "agent-notify" ,
994- "miseledger" ,
995- "usage-tracker" ,
996- "plating" ,
1006+ _SEVERITY_ORDER = (FAIL , WARN , MANUAL , INFO , OK )
1007+ _SEVERITY_GROUP_LABELS = {
1008+ FAIL : "failures:" ,
1009+ WARN : "warnings:" ,
1010+ MANUAL : "manual actions:" ,
1011+ INFO : "info:" ,
1012+ OK : "ok:" ,
9971013}
1014+ _OPERATOR_SECTION_HEADER = "operator/host (not specific to this target):"
9981015
9991016
1000- def _is_operator_scoped (name : str ) -> bool :
1001- if name in _OPERATOR_SCOPED_EXACT :
1002- return True
1003- if name .startswith (_OPERATOR_SCOPED_PREFIXES ):
1004- return True
1005- if ": " in name :
1006- _ , rhs = name .split (": " , 1 )
1007- slug = rhs .split ()[0 ]
1008- if slug in _MANAGED_OPERATOR_TOOL_SLUGS :
1009- return True
1010- return False
1011-
1012-
1013- def _filter_target_scoped_checks (checks : List [CheckResult ]) -> List [CheckResult ]:
1014- return [check for check in checks if not _is_operator_scoped (check [1 ])]
1017+ def _filter_target_scoped_checks (checks : List [ScopedCheckResult ]) -> List [ScopedCheckResult ]:
1018+ return [check for check in checks if _check_scope (check ) == SCOPE_TARGET ]
10151019
10161020
10171021def _target_detail_prefix (target : Path ) -> str :
10181022 return f"target={ target } : "
10191023
10201024
1021- def _annotate_target_detail (target : Path , name : str , detail : str ) -> str :
1022- if _is_operator_scoped (name ):
1025+ def _annotate_target_detail (target : Path , check : CheckResult | ScopedCheckResult ) -> str :
1026+ status , name , detail , scope = _normalize_scoped_check (check )
1027+ if scope == SCOPE_OPERATOR :
10231028 return detail
10241029 prefix = _target_detail_prefix (target )
10251030 if detail .startswith (prefix ) or detail .startswith (str (target )):
10261031 return detail
10271032 return f"{ prefix } { detail } "
10281033
10291034
1030- def _report (checks : List [CheckResult ], * , full : bool = True , target : Path | None = None , operator : bool = False ) -> int :
1031- width = max ((len (name ) for _ , name , _ in checks ), default = 20 )
1032- counts = _status_counts (checks )
1035+ def _report (
1036+ checks : Sequence [CheckResult | ScopedCheckResult ],
1037+ * ,
1038+ full : bool = True ,
1039+ target : Path | None = None ,
1040+ operator : bool = False ,
1041+ ) -> int :
1042+ scoped_checks = [_normalize_scoped_check (check ) for check in checks ]
1043+ width = max ((len (check [1 ]) for check in scoped_checks ), default = 20 )
1044+ counts = _status_counts (scoped_checks )
10331045 print (
1034- f"triage: { len (checks )} checks, { counts [OK ]} ok, { counts [WARN ]} warn, "
1046+ f"triage: { len (scoped_checks )} checks, { counts [OK ]} ok, { counts [WARN ]} warn, "
10351047 f"{ counts [FAIL ]} failed, { counts [MANUAL ]} manual, { counts [INFO ]} info"
10361048 )
10371049
1038- condensed = not full and len (checks ) > DEFAULT_TEXT_CHECK_LIMIT
1039- visible_checks = [check for check in checks if check [0 ] in {FAIL , WARN , MANUAL }] if condensed else checks
1040- target_checks = [check for check in visible_checks if not _is_operator_scoped (check [1 ])]
1041- operator_checks = [check for check in visible_checks if _is_operator_scoped (check [1 ])]
1050+ visible_statuses = set (_SEVERITY_ORDER ) if full else ACTIONABLE_STATUSES
1051+ visible_checks = [check for check in scoped_checks if check [0 ] in visible_statuses ]
1052+ target_checks = [check for check in visible_checks if _check_scope (check ) == SCOPE_TARGET ]
1053+ operator_checks = [check for check in visible_checks if _check_scope (check ) == SCOPE_OPERATOR ]
1054+ hidden_detail = not full and any (check [0 ] not in ACTIONABLE_STATUSES for check in scoped_checks )
10421055
1043- def _emit (items : List [CheckResult ]) -> None :
1044- for status , name , detail in items :
1056+ def _emit (items : List [ScopedCheckResult ]) -> None :
1057+ for status , name , detail , scope in items :
1058+ annotated_detail = detail
10451059 if target is not None :
1046- detail = _annotate_target_detail (target , name , detail )
1047- print (f"{ _MARKERS [status ]} { name .ljust (width )} { detail } " )
1060+ annotated_detail = _annotate_target_detail (target , (status , name , detail , scope ))
1061+ print (f"{ _MARKERS [status ]} { name .ljust (width )} { annotated_detail } " )
1062+
1063+ def _emit_grouped (items : List [ScopedCheckResult ]) -> bool :
1064+ emitted = False
1065+ for status in _SEVERITY_ORDER :
1066+ if status not in visible_statuses :
1067+ continue
1068+ group = [check for check in items if check [0 ] == status ]
1069+ if not group :
1070+ continue
1071+ emitted = True
1072+ print (_SEVERITY_GROUP_LABELS [status ])
1073+ _emit (group )
1074+ return emitted
10481075
10491076 print ()
10501077 if visible_checks :
1051- _emit (target_checks )
1078+ if not _emit_grouped (target_checks ):
1079+ print (" no failures, warnings, or manual actions" )
10521080 else :
10531081 print (" no failures, warnings, or manual actions" )
10541082 if operator and operator_checks :
10551083 print ()
1056- print ("operator/host (not specific to this target):" )
1057- _emit (operator_checks )
1084+ print (_OPERATOR_SECTION_HEADER )
1085+ _emit_grouped (operator_checks )
10581086
1059- if condensed :
1087+ if hidden_detail :
10601088 print ()
10611089 print (f"showing { len (visible_checks )} actionable checks; run `brigade doctor --full` to show all checks" )
10621090
10631091 print ()
1064- print (f"summary: { len (checks )} checks, { counts [FAIL ]} failed, { counts [MANUAL ]} manual" )
1092+ print (f"summary: { len (scoped_checks )} checks, { counts [FAIL ]} failed, { counts [MANUAL ]} manual" )
10651093 return 1 if counts [FAIL ] else 0
10661094
10671095
1068- def _status_counts (checks : List [CheckResult ]) -> dict [str , int ]:
1096+ def _status_counts (checks : List [CheckResult | ScopedCheckResult ]) -> dict [str , int ]:
10691097 counts = {OK : 0 , WARN : 0 , FAIL : 0 , MANUAL : 0 , INFO : 0 }
1070- for status , _ , _ in checks :
1098+ for check in checks :
1099+ status = check [0 ]
10711100 counts [status ] = counts .get (status , 0 ) + 1
10721101 return counts
10731102
10741103
1075- def _report_json (ctx : DoctorContext , checks : List [CheckResult ], * , operator : bool = False ) -> int :
1076- counts = _status_counts (checks )
1104+ def _report_json (ctx : DoctorContext , checks : List [CheckResult | ScopedCheckResult ], * , operator : bool = False ) -> int :
1105+ scoped_checks = [_normalize_scoped_check (check ) for check in checks ]
1106+ counts = _status_counts (scoped_checks )
10771107 sel = ctx .selection
10781108 payload = {
10791109 "target" : str (ctx .target ),
@@ -1085,13 +1115,13 @@ def _report_json(ctx: DoctorContext, checks: List[CheckResult], *, operator: boo
10851115 {
10861116 "status" : status ,
10871117 "name" : name ,
1088- "detail" : _annotate_target_detail (ctx .target , name , detail ),
1089- "scope" : "operator" if _is_operator_scoped ( name ) else "target" ,
1118+ "detail" : _annotate_target_detail (ctx .target , ( status , name , detail , scope ) ),
1119+ "scope" : scope ,
10901120 }
1091- for status , name , detail in checks
1121+ for status , name , detail , scope in scoped_checks
10921122 ],
10931123 "summary" : {
1094- "total" : len (checks ),
1124+ "total" : len (scoped_checks ),
10951125 "ok" : counts [OK ],
10961126 "warn" : counts [WARN ],
10971127 "manual" : counts [MANUAL ],
0 commit comments