@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/tools.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
- * 五个模型可见工具:列档案、看成员、生成桥接调用、核对运行时、读公共档案源。
3
- * (roster_list / roster_show / roster_create / roster_verify / roster_index)
2
+ * 六个模型可见工具:列档案、看成员、生成桥接调用、核对运行时、比对两代档案、读公共档案源。
3
+ * (roster_list / roster_show / roster_create / roster_verify / roster_diff / roster_index)
4
4
  *
5
- * 只读优先:`roster_list` / `roster_show` / `roster_verify` 只读文件系统;
5
+ * 只读优先:`roster_list` / `roster_show` / `roster_verify` / `roster_diff` 只读文件系统;
6
6
  * `roster_create` 也不写任何东西——它输出的是**参数**,真正的成员创建仍由队长调用
7
7
  * dsh-agent-teams 的工具完成。插件不替运行时做决定,也不在背后改别人的状态。
8
8
  *
@@ -10,15 +10,29 @@
10
10
  */
11
11
  import { defineTool } from "@deepseek-ai/dsh-tools";
12
12
 
13
- import { buildMemberBinding, describeField, summarizeBinding } from "./bridge.js";
13
+ import nodePath from "node:path";
14
+
15
+ import { buildMemberBinding, describeField, provenanceLine, summarizeBinding } from "./bridge.js";
16
+ import { CAPABILITY_HINTS, capabilityRequirements, checkCapabilities } from "./capabilities.js";
17
+ import { diffRosters } from "./diff.js";
14
18
  import { LIMITS } from "./format.js";
15
19
  import { resolveDirSpec } from "./home.js";
16
20
  import { lossless } from "./json.js";
17
- import { scanRosterLibrary, findRoster } from "./registry.js";
18
- import { renderBridgePlan, renderCaptainVerification, renderRosterDetail, renderRosterList, renderVerifyReport } from "./render.js";
21
+ import { findRoster, loadRosterAt, scanRosterLibrary } from "./registry.js";
22
+ import {
23
+ renderBridgePlan,
24
+ renderCapabilityGate,
25
+ renderCapabilityReport,
26
+ renderCapabilityRequirements,
27
+ renderCaptainVerification,
28
+ renderRosterDetail,
29
+ renderRosterDiff,
30
+ renderRosterList,
31
+ renderVerifyReport,
32
+ } from "./render.js";
19
33
  import { readDeployedCaptains, verifyCaptain } from "./captain.js";
20
34
  import { readTeamStates, resolveStateDir } from "./teams-state.js";
21
- import { filterChecks, verifyBindings } from "./verify.js";
35
+ import { filterChecks, memberVerdict, verifyBindings } from "./verify.js";
22
36
 
23
37
  /** 工具输出统一的对象 schema:结构字段给程序,`text` 给人读。 */
24
38
  const OUTPUT = { type: "object", additionalProperties: true };
