@stofone/dsh-agent-roster 0.6.2 → 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/CHANGELOG.md +64 -1
- package/LICENSE +21 -21
- package/README.md +370 -319
- package/README.zh.md +57 -8
- 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/library.js +5 -0
- package/lib/registry.js +127 -4
- package/lib/render.js +238 -7
- package/lib/tools.js +248 -23
- package/lib/verify.js +135 -10
- package/package.json +4 -1
- package/schema/capabilities.v1.schema.json +124 -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
|
|
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 {
|
|
18
|
-
import {
|
|
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
|
|
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
|
-
]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
368
|
-
deployed:
|
|
369
|
-
faithful:
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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) => ({
|
|
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 {
|
|
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[]
|
|
23
|
-
*
|
|
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:
|
|
129
|
-
|
|
130
|
-
|
|
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:
|
|
160
|
-
|
|
161
|
-
|
|
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.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "多 Agent 团队的静态成员档案定义与校验层:档案库在面板里看得见、改得动,团队成员的人设、模型与工具边界固化成可校验、可版本化的文件,并在 dsh-agent-teams 创建成员时把档案桥接给运行时。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -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
|
}
|