@team-agent/installer 0.5.112 → 0.5.114

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.
Files changed (77) hide show
  1. package/Cargo.lock +252 -84
  2. package/Cargo.toml +3 -1
  3. package/crates/team-agent/Cargo.toml +3 -0
  4. package/crates/team-agent/src/cli/adapters.rs +119 -50
  5. package/crates/team-agent/src/cli/diagnose.rs +36 -13
  6. package/crates/team-agent/src/cli/emit.rs +173 -109
  7. package/crates/team-agent/src/cli/leader.rs +16 -0
  8. package/crates/team-agent/src/cli/leader_prompt.rs +261 -0
  9. package/crates/team-agent/src/cli/leaders.rs +32 -32
  10. package/crates/team-agent/src/cli/mod.rs +158 -14
  11. package/crates/team-agent/src/cli/models.rs +68 -18
  12. package/crates/team-agent/src/cli/named_address.rs +17 -16
  13. package/crates/team-agent/src/cli/route.rs +4 -4
  14. package/crates/team-agent/src/cli/send/presentation.rs +19 -18
  15. package/crates/team-agent/src/cli/send/resolve.rs +11 -11
  16. package/crates/team-agent/src/cli/send.rs +4 -4
  17. package/crates/team-agent/src/cli/spec.rs +35 -29
  18. package/crates/team-agent/src/cli/status.rs +7 -7
  19. package/crates/team-agent/src/cli/status_port/brief.rs +268 -101
  20. package/crates/team-agent/src/cli/tests/base.rs +94 -6
  21. package/crates/team-agent/src/cli/tests/divergence.rs +3 -2
  22. package/crates/team-agent/src/cli/tests/lane_c.rs +1 -1
  23. package/crates/team-agent/src/cli/tests/named_address.rs +3 -3
  24. package/crates/team-agent/src/cli/tests/status_send.rs +9 -7
  25. package/crates/team-agent/src/cli/triage.rs +53 -2
  26. package/crates/team-agent/src/cli/types.rs +8 -8
  27. package/crates/team-agent/src/compiler/tests.rs +12 -0
  28. package/crates/team-agent/src/compiler.rs +65 -26
  29. package/crates/team-agent/src/contract_runtime/backend.rs +646 -0
  30. package/crates/team-agent/src/contract_runtime/config.rs +309 -0
  31. package/crates/team-agent/src/contract_runtime/discovery.rs +237 -0
  32. package/crates/team-agent/src/contract_runtime/forward.rs +229 -0
  33. package/crates/team-agent/src/contract_runtime/framework.rs +261 -0
  34. package/crates/team-agent/src/contract_runtime/mcp.rs +396 -0
  35. package/crates/team-agent/src/contract_runtime/mod.rs +14 -0
  36. package/crates/team-agent/src/contract_runtime/prompt.rs +216 -0
  37. package/crates/team-agent/src/contract_runtime/pump.rs +207 -0
  38. package/crates/team-agent/src/contract_runtime/registry.rs +30 -0
  39. package/crates/team-agent/src/coordinator/tick.rs +26 -8
  40. package/crates/team-agent/src/leader/mod.rs +1 -0
  41. package/crates/team-agent/src/leader/prompt.rs +906 -0
  42. package/crates/team-agent/src/leader/start.rs +265 -16
  43. package/crates/team-agent/src/leader/tests/identity.rs +21 -0
  44. package/crates/team-agent/src/leader/tests/mod.rs +2 -0
  45. package/crates/team-agent/src/leader/tests/prompt_contract.rs +268 -0
  46. package/crates/team-agent/src/leader/types.rs +6 -0
  47. package/crates/team-agent/src/lib.rs +4 -0
  48. package/crates/team-agent/src/lifecycle/launch/identity.rs +172 -13
  49. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +760 -365
  50. package/crates/team-agent/src/lifecycle/launch/spawn.rs +38 -9
  51. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +17 -0
  52. package/crates/team-agent/src/lifecycle/launch.rs +39 -28
  53. package/crates/team-agent/src/lifecycle/profile_launch.rs +58 -46
  54. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +97 -52
  55. package/crates/team-agent/src/lifecycle/restart.rs +1 -1
  56. package/crates/team-agent/src/lifecycle/tests/stale_team_saveconflict_contract.rs +3 -2
  57. package/crates/team-agent/src/lifecycle/types.rs +4 -1
  58. package/crates/team-agent/src/lifecycle/worker_command_context.rs +37 -27
  59. package/crates/team-agent/src/main.rs +6 -0
  60. package/crates/team-agent/src/messaging/delivery.rs +50 -1
  61. package/crates/team-agent/src/messaging/send.rs +10 -0
  62. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +131 -0
  63. package/crates/team-agent/src/model/spec.rs +24 -12
  64. package/crates/team-agent/src/provider/adapter.rs +8 -59
  65. package/crates/team-agent/src/provider/adapters/claude.rs +6 -14
  66. package/crates/team-agent/src/provider/adapters/codex.rs +8 -18
  67. package/crates/team-agent/src/provider/adapters/copilot.rs +5 -22
  68. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +8 -45
  69. package/crates/team-agent/src/provider/adapters/grok.rs +7 -31
  70. package/crates/team-agent/src/provider/adapters/pi.rs +7 -18
  71. package/crates/team-agent/src/provider/command_helpers.rs +45 -0
  72. package/crates/team-agent/src/provider/mod.rs +1 -0
  73. package/crates/team-agent/src/provider/model_catalog.rs +21 -28
  74. package/crates/team-agent/src/provider/tests/adapter.rs +70 -0
  75. package/crates/team-agent/src/provider/tests/command_helpers.rs +55 -0
  76. package/crates/team-agent/src/state/persist.rs +73 -0
  77. package/package.json +4 -4
