@team-agent/installer 0.5.43 → 0.5.45

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
@@ -575,7 +575,7 @@ dependencies = [
575
575
 
576
576
  [[package]]
577
577
  name = "team-agent"
578
- version = "0.5.43"
578
+ version = "0.5.45"
579
579
  dependencies = [
580
580
  "anyhow",
581
581
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.43"
12
+ version = "0.5.45"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -308,7 +308,24 @@ fn command_help(command: Option<&str>) -> String {
308
308
  Some("quick-start") => "usage: team-agent quick-start [TEAMDIR] [--workspace WORKSPACE] [--name NAME] [--team-id TEAM|--team TEAM] [--yes] [--no-display] [--backend tmux|conpty] [--json]\n\ndefaults: display_backend=adaptive; set display_backend: none in TEAM.md or pass --no-display to use one worker window per agent.\n\n--backend selects the worker transport (Phase 1d Batch 2): tmux (default on POSIX; unchanged behavior), conpty (Windows-native ConPTY worker transport; requires the shim binary and Windows host).".to_string(),
309
309
  Some("start") => compat_hidden_help("start", "usage: team-agent start [TEAMDIR] [--yes] [--fresh] [--json]"),
310
310
  Some("compile") => "usage: team-agent compile --team TEAM [--out FILE] [--json]".to_string(),
311
- Some("send") => "usage: team-agent send TARGET MESSAGE... [--workspace WORKSPACE] [--team TEAM] [--targets AGENTS] [--to-name NAME] [--pane PANE] [--task TASK] [--sender SENDER] [--watch-result] [--requires-ack|--no-ack] [--no-wait] [--timeout SECONDS] [--confirm-human] [--message-id ID] [--json]\n\nMVP: name-based cross-workspace addressing assumes trusted local caller; no auth gate.".to_string(),
311
+ Some("send") => concat!(
312
+ "usage: team-agent send TARGET MESSAGE... ",
313
+ "[--workspace WORKSPACE] [--team TEAM] [--targets AGENTS] ",
314
+ "[--to-name NAME] [--pane PANE] [--task TASK] [--sender SENDER] ",
315
+ "[--watch-result] [--requires-ack|--no-ack] [--no-wait] ",
316
+ "[--timeout SECONDS] [--confirm-human] [--message-id ID] [--json]\n\n",
317
+ "TARGET is a short id scoped by --team; MCP `to` is a short id scoped ",
318
+ "by the worker's owner team.\n",
319
+ "--to-name accepts: --to-name AGENT, --to-name TEAM/AGENT, or ",
320
+ "--to-name WORKSPACE::TEAM/AGENT.\n",
321
+ "--team scopes only a bare --to-name AGENT; qualified forms keep the ",
322
+ "scope in the address.\n",
323
+ "TEAM/AGENT and WORKSPACE::TEAM/AGENT are not valid positional TARGET ",
324
+ "or MCP `to` values.\n\n",
325
+ "MVP: name-based cross-workspace addressing assumes trusted local ",
326
+ "caller; no auth gate."
327
+ )
328
+ .to_string(),
312
329
  Some("allow-peer-talk") => "usage: team-agent allow-peer-talk A B [--workspace WORKSPACE] [--json]".to_string(),
313
330
  Some("status") => "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]\n\n默认输出: worker,空闲|工作|错误;错误细分走 status --summary".to_string(),
314
331
  Some("stop") => compat_hidden_help("stop", "usage: team-agent stop [--workspace WORKSPACE] [--team TEAM] [--keep-logs] [--json]"),
@@ -357,43 +374,33 @@ fn emit_unknown_subcommand_usage(command: &str) -> ExitCode {
357
374
  if let Some(suggestion) = nearest_subcommand(command) {
358
375
  eprintln!("team-agent: did you mean `{suggestion}`?");
359
376
  }
360
- ExitCode::Usage
377
+ // 0.5.45 naming-addressing (RED-6): unknown subcommand exits 1
378
+ // (Error), aligned with the family of typo refusals throughout
379
+ // send/named. Pre-0.5.45 mapped to Usage (2) argparse-style; the
380
+ // shared refusal shape ("typo diagnostic + advisory suggestion +
381
+ // exit 1") is the invariant callers of `team-agent send` /
382
+ // `--to-name` also see, so keeping unknown-subcommand at 2 was
383
+ // internal drift.
384
+ ExitCode::Error
361
385
  }
362
386
 
363
- /// 在已知子命令里找与 `input` 最接近的一个(Levenshtein ≤ 阈值)。无足够接近者 → None。
387
+ /// 在已知子命令里找与 `input` 最接近的一个。0.5.45 naming-addressing
388
+ /// (design §3.2/§4.1) 抽公 shared `model::name_similarity` — 距离
389
+ /// 阈值与排序规则跟 CLI `--to-name` typo suggestion 走同一份纯函数,
390
+ /// 避免两套 fuzzy 逻辑漂移。既有 `statu -> status` 行为保留(RED-6
391
+ /// grep guard + prefix-hit priority)。
364
392
  fn nearest_subcommand(input: &str) -> Option<&'static str> {
365
- let candidates = COMMAND_SPECS
393
+ use crate::model::name_similarity::{rank, Candidate};
394
+ let candidates: Vec<Candidate<&'static str>> = COMMAND_SPECS
366
395
  .iter()
367
396
  .filter(|spec| spec.suggestion_index)
368
- .map(|spec| spec.name);
369
- // 阈值随长度放宽,但短词收紧,避免 'x' 误配任何东西。
370
- let max_distance = match input.chars().count() {
371
- 0..=3 => 1,
372
- 4..=6 => 2,
373
- _ => 3,
374
- };
375
- candidates
376
- .map(|c| (c, levenshtein(input, c)))
377
- .filter(|(_, d)| *d <= max_distance)
378
- .min_by_key(|(_, d)| *d)
379
- .map(|(c, _)| c)
380
- }
381
-
382
- /// 标准 Levenshtein 编辑距离(纯函数,无依赖;子命令建议用)。
383
- fn levenshtein(a: &str, b: &str) -> usize {
384
- let a: Vec<char> = a.chars().collect();
385
- let b: Vec<char> = b.chars().collect();
386
- let mut prev: Vec<usize> = (0..=b.len()).collect();
387
- let mut curr = vec![0usize; b.len() + 1];
388
- for (i, &ca) in a.iter().enumerate() {
389
- curr[0] = i + 1;
390
- for (j, &cb) in b.iter().enumerate() {
391
- let cost = if ca == cb { 0 } else { 1 };
392
- curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
393
- }
394
- std::mem::swap(&mut prev, &mut curr);
395
- }
396
- prev[b.len()]
397
+ .map(|spec| Candidate {
398
+ match_key: spec.name.to_string(),
399
+ stable_key: spec.name.to_string(),
400
+ payload: spec.name,
401
+ })
402
+ .collect();
403
+ rank(input, &candidates).into_iter().next()
397
404
  }
398
405
 
399
406
  fn emit_usage_error(message: &str) {
@@ -2011,6 +2018,10 @@ mod tests {
2011
2018
 
2012
2019
  #[test]
2013
2020
  fn e8_levenshtein_basic() {
2021
+ // 0.5.45 naming-addressing: distance function moved to
2022
+ // `crate::model::name_similarity` (shared with --to-name typo
2023
+ // suggestions). Same math, single source.
2024
+ use crate::model::name_similarity::levenshtein;
2014
2025
  assert_eq!(levenshtein("kitten", "sitting"), 3);
2015
2026
  assert_eq!(levenshtein("status", "status"), 0);
2016
2027
  assert_eq!(levenshtein("statu", "status"), 1);
@@ -4100,7 +4100,7 @@ pub mod leader_port {
4100
4100
  "action": "rerun with --confirm to claim ownership of this team",
4101
4101
  }));
4102
4102
  }
4103
- let state = crate::state::persist::load_runtime_state(workspace)
4103
+ let state = crate::state::persist::load_runtime_state_without_migrations(workspace)
4104
4104
  .map_err(|e| CliError::Runtime(e.to_string()))?;
4105
4105
  let Some(team_id) = resolve_owner_team_id(&state, team) else {
4106
4106
  return Ok(json!({
@@ -4112,8 +4112,7 @@ pub mod leader_port {
4112
4112
  }));
4113
4113
  };
4114
4114
  let requested_team = team;
4115
- let explicit_team = requested_team.filter(|team| !team.is_empty());
4116
- let resolved_team = explicit_team.map(|_| team_id.as_str().to_string());
4115
+ let resolved_team = Some(team_id.as_str().to_string());
4117
4116
  if !positive_caller_pane_env_present() {
4118
4117
  let bind = crate::leader::bind_owner_from_caller_pane(workspace, &team_id, None)
4119
4118
  .map_err(|e| CliError::Runtime(e.to_string()))?;
@@ -4124,6 +4123,7 @@ pub mod leader_port {
4124
4123
  .map_err(|e| CliError::Runtime(e.to_string()))?;
4125
4124
  let mut value = lease_value(result);
4126
4125
  insert_resolved_team(&mut value, requested_team, resolved_team.as_deref());
4126
+ append_team_session_ready(workspace, resolved_team.as_deref(), &mut value);
4127
4127
  if value.get("ok").and_then(Value::as_bool) == Some(true) {
4128
4128
  emit_topology_convergence_event(
4129
4129
  workspace,
@@ -4137,6 +4137,7 @@ pub mod leader_port {
4137
4137
  "takeover",
4138
4138
  &mut value,
4139
4139
  );
4140
+ restore_non_target_teams(workspace, &state, resolved_team.as_deref());
4140
4141
  }
4141
4142
  Ok(value)
4142
4143
  }
@@ -4146,7 +4147,7 @@ pub mod leader_port {
4146
4147
  team: Option<&str>,
4147
4148
  confirm: bool,
4148
4149
  ) -> Result<Value, CliError> {
4149
- let state = crate::state::persist::load_runtime_state(workspace)
4150
+ let state = crate::state::persist::load_runtime_state_without_migrations(workspace)
4150
4151
  .map_err(|e| CliError::Runtime(e.to_string()))?;
4151
4152
  let Some(team_id) = resolve_owner_team_id(&state, team) else {
4152
4153
  return Ok(json!({
@@ -4165,12 +4166,12 @@ pub mod leader_port {
4165
4166
  }
4166
4167
  return Ok(owner_bind_value(bind));
4167
4168
  }
4168
- let explicit_team = team.filter(|team| !team.is_empty());
4169
- let resolved_team = explicit_team.map(|_| team_id.as_str().to_string());
4169
+ let resolved_team = Some(team_id.as_str().to_string());
4170
4170
  let result = crate::leader::claim_leader(workspace, resolved_team.as_deref(), confirm)
4171
4171
  .map_err(|e| CliError::Runtime(e.to_string()))?;
4172
4172
  let mut value = lease_value(result);
4173
4173
  insert_resolved_team(&mut value, team, resolved_team.as_deref());
4174
+ append_team_session_ready(workspace, resolved_team.as_deref(), &mut value);
4174
4175
  if value.get("ok").and_then(Value::as_bool) == Some(true) {
4175
4176
  emit_topology_convergence_event(
4176
4177
  workspace,
@@ -4184,6 +4185,7 @@ pub mod leader_port {
4184
4185
  "claim-leader",
4185
4186
  &mut value,
4186
4187
  );
4188
+ restore_non_target_teams(workspace, &state, resolved_team.as_deref());
4187
4189
  }
4188
4190
  Ok(value)
4189
4191
  }
@@ -4279,6 +4281,7 @@ pub mod leader_port {
4279
4281
  "owner_epoch": convergence.get("owner_epoch").cloned().unwrap_or(Value::Null),
4280
4282
  "persisted": convergence.get("persisted").cloned().unwrap_or(Value::Bool(false)),
4281
4283
  "checked_paths": convergence.get("checked_paths").cloned().unwrap_or_else(|| json!([])),
4284
+ "team_session_ready": response.get("team_session_ready").cloned().unwrap_or(Value::Null),
4282
4285
  }),
4283
4286
  );
4284
4287
  }
@@ -4344,9 +4347,96 @@ pub mod leader_port {
4344
4347
  | crate::state::projection::OwnerTeamResolution::Ambiguous { .. } => None,
4345
4348
  }
4346
4349
  }
4347
- None => Some(TeamKey::new(crate::state::projection::team_state_key(
4348
- state,
4349
- ))),
4350
+ None => Some(TeamKey::new(active_or_derived_team_key(state))),
4351
+ }
4352
+ }
4353
+
4354
+ fn active_or_derived_team_key(state: &Value) -> String {
4355
+ state
4356
+ .get("active_team_key")
4357
+ .and_then(Value::as_str)
4358
+ .filter(|team| !team.is_empty())
4359
+ .map(str::to_string)
4360
+ .unwrap_or_else(|| crate::state::projection::team_state_key(state))
4361
+ }
4362
+
4363
+ fn append_team_session_ready(workspace: &Path, team: Option<&str>, response: &mut Value) {
4364
+ let Some(obj) = response.as_object_mut() else {
4365
+ return;
4366
+ };
4367
+ let Some(convergence) = obj.get("topology_convergence") else {
4368
+ return;
4369
+ };
4370
+ if convergence.get("status").and_then(Value::as_str) != Some("converged") {
4371
+ return;
4372
+ }
4373
+ let Some(endpoint) = convergence
4374
+ .get("new_tmux_endpoint")
4375
+ .and_then(Value::as_str)
4376
+ .filter(|endpoint| !endpoint.is_empty())
4377
+ else {
4378
+ obj.insert("team_session_ready".to_string(), Value::Null);
4379
+ return;
4380
+ };
4381
+ let session_name = crate::state::persist::load_runtime_state_without_migrations(workspace)
4382
+ .ok()
4383
+ .and_then(|state| target_team_session_name(&state, team));
4384
+ let ready = session_name
4385
+ .as_deref()
4386
+ .and_then(|session| crate::topology::team_session_ready_on_endpoint(endpoint, session));
4387
+ obj.insert(
4388
+ "team_session_ready".to_string(),
4389
+ ready.map(Value::Bool).unwrap_or(Value::Null),
4390
+ );
4391
+ }
4392
+
4393
+ fn target_team_session_name(state: &Value, team: Option<&str>) -> Option<String> {
4394
+ team.and_then(|team| {
4395
+ state
4396
+ .get("teams")
4397
+ .and_then(Value::as_object)
4398
+ .and_then(|teams| teams.get(team))
4399
+ })
4400
+ .and_then(|team_state| team_state.get("session_name"))
4401
+ .and_then(Value::as_str)
4402
+ .filter(|session| !session.is_empty())
4403
+ .or_else(|| state.get("session_name").and_then(Value::as_str))
4404
+ .filter(|session| !session.is_empty())
4405
+ .map(str::to_string)
4406
+ }
4407
+
4408
+ fn restore_non_target_teams(workspace: &Path, before: &Value, target: Option<&str>) {
4409
+ let Some(target) = target.filter(|team| !team.is_empty()) else {
4410
+ return;
4411
+ };
4412
+ let Some(before_teams) = before.get("teams").and_then(Value::as_object) else {
4413
+ return;
4414
+ };
4415
+ let Ok(mut latest) =
4416
+ crate::state::persist::load_runtime_state_without_migrations(workspace)
4417
+ else {
4418
+ return;
4419
+ };
4420
+ let Some(latest_teams) = latest.get_mut("teams").and_then(Value::as_object_mut) else {
4421
+ return;
4422
+ };
4423
+ let mut changed = false;
4424
+ for (team, before_entry) in before_teams {
4425
+ if team == target {
4426
+ continue;
4427
+ }
4428
+ if latest_teams.get(team) != Some(before_entry) {
4429
+ latest_teams.insert(team.clone(), before_entry.clone());
4430
+ changed = true;
4431
+ }
4432
+ }
4433
+ if changed {
4434
+ let _ = crate::state::repository::StateRepository::new(workspace).save(
4435
+ crate::state::repository::StateWriteIntent::LeaderBindingRestoreNonTargetTeams {
4436
+ target_team_key: target,
4437
+ },
4438
+ &latest,
4439
+ );
4350
4440
  }
4351
4441
  }
4352
4442
 
@@ -61,6 +61,11 @@ pub(crate) struct NamedAddressError {
61
61
  pub requested_team: Option<String>,
62
62
  pub available_team_keys: Vec<String>,
63
63
  pub suggested_name: Option<String>,
64
+ /// 0.5.45 naming-addressing (design §3.4, RED-2/RED-3): echo the
65
+ /// user's original typo so JSON consumers + N38 human can pair
66
+ /// `requested_name`/`suggested_name` without re-parsing. Absent
67
+ /// for exact refusals like empty-workspace.
68
+ pub requested_name: Option<String>,
64
69
  }
65
70
 
66
71
  impl NamedAddressError {
@@ -74,18 +79,149 @@ impl NamedAddressError {
74
79
  requested_team: None,
75
80
  available_team_keys: Vec::new(),
76
81
  suggested_name: None,
82
+ requested_name: None,
77
83
  }
78
84
  }
79
85
 
80
86
  pub(crate) fn n38_message(&self) -> String {
87
+ // 0.5.45 naming-addressing (design §3.4, RED-3): the N38 first
88
+ // line embeds the human-readable `message` (which contains
89
+ // the original typo like `qa-namng`/`btea`) after the reason.
90
+ // Pre-0.5.45 the line was `Error: <reason>` only; the typo
91
+ // hid in the JSON-only `error` field, so the human line
92
+ // couldn't be scanned to recover the original request.
81
93
  format!(
82
- "Error: {}\nAction: {}\nLog: {}",
94
+ "Error: {}: {}\nAction: {}\nLog: {}",
83
95
  self.kind.as_str(),
96
+ self.message,
84
97
  self.action,
85
98
  self.log
86
99
  )
87
100
  }
88
101
 
102
+ /// 0.5.45 (design §3.4, RED-2 named-team): rank team-scope typo
103
+ /// against target workspace `teams.*` keys; candidate `name`
104
+ /// preserves the qualified `team/entity` shape so the copyable
105
+ /// token in Action can be pasted back into `--to-name` unchanged.
106
+ fn with_scoped_suggestions_for_team(
107
+ self,
108
+ state: &Value,
109
+ team_typo: &str,
110
+ entity: &str,
111
+ parsed: &ParsedNamedAddress,
112
+ ) -> Self {
113
+ use crate::model::name_similarity::{rank, Candidate};
114
+ let candidates: Vec<Candidate<(String, String)>> = state
115
+ .get("teams")
116
+ .and_then(Value::as_object)
117
+ .map(|teams| {
118
+ teams
119
+ .keys()
120
+ .map(|team_key| Candidate {
121
+ match_key: team_key.clone(),
122
+ stable_key: team_key.clone(),
123
+ payload: (team_key.clone(), entity.to_string()),
124
+ })
125
+ .collect()
126
+ })
127
+ .unwrap_or_default();
128
+ let ranked = rank(team_typo, &candidates);
129
+ let requested_display = parsed.display_name();
130
+ let candidate_values: Vec<Value> = ranked
131
+ .iter()
132
+ .map(|(team_key, entity)| {
133
+ let name = format!("{team_key}/{entity}");
134
+ json!({
135
+ "name": name,
136
+ "team_key": team_key,
137
+ "agent_id": entity,
138
+ "advisory": true,
139
+ })
140
+ })
141
+ .collect();
142
+ let best = ranked
143
+ .first()
144
+ .map(|(team_key, entity)| format!("{team_key}/{entity}"));
145
+ self.with_scoped_suggestions(requested_display, candidate_values, best)
146
+ }
147
+
148
+ /// 0.5.45 (design §3.4, RED-2 named-agent): rank agent-scope typo
149
+ /// against the legal `teams[team].agents` keys.
150
+ fn with_scoped_suggestions_for_agent(
151
+ self,
152
+ team_entry: &Value,
153
+ team: &str,
154
+ agent_typo: &str,
155
+ parsed: &ParsedNamedAddress,
156
+ ) -> Self {
157
+ use crate::model::name_similarity::{rank, Candidate};
158
+ let candidates: Vec<Candidate<String>> = team_entry
159
+ .get("agents")
160
+ .and_then(Value::as_object)
161
+ .map(|agents| {
162
+ agents
163
+ .keys()
164
+ .map(|agent_id| Candidate {
165
+ match_key: agent_id.clone(),
166
+ stable_key: agent_id.clone(),
167
+ payload: agent_id.clone(),
168
+ })
169
+ .collect()
170
+ })
171
+ .unwrap_or_default();
172
+ let ranked = rank(agent_typo, &candidates);
173
+ let requested_display = parsed.display_name();
174
+ let candidate_values: Vec<Value> = ranked
175
+ .iter()
176
+ .map(|agent_id| {
177
+ json!({
178
+ "name": format!("{team}/{agent_id}"),
179
+ "team_key": team,
180
+ "agent_id": agent_id,
181
+ "advisory": true,
182
+ })
183
+ })
184
+ .collect();
185
+ let best = ranked
186
+ .first()
187
+ .map(|agent_id| format!("{team}/{agent_id}"));
188
+ self.with_scoped_suggestions(requested_display, candidate_values, best)
189
+ }
190
+
191
+ /// 0.5.45 naming-addressing (design §3.3/§3.4): attach an
192
+ /// advisory suggestion payload. `requested` is the original typo
193
+ /// (surfaced verbatim in JSON + N38 Action); `ranked_candidates`
194
+ /// carry the scope-safe candidate payloads produced by
195
+ /// `model::name_similarity::rank`. The best candidate populates
196
+ /// `suggested_name` for convenience; the full list appears as
197
+ /// `candidates` in the JSON envelope.
198
+ pub(crate) fn with_scoped_suggestions(
199
+ mut self,
200
+ requested: impl Into<String>,
201
+ ranked_candidates: Vec<Value>,
202
+ best_copyable: Option<String>,
203
+ ) -> Self {
204
+ let requested = requested.into();
205
+ self.requested_name = Some(requested.clone());
206
+ self.candidates = ranked_candidates;
207
+ self.suggested_name = best_copyable.clone();
208
+ // Rewrite Action so the human line points at the actual
209
+ // returned candidate (RED-4). Falls back to a status-JSON
210
+ // hint when no candidate cleared the threshold.
211
+ if let Some(best) = best_copyable {
212
+ self.action = format!(
213
+ "Did you mean `{best}`? Rerun with `--to-name {best}`."
214
+ );
215
+ } else {
216
+ self.action = format!(
217
+ "No close matches for `{requested}`. \
218
+ Run `team-agent status --json` for the current team_key + agent list \
219
+ and combine them as `<team>/<agent>` for `--to-name`."
220
+ );
221
+ }
222
+ self
223
+ }
224
+
89
225
  pub(crate) fn to_json(&self) -> Value {
90
226
  let mut obj = serde_json::Map::new();
91
227
  obj.insert("ok".to_string(), Value::Bool(false));
@@ -124,6 +260,12 @@ impl NamedAddressError {
124
260
  Value::String(suggested.to_string()),
125
261
  );
126
262
  }
263
+ if let Some(requested) = self.requested_name.as_deref() {
264
+ obj.insert(
265
+ "requested_name".to_string(),
266
+ Value::String(requested.to_string()),
267
+ );
268
+ }
127
269
  Value::Object(obj)
128
270
  }
129
271
  }
@@ -261,8 +403,20 @@ pub(crate) fn resolve_name_with_workspace_transports(
261
403
  pub(crate) fn resolve_name_for_cli(
262
404
  sender_workspace: &Path,
263
405
  raw_name: &str,
406
+ bare_team_scope: Option<&str>,
264
407
  ) -> Result<(ResolvedNamedAddress, Box<dyn Transport>), NamedAddressError> {
265
408
  let parsed = parse_named_address(raw_name)?;
409
+ // 0.5.45 naming-addressing (design §3.1, RED-1): bare `--to-name`
410
+ // + explicit `--team` normalizes to `ParsedTarget::TeamEntity`
411
+ // BEFORE transport selection. Only fires when:
412
+ // 1. address parsed as bare (no `team/`, no `workspace::`),
413
+ // 2. caller passed a non-empty `bare_team_scope`.
414
+ // Qualified addresses keep the scope in the address (design §1
415
+ // priority: workspace::team/entity > team/entity > bare + --team
416
+ // > bare workspace-unique). resolve_name_with_transport and the
417
+ // registry-to-E6 internal helper deliberately pass `None` so
418
+ // caller-supplied `--team` never overrides a full address.
419
+ let parsed = normalize_bare_with_team_scope(parsed, bare_team_scope);
266
420
  let target_workspace = resolve_workspace(sender_workspace, parsed.workspace.as_deref())?;
267
421
  let state = load_state_checked(&target_workspace)?;
268
422
  let transport = transport_for_cli_target(&target_workspace, &state, &parsed);
@@ -276,6 +430,34 @@ pub(crate) fn resolve_name_for_cli(
276
430
  Ok((resolved, transport))
277
431
  }
278
432
 
433
+ /// 0.5.45 naming-addressing (design §3.1): apply bare-team scope归一.
434
+ /// Only bare targets with no workspace prefix consume `--team`;
435
+ /// qualified addresses keep the scope embedded in the address.
436
+ fn normalize_bare_with_team_scope(
437
+ parsed: ParsedNamedAddress,
438
+ bare_team_scope: Option<&str>,
439
+ ) -> ParsedNamedAddress {
440
+ let Some(scope) = bare_team_scope.map(str::trim).filter(|s| !s.is_empty()) else {
441
+ return parsed;
442
+ };
443
+ if parsed.workspace.is_some() {
444
+ return parsed;
445
+ }
446
+ match parsed.target {
447
+ ParsedTarget::BareAgent(agent) => ParsedNamedAddress {
448
+ workspace: parsed.workspace,
449
+ target: ParsedTarget::TeamEntity {
450
+ team: scope.to_string(),
451
+ entity: agent,
452
+ },
453
+ },
454
+ target => ParsedNamedAddress {
455
+ workspace: parsed.workspace,
456
+ target,
457
+ },
458
+ }
459
+ }
460
+
279
461
  /// E6 wiring helper (`cli::send::maybe_enqueue_offline_leader_mailbox`):
280
462
  /// re-parse a `<workspace>::<team>/leader` name and return the resolved
281
463
  /// target workspace + canonical team_key so send.rs can enqueue the
@@ -462,12 +644,32 @@ fn resolve_worker(
462
644
  parsed: &ParsedNamedAddress,
463
645
  transport: &dyn Transport,
464
646
  ) -> Result<ResolvedNamedAddress, NamedAddressError> {
465
- let team_entry = team_entry(state, team).ok_or_else(|| name_not_resolvable_team(team))?;
466
- let agent_entry = team_entry
647
+ let team_entry = match team_entry(state, team) {
648
+ Some(entry) => entry,
649
+ None => {
650
+ // 0.5.45 naming-addressing (design §3.4, RED-2 named):
651
+ // typo in team scope. Candidates come from the target
652
+ // workspace's `teams.*.*` — scope-safe because we're
653
+ // already inside the requested workspace projection.
654
+ return Err(name_not_resolvable_team(team)
655
+ .with_scoped_suggestions_for_team(state, team, agent, parsed));
656
+ }
657
+ };
658
+ let agent_entry = match team_entry
467
659
  .get("agents")
468
660
  .and_then(Value::as_object)
469
661
  .and_then(|agents| agents.get(agent))
470
- .ok_or_else(|| name_not_resolvable_agent(team, agent))?;
662
+ {
663
+ Some(entry) => entry,
664
+ None => {
665
+ // 0.5.45 naming-addressing (design §3.4, RED-2 named):
666
+ // typo in agent within a legal team. Candidates come from
667
+ // `teams[team].agents` — scope stays in the requested
668
+ // team, so sibling teams cannot leak.
669
+ return Err(name_not_resolvable_agent(team, agent)
670
+ .with_scoped_suggestions_for_agent(team_entry, team, agent, parsed));
671
+ }
672
+ };
471
673
  let session = string_field(team_entry, "session_name")
472
674
  .ok_or_else(|| name_not_resolvable("team is missing session_name"))?;
473
675
  let window = string_field(agent_entry, "window")