dsh-plugin-t-expert 0.2.10 → 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 CHANGED
@@ -49,7 +49,7 @@ dsh plugin --profile web add dsh-plugin-t-expert
49
49
 
50
50
  ## 二、数据目录
51
51
 
52
- 插件对任何机器都**自包含**:包内带一份名册快照,首次启动播种到 `~/.t-team/`。
52
+ 插件对任何机器都**自包含**:包内带一份名册快照,启动时与 `~/.t-team/` 对齐(不是只在首次播种)。
53
53
 
54
54
  ```
55
55
  ~/.t-team/
@@ -60,11 +60,19 @@ dsh plugin --profile web add dsh-plugin-t-expert
60
60
  ├── t-team.config.json 小队编译产物(引擎配置,人格已内联)
61
61
  ├── teams.resolved.json 小队编译产物(/t 列表用)
62
62
  ├── team-profiles.py 小队编译器的播种副本(实际跑的是包内那份)
63
- └── source.json 名册清单(专家数 / 分类 / 更新时间)
63
+ ├── source.json 名册清单(专家数 / 分类 / 更新时间)
64
+ └── .t-team-snapshot.json 上次对齐的记录(版本 / 漂移计数,用于「升级与送不到」出声)
64
65
  ```
65
66
 
66
- - **已有文件不会被覆盖**:升级插件不会动你改过的小队定义或补译的中文正文。
67
- 唯一例外是小队编译器——它是代码、随插件版本走,始终用包内那份(数据目录里那份只作兜底)。
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`——它是代码,随插件版本走。
68
76
  - 想换目录位置,用配置项 `root` / `zhRoot` / `customRoot` 覆盖即可,不必改代码。
69
77
 
70
78
  ---
@@ -175,7 +183,7 @@ Host/Origin 校验与浏览器认证,静态资源另有白名单,非白名
175
183
  ```bash
176
184
  npm install # 装开发期依赖(.npmrc 里开了 legacy-peer-deps,原因见该文件)
177
185
  npm run typecheck # 类型检查(tsconfig.json 只覆盖自研 Host 文件,不碰并入的引擎)
178
- npm run verify # 自检套件(现 468 项断言):插件契约、工具、remote、客户端产物、播种与快照,
186
+ npm run verify # 自检套件(现 511 项断言):插件契约、工具、remote、客户端产物、播种与快照,
179
187
  # 外加「发布包自洽」(真打一份 tgz、解开、再用插件解析器读一遍)
180
188
  npm run build # 构建客户端产物(lib/client.js)
181
189
  npm run sync-data # 把运行时数据同步进包内 data/
@@ -201,3 +209,15 @@ npm run sync-data # 把运行时数据同步进包内 data/
201
209
 
202
210
  内置团队引擎(`lib/teams/`)是**手工维护的源码**,改引擎直接改这里;
203
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, and the serial `agent/turn-stopping` listener that must not
241
- - Keeping `Model-visible logged` true across new prompt sections, new injections, and new tool schemas
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,83 +1,351 @@
1
1
  // @ts-check
2
2
  /**
3
- * 首次启动播种:把包内自带的名册快照落到可写数据目录,让插件对任何机器都自包含。
3
+ * 启动期同步:把包内自带的名册快照落到可写数据目录,让插件对任何机器都自包含。
4
4
  *
5
5
  * 背景:T专家 的运行时数据(专家名册、中文侧车、小队定义、引擎配置)体积不大但必须先存在,
6
- * 插件才可用。把它们随包发布后,安装即自带一份快照;这里负责在**首次启动**时把它复制到
7
- * 数据目录(默认 `~/.t-team`),此后由用户自己的数据目录作主。
6
+ * 插件才可用。把它们随包发布后,安装即自带一份快照;这里负责在**每次启动**把包内快照与
7
+ * 数据目录对齐,此后由用户自己的数据目录作主。
8
8
  *
9
- * 三条规则:
10
- * 1. **非破坏性**:目标已存在且有内容就完全不动它——升级插件不会覆盖用户改过的小队定义,
11
- * 也不会把用户补译的中文正文明回去。
12
- * 2. **按项播种**:目录(experts / zh)与文件(teams.json 等)各自独立判断,缺哪个补哪个,
13
- * 所以「目录在但缺配置文件」的半成品状态也能自愈。
14
- * 3. **失败不致命**:只读文件系统、权限不足等导致播种失败时只告警,插件照常加载,
15
- * 后续会以「读不到名册」的正常错误路径报出来,不额外抛出难懂的异常。
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
+ * 而不是每次启动都刷屏。
16
37
  */
