@stofone/dsh-agent-roster 0.7.0 → 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.
Files changed (36) hide show
  1. package/CHANGELOG.md +35 -1
  2. package/README.md +39 -2
  3. package/README.zh.md +39 -2
  4. package/bin/cli.mjs +10 -0
  5. package/index/registry.json +45 -1
  6. package/lib/format.js +4 -5
  7. package/lib/organization.js +331 -0
  8. package/lib/registry.js +83 -1
  9. package/lib/render.js +15 -0
  10. package/lib/tools.js +3 -0
  11. package/orgs/agent-roster-collective/api-design/roster.yaml +67 -0
  12. package/orgs/agent-roster-collective/content-ops/roster.yaml +55 -0
  13. package/orgs/agent-roster-collective/contract-review/roster.yaml +58 -0
  14. package/orgs/agent-roster-collective/data-quality/roster.yaml +64 -0
  15. package/orgs/agent-roster-collective/db-migration/roster.yaml +64 -0
  16. package/orgs/agent-roster-collective/docs-writer/roster.yaml +59 -0
  17. package/orgs/agent-roster-collective/finance-recon/roster.yaml +63 -0
  18. package/orgs/agent-roster-collective/frontend-ui/roster.yaml +60 -0
  19. package/orgs/agent-roster-collective/i18n-l10n/roster.yaml +60 -0
  20. package/orgs/agent-roster-collective/incident-response/roster.yaml +65 -0
  21. package/orgs/agent-roster-collective/literature-review/roster.yaml +61 -0
  22. package/orgs/agent-roster-collective/market-research/roster.yaml +60 -0
  23. package/orgs/agent-roster-collective/ml-eval/roster.yaml +63 -0
  24. package/orgs/agent-roster-collective/organization.yaml +118 -0
  25. package/orgs/agent-roster-collective/performance/roster.yaml +63 -0
  26. package/orgs/agent-roster-collective/product-discovery/roster.yaml +55 -0
  27. package/orgs/agent-roster-collective/qa-regression/roster.yaml +64 -0
  28. package/orgs/agent-roster-collective/recruiting/roster.yaml +58 -0
  29. package/orgs/agent-roster-collective/release-manager/roster.yaml +59 -0
  30. package/orgs/agent-roster-collective/security-audit/capabilities.yaml +12 -0
  31. package/orgs/agent-roster-collective/security-audit/roster.yaml +65 -0
  32. package/orgs/agent-roster-collective/sre-oncall/roster.yaml +60 -0
  33. package/orgs/agent-roster-collective/support-triage/roster.yaml +58 -0
  34. package/package.json +2 -1
  35. package/schema/organization.v1.schema.json +50 -0
  36. package/schema/registry.v1.schema.json +83 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,39 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.8.0] - 2026-11-02
8
+
9
+ ### Library organization (new root file)
10
+
11
+ - **`organization.yaml`** — an optional file at the library root, beside the roster directories.
12
+ It declares which rosters belong to the library, their status (`active` | `archived`) and where
13
+ they came from. It is the only file allowed to sit at the root, and it still cannot create
14
+ anything: no teams, no members, no `agent_teams_add_member` arguments.
15
+ - **Three-way cross-check against the disk**, surfaced as diagnostics the panel already renders:
16
+ declared but missing → **error**, present but undeclared → **warn**, declared `archived` but
17
+ still in the library → **info**. Archiving is a declaration, not a deletion.
18
+ - **`roster_list` and `dsh-agent-roster validate` show it** (`organization=<id>`, the counts, and
19
+ per-file `organization` / `organizationCheck` in `--json`);
20
+ `schema/organization.v1.schema.json` covers the file for editor-side validation.
21
+ - **`orgs/agent-roster-collective/`** ships as a worked example: 21 self-authored rosters
22
+ (63 members) covering review, incident response, data, docs, release, research and more.
23
+ - **`security-audit/capabilities.yaml`** in that example shows the two-file library: a roster plus
24
+ its declared requirements, with `onMissing: degraded` so a deployment without the policy still
25
+ builds the team but reports the gap instead of pretending it passed.
26
+
27
+ ### Registry schema (new file)
28
+
29
+ - **`schema/registry.v1.schema.json`** — the shipped `index/registry.json` pointed at a schema that
30
+ did not exist, so editors had nothing to lint against. The file now exists, mirrors
31
+ `lib/remote.js#validateRegistry`, and `test/registry-schema.test.js` keeps the two aligned
32
+ (field sets, id/repo/ref/path patterns, collection and roster limits).
33
+
34
+ ### Removed
35
+
36
+ - **`INDEX_KIND`** (`dsh-agent-roster-index/v1`). Nothing ever produced that document: a registry
37
+ collection already carries its `rosters` list, and `registry sync` recomputes it from the
38
+ repository. Keeping a second manifest format would have been a second source of truth.
39
+
7
40
  ## [0.7.0] - 2026-11-02
8
41
 
9
42
  ### Honest verification (issue #2)
