@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/README.zh.md CHANGED
@@ -24,7 +24,7 @@
24
24
  @stofone/dsh-agent-roster
25
25
  ```
26
26
 
27
- 包内带 `cordis.patch.yml`,安装即把插件插进 profile。装好后应看到五个工具(`roster_list`、`roster_show`、`roster_create`、`roster_verify`、`roster_index`)以及左侧栏里的 **Agent Roster** 面板(独立主面板,不再放设置页)。
27
+ 包内带 `cordis.patch.yml`,安装即把插件插进 profile。装好后应看到六个工具(`roster_list`、`roster_show`、`roster_create`、`roster_verify`、`roster_diff`、`roster_index`)以及左侧栏里的 **Agent Roster** 面板(独立主面板,不再放设置页)。
28
28
 
29
29
  ## 快速开始
30
30
 
@@ -117,6 +117,8 @@ npx dsh-agent-roster list
117
117
 
118
118
  成员键:`name`、`role`、`systemPrompt`(必填),以及 `provider`、`model`、`reasoningEffort`、`toolPolicy`、`fallback`、`toolScope`、`maxTokens`、`title`、`tags`、`notes`、`description`、`aliases`、`skills`、`examples`。
119
119
 
120
+ 档案目录里还可以有一个**可选的兄弟文件** `capabilities.yaml`,声明"建这支队伍前部署侧必须提供什么"——见「能力要求」一节。
121
+
120
122
  `systemPrompt` 上限 8000 字符。成员省略路由字段时从 `defaults` 继承,继承结果记入 `inherited` 列表,能看清"这个值哪来的"。
121
123
 
122
124
  机器可读的 JSON Schema 在 [`schema/roster.v1.schema.json`](./schema/roster.v1.schema.json),供编辑器补全与外部工具校验;权威校验始终是 `lib/validate.js`,两者由 `test/schema.test.js` 对齐。
@@ -150,19 +152,66 @@ npx dsh-agent-roster list
150
152
 
151
153
  真正生效要改 profile 的 `cordis.patch.yml` 与 agent preset。面板里编辑队长段只是在记录意图——插件不会、也不能替你改会话。
152
154
 
155
+ ## 能力要求(`capabilities.yaml`)
156
+
157
+ 档案说的是**成员是谁**,说不了**这台机器到底能不能把他们跑起来**——技能目录与工具策略定义都在部署侧,不在档案里。档案目录里一个可选的兄弟文件只声明这件事,别的一律不写:
158
+
159
+ ```yaml
160
+ kind: dsh-agent-roster-capabilities/v1
161
+ roster: money-lab # 必须等于目录名
162
+ onMissing: refuse # refuse(默认)| degraded
163
+ asOf: 2026-11-02 # 只写日期
164
+ requires:
165
+ skills: [frontend-skill, {name: dsh-chat-import, reason: 会话导出需要}]
166
+ toolPolicies: [member-safe]
167
+ notes: 建队前先确认成员会话可用。
168
+ ```
169
+
170
+ 每一条要求只会落进**三种**状态之一,没有第四种:
171
+
172
+ | 状态 | 含义 | 效果 |
173
+ |---|---|---|
174
+ | `present` | 证据源说有 | 无需处理 |
175
+ | `missing` | 证据源明确说没有 | **唯一**会触发闸门的状态 |
176
+ | `unknown` | 根本没有证据源 | 既不算过也不算错,但一定要写下来——沉默不等于批准 |
177
+
178
+ 证据源只有两个,因为宿主只答得出这两件事:`skills` 来自技能目录,`toolPolicies` 来自本插件的 `knownToolPolicies` 配置。其余一切——模型路由可用性、插件版本、凭据指针、业务前置条件——一律拒收(`unknown-field`):没人核得了的要求,只是换一种方式发绿。
179
+
180
+ 行为:`onMissing: refuse`(默认)下,只要有一项 `missing`,`roster_create` **不产出任何** `agent_teams_add_member` 参数,并逐项告诉你该装到哪;`roster_verify` 状态行报 `requires=<缺>missing/<声明>` 与 `capabilityBlocked`,被能力拦下的档案会让 `ok` 为 false。`onMissing: degraded` 照样给参数,但这笔账每次核对都会记着。`roster_show` 直接打印这份声明。
181
+
182
+ **面板也会显示它。** 每张档案卡片把这些要求作为诊断列出:缺 `toolPolicies` 时,`onMissing: refuse` 记 **error**(左侧栏角标因此变红),`degraded` 记 **warn**。技能要求一律记成 `capability-skills-unverified` / **info**——面板快照是同步的,列不出技能目录,所以它只说"这里不判定"而不猜;要真答案用 `roster_verify`。没配 `knownToolPolicies` 时只出一条 `capability-cross-check-skipped`,点名受影响的档案,而不是每份档案刷一遍。
183
+
184
+ 兄弟文件有自己的 JSON Schema:[`schema/capabilities.v1.schema.json`](./schema/capabilities.v1.schema.json),由 `test/capabilities.test.js` 钉住与 `lib/capabilities.js` 一致。
185
+
153
186
  ## 工具
154
187
 
155
188
  | 工具 | 作用 |
156
189
  |---|---|
157
190
  | `roster_list` | 列出档案库里的档案与每份的校验结果 |
158
191
  | `roster_show` | 完整查看一份档案(或其中一名成员),含字段归宿 |
159
- | `roster_create` | 把成员翻译成 `agent_teams_add_member` 参数 / `plan.members` |
160
- | `roster_verify` | 与 `dsh-agent-teams` 的 `team.json` 比对;状态缺失时降级为「无法核对」 |
192
+ | `roster_create` | 把成员翻译成 `agent_teams_add_member` 参数 / `plan.members`;声明的能力缺失时拒绝出参数 |
193
+ | `roster_verify` | 与 `dsh-agent-teams` 的 `team.json` 比对;分「一致 / 未核实 / 降级 / 未部署」四桶,并报物化后的工具过滤器摘要、策略漂移、团队溯源与能力要求 |
194
+ | `roster_diff` | 比较两代档案:成员按 name 对齐、比有效值、人设只比长度与哈希 |
161
195
  | `roster_index` | 列出公共档案源(registry)的索引状态与 collection;给定 collection 时只读地拉下来看 |
162
196
 
163
- 五个工具都不写团队状态。`roster_create` 只产出参数,真正的建员仍由模型调用 `agent_teams_add_member`。
197
+ 六个工具都不写团队状态。`roster_create` 只产出参数,真正的建员仍由模型调用 `agent_teams_add_member`。
198
+
199
+ 带路径的工具(`roster_list` / `roster_show` / `roster_create` / `roster_verify` / `roster_diff`)都接受 `path` 参数:给了就只看那个目录,不会悄悄回落到配置目录。
200
+
201
+ ### 一目录一档案,一台机器可有两代
202
+
203
+ `roster` 必须等于目录名,一座库里每个目录只有一份档案。所以同一份档案的两代就放在**两座库**里——
204
+ 这是预期用法,不是缺口:它保住了「位置即身份」(后续加载永远不会误取另一代),
205
+ 也让换代成为一个显式的 `path` 参数,而不是悄悄发生的副作用。
206
+
207
+ ```text
208
+ ~/.dsh/agent-roster/money-lab/roster.yaml # 这一代
209
+ /workspace/money/.dsh/rosters/money-lab/roster.yaml # 下一代
210
+ ```
164
211
 
165
- 三个带路径的工具(`roster_list` / `roster_show` / `roster_create`)都接受 `path` 参数:给了就只看那个目录,不会悄悄回落到配置目录。
212
+ `roster_diff({ from, to })` 回答「两代之间改了什么」;`roster_create` 会打印一行溯源串
213
+ (`roster:money-lab@9.0.0#ab12cd34`),建团队时放进 description;`roster_verify` 再拿这一行
214
+ 与当前档案内容比对——档案在建团队之后被改过,就会报 `differ`,而不是静默通过。
166
215
 
