@team-agent/installer 0.4.1 → 0.4.2

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 (44) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +82 -22
  4. package/crates/team-agent/src/cli/diagnose.rs +6 -2
  5. package/crates/team-agent/src/cli/emit.rs +301 -38
  6. package/crates/team-agent/src/cli/mod.rs +7 -14
  7. package/crates/team-agent/src/cli/status_port.rs +12 -1
  8. package/crates/team-agent/src/cli/tests/base.rs +6 -2
  9. package/crates/team-agent/src/cli/tests/lane_c.rs +1 -1
  10. package/crates/team-agent/src/cli/tests/leader_watch.rs +5 -3
  11. package/crates/team-agent/src/cli/tests/main_preserved.rs +28 -2
  12. package/crates/team-agent/src/cli/tests/peer_allow.rs +19 -0
  13. package/crates/team-agent/src/cli/tests/repair_state_byte_lock.rs +72 -0
  14. package/crates/team-agent/src/cli/tests/run_delegation.rs +1 -3
  15. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  16. package/crates/team-agent/src/cli/types.rs +35 -2
  17. package/crates/team-agent/src/coordinator/runtime_detectors.rs +7 -2
  18. package/crates/team-agent/src/diagnose/comms.rs +10 -2
  19. package/crates/team-agent/src/leader/lease.rs +98 -19
  20. package/crates/team-agent/src/leader/rediscover/tests.rs +21 -7
  21. package/crates/team-agent/src/leader/rediscover.rs +16 -7
  22. package/crates/team-agent/src/leader/start.rs +25 -12
  23. package/crates/team-agent/src/leader/tests/byte_findings.rs +11 -2
  24. package/crates/team-agent/src/leader/tests/lease_claim.rs +248 -7
  25. package/crates/team-agent/src/lifecycle/launch.rs +116 -100
  26. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +5 -12
  27. package/crates/team-agent/src/lifecycle/tests/core.rs +1 -1
  28. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +16 -15
  29. package/crates/team-agent/src/lifecycle/types.rs +1 -1
  30. package/crates/team-agent/src/messaging/leader_receiver.rs +22 -8
  31. package/crates/team-agent/src/messaging/send.rs +21 -4
  32. package/crates/team-agent/src/provider/adapter.rs +22 -0
  33. package/crates/team-agent/src/provider/session/capture.rs +228 -0
  34. package/crates/team-agent/src/state/identity.rs +42 -12
  35. package/crates/team-agent/src/state/identity_keys.rs +134 -0
  36. package/crates/team-agent/src/state/mod.rs +8 -0
  37. package/crates/team-agent/src/state/owner_gate.rs +11 -1
  38. package/crates/team-agent/src/state/ownership.rs +556 -0
  39. package/crates/team-agent/src/state/paths.rs +358 -0
  40. package/crates/team-agent/src/state/persist.rs +43 -5
  41. package/crates/team-agent/src/state/projection.rs +13 -5
  42. package/crates/team-agent/src/tmux_backend.rs +12 -0
  43. package/package.json +4 -4
  44. package/skills/team-agent/SKILL.md +3 -3
