@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
@@ -196,7 +196,7 @@ use super::*;
196
196
  #[test]
197
197
  fn inbox_golden_shape_is_agent_id_and_since_not_agent_limit() {
198
198
  let ws = tmp_workspace();
199
- let v = status_port::inbox(&ws, "alpha", 20, None, true).expect("inbox");
199
+ let v = status_port::inbox(&ws, "alpha", 20, None, true, None).expect("inbox");
200
200
  let obj = v.as_object().expect("inbox dict");
201
201
  let order: Vec<&str> = obj.keys().map(String::as_str).collect();
202
202
  assert_eq!(
@@ -167,9 +167,11 @@ use super::*;
167
167
  assert_eq!(r.status, crate::leader::LeaseStatus::Claimed);
168
168
  assert_eq!(r.owner_epoch, Some(crate::model::ids::OwnerEpoch(3)));
169
169
  assert_eq!(r.bound_pane_id, Some(caller.clone()));
170
- assert_eq!(state["leader_receiver"]["pane_id"], json!("%5"));
171
- assert_eq!(state["team_owner"]["pane_id"], json!("%5"));
172
- assert_eq!(state["team_owner"]["owner_epoch"], json!(3));
170
+ // Stage 3d: canonical owner/receiver at teams.<team_key>.
171
+ let team_key = crate::state::projection::team_state_key(&state);
172
+ assert_eq!(state["teams"][&team_key]["leader_receiver"]["pane_id"], json!("%5"));
173
+ assert_eq!(state["teams"][&team_key]["team_owner"]["pane_id"], json!("%5"));
174
+ assert_eq!(state["teams"][&team_key]["team_owner"]["owner_epoch"], json!(3));
173
175
  }
174
176
 
175
177
  struct LeaderPortSeededLiveness {
@@ -186,7 +186,7 @@ fn seed_team_spec(ws: &std::path::Path) {
186
186
  let mid = store
187
187
  .create_message(None, "leader", "w1", "hello w1", None, true, None)
188
188
  .unwrap();
189
- let v = status_port::inbox(&ws, "w1", 20, None, true).expect("inbox");
189
+ let v = status_port::inbox(&ws, "w1", 20, None, true, None).expect("inbox");
190
190
  let messages = v["messages"].as_array().expect("messages array");
191
191
  assert_eq!(
192
192
  messages.len(),
@@ -217,7 +217,7 @@ fn seed_team_spec(ws: &std::path::Path) {
217
217
  store.create_message(None, "leader", "w1", "to w1", None, true, None).unwrap();
218
218
  store.create_message(None, "w1", "leader", "from w1", None, true, None).unwrap();
219
219
  store.create_message(None, "leader", "w2", "unrelated to w2", None, true, None).unwrap();
220
- let v = status_port::inbox(&ws, "w1", 20, None, true).expect("inbox");
220
+ let v = status_port::inbox(&ws, "w1", 20, None, true, None).expect("inbox");
221
221
  let messages = v["messages"].as_array().expect("messages array");
222
222
  let mut contents: Vec<String> =
223
223
  messages.iter().map(|m| m["content"].as_str().unwrap().to_string()).collect();
@@ -343,6 +343,7 @@ fn seed_team_spec(ws: &std::path::Path) {
343
343
  workspace: ws.clone(),
344
344
  timeout: 0.0,
345
345
  json: true,
346
+ team: None,
346
347
  }).expect("wait-ready"));
347
348
  assert_eq!(value["ok"], json!(false), "wait-ready must not fake success before workers are ready");
348
349
  assert_eq!(value.pointer("/readiness/ready"), Some(&json!(false)));
@@ -386,6 +387,7 @@ fn seed_team_spec(ws: &std::path::Path) {
386
387
  workspace: ws.clone(),
387
388
  timeout: 0.0,
388
389
  json: true,
390
+ team: None,
389
391
  }).expect("wait-ready"));
390
392
  assert_eq!(
391
393
  value.pointer("/readiness/mcp_ready"),
@@ -571,6 +573,7 @@ fn seed_team_spec(ws: &std::path::Path) {
571
573
  workspace: ws.clone(),
572
574
  result_file: None,
573
575
  json: true,
576
+ team: None,
574
577
  })
575
578
  .unwrap(),
576
579
  );
