sillyspec 3.25.11 → 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.
@@ -33,10 +33,12 @@ export class ChangeRegistry {
33
33
  const sqlDb = db.getDb();
34
34
  const now = new Date().toISOString();
35
35
  // 尝试插入新行
36
- sqlDb.prepare(
36
+ const ins = sqlDb.prepare(
37
37
  `INSERT OR IGNORE INTO changes (name, created_at, last_active)
38
38
  VALUES (?, ?, ?)`
39
39
  ).run(changeName, now, now);
40
+ // 只在真正创建 change 时标脏(D-013/task-04);已存在行不标(读路径/重复调用不误判脏)
41
+ if (ins.changes > 0) this.pm._touchLocalModified(cwd, changeName, now);
40
42
  // 注意:不复活已归档的变更——归档是不可逆操作
41
43
  // 如果变更已存在且为 archived,保持 archived 状态不变
42
44
  });
@@ -53,9 +55,12 @@ export class ChangeRegistry {
53
55
  try {
54
56
  db.transaction(() => {
55
57
  const sqlDb = db.getDb();
58
+ const now = new Date().toISOString();
56
59
  sqlDb.prepare(
57
60
  `UPDATE changes SET isolation_status = ?, isolation_mode = ?, isolation_reason = ?, last_active = ? WHERE name = ?`
58
- ).run(isolation.status, isolation.mode || null, isolation.reason || null, new Date().toISOString(), changeName);
61
+ ).run(isolation.status, isolation.mode || null, isolation.reason || null, now, changeName);
62
+ // 本地脏度(D-013 / task-04):隔离状态变更也是本地推进
63
+ this.pm._touchLocalModified(cwd, changeName, now);
59
64
  });
60
65
  } catch (err) {
61
66
  console.warn('⚠️ 更新 isolation 状态失败:', err.message);
@@ -117,6 +122,8 @@ export class ChangeRegistry {
117
122
  status === 'approved' ? now : null,
118
123
  status === 'rejected' ? reason : null,
119
124
  );
125
+ // 本地脏度(D-013 / task-04):审批状态变更也是本地推进
126
+ this.pm._touchLocalModified(cwd, changeName, now);
120
127
  });
121
128
  }
122
129
 
@@ -167,6 +174,8 @@ export class ChangeRegistry {
167
174
  db.transaction(() => {
168
175
  const sqlDb = db.getDb();
169
176
  sqlDb.prepare(`UPDATE changes SET name = ?, last_active = ? WHERE name = ?`).run(newName, now, oldName);
177
+ // 本地脏度(D-013 / task-04):重命名也是本地推进(标新名)
178
+ this.pm._touchLocalModified(cwd, newName, now);
170
179
  });
171
180
  } catch (e) {
172
181
  console.error(`❌ 重命名失败:更新数据库时出错(${e.message})`);
@@ -183,6 +192,8 @@ export class ChangeRegistry {
183
192
  db.transaction(() => {
184
193
  const sqlDb = db.getDb();
185
194
  sqlDb.prepare(`UPDATE changes SET name = ?, last_active = ? WHERE name = ?`).run(oldName, now, newName);
195
+ // 回滚也是写(恢复 oldName),对称标脏旧名
196
+ this.pm._touchLocalModified(cwd, oldName, now);
186
197
  });
187
198
  console.error(`❌ 重命名失败:移动目录出错(${e.message}),已回滚数据库`);
188
199
  } catch (rollbackErr) {
@@ -213,6 +224,8 @@ export class ChangeRegistry {
213
224
  sqlDb.prepare(
214
225
  `UPDATE changes SET status = 'archived', last_active = ? WHERE name = ?`
215
226
  ).run(now, changeName);
227
+ // 本地脏度(D-013 / task-04):归档也是本地状态推进
228
+ this.pm._touchLocalModified(cwd, changeName, now);
216
229
  });
217
230
  }
218
231
  }
