@team-agent/installer 0.5.44 → 0.5.46

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 (28) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/emit.rs +44 -33
  4. package/crates/team-agent/src/cli/mod.rs +6 -0
  5. package/crates/team-agent/src/cli/named_address.rs +206 -4
  6. package/crates/team-agent/src/cli/send.rs +105 -2
  7. package/crates/team-agent/src/cli/spec.rs +1 -1
  8. package/crates/team-agent/src/cli/status_port.rs +70 -2
  9. package/crates/team-agent/src/cli/tests/run_delegation.rs +7 -4
  10. package/crates/team-agent/src/coordinator/steps/abnormal.rs +14 -22
  11. package/crates/team-agent/src/lifecycle/restart/agent.rs +103 -27
  12. package/crates/team-agent/src/lifecycle/restart/common.rs +136 -0
  13. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +168 -0
  14. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +7 -7
  15. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +1 -1
  16. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +3 -0
  17. package/crates/team-agent/src/lifecycle/types.rs +3 -0
  18. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +10 -0
  19. package/crates/team-agent/src/mcp_server/tools.rs +67 -8
  20. package/crates/team-agent/src/model/mod.rs +6 -0
  21. package/crates/team-agent/src/model/name_similarity.rs +266 -0
  22. package/crates/team-agent/src/provider/session/capture.rs +113 -22
  23. package/crates/team-agent/src/provider/session_scan/codex.rs +51 -4
  24. package/crates/team-agent/src/tmux_backend/tests.rs +116 -10
  25. package/crates/team-agent/src/tmux_backend.rs +5 -16
  26. package/crates/team-agent/src/transport/tests/wire.rs +6 -0
  27. package/crates/team-agent/src/transport.rs +8 -2
  28. package/package.json +4 -4