@@ -611,6 +614,7 @@ fn seed_team_spec(ws: &std::path::Path) {
611
614
  workspace: ws.clone(),
612
615
  alert_type: None,
613
616
  json: true,
617
+ team: None,
614
618
  })
615
619
  .unwrap(),
616
620
  );
@@ -637,6 +641,7 @@ fn seed_team_spec(ws: &std::path::Path) {
637
641
  cmd_stuck_list(&StuckListArgs {
638
642
  workspace: ws.clone(),
639
643
  json: true,
644
+ team: None,
640
645
  })
641
646
  .unwrap(),
642
647
  );
@@ -646,6 +651,26 @@ fn seed_team_spec(ws: &std::path::Path) {
646
651
  "stuck-list must read the persisted state mirror, not return a hard-coded empty list"
647
652
  );
648
653
  }
654
+
655
+ #[test]
656
+ fn stuck_cancel_explicit_team_is_rejected_until_backend_is_scoped() {
657
+ let ws = tmp_workspace();
658
+ seed_collect_state(&ws);
659
+ let err = cmd_stuck_cancel(&StuckCancelArgs {
660
+ agent: "fake_impl".to_string(),
661
+ workspace: ws.clone(),
662
+ alert_type: None,
663
+ json: true,
664
+ team: Some("current".to_string()),
665
+ })
666
+ .expect_err("explicit --team must not silently write global stuck suppression");
667
+ assert!(
668
+ err.to_string().contains("not supported yet"),
669
+ "stuck-cancel --team must be an explicit refusal until backend supports scoped writes; got {err}"
670
+ );
671
+ let _ = std::fs::remove_dir_all(&ws);
672
+ }
673
+
649
674
  #[test]
650
675
  fn stuck_cancel_invalid_alert_type_is_rejected() {
651
676
  let ws = tmp_workspace();
@@ -717,6 +742,7 @@ fn seed_team_spec(ws: &std::path::Path) {
717
742
  status: "done".to_string(),
718
743
  summary: Some("manual repair accepted".to_string()),
719
744
  json: true,
745
+ team: None,
720
746
  })
721
747
  .expect("repair-state should not fail on a valid runtime state"),
722
748
  );
@@ -43,6 +43,7 @@ fn app_call(ws: &std::path::Path, a: &str, b: &str) -> CmdResult {
43
43
  b: b.to_string(),
44
44
  workspace: ws.to_path_buf(),
45
45
  json: false,
46
+ team: None,
46
47
  })
47
48
  .expect("allow-peer-talk is a compat-noop and must not error")
48
49
  }
@@ -135,3 +136,21 @@ fn allow_peer_talk_unknown_agents_succeed_no_validation() {
135
136
  );
136
137
  let _ = std::fs::remove_dir_all(&ws);
137
138
  }
139
+
140
+ #[test]
141
+ fn allow_peer_talk_explicit_team_is_rejected_until_backend_is_scoped() {
142
+ let ws = tmp_workspace();
143
+ let err = cmd_allow_peer_talk(&AllowPeerTalkArgs {
144
+ a: "alpha".to_string(),
145
+ b: "bravo".to_string(),
146
+ workspace: ws.clone(),
147
+ json: true,
148
+ team: Some("current".to_string()),
149
+ })
150
+ .expect_err("explicit --team must not silently write the global peer allowlist");
151
+ assert!(
152
+ err.to_string().contains("not supported yet"),
153
+ "allow-peer-talk --team must be an explicit refusal until backend supports scoped writes; got {err}"
154
+ );
155
+ let _ = std::fs::remove_dir_all(&ws);
156
+ }
@@ -130,9 +130,54 @@ fn repair_args(
130
130
  status: status.to_string(),
131
131
  summary: summary.map(str::to_string),
132
132
  json,
133
+ team: None,
133
134
  }
