@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
@@ -0,0 +1,383 @@
1
+ //! Public Phase-0 parser regressions. No model, real team or external pane is used.
2
+ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
3
+ use super::*;
4
+ use std::sync::atomic::{AtomicU64, Ordering};
5
+
6
+ fn strings(args: &[&str]) -> Vec<String> {
7
+ args.iter().map(|arg| (*arg).to_string()).collect()
8
+ }
9
+
10
+ struct Sandbox(PathBuf);
11
+ impl Sandbox {
12
+ fn new() -> Self {
13
+ static NEXT: AtomicU64 = AtomicU64::new(0);
14
+ let dir = std::env::temp_dir().join(format!(
15
+ "ta-cli-parser334-{}-{}",
16
+ std::process::id(),
17
+ NEXT.fetch_add(1, Ordering::Relaxed)
18
+ ));
19
+ std::fs::create_dir(&dir).unwrap();
20
+ Self(dir)
21
+ }
22
+ fn reject(&self, args: &[&str]) {
23
+ assert_eq!(run(&strings(args), &self.0), ExitCode::Usage, "{args:?}");
24
+ assert!(
25
+ std::fs::read_dir(&self.0).unwrap().next().is_none(),
26
+ "parser errors must not create state, logs, roles or a database: {args:?}"
27
+ );
28
+ }
29
+ }
30
+ impl Drop for Sandbox {
31
+ fn drop(&mut self) {
32
+ let _ = std::fs::remove_dir_all(&self.0);
33
+ }
34
+ }
35
+
36
+ #[test]
37
+ fn p1_read_and_lifecycle_unknown_flags_are_usage_without_writes() {
38
+ for command in [
39
+ "status",
40
+ "shutdown",
41
+ "restart",
42
+ "approvals",
43
+ "doctor",
44
+ "models",
45
+ ] {
46
+ let sandbox = Sandbox::new();
47
+ sandbox.reject(&[command, "--worksapce", "never-selected", "--json"]);
48
+ }
49
+ }
50
+
51
+ #[test]
52
+ fn p1_missing_duplicate_extra_and_invalid_numeric_arguments_refuse() {
53
+ for args in [
54
+ vec!["status", "--workspace"],
55
+ vec!["status", "--workspace", "--team", "x"],
56
+ vec!["status", "--team=x", "--team", "y"],
57
+ vec!["status", "worker", "extra"],
58
+ vec!["shutdown", "extra"],
59
+ vec!["inbox", "leader", "--limit", "NaN"],
60
+ vec!["inbox", "leader", "-n", "3", "--limit=4"],
61
+ vec!["restart", "--session-converge-deadline=inf"],
62
+ vec!["status", "--detail", "--summary"],
63
+ ] {
64
+ Sandbox::new().reject(&args);
65
+ }
66
+ }
67
+
68
+ #[test]
69
+ fn p2_equal_and_split_values_have_identical_fields_without_losing_equals() {
70
+ let sandbox = Sandbox::new();
71
+ let path = sandbox.0.join("space = directory");
72
+ std::fs::create_dir(&path).unwrap();
73
+ let path = path.to_string_lossy();
74
+ let split = parse_args(&strings(&[
75
+ "--workspace",
76
+ &path,
77
+ "--name",
78
+ "x=y",
79
+ "--timeout",
80
+ "1.25",
81
+ ]));
82
+ let inline = parse_args(&strings(&[
83
+ &format!("--workspace={path}"),
84
+ "--name=x=y",
85
+ "--timeout=1.25",
86
+ ]));
87
+ assert_eq!(inline.workspace, split.workspace);
88
+ assert_eq!(inline.name, split.name);
89
+ assert_eq!(inline.timeout, split.timeout);
90
+ for path in [r"C:\owned dir\a=b", r"\\server\share\a=b"] {
91
+ assert_eq!(
92
+ parse_args(&strings(&[&format!("--workspace={path}")])).workspace,
93
+ Some(PathBuf::from(path))
94
+ );
95
+ }
96
+ }
97
+
98
+ #[test]
99
+ fn p2_missing_explicit_workspace_never_falls_back_to_cwd_or_writes_logs() {
100
+ for inline in [false, true] {
101
+ let sandbox = Sandbox::new();
102
+ let missing = sandbox.0.join("missing=a b").to_string_lossy().to_string();
103
+ let mut args = vec!["status".to_string()];
104
+ if inline {
105
+ args.push(format!("--workspace={missing}"));
106
+ } else {
107
+ args.extend(["--workspace".to_string(), missing]);
108
+ }
109
+ assert_eq!(run(&args, &sandbox.0), ExitCode::Usage);
110
+ assert!(std::fs::read_dir(&sandbox.0).unwrap().next().is_none());
111
+ }
112
+ }
113
+
114
+ #[test]
115
+ fn p3_bare_restart_is_never_interpreted_as_workspace_even_if_directory_exists() {
116
+ for exists in [false, true] {
117
+ let sandbox = Sandbox::new();
118
+ if exists {
119
+ std::fs::create_dir(sandbox.0.join("worker")).unwrap();
120
+ }
121
+ assert!(matches!(
122
+ restart_args(&strings(&["worker"]), &sandbox.0),
123
+ Err(CliError::Usage(_))
124
+ ));
125
+ assert!(matches!(
126
+ restart_args(&strings(&["reviewer.v2"]), &sandbox.0),
127
+ Err(CliError::Usage(_))
128
+ ));
129
+ }
130
+ }
131
+
132
+ #[test]
133
+ fn p4_attach_requires_explicit_nonempty_provider_but_keeps_declared_identity() {
134
+ let sandbox = Sandbox::new();
135
+ for args in [vec![], vec!["--provider="], vec!["--provider", ""]] {
136
+ assert!(matches!(
137
+ attach_leader_args(&strings(&args), &sandbox.0),
138
+ Err(CliError::Usage(_))
139
+ ));
140
+ }
141
+ for provider in ["pi", "claude", "codex"] {
142
+ let expected = parse_cli_provider(provider).unwrap();
143
+ assert_eq!(
144
+ attach_leader_args(&strings(&["--provider", provider]), &sandbox.0)
145
+ .unwrap()
146
+ .provider,
147
+ expected
148
+ );
149
+ }
150
+ }
151
+
152
+ #[test]
153
+ fn p5_start_force_refuses_and_guides_to_existing_stop_start_commands() {
154
+ let sandbox = Sandbox::new();
155
+ match start_agent_args(&strings(&["worker", "--force"]), &sandbox.0) {
156
+ Err(CliError::Usage(message)) => {
157
+ assert!(
158
+ message.contains("stop-agent") && message.contains("start-agent"),
159
+ "{message}"
160
+ );
161
+ }
162
+ _ => panic!("--force must not be accepted and then silently discarded"),
163
+ }
164
+ }
165
+
166
+ #[test]
167
+ fn payload_delimiter_preserves_help_no_display_and_equal_bytes() {
168
+ let parsed = parse_args(&strings(&[
169
+ "worker",
170
+ "--",
171
+ "--help",
172
+ "--no-display",
173
+ "--workspace=a=b",
174
+ ]));
175
+ assert_eq!(
176
+ parsed.positionals,
177
+ strings(&["worker", "--help", "--no-display", "--workspace=a=b"])
178
+ );
179
+ assert!(parsed.workspace.is_none());
180
+ let literal = strings(&["--", "--help", "--no-display", "--workspace=a=b"]);
181
+ assert_eq!(
182
+ leader_launcher_args(&literal).unwrap().provider_args,
183
+ literal[1..]
184
+ );
185
+ }
186
+
187
+ #[test]
188
+ fn consumed_prompt_help_does_not_trigger_global_help_or_scope_fallback() {
189
+ let sandbox = Sandbox::new();
190
+ sandbox.reject(&[
191
+ "add-agent",
192
+ "worker",
193
+ "--prompt",
194
+ "--help",
195
+ "--provider",
196
+ "pi",
197
+ "--bypass",
198
+ "false",
199
+ "--workspace",
200
+ "missing",
201
+ ]);
202
+ }
203
+
204
+ #[test]
205
+ fn real_control_help_remains_an_effect_free_short_circuit() {
206
+ let sandbox = Sandbox::new();
207
+ assert_eq!(
208
+ run(&strings(&["status", "--unknown", "--help"]), &sandbox.0),
209
+ ExitCode::Ok
210
+ );
211
+ assert!(std::fs::read_dir(&sandbox.0).unwrap().next().is_none());
212
+ }
213
+
214
+ #[test]
215
+ fn every_dispatch_command_has_a_closed_control_schema() {
216
+ for spec in COMMAND_SPECS {
217
+ if !matches!(spec.kind, CommandKind::Dispatch(_))
218
+ || matches!(spec.name, "route" | "leader-prompt")
219
+ {
220
+ continue;
221
+ }
222
+ // Family grammars select the concrete operation before its closed
223
+ // schema; the family itself has no permissive catch-all flag parser.
224
+ let schema = if matches!(spec.name, "team" | "agent") {
225
+ "intent-start"
226
+ } else {
227
+ spec.name
228
+ };
229
+ let checked = super::super::arguments::parse(schema, &strings(&["--help"])).unwrap();
230
+ assert!(checked.help, "{} must have a schema", spec.name);
231
+ let compatibility =
232
+ super::super::arguments::parse(schema, &strings(&["--no-display"])).unwrap();
233
+ assert!(
234
+ compatibility.tokens.is_empty(),
235
+ "explicit legacy no-display control: {}",
236
+ spec.name
237
+ );
238
+ assert!(super::super::arguments::parse(schema, &strings(&["--unknown"])).is_err());
239
+ }
240
+ for command in ["wait", "coordinator", "attach-app-server-leader"] {
241
+ assert!(
242
+ super::super::arguments::parse(command, &strings(&["--workspace", "--once"])).is_err()
243
+ );
244
+ }
245
+ }
246
+
247
+ #[test]
248
+ fn canonical_scope_equal_and_split_share_one_directory_and_reject_conflicts() {
249
+ let sandbox = Sandbox::new();
250
+ let selected = sandbox.0.join("scope=a b");
251
+ std::fs::create_dir(&selected).unwrap();
252
+ let canonical = std::fs::canonicalize(&selected).unwrap();
253
+ for args in [
254
+ strings(&["--workspace", "scope=a b"]),
255
+ strings(&["--workspace=scope=a b"]),
256
+ ] {
257
+ let checked = super::super::arguments::parse("status", &args).unwrap();
258
+ let args = resolve_command_scope("status", checked.tokens, &sandbox.0).unwrap();
259
+ assert_eq!(parse_args(&args).workspace, Some(canonical.clone()));
260
+ }
261
+ let args =
262
+ super::super::arguments::parse("restart", &strings(&["./scope=a b", "--workspace=."]))
263
+ .unwrap();
264
+ assert!(matches!(
265
+ resolve_command_scope("restart", args.tokens, &sandbox.0),
266
+ Err(CliError::Usage(_))
267
+ ));
268
+ let args = super::super::arguments::parse(
269
+ "restart",
270
+ &strings(&["./scope=a b", "--workspace=scope=a b"]),
271
+ )
272
+ .unwrap();
273
+ let args = resolve_command_scope("restart", args.tokens, &sandbox.0).unwrap();
274
+ assert_eq!(
275
+ restart_args(&args, &sandbox.0).unwrap().workspace,
276
+ canonical
277
+ );
278
+ }
279
+
280
+ #[test]
281
+ fn payload_help_is_not_control_and_compatibility_flags_do_not_delete_literals() {
282
+ let checked = super::super::arguments::parse(
283
+ "add-agent",
284
+ &strings(&["w", "--provider=pi", "--bypass=false", "--prompt", "--help"]),
285
+ )
286
+ .unwrap();
287
+ assert!(!checked.help);
288
+ let (_, patch) = role_agent_args(&checked.tokens, true).unwrap();
289
+ assert_eq!(patch.prompt.as_deref(), Some("--help"));
290
+ let checked = super::super::arguments::parse(
291
+ "send",
292
+ &strings(&["w", "--", "--help", "--no-display", "--workspace=a=b"]),
293
+ )
294
+ .unwrap();
295
+ assert!(!checked.help);
296
+ assert_eq!(
297
+ parse_args(&checked.tokens).positionals,
298
+ strings(&["w", "--help", "--no-display", "--workspace=a=b"])
299
+ );
300
+ let checked = super::super::arguments::parse(
301
+ "quick-start",
302
+ &strings(&["--no-display", "--", "--no-display"]),
303
+ )
304
+ .unwrap();
305
+ assert_eq!(checked.tokens, strings(&["--", "--no-display"]));
306
+ assert!(super::super::arguments::parse("status", &strings(&["--yes"])).is_err());
307
+ assert!(!super::super::arguments::has_flag(
308
+ "add-agent",
309
+ &strings(&["--prompt", "--json"]),
310
+ "--json"
311
+ ));
312
+ }
313
+
314
+ #[test]
315
+ fn restart_node_directory_phase1_migration_refuses_without_mutating_state() {
316
+ for agent in [false, true] {
317
+ for directory in [false, true] {
318
+ let sandbox = Sandbox::new();
319
+ let runtime = sandbox.0.join(".team/runtime");
320
+ std::fs::create_dir_all(&runtime).unwrap();
321
+ let state = if agent {
322
+ r#"{"agents":{"worker":{"status":"stopped"}}}"#
323
+ } else {
324
+ r#"{"agents":{}}"#
325
+ };
326
+ std::fs::write(runtime.join("state.json"), state).unwrap();
327
+ if directory {
328
+ std::fs::create_dir(sandbox.0.join("worker")).unwrap();
329
+ }
330
+ assert_eq!(
331
+ run(&strings(&["restart", "worker"]), &sandbox.0),
332
+ // P3 Phase1: a plain ID is an agent intent; same-name directory
333
+ // remains a syntax ambiguity. Incomplete runtime still refuses.
334
+ if directory {
335
+ ExitCode::Usage
336
+ } else {
337
+ ExitCode::Error
338
+ }
339
+ );
340
+ assert_eq!(
341
+ std::fs::read_to_string(runtime.join("state.json")).unwrap(),
342
+ state
343
+ );
344
+ // Canonical planning refusals are effect-free envelopes; legacy
345
+ // restart diagnostics remain on their frozen entry point.
346
+ assert!(!sandbox.0.join(".team/logs").exists());
347
+ }
348
+ }
349
+ }
350
+
351
+ #[test]
352
+ fn text_queries_and_native_rendering_switches_keep_literal_boundaries() {
353
+ for backend in ["tmux", "TMUX", " conpty "] {
354
+ let input = format!("--backend={backend}");
355
+ let checked = super::super::arguments::parse("quick-start", &[input]).unwrap();
356
+ let parsed = quick_start_args(&checked.tokens, Path::new(".")).unwrap();
357
+ assert_eq!(parsed.backend.as_deref(), Some(backend));
358
+ }
359
+ for query in ["", " ", "--help", "a=b"] {
360
+ let checked =
361
+ super::super::arguments::parse("models", &strings(&[&format!("--search={query}")]))
362
+ .unwrap();
363
+ assert!(!checked.help);
364
+ let parsed = models_args(&checked.tokens).unwrap();
365
+ assert_eq!(
366
+ parsed.search.as_deref(),
367
+ (!query.trim().is_empty()).then_some(query)
368
+ );
369
+ }
370
+ let checked =
371
+ super::super::arguments::parse("models", &strings(&["--search", "--help"])).unwrap();
372
+ assert!(!checked.help);
373
+ assert!(super::super::arguments::has_flag(
374
+ "pi",
375
+ &strings(&["--json", "--", "--help"]),
376
+ "--json"
377
+ ));
378
+ assert!(!super::super::arguments::has_flag(
379
+ "pi",
380
+ &strings(&["--", "--json"]),
381
+ "--json"
382
+ ));
383
+ }
@@ -1,5 +1,8 @@
1
1
  use super::*;