17
- import { cpSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
38
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
18
39
  import { dirname, join, resolve } from "node:path";
19
40
  import { fileURLToPath } from "node:url";
20
41
 
21
42
  /** 包内快照目录(`<包根>/data`)。 */
22
43
  export const SNAPSHOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "data");
23
44
 
45
+ /** 同步记录文件名(写在数据目录根,不属于名册内容)。 */
46
+ export const MANIFEST_NAME = ".t-team-snapshot.json";
47
+
24
48
  /** 快照里的目录项:`experts` → root,`zh` → zhRoot。 */
25
49
  const SNAPSHOT_DIRS = ["experts", "zh"];
26
50
  /** 快照里的文件项:全部落在数据目录(= dirname(root))下。 */
27
51
  const SNAPSHOT_FILES = ["teams.json", "teams.resolved.json", "t-team.config.json", "team-profiles.py", "source.json"];
28
52
 
29
- /** 目录不存在,或存在但没有任何有效条目(忽略 .DS_Store 这类系统垃圾)。 */
30
- function emptyOrMissing(path) {
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;
31
164
  try {
32
- if (!statSync(path).isDirectory()) return true;
165
+ target = JSON.parse(targetText);
166
+ source = JSON.parse(sourceText);
33
167
  } catch {
34
- return true;
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);
35
177
  }
36
- return readdirSync(path).filter((name) => name !== ".DS_Store").length === 0;
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 };
37
190
  }
38
191
 
39
192
  /**
40
- * 把包内快照播种到数据目录。
41
- * @param root - 名册目录(默认 `~/.t-team/experts`)。
42
- * @param zhRoot - 中文侧车目录(默认 `~/.t-team/zh`)。
43
- * @returns 已复制与已跳过的项,供日志与自检使用。
193
+ * 盘上有、包内已无的条目(上游删过的专家/译文)。只报告,绝不删除用户盘上的文件。
194
+ * @param snapshotDir - 包内快照目录。
195
+ * @param name - 目录项名(experts / zh)。
196
+ * @param targetDir - 对应的盘上目录。
197
+ * @returns 条目相对路径数组。
44
198
  */
45
- export function seedData({ root, zhRoot }) {
46
- const report = { copied: [], skipped: [], source: SNAPSHOT_DIR, ok: true, error: "" };
47
- if (!existsSync(SNAPSHOT_DIR)) {
48
- report.ok = false;
49
- report.error = `包内快照不存在:${SNAPSHOT_DIR}`;
50
- return report;
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 "";
51
232
  }
233
+ }
52
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 }) {
53
261
  const home = dirname(root);
54
- const targets = { experts: root, zh: zhRoot };
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
+ }
55
288
 
