@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/CHANGELOG.md CHANGED
@@ -4,6 +4,62 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.1.0] - 2026-11-03
8
+
9
+ ### The library reads as spaces, not groups
10
+
11
+ 1.0.0 made the space a real level in the format. The panel still showed collections as small labels, so the
12
+ level existed in the files and not on screen. Each group header is now the space itself: its id sits in the
13
+ same column as the roster ids (the register keeps one spine), the right side states two facts you can check
14
+ on the spot — how many rosters it holds, how many are already here — and one button pulls the whole space.
15
+
16
+ Rows also stopped lying about state. A roster that is already on this machine says so, and its action
17
+ becomes **Open** instead of **Pull**: the panel no longer offers to fetch something you already have. The
18
+ local group drops the per-row "local" label, because the group header already says it.
19
+
20
+ On a narrow panel the space header folds into two lines (id + action, then title + facts) instead of
21
+ squeezing a long space name into a vertical stack one character wide.
22
+
23
+
24
+ ## [1.0.0] - 2026-11-03
25
+
26
+ ### A space is the thing a repository holds
27
+
28
+ Until now a pulled roster was a flattened copy: whatever the source repository looked like, it arrived as
29
+ `<id>/roster.yaml` and the editor had no idea which file upstream it corresponded to. That is fine for "I
30
+ only need this one", and wrong for every monorepo — which is what this project's own libraries have been
31
+ since 0.10.0.
32
+
33
+ `space.yaml` (`kind: dsh-agent-roster/space@1`) is the level that was missing. It declares what a
34
+ directory in a repository *is*: which rosters it holds, whether the layout is `flat` (`<id>/roster.yaml`)
35
+ or `monorepo` (`roles/` + `rosters/` + `dist/`), and — only on local copies — where it came from
36
+ (`origin: {repo, ref, path}`). Layout is now part of the format instead of something the puller guesses.
37
+ Clone a space and the local tree is the repository's tree: edit `rosters/<id>/roster.yaml` and you are
38
+ editing the file upstream.
39
+
40
+ ### Changed (breaking)
41
+
42
+ - `organization.yaml` is replaced by `space.yaml`. The old file, its `kind`
43
+ (`dsh-agent-roster-organization/v1`), its schema and its diagnostics codes (`organization-*`) are gone;
44
+ the equivalents are `space.yaml`, `dsh-agent-roster/space@1` and `space-*`. Migration: rename the file,
45
+ change `kind`, rename the `organization:` key to `space:`, and add `layout: flat|monorepo`.
46
+ - A space file inside a repository may not declare `origin`; the validator rejects it. `origin` describes
47
+ a local copy, and a repository claiming a source is a second copy of the truth.
48
+
49
+ ### Added
50
+
51
+ - `lib/space.js` + `schema/space.v1.schema.json`: declaration, cross-check (missing = error, undeclared =
52
+ warn, archived = info) and strict validation, with `layout` and `origin`.
53
+ - `lib/clone.js` and `dsh-agent-roster clone --repo <owner/repo> [--path] [--ref] [--space]`: pull a whole
54
+ space with its layout intact — rosters, the roles they reference, and each role's `parts/`. The written
55
+ `space.yaml` is the source's cover plus `origin`.
56
+ - `POST /plugins/dsh-agent-roster/install-space` and a "Pull whole space" button on every source group in
57
+ the Library tab; pulled files land in `<library>/<space>/` with the repository's paths.
58
+ - The Library tab marks rosters that are already installed locally, so "everything is pulled" and "what
59
+ do I actually have" stop looking the same.
60
+ - `examples/`, `examples-orchestration/` and `orgs/agent-roster-collective/` each ship a `space.yaml`.
61
+
62
+
7
63
  ## [0.13.1] - 2026-11-03
8
64
 
9
65
  ### The local rosters are the front page
@@ -628,6 +684,8 @@ point is now one search field above the tabs.
628
684
 
629
685
  [Unreleased]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.12.0...main
630
686
  [0.13.1]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.13.0...v0.13.1
687
+ [1.0.0]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.13.1...v1.0.0
688
+ [1.1.0]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v1.0.0...v1.1.0
631
689
  [0.13.0]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.12.0...v0.13.0
632
690
  [0.12.0]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.11.1...v0.12.0
633
691
  [0.11.1]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.11.0...v0.11.1
package/README.md CHANGED
@@ -187,13 +187,14 @@ 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`)
190
+ ## Spaces (`space.yaml`)
191
191
 
192
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
193
 
194
194
  ```yaml
195
- kind: dsh-agent-roster-organization/v1
196
- organization: agent-roster-collective # must equal the directory name
195
+ kind: dsh-agent-roster/space@1
196
+ space: agent-roster-collective # must equal the directory name
197
+ layout: monorepo # flat = <id>/roster.yaml; monorepo = roles/ + rosters/ + dist/
197
198
  title: 团队档案合集
