dsh-plugin-t-expert 0.2.7 → 0.2.9
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 +20 -9
- package/THIRD-PARTY-NOTICES +16 -8
- package/data/experts/engineering/engineering-deepseek-harness-project-expert.md +256 -0
- package/data/source.json +2 -2
- package/data/t-team.config.json +16 -1
- package/data/team-profiles.py +80 -78
- package/data/teams.json +103 -47
- package/data/teams.resolved.json +17 -1
- package/data/zh/COVERAGE.json +15 -14
- package/data/zh/descriptions.json +2 -1
- package/data/zh/names.json +2 -1
- package/lib/catalog.js +134 -31
- package/lib/client.js +1 -1
- package/lib/command.js +4 -1
- package/lib/i18n.js +75 -11
- package/lib/index.js +167 -62
- package/lib/plan-check.js +14 -3
- package/lib/remote.js +82 -15
- package/lib/skill.js +102 -42
- package/lib/squads.js +51 -3
- package/lib/teams/assignee-contract.js +47 -0
- package/lib/teams/quality-gates.js +24 -1
- package/lib/teams/state.js +14 -2
- package/lib/teams/tools.js +9 -3
- package/package.json +10 -24
- package/skills/dsh-harness-languages/SKILL.md +175 -0
- package/skills/dsh-harness-project/SKILL.md +209 -0
- package/skills/t-expert-manager/SKILL.md +4 -3
- package/skills/t-expert-manager/references/ops-reference.md +2 -2
- package/vendor/third-party-licenses/README.md +1 -1
package/lib/remote.js
CHANGED
|
@@ -6,7 +6,18 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { Remote, RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
8
8
|
import { z } from "zod";
|
|
9
|
-
import { CATALOG_SERVICE, SETTINGS_NAMESPACE, SQUAD_SERVICE, TEAM_SERVICE } from "./index.js";
|
|
9
|
+
import { CATALOG_SERVICE, SETTINGS_NAMESPACE, SQUAD_SERVICE, TEAM_SERVICE, UNKNOWN_SLUG_ERROR_CODE } from "./index.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 领域码形态:`<domain>/<reason>`(如 `tTeam/squads-invalid`)。
|
|
13
|
+
*
|
|
14
|
+
* 只有通过它校验的 `error.code` 才允许当 wire code 透传 —— 系统 errno(`EACCES`…)不是领域码,
|
|
15
|
+
* 透传出去会让客户端按 code 分支时把「文件权限」误认成业务失败类型(D-17)。口径与宿主网关一致
|
|
16
|
+
* (`<domain>/<reason>`,见 docs/cookbook/adding-a-remote-api.md)。
|
|
17
|
+
*/
|
|
18
|
+
const DOMAIN_CODE_PATTERN = /^[a-zA-Z][a-zA-Z0-9]*\/[a-z0-9-]+$/u;
|
|
19
|
+
/** 与 `lib/index.js` 共用的「未知专家 slug」稳定码(不用显示文案做分类,见 D-3)。 */
|
|
20
|
+
const UNKNOWN_SLUG_CODE = UNKNOWN_SLUG_ERROR_CODE;
|
|
10
21
|
|
|
11
22
|
const expertSchema = z.object({
|
|
12
23
|
slug: z.string(),
|
|
@@ -349,16 +360,37 @@ class TTeamRemote extends TypertRemoteService {
|
|
|
349
360
|
this.ctx.typert.register(TYPERT);
|
|
350
361
|
}
|
|
351
362
|
|
|
363
|
+
/**
|
|
364
|
+
* host 侧服务缺失的统一领域错误。
|
|
365
|
+
*
|
|
366
|
+
* 三个访问器(catalog / squads / teams)共用它:**不要**裸抛 `Error` —— 裸 Error 过线会被
|
|
367
|
+
* 网关折成 `gateway/internal`,领域信息全丢;也**不要**把「host 没加载」塞进别的领域码
|
|
368
|
+
* (例如 `tTeam/missing-expert`),那会把「服务不可用」显示成「专家不存在」(D-4)。
|
|
369
|
+
* @param name - 服务名(写进 details,便于面板区分是哪一个)。
|
|
370
|
+
* @param hint - 面向用户/面板的原因说明。
|
|
371
|
+
* @returns 带稳定 code 的 RemoteError。
|
|
372
|
+
*/
|
|
373
|
+
hostUnavailable(name, hint) {
|
|
374
|
+
return new RemoteError("tTeam/host-unavailable", hint, { service: name });
|
|
375
|
+
}
|
|
376
|
+
|
|
352
377
|
/** 名册服务由 host 主插件通过 ctx.reflect.provide 提供。 */
|
|
353
378
|
catalog() {
|
|
354
379
|
const service = this.ctx.get(CATALOG_SERVICE);
|
|
355
|
-
if (service === undefined)
|
|
380
|
+
if (service === undefined) {
|
|
381
|
+
throw this.hostUnavailable(CATALOG_SERVICE, "T专家 名册服务不可用(host 插件未加载或已卸载)");
|
|
382
|
+
}
|
|
356
383
|
return service;
|
|
357
384
|
}
|
|
358
385
|
|
|
359
386
|
/** 动态名册:完整专家列表 + 已启用 slug + 修订号。 */
|
|
360
387
|
async getCatalog() {
|
|
361
|
-
|
|
388
|
+
try {
|
|
389
|
+
return await this.catalog().snapshot();
|
|
390
|
+
} catch (error) {
|
|
391
|
+
// 服务缺失已在 catalog() 里铸成 tTeam/host-unavailable;这里只兜业务失败。
|
|
392
|
+
throw this.businessError(error, "tTeam/catalog-unreadable");
|
|
393
|
+
}
|
|
362
394
|
}
|
|
363
395
|
|
|
364
396
|
/** 整体替换启用列表;过期修订号由 settings 服务拒绝。 */
|
|
@@ -369,27 +401,52 @@ class TTeamRemote extends TypertRemoteService {
|
|
|
369
401
|
const message = error instanceof Error ? error.message : String(error);
|
|
370
402
|
const code = typeof error === "object" && error !== null ? error.code : undefined;
|
|
371
403
|
// 裸 Error 过线会退化成 gateway/internal 并丢掉 code,所以业务错误统一转成 RemoteError。
|
|
372
|
-
|
|
404
|
+
//
|
|
405
|
+
// 冲突**只认稳定 code**,不再按显示文案兜底(复核 F-3)。依据:
|
|
406
|
+
// · 宿主 SettingsConflictError 自带 `code = "SETTINGS_CONFLICT"`,且 dsh-settings 的
|
|
407
|
+
// Service Definition 在 JSDoc 里承诺「namespace 移动后拒绝写入即抛 SettingsConflictError」
|
|
408
|
+
// —— 也就是说这个 code 是**契约的一部分**,不是实现细节;
|
|
409
|
+
// · 原先那条 `/changed since it was read|another window|其他窗口/` 与 D-3 被修掉的根因同类:
|
|
410
|
+
// 用随宿主版本/locale 变的文案做身份判断,宿主一改措辞冲突就会被误归成 settings-failed;
|
|
411
|
+
// · 「其他窗口」在本仓根本不存在于宿主侧,纯属臆测的变体。
|
|
412
|
+
// 因此无 code 的同类错误会落到下面的 settings-failed(可见且可诊断),而不是被文案猜成冲突。
|
|
413
|
+
if (code === "SETTINGS_CONFLICT") {
|
|
373
414
|
throw new RemoteError("tTeam/conflict", message, { expectedRevision: String(expectedRevision) });
|
|
374
415
|
}
|
|
375
|
-
|
|
416
|
+
// 未知专家 slug 由 host 侧抛带稳定 code 的错误(lib/index.js 的 customError)。
|
|
417
|
+
// **不再**用 message.includes("未知专家") 分类 —— 显示文案随 locale 变,用它做身份会让
|
|
418
|
+
// 非中文 locale(或任何文案改写)下的错误直接退化成裸 Error,客户端只剩 gateway/internal(D-3)。
|
|
419
|
+
if (code === UNKNOWN_SLUG_CODE) {
|
|
376
420
|
throw new RemoteError("tTeam/unknown-expert", message, {});
|
|
377
421
|
}
|
|
378
|
-
throw error;
|
|
422
|
+
throw this.businessError(error, "tTeam/settings-failed");
|
|
379
423
|
}
|
|
380
424
|
}
|
|
381
425
|
|
|
382
426
|
/** 小队定义服务(设置页「小队」标签)。 */
|
|
383
427
|
squads() {
|
|
384
428
|
const service = this.ctx.get(SQUAD_SERVICE);
|
|
385
|
-
if (service === undefined)
|
|
429
|
+
if (service === undefined) {
|
|
430
|
+
throw this.hostUnavailable(SQUAD_SERVICE, "T专家 小队服务不可用(host 插件未加载)");
|
|
431
|
+
}
|
|
386
432
|
return service;
|
|
387
433
|
}
|
|
388
434
|
|
|
389
|
-
/**
|
|
435
|
+
/**
|
|
436
|
+
* 团队运行服务(设置页「团队」标签)。
|
|
437
|
+
*
|
|
438
|
+
* 这一处**刻意偏离**「缺服务一律 tTeam/host-unavailable」的统一口径,原因有两条,都记录在此:
|
|
439
|
+
* ① 语义上仍准确 —— 对 `startSquad` 来说「host 没挂团队服务」就是「这支小队起不来」,
|
|
440
|
+
* `tTeam/start-failed` 是可读的,而 `host-unavailable` 会丢掉「用户点的是启动」这层上下文;
|
|
441
|
+
* ② 向后兼容 —— 既有自检(verify.mjs 的 "startSquad 未知小队 → tTeam/start-failed")与客户端都把
|
|
442
|
+
* 这个码当作「启动失败」的分类键,改成新码会让唯一被断言的分类断裂。
|
|
443
|
+
* 缺的是 `catalog`/`squads` 那两个访问器(没有等价的历史承诺),它们统一走 hostUnavailable()。
|
|
444
|
+
*/
|
|
390
445
|
teams() {
|
|
391
446
|
const service = this.ctx.get(TEAM_SERVICE);
|
|
392
|
-
if (service === undefined)
|
|
447
|
+
if (service === undefined) {
|
|
448
|
+
throw new RemoteError("tTeam/start-failed", "T专家 团队服务不可用(host 插件未加载)", { service: TEAM_SERVICE });
|
|
449
|
+
}
|
|
393
450
|
return service;
|
|
394
451
|
}
|
|
395
452
|
|
|
@@ -439,21 +496,31 @@ class TTeamRemote extends TypertRemoteService {
|
|
|
439
496
|
}
|
|
440
497
|
}
|
|
441
498
|
|
|
442
|
-
/**
|
|
499
|
+
/**
|
|
500
|
+
* 业务错误统一转 RemoteError(裸 Error 过线会退化成 gateway/internal 并丢掉 code)。
|
|
501
|
+
*
|
|
502
|
+
* `error.code` 只有**形如 `<domain>/<reason>`** 才能当 wire code 用:系统 errno
|
|
503
|
+
* (`EACCES`/`ENOENT`/`EPERM`…)过线后被客户端按 code 分支时会误当领域码(D-17),
|
|
504
|
+
* 所以形态不符一律回落 `fallbackCode`。
|
|
505
|
+
* @param error - 被捕获的原错误。
|
|
506
|
+
* @param fallbackCode - 该访问器的领域码。
|
|
507
|
+
*/
|
|
443
508
|
businessError(error, fallbackCode) {
|
|
444
509
|
if (error instanceof RemoteError) return error;
|
|
445
510
|
const message = error instanceof Error ? error.message : String(error);
|
|
446
|
-
const
|
|
447
|
-
return new RemoteError(
|
|
511
|
+
const candidate = typeof error === "object" && error !== null && typeof error.code === "string" ? error.code : undefined;
|
|
512
|
+
return new RemoteError(DOMAIN_CODE_PATTERN.test(candidate ?? "") ? candidate : fallbackCode, message, {});
|
|
448
513
|
}
|
|
449
514
|
|
|
450
515
|
/** 按需读取一位专家的 persona 正文(面板预览用)。 */
|
|
451
516
|
async getPrompt(slug, division) {
|
|
517
|
+
// 先判服务:服务缺失要说「host 不可用」,不能说「专家不存在」——客户端把后者当
|
|
518
|
+
// 「这位专家不在名册里」,用户于是去一个根本没加载的名册里找人(D-4)。
|
|
519
|
+
const service = this.catalog();
|
|
452
520
|
try {
|
|
453
|
-
return await
|
|
521
|
+
return await service.prompt(slug, division);
|
|
454
522
|
} catch (error) {
|
|
455
|
-
|
|
456
|
-
throw new RemoteError("tTeam/missing-expert", message, { slug });
|
|
523
|
+
throw this.businessError(error, "tTeam/missing-expert");
|
|
457
524
|
}
|
|
458
525
|
}
|
|
459
526
|
|
package/lib/skill.js
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 随包发布的 skill:把「T专家 运维」与「DeepSeek Harness 项目知识」做成**随 npm 包分发**的 skill。
|
|
3
3
|
*
|
|
4
|
-
* 为什么是 skill 而不是再加一段 systemPrompt
|
|
5
|
-
*
|
|
4
|
+
* 为什么是 skill 而不是再加一段 systemPrompt:这些内容只在特定任务里需要(改名册/小队/装机/发布,
|
|
5
|
+
* 或在一个 deepseek-harness 检出里读写代码),常驻提示段是纯浪费 token;skill 的 description 本来就是路由面。
|
|
6
6
|
*
|
|
7
|
-
* 形态:`<包根>/skills
|
|
8
|
-
*
|
|
7
|
+
* 形态:`<包根>/skills/<name>/SKILL.md` 是标准 skill 文件(任何 skill 扫描器都能直接发现),
|
|
8
|
+
* 这里额外把它们注册进宿主的 skill 注册表,这样 npm 安装(插件在 node_modules 里、没有运维台)也能用。
|
|
9
|
+
*
|
|
10
|
+
* 三个 skill 的来源:
|
|
11
|
+
* - `t-expert-manager`:本仓运维入口,正文带占位符,注册时按本机布局渲染。
|
|
12
|
+
* - `dsh-harness-project` / `dsh-harness-languages`:`dsh-project-expert` agent preset 里那两个
|
|
13
|
+
* 项目知识 skill 的**逐字节拷贝**(preset 是源,这里是随插件发布的副本),
|
|
14
|
+
* 所以插件用户在任何仓库里都能加载这套 harness 项目知识。
|
|
9
15
|
*
|
|
10
16
|
* 两条刻意的设计:
|
|
11
17
|
* 1. **可选依赖**:走 `ctx.inject(["skills"], …)`,而不是把 `skills` 加进插件的静态 `inject`。
|
|
@@ -13,16 +19,25 @@
|
|
|
13
19
|
* 2. **路径不写死在正文里**:注册时解出运维台根目录(`tz.sh` 所在目录)再替换 `{{TZ}}` / `{{OPS_ROOT}}`,
|
|
14
20
|
* 所以同一份 skill 在开发机和别的安装布局下都不会说谎;解不出来就明说"没找到"。
|
|
15
21
|
*
|
|
16
|
-
* @module
|
|
22
|
+
* @module bundled skills
|
|
17
23
|
*/
|
|
18
24
|
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
19
25
|
import { dirname, join, resolve } from "node:path";
|
|
20
26
|
import { fileURLToPath } from "node:url";
|
|
21
27
|
|
|
22
|
-
/**
|
|
23
|
-
export const
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
/** 包内 skills 根目录:`<包根>/skills`。 */
|
|
29
|
+
export const SKILLS_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "skills");
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 随包发布的 skill 清单(目录名 = 宿主路由用的 skill 名)。
|
|
33
|
+
*
|
|
34
|
+
* 顺序即注册顺序。缺文件的条目会被跳过并逐条告警,而不是让整段注册失败 ——
|
|
35
|
+
* 一个 skill 文件缺失不该带走另外两个。
|
|
36
|
+
*/
|
|
37
|
+
export const BUNDLED_SKILL_NAMES = ["t-expert-manager", "dsh-harness-project", "dsh-harness-languages"];
|
|
38
|
+
|
|
39
|
+
/** 只有运维 skill 的正文带 `{{TZ}}` / `{{OPS_ROOT}}` 占位符,其余按原样注册。 */
|
|
40
|
+
const PLACEHOLDER_SKILL = "t-expert-manager";
|
|
26
41
|
|
|
27
42
|
/** 运维台根目录不存在时的正文占位说明(宁可说不知道,也不要指向一个不存在的脚本)。 */
|
|
28
43
|
const NO_OPS_ROOT = "(未在本机找到 tz.sh:请用 T_TEAM_REPO 指定插件仓库,或从源码目录运行运维台。)";
|
|
@@ -38,7 +53,7 @@ function readText(path) {
|
|
|
38
53
|
/**
|
|
39
54
|
* 取 SKILL.md frontmatter 里的一个单行标量字段。
|
|
40
55
|
*
|
|
41
|
-
*
|
|
56
|
+
* 只认单行,所以随包 skill 的 `description` 必须写在一行里 —— 刻意不引 YAML 依赖,
|
|
42
57
|
* frontmatter 里就只有 name/description 两个键。
|
|
43
58
|
* @param text - 完整 SKILL.md 文本。
|
|
44
59
|
* @param key - 字段名。
|
|
@@ -102,48 +117,93 @@ export function renderOpsSkill({ template, opsRoot }) {
|
|
|
102
117
|
}
|
|
103
118
|
|
|
104
119
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
120
|
+
* 组织出一个随包 skill,并带上失败原因(供调用方逐条告警)。
|
|
121
|
+
*
|
|
122
|
+
* 与 {@link buildBundledSkill} 的区别只在「失败时说什么」:这里保留原因,那里只看有没有。
|
|
123
|
+
* @param name - skill 名(= `<包根>/skills/<name>/SKILL.md` 的目录名)。
|
|
124
|
+
* @param config - 插件配置;只有运维 skill 用到(由 `root` 推数据目录)。
|
|
125
|
+
* @returns `{ skill }` 或 `{ reason }`;`reason` 是给人/日志看的一句话。
|
|
108
126
|
*/
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
const
|
|
127
|
+
function loadBundledSkill(name, config = {}) {
|
|
128
|
+
const directory = join(SKILLS_ROOT, name);
|
|
129
|
+
const path = join(directory, "SKILL.md");
|
|
130
|
+
const text = readText(path);
|
|
131
|
+
if (text === "") return { reason: `读不到 ${path}` };
|
|
132
|
+
const skillName = frontmatterField(text, "name") || name;
|
|
113
133
|
const description = frontmatterField(text, "description");
|
|
114
|
-
if (description === "")
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
134
|
+
if (description === "") {
|
|
135
|
+
return { reason: `${path} 的 frontmatter 缺 description(没有它就没法作为 skill 的路由面)` };
|
|
136
|
+
}
|
|
137
|
+
let body = text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/u, "").trim();
|
|
138
|
+
if (name === PLACEHOLDER_SKILL) {
|
|
139
|
+
const dataDir = typeof config.root === "string" && config.root !== "" ? dirname(config.root) : undefined;
|
|
140
|
+
// `resolveOpsRoot` 取的是传入目录的父目录,所以这里传**包根**(skills/ 的上一层)。
|
|
141
|
+
body = renderOpsSkill({ template: body, opsRoot: resolveOpsRoot({ dataDir, packageDir: resolve(SKILLS_ROOT, "..") }) });
|
|
142
|
+
}
|
|
118
143
|
return {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
144
|
+
skill: {
|
|
145
|
+
name: skillName,
|
|
146
|
+
description,
|
|
147
|
+
content: body,
|
|
148
|
+
// `source` 必须自己给:宿主只给 invocation / provider 兜默认值,**不兜 source**,
|
|
149
|
+
// 而加载路径(skills.get)会校验 source 必须是字符串 —— 少了它,skill 在目录里
|
|
150
|
+
// 显示正常、一加载就抛 `source must be a string`。
|
|
151
|
+
source: "runtime",
|
|
152
|
+
// 让模型能从资源基准目录读到同目录下的附属文件(如运维 skill 的 references/)。
|
|
153
|
+
resourceBase: { kind: "directory", path: directory },
|
|
154
|
+
},
|
|
128
155
|
};
|
|
129
156
|
}
|
|
130
157
|
|
|
131
158
|
/**
|
|
132
|
-
*
|
|
159
|
+
* 逐个构建随包 skill,并**记下**哪些条目没建成以及为什么。
|
|
160
|
+
*
|
|
161
|
+
* 注册方靠 `missing` 逐条告警 —— 缺一个文件不该静默、也不该带走另外两个。
|
|
162
|
+
* @param config - 插件配置。
|
|
163
|
+
* @returns `{ skills, missing }`;顺序与 {@link BUNDLED_SKILL_NAMES} 一致。
|
|
164
|
+
*/
|
|
165
|
+
function collectBundledSkills(config = {}) {
|
|
166
|
+
const skills = [];
|
|
167
|
+
const missing = [];
|
|
168
|
+
for (const name of BUNDLED_SKILL_NAMES) {
|
|
169
|
+
const loaded = loadBundledSkill(name, config);
|
|
170
|
+
if (loaded.skill === undefined) missing.push({ name, reason: loaded.reason });
|
|
171
|
+
else skills.push(loaded.skill);
|
|
172
|
+
}
|
|
173
|
+
return { skills, missing };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 组织出全部随包 skill(失败条目不进数组,见 {@link collectBundledSkills} 拿原因)。
|
|
178
|
+
* @param config - 插件配置。
|
|
179
|
+
* @returns 定义数组;缺文件的条目被跳过(顺序与 {@link BUNDLED_SKILL_NAMES} 一致)。
|
|
180
|
+
*/
|
|
181
|
+
export function buildBundledSkills(config = {}) {
|
|
182
|
+
return collectBundledSkills(config).skills;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 把随包 skill 注册进宿主。`skills` 服务缺席时什么都不做(可选依赖,见文件头注释)。
|
|
187
|
+
*
|
|
188
|
+
* 缺失条目**逐条 warn**(一个 skill 文件缺失不该带走另外两个,但也不能悄悄消失);
|
|
189
|
+
* 全军覆没时额外再给一条汇总 warn,保持既有的「都读不到」信号。
|
|
133
190
|
* @param ctx - 插件上下文。
|
|
134
191
|
* @param config - 插件配置。
|
|
135
|
-
* @returns
|
|
192
|
+
* @returns 排入注册的 skill 数;`0` 表示宿主没有 skill 注册表,或包内一个 skill 文件都读不到。
|
|
136
193
|
*/
|
|
137
|
-
export function
|
|
138
|
-
if (typeof ctx?.inject !== "function") return
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
ctx.logger?.warn?.(`[t-team]
|
|
142
|
-
|
|
194
|
+
export function installBundledSkills(ctx, config) {
|
|
195
|
+
if (typeof ctx?.inject !== "function") return 0;
|
|
196
|
+
const { skills, missing } = collectBundledSkills(config);
|
|
197
|
+
for (const item of missing) {
|
|
198
|
+
ctx.logger?.warn?.(`[t-team] 随包 skill「${item.name}」未注册:${item.reason}(其余 skill 照常注册)`);
|
|
199
|
+
}
|
|
200
|
+
if (skills.length === 0) {
|
|
201
|
+
ctx.logger?.warn?.(`[t-team] 随包 skill 未注册:读不到 ${join(SKILLS_ROOT, "<name>", "SKILL.md")}`);
|
|
202
|
+
return 0;
|
|
143
203
|
}
|
|
144
204
|
ctx.inject(["skills"], (scoped) => {
|
|
145
|
-
scoped.effect(() => scoped.skills.register(skill));
|
|
146
|
-
ctx.logger?.info?.(`[t-team]
|
|
205
|
+
for (const skill of skills) scoped.effect(() => scoped.skills.register(skill));
|
|
206
|
+
ctx.logger?.info?.(`[t-team] 已注册随包 skill:${skills.map((skill) => skill.name).join("、")}`);
|
|
147
207
|
});
|
|
148
|
-
return
|
|
208
|
+
return skills.length;
|
|
149
209
|
}
|
package/lib/squads.js
CHANGED
|
@@ -35,16 +35,35 @@ function memberSlug(member) {
|
|
|
35
35
|
return "";
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* 编译器的成员上限默认值(与 `data/team-profiles.py` 的 `MAX_MEMBERS` 同一口径)。
|
|
40
|
+
*
|
|
41
|
+
* 它只是**兼容旧编译器的回退默认**,不是第二个真源:调用方通过 `maxMembers` 把
|
|
42
|
+
* `Config.maxMembers` 传进来,编译器支持 `--max-members` 时以传入值为准(见 `build()`)。
|
|
43
|
+
*/
|
|
44
|
+
export const COMPILER_DEFAULT_MAX_MEMBERS = 8;
|
|
45
|
+
|
|
38
46
|
/**
|
|
39
47
|
* @param generator - 小队编译器路径。调用方**优先传包内那份**(`<包>/data/team-profiles.py`):
|
|
40
48
|
* 它是随插件版本走的**代码**,而数据目录里那份只在首次启动时播种一次(非破坏性),
|
|
41
49
|
* 升级时不会更新 —— 一直用数据目录那份,等于这个版本的编译器修复永远送不到老用户手里。
|
|
50
|
+
* @param maxMembers - 成员上限(来自 `Config.maxMembers`)。只有编译器声明支持 `--max-members`
|
|
51
|
+
* 时才会真正生效;旧编译器把上限写死在源码里,此时会**记一条 warn** 并退回它的默认值,
|
|
52
|
+
* 而不是让保存小队在「改大上限」后必然编译失败并回滚(D-14)。
|
|
42
53
|
*/
|
|
43
|
-
export function createSquadService({ teamsFile, catalog, python = "python3", generator: generatorPath }) {
|
|
54
|
+
export function createSquadService({ teamsFile, catalog, python = "python3", generator: generatorPath, maxMembers }) {
|
|
44
55
|
const dataDir = dirname(teamsFile);
|
|
45
56
|
const generator = typeof generatorPath === "string" && generatorPath !== ""
|
|
46
57
|
? generatorPath
|
|
47
58
|
: join(dataDir, "team-profiles.py");
|
|
59
|
+
const memberLimit = typeof maxMembers === "number" && Number.isSafeInteger(maxMembers) && maxMembers > 0
|
|
60
|
+
? maxMembers
|
|
61
|
+
: COMPILER_DEFAULT_MAX_MEMBERS;
|
|
62
|
+
// 编译器能力探测**只做一次**(读它自己的源码找 flag):比「先试传、失败再重跑」便宜,
|
|
63
|
+
// 也不会在 argparse 报错时留下半跑过的生成物。
|
|
64
|
+
const generatorSupportsMemberLimit = existsSync(generator)
|
|
65
|
+
? readFileSync(generator, "utf8").includes("--max-members")
|
|
66
|
+
: false;
|
|
48
67
|
let lastBuild = { at: 0, ok: true, output: "" };
|
|
49
68
|
|
|
50
69
|
function readSpec() {
|
|
@@ -136,6 +155,9 @@ export function createSquadService({ teamsFile, catalog, python = "python3", gen
|
|
|
136
155
|
};
|
|
137
156
|
}
|
|
138
157
|
|
|
158
|
+
/** 编译器**实际**会执行的上限:旧编译器不认 --max-members,用它写死的默认值。 */
|
|
159
|
+
const effectiveMemberLimit = () => (generatorSupportsMemberLimit ? memberLimit : COMPILER_DEFAULT_MAX_MEMBERS);
|
|
160
|
+
|
|
139
161
|
/** 校验(生成器之外的第一道闸门,错误信息面向面板)。 */
|
|
140
162
|
function validate(squads) {
|
|
141
163
|
if (!Array.isArray(squads) || squads.length === 0) {
|
|
@@ -153,6 +175,16 @@ export function createSquadService({ teamsFile, catalog, python = "python3", gen
|
|
|
153
175
|
seenKeys.add(key);
|
|
154
176
|
const members = Array.isArray(squad?.members) ? squad.members.map(memberSlug).filter((slug) => slug !== "") : [];
|
|
155
177
|
if (members.length === 0) throw fail("tTeam/squads-invalid", `「${key}」没有成员:至少要选一位专家。`);
|
|
178
|
+
// 上限只有一个真源,但旧编译器**执行不了** Config 的值:直接挡在保存前,并把原因说清,
|
|
179
|
+
// 而不是把 10 人小队写下去、让编译器报「超过上限 8」再回滚(D-14 的可见形态)。
|
|
180
|
+
const limit = effectiveMemberLimit();
|
|
181
|
+
if (members.length > limit) {
|
|
182
|
+
throw fail("tTeam/squads-invalid",
|
|
183
|
+
`「${key}」有 ${members.length} 位成员,超过当前生效上限 ${limit}。`
|
|
184
|
+
+ (generatorSupportsMemberLimit
|
|
185
|
+
? "(上限来自 Config.maxMembers)"
|
|
186
|
+
: `该编译器版本不支持 --max-members,只能按它内置的 ${COMPILER_DEFAULT_MAX_MEMBERS} 执行;请更新 data/team-profiles.py 后再调大 Config.maxMembers。`));
|
|
187
|
+
}
|
|
156
188
|
for (const alias of Array.isArray(squad?.aliases) ? squad.aliases : []) {
|
|
157
189
|
const value = text(alias).trim();
|
|
158
190
|
if (value === "") continue;
|
|
@@ -198,7 +230,18 @@ export function createSquadService({ teamsFile, catalog, python = "python3", gen
|
|
|
198
230
|
return lastBuild;
|
|
199
231
|
}
|
|
200
232
|
try {
|
|
201
|
-
|
|
233
|
+
// 上限随 Config 走;旧编译器(无 --max-members)退回它写死的默认值并明确告警。
|
|
234
|
+
if (memberLimit !== COMPILER_DEFAULT_MAX_MEMBERS && !generatorSupportsMemberLimit) {
|
|
235
|
+
lastBuild = {
|
|
236
|
+
at: Date.now(),
|
|
237
|
+
ok: true,
|
|
238
|
+
output: `[warn] 小队编译器不支持 --max-members(${generator}),成员上限仍按它内置的 ${COMPILER_DEFAULT_MAX_MEMBERS} 执行:`
|
|
239
|
+
+ `超过 ${COMPILER_DEFAULT_MAX_MEMBERS} 人的小队会编译失败,Config.maxMembers=${memberLimit} 不会生效。`
|
|
240
|
+
+ `要真正用上它,请把 data/team-profiles.py 更新到带 --max-members 的版本。`,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const memberArgs = generatorSupportsMemberLimit ? ["--max-members", String(memberLimit)] : [];
|
|
244
|
+
const output = execFileSync(python, [generator, "--teams", teamsFile, ...memberArgs], {
|
|
202
245
|
cwd: dataDir,
|
|
203
246
|
encoding: "utf8",
|
|
204
247
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -234,5 +277,10 @@ export function createSquadService({ teamsFile, catalog, python = "python3", gen
|
|
|
234
277
|
return await list();
|
|
235
278
|
}
|
|
236
279
|
|
|
237
|
-
return {
|
|
280
|
+
return {
|
|
281
|
+
list, save, validate, revision: revisionOf, generator, dataDir,
|
|
282
|
+
// 让调用方(与自检)看得到「这次编译用的是哪个上限、编译器到底认不认这个参数」。
|
|
283
|
+
memberLimit,
|
|
284
|
+
generatorSupportsMemberLimit,
|
|
285
|
+
};
|
|
238
286
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
}
|
|
@@ -97,14 +97,37 @@ export function pathMatchesScope(path, pattern) {
|
|
|
97
97
|
const rawPattern = pattern.trim().replaceAll('\\', '/');
|
|
98
98
|
if (rawPattern.startsWith('~') || rawPattern.startsWith('/') || /^[A-Za-z]:/.test(rawPattern))
|
|
99
99
|
return false;
|
|
100
|
+
// 审计 N-20:旧实现只在模式**以 `/` 结尾**时才当目录,于是 `inScope: ["src"]` 对 `src/a.ts`
|
|
101
|
+
// 返回 false —— 写域等于没声明,真正该拦的写冲突会被放过。这里补齐两种常见写法:
|
|
102
|
+
// · `src` —— 不含扩展名的裸目录名,按目录前缀处理(含 `.` 的仍按文件精确匹配,保住 `src/a.ts` 语义)
|
|
103
|
+
// · `src/*` / `src/**` —— 单层 / 任意层通配
|
|
100
104
|
const directory = rawPattern.endsWith('/');
|
|
105
|
+
const wildcard = rawPattern.includes('*');
|
|
106
|
+
const bareDirectory = !directory && !wildcard && !rawPattern.includes('.') && rawPattern !== '.' && rawPattern !== '';
|
|
107
|
+
if (wildcard) {
|
|
108
|
+
const normalizedWildcardPattern = normalizeWorkspacePath(rawPattern);
|
|
109
|
+
if (normalizedWildcardPattern === undefined)
|
|
110
|
+
return false;
|
|
111
|
+
// 逐段翻译:`*` = 单层,`**` = 任意层;其余段按字面量并转义正则元字符。
|
|
112
|
+
const escaped = normalizedWildcardPattern
|
|
113
|
+
.split('/')
|
|
114
|
+
.map((segment) => {
|
|
115
|
+
if (segment === '**')
|
|
116
|
+
return '.*';
|
|
117
|
+
if (segment === '*')
|
|
118
|
+
return '[^/]+';
|
|
119
|
+
return segment.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
120
|
+
})
|
|
121
|
+
.join('/');
|
|
122
|
+
return new RegExp(`^${escaped}$`, 'u').test(normalizedPath);
|
|
123
|
+
}
|
|
101
124
|
const normalizedPattern = normalizeWorkspacePath(rawPattern);
|
|
102
125
|
if (normalizedPattern === undefined) {
|
|
103
126
|
if (directory && (rawPattern === './' || rawPattern === '/' || rawPattern === '.'))
|
|
104
127
|
return true;
|
|
105
128
|
return false;
|
|
106
129
|
}
|
|
107
|
-
if (directory || rawPattern === './' || rawPattern === '.') {
|
|
130
|
+
if (directory || bareDirectory || rawPattern === './' || rawPattern === '.') {
|
|
108
131
|
if (normalizedPattern === '')
|
|
109
132
|
return true;
|
|
110
133
|
return normalizedPath === normalizedPattern || normalizedPath.startsWith(`${normalizedPattern}/`);
|
package/lib/teams/state.js
CHANGED
|
@@ -235,14 +235,26 @@ export function readTeamSync(stateRoot, teamId) {
|
|
|
235
235
|
}
|
|
236
236
|
/**
|
|
237
237
|
* Persist one team record (inside the caller's lock).
|
|
238
|
+
*
|
|
239
|
+
* **落盘前必须先过读侧同一套判据(审计 N-18)**:`readTeam` 会 `coerceTeamState`,不合法就抛
|
|
240
|
+
* `invalid T Team state in team "<id>"`。曾经的缺陷是 `writeTeam` **完全不校验**,于是状态可以被写成
|
|
241
|
+
* 引擎自己拒绝读的形态(例如 provider 每次 spawn 返回同一个 childId → 成员 id 重复),
|
|
242
|
+
* 之后**任何**团队工具都会永久抛错、整支团队报废,而错误串里没有任何可定位字段。
|
|
243
|
+
* 写侧与读侧用同一判据,两者就无法再分叉:能写下去的,一定读得回来。
|
|
238
244
|
* @param stateRoot - resolved absolute state root directory.
|
|
239
245
|
* @param state - the record to persist.
|
|
240
246
|
*/
|
|
241
247
|
export async function writeTeam(stateRoot, state) {
|
|
248
|
+
const teamId = isRecord(state) ? state['id'] : undefined;
|
|
249
|
+
if (coerceTeamState(JSON.parse(JSON.stringify(state)), teamId) === undefined) {
|
|
250
|
+
throw new Error(`refusing to persist invalid T Team state for team "${String(teamId ?? '(缺少 id)')}":`
|
|
251
|
+
+ '写入前校验未通过(与 readTeam 同一判据)。常见原因:成员 id 重复、成员缺 joinedAt、'
|
|
252
|
+
+ 'status 不在 idle/working/removed 之内、任务缺 createdAt/updatedAt/dependencies。');
|
|
253
|
+
}
|
|
254
|
+
await mkdir(join(stateRoot, state.id), { recursive: true });
|
|
242
255
|
await atomicWriteText(join(stateRoot, state.id, 'team.json'), JSON.stringify(state, null, 2));
|
|
243
256
|
}
|
|
244
|
-
/** Read the durable set of member session ids retired by remove/delete. */
|
|
245
|
-
function parseRetiredMemberIds(raw) {
|
|
257
|
+
/** Read the durable set of member session ids retired by remove/delete. */function parseRetiredMemberIds(raw) {
|
|
246
258
|
const parsed = JSON.parse(stripLeadingBom(raw));
|
|
247
259
|
if (!Array.isArray(parsed) || parsed.some(value => typeof value !== 'string' || value === '')) {
|
|
248
260
|
throw new Error('invalid T Team retired member index');
|
package/lib/teams/tools.js
CHANGED
|
@@ -15,6 +15,7 @@ import { appendTeamEvent, captainSessionOf } from "./events.js";
|
|
|
15
15
|
import { acknowledgeMailbox, appendMailbox, archiveTeamDir, beginTaskAttempt, CAPTAIN_KEY, createMessage, createTeamDir, findTeamByCaptain, findTeamByParticipant, cancelUnfinishedTask, invalidateTaskAttempt, readUnreadMailbox, recordRetiredMemberIds, releaseMailboxDelivery, readTeam, sanitizeKey, transitionError, unsatisfiedDependencies, withTeamLock, writeTeam, removeTeamDir, validateCreateTask, evaluateQualityCompletion, planQualityFollowUp, resumeTeamState, buildCoverageMatrix, canDeclareDelivery, describeQualityLoop, sanitizeReviewAcceptance, sanitizeReviewObjective, normalizeBlankOptionalTaskFields, taskKindOf, } from "./state.js";
|
|
16
16
|
import { deliverToMember, installRetiredMemberGuard, installMemberSelectionRuntime, interruptMember, memberActivity, resolveMemberLlmSelection, spawnMember, steerCaptainReport, validateMemberLlmSelections, } from "./members.js";
|
|
17
17
|
import { TERMINAL_TASK_STATUSES } from "./types.js";
|
|
18
|
+
import { normalizeAssigneeForCreate } from "./assignee-contract.js";
|
|
18
19
|
import { installTeamScheduler } from "./scheduler.js";
|
|
19
20
|
import { resolveTeamProfile } from "./profiles.js";
|
|
20
21
|
export { steerCaptainReport } from "./members.js";
|
|
@@ -1034,7 +1035,7 @@ export function registerTTeamTools(ctx, config) {
|
|
|
1034
1035
|
}));
|
|
1035
1036
|
ctx.tools.register(defineTool({
|
|
1036
1037
|
name: 't_team_create_task',
|
|
1037
|
-
description: 'Create a task in your team\'s task list. Every call must include a non-empty subject, including verification and review tasks. Tasks can depend on other tasks (dependencies): a task is only claimable once every dependency is completed. Optionally assign it to a member, who still claims it before working.',
|
|
1038
|
+
description: 'Create a task in your team\'s task list. Every call must include a non-empty subject, including verification and review tasks. Tasks can depend on other tasks (dependencies): a task is only claimable once every dependency is completed. Optionally assign it to a member, who still claims it before working. Omit assignee to put the task in the shared pool; the captain cannot be set here — take a task over with t_team_reassign_task(assignee="captain").',
|
|
1038
1039
|
parameters: {
|
|
1039
1040
|
subject: { type: 'string', required: true, description: 'Required non-empty title for this task. Never omit it, including for verification or review tasks.' },
|
|
1040
1041
|
description: { type: 'string', description: 'What needs to be done, in detail.' },
|
|
@@ -1043,7 +1044,7 @@ export function registerTTeamTools(ctx, config) {
|
|
|
1043
1044
|
items: { type: 'string' },
|
|
1044
1045
|
description: 'Task ids this task depends on (must be completed before this task can be claimed).',
|
|
1045
1046
|
},
|
|
1046
|
-
assignee: { type: 'string', description: 'Optional member name this task is intended for.' },
|
|
1047
|
+
assignee: { type: 'string', description: 'Optional active member name this task is intended for. Omit for the shared pool. Never "captain": create the task unassigned, then take it over with t_team_reassign_task(assignee="captain").' },
|
|
1047
1048
|
kind: {
|
|
1048
1049
|
type: 'string',
|
|
1049
1050
|
enum: ['work', 'requirements', 'implementation', 'verification', 'review', 'repair', 'integration'],
|
|
@@ -1133,8 +1134,13 @@ export function registerTTeamTools(ctx, config) {
|
|
|
1133
1134
|
throw new Error(`dependency "${dependency}" does not exist in team "${fresh.name}"`);
|
|
1134
1135
|
}
|
|
1135
1136
|
}
|
|
1136
|
-
if (args.assignee !== undefined)
|
|
1137
|
+
if (args.assignee !== undefined) {
|
|
1138
|
+
// 预留键 `captain` 必须在这里被拦下并**指向 reassign_task**:
|
|
1139
|
+
// 直接丢给 requireMember 只会回一句 no active member named "captain",
|
|
1140
|
+
// 那是按成员名精确匹配的报错,完全不提示正确入口(曾经的实际缺陷)。
|
|
1141
|
+
normalizeAssigneeForCreate(args.assignee);
|
|
1137
1142
|
requireMember(fresh, args.assignee);
|
|
1143
|
+
}
|
|
1138
1144
|
const kind = gate.kind ?? 'work';
|
|
1139
1145
|
const objective = kind === 'review' || kind === 'requirements'
|
|
1140
1146
|
? sanitizeReviewObjective(input.objective)
|