sillyspec 3.26.10 → 3.26.13

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.
Files changed (101) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +85 -85
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
  3. package/.claude/skills/sillyspec-commit/SKILL.md +109 -109
  4. package/.claude/skills/sillyspec-continue/SKILL.md +45 -45
  5. package/.claude/skills/sillyspec-doctor/SKILL.md +76 -76
  6. package/.claude/skills/sillyspec-execute/SKILL.md +200 -200
  7. package/.claude/skills/sillyspec-explore/SKILL.md +95 -95
  8. package/.claude/skills/sillyspec-knowledge/SKILL.md +269 -269
  9. package/.claude/skills/sillyspec-plan/SKILL.md +148 -148
  10. package/.claude/skills/sillyspec-quick/SKILL.md +115 -111
  11. package/.claude/skills/sillyspec-resume/SKILL.md +68 -68
  12. package/.claude/skills/sillyspec-scan/SKILL.md +79 -79
  13. package/.claude/skills/sillyspec-state/SKILL.md +64 -64
  14. package/.claude/skills/sillyspec-status/SKILL.md +54 -54
  15. package/.claude/skills/sillyspec-verify/SKILL.md +83 -83
  16. package/.claude/skills/sillyspec-workspace/SKILL.md +169 -169
  17. package/.gitattributes +2 -0
  18. package/.husky/pre-push +20 -20
  19. package/README.md +203 -201
  20. package/SKILL.md +91 -90
  21. package/package.json +39 -39
  22. package/packages/dashboard/dist/assets/index-Bq_Z2hne.js +7446 -7446
  23. package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -1
  24. package/packages/dashboard/dist/index.html +16 -16
  25. package/packages/dashboard/package.json +25 -25
  26. package/packages/dashboard/server/executor.js +95 -86
  27. package/packages/dashboard/server/index.js +663 -601
  28. package/packages/dashboard/server/parser.js +526 -526
  29. package/src/change-risk-profile.js +254 -254
  30. package/src/classify-change.js +109 -109
  31. package/src/config-schema.js +313 -310
  32. package/src/contract-matrix.js +388 -379
  33. package/src/db.js +370 -350
  34. package/src/dispatch/backends/local-agent.js +155 -155
  35. package/src/dispatch/backends/sillyhub-mcp.js +245 -245
  36. package/src/dispatch/probe.js +246 -210
  37. package/src/dispatch/strategy.js +121 -121
  38. package/src/docs-check.js +188 -16
  39. package/src/docs-debt.js +6 -2
  40. package/src/docs-gate.js +1 -0
  41. package/src/doctor-diagnostics.js +898 -711
  42. package/src/endpoint-extractor.js +401 -401
  43. package/src/fs-atomic.js +71 -69
  44. package/src/git-helper.js +82 -79
  45. package/src/hooks/claude-pre-tool-use.cjs +125 -125
  46. package/src/hooks/worktree-guard.js +854 -848
  47. package/src/index.js +251 -20
  48. package/src/init.js +624 -623
  49. package/src/knowledge-match.js +130 -130
  50. package/src/local-detect.js +128 -120
  51. package/src/machine-interface.js +543 -543
  52. package/src/migrate.js +127 -127
  53. package/src/modules.js +7 -5
  54. package/src/progress/change-registry.js +297 -260
  55. package/src/progress/consistency-doctor.js +355 -355
  56. package/src/progress/shared.js +36 -36
  57. package/src/progress/stage-machine.js +589 -550
  58. package/src/progress/step-store.js +261 -261
  59. package/src/progress.js +1285 -1127
  60. package/src/quicklog.js +724 -693
  61. package/src/review-tier.js +77 -77
  62. package/src/run/command.js +1408 -1364
  63. package/src/run/complete-handlers.js +1450 -1399
  64. package/src/run/complete.js +1181 -920
  65. package/src/run/gates.js +878 -755
  66. package/src/run/prompt.js +765 -761
  67. package/src/run/quick-audit.js +155 -146
  68. package/src/run/shared.js +1348 -1215
  69. package/src/run/stage.js +527 -479
  70. package/src/scan-postcheck.js +371 -371
  71. package/src/setup.js +414 -416
  72. package/src/sillyhub-mcp/client.js +597 -529
  73. package/src/spec-dir-typo.js +3 -24
  74. package/src/spec-sync.js +358 -319
  75. package/src/stage-contract.js +871 -897
  76. package/src/stage-review.js +549 -549
  77. package/src/stages/archive.js +156 -155
  78. package/src/stages/brainstorm-auto.js +236 -236
  79. package/src/stages/brainstorm.js +597 -597
  80. package/src/stages/cmd-existence.js +145 -145
  81. package/src/stages/doctor.js +386 -371
  82. package/src/stages/execute.js +1014 -1003
  83. package/src/stages/explore.js +34 -34
  84. package/src/stages/index.js +29 -29
  85. package/src/stages/knowledge.js +498 -498
  86. package/src/stages/plan-postcheck.js +1323 -1302
  87. package/src/stages/plan.js +642 -623
  88. package/src/stages/quick.js +153 -151
  89. package/src/stages/scan.js +607 -607
  90. package/src/stages/status.js +64 -64
  91. package/src/stages/verify.js +264 -264
  92. package/src/sync.js +1325 -1135
  93. package/src/task-review.js +1204 -1019
  94. package/src/taskcard.js +178 -0
  95. package/src/verify-postcheck.js +1199 -1186
  96. package/src/workflow.js +600 -600
  97. package/src/worktree-apply.js +1237 -1136
  98. package/src/worktree-deps.js +453 -353
  99. package/src/worktree.js +1527 -1481
  100. package/templates/workflows/archive-impact.yaml +79 -79
  101. package/templates/workflows/scan-docs.yaml +132 -132
