@stofone/dsh-agent-roster 0.13.1 → 1.1.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/lib/space.js ADDED
@@ -0,0 +1,382 @@
1
+ /**
2
+ * 档案空间(space):一个可以装很多份档案的目录(通常是 git 仓库)的"封面"。
3
+ *
4
+ * 为什么需要它:一份 `roster.yaml` 只回答"这个团队是谁";它回答不了
5
+ * "这几十份档案在哪个仓库、用什么布局、我拉下来以后该改哪个文件"。
6
+ * 一旦档案来自 monorepo,拉取就会把文件摊平到本地,编辑和源文件从此对不上。
7
+ * 空间这一级就是用来固定这件事的:**布局是声明出来的,不是猜出来的**。
8
+ *
9
+ * 边界(与 capabilities.yaml / roster 同一条纪律):
10
+ * - 空间只做**声明与核对**:声明这个空间有哪些档案、什么布局、源在哪;
11
+ * 它不创建团队、不改档案、不进 `agent_teams_add_member`。
12
+ * - 它**能被机械核对**:声明了但目录不在 = error,目录在却没声明 = warn,
13
+ * 归档却还在 = info;单档案布局却登记了多份 = error。
14
+ * - 位置即身份:`space` 必须等于所在目录名(本地副本同样遵守)。
15
+ *
16
+ * 布局只有两种,且只有一种格式:
17
+ * - `flat`:`<space>/<roster-id>/roster.yaml`(v1 时代的一仓多档案,也兼容一仓一份)
18
+ * - `monorepo`:`<space>/roles/<role>/AGENT.md` + `<space>/rosters/<id>/roster.yaml` + `<space>/dist/`
19
+ *
20
+ * @module dsh-agent-roster/space
21
+ */
22
+
23
+ /** 空间文件的 `kind`。与 roster / registry / capabilities 并列,互不冒充。 */
24
+ export const SPACE_KIND = "dsh-agent-roster/space@1";
25
+
26
+ /** 空间文件允许的文件名,按优先级排列。 */
27
+ export const SPACE_FILE_NAMES = ["space.yaml", "space.yml", "space.json"];
28
+
29
+ /** 布局枚举:只有两种,多一种就得多写一套扫描与拉取。 */
30
+ export const SPACE_LAYOUTS = ["flat", "monorepo"];
31
+
32
+ /** 档案在空间里的状态。归档不等于删除,删除就是没有这个目录。 */
33
+ export const SPACE_STATUSES = ["active", "archived"];
34
+
35
+ /** 长度上限:与档案的 LIMITS 同一套口径,超了报错而不是截断。 */
36
+ export const SPACE_LIMITS = {
37
+ rosters: 200,
38
+ roles: 200,
39
+ titleChars: 120,
40
+ descriptionChars: 500,
41
+ licenseChars: 120,
42
+ homepageChars: 200,
43
+ maintainers: 16,
44
+ maintainerChars: 120,
45
+ noteChars: 300,
46
+ repoChars: 200,
47
+ refChars: 120,
48
+ pathChars: 200,
49
+ };
50
+
51
+ /** 空间 id 与档案 id 同形:小写、连字符、首字符必须是字母或数字。 */
52
+ export const SPACE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{1,63}$/;
53
+
54
+ /** `owner/repo`,与 registry 的 repo 同形。 */
55
+ export const REPO_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
56
+
57
+ const ROOT_FIELDS = new Set([
58
+ "kind",
59
+ "space",
60
+ "title",
61
+ "description",
62
+ "license",
63
+ "homepage",
64
+ "maintainers",
65
+ "layout",
66
+ "origin",
67
+ "rosters",
68
+ "roles",
69
+ ]);
70
+
71
+ const ROSTER_FIELDS = new Set(["id", "title", "status", "origin", "note"]);
72
+
73
+ const ORIGIN_FIELDS = new Set(["repo", "ref", "path"]);
74
+
75
+ /** 顶层字段清单(供 schema 对齐测试反向钉住,避免两处各写一份)。 */
76
+ export const SPACE_FIELDS = Object.freeze([...ROOT_FIELDS]);
77
+
78
+ /** 每条登记项的字段清单。 */
79
+ export const SPACE_ROSTER_FIELDS = Object.freeze([...ROSTER_FIELDS]);
80
+
81
+ /** 本地副本记录来源的字段清单。 */
82
+ export const SPACE_ORIGIN_FIELDS = Object.freeze([...ORIGIN_FIELDS]);
83
+
84
+ function diagnose(severity, code, file, reason, where = "") {
85
+ return { severity, code, path: where, file, reason };
86
+ }
87
+
88
+ function optionalString(source, key, max, diagnostics, file, code, where = "") {
89
+ const value = source[key];
90
+ if (value === undefined) return undefined;
91
+ const label = where === "" ? key : `${where}.${key}`;
92
+ if (typeof value !== "string" || value.length > max) {
93
+ diagnostics.push(diagnose("error", code, file, `${label} 必须是长度不超过 ${max} 的字符串。`, where));
94
+ return undefined;
95
+ }
96
+ return value;
97
+ }
98
+
99
+ /**
100
+ * 校验一份空间文件。
101
+ *
102
+ * 与 roster 的校验同一条纪律:**能核对的才收**。`origin.repo` 只校验形态,不联网;
103
+ * `origin` 只出现在本地副本上——远端仓库里的空间文件不该声称自己有个源,
104
+ * 那是第二真源。
105
+ *
106
+ * @param {unknown} value 解析后的 JSON/YAML 值
107
+ * @param {{file?: string, expectedId?: string|null, allowOrigin?: boolean}} [options]
108
+ * @returns {{ok: boolean, space: object|null, diagnostics: object[]}}
109
+ */
110
+ export function validateSpace(value, options = {}) {
111
+ const file = options.file ?? SPACE_FILE_NAMES[0];
112
+ const expectedId = options.expectedId ?? null;
113
+ const allowOrigin = options.allowOrigin === true;
114
+ /** @type {object[]} */
115
+ const diagnostics = [];
116
+
117
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
118
+ diagnostics.push(diagnose("error", "space-not-object", file, "空间文件必须是一个对象(键值对)。"));
119
+ return { ok: false, space: null, diagnostics };
120
+ }
121
+
122
+ for (const key of Object.keys(value)) {
123
+ if (!ROOT_FIELDS.has(key)) {
124
+ diagnostics.push(
125
+ diagnose(
126
+ "error",
127
+ "unknown-field",
128
+ file,
129
+ `未知字段 "${key}"。空间文件只收 ${[...ROOT_FIELDS].join(" / ")}:` +
130
+ "核对不了的字段不收——写了也不会被任何人验证,只会被误当成事实。",
131
+ ),
132
+ );
133
+ }
134
+ }
135
+
136
+ if (value.kind !== SPACE_KIND) {
137
+ diagnostics.push(
138
+ diagnose("error", "space-kind", file, `kind 必须是 "${SPACE_KIND}",实际是 ${JSON.stringify(value.kind)}。`),
139
+ );
140
+ }
141
+
142
+ const id = value.space;
143
+ if (typeof id !== "string" || !SPACE_ID_PATTERN.test(id)) {
144
+ diagnostics.push(
145
+ diagnose("error", "space-id", file, "space 必须是 2–64 位小写字母/数字/连字符,且以字母或数字开头。"),
146
+ );
147
+ } else if (expectedId !== null && id !== expectedId) {
148
+ diagnostics.push(
149
+ diagnose(
150
+ "error",
151
+ "space-id-mismatch",
152
+ file,
153
+ `space 是 "${id}",但所在目录叫 "${expectedId}"。位置即身份:空间 id 必须等于目录名。`,
154
+ ),
155
+ );
156
+ }
157
+
158
+ const layout = value.layout ?? "flat";
159
+ if (!SPACE_LAYOUTS.includes(layout)) {
160
+ diagnostics.push(diagnose("error", "space-layout", file, `layout 只能是 ${SPACE_LAYOUTS.join(" / ")}。`));
161
+ }
162
+
163
+ const title = optionalString(value, "title", SPACE_LIMITS.titleChars, diagnostics, file, "space-title");
164
+ optionalString(value, "description", SPACE_LIMITS.descriptionChars, diagnostics, file, "space-description");
165
+ optionalString(value, "license", SPACE_LIMITS.licenseChars, diagnostics, file, "space-license");
166
+
167
+ if (value.homepage !== undefined) {
168
+ const homepage = optionalString(value, "homepage", SPACE_LIMITS.homepageChars, diagnostics, file, "space-homepage");
169
+ if (typeof homepage === "string" && !/^https?:\/\/\S+$/i.test(homepage)) {
170
+ diagnostics.push(diagnose("error", "space-homepage", file, "homepage 只收 http(s) 地址:别的写法既打不开也核对不了。"));
171
+ }
172
+ }
173
+
174
+ if (value.maintainers !== undefined) {
175
+ if (!Array.isArray(value.maintainers)) {
176
+ diagnostics.push(diagnose("error", "space-maintainers", file, "maintainers 必须是字符串数组。"));
177
+ } else if (value.maintainers.length > SPACE_LIMITS.maintainers) {
178
+ diagnostics.push(diagnose("error", "space-maintainers", file, `maintainers 最多 ${SPACE_LIMITS.maintainers} 项。`));
179
+ } else {
180
+ for (const who of value.maintainers) {
181
+ if (typeof who !== "string" || who.length === 0 || who.length > SPACE_LIMITS.maintainerChars) {
182
+ diagnostics.push(
183
+ diagnose("error", "space-maintainer", file, `维护者名字必须是 1–${SPACE_LIMITS.maintainerChars} 个字符的字符串。`),
184
+ );
185
+ break;
186
+ }
187
+ }
188
+ }
189
+ }
190
+
191
+ let origin = null;
192
+ if (value.origin !== undefined) {
193
+ if (!allowOrigin) {
194
+ diagnostics.push(
195
+ diagnose(
196
+ "error",
197
+ "space-origin-not-allowed",
198
+ file,
199
+ "仓库里的空间文件不该写 origin:源就是这个仓库本身,再写一遍就是第二真源。" +
200
+ "origin 只出现在本地拉取出来的副本上。",
201
+ ),
202
+ );
203
+ } else if (value.origin === null || typeof value.origin !== "object" || Array.isArray(value.origin)) {
204
+ diagnostics.push(diagnose("error", "space-origin", file, "origin 必须是 { repo, ref, path } 对象。"));
205
+ } else {
206
+ for (const key of Object.keys(value.origin)) {
207
+ if (!ORIGIN_FIELDS.has(key)) {
208
+ diagnostics.push(diagnose("error", "unknown-field", file, `origin.${key} 不是合法字段(只收 ${[...ORIGIN_FIELDS].join(" / ")})。`));
209
+ }
210
+ }
211
+ const repo = value.origin.repo;
212
+ const ref = value.origin.ref ?? "main";
213
+ const originPath = value.origin.path ?? "";
214
+ if (typeof repo !== "string" || repo.length > SPACE_LIMITS.repoChars || !REPO_PATTERN.test(repo)) {
215
+ diagnostics.push(diagnose("error", "space-origin-repo", file, "origin.repo 必须是 owner/name。"));
216
+ }
217
+ if (typeof ref !== "string" || ref.length === 0 || ref.length > SPACE_LIMITS.refChars) {
218
+ diagnostics.push(diagnose("error", "space-origin-ref", file, "origin.ref 必须是 1–120 个字符的分支/tag/提交。"));
219
+ }
220
+ if (typeof originPath !== "string" || originPath.length > SPACE_LIMITS.pathChars) {
221
+ diagnostics.push(diagnose("error", "space-origin-path", file, "origin.path 必须是字符串(仓库内的相对目录,根目录为空串)。"));
222
+ } else if (originPath !== "" && (originPath.startsWith("/") || originPath.includes("..") || originPath.includes("\\"))) {
223
+ diagnostics.push(diagnose("error", "space-origin-path", file, "origin.path 必须是安全的相对路径。"));
224
+ }
225
+ origin = { repo: String(repo ?? ""), ref: String(ref ?? "main"), path: String(originPath ?? "") };
226
+ }
227
+ }
228
+
229
+ const roles = [];
230
+ if (value.roles !== undefined) {
231
+ if (!Array.isArray(value.roles) || value.roles.length > SPACE_LIMITS.roles) {
232
+ diagnostics.push(diagnose("error", "space-roles", file, `roles 必须是不超过 ${SPACE_LIMITS.roles} 项的数组(只收角色名)。`));
233
+ } else {
234
+ for (const name of value.roles) {
235
+ if (typeof name !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name)) {
236
+ diagnostics.push(diagnose("error", "space-role-name", file, "roles 里每项都必须是角色名(字母/数字/点/下划线/连字符)。"));
237
+ break;
238
+ }
239
+ roles.push(name);
240
+ }
241
+ }
242
+ }
243
+
244
+ const rosters = [];
245
+ const rawRosters = value.rosters;
246
+ if (!Array.isArray(rawRosters) || rawRosters.length === 0) {
247
+ diagnostics.push(diagnose("error", "space-rosters", file, "rosters 必须是非空数组:空间的意义就是登记这些档案。"));
248
+ } else if (rawRosters.length > SPACE_LIMITS.rosters) {
249
+ diagnostics.push(diagnose("error", "space-rosters", file, `rosters 最多 ${SPACE_LIMITS.rosters} 项。`));
250
+ } else {
251
+ const seen = new Set();
252
+ rawRosters.forEach((item, index) => {
253
+ const where = `rosters[${index}]`;
254
+ if (item === null || typeof item !== "object" || Array.isArray(item)) {
255
+ diagnostics.push(diagnose("error", "space-roster-not-object", file, `${where} 必须是对象。`, where));
256
+ return;
257
+ }
258
+ for (const key of Object.keys(item)) {
259
+ if (!ROSTER_FIELDS.has(key)) {
260
+ diagnostics.push(
261
+ diagnose("error", "unknown-field", file, `${where} 的未知字段 "${key}"(只收 ${[...ROSTER_FIELDS].join(" / ")})。`, where),
262
+ );
263
+ }
264
+ }
265
+ if (typeof item.id !== "string" || !/^[a-z0-9][a-z0-9._-]{0,63}$/.test(item.id)) {
266
+ diagnostics.push(diagnose("error", "space-roster-id", file, `${where}.id 必须是合法档案 id。`, where));
267
+ return;
268
+ }
269
+ if (seen.has(item.id)) {
270
+ diagnostics.push(diagnose("error", "space-duplicate-roster", file, `档案 "${item.id}" 在空间里登记了两次。`, where));
271
+ return;
272
+ }
273
+ seen.add(item.id);
274
+ const status = item.status ?? "active";
275
+ if (!SPACE_STATUSES.includes(status)) {
276
+ diagnostics.push(diagnose("error", "space-roster-status", file, `${where}.status 只能是 ${SPACE_STATUSES.join(" / ")}。`, where));
277
+ }
278
+ const entryTitle = optionalString(item, "title", SPACE_LIMITS.titleChars, diagnostics, file, "space-roster-title", where);
279
+ const entryOrigin = optionalString(item, "origin", SPACE_LIMITS.titleChars, diagnostics, file, "space-roster-origin", where);
280
+ const note = optionalString(item, "note", SPACE_LIMITS.noteChars, diagnostics, file, "space-roster-note", where);
281
+ rosters.push({ id: item.id, title: entryTitle ?? "", status, origin: entryOrigin ?? "", note: note ?? "" });
282
+ });
283
+ }
284
+
285
+ if (diagnostics.some((item) => item.severity === "error")) {
286
+ return { ok: false, space: null, diagnostics };
287
+ }
288
+
289
+ return {
290
+ ok: true,
291
+ space: {
292
+ kind: SPACE_KIND,
293
+ id,
294
+ title: title ?? "",
295
+ description: value.description ?? "",
296
+ license: value.license ?? "",
297
+ homepage: value.homepage ?? "",
298
+ maintainers: Array.isArray(value.maintainers) ? [...value.maintainers] : [],
299
+ layout,
300
+ origin,
301
+ roles,
302
+ rosters,
303
+ },
304
+ diagnostics,
305
+ };
306
+ }
307
+
308
+ /**
309
+ * 空间声明与磁盘实际情况的交叉核对。
310
+ *
311
+ * - **缺**(声明了却找不到目录):error。空间页在撒谎,比没有空间页更糟。
312
+ * - **多**(目录在却没登记):warn。不是错误,但意味着有人绕过空间加了档案。
313
+ * - **归档仍在**:info。归档是声明,不是删除。
314
+ *
315
+ * @param {object|null} space
316
+ * @param {string[]} rosterIds 磁盘上真正通过校验的档案 id
317
+ * @returns {{declared: number, present: number, missing: string[], undeclared: string[], archived: string[], status: string}}
318
+ */
319
+ export function checkSpace(space, rosterIds) {
320
+ const onDisk = new Set(rosterIds);
321
+ const declared = new Set(space === null ? [] : space.rosters.map((item) => item.id));
322
+ const missing = [...declared].filter((id) => !onDisk.has(id)).sort();
323
+ const undeclared = [...onDisk].filter((id) => !declared.has(id)).sort();
324
+ const archived =
325
+ space === null
326
+ ? []
327
+ : space.rosters.filter((item) => item.status === "archived" && onDisk.has(item.id)).map((item) => item.id).sort();
328
+ return {
329
+ declared: declared.size,
330
+ present: onDisk.size,
331
+ missing,
332
+ undeclared,
333
+ archived,
334
+ status: space === null ? "absent" : missing.length > 0 ? "incomplete" : "ok",
335
+ };
336
+ }
337
+
338
+ /**
339
+ * 把交叉核对结果折成诊断。
340
+ *
341
+ * @param {object|null} space
342
+ * @param {object} check {@link checkSpace} 的返回值
343
+ * @param {string} file 空间文件相对路径
344
+ * @returns {object[]}
345
+ */
346
+ export function spaceDiagnostics(space, check, file) {
347
+ const diagnostics = [];
348
+ if (space === null) return diagnostics;
349
+ if (check.missing.length > 0) {
350
+ diagnostics.push(
351
+ diagnose(
352
+ "error",
353
+ "space-roster-missing",
354
+ file,
355
+ `空间声明了 ${check.missing.length} 份档案但目录里找不到:${check.missing.join(", ")}。` +
356
+ "要么把目录加回来,要么把它们从空间里删掉——空间页不能比磁盘更乐观。",
357
+ ),
358
+ );
359
+ }
360
+ if (check.undeclared.length > 0) {
361
+ diagnostics.push(
362
+ diagnose(
363
+ "warn",
364
+ "space-roster-undeclared",
365
+ file,
366
+ `目录里有 ${check.undeclared.length} 份档案没在空间里登记:${check.undeclared.join(", ")}。` +
367
+ "它们仍会被加载,但空间页看不到它们——补一行登记,或确认它们不该在这里。",
368
+ ),
369
+ );
370
+ }
371
+ if (check.archived.length > 0) {
372
+ diagnostics.push(
373
+ diagnose(
374
+ "info",
375
+ "space-roster-archived",
376
+ file,
377
+ `已归档但仍在目录里的档案:${check.archived.join(", ")}。归档只是声明,不影响加载。`,
378
+ ),
379
+ );
380
+ }
381
+ return diagnostics;
382
+ }
package/lib/tools.js CHANGED
@@ -96,7 +96,7 @@ export function registerRosterTools(ctx, config, meta) {
96
96
  const text = [
97
97
  statusLine("roster_list", {
98
98
  rootDir: rosterDir,
99
- organization: scan.organization === null ? "none" : scan.organization.id,
99
+ space: scan.space === null ? "none" : scan.space.id,
100
100
  total: scan.stats.total,
101
101
  ok: scan.stats.ok,
102
102
  failed: scan.stats.failed,
@@ -109,8 +109,8 @@ export function registerRosterTools(ctx, config, meta) {
109
109
  workspace,
110
110
  rootDir: rosterDir,
111
111
  stats: scan.stats,
112
- organization: scan.organization,
113
- organizationCheck: scan.organizationCheck,
112
+ space: scan.space,
113
+ spaceCheck: scan.spaceCheck,
114
114
  rosters: scan.rosters.map((roster) => ({
115
115
  id: roster.id,
116
116
  version: roster.version,
package/locale/en.json CHANGED
@@ -64,7 +64,7 @@
64
64
  "metaRosters": "rosters",
65
65
  "metaMembers": "members",
66
66
  "metaTeams": "teams",
67
- "metaOrganization": "Organization {name}",
67
+ "metaSpace": "Space {name}",
68
68
  "tabsCatalog": "Library"
69
69
  },
70
70
  "roster": {
@@ -317,6 +317,11 @@
317
317
  "mode": "Layout",
318
318
  "modeSource": "By source",
319
319
  "modeFlat": "Flat",
320
- "refresh": "Refresh"
320
+ "refresh": "Refresh",
321
+ "installed": "Installed",
322
+ "pullSpace": "Pull whole space",
323
+ "pulledSpace": "Space pulled: {files} files written, {skipped} skipped",
324
+ "facts": "{count} rosters · {installed} here",
325
+ "here": "Already here"
321
326
  }
322
327
  }
package/locale/zh.json CHANGED
@@ -64,7 +64,7 @@
64
64
  "metaRosters": "份档案",
65
65
  "metaMembers": "名成员",
66
66
  "metaTeams": "支团队",
67
- "metaOrganization": "组织 {name}",
67
+ "metaSpace": "空间 {name}",
68
68
  "tabsCatalog": "档案库"
69
69
  },
70
70
  "roster": {
@@ -317,6 +317,11 @@
317
317
  "mode": "显示方式",
318
318
  "modeSource": "按来源",
319
319
  "modeFlat": "平铺",
320
- "refresh": "刷新"
320
+ "refresh": "刷新",
321
+ "installed": "已安装",
322
+ "pullSpace": "拉取整个空间",
323
+ "pulledSpace": "空间已拉取:写入 {files} 个文件,跳过 {skipped} 个",
324
+ "facts": "{count} 份 · 已装 {installed}",
325
+ "here": "已在本机"
321
326
  }
322
327
  }
@@ -0,0 +1,30 @@
1
+ kind: dsh-agent-roster/space@1
2
+ space: agent-roster-collective
3
+ title: 团队档案合集
4
+ description: 二十一份自撰档案,覆盖常见的工程与运营职能。
5
+ license: MIT
6
+ maintainers:
7
+ - STofone
8
+ layout: monorepo
9
+ rosters:
10
+ - id: api-design
11
+ - id: content-ops
12
+ - id: contract-review
13
+ - id: data-quality
14
+ - id: db-migration
15
+ - id: docs-writer
16
+ - id: finance-recon
17
+ - id: frontend-ui
18
+ - id: i18n-l10n
19
+ - id: incident-response
20
+ - id: literature-review
21
+ - id: market-research
22
+ - id: ml-eval
23
+ - id: performance
24
+ - id: product-discovery
25
+ - id: qa-regression
26
+ - id: recruiting
27
+ - id: release-manager
28
+ - id: security-audit
29
+ - id: sre-oncall
30
+ - id: support-triage
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stofone/dsh-agent-roster",
3
- "version": "0.13.1",
3
+ "version": "1.1.0",
4
4
  "description": "多 Agent 团队的静态成员档案定义与校验层:档案库在面板里看得见、改得动,团队成员的人设、模型与工具边界固化成可校验、可版本化的文件,并在 dsh-agent-teams 创建成员时把档案桥接给运行时。",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -0,0 +1,60 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://raw.githubusercontent.com/dsh-agent-roster/dsh-agent-roster/main/schema/space.v1.schema.json",
4
+ "title": "DSH agent roster space",
5
+ "description": "一个装档案的目录(通常是 git 仓库)的封面:声明布局、登记档案、记录本地副本的来源。它只做声明与核对,不创建团队。",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["kind", "space", "rosters"],
9
+ "properties": {
10
+ "kind": { "const": "dsh-agent-roster/space@1" },
11
+ "space": { "$ref": "#/$defs/id" },
12
+ "title": { "type": "string", "maxLength": 120 },
13
+ "description": { "type": "string", "maxLength": 500 },
14
+ "license": { "type": "string", "maxLength": 120 },
15
+ "homepage": { "type": "string", "pattern": "^https?://\\S+$", "maxLength": 200 },
16
+ "maintainers": {
17
+ "type": "array",
18
+ "maxItems": 16,
19
+ "items": { "type": "string", "minLength": 1, "maxLength": 120 }
20
+ },
21
+ "layout": { "enum": ["flat", "monorepo"] },
22
+ "origin": {
23
+ "type": "object",
24
+ "description": "只出现在本地拉取出来的副本上;仓库里的空间文件不该写 origin。",
25
+ "additionalProperties": false,
26
+ "required": ["repo"],
27
+ "properties": {
28
+ "repo": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$", "maxLength": 200 },
29
+ "ref": { "type": "string", "minLength": 1, "maxLength": 120 },
30
+ "path": { "type": "string", "maxLength": 200 }
31
+ }
32
+ },
33
+ "roles": {
34
+ "type": "array",
35
+ "maxItems": 200,
36
+ "items": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" }
37
+ },
38
+ "rosters": {
39
+ "type": "array",
40
+ "minItems": 1,
41
+ "maxItems": 200,
42
+ "items": {
43
+ "type": "object",
44
+ "additionalProperties": false,
45
+ "required": ["id"],
46
+ "properties": {
47
+ "id": { "$ref": "#/$defs/rosterId" },
48
+ "title": { "type": "string", "maxLength": 120 },
49
+ "status": { "enum": ["active", "archived"] },
50
+ "origin": { "type": "string", "maxLength": 120 },
51
+ "note": { "type": "string", "maxLength": 300 }
52
+ }
53
+ }
54
+ }
55
+ },
56
+ "$defs": {
57
+ "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{1,63}$" },
58
+ "rosterId": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{0,63}$" }
59
+ }
60
+ }