dsh-plugin-t-expert 0.3.88 → 0.3.89
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 +29 -56
- package/THIRD-PARTY-NOTICES +6 -31
- package/lib/bootstrap.js +11 -33
- package/lib/catalog.js +1 -1
- package/lib/client.js +20707 -21863
- package/lib/i18n.js +0 -4
- package/lib/index.js +171 -858
- package/lib/remote-schemas.js +5 -104
- package/lib/remote.js +7 -174
- package/lib/schedule.js +93 -23
- package/lib/skill.js +1 -1
- package/package.json +3 -10
- package/vendor/third-party-licenses/README.md +0 -1
- package/data/t-team.config.json +0 -1558
- package/data/team-profiles.py +0 -367
- package/data/teams.json +0 -791
- package/data/teams.resolved.json +0 -1594
- package/lib/command.js +0 -251
- package/lib/plan-check.js +0 -166
- package/lib/squads.js +0 -446
- package/lib/teams/assignee-contract.js +0 -47
- package/lib/teams/capabilities.js +0 -106
- package/lib/teams/command.js +0 -147
- package/lib/teams/event-types.js +0 -12
- package/lib/teams/events.js +0 -60
- package/lib/teams/harness-compat.js +0 -561
- package/lib/teams/index.js +0 -463
- package/lib/teams/members.js +0 -619
- package/lib/teams/profiles.js +0 -572
- package/lib/teams/quality-gates.js +0 -905
- package/lib/teams/scheduler.js +0 -642
- package/lib/teams/snapshot.js +0 -184
- package/lib/teams/state.js +0 -946
- package/lib/teams/tool-names.js +0 -11
- package/lib/teams/tools.js +0 -2346
- package/lib/teams/types.js +0 -22
- package/lib/teams/web-routes.js +0 -86
- package/vendor/dsh-agent-teams/LICENSE +0 -21
package/lib/index.js
CHANGED
|
@@ -41,36 +41,11 @@ import {
|
|
|
41
41
|
import { localized, NS, readLocale, renderList, renderSummonResults, t, toRenderableGroups, toRenderableSummonResults } from "./i18n.js";
|
|
42
42
|
import { seedData } from "./bootstrap.js";
|
|
43
43
|
import { installBundledSkills } from "./skill.js";
|
|
44
|
-
import { registerTeamCommand } from "./command.js";
|
|
45
|
-
import { createSquadService } from "./squads.js";
|
|
46
44
|
import { SNAPSHOT_DIR } from "./bootstrap.js";
|
|
47
|
-
import { checkPlan } from "./plan-check.js";
|
|
48
45
|
// 定时事项引擎:T专家 自己的实现(JSON 文件 + croner),与 @weibaohui/dsh-tasks 零共享 ——
|
|
49
46
|
// 两个插件允许同时安装同时可用,所以存储、服务名、错误码、消息来源全都各走各的。
|
|
50
47
|
import { SCHEDULE_FILE_NAME, SCHEDULE_SERVICE, createScheduleEngine } from "./schedule.js";
|
|
51
|
-
import {
|
|
52
|
-
archiveTeamDir,
|
|
53
|
-
findTeamByCaptain,
|
|
54
|
-
listArchivedTeamIds,
|
|
55
|
-
readArchivedTeam,
|
|
56
|
-
removeArchivedTeamDir,
|
|
57
|
-
removeTeamDir,
|
|
58
|
-
} from "./teams/state.js";
|
|
59
48
|
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
60
|
-
// 内置团队引擎:随插件一起发布的品牌化产物在 lib/teams/ 下(源自第三方引擎,改名后
|
|
61
|
-
// 由本项目直接维护),这里在 T专家 内部挂载它 ——
|
|
62
|
-
// 安装 T专家 即自带团队引擎与团队面板,不再需要单独安装引擎插件。
|
|
63
|
-
// 上游来源、版本与许可见 THIRD-PARTY-NOTICES 与 vendor/。
|
|
64
|
-
import * as teamsEngine from "./teams/index.js";
|
|
65
|
-
// 引擎自带的 /t 命令不注册(否则与 T专家 自己的 /t 撞名),但手势边界必须复用:
|
|
66
|
-
// 它把 `/t --profile <小队> <目标>` 这条用户消息翻译成队长协议指令。
|
|
67
|
-
import { installTTeamGestureBoundary } from "./teams/command.js";
|
|
68
|
-
// 设置页「团队」标签复用引擎自己的快照收集与停止实现(不重写一套)。
|
|
69
|
-
import { collectArchivedTeamsActivity, collectTeamsActivity } from "./teams/snapshot.js";
|
|
70
|
-
import { haltTeamWork } from "./teams/tools.js";
|
|
71
|
-
// 宿主 subagent 契约体检(B-2 软闸门):引擎用的是上游**未文档化**的内部投递契约,
|
|
72
|
-
// 未实测的宿主版本/契约漂移必须对模型可见(见 harness-compat.js 的说明)。
|
|
73
|
-
import { TESTED_HARNESS_VERSIONS, probeHarnessContract, resolveHarnessAuthority } from "./teams/harness-compat.js";
|
|
74
49
|
|
|
75
50
|
export const name = NS;
|
|
76
51
|
/**
|
|
@@ -86,10 +61,39 @@ export const inject = ["tools", "subagents", "systemPrompt"];
|
|
|
86
61
|
* 插件配置。
|
|
87
62
|
*
|
|
88
63
|
* 规范要求「部署可能取不同值的参数都必须是可被 cordis.yml 覆盖的 Config 字段」,
|
|
89
|
-
*
|
|
90
|
-
* stateDir / memberProvider / memberModel / maxMembers 与引擎配置路径都在这里;
|
|
64
|
+
* 所以召唤上限/并发、任务长度上限、简介截断长度都放在这里;
|
|
91
65
|
* 模块级常量只保留协议常量与固定清单。
|
|
92
66
|
*/
|
|
67
|
+
/**
|
|
68
|
+
* cosmokit 的 volatile 引用所用的全局 symbol —— 跨 ESM/CJS 副本的稳定判据。
|
|
69
|
+
*/
|
|
70
|
+
const VOLATILE_WRITE = Symbol.for("cosmokit.volatile.write");
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 读一个可能是 volatile 引用的配置值。
|
|
74
|
+
*
|
|
75
|
+
* 为什么不用 `typeof value.get === "function"`:普通对象也可能有 get;也不用 `instanceof`:
|
|
76
|
+
* 多份 cosmokit 副本之间会漏判。0.1.7 的 volatile 字段在 Config 上就是这种引用。
|
|
77
|
+
* @param value - Config 上的原始值。
|
|
78
|
+
* @returns 引用的当前快照,或原值。
|
|
79
|
+
*/
|
|
80
|
+
const unwrapVolatile = (value) => (
|
|
81
|
+
typeof value === "object" && value !== null && VOLATILE_WRITE in value ? value.get() : value
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 按宿主能力给字段打 `volatile` 标记。
|
|
86
|
+
*
|
|
87
|
+
* 为什么需要包装:`.volatile()` 是 DSH 0.1.7 才有的 schemastery API(旧宿主上是
|
|
88
|
+
* `undefined`),而本插件要同时跑在 0.1.5/0.1.6 与 0.1.7+ 上 —— 直接调 `.volatile()`
|
|
89
|
+
* 会让插件在旧宿主上一启动就抛 `field.volatile is not a function`。旧宿主上原样返回即可。
|
|
90
|
+
* @param field - Config 字段 schema。
|
|
91
|
+
* @returns 打了 volatile 标记的字段(宿主不支持时就是原字段)。
|
|
92
|
+
*/
|
|
93
|
+
const volatileField = (field) => (
|
|
94
|
+
typeof field?.volatile === "function" ? field.volatile() : field
|
|
95
|
+
);
|
|
96
|
+
|
|
93
97
|
export const Config = schema.object({
|
|
94
98
|
root: schema.string().default(join(homedir(), ".t-team", "experts")),
|
|
95
99
|
zhRoot: schema.string().default(join(homedir(), ".t-team", "zh")),
|
|
@@ -110,16 +114,6 @@ export const Config = schema.object({
|
|
|
110
114
|
summonTaskMaxChars: schema.natural().min(1).default(8000),
|
|
111
115
|
/** list_t_experts 里简介的截断长度。 */
|
|
112
116
|
descriptionLimit: schema.natural().min(1).default(120),
|
|
113
|
-
/** 团队引擎配置(team-profiles.py 的产物);留空则取 root 同级目录下的 t-team.config.json。 */
|
|
114
|
-
engineConfig: schema.string(),
|
|
115
|
-
/** 引擎的团队状态目录(工作区内,相对路径)。 */
|
|
116
|
-
stateDir: schema.string().default(".agent-teams"),
|
|
117
|
-
/** 队员子代理使用的 provider 名。 */
|
|
118
|
-
memberProvider: schema.string().default("spawn"),
|
|
119
|
-
/** 队员默认模型;留空 = 跟随队长。 */
|
|
120
|
-
memberModel: schema.string(),
|
|
121
|
-
/** 一支团队最多几名成员。 */
|
|
122
|
-
maxMembers: schema.natural().min(1).default(8),
|
|
123
117
|
/**
|
|
124
118
|
* 定时任务默认绑定的工作区(首次使用时自动建/复用)。
|
|
125
119
|
*
|
|
@@ -130,14 +124,32 @@ export const Config = schema.object({
|
|
|
130
124
|
*/
|
|
131
125
|
scheduleWorkspaceTitle: schema.string().default("定时任务"),
|
|
132
126
|
scheduleWorkspaceDir: schema.string().default(join(homedir(), "tesk")),
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 以下五个是**用户偏好**(专家启停 + 界面开关),不是部署配置。
|
|
130
|
+
*
|
|
131
|
+
* 为什么它们必须待在 Config 里:DSH 0.1.7 起设置服务不再提供 `register()` / 命名空间句柄
|
|
132
|
+
* (PR #4587「project volatile Config through profile-backed forms」),用户偏好改由
|
|
133
|
+
* 「entry Config 的 volatile 字段 + profile patch」承载。字段不进 Config,设置服务就
|
|
134
|
+
* 列不出这个 entry —— 实测表现是服务端抛「T专家 设置段尚未注册」、设置页整块白屏、
|
|
135
|
+
* `list_t_experts` 报「还没有启用任何专家」(2026-09-22 定位)。
|
|
136
|
+
*
|
|
137
|
+
* 旧宿主(≤0.1.6)读写的仍是 settings 命名空间(见 apply 里的 prefsMode),
|
|
138
|
+
* 这些字段只是多出来的、带默认值的普通配置,不影响旧行为。
|
|
139
|
+
*/
|
|
140
|
+
enabled: volatileField(schema.array(schema.string()).default([])),
|
|
141
|
+
/** 兼容位:早期实现用的「已播种」布尔标记。幂等判据已改用 enabledSeedVersion。 */
|
|
142
|
+
enabledSeeded: volatileField(schema.boolean().default(false)),
|
|
143
|
+
/** 「默认全部启用」已按哪一版策略播种过;+1 即对所有用户重新播种一次。 */
|
|
144
|
+
enabledSeedVersion: volatileField(schema.number().default(0)),
|
|
145
|
+
/** 是否显示侧栏「定时事项」按钮(关掉 = 定时功能整体停用)。 */
|
|
146
|
+
showScheduleButton: volatileField(schema.boolean().default(true)),
|
|
147
|
+
/** 是否显示窗口右上角的「活跃指示」。 */
|
|
148
|
+
showActivePulse: volatileField(schema.boolean().default(true)),
|
|
133
149
|
});
|
|
134
150
|
|
|
135
151
|
/** 供 remote 服务读取名册的服务名。 */
|
|
136
152
|
export const CATALOG_SERVICE = "tTeamCatalog";
|
|
137
|
-
/** 小队定义服务(设置页「小队」标签读写 teams.json)。 */
|
|
138
|
-
export const SQUAD_SERVICE = "tTeamSquads";
|
|
139
|
-
/** 团队运行服务(设置页「团队」标签列活动/归档团队、停止团队)。 */
|
|
140
|
-
export const TEAM_SERVICE = "tTeamTeams";
|
|
141
153
|
/**
|
|
142
154
|
* 定时事项服务(remote 侧与服务访问器共用)。
|
|
143
155
|
*
|
|
@@ -173,8 +185,6 @@ const settingsSchema = schema.object({
|
|
|
173
185
|
* 而且置了 true 之后连改逻辑都救不回来。换成版本号后,+1 就能对所有人重新播种一次。
|
|
174
186
|
*/
|
|
175
187
|
enabledSeedVersion: schema.number().default(0),
|
|
176
|
-
/** 是否自动显示右上角的任务徽章(界面偏好,关掉后只在输入区浮层里手动查看)。 */
|
|
177
|
-
showTaskBadge: schema.boolean().default(true),
|
|
178
188
|
/**
|
|
179
189
|
* 是否显示侧栏「定时事项」按钮(界面偏好)。
|
|
180
190
|
*
|
|
@@ -186,7 +196,7 @@ const settingsSchema = schema.object({
|
|
|
186
196
|
* 是否显示窗口右上角的「活跃指示」(心电图图标,界面偏好)。
|
|
187
197
|
*
|
|
188
198
|
* 彩色 = 有会话正在活动(自己在跑、或子代理在跑)**或**有待查看的完成提醒;
|
|
189
|
-
* 灰色 =
|
|
199
|
+
* 灰色 = 全部空闲且没有待看。它是**全局**的(挂会话标题栏的 utilities 槽),
|
|
190
200
|
* 不受"当前打开哪个会话"影响。
|
|
191
201
|
*/
|
|
192
202
|
showActivePulse: schema.boolean().default(true),
|
|
@@ -196,7 +206,7 @@ const settingsSchema = schema.object({
|
|
|
196
206
|
* 把启动期名册同步的结果写进日志:**送达的内容要留痕,送不到的内容要出声**。
|
|
197
207
|
*
|
|
198
208
|
* 为什么单独成函数:旧实现只有一句 `copied.length > 0` 才打印的 info —— 于是「升级后
|
|
199
|
-
*
|
|
209
|
+
* 包内新增的专家/译文一个都没到达」这件事在日志里**完全不存在**(2026-09-13 定性的
|
|
200
210
|
* 恒存缺陷)。这里把「漂移状态变化」变成唯一告警条件:既不静默,也不每次启动刷屏。
|
|
201
211
|
* @param logger - `ctx.logger`(可能缺席)。
|
|
202
212
|
* @param seeded - `seedData` 的报告。
|
|
@@ -265,7 +275,6 @@ export function apply(ctx, config) {
|
|
|
265
275
|
const maxDepth = config.maxDepth;
|
|
266
276
|
|
|
267
277
|
// ---- 启动期同步:包内自带名册快照 → 可写数据目录(缺失就补、只读对齐、用户内容不动)----
|
|
268
|
-
// 必须在下面同步读取 t-team.config.json / teams.json 之前完成,否则首次安装会读到空配置。
|
|
269
278
|
const seeded = seedData({ root: config.root, zhRoot: config.zhRoot });
|
|
270
279
|
logSeedReport(ctx.logger, seeded, dirname(config.root));
|
|
271
280
|
if (!seeded.ok) {
|
|
@@ -275,7 +284,7 @@ export function apply(ctx, config) {
|
|
|
275
284
|
}
|
|
276
285
|
|
|
277
286
|
// ---- 定时事项引擎(T专家 自己的数据文件 + croner 调度)----
|
|
278
|
-
// 数据落在**本插件自己的数据根**里(`~/.t-team/schedule.json
|
|
287
|
+
// 数据落在**本插件自己的数据根**里(`~/.t-team/schedule.json`):
|
|
279
288
|
// 刻意不碰宿主的 dsh_tasks 存储域,也不碰 dsh-tasks 插件的任何文件——两个插件同时装着时,
|
|
280
289
|
// 各自的任务列表、执行记录、定时器完全隔离,谁先加载都不影响对方。
|
|
281
290
|
const scheduleEngine = createScheduleEngine({
|
|
@@ -285,8 +294,11 @@ export function apply(ctx, config) {
|
|
|
285
294
|
// 文案按**当前**语言现取:错误消息会出现在面板上,切了语言不该还留着旧语言。
|
|
286
295
|
t: (key, params) => t(locale(), key, params),
|
|
287
296
|
defaultCwd: process.cwd(),
|
|
288
|
-
//
|
|
289
|
-
|
|
297
|
+
// 提示词**原样提交**,不做任何改写:定时任务开的新会话与手动开的会话挂同一份
|
|
298
|
+
// system prompt(那节只在子会话里返回空串),里面已经写着「看到 `@专家名` 就
|
|
299
|
+
// summon_t_expert FIRST」。以前这里会把 `@名字` 翻译成一段"请用 summon_t_expert
|
|
300
|
+
// 召唤专家「…」"的指令,导致同一个任务在定时任务里显示成一长段模板、在手动对话里
|
|
301
|
+
// 却只是 `@名字` —— 两条入口观感不一致(用户实测反馈)。现在统一按原文提交。
|
|
290
302
|
// 工作区需求:定时任务默认绑到「定时任务」工作区(目录 <默认目录>/tesk),也可自定义目录。
|
|
291
303
|
ensureWorkspace: (path, title) => ensureWorkspace(path, title),
|
|
292
304
|
// 先按「开」起来;settings 就绪后立刻按真实偏好对齐(见下面的 inject 回调)。
|
|
@@ -296,12 +308,16 @@ export function apply(ctx, config) {
|
|
|
296
308
|
ctx.reflect.provide(SCHEDULE_SERVICE, scheduleEngine);
|
|
297
309
|
|
|
298
310
|
/**
|
|
299
|
-
*
|
|
311
|
+
* 确保一个工作区存在,并返回工作区实体。
|
|
300
312
|
*
|
|
301
313
|
* 顺序:目录不存在先 mkdir(宿主的工作区 create **只接受已存在的目录**)→ 交给
|
|
302
314
|
* workspaceRegistry.create:它内部对同路径工作区是**幂等复用**(createCanonical 第一件事
|
|
303
315
|
* 就是按 path 查找已有实体),所以重复调用不会造出第二个「定时任务」工作区。
|
|
304
316
|
*
|
|
317
|
+
* **但"同路径幂等"挡不住"路径漂移"**:只要默认目录变过一次、或者历史数据里存过另一个路径,
|
|
318
|
+
* 就会各自建出一个同名「定时任务」——侧栏里出现两个同名工作区(用户实测报过)。所以默认
|
|
319
|
+
* 工作区这条路径上,先按标题找一遍已存在的工作区直接复用,把"共用一个"变成硬保证。
|
|
320
|
+
*
|
|
305
321
|
* @param path - 目录绝对路径;空串表示用配置里的 scheduleWorkspaceDir。
|
|
306
322
|
* @param title - 工作区标题;缺省时:默认工作区用配置里的 scheduleWorkspaceTitle,自定义目录用目录名。
|
|
307
323
|
*/
|
|
@@ -310,8 +326,13 @@ export function apply(ctx, config) {
|
|
|
310
326
|
if (registry === undefined || typeof registry.create !== "function") {
|
|
311
327
|
throw new Error("宿主没有可用的工作区服务(workspaceRegistry.create),无法准备定时任务工作区。");
|
|
312
328
|
}
|
|
313
|
-
const raw = typeof path === "string" && path.trim() !== "" ? path.trim() : config.scheduleWorkspaceDir;
|
|
314
329
|
const custom = typeof path === "string" && path.trim() !== "";
|
|
330
|
+
// 默认工作区(调用方没指定目录):已经有同名工作区就直接复用,不再按默认目录另起一个。
|
|
331
|
+
if (!custom) {
|
|
332
|
+
const existing = findWorkspaceByTitle(registry, config.scheduleWorkspaceTitle);
|
|
333
|
+
if (existing !== undefined) return existing;
|
|
334
|
+
}
|
|
335
|
+
const raw = custom ? path.trim() : config.scheduleWorkspaceDir;
|
|
315
336
|
const target = resolve(raw);
|
|
316
337
|
await mkdir(target, { recursive: true });
|
|
317
338
|
// 自定义目录的工作区标题缺省取目录名:否则几个自定义目录会挤成同名「定时任务」,侧栏里分不出来。
|
|
@@ -319,60 +340,28 @@ export function apply(ctx, config) {
|
|
|
319
340
|
? title
|
|
320
341
|
: (custom ? basename(target) : config.scheduleWorkspaceTitle);
|
|
321
342
|
const entity = await registry.create(target, label);
|
|
322
|
-
|
|
343
|
+
// 把 entity 透出去:执行路径要调 entity.attachSession 把新建的 session 挂回工作区 sessionIds
|
|
344
|
+
// (侧栏的"工作区 → 会话"分组就是从这条列表里取的;不挂上用户感知是"任务跑了但侧栏没新会话")。
|
|
345
|
+
return entity;
|
|
323
346
|
}
|
|
324
347
|
|
|
325
348
|
/**
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
* 面板的「T专家」挑选器往提示词开头填的就是 `@专家名`(可以连着填好几位);而执行时开的是一个
|
|
329
|
-
* **新会话**:它没有输入框那套引用芯片机制,只看到几个 `@名字`,模型不一定会去请人。所以这里按
|
|
330
|
-
* 当前名册把名字解析成"已启用专家",并把提示词改写成 summon_t_expert(1 位)/ summon_t_experts
|
|
331
|
-
* (多位)指令;解析不出来(名字不在已启用名册里)就**原样提交** —— 不猜,也不改写用户没指定的东西。
|
|
349
|
+
* 在宿主的工作区注册表里按标题找第一个工作区(找不到 / 宿主没提供 list 就返回 undefined)。
|
|
332
350
|
*
|
|
333
|
-
*
|
|
351
|
+
* 用于「默认工作区只有一个」这条保证:同名工作区已经存在时复用它,而不是按当前默认目录
|
|
352
|
+
* 再建一个。`WorkspaceEntity` 自带 id / path / title / attachSession,正是执行路径需要的形状。
|
|
334
353
|
*
|
|
335
|
-
* @param
|
|
336
|
-
* @
|
|
354
|
+
* @param registry - 宿主的 workspaceRegistry。
|
|
355
|
+
* @param title - 要匹配的工作区标题。
|
|
337
356
|
*/
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
const hits = [];
|
|
346
|
-
for (const expert of current?.experts ?? []) {
|
|
347
|
-
if (expert?.conflict === true || enabled.has(expert.slug) !== true) continue;
|
|
348
|
-
const names = [expert.name, expert.nameEn, expert.slug].filter((name) => typeof name === "string" && name !== "");
|
|
349
|
-
for (const name of names) {
|
|
350
|
-
if (!raw.includes(`@${name}`)) continue;
|
|
351
|
-
hits.push({
|
|
352
|
-
name,
|
|
353
|
-
display: currentLocale === "en" ? (expert.nameEn || expert.name) : (expert.name || expert.nameEn),
|
|
354
|
-
slug: expert.slug,
|
|
355
|
-
});
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
if (hits.length === 0) return raw;
|
|
359
|
-
// 长名先摘:名字互为子串时(「X」与「X 助理」)先处理长的,免得把长的截成短的;同一专家按 slug 去重。
|
|
360
|
-
hits.sort((a, b) => b.name.length - a.name.length);
|
|
361
|
-
const picked = [];
|
|
362
|
-
const seen = new Set();
|
|
363
|
-
for (const hit of hits) {
|
|
364
|
-
if (seen.has(hit.slug)) continue;
|
|
365
|
-
seen.add(hit.slug);
|
|
366
|
-
picked.push(hit);
|
|
367
|
-
}
|
|
368
|
-
let task = raw;
|
|
369
|
-
for (const hit of picked) task = task.split(`@${hit.name}`).join(" ");
|
|
370
|
-
task = task.replace(/[ \t]{2,}/gu, " ").trim();
|
|
371
|
-
if (picked.length === 1) {
|
|
372
|
-
return t(currentLocale, "schedule.expertInvoke", { name: picked[0].display, task });
|
|
357
|
+
function findWorkspaceByTitle(registry, title) {
|
|
358
|
+
if (typeof registry?.list !== "function") return undefined;
|
|
359
|
+
try {
|
|
360
|
+
return registry.list().find((entity) => entity?.title === title);
|
|
361
|
+
} catch {
|
|
362
|
+
// list 不可用(宿主版本差异)不致命:退回"按路径 create",最多是没享受到复用。
|
|
363
|
+
return undefined;
|
|
373
364
|
}
|
|
374
|
-
const names = picked.map((hit) => hit.display).join(currentLocale === "en" ? ", " : "、");
|
|
375
|
-
return t(currentLocale, "schedule.expertInvokeMany", { names, task });
|
|
376
365
|
}
|
|
377
366
|
|
|
378
367
|
/**
|
|
@@ -398,10 +387,19 @@ export function apply(ctx, config) {
|
|
|
398
387
|
//
|
|
399
388
|
// 正解是用 cordis 的 `ctx.inject`:它等的就是「该服务就绪」这件事,服务迟到也能补上。
|
|
400
389
|
// `ctx.inject(["settings"])` 同时保留了「宿主真没有 settings 就不注册、只降级」的语义。
|
|
401
|
-
/** @type {undefined | { get: () => any }}
|
|
390
|
+
/** @type {undefined | { get: () => any }} 设置段句柄;只有旧宿主(≤0.1.6)的 register 会给。 */
|
|
402
391
|
let scope;
|
|
403
392
|
/** @type {any} settings 服务本体;就绪后由 inject 赋值,`requireSettings()` 用它。 */
|
|
404
393
|
let settingsService;
|
|
394
|
+
/**
|
|
395
|
+
* 偏好来源,两条路径**必须二选一**,不能"读得到就用、读不到就退":
|
|
396
|
+
* - `scope`:≤0.1.6 —— 偏好存在 settings 命名空间里,只有 register 返回的句柄读得到。
|
|
397
|
+
* - `entry`:0.1.7+ —— 偏好就是本 entry 的 Config 字段(volatile 引用),直接读 Config。
|
|
398
|
+
*
|
|
399
|
+
* 为什么不能一律读 Config:0.1.7 给这些字段带了 schema 默认值,在旧宿主上误读 Config
|
|
400
|
+
* 会把用户真实的启用名单读成一份空数组 —— 那正是这次事故的表象(「专家列表空了」)。
|
|
401
|
+
*/
|
|
402
|
+
let prefsMode = "none";
|
|
405
403
|
if (typeof ctx.inject !== "function") {
|
|
406
404
|
// 只可能出现在极简/测试上下文里:如实说明,不要静默。
|
|
407
405
|
ctx.logger?.warn?.("[t-team] 上下文没有 inject:无法等待 settings 就绪,专家启停不可用。");
|
|
@@ -412,27 +410,72 @@ export function apply(ctx, config) {
|
|
|
412
410
|
ctx.logger?.warn?.("[t-team] 正在等待 settings 服务就绪以注册设置段;若宿主不提供它,专家启停与设置页的 T专家 段将不可用。");
|
|
413
411
|
ctx.inject(["settings"], (scoped) => {
|
|
414
412
|
settingsService = scoped.settings ?? scoped.get?.("settings");
|
|
415
|
-
if (typeof settingsService?.register
|
|
416
|
-
|
|
413
|
+
if (typeof settingsService?.register === "function") {
|
|
414
|
+
// ── 旧宿主(≤0.1.6):按命名空间注册,读写都走返回的句柄。
|
|
415
|
+
scope = settingsService.register(SETTINGS_NAMESPACE, settingsSchema, {
|
|
416
|
+
base: { enabled: [], enabledSeeded: false, enabledSeedVersion: 0, showScheduleButton: true, showActivePulse: true },
|
|
417
|
+
applies: "live",
|
|
418
|
+
validate: (value) => {
|
|
419
|
+
const list = value?.enabled;
|
|
420
|
+
if (!Array.isArray(list) || list.some((item) => typeof item !== "string")) {
|
|
421
|
+
throw new Error("t-team.enabled 必须是字符串数组");
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
prefsMode = "scope";
|
|
426
|
+
// 设置段一就绪就把定时事项开关对齐真实偏好:base 默认是「开」,而用户可能早就关过 ——
|
|
427
|
+
// 不对齐的话,插件每次启动都会先把已经关掉的功能重新跑起来。
|
|
428
|
+
applyScheduleArmed(scope?.get?.());
|
|
417
429
|
return;
|
|
418
430
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
431
|
+
if (typeof settingsService?.mutate === "function") {
|
|
432
|
+
// ── 0.1.7+:设置服务改由「entry Config 的 volatile 字段 + profile patch」承载
|
|
433
|
+
// (PR #4587 移除了 register/SettingsScope),没有句柄可拿,也没有东西要注册。
|
|
434
|
+
// 读走 Config;写与修订号仍走 settings.mutate / settings.describe ——
|
|
435
|
+
// 它们的 ns 就是 profile entry id,而本插件的 entry id 正是 SETTINGS_NAMESPACE。
|
|
436
|
+
prefsMode = "entry";
|
|
437
|
+
// 声明「本插件自带设置页」:否则 0.1.7 会按 Config schema 为这个 entry 自动生成
|
|
438
|
+
// 一个表单页,与插件自己注册的「T专家」页重复(宿主 README 的适配指引即此)。
|
|
439
|
+
if (typeof settingsService?.configure === "function" && typeof ctx.effect === "function") {
|
|
440
|
+
ctx.effect(
|
|
441
|
+
() => settingsService.configure({ auto: false }, ctx.fiber),
|
|
442
|
+
"t-team: 设置页策略",
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
if (typeof ctx.on === "function") {
|
|
446
|
+
// volatile 字段的变更不会重挂插件,只发这个事件。定时开关必须跟着走:
|
|
447
|
+
// 否则用户在面板里关掉「定时事项」后,后台定时器还在开新会话。
|
|
448
|
+
ctx.on("loader/volatile-update", () => { applyScheduleArmed(readPrefs()); });
|
|
449
|
+
}
|
|
450
|
+
applyScheduleArmed(readPrefs());
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
ctx.logger?.warn?.("[t-team] settings 服务既没有 register 也没有 mutate:专家启停不可用(名册与召唤不受影响)。");
|
|
432
454
|
});
|
|
433
455
|
}
|
|
456
|
+
/**
|
|
457
|
+
* 读偏好段(专家启停 + 界面开关)。
|
|
458
|
+
*
|
|
459
|
+
* 来源严格按 `prefsMode` 二选一(理由见 prefsMode 注释);0.1.7 的 Config 字段是
|
|
460
|
+
* volatile 引用,统一 unwrap 后再用。
|
|
461
|
+
* @returns 偏好对象;设置段不可用时为 undefined(调用方按各自语义处理)。
|
|
462
|
+
*/
|
|
463
|
+
function readPrefs() {
|
|
464
|
+
if (prefsMode === "scope") return scope?.get?.();
|
|
465
|
+
if (prefsMode === "entry") {
|
|
466
|
+
const source = config ?? {};
|
|
467
|
+
return {
|
|
468
|
+
enabled: unwrapVolatile(source.enabled),
|
|
469
|
+
enabledSeeded: unwrapVolatile(source.enabledSeeded),
|
|
470
|
+
enabledSeedVersion: unwrapVolatile(source.enabledSeedVersion),
|
|
471
|
+
showScheduleButton: unwrapVolatile(source.showScheduleButton),
|
|
472
|
+
showActivePulse: unwrapVolatile(source.showActivePulse),
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
return undefined;
|
|
476
|
+
}
|
|
434
477
|
const enabledSet = () => new Set(
|
|
435
|
-
(
|
|
478
|
+
(readPrefs()?.enabled ?? []).filter((value) => typeof value === "string"),
|
|
436
479
|
);
|
|
437
480
|
/**
|
|
438
481
|
* 需要读写「专家启停」的路径在 settings 缺席时必须**响亮失败**。
|
|
@@ -587,7 +630,7 @@ export function apply(ctx, config) {
|
|
|
587
630
|
*/
|
|
588
631
|
async function seedDefaultEnabled() {
|
|
589
632
|
if (settingsService === undefined) return;
|
|
590
|
-
const state =
|
|
633
|
+
const state = readPrefs();
|
|
591
634
|
if (state === undefined || (state.enabledSeedVersion ?? 0) >= ENABLED_SEED_VERSION) return;
|
|
592
635
|
const nextEnabled = usable().map((expert) => expert.slug);
|
|
593
636
|
// 名册残缺(例如分区目录暂时读不到)时绝不写:否则等于用一份空名单把用户的启用项抹掉。
|
|
@@ -948,17 +991,17 @@ export function apply(ctx, config) {
|
|
|
948
991
|
}
|
|
949
992
|
|
|
950
993
|
/**
|
|
951
|
-
*
|
|
952
|
-
*
|
|
994
|
+
* 读界面偏好(定时事项按钮 + 活跃指示)。
|
|
995
|
+
* 读当前值统一走 `readPrefs()`(与 enabledSet() 同一来源):旧宿主是 settings 命名空间句柄,
|
|
996
|
+
* 0.1.7+ 是本 entry Config 的 volatile 字段。
|
|
953
997
|
* 直接调 settingsService.get().value 在本机版本的设置服务上不成立(读到 undefined 抛错)。
|
|
954
998
|
*
|
|
955
999
|
* 两个键都**必有**且缺省为 true:设置文件里没有这个键时保持与默认一致,
|
|
956
1000
|
* 客户端于是不必到处兜 undefined(wire schema 也是这么声明的)。
|
|
957
1001
|
*/
|
|
958
1002
|
function getUiPrefs() {
|
|
959
|
-
const current =
|
|
1003
|
+
const current = readPrefs();
|
|
960
1004
|
return {
|
|
961
|
-
showTaskBadge: current?.showTaskBadge !== false,
|
|
962
1005
|
showScheduleButton: current?.showScheduleButton !== false,
|
|
963
1006
|
showActivePulse: current?.showActivePulse !== false,
|
|
964
1007
|
};
|
|
@@ -968,7 +1011,6 @@ export function apply(ctx, config) {
|
|
|
968
1011
|
function normalizePrefsPatch(patch) {
|
|
969
1012
|
const source = patch !== null && typeof patch === "object" ? patch : {};
|
|
970
1013
|
return {
|
|
971
|
-
...(typeof source.showTaskBadge === "boolean" ? { showTaskBadge: source.showTaskBadge } : {}),
|
|
972
1014
|
...(typeof source.showScheduleButton === "boolean" ? { showScheduleButton: source.showScheduleButton } : {}),
|
|
973
1015
|
...(typeof source.showActivePulse === "boolean" ? { showActivePulse: source.showActivePulse } : {}),
|
|
974
1016
|
};
|
|
@@ -985,7 +1027,6 @@ export function apply(ctx, config) {
|
|
|
985
1027
|
await settingsService.mutate(
|
|
986
1028
|
SETTINGS_NAMESPACE,
|
|
987
1029
|
[
|
|
988
|
-
{ op: "set", path: ["showTaskBadge"], value: next.showTaskBadge },
|
|
989
1030
|
{ op: "set", path: ["showScheduleButton"], value: next.showScheduleButton },
|
|
990
1031
|
{ op: "set", path: ["showActivePulse"], value: next.showActivePulse },
|
|
991
1032
|
],
|
|
@@ -1081,7 +1122,7 @@ export function apply(ctx, config) {
|
|
|
1081
1122
|
// ---- 工具 ----
|
|
1082
1123
|
ctx.tools.register(defineTool({
|
|
1083
1124
|
name: "list_t_experts",
|
|
1084
|
-
description: "List the enabled T专家 domain experts grouped by division. Without a division filter it returns division names and counts; pass a division to expand it with expert names and descriptions.
|
|
1125
|
+
description: "List the enabled T专家 domain experts grouped by division. Without a division filter it returns division names and counts; pass a division to expand it with expert names, slugs and descriptions. Use this only to DISCOVER an expert when the user named none; if the user already named one (a leading `@专家名` chip, or an explicit name/slug), skip this and call summon_t_expert directly.",
|
|
1085
1126
|
parameters: {
|
|
1086
1127
|
division: {
|
|
1087
1128
|
type: "string",
|
|
@@ -1146,13 +1187,13 @@ export function apply(ctx, config) {
|
|
|
1146
1187
|
|
|
1147
1188
|
ctx.tools.register(defineTool({
|
|
1148
1189
|
name: "summon_t_expert",
|
|
1149
|
-
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.
|
|
1190
|
+
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. If the user already named the expert (a leading `@专家名` chip, or an explicit name/slug), summon it right away — no list_t_experts lookup is needed. Call list_t_experts only when no expert is named.",
|
|
1150
1191
|
// 预算声明:单次召唤给 10 分钟。**注意它只是声明**(`dsh-tools` 的 registry 不强制 deadline),
|
|
1151
1192
|
// 真正生效要宿主装配 `@deepseek-ai/dsh-tool-call-timeout-policy`;这里写出来是为了让
|
|
1152
1193
|
// 「最坏要等多久」有个明确契约,而不是无界挂起。
|
|
1153
1194
|
timeoutMs: 600_000,
|
|
1154
1195
|
parameters: {
|
|
1155
|
-
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).
|
|
1196
|
+
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). A name copied from a composer `@` chip works as-is; variants and an emoji prefix (e.g. \"🧠 心理学家\") resolve to the same expert, so no exact-spelling lookup is required." },
|
|
1156
1197
|
task: { type: "string", required: true, description: "The complete, self-contained task to give the expert. Include all necessary context." },
|
|
1157
1198
|
},
|
|
1158
1199
|
output: {
|
|
@@ -1174,7 +1215,7 @@ export function apply(ctx, config) {
|
|
|
1174
1215
|
|
|
1175
1216
|
ctx.tools.register(defineTool({
|
|
1176
1217
|
name: "summon_t_experts",
|
|
1177
|
-
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.`,
|
|
1218
|
+
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. Each expert is usually pre-named by the caller (a composer @ chip, or an explicit @专家名 anywhere in the text — including a scheduled task's stored prompt) — when so, summon directly without list_t_experts; only call list_t_experts when no expert is named.`,
|
|
1178
1219
|
// 预算声明:批量最坏是 ceil(批大小 / 并发) 轮串行,给 20 分钟。
|
|
1179
1220
|
// 与单次召唤同理,它只是声明,强制 deadline 需要宿主装配 timeout-policy wrapper。
|
|
1180
1221
|
timeoutMs: 1_200_000,
|
|
@@ -1240,735 +1281,7 @@ export function apply(ctx, config) {
|
|
|
1240
1281
|
},
|
|
1241
1282
|
}));
|
|
1242
1283
|
|
|
1243
|
-
// ---- 内置团队引擎(源自第三方引擎、已品牌化并入;小队数据来自 team-profiles.py 生成的 JSON)----
|
|
1244
|
-
// 配置路径与引擎标量都是 Config 字段(可从 cordis.yml 覆盖):标量以 Config 为准,
|
|
1245
|
-
// 数据文件只提供生成物本身(profiles / memberMaxDepth)——小队目录是数据,不是可调参数。
|
|
1246
|
-
const engineConfigDir = dirname(config.root);
|
|
1247
|
-
const engineConfigPath = typeof config.engineConfig === "string" && config.engineConfig !== ""
|
|
1248
|
-
? config.engineConfig
|
|
1249
|
-
: ["t-team.config.json", "agent-teams.config.json"]
|
|
1250
|
-
.map((file) => join(engineConfigDir, file))
|
|
1251
|
-
.find((candidate) => existsSync(candidate)) ?? join(engineConfigDir, "t-team.config.json");
|
|
1252
|
-
/**
|
|
1253
|
-
* 读并解析引擎配置(**每次调用都重新读盘**)。
|
|
1254
|
-
*
|
|
1255
|
-
* 为什么必须是函数而不是启动期的一个常量:热重载要在保存小队后重新读这份文件。启动期快照
|
|
1256
|
-
* 正是「改一项要重启」的成因(audit/01-spec-checklist.md:235)。
|
|
1257
|
-
*
|
|
1258
|
-
* 失败形态刻意分开(两条路径都不可静默):
|
|
1259
|
-
* · 文件不存在 → `{ config: {}, detail: "..." }`:还没生成过小队是**合法状态**(自定义 root
|
|
1260
|
-
* 的部署就是这样),降级但把原因送到日志/`/t`/系统提示段;
|
|
1261
|
-
* · 存在但内容不合法 → `{ config: null, failure: "..." }`:自带错配,**必须响亮**。
|
|
1262
|
-
* 启动路径沿用旧行为(直接让插件加载失败),热重载路径见 `reloadEngine()`(失败则保留旧
|
|
1263
|
-
* 引擎并留可诊断信号)。
|
|
1264
|
-
*/
|
|
1265
|
-
const loadEngineConfig = () => {
|
|
1266
|
-
if (!existsSync(engineConfigPath)) {
|
|
1267
|
-
return { config: {}, failure: "", detail: `找不到团队引擎配置 ${engineConfigPath}(跑一次 team-profiles.py 生成小队后即可用 /t 拉起团队)` };
|
|
1268
|
-
}
|
|
1269
|
-
let parsed;
|
|
1270
|
-
try {
|
|
1271
|
-
parsed = JSON.parse(readFileSync(engineConfigPath, "utf8"));
|
|
1272
|
-
} catch (error) {
|
|
1273
|
-
return { config: null, failure: `团队引擎配置无法解析:${engineConfigPath}(${error instanceof Error ? error.message : String(error)})。它是 team-profiles.py 的产物,删掉后重跑一次即可重建。`, detail: "" };
|
|
1274
|
-
}
|
|
1275
|
-
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1276
|
-
return { config: null, failure: `团队引擎配置必须是 JSON 对象:${engineConfigPath}`, detail: "" };
|
|
1277
|
-
}
|
|
1278
|
-
if (parsed.profiles !== undefined && (parsed.profiles === null || typeof parsed.profiles !== "object" || Array.isArray(parsed.profiles))) {
|
|
1279
|
-
return { config: null, failure: `团队引擎配置的 profiles 必须是对象:${engineConfigPath}`, detail: "" };
|
|
1280
|
-
}
|
|
1281
|
-
return { config: parsed, failure: "", detail: "" };
|
|
1282
|
-
};
|
|
1283
|
-
const initialEngineConfig = loadEngineConfig();
|
|
1284
|
-
if (initialEngineConfig.failure !== "") throw new Error(initialEngineConfig.failure);
|
|
1285
|
-
// 引擎与名册解耦:引擎挂载失败(例如 Harness 子代理契约不匹配)时,
|
|
1286
|
-
// T专家 的专家名册、@ 召唤、/t 列表仍然可用,只是团队功能不可用。
|
|
1287
|
-
// 这是**已声明的降级**,不是静默跳过:原因会写进日志、经 /t 报出,并进系统提示段。
|
|
1288
|
-
//
|
|
1289
|
-
// `engineConfig` / `engineFiber` 是热重载的**活状态**(不是启动期快照):
|
|
1290
|
-
// · `engineConfig` —— 当前挂载所用的配置对象;每次成功重挂会换成新解析出来的对象;
|
|
1291
|
-
// · `engineFiber` —— 当前引擎 fiber;重载时 dispose 它,再用新配置 `ctx.plugin()` 重挂;
|
|
1292
|
-
// · `lastMountError` —— 最近一次挂载/重挂失败的原因,挂载成功时清空。
|
|
1293
|
-
// 三者都刻意与 `Config` 分开:它们是**运行态**,`Config` 只提供不随小队定义变化的那几个标量。
|
|
1294
|
-
let engineConfig = initialEngineConfig.config;
|
|
1295
|
-
let engineFiber;
|
|
1296
|
-
let lastMountError = "";
|
|
1297
|
-
const engineState = {
|
|
1298
|
-
ok: false,
|
|
1299
|
-
detail: initialEngineConfig.detail,
|
|
1300
|
-
/**
|
|
1301
|
-
* 最近一次**热重载失败**的原因(成功时清空)。
|
|
1302
|
-
*
|
|
1303
|
-
* 与 `lastMountError` 分开是有意的:热重载失败时**旧引擎仍然可用**,所以不能把引擎整体报成
|
|
1304
|
-
* "不可用"(那会让用户以为团队功能全没了);但"盘上是新小队、运行中的引擎还是旧的"这件事
|
|
1305
|
-
* 必须对模型/用户可见 —— 否则用户会看到 `/t` 列着新小队、却用它建不出队,正是本任务要消灭
|
|
1306
|
-
* 的那个分叉。提示段靠它区分「引擎从未挂载过」与「挂载过、但这次热重载失败」。
|
|
1307
|
-
*/
|
|
1308
|
-
reloadFailure: "",
|
|
1309
|
-
/**
|
|
1310
|
-
* 这次重挂失败的**来源**:`"squad"`(用户保存小队触发的热重载)或 `"harness"`(宿主投递符号
|
|
1311
|
-
* 换了口径后的自动重挂)。提示段的标题与建议按它换 —— 否则会把"宿主契约变了"说成
|
|
1312
|
-
* "你保存的小队没生效",文案与事实不一致。
|
|
1313
|
-
*/
|
|
1314
|
-
reloadFailureKind: "squad",
|
|
1315
|
-
};
|
|
1316
|
-
/** 首次探到「调度成功但工具没注册」时的告警只打一次(此后每轮都探,但不再刷日志)。 */
|
|
1317
|
-
let engineNotReadyWarned = false;
|
|
1318
|
-
const NOT_READY_DETAIL = "内置团队引擎的工具没有注册成功(引擎可能还在等待它声明的宿主服务)";
|
|
1319
|
-
/**
|
|
1320
|
-
* 引擎是否**真的**就绪 —— 必须真机探测,不能只看 `ctx.plugin()` 有没有抛错。
|
|
1321
|
-
*
|
|
1322
|
-
* cordis 的 `ctx.plugin()` 只**构造并调度** fiber,不等子插件 `apply` 跑完;而引擎声明的
|
|
1323
|
-
* `inject`(lib/teams/index.js:39 的 tools/llm/subagents/systemPrompt/agents)只要缺一个,
|
|
1324
|
-
* cordis 就会**静默挂起它的 fiber**:不抛错、不告警,13 个 t_team_* 工具一个都不会注册。
|
|
1325
|
-
* 所以唯一可靠的判据是「工具真在注册表里」:`engineState.ok` 只说明调度成功,
|
|
1326
|
-
* `ctx.tools.get("t_team_create")` 才说明引擎跑起来了。
|
|
1327
|
-
*
|
|
1328
|
-
* **探测时机很关键**:宿主里子插件的 fiber 可能在同一同步段末尾、也可能更晚才激活,
|
|
1329
|
-
* 所以**不在 mount 的那一刻探**(那一刻必然探空,会把每次正常启动都误报成降级)。
|
|
1330
|
-
* 探测发生在所有真实读取点(系统提示段 / `/t` 命令 / 热重载收口),那时引擎早已定局;
|
|
1331
|
-
* 且每一轮都重新探,只在**第一次**探到假时补一条 warn,避免刷日志。
|
|
1332
|
-
* @returns 引擎工具是否已注册。
|
|
1333
|
-
*/
|
|
1334
|
-
const engineReady = () => {
|
|
1335
|
-
if (engineState.ok !== true) return false;
|
|
1336
|
-
let present = false;
|
|
1337
|
-
try {
|
|
1338
|
-
present = ctx.tools.get("t_team_create") !== undefined;
|
|
1339
|
-
} catch {
|
|
1340
|
-
present = false;
|
|
1341
|
-
}
|
|
1342
|
-
if (!present && !engineNotReadyWarned) {
|
|
1343
|
-
engineNotReadyWarned = true;
|
|
1344
|
-
// 「工具没注册」这件事必须在日志里留下一条:否则提示段、/t、日志三处都没有信号。
|
|
1345
|
-
ctx.logger?.warn?.(`[t-team] 团队引擎已调度但工具未注册(引擎声明的 inject 服务可能不齐):${NOT_READY_DETAIL}`);
|
|
1346
|
-
}
|
|
1347
|
-
return present;
|
|
1348
|
-
};
|
|
1349
|
-
/** 给用户/模型的原因:挂载/重挂抛错就报那个错,否则报「工具没注册」。 */
|
|
1350
|
-
const engineUnreadyDetail = () => (lastMountError !== "" ? lastMountError : engineState.detail !== "" ? engineState.detail : NOT_READY_DETAIL);
|
|
1351
|
-
/** 引擎挂载参数。**每次挂载现算**:`profiles` 从活取的 `engineConfig` 里取,所以重挂必然带新小队。 */
|
|
1352
|
-
const teamsEngineArgs = () => ({
|
|
1353
|
-
stateDir: config.stateDir,
|
|
1354
|
-
memberProvider: config.memberProvider,
|
|
1355
|
-
...(config.memberModel === undefined ? {} : { memberModel: config.memberModel }),
|
|
1356
|
-
...(engineConfig.memberMaxDepth === undefined ? {} : { memberMaxDepth: engineConfig.memberMaxDepth }),
|
|
1357
|
-
maxMembers: config.maxMembers,
|
|
1358
|
-
// 引擎自带命令必须关掉:T专家 自己注册 /t(带小队别名解析),否则两个同名命令相撞。
|
|
1359
|
-
// 手势边界另外复用(见下),它是队长协议真正被触发的地方。
|
|
1360
|
-
slashCommand: false,
|
|
1361
|
-
profiles: engineConfig.profiles ?? {},
|
|
1362
|
-
});
|
|
1363
|
-
/**
|
|
1364
|
-
* 安装手势边界(`/t --profile <小队> <目标>` → 队长协议指令),**恰好一次**。
|
|
1365
|
-
*
|
|
1366
|
-
* 只在引擎真的挂上之后装:边界生成的是"按这支小队建队"的指令,引擎不可用时装了也只会把用户
|
|
1367
|
-
* 消息翻成一条建不成的指令(旧行为如此,这里刻意保持)。传的是**活取值函数**而不是当前
|
|
1368
|
-
* profiles 对象:热重载后它立刻读到新小队目录,不必重装监听器。
|
|
1369
|
-
*/
|
|
1370
|
-
let gestureBoundaryInstalled = false;
|
|
1371
|
-
const installGestureBoundaryOnce = () => {
|
|
1372
|
-
if (gestureBoundaryInstalled) return;
|
|
1373
|
-
installTTeamGestureBoundary(ctx, () => engineConfig.profiles ?? {});
|
|
1374
|
-
gestureBoundaryInstalled = true;
|
|
1375
|
-
};
|
|
1376
|
-
/**
|
|
1377
|
-
* 宿主 subagent 契约体检结果(B-2 软闸门):**懒算一次 + 每次挂载成功刷新**。
|
|
1378
|
-
*
|
|
1379
|
-
* 为什么不用"每次渲染现算":下面的 `text:` 是**每次请求**都求值的,而这个体检要读安装树里的
|
|
1380
|
-
* `package.json`(同步 I/O)。契约在同一进程里不会变(同一份安装、同一个服务实例),算一次就够;
|
|
1381
|
-
* 挂载/热重载是"宿主可能刚被换过"的最近观测点,顺手刷一遍不会多花什么。
|
|
1382
|
-
* 探测本身**绝不抛错**(它是诊断,不是门禁),所以放在提示段里是安全的。
|
|
1383
|
-
*/
|
|
1384
|
-
let harnessReport;
|
|
1385
|
-
const refreshHarnessReport = () => (harnessReport = probeHarnessContract(ctx));
|
|
1386
|
-
const harnessReportOf = () => (harnessReport ??= probeHarnessContract(ctx));
|
|
1387
|
-
try {
|
|
1388
|
-
// 首次挂载(启动期读不到配置就降级,不假装就绪;原因见 prompt 段与日志)。
|
|
1389
|
-
if (initialEngineConfig.detail === "") {
|
|
1390
|
-
engineFiber = ctx.plugin(teamsEngine, teamsEngineArgs());
|
|
1391
|
-
engineState.ok = true;
|
|
1392
|
-
refreshHarnessReport();
|
|
1393
|
-
installGestureBoundaryOnce();
|
|
1394
|
-
} else {
|
|
1395
|
-
ctx.logger?.error?.(`[t-team] ${initialEngineConfig.detail}`);
|
|
1396
|
-
}
|
|
1397
|
-
} catch (error) {
|
|
1398
|
-
engineState.ok = false;
|
|
1399
|
-
lastMountError = error instanceof Error ? error.message : String(error);
|
|
1400
|
-
ctx.logger?.error?.(`[t-team] 团队引擎挂载失败(专家名册功能不受影响):${lastMountError}`);
|
|
1401
|
-
}
|
|
1402
|
-
/**
|
|
1403
|
-
* 团队引擎热重载:保存小队(编译成功后)由小队服务敲门。
|
|
1404
|
-
*
|
|
1405
|
-
* 这是「不重启就能用新小队建队」的收口点。之所以能成立:引擎的 13 个工具、提示段与命令
|
|
1406
|
-
* 都注册在自己的 fiber 下(走 effect),换配置重挂就是「先卸载旧的、再用新 profiles 跑一次
|
|
1407
|
-
* apply」——引擎注册面与卸载面都不需要改代码。
|
|
1408
|
-
*
|
|
1409
|
-
* 为什么用 `dispose()` + 重新 `plugin()`,而不是 `fiber.update(config)`:两者在本仓库当前装的
|
|
1410
|
-
* cordis 4.0.2 上**都实测可用**(同一份真机探针各跑一遍,见交付说明),区别只在语义强弱:
|
|
1411
|
-
* · `update()` 复用同一条 fiber,由框架按"先卸载旧 effect、再用新 config apply"重载,最贴近
|
|
1412
|
-
* 框架本意(设置页与 HMR 走的就是它);但它要求"插件注册的每一条都挂在同一条 fiber 的
|
|
1413
|
-
* effect 上"。本插件是"插件里又挂子插件"的形态(引擎的 apply 里还有工具/提示段/命令/子
|
|
1414
|
-
* fiber),注册面越大,越容易被后来新增的、不走 effect 的注册悄悄破坏 —— 本轮就真的被
|
|
1415
|
-
* 误导过一次:探针桩漏了 effect 包裹,`update()` 立刻撞 `tool "t_team_create" is already
|
|
1416
|
-
* registered in this scope`,看着像框架缺陷、其实是被测面自己没走 effect。
|
|
1417
|
-
* · `dispose()` 会 await 整棵子树的卸载,之后再 `plugin()` 是一个全新的 fiber —— 不存在
|
|
1418
|
-
* "旧 effect 残留"这一类问题,代价只是多创建一次 fiber。
|
|
1419
|
-
* 小队重载是低频操作(用户点一次保存),这里选**更难用错**的那条:以后注册面变大也不会悄悄失效。
|
|
1420
|
-
* 若将来有理由改回 `update()`,探针(reload-probe.mjs)对两者都成立,换实现只需重跑它。
|
|
1421
|
-
*
|
|
1422
|
-
* 语义(对应任务的失败/并发要求):
|
|
1423
|
-
* · 配置读不出来(不存在/解析失败/类型不对)→ **不重挂**,保留旧引擎继续可用,`ok:false`
|
|
1424
|
-
* 带原因返回;下一次保存成功会重新对齐。
|
|
1425
|
-
* · `dispose()` 抛错 → 引擎此刻确实不在了,`ok:false` 且 `engineState.ok=false`(提示段会
|
|
1426
|
-
* 报「引擎不可用」,不假装就绪);但**已保存的配置不会回滚**(它不是坏定义,只是引擎没起来)。
|
|
1427
|
-
* · 重挂抛错 → 同上,但已经尝试过重新挂载,`engineState.ok` 如实反映结果。
|
|
1428
|
-
* · 全程不向上抛(保存已经成功,抛错会让用户以为没保存成)。
|
|
1429
|
-
* @returns `{ ok, detail }`:`detail` 只在失败时非空,且必须是**可操作**的原因。
|
|
1430
|
-
*/
|
|
1431
|
-
const reloadEngine = async (origin = "squad") => {
|
|
1432
|
-
/** 重载失败的统一收口:留在提示段与日志里("引擎还在用旧小队"与"引擎整个没了"要分清)。 */
|
|
1433
|
-
const reloadFailed = (detail) => {
|
|
1434
|
-
engineState.reloadFailure = detail;
|
|
1435
|
-
engineState.reloadFailureKind = origin === "harness" ? "harness" : "squad";
|
|
1436
|
-
ctx.logger?.error?.(origin === "harness"
|
|
1437
|
-
? `[t-team] 宿主投递符号换了口径,自动重挂团队引擎失败:${detail}`
|
|
1438
|
-
: `[t-team] 小队已保存,但团队引擎热重载失败:${detail}`);
|
|
1439
|
-
return { ok: false, detail };
|
|
1440
|
-
};
|
|
1441
|
-
const fresh = loadEngineConfig();
|
|
1442
|
-
if (fresh.failure !== "" || fresh.config === null) {
|
|
1443
|
-
// 不换 `engineConfig`:读不出来就当这次重载没发生过,旧引擎继续用旧配置,可用性不变。
|
|
1444
|
-
return reloadFailed(fresh.failure);
|
|
1445
|
-
}
|
|
1446
|
-
engineConfig = fresh.config; // 无论 mount 是否成功都换:新配置就是事实,否则会永远重装旧快照。
|
|
1447
|
-
engineState.detail = fresh.detail;
|
|
1448
|
-
const previousFiber = engineFiber;
|
|
1449
|
-
if (previousFiber !== undefined) {
|
|
1450
|
-
// 先断开引用:dispose 抛错时不能留着一个已死的 fiber 被后续重载再 dispose 一次。
|
|
1451
|
-
engineFiber = undefined;
|
|
1452
|
-
try {
|
|
1453
|
-
await previousFiber.dispose();
|
|
1454
|
-
} catch (error) {
|
|
1455
|
-
engineState.ok = false;
|
|
1456
|
-
engineState.reloadFailure = "";
|
|
1457
|
-
return reloadFailed(`卸载旧引擎失败:${error instanceof Error ? error.message : String(error)}`);
|
|
1458
|
-
}
|
|
1459
|
-
}
|
|
1460
|
-
try {
|
|
1461
|
-
const next = ctx.plugin(teamsEngine, teamsEngineArgs());
|
|
1462
|
-
engineFiber = next;
|
|
1463
|
-
await next; // fiber 是 thenable:await 它等价于等 apply 落定。
|
|
1464
|
-
} catch (error) {
|
|
1465
|
-
engineFiber = undefined;
|
|
1466
|
-
engineState.ok = false;
|
|
1467
|
-
lastMountError = error instanceof Error ? error.message : String(error);
|
|
1468
|
-
return reloadFailed(`重新挂载引擎失败:${lastMountError}`);
|
|
1469
|
-
}
|
|
1470
|
-
// 定局探测(engineReady 同样只报一次「工具未注册」的 warn,避免刷日志)。
|
|
1471
|
-
if (ctx.tools.get("t_team_create") === undefined && engineState.ok !== true) {
|
|
1472
|
-
// apply 抛过错 → 引擎真的没起来;服务不齐(正常生命周期)在这里不算失败:cordis 会在
|
|
1473
|
-
// 服务回来时自己重载那条 fiber。
|
|
1474
|
-
return reloadFailed(engineUnreadyDetail());
|
|
1475
|
-
}
|
|
1476
|
-
engineState.ok = true;
|
|
1477
|
-
lastMountError = "";
|
|
1478
|
-
engineState.reloadFailure = "";
|
|
1479
|
-
refreshHarnessReport();
|
|
1480
|
-
// 启动期因"还没有小队配置"而没挂上时,这里补装手势边界:现在引擎已经起来了。
|
|
1481
|
-
installGestureBoundaryOnce();
|
|
1482
|
-
return { ok: true, detail: "" };
|
|
1483
|
-
};
|
|
1484
|
-
|
|
1485
|
-
/**
|
|
1486
|
-
* 契约报告 → 一行**给模型看**的说明,**只在未实测时非空**。
|
|
1487
|
-
*
|
|
1488
|
-
* 为什么只在未实测时出现:已实测(版本在矩阵内、形状一致)是常态,常态不该占提示词预算;
|
|
1489
|
-
* 而未实测正是"将来某天叫不醒成员"的唯一解释来源 —— 那时用户问起来,模型必须答得上来。
|
|
1490
|
-
* 文案与日志里的那条(harnessContractWarning)同源同因,但这里讲的是"怎么办"。
|
|
1491
|
-
* @param report - probeHarnessContract() 的结果。
|
|
1492
|
-
* @returns 中文单行;已实测时为空串。
|
|
1493
|
-
*/
|
|
1494
|
-
const harnessContractLine = (report) => {
|
|
1495
|
-
if (report === undefined || report.tested) return "";
|
|
1496
|
-
const why = report.reason === "contract-drift"
|
|
1497
|
-
? `形状与实测不符(${report.drift.join(";")})`
|
|
1498
|
-
: report.reason === "symbol-renamed"
|
|
1499
|
-
? "宿主把投递符号换成了非全局符号(已自动改用动态 import 得到的权威符号)"
|
|
1500
|
-
: report.version === ""
|
|
1501
|
-
? "宿主版本未识别"
|
|
1502
|
-
: `宿主版本 ${report.version} 不在本插件的实测矩阵(${TESTED_HARNESS_VERSIONS.join(" / ")})内`;
|
|
1503
|
-
const where = report.route === "none" ? "当前没有可用的投递路径" : `投递走 ${report.route}`;
|
|
1504
|
-
return `宿主 subagent 契约:${why},${where}。团队功能按实测契约尽力运行;升级 DSH 后若出现「叫不醒成员 / 已退休成员被复活」,先按这条排查。`;
|
|
1505
|
-
};
|
|
1506
1284
|
|
|
1507
|
-
// 已声明的降级也要**对模型可见**:否则"团队功能没了"这件事只留在日志里,
|
|
1508
|
-
// 用户问起来模型只能说不知道 —— 那正是规范禁止的"静默跳过缺失的引用对象"。
|
|
1509
|
-
// 门槛用 engineReady()(真机探测)而不是 engineState.ok:两者在「引擎 fiber 被挂起」时不同。
|
|
1510
|
-
ctx.systemPrompt.section({
|
|
1511
|
-
name: "t-team:engine-status",
|
|
1512
|
-
order: 120,
|
|
1513
|
-
text: (context) => {
|
|
1514
|
-
if (context.agent?.session?.header?.parentSession !== undefined) return "";
|
|
1515
|
-
if (engineReady()) {
|
|
1516
|
-
// 引擎可用、但上一次热重载没成功:盘上的小队定义已经变了,运行中的引擎还是旧的。
|
|
1517
|
-
const contract = harnessContractLine(harnessReportOf());
|
|
1518
|
-
if (engineState.reloadFailure === "")
|
|
1519
|
-
return contract;
|
|
1520
|
-
// 两种来源都要说清,但**不能说错**:宿主投递符号换口径后的自动重挂失败,与"你保存的小队
|
|
1521
|
-
// 没生效"是两件事(B-3,2026-09-16)。
|
|
1522
|
-
const byHarness = engineState.reloadFailureKind === "harness";
|
|
1523
|
-
return [
|
|
1524
|
-
byHarness
|
|
1525
|
-
? "## T专家 团队引擎自动重挂失败(宿主投递契约换了口径)"
|
|
1526
|
-
: "## T专家 小队定义已保存,但团队引擎没有换成新配置",
|
|
1527
|
-
byHarness
|
|
1528
|
-
? `宿主的 subagent 投递符号与实测口径不同,插件已改用动态 import 得到的权威符号并自动重挂引擎,但这次重挂失败了。原因:${engineState.reloadFailure}`
|
|
1529
|
-
: `这次保存已经生效(/t 列表就是新定义),但引擎仍在使用上一次的小队配置,所以用新改的小队建队会失败。原因:${engineState.reloadFailure}`,
|
|
1530
|
-
byHarness
|
|
1531
|
-
? "团队功能此刻不可用;重启 DSH 会在解析权威符号后重新挂载。用户问起时请原样转述上面的原因。"
|
|
1532
|
-
: "需要重新保存一次小队定义让引擎再试一次,或重启 DSH。用户问起时请原样转述上面的原因。",
|
|
1533
|
-
contract,
|
|
1534
|
-
].filter((line) => line !== "").join("\n");
|
|
1535
|
-
}
|
|
1536
|
-
return [
|
|
1537
|
-
"## T专家 团队引擎当前不可用",
|
|
1538
|
-
`团队功能(/t 建队、t_team_* 工具)现在不可用。原因:${engineUnreadyDetail()}`,
|
|
1539
|
-
"专家名册、@ 召唤与 summon_t_expert 不受影响。用户需要团队功能时,请原样转述上面的原因,不要假装能建队。",
|
|
1540
|
-
harnessContractLine(harnessReportOf()),
|
|
1541
|
-
].filter((line) => line !== "").join("\n");
|
|
1542
|
-
},
|
|
1543
|
-
});
|
|
1544
|
-
|
|
1545
|
-
// ---- 权威契约解析(B-3,2026-09-16)----
|
|
1546
|
-
// 动态 import 宿主的 `@deepseek-ai/dsh-subagent/internal`:拿到投递符号的**真身**与官方适配
|
|
1547
|
-
// 函数(`queueHostSubagentPrompt`),替代"按 Symbol.for 猜键 + 自己拼参数"。解析细节与失败语义
|
|
1548
|
-
// 见 lib/teams/harness-compat.js 的 resolveHarnessAuthority。
|
|
1549
|
-
//
|
|
1550
|
-
// 为什么要在这里重挂:解析是异步的,而引擎首挂是同步的 —— 宿主真把符号换成非全局符号时,
|
|
1551
|
-
// 首挂那一刻守卫按旧键找不到函数(B-1 硬闸门 → 引擎降级)。解析落地后立刻用权威键重挂一次,
|
|
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");
|
|
1558
|
-
void resolveHarnessAuthority().then((authority) => {
|
|
1559
|
-
if (disposed) return;
|
|
1560
|
-
refreshHarnessReport();
|
|
1561
|
-
if (authority.changed !== true) return;
|
|
1562
|
-
ctx.logger?.warn?.(`[t-team] 宿主投递符号换了口径(权威符号:${String(authority.deliver)}),已改用权威符号并重挂团队引擎。`);
|
|
1563
|
-
return reloadEngine("harness");
|
|
1564
|
-
}).catch((error) => {
|
|
1565
|
-
// 解析函数自身不抛(失败会写进报告),这里只兜住"注入方/宿主意外":绝不让它变成
|
|
1566
|
-
// unhandled rejection 污染宿主日志。
|
|
1567
|
-
ctx.logger?.warn?.(`[t-team] 权威契约解析异常(继续按全局符号工作):${error instanceof Error ? error.message : String(error)}`);
|
|
1568
|
-
});
|
|
1569
|
-
|
|
1570
|
-
// ---- 小队定义服务(设置页「小队」标签用;写回 teams.json 后自动重跑 team-profiles.py)----
|
|
1571
|
-
const teamsFile = join(dirname(config.root), "teams.json");
|
|
1572
|
-
// 编译器**只认包内那份**(随插件版本走)。刻意**不再回退**数据目录里的 `team-profiles.py`:
|
|
1573
|
-
// 那份是 bootstrap 播种的用户可写文件,会让「保存小队」变成「执行用户可写脚本」——
|
|
1574
|
-
// 同用户权限下的代码执行面,且它与包内版本的漂移无法校验。裁剪安装(包内被删)时
|
|
1575
|
-
// 小队编译会**响亮失败**(原因经 logger 与面板可见),而不是悄悄执行另一份文件(C-3)。
|
|
1576
|
-
const packagedGenerator = join(SNAPSHOT_DIR, "team-profiles.py");
|
|
1577
|
-
const squads = createSquadService({
|
|
1578
|
-
teamsFile,
|
|
1579
|
-
catalog: catalogService,
|
|
1580
|
-
generator: packagedGenerator,
|
|
1581
|
-
// 成员上限**只有一个真源**:Config.maxMembers。它同时喂给引擎(成员上限)与小队编译器
|
|
1582
|
-
// (--max-members)。过去编译器把上限写死 8、这里又没透传,于是把 maxMembers 改大后保存小队
|
|
1583
|
-
// 必然编译失败并回滚(D-14)。编译器不认这个参数时由 squads.js 记 warn 并退回它内置默认值。
|
|
1584
|
-
maxMembers: config.maxMembers,
|
|
1585
|
-
// 保存小队**编译成功后**的热重载钩子:让运行中的引擎立刻用新 profiles 重挂,不必重启 DSH。
|
|
1586
|
-
// 注入的是回调而不是 `ctx`:数据层不该知道 cordis 的存在(它只负责在正确时机敲门)。
|
|
1587
|
-
onReload: reloadEngine,
|
|
1588
|
-
// 小队编译/重载的诊断出口(N-3):过去 squads.js 直接写 console.error,桌面与 Web 里
|
|
1589
|
-
// 用户看不见,等于「保存成功但重载失败」这条失败从来没被上报过。
|
|
1590
|
-
logger: ctx.logger,
|
|
1591
|
-
});
|
|
1592
|
-
ctx.reflect.provide(SQUAD_SERVICE, squads);
|
|
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
|
-
|
|
1607
|
-
// ---- 团队运行服务(设置页「团队」标签用;复用引擎的快照与停止实现)----
|
|
1608
|
-
// 状态目录**只有一个来源**:`config.stateDir`(同时也是挂给引擎的那个值,见上面的 ctx.plugin)。
|
|
1609
|
-
// 曾经这里读的是数据文件 `t-team.config.json` 的 stateDir(`agent-teams.config.json` 时代的遗留),
|
|
1610
|
-
// 于是「引擎挂到 config.stateDir、面板与 t_team_plan_check 却去数据文件那个目录找」——
|
|
1611
|
-
// 改开 stateDir 的部署会两边失明且没有任何报错(D-1)。
|
|
1612
|
-
const engineStateDir = config.stateDir;
|
|
1613
|
-
const teamRoots = () => {
|
|
1614
|
-
const registry = ctx.get("workspaceRegistry") ?? ctx.get("workspace");
|
|
1615
|
-
const list = typeof registry?.list === "function" ? registry.list() : [];
|
|
1616
|
-
return list
|
|
1617
|
-
.map((workspace) => ({
|
|
1618
|
-
workspace: workspace.title ?? workspace.path,
|
|
1619
|
-
path: workspace.path,
|
|
1620
|
-
stateRoot: join(workspace.path, engineStateDir),
|
|
1621
|
-
}))
|
|
1622
|
-
.filter((root) => typeof root.stateRoot === "string");
|
|
1623
|
-
};
|
|
1624
|
-
/**
|
|
1625
|
-
* 团队 id 的格式闸门:这个值会被直接拼进文件路径(`<stateRoot>/<teamId>` 与
|
|
1626
|
-
* `<stateRoot>/archive/<teamId>`),所以要挡住的是**路径穿越**,而不是"必须长得像 slug"。
|
|
1627
|
-
*
|
|
1628
|
-
* 2026-09-15 二次修正(复核 N2):第一版要求 `sanitizeKey(teamId) === teamId`,结果是盘上
|
|
1629
|
-
* 任何**非 slug 目录名**的团队(历史遗留、手工建的目录,如 `My Team`)在设置页里变成
|
|
1630
|
-
* "按钮点得动、点了必失败"的僵尸行 —— 而它在加闸门之前是删得掉的。现在按"能不能安全拼进
|
|
1631
|
-
* 路径"判定:不接受分隔符、`..`、NUL,也不接受带目录成分的路径。
|
|
1632
|
-
*/
|
|
1633
|
-
const assertTeamId = (teamId) => {
|
|
1634
|
-
const bad = typeof teamId !== "string"
|
|
1635
|
-
|| teamId === "" || teamId === "." || teamId === ".."
|
|
1636
|
-
|| teamId.includes("/") || teamId.includes("\\") || teamId.includes("\0")
|
|
1637
|
-
|| basename(teamId) !== teamId;
|
|
1638
|
-
if (bad) {
|
|
1639
|
-
throw new Error(`团队 id 非法:${JSON.stringify(teamId)}(不能为空、不能含路径分隔符或 "..")。`);
|
|
1640
|
-
}
|
|
1641
|
-
};
|
|
1642
|
-
/**
|
|
1643
|
-
* 定位要操作的 stateRoot:给了 `workspacePath` 就**只用它**。
|
|
1644
|
-
*
|
|
1645
|
-
* 2026-09-15(独立审查发现的缺陷):`teamId` 是团队名 `sanitizeKey(name)` 的结果,
|
|
1646
|
-
* 不同工作区里的同名团队会拿到同一个 teamId —— 只按 teamId 找,`find` 会命中最靠前的那个
|
|
1647
|
-
* 工作区,于是「点 B 工作区那支的删除」实际删掉 A 工作区的目录(停止同理)。
|
|
1648
|
-
*
|
|
1649
|
-
* 空串与 undefined 都**放行**(退回全量遍历):那是「客户端比 host 旧、快照里没带
|
|
1650
|
-
* workspacePath」的降级路径 —— 此时除全遍历外无从定位,而它带着 F1 那个固有歧义。
|
|
1651
|
-
* 这是旧版本组合下唯一可能的降级,不是新客户端会走的路径。
|
|
1652
|
-
*/
|
|
1653
|
-
const locateRoots = (roots, workspacePath) => {
|
|
1654
|
-
if (typeof workspacePath !== "string" || workspacePath === "") return roots;
|
|
1655
|
-
const hit = roots.find((root) => root.path === workspacePath);
|
|
1656
|
-
if (hit === undefined) {
|
|
1657
|
-
throw new Error(`工作区不在当前列表里:${workspacePath}(它可能已被移除,或不在本机工作区注册表里)。`);
|
|
1658
|
-
}
|
|
1659
|
-
return [hit];
|
|
1660
|
-
};
|
|
1661
|
-
const compactTeam = (team, workspacePath, captainActive) => ({
|
|
1662
|
-
teamId: team.teamId,
|
|
1663
|
-
name: team.name,
|
|
1664
|
-
workspace: team.workspace,
|
|
1665
|
-
/** 所属工作区路径:同名团队跨工作区时,客户端靠它精确定位(见 locateRoots)。 */
|
|
1666
|
-
workspacePath,
|
|
1667
|
-
/** 队长会话此刻是否在运行:客户端据此决定按钮叫「停止」还是「归档」,与服务端判定对齐。 */
|
|
1668
|
-
captainActive,
|
|
1669
|
-
// 面板/设置页的「打开活动面板」要带 captainSessionId:活动面板是会话作用域的,
|
|
1670
|
-
// 缺它就只能匹配当前会话的团队,别的会话(或历史归档)点按钮会静默无反应。
|
|
1671
|
-
captainSessionId: team.captainSessionId,
|
|
1672
|
-
phase: team.phase,
|
|
1673
|
-
halted: team.halted === true,
|
|
1674
|
-
members: (team.members ?? []).map((member) => ({
|
|
1675
|
-
name: member.name,
|
|
1676
|
-
role: member.role ?? "",
|
|
1677
|
-
status: member.status,
|
|
1678
|
-
activity: member.activity,
|
|
1679
|
-
done: member.done ?? 0,
|
|
1680
|
-
total: member.total ?? 0,
|
|
1681
|
-
})),
|
|
1682
|
-
tasks: (team.tasks ?? []).map((task) => ({
|
|
1683
|
-
id: task.id,
|
|
1684
|
-
subject: task.subject,
|
|
1685
|
-
status: task.status,
|
|
1686
|
-
assignee: task.assignee ?? "",
|
|
1687
|
-
})),
|
|
1688
|
-
});
|
|
1689
|
-
ctx.reflect.provide(TEAM_SERVICE, {
|
|
1690
|
-
async list(archived = false) {
|
|
1691
|
-
const roots = teamRoots();
|
|
1692
|
-
if (roots.length === 0) return [];
|
|
1693
|
-
// 逐工作区收集(而不是一次性把 roots 交给 snapshot 层):只有这样才能把**每支团队**
|
|
1694
|
-
// 与它所属的工作区路径、以及「队长此刻是否在运行」一起带出去(见 compactTeam 注释)。
|
|
1695
|
-
const agents = ctx.get("agents");
|
|
1696
|
-
const out = [];
|
|
1697
|
-
const failures = [];
|
|
1698
|
-
for (const root of roots) {
|
|
1699
|
-
let snapshots;
|
|
1700
|
-
try {
|
|
1701
|
-
snapshots = archived
|
|
1702
|
-
? await collectArchivedTeamsActivity(ctx, [root])
|
|
1703
|
-
: await collectTeamsActivity(ctx, [root]);
|
|
1704
|
-
} catch (error) {
|
|
1705
|
-
const reason = error instanceof Error ? error.message : String(error);
|
|
1706
|
-
failures.push(`${root.stateRoot}:${reason}`);
|
|
1707
|
-
ctx.logger?.warn?.(`[t-team] 读取团队活动失败:${root.stateRoot}(${reason})`);
|
|
1708
|
-
continue;
|
|
1709
|
-
}
|
|
1710
|
-
for (const snapshot of snapshots) {
|
|
1711
|
-
out.push(compactTeam(snapshot, root.path, agents?.get?.(snapshot.captainSessionId) !== undefined));
|
|
1712
|
-
}
|
|
1713
|
-
}
|
|
1714
|
-
// 全部工作区都读失败时不要返回空数组 —— UI 会把它当「没有团队」显示,
|
|
1715
|
-
// 真实原因(EACCES 之类)就此消失(与 stop/remove 里的 N-4 同一条原则)。
|
|
1716
|
-
if (out.length === 0 && failures.length > 0 && failures.length === roots.length) {
|
|
1717
|
-
throw new Error(`读取团队活动失败:${roots.length} 个工作区全部读取失败(${failures.join(";")})。`);
|
|
1718
|
-
}
|
|
1719
|
-
return out;
|
|
1720
|
-
},
|
|
1721
|
-
/** 面板里点一支小队:投一条 `/t --profile <key>` 让队长按该小队接活(目标由队长追问用户)。 */
|
|
1722
|
-
async start(profileKey, sessionId) {
|
|
1723
|
-
const list = await squads.list();
|
|
1724
|
-
const hit = list.squads.find((squad) => squad.key === profileKey && squad.enabled !== false);
|
|
1725
|
-
if (hit === undefined) throw new Error(`没有可用的「${profileKey}」小队(可能已关停或改名)。`);
|
|
1726
|
-
// 注意:cordis 里 `ctx.agents` 是属性访问,未 inject 会抛 "cannot get property agents without inject";
|
|
1727
|
-
// 这里要的是「可选服务」,必须走 ctx.get()(与 workspaceRegistry 一样的用法)。
|
|
1728
|
-
const agents = ctx.get("agents");
|
|
1729
|
-
const agent = typeof sessionId === "string" && sessionId !== "" ? agents?.get?.(sessionId) : undefined;
|
|
1730
|
-
if (agent === undefined) throw new Error("当前会话不在运行中,无法从这里拉起小队(先用 /t 试)。");
|
|
1731
|
-
agent.followup(createUserMessage({
|
|
1732
|
-
content: [{ type: "text", text: `/t --profile ${profileKey}` }],
|
|
1733
|
-
source: { kind: "user" },
|
|
1734
|
-
}));
|
|
1735
|
-
return {
|
|
1736
|
-
profile: profileKey,
|
|
1737
|
-
label: hit.description === "" ? profileKey : hit.description,
|
|
1738
|
-
members: hit.members.length,
|
|
1739
|
-
};
|
|
1740
|
-
},
|
|
1741
|
-
async stop(teamId, workspacePath) {
|
|
1742
|
-
assertTeamId(teamId);
|
|
1743
|
-
const roots = locateRoots(teamRoots(), workspacePath);
|
|
1744
|
-
// 逐 root 读取失败时**留住根因**:过去 `catch { continue; }` 把 EACCES 之类换成
|
|
1745
|
-
// 下面那句「没找到团队 …(它可能已被归档)」,用户按提示去查归档永远查不到(N-4)。
|
|
1746
|
-
const failures = [];
|
|
1747
|
-
for (const root of roots) {
|
|
1748
|
-
let snapshots;
|
|
1749
|
-
try {
|
|
1750
|
-
snapshots = await collectTeamsActivity(ctx, [root]);
|
|
1751
|
-
} catch (error) {
|
|
1752
|
-
const reason = error instanceof Error ? `${error.message}` : String(error);
|
|
1753
|
-
failures.push(`${root.stateRoot}:${reason}`);
|
|
1754
|
-
ctx.logger?.warn?.(`[t-team] 读取团队活动失败:${root.stateRoot}(${reason})`);
|
|
1755
|
-
continue;
|
|
1756
|
-
}
|
|
1757
|
-
const team = snapshots.find((item) => item.teamId === teamId);
|
|
1758
|
-
if (team === undefined) continue;
|
|
1759
|
-
// 2026-09-15 修(用户报「运行中的团队删不掉」):过去只要队长会话不在运行就直接抛错,
|
|
1760
|
-
// 于是 DSH 重启后残留下来的团队在设置页里**永远**清不掉 —— 它躺在「运行中」列表里,
|
|
1761
|
-
// 点一次报一次「队长会话已不在运行中」,而那句提示让用户去活动面板归档,活动面板是
|
|
1762
|
-
// 会话作用域的、队长会话都没了自然也打不开。现在按状态分流:
|
|
1763
|
-
// · 队长在运行且还没停 → haltTeamWork(原行为:取消未完成任务 + 中断成员)
|
|
1764
|
-
// · 队长不在运行、或团队早已 halted → **归档**(目录移进 <stateRoot>/archive/,
|
|
1765
|
-
// 从「运行中」列表消失,「看归档」里仍能查到它的任务记录)
|
|
1766
|
-
const captain = ctx.get("agents")?.get?.(team.captainSessionId);
|
|
1767
|
-
if (captain !== undefined && team.halted !== true) {
|
|
1768
|
-
const result = await haltTeamWork({ ctx, stateRoot: root.stateRoot, teamId, captain });
|
|
1769
|
-
return {
|
|
1770
|
-
teamName: result.teamName,
|
|
1771
|
-
cancelledTasks: result.cancelledTasks,
|
|
1772
|
-
alreadyHalted: result.alreadyHalted === true,
|
|
1773
|
-
archived: false,
|
|
1774
|
-
};
|
|
1775
|
-
}
|
|
1776
|
-
await archiveTeamDir(root.stateRoot, teamId);
|
|
1777
|
-
ctx.logger?.info?.(`[t-team] 团队「${team.name}」已归档(队长不在运行:${String(captain === undefined)})。`);
|
|
1778
|
-
return {
|
|
1779
|
-
teamName: team.name,
|
|
1780
|
-
cancelledTasks: 0,
|
|
1781
|
-
alreadyHalted: team.halted === true,
|
|
1782
|
-
archived: true,
|
|
1783
|
-
};
|
|
1784
|
-
}
|
|
1785
|
-
// 读不到任何 root 时不要用「可能已归档」这种猜测掩盖根因:把真实失败原因一并报出来。
|
|
1786
|
-
throw new Error(failures.length > 0
|
|
1787
|
-
? `没找到团队 ${teamId}:${roots.length} 个工作区里${failures.length} 个读取失败(${failures.join(";")})。`
|
|
1788
|
-
: `没找到团队 ${teamId} 的状态目录(它可能已被归档)。`);
|
|
1789
|
-
},
|
|
1790
|
-
/**
|
|
1791
|
-
* 彻底删除一支团队(运行中的与已归档的都收)—— 设置页那一行上的「删除」按钮。
|
|
1792
|
-
*
|
|
1793
|
-
* 2026-09-15 加:用户报「已经停止的团队没有删除按钮」。stop 只能让它停下/进归档,
|
|
1794
|
-
* 目录与任务记录都还在;要真正清掉就得删目录。运行中的先照常停一次(取消未完成任务、
|
|
1795
|
-
* 中断成员),避免留下还在跑的成员;队长不在运行就直接删(成员随会话结束已经没了)。
|
|
1796
|
-
*/
|
|
1797
|
-
async remove(teamId, workspacePath) {
|
|
1798
|
-
assertTeamId(teamId);
|
|
1799
|
-
const roots = locateRoots(teamRoots(), workspacePath);
|
|
1800
|
-
const failures = [];
|
|
1801
|
-
for (const root of roots) {
|
|
1802
|
-
let snapshots;
|
|
1803
|
-
try {
|
|
1804
|
-
snapshots = await collectTeamsActivity(ctx, [root]);
|
|
1805
|
-
} catch (error) {
|
|
1806
|
-
const reason = error instanceof Error ? `${error.message}` : String(error);
|
|
1807
|
-
failures.push(`${root.stateRoot}:${reason}`);
|
|
1808
|
-
ctx.logger?.warn?.(`[t-team] 读取团队活动失败:${root.stateRoot}(${reason})`);
|
|
1809
|
-
continue;
|
|
1810
|
-
}
|
|
1811
|
-
const team = snapshots.find((item) => item.teamId === teamId);
|
|
1812
|
-
if (team !== undefined) {
|
|
1813
|
-
const captain = ctx.get("agents")?.get?.(team.captainSessionId);
|
|
1814
|
-
if (captain !== undefined && team.halted !== true) {
|
|
1815
|
-
try {
|
|
1816
|
-
await haltTeamWork({ ctx, stateRoot: root.stateRoot, teamId, captain });
|
|
1817
|
-
} catch (error) {
|
|
1818
|
-
// 停不下来不该挡住删除:目录都要删了,成员随会话结束时也会被回收。
|
|
1819
|
-
const reason = error instanceof Error ? error.message : String(error);
|
|
1820
|
-
ctx.logger?.warn?.(`[t-team] 删除前停止团队「${team.name}」失败(继续删除):${reason}`);
|
|
1821
|
-
}
|
|
1822
|
-
}
|
|
1823
|
-
await removeTeamDir(root.stateRoot, teamId);
|
|
1824
|
-
// 防护(独立审查 F3):队长/成员若还有在途写盘,`writeTeam` 会 `mkdir -p` 把目录
|
|
1825
|
-
// **重建**出来(state.js 里那行是无条件的),于是「删掉的团队」过一会儿又冒出来。
|
|
1826
|
-
// 这里确认一次:真被重建就再删一次并出声 —— 静默留下空壳比报错更难查。
|
|
1827
|
-
if (existsSync(join(root.stateRoot, teamId))) {
|
|
1828
|
-
ctx.logger?.warn?.(`[t-team] 团队「${team.name}」删除后有写入者重建了目录,已再次删除(可能有成员在收尾)。`);
|
|
1829
|
-
await removeTeamDir(root.stateRoot, teamId);
|
|
1830
|
-
}
|
|
1831
|
-
return { teamName: team.name, deleted: true, wasArchived: false };
|
|
1832
|
-
}
|
|
1833
|
-
const archivedIds = await listArchivedTeamIds(root.stateRoot);
|
|
1834
|
-
if (archivedIds.includes(teamId)) {
|
|
1835
|
-
const state = await readArchivedTeam(root.stateRoot, teamId);
|
|
1836
|
-
await removeArchivedTeamDir(root.stateRoot, teamId);
|
|
1837
|
-
return { teamName: state?.name ?? teamId, deleted: true, wasArchived: true };
|
|
1838
|
-
}
|
|
1839
|
-
}
|
|
1840
|
-
throw new Error(failures.length > 0
|
|
1841
|
-
? `没找到团队 ${teamId}:${roots.length} 个工作区里${failures.length} 个读取失败(${failures.join(";")})。`
|
|
1842
|
-
: `没找到团队 ${teamId} 的状态目录(它可能已被删除)。`);
|
|
1843
|
-
},
|
|
1844
|
-
});
|
|
1845
|
-
|
|
1846
|
-
// ---- `/t` 小队短命令(把 /t <小队> <目标> 转成引擎认得的 /t --profile <key> <目标> 用户消息)----
|
|
1847
|
-
// `commands` 同为**可选**服务(见顶部 inject 注释):与 settings 完全同理——
|
|
1848
|
-
// 真实宿主里它也可能**晚于本插件就绪**,而 `ctx.get` 只在服务已就绪时才返回对象
|
|
1849
|
-
// (2026-09-13 真实 GUI 实证:apply 时同步探测判负 → /t 根本没注册,而引擎自己那条
|
|
1850
|
-
// `ctx.inject(['commands'])` 却正常)。所以这里也改成 `ctx.inject` 等就绪,不用同步探测。
|
|
1851
|
-
if (typeof ctx.inject !== "function") {
|
|
1852
|
-
ctx.logger?.warn?.("[t-team] 上下文没有 inject:无法等待 commands 就绪,/t 命令不可用。");
|
|
1853
|
-
} else {
|
|
1854
|
-
// 与 settings 同理:`ctx.inject` 对**永不出现**的服务不会有回调,只在回调里告警的话
|
|
1855
|
-
// 宿主真没给 commands 时日志里什么线索都没有。所以申请时就先说一句。
|
|
1856
|
-
ctx.logger?.warn?.("[t-team] 正在等待 commands 服务就绪以注册 /t 命令;若宿主不提供它,/t 将不可用。");
|
|
1857
|
-
ctx.inject(["commands"], (scoped) => {
|
|
1858
|
-
// `scoped` 是 cordis 的**作用域 ctx**。2026-09-13 用真 cordis 4.0.2 实测:
|
|
1859
|
-
// 它是**完整 Context**(`effect` / `get` / `logger` / `inject` / `on` / `plugin` 全在),
|
|
1860
|
-
// 所以「`scoped.effect` 是 undefined」属于**过时说法,别照抄**(自检 [8c] 已用真 cordis
|
|
1861
|
-
// 把这条事实钉住,注释再漂就会被那条断言顶回来)。
|
|
1862
|
-
// 分工照旧:服务从 `scoped` 解析(它保证服务已就绪),effect/logger 挂主 ctx —— 这是
|
|
1863
|
-
// **生命周期上的选择**(注册点随插件 fiber 一起卸载),不是能力限制。
|
|
1864
|
-
const commands = scoped?.commands ?? scoped?.get?.("commands");
|
|
1865
|
-
if (typeof commands?.register !== "function") {
|
|
1866
|
-
ctx.logger?.warn?.("[t-team] commands 服务没有 register:/t 命令不可用(@ 召唤与 summon_t_expert 不受影响)。");
|
|
1867
|
-
return;
|
|
1868
|
-
}
|
|
1869
|
-
registerTeamCommand(ctx, {
|
|
1870
|
-
commands,
|
|
1871
|
-
teamsFile,
|
|
1872
|
-
// 与系统提示段共用同一个真机探测(engineReady),两条可见面不会再次分叉。
|
|
1873
|
-
hasEngine: () => engineReady(),
|
|
1874
|
-
// 报给用户的原因用 engineUnreadyDetail():它区分「调度都没成功」与「调度成功但工具没注册」,
|
|
1875
|
-
// 后者在旧文案里会指向一条永远不存在的 [t-team] error 行(N-5)。
|
|
1876
|
-
engineError: () => engineUnreadyDetail(),
|
|
1877
|
-
});
|
|
1878
|
-
});
|
|
1879
|
-
}
|
|
1880
|
-
|
|
1881
|
-
// ---- DAG 预检工具(只读):把整套拟建任务一次跑完引擎校验,避免"边建边撞"留下半成品 ----
|
|
1882
|
-
ctx.tools.register(defineTool({
|
|
1883
|
-
name: "t_team_plan_check",
|
|
1884
|
-
description: "Dry-run a batch of T Team tasks through the engine's own validators WITHOUT creating anything, and get every problem in one reply: missing contract fields (objective/acceptance/inScope/verify), inScope overlap with existing open write tasks, unknown or cyclic dependencies, unknown assignee. Also returns warnings (a member already owns an open task; duplicate subjects) and the execution order. Call this before creating more than one task, and after any failed t_team_create_task before retrying.",
|
|
1885
|
-
parameters: {
|
|
1886
|
-
tasks: {
|
|
1887
|
-
type: "array",
|
|
1888
|
-
required: true,
|
|
1889
|
-
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.",
|
|
1890
|
-
items: { type: "json" },
|
|
1891
|
-
},
|
|
1892
|
-
},
|
|
1893
|
-
output: {
|
|
1894
|
-
schema: {
|
|
1895
|
-
type: "object",
|
|
1896
|
-
additionalProperties: false,
|
|
1897
|
-
properties: {
|
|
1898
|
-
ok: { type: "boolean", required: true },
|
|
1899
|
-
problems: { type: "array", required: true, items: { type: "json" } },
|
|
1900
|
-
warnings: { type: "array", required: true, items: { type: "string" } },
|
|
1901
|
-
order: { type: "array", required: true, items: { type: "string" } },
|
|
1902
|
-
},
|
|
1903
|
-
},
|
|
1904
|
-
render: (_args, value) => [{ type: "text", text: renderPlanReport(value) }],
|
|
1905
|
-
},
|
|
1906
|
-
async execute(args, exec) {
|
|
1907
|
-
const agent = exec?.agent;
|
|
1908
|
-
if (agent === undefined) return { ok: false, problems: [{ index: -1, id: "agent", subject: "", error: "这个工具需要在会话里调用" }], warnings: [], order: [] };
|
|
1909
|
-
// 引擎就绪门控(C-1):本工具由 T专家 自己注册,**即使团队引擎挂载失败也照常存在**。
|
|
1910
|
-
// 不拦的话,引擎不在时会返回「先用 t_team_create 建队」——而那个工具此刻根本不在表里,
|
|
1911
|
-
// 等于把模型指向一条死路。这里与 `/t`、系统提示段用同一个真机探测,口径一致。
|
|
1912
|
-
if (!engineReady()) {
|
|
1913
|
-
return {
|
|
1914
|
-
ok: false,
|
|
1915
|
-
problems: [{ index: -1, id: "engine", subject: "", error: `当前会话没有可用的团队引擎,t_team_plan_check 无法预检:${engineUnreadyDetail()}` }],
|
|
1916
|
-
warnings: [],
|
|
1917
|
-
order: [],
|
|
1918
|
-
};
|
|
1919
|
-
}
|
|
1920
|
-
const workspace = agent.session?.header?.cwd ?? process.cwd();
|
|
1921
|
-
const stateRoot = join(workspace, engineStateDir);
|
|
1922
|
-
let team;
|
|
1923
|
-
try {
|
|
1924
|
-
team = await findTeamByCaptain(stateRoot, agent.id);
|
|
1925
|
-
} catch (error) {
|
|
1926
|
-
return { ok: false, problems: [{ index: -1, id: "state", subject: "", error: `读团队状态失败:${String(error)}` }], warnings: [], order: [] };
|
|
1927
|
-
}
|
|
1928
|
-
if (team === undefined) {
|
|
1929
|
-
return {
|
|
1930
|
-
ok: false,
|
|
1931
|
-
problems: [{ index: -1, id: "team", subject: "", error: "当前会话还没有团队;先用 t_team_create(approval=required)建一支 staged 团队,再来预检任务。" }],
|
|
1932
|
-
warnings: [],
|
|
1933
|
-
order: [],
|
|
1934
|
-
};
|
|
1935
|
-
}
|
|
1936
|
-
const report = checkPlan(team, args.tasks);
|
|
1937
|
-
return {
|
|
1938
|
-
ok: report.ok,
|
|
1939
|
-
problems: report.problems,
|
|
1940
|
-
warnings: report.warnings,
|
|
1941
|
-
order: report.order,
|
|
1942
|
-
};
|
|
1943
|
-
},
|
|
1944
|
-
}));
|
|
1945
|
-
|
|
1946
|
-
// ---- 父会话系统提示 ----
|
|
1947
|
-
/**
|
|
1948
|
-
* T Team 的硬约束:引擎是**逐条硬校验、无事务、无预检**,模型却习惯"一次想好整套 DAG、逐条创建",
|
|
1949
|
-
* 于是很容易撞规则并留下半成品(重复任务)。这一节把规则与做法写成明文,减少试错。
|
|
1950
|
-
*/
|
|
1951
|
-
ctx.systemPrompt.section({
|
|
1952
|
-
name: "t-team:constraints",
|
|
1953
|
-
order: 119,
|
|
1954
|
-
text: (context) => {
|
|
1955
|
-
if (context.agent?.session?.header?.parentSession !== undefined) return "";
|
|
1956
|
-
return [
|
|
1957
|
-
"## T Team 硬约束(建任务前必读)",
|
|
1958
|
-
"The engine validates task creation one call at a time and never rolls back. Follow these rules:",
|
|
1959
|
-
"1. **One writer per path.** Two open write tasks (implementation/repair) must not share any inScope path; if they need the same file, make the later one depend on the earlier one (serialize) or split the file paths. Otherwise: `inScope overlaps …`.",
|
|
1960
|
-
"2. **One open task per member.** A member cannot hold two unfinished tasks; extra assignments are rejected/queued. Use the shared pool (leave assignee empty) or more members instead of stacking work on one person.",
|
|
1961
|
-
"3. **Captain owns one takeover at a time**, and a dependency-blocked task cannot be taken over. Do not try to fix the plan by taking many tasks yourself.",
|
|
1962
|
-
"4. **Dependencies may only reference already-created tasks.** Create in dependency order; never reference a task you have not created yet.",
|
|
1963
|
-
"5. **After approval the plan is frozen** (`t_team_edit_plan` works only while staged). Before approving, get the plan right; afterwards you can only create/add, reassign, update.",
|
|
1964
|
-
"",
|
|
1965
|
-
"Working rules that prevent the failure spiral:",
|
|
1966
|
-
"- Planning more than one task? **Call `t_team_plan_check` first** with the whole batch: it dry-runs the engine's own validators and returns every problem at once (contract fields, path overlaps, missing/cyclic dependencies, unknown assignee) plus warnings and the execution order. Fix until it reports ok, then create.",
|
|
1967
|
-
"- **After any failed create, call `t_team_status` before retrying.** A failed call may still have created earlier tasks; retrying blindly duplicates work.",
|
|
1968
|
-
"- Prefer one atomic batch edit while staged; keep task subjects unique.",
|
|
1969
|
-
].join("\n");
|
|
1970
|
-
},
|
|
1971
|
-
});
|
|
1972
1285
|
|
|
1973
1286
|
ctx.systemPrompt.section({
|
|
1974
1287
|
name: "t-team:experts",
|
|
@@ -1984,16 +1297,16 @@ export function apply(ctx, config) {
|
|
|
1984
1297
|
"## T专家 (T Expert) expert mode",
|
|
1985
1298
|
scope,
|
|
1986
1299
|
"Experts are individually enabled/disabled in the T专家 settings tab; all experts are enabled by default and a disabled expert cannot be summoned.",
|
|
1987
|
-
"A composer selection inserts one enabled expert as a native reference chip; the remaining draft text is that expert's task.",
|
|
1988
|
-
|
|
1989
|
-
|
|
1300
|
+
"A composer selection inserts one enabled expert as a native reference chip; the remaining draft text is that expert's task. The chip reaches you as plain text `@专家名` at the head of the user's message — that IS the user's selection, and the name as written is enough (the resolver accepts the Chinese display name, the English name or the slug, and tolerates an emoji prefix).",
|
|
1301
|
+
"When the user's message already names the expert — a leading `@专家名` chip, or an explicit name/slug in the text — call `summon_t_expert` FIRST with that name. Do NOT call `list_t_experts` to confirm or spell the name: the lookup costs two extra round trips and does not change which expert runs.",
|
|
1302
|
+
`Only when the user named no expert, discover one first: call \`list_t_experts()\` for enabled division names and counts, then \`list_t_experts(division)\` to expand the relevant division, then \`summon_t_expert(expert, task)\` — or \`summon_t_experts\` for a small parallel team (at most ${config.maxSummonBatch}; partial results when some fail).`,
|
|
1990
1303
|
].join("\n");
|
|
1991
1304
|
},
|
|
1992
1305
|
});
|
|
1993
1306
|
|
|
1994
1307
|
// ---- 随包 skill(运维入口)----
|
|
1995
1308
|
// 它只对特定任务有用,所以不占常驻提示段,改成按需加载的 skill:
|
|
1996
|
-
// · t-expert-manager 维护这个插件的人:名册 /
|
|
1309
|
+
// · t-expert-manager 维护这个插件的人:名册 / 装机 / 发布
|
|
1997
1310
|
// 可选依赖:宿主没有 skill 注册表时静默跳过,不影响上面任何功能。
|
|
1998
1311
|
installBundledSkills(ctx, config);
|
|
1999
1312
|
}
|