@team-agent/installer 0.3.6 → 0.3.7

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 (33) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/diagnose.rs +9 -0
  4. package/crates/team-agent/src/cli/emit.rs +63 -0
  5. package/crates/team-agent/src/cli/mod.rs +334 -35
  6. package/crates/team-agent/src/cli/status_port.rs +62 -0
  7. package/crates/team-agent/src/cli/tests/base.rs +9 -4
  8. package/crates/team-agent/src/cli/tests/run_delegation.rs +10 -2
  9. package/crates/team-agent/src/cli/types.rs +3 -2
  10. package/crates/team-agent/src/compiler.rs +73 -50
  11. package/crates/team-agent/src/coordinator/tick.rs +108 -20
  12. package/crates/team-agent/src/db/migration.rs +17 -1
  13. package/crates/team-agent/src/lifecycle/launch.rs +182 -47
  14. package/crates/team-agent/src/lifecycle/restart/common.rs +4 -9
  15. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +75 -2
  16. package/crates/team-agent/src/lifecycle/restart/selection.rs +6 -4
  17. package/crates/team-agent/src/lifecycle/tests/core.rs +46 -3
  18. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +221 -7
  19. package/crates/team-agent/src/mcp_server/normalize.rs +29 -7
  20. package/crates/team-agent/src/mcp_server/tests/golden.rs +7 -5
  21. package/crates/team-agent/src/mcp_server/tests/normalize.rs +5 -2
  22. package/crates/team-agent/src/mcp_server/tools.rs +25 -1
  23. package/crates/team-agent/src/mcp_server/wire.rs +11 -1
  24. package/crates/team-agent/src/model/paths.rs +7 -0
  25. package/crates/team-agent/src/model/spec.rs +23 -1
  26. package/crates/team-agent/src/packaging/install.rs +42 -4
  27. package/crates/team-agent/src/packaging/tests.rs +91 -14
  28. package/crates/team-agent/src/packaging/types.rs +13 -1
  29. package/crates/team-agent/src/provider/adapter.rs +204 -0
  30. package/crates/team-agent/src/state/selector.rs +48 -14
  31. package/crates/team-agent/src/tmux_backend.rs +14 -2
  32. package/package.json +4 -4
  33. package/skills/team-agent/SKILL.md +82 -5
@@ -18,6 +18,45 @@ pub(super) fn quick_start_team_dir(role_doc: &str) -> PathBuf {
18
18
  team
19
19
  }
20
20
 
21
+ /// E5 spec 迁移:spec 不再落 team_dir,而在 <team_workspace>/.team/runtime/<team_key>/。
22
+ /// team_key = team_dir.name(quick_start_team_dir → "teamdir");workspace = team_workspace(team)。
23
+ pub(super) fn quick_start_runtime_spec_path(team: &std::path::Path) -> PathBuf {
24
+ let workspace = crate::model::paths::team_workspace(team).unwrap();
25
+ let team_key = team.file_name().unwrap().to_string_lossy().to_string();
26
+ crate::model::paths::runtime_spec_path(&workspace, &team_key)
27
+ }
28
+
29
+ /// 在 team 自身或其 team_workspace 父下找 .team/runtime/*/team.spec.yaml。run_workspace 解析
30
+ /// 可能是 team 或父、team_key 可能因 state 缺省而非 team 名,故扫整个 runtime 目录任一子目录。
31
+ /// `team_key` 给定时优先精确命中,否则取扫到的第一个。
32
+ pub(super) fn find_runtime_spec(team: &std::path::Path, team_key: &str) -> Option<PathBuf> {
33
+ let mut workspaces = vec![team.to_path_buf()];
34
+ if let Ok(ws) = crate::model::paths::team_workspace(team) {
35
+ workspaces.push(ws);
36
+ }
37
+ // 精确命中优先。
38
+ for ws in &workspaces {
39
+ let exact = crate::model::paths::runtime_spec_path(ws, team_key);
40
+ if exact.exists() {
41
+ return Some(exact);
42
+ }
43
+ }
44
+ // 回退:扫 .team/runtime/*/team.spec.yaml。
45
+ for ws in &workspaces {
46
+ let runtime = crate::model::paths::runtime_dir(ws);
47
+ let Ok(entries) = std::fs::read_dir(&runtime) else {
48
+ continue;
49
+ };
50
+ for entry in entries.flatten() {
51
+ let spec = entry.path().join("team.spec.yaml");
52
+ if spec.exists() {
53
+ return Some(spec);
54
+ }
55
+ }
56
+ }
57
+ None
58
+ }
59
+
21
60
  /// A no-owner running workspace: state.json carries session_name + agents but NO team_owner →
