@stofone/dsh-agent-roster 0.10.1 → 0.10.3

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 CHANGED
@@ -4,6 +4,40 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.10.3] - 2026-11-03
8
+
9
+ ### Added
10
+
11
+ - `migrate --from <dir>` and `--repo <owner/repo>`: a repository that publishes a collection keeps its
12
+ v1 rosters in `rosters/<id>/`, so the library root and the old files are not the same directory. The
13
+ migration now rewrites that file in place, adds `roles/` and `dist/` at the root, removes the stale
14
+ `roster.json`/`roster.yml` beside it (two spellings of one roster are two sources of truth), and does
15
+ **not** delete `rosters/<id>/` — that is where the new assembly lives.
16
+ - A collection's `path` may be empty, meaning the repository root *is* the library. The three example
17
+ repositories (`agent-teams`, `metagpt-company`, `openmanus`) now use that shape: `roles/`, `rosters/`,
18
+ `dist/` at the root, `files` pointing at the compiled artifact.
19
+
20
+ ### Fixed
21
+
22
+ - The `agent-teams`, `metagpt-company` and `openmanus` collections were still published as v1, so their
23
+ `roster.yaml` was fetched while a v2 world had already moved on; every entry now names its artifact.
24
+
25
+ ## [0.10.2] - 2026-11-03
26
+
27
+ ### Fixed
28
+
29
+ - **Every v2 collection was unfetchable (HTTP 404).** The index assumed the v1 flat layout
30
+ (`<path>/<id>/roster.yaml`), which stopped existing the moment rosters moved to `rosters/<id>/`.
31
+ A collection may now pin one relative path per roster in an optional `files` map;
32
+ `registry sync` fills it in and points v2 rosters at the self-contained `dist/<id>-<ver>.json`
33
+ (an assembly alone cannot be pulled — its members are role references).
34
+ - Pulling a compiled artifact flattens it into an inline v1 document (personas are the assembled
35
+ prompt, which is what would actually be sent); run `migrate` afterwards if you want the role split
36
+ on your machine. A bare `roster@2` is reported as `collection-roster-needs-roles` instead of being
37
+ fetched as if it were one file.
38
+ - `flattenCompiledToRoster` no longer emits empty `title`/`description` strings — v1 rejects those,
39
+ so a flattened artifact used to fail its own validation.
40
+
7
41
  ## [0.10.1] - 2026-11-03
8
42
 
9
43
  ### Docs
@@ -456,7 +490,11 @@
456
490
  - 工具返回值含 `undefined` 键,运行时按 `value is not lossless JSON` 整次拒绝;缺失值统一写 `null`,
457
491
  且所有返回值先过 `lib/json.js#lossless`。
458
492
 
459
- [Unreleased]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.9.2...main
493
+ [Unreleased]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.10.3...main
494
+ [0.10.3]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.10.2...v0.10.3
495
+ [0.10.2]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.10.1...v0.10.2
496
+ [0.10.1]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.10.0...v0.10.1
497
+ [0.10.0]: https://github.com/dsh-agent-roster/dsh-agent-roster/releases/tag/v0.10.0
460
498
  [0.9.2]: https://github.com/dsh-agent-roster/dsh-agent-roster/releases/tag/v0.9.2
461
499
  [0.9.1]: https://github.com/dsh-agent-roster/dsh-agent-roster/releases/tag/v0.9.1
462
500
  [0.9.0]: https://github.com/dsh-agent-roster/dsh-agent-roster/releases/tag/v0.9.0
package/README.md CHANGED
@@ -276,6 +276,18 @@ Migration keeps personas verbatim: a member's `systemPrompt` becomes the body of
276
276
  loose field and becomes a role file of its own — `<roster>-<captain>-captain` when its name collides
277
277
  with a member, because those are two different personas and silently merging them would lose one.
278
278
 
279
+ If the v1 rosters live in a subdirectory (the usual case for a repository that publishes a
280
+ collection), point the migration at the library root and tell it where the old files are:
281
+
282
+ ```bash
283
+ node bin/cli.mjs migrate --dir . --from rosters --repo owner/repo --write
284
+ ```
285
+
286
+ That layout rewrites `rosters/<id>/roster.yaml` in place, adds `roles/` and `dist/` at the repository
287
+ root, and drops the stale `roster.json`/`roster.yml` next to it — two spellings of one roster are two
288
+ sources of truth. The collection then uses `path: ""` (the repository root *is* the library) and a
289
+ `files` map pointing at `dist/`, because an assembly only references roles and cannot be pulled alone.
290
+
279
291
  Role names carry the roster id prefix because v2 requires role names to be **unique per library**,
280
292
  and a library whose 21 rosters all call their captain `lead` cannot have 21 files with the same name.
281
293
 
@@ -383,6 +395,10 @@ repository's `index/registry.json`, and anyone who subscribes can see them in th
383
395
  - **Pulling**: writes are idempotent and report one of `created`, `updated` (explicit overwrite), `unchanged`
384
396
  (same content, nothing written) or `conflict` (the local copy differs and is **not** overwritten by default).
385
397
  Conflicts are yours to resolve: merge in the editor, or pull again with `overwrite` in the request body.
398
+ - **Where each roster lives**: a collection's `rosters[]` is just the list of ids; the optional
399
+ `files` map says where each one is (`{"core-review": "dist/core-review-1.0.0.json"}`). It is optional —
400
+ without it the fetcher assumes the v1 layout `<id>/roster.yaml` — but a v2 library needs it, because an
401
+ assembly only references roles and cannot be pulled on its own. `registry sync` writes this map for you.
386
402
  - **The index has a schema**: [`schema/registry.v1.schema.json`](./schema/registry.v1.schema.json) mirrors the
387
403
  runtime validator, so an editor flags a typo before CI does.
