dsh-plugin-t-expert 0.3.46 → 0.3.70
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/client.js +3429 -3254
- package/lib/index.js +43 -16
- package/lib/remote-schemas.js +1 -1
- package/lib/squads.js +42 -1
- package/lib/teams/index.js +23 -9
- package/lib/teams/members.js +4 -1
- package/lib/teams/quality-gates.js +57 -2
- package/lib/teams/scheduler.js +18 -3
- package/lib/teams/tools.js +32 -3
- package/package.json +16 -3
- package/data/.t-team-snapshot.json +0 -10
package/lib/index.js
CHANGED
|
@@ -158,14 +158,21 @@ export const SETTINGS_NAMESPACE = NS;
|
|
|
158
158
|
export const UNKNOWN_SLUG_ERROR_CODE = "tTeam/unknown-slug";
|
|
159
159
|
|
|
160
160
|
|
|
161
|
+
/** 「默认全部启用」的当前策略版本;把它 +1 即对所有用户重新播种一次(见 seedDefaultEnabled)。 */
|
|
162
|
+
const ENABLED_SEED_VERSION = 1;
|
|
163
|
+
|
|
161
164
|
const settingsSchema = schema.object({
|
|
162
165
|
enabled: schema.array(schema.string()).default([]),
|
|
166
|
+
/** 兼容位:早期实现用的「已播种」布尔标记。幂等判据已改用 enabledSeedVersion,保留它只为不弄坏已写入的旧数据。 */
|
|
167
|
+
enabledSeeded: schema.boolean().default(false),
|
|
163
168
|
/**
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
169
|
+
* 「默认全部启用」已按哪一版策略播种过。见 seedDefaultEnabled。
|
|
170
|
+
*
|
|
171
|
+
* 为什么用版本号而不是布尔:策略本身会演进,而布尔是一次性的。早期实现是「只在 enabled 为空时播种」
|
|
172
|
+
* 且播完就把布尔置 true —— 于是「早就手动启用过一部分」的老用户永远拿不到默认值(用户实测报过),
|
|
173
|
+
* 而且置了 true 之后连改逻辑都救不回来。换成版本号后,+1 就能对所有人重新播种一次。
|
|
167
174
|
*/
|
|
168
|
-
|
|
175
|
+
enabledSeedVersion: schema.number().default(0),
|
|
169
176
|
/** 是否自动显示右上角的任务徽章(界面偏好,关掉后只在输入区浮层里手动查看)。 */
|
|
170
177
|
showTaskBadge: schema.boolean().default(true),
|
|
171
178
|
/**
|
|
@@ -410,7 +417,7 @@ export function apply(ctx, config) {
|
|
|
410
417
|
return;
|
|
411
418
|
}
|
|
412
419
|
scope = settingsService.register(SETTINGS_NAMESPACE, settingsSchema, {
|
|
413
|
-
base: { enabled: [], enabledSeeded: false, showTaskBadge: true, showScheduleButton: true, showActivePulse: true },
|
|
420
|
+
base: { enabled: [], enabledSeeded: false, enabledSeedVersion: 0, showTaskBadge: true, showScheduleButton: true, showActivePulse: true },
|
|
414
421
|
applies: "live",
|
|
415
422
|
validate: (value) => {
|
|
416
423
|
const list = value?.enabled;
|
|
@@ -571,27 +578,28 @@ export function apply(ctx, config) {
|
|
|
571
578
|
}
|
|
572
579
|
}
|
|
573
580
|
/**
|
|
574
|
-
*
|
|
581
|
+
* 按当前策略把「默认全部启用」落到 enabled 名单上(同一策略版本对同一用户只做一次)。
|
|
575
582
|
*
|
|
576
|
-
* enabled 是白名单,缺省 [] 本意是"一个都没启用"
|
|
577
|
-
*
|
|
578
|
-
*
|
|
579
|
-
*
|
|
583
|
+
* enabled 是白名单,缺省 [] 本意是"一个都没启用",这让新装用户得逐个开关 323 个专家。
|
|
584
|
+
* 现在只要还没按当前策略播种过,就把**全部可用专家**写进 enabled —— 不再看 enabled 是否为空:
|
|
585
|
+
* "非空"并不等于用户刻意挑过(早期版本留下的部分记录同样非空),拿它当挡箭牌会让老用户永远
|
|
586
|
+
* 停在部分启用(用户实测报过)。用户之后自己的启用/禁用不会被覆盖 —— 那时版本号已经追上来了。
|
|
580
587
|
*/
|
|
581
588
|
async function seedDefaultEnabled() {
|
|
582
589
|
if (settingsService === undefined) return;
|
|
583
590
|
const state = scope?.get?.();
|
|
584
|
-
if (state === undefined || state.
|
|
585
|
-
const
|
|
586
|
-
//
|
|
587
|
-
|
|
588
|
-
if (nextEnabled.length === 0) return; // 名册残缺(既无名单也无专家),别乱写
|
|
591
|
+
if (state === undefined || (state.enabledSeedVersion ?? 0) >= ENABLED_SEED_VERSION) return;
|
|
592
|
+
const nextEnabled = usable().map((expert) => expert.slug);
|
|
593
|
+
// 名册残缺(例如分区目录暂时读不到)时绝不写:否则等于用一份空名单把用户的启用项抹掉。
|
|
594
|
+
if (nextEnabled.length === 0) return;
|
|
589
595
|
try {
|
|
590
596
|
await settingsService.mutate(SETTINGS_NAMESPACE, [
|
|
591
597
|
{ op: "set", path: ["enabled"], value: nextEnabled },
|
|
598
|
+
// 老键一并置 true:保持它在语义上的"已播种",也免得多版本并存时两边打架。
|
|
592
599
|
{ op: "set", path: ["enabledSeeded"], value: true },
|
|
600
|
+
{ op: "set", path: ["enabledSeedVersion"], value: ENABLED_SEED_VERSION },
|
|
593
601
|
], revision());
|
|
594
|
-
|
|
602
|
+
ctx.logger?.info?.(`[t-team] 已按默认策略 v${ENABLED_SEED_VERSION} 启用全部 ${nextEnabled.length} 位专家`);
|
|
595
603
|
} catch (error) {
|
|
596
604
|
// 写不进(并发 / 服务暂不可用)不致命:下次 ensureReady 还会再试。
|
|
597
605
|
ctx.logger?.warn?.(`[t-team] 默认启用全部专家写入失败:${String(error)}`);
|
|
@@ -1542,7 +1550,13 @@ export function apply(ctx, config) {
|
|
|
1542
1550
|
// 为什么要在这里重挂:解析是异步的,而引擎首挂是同步的 —— 宿主真把符号换成非全局符号时,
|
|
1543
1551
|
// 首挂那一刻守卫按旧键找不到函数(B-1 硬闸门 → 引擎降级)。解析落地后立刻用权威键重挂一次,
|
|
1544
1552
|
// 引擎就能起来,用户不必重启 DSH。**只有换了口径才重挂**:常态(键没变)一行都不做。
|
|
1553
|
+
// 审计修复(#2):这个 promise 是 fire-and-forget,可能晚到「插件已经卸载」之后才落定,
|
|
1554
|
+
// 届时在已死 fiber 上 reloadEngine(ctx.plugin)会重挂一个不受任何 fiber 管辖的引擎。
|
|
1555
|
+
// 用 disposed 守卫让晚到的回调变成 no-op。
|
|
1556
|
+
let disposed = false;
|
|
1557
|
+
ctx.effect(() => () => { disposed = true; }, "t-team: authority-dispose-guard");
|
|
1545
1558
|
void resolveHarnessAuthority().then((authority) => {
|
|
1559
|
+
if (disposed) return;
|
|
1546
1560
|
refreshHarnessReport();
|
|
1547
1561
|
if (authority.changed !== true) return;
|
|
1548
1562
|
ctx.logger?.warn?.(`[t-team] 宿主投递符号换了口径(权威符号:${String(authority.deliver)}),已改用权威符号并重挂团队引擎。`);
|
|
@@ -1577,6 +1591,19 @@ export function apply(ctx, config) {
|
|
|
1577
1591
|
});
|
|
1578
1592
|
ctx.reflect.provide(SQUAD_SERVICE, squads);
|
|
1579
1593
|
|
|
1594
|
+
// P0 启动期对账:升级后 teams.json 补进新小队、但 t-team.config.json 是 DERIVED 文件不更新,
|
|
1595
|
+
// 会变成「/t 列得出、引擎建不了」。异步重编译 + 热重载(execFile 不阻塞事件循环),
|
|
1596
|
+
// 失败只告警不打断插件加载。
|
|
1597
|
+
void squads.resync().then((result) => {
|
|
1598
|
+
if (result.rebuilt === true) {
|
|
1599
|
+
ctx.logger?.info?.(`[t-team] 启动期小队对账:${result.detail}`);
|
|
1600
|
+
} else if (result.ok === false) {
|
|
1601
|
+
ctx.logger?.warn?.(`[t-team] 启动期小队对账失败:${result.detail}`);
|
|
1602
|
+
}
|
|
1603
|
+
}).catch((error) => {
|
|
1604
|
+
ctx.logger?.warn?.(`[t-team] 启动期小队对账异常:${error instanceof Error ? error.message : String(error)}`);
|
|
1605
|
+
});
|
|
1606
|
+
|
|
1580
1607
|
// ---- 团队运行服务(设置页「团队」标签用;复用引擎的快照与停止实现)----
|
|
1581
1608
|
// 状态目录**只有一个来源**:`config.stateDir`(同时也是挂给引擎的那个值,见上面的 ctx.plugin)。
|
|
1582
1609
|
// 曾经这里读的是数据文件 `t-team.config.json` 的 stateDir(`agent-teams.config.json` 时代的遗留),
|
package/lib/remote-schemas.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Remote 线格式的**单一 schema 源**(A-2 / A-3)。
|
|
4
4
|
*
|
|
5
|
-
* 为什么要有这个文件:这些 schema 过去在 host(`lib/remote.js`)与客户端(`src/client
|
|
5
|
+
* 为什么要有这个文件:这些 schema 过去在 host(`lib/remote.js`)与客户端(`src/client/`)
|
|
6
6
|
* 各写了一份,**13 个同名定义逐字重复**。加一个 remote 方法或改一个字段要改两处,漏改一处
|
|
7
7
|
* 不会在加载期报错,只会在调用期炸(或更糟:静默把字段剥掉)。
|
|
8
8
|
*
|
package/lib/squads.js
CHANGED
|
@@ -394,8 +394,49 @@ export function createSquadService({ teamsFile, catalog, python = "python3", gen
|
|
|
394
394
|
return { ...result, lastReload, reloading: reloadTail !== undefined };
|
|
395
395
|
}
|
|
396
396
|
|
|
397
|
+
/**
|
|
398
|
+
* 启动期对账(P0):升级后 `teams.json` 按 MERGE 补进了新小队,但 `t-team.config.json`
|
|
399
|
+
* 是 DERIVED 文件(盘上存在就不动),于是「/t 列得出、引擎建不了」。这里检测两队 key
|
|
400
|
+
* 集合的差集,有差异就重编译并热重载,让升级带来的新小队无需用户手动保存即可用。
|
|
401
|
+
*
|
|
402
|
+
* 只补**缺失的 key**:已有 key 的内容差异由 `save()` 负责,启动期不强行覆盖(尊重用户
|
|
403
|
+
* 可能改过的字段)。编译失败不抛(升级不因小队编译问题打断插件加载),原因进返回值。
|
|
404
|
+
* @returns `{ ok, rebuilt, detail }`:`rebuilt` 为 true 表示真的重编译并热重载了。
|
|
405
|
+
*/
|
|
406
|
+
async function resync() {
|
|
407
|
+
const spec = readSpec();
|
|
408
|
+
const profiles = spec.profiles !== null && typeof spec.profiles === "object" ? spec.profiles : {};
|
|
409
|
+
const configPath = ["t-team.config.json", "agent-teams.config.json"]
|
|
410
|
+
.map((file) => join(dataDir, file))
|
|
411
|
+
.find((candidate) => existsSync(candidate));
|
|
412
|
+
if (configPath === undefined) {
|
|
413
|
+
return { ok: true, rebuilt: false, detail: "" };
|
|
414
|
+
}
|
|
415
|
+
let engineProfiles;
|
|
416
|
+
try {
|
|
417
|
+
const engineCfg = JSON.parse(readFileSync(configPath, "utf8"));
|
|
418
|
+
engineProfiles = engineCfg !== null && typeof engineCfg === "object" && !Array.isArray(engineCfg)
|
|
419
|
+
&& engineCfg.profiles !== null && typeof engineCfg.profiles === "object"
|
|
420
|
+
? engineCfg.profiles
|
|
421
|
+
: {};
|
|
422
|
+
} catch (error) {
|
|
423
|
+
return { ok: false, rebuilt: false, detail: `引擎配置无法解析(${configPath}):${error instanceof Error ? error.message : String(error)}` };
|
|
424
|
+
}
|
|
425
|
+
const missing = Object.keys(profiles).filter((key) => !Object.hasOwn(engineProfiles, key));
|
|
426
|
+
if (missing.length === 0) {
|
|
427
|
+
return { ok: true, rebuilt: false, detail: "" };
|
|
428
|
+
}
|
|
429
|
+
const built = await build();
|
|
430
|
+
if (!built.ok) {
|
|
431
|
+
return { ok: false, rebuilt: false, detail: `自动重编译失败:${built.output}` };
|
|
432
|
+
}
|
|
433
|
+
const settled = scheduleReload();
|
|
434
|
+
await settled;
|
|
435
|
+
return { ok: true, rebuilt: true, detail: `重编译补上 ${missing.length} 支小队:${missing.join(", ")}` };
|
|
436
|
+
}
|
|
437
|
+
|
|
397
438
|
return {
|
|
398
|
-
list, save, validate, revision: revisionOf, generator, dataDir,
|
|
439
|
+
list, save, validate, resync, revision: revisionOf, generator, dataDir,
|
|
399
440
|
// 本服务没有 `dispose()`:它只在 `save()` 调用期间持有状态(重载串行尾由 save() 自己 await
|
|
400
441
|
// 干净),两次调用之间是无状态的,所以宿主卸载时没有需要收尾的异步工作。
|
|
401
442
|
// 让调用方(与自检)看得到「这次编译用的是哪个上限、编译器到底认不认这个参数」。
|
package/lib/teams/index.js
CHANGED
|
@@ -163,11 +163,16 @@ export function apply(ctx, config) {
|
|
|
163
163
|
if (rawWebServer === undefined || workspaceRegistry === undefined)
|
|
164
164
|
return;
|
|
165
165
|
const webServer = authenticatedWebRoutes(rawWebServer, () => ctx.get('connection'));
|
|
166
|
-
webRegistered = true
|
|
166
|
+
// 审计修复(#10):`webRegistered = true` 过去写在任何注册之前,一旦某条路由
|
|
167
|
+
// register 抛错(例如 webServer 服务重载后重复路径),布尔已置位,后续
|
|
168
|
+
// internal/service 事件永不重试,路由永久缺失。改为「收集 disposer + 全部成功
|
|
169
|
+
// 才置位」,任一失败回滚已注册的路由并重抛,让下次服务绑定事件可以再试一次。
|
|
170
|
+
const disposers = [];
|
|
171
|
+
try {
|
|
167
172
|
// Activity panel data route: the browser floater polls this for team
|
|
168
173
|
// snapshots (disk truth + live subagent activity). Mirrors the Claude
|
|
169
174
|
// Code desktop watcher's server-side snapshot pattern.
|
|
170
|
-
ctx.effect(() => webServer.register({
|
|
175
|
+
disposers.push(ctx.effect(() => webServer.register({
|
|
171
176
|
kind: 'exact',
|
|
172
177
|
path: '/plugins/t-team/state',
|
|
173
178
|
handler: async (req, res) => {
|
|
@@ -187,8 +192,8 @@ export function apply(ctx, config) {
|
|
|
187
192
|
});
|
|
188
193
|
res.end(body);
|
|
189
194
|
},
|
|
190
|
-
}), 't-team: activity route');
|
|
191
|
-
ctx.effect(() => webServer.register({
|
|
195
|
+
}), 't-team: activity route'));
|
|
196
|
+
disposers.push(ctx.effect(() => webServer.register({
|
|
192
197
|
kind: 'exact',
|
|
193
198
|
path: '/plugins/t-team/halt',
|
|
194
199
|
handler: async (req, res) => {
|
|
@@ -243,8 +248,8 @@ export function apply(ctx, config) {
|
|
|
243
248
|
res.end(JSON.stringify({ error: 'failed to stop the team' }));
|
|
244
249
|
}
|
|
245
250
|
},
|
|
246
|
-
}), 't-team: halt route');
|
|
247
|
-
ctx.effect(() => webServer.register({
|
|
251
|
+
}), 't-team: halt route'));
|
|
252
|
+
disposers.push(ctx.effect(() => webServer.register({
|
|
248
253
|
kind: 'exact',
|
|
249
254
|
path: '/plugins/t-team/plan',
|
|
250
255
|
handler: async (req, res) => {
|
|
@@ -389,7 +394,7 @@ export function apply(ctx, config) {
|
|
|
389
394
|
res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'plan operation failed' }));
|
|
390
395
|
}
|
|
391
396
|
},
|
|
392
|
-
}), 't-team: plan route');
|
|
397
|
+
}), 't-team: plan route'));
|
|
393
398
|
// Whale mascot artwork: serve the packaged V2 role/action images to the
|
|
394
399
|
// activity panel. An explicit allowlist guards the route (no path
|
|
395
400
|
// traversal); the images ship with the bundle (files: assets/).
|
|
@@ -404,7 +409,7 @@ export function apply(ctx, config) {
|
|
|
404
409
|
'action-reporting-v2.png', 'action-celebrating-v2.png',
|
|
405
410
|
'action-sleeping-v2.png', 'action-sending-v2.png',
|
|
406
411
|
]);
|
|
407
|
-
ctx.effect(() => webServer.register({
|
|
412
|
+
disposers.push(ctx.effect(() => webServer.register({
|
|
408
413
|
kind: 'prefix',
|
|
409
414
|
path: '/plugins/t-team/assets',
|
|
410
415
|
handler: async (req, res) => {
|
|
@@ -437,7 +442,16 @@ export function apply(ctx, config) {
|
|
|
437
442
|
res.end();
|
|
438
443
|
}
|
|
439
444
|
},
|
|
440
|
-
}), 't-team: artwork route');
|
|
445
|
+
}), 't-team: artwork route'));
|
|
446
|
+
} catch (error) {
|
|
447
|
+
// 任一路由注册失败:回滚已经注册成功的,保持 webRegistered=false,让下次
|
|
448
|
+
// internal/service 绑定事件重试,而不是留下半套路由 + 永久不再重试。
|
|
449
|
+
for (const dispose of disposers.reverse()) {
|
|
450
|
+
try { dispose(); } catch { /* 回滚尽力而为,失败只影响清理 */ }
|
|
451
|
+
}
|
|
452
|
+
throw error;
|
|
453
|
+
}
|
|
454
|
+
webRegistered = true;
|
|
441
455
|
};
|
|
442
456
|
registerWebSurface();
|
|
443
457
|
ctx.on('internal/service', (name) => {
|
package/lib/teams/members.js
CHANGED
|
@@ -544,7 +544,10 @@ export async function deliverToMember(ctx, captain, childId, text, signal) {
|
|
|
544
544
|
return true;
|
|
545
545
|
}
|
|
546
546
|
catch (error) {
|
|
547
|
-
|
|
547
|
+
// 审计修复(#8):投递失败过去只记一行 warn,然后任务被静默回滚成 pending,队长
|
|
548
|
+
// 看起来「重派成功」实则永远唤不醒成员。升级为 error 并保留根因,让宿主契约漂移
|
|
549
|
+
// 这类系统性问题在日志里可定位,而不是混在普通 warn 里被忽略。
|
|
550
|
+
ctx.logger.error(`t-team: prompt delivery to member ${childId} failed: ${String(error)}`);
|
|
548
551
|
return false;
|
|
549
552
|
}
|
|
550
553
|
}
|
|
@@ -95,12 +95,24 @@ export function pathMatchesScope(path, pattern) {
|
|
|
95
95
|
if (normalizedPath === undefined)
|
|
96
96
|
return false;
|
|
97
97
|
const rawPattern = pattern.trim().replaceAll('\\', '/');
|
|
98
|
-
|
|
98
|
+
// 审计修复(#6):`.`、`./` 与空模式归一化后是空串,旧逻辑在目录分支把它们放行成
|
|
99
|
+
// 「匹配一切」,等于写域审计整体失效(`inScope: ["."]` 让任意路径都判 in_scope)。
|
|
100
|
+
// 根路径/盘符/`~` 已是非法;这里把「当前目录」类模式一并在最早期拒绝,绝不当作通配。
|
|
101
|
+
if (rawPattern === '' || rawPattern === '.' || rawPattern === './'
|
|
102
|
+
|| rawPattern.startsWith('~') || rawPattern.startsWith('/') || /^[A-Za-z]:/.test(rawPattern))
|
|
99
103
|
return false;
|
|
100
104
|
// 审计 N-20:旧实现只在模式**以 `/` 结尾**时才当目录,于是 `inScope: ["src"]` 对 `src/a.ts`
|
|
101
105
|
// 返回 false —— 写域等于没声明,真正该拦的写冲突会被放过。这里补齐两种常见写法:
|
|
102
106
|
// · `src` —— 不含扩展名的裸目录名,按目录前缀处理(含 `.` 的仍按文件精确匹配,保住 `src/a.ts` 语义)
|
|
103
107
|
// · `src/*` / `src/**` —— 单层 / 任意层通配
|
|
108
|
+
//
|
|
109
|
+
// ⚠️ 已知且有意的取舍(2026-09-17 复核):这条启发式只看字符串,无扩展名的**文件**名
|
|
110
|
+
// (`Makefile`、`LICENSE`)也会被当成目录,于是 `inScope: ["Makefile"]` 会放行 `Makefile/x`。
|
|
111
|
+
// 不修的理由是反方向的代价更大:改成「只有 `dir/` 才算目录」就会把 N-20 退回去,让
|
|
112
|
+
// `inScope: ["src"]` 对 `src/a.ts` 失效 —— 那是"写域声明形同虚设"(该拦的写冲突被放过),
|
|
113
|
+
// 比"多放行一个同名子路径"危险得多。而真正的精确判定需要知道工作区里 `Makefile` 是文件
|
|
114
|
+
// 还是目录,这个函数是**纯函数**(plan-check 与 tools 共用、不碰文件系统),拿不到该信息。
|
|
115
|
+
// 所以保留现状:宁可偶尔过宽,不可让写域声明失效。
|
|
104
116
|
const directory = rawPattern.endsWith('/');
|
|
105
117
|
const wildcard = rawPattern.includes('*');
|
|
106
118
|
const bareDirectory = !directory && !wildcard && !rawPattern.includes('.') && rawPattern !== '.' && rawPattern !== '';
|
|
@@ -217,6 +229,25 @@ function dependencyClosureContains(tasks, dependencies, targetId) {
|
|
|
217
229
|
}
|
|
218
230
|
return false;
|
|
219
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* 判断「把 review 任务交给 `assigneeKey`」是否构成自审。
|
|
234
|
+
*
|
|
235
|
+
* 只对**有明确归属**的被审任务成立:被审任务落在共享池(`assignee` 为空)时谁都能审。
|
|
236
|
+
* `assigneeKey` 与 `reviewedTask.assignee` 都是同一种键(成员名,或队长占位键 CAPTAIN_KEY),
|
|
237
|
+
* 直接比较即可。审计修复(#5):此前「不得自审」只写在提示词里,创建/重派都没有硬门禁。
|
|
238
|
+
* @param team - 团队状态。
|
|
239
|
+
* @param reviewedTaskId - review 指向的任务 id。
|
|
240
|
+
* @param assigneeKey - 拟交给的成员名或队长键(空串/undefined = 共享池,永不构成自审)。
|
|
241
|
+
* @returns 构成自审时为 true。
|
|
242
|
+
*/
|
|
243
|
+
export function isSelfReview(team, reviewedTaskId, assigneeKey) {
|
|
244
|
+
if (typeof assigneeKey !== 'string' || assigneeKey === '')
|
|
245
|
+
return false;
|
|
246
|
+
const reviewed = team.tasks.find((item) => item.id === reviewedTaskId);
|
|
247
|
+
if (reviewed === undefined || typeof reviewed.assignee !== 'string' || reviewed.assignee === '')
|
|
248
|
+
return false;
|
|
249
|
+
return reviewed.assignee === assigneeKey;
|
|
250
|
+
}
|
|
220
251
|
export function validateCreateTask(team, input) {
|
|
221
252
|
const kind = input.kind ?? 'work';
|
|
222
253
|
if (!TASK_KINDS.includes(kind)) {
|
|
@@ -251,6 +282,10 @@ export function validateCreateTask(team, input) {
|
|
|
251
282
|
if (!team.tasks.some((item) => item.id === input.reviewedTaskId)) {
|
|
252
283
|
return { ok: false, error: `reviewed task "${input.reviewedTaskId}" does not exist` };
|
|
253
284
|
}
|
|
285
|
+
// 审计修复(#5):不得把 review 明确派给被审任务的实现者本人(自审)。
|
|
286
|
+
if (isSelfReview(team, input.reviewedTaskId, input.assignee)) {
|
|
287
|
+
return { ok: false, error: `review must not be assigned to the same member as the reviewed task "${input.reviewedTaskId}" (no self-review)` };
|
|
288
|
+
}
|
|
254
289
|
}
|
|
255
290
|
if (kind === 'repair') {
|
|
256
291
|
if (!nonemptyString(input.sourceTaskId) || !nonemptyStringList(input.sourceFindingIds)) {
|
|
@@ -381,7 +416,16 @@ export function evaluateQualityCompletion(task, update) {
|
|
|
381
416
|
return { ok: false, error: `${kind} pass cannot leave unresolved high/blocker findings` };
|
|
382
417
|
}
|
|
383
418
|
}
|
|
384
|
-
|
|
419
|
+
// 非 pass 的 verdict **必须**把任务判成 failed —— 这不只是文案问题,而是循环能不能推进的判据:
|
|
420
|
+
// 自动补 repair + 下一轮 review 只在 status==='failed' 时生成(tools.js 的 followUp),
|
|
421
|
+
// 所以旧行为下「给了 needs_revision 却没给 status」既没落成失败、也不会补修,任务停在
|
|
422
|
+
// in_progress:队长只看到一条卡住的任务,成员那边也没有"下一步要干什么"的信号
|
|
423
|
+
// (2026-09-17 复核发现:evaluateQualityCompletion 只在 nextStatus==='failed' 分支里
|
|
424
|
+
// 校验 findings,nextStatus 为 undefined 时直接放行)。
|
|
425
|
+
if (verdict === 'needs_revision' || verdict === 'reject') {
|
|
426
|
+
if (nextStatus !== 'failed') {
|
|
427
|
+
return { ok: false, error: `${kind} with verdict=${verdict} must fail the task (status=failed)`, requiredStatus: 'failed' };
|
|
428
|
+
}
|
|
385
429
|
if ((findings ?? []).length < 1) {
|
|
386
430
|
return { ok: false, error: `${kind} ${verdict} requires at least one finding` };
|
|
387
431
|
}
|
|
@@ -536,6 +580,17 @@ export function buildCoverageMatrix(goalItems, tasks) {
|
|
|
536
580
|
}
|
|
537
581
|
export function canDeclareDelivery(team) {
|
|
538
582
|
const blockers = [];
|
|
583
|
+
// 审计修复(#7):`quality` 过滤掉非质量类任务后,纯 `work` 团队(或零任务团队)的
|
|
584
|
+
// `quality` 为空 → blockers 恒为空 → 被误报成「所有质量门禁已通过、可以交付」,哪怕
|
|
585
|
+
// 所有任务都还是 pending。交付要求**所有任务**(含 work)都到达终态,而不只是质量类。
|
|
586
|
+
if (team.tasks.length === 0) {
|
|
587
|
+
blockers.push('no tasks have been planned; there is nothing to deliver');
|
|
588
|
+
}
|
|
589
|
+
for (const item of team.tasks) {
|
|
590
|
+
if (!isQualityKind(taskKindOf(item)) && OPEN_STATUSES.includes(item.status)) {
|
|
591
|
+
blockers.push(`${item.id} (${taskKindOf(item)}) is still ${item.status}; all work must reach a terminal state before delivery`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
539
594
|
const quality = team.tasks.filter((item) => isQualityKind(taskKindOf(item)));
|
|
540
595
|
const implementations = quality.filter((item) => taskKindOf(item) === 'implementation' || taskKindOf(item) === 'repair');
|
|
541
596
|
const reviews = quality.filter((item) => taskKindOf(item) === 'review');
|
package/lib/teams/scheduler.js
CHANGED
|
@@ -114,8 +114,20 @@ function nextReadyTask(tasks, memberName) {
|
|
|
114
114
|
const ready = tasks.filter(task => task.status === 'pending'
|
|
115
115
|
&& task.reassigning !== true
|
|
116
116
|
&& unsatisfiedDependencies([...tasks], task.dependencies).length === 0);
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
const assigned = ready.find(task => task.assignee === memberName);
|
|
118
|
+
if (assigned !== undefined)
|
|
119
|
+
return assigned;
|
|
120
|
+
// 审计修复(#5 调度侧):共享池里的 review 任务不得分给被审任务的实现者本人
|
|
121
|
+
// (自审)。显式 assignee 已在创建/reassign 两道闸门挡住,这里挡住「调度器自动
|
|
122
|
+
// 把 review 派回实现者」这条剩余路径。
|
|
123
|
+
return ready.find(task => {
|
|
124
|
+
if (task.assignee !== undefined)
|
|
125
|
+
return false;
|
|
126
|
+
if ((task.kind ?? 'work') !== 'review')
|
|
127
|
+
return true;
|
|
128
|
+
const reviewed = tasks.find(candidate => candidate.id === task.reviewedTaskId);
|
|
129
|
+
return reviewed?.assignee !== memberName;
|
|
130
|
+
});
|
|
119
131
|
}
|
|
120
132
|
export function assignmentPrompt(ticket, stateDir, teamId) {
|
|
121
133
|
const description = ticket.description === undefined ? '' : `\n\n${ticket.description}`;
|
|
@@ -320,7 +332,7 @@ export function installTeamScheduler(ctx, config) {
|
|
|
320
332
|
return;
|
|
321
333
|
const accepted = await deliverToMember(ctx, captain, ticket.memberId, assignmentPrompt(ticket, config.stateDir, team.id), new AbortController().signal);
|
|
322
334
|
if (accepted)
|
|
323
|
-
return;
|
|
335
|
+
return { dispatched: true };
|
|
324
336
|
// Roll back only our exact failed dispatch. A concurrent captain
|
|
325
337
|
// handoff has already changed the capability and wins.
|
|
326
338
|
await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
|
|
@@ -354,6 +366,9 @@ export function installTeamScheduler(ctx, config) {
|
|
|
354
366
|
currentMember.status = 'idle';
|
|
355
367
|
await writeTeam(stateRoot, fresh);
|
|
356
368
|
});
|
|
369
|
+
// 审计修复(#8):把投递失败传回调用方(同步的 create/reassign 会把它带进工具
|
|
370
|
+
// 结果),而不是只让任务静默回滚成 pending。后台 idle-edge 调用方忽略该返回值。
|
|
371
|
+
return { dispatched: false, deliveryError: 'prompt delivery to member failed; see host logs' };
|
|
357
372
|
});
|
|
358
373
|
},
|
|
359
374
|
};
|
package/lib/teams/tools.js
CHANGED
|
@@ -1253,6 +1253,13 @@ export function registerTTeamTools(ctx, config) {
|
|
|
1253
1253
|
throw new Error(`completed task ${task.id} is immutable and cannot be reassigned`);
|
|
1254
1254
|
if (task.reassigning === true)
|
|
1255
1255
|
throw new Error(`task ${task.id} is already being reassigned`);
|
|
1256
|
+
// 审计修复(#5 重派侧):不得把 review 重派给被审任务的实现者本人(自审)。
|
|
1257
|
+
if ((task.kind ?? 'work') === 'review') {
|
|
1258
|
+
const reviewed = fresh.tasks.find((candidate) => candidate.id === task.reviewedTaskId);
|
|
1259
|
+
if (reviewed !== undefined && reviewed.assignee === target) {
|
|
1260
|
+
throw new Error(`cannot reassign review ${task.id} to ${target}: it reviews ${target}'s own task (no self-review)`);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1256
1263
|
const targetMember = target === CAPTAIN_KEY ? undefined : requireMember(fresh, target);
|
|
1257
1264
|
if (target === CAPTAIN_KEY) {
|
|
1258
1265
|
const busy = captainOpenTask(fresh, task.id);
|
|
@@ -1318,8 +1325,13 @@ export function registerTTeamTools(ctx, config) {
|
|
|
1318
1325
|
});
|
|
1319
1326
|
if (quiescenceError !== undefined)
|
|
1320
1327
|
throw quiescenceError;
|
|
1321
|
-
|
|
1322
|
-
|
|
1328
|
+
let deliveryWarning;
|
|
1329
|
+
if (target !== CAPTAIN_KEY) {
|
|
1330
|
+
const kicked = await scheduler.kickMember(workspace, team.id, target, captain);
|
|
1331
|
+
if (kicked?.dispatched === false) {
|
|
1332
|
+
deliveryWarning = kicked.deliveryError ?? 'prompt delivery to member failed';
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1323
1335
|
const current = await readTeam(stateRoot, team.id);
|
|
1324
1336
|
const task = current === undefined ? undefined : requireTask(current, args.task_id);
|
|
1325
1337
|
if (task === undefined)
|
|
@@ -1331,6 +1343,7 @@ export function registerTTeamTools(ctx, config) {
|
|
|
1331
1343
|
status: task.status,
|
|
1332
1344
|
attempt: task.attempt ?? 0,
|
|
1333
1345
|
...task.attemptId === undefined ? {} : { attempt_id: task.attemptId },
|
|
1346
|
+
...deliveryWarning === undefined ? {} : { delivery_warning: deliveryWarning },
|
|
1334
1347
|
};
|
|
1335
1348
|
},
|
|
1336
1349
|
}));
|
|
@@ -1386,6 +1399,13 @@ export function registerTTeamTools(ctx, config) {
|
|
|
1386
1399
|
throw new Error(`task ${task.id} is assigned to "${assignee}", not you`);
|
|
1387
1400
|
}
|
|
1388
1401
|
assignee = identity.name;
|
|
1402
|
+
// 审计修复(#5 认领侧):成员不得认领「审自己实现」的 review 任务。
|
|
1403
|
+
if ((task.kind ?? 'work') === 'review') {
|
|
1404
|
+
const reviewed = fresh.tasks.find((candidate) => candidate.id === task.reviewedTaskId);
|
|
1405
|
+
if (reviewed?.assignee === identity.name) {
|
|
1406
|
+
throw new Error(`cannot claim review ${task.id}: it reviews your own task (no self-review)`);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1389
1409
|
}
|
|
1390
1410
|
// Authorization must happen before the idempotent return: another
|
|
1391
1411
|
// member must not receive a false success for somebody else's task.
|
|
@@ -2110,7 +2130,7 @@ function parseFindings(value) {
|
|
|
2110
2130
|
return undefined;
|
|
2111
2131
|
if (!Array.isArray(value))
|
|
2112
2132
|
throw new Error('findings must be an array');
|
|
2113
|
-
|
|
2133
|
+
const parsed = value.map((item, index) => {
|
|
2114
2134
|
if (typeof item !== 'object' || item === null || Array.isArray(item)) {
|
|
2115
2135
|
throw new Error(`findings[${index}] must be an object`);
|
|
2116
2136
|
}
|
|
@@ -2136,6 +2156,15 @@ function parseFindings(value) {
|
|
|
2136
2156
|
...typeof raw['resolved'] === 'boolean' ? { resolved: raw['resolved'] } : {},
|
|
2137
2157
|
};
|
|
2138
2158
|
});
|
|
2159
|
+
// 审计修复(#9):写侧过去不校验 id 唯一,读侧 hasValidQualityTaskFields 却要求唯一,
|
|
2160
|
+
// 于是两个同名 finding 能过工具入参、却在 writeTeam 处卡死在误导性错误上(文案列举的
|
|
2161
|
+
// 原因里根本没有 findings)。这里在最早可解析点报出可定位的错误。
|
|
2162
|
+
const ids = parsed.map((finding) => finding.id);
|
|
2163
|
+
const duplicate = ids.find((id, index) => ids.indexOf(id) !== index);
|
|
2164
|
+
if (duplicate !== undefined) {
|
|
2165
|
+
throw new Error(`findings ids must be unique; duplicate id "${duplicate}"`);
|
|
2166
|
+
}
|
|
2167
|
+
return parsed;
|
|
2139
2168
|
}
|
|
2140
2169
|
function parseAcceptanceResults(value) {
|
|
2141
2170
|
if (value === undefined)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-t-expert",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.70",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "T Expert — a 323-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",
|
|
@@ -41,7 +41,13 @@
|
|
|
41
41
|
},
|
|
42
42
|
"files": [
|
|
43
43
|
"lib",
|
|
44
|
-
"data",
|
|
44
|
+
"data/experts",
|
|
45
|
+
"data/zh",
|
|
46
|
+
"data/source.json",
|
|
47
|
+
"data/team-profiles.py",
|
|
48
|
+
"data/teams.json",
|
|
49
|
+
"data/teams.resolved.json",
|
|
50
|
+
"data/t-team.config.json",
|
|
45
51
|
"skills/dsh-harness-project",
|
|
46
52
|
"skills/dsh-harness-languages",
|
|
47
53
|
"cordis.patch.yml",
|
|
@@ -60,6 +66,7 @@
|
|
|
60
66
|
"@deepseek-ai/dsh-client-connection",
|
|
61
67
|
"@deepseek-ai/dsh-client-locale",
|
|
62
68
|
"@deepseek-ai/dsh-api-remotes",
|
|
69
|
+
"@deepseek-ai/dsh-api-session-controller",
|
|
63
70
|
"@deepseek-ai/dsh-client-ui-settings",
|
|
64
71
|
"@deepseek-ai/dsh-client-ui-input-trigger",
|
|
65
72
|
"@deepseek-ai/dsh-client-ui-primitives"
|
|
@@ -70,9 +77,11 @@
|
|
|
70
77
|
"scripts": {
|
|
71
78
|
"typecheck": "tsc -p tsconfig.json",
|
|
72
79
|
"build": "node tools/build-client.mjs",
|
|
80
|
+
"check-client": "node tools/check-client-refs.mjs",
|
|
81
|
+
"render-smoke": "node tools/render-smoke.mjs",
|
|
73
82
|
"verify": "node tools/verify.mjs",
|
|
74
83
|
"sync-data": "node tools/sync-data.mjs",
|
|
75
|
-
"prepublishOnly": "npm run typecheck && npm run build && npm run verify && npm run invariants && node tools/sync-data.mjs --check",
|
|
84
|
+
"prepublishOnly": "npm run typecheck && npm run check-client && npm run render-smoke && npm run build && npm run verify && npm run invariants && node tools/sync-data.mjs --check",
|
|
76
85
|
"invariants": "node tools/invariants.mjs"
|
|
77
86
|
},
|
|
78
87
|
"peerDependencies": {
|
|
@@ -80,6 +89,7 @@
|
|
|
80
89
|
"@deepseek-ai/dsh": "0.1.5-rc.1 || 0.1.5-rc.2",
|
|
81
90
|
"@deepseek-ai/dsh-agent": "0.1.5-rc.1 || 0.1.5-rc.2",
|
|
82
91
|
"@deepseek-ai/dsh-api-remotes": "0.1.5-rc.1 || 0.1.5-rc.2",
|
|
92
|
+
"@deepseek-ai/dsh-api-session-controller": "0.1.5-rc.1 || 0.1.5-rc.2",
|
|
83
93
|
"@deepseek-ai/dsh-client-connection": "0.1.5-rc.1 || 0.1.5-rc.2",
|
|
84
94
|
"@deepseek-ai/dsh-client-locale": "0.1.5-rc.1 || 0.1.5-rc.2",
|
|
85
95
|
"@deepseek-ai/dsh-client-ui-input-trigger": "0.1.5-rc.1 || 0.1.5-rc.2",
|
|
@@ -106,6 +116,9 @@
|
|
|
106
116
|
"@deepseek-ai/dsh-api-remotes": {
|
|
107
117
|
"optional": true
|
|
108
118
|
},
|
|
119
|
+
"@deepseek-ai/dsh-api-session-controller": {
|
|
120
|
+
"optional": true
|
|
121
|
+
},
|
|
109
122
|
"@deepseek-ai/dsh-client-connection": {
|
|
110
123
|
"optional": true
|
|
111
124
|
},
|