198
199
  maintainers: [STofone]
199
200
  rosters:
@@ -207,13 +208,13 @@ The declaration is mechanically checked against disk, and the three outcomes are
207
208
 
208
209
  | Situation | Code | Severity | Why |
209
210
  |---|---|---|---|
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 |
211
+ | declared, directory missing | `space-roster-missing` | **error** | the organization page is lying — worse than no page |
212
+ | directory present, not declared | `space-roster-undeclared` | warn | someone added a roster around the index; it still loads |
213
+ | declared `archived`, directory still there | `space-roster-archived` | info | archiving is a statement, not a delete |
213
214
 
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
+ `space` 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
 
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
+ 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/space.v1.schema.json`](./schema/space.v1.schema.json), kept aligned with `lib/organization.js` by `test/space.test.js`.
217
218
 
218
219
  **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
 
@@ -325,6 +326,11 @@ response, the tag strip summarises every tag in view with its count (multiple ta
325
326
  segmented control switches between **by source** (this machine, then one group per collection) and **flat**
326
327
  (one list, source named per row). Tag filtering is view state only; it never writes back to a file.
327
328
 
329
+ By source, each group's head **is the space**: its id sits in the same column as the roster ids, the
330
+ right side states two facts you can check on the spot — how many rosters it holds, how many are already
331
+ here — and carries one button that pulls the whole space. A roster that is already here says so in its row
332
+ and its button reads **Open**: the panel never asks you to pull something you already have.
333
+
328
334
  Multiple words are AND, not OR: `data kubernetes` returns nothing rather than every roster that merely
329
335
  mentions "data". Ranking is a pure function in `lib/search.js`, so it is unit-tested rather than eyeballed.
330
336
 
@@ -510,7 +516,7 @@ bin/cli.mjs validate/list/compile/migrate/registry for CI
510
516
  scripts/ host-smoke.mjs, client-smoke.mjs, check-locale.mjs (dev only, not shipped)
511
517
  examples/ a v2 library migrated from the v1 archives
512
518
  examples-orchestration/ a v2 library with orchestration, gates and a construction plan
513
- orgs/ a worked example library with its own organization.yaml (21 rosters)
519
+ orgs/ a worked example library with its own space.yaml (21 rosters)
514
520
  test/ unit tests
515
521
  ```
516
522
 
@@ -518,7 +524,7 @@ test/ unit tests
518
524
 
519
525
  Verified on this machine:
520
526
 
521
- - `npm test` — 308/308 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).
527
+ - `npm test` — 313/313 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).
522
528
  - `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.
523
529
  - CLI: `validate --dir examples` returns exit 0 over both example libraries.
524
530
  - 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,13 +183,14 @@ 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`)
186
+ ## 档案空间(`space.yaml`)
187
187
 
188
188
  档案回答的是**成员是谁**,回答不了**哪几份档案属于同一套、哪份是生产、哪份已退役**。一座库放二十份来自不同来源的档案时,目录列表就不再是索引。库**根目录**上一个可选文件只声明这件事:
189
189
 