@@ -26,8 +26,8 @@ export const STAGE_ORDER = ['scan', 'brainstorm', 'plan', 'execute', 'verify', '
26
26
  // (consistency-doctor 坑 + prompt-control-debt plan-c 同根因)。
27
27
  export const MAIN_FLOW_ORDER = ['brainstorm', 'plan', 'execute', 'verify', 'archive'];
28
28
 
29
- // progress 数据版本(v3 = SQLite)
30
- export const CURRENT_VERSION = 3;
29
+ // progress 数据版本(v3 = SQLite;v4 = changes 加 base_ts / 本地脏度两列,platform-sync §8)
30
+ export const CURRENT_VERSION = 4;
31
31
 
32
32
  // 空阶段骨架
33
33
  export function emptyStage() {
@@ -98,6 +98,8 @@ export class StageMachine {
98
98
 
99
99
  // UPDATE changes.last_active
100
100
  sqlDb.prepare('UPDATE changes SET last_active = ? WHERE name = ?').run(now, cn);
101
+ // 本地脏度(D-013 / task-04):阶段完成是本地推进
102
+ this.pm._touchLocalModified(cwd, cn, now);
101
103
  });
102
104
 
103
105
  // 写 history 文件(保持文件系统,不变)
@@ -492,6 +494,8 @@ export class StageMachine {
492
494
  for (const s of VALID_STAGES) {
493
495
  sqlDb.prepare(`INSERT OR IGNORE INTO stages (change_id, stage, status) VALUES (?, ?, 'pending')`).run(changeId, s);
494
496
  }
497
+ // 本地脏度(D-013 / task-04):重置该变更全部进度是本地推进
498
+ this.pm._touchLocalModified(cwd, changeName);
495
499
  }
496
500
  });
497
501
  console.log(`✅ 已重置变更 ${changeName} 的进度`);
@@ -507,6 +511,8 @@ export class StageMachine {
507
511
  const changeId = changeRow.id;
508
512
  sqlDb.prepare('DELETE FROM steps WHERE stage_id IN (SELECT id FROM stages WHERE change_id = ?)').run(changeId);
509
513
  sqlDb.prepare(`UPDATE stages SET status = 'pending', started_at = NULL, completed_at = NULL WHERE change_id = ?`).run(changeId);
514
+ // 本地脏度(D-013 / task-04):重置进度是本地推进
515
+ this.pm._touchLocalModified(cwd, cn);
510
516
  }
511
517
  }
512
518
  });
@@ -47,6 +47,9 @@ export class StepStore {
47
47
 
48
48
  // UPDATE stages.status 为 in-progress(仅当仍为 pending 时)
49
49
  sqlDb.prepare("UPDATE stages SET status = 'in-progress', started_at = ? WHERE change_id = ? AND stage = ? AND status = 'pending'").run(now, changeId, stage);
50
+
51
+ // 本地脏度(D-013 / task-04):切换阶段是本地推进
52
+ this.pm._touchLocalModified(cwd, cn, now);
50
53
  });
51
54
 
52
55
  if (!changeFound) {
@@ -100,6 +103,8 @@ export class StepStore {
100
103
  VALUES (?, ?, (SELECT COALESCE(MAX(ordering), 0) + 1 FROM steps WHERE stage_id = ?), 'pending')`
101
104
  ).run(stageId, stepName, stageId);
102
105
  tDb.prepare('UPDATE changes SET last_active = ? WHERE name = ?').run(new Date().toISOString(), cn);
106
+ // 本地脏度(D-013 / task-04):添加步骤是本地推进
107
+ this.pm._touchLocalModified(cwd, cn);
103
108
  });
104
109
 
105
110
  console.log(`✅ 已添加步骤: ${stage}/${stepName}`);
@@ -166,6 +171,8 @@ export class StepStore {
166
171
  }
167
172
 
168
173
  tDb.prepare('UPDATE changes SET last_active = ? WHERE name = ?').run(now, cn);
174
+ // 本地脏度(D-013 / task-04):更新步骤是本地推进
175
+ this.pm._touchLocalModified(cwd, cn, now);
169
176
  });
170
177
 
171
178
  if (stageCompletionCandidateId !== null) {
@@ -227,6 +234,9 @@ export class StepStore {
227
234
  failed = excluded.failed,
228
235
  skipped = excluded.skipped`
229
236
  ).run(changeId, batchData.total || 0, batchData.completed || 0, batchData.failed || 0, batchData.skipped || 0);
237
+ // 本地脏度(D-013 / task-04):批量进度更新是本地推进
238
+ const nameRow = sqlDb.prepare('SELECT name FROM changes WHERE id = ?').get(changeId);
239
+ if (nameRow) this.pm._touchLocalModified(cwd, nameRow.name);
230
240
  });
