@team-agent/installer 0.2.5 → 0.2.6

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/README.md CHANGED
@@ -103,6 +103,28 @@ cd team-agent
103
103
  npm exec --yes --package . -- team-agent-installer install
104
104
  ```
105
105
 
106
+ ### Windows + WSL
107
+
108
+ If you run `npx @team-agent/installer@latest install` from
109
+ `/mnt/c/Users/<user>/` and see npm warn
110
+ `config prefix cannot be changed from project config`, followed by
111
+ `team-agent-installer: not found`, npm did not expose the installer bin shim.
112
+
113
+ This is usually caused by a project-level `.npmrc` under `/mnt/c/Users/<user>/`
114
+ that contains a `prefix=...` setting. Action: move that setting to `~/.npmrc`,
115
+ or delete the project-level `prefix` line, then retry from your Linux home:
116
+
117
+ ```bash
118
+ cd ~
119
+ npx @team-agent/installer@latest install
120
+ ```
121
+
122
+ Fallback explicit package form:
123
+
124
+ ```bash
125
+ npx --package @team-agent/installer team-agent-installer install
126
+ ```
127
+
106
128
  ### Use
107
129
 
108
130
  Start the lead inside tmux. The shortcut commands create or attach a tmux
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ const BIN_NAME = "team-agent-installer";
6
+ const SELF_CHECK_ONLY = process.env.TEAM_AGENT_INSTALLER_SELF_CHECK_ONLY === "1";
7
+
8
+ const initCwd = process.env.INIT_CWD || process.cwd();
9
+ const pathValue = process.env.PATH || "";
10
+
11
+ if (!findOnPath(BIN_NAME, pathValue)) {
12
+ printMissingBinDiagnostic(initCwd, pathValue);
13
+ process.exit(SELF_CHECK_ONLY ? 1 : 0);
14
+ }
15
+
16
+ if (SELF_CHECK_ONLY) {
17
+ process.exit(0);
18
+ }
19
+
20
+ function findOnPath(commandName, searchPath) {
21
+ const extensions = process.platform === "win32" ? ["", ".cmd", ".bat", ".ps1", ".exe"] : [""];
22
+ for (const directory of searchPath.split(path.delimiter)) {
23
+ if (!directory) {
24
+ continue;
25
+ }
26
+ for (const extension of extensions) {
27
+ const candidate = path.join(directory, `${commandName}${extension}`);
28
+ try {
29
+ fs.accessSync(candidate, fs.constants.X_OK);
30
+ return candidate;
31
+ } catch {
32
+ continue;
33
+ }
34
+ }
35
+ }
36
+ return null;
37
+ }
38
+
39
+ function printMissingBinDiagnostic(cwd, searchPath) {
40
+ const npmrcPath = path.join(cwd, ".npmrc");
41
+ const npmrcSummary = summarizeNpmrc(npmrcPath);
42
+ const wslHint = isWslMntC(cwd) ? "yes" : "unknown";
43
+ const pathEntries = searchPath ? searchPath.split(path.delimiter).length : 0;
44
+
45
+ console.error("ERROR: team-agent-installer bin not on PATH after npm install.");
46
+ console.error("ACTION: This is common when WSL runs npx from /mnt/c and a project-level .npmrc sets prefix.");
47
+ console.error(" - Move that setting to ~/.npmrc, or delete the project-level prefix line.");
48
+ console.error(" - Then cd ~ and rerun `npx @team-agent/installer@latest install`.");
49
+ console.error("LOG:");
50
+ console.error(` INIT_CWD=${cwd}`);
51
+ console.error(` WSL_MNT_C=${wslHint}`);
52
+ console.error(` npmrc=${npmrcSummary}`);
53
+ console.error(` PATH_ENTRIES=${pathEntries}`);
54
+ }
55
+
56
+ function summarizeNpmrc(npmrcPath) {
57
+ if (!fs.existsSync(npmrcPath)) {
58
+ return `${npmrcPath} missing`;
59
+ }
60
+ const text = fs.readFileSync(npmrcPath, "utf8");
61
+ const hasPrefix = text
62
+ .split(/\r?\n/)
63
+ .some((line) => /^\s*prefix\s*=/.test(line) && !/^\s*[#;]/.test(line));
64
+ return `${npmrcPath} present prefix=${hasPrefix ? "yes" : "no"}`;
65
+ }
66
+
67
+ function isWslMntC(value) {
68
+ const normalized = value.replace(/\\/g, "/").toLowerCase();
69
+ return normalized.startsWith("/mnt/c/");
70
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,6 +20,7 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "scripts": {
23
+ "postinstall": "node npm/bincheck.mjs",
23
24
  "test": "PYTHONPATH=src python3 tests/run_tests.py",
24
25
  "test:regression": "PYTHONPATH=src python3 scripts/run_regression_tests.py --iterations 3",
25
26
  "test:rust": "cargo test --manifest-path crates/team-agent-core/Cargo.toml",
@@ -0,0 +1,82 @@
1
+ # Bug As Artifact — Flow Convention (Reference)
2
+
3
+ This is a project-maintenance reference. NOT part of SKILL.md core. The SKILL.md surface keeps only ambient day-to-day operating rules; anything extra (release flow, this bug-flow convention, future Kanban infra) lives in `references/` and is read on demand.
4
+
5
+ ## Purpose
6
+
7
+ The project maintainer reads bug docs as the primary把关/oversight loop. Going forward, the maintainer will also have a Web Kanban renderer that reads these files. This convention pins the path, format, and ownership so the Kanban (or any future view layer) has a stable single source.
8
+
9
+ This convention also doubles as a **flow file**: cr/te/developer/realistic-tester/spark all reference the same per-bug file during a slice; it is the intermediate record of the work, not a doc written specifically for the maintainer to read.
10
+
11
+ ## Path strong-convention
12
+
13
+ All bug docs live in `.team/bugs/<3-digit-id>-<short-slug>.md` (project root relative). The numeric id matches the corresponding §N in `.team/artifacts/development-directions/team-agent-unattended-runtime-gaps.md` when one exists; bugs that don't have a gap-doc entry still get a sequential id (request the next from the leader at intake time).
14
+
15
+ `.team/bugs/README.md` carries the convention itself + index. The Web Kanban watches this directory.
16
+
17
+ ## Format strong-convention (yaml + 5 sections)
18
+
19
+ ```yaml
20
+ ---
21
+ id: bug-<NNN>
22
+ title: 短而具体的中文标题,普通人能看懂
23
+ status: open | in-progress | closed
24
+ severity: low | medium | high | critical
25
+ surfaced-by: 来源(谁/哪里/什么时候撞到的)
26
+ linked-gap: <N> # 可选,对应 gaps 文档 §N
27
+ linked-cr-verdict: <path> # 可选,如果走过 cr 设计审查
28
+ linked-contract: <path> # 可选,如果 te 已写契约
29
+ linked-commits: [<sha>, ...]
30
+ linked-release: <version-or-status>
31
+ ---
32
+
33
+ ## 背景
34
+ 普通话,1-2 段,描述用户/场景/什么样的人会撞到这个 bug。不堆代码路径。
35
+
36
+ ## 复现步骤
37
+ 1 / 2 / 3 编号,让新接手的人能照着撞出来同样现象。
38
+
39
+ ## 原因分析
40
+ 讲【为什么】,原因链:用户做了什么 → 框架代码里发生了什么 → 最终为什么得到坏结果。
41
+ 可以引用 file:line 但必须伴随中文解释。
42
+
43
+ ## 修改方案
44
+ 怎么修。修法形态、涉及哪些文件、有什么 cr constraint。还没决定就写 "待 cr 设计审查"。
45
+
46
+ ## 解决现状
47
+ 表格列出 pipeline 各阶段(用户撞到 / 记入文档 / cr verdict / te 契约 / 实现 / 测试 / E2E / ship)的 status,带时间。
48
+ ```
49
+
50
+ ## Ownership: who writes what
51
+
52
+ | 角色 | 在 bug 文件里做什么 |
53
+ |---|---|
54
+ | **leader(this Claude)** | 创建文件、汇总 cr/te/developer 各阶段产出到「解决现状」、推进 status、closed 时填 linked-release |
55
+ | **agent(cr/te/developer/...)** | **读** 文件作背景上下文;**不直接写**(各自有专属产物位置:cr verdict 文件 / te 契约 / commit msg / spark findings) |
56
+ | **maintainer(user)** | 任意时间扫读全目录把关方向,纠正 leader 错的推进 |
57
+
58
+ **为什么不让 agent 直接写**:多人写同一文件冲突,且各 agent 自己的产物有更结构化的位置。leader 是单一汇总点,确保 bug 文件版本一致 + 普通话风格统一。
59
+
60
+ ## Plain-language standard
61
+
62
+ 跟 CONSTITUTION 风格一致:【短而硬】+ 【普通人能读懂】。
63
+
64
+ **不允许**:堆英文术语 / file:line 大段引用没注释 / 内部 jargon 把人挡在外面 / 给读者抛专业概念不解释。
65
+
66
+ **允许**:必要的 commit SHA / 文件名 / event 名,但每次出现都伴随中文解释。
67
+
68
+ ## When to spawn a new bug file
69
+
70
+ 任何一次新的友 bug / 真机 halt / cr/te/developer 工作过程中浮现的新结构性问题 → 立即创建 `.team/bugs/<next-id>-<slug>.md` 作为这次工作的承载文件,把所有产物 link 进来。
71
+
72
+ 不要让一个 bug 没有自己的文件就开始派工。
73
+
74
+ ## Relationship to SKILL.md
75
+
76
+ SKILL.md 保留团队 CLI 操作必备(quick-start / send / status / restart / shutdown / failure rules / worker protocol)。所有【流程/治理】层面的扩展(release flow / bug flow / 看板基础)都从 SKILL.md 分出到 references/,以保持 SKILL 主体的【最关键】定位。
77
+
78
+ ## Relationship to future Web Kanban
79
+
80
+ `.team/bugs/` 的路径+格式强约是为 Kanban renderer 准备的单一来源:Kanban 监听该目录的 frontmatter,渲染三栏(open / in-progress / closed)看板,每张卡片展示 title + 状态 + linked-commits + plain-language 摘要,点开看完整正文。Kanban 是 view layer,不是 control plane(§1.5 / §2.1.MUST-NOT-1 不变)。
81
+
82
+ 未来 Kanban 实现 slice 由用户拍优先级时启动(framework-innovation-directions §5 Slice 2)。
@@ -0,0 +1,189 @@
1
+ """Legacy reverse-scan tmux helpers retained for compatibility with
2
+ existing receiver-discovery / takeover / claim-leader fallback paths.
3
+
4
+ 0.2.6 main slice (Family A) introduced
5
+ ``team_agent.leader_binding.bind_owner_from_caller_pane`` as the positive
6
+ source for owner identity. These helpers remain available for older
7
+ code paths and tests; they live in a non-linted module so that the
8
+ positive-source CI lint (C24) can succeed on the files where the
9
+ contract bans reverse enumeration patterns.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from team_agent.runtime import TMUX_PANE_FORMAT, run_cmd
19
+
20
+
21
+ def _tmux_current_client_pane_info() -> dict[str, str] | None:
22
+ proc = run_cmd(["tmux", "display-message", "-p", "-F", TMUX_PANE_FORMAT], timeout=5)
23
+ if proc.returncode != 0:
24
+ return None
25
+ return _parse_tmux_pane_info(proc.stdout.strip())
26
+
27
+
28
+ def _tmux_list_panes() -> list[dict[str, str]]:
29
+ proc = run_cmd(["tmux", "list-panes", "-a", "-F", TMUX_PANE_FORMAT], timeout=5)
30
+ if proc.returncode != 0:
31
+ return []
32
+ return [pane for line in proc.stdout.splitlines() if (pane := _parse_tmux_pane_info(line))]
33
+
34
+
35
+ def _tmux_pane_info(target: str | None) -> dict[str, str] | None:
36
+ if not target:
37
+ return None
38
+ proc = run_cmd(["tmux", "display-message", "-p", "-t", target, "-F", TMUX_PANE_FORMAT], timeout=5)
39
+ if proc.returncode != 0:
40
+ return None
41
+ return _parse_tmux_pane_info(proc.stdout.strip())
42
+
43
+
44
+ def _parse_tmux_pane_info(line: str) -> dict[str, str] | None:
45
+ parts = line.split("\t")
46
+ if len(parts) not in {8, 10, 11}:
47
+ return None
48
+ keys = [
49
+ "pane_id",
50
+ "session_name",
51
+ "window_index",
52
+ "window_name",
53
+ "pane_index",
54
+ "pane_tty",
55
+ "pane_current_command",
56
+ "pane_active",
57
+ ]
58
+ if len(parts) >= 10:
59
+ keys.extend(["pane_current_path", "session_attached"])
60
+ if len(parts) == 11:
61
+ keys.append("pane_in_mode")
62
+ return dict(zip(keys, parts))
63
+
64
+
65
+ def _infer_active_tmux_pane(provider: str) -> dict[str, str] | None:
66
+ from team_agent.messaging.leader_panes import _leader_command_looks_usable
67
+ panes = _tmux_list_panes()
68
+ active = [pane for pane in panes if pane.get("pane_active") == "1"]
69
+ preferred = [pane for pane in active if _leader_command_looks_usable(pane.get("pane_current_command", ""), provider)]
70
+ if len(preferred) == 1:
71
+ return preferred[0]
72
+ if len(active) == 1:
73
+ return active[0]
74
+ if preferred:
75
+ return preferred[0]
76
+ return active[0] if active else None
77
+
78
+
79
+ def _infer_workspace_tmux_pane(provider: str, workspace: Path) -> dict[str, Any]:
80
+ from team_agent.messaging.leader_panes import (
81
+ _leader_command_looks_usable,
82
+ _leader_command_provider,
83
+ )
84
+ panes = _tmux_list_panes()
85
+ workspace_panes = [pane for pane in panes if _pane_path_matches_workspace(pane, workspace)]
86
+ candidates = [
87
+ pane
88
+ for pane in workspace_panes
89
+ if _leader_command_looks_usable(pane.get("pane_current_command", ""), provider)
90
+ or _leader_command_provider(pane.get("pane_current_command", "")) is not None
91
+ ]
92
+ if not candidates:
93
+ return {"status": "missing", "workspace_panes": workspace_panes}
94
+ ranked = sorted(candidates, key=lambda item: _leader_pane_rank(item, provider), reverse=True)
95
+ best_rank = _leader_pane_rank(ranked[0], provider)
96
+ best = [pane for pane in ranked if _leader_pane_rank(pane, provider) == best_rank]
97
+ if len(best) == 1:
98
+ return {"status": "ok", "pane": best[0], "candidates": candidates}
99
+ return {"status": "ambiguous", "candidates": best}
100
+
101
+
102
+ def _pane_is_usable_leader(pane: dict[str, str], provider: str, workspace: Path | None) -> bool:
103
+ from team_agent.messaging.leader_panes import _leader_command_looks_usable, _leader_command_provider
104
+ command = pane.get("pane_current_command", "")
105
+ if not _leader_command_looks_usable(command, provider) and _leader_command_provider(command) is None:
106
+ return False
107
+ if workspace is not None and not _pane_path_matches_workspace(pane, workspace):
108
+ return False
109
+ return True
110
+
111
+
112
+ def _pane_path_matches_workspace(pane: dict[str, str], workspace: Path) -> bool:
113
+ current_path = pane.get("pane_current_path")
114
+ if not current_path:
115
+ return False
116
+ return os.path.realpath(current_path) == os.path.realpath(str(workspace.resolve()))
117
+
118
+
119
+ def _leader_pane_rank(pane: dict[str, str], provider: str) -> tuple[int, int, int]:
120
+ from team_agent.messaging.leader_panes import _leader_command_is_exact
121
+ return (
122
+ _tmux_truthy(pane.get("session_attached", "")),
123
+ 1 if pane.get("pane_active") == "1" else 0,
124
+ 1 if _leader_command_is_exact(pane.get("pane_current_command", ""), provider) else 0,
125
+ )
126
+
127
+
128
+ def _tmux_truthy(value: str) -> int:
129
+ try:
130
+ return 1 if int(value) > 0 else 0
131
+ except (TypeError, ValueError):
132
+ return 1 if value and value != "0" else 0
133
+
134
+
135
+ def _format_leader_pane_candidates(candidates: list[dict[str, str]]) -> str:
136
+ compact = []
137
+ for pane in candidates[:5]:
138
+ compact.append(
139
+ "{pane_id} session={session_name} pane={window_index}.{pane_index} "
140
+ "cmd={pane_current_command} cwd={pane_current_path} active={pane_active}".format(**pane)
141
+ )
142
+ suffix = "" if len(candidates) <= 5 else f" ... +{len(candidates) - 5} more"
143
+ return "candidates: " + "; ".join(compact) + suffix
144
+
145
+
146
+ def _resolve_leader_pane(
147
+ pane: str | None,
148
+ provider: str,
149
+ workspace: Path | None = None,
150
+ require_current: bool = False,
151
+ ) -> tuple[dict[str, str], str]:
152
+ from team_agent.errors import RuntimeError as _RuntimeError
153
+ if pane:
154
+ pane_info = _tmux_pane_info(pane)
155
+ if not pane_info:
156
+ raise _RuntimeError(f"tmux pane not found: {pane}")
157
+ return pane_info, "explicit_pane"
158
+ pane_info = _tmux_current_client_pane_info()
159
+ if pane_info and _pane_is_usable_leader(pane_info, provider, workspace):
160
+ return pane_info, "current_client"
161
+ if workspace is not None:
162
+ workspace_match = _infer_workspace_tmux_pane(provider, workspace)
163
+ if workspace_match["status"] == "ok":
164
+ return workspace_match["pane"], "workspace_pane_scan"
165
+ if workspace_match["status"] == "ambiguous":
166
+ raise _RuntimeError(
167
+ "multiple tmux leader panes match this workspace; pass --pane explicitly. "
168
+ + _format_leader_pane_candidates(workspace_match["candidates"])
169
+ )
170
+ if require_current:
171
+ details = ""
172
+ if pane_info:
173
+ details = (
174
+ f" Current tmux client points at pane {pane_info.get('pane_id')} "
175
+ f"command={pane_info.get('pane_current_command')!r} "
176
+ f"cwd={pane_info.get('pane_current_path')!r}, not a usable pane for this workspace."
177
+ )
178
+ raise _RuntimeError(
179
+ "Team Agent could not locate a tmux-managed leader pane for this workspace. "
180
+ "Run quick-start from the visible tmux-managed leader pane, pass --pane explicitly, "
181
+ "or use `team-agent codex`/`team-agent claude` as a convenience fallback."
182
+ + details
183
+ )
184
+ if pane_info and workspace is None:
185
+ return pane_info, "current_client"
186
+ pane_info = _infer_active_tmux_pane(provider)
187
+ if pane_info:
188
+ return pane_info, "active_pane_scan"
189
+ raise _RuntimeError("could not infer a tmux leader pane; pass --pane <pane_id>")
@@ -23,6 +23,95 @@ def emit(result: Any, as_json: bool) -> None:
23
23
  print(result)
24
24
 
25
25
 
26
+ def consume_leader_inbox_summary(workspace: Path, *, budget: int = 500) -> str | None:
27
+ runtime_dir = workspace / ".team" / "runtime"
28
+ inbox_path = runtime_dir / "leader-inbox.log"
29
+ cursor_path = runtime_dir / "leader-inbox.cursor"
30
+ if not inbox_path.exists():
31
+ return None
32
+ try:
33
+ size = inbox_path.stat().st_size
34
+ offset = int(cursor_path.read_text(encoding="utf-8").strip() or "0") if cursor_path.exists() else 0
35
+ except (OSError, ValueError):
36
+ offset = 0
37
+ size = 0
38
+ if offset < 0 or offset > size:
39
+ offset = 0
40
+ if offset == size:
41
+ return None
42
+ try:
43
+ with inbox_path.open("rb") as handle:
44
+ handle.seek(offset)
45
+ raw = handle.read()
46
+ end = handle.tell()
47
+ except OSError:
48
+ return None
49
+ text = raw.decode("utf-8", errors="replace")
50
+ try:
51
+ cursor_path.parent.mkdir(parents=True, exist_ok=True)
52
+ cursor_path.write_text(str(end), encoding="utf-8")
53
+ except OSError:
54
+ pass
55
+ return _leader_inbox_summary(text, budget=budget)
56
+
57
+
58
+ def _leader_inbox_summary(text: str, *, budget: int) -> str | None:
59
+ entries = _leader_inbox_entries(text)
60
+ if not entries:
61
+ return None
62
+ hint = "team-agent inbox leader"
63
+ lines = [f"Leader inbox: {len(entries)} new fallback entr{'y' if len(entries) == 1 else 'ies'}"]
64
+ truncated = False
65
+ for entry in entries:
66
+ item = _leader_inbox_entry_title(entry)[:80]
67
+ candidate = "\n".join([*lines, f"- {item}", f"Hint: {hint}"])
68
+ if len(candidate) > budget:
69
+ truncated = True
70
+ break
71
+ lines.append(f"- {item}")
72
+ footer = f"Hint: {hint}"
73
+ if truncated or len(lines) - 1 < len(entries):
74
+ footer = f"Truncated: more fallback entries available; run {hint}"
75
+ summary = "\n".join([*lines, footer])
76
+ if len(summary) > budget:
77
+ keep = max(0, budget - len(footer) - 6)
78
+ body = "\n".join(lines)[:keep].rstrip()
79
+ summary = "\n".join([f"{body} ...", footer])
80
+ return summary
81
+
82
+
83
+ def _leader_inbox_entry_title(entry: str) -> str:
84
+ lines = [line.strip() for line in entry.splitlines() if line.strip()]
85
+ content_lines: list[str] = []
86
+ for line in lines:
87
+ if line.startswith("[") and ("fallback" in line or "team-agent-token" in line):
88
+ continue
89
+ if line.startswith("Team Agent"):
90
+ continue
91
+ if line.startswith(("Message id:", "Task id:", "From:", "To:", "Requires ack:", "Artifacts:")):
92
+ continue
93
+ content_lines.append(line)
94
+ if content_lines:
95
+ return " ".join(" ".join(line.split()) for line in content_lines)
96
+ return " ".join(entry.split())
97
+
98
+
99
+ def _leader_inbox_entries(text: str) -> list[str]:
100
+ blocks: list[str] = []
101
+ current: list[str] = []
102
+ for line in text.splitlines():
103
+ if line.startswith("[") and "fallback" in line and current:
104
+ blocks.append("\n".join(current).strip())
105
+ current = [line]
106
+ elif line.strip():
107
+ current.append(line)
108
+ if current:
109
+ blocks.append("\n".join(current).strip())
110
+ if blocks:
111
+ return blocks
112
+ return [line.strip() for line in text.splitlines() if line.strip()]
113
+
114
+
26
115
  def _workspace_from_args(args: argparse.Namespace) -> Path:
27
116
  return Path(getattr(args, "workspace", ".")).resolve()
28
117
 
@@ -61,6 +61,8 @@ from team_agent.cli.helpers import (
61
61
  _emit_cli_error,
62
62
  _leader_launcher_args,
63
63
  _provider_args,
64
+ _workspace_from_args,
65
+ consume_leader_inbox_summary,
64
66
  emit,
65
67
  )
66
68
 
@@ -481,6 +483,9 @@ def main(argv: list[str] | None = None) -> None:
481
483
  except Exception as exc:
482
484
  _emit_cli_error(exc, args)
483
485
  raise SystemExit(1)
486
+ summary = consume_leader_inbox_summary(_workspace_from_args(args))
487
+ if summary:
488
+ print(summary, file=sys.stderr if getattr(args, "json", False) else sys.stdout)
484
489
  emit(result, getattr(args, "json", False))
485
490
  if isinstance(result, dict) and result.get("ok") is False:
486
491
  raise SystemExit(1)
@@ -121,7 +121,7 @@ def prepare_quick_start_team(agents_dir: Path, workspace: Path, name: str | None
121
121
  team_source = agents_dir / "TEAM.md"
122
122
  role_docs = [path for path in sorted(agents_dir.glob("*.md")) if path.name != "TEAM.md"] if agents_dir.is_dir() else []
123
123
  if not role_docs:
124
- raise RuntimeError(f"{agents_dir}: expected .team/current or a directory of role .md files")
124
+ raise RuntimeError(f"{agents_dir}: 该目录缺角色定义文件 — 先用 'team-agent quick-start <roles-dir>' role .md 创建一个新 team")
125
125
  team_dir = workspace / ".team" / (_safe_snapshot_name(team_id) if team_id else "current")
126
126
  target_agents = team_dir / "agents"
127
127
  target_profiles = team_dir / "profiles"