56
289
  try {
57
290
  mkdirSync(home, { recursive: true });
58
- for (const name of SNAPSHOT_DIRS) {
59
- const from = join(SNAPSHOT_DIR, name);
60
- const to = targets[name];
61
- if (!existsSync(from)) continue;
62
- if (!emptyOrMissing(to)) {
63
- report.skipped.push(name);
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);
64
300
  continue;
65
301
  }
66
- mkdirSync(dirname(to), { recursive: true });
67
- cpSync(from, to, { recursive: true });
68
- report.copied.push(name);
69
- }
70
- for (const name of SNAPSHOT_FILES) {
71
- const from = join(SNAPSHOT_DIR, name);
72
- const to = join(home, name);
73
- if (!existsSync(from)) continue;
74
- if (existsSync(to)) {
75
- 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;
76
309
  continue;
77
310
  }
78
- cpSync(from, to);
79
- report.copied.push(name);
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);
80
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;
81
349
  } catch (error) {
82
350
  report.ok = false;
83
351
  report.error = error instanceof Error ? error.message : String(error);
package/lib/catalog.js CHANGED
@@ -228,8 +228,9 @@ export async function loadCatalog(root, divisions, options = {}) {
228
228
  // 侧车缺失是**自洽的错配**(它由 seedData 播种、路径来自 Config.zhRoot),必须可观察:
229
229
  // 过去这里静默变成「全英文名册」,面板与工具都不报错(D-5)。
230
230
  // 侧车「可用」= 目录存在**且**至少有一个内容锚点(names/descriptions/divisions/manual/manual-bodies
231
- // 或任一分区目录)。只看目录存在是不够的:存在但空(或只剩 .DS_Store)时 seedData 会跳过播种,
232
- // 于是中文名/简介全空却什么都不说 —— 正是 D-5 要消灭的静默降级。
231
+ // 或任一分区目录)。只看目录存在是不够的:一个锚点都没有时中文名/简介会全空却什么都不说 ——
232
+ // 正是 D-5 要消灭的静默降级。(2026-09-13 起 seedData 按**条目**同步,空目录会被逐文件补齐,
233
+ // 所以这里判的是「补齐之后仍无可用侧车」,而不是「播种跳过了」。)
233
234
  const sidecarDirExists = zhRoot === undefined ? true : await isDirectory(zhRoot);
234
235
  const sidecarAnchor = zhRoot === undefined || !sidecarDirExists
235
236
  ? undefined
package/lib/i18n.js CHANGED
@@ -107,10 +107,23 @@ export function t(locale, key, params = {}) {
107
107
  return text;
108
108
  }
109
109
 
110
- /** 从宿主 locale 设置读语言,缺失或异常回退 zh。 */
110
+ /**
111
+ * 从宿主 locale 设置读语言,缺失或异常回退 zh。
112
+ *
113
+ * ⚠️ `settings` 是**可选服务**,必须用 `ctx.get("settings")` 懒查,不要写成 `ctx.settings`
114
+ * 属性访问:它不在本插件的静态 `inject` 里,而宿主**没提供**这个服务时,属性访问会被
115
+ * cordis 抛 `cannot get property "settings" without inject`,只剩 try/catch 兜底、
116
+ * 静默退中文(2026-09-13 用真 cordis 4.0.2 实测:宿主**有**这个服务时属性访问读得到,
117
+ * 所以这类 bug 只在"服务不全"的宿主上现形,正常机器上不会露头)。
118
+ * 与 lib/index.js 里 workspaceRegistry / agents 的取值方式保持一致。
119
+ *
120
+ * 这里是**执行期**读取(工具调用那一刻),不是 apply 期探测,所以 `get` 是正确用法:
121
+ * 那一刻 settings 若存在一定已经就绪;不存在就退中文,不会挂起本插件。
122
+ */
111
123
  export function readLocale(ctx) {
112
124
  try {
113
- const section = ctx.settings?.get?.("locale");
125
+ const settings = typeof ctx?.get === "function" ? ctx.get("settings") : undefined;
126
+ const section = settings?.get?.("locale");
114
127
  const preference = section?.preference;
115
128
  if (typeof preference === "string" && preference.toLowerCase().startsWith("en")) return "en";
116
129
  } catch {
package/lib/index.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * 与其它专家插件并存:工具名、设置命名空间、服务名、remote 服务名都不重叠。
12
12
  */
13
13
  import { homedir } from "node:os";
14
- import { existsSync, readFileSync } from "node:fs";
14
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
15
15
  import { stat, rmdir } from "node:fs/promises";
16
16
  import { dirname, join } from "node:path";
17
17
  import schema from "@deepseek-ai/schemastery";
@@ -133,20 +133,86 @@ const settingsSchema = schema.object({
133
133
  enabled: schema.array(schema.string()).default([]),
134
134
  });
135
135
 
136
+ /**
137
+ * 把启动期名册同步的结果写进日志:**送达的内容要留痕,送不到的内容要出声**。
138
+ *
139
+ * 为什么单独成函数:旧实现只有一句 `copied.length > 0` 才打印的 info —— 于是「升级后
140
+ * 包内新增的专家/小队/译文一个都没到达」这件事在日志里**完全不存在**(2026-09-13 定性的
141
+ * 恒存缺陷)。这里把「漂移状态变化」变成唯一告警条件:既不静默,也不每次启动刷屏。
142
+ * @param logger - `ctx.logger`(可能缺席)。
143
+ * @param seeded - `seedData` 的报告。
144
+ * @param dataHome - 数据目录(日志里给人看的位置)。
145
+ */
146
+ function logSeedReport(logger, seeded, dataHome) {
147
+ const sample = (list) => (list.length === 0 ? "" : `,如 ${list.slice(0, 3).join("、")}${list.length > 3 ? " 等" : ""}`);
148
+ const mergedKeys = seeded.merged.reduce((sum, item) => sum + item.keys.length, 0);
149
+ const delivered = seeded.copied.length + seeded.updated.length + mergedKeys;
150
+ const versionNote = seeded.previousVersion !== "" && seeded.previousVersion !== seeded.version
151
+ ? `(包内快照 v${seeded.previousVersion} → v${seeded.version})`
152
+ : "";
153
+
154
+ if (delivered > 0 || versionNote !== "") {
155
+ const parts = [];
156
+ if (seeded.copied.length > 0) parts.push(`新增 ${seeded.copied.length} 项${sample(seeded.copied)}`);
157
+ if (seeded.updated.length > 0) parts.push(`对齐只读条目 ${seeded.updated.length} 项${sample(seeded.updated)}`);
158
+ if (mergedKeys > 0) parts.push(`补齐键 ${mergedKeys} 个${sample(seeded.merged.flatMap((item) => item.keys))}`);
159
+ logger?.info?.(`[t-team] 名册快照已同步到 ${dataHome}${versionNote}:${parts.join(";") || "无内容变更"}`);
160
+ }
161
+
162
+ // 送不到的必须出声:只在「与上次记录相比有变化」时告警一次,避免每次启动刷屏。
163
+ if ((seeded.kept.length > 0 || seeded.stale.length > 0) && seeded.driftChanged) {
164
+ const parts = [];
165
+ if (seeded.kept.length > 0) parts.push(`${seeded.kept.length} 项与包内快照不同${sample(seeded.kept)}`);
166
+ if (seeded.stale.length > 0) parts.push(`${seeded.stale.length} 项包内已移除${sample(seeded.stale)}`);
167
+ logger?.warn?.(
168
+ `[t-team] 名册快照有${parts.join("、")};已按「不覆盖你的内容」保留盘上那份,未做任何覆盖。`
169
+ + "若这些不是你改的,删掉对应文件后重启即可重新播种。",
170
+ );
171
+ }
172
+ }
173
+
174
+ /**
175
+ * 包内名册的规模(专家数 / 分区数),**从名册目录现算**并按目录缓存。
176
+ *
177
+ * 为什么不能写死:提示段是**模型可见的事实**。「a 316-expert, 22-division roster」这类字面量
178
+ * 在名册增删改分区之后就对模型说谎,而且没有任何东西会红(2026-09-13 定性的恒存缺陷之一:
179
+ * 数据更新了,模型看到的事实还是旧的)。现算 + 按目录缓存:一次 readdir 的开销,换来提示
180
+ * 永远等于该名册的真实规模。
181
+ * @param root - 名册目录(包内快照或用户数据目录)。
182
+ * @returns 规模;目录读不到时 `experts` 为 0(调用方据此改用不带数字的说法,而不是报 0 位)。
183
+ */
184
+ const rosterScaleCache = new Map();
185
+ function rosterScale(root) {
186
+ const key = typeof root === "string" ? root : "";
187
+ const cached = rosterScaleCache.get(key);
188
+ if (cached !== undefined) return cached;
189
+ let scale = { experts: 0, divisions: 0 };
190
+ try {
191
+ const divisions = readdirSync(key, { withFileTypes: true }).filter((entry) => entry.isDirectory());
192
+ // **递归**数 `.md`:名册里存在 `game-development/unity/<slug>.md` 这类二级分组,
193
+ // 只数一层会少算(2026-09-13 实测:一层 301 / 递归 316,与 README、source.json、
194
+ // zh/names.json 的 316 一致)。分区数只取一级目录 —— 二级目录是分组不是分区。
195
+ const experts = readdirSync(key, { recursive: true }).filter((name) => String(name).endsWith(".md")).length;
196
+ scale = { experts, divisions: divisions.length };
197
+ } catch {
198
+ scale = { experts: 0, divisions: 0 };
199
+ }
200
+ rosterScaleCache.set(key, scale);
201
+ return scale;
202
+ }
203
+
136
204
  export function apply(ctx, config) {
137
205
  const locale = () => readLocale(ctx);
138
206
  const maxDepth = config.maxDepth;
139
207
 
140
- // ---- 首次启动播种:包内自带名册快照可写数据目录(非破坏性,已有内容不动)----
208
+ // ---- 启动期同步:包内自带名册快照可写数据目录(缺失就补、只读对齐、用户内容不动)----
141
209
  // 必须在下面同步读取 t-team.config.json / teams.json 之前完成,否则首次安装会读到空配置。
142
210
  const seeded = seedData({ root: config.root, zhRoot: config.zhRoot });
143
- if (seeded.copied.length > 0) {
144
- ctx.logger?.info?.(`[t-team] 已把包内名册快照播种到 ${dirname(config.root)}(${seeded.copied.join(", ")})`);
145
- }
211
+ logSeedReport(ctx.logger, seeded, dirname(config.root));
146
212
  if (!seeded.ok) {
147
- // 播种失败不当作致命:名册目录若因此缺失,第一次读名册会在 ensureReady() 里**响亮地**抛出
213
+ // 同步失败不当作致命:名册目录若因此缺失,第一次读名册会在 ensureReady() 里**响亮地**抛出
148
214
  // (error.rootMissing),那才是最早可解析点。这里只保证失败一定会出现在日志里。
149
- ctx.logger?.error?.(`[t-team] 名册快照播种失败,将按已有数据继续:${seeded.error}`);
215
+ ctx.logger?.error?.(`[t-team] 名册快照同步失败,将按已有数据继续:${seeded.error}`);
150
216
  }
151
217
 
152
218
  // ---- 设置段(T专家 自己的命名空间,与其它插件不冲突)----
@@ -1298,9 +1364,12 @@ export function apply(ctx, config) {
1298
1364
  // 宿主真没给 commands 时日志里什么线索都没有。所以申请时就先说一句。
1299
1365
  ctx.logger?.warn?.("[t-team] 正在等待 commands 服务就绪以注册 /t 命令;若宿主不提供它,/t 将不可用。");
1300
1366
  ctx.inject(["commands"], (scoped) => {
1301
- // `scoped` 是**作用域 ctx**(真 cordis 实测:服务属性在它上面、`scoped.get` 也可用),
1302
- // 但它**不是**完整 ctx(`scoped.effect` undefined)。所以分工:作用域解析服务,
1303
- // 主 ctx 负责 effect/logger —— 绝不把 `scoped` ctx 传给注册点。
1367
+ // `scoped` cordis 的**作用域 ctx**。2026-09-13 用真 cordis 4.0.2 实测:
1368
+ // 它是**完整 Context**(`effect` / `get` / `logger` / `inject` / `on` / `plugin` 全在),
1369
+ // 所以「`scoped.effect` undefined」属于**过时说法,别照抄**(自检 [8c] 已用真 cordis
1370
+ // 把这条事实钉住,注释再漂就会被那条断言顶回来)。
1371
+ // 分工照旧:服务从 `scoped` 解析(它保证服务已就绪),effect/logger 挂主 ctx —— 这是
1372
+ // **生命周期上的选择**(注册点随插件 fiber 一起卸载),不是能力限制。
1304
1373
  const commands = scoped?.commands ?? scoped?.get?.("commands");
1305
1374
  if (typeof commands?.register !== "function") {
1306
1375
  ctx.logger?.warn?.("[t-team] commands 服务没有 register:/t 命令不可用(@ 召唤与 summon_t_expert 不受影响)。");
@@ -1415,12 +1484,17 @@ export function apply(ctx, config) {
1415
1484
  order: 118,
1416
1485
  text: (context) => {
1417
1486
  if (context.agent?.session?.header?.parentSession !== undefined) return "";
1487
+ // 名册规模与批量上限都**现算**:写死会在名册变更 / 用户改 Config 之后对模型说谎。
1488
+ const scale = rosterScale(config.root);
1489
+ const scope = scale.experts > 0
1490
+ ? `The parent session has T专家 — a ${scale.experts}-expert, ${scale.divisions}-division roster with full Chinese translations, exposed as summonable domain experts.`
1491
+ : "The parent session has T专家 — a curated roster of domain experts with full Chinese translations, exposed as summonable domain experts.";
1418
1492
  return [
1419
1493
  "## T专家 (T Expert) expert mode",
1420
- "The parent session has T专家 — a 316-expert, 22-division roster with full Chinese translations, exposed as summonable domain experts.",
1494
+ scope,
1421
1495
  "Experts are individually enabled/disabled in the T专家 settings tab; ALL are disabled by default and a disabled expert cannot be summoned.",
1422
1496
  "A composer selection inserts one enabled expert as a native reference chip; the remaining draft text is that expert's task.",
1423
- "In the parent session, call `list_t_experts()` for enabled division names and counts, then `list_t_experts(division)` to pick a unique expert name, then `summon_t_expert(expert, task)` — or `summon_t_experts` for a small parallel team (at most 8; partial results when some fail).",
1497
+ `In the parent session, call \`list_t_experts()\` for enabled division names and counts, then \`list_t_experts(division)\` to pick a unique expert name, then \`summon_t_expert(expert, task)\` — or \`summon_t_experts\` for a small parallel team (at most ${config.maxSummonBatch}; partial results when some fail).`,
1424
1498
  "For a long multi-step goal that needs a durable team (members, tasks with dependencies, review gates), the user can run a T专家 squad with the `/t <squad> <goal>` slash command (squad names and aliases come from the plugin's squad definitions; plain `/t` lists them); it activates the built-in T专家 team engine with a squad whose members already carry the experts' personas. Do not invent squad names when `/t` is available.",
1425
1499
  ].join("\n");
1426
1500
  },
@@ -589,16 +589,22 @@ export function installRetiredMemberGuard(ctx, stateDir) {
589
589
  * rc.8 changed those rows to branded `SessionId` values plus residency-only
590
590
  * `activity`; neither is needed to answer whether the live Agent driver is
591
591
  * running, idle, or absent/ready.
592
- * @param ctx - the plugin context (injects `agents`).
592
+ * @param ctx - the plugin context (only needs `ctx.get`; `agents` itself is optional).
593
593
  * @param memberIds - child ids restored from the durable team record.
594
594
  * @returns child id → live activity.
595
595
  */
596
596
  export function memberActivity(ctx, memberIds) {
597
597
  const activity = new Map();
598
+ // `agents` 是**可选服务**:宿主侧静态 `inject` 只有 tools/subagents/systemPrompt,所以
599
+ // 属性访问 `ctx.agents` 在那种 ctx 上会抛 `cannot get property "agents" without inject`,
600
+ // 而调用方(团队面板的快照收集)用 try/catch 把它吞成「skipped unreadable team state」——
601
+ // 整支队伍从面板消失,只留一句没有原因可查的告警(2026-09-13 查明)。规则与
602
+ // lib/index.js 里 `ctx.get("agents")` 一致:可选服务必须惰查,缺了降级成 ready。
603
+ const agents = typeof ctx?.get === 'function' ? ctx.get('agents') : undefined;
598
604
  for (const id of memberIds) {
599
605
  if (id === '')
600
606
  continue;
601
- const live = ctx.agents.get(brandedSessionId(id));
607
+ const live = agents?.get?.(brandedSessionId(id));
602
608
  activity.set(id, live === undefined ? 'ready' : live.status);
603
609
  }
604
610
  return activity;
@@ -147,8 +147,11 @@ export async function collectTeamsActivity(ctx, roots) {
147
147
  continue;
148
148
  snapshots.push(await assembleTeamSnapshot(ctx, root.stateRoot, root.workspace, state));
149
149
  }
150
- catch {
151
- ctx.logger.warn(`t-team: skipped unreadable team state "${entry.name}" in workspace "${root.workspace}"`);
150
+ catch (error) {
151
+ // 必须带上根因:裸 `catch {` 只留一句「unreadable」,解析失败、权限、服务缺失、
152
+ // 并发读全都会变成同一句无法诊断的告警(2026-09-13 在真实宿主日志里就撞上一条
153
+ // 谁也没法解释的 "skipped unreadable team state",根因正是被这里吞掉的)。
154
+ ctx.logger.warn(`t-team: skipped unreadable team state "${entry.name}" in workspace "${root.workspace}": ${String(error)}`);
152
155
  }
153
156
  }
154
157
  }
@@ -172,8 +175,8 @@ export async function collectArchivedTeamsActivity(ctx, roots) {
172
175
  continue;
173
176
  snapshots.push(await assembleTeamSnapshot(ctx, join(root.stateRoot, 'archive'), root.workspace, state, { includeRemoved: true, historic: true }));
174
177
  }
175
- catch {
176
- ctx.logger.warn(`t-team: skipped unreadable archived team "${teamId}" in workspace "${root.workspace}"`);
178
+ catch (error) {
179
+ ctx.logger.warn(`t-team: skipped unreadable archived team "${teamId}" in workspace "${root.workspace}": ${String(error)}`);
177
180
  }
178
181
  }
179
182
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-t-expert",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "type": "module",
5
5
  "description": "T Expert — a 316-expert, 22-division roster with full Chinese translations, as a standalone DeepSeek Harness plugin, with a built-in multi-agent team engine (T Team).",
6
6
  "license": "MIT",
@@ -65,6 +65,33 @@ dsh --profile web --dump-config # 看本机实际启动的树;打印出的
65
65
 
66
66
  **应用启动只有一条路**:`dsh` CLI + 具名 profile(`dsh web` 是 `--profile web` 的别名)。package bin、demo、public SDK argv 直接拼 Cordis 树都是禁止的,`scripts/verify-application-entrypoints.ts` 会拒绝。
67
67
 
68
+ ## 依赖语义:决定「等待」还是「挂起」
69
+
70
+ `inject` 与 `ctx.get` 不是两种风格,而是**两种契约**(官方 `develop/framework/service` 有权威表述,本仓库同一套语义):
71
+
72
+ - **`inject = ['x']` = 必需依赖**。框架保证 `apply` 执行时声明的服务**已全部就绪**;缺一个,插件就**等着、不执行**。
73
+ → 这就是 **silent PENDING**:插件看起来"没加载",而且**它自己不产生任何日志**。排查时先怀疑这里,别先怀疑业务代码。
74
+ - **省略 `inject`、用 `ctx.get('x')` = 可选依赖**,但**必须在使用点调用**,不能在 `apply` 里探一次:
75
+ `apply` 是最早也最脆弱的时刻,提供方晚到的话那次探测已经返回 `undefined`,**没有任何机制会重跑它**。
76
+ (真实事故:把可选服务的 `ctx.get` 写在 apply 期做一次性探测 → 服务晚到时永久判负 → 设置段不注册、面板空白。)
77
+ - 运行期必需服务**消失** → 依赖它的插件自动 dispose;服务**回来** → 自动重新加载。所以「注册即 effect」是生命周期要求,不是代码风格。
78
+ - 需要"等某个服务就绪再做事"时用 `ctx.inject(['x'], cb)`:它等的是"服务就绪"这件事本身,服务迟到也能补上。
79
+
80
+ ## 官网文档:面向插件作者的同一份知识
81
+
82
+ `https://deepseek-harness.github.io/deepseek-harness/` 是本仓库的**公开面**(`website/` 的 VitePress 投影),适合快速核对启动模型、依赖语义与钩子,再去读源码:
83
+
84
+ | 页面 | 用途 |
85
+ |---|---|
86
+ | `guide/quickstart` | Web UI 起步:起服务、加工作区、配模型(新用户从这里开始) |
87
+ | `develop/basic/` | 最小插件、`scratch-plugin/cordis.yml` 的 `- insert:` 行、**插件路径必须是绝对路径** |
88
+ | `develop/basic/tool` · `config` · `publish` | 工具注册;受校验的 `Config`;`dsh.bundle` vs `dsh.profile` + `dsh plugin add` |
89
+ | `develop/framework/` · `service` · `events` | 生命周期/effect、服务与依赖语义、事件系统 |
90
+ | `develop/practice/` · `llm-adapter` · `dynamic-cordis` | 能力三角色分层、模型适配器、在运行中的智能体里改插件 |
91
+ | `develop/cordis-tutorial/01…07` | Cordis 阶梯,终点是「进入 harness」 |
92
+
93
+ **边界(重要)**:站点**不覆盖** `vitest` / `oxlint` / `run-gates.ts` / 覆盖率策略、`.agents/notes/` 约定、生成的子系统页 —— 所以**概念查站点、门禁与契约查检出**。冲突时以检出为准(站点由本仓库生成,冲突本身值得上报)。
94
+
68
95
  ## 核心包(spine)
69
96
 
70
97
  | 包 | 拥有 | ctx key |