@@ -263,6 +296,7 @@
263
296
  - 工具返回值含 `undefined` 键,运行时按 `value is not lossless JSON` 整次拒绝;缺失值统一写 `null`,
264
297
  且所有返回值先过 `lib/json.js#lossless`。
265
298
 
266
- [Unreleased]: https://github.com/STofone/dsh-agent-roster/compare/v0.7.0...main
299
+ [Unreleased]: https://github.com/STofone/dsh-agent-roster/compare/v0.8.0...main
300
+ [0.8.0]: https://github.com/STofone/dsh-agent-roster/releases/tag/v0.8.0
267
301
  [0.7.0]: https://github.com/STofone/dsh-agent-roster/releases/tag/v0.7.0
268
302
  [0.2.0]: https://github.com/STofone/dsh-agent-roster/releases/tag/v0.2.0
package/README.md CHANGED
@@ -187,6 +187,40 @@ Behaviour: under `onMissing: refuse` (default) `roster_create` emits **no** `age
187
187
 
188
188
  The sibling file has its own JSON Schema: [`schema/capabilities.v1.schema.json`](./schema/capabilities.v1.schema.json), kept aligned with `lib/capabilities.js` by `test/capabilities.test.js`.
189
189
 
190
+ ## Library organization (`organization.yaml`)
191
+
192
+ A roster answers *who the members are*; it cannot answer **which rosters belong together, which one is production and which is retired**. When a library holds twenty archives from different sources, the directory listing stops being an index. An optional file at the **library root** declares that, and nothing else:
193
+
194
+ ```yaml
195
+ kind: dsh-agent-roster-organization/v1
196
+ organization: agent-roster-collective # must equal the directory name
197
+ title: 团队档案合集
198
+ maintainers: [STofone]
199
+ rosters:
200
+ - {id: api-design, title: API 设计评审组, status: active, origin: 本项目自撰}
201
+ - {id: legacy-team, status: archived}
202
+ ```
203
+
204
+ Placement matters: the file sits **next to** the roster directories, not inside one. It is a table of contents for the library, so it is the only file allowed at the root — scatter a `foo.yaml` there and the scanner still warns `unexpected-file`.
205
+
206
+ The declaration is mechanically checked against disk, and the three outcomes are deliberately different severities:
207
+
208
+ | Situation | Code | Severity | Why |
209
+ |---|---|---|---|
210
+ | declared, directory missing | `organization-roster-missing` | **error** | the organization page is lying — worse than no page |
211
+ | directory present, not declared | `organization-roster-undeclared` | warn | someone added a roster around the index; it still loads |
212
+ | declared `archived`, directory still there | `organization-roster-archived` | info | archiving is a statement, not a delete |
213
+
214
+ `organization` must equal the directory name, exactly like `roster` does: position is identity, and two ids for one library would make every diagnostic ambiguous. Unknown keys are rejected — a field nobody can check is another way to look green.
215
+
216
+ This file creates nothing. It does not write `team.json`, does not create members, and never reaches `agent_teams_add_member`. Its JSON Schema is [`schema/organization.v1.schema.json`](./schema/organization.v1.schema.json), kept aligned with `lib/organization.js` by `test/organization.test.js`.
217
+
218
+ **A worked example ships in this repository:** [`orgs/agent-roster-collective/`](./orgs/agent-roster-collective/) holds 21 rosters (63 members) covering API review, incident response, data quality, database migration, localization, security audit, performance, frontend, docs, release, support triage, recruiting, market research, finance reconciliation, contract review, content ops, regression testing, model evaluation, SRE on-call, literature review and product discovery. Every roster is a three-person team with a captain, and each was written for this repository (no upstream text is copied). Validate it with:
219
+
220
+ ```bash
221
+ node bin/cli.mjs validate --dir orgs/agent-roster-collective
222
+ ```
223
+
190
224
  ## Tools
191
225
 
192
226
  | Tool | Purpose |
@@ -274,6 +308,8 @@ repository's `index/registry.json`, and anyone who subscribes can see them in th
274
308
  - **Pulling**: writes are idempotent and report one of `created`, `updated` (explicit overwrite), `unchanged`
275
309
  (same content, nothing written) or `conflict` (the local copy differs and is **not** overwritten by default).
276
310
  Conflicts are yours to resolve: merge in the editor, or pull again with `overwrite` in the request body.
311
+ - **The index has a schema**: [`schema/registry.v1.schema.json`](./schema/registry.v1.schema.json) mirrors the
312
+ runtime validator, so an editor flags a typo before CI does.
277
313
  - **Publishing your own**: add an entry to `collections` in `index/registry.json` (`id`, `repo: github:owner/repo`,
278
314
  `ref`, `path`, `rosters[]`) and open a PR. `.github/workflows/registry.yml` checks the index against the real files.
279
315
 
@@ -339,10 +375,11 @@ lib/ format, parse, validate, registry, bridge, verify, render, lib
339
375
  config-view, json, http, write, service, teams-state, tools
340
376
  client.js client plugin: sidebar entry, main panel (rosters/teams/health), settings section
341
377
  locale/ panel + listing dictionaries (en/zh)
342
- schema/ roster.v1.schema.json (editor completion; kept aligned by tests)
378
+ schema/ roster / capabilities / organization / registry v1 schemas (editor completion; kept aligned by tests)
343
379
  bin/cli.mjs validate/list/version for CI
344
380
  scripts/ host-smoke.mjs, client-smoke.mjs, check-locale.mjs (dev only, not shipped)
345
381
  examples/ two example libraries
382
+ orgs/ a worked example library with its own organization.yaml (21 rosters)
346
383
  test/ unit tests
347
384
  ```