167
216
  ### 队长核对
168
217
 
@@ -284,12 +333,12 @@ test/ 单测
284
333
 
285
334
  本机已验证:
286
335
 
287
- - `npm test` —— 191/191 通过(格式、解析、校验、扫描、路径、桥接、核对、无损 JSON、写入计划与执行、六条路由、快照与提示、两本词典一致、工具契约、客户端加载器契约、包清单、配置视图、样式 token 约束、声明层字段与队长段、schema 与常量对齐)。
288
- - `npm run smoke` —— 用真实 DSH bundle 的 `defineTool` 注册五个工具,逐个调用:输出全部通过无损校验、`path` 覆盖生效、释放干净;同一条命令随后用桩模块表加载 `client.js`,断言三处槽位注册、样式只注入一次、effect 可回收。
336
+ - `npm test` —— 223/223 通过(格式、解析、校验、扫描、路径、桥接、核对四桶、摘要与溯源、档案差异、能力要求与能力闸门、无损 JSON、写入计划与执行、六条路由、快照与提示、两本词典一致、工具契约、客户端加载器契约、包清单、配置视图、样式 token 约束、声明层字段与队长段、schema 与常量对齐)。
337
+ - `npm run smoke` —— 用真实 DSH bundle 的 `defineTool` 注册六个工具,逐个调用:输出全部通过无损校验、`path` 覆盖生效、释放干净;同一条命令随后用桩模块表加载 `client.js`,断言三处槽位注册、样式只注入一次、effect 可回收。
289
338
  - CLI:`validate --dir examples` 对两座示例库退出码 0。