@@ -0,0 +1,358 @@
1
+ //! Stage 0 of the identity-boundary unified plan (architect direction
2
+ //! 2026-06-23): `TeamScope` + `TeamRuntimePaths` foundation.
3
+ //!
4
+ //! This module is **pure additive** — it does NOT change existing on-disk
5
+ //! paths or behaviour. It introduces a shared vocabulary that Stage 2 (owner
6
+ //! repository), Stage 5 (per-team runtime state), and Stage 6 (per-team
7
+ //! coordinator) will all consume, so that no later stage has to hand-build
8
+ //! `.team/runtime/<team_key>/...` paths or guess the canonical team_key from
9
+ //! a display name.
10
+ //!
11
+ //! Canonical rules:
12
+ //! - `team_key` is the IDENTITY of a team within a workspace. Display name
13
+ //! (`team_dir.file_name()`) is NOT identity — two `agentX` dirs under
14
+ //! different parents must end up with different team_keys.
15
+ //! - For the foundation slice, `TeamScope::new(team_key)` accepts whatever
16
+ //! the caller already resolved (state/selector + state/persist already do
17
+ //! this work). Slug/hash promotion to global uniqueness lands in Stage 5.
18
+ //! - `TeamRuntimePaths` is the SINGLE place where the
19
+ //! `.team/runtime/<team_key>/` layout is constructed. Anywhere downstream
20
+ //! code today hand-joins `runtime_dir(ws).join(team_key).join(...)` it
21
+ //! should migrate to call a method on `TeamRuntimePaths` in a later stage.
22
+ //!
23
+ //! Single-team behaviour: unchanged. The foundation does not move data, does
24
+ //! not introduce new files, and is invoked by zero existing call sites yet.
25
+
26
+ use std::path::{Path, PathBuf};
27
+
28
+ use crate::model::paths::{runtime_dir, runtime_spec_path};
29
+ use crate::state::persist::load_runtime_state;
30
+ use crate::state::projection::team_state_candidates;
31
+
32
+ /// The identity of a team within a workspace. Carries the *workspace root*
33
+ /// and the *canonical team_key* (the directory name that state/selector
34
+ /// resolves; see `runtime_spec_path(workspace, team_key)`).
35
+ ///
36
+ /// `TeamScope` is the input to `TeamRuntimePaths`. It deliberately does not
37
+ /// store a display name — display name is a UX label, not an identity.
38
+ #[derive(Debug, Clone, PartialEq, Eq)]
39
+ pub struct TeamScope {
40
+ workspace: PathBuf,
41
+ team_key: String,
42
+ }
43
+
44
+ impl TeamScope {
45
+ /// Construct a TeamScope from an already-resolved workspace + team_key.
46
+ /// Callers that have a display name must resolve it through the existing
47
+ /// `state::selector` machinery first; this constructor refuses an empty
48
+ /// team_key because that would silently fall through to the workspace
49
+ /// root (the exact ambiguity Stage 5 is trying to eliminate).
50
+ pub fn new(workspace: impl Into<PathBuf>, team_key: impl Into<String>) -> Option<Self> {
51
+ let team_key = team_key.into();
52
+ if team_key.is_empty() {
53
+ return None;
54
+ }
55
+ Some(Self {
56
+ workspace: workspace.into(),
57
+ team_key,
58
+ })
59
+ }
60
+
61
+ pub fn workspace(&self) -> &Path {
62
+ &self.workspace
63
+ }
64
+
65
+ pub fn team_key(&self) -> &str {
66
+ &self.team_key
67
+ }
68
+
69
+ /// Convenience: derive the path helper from this scope.
70
+ pub fn paths(&self) -> TeamRuntimePaths {
71
+ TeamRuntimePaths::for_scope(self)
72
+ }
73
+ }
74
+
75
+ /// Single source of `.team/runtime/<team_key>/...` path construction.
76
+ ///
77
+ /// Stage 2 (owner repository), Stage 5 (per-team state), Stage 6 (per-team
78
+ /// coordinator), and Stage 7 (per-team tmux socket) will all migrate from
79
+ /// hand-built paths to `TeamRuntimePaths`. Today nothing reads from these
80
+ /// methods yet — the foundation just owns the layout decision so when later
81
+ /// stages start writing `.team/runtime/<team_key>/state.json` (Stage 5) or
82
+ /// `.team/runtime/<team_key>/coordinator.pid` (Stage 6), there is exactly
83
+ /// one place to change the layout.
84
+ #[derive(Debug, Clone)]
85
+ pub struct TeamRuntimePaths {
86
+ workspace: PathBuf,
87
+ team_key: String,
88
+ }
89
+
90
+ impl TeamRuntimePaths {
91
+ pub fn new(workspace: impl Into<PathBuf>, team_key: impl Into<String>) -> Self {
92
+ Self {
93
+ workspace: workspace.into(),
94
+ team_key: team_key.into(),
95
+ }
96
+ }
97
+
98
+ pub fn for_scope(scope: &TeamScope) -> Self {
99
+ Self::new(scope.workspace().to_path_buf(), scope.team_key().to_string())
100
+ }
101
+
102
+ pub fn workspace(&self) -> &Path {
103
+ &self.workspace
104
+ }
105
+
106
+ pub fn team_key(&self) -> &str {
107
+ &self.team_key
108
+ }
109
+
110
+ /// `.team/runtime/<team_key>/` — the team's runtime directory. Today this
111
+ /// already exists (runtime_spec lives under it). Stage 5 will start
112
+ /// writing `state.json` here too.
113
+ pub fn team_dir(&self) -> PathBuf {
114
+ runtime_dir(&self.workspace).join(&self.team_key)
115
+ }
116
+
117
+ /// `.team/runtime/<team_key>/team.spec.yaml` — runtime spec. Mirrors the
118
+ /// existing `runtime_spec_path` helper; provided here so downstream
119
+ /// callers don't need to import two layout APIs.
120
+ pub fn spec_path(&self) -> PathBuf {
121
+ runtime_spec_path(&self.workspace, &self.team_key)
122
+ }
123
+
124
+ /// `.team/runtime/<team_key>/state.json` — the canonical per-team state
125
+ /// path that Stage 5 will start writing. Not used by Stage 0 product
126
+ /// code; callers must continue using `runtime_state_path` until Stage 5
127
+ /// migrates the truth source.
128
+ pub fn state_path(&self) -> PathBuf {
129
+ self.team_dir().join("state.json")
130
+ }
131
+
132
+ /// `.team/runtime/<team_key>/coordinator.pid` — Stage 6 sidecar location.
133
+ pub fn coordinator_pid_path(&self) -> PathBuf {
134
+ self.team_dir().join("coordinator.pid")
135
+ }
136
+
137
+ /// `.team/runtime/<team_key>/coordinator.log` — Stage 6 sidecar location.
138
+ pub fn coordinator_log_path(&self) -> PathBuf {
139
+ self.team_dir().join("coordinator.log")
140
+ }
141
+ }
142
+
143
+ /// Stage 4 of identity-boundary unified plan (architect direction
144
+ /// 2026-06-24, .team/artifacts/identity-boundary-unified-plan.md §2 Stage
145
+ /// 4): the CLI ambiguity gate for destructive commands. Pre-Stage-4 a
146
+ /// bare `shutdown` / `restart` / `reset-agent` / etc. on a workspace with
147
+ /// two alive teams silently picked the first one — exactly the
148
+ /// "active_team_key as destructive command authority" anti-pattern §4 不可改项
149
+ /// rejects. `CommandScope` makes this state explicit.
150
+ ///
151
+ /// Usage from CLI dispatch:
152
+ /// let scope = CommandScope::resolve(workspace, args.team.as_deref());
153
+ /// scope.require_unambiguous_for_destructive(&workspace)?;
154
+ ///
155
+ /// For single-team workspaces (the 0.4.x baseline) `Unambiguous` is
156
+ /// returned and the gate is a no-op. For two+ alive teams the gate
157
+ /// refuses with a `MissingTeamScope` error listing the candidates.
158
+ #[derive(Debug, Clone)]
159
+ pub enum CommandScope {
160
+ /// Caller passed `--team X` explicitly (or there's only one alive team
161
+ /// and we resolved it). Carries the canonical team_key.
162
+ Resolved(String),
163
+ /// No `--team` and multiple alive teams. Destructive commands MUST
164
+ /// refuse with this list of candidates so the operator chooses
165
+ /// explicitly. Read-only commands may still proceed (their scope is
166
+ /// "all teams" by default).
167
+ Ambiguous(Vec<String>),
168
+ /// No `--team` and no teams alive yet (fresh workspace) — bare
169
+ /// commands fall through to legacy single-team behaviour.
170
+ EmptyWorkspace,
171
+ }
172
+
173
+ impl CommandScope {
174
+ /// Resolve the CLI's `--team` argument against the workspace state.
175
+ /// On any I/O error the empty case is returned — destructive commands
176
+ /// will run their own selector and surface the real error.
177
+ pub fn resolve(workspace: &Path, requested_team: Option<&str>) -> Self {
178
+ if let Some(team) = requested_team.filter(|t| !t.is_empty()) {
179
+ return Self::Resolved(team.to_string());
180
+ }
181
+ let Ok(state) = load_runtime_state(workspace) else {
182
+ return Self::EmptyWorkspace;
183
+ };
184
+ let alive = team_state_candidates(&state);
185
+ match alive.len() {
186
+ 0 => Self::EmptyWorkspace,
187
+ 1 => Self::Resolved(
188
+ alive.keys().next().cloned().unwrap_or_default(),
189
+ ),
190
+ _ => {
191
+ let mut keys: Vec<String> = alive.keys().cloned().collect();
192
+ keys.sort();
193
+ Self::Ambiguous(keys)
194
+ }
195
+ }
196
+ }
197
+
198
+ /// Convert to an `Option<String>` for backward compatibility with
199
+ /// existing `args.team.as_deref()` call sites.
200
+ pub fn team_key(&self) -> Option<&str> {
201
+ match self {
202
+ Self::Resolved(key) => Some(key.as_str()),
203
+ _ => None,
204
+ }
205
+ }
206
+
207
+ /// True iff there are 2+ alive teams and no explicit `--team`.
208
+ pub fn is_ambiguous(&self) -> bool {
209
+ matches!(self, Self::Ambiguous(_))
210
+ }
211
+
212
+ /// The candidate list when ambiguous; empty otherwise.
213
+ pub fn candidates(&self) -> &[String] {
214
+ match self {
215
+ Self::Ambiguous(keys) => keys,
216
+ _ => &[],
217
+ }
218
+ }
219
+ }
220
+
221
+ #[cfg(test)]
222
+ mod tests {
223
+ use super::*;
224
+
225
+ #[test]
226
+ fn team_scope_refuses_empty_team_key() {
227
+ assert!(TeamScope::new(PathBuf::from("/ws"), "").is_none());
228
+ }
229
+
230
+ #[test]
231
+ fn team_scope_accepts_workspace_and_team_key() {
232
+ let scope = TeamScope::new(PathBuf::from("/ws"), "alpha").unwrap();
233
+ assert_eq!(scope.workspace(), Path::new("/ws"));
234
+ assert_eq!(scope.team_key(), "alpha");
235
+ }
236
+
237
+ #[test]
238
+ fn team_runtime_paths_layout_matches_existing_runtime_dir_layout() {
239
+ let paths = TeamRuntimePaths::new(PathBuf::from("/ws/proj"), "alpha");
240
+ assert_eq!(paths.team_dir(), PathBuf::from("/ws/proj/.team/runtime/alpha"));
241
+ assert_eq!(
242
+ paths.spec_path(),
243
+ PathBuf::from("/ws/proj/.team/runtime/alpha/team.spec.yaml")
244
+ );
245
+ assert_eq!(
246
+ paths.state_path(),
247
+ PathBuf::from("/ws/proj/.team/runtime/alpha/state.json")
248
+ );
249
+ assert_eq!(
250
+ paths.coordinator_pid_path(),
251
+ PathBuf::from("/ws/proj/.team/runtime/alpha/coordinator.pid")
252
+ );
253
+ }
254
+
255
+ #[test]
256
+ fn two_different_team_keys_in_same_workspace_get_different_paths() {
257
+ let alpha = TeamRuntimePaths::new(PathBuf::from("/ws"), "alpha");
258
+ let beta = TeamRuntimePaths::new(PathBuf::from("/ws"), "beta");
259
+ assert_ne!(alpha.team_dir(), beta.team_dir());
260
+ assert_ne!(alpha.state_path(), beta.state_path());
261
+ assert_ne!(alpha.coordinator_pid_path(), beta.coordinator_pid_path());
262
+ }
263
+
264
+ #[test]
265
+ fn team_scope_to_paths_round_trip() {
266
+ let scope = TeamScope::new(PathBuf::from("/ws"), "alpha").unwrap();
267
+ let paths = scope.paths();
268
+ assert_eq!(paths.workspace(), scope.workspace());
269
+ assert_eq!(paths.team_key(), scope.team_key());
270
+ }
271
+
272
+ // ─────────────── Stage 4: CommandScope tests ───────────────
273
+
274
+ fn tmp_workspace(label: &str) -> PathBuf {
275
+ let n = std::time::SystemTime::now()
276
+ .duration_since(std::time::UNIX_EPOCH)
277
+ .map(|d| d.as_nanos())
278
+ .unwrap_or(0);
279
+ let ws = std::env::temp_dir().join(format!(
280
+ "ta_cmdscope_{}_{}_{}",
281
+ label,
282
+ std::process::id(),
283
+ n
284
+ ));
285
+ std::fs::create_dir_all(&ws).unwrap();
286
+ ws
287
+ }
288
+
289
+ #[test]
290
+ fn command_scope_explicit_team_always_wins() {
291
+ let ws = tmp_workspace("explicit");
292
+ let scope = CommandScope::resolve(&ws, Some("alpha"));
293
+ assert_eq!(scope.team_key(), Some("alpha"));
294
+ assert!(!scope.is_ambiguous());
295
+ }
296
+
297
+ #[test]
298
+ fn command_scope_empty_workspace_falls_through() {
299
+ let ws = tmp_workspace("empty");
300
+ // No state file written.
301
+ let scope = CommandScope::resolve(&ws, None);
302
+ assert!(scope.team_key().is_none());
303
+ assert!(!scope.is_ambiguous());
304
+ assert!(matches!(scope, CommandScope::EmptyWorkspace));
305
+ }
306
+
307
+ #[test]
308
+ fn command_scope_single_alive_team_resolves_automatically() {
309
+ let ws = tmp_workspace("single");
310
+ crate::state::persist::save_runtime_state(
311
+ &ws,
312
+ &serde_json::json!({
313
+ "teams": {"alpha": {"status": "alive"}},
314
+ }),
315
+ )
316
+ .unwrap();
317
+ let scope = CommandScope::resolve(&ws, None);
318
+ assert_eq!(scope.team_key(), Some("alpha"));
319
+ assert!(!scope.is_ambiguous());
320
+ }
321
+
322
+ #[test]
323
+ fn command_scope_two_alive_teams_no_explicit_team_is_ambiguous() {
324
+ let ws = tmp_workspace("multi");
325
+ crate::state::persist::save_runtime_state(
326
+ &ws,
327
+ &serde_json::json!({
328
+ "teams": {
329
+ "alpha": {"status": "alive"},
330
+ "beta": {"status": "alive"},
331
+ },
332
+ }),
333
+ )
334
+ .unwrap();
335
+ let scope = CommandScope::resolve(&ws, None);
336
+ assert!(scope.is_ambiguous());
337
+ assert_eq!(scope.candidates(), &["alpha".to_string(), "beta".to_string()]);
338
+ }
339
+
340
+ #[test]
341
+ fn command_scope_archived_team_does_not_count_as_alive() {
342
+ let ws = tmp_workspace("archived");
343
+ crate::state::persist::save_runtime_state(
344
+ &ws,
345
+ &serde_json::json!({
346
+ "teams": {
347
+ "alpha": {"status": "alive"},
348
+ "beta": {"archived_at": "2026-06-01T00:00:00Z"},
349
+ },
350
+ }),
351
+ )
352
+ .unwrap();
353
+ let scope = CommandScope::resolve(&ws, None);
354
+ // Only alpha counts → not ambiguous, resolves to alpha.
355
+ assert_eq!(scope.team_key(), Some("alpha"));
356
+ assert!(!scope.is_ambiguous());
357
+ }
358
+ }
@@ -204,14 +204,22 @@ fn save_runtime_state_with_merge_exceptions(
204
204
  skip_capture_backfill_agent_ids: &[&str],
205
205
  ) -> Result<(), StateError> {
206
206
  let path = runtime_state_path(workspace);
207
- if cache_equals(&path, state) {
208
- return Ok(());
209
- }
210
207
  // Python `state.py:497`:先对入参 state 跑 `_migrate_state_identity`(就地填缺失 leader uuid)。
211
208
  // 我们 `&Value` 不可变 → 克隆后迁移,后续比较/写入/缓存/self-heal 全走 `migrated`。
212
209
  // 该步**不**包 try/except → 错误 propagate(对齐 Python)。
213
210
  let mut migrated = state.clone();
214
211
  migrate_state_identity(&mut migrated, &SystemEnv, workspace)?;
212
+ // Stage 3 save-output canonical-aware strip (architect direction
213
+ // 2026-06-24, .team/artifacts/stage3-save-strip-fix.md): when the
214
+ // state carries a canonical `teams.<active>.team_owner` record, drop
215
+ // the legacy top-level `team_owner / leader_receiver / owner_epoch`
216
+ // before any cache comparison. The pre-fix order ran a `cache_equals`
217
+ // check against the RAW (pre-strip) state at the function entry — if
218
+ // disk and cache were already in the dual-source (post-restart /
219
+ // post-shutdown) shape, the early return would skip the cleanup
220
+ // entirely. Moving migrate+strip ahead of every cache_equals makes
221
+ // the canonical-only shape the cache invariant for new writes.
222
+ crate::state::ownership::strip_top_level_ownership_if_canonical_present(&mut migrated);
215
223
  if cache_equals(&path, &migrated) {
216
224
  return Ok(());
217
225
  }
@@ -224,6 +232,14 @@ fn save_runtime_state_with_merge_exceptions(
224
232
  if let Ok(mut existing) = serde_json::from_str::<Value>(&text) {
225
233
  normalize_agent_session_state(&mut existing);
226
234
  let _ = migrate_state_identity(&mut existing, &SystemEnv, workspace);
235
+ // Stage 3 save-output strip applied to `existing` too so
236
+ // the equality comparison is between two canonical-only
237
+ // shapes. Without this, a pre-fix dual-source disk state
238
+ // would never match the post-fix canonical-only `migrated`
239
+ // and would force a spurious rewrite on every save.
240
+ crate::state::ownership::strip_top_level_ownership_if_canonical_present(
241
+ &mut existing,
242
+ );
227
243
  if existing == migrated {
228
244
  cache_set(&path, &migrated);
229
245
  return Ok(());
@@ -257,6 +273,12 @@ fn save_runtime_state_with_merge_exceptions(
257
273
  &skip_capture_backfill,
258
274
  );
259
275
  }
276
+ // Stage 3 save-output strip second pass (defence-in-depth): after the
277
+ // `preserve_latest_roster_entries` lock-held merge, a future addition
278
+ // to the preserve path could re-introduce root owner fields from the
279
+ // on-disk latest. Re-strip here so the serialized payload is always
280
+ // canonical-only when a canonical teams.<key>.team_owner is present.
281
+ crate::state::ownership::strip_top_level_ownership_if_canonical_present(&mut migrated);
260
282
  // 字节对拍 Python json.dumps(indent=2, ensure_ascii=False)(无尾换行)。
261
283
  let payload = serde_json::to_string_pretty(&migrated)?;
262
284
  let delays = [0.05_f64, 0.2, 0.5];
@@ -331,7 +353,16 @@ fn preserve_latest_roster_entries(
331
353
  skip_top_level_capture_backfill,
332
354
  skip_capture_backfill_agent_ids,
333
355
  );
334
- preserve_latest_ownership_fields(incoming, latest);
356
+ // Stage 3c (identity-boundary unified plan, architect direction
357
+ // 2026-06-23): top-level owner copy-back removed. Pre-3c this
358
+ // copied legacy `state.{team_owner, leader_receiver, owner_epoch}`
359
+ // from disk's latest back into the incoming save — the persist-side
360
+ // mirror of the projection promote that 3b removed. With Stage 3a's
361
+ // write_owner funneling all owner mutations and 3b's projection
362
+ // cleanup, top-level owner truth is no longer authoritative; the
363
+ // teams.<key> entry-scoped preservation at :370 remains so legacy
364
+ // teams.<key>.team_owner entries still survive a save that omits
365
+ // them.
335
366
  }
336
367
 
337
368
  if projection_matches {
@@ -381,7 +412,14 @@ fn preserve_latest_roster_entries(
381
412
  should_skip_capture_backfill(Some(active_team), skip_capture_backfill_team_key),
382
413
  skip_capture_backfill_agent_ids,
383
414
  );
384
- preserve_latest_ownership_fields(incoming_entry, latest);
415
+ // Stage 3c (identity-boundary unified plan, architect direction
416
+ // 2026-06-23): top-level → teams.<active> owner cross-promote
417
+ // removed. This was the persist-side mirror of the projection
418
+ // copy-back (3b) and a quiet migration path that let stale
419
+ // top-level owner re-seed teams.<active>. With write_owner
420
+ // (3a) producing both top-level and teams.<key> directly,
421
+ // this auto-promotion is redundant and reintroduces the dual
422
+ // source the unified plan eliminates.
385
423
  }
386
424
  }
387
425
  }