134
135
  }
135
136
 
137
+ fn team_repair_state(ws: &std::path::Path, team: &str) -> serde_json::Value {
138
+ json!({
139
+ "active_team_key": team,
140
+ "status": "alive",
141
+ "spec_path": ws.join(".team").join("runtime").join(team).join("team.spec.yaml").to_string_lossy(),
142
+ "session_name": format!("team-agent-{team}"),
143
+ "leader": {"id": "leader"},
144
+ "agents": {"fake_impl": {"status": "stopped", "provider": "fake"}},
145
+ "tasks": [{
146
+ "id": "task_impl",
147
+ "title": format!("{team} implementation"),
148
+ "type": "implementation",
149
+ "assignee": null,
150
+ "deps": [],
151
+ "acceptance": ["fake result collected"],
152
+ "status": "pending"
153
+ }]
154
+ })
155
+ }
156
+
157
+ fn seed_two_team_repair_workspace(ws: &std::path::Path) {
158
+ std::fs::create_dir_all(ws.join(".team").join("logs")).unwrap();
159
+ for team in ["alpha", "beta"] {
160
+ let spec_dir = ws.join(".team").join("runtime").join(team);
161
+ std::fs::create_dir_all(&spec_dir).unwrap();
162
+ std::fs::write(
163
+ spec_dir.join("team.spec.yaml"),
164
+ REPAIR_SPEC_TEMPLATE.replace("__WS__", &ws.to_string_lossy()),
165
+ )
166
+ .unwrap();
167
+ }
168
+ crate::state::persist::save_runtime_state(
169
+ ws,
170
+ &json!({
171
+ "active_team_key": "alpha",
172
+ "teams": {
173
+ "alpha": team_repair_state(ws, "alpha"),
174
+ "beta": team_repair_state(ws, "beta"),
175
+ }
176
+ }),
177
+ )
178
+ .unwrap();
179
+ }
180
+
136
181
  // Golden source:
137
182
  // - cli/parser.py:303-310 registers `repair-state --workspace --task --assignee --status --summary --json`.
138
183
  // - cli/commands.py:196-203 delegates all five args to `runtime.repair_state`.
@@ -248,6 +293,33 @@ fn repair_state_rejects_status_outside_task_statuses() {
248
293
  let _ = std::fs::remove_dir_all(&ws);
249
294
  }
250
295
 
296
+ #[test]
297
+ fn repair_state_explicit_team_updates_selected_team_not_active_team() {
298
+ let ws = tmp_workspace();
299
+ seed_two_team_repair_workspace(&ws);
300
+ let mut args = repair_args(&ws, "done", Some("patched beta"), true);
301
+ args.team = Some("beta".to_string());
302
+
303
+ let cmd = cmd_repair_state(&args).expect("repair-state --team beta should update beta");
304
+ assert_eq!(cmd.exit, ExitCode::Ok);
305
+ let state = crate::state::persist::load_runtime_state(&ws).unwrap();
306
+ assert_eq!(
307
+ state["teams"]["alpha"]["tasks"][0]["status"],
308
+ json!("pending"),
309
+ "repair-state --team beta must not mutate active/default alpha"
310
+ );
311
+ assert_eq!(
312
+ state["teams"]["beta"]["tasks"][0]["status"],
313
+ json!("done"),
314
+ "repair-state must consume args.team and update beta"
315
+ );
316
+ assert_eq!(
317
+ state["teams"]["beta"]["tasks"][0]["last_result_summary"],
318
+ json!("patched beta")
319
+ );
320
+ let _ = std::fs::remove_dir_all(&ws);
321
+ }
322
+
251
323
  // CONTRACT (real-machine repair_state product FAIL): rt-host-d ran `repair-state --task rp1 --status done`
252
324
  // in a quick-start workspace whose spec lives in a teamdir (state.spec_path -> <ws>/teamdir/team.spec.yaml),