2
2
 
3
+ #[path = "../../../tests/support/hermetic.rs"]
4
+ mod hermetic_guard;
5
+
3
6
  struct TeamSocketGuard {
4
7
  ws: std::path::PathBuf,
5
8
  }
@@ -212,10 +215,16 @@ fn run_dispatches_quick_start_compiles_spec() {
212
215
  // coordinator/health.rs, message_store.
213
216
  // =========================================================================
214
217
  #[test]
215
- fn cli_quick_start_invokes_real_lifecycle_compiles_spec() {
216
- let team = deleg_team_dir_with_healthy_coordinator();
217
- let ws = crate::model::paths::team_workspace(&team).unwrap();
218
- let _guard = TeamSocketGuard { ws };
218
+ #[serial_test::serial(env)]
219
+ fn cli_quick_start_invokes_real_lifecycle_admission_before_runtime_effects() {
220
+ let env = hermetic_guard::HermeticTestEnv::enter("issue337-cli-admission");
221
+ env.scrub_tmux();
222
+ env.assert_no_real_tmux();
223
+ let workspace = env.workspace("ws");
224
+ let team = workspace.join("roles");
225
+ std::fs::create_dir_all(team.join("agents")).unwrap();
226
+ std::fs::write(team.join("TEAM.md"), DELEG_TEAM_MD).unwrap();
227
+ std::fs::write(team.join("agents/implementer.md"), DELEG_VALID_ROLE).unwrap();
219
228
  let args = QuickStartArgs {
220
229
  workspace: crate::model::paths::team_workspace(&team).unwrap(),
221
230
  agents_dir: team.clone(),
@@ -226,16 +235,17 @@ fn cli_quick_start_invokes_real_lifecycle_compiles_spec() {
226
235
  json: true,
227
236
  detail: false,
228
237
  };
229
- let _ = cmd_quick_start(&args); // real quick_start compiles the spec before any coordinator/launch step
230
- // E5: spec compiled to .team/runtime/<team_key>/, NOT the user team dir.
231
- let team_key = "clidelegteam";
232
- let workspace = crate::model::paths::team_workspace(&team).unwrap();
233
- let runtime_spec = crate::model::paths::runtime_spec_path(&workspace, &team_key);
234
- assert!(
235
- runtime_spec.exists(),
236
- "cmd_quick_start must delegate to crate::lifecycle::quick_start, which compiles team.spec.yaml \
237
- under .team/runtime/<team_key>/; the placeholder never writes it"
238
- );
238
+ let result = cmd_quick_start(&args).expect("real lifecycle admission");
239
+ assert_eq!(result.exit, ExitCode::Error);
240
+ let CmdOutput::Json(value) = result.output else { panic!() };
241
+ assert_eq!(value["status"], "preflight_blocked");
242
+ assert!(value["blockers"].to_string().contains("caller_pane_missing"));
243
+ assert_eq!(value["effects"]["worker_panes_started"], json!([]));
244
+ assert_eq!(value["effects"]["coordinator_start"], "not_attempted");
245
+ let runtime_spec = crate::model::paths::runtime_spec_path(&args.workspace, "clidelegteam");
246
+ assert!(!runtime_spec.exists(), "#337 admission precedes runtime spec publication");
247
+ assert!(!crate::state::persist::runtime_state_path(&args.workspace).exists());
248
+ assert!(!args.workspace.join(".team/team.db").exists());
239
249
  assert!(
240
250
  !team.join("team.spec.yaml").exists(),
241
251
  "E5: quick_start must NOT write spec into the user team dir"
@@ -333,12 +343,10 @@ fn cli_add_agent_duplicate_id_surfaces_real_error() {
333
343
  );
334
344
  }
335
345
 
336
- // 5 [P1] — cmd_status --json emits the concise nine-field node projection.
337
- // Status must not leak coordinator/db/history diagnostics; missing exact nodeprobe evidence remains
338
- // an explicit unknown projection. The fixture still seeds a real store to ensure the status path
339
- // does not accidentally depend on coordinator or mailbox reads.
346
+ // Nine base fields remain; #338 adds only observation reason and scoped action
347
+ // when uncertain. No coordinator/db/history/payload data enters the node.
340
348
  #[test]
341
- fn cli_status_emits_nine_field_brief_without_diagnostics() {
349
+ fn cli_status_emits_brief_with_scoped_unknown_diagnostics() {
342
350
  let ws = seed_status_workspace(); // writes .team/runtime/state.json
343
351
  let store = crate::message_store::MessageStore::open(&ws).unwrap();
344
352
  let _ = store
@@ -406,10 +414,46 @@ fn cli_status_emits_nine_field_brief_without_diagnostics() {
406
414
  "health",
407
415
  "session_name",
408
416
  "tmux_command",
417
+ "observation_reason",
418
+ "next_action",
409
419
  ]
410
420
  .into_iter()
411
421
  .collect()
412
422
  );
423
+ assert_eq!(nodes[0]["runtime_status"], "unknown");
424
+ assert!(!nodes[0]["observation_reason"].as_str().unwrap().is_empty());
425
+ assert!(nodes[0]["next_action"].as_str().unwrap().contains(&*args.workspace.to_string_lossy()));
426
+ }
427
+
428
+ #[test]
429
+ #[serial_test::serial(env)]
430
+ fn issue338_doctor_reports_same_scope_unknown_observations_without_state_writes() {
431
+ let env = hermetic_guard::HermeticTestEnv::enter("issue338-doctor");
432
+ env.scrub_tmux();
433
+ env.assert_no_real_tmux();
434
+ let workspace = env.workspace("ws");
435
+ crate::state::persist::save_runtime_state(&workspace, &json!({
436
+ "team_key": "current", "session_name": "test-workers",
437
+ "agents": {"worker": {"provider": "pi", "status": "running"}},
438
+ })).unwrap();
439
+ // Complete the existing legacy-to-current state normalization as fixture
440
+ // preparation; this test covers observation of an already-current runtime.
441
+ crate::state::persist::load_runtime_state(&workspace).unwrap();
442
+ let state_path = crate::state::persist::runtime_state_path(&workspace);
443
+ let before = std::fs::read(&state_path).unwrap();
444
+ let result = cmd_doctor(&DoctorArgs {
445
+ spec: None, workspace: workspace.clone(), gate: None, comms: false,
446
+ team: Some("current".into()), fix: false, fix_schema: false,
447
+ cleanup_orphans: false, confirm: false, json: true,
448
+ }).unwrap();
449
+ let CmdOutput::Json(value) = result.output else { panic!() };
450
+ let node = &value["runtime"]["observations"][0];
451
+ assert_eq!(node["name"], "worker");
452
+ assert_eq!(node["observation_reason"], "endpoint_unregistered");
453
+ let action = node["next_action"].as_str().unwrap();
454
+ assert!(action.contains("--team 'current'"));
455
+ assert!(action.contains(&*workspace.to_string_lossy()));
456
+ assert_eq!(std::fs::read(state_path).unwrap(), before);
413
457
  }
414
458
 
415
459
  // 6 [P1] — cmd_shutdown delegates to the REAL crate::coordinator::stop_coordinator (+ tmux
@@ -84,9 +84,10 @@ fn install_skill_invalid_target_is_usage_error() {
84
84
  );
85
85
  assert_eq!(
86
86
  code,
87
- ExitCode::Error,
88
- "install-skill --target bogus must error (CliError::Usage -> emit_cli_error exit 1)"
87
+ ExitCode::Usage,
88
+ "invalid control values must fail as Usage2 before installation"
89
89
  );
90
+ assert!(!ws.join(".team/logs").exists());
90
91
  let _ = std::fs::remove_dir_all(&ws);
91
92
  let _ = std::fs::remove_dir_all(source.parent().unwrap());
92
93
  }
@@ -194,7 +194,9 @@ fn profile_init_accepts_local_proxy_mode_and_rejects_unknown_values() {
194
194
  ]),
195
195
  &ws,
196
196
  );
197
- assert_eq!(invalid, ExitCode::Error, "unknown proxy mode must fail");
197
+ assert_eq!(invalid, ExitCode::Usage, "unknown proxy mode must fail as Usage2");
198
+ assert!(!profiles_dir(&ws).join("bad_proxy.env").exists());
199
+ assert!(!ws.join(".team/logs").exists());
198
200
  let _ = std::fs::remove_dir_all(&ws);
199
201
  }
200
202
 
@@ -689,7 +689,7 @@ fn write_role(binding: &Binding, identity: &InstanceIdentity, role: &RoleConfig)
689
689
  file.sync_all()?;
690
690
  Ok(())
691
691
  }
692
- pub(super) fn read_role(binding: &Binding, identity: &InstanceIdentity) -> Result<RoleConfig, BackendError> {
692
+ pub(crate) fn read_role(binding: &Binding, identity: &InstanceIdentity) -> Result<RoleConfig, BackendError> {
693
693
  let mut path = role_path(binding, identity);
694
694
  // Existing generation-one deployments retain their immutable source.
695
695
  if identity.generation == Generation(1) {