@stofone/dsh-agent-roster 0.6.1 → 0.7.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/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/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,
package/lib/registry.js CHANGED
@@ -24,6 +24,8 @@
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";
28
30
  import { parseDocumentText } from "./parse.js";
29
31
  import { validateRoster } from "./validate.js";
@@ -51,6 +53,10 @@ export function scanRosterLibrary({ rootDir, knownToolPolicies = null }) {
51
53
  const diagnostics = [];
52
54
  const entries = [];
53
55
  const rosters = [];
56
+ /** 档案 id → 能力要求(`capabilities.yaml` 的解析结果)。 */
57
+ const capabilities = {};
58
+ /** 档案 id → 能力要求的文件名(诊断要指到具体那份文件)。 */
59
+ const capabilityFiles = {};
54
60
  const stats = {
55
61
  total: 0,
56
62
  ok: 0,
@@ -97,7 +103,15 @@ export function scanRosterLibrary({ rootDir, knownToolPolicies = null }) {
97
103
  }
98
104
 
99
105
  stats.total += 1;
100
- const entry = { id: child.name, file: found.relative, roster: null };
106
+ const entry = { id: child.name, file: found.relative, roster: null, capabilities: null };
107
+ // 能力要求与档案分开加载:档案写错了不该顺带把"缺能力"这条信息也藏起来。
108
+ const wanted = loadCapabilities(dirPath, child.name);
109
+ diagnostics.push(...wanted.diagnostics);
110
+ if (wanted.capabilities !== null) {
111
+ entry.capabilities = wanted.capabilities;
112
+ capabilities[child.name] = wanted.capabilities;
113
+ capabilityFiles[child.name] = wanted.file;
114
+ }
101
115
  const outcome = loadDocument(found, child.name, { knownToolPolicies });
102
116
  diagnostics.push(...outcome.diagnostics);
103
117
  if (outcome.ok) {
@@ -151,7 +165,7 @@ export function scanRosterLibrary({ rootDir, knownToolPolicies = null }) {
151
165
  String(left.file ?? "").localeCompare(String(right.file ?? "")),
152
166
  );
153
167
 
154
- return { rootDir, missing: childDirs === null, entries, rosters, stats, diagnostics };
168
+ return { rootDir, missing: childDirs === null, entries, rosters, capabilities, capabilityFiles, stats, diagnostics };
155
169
  }
156
170
 
157
171
  /**
@@ -219,10 +233,10 @@ function locateDocument(dirPath, dirName, diagnostics) {
219
233
  * @param {{ knownToolPolicies: string[] | null }} options
220
234
  * @returns {{ ok: boolean, roster: object | null, diagnostics: object[] }}
221
235
  */
222
- function loadDocument(found, expectedId, { knownToolPolicies }) {
236
+ function loadDocument(found, expectedId, { knownToolPolicies }, rawText = null) {
223
237
  let raw;
224
238
  try {
225
- raw = readFileSync(found.absolute, "utf8");
239
+ raw = rawText === null ? readFileSync(found.absolute, "utf8") : rawText;
226
240
  } catch (error) {
227
241
  return {
228
242
  ok: false,
@@ -256,6 +270,115 @@ function loadDocument(found, expectedId, { knownToolPolicies }) {
256
270
  };
257
271
  }
258
272
 
273
+ /**
274
+ * 读取档案目录里的能力要求兄弟文件(`capabilities.yaml|yml|json`)。
275
+ *
276
+ * 文件不存在是**正常状态**:没写能力要求就是不设要求,不产生任何诊断。
277
+ * 文件存在但校验不过时才记 error——那时档案本身仍然是好的,缺的是"要求"这份文档。
278
+ *
279
+ * @param {string} dirPath 档案目录
280
+ * @param {string} dirName 目录名(同时也是期望的 `roster` 字段)
281
+ * @returns {{ capabilities: object | null, file: string | null, diagnostics: object[] }}
282
+ */
283
+ export function loadCapabilities(dirPath, dirName) {
284
+ let names;
285
+ try {
286
+ names = readdirSync(dirPath);
287
+ } catch {
288
+ return { capabilities: null, file: null, diagnostics: [] };
289
+ }
290
+ const present = CAPABILITIES_FILE_NAMES.filter((name) => names.includes(name));
291
+ if (present.length === 0) return { capabilities: null, file: null, diagnostics: [] };
292
+
293
+ const diagnostics = [];
294
+ const relative = `${dirName}/${present[0]}`;
295
+ if (present.length > 1) {
296
+ diagnostics.push({
297
+ severity: "warn",
298
+ code: "multiple-capabilities-files",
299
+ path: "",
300
+ file: relative,
301
+ reason:
302
+ `目录 "${dirName}" 同时存在 ${present.join("、")},只读 ${present[0]};` +
303
+ "请删掉多余文件,否则另一份永远不会生效。",
304
+ });
305
+ }
306
+
307
+ let raw;
308
+ try {
309
+ raw = readFileSync(nodePath.join(dirPath, present[0]), "utf8");
310
+ } catch (error) {
311
+ diagnostics.push({
312
+ severity: "error",
313
+ code: "unreadable-file",
314
+ path: "",
315
+ file: relative,
316
+ reason: `文件不可读:${messageOf(error)}`,
317
+ });
318
+ return { capabilities: null, file: relative, diagnostics };
319
+ }
320
+
321
+ const format = present[0].endsWith(".json") ? "json" : "yaml";
322
+ const parsed = parseDocumentText(raw, { file: relative, format });
323
+ if (!parsed.ok) return { capabilities: null, file: relative, diagnostics: [...diagnostics, ...parsed.diagnostics] };
324
+
325
+ const validated = validateCapabilities(parsed.value, { file: relative, expectedId: dirName });
326
+ diagnostics.push(...validated.diagnostics);
327
+ if (!validated.ok) return { capabilities: null, file: relative, diagnostics };
328
+ return { capabilities: validated.capabilities, file: relative, diagnostics };
329
+ }
330
+
331
+ /**
332
+ * 从任意目录加载**一份**档案,并带出它的文件路径与内容哈希。
333
+ *
334
+ * 两个用途共用这一个入口:
335
+ * - 溯源串要把"档案文件内容的 sha256"写进团队 description(`roster:<id>@<version>#<sha8>`);
336
+ * - 换代对比(`roster_diff`)要能收任意目录,而不是只有 `rosterDirs[0]`。
337
+ *
338
+ * 目录布局兼容两种:目录本身就是一个档案目录(里面有 `roster.yaml|yml|json`),
339
+ * 或者是一个档案库目录下的 `<id>/`。`expectedId` 传 `null` 表示"目录名不必等于档案 id"。
340
+ *
341
+ * @param {string} dirPath 档案目录的绝对路径
342
+ * @param {{ expectedId?: string | null, knownToolPolicies?: string[] | null }} [options]
343
+ * @returns {{ ok: boolean, roster: object | null, diagnostics: object[], file: string | null,
344
+ * relative: string | null, contentSha: string | null }}
345
+ */
346
+ export function loadRosterAt(dirPath, options = {}) {
347
+ const diagnostics = [];
348
+ const expectedId =
349
+ typeof options.expectedId === "string" && options.expectedId !== "" ? options.expectedId : null;
350
+ const dirName = expectedId ?? nodePath.basename(dirPath);
351
+ const found = locateDocument(dirPath, dirName, diagnostics);
352
+ if (found === null) return { ok: false, roster: null, diagnostics, file: null, relative: null, contentSha: null };
353
+
354
+ let raw;
355
+ try {
356
+ raw = readFileSync(found.absolute, "utf8");
357
+ } catch (error) {
358
+ diagnostics.push({
359
+ severity: "error",
360
+ code: "unreadable-file",
361
+ path: "",
362
+ file: found.relative,
363
+ reason: `文件不可读:${messageOf(error)}`,
364
+ });
365
+ return { ok: false, roster: null, diagnostics, file: found.absolute, relative: found.relative, contentSha: null };
366
+ }
367
+
368
+ const outcome = loadDocument(found, expectedId, { knownToolPolicies: options.knownToolPolicies ?? null }, raw);
369
+ const wanted = loadCapabilities(dirPath, dirName);
370
+ return {
371
+ ok: outcome.ok,
372
+ roster: outcome.roster,
373
+ capabilities: wanted.capabilities,
374
+ capabilitiesFile: wanted.file,
375
+ diagnostics: [...diagnostics, ...wanted.diagnostics, ...outcome.diagnostics],
376
+ file: found.absolute,
377
+ relative: found.relative,
378
+ contentSha: sha256Hex(raw),
379
+ };
380
+ }
381
+
259
382
  /**
260
383
  * 定位一份档案或其中某个成员。
261
384
  *
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
  * 诊断列表 → 文本块。错误在前、按文件分组,超出上限时明确写出省略了多少条。
@@ -210,9 +248,11 @@ export function renderVerifyReport(report, options = {}) {
210
248
  }
211
249
 
212
250
  for (const [key, group] of byRoster) {
213
- const faithful = group.filter((check) => check.found && check.faithful).length;
214
251
  const found = group.filter((check) => check.found).length;
215
- lines.push(`roster:${key} 成员 ${group.length} · 已部署 ${found} · 一致 ${faithful}`);
252
+ const faithful = group.filter((check) => memberVerdict(check) === "faithful").length;
253
+ const unverified = group.filter((check) => memberVerdict(check) === "unverified").length;
254
+ const suffix = unverified === 0 ? "" : ` · 未核实 ${unverified}`;
255
+ lines.push(`roster:${key} 成员 ${group.length} · 已部署 ${found} · 一致 ${faithful}${suffix}`);
216
256
  for (const check of group) {
217
257
  if (!check.found) {
218
258
  lines.push("");
@@ -220,11 +260,12 @@ export function renderVerifyReport(report, options = {}) {
220
260
  continue;
221
261
  }
222
262
  lines.push("");
223
- const verdict = check.faithful ? "一致" : "降级";
224
- lines.push(`▸ ${check.member} @ ${check.teamName} ${verdict}`);
263
+ const verdict = memberVerdict(check);
264
+ lines.push(`▸ ${check.member} @ ${check.teamName} ${VERDICT_LABEL[verdict]}`);
225
265
  if (typeof check.matchedAs === "string" && check.matchedAs !== check.member) {
226
266
  lines.push(` (运行时以旧名 "${check.matchedAs}" 记录,来自 aliases)`);
227
267
  }
268
+ lines.push(toolFilterLine(check.toolFilter));
228
269
  for (const field of check.binding.fields) {
229
270
  if (field.status === "pending") continue;
230
271
  lines.push(` ${describeField(field)}`);
@@ -232,7 +273,7 @@ export function renderVerifyReport(report, options = {}) {
232
273
  if (!check.faithful) {
233
274
  const summary = check.summary;
234
275
  lines.push(
235
- ` → 降级字段:覆盖 ${summary.counts.overridden ?? 0} · 缺失 ${summary.counts.omitted ?? 0} · 无法核对 ${summary.counts.unconfirmed ?? 0}`,
276
+ ` → ${verdict === "unverified" ? "无法核对" : "降级"}字段:覆盖 ${summary.counts.overridden ?? 0} · 未留痕 ${summary.counts["not-persisted"] ?? 0} · 未声明 ${summary.counts.omitted ?? 0} · 无法核对 ${summary.counts.unconfirmed ?? 0}`,
236
277
  );
237
278
  }
238
279
  }
@@ -248,10 +289,33 @@ export function renderVerifyReport(report, options = {}) {
248
289
  lines.push("");
249
290
  }
250
291
 
292
+ const drift = report.policyDrift ?? [];
293
+ if (drift.length > 0) {
294
+ lines.push("策略漂移(同一个 toolPolicy 被物化成了不同的 deny 列表)");
295
+ for (const item of drift) {
296
+ lines.push(` · ${item.toolPolicy} @ ${item.teamName}:${item.variants.length} 种物化结果`);
297
+ for (const variant of item.variants) {
298
+ lines.push(` deny ${variant.deny} · sha ${variant.sha} · ${variant.members.length} 人(${variant.members.join(", ")})`);
299
+ }
300
+ }
301
+ lines.push("");
302
+ }
303
+
304
+ const provenance = report.provenance ?? [];
305
+ if (provenance.length > 0) {
306
+ lines.push("团队溯源(团队由哪一版档案建成;插件只读 description)");
307
+ for (const item of provenance) lines.push(` · ${item.teamName}:${provenanceText(item)}`);
308
+ lines.push("");
309
+ }
310
+
251
311
  const summary = report.summary;
252
312
  lines.push(
253
- `汇总:一致 ${summary.faithful} · 降级 ${summary.degraded} · 未部署 ${summary.missing} · 无档案 ${summary.unmanaged}`,
313
+ `汇总:忠实 ${summary.faithful} · 未核实 ${summary.unverified} · 降级 ${summary.drift} · 未部署 ${summary.missing} · 无档案 ${summary.unmanaged}`,
254
314
  );
315
+ if (summary.unverified > 0) {
316
+ lines.push("(未核实 = 档案声明了值、运行时却不持久化该字段,例如人设 executionPrompt;这不是降级,但也拿不到回执。)");
317
+ }
318
+ if (summary.policyDrift > 0) lines.push(`(另有 ${summary.policyDrift} 处策略漂移、${summary.provenanceDiffer} 处溯源自建档案后已变。)`);
255
319
 
256
320
  const skills = (report.skills ?? []).filter(
257
321
  (item) => rosterIds.size === 0 || rosterIds.has(item.rosterId),
@@ -272,7 +336,68 @@ export function renderVerifyReport(report, options = {}) {
272
336
  }
273
337
 
274
338
  /**
275
- * 队长段:说明"能声明什么、真要生效要改哪里"。
339
+ * 档案差异(`roster_diff`)的人读渲染。
340
+ *
341
+ * @param {object} diff `diffRosters` 的结果
342
+ * @param {{ from: object, to: object }} labels 两端的档案标识(`{ id, version, sha }`)
343
+ * @returns {string}
344
+ */
345
+ export function renderRosterDiff(diff, labels) {
346
+ const lines = [];
347
+ lines.push(`档案差异 ${rosterLabel(labels.from)} → ${rosterLabel(labels.to)}`);
348
+ lines.push("(比的是有效值:defaults 已合进成员;成员按 name 对齐,aliases 不自动合并)");
349
+
350
+ if (diff.rosters.length === 0 && diff.counts.added === 0 && diff.counts.removed === 0 && diff.counts.changed === 0) {
351
+ lines.push("");
352
+ lines.push("两份档案没有语义差别。");
353
+ }
354
+
355
+ if (diff.rosters.length > 0) {
356
+ lines.push("");
357
+ lines.push("档案字段");
358
+ for (const change of diff.rosters) {
359
+ lines.push(` · ${change.field} ${change.from} → ${change.to}`);
360
+ }
361
+ }
362
+
363
+ lines.push("");
364
+ lines.push(`成员 增 ${diff.counts.added} · 删 ${diff.counts.removed} · 改 ${diff.counts.changed}`);
365
+ for (const name of diff.members.added) lines.push(` + ${name} (新一代新增,旧团队里没有)`);
366
+ for (const name of diff.members.removed) lines.push(` - ${name} (新一代已移除,旧团队里还在)`);
367
+ for (const change of diff.members.changed) {
368
+ lines.push(` · ${change.name}`);
369
+ for (const field of change.fields) {
370
+ const origin = field.origin === undefined ? "" : ` [${field.origin}]`;
371
+ lines.push(` ${field.field} ${field.from} → ${field.to}${origin}`);
372
+ }
373
+ }
374
+
375
+ if (diff.skills.length > 0) {
376
+ lines.push("");
377
+ lines.push("技能(声明层:不会因为档案写了就加载)");
378
+ for (const change of diff.skills) {
379
+ const parts = [];
380
+ if (change.added.length > 0) parts.push(`新增 ${change.added.join(", ")}`);
381
+ if (change.removed.length > 0) parts.push(`移除 ${change.removed.join(", ")}`);
382
+ lines.push(` · ${change.member} ${parts.join(";")}`);
383
+ }
384
+ }
385
+ return lines.join("\n");
386
+ }
387
+
388
+ /**
389
+ * `roster:<id>@<ver>[#sha8]`。
390
+ *
391
+ * @param {{ id: string, version: string, sha: string | null }} label
392
+ * @returns {string}
393
+ */
394
+ function rosterLabel(label) {
395
+ const sha = typeof label.sha === "string" && label.sha !== "" ? `#${label.sha.slice(0, 8)}` : "";
396
+ return `roster:${label.id}@${label.version}${sha}`;
397
+ }
398
+
399
+ /**
400
+ * 队长段:说明"能声明什么,真要生效要改哪里"。
276
401
  *
277
402
  * 队长不是成员——它就是创建团队的那个会话。这里绝不写成"档案已配置队长",
278
403
  * 否则使用者会以为改档案就能换队长模型。
@@ -366,3 +491,109 @@ function firstLines(text, maxLines, maxChars) {
366
491
  const clipped = lines.length > maxChars ? `${lines.slice(0, maxChars)}…` : lines;
367
492
  return clipped === "" ? "(空)" : clipped;
368
493
  }
494
+
495
+ /**
496
+ * 一份档案的「能力要求」声明块(`roster_show` 用)。
497
+ *
498
+ * 只陈述"要求什么、缺失时怎么办、什么时候核过",不评价是否满足——满足与否是核对器的事,
499
+ * 两件事混在一段文字里,读者永远分不清哪句是声明、哪句是判定。
500
+ *
501
+ * @param {object} roster 已校验档案
502
+ * @param {object} capabilities `capabilities.yaml` 的解析结果
503
+ * @returns {string}
504
+ */
505
+ export function renderCapabilityRequirements(roster, capabilities) {
506
+ const requires = capabilities?.requires ?? { skills: [], toolPolicies: [] };
507
+ const lines = [
508
+ `能力要求 ${roster.id} onMissing=${capabilities?.onMissing ?? "refuse"}` +
509
+ `${capabilities?.asOf === null || capabilities?.asOf === undefined ? "" : ` asOf=${capabilities.asOf}`}`,
510
+ ];
511
+ const sections = [
512
+ ["技能", requires.skills ?? []],
513
+ ["工具策略", requires.toolPolicies ?? []],
514
+ ];
515
+ const total = sections.reduce((sum, [, items]) => sum + items.length, 0);
516
+ if (total === 0) {
517
+ lines.push(" (没有声明任何能力要求)");
518
+ } else {
519
+ for (const [label, items] of sections) {
520
+ if (items.length === 0) continue;
521
+ lines.push(` ${label} ${items.length} 项`);
522
+ for (const item of items) {
523
+ lines.push(` · ${item.name}${item.reason === null || item.reason === undefined ? "" : ` — ${item.reason}`}`);
524
+ }
525
+ }
526
+ }
527
+ if (capabilities?.notes) lines.push(` 说明 ${capabilities.notes}`);
528
+ lines.push(" 只声明不承载:能力本体在技能目录与策略定义里,这份文件只回答「需要什么」。");
529
+ return lines.join("\n");
530
+ }
531
+
532
+ /**
533
+ * 拒绝出参数时的说明(`roster_create` 的闸门)。
534
+ *
535
+ * 三件事必须同时说清:缺什么、缺的那项该装到哪、以及"核对不到"为什么不算缺。
536
+ * 只说"缺少能力"等于把问题原样丢回给调用者。
537
+ *
538
+ * @param {object} roster 已校验档案
539
+ * @param {object} gate `checkCapabilities().entries` 的单条结果
540
+ * @param {Record<string, { label: string, install: string }>} hints 类别 → 人话名与安装位置
541
+ * @returns {string}
542
+ */
543
+ export function renderCapabilityGate(roster, gate, hints) {
544
+ const lines = [
545
+ `能力闸门 ${roster.id} 缺 ${gate.missing.length} 项,未生成建队参数(onMissing=${gate.onMissing})`,
546
+ ];
547
+ for (const item of gate.missing) {
548
+ const hint = hints[item.kind] ?? { label: item.kind, install: "(无已知安装位置)" };
549
+ lines.push(` ✖ ${hint.label} ${item.name}${item.reason ? ` — ${item.reason}` : ""}`);
550
+ lines.push(` 怎么补:${hint.install}`);
551
+ }
552
+ if (gate.unknown.length > 0) {
553
+ lines.push(
554
+ ` ? 核对不到 ${gate.unknown.length} 项(不算缺失,也不会被当成通过):` +
555
+ gate.unknown.map((item) => item.name).join("、"),
556
+ );
557
+ lines.push(" 原因通常是宿主没挂技能服务,或本插件没配 knownToolPolicies。");
558
+ }
559
+ return lines.join("\n");
560
+ }
561
+
562
+ /**
563
+ * 多份档案的能力核对汇总(`roster_verify` 用)。
564
+ *
565
+ * @param {object[]} entries `checkCapabilities().entries`
566
+ * @param {Record<string, { label: string }>} hints 类别 → 人话名
567
+ * @returns {string}
568
+ */
569
+ export function renderCapabilityReport(entries, hints) {
570
+ const total = { declared: 0, missing: 0, unknown: 0, blocked: 0 };
571
+ const lines = [];
572
+ for (const entry of entries) {
573
+ total.declared += entry.requires;
574
+ total.missing += entry.missing.length;
575
+ total.unknown += entry.unknown.length;
576
+ if (entry.blocked) total.blocked += 1;
577
+ }
578
+ lines.push(
579
+ `能力要求 要求 ${total.declared} · 缺 ${total.missing} · 核对不到 ${total.unknown} · 拦下 ${total.blocked} 份档案`,
580
+ );
581
+ for (const entry of entries) {
582
+ const detail =
583
+ entry.requires === 0
584
+ ? "未声明要求"
585
+ : entry.missing.length > 0
586
+ ? `缺 ${entry.missing.map((item) => `${hints[item.kind]?.label ?? item.kind}:${item.name}`).join("、")}`
587
+ : entry.unknown.length > 0
588
+ ? `核对不到 ${entry.unknown.map((item) => item.name).join("、")}`
589
+ : `要求 ${entry.requires} 项全部在位`;
590
+ lines.push(
591
+ ` ${entry.rosterId.padEnd(24)} ${entry.status.padEnd(9)} ${detail}` +
592
+ ` onMissing=${entry.onMissing}${entry.asOf === null ? "" : ` asOf=${entry.asOf}`}`,
593
+ );
594
+ }
595
+ if (total.unknown > 0) {
596
+ lines.push(" 核对不到 ≠ 缺失:没有证据源时既不翻红,也不冒充通过。");
597
+ }
598
+ return lines.join("\n");
599
+ }