388
404
  - **Publishing your own**: add an entry to `collections` in `index/registry.json` (`id`, `repo: github:owner/repo`,
@@ -464,7 +480,7 @@ test/ unit tests
464
480
 
465
481
  Verified on this machine:
466
482
 
467
- - `npm test` — 286/286 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, the v2 role/roster/compiled layers and their schema alignment, v1→v2 migration, run records feeding the plan, remote registry and one-click install).
483
+ - `npm test` — 293/293 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, the v2 role/roster/compiled layers and their schema alignment, v1→v2 migration, run records feeding the plan, remote registry and one-click install).
468
484
  - `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.
469
485
  - CLI: `validate --dir examples` returns exit 0 over both example libraries.
470
486
  - 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
@@ -269,6 +269,16 @@ node bin/cli.mjs migrate --dir examples --write # 原地重写:roles/ + ros
269
269
 
270
270
  迁移逐字保留人设:成员的 `systemPrompt` 原文成为 `roles/<档案>-<成员>/AGENT.md` 的正文,成员本身变成引用。队长不再是一个散字段,而是自己的角色文件——与成员同名时得到 `-captain` 后缀,因为那是两份人设,悄悄合并会丢掉一份。
271
271
 
272
+ 如果 v1 的档案在子目录里(把仓库当 collection 发布的常见形态),把 `--dir` 指到库根、用 `--from` 指旧目录:
273
+
274
+ ```bash
275
+ node bin/cli.mjs migrate --dir . --from rosters --repo owner/repo --write
276
+ ```
277
+
278
+ 这种布局会就地重写 `rosters/<id>/roster.yaml`,在仓库根上补 `roles/` 与 `dist/`,并删掉并排的旧
279
+ `roster.json`/`roster.yml`——同一份档案两种写法就是两个真源。之后 collection 用 `path: ""`(仓库根就是库),
280
+ 再用 `files` 指向 `dist/`:组装只引用角色,单独拉一个文件拉不到角色目录。
281
+
272
282
  角色名带档案 id 前缀,是因为 v2 要求同库角色名唯一:一座库里 21 套组装都叫自己的队长 `lead`,不可能有 21 个同名文件。
273
283
 
274
284
  ### 把失败的一轮喂回施工图
@@ -364,6 +374,9 @@ node bin/cli.mjs compile --roster examples-orchestration/rosters/core-review \
364
374
  冲突要你自己决定:要么在编辑器里合并,要么再点一次「拉取到本机」并在请求里带 `overwrite`。
365
375
  - **索引本身也有 schema**:[`schema/registry.v1.schema.json`](./schema/registry.v1.schema.json) 与运行时校验器对齐,
366
376
  拼错字段在编辑器里就会提示,不必等 CI。
377
+ - **每份档案住在哪**:collection 的 `rosters[]` 只是 id 名单,实际位置由可选的 `files` 映射给出
378
+ (如 `{"core-review": "dist/core-review-1.0.0.json"}`)。不写就按 v1 布局 `<id>/roster.yaml` 猜——v2 的库必须写,
379
+ 因为组装本身只引用角色,单独拉一个文件拉不到角色目录。`registry sync` 会替你填。
367
380
  - **登记自己的档案**:在 `index/registry.json` 的 `collections` 里加一条(`id`、`repo: github:owner/repo`、
368
381
  `ref`、`path`、`rosters[]`),提交 PR 即可;`.github/workflows/registry.yml` 会校验索引与仓库真实文件是否对得上。
369
382
 
@@ -441,7 +454,7 @@ test/ 单测
441
454
 
442
455
  本机已验证:
443
456
 
444
- - `npm test` —— 286/286 通过(格式、解析、校验、扫描、路径、桥接、核对四桶、摘要与溯源、档案差异、能力要求与能力闸门、档案库组织、索引 schema、无损 JSON、写入计划与执行、六条路由、快照与提示、两本词典一致、工具契约、客户端加载器契约、包清单、配置视图、样式 token 约束、声明层字段与队长段、schema 与常量对齐、v2 角色/组装/编译三层及其 schema 对齐、v1→v2 迁移、执行回执并入施工图)。
457
+ - `npm test` —— 293/293 通过(格式、解析、校验、扫描、路径、桥接、核对四桶、摘要与溯源、档案差异、能力要求与能力闸门、档案库组织、索引 schema、无损 JSON、写入计划与执行、六条路由、快照与提示、两本词典一致、工具契约、客户端加载器契约、包清单、配置视图、样式 token 约束、声明层字段与队长段、schema 与常量对齐、v2 角色/组装/编译三层及其 schema 对齐、v1→v2 迁移、执行回执并入施工图)。
445
458
  - `npm run smoke` —— 用真实 DSH bundle 的 `defineTool` 注册六个工具,逐个调用:输出全部通过无损校验、`path` 覆盖生效、释放干净;同一条命令随后用桩模块表加载 `client.js`,断言三处槽位注册、样式只注入一次、effect 可回收。
446
459
  - CLI:`validate --dir examples` 对两座示例库退出码 0。
447
460
  - 用插件管理器真的装进了本机 profile(`plugin_manager install_bundle` 指向本仓库 → `link:` 依赖 + bundle 条目),重启后按链接读取本仓库文件。
package/bin/cli.mjs CHANGED
@@ -13,6 +13,7 @@
13
13
  * dsh-agent-roster registry sync --file index/registry.json [--write]
14
14
  * dsh-agent-roster compile --roster rosters/money-lab [--roles roles] [--run run.json] [--write] [--json]
15
15
  * dsh-agent-roster migrate --dir examples [--write] [--json]
16
+ * dsh-agent-roster migrate --dir . --from rosters --repo owner/repo --write
16
17
  */
17
18
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
19
  import nodePath from "node:path";
@@ -26,6 +27,7 @@ import { compileRosterAt } from "../lib/compile2.js";
26
27
  import { migrateLibrary } from "../lib/migrate.js";
27
28
  import { compilePlan } from "../lib/plan.js";
28
29
  import { mergeRunIntoPlan, validateRun } from "../lib/run.js";
30
+ import { DIST_DIR } from "../lib/migrate.js";
29
31
 
30
32
  const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
31
33
 
@@ -36,7 +38,8 @@ const USAGE = `用法:
36
38
  dsh-agent-roster registry fetch --file <路径> 逐条确认 collection 真的能取到
37
39
  dsh-agent-roster registry sync --file <路径> 按仓库实际内容重算 roster 列表