@@ -1,711 +1,898 @@
1
- /**
2
- * doctor-diagnostics.js — 结构化项目自检(平台模式状态分裂检测)
3
- *
4
- * 为什么需要这个模块:
5
- * 现状 doctor 阶段是 prompt 驱动的 bash 清单,假设单一本地 .sillyspec/,
6
- * 看不见平台模式下的本地/平台状态分裂(孤儿 db、changes 历史断裂、pointer
7
- * 失效静默回退)。本模块补这一层:
8
- * - 维度化检测(D1 多 db / D2 pointer / D3 changes 分裂 / D4 change↔db 一致
9
- * / D5 execute-progress-plan-mismatch execute 派生戳 vs plan.md 声明)
10
- * - 结构化 JSON 输出(sillyspec doctor --json),agent 可直接解析
11
- * - safe_actions:只描述建议动作与风险等级,绝不自动执行
12
- *
13
- * 安全约束(硬性):
14
- * - 所有检测只读。DB 以 node:sqlite 只读连接打开(readOnly + existsSync 前置门),不调用
15
- * export/writeFileSync,不跑建表/迁移,close 后丢弃——绝不写回原 db 文件。
16
- * - 不删除/移动任何文件;orphan db 仅报告,处理交给后续 --dump-db / --confirm 流程。
17
- * - execute-progress-plan-mismatch 维度同样只读:仅读 plan.md checkbox + 只读查 stages 表,
18
- * 绝不调用 ProgressManager 写方法(写操作是 progress.alignExecuteToPlan 的职责,D-001@v2 诊断/写分离)。
19
- *
20
- * 风格对齐 scan-postcheck.js:checks 用 CHECK_SEVERITY,formatter 产出 schema_version JSON,
21
- * writer 落盘到 <authoritySpecDir>/.runtime/。
22
- */
23
- import { openDatabase, pluckGet, pluckAll } from './db-engine.js';
24
- import { existsSync, statSync, readFileSync, readdirSync, mkdirSync, writeFileSync, unlinkSync } from 'fs';
25
- import { join } from 'path';
26
- import { CHECK_SEVERITY } from './constants.js';
27
- import { checkPlatformManaged } from './run/shared.js';
28
-
29
- // db 角色标签
30
- const DB_ROLE = {
31
- AUTHORITY: 'authority', // pointer 指向 / 当前权威
32
- ORPHAN: 'orphan', // 有内容但非权威 → pointer 失效时会静默读到它(定时炸弹)
33
- EMPTY_REMNANT: 'empty_remnant', // 0 字节或不可读的历史占位
34
- };
35
-
36
- // ── pointer 解析 ───────────────────────────────────────────────────────
37
-
38
- function resolvePointer(cwd) {
39
- const pointerPath = join(cwd, '.sillyspec-platform.json');
40
- if (!existsSync(pointerPath)) return { present: false, path: pointerPath };
41
- try {
42
- const ptr = JSON.parse(readFileSync(pointerPath, 'utf8'));
43
- return {
44
- present: true,
45
- path: pointerPath,
46
- specRoot: ptr.specRoot || null,
47
- runtimeRoot: ptr.runtimeRoot || null,
48
- workspaceId: ptr.workspaceId || null,
49
- savedAt: ptr.savedAt || null,
50
- corrupted: !ptr.specRoot,
51
- };
52
- } catch (e) {
53
- return { present: true, path: pointerPath, specRoot: null, corrupted: true, error: e.message };
54
- }
55
- }
56
-
57
- // ── DB 只读探测 ────────────────────────────────────────────────────────
58
-
59
- /**
60
- * 只读打开一个 db 文件,提取诊断信号。绝不写回。
61
- * 返回 null 表示文件不存在。
62
- */
63
- function probeDb(dbPath) {
64
- if (!existsSync(dbPath)) return null;
65
- const st = statSync(dbPath);
66
- const base = { path: dbPath, exists: true, size: st.size, mtime: st.mtime.toISOString() };
67
- if (st.size === 0) {
68
- return { ...base, readable: false, reason: '0 字节文件(历史占位)' };
69
- }
70
- let db = null;
71
- try {
72
- db = openDatabase(dbPath, { readOnly: true });
73
- const pick = (sql, fallback = null) => {
74
- try {
75
- const r = pluckGet(db, sql);
76
- return r === undefined ? fallback : r;
77
- } catch { return fallback; }
78
- };
79
- const pickCol = (sql) => {
80
- try {
81
- return pluckAll(db, sql);
82
- } catch { return []; }
83
- };
84
- // 每个 active change 的 execute stage status(只读查询;stages 表的 status 列)。
85
- // 用于 execute-progress-plan-mismatch 诊断:execute 派生戳 vs plan.md 声明。
86
- // change_name → execute stage status(无 execute 行则 absent)。
87
- const pickExecuteStatusByChange = () => {
88
- try {
89
- const rows = db.prepare(
90
- `SELECT c.name, s.status FROM changes c
91
- LEFT JOIN stages s ON s.change_id = c.id AND s.stage = 'execute'`
92
- ).all();
93
- const out = {};
94
- for (const row of rows) out[row.name] = row.status || null;
95
- return out;
96
- } catch { return {}; }
97
- };
98
- return {
99
- ...base,
100
- readable: true,
101
- schema_version: pick('SELECT schema_version FROM project LIMIT 1'),
102
- change_count: pick('SELECT count(*) FROM changes', 0),
103
- active_changes: pickCol("SELECT name FROM changes WHERE status='active'"),
104
- last_active: pick('SELECT MAX(last_active) FROM changes'),
105
- execute_status_by_change: pickExecuteStatusByChange(),
106
- };
107
- } catch (e) {
108
- return { ...base, readable: false, reason: `打开失败: ${e.message}` };
109
- } finally {
110
- if (db) {
111
- try { db.close(); } catch { /* noop */ }
112
- }
113
- }
114
- }
115
-
116
- function summarizeActive(changes) {
117
- // 完整列表交给 changes_split 维度做对比;db 摘要里只给 count + 前 3 个 sample
118
- return { count: changes.length, sample: changes.slice(0, 3) };
119
- }
120
-
121
- // ── D1 多 db 探测与权威判定 ────────────────────────────────────────────
122
-
123
- function detectMultiDb(cwd, pointer) {
124
- const localSpec = join(cwd, '.sillyspec');
125
- const candidates = [
126
- { path: join(localSpec, 'sillyspec.db'), kind: 'local_root' },
127
- { path: join(localSpec, '.runtime', 'sillyspec.db'), kind: 'local_runtime' },
128
- ];
129
- if (pointer.specRoot) {
130
- candidates.push({ path: join(pointer.specRoot, '.runtime', 'sillyspec.db'), kind: 'platform_runtime' });
131
- candidates.push({ path: join(pointer.specRoot, 'sillyspec.db'), kind: 'platform_root' });
132
- }
133
-
134
- const dbs = [];
135
- for (const c of candidates) {
136
- const probed = probeDb(c.path);
137
- if (!probed) continue;
138
- dbs.push({ kind: c.kind, ...probed });
139
- }
140
-
141
- // 权威判定:pointer 在 → platform_runtime 为权威;否则 local_runtime
142
- const authorityKind = pointer.present && pointer.specRoot ? 'platform_runtime' : 'local_runtime';
143
- for (const d of dbs) {
144
- if (!d.readable) d.role = DB_ROLE.EMPTY_REMNANT;
145
- else if (d.kind === authorityKind) d.role = DB_ROLE.AUTHORITY;
146
- else d.role = DB_ROLE.ORPHAN;
147
- }
148
-
149
- const orphans = dbs.filter((d) => d.role === DB_ROLE.ORPHAN);
150
- const authorities = dbs.filter((d) => d.role === DB_ROLE.AUTHORITY);
151
- const remnants = dbs.filter((d) => d.role === DB_ROLE.EMPTY_REMNANT);
152
-
153
- const findings = [];
154
- let pass = true;
155
- let severity = null;
156
- const safeActions = [];
157
-
158
- if (orphans.length > 0) {
159
- pass = false;
160
- severity = CHECK_SEVERITY.FAILED;
161
- for (const o of orphans) {
162
- findings.push(
163
- `孤儿 db [${o.kind}] ${o.path}:${o.size}B,${o.change_count} changes,最后写入 ${o.last_active || '?'};` +
164
- `pointer 指向 ${authorityKind},此 db 无人读——pointer 失效时 resolvePlatformSpecDir 会静默回退到这个过期状态`
165
- );
166
- safeActions.push({
167
- dimension: 'multi_db',
168
- action: 'dump_and_classify_orphan_db',
169
- target: o.path,
170
- risk: 'read_only',
171
- rationale: `孤儿 db 含 ${o.change_count} 个 change 的进度但已无人读;先 dump 到 .runtime/doctor-dumps/ 再决定导入/归档,绝不直接删除`,
172
- next_step: '后续实现 sillyspec doctor --dump-db --path <path> 后可直接执行',
173
- });
174
- }
175
- }
176
- if (authorities.length === 0 && dbs.some((d) => d.readable)) {
177
- pass = false;
178
- severity = CHECK_SEVERITY.FAILED;
179
- findings.push(`未找到权威 db(期望 ${authorityKind},但该位置无有效 db),却存在其它可读 db——状态真相源缺失`);
180
- }
181
- for (const r of remnants) {
182
- findings.push(`空占位 db [${r.kind}] ${r.path}:${r.size}B——历史遗留,建议清理`);
183
- safeActions.push({
184
- dimension: 'multi_db',
185
- action: 'remove_empty_remnant',
186
- target: r.path,
187
- risk: 'confirm_required',
188
- rationale: '0 字节占位文件,无数据,可安全删除',
189
- next_step: 'sillyspec doctor --cleanup-remnant --path <path>(需 --confirm)',
190
- });
191
- }
192
- if (pass && findings.length === 0) {
193
- findings.push(`db 状态健康:权威为 ${authorityKind},无孤儿`);
194
- }
195
-
196
- return {
197
- name: 'multi_db',
198
- label: '多 db 探测与权威判定',
199
- pass,
200
- severity,
201
- findings,
202
- safe_actions: safeActions,
203
- dbs: dbs.map((d) => ({
204
- kind: d.kind,
205
- role: d.role,
206
- path: d.path,
207
- size: d.size,
208
- mtime: d.mtime,
209
- readable: d.readable,
210
- schema_version: d.schema_version,
211
- change_count: d.change_count,
212
- last_active: d.last_active,
213
- active: d.readable ? summarizeActive(d.active_changes) : null,
214
- })),
215
- };
216
- }
217
-
218
- // ── D2 pointer 健康 ───────────────────────────────────────────────────
219
-
220
- function detectPointerHealth(pointer, cwd = null) {
221
- if (!pointer.present) {
222
- // FR-06:指针缺失但接管声明存在 → 报 pointer_missing_but_managed(warning 非阻断)。
223
- // 此状态 resolvePlatformSpecDir / runCommand 恢复链会 fail-closed 拒绝静默落本地,
224
- // doctor 只读诊断给出同款恢复引导(不自动执行任何修复)。
225
- if (cwd) {
226
- const decl = checkPlatformManaged(cwd);
227
- if (decl) {
228
- return {
229
- name: 'pointer_health',
230
- label: '平台指针健康',
231
- pass: false,
232
- severity: CHECK_SEVERITY.WARNING,
233
- findings: [
234
- `pointer_missing_but_managed: 平台接管声明存在但恢复指针缺失(原 specRoot: ${decl.specRoot || '(未记录)'})——` +
235
- `裸调命令将 fail-closed 拒绝静默落本地。恢复:① 重跑平台 scan/init(带 --spec-root)重建指针;` +
236
- `② sillyspec platform disconnect(删除接管声明,彻底脱离平台);③ 显式 --spec-dir <路径> 临时指定。`,
237
- ],
238
- safe_actions: [{ dimension: 'pointer_health', action: 'recreate_pointer', risk: 'confirm_required', rationale: '接管声明存在但指针缺失,需重建指针或显式脱离平台', next_step: '重跑平台 scan(带 --spec-root)或 sillyspec platform disconnect' }],
239
- };
240
- }
241
- }
242
- return {
243
- name: 'pointer_health',
244
- label: '平台指针健康',
245
- pass: true,
246
- severity: null,
247
- findings: ['无平台 pointer,本地模式(无平台分裂风险)'],
248
- safe_actions: [],
249
- };
250
- }
251
- const findings = [];
252
- let pass = true;
253
- let severity = null;
254
- if (pointer.corrupted) {
255
- return {
256
- name: 'pointer_health',
257
- label: '平台指针健康',
258
- pass: false,
259
- severity: CHECK_SEVERITY.FAILED,
260
- findings: [`pointer 损坏: ${pointer.error || '缺少 specRoot 字段'}——建议删除后重跑平台 scan`],
261
- safe_actions: [{ dimension: 'pointer_health', action: 'recreate_pointer', risk: 'confirm_required', rationale: 'pointer 损坏,需重建', next_step: 'sillyspec platform pointer --cleanup 后重新 scan' }],
262
- };
263
- }
264
- const reachable = !!pointer.specRoot && existsSync(pointer.specRoot);
265
- if (!reachable) {
266
- pass = false;
267
- severity = CHECK_SEVERITY.FAILED;
268
- findings.push(
269
- `pointer.specRoot 不可达: ${pointer.specRoot}(daemon 未起?已迁移?)——` +
270
- `resolvePlatformSpecDir 当前会静默回退到本地孤儿 db,这是危险行为(建议改 fail-closed)`
271
- );
272
- }
273
- const authDb = pointer.specRoot ? join(pointer.specRoot, '.runtime', 'sillyspec.db') : null;
274
- if (authDb && !existsSync(authDb)) {
275
- pass = false;
276
- severity = severity || CHECK_SEVERITY.WARNING;
277
- findings.push(`权威 db 不存在: ${authDb}`);
278
- }
279
- if (pass) findings.push(`pointer 正常,specRoot 可达`);
280
- return {
281
- name: 'pointer_health',
282
- label: '平台指针健康',
283
- pass,
284
- severity,
285
- findings,
286
- safe_actions: [],
287
- pointer: { specRoot: pointer.specRoot, runtimeRoot: pointer.runtimeRoot, reachable, savedAt: pointer.savedAt },
288
- };
289
- }
290
-
291
- // ── D3 本地/平台 changes 分裂 ──────────────────────────────────────────
292
-
293
- function listChanges(specRoot) {
294
- const dir = join(specRoot, 'changes');
295
- if (!existsSync(dir)) return [];
296
- try {
297
- return readdirSync(dir, { withFileTypes: true })
298
- .filter((d) => d.isDirectory() && d.name !== 'archive')
299
- .map((d) => d.name);
300
- } catch {
301
- return [];
302
- }
303
- }
304
-
305
- function detectChangesSplit(cwd, pointer) {
306
- const localChanges = listChanges(join(cwd, '.sillyspec'));
307
- const findings = [];
308
- let pass = true;
309
- let severity = null;
310
- const dim = {
311
- name: 'changes_split',
312
- label: '本地/平台 changes 分裂',
313
- local: localChanges,
314
- safe_actions: [],
315
- };
316
-
317
- if (!(pointer.present && pointer.specRoot && existsSync(pointer.specRoot))) {
318
- dim.platform = [];
319
- dim.findings = ['无可达平台 specRoot,仅本地 changes(无分裂可比较)'];
320
- return { ...dim, pass: true, severity: null };
321
- }
322
-
323
- const platformChanges = listChanges(pointer.specRoot);
324
- dim.platform = platformChanges;
325
- const platformSet = new Set(platformChanges);
326
- const localSet = new Set(localChanges);
327
- dim.local_only = localChanges.filter((c) => !platformSet.has(c));
328
- dim.platform_only = platformChanges.filter((c) => !localSet.has(c));
329
- dim.intersection = localChanges.filter((c) => platformSet.has(c));
330
-
331
- const lo = dim.local_only.length;
332
- const po = dim.platform_only.length;
333
- const ix = dim.intersection.length;
334
-
335
- if (lo > 0 && po > 0 && ix === 0) {
336
- pass = false;
337
- severity = CHECK_SEVERITY.FAILED;
338
- findings.push(
339
- `严重分裂:本地 ${localChanges.length} 个 changes 与平台 ${platformChanges.length} 个 零交集——` +
340
- `本地→平台模式切换时迁移未完成,变更历史断裂(6月在本地、7月在平台各走各的)`
341
- );
342
- dim.safe_actions.push({
343
- dimension: 'changes_split',
344
- action: 'migrate_local_changes',
345
- risk: 'confirm_required',
346
- rationale: `本地 ${lo} 个变更平台没有,需决定逐个导入进度 or 归档;属于数据迁移,必须逐项确认`,
347
- next_step: '后续实现 sillyspec doctor --merge-local-to-platform(带 --confirm)',
348
- });
349
- } else if (lo > 3 || po > 3) {
350
- pass = false;
351
- severity = CHECK_SEVERITY.WARNING;
352
- findings.push(`部分分裂:local_only ${lo},platform_only ${po},intersection ${ix}`);
353
- } else {
354
- findings.push(`本地/平台 changes 基本同步(intersection ${ix},local_only ${lo},platform_only ${po})`);
355
- }
356
-
357
- return { ...dim, pass, severity, findings };
358
- }
359
-
360
- // ── D4 change↔db 一致性(针对权威 db + 权威 changes 目录)──────────────
361
-
362
- function detectChangeDbConsistency(cwd, pointer, multiDb) {
363
- const auth = (multiDb.dbs || []).find((d) => d.role === DB_ROLE.AUTHORITY);
364
- if (!auth) {
365
- return {
366
- name: 'change_db_consistency',
367
- label: 'change↔db 一致性',
368
- pass: true,
369
- severity: null,
370
- findings: ['无权威 db,跳过一致性校验'],
371
- safe_actions: [],
372
- };
373
- }
374
- const authoritySpecRoot = pointer.present && pointer.specRoot ? pointer.specRoot : join(cwd, '.sillyspec');
375
- const dirChanges = listChanges(authoritySpecRoot);
376
- const dbActive = auth.active || { count: 0, sample: [] };
377
- // active.count 是权威 db 的 active change 数;但 listChanges 给的是目录数,两者口径要对齐
378
- // 这里用 active_changes 全量需从 probe 取——summary 只给了 count/sample,
379
- // 所以用 count 与目录数做粗对齐,细对齐留给后续 dump。
380
- const dirCount = dirChanges.length;
381
- const dbCount = dbActive.count;
382
- const findings = [];
383
- let pass = true;
384
- let severity = null;
385
- // 仅当差异显著才告警(口径不完全可比,避免误报)
386
- if (Math.abs(dirCount - dbCount) > Math.max(2, dirCount * 0.2)) {
387
- pass = false;
388
- severity = CHECK_SEVERITY.WARNING;
389
- findings.push(
390
- `权威 db active changes (${dbCount}) 与 changes/ 目录数 (${dirCount}) 差异显著——` +
391
- `可能存在孤儿目录(有目录无 db 记录)或幽灵记录(db 有记录无目录),需 dump 后细对齐`
392
- );
393
- } else {
394
- findings.push(`权威 db active changes (${dbCount}) 与 changes/ 目录数 (${dirCount}) 基本一致`);
395
- }
396
- return {
397
- name: 'change_db_consistency',
398
- label: 'change↔db 一致性',
399
- pass,
400
- severity,
401
- findings,
402
- safe_actions: [],
403
- authority_spec_root: authoritySpecRoot,
404
- dir_count: dirCount,
405
- db_active_count: dbCount,
406
- };
407
- }
408
-
409
- // ── D5 execute-progress-plan-mismatch(execute 派生戳 vs plan.md 声明)─────
410
-
411
- /**
412
- * 只读解析 plan.md(回退 tasks.md)的 task checkbox 全勾状态。
413
- * 真相源语义对齐 run.js:832 + execute.js 的 checkbox 格式:
414
- * `- [ ] task-NN: ...` / `- [x] task-NN: ...`
415
- * 只读文件,不调用任何 ProgressManager 写方法(D-001@v2 诊断/写分离)。
416
- * @returns {{total:number, checked:number} | null} null = plan/tasks.md 都不存在或无 task 行
417
- */
418
- function readPlanCheckboxStatus(changeDir) {
419
- const planPath = join(changeDir, 'plan.md');
420
- const tasksPath = join(changeDir, 'tasks.md');
421
- let content = null;
422
- if (existsSync(planPath)) {
423
- try { content = readFileSync(planPath, 'utf8'); } catch { content = null; }
424
- }
425
- if (content == null && existsSync(tasksPath)) {
426
- try { content = readFileSync(tasksPath, 'utf8'); } catch { content = null; }
427
- }
428
- if (content == null) return null;
429
- // match both "- [ ] task-01: title" and "- [x] task-01: title"
430
- const taskLine = /(?:^- \[[ x]\] )task-\d+[^:]*:?\s*.+$/gm;
431
- const matches = content.match(taskLine) || [];
432
- if (matches.length === 0) return null;
433
- const checked = matches.filter((l) => /^- \[x\] task-\d+/i.test(l)).length;
434
- return { total: matches.length, checked };
435
- }
436
-
437
- /**
438
- * D5 诊断维度(advisory,CHECK_SEVERITY.WARNING,不阻断):
439
- * 触发条件:change 的 execute stage status≠completed,且其 plan.md 所有 task checkbox 全勾。
440
- * 命中即输出 safe_action 建议显式对齐命令(绝不执行)。
441
- * 只读:仅读 changes/<name>/plan.md + 只读用 probeDb 已取回的 execute stage status 字段,
442
- * 绝不写 db、不动文件(D-001@v2)。
443
- */
444
- function detectExecuteProgressPlanMismatch(authoritySpecRoot, authDb) {
445
- const dim = {
446
- name: 'execute-progress-plan-mismatch',
447
- label: 'execute 派生戳 vs plan.md 声明',
448
- safe_actions: [],
449
- findings: [],
450
- per_change: [],
451
- };
452
- if (!authoritySpecRoot || !existsSync(authoritySpecRoot)) {
453
- dim.pass = true;
454
- dim.severity = null;
455
- dim.findings = ['无权威 specRoot,跳过 execute-progress-plan-mismatch 诊断'];
456
- return dim;
457
- }
458
- const executeStatusByChange = (authDb && authDb.execute_status_by_change) || {};
459
- const changes = listChanges(authoritySpecRoot);
460
- let triggered = false;
461
- let severity = null;
462
- for (const name of changes) {
463
- const planStatus = readPlanCheckboxStatus(join(authoritySpecRoot, 'changes', name));
464
- if (!planStatus || planStatus.total === 0) {
465
- dim.per_change.push({ change: name, plan_total: 0, skipped: '无 task checkbox' });
466
- continue;
467
- }
468
- const execStatus = executeStatusByChange[name] !== undefined
469
- ? executeStatusByChange[name]
470
- : null;
471
- dim.per_change.push({
472
- change: name,
473
- plan_total: planStatus.total,
474
- plan_checked: planStatus.checked,
475
- execute_status: execStatus,
476
- });
477
- const allChecked = planStatus.checked >= planStatus.total;
478
- const execNotCompleted = execStatus !== 'completed';
479
- // 触发:execute status≠completed 且 plan.md 全勾
480
- if (allChecked && execNotCompleted) {
481
- triggered = true;
482
- severity = CHECK_SEVERITY.WARNING;
483
- dim.findings.push(
484
- `change [${name}]:plan.md ${planStatus.checked}/${planStatus.total} task 全勾,` +
485
- `但 execute stage status=${execStatus || 'absent'}(≠completed)——派生戳未对齐真相源声明`
486
- );
487
- dim.safe_actions.push({
488
- dimension: 'execute-progress-plan-mismatch',
489
- action: `sillyspec doctor --align-execute-progress --change ${name}`,
490
- risk: 'low',
491
- rationale: `plan.md 声明全完成但 execute 派生戳未对齐(${planStatus.checked}/${planStatus.total} 全勾,execute status=${execStatus || 'absent'});doctor 信任 plan.md 声明,对齐由 --confirm 显式触发`,
492
- next_step: 'sillyspec doctor --align-execute-progress --change ' + name + ' --confirm',
493
- });
494
- }
495
- }
496
- dim.pass = !triggered;
497
- dim.severity = triggered ? CHECK_SEVERITY.WARNING : null;
498
- if (!triggered) {
499
- dim.findings.push(
500
- changes.length === 0
501
- ? '无 changes,无 execute-progress-plan-mismatch 可检测'
502
- : `所有 ${changes.length} 个 change 的 execute 派生戳与 plan.md 声明一致(或 plan.md 未全勾)`
503
- );
504
- }
505
- return dim;
506
- }
507
-
508
- // ── 主入口 ────────────────────────────────────────────────────────────
509
-
510
- export async function runDoctorDiagnostics({ cwd }) {
511
- const pointer = resolvePointer(cwd);
512
- const multiDb = detectMultiDb(cwd, pointer);
513
- const pointerHealth = detectPointerHealth(pointer, cwd);
514
- const changesSplit = detectChangesSplit(cwd, pointer);
515
- const changeDb = detectChangeDbConsistency(cwd, pointer, multiDb);
516
-
517
- // 权威 specDir(用于 D5 execute-progress-plan-mismatch 读 plan.md)
518
- const authoritySpecDir =
519
- pointer.present && pointer.specRoot && existsSync(pointer.specRoot)
520
- ? pointer.specRoot
521
- : existsSync(join(cwd, '.sillyspec'))
522
- ? join(cwd, '.sillyspec')
523
- : null;
524
- const authDb = (multiDb.dbs || []).find((d) => d.role === DB_ROLE.AUTHORITY);
525
- const executeMismatch = detectExecuteProgressPlanMismatch(authoritySpecDir, authDb);
526
-
527
- const dimensions = [multiDb, pointerHealth, changesSplit, changeDb, executeMismatch];
528
-
529
- return {
530
- dimensions,
531
- pointer_summary: pointer.present
532
- ? { present: true, specRoot: pointer.specRoot, corrupted: !!pointer.corrupted, savedAt: pointer.savedAt }
533
- : { present: false },
534
- dbs_summary: multiDb.dbs,
535
- authoritySpecDir,
536
- };
537
- }
538
-
539
- /**
540
- * 转结构化 JSON(sillyspec doctor --json 输出 + 落盘格式)。
541
- * severity 口径对齐 scan-postcheck:CHECK_SEVERITY.FAILED → 'critical'。
542
- */
543
- export function formatDoctorJson(result, meta = {}) {
544
- const dims = result.dimensions;
545
- const critical = dims.filter((d) => d.severity === CHECK_SEVERITY.FAILED).length;
546
- const warning = dims.filter((d) => d.severity === CHECK_SEVERITY.WARNING).length;
547
- const overallStatus = critical > 0 ? 'failed' : warning > 0 ? 'warning' : 'pass';
548
-
549
- const safeActions = [];
550
- for (const d of dims) {
551
- for (const a of d.safe_actions || []) safeActions.push({ dimension: d.name, ...a });
552
- }
553
-
554
- return {
555
- schema_version: 1,
556
- generated_at: new Date().toISOString(),
557
- tool: 'sillyspec-doctor',
558
- overall_status: overallStatus,
559
- summary: { critical, warning, total_dimensions: dims.length },
560
- ...(meta.source_root ? { source_root: meta.source_root } : {}),
561
- ...(result.authoritySpecDir ? { authority_spec_dir: result.authoritySpecDir } : {}),
562
- pointer: result.pointer_summary,
563
- dbs: result.dbs_summary.map((d) => ({
564
- kind: d.kind,
565
- role: d.role,
566
- path: d.path,
567
- size: d.size,
568
- mtime: d.mtime,
569
- change_count: d.change_count,
570
- last_active: d.last_active,
571
- })),
572
- dimensions: dims.map((d) => {
573
- const out = {
574
- name: d.name,
575
- label: d.label,
576
- pass: d.pass,
577
- severity: d.severity === CHECK_SEVERITY.FAILED ? 'critical' : d.severity,
578
- evidence: (d.findings || []).join('; '),
579
- };
580
- if (d.name === 'changes_split') {
581
- out.local_count = d.local.length;
582
- out.platform_count = d.platform.length;
583
- out.local_only_count = (d.local_only || []).length;
584
- out.platform_only_count = (d.platform_only || []).length;
585
- out.intersection_count = (d.intersection || []).length;
586
- out.local_only_sample = (d.local_only || []).slice(0, 8);
587
- out.platform_only_sample = (d.platform_only || []).slice(0, 8);
588
- }
589
- return out;
590
- }),
591
- safe_actions: safeActions,
592
- };
593
- }
594
-
595
- /**
596
- * 落盘到 <authoritySpecDir>/.runtime/doctor-diagnosis.json(对齐 scan-postcheck.writeStructuredResult)。
597
- */
598
- export function writeDoctorDiagnosis(json, specDir) {
599
- if (!specDir) return null;
600
- try {
601
- const runtimeDir = join(specDir, '.runtime');
602
- mkdirSync(runtimeDir, { recursive: true });
603
- const outPath = join(runtimeDir, 'doctor-diagnosis.json');
604
- writeFileSync(outPath, JSON.stringify(json, null, 2) + '\n');
605
- return outPath;
606
- } catch (e) {
607
- console.warn(`⚠️ doctor-diagnosis.json 写入失败: ${e.message}`);
608
- return null;
609
- }
610
- }
611
-
612
- // ── 执行流:安全清理 + 取证 dump ───────────────────────────────────────
613
- //
614
- // 让 doctor --json 输出的 safe_actions 真正可执行。安全约束不变:
615
- // cleanup 只删 0 字节空占位(probeDb 判定 empty_remnant),绝不碰有内容的 db;
616
- // dump 纯只读。destructive 操作必须 --confirm。
617
-
618
- /**
619
- * 清理 0 字节空占位 db(历史遗留)。默认 dry-run,--confirm 才真删。
620
- * 只删 size===0 的文件;有内容的 db 一律不动。
621
- */
622
- export async function cleanupRemnantDbs({ cwd, confirm = false }) {
623
- const pointer = resolvePointer(cwd);
624
- const localSpec = join(cwd, '.sillyspec');
625
- const candidates = [
626
- join(localSpec, 'sillyspec.db'),
627
- join(localSpec, '.runtime', 'sillyspec.db'),
628
- ];
629
- if (pointer.specRoot) {
630
- candidates.push(join(pointer.specRoot, '.runtime', 'sillyspec.db'));
631
- candidates.push(join(pointer.specRoot, 'sillyspec.db'));
632
- }
633
- const remnants = [];
634
- for (const p of candidates) {
635
- if (!existsSync(p)) continue;
636
- try {
637
- const st = statSync(p);
638
- if (st.size === 0) remnants.push({ path: p, size: 0, mtime: st.mtime.toISOString() });
639
- } catch { /* stat 失败跳过 */ }
640
- }
641
- const deleted = [];
642
- const errors = [];
643
- if (confirm) {
644
- for (const r of remnants) {
645
- try {
646
- unlinkSync(r.path);
647
- deleted.push(r.path);
648
- } catch (e) {
649
- errors.push({ path: r.path, error: e.message });
650
- }
651
- }
652
- }
653
- return {
654
- action: confirm ? 'deleted' : 'dry_run',
655
- would_delete: remnants,
656
- deleted,
657
- errors,
658
- count: remnants.length,
659
- };
660
- }
661
-
662
- /**
663
- * 把任意 db 文件的内容 dump 成 JSON(schema_version + 全量 changes + stages)。
664
- * 落盘到 <authoritySpecDir>/.runtime/doctor-dumps/dump-<ts>.json。纯只读。
665
- */
666
- export async function dumpDb({ dbPath, cwd }) {
667
- const pointer = resolvePointer(cwd);
668
- const authoritySpecDir = (pointer.present && pointer.specRoot && existsSync(pointer.specRoot))
669
- ? pointer.specRoot
670
- : (existsSync(join(cwd, '.sillyspec')) ? join(cwd, '.sillyspec') : null);
671
- if (!existsSync(dbPath)) {
672
- return { ok: false, error: `db 文件不存在: ${dbPath}`, path: dbPath };
673
- }
674
- const st = statSync(dbPath);
675
- const meta = { path: dbPath, size: st.size, mtime: st.mtime.toISOString(), dumped_at: new Date().toISOString() };
676
- if (st.size === 0) {
677
- return writeDump({ ok: true, meta, note: '0 字节文件,无表数据', changes: [], stages: [] }, authoritySpecDir);
678
- }
679
- let db = null;
680
- try {
681
- db = openDatabase(dbPath, { readOnly: true });
682
- const rows = (sql) => {
683
- try { return db.prepare(sql).all(); } catch { return []; }
684
- };
685
- const sv = rows('SELECT schema_version FROM project LIMIT 1');
686
- const schemaVersion = sv.length ? sv[0].schema_version : null;
687
- const changes = rows('SELECT name, current_stage, status, created_at, last_active FROM changes ORDER BY last_active DESC')
688
- .map((r) => ({ name: r.name, current_stage: r.current_stage, status: r.status, created_at: r.created_at, last_active: r.last_active }));
689
- const stages = rows('SELECT c.name, s.stage, s.status, s.started_at, s.completed_at FROM stages s JOIN changes c ON s.change_id = c.id ORDER BY c.name, s.stage')
690
- .map((r) => ({ change: r.name, stage: r.stage, status: r.status, started_at: r.started_at, completed_at: r.completed_at }));
691
- return writeDump({ ok: true, meta, schema_version: schemaVersion, changes, stages }, authoritySpecDir);
692
- } catch (e) {
693
- return writeDump({ ok: false, meta, error: `读取失败: ${e.message}` }, authoritySpecDir);
694
- } finally {
695
- if (db) { try { db.close(); } catch { /* noop */ } }
696
- }
697
- }
698
-
699
- function writeDump(result, authoritySpecDir) {
700
- if (!authoritySpecDir) return result; // 无处落盘也把内容返回给调用方
701
- try {
702
- const outDir = join(authoritySpecDir, '.runtime', 'doctor-dumps');
703
- mkdirSync(outDir, { recursive: true });
704
- const ts = new Date().toISOString().replace(/[:.]/g, '-');
705
- const outPath = join(outDir, `dump-${ts}.json`);
706
- writeFileSync(outPath, JSON.stringify(result, null, 2) + '\n');
707
- return { ...result, written_to: outPath };
708
- } catch (e) {
709
- return { ...result, dump_write_error: e.message };
710
- }
711
- }
1
+ /**
2
+ * doctor-diagnostics.js — 结构化项目自检(平台模式状态分裂检测)
3
+ *
4
+ * 为什么需要这个模块:
5
+ * 现状 doctor 阶段是 prompt 驱动的 bash 清单,假设单一本地 .sillyspec/,
6
+ * 看不见平台模式下的本地/平台状态分裂(孤儿 db、changes 历史断裂、pointer
7
+ * 失效静默回退)。本模块补这一层:
8
+ * - 维度化检测(D1 多 db / D2 pointer / D3 changes 分裂 / D4 change↔db 一致
9
+ * / D5 execute-progress-plan-mismatch execute 派生戳 vs plan.md 声明)
10
+ * - 结构化 JSON 输出(sillyspec doctor --json),agent 可直接解析
11
+ * - safe_actions:只描述建议动作与风险等级,绝不自动执行
12
+ *
13
+ * 安全约束(硬性):
14
+ * - 所有检测只读。DB 以 node:sqlite 只读连接打开(readOnly + existsSync 前置门),不调用
15
+ * export/writeFileSync,不跑建表/迁移,close 后丢弃——绝不写回原 db 文件。
16
+ * - 不删除/移动任何文件;orphan db 仅报告,处理交给后续 --dump-db / --confirm 流程。
17
+ * - execute-progress-plan-mismatch 维度同样只读:仅读 plan.md checkbox + 只读查 stages 表,
18
+ * 绝不调用 ProgressManager 写方法(写操作是 progress.alignExecuteToPlan 的职责,D-001@v2 诊断/写分离)。
19
+ *
20
+ * 风格对齐 scan-postcheck.js:checks 用 CHECK_SEVERITY,formatter 产出 schema_version JSON,
21
+ * writer 落盘到 <authoritySpecDir>/.runtime/。
22
+ */
23
+ import { openDatabase, pluckGet, pluckAll } from './db-engine.js';
24
+ import { existsSync, statSync, readFileSync, readdirSync, mkdirSync, writeFileSync, unlinkSync, rmSync } from 'fs';
25
+ import { join } from 'path';
26
+ import { CHECK_SEVERITY } from './constants.js';
27
+ import { checkPlatformManaged } from './run/shared.js';
28
+
29
+ // db 角色标签
30
+ const DB_ROLE = {
31
+ AUTHORITY: 'authority', // pointer 指向 / 当前权威
32
+ ORPHAN: 'orphan', // 有内容但非权威 → pointer 失效时会静默读到它(定时炸弹)
33
+ EMPTY_REMNANT: 'empty_remnant', // 0 字节或不可读的历史占位
34
+ };
35
+
36
+ // ── pointer 解析 ───────────────────────────────────────────────────────
37
+
38
+ function resolvePointer(cwd) {
39
+ const pointerPath = join(cwd, '.sillyspec-platform.json');
40
+ if (!existsSync(pointerPath)) return { present: false, path: pointerPath };
41
+ try {
42
+ const ptr = JSON.parse(readFileSync(pointerPath, 'utf8'));
43
+ return {
44
+ present: true,
45
+ path: pointerPath,
46
+ specRoot: ptr.specRoot || null,
47
+ runtimeRoot: ptr.runtimeRoot || null,
48
+ workspaceId: ptr.workspaceId || null,
49
+ savedAt: ptr.savedAt || null,
50
+ corrupted: !ptr.specRoot,
51
+ };
52
+ } catch (e) {
53
+ return { present: true, path: pointerPath, specRoot: null, corrupted: true, error: e.message };
54
+ }
55
+ }
56
+
57
+ // ── DB 只读探测 ────────────────────────────────────────────────────────
58
+
59
+ /**
60
+ * 只读打开一个 db 文件,提取诊断信号。绝不写回。
61
+ * 返回 null 表示文件不存在。
62
+ */
63
+ function probeDb(dbPath) {
64
+ if (!existsSync(dbPath)) return null;
65
+ const st = statSync(dbPath);
66
+ const base = { path: dbPath, exists: true, size: st.size, mtime: st.mtime.toISOString() };
67
+ if (st.size === 0) {
68
+ return { ...base, readable: false, reason: '0 字节文件(历史占位)' };
69
+ }
70
+ let db = null;
71
+ try {
72
+ db = openDatabase(dbPath, { readOnly: true });
73
+ const pick = (sql, fallback = null) => {
74
+ try {
75
+ const r = pluckGet(db, sql);
76
+ return r === undefined ? fallback : r;
77
+ } catch { return fallback; }
78
+ };
79
+ const pickCol = (sql) => {
80
+ try {
81
+ return pluckAll(db, sql);
82
+ } catch { return []; }
83
+ };
84
+ // 每个 active change 的 execute stage status(只读查询;stages 表的 status 列)。
85
+ // 用于 execute-progress-plan-mismatch 诊断:execute 派生戳 vs plan.md 声明。
86
+ // change_name → execute stage status(无 execute 行则 absent)。
87
+ const pickExecuteStatusByChange = () => {
88
+ try {
89
+ const rows = db.prepare(
90
+ `SELECT c.name, s.status FROM changes c
91
+ LEFT JOIN stages s ON s.change_id = c.id AND s.stage = 'execute'`
92
+ ).all();
93
+ const out = {};
94
+ for (const row of rows) out[row.name] = row.status || null;
95
+ return out;
96
+ } catch { return {}; }
97
+ };
98
+ return {
99
+ ...base,
100
+ readable: true,
101
+ schema_version: pick('SELECT schema_version FROM project LIMIT 1'),
102
+ change_count: pick('SELECT count(*) FROM changes', 0),
103
+ active_changes: pickCol("SELECT name FROM changes WHERE status='active'"),
104
+ // SS-2b(2026-08-20):active 行 name → last_active 映射,供 D4 空壳判定做时间门槛
105
+ //(刚创建的合法变更目录本来就是空的,见 GHOST_EMPTY_DIR_STALE_MS 注释)。
106
+ active_last_active: (() => {
107
+ try {
108
+ const rows = db.prepare("SELECT name, last_active FROM changes WHERE status='active'").all();
109
+ return Object.fromEntries(rows.map((r) => [r.name, r.last_active]));
110
+ } catch { return {}; }
111
+ })(),
112
+ last_active: pick('SELECT MAX(last_active) FROM changes'),
113
+ execute_status_by_change: pickExecuteStatusByChange(),
114
+ };
115
+ } catch (e) {
116
+ return { ...base, readable: false, reason: `打开失败: ${e.message}` };
117
+ } finally {
118
+ if (db) {
119
+ try { db.close(); } catch { /* noop */ }
120
+ }
121
+ }
122
+ }
123
+
124
+ function summarizeActive(changes) {
125
+ // 完整列表交给 changes_split 维度做对比;db 摘要里只给 count + 前 3 个 sample
126
+ return { count: changes.length, sample: changes.slice(0, 3) };
127
+ }
128
+
129
+ // ── D1 多 db 探测与权威判定 ────────────────────────────────────────────
130
+
131
+ function detectMultiDb(cwd, pointer) {
132
+ const localSpec = join(cwd, '.sillyspec');
133
+ const candidates = [
134
+ { path: join(localSpec, 'sillyspec.db'), kind: 'local_root' },
135
+ { path: join(localSpec, '.runtime', 'sillyspec.db'), kind: 'local_runtime' },
136
+ ];
137
+ if (pointer.specRoot) {
138
+ candidates.push({ path: join(pointer.specRoot, '.runtime', 'sillyspec.db'), kind: 'platform_runtime' });
139
+ candidates.push({ path: join(pointer.specRoot, 'sillyspec.db'), kind: 'platform_root' });
140
+ }
141
+
142
+ const dbs = [];
143
+ for (const c of candidates) {
144
+ const probed = probeDb(c.path);
145
+ if (!probed) continue;
146
+ dbs.push({ kind: c.kind, ...probed });
147
+ }
148
+
149
+ // 权威判定:pointer 在 → platform_runtime 为权威;否则 local_runtime
150
+ const authorityKind = pointer.present && pointer.specRoot ? 'platform_runtime' : 'local_runtime';
151
+ for (const d of dbs) {
152
+ if (!d.readable) d.role = DB_ROLE.EMPTY_REMNANT;
153
+ else if (d.kind === authorityKind) d.role = DB_ROLE.AUTHORITY;
154
+ else d.role = DB_ROLE.ORPHAN;
155
+ }
156
+
157
+ const orphans = dbs.filter((d) => d.role === DB_ROLE.ORPHAN);
158
+ const authorities = dbs.filter((d) => d.role === DB_ROLE.AUTHORITY);
159
+ const remnants = dbs.filter((d) => d.role === DB_ROLE.EMPTY_REMNANT);
160
+
161
+ const findings = [];
162
+ let pass = true;
163
+ let severity = null;
164
+ const safeActions = [];
165
+
166
+ if (orphans.length > 0) {
167
+ pass = false;
168
+ severity = CHECK_SEVERITY.FAILED;
169
+ for (const o of orphans) {
170
+ findings.push(
171
+ `孤儿 db [${o.kind}] ${o.path}:${o.size}B,${o.change_count} changes,最后写入 ${o.last_active || '?'};` +
172
+ `pointer 指向 ${authorityKind},此 db 无人读——pointer 失效时 resolvePlatformSpecDir 会静默回退到这个过期状态`
173
+ );
174
+ safeActions.push({
175
+ dimension: 'multi_db',
176
+ action: 'dump_and_classify_orphan_db',
177
+ target: o.path,
178
+ risk: 'read_only',
179
+ rationale: `孤儿 db 含 ${o.change_count} 个 change 的进度但已无人读;先 dump 到 .runtime/doctor-dumps/ 再决定导入/归档,绝不直接删除`,
180
+ next_step: '后续实现 sillyspec doctor --dump-db --path <path> 后可直接执行',
181
+ });
182
+ }
183
+ }
184
+ if (authorities.length === 0 && dbs.some((d) => d.readable)) {
185
+ pass = false;
186
+ severity = CHECK_SEVERITY.FAILED;
187
+ findings.push(`未找到权威 db(期望 ${authorityKind},但该位置无有效 db),却存在其它可读 db——状态真相源缺失`);
188
+ }
189
+ for (const r of remnants) {
190
+ findings.push(`空占位 db [${r.kind}] ${r.path}:${r.size}B——历史遗留,建议清理`);
191
+ safeActions.push({
192
+ dimension: 'multi_db',
193
+ action: 'remove_empty_remnant',
194
+ target: r.path,
195
+ risk: 'confirm_required',
196
+ rationale: '0 字节占位文件,无数据,可安全删除',
197
+ next_step: 'sillyspec doctor --cleanup-remnant --path <path>(需 --confirm)',
198
+ });
199
+ }
200
+ if (pass && findings.length === 0) {
201
+ findings.push(`db 状态健康:权威为 ${authorityKind},无孤儿`);
202
+ }
203
+
204
+ return {
205
+ name: 'multi_db',
206
+ label: '多 db 探测与权威判定',
207
+ pass,
208
+ severity,
209
+ findings,
210
+ safe_actions: safeActions,
211
+ dbs: dbs.map((d) => ({
212
+ kind: d.kind,
213
+ role: d.role,
214
+ path: d.path,
215
+ size: d.size,
216
+ mtime: d.mtime,
217
+ readable: d.readable,
218
+ schema_version: d.schema_version,
219
+ change_count: d.change_count,
220
+ last_active: d.last_active,
221
+ active: d.readable ? summarizeActive(d.active_changes) : null,
222
+ // SS-2:全量名单随摘要透传,供 D4 精确集合对账(ghost/orphan)消费。
223
+ active_changes: d.readable ? (d.active_changes || []) : [],
224
+ // SS-2b:name → last_active 映射随摘要透传,供 D4 空壳判定(时间门槛)消费。
225
+ active_last_active: d.readable ? (d.active_last_active || {}) : {},
226
+ })),
227
+ };
228
+ }
229
+
230
+ // ── D2 pointer 健康 ───────────────────────────────────────────────────
231
+
232
+ function detectPointerHealth(pointer, cwd = null) {
233
+ if (!pointer.present) {
234
+ // FR-06:指针缺失但接管声明存在 → 报 pointer_missing_but_managed(warning 非阻断)。
235
+ // 此状态 resolvePlatformSpecDir / runCommand 恢复链会 fail-closed 拒绝静默落本地,
236
+ // doctor 只读诊断给出同款恢复引导(不自动执行任何修复)。
237
+ if (cwd) {
238
+ const decl = checkPlatformManaged(cwd);
239
+ if (decl) {
240
+ return {
241
+ name: 'pointer_health',
242
+ label: '平台指针健康',
243
+ pass: false,
244
+ severity: CHECK_SEVERITY.WARNING,
245
+ findings: [
246
+ `pointer_missing_but_managed: 平台接管声明存在但恢复指针缺失(原 specRoot: ${decl.specRoot || '(未记录)'})——` +
247
+ `裸调命令将 fail-closed 拒绝静默落本地。恢复:① 重跑平台 scan/init(带 --spec-root)重建指针;` +
248
+ `② sillyspec platform disconnect(删除接管声明,彻底脱离平台);③ 显式 --spec-dir <路径> 临时指定。`,
249
+ ],
250
+ safe_actions: [{ dimension: 'pointer_health', action: 'recreate_pointer', risk: 'confirm_required', rationale: '接管声明存在但指针缺失,需重建指针或显式脱离平台', next_step: '重跑平台 scan(带 --spec-root)或 sillyspec platform disconnect' }],
251
+ };
252
+ }
253
+ }
254
+ return {
255
+ name: 'pointer_health',
256
+ label: '平台指针健康',
257
+ pass: true,
258
+ severity: null,
259
+ findings: ['无平台 pointer,本地模式(无平台分裂风险)'],
260
+ safe_actions: [],
261
+ };
262
+ }
263
+ const findings = [];
264
+ let pass = true;
265
+ let severity = null;
266
+ if (pointer.corrupted) {
267
+ return {
268
+ name: 'pointer_health',
269
+ label: '平台指针健康',
270
+ pass: false,
271
+ severity: CHECK_SEVERITY.FAILED,
272
+ findings: [`pointer 损坏: ${pointer.error || '缺少 specRoot 字段'}——建议删除后重跑平台 scan`],
273
+ safe_actions: [{ dimension: 'pointer_health', action: 'recreate_pointer', risk: 'confirm_required', rationale: 'pointer 损坏,需重建', next_step: 'sillyspec platform pointer --cleanup 后重新 scan' }],
274
+ };
275
+ }
276
+ const reachable = !!pointer.specRoot && existsSync(pointer.specRoot);
277
+ if (!reachable) {
278
+ pass = false;
279
+ severity = CHECK_SEVERITY.FAILED;
280
+ findings.push(
281
+ `pointer.specRoot 不可达: ${pointer.specRoot}(daemon 未起?已迁移?)——` +
282
+ `resolvePlatformSpecDir 当前会静默回退到本地孤儿 db,这是危险行为(建议改 fail-closed)`
283
+ );
284
+ }
285
+ const authDb = pointer.specRoot ? join(pointer.specRoot, '.runtime', 'sillyspec.db') : null;
286
+ if (authDb && !existsSync(authDb)) {
287
+ pass = false;
288
+ severity = severity || CHECK_SEVERITY.WARNING;
289
+ findings.push(`权威 db 不存在: ${authDb}`);
290
+ }
291
+ if (pass) findings.push(`pointer 正常,specRoot 可达`);
292
+ return {
293
+ name: 'pointer_health',
294
+ label: '平台指针健康',
295
+ pass,
296
+ severity,
297
+ findings,
298
+ safe_actions: [],
299
+ pointer: { specRoot: pointer.specRoot, runtimeRoot: pointer.runtimeRoot, reachable, savedAt: pointer.savedAt },
300
+ };
301
+ }
302
+
303
+ // ── D3 本地/平台 changes 分裂 ──────────────────────────────────────────
304
+
305
+ function listChanges(specRoot) {
306
+ const dir = join(specRoot, 'changes');
307
+ if (!existsSync(dir)) return [];
308
+ try {
309
+ return readdirSync(dir, { withFileTypes: true })
310
+ .filter((d) => d.isDirectory() && d.name !== 'archive')
311
+ .map((d) => d.name);
312
+ } catch {
313
+ return [];
314
+ }
315
+ }
316
+
317
+ function detectChangesSplit(cwd, pointer) {
318
+ const localChanges = listChanges(join(cwd, '.sillyspec'));
319
+ const findings = [];
320
+ let pass = true;
321
+ let severity = null;
322
+ const dim = {
323
+ name: 'changes_split',
324
+ label: '本地/平台 changes 分裂',
325
+ local: localChanges,
326
+ safe_actions: [],
327
+ };
328
+
329
+ if (!(pointer.present && pointer.specRoot && existsSync(pointer.specRoot))) {
330
+ dim.platform = [];
331
+ dim.findings = ['无可达平台 specRoot,仅本地 changes(无分裂可比较)'];
332
+ return { ...dim, pass: true, severity: null };
333
+ }
334
+
335
+ const platformChanges = listChanges(pointer.specRoot);
336
+ dim.platform = platformChanges;
337
+ const platformSet = new Set(platformChanges);
338
+ const localSet = new Set(localChanges);
339
+ dim.local_only = localChanges.filter((c) => !platformSet.has(c));
340
+ dim.platform_only = platformChanges.filter((c) => !localSet.has(c));
341
+ dim.intersection = localChanges.filter((c) => platformSet.has(c));
342
+
343
+ const lo = dim.local_only.length;
344
+ const po = dim.platform_only.length;
345
+ const ix = dim.intersection.length;
346
+
347
+ if (lo > 0 && po > 0 && ix === 0) {
348
+ pass = false;
349
+ severity = CHECK_SEVERITY.FAILED;
350
+ findings.push(
351
+ `严重分裂:本地 ${localChanges.length} 个 changes 与平台 ${platformChanges.length} 个 零交集——` +
352
+ `本地→平台模式切换时迁移未完成,变更历史断裂(6月在本地、7月在平台各走各的)`
353
+ );
354
+ dim.safe_actions.push({
355
+ dimension: 'changes_split',
356
+ action: 'migrate_local_changes',
357
+ risk: 'confirm_required',
358
+ rationale: `本地 ${lo} 个变更平台没有,需决定逐个导入进度 or 归档;属于数据迁移,必须逐项确认`,
359
+ next_step: '后续实现 sillyspec doctor --merge-local-to-platform(带 --confirm)',
360
+ });
361
+ } else if (lo > 3 || po > 3) {
362
+ pass = false;
363
+ severity = CHECK_SEVERITY.WARNING;
364
+ findings.push(`部分分裂:local_only ${lo},platform_only ${po},intersection ${ix}`);
365
+ } else {
366
+ findings.push(`本地/平台 changes 基本同步(intersection ${ix},local_only ${lo},platform_only ${po})`);
367
+ }
368
+
369
+ return { ...dim, pass, severity, findings };
370
+ }
371
+
372
+ // ── D4 change↔db 一致性(针对权威 db + 权威 changes 目录)──────────────
373
+
374
+ // SS-2b(2026-08-20):空壳目录判定的 last_active 时间门槛(7 天)。
375
+ // 背景:brainstorm 刚注册的合法变更目录本来就是 0 文件(首份产物 proposal.md
376
+ // 落盘前有空窗,2026-08-20 task-truth-unify 即空目录数小时)。「active 行 + 目录
377
+ // 0 文件」不能直接判幽灵,必须叠加 last_active 陈旧度;门槛取 7 天(quick 会话
378
+ // 生命周期通常当天结束,正规变更 brainstorm 首产物也远早于 7 天)。
379
+ const GHOST_EMPTY_DIR_STALE_MS = 7 * 24 * 60 * 60 * 1000;
380
+
381
+ /** 目录内是否 0 个常规文件(递归;空目录/仅空子目录都算 0 文件)。读失败按非空处理(宁漏勿杀)。 */
382
+ function dirHasNoFiles(dirPath) {
383
+ const stack = [dirPath];
384
+ while (stack.length > 0) {
385
+ const cur = stack.pop();
386
+ let entries;
387
+ try { entries = readdirSync(cur, { withFileTypes: true }); } catch { return false; }
388
+ for (const e of entries) {
389
+ if (e.isFile()) return false;
390
+ if (e.isDirectory()) stack.push(join(cur, e.name));
391
+ }
392
+ }
393
+ return true;
394
+ }
395
+
396
+ /** last_active 是否陈旧超过 GHOST_EMPTY_DIR_STALE_MS。无值/不可解析按非陈旧处理(宁漏勿杀)。 */
397
+ function isStaleLastActive(lastActive, now = Date.now()) {
398
+ if (!lastActive) return false;
399
+ const ts = Date.parse(lastActive);
400
+ return Number.isFinite(ts) && (now - ts) > GHOST_EMPTY_DIR_STALE_MS;
401
+ }
402
+
403
+ function detectChangeDbConsistency(cwd, pointer, multiDb) {
404
+ const auth = (multiDb.dbs || []).find((d) => d.role === DB_ROLE.AUTHORITY);
405
+ if (!auth) {
406
+ return {
407
+ name: 'change_db_consistency',
408
+ label: 'change↔db 一致性',
409
+ pass: true,
410
+ severity: null,
411
+ findings: ['无权威 db,跳过一致性校验'],
412
+ safe_actions: [],
413
+ };
414
+ }
415
+ const authoritySpecRoot = pointer.present && pointer.specRoot ? pointer.specRoot : join(cwd, '.sillyspec');
416
+ const dirChanges = listChanges(authoritySpecRoot);
417
+ // SS-2(2026-08-20):从 count 粗对齐升级为精确集合对账——ghost_rows(db active
418
+ // 无目录)与 orphan_dirs(目录无 db active 行)逐名列出,供 doctor --cleanup-ghosts
419
+ // 消费,不再让 52 条残留只停留在「需 dump 后细对齐」。
420
+ const activeNames = (auth.active_changes && Array.isArray(auth.active_changes))
421
+ ? auth.active_changes
422
+ : [];
423
+ const dirSet = new Set(dirChanges);
424
+ const activeSet = new Set(activeNames);
425
+ const ghostRows = activeNames.filter((n) => !dirSet.has(n));
426
+ const orphanDirs = dirChanges.filter((n) => !activeSet.has(n));
427
+ // SS-2b:空壳——db active + 目录存在但 0 文件 + last_active 超过 7 天。有目录所以
428
+ // 逃过 ghostRows 的「无目录」判定(2026-08-15 清理时 6 个空壳即此漏网形态)。
429
+ const activeLastActive = (auth.active_last_active && typeof auth.active_last_active === 'object')
430
+ ? auth.active_last_active
431
+ : {};
432
+ const emptyShells = activeNames.filter((n) =>
433
+ dirSet.has(n) && isStaleLastActive(activeLastActive[n]) && dirHasNoFiles(join(authoritySpecRoot, 'changes', n)));
434
+ const findings = [];
435
+ let pass = true;
436
+ let severity = null;
437
+ if (ghostRows.length > 0) {
438
+ pass = false;
439
+ severity = CHECK_SEVERITY.WARNING;
440
+ findings.push(
441
+ `幽灵记录 ${ghostRows.length} 条(db active 但无目录):${ghostRows.slice(0, 10).join(', ')}${ghostRows.length > 10 ? ' …' : ''}——` +
442
+ `可用 sillyspec doctor --cleanup-ghosts --confirm 归档清理`
443
+ );
444
+ }
445
+ if (emptyShells.length > 0) {
446
+ pass = false;
447
+ severity = severity || CHECK_SEVERITY.WARNING;
448
+ findings.push(
449
+ `空壳目录 ${emptyShells.length} 个(db active + 目录 0 文件 + last_active 超 7 天):${emptyShells.slice(0, 10).join(', ')}${emptyShells.length > 10 ? ' …' : ''}——` +
450
+ `可用 sillyspec doctor --cleanup-ghosts --confirm 归档行并移除空目录`
451
+ );
452
+ }
453
+ if (orphanDirs.length > 0) {
454
+ pass = false;
455
+ severity = severity || CHECK_SEVERITY.WARNING;
456
+ findings.push(
457
+ `孤儿目录 ${orphanDirs.length} 个(目录存在但无 db active 行):${orphanDirs.slice(0, 10).join(', ')}${orphanDirs.length > 10 ? ' …' : ''}——` +
458
+ `先 dump 确认再手工归位,doctor 不自动删目录`
459
+ );
460
+ }
461
+ if (pass) {
462
+ findings.push(`权威 db active changes (${activeNames.length}) 与 changes/ 目录数 (${dirChanges.length}) 完全一致`);
463
+ }
464
+ return {
465
+ name: 'change_db_consistency',
466
+ label: 'change↔db 一致性',
467
+ pass,
468
+ severity,
469
+ findings,
470
+ safe_actions: ghostRows.length > 0 || emptyShells.length > 0
471
+ ? ['sillyspec doctor --cleanup-ghosts(dry-run)→ 加 --confirm 归档幽灵行/空壳目录']
472
+ : [],
473
+ authority_spec_root: authoritySpecRoot,
474
+ dir_count: dirChanges.length,
475
+ db_active_count: activeNames.length,
476
+ ghost_rows: ghostRows,
477
+ orphan_dirs: orphanDirs,
478
+ empty_shells: emptyShells,
479
+ };
480
+ }
481
+
482
+ // ── D5 execute-progress-plan-mismatch(execute 派生戳 vs plan.md 声明)─────
483
+
484
+ /**
485
+ * 只读解析 plan.md(回退 tasks.md)的 task checkbox 全勾状态。
486
+ * 真相源语义对齐 run.js:832 + execute.js 的 checkbox 格式:
487
+ * `- [ ] task-NN: ...` / `- [x] task-NN: ...`
488
+ * 只读文件,不调用任何 ProgressManager 写方法(D-001@v2 诊断/写分离)。
489
+ * @returns {{total:number, checked:number} | null} null = plan/tasks.md 都不存在或无 task 行
490
+ */
491
+ function readPlanCheckboxStatus(changeDir) {
492
+ // 2026-08-20-task-truth-unify:tasks.md(任务注册表)优先;plan.md 纯 ID 引用行无 checkbox,
493
+ // 旧「plan 优先」顺序对新契约变更恒 null(D5 诊断静默致盲)。tasks.md 缺失回退 plan.md(旧变更兼容)。
494
+ const tasksPath = join(changeDir, 'tasks.md');
495
+ const planPath = join(changeDir, 'plan.md');
496
+ let content = null;
497
+ if (existsSync(tasksPath)) {
498
+ try { content = readFileSync(tasksPath, 'utf8'); } catch { content = null; }
499
+ }
500
+ if (content == null && existsSync(planPath)) {
501
+ try { content = readFileSync(planPath, 'utf8'); } catch { content = null; }
502
+ }
503
+ if (content == null) return null;
504
+ // match both "- [ ] task-01: title" and "- [x] task-01: title"
505
+ const taskLine = /(?:^- \[[ x]\] )task-\d+[^:]*:?\s*.+$/gm;
506
+ const matches = content.match(taskLine) || [];
507
+ if (matches.length === 0) return null;
508
+ const checked = matches.filter((l) => /^- \[x\] task-\d+/i.test(l)).length;
509
+ return { total: matches.length, checked };
510
+ }
511
+
512
+ /**
513
+ * D5 诊断维度(advisory,CHECK_SEVERITY.WARNING,不阻断):
514
+ * 触发条件:change 的 execute stage status≠completed,且其 plan.md 所有 task checkbox 全勾。
515
+ * 命中即输出 safe_action 建议显式对齐命令(绝不执行)。
516
+ * 只读:仅读 changes/<name>/plan.md + 只读用 probeDb 已取回的 execute stage status 字段,
517
+ * 绝不写 db、不动文件(D-001@v2)。
518
+ */
519
+ function detectExecuteProgressPlanMismatch(authoritySpecRoot, authDb) {
520
+ const dim = {
521
+ name: 'execute-progress-plan-mismatch',
522
+ label: 'execute 派生戳 vs plan.md 声明',
523
+ safe_actions: [],
524
+ findings: [],
525
+ per_change: [],
526
+ };
527
+ if (!authoritySpecRoot || !existsSync(authoritySpecRoot)) {
528
+ dim.pass = true;
529
+ dim.severity = null;
530
+ dim.findings = ['无权威 specRoot,跳过 execute-progress-plan-mismatch 诊断'];
531
+ return dim;
532
+ }
533
+ const executeStatusByChange = (authDb && authDb.execute_status_by_change) || {};
534
+ const changes = listChanges(authoritySpecRoot);
535
+ let triggered = false;
536
+ let severity = null;
537
+ for (const name of changes) {
538
+ const planStatus = readPlanCheckboxStatus(join(authoritySpecRoot, 'changes', name));
539
+ if (!planStatus || planStatus.total === 0) {
540
+ dim.per_change.push({ change: name, plan_total: 0, skipped: '无 task checkbox' });
541
+ continue;
542
+ }
543
+ const execStatus = executeStatusByChange[name] !== undefined
544
+ ? executeStatusByChange[name]
545
+ : null;
546
+ dim.per_change.push({
547
+ change: name,
548
+ plan_total: planStatus.total,
549
+ plan_checked: planStatus.checked,
550
+ execute_status: execStatus,
551
+ });
552
+ const allChecked = planStatus.checked >= planStatus.total;
553
+ const execNotCompleted = execStatus !== 'completed';
554
+ // 触发:execute status≠completed 且 plan.md 全勾
555
+ if (allChecked && execNotCompleted) {
556
+ triggered = true;
557
+ severity = CHECK_SEVERITY.WARNING;
558
+ dim.findings.push(
559
+ `change [${name}]:plan.md ${planStatus.checked}/${planStatus.total} task 全勾,` +
560
+ `但 execute stage status=${execStatus || 'absent'}(≠completed)——派生戳未对齐真相源声明`
561
+ );
562
+ dim.safe_actions.push({
563
+ dimension: 'execute-progress-plan-mismatch',
564
+ action: `sillyspec doctor --align-execute-progress --change ${name}`,
565
+ risk: 'low',
566
+ rationale: `plan.md 声明全完成但 execute 派生戳未对齐(${planStatus.checked}/${planStatus.total} 全勾,execute status=${execStatus || 'absent'});doctor 信任 plan.md 声明,对齐由 --confirm 显式触发`,
567
+ next_step: 'sillyspec doctor --align-execute-progress --change ' + name + ' --confirm',
568
+ });
569
+ }
570
+ }
571
+ dim.pass = !triggered;
572
+ dim.severity = triggered ? CHECK_SEVERITY.WARNING : null;
573
+ if (!triggered) {
574
+ dim.findings.push(
575
+ changes.length === 0
576
+ ? '无 changes,无 execute-progress-plan-mismatch 可检测'
577
+ : `所有 ${changes.length} 个 change 的 execute 派生戳与 plan.md 声明一致(或 plan.md 未全勾)`
578
+ );
579
+ }
580
+ return dim;
581
+ }
582
+
583
+ // ── 主入口 ────────────────────────────────────────────────────────────
584
+
585
+ export async function runDoctorDiagnostics({ cwd }) {
586
+ const pointer = resolvePointer(cwd);
587
+ const multiDb = detectMultiDb(cwd, pointer);
588
+ const pointerHealth = detectPointerHealth(pointer, cwd);
589
+ const changesSplit = detectChangesSplit(cwd, pointer);
590
+ const changeDb = detectChangeDbConsistency(cwd, pointer, multiDb);
591
+
592
+ // 权威 specDir(用于 D5 execute-progress-plan-mismatch 读 plan.md)
593
+ const authoritySpecDir =
594
+ pointer.present && pointer.specRoot && existsSync(pointer.specRoot)
595
+ ? pointer.specRoot
596
+ : existsSync(join(cwd, '.sillyspec'))
597
+ ? join(cwd, '.sillyspec')
598
+ : null;
599
+ const authDb = (multiDb.dbs || []).find((d) => d.role === DB_ROLE.AUTHORITY);
600
+ const executeMismatch = detectExecuteProgressPlanMismatch(authoritySpecDir, authDb);
601
+
602
+ const dimensions = [multiDb, pointerHealth, changesSplit, changeDb, executeMismatch];
603
+
604
+ return {
605
+ dimensions,
606
+ pointer_summary: pointer.present
607
+ ? { present: true, specRoot: pointer.specRoot, corrupted: !!pointer.corrupted, savedAt: pointer.savedAt }
608
+ : { present: false },
609
+ dbs_summary: multiDb.dbs,
610
+ authoritySpecDir,
611
+ };
612
+ }
613
+
614
+ /**
615
+ * 转结构化 JSON(sillyspec doctor --json 输出 + 落盘格式)。
616
+ * severity 口径对齐 scan-postcheck:CHECK_SEVERITY.FAILED → 'critical'。
617
+ */
618
+ export function formatDoctorJson(result, meta = {}) {
619
+ const dims = result.dimensions;
620
+ const critical = dims.filter((d) => d.severity === CHECK_SEVERITY.FAILED).length;
621
+ const warning = dims.filter((d) => d.severity === CHECK_SEVERITY.WARNING).length;
622
+ const overallStatus = critical > 0 ? 'failed' : warning > 0 ? 'warning' : 'pass';
623
+
624
+ const safeActions = [];
625
+ for (const d of dims) {
626
+ for (const a of d.safe_actions || []) safeActions.push({ dimension: d.name, ...a });
627
+ }
628
+
629
+ return {
630
+ schema_version: 1,
631
+ generated_at: new Date().toISOString(),
632
+ tool: 'sillyspec-doctor',
633
+ overall_status: overallStatus,
634
+ summary: { critical, warning, total_dimensions: dims.length },
635
+ ...(meta.source_root ? { source_root: meta.source_root } : {}),
636
+ ...(result.authoritySpecDir ? { authority_spec_dir: result.authoritySpecDir } : {}),
637
+ pointer: result.pointer_summary,
638
+ dbs: result.dbs_summary.map((d) => ({
639
+ kind: d.kind,
640
+ role: d.role,
641
+ path: d.path,
642
+ size: d.size,
643
+ mtime: d.mtime,
644
+ change_count: d.change_count,
645
+ last_active: d.last_active,
646
+ })),
647
+ dimensions: dims.map((d) => {
648
+ const out = {
649
+ name: d.name,
650
+ label: d.label,
651
+ pass: d.pass,
652
+ severity: d.severity === CHECK_SEVERITY.FAILED ? 'critical' : d.severity,
653
+ evidence: (d.findings || []).join('; '),
654
+ };
655
+ if (d.name === 'changes_split') {
656
+ out.local_count = d.local.length;
657
+ out.platform_count = d.platform.length;
658
+ out.local_only_count = (d.local_only || []).length;
659
+ out.platform_only_count = (d.platform_only || []).length;
660
+ out.intersection_count = (d.intersection || []).length;
661
+ out.local_only_sample = (d.local_only || []).slice(0, 8);
662
+ out.platform_only_sample = (d.platform_only || []).slice(0, 8);
663
+ }
664
+ return out;
665
+ }),
666
+ safe_actions: safeActions,
667
+ };
668
+ }
669
+
670
+ /**
671
+ * 落盘到 <authoritySpecDir>/.runtime/doctor-diagnosis.json(对齐 scan-postcheck.writeStructuredResult)。
672
+ */
673
+ export function writeDoctorDiagnosis(json, specDir) {
674
+ if (!specDir) return null;
675
+ try {
676
+ const runtimeDir = join(specDir, '.runtime');
677
+ mkdirSync(runtimeDir, { recursive: true });
678
+ const outPath = join(runtimeDir, 'doctor-diagnosis.json');
679
+ writeFileSync(outPath, JSON.stringify(json, null, 2) + '\n');
680
+ return outPath;
681
+ } catch (e) {
682
+ console.warn(`⚠️ doctor-diagnosis.json 写入失败: ${e.message}`);
683
+ return null;
684
+ }
685
+ }
686
+
687
+ // ── 执行流:安全清理 + 取证 dump ───────────────────────────────────────
688
+ //
689
+ // 让 doctor --json 输出的 safe_actions 真正可执行。安全约束不变:
690
+ // cleanup 只删 0 字节空占位(probeDb 判定 empty_remnant),绝不碰有内容的 db;
691
+ // dump 纯只读。destructive 操作必须 --confirm。
692
+
693
+ /**
694
+ * 清理 0 字节空占位 db(历史遗留)。默认 dry-run,--confirm 才真删。
695
+ * 只删 size===0 的文件;有内容的 db 一律不动。
696
+ */
697
+ export async function cleanupRemnantDbs({ cwd, confirm = false }) {
698
+ const pointer = resolvePointer(cwd);
699
+ const localSpec = join(cwd, '.sillyspec');
700
+ const candidates = [
701
+ join(localSpec, 'sillyspec.db'),
702
+ join(localSpec, '.runtime', 'sillyspec.db'),
703
+ ];
704
+ if (pointer.specRoot) {
705
+ candidates.push(join(pointer.specRoot, '.runtime', 'sillyspec.db'));
706
+ candidates.push(join(pointer.specRoot, 'sillyspec.db'));
707
+ }
708
+ const remnants = [];
709
+ for (const p of candidates) {
710
+ if (!existsSync(p)) continue;
711
+ try {
712
+ const st = statSync(p);
713
+ if (st.size === 0) remnants.push({ path: p, size: 0, mtime: st.mtime.toISOString() });
714
+ } catch { /* stat 失败跳过 */ }
715
+ }
716
+ const deleted = [];
717
+ const errors = [];
718
+ if (confirm) {
719
+ for (const r of remnants) {
720
+ try {
721
+ unlinkSync(r.path);
722
+ deleted.push(r.path);
723
+ } catch (e) {
724
+ errors.push({ path: r.path, error: e.message });
725
+ }
726
+ }
727
+ }
728
+ return {
729
+ action: confirm ? 'deleted' : 'dry_run',
730
+ would_delete: remnants,
731
+ deleted,
732
+ errors,
733
+ count: remnants.length,
734
+ };
735
+ }
736
+
737
+ /**
738
+ * 把任意 db 文件的内容 dump 成 JSON(schema_version + 全量 changes + stages)。
739
+ * 落盘到 <authoritySpecDir>/.runtime/doctor-dumps/dump-<ts>.json。纯只读。
740
+ */
741
+ export async function dumpDb({ dbPath, cwd }) {
742
+ const pointer = resolvePointer(cwd);
743
+ const authoritySpecDir = (pointer.present && pointer.specRoot && existsSync(pointer.specRoot))
744
+ ? pointer.specRoot
745
+ : (existsSync(join(cwd, '.sillyspec')) ? join(cwd, '.sillyspec') : null);
746
+ if (!existsSync(dbPath)) {
747
+ return { ok: false, error: `db 文件不存在: ${dbPath}`, path: dbPath };
748
+ }
749
+ const st = statSync(dbPath);
750
+ const meta = { path: dbPath, size: st.size, mtime: st.mtime.toISOString(), dumped_at: new Date().toISOString() };
751
+ if (st.size === 0) {
752
+ return writeDump({ ok: true, meta, note: '0 字节文件,无表数据', changes: [], stages: [] }, authoritySpecDir);
753
+ }
754
+ let db = null;
755
+ try {
756
+ db = openDatabase(dbPath, { readOnly: true });
757
+ const rows = (sql) => {
758
+ try { return db.prepare(sql).all(); } catch { return []; }
759
+ };
760
+ const sv = rows('SELECT schema_version FROM project LIMIT 1');
761
+ const schemaVersion = sv.length ? sv[0].schema_version : null;
762
+ const changes = rows('SELECT name, current_stage, status, created_at, last_active FROM changes ORDER BY last_active DESC')
763
+ .map((r) => ({ name: r.name, current_stage: r.current_stage, status: r.status, created_at: r.created_at, last_active: r.last_active }));
764
+ const stages = rows('SELECT c.name, s.stage, s.status, s.started_at, s.completed_at FROM stages s JOIN changes c ON s.change_id = c.id ORDER BY c.name, s.stage')
765
+ .map((r) => ({ change: r.name, stage: r.stage, status: r.status, started_at: r.started_at, completed_at: r.completed_at }));
766
+ return writeDump({ ok: true, meta, schema_version: schemaVersion, changes, stages }, authoritySpecDir);
767
+ } catch (e) {
768
+ return writeDump({ ok: false, meta, error: `读取失败: ${e.message}` }, authoritySpecDir);
769
+ } finally {
770
+ if (db) { try { db.close(); } catch { /* noop */ } }
771
+ }
772
+ }
773
+
774
+ function writeDump(result, authoritySpecDir) {
775
+ if (!authoritySpecDir) return result; // 无处落盘也把内容返回给调用方
776
+ try {
777
+ const outDir = join(authoritySpecDir, '.runtime', 'doctor-dumps');
778
+ mkdirSync(outDir, { recursive: true });
779
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
780
+ const outPath = join(outDir, `dump-${ts}.json`);
781
+ writeFileSync(outPath, JSON.stringify(result, null, 2) + '\n');
782
+ return { ...result, written_to: outPath };
783
+ } catch (e) {
784
+ return { ...result, dump_write_error: e.message };
785
+ }
786
+ }
787
+
788
+
789
+ /**
790
+ * SS-2(2026-08-20):归档幽灵行——db active 但 changes/ 无同名目录的记录。
791
+ * SS-2b(2026-08-20):扩空壳目录——db active + 目录存在但 0 文件 + last_active 超
792
+ * 7 天(GHOST_EMPTY_DIR_STALE_MS)。两者都是 quick 收尾注销缺陷(工具侧已修)在
793
+ * 历史会话中累积的僵尸 active 行,污染 listChanges/progress show 的「活跃变更」
794
+ * 列表;旧 doctor 只 WARNING 不给动作(stage-machine 提示「可用 doctor 清理」却清不掉)。
795
+ *
796
+ * 安全设计:
797
+ * - 仅把 changes.status 从 active 改为 archived(对齐 change-registry 归档语义,
798
+ * 状态可逆:手工改回 status='active' 即恢复,不删任何行)。
799
+ * - 默认 dry-run 只列名单;加 confirm 才写。
800
+ * - 空壳目录归档时同时移除空目录(删前复查仍为 0 文件,复查非空整条放过);
801
+ * 有内容的目录一律不动。孤儿目录(有目录无行)不自动删,留给人工归位。
802
+ */
803
+ export async function cleanupGhostChanges({ cwd, specDir = null, confirm = false }) {
804
+ const pointer = resolvePointer(cwd);
805
+ const localSpec = join(cwd, '.sillyspec');
806
+ const candidates = [
807
+ join(localSpec, '.runtime', 'sillyspec.db'),
808
+ join(localSpec, 'sillyspec.db'),
809
+ ];
810
+ if (pointer.specRoot) {
811
+ candidates.unshift(
812
+ join(pointer.specRoot, '.runtime', 'sillyspec.db'),
813
+ join(pointer.specRoot, 'sillyspec.db'),
814
+ );
815
+ }
816
+ let dbPath = null;
817
+ for (const p of candidates) {
818
+ try {
819
+ if (existsSync(p) && statSync(p).size > 0) { dbPath = p; break; }
820
+ } catch { /* stat 失败跳过 */ }
821
+ }
822
+ if (!dbPath) {
823
+ return { action: 'skipped', reason: '未找到非空 sillyspec.db', ghosts: [], archived: [], errors: [], count: 0 };
824
+ }
825
+ const authoritySpecRoot = (pointer.present && pointer.specRoot && existsSync(pointer.specRoot))
826
+ ? pointer.specRoot
827
+ : localSpec;
828
+ const changesDir = join(authoritySpecRoot, 'changes');
829
+ let dirNames = [];
830
+ try {
831
+ dirNames = readdirSync(changesDir, { withFileTypes: true })
832
+ .filter((e) => e.isDirectory() && e.name !== 'archive')
833
+ .map((e) => e.name);
834
+ } catch { /* 目录缺失 → 全部 active 都是幽灵 */ }
835
+ const dirSet = new Set(dirNames);
836
+
837
+ let db = null;
838
+ const archived = [];
839
+ const errors = [];
840
+ const skippedNonEmpty = [];
841
+ const removedDirs = [];
842
+ try {
843
+ db = openDatabase(dbPath, { readOnly: !confirm });
844
+ // last_active 与 name 一起取:空壳判定需要时间门槛(见 isStaleLastActive)。
845
+ const activeRows = db.prepare("SELECT name, last_active FROM changes WHERE status='active'").all();
846
+ const ghosts = activeRows.filter((r) => !dirSet.has(r.name)).map((r) => r.name);
847
+ const emptyShells = activeRows
848
+ .filter((r) => dirSet.has(r.name) && isStaleLastActive(r.last_active) && dirHasNoFiles(join(changesDir, r.name)))
849
+ .map((r) => r.name);
850
+ if (confirm) {
851
+ const now = new Date().toISOString();
852
+ const stmt = db.prepare("UPDATE changes SET status = 'archived', last_active = ? WHERE name = ? AND status = 'active'");
853
+ for (const n of ghosts) {
854
+ try {
855
+ stmt.run(now, n);
856
+ archived.push(n);
857
+ } catch (e) {
858
+ errors.push({ name: n, error: e.message });
859
+ }
860
+ }
861
+ for (const n of emptyShells) {
862
+ // 删目录前复查非空:dry-run 与 --confirm 之间并发会话可能已往空目录写入首份产物
863
+ //(如 brainstorm 落 proposal.md)。复查非空 → 整条放过(行也不归档),宁漏勿杀。
864
+ const dirPath = join(changesDir, n);
865
+ if (!dirHasNoFiles(dirPath)) {
866
+ skippedNonEmpty.push(n);
867
+ continue;
868
+ }
869
+ try {
870
+ stmt.run(now, n);
871
+ rmSync(dirPath, { recursive: true, force: true });
872
+ removedDirs.push(n);
873
+ archived.push(n);
874
+ } catch (e) {
875
+ errors.push({ name: n, error: e.message });
876
+ }
877
+ }
878
+ }
879
+ const activeAfter = confirm ? pluckAll(db, "SELECT name FROM changes WHERE status='active'") : null;
880
+ return {
881
+ action: confirm ? 'archived' : 'dry_run',
882
+ db_path: dbPath,
883
+ changes_dir: changesDir,
884
+ ghosts: confirm ? archived : ghosts,
885
+ empty_shells: confirm ? removedDirs : emptyShells,
886
+ skipped_nonempty: skippedNonEmpty,
887
+ removed_dirs: removedDirs,
888
+ archived,
889
+ errors,
890
+ count: ghosts.length + emptyShells.length,
891
+ active_after: activeAfter,
892
+ };
893
+ } catch (e) {
894
+ return { action: 'error', reason: e.message, ghosts: [], archived: [], errors: [{ error: e.message }], count: 0 };
895
+ } finally {
896
+ if (db) { try { db.close(); } catch { /* 忽略关闭失败 */ } }
897
+ }
898
+ }