package/Cargo.lock CHANGED
@@ -575,7 +575,7 @@ dependencies = [
575
575
 
576
576
  [[package]]
577
577
  name = "team-agent"
578
- version = "0.5.44"
578
+ version = "0.5.46"
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.44"
12
+ version = "0.5.46"
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);
@@ -2275,6 +2275,9 @@ pub mod lifecycle_port {
2275
2275
  discarded_session_id,
2276
2276
  session_id,
2277
2277
  new_session_id,
2278
+ capture_state,
2279
+ reset_proof,
2280
+ weak_reset_warning,
2278
2281
  }) => Ok(json!({
2279
2282
  "ok": true,
2280
2283
  "agent_id": env.agent_id.as_str(),
@@ -2285,6 +2288,9 @@ pub mod lifecycle_port {
2285
2288
  "discarded_session_id": discarded_session_id.as_ref().map(|id| id.as_str()),
2286
2289
  "session_id": session_id.as_ref().map(|id| id.as_str()),
2287
2290
  "new_session_id": new_session_id.as_ref().map(|id| id.as_str()),
2291
+ "capture_state": capture_state,
2292
+ "reset_proof": reset_proof,
2293
+ "weak_reset_warning": weak_reset_warning,
2288
2294
  })),
2289
2295
  Ok(crate::lifecycle::ResetAgentOutcome::Refused { reason }) => Ok(json!({
2290
2296
  "ok": false,
@@ -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")
@@ -55,8 +55,17 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
55
55
  "--to-name requires a non-empty message".to_string(),
56
56
  ));
57
57
  }
58
+ // 0.5.45 naming-addressing (design §3.1, RED-1): thread
59
+ // `--team` down to resolver so bare `--to-name agent --team T`
60
+ // scopes to `T` BEFORE workspace scanning. Qualified addresses
61
+ // (`team/agent`, `workspace::team/agent`) ignore the scope
62
+ // per §1 priority ladder.
58
63
  let (resolved, transport) =
59
- match crate::cli::named_address::resolve_name_for_cli(&args.workspace, to_name) {
64
+ match crate::cli::named_address::resolve_name_for_cli(
65
+ &args.workspace,
66
+ to_name,
67
+ args.team.as_deref(),
68
+ ) {
60
69
  Ok(resolved) => resolved,
61
70
  Err(error) => {
62
71
  // E6 (0.5.9 offline-mailbox-toname-design §3.1/§6.2): when
@@ -166,6 +175,15 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
166
175
  outcome = observe_initial_delivery_for_watch(&selected, &target, &outcome, &opts)?;
167
176
  }
168
177
  let mut value = delivery_outcome_json(&outcome, &target, &content, &opts);
178
+ // 0.5.45 naming-addressing (design §3.5, RED-2/RED-3 positional):
179
+ // when the refusal reason is `target_not_in_team` AND the target
180
+ // was a Single non-special short id, attach scope-safe
181
+ // suggestions ranked from `selected.state.agents` — never from
182
+ // raw workspace `teams` (design §3.5 & risk table). Zero DB
183
+ // write / zero inject: the request stays refused, only the JSON
184
+ // envelope gains `requested_name`/`suggested_name`/`candidates`
185
+ // and the human `Action` gains a "Did you mean" line.
186
+ attach_positional_typo_suggestions(&mut value, &target, &selected.state);
169
187
  append_loud_ensure_fields(&mut value, coordinator_ensure.as_ref());
170
188
  if opts.watch_result && initial_delivery_allows_watch(outcome.status) {
171
189
  if let Some(obj) = value.as_object_mut() {
@@ -773,6 +791,67 @@ fn watch_notice_json(target: &MessageTarget, opts: &SendOptions) -> Value {
773
791
  })
774
792
  }
775
793
 
794
+ /// 0.5.45 naming-addressing (design §3.5, RED-2/RED-3 positional):
795
+ /// after `messaging::send_message` refuses with `target_not_in_team`
796
+ /// for a Single non-special short id, attach scope-safe advisory
797
+ /// suggestions to the outbound JSON envelope. Candidate source =
798
+ /// selected team's projected `agents` map (never the raw workspace
799
+ /// `teams`) so sibling teams cannot leak. Zero DB write, zero inject
800
+ /// — the refusal exit code is unchanged.
801
+ fn attach_positional_typo_suggestions(
802
+ value: &mut Value,
803
+ target: &MessageTarget,
804
+ selected_state: &Value,
805
+ ) {
806
+ use crate::model::name_similarity::{rank, Candidate};
807
+ let requested = match target {
808
+ MessageTarget::Single(id) if id != "*" && id != "leader" => id.clone(),
809
+ _ => return,
810
+ };
811
+ let Some(obj) = value.as_object_mut() else {
812
+ return;
813
+ };
814
+ if obj.get("reason").and_then(Value::as_str) != Some("target_not_in_team") {
815
+ return;
816
+ }
817
+ let team_key = selected_state
818
+ .get("active_team_key")
819
+ .or_else(|| selected_state.get("team_key"))
820
+ .and_then(Value::as_str)
821
+ .unwrap_or("");
822
+ let candidates: Vec<Candidate<String>> = selected_state
823
+ .get("agents")
824
+ .and_then(Value::as_object)
825
+ .map(|agents| {
826
+ agents
827
+ .keys()
828
+ .map(|agent_id| Candidate {
829
+ match_key: agent_id.clone(),
830
+ stable_key: agent_id.clone(),
831
+ payload: agent_id.clone(),
832
+ })
833
+ .collect()
834
+ })
835
+ .unwrap_or_default();
836
+ let ranked = rank(&requested, &candidates);
837
+ let candidate_values: Vec<Value> = ranked
838
+ .iter()
839
+ .map(|agent_id| {
840
+ json!({
841
+ "name": agent_id,
842
+ "team_key": team_key,
843
+ "agent_id": agent_id,
844
+ "advisory": true,
845
+ })
846
+ })
847
+ .collect();
848
+ obj.insert("requested_name".to_string(), json!(requested));
849
+ if let Some(best) = ranked.first() {
850
+ obj.insert("suggested_name".to_string(), json!(best));
851
+ }
852
+ obj.insert("candidates".to_string(), Value::Array(candidate_values));
853
+ }
854
+
776
855
  fn delivery_outcome_json(
777
856
  outcome: &DeliveryOutcome,
778
857
  target: &MessageTarget,
@@ -973,6 +1052,25 @@ fn send_human_output(value: &Value) -> String {
973
1052
  parts.push(send_human_field(value, key));
974
1053
  }
975
1054
  }
1055
+ // 0.5.45 naming-addressing (design §3.4/§3.5, RED-3 positional):
1056
+ // when the refusal envelope carries a scope-safe suggestion,
1057
+ // surface it verbatim in human output so users can copy the
1058
+ // right short id. `requested_name` echoes the typo, `suggested_
1059
+ // name` is the copyable canonical.
1060
+ if let Some(requested) = value
1061
+ .get("requested_name")
1062
+ .and_then(Value::as_str)
1063
+ .filter(|s| !s.is_empty())
1064
+ {
1065
+ parts.push(format!("requested_name: {requested}"));
1066
+ }
1067
+ if let Some(suggested) = value
1068
+ .get("suggested_name")
1069
+ .and_then(Value::as_str)
1070
+ .filter(|s| !s.is_empty())
1071
+ {
1072
+ parts.push(format!("Did you mean `{suggested}`? suggested_name: {suggested}"));
1073
+ }
976
1074
  parts.join(" ")
977
1075
  }
978
1076
 
@@ -1250,8 +1348,13 @@ pub fn send_to_canonical_leader_target(
1250
1348
  // synthesized `<workspace>::<team_key>/leader` name so live inject +
1251
1349
  // mailbox both go through one code path.
1252
1350
  let to_name = format!("{}::{}/leader", entry.workspace.display(), entry.team_key);
1351
+ // 0.5.45 naming-addressing (design §3.1 / §4.1): internal
1352
+ // registry-to-E6 delegation MUST pass None for bare_team_scope.
1353
+ // The synthesized name above is a full `workspace::team/leader`
1354
+ // form, and the caller's `--team` flag (if any) must not
1355
+ // override the registry's authoritative team_key.
1253
1356
  let (resolved, transport) =
1254
- match crate::cli::named_address::resolve_name_for_cli(sender_workspace, &to_name) {
1357
+ match crate::cli::named_address::resolve_name_for_cli(sender_workspace, &to_name, None) {
1255
1358
  Ok(r) => r,
1256
1359
  Err(err) => {
1257
1360
  // Named-address refusal — surface it verbatim but tag as
@@ -153,7 +153,7 @@ pub(crate) struct CommandSpec {
153
153
  #[rustfmt::skip]
154
154
  pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
155
155
  CommandSpec { name: "quick-start", tier: CommandTier::Core, category: CommandCategory::Start, kind: CommandKind::Dispatch(DispatchKind::QuickStart), summary: "start or attach a team from TEAM.md", usage: "usage: team-agent quick-start [TEAMDIR] [--workspace WORKSPACE] [--name NAME] [--team-id TEAM|--team TEAM] [--yes] [--no-display] [--backend tmux|conpty] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
156
- CommandSpec { name: "send", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Send), summary: "send a message/task", usage: "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]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
156
+ CommandSpec { name: "send", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Send), summary: "send a message/task", usage: "usage: team-agent send TARGET MESSAGE... [--workspace WORKSPACE] [--team TEAM] [--targets AGENTS] [--to-name NAME | --to-name agent | --to-name team/agent | --to-name workspace::team/agent] [--pane PANE] [--task TASK] [--sender SENDER] [--watch-result] [--requires-ack|--no-ack] [--no-wait] [--timeout SECONDS] [--confirm-human] [--message-id ID] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
157
157
  CommandSpec { name: "status", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Status), summary: "show current team status", usage: "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
158
158
  CommandSpec { name: "collect", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Collect), summary: "collect reported results", usage: "usage: team-agent collect [--workspace WORKSPACE] [--team TEAM] [--result-file FILE] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
159
159
  CommandSpec { name: "restart", tier: CommandTier::Core, category: CommandCategory::TeamLifecycle, kind: CommandKind::Dispatch(DispatchKind::Restart), summary: "restart the selected team", usage: "usage: team-agent restart [WORKSPACE] [--team TEAM] [--allow-fresh] [--session-converge-deadline SECONDS] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },