@stofone/dsh-agent-roster 0.6.2 → 0.8.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +98 -1
  2. package/LICENSE +21 -21
  3. package/README.md +407 -319
  4. package/README.zh.md +95 -9
  5. package/bin/cli.mjs +10 -0
  6. package/index/registry.json +45 -1
  7. package/index.js +2 -2
  8. package/lib/bridge.js +89 -10
  9. package/lib/capabilities.js +434 -0
  10. package/lib/diff.js +216 -0
  11. package/lib/digest.js +65 -0
  12. package/lib/format.js +4 -5
  13. package/lib/library.js +5 -0
  14. package/lib/organization.js +331 -0
  15. package/lib/registry.js +209 -4
  16. package/lib/render.js +253 -7
  17. package/lib/tools.js +251 -23
  18. package/lib/verify.js +135 -10
  19. package/orgs/agent-roster-collective/api-design/roster.yaml +67 -0
  20. package/orgs/agent-roster-collective/content-ops/roster.yaml +55 -0
  21. package/orgs/agent-roster-collective/contract-review/roster.yaml +58 -0
  22. package/orgs/agent-roster-collective/data-quality/roster.yaml +64 -0
  23. package/orgs/agent-roster-collective/db-migration/roster.yaml +64 -0
  24. package/orgs/agent-roster-collective/docs-writer/roster.yaml +59 -0
  25. package/orgs/agent-roster-collective/finance-recon/roster.yaml +63 -0
  26. package/orgs/agent-roster-collective/frontend-ui/roster.yaml +60 -0
  27. package/orgs/agent-roster-collective/i18n-l10n/roster.yaml +60 -0
  28. package/orgs/agent-roster-collective/incident-response/roster.yaml +65 -0
  29. package/orgs/agent-roster-collective/literature-review/roster.yaml +61 -0
  30. package/orgs/agent-roster-collective/market-research/roster.yaml +60 -0
  31. package/orgs/agent-roster-collective/ml-eval/roster.yaml +63 -0
  32. package/orgs/agent-roster-collective/organization.yaml +118 -0
  33. package/orgs/agent-roster-collective/performance/roster.yaml +63 -0
  34. package/orgs/agent-roster-collective/product-discovery/roster.yaml +55 -0
  35. package/orgs/agent-roster-collective/qa-regression/roster.yaml +64 -0
  36. package/orgs/agent-roster-collective/recruiting/roster.yaml +58 -0
  37. package/orgs/agent-roster-collective/release-manager/roster.yaml +59 -0
  38. package/orgs/agent-roster-collective/security-audit/capabilities.yaml +12 -0
  39. package/orgs/agent-roster-collective/security-audit/roster.yaml +65 -0
  40. package/orgs/agent-roster-collective/sre-oncall/roster.yaml +60 -0
  41. package/orgs/agent-roster-collective/support-triage/roster.yaml +58 -0
  42. package/package.json +5 -1
  43. package/schema/capabilities.v1.schema.json +124 -0
  44. package/schema/organization.v1.schema.json +50 -0
  45. package/schema/registry.v1.schema.json +83 -0
