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/squads.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
/**
|
|
2
3
|
* T专家 小队数据层:读写 `teams.json`,并把改动编译成引擎配置。
|
|
3
4
|
*
|
|
@@ -5,10 +6,16 @@
|
|
|
5
6
|
* 编译交给既有的 `team-profiles.py`(它已内置成员名撞键预检、别名校验、人格注入)。
|
|
6
7
|
* 这样 UI 编辑器不需要复制一份校验逻辑,生成器仍是唯一权威。
|
|
7
8
|
*/
|
|
8
|
-
import { readFileSync,
|
|
9
|
-
import {
|
|
9
|
+
import { readFileSync, statSync, existsSync } from "node:fs";
|
|
10
|
+
import { execFile } from "node:child_process";
|
|
10
11
|
import { createHash } from "node:crypto";
|
|
11
12
|
import { dirname, join } from "node:path";
|
|
13
|
+
import { promisify } from "node:util";
|
|
14
|
+
|
|
15
|
+
import { writeFileAtomic } from "./catalog.js";
|
|
16
|
+
|
|
17
|
+
/** 异步编译器调用:**绝不能**用 execFileSync —— 那会同步阻塞整个 Host 事件循环。 */
|
|
18
|
+
const runFile = promisify(execFile);
|
|
12
19
|
|
|
13
20
|
/** profile key 只允许小写 ASCII + 数字 + 连字符(引擎按它生成命令别名)。 */
|
|
14
21
|
const KEY_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
@@ -18,6 +25,7 @@ function text(value) {
|
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
function fail(code, message, details = {}) {
|
|
28
|
+
/** @type {Error & { code?: string, details?: object }} */
|
|
21
29
|
const error = new Error(message);
|
|
22
30
|
error.code = code;
|
|
23
31
|
error.details = details;
|
|
@@ -44,14 +52,28 @@ function memberSlug(member) {
|
|
|
44
52
|
export const COMPILER_DEFAULT_MAX_MEMBERS = 8;
|
|
45
53
|
|
|
46
54
|
/**
|
|
47
|
-
* @param generator -
|
|
48
|
-
*
|
|
49
|
-
*
|
|
55
|
+
* @param generator - 小队编译器路径。调用方**只有传包内那份**才是安全且正确的接线:
|
|
56
|
+
* `lib/index.js` 现在**只**传 `<包>/data/team-profiles.py`,**不回退**数据目录副本。
|
|
57
|
+
* 为什么:数据目录那份是 bootstrap 播种的**用户可写**文件,让「保存小队」去执行它
|
|
58
|
+
* 等于一条同权限代码执行面,且它与包内版本的漂移无法校验(C-3)。
|
|
59
|
+
* 本函数保留「未传就取同目录 `team-profiles.py`」的默认,只是为了直连数据层的调用方
|
|
60
|
+
* (自检 / 面板预览脚本)不必显式传路径 —— **插件运行路径绝不走这个默认**。
|
|
50
61
|
* @param maxMembers - 成员上限(来自 `Config.maxMembers`)。只有编译器声明支持 `--max-members`
|
|
51
62
|
* 时才会真正生效;旧编译器把上限写死在源码里,此时会**记一条 warn** 并退回它的默认值,
|
|
52
63
|
* 而不是让保存小队在「改大上限」后必然编译失败并回滚(D-14)。
|
|
64
|
+
* @param onReload - 小队保存**编译成功之后**的热重载回调(由 `lib/index.js` 注入:它持有团队引擎的
|
|
65
|
+
* fiber,用新的 profiles 重挂引擎)。数据层不碰 `ctx`:这里只负责在正确时机敲门。
|
|
66
|
+
* 回调返回 `{ ok: true }` 表示重载成功;`{ ok: false, detail }` 表示重载失败(原因要能报给用户)。
|
|
67
|
+
* **没有注入时不算成功**:那与"重载失败"是同一个后果(盘上是新配置、引擎还在用旧的),
|
|
68
|
+
* 所以会记成 `lastReload.ok === false` 并带出明确原因,而不是静默当成功(见 `scheduleReload()`)。
|
|
69
|
+
* 编译失败并回滚时本回调**绝不**被调用:那时盘上还是旧配置,重载只会重新加载同一份旧配置
|
|
70
|
+
* (见 `save()`)。
|
|
71
|
+
* @param logger - 宿主 logger(`ctx.logger`,可选)。小队编译/重载的诊断**必须走这里**,
|
|
72
|
+
* 不能写 `console`:桌面与 Web 里 stderr 用户看不见,等于这条失败从来没被上报过
|
|
73
|
+
* (与 `catalog.js` 的 N-3 同一条口径)。
|
|
74
|
+
* **没有注入时也不写 console**:那正是「用户看不见」的老毛病,宁可静默也不能假装报了。
|
|
53
75
|
*/
|
|
54
|
-
export function createSquadService({ teamsFile, catalog, python = "python3", generator: generatorPath, maxMembers }) {
|
|
76
|
+
export function createSquadService({ teamsFile, catalog, python = "python3", generator: generatorPath, maxMembers, onReload, logger }) {
|
|
55
77
|
const dataDir = dirname(teamsFile);
|
|
56
78
|
const generator = typeof generatorPath === "string" && generatorPath !== ""
|
|
57
79
|
? generatorPath
|
|
@@ -64,7 +86,24 @@ export function createSquadService({ teamsFile, catalog, python = "python3", gen
|
|
|
64
86
|
const generatorSupportsMemberLimit = existsSync(generator)
|
|
65
87
|
? readFileSync(generator, "utf8").includes("--max-members")
|
|
66
88
|
: false;
|
|
89
|
+
/** 诊断一律走宿主 logger(见 `logger` 参数的说明):console 在 GUI 里无人可见。 */
|
|
90
|
+
const logError = (message) => logger?.error?.(`[t-team] ${message}`);
|
|
67
91
|
let lastBuild = { at: 0, ok: true, output: "" };
|
|
92
|
+
/** 正在跑的编译(见 `build()`):并发调用合并成同一次,避免共享的 `lastBuild` 被交错覆盖。 */
|
|
93
|
+
let buildInFlight;
|
|
94
|
+
/**
|
|
95
|
+
* 热重载的**串行尾**:连续快速保存时,后一次重载必须等前一次结束再开始。
|
|
96
|
+
*
|
|
97
|
+
* 不只是节流:引擎重挂期间它自己的 fiber 处于 unload→load 过渡,并发重挂会让两次
|
|
98
|
+
* dispose/apply 交叠,最后一次重挂会在「已卸载」的中间态上跑,留下半死的引擎。
|
|
99
|
+
* 用一条 promise 链把重载串起来,天然满足「重载中不得并发重载」。
|
|
100
|
+
*/
|
|
101
|
+
let reloadTail;
|
|
102
|
+
/**
|
|
103
|
+
* 最近一次**已完成**的重载结果(诊断用:设置页面板与系统提示段都读它)。
|
|
104
|
+
* 初值是 ok:true —— 「还没重载过」不等于失败。
|
|
105
|
+
*/
|
|
106
|
+
let lastReload = { at: 0, ok: true, detail: "" };
|
|
68
107
|
|
|
69
108
|
function readSpec() {
|
|
70
109
|
try {
|
|
@@ -152,6 +191,8 @@ export function createSquadService({ teamsFile, catalog, python = "python3", gen
|
|
|
152
191
|
engine: spec.engine ?? {},
|
|
153
192
|
defaults: spec.defaults ?? {},
|
|
154
193
|
lastBuild,
|
|
194
|
+
lastReload,
|
|
195
|
+
reloading: reloadTail !== undefined,
|
|
155
196
|
};
|
|
156
197
|
}
|
|
157
198
|
|
|
@@ -223,36 +264,101 @@ export function createSquadService({ teamsFile, catalog, python = "python3", gen
|
|
|
223
264
|
return { ...spec, profiles };
|
|
224
265
|
}
|
|
225
266
|
|
|
226
|
-
/**
|
|
267
|
+
/**
|
|
268
|
+
* 跑生成器:编译 t-team.config.json + teams.resolved.json(失败即回滚 teams.json)。
|
|
269
|
+
*
|
|
270
|
+
* **异步、且不得并发**(P-1):这里过去用 `execFileSync`,保存一次小队就把整个 Host 事件
|
|
271
|
+
* 循环同步阻塞(最长 60s)—— 同一进程里所有会话的 SSE、工具调用与 Web 请求全部停摆。
|
|
272
|
+
* 改成 `await execFile(...)` 之后事件循环继续转,但会引入原来不存在的交错点:两次 `build()`
|
|
273
|
+
* 可能并行运行,共享的 `lastBuild` 会被先完成的那次覆盖。所以用一条 in-flight promise 把
|
|
274
|
+
* 并发调用**合并成同一次**(语义上也更正确:同时刻的 teams.json 只该被编译一次)。
|
|
275
|
+
*
|
|
276
|
+
* @returns {Promise<{at:number, ok:boolean, output:string}>}
|
|
277
|
+
*/
|
|
227
278
|
function build() {
|
|
228
|
-
if (
|
|
229
|
-
|
|
279
|
+
if (buildInFlight !== undefined) return buildInFlight;
|
|
280
|
+
const run = (async () => {
|
|
281
|
+
if (!existsSync(generator)) {
|
|
282
|
+
lastBuild = { at: Date.now(), ok: false, output: `找不到生成器 ${generator}` };
|
|
283
|
+
return lastBuild;
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
// 上限随 Config 走;旧编译器(无 --max-members)退回它写死的默认值并明确告警。
|
|
287
|
+
if (memberLimit !== COMPILER_DEFAULT_MAX_MEMBERS && !generatorSupportsMemberLimit) {
|
|
288
|
+
lastBuild = {
|
|
289
|
+
at: Date.now(),
|
|
290
|
+
ok: true,
|
|
291
|
+
output: `[warn] 小队编译器不支持 --max-members(${generator}),成员上限仍按它内置的 ${COMPILER_DEFAULT_MAX_MEMBERS} 执行:`
|
|
292
|
+
+ `超过 ${COMPILER_DEFAULT_MAX_MEMBERS} 人的小队会编译失败,Config.maxMembers=${memberLimit} 不会生效。`
|
|
293
|
+
+ `要真正用上它,请把 data/team-profiles.py 更新到带 --max-members 的版本。`,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
const memberArgs = generatorSupportsMemberLimit ? ["--max-members", String(memberLimit)] : [];
|
|
297
|
+
// 参数与 execFileSync 时代逐项一致(数组传参、无 shell、60s 上限),只换执行方式。
|
|
298
|
+
const { stdout, stderr } = await runFile(python, [generator, "--teams", teamsFile, ...memberArgs], {
|
|
299
|
+
cwd: dataDir,
|
|
300
|
+
encoding: "utf8",
|
|
301
|
+
timeout: 60_000,
|
|
302
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
303
|
+
});
|
|
304
|
+
const parts = [stdout, stderr].map((part) => text(part).trim()).filter((part) => part !== "");
|
|
305
|
+
lastBuild = { at: Date.now(), ok: true, output: parts.join("\n").split("\n").slice(-6).join("\n") };
|
|
306
|
+
} catch (error) {
|
|
307
|
+
const output = [error?.stdout, error?.stderr].map((part) => text(part).trim()).filter((part) => part !== "").join("\n");
|
|
308
|
+
lastBuild = { at: Date.now(), ok: false, output: output === "" ? String(error) : output };
|
|
309
|
+
}
|
|
230
310
|
return lastBuild;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
311
|
+
})().finally(() => {
|
|
312
|
+
if (buildInFlight === run) buildInFlight = undefined;
|
|
313
|
+
});
|
|
314
|
+
buildInFlight = run;
|
|
315
|
+
return run;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* 引擎热重载:保存成功后,让运行中的引擎用**新的** profiles 重挂,无需重启 DSH。
|
|
320
|
+
*
|
|
321
|
+
* 为什么必须在这里做:`/t` 列表每次从磁盘读(`lib/command.js` 的 `readTeams`),而引擎的
|
|
322
|
+
* profiles 是挂载期快照 —— 两者过去只在「重启」这一个点上重新对齐。保存是唯一让磁盘
|
|
323
|
+
* 配置变化的地方,所以在**编译成功之后**敲门重载是恢复一致性的正确时机。
|
|
324
|
+
*
|
|
325
|
+
* 三条不可动摇的语义:
|
|
326
|
+
* 1. 编译失败并回滚 → **不调用**(见 `save()`:回滚分支在调用点之前就抛了);
|
|
327
|
+
* 2. 重载失败**不改抛**——盘上已经是新配置,抛错会让用户以为「保存没生效」而不停重试;
|
|
328
|
+
* 失败原因通过返回值进 `lastReload`、日志与系统提示段,用户/模型都看得到;
|
|
329
|
+
* 3. 重载串行化:并发保存不会产生并发重挂。
|
|
330
|
+
*/
|
|
331
|
+
function scheduleReload() {
|
|
332
|
+
const run = (reloadTail ?? Promise.resolve()).then(async () => {
|
|
333
|
+
if (typeof onReload !== "function") {
|
|
334
|
+
// **绝不能静默当成功**:没有注入重载方意味着「保存生效了、引擎还用着旧 profiles」,
|
|
335
|
+
// 与"重载失败"是同一个可观察后果。记成失败(lastReload.ok=false + 原因),
|
|
336
|
+
// 系统提示段与 lastReload 都会带出去,用户/模型复述得到的是真实状态。
|
|
337
|
+
// 唯一的合法场景是单测直接构造数据层(此时没人会读 lastReload)。
|
|
338
|
+
lastReload = {
|
|
236
339
|
at: Date.now(),
|
|
237
|
-
ok:
|
|
238
|
-
|
|
239
|
-
+ `超过 ${COMPILER_DEFAULT_MAX_MEMBERS} 人的小队会编译失败,Config.maxMembers=${memberLimit} 不会生效。`
|
|
240
|
-
+ `要真正用上它,请把 data/team-profiles.py 更新到带 --max-members 的版本。`,
|
|
340
|
+
ok: false,
|
|
341
|
+
detail: "没有注入热重载回调(onReload):这次保存已写入磁盘,但引擎仍在使用上一次的小队配置,需要重载插件或重启 DSH 才能用新小队建队。",
|
|
241
342
|
};
|
|
343
|
+
logError(lastReload.detail);
|
|
344
|
+
return;
|
|
242
345
|
}
|
|
243
|
-
const
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
}
|
|
255
|
-
|
|
346
|
+
const outcome = await onReload();
|
|
347
|
+
const ok = outcome?.ok !== false;
|
|
348
|
+
lastReload = { at: Date.now(), ok, detail: text(outcome?.detail) };
|
|
349
|
+
if (!ok) {
|
|
350
|
+
// 失败必须响:重载失败意味着"保存生效了、但运行中的引擎还用着旧 profiles"。
|
|
351
|
+
logError(`小队已保存,但团队引擎热重载失败:${lastReload.detail}`);
|
|
352
|
+
}
|
|
353
|
+
}).catch((error) => {
|
|
354
|
+
// 注入方自己抛错(契约违背)也要留住,不能让保存报告成"一切正常"。
|
|
355
|
+
lastReload = { at: Date.now(), ok: false, detail: String(error) };
|
|
356
|
+
logError(`小队已保存,但团队引擎热重载异常:${lastReload.detail}`);
|
|
357
|
+
}).finally(() => {
|
|
358
|
+
if (reloadTail === run) reloadTail = undefined;
|
|
359
|
+
});
|
|
360
|
+
reloadTail = run;
|
|
361
|
+
return run;
|
|
256
362
|
}
|
|
257
363
|
|
|
258
364
|
async function save(squads, expectedRevision) {
|
|
@@ -266,19 +372,32 @@ export function createSquadService({ teamsFile, catalog, python = "python3", gen
|
|
|
266
372
|
}
|
|
267
373
|
const previousText = readFileSync(teamsFile, "utf8");
|
|
268
374
|
const nextSpec = serialize(readSpec(), squads);
|
|
269
|
-
|
|
270
|
-
|
|
375
|
+
// 原子写(P-2):先写临时文件再 rename。进程若在写入中途被杀,原来会留下**截断的**
|
|
376
|
+
// teams.json,`readSpec()` 随即抛 `tTeam/squads-unreadable`,设置页「小队」标签整体不可用。
|
|
377
|
+
await writeFileAtomic(teamsFile, `${JSON.stringify(nextSpec, null, 2)}\n`);
|
|
378
|
+
const built = await build();
|
|
271
379
|
if (!built.ok) {
|
|
272
|
-
|
|
380
|
+
await writeFileAtomic(teamsFile, previousText); // 回滚,别把坏定义留在盘上(同样原子)
|
|
381
|
+
// 回滚之后**不重载**:盘上是旧配置,重载只会重新加载同一份旧配置,白跑一次还可能
|
|
382
|
+
// 让「保存失败」看起来像「保存成功」。下一次成功保存会重新对齐。
|
|
273
383
|
throw fail("tTeam/squads-build-failed", `配置生成失败,已回滚 teams.json:\n${built.output}`, {
|
|
274
384
|
actualRevision: revisionOf(),
|
|
275
385
|
});
|
|
276
386
|
}
|
|
277
|
-
|
|
387
|
+
// 编译成功 = 新配置已经落在盘上(`build()` 写的正是引擎启动时读的那份),现在敲门重载。
|
|
388
|
+
const settled = scheduleReload();
|
|
389
|
+
const result = await list();
|
|
390
|
+
// 有重载还在跑时(可能是本次的,也可能是上一次的尾巴)就等它结束:这样本次保存返回的
|
|
391
|
+
// `lastReload` 才名副其实,调用方(自检探针 / 未来的面板)能直接读到「这次保存到底重载
|
|
392
|
+
// 成功没有」。等待期间系统提示段已经把真相写出来了,不会误导。
|
|
393
|
+
await settled;
|
|
394
|
+
return { ...result, lastReload, reloading: reloadTail !== undefined };
|
|
278
395
|
}
|
|
279
396
|
|
|
280
397
|
return {
|
|
281
398
|
list, save, validate, revision: revisionOf, generator, dataDir,
|
|
399
|
+
// 本服务没有 `dispose()`:它只在 `save()` 调用期间持有状态(重载串行尾由 save() 自己 await
|
|
400
|
+
// 干净),两次调用之间是无状态的,所以宿主卸载时没有需要收尾的异步工作。
|
|
282
401
|
// 让调用方(与自检)看得到「这次编译用的是哪个上限、编译器到底认不认这个参数」。
|
|
283
402
|
memberLimit,
|
|
284
403
|
generatorSupportsMemberLimit,
|
|
@@ -16,6 +16,32 @@ function boundary(runtime) {
|
|
|
16
16
|
function unsupported(detail) {
|
|
17
17
|
throw new Error(`t-team: unsupported Harness subagent contract (${detail}); use an explicitly tested Harness version and a coherent dependency installation`);
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* 这个兼容层**实测过**的 Harness 版本(A-1/E-1)。
|
|
21
|
+
*
|
|
22
|
+
* 为什么要写出来:`guardSubagentDelivery` 会覆写共享的 `ctx.subagents` 服务,用的两个
|
|
23
|
+
* `Symbol.for` 与调用签名都来自**未文档化的上游内部实现**。上游一旦改动,症状是"团队功能
|
|
24
|
+
* 突然不可用(引擎降级)",没有这张表就只能靠猜。升级 Harness 后**必须**把这里改成
|
|
25
|
+
* "已实测通过"再发布,未知版本会走 `unsupported()` 明确报错(而不是半死)。
|
|
26
|
+
*/
|
|
27
|
+
export const TESTED_HARNESS_VERSIONS = ['0.1.5-rc.1', '0.1.5-rc.2'];
|
|
28
|
+
/** 单个属性能否被安全覆写:可写数据属性、有 setter 的访问器,或对象可扩展时的新属性。 */
|
|
29
|
+
function canAssign(object, key) {
|
|
30
|
+
const descriptor = Object.getOwnPropertyDescriptor(object, key);
|
|
31
|
+
if (descriptor === undefined)
|
|
32
|
+
return Object.isExtensible(object);
|
|
33
|
+
if ('value' in descriptor)
|
|
34
|
+
return descriptor.writable === true;
|
|
35
|
+
return typeof descriptor.set === 'function';
|
|
36
|
+
}
|
|
37
|
+
/** 覆写前先确认**每一个**目标属性都可写,否则响亮失败(避免 strict mode 下抛裸 TypeError)。 */
|
|
38
|
+
function assertAssignable(object, keys) {
|
|
39
|
+
const blocked = keys.filter((key) => !canAssign(object, key));
|
|
40
|
+
if (blocked.length > 0) {
|
|
41
|
+
const names = blocked.map((key) => (typeof key === 'symbol' ? key.toString() : key)).join(', ');
|
|
42
|
+
return unsupported(`subagent service properties are not writable (${names}); upstream may have frozen or accessor-ized the service`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
19
45
|
/** Read child-owned history, excluding any descriptor inherited from a parent. */
|
|
20
46
|
export function sessionOwnEvents(session) {
|
|
21
47
|
const current = session;
|
|
@@ -116,6 +142,16 @@ export function guardSubagentDelivery(ctx, isRetired) {
|
|
|
116
142
|
if (typeof legacy !== 'function' && ((typeof queue !== 'function' && typeof deliver !== 'function') || typeof send !== 'function')) {
|
|
117
143
|
return unsupported('cannot install complete retired-member guard');
|
|
118
144
|
}
|
|
145
|
+
// A-1/E-1:**先探测可写性再动手**。过去直接赋值,若上游把服务冻结(`Object.freeze`)
|
|
146
|
+
// 或改成只有 getter 的访问器,strict mode 下这里会抛一个与"版本不支持"毫无关系的裸
|
|
147
|
+
// TypeError;现在会得到明确原因。同时这也是一次"契约变了"的早期信号。
|
|
148
|
+
const targets = [
|
|
149
|
+
...(typeof legacy === 'function' ? ['followup'] : []),
|
|
150
|
+
...(typeof queue === 'function' ? [hostPromptQueue] : []),
|
|
151
|
+
...(typeof deliver === 'function' ? [hostPromptDeliver] : []),
|
|
152
|
+
...(typeof send === 'function' ? ['sendMessage'] : []),
|
|
153
|
+
];
|
|
154
|
+
assertAssignable(host, targets);
|
|
119
155
|
ctx.effect(() => {
|
|
120
156
|
const descriptors = new Map([
|
|
121
157
|
['followup', Object.getOwnPropertyDescriptor(host, 'followup')],
|
package/lib/teams/index.js
CHANGED
|
@@ -111,6 +111,12 @@ export function apply(ctx, config) {
|
|
|
111
111
|
maxMembers: config.maxMembers ?? 8,
|
|
112
112
|
profiles: config.profiles ?? {},
|
|
113
113
|
};
|
|
114
|
+
// Hot reload: a squad saved in the settings tab re-applies this plugin with a new
|
|
115
|
+
// `profiles` object (the host updates the fiber's config instead of restarting the
|
|
116
|
+
// harness). Every read below must therefore go through `resolved.profiles` — the
|
|
117
|
+
// per-apply snapshot — and never through a module-level constant captured once at
|
|
118
|
+
// startup: that was the "edit a squad, restart DSH" behaviour this exists to remove.
|
|
119
|
+
const profiles = () => resolved.profiles;
|
|
114
120
|
// Provider registration is a sibling plugin's effect (`subagent-spawn` /
|
|
115
121
|
// `subagent-fork` rows), which can land after this mount under the Loader's
|
|
116
122
|
// concurrent activation — so capability validation happens at the first
|
|
@@ -123,7 +129,9 @@ export function apply(ctx, config) {
|
|
|
123
129
|
order: config.promptSectionOrder,
|
|
124
130
|
// Keep the bounded profile directory available without extra tool calls.
|
|
125
131
|
// installTeamCapabilities snapshots this once; no business state rewrites it.
|
|
126
|
-
|
|
132
|
+
// resolved.profiles is this apply's snapshot, so a hot reload rebuilds the
|
|
133
|
+
// captain prompt with the new squad directory instead of a stale closure.
|
|
134
|
+
captainPrompt: () => usageSectionText(TEAM_TOOL_NAMES.join(', '), formatProfilesForPrompt(profiles())),
|
|
127
135
|
});
|
|
128
136
|
// Deterministic activation surfaces: the closed-namespace `/t-team`
|
|
129
137
|
// host command (surfaces in the Web GUI slash menu via the Harness
|
|
@@ -137,9 +145,9 @@ export function apply(ctx, config) {
|
|
|
137
145
|
// never pends on it and simply never gains the slash command.
|
|
138
146
|
if (config.slashCommand ?? true) {
|
|
139
147
|
ctx.inject(['commands'], (commandCtx) => {
|
|
140
|
-
registerTTeamCommand(commandCtx,
|
|
148
|
+
registerTTeamCommand(commandCtx, profiles);
|
|
141
149
|
});
|
|
142
|
-
installTTeamGestureBoundary(ctx,
|
|
150
|
+
installTTeamGestureBoundary(ctx, profiles);
|
|
143
151
|
}
|
|
144
152
|
// The activity panel data/artwork routes need the Web server and the
|
|
145
153
|
// workspace registry, which headless profiles do not mount; under
|
package/lib/teams/members.js
CHANGED
|
@@ -70,7 +70,7 @@ export function selectFallbackRoute(current, fallback, failureCode, alreadySwitc
|
|
|
70
70
|
return { retry: true, switched: true, selection: fallback };
|
|
71
71
|
}
|
|
72
72
|
/** Deliver a durable member report to the live captain at its next model step. */
|
|
73
|
-
export function steerCaptainReport(captain, from, content) {
|
|
73
|
+
export function steerCaptainReport(captain, from, content, logger) {
|
|
74
74
|
try {
|
|
75
75
|
captain.steer(createUserMessage({
|
|
76
76
|
content: [{ type: 'text', text: `T Team message from member ${from}:\n\n${content}` }],
|
|
@@ -78,7 +78,10 @@ export function steerCaptainReport(captain, from, content) {
|
|
|
78
78
|
}));
|
|
79
79
|
return true;
|
|
80
80
|
}
|
|
81
|
-
catch {
|
|
81
|
+
catch (error) {
|
|
82
|
+
// 投递失败必须留痕:调用方只拿到一个布尔值(true=实时投递 / false=回落持久信箱),
|
|
83
|
+
// 不留日志就只能靠猜「这次为什么走了信箱」。steer 的异常根因在这里唯一可见。
|
|
84
|
+
logger?.warn?.(`[t-team] 向队长实时投递消息失败,已回落到持久信箱:${error instanceof Error ? error.message : String(error)}`);
|
|
82
85
|
return false;
|
|
83
86
|
}
|
|
84
87
|
}
|
|
@@ -138,7 +141,7 @@ export async function failMemberOpenAttempt(ctx, stateRoot, teamId, memberName,
|
|
|
138
141
|
// Use the same lease/acknowledgment contract as send_message, outside the
|
|
139
142
|
// team lock: steering can synchronously start another agent turn.
|
|
140
143
|
const captain = ctx.agents.get(brandedSessionId(prepared.captainSessionId));
|
|
141
|
-
const delivered = captain !== undefined && steerCaptainReport(captain, memberName, prepared.message.content);
|
|
144
|
+
const delivered = captain !== undefined && steerCaptainReport(captain, memberName, prepared.message.content, ctx.logger);
|
|
142
145
|
await withTeamLock(lockKey, () => delivered
|
|
143
146
|
? acknowledgeMailbox(stateRoot, teamId, CAPTAIN_KEY, [prepared.message.id])
|
|
144
147
|
: releaseMailboxDelivery(stateRoot, teamId, CAPTAIN_KEY, [prepared.message.id]));
|
|
@@ -586,16 +589,22 @@ export function installRetiredMemberGuard(ctx, stateDir) {
|
|
|
586
589
|
* rc.8 changed those rows to branded `SessionId` values plus residency-only
|
|
587
590
|
* `activity`; neither is needed to answer whether the live Agent driver is
|
|
588
591
|
* running, idle, or absent/ready.
|
|
589
|
-
* @param ctx - the plugin context (
|
|
592
|
+
* @param ctx - the plugin context (only needs `ctx.get`; `agents` itself is optional).
|
|
590
593
|
* @param memberIds - child ids restored from the durable team record.
|
|
591
594
|
* @returns child id → live activity.
|
|
592
595
|
*/
|
|
593
596
|
export function memberActivity(ctx, memberIds) {
|
|
594
597
|
const activity = new Map();
|
|
598
|
+
// `agents` 是**可选服务**:宿主侧静态 `inject` 只有 tools/subagents/systemPrompt,所以
|
|
599
|
+
// 属性访问 `ctx.agents` 在那种 ctx 上会抛 `cannot get property "agents" without inject`,
|
|
600
|
+
// 而调用方(团队面板的快照收集)用 try/catch 把它吞成「skipped unreadable team state」——
|
|
601
|
+
// 整支队伍从面板消失,只留一句没有原因可查的告警(2026-09-13 查明)。规则与
|
|
602
|
+
// lib/index.js 里 `ctx.get("agents")` 一致:可选服务必须惰查,缺了降级成 ready。
|
|
603
|
+
const agents = typeof ctx?.get === 'function' ? ctx.get('agents') : undefined;
|
|
595
604
|
for (const id of memberIds) {
|
|
596
605
|
if (id === '')
|
|
597
606
|
continue;
|
|
598
|
-
const live =
|
|
607
|
+
const live = agents?.get?.(brandedSessionId(id));
|
|
599
608
|
activity.set(id, live === undefined ? 'ready' : live.status);
|
|
600
609
|
}
|
|
601
610
|
return activity;
|
package/lib/teams/snapshot.js
CHANGED
|
@@ -147,8 +147,11 @@ export async function collectTeamsActivity(ctx, roots) {
|
|
|
147
147
|
continue;
|
|
148
148
|
snapshots.push(await assembleTeamSnapshot(ctx, root.stateRoot, root.workspace, state));
|
|
149
149
|
}
|
|
150
|
-
catch {
|
|
151
|
-
|
|
150
|
+
catch (error) {
|
|
151
|
+
// 必须带上根因:裸 `catch {` 只留一句「unreadable」,解析失败、权限、服务缺失、
|
|
152
|
+
// 并发读全都会变成同一句无法诊断的告警(2026-09-13 在真实宿主日志里就撞上一条
|
|
153
|
+
// 谁也没法解释的 "skipped unreadable team state",根因正是被这里吞掉的)。
|
|
154
|
+
ctx.logger.warn(`t-team: skipped unreadable team state "${entry.name}" in workspace "${root.workspace}": ${String(error)}`);
|
|
152
155
|
}
|
|
153
156
|
}
|
|
154
157
|
}
|
|
@@ -172,8 +175,8 @@ export async function collectArchivedTeamsActivity(ctx, roots) {
|
|
|
172
175
|
continue;
|
|
173
176
|
snapshots.push(await assembleTeamSnapshot(ctx, join(root.stateRoot, 'archive'), root.workspace, state, { includeRemoved: true, historic: true }));
|
|
174
177
|
}
|
|
175
|
-
catch {
|
|
176
|
-
ctx.logger.warn(`t-team: skipped unreadable archived team "${teamId}" in workspace "${root.workspace}"`);
|
|
178
|
+
catch (error) {
|
|
179
|
+
ctx.logger.warn(`t-team: skipped unreadable archived team "${teamId}" in workspace "${root.workspace}": ${String(error)}`);
|
|
177
180
|
}
|
|
178
181
|
}
|
|
179
182
|
}
|
package/lib/teams/tools.js
CHANGED
|
@@ -115,45 +115,62 @@ function trimmedOptional(value) {
|
|
|
115
115
|
const trimmed = value?.trim();
|
|
116
116
|
return trimmed === undefined || trimmed === '' ? undefined : trimmed;
|
|
117
117
|
}
|
|
118
|
-
/**
|
|
119
|
-
function
|
|
118
|
+
/** Collect every staged-graph problem: empty subject, bad assignee, unknown/self dependency, cycles. */
|
|
119
|
+
function collectStagedGraphProblems(team, requireRunnable) {
|
|
120
|
+
const problems = [];
|
|
120
121
|
const members = team.members.filter((member) => member.status !== 'removed');
|
|
121
122
|
if (requireRunnable && members.length === 0)
|
|
122
|
-
|
|
123
|
+
problems.push('add at least one member before approving the plan');
|
|
123
124
|
if (requireRunnable && team.tasks.length === 0)
|
|
124
|
-
|
|
125
|
+
problems.push('add at least one task before approving the plan');
|
|
125
126
|
const memberNames = new Set(members.map((member) => member.name));
|
|
126
127
|
const taskIds = new Set(team.tasks.map((task) => task.id));
|
|
127
128
|
for (const task of team.tasks) {
|
|
128
|
-
if (task.subject.trim() === '')
|
|
129
|
-
|
|
130
|
-
if (task.assignee !== undefined && task.assignee !== CAPTAIN_KEY && !memberNames.has(task.assignee)) {
|
|
131
|
-
|
|
129
|
+
if (String(task.subject ?? '').trim() === '')
|
|
130
|
+
problems.push(`task "${task.id}" must have a subject`);
|
|
131
|
+
if (task.assignee !== undefined && task.assignee !== null && task.assignee !== CAPTAIN_KEY && !memberNames.has(task.assignee)) {
|
|
132
|
+
problems.push(`task "${task.id}" assignee "${task.assignee}" is not an active member`);
|
|
132
133
|
}
|
|
133
|
-
for (const dependency of task.dependencies) {
|
|
134
|
+
for (const dependency of task.dependencies ?? []) {
|
|
134
135
|
if (dependency === task.id)
|
|
135
|
-
|
|
136
|
-
if (!taskIds.has(dependency))
|
|
137
|
-
|
|
136
|
+
problems.push(`task "${task.id}" cannot depend on itself`);
|
|
137
|
+
else if (!taskIds.has(dependency))
|
|
138
|
+
problems.push(`task "${task.id}" depends on unknown task "${dependency}"`);
|
|
138
139
|
}
|
|
139
140
|
}
|
|
140
141
|
const visiting = new Set();
|
|
141
142
|
const visited = new Set();
|
|
142
143
|
const byId = new Map(team.tasks.map((task) => [task.id, task]));
|
|
143
|
-
const visit = (taskId) => {
|
|
144
|
-
if (visiting.has(taskId))
|
|
145
|
-
|
|
144
|
+
const visit = (taskId, trail) => {
|
|
145
|
+
if (visiting.has(taskId)) {
|
|
146
|
+
problems.push(`task dependency graph contains a cycle: ${[...trail, taskId].join(' → ')}`);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
146
149
|
if (visited.has(taskId))
|
|
147
150
|
return;
|
|
148
151
|
visiting.add(taskId);
|
|
149
152
|
for (const dependency of byId.get(taskId)?.dependencies ?? [])
|
|
150
|
-
visit(dependency);
|
|
153
|
+
visit(dependency, [...trail, taskId]);
|
|
151
154
|
visiting.delete(taskId);
|
|
152
155
|
visited.add(taskId);
|
|
153
156
|
};
|
|
154
157
|
for (const task of team.tasks)
|
|
155
|
-
visit(task.id);
|
|
158
|
+
visit(task.id, []);
|
|
159
|
+
return problems;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Validate references and cycles before a staged graph can be saved or run.
|
|
163
|
+
*
|
|
164
|
+
* 抛**第一条**问题(调用方的门禁语义:越早拒绝越好)。需要"一次报全部"的调用方
|
|
165
|
+
* (插件侧的 `t_team_plan_check` 只读预检)用导出的 `collectStagedGraphProblems`,
|
|
166
|
+
* 不要自己复刻规则 —— 两处规则一旦漂移,预检就会说 ok 而引擎拒绝。
|
|
167
|
+
*/
|
|
168
|
+
function validateStagedGraph(team, requireRunnable) {
|
|
169
|
+
const problems = collectStagedGraphProblems(team, requireRunnable);
|
|
170
|
+
if (problems.length > 0)
|
|
171
|
+
throw new Error(problems[0]);
|
|
156
172
|
}
|
|
173
|
+
export { collectStagedGraphProblems };
|
|
157
174
|
function memberOpenTask(team, memberName, exceptTaskId) {
|
|
158
175
|
return team.tasks.find(task => task.id !== exceptTaskId
|
|
159
176
|
&& task.assignee === memberName
|
|
@@ -1687,7 +1704,7 @@ export function registerTTeamTools(ctx, config) {
|
|
|
1687
1704
|
if (prepared.kind === 'captain') {
|
|
1688
1705
|
let delivered = 'mailbox';
|
|
1689
1706
|
if (captain !== undefined && prepared.identity.kind === 'member') {
|
|
1690
|
-
delivered = steerCaptainReport(captain, prepared.from, args.content) ? 'live' : 'mailbox';
|
|
1707
|
+
delivered = steerCaptainReport(captain, prepared.from, args.content, ctx.logger) ? 'live' : 'mailbox';
|
|
1691
1708
|
}
|
|
1692
1709
|
if (delivered === 'live') {
|
|
1693
1710
|
await withTeamLock(teamLockKey(stateRoot, prepared.fresh.id), () => (acknowledgeMailbox(stateRoot, prepared.fresh.id, CAPTAIN_KEY, [prepared.message.id])));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-t-expert",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "T Expert — a 316-expert, 22-division roster with full Chinese translations, as a standalone DeepSeek Harness plugin, with a built-in multi-agent team engine (T Team).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -67,10 +67,11 @@
|
|
|
67
67
|
}
|
|
68
68
|
},
|
|
69
69
|
"scripts": {
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
"
|
|
73
|
-
"
|
|
70
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
71
|
+
"build": "node tools/build-client.mjs",
|
|
72
|
+
"verify": "node tools/verify.mjs",
|
|
73
|
+
"sync-data": "node tools/sync-data.mjs",
|
|
74
|
+
"prepublishOnly": "npm run typecheck && npm run build && npm run verify && node tools/sync-data.mjs --check"
|
|
74
75
|
},
|
|
75
76
|
"peerDependencies": {
|
|
76
77
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
@@ -147,7 +148,6 @@
|
|
|
147
148
|
"@deepseek-ai/dsh-attachment": "^0.1.5-rc.1",
|
|
148
149
|
"@deepseek-ai/dsh-client-connection": "0.1.5-rc.1",
|
|
149
150
|
"@deepseek-ai/dsh-client-locale": "0.1.5-rc.1",
|
|
150
|
-
"@deepseek-ai/dsh-client-runtime": "0.1.1-rc.2",
|
|
151
151
|
"@deepseek-ai/dsh-client-ui-input-trigger": "0.1.5-rc.1",
|
|
152
152
|
"@deepseek-ai/dsh-client-ui-primitives": "0.1.5-rc.1",
|
|
153
153
|
"@deepseek-ai/dsh-client-ui-settings": "0.1.5-rc.1",
|
|
@@ -164,9 +164,11 @@
|
|
|
164
164
|
"@deepseek-ai/dsh-util-time": "^0.1.5-rc.1",
|
|
165
165
|
"@deepseek-ai/dsh-util-values": "^0.1.5-rc.1",
|
|
166
166
|
"@deepseek-ai/schemastery": "^3.18.2",
|
|
167
|
+
"@types/node": "^22.20.2",
|
|
167
168
|
"esbuild": "^0.25.0",
|
|
168
169
|
"react": "^18.3.1",
|
|
169
|
-
"react-dom": "^18.3.1"
|
|
170
|
+
"react-dom": "^18.3.1",
|
|
171
|
+
"typescript": "^5.9.3"
|
|
170
172
|
},
|
|
171
173
|
"dependencies": {
|
|
172
174
|
"zod": "^4.4.3"
|
|
@@ -65,6 +65,33 @@ dsh --profile web --dump-config # 看本机实际启动的树;打印出的
|
|
|
65
65
|
|
|
66
66
|
**应用启动只有一条路**:`dsh` CLI + 具名 profile(`dsh web` 是 `--profile web` 的别名)。package bin、demo、public SDK argv 直接拼 Cordis 树都是禁止的,`scripts/verify-application-entrypoints.ts` 会拒绝。
|
|
67
67
|
|
|
68
|
+
## 依赖语义:决定「等待」还是「挂起」
|
|
69
|
+
|
|
70
|
+
`inject` 与 `ctx.get` 不是两种风格,而是**两种契约**(官方 `develop/framework/service` 有权威表述,本仓库同一套语义):
|
|
71
|
+
|
|
72
|
+
- **`inject = ['x']` = 必需依赖**。框架保证 `apply` 执行时声明的服务**已全部就绪**;缺一个,插件就**等着、不执行**。
|
|
73
|
+
→ 这就是 **silent PENDING**:插件看起来"没加载",而且**它自己不产生任何日志**。排查时先怀疑这里,别先怀疑业务代码。
|
|
74
|
+
- **省略 `inject`、用 `ctx.get('x')` = 可选依赖**,但**必须在使用点调用**,不能在 `apply` 里探一次:
|
|
75
|
+
`apply` 是最早也最脆弱的时刻,提供方晚到的话那次探测已经返回 `undefined`,**没有任何机制会重跑它**。
|
|
76
|
+
(真实事故:把可选服务的 `ctx.get` 写在 apply 期做一次性探测 → 服务晚到时永久判负 → 设置段不注册、面板空白。)
|
|
77
|
+
- 运行期必需服务**消失** → 依赖它的插件自动 dispose;服务**回来** → 自动重新加载。所以「注册即 effect」是生命周期要求,不是代码风格。
|
|
78
|
+
- 需要"等某个服务就绪再做事"时用 `ctx.inject(['x'], cb)`:它等的是"服务就绪"这件事本身,服务迟到也能补上。
|
|
79
|
+
|
|
80
|
+
## 官网文档:面向插件作者的同一份知识
|
|
81
|
+
|
|
82
|
+
`https://deepseek-harness.github.io/deepseek-harness/` 是本仓库的**公开面**(`website/` 的 VitePress 投影),适合快速核对启动模型、依赖语义与钩子,再去读源码:
|
|
83
|
+
|
|
84
|
+
| 页面 | 用途 |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `guide/quickstart` | Web UI 起步:起服务、加工作区、配模型(新用户从这里开始) |
|
|
87
|
+
| `develop/basic/` | 最小插件、`scratch-plugin/cordis.yml` 的 `- insert:` 行、**插件路径必须是绝对路径** |
|
|
88
|
+
| `develop/basic/tool` · `config` · `publish` | 工具注册;受校验的 `Config`;`dsh.bundle` vs `dsh.profile` + `dsh plugin add` |
|
|
89
|
+
| `develop/framework/` · `service` · `events` | 生命周期/effect、服务与依赖语义、事件系统 |
|
|
90
|
+
| `develop/practice/` · `llm-adapter` · `dynamic-cordis` | 能力三角色分层、模型适配器、在运行中的智能体里改插件 |
|
|
91
|
+
| `develop/cordis-tutorial/01…07` | Cordis 阶梯,终点是「进入 harness」 |
|
|
92
|
+
|
|
93
|
+
**边界(重要)**:站点**不覆盖** `vitest` / `oxlint` / `run-gates.ts` / 覆盖率策略、`.agents/notes/` 约定、生成的子系统页 —— 所以**概念查站点、门禁与契约查检出**。冲突时以检出为准(站点由本仓库生成,冲突本身值得上报)。
|
|
94
|
+
|
|
68
95
|
## 核心包(spine)
|
|
69
96
|
|
|
70
97
|
| 包 | 拥有 | ctx key |
|
|
@@ -21,7 +21,13 @@ description: T专家 运维台 —— 316 位专家 / 22 分区的名册增删
|
|
|
21
21
|
正文里不写死位数,因为每加一位专家它就会过期。
|
|
22
22
|
4. **数据更新不用重装、不用重启**(宿主按 mtime 指纹自动重载名册);**改插件代码**才需要
|
|
23
23
|
`tz.sh build` → `tz.sh install` → 重启 DSH Desktop。
|
|
24
|
-
5.
|
|
24
|
+
5. **小队改完分两条路,只有设置页那条会热重载**:
|
|
25
|
+
- **设置页「队伍」保存**(推荐):`squads.save()` 编译成功后**就地热重载引擎配置 → 立即生效、不用重启**。
|
|
26
|
+
- **`tz.sh squads`(= 菜单 11)**:只是 CLI 直跑编译器(`python3 data/team-profiles.py`)把 `teams.json`
|
|
27
|
+
编译成引擎配置,**不经过插件、不会热重载**——运行中的引擎仍用旧 profiles,**必须重启 DSH**
|
|
28
|
+
(或再到设置页保存一次)才生效。所以别对用户说「跑完 tz.sh squads 就已经生效」。
|
|
29
|
+
两条路的失败语义一致:编译失败会**回滚**,盘上仍是旧配置(按旧小队建队)。
|
|
30
|
+
只有**改插件代码**才需要重启(见第 4 条)。
|
|
25
31
|
6. **`data/zh/` 已冻结**:不要新增或"补译"中文。新专家没有中文名/简介会回退英文,这是预期行为。
|
|
26
32
|
7. **破坏性动作先确认**:删除专家、卸载插件、发布 npm 之前,把影响讲给用户听,得到明确同意再执行。
|
|
27
33
|
|
|
@@ -78,7 +84,10 @@ bash "{{TZ}}" experts stats
|
|
|
78
84
|
```bash
|
|
79
85
|
bash "{{TZ}}" squads
|
|
80
86
|
```
|
|
81
|
-
3.
|
|
87
|
+
3. 明确告诉用户:**`tz.sh squads` 只是 CLI 直跑编译器**——它把 `teams.json` 编译成引擎配置,
|
|
88
|
+
**不会让运行中的插件重载**。所以:**要让新小队真正生效,需重启 DSH Desktop**(或再到设置页「队伍」保存一次,
|
|
89
|
+
那条路才会就地热重载、立即生效)。若 `/t` 里仍看不到新小队,先确认编译没报错(编译失败会回滚,盘上仍是旧配置)。
|
|
90
|
+
4. 想免重启就直接走设置页:设置 → T专家 → 队伍 → 改完保存(`squads.save()` 编译成功后触发引擎热重载,立即生效)。
|
|
82
91
|
|
|
83
92
|
### F. 装机(只在改过插件代码时需要)
|
|
84
93
|
|