@team-agent/installer 0.3.7 → 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.
package/Cargo.lock CHANGED
@@ -566,7 +566,7 @@ dependencies = [
566
566
 
567
567
  [[package]]
568
568
  name = "team-agent"
569
- version = "0.3.7"
569
+ version = "0.3.8"
570
570
  dependencies = [
571
571
  "anyhow",
572
572
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent"]
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.3.7"
12
+ version = "0.3.8"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -133,13 +133,33 @@ pub fn cmd_quick_start(args: &QuickStartArgs) -> Result<CmdResult, CliError> {
133
133
  }
134
134
  Ok(result)
135
135
  } else {
136
- Ok(CmdResult::human(
137
- value
138
- .get("summary")
139
- .and_then(Value::as_str)
140
- .unwrap_or("quick-start complete"),
141
- ))
136
+ // E13:happy 人类路径必须带 attach_commands(json 路径 cli/mod.rs:1775 已有)。
137
+ Ok(CmdResult::human(&quickstart_human(&value)))
138
+ }
139
+ }
140
+
141
+ /// E13:quick-start "team 起了" 人类输出 = summary + attach 块。所有成功出口共用(别每分支手拷)
142
+ /// attach_commands 缺/空 → 只 summary(向后兼容)。
143
+ fn quickstart_human(value: &Value) -> String {
144
+ let summary = value
145
+ .get("summary")
146
+ .and_then(Value::as_str)
147
+ .unwrap_or("quick-start complete");
148
+ let attach: Vec<&str> = value
149
+ .get("attach_commands")
150
+ .and_then(Value::as_array)
151
+ .map(|items| items.iter().filter_map(Value::as_str).collect())
152
+ .unwrap_or_default();
153
+ if attach.is_empty() {
154
+ return summary.to_string();
155
+ }
156
+ let mut out = String::from(summary);
157
+ out.push_str("\n\nattach:");
158
+ for cmd in attach {
159
+ out.push_str("\n ");
160
+ out.push_str(cmd);
142
161
  }
162
+ out
143
163
  }
144
164
 
145
165
  /// `cmd_compile`(`commands.py:42`)。