290
339
  - 用插件管理器真的装进了本机 profile(`plugin_manager install_bundle` 指向本仓库 → `link:` 依赖 + bundle 条目),重启后按链接读取本仓库文件。
291
340
  - 运行中的宿主已能应答只读路由:`GET /plugins/dsh-agent-roster/library` 返回 200,带插件版本与解析出的档案目录。
292
- - 另一个 agent 会话在工具清单里看到了五个工具,并实际调用了它们。
341
+ - 另一个 agent 会话在工具清单里看到了六个工具,并实际调用了它们。
293
342
  - 左侧栏面板已在活页面里确认可见,并展示了真实档案。
294
343
 
295
344
  这一轮没验证到的:0.2.0 的管理界面(页签、编辑器、团队页、语言切换、`document` / `rosters` 路由)在活页面里的表现——这些改动要重启宿主才会被加载,重启后再验。
package/index.js CHANGED
@@ -48,7 +48,7 @@ export const Config = z.object({
48
48
  .description(
49
49
  "档案库目录。留空 = 全局默认目录 $DSH_HOME/agent-roster(默认 ~/.dsh/agent-roster," +
50
50
  "docker 下就是被挂载的数据卷),与工作区无关。填绝对路径或 ~ 开头路径同样是全局的;" +
51
- "只有填相对路径(如 examples)才按工作区解析。第一项是四个工具默认读取的档案库。",
51
+ "只有填相对路径(如 examples)才按工作区解析。第一项是各工具默认读取的档案库。",
52
52
  )
53
53
  .volatile(),
54
54
  knownToolPolicies: z
@@ -102,7 +102,7 @@ export function apply(ctx, config) {
102
102
  const settings = configView(config);
103
103
 
104
104
  registerRosterTools(ctx, settings, { version: pkg.version });
105
- ctx.logger?.info?.(`dsh-agent-roster ${pkg.version}: registered 5 tools`);
105
+ ctx.logger?.info?.(`dsh-agent-roster ${pkg.version}: registered 6 tools`);
106
106
 
107
107
  // 面板路由:读快照 / 读单份档案原文 / 保存 / 删除 / 导入。
108
108
  // 没有 webServer 也不影响工具可用性。
package/lib/bridge.js CHANGED
@@ -5,14 +5,21 @@
5
5
  * `agent_teams_add_member`。原因是档案层不知道团队此刻处于什么阶段(暂存/运行/已归档),
6
6
  * 也不应该替运行时决定"现在创建一个成员"。工具只输出参数,模型原样传入,运行时决定成败。
7
7
  *
8
- * 三个诚实状态必须被区分,否则档案会给人"能力已被运行时保证"的错觉:
8
+ * 五个诚实状态必须被区分,否则档案会给人"能力已被运行时保证"的错觉:
9
9
  *
10
10
  * - `applied`:运行时状态里的值与档案一致(读取的是 team.json,不是我们的请求);
11
- * - `overridden` / `omitted`:运行时最终采用了别的值,或根本没带这个字段;
11
+ * - `overridden`:运行时采用了别的值——真的降级;
12
+ * - `omitted`:档案本来就没声明(或运行时也没有),沿用默认——**预期内**,不算降级;
13
+ * - `not-persisted`:我们发出了值,但运行时状态里根本没有这个键——**无法核对**。
14
+ * 它与 `omitted` 的区别就是本插件曾经报假绿的地方:都叫"没有",但一个是"本来没有",
15
+ * 另一个是"发了却没留痕"。后者必须让 `faithful` 变红,并且单独计数,
16
+ * 否则"人设没被核对过"会被折叠进"一致"里(见 issue #2)。
12
17
  * - `not-carried`:agent-teams 没有对应承载(toolScope、maxTokens 等),档案只是声明。
13
18
  *
14
19
  * @module dsh-agent-roster/bridge
15
20
  */
21
+ import { createHash } from "node:crypto";
22
+
16
23
  import { FIELD_BINDING } from "./format.js";
17
24
 
18
25
  /** 成员字段 → 调用参数名 / 运行时状态键。 */
@@ -31,7 +38,8 @@ export const BINDING_STATUS = {
31
38
  pending: "已生成参数,运行时尚未回执",
32
39
  applied: "运行时取值与档案一致",
33
40
  overridden: "运行时实际取值与档案不同",
34
- omitted: "运行时未携带该字段",
41
+ omitted: "档案未声明,沿用运行时默认",
42
+ "not-persisted": "运行时状态不含该字段(已发出,无法核对)",
35
43
  unconfirmed: "缺少运行时状态,无法核对",
36
44
  "not-carried": "agent-teams 无承载,仅声明",
37
45
  "config-required": "需要落到部署配置才生效",
@@ -157,8 +165,22 @@ export function reconcileBinding(binding, teamMember) {
157
165
  const actual = found[entry.stateKey];
158
166
  entry.actual = actual ?? null;
159
167
  if (actual === undefined || actual === null || actual === "") {
168
+ // 两个方向都要分清,否则会同时制造假绿和假红:
169
+ // - 档案没声明、运行时也没记 → 沿用默认,预期内;
170
+ // - 档案声明了值、运行时状态里没有这个键 → 发了却没留痕,无法核对(issue #2)。
171
+ if (entry.requested === null) {
172
+ entry.status = "omitted";
173
+ entry.note = "档案未声明,运行时也未记录(沿用默认)";
174
+ } else {
175
+ entry.status = "not-persisted";
176
+ entry.note = "team.json 未持久化该字段,无法核对(不等于未生效)";
177
+ }
178
+ continue;
179
+ }
180
+ if (entry.requested === null) {
181
+ // 档案没声明路由,运行时取了队长默认:这不是"运行时改了我们的值",不该报降级。
160
182
  entry.status = "omitted";
161
- entry.note = "运行时没有记录该字段";
183
+ entry.note = "档案未声明,运行时取了默认值";
162
184
  continue;
163
185
  }
164
186
  entry.status = valueMatches(entry.field, entry.requested, actual) ? "applied" : "overridden";
@@ -170,8 +192,14 @@ export function reconcileBinding(binding, teamMember) {
170
192
  /**
171
193
  * 生成绑定摘要:按状态统计,供工具输出和索引里的 `binding` 段使用。
172
194
  *
195
+ * `degraded` 与 `faithful` 只在这里算一次——曾经 `isBindingFaithful` 自己另写了一份判据,
196
+ * 两份判据一旦不一致就会出现"摘要说降级、判定说忠实"的假绿(issue #2)。
197
+ *
173
198
  * @param {object} binding
174
- * @returns {{ counts: Record<string, number>, carried: number, applied: number, degraded: number }}
199
+ * @returns {{
200
+ * counts: Record<string, number>, carried: number, applied: number,
201
+ * degraded: number, unverified: number, absent: number, faithful: boolean,
202
+ * }}
175
203
  */
176
204
  export function summarizeBinding(binding) {
177
205
  const counts = {};
@@ -180,8 +208,21 @@ export function summarizeBinding(binding) {
180
208
  }
181
209
  const carried = binding.fields.filter((entry) => entry.param !== null).length;
182
210
  const applied = counts.applied ?? 0;
183
- const degraded = (counts.overridden ?? 0) + (counts.omitted ?? 0) + (counts.unconfirmed ?? 0);
184
- return { counts, carried, applied, degraded };
211
+ const notPersisted = counts["not-persisted"] ?? 0;
212
+ const overridden = counts.overridden ?? 0;
213
+ const unconfirmed = counts.unconfirmed ?? 0;
214
+ const degraded = overridden + notPersisted + unconfirmed;
215
+ return {
216
+ counts,
217
+ carried,
218
+ applied,
219
+ degraded,
220
+ /** 发出了值却拿不到运行时回执的字段数(今天只有 systemPrompt 会命中)。 */
221
+ unverified: notPersisted,
222
+ /** 档案本来就没声明、运行时也没记的字段数:预期内,不计入降级。 */
223
+ absent: counts.omitted ?? 0,
224
+ faithful: degraded === 0,
225
+ };
185
226
  }
186
227
 
187
228
  /** 人读的一行:`model 已应用 deepseek/deepseek-v4.1-flash(来源:member)`。 */
@@ -204,11 +245,49 @@ export function describeField(entry) {
204
245
  /**
205
246
  * 团队状态是否与档案一致:只统计"被运行时承载"的字段。
206
247
  *
207
- * 存在 overridden / omitted / unconfirmed 时返回 false——降级必须被看见。
248
+ * 三个真降级都会让这里变红:`overridden`(运行时换了值)、`unconfirmed`(查不到成员)、
249
+ * 以及 `not-persisted`(发了值但运行时状态里没留痕,无法核对)。
250
+ * `omitted` 不算——那是档案本来就没声明、运行时沿用默认,属预期内。
251
+ *
252
+ * 判据只有一份,就在 {@link summarizeBinding} 里:此处绝不重复实现,
253
+ * 否则两份判据一漂移就回到 issue #2 的假绿。
208
254
  */
209
255
  export function isBindingFaithful(binding) {
210
- const summary = summarizeBinding(binding);
211
- return (summary.counts.overridden ?? 0) === 0 && (summary.counts.unconfirmed ?? 0) === 0;
256
+ return summarizeBinding(binding).faithful;
257
+ }
258
+
259
+ /**
260
+ * 团队溯源串:`roster:<id>@<version>#<档案内容 sha256 前 8 位>`。
261
+ *
262
+ * `team.json` 没有"由哪一版档案建成"的字段,所以只能借 `description` 这一行把出处的**指针**带进去。
263
+ * 插件仍然不写团队状态——它只给出"建议写什么",由队长模型传进 `agent_teams_create`。
264
+ *
265
+ * @param {object} roster 已校验档案
266
+ * @param {string} [contentSha] 档案文件内容的 sha256(十六进制);缺省时省略 `#` 段
267
+ * @returns {string}
268
+ */
269
+ export function provenanceLine(roster, contentSha) {
270
+ const short = typeof contentSha === "string" && contentSha !== "" ? `#${contentSha.slice(0, 8)}` : "";
271
+ return `roster:${roster.id}@${roster.version}${short}`;
272
+ }
273
+
274
+ /**
275
+ * 从任意文本里解析溯源串。
276
+ *
277
+ * @param {string} text 团队 description 或任意一行文字
278
+ * @returns {{ id: string, version: string, sha: string | null, raw: string } | null}
279
+ */
280
+ export function parseProvenanceLine(text) {
281
+ if (typeof text !== "string") return null;
282
+ // 版本段必须是一个**完整**的 semver 词,且其后立刻是 `#<sha>` 或词边界。
283
+ // 否则 `roster:core-review@1.2.0+provider,model`(`provenanceOf` 产出的另一种写法,
284
+ // 带继承字段后缀)会被读成 version `1.2.0+provider`——一个根本不存在的版本,
285
+ // 比对时永远 differ,比"读不出来"更糟。
286
+ const match = /roster:([A-Za-z0-9][A-Za-z0-9._-]*)@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)(?:#([0-9a-f]{8}))?(?![0-9A-Za-z.+,-])/.exec(
287
+ text,
288
+ );
289
+ if (match === null) return null;
290
+ return { id: match[1], version: match[2], sha: match[3] ?? null, raw: match[0] };
212
291
  }
213
292
 
214
293
  /** `roster:<id>@<version>`——出现在输出、提交信息与绑定记录里的出处标识。 */