@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/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
  () =>
@@ -126,7 +148,7 @@ export function registerRosterTools(ctx, config, meta) {
126
148
  },
127
149
  output: { schema: OUTPUT, render: (_args, value) => [{ type: "text", text: value.text }] },
128
150
  async execute(args, exec) {
129
- const { rosterDir } = resolve(exec, args.path);
151
+ const { rosterDir, workspace } = resolve(exec, args.path);
130
152
  const scan = scanAt(rosterDir);
131
153
  const hit = findRoster(scan.rosters, args.ref);
132
154
  if (hit === null) {
@@ -163,16 +185,20 @@ export function registerRosterTools(ctx, config, meta) {
163
185
  const { binding } = buildMemberBinding(roster, member);
164
186
  return { member, fields: binding.fields, summary: summarizeBinding(binding) };
165
187
  });
166
- const text = [
188
+ const capabilities = scan.capabilities?.[roster.id] ?? null;
189
+ const blocks = [
167
190
  statusLine("roster_show", {
168
191
  found: true,
169
192
  roster: roster.id,
170
193
  version: roster.version,
171
194
  members: members.length,
172
195
  matchedBy: hit.via,
196
+ requires: capabilityRequirements(capabilities).length,
173
197
  }),
174
198
  renderRosterDetail(roster, only),
175
- ].join("\n\n");
199
+ ];
200
+ if (capabilities !== null) blocks.push(renderCapabilityRequirements(roster, capabilities));
201
+ const text = blocks.join("\n\n");
176
202
  return lossless({
177
203
  found: true,
178
204
  matchedBy: hit.via,
@@ -202,6 +228,7 @@ export function registerRosterTools(ctx, config, meta) {
202
228
  bindingSummary: summary,
203
229
  })),
204
230
  captain: roster.captain ?? null,
231
+ capabilities,
205
232
  diagnostics: scan.diagnostics,
206
233
  text,
207
234
  });
@@ -238,7 +265,7 @@ export function registerRosterTools(ctx, config, meta) {
238
265
  },
239
266
  output: { schema: OUTPUT, render: (_args, value) => [{ type: "text", text: value.text }] },
240
267
  async execute(args, exec) {
241
- const { rosterDir } = resolve(exec, args.path);
268
+ const { rosterDir, workspace } = resolve(exec, args.path);
242
269
  const scan = scanAt(rosterDir);
243
270
  const hit = findRoster(scan.rosters, args.ref);
244
271
  const fail = (message) =>
@@ -259,7 +286,44 @@ export function registerRosterTools(ctx, config, meta) {
259
286
  `档案 ${roster.id} 里没有成员 ${[...(wanted ?? [])].join(", ")};可用:${roster.members.map((m) => m.name).join(", ")}`,
260
287
  );
261
288
  }
289
+ // 能力闸门:档案只声明"要用到什么",这里核对"这台机器有没有"。
290
+ // 缺了就拒绝出参数(onMissing 默认 refuse)——先建了再补,等于让假绿换个位置住下来。
291
+ const capabilities = scan.capabilities?.[roster.id] ?? null;
292
+ let gate = null;
293
+ if (capabilities !== null) {
294
+ const knownSkills = await readKnownSkills(ctx, workspace);
295
+ gate = checkCapabilities([roster], { [roster.id]: capabilities }, {
296
+ knownSkills,
297
+ knownToolPolicies: config.knownToolPolicies ?? null,
298
+ }).entries[0] ?? null;
299
+ if (gate !== null && gate.blocked) {
300
+ return lossless({
301
+ ok: false,
302
+ blocked: true,
303
+ rosterId: roster.id,
304
+ rosterVersion: roster.version,
305
+ capabilities: gate,
306
+ diagnostics: scan.diagnostics,
307
+ text: [
308
+ statusLine("roster_create", {
309
+ ok: false,
310
+ blocked: true,
311
+ roster: roster.id,
312
+ missing: gate.missing.length,
313
+ }),
314
+ renderCapabilityGate(roster, gate, CAPABILITY_HINTS),
315
+ "档案没有被改动;补齐上面列出的能力后重新调用即可。\n" +
316
+ "若确有应急需要(先有人干活),在该档案目录的 capabilities.yaml 里写 onMissing: degraded——" +
317
+ "但那会在每次核对时留下 \"degraded\" 记录,不会被当成通过。",
318
+ ].join("\n\n"),
319
+ });
320
+ }
321
+ }
262
322
  const entries = selected.map((member) => ({ member, ...buildMemberBinding(roster, member) }));