@@ -420,7 +420,7 @@ fn format_status_csv_preserves_agent_order_and_collapses_errors() {
420
420
  });
421
421
  assert_eq!(
422
422
  format_status_csv(&data),
423
- "zeta,空闲\nalpha,工作\nerr_failed,错误\nerr_missing_pane,错误\nerr_unknown,错误\nerr_stopped,错误"
423
+ "zeta,idle\nalpha,working\nerr_failed,error\nerr_missing_pane,error\nerr_unknown,error\nerr_stopped,error"
424
424
  );
425
425
  }
426
426
 
@@ -489,7 +489,7 @@ fn cli_error_payload_plain_runtime() {
489
489
  assert_eq!(payload.error, "some other error");
490
490
  assert_eq!(
491
491
  payload.action,
492
- "先运行 team-agent doctor --workspace . 检查所选队伍,或查看此处列出的错误日志。"
492
+ "Run team-agent doctor --workspace . for the selected team, or inspect the error log shown here."
493
493
  );
494
494
  assert_eq!(payload.log, "/tmp/y.log");
495
495
  assert_eq!(payload.reason, None);
@@ -511,7 +511,7 @@ fn cli_error_payload_tmux_conflict_quick_start_enrichment() {
511
511
  // context reset is only through restart --allow-fresh with explicit consent.
512
512
  assert_eq!(
513
513
  payload.action,
514
- "终端会话 `my-team` 已存在,可能是你的已有队伍。恢复用 team-agent restart;只有明确同意丢弃旧对话才用 --allow-fresh。另建队伍请修改 TEAM.md 的 name 后再 quick-start;不要关闭已有队伍凑成功。"
514
+ "Terminal session `my-team` already exists and may belong to your existing team. Resume with team-agent restart; use --allow-fresh only after explicitly accepting loss of saved context. To create another team, change the name in TEAM.md before quick-start. Do not shut down an existing team to bypass this refusal."
515
515
  );
516
516
  let next = payload.next_actions.as_ref().unwrap();
517
517
  assert_eq!(next.len(), 2);
@@ -529,11 +529,11 @@ fn cli_error_payload_tmux_conflict_non_quick_start_enrichment() {
529
529
  assert_eq!(payload.session_name.as_deref(), Some("my-team"));
530
530
  assert_eq!(
531
531
  payload.action,
532
- "终端会话 `my-team` 已存在,可能属于运行中的队伍。不要关闭它;请使用另一个队伍名称后再启动。"
532
+ "Terminal session `my-team` already exists and may belong to a running team. Do not shut it down; choose another team name before starting."
533
533
  );
534
534
  let next = payload.next_actions.as_ref().unwrap();
535
535
  assert_eq!(next.len(), 1);
536
- assert!(next[0].contains("队伍") && next[0].contains("名称"));
536
+ assert!(next[0].contains("team") && next[0].contains("name"));
537
537
  }
538
538
 
539
539
  #[test]
@@ -749,7 +749,7 @@ fn cmd_leader_passthrough_help_is_pure_human_guidance() {
749
749
  let result = cmd_leader_passthrough(provider, &[alias.into()], &cwd).unwrap();
750
750
  assert_eq!(result.exit, ExitCode::Ok);
751
751
  assert!(
752
- matches!(&result.output, CmdOutput::Human(text) if text.contains(provider) && text.contains("用法") && text.contains("Examples") && text.contains("下一步"))
752
+ matches!(&result.output, CmdOutput::Human(text) if text.contains(provider) && text.contains("Usage") && text.contains("Examples") && text.contains("Next Action"))
753
753
  );
754
754
  }
755
755
  }
@@ -797,3 +797,91 @@ fn cmd_leader_passthrough_maps_grok_and_cursor_not_claude() {
797
797
  assert_eq!(leader_passthrough_provider("cursor_agent"), None);
798
798
  assert_eq!(leader_passthrough_provider("not-a-passthrough"), None);
799
799
  }
800
+
801
+ #[cfg(unix)]
802
+ #[test]
803
+ #[serial_test::serial(env)]
804
+ fn kiro_doctor_inventory_is_read_only_and_does_not_claim_native_readiness() {
805
+ use std::os::unix::fs::PermissionsExt;
806
+ let env = crate::cli::hermetic_test_support::HermeticTestEnv::enter("kiro-inventory");
807
+ let bin = env.workspace("bin");
808
+ let _path = env.with_env("PATH", &bin.to_string_lossy());
809
+ let inventory = provider_doctor_checks();
810
+ assert_eq!(inventory["kiro"]["installed"], false);
811
+ assert_eq!(inventory["codex"]["command"], "codex");
812
+ let launcher = bin.join("kiro-cli");
813
+ std::fs::write(
814
+ &launcher,
815
+ "#!/bin/sh\nprintf called > \"$0.called\"\nexit 0\n",
816
+ )
817
+ .unwrap();
818
+ for (mode, installed) in [(0o600, false), (0o700, true)] {
819
+ std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(mode)).unwrap();
820
+ assert_eq!(
821
+ provider_doctor_checks()["kiro"],
822
+ json!({
823
+ "auth": "unknown",
824
+ "command": "kiro-cli",
825
+ "installed": installed,
826
+ "version": "unknown",
827
+ "probe_status": "not_run",
828
+ "provider_probe_status": "not_run",
829
+ })
830
+ );
831
+ }
832
+ assert!(!bin.join("kiro-cli.called").exists());
833
+ assert!(env.registry_entries().is_empty());
834
+ }
835
+
836
+ #[cfg(unix)]
837
+ #[test]
838
+ #[serial_test::serial(env)]
839
+ fn kiro_leader_entry_has_help_and_typed_refusal_without_native_or_fallback_effects() {
840
+ use std::os::unix::fs::PermissionsExt;
841
+ let env = crate::cli::hermetic_test_support::HermeticTestEnv::enter("kiro-leader-gate");
842
+ let cwd = env.workspace("empty");
843
+ let bin = env.workspace("bin");
844
+ for tool in ["kiro-cli", "kiro-cli-chat", "codex", "pi", "tmux"] {
845
+ let path = bin.join(tool);
846
+ std::fs::write(&path, "#!/bin/sh\nprintf called > \"$0.called\"\nexit 0\n").unwrap();
847
+ std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap();
848
+ }
849
+ let _path = env.with_env("PATH", &bin.to_string_lossy());
850
+ assert!(!crate::cli::emit::is_leader_passthrough_command("kiro"));
851
+ assert_eq!(leader_passthrough_provider("kiro"), None);
852
+ let help = crate::cli::emit::command_help(Some("kiro"));
853
+ assert!(help.contains("team-agent kiro [--json]"));
854
+ assert!(help.contains("kiro_leader_not_admitted"));
855
+ assert!(crate::cli::emit::default_help().contains("Check Kiro leader launch admission"));
856
+ for alias in ["-h", "--help"] {
857
+ assert_eq!(
858
+ crate::cli::run(&["kiro".into(), alias.into()], &cwd),
859
+ ExitCode::Ok
860
+ );
861
+ }
862
+ let cases: &[&[&str]] = &[&[], &["--json"], &["--attach-existing", "--confirm"]];
863
+ for values in cases {
864
+ let args: Vec<String> = values.iter().map(|arg| (*arg).into()).collect();
865
+ let result = cmd_kiro_leader(&args);
866
+ assert_eq!(result.exit, ExitCode::Error);
867
+ assert_eq!(result.as_json, values.contains(&"--json"));
868
+ let CmdOutput::Json(value) = result.output else {
869
+ panic!("expected structured capability refusal");
870
+ };
871
+ assert_eq!(value["schema_version"], "leader_launch.v1");
872
+ assert_eq!(value["ok"], false);
873
+ assert_eq!(value["provider"], "kiro");
874
+ assert_eq!(value["capability"], "leader_launch");
875
+ assert_eq!(value["reason"], "kiro_leader_not_admitted");
876
+ let mut argv = vec!["kiro".into()];
877
+ argv.extend(args);
878
+ assert_eq!(crate::cli::run(&argv, &cwd), ExitCode::Error);
879
+ }
880
+ assert_eq!(std::fs::read_dir(&cwd).unwrap().count(), 0);
881
+ assert_eq!(
882
+ std::fs::read_dir(&bin).unwrap().count(),
883
+ 5,
884
+ "no tool invoked"
885
+ );
886
+ assert!(env.registry_entries().is_empty());
887
+ }
@@ -457,7 +457,7 @@ fn red_send_target_none_is_not_broadcast() {
457
457
  assert_eq!(send_target(None, Some("*")), MessageTarget::Broadcast);
458
458
  }