348
385
 
@@ -350,7 +387,7 @@ test/ unit tests
350
387
 
351
388
  Verified on this machine:
352
389
 
353
- - `npm test` — 223/223 passing (format, parse, validate, registry, paths, bridge, verify buckets, digests and provenance, roster diff, capability requirements and the capability gate, lossless JSON, write plan/apply, all routes, panel snapshot and hints, dictionary consistency, tool contracts, client loader contract, library snapshot, config view, package manifest, declared fields and captain block, schema/constant alignment, remote registry and one-click install).
390
+ - `npm test` — 239/239 passing (format, parse, validate, registry, paths, bridge, verify buckets, digests and provenance, roster diff, capability requirements and the capability gate, library organization, registry schema, lossless JSON, write plan/apply, all routes, panel snapshot and hints, dictionary consistency, tool contracts, client loader contract, library snapshot, config view, package manifest, declared fields and captain block, schema/constant alignment, remote registry and one-click install).
354
391
  - `npm run smoke` — `apply()` registers all six tools through a real `defineTool` from a DSH bundle; `roster_list` / `roster_show` / `roster_create` / `roster_verify` / `roster_diff` execute, every output survives `lossless` checks, `path` overrides are honored, effect disposal runs cleanly. The same command then loads `client.js` against a stubbed module table and asserts the three slot registrations, style injection and effect disposal.
355
392
  - CLI: `validate --dir examples` returns exit 0 over both example libraries.
356
393
  - Installed into a real profile with the plugin manager (`plugin_manager install_bundle` against this directory → `link:` dependency + bundle entry); the profile re-reads the linked files on restart.
package/README.zh.md CHANGED
@@ -183,6 +183,40 @@ notes: 建队前先确认成员会话可用。
183
183
 
184
184
  兄弟文件有自己的 JSON Schema:[`schema/capabilities.v1.schema.json`](./schema/capabilities.v1.schema.json),由 `test/capabilities.test.js` 钉住与 `lib/capabilities.js` 一致。
185
185
 
186
+ ## 档案库组织(`organization.yaml`)
187
+
188
+ 档案回答的是**成员是谁**,回答不了**哪几份档案属于同一套、哪份是生产、哪份已退役**。一座库放二十份来自不同来源的档案时,目录列表就不再是索引。库**根目录**上一个可选文件只声明这件事:
189
+
190
+ ```yaml
191
+ kind: dsh-agent-roster-organization/v1
192
+ organization: agent-roster-collective # 必须等于目录名
193
+ title: 团队档案合集
194
+ maintainers: [STofone]
195
+ rosters:
196
+ - {id: api-design, title: API 设计评审组, status: active, origin: 本项目自撰}
197
+ - {id: legacy-team, status: archived}
198
+ ```
199
+
200
+ 位置是有讲究的:它和档案目录**并列**,不在任何一份档案里面。它是这座库的目录页,所以也是库根唯一被允许的文件——在根上散落一个 `foo.yaml` 依然会报 `unexpected-file`。
201
+
202
+ 声明会和磁盘对账,三种结果刻意用不同严重度:
203
+
204
+ | 情况 | 代码 | 严重度 | 理由 |
205
+ |---|---|---|---|
206
+ | 声明了,目录不在 | `organization-roster-missing` | **error** | 组织页在撒谎,比没有组织页更糟 |
207
+ | 目录在,没登记 | `organization-roster-undeclared` | warn | 有人绕过索引加了档案,但它仍会被加载 |
208
+ | 声明 `archived`,目录还在 | `organization-roster-archived` | info | 归档是声明,不是删除 |
209
+
210
+ `organization` 必须等于目录名,和 `roster` 同一条规则:位置即身份,一座库两个 id 会让所有诊断都变得没法定位。未知键一律拒收——没人核得了的字段,只是换一种方式发绿。
211
+
212
+ 这份文件什么都不创建:不写 `team.json`、不建成员,也永远不会进入 `agent_teams_add_member` 参数。它的 JSON Schema 在 [`schema/organization.v1.schema.json`](./schema/organization.v1.schema.json),由 `test/organization.test.js` 钉住与 `lib/organization.js` 一致。
213
+
214
+ **仓内带了一个可跑的示例**:[`orgs/agent-roster-collective/`](./orgs/agent-roster-collective/) 里有 21 份档案(63 名成员),覆盖接口评审、事故响应、数据质量、数据库迁移、本地化、安全审计、性能、前端、文档、发布、客服分诊、招聘、市场研究、财务对账、合同审阅、内容运营、回归测试、模型评测、SRE 值守、文献综述与产品发现。每份都是"队长 + 两名专家"的三人组,全部为本项目自撰(未复制任何上游文本)。校验方式:
215
+
216
+ ```bash
217
+ node bin/cli.mjs validate --dir orgs/agent-roster-collective
218
+ ```
219
+
186
220
  ## 工具
