@team-agent/installer 0.5.44 → 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 +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/emit.rs +44 -33
- package/crates/team-agent/src/cli/named_address.rs +206 -4
- package/crates/team-agent/src/cli/send.rs +105 -2
- package/crates/team-agent/src/cli/spec.rs +1 -1
- package/crates/team-agent/src/cli/tests/run_delegation.rs +7 -4
- package/crates/team-agent/src/mcp_server/tools.rs +67 -8
- package/crates/team-agent/src/model/mod.rs +6 -0
- package/crates/team-agent/src/model/name_similarity.rs +266 -0
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -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") =>
|
|
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
|
-
|
|
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`
|
|
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
|
-
|
|
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|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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);
|
|
@@ -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)
|
|
466
|
-
|
|
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
|
-
|
|
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(
|
|
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 },
|
|
@@ -125,12 +125,15 @@ fn run_dispatches_send_to_handler_returns_ok() {
|
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
#[test]
|
|
128
|
-
fn
|
|
129
|
-
//
|
|
128
|
+
fn run_unknown_subcommand_is_error_not_usage() {
|
|
129
|
+
// 0.5.45 naming-addressing (RED-6): unknown subcommand exits 1
|
|
130
|
+
// (Error), aligned with the shared refusal shape used by
|
|
131
|
+
// `send`/`--to-name` typos. Pre-0.5.45 argparse-style Usage (2)
|
|
132
|
+
// was internal drift.
|
|
130
133
|
assert_eq!(
|
|
131
134
|
run(&["totally-not-a-subcommand".to_string()], Path::new(".")),
|
|
132
|
-
ExitCode::
|
|
133
|
-
"
|
|
135
|
+
ExitCode::Error,
|
|
136
|
+
"unknown subcommand must map to ExitCode::Error (aligned with send/--to-name typo family)"
|
|
134
137
|
);
|
|
135
138
|
}
|
|
136
139
|
|
|
@@ -737,24 +737,83 @@ impl TeamOrchestratorTools {
|
|
|
737
737
|
{
|
|
738
738
|
return None;
|
|
739
739
|
}
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
740
|
+
// 0.5.45 naming-addressing (design §3.6, RED-2 MCP): compute
|
|
741
|
+
// the visible peers ONCE and reuse for both exact-match check
|
|
742
|
+
// AND scope-safe candidate ranking. Peers come from
|
|
743
|
+
// `get_visible_peers()` (owner-team only) so sibling teams,
|
|
744
|
+
// host registry, and workspace reverse scans cannot leak.
|
|
745
|
+
let visible_peers: Vec<String> = self
|
|
746
|
+
.get_visible_peers()
|
|
747
|
+
.map(|visible| {
|
|
748
|
+
visible
|
|
749
|
+
.peers
|
|
750
|
+
.iter()
|
|
751
|
+
.map(|peer| peer.as_str().to_string())
|
|
752
|
+
.collect()
|
|
753
|
+
})
|
|
754
|
+
.unwrap_or_default();
|
|
755
|
+
if visible_peers.iter().any(|peer| peer == target) {
|
|
756
|
+
return None;
|
|
744
757
|
}
|
|
745
|
-
let
|
|
758
|
+
let owner_team_key = owner_team
|
|
759
|
+
.as_ref()
|
|
760
|
+
.map(TeamKey::as_str)
|
|
761
|
+
.unwrap_or("")
|
|
762
|
+
.to_string();
|
|
763
|
+
// Rank the typo against owner-scope peers.
|
|
764
|
+
let ranked = {
|
|
765
|
+
use crate::model::name_similarity::{rank, Candidate};
|
|
766
|
+
let candidates: Vec<Candidate<String>> = visible_peers
|
|
767
|
+
.iter()
|
|
768
|
+
.map(|peer| Candidate {
|
|
769
|
+
match_key: peer.clone(),
|
|
770
|
+
stable_key: peer.clone(),
|
|
771
|
+
payload: peer.clone(),
|
|
772
|
+
})
|
|
773
|
+
.collect();
|
|
774
|
+
rank(target, &candidates)
|
|
775
|
+
};
|
|
776
|
+
let candidate_values: Vec<Value> = ranked
|
|
777
|
+
.iter()
|
|
778
|
+
.map(|peer| {
|
|
779
|
+
serde_json::json!({
|
|
780
|
+
"name": peer,
|
|
781
|
+
"team_key": owner_team_key,
|
|
782
|
+
"agent_id": peer,
|
|
783
|
+
"advisory": true,
|
|
784
|
+
})
|
|
785
|
+
})
|
|
786
|
+
.collect();
|
|
787
|
+
let best = ranked.first().cloned();
|
|
788
|
+
let hint = if let Some(best) = best.as_deref() {
|
|
789
|
+
format!(
|
|
790
|
+
"the requested peer is not in your owner team; did you mean `{best}`?"
|
|
791
|
+
)
|
|
792
|
+
} else {
|
|
793
|
+
"the requested peer is not part of your team; worker-origin MCP cannot widen team scope.".to_string()
|
|
794
|
+
};
|
|
746
795
|
let _ = EventLog::new(&self.workspace).write(
|
|
747
796
|
"mcp.send_message_refused",
|
|
748
797
|
serde_json::json!({
|
|
749
798
|
"reason": "peer_not_in_scope",
|
|
750
|
-
"sender_team_id":
|
|
799
|
+
"sender_team_id": owner_team_key,
|
|
751
800
|
"scope": "team",
|
|
752
|
-
"hint": hint
|
|
801
|
+
"hint": hint,
|
|
753
802
|
}),
|
|
754
803
|
);
|
|
755
804
|
let mut extra = serde_json::Map::new();
|
|
756
805
|
extra.insert("status".to_string(), Value::String("refused".to_string()));
|
|
757
|
-
extra.insert("hint".to_string(), Value::String(hint.
|
|
806
|
+
extra.insert("hint".to_string(), Value::String(hint.clone()));
|
|
807
|
+
// 0.5.45 naming-addressing (design §3.6, RED-2 MCP): additive
|
|
808
|
+
// scope-safe suggestions inside the existing ToolError.extra
|
|
809
|
+
// JSON envelope — no struct/schema change, no event log
|
|
810
|
+
// widening. Advisory only: `isError=true` and
|
|
811
|
+
// reason=peer_not_in_scope stay unchanged.
|
|
812
|
+
extra.insert("requested_name".to_string(), Value::String(target.clone()));
|
|
813
|
+
if let Some(best) = best {
|
|
814
|
+
extra.insert("suggested_name".to_string(), Value::String(best));
|
|
815
|
+
}
|
|
816
|
+
extra.insert("candidates".to_string(), Value::Array(candidate_values));
|
|
758
817
|
Some(ToolError {
|
|
759
818
|
reason: ToolErrorReason::PeerNotInScope,
|
|
760
819
|
exc_type: "PeerNotInScope".to_string(),
|
|
@@ -12,6 +12,12 @@
|
|
|
12
12
|
pub mod enums;
|
|
13
13
|
pub mod errors;
|
|
14
14
|
pub mod ids;
|
|
15
|
+
// 0.5.45 naming-addressing (design §3.2/§4.1): shared pure name
|
|
16
|
+
// similarity ranking helper. crate-private consumer set = cli/emit.rs
|
|
17
|
+
// (subcommand hint), cli/named_address.rs (typo candidates),
|
|
18
|
+
// cli/send.rs (positional adapter), mcp_server/tools.rs (owner-scoped
|
|
19
|
+
// peer suggestions). Never a routing authority — advisory only.
|
|
20
|
+
pub(crate) mod name_similarity;
|
|
15
21
|
pub mod paths;
|
|
16
22
|
pub mod permissions;
|
|
17
23
|
pub mod routing;
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
//! 0.5.45 naming-addressing (design §3.2/§4.1) — shared pure name
|
|
2
|
+
//! similarity ranking helper.
|
|
3
|
+
//!
|
|
4
|
+
//! **Pure**: no filesystem reads, no state loads, no terminal
|
|
5
|
+
//! multiplexer calls, no message dispatch. The RED-6 governance guard
|
|
6
|
+
//! scans this file for the forbidden import tokens and rejects any
|
|
7
|
+
//! occurrence.
|
|
8
|
+
//!
|
|
9
|
+
//! **Single source of truth for name-similarity ranking**: consumed by
|
|
10
|
+
//! - `cli/emit.rs::nearest_subcommand` (unknown top-level subcommand hint),
|
|
11
|
+
//! - `cli/named_address.rs` (bare/`--to-name` typo candidates),
|
|
12
|
+
//! - `cli/send.rs` positional adapter (unknown short id in selected team),
|
|
13
|
+
//! - `mcp_server/tools.rs` owner-scoped peer suggestions.
|
|
14
|
+
//!
|
|
15
|
+
//! **Never a routing authority**: the returned ranking is *advisory
|
|
16
|
+
//! only*. Callers must still exit 1 / `isError=true` / refuse the
|
|
17
|
+
//! request on their original reason; the ranked payload only
|
|
18
|
+
//! populates the additive `requested_name` / `suggested_name` /
|
|
19
|
+
//! `candidates` fields that get echoed back to the user.
|
|
20
|
+
//!
|
|
21
|
+
//! Ranking rules (design §3.2, byte-locked by RED-6):
|
|
22
|
+
//! 1. Comparison keys are trimmed and ASCII-lowercased.
|
|
23
|
+
//! 2. Candidates whose `match_key` prefixes the request outrank
|
|
24
|
+
//! lower-distance non-prefix candidates.
|
|
25
|
+
//! 3. Otherwise sort by Levenshtein distance.
|
|
26
|
+
//! 4. Threshold widens with request length: `0..=3 → 1`, `4..=6 →
|
|
27
|
+
//! 2`, longer → `3`. Nothing beyond the threshold is returned —
|
|
28
|
+
//! no "best guess for a far name".
|
|
29
|
+
//! 5. Sort order: `prefix_miss` (bool), `distance`, `stable_key`.
|
|
30
|
+
//! 6. Deduplicate on `stable_key`, then cap at 3 results.
|
|
31
|
+
//! 7. Canonical spelling comes from the candidate `name` payload
|
|
32
|
+
//! (not the lowercased comparison key) so case is preserved.
|
|
33
|
+
|
|
34
|
+
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
35
|
+
|
|
36
|
+
use std::collections::BTreeSet;
|
|
37
|
+
|
|
38
|
+
/// A candidate to rank. `T` is a caller-owned payload returned
|
|
39
|
+
/// verbatim in the ranked output — callers use it to keep whatever
|
|
40
|
+
/// side data (team_key, workspace, etc.) they need to reconstruct
|
|
41
|
+
/// their advisory JSON envelope.
|
|
42
|
+
#[derive(Debug, Clone)]
|
|
43
|
+
pub struct Candidate<T> {
|
|
44
|
+
/// Comparison key. Callers pass the raw canonical name; this
|
|
45
|
+
/// function trims + lowercases before comparing.
|
|
46
|
+
pub match_key: String,
|
|
47
|
+
/// Stable tie-break key. Typically the same as `match_key`, but
|
|
48
|
+
/// callers with cross-team or cross-workspace candidates use a
|
|
49
|
+
/// composite (`team/agent`) so ordering is deterministic across
|
|
50
|
+
/// otherwise-tied entries.
|
|
51
|
+
pub stable_key: String,
|
|
52
|
+
/// Caller-owned payload returned verbatim.
|
|
53
|
+
pub payload: T,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// Threshold for accepting a candidate given the request length.
|
|
57
|
+
/// Byte-locked by RED-6: short requests get 1, medium 2, longer 3.
|
|
58
|
+
fn threshold_for_len(len: usize) -> usize {
|
|
59
|
+
match len {
|
|
60
|
+
0..=3 => 1,
|
|
61
|
+
4..=6 => 2,
|
|
62
|
+
_ => 3,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// Rank `candidates` against `request`. Returns up to 3 payloads,
|
|
67
|
+
/// most-similar-first. `<request>` casing does not affect matching,
|
|
68
|
+
/// only comparison; the caller-supplied payload (which carries the
|
|
69
|
+
/// canonical `name`) is what surfaces to the user.
|
|
70
|
+
///
|
|
71
|
+
/// Empty return means "no acceptable candidate" — do NOT re-rank or
|
|
72
|
+
/// synthesize a best-guess elsewhere.
|
|
73
|
+
pub fn rank<T: Clone>(request: &str, candidates: &[Candidate<T>]) -> Vec<T> {
|
|
74
|
+
let request_key = normalize(request);
|
|
75
|
+
if request_key.is_empty() {
|
|
76
|
+
return Vec::new();
|
|
77
|
+
}
|
|
78
|
+
let threshold = threshold_for_len(request_key.chars().count());
|
|
79
|
+
let mut seen: BTreeSet<String> = BTreeSet::new();
|
|
80
|
+
let mut scored: Vec<(bool, usize, String, T)> = Vec::new();
|
|
81
|
+
for candidate in candidates {
|
|
82
|
+
if !seen.insert(candidate.stable_key.clone()) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
let match_key = normalize(&candidate.match_key);
|
|
86
|
+
if match_key.is_empty() {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
let distance = levenshtein(&request_key, &match_key);
|
|
90
|
+
// Prefix hit means "request is a prefix of candidate" OR
|
|
91
|
+
// "candidate is a prefix of request" — either direction
|
|
92
|
+
// beats a same-distance edit anywhere in the middle.
|
|
93
|
+
let is_prefix = match_key.starts_with(&request_key) || request_key.starts_with(&match_key);
|
|
94
|
+
// Prefix candidates skip the distance threshold (a 1-char
|
|
95
|
+
// prefix miss with distance 5 is still a legitimate
|
|
96
|
+
// completion hint); non-prefix candidates must clear the
|
|
97
|
+
// threshold.
|
|
98
|
+
if !is_prefix && distance > threshold {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
// Prefix-miss is the primary sort key; sorting bools puts
|
|
102
|
+
// false (prefix hit) before true (prefix miss).
|
|
103
|
+
scored.push((
|
|
104
|
+
!is_prefix,
|
|
105
|
+
distance,
|
|
106
|
+
candidate.stable_key.clone(),
|
|
107
|
+
candidate.payload.clone(),
|
|
108
|
+
));
|
|
109
|
+
}
|
|
110
|
+
scored.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2)));
|
|
111
|
+
scored.into_iter().take(3).map(|(_, _, _, p)| p).collect()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// Convenience wrapper for the common case where callers only need
|
|
115
|
+
/// the single best canonical name (a `String`), not full payloads.
|
|
116
|
+
/// Returns `None` when no candidate clears the threshold.
|
|
117
|
+
pub fn best_name<'a, S: AsRef<str>>(request: &str, candidates: &'a [S]) -> Option<&'a str> {
|
|
118
|
+
let payloads: Vec<Candidate<&'a str>> = candidates
|
|
119
|
+
.iter()
|
|
120
|
+
.map(|c| {
|
|
121
|
+
let name = c.as_ref();
|
|
122
|
+
Candidate {
|
|
123
|
+
match_key: name.to_string(),
|
|
124
|
+
stable_key: name.to_string(),
|
|
125
|
+
payload: name,
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
.collect();
|
|
129
|
+
rank(request, &payloads).into_iter().next()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
fn normalize(input: &str) -> String {
|
|
133
|
+
input.trim().to_ascii_lowercase()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/// Standard Levenshtein edit distance. Pure, no dependencies.
|
|
137
|
+
/// Compared strings must already be normalized (trim + lowercase) by
|
|
138
|
+
/// the caller (`rank` does this internally).
|
|
139
|
+
pub fn levenshtein(a: &str, b: &str) -> usize {
|
|
140
|
+
let a: Vec<char> = a.chars().collect();
|
|
141
|
+
let b: Vec<char> = b.chars().collect();
|
|
142
|
+
let mut prev: Vec<usize> = (0..=b.len()).collect();
|
|
143
|
+
let mut curr = vec![0usize; b.len() + 1];
|
|
144
|
+
for (i, &ca) in a.iter().enumerate() {
|
|
145
|
+
curr[0] = i + 1;
|
|
146
|
+
for (j, &cb) in b.iter().enumerate() {
|
|
147
|
+
let cost = if ca == cb { 0 } else { 1 };
|
|
148
|
+
curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
|
|
149
|
+
}
|
|
150
|
+
std::mem::swap(&mut prev, &mut curr);
|
|
151
|
+
}
|
|
152
|
+
prev[b.len()]
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#[cfg(test)]
|
|
156
|
+
mod tests {
|
|
157
|
+
use super::*;
|
|
158
|
+
|
|
159
|
+
fn c(name: &str) -> Candidate<String> {
|
|
160
|
+
Candidate {
|
|
161
|
+
match_key: name.to_string(),
|
|
162
|
+
stable_key: name.to_string(),
|
|
163
|
+
payload: name.to_string(),
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#[test]
|
|
168
|
+
fn levenshtein_basic() {
|
|
169
|
+
assert_eq!(levenshtein("kitten", "sitting"), 3);
|
|
170
|
+
assert_eq!(levenshtein("", ""), 0);
|
|
171
|
+
assert_eq!(levenshtein("abc", "abc"), 0);
|
|
172
|
+
assert_eq!(levenshtein("abc", "abd"), 1);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
#[test]
|
|
176
|
+
fn prefix_hit_beats_lower_distance_non_prefix() {
|
|
177
|
+
// "stat" is a prefix of "status" (distance 2) but "slat" is
|
|
178
|
+
// distance 1 (mid-word substitution). Prefix wins.
|
|
179
|
+
let out = rank("stat", &[c("slat"), c("status")]);
|
|
180
|
+
assert_eq!(out, vec!["status".to_string(), "slat".to_string()]);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
#[test]
|
|
184
|
+
fn distance_orders_non_prefix() {
|
|
185
|
+
let out = rank("btea", &[c("bota"), c("beta")]);
|
|
186
|
+
assert_eq!(out[0], "beta"); // 1 edit
|
|
187
|
+
assert_eq!(out[1], "bota"); // 2 edits
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
#[test]
|
|
191
|
+
fn stable_key_decides_tie() {
|
|
192
|
+
// Both "beta" and "bota" are distance 2 from "bata".
|
|
193
|
+
let out = rank("bata", &[c("bota"), c("beta")]);
|
|
194
|
+
assert_eq!(out, vec!["beta".to_string(), "bota".to_string()]);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#[test]
|
|
198
|
+
fn caps_at_three() {
|
|
199
|
+
let out = rank("aaaa", &[c("aaab"), c("aaac"), c("aaad"), c("aaae")]);
|
|
200
|
+
assert_eq!(out.len(), 3);
|
|
201
|
+
assert_eq!(out, vec!["aaab", "aaac", "aaad"]);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#[test]
|
|
205
|
+
fn case_preserved_in_payload() {
|
|
206
|
+
// "BTEA" normalized matches "Beta" (distance 1); the
|
|
207
|
+
// canonical payload preserves original casing.
|
|
208
|
+
let out = rank("BTEA", &[c("Beta")]);
|
|
209
|
+
assert_eq!(out, vec!["Beta".to_string()]);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
#[test]
|
|
213
|
+
fn far_request_returns_empty() {
|
|
214
|
+
let out = rank("zzzzzz", &[c("beta")]);
|
|
215
|
+
assert!(out.is_empty());
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#[test]
|
|
219
|
+
fn threshold_widens_with_length() {
|
|
220
|
+
assert_eq!(threshold_for_len(0), 1);
|
|
221
|
+
assert_eq!(threshold_for_len(3), 1);
|
|
222
|
+
assert_eq!(threshold_for_len(4), 2);
|
|
223
|
+
assert_eq!(threshold_for_len(6), 2);
|
|
224
|
+
assert_eq!(threshold_for_len(7), 3);
|
|
225
|
+
assert_eq!(threshold_for_len(20), 3);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
#[test]
|
|
229
|
+
fn empty_request_returns_empty() {
|
|
230
|
+
let out = rank("", &[c("beta")]);
|
|
231
|
+
assert!(out.is_empty());
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
#[test]
|
|
235
|
+
fn dedup_on_stable_key() {
|
|
236
|
+
let out = rank(
|
|
237
|
+
"beta",
|
|
238
|
+
&[
|
|
239
|
+
c("beta"),
|
|
240
|
+
Candidate {
|
|
241
|
+
match_key: "beta".to_string(),
|
|
242
|
+
stable_key: "beta".to_string(),
|
|
243
|
+
payload: "beta-dup".to_string(),
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
);
|
|
247
|
+
assert_eq!(out, vec!["beta".to_string()]);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
#[test]
|
|
251
|
+
fn best_name_convenience() {
|
|
252
|
+
assert_eq!(best_name("btea", &["beta", "bota"]), Some("beta"));
|
|
253
|
+
assert_eq!(best_name("zzzzzz", &["beta"]), None);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
#[test]
|
|
257
|
+
fn statu_still_maps_to_status() {
|
|
258
|
+
// Byte-lock the existing emit.rs behavior: `statu` → `status`
|
|
259
|
+
// with the shared helper.
|
|
260
|
+
let out = rank(
|
|
261
|
+
"statu",
|
|
262
|
+
&[c("status"), c("send"), c("start"), c("restart")],
|
|
263
|
+
);
|
|
264
|
+
assert_eq!(out[0], "status");
|
|
265
|
+
}
|
|
266
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.45",
|
|
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.5.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.5.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.5.
|
|
23
|
+
"@team-agent/cli-darwin-arm64": "0.5.45",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.5.45",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.5.45"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|