@@ -317,17 +317,25 @@ pub fn project_top_level_view(state: &Value, team_key: &str) -> Value {
317
317
  .get("teams")
318
318
  .and_then(Value::as_object)
319
319
  .is_some_and(|teams| !teams.is_empty());
320
- // owner binding is team-scoped: never borrow another team's top-level binding.
320
+ // Stage 3b (identity-boundary unified plan, architect direction 2026-06-23):
321
+ // remove the top-level owner promote. Pre-3b, when `teams.<key>` lacked
322
+ // an owner entry the projection promoted the legacy top-level
323
+ // `state.team_owner` into the projected view — that's the
324
+ // "copy-back/promotion" the architect §Stage 3 calls out as the
325
+ // dual-source bug origin (stale top-level owner could be served to
326
+ // callers of a different team's projection). Now only the canonical
327
+ // teams.<key> branch promotes; callers needing the legacy top-level
328
+ // path must go through `state::ownership::read_owner_for_team` whose
329
+ // precedence rule applies the migration-precedence semantics
330
+ // explicitly (architect §3: teams > top-level only when
331
+ // team_state_key matches).
321
332
  if let Some(v) = entry_obj.get("team_owner") {
322
333
  p.insert("team_owner".to_string(), v.clone());
323
- } else if !has_team_entries && state.get("team_owner").is_some_and(|v| !v.is_null()) {
324
- p.insert("team_owner".to_string(), state["team_owner"].clone());
325
334
  }
326
335
  if let Some(v) = entry_obj.get("leader_receiver") {
327
336
  p.insert("leader_receiver".to_string(), v.clone());
328
- } else if !has_team_entries && state.get("leader_receiver").is_some_and(|v| !v.is_null()) {
329
- p.insert("leader_receiver".to_string(), state["leader_receiver"].clone());
330
337
  }
338
+ let _ = has_team_entries; // silence unused warning; kept for clarity.
331
339
  // coordinator:仅顶层有 key 时 setdefault(投影里没有才插)。
332
340
  if state.as_object().is_some_and(|o| o.contains_key("coordinator")) && !p.contains_key("coordinator") {
333
341
  p.insert("coordinator".to_string(), state["coordinator"].clone());
@@ -502,6 +502,18 @@ pub(crate) fn attach_command_for_workspace(
502
502
  ))
503
503
  }