22
61
  /// check_team_owner returns None = allowed (owner_gate.rs:48), so the owner-gated entry points can
23
62
  /// proceed PAST the owner gate. Also inits the real team.db.
@@ -46,11 +85,16 @@ fn quick_start_compiles_real_spec_to_team_spec_yaml() {
46
85
  let result = quick_start_with_transport(&team, None, true, true, None, &transport);
47
86
 
48
87
  // OBSERVABLE 1 (real compiler ran; spawn-independent): the compiled spec is written.
49
- let spec_path = team.join("team.spec.yaml");
88
+ // E5: spec lands in .team/runtime/<team_key>/ (NOT the user team dir).
89
+ let spec_path = quick_start_runtime_spec_path(&team);
50
90
  assert!(
51
91
  spec_path.exists(),
52
- "quick_start must compile the team dir and write team.spec.yaml (the real compiler runs \
53
- before launch); the stub returns before compiling. result={result:?}"
92
+ "quick_start must compile the team dir and write team.spec.yaml under .team/runtime/<team_key>/ \
93
+ (the real compiler runs before launch); the stub returns before compiling. result={result:?}"
94
+ );
95
+ assert!(
96
+ !team.join("team.spec.yaml").exists(),
97
+ "E5: spec must NOT be written into the user team dir"
54
98
  );
55
99
  let spec_text = std::fs::read_to_string(&spec_path).unwrap_or_default();
56
100
  assert!(
@@ -94,9 +138,11 @@ fn quick_start_teamdir_under_dot_team_uses_project_workspace_for_status_and_coll
94
138
  )
95
139
  .expect("status/collect selector should resolve project root");
96
140
  assert_eq!(selected.run_workspace, workspace, "input={}", input.display());
141
+ // E5: selector resolves spec_path to .team/runtime/<team_key>/ (team_key="current").
142
+ let expected_spec = crate::model::paths::runtime_spec_path(&workspace, "current");
97
143
  assert_eq!(
98
144
  selected.spec_path.as_deref().map(std::fs::canonicalize).transpose().unwrap(),
99
- Some(std::fs::canonicalize(team.join("team.spec.yaml")).unwrap()),
145
+ Some(std::fs::canonicalize(&expected_spec).unwrap()),
100
146
  "input={}",
101
147
  input.display()
102
148
  );
@@ -433,6 +479,164 @@ fn spine_add_agent_rejects_duplicate_agent_id() {
433
479
  );
434
480
  }
435
481
 