@@ -47,6 +61,14 @@ export function registerRosterTools(ctx, config, meta) {
47
61
  // args.path 是显式的档案库覆盖;没有就给配置里的 rosterDirs[0]。
48
62
  const resolve = (exec, override) => resolvePaths(config, exec, override);
49
63
  const scanAt = (rootDir) => scanRosterLibrary({ rootDir, knownToolPolicies: config.knownToolPolicies ?? null });
64
+ // 档案文件内容的哈希:只有重新读一次那份文件才拿得到(扫描结果里不带字节)。
65
+ const shaOf = (rootDir, roster) => {
66
+ const loaded = loadRosterAt(nodePath.join(rootDir, roster.id), {
67
+ expectedId: roster.id,
68
+ knownToolPolicies: config.knownToolPolicies ?? null,
69
+ });
70
+ return loaded.ok && typeof loaded.contentSha === "string" ? loaded.contentSha : null;
71
+ };
50
72
 
51
73
  ctx.effect(
52
74
  () =>
@@ -72,6 +94,7 @@ export function registerRosterTools(ctx, config, meta) {
72
94
  const text = [
73
95
  statusLine("roster_list", {
74
96
  rootDir: rosterDir,
97
+ organization: scan.organization === null ? "none" : scan.organization.id,
75
98
  total: scan.stats.total,
76
99
  ok: scan.stats.ok,
77
100
  failed: scan.stats.failed,
@@ -84,6 +107,8 @@ export function registerRosterTools(ctx, config, meta) {
84
107
  workspace,
85
108
  rootDir: rosterDir,
86
109
  stats: scan.stats,
110
+ organization: scan.organization,
111
+ organizationCheck: scan.organizationCheck,
87
112
  rosters: scan.rosters.map((roster) => ({
88
113
  id: roster.id,
89
114
  version: roster.version,
@@ -126,7 +151,7 @@ export function registerRosterTools(ctx, config, meta) {
126
151
  },
127
152
  output: { schema: OUTPUT, render: (_args, value) => [{ type: "text", text: value.text }] },
128
153
  async execute(args, exec) {
129
- const { rosterDir } = resolve(exec, args.path);
154
+ const { rosterDir, workspace } = resolve(exec, args.path);
130
155
  const scan = scanAt(rosterDir);
131
156
  const hit = findRoster(scan.rosters, args.ref);
132
157
  if (hit === null) {
@@ -163,16 +188,20 @@ export function registerRosterTools(ctx, config, meta) {
163
188
  const { binding } = buildMemberBinding(roster, member);
164
189
  return { member, fields: binding.fields, summary: summarizeBinding(binding) };
165
190
  });
166
- const text = [
191
+ const capabilities = scan.capabilities?.[roster.id] ?? null;
192
+ const blocks = [
167
193
  statusLine("roster_show", {
168
194
  found: true,
169
195
  roster: roster.id,
170
196
  version: roster.version,
171
197
  members: members.length,
172
198
  matchedBy: hit.via,
199
+ requires: capabilityRequirements(capabilities).length,
173
200
  }),
174
201
  renderRosterDetail(roster, only),
175
- ].join("\n\n");
202
+ ];
203
+ if (capabilities !== null) blocks.push(renderCapabilityRequirements(roster, capabilities));
204
+ const text = blocks.join("\n\n");
176
205
  return lossless({
177
206
  found: true,
178
207
  matchedBy: hit.via,
@@ -202,6 +231,7 @@ export function registerRosterTools(ctx, config, meta) {
202
231
  bindingSummary: summary,
203
232
  })),
204
233
  captain: roster.captain ?? null,
234
+ capabilities,
205
235
  diagnostics: scan.diagnostics,
206
236
  text,
207
237
  });
@@ -238,7 +268,7 @@ export function registerRosterTools(ctx, config, meta) {
238
268
  },
239
269
  output: { schema: OUTPUT, render: (_args, value) => [{ type: "text", text: value.text }] },
240
270
  async execute(args, exec) {
241
- const { rosterDir } = resolve(exec, args.path);
271
+ const { rosterDir, workspace } = resolve(exec, args.path);
242
272
  const scan = scanAt(rosterDir);
243
273
  const hit = findRoster(scan.rosters, args.ref);
244
274
  const fail = (message) =>
@@ -259,7 +289,44 @@ export function registerRosterTools(ctx, config, meta) {
259
289
  `档案 ${roster.id} 里没有成员 ${[...(wanted ?? [])].join(", ")};可用:${roster.members.map((m) => m.name).join(", ")}`,
260
290
  );
261
291
  }
292
+ // 能力闸门:档案只声明"要用到什么",这里核对"这台机器有没有"。
293
+ // 缺了就拒绝出参数(onMissing 默认 refuse)——先建了再补,等于让假绿换个位置住下来。
294
+ const capabilities = scan.capabilities?.[roster.id] ?? null;
295
+ let gate = null;
296
+ if (capabilities !== null) {
297
+ const knownSkills = await readKnownSkills(ctx, workspace);
298
+ gate = checkCapabilities([roster], { [roster.id]: capabilities }, {
299
+ knownSkills,
300
+ knownToolPolicies: config.knownToolPolicies ?? null,
301
+ }).entries[0] ?? null;
302
+ if (gate !== null && gate.blocked) {
303
+ return lossless({
304
+ ok: false,
305
+ blocked: true,
306
+ rosterId: roster.id,
307
+ rosterVersion: roster.version,
308
+ capabilities: gate,
309
+ diagnostics: scan.diagnostics,
310
+ text: [
311
+ statusLine("roster_create", {
312
+ ok: false,
313
+ blocked: true,
314
+ roster: roster.id,
315
+ missing: gate.missing.length,
316
+ }),
317
+ renderCapabilityGate(roster, gate, CAPABILITY_HINTS),
318
+ "档案没有被改动;补齐上面列出的能力后重新调用即可。\n" +
319
+ "若确有应急需要(先有人干活),在该档案目录的 capabilities.yaml 里写 onMissing: degraded——" +
320
+ "但那会在每次核对时留下 \"degraded\" 记录,不会被当成通过。",
321
+ ].join("\n\n"),
322
+ });
323
+ }
324
+ }
262
325
  const entries = selected.map((member) => ({ member, ...buildMemberBinding(roster, member) }));
326
+ // 团队状态里没有"由哪一版档案建成",只能把出处写进 description。
327
+ // 插件仍然不写 team.json:它只给出建议的那一行,由队长传进 agent_teams_create。
328
+ const contentSha = shaOf(rosterDir, roster);
329
+ const provenance = provenanceLine(roster, contentSha ?? undefined);
263
330
  const text = [
264
331
  statusLine("roster_create", {
265
332
  ok: true,
@@ -268,11 +335,19 @@ export function registerRosterTools(ctx, config, meta) {
268
335
  calls: entries.length,
269
336
  }),
270
337
  renderBridgePlan({ roster, entries }),
271
- ].join("\n\n");
338
+ `建团队时把这一行放进 agent_teams_create 的 description(插件不写团队状态):\n ${provenance}\n` +
339
+ "之后 roster_verify 会把团队声明的这一行与当前档案内容比对;对不上就说明档案在建团队之后改过。",
340
+ ];
341
+ if (gate !== null && gate.missing.length > 0) {
342
+ text.push(renderCapabilityGate(roster, gate, CAPABILITY_HINTS));
343
+ }
344
+ const textBlocks = text.join("\n\n");
272
345
  return lossless({
273
346
  ok: true,
274
347
  rosterId: roster.id,
275
348
  rosterVersion: roster.version,
349
+ provenance: { line: provenance, rosterId: roster.id, version: roster.version, contentSha },
350
+ capabilities: gate,
276
351
  calls: entries.map((entry) => ({ tool: "agent_teams_add_member", arguments: entry.args })),
277
352
  planMembers: entries.map((entry) => entry.planMember),
278
353
  bindings: entries.map((entry) => ({
@@ -282,7 +357,7 @@ export function registerRosterTools(ctx, config, meta) {
282
357
  })),
283
358
  nextStep: "把每个 calls[].arguments 原样传给 agent_teams_add_member;随后用 roster_verify 核对运行时实际值",
284
359
  diagnostics: scan.diagnostics,
285
- text,
360
+ text: textBlocks,
286
361
  });
287
362
  },
288
363
  presentCall: (args) => ({ card: "generic", title: `桥接档案 ${args.ref}` }),
@@ -350,7 +425,17 @@ export function registerRosterTools(ctx, config, meta) {
350
425
  const state = readTeamStates({ stateDir, teamId: args.team });
351
426
  // 技能是声明层:只有宿主真的能列出技能目录时才核对,否则如实记成"无法核对"。
352
427
  const knownSkills = await readKnownSkills(ctx, workspace);
353
- let report = verifyBindings(rosters, state.teams, { knownSkills });
428
+ const rosterShas = {};
429
+ for (const roster of rosters) {
430
+ const sha = shaOf(rosterDir, roster);
431
+ if (sha !== null) rosterShas[roster.id] = sha;
432
+ }
433
+ let report = verifyBindings(rosters, state.teams, {
434
+ knownSkills,
435
+ rosterShas,
436
+ knownToolPolicies: config.knownToolPolicies ?? null,
437
+ capabilitiesById: scan.capabilities ?? {},
438
+ });
354
439
  // 队长不是成员行:只能拿实际的部署配置去比档案里的声明,读不到就记成“无法核对”。
355
440
  const deployedCaptains = readDeployedCaptains();
356
441
  const captainReports = rosters
@@ -359,21 +444,39 @@ export function registerRosterTools(ctx, config, meta) {
359
444
  if (Array.isArray(args.members) && args.members.length > 0) {
360
445
  report = filterChecks(report, args.members);
361
446
  }
362
- const ok = report.summary.degraded === 0 && state.available;
447
+ // `ok` 只判"部署侧有没有漂移"。人设拿不到回执是上游 team.json 不持久化该键,
448
+ // 那不是部署做错了——所以它单列成 unverified,绝不混进 ok,也不许悄悄消失。
449
+ // 能力缺失只有在 onMissing: refuse 时才翻红:degraded 是"明知缺、仍要建"的显式选择,
450
+ // 那笔账记在 summary.capabilityMissing 里,不该被伪装成通过,也不该被伪装成失败。
451
+ const ok =
452
+ report.summary.drift === 0 &&
453
+ report.summary.missing === 0 &&
454
+ report.summary.capabilityBlocked === 0 &&
455
+ state.available;
456
+ const summary = report.summary;
363
457
  const text = [
364
458
  statusLine("roster_verify", {
365
459
  ok,
366
460
  state: state.available ? "available" : "unavailable",
367
- members: report.summary.members,
368
- deployed: report.summary.deployed,
369
- faithful: report.summary.faithful,
370
- degraded: report.summary.degraded,
371
- missing: report.summary.missing,
372
- unmanaged: report.summary.unmanaged,
373
- skills: `${report.summary.skillsMissing}/${report.summary.skillsDeclared}`,
461
+ members: summary.members,
462
+ deployed: summary.deployed,
463
+ faithful: summary.faithful,
464
+ unverified: summary.unverified,
465
+ drift: summary.drift,
466
+ missing: summary.missing,
467
+ unmanaged: summary.unmanaged,
468
+ toolFilter: `${summary.toolFilterChecked}/${summary.toolFilterChecked + summary.toolFilterMissing}`,
469
+ policyDrift: summary.policyDrift,
470
+ provenance: `${summary.provenanceAbsent}absent/${summary.provenanceDiffer}differ`,
471
+ skills: `${summary.skillsMissing}/${summary.skillsDeclared}`,
472
+ requires: `${summary.capabilityMissing}missing/${summary.capabilityDeclared}`,
473
+ capabilityBlocked: summary.capabilityBlocked,
374
474
  captain: captainReports.length,
375
475
  }),
376
476
  renderVerifyReport(report, { rosterIds: refRosterIds }),
477
+ ...(report.capabilities.entries.length === 0
478
+ ? []
479
+ : [renderCapabilityReport(report.capabilities.entries, CAPABILITY_HINTS)]),
377
480
  ...(captainReports.length === 0
378
481
  ? []
379
482
  : [
@@ -385,7 +488,13 @@ export function registerRosterTools(ctx, config, meta) {
385
488
  return lossless({
386
489
  ok,
387
490
  stateDir,
388
- teams: state.teams.map((team) => ({ id: team.id, name: team.name, phase: team.phase, file: team.file })),
491
+ teams: state.teams.map((team) => ({
492
+ id: team.id,
493
+ name: team.name,
494
+ phase: team.phase,
495
+ file: team.file,
496
+ description: typeof team.description === "string" ? team.description : "",
497
+ })),
389
498
  summary: report.summary,
390
499
  checks: report.checks.map((check) => ({
391
500
  rosterId: check.rosterId,
@@ -394,15 +503,21 @@ export function registerRosterTools(ctx, config, meta) {
394
503
  found: check.found,
395
504
  teamId: check.teamId,
396
505
  faithful: check.faithful,
506
+ verdict: memberVerdict(check),
507
+ toolPolicy: check.toolPolicy ?? null,
508
+ toolFilter: check.toolFilter ?? null,
397
509
  fields: check.binding === null ? [] : check.binding.fields,
398
510
  })),
399
511
  unmanaged: report.unmanaged,
512
+ policyDrift: report.policyDrift,
513
+ provenance: report.provenance,
400
514
  captain: captainReports.map(({ roster, verified }) => ({
401
515
  rosterId: roster.id,
402
516
  ok: verified.ok,
403
517
  checks: verified.checks,
404
518
  })),
405
519
  skills: report.skills,
520
+ capabilities: report.capabilities,
406
521
  diagnostics: [...scan.diagnostics, ...state.diagnostics],
407
522
  text,
408
523
  });
@@ -414,6 +529,119 @@ export function registerRosterTools(ctx, config, meta) {
414
529
  "dsh-agent-roster: roster_verify",
415
530
  );
416
531
 
532
+ ctx.effect(
533
+ () =>
534
+ ctx.tools.register(
535
+ defineTool({
536
+ name: "roster_diff",
537
+ description:
538
+ "比较两份档案的语义差异:换班时回答\"新一代改了什么\"。" +
539
+ "成员按 name 对齐(aliases 只作提示,不自动合并,改名会显示成删一个、加一个);" +
540
+ "比的是有效值(defaults 已合进成员),并标注这个值来自成员还是 defaults。" +
541
+ "人设只比长度与内容哈希,不把整段文字贴出来。本工具只读,不写任何东西。",
542
+ parameters: {
543
+ from: { type: "string", required: true, description: "旧档案所在目录:档案目录本身,或包含它的档案库目录" },
544
+ to: { type: "string", required: true, description: "新档案所在目录:档案目录本身,或包含它的档案库目录" },
545
+ id: {
546
+ type: "string",
547
+ description: "目录里有不止一份档案时,指明要比较哪一份(档案 id);省略时要求目录里恰好只有一份",
548
+ },
549
+ },
550
+ output: { schema: OUTPUT, render: (_args, value) => [{ type: "text", text: value.text }] },
551
+ async execute(args, exec) {
552
+ const { workspace } = resolve(exec, undefined);
553
+ const fail = (message, diagnostics = []) =>
554
+ lossless({
555
+ ok: false,
556
+ text: [statusLine("roster_diff", { ok: false, from: args.from, to: args.to }), message].join("\n\n"),
557
+ diagnostics,
558
+ });
559
+
560
+ const load = (spec) => {
561
+ const dir = resolveDirSpec(spec, workspace);
562
+ const direct = loadRosterAt(dir, { knownToolPolicies: config.knownToolPolicies ?? null });
563
+ if (direct.ok) return { ok: true, dir, roster: direct.roster, contentSha: direct.contentSha, diagnostics: direct.diagnostics };
564
+ const scan = scanRosterLibrary({ rootDir: dir, knownToolPolicies: config.knownToolPolicies ?? null });
565
+ const candidates = args.id === undefined ? scan.rosters : scan.rosters.filter((item) => item.id === args.id);
566
+ const diagnostics = [...direct.diagnostics, ...scan.diagnostics];
567
+ if (candidates.length === 0) {
568
+ return {
569
+ ok: false,
570
+ dir,
571
+ diagnostics,
572
+ reason:
573
+ `目录 "${dir}" 里没有可用的档案` +
574
+ (args.id === undefined ? "。" : `(id=${args.id})。`) +
575
+ "请指向档案目录本身,或指向包含它的档案库目录。",
576
+ };
577
+ }
578
+ if (candidates.length > 1) {
579
+ return {
580
+ ok: false,
581
+ dir,
582
+ diagnostics,
583
+ reason: `目录 "${dir}" 里有 ${candidates.length} 份档案,请用 id 指明要比较哪一份:${candidates.map((item) => item.id).join(", ")}`,
584
+ };
585
+ }
586
+ const only = candidates[0];
587
+ const loaded = loadRosterAt(nodePath.join(dir, only.id), {
588
+ expectedId: only.id,
589
+ knownToolPolicies: config.knownToolPolicies ?? null,
590
+ });
591
+ return {
592
+ ok: loaded.ok,
593
+ dir,
594
+ roster: loaded.roster,
595
+ contentSha: loaded.contentSha,
596
+ diagnostics: [...diagnostics, ...loaded.diagnostics],
597
+ reason: loaded.ok ? undefined : `档案 ${only.id} 校验不通过,无法比较。`,
598
+ };
599
+ };
600
+
601
+ const left = load(args.from);
602
+ if (left.ok !== true) return fail(left.reason ?? "旧档案读不出来。", left.diagnostics);
603
+ const right = load(args.to);
604
+ if (right.ok !== true) return fail(right.reason ?? "新档案读不出来。", right.diagnostics);
605
+
606
+ const diff = diffRosters(left.roster, right.roster);
607
+ const labels = {
608
+ from: { id: left.roster.id, version: left.roster.version, sha: left.contentSha },
609
+ to: { id: right.roster.id, version: right.roster.version, sha: right.contentSha },
610
+ };
611
+ const sameFile =
612
+ typeof left.contentSha === "string" && left.contentSha === right.contentSha
613
+ ? "(两份是逐字节相同的文件)"
614
+ : "";
615
+ const text = [
616
+ statusLine("roster_diff", {
617
+ ok: true,
618
+ from: `${left.roster.id}@${left.roster.version}`,
619
+ to: `${right.roster.id}@${right.roster.version}`,
620
+ added: diff.counts.added,
621
+ removed: diff.counts.removed,
622
+ changed: diff.counts.changed,
623
+ }),
624
+ renderRosterDiff(diff, labels) + (sameFile === "" ? "" : `\n${sameFile}`),
625
+ ].join("\n\n");
626
+ return lossless({
627
+ ok: true,
628
+ from: labels.from,
629
+ to: labels.to,
630
+ rosters: diff.rosters,
631
+ members: diff.members,
632
+ skills: diff.skills,
633
+ counts: diff.counts,
634
+ diagnostics: [...left.diagnostics, ...right.diagnostics],
635
+ text,
636
+ });
637
+ },
638
+ presentCall: (args) => ({ card: "generic", title: `比较档案 ${args.from} → ${args.to}` }),
639
+ presentResult: (_args, result) => ({ card: "generic", title: result.isError ? "比较失败" : "档案差异" }),
640
+ }),
641
+ ),
642
+ "dsh-agent-roster: roster_diff",
643
+ );
644
+
417
645
  ctx.effect(
418
646
  () =>
419
647
  ctx.tools.register(
package/lib/verify.js CHANGED
@@ -10,23 +10,44 @@
10
10
  * - `skills` 是声明层字段,运行时不会帮你加载技能;宿主把技能目录传进来时,
11
11
  * 这里只负责回答"声明了但没装"这一件事,不假装已生效。
12
12
  *
13
+ * 两条可见性规则(issue #2):
14
+ * - 每个成员带出 `toolFilter` 的规范化摘要。只比 `toolPolicy` 这个 id 的话,
15
+ * 部署侧改了策略定义、旧成员还拿着旧列表时,核对永远是绿的。
16
+ * - 每个团队带出溯源结论。`team.json` 没有"由哪一版档案建成"的字段,只能读 description
17
+ * 里 `roster:<id>@<version>#<sha8>` 这一行,再与档案文件现在的哈希对比。
18
+ *
13
19
  * @module dsh-agent-roster/verify
14
20
  */
15
- import { buildMemberBinding, isBindingFaithful, reconcileBinding, summarizeBinding } from "./bridge.js";
21
+ import {
22
+ buildMemberBinding,
23
+ isBindingFaithful,
24
+ parseProvenanceLine,
25
+ reconcileBinding,
26
+ summarizeBinding,
27
+ } from "./bridge.js";
28
+ import { checkCapabilities } from "./capabilities.js";
29
+ import { shortSha, toolFilterDigest } from "./digest.js";
16
30
 
17
31
  /**
18
32
  * 逐个成员核对档案与团队状态。
19
33
  *
20
34
  * @param {object[]} rosters 已校验档案
21
35
  * @param {object[]} teams `readTeamStates` 产出的团队记录
22
- * @param {{ knownSkills?: string[] }} [options] 宿主已知技能目录;缺省表示无法核对技能
23
- * @returns {{ checks: object[], unmanaged: object[], skills: object[], summary: object }}
36
+ * @param {{ knownSkills?: string[], rosterShas?: Record<string, string>,
37
+ * knownToolPolicies?: string[], capabilitiesById?: Record<string, object> }} [options]
38
+ * `knownSkills`:宿主已知技能目录,缺省表示无法核对技能;
39
+ * `rosterShas`:档案 id → 档案文件内容的 sha256,用于核对团队溯源串;
40
+ * `knownToolPolicies`:部署已声明的策略 id,缺省表示无法核对能力要求里的策略;
41
+ * `capabilitiesById`:档案 id → 能力要求(`capabilities.yaml`),缺省表示没有声明要求。
42
+ * @returns {{ checks: object[], unmanaged: object[], skills: object[], policyDrift: object[],
43
+ * provenance: object[], capabilities: { entries: object[], summary: object }, summary: object }}
24
44
  */
25
45
  export function verifyBindings(rosters, teams, options = {}) {
26
46
  const knownSkills =
27
47
  Array.isArray(options.knownSkills) && options.knownSkills.length > 0
28
48
  ? new Set(options.knownSkills.filter((name) => typeof name === "string" && name !== ""))
29
49
  : null;
50
+ const rosterShas = options.rosterShas ?? {};
30
51
 
31
52
  const checks = [];
32
53
  const managedNames = new Set();
@@ -56,6 +77,8 @@ export function verifyBindings(rosters, teams, options = {}) {
56
77
  teamName: null,
57
78
  matchedAs: null,
58
79
  binding: null,
80
+ toolPolicy: "",
81
+ toolFilter: null,
59
82
  faithful: false,
60
83
  summary: null,
61
84
  });
@@ -84,6 +107,8 @@ export function verifyBindings(rosters, teams, options = {}) {
84
107
  teamName: team.name,
85
108
  matchedAs: teamMember.name,
86
109
  binding,
110
+ toolPolicy: typeof teamMember.toolPolicy === "string" ? teamMember.toolPolicy : "",
111
+ toolFilter: toolFilterDigest(teamMember.toolFilter),
87
112
  faithful: isBindingFaithful(binding),
88
113
  summary: summarizeBinding(binding),
89
114
  });
@@ -122,19 +147,113 @@ export function verifyBindings(rosters, teams, options = {}) {
122
147
  }
123
148
  }
124
149
 
150
+ // 同一个 toolPolicy 在同一支团队里必须物化成同一份 deny 列表。
151
+ // 这条不变量今天成立,所以它恰恰最该被断言——一旦部署侧改了策略定义、
152
+ // 而旧成员仍拿着旧列表,只比 id 的核对永远看不出来。
153
+ const policyDrift = [];
154
+ for (const team of teams) {
155
+ const byPolicy = new Map();
156
+ for (const member of team.members) {
157
+ const policy = typeof member.toolPolicy === "string" ? member.toolPolicy : "";
158
+ if (policy === "") continue;
159
+ const digest = toolFilterDigest(member.toolFilter);
160
+ if (digest === null) continue;
161
+ if (!byPolicy.has(policy)) byPolicy.set(policy, new Map());
162
+ const bySha = byPolicy.get(policy);
163
+ if (!bySha.has(digest.sha)) bySha.set(digest.sha, { sha: digest.sha, deny: digest.deny, members: [] });
164
+ bySha.get(digest.sha).members.push(typeof member.name === "string" ? member.name : "");
165
+ }
166
+ for (const [policy, bySha] of byPolicy) {
167
+ if (bySha.size < 2) continue;
168
+ policyDrift.push({
169
+ teamId: team.id,
170
+ teamName: team.name,
171
+ toolPolicy: policy,
172
+ variants: [...bySha.values()].sort((left, right) => right.members.length - left.members.length),
173
+ });
174
+ }
175
+ }
176
+
177
+ // 溯源:团队是哪一版档案建的。只读 description,不改它。
178
+ const provenance = teams.map((team) => {
179
+ const parsed = parseProvenanceLine(team.description ?? "");
180
+ const base = { teamId: team.id, teamName: team.name, declared: parsed === null ? null : parsed.raw };
181
+ if (parsed === null) return { ...base, status: "absent", rosterId: null, version: null, sha: null, currentSha: null };
182
+ const current = typeof rosterShas[parsed.id] === "string" ? rosterShas[parsed.id] : null;
183
+ if (current === null) {
184
+ return { ...base, status: "unknown-roster", rosterId: parsed.id, version: parsed.version, sha: parsed.sha, currentSha: null };
185
+ }
186
+ const currentSha = shortSha(current);
187
+ if (parsed.sha === null) {
188
+ return { ...base, status: "id-only", rosterId: parsed.id, version: parsed.version, sha: null, currentSha };
189
+ }
190
+ return {
191
+ ...base,
192
+ status: parsed.sha === currentSha ? "match" : "differ",
193
+ rosterId: parsed.id,
194
+ version: parsed.version,
195
+ sha: parsed.sha,
196
+ currentSha,
197
+ };
198
+ });
199
+
200
+ // 能力要求是档案级(不是成员级),单独一栏:它回答"这台机器有没有条件把这支队伍建起来"。
201
+ const capabilityReport = checkCapabilities(rosters, options.capabilitiesById ?? {}, {
202
+ knownSkills: options.knownSkills ?? null,
203
+ knownToolPolicies: options.knownToolPolicies ?? null,
204
+ });
205
+
206
+ // 三个互斥的判读桶,加未部署:每个成员只会落进一个桶,避免"忠实 3 + 降级 2"这种加起来对不上的账。
207
+ const buckets = { faithful: 0, drift: 0, unverified: 0, missing: 0 };
208
+ for (const check of checks) buckets[memberVerdict(check)] += 1;
209
+
125
210
  const summary = {
126
211
  members: checks.length,
127
212
  deployed: checks.filter((check) => check.found).length,
128
- faithful: checks.filter((check) => check.found && check.faithful).length,
129
- degraded: checks.filter((check) => check.found && !check.faithful).length,
130
- missing: checks.filter((check) => !check.found).length,
213
+ faithful: buckets.faithful,
214
+ /** 真降级:运行时换了值,或团队里根本没有这个成员。 */
215
+ drift: buckets.drift,
216
+ /** 只有"发了却没留痕"的字段(今天只有 systemPrompt):无法核对,不等于没生效。 */
217
+ unverified: buckets.unverified,
218
+ /** 不忠实总数 = drift + unverified,保留旧字段名供旧调用方使用。 */
219
+ degraded: buckets.drift + buckets.unverified,
220
+ missing: buckets.missing,
131
221
  unmanaged: unmanaged.length,
132
222
  skillsDeclared: skills.length,
133
223
  skillsMissing: skills.filter((item) => item.missing.length > 0).length,
134
224
  skillsUnverified: skills.filter((item) => !item.checked).length,
225
+ toolFilterChecked: checks.filter((check) => check.found && check.toolFilter !== null).length,
226
+ toolFilterMissing: checks.filter((check) => check.found && check.toolFilter === null).length,
227
+ policyDrift: policyDrift.length,
228
+ provenanceAbsent: provenance.filter((item) => item.status === "absent").length,
229
+ provenanceDiffer: provenance.filter((item) => item.status === "differ").length,
230
+ /** 能力要求总数 / 缺失 / 核对不到 / 会拦住建队的档案数。 */
231
+ capabilityDeclared: capabilityReport.summary.declared,
232
+ capabilityMissing: capabilityReport.summary.missing,
233
+ capabilityUnknown: capabilityReport.summary.unknown,
234
+ capabilityBlocked: capabilityReport.summary.blocked,
135
235
  };
136
236
 
137
- return { checks, unmanaged, skills, summary };
237
+ return { checks, unmanaged, skills, policyDrift, provenance, capabilities: capabilityReport, summary };
238
+ }
239
+
240
+ /**
241
+ * 一个成员的判读结论,四个互斥取值。
242
+ *
243
+ * - `faithful`:被运行时承载的字段全部拿到回执且一致;
244
+ * - `unverified`:只有"发了却没留痕"的字段(今天只有 systemPrompt),无法核对,但没看到漂移;
245
+ * - `drift`:运行时换了值,或团队里根本没有这个成员——这才是要动手修的;
246
+ * - `missing`:档案定义了、运行时没有同名成员。
247
+ *
248
+ * @param {object} check `verifyBindings` 产出的单条核对记录
249
+ * @returns {"faithful" | "unverified" | "drift" | "missing"}
250
+ */
251
+ export function memberVerdict(check) {
252
+ if (check === null || check.found !== true) return "missing";
253
+ if (check.faithful === true) return "faithful";
254
+ const summary = check.summary ?? { counts: {}, unverified: 0 };
255
+ const realDrift = (summary.counts.overridden ?? 0) > 0 || (summary.counts.unconfirmed ?? 0) > 0;
256
+ return realDrift ? "drift" : "unverified";
138
257
  }
139
258
 
140
259
  /**
@@ -152,13 +271,19 @@ export function filterChecks(report, names) {
152
271
  const checks = report.checks.filter(
153
272
  (check) => wanted.has(check.member) || (check.matchedAs !== null && wanted.has(check.matchedAs)),
154
273
  );
274
+ const buckets = { faithful: 0, drift: 0, unverified: 0, missing: 0 };
275
+ for (const check of checks) buckets[memberVerdict(check)] += 1;
155
276
  const summary = {
156
277
  ...report.summary,
157
278
  members: checks.length,
158
279
  deployed: checks.filter((check) => check.found).length,
159
- faithful: checks.filter((check) => check.found && check.faithful).length,
160
- degraded: checks.filter((check) => check.found && !check.faithful).length,
161
- missing: checks.filter((check) => !check.found).length,
280
+ faithful: buckets.faithful,
281
+ drift: buckets.drift,
282
+ unverified: buckets.unverified,
283
+ degraded: buckets.drift + buckets.unverified,
284
+ missing: buckets.missing,
285
+ toolFilterChecked: checks.filter((check) => check.found && check.toolFilter !== null).length,
286
+ toolFilterMissing: checks.filter((check) => check.found && check.toolFilter === null).length,
162
287
  };
163
288
  return { ...report, checks, summary };
164
289
  }