@team-agent/installer 0.5.119 → 0.5.120

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 (59) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/README.md +46 -1
  4. package/README.zh.md +46 -1
  5. package/crates/team-agent/src/cli/adapters.rs +11 -0
  6. package/crates/team-agent/src/cli/arguments.rs +401 -0
  7. package/crates/team-agent/src/cli/emit.rs +241 -142
  8. package/crates/team-agent/src/cli/intent/runtime.rs +1139 -0
  9. package/crates/team-agent/src/cli/intent/runtime_tests.rs +584 -0
  10. package/crates/team-agent/src/cli/intent/tests.rs +654 -0
  11. package/crates/team-agent/src/cli/intent.rs +642 -0
  12. package/crates/team-agent/src/cli/mod.rs +49 -5
  13. package/crates/team-agent/src/cli/named_address.rs +10 -1
  14. package/crates/team-agent/src/cli/spec.rs +22 -10
  15. package/crates/team-agent/src/cli/status_port/brief.rs +117 -16
  16. package/crates/team-agent/src/cli/tests/base.rs +6 -1
  17. package/crates/team-agent/src/cli/tests/parser_safety.rs +383 -0
  18. package/crates/team-agent/src/cli/tests/run_delegation.rs +63 -19
  19. package/crates/team-agent/src/cli/tests/verb_install_skill.rs +3 -2
  20. package/crates/team-agent/src/cli/tests/verb_profile.rs +3 -1
  21. package/crates/team-agent/src/contract_runtime/backend.rs +1 -1
  22. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +166 -77
  23. package/crates/team-agent/src/lifecycle/launch.rs +28 -0
  24. package/crates/team-agent/src/lifecycle/restart.rs +17 -0
  25. package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +1 -3
  26. package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +5 -15
  27. package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +2 -3
  28. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +2 -3
  29. package/crates/team-agent/src/lifecycle/tests/cli_argv_routing_red.rs +10 -2
  30. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +8 -1
  31. package/crates/team-agent/src/lifecycle/tests/clone_fork_copilot_perms_red.rs +3 -2
  32. package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +6 -9
  33. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +7 -5
  34. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +4 -2
  35. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +5 -2
  36. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +4 -3
  37. package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +2 -4
  38. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +3 -4
  39. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +5 -4
  40. package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +2 -4
  41. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +78 -36
  42. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +8 -4
  43. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +6 -12
  44. package/crates/team-agent/src/lifecycle/tests/pi_fork_session_inheritance_red.rs +4 -0
  45. package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +1 -3
  46. package/crates/team-agent/src/lifecycle/tests/status_credential_redaction_contract.rs +7 -0
  47. package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +1 -3
  48. package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +2 -1
  49. package/crates/team-agent/src/lifecycle/types.rs +10 -0
  50. package/crates/team-agent/src/tmux_backend/tests.rs +37 -0
  51. package/crates/team-agent/src/tmux_backend.rs +8 -1
  52. package/package.json +4 -4
  53. package/skills/team-agent/SKILL.md +71 -54
  54. package/skills/team-agent/command-coverage.json +283 -13
  55. package/skills/team-agent/docs/reference/team-agent-operator.md +220 -0
  56. package/skills/team-agent/references/bug-as-artifact-flow.md +1 -1
  57. package/skills/team-agent/references/recovery-runbook.md +61 -212
  58. package/skills/team-agent/references/team-agent-operator.md +181 -48
  59. package/skills/team-agent/references/team-in-team.md +47 -80
@@ -57,14 +57,6 @@ pub fn run(argv: &[String], cwd: &Path) -> ExitCode {
57
57
  println!("{}", default_help());
58
58
  return ExitCode::Ok;
59
59
  };
60
- if is_machine_command(command)
61
- && argv[1..]
62
- .iter()
63
- .any(|arg| matches!(arg.as_str(), "-h" | "--help"))
64
- {
65
- eprintln!("{HUMAN_NAVIGATION}");
66
- return ExitCode::Usage;
67
- }
68
60
  if command == "route" {
69
61
  return super::route::run(&argv[1..]);
70
62
  }
@@ -85,21 +77,26 @@ pub fn run(argv: &[String], cwd: &Path) -> ExitCode {
85
77
  println!("team-agent {}", env!("CARGO_PKG_VERSION"));
86
78
  return ExitCode::Ok;
87
79
  }
88
- // CR-063/G4: every registered subcommand's `--help` must short-circuit before dispatch,
89
- // before argument validation, leader-pane checks, or runtime-state writes.
90
- //
91
- // The gate stays on KNOWN subcommands so an unknown command still falls through to
92
- // the argparse-style invalid-choice path (golden parser.py:84; covered by
93
- // `cli_unknown_command_red` and the `claude_code` divergence guard which would
94
- // otherwise be silently passthrough-shaped).
95
- if is_known_subcommand(command)
96
- && argv
97
- .iter()
98
- .skip(1)
99
- .any(|arg| matches!(arg.as_str(), "-h" | "--help"))
80
+ if let Some(result) = super::intent::run(command, &argv[1..], cwd) {
81
+ return match result {
82
+ Ok(result) => emit_result(result),
83
+ Err(error) => emit_cli_error_for_command(command, &argv[1..], cwd, &error),
84
+ };
85
+ }
86
+ // Classify syntax/scope errors before dispatch; contextual status refusals
87
+ // retain their existing stderr-only contract. Resolve the scope only once.
88
+ if is_machine_command(command)
89
+ || matches!(command_spec(command).map(|spec| spec.kind), Some(CommandKind::Dispatch(_)))
100
90
  {
101
- println!("{}", command_help(Some(command)));
102
- return ExitCode::Ok;
91
+ let checked = match prepare_control_args(command, &argv[1..], cwd) {
92
+ Ok(checked) => checked,
93
+ Err(error) => return emit_parser_usage(command, &argv[1..], &error),
94
+ };
95
+ let error_args = checked.tokens.clone();
96
+ return match dispatch_prepared(command, checked, cwd) {
97
+ Ok(exit) => exit,
98
+ Err(error) => emit_cli_error_for_command(command, &error_args, cwd, &error),
99
+ };
103
100
  }
104
101
  match dispatch(command, &argv[1..], cwd) {
105
102
  Ok(exit) => exit,
@@ -136,7 +133,7 @@ fn emit_cli_error_for_command(
136
133
  },
137
134
  _ => error.to_string(),
138
135
  };
