sillyspec 3.25.9 → 3.26.0

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 (76) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +8 -13
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +8 -6
  3. package/.claude/skills/sillyspec-continue/SKILL.md +3 -3
  4. package/.claude/skills/sillyspec-doctor/SKILL.md +1 -1
  5. package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
  6. package/.claude/skills/sillyspec-plan/SKILL.md +1 -1
  7. package/.claude/skills/sillyspec-quick/SKILL.md +3 -1
  8. package/.claude/skills/sillyspec-verify/SKILL.md +1 -1
  9. package/.sillyspec-platform-cleaned +1 -0
  10. package/.sillyspec-platform.json +7 -0
  11. package/README.md +9 -0
  12. package/package.json +2 -2
  13. package/src/change-list.js +11 -8
  14. package/src/change-risk-profile.js +22 -0
  15. package/src/db.js +170 -161
  16. package/src/dispatch/backends/local-agent.js +152 -0
  17. package/src/dispatch/backends/sillyhub-mcp.js +242 -0
  18. package/src/dispatch/probe.js +210 -0
  19. package/src/dispatch/strategy.js +121 -0
  20. package/src/doctor-diagnostics.js +19 -25
  21. package/src/fs-atomic.js +69 -69
  22. package/src/git-helper.js +66 -0
  23. package/src/hooks/worktree-guard.js +64 -55
  24. package/src/index.js +326 -36
  25. package/src/init.js +7 -14
  26. package/src/local-detect.js +120 -110
  27. package/src/machine-interface.js +54 -19
  28. package/src/migrate.js +14 -4
  29. package/src/modules.js +16 -5
  30. package/src/progress/change-registry.js +85 -70
  31. package/src/progress/consistency-doctor.js +71 -14
  32. package/src/progress/shared.js +8 -5
  33. package/src/progress/stage-machine.js +67 -65
  34. package/src/progress/step-store.js +72 -67
  35. package/src/progress.js +448 -236
  36. package/src/quicklog.js +59 -12
  37. package/src/run/command.js +78 -39
  38. package/src/run/complete-handlers.js +1028 -889
  39. package/src/run/complete.js +132 -212
  40. package/src/run/concurrent-detect.js +132 -0
  41. package/src/run/gates.js +302 -19
  42. package/src/run/prompt.js +22 -68
  43. package/src/run/quick-audit.js +10 -9
  44. package/src/run/shared.js +722 -600
  45. package/src/run/stage.js +43 -24
  46. package/src/scan-postcheck.js +9 -31
  47. package/src/sillyhub-mcp/client.js +394 -0
  48. package/src/sillyhub-mcp/config.js +62 -0
  49. package/src/spec-dir-typo.js +71 -0
  50. package/src/stage-contract-engine.js +22 -1
  51. package/src/stage-contract-spec.js +11 -4
  52. package/src/stage-contract.js +26 -3
  53. package/src/stage-review.js +118 -10
  54. package/src/stages/archive.js +4 -6
  55. package/src/stages/brainstorm-auto.js +26 -20
  56. package/src/stages/brainstorm.js +29 -5
  57. package/src/stages/cmd-existence.js +145 -0
  58. package/src/stages/doctor.js +10 -7
  59. package/src/stages/execute.js +64 -22
  60. package/src/stages/plan-postcheck.js +152 -0
  61. package/src/stages/plan.js +16 -31
  62. package/src/stages/quick.js +25 -9
  63. package/src/stages/scan.js +53 -8
  64. package/src/stages/status.js +0 -1
  65. package/src/stages/verify.js +14 -9
  66. package/src/sync.js +433 -16
  67. package/src/task-review.js +59 -1
  68. package/src/verify-postcheck.js +938 -862
  69. package/src/version.js +19 -0
  70. package/src/worktree-apply.js +218 -93
  71. package/src/worktree-deps.js +89 -5
  72. package/src/worktree.js +213 -111
  73. package/templates/prompts/taskcard-rules.md +27 -0
  74. package/templates/prompts/verify-probes.md +3 -3
  75. package/.claude/skills/sillyspec-propose/SKILL.md +0 -87
  76. package/src/stages/propose.js +0 -181
package/src/progress.js CHANGED
@@ -5,16 +5,16 @@
5
5
  *
6
6
  * 存储结构:
7
7
  * .sillyspec/.runtime/sillyspec.db — SQLite 数据库(权威状态源,含全局状态/项目名/活跃变更)
8
- * .sillyspec/.runtime/gate-status.json — worktree-guard 门禁状态缓存
9
8
  *
10
9
  * 历史迁移:v1/v2 使用 progress.json 文件,v3 已全部迁移至 SQLite。
10
+ * worktree-guard hook 直读 sillyspec.db,不再有 gate-status.json 缓存双源(task-10 废除)。
11
11
  */
12
12
 
13
- import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, appendFileSync } from 'fs';
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, appendFileSync, copyFileSync } from 'fs';
14
14
  import { join, basename, dirname, resolve, sep } from 'path';
15
15
  import { tmpdir } from 'os';
16
16
  import { DB } from './db.js';
17
- import { writeAtomicSync } from './fs-atomic.js';
17
+ import { checkExecuteCodeEvidence } from './stage-contract.js';
18
18
  import { ConsistencyDoctor } from './progress/consistency-doctor.js';
19
19
  import { ChangeRegistry } from './progress/change-registry.js';
20
20
  import { StepStore } from './progress/step-store.js';
@@ -191,11 +191,11 @@ export class ProgressManager {
191
191
  }
192
192
  }
193
193
 