package/lib/digest.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * 摘要工具:把"看不见的东西"变成可以比对的一行字。
3
+ *
4
+ * 两个用途,都是 issue #2 暴露出来的盲区:
5
+ * - **档案文件内容**:团队状态里没有"由哪一版档案建成"的字段,只能把文件内容的 sha256
6
+ * 写进 `agent_teams_create` 的 description(溯源串),下次核对比的是同一份字节。
7
+ * - **物化后的工具策略**:`team.json` 里除了 `toolPolicy` 这个 id,还存了策略被展开成工具名
8
+ * 之后的 `toolFilter` {@code {deny:[…]}}。只比 id 会在"部署侧改了策略定义"时永远绿,
9
+ * 所以要比这一坨工具名的规范化摘要。
10
+ *
11
+ * 规范化一律按"排序 + JSON.stringify":键序与元素顺序不同不算变化,否则每次重排都会假报漂移。
12
+ *
13
+ * @module dsh-agent-roster/digest
14
+ */
15
+ import { createHash } from "node:crypto";
16
+
17
+ /**
18
+ * 计算文本的 sha256 十六进制摘要。
19
+ *
20
+ * @param {string | Uint8Array} input
21
+ * @returns {string} 64 位小写十六进制
22
+ */
23
+ export function sha256Hex(input) {
24
+ return createHash("sha256").update(input).digest("hex");
25
+ }
26
+
27
+ /**
28
+ * 取摘要前 8 位:打印、写进 description 时用短的,避免把一行文字撑得太长。
29
+ *
30
+ * @param {string | null | undefined} hex
31
+ * @returns {string}
32
+ */
33
+ export function shortSha(hex) {
34
+ return typeof hex === "string" && hex !== "" ? hex.slice(0, 8) : "";
35
+ }
36
+
37
+ /**
38
+ * 把一个成员的工具过滤器折叠成"可比较的形状"。
39
+ *
40
+ * 缺失、非对象、字段类型不对时返回 `null`——读不到就是读不到,不编一个空摘要出来,
41
+ * 否则"没有 toolFilter"会被伪装成"toolFilter 与别人一致"。
42
+ *
43
+ * @param {unknown} toolFilter `team.json` 成员的 `toolFilter`
44
+ * @returns {{ deny: number, allow: number, sha: string } | null}
45
+ */
46
+ export function toolFilterDigest(toolFilter) {
47
+ if (toolFilter === null || typeof toolFilter !== "object" || Array.isArray(toolFilter)) return null;
48
+ const deny = namesOf(toolFilter.deny);
49
+ const allow = namesOf(toolFilter.allow);
50
+ if (deny === null && allow === null) return null;
51
+ const canonical = JSON.stringify({ allow: allow ?? [], deny: deny ?? [] });
52
+ return { deny: (deny ?? []).length, allow: (allow ?? []).length, sha: shortSha(sha256Hex(canonical)) };
53
+ }
54
+
55
+ /**
56
+ * 取出字符串数组并排序去重;不是数组时返回 `null`,让调用方区分"空"与"没有这个键"。
57
+ *
58
+ * @param {unknown} value
59
+ * @returns {string[] | null}
60
+ */
61
+ function namesOf(value) {
62
+ if (!Array.isArray(value)) return null;
63
+ const names = value.filter((item) => typeof item === "string" && item !== "");
64
+ return [...new Set(names)].sort();
65
+ }
package/lib/format.js CHANGED
@@ -10,14 +10,13 @@
10
10
  /** 档案文档格式标识(出现在每份 roster.yaml 的 kind 字段)。 */
11
11
  export const ROSTER_KIND = "dsh-agent-roster/v1";
12
12
 
13
- /** 索引文档格式标识(由 scan CLI / GitHub Action 产出)。 */
14
- export const INDEX_KIND = "dsh-agent-roster-index/v1";
15
-
16
13
  /**
17
14
  * 公共档案库目录(registry)的格式标识。
18
15
  *
19
- * 与 {@link INDEX_KIND} 的区别:index 是“一个档案库扫描后的清单”(roster 指针),
20
- * registry 是“许多档案库的登记簿”(collection 指针)——后者才是订阅/共享的入口。
16
+ * 曾经另有一个 `dsh-agent-roster-index/v1`(“一个档案库扫描后的清单”),但没有任何命令
17
+ * 产出过它:registry 的每条 collection 本来就带 `rosters` 列表,并由 `registry sync`
18
+ * 按仓库实际内容重算——再定义第二种清单格式等于造第二个真源。故该常量已删除,
19
+ * 见 CHANGELOG 的 Removed 一节。
21
20
  */
22
21
  export const REGISTRY_KIND = "dsh-agent-roster-registry/v1";
23
22
 
package/lib/library.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * @module dsh-agent-roster/library
9
9
  */
10
10
  import { displayDshHome, isGlobalDirSpec, resolveDshHome, resolveDirSpec } from "./home.js";
11
+ import { capabilityDiagnostics } from "./capabilities.js";
11
12
  import { scanRosterLibrary } from "./registry.js";