139
- if has_arg(args, "--json") {
136
+ if super::arguments::has_flag(command, args, "--json") {
140
137
  let payload = error.to_payload(Path::new(""), command);
141
138
  let mut value = serde_json::to_value(payload)
142
139
  .unwrap_or_else(|_| serde_json::json!({"ok": false, "error": error.to_string()}));
@@ -152,11 +149,18 @@ fn emit_cli_error_for_command(
152
149
  ExitCode::Usage
153
150
  } else if command == "status" {
154
151
  emit_status_cli_error(error)
152
+ } else if matches!(error, CliError::Usage(_)) {
153
+ emit_parser_usage(command, args, error)
155
154
  } else {
156
155
  emit_cli_error(command, args, cwd, error)
157
156
  }
158
157
  }
159
158
 
159
+ #[cfg(test)]
160
+ pub(crate) fn __test_runtime_error_emitter(command: &str, cwd: &Path, error: &CliError) -> ExitCode {
161
+ emit_cli_error(command, &[], cwd, error)
162
+ }
163
+
160
164
  fn emit_status_cli_error(error: &CliError) -> ExitCode {
161
165
  let normalized = normalize_cli_error(error);
162
166
  let payload_error = normalized.as_ref().unwrap_or(error);
@@ -218,33 +222,34 @@ fn dispatch(command: &str, args: &[String], cwd: &Path) -> Result<ExitCode, CliE
218
222
  if command == "leader-prompt" {
219
223
  return Ok(super::leader_prompt::run(args));
220
224
  }
221
- // Keep the removed flag harmless for older scripts and persisted command
222
- // lines; the default tmux backend is already the only runtime path.
223
- let filtered_args: Vec<String> = args
224
- .iter()
225
- .filter(|arg| arg.as_str() != "--no-display")
226
- .cloned()
227
- .collect();
228
- let args = filtered_args.as_slice();
229
- if is_machine_command(command) {
230
- if args
231
- .iter()
232
- .any(|arg| matches!(arg.as_str(), "-h" | "--help"))
233
- {
234
- eprintln!("{HUMAN_NAVIGATION}");
235
- return Ok(ExitCode::Usage);
236
- }
237
- return dispatch_machine(command, args, cwd);
225
+ if let Some(result) = super::intent::run(command, args, cwd) {
226
+ return result.map(emit_result);
238
227
  }
239
- let Some(spec) = command_spec(command) else {
228
+ let machine = is_machine_command(command);
229
+ if !machine && !matches!(command_spec(command).map(|spec| spec.kind), Some(CommandKind::Dispatch(_))) {
240
230
  return Ok(emit_unknown_subcommand_usage(command));
241
- };
242
- match spec.kind {
243
- CommandKind::Dispatch(_) => {}
244
- CommandKind::LeaderPassthrough { .. } => {
245
- return Ok(emit_unknown_subcommand_usage(command));
231
+ }
232
+ dispatch_prepared(command, prepare_control_args(command, args, cwd)?, cwd)
233
+ }
234
+
235
+ fn prepare_control_args(command: &str, args: &[String], cwd: &Path) -> Result<super::arguments::CheckedArgs, CliError> {
236
+ let mut checked = super::arguments::parse(command, args)?;
237
+ if !checked.help { checked.tokens = resolve_command_scope(command, checked.tokens, cwd)?; }
238
+ Ok(checked)
239
+ }
240
+
241
+ fn dispatch_prepared(command: &str, checked: super::arguments::CheckedArgs, cwd: &Path) -> Result<ExitCode, CliError> {
242
+ let machine = is_machine_command(command);
243
+ if checked.help {
244
+ if machine {
245
+ eprintln!("{HUMAN_NAVIGATION}");
246
+ return Ok(ExitCode::Usage);
246
247
  }
248
+ println!("{}", command_help(Some(command)));
249
+ return Ok(ExitCode::Ok);
247
250
  }
251
+ let args = checked.tokens.as_slice();
252
+ if machine { return dispatch_machine(command, args, cwd); }
248
253
  if args.iter().all(|arg| arg == "--json")
249
254
  && matches!(
250
255
  command,
@@ -344,17 +349,18 @@ fn is_known_subcommand(command: &str) -> bool {
344
349
  }
345
350
 
346
351
  pub(crate) fn default_help() -> String {
347
- let mut out = String::from("Team Agent: create a team, send tasks, read replies and shut down safely.\n\n1. Prepare agents\n Run team-agent quick-start in the project directory. An empty directory receives TEAM.md and agents/worker.md templates to create manually.\n2. Open a leader and create the team\n With Pi installed and signed in, run team-agent pi.\n Run team-agent quick-start in that leader's tool context.\n To recover an existing team, see team-agent restart --help; do not take over or delete it.\n3. Send tasks and read replies\n team-agent send worker 'Calculate 245 * 37 and reply to the leader.'\n team-agent inbox leader -n 3\n team-agent status\n Sending is not completion; wait for an actual reply.\n4. Diagnose and shut down\n team-agent doctor --workspace .\n team-agent shutdown --workspace . --json\n Verify this team's residual resources are empty, then diagnose the selected workspace.\n\nSelect workspace/team scope with --workspace/--team. Discover model names with models.\nWindows ConPTY requires a Windows host and an installed shim; tmux commands are not a generic Windows path.\n");
352
+ let mut out = String::from("Team Agent: create a team, send tasks, read replies and shut down safely.\n\n1. Prepare agents\n Prepare TEAM.md and agents/*.md with explicit provider and bypass decisions; use --from for an external source.\n2. Open a leader and create the team\n With Pi installed and signed in, run team-agent pi.\n Run team-agent start in that leader's tool context. Verified live members stay unchanged; stopped members are started.\n Use team restart for a deliberate restart (session auto); legacy bare restart retains preserve. Do not take over or delete an existing team.\n3. Send tasks and read replies\n team-agent send worker 'Calculate 245 * 37 and reply to the leader.'\n team-agent inbox leader -n 3\n team-agent status\n Sending is not completion; wait for an actual reply.\n4. Diagnose and stop\n team-agent doctor --workspace .\n team-agent stop --workspace . --json\n Verify this team's residual resources are empty, then diagnose the selected workspace.\n\nSelect workspace/team scope with --workspace/--team. Discover model names with models.\nWindows ConPTY requires a Windows host and an installed shim; tmux commands are not a generic Windows path.\n");
348
353
  append_help_section(
349
354
  &mut out,
350
355
  "Getting started",
351
- &["quick-start", "send", "status", "models", "inbox"],
356
+ &["start", "quick-start", "send", "status", "models", "inbox"],
352
357
  );
353
- append_help_section(&mut out, "Team lifecycle", &["restart", "shutdown"]);
358
+ append_help_section(&mut out, "Team lifecycle", &["stop", "team", "restart", "shutdown"]);
354
359
  append_help_section(
355
360
  &mut out,
356
361
  "Agent lifecycle",
357
362
  &[
363
+ "agent",
358
364
  "add-agent",
359
365
  "start-agent",
360
366
  "stop-agent",
@@ -424,6 +430,9 @@ pub(super) const WORKER_TEMPLATE: &str = "---\nname: worker\nrole: assistant\npr
424
430
  const HUMAN_NAVIGATION: &str = "Use team-agent --help to list available commands.";
425
431
 
426
432
  pub(super) fn command_help(command: Option<&str>) -> String {
433
+ if let Some(name) = command.filter(|name| matches!(*name, "start" | "stop" | "team" | "agent")) {
434
+ return super::intent::help(name);
435
+ }
427
436
  let Some(name) = command else {
428
437
  return default_help();
429
438
  };
@@ -440,7 +449,7 @@ pub(super) fn command_help(command: Option<&str>) -> String {
440
449
  "quick-start" => (
441
450
  "TEAMDIR contains TEAM.md and agents/; --workspace selects the runtime project.\n--name sets the team name; --team/--team-id selects the team; --yes retains compatibility; --detail includes full diagnostics.\nProviders must be installed and signed in. Declare provider/model/effort in role files; permission bypass defaults to false.\n--backend tmux is for POSIX; conpty requires a Windows host and an installed ConPTY shim.",
442
451
  "team-agent quick-start\nteam-agent quick-start ./roles --workspace ./project\nteam-agent quick-start . --json",
443
- "Check Pi installation and sign-in; verify models with team-agent models --provider pi.\nRun team-agent pi, then quick-start in that leader's tool context. For an existing team, see restart --help."),
452
+ "Compatibility bridge only. In the authorized Leader context, prefer team-agent start --from TEAMDIR for complete new configuration and team-agent start for later activation. Check models and native sign-in first; inspect status and actual replies after starting."),
444
453
  "send" => ("<agent> is an agent name from status; MESSAGE is the task text.\n--mailbox stores a message without injecting it into the current conversation; default delivery targets the agent conversation.",
445
454
  "team-agent send worker 'Calculate 245 * 37 and reply to the leader.'\nteam-agent send worker 'Review the changes' --workspace . --team help-demo\nteam-agent send worker 'Read this when next available' --mailbox",
446
455
  "Acceptance is not delivery or completion. Wait for an actual reply; use team-agent inbox leader -n 3. Diagnose with status/doctor rather than repeated sends."),
@@ -449,25 +458,25 @@ pub(super) fn command_help(command: Option<&str>) -> String {
449
458
  "status" => ("Optional <agent> selects one agent. --summary/--detail retain compatibility and do not add fields.\nNine fields: name/provider/model/effort/runtime_status/activity/health/session_name/tmux_command.\nRuntime, activity and health are distinct. unknown means insufficient evidence; null means unset. No default model is guessed.",
450
459
  "team-agent status\nteam-agent status worker\nteam-agent status --workspace . --team help-demo --json", "Read replies with team-agent inbox leader -n 3; diagnose problems with team-agent doctor --workspace ."),
451
460
  "models" => ("--provider selects the provider, default pi. QUERY or --search performs keyword search; they are mutually exclusive.\nUse cursor for the contract-native Cursor catalog; cursor_agent retains the legacy entrypoint. Copilot has no model discovery entrypoint here. Results come from the native catalog, not guesses.",
452
- "team-agent models --provider pi\nteam-agent models --provider pi --search luna\nteam-agent models --provider cursor --json", "Use the exact returned model name in a role file or add-agent --model before launch."),
453
- "restart" => ("WORKSPACE defaults to the current project; saved sessions are preferred.\nUse --allow-fresh only after explicit authorization to lose saved context. --session-converge-deadline sets the wait in seconds.",
454
- "team-agent restart .\nteam-agent restart . --team help-demo --json", "Check status after restart. Obtain authorization before creating a fresh session; takeover is not a substitute for restart."),
455
- "shutdown" => ("Shuts down only the selected workspace/team. Logs are retained by default; --keep-logs remains compatible.\n--json reports scope, degradation and owned residuals. Success does not mean other teams were shut down.",
456
- "team-agent shutdown --workspace .\nteam-agent shutdown --workspace . --team help-demo --json", "Verify this team's owned residuals are empty, then run team-agent doctor --workspace . Do not clean up other teams."),
457
- "add-agent" => ("<agent> names the new agent. --provider selects the provider; --model sets the model; --effort sets reasoning effort.\n--bypass controls permission bypass (example: false); --prompt sets responsibilities; --profile selects authentication/proxy settings.\nProvider and bypass must be explicit in arguments or --role-file. Conflicting settings are refused, not guessed. Use --force to replace an agent only after explicit authorization.",
458
- "team-agent add-agent reviewer --provider pi --model openai-codex/gpt-6-luna --bypass false --prompt 'Review tasks'\nteam-agent add-agent reviewer --role-file ./agents/reviewer.md", "Verify the agent with status, then run team-agent send reviewer 'Review the changes'."),
459
- "start-agent" => ("Starts an existing agent only; stop-agent first if it is running.\n--provider selects the provider; --model sets the model; --effort sets reasoning effort; --bypass controls permission bypass.\n--prompt/--profile changes responsibilities/authentication. Use --allow-fresh only after explicit authorization to lose saved context.",
460
- "team-agent start-agent worker\nteam-agent start-agent worker --model openai-codex/gpt-6-luna", "Use add-agent for a new agent. Check status after starting, then send a task."),
461
- "stop-agent" => ("<agent> names the agent to stop. This is not removal; configuration and session history are retained.",
462
- "team-agent stop-agent worker\nteam-agent stop-agent worker --workspace . --team help-demo --json", "Verify stopped status; resume with team-agent start-agent worker."),
463
- "reset-agent" => ("Requires explicit --discard-session. Stops the old process, clears the saved session association and attempts a fresh restart. Paused agents stay paused. This does not delete all native provider history.",
464
- "team-agent reset-agent worker --discard-session\nteam-agent reset-agent worker --discard-session --workspace . --team help-demo --json", "Verify with team-agent status. After a normal reset, send a new task; another start-agent is unnecessary."),
465
- "clone-agent" => ("<agent> is the source; --as names the new agent; --label sets a readable label.\nCreates an agent from copied configuration, not a complete conversation copy.",
466
- "team-agent clone-agent worker --as reviewer\nteam-agent clone-agent worker --as reviewer --label 'Review agent' --workspace .", "Check the new agent with status, then send reviewer an independent task."),
467
- "fork-agent" => ("<agent> is the source; --as names the new agent; --label sets a readable label.\nRequires a captured source session and supported fork behavior. Unsupported cases are refused; not all providers support the same kind of fork.",
468
- "team-agent fork-agent worker --as experiment\nteam-agent fork-agent worker --as experiment --workspace . --team help-demo --json", "Inspect status after success, then send a task to the resulting agent. Stop or remove a new seat when finished."),
469
- "remove-agent" => ("Requires --confirm. Spec-defined agents also require --from-spec; dynamic agents do not.\nDeletes only the managed role copy, not an external user role file. Stop a running agent first.\n--force stops and removes a running agent; use only after understanding the risk and receiving explicit authorization.",
470
- "team-agent remove-agent reviewer --confirm\nteam-agent remove-agent reviewer --from-spec --confirm --workspace . --team help-demo --json", "Save required replies first; verify status and configuration scope after removal. Do not force-clean other agents."),
461
+ "team-agent models --provider pi\nteam-agent models --provider pi --search luna\nteam-agent models --provider cursor --json", "Use the exact returned model in a role file or team-agent start AGENT --model MODEL; stop a running seat before changing its settings."),
462
+ "restart" => ("restart AGENT and team restart default to --session auto; bare/explicit-path restart keeps preserve.\n--session preserve requires continuation evidence; auto permits only the original legal fresh fallback, not guaranteed saved memory.\n--dry-run plans without lifecycle effects. --allow-fresh is the legacy authorized opt-in; --session-converge-deadline is in seconds.",
463
+ "team-agent restart worker --dry-run --json\nteam-agent restart worker --session preserve --workspace . --dry-run --json", "Use team-agent team restart for deliberate team recovery; choose --session preserve when context must be retained. Team restart leaves explicitly stopped/paused seats alone; team-agent start intentionally activates the non-paused roster. Inspect partial/unknown outcomes with status/doctor, never blind reset or retry."),
464
+ "shutdown" => ("Compatibility bridge: stop only the selected workspace/team. Logs are retained by default; --keep-logs remains compatible.\n--json reports scope, degradation and owned residuals. Success does not mean other teams were shut down.",
465
+ "team-agent shutdown --workspace .\nteam-agent shutdown --workspace . --team help-demo --json", "Prefer team-agent stop --workspace . --team KEY --json. Verify this team's owned residuals are empty; use team-agent start for authorized later activation. Do not clean up other teams or shared servers."),
466
+ "add-agent" => ("Compatibility add-only entry. <agent> names the new seat; --provider/--model/--effort select intentional launch settings.\n--bypass requires true|false; --prompt sets responsibilities; --profile selects a local profile.\nProvider and bypass must be explicit in arguments or --role-file. Conflicting settings are refused before writes. --force requires explicit authorization, a positively dead old pane and no live cohort.",
467
+ "team-agent add-agent reviewer --provider pi --model openai-codex/gpt-6-luna --bypass false --prompt 'Review tasks'\nteam-agent add-agent reviewer --role-file ./agents/reviewer.md", "Prefer team-agent start reviewer --role-file FILE for add-or-wake, or team-agent agent add reviewer for strict new-seat behavior. Keep provider/bypass explicit; check status and send a task after actual startup."),
468
+ "start-agent" => ("Compatibility entry starts an existing stopped seat only; a running seat is refused.\n--provider/--model/--effort/--bypass select intentional settings; --prompt replaces permanent instructions and --profile selects local authentication.\nA different provider is refused. Use --allow-fresh only after explicit authorization to lose saved context.",
469
+ "team-agent start-agent worker\nteam-agent start-agent worker --model openai-codex/gpt-6-luna", "Prefer team-agent start worker for daily add-or-wake or verified live no-op. Use team-agent stop worker before applying changed settings; inspect status and send a task afterward. A no-op is not role synchronization or business readiness."),
470
+ "stop-agent" => ("Compatibility single-agent stop; configuration and saved session associations are retained, not removed.",
471
+ "team-agent stop-agent worker\nteam-agent stop-agent worker --workspace . --team help-demo --json", "Prefer team-agent stop worker. Verify the stopped result; intentionally wake with team-agent start worker, then check status before sending new work."),
472
+ "reset-agent" => ("Compatibility entry requires explicit --discard-session. Stops the old process, clears the saved association and attempts fresh startup. Paused agents stay paused; not all native history is deleted.",
473
+ "team-agent reset-agent worker --discard-session\nteam-agent reset-agent worker --discard-session --workspace . --team help-demo --json", "Prefer team-agent agent reset worker --discard-session only after explicit context-loss approval. Never use reset as a resume-refusal fallback; check the actual result/status before sending new work."),
474
+ "clone-agent" => ("Compatibility entry: <agent> is the source; --as names the new agent; --label sets a readable label.\nCopies configuration, not a complete conversation.",
475
+ "team-agent clone-agent worker --as reviewer\nteam-agent clone-agent worker --as reviewer --label 'Review agent' --workspace .", "Prefer team-agent agent clone worker --as reviewer. Check the new agent with status, then send it an independent task; original capability gates still apply."),
476
+ "fork-agent" => ("Compatibility entry: <agent> is the source; --as names the new agent; --label sets a readable label.\nRequires captured context and admitted new-seat fork capability. Unsupported providers refuse without fallback.",
477
+ "team-agent fork-agent worker --as experiment\nteam-agent fork-agent worker --as experiment --workspace . --team help-demo --json", "Prefer team-agent agent fork worker --as experiment where admitted. Inspect status before sending work; finish with team-agent stop experiment or the explicit agent remove operation."),
478
+ "remove-agent" => ("Compatibility entry requires --confirm. Spec-defined agents also require --from-spec; dynamic agents do not.\nDeletes only the managed role copy, not external user files. Stop a running agent first.\n--force stops/removes a running agent only with explicit authorization and the original safety checks.",
479
+ "team-agent remove-agent reviewer --confirm\nteam-agent remove-agent reviewer --from-spec --confirm --workspace . --team help-demo --json", "Prefer team-agent agent remove reviewer --confirm with any required --from-spec. Save replies first and verify the resulting scope/status; do not force-clean other agents."),
471
480
  "leaders" => ("Lists live leaders by default; --all includes stale registrations and --stale selects stale entries.\nQUERY/--search filters workspace/team/name. --prune removes verified retired registrations; preview with --dry-run.",
472
481
  "team-agent leaders\nteam-agent leaders --all --json\nteam-agent leaders --prune --dry-run", "Select --workspace/--team from the reported scope. Registry cleanup is not process shutdown."),
473
482
  "doctor" => ("Read-only diagnosis by default; SPEC is an optional configuration path.\n--comms/--gate comms checks channels, not actual replies; --gate orphans checks residual resources.\nUse --fix/--fix-schema/--cleanup-orphans only within authorized scope. Dangerous repairs require --confirm.",
@@ -475,19 +484,19 @@ pub(super) fn command_help(command: Option<&str>) -> String {
475
484
  "approvals" => ("Optional <agent> selects one agent. Observes permission prompts without granting approval.",
476
485
  "team-agent approvals\nteam-agent approvals worker --json", "Verify the requested permission, handle it in the actual provider prompt, then check status."),
477
486
  "profile" => ("init creates a profile; doctor checks it; show displays settings. NAME is the profile name.\n--auth-mode selects authentication; --proxy-mode direct bypasses proxies and inherit uses the environment proxy.\nProfiles belong to the selected workspace, not automatically to an isolated --team. Do not expose credentials.",
478
- "team-agent profile init local --auth-mode subscription\nteam-agent profile doctor local\nteam-agent profile show local", "After validation, select --profile local with add-agent/start-agent. Use native provider sign-in when needed."),
487
+ "team-agent profile init local --auth-mode subscription\nteam-agent profile doctor local\nteam-agent profile show local", "After validation, use team-agent start AGENT --profile local; stop a running seat before changing settings. Use native sign-in when needed, never copy external credentials."),
479
488
  "install-skill" => ("--source must be a verified skill directory; --target selects codex/claude/copilot/all.\n--dest overrides the destination; --dry-run previews; --uninstall removes the target skill. Installation is not required to create a team.",
480
489
  "team-agent install-skill --source \"$SKILL_DIR\" --target codex --dry-run\nteam-agent install-skill --source \"$SKILL_DIR\" --target codex", "Set SKILL_DIR to a verified directory and inspect the preview before installation. Do not overwrite unknown user files."),
481
490
  "claim-leader" => ("Use only when doctor recommends leader registration and this terminal is the actual leader.\n--confirm authorizes registration; it does not open a new provider.",
482
491
  "team-agent claim-leader --workspace . --json\nteam-agent claim-leader --workspace . --team help-demo --confirm --json", "Verify doctor findings and terminal ownership first. A refusal without confirmation is not successful registration."),
483
- "takeover" => ("Use only when doctor recommends takeover and the user has authorized it. This changes leader ownership.\nWith a usable team terminal, takeover may execute even without --confirm; it is not a read-only preview. Use restart for normal recovery.",
492
+ "takeover" => ("Use only when doctor recommends takeover and the user has authorized it. This changes leader ownership.\nWith a usable team terminal, takeover may execute even without --confirm; it is not a read-only preview. Use team restart --session preserve for normal context-preserving recovery.",
484
493
  "team-agent takeover --workspace . --team help-demo --confirm\nteam-agent takeover --workspace . --team help-demo --confirm --json", "Verify leader/team ownership before confirmation. Check status/doctor afterwards; do not take over someone else's team."),
485
494
  "attach-leader" => ("PANE must be a verified leader terminal, not a guessed pane id.\n--provider selects the actual provider; --confirm authorizes attachment to an existing leader. Use only when doctor recommends it.",
486
495
  "team-agent attach-leader --pane \"$PANE\" --provider pi --workspace .\nteam-agent attach-leader --pane \"$PANE\" --provider pi --workspace . --team help-demo --confirm --json", "Set PANE to the verified terminal. Confirm attachment with doctor before sending a task."),
487
496
  "kiro" => ("Kiro leader launching is not admitted by this candidate. This entry returns the stable capability refusal kiro_leader_not_admitted (exit 1).\nNo native process, leader binding, attach or provider fallback is attempted. --help/-h is read-only and exits 0.",
488
- "team-agent kiro\nteam-agent kiro --json", "Use provider: kiro in a worker role with team-agent quick-start; worker support does not grant leader capability."),
497
+ "team-agent kiro\nteam-agent kiro --json", "Use provider: kiro in a complete worker role source with team-agent start --from TEAMDIR; worker support does not grant Leader capability."),
489
498
  _ => ("Install the provider and complete native sign-in first. The current directory is the workspace.\nUse --attach-existing/--confirm only after verifying leader ownership and receiving authorization; --attach-session selects a verified session.\n--external-leader selects an external leader; --allow-nested-attach requires explicit authorization for nested terminals.\nArguments after -- pass through unchanged. Bare --help/-h displays this help without starting the provider.",
490
- "", "Run team-agent quick-start in the opened leader's tool context, not in an ordinary worker conversation."),
499
+ "", "Prepare complete TEAM.md/roles with explicit user choices, then run team-agent start in the opened Leader's tool context, never an ordinary worker conversation."),
491
500
  };
492
501
  let examples = if matches!(spec.kind, CommandKind::LeaderPassthrough { .. }) {
493
502
  let native = if name == "cursor" { "agent" } else { name };
@@ -513,6 +522,9 @@ pub(super) fn command_help(command: Option<&str>) -> String {
513
522
  if name == "quick-start" {
514
523
  out.push_str(&format!("\n\nMinimal two-file configuration (create manually; not written automatically):\nTEAM.md:\n{TEAM_TEMPLATE}\nagents/worker.md:\n{WORKER_TEMPLATE}"));
515
524
  }
525
+ if name == "restart" {
526
+ out.push_str("\nRestart forms: restart AGENT selects one registered agent (default session auto); use agent restart ID to disambiguate a same-named directory. Legacy bare or explicit-path restart retains preserve and original receipts. New team restart defaults to auto. auto never means reset; native capability refusals remain authoritative.");
527
+ }
516
528
  if name == "cursor" {
517
529
  out.push_str("\nDiscover models with team-agent models --provider cursor_agent.");
518
530
  }
@@ -612,9 +624,14 @@ fn install_skill_args(args: &[String]) -> Result<InstallSkillArgs, CliError> {
612
624
 
613
625
  /// 取 `--flag <value>` 的值(用于 install-skill 的 --source/--dest,parse_args 不覆盖的旗标)。
614
626
  fn flag_value(args: &[String], flag: &str) -> Option<String> {
615
- args.iter()
616
- .position(|a| a == flag)
617
- .and_then(|i| args.get(i + 1).cloned())
627
+ for (index, argument) in args.iter().enumerate() {
628
+ if argument == "--" { break; }
629
+ if argument == flag { return args.get(index + 1).cloned(); }
630
+ if let Some((key, value)) = argument.split_once('=') {
631
+ if key == flag { return Some(value.to_string()); }
632
+ }
633
+ }
634
+ None
618
635
  }
619
636
 
620
637
  /// `team-agent install-skill`(RED-1 单源):repo `skills/team-agent` → `~/.codex|.claude|.copilot`。
@@ -685,7 +702,21 @@ fn resolve_path(path: &Path) -> PathBuf {
685
702
  std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
686
703
  }
687
704
 
705
+ fn emit_parser_usage(command: &str, args: &[String], error: &CliError) -> ExitCode {
706
+ if super::arguments::has_flag(command, args, "--json") {
707
+ let mut payload = error.to_payload(Path::new(""), command);
708
+ payload.action = format!("Use team-agent {command} --help to correct the command arguments.");
709
+ if let Ok(value) = serde_json::to_value(payload) {
710
+ println!("{}", python_compact_json(&crate::redaction::redact_external_value(&value)));
711
+ }
712
+ } else {
713
+ eprintln!("error: {}\nUse team-agent {command} --help.", crate::redaction::redact_external_text(&error.to_string()));
714
+ }
715
+ ExitCode::Usage
716
+ }
717
+
688
718
  fn emit_cli_error(command: &str, args: &[String], cwd: &Path, error: &CliError) -> ExitCode {
719
+ if matches!(error, CliError::Usage(_)) { return emit_parser_usage(command, args, error); }
689
720
  let parsed = parse_args(args);
690
721
  let workspace = workspace(&parsed, cwd);
691
722
  let log_path = cli_error_log_path(&workspace);
@@ -699,7 +730,7 @@ fn emit_cli_error(command: &str, args: &[String], cwd: &Path, error: &CliError)
699
730
  let mut payload = payload_error.to_payload(&log_path, command);
700
731
  payload.error = safe_error;
701
732
  if let CliError::AddressRefusal { error, action, .. } = payload_error {
702
- if !has_arg(args, "--json") {
733
+ if !super::arguments::has_flag(command, args, "--json") {
703
734
  payload.error = crate::redaction::redact_external_text(error);
704
735
  payload.action = action.clone();
705
736
  }
@@ -742,7 +773,7 @@ fn emit_cli_error(command: &str, args: &[String], cwd: &Path, error: &CliError)
742
773
  .map(|value| crate::redaction::redact_external_text(&value))
743
774
  .collect()
744
775
  });
745
- if has_arg(args, "--json") {
776
+ if super::arguments::has_flag(command, args, "--json") {
746
777
  if let Ok(value) = serde_json::to_value(payload) {
747
778
  println!("{}", python_compact_json(&value));
748
779
  }
@@ -820,7 +851,7 @@ fn python_compact_json(value: &Value) -> String {
820
851
  }
821
852
 
822
853
  fn has_arg(args: &[String], needle: &str) -> bool {
823
- args.iter().any(|arg| arg == needle)
854
+ args.iter().take_while(|arg| arg.as_str() != "--").any(|arg| arg == needle)
824
855
  }
825
856
 
826
857
  #[derive(Debug, Default)]
@@ -894,6 +925,19 @@ struct ParsedArgs {
894
925
  }
895
926
 
896
927
  fn parse_args(args: &[String]) -> ParsedArgs {
928
+ let mut expanded = Vec::new();
929
+ let mut literal = false;
930
+ for argument in args {
931
+ if argument == "--" { literal = true; }
932
+ if !literal && argument.starts_with("--") {
933
+ if let Some((flag, value)) = argument.split_once('=') {
934
+ expanded.extend([flag.to_string(), value.to_string()]);
935
+ continue;
936
+ }
937
+ }
938
+ expanded.push(argument.clone());
939
+ }
940
+ let args = expanded.as_slice();
897
941
  let mut parsed = ParsedArgs::default();
898
942
  let mut i = 0usize;
899
943
  while i < args.len() {
@@ -901,6 +945,10 @@ fn parse_args(args: &[String]) -> ParsedArgs {
901
945
  break;
902
946
  };
903
947
  match arg.as_str() {
948
+ "--" => {
949
+ parsed.positionals.extend_from_slice(&args[i + 1..]);
950
+ break;
951
+ }
904
952
  "--workspace" => {
905
953
  parsed.workspace = next_arg(args, &mut i).map(PathBuf::from);
906
954
  }
@@ -1022,7 +1070,7 @@ fn next_arg(args: &[String], index: &mut usize) -> Option<String> {
1022
1070
  args.get(*index).cloned()
1023
1071
  }
1024
1072
 
1025
- fn parse_seconds_ms(raw: &str) -> Option<u64> {
1073
+ pub(super) fn parse_seconds_ms(raw: &str) -> Option<u64> {
1026
1074
  let seconds = raw.parse::<f64>().ok()?;
1027
1075
  if seconds.is_finite() && seconds >= 0.0 {
1028
1076
  Some((seconds * 1000.0).round() as u64)
@@ -1031,10 +1079,54 @@ fn parse_seconds_ms(raw: &str) -> Option<u64> {
1031
1079
  }
1032
1080
  }
1033
1081
 
1034
- fn parse_cli_provider(raw: Option<&str>) -> Result<crate::provider::Provider, CliError> {
1035
- let raw = raw.unwrap_or("codex");
1082
+ fn parse_cli_provider(raw: &str) -> Result<crate::provider::Provider, CliError> {
1036
1083
  serde_json::from_value::<crate::provider::Provider>(serde_json::json!(raw))
1037
- .map_err(|_| CliError::Runtime(format!("unknown provider: {raw}")))
1084
+ .map_err(|_| CliError::Usage(format!("unknown provider: {raw}")))
1085
+ }
1086
+
1087
+ pub(super) fn explicit_path(value: &str) -> bool {
1088
+ value == "." || value == ".." || value.contains('/') || value.contains('\\')
1089
+ || Path::new(value).is_absolute()
1090
+ || (value.as_bytes().get(1) == Some(&b':') && value.as_bytes().first().is_some_and(u8::is_ascii_alphabetic))
1091
+ }
1092
+
1093
+ fn checked_workspace(path: &Path, cwd: &Path) -> Result<PathBuf, CliError> {
1094
+ let path = resolve_cli_path(cwd, path);
1095
+ let canonical = std::fs::canonicalize(&path).map_err(|_| CliError::Usage(format!(
1096
+ "workspace does not exist or is unavailable: {}", path.display()
1097
+ )))?;
1098
+ if !canonical.is_dir() {
1099
+ return Err(CliError::Usage(format!("workspace is not a directory: {}", path.display())));
1100
+ }
1101
+ Ok(canonical)
1102
+ }
1103
+
1104
+ fn resolve_command_scope(command: &str, mut args: Vec<String>, cwd: &Path) -> Result<Vec<String>, CliError> {
1105
+ let mut selected = None;
1106
+ for argument in &mut args {
1107
+ if argument == "--" { break; }
1108
+ if let Some(path) = argument.strip_prefix("--workspace=") {
1109
+ let canonical = checked_workspace(Path::new(path), cwd)?;
1110
+ *argument = format!("--workspace={}", canonical.display());
1111
+ selected = Some(canonical);
1112
+ }
1113
+ }
1114
+ if command == "restart" {
1115
+ let index = args.iter().position(|arg| arg == "--")
1116
+ .and_then(|index| (index + 1 < args.len()).then_some(index + 1))
1117
+ .or_else(|| args.iter().position(|arg| !arg.starts_with('-')));
1118
+ if let Some(index) = index {
1119
+ if !explicit_path(&args[index]) {
1120
+ return Err(CliError::Usage("bare restart targets are ambiguous; use restart --workspace PATH or restart ./PATH for a team".to_string()));
1121
+ }
1122
+ let canonical = checked_workspace(Path::new(&args[index]), cwd)?;
1123
+ if selected.as_ref().is_some_and(|selected| selected != &canonical) {
1124
+ return Err(CliError::Usage("restart WORKSPACE conflicts with --workspace".to_string()));
1125
+ }
1126
+ args[index] = canonical.to_string_lossy().into_owned();
1127
+ }
1128
+ }
1129
+ Ok(args)
1038
1130
  }
1039
1131
 
1040
1132
  fn workspace(parsed: &ParsedArgs, cwd: &Path) -> PathBuf {
@@ -1279,6 +1371,7 @@ fn warn_send_legacy_delivery_flags(args: &[String]) {
1279
1371
  for flag in FLAGS {
1280
1372
  if args
1281
1373
  .iter()
1374
+ .take_while(|arg| arg.as_str() != "--")
1282
1375
  .any(|arg| arg == flag || arg.starts_with(&format!("{flag}=")))
1283
1376
  {
1284
1377
  eprintln!(
@@ -1289,52 +1382,7 @@ fn warn_send_legacy_delivery_flags(args: &[String]) {
1289
1382
  }
1290
1383
 
1291
1384
  fn validate_send_flags(args: &[String]) -> Result<(), CliError> {
1292
- const ALLOWED: &[&str] = &[
1293
- "--workspace",
1294
- "--team",
1295
- "--targets",
1296
- "--target",
1297
- "--to",
1298
- "--to-name",
1299
- "--to-leader",
1300
- "--pane",
1301
- "--task",
1302
- "--watch-result",
1303
- "--requires-ack",
1304
- "--no-ack",
1305
- "--no-wait",
1306
- "--timeout",
1307
- "--confirm-human",
1308
- "--message-id",
1309
- "--mailbox",
1310
- "--presentation-sink",
1311
- "--message-class",
1312
- "--case-id",
1313
- "--json",
1314
- "-h",
1315
- "--help",
1316
- ];
1317
- const ALLOWED_PREFIXES: &[&str] = &[
1318
- "--team=",
1319
- "--pane=",
1320
- "--to-name=",
1321
- "--to-leader=",
1322
- "--presentation-sink=",
1323
- "--message-class=",
1324
- "--case-id=",
1325
- ];
1326
- if let Some(flag) = args.iter().find(|arg| {
1327
- arg.starts_with('-')
1328
- && !ALLOWED.contains(&arg.as_str())
1329
- && !ALLOWED_PREFIXES
1330
- .iter()
1331
- .any(|prefix| arg.starts_with(prefix))
1332
- }) {
1333
- return Err(CliError::Usage(format!(
1334
- "unrecognized argument for `send`: {flag}"
1335
- )));
1336
- }
1337
- Ok(())
1385
+ super::arguments::parse("send", args).map(|_| ())
1338
1386
  }
1339
1387
 
1340
1388
  /// Stage 4 of identity-boundary unified plan (architect direction
@@ -1445,7 +1493,9 @@ fn attach_leader_args(args: &[String], cwd: &Path) -> Result<AttachLeaderArgs, C
1445
1493
  .pane
1446
1494
  .filter(|pane| !pane.is_empty())
1447
1495
  .map(crate::transport::PaneId::new),
1448
- provider: parse_cli_provider(parsed.provider.as_deref())?,
1496
+ provider: parse_cli_provider(parsed.provider.as_deref()
1497
+ .filter(|provider| !provider.trim().is_empty())
1498
+ .ok_or_else(|| CliError::Usage("attach-leader requires --provider <name>".to_string()))?)?,
1449
1499
  confirm: parsed.confirm,
1450
1500
  json: parsed.json,
1451
1501
  })
@@ -1485,11 +1535,12 @@ fn shutdown_args(args: &[String], cwd: &Path) -> Result<ShutdownArgs, CliError>
1485
1535
 
1486
1536
  fn restart_args(args: &[String], cwd: &Path) -> Result<RestartArgs, CliError> {
1487
1537
  let parsed = parse_args(args);
1488
- let workspace = parsed
1489
- .positionals
1490
- .first()
1491
- .map(PathBuf::from)
1492
- .unwrap_or_else(|| workspace(&parsed, cwd));
1538
+ let workspace = if let Some(path) = parsed.positionals.first() {
1539
+ if !explicit_path(path) {
1540
+ return Err(CliError::Usage("bare restart targets are ambiguous; use restart --workspace PATH or restart ./PATH for a team".to_string()));
1541
+ }
1542
+ resolve_cli_path(cwd, Path::new(path))
1543
+ } else { workspace(&parsed, cwd) };
1493
1544
  refuse_if_multi_alive_team_missing_scope("restart", &workspace, parsed.team.as_deref())?;
1494
1545
  Ok(RestartArgs {
1495
1546
  workspace,
@@ -1502,6 +1553,41 @@ fn restart_args(args: &[String], cwd: &Path) -> Result<RestartArgs, CliError> {
1502
1553
  }
1503
1554
 
1504
1555
  // Keep strict role CLI parsing separate from the legacy shared parser.
1556
+ pub(super) fn intent_source_workspace(source: &Path, cwd: &Path) -> Result<PathBuf, CliError> {
1557
+ quick_start_args(&[source.to_string_lossy().into_owned()], cwd).map(|args| args.workspace)
1558
+ }
1559
+
1560
+ pub(super) fn intent_role_patch(args: &[String]) -> Result<crate::lifecycle::role_config::RoleConfigPatch, CliError> {
1561
+ role_agent_args(args, false).map(|(_, patch)| patch)
1562
+ }
1563
+
1564
+ /// Explicit object aliases reuse the same syntax checks, typed builders and handlers.
1565
+ pub(super) fn intent_alias(command: &str, args: &[String], cwd: &Path) -> Result<CmdResult, CliError> {
1566
+ let mut checked = prepare_control_args(command, args, cwd)?;
1567
+ if checked.help { return Ok(CmdResult::human(command_help(Some(command)))); }
1568
+ for token in &mut checked.tokens {
1569
+ if let Some(path) = token.strip_prefix("--role-file=") {
1570
+ let path = Path::new(path);
1571
+ let path = if path.is_absolute() { path.to_path_buf() } else { cwd.join(path) };
1572
+ let path = path.canonicalize().map_err(|e| CliError::Usage(format!("invalid explicit role file: {e}")))?;
1573
+ *token = format!("--role-file={}", path.display());
1574
+ }
1575
+ }
1576
+ let args = checked.tokens.as_slice();
1577
+ match command {
1578
+ "status" => {
1579
+ let parsed = status_args(args, cwd);
1580
+ cmd_status_for_team(&parsed, parsed.team.as_deref())
1581
+ }
1582
+ "add-agent" => cmd_add_agent(&add_agent_args(args, cwd)?),
1583
+ "remove-agent" => cmd_remove_agent(&remove_agent_args(args, cwd)?),
1584
+ "reset-agent" => cmd_reset_agent(&reset_agent_args(args, cwd)?),
1585
+ "clone-agent" => cmd_clone_agent(&clone_agent_args(args, cwd)?),
1586
+ "fork-agent" => cmd_fork_agent(&fork_agent_args(args, cwd)?),
1587
+ _ => Err(CliError::Usage("unsupported lifecycle alias".into())),
1588
+ }
1589
+ }
1590
+
1505
1591
  fn role_agent_args(
1506
1592
  args: &[String],
1507
1593
  add: bool,
@@ -1551,7 +1637,7 @@ fn role_agent_args(
1551
1637
  } else {
1552
1638
  index += 1;
1553
1639
  args.get(index)
1554
- .filter(|value| !value.starts_with("--"))
1640
+ .filter(|value| flag == "--prompt" || !value.starts_with("--"))
1555
1641
  .ok_or_else(|| CliError::Usage(format!("missing value for {flag}")))?
1556
1642
  .clone()
1557
1643
  };
@@ -1671,6 +1757,9 @@ mod role_cli_tests {
1671
1757
 
1672
1758
  fn start_agent_args(args: &[String], cwd: &Path) -> Result<StartAgentArgs, CliError> {
1673
1759
  let (parsed, role_config) = role_agent_args(args, false)?;
1760
+ if parsed.force {
1761
+ return Err(CliError::Usage("start-agent does not support --force; use stop-agent AGENT followed by start-agent AGENT for a safe restart".to_string()));
1762
+ }
1674
1763
  Ok(StartAgentArgs {
1675
1764
  role_config,
1676
1765
  agent: required_pos(&parsed, 0, "agent")?,
@@ -2100,8 +2189,8 @@ mod tests {
2100
2189
  .collect();
2101
2190
  assert_eq!(
2102
2191
  expected.len(),
2103
- 31,
2104
- "the public catalog includes Leader prompt management and the Kiro capability gate"
2192
+ 35,
2193
+ "the public catalog adds the four canonical lifecycle surfaces"
2105
2194
  );
2106
2195
  for spec_name in &expected {
2107
2196
  assert!(
@@ -2554,11 +2643,15 @@ mod tests {
2554
2643
  #[test]
2555
2644
  fn send_pane_still_rejects_to_and_empty_message() {
2556
2645
  let cwd = tmp_workspace();
2557
- let args = send_args(
2646
+ let err = send_args(
2558
2647
  &cli_argv(&["--pane", "%1596", "--to", "worker", "hello"]),
2559
2648
  &cwd,
2560
- )
2561
- .unwrap();
2649
+ ).unwrap_err();
2650
+ assert!(matches!(err, CliError::Usage(ref message) if message.contains("one recipient addressing mode")));
2651
+ // The public parser now refuses first; the typed handler must still
2652
+ // reject the same conflict if a non-CLI caller constructs it directly.
2653
+ let mut args = send_args(&cli_argv(&["--pane", "%1596", "hello"]), &cwd).unwrap();
2654
+ args.targets = Some("worker".to_string());
2562
2655
  let err = cmd_send(&args).unwrap_err();
2563
2656
  assert!(
2564
2657
  matches!(err, CliError::Usage(ref message) if message.contains("--pane") && message.contains("--to") && message.contains("cannot") && message.contains("combined")),
@@ -2630,7 +2723,9 @@ mod tests {
2630
2723
  // 'statu' typo → status; 'add-agen' → add-agent.
2631
2724
  assert_eq!(nearest_subcommand("statu"), Some("status"));
2632
2725
  assert_eq!(nearest_subcommand("add-agen"), Some("add-agent"));
2633
- assert_eq!(nearest_subcommand("start-agnet"), Some("start-agent"));
2726
+ // Presentation migration: the new canonical short verb is now a
2727
+ // prefix candidate in the unchanged shared similarity ranking.
2728
+ assert_eq!(nearest_subcommand("start-agnet"), Some("start"));
2634
2729
  }
2635
2730
 
2636
2731
  #[test]
@@ -2766,3 +2861,7 @@ mod tests {
2766
2861
  // without it.
2767
2862
  }
2768
2863
  }
2864
+
2865
+ #[cfg(test)]
2866
+ #[path = "tests/parser_safety.rs"]
2867
+ mod parser_safety_tests;