459
459
 
460
- // Unified doctor human output is triage only, including the comms gate.
460
+ // Doctor retains bounded triage navigation plus complete diagnostic details.
461
461
  #[test]
462
462
  fn cmd_doctor_comms_human_is_compact_triage_without_boundary_prefix() {
463
463
  let mut args = DoctorArgs {
@@ -483,8 +483,9 @@ fn cmd_doctor_comms_human_is_compact_triage_without_boundary_prefix() {
483
483
  };
484
484
  assert_eq!(text, crate::cli::triage::render("doctor", &report));
485
485
  assert!(text.starts_with("doctor:"));
486
- assert_eq!(text.lines().count(), 1 + report["issues"].as_array().unwrap().len()
486
+ assert_eq!(text.lines().count(), 2 + report["issues"].as_array().unwrap().len()
487
487
  + report["suggested_repairs"].as_array().unwrap().len());
488
+ assert!(text.lines().last().unwrap().starts_with("details: "));
488
489
  }
489
490
 
490
491
  // ---- #13 / #27 (P2): run() must NOT treat 'claude_code' as a passthrough trigger ----
@@ -257,7 +257,7 @@ fn inbox_excludes_leader_stage_result_notifications() {
257
257
  // the deterministic check sub-shapes (run_id is a random uuid; not value-locked). ────────────────
258
258
  #[test]
259
259
  fn comms_selftest_golden_boundary_scope_and_check_shapes() {
260
- let boundary = "这里只检查终端连接是否一致,不实际发送消息,也不证明队友已回复。";
260
+ let boundary = "This checks terminal connection consistency only; it does not send a message or prove that a worker replied.";
261
261
  let ws = tmp_workspace();
262
262
  let v = diagnose_port::comms_selftest(&ws, None, None).expect("comms_selftest");
263
263
  let obj = v.as_object().expect("comms dict");
@@ -377,21 +377,21 @@ fn send_to_name_mutual_exclusion() {
377
377
  let args = named_send_args(&cwd, Some("team-a/qa"), Some("%1"), None, &["hello"]);
378
378
  let err = cmd_send(&args).unwrap_err();
379
379
  assert!(
380
- matches!(err, CliError::Usage(ref message) if message.contains("--to-name/--pane/--to") && message.contains("不能同时")),
380
+ matches!(err, CliError::Usage(ref message) if message.contains("--to-name/--pane/--to") && message.contains("cannot be combined")),
381
381
  "expected --to-name mutual exclusion, got {err:?}"
382
382
  );
383
383
 
384
384
  let args = named_send_args(&cwd, Some("team-a/qa"), None, Some("worker"), &["hello"]);
385
385
  let err = cmd_send(&args).unwrap_err();
386
386
  assert!(
387
- matches!(err, CliError::Usage(ref message) if message.contains("--to-name/--pane/--to") && message.contains("不能同时")),
387
+ matches!(err, CliError::Usage(ref message) if message.contains("--to-name/--pane/--to") && message.contains("cannot be combined")),
388
388
  "expected --to-name/--to mutual exclusion, got {err:?}"
389
389
  );
390
390
 
391
391
  let args = named_send_args(&cwd, Some("team-a/qa"), None, None, &[]);
392
392
  let err = cmd_send(&args).unwrap_err();
393
393
  assert!(
394
- matches!(err, CliError::Usage(ref message) if message == "请填写任务内容;--to-name 不能发送空消息"),
394
+ matches!(err, CliError::Usage(ref message) if message == "Provide a task message; --to-name cannot send an empty message"),
395
395
  "expected empty-message usage error, got {err:?}"
396
396
  );
397
397
  let _ = std::fs::remove_dir_all(&cwd);
@@ -436,6 +436,8 @@ fn leader_attach_command_for_plan_uses_plan_leader_window() {
436
436
  )),
437
437
  argv: Vec::new(),
438
438
  provider_argv: Vec::new(),
439
+ leader_prompt: None,
440
+ session_capture: None,
439
441
  leader_window: Some(crate::transport::WindowName::new("claude_code")),
440
442
  is_external_leader: false,
441
443
  leader_env: std::collections::BTreeMap::new(),
@@ -641,11 +643,11 @@ fn cmd_send_joins_message_with_single_space() {
641
643
  // 应立即失败以强制重新审视,而不是悄悄跑一条从未验过的分支。
642
644
  assert!(
643
645
  !delivered,
644
- "hermetic seed 不应产生 delivered=true;tier 假设变了"
646
+ "hermetic seed must not produce delivered=true; the fixture assumption changed"
645
647
  );
646
648
  assert_eq!(
647
649
  v.get("reminder").and_then(|reminder| reminder.as_str()),
648
- Some("任务已收下,但还未确认送到队友对话。查看 team-agent status/doctor,等真实回复,不反复重发。")
650
+ Some("Task accepted, but delivery is unconfirmed. Check team-agent status/doctor; wait for an actual reply without repeated sends.")
649
651
  );
650
652
  }
651
653
  }
@@ -665,15 +667,15 @@ fn cmd_send_default_human_output_is_brief_without_false_delivered() {
665
667
  "send must separate outcome and next action: {text}"
666
668
  );
667
669
  assert!(
668
- lines[0].contains("任务已收下") && lines[0].contains("还未送到队友的对话"),
670
+ lines[0].contains("Task accepted") && lines[0].contains("delivery to the agent's conversation is still pending"),
669
671
  "queued send must report its true pending outcome: {text}"
670
672
  );
671
673
  assert!(
672
- lines[1].contains("下一步")
674
+ lines[1].contains("Next:")
673
675
  && lines[1].contains("team-agent status")
674
676
  && lines[1].contains("doctor")
675
- && lines[1].contains("等真实回复")
676
- && lines[1].contains("不反复重发"),
677
+ && lines[1].contains("wait for an actual reply")
678
+ && lines[1].contains("without repeated sends"),
677
679
  "queued send must give honest next steps: {text}"
678
680
  );
679
681
  assert!(
@@ -951,7 +953,7 @@ fn cmd_send_unknown_task_surfaces_golden_error_envelope_not_silent() {
951
953
  );
952
954
  assert_eq!(
953
955
  payload.action,
954
- "先运行 team-agent doctor --workspace . 检查所选队伍,或查看此处列出的错误日志。"
956
+ "Run team-agent doctor --workspace . for the selected team, or inspect the error log shown here."
955
957
  );
956
958
  let _ = std::fs::remove_dir_all(&ws);
957
959
  }
@@ -56,9 +56,34 @@ pub(crate) fn render(command: &str, report: &Value) -> String {
56
56
  for repair in repairs {
57
57
  lines.push(bounded(format!("repair: {}", summarize(repair, true))));
58
58
  }
59
+ // Short triage lines are navigation, not the authoritative refusal. Retain
60
+ // complete causes/actions as JSON, without dumping unrelated runtime or
61
+ // provider diagnostics. Values have already crossed the redaction boundary.
62
+ let details = diagnostic_details(&report);
63
+ if details.as_object().is_some_and(|details| !details.is_empty()) {
64
+ lines.push(format!("details: {details}"));
65
+ }
59
66
  lines.join("\n")
60
67
  }
61
68
 
69
+ // Shared by doctor/diagnose and send. Never include message bodies, profiles or
70
+ // unrelated runtime/provider dumps in a command's diagnostic output.
71
+ pub(crate) fn diagnostic_details(report: &Value) -> Value {
72
+ let report = crate::redaction::redact_external_value(report);
73
+ let mut details = serde_json::Map::new();
74
+ for key in [
75
+ "error", "status", "summary", "reason", "blockers", "action",
76
+ "next_action", "next_actions", "issues", "suggested_repairs",
77
+ "state_path", "log", "schema_error", "stage", "delivery_status",
78
+ "message_status", "verification", "channel", "turn_verification",
79
+ ] {
80
+ if let Some(value) = report.get(key) {
81
+ details.insert(key.to_string(), value.clone());
82
+ }
83
+ }
84
+ Value::Object(details)
85
+ }
86
+
62
87
  fn array_items(value: Option<&Value>) -> &[Value] {
63
88
  value.and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[])
64
89
  }
@@ -126,6 +151,7 @@ fn bounded(line: String) -> String {
126
151
 
127
152
  #[cfg(test)]
128
153
  mod tests {
154
+ #![allow(clippy::unwrap_used)]
129
155
  use super::*;
130
156
  use serde_json::json;
131
157
 
@@ -149,12 +175,37 @@ mod tests {
149
175
  #[test]
150
176
  fn renderer_sanitizes_and_bounds_utf8_lines() {
151
177
  let report = json!({"ok": false, "issues": ["问题\n".to_string() + &"x".repeat(200)]});
152
- for line in render("doctor", &report).lines() {
153
- assert!(line.len() <= LINE_LIMIT_BYTES);
178
+ let output = render("doctor", &report);
179
+ for line in output.lines() {
180
+ if let Some(details) = line.strip_prefix("details: ") {
181
+ let parsed: Value = serde_json::from_str(details).unwrap();
182
+ assert_eq!(parsed.get("issues"), report.get("issues"));
183
+ } else {
184
+ assert!(line.len() <= LINE_LIMIT_BYTES);
185
+ }
154
186
  assert!(!line.chars().any(char::is_control));
155
187
  }
156
188
  }
157
189
 
190
+ #[test]
191
+ fn renderer_preserves_typed_failure_details_and_long_actions() {
192
+ let action = "inspect the precise failure ".repeat(20);
193
+ let report = json!({
194
+ "ok": false,
195
+ "status": "preflight_blocked",
196
+ "reason": "exact refusal",
197
+ "blockers": [{"stage": "database", "reason": "permission denied"}],
198
+ "next_actions": [action],
199
+ "state_path": "/tmp/workspace/.team/runtime/state.json"
200
+ });
201
+ let output = render("doctor", &report);
202
+ let details = output.lines().find_map(|line| line.strip_prefix("details: ")).unwrap();
203
+ let parsed: Value = serde_json::from_str(details).unwrap();
204
+ for key in ["status", "reason", "blockers", "next_actions", "state_path"] {
205
+ assert_eq!(parsed.get(key), report.get(key), "lost {key}");
206
+ }
207
+ }
208
+
158
209
  #[test]
159
210
  fn renderer_projects_secret_findings_without_values() {
160
211
  let report = json!({
@@ -84,7 +84,7 @@ impl CliError {
84
84
  ok: false,
85
85
  error: error.clone(),
86
86
  action: if super::spec::command_spec(command).is_some() {
87
- "先运行 team-agent doctor --workspace . 检查所选队伍,或查看此处列出的错误日志。"
87
+ "Run team-agent doctor --workspace . for the selected team, or inspect the error log shown here."
88
88
  } else {
89
89
  "run `team-agent doctor` or inspect the log path shown here"
90
90
  }
@@ -99,27 +99,27 @@ impl CliError {
99
99
  payload.session_name = Some(session.clone());
100
100
  if command == "quick-start" {
101
101
  payload.action = format!(
102
- "终端会话 `{session}` 已存在,可能是你的已有队伍。恢复用 team-agent restart;只有明确同意丢弃旧对话才用 --allow-fresh。另建队伍请修改 TEAM.md 的 name 后再 quick-start;不要关闭已有队伍凑成功。"
102
+ "Terminal session `{session}` already exists and may belong to your existing team. Resume with team-agent restart; use --allow-fresh only after explicitly accepting loss of saved context. To create another team, change the name in TEAM.md before quick-start. Do not shut down an existing team to bypass this refusal."
103
103
  );
104
104
  payload.next_actions = Some(vec![
105
- "如果是自己的已有队伍,使用 team-agent restart 恢复。".to_string(),
106
- "如要另建队伍,修改 TEAM.md 的 name 后再运行 team-agent quick-start。"
105
+ "If this is your existing team, resume it with team-agent restart.".to_string(),
106
+ "To create another team, change the name in TEAM.md before running team-agent quick-start."
107
107
  .to_string(),
108
108
  ]);
109
109
  } else {
110
110
  payload.action = format!(
111
- "终端会话 `{session}` 已存在,可能属于运行中的队伍。不要关闭它;请使用另一个队伍名称后再启动。"
111
+ "Terminal session `{session}` already exists and may belong to a running team. Do not shut it down; choose another team name before starting."
112
112
  );
113
- payload.next_actions = Some(vec!["请使用另一个队伍名称后再启动。".to_string()]);
113
+ payload.next_actions = Some(vec!["Choose another team name before starting.".to_string()]);
114
114
  }
115
115
  } else if error.contains("Team Agent launcher flag")
116
116
  && error.contains("must appear before --")
117
117
  {
118
118
  payload.action =
119
- String::from("将主控启动选项放在 -- 前面;-- 后面只能是工具自己的参数。");
119
+ String::from("Place Team Agent launcher options before --; only native provider arguments belong after --.");
120
120
  } else if error.contains("managed launcher refuses a different ambient tmux server") {
121
121
  payload.action = String::from(
122
- "先退出当前 tmux 客户端再重试;只有明确需要嵌套连接时才在 -- 前加 --allow-nested-attach。",
122
+ "Leave the current tmux client before retrying. Add --allow-nested-attach before -- only when nested attachment is intentional.",
123
123
  );
124
124
  }
125
125
  payload
@@ -7,6 +7,18 @@ use std::sync::atomic::{AtomicU32, Ordering};
7
7
 
8
8
  static COUNTER: AtomicU32 = AtomicU32::new(0);
9
9
 
10
+ #[test]
11
+ fn cursor_effort_is_rejected_by_shared_admission_for_every_level() {
12
+ for effort in ["low", "medium", "high", "xhigh", "max", "ultra"] {
13
+ let role = write_tmp("cursor.md", &format!("---\nprovider: cursor_agent\neffort: {effort}\n---\nRole\n"));
14
+ let error = compile_role_agent(&role, &Value::Map(Vec::new()), "/workspace")
15
+ .err()
16
+ .expect("Cursor must reject effort before command construction");
17
+ assert!(error.to_string().contains("cursor_agent does not support effort; the Cursor CLI has no --effort flag"));
18
+ fs::remove_dir_all(role.parent().unwrap()).unwrap();
19
+ }
20
+ }
21
+
10
22
  fn unique_base() -> PathBuf {
11
23
  let n = COUNTER.fetch_add(1, Ordering::Relaxed);
12
24
  std::env::temp_dir().join(format!("ta_rs_compile_{}_{}", std::process::id(), n))
@@ -42,10 +42,16 @@ pub struct IgnoredTeamField {
42
42
  pub fn read_front_matter(path: &Path) -> Result<(Value, String), ModelError> {
43
43
  let text = fs::read_to_string(path)
44
44
  .map_err(|e| ModelError::Runtime(format!("{}: {e}", path.display())))?;
45
+ Ok(split_front_matter(&text))
46
+ }
47
+
48
+ /// Parse the same captured bytes that a prompt receipt hashes; the existing
49
+ /// path-based compiler keeps its original normalization/plain-text fallback.
50
+ pub(crate) fn split_front_matter(text: &str) -> (Value, String) {
45
51
  let text = text.replace("\r\n", "\n").replace('\r', "\n");
46
52
  let empty_meta = || Value::Map(Vec::new());
47
53
  let Some(rest) = text.strip_prefix("---\n") else {
48
- return Ok((empty_meta(), text));
54
+ return (empty_meta(), text);
49
55
  };
50
56
 
51
57
  let mut offset = 0;
@@ -58,7 +64,7 @@ pub fn read_front_matter(path: &Path) -> Result<(Value, String), ModelError> {
58
64
  offset += line.len();
59
65
  }
60
66
  let Some(close) = close else {
61
- return Ok((empty_meta(), text));
67
+ return (empty_meta(), text);
62
68
  };
63
69
  let raw_meta = &rest[..close];
64
70
  let meta = if raw_meta.trim().is_empty() {
@@ -66,11 +72,11 @@ pub fn read_front_matter(path: &Path) -> Result<(Value, String), ModelError> {
66
72
  } else {
67
73
  match yaml::loads(raw_meta) {
68
74
  Ok(meta) if meta.is_map() => meta,
69
- _ => return Ok((empty_meta(), text)),
75
+ _ => return (empty_meta(), text),
70
76
  }
71
77
  };
72
78
  let after_marker = &rest[close + 3..];
73
- Ok((meta, after_marker.trim_start_matches('\n').to_string()))
79
+ (meta, after_marker.trim_start_matches('\n').to_string())
74
80
  }
75
81
 
76
82
  pub fn ignored_owner_team_id_from_team_md(
@@ -489,6 +495,17 @@ fn compile_role_agent_with_mode(
489
495
  .filter(|provider| !provider.trim().is_empty())
490
496
  .unwrap_or_else(|| "pi".to_string());
491
497
  let is_pi = parse_canonical_provider(&provider) == Some(Provider::Pi);
498
+ #[cfg(unix)]
499
+ let contract = crate::contract_runtime::registry::descriptor(&provider);
500
+ #[cfg(unix)]
501
+ let contract_role = contract.is_some();
502
+ #[cfg(not(unix))]
503
+ let contract_role = false;
504
+ #[cfg(unix)]
505
+ let skip_team_effort =
506
+ contract.is_some_and(|descriptor| !descriptor.effort.inherit_team_default);
507
+ #[cfg(not(unix))]
508
+ let skip_team_effort = false;
492
509
  validate_pi_role_fields(&meta, role_path, &provider)?;
493
510
  let model = string_field(&meta, "model")
494
511
  .filter(|value| !value.trim().is_empty())
@@ -567,7 +584,7 @@ fn compile_role_agent_with_mode(
567
584
  Some(raw) if !raw.trim().is_empty() => ProviderEffort::parse(raw.trim()),
568
585
  _ => None,
569
586
  };
570
- let resolved_effort = if is_pi {
587
+ let resolved_effort = if is_pi || skip_team_effort {
571
588
  role_effort
572
589
  } else {
573
590
  role_effort.or(team_effort)
@@ -582,22 +599,30 @@ fn compile_role_agent_with_mode(
582
599
  _ => None,
583
600
  })
584
601
  .unwrap_or("");
585
- let provider_enum = parse_canonical_provider(provider_str).unwrap_or(Provider::Codex);
586
- if let Err(reason) = effort.resolve_for_provider(provider_enum) {
587
- return Err(ModelError::Validation(format!(
588
- "{}: {reason} (effort: {}; provider: {provider_str})",
589
- role_path.display(),
590
- effort.as_str()
591
- )));
602
+ #[cfg(unix)]
603
+ if let Some(descriptor) = contract {
604
+ use team_agent_contract::contract::descriptor::{Effort, EffortAdmission};
605
+ let native_effort = Effort::parse(effort.as_str())
606
+ .map_err(|error| ModelError::Validation(error.to_string()))?;
607
+ if let EffortAdmission::Reject(reason) =
608
+ descriptor.effort.admission[native_effort.index()]
609
+ {
610
+ return Err(ModelError::Validation(format!(
611
+ "{}: {}",
612
+ role_path.display(),
613
+ reason.message
614
+ )));
615
+ }
592
616
  }
593
- if provider_enum == Provider::CursorAgent {
594
- return Err(ModelError::Validation(format!(
595
- "{}: cursor_agent does not support effort '{}'. \
596
- The Cursor CLI has no `--effort` flag; the framework must not drop the field and still launch. \
597
- Remove effort from the role file (do not map it into `--model[effort=]` until that form is measured).",
598
- role_path.display(),
599
- effort.as_str()
600
- )));
617
+ if !contract_role {
618
+ let provider_enum = parse_canonical_provider(provider_str).unwrap_or(Provider::Codex);
619
+ if let Err(reason) = effort.resolve_for_provider(provider_enum) {
620
+ return Err(ModelError::Validation(format!(
621
+ "{}: {reason} (effort: {}; provider: {provider_str})",
622
+ role_path.display(),
623
+ effort.as_str()
624
+ )));
625
+ }
601
626
  }
602
627
  agent_items.push(("effort", Value::Str(effort.as_str().to_string())));
603
628
  }
@@ -721,12 +746,17 @@ mod pi_preflight_tests {
721
746
 
722
747
  #[test]
723
748
  fn omitted_qualified_and_non_pi_do_not_discover() {
724
- for meta in [role("pi", None), role("pi", Some("openai/gpt-5")), role("codex", Some("gpt-5"))] {
749
+ for meta in [
750
+ role("pi", None),
751
+ role("pi", Some("openai/gpt-5")),
752
+ role("codex", Some("gpt-5")),
753
+ ] {
725
754
  let calls = Cell::new(0);
726
755
  assert!(preflight_pi_role_model_with(&meta, |_| {
727
756
  calls.set(calls.get() + 1);
728
757
  Ok(vec![])
729
- }).is_ok());
758
+ })
759
+ .is_ok());
730
760
  assert_eq!(calls.get(), 0);
731
761
  }
732
762
  }
@@ -737,12 +767,21 @@ mod pi_preflight_tests {
737
767
  let error = preflight_pi_role_model_with(&role("pi", Some("gpt-5.6-sol")), |requested| {
738
768
  calls.set(calls.get() + 1);
739
769
  assert_eq!(requested, "gpt-5.6-sol");
740
- Ok(vec!["openai-codex/gpt-5.6-sol".into(), "azure/gpt-5.6-sol".into()])
741
- }).expect_err("unqualified model must fail closed");
770
+ Ok(vec![
771
+ "openai-codex/gpt-5.6-sol".into(),
772
+ "azure/gpt-5.6-sol".into(),
773
+ ])
774
+ })
775
+ .expect_err("unqualified model must fail closed");
742
776
  assert_eq!(calls.get(), 1);
743
- assert_eq!(error.candidates, vec!["openai-codex/gpt-5.6-sol", "azure/gpt-5.6-sol"]);
777
+ assert_eq!(
778
+ error.candidates,
779
+ vec!["openai-codex/gpt-5.6-sol", "azure/gpt-5.6-sol"]
780
+ );
744
781
  assert!(!error.not_ready);
745
- assert!(error.action.contains("team-agent models --provider pi --search gpt-5.6-sol"));
782
+ assert!(error
783
+ .action
784
+ .contains("team-agent models --provider pi --search gpt-5.6-sol"));
746
785
  }
747
786
 
748
787
  #[test]