@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
@@ -57,6 +57,44 @@ fn dest_dir_claude_resolves_to_dot_claude() {
57
57
  );
58
58
  }
59
59
 
60
+ #[test]
61
+ fn skill_target_copilot_maps_to_provider_copilot() {
62
+ // E9: copilot → Provider::Copilot (0.3.x 新增 provider,不归到 codex/claude).
63
+ assert_eq!(SkillTarget::Copilot.provider(), Some(Provider::Copilot));
64
+ }
65
+
66
+ #[test]
67
+ fn dest_dir_copilot_resolves_to_dot_copilot() {
68
+ // E9 实证 (.team/artifacts/copilot-probe/):copilot skill 源 = ~/.copilot/skills,
69
+ // configDir 默认 ~/.copilot → ~/.copilot/skills/team-agent.
70
+ let home = Path::new("/home/testuser");
71
+ let got = SkillTarget::Copilot.dest_dir(home);
72
+ assert_eq!(
73
+ got,
74
+ Some(SkillDestDir(PathBuf::from(
75
+ "/home/testuser/.copilot/skills/team-agent"
76
+ )))
77
+ );
78
+ }
79
+
80
+ #[test]
81
+ fn dest_dir_three_providers_distinct_locations() {
82
+ // E9: 三 provider 各自独立位置 (.codex / .claude / .copilot),互不踩.
83
+ let home = Path::new("/home/testuser");
84
+ let dirs: Vec<PathBuf> = SkillTarget::SINGLE_TARGETS
85
+ .iter()
86
+ .map(|t| t.dest_dir(home).expect("single target has dest").0)
87
+ .collect();
88
+ assert_eq!(
89
+ dirs,
90
+ vec![
91
+ PathBuf::from("/home/testuser/.codex/skills/team-agent"),
92
+ PathBuf::from("/home/testuser/.claude/skills/team-agent"),
93
+ PathBuf::from("/home/testuser/.copilot/skills/team-agent"),
94
+ ]
95
+ );
96
+ }
97
+
60
98
  #[test]
61
99
  fn dest_dir_all_is_none_not_single_dir() {
62
100
  // `All` 应 fan-out 到两者 → 非单 dir → None (skeleton:116).
@@ -520,14 +558,15 @@ fn install_skill_dry_run_single_target_reports_plan_no_side_effects() {
520
558
  }
521
559
 
522
560
  #[test]
523
- fn install_skill_dry_run_target_all_fans_out_to_two() {
524
- // commands.py:458-463 target all → 两个 outcome (codex + claude),顺序固定.
561
+ fn install_skill_dry_run_target_all_fans_out_to_all_providers() {
562
+ // E9: target all → 表驱动 fan-out 全 provider (codex + claude + copilot),顺序固定.
525
563
  let opts = skill_opts(SkillTarget::All, None, true);
526
564
  let outcomes = install_skill(&opts).expect("dry-run install-skill all");
527
- assert_eq!(outcomes.len(), 2, "all → fan-out codex+claude");
528
- // KEY ORDER:commands.py:460-461 codex first, claude second.
565
+ assert_eq!(outcomes.len(), 3, "all → fan-out codex+claude+copilot");
566
+ // KEY ORDER:SINGLE_TARGETS 顺序 codex, claude, copilot.
529
567
  assert_eq!(outcomes[0].target, SkillTarget::Codex);
530
568
  assert_eq!(outcomes[1].target, SkillTarget::Claude);
569
+ assert_eq!(outcomes[2].target, SkillTarget::Copilot);
531
570
  assert!(outcomes.iter().all(|o| o.dry_run));
532
571
  }
533
572
 
@@ -791,6 +830,43 @@ impl Drop for HomeGuard {
791
830
  }
792
831
  }
793
832
 
833
+ // E9 — install-skill --target all (real copy) must land team-agent SKILL.md in ALL THREE
834
+ // provider locations (~/.codex|.claude|.copilot/skills/team-agent) and the copied bytes must
835
+ // equal the source. This is the "三 provider 位置断言(install 后文件存在且=源)" gate.
836
+ #[test]
837
+ #[serial_test::serial(env)]
838
+ fn install_skill_all_real_copies_to_three_provider_locations() {
839
+ let _g = ENV_LOCK_PKG.lock().unwrap_or_else(|p| p.into_inner());
840
+ let base = std::env::temp_dir().join(format!("ta-e9-skill-all-{}", std::process::id()));
841
+ let home = base.join("home");
842
+ std::fs::create_dir_all(&home).unwrap();
843
+ // Build a real skill source tree with a recognizable SKILL.md.
844
+ let source = base.join("src-skill");
845
+ std::fs::create_dir_all(&source).unwrap();
846
+ let body = b"---\nname: team-agent\n---\nE9 source marker\n";
847
+ std::fs::write(source.join("SKILL.md"), body).unwrap();
848
+ let _h = HomeGuard::set(&home);
849
+
850
+ let opts = SkillInstallOptions {
851
+ target: SkillTarget::All,
852
+ dest: None,
853
+ dry_run: false,
854
+ source: source.clone(),
855
+ };
856
+ let outcomes = install_skill(&opts).expect("real install-skill all");
857
+ assert_eq!(outcomes.len(), 3, "all → codex+claude+copilot");
858
+
859
+ for sub in [".codex", ".claude", ".copilot"] {
860
+ let dest = home.join(sub).join("skills").join("team-agent").join("SKILL.md");
861
+ assert!(dest.exists(), "{sub}: SKILL.md must exist after install");
862
+ assert_eq!(
863
+ std::fs::read(&dest).unwrap(),
864
+ body,
865
+ "{sub}: installed SKILL.md bytes must equal source"
866
+ );
867
+ }
868
+ }
869
+
794
870
  // P1 — update() must perform a REAL atomic replace (rename dest→.previous), not fabricate
795
871
  // a Replaced outcome whose backup file never exists (install.mjs:60-66; bug-084).
796
872
  #[test]
@@ -819,21 +895,21 @@ fn p2_update_creates_real_atomic_replace_backup() {
819
895
  );
820
896
  }
