@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.
- package/CHANGELOG.md +98 -1
- package/LICENSE +21 -21
- package/README.md +407 -319
- package/README.zh.md +95 -9
- package/bin/cli.mjs +10 -0
- package/index/registry.json +45 -1
- package/index.js +2 -2
- package/lib/bridge.js +89 -10
- package/lib/capabilities.js +434 -0
- package/lib/diff.js +216 -0
- package/lib/digest.js +65 -0
- package/lib/format.js +4 -5
- package/lib/library.js +5 -0
- package/lib/organization.js +331 -0
- package/lib/registry.js +209 -4
- package/lib/render.js +253 -7
- package/lib/tools.js +251 -23
- package/lib/verify.js +135 -10
- package/orgs/agent-roster-collective/api-design/roster.yaml +67 -0
- package/orgs/agent-roster-collective/content-ops/roster.yaml +55 -0
- package/orgs/agent-roster-collective/contract-review/roster.yaml +58 -0
- package/orgs/agent-roster-collective/data-quality/roster.yaml +64 -0
- package/orgs/agent-roster-collective/db-migration/roster.yaml +64 -0
- package/orgs/agent-roster-collective/docs-writer/roster.yaml +59 -0
- package/orgs/agent-roster-collective/finance-recon/roster.yaml +63 -0
- package/orgs/agent-roster-collective/frontend-ui/roster.yaml +60 -0
- package/orgs/agent-roster-collective/i18n-l10n/roster.yaml +60 -0
- package/orgs/agent-roster-collective/incident-response/roster.yaml +65 -0
- package/orgs/agent-roster-collective/literature-review/roster.yaml +61 -0
- package/orgs/agent-roster-collective/market-research/roster.yaml +60 -0
- package/orgs/agent-roster-collective/ml-eval/roster.yaml +63 -0
- package/orgs/agent-roster-collective/organization.yaml +118 -0
- package/orgs/agent-roster-collective/performance/roster.yaml +63 -0
- package/orgs/agent-roster-collective/product-discovery/roster.yaml +55 -0
- package/orgs/agent-roster-collective/qa-regression/roster.yaml +64 -0
- package/orgs/agent-roster-collective/recruiting/roster.yaml +58 -0
- package/orgs/agent-roster-collective/release-manager/roster.yaml +59 -0
- package/orgs/agent-roster-collective/security-audit/capabilities.yaml +12 -0
- package/orgs/agent-roster-collective/security-audit/roster.yaml +65 -0
- package/orgs/agent-roster-collective/sre-oncall/roster.yaml +60 -0
- package/orgs/agent-roster-collective/support-triage/roster.yaml +58 -0
- package/package.json +5 -1
- package/schema/capabilities.v1.schema.json +124 -0
- package/schema/organization.v1.schema.json +50 -0
- package/schema/registry.v1.schema.json +83 -0
package/lib/registry.js
CHANGED
|
@@ -24,7 +24,15 @@
|
|
|
24
24
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
25
25
|
import nodePath from "node:path";
|
|
26
26
|
|
|
27
|
+
import { CAPABILITIES_FILE_NAMES, validateCapabilities } from "./capabilities.js";
|
|
28
|
+
import { sha256Hex } from "./digest.js";
|
|
27
29
|
import { MEMBER_FILE_NAMES } from "./format.js";
|
|
30
|
+
import {
|
|
31
|
+
ORGANIZATION_FILE_NAMES,
|
|
32
|
+
checkOrganization,
|
|
33
|
+
organizationDiagnostics,
|
|
34
|
+
validateOrganization,
|
|
35
|
+
} from "./organization.js";
|
|
28
36
|
import { parseDocumentText } from "./parse.js";
|
|
29
37
|
import { validateRoster } from "./validate.js";
|
|
30
38
|
|
|
@@ -51,6 +59,13 @@ export function scanRosterLibrary({ rootDir, knownToolPolicies = null }) {
|
|
|
51
59
|
const diagnostics = [];
|
|
52
60
|
const entries = [];
|
|
53
61
|
const rosters = [];
|
|
62
|
+
/** 档案 id → 能力要求(`capabilities.yaml` 的解析结果)。 */
|
|
63
|
+
const capabilities = {};
|
|
64
|
+
/** 档案 id → 能力要求的文件名(诊断要指到具体那份文件)。 */
|
|
65
|
+
const capabilityFiles = {};
|
|
66
|
+
/** 组织档案(organization.yaml)与它的交叉核对结果。 */
|
|
67
|
+
let organization = null;
|
|
68
|
+
let organizationFile = null;
|
|
54
69
|
const stats = {
|
|
55
70
|
total: 0,
|
|
56
71
|
ok: 0,
|
|
@@ -76,6 +91,8 @@ export function scanRosterLibrary({ rootDir, knownToolPolicies = null }) {
|
|
|
76
91
|
if (child.name.startsWith(".") || SKIP_DIRS.has(child.name) || child.isSymbolicLink()) continue;
|
|
77
92
|
|
|
78
93
|
if (!child.isDirectory()) {
|
|
94
|
+
// 库根上的 organization.yaml 是合法的:它是这座库的目录页,不是散落的档案。
|
|
95
|
+
if (ORGANIZATION_FILE_NAMES.includes(child.name)) continue;
|
|
79
96
|
// 散落的 *.yaml 曾经是另一种布局;现在只认目录,必须明确告知而不是忽略。
|
|
80
97
|
diagnostics.push({
|
|
81
98
|
severity: "warn",
|
|
@@ -97,7 +114,15 @@ export function scanRosterLibrary({ rootDir, knownToolPolicies = null }) {
|
|
|
97
114
|
}
|
|
98
115
|
|
|
99
116
|
stats.total += 1;
|
|
100
|
-
const entry = { id: child.name, file: found.relative, roster: null };
|
|
117
|
+
const entry = { id: child.name, file: found.relative, roster: null, capabilities: null };
|
|
118
|
+
// 能力要求与档案分开加载:档案写错了不该顺带把"缺能力"这条信息也藏起来。
|
|
119
|
+
const wanted = loadCapabilities(dirPath, child.name);
|
|
120
|
+
diagnostics.push(...wanted.diagnostics);
|
|
121
|
+
if (wanted.capabilities !== null) {
|
|
122
|
+
entry.capabilities = wanted.capabilities;
|
|
123
|
+
capabilities[child.name] = wanted.capabilities;
|
|
124
|
+
capabilityFiles[child.name] = wanted.file;
|
|
125
|
+
}
|
|
101
126
|
const outcome = loadDocument(found, child.name, { knownToolPolicies });
|
|
102
127
|
diagnostics.push(...outcome.diagnostics);
|
|
103
128
|
if (outcome.ok) {
|
|
@@ -111,6 +136,17 @@ export function scanRosterLibrary({ rootDir, knownToolPolicies = null }) {
|
|
|
111
136
|
}
|
|
112
137
|
}
|
|
113
138
|
|
|
139
|
+
// 组织档案在目录扫完之后读:它的交叉核对需要知道磁盘上到底有哪些档案。
|
|
140
|
+
const wantedOrganization = loadOrganization(rootDir, nodePath.basename(rootDir));
|
|
141
|
+
organization = wantedOrganization.organization;
|
|
142
|
+
organizationFile = wantedOrganization.file;
|
|
143
|
+
diagnostics.push(...wantedOrganization.diagnostics);
|
|
144
|
+
const organizationCheck = checkOrganization(
|
|
145
|
+
organization,
|
|
146
|
+
rosters.map((item) => item.id),
|
|
147
|
+
);
|
|
148
|
+
diagnostics.push(...organizationDiagnostics(organization, organizationCheck, organizationFile ?? ORGANIZATION_FILE_NAMES[0]));
|
|
149
|
+
|
|
114
150
|
if (stats.total > 0 && rosters.length === 0) {
|
|
115
151
|
diagnostics.push({
|
|
116
152
|
severity: "error",
|
|
@@ -151,7 +187,19 @@ export function scanRosterLibrary({ rootDir, knownToolPolicies = null }) {
|
|
|
151
187
|
String(left.file ?? "").localeCompare(String(right.file ?? "")),
|
|
152
188
|
);
|
|
153
189
|
|
|
154
|
-
return {
|
|
190
|
+
return {
|
|
191
|
+
rootDir,
|
|
192
|
+
missing: childDirs === null,
|
|
193
|
+
entries,
|
|
194
|
+
rosters,
|
|
195
|
+
capabilities,
|
|
196
|
+
capabilityFiles,
|
|
197
|
+
organization,
|
|
198
|
+
organizationFile,
|
|
199
|
+
organizationCheck,
|
|
200
|
+
stats,
|
|
201
|
+
diagnostics,
|
|
202
|
+
};
|
|
155
203
|
}
|
|
156
204
|
|
|
157
205
|
/**
|
|
@@ -219,10 +267,10 @@ function locateDocument(dirPath, dirName, diagnostics) {
|
|
|
219
267
|
* @param {{ knownToolPolicies: string[] | null }} options
|
|
220
268
|
* @returns {{ ok: boolean, roster: object | null, diagnostics: object[] }}
|
|
221
269
|
*/
|
|
222
|
-
function loadDocument(found, expectedId, { knownToolPolicies }) {
|
|
270
|
+
function loadDocument(found, expectedId, { knownToolPolicies }, rawText = null) {
|
|
223
271
|
let raw;
|
|
224
272
|
try {
|
|
225
|
-
raw = readFileSync(found.absolute, "utf8");
|
|
273
|
+
raw = rawText === null ? readFileSync(found.absolute, "utf8") : rawText;
|
|
226
274
|
} catch (error) {
|
|
227
275
|
return {
|
|
228
276
|
ok: false,
|
|
@@ -256,6 +304,163 @@ function loadDocument(found, expectedId, { knownToolPolicies }) {
|
|
|
256
304
|
};
|
|
257
305
|
}
|
|
258
306
|
|
|
307
|
+
/**
|
|
308
|
+
* 读取档案目录里的能力要求兄弟文件(`capabilities.yaml|yml|json`)。
|
|
309
|
+
*
|
|
310
|
+
* 文件不存在是**正常状态**:没写能力要求就是不设要求,不产生任何诊断。
|
|
311
|
+
* 文件存在但校验不过时才记 error——那时档案本身仍然是好的,缺的是"要求"这份文档。
|
|
312
|
+
*
|
|
313
|
+
* @param {string} dirPath 档案目录
|
|
314
|
+
* @param {string} dirName 目录名(同时也是期望的 `roster` 字段)
|
|
315
|
+
* @returns {{ capabilities: object | null, file: string | null, diagnostics: object[] }}
|
|
316
|
+
*/
|
|
317
|
+
export function loadOrganization(rootDir, dirName) {
|
|
318
|
+
let names;
|
|
319
|
+
try {
|
|
320
|
+
names = readdirSync(rootDir);
|
|
321
|
+
} catch {
|
|
322
|
+
return { organization: null, file: null, diagnostics: [] };
|
|
323
|
+
}
|
|
324
|
+
const present = ORGANIZATION_FILE_NAMES.filter((name) => names.includes(name));
|
|
325
|
+
if (present.length === 0) return { organization: null, file: null, diagnostics: [] };
|
|
326
|
+
|
|
327
|
+
const diagnostics = [];
|
|
328
|
+
const relative = present[0];
|
|
329
|
+
if (present.length > 1) {
|
|
330
|
+
diagnostics.push({
|
|
331
|
+
severity: "warn",
|
|
332
|
+
code: "multiple-organization-files",
|
|
333
|
+
path: "",
|
|
334
|
+
file: relative,
|
|
335
|
+
reason:
|
|
336
|
+
`档案库同时存在 ${present.join("、")},只读 ${present[0]};` +
|
|
337
|
+
"请删掉多余文件,否则另一份永远不会生效。",
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
let raw;
|
|
342
|
+
try {
|
|
343
|
+
raw = readFileSync(nodePath.join(rootDir, relative), "utf8");
|
|
344
|
+
} catch (error) {
|
|
345
|
+
diagnostics.push({
|
|
346
|
+
severity: "error",
|
|
347
|
+
code: "unreadable-file",
|
|
348
|
+
path: "",
|
|
349
|
+
file: relative,
|
|
350
|
+
reason: `文件不可读:${messageOf(error)}`,
|
|
351
|
+
});
|
|
352
|
+
return { organization: null, file: relative, diagnostics };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const format = relative.endsWith(".json") ? "json" : "yaml";
|
|
356
|
+
const parsed = parseDocumentText(raw, { file: relative, format });
|
|
357
|
+
if (!parsed.ok) return { organization: null, file: relative, diagnostics: [...diagnostics, ...parsed.diagnostics] };
|
|
358
|
+
|
|
359
|
+
const validated = validateOrganization(parsed.value, { file: relative, expectedId: dirName });
|
|
360
|
+
diagnostics.push(...validated.diagnostics);
|
|
361
|
+
if (!validated.ok) return { organization: null, file: relative, diagnostics };
|
|
362
|
+
return { organization: validated.organization, file: relative, diagnostics };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function loadCapabilities(dirPath, dirName) {
|
|
366
|
+
let names;
|
|
367
|
+
try {
|
|
368
|
+
names = readdirSync(dirPath);
|
|
369
|
+
} catch {
|
|
370
|
+
return { capabilities: null, file: null, diagnostics: [] };
|
|
371
|
+
}
|
|
372
|
+
const present = CAPABILITIES_FILE_NAMES.filter((name) => names.includes(name));
|
|
373
|
+
if (present.length === 0) return { capabilities: null, file: null, diagnostics: [] };
|
|
374
|
+
|
|
375
|
+
const diagnostics = [];
|
|
376
|
+
const relative = `${dirName}/${present[0]}`;
|
|
377
|
+
if (present.length > 1) {
|
|
378
|
+
diagnostics.push({
|
|
379
|
+
severity: "warn",
|
|
380
|
+
code: "multiple-capabilities-files",
|
|
381
|
+
path: "",
|
|
382
|
+
file: relative,
|
|
383
|
+
reason:
|
|
384
|
+
`目录 "${dirName}" 同时存在 ${present.join("、")},只读 ${present[0]};` +
|
|
385
|
+
"请删掉多余文件,否则另一份永远不会生效。",
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
let raw;
|
|
390
|
+
try {
|
|
391
|
+
raw = readFileSync(nodePath.join(dirPath, present[0]), "utf8");
|
|
392
|
+
} catch (error) {
|
|
393
|
+
diagnostics.push({
|
|
394
|
+
severity: "error",
|
|
395
|
+
code: "unreadable-file",
|
|
396
|
+
path: "",
|
|
397
|
+
file: relative,
|
|
398
|
+
reason: `文件不可读:${messageOf(error)}`,
|
|
399
|
+
});
|
|
400
|
+
return { capabilities: null, file: relative, diagnostics };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const format = present[0].endsWith(".json") ? "json" : "yaml";
|
|
404
|
+
const parsed = parseDocumentText(raw, { file: relative, format });
|
|
405
|
+
if (!parsed.ok) return { capabilities: null, file: relative, diagnostics: [...diagnostics, ...parsed.diagnostics] };
|
|
406
|
+
|
|
407
|
+
const validated = validateCapabilities(parsed.value, { file: relative, expectedId: dirName });
|
|
408
|
+
diagnostics.push(...validated.diagnostics);
|
|
409
|
+
if (!validated.ok) return { capabilities: null, file: relative, diagnostics };
|
|
410
|
+
return { capabilities: validated.capabilities, file: relative, diagnostics };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* 从任意目录加载**一份**档案,并带出它的文件路径与内容哈希。
|
|
415
|
+
*
|
|
416
|
+
* 两个用途共用这一个入口:
|
|
417
|
+
* - 溯源串要把"档案文件内容的 sha256"写进团队 description(`roster:<id>@<version>#<sha8>`);
|
|
418
|
+
* - 换代对比(`roster_diff`)要能收任意目录,而不是只有 `rosterDirs[0]`。
|
|
419
|
+
*
|
|
420
|
+
* 目录布局兼容两种:目录本身就是一个档案目录(里面有 `roster.yaml|yml|json`),
|
|
421
|
+
* 或者是一个档案库目录下的 `<id>/`。`expectedId` 传 `null` 表示"目录名不必等于档案 id"。
|
|
422
|
+
*
|
|
423
|
+
* @param {string} dirPath 档案目录的绝对路径
|
|
424
|
+
* @param {{ expectedId?: string | null, knownToolPolicies?: string[] | null }} [options]
|
|
425
|
+
* @returns {{ ok: boolean, roster: object | null, diagnostics: object[], file: string | null,
|
|
426
|
+
* relative: string | null, contentSha: string | null }}
|
|
427
|
+
*/
|
|
428
|
+
export function loadRosterAt(dirPath, options = {}) {
|
|
429
|
+
const diagnostics = [];
|
|
430
|
+
const expectedId =
|
|
431
|
+
typeof options.expectedId === "string" && options.expectedId !== "" ? options.expectedId : null;
|
|
432
|
+
const dirName = expectedId ?? nodePath.basename(dirPath);
|
|
433
|
+
const found = locateDocument(dirPath, dirName, diagnostics);
|
|
434
|
+
if (found === null) return { ok: false, roster: null, diagnostics, file: null, relative: null, contentSha: null };
|
|
435
|
+
|
|
436
|
+
let raw;
|
|
437
|
+
try {
|
|
438
|
+
raw = readFileSync(found.absolute, "utf8");
|
|
439
|
+
} catch (error) {
|
|
440
|
+
diagnostics.push({
|
|
441
|
+
severity: "error",
|
|
442
|
+
code: "unreadable-file",
|
|
443
|
+
path: "",
|
|
444
|
+
file: found.relative,
|
|
445
|
+
reason: `文件不可读:${messageOf(error)}`,
|
|
446
|
+
});
|
|
447
|
+
return { ok: false, roster: null, diagnostics, file: found.absolute, relative: found.relative, contentSha: null };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const outcome = loadDocument(found, expectedId, { knownToolPolicies: options.knownToolPolicies ?? null }, raw);
|
|
451
|
+
const wanted = loadCapabilities(dirPath, dirName);
|
|
452
|
+
return {
|
|
453
|
+
ok: outcome.ok,
|
|
454
|
+
roster: outcome.roster,
|
|
455
|
+
capabilities: wanted.capabilities,
|
|
456
|
+
capabilitiesFile: wanted.file,
|
|
457
|
+
diagnostics: [...diagnostics, ...wanted.diagnostics, ...outcome.diagnostics],
|
|
458
|
+
file: found.absolute,
|
|
459
|
+
relative: found.relative,
|
|
460
|
+
contentSha: sha256Hex(raw),
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
259
464
|
/**
|
|
260
465
|
* 定位一份档案或其中某个成员。
|
|
261
466
|
*
|
package/lib/render.js
CHANGED
|
@@ -8,6 +8,44 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { BINDING_STATUS, describeField, summarizeBinding } from "./bridge.js";
|
|
10
10
|
import { LIMITS } from "./format.js";
|
|
11
|
+
import { memberVerdict } from "./verify.js";
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
/** 核对结论的人读标签。 */
|
|
15
|
+
const VERDICT_LABEL = { faithful: "一致", unverified: "未核实", drift: "降级", missing: "未部署" };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 一行工具过滤器摘要。`null` 表示 `team.json` 里没有物化后的 deny 列表——
|
|
19
|
+
* 那不是"一致",只是读不到,所以照实写"无法核对"。
|
|
20
|
+
*
|
|
21
|
+
* @param {{ deny: number, allow: number, sha: string } | null} digest
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
function toolFilterLine(digest) {
|
|
25
|
+
if (digest === null) return " toolFilter 无法核对(team.json 没有物化后的 deny 列表)";
|
|
26
|
+
return ` toolFilter deny ${digest.deny} · allow ${digest.allow} · sha ${digest.sha}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 一句人读的溯源结论。
|
|
31
|
+
*
|
|
32
|
+
* @param {object} item `verifyBindings` 的 provenance 项
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
function provenanceText(item) {
|
|
36
|
+
switch (item.status) {
|
|
37
|
+
case "absent":
|
|
38
|
+
return "description 里没有溯源串(建团队时把 roster_create 给的那一行写进 description 才有出处)";
|
|
39
|
+
case "unknown-roster":
|
|
40
|
+
return `声明 ${item.declared},但本机没有这份档案`;
|
|
41
|
+
case "id-only":
|
|
42
|
+
return `声明 ${item.declared},没有内容哈希,只能核对 id 与版本`;
|
|
43
|
+
case "match":
|
|
44
|
+
return `${item.declared} · 与当前档案内容一致`;
|
|
45
|
+
default:
|
|
46
|
+
return `${item.declared} · 档案文件内容已变(当前 #${item.currentSha})`;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
11
49
|
|
|
12
50
|
/**
|
|
13
51
|
* 诊断列表 → 文本块。错误在前、按文件分组,超出上限时明确写出省略了多少条。
|
|
@@ -51,6 +89,21 @@ export function renderRosterList(scan) {
|
|
|
51
89
|
const { entries, stats, rootDir } = scan;
|
|
52
90
|
const lines = [];
|
|
53
91
|
lines.push(`档案库 ${rootDir}`);
|
|
92
|
+
const organization = scan.organization ?? null;
|
|
93
|
+
if (organization !== null) {
|
|
94
|
+
const check = scan.organizationCheck ?? { declared: 0, present: 0, missing: [], undeclared: [], archived: [] };
|
|
95
|
+
lines.push(`组织 ${organization.id} ${organization.title}`);
|
|
96
|
+
if (organization.description !== "") lines.push(` ${organization.description}`);
|
|
97
|
+
const parts = [
|
|
98
|
+
`登记 ${check.declared}`,
|
|
99
|
+
`在库 ${check.present}`,
|
|
100
|
+
`缺 ${check.missing.length}`,
|
|
101
|
+
`未登记 ${check.undeclared.length}`,
|
|
102
|
+
];
|
|
103
|
+
if (check.archived.length > 0) parts.push(`已归档 ${check.archived.length}`);
|
|
104
|
+
if (organization.maintainers.length > 0) parts.push(`维护者 ${organization.maintainers.join(", ")}`);
|
|
105
|
+
lines.push(` ${parts.join(" · ")}`);
|
|
106
|
+
}
|
|
54
107
|
if (entries.length === 0) {
|
|
55
108
|
lines.push("");
|
|
56
109
|
lines.push(scan.missing === true ? "(目录尚未创建:保存第一份档案时会自动建)" : "(空:没有任何 roster 目录)");
|
|
@@ -210,9 +263,11 @@ export function renderVerifyReport(report, options = {}) {
|
|
|
210
263
|
}
|
|
211
264
|
|
|
212
265
|
for (const [key, group] of byRoster) {
|
|
213
|
-
const faithful = group.filter((check) => check.found && check.faithful).length;
|
|
214
266
|
const found = group.filter((check) => check.found).length;
|
|
215
|
-
|
|
267
|
+
const faithful = group.filter((check) => memberVerdict(check) === "faithful").length;
|
|
268
|
+
const unverified = group.filter((check) => memberVerdict(check) === "unverified").length;
|
|
269
|
+
const suffix = unverified === 0 ? "" : ` · 未核实 ${unverified}`;
|
|
270
|
+
lines.push(`roster:${key} 成员 ${group.length} · 已部署 ${found} · 一致 ${faithful}${suffix}`);
|
|
216
271
|
for (const check of group) {
|
|
217
272
|
if (!check.found) {
|
|
218
273
|
lines.push("");
|
|
@@ -220,11 +275,12 @@ export function renderVerifyReport(report, options = {}) {
|
|
|
220
275
|
continue;
|
|
221
276
|
}
|
|
222
277
|
lines.push("");
|
|
223
|
-
const verdict = check
|
|
224
|
-
lines.push(`▸ ${check.member} @ ${check.teamName} ${verdict}`);
|
|
278
|
+
const verdict = memberVerdict(check);
|
|
279
|
+
lines.push(`▸ ${check.member} @ ${check.teamName} ${VERDICT_LABEL[verdict]}`);
|
|
225
280
|
if (typeof check.matchedAs === "string" && check.matchedAs !== check.member) {
|
|
226
281
|
lines.push(` (运行时以旧名 "${check.matchedAs}" 记录,来自 aliases)`);
|
|
227
282
|
}
|
|
283
|
+
lines.push(toolFilterLine(check.toolFilter));
|
|
228
284
|
for (const field of check.binding.fields) {
|
|
229
285
|
if (field.status === "pending") continue;
|
|
230
286
|
lines.push(` ${describeField(field)}`);
|
|
@@ -232,7 +288,7 @@ export function renderVerifyReport(report, options = {}) {
|
|
|
232
288
|
if (!check.faithful) {
|
|
233
289
|
const summary = check.summary;
|
|
234
290
|
lines.push(
|
|
235
|
-
` →
|
|
291
|
+
` → ${verdict === "unverified" ? "无法核对" : "降级"}字段:覆盖 ${summary.counts.overridden ?? 0} · 未留痕 ${summary.counts["not-persisted"] ?? 0} · 未声明 ${summary.counts.omitted ?? 0} · 无法核对 ${summary.counts.unconfirmed ?? 0}`,
|
|
236
292
|
);
|
|
237
293
|
}
|
|
238
294
|
}
|
|
@@ -248,10 +304,33 @@ export function renderVerifyReport(report, options = {}) {
|
|
|
248
304
|
lines.push("");
|
|
249
305
|
}
|
|
250
306
|
|
|
307
|
+
const drift = report.policyDrift ?? [];
|
|
308
|
+
if (drift.length > 0) {
|
|
309
|
+
lines.push("策略漂移(同一个 toolPolicy 被物化成了不同的 deny 列表)");
|
|
310
|
+
for (const item of drift) {
|
|
311
|
+
lines.push(` · ${item.toolPolicy} @ ${item.teamName}:${item.variants.length} 种物化结果`);
|
|
312
|
+
for (const variant of item.variants) {
|
|
313
|
+
lines.push(` deny ${variant.deny} · sha ${variant.sha} · ${variant.members.length} 人(${variant.members.join(", ")})`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
lines.push("");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const provenance = report.provenance ?? [];
|
|
320
|
+
if (provenance.length > 0) {
|
|
321
|
+
lines.push("团队溯源(团队由哪一版档案建成;插件只读 description)");
|
|
322
|
+
for (const item of provenance) lines.push(` · ${item.teamName}:${provenanceText(item)}`);
|
|
323
|
+
lines.push("");
|
|
324
|
+
}
|
|
325
|
+
|
|
251
326
|
const summary = report.summary;
|
|
252
327
|
lines.push(
|
|
253
|
-
|
|
328
|
+
`汇总:忠实 ${summary.faithful} · 未核实 ${summary.unverified} · 降级 ${summary.drift} · 未部署 ${summary.missing} · 无档案 ${summary.unmanaged}`,
|
|
254
329
|
);
|
|
330
|
+
if (summary.unverified > 0) {
|
|
331
|
+
lines.push("(未核实 = 档案声明了值、运行时却不持久化该字段,例如人设 executionPrompt;这不是降级,但也拿不到回执。)");
|
|
332
|
+
}
|
|
333
|
+
if (summary.policyDrift > 0) lines.push(`(另有 ${summary.policyDrift} 处策略漂移、${summary.provenanceDiffer} 处溯源自建档案后已变。)`);
|
|
255
334
|
|
|
256
335
|
const skills = (report.skills ?? []).filter(
|
|
257
336
|
(item) => rosterIds.size === 0 || rosterIds.has(item.rosterId),
|
|
@@ -272,7 +351,68 @@ export function renderVerifyReport(report, options = {}) {
|
|
|
272
351
|
}
|
|
273
352
|
|
|
274
353
|
/**
|
|
275
|
-
*
|
|
354
|
+
* 档案差异(`roster_diff`)的人读渲染。
|
|
355
|
+
*
|
|
356
|
+
* @param {object} diff `diffRosters` 的结果
|
|
357
|
+
* @param {{ from: object, to: object }} labels 两端的档案标识(`{ id, version, sha }`)
|
|
358
|
+
* @returns {string}
|
|
359
|
+
*/
|
|
360
|
+
export function renderRosterDiff(diff, labels) {
|
|
361
|
+
const lines = [];
|
|
362
|
+
lines.push(`档案差异 ${rosterLabel(labels.from)} → ${rosterLabel(labels.to)}`);
|
|
363
|
+
lines.push("(比的是有效值:defaults 已合进成员;成员按 name 对齐,aliases 不自动合并)");
|
|
364
|
+
|
|
365
|
+
if (diff.rosters.length === 0 && diff.counts.added === 0 && diff.counts.removed === 0 && diff.counts.changed === 0) {
|
|
366
|
+
lines.push("");
|
|
367
|
+
lines.push("两份档案没有语义差别。");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (diff.rosters.length > 0) {
|
|
371
|
+
lines.push("");
|
|
372
|
+
lines.push("档案字段");
|
|
373
|
+
for (const change of diff.rosters) {
|
|
374
|
+
lines.push(` · ${change.field} ${change.from} → ${change.to}`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
lines.push("");
|
|
379
|
+
lines.push(`成员 增 ${diff.counts.added} · 删 ${diff.counts.removed} · 改 ${diff.counts.changed}`);
|
|
380
|
+
for (const name of diff.members.added) lines.push(` + ${name} (新一代新增,旧团队里没有)`);
|
|
381
|
+
for (const name of diff.members.removed) lines.push(` - ${name} (新一代已移除,旧团队里还在)`);
|
|
382
|
+
for (const change of diff.members.changed) {
|
|
383
|
+
lines.push(` · ${change.name}`);
|
|
384
|
+
for (const field of change.fields) {
|
|
385
|
+
const origin = field.origin === undefined ? "" : ` [${field.origin}]`;
|
|
386
|
+
lines.push(` ${field.field} ${field.from} → ${field.to}${origin}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (diff.skills.length > 0) {
|
|
391
|
+
lines.push("");
|
|
392
|
+
lines.push("技能(声明层:不会因为档案写了就加载)");
|
|
393
|
+
for (const change of diff.skills) {
|
|
394
|
+
const parts = [];
|
|
395
|
+
if (change.added.length > 0) parts.push(`新增 ${change.added.join(", ")}`);
|
|
396
|
+
if (change.removed.length > 0) parts.push(`移除 ${change.removed.join(", ")}`);
|
|
397
|
+
lines.push(` · ${change.member} ${parts.join(";")}`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return lines.join("\n");
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* `roster:<id>@<ver>[#sha8]`。
|
|
405
|
+
*
|
|
406
|
+
* @param {{ id: string, version: string, sha: string | null }} label
|
|
407
|
+
* @returns {string}
|
|
408
|
+
*/
|
|
409
|
+
function rosterLabel(label) {
|
|
410
|
+
const sha = typeof label.sha === "string" && label.sha !== "" ? `#${label.sha.slice(0, 8)}` : "";
|
|
411
|
+
return `roster:${label.id}@${label.version}${sha}`;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* 队长段:说明"能声明什么,真要生效要改哪里"。
|
|
276
416
|
*
|
|
277
417
|
* 队长不是成员——它就是创建团队的那个会话。这里绝不写成"档案已配置队长",
|
|
278
418
|
* 否则使用者会以为改档案就能换队长模型。
|
|
@@ -366,3 +506,109 @@ function firstLines(text, maxLines, maxChars) {
|
|
|
366
506
|
const clipped = lines.length > maxChars ? `${lines.slice(0, maxChars)}…` : lines;
|
|
367
507
|
return clipped === "" ? "(空)" : clipped;
|
|
368
508
|
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* 一份档案的「能力要求」声明块(`roster_show` 用)。
|
|
512
|
+
*
|
|
513
|
+
* 只陈述"要求什么、缺失时怎么办、什么时候核过",不评价是否满足——满足与否是核对器的事,
|
|
514
|
+
* 两件事混在一段文字里,读者永远分不清哪句是声明、哪句是判定。
|
|
515
|
+
*
|
|
516
|
+
* @param {object} roster 已校验档案
|
|
517
|
+
* @param {object} capabilities `capabilities.yaml` 的解析结果
|
|
518
|
+
* @returns {string}
|
|
519
|
+
*/
|
|
520
|
+
export function renderCapabilityRequirements(roster, capabilities) {
|
|
521
|
+
const requires = capabilities?.requires ?? { skills: [], toolPolicies: [] };
|
|
522
|
+
const lines = [
|
|
523
|
+
`能力要求 ${roster.id} onMissing=${capabilities?.onMissing ?? "refuse"}` +
|
|
524
|
+
`${capabilities?.asOf === null || capabilities?.asOf === undefined ? "" : ` asOf=${capabilities.asOf}`}`,
|
|
525
|
+
];
|
|
526
|
+
const sections = [
|
|
527
|
+
["技能", requires.skills ?? []],
|
|
528
|
+
["工具策略", requires.toolPolicies ?? []],
|
|
529
|
+
];
|
|
530
|
+
const total = sections.reduce((sum, [, items]) => sum + items.length, 0);
|
|
531
|
+
if (total === 0) {
|
|
532
|
+
lines.push(" (没有声明任何能力要求)");
|
|
533
|
+
} else {
|
|
534
|
+
for (const [label, items] of sections) {
|
|
535
|
+
if (items.length === 0) continue;
|
|
536
|
+
lines.push(` ${label} ${items.length} 项`);
|
|
537
|
+
for (const item of items) {
|
|
538
|
+
lines.push(` · ${item.name}${item.reason === null || item.reason === undefined ? "" : ` — ${item.reason}`}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
if (capabilities?.notes) lines.push(` 说明 ${capabilities.notes}`);
|
|
543
|
+
lines.push(" 只声明不承载:能力本体在技能目录与策略定义里,这份文件只回答「需要什么」。");
|
|
544
|
+
return lines.join("\n");
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* 拒绝出参数时的说明(`roster_create` 的闸门)。
|
|
549
|
+
*
|
|
550
|
+
* 三件事必须同时说清:缺什么、缺的那项该装到哪、以及"核对不到"为什么不算缺。
|
|
551
|
+
* 只说"缺少能力"等于把问题原样丢回给调用者。
|
|
552
|
+
*
|
|
553
|
+
* @param {object} roster 已校验档案
|
|
554
|
+
* @param {object} gate `checkCapabilities().entries` 的单条结果
|
|
555
|
+
* @param {Record<string, { label: string, install: string }>} hints 类别 → 人话名与安装位置
|
|
556
|
+
* @returns {string}
|
|
557
|
+
*/
|
|
558
|
+
export function renderCapabilityGate(roster, gate, hints) {
|
|
559
|
+
const lines = [
|
|
560
|
+
`能力闸门 ${roster.id} 缺 ${gate.missing.length} 项,未生成建队参数(onMissing=${gate.onMissing})`,
|
|
561
|
+
];
|
|
562
|
+
for (const item of gate.missing) {
|
|
563
|
+
const hint = hints[item.kind] ?? { label: item.kind, install: "(无已知安装位置)" };
|
|
564
|
+
lines.push(` ✖ ${hint.label} ${item.name}${item.reason ? ` — ${item.reason}` : ""}`);
|
|
565
|
+
lines.push(` 怎么补:${hint.install}`);
|
|
566
|
+
}
|
|
567
|
+
if (gate.unknown.length > 0) {
|
|
568
|
+
lines.push(
|
|
569
|
+
` ? 核对不到 ${gate.unknown.length} 项(不算缺失,也不会被当成通过):` +
|
|
570
|
+
gate.unknown.map((item) => item.name).join("、"),
|
|
571
|
+
);
|
|
572
|
+
lines.push(" 原因通常是宿主没挂技能服务,或本插件没配 knownToolPolicies。");
|
|
573
|
+
}
|
|
574
|
+
return lines.join("\n");
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* 多份档案的能力核对汇总(`roster_verify` 用)。
|
|
579
|
+
*
|
|
580
|
+
* @param {object[]} entries `checkCapabilities().entries`
|
|
581
|
+
* @param {Record<string, { label: string }>} hints 类别 → 人话名
|
|
582
|
+
* @returns {string}
|
|
583
|
+
*/
|
|
584
|
+
export function renderCapabilityReport(entries, hints) {
|
|
585
|
+
const total = { declared: 0, missing: 0, unknown: 0, blocked: 0 };
|
|
586
|
+
const lines = [];
|
|
587
|
+
for (const entry of entries) {
|
|
588
|
+
total.declared += entry.requires;
|
|
589
|
+
total.missing += entry.missing.length;
|
|
590
|
+
total.unknown += entry.unknown.length;
|
|
591
|
+
if (entry.blocked) total.blocked += 1;
|
|
592
|
+
}
|
|
593
|
+
lines.push(
|
|
594
|
+
`能力要求 要求 ${total.declared} · 缺 ${total.missing} · 核对不到 ${total.unknown} · 拦下 ${total.blocked} 份档案`,
|
|
595
|
+
);
|
|
596
|
+
for (const entry of entries) {
|
|
597
|
+
const detail =
|
|
598
|
+
entry.requires === 0
|
|
599
|
+
? "未声明要求"
|
|
600
|
+
: entry.missing.length > 0
|
|
601
|
+
? `缺 ${entry.missing.map((item) => `${hints[item.kind]?.label ?? item.kind}:${item.name}`).join("、")}`
|
|
602
|
+
: entry.unknown.length > 0
|
|
603
|
+
? `核对不到 ${entry.unknown.map((item) => item.name).join("、")}`
|
|
604
|
+
: `要求 ${entry.requires} 项全部在位`;
|
|
605
|
+
lines.push(
|
|
606
|
+
` ${entry.rosterId.padEnd(24)} ${entry.status.padEnd(9)} ${detail}` +
|
|
607
|
+
` onMissing=${entry.onMissing}${entry.asOf === null ? "" : ` asOf=${entry.asOf}`}`,
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
if (total.unknown > 0) {
|
|
611
|
+
lines.push(" 核对不到 ≠ 缺失:没有证据源时既不翻红,也不冒充通过。");
|
|
612
|
+
}
|
|
613
|
+
return lines.join("\n");
|
|
614
|
+
}
|