253
325
  // NOT at <ws>/team.spec.yaml. Rust cmd_repair_state calls load_team_spec(workspace) which HARDCODES
@@ -205,7 +205,6 @@ fn current_uid() -> Option<String> {
205
205
  name: None,
206
206
  team_id: None,
207
207
  yes: true,
208
- fresh: false,
209
208
  no_display: false,
210
209
  json: true,
211
210
  };
@@ -240,7 +239,6 @@ fn current_uid() -> Option<String> {
240
239
  name: None,
241
240
  team_id: None,
242
241
  yes: false,
243
- fresh: false,
244
242
  no_display: false,
245
243
  json: true,
246
244
  };
@@ -339,7 +337,7 @@ fn current_uid() -> Option<String> {
339
337
  crate::coordinator::write_coordinator_metadata(&wp, me, crate::coordinator::MetadataSource::Boot).unwrap();
340
338
  std::fs::write(crate::coordinator::coordinator_pid_path(&wp), me.to_string()).unwrap();
341
339
 
342
- let args = StatusArgs { agent: None, workspace: ws, detail: true, summary: false, json: true };
340
+ let args = StatusArgs { agent: None, workspace: ws, detail: true, summary: false, json: true, team: None };
343
341
  let r = cmd_status(&args).expect("cmd_status");
344
342
  let value = match r.output {
345
343
  CmdOutput::Json(v) => v,
@@ -268,6 +268,7 @@ use super::*;
268
268
  detail: false,
269
269
  summary: false,
270
270
  json: false,
271
+ team: None,
271
272
  };
272
273
 
273
274
  let r = cmd_status_for_team(&args, None).expect("status");
@@ -86,7 +86,7 @@ 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 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."
89
+ "tmux session `{session}` already exists. It may be your own existing team. To resume it use `team-agent restart`. If recovery is impossible, use `team-agent restart --allow-fresh` only after explicit context-loss consent. 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
92
  "If this is your existing team, resume it with `team-agent restart`.".to_string(),
@@ -270,9 +270,13 @@ pub struct QuickStartArgs {
270
270
  pub name: Option<String>,
271
271
  pub team_id: Option<String>,
272
272
  pub yes: bool,
273
- pub fresh: bool,
274
273
  pub no_display: bool,
275
274
  pub json: bool,
275
+ // Stage QR (quick-start/restart separation, design doc
276
+ // .team/artifacts/quickstart-restart-separation-design.md): the
277
+ // `fresh` field is intentionally REMOVED. Reset semantics now live
278
+ // exclusively on restart (`restart --allow-fresh`) and must be
279
+ // user-confirmed. quick-start is now an initial-creation-only verb.
276
280
  }
277
281
 
278
282
  /// `init`(`parser.py` bootstrap verb)。
@@ -355,6 +359,9 @@ pub struct AllowPeerTalkArgs {
355
359
  pub b: String,
356
360
  pub workspace: PathBuf,
357
361
  pub json: bool,
362
+ /// Parsed but rejected until peer allowlist storage grows a team-scoped
363
+ /// backend column.
364
+ pub team: Option<String>,
358
365
  }
359
366
 
360
367
  /// `status`(`parser.py:182`)。`--summary`/`--json`/`--detail` 三态(summary xor json,见 `cmd_status`)。
@@ -365,6 +372,9 @@ pub struct StatusArgs {
365
372
  pub detail: bool,
366
373
  pub summary: bool,
367
374
  pub json: bool,
375
+ /// Stage 4: explicit `--team` scope (read-only command; multi-team
376
+ /// workspaces use this for filtering, not for refusal).
377
+ pub team: Option<String>,
368
378
  }
369
379
 
370
380
  /// `watch`(`parser.py:190`)。无 `--json`(纯 stream 到 SystemExit)。
@@ -380,6 +390,8 @@ pub struct ApprovalsArgs {
380
390
  pub agent: Option<String>,
381
391
  pub workspace: PathBuf,
382
392
  pub json: bool,
393
+ /// Stage 4: explicit `--team` scope.
394
+ pub team: Option<String>,
383
395
  }
384
396
 
385
397
  /// `inbox`(`parser.py:217`)。`--since` ISO8601(claim-leader inbox_hint 复用)。
@@ -390,6 +402,8 @@ pub struct InboxArgs {
390
402
  pub limit: usize,
391
403
  pub since: Option<String>,
392
404
  pub json: bool,
405
+ /// Stage 4: explicit `--team` scope.
406
+ pub team: Option<String>,
393
407
  }
394
408
 
395
409
  /// `takeover`(`parser.py:242`)。`--confirm` 必需(覆写 recorded team_owner)。
@@ -521,6 +535,8 @@ pub struct RemoveAgentArgs {
521
535
  pub struct StuckListArgs {
522
536
  pub workspace: PathBuf,
523
537
  pub json: bool,
538
+ /// Stage 4: explicit `--team` scope (read-only enumeration).
539
+ pub team: Option<String>,
524
540
  }
525
541
 
526
542
  /// `stuck-cancel`(`parser.py:429`)。`--alert-type` ∈ {stuck, idle_fallback, all},默认 stuck。
@@ -531,6 +547,9 @@ pub struct StuckCancelArgs {
531
547
  /// `None` 表 `all`(展开全集);`Some(AlertType)` 表单类型。
532
548
  pub alert_type: Option<AlertType>,
533
549
  pub json: bool,
550
+ /// Parsed but rejected until stuck suppression storage grows a team-scoped
551
+ /// backend selector.
552
+ pub team: Option<String>,
534
553
  }
535
554
 
536
555
  /// `acknowledge-idle`(`parser.py:436`)。
@@ -573,6 +592,8 @@ pub enum DoctorGate {
573
592
  pub struct SessionsArgs {
574
593
  pub workspace: PathBuf,
575
594
  pub json: bool,
595
+ /// Stage 4: explicit `--team` scope (read-only enumeration).
596
+ pub team: Option<String>,
576
597
  }
577
598
 
578
599
  /// `validate [spec=team.spec.yaml] --json`(`parser.py:120`)。
@@ -608,6 +629,10 @@ pub struct CollectArgs {
608
629
  pub workspace: PathBuf,
609
630
  pub result_file: Option<PathBuf>,
610
631
  pub json: bool,
632
+ /// Stage 4: explicit `--team` scope. Destructive (consumes result
633
+ /// envelopes); CLI dispatch refuses bare invocation in a
634
+ /// multi-alive-team workspace.
635
+ pub team: Option<String>,
611
636
  }
612
637
 
613
638
  /// `settle`(`parser.py:177`)。
@@ -631,6 +656,10 @@ pub struct RepairStateArgs {
631
656
  pub status: String,
632
657
  pub summary: Option<String>,
633
658
  pub json: bool,
659
+ /// Stage 4: explicit `--team` scope. Destructive (rewrites task state);
660
+ /// CLI dispatch refuses bare invocation in a multi-alive-team
661
+ /// workspace.
662
+ pub team: Option<String>,
634
663
  }
635
664
 
636
665
  /// `diagnose`(`parser.py:298`) runtime health report, distinct from `doctor`.
@@ -638,6 +667,8 @@ pub struct RepairStateArgs {
638
667
  pub struct DiagnoseArgs {
639
668
  pub workspace: PathBuf,
640
669
  pub json: bool,
670
+ /// Stage 4: explicit `--team` scope (read-only health report).
671
+ pub team: Option<String>,
641
672
  }
642
673
 
643
674
  /// `preflight`(`parser.py:160`)。
@@ -653,6 +684,8 @@ pub struct WaitReadyArgs {
653
684
  pub workspace: PathBuf,
654
685
  pub timeout: f64,
655
686
  pub json: bool,
687
+ /// Stage 4: explicit `--team` scope (read-only readiness probe).
688
+ pub team: Option<String>,
656
689
  }
657
690
 
658
691
  /// `e2e`(`parser.py:449`)。
@@ -216,8 +216,13 @@ fn detect_leader_api_error(
216
216
  .and_then(Value::as_str)
217
217
  .map(ToString::to_string)
218
218
  });
219
- let leader_session_uuid = state
220
- .get("team_owner")
219
+ // Stage 3a (identity-boundary unified plan, architect direction
220
+ // 2026-06-23): route owner read through the ownership repository.
221
+ // The coordinator state here is the workspace runtime state — Stage 5
222
+ // will swap to per-team state and this read follows automatically.
223
+ // capture.leader_receiver fallback is kept as evidence, not owner truth
224
+ // (architect §4 cross-module risk: capture fallback is evidence-only).
225
+ let leader_session_uuid = crate::state::ownership::read_owner_value(state, "")
221
226
  .and_then(|owner| owner.get("leader_session_uuid"))
222
227
  .or_else(|| {
223
228
  capture
@@ -82,11 +82,19 @@ fn receiver_binding_snapshot(workspace: &Path, team: Option<&TeamKey>) -> Value
82
82
  };
83
83
  let state = selected.state;
84
84
  let receiver = state.get("leader_receiver").and_then(Value::as_object);
85
+ // Stage 2 (identity-boundary unified plan, architect direction
86
+ // 2026-06-23): route owner pane lookup through the ownership repository.
87
+ // `selected.state` is already team-projected by the selector, so the
88
+ // empty-team-key path returns the same top-level owner the legacy direct
89
+ // read produced — but Stage 5 will swap the data source under the
90
+ // repository and this diagnose surface follows automatically. The
91
+ // `owner` key fallback is retained for callers that pre-date the
92
+ // `team_owner` rename.
85
93
  let owner_pane_id = state
86
94
  .get("owner")
87
- .or_else(|| state.get("team_owner"))
88
- .and_then(|v| v.get("pane_id"))
89
95
  .cloned()
96
+ .or_else(|| crate::state::ownership::read_owner_value(&state, "").cloned())
97
+ .and_then(|v| v.get("pane_id").cloned())
90
98
  .unwrap_or(Value::Null);
91
99
  let caller_pane_id = std::env::var("TMUX_PANE").ok().map(Value::String).unwrap_or(Value::Null);
92
100
  let pane_id = receiver
@@ -1031,6 +1031,17 @@ fn scan_agents_for_pane(agents: Option<&Value>, caller_pane: &PaneId) -> Option<
1031
1031
  None
1032
1032
  }
1033
1033
 
1034
+ /// Stage 3a (identity-boundary unified plan, architect direction 2026-06-23)
1035
+ /// + Stage 3 owner persist fix (architect direction 2026-06-24): route the
1036
+ /// in-memory binding write through the `state::ownership` API. The
1037
+ /// canonical team_key is derived from `active_team_key` first (the explicit
1038
+ /// scope set by `project_top_level_view` when an explicit team was passed
1039
+ /// to claim-leader) and only falls back to `team_state_key(state)` for the
1040
+ /// legacy unscoped flow. Without the `active_team_key` preference, an
1041
+ /// explicit `claim-leader --team alpha` whose projected state was emptied
1042
+ /// of team_dir/session_name would derive "current" and write the owner to
1043
+ /// `teams.current`, while `save_claim_team_scoped_state` expected
1044
+ /// `teams.alpha` — the S3-OWNER-001 persistence loss shape.
1034
1045
  fn write_binding_to_state(
1035
1046
  state: &mut Value,
1036
1047
  receiver: &LeaderReceiver,
@@ -1039,12 +1050,15 @@ fn write_binding_to_state(
1039
1050
  if !state.is_object() {
1040
1051
  *state = json!({});
1041
1052
  }
1042
- let Some(root) = state.as_object_mut() else {
1053
+ if !state.is_object() {
1043
1054
  return Err(LeaderError::Validation("state root is not an object".to_string()));
1044
- };
1045
- root.insert("leader_receiver".to_string(), serde_json::to_value(receiver)?);
1046
- root.insert("team_owner".to_string(), serde_json::to_value(owner)?);
1047
- root.insert("owner_epoch".to_string(), json!(owner.owner_epoch.0));
1055
+ }
1056
+ let team_key = canonical_owner_write_key(state);
1057
+ let record = crate::state::ownership::OwnershipWrite::new()
1058
+ .with_leader_receiver(serde_json::to_value(receiver)?)
1059
+ .with_team_owner(serde_json::to_value(owner)?)
1060
+ .with_owner_epoch(owner.owner_epoch.0);
1061
+ crate::state::ownership::write_owner(state, &team_key, record);
1048
1062
  Ok(())
1049
1063
  }
1050
1064
 
@@ -1055,13 +1069,34 @@ fn write_receiver_to_state(
1055
1069
  if !state.is_object() {
1056
1070
  *state = json!({});
1057
1071
  }
1058
- let Some(root) = state.as_object_mut() else {
1072
+ if !state.is_object() {
1059
1073
  return Err(LeaderError::Validation("state root is not an object".to_string()));
1060
- };
1061
- root.insert("leader_receiver".to_string(), serde_json::to_value(receiver)?);
1074
+ }
1075
+ let team_key = canonical_owner_write_key(state);
1076
+ let record = crate::state::ownership::OwnershipWrite::new()
1077
+ .with_leader_receiver(serde_json::to_value(receiver)?);
1078
+ crate::state::ownership::write_owner(state, &team_key, record);
1062
1079
  Ok(())
1063
1080
  }
1064
1081
 
1082
+ /// Stage 3 owner persist fix (architect direction 2026-06-24): determine
1083
+ /// the canonical team_key for an in-memory ownership write. When the state
1084
+ /// carries an `active_team_key` (set by `project_top_level_view` whenever
1085
+ /// an explicit team was scoped into the claim path), trust it — that's the
1086
+ /// requested team that `save_claim_team_scoped_state` will read back. Only
1087
+ /// fall back to `team_state_key(state)` for the legacy unscoped flow where
1088
+ /// the writer must derive team identity from session/dir/spec_path fields.
1089
+ fn canonical_owner_write_key(state: &Value) -> String {
1090
+ if let Some(active) = state
1091
+ .get("active_team_key")
1092
+ .and_then(Value::as_str)
1093
+ .filter(|key| !key.is_empty())
1094
+ {
1095
+ return active.to_string();
1096
+ }
1097
+ crate::state::projection::team_state_key(state)
1098
+ }
1099
+
1065
1100
  fn state_receiver(state: &Value) -> Option<LeaderReceiver> {
1066
1101
  state
1067
1102
  .get("leader_receiver")
@@ -1139,9 +1174,16 @@ fn save_claim_team_scoped_state(workspace: &Path, state: &Value, target_key: &st
1139
1174
  .entry(existing_key)
1140
1175
  .or_insert_with(|| crate::state::projection::compact_team_state(&existing));
1141
1176
  }
1177
+ // Stage 3 owner persist fix (architect direction 2026-06-24,
1178
+ // .team/artifacts/stage3-owner-persist-fix.md): after Stage 3d removed
1179
+ // top-level dual-write, the canonical ownership record lives ONLY at
1180
+ // `state.teams[target_key].{team_owner, leader_receiver, owner_epoch}`.
1181
+ // `compact_team_state(state)` strips the `teams` key entirely — that
1182
+ // dropped the just-written nested ownership record on disk. Use the
1183
+ // preserving helper so the canonical fields survive the compaction.
1142
1184
  teams.insert(
1143
1185
  target_key.to_string(),
1144
- crate::state::projection::compact_team_state(state),
1186
+ compact_team_state_preserving_ownership(state, target_key),
1145
1187
  );
1146
1188
  let existing_primary_key = existing
1147
1189
  .get("session_name")
@@ -1154,16 +1196,18 @@ fn save_claim_team_scoped_state(workspace: &Path, state: &Value, target_key: &st
1154
1196
  } else {
1155
1197
  value_object(&existing)
1156
1198
  };
1157
- if !had_existing_teams
1158
- || existing_primary_key.as_deref().is_none_or(|key| key == target_key)
1159
- || existing_active_key == Some(target_key)
1160
- {
1161
- for key in ["leader_receiver", "team_owner", "owner_epoch"] {
1162
- if let Some(value) = state.get(key) {
1163
- merged.insert(key.to_string(), value.clone());
1164
- }
1165
- }
1166
- }
1199
+ // Stage 3 top-level cleanup (architect direction 2026-06-24,
1200
+ // .team/artifacts/stage3-toplevel-cleanup-fix.md + stage3-save-strip-fix.md):
1201
+ // the legacy top-level owner copy loop is gone; the canonical-aware
1202
+ // strip is now performed at the persistence boundary
1203
+ // (`state::persist::save_runtime_state_with_merge_exceptions`), which
1204
+ // every save_runtime_state* path funnels through. This per-call-site
1205
+ // strip call has been removed — the save output handles cleanup
1206
+ // uniformly for claim / restart / shutdown / start-agent / stop-agent
1207
+ // / coordinator tick / promote sibling. `had_existing_teams` /
1208
+ // primary/active key computations are retained as dead writes only
1209
+ // via _ binding — they no longer gate any owner promote.
1210
+ let _ = (had_existing_teams, existing_primary_key, existing_active_key);
1167
1211
  merged.insert("teams".to_string(), Value::Object(teams));
1168
1212
  crate::state::persist::save_runtime_state(workspace, &Value::Object(merged))?;
1169
1213
  Ok(())
@@ -1173,6 +1217,41 @@ fn value_object(value: &Value) -> serde_json::Map<String, Value> {
1173
1217
  value.as_object().cloned().unwrap_or_default()
1174
1218
  }
1175
1219
 
1220
+ /// Stage 3 owner persist fix (architect direction 2026-06-24,
1221
+ /// .team/artifacts/stage3-owner-persist-fix.md): build the per-team
1222
+ /// snapshot used by `save_claim_team_scoped_state` so the canonical
1223
+ /// `teams.<target_key>.{team_owner, leader_receiver, owner_epoch}` record
1224
+ /// survives the `compact_team_state` strip. Without this helper the
1225
+ /// claim-leader save loses the just-written nested ownership record (the
1226
+ /// S3-OWNER-001 evidence shape: `persisted_owner_locations=[]`).
1227
+ ///
1228
+ /// Behaviour:
1229
+ /// - Start from the existing `compact_team_state(state)` (strips `teams`).
1230
+ /// - Look up `state.teams[target_key].{team_owner, leader_receiver, owner_epoch}`
1231
+ /// and copy each present field into the compacted entry.
1232
+ /// - Top-level owner fields stay UNTOUCHED — Stage 3d's canonical-only
1233
+ /// shape is preserved on disk.
1234
+ fn compact_team_state_preserving_ownership(state: &Value, target_key: &str) -> Value {
1235
+ let mut entry = crate::state::projection::compact_team_state(state);
1236
+ let Some(entry_obj) = entry.as_object_mut() else {
1237
+ return entry;
1238
+ };
1239
+ let Some(owner_obj) = state
1240
+ .get("teams")
1241
+ .and_then(Value::as_object)
1242
+ .and_then(|teams| teams.get(target_key))
1243
+ .and_then(Value::as_object)
1244
+ else {
1245
+ return entry;
1246
+ };
1247
+ for key in ["team_owner", "leader_receiver", "owner_epoch"] {
1248
+ if let Some(value) = owner_obj.get(key) {
1249
+ entry_obj.insert(key.to_string(), value.clone());
1250
+ }
1251
+ }
1252
+ entry
1253
+ }
1254
+
1176
1255
  /// `_detect_dual_state_divergence`(card §85 C18;`__init__.py:556`)。workspace-level 与
1177
1256
  /// team-level snapshot 在 owner_uuid/receiver_pane_id/owner_epoch 上是否分叉 → `Some(详情)`。
1178
1257
  /// **CROSS-LANE**:snapshot 读经 step 13 restart。