821
897
 
822
- // P1 — uninstall() must remove BOTH ~/.codex/skills/team-agent and ~/.claude/skills/team-agent
823
- // and record them (install.mjs:115-122). Current returns removed_skill_dirs empty and leaves
824
- // the dirs on disk.
898
+ // P1 — uninstall() must remove ALL provider skill dirs (~/.codex|.claude|.copilot/skills/team-agent)
899
+ // and record them (install.mjs:115-122 + E9 copilot). Table-driven over SkillTarget::SINGLE_TARGETS.
825
900
  #[test]
826
901
  #[serial_test::serial(env)]
827
- fn p2_uninstall_removes_both_skill_dirs() {
902
+ fn p2_uninstall_removes_all_provider_skill_dirs() {
828
903
  let _g = ENV_LOCK_PKG.lock().unwrap_or_else(|p| p.into_inner());
829
904
  let base = std::env::temp_dir().join(format!("ta-p2-uninst-{}", std::process::id()));
830
905
  let home = base.join("home");
831
906
  let codex = home.join(".codex").join("skills").join("team-agent");
832
907
  let claude = home.join(".claude").join("skills").join("team-agent");
833
- std::fs::create_dir_all(&codex).unwrap();
834
- std::fs::create_dir_all(&claude).unwrap();
835
- std::fs::write(codex.join("SKILL.md"), b"x").unwrap();
836
- std::fs::write(claude.join("SKILL.md"), b"x").unwrap();
908
+ let copilot = home.join(".copilot").join("skills").join("team-agent");
909
+ for d in [&codex, &claude, &copilot] {
910
+ std::fs::create_dir_all(d).unwrap();
911
+ std::fs::write(d.join("SKILL.md"), b"x").unwrap();
912
+ }
837
913
  let _h = HomeGuard::set(&home);
838
914
 
839
915
  let opts = UninstallOptions {
@@ -844,9 +920,10 @@ fn p2_uninstall_removes_both_skill_dirs() {
844
920
  let out = uninstall(&opts).unwrap();
845
921
  assert_eq!(
846
922
  out.removed_skill_dirs.len(),
847
- 2,
848
- "uninstall must remove BOTH ~/.codex and ~/.claude skill dirs"
923
+ 3,
924
+ "uninstall must remove ~/.codex, ~/.claude AND ~/.copilot skill dirs"
849
925
  );
850
926
  assert!(!codex.exists(), "~/.codex skill dir must be removed");
851
927
  assert!(!claude.exists(), "~/.claude skill dir must be removed");
928
+ assert!(!copilot.exists(), "~/.copilot skill dir must be removed");
852
929
  }
@@ -40,24 +40,36 @@ pub enum InstallerCommand {
40
40
  pub enum SkillTarget {
41
41
  Codex,
42
42
  Claude,
43
+ /// GitHub Copilot CLI(0.3.x 新增 provider)。skill 落 `~/.copilot/skills/team-agent`
44
+ /// —— 实证:copilot bundle skill 源枚举含 `personal-copilot (~/.copilot/skills)`,
45
+ /// config dir = `~/.copilot`(可被 `$COPILOT_HOME` 覆盖),manifest 约定 `**/SKILL.md`
46
+ /// 同 claude/codex(见 `.team/artifacts/copilot-probe/`)。
47
+ Copilot,
43
48
  All,
44
49
  }
45
50
 
46
51
  impl SkillTarget {
52
+ /// `All` fan-out 的单目标全集(表驱动唯一真相源:新增 provider 只改这里,
53
+ /// install/uninstall/install-skill 三处共用,杜绝漏装/漏卸)。
54
+ pub const SINGLE_TARGETS: [SkillTarget; 3] =
55
+ [SkillTarget::Codex, SkillTarget::Claude, SkillTarget::Copilot];
56
+
47
57
  /// 单目标 → 对应 provider(`All` 无单一 provider → `None`)。与 [`Provider`] 对齐,防散字符串再生。
48
58
  pub fn provider(self) -> Option<Provider> {
49
59
  match self {
50
60
  Self::Codex => Some(Provider::Codex),
51
61
  Self::Claude => Some(Provider::ClaudeCode),
62
+ Self::Copilot => Some(Provider::Copilot),
52
63
  Self::All => None,
53
64
  }
54
65
  }
55
66
 
56
- /// `_skill_dest_dir`:`~/.codex|.claude/skills/team-agent`(`All` fan-out 到两者,非单 dir → None)。
67
+ /// `_skill_dest_dir`:`~/.codex|.claude|.copilot/skills/team-agent`(`All` fan-out 全集,非单 dir → None)。
57
68
  pub fn dest_dir(self, home: &Path) -> Option<SkillDestDir> {
58
69
  match self {
59
70
  Self::Codex => Some(SkillDestDir(home.join(".codex").join("skills").join("team-agent"))),
60
71
  Self::Claude => Some(SkillDestDir(home.join(".claude").join("skills").join("team-agent"))),
72
+ Self::Copilot => Some(SkillDestDir(home.join(".copilot").join("skills").join("team-agent"))),
61
73
  Self::All => None,
62
74
  }
63
75
  }
@@ -1025,6 +1025,43 @@ fn scan_session_candidates_once(
1025
1025
  agent_path_match,
1026
1026
  });
1027
1027
  }
1028
+ // E6 层1·C(机会性兜底):若盘上真有 expected_session_id 命名的 transcript(claude 哪天
1029
+ // 真采用 --session-id,或别的 provider 本就采用),直接唯一命中,省去时间窗扫描。
1030
+ // 命不中(交互式 claude 现实:不落 <expected>.jsonl)→ 回落 B。
1031
+ if let Some(expected) = context.expected_session_id.as_ref() {
1032
+ if let Some(hit) = out.iter().find(|candidate| {
1033
+ candidate
1034
+ .captured
1035
+ .session_id
1036
+ .as_ref()
1037
+ .is_some_and(|session| session.as_str() == expected.as_str())
1038
+ }) {
1039
+ return Ok(vec![hit.clone()]);
1040
+ }
1041
+ }
1042
+ // E6 层1·B(主路径,交互式现实):cwd 匹配但盘上有多个 sibling transcript(claude 自生成,
1043
+ // 不采用预定 UUID)→ 用 spawn 时间窗唯一选:只留 mtime >= spawned_at 的候选,打破歧义。
1044
+ // spawned_at 缺/无法解析时不收窄(保守,维持既有行为)。
1045
+ if context.expected_session_id.is_none() || out.len() > 1 {
1046
+ if let Some(spawned_at) = context.spawned_at.as_deref().and_then(parse_spawned_at) {
1047
+ let within: Vec<CapturedSessionCandidate> = out
1048
+ .iter()
1049
+ .filter(|candidate| {
1050
+ candidate
1051
+ .captured
1052
+ .rollout_path
1053
+ .as_ref()
1054
+ .and_then(|p| std::fs::metadata(p.as_path()).and_then(|m| m.modified()).ok())
1055
+ .is_some_and(|mtime| mtime >= spawned_at)
1056
+ })
1057
+ .cloned()
1058
+ .collect();
1059
+ // 只有当时间窗把候选收成唯一时才采用(收成 0 或仍多义则不强行,交给上层 ambiguous)。
1060
+ if within.len() == 1 {
1061
+ return Ok(within);
1062
+ }
1063
+ }
1064
+ }
1028
1065
  if let Some(expected) = context.expected_session_id.as_ref() {
1029
1066
  out.sort_by_key(|candidate| {
1030
1067
  candidate
@@ -1037,6 +1074,28 @@ fn scan_session_candidates_once(
1037
1074
  Ok(out)
1038
1075
  }
1039
1076
 
1077
+ /// 解析 state 里的 `spawned_at`(RFC3339)为 SystemTime,用于 spawn 时间窗候选筛选。
1078
+ /// 解析失败 → None(调用方据此不收窄时间窗,保守维持既有行为)。
1079
+ fn parse_spawned_at(raw: &str) -> Option<std::time::SystemTime> {
1080
+ chrono::DateTime::parse_from_rfc3339(raw)
1081
+ .ok()
1082
+ .map(|dt| std::time::SystemTime::from(dt.with_timezone(&chrono::Utc)))
1083
+ }
1084
+
1085
+ /// E6 层1·B:把 spawn cwd 映射成 claude transcript 子目录 `~/.claude/projects/<encoded>`。
1086
+ /// claude 用 **canonical(realpath)** cwd 且把每个 `/` 替换成 `-`(实证:cwd
1087
+ /// `/private/tmp/x` → `-private-tmp-x`;macOS `/tmp`→`/private/tmp` 必须先 canonical)。
1088
+ /// canonical 失败(目录已不在)退回原始路径,仍尽力编码。dir 不存在也返回(调用方
1089
+ /// `collect_optional_candidate_files` 对不存在目录是 no-op)。
1090
+ fn claude_projects_dir_for_cwd(home: &Path, spawn_cwd: &Path) -> Option<PathBuf> {
1091
+ let canonical = std::fs::canonicalize(spawn_cwd).unwrap_or_else(|_| spawn_cwd.to_path_buf());
1092
+ let encoded = canonical.to_string_lossy().replace('/', "-");
1093
+ if encoded.is_empty() {
1094
+ return None;
1095
+ }
1096
+ Some(home.join(".claude").join("projects").join(encoded))
1097
+ }
1098
+
1040
1099
  /// §C4 cr verdict — copilot session 真相源 sqlite 点查。
1041
1100
  ///
1042
1101
  /// 路径:`<HOME>/.copilot/session-store.db`,sessions 表(id/cwd/created_at/updated_at)
@@ -1113,6 +1172,12 @@ fn candidate_session_files(
1113
1172
  }
1114
1173
  Provider::Claude | Provider::ClaudeCode => {
1115
1174
  collect_optional_candidate_files(&home.join(".claude").join("sessions"), &context.agent_id, &mut out)?;
1175
+ // E6 层1·B:优先锚到 ~/.claude/projects/<canonical spawn_cwd 编码> 子目录
1176
+ // (claude 把 cwd 的 '/' 编码成 '-';交互式 worker 的真实 transcript 落在此),
1177
+ // 而非全 projects 树盲扫(交互式 claude 自生成 UUID,锚 cwd 子目录 + 时间窗才能唯一选)。
1178
+ if let Some(dir) = claude_projects_dir_for_cwd(&home, &context.spawn_cwd) {
1179
+ collect_optional_candidate_files(&dir, &context.agent_id, &mut out)?;
1180
+ }
1116
1181
  collect_optional_candidate_files(&home.join(".claude").join("projects"), &context.agent_id, &mut out)?;
1117
1182
  }
1118
1183
  // §C4 cr verdict + 设计 §C: copilot session 真相源是 ~/.copilot/session-store.db
@@ -1765,3 +1830,142 @@ fn next_session_token() -> String {
1765
1830
  bytes[15],
1766
1831
  )
1767
1832
  }
1833
+
1834
+ #[cfg(test)]
1835
+ mod e6_session_attribution_tests {
1836
+ #![allow(clippy::unwrap_used)]
1837
+ use super::*;
1838
+ use std::sync::atomic::{AtomicU64, Ordering};
1839
+
1840
+ fn tmp_root(tag: &str) -> PathBuf {
1841
+ static CTR: AtomicU64 = AtomicU64::new(0);
1842
+ let dir = std::env::temp_dir().join(format!(
1843
+ "ta-e6-attr-{}-{}-{}",
1844
+ tag,
1845
+ std::process::id(),
1846
+ CTR.fetch_add(1, Ordering::Relaxed)
1847
+ ));
1848
+ std::fs::create_dir_all(&dir).unwrap();
1849
+ dir
1850
+ }
1851
+
1852
+ fn write_transcript(dir: &Path, uuid: &str, cwd: &Path) -> PathBuf {
1853
+ std::fs::create_dir_all(dir).unwrap();
1854
+ let path = dir.join(format!("{uuid}.jsonl"));
1855
+ let line = serde_json::json!({
1856
+ "sessionId": uuid,
1857
+ "cwd": cwd.to_string_lossy(),
1858
+ });
1859
+ std::fs::write(&path, format!("{line}\n")).unwrap();
1860
+ path
1861
+ }
1862
+
1863
+ #[test]
1864
+ fn claude_projects_dir_for_cwd_encodes_slashes_to_dashes() {
1865
+ let home = Path::new("/home/u");
1866
+ // 用一个真实存在的 cwd 让 canonicalize 成功(否则退回原始);用 tmp。
1867
+ let cwd = tmp_root("encode");
1868
+ let got = claude_projects_dir_for_cwd(home, &cwd).unwrap();
1869
+ let canon = std::fs::canonicalize(&cwd).unwrap();
1870
+ let expected_leaf = canon.to_string_lossy().replace('/', "-");
1871
+ assert_eq!(got, home.join(".claude").join("projects").join(expected_leaf));
1872
+ let _ = std::fs::remove_dir_all(&cwd);
1873
+ }
1874
+
1875
+ #[test]
1876
+ fn parse_spawned_at_rfc3339_roundtrips_and_rejects_junk() {
1877
+ assert!(parse_spawned_at("2026-06-10T21:40:00+00:00").is_some());
1878
+ assert!(parse_spawned_at("not-a-date").is_none());
1879
+ assert!(parse_spawned_at("").is_none());
1880
+ }
1881
+
1882
+ #[test]
1883
+ fn scan_expected_session_id_hit_returns_only_that_candidate() {
1884
+ // C 兜底:盘上恰有 <expected>.jsonl(假设 claude 哪天真采用)→ 唯一命中,忽略 sibling。
1885
+ let base = tmp_root("c-hit");
1886
+ let cwd = base.join("ws");
1887
+ std::fs::create_dir_all(&cwd).unwrap();
1888
+ let proj = base.join("projects");
1889
+ write_transcript(&proj, "11111111-1111-4111-8111-111111111111", &cwd);
1890
+ write_transcript(&proj, "22222222-2222-4222-8222-222222222222", &cwd);
1891
+ let ctx = CaptureSessionContext {
1892
+ agent_id: "w1".to_string(),
1893
+ spawn_cwd: cwd.clone(),
1894
+ pane_id: None,
1895
+ pane_pid: None,
1896
+ spawned_at: None,
1897
+ expected_session_id: Some(SessionId::new("22222222-2222-4222-8222-222222222222")),
1898
+ provider_projects_root: Some(proj.clone()),
1899
+ };
1900
+ let out = scan_session_candidates_once(Provider::ClaudeCode, &ctx).unwrap();
1901
+ assert_eq!(out.len(), 1, "expected-id hit must collapse to the single match");
1902
+ assert_eq!(
1903
+ out[0].captured.session_id.as_ref().unwrap().as_str(),
1904
+ "22222222-2222-4222-8222-222222222222"
1905
+ );
1906
+ let _ = std::fs::remove_dir_all(&base);
1907
+ }
1908
+
1909
+ #[test]
1910
+ fn scan_spawn_time_window_disambiguates_two_siblings() {
1911
+ // B 主路径:claude 不采用预定 UUID,盘上两个自生成 sibling 都匹配 cwd。
1912
+ // 只有一个在 spawn 时间窗内(mtime >= spawned_at)→ 时间窗唯一选出它。
1913
+ let base = tmp_root("b-window");
1914
+ let cwd = base.join("ws");
1915
+ std::fs::create_dir_all(&cwd).unwrap();
1916
+ let proj = base.join("projects");
1917
+ let old = write_transcript(&proj, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", &cwd);
1918
+ let new = write_transcript(&proj, "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", &cwd);
1919
+ // 把 old 的 mtime 设到很久以前,new 保持现在;spawned_at = 两者之间。
1920
+ let long_ago = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000);
1921
+ filetime_set(&old, long_ago);
1922
+ // spawned_at 取一个介于 old 与 new 之间、肯定早于 new 真实 mtime 的时刻(2020 年)。
1923
+ let ctx = CaptureSessionContext {
1924
+ agent_id: "w1".to_string(),
1925
+ spawn_cwd: cwd.clone(),
1926
+ pane_id: None,
1927
+ pane_pid: None,
1928
+ spawned_at: Some("2020-01-01T00:00:00+00:00".to_string()),
1929
+ expected_session_id: None,
1930
+ provider_projects_root: Some(proj.clone()),
1931
+ };
1932
+ let out = scan_session_candidates_once(Provider::ClaudeCode, &ctx).unwrap();
1933
+ assert_eq!(out.len(), 1, "time window must collapse two siblings to one");
1934
+ assert_eq!(
1935
+ out[0].captured.session_id.as_ref().unwrap().as_str(),
1936
+ "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
1937
+ "the in-window (recent) transcript must win"
1938
+ );
1939
+ let _ = new;
1940
+ let _ = std::fs::remove_dir_all(&base);
1941
+ }
1942
+
1943
+ #[test]
1944
+ fn scan_no_spawned_at_keeps_both_siblings_ambiguous() {
1945
+ // 保守:spawned_at 缺 → 不收窄时间窗,两 sibling 仍并存(交上层 ambiguous 处理)。
1946
+ let base = tmp_root("b-noamb");
1947
+ let cwd = base.join("ws");
1948
+ std::fs::create_dir_all(&cwd).unwrap();
1949
+ let proj = base.join("projects");
1950
+ write_transcript(&proj, "cccccccc-cccc-4ccc-8ccc-cccccccccccc", &cwd);
1951
+ write_transcript(&proj, "dddddddd-dddd-4ddd-8ddd-dddddddddddd", &cwd);
1952
+ let ctx = CaptureSessionContext {
1953
+ agent_id: "w1".to_string(),
1954
+ spawn_cwd: cwd.clone(),
1955
+ pane_id: None,
1956
+ pane_pid: None,
1957
+ spawned_at: None,
1958
+ expected_session_id: None,
1959
+ provider_projects_root: Some(proj.clone()),
1960
+ };
1961
+ let out = scan_session_candidates_once(Provider::ClaudeCode, &ctx).unwrap();
1962
+ assert!(out.len() >= 2, "no spawned_at → no time-window narrowing");
1963
+ let _ = std::fs::remove_dir_all(&base);
1964
+ }
1965
+
1966
+ fn filetime_set(path: &Path, when: std::time::SystemTime) {
1967
+ // 用 utimensat 经 std:无直接 set_mtime,借 filetime-free 方式:写后用 File::set_modified。
1968
+ let f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
1969
+ f.set_modified(when).unwrap();
1970
+ }
1971
+ }
@@ -4,7 +4,7 @@ use std::path::{Path, PathBuf};
4
4
 
5
5
  use serde_json::Value;
6
6
 
7
- use crate::model::paths::{canonical_run_workspace, team_workspace};
7
+ use crate::model::paths::{canonical_run_workspace, runtime_spec_path, team_workspace};
8
8
  use crate::state::persist::{load_runtime_state, runtime_state_path};
9
9
  use crate::state::projection::{select_runtime_state, team_state_key};
10
10
  use crate::state::StateError;
@@ -20,7 +20,13 @@ pub struct SelectedTeam {
20
20
  pub run_workspace: PathBuf,
21
21
  pub team_key: String,
22
22
  pub state: Value,
23
+ /// E5 §3 解耦:**角色定义目录**(用户目录,含 TEAM.md+agents/*.md+profiles)。
24
+ /// 给 compile_team / 找角色定义 / profiles。**永远是用户目录**,不是 spec 落点。
25
+ /// 来源 = state.team_dir;缺则回落 run_workspace(自含 team-dir 布局)。
26
+ pub team_dir: PathBuf,
27
+ /// spec yaml 所在目录(demote 后 = .team/runtime/<team_key>/)。读写 spec yaml 用。
23
28
  pub spec_workspace: Option<PathBuf>,
29
+ /// spec yaml 路径(= runtime_spec_path(run_ws, team_key))。读写 spec yaml 用。
24
30
  pub spec_path: Option<PathBuf>,
25
31
  }
26
32
 
@@ -30,7 +36,7 @@ pub fn resolve_active_team(
30
36
  mode: SelectorMode,
31
37
  ) -> Result<SelectedTeam, StateError> {
32
38
  let explicit_spec = input.join("team.spec.yaml");
33
- let (run_workspace, state, spec_workspace) = if explicit_spec.exists() {
39
+ let (run_workspace, state) = if explicit_spec.exists() {
34
40
  let team_run = team_workspace(input).map_err(|e| StateError::TeamSelect(e.to_string()))?;
35
41
  let run = if runtime_state_path(input).exists() || !runtime_state_path(&team_run).exists() {
36
42
  input.to_path_buf()
@@ -38,7 +44,7 @@ pub fn resolve_active_team(
38
44
  team_run
39
45
  };
40
46
  let state = select_runtime_state(&run, team).or_else(|_| load_runtime_state(&run))?;
41
- (run, state, Some(input.to_path_buf()))
47
+ (run, state)
42
48
  } else {
43
49
  let run = canonical_run_workspace(input)
44
50
  .map_err(|e| StateError::TeamSelect(e.to_string()))?;
@@ -53,30 +59,58 @@ pub fn resolve_active_team(
53
59
  )));
54
60
  }
55
61
  let state = select_runtime_state(&run, team).or_else(|_| load_runtime_state(&run))?;
56
- let spec_workspace = spec_workspace_from_state(&state)
57
- .or_else(|| run.join("team.spec.yaml").exists().then(|| run.clone()));
58
- (run, state, spec_workspace)
62
+ (run, state)
59
63
  };
60
64
 
61
- let spec_path = spec_workspace.as_ref().map(|workspace| workspace.join("team.spec.yaml"));
65
+ // E5 spec 迁移·读序 B(architect+leader 裁定):
66
+ // 1) runtime spec 优先严格:<run_ws>/.team/runtime/<team_key>/team.spec.yaml 存在即必用。
67
+ // 2) 缺失才**只读回落**用户目录旧 spec(过渡腿;绝不在此写/迁移——迁移+清理只属启动重建)。
68
+ // TODO(E5 后续版本):新 team 永不写用户目录(G1),回落腿可在 legacy 清零后移除。
69
+ let team_key = selected_team_key(&state, team);
70
+ let runtime_spec = runtime_spec_path(&run_workspace, &team_key);
71
+ let (spec_workspace, spec_path) = if runtime_spec.exists() {
72
+ (
73
+ runtime_spec.parent().map(Path::to_path_buf),
74
+ Some(runtime_spec.clone()),
75
+ )
76
+ } else {
77
+ // 回落(只读):优先 explicit input/team.spec.yaml,其次 state 推断的 spec_workspace。
78
+ let legacy_ws = if explicit_spec.exists() {
79
+ Some(input.to_path_buf())
80
+ } else {
81
+ spec_workspace_from_state(&state)
82
+ .or_else(|| run_workspace.join("team.spec.yaml").exists().then(|| run_workspace.clone()))
83
+ };
84
+ let legacy_spec = legacy_ws.as_ref().map(|ws| ws.join("team.spec.yaml"));
85
+ (legacy_ws, legacy_spec)
86
+ };
62
87
  if matches!(mode, SelectorMode::RequireSpec) && !spec_path.as_ref().is_some_and(|path| path.exists()) {
63
- let expected = spec_path
64
- .as_ref()
65
- .cloned()
66
- .unwrap_or_else(|| run_workspace.join("team.spec.yaml"));
88
+ // 期望路径报 canonical runtime spec(重建落点),非用户目录。
89
+ let expected = spec_path.as_ref().cloned().unwrap_or(runtime_spec);
90
+ // E5 Bug2 N38:spec=中间产物,运行期由 restart 以角色定义重建;首装走 quick-start;
91
+ // 加新角色用 add-agent。不再提 reconcile(已废)
67
92
  return Err(StateError::TeamSelect(format!(
68
- "active team spec not found: input_workspace={} run_workspace={} team_key={} expected_spec_path={} hint=run quick-start or pass --team/--workspace <teamdir>",
93
+ "active team spec not found: input_workspace={} run_workspace={} team_key={} expected_spec_path={} hint=run `team-agent restart` to rebuild it from the role docs, or `team-agent quick-start <teamdir>` for first launch (to add a role at runtime use `team-agent add-agent <id> --role-file <path>`)",
69
94
  input.display(),
70
95
  run_workspace.display(),
71
- selected_team_key(&state, team),
96
+ team_key,
72
97
  expected.display()
73
98
  )));
74
99
  }
75
100
 
101
+ // E5 §3 解耦:team_dir = 角色定义目录(用户目录),恒取 state.team_dir;缺则回落 run_workspace。
102
+ let team_dir = state
103
+ .get("team_dir")
104
+ .and_then(Value::as_str)
105
+ .filter(|s| !s.is_empty())
106
+ .map(PathBuf::from)
107
+ .unwrap_or_else(|| run_workspace.clone());
108
+
76
109
  Ok(SelectedTeam {
77
110
  run_workspace,
78
- team_key: selected_team_key(&state, team),
111
+ team_key,
79
112
  state,
113
+ team_dir,
80
114
  spec_workspace,
81
115
  spec_path,
82
116
  })
@@ -448,9 +448,21 @@ impl TmuxBackend {
448
448
  ];
449
449
  let output = self.run_spawn(&pane_argv)?;
450
450
  let pane = output.stdout.trim();
451
- let pane_id = if pane.is_empty() { "%0" } else { pane };
451
+ // T3-5 (harvest §1): never fabricate a `%0` pane id on an empty reply — a fake
452
+ // pane id mis-addresses every later inject/capture/kill. Surface the miss.
453
+ if pane.is_empty() {
454
+ return Err(TransportError::Subprocess {
455
+ argv: pane_argv,
456
+ code: output.code,
457
+ stderr: format!(
458
+ "tmux display-message returned no pane id for {}:{}",
459
+ session.as_str(),
460
+ window.as_str()
461
+ ),
462
+ });
463
+ }
452
464
  Ok(SpawnResult {
453
- pane_id: PaneId::new(pane_id),
465
+ pane_id: PaneId::new(pane),
454
466
  session: session.clone(),
455
467
  window: window.clone(),
456
468
  child_pid: None,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.3.6",
3
+ "version": "0.3.7",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,9 +20,9 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@team-agent/cli-darwin-arm64": "0.3.6",
24
- "@team-agent/cli-darwin-x64": "0.3.6",
25
- "@team-agent/cli-linux-x64": "0.3.6"
23
+ "@team-agent/cli-darwin-arm64": "0.3.7",
24
+ "@team-agent/cli-darwin-x64": "0.3.7",
25
+ "@team-agent/cli-linux-x64": "0.3.7"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",