504
504
 
505
+ pub(crate) fn attach_command_for_session(
506
+ workspace: &Path,
507
+ session_name: &SessionName,
508
+ ) -> Option<String> {
509
+ let socket_path = socket_path_for_workspace(workspace)?;
510
+ Some(format!(
511
+ "tmux -S {} attach -t {}",
512
+ socket_path.display(),
513
+ session_name.as_str()
514
+ ))
515
+ }
516
+
505
517
  /// Bug #7 (prerelease 0.4.0 gate review §6): when the runtime state carries a
506
518
  /// persisted `tmux_endpoint` / `tmux_socket` (e.g. `/private/tmp/tmux-501/default`),
507
519
  /// the attach command MUST point at THAT endpoint, not the workspace-hash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
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.4.1",
24
- "@team-agent/cli-darwin-x64": "0.4.1",
25
- "@team-agent/cli-linux-x64": "0.4.1"
23
+ "@team-agent/cli-darwin-arm64": "0.4.2",
24
+ "@team-agent/cli-darwin-x64": "0.4.2",
25
+ "@team-agent/cli-linux-x64": "0.4.2"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",
@@ -157,7 +157,7 @@ For diagnosis, run `team-agent profile show deepseek --workspace . --json`; neve
157
157
  - For real workers, `quick-start` requires a current tmux leader pane. If it says the leader must run inside tmux, restart the leader with `team-agent codex`/`team-agent claude` or use an existing tmux-managed layout, then run quick-start again.
