sillyspec 3.25.0 → 3.25.2

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 (45) hide show
  1. package/.claude/skills/sillyspec-doctor/SKILL.md +9 -0
  2. package/.playwright-mcp/console-2026-07-27T15-45-50-621Z.log +1 -0
  3. package/.playwright-mcp/page-2026-07-27T15-45-51-459Z.yml +144 -0
  4. package/.playwright-mcp/prompt-final.png +0 -0
  5. package/.playwright-mcp/prompt-view.png +0 -0
  6. package/CLAUDE.md +18 -0
  7. package/package.json +1 -1
  8. package/src/change-risk-profile.js +60 -12
  9. package/src/check-primitives.js +65 -0
  10. package/src/db.js +28 -8
  11. package/src/hooks/worktree-guard.js +9 -2
  12. package/src/index.js +140 -11
  13. package/src/machine-interface.js +13 -7
  14. package/src/progress/change-registry.js +217 -0
  15. package/src/progress/consistency-doctor.js +259 -0
  16. package/src/progress/shared.js +33 -0
  17. package/src/progress/stage-machine.js +535 -0
  18. package/src/progress/step-store.js +256 -0
  19. package/src/progress.js +75 -1191
  20. package/src/quicklog.js +3 -1
  21. package/src/run/command.js +1088 -0
  22. package/src/run/complete-handlers.js +874 -0
  23. package/src/run/complete.js +725 -0
  24. package/src/run/gates.js +326 -0
  25. package/src/run/prompt.js +619 -0
  26. package/src/run/quick-audit.js +107 -0
  27. package/src/run/scan-profile.js +238 -0
  28. package/src/run/shared.js +418 -0
  29. package/src/run/stage.js +414 -0
  30. package/src/run.js +23 -4379
  31. package/src/stage-contract-engine.js +150 -0
  32. package/src/stage-contract-spec.js +517 -0
  33. package/src/stage-contract.js +87 -150
  34. package/src/stage-review.js +63 -0
  35. package/src/stages/brainstorm.js +3 -1
  36. package/src/stages/execute.js +5 -2
  37. package/src/stages/plan-postcheck.js +38 -27
  38. package/src/stages/plan.js +12 -49
  39. package/src/stages/propose.js +2 -1
  40. package/src/stages/scan.js +3 -1
  41. package/src/workflow.js +9 -17
  42. package/src/worktree-apply.js +5 -2
  43. package/src/worktree-deps.js +114 -32
  44. package/src/worktree.js +23 -11
  45. package/src/brainstorm-postcheck.js +0 -158
package/src/index.js CHANGED
@@ -16,6 +16,8 @@ import { detectLocalYaml } from './local-detect.js';
16
16
 
17
17
  // ── CLI 入口 ──
18
18
 
