dsh-plugin-t-expert 0.2.7 → 0.2.10

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/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  /**
2
3
  * T专家 host 插件:把 ~/.t-team/experts 里的专家名册接到 DSH。
3
4
  *
@@ -38,9 +39,9 @@ import {
38
39
  truncate,
39
40
  writeFileAtomic,
40
41
  } from "./catalog.js";
41
- import { localized, NS, readLocale, renderList, renderSummonResults, t } from "./i18n.js";
42
+ import { localized, NS, readLocale, renderList, renderSummonResults, t, toRenderableGroups, toRenderableSummonResults } from "./i18n.js";
42
43
  import { seedData } from "./bootstrap.js";
43
- import { installOpsSkill } from "./skill.js";
44
+ import { installBundledSkills } from "./skill.js";
44
45
  import { registerTeamCommand } from "./command.js";
45
46
  import { createSquadService } from "./squads.js";
46
47
  import { SNAPSHOT_DIR } from "./bootstrap.js";
@@ -60,7 +61,14 @@ import { collectArchivedTeamsActivity, collectTeamsActivity } from "./teams/snap
60
61
  import { haltTeamWork } from "./teams/tools.js";
61
62
 
62
63
  export const name = NS;
63
- export const inject = ["tools", "subagents", "systemPrompt", "settings", "commands"];
64
+ /**
65
+ * 静态注入的**只有硬依赖**:少了它们,名册功能本身就不成立(工具表、子代理、提示段)。
66
+ *
67
+ * `settings` 与 `commands` 刻意**不在这里**:它们是可选能力,cordis 遇到缺失的静态 inject
68
+ * 会让整个 fiber 静默 pending —— 插件一行日志都不会打,用户既看不到工具也得不到原因。
69
+ * 两者改为在 `apply` 里做同步能力检查(缺了只降级对应功能并 warn)。
70
+ */
71
+ export const inject = ["tools", "subagents", "systemPrompt"];
64
72
 
65
73
  /**
66
74
  * 插件配置。
@@ -111,6 +119,15 @@ export const TEAM_SERVICE = "tTeamTeams";
111
119
  /** 设置命名空间(= 插件名)。 */
112
120
  export const SETTINGS_NAMESPACE = NS;
113
121
 
122
+ /**
123
+ * 「未知专家 slug」的稳定错误码(与 `lib/remote.js` 共用)。
124
+ *
125
+ * 为什么要有它:remote 侧过去靠 `message.includes("未知专家")` 判断这是不是「未知专家」,
126
+ * 而 message 是**显示文案**(随 locale 变,也会被改写)——用它做错误身份,等于把分类建在
127
+ * 会漂移的字符串上。这里给出稳定码,remote 只认码(D-3)。
128
+ */
129
+ export const UNKNOWN_SLUG_ERROR_CODE = "tTeam/unknown-slug";
130
+
114
131
 