158
158
  - Quick-start generated files stay inside the selected team directory, for example `.team/current/` or `.team/alpha/`; do not create or expect root `team.spec.yaml` or `team_state.md`.
159
159
  - Use `team-agent quick-start ./roles --team-id alpha` to create a second generated team under `.team/alpha/`, or pass an existing team directory directly such as `team-agent quick-start .team/alpha`.
160
- - `quick-start` is for fresh startup from role docs. If stored worker context already exists for that runtime session, follow the returned `team-agent restart . --team <session>` action to resume it. Use `--fresh` only when the user explicitly accepts new blank worker contexts.
160
+ - `quick-start` is only for first-time team creation from role docs. If that team already has runtime state, use `team-agent restart . --team <session_name_or_team_name>` to resume it. If restart cannot recover context, explain the loss and wait for explicit user consent before using `team-agent restart . --allow-fresh`; never reset context through quick-start.
161
161
  - If the user explicitly asks a worker to create or operate a nested child team, first read `references/team-in-team.md`. Child teams must use an independent child workspace, never the parent `.team/current`.
162
162
  - `team-agent send --watch-result coder "Do the bounded task"` sends a direct worker message, returns after delivery, and lets the coordinator collect/report completion asynchronously.
163
163
  - After `send --watch-result` succeeds, do not run `sleep`, `status`, `inbox`, or `collect` polling loops unless the user explicitly asks for diagnosis; the coordinator will notify the leader when the result arrives.
