dsh-plugin-t-expert 0.2.9 → 0.2.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -11
- package/data/experts/engineering/engineering-deepseek-harness-project-expert.md +29 -3
- package/lib/bootstrap.js +314 -45
- package/lib/catalog.js +24 -3
- package/lib/client.js +131 -127
- package/lib/command.js +18 -5
- package/lib/i18n.js +18 -2
- package/lib/index.js +426 -80
- package/lib/plan-check.js +36 -54
- package/lib/remote-schemas.js +156 -0
- package/lib/remote.js +29 -133
- package/lib/skill.js +5 -2
- package/lib/squads.js +154 -35
- package/lib/teams/harness-compat.js +36 -0
- package/lib/teams/index.js +11 -3
- package/lib/teams/members.js +14 -5
- package/lib/teams/snapshot.js +7 -4
- package/lib/teams/tools.js +35 -18
- package/package.json +9 -7
- package/skills/dsh-harness-project/SKILL.md +27 -0
- package/skills/t-expert-manager/SKILL.md +11 -2
- package/skills/t-expert-manager/references/ops-reference.md +2 -2
package/README.md
CHANGED
|
@@ -33,11 +33,23 @@ dsh plugin --profile web add dsh-plugin-t-expert
|
|
|
33
33
|
|
|
34
34
|
装完重启 DSH,然后在 **设置 → T专家** 里启用专家——**默认全部未启用**,未启用的专家不能被召唤。
|
|
35
35
|
|
|
36
|
+
### 兼容性与两个已知依赖
|
|
37
|
+
|
|
38
|
+
团队引擎里有两处**贴着宿主实现细节**的适配,升级 DSH 前值得知道:
|
|
39
|
+
|
|
40
|
+
| 依赖 | 说明 | 失效时的表现 |
|
|
41
|
+
| --- | --- | --- |
|
|
42
|
+
| 子代理投递契约(`ctx.subagents` 的 `followup` / `sendMessage` 与两个 `Symbol.for` 内部接口) | 插件要在"成员已退役"时拦住投递,而宿主没有公开的 retire/forget 扩展点,只能按**实测过的版本**包装这几个方法;已实测版本写在 `lib/teams/harness-compat.js` 的 `TESTED_HARNESS_VERSIONS`(当前 `0.1.5-rc.1` / `rc.2`) | 引擎**降级**(团队功能不可用,名册与召唤不受影响),日志与 `/t` 会给出原因。若上游把服务冻结或改成 accessor,会明确报「属性不可写」而不是一个裸 `TypeError` |
|
|
43
|
+
| 活动面板的客户端物化用 `new Function` 求值内联产物 | 因此需要宿主允许 `eval`。当前 DSH 壳**没有**设置 CSP,本机实测可用 | 壳将来若加严格 CSP,面板模块会加载失败——失败会记 `console.error` 并跳过,不影响名册与团队引擎 |
|
|
44
|
+
|
|
45
|
+
这两条都不是"插件写坏了",而是宿主还没提供文档化扩展点。要根治第一条,需要宿主提供一个
|
|
46
|
+
文档化的退役/投递拦截扩展点,插件改为挂在那里。
|
|
47
|
+
|
|
36
48
|
---
|
|
37
49
|
|
|
38
50
|
## 二、数据目录
|
|
39
51
|
|
|
40
|
-
|
|
52
|
+
插件对任何机器都**自包含**:包内带一份名册快照,启动时与 `~/.t-team/` 对齐(不是只在首次播种)。
|
|
41
53
|
|
|
42
54
|
```
|
|
43
55
|
~/.t-team/
|
|
@@ -48,11 +60,19 @@ dsh plugin --profile web add dsh-plugin-t-expert
|
|
|
48
60
|
├── t-team.config.json 小队编译产物(引擎配置,人格已内联)
|
|
49
61
|
├── teams.resolved.json 小队编译产物(/t 列表用)
|
|
50
62
|
├── team-profiles.py 小队编译器的播种副本(实际跑的是包内那份)
|
|
51
|
-
|
|
63
|
+
├── source.json 名册清单(专家数 / 分类 / 更新时间)
|
|
64
|
+
└── .t-team-snapshot.json 上次对齐的记录(版本 / 漂移计数,用于「升级与送不到」出声)
|
|
52
65
|
```
|
|
53
66
|
|
|
54
|
-
-
|
|
55
|
-
|
|
67
|
+
- **升级会送达,用户内容不动**(2026-09-13 起按条目同步,不再是「首次播种后一律不动」):
|
|
68
|
+
- 包内**新增**的专家、小队、译文键:直接补齐到你的数据目录;
|
|
69
|
+
- 包内**修订**的只读条目(`experts/`、`source.json`、`team-profiles.py`、`zh/` 的覆盖表与分区表):
|
|
70
|
+
与包内对齐;
|
|
71
|
+
- `zh/names.json`、`zh/descriptions.json`、`teams.json`:**只补缺失的键**,你改过的键一个都不动;
|
|
72
|
+
- 你改过或补译的 `zh/<分类>/<slug>.md` 正文:**保留盘上那份**,并在日志里明确告知;
|
|
73
|
+
- 包内已移除的条目:只报告,不删你的文件。
|
|
74
|
+
- 一句话:**送得到的送到,送不到的一定出声**(日志里给出数量与例子,删掉对应文件后重启即可重新播种)。
|
|
75
|
+
- 唯一始终用包内那份的是小队编译器 `team-profiles.py`——它是代码,随插件版本走。
|
|
56
76
|
- 想换目录位置,用配置项 `root` / `zhRoot` / `customRoot` 覆盖即可,不必改代码。
|
|
57
77
|
|
|
58
78
|
---
|
|
@@ -103,6 +123,14 @@ dsh plugin --profile web add dsh-plugin-t-expert
|
|
|
103
123
|
`t-expert-manager` 正文里的 `tz.sh` 路径是注册时按本机解出来的,不是写死的。
|
|
104
124
|
后两个是 `dsh-project-expert`(项目专家模式)这个 agent preset 里同名 skill 的随包副本。
|
|
105
125
|
|
|
126
|
+
### 本地活动的可见范围
|
|
127
|
+
|
|
128
|
+
设置页「团队」标签读的是插件在本机注册的只读接口(`/plugins/t-team/…`):每个请求都先过宿主的
|
|
129
|
+
Host/Origin 校验与浏览器认证,静态资源另有白名单,非白名单一律 404。其中团队活动接口会**遍历
|
|
130
|
+
当前机器的所有工作区**,所以任何已认证的本机浏览器上下文都能看到这些工作区里的团队名、成员名
|
|
131
|
+
与任务标题(这是内置引擎的既有行为,不是 T专家 自己引入的)。DSH 是单用户本地应用,风险有限;
|
|
132
|
+
只在你的机器上有不信任的浏览器扩展或共享浏览器配置时才需要留意。
|
|
133
|
+
|
|
106
134
|
---
|
|
107
135
|
|
|
108
136
|
## 五、小队
|
|
@@ -110,8 +138,12 @@ dsh plugin --profile web add dsh-plugin-t-expert
|
|
|
110
138
|
设置 → 队伍 里搜索、修改成员 / 别名 / 启停,然后保存:改动写回 `~/.t-team/teams.json`,
|
|
111
139
|
再调用小队编译器生成引擎配置;**编译失败会自动回滚**,不会把坏定义留在盘上。
|
|
112
140
|
|
|
113
|
-
|
|
114
|
-
|
|
141
|
+
**保存后立即生效,无需重启 DSH**:编译成功后运行中的插件会就地重载引擎配置,
|
|
142
|
+
`/t` 列表与新建队(含队长提示段)随即看到新小队;已经跑起来的团队不受影响,继续用启动时的成员表。
|
|
143
|
+
|
|
144
|
+
- 编译失败并**回滚**时不会重载:盘上还是旧配置,建队照旧按旧小队走,设置页会直接报错。
|
|
145
|
+
- 引擎重载本身失败时会留下可诊断的信号(日志里有 `[t-team]` 的 error/warn),
|
|
146
|
+
并且报错时会带着原因说清引擎当前是否可用,不会静默假装已生效。
|
|
115
147
|
|
|
116
148
|
约束:小队 key 只能 ASCII `a-z0-9-`(中文放 `description` / `aliases`);每队 ≤ `maxMembers`(默认 8,见「六、配置项」);
|
|
117
149
|
小队总数 ≤ 48;别名全局唯一;成员必须是名册里真实存在的专家。
|
|
@@ -133,7 +165,7 @@ dsh plugin --profile web add dsh-plugin-t-expert
|
|
|
133
165
|
| `provider` | `"spawn"` | 召唤专家用的子代理 provider |
|
|
134
166
|
| `divisions` | `[]` | 留空=自动扫描 `root` 下所有含 `.md` 的分类 |
|
|
135
167
|
| `maxSummonBatch` / `summonConcurrency` | `8` / `4` | 批量召唤上限与并发 |
|
|
136
|
-
| `stateDir` | `.agent-teams` | 团队状态目录(**工作区内的相对路径**),团队状态落在 `<工作区>/<stateDir>/<teamId>/`。内置团队引擎、设置页「队伍」标签与只读预检工具 `t_team_plan_check`
|
|
168
|
+
| `stateDir` | `.agent-teams` | 团队状态目录(**工作区内的相对路径**),团队状态落在 `<工作区>/<stateDir>/<teamId>/`。内置团队引擎、设置页「队伍」标签与只读预检工具 `t_team_plan_check` 都按它定位团队——**三处都读这一个字段**,不存在第二来源(自检 8f 有静态 + 行为双断言兜着)。要挪团队状态目录只改这里 |
|
|
137
169
|
| `memberProvider` / `memberModel` / `maxMembers` | — | 转给内置团队引擎;`maxMembers` 还由插件透传给小队编译器(见「五、小队」) |
|
|
138
170
|
|
|
139
171
|
---
|
|
@@ -149,14 +181,43 @@ dsh plugin --profile web add dsh-plugin-t-expert
|
|
|
149
181
|
## 八、开发
|
|
150
182
|
|
|
151
183
|
```bash
|
|
152
|
-
npm
|
|
153
|
-
npm run
|
|
184
|
+
npm install # 装开发期依赖(.npmrc 里开了 legacy-peer-deps,原因见该文件)
|
|
185
|
+
npm run typecheck # 类型检查(tsconfig.json 只覆盖自研 Host 文件,不碰并入的引擎)
|
|
186
|
+
npm run verify # 自检套件(现 511 项断言):插件契约、工具、remote、客户端产物、播种与快照,
|
|
154
187
|
# 外加「发布包自洽」(真打一份 tgz、解开、再用插件解析器读一遍)
|
|
188
|
+
npm run build # 构建客户端产物(lib/client.js)
|
|
155
189
|
npm run sync-data # 把运行时数据同步进包内 data/
|
|
156
190
|
```
|
|
157
191
|
|
|
158
|
-
|
|
159
|
-
|
|
192
|
+
`tools/` 里的构建 / 自检 / 同步脚本**随仓库走**,所以干净 clone 就能跑 `typecheck` 与 `verify`;
|
|
193
|
+
需要运维台(`tz.sh`、`add-expert.py`)的那几节在本机没有运维目录时会**显式打印 skip**,不会
|
|
194
|
+
静默少跑。推送与 PR 由 `.github/workflows/ci.yml` 跑同一套门禁。
|
|
195
|
+
|
|
196
|
+
四条容易踩的开发约定:
|
|
197
|
+
|
|
198
|
+
- **线格式(remote)的 schema 只有一份**:`lib/remote-schemas.js`,host 的 `lib/remote.js`
|
|
199
|
+
与客户端 `src/client.jsx` 都从它取;两端各自只保留**信封**(`descriptor()` / `direct()`)。
|
|
200
|
+
改字段只改 schema 文件;改方法签名要同时改两端信封——自检会机械比对每个方法的**参数名与
|
|
201
|
+
typeSymbol 是否两端一致**,漏改一处就会被拦下(不用等调用期)。
|
|
202
|
+
- `lib/client.js` 是**入库的构建产物**(≈1 MB)。改客户端只改 `src/client.jsx`,
|
|
203
|
+
然后必须重跑 `npm run build` —— 自检里有一条「产物新鲜度」门禁,忘了重跑会拦下来。
|
|
204
|
+
- `package-lock.json` 管的是**本插件自己的 devDependencies**;用户装插件用的 `pnpm`
|
|
205
|
+
是宿主 `dsh plugin` 在 profile 目录里跑的,两者互不冲突,不要为了方便删掉 lock。
|
|
206
|
+
- `lib/squads.js` 这类数据层**不写 `console`**:诊断一律走注入的宿主 logger
|
|
207
|
+
(`ctx.logger`),桌面与 Web 里 stderr 用户看不见。编译团队配置是**异步**的
|
|
208
|
+
(`execFile`,不是 `execFileSync`)—— 同步跑会把整个 Host 事件循环卡住。
|
|
160
209
|
|
|
161
210
|
内置团队引擎(`lib/teams/`)是**手工维护的源码**,改引擎直接改这里;
|
|
162
211
|
名册(`data/experts/`)与中文侧车(`data/zh/`)就是发布源,两者都不再从任何上游同步。
|
|
212
|
+
|
|
213
|
+
### `dsh.client.inject` 为什么写着 `dsh-client-ui-primitives`
|
|
214
|
+
|
|
215
|
+
那是**承重的声明,不是笔误**。`@deepseek-ai/dsh-client-modules` 的到达顺序契约里,
|
|
216
|
+
`dsh.client.inject` 让被点名的包**先于本插件的 client module 到达**;而 `lib/client.js`
|
|
217
|
+
里确实有 `require("@deepseek-ai/dsh-client-ui-primitives")`(构建时只把 `react` 外部化)。
|
|
218
|
+
删掉它并不会让构建失败,却可能让本插件赶在 primitives 之前物化、require 当场抛错。
|
|
219
|
+
|
|
220
|
+
顺带说明两个容易误判的点:**①** 这个包没有 `exports["./client"]`,所以它不作为「client module」
|
|
221
|
+
参与 `dsh.client.inject` 的常规扫描——但名字仍必须留在这里;**②** 自检 `[20b]` 已经把
|
|
222
|
+
「bundle 里每个 `@deepseek-ai/*` 外部 require 都必须在名单里」固化成不变量,以后新增 require
|
|
223
|
+
忘了声明会当场红,不必再靠人肉比对。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: DeepSeek Harness Project Expert
|
|
3
|
-
description: Maintainer-level command of the DeepSeek Harness (dsh) monorepo — the all-plugin Cordis agent harness. Knows the package map, the profile/bundle/patch boot model, the three planes (host composition / agent preset / session), the capability seams, the per-language rules of every face (TypeScript on Node, React browser client, Python SDK, C Node-API addon, Cordis YAML, SQLite, shell), and the repository's own quality gates — so a change lands in the right plane on the first attempt.
|
|
3
|
+
description: Maintainer-level command of the DeepSeek Harness (dsh) monorepo — the all-plugin Cordis agent harness. Knows the package map, the profile/bundle/patch boot model, the three planes (host composition / agent preset / session), the capability seams, the per-language rules of every face (TypeScript on Node, React browser client, Python SDK, C Node-API addon, Cordis YAML, SQLite, shell), the dependency semantics that decide whether a plugin waits or hangs, the user-facing surfaces (Web UI, packaging/install), and the repository's own quality gates — so a change lands in the right plane on the first attempt.
|
|
4
4
|
color: indigo
|
|
5
5
|
emoji: 🧭
|
|
6
6
|
vibe: There is no privileged kernel to patch — mount a plugin beside it, and ground every claim in a file you actually opened.
|
|
@@ -55,6 +55,28 @@ Host TypeScript, client TSX/CSS Modules, Python, C, Cordis YAML, SQLite, and she
|
|
|
55
55
|
|
|
56
56
|
When prose and code disagree, the code is current and the prose is a defect worth reporting.
|
|
57
57
|
|
|
58
|
+
### The published docs are the same knowledge, written for plugin authors
|
|
59
|
+
|
|
60
|
+
`https://deepseek-harness.github.io/deepseek-harness/` is the **public face** of this repository — treat it as a fast, citable check on the boot model, dependency semantics, and hooks before you go read source:
|
|
61
|
+
|
|
62
|
+
| Page | Use it for |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| `guide/quickstart` | Web UI loop: start the server, add a workspace, configure a model — where a new user actually begins |
|
|
65
|
+
| `develop/basic/` | a minimal plugin, `scratch-plugin/cordis.yml` with an `- insert:` row, and **absolute** plugin paths |
|
|
66
|
+
| `develop/basic/tool` · `config` · `publish` | tool registration; validated `Config`; `dsh.bundle` vs `dsh.profile` + `dsh plugin add` |
|
|
67
|
+
| `develop/framework/` · `service` · `events` | lifecycle/effect, services and dependency semantics, the event system |
|
|
68
|
+
| `develop/practice/` · `llm-adapter` · `dynamic-cordis` | capability layering in three roles, model adapters, editing a running agent in memory |
|
|
69
|
+
| `develop/cordis-tutorial/01…07` | the Cordis ladder, ending at "into the harness" |
|
|
70
|
+
|
|
71
|
+
It is **less complete** than this checkout — it does not document `vitest` / `oxlint` / `run-gates.ts` / coverage policy, the `.agents/notes/` convention, or the generated subsystem pages. So: cite the site for concepts, cite the checkout for gates and contracts. When they disagree, the checkout wins — but a disagreement is worth reporting, because the site is generated from this repository.
|
|
72
|
+
|
|
73
|
+
**The dependency rule that decides "waits" vs "hangs"** (documented on `develop/framework/service`, and the trap that costs the most debugging time):
|
|
74
|
+
|
|
75
|
+
- `inject = ['x']` is a **required** dependency: *the framework guarantees that every declared service is ready when `apply` runs — the plugin waits, and does not execute, while one is missing.* That is a **silent PENDING**, with no log line of its own.
|
|
76
|
+
- Omitting `inject` and calling `ctx.get('x')` is the **optional** form — and it must be called **at the use site**, never once during `apply`. A one-shot probe inside `apply` runs at the earliest, most fragile moment; if the provider arrives later the probe has already returned `undefined` and nothing re-runs it.
|
|
77
|
+
- A missing **required** service leaves the plugin in PENDING forever (e.g. an HMR plugin without `cordis-plugin-timer`), and a missing optional one is simply absent. Neither is an error unless you make it one.
|
|
78
|
+
- If a required service **disappears at runtime**, dependents are disposed; when it returns they are re-loaded — which is also why "registration is an effect" is load-bearing rather than stylistic.
|
|
79
|
+
|
|
58
80
|
### Ship the change as a whole change
|
|
59
81
|
|
|
60
82
|
A non-trivial change ships in one PR with its Agent Note, its package README/JSDoc updates, its owning `docs/subsystems` page, and its no-key recorded-session scenario when the change is non-trivial and model/protocol/user visible. A behavior change without its note, contract page, or snapshot is not finished — it is a future incident with a green checkmark.
|
|
@@ -231,14 +253,18 @@ You are successful when:
|
|
|
231
253
|
## 🚀 Advanced Capabilities
|
|
232
254
|
|
|
233
255
|
### Boot model and composition
|
|
256
|
+
- **A profile is a named assembly in the Harness home** (`$DSH_HOME/profiles/<name>/`): it lists the bundles it stacks, holds the out-of-tree plugins installed into it, and keeps the user's own `cordis.patch.yml`. `web` / `headless` / `sdk` / `sdk-minimal` / `acp` ship as templates; `dsh-base` is the shared first layer for `web` / `headless` / `sdk` / `acp`.
|
|
257
|
+
- Both halves declare themselves under the `dsh` field of their `package.json`: `dsh.profile` lists a profile's bundles; `dsh.bundle` points at a bundle's patch file — "what does this package contribute?" A bundle is an npm package that carries a configuration layer, so anything it inserts stays patchable by the layers above it.
|
|
234
258
|
- Profile / bundle / patch layering, with `dsh --profile web --dump-config` used to read the tree actually running rather than the tree you assume
|
|
235
259
|
- Row-id-targeted patches, live reload on the `web` profile versus start-time-only application on `headless` / `sdk` / `sdk-minimal` / `acp`
|
|
236
260
|
- Preset metadata degradation designed so a broken display string can never stop a preset from starting
|
|
237
261
|
|
|
238
262
|
### Session, events, and reconstruction
|
|
263
|
+
- **The session log is the source of the context the model sees** — `deriveMessages()` projects model history from it. Every `assistant/message` embeds the exact compact stream that produced its assembled content; `assistant/attempt` preserves failures, retries, cancellations, and stream problems that reached settlement.
|
|
264
|
+
- **"Model-visible ⟺ logged"** is the repository's own phrasing, enforced by a runtime invariant: everything reaching a model request must be reconstructible from the log, so a new model-visible input means a new session event — extend `SessionEventMap` and render from the log.
|
|
239
265
|
- Choosing between session events, agent events, and capability events as the *first* decision of a change
|
|
240
|
-
- Waterfall listeners that must call `next()` to delegate,
|
|
241
|
-
-
|
|
266
|
+
- Waterfall listeners that must call `next()` to delegate — one that forgets silently swallows every downstream default (standing rule), while returning without `next()` is a deliberate short-circuit
|
|
267
|
+
- The serial `agent/turn-stopping` listener that must **not** call `next()`; `agent.inject()` is the documented way to add model-visible context to the next admitted request
|
|
242
268
|
|
|
243
269
|
### Client and desktop surface
|
|
244
270
|
- `ui-theme` token ownership and the `--dsw-alias-*` semantic layer, with elevation and border rules that the theme spec enforces
|
package/lib/bootstrap.js
CHANGED
|
@@ -1,82 +1,351 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
+
* 启动期同步:把包内自带的名册快照落到可写数据目录,让插件对任何机器都自包含。
|
|
3
4
|
*
|
|
4
5
|
* 背景:T专家 的运行时数据(专家名册、中文侧车、小队定义、引擎配置)体积不大但必须先存在,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
6
|
+
* 插件才可用。把它们随包发布后,安装即自带一份快照;这里负责在**每次启动**把包内快照与
|
|
7
|
+
* 数据目录对齐,此后由用户自己的数据目录作主。
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* ⚠️ 为什么不再是「首次播种、之后一律不动」(2026-09-13 修):
|
|
10
|
+
* 旧实现按**整项**判断——目录非空就跳过、文件已存在就跳过——于是升级到新版本时,
|
|
11
|
+
* 包内**新增**的专家/小队/译文、以及包内**修订**过的只读条目,对老安装**永不送达**,
|
|
12
|
+
* 而且一句日志都没有(旧代码只在 `copied.length > 0` 时打印)。这是本项目最典型的一类
|
|
13
|
+
* 恒存缺陷:「同一事实多份副本 + 只在缺失时播种」= 静默过期。
|
|
14
|
+
*
|
|
15
|
+
* 现在的契约(**按条目分类**,不再整项跳过):
|
|
16
|
+
* 1. **缺失就补**:任何包内条目在数据目录里不存在就复制过去 —— 首次安装、半成品自愈、
|
|
17
|
+
* 升级新增,走的是同一条路径。
|
|
18
|
+
* 2. **只读条目一律对齐**(见 {@link PACKAGE_OWNED_DIRS}/{@link PACKAGE_OWNED_FILES}):
|
|
19
|
+
* 名册 `experts/`、`source.json`、小队编译器 `team-profiles.py`、`zh/` 的覆盖表与分区表
|
|
20
|
+
* 都是**随包发布的只读内容**(`lib/catalog.js` 里也写明 root 只读,自建专家走 customRoot),
|
|
21
|
+
* 安装副本与包内不同就覆盖,并进 `updated` 报告。
|
|
22
|
+
* 3. **键值型用户文件只补键、不改键**({@link MERGE_FILES}):`zh/names.json`、
|
|
23
|
+
* `zh/descriptions.json`、`teams.json` 的缺失键从包内补齐 —— 上游新增的译文与新小队
|
|
24
|
+
* 因此能到达老安装,而用户改过的键一个都不动。
|
|
25
|
+
* 4. **用户内容绝不覆盖**:`zh/<分区>/<slug>.md` 正文等与包内不同时**保留盘上那份**,
|
|
26
|
+
* 进 `kept` 报告(而不是像旧实现那样静默跳过)。
|
|
27
|
+
* 5. **派生产物只补缺**({@link DERIVED_FILES}):`t-team.config.json` /
|
|
28
|
+
* `teams.resolved.json` 由引擎按用户的 `teams.json` 编译,盘上那份天生与包内不同,
|
|
29
|
+
* 因此只在缺失时播种,也不计入漂移。
|
|
30
|
+
* 6. **只报告、不删除**:包内已移除的条目进 `stale`,盘上文件保留。
|
|
31
|
+
* 7. **失败不致命**:只读文件系统、权限不足等导致同步失败时只报告,插件照常加载,
|
|
32
|
+
* 后续会以「读不到名册」的正常错误路径报出来。
|
|
33
|
+
*
|
|
34
|
+
* 「送不到」也必须出声:每次同步都会把结果(新增/对齐/合并/保留/残留 + 快照版本)写进
|
|
35
|
+
* 数据目录根的 {@link MANIFEST_NAME} 同步记录,调用方据此在**状态变化时**告警一次,
|
|
36
|
+
* 而不是每次启动都刷屏。
|
|
15
37
|
*/
|
|
16
|
-
import { cpSync, existsSync, mkdirSync, readdirSync,
|
|
38
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
|
|
17
39
|
import { dirname, join, resolve } from "node:path";
|
|
18
40
|
import { fileURLToPath } from "node:url";
|
|
19
41
|
|
|
20
42
|
/** 包内快照目录(`<包根>/data`)。 */
|
|
21
43
|
export const SNAPSHOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "data");
|
|
22
44
|
|
|
45
|
+
/** 同步记录文件名(写在数据目录根,不属于名册内容)。 */
|
|
46
|
+
export const MANIFEST_NAME = ".t-team-snapshot.json";
|
|
47
|
+
|
|
23
48
|
/** 快照里的目录项:`experts` → root,`zh` → zhRoot。 */
|
|
24
49
|
const SNAPSHOT_DIRS = ["experts", "zh"];
|
|
25
50
|
/** 快照里的文件项:全部落在数据目录(= dirname(root))下。 */
|
|
26
51
|
const SNAPSHOT_FILES = ["teams.json", "teams.resolved.json", "t-team.config.json", "team-profiles.py", "source.json"];
|
|
27
52
|
|
|
28
|
-
/**
|
|
29
|
-
|
|
53
|
+
/** 只读目录:随包发布、安装副本一律对齐(自建专家在 customRoot,不在这些目录里)。 */
|
|
54
|
+
const PACKAGE_OWNED_DIRS = ["experts"];
|
|
55
|
+
/** 只读文件:同上。`zh/` 下的两个表是生成物,不是用户译文。 */
|
|
56
|
+
const PACKAGE_OWNED_FILES = ["source.json", "team-profiles.py", "zh/COVERAGE.json", "zh/divisions.json"];
|
|
57
|
+
/** 键值型用户文件:只补缺失键,绝不改已有键(`teams.json` 的小队在 `profiles` 下)。 */
|
|
58
|
+
const MERGE_FILES = ["zh/names.json", "zh/descriptions.json", "teams.json"];
|
|
59
|
+
/** 派生产物:只在缺失时播种;盘上那份由引擎/编译器维护。 */
|
|
60
|
+
const DERIVED_FILES = ["t-team.config.json", "teams.resolved.json"];
|
|
61
|
+
/** 遍历时跳过的垃圾与同步记录本身。 */
|
|
62
|
+
const JUNK = new Set([".DS_Store", MANIFEST_NAME]);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 同步报告。
|
|
66
|
+
* @typedef {object} SeedReport
|
|
67
|
+
* @property {boolean} ok - 同步是否正常完成(失败不致命,调用方只报告)。
|
|
68
|
+
* @property {string} error - 失败原因(`ok` 为 false 时非空)。
|
|
69
|
+
* @property {string} source - 包内快照目录。
|
|
70
|
+
* @property {string} version - 包内快照所属的插件版本(读包内 package.json)。
|
|
71
|
+
* @property {string} previousVersion - 上次同步记录的版本(首次为 "")。
|
|
72
|
+
* @property {number} scanned - 本次检查的包内条目数。
|
|
73
|
+
* @property {string[]} copied - 盘上缺失、本次落地的条目。
|
|
74
|
+
* @property {string[]} updated - 只读条目与包内不同、本次对齐的条目。
|
|
75
|
+
* @property {{ rel: string, keys: string[] }[]} merged - 键值型文件本次补齐的键。
|
|
76
|
+
* @property {string[]} kept - 用户内容且与包内不同、**保留未覆盖**的条目。
|
|
77
|
+
* @property {string[]} stale - 盘上有、包内已无的条目(只报告不删除)。
|
|
78
|
+
* @property {number} unchanged - 与包内一致的条目数(含派生产物)。
|
|
79
|
+
* @property {boolean} driftChanged - 与上次记录相比,版本或漂移情况是否变了(决定要不要出声)。
|
|
80
|
+
* @property {string} manifestPath - 同步记录路径。
|
|
81
|
+
* @property {boolean} manifestWritten - 本次是否成功写下同步记录。
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 递归列出一个目录下的全部文件,返回相对路径(`/` 分隔,跨平台一致)。
|
|
86
|
+
* @param dir - 目录。
|
|
87
|
+
* @param prefix - 递归用的前缀。
|
|
88
|
+
* @returns 相对路径数组。
|
|
89
|
+
*/
|
|
90
|
+
function listFiles(dir, prefix = "") {
|
|
91
|
+
/** @type {string[]} */
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
94
|
+
if (JUNK.has(entry.name)) continue;
|
|
95
|
+
const rel = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
96
|
+
const abs = join(dir, entry.name);
|
|
97
|
+
if (entry.isDirectory()) out.push(...listFiles(abs, rel));
|
|
98
|
+
else if (entry.isFile()) out.push(rel);
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 包内条目 → 盘上目标路径。映射规则与旧实现一致:`experts/*` → root,`zh/*` → zhRoot,
|
|
105
|
+
* 其余单文件 → 数据目录根。
|
|
106
|
+
* @param snapshotDir - 包内快照目录。
|
|
107
|
+
* @param root - 名册目录。
|
|
108
|
+
* @param zhRoot - 中文侧车目录。
|
|
109
|
+
* @returns 条目数组。
|
|
110
|
+
*/
|
|
111
|
+
function collectEntries(snapshotDir, root, zhRoot) {
|
|
112
|
+
/** @type {{ rel: string, from: string, to: string }[]} */
|
|
113
|
+
const entries = [];
|
|
114
|
+
/** @type {Record<string, string>} */
|
|
115
|
+
const dirTargets = { experts: root, zh: zhRoot };
|
|
116
|
+
for (const name of SNAPSHOT_DIRS) {
|
|
117
|
+
const from = join(snapshotDir, name);
|
|
118
|
+
if (!existsSync(from)) continue;
|
|
119
|
+
const to = dirTargets[name];
|
|
120
|
+
for (const rel of listFiles(from)) entries.push({ rel: `${name}/${rel}`, from: join(from, rel), to: join(to, rel) });
|
|
121
|
+
}
|
|
122
|
+
const home = dirname(root);
|
|
123
|
+
for (const name of SNAPSHOT_FILES) {
|
|
124
|
+
const from = join(snapshotDir, name);
|
|
125
|
+
if (!existsSync(from)) continue;
|
|
126
|
+
entries.push({ rel: name, from, to: join(home, name) });
|
|
127
|
+
}
|
|
128
|
+
return entries;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 条目分类决定「不同时怎么办」。
|
|
133
|
+
* @param rel - 相对快照目录的路径。
|
|
134
|
+
* @returns 分类。
|
|
135
|
+
*/
|
|
136
|
+
function classify(rel) {
|
|
137
|
+
if (PACKAGE_OWNED_FILES.includes(rel)) return "package-owned";
|
|
138
|
+
if (MERGE_FILES.includes(rel)) return "merge";
|
|
139
|
+
if (DERIVED_FILES.includes(rel)) return "derived";
|
|
140
|
+
for (const dir of PACKAGE_OWNED_DIRS) if (rel.startsWith(`${dir}/`)) return "package-owned";
|
|
141
|
+
return "user";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 是不是普通 JSON 对象(不是数组、不是 null)。 */
|
|
145
|
+
function isPlainObject(value) {
|
|
146
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 把包内文件里**盘上还没有的键**补进去(已有键一个都不动)。
|
|
151
|
+
*
|
|
152
|
+
* 两处特例都是真实数据形状:`zh/names.json` / `zh/descriptions.json` 是扁平的
|
|
153
|
+
* `slug → 文案`;`teams.json` 的小队表在 `profiles` 这一层。除 `profiles` 外不做嵌套合并 ——
|
|
154
|
+
* 合并范围越宽,越容易覆盖用户内容。
|
|
155
|
+
* @param targetText - 盘上那份的文本。
|
|
156
|
+
* @param sourceText - 包内那份的文本。
|
|
157
|
+
* @returns 合并后的文本(无键可补时 `text` 为 undefined)与补齐的键名。
|
|
158
|
+
*/
|
|
159
|
+
function mergeMissingKeys(targetText, sourceText) {
|
|
160
|
+
/** @type {any} */
|
|
161
|
+
let target;
|
|
162
|
+
/** @type {any} */
|
|
163
|
+
let source;
|
|
30
164
|
try {
|
|
31
|
-
|
|
165
|
+
target = JSON.parse(targetText);
|
|
166
|
+
source = JSON.parse(sourceText);
|
|
32
167
|
} catch {
|
|
33
|
-
return
|
|
168
|
+
return { text: undefined, keys: /** @type {string[]} */ ([]) };
|
|
169
|
+
}
|
|
170
|
+
if (!isPlainObject(target) || !isPlainObject(source)) return { text: undefined, keys: /** @type {string[]} */ ([]) };
|
|
171
|
+
/** @type {string[]} */
|
|
172
|
+
const keys = [];
|
|
173
|
+
for (const [key, value] of Object.entries(source)) {
|
|
174
|
+
if (Object.prototype.hasOwnProperty.call(target, key)) continue;
|
|
175
|
+
target[key] = value;
|
|
176
|
+
keys.push(key);
|
|
34
177
|
}
|
|
35
|
-
|
|
178
|
+
for (const nested of ["profiles"]) {
|
|
179
|
+
const inner = source[nested];
|
|
180
|
+
const existing = target[nested];
|
|
181
|
+
if (!isPlainObject(inner) || !isPlainObject(existing)) continue;
|
|
182
|
+
for (const [key, value] of Object.entries(inner)) {
|
|
183
|
+
if (Object.prototype.hasOwnProperty.call(existing, key)) continue;
|
|
184
|
+
existing[key] = value;
|
|
185
|
+
keys.push(`${nested}.${key}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (keys.length === 0) return { text: undefined, keys };
|
|
189
|
+
return { text: `${JSON.stringify(target, null, 2)}\n`, keys };
|
|
36
190
|
}
|
|
37
191
|
|
|
38
192
|
/**
|
|
39
|
-
*
|
|
40
|
-
* @param
|
|
41
|
-
* @param
|
|
42
|
-
* @
|
|
193
|
+
* 盘上有、包内已无的条目(上游删过的专家/译文)。只报告,绝不删除用户盘上的文件。
|
|
194
|
+
* @param snapshotDir - 包内快照目录。
|
|
195
|
+
* @param name - 目录项名(experts / zh)。
|
|
196
|
+
* @param targetDir - 对应的盘上目录。
|
|
197
|
+
* @returns 条目相对路径数组。
|
|
43
198
|
*/
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
if (!existsSync(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
199
|
+
function collectStale(snapshotDir, name, targetDir) {
|
|
200
|
+
const from = join(snapshotDir, name);
|
|
201
|
+
if (!existsSync(from) || !existsSync(targetDir)) return [];
|
|
202
|
+
const packaged = new Set(listFiles(from));
|
|
203
|
+
return listFiles(targetDir).filter((rel) => !packaged.has(rel)).map((rel) => `${name}/${rel}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 读上次的同步记录;缺失/损坏都当没有(下次会重新写)。
|
|
208
|
+
* @param path - 同步记录路径。
|
|
209
|
+
* @returns 记录对象或 undefined。
|
|
210
|
+
*/
|
|
211
|
+
function readManifest(path) {
|
|
212
|
+
try {
|
|
213
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
214
|
+
return isPlainObject(raw) ? raw : undefined;
|
|
215
|
+
} catch {
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 读包内快照所属的插件版本(`<包根>/package.json`)。自检用合成快照时这份可能是假的,
|
|
222
|
+
* 所以只用于「升级可感知」的日志,读不到就是空串。
|
|
223
|
+
* @param snapshotDir - 包内快照目录。
|
|
224
|
+
* @returns 版本号或 ""。
|
|
225
|
+
*/
|
|
226
|
+
function readPackageVersion(snapshotDir) {
|
|
227
|
+
try {
|
|
228
|
+
const pkg = JSON.parse(readFileSync(join(snapshotDir, "..", "package.json"), "utf8"));
|
|
229
|
+
return typeof pkg?.version === "string" ? pkg.version : "";
|
|
230
|
+
} catch {
|
|
231
|
+
return "";
|
|
50
232
|
}
|
|
233
|
+
}
|
|
51
234
|
|
|
235
|
+
/**
|
|
236
|
+
* 原子写(先写临时文件再 rename):同步记录与合并后的 JSON 都不能留半截。
|
|
237
|
+
* @param path - 目标路径。
|
|
238
|
+
* @param text - 内容。
|
|
239
|
+
*/
|
|
240
|
+
function writeFileAtomic(path, text) {
|
|
241
|
+
const tmp = `${path}.tmp`;
|
|
242
|
+
writeFileSync(tmp, text);
|
|
243
|
+
renameSync(tmp, path);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** 两个文件内容是否逐字节相同。 */
|
|
247
|
+
function sameContent(from, to) {
|
|
248
|
+
try {
|
|
249
|
+
return readFileSync(from).equals(readFileSync(to));
|
|
250
|
+
} catch {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 把包内快照与数据目录对齐。
|
|
257
|
+
* @param options - `root` 名册目录、`zhRoot` 中文侧车目录;`snapshotDir` 仅自检用于合成快照。
|
|
258
|
+
* @returns 同步报告。
|
|
259
|
+
*/
|
|
260
|
+
export function seedData({ root, zhRoot, snapshotDir = SNAPSHOT_DIR }) {
|
|
52
261
|
const home = dirname(root);
|
|
53
|
-
const
|
|
262
|
+
const manifestPath = join(home, MANIFEST_NAME);
|
|
263
|
+
const previous = readManifest(manifestPath);
|
|
264
|
+
const version = readPackageVersion(snapshotDir);
|
|
265
|
+
/** @type {SeedReport} */
|
|
266
|
+
const report = {
|
|
267
|
+
ok: true,
|
|
268
|
+
error: "",
|
|
269
|
+
source: snapshotDir,
|
|
270
|
+
version,
|
|
271
|
+
previousVersion: typeof previous?.version === "string" ? previous.version : "",
|
|
272
|
+
scanned: 0,
|
|
273
|
+
copied: [],
|
|
274
|
+
updated: [],
|
|
275
|
+
merged: [],
|
|
276
|
+
kept: [],
|
|
277
|
+
stale: [],
|
|
278
|
+
unchanged: 0,
|
|
279
|
+
driftChanged: false,
|
|
280
|
+
manifestPath,
|
|
281
|
+
manifestWritten: false,
|
|
282
|
+
};
|
|
283
|
+
if (!existsSync(snapshotDir)) {
|
|
284
|
+
report.ok = false;
|
|
285
|
+
report.error = `包内快照不存在:${snapshotDir}`;
|
|
286
|
+
return report;
|
|
287
|
+
}
|
|
54
288
|
|
|
55
289
|
try {
|
|
56
290
|
mkdirSync(home, { recursive: true });
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
291
|
+
const entries = collectEntries(snapshotDir, root, zhRoot);
|
|
292
|
+
report.scanned = entries.length;
|
|
293
|
+
|
|
294
|
+
for (const { rel, from, to } of entries) {
|
|
295
|
+
const kind = classify(rel);
|
|
296
|
+
if (!existsSync(to)) {
|
|
297
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
298
|
+
cpSync(from, to);
|
|
299
|
+
report.copied.push(rel);
|
|
63
300
|
continue;
|
|
64
301
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (!existsSync(from)) continue;
|
|
73
|
-
if (existsSync(to)) {
|
|
74
|
-
report.skipped.push(name);
|
|
302
|
+
// 派生产物由引擎按用户的小队定义编译,与包内那份天生不同 —— 不是漂移,也不碰。
|
|
303
|
+
if (kind === "derived") {
|
|
304
|
+
report.unchanged += 1;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (sameContent(from, to)) {
|
|
308
|
+
report.unchanged += 1;
|
|
75
309
|
continue;
|
|
76
310
|
}
|
|
77
|
-
|
|
78
|
-
|
|
311
|
+
if (kind === "package-owned") {
|
|
312
|
+
cpSync(from, to);
|
|
313
|
+
report.updated.push(rel);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (kind === "merge") {
|
|
317
|
+
const { text, keys } = mergeMissingKeys(readFileSync(to, "utf8"), readFileSync(from, "utf8"));
|
|
318
|
+
if (text === undefined) report.unchanged += 1;
|
|
319
|
+
else {
|
|
320
|
+
writeFileAtomic(to, text);
|
|
321
|
+
report.merged.push({ rel, keys });
|
|
322
|
+
}
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
// 用户内容:保留盘上那份,进报告让调用方出声。
|
|
326
|
+
report.kept.push(rel);
|
|
79
327
|
}
|
|
328
|
+
|
|
329
|
+
for (const name of SNAPSHOT_DIRS) {
|
|
330
|
+
report.stale.push(...collectStale(snapshotDir, name, name === "experts" ? root : zhRoot));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
report.driftChanged = previous === undefined
|
|
334
|
+
|| previous.version !== version
|
|
335
|
+
|| previous.kept !== report.kept.length
|
|
336
|
+
|| previous.stale !== report.stale.length;
|
|
337
|
+
|
|
338
|
+
writeFileAtomic(manifestPath, `${JSON.stringify({
|
|
339
|
+
version,
|
|
340
|
+
at: new Date().toISOString(),
|
|
341
|
+
scanned: report.scanned,
|
|
342
|
+
copied: report.copied.length,
|
|
343
|
+
updated: report.updated.length,
|
|
344
|
+
merged: report.merged.reduce((sum, item) => sum + item.keys.length, 0),
|
|
345
|
+
kept: report.kept.length,
|
|
346
|
+
stale: report.stale.length,
|
|
347
|
+
}, null, 2)}\n`);
|
|
348
|
+
report.manifestWritten = true;
|
|
80
349
|
} catch (error) {
|
|
81
350
|
report.ok = false;
|
|
82
351
|
report.error = error instanceof Error ? error.message : String(error);
|
package/lib/catalog.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
/**
|
|
2
3
|
* T专家 花名册:扫描固定专家目录,解析 frontmatter,按 slug 建立索引。
|
|
3
4
|
*
|
|
@@ -9,6 +10,24 @@ import { createHash } from "node:crypto";
|
|
|
9
10
|
import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
10
11
|
import { dirname, join, relative } from "node:path";
|
|
11
12
|
|
|
13
|
+
/**
|
|
14
|
+
* 名册本体:`Map<slug, Expert>`,另外挂上扫描诊断与分类表。
|
|
15
|
+
*
|
|
16
|
+
* 之所以是 Map 加属性而不是 `{bySlug, ...}`:调用方(`lib/index.js`、测试)大量直接
|
|
17
|
+
* `catalog.get(slug)` / `catalog.size` / 迭代,保持 Map 形态的改动面最小。
|
|
18
|
+
* @typedef {Map<string, any> & {
|
|
19
|
+
* divisions: string[],
|
|
20
|
+
* rosterDivisions: string[],
|
|
21
|
+
* customDivisions: string[],
|
|
22
|
+
* customRoot: string | undefined,
|
|
23
|
+
* customLabels: Record<string, string>,
|
|
24
|
+
* sidecarPresent: boolean,
|
|
25
|
+
* skippedFiles: number,
|
|
26
|
+
* unreadableDivisions: string[],
|
|
27
|
+
* labels: Record<string, string>,
|
|
28
|
+
* }} CatalogMap
|
|
29
|
+
*/
|
|
30
|
+
|
|
12
31
|
/** 内置的分区目录名(= divisions.json 的键)。 */
|
|
13
32
|
export const DEFAULT_DIVISIONS = [
|
|
14
33
|
"academic",
|
|
@@ -209,8 +228,9 @@ export async function loadCatalog(root, divisions, options = {}) {
|
|
|
209
228
|
// 侧车缺失是**自洽的错配**(它由 seedData 播种、路径来自 Config.zhRoot),必须可观察:
|
|
210
229
|
// 过去这里静默变成「全英文名册」,面板与工具都不报错(D-5)。
|
|
211
230
|
// 侧车「可用」= 目录存在**且**至少有一个内容锚点(names/descriptions/divisions/manual/manual-bodies
|
|
212
|
-
//
|
|
213
|
-
//
|
|
231
|
+
// 或任一分区目录)。只看目录存在是不够的:一个锚点都没有时中文名/简介会全空却什么都不说 ——
|
|
232
|
+
// 正是 D-5 要消灭的静默降级。(2026-09-13 起 seedData 按**条目**同步,空目录会被逐文件补齐,
|
|
233
|
+
// 所以这里判的是「补齐之后仍无可用侧车」,而不是「播种跳过了」。)
|
|
214
234
|
const sidecarDirExists = zhRoot === undefined ? true : await isDirectory(zhRoot);
|
|
215
235
|
const sidecarAnchor = zhRoot === undefined || !sidecarDirExists
|
|
216
236
|
? undefined
|
|
@@ -248,7 +268,8 @@ export async function loadCatalog(root, divisions, options = {}) {
|
|
|
248
268
|
.filter(isValidDivision);
|
|
249
269
|
/** 读不动的分区(供 host 侧 snapshot 暴露,诊断用)。 */
|
|
250
270
|
const unreadableDivisions = (discovery?.unreadable ?? []).map((item) => item.division);
|
|
251
|
-
|
|
271
|
+
/** @type {CatalogMap} 名册本体 + 扫描诊断(见 CatalogMap 的各字段说明)。 */
|
|
272
|
+
const catalog = /** @type {CatalogMap} */ (new Map());
|
|
252
273
|
|
|
253
274
|
/** 解析一个 persona 文件并放进名册;自定义根的文件额外带 custom 标记与写回路径。 */
|
|
254
275
|
async function ingest(fromRoot, division, filePath, fileName, isCustom) {
|