231
241
  }
232
242
 
package/src/progress.js CHANGED
@@ -10,7 +10,7 @@
10
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';
@@ -347,7 +347,7 @@ export class ProgressManager {
347
347
  }
348
348
 
349
349
  const result = {
350
- _version: 3,
350
+ _version: 4,
351
351
  project: projectName,
352
352
  currentChange: cName,
353
353
  currentStage: currentStage || '',
@@ -364,6 +364,292 @@ export class ProgressManager {
364
364
  return result;
365
365
  }
366
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
+
367
653
  /**
368
654
  * 写入指定变更的 progress
369
655
  * @param {string} cwd
@@ -392,12 +678,12 @@ export class ProgressManager {
392
678
  const db = this._ensureDB(cwd);
393
679
  db.transaction(() => {
394
680
  const sqlDb = db.getDb();
395
- // 1. 更新 changes 表
681
+ // 1. 更新 changes 表(last_local_modified_ts 脏度:D-013 / task-04)
396
682
  const now = new Date().toISOString();
397
683
  const noWorktree = data.noWorktree ? 1 : 0;
398
684
  sqlDb.prepare(
399
- 'UPDATE changes SET current_stage = ?, last_active = ?, no_worktree = ? WHERE name = ?'
400
- ).run(data.currentStage || '', now, noWorktree, cn);
685
+ 'UPDATE changes SET current_stage = ?, last_active = ?, no_worktree = ?, last_local_modified_ts = ? WHERE name = ?'
686
+ ).run(data.currentStage || '', now, noWorktree, now, cn);
401
687
 
402
688
  // 2. 获取 change_id
403
689
  const changeRow = sqlDb.prepare('SELECT id FROM changes WHERE name = ?').get(cn);
@@ -566,6 +852,8 @@ export class ProgressManager {
566
852
  VALUES (?, ?, 'pending')`
567
853
  ).run(changeId, stage);
568
854
  }
855
+ // 本地脏度(D-013 / task-04):新建 change 视为有未同步数据
856
+ this._touchLocalModified(cwd, changeName, now);
569
857
  });
570
858
 
571
859
  // 不再需要写文件:read() 已改为 SQL
@@ -620,7 +908,7 @@ export class ProgressManager {
620
908
  db.transaction(() => {
621
909
  const sqlDb = db.getDb();
622
910
  const now = new Date().toISOString();
623
- sqlDb.prepare(
911
+ const ins = sqlDb.prepare(
624
912
  `INSERT OR IGNORE INTO changes (name, current_stage, status, created_at, last_active) VALUES (?, 'scan', 'active', ?, ?)`
625
913
  ).run(changeName, now, now);
626
914
  const changeRow = sqlDb.prepare('SELECT id FROM changes WHERE name = ?').get(changeName);
@@ -630,6 +918,8 @@ export class ProgressManager {
630
918
  sqlDb.prepare(`INSERT OR IGNORE INTO stages (change_id, stage, status) VALUES (?, ?, 'pending')`).run(changeId, s);
631
919
  }
632
920
  }
921
+ // 只在真正创建 change 时标脏(D-013/task-04);读路径(change 已存在)不标,避免 pull 误判本地有未同步改动
922
+ if (ins.changes > 0) this._touchLocalModified(cwd, changeName, now);
633
923
  });
634
924
  this.registerChange(cwd, changeName);
635
925
  }
package/src/quicklog.js CHANGED
@@ -117,6 +117,36 @@ function sanitizeResult(resultText) {
117
117
  return String(resultText || '').trim()
118
118
  }
119
119
 
120
+ // ── quick step3 --file-notes 旁路通道 ──
121
+ // flag 在 command.js 解析(--file-notes),completeQuicklogEntry 在 quick 收尾时消费。
122
+ // 不经 completeStep → handleQuickStageCompletion → completeQuicklogEntry 三层透传(会扩 3 处签名,
123
+ // 且 quick 收尾路径是多 agent 并发改热点);用 per-process setter,CLI 单进程生命周期内有效,
124
+ // completeQuicklogEntry 读后即清。无 --file-notes 时为 '' → 回退 changedFiles 单行(向后兼容)。
125
+ let _pendingFileNotes = ''
126
+ export function setQuickFileNotes(raw) { _pendingFileNotes = raw == null ? '' : String(raw) }
127
+ export function getQuickFileNotes() { return _pendingFileNotes }
128
+
129
+ /**
130
+ * 解析 --file-notes 原文 → [{path, note}]。
131
+ * 格式:「path1::括注1 || path2::括注2」——`||` 分隔条目,`::` 分隔路径与括注(首个 `::` 为界,
132
+ * 括注内含 `::` 不误切);路径反斜杠归一正斜杠(匹配 git 路径风格)。无 `::` 的段 → note=''。
133
+ * 空 / 全空段 → [](调用方回退 changedFiles 单行)。导出供测试 + 未来直接调用。
134
+ */
135
+ export function parseFileNotes(raw) {
136
+ if (!raw) return []
137
+ return String(raw)
138
+ .split('||')
139
+ .map((seg) => {
140
+ const s = seg.trim()
141
+ if (!s) return null
142
+ const idx = s.indexOf('::')
143
+ const path = (idx === -1 ? s : s.slice(0, idx)).replace(/\\/g, '/').trim()
144
+ const note = idx === -1 ? '' : s.slice(idx + 2).trim()
145
+ return path ? { path, note } : null
146
+ })
147
+ .filter(Boolean)
148
+ }
149
+
120
150
  // 结果块必填字段(quick step3 --done --output 的结构化结果模板,见 src/stages/quick.js)。
121
151
  // 4 个必填字段标签从 manifest 同源(stage-contract-spec.js quick.result-labels),prompt 事前契约与本校验单源。
122
152
  const RESULT_REQUIRED_LABELS = getRule('quick.result-labels').data.literals
@@ -284,7 +314,7 @@ function splitSingleLineFields(body) {
284
314
  return scanFields(body, findBoundaryLabel) ?? scanFields(body, (b, l, f) => b.indexOf(l, f))
285
315
  }
286
316
 
287
- function flipEntryInContent(content, qlId, result, changedFiles = []) {
317
+ function flipEntryInContent(content, qlId, result, changedFiles = [], fileNotes = []) {
288
318
  const lines = content.split('\n')
289
319
  const startIdx = lines.findIndex(l => l.startsWith(`## ${qlId} |`))
290
320
  if (startIdx === -1) return null
@@ -303,11 +333,16 @@ function flipEntryInContent(content, qlId, result, changedFiles = []) {
303
333
  for (let i = startIdx + 1; i < endIdx; i++) {
304
334
  if (/^状态:进行中\r?$/.test(lines[i])) { lines[i] = '状态:已完成'; flipped = true }
305
335
  if (lines[i].startsWith('结果:')) hasResult = true
306
- // 回填实际改动文件(调用方已用 isQuickMetadata 过滤 quick 自身元数据,只留业务文件)。
307
- // 原地替换 lines[i],不改数组长度 → 与下方状态/结果 splice 的索引完全解耦。
308
- // changedFiles 空(brownfield 无 guard / 调用方未传)→ 不动文件行,保持「(见实际改动)」。
309
- if (changedFiles.length > 0 && lines[i].startsWith('文件:')) {
310
- lines[i] = `文件:${changedFiles.join(', ')}`
336
+ // 文件行落盘:--file-notes 优先(多行 bullet 带括注),否则回填审计到的实际改动文件单行,
337
+ // 都空则不动(保持「(见实际改动)」)。bullet 用「数组元素内嵌 \n」写——lines.join('\n') 展平为
338
+ // 多行,数组长度不变 → 与下方状态/结果 splice 的索引完全解耦(changedFiles 单行同理原地替换)。
339
+ if (lines[i].startsWith('文件:')) {
340
+ if (fileNotes.length > 0) {
341
+ const bullets = fileNotes.map((n) => `- ${n.path}${n.note ? `(${n.note})` : ''}`)
342
+ lines[i] = `文件:\n${bullets.join('\n')}`
343
+ } else if (changedFiles.length > 0) {
344
+ lines[i] = `文件:${changedFiles.join(', ')}`
345
+ }
311
346
  }
312
347
  }
313
348
  if (!flipped && !lines.slice(startIdx, endIdx).some(l => /^状态:已完成\r?$/.test(l))) {
@@ -405,6 +440,10 @@ export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultTex
405
440
  const linked = Array.isArray(linkedChanges) ? linkedChanges : []
406
441
  // 实际改动文件(调用方 complete-handlers 已用 isQuickMetadata 过滤 quick 自身元数据)。空 → 不动文件行。
407
442
  const realFiles = Array.isArray(changedFiles) ? changedFiles.filter(Boolean) : []
443
+ // --file-notes(command.js 经 setQuickFileNotes 注入):有则文件行写多行 bullet 带括注。
444
+ // 读后即清(per-process,防残留跨调用)。flipEntryInContent 优先用 fileNotes,空则回退 realFiles。
445
+ const fileNotes = parseFileNotes(_pendingFileNotes)
446
+ _pendingFileNotes = ''
408
447
 
409
448
  await withFileLock(lockPath, async () => {
410
449
  // 条目可能在主文件或轮转归档中
@@ -412,7 +451,7 @@ export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultTex
412
451
  const filePath = join(quicklogDir, f)
413
452
  let content = ''
414
453
  try { content = readFileSync(filePath, 'utf8') } catch { continue }
415
- const updated = flipEntryInContent(content, qlId, result, realFiles)
454
+ const updated = flipEntryInContent(content, qlId, result, realFiles, fileNotes)
416
455
  if (updated !== null) {
417
456
  await writeAtomic(filePath, updated) // 命中处原子落盘(只改含目标条目的那一个文件)
418
457
  break
@@ -32,6 +32,7 @@ import { ProgressManager } from '../progress.js'
32
32
  import { validateChangeExists } from '../stage-contract.js'
33
33
  import { stageRegistry, auxiliaryStages } from '../stages/index.js'
34
34
  import { definition as brainstormAutoDef } from '../stages/brainstorm-auto.js'
35
+ import { setQuickFileNotes } from '../quicklog.js'
35
36
 
36
37
  // F2/F4: 哪些 flag 吃下一个 token 作「值」(其余 --flag 都是布尔,不吞值)。
37
38
  // 校验循环只对 VALUE_FLAGS 跳下一个 token(否则 --done 后跟 typo 会被当 --done 的值吞掉)。
@@ -40,7 +41,7 @@ const VALUE_FLAGS = new Set([
40
41
  '--reason', '--options', '--answer', '--confirm-mode',
41
42
  '--output', '--input', '--change', '--linked-changes',
42
43
  '--spec-dir', '--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
43
- '--files', '--from-step', '--mode', '--dir', '--confirm-mode',
44
+ '--files', '--file-notes', '--from-step', '--mode', '--dir', '--confirm-mode',
44
45
  ])
45
46
 
46
47
  /**
@@ -449,6 +450,16 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
449
450
  quickFiles = flags[filesIdx + 1].split(',').map(f => f.trim()).filter(Boolean)
450
451
  }
451
452
 
453
+ // 解析 --file-notes "path::括注 || path::括注"(quick 专用:QUICKLOG「文件:」行落盘多行括注)。
454
+ // 旁路注入 quicklog.js(per-process setter),不经 complete 收尾路径透传(避碰多 agent 并发改的收尾热点)。
455
+ // completeQuicklogEntry 读后即清;不传 → '' → 回填审计到的实际文件单行(向后兼容)。
456
+ let quickFileNotes = ''
457
+ const fileNotesIdx = flags.indexOf('--file-notes')
458
+ if (fileNotesIdx !== -1 && flags[fileNotesIdx + 1]) {
459
+ quickFileNotes = flags[fileNotesIdx + 1]
460
+ }
461
+ setQuickFileNotes(quickFileNotes)
462
+
452
463
  const isAllowNew = flags.includes('--allow-new')
453
464
  const isForceBaseline = flags.includes('--force-baseline')
454
465
  const isForceRescan = flags.includes('--force-rescan')
@@ -460,7 +471,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
460
471
  '--reason', '--options', '--answer', '--confirm-mode',
461
472
  '--output', '--input', '--change', '--linked-changes',
462
473
  '--spec-dir', '--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
463
- '--files', '--allow-new', '--force-baseline', '--force-rescan',
474
+ '--files', '--file-notes', '--allow-new', '--force-baseline', '--force-rescan',
464
475
  '--json', '--dir', '--help',
465
476
  '--reopen', '--from-step', '--mode',
466
477
  '--deep', '--quick', '--standard', // scan profile 三档显式选择(scan-profile.js 从 argv 读;互斥见下方 PROFILE_FLAGS 检测)