dsh-plugin-t-expert 0.2.7 → 0.2.9

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.
@@ -1,25 +1,26 @@
1
1
  #!/usr/bin/env python3
2
- """把 T专家 的小队定义(teams.json)编译成 AgentTeams 的 profiles 配置。
2
+ """把 T专家 的小队定义(teams.json)编译成内置团队引擎的 profiles 配置。
3
3
 
4
- 职责分工:T专家 管「人」(名册 + 中文 + 人格),AgentTeams 管「队」(成员生命周期、任务 DAG、
4
+ 职责分工:T专家 管「人」(名册 + 中文 + 人格),内置引擎 管「队」(成员生命周期、任务 DAG、
5
5
  调度、邮箱、面板)。本脚本只做**数据转换**:读 teams.json + experts/ + zh/,把每位专家的
6
- 中文名与人格正文写成 profile 成员的 executionPrompt,然后写进 DSH profile 的
7
- `cordis.patch.yml`(用标记块包裹,幂等可重放)。
6
+ 中文名与人格正文写成 profile 成员的 executionPrompt,然后写进 `t-team.config.json`
7
+ (引擎由 T专家 在 apply 时挂载并读这份配置)。
8
8
 
9
9
  用法:
10
- python3 team-profiles.py # 生成并写入 profile patch(自动备份)
11
- python3 team-profiles.py --dry-run # 只打印校验与将要写入的内容
10
+ python3 team-profiles.py # 生成并写入 t-team.config.json(+ teams.resolved.json)
11
+ python3 team-profiles.py --dry-run # 只打印校验与**将要写入的内容**(预览 == 产物)
12
12
  python3 team-profiles.py --mode full # 成员人格用完整正文(默认 summary 摘录)
13
- python3 team-profiles.py --no-write # 只输出片段到 stdout,不动文件
13
+ python3 team-profiles.py --no-write # 同上,但不动文件
14
14
 
15
15
  注意(两个已确认的坑):
16
- 1) profile patch 行的 config 是**整体替换**而非深合并,所以这里会把 stateDir / memberProvider
17
- 一起写全,避免抹掉 AgentTeams 自带默认值。
16
+ 1) **不要把 stateDir 写进 t-team.config.json**:状态目录的唯一真源是插件侧 Config.stateDir
17
+ 数据文件里再留一份就会出现「文件里一个值、实际生效另一个值」的双来源(审计 D-1)。
18
18
  2) AgentTeams 的 profile key 只支持 ASCII(中文 key 不会生成命令别名),中文放 description/aliases。
