@stofone/dsh-agent-roster 0.6.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +98 -1
- package/LICENSE +21 -21
- package/README.md +407 -319
- package/README.zh.md +95 -9
- package/bin/cli.mjs +10 -0
- package/index/registry.json +45 -1
- package/index.js +2 -2
- package/lib/bridge.js +89 -10
- package/lib/capabilities.js +434 -0
- package/lib/diff.js +216 -0
- package/lib/digest.js +65 -0
- package/lib/format.js +4 -5
- package/lib/library.js +5 -0
- package/lib/organization.js +331 -0
- package/lib/registry.js +209 -4
- package/lib/render.js +253 -7
- package/lib/tools.js +251 -23
- package/lib/verify.js +135 -10
- package/orgs/agent-roster-collective/api-design/roster.yaml +67 -0
- package/orgs/agent-roster-collective/content-ops/roster.yaml +55 -0
- package/orgs/agent-roster-collective/contract-review/roster.yaml +58 -0
- package/orgs/agent-roster-collective/data-quality/roster.yaml +64 -0
- package/orgs/agent-roster-collective/db-migration/roster.yaml +64 -0
- package/orgs/agent-roster-collective/docs-writer/roster.yaml +59 -0
- package/orgs/agent-roster-collective/finance-recon/roster.yaml +63 -0
- package/orgs/agent-roster-collective/frontend-ui/roster.yaml +60 -0
- package/orgs/agent-roster-collective/i18n-l10n/roster.yaml +60 -0
- package/orgs/agent-roster-collective/incident-response/roster.yaml +65 -0
- package/orgs/agent-roster-collective/literature-review/roster.yaml +61 -0
- package/orgs/agent-roster-collective/market-research/roster.yaml +60 -0
- package/orgs/agent-roster-collective/ml-eval/roster.yaml +63 -0
- package/orgs/agent-roster-collective/organization.yaml +118 -0
- package/orgs/agent-roster-collective/performance/roster.yaml +63 -0
- package/orgs/agent-roster-collective/product-discovery/roster.yaml +55 -0
- package/orgs/agent-roster-collective/qa-regression/roster.yaml +64 -0
- package/orgs/agent-roster-collective/recruiting/roster.yaml +58 -0
- package/orgs/agent-roster-collective/release-manager/roster.yaml +59 -0
- package/orgs/agent-roster-collective/security-audit/capabilities.yaml +12 -0
- package/orgs/agent-roster-collective/security-audit/roster.yaml +65 -0
- package/orgs/agent-roster-collective/sre-oncall/roster.yaml +60 -0
- package/orgs/agent-roster-collective/support-triage/roster.yaml +58 -0
- package/package.json +5 -1
- package/schema/capabilities.v1.schema.json +124 -0
- package/schema/organization.v1.schema.json +50 -0
- package/schema/registry.v1.schema.json +83 -0
package/lib/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
|
() =>
|
|
@@ -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
|
|
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
|
-
]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
368
|
-
deployed:
|
|
369
|
-
faithful:
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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) => ({
|
|
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 {
|
|
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
|
}
|