190
190
  ```yaml
191
- kind: dsh-agent-roster-organization/v1
192
- organization: agent-roster-collective # 必须等于目录名
191
+ kind: dsh-agent-roster/space@1
192
+ space: agent-roster-collective # 必须等于目录名
193
+ layout: monorepo # flat = <id>/roster.yaml;monorepo = roles/ + rosters/ + dist/
193
194
  title: 团队档案合集
194
195
  maintainers: [STofone]
195
196
  rosters:
@@ -203,13 +204,26 @@ rosters:
203
204
 
204
205
  | 情况 | 代码 | 严重度 | 理由 |
205
206
  |---|---|---|---|
206
- | 声明了,目录不在 | `organization-roster-missing` | **error** | 组织页在撒谎,比没有组织页更糟 |
207
- | 目录在,没登记 | `organization-roster-undeclared` | warn | 有人绕过索引加了档案,但它仍会被加载 |
208
- | 声明 `archived`,目录还在 | `organization-roster-archived` | info | 归档是声明,不是删除 |
207
+ | 声明了,目录不在 | `space-roster-missing` | **error** | 组织页在撒谎,比没有组织页更糟 |
208
+ | 目录在,没登记 | `space-roster-undeclared` | warn | 有人绕过索引加了档案,但它仍会被加载 |
209
+ | 声明 `archived`,目录还在 | `space-roster-archived` | info | 归档是声明,不是删除 |
209
210
 
210
- `organization` 必须等于目录名,和 `roster` 同一条规则:位置即身份,一座库两个 id 会让所有诊断都变得没法定位。未知键一律拒收——没人核得了的字段,只是换一种方式发绿。
211
+ `space` 必须等于目录名,和 `roster` 同一条规则:位置即身份,一座库两个 id 会让所有诊断都变得没法定位。未知键一律拒收——没人核得了的字段,只是换一种方式发绿。
211
212
 
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
+ 这份文件什么都不创建:不写 `team.json`、不建成员,也永远不会进入 `agent_teams_add_member` 参数。
214
+
215
+ **它同时固定了布局。** `layout: flat` 是 `<id>/roster.yaml`(v1 时代的一仓多档案);`layout: monorepo` 是 `roles/<role>/AGENT.md` + `rosters/<id>/roster.yaml` + `dist/`。布局进了格式,编辑器才知道该改哪个文件——不再是"拉下来摊平、改的和源文件没关系"。
216
+
217
+ **本地副本靠 `origin` 认祖归宗。** 从远端拉下来的空间会在 `space.yaml` 里多一段 `origin: {repo, ref, path}`,其余字段原样保留;仓库里的空间文件**不许**写 `origin`(源就是这个仓库本身,再写一遍就是第二真源),校验器会拒收。
218
+
219
+ 拉一整座空间、并且保留原布局(拉完就能改,改的就是源仓库里那个文件):
220
+
221
+ ```bash
222
+ dsh-agent-roster clone --repo dsh-agent-roster/agent-teams --space agent-teams
223
+ ```
224
+
225
+ 面板「档案库」里每组来源都有一个「拉取整个空间」按钮,等价于这条命令;单份档案的「拉取」仍然只摊平那一份(适合"我只要这一份、不打算往回改")。
226
+ 它的 JSON Schema 在 [`schema/space.v1.schema.json`](./schema/space.v1.schema.json),由 `test/space.test.js` 钉住与 `lib/space.js` 一致。
213
227
 
214
228
  **仓内带了一个可跑的示例**:[`orgs/agent-roster-collective/`](./orgs/agent-roster-collective/) 里有 21 份档案(63 名成员),覆盖接口评审、事故响应、数据质量、数据库迁移、本地化、安全审计、性能、前端、文档、发布、客服分诊、招聘、市场研究、财务对账、合同审阅、内容运营、回归测试、模型评测、SRE 值守、文献综述与产品发现。每份都是"队长 + 两名专家"的三人组,全部为本项目自撰(未复制任何上游文本)。校验方式:
215
229
 
@@ -309,6 +323,10 @@ node bin/cli.mjs compile --roster examples-orchestration/rosters/core-review \
309
323
  多选是「且」;右侧可以在**按来源**(本机 + 每个 collection 各一组)与**平铺**(一份清单,来源写在行内)
310
324
  之间切换。标签筛选只影响视图,不写回文件。
311
325
 
326
+ 按来源时,每一组的头就是**空间**本身:等宽的空间 id 落在与档案 id 同一列,右侧写两件当场能核对的事实
327
+ ——「几份 · 已装几份」——并带一个「拉取整个空间」按钮。已经在本机的那份,行内写「已在本机」,
328
+ 按钮变成「打开」:面板不会让你去拉一份自己已经有的档案。窄屏上这个头折成两行,空间名不会被挤成竖排。
329
+
312
330
  多个词是「且」不是「或」:`data kubernetes` 一条都不返,而不是把所有提到 data 的档案都堆上来。
313
331
  排序是 `lib/search.js` 里的纯函数,所以它被单测钉住,而不是靠肉眼看着顺。
314
332
 
@@ -487,7 +505,7 @@ test/ 单测
487
505
 
488
506
  本机已验证:
489
507
 
490
- - `npm test` —— 308/308 通过(格式、解析、校验、扫描、路径、桥接、核对四桶、摘要与溯源、档案差异、能力要求与能力闸门、档案库组织、索引 schema、无损 JSON、写入计划与执行、六条路由、快照与提示、两本词典一致、工具契约、客户端加载器契约、包清单、配置视图、样式 token 约束、声明层字段与队长段、schema 与常量对齐、v2 角色/组装/编译三层及其 schema 对齐、v1→v2 迁移、执行回执并入施工图)。
508
+ - `npm test` —— 313/313 通过(格式、解析、校验、扫描、路径、桥接、核对四桶、摘要与溯源、档案差异、能力要求与能力闸门、档案库组织、索引 schema、无损 JSON、写入计划与执行、六条路由、快照与提示、两本词典一致、工具契约、客户端加载器契约、包清单、配置视图、样式 token 约束、声明层字段与队长段、schema 与常量对齐、v2 角色/组装/编译三层及其 schema 对齐、v1→v2 迁移、执行回执并入施工图)。
491
509
  - `npm run smoke` —— 用真实 DSH bundle 的 `defineTool` 注册六个工具,逐个调用:输出全部通过无损校验、`path` 覆盖生效、释放干净;同一条命令随后用桩模块表加载 `client.js`,断言三处槽位注册、样式只注入一次、effect 可回收。
492
510
  - CLI:`validate --dir examples` 对两座示例库退出码 0。
493
511
  - 用插件管理器真的装进了本机 profile(`plugin_manager install_bundle` 指向本仓库 → `link:` 依赖 + bundle 条目),重启后按链接读取本仓库文件。
package/bin/cli.mjs CHANGED
@@ -15,10 +15,12 @@
15
15
  * dsh-agent-roster migrate --dir examples [--write] [--json]
16
16
  * dsh-agent-roster migrate --dir . --from rosters --repo owner/repo --write
17
17
  */
18
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
19
  import nodePath from "node:path";
20
20
 
21
21
  import { resolveDirSpec } from "../lib/home.js";
22
+ import { globalRosterRoot } from "../lib/home.js";
23
+ import { cloneSpace } from "../lib/clone.js";
22
24
  import { scanRosterLibrary } from "../lib/registry.js";
23
25
  import { renderRosterList } from "../lib/render.js";
24
26
  import { REGISTRY_KIND } from "../lib/format.js";
@@ -40,6 +42,8 @@ const USAGE = `用法:
40
42
  dsh-agent-roster compile --roster <目录> 把 v2 组装编译成自包含产物(不写盘)
