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.
- package/.claude/skills/sillyspec-execute/SKILL.md +1 -1
- package/.claude/skills/sillyspec-quick/SKILL.md +3 -1
- package/.sillyspec-platform-cleaned +1 -0
- package/.sillyspec-platform.json +7 -0
- package/package.json +1 -1
- package/src/db.js +10 -2
- package/src/dispatch/probe.js +11 -9
- package/src/index.js +151 -3
- package/src/local-detect.js +120 -110
- package/src/progress/change-registry.js +15 -2
- package/src/progress/shared.js +2 -2
- package/src/progress/stage-machine.js +6 -0
- package/src/progress/step-store.js +10 -0
- package/src/progress.js +296 -6
- package/src/quicklog.js +46 -7
- package/src/run/command.js +13 -2
- package/src/run/complete-handlers.js +30 -7
- package/src/run/complete.js +17 -4
- package/src/run/gates.js +13 -2
- package/src/run/shared.js +74 -0
- package/src/sillyhub-mcp/client.js +12 -7
- package/src/sillyhub-mcp/config.js +62 -0
- package/src/stage-review.js +105 -1
- package/src/stages/doctor.js +1 -1
- package/src/stages/execute.js +6 -5
- package/src/stages/quick.js +24 -8
- package/src/stages/scan.js +44 -5
- package/src/stages/verify.js +2 -2
- package/src/sync.js +430 -13
- package/src/task-review.js +29 -0
- package/src/verify-postcheck.js +7 -2
- package/src/worktree-apply.js +135 -27
|
@@ -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,
|
|
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
|
}
|
package/src/progress/shared.js
CHANGED
|
@@ -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 =
|
|
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:
|
|
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
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
if (
|
|
310
|
-
|
|
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
|
package/src/run/command.js
CHANGED
|
@@ -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 检测)
|