482
+ // E5 Bug1 — add-agent must NOT copy the external role file into <team_dir>/agents (the
483
+ // self-copy O_TRUNC truncation reproduced on real machine), and must still inject the new
484
+ // agent into the compiled spec by reading the role file in place.
485
+ #[test]
486
+ fn e5_add_agent_does_not_copy_role_into_platform_dir_and_injects_into_spec() {
487
+ let team = quick_start_team_dir(QS_VALID_ROLE);
488
+ // External role file OUTSIDE team_dir/agents, with recognizable body content.
489
+ let role = team.parent().unwrap().join("w2-external-role.md");
490
+ let role_body = "---\nname: w2\nrole: Second Worker\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nUNIQUE-E5-BUG1-MARKER body for w2.\n";
491
+ std::fs::write(&role, role_body).unwrap();
492
+ let transport = OfflineTransport::new();
493
+ // The spawn may not complete under OfflineTransport; we only assert the no-copy + spec-inject
494
+ // observables, which happen BEFORE spawn. Ignore the spawn-stage Result.
495
+ let _ = add_agent_with_transport(&team, &AgentId::new("w2"), &role, false, None, &transport);
496
+
497
+ // (1) No copy landed in the platform agents dir.
498
+ let copied = team.join("agents").join("w2.md");
499
+ assert!(
500
+ !copied.exists(),
501
+ "add-agent must NOT copy the role file into <team_dir>/agents (anti-pattern + O_TRUNC bug)"
502
+ );
503
+ // (2) The external role file is untouched (not truncated by a self-copy).
504
+ assert_eq!(
505
+ std::fs::read_to_string(&role).unwrap(),
506
+ role_body,
507
+ "external role file must be left intact (no truncating self-copy)"
508
+ );
509
+ // (3) The compiled spec (under .team/runtime/<team_key>/, NOT the user team dir) carries w2.
510
+ let runtime_spec = find_runtime_spec(&team, "teamdir").expect("runtime spec written");
511
+ let spec_text = std::fs::read_to_string(&runtime_spec).unwrap();
512
+ assert!(
513
+ spec_text.contains("w2"),
514
+ "compiled spec must inject the new agent w2 (read in place, not copied); spec=\n{spec_text}"
515
+ );
516
+ assert!(
517
+ !team.join("team.spec.yaml").exists(),
518
+ "E5: add_agent must NOT write spec into the user team dir"
519
+ );
520
+ }
521
+
522
+ // E5 task#3 / E4 — restart rebuilds the runtime spec from role docs EVERY time: editing a role
523
+ // doc (here: rename a role) is reflected in the freshly-written runtime spec after restart.
524
+ #[test]
525
+ fn e5_restart_rebuilds_runtime_spec_from_role_docs() {
526
+ let ws = restart_ws_two_resumable_workers(); // has TEAM.md + agents/{alpha,bravo}.md + state
527
+ // Edit a role doc AFTER initial compile: change alpha's role text.
528
+ std::fs::write(
529
+ ws.join("agents").join("alpha.md"),
530
+ "---\nname: alpha\nrole: RENAMED Alpha Role\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nAlpha edited.\n",
531
+ )
532
+ .unwrap();
533
+ let transport = OfflineTransport::new();
534
+ let _ = restart_with_transport(&ws, false, None, &transport);
535
+ // The runtime spec (rebuilt from role docs) must carry the edited role text.
536
+ let runtime_spec = find_runtime_spec(&ws, "restartteam").expect("runtime spec rebuilt");
537
+ let spec_text = std::fs::read_to_string(&runtime_spec).unwrap();
538
+ assert!(
539
+ spec_text.contains("RENAMED Alpha Role"),
540
+ "restart must rebuild runtime spec from role docs (E4: TEAM.md/role edits take effect); spec=\n{spec_text}"
541
+ );
542
+ assert!(
543
+ !ws.join("team.spec.yaml").exists(),
544
+ "E5: rebuilt spec must land in .team/runtime, not the user team dir"
545
+ );
546
+ }
547
+
548
+ // E5 task#3 / RC-A6b — restart with role definitions MISSING explicitly refuses (lists what's
549
+ // missing) and leaves the previous runtime spec in place (no silent path, no data destruction).
550
+ #[test]
551
+ fn e5_restart_missing_role_docs_refuses_and_preserves_old_spec() {
552
+ let ws = restart_ws_two_resumable_workers();
553
+ // Pre-seed a previous runtime spec so we can assert it survives the refusal.
554
+ let prev_spec = crate::model::paths::runtime_spec_path(&ws, "restartteam");
555
+ std::fs::create_dir_all(prev_spec.parent().unwrap()).unwrap();
556
+ std::fs::write(&prev_spec, "PREVIOUS-RUNTIME-SPEC-MARKER\n").unwrap();
557
+ // Remove the role definitions (TEAM.md) → role source gone.
558
+ std::fs::remove_file(ws.join("TEAM.md")).unwrap();
559
+ let transport = OfflineTransport::new();
560
+ let result = restart_with_transport(&ws, false, None, &transport);
561
+ let text = format!("{result:?}");
562
+ assert!(
563
+ text.contains("role definitions missing") || text.to_lowercase().contains("missing"),
564
+ "restart with missing role docs must explicitly refuse listing what's missing; got {text}"
565
+ );
566
+ // Old runtime spec preserved untouched (T2: no data destruction on refusal).
567
+ assert_eq!(
568
+ std::fs::read_to_string(&prev_spec).unwrap(),
569
+ "PREVIOUS-RUNTIME-SPEC-MARKER\n",
570
+ "previous runtime spec must be left in place on refusal"
571
+ );
572
+ }
573
+
574
+ // E5 §3 解耦(tester 场景2)— add-agent on a team whose runtime spec already exists must still
575
+ // resolve team_dir to the USER role dir for compile_team (find TEAM.md/agents), not the runtime
576
+ // spec dir. Regression: SelectedTeam.spec_workspace=runtime was used as team_dir → compile_team
577
+ // looked for TEAM.md under .team/runtime/<key>/ → "missing TEAM.md".
578
+ #[test]
579
+ fn e5_add_agent_resolves_team_dir_to_role_dir_when_runtime_spec_exists() {
580
+ let team = quick_start_team_dir(QS_VALID_ROLE);
581
+ // Pre-create the runtime spec so the selector's runtime-first branch is taken (migrated team).
582
+ let runtime_spec = quick_start_runtime_spec_path(&team);
583
+ std::fs::create_dir_all(runtime_spec.parent().unwrap()).unwrap();
584
+ let base = crate::compiler::compile_team(&team).unwrap();
585
+ std::fs::write(&runtime_spec, crate::model::yaml::dumps(&base)).unwrap();
586
+ // External role file (relative-ish, outside agents/).
587
+ let role = team.join("w2-role.md");
588
+ std::fs::write(
589
+ &role,
590
+ "---\nname: w2\nrole: Second Worker\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nw2.\n",
591
+ )
592
+ .unwrap();
593
+ let transport = OfflineTransport::new();
594
+ let result = add_agent_with_transport(&team, &AgentId::new("w2"), &role, false, None, &transport);
595
+ // Core decoupling signature: must NOT fail because compile_team looked for TEAM.md/agents under
596
+ // the runtime spec dir. Before the fix, team_dir=spec_workspace(runtime) → "missing TEAM.md".
597
+ let text = format!("{result:?}");
598
+ assert!(
599
+ !text.contains("missing TEAM.md") && !text.to_lowercase().contains("missing agents"),
600
+ "add-agent must resolve team_dir to the role dir for compile_team (not the runtime spec dir); got {text}"
601
+ );
602
+ }
603
+
604
+ // E5 grep guard G1 — writers must land spec via runtime_spec_path (.team/runtime/<team_key>/),
605
+ // never write team.spec.yaml into the user team_dir/agents_dir. Pins the spec-demote invariant.
606
+ #[test]
607
+ fn e5_guard_g1_writers_use_runtime_spec_path_not_user_dir() {
608
+ let source = include_str!("../launch.rs");
609
+ let runtime_writes = source.matches("runtime_spec_path(").count();
610
+ assert!(
611
+ runtime_writes >= 2,
612
+ "G1: quick_start + add_agent must write spec via runtime_spec_path (found {runtime_writes})"
613
+ );
614
+ for forbidden in [
615
+ "let spec_path = agents_dir.join(\"team.spec.yaml\");\n std::fs::write",
616
+ "let spec_path = team_dir.join(\"team.spec.yaml\");\n std::fs::write",
617
+ ] {
618
+ assert!(
619
+ !source.contains(forbidden),
620
+ "G1: user-dir spec write idiom must be gone: {forbidden:?}"
621
+ );
622
+ }
623
+ }
624
+
625
+ // E5 grep guard G2 — launch.rs must NOT copy a role file into the platform team dir.
626
+ // Pins the Bug1 fix: no `fs::copy` of a role file + no `materialize_added_role_file` reborn.
627
+ #[test]
628
+ fn e5_guard_g2_no_copy_role_into_platform_dir() {
629
+ let source = include_str!("../launch.rs");
630
+ assert!(
631
+ !source.contains("materialize_added_role_file"),
632
+ "G2: materialize_added_role_file (role copy anti-pattern) must stay deleted"
633
+ );
634
+ assert!(
635
+ !source.contains("copy role file"),
636
+ "G2: no 'copy role file' into the platform agents dir (O_TRUNC self-copy bug)"
637
+ );
638
+ }
639
+
436
640
  // ═════════════════════════════════════════════════════════════════════════
