@team-agent/installer 0.5.94 → 0.5.98

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.
@@ -161,6 +161,19 @@ pub(crate) fn diagnose_runtime(state: &Value, backend: &dyn Transport) -> (Value
161
161
  }
162
162
  issues.push(issue);
163
163
  }
164
+ // Preserve distinct worker failures even when the shared session probe
165
+ // short-circuits pane enumeration. This keeps scope evidence without
166
+ // issuing another transport probe per worker.
167
+ if let Some(agents) = state.get("agents").and_then(Value::as_object) {
168
+ for agent_id in agents.keys() {
169
+ let issue = format!("worker_window_missing:{agent_id}");
170
+ issues.push(json!(issue.clone()));
171
+ repairs.push(json!({
172
+ "issue": issue,
173
+ "action": "team-agent restart",
174
+ }));
175
+ }
176
+ }
164
177
  return (Value::Array(issues), Value::Array(repairs));
165
178
  }
166
179
 
@@ -289,6 +302,7 @@ pub(crate) fn diagnose_runtime_for_workspace(
289
302
  state: &Value,
290
303
  backend: &dyn Transport,
291
304
  selected_team_key: Option<&str>,
305
+ coordinator_health: &crate::coordinator::HealthReport,
292
306
  ) -> (Value, Value) {
293
307
  let (mut issues, mut repairs) = diagnose_runtime(state, backend);
294
308
  append_live_leader_workspace_mismatch_issue(
@@ -306,7 +320,7 @@ pub(crate) fn diagnose_runtime_for_workspace(
306
320
  &mut repairs,
307
321
  );
308
322
  append_legacy_snapshot_issue(workspace, state, &mut issues);
309
- append_coordinator_health_issue(workspace, state, &mut issues, &mut repairs);
323
+ append_coordinator_health_issue(workspace, state, coordinator_health, &mut issues, &mut repairs);
310
324
  append_runtime_bindings_stale_after_boot_issue(workspace, state, &mut issues, &mut repairs);
311
325
  (issues, repairs)
312
326
  }
@@ -322,8 +336,27 @@ pub(crate) fn workspace_has_existing_team_runtime(
322
336
  workspace: &std::path::Path,
323
337
  team_key: &str,
324
338
  ) -> bool {
325
- workspace.join("team.spec.yaml").is_file()
326
- || crate::model::paths::runtime_spec_path(workspace, team_key).is_file()
339
+ // A source TEAM.md/team.spec.yaml is install/configuration context, not a
340
+ // live runtime. Only persisted runtime state/specs make topology checks
341
+ // applicable; an empty workspace therefore remains a healthy no-runtime
342
+ // report instead of a fabricated missing-session issue.
343
+ if let Ok(state) = crate::state::persist::load_runtime_state_without_migrations(workspace) {
344
+ // The workspace's top-level view may describe a stopped sibling.
345
+ // Preserve the top-level fallback for legacy single-team state only
346
+ // when there is no selected team entry.
347
+ let team_state = state
348
+ .get("teams")
349
+ .and_then(|teams| teams.get(team_key))
350
+ .unwrap_or(&state);
351
+ let terminal = team_state
352
+ .get("status")
353
+ .and_then(Value::as_str)
354
+ .is_some_and(|status| matches!(status, "stopped" | "shutdown" | "archived" | "terminal"));
355
+ if terminal {
356
+ return false;
357
+ }
358
+ }
359
+ crate::model::paths::runtime_spec_path(workspace, team_key).is_file()
327
360
  || crate::state::persist::runtime_state_path(workspace).is_file()
328
361
  }
329
362
 
@@ -790,21 +823,18 @@ fn append_legacy_snapshot_issue(workspace: &std::path::Path, state: &Value, issu
790
823
  fn append_coordinator_health_issue(
791
824
  workspace: &std::path::Path,
792
825
  state: &Value,
826
+ health: &crate::coordinator::HealthReport,
793
827
  issues: &mut Value,
794
828
  repairs: &mut Value,
795
829
  ) {
796
- // Diagnose must observe coordinator state without initializing or rewriting
797
- // `.team/runtime/team.db`, including when the file is missing or empty.
798
- let workspace = crate::coordinator::WorkspacePath::new(workspace.to_path_buf());
799
- let health = crate::coordinator::coordinator_health_read_only(&workspace);
800
- let Some(id) = coordinator_issue_id(state, &health) else {
830
+ let Some(id) = coordinator_issue_id(state, health) else {
801
831
  return;
802
832
  };
803
833
  if let Some(items) = issues.as_array_mut() {
804
- items.push(coordinator_issue_value(id, &health, workspace.as_path()));
834
+ items.push(coordinator_issue_value(id, health, workspace));
805
835
  }
806
836
  if let Some(items) = repairs.as_array_mut() {
807
- items.push(coordinator_repair_hint(id, &health));
837
+ items.push(coordinator_repair_hint(id, health));
808
838
  }
809
839
  }
810
840
 
@@ -174,7 +174,6 @@ fn dispatch(command: &str, args: &[String], cwd: &Path) -> Result<ExitCode, CliE
174
174
  match command {
175
175
  "init" => cmd_init(&init_args(args, cwd)).map(emit_result),
176
176
  "quick-start" => cmd_quick_start(&quick_start_args(args, cwd)?).map(emit_result),
177
- "compile" => cmd_compile(&compile_args(args, cwd)?).map(emit_result),
178
177
  "send" => cmd_send(&send_args(args, cwd)?).map(emit_result),
179
178
  "allow-peer-talk" => {
180
179
  cmd_allow_peer_talk(&allow_peer_talk_args(args, cwd)?).map(emit_result)
@@ -225,7 +224,8 @@ fn dispatch(command: &str, args: &[String], cwd: &Path) -> Result<ExitCode, CliE
225
224
  "profile" => cmd_profile(&profile_args(args, cwd)?).map(emit_result),
226
225
  "results" => cmd_results(&results_args(args, cwd)?).map(emit_result),
227
226
  "wait" => cmd_wait(&wait_args(args, cwd)?).map(emit_result),
228
- "diagnose" => cmd_diagnose(&diagnose_args(args, cwd)).map(emit_result),
227
+ // Compatibility spelling: parse and execute the exact doctor contract.
228
+ "diagnose" => cmd_doctor(&doctor_args(args, cwd)).map(emit_result),
229
229
  "preflight" => cmd_preflight(&preflight_args(args, cwd)).map(emit_result),
230
230
  "wait-ready" => cmd_wait_ready(&wait_ready_args(args, cwd)).map(emit_result),
231
231
  "e2e" => cmd_e2e(&e2e_args(args, cwd)).map(emit_result),
@@ -238,7 +238,6 @@ fn dispatch(command: &str, args: &[String], cwd: &Path) -> Result<ExitCode, CliE
238
238
  const DISPATCH_COMMANDS: &[&str] = &[
239
239
  "init",
240
240
  "quick-start",
241
- "compile",
242
241
  "send",
243
242
  "allow-peer-talk",
244
243
  "status",
@@ -340,7 +339,7 @@ pub(crate) fn default_help() -> String {
340
339
  "reset-agent",
341
340
  ],
342
341
  );
343
- append_help_section(&mut out, "Diagnose", &["diagnose"]);
342
+ append_help_section(&mut out, "Diagnose", &["doctor"]);
344
343
  append_help_section(
345
344
  &mut out,
346
345
  "Guided recovery",
@@ -400,7 +399,6 @@ fn command_help(command: Option<&str>) -> String {
400
399
  Some("init") => compat_hidden_help("init", "usage: team-agent init [--workspace WORKSPACE] [--force] [--json]"),
401
400
  Some("quick-start") => "usage: team-agent quick-start [TEAMDIR] [--workspace WORKSPACE] [--name NAME] [--team-id TEAM|--team TEAM] [--yes] [--no-display] [--backend tmux|conpty] [--json] [--detail]\n\ndefaults: display_backend=adaptive; set display_backend: none in TEAM.md or pass --no-display to use one worker window per agent.\n\n--backend selects the worker transport (Phase 1d Batch 2): tmux (default on POSIX; unchanged behavior), conpty (Windows-native ConPTY worker transport; requires the shim binary and Windows host).\n\n--detail includes internal receiver/topology diagnostics in JSON output.\n\nAfter a successful start, use the returned `send_commands` (or choose an agent explicitly) with `team-agent send AGENT MESSAGE`.".to_string(),
402
401
  Some("start") => compat_hidden_help("start", "usage: team-agent start [TEAMDIR] [--yes] [--fresh] [--json]"),
403
- Some("compile") => "usage: team-agent compile --team TEAM [--out FILE] [--json]".to_string(),
404
402
  Some("send") => concat!(
405
403
  "usage: team-agent send TO MESSAGE... ",
406
404
  "[--workspace WORKSPACE] [--team TEAM] ",
@@ -444,7 +442,7 @@ fn command_help(command: Option<&str>) -> String {
444
442
  Some("profile") => "usage: team-agent profile COMMAND NAME [--workspace WORKSPACE] [--team TEAM] [--auth-mode MODE] [--proxy-mode direct|inherit] [--json]".to_string(),
445
443
  Some("results") => "usage: team-agent results --case CASE_ID [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
446
444
  Some("wait") => "usage: team-agent wait --task TASK [--workspace WORKSPACE] [--json]".to_string(),
447
- Some("diagnose") => "usage: team-agent diagnose [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
445
+ Some("diagnose") => "usage: team-agent doctor [SPEC] [--workspace WORKSPACE] [--team TEAM] [--gate orphans|comms] [--comms] [--fix] [--fix-schema] [--cleanup-orphans] [--confirm] [--json]".to_string(),
448
446
  Some("preflight") => "usage: team-agent preflight [TEAMDIR] [--json]".to_string(),
449
447
  Some("wait-ready") => "usage: team-agent wait-ready [--workspace WORKSPACE] [--team TEAM] [--timeout SECONDS] [--json]".to_string(),
450
448
  Some("e2e") => "usage: team-agent e2e [--workspace WORKSPACE] [--providers LIST] [--real] [--json]".to_string(),
@@ -852,7 +850,6 @@ struct ParsedArgs {
852
850
  result: Option<String>,
853
851
  real: bool,
854
852
  assignee: Option<String>,
855
- out: Option<PathBuf>,
856
853
  auth_mode: Option<String>,
857
854
  proxy_mode: Option<String>,
858
855
  pane: Option<String>,
@@ -949,7 +946,6 @@ fn parse_args(args: &[String]) -> ParsedArgs {
949
946
  "--result" => parsed.result = next_arg(args, &mut i),
950
947
  "--real" => parsed.real = true,
951
948
  "--assignee" => parsed.assignee = next_arg(args, &mut i),
952
- "--out" => parsed.out = next_arg(args, &mut i).map(PathBuf::from),
953
949
  "--auth-mode" => parsed.auth_mode = next_arg(args, &mut i),
954
950
  "--proxy-mode" => parsed.proxy_mode = next_arg(args, &mut i),
955
951
  "--pane" => parsed.pane = next_arg(args, &mut i),
@@ -1144,23 +1140,6 @@ fn init_args(args: &[String], cwd: &Path) -> InitArgs {
1144
1140
  }
1145
1141
  }
1146
1142
 
1147
- fn compile_args(args: &[String], cwd: &Path) -> Result<CompileArgs, CliError> {
1148
- let parsed = parse_args(args);
1149
- let team = parsed
1150
- .team
1151
- .as_deref()
1152
- .map(PathBuf::from)
1153
- .ok_or_else(|| CliError::Usage("missing --team".to_string()))?;
1154
- let out = parsed
1155
- .out
1156
- .unwrap_or_else(|| PathBuf::from("team.spec.yaml"));
1157
- Ok(CompileArgs {
1158
- team: resolve_cli_path(cwd, &team),
1159
- out: resolve_cli_path(cwd, &out),
1160
- json: parsed.json,
1161
- })
1162
- }
1163
-
1164
1143
  fn resolve_cli_path(cwd: &Path, path: &Path) -> PathBuf {
1165
1144
  if path.is_absolute() {
1166
1145
  path.to_path_buf()
@@ -2172,7 +2151,8 @@ mod tests {
2172
2151
  fn hidden_commands_not_in_default_help() {
2173
2152
  let top_help = command_help(None);
2174
2153
  let visible = visible_help_commands(&top_help);
2175
- for command in ["leaders", "doctor", "e2e", "peek", "coordinator"] {
2154
+ assert!(visible.iter().any(|command| command == "doctor"));
2155
+ for command in ["leaders", "diagnose", "e2e", "peek", "coordinator"] {
2176
2156
  assert!(
2177
2157
  !visible.iter().any(|visible| visible == command),
2178
2158
  "`{command}` must stay hidden from default help"
@@ -516,10 +516,11 @@ pub mod lifecycle_port {
516
516
  /// (经同一帧 list_targets pane→session)。state 无任何锚 → 退命名判据 + spare_fallback event。
517
517
  fn anchor_sessions_from_state(
518
518
  state: &Value,
519
+ endpoint: Option<&str>,
519
520
  pane_targets: &[crate::transport::PaneInfo],
520
521
  event_log: &crate::event_log::EventLog,
521
522
  ) -> std::collections::BTreeSet<String> {
522
- let anchor_pane_ids = collect_state_leader_anchor_pane_ids(state);
523
+ let anchor_pane_ids = collect_state_leader_anchor_pane_ids(state, endpoint);
523
524
  if anchor_pane_ids.is_empty() {
524
525
  // 无锚(state 损坏/未记)→ 退纯命名前缀判据(下游 sessions_to_kill 仍 spare 前缀)。
525
526
  let _ = event_log.write(
@@ -649,7 +650,12 @@ pub mod lifecycle_port {
649
650
  }
650
651
  }
651
652
  };
652
- let protected_sessions = anchor_sessions_from_state(state, &targets, event_log);
653
+ let protected_sessions = anchor_sessions_from_state(
654
+ state,
655
+ transport.tmux_endpoint().as_deref(),
656
+ &targets,
657
+ event_log,
658
+ );
653
659
  let mut result = ShutdownSocketCleanup::default();
654
660
  for session in candidates {
655
661
  if protected_sessions.contains(session.as_str()) {
@@ -791,6 +797,43 @@ pub mod lifecycle_port {
791
797
  use crate::transport::test_support::OfflineTransport;
792
798
  use crate::transport::{PaneId, PaneInfo, SessionName};
793
799
 
800
+ #[test]
801
+ fn leader_process_protection_does_not_alias_panes_across_endpoints() {
802
+ let state = json!({
803
+ "team_owner": {"pane_id": "%2"},
804
+ "leader_receiver": {"pane_id": "%2", "tmux_socket": "/tmp/s4-host-only.sock"}
805
+ });
806
+ let table = [(10, 1), (20, 10), (30, 10), (31, 30)]
807
+ .into_iter()
808
+ .map(|(pid, ppid)| ProcessInfo { pid, ppid, pgid: None, session: None, command: String::new() })
809
+ .collect::<Vec<_>>();
810
+ for (endpoint, pane_pid, should_protect) in [
811
+ ("/tmp/s4-worker-only.sock", 20, false),
812
+ ("/tmp/s4-host-only.sock", 30, true),
813
+ ] {
814
+ let transport = OfflineTransport::new()
815
+ .with_tmux_endpoint(endpoint)
816
+ .with_targets(vec![PaneInfo {
817
+ pane_id: PaneId::new("%2"),
818
+ session: SessionName::new("ordinary-session"),
819
+ window_index: None,
820
+ window_name: None,
821
+ pane_index: None,
822
+ tty: None,
823
+ current_command: None,
824
+ current_path: None,
825
+ active: true,
826
+ pane_pid: Some(pane_pid),
827
+ leader_env: BTreeMap::new(),
828
+ }]);
829
+ let mut protected = ShutdownProtection::default();
830
+ extend_protection_with_leader_panes(&mut protected, &transport, &state, &table);
831
+ assert_eq!(protected.pids.contains(&pane_pid), should_protect);
832
+ assert_eq!(protected.pids.contains(&31), should_protect, "leader descendants");
833
+ assert_eq!(protected.pids.contains(&10), should_protect, "leader's server");
834
+ }
835
+ }
836
+
794
837
  #[test]
795
838
  fn audit_write_failure_skips_destructive_kill() {
796
839
  let root =
@@ -1042,7 +1085,10 @@ pub mod lifecycle_port {
1042
1085
  // External and managed leaders both pass the same positive ownership
1043
1086
  // gate; topology only decides whether a verified session is reduced
1044
1087
  // to worker panes or removed as a whole.
1045
- let leader_anchor_ids = collect_state_leader_anchor_pane_ids(&state);
1088
+ let leader_anchor_ids = collect_state_leader_anchor_pane_ids(
1089
+ &state,
1090
+ transport.tmux_endpoint().as_deref(),
1091
+ );
1046
1092
  let (live_targets_now, live_targets_known) = match transport.list_targets() {
1047
1093
  Ok(targets) => (targets, true),
1048
1094
  Err(error) => {
@@ -2059,12 +2105,13 @@ pub mod lifecycle_port {
2059
2105
  /// → 任一 team 的 shutdown 都不杀任何 team 的 leader 锚 pane
2060
2106
  pub fn collect_state_leader_anchor_pane_ids(
2061
2107
  state: &Value,
2108
+ endpoint: Option<&str>,
2062
2109
  ) -> std::collections::BTreeSet<String> {
2063
2110
  let mut out = std::collections::BTreeSet::new();
2064
- push_anchor_pane_id(state, &mut out);
2111
+ push_anchor_pane_id(state, endpoint, &mut out);
2065
2112
  if let Some(teams) = state.get("teams").and_then(Value::as_object) {
2066
2113
  for (_, team_state) in teams {
2067
- push_anchor_pane_id(team_state, &mut out);
2114
+ push_anchor_pane_id(team_state, endpoint, &mut out);
2068
2115
  }
2069
2116
  }
2070
2117
  out
@@ -2184,17 +2231,40 @@ pub mod lifecycle_port {
2184
2231
  Ok(())
2185
2232
  }
2186
2233
 
2187
- /// 单帧扫 team_owner.pane_id + leader_receiver.pane_id → BTreeSet 累加。
2188
- fn push_anchor_pane_id(state: &Value, out: &mut std::collections::BTreeSet<String>) {
2189
- for key in &["team_owner", "leader_receiver"] {
2190
- if let Some(pane_id) = state
2191
- .get(*key)
2192
- .and_then(|v| v.get("pane_id"))
2193
- .and_then(Value::as_str)
2234
+ /// Pane IDs are server-local. Exclude proven foreign-endpoint anchors, but
2235
+ /// retain legacy anchors when their endpoint (or the transport's) is unknown.
2236
+ fn push_anchor_pane_id(
2237
+ state: &Value,
2238
+ endpoint: Option<&str>,
2239
+ out: &mut std::collections::BTreeSet<String>,
2240
+ ) {
2241
+ for (key, peer_key) in [("team_owner", "leader_receiver"), ("leader_receiver", "team_owner")] {
2242
+ let Some(anchor) = state.get(key) else { continue };
2243
+ let Some(pane_id) = anchor.get("pane_id").and_then(Value::as_str).filter(|s| !s.is_empty()) else {
2244
+ continue;
2245
+ };
2246
+ // team_owner often records only a pane ID; its receiver supplies
2247
+ // the endpoint only when it describes that exact same anchor.
2248
+ let anchor_endpoint = anchor.get("tmux_socket").and_then(Value::as_str)
2194
2249
  .filter(|s| !s.is_empty())
2195
- {
2196
- out.insert(pane_id.to_string());
2250
+ .or_else(|| {
2251
+ state.get(peer_key)
2252
+ .filter(|peer| peer.get("pane_id").and_then(Value::as_str) == Some(pane_id))
2253
+ .and_then(|peer| peer.get("tmux_socket"))
2254
+ .and_then(Value::as_str)
2255
+ .filter(|s| !s.is_empty())
2256
+ });
2257
+ if let (Some(anchor_path), Some(transport_path)) = (
2258
+ anchor_endpoint.and_then(socket_file_for_endpoint),
2259
+ endpoint.filter(|s| !s.is_empty()).and_then(socket_file_for_endpoint),
2260
+ ) {
2261
+ let anchor_path = anchor_path.canonicalize().unwrap_or(anchor_path);
2262
+ let transport_path = transport_path.canonicalize().unwrap_or(transport_path);
2263
+ if anchor_path != transport_path {
2264
+ continue;
2265
+ }
2197
2266
  }
2267
+ out.insert(pane_id.to_string());
2198
2268
  }
2199
2269
  }
2200
2270
 
@@ -2305,8 +2375,10 @@ pub mod lifecycle_port {
2305
2375
  );
2306
2376
  // Source 2: state.json team_owner / leader_receiver 真锚 pane_id(top-level +
2307
2377
  // teams[*]),per-workspace socket 命中。
2308
- let anchor_pane_ids: std::collections::BTreeSet<String> =
2309
- collect_state_leader_anchor_pane_ids(state);
2378
+ let anchor_pane_ids = collect_state_leader_anchor_pane_ids(
2379
+ state,
2380
+ transport.tmux_endpoint().as_deref(),
2381
+ );
2310
2382
  leader_pane_pids.extend(
2311
2383
  pane_targets
2312
2384
  .iter()
@@ -2322,6 +2394,7 @@ pub mod lifecycle_port {
2322
2394
  // 的 list_targets 找 anchor pane_id → pane_pid → 进入 process_tree 保护。
2323
2395
  // 不在 state 中的 socket 不查(MUST-17 不撒宽 / 不主动枚举全机器 sockets)。
2324
2396
  for socket_endpoint in collect_state_recorded_tmux_sockets(state) {
2397
+ let anchor_pane_ids = collect_state_leader_anchor_pane_ids(state, Some(&socket_endpoint));
2325
2398
  let cross_backend =
2326
2399
  crate::tmux_backend::TmuxBackend::for_tmux_endpoint(&socket_endpoint);
2327
2400
  let cross_panes =
@@ -4752,17 +4825,15 @@ pub mod diagnose_port {
4752
4825
  && profile_smoke_value.get("status").and_then(Value::as_str)
4753
4826
  == Some("legacy_team_invalid");
4754
4827
  let effective_smoke_ok = profile_smoke_ok || legacy_only_failure;
4828
+ // Coordinator and runtime topology are facts of the unified pipeline,
4829
+ // not of the static environment probe. The caller supplies the
4830
+ // selected runtime workspace and performs one read-only health read.
4755
4831
  let ok = workspace_valid && (team_context || workspace_has_entries) && effective_smoke_ok;
4756
- let health = crate::coordinator::coordinator_health(
4757
- &crate::coordinator::WorkspacePath::new(workspace.to_path_buf()),
4758
- );
4759
- let coordinator_ok = health.ok;
4760
- let coordinator_error = health.metadata_mismatch_reason.clone();
4761
4832
  let state = crate::state::persist::load_runtime_state(workspace).unwrap_or(Value::Null);
4762
4833
  let grok_slot = crate::cli::grok_slot_report(workspace, &state).to_json();
4763
4834
  let grok_slot_ok = grok_slot.get("consistent").and_then(Value::as_bool) == Some(true)
4764
4835
  && grok_slot.get("readable").and_then(Value::as_bool) == Some(true);
4765
- let ok = ok && grok_slot_ok && coordinator_ok;
4836
+ let ok = ok && grok_slot_ok;
4766
4837
  Ok(json!({
4767
4838
  "tmux": {
4768
4839
  "installed": tmux_installed,
@@ -4770,14 +4841,14 @@ pub mod diagnose_port {
4770
4841
  },
4771
4842
  "workspace": workspace.to_string_lossy().to_string(),
4772
4843
  "workspace_is_git_repo": workspace.join(".git").exists(),
4773
- "providers": {},
4844
+ "providers": crate::cli::diagnose::provider_doctor_checks(),
4774
4845
  "mcp": {
4775
4846
  "server_command": which_path("team_orchestrator"),
4776
4847
  "local_module": true,
4777
4848
  },
4778
4849
  "secret_scan": secret_scan(workspace),
4779
4850
  "profile_smoke": profile_smoke_value,
4780
- "coordinator": coordinator_health_value(health),
4851
+ "coordinator": coordinator_not_required_value(),
4781
4852
  "grok_slot": grok_slot,
4782
4853
  "ok": ok,
4783
4854
  "error": if ok {
@@ -4789,10 +4860,6 @@ pub mod diagnose_port {
4789
4860
  .unwrap_or_else(|| json!("grok_slot_mismatch"))
4790
4861
  } else if !profile_smoke_ok && !legacy_only_failure {
4791
4862
  json!("profile_smoke_failed")
4792
- } else if !coordinator_ok {
4793
- coordinator_error
4794
- .map(Value::String)
4795
- .unwrap_or_else(|| json!("coordinator_unavailable"))
4796
4863
  } else if workspace_valid {
4797
4864
  json!("workspace has no Team Agent spec or runtime context")
4798
4865
  } else {
@@ -4984,11 +5051,12 @@ pub mod diagnose_port {
4984
5051
  continue;
4985
5052
  };
4986
5053
  let rel = path.strip_prefix(root).unwrap_or(path);
5054
+ // Keep only a locating rule/path/line tuple. Never include even
5055
+ // redacted assignment text in the structured report.
4987
5056
  findings.push(json!({
4988
5057
  "path": rel.to_string_lossy().to_string(),
4989
5058
  "line": idx.saturating_add(1),
4990
5059
  "rule": "api_key_assignment",
4991
- "match_excerpt": format!("{key}=<redacted>"),
4992
5060
  }));
4993
5061
  }
4994
5062
  }
@@ -5153,7 +5221,23 @@ pub mod diagnose_port {
5153
5221
  })
5154
5222
  }
5155
5223
 
5156
- fn coordinator_health_value(health: crate::coordinator::HealthReport) -> Value {
5224
+ pub(crate) fn coordinator_not_required_value() -> Value {
5225
+ json!({
5226
+ "ok": true,
5227
+ "status": "not_required",
5228
+ "pid": Value::Null,
5229
+ "metadata": Value::Null,
5230
+ "metadata_ok": true,
5231
+ "metadata_mismatch_reason": Value::Null,
5232
+ "binary_path": Value::Null,
5233
+ "binary_version": Value::Null,
5234
+ "schema_ok": true,
5235
+ "schema_error": Value::Null,
5236
+ "schema": {"message_store_schema_version": Value::Null},
5237
+ })
5238
+ }
5239
+
5240
+ pub(crate) fn coordinator_health_value(health: crate::coordinator::HealthReport) -> Value {
5157
5241
  let expose_binary_drift = health.service_available && !health.metadata_ok;
5158
5242
  let binary_identity_relation = health.binary_identity_relation.as_str();
5159
5243
  let mut value = json!({
@@ -44,7 +44,6 @@ pub(crate) enum TokenUsage {
44
44
  pub(crate) enum DispatchKind {
45
45
  Init,
46
46
  QuickStart,
47
- Compile,
48
47
  Send,
49
48
  AllowPeerTalk,
50
49
  Status,
@@ -90,7 +89,6 @@ pub(crate) enum DispatchKind {
90
89
  pub(crate) const ALL_DISPATCH_KINDS: &[DispatchKind] = &[
91
90
  DispatchKind::Init,
92
91
  DispatchKind::QuickStart,
93
- DispatchKind::Compile,
94
92
  DispatchKind::Send,
95
93
  DispatchKind::AllowPeerTalk,
96
94
  DispatchKind::Status,
@@ -176,7 +174,7 @@ pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
176
174
  CommandSpec { name: "start-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::StartAgent), summary: "start an existing worker", usage: "usage: team-agent start-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--force] [--allow-fresh] [--no-display] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
177
175
  CommandSpec { name: "stop-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::StopAgent), summary: "stop a worker", usage: "usage: team-agent stop-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
178
176
  CommandSpec { name: "reset-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::ResetAgent), summary: "reset a worker session", usage: "usage: team-agent reset-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--discard-session] [--no-display] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
179
- CommandSpec { name: "diagnose", tier: CommandTier::Core, category: CommandCategory::Observe, kind: CommandKind::Dispatch(DispatchKind::Diagnose), summary: "inspect this workspace runtime and suggested repairs", usage: "usage: team-agent diagnose [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
177
+ CommandSpec { name: "diagnose", tier: CommandTier::CompatHidden, category: CommandCategory::Compat, kind: CommandKind::Dispatch(DispatchKind::Diagnose), summary: "compatibility alias for doctor", usage: "usage: team-agent doctor [SPEC] [--workspace WORKSPACE] [--team TEAM] [--gate orphans|comms] [--comms] [--fix] [--fix-schema] [--cleanup-orphans] [--confirm] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: Some("doctor"), sunset: None, action: None, governance: None },
180
178
  CommandSpec { name: "claim-leader", tier: CommandTier::Guided, category: CommandCategory::GuidedRecovery, kind: CommandKind::Dispatch(DispatchKind::ClaimLeader), summary: "bind this pane when diagnose reports rebind_required", usage: "usage: team-agent claim-leader [--workspace WORKSPACE] [--team TEAM] [--confirm] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
181
179
  CommandSpec { name: "takeover", tier: CommandTier::Guided, category: CommandCategory::GuidedRecovery, kind: CommandKind::Dispatch(DispatchKind::Takeover), summary: "replace a live leader binding with explicit consent", usage: "usage: team-agent takeover [--workspace WORKSPACE] [--team TEAM] [--confirm] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
182
180
  CommandSpec { name: "attach-leader", tier: CommandTier::Guided, category: CommandCategory::GuidedRecovery, kind: CommandKind::Dispatch(DispatchKind::AttachLeader), summary: "bind an external leader pane", usage: "usage: team-agent attach-leader [--workspace WORKSPACE] [--team TEAM] [--pane PANE] [--provider PROVIDER] [--confirm] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
@@ -196,7 +194,7 @@ pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
196
194
  CommandSpec { name: "stuck-cancel", tier: CommandTier::CompatHidden, category: CommandCategory::Compat, kind: CommandKind::Dispatch(DispatchKind::StuckCancel), summary: "legacy stuck alert suppression", usage: "usage: team-agent stuck-cancel AGENT [--workspace WORKSPACE] [--alert-type stuck|idle_fallback|cross_worker_deadlock|all] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: Some("C2"), action: Some("use the concrete notification action or `team-agent diagnose`"), governance: None },
197
195
  CommandSpec { name: "acknowledge-idle", tier: CommandTier::CompatHidden, category: CommandCategory::Compat, kind: CommandKind::Dispatch(DispatchKind::AcknowledgeIdle), summary: "legacy idle acknowledgement", usage: "usage: team-agent acknowledge-idle [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: Some("C2"), action: Some("use the concrete idle notification action or `team-agent diagnose`"), governance: None },
198
196
  CommandSpec { name: "leaders", tier: CommandTier::Secondary, category: CommandCategory::Observe, kind: CommandKind::Dispatch(DispatchKind::Leaders), summary: "list live, stale, or ambiguous host leaders", usage: "usage: team-agent leaders [QUERY|--search TEXT] [--all|--stale] [--json] | --prune [--dry-run] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
199
- CommandSpec { name: "doctor", tier: CommandTier::Secondary, category: CommandCategory::Setup, kind: CommandKind::Dispatch(DispatchKind::Doctor), summary: "run host maintenance checks", usage: "usage: team-agent doctor [SPEC] [--workspace WORKSPACE] [--team TEAM] [--gate orphans|comms] [--comms] [--fix] [--fix-schema] [--cleanup-orphans] [--confirm] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
197
+ CommandSpec { name: "doctor", tier: CommandTier::Core, category: CommandCategory::Observe, kind: CommandKind::Dispatch(DispatchKind::Doctor), summary: "inspect environment and Team runtime health", usage: "usage: team-agent doctor [SPEC] [--workspace WORKSPACE] [--team TEAM] [--gate orphans|comms] [--comms] [--fix] [--fix-schema] [--cleanup-orphans] [--confirm] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
200
198
  CommandSpec { name: "attach-app-server-leader", tier: CommandTier::Guided, category: CommandCategory::GuidedRecovery, kind: CommandKind::Dispatch(DispatchKind::AttachAppServerLeader), summary: "bind an app-server leader receiver", usage: "usage: team-agent attach-app-server-leader [--workspace WORKSPACE] [--team TEAM] --socket unix:///path.sock --thread-id THREAD_ID [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
201
199
  CommandSpec { name: "remove-agent", tier: CommandTier::Secondary, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::RemoveAgent), summary: "remove a worker", usage: "usage: team-agent remove-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--from-spec] [--confirm] [--force] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
202
200
  CommandSpec { name: "fork-agent", tier: CommandTier::Secondary, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::ForkAgent), summary: "fork a worker", usage: "usage: team-agent fork-agent SOURCE_AGENT --as AGENT [--label LABEL] [--workspace WORKSPACE] [--team TEAM] [--no-display] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
@@ -210,7 +208,6 @@ pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
210
208
  CommandSpec { name: "identity", tier: CommandTier::DevInternal, category: CommandCategory::Dev, kind: CommandKind::Dispatch(DispatchKind::Identity), summary: "inspect identity metadata", usage: "usage: team-agent identity [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
211
209
  CommandSpec { name: "watch", tier: CommandTier::DevInternal, category: CommandCategory::Dev, kind: CommandKind::Dispatch(DispatchKind::Watch), summary: "watch event logs", usage: "usage: team-agent watch [--workspace WORKSPACE] [--team TEAM]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
212
210
  CommandSpec { name: "sessions", tier: CommandTier::DevInternal, category: CommandCategory::Dev, kind: CommandKind::Dispatch(DispatchKind::Sessions), summary: "inspect session inventory", usage: "usage: team-agent sessions [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
213
- CommandSpec { name: "compile", tier: CommandTier::DevInternal, category: CommandCategory::Dev, kind: CommandKind::Dispatch(DispatchKind::Compile), summary: "compile a team spec", usage: "usage: team-agent compile --team TEAM [--out FILE] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
214
211
  CommandSpec { name: "validate", tier: CommandTier::DevInternal, category: CommandCategory::Dev, kind: CommandKind::Dispatch(DispatchKind::Validate), summary: "validate a team spec", usage: "usage: team-agent validate [SPEC] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
215
212
  CommandSpec { name: "preflight", tier: CommandTier::DevInternal, category: CommandCategory::Dev, kind: CommandKind::Dispatch(DispatchKind::Preflight), summary: "run preflight checks", usage: "usage: team-agent preflight [TEAMDIR] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
216
213
  CommandSpec { name: "wait-ready", tier: CommandTier::DevInternal, category: CommandCategory::Dev, kind: CommandKind::Dispatch(DispatchKind::WaitReady), summary: "wait for runtime readiness", usage: "usage: team-agent wait-ready [--workspace WORKSPACE] [--team TEAM] [--timeout SECONDS] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
@@ -671,13 +671,12 @@ fn exit_code_numeric() {
671
671
  }
672
672
 
673
673
  // =========================================================================
674
- // cmd_doctor 分派(commands.py:218-260):--fix 缺 gate -> Usage err
674
+ // cmd_doctor: --fix without --gate is a structured failure in both renderers.
675
675
  // =========================================================================
676
676
 
677
677
  #[test]
678
- fn cmd_doctor_fix_without_gate_is_usage_error() {
679
- // commands.py:220-221: --fix and not gate -> TeamAgentError("--fix requires --gate")
680
- let args = DoctorArgs {
678
+ fn cmd_doctor_fix_without_gate_reports_usage_failure() {
679
+ let mut args = DoctorArgs {
681
680
  spec: None,
682
681
  workspace: PathBuf::from("."),
683
682
  gate: None,
@@ -689,12 +688,23 @@ fn cmd_doctor_fix_without_gate_is_usage_error() {
689
688
  confirm: false,
690
689
  json: false,
691
690
  };
692
- let err = cmd_doctor(&args).unwrap_err();
693
- let msg = err.to_string();
694
- assert!(
695
- msg.contains("--fix requires --gate"),
696
- "expected '--fix requires --gate', got: {msg}"
697
- );
691
+ let human = cmd_doctor(&args).expect("structured usage failure");
692
+ assert_eq!(human.exit, ExitCode::Error);
693
+ let CmdOutput::Human(text) = human.output else {
694
+ panic!("expected human triage");
695
+ };
696
+ assert!(text.contains("fix_requires_gate"), "{text}");
697
+ assert!(text.contains("add --gate orphans|comms"), "{text}");
698
+
699
+ args.json = true;
700
+ let result = cmd_doctor(&args).expect("structured JSON usage failure");
701
+ assert_eq!(result.exit, ExitCode::Error);
702
+ let CmdOutput::Json(value) = result.output else {
703
+ panic!("expected JSON usage failure");
704
+ };
705
+ assert_eq!(value["ok"], false);
706
+ assert_eq!(value["error"], "--fix requires --gate");
707
+ assert_eq!(value["issues"], json!(["fix_requires_gate"]));
698
708
  }
699
709
 
700
710
  // =========================================================================
@@ -457,14 +457,10 @@ fn red_send_target_none_is_not_broadcast() {
457
457
  assert_eq!(send_target(None, Some("*")), MessageTarget::Broadcast);
458
458
  }
459
459
 
460
- // ---- #23: cmd_doctor comms (human) returns COMMS_BOUNDARY_TEXT + sorted indented JSON ----
461
- // golden: for --comms WITHOUT --json, cmd_doctor returns the STRING
462
- // f"{COMMS_BOUNDARY_TEXT}\n{json.dumps(result, indent=2, ensure_ascii=False, sort_keys=True)}".
463
- // Rust always does CmdResult::from_json -> CmdOutput::Json (wrong shape).
460
+ // Unified doctor human output is triage only, including the comms gate.
464
461
  #[test]
465
- fn red_cmd_doctor_comms_human_is_boundary_text_plus_sorted_json() {
466
- const COMMS_BOUNDARY_TEXT: &str = "validates live pane binding consistency and zero-token comms contracts. Does NOT perform live runtime message round-trip. (zero token, zero pollution)";
467
- let args = DoctorArgs {
462
+ fn cmd_doctor_comms_human_is_compact_triage_without_boundary_prefix() {
463
+ let mut args = DoctorArgs {
468
464
  spec: None,
469
465
  workspace: PathBuf::from("."),
470
466
  gate: None,
@@ -479,21 +475,16 @@ fn red_cmd_doctor_comms_human_is_boundary_text_plus_sorted_json() {
479
475
  let result = cmd_doctor(&args).expect("comms doctor returns CmdResult");
480
476
  let text = match result.output {
481
477
  CmdOutput::Human(s) => s,
482
- other => panic!(
483
- "comms WITHOUT --json MUST be a Human boundary-text + JSON string, got {other:?}"
484
- ),
478
+ other => panic!("comms WITHOUT --json must be human triage, got {other:?}"),
485
479
  };
486
- assert!(
487
- text.starts_with(&format!("{COMMS_BOUNDARY_TEXT}\n")),
488
- "comms human output MUST start with COMMS_BOUNDARY_TEXT then a newline (golden commands.py:231); got: {text:?}"
489
- );
490
- // the tail is the selftest result rendered as sort_keys+indent=2 JSON (parseable, sorted).
491
- let json_tail = text
492
- .strip_prefix(&format!("{COMMS_BOUNDARY_TEXT}\n"))
493
- .unwrap();
494
- let parsed: Value = serde_json::from_str(json_tail)
495
- .expect("comms human tail MUST be indent=2 sort_keys JSON of the selftest result");
496
- assert!(parsed.is_object(), "comms selftest JSON tail is an object");
480
+ args.json = true;
481
+ let CmdOutput::Json(report) = cmd_doctor(&args).unwrap().output else {
482
+ panic!("--json must preserve the structured report");
483
+ };
484
+ assert_eq!(text, crate::cli::triage::render("doctor", &report));
485
+ assert!(text.starts_with("doctor:"));
486
+ assert_eq!(text.lines().count(), 1 + report["issues"].as_array().unwrap().len()
487
+ + report["suggested_repairs"].as_array().unwrap().len());
497
488
  }
498
489
 
499
490
  // ---- #13 / #27 (P2): run() must NOT treat 'claude_code' as a passthrough trigger ----
@@ -368,12 +368,17 @@ fn ux_doctor_secret_scan_findings_name_the_exact_trigger() {
368
368
  .pointer("/secret_scan/findings/0")
369
369
  .and_then(serde_json::Value::as_object)
370
370
  .expect("secret-scan must report the concrete trigger");
371
- for key in ["path", "line", "rule", "match_excerpt"] {
371
+ for key in ["path", "line", "rule"] {
372
372
  assert!(
373
373
  finding.contains_key(key),
374
374
  "secret-scan finding missing `{key}`: {finding:?}"
375
375
  );
376
376
  }
377
+ assert_eq!(finding["path"], "leaky-role.md");
378
+ assert_eq!(finding["line"], 1);
379
+ assert_eq!(finding["rule"], "api_key_assignment");
380
+ assert!(!finding.contains_key("match_excerpt"));
381
+ assert!(!value.to_string().contains("sk-test-red-contract"));
377
382
  let _ = std::fs::remove_dir_all(&ws);
378
383
  }
379
384
  #[test]