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.
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,9 +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 { installOpsSkill } from "./skill.js";
43
+ import { installBundledSkills } from "./skill.js";
44
44
  import { registerTeamCommand } from "./command.js";
45
45
  import { createSquadService } from "./squads.js";
46
46
  import { SNAPSHOT_DIR } from "./bootstrap.js";
@@ -111,6 +111,15 @@ export const TEAM_SERVICE = "tTeamTeams";
111
111
  /** 设置命名空间(= 插件名)。 */
112
112
  export const SETTINGS_NAMESPACE = NS;
113
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
+
114
123
 
115
124
  const settingsSchema = schema.object({
116
125
  enabled: schema.array(schema.string()).default([]),
@@ -161,6 +170,12 @@ export function apply(ctx, config) {
161
170
  let loading = null;
162
171
  let stamp = "";
163
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 = [];
164
179
 
165
180
  /**
166
181
  * 名册指纹:source.json、各分区目录、以及中文侧车文件的 mtime。
@@ -201,8 +216,13 @@ export function apply(ctx, config) {
201
216
  const catalog = await loadCatalog(config.root, config.divisions, {
202
217
  zhRoot: config.zhRoot,
203
218
  customRoot: config.customRoot,
219
+ // 名册加载的诊断必须走宿主日志通道,而不是 console(N-3):桌面/Web 里 stderr 用户看不到。
220
+ logger: ctx.logger,
204
221
  });
205
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 : [];
206
226
  const discovered = catalogDivisions(catalog);
207
227
  if (catalog.customDivisions !== undefined) customDivisions = catalog.customDivisions;
208
228
  if (catalog.rosterDivisions !== undefined) rosterDivisions = catalog.rosterDivisions;
@@ -233,6 +253,22 @@ export function apply(ctx, config) {
233
253
  /** 可召唤/可展示的专家(排除重名冲突项)。 */
234
254
  const usable = () => entries.filter((expert) => expert.conflict !== true);
235
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
+
236
272
  // ---- 设置段修订号与写入 ----
237
273
  const revision = () => {
238
274
  const descriptor = ctx.settings.describe().find((item) => item.ns === SETTINGS_NAMESPACE);
@@ -243,8 +279,7 @@ export function apply(ctx, config) {
243
279
  /** 客户端面板用的名册快照。 */
244
280
  async function snapshot() {
245
281
  await ensureReady();
246
- const enabled = enabledSet();
247
- const experts = await Promise.all(usable().map(async (expert) => ({
282
+ const enabled = enabledSet(); const experts = await Promise.all(usable().map(async (expert) => ({
248
283
  // 面板按 locale 取字段:name/description 为中文(缺译回退英文),nameEn/descriptionEn 恒为英文。
249
284
  slug: expert.slug,
250
285
  name: expert.nameZh ?? expert.name,
@@ -264,6 +299,14 @@ export function apply(ctx, config) {
264
299
  experts,
265
300
  enabled: [...enabled],
266
301
  revision: revision(),
302
+ // 名册健康状态:面板可以据此提示「中文侧车没找到 / 有 N 个专家文件被跳过」,
303
+ // 而不是让用户对着一个静默变全英文的名册猜。
304
+ sidecar: {
305
+ zhRoot: config.zhRoot,
306
+ present: sidecarPresent,
307
+ skippedFiles,
308
+ unreadableDivisions,
309
+ },
267
310
  // 自建专家固定落在这个分区;连同中文标签一起给面板,省得两端各写一份常量。
268
311
  customDivision: CUSTOM_DIVISION,
269
312
  customDivisionLabel: divisionOf(CUSTOM_DIVISION, divisionLabels).zh,
@@ -517,7 +560,15 @@ export function apply(ctx, config) {
517
560
  const list = (Array.isArray(next) ? next : []).filter((item) => typeof item === "string");
518
561
  const known = new Set(usable().map((expert) => expert.slug));
519
562
  const unknown = list.filter((slug) => !known.has(slug));
520
- 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
+ }
521
572
  await ctx.settings.mutate(SETTINGS_NAMESPACE, [{ op: "set", path: ["enabled"], value: list }], expectedRevision);
522
573
  return { enabled: [...enabledSet()], revision: revision() };
523
574
  }
@@ -628,57 +679,50 @@ export function apply(ctx, config) {
628
679
  properties: {
629
680
  divisions: { type: "array", required: true, items: { type: "json" } },
630
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" } },
631
686
  },
632
687
  },
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
- }],
688
+ // render 只读 (args, value):同一个 value 在日志里回放时渲染出同一段文本。
689
+ render: (args, value) => [{ type: "text", text: renderList(args, value) }],
642
690
  },
643
691
  async execute(args) {
644
692
  await ensureReady();
693
+ const current = locale();
645
694
  const query = args.division === undefined ? "" : String(args.division).trim();
646
695
  const enabled = enabledSet();
647
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
+ }));
648
706
  if (query === "") {
649
707
  return {
650
- divisions: groups.map((group) => ({
651
- division: group.division,
652
- label: group.label,
653
- labelEn: group.labelEn,
654
- count: group.count,
655
- })),
708
+ divisions: projection(groups),
656
709
  total: enabled.size,
710
+ locale: current,
711
+ query,
712
+ allDivisions: effectiveDivisions.slice(),
657
713
  };
658
714
  }
659
715
  const needle = query.toLowerCase();
660
716
  const matched = groups
661
717
  .filter((group) => group.division.toLowerCase() === needle
662
718
  || 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
- }));
719
+ || group.labelEn.toLowerCase() === needle);
679
720
  return {
680
- divisions: matched,
721
+ divisions: projection(matched),
681
722
  total: matched.reduce((sum, group) => sum + group.count, 0),
723
+ locale: current,
724
+ query,
725
+ allDivisions: effectiveDivisions.slice(),
682
726
  };
683
727
  },
684
728
  }));
@@ -729,12 +773,13 @@ export function apply(ctx, config) {
729
773
  schema: {
730
774
  type: "object",
731
775
  additionalProperties: false,
732
- 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
+ },
733
781
  },
734
- render: (_args, value) => [{
735
- type: "text",
736
- text: renderSummonResults(locale(), value.results),
737
- }],
782
+ render: (args, value) => [{ type: "text", text: renderSummonResults(args, value) }],
738
783
  },
739
784
  async execute(args, exec) {
740
785
  await ensureReady();
@@ -755,14 +800,19 @@ export function apply(ctx, config) {
755
800
  return { expert: result.expert, ok: true, answer: result.answer };
756
801
  } catch (error) {
757
802
  return {
758
- expert: spec.expert,
803
+ // 失败项也要在**执行期**定好显示名:否则 render 得自己去解析名册才说得清是谁失败了
804
+ // (D-11:render 必须是 (args,value) 的纯函数,不能读名册/宿主 locale)。
805
+ expert: displayNameOf(spec.expert, current),
759
806
  ok: false,
760
807
  answer: "",
761
808
  error: error instanceof Error ? error.message : String(error),
762
809
  };
763
810
  }
764
811
  });
