dsh-plugin-t-expert 0.3.84 → 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/data/experts/engineering/engineering-deepseek-harness-project-expert.md +5 -5
- package/lib/bootstrap.js +11 -33
- package/lib/catalog.js +1 -1
- package/lib/client.js +20830 -21811
- package/lib/i18n.js +0 -4
- package/lib/index.js +173 -862
- package/lib/remote-schemas.js +5 -104
- package/lib/remote.js +7 -174
- package/lib/schedule.js +99 -26
- package/lib/skill.js +11 -12
- package/package.json +3 -12
- 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/skills/dsh-harness-languages/SKILL.md +0 -175
- package/skills/dsh-harness-project/SKILL.md +0 -236
- package/vendor/dsh-agent-teams/LICENSE +0 -21
package/lib/squads.js
DELETED
|
@@ -1,446 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
/**
|
|
3
|
-
* T专家 小队数据层:读写 `teams.json`,并把改动编译成引擎配置。
|
|
4
|
-
*
|
|
5
|
-
* 职责边界:本文件只管**小队定义**(有哪些队、谁在里面、别名、启停),
|
|
6
|
-
* 编译交给既有的 `team-profiles.py`(它已内置成员名撞键预检、别名校验、人格注入)。
|
|
7
|
-
* 这样 UI 编辑器不需要复制一份校验逻辑,生成器仍是唯一权威。
|
|
8
|
-
*/
|
|
9
|
-
import { readFileSync, statSync, existsSync } from "node:fs";
|
|
10
|
-
import { execFile } from "node:child_process";
|
|
11
|
-
import { createHash } from "node:crypto";
|
|
12
|
-
import { dirname, join } from "node:path";
|
|
13
|
-
import { promisify } from "node:util";
|
|
14
|
-
|
|
15
|
-
import { writeFileAtomic } from "./catalog.js";
|
|
16
|
-
|
|
17
|
-
/** 异步编译器调用:**绝不能**用 execFileSync —— 那会同步阻塞整个 Host 事件循环。 */
|
|
18
|
-
const runFile = promisify(execFile);
|
|
19
|
-
|
|
20
|
-
/** profile key 只允许小写 ASCII + 数字 + 连字符(引擎按它生成命令别名)。 */
|
|
21
|
-
const KEY_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
22
|
-
|
|
23
|
-
function text(value) {
|
|
24
|
-
return typeof value === "string" ? value : "";
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function fail(code, message, details = {}) {
|
|
28
|
-
/** @type {Error & { code?: string, details?: object }} */
|
|
29
|
-
const error = new Error(message);
|
|
30
|
-
error.code = code;
|
|
31
|
-
error.details = details;
|
|
32
|
-
return error;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** members 允许两种写法:slug 字符串,或 {slug|name, role} 对象(兼容手写)。 */
|
|
36
|
-
function memberSlug(member) {
|
|
37
|
-
if (typeof member === "string") return member.trim();
|
|
38
|
-
if (member !== null && typeof member === "object") {
|
|
39
|
-
for (const key of ["slug", "name", "role"]) {
|
|
40
|
-
if (typeof member[key] === "string" && member[key].trim() !== "") return member[key].trim();
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
return "";
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* 编译器的成员上限默认值(与 `data/team-profiles.py` 的 `MAX_MEMBERS` 同一口径)。
|
|
48
|
-
*
|
|
49
|
-
* 它只是**兼容旧编译器的回退默认**,不是第二个真源:调用方通过 `maxMembers` 把
|
|
50
|
-
* `Config.maxMembers` 传进来,编译器支持 `--max-members` 时以传入值为准(见 `build()`)。
|
|
51
|
-
*/
|
|
52
|
-
export const COMPILER_DEFAULT_MAX_MEMBERS = 8;
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* @param generator - 小队编译器路径。调用方**只有传包内那份**才是安全且正确的接线:
|
|
56
|
-
* `lib/index.js` 现在**只**传 `<包>/data/team-profiles.py`,**不回退**数据目录副本。
|
|
57
|
-
* 为什么:数据目录那份是 bootstrap 播种的**用户可写**文件,让「保存小队」去执行它
|
|
58
|
-
* 等于一条同权限代码执行面,且它与包内版本的漂移无法校验(C-3)。
|
|
59
|
-
* 本函数保留「未传就取同目录 `team-profiles.py`」的默认,只是为了直连数据层的调用方
|
|
60
|
-
* (自检 / 面板预览脚本)不必显式传路径 —— **插件运行路径绝不走这个默认**。
|
|
61
|
-
* @param maxMembers - 成员上限(来自 `Config.maxMembers`)。只有编译器声明支持 `--max-members`
|
|
62
|
-
* 时才会真正生效;旧编译器把上限写死在源码里,此时会**记一条 warn** 并退回它的默认值,
|
|
63
|
-
* 而不是让保存小队在「改大上限」后必然编译失败并回滚(D-14)。
|
|
64
|
-
* @param onReload - 小队保存**编译成功之后**的热重载回调(由 `lib/index.js` 注入:它持有团队引擎的
|
|
65
|
-
* fiber,用新的 profiles 重挂引擎)。数据层不碰 `ctx`:这里只负责在正确时机敲门。
|
|
66
|
-
* 回调返回 `{ ok: true }` 表示重载成功;`{ ok: false, detail }` 表示重载失败(原因要能报给用户)。
|
|
67
|
-
* **没有注入时不算成功**:那与"重载失败"是同一个后果(盘上是新配置、引擎还在用旧的),
|
|
68
|
-
* 所以会记成 `lastReload.ok === false` 并带出明确原因,而不是静默当成功(见 `scheduleReload()`)。
|
|
69
|
-
* 编译失败并回滚时本回调**绝不**被调用:那时盘上还是旧配置,重载只会重新加载同一份旧配置
|
|
70
|
-
* (见 `save()`)。
|
|
71
|
-
* @param logger - 宿主 logger(`ctx.logger`,可选)。小队编译/重载的诊断**必须走这里**,
|
|
72
|
-
* 不能写 `console`:桌面与 Web 里 stderr 用户看不见,等于这条失败从来没被上报过
|
|
73
|
-
* (与 `catalog.js` 的 N-3 同一条口径)。
|
|
74
|
-
* **没有注入时也不写 console**:那正是「用户看不见」的老毛病,宁可静默也不能假装报了。
|
|
75
|
-
*/
|
|
76
|
-
export function createSquadService({ teamsFile, catalog, python = "python3", generator: generatorPath, maxMembers, onReload, logger }) {
|
|
77
|
-
const dataDir = dirname(teamsFile);
|
|
78
|
-
const generator = typeof generatorPath === "string" && generatorPath !== ""
|
|
79
|
-
? generatorPath
|
|
80
|
-
: join(dataDir, "team-profiles.py");
|
|
81
|
-
const memberLimit = typeof maxMembers === "number" && Number.isSafeInteger(maxMembers) && maxMembers > 0
|
|
82
|
-
? maxMembers
|
|
83
|
-
: COMPILER_DEFAULT_MAX_MEMBERS;
|
|
84
|
-
// 编译器能力探测**只做一次**(读它自己的源码找 flag):比「先试传、失败再重跑」便宜,
|
|
85
|
-
// 也不会在 argparse 报错时留下半跑过的生成物。
|
|
86
|
-
const generatorSupportsMemberLimit = existsSync(generator)
|
|
87
|
-
? readFileSync(generator, "utf8").includes("--max-members")
|
|
88
|
-
: false;
|
|
89
|
-
/** 诊断一律走宿主 logger(见 `logger` 参数的说明):console 在 GUI 里无人可见。 */
|
|
90
|
-
const logError = (message) => logger?.error?.(`[t-team] ${message}`);
|
|
91
|
-
let lastBuild = { at: 0, ok: true, output: "" };
|
|
92
|
-
/** 正在跑的编译(见 `build()`):并发调用合并成同一次,避免共享的 `lastBuild` 被交错覆盖。 */
|
|
93
|
-
let buildInFlight;
|
|
94
|
-
/**
|
|
95
|
-
* 热重载的**串行尾**:连续快速保存时,后一次重载必须等前一次结束再开始。
|
|
96
|
-
*
|
|
97
|
-
* 不只是节流:引擎重挂期间它自己的 fiber 处于 unload→load 过渡,并发重挂会让两次
|
|
98
|
-
* dispose/apply 交叠,最后一次重挂会在「已卸载」的中间态上跑,留下半死的引擎。
|
|
99
|
-
* 用一条 promise 链把重载串起来,天然满足「重载中不得并发重载」。
|
|
100
|
-
*/
|
|
101
|
-
let reloadTail;
|
|
102
|
-
/**
|
|
103
|
-
* 最近一次**已完成**的重载结果(诊断用:设置页面板与系统提示段都读它)。
|
|
104
|
-
* 初值是 ok:true —— 「还没重载过」不等于失败。
|
|
105
|
-
*/
|
|
106
|
-
let lastReload = { at: 0, ok: true, detail: "" };
|
|
107
|
-
|
|
108
|
-
function readSpec() {
|
|
109
|
-
try {
|
|
110
|
-
const spec = JSON.parse(readFileSync(teamsFile, "utf8"));
|
|
111
|
-
return spec !== null && typeof spec === "object" ? spec : {};
|
|
112
|
-
} catch (error) {
|
|
113
|
-
throw fail("tTeam/squads-unreadable", `读不到小队定义 ${teamsFile}:${String(error)}`);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function revisionOf() {
|
|
118
|
-
try {
|
|
119
|
-
const stat = statSync(teamsFile);
|
|
120
|
-
const hash = createHash("sha256").update(readFileSync(teamsFile)).digest("hex").slice(0, 12);
|
|
121
|
-
return `${stat.mtimeMs.toString(36)}-${hash}`;
|
|
122
|
-
} catch {
|
|
123
|
-
return "missing";
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** 名册快照(成员选择器用):slug / 中英文名 / 分区 / 是否已启用。 */
|
|
128
|
-
async function roster() {
|
|
129
|
-
let experts = [];
|
|
130
|
-
let enabled = new Set();
|
|
131
|
-
try {
|
|
132
|
-
// 名册服务的 snapshot() 是异步的(它要读 settings),必须 await。
|
|
133
|
-
const snapshot = await catalog?.snapshot?.();
|
|
134
|
-
experts = snapshot?.experts ?? [];
|
|
135
|
-
enabled = new Set(snapshot?.enabled ?? []);
|
|
136
|
-
} catch {
|
|
137
|
-
/* 名册不可用时只给小队本身 */
|
|
138
|
-
}
|
|
139
|
-
const bySlug = new Map(experts.map((expert) => [expert.slug, expert]));
|
|
140
|
-
return {
|
|
141
|
-
experts: experts.map((expert) => ({
|
|
142
|
-
slug: expert.slug,
|
|
143
|
-
name: expert.name,
|
|
144
|
-
nameZh: expert.nameZh ?? expert.name,
|
|
145
|
-
division: expert.division,
|
|
146
|
-
divisionZh: expert.divisionZh ?? expert.division,
|
|
147
|
-
enabled: enabled.has(expert.slug),
|
|
148
|
-
conflict: expert.conflict === true,
|
|
149
|
-
})),
|
|
150
|
-
bySlug,
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function describeMembers(members, bySlug) {
|
|
155
|
-
return (Array.isArray(members) ? members : []).map((member) => {
|
|
156
|
-
const slug = memberSlug(member);
|
|
157
|
-
const expert = bySlug.get(slug);
|
|
158
|
-
return {
|
|
159
|
-
slug,
|
|
160
|
-
role: typeof member === "object" && member !== null && typeof member.role === "string" ? member.role : "",
|
|
161
|
-
name: expert?.name ?? slug,
|
|
162
|
-
nameZh: expert?.nameZh ?? expert?.name ?? slug,
|
|
163
|
-
division: expert?.division ?? "",
|
|
164
|
-
divisionZh: expert?.divisionZh ?? expert?.division ?? "",
|
|
165
|
-
known: expert !== undefined,
|
|
166
|
-
};
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
async function list() {
|
|
171
|
-
const spec = readSpec();
|
|
172
|
-
const { bySlug, experts } = await roster();
|
|
173
|
-
const rawProfiles = spec.profiles !== null && typeof spec.profiles === "object" ? spec.profiles : {};
|
|
174
|
-
const squads = Object.entries(rawProfiles).map(([key, profile]) => {
|
|
175
|
-
const source = profile !== null && typeof profile === "object" ? profile : {};
|
|
176
|
-
return {
|
|
177
|
-
key,
|
|
178
|
-
description: text(source.description),
|
|
179
|
-
aliases: Array.isArray(source.aliases) ? source.aliases.filter((alias) => typeof alias === "string") : [],
|
|
180
|
-
taskPlanning: source.taskPlanning === "seed" ? "seed" : "captain",
|
|
181
|
-
// 缺省视为启用(老定义没有这个字段)
|
|
182
|
-
enabled: source.enabled !== false,
|
|
183
|
-
members: describeMembers(source.members, bySlug),
|
|
184
|
-
};
|
|
185
|
-
});
|
|
186
|
-
return {
|
|
187
|
-
revision: revisionOf(),
|
|
188
|
-
teamsFile,
|
|
189
|
-
squads,
|
|
190
|
-
experts,
|
|
191
|
-
engine: spec.engine ?? {},
|
|
192
|
-
defaults: spec.defaults ?? {},
|
|
193
|
-
lastBuild,
|
|
194
|
-
lastReload,
|
|
195
|
-
reloading: reloadTail !== undefined,
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/** 编译器**实际**会执行的上限:旧编译器不认 --max-members,用它写死的默认值。 */
|
|
200
|
-
const effectiveMemberLimit = () => (generatorSupportsMemberLimit ? memberLimit : COMPILER_DEFAULT_MAX_MEMBERS);
|
|
201
|
-
|
|
202
|
-
/** 校验(生成器之外的第一道闸门,错误信息面向面板)。 */
|
|
203
|
-
function validate(squads) {
|
|
204
|
-
if (!Array.isArray(squads) || squads.length === 0) {
|
|
205
|
-
throw fail("tTeam/squads-invalid", "至少要保留一个小队。");
|
|
206
|
-
}
|
|
207
|
-
const seenKeys = new Set();
|
|
208
|
-
const seenAliases = new Map();
|
|
209
|
-
squads.forEach((squad, index) => {
|
|
210
|
-
const label = `第 ${index + 1} 个小队`;
|
|
211
|
-
const key = text(squad?.key).trim();
|
|
212
|
-
if (!KEY_RE.test(key)) {
|
|
213
|
-
throw fail("tTeam/squads-invalid", `${label} 的 key「${key}」不合法:只能用小写字母、数字和连字符(例如 t-team-writing)。`);
|
|
214
|
-
}
|
|
215
|
-
if (seenKeys.has(key)) throw fail("tTeam/squads-invalid", `小队 key 重复:${key}`);
|
|
216
|
-
seenKeys.add(key);
|
|
217
|
-
const members = Array.isArray(squad?.members) ? squad.members.map(memberSlug).filter((slug) => slug !== "") : [];
|
|
218
|
-
if (members.length === 0) throw fail("tTeam/squads-invalid", `「${key}」没有成员:至少要选一位专家。`);
|
|
219
|
-
// 上限只有一个真源,但旧编译器**执行不了** Config 的值:直接挡在保存前,并把原因说清,
|
|
220
|
-
// 而不是把 10 人小队写下去、让编译器报「超过上限 8」再回滚(D-14 的可见形态)。
|
|
221
|
-
const limit = effectiveMemberLimit();
|
|
222
|
-
if (members.length > limit) {
|
|
223
|
-
throw fail("tTeam/squads-invalid",
|
|
224
|
-
`「${key}」有 ${members.length} 位成员,超过当前生效上限 ${limit}。`
|
|
225
|
-
+ (generatorSupportsMemberLimit
|
|
226
|
-
? "(上限来自 Config.maxMembers)"
|
|
227
|
-
: `该编译器版本不支持 --max-members,只能按它内置的 ${COMPILER_DEFAULT_MAX_MEMBERS} 执行;请更新 data/team-profiles.py 后再调大 Config.maxMembers。`));
|
|
228
|
-
}
|
|
229
|
-
for (const alias of Array.isArray(squad?.aliases) ? squad.aliases : []) {
|
|
230
|
-
const value = text(alias).trim();
|
|
231
|
-
if (value === "") continue;
|
|
232
|
-
const owner = seenAliases.get(value);
|
|
233
|
-
if (owner !== undefined && owner !== key) {
|
|
234
|
-
throw fail("tTeam/squads-invalid", `别名「${value}」被 ${owner} 和 ${key} 同时占用,会让 /t 无法判断用哪个小队。`);
|
|
235
|
-
}
|
|
236
|
-
seenAliases.set(value, key);
|
|
237
|
-
}
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/** 把 UI 传来的小队列表写回 teams.json(保留 engine/defaults 等顶层字段)。 */
|
|
242
|
-
function serialize(spec, squads) {
|
|
243
|
-
const previous = spec.profiles !== null && typeof spec.profiles === "object" ? spec.profiles : {};
|
|
244
|
-
const profiles = {};
|
|
245
|
-
for (const squad of squads) {
|
|
246
|
-
const key = text(squad.key).trim();
|
|
247
|
-
const members = (Array.isArray(squad.members) ? squad.members : [])
|
|
248
|
-
.map(memberSlug)
|
|
249
|
-
.filter((slug) => slug !== "");
|
|
250
|
-
// 保留面板没建模的字段(personaMode / personaBudget 等),只覆盖面板负责的那几项。
|
|
251
|
-
const merged = {
|
|
252
|
-
...(previous[key] !== null && typeof previous[key] === "object" ? previous[key] : {}),
|
|
253
|
-
description: text(squad.description),
|
|
254
|
-
aliases: (Array.isArray(squad.aliases) ? squad.aliases : [])
|
|
255
|
-
.map((alias) => text(alias).trim())
|
|
256
|
-
.filter((alias) => alias !== ""),
|
|
257
|
-
taskPlanning: squad.taskPlanning === "seed" ? "seed" : "captain",
|
|
258
|
-
members,
|
|
259
|
-
};
|
|
260
|
-
if (squad.enabled === false) merged.enabled = false;
|
|
261
|
-
else delete merged.enabled;
|
|
262
|
-
profiles[key] = merged;
|
|
263
|
-
}
|
|
264
|
-
return { ...spec, profiles };
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* 跑生成器:编译 t-team.config.json + teams.resolved.json(失败即回滚 teams.json)。
|
|
269
|
-
*
|
|
270
|
-
* **异步、且不得并发**(P-1):这里过去用 `execFileSync`,保存一次小队就把整个 Host 事件
|
|
271
|
-
* 循环同步阻塞(最长 60s)—— 同一进程里所有会话的 SSE、工具调用与 Web 请求全部停摆。
|
|
272
|
-
* 改成 `await execFile(...)` 之后事件循环继续转,但会引入原来不存在的交错点:两次 `build()`
|
|
273
|
-
* 可能并行运行,共享的 `lastBuild` 会被先完成的那次覆盖。所以用一条 in-flight promise 把
|
|
274
|
-
* 并发调用**合并成同一次**(语义上也更正确:同时刻的 teams.json 只该被编译一次)。
|
|
275
|
-
*
|
|
276
|
-
* @returns {Promise<{at:number, ok:boolean, output:string}>}
|
|
277
|
-
*/
|
|
278
|
-
function build() {
|
|
279
|
-
if (buildInFlight !== undefined) return buildInFlight;
|
|
280
|
-
const run = (async () => {
|
|
281
|
-
if (!existsSync(generator)) {
|
|
282
|
-
lastBuild = { at: Date.now(), ok: false, output: `找不到生成器 ${generator}` };
|
|
283
|
-
return lastBuild;
|
|
284
|
-
}
|
|
285
|
-
try {
|
|
286
|
-
// 上限随 Config 走;旧编译器(无 --max-members)退回它写死的默认值并明确告警。
|
|
287
|
-
if (memberLimit !== COMPILER_DEFAULT_MAX_MEMBERS && !generatorSupportsMemberLimit) {
|
|
288
|
-
lastBuild = {
|
|
289
|
-
at: Date.now(),
|
|
290
|
-
ok: true,
|
|
291
|
-
output: `[warn] 小队编译器不支持 --max-members(${generator}),成员上限仍按它内置的 ${COMPILER_DEFAULT_MAX_MEMBERS} 执行:`
|
|
292
|
-
+ `超过 ${COMPILER_DEFAULT_MAX_MEMBERS} 人的小队会编译失败,Config.maxMembers=${memberLimit} 不会生效。`
|
|
293
|
-
+ `要真正用上它,请把 data/team-profiles.py 更新到带 --max-members 的版本。`,
|
|
294
|
-
};
|
|
295
|
-
}
|
|
296
|
-
const memberArgs = generatorSupportsMemberLimit ? ["--max-members", String(memberLimit)] : [];
|
|
297
|
-
// 参数与 execFileSync 时代逐项一致(数组传参、无 shell、60s 上限),只换执行方式。
|
|
298
|
-
const { stdout, stderr } = await runFile(python, [generator, "--teams", teamsFile, ...memberArgs], {
|
|
299
|
-
cwd: dataDir,
|
|
300
|
-
encoding: "utf8",
|
|
301
|
-
timeout: 60_000,
|
|
302
|
-
maxBuffer: 4 * 1024 * 1024,
|
|
303
|
-
});
|
|
304
|
-
const parts = [stdout, stderr].map((part) => text(part).trim()).filter((part) => part !== "");
|
|
305
|
-
lastBuild = { at: Date.now(), ok: true, output: parts.join("\n").split("\n").slice(-6).join("\n") };
|
|
306
|
-
} catch (error) {
|
|
307
|
-
const output = [error?.stdout, error?.stderr].map((part) => text(part).trim()).filter((part) => part !== "").join("\n");
|
|
308
|
-
lastBuild = { at: Date.now(), ok: false, output: output === "" ? String(error) : output };
|
|
309
|
-
}
|
|
310
|
-
return lastBuild;
|
|
311
|
-
})().finally(() => {
|
|
312
|
-
if (buildInFlight === run) buildInFlight = undefined;
|
|
313
|
-
});
|
|
314
|
-
buildInFlight = run;
|
|
315
|
-
return run;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* 引擎热重载:保存成功后,让运行中的引擎用**新的** profiles 重挂,无需重启 DSH。
|
|
320
|
-
*
|
|
321
|
-
* 为什么必须在这里做:`/t` 列表每次从磁盘读(`lib/command.js` 的 `readTeams`),而引擎的
|
|
322
|
-
* profiles 是挂载期快照 —— 两者过去只在「重启」这一个点上重新对齐。保存是唯一让磁盘
|
|
323
|
-
* 配置变化的地方,所以在**编译成功之后**敲门重载是恢复一致性的正确时机。
|
|
324
|
-
*
|
|
325
|
-
* 三条不可动摇的语义:
|
|
326
|
-
* 1. 编译失败并回滚 → **不调用**(见 `save()`:回滚分支在调用点之前就抛了);
|
|
327
|
-
* 2. 重载失败**不改抛**——盘上已经是新配置,抛错会让用户以为「保存没生效」而不停重试;
|
|
328
|
-
* 失败原因通过返回值进 `lastReload`、日志与系统提示段,用户/模型都看得到;
|
|
329
|
-
* 3. 重载串行化:并发保存不会产生并发重挂。
|
|
330
|
-
*/
|
|
331
|
-
function scheduleReload() {
|
|
332
|
-
const run = (reloadTail ?? Promise.resolve()).then(async () => {
|
|
333
|
-
if (typeof onReload !== "function") {
|
|
334
|
-
// **绝不能静默当成功**:没有注入重载方意味着「保存生效了、引擎还用着旧 profiles」,
|
|
335
|
-
// 与"重载失败"是同一个可观察后果。记成失败(lastReload.ok=false + 原因),
|
|
336
|
-
// 系统提示段与 lastReload 都会带出去,用户/模型复述得到的是真实状态。
|
|
337
|
-
// 唯一的合法场景是单测直接构造数据层(此时没人会读 lastReload)。
|
|
338
|
-
lastReload = {
|
|
339
|
-
at: Date.now(),
|
|
340
|
-
ok: false,
|
|
341
|
-
detail: "没有注入热重载回调(onReload):这次保存已写入磁盘,但引擎仍在使用上一次的小队配置,需要重载插件或重启 DSH 才能用新小队建队。",
|
|
342
|
-
};
|
|
343
|
-
logError(lastReload.detail);
|
|
344
|
-
return;
|
|
345
|
-
}
|
|
346
|
-
const outcome = await onReload();
|
|
347
|
-
const ok = outcome?.ok !== false;
|
|
348
|
-
lastReload = { at: Date.now(), ok, detail: text(outcome?.detail) };
|
|
349
|
-
if (!ok) {
|
|
350
|
-
// 失败必须响:重载失败意味着"保存生效了、但运行中的引擎还用着旧 profiles"。
|
|
351
|
-
logError(`小队已保存,但团队引擎热重载失败:${lastReload.detail}`);
|
|
352
|
-
}
|
|
353
|
-
}).catch((error) => {
|
|
354
|
-
// 注入方自己抛错(契约违背)也要留住,不能让保存报告成"一切正常"。
|
|
355
|
-
lastReload = { at: Date.now(), ok: false, detail: String(error) };
|
|
356
|
-
logError(`小队已保存,但团队引擎热重载异常:${lastReload.detail}`);
|
|
357
|
-
}).finally(() => {
|
|
358
|
-
if (reloadTail === run) reloadTail = undefined;
|
|
359
|
-
});
|
|
360
|
-
reloadTail = run;
|
|
361
|
-
return run;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
async function save(squads, expectedRevision) {
|
|
365
|
-
validate(squads);
|
|
366
|
-
const current = revisionOf();
|
|
367
|
-
if (typeof expectedRevision === "string" && expectedRevision !== "" && expectedRevision !== current) {
|
|
368
|
-
throw fail("tTeam/squads-conflict", "小队定义已在别处改动,请刷新后重试。", {
|
|
369
|
-
expectedRevision,
|
|
370
|
-
actualRevision: current,
|
|
371
|
-
});
|
|
372
|
-
}
|
|
373
|
-
const previousText = readFileSync(teamsFile, "utf8");
|
|
374
|
-
const nextSpec = serialize(readSpec(), squads);
|
|
375
|
-
// 原子写(P-2):先写临时文件再 rename。进程若在写入中途被杀,原来会留下**截断的**
|
|
376
|
-
// teams.json,`readSpec()` 随即抛 `tTeam/squads-unreadable`,设置页「小队」标签整体不可用。
|
|
377
|
-
await writeFileAtomic(teamsFile, `${JSON.stringify(nextSpec, null, 2)}\n`);
|
|
378
|
-
const built = await build();
|
|
379
|
-
if (!built.ok) {
|
|
380
|
-
await writeFileAtomic(teamsFile, previousText); // 回滚,别把坏定义留在盘上(同样原子)
|
|
381
|
-
// 回滚之后**不重载**:盘上是旧配置,重载只会重新加载同一份旧配置,白跑一次还可能
|
|
382
|
-
// 让「保存失败」看起来像「保存成功」。下一次成功保存会重新对齐。
|
|
383
|
-
throw fail("tTeam/squads-build-failed", `配置生成失败,已回滚 teams.json:\n${built.output}`, {
|
|
384
|
-
actualRevision: revisionOf(),
|
|
385
|
-
});
|
|
386
|
-
}
|
|
387
|
-
// 编译成功 = 新配置已经落在盘上(`build()` 写的正是引擎启动时读的那份),现在敲门重载。
|
|
388
|
-
const settled = scheduleReload();
|
|
389
|
-
const result = await list();
|
|
390
|
-
// 有重载还在跑时(可能是本次的,也可能是上一次的尾巴)就等它结束:这样本次保存返回的
|
|
391
|
-
// `lastReload` 才名副其实,调用方(自检探针 / 未来的面板)能直接读到「这次保存到底重载
|
|
392
|
-
// 成功没有」。等待期间系统提示段已经把真相写出来了,不会误导。
|
|
393
|
-
await settled;
|
|
394
|
-
return { ...result, lastReload, reloading: reloadTail !== undefined };
|
|
395
|
-
}
|
|
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
|
-
|
|
438
|
-
return {
|
|
439
|
-
list, save, validate, resync, revision: revisionOf, generator, dataDir,
|
|
440
|
-
// 本服务没有 `dispose()`:它只在 `save()` 调用期间持有状态(重载串行尾由 save() 自己 await
|
|
441
|
-
// 干净),两次调用之间是无状态的,所以宿主卸载时没有需要收尾的异步工作。
|
|
442
|
-
// 让调用方(与自检)看得到「这次编译用的是哪个上限、编译器到底认不认这个参数」。
|
|
443
|
-
memberLimit,
|
|
444
|
-
generatorSupportsMemberLimit,
|
|
445
|
-
};
|
|
446
|
-
}
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 「谁能当 assignee」的契约 —— 一处定义,三处复用。
|
|
3
|
-
*
|
|
4
|
-
* `captain` 是引擎的**预留键**,不是成员:`add_member` 明确禁止把成员命名成
|
|
5
|
-
* captain(`tools.js`),成员表里也永远查不到它。
|
|
6
|
-
*
|
|
7
|
-
* 但同名参数 `assignee` 在三个入口里的合法取值**并不相同**:
|
|
8
|
-
*
|
|
9
|
-
* | 入口 | `assignee="captain"` |
|
|
10
|
-
* |---|---|
|
|
11
|
-
* | `t_team_create_task` | ✗ 拒绝(本模块的报错) |
|
|
12
|
-
* | `t_team_reassign_task` | ✓ 队长接管,由它自己开旁路并执行接管约束 |
|
|
13
|
-
* | `t_team_plan_check` | ✗ 拒绝(与 create_task 一致) |
|
|
14
|
-
*
|
|
15
|
-
* 为什么不干脆让 create_task 也接受:队长接管受两条**只能在接管阶段**判断的约束
|
|
16
|
-
* (依赖未完成的阻塞任务不能被接管;同一时刻只能持有 1 个未完成接管任务),
|
|
17
|
-
* 而建图时任务还是 pending、两条都无据可查。放开等于绕开安全闸。
|
|
18
|
-
*
|
|
19
|
-
* 因此正确做法是:**建任务不给 assignee(进共享池),等依赖完成后用
|
|
20
|
-
* `reassign_task(task_id, assignee="captain")` 接管**。
|
|
21
|
-
*
|
|
22
|
-
* 曾经的缺陷:create_task 直接把它丢给按成员名精确匹配的 `requireMember()`,
|
|
23
|
-
* 于是只回一句 `no active member named "captain"`,不提示正确入口 ——
|
|
24
|
-
* 模型自然会重试同一条路。本模块存在的意义就是把这个岔路口说清楚。
|
|
25
|
-
* @module t-team/assignee-contract
|
|
26
|
-
*/
|
|
27
|
-
import { CAPTAIN_KEY } from "./state.js";
|
|
28
|
-
|
|
29
|
-
/** create_task / plan_check 对 reserve 键的标准报错文案(含可执行的下一步)。 */
|
|
30
|
-
export const CAPTAIN_ASSIGNEE_HINT = 'captain 是引擎预留键、不是成员,create_task 不能把任务派给队长;'
|
|
31
|
-
+ '请不带 assignee 建任务(进共享池),等依赖完成后再用 t_team_reassign_task(task_id, assignee="captain") 接管'
|
|
32
|
-
+ '(依赖未完成的任务不能被接管,且队长同一时刻只能持有 1 个未完成接管任务)';
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* 在 create_task / plan_check 的 assignee 校验位拦下预留键。
|
|
36
|
-
* 大小写与空白无关(`Captain`、` CAPTAIN ` 同样拦下),与 `sanitizeKey` 的语义对齐。
|
|
37
|
-
* @param {unknown} assignee 调用方给的 assignee 原值
|
|
38
|
-
* @returns {string | undefined} 通过时返回清洗后的成员名,未给 assignee 时返回 undefined
|
|
39
|
-
* @throws {Error} 取值等价于 captain 时抛出,文案即 `CAPTAIN_ASSIGNEE_HINT`
|
|
40
|
-
*/
|
|
41
|
-
export function normalizeAssigneeForCreate(assignee) {
|
|
42
|
-
if (typeof assignee !== 'string') return undefined;
|
|
43
|
-
const trimmed = assignee.trim();
|
|
44
|
-
if (trimmed === '') return undefined;
|
|
45
|
-
if (trimmed.toLowerCase() === CAPTAIN_KEY) throw new Error(CAPTAIN_ASSIGNEE_HINT);
|
|
46
|
-
return trimmed;
|
|
47
|
-
}
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
import { readdirSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { readTeamSync, readRetiredMemberIdsSync } from "./state.js";
|
|
4
|
-
import { MEMBER_TOOL_NAMES, TEAM_TOOL_NAMES } from "./tool-names.js";
|
|
5
|
-
export const TEAM_ACTIVATION_PROMPT = 'T Team (T专家 团队) provides multi-agent team collaboration. Apply these rules when the user requests it (including /t-team) or when continuing an existing team. Mentioning, quoting, discussing, or declining T Team alone is not a request to start work.';
|
|
6
|
-
export const TEAM_MEMBER_PROMPT = 'You are a T Team member. Follow your assigned member persona and task contract. Use t_team_claim_task, t_team_update_task, t_team_send_message and t_team_status for your own work. Include the current attempt_id in updates; report completion or failure to the captain. Do not create, approve, edit or resume a team. If your durable membership is unavailable, report that to the parent instead of creating a replacement.';
|
|
7
|
-
function stateRoot(agent, config) {
|
|
8
|
-
return join(agent.session.header.cwd ?? process.cwd(), config.stateDir);
|
|
9
|
-
}
|
|
10
|
-
/** Synchronous startup/HMR hydration must finish before the first assembly. */
|
|
11
|
-
function currentTeam(agent, config) {
|
|
12
|
-
const root = stateRoot(agent, config);
|
|
13
|
-
let entries;
|
|
14
|
-
try {
|
|
15
|
-
entries = readdirSync(root, { withFileTypes: true });
|
|
16
|
-
}
|
|
17
|
-
catch (error) {
|
|
18
|
-
if (error.code === 'ENOENT')
|
|
19
|
-
return undefined;
|
|
20
|
-
throw error;
|
|
21
|
-
}
|
|
22
|
-
let found;
|
|
23
|
-
for (const entry of entries) {
|
|
24
|
-
if (!entry.isDirectory() || entry.name === 'archive')
|
|
25
|
-
continue;
|
|
26
|
-
const team = readTeamSync(root, entry.name);
|
|
27
|
-
if (team === undefined || (team.captainSessionId !== agent.id
|
|
28
|
-
&& !team.members.some(member => member.id === agent.id)))
|
|
29
|
-
continue;
|
|
30
|
-
if (found !== undefined)
|
|
31
|
-
throw new Error('ambiguous T Team membership');
|
|
32
|
-
found = team;
|
|
33
|
-
}
|
|
34
|
-
return found;
|
|
35
|
-
}
|
|
36
|
-
/** Call once, after all business definitions have registered. Never per member. */
|
|
37
|
-
export function installTeamCapabilities(ctx, config) {
|
|
38
|
-
const states = new WeakMap();
|
|
39
|
-
const active = new Set();
|
|
40
|
-
let mounted = true;
|
|
41
|
-
// Snapshot policy once: profiles, team state, and tool results must never
|
|
42
|
-
// rewrite this prefix or control whether core instructions are available.
|
|
43
|
-
const captainPrompt = `${TEAM_ACTIVATION_PROMPT}\n\n${config.captainPrompt()}`;
|
|
44
|
-
function attach(agent) {
|
|
45
|
-
const prior = states.get(agent);
|
|
46
|
-
if (prior !== undefined)
|
|
47
|
-
return prior;
|
|
48
|
-
if (!mounted)
|
|
49
|
-
throw new Error('T Team capability provider is disposed');
|
|
50
|
-
// Determine a member's role before its first request and retain it for the
|
|
51
|
-
// lifetime of this scope. Team creation/archive must never rewrite the
|
|
52
|
-
// captain's system/tools prefix, even after a long ordinary conversation.
|
|
53
|
-
let member = config.isPendingMember(agent);
|
|
54
|
-
try {
|
|
55
|
-
member ||= readRetiredMemberIdsSync(stateRoot(agent, config)).has(agent.id);
|
|
56
|
-
const team = currentTeam(agent, config);
|
|
57
|
-
member ||= team !== undefined && team.captainSessionId !== agent.id;
|
|
58
|
-
}
|
|
59
|
-
catch (error) {
|
|
60
|
-
// Unrelated damaged state must not disable ordinary conversation.
|
|
61
|
-
// Business tools still validate durable team state before acting.
|
|
62
|
-
ctx.logger.warn(`t-team: capability hydration failed: ${String(error)}`);
|
|
63
|
-
}
|
|
64
|
-
const state = { member, dispose: () => undefined };
|
|
65
|
-
let revoke;
|
|
66
|
-
let disposed = false;
|
|
67
|
-
let releaseLifetime;
|
|
68
|
-
state.dispose = () => {
|
|
69
|
-
if (disposed)
|
|
70
|
-
return;
|
|
71
|
-
disposed = true;
|
|
72
|
-
revoke?.();
|
|
73
|
-
releaseLifetime?.();
|
|
74
|
-
states.delete(agent);
|
|
75
|
-
active.delete(state);
|
|
76
|
-
};
|
|
77
|
-
states.set(agent, state);
|
|
78
|
-
active.add(state);
|
|
79
|
-
try {
|
|
80
|
-
if (member)
|
|
81
|
-
revoke = agent.ctx.tools.restrict({
|
|
82
|
-
deny: TEAM_TOOL_NAMES.filter(name => !MEMBER_TOOL_NAMES.includes(name)),
|
|
83
|
-
});
|
|
84
|
-
releaseLifetime = agent.ctx.effect(() => state.dispose, 't-team: capability lifetime');
|
|
85
|
-
return state;
|
|
86
|
-
}
|
|
87
|
-
catch (error) {
|
|
88
|
-
state.dispose();
|
|
89
|
-
throw error;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
ctx.systemPrompt.section({
|
|
93
|
-
name: 't-team:usage', order: config.order ?? 117,
|
|
94
|
-
text: ({ agent }) => {
|
|
95
|
-
return agent !== undefined && states.get(agent)?.member ? TEAM_MEMBER_PROMPT : captainPrompt;
|
|
96
|
-
},
|
|
97
|
-
});
|
|
98
|
-
ctx.on('agent/session-start', ({ agent }) => { attach(agent); });
|
|
99
|
-
ctx.effect(() => () => {
|
|
100
|
-
mounted = false;
|
|
101
|
-
for (const state of [...active])
|
|
102
|
-
state.dispose();
|
|
103
|
-
}, 't-team: capability scopes');
|
|
104
|
-
for (const agent of ctx.agents.list())
|
|
105
|
-
attach(agent);
|
|
106
|
-
}
|