12
13
 
13
14
  /**
@@ -46,6 +47,10 @@ export function collectLibrary(ctx, config, meta) {
46
47
  rootDir,
47
48
  knownToolPolicies: config.knownToolPolicies ?? null,
48
49
  });
50
+ // 能力要求的面板侧诊断:能同步判定的(策略)落到严重度上,判不了的(技能)如实标成不判定。
51
+ scan.diagnostics.push(
52
+ ...capabilityDiagnostics(scan.capabilities ?? {}, scan.capabilityFiles ?? {}, config.knownToolPolicies ?? null),
53
+ );
49
54
  reports.push({
50
55
  ...target,
51
56
  dirSpec,
@@ -0,0 +1,331 @@
1
+ /**
2
+ * 组织档案(organization):一座档案库的"目录页"。
3
+ *
4
+ * 为什么需要它:档案库只能回答"这一份档案写了什么",回答不了"这些档案是什么关系、
5
+ * 哪份是生产、哪份是归档、谁维护"。当一座库里放二十份来自不同来源的档案时,
6
+ * 缺了这层就只能靠目录名猜。
7
+ *
8
+ * 边界(与 capabilities.yaml 同一条纪律):
9
+ * - 组织只做**声明与核对**:声明库里有哪几份档案、各自什么状态;
10
+ * 它不创建团队、不改档案、不进 `agent_teams_add_member`。
11
+ * - 它**能被机械核对**:声明了但目录不在 = error,目录在却没声明 = warn,
12
+ * 已归档却仍在用 = info。核对不了的字段一律不收(未知键即错误)。
13
+ * - 位置即身份:`organization` 必须等于所在目录名,和 `roster` 的规则一致。
14
+ *
15
+ * @module dsh-agent-roster/organization
16
+ */
17
+
18
+ /** 组织文件的 `kind`。与 roster / registry / capabilities 并列,互不冒充。 */
19
+ export const ORGANIZATION_KIND = "dsh-agent-roster-organization/v1";
20
+
21
+ /** 组织文件允许的文件名,按优先级排列。 */
22
+ export const ORGANIZATION_FILE_NAMES = ["organization.yaml", "organization.yml", "organization.json"];
23
+
24
+ /** 档案在组织里的状态。刻意只有两档:归档不等于删除,删除就是没有这个目录。 */
25
+ export const ORGANIZATION_STATUSES = ["active", "archived"];
26
+
27
+ /** 长度上限:与档案的 LIMITS 同一套口径,超了就报错而不是截断。 */
28
+ export const LIMITS = {
29
+ rosters: 64,
30
+ titleChars: 120,
31
+ descriptionChars: 500,
32
+ licenseChars: 120,
33
+ homepageChars: 200,
34
+ maintainers: 16,
35
+ maintainerChars: 120,
36
+ noteChars: 300,
37
+ };
38
+
39
+ /** 组织 id 与档案 id 同形:小写、连字符、首字符必须是字母或数字。 */
40
+ export const ID_PATTERN = /^[a-z0-9][a-z0-9-]{1,63}$/;
41
+
42
+ const ROOT_FIELDS = new Set([
43
+ "kind",
44
+ "organization",
45
+ "title",
46
+ "description",
47
+ "license",
48
+ "homepage",
49
+ "maintainers",
50
+ "rosters",
51
+ ]);
52
+
53
+ const ROSTER_FIELDS = new Set(["id", "title", "status", "origin", "note"]);
54
+
55
+ /** 顶层字段清单(供 schema 对齐测试反向钉住,避免两处各写一份)。 */
56
+ export const ORGANIZATION_FIELDS = Object.freeze([...ROOT_FIELDS]);
57
+
58
+ /** 每条登记项的字段清单。 */
59
+ export const ORGANIZATION_ROSTER_FIELDS = Object.freeze([...ROSTER_FIELDS]);
60
+
61
+ /**
62
+ * 一条诊断。形状与 `lib/validate.js` 保持一致,好让 render / panel / CLI 用同一套渲染。
63
+ *
64
+ * @param {"error"|"warn"|"info"} severity
65
+ * @param {string} code
66
+ * @param {string} file
67
+ * @param {string} reason
68
+ * @returns {{severity: string, code: string, path: string, file: string, reason: string}}
69
+ */
70
+ function diagnose(severity, code, file, reason) {
71
+ return { severity, code, path: "", file, reason };
72
+ }
73
+
74
+ /**
75
+ * 校验一份组织档案。
76
+ *
77
+ * 与 roster 的校验同一条纪律:**能核对的才收**。`homepage` 只校验是不是 http(s),
78
+ * 不联网;`maintainers` 只校验形态,不校验这个人存在不存在。
79
+ *
80
+ * @param {unknown} value 解析后的 JSON/YAML 值
81
+ * @param {{file?: string, expectedId?: string|null}} [options]
82
+ * @returns {{ok: boolean, organization: object|null, diagnostics: object[]}}
83
+ */
84
+ export function validateOrganization(value, options = {}) {
85
+ const file = options.file ?? ORGANIZATION_FILE_NAMES[0];
86
+ const expectedId = options.expectedId ?? null;
87
+ /** @type {object[]} */
88
+ const diagnostics = [];
89
+
90
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
91
+ diagnostics.push(diagnose("error", "organization-not-object", file, "组织文件必须是一个对象(键值对)。"));
92
+ return { ok: false, organization: null, diagnostics };
93
+ }
94
+
95
+ for (const key of Object.keys(value)) {
96
+ if (!ROOT_FIELDS.has(key)) {
97
+ diagnostics.push(
98
+ diagnose(
99
+ "error",
100
+ "unknown-field",
101
+ file,
102
+ `未知字段 "${key}"。组织文件只收 ${[...ROOT_FIELDS].join(" / ")}:` +
103
+ "核对不了的字段不收——写了也不会被任何人验证,只会被误当成事实。",
104
+ ),
105
+ );
106
+ }
107
+ }
108
+
109
+ if (value.kind !== ORGANIZATION_KIND) {
110
+ diagnostics.push(
111
+ diagnose("error", "organization-kind", file, `kind 必须是 "${ORGANIZATION_KIND}",实际是 ${JSON.stringify(value.kind)}。`),
112
+ );
113
+ }
114
+
115
+ const id = value.organization;
116
+ if (typeof id !== "string" || !ID_PATTERN.test(id)) {
117
+ diagnostics.push(
118
+ diagnose("error", "organization-id", file, "organization 必须是 2–64 位小写字母/数字/连字符,且以字母或数字开头。"),
119
+ );
120
+ } else if (expectedId !== null && id !== expectedId) {
121
+ diagnostics.push(
122
+ diagnose(
123
+ "error",
124
+ "organization-id-mismatch",
125
+ file,
126
+ `organization 是 "${id}",但所在目录叫 "${expectedId}"。位置即身份:组织 id 必须等于目录名。`,
127
+ ),
128
+ );
129
+ }
130
+
131
+ const title = optionalString(value, "title", LIMITS.titleChars, diagnostics, file, "organization-title");
132
+ optionalString(value, "description", LIMITS.descriptionChars, diagnostics, file, "organization-description");
133
+ optionalString(value, "license", LIMITS.licenseChars, diagnostics, file, "organization-license");
134
+
135
+ if (value.homepage !== undefined) {
136
+ const homepage = optionalString(value, "homepage", LIMITS.homepageChars, diagnostics, file, "organization-homepage");
137
+ if (typeof homepage === "string" && !/^https?:\/\/\S+$/i.test(homepage)) {
138
+ diagnostics.push(
139
+ diagnose("error", "organization-homepage", file, "homepage 只收 http(s) 地址:别的写法既打不开也核对不了。"),
140
+ );
141
+ }
142
+ }
143
+
144
+ if (value.maintainers !== undefined) {
145
+ if (!Array.isArray(value.maintainers)) {
146
+ diagnostics.push(diagnose("error", "organization-maintainers", file, "maintainers 必须是字符串数组。"));
147
+ } else if (value.maintainers.length > LIMITS.maintainers) {
148
+ diagnostics.push(
149
+ diagnose("error", "organization-maintainers", file, `maintainers 最多 ${LIMITS.maintainers} 项。`),
150
+ );
151
+ } else {
152
+ for (const who of value.maintainers) {
153
+ if (typeof who !== "string" || who.length === 0 || who.length > LIMITS.maintainerChars) {
154
+ diagnostics.push(
155
+ diagnose("error", "organization-maintainer", file, `维护者名字必须是 1–${LIMITS.maintainerChars} 个字符的字符串。`),
156
+ );
157
+ break;
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ const rosters = [];
164
+ const rawRosters = value.rosters;
165
+ if (!Array.isArray(rawRosters) || rawRosters.length === 0) {
166
+ diagnostics.push(diagnose("error", "organization-rosters", file, "rosters 必须是非空数组:组织的意义就是登记这些档案。"));
167
+ } else if (rawRosters.length > LIMITS.rosters) {
168
+ diagnostics.push(diagnose("error", "organization-rosters", file, `rosters 最多 ${LIMITS.rosters} 项。`));
169
+ } else {
170
+ const seen = new Set();
171
+ rawRosters.forEach((item, index) => {
172
+ const where = `rosters[${index}]`;
173
+ if (item === null || typeof item !== "object" || Array.isArray(item)) {
174
+ diagnostics.push(diagnose("error", "organization-roster-not-object", file, `${where} 必须是对象。`));
175
+ return;
176
+ }
177
+ for (const key of Object.keys(item)) {
178
+ if (!ROSTER_FIELDS.has(key)) {
179
+ diagnostics.push(
180
+ diagnose("error", "unknown-field", file, `${where} 的未知字段 "${key}"(只收 ${[...ROSTER_FIELDS].join(" / ")})。`),
181
+ );
182
+ }
183
+ }
184
+ if (typeof item.id !== "string" || !ID_PATTERN.test(item.id)) {
185
+ diagnostics.push(diagnose("error", "organization-roster-id", file, `${where}.id 必须是合法档案 id。`));
186
+ return;
187
+ }
188
+ if (seen.has(item.id)) {
189
+ diagnostics.push(
190
+ diagnose("error", "organization-duplicate-roster", file, `档案 "${item.id}" 在组织里登记了两次。`),
191
+ );
192
+ return;
193
+ }
194
+ seen.add(item.id);
195
+ const status = item.status ?? "active";
196
+ if (!ORGANIZATION_STATUSES.includes(status)) {
197
+ diagnostics.push(
198
+ diagnose("error", "organization-roster-status", file, `${where}.status 只能是 ${ORGANIZATION_STATUSES.join(" / ")}。`),
199
+ );
200
+ }
201
+ const entryTitle = optionalString(item, "title", LIMITS.titleChars, diagnostics, file, "organization-roster-title", where);
202
+ const origin = optionalString(item, "origin", LIMITS.titleChars, diagnostics, file, "organization-roster-origin", where);
203
+ const note = optionalString(item, "note", LIMITS.noteChars, diagnostics, file, "organization-roster-note", where);
204
+ rosters.push({
205
+ id: item.id,
206
+ title: entryTitle ?? "",
207
+ status,
208
+ origin: origin ?? "",
209
+ note: note ?? "",
210
+ });
211
+ });
212
+ }
213
+
214
+ if (diagnostics.some((item) => item.severity === "error")) {
215
+ return { ok: false, organization: null, diagnostics };
216
+ }
217
+
218
+ return {
219
+ ok: true,
220
+ organization: {
221
+ kind: ORGANIZATION_KIND,
222
+ id,
223
+ title: title ?? "",
224
+ description: value.description ?? "",
225
+ license: value.license ?? "",
226
+ homepage: value.homepage ?? "",
227
+ maintainers: Array.isArray(value.maintainers) ? [...value.maintainers] : [],
228
+ rosters,
229
+ },
230
+ diagnostics,
231
+ };
232
+ }
233
+
234
+ /**
235
+ * 读一个可选字符串字段并钉住上限。
236
+ *
237
+ * @param {object} source
238
+ * @param {string} key
239
+ * @param {number} max
240
+ * @param {object[]} diagnostics
241
+ * @param {string} file
242
+ * @param {string} code
243
+ * @param {string} [where]
244
+ * @returns {string|undefined}
245
+ */
246
+ function optionalString(source, key, max, diagnostics, file, code, where = "") {
247
+ const value = source[key];
248
+ if (value === undefined) return undefined;
249
+ const label = where === "" ? key : `${where}.${key}`;
250
+ if (typeof value !== "string" || value.length > max) {
251
+ diagnostics.push(diagnose("error", code, file, `${label} 必须是长度不超过 ${max} 的字符串。`));
252
+ return undefined;
253
+ }
254
+ return value;
255
+ }
256
+
257
+ /**
258
+ * 组织声明与磁盘实际情况的交叉核对。
259
+ *
260
+ * 三件事分开说,因为它们的严重度不同:
261
+ * - **缺**(声明了却找不到目录):error。组织页在撒谎,比没有组织页更糟。
262
+ * - **多**(目录在却没登记):warn。不是错误,但意味着有人绕过组织加了档案。
263
+ * - **归档仍在**:info。归档是声明,不是删除;提醒一句即可。
264
+ *
265
+ * @param {object|null} organization
266
+ * @param {string[]} rosterIds 磁盘上真正通过校验的档案 id
267
+ * @returns {{declared: number, present: number, missing: string[], undeclared: string[], archived: string[], status: string}}
268
+ */
269
+ export function checkOrganization(organization, rosterIds) {
270
+ const onDisk = new Set(rosterIds);
271
+ const declared = new Set(organization === null ? [] : organization.rosters.map((item) => item.id));
272
+ const missing = [...declared].filter((id) => !onDisk.has(id)).sort();
273
+ const undeclared = [...onDisk].filter((id) => !declared.has(id)).sort();
274
+ const archived = organization === null
275
+ ? []
276
+ : organization.rosters.filter((item) => item.status === "archived" && onDisk.has(item.id)).map((item) => item.id).sort();
277
+ return {
278
+ declared: declared.size,
279
+ present: onDisk.size,
280
+ missing,
281
+ undeclared,
282
+ archived,
283
+ status: organization === null ? "absent" : missing.length > 0 ? "incomplete" : "ok",
284
+ };
285
+ }
286
+
287
+ /**
288
+ * 把交叉核对结果折成诊断。
289
+ *
290
+ * @param {object|null} organization
291
+ * @param {object} check {@link checkOrganization} 的返回值
292
+ * @param {string} file 组织文件相对路径
293
+ * @returns {object[]}
294
+ */
295
+ export function organizationDiagnostics(organization, check, file) {
296
+ const diagnostics = [];
297
+ if (organization === null) return diagnostics;
298
+ if (check.missing.length > 0) {
299
+ diagnostics.push(
300
+ diagnose(
301
+ "error",
302
+ "organization-roster-missing",
303
+ file,
304
+ `组织声明了 ${check.missing.length} 份档案但库里找不到:${check.missing.join(", ")}。` +
305
+ "要么把目录加回来,要么把它们从 organization 里删掉——组织页不能比磁盘更乐观。",
306
+ ),
307
+ );
308
+ }
309
+ if (check.undeclared.length > 0) {
310
+ diagnostics.push(
311
+ diagnose(
312
+ "warn",
313
+ "organization-roster-undeclared",
314
+ file,
315
+ `库里有 ${check.undeclared.length} 份档案没在组织里登记:${check.undeclared.join(", ")}。` +
316
+ "它们仍会被加载,但组织页看不到它们——补一行登记,或确认它们不该在这里。",
317
+ ),
318
+ );
319
+ }
320
+ if (check.archived.length > 0) {
321
+ diagnostics.push(
322
+ diagnose(
323
+ "info",
324
+ "organization-roster-archived",
325
+ file,
326
+ `已归档但仍在库里的档案:${check.archived.join(", ")}。归档只是声明,不影响加载。`,
327
+ ),
328
+ );
329
+ }
330
+ return diagnostics;
331
+ }