437
641
  // SPAWN sub-phase RED — launch(dry_run=false) must REALLY spawn (unlocks the acceptance
438
642
  // framework's cheap real-machine Tier-1). Golden launch/core.py: create the session + one worker
@@ -754,8 +958,17 @@ fn add_agent_with_transport_spawns_new_worker_not_stub() {
754
958
 
755
959
  let _result = add_agent_with_transport(&team, &AgentId::new("worker2"), &role_file, false, None, &transport);
756
960
 
757
- // (a) the recompiled spec was written (real subsystem step works today).
758
- assert!(team.join("team.spec.yaml").exists(), "add_agent must recompile + write team.spec.yaml under the team dir");
961
+ // (a) the recompiled spec was written (real subsystem step). E5: under .team/runtime/<team_key>/,
962
+ // NOT the user team dir.
963
+ let runtime_spec = find_runtime_spec(&team, "addteam");
964
+ assert!(
965
+ runtime_spec.is_some(),
966
+ "add_agent must recompile + write team.spec.yaml under .team/runtime/<team_key>/"
967
+ );
968
+ assert!(
969
+ !team.join("team.spec.yaml").exists(),
970
+ "E5: add_agent must NOT write spec into the user team dir"
971
+ );
759
972
  // (b) the new worker window was spawned (RED: stub recompiles then RequirementUnmet -> ZERO spawns).
760
973
  let recorded = transport.spawn_records();
761
974
  assert!(
@@ -839,7 +1052,8 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
839
1052
  let transport = OfflineTransport::new();
840
1053
  let _ = quick_start_with_transport(&team, None, true, true, None, &transport);
841
1054
  let workspace = team.parent().expect("team_workspace(<base>/teamdir) = <base>");
842
- let spec_path = team.join("team.spec.yaml");
1055
+ // E5: spec_path in state now points to .team/runtime/<team_key>/, team_dir stays the user role dir.
1056
+ let spec_path = quick_start_runtime_spec_path(&team);
843
1057
  let (raw, state) = raw_runtime_state(workspace);
844
1058
  let keys = state.as_object().expect("state root object").keys().cloned().collect::<Vec<_>>();
845
1059
  assert_eq!(
@@ -16,14 +16,36 @@ use super::types::{
16
16
  // These are contract-callable: RED tests pass alias strings and assert the enum.
17
17
  // ═══════════════════════════════════════════════════════════════════════════
18
18
 
19
- /// `_normalize_result_status` (`normalize.py:106-123`): map ok/done/passed/… aliases
20
- /// onto [`ResultStatus`]; anything unrecognized → `Success`.
19
+ /// `_normalize_result_status` (`normalize.py:106-123`) cr verdict (refined,
20
+ /// 2026-06-10) three-way split:
21
+ /// * status missing/null/empty → `Success` — **parity lock** (Python :107
22
+ /// `or "success"`: the implicit-success convention, exit-code-0 equivalent).
23
+ /// * recognized aliases → mapped (Python alias table verbatim).
24
+ /// * a truly UNKNOWN non-empty literal → `Partial` (uncertain ≠ silent success;
25
+ /// MUST-NOT-13 — deliberate divergence from Python :123's fallback "success",
26
+ /// P7-type RS-first fix; the raw literal is observable via the `_observed` variant).
21
27
  pub fn normalize_result_status(value: Option<&str>) -> ResultStatus {
22
- match normalize_token(value).as_str() {
23
- "blocked" | "block" => ResultStatus::Blocked,
24
- "failed" | "fail" | "error" => ResultStatus::Failed,
25
- "partial" | "partially_done" => ResultStatus::Partial,
26
- _ => ResultStatus::Success,
28
+ normalize_result_status_observed(value).0
29
+ }
30
+
31
+ /// The observed variant: `.1` carries the raw unrecognized literal so ingestion
32
+ /// boundaries (wire report_result) can emit `provider.result.unknown_status_normalized`.
33
+ pub fn normalize_result_status_observed(value: Option<&str>) -> (ResultStatus, Option<String>) {
34
+ let token = normalize_token(value);
35
+ if token.is_empty() {
36
+ return (ResultStatus::Success, None);
37
+ }
38
+ match token.as_str() {
39
+ "success" | "ok" | "done" | "complete" | "completed" | "passed" | "pass" => {
40
+ (ResultStatus::Success, None)
41
+ }
42
+ "blocked" | "block" => (ResultStatus::Blocked, None),
43
+ "failed" | "fail" | "error" => (ResultStatus::Failed, None),
44
+ "partial" | "partially_done" => (ResultStatus::Partial, None),
45
+ _ => (
46
+ ResultStatus::Partial,
47
+ Some(value.unwrap_or_default().to_string()),
48
+ ),
27
49
  }
28
50
  }
29
51
 
@@ -83,12 +83,14 @@
83
83
  assert_eq!(normalize_change_kind(None, "inspected it"), ChangeKind::Observed);
84
84
  }
85
85
 
86
- // ── #40 normalize_result_status: 'partiallydone' (no underscore) → Success ──
87
- // GOLDEN (probe_mcp_red.py STATUS partiallydone): not in mapping/canonical set → success.
86
+ // ── #40 normalize_result_status: 'partiallydone' (no underscore) → Partial ──
87
+ // Re-anchored per cr verdict (refined, 2026-06-10): the old Python golden mapped
88
+ // an unmatched literal to success (probe_mcp_red.py STATUS); RS deliberately
89
+ // normalizes a NON-EMPTY unknown literal to Partial (MUST-NOT-13, P7-type fix).
88
90
  #[test]
89
- fn result_status_partiallydone_no_underscore_is_success() {
90
- assert_eq!(normalize_result_status(Some("partiallydone")), ResultStatus::Success);
91
- assert_eq!(normalize_result_status(Some("PartiallyDone")), ResultStatus::Success);
91
+ fn result_status_partiallydone_no_underscore_is_partial() {
92
+ assert_eq!(normalize_result_status(Some("partiallydone")), ResultStatus::Partial);
93
+ assert_eq!(normalize_result_status(Some("PartiallyDone")), ResultStatus::Partial);
92
94
  }
93
95
 
94
96
  // ── #35/#46/#53 normalize_changes: full alias set + empty-path SKIP ─────────
@@ -1,8 +1,11 @@
1
1
  #[test]
2
2
  fn result_status_alias_chain() {
3
- // None / unknown Success
3
+ // cr verdict (refined, 2026-06-10) three-way split: missing/null/empty stays
4
+ // the parity-locked implicit success (Python :107); a NON-EMPTY unknown
5
+ // literal is Partial, never silent success (MUST-NOT-13, diverges from :123).
4
6
  assert_eq!(normalize_result_status(None), ResultStatus::Success);
5
- assert_eq!(normalize_result_status(Some("weird")), ResultStatus::Success);
7
+ assert_eq!(normalize_result_status(Some("")), ResultStatus::Success);
8
+ assert_eq!(normalize_result_status(Some("weird")), ResultStatus::Partial);
6
9
  // ok/done/complete/completed/passed/pass → success
7
10
  for a in ["ok", "DONE", "complete", "completed", "passed", "pass"] {
8
11
  assert_eq!(normalize_result_status(Some(a)), ResultStatus::Success, "alias {a}");
@@ -22,7 +22,9 @@ use super::helpers::{
22
22
  json_dumps_default, latest_task_for_assignee, non_empty_string, normalized_envelope_value, object_fields,
23
23
  requires_ack_for_target, tool_runtime_error,
24
24
  };
25
- use super::normalize::{compact_tool_result, normalize_report_envelope};
25
+ use super::normalize::{
26
+ compact_tool_result, normalize_report_envelope, normalize_result_status_observed,
27
+ };
26
28
  use super::types::{Scope, SendOutcome, ToolError, ToolErrorReason, ToolOk, ToolResult, VisiblePeers};
27
29
 
28
30
  // ═══════════════════════════════════════════════════════════════════════════
@@ -321,6 +323,14 @@ impl TeamOrchestratorTools {
321
323
  insert_array(obj, "next_actions", next_actions);
322
324
  }
323
325
  }
326
+ // T3-1 cr verdict (refined): an unknown non-empty status literal normalizes to
327
+ // Partial and must be OBSERVABLE at this ingestion boundary (the envelope-borne
328
+ // path; the wire `status` arg path emits at dispatch). Never a silent swallow.
329
+ if let Some(raw) =
330
+ normalize_result_status_observed(base.get("status").and_then(Value::as_str)).1
331
+ {
332
+ self.note_unknown_result_status(&raw);
333
+ }
324
334
  let normalized = normalize_report_envelope(&base);
325
335
  let env_value = normalized_envelope_value(&normalized);
326
336
  let owner_team = self.canonical_owner_team_key()?;
@@ -333,6 +343,20 @@ impl TeamOrchestratorTools {
333
343
  .and_then(|value| compact_tool_result(&value))
334
344
  }
335
345
 
346
+ /// T3-1 cr verdict: the observable record of an unknown→Partial status
347
+ /// normalization (`provider.result.unknown_status_normalized`, raw literal
348
+ /// included) — MUST-NOT-13: the swallow is never silent.
349
+ pub(crate) fn note_unknown_result_status(&self, raw: &str) {
350
+ let _ = EventLog::new(&self.workspace).write(
351
+ "provider.result.unknown_status_normalized",
352
+ serde_json::json!({
353
+ "agent_id": self.agent_id.as_ref().map(AgentId::as_str),
354
+ "raw_status": raw,
355
+ "normalized": "partial",
356
+ }),
357
+ );
358
+ }
359
+
336
360
  /// `update_state` (`tools.py:316-325`): delegated through the lifecycle tools
337
361
  /// facade. S0 preserves the old placeholder behavior.
338
362
  pub fn update_state(&self, note: &str) -> ToolResult {
@@ -411,7 +411,17 @@ pub(crate) fn dispatch_tool(tools: &TeamOrchestratorTools, tool: McpTool, args:
411
411
  McpTool::ReportResult => tools.report_result(
412
412
  args.get("envelope"),
413
413
  args.get("summary").and_then(Value::as_str),
414
- normalize_result_status(args.get("status").and_then(Value::as_str)),
414
+ // cr verdict (T3-1 refined): an unknown status literal normalizes to
415
+ // Partial and is OBSERVABLE at this ingestion boundary, never silent.
416
+ {
417
+ let (status, unknown) = crate::mcp_server::normalize::normalize_result_status_observed(
418
+ args.get("status").and_then(Value::as_str),
419
+ );
420
+ if let Some(raw) = unknown {
421
+ tools.note_unknown_result_status(&raw);
422
+ }
423
+ status
424
+ },
415
425
  args.get("changes").and_then(Value::as_array).map(Vec::as_slice),
416
426
  args.get("tests").and_then(Value::as_array).map(Vec::as_slice),
417
427
  args.get("risks").and_then(Value::as_array).map(Vec::as_slice),
@@ -17,6 +17,13 @@ pub fn runtime_dir(workspace: &Path) -> PathBuf {
17
17
  team_subdir(workspace, "runtime")
18
18
  }
19
19
 
20
+ /// E5:**spec.yaml 的唯一落地点** = `<workspace>/.team/runtime/<team_key>/team.spec.yaml`。
21
+ /// 用户终裁:角色定义(TEAM.md/agents/*.md)=第一真相源;spec=中间产物,**绝不落用户目录**。
22
+ /// 所有 spec 读写经此单点,杜绝 `<user_dir>/team.spec.yaml`。
23
+ pub fn runtime_spec_path(workspace: &Path, team_key: &str) -> PathBuf {
24
+ runtime_dir(workspace).join(team_key).join("team.spec.yaml")
25
+ }
26
+
20
27
  /// `<workspace>/.team/logs`。
21
28
  pub fn logs_dir(workspace: &Path) -> PathBuf {
22
29
  team_subdir(workspace, "logs")
@@ -409,9 +409,31 @@ fn semantic_errors(spec: &Yaml, base_dir: &Path) -> Vec<String> {
409
409
  let map_agents: Vec<&Yaml> = agents.iter().filter(|a| a.is_map()).collect();
410
410
 
411
411
  // duplicate agent id(集合含 None 复刻 Python `{a.get("id") ...}`)。
412
+ // SMOKE-1 N38 失败可解释性:不仅报"有重复",还点出**哪个 id 重复**(可能多个),
413
+ // 给 operator 直接定位线索;locate.md §"Smallest likely code touch" item 3。
412
414
  let id_set: HashSet<Option<&str>> = map_agents.iter().map(|a| a.get("id").and_then(Yaml::as_str)).collect();
413
415
  if id_set.len() != map_agents.len() {
414
- e.push("/agents: duplicate agent id".to_string());
416
+ let mut seen: HashSet<&str> = HashSet::new();
417
+ let mut duplicates: Vec<&str> = Vec::new();
418
+ for agent in &map_agents {
419
+ if let Some(id) = agent.get("id").and_then(Yaml::as_str) {
420
+ if !seen.insert(id) && !duplicates.contains(&id) {
421
+ duplicates.push(id);
422
+ }
423
+ }
424
+ }
425
+ if duplicates.is_empty() {
426
+ e.push("/agents: duplicate agent id".to_string());
427
+ } else {
428
+ e.push(format!(
429
+ "/agents: duplicate agent id: {}",
430
+ duplicates
431
+ .iter()
432
+ .map(|id| format!("`{id}`"))
433
+ .collect::<Vec<_>>()
434
+ .join(", ")
435
+ ));
436
+ }
415
437
  }
416
438
  // all_ids:present 的 agent id + leader id(若 truthy)。
417
439
  let mut all_ids: HashSet<&str> = map_agents.iter().filter_map(|a| a.get("id").and_then(Yaml::as_str)).collect();
@@ -21,12 +21,22 @@ pub fn install(opts: &InstallOptions) -> Result<InstallReport, PackagingError> {
21
21
  dry_run: true,
22
22
  source: default_skill_source(),
23
23
  })?;
24
+ // T3-6 (harvest §1): never a hardcoded DoctorStatus::Ok with no check behind it —
25
+ // run the real packaging doctor (schema diagnosis + gates) for the invoking
26
+ // workspace so the report reflects an actual result.
27
+ let doctor = super::migrate::doctor(&super::types::DoctorOptions {
28
+ workspace: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
29
+ gate: None,
30
+ fix: false,
31
+ cleanup_orphans: false,
32
+ confirm: false,
33
+ })?;
24
34
  Ok(InstallReport {
25
35
  installed_bin,
26
36
  version: Version::current(),
27
37
  replace: None,
28
38
  skills,
29
- doctor: DoctorStatus::Ok,
39
+ doctor,
30
40
  path_hint: diagnose_path(&bin_dir)?,
31
41
  })
32
42
  }
@@ -58,7 +68,7 @@ pub fn uninstall(opts: &UninstallOptions) -> Result<UninstallOutcome, PackagingE
58
68
 
59
69
  let home = home_dir();
60
70
  let mut removed_skill_dirs = Vec::new();
61
- for target in [SkillTarget::Codex, SkillTarget::Claude] {
71
+ for target in SkillTarget::SINGLE_TARGETS {
62
72
  if let Some(dest) = target.dest_dir(&home) {
63
73
  if dest.0.exists() {
64
74
  std::fs::remove_dir_all(&dest.0)?;
@@ -102,7 +112,7 @@ pub fn install_skill(opts: &SkillInstallOptions) -> Result<Vec<SkillInstallOutco
102
112
  ));
103
113
  }
104
114
  let targets: Vec<SkillTarget> = match opts.target {
105
- SkillTarget::All => vec![SkillTarget::Codex, SkillTarget::Claude],
115
+ SkillTarget::All => SkillTarget::SINGLE_TARGETS.to_vec(),
106
116
  target => vec![target],
107
117
  };
108
118
  let home = home_dir();
@@ -116,11 +126,27 @@ pub fn install_skill(opts: &SkillInstallOptions) -> Result<Vec<SkillInstallOutco
116
126
  };
117
127
  let mut removed_stale = Vec::new();
118
128
  if !opts.dry_run {
129
+ // T2-1 (harvest §1): NEVER pre-wipe the user's existing skill dir — stage
130
+ // the copy into a sibling temp dir and only swap after the copy succeeded
131
+ // (write_worker_mcp_config tmp+rename 范式). A failed copy leaves the
132
+ // user's dir byte-identical.
133
+ let staging = staging_dir_for(&dest.0)?;
134
+ let _ = std::fs::remove_dir_all(&staging);
135
+ if let Err(error) = copy_tree(&opts.source, &staging) {
136
+ let _ = std::fs::remove_dir_all(&staging);
137
+ return Err(error);
138
+ }
119
139
  if dest.0.exists() {
120
140
  removed_stale = collect_files(&dest.0)?;
121
141
  std::fs::remove_dir_all(&dest.0)?;
122
142
  }
123
- copy_tree(&opts.source, &dest.0)?;
143
+ if let Err(error) = std::fs::rename(&staging, &dest.0) {
144
+ let _ = std::fs::remove_dir_all(&staging);
145
+ return Err(PackagingError::Io(std::io::Error::other(format!(
146
+ "swap staged skill dir into {}: {error}",
147
+ dest.0.display()
148
+ ))));
149
+ }
124
150
  }
125
151
  out.push(SkillInstallOutcome {
126
152
  target,
@@ -260,6 +286,18 @@ fn collect_files_inner(path: &Path, out: &mut Vec<PathBuf>) -> Result<(), Packag
260
286
  Ok(())
261
287
  }
262
288
 
289
+ /// T2-1: a sibling staging path for the atomic skill-dir swap (same parent so the
290
+ /// final rename never crosses filesystems).
291
+ fn staging_dir_for(dest: &Path) -> Result<PathBuf, PackagingError> {
292
+ let name = dest
293
+ .file_name()
294
+ .and_then(|name| name.to_str())
295
+ .unwrap_or("skill");
296
+ let parent = dest.parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from("."));
297
+ std::fs::create_dir_all(&parent)?;
298
+ Ok(parent.join(format!(".{name}.ta-staging-{}", std::process::id())))
299
+ }
300
+
263
301
  fn copy_tree(source: &Path, dest: &Path) -> Result<(), PackagingError> {
264
302
  if !source.exists() {
265
303
  return Err(PackagingError::Io(std::io::Error::new(