187
221
 
188
222
  | 工具 | 作用 |
@@ -259,6 +293,8 @@ notes: 建队前先确认成员会话可用。
259
293
  - **拉取到本机**:写入是幂等的,四种结果都会报给你——
260
294
  `created`(新写入)、`updated`(显式覆盖)、`unchanged`(内容一致,不写盘)、`conflict`(本地不一样,**默认不覆盖**)。
261
295
  冲突要你自己决定:要么在编辑器里合并,要么再点一次「拉取到本机」并在请求里带 `overwrite`。
296
+ - **索引本身也有 schema**:[`schema/registry.v1.schema.json`](./schema/registry.v1.schema.json) 与运行时校验器对齐,
297
+ 拼错字段在编辑器里就会提示,不必等 CI。
262
298
  - **登记自己的档案**:在 `index/registry.json` 的 `collections` 里加一条(`id`、`repo: github:owner/repo`、
263
299
  `ref`、`path`、`rosters[]`),提交 PR 即可;`.github/workflows/registry.yml` 会校验索引与仓库真实文件是否对得上。
264
300
 
@@ -322,10 +358,11 @@ lib/ format / parse / validate / registry / bridge / verify / rende
322
358
  config-view / json / http / write / service / teams-state / tools
323
359
  client.js 客户端半边:左侧栏图标 + 主面板(档案/团队/体检)+ 设置页小节
324
360
  locale/ 面板与列表词典(en/zh)
325
- schema/ roster.v1.schema.json(编辑器补全用,与校验器由测试对齐)
361
+ schema/ roster / capabilities / organization / registry v1 schema(编辑器补全用,与校验器由测试对齐)
326
362
  bin/cli.mjs validate / list / version,给 CI 用
327
363
  scripts/ host-smoke.mjs、client-smoke.mjs、check-locale.mjs(开发用,不随包发布)
328
364
  examples/ 两座示例档案库
365
+ orgs/ 带 organization.yaml 的示例库(21 套档案)
329
366
  test/ 单测
