@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.
@@ -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
 
@@ -225,7 +225,8 @@ fn dispatch(command: &str, args: &[String], cwd: &Path) -> Result<ExitCode, CliE
225
225
  "profile" => cmd_profile(&profile_args(args, cwd)?).map(emit_result),
226
226
  "results" => cmd_results(&results_args(args, cwd)?).map(emit_result),
227
227
  "wait" => cmd_wait(&wait_args(args, cwd)?).map(emit_result),
228
- "diagnose" => cmd_diagnose(&diagnose_args(args, cwd)).map(emit_result),
228
+ // Compatibility spelling: parse and execute the exact doctor contract.
229
+ "diagnose" => cmd_doctor(&doctor_args(args, cwd)).map(emit_result),
229
230
  "preflight" => cmd_preflight(&preflight_args(args, cwd)).map(emit_result),
230
231
  "wait-ready" => cmd_wait_ready(&wait_ready_args(args, cwd)).map(emit_result),
231
232
  "e2e" => cmd_e2e(&e2e_args(args, cwd)).map(emit_result),
@@ -340,7 +341,7 @@ pub(crate) fn default_help() -> String {
340
341
  "reset-agent",
341
342
  ],
342
343
  );
343
- append_help_section(&mut out, "Diagnose", &["diagnose"]);
344
+ append_help_section(&mut out, "Diagnose", &["doctor"]);
344
345
  append_help_section(
345
346
  &mut out,
346
347
  "Guided recovery",
@@ -444,7 +445,7 @@ fn command_help(command: Option<&str>) -> String {
444
445
  Some("profile") => "usage: team-agent profile COMMAND NAME [--workspace WORKSPACE] [--team TEAM] [--auth-mode MODE] [--proxy-mode direct|inherit] [--json]".to_string(),
445
446
  Some("results") => "usage: team-agent results --case CASE_ID [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
446
447
  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(),
448
+ 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
449
  Some("preflight") => "usage: team-agent preflight [TEAMDIR] [--json]".to_string(),
449
450
  Some("wait-ready") => "usage: team-agent wait-ready [--workspace WORKSPACE] [--team TEAM] [--timeout SECONDS] [--json]".to_string(),
450
451
  Some("e2e") => "usage: team-agent e2e [--workspace WORKSPACE] [--providers LIST] [--real] [--json]".to_string(),
@@ -2172,7 +2173,8 @@ mod tests {
2172
2173
  fn hidden_commands_not_in_default_help() {
2173
2174
  let top_help = command_help(None);
2174
2175
  let visible = visible_help_commands(&top_help);
2175
- for command in ["leaders", "doctor", "e2e", "peek", "coordinator"] {
2176
+ assert!(visible.iter().any(|command| command == "doctor"));
2177
+ for command in ["leaders", "diagnose", "e2e", "peek", "coordinator"] {
2176
2178
  assert!(
2177
2179
  !visible.iter().any(|visible| visible == command),
2178
2180
  "`{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!({
@@ -176,7 +176,7 @@ pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
176
176
  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
177
  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
178
  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 },
179
+ 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
180
  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
181
  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
182
  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 +196,7 @@ pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
196
196
  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
197
  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
198
  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 },
199
+ 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
200
  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
201
  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
202
  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 },
@@ -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]
@@ -299,7 +299,7 @@ fn dispatch_routes_sessions_subcommand() {
299
299
  // + NO session_name + NO agents -> issues=[] -> EXIT 0.
300
300
  #[test]
301
301
  #[serial(env)]
302
- fn dispatch_routes_diagnose_healthy_leader() {
302
+ fn dispatch_routes_diagnose_checks_runtime_beyond_healthy_leader() {
303
303
  let _env = EnvUnsetGuard::unset(&[
304
304
  "TMUX",
305
305
  "TMUX_PANE",
@@ -332,17 +332,26 @@ fn dispatch_routes_diagnose_healthy_leader() {
332
332
  pane_pid: None,
333
333
  leader_env: Default::default(),
334
334
  }]);
335
- let code = crate::transport_factory::with_leader_endpoint_transport(endpoint, transport, || {
336
- run(
337
- &cli_argv(&["diagnose", "--workspace", &ws.to_string_lossy(), "--json"]),
338
- &ws,
339
- )
335
+ crate::transport_factory::with_leader_endpoint_transport(endpoint, transport, || {
336
+ for command in ["doctor", "diagnose"] {
337
+ let code = run(
338
+ &cli_argv(&[command, "--workspace", &ws.to_string_lossy(), "--json"]),
339
+ &ws,
340
+ );
341
+ assert_eq!(code, ExitCode::Error, "healthy binding alone cannot hide missing coordinator");
342
+ }
343
+ let result = cmd_diagnose(&DiagnoseArgs {
344
+ workspace: ws.clone(), json: true, team: None,
345
+ }).expect("diagnostic report");
346
+ let CmdOutput::Json(report) = result.output else {
347
+ panic!("expected JSON diagnostic report");
348
+ };
349
+ assert_eq!(report["coordinator"]["ok"], false, "{report}");
350
+ let issues = report["issues"].to_string();
351
+ for binding_failure in ["leader_not_attached", "leader_receiver_unbound", "leader_workspace_mismatch"] {
352
+ assert!(!issues.contains(binding_failure), "healthy binding regressed: {report}");
353
+ }
340
354
  });
341
- assert_eq!(
342
- code,
343
- ExitCode::Ok,
344
- "`diagnose` must be Ok when canonical owner/receiver, production registry, and live align; got {code:?}"
345
- );
346
355
  let _ = std::fs::remove_dir_all(&ws);
347
356
  }
348
357
 
@@ -23,6 +23,49 @@ fn anchors(raw: &[&str]) -> BTreeSet<String> {
23
23
  raw.iter().map(|s| s.to_string()).collect()
24
24
  }
25
25
 
26
+ #[test]
27
+ fn leader_anchors_are_endpoint_scoped_and_unknown_anchors_stay_protected() {
28
+ use crate::cli::lifecycle_port::collect_state_leader_anchor_pane_ids as collect;
29
+ let state = json!({
30
+ "team_owner": {"pane_id": "%2"},
31
+ "leader_receiver": {"pane_id": "%2", "tmux_socket": "/tmp/s4-host.sock"},
32
+ "teams": {
33
+ "local": {
34
+ "team_owner": {"pane_id": "%3", "tmux_socket": "/tmp/s4-worker.sock"},
35
+ "leader_receiver": {"pane_id": "%3"}
36
+ },
37
+ "legacy": {"team_owner": {"pane_id": "%4"}}
38
+ }
39
+ });
40
+ assert_eq!(collect(&state, Some("/tmp/s4-worker.sock")), anchors(&["%3", "%4"]));
41
+ assert_eq!(collect(&state, Some("/tmp/s4-host.sock")), anchors(&["%2", "%4"]));
42
+ assert_eq!(collect(&state, None), anchors(&["%2", "%3", "%4"]));
43
+
44
+ // An unrelated receiver's socket is not evidence about the owner's pane.
45
+ let different_panes = json!({
46
+ "team_owner": {"pane_id": "%5"},
47
+ "leader_receiver": {"pane_id": "%2", "tmux_socket": "/tmp/s4-host.sock"}
48
+ });
49
+ assert_eq!(collect(&different_panes, Some("/tmp/s4-worker.sock")), anchors(&["%5"]));
50
+ }
51
+
52
+ #[test]
53
+ #[cfg(unix)]
54
+ fn leader_anchor_endpoint_aliases_preserve_same_server_protection() {
55
+ use crate::cli::lifecycle_port::collect_state_leader_anchor_pane_ids as collect;
56
+ let ws = tmp_shutdown_workspace("anchor-endpoint-alias");
57
+ let socket = ws.join("server.sock");
58
+ let alias = ws.join("alias.sock");
59
+ std::fs::write(&socket, b"fixture").unwrap();
60
+ std::os::unix::fs::symlink(&socket, &alias).unwrap();
61
+ let state = json!({"leader_receiver": {"pane_id": "%2", "tmux_socket": alias}});
62
+ assert_eq!(collect(&state, socket.to_str()), anchors(&["%2"]));
63
+ let short_name = format!("s4-anchor-{}", std::process::id());
64
+ let full_path = crate::tmux_backend::socket_path_for_name(&short_name).unwrap();
65
+ let state = json!({"leader_receiver": {"pane_id": "%2", "tmux_socket": short_name}});
66
+ assert_eq!(collect(&state, full_path.to_str()), anchors(&["%2"]));
67
+ }
68
+
26
69
  // RC-4: even an apparently exclusive socket uses per-session cleanup.
27
70
  #[test]
28
71
  fn rc4_exclusive_socket_is_fail_closed() {