@team-agent/installer 0.3.6 → 0.3.8

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 (45) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +52 -7
  4. package/crates/team-agent/src/cli/diagnose.rs +9 -0
  5. package/crates/team-agent/src/cli/emit.rs +175 -0
  6. package/crates/team-agent/src/cli/mod.rs +455 -63
  7. package/crates/team-agent/src/cli/status_port.rs +62 -0
  8. package/crates/team-agent/src/cli/tests/base.rs +9 -4
  9. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +83 -1
  10. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  11. package/crates/team-agent/src/cli/tests/run_delegation.rs +10 -2
  12. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +86 -21
  13. package/crates/team-agent/src/cli/tests/verb_install_skill.rs +76 -0
  14. package/crates/team-agent/src/cli/types.rs +3 -2
  15. package/crates/team-agent/src/compiler.rs +73 -50
  16. package/crates/team-agent/src/coordinator/tick.rs +108 -20
  17. package/crates/team-agent/src/db/migration.rs +17 -1
  18. package/crates/team-agent/src/leader/owner_bind.rs +59 -20
  19. package/crates/team-agent/src/lifecycle/launch.rs +378 -56
  20. package/crates/team-agent/src/lifecycle/restart/common.rs +4 -9
  21. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +91 -12
  22. package/crates/team-agent/src/lifecycle/restart/selection.rs +6 -4
  23. package/crates/team-agent/src/lifecycle/tests/core.rs +238 -3
  24. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +257 -7
  25. package/crates/team-agent/src/lifecycle/types.rs +2 -0
  26. package/crates/team-agent/src/mcp_server/normalize.rs +29 -7
  27. package/crates/team-agent/src/mcp_server/tests/golden.rs +7 -5
  28. package/crates/team-agent/src/mcp_server/tests/normalize.rs +5 -2
  29. package/crates/team-agent/src/mcp_server/tools.rs +25 -1
  30. package/crates/team-agent/src/mcp_server/wire.rs +11 -1
  31. package/crates/team-agent/src/model/paths.rs +7 -0
  32. package/crates/team-agent/src/model/spec.rs +23 -1
  33. package/crates/team-agent/src/packaging/install.rs +42 -4
  34. package/crates/team-agent/src/packaging/tests.rs +91 -14
  35. package/crates/team-agent/src/packaging/types.rs +13 -1
  36. package/crates/team-agent/src/provider/adapter.rs +381 -15
  37. package/crates/team-agent/src/state/identity.rs +29 -0
  38. package/crates/team-agent/src/state/selector.rs +48 -14
  39. package/crates/team-agent/src/tmux_backend/tests.rs +44 -0
  40. package/crates/team-agent/src/tmux_backend.rs +104 -9
  41. package/crates/team-agent/src/transport/test_support.rs +57 -4
  42. package/crates/team-agent/src/transport.rs +13 -0
  43. package/npm/install.mjs +31 -35
  44. package/package.json +4 -4
  45. package/skills/team-agent/SKILL.md +82 -5
@@ -29,6 +29,15 @@ use rusqlite::params;
29
29
  .map_err(|e| CliError::Runtime(e.to_string()))?;
30
30
  let conn = crate::db::schema::open_db(store.db_path())
31
31
  .map_err(|e| CliError::Runtime(e.to_string()))?;
32
+ // B-5 / 036b N38 explicable — status 出口 runtime 块:把 coordinator_health
33
+ // (现状)+ undelivered backlog count 一起暴露;coordinator not running ∧
34
+ // backlog>0 才挂 down-hint(anti-nag)。auto-recovery 不做(user 已裁)。
35
+ let coordinator_running = coordinator_status_running(&health);
36
+ let undelivered_backlog = count_undelivered_backlog(&conn, owner_team_id)?;
37
+ let runtime_block = build_runtime_status_block(
38
+ coordinator_running,
39
+ undelivered_backlog,
40
+ );
32
41
  let agents = enrich_agents(state.get("agents"));
33
42
  let tasks = state
34
43
  .get("tasks")
@@ -68,6 +77,7 @@ use rusqlite::params;
68
77
  "latest_results": latest_result_summaries(&store, owner_team_id)?,
69
78
  "readiness": readiness,
70
79
  "coordinator": coordinator_health_value(health),
