@stofone/dsh-agent-roster 0.0.0-stage → 0.5.0
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/CHANGELOG.md +139 -0
- package/LICENSE +21 -0
- package/README.md +290 -3
- package/README.zh.md +282 -0
- package/bin/cli.mjs +325 -0
- package/client.js +1950 -0
- package/cordis.patch.yml +9 -0
- package/examples/core-review/roster.yaml +82 -0
- package/examples/research/roster.yaml +42 -0
- package/icon.svg +11 -0
- package/index/registry.json +23 -0
- package/index.js +298 -0
- package/lib/bridge.js +233 -0
- package/lib/config-view.js +59 -0
- package/lib/format.js +223 -0
- package/lib/home.js +108 -0
- package/lib/http.js +122 -0
- package/lib/json.js +89 -0
- package/lib/library.js +161 -0
- package/lib/parse.js +148 -0
- package/lib/registry.js +285 -0
- package/lib/remote.js +623 -0
- package/lib/render.js +343 -0
- package/lib/serialize.js +92 -0
- package/lib/service.js +421 -0
- package/lib/teams-state.js +146 -0
- package/lib/tools.js +601 -0
- package/lib/validate.js +747 -0
- package/lib/verify.js +164 -0
- package/lib/write.js +290 -0
- package/locale/en.json +245 -0
- package/locale/zh.json +245 -0
- package/package.json +98 -4
- package/schema/roster.v1.schema.json +193 -0
package/lib/registry.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 档案库枚举与索引。
|
|
3
|
+
*
|
|
4
|
+
* 目录约定(一份档案 = 一个目录):
|
|
5
|
+
*
|
|
6
|
+
* ```text
|
|
7
|
+
* rosters/
|
|
8
|
+
* core-review/
|
|
9
|
+
* roster.yaml
|
|
10
|
+
* research/
|
|
11
|
+
* roster.yaml
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* 为什么是目录而不是散落的 `*.yaml`:目录名就是档案 id(`roster` 字段必须与之一致),
|
|
15
|
+
* 未来要给档案加附加文件(说明、示例任务、覆盖片段)时不用改布局;也让"某个 id 到底是
|
|
16
|
+
* 哪个文件"没有歧义。
|
|
17
|
+
*
|
|
18
|
+
* 本模块只读文件系统,不写任何东西。缺失的档案库不是错误(空库是合法状态),
|
|
19
|
+
* 但会给出显式提示,避免"静默空结果"。目录里没有 `roster.*` 的会被跳过并记一条 info,
|
|
20
|
+
* 不算成"校验失败"——这两件事必须分开,否则用户分不清是写错了还是根本没写。
|
|
21
|
+
*
|
|
22
|
+
* @module dsh-agent-roster/registry
|
|
23
|
+
*/
|
|
24
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
25
|
+
import nodePath from "node:path";
|
|
26
|
+
|
|
27
|
+
import { MEMBER_FILE_NAMES } from "./format.js";
|
|
28
|
+
import { parseDocumentText } from "./parse.js";
|
|
29
|
+
import { validateRoster } from "./validate.js";
|
|
30
|
+
|
|
31
|
+
/** 与档案无关、必须跳过的目录名(依赖、隐藏状态、虚拟环境)。 */
|
|
32
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", ".agent-teams", ".dsh", ".venv", "__pycache__"]);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 扫描一座档案库。
|
|
36
|
+
*
|
|
37
|
+
* @param {object} options
|
|
38
|
+
* @param {string} options.rootDir 绝对路径
|
|
39
|
+
* @param {string[] | null} [options.knownToolPolicies] 部署已声明的 toolPolicy id
|
|
40
|
+
* @returns {{
|
|
41
|
+
* rootDir: string,
|
|
42
|
+
* missing: boolean,
|
|
43
|
+
* entries: { id: string, file: string | null, roster: object | null }[],
|
|
44
|
+
* rosters: object[],
|
|
45
|
+
* stats: { total: number, ok: number, failed: number, members: number,
|
|
46
|
+
* skippedDirs: number, errors: number, warnings: number, infos: number },
|
|
47
|
+
* diagnostics: object[],
|
|
48
|
+
* }}
|
|
49
|
+
*/
|
|
50
|
+
export function scanRosterLibrary({ rootDir, knownToolPolicies = null }) {
|
|
51
|
+
const diagnostics = [];
|
|
52
|
+
const entries = [];
|
|
53
|
+
const rosters = [];
|
|
54
|
+
const stats = {
|
|
55
|
+
total: 0,
|
|
56
|
+
ok: 0,
|
|
57
|
+
failed: 0,
|
|
58
|
+
members: 0,
|
|
59
|
+
skippedDirs: 0,
|
|
60
|
+
errors: 0,
|
|
61
|
+
warnings: 0,
|
|
62
|
+
infos: 0,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
let childDirs = null;
|
|
66
|
+
try {
|
|
67
|
+
if (statSync(rootDir).isDirectory()) childDirs = readdirSync(rootDir, { withFileTypes: true });
|
|
68
|
+
} catch {
|
|
69
|
+
childDirs = null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 目录不存在只记 missing 标记:首次保存会自动建,空库不该产出 warn/info 噪音。
|
|
73
|
+
if (childDirs !== null) {
|
|
74
|
+
childDirs.sort((left, right) => left.name.localeCompare(right.name));
|
|
75
|
+
for (const child of childDirs) {
|
|
76
|
+
if (child.name.startsWith(".") || SKIP_DIRS.has(child.name) || child.isSymbolicLink()) continue;
|
|
77
|
+
|
|
78
|
+
if (!child.isDirectory()) {
|
|
79
|
+
// 散落的 *.yaml 曾经是另一种布局;现在只认目录,必须明确告知而不是忽略。
|
|
80
|
+
diagnostics.push({
|
|
81
|
+
severity: "warn",
|
|
82
|
+
code: "unexpected-file",
|
|
83
|
+
path: "",
|
|
84
|
+
file: child.name,
|
|
85
|
+
reason:
|
|
86
|
+
`档案库里直接出现了文件 "${child.name}"。目录约定是 <root>/<roster-id>/roster.yaml:` +
|
|
87
|
+
"请把它移进以档案 id 命名的子目录,否则它不会被加载。",
|
|
88
|
+
});
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const dirPath = nodePath.join(rootDir, child.name);
|
|
93
|
+
const found = locateDocument(dirPath, child.name, diagnostics);
|
|
94
|
+
if (found === null) {
|
|
95
|
+
stats.skippedDirs += 1;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
stats.total += 1;
|
|
100
|
+
const entry = { id: child.name, file: found.relative, roster: null };
|
|
101
|
+
const outcome = loadDocument(found, child.name, { knownToolPolicies });
|
|
102
|
+
diagnostics.push(...outcome.diagnostics);
|
|
103
|
+
if (outcome.ok) {
|
|
104
|
+
entry.roster = outcome.roster;
|
|
105
|
+
rosters.push(outcome.roster);
|
|
106
|
+
stats.ok += 1;
|
|
107
|
+
} else {
|
|
108
|
+
stats.failed += 1;
|
|
109
|
+
}
|
|
110
|
+
entries.push(entry);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (stats.total > 0 && rosters.length === 0) {
|
|
115
|
+
diagnostics.push({
|
|
116
|
+
severity: "error",
|
|
117
|
+
code: "library-unusable",
|
|
118
|
+
path: "",
|
|
119
|
+
file: null,
|
|
120
|
+
reason: `档案库里有 ${stats.total} 份档案,却没有一份通过校验;档案层无法为运行时提供任何成员。`,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 「没配置交叉校验的已知列表」是档案库级的一件事,说一次就够:
|
|
125
|
+
// 逐份档案重复警告会把噪音放大成 n 倍,反而让人习惯性忽略警告。
|
|
126
|
+
// 只有真的有档案声明了 toolPolicy 才提——空库或没人用策略时,没东西好交叉核对。
|
|
127
|
+
const declaresToolPolicy = rosters.some((roster) => roster.members.some((member) => member.toolPolicy));
|
|
128
|
+
if ((knownToolPolicies ?? []).length === 0 && declaresToolPolicy) {
|
|
129
|
+
diagnostics.push({
|
|
130
|
+
severity: "info",
|
|
131
|
+
code: "tool-policy-cross-check-skipped",
|
|
132
|
+
path: "",
|
|
133
|
+
file: null,
|
|
134
|
+
reason:
|
|
135
|
+
"未配置 knownToolPolicies:toolPolicy 只做格式校验,不与部署已声明的策略 id 交叉核对。" +
|
|
136
|
+
"把部署里存在的策略 id 列进该配置即可启用交叉校验。",
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const item of diagnostics) {
|
|
141
|
+
if (item.severity === "error") stats.errors += 1;
|
|
142
|
+
else if (item.severity === "warn") stats.warnings += 1;
|
|
143
|
+
else stats.infos += 1;
|
|
144
|
+
}
|
|
145
|
+
stats.members = rosters.reduce((sum, roster) => sum + roster.members.length, 0);
|
|
146
|
+
|
|
147
|
+
const severityRank = { error: 0, warn: 1, info: 2 };
|
|
148
|
+
diagnostics.sort(
|
|
149
|
+
(left, right) =>
|
|
150
|
+
severityRank[left.severity] - severityRank[right.severity] ||
|
|
151
|
+
String(left.file ?? "").localeCompare(String(right.file ?? "")),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
return { rootDir, missing: childDirs === null, entries, rosters, stats, diagnostics };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 在一个档案目录里找唯一的档案文件。
|
|
159
|
+
*
|
|
160
|
+
* 多个 `roster.*` 同时存在时按 {@link MEMBER_FILE_NAMES} 顺序取第一个并警告:
|
|
161
|
+
* 两份档案同时生效会让人以为改了字段却没生效。
|
|
162
|
+
*
|
|
163
|
+
* @param {string} dirPath
|
|
164
|
+
* @param {string} dirName
|
|
165
|
+
* @param {object[]} diagnostics
|
|
166
|
+
* @returns {{ absolute: string, relative: string, format: string } | null}
|
|
167
|
+
*/
|
|
168
|
+
function locateDocument(dirPath, dirName, diagnostics) {
|
|
169
|
+
let names;
|
|
170
|
+
try {
|
|
171
|
+
names = readdirSync(dirPath);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
diagnostics.push({
|
|
174
|
+
severity: "warn",
|
|
175
|
+
code: "unreadable-dir",
|
|
176
|
+
path: "",
|
|
177
|
+
file: dirName,
|
|
178
|
+
reason: `档案目录不可读:${messageOf(error)}`,
|
|
179
|
+
});
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const present = MEMBER_FILE_NAMES.filter((name) => names.includes(name));
|
|
184
|
+
if (present.length === 0) {
|
|
185
|
+
diagnostics.push({
|
|
186
|
+
severity: "info",
|
|
187
|
+
code: "missing-roster-file",
|
|
188
|
+
path: "",
|
|
189
|
+
file: `${dirName}/`,
|
|
190
|
+
reason:
|
|
191
|
+
`目录 "${dirName}" 里没有 ${MEMBER_FILE_NAMES.join(" / ")},已跳过(不计入校验失败)。` +
|
|
192
|
+
"如果这是要新建的档案,请在目录里放一份 roster.yaml。",
|
|
193
|
+
});
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
if (present.length > 1) {
|
|
197
|
+
diagnostics.push({
|
|
198
|
+
severity: "warn",
|
|
199
|
+
code: "multiple-roster-files",
|
|
200
|
+
path: "",
|
|
201
|
+
file: `${dirName}/${present[0]}`,
|
|
202
|
+
reason:
|
|
203
|
+
`目录 "${dirName}" 同时存在 ${present.join("、")},只加载 ${present[0]};` +
|
|
204
|
+
"请删掉多余文件,否则另一份永远不会生效。",
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
absolute: nodePath.join(dirPath, present[0]),
|
|
209
|
+
relative: `${dirName}/${present[0]}`,
|
|
210
|
+
format: present[0].endsWith(".json") ? "json" : "yaml",
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 读取 + 解析 + 校验一份档案。
|
|
216
|
+
*
|
|
217
|
+
* @param {{ absolute: string, relative: string, format: string }} found
|
|
218
|
+
* @param {string} expectedId 目录名,必须与 `roster` 字段一致
|
|
219
|
+
* @param {{ knownToolPolicies: string[] | null }} options
|
|
220
|
+
* @returns {{ ok: boolean, roster: object | null, diagnostics: object[] }}
|
|
221
|
+
*/
|
|
222
|
+
function loadDocument(found, expectedId, { knownToolPolicies }) {
|
|
223
|
+
let raw;
|
|
224
|
+
try {
|
|
225
|
+
raw = readFileSync(found.absolute, "utf8");
|
|
226
|
+
} catch (error) {
|
|
227
|
+
return {
|
|
228
|
+
ok: false,
|
|
229
|
+
roster: null,
|
|
230
|
+
diagnostics: [
|
|
231
|
+
{
|
|
232
|
+
severity: "error",
|
|
233
|
+
code: "unreadable-file",
|
|
234
|
+
path: "",
|
|
235
|
+
file: found.relative,
|
|
236
|
+
reason: `文件不可读:${messageOf(error)}`,
|
|
237
|
+
},
|
|
238
|
+
],
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const parsed = parseDocumentText(raw, { file: found.relative, format: found.format });
|
|
243
|
+
if (!parsed.ok) return { ok: false, roster: null, diagnostics: parsed.diagnostics };
|
|
244
|
+
|
|
245
|
+
const validated = validateRoster(parsed.value, {
|
|
246
|
+
file: found.relative,
|
|
247
|
+
expectedId,
|
|
248
|
+
knownToolPolicies,
|
|
249
|
+
});
|
|
250
|
+
if (!validated.ok) return { ok: false, roster: null, diagnostics: validated.diagnostics };
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
ok: true,
|
|
254
|
+
roster: { ...validated.roster, format: found.format },
|
|
255
|
+
diagnostics: validated.diagnostics,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* 定位一份档案或其中某个成员。
|
|
261
|
+
*
|
|
262
|
+
* 这是个**纯查找**函数:不猜测、不模糊匹配。成员名在多份档案里重复出现时返回歧义列表,
|
|
263
|
+
* 由调用方要求用户改用档案 id——替用户随便挑一份,正是"档案看起来生效了"的根源。
|
|
264
|
+
*
|
|
265
|
+
* @param {object[]} rosters 已通过校验的档案
|
|
266
|
+
* @param {string} ref 档案 id 或成员名
|
|
267
|
+
* @returns {null | { via: "id" | "member", roster: object } | { ambiguous: object[] }}
|
|
268
|
+
*/
|
|
269
|
+
export function findRoster(rosters, ref) {
|
|
270
|
+
const wanted = String(ref ?? "").trim();
|
|
271
|
+
if (wanted === "") return null;
|
|
272
|
+
|
|
273
|
+
const byId = rosters.find((roster) => roster.id === wanted);
|
|
274
|
+
if (byId !== undefined) return { via: "id", roster: byId };
|
|
275
|
+
|
|
276
|
+
const matches = rosters.filter((roster) => roster.members.some((member) => member.name === wanted));
|
|
277
|
+
if (matches.length === 0) return null;
|
|
278
|
+
if (matches.length === 1) return { via: "member", roster: matches[0] };
|
|
279
|
+
return { ambiguous: matches };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** 把任意异常的 message 折成一行;与 parse.js 保持同一口径。 */
|
|
283
|
+
export function messageOf(error) {
|
|
284
|
+
return String(error?.message ?? error ?? "未知错误").split("\n")[0];
|
|
285
|
+
}
|