765
- return { results };
812
+ return {
813
+ results: toRenderableSummonResults(results, current),
814
+ locale: current,
815
+ };
766
816
  },
767
817
  }));
768
818
 
@@ -801,6 +851,41 @@ export function apply(ctx, config) {
801
851
  // T专家 的专家名册、@ 召唤、/t 列表仍然可用,只是团队功能不可用。
802
852
  // 这是**已声明的降级**,不是静默跳过:原因会写进日志、经 /t 报出,并进系统提示段。
803
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);
804
889
  if (engineConfigDetail === "") {
805
890
  try {
806
891
  ctx.plugin(teamsEngine, {
@@ -816,6 +901,9 @@ export function apply(ctx, config) {
816
901
  });
817
902
  // 复用引擎导出的手势边界:把 `/t --profile <小队> <目标>` 这条用户消息翻成队长协议指令。
818
903
  installTTeamGestureBoundary(ctx, () => engineConfig.profiles ?? {});
904
+ // 只记录「调度成功」。**不要**在这里同步探测工具是否已注册:cordis 的子插件 fiber
905
+ // 尚未激活,此刻必然探空 → 会把每一次正常启动都误报成降级(本改动实测踩到过)。
906
+ // 真机探测推迟到读取点(engineReady())。
819
907
  engineState.ok = true;
820
908
  } catch (error) {
821
909
  engineState.detail = error instanceof Error ? error.message : String(error);
@@ -827,15 +915,16 @@ export function apply(ctx, config) {
827
915
 
828
916
  // 已声明的降级也要**对模型可见**:否则"团队功能没了"这件事只留在日志里,
829
917
  // 用户问起来模型只能说不知道 —— 那正是规范禁止的"静默跳过缺失的引用对象"。
918
+ // 门槛用 engineReady()(真机探测)而不是 engineState.ok:两者在「引擎 fiber 被挂起」时不同。
830
919
  ctx.systemPrompt.section({
831
920
  name: "t-team:engine-status",
832
921
  order: 120,
833
922
  text: (context) => {
834
923
  if (context.agent?.session?.header?.parentSession !== undefined) return "";
835
- if (engineState.ok === true) return "";
924
+ if (engineReady()) return "";
836
925
  return [
837
926
  "## T专家 团队引擎当前不可用",
838
- `团队功能(/t 建队、t_team_* 工具)现在不可用。原因:${engineState.detail}`,
927
+ `团队功能(/t 建队、t_team_* 工具)现在不可用。原因:${engineUnreadyDetail()}`,
839
928
  "专家名册、@ 召唤与 summon_t_expert 不受影响。用户需要团队功能时,请原样转述上面的原因,不要假装能建队。",
840
929
  ].join("\n");
841
930
  },
@@ -850,11 +939,19 @@ export function apply(ctx, config) {
850
939
  generator: existsSync(join(SNAPSHOT_DIR, "team-profiles.py"))
851
940
  ? join(SNAPSHOT_DIR, "team-profiles.py")
852
941
  : undefined,
942
+ // 成员上限**只有一个真源**:Config.maxMembers。它同时喂给引擎(成员上限)与小队编译器
943
+ // (--max-members)。过去编译器把上限写死 8、这里又没透传,于是把 maxMembers 改大后保存小队
944
+ // 必然编译失败并回滚(D-14)。编译器不认这个参数时由 squads.js 记 warn 并退回它内置默认值。
945
+ maxMembers: config.maxMembers,
853
946
  });
854
947
  ctx.reflect.provide(SQUAD_SERVICE, squads);
855
948
 
856
949
  // ---- 团队运行服务(设置页「团队」标签用;复用引擎的快照与停止实现)----
857
- 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;
858
955
  const teamRoots = () => {
859
956
  const registry = ctx.get("workspaceRegistry") ?? ctx.get("workspace");
860
957
  const list = typeof registry?.list === "function" ? registry.list() : [];
@@ -921,11 +1018,17 @@ export function apply(ctx, config) {
921
1018
  },
922
1019
  async stop(teamId) {
923
1020
  const roots = teamRoots();
1021
+ // 逐 root 读取失败时**留住根因**:过去 `catch { continue; }` 把 EACCES 之类换成
1022
+ // 下面那句「没找到团队 …(它可能已被归档)」,用户按提示去查归档永远查不到(N-4)。
1023
+ const failures = [];
924
1024
  for (const root of roots) {
925
1025
  let snapshots;
926
1026
  try {
927
1027
  snapshots = await collectTeamsActivity(ctx, [root]);
928
- } 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})`);
929
1032
  continue;
930
1033
  }
931
1034
  const team = snapshots.find((item) => item.teamId === teamId);
@@ -937,22 +1040,21 @@ export function apply(ctx, config) {
937
1040
  const result = await haltTeamWork({ ctx, stateRoot: root.stateRoot, teamId, captain });
938
1041
  return { teamName: result.teamName, cancelledTasks: result.cancelledTasks, alreadyHalted: result.alreadyHalted === true };
939
1042
  }
940
- throw new Error(`没找到团队 ${teamId} 的状态目录(它可能已被归档)。`);
1043
+ // 读不到任何 root 时不要用「可能已归档」这种猜测掩盖根因:把真实失败原因一并报出来。
1044
+ throw new Error(failures.length > 0
1045
+ ? `没找到团队 ${teamId}:${roots.length} 个工作区里${failures.length} 个读取失败(${failures.join(";")})。`
1046
+ : `没找到团队 ${teamId} 的状态目录(它可能已被归档)。`);
941
1047
  },
942
1048
  });
943
1049
 
944
1050
  // ---- `/t` 小队短命令(把 /t <小队> <目标> 转成引擎认得的 /t --profile <key> <目标> 用户消息)----
945
1051
  registerTeamCommand(ctx, {
946
1052
  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;
953
- }
954
- },
955
- engineError: () => engineState.detail,
1053
+ // 与系统提示段共用同一个真机探测(engineReady),两条可见面不会再次分叉。
1054
+ hasEngine: () => engineReady(),
1055
+ // 报给用户的原因用 engineUnreadyDetail():它区分「调度都没成功」与「调度成功但工具没注册」,
1056
+ // 后者在旧文案里会指向一条永远不存在的 [t-team] error 行(N-5)。
1057
+ engineError: () => engineUnreadyDetail(),
956
1058
  });
957
1059
 
958
1060
  // ---- DAG 预检工具(只读):把整套拟建任务一次跑完引擎校验,避免"边建边撞"留下半成品 ----
@@ -1043,7 +1145,7 @@ export function apply(ctx, config) {
1043
1145
  if (context.agent?.session?.header?.parentSession !== undefined) return "";
1044
1146
  return [
1045
1147
  "## 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.",
1148
+ "The parent session has T专家 — a 316-expert, 22-division roster with full Chinese translations, exposed as summonable domain experts.",
1047
1149
  "Experts are individually enabled/disabled in the T专家 settings tab; ALL are disabled by default and a disabled expert cannot be summoned.",
1048
1150
  "A composer selection inserts one enabled expert as a native reference chip; the remaining draft text is that expert's task.",
1049
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).",
@@ -1052,8 +1154,11 @@ export function apply(ctx, config) {
1052
1154
  },
1053
1155
  });
1054
1156
 
1055
- // ---- 运维 skill(名册 / 小队 / 装机 / 发布)----
1056
- // 只对"维护这个插件的人"有用,所以不占常驻提示段,改成一个按需加载的 skill
1157
+ // ---- 随包 skill(运维入口 + DeepSeek Harness 项目知识)----
1158
+ // 都只对特定任务有用,所以不占常驻提示段,改成按需加载的 skill
1159
+ // · t-expert-manager 维护这个插件的人:名册 / 小队 / 装机 / 发布
1160
+ // · dsh-harness-project 在 deepseek-harness 检出里读写代码:架构、启动模型、扩展点、门禁
1161
+ // · dsh-harness-languages 同一个仓库里的各语言面规则与工具链
1057
1162
  // 可选依赖:宿主没有 skill 注册表时静默跳过,不影响上面任何功能。
1058
- installOpsSkill(ctx, config);
1163
+ installBundledSkills(ctx, config);
1059
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
  // 引擎的契约/写域/质量门槛校验