dsh-plugin-deck-studio 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/lib/config.js CHANGED
@@ -171,25 +171,29 @@ export function mergeConfig(raw, options = {}) {
171
171
  tools: input.tools === true,
172
172
  /** preset 子树挂载点设 false(根挂载已注册同路径路由,避免双重注册)。 */
173
173
  routes: input.routes !== false,
174
+ /**
175
+ * P0:自检面板与黄条要显示「命中来源」,所以这里保留 env / profile 两个原始值。
176
+ * 生效值按「env > profile > 默认」算;指针文件($DSH_HOME/deckline/location.json)由
177
+ * lib/data-dir.js 在服务启动时插到 env 与 profile 之间——本函数保持纯函数、不做 IO。
178
+ */
179
+ envDataRoot: expandHome(env.DSH_DECK_DATA_ROOT ?? "", env),
180
+ profileDataRoot: expandHome(typeof input.dataRoot === "string" ? input.dataRoot.trim() : "", env),
174
181
  dataRoot:
175
- expandHome(
176
- typeof input.dataRoot === "string" && input.dataRoot
177
- ? input.dataRoot
178
- : (env.DSH_DECK_DATA_ROOT ?? ""),
179
- env,
180
- ) || defaultDataRoot(env),
182
+ expandHome(env.DSH_DECK_DATA_ROOT ?? "", env)
183
+ || expandHome(typeof input.dataRoot === "string" ? input.dataRoot.trim() : "", env)
184
+ || defaultDataRoot(env),
181
185
  /**
182
186
  * 新建 deck 项目的落盘根。留空 = <dataRoot>/projects/<id>/source。
183
187
  * 与会话 workspacePath 独立;默认数据根不在插件安装目录内。
184
188
  * 配置了这个根时,新项目直接建在 `<projectsRoot>/<目录名>/`
185
189
  * (script.md / intent.md / style-reference.md / prompts/ 同层,和已有 deck 源结构一致)。
190
+ * env `DSH_DECK_PROJECTS_ROOT` 优先于 profile(与 dataRoot 同一口径)。
186
191
  */
187
- projectsRoot: expandHome(
188
- typeof input.projectsRoot === "string" && input.projectsRoot.trim()
189
- ? input.projectsRoot.trim()
190
- : (env.DSH_DECK_PROJECTS_ROOT ?? ""),
191
- env,
192
- ),
192
+ envProjectsRoot: expandHome(env.DSH_DECK_PROJECTS_ROOT ?? "", env),
193
+ profileProjectsRoot: expandHome(typeof input.projectsRoot === "string" ? input.projectsRoot.trim() : "", env),
194
+ projectsRoot:
195
+ expandHome(env.DSH_DECK_PROJECTS_ROOT ?? "", env)
196
+ || expandHome(typeof input.projectsRoot === "string" ? input.projectsRoot.trim() : "", env),
193
197
  decks,
194
198
  render,
195
199
  styleBrief:
@@ -0,0 +1,381 @@
1
+ /**
2
+ * 数据目录不匹配检测(P0):当前 dataRoot 里到底有没有这本 deck 的正文。
3
+ *
4
+ * 判定口径(规格 §2 + lead 认的例外):
5
+ * - `ok`:`decks/<id>/script/current.md` 在;
6
+ * - `partial`:有出图(slides 里有 image.*)但 script 没了 → revision 丢失,文案与 mismatch 区分;
7
+ * - `mismatch`:`decks/<id>/` 在(有 deck.json 等残留)但正文没了,或别处真的有这本 deck 的 script;
8
+ * - `absent`:当前 dataRoot 里完全没有痕迹。**分两种口径**:
9
+ * - 本机新建 deck(源在 projectsRoot 下):静默,不记事件、不出黄条(首次打开本来就该初始化);
10
+ * - 注册的外部受控源 deck:给 info 级弱提示(不拦渲染)——
11
+ * 「这本 deck 是外部源,当前数据目录还没有它的工作副本,首次打开会初始化」。
12
+ *
13
+ * 扫盘只在「疑似不对」时做(正常用户每次进 deck 只付两次 stat)。
14
+ * @module dsh-plugin-deck-studio/data-dir-detect
15
+ */
16
+
17
+ import { existsSync, readdirSync, statSync } from "node:fs";
18
+ import { join, resolve } from "node:path";
19
+ import { defaultDataRoot } from "./config.js";
20
+ import { DECK_DATA_STATUS_LABELS, normalizeDirPath } from "./data-dir.js";
21
+
22
+ /** 只允许这个形状的 id 参与拼路径(与 deck-workspace 同一条纪律)。 */
23
+ const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
24
+ /** 出图文件名:slides/<pageId>/versions/vNNN/image.<ext>。 */
25
+ const IMAGE_FILE = /^image\.(png|webp|jpe?g|svg)$/i;
26
+ /** 外部源 absent 的弱提示(info 级,不拦渲染)。 */
27
+ export const EXTERNAL_ABSENT_NOTICE = "这本 deck 是外部源,当前数据目录还没有它的工作副本,首次打开会初始化。";
28
+
29
+ function safeReaddir(path) {
30
+ try {
31
+ return readdirSync(path, { withFileTypes: true });
32
+ } catch {
33
+ return [];
34
+ }
35
+ }
36
+
37
+ function isDirectory(path) {
38
+ try {
39
+ return statSync(path).isDirectory();
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * 数一本 deck 已出图的张数(`slides/<pageId>/versions/vNNN/image.<ext>`)。
47
+ * 只在需要时调(检测、自检面板),不做全量预热。
48
+ * @param {string} deckDir - `<dataRoot>/decks/<deckId>`
49
+ */
50
+ export function countDeckImages(deckDir, { cap = 2000 } = {}) {
51
+ const slidesDir = join(deckDir, "slides");
52
+ if (!existsSync(slidesDir)) return 0;
53
+ let count = 0;
54
+ for (const pageEntry of safeReaddir(slidesDir)) {
55
+ if (!pageEntry.isDirectory()) continue;
56
+ const versionsDir = join(slidesDir, pageEntry.name, "versions");
57
+ if (!existsSync(versionsDir)) continue;
58
+ for (const versionEntry of safeReaddir(versionsDir)) {
59
+ if (!versionEntry.isDirectory() || !/^v\d+$/.test(versionEntry.name)) continue;
60
+ for (const fileEntry of safeReaddir(join(versionsDir, versionEntry.name))) {
61
+ if (!fileEntry.isFile() || !IMAGE_FILE.test(fileEntry.name)) continue;
62
+ count += 1;
63
+ if (count >= cap) return count;
64
+ }
65
+ }
66
+ }
67
+ return count;
68
+ }
69
+
70
+ /**
71
+ * 一本 deck 在当前 dataRoot 里的数据状态。
72
+ * @param {string} dataRoot
73
+ * @param {string} deckId
74
+ * @param {{countImages?: boolean}} [options] - 自检面板要「已出图页数」时传 true(健康 deck 也数)。
75
+ */
76
+ export function deckDataHealth(dataRoot, deckId, { countImages = false } = {}) {
77
+ const id = String(deckId ?? "").trim();
78
+ const safe = SAFE_ID.test(id);
79
+ const deckDir = safe ? join(dataRoot, "decks", id) : "";
80
+ const result = {
81
+ deckId: id,
82
+ dataRoot,
83
+ deckDir,
84
+ deckDirExists: false,
85
+ hasManifest: false,
86
+ hasScript: false,
87
+ imageCount: 0,
88
+ scriptFile: safe ? join(deckDir, "script", "current.md") : "",
89
+ status: "absent",
90
+ };
91
+ if (!safe || !existsSync(deckDir) || !isDirectory(deckDir)) return result;
92
+ const hasManifest = existsSync(join(deckDir, "deck.json"));
93
+ const hasScript = existsSync(join(deckDir, "script", "current.md"));
94
+ // 没 script 时必须数图:有图 = partial(revision 丢了),没图 = mismatch(只剩残留)。
95
+ const imageCount = countImages || !hasScript ? countDeckImages(deckDir) : 0;
96
+ const status = hasScript ? "ok" : imageCount > 0 ? "partial" : "mismatch";
97
+ return { ...result, deckDirExists: true, hasManifest, hasScript, imageCount, status };
98
+ }
99
+
100
+ /**
101
+ * 一个候选数据目录里跟某本 deck 有关的事实(黄条「已发现:…(N 本 deck · M 张图)」)。
102
+ */
103
+ export function scanDataDirCandidate(dataRoot, deckId) {
104
+ const root = normalizeDirPath(dataRoot);
105
+ const id = String(deckId ?? "").trim();
106
+ const result = { dataRoot: root, deckFound: false, scriptFound: false, deckCount: 0, imageCount: 0, readable: false, score: 0 };
107
+ if (!root || !SAFE_ID.test(id) || !existsSync(root) || !isDirectory(root)) return result;
108
+ result.readable = true;
109
+ const decksDir = join(root, "decks");
110
+ for (const entry of safeReaddir(decksDir)) {
111
+ if (!entry.isDirectory() || !SAFE_ID.test(entry.name)) continue;
112
+ const deckDir = join(decksDir, entry.name);
113
+ const hasScript = existsSync(join(deckDir, "script", "current.md"));
114
+ if (!hasScript && !existsSync(join(deckDir, "deck.json"))) continue;
115
+ result.deckCount += 1;
116
+ result.imageCount += countDeckImages(deckDir);
117
+ if (entry.name === id) {
118
+ result.deckFound = true;
119
+ result.scriptFound = hasScript;
120
+ }
121
+ }
122
+ result.score = result.deckCount + result.imageCount;
123
+ return result;
124
+ }
125
+
126
+ /** 磁盘上有内容的 deck id(有 deck.json 或 script/current.md),升序。 */
127
+ export function listDataRootDeckIds(dataRoot) {
128
+ const decksDir = join(dataRoot, "decks");
129
+ const ids = [];
130
+ for (const entry of safeReaddir(decksDir)) {
131
+ if (!entry.isDirectory() || !SAFE_ID.test(entry.name)) continue;
132
+ const deckDir = join(decksDir, entry.name);
133
+ if (existsSync(join(deckDir, "deck.json")) || existsSync(join(deckDir, "script", "current.md"))) ids.push(entry.name);
134
+ }
135
+ return ids.sort();
136
+ }
137
+
138
+ /**
139
+ * 可用候选目录(内部口径,candidateDataRoots 与 scanDataDirCandidates 共用,别再各写一遍)。
140
+ * 规格 §2「对每个候选检查 <candidate>/decks/<deckId>/script/current.md 是否存在,命中才记为可用候选」:
141
+ * 目录必须真的存在;给了 deckId 时还必须有这本 deck 的正文。当前 dataRoot 自己不算候选。
142
+ */
143
+ function usableCandidateRoots({ env, cwd, profileDataRoots, currentDataRoot, deckId }) {
144
+ const current = normalizeDirPath(currentDataRoot, env);
145
+ const raw = [
146
+ cwd ? join(cwd, ".deck") : "",
147
+ defaultDataRoot(env),
148
+ env.DSH_DECK_DATA_ROOT ?? "",
149
+ ...(Array.isArray(profileDataRoots) ? profileDataRoots : [profileDataRoots]),
150
+ ];
151
+ const seen = new Set();
152
+ const out = [];
153
+ for (const value of raw) {
154
+ const path = normalizeDirPath(value, env);
155
+ if (!path || path[0] !== "/") continue;
156
+ const key = resolve(path);
157
+ if (seen.has(key) || (current && key === resolve(current))) continue;
158
+ // 不存在 / 没有这本 deck 正文的目录不是「可用候选」(列出来只会让人点一个空目录)。
159
+ if (!existsSync(path)) continue;
160
+ if (deckId && !existsSync(join(path, "decks", deckId, "script", "current.md"))) continue;
161
+ seen.add(key);
162
+ out.push(path);
163
+ }
164
+ return out;
165
+ }
166
+
167
+ /**
168
+ * 候选数据目录(规格顺序):`<cwd>/.deck` → `$DSH_HOME/deckline` → `$DSH_DECK_DATA_ROOT` → profile 里出现过的 dataRoot。
169
+ * 去重、排除当前 dataRoot(它自己不算「候选」),并只留**可用**的(见 usableCandidateRoots)。
170
+ * 不传 deckId 时按「目录存在」筛(诊断 / 列表用)。
171
+ */
172
+ export function candidateDataRoots({ env = process.env, cwd = process.cwd(), profileDataRoots = [], currentDataRoot = "", deckId = "" } = {}) {
173
+ return usableCandidateRoots({ env, cwd, profileDataRoots, currentDataRoot, deckId });
174
+ }
175
+
176
+ /**
177
+ * 扫候选目录,只返回**真的存着这本 deck 的 script** 的目录(规格:「命中就记为可用候选」)。
178
+ * 排序:deck 数多的优先,其次图多的;最多 `limit` 个(界面只列前 3)。
179
+ */
180
+ export function scanDataDirCandidates({ deckId, env = process.env, cwd = process.cwd(), profileDataRoots = [], currentDataRoot = "", limit = 3 } = {}) {
181
+ const roots = candidateDataRoots({ env, cwd, profileDataRoots, currentDataRoot, deckId });
182
+ const hits = roots
183
+ .map((root, order) => ({ ...scanDataDirCandidate(root, deckId), order }))
184
+ .filter((entry) => entry.scriptFound);
185
+ hits.sort((a, b) => (b.deckCount - a.deckCount) || (b.imageCount - a.imageCount) || (a.order - b.order));
186
+ return hits.slice(0, Math.max(1, limit)).map(({ order, ...entry }) => entry);
187
+ }
188
+
189
+ /**
190
+ * 整体体检:注册的 deck 在当前 dataRoot 里是不是都有正文;没有的扫候选目录找证据。
191
+ */
192
+ export function assessDataDir({
193
+ dataRoot,
194
+ decks = [],
195
+ env = process.env,
196
+ cwd = process.cwd(),
197
+ profileDataRoots = [],
198
+ withCandidates = true,
199
+ candidateLimit = 3,
200
+ countImages = false,
201
+ } = {}) {
202
+ const root = normalizeDirPath(dataRoot);
203
+ const entries = (Array.isArray(decks) ? decks : [])
204
+ .map((deck) => (typeof deck === "string" ? { id: deck } : deck))
205
+ .filter((deck) => deck && SAFE_ID.test(String(deck.id ?? "")));
206
+ const assessed = [];
207
+ for (const deck of entries) {
208
+ const health = deckDataHealth(root, deck.id, { countImages });
209
+ let status = health.status;
210
+ let candidates = [];
211
+ if (status !== "ok" && withCandidates) {
212
+ candidates = scanDataDirCandidates({
213
+ deckId: deck.id,
214
+ env,
215
+ cwd,
216
+ profileDataRoots,
217
+ currentDataRoot: root,
218
+ limit: candidateLimit,
219
+ });
220
+ // absent → mismatch 只在**别处真有这本 deck 的 script** 时升级(否则新 deck 首次打开就误报)。
221
+ if (status === "absent" && candidates.length) status = "mismatch";
222
+ }
223
+ const sourceRoot = typeof deck.sourceRoot === "string" ? deck.sourceRoot : "";
224
+ const sourceAvailable = typeof deck.sourceAvailable === "boolean" ? deck.sourceAvailable : null;
225
+ const local = typeof deck.local === "boolean" ? deck.local : false;
226
+ assessed.push({
227
+ deckId: String(deck.id),
228
+ label: typeof deck.label === "string" && deck.label ? deck.label : String(deck.id),
229
+ title: typeof deck.title === "string" && deck.title ? deck.title : "",
230
+ pageCount: Number.isFinite(Number(deck.pageCount)) ? Number(deck.pageCount) : 0,
231
+ renderedPages: countImages ? health.imageCount : null,
232
+ sourceRoot,
233
+ sourceAvailable,
234
+ registrationValid: sourceAvailable === true || Boolean(sourceRoot && existsSync(sourceRoot)),
235
+ ...health,
236
+ status,
237
+ statusLabel: DECK_DATA_STATUS_LABELS[status] ?? status,
238
+ origin: local ? "local" : "external",
239
+ // 弱提示:只有「外部源 + 完全没痕迹」才给,info 级、不拦渲染、不记事件。
240
+ notice: status === "absent" && !local ? { level: "info", message: EXTERNAL_ABSENT_NOTICE } : null,
241
+ candidates,
242
+ // 黄条口径(规格 §2 收口,lead 定的):注册 deck 在当前 root 没有正文 → **一定**出黄条,
243
+ // status 是 mismatch(别处找到 script)还是 absent(别处也没找到)只是文案变体。
244
+ // 唯一静默:本机新建 deck(local)且当前 root 完全没痕迹 —— 首次打开本来就是正常初始化。
245
+ needsBanner: status === "mismatch" || status === "partial" || (status === "absent" && !local),
246
+ });
247
+ }
248
+ const broken = assessed.filter((deck) => deck.status === "mismatch" || deck.status === "partial");
249
+ // 黄条范围比「数据坏」宽:外部源 absent 也要出条,所以收候选按 needsBanner 收。
250
+ const bannerDecks = assessed.filter((deck) => deck.needsBanner);
251
+ const merged = new Map();
252
+ for (const deck of bannerDecks) {
253
+ for (const candidate of deck.candidates) {
254
+ const key = resolve(candidate.dataRoot);
255
+ if (!merged.has(key)) merged.set(key, { ...candidate });
256
+ }
257
+ }
258
+ const candidates = [...merged.values()]
259
+ .sort((a, b) => (b.deckCount - a.deckCount) || (b.imageCount - a.imageCount))
260
+ .slice(0, Math.max(1, candidateLimit));
261
+ return {
262
+ dataRoot: root,
263
+ checkedAt: new Date().toISOString(),
264
+ decks: assessed,
265
+ mismatchDeckIds: broken.map((deck) => deck.deckId),
266
+ bannerDeckIds: bannerDecks.map((deck) => deck.deckId),
267
+ candidates,
268
+ healthy: broken.length === 0,
269
+ };
270
+ }
271
+
272
+ /**
273
+ * 黄条 / 诊断要的事实(文案由前端按 UI 口径渲染,这里只给事实)。
274
+ */
275
+ export function dataDirBannerFacts({ report, deckId = null, envOverride = false, dataRoot = "" } = {}) {
276
+ const decks = report?.decks ?? [];
277
+ // 黄条口径(规格 §2):注册 deck 在当前 root 没有正文 → 出黄条;absent 只是文案变体,零候选时仍出黄条
278
+ // (带手填输入框),只有本机新建 deck 的首次打开静默。判据统一用 needsBanner,别在这里再写一遍状态机。
279
+ const bannerDecks = decks.filter((deck) => deck.needsBanner);
280
+ const active = deckId ? decks.find((deck) => deck.deckId === deckId) ?? null : null;
281
+ const activeNeedsBanner = Boolean(active && active.needsBanner);
282
+ if (!activeNeedsBanner && !bannerDecks.length) {
283
+ return { show: false, kind: "", deckId: null, deckLabel: "", scope: "deck", candidates: [], affectedDecks: 0, imageCount: 0, manualInput: false, envOverride: Boolean(envOverride), dataRoot };
284
+ }
285
+ const subject = activeNeedsBanner ? active : bannerDecks[0];
286
+ const candidates = (activeNeedsBanner ? subject.candidates : report?.candidates) ?? [];
287
+ return {
288
+ show: true,
289
+ kind: subject.status,
290
+ deckId: subject.deckId,
291
+ deckLabel: subject.label,
292
+ scope: activeNeedsBanner ? "deck" : "home",
293
+ candidates,
294
+ // 零候选 = 连「切到这个目录」都没得点:前端要出手填绝对路径输入框(+ 目录选择器)。
295
+ manualInput: candidates.length === 0,
296
+ affectedDecks: bannerDecks.length,
297
+ imageCount: activeNeedsBanner ? subject.imageCount : bannerDecks.reduce((sum, deck) => sum + (deck.imageCount ?? 0), 0),
298
+ envOverride: Boolean(envOverride),
299
+ dataRoot: dataRoot || report?.dataRoot || "",
300
+ };
301
+ }
302
+
303
+ /**
304
+ * 自检面板整表(一处拼装,前端不重算口径)。
305
+ */
306
+ export function buildSelfCheckPayload({ resolved = {}, workspacePath = null, report = null, diskDeckIds = [], render = {}, versions = {} } = {}) {
307
+ const decks = report?.decks ?? [];
308
+ const registeredIds = decks.map((deck) => deck.deckId);
309
+ const onDisk = (Array.isArray(diskDeckIds) ? diskDeckIds : []).filter((id) => SAFE_ID.test(String(id ?? "")));
310
+ const payload = {
311
+ checkedAt: new Date().toISOString(),
312
+ processCwd: resolved.processCwd ?? process.cwd(),
313
+ dataRoot: resolved.dataRoot ?? null,
314
+ projectsRoot: resolved.projectsRoot ?? null,
315
+ workspacePath: workspacePath ?? null,
316
+ dshHome: resolved.dshHome ?? null,
317
+ pointerFile: resolved.pointerFile ?? { path: "", exists: false, error: null },
318
+ envOverride: resolved.envOverride ?? { active: false, name: "DSH_DECK_DATA_ROOT" },
319
+ firstRun: resolved.firstRun === true,
320
+ versions: {
321
+ plugin: versions.plugin ?? "",
322
+ dsh: versions.dsh ?? "",
323
+ node: versions.node ?? process.version,
324
+ },
325
+ render: { mode: render.mode ?? "", nodeName: render.nodeName ?? "", model: render.model ?? "" },
326
+ decks: {
327
+ registeredCount: registeredIds.length,
328
+ onDiskCount: onDisk.length,
329
+ onlyRegistered: registeredIds.filter((id) => !onDisk.includes(id)),
330
+ onlyOnDisk: onDisk.filter((id) => !registeredIds.includes(id)),
331
+ items: decks.map((deck) => ({
332
+ deckId: deck.deckId,
333
+ label: deck.label,
334
+ title: deck.title || deck.label,
335
+ pageCount: deck.pageCount ?? 0,
336
+ renderedPages: deck.renderedPages ?? (deck.hasScript ? 0 : deck.imageCount),
337
+ hasScript: deck.hasScript === true,
338
+ status: deck.status,
339
+ statusLabel: deck.statusLabel ?? DECK_DATA_STATUS_LABELS[deck.status] ?? deck.status,
340
+ origin: deck.origin ?? "external",
341
+ notice: deck.notice ?? null,
342
+ sourceRoot: deck.sourceRoot ?? "",
343
+ sourceAvailable: deck.sourceAvailable ?? null,
344
+ candidates: deck.candidates ?? [],
345
+ })),
346
+ },
347
+ };
348
+ return { ...payload, diagnosticsText: diagnosticsTextOf(payload) };
349
+ }
350
+
351
+ /**
352
+ * 脱敏、可粘贴的诊断文本(「复制诊断信息」只复制这一段,避免前后端两套脱敏口径)。
353
+ * 只含路径 / 版本 / 计数,不含 key、token。
354
+ */
355
+ export function diagnosticsTextOf(payload = {}) {
356
+ const fact = (root) => (root ? `${root.value || "(空)"}〔来源:${root.source}${root.envOverridden ? " · 环境变量覆盖(只读)" : ""}〕` : "(未知)");
357
+ const lines = [];
358
+ lines.push(`Deckline 自检 · ${payload.checkedAt ?? new Date().toISOString()}`);
359
+ lines.push(`进程 cwd:${payload.processCwd ?? process.cwd()}`);
360
+ lines.push(`数据目录 dataRoot:${fact(payload.dataRoot)}`);
361
+ lines.push(`新建 deck 源根 projectsRoot:${fact(payload.projectsRoot)}`);
362
+ lines.push(`会话工作区 workspacePath:${fact(payload.workspacePath)}`);
363
+ lines.push(`DSH_HOME:${fact(payload.dshHome)}`);
364
+ const pointer = payload.pointerFile ?? {};
365
+ lines.push(`指针文件:${pointer.path || "(未知)"}(${pointer.exists ? "存在" : "不存在"}${pointer.error ? ` · ${pointer.error}` : ""})`);
366
+ lines.push(`环境变量覆盖:${payload.envOverride?.active ? `${payload.envOverride.name}(只读)` : "无"}`);
367
+ const render = payload.render ?? {};
368
+ lines.push(`出图:${render.mode || "—"} · ${render.nodeName || "默认节点"} · ${render.model || "—"}`);
369
+ const versions = payload.versions ?? {};
370
+ lines.push(`版本:插件 ${versions.plugin || "—"} · DSH ${versions.dsh || "—"} · Node ${versions.node || process.version}`);
371
+ const decks = payload.decks ?? {};
372
+ lines.push(`注册 deck ${decks.registeredCount ?? 0} 本 / 磁盘 ${decks.onDiskCount ?? 0} 本;仅注册:${(decks.onlyRegistered ?? []).join(", ") || "无"};仅磁盘:${(decks.onlyOnDisk ?? []).join(", ") || "无"}`);
373
+ for (const deck of decks.items ?? []) {
374
+ const parts = [`[${deck.status}]`, deck.deckId, `${deck.pageCount ?? 0} 页`, `已出图 ${deck.renderedPages ?? 0} 页`, deck.hasScript ? "script 在" : "script 缺失"];
375
+ if ((deck.candidates ?? []).length) {
376
+ parts.push(`候选:${deck.candidates.map((candidate) => `${candidate.dataRoot}(${candidate.deckCount} 本 deck · ${candidate.imageCount} 张图)`).join(" / ")}`);
377
+ }
378
+ lines.push(`- ${parts.join(" · ")}`);
379
+ }
380
+ return lines.join("\n");
381
+ }