19
+ // did-you-mean 从 ./run/shared.js 复用(命令级 + flag 级 typo 建议)。
20
+ import { didYouMean, assertSafeChangeName } from './run/shared.js'
19
21
  function printUsage() {
20
22
  console.log(`
21
23
  SillySpec CLI — 规范驱动开发工具包
@@ -73,11 +75,20 @@ SillySpec CLI — 规范驱动开发工具包
73
75
  doctor [--fix] [--stale-hours N] 健康检查 + 修复
74
76
 
75
77
  sillyspec local detect [--dir <path>] 生成本地配置 local.yaml(纯 fs 嗅探,零 token、不跑 scan)
78
+ sillyspec runtime list [--json] 枚举 .sillyspec/.runtime/ 运行时产物(只读,看手上有哪些证据/状态文件)
76
79
 
77
- sillyspec workflow check <name> [--project <p>] [--change <c>] [--json]
80
+ sillyspec workflow check <name> [--project <p>] [--change <c>] [--json] [--save]
81
+ 全局 workflow 校验(--save 归档到 .sillyspec/.runtime/workflow-runs/)
78
82
  sillyspec workflow list
79
83
  sillyspec gate <stage> --change <name> [--json] 机器门控:阶段能否标记完成(只读)
80
84
  sillyspec derive <facet> --change <name> [--json] 单项事实核验(facet: execute-evidence|verify-test|task-reviews|artifacts)
85
+
86
+ sillyspec doctor [子命令] 进度库健康检查 + 修复(顶层命令,非 worktree doctor)
87
+ (无子命令) 跑诊断,列出问题
88
+ --align-execute-progress [--change <名>] [--confirm] 基于 plan.md 对齐 execute 派生戳(默认 dry-run)
89
+ --cleanup-remnant [--confirm] 删除 0 字节空占位 db(默认 dry-run,仅 --confirm 落盘)
90
+ --dump-db --path <db 路径> dump 指定 db 内容到文件(取证用)
91
+ --json 结构化诊断 + 落盘 .sillyspec/.runtime/doctor-diagnosis.json
81
92
  sillyspec modules <rebuild | status | migrate>
82
93
  sillyspec change-rename <旧变更名> <新变更名>
83
94
  sillyspec knowledge <search --query "..." --limit N
@@ -175,6 +186,13 @@ async function main() {
175
186
  }
176
187
  }
177
188
 
189
+ // F3:--interactive(全局 flag,被上面吞进 interactive 变量)与 --non-interactive(透传到 filteredArgs)
190
+ // 语义相反,同时给是矛盾。在此层检测(command.js 看不到 --interactive)。
191
+ if (interactive && filteredArgs.includes('--non-interactive')) {
192
+ console.error('❌ --non-interactive 与 --interactive 互斥(一个禁用交互、一个强制交互),请只保留一个。');
193
+ process.exit(2);
194
+ }
195
+
178
196
  const command = filteredArgs[0];
179
197
  // 支持 sillyspec init /path/to/project 语法:如果第二个参数看起来像路径,当作 targetDir
180
198
  if (command === 'init' && filteredArgs[1] && !filteredArgs[1].startsWith('-')) {
@@ -233,9 +251,19 @@ async function main() {
233
251
  case 'show':
234
252
  pm.show(progDir, progChangeName);
235
253
  break;
236
- case 'check':
237
- await pm.checkConsistency(progDir, progChangeName);
254
+ case 'check': {
255
+ const result = await pm.checkConsistency(progDir, progChangeName);
256
+ // checkConsistency 只返回 {ok, issues, warnings},不打印——必须在此输出,
257
+ // 否则 agent 跑只读健康检查得到一片寂静,无法分辨「正常」还是「已损坏」。
258
+ if (!result || result.ok) {
259
+ console.log(`✅ 状态一致性检查通过${progChangeName ? `(变更 ${progChangeName})` : '(所有变更)'}`);
260
+ } else {
261
+ console.error(`❌ 状态一致性检查发现问题(${result.issues?.length || 0} 项):`);
262
+ for (const issue of (result.issues || [])) console.error(` - ${issue}`);
263
+ console.error(` 修复:sillyspec progress repair${progChangeName ? ` --change ${progChangeName}` : ''}(先 dry-run 看可修项,加 --apply 执行)`);
264
+ }
238
265
  break;
266
+ }
239
267
  case 'repair': {
240
268
  const repairApply = filteredArgs.includes('--apply');
241
269
  await pm.repairConsistency(progDir, { apply: repairApply, changeName: progChangeName });
@@ -340,8 +368,8 @@ async function main() {
340
368
  for (const e of (c.errors || [])) console.log(` ✗ ${e}`);
341
369
  for (const w of (c.warnings || [])) console.log(` ⚠ ${w}`);
342
370
  }
343
- for (const e of (envelope.errors || [])) console.log(` ✗ ${e}`);
344
- for (const w of (envelope.warnings || [])) console.log(` ⚠ ${w}`);
371
+ // 顶层 errors/warnings 是各 check 的聚合,已在上面按 check 分组显示;不再重复打印——
372
+ // 非 json 是 agent 默认消费路径,重复文本纯耗 context(W1-J)。
345
373
  }
346
374
  process.exitCode = exitCode;
347
375
  break;
@@ -402,7 +430,7 @@ async function main() {
402
430
  const { runCommand } = await import('./run.js')
403
431
  // 平台模式(--spec-dir 已指定)时,--dir 是明确的 source_root,不应被 resolveEffectiveDir 纠正
404
432
  const effectiveDir = specDir ? dir : resolveEffectiveDir(dir)
405
- await runCommand(filteredArgs.slice(1), effectiveDir, specDir)
433
+ await runCommand(filteredArgs.slice(1), effectiveDir, specDir, { json })
406
434
  break
407
435
  }
408
436
  // 顶层命令别名:转发 runCommand,与 case 'run': 路径行为一致。
@@ -513,7 +541,7 @@ async function main() {
513
541
  }
514
542
  // 否则:保持原有 prompt 驱动的 bash 自检流程
515
543
  const { runCommand } = await import('./run.js');
516
- await runCommand([command, ...filteredArgs.slice(1)], doctorEffectiveDir, specDir);
544
+ await runCommand([command, ...filteredArgs.slice(1)], doctorEffectiveDir, specDir, { json });
517
545
  break;
518
546
  }
519
547
  case 'scan':
@@ -528,7 +556,7 @@ async function main() {
528
556
  const { runCommand } = await import('./run.js')
529
557
  const stageArgs = [command, ...filteredArgs.slice(1)]
530
558
  const effectiveDir = specDir ? dir : resolveEffectiveDir(dir)
531
- await runCommand(stageArgs, effectiveDir, specDir)
559
+ await runCommand(stageArgs, effectiveDir, specDir, { json })
532
560
  break
533
561
  }
534
562
  case 'knowledge': {
@@ -610,6 +638,9 @@ SillySpec worktree — git worktree 隔离管理
610
638
  console.error('❌ 用法: sillyspec worktree create <change-name> [--base <branch>]');
611
639
  process.exit(1);
612
640
  }
641
+ // F6 路径穿越消毒:worktree 名用于分支名 + worktree 目录,含 ../ 会逃出 .sillyspec/worktrees/。
642
+ try { assertSafeChangeName(wtName, 'worktree 变更名'); }
643
+ catch (e) { console.error(`❌ ${e.message}`); process.exit(2); }
613
644
  const baseIdx = args.indexOf('--base');
614
645
  const base = baseIdx >= 0 && args[baseIdx + 1] ? args[baseIdx + 1] : undefined;
615
646
  try {
@@ -662,7 +693,14 @@ SillySpec worktree — git worktree 隔离管理
662
693
  } else if (result.merged) {
663
694
  console.log(`✅ 已通过 git merge 应用变更(baseline 漂移降级,引入合并提交)${result.mergeSummary ? ':' + result.mergeSummary : ''}`);
664
695
  } else {
665
- console.log(`✅ 已应用 ${result.changedFiles.length} 个文件变更`);
696
+ console.log(`✅ 已应用 ${result.changedFiles.length} 个文件变更:`);
697
+ for (const f of result.changedFiles) {
698
+ console.log(` ${f}`);
699
+ }
700
+ // filterDeliverableFiles 一刀切排除 .sillyspec/ + meta.json:
701
+ // 模块文档/变更文档等不会 auto-apply 到主工作区。须告知 agent,
702
+ // 否则它期望这些文件落地却找不到(memory: worktree-apply-excludes-module-docs)。
703
+ console.log(` ℹ️ 注:.sillyspec/ 下的文件(模块文档/变更文档等)按规则不自动 apply,如需请手动从 worktree 分支或 dangling commit 取(git show sillyspec/${wtName}:<path>)。`);
666
704
  }
667
705
  if (result.warnings && result.warnings.length > 0) {
668
706
  for (const w of result.warnings) {
@@ -708,7 +746,9 @@ SillySpec worktree — git worktree 隔离管理
708
746
  if (applyResult.errors.length > 0) {
709
747
  console.error('❌ apply 失败:', applyResult.errors.join('; '));
710
748
  } else {
711
- console.log(`✅ 已自动应用 ${applyResult.changedFiles.length} 个文件变更`);
749
+ console.log(`✅ 已自动应用 ${applyResult.changedFiles.length} 个文件变更:`);
750
+ for (const f of applyResult.changedFiles) console.log(` ${f}`);
751
+ console.log(` ℹ️ 注:.sillyspec/ 下的文件按规则不自动 apply,如需请手动从分支取(git show sillyspec/${wtName}:<path>)。`);
712
752
  }
713
753
  } else {
714
754
  console.log('Action: blocked');
@@ -970,6 +1010,14 @@ SillySpec platform — SillyHub 平台同步
970
1010
  console.error('❌ 用法: sillyspec change-rename <旧变更名> <新变更名>');
971
1011
  process.exit(1);
972
1012
  }
1013
+ // F6 路径穿越消毒:renameChange 会 mv changes/<old> → changes/<new>,名含 ../ 会逃出 changes/。
1014
+ try {
1015
+ assertSafeChangeName(oldName, '旧变更名');
1016
+ assertSafeChangeName(newName, '新变更名');
1017
+ } catch (e) {
1018
+ console.error(`❌ ${e.message}`);
1019
+ process.exit(2);
1020
+ }
973
1021
  const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(dir, specDir) });
974
1022
  await pm.renameChange(dir, oldName, newName);
975
1023
  break;
@@ -1100,7 +1148,11 @@ SillySpec workflow — 工作流管理
1100
1148
  }
1101
1149
  process.exit(result.status === 'pass' ? 0 : 1);
1102
1150
  } else {
1151
+ const wfSubs = ['check', 'list'];
1152
+ const sug = didYouMean(wfSub, wfSubs);
1103
1153
  console.error(`❌ 未知子命令: workflow ${wfSub}`);
1154
+ if (sug) console.error(` 你是想输入「workflow ${sug}」吗?`);
1155
+ console.error(` 可用子命令:${wfSubs.join(' | ')}(运行 sillyspec workflow 查看用法)`);
1104
1156
  process.exit(1);
1105
1157
  }
1106
1158
  break;
@@ -1128,7 +1180,11 @@ SillySpec modules — 模块文档管理
1128
1180
  const { migrateModuleDocs } = await import('./modules.js');
1129
1181
  await migrateModuleDocs(dir);
1130
1182
  } else {
1183
+ const modSubs = ['rebuild', 'status', 'migrate'];
1184
+ const sug = didYouMean(modulesSub, modSubs);
1131
1185
  console.error(`❌ 未知子命令: modules ${modulesSub}`);
1186
+ if (sug) console.error(` 你是想输入「modules ${sug}」吗?`);
1187
+ console.error(` 可用子命令:${modSubs.join(' | ')}(运行 sillyspec modules 查看用法)`);
1132
1188
  process.exit(1);
1133
1189
  }
1134
1190
  break;
@@ -1180,10 +1236,83 @@ SillySpec modules — 模块文档管理
1180
1236
  console.log(` 路径: ${localYamlPath}`);
1181
1237
  break;
1182
1238
  }
1183
- default:
1239
+ case 'runtime': {
1240
+ // D2:.runtime/ 产物索引。CLI 往 .runtime/ 写各种证据/状态文件(db、doctor-diagnosis、
1241
+ // workflow-runs、gate-status、user-inputs…),却没有命令让 agent 一眼看到「我手上有哪些产物」。
1242
+ // 多会话/压缩后尤其痛:上轮写的证据文件,下轮不知去哪找。纯只读枚举,不写盘。
1243
+ const runtimeSubCmd = filteredArgs[1];
1244
+ if (runtimeSubCmd && runtimeSubCmd !== 'list') {
1245
+ const sug = didYouMean(runtimeSubCmd, ['list']);
1246
+ console.error(`❌ 未知子命令: runtime ${runtimeSubCmd}`);
1247
+ if (sug) console.error(` 你是想输入「runtime ${sug}」吗?`);
1248
+ console.error('用法: sillyspec runtime list 枚举 .sillyspec/.runtime/ 产物(只读)');
1249
+ process.exit(2);
1250
+ }
1251
+ const specRoot = resolvePlatformSpecDir(dir, specDir) || join(dir, '.sillyspec');
1252
+ const runtimeDir = join(specRoot, '.runtime');
1253
+ if (!existsSync(runtimeDir)) {
1254
+ if (json) { console.log(JSON.stringify({ runtimeDir, exists: false, artifacts: [] }, null, 2)); break; }
1255
+ console.log(`📭 无 .runtime/ 目录(${runtimeDir})——尚未产生任何运行时产物。`);
1256
+ break;
1257
+ }
1258
+ // 已知产物 → 用途说明。agent 据此判断每份产物能否作为证据/该读哪个。
1259
+ const KNOWN = {
1260
+ 'sillyspec.db': '进度库 SQLite(stages/steps/changes 表)',
1261
+ 'sillyspec.db.bak': '进度库备份(写前快照)',
1262
+ 'sillyspec.db.schema-version': '进度库 schema 版本标记',
1263
+ 'doctor-diagnosis.json': 'doctor --json 结构化诊断快照',
1264
+ 'doctor-dumps': 'doctor --dump-db 取证输出目录',
1265
+ 'workflow-runs': 'workflow check --save 归档目录',
1266
+ 'gate-status.json': '门控结果缓存',
1267
+ 'scan-guard.json': 'scan 覆盖保护记录',
1268
+ 'quick-sessions': 'quick 会话记录目录',
1269
+ 'quick-guard.json': 'quick baseline 守卫(旧位置)',
1270
+ 'worktrees': 'worktree meta(hooks 侧)',
1271
+ 'user-inputs.md': '步骤输出/输入历史追加日志',
1272
+ 'current-quick-run-id': '当前 quick sessionId 指针(--done 无 --change 时 fallback)',
1273
+ 'scan-runs': '平台模式 scan/workflow 取证目录',
1274
+ 'artifacts': '派生产物目录(init 预建)',
1275
+ 'contract-artifacts': 'execute endpoint 契约产物(verify 阶段读取)',
1276
+ 'history': '历史记录目录(init 预建)',
1277
+ 'logs': '日志目录(init 预建)',
1278
+ 'templates': '工作流模板目录(init 预建)',
1279
+ };
1280
+ let entries;
1281
+ try {
1282
+ entries = readdirSync(runtimeDir, { withFileTypes: true });
1283
+ } catch (e) {
1284
+ console.error(`❌ 读取 .runtime/ 失败: ${e.message}`);
1285
+ process.exit(1);
1286
+ }
1287
+ entries.sort((a, b) => a.name.localeCompare(b.name));
1288
+ if (json) {
1289
+ console.log(JSON.stringify({
1290
+ runtimeDir, exists: true,
1291
+ artifacts: entries.map(e => ({ name: e.name, type: e.isDirectory() ? 'dir' : 'file', desc: KNOWN[e.name] || null })),
1292
+ }, null, 2));
1293
+ break;
1294
+ }
1295
+ console.log(`📂 .runtime/ 产物(${runtimeDir})`);
1296
+ if (entries.length === 0) { console.log(' (空目录)'); break; }
1297
+ for (const e of entries) {
1298
+ const kind = e.isDirectory() ? '📁' : '📄';
1299
+ const desc = KNOWN[e.name] ? ` — ${KNOWN[e.name]}` : '';
1300
+ console.log(` ${kind} ${e.name}${desc}`);
1301
+ }
1302
+ const unknown = entries.filter(e => !KNOWN[e.name]);
1303
+ if (unknown.length > 0) {
1304
+ console.log(`\n (${unknown.length} 项未登记:${unknown.map(e => e.name).join(', ')})`);
1305
+ }
1306
+ break;
1307
+ }
1308
+ default: {
1309
+ const topCommands = ['init', 'setup', 'run', 'progress', 'worktree', 'local', 'workflow', 'gate', 'derive', 'modules', 'change-rename', 'knowledge', 'platform', 'scan', 'brainstorm', 'plan', 'execute', 'verify', 'archive', 'quick', 'explore', 'status', 'doctor', 'auto', 'runtime'];
1310
+ const suggestion = didYouMean(command, topCommands);
1184
1311
  console.error(`❌ 未知命令: ${command}`);
1312
+ if (suggestion) console.error(` 你是想输入「${suggestion}」吗?`);
1185
1313
  printUsage();
1186
1314
  process.exit(1);
1315
+ }
1187
1316
  }
1188
1317
  }
1189
1318
 
@@ -125,10 +125,16 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot }
125
125
  const currentStage = progress.currentStage || '';
126
126
  const checks = [];
127
127
 
128
+ // W4-G (D-008):execute 阶段预计算 code evidence 一次,供 artifacts check(经 runValidators
129
+ // → validateExecuteOutputs)与下方 execute-evidence check 共享——避免各调一次 checkExecuteCodeEvidence
130
+ // 各 spawn 2 个 git 进程(gate execute 一次省 ≈ 60-200ms on Windows)。
131
+ const sharedEvidence = stage === 'execute' ? checkExecuteCodeEvidence(cwd, changeName) : undefined;
132
+
128
133
  // ── a. artifacts:阶段产物校验(全部阶段)──
129
134
  const r = runValidators(stage, cwd, changeName, {
130
135
  projectName: progress.project,
131
136
  specRoot,
137
+ ...(sharedEvidence ? { evidence: sharedEvidence } : {}),
132
138
  });
133
139
  checks.push({
134
140
  id: 'artifacts',
@@ -137,24 +143,24 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot }
137
143
  warnings: r.warnings || [],
138
144
  });
139
145
 
140
- // ── b. transition:状态转换合法性(信息性,不参与综合 ok)──
141
- // 与 run.js:1727 同源:传 fromStageData 触发 failed_post_check 门控,避免 gate 的
142
- // transition 结论与 completeStep 漂移(design §8「聚合语义漂移」风险对策)。
146
+ // ── b. transition:状态转换合法性(参与综合 ok,与 completeStep 硬阻断一致)──
147
+ // 与 run.js runStage 同源:传 fromStageData 触发 failed_post_check 门控。
148
+ // transition 必须参与 ok:否则 gate 返回 ok=true、exit 0,Agent 据此 --done 却被
149
+ // runStage 的 checkTransition 硬阻断(exit 1),gate/run 判定分裂(design §8 漂移)。
143
150
  const fromStageData = (progress.stages && currentStage && progress.stages[currentStage]) || undefined;
144
151
  const t = checkTransition(currentStage, stage, fromStageData ? { fromStageData } : {});
145
152
  checks.push({
146
153
  id: 'transition',
147
154
  ok: t.allowed,
148
- informational: true,
149
155
  errors: t.allowed ? [] : [t.reason].filter(Boolean),
150
156
  warnings: [],
151
157
  });
152
158
 
153
159
  // ── c. execute 阶段追加 execute-evidence + task-reviews ──
154
- // D-008@v1 去重:checkExecuteCodeEvidence 整个 runGate 只调一次,
155
- // 结果同时供 execute-evidence check 的 data 与综合结论。
160
+ // D-008 + W4-G:checkExecuteCodeEvidence 整个 runGate 只调一次(顶部 sharedEvidence 预计算),
161
+ // 这里复用——真正落实去重(原注释声称"只调一次"但实现里 execute-evidence check 仍重复调了第二次)。
156
162
  if (stage === 'execute') {
157
- const ev = checkExecuteCodeEvidence(cwd, changeName);
163
+ const ev = sharedEvidence;
158
164
  checks.push({
159
165
  id: 'execute-evidence',
160
166
  ok: ev.status !== 'unchanged',
@@ -0,0 +1,217 @@
1
+ // W6 Step9b: 变更注册表(changes 表生命周期)—— 注册/注销/重命名/隔离状态/平台同步戳/审批状态。
2
+ // 从 src/progress.js 单体 ProgressManager 抽出。持有 pm 引用(构造注入),调 pm._ensureDB /
3
+ // pm._changePath(persistence-core 留 facade 本体)。无共享常量依赖。
4
+ import { existsSync, mkdirSync, renameSync } from 'fs';
5
+
6
+ export class ChangeRegistry {
7
+ constructor(pm) {
8
+ this.pm = pm;
9
+ }
10
+
11
+ /**
12
+ * 列出所有活跃变更名
13
+ * SQL: SELECT name FROM changes WHERE status = 'active'
14
+ */
15
+ async listChanges(cwd) {
16
+ const db = await this.pm._ensureDB(cwd);
17
+ const sqlDb = db.getDb();
18
+ const rows = sqlDb.exec("SELECT name FROM changes WHERE status = 'active' ORDER BY name");
19
+ if (!rows || rows.length === 0) return [];
20
+ return rows[0].values.map(r => r[0]);
21
+ }
22
+
23
+ /**
24
+ * 注册变更到活跃列表
25
+ * SQL: INSERT OR IGNORE → 若已 archived 则 UPDATE status='active'
26
+ */
27
+ async registerChange(cwd, changeName) {
28
+ if (!changeName) {
29
+ console.warn('⚠️ registerChange: changeName 为空,跳过');
30
+ return;
31
+ }
32
+ const db = await this.pm._ensureDB(cwd);
33
+ db.transaction((sqlDb) => {
34
+ const now = new Date().toISOString();
35
+ // 尝试插入新行
36
+ sqlDb.run(
37
+ `INSERT OR IGNORE INTO changes (name, created_at, last_active)
38
+ VALUES (?, ?, ?)`,
39
+ [changeName, now, now]
40
+ );
41
+ // 注意:不复活已归档的变更——归档是不可逆操作
42
+ // 如果变更已存在且为 archived,保持 archived 状态不变
43
+ });
44
+ }
45
+
46
+ /**
47
+ * 更新变更的隔离状态
48
+ * @param {string} cwd - 项目根目录
49
+ * @param {string} changeName - 变更名
50
+ * @param {{ status: string, mode?: string, reason?: string }} isolation
51
+ */
52
+ async updateChangeIsolation(cwd, changeName, isolation) {
53
+ const db = await this.pm._ensureDB(cwd);
54
+ const sqlDb = db.getDb();
55
+ try {
56
+ sqlDb.run(
57
+ `UPDATE changes SET isolation_status = ?, isolation_mode = ?, isolation_reason = ?, last_active = ? WHERE name = ?`,
58
+ [isolation.status, isolation.mode || null, isolation.reason || null, new Date().toISOString(), changeName]
59
+ );
60
+ db._save();
61
+ } catch (err) {
62
+ console.warn('⚠️ 更新 isolation 状态失败:', err.message);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * 读取变更的隔离状态
68
+ * @param {string} cwd - 项目根目录
69
+ * @param {string} changeName - 变更名
70
+ * @returns {{ status: string|null, mode: string|null, reason: string|null }|null}
71
+ */
72
+ async readChangeIsolation(cwd, changeName) {
73
+ const db = await this.pm._ensureDB(cwd);
74
+ const sqlDb = db.getDb();
75
+ try {
76
+ const rows = sqlDb.exec(
77
+ `SELECT isolation_status, isolation_mode, isolation_reason FROM changes WHERE name = ?`,
78
+ [changeName]
79
+ );
80
+ if (!rows || rows.length === 0 || rows[0].values.length === 0) return null;
81
+ const [status, mode, reason] = rows[0].values[0];
82
+ return { status: status || null, mode: mode || null, reason: reason || null };
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ async _updatePlatformLastSync(cwd, changeName) {
89
+ if (!changeName) return;
90
+ const db = await this.pm._ensureDB(cwd);
91
+ db.transaction((sqlDb) => {
92
+ sqlDb.run(
93
+ 'UPDATE changes SET platform_last_sync = ?, platform_sync_enabled = 1 WHERE name = ?',
94
+ [new Date().toISOString(), changeName]
95
+ );
96
+ });
97
+ }
98
+
99
+ async _updateApprovalStatus(cwd, changeName, status, reason = null) {
100
+ if (!changeName || !status) return;
101
+ const db = await this.pm._ensureDB(cwd);
102
+ db.transaction((sqlDb) => {
103
+ const rows = sqlDb.exec('SELECT id FROM changes WHERE name = ?', [changeName]);
104
+ if (!rows || rows.length === 0 || rows[0].values.length === 0) return;
105
+ const changeId = rows[0].values[0][0];
106
+ const now = new Date().toISOString();
107
+ sqlDb.run(
108
+ `INSERT INTO approvals (change_id, status, requested_at, approved_at, rejection_reason)
109
+ VALUES (?, ?, ?, ?, ?)
110
+ ON CONFLICT(change_id) DO UPDATE SET
111
+ status = excluded.status,
112
+ approved_at = excluded.approved_at,
113
+ rejection_reason = excluded.rejection_reason`,
114
+ [
115
+ changeId,
116
+ status,
117
+ now,
118
+ status === 'approved' ? now : null,
119
+ status === 'rejected' ? reason : null,
120
+ ]
121
+ );
122
+ });
123
+ }
124
+
125
+ /**
126
+ * 重命名变更:同步更新 DB + 目录
127
+ * @param {string} cwd - 项目根目录
128
+ * @param {string} oldName - 旧变更名
129
+ * @param {string} newName - 新变更名
130
+ */
131
+ async renameChange(cwd, oldName, newName) {
132
+ if (!oldName || !newName) {
133
+ console.warn('⚠️ renameChange: 旧名或新名为空,跳过');
134
+ return;
135
+ }
136
+ if (oldName === newName) {
137
+ console.warn('⚠️ renameChange: 新旧名称相同,跳过');
138
+ return;
139
+ }
140
+ const db = await this.pm._ensureDB(cwd);
141
+ // 检查旧名是否存在
142
+ const existing = db.transaction((sqlDb) => {
143
+ const row = sqlDb.exec(`SELECT name, status FROM changes WHERE name = ?`, [oldName]);
144
+ if (!row || !row[0] || row[0].values.length === 0) return null;
145
+ return { name: row[0].values[0][0], status: row[0].values[0][1] };
146
+ });
147
+ if (!existing) {
148
+ console.error(`❌ 变更 ${oldName} 不存在`);
149
+ return;
150
+ }
151
+ // 检查新名是否已存在
152
+ const conflict = db.transaction((sqlDb) => {
153
+ const row = sqlDb.exec(`SELECT name FROM changes WHERE name = ?`, [newName]);
154
+ return row && row[0] && row[0].values.length > 0;
155
+ });
156
+ if (conflict) {
157
+ console.error(`❌ 变更 ${newName} 已存在`);
158
+ return;
159
+ }
160
+ // 先更新 DB,再重命名目录;FS 失败则回滚 DB,避免"目录已改名但 DB 旧名"的孤儿
161
+ // (旧实现 FS-first 无补偿:renameSync 成功后 DB transaction 抛 EPERM/EBUSY 会让
162
+ // 目录已是 newName、DB 仍是 oldName,read(两名) 都失联且无自动恢复)。
163
+ const oldDir = this.pm._changePath(cwd, oldName);
164
+ const newDir = this.pm._changePath(cwd, newName);
165
+ const now = new Date().toISOString();
166
+ try {
167
+ db.transaction((sqlDb) => {
168
+ sqlDb.run(`UPDATE changes SET name = ?, last_active = ? WHERE name = ?`, [newName, now, oldName]);
169
+ });
170
+ } catch (e) {
171
+ console.error(`❌ 重命名失败:更新数据库时出错(${e.message})`);
172
+ return;
173
+ }
174
+ let renamed = true;
175
+ if (existsSync(oldDir)) {
176
+ try {
177
+ renameSync(oldDir, newDir);
178
+ } catch (e) {
179
+ renamed = false;
180
+ // FS 重命名失败:回滚 DB 恢复 oldName,保持 DB 与目录一致
181
+ try {
182
+ db.transaction((sqlDb) => {
183
+ sqlDb.run(`UPDATE changes SET name = ?, last_active = ? WHERE name = ?`, [oldName, now, newName]);
184
+ });
185
+ console.error(`❌ 重命名失败:移动目录出错(${e.message}),已回滚数据库`);
186
+ } catch (rollbackErr) {
187
+ // 回滚本身也失败:DB 是 newName、目录是 oldName,两端分裂且无自动恢复。
188
+ // 不能再撒谎"已回滚"——必须显式报错让用户跑 doctor 修复。
189
+ console.error(`❌ 重命名失败且数据库回滚也失败(状态分裂,需手动修复):原错=${e.message} 回滚错=${rollbackErr.message}。请跑 sillyspec doctor --json`);
190
+ }
191
+ }
192
+ } else {
193
+ mkdirSync(newDir, { recursive: true });
194
+ }
195
+ if (renamed) console.log(`✅ 变更已重命名:${oldName} → ${newName}`);
196
+ }
197
+
198
+ /**
199
+ * 从活跃列表移除变更(归档时调用,不物理删除)
200
+ * SQL: UPDATE changes SET status = 'archived'
201
+ */
202
+ async unregisterChange(cwd, changeName) {
203
+ if (!changeName) {
204
+ console.warn('⚠️ unregisterChange: changeName 为空,跳过');
205
+ return;
206
+ }
207
+ const db = await this.pm._ensureDB(cwd);
208
+ db.transaction((sqlDb) => {
209
+ const now = new Date().toISOString();
210
+ sqlDb.run(
211
+ `UPDATE changes SET status = 'archived', last_active = ? WHERE name = ?`,
212
+ [now, changeName]
213
+ );
214
+ });
215
+ }
216
+ }
217
+