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/command.js
DELETED
|
@@ -1,251 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
/**
|
|
3
|
-
* `/t` —— T专家 的小队短命令。
|
|
4
|
-
*
|
|
5
|
-
* 只做一件事:把 `/t <小队> <目标>` 翻译成引擎认得的手势消息
|
|
6
|
-
* `/t --profile <key> <目标>`(引擎的手势边界认得这个形状),并作为**一条真实用户消息**投递回去;
|
|
7
|
-
* 之后由引擎的 `agent/pre-step` 手势边界接手。
|
|
8
|
-
* 也就是说:引擎 100% 复用内置的团队引擎(lib/teams/),T专家 只提供「人 + 短命令」。
|
|
9
|
-
*/
|
|
10
|
-
import { readFileSync } from "node:fs";
|
|
11
|
-
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
12
|
-
|
|
13
|
-
export const COMMAND_NAME = "t";
|
|
14
|
-
|
|
15
|
-
/** 把两种来源的成员写法归一成 { name, role }:
|
|
16
|
-
* - team-profiles.py 生成的 teams.resolved.json:成员已是对象
|
|
17
|
-
* - 人工维护的 teams.json:成员是专家 slug 字符串
|
|
18
|
-
*/
|
|
19
|
-
function normalizeMembers(members) {
|
|
20
|
-
if (!Array.isArray(members)) return [];
|
|
21
|
-
return members
|
|
22
|
-
.map((member) => {
|
|
23
|
-
if (typeof member === "string") return { name: member, role: member };
|
|
24
|
-
if (member !== null && typeof member === "object" && typeof member.name === "string") {
|
|
25
|
-
return { name: member.name, role: typeof member.role === "string" ? member.role : member.name };
|
|
26
|
-
}
|
|
27
|
-
return undefined;
|
|
28
|
-
})
|
|
29
|
-
.filter((member) => member !== undefined);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** 归一成 { description, aliases, taskPlanning, members },缺字段给安全默认值。 */
|
|
33
|
-
function normalizeProfile(profile) {
|
|
34
|
-
const source = profile !== null && typeof profile === "object" ? profile : {};
|
|
35
|
-
return {
|
|
36
|
-
description: typeof source.description === "string" ? source.description : "",
|
|
37
|
-
aliases: Array.isArray(source.aliases) ? source.aliases.filter((a) => typeof a === "string") : [],
|
|
38
|
-
taskPlanning: source.taskPlanning === "seed" ? "seed" : "captain",
|
|
39
|
-
members: normalizeMembers(source.members),
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* 每次调用都从磁盘读小队定义(改名单不需要重启):
|
|
45
|
-
* 优先读同目录下由 team-profiles.py 生成的 `teams.resolved.json`(成员带中文名),
|
|
46
|
-
* 缺失时回退人工维护的 `teams.json`(成员是 slug)。
|
|
47
|
-
*/
|
|
48
|
-
export function readTeams(file) {
|
|
49
|
-
const candidates = [file.replace(/teams\.json$/u, "teams.resolved.json"), file];
|
|
50
|
-
for (const candidate of candidates) {
|
|
51
|
-
try {
|
|
52
|
-
const parsed = JSON.parse(readFileSync(candidate, "utf8"));
|
|
53
|
-
const profiles = parsed?.profiles;
|
|
54
|
-
if (profiles === null || typeof profiles !== "object" || Array.isArray(profiles)) continue;
|
|
55
|
-
const out = {};
|
|
56
|
-
for (const [key, profile] of Object.entries(profiles)) out[key] = normalizeProfile(profile);
|
|
57
|
-
if (Object.keys(out).length > 0) return out;
|
|
58
|
-
} catch {
|
|
59
|
-
/* 试下一个候选 */
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
return {};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** 归一化:小写、去空白、全角转半角(中文别名不受影响)。 */
|
|
66
|
-
function normalizeToken(value) {
|
|
67
|
-
return String(value ?? "")
|
|
68
|
-
.trim()
|
|
69
|
-
.toLowerCase()
|
|
70
|
-
.replace(/[\uFF01-\uFF5E]/gu, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xfee0))
|
|
71
|
-
.replace(/[\s\u3000]/gu, "");
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* 用 profile key 或别名找小队。三轮:① 精确(key/别名)② 双向子串(用户多打或少打字)
|
|
76
|
-
* ③ 多个别名命中同一小队时视为命中(例如「写首诗」同时含「诗」与「写」两个别名)。
|
|
77
|
-
*/
|
|
78
|
-
export function resolveTeam(profiles, query) {
|
|
79
|
-
const needle = normalizeToken(query);
|
|
80
|
-
if (needle === "") return undefined;
|
|
81
|
-
const entries = Object.entries(profiles).map(([key, profile]) => ({
|
|
82
|
-
key,
|
|
83
|
-
profile,
|
|
84
|
-
tokens: [key, ...(Array.isArray(profile?.aliases) ? profile.aliases : [])].map(normalizeToken).filter((t) => t !== ""),
|
|
85
|
-
}));
|
|
86
|
-
for (const entry of entries) {
|
|
87
|
-
if (entry.tokens.some((token) => token === needle)) return { key: entry.key, profile: entry.profile };
|
|
88
|
-
}
|
|
89
|
-
const fuzzy = entries.filter((entry) => entry.tokens.some((token) => token.includes(needle) || needle.includes(token)));
|
|
90
|
-
if (fuzzy.length === 1) return { key: fuzzy[0].key, profile: fuzzy[0].profile };
|
|
91
|
-
const multi = entries.filter((entry) => entry.tokens.filter((token) => needle.includes(token)).length >= 2);
|
|
92
|
-
if (multi.length === 1) return { key: multi[0].key, profile: multi[0].profile };
|
|
93
|
-
return undefined;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** 小队一句话摘要(给队长复述用)。 */
|
|
97
|
-
function describeProfile(key, profile) {
|
|
98
|
-
const members = Array.isArray(profile?.members) ? profile.members : [];
|
|
99
|
-
const names = members.map((member) => member.name).filter((name) => typeof name === "string");
|
|
100
|
-
const aliases = Array.isArray(profile?.aliases) && profile.aliases.length > 0 ? `(别名 ${profile.aliases.join(" / ")})` : "";
|
|
101
|
-
return `${key}${aliases} — ${profile?.description ?? ""}${names.length > 0 ? ` · ${names.length} 人:${names.join("、")}` : ""}`;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* 让队长把信息**用中文说出来**的投递文本。
|
|
106
|
-
*
|
|
107
|
-
* 为什么不用命令的返回值:部分 DSH 版本里命令行的结果行不渲染(用户「发了命令没任何反应」),
|
|
108
|
-
* 而 followup 消息一定会渲染成可见对话。所以 `/t` 的每一种结果都投一条可读指令给队长。
|
|
109
|
-
*/
|
|
110
|
-
function relayListDirective(list) {
|
|
111
|
-
return [
|
|
112
|
-
"【T专家 · 小队列表】用户敲了 `/t`(没有带目标),请直接用中文把下面的小队列表回复给用户,",
|
|
113
|
-
"并带一句用法提示:`/t <小队|别名> <目标>`。不要创建团队、不要调用建队工具。",
|
|
114
|
-
"",
|
|
115
|
-
list,
|
|
116
|
-
].join("\n");
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function relayPickDirective(key, profile) {
|
|
120
|
-
const members = Array.isArray(profile?.members) ? profile.members : [];
|
|
121
|
-
const names = members.map((member) => member.name).filter((name) => typeof name === "string");
|
|
122
|
-
return [
|
|
123
|
-
`【T专家 · 已选定小队:${key}】用户敲了 \`/t ${key}\` 但没给目标。`,
|
|
124
|
-
"请用中文先列出这个小队的成员,再问用户这次要完成什么。现在不要建队、不要调用建队工具。",
|
|
125
|
-
"",
|
|
126
|
-
`小队:${describeProfile(key, profile)}`,
|
|
127
|
-
names.length > 0 ? `成员:${names.join("、")}` : "成员:尚未解析(跑一次 team-profiles.py)",
|
|
128
|
-
].join("\n");
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function relayRouteDirective(input, list) {
|
|
132
|
-
return [
|
|
133
|
-
`【T专家 · 未指定小队】用户敲的是:\`/t ${input}\`,其中没有能匹配到小队名的词。`,
|
|
134
|
-
`请把「${input}」当作目标,从下面的小队里挑最合适的一个,然后**直接调用 t_team_create 并带上 profile="<key>"** 建队(approval=\"required\" 先 staged,等用户在面板 review);`,
|
|
135
|
-
"不要反问用户该用哪个小队,也不要自己手搭成员——profile 已带好成员与人格。",
|
|
136
|
-
// profile 描述的是「这类目标通常涉及哪些角色」,不是「这次必须全员上场」。不写清这一条,
|
|
137
|
-
// 队长会把它读成「roster 不可动」,于是为了不让人闲着而给边缘角色硬造任务:实测一份单文件
|
|
138
|
-
// HTML 简历被派给了 5 个人 —— 后端架构师去写「信息架构」、数据库性能工程师去查「A4 分页」。
|
|
139
|
-
// 那次的队长思考里两处原话正是「有点牵强」「这有点为凑人头造工作」,但它仍然照派了。
|
|
140
|
-
"但 profile 是「编制清单」,不等于这次全员都得上场:挑定小队后先逐人自问「这次真的需要这个角色的独有产出吗」,用不上的在 staged 阶段用 t_team_edit_plan 的 remove_member 裁掉。",
|
|
141
|
-
"顺序必须是**先裁人、后建任务**——任务一旦引用了成员,再删人就得先改依赖引用。拿不准的一律保留,且至少留 2 人(一个产出者 + 一个独立验证者,否则独立验证无从谈起)。",
|
|
142
|
-
"无论裁没裁,都要在给用户的计划说明里写明留了谁、裁了谁、为什么。",
|
|
143
|
-
"若确实没有合适的小队,就用中文说明原因并列出可用小队。",
|
|
144
|
-
"",
|
|
145
|
-
list,
|
|
146
|
-
].join("\n");
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** `/t` 无参数时的列表。 */
|
|
150
|
-
export function renderTeamList(profiles) {
|
|
151
|
-
const keys = Object.keys(profiles);
|
|
152
|
-
if (keys.length === 0) {
|
|
153
|
-
return "还没有配置小队。打开 设置 → T专家 → 小队 保存一次就会生成(保存时用数据目录里的 team-profiles.py 编译引擎配置)。";
|
|
154
|
-
}
|
|
155
|
-
const lines = ["T专家 小队(用 /t <小队> <目标> 拉起):"];
|
|
156
|
-
for (const key of keys) {
|
|
157
|
-
const profile = profiles[key] ?? {};
|
|
158
|
-
const members = Array.isArray(profile.members) ? profile.members : [];
|
|
159
|
-
const names = members.map((member) => member.name).filter((name) => typeof name === "string");
|
|
160
|
-
const aliases = Array.isArray(profile.aliases) && profile.aliases.length > 0
|
|
161
|
-
? `(别名 ${profile.aliases.join(" / ")})`
|
|
162
|
-
: "";
|
|
163
|
-
const detail = names.length > 0 ? `${names.length} 人:${names.join("、")}` : "尚未解析成员(跑一次 team-profiles.py)";
|
|
164
|
-
lines.push(`- ${key}${aliases} — ${profile.description ?? ""} · ${detail}`);
|
|
165
|
-
}
|
|
166
|
-
lines.push("也可以直接用引号里的完整写法:/t --profile <key> <目标>");
|
|
167
|
-
return lines.join("\n");
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* 注册 `/t`。
|
|
172
|
-
* @param ctx 宿主上下文(需要 `effect` / `logger` 等完整能力)
|
|
173
|
-
* @param {object} options 注册选项
|
|
174
|
-
* @param {string} options.teamsFile teams.json 路径(通常与名册同目录)
|
|
175
|
-
* @param {() => boolean} options.hasEngine 探测内置团队引擎是否在位的函数
|
|
176
|
-
* @param {() => string} [options.engineError] 引擎不可用时的原因(报给用户)
|
|
177
|
-
* @param {object} [options.commands] 已解析好的 commands 服务(可选服务的时序修复,见下)
|
|
178
|
-
*/
|
|
179
|
-
export function registerTeamCommand(ctx, options) {
|
|
180
|
-
const teamsFile = options.teamsFile;
|
|
181
|
-
const hasEngine = options.hasEngine;
|
|
182
|
-
// ⚠️ 2026-09-13:`commands` 是**可选服务**,真实宿主里它**晚于本插件 apply** 才就绪;
|
|
183
|
-
// 而 `ctx.get("commands")` 在那一刻是 undefined,且 cordis 的 `provide` **不回溯通知**已跑过的代码
|
|
184
|
-
// —— 于是 `/t` 永远不会注册。调用方已用 `ctx.inject(["commands"], …)` 等到就绪后把服务传进来;
|
|
185
|
-
// 这里再兜底懒查一次(直连调用/测试),两条路都不做属性访问。
|
|
186
|
-
const commands = options.commands ?? ctx.get?.("commands");
|
|
187
|
-
if (typeof commands?.register !== "function") {
|
|
188
|
-
ctx.logger?.warn?.("[t-team] 没有可用的 commands 服务:/t 命令不注册。");
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
ctx.effect(() => commands.register({
|
|
192
|
-
name: COMMAND_NAME,
|
|
193
|
-
description: "用 T专家 小队执行一个目标(底层走 T专家 内置的多智能体团队引擎)",
|
|
194
|
-
input: { hint: "<小队名|别名> <目标>" },
|
|
195
|
-
handler(invocation) {
|
|
196
|
-
if (hasEngine() !== true) {
|
|
197
|
-
const detail = typeof options.engineError === "function" ? options.engineError() : "";
|
|
198
|
-
// 中性表述:不指路日志。旧文案让用户去看 `[t-team]` error 行,但「引擎已调度、工具却没注册」
|
|
199
|
-
// 这条路径下那句话**永远不会打印**(lib/index.js 的 engineReady 探到假只会补 warn),
|
|
200
|
-
// 于是指引恒定失效(N-5)。
|
|
201
|
-
const text = detail === ""
|
|
202
|
-
? "团队引擎未就绪:T专家 内置的团队引擎没有注册成功(它的工具没有出现在工具表里)。"
|
|
203
|
-
: `团队引擎挂载失败:${detail}`;
|
|
204
|
-
// 命令结果行在部分 DSH 版本不渲染,所以连错误也交给队长转述一次。
|
|
205
|
-
invocation.agent.followup(createUserMessage({
|
|
206
|
-
content: [{ type: "text", text: `【T专家】团队引擎未就绪,请用中文把这句话转告用户,不要建队:${text}` }],
|
|
207
|
-
source: { kind: "user" },
|
|
208
|
-
}));
|
|
209
|
-
return { kind: "error", text };
|
|
210
|
-
}
|
|
211
|
-
const profiles = readTeams(teamsFile);
|
|
212
|
-
const list = renderTeamList(profiles);
|
|
213
|
-
const deliver = (text) => invocation.agent.followup(createUserMessage({
|
|
214
|
-
content: [{ type: "text", text }],
|
|
215
|
-
source: { kind: "user" },
|
|
216
|
-
}));
|
|
217
|
-
const raw = String(invocation?.rawInput ?? "").trim();
|
|
218
|
-
|
|
219
|
-
// ① `/t` 裸调用:让队长把小队列表说出来(可见),而不是只塞进命令结果行。
|
|
220
|
-
if (raw === "" || raw === "list" || raw === "ls" || raw === "help") {
|
|
221
|
-
deliver(relayListDirective(list));
|
|
222
|
-
return { kind: "success", text: list };
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const [head, ...rest] = raw.split(/\s+/u);
|
|
226
|
-
const goal = rest.join(" ").trim();
|
|
227
|
-
const hit = resolveTeam(profiles, head);
|
|
228
|
-
|
|
229
|
-
// ② 没匹配到小队:不报错了事,让队长按目标自动挑小队(用户不必记得队名)。
|
|
230
|
-
if (hit === undefined) {
|
|
231
|
-
deliver(relayRouteDirective(raw, list));
|
|
232
|
-
return { kind: "success", text: `「${head}」不是小队名——已交给队长按目标挑选小队。` };
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// ③ 只给了小队名:让队长列成员并问目标。
|
|
236
|
-
if (goal === "") {
|
|
237
|
-
deliver(relayPickDirective(hit.key, hit.profile));
|
|
238
|
-
return { kind: "success", text: `已选定「${hit.key}」,队长会列出成员并问你这次要做什么。` };
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// ④ 小队 + 目标:投成引擎认得的手势消息,pre-step 边界注入队长协议。
|
|
242
|
-
deliver(`/t --profile ${hit.key} ${goal}`);
|
|
243
|
-
const memberCount = Array.isArray(hit.profile?.members) ? hit.profile.members.length : 0;
|
|
244
|
-
const label = hit.profile?.description || hit.key;
|
|
245
|
-
return {
|
|
246
|
-
kind: "success",
|
|
247
|
-
text: `已激活「${label}」${memberCount > 0 ? `(${memberCount} 人)` : ""} —— 队长会先给出成员与任务草案,等你 review 后再启动。`,
|
|
248
|
-
};
|
|
249
|
-
},
|
|
250
|
-
}), "t-team: /t command");
|
|
251
|
-
}
|
package/lib/plan-check.js
DELETED
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
/**
|
|
3
|
-
* DAG 预检(只读):把「打算创建的一整套任务」先跑一遍引擎的校验,**一次报出全部问题**。
|
|
4
|
-
*
|
|
5
|
-
* 为什么需要它:引擎建任务是**逐条**的(依赖只能引用已存在的任务),每条硬校验都单独拒绝,
|
|
6
|
-
* 中途失败会留下半成品,模型再重试就容易出现重复任务(本机实测踩过)。
|
|
7
|
-
* 这里复用引擎自己的 `validateCreateTask`(契约/写域重叠/质量门槛),**并复用引擎导出的
|
|
8
|
-
* `collectStagedGraphProblems`** 做引用与环检查 —— 全部在内存里模拟、不写任何状态。
|
|
9
|
-
*
|
|
10
|
-
* 规则**只有一份**(`lib/teams/tools.js`):过去这里按同规则复刻过一遍,两处一旦漂移,
|
|
11
|
-
* 预检就会说 ok、真建任务却被引擎拒 —— 而预检的全部意义正是提前发现这种岔路。
|
|
12
|
-
*/
|
|
13
|
-
import { CAPTAIN_KEY } from "./teams/state.js";
|
|
14
|
-
import { normalizeAssigneeForCreate } from "./teams/assignee-contract.js";
|
|
15
|
-
import { validateCreateTask } from "./teams/quality-gates.js";
|
|
16
|
-
import { collectStagedGraphProblems } from "./teams/tools.js";
|
|
17
|
-
|
|
18
|
-
/** 拓扑序(依赖在前);有环时返回已排好的部分。 */
|
|
19
|
-
function topologicalOrder(tasks) {
|
|
20
|
-
const ids = new Set(tasks.map((task) => task.id));
|
|
21
|
-
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
22
|
-
const done = new Set();
|
|
23
|
-
const order = [];
|
|
24
|
-
const visit = (id) => {
|
|
25
|
-
if (done.has(id)) return;
|
|
26
|
-
done.add(id);
|
|
27
|
-
for (const dependency of byId.get(id)?.dependencies ?? []) if (ids.has(dependency)) visit(dependency);
|
|
28
|
-
order.push(id);
|
|
29
|
-
};
|
|
30
|
-
for (const task of tasks) visit(task.id);
|
|
31
|
-
return order;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* @param team 引擎的团队状态(`readTeam` 的返回值)
|
|
36
|
-
* @param tasks 拟创建的任务数组;每项可带 `id`(本批内互相引用的标签,可省略)、`subject`、`kind`、
|
|
37
|
-
* `dependencies`、`inScope`、`outOfScope`、`verify`、`acceptance`、`objective`、`assignee` 等
|
|
38
|
-
* @returns `{ ok, problems, warnings, order, existing }`
|
|
39
|
-
*/
|
|
40
|
-
export function checkPlan(team, tasks) {
|
|
41
|
-
const proposed = Array.isArray(tasks) ? tasks : [];
|
|
42
|
-
const existing = Array.isArray(team?.tasks) ? team.tasks : [];
|
|
43
|
-
const problems = [];
|
|
44
|
-
const warnings = [];
|
|
45
|
-
if (proposed.length === 0) problems.push("没有要检查的任务(tasks 为空)");
|
|
46
|
-
|
|
47
|
-
// 成员名集合:assignee 必须是活跃成员或 captain
|
|
48
|
-
const memberNames = new Set(
|
|
49
|
-
(team?.members ?? []).filter((member) => member.status !== "removed").map((member) => member.name),
|
|
50
|
-
);
|
|
51
|
-
|
|
52
|
-
// 逐条模拟:先做静态检查,再跑引擎的 validateCreateTask(它只看已存在的任务 + 已接受的模拟任务)
|
|
53
|
-
const sim = { ...(team ?? {}), tasks: [...existing] };
|
|
54
|
-
const taken = new Set(existing.map((task) => task.id));
|
|
55
|
-
// 本批 id(标签)→ 引擎会分配的模拟 id。**按声明顺序累积**,不预先解析全批 —— 见
|
|
56
|
-
// `resolveDependencies` 的说明:引擎要求依赖已存在,前向引用本就建不出来。
|
|
57
|
-
const labels = new Map();
|
|
58
|
-
// 每个位置(含**没有 id 的**任务)→ 模拟 id:发号与标签解析必须共用同一次发号,
|
|
59
|
-
// 否则「只给带 id 的发号」会让报告里的 id 与实际创建顺序不一致。
|
|
60
|
-
const simulatedIds = [];
|
|
61
|
-
// 引擎分配的是 `t${team.taskSeq + 1}` 并随后自增(不是"现有任务条数")。两者只在
|
|
62
|
-
// taskSeq === tasks.length 时才一致 —— 有任务被删除/取消过就会分叉,所以以 taskSeq 为准。
|
|
63
|
-
let counter = Number.isSafeInteger(team?.taskSeq) ? team.taskSeq : existing.length;
|
|
64
|
-
const issueSimulatedId = () => {
|
|
65
|
-
let simulatedId;
|
|
66
|
-
do {
|
|
67
|
-
counter += 1;
|
|
68
|
-
simulatedId = `t${counter}`;
|
|
69
|
-
} while (taken.has(simulatedId) || existing.some((task) => task.id === simulatedId));
|
|
70
|
-
taken.add(simulatedId);
|
|
71
|
-
return simulatedId;
|
|
72
|
-
};
|
|
73
|
-
const resolveDependencies = (raw) => (Array.isArray(raw?.dependencies) ? raw.dependencies : [])
|
|
74
|
-
.map((dependency) => String(dependency))
|
|
75
|
-
.map((dependency) => labels.get(dependency) ?? dependency);
|
|
76
|
-
const report = [];
|
|
77
|
-
|
|
78
|
-
proposed.forEach((raw, index) => {
|
|
79
|
-
const local = [];
|
|
80
|
-
const wantId = typeof raw?.id === "string" && raw.id.trim() !== "" ? raw.id.trim() : undefined;
|
|
81
|
-
const simulatedId = issueSimulatedId();
|
|
82
|
-
simulatedIds[index] = simulatedId;
|
|
83
|
-
// 登记在本条**解析依赖之后**:这样 `dependencies` 里的同批标签只有「已声明过的」
|
|
84
|
-
// 才能解析成模拟 id,与引擎的「依赖必须已存在」一致。
|
|
85
|
-
if (wantId !== undefined) labels.set(wantId, simulatedId);
|
|
86
|
-
|
|
87
|
-
const subject = String(raw?.subject ?? "").trim();
|
|
88
|
-
if (subject === "") local.push("缺少 subject");
|
|
89
|
-
const kind = typeof raw?.kind === "string" && raw.kind !== "" ? raw.kind : "work";
|
|
90
|
-
const dependencies = resolveDependencies(raw);
|
|
91
|
-
if (dependencies.includes(simulatedId)) local.push("不能依赖自己");
|
|
92
|
-
// 判定「不存在的依赖」时,本批**已声明**的标签算作已知(它们会在模拟里成为真实任务 id)。
|
|
93
|
-
const unknown = dependencies.filter(
|
|
94
|
-
(dependency) => !taken.has(dependency) && !labels.has(dependency) && !existing.some((task) => task.id === dependency),
|
|
95
|
-
);
|
|
96
|
-
if (unknown.length > 0) local.push(`依赖不存在的任务:${unknown.join(", ")}`);
|
|
97
|
-
const rawAssignee = typeof raw?.assignee === "string" && raw.assignee.trim() !== "" ? raw.assignee.trim() : undefined;
|
|
98
|
-
let assignee = rawAssignee;
|
|
99
|
-
if (rawAssignee !== undefined) {
|
|
100
|
-
// 预检必须与引擎的 create_task **同规**:captain 不能在这里被"通过",
|
|
101
|
-
// 否则预检说 ok、真建任务却被拒 —— 而预检的全部意义就是提前发现这种岔路。
|
|
102
|
-
try {
|
|
103
|
-
assignee = normalizeAssigneeForCreate(rawAssignee) ?? rawAssignee;
|
|
104
|
-
} catch (error) {
|
|
105
|
-
local.push(error instanceof Error ? error.message : String(error));
|
|
106
|
-
}
|
|
107
|
-
if (local.length === 0 && assignee !== undefined && !memberNames.has(assignee)) {
|
|
108
|
-
local.push(`assignee「${assignee}」不是当前成员(用 t_team_status 看成员名,或先 add_member)`);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// 引擎的契约/写域/质量门槛校验
|
|
113
|
-
let contract = "";
|
|
114
|
-
if (local.length === 0) {
|
|
115
|
-
const verdict = validateCreateTask(sim, {
|
|
116
|
-
...raw,
|
|
117
|
-
subject,
|
|
118
|
-
kind,
|
|
119
|
-
dependencies,
|
|
120
|
-
assignee,
|
|
121
|
-
});
|
|
122
|
-
if (verdict.ok !== true) {
|
|
123
|
-
contract = String(verdict.error ?? "校验未通过");
|
|
124
|
-
local.push(contract);
|
|
125
|
-
} else {
|
|
126
|
-
sim.tasks.push({ ...verdict.task, id: simulatedId, dependencies });
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
if (local.length > 0) problems.push({ index, id: wantId ?? simulatedId, subject, error: local.join(";") });
|
|
131
|
-
report.push({ index, id: wantId ?? simulatedId, simulatedId, subject, ok: local.length === 0, error: local.join(";") });
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
// 环/引用检查(含已存在的任务):**调用引擎导出实现**,不再本地复刻。
|
|
135
|
-
// 用 requireRunnable=false —— 预检允许"成员还没加",那是 approve 阶段才要求的。
|
|
136
|
-
problems.push(...collectStagedGraphProblems(sim, false).map((error) => ({ index: -1, id: "graph", subject: "", error })));
|
|
137
|
-
|
|
138
|
-
// 提示(不阻塞):成员同时只能有 1 个未完成任务(引擎在 claim/reassign 时才会拒)
|
|
139
|
-
const openStatuses = new Set(["pending", "claimed", "in_progress"]);
|
|
140
|
-
const busy = new Map();
|
|
141
|
-
for (const task of existing) {
|
|
142
|
-
if (task.assignee !== undefined && openStatuses.has(task.status)) busy.set(task.assignee, task.id);
|
|
143
|
-
}
|
|
144
|
-
for (const item of report) {
|
|
145
|
-
if (!item.ok) continue;
|
|
146
|
-
const assignee = proposed[item.index]?.assignee;
|
|
147
|
-
if (typeof assignee !== "string" || assignee === "" || assignee === CAPTAIN_KEY) continue;
|
|
148
|
-
const current = busy.get(assignee);
|
|
149
|
-
if (current !== undefined) {
|
|
150
|
-
warnings.push(`成员「${assignee}」已有未完成任务 ${current}(一个成员同时只能有 1 个未完成任务,新任务要等它结束)`);
|
|
151
|
-
}
|
|
152
|
-
busy.set(assignee, item.simulatedId);
|
|
153
|
-
}
|
|
154
|
-
if (report.length >= 3) {
|
|
155
|
-
const duplicated = report.map((item) => item.subject).filter((subject, index, all) => subject !== "" && all.indexOf(subject) !== index);
|
|
156
|
-
if (duplicated.length > 0) warnings.push(`有重复的 subject:${[...new Set(duplicated)].join("、")}(确认不是同一件事被建了两次)`);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
return {
|
|
160
|
-
ok: problems.length === 0,
|
|
161
|
-
problems,
|
|
162
|
-
warnings: [...new Set(warnings)],
|
|
163
|
-
order: topologicalOrder(sim.tasks).filter((id) => !existing.some((task) => task.id === id)),
|
|
164
|
-
existing: existing.map((task) => ({ id: task.id, status: task.status, kind: task.kind ?? "work", assignee: task.assignee ?? "" })),
|
|
165
|
-
};
|
|
166
|
-
}
|