sillyspec 3.25.11 → 3.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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: 5,
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);
@@ -490,6 +776,8 @@ export class ProgressManager {
490
776
 
491
777
  unregisterChange(cwd, changeName) { return this._changeRegistry.unregisterChange(cwd, changeName); }
492
778
 
779
+ updateChangeMeta(cwd, changeName, meta) { return this._changeRegistry.updateChangeMeta(cwd, changeName, meta); }
780
+
493
781
  // ── CLI 命令 ──
494
782
 
495
783
  init(cwd) {
@@ -529,7 +817,7 @@ export class ProgressManager {
529
817
  * 初始化指定变更的 progress
530
818
  * SQL: INSERT changes + 批量 INSERT stages
531
819
  */
532
- initChange(cwd, changeName) {
820
+ initChange(cwd, changeName, meta = {}) {
533
821
  if (!changeName) {
534
822
  console.warn('⚠️ initChange: changeName 为空,跳过');
535
823
  return null;
@@ -550,9 +838,9 @@ export class ProgressManager {
550
838
  if (existing === undefined) {
551
839
  // 插入 changes 行
552
840
  sqlDb.prepare(
553
- `INSERT INTO changes (name, current_stage, status, created_at, last_active)
554
- VALUES (?, 'scan', 'active', ?, ?)`
555
- ).run(changeName, now, now);
841
+ `INSERT INTO changes (name, current_stage, status, created_at, last_active, title, quicklog_id)
842
+ VALUES (?, 'scan', 'active', ?, ?, ?, ?)`
843
+ ).run(changeName, now, now, meta.title || null, meta.quicklogId || null);
556
844
  }
557
845
 
558
846
  // 获取 change_id
@@ -566,6 +854,8 @@ export class ProgressManager {
566
854
  VALUES (?, ?, 'pending')`
567
855
  ).run(changeId, stage);
568
856
  }
857
+ // 本地脏度(D-013 / task-04):新建 change 视为有未同步数据
858
+ this._touchLocalModified(cwd, changeName, now);
569
859
  });
570
860
 
571
861
  // 不再需要写文件:read() 已改为 SQL
@@ -620,7 +910,7 @@ export class ProgressManager {
620
910
  db.transaction(() => {
621
911
  const sqlDb = db.getDb();
622
912
  const now = new Date().toISOString();
623
- sqlDb.prepare(
913
+ const ins = sqlDb.prepare(
624
914
  `INSERT OR IGNORE INTO changes (name, current_stage, status, created_at, last_active) VALUES (?, 'scan', 'active', ?, ?)`
625
915
  ).run(changeName, now, now);
626
916
  const changeRow = sqlDb.prepare('SELECT id FROM changes WHERE name = ?').get(changeName);
@@ -630,6 +920,8 @@ export class ProgressManager {
630
920
  sqlDb.prepare(`INSERT OR IGNORE INTO stages (change_id, stage, status) VALUES (?, ?, 'pending')`).run(changeId, s);
631
921
  }
632
922
  }
923
+ // 只在真正创建 change 时标脏(D-013/task-04);读路径(change 已存在)不标,避免 pull 误判本地有未同步改动
924
+ if (ins.changes > 0) this._touchLocalModified(cwd, changeName, now);
633
925
  });
634
926
  this.registerChange(cwd, changeName);
635
927
  }
package/src/quicklog.js CHANGED
@@ -75,7 +75,7 @@ function nowDatetime(d = new Date()) {
75
75
  }
76
76
 
77
77
  // 描述压成一行、限长,避免破坏 QUICKLOG 条目结构
78
- function sanitizeDesc(description) {
78
+ export function sanitizeDesc(description) {
79
79
  const s = String(description || '').replace(/[\r\n]+/g, ' ').trim()
80
80
  if (!s) return '(quick 任务)'
81
81
  return s.length > 120 ? s.slice(0, 120) + '…' : s
@@ -101,7 +101,7 @@ export function deriveTitleFromLinkedChange(specBase, change) {
101
101
 
102
102
  // 从 quick step3 --output 四字段提取「需求:」摘要,用于翻完成时刷新标题行
103
103
  // (覆盖启动时的占位/弱标题)。提取失败返回 ''(不刷新)。
104
- function extractTitleFromResult(result) {
104
+ export function extractTitleFromResult(result) {
105
105
  if (!result) return ''
106
106
  const m = String(result).match(/需求:([^\n\r]*?)(?:\s+根因:|$)/)
107
107
  if (!m) return ''
@@ -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 检测)
@@ -25,12 +25,12 @@
25
25
  import { basename, join, resolve, relative, isAbsolute } from 'node:path'
26
26
  import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, rmSync } from 'node:fs'
27
27
  import { renameSyncRetry, writeAtomicSync } from '../fs-atomic.js'
28
- import { resolveChangeDir, resolveQuickSessionsDir, safeGit, auditQuickCompletion, triggerSync, isQuickMetadata } from './shared.js'
28
+ import { resolveChangeDir, resolveQuickSessionsDir, safeGit, auditQuickCompletion, triggerSync, isQuickMetadata, resolveRuntimeRoot } from './shared.js'
29
29
  import { detectConcurrentChanges, formatConcurrentWarning } from './concurrent-detect.js'
30
30
  import { stageRegistry } from '../stages/index.js'
31
31
  import { SCAN_STATUS, POINTER_STATUS } from '../constants.js'
32
32
  import { printQuickAuditReview } from './quick-audit.js'
33
- import { validateQuickResult, allocateQuicklogEntry, findQuicklogEntry, completeQuicklogEntry } from '../quicklog.js'
33
+ import { validateQuickResult, allocateQuicklogEntry, findQuicklogEntry, completeQuicklogEntry, extractTitleFromResult } from '../quicklog.js'
34
34
  import { getRule } from '../stage-contract-spec.js'
35
35
  import { archiveDestDirName } from '../stage-contract.js'
36
36
 
@@ -130,9 +130,32 @@ export function findAlreadyArchivedDir(archiveDir, changeName) {
130
130
  /**
131
131
  * 归档时清理可能残留的 worktree(execute 自动清理未走到 / 有未 apply 变更被遗弃)。
132
132
  * 安全策略:有未 apply 变更时保留 worktree 并警告,避免误删用户未应用的代码。
133
+ * 同时清理该变更的 execute / stage-review runId marker(只写不删的累积物,
134
+ * 见 stage.js execute 启动固定 executeRunId / stage-review.js stageReviewMarkerPath)。
133
135
  * 从 archiveChangeDirectory 抽出,供正常归档 + 自愈归档复用。
134
136
  */
135
- async function archiveWorktreeCleanup(cwd, archiveChangeName) {
137
+ async function archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platformOpts = {}) {
138
+ // ── 清理 runId marker:execute(current-execute-run-id-<change>)与 stage-review
139
+ // (current-stage-review-run-id-<stage>-<change>)。marker 只服务 execute→verify→archive
140
+ // 期间,归档后无读者;不删则 .runtime 随变更数无限累积。runtimeRoot 解析同写入侧
141
+ // (resolveRuntimeRoot 锚主仓),避免平台模式清理错位置。
142
+ try {
143
+ const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
144
+ if (existsSync(runtimeRoot)) {
145
+ const markers = readdirSync(runtimeRoot).filter((f) =>
146
+ f === `current-execute-run-id-${archiveChangeName}` ||
147
+ (f.startsWith('current-stage-review-run-id-') && f.endsWith(`-${archiveChangeName}`)),
148
+ )
149
+ for (const m of markers) {
150
+ try { unlinkSync(join(runtimeRoot, m)) } catch {}
151
+ }
152
+ if (markers.length > 0) {
153
+ console.log(`🧹 归档清理 ${markers.length} 个 runId marker(execute/stage-review)`)
154
+ }
155
+ }
156
+ } catch (e) {
157
+ console.warn(`⚠️ 归档 runId marker 清理失败(不阻断归档): ${e.message}`)
158
+ }
136
159
  try {
137
160
  const { WorktreeManager } = await import('../worktree.js')
138
161
  const wm = new WorktreeManager({ cwd })
@@ -154,7 +177,7 @@ async function archiveWorktreeCleanup(cwd, archiveChangeName) {
154
177
  }
155
178
  }
156
179
 
157
- export async function archiveChangeDirectory(pm, cwd, progress, specBase) {
180
+ export async function archiveChangeDirectory(pm, cwd, progress, specBase, platformOpts = {}) {
158
181
  const archiveChangeName = progress.currentChange
159
182
  if (!archiveChangeName) {
160
183
  console.error('❌ 归档失败:未找到当前变更名(currentChange)')
@@ -176,7 +199,7 @@ export async function archiveChangeDirectory(pm, cwd, progress, specBase) {
176
199
  if (alreadyArchivedDir) {
177
200
  console.log(`ℹ️ 源目录不存在但变更已在 archive/(${basename(alreadyArchivedDir)}),判定已归档,自愈进度 DB`)
178
201
  pm.unregisterChange(cwd, archiveChangeName)
179
- await archiveWorktreeCleanup(cwd, archiveChangeName)
202
+ await archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platformOpts)
180
203
  console.log(`📦 已自愈归档:${archiveChangeName} → archive/${basename(alreadyArchivedDir)}/`)
181
204
  return alreadyArchivedDir
182
205
  }
@@ -221,7 +244,7 @@ export async function archiveChangeDirectory(pm, cwd, progress, specBase) {
221
244
  } catch {}
222
245
 
223
246
  // 归档时清理可能残留的 worktree(自愈路径也复用,见上方 srcDir 缺失分支)。
224
- await archiveWorktreeCleanup(cwd, archiveChangeName)
247
+ await archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platformOpts)
225
248
 
226
249
  console.log(`📦 已归档:${archiveChangeName} → archive/${destName}/`)
227
250
  return destDir
@@ -236,7 +259,7 @@ export async function archiveChangeDirectory(pm, cwd, progress, specBase) {
236
259
  *
237
260
  * @returns {{stageCompleted:false,currentIdx,nextPendingIdx:number}|null}
238
261
  */
239
- export async function handleArchiveConfirmStep({ stageName, steps, currentIdx, confirm, outputText, pm, cwd, progress, changeName, specBase }) {
262
+ export async function handleArchiveConfirmStep({ stageName, steps, currentIdx, confirm, outputText, pm, cwd, progress, changeName, specBase, platformOpts = {} }) {
240
263
  if (stageName !== 'archive' || steps[currentIdx]?.name !== '确认归档') return null
241
264
  if (!confirm) {
242
265
  steps[currentIdx].status = 'pending'
@@ -246,7 +269,7 @@ export async function handleArchiveConfirmStep({ stageName, steps, currentIdx, c
246
269
  console.log('⚠️ 请添加 --confirm 确认归档,例如:sillyspec run archive --done --confirm --output "确认归档"')
247
270
  return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
248
271
  }
249
- const archivedDir = await archiveChangeDirectory(pm, cwd, progress, specBase)
272
+ const archivedDir = await archiveChangeDirectory(pm, cwd, progress, specBase, platformOpts)
250
273
  if (archivedDir && existsSync(archivedDir)) {
251
274
  const recommendedDocs = ['design.md', 'module-impact.md']
252
275
  const missingRecommended = recommendedDocs.filter(d => !existsSync(join(archivedDir, d)))
@@ -751,6 +774,12 @@ export async function handleQuickStageCompletion({ stageName, steps, currentIdx,
751
774
  changedFiles: realFiles,
752
775
  })
753
776
  console.log(`📝 QUICKLOG 条目 ${qlId} 已标记完成`)
777
+ // 刷新 DB title:从 step3「需求:」提取(agent 可改 title 的途径),覆盖启动时的兜底快照。
778
+ // 与 QUICKLOG 标题刷新(flipEntryInContent 内 extractTitleFromResult)同源,保持 DB↔QUICKLOG 一致。
779
+ try {
780
+ const refinedTitle = extractTitleFromResult(outputText || '');
781
+ if (refinedTitle) pm.updateChangeMeta(cwd, changeName, { title: refinedTitle });
782
+ } catch { /* title 刷新失败不阻断完成 */ }
754
783
  } catch (e) {
755
784
  console.warn(`⚠️ QUICKLOG 完成态写入失败: ${e.message}`)
756
785
  }