dsh-plugin-t-expert 0.2.6 → 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.
package/lib/command.js CHANGED
@@ -175,8 +175,11 @@ export function registerTeamCommand(ctx, options) {
175
175
  handler(invocation) {
176
176
  if (hasEngine() !== true) {
177
177
  const detail = typeof options.engineError === "function" ? options.engineError() : "";
178
+ // 中性表述:不指路日志。旧文案让用户去看 `[t-team]` error 行,但「引擎已调度、工具却没注册」
179
+ // 这条路径下那句话**永远不会打印**(lib/index.js 的 engineReady 探到假只会补 warn),
180
+ // 于是指引恒定失效(N-5)。
178
181
  const text = detail === ""
179
- ? "团队引擎未就绪:T专家 内置的团队引擎没有挂载成功(查看 DSH 日志里的 [t-team] 行)。"
182
+ ? "团队引擎未就绪:T专家 内置的团队引擎没有注册成功(它的工具没有出现在工具表里)。"
180
183
  : `团队引擎挂载失败:${detail}`;
181
184
  // 命令结果行在部分 DSH 版本不渲染,所以连错误也交给队长转述一次。
182
185
  invocation.agent.followup(createUserMessage({
package/lib/i18n.js CHANGED
@@ -20,6 +20,7 @@ const ZH = {
20
20
  "error.specsTooMany": "一次最多召唤 {limit} 位专家",
21
21
  "error.specInvalid": "experts[{index}] 需要 expert 与 task 两个字段",
22
22
  "error.settingsMissing": "T专家 设置段尚未注册",
23
+ "error.customUnknownSlug": "未知专家 slug:{slugs}(这些 slug 不在当前名册里;先用 list_t_experts 取准确的 slug)",
23
24
  "error.customSlugInvalid": "slug 只能用 a-z、0-9 与连字符,且以字母或数字开头:\"{slug}\"",
24
25
  "error.customSlugTaken": "slug「{slug}」已被内置或自建专家占用",
25
26
  "error.customDivisionInvalid": "分类目录名只能用 a-z、0-9、连字符与点,且以字母或数字开头(中文请填在「显示名」里):\"{division}\"",
@@ -65,6 +66,7 @@ const EN = {
65
66
  "error.specsTooMany": "At most {limit} experts per call",
66
67
  "error.specInvalid": "experts[{index}] requires both expert and task",
67
68
  "error.settingsMissing": "T Expert settings section is not registered yet",
69
+ "error.customUnknownSlug": "Unknown expert slug(s): {slugs} (not in the current roster; call list_t_experts for exact slugs)",
68
70
  "error.customSlugInvalid": "slug may only use a-z, 0-9 and hyphens, starting with a letter or digit: \"{slug}\"",
69
71
  "error.categoryOfficial": "\"{division}\" is an official division: its name and existence follow the built-in roster sync, so only custom divisions can be changed",
70
72
  "error.categoryExists": "Division \"{division}\" already exists",
@@ -128,8 +130,49 @@ export function localized(expert, locale) {
128
130
  };
129
131
  }
130
132
 
131
- /** list_t_experts 的文本渲染。 */
132
- export function renderList(locale, { query, groups, total, allDivisions }) {
133
+ /**
134
+ * 把执行期名册分组投影成渲染期就绪的形状。
135
+ *
136
+ * 为什么在这里做:`output.render(args, value)` 必须是 `(args, value)` 的纯函数
137
+ * ——同一个 canonical value 在会话日志里回放时必须渲染出同一段文本。而「用哪种语言」
138
+ * 是**执行期**才知道的事实(读宿主 locale 设置),所以它必须和分组一起进 canonical value,
139
+ * 不能在 render 里再读一次宿主。
140
+ *
141
+ * @param groups - `groupByDivision()` 的分组(带 label/labelEn 与 expert 条目)。
142
+ * @param locale - 执行期语言("zh" | "en")。
143
+ * @returns 只含渲染所需字段的分组数组。
144
+ */
145
+ export function toRenderableGroups(groups, locale) {
146
+ return (Array.isArray(groups) ? groups : []).map((group) => ({
147
+ division: group.division,
148
+ label: locale === "en" ? (group.labelEn ?? group.label ?? group.division) : (group.label ?? group.division),
149
+ count: group.count ?? (group.experts ?? []).length,
150
+ experts: (group.experts ?? []).map((expert) => {
151
+ const { name, description } = localized(expert, locale);
152
+ return {
153
+ slug: expert.slug,
154
+ emoji: expert.emoji ?? "",
155
+ // 渲染期不再读侧车译文:名字与简介在执行期就按 locale 定好。
156
+ name,
157
+ description,
158
+ };
159
+ }),
160
+ }));
161
+ }
162
+
163
+ /**
164
+ * list_t_experts 的文本渲染(纯函数)。
165
+ *
166
+ * 语言只从 `value.locale` 取,不读宿主设置:同一个 `value` 永远渲染出同一段文本。
167
+ * @param args - 工具入参(只用来取 division 过滤词)。
168
+ * @param value - `list_t_experts.execute` 返回的 canonical value。
169
+ */
170
+ export function renderList(_args, value) {
171
+ const query = typeof value.query === "string" ? value.query : "";
172
+ const locale = value.locale === "en" ? "en" : "zh";
173
+ const groups = Array.isArray(value.divisions) ? value.divisions : [];
174
+ const total = typeof value.total === "number" ? value.total : 0;
175
+ const allDivisions = Array.isArray(value.allDivisions) ? value.allDivisions : [];
133
176
  if (query !== "" && groups.length === 0) {
134
177
  return t(locale, "list.unknownDivision", { query, divisions: allDivisions.join(", ") });
135
178
  }
@@ -146,23 +189,44 @@ export function renderList(locale, { query, groups, total, allDivisions }) {
146
189
  } else {
147
190
  const group = groups[0];
148
191
  lines.push(t(locale, "list.headerOne", { division: `${group.label} (${group.division})`, count: group.count }));
149
- for (const expert of group.experts) {
150
- const { name, description } = localized(expert, locale);
192
+ for (const expert of group.experts ?? []) {
151
193
  // 带上 slug:模型可以直接拿它当召唤参数,避免只靠名字解析
152
- lines.push(`- ${expert.emoji} ${name} (${expert.slug}) — ${description}`);
194
+ lines.push(`- ${expert.emoji} ${expert.name} (${expert.slug}) — ${expert.description}`);
153
195
  }
154
196
  }
155
197
  return lines.join("\n");
156
198
  }
157
199
 
158
- /** summon_experts 的文本渲染。 */
159
- export function renderSummonResults(locale, results) {
160
- const ok = results.filter((r) => r.ok).length;
200
+ /**
201
+ * 把一批召唤结果投影成渲染期就绪的形状。
202
+ * @param results - `summon_t_experts` 的结果项;`expert` 已是**执行期按 locale 定好的显示名**
203
+ * (`summon_t_experts.execute` 负责解析,render 不再碰名册)。
204
+ * @param locale - 执行期语言(写进 value,供 render 取文案)。
205
+ */
206
+ export function toRenderableSummonResults(results, locale) {
207
+ return (Array.isArray(results) ? results : []).map((item) => ({
208
+ expert: typeof item?.expert === "string" ? item.expert : "",
209
+ ok: item?.ok === true,
210
+ answer: item?.answer ?? "",
211
+ error: item?.error ?? "",
212
+ }));
213
+ }
214
+
215
+ /**
216
+ * summon_t_experts 的文本渲染(纯函数)。
217
+ * 语言只从 `value.locale` 取,专家名来自 `value.results[].expert`(执行期已本地化)。
218
+ * @param args - 工具入参(未使用)。
219
+ * @param value - `summon_t_experts.execute` 返回的 canonical value。
220
+ */
221
+ export function renderSummonResults(_args, value) {
222
+ const locale = value.locale === "en" ? "en" : "zh";
223
+ const results = Array.isArray(value.results) ? value.results : [];
224
+ const ok = results.filter((item) => item.ok === true).length;
161
225
  const lines = [t(locale, "summon.batchHeader", { total: results.length, ok, failed: results.length - ok })];
162
226
  for (const item of results) {
163
- lines.push(item.ok
164
- ? t(locale, "summon.itemOk", { expert: item.expert, answer: item.answer })
165
- : t(locale, "summon.itemFail", { expert: item.expert, error: item.error ?? "" }));
227
+ lines.push(item.ok === true
228
+ ? t(locale, "summon.itemOk", { expert: item.expert ?? "", answer: item.answer ?? "" })
229
+ : t(locale, "summon.itemFail", { expert: item.expert ?? "", error: item.error ?? "" }));
166
230
  }
167
231
  return lines.join("\n\n");
168
232
  }
package/lib/index.js CHANGED
@@ -38,8 +38,9 @@ import {
38
38
  truncate,
39
39
  writeFileAtomic,
40
40
  } from "./catalog.js";
41
- import { localized, NS, readLocale, renderList, renderSummonResults, t } from "./i18n.js";
41
+ import { localized, NS, readLocale, renderList, renderSummonResults, t, toRenderableGroups, toRenderableSummonResults } from "./i18n.js";
42
42
  import { seedData } from "./bootstrap.js";
43
+ import { installBundledSkills } from "./skill.js";
43
44
  import { registerTeamCommand } from "./command.js";
44
45
  import { createSquadService } from "./squads.js";
45
46
  import { SNAPSHOT_DIR } from "./bootstrap.js";
@@ -110,6 +111,15 @@ export const TEAM_SERVICE = "tTeamTeams";
110
111
  /** 设置命名空间(= 插件名)。 */
111
112
  export const SETTINGS_NAMESPACE = NS;
112
113
 
114
+ /**
115
+ * 「未知专家 slug」的稳定错误码(与 `lib/remote.js` 共用)。
116
+ *
117
+ * 为什么要有它:remote 侧过去靠 `message.includes("未知专家")` 判断这是不是「未知专家」,
118
+ * 而 message 是**显示文案**(随 locale 变,也会被改写)——用它做错误身份,等于把分类建在
119
+ * 会漂移的字符串上。这里给出稳定码,remote 只认码(D-3)。
120
+ */
121
+ export const UNKNOWN_SLUG_ERROR_CODE = "tTeam/unknown-slug";
122
+
113
123
 
114
124
  const settingsSchema = schema.object({
115
125
  enabled: schema.array(schema.string()).default([]),
@@ -160,6 +170,12 @@ export function apply(ctx, config) {
160
170
  let loading = null;
161
171
  let stamp = "";
162
172
  let loadError = null;
173
+ /** 中文侧车目录是否存在(D-5:缺了要能被面板/日志看见,而不是静默变英文名册)。 */
174
+ let sidecarPresent = true;
175
+ /** 加载期被跳过的专家文件数(缺 frontmatter / 读不到 / 冲突,见 lib/catalog.js)。 */
176
+ let skippedFiles = 0;
177
+ /** 加载期读不动的分区目录(该分区专家整批缺席,D-18)。 */
178
+ let unreadableDivisions = [];
163
179
 
164
180
  /**
165
181
  * 名册指纹:source.json、各分区目录、以及中文侧车文件的 mtime。
@@ -200,8 +216,13 @@ export function apply(ctx, config) {
200
216
  const catalog = await loadCatalog(config.root, config.divisions, {
201
217
  zhRoot: config.zhRoot,
202
218
  customRoot: config.customRoot,
219
+ // 名册加载的诊断必须走宿主日志通道,而不是 console(N-3):桌面/Web 里 stderr 用户看不到。
220
+ logger: ctx.logger,
203
221
  });
204
222
  entries = [...catalog.values()];
223
+ sidecarPresent = catalog.sidecarPresent !== false;
224
+ skippedFiles = typeof catalog.skippedFiles === "number" ? catalog.skippedFiles : 0;
225
+ unreadableDivisions = Array.isArray(catalog.unreadableDivisions) ? catalog.unreadableDivisions : [];
205
226
  const discovered = catalogDivisions(catalog);
206
227
  if (catalog.customDivisions !== undefined) customDivisions = catalog.customDivisions;
207
228
  if (catalog.rosterDivisions !== undefined) rosterDivisions = catalog.rosterDivisions;
@@ -232,6 +253,22 @@ export function apply(ctx, config) {
232
253
  /** 可召唤/可展示的专家(排除重名冲突项)。 */
233
254
  const usable = () => entries.filter((expert) => expert.conflict !== true);
234
255
 
256
+ /**
257
+ * 把一次召唤目标(中文名 / 英文名 / slug 都行)解析成**该语言下的显示名**。
258
+ * 解析不到就原样返回:失败项也要让模型看清它请求的是谁。
259
+ * @param query - 模型给的召唤目标。
260
+ * @param current - 执行期语言。
261
+ */
262
+ function displayNameOf(query, current) {
263
+ const text = String(query ?? "").trim();
264
+ if (text === "") return "";
265
+ try {
266
+ return localized(resolveExpert(usable(), text, current), current).name;
267
+ } catch {
268
+ return text;
269
+ }
270
+ }
271
+
235
272
  // ---- 设置段修订号与写入 ----
236
273
  const revision = () => {
237
274
  const descriptor = ctx.settings.describe().find((item) => item.ns === SETTINGS_NAMESPACE);
@@ -242,8 +279,7 @@ export function apply(ctx, config) {
242
279
  /** 客户端面板用的名册快照。 */
243
280
  async function snapshot() {
244
281
  await ensureReady();
245
- const enabled = enabledSet();
246
- const experts = await Promise.all(usable().map(async (expert) => ({
282
+ const enabled = enabledSet(); const experts = await Promise.all(usable().map(async (expert) => ({
247
283
  // 面板按 locale 取字段:name/description 为中文(缺译回退英文),nameEn/descriptionEn 恒为英文。
248
284
  slug: expert.slug,
249
285
  name: expert.nameZh ?? expert.name,
@@ -263,6 +299,14 @@ export function apply(ctx, config) {
263
299
  experts,
264
300
  enabled: [...enabled],
265
301
  revision: revision(),
302
+ // 名册健康状态:面板可以据此提示「中文侧车没找到 / 有 N 个专家文件被跳过」,
303
+ // 而不是让用户对着一个静默变全英文的名册猜。
304
+ sidecar: {
305
+ zhRoot: config.zhRoot,
306
+ present: sidecarPresent,
307
+ skippedFiles,
308
+ unreadableDivisions,
309
+ },
266
310
  // 自建专家固定落在这个分区;连同中文标签一起给面板,省得两端各写一份常量。
267
311
  customDivision: CUSTOM_DIVISION,
268
312
  customDivisionLabel: divisionOf(CUSTOM_DIVISION, divisionLabels).zh,
@@ -516,7 +560,15 @@ export function apply(ctx, config) {
516
560
  const list = (Array.isArray(next) ? next : []).filter((item) => typeof item === "string");
517
561
  const known = new Set(usable().map((expert) => expert.slug));
518
562
  const unknown = list.filter((slug) => !known.has(slug));
519
- if (unknown.length > 0) throw new Error(`未知专家 slug:${unknown.slice(0, 5).join(", ")}`);
563
+ if (unknown.length > 0) {
564
+ // 稳定 code(不是显示文案):remote 侧按它分类成 tTeam/unknown-expert,与 locale 无关(D-3)。
565
+ // 文案走正确的 t() 通道(过去这里也是硬编码中文,非中文 locale 下会中英混排)。
566
+ const slugs = unknown.slice(0, 5);
567
+ const error = new Error(t(locale(), "error.customUnknownSlug", { slugs: slugs.join(", ") }));
568
+ error.code = UNKNOWN_SLUG_ERROR_CODE;
569
+ error.details = { slugs };
570
+ throw error;
571
+ }
520
572
  await ctx.settings.mutate(SETTINGS_NAMESPACE, [{ op: "set", path: ["enabled"], value: list }], expectedRevision);
521
573
  return { enabled: [...enabledSet()], revision: revision() };
522
574
  }
@@ -627,57 +679,50 @@ export function apply(ctx, config) {
627
679
  properties: {
628
680
  divisions: { type: "array", required: true, items: { type: "json" } },
629
681
  total: { type: "number", required: true },
682
+ // 渲染期事实:语言与过滤词进 canonical value,render 才能是纯函数(D-11)。
683
+ locale: { type: "string", required: true },
684
+ query: { type: "string", required: true },
685
+ allDivisions: { type: "array", required: true, items: { type: "string" } },
630
686
  },
631
687
  },
632
- render: (args, value) => [{
633
- type: "text",
634
- text: renderList(locale(), {
635
- query: args.division === undefined ? "" : String(args.division).trim(),
636
- groups: value.divisions,
637
- total: value.total,
638
- allDivisions: effectiveDivisions,
639
- }),
640
- }],
688
+ // render 只读 (args, value):同一个 value 在日志里回放时渲染出同一段文本。
689
+ render: (args, value) => [{ type: "text", text: renderList(args, value) }],
641
690
  },
642
691
  async execute(args) {
643
692
  await ensureReady();
693
+ const current = locale();
644
694
  const query = args.division === undefined ? "" : String(args.division).trim();
645
695
  const enabled = enabledSet();
646
696
  const groups = groupByDivision(usable(), enabled);
697
+ // 简介截断是**执行期**的可配项(Config.descriptionLimit),所以在投影时一次做完,
698
+ // render 拿到的就是最终文本(纯函数,不再读 config)。
699
+ const projection = (list) => toRenderableGroups(list, current).map((group) => ({
700
+ ...group,
701
+ experts: group.experts.map((expert) => ({
702
+ ...expert,
703
+ description: truncate(expert.description, config.descriptionLimit),
704
+ })),
705
+ }));
647
706
  if (query === "") {
648
707
  return {
649
- divisions: groups.map((group) => ({
650
- division: group.division,
651
- label: group.label,
652
- labelEn: group.labelEn,
653
- count: group.count,
654
- })),
708
+ divisions: projection(groups),
655
709
  total: enabled.size,
710
+ locale: current,
711
+ query,
712
+ allDivisions: effectiveDivisions.slice(),
656
713
  };
657
714
  }
658
715
  const needle = query.toLowerCase();
659
716
  const matched = groups
660
717
  .filter((group) => group.division.toLowerCase() === needle
661
718
  || group.label.toLowerCase() === needle
662
- || group.labelEn.toLowerCase() === needle)
663
- .map((group) => ({
664
- division: group.division,
665
- label: group.label,
666
- labelEn: group.labelEn,
667
- count: group.count,
668
- experts: group.experts.map((expert) => {
669
- const { name, description } = localized(expert, locale());
670
- return {
671
- slug: expert.slug,
672
- name,
673
- emoji: expert.emoji,
674
- description: truncate(description, config.descriptionLimit),
675
- };
676
- }),
677
- }));
719
+ || group.labelEn.toLowerCase() === needle);
678
720
  return {
679
- divisions: matched,
721
+ divisions: projection(matched),
680
722
  total: matched.reduce((sum, group) => sum + group.count, 0),
723
+ locale: current,
724
+ query,
725
+ allDivisions: effectiveDivisions.slice(),
681
726
  };
682
727
  },
683
728
  }));
@@ -728,12 +773,13 @@ export function apply(ctx, config) {
728
773
  schema: {
729
774
  type: "object",
730
775
  additionalProperties: false,
731
- properties: { results: { type: "array", required: true, items: { type: "json" } } },
776
+ properties: {
777
+ results: { type: "array", required: true, items: { type: "json" } },
778
+ // 渲染期事实(D-11):语言进 canonical value,render 才不需要读宿主。
779
+ locale: { type: "string", required: true },
780
+ },
732
781
  },
733
- render: (_args, value) => [{
734
- type: "text",
735
- text: renderSummonResults(locale(), value.results),
736
- }],
782
+ render: (args, value) => [{ type: "text", text: renderSummonResults(args, value) }],
737
783
  },
738
784
  async execute(args, exec) {
739
785
  await ensureReady();
@@ -754,14 +800,19 @@ export function apply(ctx, config) {
754
800
  return { expert: result.expert, ok: true, answer: result.answer };
755
801
  } catch (error) {
756
802
  return {
757
- expert: spec.expert,
803
+ // 失败项也要在**执行期**定好显示名:否则 render 得自己去解析名册才说得清是谁失败了
804
+ // (D-11:render 必须是 (args,value) 的纯函数,不能读名册/宿主 locale)。
805
+ expert: displayNameOf(spec.expert, current),
758
806
  ok: false,
759
807
  answer: "",
760
808
  error: error instanceof Error ? error.message : String(error),
761
809
  };
762
810
  }
763
811
  });
764
- return { results };
812
+ return {
813
+ results: toRenderableSummonResults(results, current),
814
+ locale: current,
815
+ };
765
816
  },
766
817
  }));
767
818
 
@@ -800,6 +851,41 @@ export function apply(ctx, config) {
800
851
  // T专家 的专家名册、@ 召唤、/t 列表仍然可用,只是团队功能不可用。
801
852
  // 这是**已声明的降级**,不是静默跳过:原因会写进日志、经 /t 报出,并进系统提示段。
802
853
  const engineState = { ok: false, detail: engineConfigDetail };
854
+ /** 首次探到「调度成功但工具没注册」时的告警只打一次(此后每轮都探,但不再刷日志)。 */
855
+ let engineNotReadyWarned = false;
856
+ const NOT_READY_DETAIL = "内置团队引擎的工具没有注册成功(引擎可能还在等待它声明的宿主服务)";
857
+ /**
858
+ * 引擎是否**真的**就绪 —— 必须真机探测,不能只看 `ctx.plugin()` 有没有抛错。
859
+ *
860
+ * cordis 的 `ctx.plugin()` 只**构造并调度** fiber,不等子插件 `apply` 跑完;而引擎声明的
861
+ * `inject`(lib/teams/index.js:39 的 tools/llm/subagents/systemPrompt/agents)只要缺一个,
862
+ * cordis 就会**静默挂起它的 fiber**:不抛错、不告警,13 个 t_team_* 工具一个都不会注册。
863
+ * 所以唯一可靠的判据是「工具真在注册表里」:`engineState.ok` 只说明调度成功,
864
+ * `ctx.tools.get("t_team_create")` 才说明引擎跑起来了。
865
+ *
866
+ * **探测时机很关键**:宿主里子插件的 fiber 可能在同一同步段末尾、也可能更晚才激活,
867
+ * 所以**不在 mount 的那一刻探**(那一刻必然探空,会把每次正常启动都误报成降级)。
868
+ * 探测发生在所有真实读取点(系统提示段 / `/t` 命令),那时引擎早已定局;且每一轮都重新探,
869
+ * 只在**第一次**探到假时补一条 warn,避免刷日志。两处可见面共用它,不会再分叉。
870
+ * @returns 引擎工具是否已注册。
871
+ */
872
+ const engineReady = () => {
873
+ if (engineState.ok !== true) return false;
874
+ let present = false;
875
+ try {
876
+ present = ctx.tools.get("t_team_create") !== undefined;
877
+ } catch {
878
+ present = false;
879
+ }
880
+ if (!present && !engineNotReadyWarned) {
881
+ engineNotReadyWarned = true;
882
+ // 「工具没注册」这件事必须在日志里留下一条:否则提示段、/t、日志三处都没有信号。
883
+ ctx.logger?.warn?.(`[t-team] 团队引擎已调度但工具未注册(引擎声明的 inject 服务可能不齐):${NOT_READY_DETAIL}`);
884
+ }
885
+ return present;
886
+ };
887
+ /** 给用户/模型的原因:挂载明明抛错就报那个错,否则报「工具没注册」。 */
888
+ const engineUnreadyDetail = () => (engineState.detail === "" ? NOT_READY_DETAIL : engineState.detail);
803
889
  if (engineConfigDetail === "") {
804
890
  try {
805
891
  ctx.plugin(teamsEngine, {
@@ -815,6 +901,9 @@ export function apply(ctx, config) {
815
901
  });
816
902
  // 复用引擎导出的手势边界:把 `/t --profile <小队> <目标>` 这条用户消息翻成队长协议指令。
817
903
  installTTeamGestureBoundary(ctx, () => engineConfig.profiles ?? {});
904
+ // 只记录「调度成功」。**不要**在这里同步探测工具是否已注册:cordis 的子插件 fiber
905
+ // 尚未激活,此刻必然探空 → 会把每一次正常启动都误报成降级(本改动实测踩到过)。
906
+ // 真机探测推迟到读取点(engineReady())。
818
907
  engineState.ok = true;
819
908
  } catch (error) {
820
909
  engineState.detail = error instanceof Error ? error.message : String(error);
@@ -826,15 +915,16 @@ export function apply(ctx, config) {
826
915
 
827
916
  // 已声明的降级也要**对模型可见**:否则"团队功能没了"这件事只留在日志里,
828
917
  // 用户问起来模型只能说不知道 —— 那正是规范禁止的"静默跳过缺失的引用对象"。
918
+ // 门槛用 engineReady()(真机探测)而不是 engineState.ok:两者在「引擎 fiber 被挂起」时不同。
829
919
  ctx.systemPrompt.section({
830
920
  name: "t-team:engine-status",
831
921
  order: 120,
832
922
  text: (context) => {
833
923
  if (context.agent?.session?.header?.parentSession !== undefined) return "";
834
- if (engineState.ok === true) return "";
924
+ if (engineReady()) return "";
835
925
  return [
836
926
  "## T专家 团队引擎当前不可用",
837
- `团队功能(/t 建队、t_team_* 工具)现在不可用。原因:${engineState.detail}`,
927
+ `团队功能(/t 建队、t_team_* 工具)现在不可用。原因:${engineUnreadyDetail()}`,
838
928
  "专家名册、@ 召唤与 summon_t_expert 不受影响。用户需要团队功能时,请原样转述上面的原因,不要假装能建队。",
839
929
  ].join("\n");
840
930
  },
@@ -849,11 +939,19 @@ export function apply(ctx, config) {
849
939
  generator: existsSync(join(SNAPSHOT_DIR, "team-profiles.py"))
850
940
  ? join(SNAPSHOT_DIR, "team-profiles.py")
851
941
  : undefined,
942
+ // 成员上限**只有一个真源**:Config.maxMembers。它同时喂给引擎(成员上限)与小队编译器
943
+ // (--max-members)。过去编译器把上限写死 8、这里又没透传,于是把 maxMembers 改大后保存小队
944
+ // 必然编译失败并回滚(D-14)。编译器不认这个参数时由 squads.js 记 warn 并退回它内置默认值。
945
+ maxMembers: config.maxMembers,
852
946
  });
853
947
  ctx.reflect.provide(SQUAD_SERVICE, squads);
854
948
 
855
949
  // ---- 团队运行服务(设置页「团队」标签用;复用引擎的快照与停止实现)----
856
- const engineStateDir = typeof engineConfig.stateDir === "string" ? engineConfig.stateDir : ".agent-teams";
950
+ // 状态目录**只有一个来源**:`config.stateDir`(同时也是挂给引擎的那个值,见上面的 ctx.plugin)。
951
+ // 曾经这里读的是数据文件 `t-team.config.json` 的 stateDir(`agent-teams.config.json` 时代的遗留),
952
+ // 于是「引擎挂到 config.stateDir、面板与 t_team_plan_check 却去数据文件那个目录找」——
953
+ // 改开 stateDir 的部署会两边失明且没有任何报错(D-1)。
954
+ const engineStateDir = config.stateDir;
857
955
  const teamRoots = () => {
858
956
  const registry = ctx.get("workspaceRegistry") ?? ctx.get("workspace");
859
957
  const list = typeof registry?.list === "function" ? registry.list() : [];
@@ -920,11 +1018,17 @@ export function apply(ctx, config) {
920
1018
  },
921
1019
  async stop(teamId) {
922
1020
  const roots = teamRoots();
1021
+ // 逐 root 读取失败时**留住根因**:过去 `catch { continue; }` 把 EACCES 之类换成
1022
+ // 下面那句「没找到团队 …(它可能已被归档)」,用户按提示去查归档永远查不到(N-4)。
1023
+ const failures = [];
923
1024
  for (const root of roots) {
924
1025
  let snapshots;
925
1026
  try {
926
1027
  snapshots = await collectTeamsActivity(ctx, [root]);
927
- } catch {
1028
+ } catch (error) {
1029
+ const reason = error instanceof Error ? `${error.message}` : String(error);
1030
+ failures.push(`${root.stateRoot}:${reason}`);
1031
+ ctx.logger?.warn?.(`[t-team] 读取团队活动失败:${root.stateRoot}(${reason})`);
928
1032
  continue;
929
1033
  }
930
1034
  const team = snapshots.find((item) => item.teamId === teamId);
@@ -936,22 +1040,21 @@ export function apply(ctx, config) {
936
1040
  const result = await haltTeamWork({ ctx, stateRoot: root.stateRoot, teamId, captain });
937
1041
  return { teamName: result.teamName, cancelledTasks: result.cancelledTasks, alreadyHalted: result.alreadyHalted === true };
938
1042
  }
939
- throw new Error(`没找到团队 ${teamId} 的状态目录(它可能已被归档)。`);
1043
+ // 读不到任何 root 时不要用「可能已归档」这种猜测掩盖根因:把真实失败原因一并报出来。
1044
+ throw new Error(failures.length > 0
1045
+ ? `没找到团队 ${teamId}:${roots.length} 个工作区里${failures.length} 个读取失败(${failures.join(";")})。`
1046
+ : `没找到团队 ${teamId} 的状态目录(它可能已被归档)。`);
940
1047
  },
941
1048
  });
942
1049
 
943
1050
  // ---- `/t` 小队短命令(把 /t <小队> <目标> 转成引擎认得的 /t --profile <key> <目标> 用户消息)----
944
1051
  registerTeamCommand(ctx, {
945
1052
  teamsFile,
946
- hasEngine: () => {
947
- if (engineState.ok !== true) return false;
948
- try {
949
- return ctx.tools.get("t_team_create") !== undefined;
950
- } catch {
951
- return false;
952
- }
953
- },
954
- engineError: () => engineState.detail,
1053
+ // 与系统提示段共用同一个真机探测(engineReady),两条可见面不会再次分叉。
1054
+ hasEngine: () => engineReady(),
1055
+ // 报给用户的原因用 engineUnreadyDetail():它区分「调度都没成功」与「调度成功但工具没注册」,
1056
+ // 后者在旧文案里会指向一条永远不存在的 [t-team] error 行(N-5)。
1057
+ engineError: () => engineUnreadyDetail(),
955
1058
  });
956
1059
 
957
1060
  // ---- DAG 预检工具(只读):把整套拟建任务一次跑完引擎校验,避免"边建边撞"留下半成品 ----
@@ -1042,7 +1145,7 @@ export function apply(ctx, config) {
1042
1145
  if (context.agent?.session?.header?.parentSession !== undefined) return "";
1043
1146
  return [
1044
1147
  "## T专家 (T Expert) expert mode",
1045
- "The parent session has T专家 — a 314-expert, 22-division roster with full Chinese translations, exposed as summonable domain experts.",
1148
+ "The parent session has T专家 — a 316-expert, 22-division roster with full Chinese translations, exposed as summonable domain experts.",
1046
1149
  "Experts are individually enabled/disabled in the T专家 settings tab; ALL are disabled by default and a disabled expert cannot be summoned.",
1047
1150
  "A composer selection inserts one enabled expert as a native reference chip; the remaining draft text is that expert's task.",
1048
1151
  "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).",
@@ -1050,4 +1153,12 @@ export function apply(ctx, config) {
1050
1153
  ].join("\n");
1051
1154
  },
1052
1155
  });
1156
+
1157
+ // ---- 随包 skill(运维入口 + DeepSeek Harness 项目知识)----
1158
+ // 都只对特定任务有用,所以不占常驻提示段,改成按需加载的 skill:
1159
+ // · t-expert-manager 维护这个插件的人:名册 / 小队 / 装机 / 发布
1160
+ // · dsh-harness-project 在 deepseek-harness 检出里读写代码:架构、启动模型、扩展点、门禁
1161
+ // · dsh-harness-languages 同一个仓库里的各语言面规则与工具链
1162
+ // 可选依赖:宿主没有 skill 注册表时静默跳过,不影响上面任何功能。
1163
+ installBundledSkills(ctx, config);
1053
1164
  }
package/lib/plan-check.js CHANGED
@@ -7,6 +7,7 @@
7
7
  * `validateStagedGraph`(未导出)里做的引用与环检查,全部在内存里模拟、不写任何状态。
8
8
  */
9
9
  import { CAPTAIN_KEY, readTeam } from "./teams/state.js";
10
+ import { normalizeAssigneeForCreate } from "./teams/assignee-contract.js";
10
11
  import { validateCreateTask } from "./teams/quality-gates.js";
11
12
 
12
13
  /** 与引擎 `validateStagedGraph` 对齐的引用/环检查(该函数未导出,这里按同规则复刻)。 */
@@ -104,9 +105,19 @@ export function checkPlan(team, tasks) {
104
105
  (dependency) => !taken.has(dependency) && !existing.some((task) => task.id === dependency),
105
106
  );
106
107
  if (unknown.length > 0) local.push(`依赖不存在的任务:${unknown.join(", ")}`);
107
- const assignee = typeof raw?.assignee === "string" && raw.assignee.trim() !== "" ? raw.assignee.trim() : undefined;
108
- if (assignee !== undefined && assignee !== CAPTAIN_KEY && !memberNames.has(assignee)) {
109
- local.push(`assignee「${assignee}」不是当前成员(用 t_team_status 看成员名,或先 add_member)`);
108
+ const rawAssignee = typeof raw?.assignee === "string" && raw.assignee.trim() !== "" ? raw.assignee.trim() : undefined;
109
+ let assignee = rawAssignee;
110
+ if (rawAssignee !== undefined) {
111
+ // 预检必须与引擎的 create_task **同规**:captain 不能在这里被"通过",
112
+ // 否则预检说 ok、真建任务却被拒 —— 而预检的全部意义就是提前发现这种岔路。
113
+ try {
114
+ assignee = normalizeAssigneeForCreate(rawAssignee) ?? rawAssignee;
115
+ } catch (error) {
116
+ local.push(error instanceof Error ? error.message : String(error));
117
+ }
118
+ if (local.length === 0 && assignee !== undefined && !memberNames.has(assignee)) {
119
+ local.push(`assignee「${assignee}」不是当前成员(用 t_team_status 看成员名,或先 add_member)`);
120
+ }
110
121
  }
111
122
 
112
123
  // 引擎的契约/写域/质量门槛校验