38
40
  dsh-agent-roster compile --roster <目录> 把 v2 组装编译成自包含产物(不写盘)
39
- dsh-agent-roster migrate --dir <目录> 把 v1 档案原地迁成 v2(roles/ + rosters/ + dist/)
41
+ dsh-agent-roster migrate --dir <目录> [--from <子目录>] [--repo <owner/repo>] [--write]
42
+ 把 v1 档案原地迁成 v2(roles/ + rosters/ + dist/)
40
43
  dsh-agent-roster --version 输出版本号
41
44
 
42
45
  选项:
@@ -46,6 +49,8 @@ const USAGE = `用法:
46
49
  --local-only fetch 只验本仓库登记的 collection(CI 里用,不依赖外网)
47
50
  --write sync 把重算结果写回文件
48
51
  --roster <path> v2 组装目录(compile 用)
52
+ --from <path> v1 档案所在目录(migrate 用;缺省与 --dir 相同,用于仓库根 + rosters/ 布局)
53
+ --repo <owner/repo> 写进 dist 产物的仓库坐标(migrate 用;缺省取本仓 remote)
49
54
  --run <path> 执行回执(compile 用;把上一轮失败并入重试卡)
50
55
  --roles <path> 角色库目录(compile 用;缺省按 rosters/<id> 推同级的 roles/)
51
56
  --json 以 JSON 输出(validate / list / compile 都支持)
@@ -67,6 +72,8 @@ function parseArgs(argv) {
67
72
  roster: null,
68
73
  roles: null,
69
74
  run: null,
75
+ from: null,
76
+ repo: null,
70
77
  };
71
78
  for (let index = 0; index < argv.length; index += 1) {
72
79
  const arg = argv[index];
@@ -90,6 +97,16 @@ function parseArgs(argv) {
90
97
  if (value === undefined) throw new UsageError("--roster 需要一个路径");
91
98
  options.roster = value;
92
99
  index += 1;
100
+ } else if (arg === "--from") {
101
+ const value = argv[index + 1];
102
+ if (value === undefined) throw new UsageError("--from 需要一个路径");
103
+ options.from = value;
104
+ index += 1;
105
+ } else if (arg === "--repo") {
106
+ const value = argv[index + 1];
107
+ if (value === undefined) throw new UsageError("--repo 需要 owner/name");
108
+ options.repo = value;
109
+ index += 1;
93
110
  } else if (arg === "--run") {
94
111
  const value = argv[index + 1];
95
112
  if (value === undefined) throw new UsageError("--run 需要一个路径");
@@ -442,6 +459,17 @@ async function runRegistry(options) {
442
459
  entry.rosters = ids;
443
460
  changed = true;
444
461
  }
462
+ const files = {};
463
+ for (const roster of scanned.rosters.slice().sort((a, b) => a.id.localeCompare(b.id))) {
464
+ files[roster.id] =
465
+ roster.format === "v2"
466
+ ? `${DIST_DIR}/${roster.id}-${roster.version}.json`
467
+ : `${roster.id}/${roster.file ?? "roster.yaml"}`;
468
+ }
469
+ if (JSON.stringify(files) !== JSON.stringify(entry.files ?? {})) {
470
+ entry.files = files;
471
+ changed = true;
472
+ }
445
473
  }
446
474
  if (options.write) {
447
475
  writeFileSync(file, `${JSON.stringify(document, null, 2)}\n`);
@@ -508,7 +536,13 @@ function runMigrate(options) {
508
536
  const reports = [];
509
537
  for (const dir of dirs) {
510
538
  const rootDir = resolveDirSpec(dir, process.cwd());
511
- const report = migrateLibrary(rootDir, { write: options.write, knownToolPolicies, source: { repo: ownRepo(), ref: "main" } });
539
+ const fromDir = options.from === null ? rootDir : resolveDirSpec(options.from, process.cwd());
540
+ const report = migrateLibrary(rootDir, {
541
+ write: options.write,
542
+ knownToolPolicies,
543
+ source: { repo: options.repo ?? ownRepo(), ref: "main" },
544
+ fromDir,
545
+ });
512
546
  reports.push({ dir, rootDir, report });
513
547
  if (report.diagnostics.some((item) => item.severity === "error")) rc = 1;
514
548
  if (options.json) continue;
package/docs/roster-v2.md CHANGED
@@ -197,6 +197,8 @@ gates:
197
197
  ## 8. 迁移
198
198
 
199
199
  - `roster migrate --dir <库> [--write]` 读 v1 档案,把 `systemPrompt` 拆成角色(`roles/<member>/AGENT.md`)+ 组装(成员改为引用),原地重写并生成 `dist/`。默认只出计划,`--write` 才落盘——这是唯一会删旧目录的命令。
200
+ - `roster migrate --dir <库根> --from <旧目录> [--repo <owner/repo>] [--write]`:v1 档案放在子目录(`rosters/<id>/roster.yaml`,即"仓库本身就是库、只是档案隔了一层")时用这条。它就地重写 `rosters/<id>/roster.yaml`、在库根补 `roles/` 与 `dist/`、删掉并排的 `roster.json`/`roster.yml`(同义文件=第二真源),**不删** `rosters/<id>/` —— 那个目录就是新档案的落脚处。`--repo` 写进产物的 `source.repo`,避免发布仓的产物署错名。
201
+ - **collection 的 `path` 可以是空字符串**,表示仓库根就是库:`roles/`、`rosters/`、`dist/` 直接躺在根上。配合 `files` 映射指向 `dist/<id>-<ver>.json`。
200
202
  - **角色名带档案 id 前缀**(`core-review-reviewer`):v2 要求同库角色名唯一,而真实档案库里几十套组装都可能叫 `lead`。队长与成员同名时,队长得到 `-captain` 后缀,不与人设合并。
201
203
  - `maintainers` 用 `handle` 模式(允许大写与点),不复用档案 id 的小写短横线规则——维护者是账号,不是标识符。
202
204
  - v1 冻结:新字段只进 v2,不再回填 v1。
@@ -212,9 +214,14 @@ gates:
212
214
 
213
215
  三者正交:`requires` 说"必须有",`capabilities.yaml` 说"这份组装必须有",`organization.yaml` 说"这座库有哪些"。
214
216
 
217
+ 远端的 registry 索引也是同理:登记的 `rosters[]` 只是 id 名单,实际位置由 `files` 映射给出。
218
+ v1 的库可以省略(回退成 `<id>/roster.yaml`);v2 的库必须写,**指向 `dist/` 的编译产物**——
219
+ 组装文件本身只引用角色,单独拉一个 `roster.yaml` 拉不到角色目录,那不是"没实现",是"拉下来也不完整"。
220
+ 拉到编译产物后,客户端把它摊成一份内联 v1 文档(人设就是拼装结果),要角色分层就在本地跑 `migrate`。
221
+
215
222
  ## 10. 未决与下一步
216
223
 
217
224
  1. **施工图 ↔ 团队真实卡 id 的映射**:`run@1` 已经能读、能合并(`compile --run` 会把上一轮失败写进重试卡),但「施工图内部 id ↔ 团队真实 task id」的映射表还没落成文件——目前靠执行者按 id 命名建卡。
218
- 2. `dist` 的发布与 `registry` 只登记产物(现在 registry 登记的是档案路径)。
225
+ 2. `dist` 的发布流程(索引已能逐份指向 `dist/<id>-<ver>.json`,但"从 dist 反推角色版本、做跨库一致性检查"还没做)。
219
226
  3. `profile/agent-preset` 目标:把队长与成员的 preset 片段生成出来,仍然只产参不写入。
220
227
  4. 编辑器支持:三个 schema 已就位,LSP 级校验待做。
@@ -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-11-02T17:00:43.713Z",
5
+ "generatedAt": "2026-11-03T06:14:00.000Z",
6
6
  "generator": "dsh-agent-roster/dsh-agent-roster (hand-maintained; verified by .github/workflows/registry.yml)",
7
7
  "source": "https://github.com/dsh-agent-roster/dsh-agent-roster",
8
8
  "collections": [
@@ -26,7 +26,11 @@
26
26
  "maintainers": [
27
27
  "STofone"
28
28
  ],
29
- "homepage": "https://github.com/dsh-agent-roster/dsh-agent-roster#examples"
29
+ "homepage": "https://github.com/dsh-agent-roster/dsh-agent-roster#examples",
30
+ "files": {
31
+ "core-review": "dist/core-review-1.0.0.json",
32
+ "research": "dist/research-0.1.0.json"
33
+ }
30
34
  },
31
35
  {
32
36
  "id": "agent-roster-collective",
@@ -70,7 +74,30 @@
70
74
  "maintainers": [
71
75
  "STofone"
72
76
  ],
73
- "homepage": "https://github.com/dsh-agent-roster/dsh-agent-roster/tree/main/orgs/agent-roster-collective"
77
+ "homepage": "https://github.com/dsh-agent-roster/dsh-agent-roster/tree/main/orgs/agent-roster-collective",
78
+ "files": {
79
+ "api-design": "dist/api-design-1.0.0.json",
80
+ "content-ops": "dist/content-ops-1.0.0.json",
81
+ "contract-review": "dist/contract-review-1.0.0.json",
82
+ "data-quality": "dist/data-quality-1.0.0.json",
83
+ "db-migration": "dist/db-migration-1.0.0.json",
84
+ "docs-writer": "dist/docs-writer-1.0.0.json",
85
+ "finance-recon": "dist/finance-recon-1.0.0.json",
86
+ "frontend-ui": "dist/frontend-ui-1.0.0.json",
87
+ "i18n-l10n": "dist/i18n-l10n-1.0.0.json",
88
+ "incident-response": "dist/incident-response-1.0.0.json",
89
+ "literature-review": "dist/literature-review-1.0.0.json",
90
+ "market-research": "dist/market-research-1.0.0.json",
91
+ "ml-eval": "dist/ml-eval-1.0.0.json",
92
+ "performance": "dist/performance-1.0.0.json",
93
+ "product-discovery": "dist/product-discovery-1.0.0.json",
94
+ "qa-regression": "dist/qa-regression-1.0.0.json",
95
+ "recruiting": "dist/recruiting-1.0.0.json",
96
+ "release-manager": "dist/release-manager-1.0.0.json",
97
+ "security-audit": "dist/security-audit-1.0.0.json",
98
+ "sre-oncall": "dist/sre-oncall-1.0.0.json",
99
+ "support-triage": "dist/support-triage-1.0.0.json"
100
+ }
74
101
  },
75
102
  {
76
103
  "id": "agent-teams",
@@ -78,7 +105,7 @@
78
105
  "description": "把 wshobson/agents 的 agent-teams 插件(4.2 万星,MIT)迁成档案:队长、实现者、审查者、调试者,保留上游工具边界。",
79
106
  "repo": "github:dsh-agent-roster/agent-teams",
80
107
  "ref": "main",
81
- "path": "rosters",
108
+ "path": "",
82
109
  "rosters": [
83
110
  "agent-teams"
84
111
  ],
@@ -92,7 +119,10 @@
92
119
  "maintainers": [
93
120
  "STofone"
94
121
  ],
95
- "homepage": "https://github.com/dsh-agent-roster/agent-teams"
122
+ "homepage": "https://github.com/dsh-agent-roster/agent-teams",
123
+ "files": {
124
+ "agent-teams": "dist/agent-teams-1.0.0.json"
125
+ }
96
126
  },
97
127
  {
98
128
  "id": "metagpt-company",
@@ -100,7 +130,7 @@
100
130
  "description": "MetaGPT(5 万星+,MIT)的软件公司角色:产品经理、架构师、项目经理、工程师,字段取自上游 Role 的 profile/goal/constraints。",
101
131
  "repo": "github:dsh-agent-roster/metagpt-company",
102
132
  "ref": "main",
103
- "path": "rosters",
133
+ "path": "",
104
134
  "rosters": [
105
135
  "metagpt-company"
106
136
  ],
@@ -113,7 +143,10 @@
113
143
  "maintainers": [
114
144
  "STofone"
115
145
  ],
116
- "homepage": "https://github.com/dsh-agent-roster/metagpt-company"
146
+ "homepage": "https://github.com/dsh-agent-roster/metagpt-company",
147
+ "files": {
148
+ "metagpt-company": "dist/metagpt-company-1.0.0.json"
149
+ }
117
150
  },
118
151
  {
119
152
  "id": "openmanus",
@@ -121,7 +154,7 @@
121
154
  "description": "OpenManus(5.8 万星,MIT)的三个代理提示词:规划、编码、浏览器,systemPrompt 保留上游原文。",
122
155
  "repo": "github:dsh-agent-roster/openmanus",
123
156
  "ref": "main",
124
- "path": "rosters",
157
+ "path": "",
125
158
  "rosters": [
126
159
  "openmanus"
127
160
  ],
@@ -135,7 +168,10 @@
135
168
  "maintainers": [
136
169
  "STofone"
137
170
  ],
138
- "homepage": "https://github.com/dsh-agent-roster/openmanus"
171
+ "homepage": "https://github.com/dsh-agent-roster/openmanus",
172
+ "files": {
173
+ "openmanus": "dist/openmanus-1.0.0.json"
174
+ }
139
175
  }
140
176
  ]
141
177
  }
package/lib/compile2.js CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  ROLE_PARTS_DIR,
22
22
  ROSTER2_FILE_NAMES,
23
23
  } from "./format-v2.js";
24
+ import { ROSTER_KIND } from "./format.js";
24
25
  import { diagnostic, parseDocumentText } from "./parse.js";
25
26
  import { loadRoleAt, safeResolve } from "./role.js";
26
27
  import { validateRoster2 } from "./roster2.js";
@@ -520,3 +521,110 @@ export function rosterViewOf(compiled, roster2, options = {}) {
520
521
  orchestration: compiled.orchestration ?? null,
521
522
  };
522
523
  }
524
+
525
+ /**
526
+ * 校验一份编译产物。
527
+ *
528
+ * 这是「消费者只看产物」的入口:远端订阅拿到的就是它,所以这里只认产物该有的形状,
529
+ * 不回头读角色文件——拉一份档案不该变成拉一个仓库。
530
+ *
531
+ * @param {unknown} value
532
+ * @param {{ file?: string | null }} [options]
533
+ * @returns {{ ok: boolean, compiled: object | null, diagnostics: object[] }}
534
+ */
535
+ export function validateCompiled(value, { file = null } = {}) {
536
+ const diagnostics = [];
537
+ const at = (relative) => (file === null ? relative : `${file} · ${relative}`);
538
+ const fail = () => ({ ok: false, compiled: null, diagnostics });
539
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
540
+ diagnostics.push({ severity: "error", code: "compiled-not-object", reason: "编译产物必须是一个对象", file });
541
+ return fail();
542
+ }
543
+ if (value.kind !== COMPILED_KIND) {
544
+ diagnostics.push({
545
+ severity: "error",
546
+ code: "compiled-kind",
547
+ reason: `判别键必须是 ${COMPILED_KIND},实际是 ${JSON.stringify(value.kind ?? null)}`,
548
+ file,
549
+ });
550
+ }
551
+ const source = value.source;
552
+ if (source === null || typeof source !== "object") {
553
+ diagnostics.push({ severity: "error", code: "compiled-source", reason: "缺少 source", file });
554
+ } else {
555
+ for (const key of ["roster", "version"]) {
556
+ if (typeof source[key] !== "string" || source[key] === "") {
557
+ diagnostics.push({
558
+ severity: "error",
559
+ code: "compiled-source",
560
+ reason: `source.${key} 必须是非空字符串`,
561
+ file: at("source"),
562
+ });
563
+ }
564
+ }
565
+ }
566
+ const members = value.members;
567
+ if (!Array.isArray(members) || members.length === 0) {
568
+ diagnostics.push({ severity: "error", code: "compiled-members", reason: "members 必须是非空数组", file });
569
+ } else {
570
+ for (const [index, member] of members.entries()) {
571
+ const where = at(`members[${String(index)}]`);
572
+ if (member === null || typeof member !== "object") {
573
+ diagnostics.push({ severity: "error", code: "compiled-member", reason: "成员必须是对象", file: where });
574
+ continue;
575
+ }
576
+ if (typeof member.name !== "string" || member.name === "") {
577
+ diagnostics.push({ severity: "error", code: "compiled-member", reason: "成员缺少 name", file: where });
578
+ }
579
+ if (typeof member.executionPrompt !== "string" || member.executionPrompt.trim() === "") {
580
+ diagnostics.push({ severity: "error", code: "compiled-member", reason: "成员缺少 executionPrompt", file: where });
581
+ }
582
+ }
583
+ }
584
+ const ok = diagnostics.every((item) => item.severity !== "error");
585
+ return { ok, compiled: ok ? value : null, diagnostics };
586
+ }
587
+
588
+ /**
589
+ * 把编译产物摊回一份 v1 档案文档。
590
+ *
591
+ * 远端订阅只拿得到产物:产物里的人设已经是最终拼装结果,所以导入本地时写成 v1 内联档案,
592
+ * 不假装自己带着角色目录。想要角色分层,导入后跑一次 `migrate` 重写即可。
593
+ *
594
+ * @param {object} compiled
595
+ * @returns {object}
596
+ */
597
+ export function flattenCompiledToRoster(compiled) {
598
+ const source = compiled.source ?? {};
599
+ const members = (compiled.members ?? []).map((member) => {
600
+ const flat = {
601
+ name: member.name,
602
+ role: member.role ?? "",
603
+ systemPrompt: member.executionPrompt,
604
+ };
605
+ if (member.title) flat.title = member.title;
606
+ if (member.description) flat.description = member.description;
607
+ if (member.provider) flat.provider = member.provider;
608
+ if (member.model) flat.model = member.model;
609
+ if (member.reasoningEffort) flat.reasoningEffort = member.reasoningEffort;
610
+ if (member.toolPolicy) flat.toolPolicy = member.toolPolicy;
611
+ return flat;
612
+ });
613
+ const roster = {
614
+ kind: ROSTER_KIND,
615
+ roster: source.roster,
616
+ version: source.version,
617
+ members,
618
+ };
619
+ if (source.title) roster.title = source.title;
620
+ if (source.description) roster.description = source.description;
621
+ if (compiled.captain) {
622
+ const captain = { name: String(compiled.captain.role ?? "").replace(/@[^@]*$/, "") };
623
+ if (compiled.captain.provider) captain.provider = compiled.captain.provider;
624
+ if (compiled.captain.model) captain.model = compiled.captain.model;
625
+ if (compiled.captain.reasoningEffort) captain.reasoningEffort = compiled.captain.reasoningEffort;
626
+ if (compiled.captain.persona) captain.personaPrefix = compiled.captain.persona;
627
+ roster.captain = captain;
628
+ }
629
+ return roster;
630
+ }
package/lib/format.js CHANGED
@@ -199,6 +199,7 @@ export const COLLECTION_FIELDS = [
199
199
  "ref",
200
200
  "path",
201
201
  "rosters",
202
+ "files",
202
203
  "tags",
203
204
  "license",
204
205
  "maintainers",
package/lib/migrate.js CHANGED
@@ -206,11 +206,11 @@ function loadCapabilitiesOf(dir) {
206
206
  *
207
207
  * 不写盘时只返回计划;`write: true` 才落盘、编译、写出 `dist/`,并在最后删掉旧的 `<id>/` 目录。
208
208
  */
209
- export function migrateLibrary(rootDir, { write = false, knownToolPolicies = null, source = {} } = {}) {
209
+ export function migrateLibrary(rootDir, { write = false, knownToolPolicies = null, source = {}, fromDir = rootDir } = {}) {
210
210
  const report = { rootDir, write, entries: [], diagnostics: [], written: [], removed: [], compiled: [] };
211
211
  let children = [];
212
212
  try {
213
- children = readdirSync(rootDir, { withFileTypes: true });
213
+ children = readdirSync(fromDir, { withFileTypes: true });
214
214
  } catch (error) {
215
215
  report.diagnostics.push({ severity: "error", code: "unreadable-dir", path: "", file: null, reason: `档案库不可读:${error?.message ?? error}` });
216
216
  return report;
@@ -220,7 +220,7 @@ export function migrateLibrary(rootDir, { write = false, knownToolPolicies = nul
220
220
  for (const child of children) {
221
221
  if (!child.isDirectory() || child.isSymbolicLink()) continue;
222
222
  if (child.name.startsWith(".") || child.name === ROLES_DIR || child.name === ROSTERS_DIR || child.name === DIST_DIR) continue;
223
- const dir = nodePath.join(rootDir, child.name);
223
+ const dir = nodePath.join(fromDir, child.name);
224
224
  // v1 的档案文件名可以是 yaml / yml / json——迁移只认内容,不认扩展名。
225
225
  if (!MEMBER_FILE_NAMES.some((name) => existsSync(nodePath.join(dir, name)))) continue;
226
226
  const loaded = loadRosterAt(dir, { knownToolPolicies });
@@ -269,7 +269,7 @@ export function migrateLibrary(rootDir, { write = false, knownToolPolicies = nul
269
269
  report.written.push(file.path);
270
270
  }
271
271
  for (const name of CAPABILITIES_FILE_NAMES) {
272
- const source = nodePath.join(rootDir, entry.id, name);
272
+ const source = nodePath.join(fromDir, entry.id, name);
273
273
  if (!existsSync(source)) continue;
274
274
  const relative = nodePath.join(ROSTERS_DIR, entry.id, name);
275
275
  const target = nodePath.join(rootDir, relative);
@@ -278,6 +278,13 @@ export function migrateLibrary(rootDir, { write = false, knownToolPolicies = nul
278
278
  report.written.push(relative);
279
279
  break;
280
280
  }
281
+ // 就地迁移时旧的 roster.json / roster.yml 会和新的 roster.yaml 并排躺着:
282
+ // 两份同义文件就是两个真源,删掉非 .yaml 的旧件。
283
+ for (const name of MEMBER_FILE_NAMES) {
284
+ if (name === "roster.yaml") continue;
285
+ const stale = nodePath.join(rootDir, ROSTERS_DIR, entry.id, name);
286
+ if (existsSync(stale)) rmSync(stale, { force: true });
287
+ }
281
288
  }
282
289
 
283
290
  const scanned = scanRoles(nodePath.join(rootDir, ROLES_DIR), { knownToolPolicies });
@@ -316,7 +323,9 @@ export function migrateLibrary(rootDir, { write = false, knownToolPolicies = nul
316
323
  }
317
324
 
318
325
  for (const entry of report.entries) {
319
- const legacy = nodePath.join(rootDir, entry.id);
326
+ const legacy = nodePath.join(fromDir, entry.id);
327
+ // 仓库根 + rosters/ 的布局里,v1 的 rosters/<id>/ 正是 v2 新档案落脚处 —— 不能删。
328
+ if (nodePath.resolve(legacy) === nodePath.resolve(nodePath.join(rootDir, ROSTERS_DIR, entry.id))) continue;
320
329
  if (!existsSync(legacy) || !statSync(legacy).isDirectory()) continue;
321
330
  // 只删「迁移前的那份目录」:v2 布局里档案在 rosters/ 下,root 上不该再有同名目录。
322
331
  rmSync(legacy, { recursive: true, force: true });
package/lib/remote.js CHANGED
@@ -23,6 +23,8 @@ import fs from "node:fs";
23
23
  import nodePath from "node:path";
24
24
  import { gunzipSync } from "node:zlib";
25
25
 
26
+ import { flattenCompiledToRoster, validateCompiled } from "./compile2.js";
27
+ import { COMPILED_KIND, ROSTER2_KIND } from "./format-v2.js";
26
28
  import {
27
29
  COLLECTION_FIELDS,
28
30
  LIMITS,
@@ -466,8 +468,9 @@ export function validateRegistry(value, meta = {}) {
466
468
  bad = true;
467
469
  }
468
470
  const path = entry.path === undefined ? "rosters" : String(entry.path);
469
- if (!PATTERNS.relPath.test(path)) {
470
- diagnostics.push(diagnostic("error", "collection-path", `${where}.path 必须是仓库内相对目录`, file));
471
+ // 空 path 表示「仓库根就是这座库」:v2 的 roles/ + rosters/ + dist/ 直接躺在仓库根上。
472
+ if (path !== "" && !PATTERNS.relPath.test(path)) {
473
+ diagnostics.push(diagnostic("error", "collection-path", `${where}.path 必须是仓库内相对目录(空字符串表示仓库根)`, file));
471
474
  bad = true;
472
475
  }
473
476
 
@@ -486,6 +489,25 @@ export function validateRegistry(value, meta = {}) {
486
489
  }
487
490
  if (bad) return;
488
491
 
492
+ const files = {};
493
+ if (entry.files !== undefined) {
494
+ if (entry.files === null || typeof entry.files !== "object" || Array.isArray(entry.files)) {
495
+ diagnostics.push(diagnostic("warn", "collection-files", `${where}.files 必须是「档案 id → 仓库内相对路径」的对象`, file));
496
+ } else {
497
+ for (const [key, value] of Object.entries(entry.files)) {
498
+ if (!rosters.includes(key)) {
499
+ diagnostics.push(diagnostic("warn", "collection-files-unknown", `${where}.files 里的 ${key} 不在 rosters 名单里,已忽略`, file));
500
+ continue;
501
+ }
502
+ if (typeof value !== "string" || value.trim() === "" || !PATTERNS.relPath.test(value) || value.split("/").includes("..")) {
503
+ diagnostics.push(diagnostic("warn", "collection-files-path", `${where}.files.${key} 必须是仓库内相对路径`, file));
504
+ continue;
505
+ }
506
+ files[key] = value.trim();
507
+ }
508
+ }
509
+ }
510
+
489
511
  seen.add(id);
490
512
  collections.push({
491
513
  id,
@@ -495,6 +517,7 @@ export function validateRegistry(value, meta = {}) {
495
517
  ref,
496
518
  path: path.replace(/^\/+|\/+$/g, ""),
497
519
  rosters,
520
+ files,
498
521
  tags: Array.isArray(entry.tags) ? entry.tags.filter((tag) => typeof tag === "string") : [],
499
522
  license: typeof entry.license === "string" ? entry.license : "",
500
523
  maintainers: Array.isArray(entry.maintainers) ? entry.maintainers.filter((item) => typeof item === "string") : [],
@@ -526,8 +549,10 @@ export async function fetchCollection(collection, options = {}) {
526
549
  for (const rosterId of wanted) {
527
550
  let loaded = null;
528
551
  let lastError = "";
529
- for (const fileName of ["roster.yaml", "roster.yml", "roster.json"]) {
530
- const url = collectionFileUrl(collection, rosterId, fileName);
552
+ const listedPath = collection.files?.[rosterId] ?? null;
553
+ const candidateNames = listedPath === null ? ["roster.yaml", "roster.yml", "roster.json"] : [null];
554
+ for (const fileName of candidateNames) {
555
+ const url = listedPath === null ? collectionFileUrl(collection, rosterId, fileName) : collectionFileUrl(collection, rosterId, "");
531
556
  try {
532
557
  const response = await fetchImpl(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
533
558
  if (!response.ok) {
@@ -545,15 +570,37 @@ export async function fetchCollection(collection, options = {}) {
545
570
  continue;
546
571
  }
547
572
  const parsed = parseDocumentText(loaded.text, { file: loaded.url });
548
- const checked = parsed.ok
549
- ? validateRoster(parsed.value, { file: loaded.url, expectedId: rosterId })
550
- : { ok: false, diagnostics: parsed.diagnostics };
573
+ if (parsed.ok && parsed.value !== null && typeof parsed.value === "object" && parsed.value.kind === ROSTER2_KIND) {
574
+ diagnostics.push(
575
+ diagnostic(
576
+ "warn",
577
+ "collection-roster-needs-roles",
578
+ `${collection.id}/${rosterId} 是 v2 组装,成员是角色引用,单个文件拉不到角色目录;索引应指向 dist/ 里的编译产物`,
579
+ ),
580
+ );
581
+ continue;
582
+ }
583
+ let checked;
584
+ if (parsed.ok && parsed.value !== null && typeof parsed.value === "object" && parsed.value.kind === COMPILED_KIND) {
585
+ const compiled = validateCompiled(parsed.value, { file: loaded.url });
586
+ if (!compiled.ok) {
587
+ diagnostics.push(...compiled.diagnostics);
588
+ continue;
589
+ }
590
+ checked = validateRoster(flattenCompiledToRoster(compiled.compiled), { file: loaded.url, expectedId: rosterId });
591
+ checked = { ...checked, format: "compiled" };
592
+ } else {
593
+ checked = parsed.ok
594
+ ? validateRoster(parsed.value, { file: loaded.url, expectedId: rosterId })
595
+ : { ok: false, diagnostics: parsed.diagnostics };
596
+ }
551
597
  if (!checked.ok) {
552
598
  diagnostics.push(...checked.diagnostics);
553
599
  continue;
554
600
  }
555
601
  rosters.push({
556
602
  ...checked.roster,
603
+ ...(checked.format === undefined ? {} : { format: checked.format }),
557
604
  origin: {
558
605
  kind: "remote",
559
606
  collection: collection.id,
@@ -569,10 +616,12 @@ export async function fetchCollection(collection, options = {}) {
569
616
  return { collection, rosters, diagnostics };
570
617
  }
571
618
 
572
- /** 拼一份档案的 raw 地址。 */
619
+ /** 拼一份档案的 raw 地址(relativePath 由索引给出,或回退到 v1 的 `<id>/roster.<ext>`)。 */
573
620
  export function collectionFileUrl(collection, rosterId, fileName) {
574
621
  const repo = collection.repo.replace(/^github:/, "");
575
- return `${GITHUB_RAW}/${repo}/${encodeURIComponent(collection.ref)}/${collection.path}/${rosterId}/${fileName}`;
622
+ const relative = collection.files?.[rosterId] ?? `${rosterId}/${fileName}`;
623
+ const base = collection.path === "" ? "" : `${collection.path}/`;
624
+ return `${GITHUB_RAW}/${repo}/${encodeURIComponent(collection.ref)}/${base}${relative}`;
576
625
  }
577
626
 
578
627
  /** 一次读多个源(串行:GitHub 未认证配额经不起并发烧)。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stofone/dsh-agent-roster",
3
- "version": "0.10.1",
3
+ "version": "0.10.3",
4
4
  "description": "多 Agent 团队的静态成员档案定义与校验层:档案库在面板里看得见、改得动,团队成员的人设、模型与工具边界固化成可校验、可版本化的文件,并在 dsh-agent-teams 创建成员时把档案桥接给运行时。",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -5,32 +5,57 @@
5
5
  "description": "公共档案索引:登记若干 collection(仓库 + 目录 + 确有哪几份档案),供订阅与一键安装。权威校验实现是 lib/remote.js#validateRegistry;本文件供编辑器与外部工具提示,两者由 test/registry-schema.test.js 对齐。注意:运行时对未知字段只告警不判错,这里写成 additionalProperties:false 是为了让编辑器早点指出拼写错误。",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
- "required": ["kind", "collections"],
8
+ "required": [
9
+ "kind",
10
+ "collections"
11
+ ],
9
12
  "properties": {
10
13
  "$schema": {
11
14
  "type": "string",
12
15
  "description": "编辑器用的 schema 地址,插件不读它。"
13
16
  },
14
- "kind": { "const": "dsh-agent-roster-registry/v1" },
15
- "version": { "type": "integer", "minimum": 1 },
17
+ "kind": {
18
+ "const": "dsh-agent-roster-registry/v1"
19
+ },
20
+ "version": {
21
+ "type": "integer",
22
+ "minimum": 1
23
+ },
16
24
  "generatedAt": {
17
25
  "type": "string",
18
26
  "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?Z$",
19
27
  "description": "上一次校验通过的时间,由 CI 或维护者盖章。"
20
28
  },
21
- "generator": { "type": "string", "maxLength": 200 },
22
- "source": { "type": "string", "maxLength": 200 },
29
+ "generator": {
30
+ "type": "string",
31
+ "maxLength": 200
32
+ },
33
+ "source": {
34
+ "type": "string",
35
+ "maxLength": 200
36
+ },
23
37
  "collections": {
24
38
  "type": "array",
25
39
  "maxItems": 500,
26
40
  "items": {
27
41
  "type": "object",
28
42
  "additionalProperties": false,
29
- "required": ["id", "repo"],
43
+ "required": [
44
+ "id",
45
+ "repo"
46
+ ],
30
47
  "properties": {
31
- "id": { "$ref": "#/$defs/id" },
32
- "title": { "type": "string", "maxLength": 120 },
33
- "description": { "type": "string", "maxLength": 500 },
48
+ "id": {
49
+ "$ref": "#/$defs/id"
50
+ },
51
+ "title": {
52
+ "type": "string",
53
+ "maxLength": 120
54
+ },
55
+ "description": {
56
+ "type": "string",
57
+ "maxLength": 500
58
+ },
34
59
  "repo": {
35
60
  "type": "string",
36
61
  "pattern": "^(?:github:)?[A-Za-z0-9][A-Za-z0-9_.-]{0,38}/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$",
@@ -46,13 +71,15 @@
46
71
  "type": "string",
47
72
  "pattern": "^(?:/)?(?:[A-Za-z0-9._-]+/)*[A-Za-z0-9._-]*$",
48
73
  "default": "rosters",
49
- "description": "仓库内存放档案的目录。"
74
+ "description": "仓库内存放档案的目录;空字符串表示仓库根(v2 布局的 roles/ + rosters/ + dist/ 就在根上)。"
50
75
  },
51
76
  "rosters": {
52
77
  "type": "array",
53
78
  "maxItems": 200,
54
79
  "description": "目录里确实存在的档案 id。索引不撒谎:不存在的 id 会被 registry sync 剔除。",
55
- "items": { "$ref": "#/$defs/id" }
80
+ "items": {
81
+ "$ref": "#/$defs/id"
82
+ }
56
83
  },
57
84
  "tags": {
58
85
  "type": "array",
@@ -62,13 +89,34 @@
62
89
  "pattern": "^[a-z0-9][a-z0-9-]{0,31}$"
63
90
  }
64
91
  },
65
- "license": { "type": "string", "maxLength": 120 },
92
+ "license": {
93
+ "type": "string",
94
+ "maxLength": 120
95
+ },
66
96
  "maintainers": {
67
97
  "type": "array",
68
98
  "maxItems": 16,
69
- "items": { "type": "string", "minLength": 1, "maxLength": 120 }
99
+ "items": {
100
+ "type": "string",
101
+ "minLength": 1,
102
+ "maxLength": 120
103
+ }
70
104
  },
71
- "homepage": { "type": "string", "maxLength": 200, "pattern": "^https?://\\S+$" }
105
+ "homepage": {
106
+ "type": "string",
107
+ "maxLength": 200,
108
+ "pattern": "^https?://\\S+$"
109
+ },
110
+ "files": {
111
+ "type": "object",
112
+ "description": "档案 id → 仓库内相对路径。v2 档案指向 dist/ 的编译产物(自包含),v1 可省略。",
113
+ "propertyNames": {
114
+ "$ref": "#/$defs/id"
115
+ },
116
+ "additionalProperties": {
117
+ "$ref": "#/$defs/relPath"
118
+ }
119
+ }
72
120
  }
73
121
  }
74
122
  }
@@ -78,6 +126,13 @@
78
126
  "type": "string",
79
127
  "pattern": "^[a-z0-9][a-z0-9-]{1,63}$",
80
128
  "description": "kebab-case 标识,与档案目录名同规则。"
129
+ },
130
+ "relPath": {
131
+ "type": "string",
132
+ "minLength": 1,
133
+ "maxLength": 200,
134
+ "pattern": "^(?:[A-Za-z0-9._-]+/)*[A-Za-z0-9._-]+$",
135
+ "description": "仓库内相对路径,不含 ..、不以 / 开头。"
81
136
  }
82
137
  }
83
138
  }