@stofone/dsh-agent-roster 0.0.0-stage → 0.5.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 ADDED
@@ -0,0 +1,282 @@
1
+ # @stofone/dsh-agent-roster
2
+
3
+ 面向多 Agent 团队的**静态成员档案**插件(DeepSeek Harness)。
4
+
5
+ 一份档案就是磁盘上的一个文件:团队里有谁、各自负责什么、走哪个模型路由、被允许碰哪些工具。本插件定义这套格式、校验它,并把它翻译成 `agent_teams_*` 的调用参数——仅此而已。
6
+
7
+ ## 它是什么 / 不是什么
8
+
9
+ | 是 | 不是 |
10
+ |---|---|
11
+ | 定义与校验层:档案可版本化、可评审,写错时带文件路径与字段路径**直接报错**,不静默降级 | 团队运行时:从不建成员、不调度任务、不替代任何调度器 |
12
+ | 对 `dsh-agent-teams` 的桥接:`roster_create` 产出可直接使用的 `agent_teams_add_member` 参数,并逐字段标注运行时是否真的接收 | 不写 `.agent-teams/` 状态:只读,核对用 |
13
+ | 核对手段:`roster_verify` 读 `team.json`,比对运行时**实际存下的值** | 不假装通过:读不到运行时状态时如实降级为「无法核对」 |
14
+
15
+ ## 安装
16
+
17
+ 前置:DSH 提供 `tools` 服务;依赖已在 `package.json` 里声明(`yaml`、`@deepseek-ai/schemastery`)。
18
+
19
+ 用插件管理器指向本仓库(绝对路径),或发布后按包名安装:
20
+
21
+ ```
22
+ @stofone/dsh-agent-roster
23
+ ```
24
+
25
+ 包内带 `cordis.patch.yml`,安装即把插件插进 profile。装好后应看到五个工具(`roster_list`、`roster_show`、`roster_create`、`roster_verify`、`roster_index`)以及左侧栏里的 **Agent Roster** 面板(独立主面板,不再放设置页)。
26
+
27
+ ## 快速开始
28
+
29
+ 档案库是**全局**的:一台机器一份,不跟工作区走,放在 DSH 数据目录下
30
+ (`$DSH_HOME/agent-roster`,默认 `~/.dsh/agent-roster`;docker 部署时就是被挂载的数据卷)。
31
+ 目录在第一次保存时自动创建,不需要你提前建。
32
+
33
+ ```
34
+ ~/.dsh/agent-roster/
35
+ └── core-review/
36
+ └── roster.yaml
37
+ ```
38
+
39
+ ```yaml
40
+ kind: dsh-agent-roster/v1
41
+ roster: core-review # 必须与所在目录名一致
42
+ version: 1.0.0 # 精确 SemVer
43
+ title: 核心评审组
44
+ description: 负责代码评审与发布把关的固定成员。
45
+ license: MIT
46
+ maintainers: [STofone]
47
+ tags: [review, release]
48
+
49
+ defaults: # 团队成员共享的路由默认值
50
+ provider: deepseek
51
+ model: deepseek-v4.1-flash
52
+ reasoningEffort: high
53
+
54
+ members:
55
+ - name: reviewer # kebab-case,团队内唯一
56
+ role: 代码评审
57
+ description: 只做评审,不碰发布 # 一句话职责,供派活与团队列表用
58
+ aliases: [code-reviewer] # 曾用名:改名后核对仍认作同一成员
59
+ skills: [frontend-skill] # 声明期望用到的技能(不自动加载)
60
+ examples: # 典型请求,派活时当提示
61
+ - 帮我审一下这次改动
62
+ systemPrompt: |
63
+ 你先找语义错误,再看风格。
64
+ toolPolicy: readonly
65
+ - name: release-manager
66
+ role: 发布把关
67
+ systemPrompt: |
68
+ 你核对变更、版本号与回滚方案。
69
+ model: deepseek-v4.1-pro
70
+ reasoningEffort: low
71
+
72
+ captain: # 可选:记录队长(=创建团队的那个会话)应该是什么样
73
+ name: lead
74
+ description: 主会话即队长,负责拆活与验收
75
+ provider: tsingmicro
76
+ model: deepseek-v4.1-flash
77
+ personaPrefix: |
78
+ 派活前先复述目标与验收标准。
79
+ ```
80
+
81
+ JSON 也可以(`roster.json`)。同一目录下按 `roster.yaml` → `roster.yml` → `roster.json` 顺序探测,**只认一份**;出现第二份会报诊断,绝不静默挑一份。
82
+
83
+ 本包自带两份示例档案库([`examples/`](./examples))。`rosterDirs` 默认留空,也就是上面那座全局库;
84
+ 新装时它是空的,所以面板一开始显示「0 份档案」——在面板里新建一份即可(目录会自动创建)。想先看示例库:
85
+
86
+ ```bash
87
+ # 先看看示例库长什么样
88
+ npx dsh-agent-roster list --dir examples
89
+ ```
90
+
91
+ 之后让模型调用 `roster_list`,或用 CLI(默认读同一座全局库):
92
+
93
+ ```bash
94
+ npx dsh-agent-roster validate
95
+ npx dsh-agent-roster list
96
+ ```
97
+
98
+ ## 格式说明
99
+
100
+ 顶层只允许这些键,未知键是 **error** 而不是警告:
101
+
102
+ | 键 | 必填 | 说明 |
103
+ |---|---|---|
104
+ | `kind` | 是 | 必须是 `dsh-agent-roster/v1` |
105
+ | `roster` | 是 | kebab-case id,必须等于所在目录名 |
106
+ | `version` | 是 | 精确 SemVer(`1.2.0`,不接受 `^1.2.0`) |
107
+ | `title` | 否 | 单行,≤120 字符(省略时用 id) |
108
+ | `description` | 否 | ≤500 字符 |
109
+ | `license` | 否 | 单行 |
110
+ | `maintainers` | 否 | 非空字符串列表 |
111
+ | `tags` | 否 | ≤16 个,小写 kebab-case |
112
+ | `defaults` | 否 | 成员继承的路由字段 |
113
+ | `members` | 是 | 1 到 32 个 |
114
+ | `captain` | 否 | 队长段:只描述、不创建(见下) |
115
+
116
+ 成员键:`name`、`role`、`systemPrompt`(必填),以及 `provider`、`model`、`reasoningEffort`、`toolPolicy`、`fallback`、`toolScope`、`maxTokens`、`title`、`tags`、`notes`、`description`、`aliases`、`skills`、`examples`。
117
+
118
+ `systemPrompt` 上限 8000 字符。成员省略路由字段时从 `defaults` 继承,继承结果记入 `inherited` 列表,能看清"这个值哪来的"。
119
+
120
+ 机器可读的 JSON Schema 在 [`schema/roster.v1.schema.json`](./schema/roster.v1.schema.json),供编辑器补全与外部工具校验;权威校验始终是 `lib/validate.js`,两者由 `test/schema.test.js` 对齐。
121
+
122
+ ### 每个字段到底落在哪
123
+
124
+ 桥接绝不假装字段生效。每个成员字段都归类:
125
+
126
+ | 类别 | 字段 | 含义 |
127
+ |---|---|---|
128
+ | `direct` | `name`、`role`、`systemPrompt`、`provider`、`model`、`reasoningEffort`、`toolPolicy` | 进入 `agent_teams_add_member` 参数并被运行时持久化 |
129
+ | `config` | `fallback` | 运行时无对应参数,需落部署配置 |
130
+ | `declaration` | `toolScope`、`maxTokens`、`title`、`tags`、`notes`、`description`、`aliases`、`skills`、`examples` | 仅作文档与校验依据,明确标注未生效 |
131
+
132
+ `roster_create` 输出 `agent_teams_add_member` 参数(`systemPrompt` → `executionPrompt`、`reasoningEffort` → `reasoning_effort`、`toolPolicy` → `tool_policy`)与逐字段归宿;`roster_verify` 随后把每个字段判为 `applied`、`overridden`、`omitted` 或 `unconfirmed`。
133
+
134
+ ### 声明层字段与队长
135
+
136
+ 这四个声明字段解决的是"档案写着给人看、运行时却不认"的那类信息,各有明确用途:
137
+
138
+ - `description`:一句话说明这名成员负责什么。CrewAI 的 `backstory`、Claude 的 `description`、OpenAI Agents SDK 的 `handoff_description` 都是这个位置——派活时先看它。
139
+ - `aliases`:曾用名。成员改名后团队状态里仍是旧名,核对时按别名认作同一成员(结果里标 `matchedAs`),不会把一次改名变成"未部署 + 无档案"两条假报警。别名不能与现有成员重名,也不能两名成员共用一个别名。
140
+ - `skills`:期望这名成员用到的技能名,与 DSH 技能目录同名。**声明层**:运行时不会据此加载技能;宿主能列出技能时,`roster_verify` 只回答"声明了但没装"这一件事。
141
+ - `examples`:典型请求,一行一条,派活时当提示参考,永远不会被当作指令执行。
142
+
143
+ `captain` 段记录队长——它**不是成员**。`dsh-agent-teams` 里队长就是创建团队的那个会话:`team.json` 没有 captain 成员行,也没有任何参数能改写它的模型。所以档案对队长只做三件事:
144
+
145
+ 1. 描述它应该是什么样(`name`、`description`、`systemPrompt`);
146
+ 2. 声明它的路由与人格前缀,并在 `roster_show` 里给出可复制的 profile 片段(`agent-default-model`、`@deepseek-ai/dsh-persona`);
147
+ 3. 校验这些声明是否成对(`provider` 与 `model` 必须同时出现)。
148
+
149
+ 真正生效要改 profile 的 `cordis.patch.yml` 与 agent preset。面板里编辑队长段只是在记录意图——插件不会、也不能替你改会话。
150
+
151
+ ## 工具
152
+
153
+ | 工具 | 作用 |
154
+ |---|---|
155
+ | `roster_list` | 列出档案库里的档案与每份的校验结果 |
156
+ | `roster_show` | 完整查看一份档案(或其中一名成员),含字段归宿 |
157
+ | `roster_create` | 把成员翻译成 `agent_teams_add_member` 参数 / `plan.members` |
158
+ | `roster_verify` | 与 `dsh-agent-teams` 的 `team.json` 比对;状态缺失时降级为「无法核对」 |
159
+ | `roster_index` | 列出公共档案源(registry)的索引状态与 collection;给定 collection 时只读地拉下来看 |
160
+
161
+ 五个工具都不写团队状态。`roster_create` 只产出参数,真正的建员仍由模型调用 `agent_teams_add_member`。
162
+
163
+ 三个带路径的工具(`roster_list` / `roster_show` / `roster_create`)都接受 `path` 参数:给了就只看那个目录,不会悄悄回落到配置目录。
164
+
165
+ ## 左侧栏面板
166
+
167
+ 客户端插件在左侧全局栏新增一行 **Agent Roster** 图标(`sidebar.panellist` 列表槽),并在 `main` 槽用同一个面板 id 注册整页内容;点图标即在主区域打开,宽度与对话页一致。面板分三个页签,不只是看:
168
+
169
+ | 页签 | 能做什么 |
170
+ |---|---|
171
+ | 档案 | 浏览每个工作区 × 每座档案库,展开成员;**新建 / 编辑 / 复制 / 删除 / 导入 / 导出**档案。编辑器有表单与源码两种模式(源码就是文件原文);写之前由宿主校验,覆盖已有文件会留一份 `.bak` |
172
+ | 团队 | 读一个配置好的状态目录(默认 `.agent-teams`),列出运行时真正建出来的每个团队:档案、成员档案与模型路由、每个持久化成员是否真的与其档案一致,以及该团队的任务 |
173
+ | 档案源 | 列出订阅的公共 registry 目录,每个 collection 一个「拉取到本机」按钮;可展开先看里面有哪些档案 |
174
+ | 体检 | 把「配置有没有指错」集中一处:目录解析到了哪、状态目录在不在、有多少声明未被确认,以及该改哪个设置 |
175
+
176
+ 顶部一条徽标汇总「有几件事需要处理」,体检页逐条说明。所有写操作都走 `POST /plugins/dsh-agent-roster/rosters`:宿主侧校验同源(`connection`)、JSON content-type、请求体大小上限与档案库根目录白名单,客户端不直接写文件。
177
+
178
+ 面板是中英双语,语言默认跟随 DSH,可在 **设置 → 插件 → Agent Roster** 里固定(同一节还有只读的配置摘要)。词典放在 `locale/` 由宿主提供,所以切 DSH 语言时面板无需刷新就会跟着变。
179
+
180
+ ## 公共档案源与一键拉取
181
+
182
+ 档案可以共享:把一份或多份档案放在 GitHub 仓库里,在 `index/registry.json` 登记成一个 **collection**,
183
+ 别人订阅后就能在面板里看到并一键拉到自己机器上。
184
+
185
+ - **订阅方式**:设置里 `indexes` 填 `https://…/registry.json`,或 `npm:pkg`(从 npm 包的 `registry.json` 读)。
186
+ 留空 = 只用官方源 `STofone/dsh-agent-roster` 的 `index/registry.json`;显式写 `false` 才彻底不连远端。
187
+ - **多久更新一次**:TTL **一小时**——等于未认证 GitHub 的 60 次/小时配额。`?refresh=1`(或工具的 `refresh`)
188
+ 可绕过缓存,但 60 秒内最多一次,避免把配额烧光。拉取失败时沿用上次缓存并在页面上写明「缓存(拉取失败)」。
189
+ - **拉取到本机**:写入是幂等的,四种结果都会报给你——
190
+ `created`(新写入)、`updated`(显式覆盖)、`unchanged`(内容一致,不写盘)、`conflict`(本地不一样,**默认不覆盖**)。
191
+ 冲突要你自己决定:要么在编辑器里合并,要么再点一次「拉取到本机」并在请求里带 `overwrite`。
192
+ - **登记自己的档案**:在 `index/registry.json` 的 `collections` 里加一条(`id`、`repo: github:owner/repo`、
193
+ `ref`、`path`、`rosters[]`),提交 PR 即可;`.github/workflows/registry.yml` 会校验索引与仓库真实文件是否对得上。
194
+
195
+ ## 路由
196
+
197
+ | 路由 | 方法 | 用途 |
198
+ |---|---|---|
199
+ | `/plugins/dsh-agent-roster/library` | GET | 面板快照:工作区、档案库、档案、团队、提示、计数、远端源状态 |
200
+ | `/plugins/dsh-agent-roster/document` | GET | 单份档案的原文与解析结果(编辑器用) |
201
+ | `/plugins/dsh-agent-roster/rosters` | POST | 新建 / 更新 / 删除 / 导入 / 导出单份档案 |
202
+ | `/plugins/dsh-agent-roster/collection` | GET | 拉取某个 collection 的档案内容(只读,不落盘) |
203
+ | `/plugins/dsh-agent-roster/install` | POST | 把某个 collection 落盘到本机档案库(幂等,默认不覆盖) |
204
+ | `/plugins/dsh-agent-roster/locale` | GET | 面板词典(`en` / `zh`) |
205
+
206
+ 六条都是 `exact` 路由,走宿主的 `connection` 围栏,方法不符返回 `405`。
207
+
208
+ ## 配置
209
+
210
+ | 键 | 默认值 | 含义 |
211
+ |---|---|---|
212
+ | `rosterDirs` | `[]` | 档案库目录。留空 = 全局库 `$DSH_HOME/agent-roster`(默认 `~/.dsh/agent-roster`);绝对路径或 `~` 开头同样是全局的;只有相对路径才按工作区解析。第一项是工具的默认目录 |
213
+ | `knownToolPolicies` | `[]` | 部署已声明的 toolPolicy id;填了才做交叉校验 |
214
+ | `agentTeamsStateDir` | `.agent-teams` | 只读:`roster_verify` 与「团队」页签在这里找团队状态 |
215
+ | `language` | `"auto"` | 面板语言:`auto` 跟随 DSH,或固定 `zh` / `en` |
216
+ | `indexes` | `[]` | 公共档案源:`https` 的 `registry.json` 或 `npm:pkg`。留空 = 官方源;显式 `false` = 只在本机库工作 |
217
+ | `region` | `"auto"` | 下载区:`china` 时用腾讯 npm 镜像读 npm 形式的目录包;`auto` 等同 `global`(不自动探测,省一次出网) |
218
+
219
+ 六项都是 `volatile`:改完立即生效,无需重启。
220
+
221
+ ## 诊断
222
+
223
+ 校验输出 error / warn / info 三档,每条带 `file`、`path`、`reason`。主要代码族:
224
+
225
+ - 结构:`missing-field`、`unknown-key`、`invalid-type`、`invalid-kind`、`pair-required`、`invalid-value`
226
+ - 取值:`invalid-id`、`invalid-provider`、`invalid-model`、`invalid-semver`、`invalid-tag`、`invalid-tool-name`、`invalid-tool-policy-id`、`invalid-number`、`empty-tool-scope`
227
+ - 安全:`reserved-character`(`^`、`$`、`|`、花括号)、`forbidden-content`(提示注入特征)、`single-line-required`
228
+ - 上限:`string-too-long`、`too-many-members`、`too-many-values`
229
+ - 档案库级:`library-missing`、`library-unusable`、`missing-roster-file`、`multiple-roster-files`、`unexpected-file`、`unreadable-file`、`unreadable-dir`、`duplicate-roster-id`、`tool-policy-cross-check-skipped`
230
+ - 远端源:`index-unreachable`、`index-invalid`、`index-refresh-throttled`、`index-source-invalid`、`collection-fetch-failed`、`collection-not-found`
231
+
232
+ 空档案库不是错误;目录不存在只算 info。`tool-policy-cross-check-skipped` **每座档案库只发一条**(不是每份档案一条)——没配 `knownToolPolicies` 是一件库级的事,按档案重复只会把一个事实放大成 n 行噪音。
233
+
234
+ ## 与 dsh-agent-teams 的关系
235
+
236
+ 团队生命周期、任务派发、成员持久化归 `dsh-agent-teams`;本插件只管成员**定义**与交接的诚实性:只读 `.agent-teams/<team>/team.json` 核对运行时真正存了什么,读不到就说「无法核对」。从不写状态。
237
+
238
+ ## 开发
239
+
240
+ ```bash
241
+ npm install
242
+ npm test # node --test,162 个用例
243
+ npm run smoke # 用真实 DSH bundle 注册工具,断言输出可无损往返
244
+ node scripts/check-locale.mjs # client.js 里的静态 t() 键必须两本词典都有
245
+ ```
246
+
247
+ ```
248
+ index.js 宿主半边:Config、apply、六条路由
249
+ lib/ format / parse / validate / registry / bridge / verify / render / library /
250
+ config-view / json / http / write / service / teams-state / tools
251
+ client.js 客户端半边:左侧栏图标 + 主面板(档案/团队/体检)+ 设置页小节
252
+ locale/ 面板与列表词典(en/zh)
253
+ schema/ roster.v1.schema.json(编辑器补全用,与校验器由测试对齐)
254
+ bin/cli.mjs validate / list / version,给 CI 用
255
+ scripts/ host-smoke.mjs、client-smoke.mjs、check-locale.mjs(开发用,不随包发布)
256
+ examples/ 两座示例档案库
257
+ test/ 单测
258
+ ```
259
+
260
+ ## 验证进度
261
+
262
+ 本机已验证:
263
+
264
+ - `npm test` —— 162/162 通过(格式、解析、校验、扫描、路径、桥接、核对、无损 JSON、写入计划与执行、六条路由、快照与提示、两本词典一致、工具契约、客户端加载器契约、包清单、配置视图、样式 token 约束、声明层字段与队长段、schema 与常量对齐)。
265
+ - `npm run smoke` —— 用真实 DSH bundle 的 `defineTool` 注册五个工具,逐个调用:输出全部通过无损校验、`path` 覆盖生效、释放干净;同一条命令随后用桩模块表加载 `client.js`,断言三处槽位注册、样式只注入一次、effect 可回收。
266
+ - CLI:`validate --dir examples` 对两座示例库退出码 0。
267
+ - 用插件管理器真的装进了本机 profile(`plugin_manager install_bundle` 指向本仓库 → `link:` 依赖 + bundle 条目),重启后按链接读取本仓库文件。
268
+ - 运行中的宿主已能应答只读路由:`GET /plugins/dsh-agent-roster/library` 返回 200,带插件版本与解析出的档案目录。
269
+ - 另一个 agent 会话在工具清单里看到了五个工具,并实际调用了它们。
270
+ - 左侧栏面板已在活页面里确认可见,并展示了真实档案。
271
+
272
+ 这一轮没验证到的:0.2.0 的管理界面(页签、编辑器、团队页、语言切换、`document` / `rosters` 路由)在活页面里的表现——这些改动要重启宿主才会被加载,重启后再验。
273
+
274
+ 验证过程抓到三个真缺陷并已修:
275
+
276
+ 1. `volatile()` 配置字段进 `apply()` 时是 `{ get, write }` 引用对象,导致面板和所有工具一直读默认值、设置表单形同虚设。现在读取统一走 `lib/config-view.js`,每次访问都取当前值,类型不对则退回默认值。
277
+ 2. `roster_list` 忽略 `path` 参数,永远扫配置目录。`resolvePaths` 现在把显式 `path` 当作覆盖。
278
+ 3. 工具返回值里带 `undefined` 键,运行时判 `value is not lossless JSON` 而整次失败。缺失值现在写 `null`(或直接省略),且所有返回值先过 `lib/json.js#lossless`。
279
+
280
+ ## 许可
281
+
282
+ MIT,见 [LICENSE](./LICENSE)。
package/bin/cli.mjs ADDED
@@ -0,0 +1,325 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 命令行入口:给 CI 与本地预检用。
4
+ *
5
+ * 工具是给模型用的,CLI 是给人/CI 用的:同一套校验器,不需要启动宿主。
6
+ * 退出码是契约:0 = 全部通过(警告不算失败),1 = 至少一处 error,2 = 用法错误。
7
+ *
8
+ * 用法:
9
+ * dsh-agent-roster validate [--dir rosters] [--known-policy <id>]... [--json]
10
+ * dsh-agent-roster list [--dir rosters]
11
+ * dsh-agent-roster registry validate --file index/registry.json [--json]
12
+ * dsh-agent-roster registry fetch --file index/registry.json [--local-only]
13
+ * dsh-agent-roster registry sync --file index/registry.json [--write]
14
+ */
15
+ import { readFileSync, writeFileSync } from "node:fs";
16
+ import nodePath from "node:path";
17
+
18
+ import { resolveDirSpec } from "../lib/home.js";
19
+ import { scanRosterLibrary } from "../lib/registry.js";
20
+ import { renderRosterList } from "../lib/render.js";
21
+ import { REGISTRY_KIND } from "../lib/format.js";
22
+ import { fetchCollection, readRegistryFile } from "../lib/remote.js";
23
+
24
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
25
+
26
+ const USAGE = `用法:
27
+ dsh-agent-roster validate [选项] 校验一座档案库,有 error 时退出码为 1
28
+ dsh-agent-roster list [选项] 列出档案与成员
29
+ dsh-agent-roster registry validate --file <路径> 校验公共索引(registry)
30
+ dsh-agent-roster registry fetch --file <路径> 逐条确认 collection 真的能取到
31
+ dsh-agent-roster registry sync --file <路径> 按仓库实际内容重算 roster 列表
32
+ dsh-agent-roster --version 输出版本号
33
+
34
+ 选项:
35
+ --dir <path> 档案库目录,可重复;默认全局库 $DSH_HOME/agent-roster(~/.dsh/agent-roster)
36
+ --known-policy <id> 部署已声明的 toolPolicy id,可重复;用于交叉校验
37
+ --file <path> registry 文件(registry 子命令用)
38
+ --local-only fetch 只验本仓库登记的 collection(CI 里用,不依赖外网)
39
+ --write sync 把重算结果写回文件
40
+ --json 以 JSON 输出(validate 与 list 都支持)
41
+ --help 显示本帮助
42
+ `;
43
+
44
+ /** 极简参数解析:这个 CLI 不值得引入依赖。 */
45
+ function parseArgs(argv) {
46
+ const options = {
47
+ command: null,
48
+ subcommand: null,
49
+ dirs: [],
50
+ knownPolicies: [],
51
+ json: false,
52
+ help: false,
53
+ file: null,
54
+ localOnly: false,
55
+ write: false,
56
+ };
57
+ for (let index = 0; index < argv.length; index += 1) {
58
+ const arg = argv[index];
59
+ if (arg === "--help" || arg === "-h") options.help = true;
60
+ else if (arg === "--json") options.json = true;
61
+ else if (arg === "--local-only") options.localOnly = true;
62
+ else if (arg === "--write") options.write = true;
63
+ else if (arg === "--version" || arg === "-v") options.command = "version";
64
+ else if (arg === "--dir") {
65
+ const value = argv[index + 1];
66
+ if (value === undefined) throw new UsageError("--dir 需要一个路径");
67
+ options.dirs.push(value);
68
+ index += 1;
69
+ } else if (arg === "--file") {
70
+ const value = argv[index + 1];
71
+ if (value === undefined) throw new UsageError("--file 需要一个路径");
72
+ options.file = value;
73
+ index += 1;
74
+ } else if (arg === "--known-policy") {
75
+ const value = argv[index + 1];
76
+ if (value === undefined) throw new UsageError("--known-policy 需要一个 id");
77
+ options.knownPolicies.push(value);
78
+ index += 1;
79
+ } else if (arg.startsWith("-")) throw new UsageError(`未知选项:${arg}`);
80
+ else if (options.command === null) options.command = arg;
81
+ else if (options.command === "registry" && options.subcommand === null) options.subcommand = arg;
82
+ else throw new UsageError(`多余参数:${arg}`);
83
+ }
84
+ return options;
85
+ }
86
+
87
+ class UsageError extends Error {}
88
+
89
+ /** 把诊断折成一行;让终端输出和工具输出口径一致。 */
90
+ function formatDiagnostic(item) {
91
+ const where = [item.file, item.path].filter(Boolean).join(" · ");
92
+ return ` ${item.severity.toUpperCase()} ${item.code}${where === "" ? "" : ` (${where})`}: ${item.reason}`;
93
+ }
94
+
95
+ async function main(argv) {
96
+ let options;
97
+ try {
98
+ options = parseArgs(argv);
99
+ } catch (error) {
100
+ if (error instanceof UsageError) {
101
+ process.stderr.write(`${error.message}\n\n${USAGE}`);
102
+ return 2;
103
+ }
104
+ throw error;
105
+ }
106
+
107
+ if (options.help || options.command === null) {
108
+ process.stdout.write(USAGE);
109
+ return options.command === null && !options.help ? 2 : 0;
110
+ }
111
+ if (options.command === "version") {
112
+ process.stdout.write(`${pkg.name} ${pkg.version}\n`);
113
+ return 0;
114
+ }
115
+ if (options.command === "registry") {
116
+ if (options.file === null) {
117
+ process.stderr.write(`registry 子命令需要 --file\n\n${USAGE}`);
118
+ return 2;
119
+ }
120
+ if (options.subcommand !== "validate" && options.subcommand !== "fetch" && options.subcommand !== "sync") {
121
+ process.stderr.write(`未知 registry 子命令:${String(options.subcommand)}\n\n${USAGE}`);
122
+ return 2;
123
+ }
124
+ return runRegistry(options);
125
+ }
126
+ if (options.command !== "validate" && options.command !== "list") {
127
+ process.stderr.write(`未知命令:${options.command}\n\n${USAGE}`);
128
+ return 2;
129
+ }
130
+
131
+ const dirs = options.dirs.length > 0 ? options.dirs : [""];
132
+ const knownToolPolicies = options.knownPolicies.length > 0 ? options.knownPolicies : null;
133
+ const reports = dirs.map((dir) => {
134
+ // 与插件同一条解析规则:留空 = 全局库,相对路径才相对当前目录。
135
+ const rootDir = resolveDirSpec(dir, process.cwd());
136
+ const scan = scanRosterLibrary({ rootDir, knownToolPolicies });
137
+ return { dir, rootDir, scan };
138
+ });
139
+
140
+ if (options.json) {
141
+ process.stdout.write(
142
+ `${JSON.stringify(
143
+ {
144
+ plugin: pkg.name,
145
+ version: pkg.version,
146
+ reports: reports.map((report) => ({
147
+ rootDir: report.rootDir,
148
+ missing: report.scan.missing,
149
+ stats: report.scan.stats,
150
+ rosters: report.scan.rosters.map((roster) => ({ id: roster.id, file: roster.file })),
151
+ diagnostics: report.scan.diagnostics,
152
+ })),
153
+ },
154
+ null,
155
+ 2,
156
+ )}\n`,
157
+ );
158
+ } else {
159
+ for (const report of reports) {
160
+ process.stdout.write(`${report.rootDir}\n`);
161
+ if (options.command === "list") {
162
+ process.stdout.write(`${renderRosterList(report.scan)}\n`);
163
+ } else {
164
+ const { stats } = report.scan;
165
+ process.stdout.write(
166
+ ` 档案 ${stats.total}(通过 ${stats.ok},失败 ${stats.failed}),成员 ${stats.members},` +
167
+ `诊断 ${stats.errors} error / ${stats.warnings} warn / ${stats.infos} info\n`,
168
+ );
169
+ for (const roster of report.scan.rosters) {
170
+ process.stdout.write(` OK ${roster.id} (${roster.file}) ${roster.members.length} 名成员\n`);
171
+ }
172
+ const diagnostics = report.scan.diagnostics;
173
+ if (diagnostics.length > 0) {
174
+ process.stdout.write(" 诊断:\n");
175
+ for (const item of diagnostics) process.stdout.write(`${formatDiagnostic(item)}\n`);
176
+ }
177
+ }
178
+ process.stdout.write("\n");
179
+ }
180
+ }
181
+
182
+ const hasError = reports.some((report) => report.scan.stats.errors > 0);
183
+ return hasError ? 1 : 0;
184
+ }
185
+
186
+ /**
187
+ * registry 三个子命令:validate / fetch / sync。
188
+ *
189
+ * 这三个动作就是“索引怎么更新”的答案:
190
+ * - `validate` 在 PR 里跑,格式不对就不许合并;
191
+ * - `fetch` 确认索引里写的每份档案真的存在(本仓库的走本地文件,其余走网络);
192
+ * - `sync` 用仓库实际内容重算 `rosters` 与 `generatedAt`,把人肉维护改成机器盖章。
193
+ */
194
+ async function runRegistry(options) {
195
+ const file = nodePath.resolve(process.cwd(), options.file);
196
+ const output = (text) => process.stdout.write(`${text}\n`);
197
+
198
+ let report;
199
+ try {
200
+ report = readRegistryFile(file);
201
+ } catch (error) {
202
+ process.stderr.write(`读不了 ${file}:${error instanceof Error ? error.message : String(error)}\n`);
203
+ return 1;
204
+ }
205
+
206
+ const diagnostics = [...report.diagnostics];
207
+ const registry = report.registry;
208
+ const own = ownRepo();
209
+
210
+ if (options.subcommand === "fetch" && registry !== null) {
211
+ const repoRoot = nodePath.resolve(nodePath.dirname(file), "..");
212
+ for (const collection of registry.collections) {
213
+ if (isOwnCollection(collection, own)) {
214
+ const scanned = scanRosterLibrary({ rootDir: nodePath.join(repoRoot, collection.path) });
215
+ const present = new Set(scanned.rosters.map((roster) => roster.id));
216
+ for (const id of collection.rosters) {
217
+ if (!present.has(id)) {
218
+ diagnostics.push({
219
+ severity: "error",
220
+ code: "collection-roster-missing",
221
+ reason: `${collection.id} 登记了 ${id},但 ${collection.path}/${id} 里没有合法档案`,
222
+ file,
223
+ path: collection.id,
224
+ });
225
+ }
226
+ }
227
+ for (const roster of scanned.rosters) {
228
+ if (!collection.rosters.includes(roster.id)) {
229
+ diagnostics.push({
230
+ severity: "warn",
231
+ code: "collection-roster-unlisted",
232
+ reason: `${collection.path}/${roster.id} 存在但没登记(跑 registry sync 会补上)`,
233
+ file,
234
+ path: collection.id,
235
+ });
236
+ }
237
+ }
238
+ continue;
239
+ }
240
+ if (options.localOnly) {
241
+ output(`跳过 ${collection.id}(不是本仓库登记,--local-only 下不联网)`);
242
+ continue;
243
+ }
244
+ const fetched = await fetchCollection(collection);
245
+ diagnostics.push(...fetched.diagnostics);
246
+ if (fetched.rosters.length !== collection.rosters.length) {
247
+ diagnostics.push({
248
+ severity: "warn",
249
+ code: "collection-fetch-partial",
250
+ reason: `${collection.id} 登记 ${collection.rosters.length} 份,实际取到 ${fetched.rosters.length} 份`,
251
+ file,
252
+ path: collection.id,
253
+ });
254
+ }
255
+ output(`${collection.id}: 取到 ${fetched.rosters.length}/${collection.rosters.length} 份`);
256
+ }
257
+ }
258
+
259
+ if (options.subcommand === "sync" && registry !== null) {
260
+ const repoRoot = nodePath.resolve(nodePath.dirname(file), "..");
261
+ const document = JSON.parse(readFileSync(file, "utf8"));
262
+ document.generatedAt = new Date().toISOString();
263
+ document.kind = REGISTRY_KIND;
264
+ let changed = false;
265
+ for (const entry of document.collections ?? []) {
266
+ const asCollection = registry.collections.find((item) => item.id === entry.id);
267
+ if (asCollection === undefined || !isOwnCollection(asCollection, own)) continue;
268
+ const scanned = scanRosterLibrary({ rootDir: nodePath.join(repoRoot, asCollection.path) });
269
+ const ids = scanned.rosters.map((roster) => roster.id).sort();
270
+ if (JSON.stringify(ids) !== JSON.stringify((entry.rosters ?? []).slice().sort())) {
271
+ entry.rosters = ids;
272
+ changed = true;
273
+ }
274
+ }
275
+ if (options.write) {
276
+ writeFileSync(file, `${JSON.stringify(document, null, 2)}\n`);
277
+ output(`已写回 ${file}(${changed ? "roster 列表有更新" : "列表未变,只更新了 generatedAt"})`);
278
+ } else {
279
+ output(`${changed ? "需要更新" : "无需更新"}:${file}(加 --write 才会落盘)`);
280
+ }
281
+ }
282
+
283
+ if (options.json) {
284
+ process.stdout.write(
285
+ `${JSON.stringify(
286
+ {
287
+ plugin: pkg.name,
288
+ version: pkg.version,
289
+ file,
290
+ subcommand: options.subcommand,
291
+ collections: registry === null ? [] : registry.collections.map((item) => ({ id: item.id, rosters: item.rosters })),
292
+ diagnostics,
293
+ },
294
+ null,
295
+ 2,
296
+ )}\n`,
297
+ );
298
+ } else {
299
+ if (options.subcommand === "validate") {
300
+ output(`${file}`);
301
+ output(
302
+ ` collection ${registry === null ? 0 : registry.collections.length} 条,` +
303
+ `诊断 ${diagnostics.filter((item) => item.severity === "error").length} error / ` +
304
+ `${diagnostics.filter((item) => item.severity === "warn").length} warn`,
305
+ );
306
+ }
307
+ for (const item of diagnostics) output(formatDiagnostic(item));
308
+ }
309
+
310
+ return diagnostics.some((item) => item.severity === "error") ? 1 : 0;
311
+ }
312
+
313
+ /** 从 package.json 的 repository 推本仓库坐标(失败时用内置值)。 */
314
+ function ownRepo() {
315
+ const url = typeof pkg.repository?.url === "string" ? pkg.repository.url : "";
316
+ const match = /github\.com[/:]([^/]+)\/([^/.]+?)(?:\.git)?$/.exec(url);
317
+ return match === null ? "stofone/dsh-agent-roster" : `${match[1]}/${match[2]}`.toLowerCase();
318
+ }
319
+
320
+ /** 这条 collection 是不是本仓库(CI 里可以走本地文件,不必联网)。 */
321
+ function isOwnCollection(collection, own) {
322
+ return collection.repo.replace(/^github:/, "").toLowerCase() === own;
323
+ }
324
+
325
+ process.exitCode = await main(process.argv.slice(2));