323
+ // 团队状态里没有"由哪一版档案建成",只能把出处写进 description。
324
+ // 插件仍然不写 team.json:它只给出建议的那一行,由队长传进 agent_teams_create。
325
+ const contentSha = shaOf(rosterDir, roster);
326
+ const provenance = provenanceLine(roster, contentSha ?? undefined);
263
327
  const text = [
264
328
  statusLine("roster_create", {
265
329
  ok: true,
@@ -268,11 +332,19 @@ export function registerRosterTools(ctx, config, meta) {
268
332
  calls: entries.length,
269
333
  }),
270
334
  renderBridgePlan({ roster, entries }),
271
- ].join("\n\n");
335
+ `建团队时把这一行放进 agent_teams_create 的 description(插件不写团队状态):\n ${provenance}\n` +
336
+ "之后 roster_verify 会把团队声明的这一行与当前档案内容比对;对不上就说明档案在建团队之后改过。",
337
+ ];
338
+ if (gate !== null && gate.missing.length > 0) {
339
+ text.push(renderCapabilityGate(roster, gate, CAPABILITY_HINTS));
340
+ }
341
+ const textBlocks = text.join("\n\n");
272
342
  return lossless({
273
343
  ok: true,
274
344
  rosterId: roster.id,
275
345
  rosterVersion: roster.version,
346
+ provenance: { line: provenance, rosterId: roster.id, version: roster.version, contentSha },
347
+ capabilities: gate,
276
348
  calls: entries.map((entry) => ({ tool: "agent_teams_add_member", arguments: entry.args })),
277
349
  planMembers: entries.map((entry) => entry.planMember),
278
350
  bindings: entries.map((entry) => ({
@@ -282,7 +354,7 @@ export function registerRosterTools(ctx, config, meta) {
282
354
  })),
283
355
  nextStep: "把每个 calls[].arguments 原样传给 agent_teams_add_member;随后用 roster_verify 核对运行时实际值",
284
356
  diagnostics: scan.diagnostics,
285
- text,
357
+ text: textBlocks,
286
358
  });
287
359
  },
288
360
  presentCall: (args) => ({ card: "generic", title: `桥接档案 ${args.ref}` }),
