dsh-plugin-t-expert 0.2.9 → 0.2.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -11
- package/data/experts/engineering/engineering-deepseek-harness-project-expert.md +29 -3
- package/lib/bootstrap.js +314 -45
- package/lib/catalog.js +24 -3
- package/lib/client.js +131 -127
- package/lib/command.js +18 -5
- package/lib/i18n.js +18 -2
- package/lib/index.js +426 -80
- package/lib/plan-check.js +36 -54
- package/lib/remote-schemas.js +156 -0
- package/lib/remote.js +29 -133
- package/lib/skill.js +5 -2
- package/lib/squads.js +154 -35
- package/lib/teams/harness-compat.js +36 -0
- package/lib/teams/index.js +11 -3
- package/lib/teams/members.js +14 -5
- package/lib/teams/snapshot.js +7 -4
- package/lib/teams/tools.js +35 -18
- package/package.json +9 -7
- package/skills/dsh-harness-project/SKILL.md +27 -0
- package/skills/t-expert-manager/SKILL.md +11 -2
- package/skills/t-expert-manager/references/ops-reference.md +2 -2
package/lib/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
/**
|
|
2
3
|
* T专家 host 插件:把 ~/.t-team/experts 里的专家名册接到 DSH。
|
|
3
4
|
*
|
|
@@ -10,7 +11,7 @@
|
|
|
10
11
|
* 与其它专家插件并存:工具名、设置命名空间、服务名、remote 服务名都不重叠。
|
|
11
12
|
*/
|
|
12
13
|
import { homedir } from "node:os";
|
|
13
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
14
15
|
import { stat, rmdir } from "node:fs/promises";
|
|
15
16
|
import { dirname, join } from "node:path";
|
|
16
17
|
import schema from "@deepseek-ai/schemastery";
|
|
@@ -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
|
-
|
|
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
|
* 插件配置。
|
|
@@ -125,36 +133,143 @@ const settingsSchema = schema.object({
|
|
|
125
133
|
enabled: schema.array(schema.string()).default([]),
|
|
126
134
|
});
|
|
127
135
|
|
|
136
|
+
/**
|
|
137
|
+
* 把启动期名册同步的结果写进日志:**送达的内容要留痕,送不到的内容要出声**。
|
|
138
|
+
*
|
|
139
|
+
* 为什么单独成函数:旧实现只有一句 `copied.length > 0` 才打印的 info —— 于是「升级后
|
|
140
|
+
* 包内新增的专家/小队/译文一个都没到达」这件事在日志里**完全不存在**(2026-09-13 定性的
|
|
141
|
+
* 恒存缺陷)。这里把「漂移状态变化」变成唯一告警条件:既不静默,也不每次启动刷屏。
|
|
142
|
+
* @param logger - `ctx.logger`(可能缺席)。
|
|
143
|
+
* @param seeded - `seedData` 的报告。
|
|
144
|
+
* @param dataHome - 数据目录(日志里给人看的位置)。
|
|
145
|
+
*/
|
|
146
|
+
function logSeedReport(logger, seeded, dataHome) {
|
|
147
|
+
const sample = (list) => (list.length === 0 ? "" : `,如 ${list.slice(0, 3).join("、")}${list.length > 3 ? " 等" : ""}`);
|
|
148
|
+
const mergedKeys = seeded.merged.reduce((sum, item) => sum + item.keys.length, 0);
|
|
149
|
+
const delivered = seeded.copied.length + seeded.updated.length + mergedKeys;
|
|
150
|
+
const versionNote = seeded.previousVersion !== "" && seeded.previousVersion !== seeded.version
|
|
151
|
+
? `(包内快照 v${seeded.previousVersion} → v${seeded.version})`
|
|
152
|
+
: "";
|
|
153
|
+
|
|
154
|
+
if (delivered > 0 || versionNote !== "") {
|
|
155
|
+
const parts = [];
|
|
156
|
+
if (seeded.copied.length > 0) parts.push(`新增 ${seeded.copied.length} 项${sample(seeded.copied)}`);
|
|
157
|
+
if (seeded.updated.length > 0) parts.push(`对齐只读条目 ${seeded.updated.length} 项${sample(seeded.updated)}`);
|
|
158
|
+
if (mergedKeys > 0) parts.push(`补齐键 ${mergedKeys} 个${sample(seeded.merged.flatMap((item) => item.keys))}`);
|
|
159
|
+
logger?.info?.(`[t-team] 名册快照已同步到 ${dataHome}${versionNote}:${parts.join(";") || "无内容变更"}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 送不到的必须出声:只在「与上次记录相比有变化」时告警一次,避免每次启动刷屏。
|
|
163
|
+
if ((seeded.kept.length > 0 || seeded.stale.length > 0) && seeded.driftChanged) {
|
|
164
|
+
const parts = [];
|
|
165
|
+
if (seeded.kept.length > 0) parts.push(`${seeded.kept.length} 项与包内快照不同${sample(seeded.kept)}`);
|
|
166
|
+
if (seeded.stale.length > 0) parts.push(`${seeded.stale.length} 项包内已移除${sample(seeded.stale)}`);
|
|
167
|
+
logger?.warn?.(
|
|
168
|
+
`[t-team] 名册快照有${parts.join("、")};已按「不覆盖你的内容」保留盘上那份,未做任何覆盖。`
|
|
169
|
+
+ "若这些不是你改的,删掉对应文件后重启即可重新播种。",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 包内名册的规模(专家数 / 分区数),**从名册目录现算**并按目录缓存。
|
|
176
|
+
*
|
|
177
|
+
* 为什么不能写死:提示段是**模型可见的事实**。「a 316-expert, 22-division roster」这类字面量
|
|
178
|
+
* 在名册增删改分区之后就对模型说谎,而且没有任何东西会红(2026-09-13 定性的恒存缺陷之一:
|
|
179
|
+
* 数据更新了,模型看到的事实还是旧的)。现算 + 按目录缓存:一次 readdir 的开销,换来提示
|
|
180
|
+
* 永远等于该名册的真实规模。
|
|
181
|
+
* @param root - 名册目录(包内快照或用户数据目录)。
|
|
182
|
+
* @returns 规模;目录读不到时 `experts` 为 0(调用方据此改用不带数字的说法,而不是报 0 位)。
|
|
183
|
+
*/
|
|
184
|
+
const rosterScaleCache = new Map();
|
|
185
|
+
function rosterScale(root) {
|
|
186
|
+
const key = typeof root === "string" ? root : "";
|
|
187
|
+
const cached = rosterScaleCache.get(key);
|
|
188
|
+
if (cached !== undefined) return cached;
|
|
189
|
+
let scale = { experts: 0, divisions: 0 };
|
|
190
|
+
try {
|
|
191
|
+
const divisions = readdirSync(key, { withFileTypes: true }).filter((entry) => entry.isDirectory());
|
|
192
|
+
// **递归**数 `.md`:名册里存在 `game-development/unity/<slug>.md` 这类二级分组,
|
|
193
|
+
// 只数一层会少算(2026-09-13 实测:一层 301 / 递归 316,与 README、source.json、
|
|
194
|
+
// zh/names.json 的 316 一致)。分区数只取一级目录 —— 二级目录是分组不是分区。
|
|
195
|
+
const experts = readdirSync(key, { recursive: true }).filter((name) => String(name).endsWith(".md")).length;
|
|
196
|
+
scale = { experts, divisions: divisions.length };
|
|
197
|
+
} catch {
|
|
198
|
+
scale = { experts: 0, divisions: 0 };
|
|
199
|
+
}
|
|
200
|
+
rosterScaleCache.set(key, scale);
|
|
201
|
+
return scale;
|
|
202
|
+
}
|
|
203
|
+
|
|
128
204
|
export function apply(ctx, config) {
|
|
129
205
|
const locale = () => readLocale(ctx);
|
|
130
206
|
const maxDepth = config.maxDepth;
|
|
131
207
|
|
|
132
|
-
// ----
|
|
208
|
+
// ---- 启动期同步:包内自带名册快照 → 可写数据目录(缺失就补、只读对齐、用户内容不动)----
|
|
133
209
|
// 必须在下面同步读取 t-team.config.json / teams.json 之前完成,否则首次安装会读到空配置。
|
|
134
210
|
const seeded = seedData({ root: config.root, zhRoot: config.zhRoot });
|
|
135
|
-
|
|
136
|
-
ctx.logger?.info?.(`[t-team] 已把包内名册快照播种到 ${dirname(config.root)}(${seeded.copied.join(", ")})`);
|
|
137
|
-
}
|
|
211
|
+
logSeedReport(ctx.logger, seeded, dirname(config.root));
|
|
138
212
|
if (!seeded.ok) {
|
|
139
|
-
//
|
|
213
|
+
// 同步失败不当作致命:名册目录若因此缺失,第一次读名册会在 ensureReady() 里**响亮地**抛出
|
|
140
214
|
// (error.rootMissing),那才是最早可解析点。这里只保证失败一定会出现在日志里。
|
|
141
|
-
ctx.logger?.error?.(`[t-team]
|
|
215
|
+
ctx.logger?.error?.(`[t-team] 名册快照同步失败,将按已有数据继续:${seeded.error}`);
|
|
142
216
|
}
|
|
143
217
|
|
|
144
218
|
// ---- 设置段(T专家 自己的命名空间,与其它插件不冲突)----
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
219
|
+
// `settings` 是**可选**服务(见文件顶部的 inject 注释):宿主没挂它时本插件仍要能加载并工作,
|
|
220
|
+
// 所以不在静态 `inject` 里。
|
|
221
|
+
//
|
|
222
|
+
// ⚠️ 但**不能**用 `ctx.get("settings")` 做同步探测(2026-09-13 实测踩到):把 settings 移出静态
|
|
223
|
+
// inject 之后,`apply` 不再等待 settings 就绪,于是那一刻 `ctx.get("settings")` 是 undefined;
|
|
224
|
+
// 而 cordis 的 `provide` **不会回溯通知**已经跑过的同步代码,于是这个判断**永久**为假 ——
|
|
225
|
+
// 真实 GUI 里表现为「设置段没注册 → 点 T专家 是白板」(host 日志有对应 warn)。
|
|
226
|
+
//
|
|
227
|
+
// 正解是用 cordis 的 `ctx.inject`:它等的就是「该服务就绪」这件事,服务迟到也能补上。
|
|
228
|
+
// `ctx.inject(["settings"])` 同时保留了「宿主真没有 settings 就不注册、只降级」的语义。
|
|
229
|
+
/** @type {undefined | { get: () => any }} 设置段句柄;由下面的 inject 在 settings 就绪时赋值。 */
|
|
230
|
+
let scope;
|
|
231
|
+
/** @type {any} settings 服务本体;就绪后由 inject 赋值,`requireSettings()` 用它。 */
|
|
232
|
+
let settingsService;
|
|
233
|
+
if (typeof ctx.inject !== "function") {
|
|
234
|
+
// 只可能出现在极简/测试上下文里:如实说明,不要静默。
|
|
235
|
+
ctx.logger?.warn?.("[t-team] 上下文没有 inject:无法等待 settings 就绪,专家启停不可用。");
|
|
236
|
+
} else {
|
|
237
|
+
// 先如实说一句"在等 settings":`ctx.inject` 对**永不出现**的服务不会有任何回调,
|
|
238
|
+
// 所以只在回调里告警的话,宿主真没给 settings 时用户/日志里**什么都看不到**(实测如此)。
|
|
239
|
+
// 服务随后到了就正常注册;一直没到,这行就是唯一的原因线索。
|
|
240
|
+
ctx.logger?.warn?.("[t-team] 正在等待 settings 服务就绪以注册设置段;若宿主不提供它,专家启停与设置页的 T专家 段将不可用。");
|
|
241
|
+
ctx.inject(["settings"], (scoped) => {
|
|
242
|
+
settingsService = scoped.settings ?? scoped.get?.("settings");
|
|
243
|
+
if (typeof settingsService?.register !== "function") {
|
|
244
|
+
ctx.logger?.warn?.("[t-team] settings 服务没有 register:专家启停不可用(名册与召唤不受影响)。");
|
|
245
|
+
return;
|
|
152
246
|
}
|
|
153
|
-
|
|
154
|
-
|
|
247
|
+
scope = settingsService.register(SETTINGS_NAMESPACE, settingsSchema, {
|
|
248
|
+
base: { enabled: [] },
|
|
249
|
+
applies: "live",
|
|
250
|
+
validate: (value) => {
|
|
251
|
+
const list = value?.enabled;
|
|
252
|
+
if (!Array.isArray(list) || list.some((item) => typeof item !== "string")) {
|
|
253
|
+
throw new Error("t-team.enabled 必须是字符串数组");
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
}
|
|
155
259
|
const enabledSet = () => new Set(
|
|
156
|
-
(scope
|
|
260
|
+
(scope?.get?.()?.enabled ?? []).filter((value) => typeof value === "string"),
|
|
157
261
|
);
|
|
262
|
+
/**
|
|
263
|
+
* 需要读写「专家启停」的路径在 settings 缺席时必须**响亮失败**。
|
|
264
|
+
*
|
|
265
|
+
* 为什么不能给个空集合凑合:那样 `list_t_experts` 会返回 `total=0`、readonly 工具报「未启用」,
|
|
266
|
+
* 用户与模型都以为「名册是空的 / 什么都没开」,而不是「宿主没挂 settings」—— 正是要被消灭的
|
|
267
|
+
* 那类静默错误。异常经工具错误面报出,原因可读(A-4)。
|
|
268
|
+
*/
|
|
269
|
+
const requireSettings = () => {
|
|
270
|
+
if (settingsService === undefined) throw new Error(t(locale(), "error.settingsServiceMissing"));
|
|
271
|
+
return settingsService;
|
|
272
|
+
};
|
|
158
273
|
|
|
159
274
|
// ---- 花名册加载 ----
|
|
160
275
|
let entries = [];
|
|
@@ -249,6 +364,36 @@ export function apply(ctx, config) {
|
|
|
249
364
|
await loading;
|
|
250
365
|
}
|
|
251
366
|
if (loadError !== null) throw loadError;
|
|
367
|
+
await pruneStaleEnabled();
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* 清掉设置里**名册已不存在**的 slug(C-6)。
|
|
371
|
+
*
|
|
372
|
+
* 为什么必须清:`enabled` 只是 slug 列表,专家被删(自建专家删除、名册升级)后旧 slug 会留下。
|
|
373
|
+
* 后果不是"多一条无用配置",而是 `list_t_experts` 的 `total` 取 `enabled.size`、与真正展示出来的
|
|
374
|
+
* 条数**对不上**(用户看到「共 3 位」却只列出 2 位),而且没有任何地方会说明原因。
|
|
375
|
+
*
|
|
376
|
+
* 只在 settings 可用时清;清完写回并留一条 warn(用户的启用列表被改动过,应该看得见)。
|
|
377
|
+
* 防误清靠「名册为空就不动」那条判断(见下),不靠"只清一次"。
|
|
378
|
+
*/
|
|
379
|
+
async function pruneStaleEnabled() {
|
|
380
|
+
if (settingsService === undefined) return;
|
|
381
|
+
const known = new Set(entries.map((expert) => expert.slug));
|
|
382
|
+
const list = [...enabledSet()];
|
|
383
|
+
const stale = list.filter((slug) => !known.has(slug));
|
|
384
|
+
if (stale.length === 0) return;
|
|
385
|
+
// 名册为空说明这次 load 是残缺的(例如分区目录暂时读不到),此时**不要**清 ——
|
|
386
|
+
// 否则一次临时故障会把用户全部启用项抹掉。
|
|
387
|
+
if (known.size === 0) return;
|
|
388
|
+
try {
|
|
389
|
+
const next = list.filter((slug) => known.has(slug));
|
|
390
|
+
await settingsService.mutate(SETTINGS_NAMESPACE, [{ op: "set", path: ["enabled"], value: next }], revision());
|
|
391
|
+
ctx.logger?.warn?.(`[t-team] 已从启用列表移除 ${stale.length} 个名册里不存在的 slug:${stale.slice(0, 5).join(", ")}`
|
|
392
|
+
+ `${stale.length > 5 ? " …" : ""}(名册升级或自建专家删除后的收尾)`);
|
|
393
|
+
} catch (error) {
|
|
394
|
+
// 清不掉不影响可用性(那些 slug 本来也不会被展示),但不能因为并发冲突把加载搞挂。
|
|
395
|
+
ctx.logger?.warn?.(`[t-team] 启用列表里的失效 slug 未能清理:${String(error)}`);
|
|
396
|
+
}
|
|
252
397
|
}
|
|
253
398
|
/** 可召唤/可展示的专家(排除重名冲突项)。 */
|
|
254
399
|
const usable = () => entries.filter((expert) => expert.conflict !== true);
|
|
@@ -271,7 +416,9 @@ export function apply(ctx, config) {
|
|
|
271
416
|
|
|
272
417
|
// ---- 设置段修订号与写入 ----
|
|
273
418
|
const revision = () => {
|
|
274
|
-
|
|
419
|
+
// settings 是可选服务:缺它时给出同一个可读错误(error.settingsMissing),
|
|
420
|
+
// 而不是 `Cannot read properties of undefined` 这种对用户毫无意义的 TypeError。
|
|
421
|
+
const descriptor = settingsService?.describe?.().find((item) => item.ns === SETTINGS_NAMESPACE);
|
|
275
422
|
if (descriptor === undefined) throw new Error(t(locale(), "error.settingsMissing"));
|
|
276
423
|
return descriptor.revision;
|
|
277
424
|
};
|
|
@@ -279,7 +426,13 @@ export function apply(ctx, config) {
|
|
|
279
426
|
/** 客户端面板用的名册快照。 */
|
|
280
427
|
async function snapshot() {
|
|
281
428
|
await ensureReady();
|
|
282
|
-
|
|
429
|
+
// 走工具面的读路径在 settings 缺席时响亮失败(见 requireSettings 的说明):
|
|
430
|
+
// 返回一套 "全部未启用" 的空快照会让用户以为是名册空了,而不是宿主少挂了一个服务。
|
|
431
|
+
requireSettings();
|
|
432
|
+
const enabled = enabledSet();
|
|
433
|
+
// 映射本身是同步的;只有**自建**专家需要读文件算内容指纹(await)。所以不要为了形式
|
|
434
|
+
// 统一给所有专家都套 async —— 这里保留 Promise.all 是因为 custom 分支确实要 await。
|
|
435
|
+
const experts = await Promise.all(usable().map(async (expert) => ({
|
|
283
436
|
// 面板按 locale 取字段:name/description 为中文(缺译回退英文),nameEn/descriptionEn 恒为英文。
|
|
284
437
|
slug: expert.slug,
|
|
285
438
|
name: expert.nameZh ?? expert.name,
|
|
@@ -344,6 +497,7 @@ export function apply(ctx, config) {
|
|
|
344
497
|
// 往里写自建专家会在下次同步时被当成"意外文件"删掉。
|
|
345
498
|
/** 带业务码的错误:remote 的 businessError 会原样透传 code 给面板。 */
|
|
346
499
|
function customError(codeKey, params = {}) {
|
|
500
|
+
/** @type {Error & { code?: string }} */
|
|
347
501
|
const error = new Error(t(locale(), codeKey, params));
|
|
348
502
|
error.code = `tTeam/${codeKey.replace(/^error\./, "").replace(/[A-Z]/g, (ch) => `-${ch.toLowerCase()}`)}`;
|
|
349
503
|
return error;
|
|
@@ -549,7 +703,7 @@ export function apply(ctx, config) {
|
|
|
549
703
|
// 删除后把该专家从启用列表里摘掉,否则设置段会留下一个指向空气的 slug
|
|
550
704
|
const enabled = [...enabledSet()].filter((item) => item !== slug);
|
|
551
705
|
if (enabled.length !== enabledSet().size) {
|
|
552
|
-
await
|
|
706
|
+
await settingsService.mutate(SETTINGS_NAMESPACE, [{ op: "set", path: ["enabled"], value: enabled }], revision());
|
|
553
707
|
}
|
|
554
708
|
await refreshCatalog();
|
|
555
709
|
return await snapshot();
|
|
@@ -564,12 +718,13 @@ export function apply(ctx, config) {
|
|
|
564
718
|
// 稳定 code(不是显示文案):remote 侧按它分类成 tTeam/unknown-expert,与 locale 无关(D-3)。
|
|
565
719
|
// 文案走正确的 t() 通道(过去这里也是硬编码中文,非中文 locale 下会中英混排)。
|
|
566
720
|
const slugs = unknown.slice(0, 5);
|
|
721
|
+
/** @type {Error & { code?: string, details?: { slugs: string[] } }} */
|
|
567
722
|
const error = new Error(t(locale(), "error.customUnknownSlug", { slugs: slugs.join(", ") }));
|
|
568
723
|
error.code = UNKNOWN_SLUG_ERROR_CODE;
|
|
569
724
|
error.details = { slugs };
|
|
570
725
|
throw error;
|
|
571
726
|
}
|
|
572
|
-
await
|
|
727
|
+
await settingsService.mutate(SETTINGS_NAMESPACE, [{ op: "set", path: ["enabled"], value: list }], expectedRevision);
|
|
573
728
|
return { enabled: [...enabledSet()], revision: revision() };
|
|
574
729
|
}
|
|
575
730
|
|
|
@@ -586,11 +741,13 @@ export function apply(ctx, config) {
|
|
|
586
741
|
snapshot, prompt, setEnabled, root: config.root,
|
|
587
742
|
createExpert, updateExpert, deleteExpert,
|
|
588
743
|
createCategory, updateCategory, deleteCategory,
|
|
589
|
-
};
|
|
744
|
+
};
|
|
745
|
+
ctx.reflect.provide(CATALOG_SERVICE, catalogService);
|
|
590
746
|
|
|
591
747
|
// ---- 召唤 ----
|
|
592
748
|
async function runExpert(query, task, exec) {
|
|
593
749
|
const current = locale();
|
|
750
|
+
requireSettings();
|
|
594
751
|
const taskText = String(task ?? "").trim();
|
|
595
752
|
if (taskText === "") throw new Error(t(current, "error.taskEmpty"));
|
|
596
753
|
if ([...taskText].length > config.summonTaskMaxChars) {
|
|
@@ -691,6 +848,7 @@ export function apply(ctx, config) {
|
|
|
691
848
|
async execute(args) {
|
|
692
849
|
await ensureReady();
|
|
693
850
|
const current = locale();
|
|
851
|
+
requireSettings();
|
|
694
852
|
const query = args.division === undefined ? "" : String(args.division).trim();
|
|
695
853
|
const enabled = enabledSet();
|
|
696
854
|
const groups = groupByDivision(usable(), enabled);
|
|
@@ -730,6 +888,10 @@ export function apply(ctx, config) {
|
|
|
730
888
|
ctx.tools.register(defineTool({
|
|
731
889
|
name: "summon_t_expert",
|
|
732
890
|
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.",
|
|
891
|
+
// 预算声明:单次召唤给 10 分钟。**注意它只是声明**(`dsh-tools` 的 registry 不强制 deadline),
|
|
892
|
+
// 真正生效要宿主装配 `@deepseek-ai/dsh-tool-call-timeout-policy`;这里写出来是为了让
|
|
893
|
+
// 「最坏要等多久」有个明确契约,而不是无界挂起。
|
|
894
|
+
timeoutMs: 600_000,
|
|
733
895
|
parameters: {
|
|
734
896
|
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." },
|
|
735
897
|
task: { type: "string", required: true, description: "The complete, self-contained task to give the expert. Include all necessary context." },
|
|
@@ -754,6 +916,9 @@ export function apply(ctx, config) {
|
|
|
754
916
|
ctx.tools.register(defineTool({
|
|
755
917
|
name: "summon_t_experts",
|
|
756
918
|
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.`,
|
|
919
|
+
// 预算声明:批量最坏是 ceil(批大小 / 并发) 轮串行,给 20 分钟。
|
|
920
|
+
// 与单次召唤同理,它只是声明,强制 deadline 需要宿主装配 timeout-policy wrapper。
|
|
921
|
+
timeoutMs: 1_200_000,
|
|
757
922
|
parameters: {
|
|
758
923
|
experts: {
|
|
759
924
|
type: "array",
|
|
@@ -825,32 +990,64 @@ export function apply(ctx, config) {
|
|
|
825
990
|
: ["t-team.config.json", "agent-teams.config.json"]
|
|
826
991
|
.map((file) => join(engineConfigDir, file))
|
|
827
992
|
.find((candidate) => existsSync(candidate)) ?? join(engineConfigDir, "t-team.config.json");
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
993
|
+
/**
|
|
994
|
+
* 读并解析引擎配置(**每次调用都重新读盘**)。
|
|
995
|
+
*
|
|
996
|
+
* 为什么必须是函数而不是启动期的一个常量:热重载要在保存小队后重新读这份文件。启动期快照
|
|
997
|
+
* 正是「改一项要重启」的成因(audit/01-spec-checklist.md:235)。
|
|
998
|
+
*
|
|
999
|
+
* 失败形态刻意分开(两条路径都不可静默):
|
|
1000
|
+
* · 文件不存在 → `{ config: {}, detail: "..." }`:还没生成过小队是**合法状态**(自定义 root
|
|
1001
|
+
* 的部署就是这样),降级但把原因送到日志/`/t`/系统提示段;
|
|
1002
|
+
* · 存在但内容不合法 → `{ config: null, failure: "..." }`:自带错配,**必须响亮**。
|
|
1003
|
+
* 启动路径沿用旧行为(直接让插件加载失败),热重载路径见 `reloadEngine()`(失败则保留旧
|
|
1004
|
+
* 引擎并留可诊断信号)。
|
|
1005
|
+
*/
|
|
1006
|
+
const loadEngineConfig = () => {
|
|
1007
|
+
if (!existsSync(engineConfigPath)) {
|
|
1008
|
+
return { config: {}, failure: "", detail: `找不到团队引擎配置 ${engineConfigPath}(跑一次 team-profiles.py 生成小队后即可用 /t 拉起团队)` };
|
|
1009
|
+
}
|
|
834
1010
|
let parsed;
|
|
835
1011
|
try {
|
|
836
1012
|
parsed = JSON.parse(readFileSync(engineConfigPath, "utf8"));
|
|
837
1013
|
} catch (error) {
|
|
838
|
-
|
|
1014
|
+
return { config: null, failure: `团队引擎配置无法解析:${engineConfigPath}(${error instanceof Error ? error.message : String(error)})。它是 team-profiles.py 的产物,删掉后重跑一次即可重建。`, detail: "" };
|
|
839
1015
|
}
|
|
840
1016
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
841
|
-
|
|
1017
|
+
return { config: null, failure: `团队引擎配置必须是 JSON 对象:${engineConfigPath}`, detail: "" };
|
|
842
1018
|
}
|
|
843
1019
|
if (parsed.profiles !== undefined && (parsed.profiles === null || typeof parsed.profiles !== "object" || Array.isArray(parsed.profiles))) {
|
|
844
|
-
|
|
1020
|
+
return { config: null, failure: `团队引擎配置的 profiles 必须是对象:${engineConfigPath}`, detail: "" };
|
|
845
1021
|
}
|
|
846
|
-
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
|
|
1022
|
+
return { config: parsed, failure: "", detail: "" };
|
|
1023
|
+
};
|
|
1024
|
+
const initialEngineConfig = loadEngineConfig();
|
|
1025
|
+
if (initialEngineConfig.failure !== "") throw new Error(initialEngineConfig.failure);
|
|
850
1026
|
// 引擎与名册解耦:引擎挂载失败(例如 Harness 子代理契约不匹配)时,
|
|
851
1027
|
// T专家 的专家名册、@ 召唤、/t 列表仍然可用,只是团队功能不可用。
|
|
852
1028
|
// 这是**已声明的降级**,不是静默跳过:原因会写进日志、经 /t 报出,并进系统提示段。
|
|
853
|
-
|
|
1029
|
+
//
|
|
1030
|
+
// `engineConfig` / `engineFiber` 是热重载的**活状态**(不是启动期快照):
|
|
1031
|
+
// · `engineConfig` —— 当前挂载所用的配置对象;每次成功重挂会换成新解析出来的对象;
|
|
1032
|
+
// · `engineFiber` —— 当前引擎 fiber;重载时 dispose 它,再用新配置 `ctx.plugin()` 重挂;
|
|
1033
|
+
// · `lastMountError` —— 最近一次挂载/重挂失败的原因,挂载成功时清空。
|
|
1034
|
+
// 三者都刻意与 `Config` 分开:它们是**运行态**,`Config` 只提供不随小队定义变化的那几个标量。
|
|
1035
|
+
let engineConfig = initialEngineConfig.config;
|
|
1036
|
+
let engineFiber;
|
|
1037
|
+
let lastMountError = "";
|
|
1038
|
+
const engineState = {
|
|
1039
|
+
ok: false,
|
|
1040
|
+
detail: initialEngineConfig.detail,
|
|
1041
|
+
/**
|
|
1042
|
+
* 最近一次**热重载失败**的原因(成功时清空)。
|
|
1043
|
+
*
|
|
1044
|
+
* 与 `lastMountError` 分开是有意的:热重载失败时**旧引擎仍然可用**,所以不能把引擎整体报成
|
|
1045
|
+
* "不可用"(那会让用户以为团队功能全没了);但"盘上是新小队、运行中的引擎还是旧的"这件事
|
|
1046
|
+
* 必须对模型/用户可见 —— 否则用户会看到 `/t` 列着新小队、却用它建不出队,正是本任务要消灭
|
|
1047
|
+
* 的那个分叉。提示段靠它区分「引擎从未挂载过」与「挂载过、但这次热重载失败」。
|
|
1048
|
+
*/
|
|
1049
|
+
reloadFailure: "",
|
|
1050
|
+
};
|
|
854
1051
|
/** 首次探到「调度成功但工具没注册」时的告警只打一次(此后每轮都探,但不再刷日志)。 */
|
|
855
1052
|
let engineNotReadyWarned = false;
|
|
856
1053
|
const NOT_READY_DETAIL = "内置团队引擎的工具没有注册成功(引擎可能还在等待它声明的宿主服务)";
|
|
@@ -865,8 +1062,8 @@ export function apply(ctx, config) {
|
|
|
865
1062
|
*
|
|
866
1063
|
* **探测时机很关键**:宿主里子插件的 fiber 可能在同一同步段末尾、也可能更晚才激活,
|
|
867
1064
|
* 所以**不在 mount 的那一刻探**(那一刻必然探空,会把每次正常启动都误报成降级)。
|
|
868
|
-
* 探测发生在所有真实读取点(系统提示段 / `/t`
|
|
869
|
-
*
|
|
1065
|
+
* 探测发生在所有真实读取点(系统提示段 / `/t` 命令 / 热重载收口),那时引擎早已定局;
|
|
1066
|
+
* 且每一轮都重新探,只在**第一次**探到假时补一条 warn,避免刷日志。
|
|
870
1067
|
* @returns 引擎工具是否已注册。
|
|
871
1068
|
*/
|
|
872
1069
|
const engineReady = () => {
|
|
@@ -884,34 +1081,125 @@ export function apply(ctx, config) {
|
|
|
884
1081
|
}
|
|
885
1082
|
return present;
|
|
886
1083
|
};
|
|
887
|
-
/**
|
|
888
|
-
const engineUnreadyDetail = () => (engineState.detail
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
1084
|
+
/** 给用户/模型的原因:挂载/重挂抛错就报那个错,否则报「工具没注册」。 */
|
|
1085
|
+
const engineUnreadyDetail = () => (lastMountError !== "" ? lastMountError : engineState.detail !== "" ? engineState.detail : NOT_READY_DETAIL);
|
|
1086
|
+
/** 引擎挂载参数。**每次挂载现算**:`profiles` 从活取的 `engineConfig` 里取,所以重挂必然带新小队。 */
|
|
1087
|
+
const teamsEngineArgs = () => ({
|
|
1088
|
+
stateDir: config.stateDir,
|
|
1089
|
+
memberProvider: config.memberProvider,
|
|
1090
|
+
...(config.memberModel === undefined ? {} : { memberModel: config.memberModel }),
|
|
1091
|
+
...(engineConfig.memberMaxDepth === undefined ? {} : { memberMaxDepth: engineConfig.memberMaxDepth }),
|
|
1092
|
+
maxMembers: config.maxMembers,
|
|
1093
|
+
// 引擎自带命令必须关掉:T专家 自己注册 /t(带小队别名解析),否则两个同名命令相撞。
|
|
1094
|
+
// 手势边界另外复用(见下),它是队长协议真正被触发的地方。
|
|
1095
|
+
slashCommand: false,
|
|
1096
|
+
profiles: engineConfig.profiles ?? {},
|
|
1097
|
+
});
|
|
1098
|
+
/**
|
|
1099
|
+
* 安装手势边界(`/t --profile <小队> <目标>` → 队长协议指令),**恰好一次**。
|
|
1100
|
+
*
|
|
1101
|
+
* 只在引擎真的挂上之后装:边界生成的是"按这支小队建队"的指令,引擎不可用时装了也只会把用户
|
|
1102
|
+
* 消息翻成一条建不成的指令(旧行为如此,这里刻意保持)。传的是**活取值函数**而不是当前
|
|
1103
|
+
* profiles 对象:热重载后它立刻读到新小队目录,不必重装监听器。
|
|
1104
|
+
*/
|
|
1105
|
+
let gestureBoundaryInstalled = false;
|
|
1106
|
+
const installGestureBoundaryOnce = () => {
|
|
1107
|
+
if (gestureBoundaryInstalled) return;
|
|
1108
|
+
installTTeamGestureBoundary(ctx, () => engineConfig.profiles ?? {});
|
|
1109
|
+
gestureBoundaryInstalled = true;
|
|
1110
|
+
};
|
|
1111
|
+
try {
|
|
1112
|
+
// 首次挂载(启动期读不到配置就降级,不假装就绪;原因见 prompt 段与日志)。
|
|
1113
|
+
if (initialEngineConfig.detail === "") {
|
|
1114
|
+
engineFiber = ctx.plugin(teamsEngine, teamsEngineArgs());
|
|
907
1115
|
engineState.ok = true;
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
ctx.logger?.error?.(`[t-team]
|
|
1116
|
+
installGestureBoundaryOnce();
|
|
1117
|
+
} else {
|
|
1118
|
+
ctx.logger?.error?.(`[t-team] ${initialEngineConfig.detail}`);
|
|
911
1119
|
}
|
|
912
|
-
}
|
|
913
|
-
|
|
1120
|
+
} catch (error) {
|
|
1121
|
+
engineState.ok = false;
|
|
1122
|
+
lastMountError = error instanceof Error ? error.message : String(error);
|
|
1123
|
+
ctx.logger?.error?.(`[t-team] 团队引擎挂载失败(专家名册功能不受影响):${lastMountError}`);
|
|
914
1124
|
}
|
|
1125
|
+
/**
|
|
1126
|
+
* 团队引擎热重载:保存小队(编译成功后)由小队服务敲门。
|
|
1127
|
+
*
|
|
1128
|
+
* 这是「不重启就能用新小队建队」的收口点。之所以能成立:引擎的 13 个工具、提示段与命令
|
|
1129
|
+
* 都注册在自己的 fiber 下(走 effect),换配置重挂就是「先卸载旧的、再用新 profiles 跑一次
|
|
1130
|
+
* apply」——引擎注册面与卸载面都不需要改代码。
|
|
1131
|
+
*
|
|
1132
|
+
* 为什么用 `dispose()` + 重新 `plugin()`,而不是 `fiber.update(config)`:两者在本仓库当前装的
|
|
1133
|
+
* cordis 4.0.2 上**都实测可用**(同一份真机探针各跑一遍,见交付说明),区别只在语义强弱:
|
|
1134
|
+
* · `update()` 复用同一条 fiber,由框架按"先卸载旧 effect、再用新 config apply"重载,最贴近
|
|
1135
|
+
* 框架本意(设置页与 HMR 走的就是它);但它要求"插件注册的每一条都挂在同一条 fiber 的
|
|
1136
|
+
* effect 上"。本插件是"插件里又挂子插件"的形态(引擎的 apply 里还有工具/提示段/命令/子
|
|
1137
|
+
* fiber),注册面越大,越容易被后来新增的、不走 effect 的注册悄悄破坏 —— 本轮就真的被
|
|
1138
|
+
* 误导过一次:探针桩漏了 effect 包裹,`update()` 立刻撞 `tool "t_team_create" is already
|
|
1139
|
+
* registered in this scope`,看着像框架缺陷、其实是被测面自己没走 effect。
|
|
1140
|
+
* · `dispose()` 会 await 整棵子树的卸载,之后再 `plugin()` 是一个全新的 fiber —— 不存在
|
|
1141
|
+
* "旧 effect 残留"这一类问题,代价只是多创建一次 fiber。
|
|
1142
|
+
* 小队重载是低频操作(用户点一次保存),这里选**更难用错**的那条:以后注册面变大也不会悄悄失效。
|
|
1143
|
+
* 若将来有理由改回 `update()`,探针(reload-probe.mjs)对两者都成立,换实现只需重跑它。
|
|
1144
|
+
*
|
|
1145
|
+
* 语义(对应任务的失败/并发要求):
|
|
1146
|
+
* · 配置读不出来(不存在/解析失败/类型不对)→ **不重挂**,保留旧引擎继续可用,`ok:false`
|
|
1147
|
+
* 带原因返回;下一次保存成功会重新对齐。
|
|
1148
|
+
* · `dispose()` 抛错 → 引擎此刻确实不在了,`ok:false` 且 `engineState.ok=false`(提示段会
|
|
1149
|
+
* 报「引擎不可用」,不假装就绪);但**已保存的配置不会回滚**(它不是坏定义,只是引擎没起来)。
|
|
1150
|
+
* · 重挂抛错 → 同上,但已经尝试过重新挂载,`engineState.ok` 如实反映结果。
|
|
1151
|
+
* · 全程不向上抛(保存已经成功,抛错会让用户以为没保存成)。
|
|
1152
|
+
* @returns `{ ok, detail }`:`detail` 只在失败时非空,且必须是**可操作**的原因。
|
|
1153
|
+
*/
|
|
1154
|
+
const reloadEngine = async () => {
|
|
1155
|
+
/** 重载失败的统一收口:留在提示段与日志里("引擎还在用旧小队"与"引擎整个没了"要分清)。 */
|
|
1156
|
+
const reloadFailed = (detail) => {
|
|
1157
|
+
engineState.reloadFailure = detail;
|
|
1158
|
+
ctx.logger?.error?.(`[t-team] 小队已保存,但团队引擎热重载失败:${detail}`);
|
|
1159
|
+
return { ok: false, detail };
|
|
1160
|
+
};
|
|
1161
|
+
const fresh = loadEngineConfig();
|
|
1162
|
+
if (fresh.failure !== "" || fresh.config === null) {
|
|
1163
|
+
// 不换 `engineConfig`:读不出来就当这次重载没发生过,旧引擎继续用旧配置,可用性不变。
|
|
1164
|
+
return reloadFailed(fresh.failure);
|
|
1165
|
+
}
|
|
1166
|
+
engineConfig = fresh.config; // 无论 mount 是否成功都换:新配置就是事实,否则会永远重装旧快照。
|
|
1167
|
+
engineState.detail = fresh.detail;
|
|
1168
|
+
const previousFiber = engineFiber;
|
|
1169
|
+
if (previousFiber !== undefined) {
|
|
1170
|
+
// 先断开引用:dispose 抛错时不能留着一个已死的 fiber 被后续重载再 dispose 一次。
|
|
1171
|
+
engineFiber = undefined;
|
|
1172
|
+
try {
|
|
1173
|
+
await previousFiber.dispose();
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
engineState.ok = false;
|
|
1176
|
+
engineState.reloadFailure = "";
|
|
1177
|
+
return reloadFailed(`卸载旧引擎失败:${error instanceof Error ? error.message : String(error)}`);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
try {
|
|
1181
|
+
const next = ctx.plugin(teamsEngine, teamsEngineArgs());
|
|
1182
|
+
engineFiber = next;
|
|
1183
|
+
await next; // fiber 是 thenable:await 它等价于等 apply 落定。
|
|
1184
|
+
} catch (error) {
|
|
1185
|
+
engineFiber = undefined;
|
|
1186
|
+
engineState.ok = false;
|
|
1187
|
+
lastMountError = error instanceof Error ? error.message : String(error);
|
|
1188
|
+
return reloadFailed(`重新挂载引擎失败:${lastMountError}`);
|
|
1189
|
+
}
|
|
1190
|
+
// 定局探测(engineReady 同样只报一次「工具未注册」的 warn,避免刷日志)。
|
|
1191
|
+
if (ctx.tools.get("t_team_create") === undefined && engineState.ok !== true) {
|
|
1192
|
+
// apply 抛过错 → 引擎真的没起来;服务不齐(正常生命周期)在这里不算失败:cordis 会在
|
|
1193
|
+
// 服务回来时自己重载那条 fiber。
|
|
1194
|
+
return reloadFailed(engineUnreadyDetail());
|
|
1195
|
+
}
|
|
1196
|
+
engineState.ok = true;
|
|
1197
|
+
lastMountError = "";
|
|
1198
|
+
engineState.reloadFailure = "";
|
|
1199
|
+
// 启动期因"还没有小队配置"而没挂上时,这里补装手势边界:现在引擎已经起来了。
|
|
1200
|
+
installGestureBoundaryOnce();
|
|
1201
|
+
return { ok: true, detail: "" };
|
|
1202
|
+
};
|
|
915
1203
|
|
|
916
1204
|
// 已声明的降级也要**对模型可见**:否则"团队功能没了"这件事只留在日志里,
|
|
917
1205
|
// 用户问起来模型只能说不知道 —— 那正是规范禁止的"静默跳过缺失的引用对象"。
|
|
@@ -921,7 +1209,16 @@ export function apply(ctx, config) {
|
|
|
921
1209
|
order: 120,
|
|
922
1210
|
text: (context) => {
|
|
923
1211
|
if (context.agent?.session?.header?.parentSession !== undefined) return "";
|
|
924
|
-
if (engineReady())
|
|
1212
|
+
if (engineReady()) {
|
|
1213
|
+
// 引擎可用、但上一次热重载没成功:盘上的小队定义已经变了,运行中的引擎还是旧的。
|
|
1214
|
+
return engineState.reloadFailure === ""
|
|
1215
|
+
? ""
|
|
1216
|
+
: [
|
|
1217
|
+
"## T专家 小队定义已保存,但团队引擎没有换成新配置",
|
|
1218
|
+
`这次保存已经生效(/t 列表就是新定义),但引擎仍在使用上一次的小队配置,所以用新改的小队建队会失败。原因:${engineState.reloadFailure}`,
|
|
1219
|
+
"需要重新保存一次小队定义让引擎再试一次,或重启 DSH。用户问起时请原样转述上面的原因。",
|
|
1220
|
+
].join("\n");
|
|
1221
|
+
}
|
|
925
1222
|
return [
|
|
926
1223
|
"## T专家 团队引擎当前不可用",
|
|
927
1224
|
`团队功能(/t 建队、t_team_* 工具)现在不可用。原因:${engineUnreadyDetail()}`,
|
|
@@ -932,17 +1229,25 @@ export function apply(ctx, config) {
|
|
|
932
1229
|
|
|
933
1230
|
// ---- 小队定义服务(设置页「小队」标签用;写回 teams.json 后自动重跑 team-profiles.py)----
|
|
934
1231
|
const teamsFile = join(dirname(config.root), "teams.json");
|
|
1232
|
+
// 编译器**只认包内那份**(随插件版本走)。刻意**不再回退**数据目录里的 `team-profiles.py`:
|
|
1233
|
+
// 那份是 bootstrap 播种的用户可写文件,会让「保存小队」变成「执行用户可写脚本」——
|
|
1234
|
+
// 同用户权限下的代码执行面,且它与包内版本的漂移无法校验。裁剪安装(包内被删)时
|
|
1235
|
+
// 小队编译会**响亮失败**(原因经 logger 与面板可见),而不是悄悄执行另一份文件(C-3)。
|
|
1236
|
+
const packagedGenerator = join(SNAPSHOT_DIR, "team-profiles.py");
|
|
935
1237
|
const squads = createSquadService({
|
|
936
1238
|
teamsFile,
|
|
937
1239
|
catalog: catalogService,
|
|
938
|
-
|
|
939
|
-
generator: existsSync(join(SNAPSHOT_DIR, "team-profiles.py"))
|
|
940
|
-
? join(SNAPSHOT_DIR, "team-profiles.py")
|
|
941
|
-
: undefined,
|
|
1240
|
+
generator: packagedGenerator,
|
|
942
1241
|
// 成员上限**只有一个真源**:Config.maxMembers。它同时喂给引擎(成员上限)与小队编译器
|
|
943
1242
|
// (--max-members)。过去编译器把上限写死 8、这里又没透传,于是把 maxMembers 改大后保存小队
|
|
944
1243
|
// 必然编译失败并回滚(D-14)。编译器不认这个参数时由 squads.js 记 warn 并退回它内置默认值。
|
|
945
1244
|
maxMembers: config.maxMembers,
|
|
1245
|
+
// 保存小队**编译成功后**的热重载钩子:让运行中的引擎立刻用新 profiles 重挂,不必重启 DSH。
|
|
1246
|
+
// 注入的是回调而不是 `ctx`:数据层不该知道 cordis 的存在(它只负责在正确时机敲门)。
|
|
1247
|
+
onReload: reloadEngine,
|
|
1248
|
+
// 小队编译/重载的诊断出口(N-3):过去 squads.js 直接写 console.error,桌面与 Web 里
|
|
1249
|
+
// 用户看不见,等于「保存成功但重载失败」这条失败从来没被上报过。
|
|
1250
|
+
logger: ctx.logger,
|
|
946
1251
|
});
|
|
947
1252
|
ctx.reflect.provide(SQUAD_SERVICE, squads);
|
|
948
1253
|
|
|
@@ -1048,14 +1353,39 @@ export function apply(ctx, config) {
|
|
|
1048
1353
|
});
|
|
1049
1354
|
|
|
1050
1355
|
// ---- `/t` 小队短命令(把 /t <小队> <目标> 转成引擎认得的 /t --profile <key> <目标> 用户消息)----
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1356
|
+
// `commands` 同为**可选**服务(见顶部 inject 注释):与 settings 完全同理——
|
|
1357
|
+
// 真实宿主里它也可能**晚于本插件就绪**,而 `ctx.get` 只在服务已就绪时才返回对象
|
|
1358
|
+
// (2026-09-13 真实 GUI 实证:apply 时同步探测判负 → /t 根本没注册,而引擎自己那条
|
|
1359
|
+
// `ctx.inject(['commands'])` 却正常)。所以这里也改成 `ctx.inject` 等就绪,不用同步探测。
|
|
1360
|
+
if (typeof ctx.inject !== "function") {
|
|
1361
|
+
ctx.logger?.warn?.("[t-team] 上下文没有 inject:无法等待 commands 就绪,/t 命令不可用。");
|
|
1362
|
+
} else {
|
|
1363
|
+
// 与 settings 同理:`ctx.inject` 对**永不出现**的服务不会有回调,只在回调里告警的话
|
|
1364
|
+
// 宿主真没给 commands 时日志里什么线索都没有。所以申请时就先说一句。
|
|
1365
|
+
ctx.logger?.warn?.("[t-team] 正在等待 commands 服务就绪以注册 /t 命令;若宿主不提供它,/t 将不可用。");
|
|
1366
|
+
ctx.inject(["commands"], (scoped) => {
|
|
1367
|
+
// `scoped` 是 cordis 的**作用域 ctx**。2026-09-13 用真 cordis 4.0.2 实测:
|
|
1368
|
+
// 它是**完整 Context**(`effect` / `get` / `logger` / `inject` / `on` / `plugin` 全在),
|
|
1369
|
+
// 所以「`scoped.effect` 是 undefined」属于**过时说法,别照抄**(自检 [8c] 已用真 cordis
|
|
1370
|
+
// 把这条事实钉住,注释再漂就会被那条断言顶回来)。
|
|
1371
|
+
// 分工照旧:服务从 `scoped` 解析(它保证服务已就绪),effect/logger 挂主 ctx —— 这是
|
|
1372
|
+
// **生命周期上的选择**(注册点随插件 fiber 一起卸载),不是能力限制。
|
|
1373
|
+
const commands = scoped?.commands ?? scoped?.get?.("commands");
|
|
1374
|
+
if (typeof commands?.register !== "function") {
|
|
1375
|
+
ctx.logger?.warn?.("[t-team] commands 服务没有 register:/t 命令不可用(@ 召唤与 summon_t_expert 不受影响)。");
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
registerTeamCommand(ctx, {
|
|
1379
|
+
commands,
|
|
1380
|
+
teamsFile,
|
|
1381
|
+
// 与系统提示段共用同一个真机探测(engineReady),两条可见面不会再次分叉。
|
|
1382
|
+
hasEngine: () => engineReady(),
|
|
1383
|
+
// 报给用户的原因用 engineUnreadyDetail():它区分「调度都没成功」与「调度成功但工具没注册」,
|
|
1384
|
+
// 后者在旧文案里会指向一条永远不存在的 [t-team] error 行(N-5)。
|
|
1385
|
+
engineError: () => engineUnreadyDetail(),
|
|
1386
|
+
});
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1059
1389
|
|
|
1060
1390
|
// ---- DAG 预检工具(只读):把整套拟建任务一次跑完引擎校验,避免"边建边撞"留下半成品 ----
|
|
1061
1391
|
ctx.tools.register(defineTool({
|
|
@@ -1065,7 +1395,7 @@ export function apply(ctx, config) {
|
|
|
1065
1395
|
tasks: {
|
|
1066
1396
|
type: "array",
|
|
1067
1397
|
required: true,
|
|
1068
|
-
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
|
|
1398
|
+
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.",
|
|
1069
1399
|
items: { type: "json" },
|
|
1070
1400
|
},
|
|
1071
1401
|
},
|
|
@@ -1085,6 +1415,17 @@ export function apply(ctx, config) {
|
|
|
1085
1415
|
async execute(args, exec) {
|
|
1086
1416
|
const agent = exec?.agent;
|
|
1087
1417
|
if (agent === undefined) return { ok: false, problems: [{ index: -1, id: "agent", subject: "", error: "这个工具需要在会话里调用" }], warnings: [], order: [] };
|
|
1418
|
+
// 引擎就绪门控(C-1):本工具由 T专家 自己注册,**即使团队引擎挂载失败也照常存在**。
|
|
1419
|
+
// 不拦的话,引擎不在时会返回「先用 t_team_create 建队」——而那个工具此刻根本不在表里,
|
|
1420
|
+
// 等于把模型指向一条死路。这里与 `/t`、系统提示段用同一个真机探测,口径一致。
|
|
1421
|
+
if (!engineReady()) {
|
|
1422
|
+
return {
|
|
1423
|
+
ok: false,
|
|
1424
|
+
problems: [{ index: -1, id: "engine", subject: "", error: `当前会话没有可用的团队引擎,t_team_plan_check 无法预检:${engineUnreadyDetail()}` }],
|
|
1425
|
+
warnings: [],
|
|
1426
|
+
order: [],
|
|
1427
|
+
};
|
|
1428
|
+
}
|
|
1088
1429
|
const workspace = agent.session?.header?.cwd ?? process.cwd();
|
|
1089
1430
|
const stateRoot = join(workspace, engineStateDir);
|
|
1090
1431
|
let team;
|
|
@@ -1143,12 +1484,17 @@ export function apply(ctx, config) {
|
|
|
1143
1484
|
order: 118,
|
|
1144
1485
|
text: (context) => {
|
|
1145
1486
|
if (context.agent?.session?.header?.parentSession !== undefined) return "";
|
|
1487
|
+
// 名册规模与批量上限都**现算**:写死会在名册变更 / 用户改 Config 之后对模型说谎。
|
|
1488
|
+
const scale = rosterScale(config.root);
|
|
1489
|
+
const scope = scale.experts > 0
|
|
1490
|
+
? `The parent session has T专家 — a ${scale.experts}-expert, ${scale.divisions}-division roster with full Chinese translations, exposed as summonable domain experts.`
|
|
1491
|
+
: "The parent session has T专家 — a curated roster of domain experts with full Chinese translations, exposed as summonable domain experts.";
|
|
1146
1492
|
return [
|
|
1147
1493
|
"## T专家 (T Expert) expert mode",
|
|
1148
|
-
|
|
1494
|
+
scope,
|
|
1149
1495
|
"Experts are individually enabled/disabled in the T专家 settings tab; ALL are disabled by default and a disabled expert cannot be summoned.",
|
|
1150
1496
|
"A composer selection inserts one enabled expert as a native reference chip; the remaining draft text is that expert's task.",
|
|
1151
|
-
|
|
1497
|
+
`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 ${config.maxSummonBatch}; partial results when some fail).`,
|
|
1152
1498
|
"For a long multi-step goal that needs a durable team (members, tasks with dependencies, review gates), the user can run a T专家 squad with the `/t <squad> <goal>` slash command (squad names and aliases come from the plugin's squad definitions; plain `/t` lists them); it activates the built-in T专家 team engine with a squad whose members already carry the experts' personas. Do not invent squad names when `/t` is available.",
|
|
1153
1499
|
].join("\n");
|
|
1154
1500
|
},
|