@@ -181,7 +181,7 @@ Use `team-agent start-agent <agent_id> --workspace .` only as a narrow repair wh
181
181
 
182
182
  ## Adding A New Worker At Runtime
183
183
 
184
- To add a new worker to a running team, write the role doc and run **one command** — do not shutdown/restart, do not regenerate the compiled spec, do not `quick-start --fresh`:
184
+ To add a new worker to a running team, write the role doc and run **one command** — do not shutdown/restart, do not regenerate the compiled spec, and do not quick-start an existing team:
185
185
 
186
186
  ```bash
187
187
  cat > .team/current/agents/reviewer.md <<'EOF'
@@ -209,7 +209,7 @@ Semantic distinction:
209
209
  - `team-agent add-agent <agent> --role-file <file>` — add a **new** worker not yet in team state.
210
210
  - `team-agent start-agent <agent>` — (re)launch a worker that **already exists** in team state but whose window is missing.
211
211
  - `team-agent restart .` — resume a fully **stopped** team from stored worker sessions.
212
- - `team-agent quick-start <dir>` — **fresh** team startup from role docs; `--fresh` is reserved for the user explicitly accepting brand-new blank worker contexts.
212
+ - `team-agent quick-start <dir>` — first-time team creation from role docs; for existing teams use `restart`, and use `restart --allow-fresh` only after explicit user consent to discard context.
213
213
 
214
214
  Removing a worker at runtime is the symmetric `team-agent remove-agent <agent> --workspace . --confirm`.
215
215