19
19
  """
20
20
  from __future__ import annotations
21
21
 
22
22
  import argparse
23
+ import copy
23
24
  import json
24
25
  import os
25
26
  import re
@@ -43,7 +44,10 @@ MARKER_START = "# >>> t-team teams(由 team-profiles.py 生成,勿手改;
43
44
  MARKER_END = "# <<< t-team teams"
44
45
 
45
46
  MAX_PROFILES = 48 # 引擎侧 lib/teams/profiles.js 已把 MAX_TEAM_PROFILES 调到 48(原默认 16)
46
- MAX_MEMBERS = 8 # 一个 profile 的成员数上限(经验值:队长上下文压力)
47
+ # 一个 profile 的成员数上限(经验值:队长上下文压力)。
48
+ # 只是**默认**:插件侧 Config.maxMembers 经 --max-members 覆盖它,两边同一个真源;
49
+ # 直接跑本脚本(不带该参数)时行为与从前完全一致。
50
+ MAX_MEMBERS = 8
47
51
  MAX_SEED_TASKS = 32 # AgentTeams: MAX_PROFILE_TASKS
48
52
  PROFILE_KEY_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
49
53
  NAME_FIELD = re.compile(r"^name\s*:", re.M)
@@ -107,10 +111,10 @@ def load_roster(experts_root: str, zh_root: str) -> dict[str, dict]:
107
111
 
108
112
 
109
113
  def build_members(slugs: list[str], roster: dict[str, dict], mode: str, budget: int,
110
- errors: list[str], where: str) -> list[dict]:
114
+ errors: list[str], where: str, max_members: int = MAX_MEMBERS) -> list[dict]:
111
115
  members, used_names = [], {}
112
- if len(slugs) > MAX_MEMBERS:
113
- errors.append(f"{where}: 成员 {len(slugs)} 位超过上限 {MAX_MEMBERS}")
116
+ if len(slugs) > max_members:
117
+ errors.append(f"{where}: 成员 {len(slugs)} 位超过上限 {max_members}")
114
118
  for slug in slugs:
115
119
  expert = roster.get(slug)
116
120
  if expert is None:
@@ -160,48 +164,31 @@ def emit_member(member: dict, indent: str) -> str:
160
164
  return "\n".join(lines)
161
165
 
162
166
 
163
- def render_block(profiles: dict[str, dict]) -> str:
164
- lines = [MARKER_START, "- id: agent-teams", " config:",
165
- " stateDir: .agent-teams # 与 AgentTeams 自带默认值保持一致(patch 的 config 是整体替换)",
166
- " memberProvider: spawn",
167
- " profiles:"]
168
- for key, profile in profiles.items():
169
- lines.append(f" {key}:")
170
- lines.append(f" description: {yaml_scalar(profile['description'])}")
171
- lines.append(f" taskPlanning: {profile['taskPlanning']}")
172
- if profile.get("tasks"):
173
- lines.append(" tasks:")
174
- for task in profile["tasks"]:
175
- lines.append(f" - id: {yaml_scalar(task['id'])}")
176
- lines.append(f" subject: {yaml_scalar(task['subject'])}")
177
- if task.get("description"):
178
- lines.append(f" description: {yaml_scalar(task['description'])}")
179
- if task.get("assignee"):
180
- lines.append(f" assignee: {yaml_scalar(task['assignee'])}")
181
- deps = task.get("dependencies") or []
182
- lines.append(" dependencies: [" + ", ".join(yaml_scalar(d) for d in deps) + "]")
183
- lines.append(" members:")
184
- for member in profile["members"]:
185
- lines.append(emit_member(member, " "))
186
- lines.append(MARKER_END)
187
- return "\n".join(lines) + "\n"
188
-
189
-
190
- def write_patch(patch_path: str, block: str, dry_run: bool) -> str:
191
- """把标记块替换进 profile patch(保留其它条目),返回备份路径。"""
192
- old = open(patch_path, encoding="utf-8").read() if os.path.isfile(patch_path) else "[]\n"
193
- pattern = re.compile(re.escape(MARKER_START) + r".*?" + re.escape(MARKER_END) + r"\n?", re.S)
194
- if pattern.search(old):
195
- new = pattern.sub(block, old)
196
- else:
197
- base = old if old.strip() and old.strip() != "[]" else ""
198
- new = (base.rstrip("\n") + "\n\n" if base else "") + block
199
- backup = f"{patch_path}.bak.{datetime.now():%Y%m%d-%H%M%S}"
200
- if dry_run:
201
- return backup
202
- shutil.copy2(patch_path, backup) if os.path.isfile(patch_path) else None
203
- open(patch_path, "w", encoding="utf-8").write(new)
204
- return backup
167
+ def build_engine_config(spec: dict, profiles: dict[str, dict]) -> dict:
168
+ """构造**真正会写下去**的引擎配置(`t-team.config.json` 的那份对象)。
169
+
170
+ 预览与落盘共用这一个构造点,两者不可能再漂移 —— 这是「预览必须等于产物」的实现方式。
171
+
172
+ 注意:**不要**把 stateDir 写进这份 JSON。状态目录的唯一真源是插件侧 Config.stateDir
173
+ (lib/index.js 用它挂载引擎、找团队状态、跑 t_team_plan_check)。这里再写一份,就会出现
174
+ 「数据文件里一个值、实际生效另一个值」的双来源:改开 Config.stateDir 的部署两边失明且无报错
175
+ (审计 D-1)。历史数据文件里的该字段已删除,且插件不再读它。
176
+ """
177
+ return {
178
+ "memberProvider": (spec.get("engine") or {}).get("memberProvider", "spawn"),
179
+ "profiles": {
180
+ key: {
181
+ "description": profile["description"],
182
+ "taskPlanning": profile["taskPlanning"],
183
+ "members": [
184
+ {"name": m["name"], "role": m["role"], "executionPrompt": m["executionPrompt"]}
185
+ for m in profile["members"]
186
+ ],
187
+ **({"tasks": profile["tasks"]} if profile.get("tasks") else {}),
188
+ }
189
+ for key, profile in profiles.items()
190
+ },
191
+ }
205
192
 
206
193
 
207
194
  def remove_patch_block(patch_path: str, dry_run: bool) -> bool:
@@ -220,6 +207,16 @@ def remove_patch_block(patch_path: str, dry_run: bool) -> bool:
220
207
  return True
221
208
 
222
209
 
210
+ def read_engine_state_dir(engine_config_path: str) -> object:
211
+ """读盘上那份产物里的 stateDir 死字段(只为让预览如实说明「它会被丢弃」;读不到就返回 None)。"""
212
+ if not os.path.isfile(engine_config_path):
213
+ return None
214
+ try:
215
+ return json.load(open(engine_config_path, encoding="utf-8")).get("stateDir")
216
+ except (OSError, json.JSONDecodeError):
217
+ return None
218
+
219
+
223
220
  def main() -> int:
224
221
  ap = argparse.ArgumentParser()
225
222
  ap.add_argument("--teams", default=DEFAULT_TEAMS)
@@ -227,10 +224,15 @@ def main() -> int:
227
224
  ap.add_argument("--zh", default=DEFAULT_ZH)
228
225
  ap.add_argument("--patch", default=DEFAULT_PATCH)
229
226
  ap.add_argument("--mode", choices=["summary", "full", "reference"], help="覆盖默认人格模式")
227
+ ap.add_argument("--max-members", type=int, default=MAX_MEMBERS,
228
+ help=f"一个 profile 的成员数上限(默认 {MAX_MEMBERS};插件侧由 Config.maxMembers 透传)")
230
229
  ap.add_argument("--dry-run", action="store_true")
231
230
  ap.add_argument("--no-write", action="store_true")
232
231
  args = ap.parse_args()
233
232
 
233
+ if args.max_members < 1:
234
+ print(f"错误:--max-members 必须是正整数,收到 {args.max_members}", file=sys.stderr)
235
+ return 1
234
236
  if not os.path.isfile(args.teams):
235
237
  print(f"错误:找不到小队定义 {args.teams}", file=sys.stderr)
236
238
  return 1
@@ -267,7 +269,7 @@ def main() -> int:
267
269
  aliases[alias] = key
268
270
  mode = args.mode or profile.get("personaMode") or defaults.get("personaMode") or "summary"
269
271
  budget = int(profile.get("personaBudget") or defaults.get("personaBudget") or 1600)
270
- members = build_members(list(profile.get("members") or []), roster, mode, budget, errors, key)
272
+ members = build_members(list(profile.get("members") or []), roster, mode, budget, errors, key, args.max_members)
271
273
  if not members:
272
274
  errors.append(f"{key}: 没有任何有效成员")
273
275
  continue
@@ -303,32 +305,32 @@ def main() -> int:
303
305
  print(" ✗", error, file=sys.stderr)
304
306
  return 1
305
307
 
306
- block = render_block({k: v for k, v in profiles.items()})
308
+ # 引擎配置:T专家 apply 时读它并 ctx.plugin(内置引擎, 该配置)
309
+ engine_config_path = os.path.join(os.path.dirname(os.path.abspath(args.teams)), "t-team.config.json")
310
+ engine_config = build_engine_config(spec, profiles)
311
+
307
312
  if args.dry_run or args.no_write:
308
- print("\n" + "=" * 60 + "\n" + block)
313
+ # 预览必须等于产物:这里打印的就是上面那个 engine_config 对象。
314
+ # 旧实现在这里打印一段带 `- id: agent-teams` / `stateDir: .agent-teams` 的**遗留 patch 块**,
315
+ # 既与实际产物不符,又与 build_engine_config 的「不要把 stateDir 写进这份 JSON」注释自相矛盾
316
+ # —— 预览是给人看的,看到的东西必须等于真正会写下去的东西(复核 F-1)。
317
+ preview = copy.deepcopy(engine_config)
318
+ legacy_state_dir = read_engine_state_dir(engine_config_path)
319
+ # 预览的键集合必须与产物一致,所以 stateDir **不作为产物键**出现;
320
+ # 盘上若还留着历史死字段,另起一行如实说明它会被丢弃(不塞进 JSON 里假装是产物)。
321
+ print("\n" + "=" * 60)
322
+ print(f"预览:将写入 {engine_config_path}(内容与该文件实际写入的完全一致):\n")
323
+ print(json.dumps(preview, ensure_ascii=False, indent=2))
324
+ print("\nstateDir:不是本文件的产物字段(预览与产物都不含它)。"
325
+ "状态目录的唯一真源是插件侧 Config.stateDir(审计 D-1);"
326
+ f"历史遗留字段{'会被丢弃(盘上当前值 ' + repr(legacy_state_dir) + ')' if legacy_state_dir is not None else '已不在盘上'}。")
309
327
  if args.dry_run:
310
328
  print("(dry-run,未写盘)")
311
329
  return 0
312
330
 
313
- # 引擎配置:T专家 apply 时读它并 ctx.plugin(内置引擎, 该配置)
314
- engine_config_path = os.path.join(os.path.dirname(os.path.abspath(args.teams)), "t-team.config.json")
315
- json.dump({
316
- "stateDir": (spec.get("engine") or {}).get("stateDir", ".agent-teams"),
317
- "memberProvider": (spec.get("engine") or {}).get("memberProvider", "spawn"),
318
- "profiles": {
319
- key: {
320
- "description": profile["description"],
321
- "taskPlanning": profile["taskPlanning"],
322
- "members": [
323
- {"name": m["name"], "role": m["role"], "executionPrompt": m["executionPrompt"]}
324
- for m in profile["members"]
325
- ],
326
- **({"tasks": profile["tasks"]} if profile.get("tasks") else {}),
327
- }
328
- for key, profile in profiles.items()
329
- },
330
- }, open(engine_config_path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
331
- open(engine_config_path, "a", encoding="utf-8").write("\n")
331
+ with open(engine_config_path, "w", encoding="utf-8") as handle:
332
+ json.dump(engine_config, handle, ensure_ascii=False, indent=2)
333
+ handle.write("\n")
332
334
  print(f" 引擎配置 : {engine_config_path}")
333
335
 
334
336
  removed = remove_patch_block(args.patch, args.dry_run)
@@ -355,7 +357,7 @@ def main() -> int:
355
357
  if removed:
356
358
  print(f"\n已从 {args.patch} 移除旧的 agent-teams 配置块(团队引擎现由 T专家 内置挂载)")
357
359
  print(f" 成员人格体积: {total} 字符({len(profiles)} 支小队)")
358
- print("\n下一步:重启 DSH Desktop 让 profile 生效,然后就能用:")
360
+ print("\n下一步:重启 DSH Desktop 让新配置生效,然后就能用:")
359
361
  for key, profile in profiles.items():
360
362
  print(f" /t {profile['aliases'][0] if profile['aliases'] else key} <目标> 或 /t --profile {key} <目标>")
361
363
  return 0