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/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
- * 首次运行是否已把「默认全部启用」写进 enabled。见 seedDefaultEnabled:
165
- * 只在「从未播种且名单为空」时把全部可用 slug 写一次,之后用户手动启用/禁用就不再动 ——
166
- * 这样默认是「全启用」,但用户显式清空也绝不会被下次启动覆盖。
169
+ * 「默认全部启用」已按哪一版策略播种过。见 seedDefaultEnabled。
170
+ *
171
+ * 为什么用版本号而不是布尔:策略本身会演进,而布尔是一次性的。早期实现是「只在 enabled 为空时播种」
172
+ * 且播完就把布尔置 true —— 于是「早就手动启用过一部分」的老用户永远拿不到默认值(用户实测报过),
173
+ * 而且置了 true 之后连改逻辑都救不回来。换成版本号后,+1 就能对所有人重新播种一次。
167
174
  */
168
- enabledSeeded: schema.boolean().default(false),
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
- * 首次运行把「默认全部启用」落到 enabled 名单上。
581
+ * 按当前策略把「默认全部启用」落到 enabled 名单上(同一策略版本对同一用户只做一次)。
575
582
  *
576
- * enabled 是白名单,缺省 [] 本意是"一个都没启用",但这让新装用户得逐个开关 323 个专家。
577
- * 改成"默认全启用"后,又不能简单地"enabled 为空就回填"——那样用户以后想清空(逐个关掉)
578
- * 会被每次启动覆盖。所以用 sentinel 键 enabledSeeded 标记"已播种":
579
- * 只在「从未播种 且 名单为空」时把全部可用 slug 写一次;名单非空说明用户已手动启用过,只补标记。
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.enabledSeeded === true) return;
585
- const current = (state.enabled ?? []).filter((value) => typeof value === "string");
586
- // 名单非空 = 用户已经手动启用过:不再覆盖,只补 sentinel。
587
- const nextEnabled = current.length > 0 ? current : usable().map((expert) => expert.slug);
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
- if (current.length === 0) ctx.logger?.info?.("[t-team] 首次运行:已默认启用全部专家");
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` 时代的遗留),
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * Remote 线格式的**单一 schema 源**(A-2 / A-3)。
4
4
  *
5
- * 为什么要有这个文件:这些 schema 过去在 host(`lib/remote.js`)与客户端(`src/client.jsx`)
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
  // 让调用方(与自检)看得到「这次编译用的是哪个上限、编译器到底认不认这个参数」。
@@ -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) => {
@@ -544,7 +544,10 @@ export async function deliverToMember(ctx, captain, childId, text, signal) {
544
544
  return true;
545
545
  }
546
546
  catch (error) {
547
- ctx.logger.warn(`t-team: prompt delivery to member ${childId} failed: ${String(error)}`);
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
- if (rawPattern.startsWith('~') || rawPattern.startsWith('/') || /^[A-Za-z]:/.test(rawPattern))
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
- if (nextStatus === 'failed' && (verdict === 'needs_revision' || verdict === 'reject')) {
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');
@@ -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
- return ready.find(task => task.assignee === memberName)
118
- ?? ready.find(task => task.assignee === undefined);
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
  };
@@ -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
- if (target !== CAPTAIN_KEY)
1322
- await scheduler.kickMember(workspace, team.id, target, captain);
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
- return value.map((item, index) => {
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.46",
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
  },
@@ -1,10 +0,0 @@
1
- {
2
- "version": "0.3.24",
3
- "at": "2026-09-15T12:15:59.731Z",
4
- "scanned": 660,
5
- "copied": 0,
6
- "updated": 0,
7
- "merged": 0,
8
- "kept": 0,
9
- "stale": 0
10
- }