115
132
  const settingsSchema = schema.object({
116
133
  enabled: schema.array(schema.string()).default([]),
@@ -133,19 +150,60 @@ export function apply(ctx, config) {
133
150
  }
134
151
 
135
152
  // ---- 设置段(T专家 自己的命名空间,与其它插件不冲突)----
136
- const scope = ctx.settings.register(SETTINGS_NAMESPACE, settingsSchema, {
137
- base: { enabled: [] },
138
- applies: "live",
139
- validate: (value) => {
140
- const list = value?.enabled;
141
- if (!Array.isArray(list) || list.some((item) => typeof item !== "string")) {
142
- throw new Error("t-team.enabled 必须是字符串数组");
153
+ // `settings` 是**可选**服务(见文件顶部的 inject 注释):宿主没挂它时本插件仍要能加载并工作,
154
+ // 所以不在静态 `inject` 里。
155
+ //
156
+ // ⚠️ 但**不能**用 `ctx.get("settings")` 做同步探测(2026-09-13 实测踩到):把 settings 移出静态
157
+ // inject 之后,`apply` 不再等待 settings 就绪,于是那一刻 `ctx.get("settings")` 是 undefined;
158
+ // cordis `provide` **不会回溯通知**已经跑过的同步代码,于是这个判断**永久**为假 ——
159
+ // 真实 GUI 里表现为「设置段没注册 → 点 T专家 是白板」(host 日志有对应 warn)。
160
+ //
161
+ // 正解是用 cordis 的 `ctx.inject`:它等的就是「该服务就绪」这件事,服务迟到也能补上。
162
+ // `ctx.inject(["settings"])` 同时保留了「宿主真没有 settings 就不注册、只降级」的语义。
163
+ /** @type {undefined | { get: () => any }} 设置段句柄;由下面的 inject 在 settings 就绪时赋值。 */
164
+ let scope;
165
+ /** @type {any} settings 服务本体;就绪后由 inject 赋值,`requireSettings()` 用它。 */
166
+ let settingsService;
167
+ if (typeof ctx.inject !== "function") {
168
+ // 只可能出现在极简/测试上下文里:如实说明,不要静默。
169
+ ctx.logger?.warn?.("[t-team] 上下文没有 inject:无法等待 settings 就绪,专家启停不可用。");
170
+ } else {
171
+ // 先如实说一句"在等 settings":`ctx.inject` 对**永不出现**的服务不会有任何回调,
172
+ // 所以只在回调里告警的话,宿主真没给 settings 时用户/日志里**什么都看不到**(实测如此)。
173
+ // 服务随后到了就正常注册;一直没到,这行就是唯一的原因线索。
174
+ ctx.logger?.warn?.("[t-team] 正在等待 settings 服务就绪以注册设置段;若宿主不提供它,专家启停与设置页的 T专家 段将不可用。");
175
+ ctx.inject(["settings"], (scoped) => {
176
+ settingsService = scoped.settings ?? scoped.get?.("settings");
177
+ if (typeof settingsService?.register !== "function") {
178
+ ctx.logger?.warn?.("[t-team] settings 服务没有 register:专家启停不可用(名册与召唤不受影响)。");
179
+ return;
143
180
  }
144
- },
145
- });
181
+ scope = settingsService.register(SETTINGS_NAMESPACE, settingsSchema, {
182
+ base: { enabled: [] },
183
+ applies: "live",
184
+ validate: (value) => {
185
+ const list = value?.enabled;
186
+ if (!Array.isArray(list) || list.some((item) => typeof item !== "string")) {
187
+ throw new Error("t-team.enabled 必须是字符串数组");
188
+ }
189
+ },
190
+ });
191
+ });
192
+ }
146
193
  const enabledSet = () => new Set(
147
- (scope.get()?.enabled ?? []).filter((value) => typeof value === "string"),
194
+ (scope?.get?.()?.enabled ?? []).filter((value) => typeof value === "string"),
148
195
  );
196
+ /**
197
+ * 需要读写「专家启停」的路径在 settings 缺席时必须**响亮失败**。
198
+ *
199
+ * 为什么不能给个空集合凑合:那样 `list_t_experts` 会返回 `total=0`、readonly 工具报「未启用」,
200
+ * 用户与模型都以为「名册是空的 / 什么都没开」,而不是「宿主没挂 settings」—— 正是要被消灭的
201
+ * 那类静默错误。异常经工具错误面报出,原因可读(A-4)。
202
+ */
203
+ const requireSettings = () => {
204
+ if (settingsService === undefined) throw new Error(t(locale(), "error.settingsServiceMissing"));
205
+ return settingsService;
206
+ };
149
207
 
150
208
  // ---- 花名册加载 ----
151
209
  let entries = [];
@@ -161,6 +219,12 @@ export function apply(ctx, config) {
161
219
  let loading = null;
162
220
  let stamp = "";
163
221
  let loadError = null;
222
+ /** 中文侧车目录是否存在(D-5:缺了要能被面板/日志看见,而不是静默变英文名册)。 */
223
+ let sidecarPresent = true;
224
+ /** 加载期被跳过的专家文件数(缺 frontmatter / 读不到 / 冲突,见 lib/catalog.js)。 */
225
+ let skippedFiles = 0;
226
+ /** 加载期读不动的分区目录(该分区专家整批缺席,D-18)。 */
227
+ let unreadableDivisions = [];
164
228
 
165
229
  /**
166
230
  * 名册指纹:source.json、各分区目录、以及中文侧车文件的 mtime。
@@ -201,8 +265,13 @@ export function apply(ctx, config) {
201
265
  const catalog = await loadCatalog(config.root, config.divisions, {
202
266
  zhRoot: config.zhRoot,
203
267
  customRoot: config.customRoot,
268
+ // 名册加载的诊断必须走宿主日志通道,而不是 console(N-3):桌面/Web 里 stderr 用户看不到。
269
+ logger: ctx.logger,
204
270
  });
205
271
  entries = [...catalog.values()];
272
+ sidecarPresent = catalog.sidecarPresent !== false;
273
+ skippedFiles = typeof catalog.skippedFiles === "number" ? catalog.skippedFiles : 0;
274
+ unreadableDivisions = Array.isArray(catalog.unreadableDivisions) ? catalog.unreadableDivisions : [];
206
275
  const discovered = catalogDivisions(catalog);
207
276
  if (catalog.customDivisions !== undefined) customDivisions = catalog.customDivisions;
208
277
  if (catalog.rosterDivisions !== undefined) rosterDivisions = catalog.rosterDivisions;
@@ -229,13 +298,61 @@ export function apply(ctx, config) {
229
298
  await loading;
230
299
  }
231
300
  if (loadError !== null) throw loadError;
301
+ await pruneStaleEnabled();
302
+ }
303
+ /**
304
+ * 清掉设置里**名册已不存在**的 slug(C-6)。
305
+ *
306
+ * 为什么必须清:`enabled` 只是 slug 列表,专家被删(自建专家删除、名册升级)后旧 slug 会留下。
307
+ * 后果不是"多一条无用配置",而是 `list_t_experts` 的 `total` 取 `enabled.size`、与真正展示出来的
308
+ * 条数**对不上**(用户看到「共 3 位」却只列出 2 位),而且没有任何地方会说明原因。
309
+ *
310
+ * 只在 settings 可用时清;清完写回并留一条 warn(用户的启用列表被改动过,应该看得见)。
311
+ * 防误清靠「名册为空就不动」那条判断(见下),不靠"只清一次"。
312
+ */
313
+ async function pruneStaleEnabled() {
314
+ if (settingsService === undefined) return;
315
+ const known = new Set(entries.map((expert) => expert.slug));
316
+ const list = [...enabledSet()];
317
+ const stale = list.filter((slug) => !known.has(slug));
318
+ if (stale.length === 0) return;
319
+ // 名册为空说明这次 load 是残缺的(例如分区目录暂时读不到),此时**不要**清 ——
320
+ // 否则一次临时故障会把用户全部启用项抹掉。
321
+ if (known.size === 0) return;
322
+ try {
323
+ const next = list.filter((slug) => known.has(slug));
324
+ await settingsService.mutate(SETTINGS_NAMESPACE, [{ op: "set", path: ["enabled"], value: next }], revision());
325
+ ctx.logger?.warn?.(`[t-team] 已从启用列表移除 ${stale.length} 个名册里不存在的 slug:${stale.slice(0, 5).join(", ")}`
326
+ + `${stale.length > 5 ? " …" : ""}(名册升级或自建专家删除后的收尾)`);
327
+ } catch (error) {
328
+ // 清不掉不影响可用性(那些 slug 本来也不会被展示),但不能因为并发冲突把加载搞挂。
329
+ ctx.logger?.warn?.(`[t-team] 启用列表里的失效 slug 未能清理:${String(error)}`);
330
+ }
232
331
  }
233
332
  /** 可召唤/可展示的专家(排除重名冲突项)。 */
234
333
  const usable = () => entries.filter((expert) => expert.conflict !== true);
235
334
 
335
+ /**
336
+ * 把一次召唤目标(中文名 / 英文名 / slug 都行)解析成**该语言下的显示名**。
337
+ * 解析不到就原样返回:失败项也要让模型看清它请求的是谁。
338
+ * @param query - 模型给的召唤目标。
339
+ * @param current - 执行期语言。
340
+ */
341
+ function displayNameOf(query, current) {
342
+ const text = String(query ?? "").trim();
343
+ if (text === "") return "";
344
+ try {
345
+ return localized(resolveExpert(usable(), text, current), current).name;
346
+ } catch {
347
+ return text;
348
+ }
349
+ }
350
+
236
351
  // ---- 设置段修订号与写入 ----
237
352
  const revision = () => {
238
- const descriptor = ctx.settings.describe().find((item) => item.ns === SETTINGS_NAMESPACE);
353
+ // settings 是可选服务:缺它时给出同一个可读错误(error.settingsMissing),
354
+ // 而不是 `Cannot read properties of undefined` 这种对用户毫无意义的 TypeError。
355
+ const descriptor = settingsService?.describe?.().find((item) => item.ns === SETTINGS_NAMESPACE);
239
356
  if (descriptor === undefined) throw new Error(t(locale(), "error.settingsMissing"));
240
357
  return descriptor.revision;
241
358
  };
@@ -243,7 +360,12 @@ export function apply(ctx, config) {
243
360
  /** 客户端面板用的名册快照。 */
244
361
  async function snapshot() {
245
362
  await ensureReady();
363
+ // 走工具面的读路径在 settings 缺席时响亮失败(见 requireSettings 的说明):
364
+ // 返回一套 "全部未启用" 的空快照会让用户以为是名册空了,而不是宿主少挂了一个服务。
365
+ requireSettings();
246
366
  const enabled = enabledSet();
367
+ // 映射本身是同步的;只有**自建**专家需要读文件算内容指纹(await)。所以不要为了形式
368
+ // 统一给所有专家都套 async —— 这里保留 Promise.all 是因为 custom 分支确实要 await。
247
369
  const experts = await Promise.all(usable().map(async (expert) => ({
248
370
  // 面板按 locale 取字段:name/description 为中文(缺译回退英文),nameEn/descriptionEn 恒为英文。
249
371
  slug: expert.slug,
@@ -264,6 +386,14 @@ export function apply(ctx, config) {
264
386
  experts,
265
387
  enabled: [...enabled],
266
388
  revision: revision(),
389
+ // 名册健康状态:面板可以据此提示「中文侧车没找到 / 有 N 个专家文件被跳过」,
390
+ // 而不是让用户对着一个静默变全英文的名册猜。
391
+ sidecar: {
392
+ zhRoot: config.zhRoot,
393
+ present: sidecarPresent,
394
+ skippedFiles,
395
+ unreadableDivisions,
396
+ },
267
397
  // 自建专家固定落在这个分区;连同中文标签一起给面板,省得两端各写一份常量。
268
398
  customDivision: CUSTOM_DIVISION,
269
399
  customDivisionLabel: divisionOf(CUSTOM_DIVISION, divisionLabels).zh,
@@ -301,6 +431,7 @@ export function apply(ctx, config) {
301
431
  // 往里写自建专家会在下次同步时被当成"意外文件"删掉。
302
432
  /** 带业务码的错误:remote 的 businessError 会原样透传 code 给面板。 */
303
433
  function customError(codeKey, params = {}) {
434
+ /** @type {Error & { code?: string }} */
304
435
  const error = new Error(t(locale(), codeKey, params));
305
436
  error.code = `tTeam/${codeKey.replace(/^error\./, "").replace(/[A-Z]/g, (ch) => `-${ch.toLowerCase()}`)}`;
306
437
  return error;
@@ -506,7 +637,7 @@ export function apply(ctx, config) {
506
637
  // 删除后把该专家从启用列表里摘掉,否则设置段会留下一个指向空气的 slug
507
638
  const enabled = [...enabledSet()].filter((item) => item !== slug);
508
639
  if (enabled.length !== enabledSet().size) {
509
- await ctx.settings.mutate(SETTINGS_NAMESPACE, [{ op: "set", path: ["enabled"], value: enabled }], revision());
640
+ await settingsService.mutate(SETTINGS_NAMESPACE, [{ op: "set", path: ["enabled"], value: enabled }], revision());
510
641
  }
511
642
  await refreshCatalog();
512
643
  return await snapshot();
@@ -517,8 +648,17 @@ export function apply(ctx, config) {
517
648
  const list = (Array.isArray(next) ? next : []).filter((item) => typeof item === "string");
518
649
  const known = new Set(usable().map((expert) => expert.slug));
519
650
  const unknown = list.filter((slug) => !known.has(slug));
520
- if (unknown.length > 0) throw new Error(`未知专家 slug:${unknown.slice(0, 5).join(", ")}`);
521
- await ctx.settings.mutate(SETTINGS_NAMESPACE, [{ op: "set", path: ["enabled"], value: list }], expectedRevision);
651
+ if (unknown.length > 0) {
652
+ // 稳定 code(不是显示文案):remote 侧按它分类成 tTeam/unknown-expert,与 locale 无关(D-3)。
653
+ // 文案走正确的 t() 通道(过去这里也是硬编码中文,非中文 locale 下会中英混排)。
654
+ const slugs = unknown.slice(0, 5);
655
+ /** @type {Error & { code?: string, details?: { slugs: string[] } }} */
656
+ const error = new Error(t(locale(), "error.customUnknownSlug", { slugs: slugs.join(", ") }));
657
+ error.code = UNKNOWN_SLUG_ERROR_CODE;
658
+ error.details = { slugs };
659
+ throw error;
660
+ }
661
+ await settingsService.mutate(SETTINGS_NAMESPACE, [{ op: "set", path: ["enabled"], value: list }], expectedRevision);
522
662
  return { enabled: [...enabledSet()], revision: revision() };
523
663
  }
524
664
 
@@ -535,11 +675,13 @@ export function apply(ctx, config) {
535
675
  snapshot, prompt, setEnabled, root: config.root,
536
676
  createExpert, updateExpert, deleteExpert,
537
677
  createCategory, updateCategory, deleteCategory,
538
- }; ctx.reflect.provide(CATALOG_SERVICE, catalogService);
678
+ };
679
+ ctx.reflect.provide(CATALOG_SERVICE, catalogService);
539
680
 
540
681
  // ---- 召唤 ----
541
682
  async function runExpert(query, task, exec) {
542
683
  const current = locale();
684
+ requireSettings();
543
685
  const taskText = String(task ?? "").trim();
544
686
  if (taskText === "") throw new Error(t(current, "error.taskEmpty"));
545
687
  if ([...taskText].length > config.summonTaskMaxChars) {
@@ -628,57 +770,51 @@ export function apply(ctx, config) {
628
770
  properties: {
629
771
  divisions: { type: "array", required: true, items: { type: "json" } },
630
772
  total: { type: "number", required: true },
773
+ // 渲染期事实:语言与过滤词进 canonical value,render 才能是纯函数(D-11)。
774
+ locale: { type: "string", required: true },
775
+ query: { type: "string", required: true },
776
+ allDivisions: { type: "array", required: true, items: { type: "string" } },
631
777
  },
632
778
  },
633
- render: (args, value) => [{
634
- type: "text",
635
- text: renderList(locale(), {
636
- query: args.division === undefined ? "" : String(args.division).trim(),
637
- groups: value.divisions,
638
- total: value.total,
639
- allDivisions: effectiveDivisions,
640
- }),
641
- }],
779
+ // render 只读 (args, value):同一个 value 在日志里回放时渲染出同一段文本。
780
+ render: (args, value) => [{ type: "text", text: renderList(args, value) }],
642
781
  },
643
782
  async execute(args) {
644
783
  await ensureReady();
784
+ const current = locale();
785
+ requireSettings();
645
786
  const query = args.division === undefined ? "" : String(args.division).trim();
646
787
  const enabled = enabledSet();
647
788
  const groups = groupByDivision(usable(), enabled);
789
+ // 简介截断是**执行期**的可配项(Config.descriptionLimit),所以在投影时一次做完,
790
+ // render 拿到的就是最终文本(纯函数,不再读 config)。
791
+ const projection = (list) => toRenderableGroups(list, current).map((group) => ({
792
+ ...group,
793
+ experts: group.experts.map((expert) => ({
794
+ ...expert,
795
+ description: truncate(expert.description, config.descriptionLimit),
796
+ })),
797
+ }));
648
798
  if (query === "") {
649
799
  return {
650
- divisions: groups.map((group) => ({
651
- division: group.division,
652
- label: group.label,
653
- labelEn: group.labelEn,
654
- count: group.count,
655
- })),
800
+ divisions: projection(groups),
656
801
  total: enabled.size,
802
+ locale: current,
803
+ query,
804
+ allDivisions: effectiveDivisions.slice(),
657
805
  };
658
806
  }
659
807
  const needle = query.toLowerCase();
660
808
  const matched = groups
661
809
  .filter((group) => group.division.toLowerCase() === needle
662
810
  || group.label.toLowerCase() === needle
663
- || group.labelEn.toLowerCase() === needle)
664
- .map((group) => ({
665
- division: group.division,
666
- label: group.label,
667
- labelEn: group.labelEn,
668
- count: group.count,
669
- experts: group.experts.map((expert) => {
670
- const { name, description } = localized(expert, locale());
671
- return {
672
- slug: expert.slug,
673
- name,
674
- emoji: expert.emoji,
675
- description: truncate(description, config.descriptionLimit),
676
- };
677
- }),
678
- }));
811
+ || group.labelEn.toLowerCase() === needle);
679
812
  return {
680
- divisions: matched,
813
+ divisions: projection(matched),
681
814
  total: matched.reduce((sum, group) => sum + group.count, 0),
815
+ locale: current,
816
+ query,
817
+ allDivisions: effectiveDivisions.slice(),
682
818
  };
683
819
  },
684
820
  }));
@@ -686,6 +822,10 @@ export function apply(ctx, config) {
686
822
  ctx.tools.register(defineTool({
687
823
  name: "summon_t_expert",
688
824
  description: "Summon one T专家 domain expert to complete a task: a specialist subagent runs with that expert's full persona and returns its result. Use it for tasks that clearly belong to a specialist domain. This call waits for the result. Call list_t_experts first when the expert name is unknown.",
825
+ // 预算声明:单次召唤给 10 分钟。**注意它只是声明**(`dsh-tools` 的 registry 不强制 deadline),
826
+ // 真正生效要宿主装配 `@deepseek-ai/dsh-tool-call-timeout-policy`;这里写出来是为了让
827
+ // 「最坏要等多久」有个明确契约,而不是无界挂起。
828
+ timeoutMs: 600_000,
689
829
  parameters: {
690
830
  expert: { type: "string", required: true, description: "Expert name to summon — the Chinese display name (e.g. \"前端开发者\"), the English name (e.g. \"Frontend Developer\"), or its slug (e.g. engineering-frontend-developer). Copy the name exactly as list_t_experts returned it." },
691
831
  task: { type: "string", required: true, description: "The complete, self-contained task to give the expert. Include all necessary context." },
@@ -710,6 +850,9 @@ export function apply(ctx, config) {
710
850
  ctx.tools.register(defineTool({
711
851
  name: "summon_t_experts",
712
852
  description: `Summon multiple T专家 experts in parallel for one mission. At most ${config.maxSummonBatch} experts run with concurrency ${config.summonConcurrency}; successful answers are returned even when some fail.`,
853
+ // 预算声明:批量最坏是 ceil(批大小 / 并发) 轮串行,给 20 分钟。
854
+ // 与单次召唤同理,它只是声明,强制 deadline 需要宿主装配 timeout-policy wrapper。
855
+ timeoutMs: 1_200_000,
713
856
  parameters: {
714
857
  experts: {
715
858
  type: "array",
@@ -729,12 +872,13 @@ export function apply(ctx, config) {
729
872
  schema: {
730
873
  type: "object",
731
874
  additionalProperties: false,
732
- properties: { results: { type: "array", required: true, items: { type: "json" } } },
875
+ properties: {
876
+ results: { type: "array", required: true, items: { type: "json" } },
877
+ // 渲染期事实(D-11):语言进 canonical value,render 才不需要读宿主。
878
+ locale: { type: "string", required: true },
879
+ },
733
880
  },
734
- render: (_args, value) => [{
735
- type: "text",
736
- text: renderSummonResults(locale(), value.results),
737
- }],
881
+ render: (args, value) => [{ type: "text", text: renderSummonResults(args, value) }],
738
882
  },
739
883
  async execute(args, exec) {
740
884
  await ensureReady();
@@ -755,14 +899,19 @@ export function apply(ctx, config) {
755
899
  return { expert: result.expert, ok: true, answer: result.answer };
756
900
  } catch (error) {
757
901
  return {
758
- expert: spec.expert,
902
+ // 失败项也要在**执行期**定好显示名:否则 render 得自己去解析名册才说得清是谁失败了
903
+ // (D-11:render 必须是 (args,value) 的纯函数,不能读名册/宿主 locale)。
904
+ expert: displayNameOf(spec.expert, current),
759
905
  ok: false,
760
906
  answer: "",
761
907
  error: error instanceof Error ? error.message : String(error),
762
908
  };
763
909
  }
764
910
  });
765
- return { results };
911
+ return {
912
+ results: toRenderableSummonResults(results, current),
913
+ locale: current,
914
+ };
766
915
  },
767
916
  }));
768
917
 
@@ -775,67 +924,238 @@ export function apply(ctx, config) {
775
924
  : ["t-team.config.json", "agent-teams.config.json"]
776
925
  .map((file) => join(engineConfigDir, file))
777
926
  .find((candidate) => existsSync(candidate)) ?? join(engineConfigDir, "t-team.config.json");
778
- let engineConfig = {};
779
- let engineConfigDetail = "";
780
- // 配置错误要响亮:**文件存在但内容不合法**属于自带的错配,直接让插件加载失败,
781
- // 而不是带着空配置把团队功能静默降级。文件不存在是"还没生成小队"(自定义 root 时合法),
782
- // 这种情况明确降级,并把原因送到所有可见面(日志 + /t + 下面的 engine-status 提示段)。
783
- if (existsSync(engineConfigPath)) {
927
+ /**
928
+ * 读并解析引擎配置(**每次调用都重新读盘**)。
929
+ *
930
+ * 为什么必须是函数而不是启动期的一个常量:热重载要在保存小队后重新读这份文件。启动期快照
931
+ * 正是「改一项要重启」的成因(audit/01-spec-checklist.md:235)。
932
+ *
933
+ * 失败形态刻意分开(两条路径都不可静默):
934
+ * · 文件不存在 → `{ config: {}, detail: "..." }`:还没生成过小队是**合法状态**(自定义 root
935
+ * 的部署就是这样),降级但把原因送到日志/`/t`/系统提示段;
936
+ * · 存在但内容不合法 → `{ config: null, failure: "..." }`:自带错配,**必须响亮**。
937
+ * 启动路径沿用旧行为(直接让插件加载失败),热重载路径见 `reloadEngine()`(失败则保留旧
938
+ * 引擎并留可诊断信号)。
939
+ */
940
+ const loadEngineConfig = () => {
941
+ if (!existsSync(engineConfigPath)) {
942
+ return { config: {}, failure: "", detail: `找不到团队引擎配置 ${engineConfigPath}(跑一次 team-profiles.py 生成小队后即可用 /t 拉起团队)` };
943
+ }
784
944
  let parsed;
785
945
  try {
786
946
  parsed = JSON.parse(readFileSync(engineConfigPath, "utf8"));
787
947
  } catch (error) {
788
- throw new Error(`团队引擎配置无法解析:${engineConfigPath}(${error instanceof Error ? error.message : String(error)})。它是 team-profiles.py 的产物,删掉后重跑一次即可重建。`);
948
+ return { config: null, failure: `团队引擎配置无法解析:${engineConfigPath}(${error instanceof Error ? error.message : String(error)})。它是 team-profiles.py 的产物,删掉后重跑一次即可重建。`, detail: "" };
789
949
  }
790
950
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
791
- throw new Error(`团队引擎配置必须是 JSON 对象:${engineConfigPath}`);
951
+ return { config: null, failure: `团队引擎配置必须是 JSON 对象:${engineConfigPath}`, detail: "" };
792
952
  }
793
953
  if (parsed.profiles !== undefined && (parsed.profiles === null || typeof parsed.profiles !== "object" || Array.isArray(parsed.profiles))) {
794
- throw new Error(`团队引擎配置的 profiles 必须是对象:${engineConfigPath}`);
954
+ return { config: null, failure: `团队引擎配置的 profiles 必须是对象:${engineConfigPath}`, detail: "" };
795
955
  }
796
- engineConfig = parsed;
797
- } else {
798
- engineConfigDetail = `找不到团队引擎配置 ${engineConfigPath}(跑一次 team-profiles.py 生成小队后即可用 /t 拉起团队)`;
799
- }
956
+ return { config: parsed, failure: "", detail: "" };
957
+ };
958
+ const initialEngineConfig = loadEngineConfig();
959
+ if (initialEngineConfig.failure !== "") throw new Error(initialEngineConfig.failure);
800
960
  // 引擎与名册解耦:引擎挂载失败(例如 Harness 子代理契约不匹配)时,
801
961
  // T专家 的专家名册、@ 召唤、/t 列表仍然可用,只是团队功能不可用。
802
962
  // 这是**已声明的降级**,不是静默跳过:原因会写进日志、经 /t 报出,并进系统提示段。
803
- const engineState = { ok: false, detail: engineConfigDetail };
804
- if (engineConfigDetail === "") {
963
+ //
964
+ // `engineConfig` / `engineFiber` 是热重载的**活状态**(不是启动期快照):
965
+ // · `engineConfig` —— 当前挂载所用的配置对象;每次成功重挂会换成新解析出来的对象;
966
+ // · `engineFiber` —— 当前引擎 fiber;重载时 dispose 它,再用新配置 `ctx.plugin()` 重挂;
967
+ // · `lastMountError` —— 最近一次挂载/重挂失败的原因,挂载成功时清空。
968
+ // 三者都刻意与 `Config` 分开:它们是**运行态**,`Config` 只提供不随小队定义变化的那几个标量。
969
+ let engineConfig = initialEngineConfig.config;
970
+ let engineFiber;
971
+ let lastMountError = "";
972
+ const engineState = {
973
+ ok: false,
974
+ detail: initialEngineConfig.detail,
975
+ /**
976
+ * 最近一次**热重载失败**的原因(成功时清空)。
977
+ *
978
+ * 与 `lastMountError` 分开是有意的:热重载失败时**旧引擎仍然可用**,所以不能把引擎整体报成
979
+ * "不可用"(那会让用户以为团队功能全没了);但"盘上是新小队、运行中的引擎还是旧的"这件事
980
+ * 必须对模型/用户可见 —— 否则用户会看到 `/t` 列着新小队、却用它建不出队,正是本任务要消灭
981
+ * 的那个分叉。提示段靠它区分「引擎从未挂载过」与「挂载过、但这次热重载失败」。
982
+ */
983
+ reloadFailure: "",
984
+ };
985
+ /** 首次探到「调度成功但工具没注册」时的告警只打一次(此后每轮都探,但不再刷日志)。 */
986
+ let engineNotReadyWarned = false;
987
+ const NOT_READY_DETAIL = "内置团队引擎的工具没有注册成功(引擎可能还在等待它声明的宿主服务)";
988
+ /**
989
+ * 引擎是否**真的**就绪 —— 必须真机探测,不能只看 `ctx.plugin()` 有没有抛错。
990
+ *
991
+ * cordis 的 `ctx.plugin()` 只**构造并调度** fiber,不等子插件 `apply` 跑完;而引擎声明的
992
+ * `inject`(lib/teams/index.js:39 的 tools/llm/subagents/systemPrompt/agents)只要缺一个,
993
+ * cordis 就会**静默挂起它的 fiber**:不抛错、不告警,13 个 t_team_* 工具一个都不会注册。
994
+ * 所以唯一可靠的判据是「工具真在注册表里」:`engineState.ok` 只说明调度成功,
995
+ * `ctx.tools.get("t_team_create")` 才说明引擎跑起来了。
996
+ *
997
+ * **探测时机很关键**:宿主里子插件的 fiber 可能在同一同步段末尾、也可能更晚才激活,
998
+ * 所以**不在 mount 的那一刻探**(那一刻必然探空,会把每次正常启动都误报成降级)。
999
+ * 探测发生在所有真实读取点(系统提示段 / `/t` 命令 / 热重载收口),那时引擎早已定局;
1000
+ * 且每一轮都重新探,只在**第一次**探到假时补一条 warn,避免刷日志。
1001
+ * @returns 引擎工具是否已注册。
1002
+ */
1003
+ const engineReady = () => {
1004
+ if (engineState.ok !== true) return false;
1005
+ let present = false;
805
1006
  try {
806
- ctx.plugin(teamsEngine, {
807
- stateDir: config.stateDir,
808
- memberProvider: config.memberProvider,
809
- ...(config.memberModel === undefined ? {} : { memberModel: config.memberModel }),
810
- ...(engineConfig.memberMaxDepth === undefined ? {} : { memberMaxDepth: engineConfig.memberMaxDepth }),
811
- maxMembers: config.maxMembers,
812
- // 引擎自带命令必须关掉:T专家 自己注册 /t(带小队别名解析),否则两个同名命令相撞。
813
- // 手势边界另外复用(见下),它是队长协议真正被触发的地方。
814
- slashCommand: false,
815
- profiles: engineConfig.profiles ?? {},
816
- });
817
- // 复用引擎导出的手势边界:把 `/t --profile <小队> <目标>` 这条用户消息翻成队长协议指令。
818
- installTTeamGestureBoundary(ctx, () => engineConfig.profiles ?? {});
1007
+ present = ctx.tools.get("t_team_create") !== undefined;
1008
+ } catch {
1009
+ present = false;
1010
+ }
1011
+ if (!present && !engineNotReadyWarned) {
1012
+ engineNotReadyWarned = true;
1013
+ // 「工具没注册」这件事必须在日志里留下一条:否则提示段、/t、日志三处都没有信号。
1014
+ ctx.logger?.warn?.(`[t-team] 团队引擎已调度但工具未注册(引擎声明的 inject 服务可能不齐):${NOT_READY_DETAIL}`);
1015
+ }
1016
+ return present;
1017
+ };
1018
+ /** 给用户/模型的原因:挂载/重挂抛错就报那个错,否则报「工具没注册」。 */
1019
+ const engineUnreadyDetail = () => (lastMountError !== "" ? lastMountError : engineState.detail !== "" ? engineState.detail : NOT_READY_DETAIL);
1020
+ /** 引擎挂载参数。**每次挂载现算**:`profiles` 从活取的 `engineConfig` 里取,所以重挂必然带新小队。 */
1021
+ const teamsEngineArgs = () => ({
1022
+ stateDir: config.stateDir,
1023
+ memberProvider: config.memberProvider,
1024
+ ...(config.memberModel === undefined ? {} : { memberModel: config.memberModel }),
1025
+ ...(engineConfig.memberMaxDepth === undefined ? {} : { memberMaxDepth: engineConfig.memberMaxDepth }),
1026
+ maxMembers: config.maxMembers,
1027
+ // 引擎自带命令必须关掉:T专家 自己注册 /t(带小队别名解析),否则两个同名命令相撞。
1028
+ // 手势边界另外复用(见下),它是队长协议真正被触发的地方。
1029
+ slashCommand: false,
1030
+ profiles: engineConfig.profiles ?? {},
1031
+ });
1032
+ /**
1033
+ * 安装手势边界(`/t --profile <小队> <目标>` → 队长协议指令),**恰好一次**。
1034
+ *
1035
+ * 只在引擎真的挂上之后装:边界生成的是"按这支小队建队"的指令,引擎不可用时装了也只会把用户
1036
+ * 消息翻成一条建不成的指令(旧行为如此,这里刻意保持)。传的是**活取值函数**而不是当前
1037
+ * profiles 对象:热重载后它立刻读到新小队目录,不必重装监听器。
1038
+ */
1039
+ let gestureBoundaryInstalled = false;
1040
+ const installGestureBoundaryOnce = () => {
1041
+ if (gestureBoundaryInstalled) return;
1042
+ installTTeamGestureBoundary(ctx, () => engineConfig.profiles ?? {});
1043
+ gestureBoundaryInstalled = true;
1044
+ };
1045
+ try {
1046
+ // 首次挂载(启动期读不到配置就降级,不假装就绪;原因见 prompt 段与日志)。
1047
+ if (initialEngineConfig.detail === "") {
1048
+ engineFiber = ctx.plugin(teamsEngine, teamsEngineArgs());
819
1049
  engineState.ok = true;
820
- } catch (error) {
821
- engineState.detail = error instanceof Error ? error.message : String(error);
822
- ctx.logger?.error?.(`[t-team] 团队引擎挂载失败(专家名册功能不受影响):${engineState.detail}`);
1050
+ installGestureBoundaryOnce();
1051
+ } else {
1052
+ ctx.logger?.error?.(`[t-team] ${initialEngineConfig.detail}`);
823
1053
  }
824
- } else {
825
- ctx.logger?.error?.(`[t-team] ${engineConfigDetail}`);
1054
+ } catch (error) {
1055
+ engineState.ok = false;
1056
+ lastMountError = error instanceof Error ? error.message : String(error);
1057
+ ctx.logger?.error?.(`[t-team] 团队引擎挂载失败(专家名册功能不受影响):${lastMountError}`);
826
1058
  }
1059
+ /**
1060
+ * 团队引擎热重载:保存小队(编译成功后)由小队服务敲门。
1061
+ *
1062
+ * 这是「不重启就能用新小队建队」的收口点。之所以能成立:引擎的 13 个工具、提示段与命令
1063
+ * 都注册在自己的 fiber 下(走 effect),换配置重挂就是「先卸载旧的、再用新 profiles 跑一次
1064
+ * apply」——引擎注册面与卸载面都不需要改代码。
1065
+ *
1066
+ * 为什么用 `dispose()` + 重新 `plugin()`,而不是 `fiber.update(config)`:两者在本仓库当前装的
1067
+ * cordis 4.0.2 上**都实测可用**(同一份真机探针各跑一遍,见交付说明),区别只在语义强弱:
1068
+ * · `update()` 复用同一条 fiber,由框架按"先卸载旧 effect、再用新 config apply"重载,最贴近
1069
+ * 框架本意(设置页与 HMR 走的就是它);但它要求"插件注册的每一条都挂在同一条 fiber 的
1070
+ * effect 上"。本插件是"插件里又挂子插件"的形态(引擎的 apply 里还有工具/提示段/命令/子
1071
+ * fiber),注册面越大,越容易被后来新增的、不走 effect 的注册悄悄破坏 —— 本轮就真的被
1072
+ * 误导过一次:探针桩漏了 effect 包裹,`update()` 立刻撞 `tool "t_team_create" is already
1073
+ * registered in this scope`,看着像框架缺陷、其实是被测面自己没走 effect。
1074
+ * · `dispose()` 会 await 整棵子树的卸载,之后再 `plugin()` 是一个全新的 fiber —— 不存在
1075
+ * "旧 effect 残留"这一类问题,代价只是多创建一次 fiber。
1076
+ * 小队重载是低频操作(用户点一次保存),这里选**更难用错**的那条:以后注册面变大也不会悄悄失效。
1077
+ * 若将来有理由改回 `update()`,探针(reload-probe.mjs)对两者都成立,换实现只需重跑它。
1078
+ *
1079
+ * 语义(对应任务的失败/并发要求):
1080
+ * · 配置读不出来(不存在/解析失败/类型不对)→ **不重挂**,保留旧引擎继续可用,`ok:false`
1081
+ * 带原因返回;下一次保存成功会重新对齐。
1082
+ * · `dispose()` 抛错 → 引擎此刻确实不在了,`ok:false` 且 `engineState.ok=false`(提示段会
1083
+ * 报「引擎不可用」,不假装就绪);但**已保存的配置不会回滚**(它不是坏定义,只是引擎没起来)。
1084
+ * · 重挂抛错 → 同上,但已经尝试过重新挂载,`engineState.ok` 如实反映结果。
1085
+ * · 全程不向上抛(保存已经成功,抛错会让用户以为没保存成)。
1086
+ * @returns `{ ok, detail }`:`detail` 只在失败时非空,且必须是**可操作**的原因。
1087
+ */
1088
+ const reloadEngine = async () => {
1089
+ /** 重载失败的统一收口:留在提示段与日志里("引擎还在用旧小队"与"引擎整个没了"要分清)。 */
1090
+ const reloadFailed = (detail) => {
1091
+ engineState.reloadFailure = detail;
1092
+ ctx.logger?.error?.(`[t-team] 小队已保存,但团队引擎热重载失败:${detail}`);
1093
+ return { ok: false, detail };
1094
+ };
1095
+ const fresh = loadEngineConfig();
1096
+ if (fresh.failure !== "" || fresh.config === null) {
1097
+ // 不换 `engineConfig`:读不出来就当这次重载没发生过,旧引擎继续用旧配置,可用性不变。
1098
+ return reloadFailed(fresh.failure);
1099
+ }
1100
+ engineConfig = fresh.config; // 无论 mount 是否成功都换:新配置就是事实,否则会永远重装旧快照。
1101
+ engineState.detail = fresh.detail;
1102
+ const previousFiber = engineFiber;
1103
+ if (previousFiber !== undefined) {
1104
+ // 先断开引用:dispose 抛错时不能留着一个已死的 fiber 被后续重载再 dispose 一次。
1105
+ engineFiber = undefined;
1106
+ try {
1107
+ await previousFiber.dispose();
1108
+ } catch (error) {
1109
+ engineState.ok = false;
1110
+ engineState.reloadFailure = "";
1111
+ return reloadFailed(`卸载旧引擎失败:${error instanceof Error ? error.message : String(error)}`);
1112
+ }
1113
+ }
1114
+ try {
1115
+ const next = ctx.plugin(teamsEngine, teamsEngineArgs());
1116
+ engineFiber = next;
1117
+ await next; // fiber 是 thenable:await 它等价于等 apply 落定。
1118
+ } catch (error) {
1119
+ engineFiber = undefined;
1120
+ engineState.ok = false;
1121
+ lastMountError = error instanceof Error ? error.message : String(error);
1122
+ return reloadFailed(`重新挂载引擎失败:${lastMountError}`);
1123
+ }
1124
+ // 定局探测(engineReady 同样只报一次「工具未注册」的 warn,避免刷日志)。
1125
+ if (ctx.tools.get("t_team_create") === undefined && engineState.ok !== true) {
1126
+ // apply 抛过错 → 引擎真的没起来;服务不齐(正常生命周期)在这里不算失败:cordis 会在
1127
+ // 服务回来时自己重载那条 fiber。
1128
+ return reloadFailed(engineUnreadyDetail());
1129
+ }
1130
+ engineState.ok = true;
1131
+ lastMountError = "";
1132
+ engineState.reloadFailure = "";
1133
+ // 启动期因"还没有小队配置"而没挂上时,这里补装手势边界:现在引擎已经起来了。
1134
+ installGestureBoundaryOnce();
1135
+ return { ok: true, detail: "" };
1136
+ };
827
1137
 
828
1138
  // 已声明的降级也要**对模型可见**:否则"团队功能没了"这件事只留在日志里,
829
1139
  // 用户问起来模型只能说不知道 —— 那正是规范禁止的"静默跳过缺失的引用对象"。
1140
+ // 门槛用 engineReady()(真机探测)而不是 engineState.ok:两者在「引擎 fiber 被挂起」时不同。
830
1141
  ctx.systemPrompt.section({
831
1142
  name: "t-team:engine-status",
832
1143
  order: 120,
833
1144
  text: (context) => {
834
1145
  if (context.agent?.session?.header?.parentSession !== undefined) return "";
835
- if (engineState.ok === true) return "";
1146
+ if (engineReady()) {
1147
+ // 引擎可用、但上一次热重载没成功:盘上的小队定义已经变了,运行中的引擎还是旧的。
1148
+ return engineState.reloadFailure === ""
1149
+ ? ""
1150
+ : [
1151
+ "## T专家 小队定义已保存,但团队引擎没有换成新配置",
1152
+ `这次保存已经生效(/t 列表就是新定义),但引擎仍在使用上一次的小队配置,所以用新改的小队建队会失败。原因:${engineState.reloadFailure}`,
1153
+ "需要重新保存一次小队定义让引擎再试一次,或重启 DSH。用户问起时请原样转述上面的原因。",
1154
+ ].join("\n");
1155
+ }
836
1156
  return [
837
1157
  "## T专家 团队引擎当前不可用",
838
- `团队功能(/t 建队、t_team_* 工具)现在不可用。原因:${engineState.detail}`,
1158
+ `团队功能(/t 建队、t_team_* 工具)现在不可用。原因:${engineUnreadyDetail()}`,
839
1159
  "专家名册、@ 召唤与 summon_t_expert 不受影响。用户需要团队功能时,请原样转述上面的原因,不要假装能建队。",
840
1160
  ].join("\n");
841
1161
  },
@@ -843,18 +1163,34 @@ export function apply(ctx, config) {
843
1163
 
844
1164
  // ---- 小队定义服务(设置页「小队」标签用;写回 teams.json 后自动重跑 team-profiles.py)----
845
1165
  const teamsFile = join(dirname(config.root), "teams.json");
1166
+ // 编译器**只认包内那份**(随插件版本走)。刻意**不再回退**数据目录里的 `team-profiles.py`:
1167
+ // 那份是 bootstrap 播种的用户可写文件,会让「保存小队」变成「执行用户可写脚本」——
1168
+ // 同用户权限下的代码执行面,且它与包内版本的漂移无法校验。裁剪安装(包内被删)时
1169
+ // 小队编译会**响亮失败**(原因经 logger 与面板可见),而不是悄悄执行另一份文件(C-3)。
1170
+ const packagedGenerator = join(SNAPSHOT_DIR, "team-profiles.py");
846
1171
  const squads = createSquadService({
847
1172
  teamsFile,
848
1173
  catalog: catalogService,
849
- // 编译器优先用包内那份(随插件版本走);包内没有(例如被裁剪的安装)才回退数据目录。
850
- generator: existsSync(join(SNAPSHOT_DIR, "team-profiles.py"))
851
- ? join(SNAPSHOT_DIR, "team-profiles.py")
852
- : undefined,
1174
+ generator: packagedGenerator,
1175
+ // 成员上限**只有一个真源**:Config.maxMembers。它同时喂给引擎(成员上限)与小队编译器
1176
+ // (--max-members)。过去编译器把上限写死 8、这里又没透传,于是把 maxMembers 改大后保存小队
1177
+ // 必然编译失败并回滚(D-14)。编译器不认这个参数时由 squads.js 记 warn 并退回它内置默认值。
1178
+ maxMembers: config.maxMembers,
1179
+ // 保存小队**编译成功后**的热重载钩子:让运行中的引擎立刻用新 profiles 重挂,不必重启 DSH。
1180
+ // 注入的是回调而不是 `ctx`:数据层不该知道 cordis 的存在(它只负责在正确时机敲门)。
1181
+ onReload: reloadEngine,
1182
+ // 小队编译/重载的诊断出口(N-3):过去 squads.js 直接写 console.error,桌面与 Web 里
1183
+ // 用户看不见,等于「保存成功但重载失败」这条失败从来没被上报过。
1184
+ logger: ctx.logger,
853
1185
  });
854
1186
  ctx.reflect.provide(SQUAD_SERVICE, squads);
855
1187
 
856
1188
  // ---- 团队运行服务(设置页「团队」标签用;复用引擎的快照与停止实现)----
857
- const engineStateDir = typeof engineConfig.stateDir === "string" ? engineConfig.stateDir : ".agent-teams";
1189
+ // 状态目录**只有一个来源**:`config.stateDir`(同时也是挂给引擎的那个值,见上面的 ctx.plugin)。
1190
+ // 曾经这里读的是数据文件 `t-team.config.json` 的 stateDir(`agent-teams.config.json` 时代的遗留),
1191
+ // 于是「引擎挂到 config.stateDir、面板与 t_team_plan_check 却去数据文件那个目录找」——
1192
+ // 改开 stateDir 的部署会两边失明且没有任何报错(D-1)。
1193
+ const engineStateDir = config.stateDir;
858
1194
  const teamRoots = () => {
859
1195
  const registry = ctx.get("workspaceRegistry") ?? ctx.get("workspace");
860
1196
  const list = typeof registry?.list === "function" ? registry.list() : [];
@@ -921,11 +1257,17 @@ export function apply(ctx, config) {
921
1257
  },
922
1258
  async stop(teamId) {
923
1259
  const roots = teamRoots();
1260
+ // 逐 root 读取失败时**留住根因**:过去 `catch { continue; }` 把 EACCES 之类换成
1261
+ // 下面那句「没找到团队 …(它可能已被归档)」,用户按提示去查归档永远查不到(N-4)。
1262
+ const failures = [];
924
1263
  for (const root of roots) {
925
1264
  let snapshots;
926
1265
  try {
927
1266
  snapshots = await collectTeamsActivity(ctx, [root]);
928
- } catch {
1267
+ } catch (error) {
1268
+ const reason = error instanceof Error ? `${error.message}` : String(error);
1269
+ failures.push(`${root.stateRoot}:${reason}`);
1270
+ ctx.logger?.warn?.(`[t-team] 读取团队活动失败:${root.stateRoot}(${reason})`);
929
1271
  continue;
930
1272
  }
931
1273
  const team = snapshots.find((item) => item.teamId === teamId);
@@ -937,23 +1279,44 @@ export function apply(ctx, config) {
937
1279
  const result = await haltTeamWork({ ctx, stateRoot: root.stateRoot, teamId, captain });
938
1280
  return { teamName: result.teamName, cancelledTasks: result.cancelledTasks, alreadyHalted: result.alreadyHalted === true };
939
1281
  }
940
- throw new Error(`没找到团队 ${teamId} 的状态目录(它可能已被归档)。`);
1282
+ // 读不到任何 root 时不要用「可能已归档」这种猜测掩盖根因:把真实失败原因一并报出来。
1283
+ throw new Error(failures.length > 0
1284
+ ? `没找到团队 ${teamId}:${roots.length} 个工作区里${failures.length} 个读取失败(${failures.join(";")})。`
1285
+ : `没找到团队 ${teamId} 的状态目录(它可能已被归档)。`);
941
1286
  },
942
1287
  });
943
1288
 
944
1289
  // ---- `/t` 小队短命令(把 /t <小队> <目标> 转成引擎认得的 /t --profile <key> <目标> 用户消息)----
945
- registerTeamCommand(ctx, {
946
- teamsFile,
947
- hasEngine: () => {
948
- if (engineState.ok !== true) return false;
949
- try {
950
- return ctx.tools.get("t_team_create") !== undefined;
951
- } catch {
952
- return false;
1290
+ // `commands` 同为**可选**服务(见顶部 inject 注释):与 settings 完全同理——
1291
+ // 真实宿主里它也可能**晚于本插件就绪**,而 `ctx.get` 只在服务已就绪时才返回对象
1292
+ // (2026-09-13 真实 GUI 实证:apply 时同步探测判负 → /t 根本没注册,而引擎自己那条
1293
+ // `ctx.inject(['commands'])` 却正常)。所以这里也改成 `ctx.inject` 等就绪,不用同步探测。
1294
+ if (typeof ctx.inject !== "function") {
1295
+ ctx.logger?.warn?.("[t-team] 上下文没有 inject:无法等待 commands 就绪,/t 命令不可用。");
1296
+ } else {
1297
+ // 与 settings 同理:`ctx.inject` 对**永不出现**的服务不会有回调,只在回调里告警的话
1298
+ // 宿主真没给 commands 时日志里什么线索都没有。所以申请时就先说一句。
1299
+ ctx.logger?.warn?.("[t-team] 正在等待 commands 服务就绪以注册 /t 命令;若宿主不提供它,/t 将不可用。");
1300
+ ctx.inject(["commands"], (scoped) => {
1301
+ // `scoped` 是**作用域 ctx**(真 cordis 实测:服务属性在它上面、`scoped.get` 也可用),
1302
+ // 但它**不是**完整 ctx(`scoped.effect` 是 undefined)。所以分工:作用域解析服务,
1303
+ // 主 ctx 负责 effect/logger —— 绝不把 `scoped` 当 ctx 传给注册点。
1304
+ const commands = scoped?.commands ?? scoped?.get?.("commands");
1305
+ if (typeof commands?.register !== "function") {
1306
+ ctx.logger?.warn?.("[t-team] commands 服务没有 register:/t 命令不可用(@ 召唤与 summon_t_expert 不受影响)。");
1307
+ return;
953
1308
  }
954
- },
955
- engineError: () => engineState.detail,
956
- });
1309
+ registerTeamCommand(ctx, {
1310
+ commands,
1311
+ teamsFile,
1312
+ // 与系统提示段共用同一个真机探测(engineReady),两条可见面不会再次分叉。
1313
+ hasEngine: () => engineReady(),
1314
+ // 报给用户的原因用 engineUnreadyDetail():它区分「调度都没成功」与「调度成功但工具没注册」,
1315
+ // 后者在旧文案里会指向一条永远不存在的 [t-team] error 行(N-5)。
1316
+ engineError: () => engineUnreadyDetail(),
1317
+ });
1318
+ });
1319
+ }
957
1320
 
958
1321
  // ---- DAG 预检工具(只读):把整套拟建任务一次跑完引擎校验,避免"边建边撞"留下半成品 ----
959
1322
  ctx.tools.register(defineTool({
@@ -963,7 +1326,7 @@ export function apply(ctx, config) {
963
1326
  tasks: {
964
1327
  type: "array",
965
1328
  required: true,
966
- description: "Tasks you intend to create, in intended order. Each item accepts: subject (required), id (optional label so later items can reference it), kind, description, dependencies (task ids: existing ones or earlier items' labels), assignee, inScope, outOfScope, verify, acceptance, objective, deliverables, nonGoals, round, reviewedTaskId, sourceTaskId, sourceFindingIds.",
1329
+ description: "Tasks you intend to create, in intended order. Each item accepts: subject (required), id (optional label so later items can reference it), kind, description, dependencies, assignee, inScope, outOfScope, verify, acceptance, objective, deliverables, nonGoals, round, reviewedTaskId, sourceTaskId, sourceFindingIds. IMPORTANT: dependencies may only reference tasks that already exist — an earlier item's `id` label, or a task id returned by t_team_status. A reference to a LATER item fails both this pre-check and real creation, so order the batch so that dependencies come first.",
967
1330
  items: { type: "json" },
968
1331
  },
969
1332
  },
@@ -983,6 +1346,17 @@ export function apply(ctx, config) {
983
1346
  async execute(args, exec) {
984
1347
  const agent = exec?.agent;
985
1348
  if (agent === undefined) return { ok: false, problems: [{ index: -1, id: "agent", subject: "", error: "这个工具需要在会话里调用" }], warnings: [], order: [] };
1349
+ // 引擎就绪门控(C-1):本工具由 T专家 自己注册,**即使团队引擎挂载失败也照常存在**。
1350
+ // 不拦的话,引擎不在时会返回「先用 t_team_create 建队」——而那个工具此刻根本不在表里,
1351
+ // 等于把模型指向一条死路。这里与 `/t`、系统提示段用同一个真机探测,口径一致。
1352
+ if (!engineReady()) {
1353
+ return {
1354
+ ok: false,
1355
+ problems: [{ index: -1, id: "engine", subject: "", error: `当前会话没有可用的团队引擎,t_team_plan_check 无法预检:${engineUnreadyDetail()}` }],
1356
+ warnings: [],
1357
+ order: [],
1358
+ };
1359
+ }
986
1360
  const workspace = agent.session?.header?.cwd ?? process.cwd();
987
1361
  const stateRoot = join(workspace, engineStateDir);
988
1362
  let team;
@@ -1043,7 +1417,7 @@ export function apply(ctx, config) {
1043
1417
  if (context.agent?.session?.header?.parentSession !== undefined) return "";
1044
1418
  return [
1045
1419
  "## T专家 (T Expert) expert mode",
1046
- "The parent session has T专家 — a 315-expert, 22-division roster with full Chinese translations, exposed as summonable domain experts.",
1420
+ "The parent session has T专家 — a 316-expert, 22-division roster with full Chinese translations, exposed as summonable domain experts.",
1047
1421
  "Experts are individually enabled/disabled in the T专家 settings tab; ALL are disabled by default and a disabled expert cannot be summoned.",
1048
1422
  "A composer selection inserts one enabled expert as a native reference chip; the remaining draft text is that expert's task.",
1049
1423
  "In the parent session, call `list_t_experts()` for enabled division names and counts, then `list_t_experts(division)` to pick a unique expert name, then `summon_t_expert(expert, task)` — or `summon_t_experts` for a small parallel team (at most 8; partial results when some fail).",
@@ -1052,8 +1426,11 @@ export function apply(ctx, config) {
1052
1426
  },
1053
1427
  });
1054
1428
 
1055
- // ---- 运维 skill(名册 / 小队 / 装机 / 发布)----
1056
- // 只对"维护这个插件的人"有用,所以不占常驻提示段,改成一个按需加载的 skill
1429
+ // ---- 随包 skill(运维入口 + DeepSeek Harness 项目知识)----
1430
+ // 都只对特定任务有用,所以不占常驻提示段,改成按需加载的 skill
1431
+ // · t-expert-manager 维护这个插件的人:名册 / 小队 / 装机 / 发布
1432
+ // · dsh-harness-project 在 deepseek-harness 检出里读写代码:架构、启动模型、扩展点、门禁
1433
+ // · dsh-harness-languages 同一个仓库里的各语言面规则与工具链
1057
1434
  // 可选依赖:宿主没有 skill 注册表时静默跳过,不影响上面任何功能。
1058
- installOpsSkill(ctx, config);
1435
+ installBundledSkills(ctx, config);
1059
1436
  }