@@ -350,7 +422,17 @@ export function registerRosterTools(ctx, config, meta) {
350
422
  const state = readTeamStates({ stateDir, teamId: args.team });
351
423
  // 技能是声明层:只有宿主真的能列出技能目录时才核对,否则如实记成"无法核对"。
352
424
  const knownSkills = await readKnownSkills(ctx, workspace);
353
- let report = verifyBindings(rosters, state.teams, { knownSkills });
425
+ const rosterShas = {};
426
+ for (const roster of rosters) {
427
+ const sha = shaOf(rosterDir, roster);
428
+ if (sha !== null) rosterShas[roster.id] = sha;
429
+ }
430
+ let report = verifyBindings(rosters, state.teams, {
431
+ knownSkills,
432
+ rosterShas,
433
+ knownToolPolicies: config.knownToolPolicies ?? null,
434
+ capabilitiesById: scan.capabilities ?? {},
435
+ });
354
436
  // 队长不是成员行:只能拿实际的部署配置去比档案里的声明,读不到就记成“无法核对”。
355
437
  const deployedCaptains = readDeployedCaptains();
356
438
  const captainReports = rosters
@@ -359,21 +441,39 @@ export function registerRosterTools(ctx, config, meta) {
359
441
  if (Array.isArray(args.members) && args.members.length > 0) {
360
442
  report = filterChecks(report, args.members);
361
443
  }
362
- const ok = report.summary.degraded === 0 && state.available;
444
+ // `ok` 只判"部署侧有没有漂移"。人设拿不到回执是上游 team.json 不持久化该键,
445
+ // 那不是部署做错了——所以它单列成 unverified,绝不混进 ok,也不许悄悄消失。
446
+ // 能力缺失只有在 onMissing: refuse 时才翻红:degraded 是"明知缺、仍要建"的显式选择,
447
+ // 那笔账记在 summary.capabilityMissing 里,不该被伪装成通过,也不该被伪装成失败。
448
+ const ok =
449
+ report.summary.drift === 0 &&
450
+ report.summary.missing === 0 &&
451
+ report.summary.capabilityBlocked === 0 &&
452
+ state.available;
453
+ const summary = report.summary;
363
454
  const text = [
364
455
  statusLine("roster_verify", {
365
456
  ok,
366
457
  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}`,
458
+ members: summary.members,
459
+ deployed: summary.deployed,
460
+ faithful: summary.faithful,
461
+ unverified: summary.unverified,
462
+ drift: summary.drift,
463
+ missing: summary.missing,
464
+ unmanaged: summary.unmanaged,
465
+ toolFilter: `${summary.toolFilterChecked}/${summary.toolFilterChecked + summary.toolFilterMissing}`,
466
+ policyDrift: summary.policyDrift,
467
+ provenance: `${summary.provenanceAbsent}absent/${summary.provenanceDiffer}differ`,
468
+ skills: `${summary.skillsMissing}/${summary.skillsDeclared}`,
469
+ requires: `${summary.capabilityMissing}missing/${summary.capabilityDeclared}`,
470
+ capabilityBlocked: summary.capabilityBlocked,
374
471
  captain: captainReports.length,
375
472
  }),
376
473
  renderVerifyReport(report, { rosterIds: refRosterIds }),
474
+ ...(report.capabilities.entries.length === 0
475
+ ? []
476
+ : [renderCapabilityReport(report.capabilities.entries, CAPABILITY_HINTS)]),
377
477
  ...(captainReports.length === 0
378
478
  ? []
379
479
  : [
@@ -385,7 +485,13 @@ export function registerRosterTools(ctx, config, meta) {
385
485
  return lossless({
386
486
  ok,
387
487
  stateDir,
388
- teams: state.teams.map((team) => ({ id: team.id, name: team.name, phase: team.phase, file: team.file })),
488
+ teams: state.teams.map((team) => ({
489
+ id: team.id,
490
+ name: team.name,
491
+ phase: team.phase,
492
+ file: team.file,
493
+ description: typeof team.description === "string" ? team.description : "",
494
+ })),
389
495
  summary: report.summary,
390
496
  checks: report.checks.map((check) => ({
391
497
  rosterId: check.rosterId,
@@ -394,15 +500,21 @@ export function registerRosterTools(ctx, config, meta) {
394
500
  found: check.found,
395
501
  teamId: check.teamId,
396
502
  faithful: check.faithful,
503
+ verdict: memberVerdict(check),
504
+ toolPolicy: check.toolPolicy ?? null,
505
+ toolFilter: check.toolFilter ?? null,
397
506
  fields: check.binding === null ? [] : check.binding.fields,
398
507
  })),
399
508
  unmanaged: report.unmanaged,
509
+ policyDrift: report.policyDrift,
510
+ provenance: report.provenance,
400
511
  captain: captainReports.map(({ roster, verified }) => ({
401
512
  rosterId: roster.id,
402
513
  ok: verified.ok,
403
514
  checks: verified.checks,
404
515
  })),
405
516
  skills: report.skills,
517
+ capabilities: report.capabilities,
406
518
  diagnostics: [...scan.diagnostics, ...state.diagnostics],
407
519
  text,
408
520
  });
@@ -414,6 +526,119 @@ export function registerRosterTools(ctx, config, meta) {
414
526
  "dsh-agent-roster: roster_verify",
415
527
  );
416
528
 
529
+ ctx.effect(
530
+ () =>
531
+ ctx.tools.register(
532
+ defineTool({
533
+ name: "roster_diff",
534
+ description:
535
+ "比较两份档案的语义差异:换班时回答\"新一代改了什么\"。" +
536
+ "成员按 name 对齐(aliases 只作提示,不自动合并,改名会显示成删一个、加一个);" +
537
+ "比的是有效值(defaults 已合进成员),并标注这个值来自成员还是 defaults。" +
538
+ "人设只比长度与内容哈希,不把整段文字贴出来。本工具只读,不写任何东西。",
539
+ parameters: {
540
+ from: { type: "string", required: true, description: "旧档案所在目录:档案目录本身,或包含它的档案库目录" },
541
+ to: { type: "string", required: true, description: "新档案所在目录:档案目录本身,或包含它的档案库目录" },
542
+ id: {
543
+ type: "string",
544
+ description: "目录里有不止一份档案时,指明要比较哪一份(档案 id);省略时要求目录里恰好只有一份",
545
+ },
546
+ },
547
+ output: { schema: OUTPUT, render: (_args, value) => [{ type: "text", text: value.text }] },
548
+ async execute(args, exec) {
549
+ const { workspace } = resolve(exec, undefined);
550
+ const fail = (message, diagnostics = []) =>
551
+ lossless({
552
+ ok: false,
553
+ text: [statusLine("roster_diff", { ok: false, from: args.from, to: args.to }), message].join("\n\n"),
554
+ diagnostics,
555
+ });
556
+
557
+ const load = (spec) => {
558
+ const dir = resolveDirSpec(spec, workspace);
559
+ const direct = loadRosterAt(dir, { knownToolPolicies: config.knownToolPolicies ?? null });
560
+ if (direct.ok) return { ok: true, dir, roster: direct.roster, contentSha: direct.contentSha, diagnostics: direct.diagnostics };
561
+ const scan = scanRosterLibrary({ rootDir: dir, knownToolPolicies: config.knownToolPolicies ?? null });
562
+ const candidates = args.id === undefined ? scan.rosters : scan.rosters.filter((item) => item.id === args.id);
563
+ const diagnostics = [...direct.diagnostics, ...scan.diagnostics];
564
+ if (candidates.length === 0) {
565
+ return {
566
+ ok: false,
567
+ dir,
568
+ diagnostics,
569
+ reason:
570
+ `目录 "${dir}" 里没有可用的档案` +
571
+ (args.id === undefined ? "。" : `(id=${args.id})。`) +
572
+ "请指向档案目录本身,或指向包含它的档案库目录。",
573
+ };
574
+ }
575
+ if (candidates.length > 1) {
576
+ return {
577
+ ok: false,
578
+ dir,
579
+ diagnostics,
580
+ reason: `目录 "${dir}" 里有 ${candidates.length} 份档案,请用 id 指明要比较哪一份:${candidates.map((item) => item.id).join(", ")}`,
581
+ };
582
+ }
583
+ const only = candidates[0];
584
+ const loaded = loadRosterAt(nodePath.join(dir, only.id), {
585
+ expectedId: only.id,
586
+ knownToolPolicies: config.knownToolPolicies ?? null,
587
+ });
588
+ return {
589
+ ok: loaded.ok,
590
+ dir,
591
+ roster: loaded.roster,
592
+ contentSha: loaded.contentSha,
593
+ diagnostics: [...diagnostics, ...loaded.diagnostics],
594
+ reason: loaded.ok ? undefined : `档案 ${only.id} 校验不通过,无法比较。`,
595
+ };
596
+ };
597
+
598
+ const left = load(args.from);
599
+ if (left.ok !== true) return fail(left.reason ?? "旧档案读不出来。", left.diagnostics);
600
+ const right = load(args.to);
601
+ if (right.ok !== true) return fail(right.reason ?? "新档案读不出来。", right.diagnostics);
602
+
603
+ const diff = diffRosters(left.roster, right.roster);
604
+ const labels = {
605
+ from: { id: left.roster.id, version: left.roster.version, sha: left.contentSha },
606
+ to: { id: right.roster.id, version: right.roster.version, sha: right.contentSha },
607
+ };
608
+ const sameFile =
609
+ typeof left.contentSha === "string" && left.contentSha === right.contentSha
610
+ ? "(两份是逐字节相同的文件)"
611
+ : "";
612
+ const text = [
613
+ statusLine("roster_diff", {
614
+ ok: true,
615
+ from: `${left.roster.id}@${left.roster.version}`,
616
+ to: `${right.roster.id}@${right.roster.version}`,
617
+ added: diff.counts.added,
618
+ removed: diff.counts.removed,
619
+ changed: diff.counts.changed,
620
+ }),
621
+ renderRosterDiff(diff, labels) + (sameFile === "" ? "" : `\n${sameFile}`),
622
+ ].join("\n\n");
623
+ return lossless({
624
+ ok: true,
625
+ from: labels.from,
626
+ to: labels.to,
627
+ rosters: diff.rosters,
628
+ members: diff.members,
629
+ skills: diff.skills,
630
+ counts: diff.counts,
631
+ diagnostics: [...left.diagnostics, ...right.diagnostics],
632
+ text,
633
+ });
634
+ },
635
+ presentCall: (args) => ({ card: "generic", title: `比较档案 ${args.from} → ${args.to}` }),
636
+ presentResult: (_args, result) => ({ card: "generic", title: result.isError ? "比较失败" : "档案差异" }),
637
+ }),
638
+ ),
639
+ "dsh-agent-roster: roster_diff",
640
+ );
641
+
417
642
  ctx.effect(
418
643
  () =>
419
644
  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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stofone/dsh-agent-roster",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "多 Agent 团队的静态成员档案定义与校验层:档案库在面板里看得见、改得动,团队成员的人设、模型与工具边界固化成可校验、可版本化的文件,并在 dsh-agent-teams 创建成员时把档案桥接给运行时。",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -62,7 +62,7 @@
62
62
  "yaml": "^2.9.1"
63
63
  },
64
64
  "peerDependencies": {
65
- "@deepseek-ai/dsh-tools": "*"
65
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.1 || ^0.2.0-rc.1 || ^0.2.1-rc.1 || ^0.3.0-rc.1"
66
66
  },
67
67
  "peerDependenciesMeta": {
68
68
  "@deepseek-ai/dsh-tools": {
@@ -96,5 +96,8 @@
96
96
  "meta": {
97
97
  "title": "Agent Roster",
98
98
  "description": "静态成员档案:定义、校验并桥接多 Agent 团队成员的人设、模型与工具边界。"
99
+ },
100
+ "devDependencies": {
101
+ "@deepseek-ai/dsh-tools": "^0.2.1-rc.2"
99
102
  }
100
103
  }