@@ -1522,9 +1542,34 @@ pub fn cmd_doctor(args: &DoctorArgs) -> Result<CmdResult, CliError> {
1522
1542
  mod tests {
1523
1543
  #![allow(clippy::unwrap_used)]
1524
1544
 
1525
- use super::agent_pane_id;
1545
+ use super::{agent_pane_id, quickstart_human};
1526
1546
  use serde_json::json;
1527
1547
 
1548
+ // E13:happy 人类输出必须带 attach 块(此前 else 分支只打 summary 丢 attach_commands)。
1549
+ #[test]
1550
+ fn e13_quickstart_human_includes_attach_commands() {
1551
+ let value = json!({
1552
+ "summary": "team started",
1553
+ "attach_commands": [
1554
+ "tmux -S /tmp/ta-x attach -t team-y:w1",
1555
+ "tmux -S /tmp/ta-x attach -t team-y:w2",
1556
+ ],
1557
+ });
1558
+ let out = quickstart_human(&value);
1559
+ assert!(out.contains("team started"), "must keep summary; got {out}");
1560
+ assert!(out.contains("attach:"), "must render attach block; got {out}");
1561
+ assert!(out.contains("team-y:w1") && out.contains("team-y:w2"), "must list each attach cmd; got {out}");
1562
+ }
1563
+
1564
+ #[test]
1565
+ fn e13_quickstart_human_summary_only_when_no_attach() {
1566
+ let value = json!({"summary": "quick-start complete"});
1567
+ assert_eq!(quickstart_human(&value), "quick-start complete");
1568
+ // 空数组也只 summary。
1569
+ let value2 = json!({"summary": "s", "attach_commands": []});
1570
+ assert_eq!(quickstart_human(&value2), "s");
1571
+ }
1572
+
1528
1573
  #[test]
1529
1574
  fn agent_pane_id_resolves_session_window_even_with_recorded_pane_id() {
1530
1575
  let state = json!({
@@ -108,6 +108,7 @@ fn dispatch(command: &str, args: &[String], cwd: &Path) -> Result<ExitCode, CliE
108
108
  "watch" => cmd_watch(&watch_args(args, cwd)).map(emit_result),
109
109
  "sessions" => cmd_sessions(&sessions_args(args, cwd)).map(emit_result),
110
110
  "validate" => cmd_validate(&validate_args(args, cwd)).map(emit_result),
111
+ "install-skill" => cmd_install_skill(&install_skill_args(args)?).map(emit_result),
111
112
  "profile" => cmd_profile(&profile_args(args, cwd)?).map(emit_result),
112
113
  "validate-result" if has_arg(args, "--result") => {
113
114
  eprintln!("team-agent: error: unrecognized arguments: --result");
@@ -160,6 +161,7 @@ const DISPATCH_COMMANDS: &[&str] = &[
160
161
  "watch",
161
162
  "sessions",
162
163
  "validate",
164
+ "install-skill",
163
165
  "profile",
164
166
  "validate-result",
165
167
  "collect",
@@ -230,6 +232,7 @@ fn command_help(command: Option<&str>) -> String {
230
232
  Some("watch") => "usage: team-agent watch [--workspace WORKSPACE] [--team TEAM]".to_string(),
231
233
  Some("sessions") => "usage: team-agent sessions [--workspace WORKSPACE] [--json]".to_string(),
232
234
  Some("validate") => "usage: team-agent validate [SPEC] [--json]".to_string(),
235
+ Some("install-skill") => "usage: team-agent install-skill (--source DIR | --uninstall) [--target codex|claude|copilot|all] [--dest DIR] [--dry-run] [--json]".to_string(),
233
236
  Some("profile") => "usage: team-agent profile COMMAND NAME [--workspace WORKSPACE] [--team TEAM] [--auth-mode MODE] [--json]".to_string(),
234
237
  Some("validate-result") => "usage: team-agent validate-result [ENVELOPE] [--file FILE|--result JSON] [--json]".to_string(),
235
238
  Some("collect") => "usage: team-agent collect [--workspace WORKSPACE] [--team TEAM] [--result-file FILE] [--json]".to_string(),
@@ -298,6 +301,115 @@ fn emit_usage_error(message: &str) {
298
301
  }
299
302
 
300
303
  /// `cmd_validate` delegates to runtime validate_file.
304
+ /// `install-skill` 参数(RED-1 根治:把 skill 安装单源收敛到二进制,install.mjs 调它)。
305
+ struct InstallSkillArgs {
306
+ target: crate::packaging::SkillTarget,
307
+ dest: Option<PathBuf>,
308
+ dry_run: bool,
309
+ /// `--uninstall`:删 target 的 skill 目标目录(单源,走同一 SkillTarget 表),不需 --source。
310
+ uninstall: bool,
311
+ source: Option<PathBuf>,
312
+ json: bool,
313
+ }
314
+
315
+ fn install_skill_args(args: &[String]) -> Result<InstallSkillArgs, CliError> {
316
+ let parsed = parse_args(args);
317
+ // `--target` 复用 parse_args.targets(codex|claude|copilot|all,默认 all)。
318
+ let target = match parsed.targets.as_deref() {
319
+ None | Some("all") => crate::packaging::SkillTarget::All,
320
+ Some("codex") => crate::packaging::SkillTarget::Codex,
321
+ Some("claude") => crate::packaging::SkillTarget::Claude,
322
+ Some("copilot") => crate::packaging::SkillTarget::Copilot,
323
+ Some(other) => {
324
+ return Err(CliError::Usage(format!(
325
+ "invalid --target: {other} (choose from codex, claude, copilot, all)"
326
+ )))
327
+ }
328
+ };
329
+ let uninstall = args.iter().any(|a| a == "--uninstall");
330
+ // `--source <dir>` 安装时必需(npm 包的 skills/team-agent;运行期无 CARGO_MANIFEST_DIR);
331
+ // 卸载不需要。
332
+ let source = flag_value(args, "--source").map(PathBuf::from);
333
+ if !uninstall && source.is_none() {
334
+ return Err(CliError::Usage("missing --source <skill dir>".to_string()));
335
+ }
336
+ let dest = flag_value(args, "--dest").map(PathBuf::from);
337
+ let dry_run = args.iter().any(|a| a == "--dry-run");
338
+ Ok(InstallSkillArgs {
339
+ target,
340
+ dest,
341
+ dry_run,
342
+ uninstall,
343
+ source,
344
+ json: parsed.json,
345
+ })
346
+ }
347
+
348
+ /// 取 `--flag <value>` 的值(用于 install-skill 的 --source/--dest,parse_args 不覆盖的旗标)。
349
+ fn flag_value(args: &[String], flag: &str) -> Option<String> {
350
+ args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1).cloned())
351
+ }
352
+
353
+ /// `team-agent install-skill`(RED-1 单源):repo `skills/team-agent` → `~/.codex|.claude|.copilot`。
354
+ /// install.mjs 删 JS 拷贝逻辑、改调本命令(`--target all --source <pkg>/skills/team-agent`)。
355
+ fn cmd_install_skill(args: &InstallSkillArgs) -> Result<CmdResult, CliError> {
356
+ // 卸载分支(单源:走同一 SkillTarget 表的 dest_dir;all → SINGLE_TARGETS 全集)。
357
+ if args.uninstall {
358
+ let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
359
+ let targets: Vec<crate::packaging::SkillTarget> = match args.target {
360
+ crate::packaging::SkillTarget::All => {
361
+ crate::packaging::SkillTarget::SINGLE_TARGETS.to_vec()
362
+ }
363
+ t => vec![t],
364
+ };
365
+ let mut removed: Vec<serde_json::Value> = Vec::new();
366
+ for t in targets {
367
+ if let Some(dest) = t.dest_dir(&home) {
368
+ let existed = dest.0.exists();
369
+ if existed && !args.dry_run {
370
+ std::fs::remove_dir_all(&dest.0).map_err(|e| CliError::Runtime(e.to_string()))?;
371
+ }
372
+ removed.push(serde_json::json!({
373
+ "target": t,
374
+ "dest": dest.0.to_string_lossy(),
375
+ "removed": existed,
376
+ "dry_run": args.dry_run,
377
+ }));
378
+ }
379
+ }
380
+ return Ok(CmdResult::from_json(
381
+ serde_json::json!({"ok": true, "uninstalled": removed}),
382
+ args.json,
383
+ ));
384
+ }
385
+ let source = args
386
+ .source
387
+ .clone()
388
+ .ok_or_else(|| CliError::Usage("missing --source <skill dir>".to_string()))?;
389
+ let outcomes = crate::packaging::install::install_skill(&crate::packaging::SkillInstallOptions {
390
+ target: args.target,
391
+ dest: args.dest.clone(),
392
+ dry_run: args.dry_run,
393
+ source,
394
+ })
395
+ .map_err(|e| CliError::Runtime(e.to_string()))?;
396
+ let installed: Vec<serde_json::Value> = outcomes
397
+ .iter()
398
+ .map(|o| {
399
+ serde_json::json!({
400
+ "target": o.target,
401
+ "dest": o.dest.0.to_string_lossy(),
402
+ "dry_run": o.dry_run,
403
+ "removed_stale": o.removed_stale.len(),
404
+ })
405
+ })
406
+ .collect();
407
+ Ok(CmdResult::from_json(
408
+ serde_json::json!({"ok": true, "installed": installed}),
409
+ args.json,
410
+ ))
411
+ }
412
+
301
413
  pub fn cmd_validate(args: &ValidateArgs) -> Result<CmdResult, CliError> {
302
414
  let spec = resolve_path(&args.spec);
303
415
  let value = if spec.is_dir() {
@@ -168,6 +168,7 @@ pub mod lifecycle_port {
168
168
  let run_ws = crate::model::paths::canonical_run_workspace(workspace)
169
169
  .map_err(|e| CliError::Runtime(e.to_string()))?;
170
170
  let state = shutdown_state_for_team(&run_ws, team)?;
171
+ let state_for_kill = state.clone();
171
172
  let transport = if let Some(endpoint) = legacy_worker_tmux_endpoint(&state) {
172
173
  crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint)
173
174
  } else {
@@ -176,17 +177,33 @@ pub mod lifecycle_port {
176
177
  let result =
177
178
  shutdown_with_transport_and_state(workspace, keep_logs, team, &transport, Some(state));
178
179
  if team.is_none() {
179
- // B5/F1: the leader terminal (`team-agent claude`) lives on this same
180
- // workspace socket by design (leader/start.rs); a bare shutdown must not
181
- // `kill-server` it away. Spare `team-agent-leader-*` sessions and clear the
182
- // remaining non-leader sessions individually; only an empty-of-leader socket
183
- // gets the whole-server teardown (the original leak-cleanup intent).
180
+ // E12 (P0): the leader terminal lives on this socket by design. A bare shutdown must
181
+ // NOT `kill-server` it away. spare = state-anchor sessions ∪ `team-agent-leader-*`
182
+ // prefix sessions (union; cr E12 ①). kill_server only when the socket is exclusively
183
+ // ours (no spare + no foreign session); shared socket kill our sessions individually
184
+ // (cr E12 ②). All spare derivation comes from ONE snapshot (list_targets + the state
185
+ // already loaded) — no independent ps/tmux re-derivation (N39).
184
186
  let transport_dyn: &dyn crate::transport::Transport = &transport;
187
+ let pane_targets = transport_dyn.list_targets().unwrap_or_default();
185
188
  let sessions = socket_session_names(transport_dyn);
186
- match sessions_to_kill_sparing_leader(&sessions) {
187
- None => transport.kill_server(),
188
- Some(non_leader_sessions) => {
189
- for session in &non_leader_sessions {
189
+ let event_log = crate::event_log::EventLog::new(&run_ws);
190
+ let anchor_sessions =
191
+ anchor_sessions_from_state(&state_for_kill, &pane_targets, &event_log);
192
+ let decision = sessions_to_kill(&sessions, &anchor_sessions);
193
+ match decision {
194
+ KillDecision::KillServerExclusive => transport.kill_server(),
195
+ KillDecision::KillIndividually { to_kill, spared } => {
196
+ if !spared.is_empty() || to_kill.len() != sessions.len() {
197
+ // shared socket / leader spared → never whole-server teardown.
198
+ let _ = event_log.write(
199
+ "shutdown.kill_server_skipped_shared_socket",
200
+ json!({
201
+ "spared_sessions": spared.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
202
+ "killed_sessions": to_kill.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
203
+ }),
204
+ );
205
+ }
206
+ for session in &to_kill {
190
207
  let _ = transport_dyn.kill_session(session);
191
208
  }
192
209
  }
@@ -195,6 +212,29 @@ pub mod lifecycle_port {
195
212
  result
196
213
  }
197
214
 
215
+ /// E12 ①:从 state 锚 pane_id(leader_receiver/team_owner,top+teams)映射到其所在 session
216
+ /// (经同一帧 list_targets pane→session)。state 无任何锚 → 退命名判据 + spare_fallback event。
217
+ fn anchor_sessions_from_state(
218
+ state: &Value,
219
+ pane_targets: &[crate::transport::PaneInfo],
220
+ event_log: &crate::event_log::EventLog,
221
+ ) -> std::collections::BTreeSet<String> {
222
+ let anchor_pane_ids = collect_state_leader_anchor_pane_ids(state);
223
+ if anchor_pane_ids.is_empty() {
224
+ // 无锚(state 损坏/未记)→ 退纯命名前缀判据(下游 sessions_to_kill 仍 spare 前缀)。
225
+ let _ = event_log.write(
226
+ "shutdown.spare_fallback_to_naming",
227
+ json!({"reason": "no leader_receiver/team_owner pane anchor in state"}),
228
+ );
229
+ return std::collections::BTreeSet::new();
230
+ }
231
+ pane_targets
232
+ .iter()
233
+ .filter(|pane| anchor_pane_ids.contains(pane.pane_id.as_str()))
234
+ .map(|pane| pane.session.as_str().to_string())
235
+ .collect()
236
+ }
237
+
198
238
  fn socket_session_names(
199
239
  transport: &dyn crate::transport::Transport,
200
240
  ) -> Vec<crate::transport::SessionName> {
@@ -208,26 +248,37 @@ pub mod lifecycle_port {
208
248
  .collect()
209
249
  }
210
250
 
211
- /// B5/F1 pure kill decision for the bare-shutdown socket teardown.
212
- /// `None` => no `team-agent-leader-*` session on the socket → safe to kill the whole
213
- /// server. `Some(rest)` => leader present → kill only the non-leader sessions.
214
- pub(crate) fn sessions_to_kill_sparing_leader(
251
+ /// E12 下沉纯函数:bare-shutdown socket 拆除决策。
252
+ #[derive(Debug, Clone, PartialEq, Eq)]
253
+ pub(crate) enum KillDecision {
254
+ /// socket 独享(无 spare、无外来 session) 可整 server 拆除。
255
+ KillServerExclusive,
256
+ /// 有 spare(leader 锚/前缀)或非独享 → 逐 session kill,绝不 kill-server。
257
+ KillIndividually {
258
+ to_kill: Vec<crate::transport::SessionName>,
259
+ spared: Vec<crate::transport::SessionName>,
260
+ },
261
+ }
262
+
263
+ /// E12 纯决策(单测下沉):spare = `anchor_sessions` ∪ `team-agent-leader-*` 前缀(并集,锚优先)。
264
+ /// 全部 session 都不 spare 且非空 → `KillServerExclusive`(独享 socket 兜底);否则逐 session
265
+ /// kill 非 spare 的(共享 socket / leader 在 → 绝不整 server 拆)。空 session 集 → 逐 kill(no-op)。
266
+ pub(crate) fn sessions_to_kill(
215
267
  sessions: &[crate::transport::SessionName],
216
- ) -> Option<Vec<crate::transport::SessionName>> {
217
- let leader_present = sessions
218
- .iter()
219
- .any(|session| session.as_str().starts_with(crate::leader::LEADER_SESSION_PREFIX));
220
- leader_present.then(|| {
221
- sessions
222
- .iter()
223
- .filter(|session| {
224
- !session
225
- .as_str()
226
- .starts_with(crate::leader::LEADER_SESSION_PREFIX)
227
- })
228
- .cloned()
229
- .collect()
230
- })
268
+ anchor_sessions: &std::collections::BTreeSet<String>,
269
+ ) -> KillDecision {
270
+ let is_spared = |s: &crate::transport::SessionName| {
271
+ s.as_str().starts_with(crate::leader::LEADER_SESSION_PREFIX)
272
+ || anchor_sessions.contains(s.as_str())
273
+ };
274
+ let spared: Vec<_> = sessions.iter().filter(|s| is_spared(s)).cloned().collect();
275
+ let to_kill: Vec<_> = sessions.iter().filter(|s| !is_spared(s)).cloned().collect();
276
+ // 独享 = 非空 + 无 spare(socket 上每个 session 都是要 kill 的我方 session)。
277
+ if spared.is_empty() && !sessions.is_empty() {
278
+ KillDecision::KillServerExclusive
279
+ } else {
280
+ KillDecision::KillIndividually { to_kill, spared }
281
+ }
231
282
  }
232
283
 
233
284
  pub fn shutdown_with_transport(
@@ -1782,6 +1833,7 @@ pub mod lifecycle_port {
1782
1833
  session_name,
1783
1834
  state_path,
1784
1835
  next_actions,
1836
+ attach_commands,
1785
1837
  } => json!({
1786
1838
  "ok": false,
1787
1839
  "summary": "existing runtime",
@@ -1789,20 +1841,61 @@ pub mod lifecycle_port {
1789
1841
  "session_name": session_name.map(|s| s.as_str().to_string()),
1790
1842
  "state_path": state_path.map(|p| p.to_string_lossy().to_string()),
1791
1843
  "next_actions": next_actions,
1844
+ "attach_commands": attach_commands,
1792
1845
  }),
1793
1846
  crate::lifecycle::QuickStartReport::PreflightBlocked {
1794
1847
  summary,
1795
1848
  blockers,
1796
1849
  next_actions,
1850
+ attach_commands,
1797
1851
  } => json!({
1798
1852
  "ok": false,
1799
1853
  "summary": summary,
1800
1854
  "blockers": blockers,
1801
1855
  "next_actions": next_actions,
1856
+ "attach_commands": attach_commands,
1802
1857
  }),
1803
1858
  }
1804
1859
  }
1805
1860
 
1861
+ #[cfg(test)]
1862
+ mod quick_start_value_tests {
1863
+ use super::*;
1864
+
1865
+ #[test]
1866
+ fn existing_runtime_json_includes_attach_commands() {
1867
+ let value = quick_start_value(crate::lifecycle::QuickStartReport::ExistingRuntime {
1868
+ team: Some("teamA".to_string()),
1869
+ session_name: Some(crate::transport::SessionName::new("team-teamA")),
1870
+ state_path: Some(PathBuf::from("/tmp/state.json")),
1871
+ next_actions: vec!["restart".to_string()],
1872
+ attach_commands: vec![
1873
+ "tmux -S /tmp/tmux-501/ta-test attach -t team-teamA:worker".to_string(),
1874
+ ],
1875
+ });
1876
+ assert_eq!(
1877
+ value.pointer("/attach_commands/0").and_then(Value::as_str),
1878
+ Some("tmux -S /tmp/tmux-501/ta-test attach -t team-teamA:worker"),
1879
+ "B-2: ExistingRuntime JSON must preserve attach_commands instead of only next_actions; value={value}"
1880
+ );
1881
+ }
1882
+
1883
+ #[test]
1884
+ fn preflight_blocked_json_includes_empty_attach_commands() {
1885
+ let value = quick_start_value(crate::lifecycle::QuickStartReport::PreflightBlocked {
1886
+ summary: "blocked".to_string(),
1887
+ blockers: vec!["missing TEAM.md".to_string()],
1888
+ next_actions: vec!["fix preflight blockers".to_string()],
1889
+ attach_commands: Vec::new(),
1890
+ });
1891
+ assert_eq!(
1892
+ value.get("attach_commands").and_then(Value::as_array).map(Vec::len),
1893
+ Some(0),
1894
+ "B-2: PreflightBlocked JSON must include attach_commands: [] for schema parity with Ready/Restart; value={value}"
1895
+ );
1896
+ }
1897
+ }
1898
+
1806
1899
  fn restart_value(report: crate::lifecycle::RestartReport) -> Value {
1807
1900
  match report {
1808
1901
  crate::lifecycle::RestartReport::Restarted {
@@ -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;