80
+ "runtime": runtime_block,
71
81
  "last_events": Value::Array(
72
82
  crate::event_log::EventLog::new(workspace)
73
83
  .tail(10)
@@ -748,6 +758,58 @@ use rusqlite::params;
748
758
  }
749
759
  }
750
760
 
761
+ /// B-5 / 036b N38 — status 出口的 runtime 块:把 coordinator_health 与
762
+ /// undelivered backlog 合体暴露。down-hint 只在【coordinator 不在跑 ∧ 有 backlog】
763
+ /// 两条件同时满足才挂(anti-nag);健康状态下不挂提示。auto-recovery 不做。
764
+ fn build_runtime_status_block(coordinator_running: bool, undelivered: i64) -> Value {
765
+ let mut runtime = serde_json::Map::new();
766
+ runtime.insert(
767
+ "coordinator".to_string(),
768
+ json!({"ok": coordinator_running}),
769
+ );
770
+ runtime.insert("undelivered".to_string(), json!(undelivered));
771
+ if !coordinator_running && undelivered > 0 {
772
+ runtime.insert(
773
+ "hint".to_string(),
774
+ json!(format!(
775
+ "coordinator not running with {undelivered} undelivered — run team-agent restart"
776
+ )),
777
+ );
778
+ }
779
+ Value::Object(runtime)
780
+ }
781
+
782
+ /// Whether the coordinator HealthReport reflects a running tick loop. Used by the
783
+ /// runtime block + the hint gate.
784
+ fn coordinator_status_running(health: &crate::coordinator::HealthReport) -> bool {
785
+ matches!(health.status, crate::coordinator::CoordinatorHealthStatus::Running)
786
+ }
787
+
788
+ /// Count of messages currently sitting in delivery-able backlog
789
+ /// (accepted/pending/queued forms — not delivered / not failed / not refused).
790
+ /// owner_team_id scope honored when present.
791
+ fn count_undelivered_backlog(
792
+ conn: &rusqlite::Connection,
793
+ owner_team_id: Option<&str>,
794
+ ) -> Result<i64, CliError> {
795
+ // Backlog statuses chosen to mirror what `deliver_pending` would pick up.
796
+ let sql = match owner_team_id {
797
+ Some(_) => "select count(*) from messages
798
+ where owner_team_id = ?1 and status in ('accepted','pending','queued','queued_until_trust')",
799
+ None => "select count(*) from messages
800
+ where status in ('accepted','pending','queued','queued_until_trust')",
801
+ };
802
+ let count: i64 = match owner_team_id {
803
+ Some(team) => conn
804
+ .query_row(sql, params![team], |row| row.get(0))
805
+ .map_err(|e| CliError::Runtime(e.to_string()))?,
806
+ None => conn
807
+ .query_row(sql, [], |row| row.get(0))
808
+ .map_err(|e| CliError::Runtime(e.to_string()))?,
809
+ };
810
+ Ok(count)
811
+ }
812
+
751
813
  fn coordinator_health_value(health: crate::coordinator::HealthReport) -> Value {
752
814
  json!({
753
815
  "ok": health.ok,
@@ -400,15 +400,20 @@ latest result: none";
400
400
  let payload = err.to_payload(Path::new("/tmp/cli-error-123.log"), "quick-start");
401
401
  assert_eq!(payload.reason.as_deref(), Some("tmux_session_name_conflict"));
402
402
  assert_eq!(payload.session_name.as_deref(), Some("my-team"));
403
+ // E8 (N38): quick-start 撞已有 runtime 引导到 restart(resume),明确 --fresh 会丢上下文。
403
404
  assert_eq!(
404
405
  payload.action,
405
- "tmux session `my-team` already exists. It may be an active team. \
406
- Do not terminate existing tmux sessions from quick-start; \
407
- change `name:` in TEAM.md and run quick-start again."
406
+ "tmux session `my-team` already exists. It may be your own existing team. \
407
+ To resume it use `team-agent restart` (NOT --fresh, which discards context). \
408
+ Only if you want a separate team, change `name:` in TEAM.md and run quick-start again. \
409
+ Never terminate existing tmux sessions from quick-start."
408
410
  );
409
411
  assert_eq!(
410
412
  payload.next_actions,
411
- Some(vec!["Change `name:` in TEAM.md and run `team-agent quick-start` again.".to_string()])
413
+ Some(vec![
414
+ "If this is your existing team, resume it with `team-agent restart`.".to_string(),
415
+ "If you want a separate team, change `name:` in TEAM.md and run `team-agent quick-start` again.".to_string(),
416
+ ])
412
417
  );
413
418
  }
414
419
 
@@ -1,4 +1,41 @@
1
1
  use super::*;
2
+ use serial_test::serial;
3
+
4
+ struct EnvUnsetGuard {
5
+ previous: Vec<(&'static str, Option<String>)>,
6
+ }
7
+
8
+ impl EnvUnsetGuard {
9
+ fn unset(keys: &[&'static str]) -> Self {
10
+ let previous = keys
11
+ .iter()
12
+ .map(|key| (*key, std::env::var(key).ok()))
13
+ .collect::<Vec<_>>();
14
+ for key in keys {
15
+ std::env::remove_var(key);
16
+ }
17
+ Self { previous }
18
+ }
19
+ }
20
+
21
+ impl Drop for EnvUnsetGuard {
22
+ fn drop(&mut self) {
23
+ for (key, value) in self.previous.drain(..).rev() {
24
+ match value {
25
+ Some(value) => std::env::set_var(key, value),
26
+ None => std::env::remove_var(key),
27
+ }
28
+ }
29
+ }
30
+ }
31
+
32
+ struct WorkspaceCleanup(std::path::PathBuf);
33
+
34
+ impl Drop for WorkspaceCleanup {
35
+ fn drop(&mut self) {
36
+ let _ = std::fs::remove_dir_all(&self.0);
37
+ }
38
+ }
2
39
 
3
40
  // =========================================================================
4
41
  // WAVE-2 NON-SUB CHECKPOINT — 9 MISSING CLI subcommands (ABSENT from cli/emit.rs dispatch).
@@ -131,6 +168,36 @@ tasks:
131
168
  std::fs::write(ws.join("team.spec.yaml"), spec).unwrap();
132
169
  }
133
170
 
171
+ fn seed_collect_runtime_state(ws: &std::path::Path) {
172
+ crate::state::persist::save_runtime_state(
173
+ ws,
174
+ &json!({
175
+ "active_team_key": "fake-e2e",
176
+ "team_dir": ws.to_string_lossy().to_string(),
177
+ "spec_path": ws.join("team.spec.yaml").to_string_lossy().to_string(),
178
+ "session_name": "team-agent-fake-e2e",
179
+ "leader": {"id": "leader"},
180
+ "agents": {
181
+ "fake_impl": {
182
+ "status": "running",
183
+ "provider": "fake",
184
+ "role": "implementation_engineer",
185
+ "window": "fake_impl",
186
+ "owner_team_id": "fake-e2e"
187
+ }
188
+ },
189
+ "tasks": [{
190
+ "id": "task_impl",
191
+ "title": "Fake implementation",
192
+ "type": "implementation",
193
+ "status": "pending",
194
+ "assignee": "fake_impl"
195
+ }]
196
+ }),
197
+ )
198
+ .unwrap();
199
+ }
200
+
134
201
  // ── sessions ── golden cli/parser.py:230 `cmd_sessions` -> runtime.sessions(ws). EXIT 0.
135
202
  // `team-agent sessions --workspace <ws> --json` on an empty ws ->
136
203
  // {"ok":true,"sessions":[],"workspace":"<ws>"} (--json sort_keys). RED: unrouted -> Error.
@@ -169,9 +236,25 @@ tasks:
169
236
  // "delivered_messages":[],"invalid_results":[],"ok":true,"results":{...},"state_file":"<ws>/team_state.md"}
170
237
  // RED: unrouted -> Error.
171
238
  #[test]
239
+ #[serial(env)]
172
240
  fn dispatch_routes_collect_with_spec() {
241
+ let _env = EnvUnsetGuard::unset(&[
242
+ "TEAM_AGENT_WORKSPACE",
243
+ "TEAM_AGENT_TEAM_ID",
244
+ "TEAM_AGENT_OWNER_TEAM_ID",
245
+ "TEAM_AGENT_ACTIVE_TEAM",
246
+ "TEAM_AGENT_ID",
247
+ "TEAM_AGENT_LEADER_PANE_ID",
248
+ "TEAM_AGENT_LEADER_SESSION_UUID",
249
+ "TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE",
250
+ "TEAM_AGENT_LEADER_PROVIDER",
251
+ "TMUX",
252
+ "TMUX_PANE",
253
+ ]);
173
254
  let ws = tmp_workspace();
255
+ let _cleanup = WorkspaceCleanup(ws.clone());
174
256
  seed_team_spec(&ws);
257
+ seed_collect_runtime_state(&ws);
175
258
  let code = run(&cli_argv(&["collect", "--workspace", &ws.to_string_lossy(), "--json"]), &ws);
176
259
  assert_eq!(
177
260
  code,
@@ -180,7 +263,6 @@ tasks:
180
263
  {{collected,collected_results,coordinator,delivered_messages,invalid_results,ok,results,state_file}}; \
181
264
  today -> unknown-subcommand Error"
182
265
  );
183
- let _ = std::fs::remove_dir_all(&ws);
184
266
  }
185
267
 
186
268
  // ── repair-state ── golden parser.py:303 `cmd_repair_state` -> runtime.repair_state (quick_start.py:285).
@@ -94,5 +94,6 @@ mod status_send;
94
94
  mod verb_validate;
95
95
  mod verb_settle;
96
96
  mod verb_profile;
97
+ mod verb_install_skill;
97
98
  mod main_preserved;
98
99
  mod shutdown_kill_plan;
@@ -209,10 +209,18 @@ fn current_uid() -> Option<String> {
209
209
  json: true,
210
210
  };
211
211
  let _ = cmd_quick_start(&args); // real quick_start compiles the spec before any coordinator/launch step
212
+ // E5: spec compiled to .team/runtime/<team_key>/, NOT the user team dir.
213
+ let team_key = team.file_name().unwrap().to_string_lossy().to_string();
214
+ let workspace = crate::model::paths::team_workspace(&team).unwrap();
215
+ let runtime_spec = crate::model::paths::runtime_spec_path(&workspace, &team_key);
212
216
  assert!(
213
- team.join("team.spec.yaml").exists(),
217
+ runtime_spec.exists(),
214
218
  "cmd_quick_start must delegate to crate::lifecycle::quick_start, which compiles team.spec.yaml \
215
- under the team dir; the placeholder never writes it"
219
+ under .team/runtime/<team_key>/; the placeholder never writes it"
220
+ );
221
+ assert!(
222
+ !team.join("team.spec.yaml").exists(),
223
+ "E5: quick_start must NOT write spec into the user team dir"
216
224
  );
217
225
  }
218
226
 
@@ -1,39 +1,104 @@
1
- //! B5/F1 · `sessions_to_kill_sparing_leader` 纯函数单测(kill 决策下沉后锁定)。
1
+ //! E12 (P0) · `sessions_to_kill` 纯决策单测(kill 决策下沉)。
2
2
  //!
3
- //! 真相源 = `team-agent-leader-` 确定性命名前缀(leader/start.rs LEADER_SESSION_PREFIX);
4
- //! 集成面由 tests/b5_leader_terminal_kill_red.rs 的真 tmux 契约覆盖,此处锁纯决策。
3
+ //! spare = state 锚 session(anchor_sessions) ∪ `team-agent-leader-` 命名前缀(并集,锚优先)
4
+ //! 独享 socket(无 spare)才允许整 server 拆;共享/leader 在 → 逐 session kill。
5
+ //! 集成面由 tests/b5_leader_terminal_kill_red.rs 的真 tmux 契约覆盖,此处锁纯决策 + 4 反向 case。
5
6
 
6
- use crate::cli::lifecycle_port::sessions_to_kill_sparing_leader;
7
+ use crate::cli::lifecycle_port::{sessions_to_kill, KillDecision};
7
8
  use crate::transport::SessionName;
9
+ use std::collections::BTreeSet;
8
10
 
9
11
  fn names(raw: &[&str]) -> Vec<SessionName> {
10
12
  raw.iter().map(|name| SessionName::new(*name)).collect()
11
13
  }
12
14
 
15
+ fn anchors(raw: &[&str]) -> BTreeSet<String> {
16
+ raw.iter().map(|s| s.to_string()).collect()
17
+ }
18
+
19
+ // RC-4(独享 socket):仅目标 session(无 spare)→ 整 server 拆。
20
+ #[test]
21
+ fn rc4_exclusive_socket_kills_server() {
22
+ assert_eq!(
23
+ sessions_to_kill(&names(&["team-x", "team-y"]), &BTreeSet::new()),
24
+ KillDecision::KillServerExclusive
25
+ );
26
+ // 空 session 集 → 逐 kill(no-op),不整 server 拆(没东西可拆)。
27
+ assert_eq!(
28
+ sessions_to_kill(&[], &BTreeSet::new()),
29
+ KillDecision::KillIndividually { to_kill: vec![], spared: vec![] }
30
+ );
31
+ }
32
+
33
+ // RC-1(本 P0 复现):in_tmux leader 在用户 session(**无前缀**),靠 state 锚 spare → 用户 session 存活。
13
34
  #[test]
14
- fn no_leader_session_means_whole_server_kill() {
15
- assert_eq!(sessions_to_kill_sparing_leader(&names(&["team-x"])), None);
16
- assert_eq!(sessions_to_kill_sparing_leader(&[]), None);
35
+ fn rc1_in_tmux_no_prefix_anchor_spares_user_session() {
36
+ let sessions = names(&["team-coder-team", "team-x"]); // 用户 session 无 leader 前缀
37
+ let anchor = anchors(&["team-coder-team"]); // state 锚 pane 所在 session
38
+ let decision = sessions_to_kill(&sessions, &anchor);
39
+ match decision {
40
+ KillDecision::KillIndividually { to_kill, spared } => {
41
+ assert_eq!(to_kill, names(&["team-x"]), "only non-anchor session killed");
42
+ assert_eq!(spared, names(&["team-coder-team"]), "user/leader session spared by anchor");
43
+ }
44
+ other => panic!("anchor session must force per-session kill, not {other:?}"),
45
+ }
46
+ }
47
+
48
+ // 前缀判据仍生效(并集):leader 前缀 session spare,即使无 state 锚。
49
+ #[test]
50
+ fn prefix_session_spared_without_anchor() {
51
+ let sessions = names(&["team-agent-leader-claude-ws-deadbeef", "team-x"]);
52
+ let decision = sessions_to_kill(&sessions, &BTreeSet::new());
53
+ match decision {
54
+ KillDecision::KillIndividually { to_kill, spared } => {
55
+ assert_eq!(to_kill, names(&["team-x"]));
56
+ assert_eq!(spared, names(&["team-agent-leader-claude-ws-deadbeef"]));
57
+ }
58
+ other => panic!("prefix session must spare, not {other:?}"),
59
+ }
60
+ }
61
+
62
+ // RC-2(state 损坏无锚):anchor_sessions 空 → 退命名前缀判据(此处仅前缀 spare;无前缀则全 kill)。
63
+ // (spare_fallback_to_naming event 在 anchor_anchor_sessions 发,本纯函数只验退化后的决策。)
64
+ #[test]
65
+ fn rc2_no_anchor_falls_back_to_naming() {
66
+ // 无锚 + 有前缀 leader → 前缀 spare。
67
+ let with_leader = sessions_to_kill(
68
+ &names(&["team-agent-leader-codex-ws-cafe", "team-x"]),
69
+ &BTreeSet::new(),
70
+ );
71
+ assert!(matches!(with_leader, KillDecision::KillIndividually { .. }));
72
+ // 无锚 + 无前缀(真损坏且 in_tmux 无前缀)→ 无 spare → 独享拆(退化兜底,与历史一致)。
73
+ assert_eq!(
74
+ sessions_to_kill(&names(&["team-x"]), &BTreeSet::new()),
75
+ KillDecision::KillServerExclusive
76
+ );
17
77
  }
18
78
 
79
+ // RC-3(共享 socket):目标 2 session + 用户 1 session(锚)→ 只 kill 目标 2,用户存活,不整 server 拆。
19
80
  #[test]
20
- fn leader_session_present_kills_only_non_leader_sessions() {
21
- let sessions = names(&[
22
- "team-agent-leader-claude-myws-deadbeef",
23
- "team-x",
24
- "team-y",
25
- ]);
26
- let to_kill = sessions_to_kill_sparing_leader(&sessions)
27
- .expect("leader present must switch to per-session kills");
28
- assert_eq!(to_kill, names(&["team-x", "team-y"]));
81
+ fn rc3_shared_socket_kills_only_target_sessions() {
82
+ let sessions = names(&["team-a", "team-b", "user-shell"]);
83
+ let anchor = anchors(&["user-shell"]);
84
+ let decision = sessions_to_kill(&sessions, &anchor);
85
+ match decision {
86
+ KillDecision::KillIndividually { to_kill, spared } => {
87
+ assert_eq!(to_kill, names(&["team-a", "team-b"]));
88
+ assert_eq!(spared, names(&["user-shell"]));
89
+ }
90
+ other => panic!("shared socket must not whole-server kill, got {other:?}"),
91
+ }
29
92
  }
30
93
 
94
+ // 并集语义:同一 session 既前缀又锚 → spare 一次(不重复)。
31
95
  #[test]
32
- fn only_leader_sessions_left_kills_nothing_but_keeps_server() {
33
- let sessions = names(&["team-agent-leader-codex-myws-cafe0123"]);
96
+ fn union_prefix_and_anchor_no_double_count() {
97
+ let sessions = names(&["team-agent-leader-claude-ws-beef"]);
98
+ let anchor = anchors(&["team-agent-leader-claude-ws-beef"]);
99
+ let decision = sessions_to_kill(&sessions, &anchor);
34
100
  assert_eq!(
35
- sessions_to_kill_sparing_leader(&sessions),
36
- Some(Vec::new()),
37
- "a socket holding only leader sessions must not be torn down"
101
+ decision,
102
+ KillDecision::KillIndividually { to_kill: vec![], spared: names(&["team-agent-leader-claude-ws-beef"]) }
38
103
  );
39
104
  }
@@ -0,0 +1,76 @@
1
+ use super::*;
2
+
3
+ // RED-1 根治:install-skill 必须作为真 CLI 子命令存在(此前 packaging::install_skill 未接进
4
+ // 任何 dispatch=双重死代码,install.mjs 走自己的 JS 拷贝逻辑漏 copilot)。
5
+ // 这里钉:① 子命令路由 ② --target all 默认 ③ --source 必需 ④ 缺 source 显式 Usage 错。
6
+
7
+ fn skill_source() -> PathBuf {
8
+ use std::sync::atomic::{AtomicU64, Ordering};
9
+ static CTR: AtomicU64 = AtomicU64::new(0);
10
+ let dir = std::env::temp_dir().join(format!(
11
+ "ta-cli-installskill-src-{}-{}",
12
+ std::process::id(),
13
+ CTR.fetch_add(1, Ordering::Relaxed)
14
+ ));
15
+ std::fs::create_dir_all(dir.join("team-agent")).unwrap();
16
+ std::fs::write(dir.join("team-agent").join("SKILL.md"), b"---\nname: team-agent\n---\nbody\n").unwrap();
17
+ dir.join("team-agent")
18
+ }
19
+
20
+ #[test]
21
+ fn dispatch_routes_install_skill_dry_run_all() {
22
+ let ws = tmp_workspace();
23
+ let source = skill_source();
24
+ // --dry-run 不落地(不碰真实 HOME),只验路由 + exit 0。
25
+ let code = run(
26
+ &[
27
+ "install-skill".to_string(),
28
+ "--target".to_string(),
29
+ "all".to_string(),
30
+ "--source".to_string(),
31
+ source.to_string_lossy().to_string(),
32
+ "--dry-run".to_string(),
33
+ "--json".to_string(),
34
+ ],
35
+ &ws,
36
+ );
37
+ assert_eq!(code, ExitCode::Ok, "`install-skill --target all --source <dir> --dry-run` must route + exit 0");
38
+ let _ = std::fs::remove_dir_all(&ws);
39
+ let _ = std::fs::remove_dir_all(source.parent().unwrap());
40
+ }
41
+
42
+ #[test]
43
+ fn install_skill_missing_source_is_usage_error() {
44
+ let ws = tmp_workspace();
45
+ // 无 --source → Usage 错(exit 2),不静默。
46
+ let code = run(
47
+ &[
48
+ "install-skill".to_string(),
49
+ "--target".to_string(),
50
+ "all".to_string(),
51
+ "--json".to_string(),
52
+ ],
53
+ &ws,
54
+ );
55
+ assert_eq!(code, ExitCode::Error, "install-skill without --source must error (CliError::Usage -> emit_cli_error exit 1)");
56
+ let _ = std::fs::remove_dir_all(&ws);
57
+ }
58
+
59
+ #[test]
60
+ fn install_skill_invalid_target_is_usage_error() {
61
+ let ws = tmp_workspace();
62
+ let source = skill_source();
63
+ let code = run(
64
+ &[
65
+ "install-skill".to_string(),
66
+ "--target".to_string(),
67
+ "bogus".to_string(),
68
+ "--source".to_string(),
69
+ source.to_string_lossy().to_string(),
70
+ ],
71
+ &ws,
72
+ );
73
+ assert_eq!(code, ExitCode::Error, "install-skill --target bogus must error (CliError::Usage -> emit_cli_error exit 1)");
74
+ let _ = std::fs::remove_dir_all(&ws);
75
+ let _ = std::fs::remove_dir_all(source.parent().unwrap());
76
+ }
@@ -86,10 +86,11 @@ impl CliError {
86
86
  payload.session_name = Some(session.clone());
87
87
  if command == "quick-start" {
88
88
  payload.action = format!(
89
- "tmux session `{session}` already exists. It may be an active team. Do not terminate existing tmux sessions from quick-start; change `name:` in TEAM.md and run quick-start again."
89
+ "tmux session `{session}` already exists. It may be your own existing team. To resume it use `team-agent restart` (NOT --fresh, which discards context). Only if you want a separate team, change `name:` in TEAM.md and run quick-start again. Never terminate existing tmux sessions from quick-start."
90
90
  );
91
91
  payload.next_actions = Some(vec![
92
- "Change `name:` in TEAM.md and run `team-agent quick-start` again.".to_string(),
92
+ "If this is your existing team, resume it with `team-agent restart`.".to_string(),
93
+ "If you want a separate team, change `name:` in TEAM.md and run `team-agent quick-start` again.".to_string(),
93
94
  ]);
94
95
  } else {
95
96
  payload.action = format!(
@@ -141,56 +141,9 @@ pub fn compile_team(team_dir: &Path) -> Result<Value, ModelError> {
141
141
  let mut agents = Vec::new();
142
142
  let mut agent_ids = Vec::new();
143
143
  for path in role_paths {
144
- let (meta, body) = read_front_matter(&path)?;
145
- let id = required_string(&meta, &path, "name")?;
146
- let role = required_string(&meta, &path, "role")?;
147
- let provider = required_string(&meta, &path, "provider")?;
148
- let model = resolve_model(&meta, &team_meta, &provider);
149
- let auth_mode = string_field(&meta, "auth_mode")
150
- .or_else(|| string_field(&team_meta, "default_auth_mode"))
151
- .unwrap_or_else(|| "subscription".to_string());
152
- if auth_mode != "subscription" && meta.get("profile").is_none() {
153
- return Err(ModelError::Validation(format!(
154
- "{}: profile is required when auth_mode is '{auth_mode}'",
155
- path.display(),
156
- )));
157
- }
158
- let tools = required_tools(&meta, &path)?;
159
- let prompt_inline = non_empty_trimmed(&body).unwrap_or_else(|| role.clone());
160
- agent_ids.push(id.clone());
161
- let mut agent_items = vec![
162
- ("id", Value::Str(id.clone())),
163
- ("role", Value::Str(role.clone())),
164
- ("provider", Value::Str(provider)),
165
- ("model", model),
166
- ("auth_mode", Value::Str(auth_mode)),
167
- ("working_directory", Value::Str(workspace_s.clone())),
168
- (
169
- "system_prompt",
170
- map(vec![
171
- ("inline", Value::Str(prompt_inline)),
172
- ("file", Value::Null),
173
- ]),
174
- ),
175
- ("tools", list_str(tools)),
176
- ("permission_mode", Value::Str("restricted".to_string())),
177
- ("preferred_for", list_str(vec![id, role])),
178
- ("avoid_for", Value::List(Vec::new())),
179
- (
180
- "output_contract",
181
- map(vec![
182
- ("format", Value::Str("result_envelope_v1".to_string())),
183
- (
184
- "required_fields",
185
- list_str(vec!["task_id", "status", "summary", "artifacts"]),
186
- ),
187
- ]),
188
- ),
189
- ];
190
- if let Some(profile) = string_field(&meta, "profile") {
191
- agent_items.push(("profile", Value::Str(profile)));
192
- }
193
- agents.push(map(agent_items));
144
+ let compiled = compile_role_agent(&path, &team_meta, &workspace_s)?;
145
+ agent_ids.push(compiled.id);
146
+ agents.push(compiled.agent);
194
147
  }
195
148
 
196
149
  let default_assignee = agent_ids.first().cloned().unwrap_or_default();
@@ -327,6 +280,76 @@ pub fn compile_team(team_dir: &Path) -> Result<Value, ModelError> {
327
280
  Ok(spec)
328
281
  }
329
282
 
283
+ /// 单个角色文档 → 编译后的 agent spec 条目(从 [`compile_team`] 的 per-role 循环抽出)。
284
+ /// E5 Bug1:add-agent 复用它**就地读** role 文件编译,不再 copy 进平台目录。
285
+ pub struct CompiledRole {
286
+ pub id: String,
287
+ pub role: String,
288
+ pub agent: Value,
289
+ }
290
+
291
+ /// 把一份 role 文档编译成 agent spec 条目。`team_meta` 供 model/auth_mode 继承;
292
+ /// `workspace_s` 是 working_directory。**纯读 `role_path`,无任何文件落地。**
293
+ pub fn compile_role_agent(
294
+ role_path: &Path,
295
+ team_meta: &Value,
296
+ workspace_s: &str,
297
+ ) -> Result<CompiledRole, ModelError> {
298
+ let (meta, body) = read_front_matter(role_path)?;
299
+ let id = required_string(&meta, role_path, "name")?;
300
+ let role = required_string(&meta, role_path, "role")?;
301
+ let provider = required_string(&meta, role_path, "provider")?;
302
+ let model = resolve_model(&meta, team_meta, &provider);
303
+ let auth_mode = string_field(&meta, "auth_mode")
304
+ .or_else(|| string_field(team_meta, "default_auth_mode"))
305
+ .unwrap_or_else(|| "subscription".to_string());
306
+ if auth_mode != "subscription" && meta.get("profile").is_none() {
307
+ return Err(ModelError::Validation(format!(
308
+ "{}: profile is required when auth_mode is '{auth_mode}'",
309
+ role_path.display(),
310
+ )));
311
+ }
312
+ let tools = required_tools(&meta, role_path)?;
313
+ let prompt_inline = non_empty_trimmed(&body).unwrap_or_else(|| role.clone());
314
+ let mut agent_items = vec![
315
+ ("id", Value::Str(id.clone())),
316
+ ("role", Value::Str(role.clone())),
317
+ ("provider", Value::Str(provider)),
318
+ ("model", model),
319
+ ("auth_mode", Value::Str(auth_mode)),
320
+ ("working_directory", Value::Str(workspace_s.to_string())),
321
+ (
322
+ "system_prompt",
323
+ map(vec![
324
+ ("inline", Value::Str(prompt_inline)),
325
+ ("file", Value::Null),
326
+ ]),
327
+ ),
328
+ ("tools", list_str(tools)),
329
+ ("permission_mode", Value::Str("restricted".to_string())),
330
+ ("preferred_for", list_str(vec![id.clone(), role.clone()])),
331
+ ("avoid_for", Value::List(Vec::new())),
332
+ (
333
+ "output_contract",
334
+ map(vec![
335
+ ("format", Value::Str("result_envelope_v1".to_string())),
336
+ (
337
+ "required_fields",
338
+ list_str(vec!["task_id", "status", "summary", "artifacts"]),
339
+ ),
340
+ ]),
341
+ ),
342
+ ];
343
+ if let Some(profile) = string_field(&meta, "profile") {
344
+ agent_items.push(("profile", Value::Str(profile)));
345
+ }
346
+ Ok(CompiledRole {
347
+ id,
348
+ role,
349
+ agent: map(agent_items),
350
+ })
351
+ }
352
+
330
353
  fn map(items: Vec<(&str, Value)>) -> Value {
331
354
  Value::Map(items.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
332
355
  }