dsh-plugin-t-expert 0.2.7 → 0.2.10
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 +67 -15
- 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/bootstrap.js +1 -0
- package/lib/catalog.js +155 -32
- package/lib/client.js +132 -128
- package/lib/command.js +22 -6
- package/lib/i18n.js +78 -11
- package/lib/index.js +492 -115
- package/lib/plan-check.js +50 -57
- package/lib/remote-schemas.js +156 -0
- package/lib/remote.js +111 -148
- package/lib/skill.js +107 -44
- package/lib/squads.js +195 -28
- package/lib/teams/assignee-contract.js +47 -0
- package/lib/teams/harness-compat.js +36 -0
- package/lib/teams/index.js +11 -3
- package/lib/teams/members.js +6 -3
- package/lib/teams/quality-gates.js +24 -1
- package/lib/teams/state.js +14 -2
- package/lib/teams/tools.js +44 -21
- package/package.json +18 -30
- 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 +15 -5
- package/skills/t-expert-manager/references/ops-reference.md +4 -4
- package/vendor/third-party-licenses/README.md +1 -1
package/lib/skill.js
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
+
* 随包发布的 skill:把「T专家 运维」与「DeepSeek Harness 项目知识」做成**随 npm 包分发**的 skill。
|
|
3
4
|
*
|
|
4
|
-
* 为什么是 skill 而不是再加一段 systemPrompt
|
|
5
|
-
*
|
|
5
|
+
* 为什么是 skill 而不是再加一段 systemPrompt:这些内容只在特定任务里需要(改名册/小队/装机/发布,
|
|
6
|
+
* 或在一个 deepseek-harness 检出里读写代码),常驻提示段是纯浪费 token;skill 的 description 本来就是路由面。
|
|
6
7
|
*
|
|
7
|
-
* 形态:`<包根>/skills
|
|
8
|
-
*
|
|
8
|
+
* 形态:`<包根>/skills/<name>/SKILL.md` 是标准 skill 文件(任何 skill 扫描器都能直接发现),
|
|
9
|
+
* 这里额外把它们注册进宿主的 skill 注册表,这样 npm 安装(插件在 node_modules 里、没有运维台)也能用。
|
|
10
|
+
*
|
|
11
|
+
* 三个 skill 的来源:
|
|
12
|
+
* - `t-expert-manager`:本仓运维入口,正文带占位符,注册时按本机布局渲染。
|
|
13
|
+
* - `dsh-harness-project` / `dsh-harness-languages`:`dsh-project-expert` agent preset 里那两个
|
|
14
|
+
* 项目知识 skill 的**逐字节拷贝**(preset 是源,这里是随插件发布的副本),
|
|
15
|
+
* 所以插件用户在任何仓库里都能加载这套 harness 项目知识。
|
|
9
16
|
*
|
|
10
17
|
* 两条刻意的设计:
|
|
11
18
|
* 1. **可选依赖**:走 `ctx.inject(["skills"], …)`,而不是把 `skills` 加进插件的静态 `inject`。
|
|
@@ -13,16 +20,25 @@
|
|
|
13
20
|
* 2. **路径不写死在正文里**:注册时解出运维台根目录(`tz.sh` 所在目录)再替换 `{{TZ}}` / `{{OPS_ROOT}}`,
|
|
14
21
|
* 所以同一份 skill 在开发机和别的安装布局下都不会说谎;解不出来就明说"没找到"。
|
|
15
22
|
*
|
|
16
|
-
* @module
|
|
23
|
+
* @module bundled skills
|
|
17
24
|
*/
|
|
18
25
|
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
19
26
|
import { dirname, join, resolve } from "node:path";
|
|
20
27
|
import { fileURLToPath } from "node:url";
|
|
21
28
|
|
|
22
|
-
/**
|
|
23
|
-
export const
|
|
24
|
-
|
|
25
|
-
|
|
29
|
+
/** 包内 skills 根目录:`<包根>/skills`。 */
|
|
30
|
+
export const SKILLS_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "skills");
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 随包发布的 skill 清单(目录名 = 宿主路由用的 skill 名)。
|
|
34
|
+
*
|
|
35
|
+
* 顺序即注册顺序。缺文件的条目会被跳过并逐条告警,而不是让整段注册失败 ——
|
|
36
|
+
* 一个 skill 文件缺失不该带走另外两个。
|
|
37
|
+
*/
|
|
38
|
+
export const BUNDLED_SKILL_NAMES = ["t-expert-manager", "dsh-harness-project", "dsh-harness-languages"];
|
|
39
|
+
|
|
40
|
+
/** 只有运维 skill 的正文带 `{{TZ}}` / `{{OPS_ROOT}}` 占位符,其余按原样注册。 */
|
|
41
|
+
const PLACEHOLDER_SKILL = "t-expert-manager";
|
|
26
42
|
|
|
27
43
|
/** 运维台根目录不存在时的正文占位说明(宁可说不知道,也不要指向一个不存在的脚本)。 */
|
|
28
44
|
const NO_OPS_ROOT = "(未在本机找到 tz.sh:请用 T_TEAM_REPO 指定插件仓库,或从源码目录运行运维台。)";
|
|
@@ -38,7 +54,7 @@ function readText(path) {
|
|
|
38
54
|
/**
|
|
39
55
|
* 取 SKILL.md frontmatter 里的一个单行标量字段。
|
|
40
56
|
*
|
|
41
|
-
*
|
|
57
|
+
* 只认单行,所以随包 skill 的 `description` 必须写在一行里 —— 刻意不引 YAML 依赖,
|
|
42
58
|
* frontmatter 里就只有 name/description 两个键。
|
|
43
59
|
* @param text - 完整 SKILL.md 文本。
|
|
44
60
|
* @param key - 字段名。
|
|
@@ -59,8 +75,10 @@ export function frontmatterField(text, key) {
|
|
|
59
75
|
*
|
|
60
76
|
* 候选按序:`T_TEAM_REPO` 的父目录 → 数据目录的父目录 → 包目录的父目录。
|
|
61
77
|
* 每个候选都要**真的存在 tz.sh** 才算数,否则返回空串(不猜)。
|
|
62
|
-
* @param options
|
|
63
|
-
*
|
|
78
|
+
* @param {object} [options] 调用选项
|
|
79
|
+
* @param {string} [options.dataDir] 数据目录(默认 `~/.t-team`,本机是指向 `<ops>/data` 的软链)
|
|
80
|
+
* @param {string} [options.packageDir] 本包根目录
|
|
81
|
+
* @param {Record<string, string | undefined>} [options.env] 环境变量(测试可注入)
|
|
64
82
|
* @returns 运维台根目录,或空串。
|
|
65
83
|
*/
|
|
66
84
|
export function resolveOpsRoot({ dataDir, packageDir, env = process.env } = {}) {
|
|
@@ -102,48 +120,93 @@ export function renderOpsSkill({ template, opsRoot }) {
|
|
|
102
120
|
}
|
|
103
121
|
|
|
104
122
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
123
|
+
* 组织出一个随包 skill,并带上失败原因(供调用方逐条告警)。
|
|
124
|
+
*
|
|
125
|
+
* 与 {@link buildBundledSkill} 的区别只在「失败时说什么」:这里保留原因,那里只看有没有。
|
|
126
|
+
* @param name - skill 名(= `<包根>/skills/<name>/SKILL.md` 的目录名)。
|
|
127
|
+
* @param config - 插件配置;只有运维 skill 用到(由 `root` 推数据目录)。
|
|
128
|
+
* @returns `{ skill }` 或 `{ reason }`;`reason` 是给人/日志看的一句话。
|
|
108
129
|
*/
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
const
|
|
130
|
+
function loadBundledSkill(name, config = {}) {
|
|
131
|
+
const directory = join(SKILLS_ROOT, name);
|
|
132
|
+
const path = join(directory, "SKILL.md");
|
|
133
|
+
const text = readText(path);
|
|
134
|
+
if (text === "") return { reason: `读不到 ${path}` };
|
|
135
|
+
const skillName = frontmatterField(text, "name") || name;
|
|
113
136
|
const description = frontmatterField(text, "description");
|
|
114
|
-
if (description === "")
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
137
|
+
if (description === "") {
|
|
138
|
+
return { reason: `${path} 的 frontmatter 缺 description(没有它就没法作为 skill 的路由面)` };
|
|
139
|
+
}
|
|
140
|
+
let body = text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/u, "").trim();
|
|
141
|
+
if (name === PLACEHOLDER_SKILL) {
|
|
142
|
+
const dataDir = typeof config.root === "string" && config.root !== "" ? dirname(config.root) : undefined;
|
|
143
|
+
// `resolveOpsRoot` 取的是传入目录的父目录,所以这里传**包根**(skills/ 的上一层)。
|
|
144
|
+
body = renderOpsSkill({ template: body, opsRoot: resolveOpsRoot({ dataDir, packageDir: resolve(SKILLS_ROOT, "..") }) });
|
|
145
|
+
}
|
|
118
146
|
return {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
147
|
+
skill: {
|
|
148
|
+
name: skillName,
|
|
149
|
+
description,
|
|
150
|
+
content: body,
|
|
151
|
+
// `source` 必须自己给:宿主只给 invocation / provider 兜默认值,**不兜 source**,
|
|
152
|
+
// 而加载路径(skills.get)会校验 source 必须是字符串 —— 少了它,skill 在目录里
|
|
153
|
+
// 显示正常、一加载就抛 `source must be a string`。
|
|
154
|
+
source: "runtime",
|
|
155
|
+
// 让模型能从资源基准目录读到同目录下的附属文件(如运维 skill 的 references/)。
|
|
156
|
+
resourceBase: { kind: "directory", path: directory },
|
|
157
|
+
},
|
|
128
158
|
};
|
|
129
159
|
}
|
|
130
160
|
|
|
131
161
|
/**
|
|
132
|
-
*
|
|
162
|
+
* 逐个构建随包 skill,并**记下**哪些条目没建成以及为什么。
|
|
163
|
+
*
|
|
164
|
+
* 注册方靠 `missing` 逐条告警 —— 缺一个文件不该静默、也不该带走另外两个。
|
|
165
|
+
* @param config - 插件配置。
|
|
166
|
+
* @returns `{ skills, missing }`;顺序与 {@link BUNDLED_SKILL_NAMES} 一致。
|
|
167
|
+
*/
|
|
168
|
+
function collectBundledSkills(config = {}) {
|
|
169
|
+
const skills = [];
|
|
170
|
+
const missing = [];
|
|
171
|
+
for (const name of BUNDLED_SKILL_NAMES) {
|
|
172
|
+
const loaded = loadBundledSkill(name, config);
|
|
173
|
+
if (loaded.skill === undefined) missing.push({ name, reason: loaded.reason });
|
|
174
|
+
else skills.push(loaded.skill);
|
|
175
|
+
}
|
|
176
|
+
return { skills, missing };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 组织出全部随包 skill(失败条目不进数组,见 {@link collectBundledSkills} 拿原因)。
|
|
181
|
+
* @param config - 插件配置。
|
|
182
|
+
* @returns 定义数组;缺文件的条目被跳过(顺序与 {@link BUNDLED_SKILL_NAMES} 一致)。
|
|
183
|
+
*/
|
|
184
|
+
export function buildBundledSkills(config = {}) {
|
|
185
|
+
return collectBundledSkills(config).skills;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 把随包 skill 注册进宿主。`skills` 服务缺席时什么都不做(可选依赖,见文件头注释)。
|
|
190
|
+
*
|
|
191
|
+
* 缺失条目**逐条 warn**(一个 skill 文件缺失不该带走另外两个,但也不能悄悄消失);
|
|
192
|
+
* 全军覆没时额外再给一条汇总 warn,保持既有的「都读不到」信号。
|
|
133
193
|
* @param ctx - 插件上下文。
|
|
134
194
|
* @param config - 插件配置。
|
|
135
|
-
* @returns
|
|
195
|
+
* @returns 排入注册的 skill 数;`0` 表示宿主没有 skill 注册表,或包内一个 skill 文件都读不到。
|
|
136
196
|
*/
|
|
137
|
-
export function
|
|
138
|
-
if (typeof ctx?.inject !== "function") return
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
ctx.logger?.warn?.(`[t-team]
|
|
142
|
-
|
|
197
|
+
export function installBundledSkills(ctx, config) {
|
|
198
|
+
if (typeof ctx?.inject !== "function") return 0;
|
|
199
|
+
const { skills, missing } = collectBundledSkills(config);
|
|
200
|
+
for (const item of missing) {
|
|
201
|
+
ctx.logger?.warn?.(`[t-team] 随包 skill「${item.name}」未注册:${item.reason}(其余 skill 照常注册)`);
|
|
202
|
+
}
|
|
203
|
+
if (skills.length === 0) {
|
|
204
|
+
ctx.logger?.warn?.(`[t-team] 随包 skill 未注册:读不到 ${join(SKILLS_ROOT, "<name>", "SKILL.md")}`);
|
|
205
|
+
return 0;
|
|
143
206
|
}
|
|
144
207
|
ctx.inject(["skills"], (scoped) => {
|
|
145
|
-
scoped.effect(() => scoped.skills.register(skill));
|
|
146
|
-
ctx.logger?.info?.(`[t-team]
|
|
208
|
+
for (const skill of skills) scoped.effect(() => scoped.skills.register(skill));
|
|
209
|
+
ctx.logger?.info?.(`[t-team] 已注册随包 skill:${skills.map((skill) => skill.name).join("、")}`);
|
|
147
210
|
});
|
|
148
|
-
return
|
|
211
|
+
return skills.length;
|
|
149
212
|
}
|
package/lib/squads.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
/**
|
|
2
3
|
* T专家 小队数据层:读写 `teams.json`,并把改动编译成引擎配置。
|
|
3
4
|
*
|
|
@@ -5,10 +6,16 @@
|
|
|
5
6
|
* 编译交给既有的 `team-profiles.py`(它已内置成员名撞键预检、别名校验、人格注入)。
|
|
6
7
|
* 这样 UI 编辑器不需要复制一份校验逻辑,生成器仍是唯一权威。
|
|
7
8
|
*/
|
|
8
|
-
import { readFileSync,
|
|
9
|
-
import {
|
|
9
|
+
import { readFileSync, statSync, existsSync } from "node:fs";
|
|
10
|
+
import { execFile } from "node:child_process";
|
|
10
11
|
import { createHash } from "node:crypto";
|
|
11
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);
|
|
12
19
|
|
|
13
20
|
/** profile key 只允许小写 ASCII + 数字 + 连字符(引擎按它生成命令别名)。 */
|
|
14
21
|
const KEY_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
@@ -18,6 +25,7 @@ function text(value) {
|
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
function fail(code, message, details = {}) {
|
|
28
|
+
/** @type {Error & { code?: string, details?: object }} */
|
|
21
29
|
const error = new Error(message);
|
|
22
30
|
error.code = code;
|
|
23
31
|
error.details = details;
|
|
@@ -36,16 +44,66 @@ function memberSlug(member) {
|
|
|
36
44
|
}
|
|
37
45
|
|
|
38
46
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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**:那正是「用户看不见」的老毛病,宁可静默也不能假装报了。
|
|
42
75
|
*/
|
|
43
|
-
export function createSquadService({ teamsFile, catalog, python = "python3", generator: generatorPath }) {
|
|
76
|
+
export function createSquadService({ teamsFile, catalog, python = "python3", generator: generatorPath, maxMembers, onReload, logger }) {
|
|
44
77
|
const dataDir = dirname(teamsFile);
|
|
45
78
|
const generator = typeof generatorPath === "string" && generatorPath !== ""
|
|
46
79
|
? generatorPath
|
|
47
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}`);
|
|
48
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: "" };
|
|
49
107
|
|
|
50
108
|
function readSpec() {
|
|
51
109
|
try {
|
|
@@ -133,9 +191,14 @@ export function createSquadService({ teamsFile, catalog, python = "python3", gen
|
|
|
133
191
|
engine: spec.engine ?? {},
|
|
134
192
|
defaults: spec.defaults ?? {},
|
|
135
193
|
lastBuild,
|
|
194
|
+
lastReload,
|
|
195
|
+
reloading: reloadTail !== undefined,
|
|
136
196
|
};
|
|
137
197
|
}
|
|
138
198
|
|
|
199
|
+
/** 编译器**实际**会执行的上限:旧编译器不认 --max-members,用它写死的默认值。 */
|
|
200
|
+
const effectiveMemberLimit = () => (generatorSupportsMemberLimit ? memberLimit : COMPILER_DEFAULT_MAX_MEMBERS);
|
|
201
|
+
|
|
139
202
|
/** 校验(生成器之外的第一道闸门,错误信息面向面板)。 */
|
|
140
203
|
function validate(squads) {
|
|
141
204
|
if (!Array.isArray(squads) || squads.length === 0) {
|
|
@@ -153,6 +216,16 @@ export function createSquadService({ teamsFile, catalog, python = "python3", gen
|
|
|
153
216
|
seenKeys.add(key);
|
|
154
217
|
const members = Array.isArray(squad?.members) ? squad.members.map(memberSlug).filter((slug) => slug !== "") : [];
|
|
155
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
|
+
}
|
|
156
229
|
for (const alias of Array.isArray(squad?.aliases) ? squad.aliases : []) {
|
|
157
230
|
const value = text(alias).trim();
|
|
158
231
|
if (value === "") continue;
|
|
@@ -191,25 +264,101 @@ export function createSquadService({ teamsFile, catalog, python = "python3", gen
|
|
|
191
264
|
return { ...spec, profiles };
|
|
192
265
|
}
|
|
193
266
|
|
|
194
|
-
/**
|
|
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
|
+
*/
|
|
195
278
|
function build() {
|
|
196
|
-
if (
|
|
197
|
-
|
|
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
|
+
}
|
|
198
310
|
return lastBuild;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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;
|
|
213
362
|
}
|
|
214
363
|
|
|
215
364
|
async function save(squads, expectedRevision) {
|
|
@@ -223,16 +372,34 @@ export function createSquadService({ teamsFile, catalog, python = "python3", gen
|
|
|
223
372
|
}
|
|
224
373
|
const previousText = readFileSync(teamsFile, "utf8");
|
|
225
374
|
const nextSpec = serialize(readSpec(), squads);
|
|
226
|
-
|
|
227
|
-
|
|
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();
|
|
228
379
|
if (!built.ok) {
|
|
229
|
-
|
|
380
|
+
await writeFileAtomic(teamsFile, previousText); // 回滚,别把坏定义留在盘上(同样原子)
|
|
381
|
+
// 回滚之后**不重载**:盘上是旧配置,重载只会重新加载同一份旧配置,白跑一次还可能
|
|
382
|
+
// 让「保存失败」看起来像「保存成功」。下一次成功保存会重新对齐。
|
|
230
383
|
throw fail("tTeam/squads-build-failed", `配置生成失败,已回滚 teams.json:\n${built.output}`, {
|
|
231
384
|
actualRevision: revisionOf(),
|
|
232
385
|
});
|
|
233
386
|
}
|
|
234
|
-
|
|
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 };
|
|
235
395
|
}
|
|
236
396
|
|
|
237
|
-
return {
|
|
397
|
+
return {
|
|
398
|
+
list, save, validate, revision: revisionOf, generator, dataDir,
|
|
399
|
+
// 本服务没有 `dispose()`:它只在 `save()` 调用期间持有状态(重载串行尾由 save() 自己 await
|
|
400
|
+
// 干净),两次调用之间是无状态的,所以宿主卸载时没有需要收尾的异步工作。
|
|
401
|
+
// 让调用方(与自检)看得到「这次编译用的是哪个上限、编译器到底认不认这个参数」。
|
|
402
|
+
memberLimit,
|
|
403
|
+
generatorSupportsMemberLimit,
|
|
404
|
+
};
|
|
238
405
|
}
|
|
@@ -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
|
+
}
|
|
@@ -16,6 +16,32 @@ function boundary(runtime) {
|
|
|
16
16
|
function unsupported(detail) {
|
|
17
17
|
throw new Error(`t-team: unsupported Harness subagent contract (${detail}); use an explicitly tested Harness version and a coherent dependency installation`);
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* 这个兼容层**实测过**的 Harness 版本(A-1/E-1)。
|
|
21
|
+
*
|
|
22
|
+
* 为什么要写出来:`guardSubagentDelivery` 会覆写共享的 `ctx.subagents` 服务,用的两个
|
|
23
|
+
* `Symbol.for` 与调用签名都来自**未文档化的上游内部实现**。上游一旦改动,症状是"团队功能
|
|
24
|
+
* 突然不可用(引擎降级)",没有这张表就只能靠猜。升级 Harness 后**必须**把这里改成
|
|
25
|
+
* "已实测通过"再发布,未知版本会走 `unsupported()` 明确报错(而不是半死)。
|
|
26
|
+
*/
|
|
27
|
+
export const TESTED_HARNESS_VERSIONS = ['0.1.5-rc.1', '0.1.5-rc.2'];
|
|
28
|
+
/** 单个属性能否被安全覆写:可写数据属性、有 setter 的访问器,或对象可扩展时的新属性。 */
|
|
29
|
+
function canAssign(object, key) {
|
|
30
|
+
const descriptor = Object.getOwnPropertyDescriptor(object, key);
|
|
31
|
+
if (descriptor === undefined)
|
|
32
|
+
return Object.isExtensible(object);
|
|
33
|
+
if ('value' in descriptor)
|
|
34
|
+
return descriptor.writable === true;
|
|
35
|
+
return typeof descriptor.set === 'function';
|
|
36
|
+
}
|
|
37
|
+
/** 覆写前先确认**每一个**目标属性都可写,否则响亮失败(避免 strict mode 下抛裸 TypeError)。 */
|
|
38
|
+
function assertAssignable(object, keys) {
|
|
39
|
+
const blocked = keys.filter((key) => !canAssign(object, key));
|
|
40
|
+
if (blocked.length > 0) {
|
|
41
|
+
const names = blocked.map((key) => (typeof key === 'symbol' ? key.toString() : key)).join(', ');
|
|
42
|
+
return unsupported(`subagent service properties are not writable (${names}); upstream may have frozen or accessor-ized the service`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
19
45
|
/** Read child-owned history, excluding any descriptor inherited from a parent. */
|
|
20
46
|
export function sessionOwnEvents(session) {
|
|
21
47
|
const current = session;
|
|
@@ -116,6 +142,16 @@ export function guardSubagentDelivery(ctx, isRetired) {
|
|
|
116
142
|
if (typeof legacy !== 'function' && ((typeof queue !== 'function' && typeof deliver !== 'function') || typeof send !== 'function')) {
|
|
117
143
|
return unsupported('cannot install complete retired-member guard');
|
|
118
144
|
}
|
|
145
|
+
// A-1/E-1:**先探测可写性再动手**。过去直接赋值,若上游把服务冻结(`Object.freeze`)
|
|
146
|
+
// 或改成只有 getter 的访问器,strict mode 下这里会抛一个与"版本不支持"毫无关系的裸
|
|
147
|
+
// TypeError;现在会得到明确原因。同时这也是一次"契约变了"的早期信号。
|
|
148
|
+
const targets = [
|
|
149
|
+
...(typeof legacy === 'function' ? ['followup'] : []),
|
|
150
|
+
...(typeof queue === 'function' ? [hostPromptQueue] : []),
|
|
151
|
+
...(typeof deliver === 'function' ? [hostPromptDeliver] : []),
|
|
152
|
+
...(typeof send === 'function' ? ['sendMessage'] : []),
|
|
153
|
+
];
|
|
154
|
+
assertAssignable(host, targets);
|
|
119
155
|
ctx.effect(() => {
|
|
120
156
|
const descriptors = new Map([
|
|
121
157
|
['followup', Object.getOwnPropertyDescriptor(host, 'followup')],
|
package/lib/teams/index.js
CHANGED
|
@@ -111,6 +111,12 @@ export function apply(ctx, config) {
|
|
|
111
111
|
maxMembers: config.maxMembers ?? 8,
|
|
112
112
|
profiles: config.profiles ?? {},
|
|
113
113
|
};
|
|
114
|
+
// Hot reload: a squad saved in the settings tab re-applies this plugin with a new
|
|
115
|
+
// `profiles` object (the host updates the fiber's config instead of restarting the
|
|
116
|
+
// harness). Every read below must therefore go through `resolved.profiles` — the
|
|
117
|
+
// per-apply snapshot — and never through a module-level constant captured once at
|
|
118
|
+
// startup: that was the "edit a squad, restart DSH" behaviour this exists to remove.
|
|
119
|
+
const profiles = () => resolved.profiles;
|
|
114
120
|
// Provider registration is a sibling plugin's effect (`subagent-spawn` /
|
|
115
121
|
// `subagent-fork` rows), which can land after this mount under the Loader's
|
|
116
122
|
// concurrent activation — so capability validation happens at the first
|
|
@@ -123,7 +129,9 @@ export function apply(ctx, config) {
|
|
|
123
129
|
order: config.promptSectionOrder,
|
|
124
130
|
// Keep the bounded profile directory available without extra tool calls.
|
|
125
131
|
// installTeamCapabilities snapshots this once; no business state rewrites it.
|
|
126
|
-
|
|
132
|
+
// resolved.profiles is this apply's snapshot, so a hot reload rebuilds the
|
|
133
|
+
// captain prompt with the new squad directory instead of a stale closure.
|
|
134
|
+
captainPrompt: () => usageSectionText(TEAM_TOOL_NAMES.join(', '), formatProfilesForPrompt(profiles())),
|
|
127
135
|
});
|
|
128
136
|
// Deterministic activation surfaces: the closed-namespace `/t-team`
|
|
129
137
|
// host command (surfaces in the Web GUI slash menu via the Harness
|
|
@@ -137,9 +145,9 @@ export function apply(ctx, config) {
|
|
|
137
145
|
// never pends on it and simply never gains the slash command.
|
|
138
146
|
if (config.slashCommand ?? true) {
|
|
139
147
|
ctx.inject(['commands'], (commandCtx) => {
|
|
140
|
-
registerTTeamCommand(commandCtx,
|
|
148
|
+
registerTTeamCommand(commandCtx, profiles);
|
|
141
149
|
});
|
|
142
|
-
installTTeamGestureBoundary(ctx,
|
|
150
|
+
installTTeamGestureBoundary(ctx, profiles);
|
|
143
151
|
}
|
|
144
152
|
// The activity panel data/artwork routes need the Web server and the
|
|
145
153
|
// workspace registry, which headless profiles do not mount; under
|