194
- /** 懒初始化 DB 连接,缓存在实例上 */
195
- async _ensureDB(cwd) {
194
+ /** 懒初始化 DB 连接,缓存在实例上(better-sqlite3 同步 API,init 无 async) */
195
+ _ensureDB(cwd) {
196
196
  if (!this._db) {
197
197
  this._db = new DB(this._runtimePath(cwd, 'sillyspec.db'));
198
- await this._db.init();
198
+ this._db.init();
199
199
  }
200
200
  return this._db;
201
201
  }
@@ -208,21 +208,19 @@ export class ProgressManager {
208
208
 
209
209
  // ── 全局状态 ──
210
210
 
211
- async readGlobal(cwd) {
211
+ readGlobal(cwd) {
212
212
  // SQL: SELECT FROM project + changes
213
- const db = await this._ensureDB(cwd);
213
+ const db = this._ensureDB(cwd);
214
214
  const sqlDb = db.getDb();
215
215
 
216
216
  // 读取 project 行(id=1)
217
- const rows = sqlDb.exec('SELECT name, schema_version FROM project WHERE id = 1');
218
- if (!rows || rows.length === 0 || rows[0].values.length === 0) return null;
219
- const [name, schemaVersion] = rows[0].values[0];
217
+ const row = sqlDb.prepare('SELECT name, schema_version FROM project WHERE id = 1').get();
218
+ if (row === undefined) return null;
219
+ const { name, schema_version: schemaVersion } = row;
220
220
 
221
221
  // 读取 active 变更列表
222
- const changeRows = sqlDb.exec("SELECT name FROM changes WHERE status = 'active' ORDER BY name");
223
- const activeChanges = changeRows && changeRows.length > 0
224
- ? changeRows[0].values.map(r => r[0])
225
- : [];
222
+ const changeRows = sqlDb.prepare("SELECT name FROM changes WHERE status = 'active' ORDER BY name").all();
223
+ const activeChanges = changeRows.map(r => r.name);
226
224
 
227
225
  return {
228
226
  _version: schemaVersion,
@@ -237,12 +235,12 @@ export class ProgressManager {
237
235
  * 读取指定变更的 progress(SQL 版)
238
236
  * @param {string} cwd
239
237
  * @param {string|null} changeName - 变更名,null 时尝试自动检测
240
- * @returns {Promise<object|null>} 与 SQLite 查询结果一致的 JS 对象
238
+ * @returns {object|null} 与 SQLite 查询结果一致的 JS 对象
241
239
  */
242
- async read(cwd, changeName = null) {
243
- // 自动检测变更名
240
+ read(cwd, changeName = null) {
241
+ // 自动检测变更名(listChanges 已在 task-08 同步化,此处直接拿到活跃变更数组)
244
242
  if (!changeName) {
245
- const changes = await this.listChanges(cwd);
243
+ const changes = this.listChanges(cwd);
246
244
  if (changes.length === 1) {
247
245
  changeName = changes[0];
248
246
  } else {
@@ -251,44 +249,39 @@ export class ProgressManager {
251
249
  }
252
250
  }
253
251
 
254
- const db = await this._ensureDB(cwd);
252
+ const db = this._ensureDB(cwd);
255
253
  const sqlDb = db.getDb();
256
254
 
257
255
  // 1. 从 changes 表获取基本信息
258
- const changeRows = sqlDb.exec('SELECT id, name, current_stage, no_worktree, last_active FROM changes WHERE name = ?', [changeName]);
259
- if (!changeRows || changeRows.length === 0 || changeRows[0].values.length === 0) return null;
260
- const [changeId, cName, currentStage, noWorktree, lastActive] = changeRows[0].values[0];
256
+ const changeRow = sqlDb.prepare('SELECT id, name, current_stage, no_worktree, last_active FROM changes WHERE name = ?').get(changeName);
257
+ if (changeRow === undefined) return null;
258
+ const { id: changeId, name: cName, current_stage: currentStage, no_worktree: noWorktree, last_active: lastActive } = changeRow;
261
259
 
262
260
  // 2. 从 stages 表获取所有阶段(含 revision 列)
263
- const stageRows = sqlDb.exec('SELECT id, stage, status, started_at, completed_at, revision, reopened_from_step, reopened_at, stale_reason FROM stages WHERE change_id = ? ORDER BY id', [changeId]);
261
+ const stageRows = sqlDb.prepare('SELECT id, stage, status, started_at, completed_at, revision, reopened_from_step, reopened_at, stale_reason FROM stages WHERE change_id = ? ORDER BY id').all(changeId);
264
262
  const stageMap = {};
265
263
  const stageIds = [];
266
- if (stageRows && stageRows.length > 0) {
267
- for (const [sId, stage, status, startedAt, completedAt, revision, reopenedFromStep, reopenedAt, staleReason] of stageRows[0].values) {
268
- stageMap[stage] = { _dbId: sId, status, startedAt, completedAt,
269
- ...(revision ? { revision } : {}),
270
- ...(reopenedFromStep ? { reopenedFromStep } : {}),
271
- ...(reopenedAt ? { reopenedAt } : {}),
272
- ...(staleReason ? { staleReason } : {}),
273
- };
274
- stageIds.push(sId);
275
- }
264
+ for (const row of stageRows) {
265
+ const { id: sId, stage, status, started_at: startedAt, completed_at: completedAt, revision, reopened_from_step: reopenedFromStep, reopened_at: reopenedAt, stale_reason: staleReason } = row;
266
+ stageMap[stage] = { _dbId: sId, status, startedAt, completedAt,
267
+ ...(revision ? { revision } : {}),
268
+ ...(reopenedFromStep ? { reopenedFromStep } : {}),
269
+ ...(reopenedAt ? { reopenedAt } : {}),
270
+ ...(staleReason ? { staleReason } : {}),
271
+ };
272
+ stageIds.push(sId);
276
273
  }
277
274
 
278
275
  // 3. 从 steps 表获取所有步骤
279
- let stepRows = null;
276
+ const stepsByStage = {};
280
277
  if (stageIds.length > 0) {
281
278
  const placeholders = stageIds.map(() => '?').join(',');
282
- stepRows = sqlDb.exec(
283
- `SELECT stage_id, name, status, output, completed_at, ordering, wait_reason, wait_options, wait_answer, waited_at, wait_answers, wait_round, max_wait_rounds FROM steps WHERE stage_id IN (${placeholders}) ORDER BY stage_id, ordering`,
284
- stageIds
285
- );
286
- }
287
- // 按阶段分组步骤
288
- const stepsByStage = {};
289
- if (stepRows && stepRows.length > 0) {
290
- for (const row of stepRows[0].values) {
291
- const [stageId, name, status, output, completedAt, ordering, waitReason, waitOptions, waitAnswer, waitedAt, waitAnswersJson, waitRound, maxWaitRounds] = row;
279
+ const stepRows = sqlDb.prepare(
280
+ `SELECT stage_id, name, status, output, completed_at, ordering, wait_reason, wait_options, wait_answer, waited_at, wait_answers, wait_round, max_wait_rounds FROM steps WHERE stage_id IN (${placeholders}) ORDER BY stage_id, ordering`
281
+ ).all(...stageIds);
282
+ // 按阶段分组步骤
283
+ for (const row of stepRows) {
284
+ const { stage_id: stageId, name, status, output, completed_at: completedAt, wait_reason: waitReason, wait_options: waitOptions, wait_answer: waitAnswer, waited_at: waitedAt, wait_answers: waitAnswersJson, wait_round: waitRound, max_wait_rounds: maxWaitRounds } = row;
292
285
  if (!stepsByStage[stageId]) stepsByStage[stageId] = [];
293
286
  let waitAnswers = null;
294
287
  if (waitAnswersJson) {
@@ -308,18 +301,16 @@ export class ProgressManager {
308
301
  }
309
302
 
310
303
  // 4. 从 batch_progress 表获取批量进度
311
- const batchRows = sqlDb.exec('SELECT total, completed, failed, skipped FROM batch_progress WHERE change_id = ?', [changeId]);
304
+ const batchRow = sqlDb.prepare('SELECT total, completed, failed, skipped FROM batch_progress WHERE change_id = ?').get(changeId);
312
305
  let batchProgress = undefined;
313
- if (batchRows && batchRows.length > 0 && batchRows[0].values.length > 0) {
314
- const [total, completed, failed, skipped] = batchRows[0].values[0];
306
+ if (batchRow !== undefined) {
307
+ const { total, completed, failed, skipped } = batchRow;
315
308
  batchProgress = { total, completed, failed, skipped };
316
309
  }
317
310
 
318
311
  // 5. 获取项目名
319
- const projectRows = sqlDb.exec('SELECT name FROM project WHERE id = 1');
320
- const projectName = (projectRows && projectRows.length > 0 && projectRows[0].values.length > 0)
321
- ? projectRows[0].values[0][0]
322
- : '';
312
+ const projectRow = sqlDb.prepare('SELECT name FROM project WHERE id = 1').get();
313
+ const projectName = projectRow !== undefined ? projectRow.name : '';
323
314
 
324
315
  // 6. 组装为兼容对象
325
316
  const stages = {};
@@ -356,7 +347,7 @@ export class ProgressManager {
356
347
  }
357
348
 
358
349
  const result = {
359
- _version: 3,
350
+ _version: 4,
360
351
  project: projectName,
361
352
  currentChange: cName,
362
353
  currentStage: currentStage || '',
@@ -373,39 +364,337 @@ export class ProgressManager {
373
364
  return result;
374
365
  }
375
366
 
367
+ /**
368
+ * 同步专用六表完整序列化(design §8/B1,task-02 / D-005@v2 / FR-07)。
369
+ * 与 read()(聚合视图,不读 approvals 且 changes 只投影五列)不同,本方法输出真正六表投影,
370
+ * 作为同步载体(ProgressManager.import() 的逆运算,task-03)。
371
+ *
372
+ * 输出契约(字段名 = DB 列名 snake_case,import 直接对列写回,round-trip 无映射损耗):
373
+ * project { name, schema_version } —— 全局单行稳定字段(id/created_at/updated_at 本地元数据不同步)
374
+ * changes [{ name, current_stage, status, last_active,
375
+ * last_synced_platform_ts, last_local_modified_ts }] —— 只投影流程进度列;
376
+ * 排除 isolation_* 系列 与 platform_* 系列(platform_change_id/workspace_id/last_sync/sync_enabled)
377
+ * 以及 created_at(本地强相关,不同步,B2)
378
+ * stages [{ change_name, stage, status, started_at, completed_at,
379
+ * revision, reopened_from_step, reopened_at, stale_reason }] —— 用 change_name+stage 替代 id 外键
380
+ * steps [{ change_name, stage, name, status, output, completed_at, ordering,
381
+ * wait_reason, wait_options, wait_answer, waited_at, wait_answers,
382
+ * wait_round, max_wait_rounds }] —— wait_options/wait_answers 透传 DB JSON 字符串
383
+ * batch_progress [{ change_name, total, completed, failed, skipped }]
384
+ * approvals [{ change_name, status, requested_at, approved_by, approved_at, rejection_reason }]
385
+ * 可空列归一为 null(非 undefined,保证 JSON 往返键稳定);不含 user/base_ts/pushed_at(D-015,sync.js 放 HTTP header)。
386
+ *
387
+ * @param {string} cwd
388
+ * @param {string|null} [changeName] - 空时单活跃变更自动推导(对齐 read());多/零活跃返回 null
389
+ * @returns {object|null} 六键同步 JSON;change 不存在或无法推导返回 null
390
+ */
391
+ serializeForSync(cwd, changeName = null) {
392
+ // 自动检测变更名(与 read() 同口径):单活跃变更推导,多/零无法确定返回 null
393
+ if (!changeName) {
394
+ const changes = this.listChanges(cwd);
395
+ if (changes.length === 1) {
396
+ changeName = changes[0];
397
+ } else {
398
+ return null;
399
+ }
400
+ }
401
+
402
+ const db = this._ensureDB(cwd);
403
+ const sqlDb = db.getDb();
404
+
405
+ // 1. changes 行(投影流程进度列 + name 标识)
406
+ const changeRow = sqlDb.prepare(
407
+ `SELECT id, name, current_stage, status, last_active, last_synced_platform_ts, last_local_modified_ts
408
+ FROM changes WHERE name = ?`
409
+ ).get(changeName);
410
+ if (changeRow === undefined) return null;
411
+ const changeId = changeRow.id;
412
+ const changeProj = {
413
+ name: changeRow.name,
414
+ current_stage: changeRow.current_stage,
415
+ status: changeRow.status,
416
+ last_active: changeRow.last_active,
417
+ last_synced_platform_ts: changeRow.last_synced_platform_ts ?? null,
418
+ last_local_modified_ts: changeRow.last_local_modified_ts ?? null,
419
+ };
420
+
421
+ // 2. project 全局单行(只投影稳定字段 name/schema_version;created_at/updated_at 是本地库元数据,
422
+ // 同步无意义且破坏 round-trip 跨库等值——与 changes 排除 created_at 同理,B2)
423
+ const projectRow = sqlDb.prepare(
424
+ 'SELECT name, schema_version FROM project WHERE id = 1'
425
+ ).get();
426
+
427
+ // 3. stages(该 change 下,用 change_name+stage 表达外键)
428
+ const stageRows = sqlDb.prepare(
429
+ `SELECT id, stage, status, started_at, completed_at, revision, reopened_from_step, reopened_at, stale_reason
430
+ FROM stages WHERE change_id = ? ORDER BY id`
431
+ ).all(changeId);
432
+ const stageJson = stageRows.map(r => ({
433
+ change_name: changeRow.name,
434
+ stage: r.stage,
435
+ status: r.status,
436
+ started_at: r.started_at ?? null,
437
+ completed_at: r.completed_at ?? null,
438
+ revision: r.revision ?? null,
439
+ reopened_from_step: r.reopened_from_step ?? null,
440
+ reopened_at: r.reopened_at ?? null,
441
+ stale_reason: r.stale_reason ?? null,
442
+ }));
443
+
444
+ // 4. steps(stage_id IN;用 change_name+stage 关联回 stage)
445
+ const stageIds = stageRows.map(r => r.id);
446
+ const stageIdToName = new Map(stageRows.map(r => [r.id, r.stage]));
447
+ let stepJson = [];
448
+ if (stageIds.length > 0) {
449
+ const placeholders = stageIds.map(() => '?').join(',');
450
+ const stepRows = sqlDb.prepare(
451
+ `SELECT stage_id, name, status, output, completed_at, ordering, wait_reason, wait_options,
452
+ wait_answer, waited_at, wait_answers, wait_round, max_wait_rounds
453
+ FROM steps WHERE stage_id IN (${placeholders}) ORDER BY stage_id, ordering`
454
+ ).all(...stageIds);
455
+ stepJson = stepRows.map(r => ({
456
+ change_name: changeRow.name,
457
+ stage: stageIdToName.get(r.stage_id),
458
+ name: r.name,
459
+ status: r.status,
460
+ output: r.output ?? null,
461
+ completed_at: r.completed_at ?? null,
462
+ ordering: r.ordering ?? null,
463
+ wait_reason: r.wait_reason ?? null,
464
+ wait_options: r.wait_options ?? null,
465
+ wait_answer: r.wait_answer ?? null,
466
+ waited_at: r.waited_at ?? null,
467
+ wait_answers: r.wait_answers ?? null,
468
+ wait_round: r.wait_round ?? null,
469
+ max_wait_rounds: r.max_wait_rounds ?? null,
470
+ }));
471
+ }
472
+
473
+ // 5. batch_progress(无行则空数组,import 侧幂等)
474
+ const batchRow = sqlDb.prepare(
475
+ 'SELECT total, completed, failed, skipped FROM batch_progress WHERE change_id = ?'
476
+ ).get(changeId);
477
+ const batchJson = batchRow !== undefined ? [{
478
+ change_name: changeRow.name,
479
+ total: batchRow.total,
480
+ completed: batchRow.completed,
481
+ failed: batchRow.failed,
482
+ skipped: batchRow.skipped,
483
+ }] : [];
484
+
485
+ // 6. approvals(read() 不读此表,serializeForSync 补齐;无行则空数组)
486
+ const approvalRow = sqlDb.prepare(
487
+ 'SELECT status, requested_at, approved_by, approved_at, rejection_reason FROM approvals WHERE change_id = ?'
488
+ ).get(changeId);
489
+ const approvalJson = approvalRow !== undefined ? [{
490
+ change_name: changeRow.name,
491
+ status: approvalRow.status,
492
+ requested_at: approvalRow.requested_at ?? null,
493
+ approved_by: approvalRow.approved_by ?? null,
494
+ approved_at: approvalRow.approved_at ?? null,
495
+ rejection_reason: approvalRow.rejection_reason ?? null,
496
+ }] : [];
497
+
498
+ return {
499
+ project: projectRow || null,
500
+ changes: [changeProj],
501
+ stages: stageJson,
502
+ steps: stepJson,
503
+ batch_progress: batchJson,
504
+ approvals: approvalJson,
505
+ };
506
+ }
507
+
508
+ /**
509
+ * serializeForSync 的逆运算:把平台权威 JSON 原子写回本地 DB 的该 change 行(design §8/B1,task-03 / D-005@v2 / D-011 / FR-07)。
510
+ * ⚠️ 必须保护真实资产:import 前对 DB 做【独立】snapshot `.runtime/sillyspec.db.pre-import-<ts>.bak`,
511
+ * 不抢 _openWithFallback 的 `${dbPath}.bak`(gap5/R-08);任何失败本地进度库不损坏。
512
+ *
513
+ * 语义:
514
+ * - 单个 DB.transaction() 包裹,原子重建 stages/steps/batch_progress/approvals 四表(任一失败整体回滚)
515
+ * - changes 行用 UPDATE 选择投影列(current_stage/status/last_active/last_synced_platform_ts/last_local_modified_ts),
516
+ * 保留 isolation_* / platform_* / created_at(本地强相关状态不被覆盖,B2;change 不存在时 INSERT 兜底平台新增)
517
+ * - import 后 last_synced_platform_ts 与 last_local_modified_ts 均置为 progressObj.pushed_at(D-013 例外:
518
+ * 不更新 now()——否则 now()>base_ts 下次 pull 误判冲突;pushed_at 由 sync.js pull() 从响应 header attach)
519
+ * - 本地确定性操作,失败 throw 中文(非 sync 类 Best Effort,CONVENTIONS #4)
520
+ *
521
+ * @param {string} cwd
522
+ * @param {object} progressObj - serializeForSync 输出 + sync.js 附加的 pushed_at
523
+ * @param {string|null} [changeName] - 空则从 progressObj.changes[0].name 推导
524
+ * @returns {ImportResult} { ok:true, imported:changeName, reason?, bakPath }
525
+ */
526
+ import(cwd, progressObj, changeName = null) {
527
+ if (!progressObj || typeof progressObj !== 'object') {
528
+ throw new Error('import 参数无效:progressObj 必须是 serializeForSync() 输出的六表 JSON 对象');
529
+ }
530
+
531
+ const cn = changeName || (progressObj.changes && progressObj.changes[0] && progressObj.changes[0].name);
532
+ if (!cn) {
533
+ throw new Error('import 缺少 changeName:无法定位目标 change(progressObj.changes[0].name 为空)');
534
+ }
535
+
536
+ // ⚠️ 必须保护真实资产:独立 .bak snapshot(前缀 pre-import- 区分于主 .bak 备份机制)
537
+ const dbPath = this._runtimePath(cwd, 'sillyspec.db');
538
+ if (!existsSync(dbPath)) {
539
+ throw new Error(`本地进度库不存在,无法 import:${dbPath}`);
540
+ }
541
+ const bakTs = new Date().toISOString().replace(/[:.]/g, '-');
542
+ const bakPath = this._runtimePath(cwd, `sillyspec.db.pre-import-${bakTs}.bak`);
543
+ copyFileSync(dbPath, bakPath);
544
+
545
+ const pushedAt = progressObj.pushed_at ?? null;
546
+ const now = new Date().toISOString();
547
+
548
+ try {
549
+ const db = this._ensureDB(cwd);
550
+ db.transaction(() => {
551
+ const sqlDb = db.getDb();
552
+
553
+ // 1. changes 行:INSERT OR IGNORE 兜底平台新增 change;UPDATE 只覆盖投影列(保留本地隔离/平台/created_at 列)
554
+ sqlDb.prepare(
555
+ `INSERT OR IGNORE INTO changes (name, current_stage, status, created_at, last_active)
556
+ VALUES (?, '', 'active', ?, ?)`
557
+ ).run(cn, now, now);
558
+ const ch = progressObj.changes && progressObj.changes[0];
559
+ sqlDb.prepare(
560
+ `UPDATE changes SET
561
+ current_stage = ?, status = ?, last_active = ?,
562
+ last_synced_platform_ts = ?, last_local_modified_ts = ?
563
+ WHERE name = ?`
564
+ ).run(
565
+ ch && ch.current_stage != null ? ch.current_stage : '',
566
+ ch && ch.status != null ? ch.status : 'active',
567
+ ch && ch.last_active != null ? ch.last_active : now,
568
+ pushedAt, pushedAt, cn
569
+ );
570
+ const changeRow = sqlDb.prepare('SELECT id FROM changes WHERE name = ?').get(cn);
571
+ const changeId = changeRow.id;
572
+
573
+ // 2. project 行:幂等确保存在(不覆盖已有全局行,round-trip 等值不受影响;仅补缺失)
574
+ const projExists = sqlDb.prepare('SELECT id FROM project WHERE id = 1').get();
575
+ if (projExists === undefined) {
576
+ const pj = progressObj.project || {};
577
+ sqlDb.prepare(
578
+ `INSERT INTO project (id, name, schema_version, created_at, updated_at)
579
+ VALUES (1, ?, ?, ?, ?)`
580
+ ).run(pj.name != null ? pj.name : 'project', pj.schema_version != null ? pj.schema_version : CURRENT_VERSION, now, now);
581
+ }
582
+
583
+ // 3. 重建 stages + steps:DELETE stages(ON DELETE CASCADE 级联删 steps),按 JSON 重建
584
+ sqlDb.prepare('DELETE FROM stages WHERE change_id = ?').run(changeId);
585
+ const stageToId = new Map();
586
+ for (const s of (progressObj.stages || [])) {
587
+ if (!s || !s.stage) continue;
588
+ sqlDb.prepare(
589
+ `INSERT INTO stages (change_id, stage, status, started_at, completed_at, revision, reopened_from_step, reopened_at, stale_reason)
590
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
591
+ ).run(changeId, s.stage, s.status != null ? s.status : 'pending', s.started_at ?? null, s.completed_at ?? null,
592
+ s.revision != null ? s.revision : 0, s.reopened_from_step ?? null, s.reopened_at ?? null, s.stale_reason ?? null);
593
+ const row = sqlDb.prepare('SELECT id FROM stages WHERE change_id = ? AND stage = ?').get(changeId, s.stage);
594
+ stageToId.set(s.stage, row.id);
595
+ }
596
+ for (const st of (progressObj.steps || [])) {
597
+ if (!st || !st.name) continue;
598
+ const stageId = stageToId.get(st.stage);
599
+ if (stageId === undefined) continue; // 孤儿 step(stage 不在 JSON)跳过,不破坏完整性
600
+ sqlDb.prepare(
601
+ `INSERT INTO steps (stage_id, name, status, output, completed_at, ordering, wait_reason, wait_options, wait_answer, waited_at, wait_answers, wait_round, max_wait_rounds)
602
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
603
+ ).run(stageId, st.name, st.status != null ? st.status : 'pending', st.output ?? null, st.completed_at ?? null,
604
+ st.ordering != null ? st.ordering : 0, st.wait_reason ?? null, st.wait_options ?? null, st.wait_answer ?? null,
605
+ st.waited_at ?? null, st.wait_answers ?? null, st.wait_round ?? null, st.max_wait_rounds ?? null);
606
+ }
607
+
608
+ // 4. 重建 batch_progress(DELETE 再插,无行则清空)
609
+ sqlDb.prepare('DELETE FROM batch_progress WHERE change_id = ?').run(changeId);
610
+ const bp = progressObj.batch_progress && progressObj.batch_progress[0];
611
+ if (bp) {
612
+ sqlDb.prepare(
613
+ `INSERT INTO batch_progress (change_id, total, completed, failed, skipped)
614
+ VALUES (?, ?, ?, ?, ?)`
615
+ ).run(changeId, bp.total != null ? bp.total : 0, bp.completed != null ? bp.completed : 0,
616
+ bp.failed != null ? bp.failed : 0, bp.skipped != null ? bp.skipped : 0);
617
+ }
618
+
619
+ // 5. 重建 approvals(DELETE 再插,无行则清空)
620
+ sqlDb.prepare('DELETE FROM approvals WHERE change_id = ?').run(changeId);
621
+ const ap = progressObj.approvals && progressObj.approvals[0];
622
+ if (ap) {
623
+ sqlDb.prepare(
624
+ `INSERT INTO approvals (change_id, status, requested_at, approved_by, approved_at, rejection_reason)
625
+ VALUES (?, ?, ?, ?, ?, ?)`
626
+ ).run(changeId, ap.status != null ? ap.status : 'not_required', ap.requested_at ?? null, ap.approved_by ?? null,
627
+ ap.approved_at ?? null, ap.rejection_reason ?? null);
628
+ }
629
+ });
630
+ } catch (e) {
631
+ // 事务失败自动回滚(DB 未变),.bak 保留可恢复;fail-loud 中文
632
+ throw new Error(`import 失败,本地进度库已回滚(snapshot: ${bakPath}):${e.message}`);
633
+ }
634
+
635
+ return { ok: true, imported: cn, reason: undefined, bakPath };
636
+ }
637
+
638
+ /**
639
+ * 本地脏度标记(D-013 / task-04):写入路径末尾更新 changes.last_local_modified_ts。
640
+ * 在写方法的事务内调用则属于该事务(原子,失败整体回滚);import 是例外不调本方法
641
+ * (import 自己把 last_local_modified_ts 置为 pushed_at,表示本地=平台干净)。
642
+ * @param {string} cwd
643
+ * @param {string} changeName
644
+ * @param {string} [ts] - 复用调用方事务内的时间戳,保证与本次写入同时刻
645
+ */
646
+ _touchLocalModified(cwd, changeName, ts = null) {
647
+ if (!changeName) return;
648
+ const db = this._ensureDB(cwd);
649
+ const stamp = ts || new Date().toISOString();
650
+ db.getDb().prepare('UPDATE changes SET last_local_modified_ts = ? WHERE name = ?').run(stamp, changeName);
651
+ }
652
+
376
653
  /**
377
654
  * 写入指定变更的 progress
378
655
  * @param {string} cwd
379
656
  * @param {object} data
380
657
  * @param {string|null} changeName - 从 data.currentChange 推导,或显式传入
658
+ *
659
+ * 持锁窗口评估(task-05 / R-08):本方法采用全量 UPSERT——无条件 UPDATE changes 行 +
660
+ * 遍历所有 stages(每 stage UPSERT stages 行 + 每 step 先 DELETE 再 INSERT 全量 + 末尾
661
+ * DELETE data 中不存在的多余 step)。评估过改"只写 diff(仅写变更过的 change/stage/step 行)"
662
+ * 以缩小事务持锁窗口、降低 BUSY 概率,结论:保持现状(全量 UPSERT)。
663
+ * 理由:
664
+ * 1. steps 表无 UNIQUE 约束(见循环内 DELETE-then-INSERT 注释),diff 写需先读现状再逐条
665
+ * 比较,复杂度高易错,易引入孤儿/缺失步骤破坏 FK 级联语义(design §3/§8);
666
+ * 2. busy_timeout=5000 + DB 层 SQLITE_BUSY 应用层有限重试(见 db.js transaction)已兜底
667
+ * 并发冲突,全量 UPSERT 的持锁窗口在此兜底下不是瓶颈;
668
+ * 3. 单进程内调用串行(design §9),事务窗口短(毫秒级),全量写正确性远高于 diff 的边际收益。
669
+ * 结论:全量 UPSERT 的简单正确性 > 持锁窗口优化的边际收益,不强行改 diff。
381
670
  */
382
- async _write(cwd, data, changeName = null) {
671
+ _write(cwd, data, changeName = null) {
383
672
  const cn = changeName || data.currentChange;
384
673
  if (!cn) {
385
674
  console.warn('⚠️ _write: 无变更名,跳过写入');
386
675
  return;
387
676
  }
388
677
 
389
- const db = await this._ensureDB(cwd);
390
- db.transaction((sqlDb) => {
391
- // 1. 更新 changes 表
678
+ const db = this._ensureDB(cwd);
679
+ db.transaction(() => {
680
+ const sqlDb = db.getDb();
681
+ // 1. 更新 changes 表(last_local_modified_ts 脏度:D-013 / task-04)
392
682
  const now = new Date().toISOString();
393
683
  const noWorktree = data.noWorktree ? 1 : 0;
394
- sqlDb.run(
395
- 'UPDATE changes SET current_stage = ?, last_active = ?, no_worktree = ? WHERE name = ?',
396
- [data.currentStage || '', now, noWorktree, cn]
397
- );
684
+ sqlDb.prepare(
685
+ 'UPDATE changes SET current_stage = ?, last_active = ?, no_worktree = ?, last_local_modified_ts = ? WHERE name = ?'
686
+ ).run(data.currentStage || '', now, noWorktree, now, cn);
398
687
 
399
688
  // 2. 获取 change_id
400
- const changeRow = sqlDb.exec('SELECT id FROM changes WHERE name = ?', [cn]);
401
- if (!changeRow || changeRow.length === 0 || changeRow[0].values.length === 0) return;
402
- const changeId = changeRow[0].values[0][0];
689
+ const changeRow = sqlDb.prepare('SELECT id FROM changes WHERE name = ?').get(cn);
690
+ if (changeRow === undefined) return;
691
+ const changeId = changeRow.id;
403
692
 
404
693
  // 3. 遍历 stages,UPSERT stages 表和 steps 表
405
694
  if (data.stages && typeof data.stages === 'object') {
406
695
  for (const [stageName, stageData] of Object.entries(data.stages)) {
407
696
  // UPSERT stages 行(含 revision 列)
408
- sqlDb.run(
697
+ sqlDb.prepare(
409
698
  `INSERT INTO stages (change_id, stage, status, started_at, completed_at, revision, reopened_from_step, reopened_at, stale_reason)
410
699
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
411
700
  ON CONFLICT(change_id, stage) DO UPDATE SET
@@ -415,15 +704,14 @@ export class ProgressManager {
415
704
  revision = COALESCE(excluded.revision, stages.revision),
416
705
  reopened_from_step = excluded.reopened_from_step,
417
706
  reopened_at = excluded.reopened_at,
418
- stale_reason = excluded.stale_reason`,
419
- [changeId, stageName, stageData.status || 'pending', stageData.startedAt || null, stageData.completedAt || null,
420
- stageData.revision || 0, stageData.reopenedFromStep || null, stageData.reopenedAt || null, stageData.staleReason || null]
421
- );
707
+ stale_reason = excluded.stale_reason`
708
+ ).run(changeId, stageName, stageData.status || 'pending', stageData.startedAt || null, stageData.completedAt || null,
709
+ stageData.revision || 0, stageData.reopenedFromStep || null, stageData.reopenedAt || null, stageData.staleReason || null);
422
710
 
423
711
  // 获取 stage_id
424
- const stageRow = sqlDb.exec('SELECT id FROM stages WHERE change_id = ? AND stage = ?', [changeId, stageName]);
425
- if (!stageRow || stageRow.length === 0 || stageRow[0].values.length === 0) continue;
426
- const stageId = stageRow[0].values[0][0];
712
+ const stageRow = sqlDb.prepare('SELECT id FROM stages WHERE change_id = ? AND stage = ?').get(changeId, stageName);
713
+ if (stageRow === undefined) continue;
714
+ const stageId = stageRow.id;
427
715
 
428
716
  // 收集 data 中的步骤名
429
717
  const stepNames = new Set();
@@ -432,86 +720,81 @@ export class ProgressManager {
432
720
  const step = stageData.steps[i];
433
721
  stepNames.add(step.name);
434
722
  // UPSERT 步骤(先删再插,steps 表无 UNIQUE 约束)
435
- sqlDb.run('DELETE FROM steps WHERE stage_id = ? AND name = ?', [stageId, step.name]);
436
- sqlDb.run(
437
- 'INSERT INTO steps (stage_id, name, status, output, completed_at, ordering, wait_reason, wait_options, wait_answer, waited_at, wait_answers, wait_round, max_wait_rounds) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
438
- [stageId, step.name, step.status || 'pending', step.output || null, step.completedAt || null, i,
439
- step.waitReason ?? null, step.waitOptions ?? null, step.waitAnswer ?? null, step.waitedAt ?? null,
723
+ sqlDb.prepare('DELETE FROM steps WHERE stage_id = ? AND name = ?').run(stageId, step.name);
724
+ sqlDb.prepare(
725
+ 'INSERT INTO steps (stage_id, name, status, output, completed_at, ordering, wait_reason, wait_options, wait_answer, waited_at, wait_answers, wait_round, max_wait_rounds) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
726
+ ).run(stageId, step.name, step.status || 'pending', step.output || null, step.completedAt || null, i,
727
+ step.waitReason ?? null, Array.isArray(step.waitOptions) ? JSON.stringify(step.waitOptions) : (step.waitOptions ?? null), step.waitAnswer ?? null, step.waitedAt ?? null,
440
728
  Array.isArray(step.waitAnswers) ? JSON.stringify(step.waitAnswers) : null,
441
- step.waitRound ?? null, step.maxWaitRounds ?? null]
442
- );
729
+ step.waitRound ?? null, step.maxWaitRounds ?? null);
443
730
  }
444
731
  }
445
732
 
446
733
  // 删除 data 中不存在的多余步骤
447
734
  if (stepNames.size > 0) {
448
735
  const namePlaceholders = [...stepNames].map(() => '?').join(',');
449
- sqlDb.run(
450
- `DELETE FROM steps WHERE stage_id = ? AND name NOT IN (${namePlaceholders})`,
451
- [stageId, ...stepNames]
452
- );
736
+ sqlDb.prepare(
737
+ `DELETE FROM steps WHERE stage_id = ? AND name NOT IN (${namePlaceholders})`
738
+ ).run(stageId, ...stepNames);
453
739
  } else {
454
740
  // data 中没有步骤,清空该阶段所有步骤
455
- sqlDb.run('DELETE FROM steps WHERE stage_id = ?', [stageId]);
741
+ sqlDb.prepare('DELETE FROM steps WHERE stage_id = ?').run(stageId);
456
742
  }
457
743
  }
458
744
  }
459
745
 
460
746
  // 4. UPSERT batch_progress
461
747
  if (data.batchProgress && typeof data.batchProgress === 'object') {
462
- sqlDb.run(
748
+ sqlDb.prepare(
463
749
  `INSERT INTO batch_progress (change_id, total, completed, failed, skipped)
464
750
  VALUES (?, ?, ?, ?, ?)
465
751
  ON CONFLICT(change_id) DO UPDATE SET
466
752
  total = excluded.total,
467
753
  completed = excluded.completed,
468
754
  failed = excluded.failed,
469
- skipped = excluded.skipped`,
470
- [changeId, data.batchProgress.total || 0, data.batchProgress.completed || 0, data.batchProgress.failed || 0, data.batchProgress.skipped || 0]
471
- );
755
+ skipped = excluded.skipped`
756
+ ).run(changeId, data.batchProgress.total || 0, data.batchProgress.completed || 0, data.batchProgress.failed || 0, data.batchProgress.skipped || 0);
472
757
  }
473
758
  });
474
-
475
- await this._updateGateStatus(cwd);
476
759
  }
477
760
 
478
761
  // ── 变更管理 ──
479
762
 
480
- async listChanges(cwd) { return this._changeRegistry.listChanges(cwd); }
763
+ listChanges(cwd) { return this._changeRegistry.listChanges(cwd); }
481
764
 
482
- async registerChange(cwd, changeName) { return this._changeRegistry.registerChange(cwd, changeName); }
765
+ registerChange(cwd, changeName) { return this._changeRegistry.registerChange(cwd, changeName); }
483
766
 
484
- async updateChangeIsolation(cwd, changeName, isolation) { return this._changeRegistry.updateChangeIsolation(cwd, changeName, isolation); }
767
+ updateChangeIsolation(cwd, changeName, isolation) { return this._changeRegistry.updateChangeIsolation(cwd, changeName, isolation); }
485
768
 
486
- async readChangeIsolation(cwd, changeName) { return this._changeRegistry.readChangeIsolation(cwd, changeName); }
769
+ readChangeIsolation(cwd, changeName) { return this._changeRegistry.readChangeIsolation(cwd, changeName); }
487
770
 
488
- async _updatePlatformLastSync(cwd, changeName) { return this._changeRegistry._updatePlatformLastSync(cwd, changeName); }
771
+ _updatePlatformLastSync(cwd, changeName) { return this._changeRegistry._updatePlatformLastSync(cwd, changeName); }
489
772
 
490
- async _updateApprovalStatus(cwd, changeName, status, reason = null) { return this._changeRegistry._updateApprovalStatus(cwd, changeName, status, reason); }
773
+ _updateApprovalStatus(cwd, changeName, status, reason = null) { return this._changeRegistry._updateApprovalStatus(cwd, changeName, status, reason); }
491
774
 
492
- async renameChange(cwd, oldName, newName) { return this._changeRegistry.renameChange(cwd, oldName, newName); }
775
+ renameChange(cwd, oldName, newName) { return this._changeRegistry.renameChange(cwd, oldName, newName); }
493
776
 
494
- async unregisterChange(cwd, changeName) { return this._changeRegistry.unregisterChange(cwd, changeName); }
777
+ unregisterChange(cwd, changeName) { return this._changeRegistry.unregisterChange(cwd, changeName); }
495
778
 
496
779
  // ── CLI 命令 ──
497
780
 
498
- async init(cwd) {
781
+ init(cwd) {
499
782
  this._ensureRuntimeDir(cwd);
500
783
 
501
784
  // 初始化 DB(如不存在则创建文件 + 建表)
502
- const db = await this._ensureDB(cwd);
503
- db.transaction((sqlDb) => {
785
+ const db = this._ensureDB(cwd);
786
+ db.transaction(() => {
787
+ const sqlDb = db.getDb();
504
788
  const now = new Date().toISOString();
505
789
  const projectName = basename(cwd) || 'project';
506
790
 
507
791
  // 检查 project id=1 是否已存在
508
- const existing = sqlDb.exec('SELECT id FROM project WHERE id = 1');
509
- if (!existing || existing.length === 0 || existing[0].values.length === 0) {
510
- sqlDb.run(
792
+ const existing = sqlDb.prepare('SELECT id FROM project WHERE id = 1').get();
793
+ if (existing === undefined) {
794
+ sqlDb.prepare(
511
795
  `INSERT INTO project (id, name, schema_version, created_at, updated_at)
512
- VALUES (1, ?, ?, ?, ?)`,
513
- [projectName, CURRENT_VERSION, now, now]
514
- );
796
+ VALUES (1, ?, ?, ?, ?)`
797
+ ).run(projectName, CURRENT_VERSION, now, now);
515
798
  console.log(`✅ 已创建全局状态文件(SQLite)`);
516
799
  } else {
517
800
  console.log(`ℹ️ 全局状态文件已存在,跳过`);
@@ -525,14 +808,14 @@ export class ProgressManager {
525
808
  }
526
809
 
527
810
  this._ensureGitignore(cwd);
528
- return await this.readGlobal(cwd);
811
+ return this.readGlobal(cwd);
529
812
  }
530
813
 
531
814
  /**
532
815
  * 初始化指定变更的 progress
533
816
  * SQL: INSERT changes + 批量 INSERT stages
534
817
  */
535
- async initChange(cwd, changeName) {
818
+ initChange(cwd, changeName) {
536
819
  if (!changeName) {
537
820
  console.warn('⚠️ initChange: changeName 为空,跳过');
538
821
  return null;
@@ -543,103 +826,105 @@ export class ProgressManager {
543
826
  this._ensureChangeDir(cwd, changeName);
544
827
  }
545
828
 
546
- const db = await this._ensureDB(cwd);
547
- db.transaction((sqlDb) => {
829
+ const db = this._ensureDB(cwd);
830
+ db.transaction(() => {
831
+ const sqlDb = db.getDb();
548
832
  const now = new Date().toISOString();
549
833
 
550
834
  // 检查变更是否已存在
551
- const existing = sqlDb.exec('SELECT id FROM changes WHERE name = ?', [changeName]);
552
- if (!existing || existing.length === 0 || existing[0].values.length === 0) {
835
+ const existing = sqlDb.prepare('SELECT id FROM changes WHERE name = ?').get(changeName);
836
+ if (existing === undefined) {
553
837
  // 插入 changes 行
554
- sqlDb.run(
838
+ sqlDb.prepare(
555
839
  `INSERT INTO changes (name, current_stage, status, created_at, last_active)
556
- VALUES (?, 'scan', 'active', ?, ?)`,
557
- [changeName, now, now]
558
- );
840
+ VALUES (?, 'scan', 'active', ?, ?)`
841
+ ).run(changeName, now, now);
559
842
  }
560
843
 
561
844
  // 获取 change_id
562
- const changeRow = sqlDb.exec('SELECT id FROM changes WHERE name = ?', [changeName]);
563
- const changeId = changeRow[0].values[0][0];
845
+ const changeRow = sqlDb.prepare('SELECT id FROM changes WHERE name = ?').get(changeName);
846
+ const changeId = changeRow.id;
564
847
 
565
- // 批量插入 9 个阶段(INSERT OR IGNORE 跳过已存在的)
566
- const allStages = ['scan', 'brainstorm', 'plan', 'execute', 'verify', 'archive', 'quick', 'explore'];
567
- for (const stage of allStages) {
568
- sqlDb.run(
848
+ // 批量插入所有合法阶段(INSERT OR IGNORE 跳过已存在的);沿用顶部 import 的 VALID_STAGES 单一源
849
+ for (const stage of VALID_STAGES) {
850
+ sqlDb.prepare(
569
851
  `INSERT OR IGNORE INTO stages (change_id, stage, status)
570
- VALUES (?, ?, 'pending')`,
571
- [changeId, stage]
572
- );
852
+ VALUES (?, ?, 'pending')`
853
+ ).run(changeId, stage);
573
854
  }
855
+ // 本地脏度(D-013 / task-04):新建 change 视为有未同步数据
856
+ this._touchLocalModified(cwd, changeName, now);
574
857
  });
575
858
 
576
859
  // 不再需要写文件:read() 已改为 SQL
577
- return await this.read(cwd, changeName);
860
+ return this.read(cwd, changeName);
578
861
  }
579
862
 
580
- async setStage(cwd, stage, changeName = null) { return this._stepStore.setStage(cwd, stage, changeName); }
863
+ setStage(cwd, stage, changeName = null) { return this._stepStore.setStage(cwd, stage, changeName); }
581
864
 
582
- async addStep(cwd, stage, stepName, changeName = null) { return this._stepStore.addStep(cwd, stage, stepName, changeName); }
865
+ addStep(cwd, stage, stepName, changeName = null) { return this._stepStore.addStep(cwd, stage, stepName, changeName); }
583
866
 
584
- async updateStep(cwd, stage, stepName, options = {}, changeName = null) { return this._stepStore.updateStep(cwd, stage, stepName, options, changeName); }
867
+ updateStep(cwd, stage, stepName, options = {}, changeName = null) { return this._stepStore.updateStep(cwd, stage, stepName, options, changeName); }
585
868
 
586
869
  _appendAuditLog(cwd, entry) { return this._consistency._appendAuditLog(cwd, entry); }
587
870
 
588
- async _validateStageArtifacts(cwd, stage, changeName) { return this._stageMachine._validateStageArtifacts(cwd, stage, changeName); }
871
+ _validateStageArtifacts(cwd, stage, changeName) { return this._stageMachine._validateStageArtifacts(cwd, stage, changeName); }
589
872
 
590
- async completeStage(cwd, stage, changeName = null, opts = {}) { return this._stageMachine.completeStage(cwd, stage, changeName, opts); }
873
+ completeStage(cwd, stage, changeName = null, opts = {}) { return this._stageMachine.completeStage(cwd, stage, changeName, opts); }
591
874
 
592
- async show(cwd, changeName = null) { return this._stageMachine.show(cwd, changeName); }
875
+ show(cwd, changeName = null) { return this._stageMachine.show(cwd, changeName); }
593
876
 
594
- async _showChange(cwd, changeName) { return this._stageMachine._showChange(cwd, changeName); }
877
+ _showChange(cwd, changeName) { return this._stageMachine._showChange(cwd, changeName); }
595
878
 
596
879
  _getNextSuggestion(data) { return this._stageMachine._getNextSuggestion(data); }
597
880
 
598
- async status(cwd, changeName = null) { return this._stageMachine.status(cwd, changeName); }
881
+ status(cwd, changeName = null) { return this._stageMachine.status(cwd, changeName); }
599
882
 
600
- async checkConsistency(cwd, changeName = null) { return this._consistency.checkConsistency(cwd, changeName); }
883
+ checkConsistency(cwd, changeName = null) { return this._consistency.checkConsistency(cwd, changeName); }
601
884
 
602
- async repairConsistency(cwd, opts = {}) { return this._consistency.repairConsistency(cwd, opts); }
885
+ repairConsistency(cwd, opts = {}) { return this._consistency.repairConsistency(cwd, opts); }
603
886
 
604
- async validate(cwd, changeName = null) { return this._stageMachine.validate(cwd, changeName); }
887
+ validate(cwd, changeName = null) { return this._stageMachine.validate(cwd, changeName); }
605
888
 
606
- async reopenStage(cwd, stage, opts = {}) { return this._stageMachine.reopenStage(cwd, stage, opts); }
889
+ reopenStage(cwd, stage, opts = {}) { return this._stageMachine.reopenStage(cwd, stage, opts); }
607
890
 
608
891
  _getDownstreamStages(stage) { return this._stageMachine._getDownstreamStages(stage); }
609
892
 
610
- async reset(cwd, stage, changeName = null) { return this._stageMachine.reset(cwd, stage, changeName); }
893
+ reset(cwd, stage, changeName = null) { return this._stageMachine.reset(cwd, stage, changeName); }
611
894
 
612
895
  // ── 内部辅助 ──
613
896
 
614
- async _readOrInit(cwd, changeName = null) {
615
- let data = await this.read(cwd, changeName);
897
+ _readOrInit(cwd, changeName = null) {
898
+ let data = this.read(cwd, changeName);
616
899
  if (!data) {
617
900
  // 尝试自动检测变更名
618
901
  if (!changeName) {
619
- const changes = await this.listChanges(cwd);
902
+ const changes = this.listChanges(cwd);
620
903
  if (changes.length === 1) changeName = changes[0];
621
904
  }
622
905
  if (changeName) {
623
906
  // 确保变更在 DB 中已初始化
624
- const db = await this._ensureDB(cwd);
625
- db.transaction((sqlDb) => {
907
+ const db = this._ensureDB(cwd);
908
+ db.transaction(() => {
909
+ const sqlDb = db.getDb();
626
910
  const now = new Date().toISOString();
627
- sqlDb.run(
628
- 'INSERT OR IGNORE INTO changes (name, current_stage, status, created_at, last_active) VALUES (?, "scan", "active", ?, ?)',
629
- [changeName, now, now]
630
- );
631
- const changeRow = sqlDb.exec('SELECT id FROM changes WHERE name = ?', [changeName]);
632
- if (changeRow && changeRow.length > 0 && changeRow[0].values.length > 0) {
633
- const changeId = changeRow[0].values[0][0];
911
+ const ins = sqlDb.prepare(
912
+ `INSERT OR IGNORE INTO changes (name, current_stage, status, created_at, last_active) VALUES (?, 'scan', 'active', ?, ?)`
913
+ ).run(changeName, now, now);
914
+ const changeRow = sqlDb.prepare('SELECT id FROM changes WHERE name = ?').get(changeName);
915
+ if (changeRow !== undefined) {
916
+ const changeId = changeRow.id;
634
917
  for (const s of VALID_STAGES) {
635
- sqlDb.run('INSERT OR IGNORE INTO stages (change_id, stage, status) VALUES (?, ?, "pending")', [changeId, s]);
918
+ sqlDb.prepare(`INSERT OR IGNORE INTO stages (change_id, stage, status) VALUES (?, ?, 'pending')`).run(changeId, s);
636
919
  }
637
920
  }
921
+ // 只在真正创建 change 时标脏(D-013/task-04);读路径(change 已存在)不标,避免 pull 误判本地有未同步改动
922
+ if (ins.changes > 0) this._touchLocalModified(cwd, changeName, now);
638
923
  });
639
- await this.registerChange(cwd, changeName);
924
+ this.registerChange(cwd, changeName);
640
925
  }
641
926
  if (!data) {
642
- data = await this.read(cwd, changeName);
927
+ data = this.read(cwd, changeName);
643
928
  }
644
929
  if (!data) {
645
930
  console.log('❌ 无法确定当前变更,请指定 --change <name>');
@@ -649,12 +934,12 @@ export class ProgressManager {
649
934
  return data;
650
935
  }
651
936
 
652
- async _requireStage(cwd, stage, changeName = null) {
937
+ _requireStage(cwd, stage, changeName = null) {
653
938
  if (!VALID_STAGES.includes(stage)) {
654
939
  console.log(`❌ 未知阶段: ${stage},可选: ${VALID_STAGES.join(', ')}`);
655
940
  return null;
656
941
  }
657
- const data = await this._readOrInit(cwd, changeName);
942
+ const data = this._readOrInit(cwd, changeName);
658
943
  if (!data) return null;
659
944
  if (!data.stages[stage]) data.stages[stage] = emptyStage();
660
945
  return data;
@@ -664,84 +949,12 @@ export class ProgressManager {
664
949
 
665
950
  // ── 批量进度 ──
666
951
 
667
- async updateBatchProgress(cwd, batchData, changeName = null) { return this._stepStore.updateBatchProgress(cwd, batchData, changeName); }
952
+ updateBatchProgress(cwd, batchData, changeName = null) { return this._stepStore.updateBatchProgress(cwd, batchData, changeName); }
668
953
 
669
- async readBatchProgress(cwd, changeName = null) { return this._stepStore.readBatchProgress(cwd, changeName); }
954
+ readBatchProgress(cwd, changeName = null) { return this._stepStore.readBatchProgress(cwd, changeName); }
670
955
 
671
956
  _renderBatchProgress(batchProgress) { return this._stepStore._renderBatchProgress(batchProgress); }
672
957
 
673
- /**
674
- * 更新 gate-status.json,供 worktree-guard hook 读取
675
- * 从 SQLite 查询所有处于 execute/quick 阶段的活跃变更,生成或删除 gate-status.json
676
- */
677
- async _updateGateStatus(cwd) {
678
- const db = await this._ensureDB(cwd);
679
- const sqlDb = db.getDb();
680
-
681
- // SQL 查询:所有处于 execute/quick 阶段的活跃变更
682
- const rows = sqlDb.exec(
683
- `SELECT name, current_stage, no_worktree FROM changes
684
- WHERE status = 'active' AND current_stage IN ('execute', 'quick')`
685
- );
686
-
687
- const gatePath = this._runtimePath(cwd, 'gate-status.json');
688
-
689
- if (!rows || rows.length === 0 || rows[0].values.length === 0) {
690
- // 无 execute/quick 阶段的活跃变更:写墓碑而非 unlink。unlink 失败(Windows 杀毒/索引占用)
691
- // 会被空 catch 吞掉,陈旧 gate-status 残留 → hook 把 brainstorm 误当 execute 放行(fail-open)。
692
- // 墓碑 {stage:null} 让 readCurrentStage 的 `gateStatus.stage` 为假 → 自动 fallback DB,语义等价删除但原子。
693
- if (existsSync(gatePath)) {
694
- try {
695
- this._ensureRuntimeDir(cwd);
696
- writeAtomicSync(gatePath, JSON.stringify({ stage: null, tombstone: true, updatedAt: new Date().toISOString() }) + '\n');
697
- } catch (e) {
698
- console.warn(`⚠️ gate-status 墓碑写入失败(hook 可能读到陈旧缓存): ${e.message}`);
699
- }
700
- }
701
- return;
702
- }
703
-
704
- let gateStage = null;
705
- let hasNoWorktree = false;
706
- const activeChanges = [];
707
-
708
- for (const [name, stage, noWorktree] of rows[0].values) {
709
- if (!stage) continue;
710
- // 优先取 execute,其次 quick
711
- if (gateStage !== 'execute' || stage === 'execute') {
712
- gateStage = stage;
713
- }
714
- activeChanges.push(name);
715
- if (noWorktree === 1) hasNoWorktree = true;
716
- }
717
-
718
- if (!gateStage) {
719
- // current_stage 为 NULL 的边界情况,等同于无 execute/quick:同样写墓碑(见上 rationale)
720
- if (existsSync(gatePath)) {
721
- try {
722
- this._ensureRuntimeDir(cwd);
723
- writeAtomicSync(gatePath, JSON.stringify({ stage: null, tombstone: true, updatedAt: new Date().toISOString() }) + '\n');
724
- } catch (e) {
725
- console.warn(`⚠️ gate-status 墓碑写入失败(hook 可能读到陈旧缓存): ${e.message}`);
726
- }
727
- }
728
- return;
729
- }
730
-
731
- try {
732
- this._ensureRuntimeDir(cwd);
733
- const gateData = {
734
- stage: gateStage,
735
- changes: activeChanges,
736
- updatedAt: new Date().toISOString(),
737
- ...(hasNoWorktree ? { noWorktree: true } : {}),
738
- };
739
- writeAtomicSync(gatePath, JSON.stringify(gateData, null, 2) + '\n');
740
- } catch (err) {
741
- console.warn('⚠️ 写入 gate-status.json 失败:', err.message);
742
- }
743
- }
744
-
745
958
  _ensureGitignore(cwd) {
746
959
  // 外部 specDir 不需要修改项目 .gitignore
747
960
  if (this._customSpecDir) return;
@@ -801,14 +1014,14 @@ export class ProgressManager {
801
1014
  * @param {string} specBase - platformOpts.specRoot || join(cwd, '.sillyspec'),用于定位 changes 目录
802
1015
  * @param {object} [opts]
803
1016
  * @param {boolean} [opts.confirm=false] - 默认 dry-run,仅当 confirm=true 才落盘
804
- * @returns {Promise<{ ok: boolean, aligned: number, skipped: number, planTotal: number, planChecked: number, reason?: string, dryRun?: boolean }>}
1017
+ * @returns {{ ok: boolean, aligned: number, skipped: number, planTotal: number, planChecked: number, reason?: string, dryRun?: boolean }}
805
1018
  */
806
- async alignExecuteToPlan(cwd, changeName, specBase, opts = {}) {
1019
+ alignExecuteToPlan(cwd, changeName, specBase, opts = {}) {
807
1020
  if (!changeName) throw new Error('changeName 不能为空');
808
1021
  const { confirm = false } = opts;
809
1022
 
810
1023
  // 1. 读 progress;无 progress / 无 execute 阶段 → 拒绝
811
- const progress = await this.read(cwd, changeName);
1024
+ const progress = this.read(cwd, changeName);
812
1025
  if (!progress || !progress.stages || !progress.stages.execute) {
813
1026
  return { ok: false, aligned: 0, skipped: 0, planTotal: 0, planChecked: 0, reason: 'execute 阶段无进度数据' };
814
1027
  }
@@ -840,7 +1053,6 @@ export class ProgressManager {
840
1053
  // 对齐前用 git 客观核验是否存在真实代码变更(能确证零变更时才拒绝,
841
1054
  // unknown 不阻断——worktree 已清理且变更已提交的正常场景无法对账)。
842
1055
  try {
843
- const { checkExecuteCodeEvidence } = await import('./stage-contract.js');
844
1056
  const evidence = checkExecuteCodeEvidence(cwd, changeName);
845
1057
  if (evidence.status === 'unchanged') {
846
1058
  return {
@@ -882,7 +1094,7 @@ export class ProgressManager {
882
1094
  executeStage.completedAt = now;
883
1095
  progress.currentStage = progress.currentStage || 'execute';
884
1096
  progress.lastActive = now;
885
- await this._write(cwd, progress, changeName);
1097
+ this._write(cwd, progress, changeName);
886
1098
 
887
1099
  return { ok: true, aligned, skipped, planTotal, planChecked };
888
1100
  }