330
367
  ```
331
368
 
@@ -333,7 +370,7 @@ test/ 单测
333
370
 
334
371
  本机已验证:
335
372
 
336
- - `npm test` —— 223/223 通过(格式、解析、校验、扫描、路径、桥接、核对四桶、摘要与溯源、档案差异、能力要求与能力闸门、无损 JSON、写入计划与执行、六条路由、快照与提示、两本词典一致、工具契约、客户端加载器契约、包清单、配置视图、样式 token 约束、声明层字段与队长段、schema 与常量对齐)。
373
+ - `npm test` —— 239/239 通过(格式、解析、校验、扫描、路径、桥接、核对四桶、摘要与溯源、档案差异、能力要求与能力闸门、档案库组织、索引 schema、无损 JSON、写入计划与执行、六条路由、快照与提示、两本词典一致、工具契约、客户端加载器契约、包清单、配置视图、样式 token 约束、声明层字段与队长段、schema 与常量对齐)。
337
374
  - `npm run smoke` —— 用真实 DSH bundle 的 `defineTool` 注册六个工具,逐个调用:输出全部通过无损校验、`path` 覆盖生效、释放干净;同一条命令随后用桩模块表加载 `client.js`,断言三处槽位注册、样式只注入一次、effect 可回收。
338
375
  - CLI:`validate --dir examples` 对两座示例库退出码 0。
339
376
  - 用插件管理器真的装进了本机 profile(`plugin_manager install_bundle` 指向本仓库 → `link:` 依赖 + bundle 条目),重启后按链接读取本仓库文件。
package/bin/cli.mjs CHANGED
@@ -147,6 +147,8 @@ async function main(argv) {
147
147
  rootDir: report.rootDir,
148
148
  missing: report.scan.missing,
149
149
  stats: report.scan.stats,
150
+ organization: report.scan.organization,
151
+ organizationCheck: report.scan.organizationCheck,
150
152
  rosters: report.scan.rosters.map((roster) => ({ id: roster.id, file: roster.file })),
151
153
  diagnostics: report.scan.diagnostics,
152
154
  })),
@@ -162,6 +164,14 @@ async function main(argv) {
162
164
  process.stdout.write(`${renderRosterList(report.scan)}\n`);
163
165
  } else {
164
166
  const { stats } = report.scan;
167
+ const organization = report.scan.organization ?? null;
168
+ if (organization !== null) {
169
+ const check = report.scan.organizationCheck;
170
+ process.stdout.write(
171
+ ` 组织 ${organization.id} ${organization.title}(登记 ${check.declared} · 在库 ${check.present} · ` +
172
+ `缺 ${check.missing.length} · 未登记 ${check.undeclared.length})\n`,
173
+ );
174
+ }
165
175
  process.stdout.write(
166
176
  ` 档案 ${stats.total}(通过 ${stats.ok},失败 ${stats.failed}),成员 ${stats.members},` +
167
177
  `诊断 ${stats.errors} error / ${stats.warnings} warn / ${stats.infos} info\n`,
@@ -2,7 +2,7 @@
2
2
  "$schema": "../schema/registry.v1.schema.json",
3
3
  "kind": "dsh-agent-roster-registry/v1",
4
4
  "version": 1,
5
- "generatedAt": "2026-10-30T00:00:00.000Z",
5
+ "generatedAt": "2026-11-02T17:00:43.713Z",
6
6
  "generator": "STofone/dsh-agent-roster (hand-maintained; verified by .github/workflows/registry.yml)",
7
7
  "source": "https://github.com/STofone/dsh-agent-roster",
8
8
  "collections": [
@@ -28,6 +28,50 @@
28
28
  ],
29
29
  "homepage": "https://github.com/STofone/dsh-agent-roster#examples"
30
30
  },
31
+ {
32
+ "id": "agent-roster-collective",
33
+ "title": "自撰团队档案合集(21 套 · 63 名成员)",
34
+ "description": "覆盖评审、事故响应、数据、文档、发布、研究等常见协作分工的 21 套档案,全部由本项目自撰(未复制任何上游文本),可直接复制改成自己的。",
35
+ "repo": "github:STofone/dsh-agent-roster",
36
+ "ref": "main",
37
+ "path": "orgs/agent-roster-collective",
38
+ "rosters": [
39
+ "api-design",
40
+ "content-ops",
41
+ "contract-review",
42
+ "data-quality",
43
+ "db-migration",
44
+ "docs-writer",
45
+ "finance-recon",
46
+ "frontend-ui",
47
+ "i18n-l10n",
48
+ "incident-response",
49
+ "literature-review",
50
+ "market-research",
51
+ "ml-eval",
52
+ "performance",
53
+ "product-discovery",
54
+ "qa-regression",
55
+ "recruiting",
56
+ "release-manager",
57
+ "security-audit",
58
+ "sre-oncall",
59
+ "support-triage"
60
+ ],
61
+ "tags": [
62
+ "collection",
63
+ "examples",
64
+ "operations",
65
+ "data",
66
+ "docs",
67
+ "research"
68
+ ],
69
+ "license": "MIT",
70
+ "maintainers": [
71
+ "STofone"
72
+ ],
73
+ "homepage": "https://github.com/STofone/dsh-agent-roster/tree/main/orgs/agent-roster-collective"
74
+ },
31
75
  {
32
76
  "id": "agent-teams",
33
77
  "title": "Agent Teams(wshobson/agents 迁移)",
package/lib/format.js CHANGED
@@ -10,14 +10,13 @@
10
10
  /** 档案文档格式标识(出现在每份 roster.yaml 的 kind 字段)。 */
11
11
  export const ROSTER_KIND = "dsh-agent-roster/v1";
12
12
 
13
- /** 索引文档格式标识(由 scan CLI / GitHub Action 产出)。 */
14
- export const INDEX_KIND = "dsh-agent-roster-index/v1";
15
-
16
13
  /**
17
14
  * 公共档案库目录(registry)的格式标识。
18
15
  *
19
- * 与 {@link INDEX_KIND} 的区别:index 是“一个档案库扫描后的清单”(roster 指针),
20
- * registry 是“许多档案库的登记簿”(collection 指针)——后者才是订阅/共享的入口。
16
+ * 曾经另有一个 `dsh-agent-roster-index/v1`(“一个档案库扫描后的清单”),但没有任何命令
17
+ * 产出过它:registry 的每条 collection 本来就带 `rosters` 列表,并由 `registry sync`
18
+ * 按仓库实际内容重算——再定义第二种清单格式等于造第二个真源。故该常量已删除,
19
+ * 见 CHANGELOG 的 Removed 一节。
21
20
  */
22
21
  export const REGISTRY_KIND = "dsh-agent-roster-registry/v1";
23
22
 
@@ -0,0 +1,331 @@
1
+ /**
2
+ * 组织档案(organization):一座档案库的"目录页"。
3
+ *
4
+ * 为什么需要它:档案库只能回答"这一份档案写了什么",回答不了"这些档案是什么关系、
5
+ * 哪份是生产、哪份是归档、谁维护"。当一座库里放二十份来自不同来源的档案时,
6
+ * 缺了这层就只能靠目录名猜。
7
+ *
8
+ * 边界(与 capabilities.yaml 同一条纪律):
9
+ * - 组织只做**声明与核对**:声明库里有哪几份档案、各自什么状态;
10
+ * 它不创建团队、不改档案、不进 `agent_teams_add_member`。
11
+ * - 它**能被机械核对**:声明了但目录不在 = error,目录在却没声明 = warn,
12
+ * 已归档却仍在用 = info。核对不了的字段一律不收(未知键即错误)。
13
+ * - 位置即身份:`organization` 必须等于所在目录名,和 `roster` 的规则一致。
14
+ *
15
+ * @module dsh-agent-roster/organization
16
+ */
17
+
18
+ /** 组织文件的 `kind`。与 roster / registry / capabilities 并列,互不冒充。 */
19
+ export const ORGANIZATION_KIND = "dsh-agent-roster-organization/v1";
20
+
21
+ /** 组织文件允许的文件名,按优先级排列。 */
22
+ export const ORGANIZATION_FILE_NAMES = ["organization.yaml", "organization.yml", "organization.json"];
23
+
24
+ /** 档案在组织里的状态。刻意只有两档:归档不等于删除,删除就是没有这个目录。 */
25
+ export const ORGANIZATION_STATUSES = ["active", "archived"];
26
+
27
+ /** 长度上限:与档案的 LIMITS 同一套口径,超了就报错而不是截断。 */
28
+ export const LIMITS = {
29
+ rosters: 64,
30
+ titleChars: 120,
31
+ descriptionChars: 500,
32
+ licenseChars: 120,
33
+ homepageChars: 200,
34
+ maintainers: 16,
35
+ maintainerChars: 120,
36
+ noteChars: 300,
37
+ };
38
+
39
+ /** 组织 id 与档案 id 同形:小写、连字符、首字符必须是字母或数字。 */
40
+ export const ID_PATTERN = /^[a-z0-9][a-z0-9-]{1,63}$/;
41
+
42
+ const ROOT_FIELDS = new Set([
43
+ "kind",
44
+ "organization",
45
+ "title",
46
+ "description",
47
+ "license",
48
+ "homepage",
49
+ "maintainers",
50
+ "rosters",
51
+ ]);
52
+
53
+ const ROSTER_FIELDS = new Set(["id", "title", "status", "origin", "note"]);
54
+
55
+ /** 顶层字段清单(供 schema 对齐测试反向钉住,避免两处各写一份)。 */
56
+ export const ORGANIZATION_FIELDS = Object.freeze([...ROOT_FIELDS]);
57
+
58
+ /** 每条登记项的字段清单。 */
59
+ export const ORGANIZATION_ROSTER_FIELDS = Object.freeze([...ROSTER_FIELDS]);
60
+
61
+ /**
62
+ * 一条诊断。形状与 `lib/validate.js` 保持一致,好让 render / panel / CLI 用同一套渲染。
63
+ *
64
+ * @param {"error"|"warn"|"info"} severity
65
+ * @param {string} code
66
+ * @param {string} file
67
+ * @param {string} reason
68
+ * @returns {{severity: string, code: string, path: string, file: string, reason: string}}
69
+ */
70
+ function diagnose(severity, code, file, reason) {
71
+ return { severity, code, path: "", file, reason };
72
+ }
73
+
74
+ /**
75
+ * 校验一份组织档案。
76
+ *
77
+ * 与 roster 的校验同一条纪律:**能核对的才收**。`homepage` 只校验是不是 http(s),
78
+ * 不联网;`maintainers` 只校验形态,不校验这个人存在不存在。
79
+ *
80
+ * @param {unknown} value 解析后的 JSON/YAML 值
81
+ * @param {{file?: string, expectedId?: string|null}} [options]
82
+ * @returns {{ok: boolean, organization: object|null, diagnostics: object[]}}
83
+ */
84
+ export function validateOrganization(value, options = {}) {
85
+ const file = options.file ?? ORGANIZATION_FILE_NAMES[0];
86
+ const expectedId = options.expectedId ?? null;
87
+ /** @type {object[]} */
88
+ const diagnostics = [];
89
+
90
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
91
+ diagnostics.push(diagnose("error", "organization-not-object", file, "组织文件必须是一个对象(键值对)。"));
92
+ return { ok: false, organization: null, diagnostics };
93
+ }
94
+
95
+ for (const key of Object.keys(value)) {
96
+ if (!ROOT_FIELDS.has(key)) {
97
+ diagnostics.push(
98
+ diagnose(
99
+ "error",
100
+ "unknown-field",
101
+ file,
102
+ `未知字段 "${key}"。组织文件只收 ${[...ROOT_FIELDS].join(" / ")}:` +
103
+ "核对不了的字段不收——写了也不会被任何人验证,只会被误当成事实。",
104
+ ),
105
+ );
106
+ }
107
+ }
108
+
109
+ if (value.kind !== ORGANIZATION_KIND) {
110
+ diagnostics.push(
111
+ diagnose("error", "organization-kind", file, `kind 必须是 "${ORGANIZATION_KIND}",实际是 ${JSON.stringify(value.kind)}。`),
112
+ );
113
+ }
114
+
115
+ const id = value.organization;
116
+ if (typeof id !== "string" || !ID_PATTERN.test(id)) {
117
+ diagnostics.push(
118
+ diagnose("error", "organization-id", file, "organization 必须是 2–64 位小写字母/数字/连字符,且以字母或数字开头。"),
119
+ );
120
+ } else if (expectedId !== null && id !== expectedId) {
121
+ diagnostics.push(
122
+ diagnose(
123
+ "error",
124
+ "organization-id-mismatch",
125
+ file,
126
+ `organization 是 "${id}",但所在目录叫 "${expectedId}"。位置即身份:组织 id 必须等于目录名。`,
127
+ ),
128
+ );
129
+ }
130
+
131
+ const title = optionalString(value, "title", LIMITS.titleChars, diagnostics, file, "organization-title");
132
+ optionalString(value, "description", LIMITS.descriptionChars, diagnostics, file, "organization-description");
133
+ optionalString(value, "license", LIMITS.licenseChars, diagnostics, file, "organization-license");
134
+
135
+ if (value.homepage !== undefined) {
136
+ const homepage = optionalString(value, "homepage", LIMITS.homepageChars, diagnostics, file, "organization-homepage");
137
+ if (typeof homepage === "string" && !/^https?:\/\/\S+$/i.test(homepage)) {
138
+ diagnostics.push(
139
+ diagnose("error", "organization-homepage", file, "homepage 只收 http(s) 地址:别的写法既打不开也核对不了。"),
140
+ );
141
+ }
142
+ }
143
+
144
+ if (value.maintainers !== undefined) {
145
+ if (!Array.isArray(value.maintainers)) {
146
+ diagnostics.push(diagnose("error", "organization-maintainers", file, "maintainers 必须是字符串数组。"));
147
+ } else if (value.maintainers.length > LIMITS.maintainers) {
148
+ diagnostics.push(
149
+ diagnose("error", "organization-maintainers", file, `maintainers 最多 ${LIMITS.maintainers} 项。`),
150
+ );
151
+ } else {
152
+ for (const who of value.maintainers) {
153
+ if (typeof who !== "string" || who.length === 0 || who.length > LIMITS.maintainerChars) {
154
+ diagnostics.push(
155
+ diagnose("error", "organization-maintainer", file, `维护者名字必须是 1–${LIMITS.maintainerChars} 个字符的字符串。`),
156
+ );
157
+ break;
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ const rosters = [];
164
+ const rawRosters = value.rosters;
165
+ if (!Array.isArray(rawRosters) || rawRosters.length === 0) {
166
+ diagnostics.push(diagnose("error", "organization-rosters", file, "rosters 必须是非空数组:组织的意义就是登记这些档案。"));
167
+ } else if (rawRosters.length > LIMITS.rosters) {
168
+ diagnostics.push(diagnose("error", "organization-rosters", file, `rosters 最多 ${LIMITS.rosters} 项。`));
169
+ } else {
170
+ const seen = new Set();
171
+ rawRosters.forEach((item, index) => {
172
+ const where = `rosters[${index}]`;
173
+ if (item === null || typeof item !== "object" || Array.isArray(item)) {
174
+ diagnostics.push(diagnose("error", "organization-roster-not-object", file, `${where} 必须是对象。`));
175
+ return;
176
+ }
177
+ for (const key of Object.keys(item)) {
178
+ if (!ROSTER_FIELDS.has(key)) {
179
+ diagnostics.push(
180
+ diagnose("error", "unknown-field", file, `${where} 的未知字段 "${key}"(只收 ${[...ROSTER_FIELDS].join(" / ")})。`),
181
+ );
182
+ }
183
+ }
184
+ if (typeof item.id !== "string" || !ID_PATTERN.test(item.id)) {
185
+ diagnostics.push(diagnose("error", "organization-roster-id", file, `${where}.id 必须是合法档案 id。`));
186
+ return;
187
+ }
188
+ if (seen.has(item.id)) {
189
+ diagnostics.push(
190
+ diagnose("error", "organization-duplicate-roster", file, `档案 "${item.id}" 在组织里登记了两次。`),
191
+ );
192
+ return;
193
+ }
194
+ seen.add(item.id);
195
+ const status = item.status ?? "active";
196
+ if (!ORGANIZATION_STATUSES.includes(status)) {
197
+ diagnostics.push(
198
+ diagnose("error", "organization-roster-status", file, `${where}.status 只能是 ${ORGANIZATION_STATUSES.join(" / ")}。`),
199
+ );
200
+ }
201
+ const entryTitle = optionalString(item, "title", LIMITS.titleChars, diagnostics, file, "organization-roster-title", where);
202
+ const origin = optionalString(item, "origin", LIMITS.titleChars, diagnostics, file, "organization-roster-origin", where);
203
+ const note = optionalString(item, "note", LIMITS.noteChars, diagnostics, file, "organization-roster-note", where);
204
+ rosters.push({
205
+ id: item.id,
206
+ title: entryTitle ?? "",
207
+ status,
208
+ origin: origin ?? "",
209
+ note: note ?? "",
210
+ });
211
+ });
212
+ }
213
+
214
+ if (diagnostics.some((item) => item.severity === "error")) {
215
+ return { ok: false, organization: null, diagnostics };
216
+ }
217
+
218
+ return {
219
+ ok: true,
220
+ organization: {
221
+ kind: ORGANIZATION_KIND,
222
+ id,
223
+ title: title ?? "",
224
+ description: value.description ?? "",
225
+ license: value.license ?? "",
226
+ homepage: value.homepage ?? "",
227
+ maintainers: Array.isArray(value.maintainers) ? [...value.maintainers] : [],
228
+ rosters,
229
+ },
230
+ diagnostics,
231
+ };
232
+ }
233
+
234
+ /**
235
+ * 读一个可选字符串字段并钉住上限。
236
+ *
237
+ * @param {object} source
238
+ * @param {string} key
239
+ * @param {number} max
240
+ * @param {object[]} diagnostics
241
+ * @param {string} file
242
+ * @param {string} code
243
+ * @param {string} [where]
244
+ * @returns {string|undefined}
245
+ */
246
+ function optionalString(source, key, max, diagnostics, file, code, where = "") {
247
+ const value = source[key];
248
+ if (value === undefined) return undefined;
249
+ const label = where === "" ? key : `${where}.${key}`;
250
+ if (typeof value !== "string" || value.length > max) {
251
+ diagnostics.push(diagnose("error", code, file, `${label} 必须是长度不超过 ${max} 的字符串。`));
252
+ return undefined;
253
+ }
254
+ return value;
255
+ }
256
+
257
+ /**
258
+ * 组织声明与磁盘实际情况的交叉核对。
259
+ *
260
+ * 三件事分开说,因为它们的严重度不同:
261
+ * - **缺**(声明了却找不到目录):error。组织页在撒谎,比没有组织页更糟。
262
+ * - **多**(目录在却没登记):warn。不是错误,但意味着有人绕过组织加了档案。
263
+ * - **归档仍在**:info。归档是声明,不是删除;提醒一句即可。
264
+ *
265
+ * @param {object|null} organization
266
+ * @param {string[]} rosterIds 磁盘上真正通过校验的档案 id
267
+ * @returns {{declared: number, present: number, missing: string[], undeclared: string[], archived: string[], status: string}}
268
+ */
269
+ export function checkOrganization(organization, rosterIds) {
270
+ const onDisk = new Set(rosterIds);
271
+ const declared = new Set(organization === null ? [] : organization.rosters.map((item) => item.id));
272
+ const missing = [...declared].filter((id) => !onDisk.has(id)).sort();
273
+ const undeclared = [...onDisk].filter((id) => !declared.has(id)).sort();
274
+ const archived = organization === null
275
+ ? []
276
+ : organization.rosters.filter((item) => item.status === "archived" && onDisk.has(item.id)).map((item) => item.id).sort();
277
+ return {
278
+ declared: declared.size,
279
+ present: onDisk.size,
280
+ missing,
281
+ undeclared,
282
+ archived,
283
+ status: organization === null ? "absent" : missing.length > 0 ? "incomplete" : "ok",
284
+ };
285
+ }
286
+
287
+ /**
288
+ * 把交叉核对结果折成诊断。
289
+ *
290
+ * @param {object|null} organization
291
+ * @param {object} check {@link checkOrganization} 的返回值
292
+ * @param {string} file 组织文件相对路径
293
+ * @returns {object[]}
294
+ */
295
+ export function organizationDiagnostics(organization, check, file) {
296
+ const diagnostics = [];
297
+ if (organization === null) return diagnostics;
298
+ if (check.missing.length > 0) {
299
+ diagnostics.push(
300
+ diagnose(
301
+ "error",
302
+ "organization-roster-missing",
303
+ file,
304
+ `组织声明了 ${check.missing.length} 份档案但库里找不到:${check.missing.join(", ")}。` +
305
+ "要么把目录加回来,要么把它们从 organization 里删掉——组织页不能比磁盘更乐观。",
306
+ ),
307
+ );
308
+ }
309
+ if (check.undeclared.length > 0) {
310
+ diagnostics.push(
311
+ diagnose(
312
+ "warn",
313
+ "organization-roster-undeclared",
314
+ file,
315
+ `库里有 ${check.undeclared.length} 份档案没在组织里登记:${check.undeclared.join(", ")}。` +
316
+ "它们仍会被加载,但组织页看不到它们——补一行登记,或确认它们不该在这里。",
317
+ ),
318
+ );
319
+ }
320
+ if (check.archived.length > 0) {
321
+ diagnostics.push(
322
+ diagnose(
323
+ "info",
324
+ "organization-roster-archived",
325
+ file,
326
+ `已归档但仍在库里的档案:${check.archived.join(", ")}。归档只是声明,不影响加载。`,
327
+ ),
328
+ );
329
+ }
330
+ return diagnostics;
331
+ }