41
43
  dsh-agent-roster migrate --dir <目录> [--from <子目录>] [--repo <owner/repo>] [--write]
42
44
  把 v1 档案原地迁成 v2(roles/ + rosters/ + dist/)
45
+ dsh-agent-roster clone --repo <owner/repo> [--ref <分支>] [--path <子目录>] [--space <id>] [--dir <目录>]
46
+ 把一个远端空间按原布局拉成本地副本(可编辑)
43
47
  dsh-agent-roster --version 输出版本号
44
48
 
45
49
  选项:
@@ -50,7 +54,10 @@ const USAGE = `用法:
50
54
  --write sync 把重算结果写回文件
51
55
  --roster <path> v2 组装目录(compile 用)
52
56
  --from <path> v1 档案所在目录(migrate 用;缺省与 --dir 相同,用于仓库根 + rosters/ 布局)
53
- --repo <owner/repo> 写进 dist 产物的仓库坐标(migrate 用;缺省取本仓 remote)
57
+ --repo <owner/repo> 写进 dist 产物的仓库坐标(migrate 用);clone 的源仓库
58
+ --ref <branch|tag> clone 的分支/tag(缺省 main)
59
+ --path <subdir> clone 的仓库内子目录(缺省仓库根)
60
+ --space <id> clone 落到本地后的空间 id(缺省取仓库名)
54
61
  --run <path> 执行回执(compile 用;把上一轮失败并入重试卡)
55
62
  --roles <path> 角色库目录(compile 用;缺省按 rosters/<id> 推同级的 roles/)
56
63
  --json 以 JSON 输出(validate / list / compile 都支持)
@@ -73,6 +80,9 @@ function parseArgs(argv) {
73
80
  roles: null,
74
81
  run: null,
75
82
  from: null,
83
+ ref: null,
84
+ path: null,
85
+ space: null,
76
86
  repo: null,
77
87
  };
78
88
  for (let index = 0; index < argv.length; index += 1) {
@@ -112,6 +122,21 @@ function parseArgs(argv) {
112
122
  if (value === undefined) throw new UsageError("--run 需要一个路径");
113
123
  options.run = value;
114
124
  index += 1;
125
+ } else if (arg === "--ref") {
126
+ const value = argv[index + 1];
127
+ if (value === undefined) throw new UsageError("--ref 需要一个分支名");
128
+ options.ref = value;
129
+ index += 1;
130
+ } else if (arg === "--path") {
131
+ const value = argv[index + 1];
132
+ if (value === undefined) throw new UsageError("--path 需要一个相对目录");
133
+ options.path = value;
134
+ index += 1;
135
+ } else if (arg === "--space") {
136
+ const value = argv[index + 1];
137
+ if (value === undefined) throw new UsageError("--space 需要一个 id");
138
+ options.space = value;
139
+ index += 1;
115
140
  } else if (arg === "--roles") {
116
141
  const value = argv[index + 1];
117
142
  if (value === undefined) throw new UsageError("--roles 需要一个路径");
@@ -159,6 +184,7 @@ async function main(argv) {
159
184
  return 0;
160
185
  }
161
186
  if (options.command === "compile") return runCompile(options);
187
+ if (options.command === "clone") return runClone(options);
162
188
  if (options.command === "migrate") return runMigrate(options);
163
189
  if (options.command === "registry") {
164
190
  if (options.file === null) {
@@ -195,8 +221,8 @@ async function main(argv) {
195
221
  rootDir: report.rootDir,
196
222
  missing: report.scan.missing,
197
223
  stats: report.scan.stats,
198
- organization: report.scan.organization,
199
- organizationCheck: report.scan.organizationCheck,
224
+ space: report.scan.space,
225
+ spaceCheck: report.scan.spaceCheck,
200
226
  rosters: report.scan.rosters.map((roster) => ({ id: roster.id, file: roster.file })),
201
227
  diagnostics: report.scan.diagnostics,
202
228
  })),
@@ -212,11 +238,11 @@ async function main(argv) {
212
238
  process.stdout.write(`${renderRosterList(report.scan)}\n`);
213
239
  } else {
214
240
  const { stats } = report.scan;
215
- const organization = report.scan.organization ?? null;
216
- if (organization !== null) {
217
- const check = report.scan.organizationCheck;
241
+ const space = report.scan.space ?? null;
242
+ if (space !== null) {
243
+ const check = report.scan.spaceCheck;
218
244
  process.stdout.write(
219
- ` 组织 ${organization.id} ${organization.title}(登记 ${check.declared} · 在库 ${check.present} · ` +
245
+ ` 空间 ${space.id} ${space.title}(登记 ${check.declared} · 在库 ${check.present} · ` +
220
246
  `缺 ${check.missing.length} · 未登记 ${check.undeclared.length})\n`,
221
247
  );
222
248
  }
@@ -593,3 +619,56 @@ function runMigrate(options) {
593
619
  }
594
620
  return rc;
595
621
  }
622
+
623
+ /**
624
+ * `clone`:把一个远端空间按原布局拉成本地副本。
625
+ *
626
+ * 落点默认是全局档案库根(`$DSH_HOME/agent-roster`),可以用 `--dir` 指到别处。
627
+ * 目录里有同名空间时**不覆盖**——覆盖别人正在用的副本不该是顺手发生的事;
628
+ * 要重来做 `rm -rf` 或换个 `--space` 名字。
629
+ *
630
+ * @param {object} options
631
+ * @returns {Promise<number>}
632
+ */
633
+ async function runClone(options) {
634
+ if (options.repo === null) {
635
+ process.stderr.write(`clone 需要 --repo <owner/repo>\n\n${USAGE}`);
636
+ return 2;
637
+ }
638
+ const rootDir = options.dirs.length > 0 ? nodePath.resolve(options.dirs[0]) : globalRosterRoot();
639
+ const spaceId = options.space ?? options.repo.split("/")[1];
640
+ const target = nodePath.join(rootDir, spaceId);
641
+ if (existsSync(target) && options.write !== true) {
642
+ process.stderr.write(
643
+ `目标已存在:${target}\n` +
644
+ "不覆盖已有副本(那可能正被使用)。要重来请先删掉它,或用 --space 换个名字。\n" +
645
+ "确认要就地重写就加 --write(只重写本次拉到的文件)。\n",
646
+ );
647
+ return 1;
648
+ }
649
+
650
+ const result = await cloneSpace({
651
+ rootDir,
652
+ spaceId,
653
+ repo: options.repo,
654
+ ref: options.ref ?? "main",
655
+ path: options.path ?? "",
656
+ fetchImpl: fetch,
657
+ });
658
+
659
+ if (options.json) {
660
+ process.stdout.write(`${JSON.stringify({ plugin: pkg.name, version: pkg.version, rootDir, ...result }, null, 2)}\n`);
661
+ } else {
662
+ const where = options.path === null ? "" : `/${options.path}`;
663
+ process.stdout.write(
664
+ `空间 ${spaceId} <- ${options.repo}@${options.ref ?? "main"}${where} 布局 ${result.layout} ` +
665
+ `写入 ${result.written.length} 个文件${result.skipped.length > 0 ? ` · 跳过 ${result.skipped.length}` : ""}\n`,
666
+ );
667
+ process.stdout.write(` 落点 ${result.dir}\n`);
668
+ for (const item of result.diagnostics) process.stdout.write(`${formatDiagnostic(item)}\n`);
669
+ if (result.written.length > 0) {
670
+ process.stdout.write(` 文件 ${result.written.slice(0, 8).join(" · ")}${result.written.length > 8 ? " …" : ""}\n`);
671
+ }
672
+ }
673
+ return result.ok ? 0 : 1;
674
+ }
package/client.js CHANGED
@@ -32,6 +32,7 @@ window.__ModuleLoader__.load({
32
32
  const DOCUMENT_ROUTE = "/plugins/dsh-agent-roster/document";
33
33
  const ROSTERS_ROUTE = "/plugins/dsh-agent-roster/rosters";
34
34
  const INSTALL_ROUTE = "/plugins/dsh-agent-roster/install";
35
+ const INSTALL_SPACE_ROUTE = "/plugins/dsh-agent-roster/install-space";
35
36
  const COLLECTION_ROUTE = "/plugins/dsh-agent-roster/collection";
36
37
  const APPLY_ROUTE = "/plugins/dsh-agent-roster/apply";
37
38
  const SEARCH_ROUTE = "/plugins/dsh-agent-roster/search";
@@ -1285,7 +1286,7 @@ window.__ModuleLoader__.load({
1285
1286
  * 查询走后端排序(`/search`):前端再写一套打分,两处迟早给出不同顺序。
1286
1287
  * 标签筛选是「且」,与档案页签的标签池同一语义。
1287
1288
  */
1288
- function CatalogTab({ onOpen, onPull }) {
1289
+ function CatalogTab({ onOpen, onPull, onPullSpace }) {
1289
1290
  useTranslation();
1290
1291
  const [mode, setMode] = useState("source");
1291
1292
  const [query, setQuery] = useState("");
@@ -1348,6 +1349,13 @@ window.__ModuleLoader__.load({
1348
1349
  return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
1349
1350
  }, [catalog.entries]);
1350
1351
 
1352
+ // 哪些远端档案本机已经有了:按 id 对齐(同一个空间里 id 唯一)。
1353
+ const installed = useMemo(() => {
1354
+ const set = new Set();
1355
+ for (const entry of catalog.entries) if (entry.scope === "local") set.add(entry.rosterId);
1356
+ return set;
1357
+ }, [catalog.entries]);
1358
+
1351
1359
  const entries = hits !== null && hits.status === "ready" ? hits.matches : catalog.entries;
1352
1360
  const filtered = useMemo(() => {
1353
1361
  return entries.filter((entry) => {
@@ -1365,14 +1373,19 @@ window.__ModuleLoader__.load({
1365
1373
  map.set(key, {
1366
1374
  key,
1367
1375
  scope: entry.scope,
1368
- title: entry.scope === "local" ? t("catalog.local") : entry.collectionTitle ?? entry.collectionId ?? "",
1376
+ id: entry.scope === "local" ? t("catalog.local") : entry.collectionId ?? "",
1377
+ title: entry.scope === "local" ? "" : entry.collectionTitle ?? "",
1369
1378
  entries: [],
1370
1379
  });
1371
1380
  }
1372
1381
  map.get(key).entries.push(entry);
1373
1382
  }
1374
- return [...map.values()];
1375
- }, [filtered, mode]);
1383
+ // 空间头上只放两件能当场核对的事实:有几份、其中几份本机已经有。
1384
+ return [...map.values()].map((group) => ({
1385
+ ...group,
1386
+ installed: group.entries.filter((entry) => installed.has(entry.rosterId)).length,
1387
+ }));
1388
+ }, [filtered, mode, installed, t]);
1376
1389
 
1377
1390
  function toggleTag(tag) {
1378
1391
  setSelected((current) => {
@@ -1410,13 +1423,17 @@ window.__ModuleLoader__.load({
1410
1423
  "div",
1411
1424
  { className: "rstr-row-stats" },
1412
1425
  h("span", { className: "rstr-stat" }, t("catalog.members", { count: String(entry.members ?? 0) })),
1413
- h(
1414
- "span",
1415
- { className: "rstr-stat" },
1416
- entry.scope === "local"
1417
- ? t("catalog.local")
1418
- : t("catalog.remoteFrom", { collection: entry.collectionTitle ?? entry.collectionId ?? "" }),
1419
- ),
1426
+ entry.scope === "remote" && installed.has(entry.rosterId)
1427
+ ? h("span", { className: "rstr-row-here" }, t("catalog.here"))
1428
+ : null,
1429
+ // 本机组里每行再写一次「本机」是废话:所在的分组头已经说了。
1430
+ entry.scope === "local"
1431
+ ? null
1432
+ : h(
1433
+ "span",
1434
+ { className: "rstr-stat" },
1435
+ t("catalog.remoteFrom", { collection: entry.collectionTitle ?? entry.collectionId ?? "" }),
1436
+ ),
1420
1437
  ),
1421
1438
  h(
1422
1439
  "div",
@@ -1428,7 +1445,8 @@ window.__ModuleLoader__.load({
1428
1445
  className: "rstr-btn rstr-btn-sm",
1429
1446
  disabled: busy,
1430
1447
  onClick: async () => {
1431
- if (entry.scope === "local") {
1448
+ // 已经在本机的那份,按钮就该是「打开」——不能让人去拉一份自己已经有的。
1449
+ if (entry.scope === "local" || installed.has(entry.rosterId)) {
1432
1450
  onOpen(entry.rosterId);
1433
1451
  return;
1434
1452
  }
@@ -1441,7 +1459,7 @@ window.__ModuleLoader__.load({
1441
1459
  }
1442
1460
  },
1443
1461
  },
1444
- entry.scope === "local" ? t("catalog.open") : t("catalog.pull"),
1462
+ entry.scope === "local" || installed.has(entry.rosterId) ? t("catalog.open") : t("catalog.pull"),
1445
1463
  ),
1446
1464
  ),
1447
1465
  ),
@@ -1538,14 +1556,42 @@ window.__ModuleLoader__.load({
1538
1556
  : groups.map((group) =>
1539
1557
  h(
1540
1558
  "div",
1541
- { key: group.key, className: "rstr-group" },
1542
- mode === "flat" || group.title === ""
1559
+ { key: group.key, className: "rstr-space" },
1560
+ mode === "flat"
1543
1561
  ? null
1544
1562
  : h(
1545
1563
  "div",
1546
- { className: "rstr-group-head" },
1547
- h("span", { className: "rstr-group-title" }, group.title),
1548
- h("span", { className: "rstr-group-count" }, String(group.entries.length)),
1564
+ { className: "rstr-space-head" },
1565
+ h("code", { className: "rstr-space-id", title: group.id }, group.id),
1566
+ h("span", { className: "rstr-space-title" }, group.title),
1567
+ h(
1568
+ "span",
1569
+ { className: "rstr-space-facts" },
1570
+ t("catalog.facts", {
1571
+ count: String(group.entries.length),
1572
+ installed: String(group.installed),
1573
+ }),
1574
+ ),
1575
+ group.scope === "remote" && typeof onPullSpace === "function"
1576
+ ? h(
1577
+ "button",
1578
+ {
1579
+ type: "button",
1580
+ className: "rstr-btn rstr-btn-sm rstr-btn-ghost",
1581
+ disabled: pulling === `space:${group.key}`,
1582
+ onClick: async () => {
1583
+ setPulling(`space:${group.key}`);
1584
+ try {
1585
+ await onPullSpace(group.key.slice(2));
1586
+ setNonce((value) => value + 1);
1587
+ } finally {
1588
+ setPulling("");
1589
+ }
1590
+ },
1591
+ },
1592
+ t("catalog.pullSpace"),
1593
+ )
1594
+ : null,
1549
1595
  ),
1550
1596
  group.entries.map(row),
1551
1597
  ),
@@ -1654,7 +1700,7 @@ window.__ModuleLoader__.load({
1654
1700
  }
1655
1701
  }
1656
1702
 
1657
- const organization = (snapshot?.workspaces ?? []).map((workspace) => workspace.organization).find((item) => item && item.id)?.id ?? "";
1703
+ const space = (snapshot?.workspaces ?? []).map((workspace) => workspace.space).find((item) => item && item.id)?.id ?? "";
1658
1704
 
1659
1705
  const header = h(
1660
1706
  "header",
@@ -1670,7 +1716,7 @@ window.__ModuleLoader__.load({
1670
1716
  h("span", null, h("b", null, String(snapshot?.summary?.rosters ?? 0)), " ", t("panel.metaRosters")),
1671
1717
  h("span", null, h("b", null, String(snapshot?.summary?.members ?? 0)), " ", t("panel.metaMembers")),
1672
1718
  h("span", null, h("b", null, String(snapshot?.summary?.teams ?? 0)), " ", t("panel.metaTeams")),
1673
- organization === "" ? null : h("span", null, t("panel.metaOrganization", { name: organization })),
1719
+ space === "" ? null : h("span", null, t("panel.metaSpace", { name: space })),
1674
1720
  ),
1675
1721
  ),
1676
1722
  h(
@@ -1766,6 +1812,28 @@ window.__ModuleLoader__.load({
1766
1812
  setToast(t("misc.failed"));
1767
1813
  }
1768
1814
  },
1815
+ onPullSpace: async (collectionId) => {
1816
+ try {
1817
+ const result = await readJson(INSTALL_SPACE_ROUTE, {
1818
+ method: "POST",
1819
+ headers: { "content-type": "application/json" },
1820
+ body: JSON.stringify({ collection: collectionId }),
1821
+ });
1822
+ if (!result.ok || result.body === null) {
1823
+ setToast(`${t("misc.failed")} (${String(result.status)})`);
1824
+ return;
1825
+ }
1826
+ const body = result.body;
1827
+ const summary = t("catalog.pulledSpace", {
1828
+ files: String((body.written ?? []).length),
1829
+ skipped: String((body.skipped ?? []).length),
1830
+ });
1831
+ setToast(summary);
1832
+ library.reload();
1833
+ } catch {
1834
+ setToast(t("misc.failed"));
1835
+ }
1836
+ },
1769
1837
  });
1770
1838
  }
1771
1839
 
@@ -2682,13 +2750,37 @@ window.__ModuleLoader__.load({
2682
2750
  }
2683
2751
  .rstr-catalog .rstr-search { max-width: 560px; }
2684
2752
 
2685
- .rstr-group { display: flex; flex-direction: column; min-width: 0; }
2753
+ .rstr-space { display: flex; flex-direction: column; min-width: 0; }
2754
+ .rstr-space + .rstr-space { margin-top: 24px; }
2755
+ .rstr-space-head {
2756
+ display: grid;
2757
+ grid-template-columns: minmax(110px, 176px) minmax(0, 1fr) auto auto;
2758
+ align-items: baseline;
2759
+ gap: 8px 16px;
2760
+ padding: 16px 8px 6px 0;
2761
+ border-bottom: 1px solid var(--dsw-alias-border-l2);
2762
+ min-width: 0;
2763
+ }
2764
+ .rstr-space-id {
2765
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
2766
+ font-size: 13px;
2767
+ font-weight: 600;
2768
+ color: var(--dsw-alias-label-primary);
2769
+ overflow: hidden;
2770
+ text-overflow: ellipsis;
2771
+ white-space: nowrap;
2772
+ }
2773
+ .rstr-space-title { font-size: 13px; color: var(--dsw-alias-label-secondary); min-width: 0; }
2774
+ .rstr-space-facts {
2775
+ font-size: 11px;
2776
+ color: var(--dsw-alias-label-tertiary);
2777
+ font-variant-numeric: tabular-nums;
2778
+ white-space: nowrap;
2779
+ }
2780
+ .rstr-row-here { font-size: 11px; color: var(--dsw-alias-label-tertiary); }
2686
2781
 
2687
- .rstr-group-head { display: flex; align-items: baseline; gap: 8px; padding: 10px 0 4px; border-bottom: 1px solid var(--dsw-alias-border-l2); }
2688
2782
 
2689
- .rstr-group-title { font-size: 12px; font-weight: 600; letter-spacing: 0.02em; color: var(--dsw-alias-label-secondary); }
2690
2783
 
2691
- .rstr-group-count { font-size: 11px; color: var(--dsw-alias-label-tertiary); font-variant-numeric: tabular-nums; }
2692
2784
 
2693
2785
  .rstr-results { display: flex; flex-direction: column; min-width: 0; padding: 0 28px 28px; border-top: 1px solid var(--dsw-alias-border-l2); }
2694
2786
 
@@ -3027,6 +3119,16 @@ window.__ModuleLoader__.load({
3027
3119
  .rstr-tag { border-left: none; border-bottom: 2px solid transparent; border-radius: 0; white-space: nowrap; }
3028
3120
  .rstr-tag-on { border-left: none; border-bottom-color: var(--dsw-alias-state-business-primary); background: transparent; }
3029
3121
  .rstr-tag-clear { margin-top: 0; flex: 0 0 auto; }
3122
+ /* 空间头:窄屏由一列 id/标题/事实/按钮的四列网格折成两行 */
3123
+ .rstr-space-head {
3124
+ grid-template-columns: minmax(0, 1fr) auto;
3125
+ row-gap: 2px;
3126
+ padding-top: 14px;
3127
+ }
3128
+ .rstr-space-id { grid-area: 1 / 1; }
3129
+ .rstr-space-head > .rstr-btn { grid-area: 1 / 2; }
3130
+ .rstr-space-title { grid-area: 2 / 1; }
3131
+ .rstr-space-facts { grid-area: 2 / 2; text-align: right; }
3030
3132
  .rstr-row-body { grid-template-columns: minmax(0, 1fr) auto; }
3031
3133
  .rstr-row-id { grid-column: 1; }
3032
3134
  .rstr-row-text { grid-column: 1; }
package/docs/roster-v2.md CHANGED
@@ -209,7 +209,7 @@ gates:
209
209
  | 层 | 文件 | 回答的问题 |
210
210
  |---|---|---|
211
211
  | 能力 | `capabilities.yaml` | 这份档案要求部署提供什么(有证据源) |
212
- | 组织 | `organization.yaml` | 这座库收了哪些档案、哪些已归档 |
212
+ | 空间 | `space.yaml` | 这座库收了哪些档案、什么布局(flat / monorepo)、本地副本从哪来 |
213
213
  | 角色/组装 | `AGENT.md` + `roster.yaml` | 谁是谁、怎么拼、编排放哪 |
214
214
 
215
215
  三者正交:`requires` 说"必须有",`capabilities.yaml` 说"这份组装必须有",`organization.yaml` 说"这座库有哪些"。
@@ -0,0 +1,11 @@
1
+ kind: dsh-agent-roster/space@1
2
+ space: examples
3
+ title: 示例档案(v2 三层)
4
+ description: 两份从 v1 迁过来的档案,用来演示角色/组装/编译与施工图。
5
+ license: MIT
6
+ maintainers:
7
+ - STofone
8
+ layout: monorepo
9
+ rosters:
10
+ - id: core-review
11
+ - id: research