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/index.js CHANGED
@@ -84,6 +84,8 @@ SillySpec CLI — 规范驱动开发工具包
84
84
  sillyspec gate <stage> --change <name> [--json] 机器门控:阶段能否标记完成(只读)
85
85
  sillyspec derive <facet> --change <name> [--json] 单项事实核验(facet: execute-evidence|verify-test|task-reviews|artifacts)
86
86
  sillyspec backfill-reviews --change <name> [--json] 为手动补的 task 生成 review.json 草稿(cannot_verify,解 archive 客观完成度阻断)
87
+ sillyspec register-stage-review --change <name> --stage <brainstorm|plan|execute> [--from <review.json>] [--json]
88
+ 生成/adopt stage 级 review.json(docHash 自动算 + 写 marker,治 tier=independent marker 死锁)
87
89
 
88
90
  sillyspec doctor [子命令] 进度库健康检查 + 修复(顶层命令,非 worktree doctor)
89
91
  (无子命令) 跑诊断,列出问题
@@ -458,6 +460,41 @@ async function main() {
458
460
  }
459
461
  break;
460
462
  }
463
+ case 'register-stage-review': {
464
+ // 手动注册 stage 级 review(brainstorm/plan/execute-acceptance)的确定性 writer ——
465
+ // 生成 run 目录 + review.json 骨架(cannot_verify,待独立审查子代理填 verdict)或 adopt
466
+ // agent 草稿(--from,保留 verdict/checklist 重算 docHash)+ 写 marker。治 tier=independent 时
467
+ // 调度者手动派独立子代理不写 marker 的死锁。docHash 由 CLI computeDocHash 算(部分实现 P6.1b defer)。
468
+ // 纯新增,不改 gate 语义。与 task 级 backfill-reviews 对称(stage 级等价物)。
469
+ const rsrChangeIdx = args.indexOf('--change');
470
+ const rsrChange = rsrChangeIdx >= 0 && args[rsrChangeIdx + 1] ? args[rsrChangeIdx + 1] : null;
471
+ const rsrStageIdx = args.indexOf('--stage');
472
+ const rsrStage = rsrStageIdx >= 0 && args[rsrStageIdx + 1] ? args[rsrStageIdx + 1] : null;
473
+ const rsrFromIdx = args.indexOf('--from');
474
+ const rsrFrom = rsrFromIdx >= 0 && args[rsrFromIdx + 1] ? args[rsrFromIdx + 1] : null;
475
+ if (!rsrChange || !rsrStage) {
476
+ console.error('用法: sillyspec register-stage-review --change <名> --stage <brainstorm|plan|execute> [--from <review.json>] [--spec-dir <path>] [--json]\n 生成/adopt stage 级 review.json(docHash 自动算 + 写 marker),治 tier=independent marker 死锁');
477
+ process.exit(2);
478
+ }
479
+ const { registerStageReview } = await import('./stage-review.js');
480
+ const rsrPlatformOpts = {};
481
+ if (specDir) rsrPlatformOpts.specRoot = specDir;
482
+ try {
483
+ const result = registerStageReview({ changeName: rsrChange, stage: rsrStage, fromFile: rsrFrom, cwd: dir, platformOpts: rsrPlatformOpts });
484
+ if (json) {
485
+ process.stdout.write(JSON.stringify({ ok: true, command: 'register-stage-review', change: rsrChange, stage: rsrStage, ...result }));
486
+ } else {
487
+ console.log(`✅ 已注册 ${rsrStage} stage review [${result.reviewRunId}] → ${result.reviewPath}(mode: ${result.mode})`);
488
+ console.log(` marker → ${result.markerPath}`);
489
+ console.log(` 下一步:独立审查子代理对照 ${result.mainDoc} 填 verdict/checklist 后重跑 --done`);
490
+ }
491
+ } catch (e) {
492
+ if (json) process.stdout.write(JSON.stringify({ ok: false, command: 'register-stage-review', error: e.message }));
493
+ else console.error('❌ ' + e.message);
494
+ process.exitCode = 1;
495
+ }
496
+ break;
497
+ }
461
498
  case 'docs': {
462
499
  const docsSubCmd = filteredArgs[1];
463
500
  if (docsSubCmd === 'migrate') {
@@ -612,6 +649,10 @@ async function main() {
612
649
  const { runCommand } = await import('./run.js')
613
650
  const stageArgs = [command, ...filteredArgs.slice(1)]
614
651
  const effectiveDir = specDir ? dir : resolveEffectiveDir(dir)
652
+ // 下行 pull:stage 命令(run/--done,含 archive)启动时拉一次(task-10 / D-009 / FR-04)
653
+ // 不在每步 pull,仅低频边界点;未连接平台静默跳过
654
+ const { triggerPullActiveChange } = await import('./run/shared.js')
655
+ await triggerPullActiveChange(effectiveDir)
615
656
  await runCommand(stageArgs, effectiveDir, specDir, { json })
616
657
  break
617
658
  }
@@ -734,6 +775,12 @@ SillySpec worktree — git worktree 隔离管理
734
775
  for (const err of result.errors) {
735
776
  console.error(` ${err}`);
736
777
  }
778
+ if (result.rescueCommands) {
779
+ console.error('');
780
+ console.error(`🆘 Rescue commands (${result.rescueCommands.cpFileCount} safe / ${result.rescueCommands.excludedCount} excluded,旁路 git apply,cp 后需手动 sillyspec worktree cleanup ${wtName}):`);
781
+ for (const c of result.rescueCommands.commands) console.error(` ${c}`);
782
+ for (const w of result.rescueCommands.warnings) console.error(` ⚠️ ${w}`);
783
+ }
737
784
  process.exit(1);
738
785
  }
739
786
 
@@ -795,6 +842,12 @@ SillySpec worktree — git worktree 隔离管理
795
842
  for (const w of assessment.warnings) console.log(` ⚠️ ${w}`);
796
843
  }
797
844
 
845
+ if (assessment.rescueCommands) {
846
+ console.log('');
847
+ console.log(`🆘 Rescue commands (${assessment.rescueCommands.cpFileCount} safe / ${assessment.rescueCommands.excludedCount} excluded,旁路 git apply,cp 后需手动 sillyspec worktree cleanup ${wtName}):`);
848
+ for (const c of assessment.rescueCommands.commands) console.log(` ${c}`);
849
+ for (const w of assessment.rescueCommands.warnings) console.log(` ⚠️ ${w}`);
850
+ }
798
851
  console.log('');
799
852
  if (assessment.decision === 'SAFE' || assessment.decision === 'WARNING') {
800
853
  console.log('Action: auto-applying...');
@@ -1045,6 +1098,8 @@ SillySpec platform — SillyHub 平台同步
1045
1098
  sillyspec platform disconnect
1046
1099
  sillyspec platform sync [--change <name>]
1047
1100
  sillyspec platform sync-docs [--change <name>]
1101
+ sillyspec platform pull [--change <name>]
1102
+ sillyspec platform resolve <change-name> <--keep-local|--take-platform|--abort>
1048
1103
  sillyspec platform status
1049
1104
  sillyspec platform pointer [--cleanup]
1050
1105
  sillyspec platform approve <change-name>
@@ -1145,15 +1200,108 @@ SillySpec platform — SillyHub 平台同步
1145
1200
  await syncModule.syncDocuments(syncDocsChangeName, dir);
1146
1201
  break;
1147
1202
  }
1148
- case 'status':
1149
- await syncModule.status(dir);
1203
+ case 'status': {
1204
+ // task-14 / FR-05:扩展 status(落后标记 + 未决冲突列表);未连接输出与现状一致
1205
+ const st = await syncModule.collectStatus(dir);
1206
+ if (!st.connected) {
1207
+ console.log('平台: 未连接');
1208
+ break;
1209
+ }
1210
+ console.log(`平台: ${st.url}`);
1211
+ console.log(`上次连接: ${st.lastSync || '未知'}`);
1212
+ if (st.listFailed) {
1213
+ console.log('ℹ️ 无法连接平台获取落后状态');
1214
+ } else if (st.behind.length > 0) {
1215
+ console.log(`⚠️ 本地可能落后(${st.behind.length} 个变更平台有更新):`);
1216
+ for (const b of st.behind) {
1217
+ console.log(` - ${b.name}: 平台 ${b.platform_pushed_at} > 本地 ${b.local_synced}`);
1218
+ }
1219
+ } else {
1220
+ console.log('✅ 本地与平台进度同步');
1221
+ }
1222
+ if (st.conflicts.length > 0) {
1223
+ console.log(`⚠️ 未决冲突(${st.conflicts.length} 个,请 platform resolve 处理):`);
1224
+ for (const c of st.conflicts) {
1225
+ console.log(` - ${c.change}${c.created_at ? ` (创建于 ${c.created_at})` : ''}`);
1226
+ }
1227
+ }
1150
1228
  break;
1229
+ }
1230
+ case 'pull': {
1231
+ // task-11 / D-006 / D-009 / FR-03:手动下行拉取平台进度到本地。
1232
+ // 与自动 triggerPull 共用 syncModule.pull(SyncManager.pull 实例方法),行为一致。
1233
+ const pullChangeIdx = args.indexOf('--change');
1234
+ const pullChangeName = pullChangeIdx >= 0 && args[pullChangeIdx + 1] ? args[pullChangeIdx + 1] : null;
1235
+ // 未连接明确提示不崩(acceptance:未连接平台输出明确提示)
1236
+ const smForCheck = new syncModule.SyncManager(dir);
1237
+ if (!smForCheck._getPlatform()) {
1238
+ console.error('❌ 未连接平台,请先 sillyspec platform connect');
1239
+ process.exit(1);
1240
+ }
1241
+ if (pullChangeName) {
1242
+ // 单变更完整 pull(两级 pull 第二级)
1243
+ const r = await syncModule.pull(pullChangeName, {}, dir);
1244
+ if (r.conflict) {
1245
+ console.log(`⚠️ ${pullChangeName}: 冲突(已写 sync-conflict 文件),请 sillyspec platform resolve 处理`);
1246
+ } else if (r.imported) {
1247
+ console.log(`✅ ${pullChangeName}: 已拉取并 import`);
1248
+ } else {
1249
+ console.log(`⏭️ ${pullChangeName}: 未 import(${r.reason || '无变更'})`);
1250
+ }
1251
+ } else {
1252
+ // 无 --change:先拉轻量列表(两级 pull 第一级),再对每个 change 按需完整 pull
1253
+ const list = await syncModule.pullList(dir);
1254
+ if (!list.ok) {
1255
+ console.error(`❌ 拉取变更列表失败: ${list.reason || '未知'}`);
1256
+ process.exit(1);
1257
+ }
1258
+ console.log(`平台变更 ${list.changes.length} 个:`);
1259
+ let importedCount = 0, conflictCount = 0;
1260
+ for (const ch of list.changes) {
1261
+ const name = typeof ch === 'string' ? ch : ch.name;
1262
+ if (!name) continue;
1263
+ const r = await syncModule.pull(name, {}, dir);
1264
+ if (r.imported) { importedCount++; console.log(` ✅ ${name}: 已 import`); }
1265
+ else if (r.conflict) { conflictCount++; console.log(` ⚠️ ${name}: 冲突,请 platform resolve`); }
1266
+ else { console.log(` ⏭️ ${name}: ${r.reason || '未 import'}`); }
1267
+ }
1268
+ console.log(`拉取完成:imported ${importedCount},冲突 ${conflictCount}`);
1269
+ }
1270
+ break;
1271
+ }
1272
+ case 'resolve': {
1273
+ // task-13 / D-002 / D-010 / D-013 / FR-05:冲突解决三选一(绝不字段级 auto-merge)
1274
+ const resolveName = platformArgs[0];
1275
+ if (!resolveName) {
1276
+ console.error('❌ 用法: sillyspec platform resolve <change-name> <--keep-local|--take-platform|--abort>');
1277
+ process.exit(1);
1278
+ }
1279
+ // 解析 mode flag(三选一互斥,多传/不传均报错)
1280
+ const resolveFlags = ['--keep-local', '--take-platform', '--abort'].filter((f) => args.includes(f));
1281
+ if (resolveFlags.length !== 1) {
1282
+ console.error('❌ 必须恰好指定 --keep-local / --take-platform / --abort 之一');
1283
+ process.exit(1);
1284
+ }
1285
+ const modeMap = { '--keep-local': 'keep-local', '--take-platform': 'take-platform', '--abort': 'abort' };
1286
+ const resolveMode = modeMap[resolveFlags[0]];
1287
+ const r = await syncModule.resolve(resolveName, resolveMode, dir);
1288
+ if (r.ok && r.resolved) {
1289
+ console.log(`✅ ${resolveName} [${resolveMode}]: ${r.reason}`);
1290
+ } else {
1291
+ console.error(`❌ ${resolveName}: ${r.reason}`);
1292
+ process.exit(1);
1293
+ }
1294
+ break;
1295
+ }
1151
1296
  case 'approve': {
1152
1297
  const approveName = platformArgs[0];
1153
1298
  if (!approveName) {
1154
1299
  console.error('❌ 用法: sillyspec platform approve <change-name>');
1155
1300
  process.exit(1);
1156
1301
  }
1302
+ // 审批前下行 pull(task-10 / D-009 / FR-06):拉最新进度避免基于过期状态审批;未连接跳过
1303
+ const { triggerPull } = await import('./run/shared.js');
1304
+ await triggerPull(dir, approveName);
1157
1305
  await syncModule.approve(approveName, dir);
1158
1306
  break;
1159
1307
  }
@@ -1481,7 +1629,7 @@ SillySpec modules — 模块文档管理
1481
1629
  break;
1482
1630
  }
1483
1631
  default: {
1484
- const topCommands = ['init', 'setup', 'run', 'progress', 'worktree', 'dispatch', 'local', 'workflow', 'gate', 'derive', 'backfill-reviews', 'modules', 'change-rename', 'knowledge', 'platform', 'scan', 'brainstorm', 'plan', 'execute', 'verify', 'archive', 'quick', 'explore', 'status', 'doctor', 'auto', 'runtime'];
1632
+ const topCommands = ['init', 'setup', 'run', 'progress', 'worktree', 'dispatch', 'local', 'workflow', 'gate', 'derive', 'backfill-reviews', 'register-stage-review', 'modules', 'change-rename', 'knowledge', 'platform', 'scan', 'brainstorm', 'plan', 'execute', 'verify', 'archive', 'quick', 'explore', 'status', 'doctor', 'auto', 'runtime'];
1485
1633
  const suggestion = didYouMean(command, topCommands);
1486
1634
  console.error(`❌ 未知命令: ${command}`);
1487
1635
  if (command === '--status') {
@@ -1,110 +1,120 @@
1
- /**
2
- * Local Detect — 纯 fs 项目类型嗅探
3
- *
4
- * 把"生成本地配置"里的项目类型判定从 scan(半小时 + 大量 token)抽出来,
5
- * 变成纯 fs 同步读几秒可完成的独立探测。create/gate 只需 project.type +
6
- * commands,不该被迫跑完整 scan。
7
- *
8
- * 纯函数:不 spawn 任何子进程、不调用任何 AI/LLM、不消耗 token。
9
- * 不写 local.yaml 到磁盘——只返回数据结构,落盘由调用方(CLI 路由 / scan.js)负责。
10
- * 不引 yaml 依赖。
11
- */
12
-
13
- import { existsSync, readFileSync } from 'fs'
14
- import { join } from 'path'
15
-
16
- /**
17
- * 从 Makefile 文本提取 test 目标的命令(轻量正则,与 verify-postcheck 同风格)。
18
- * 支持两种常见写法:
19
- * - 同行命令:`test: pytest` → 'pytest'
20
- * - tab 续行:`test:\n\tpytest\n` → 'pytest'(命令在下一 tab 开头行)
21
- * 边界:
22
- * - 纯空目标(`test:` 无同行命令、无 tab 续行)→ 回退 'make test'
23
- * - 命令行内 `#` 注释截断;取第一个非空命令行
24
- * @param {string} makefileText
25
- * @returns {string}
26
- */
27
- function parseMakefileTestCommand(makefileText) {
28
- if (!makefileText) return 'make test'
29
- // 定位 `test:` 行;捕获 `:` 后同行内容(同行命令或空)
30
- const head = makefileText.match(/^test:\s*([^\n#]*?)\s*(?:#.*)?$/m)
31
- if (!head) return 'make test'
32
- const inline = (head[1] || '').trim()
33
- if (inline) return inline
34
- // 同行为空 → 找该行之后第一个 tab 续行命令(`\tcmd` 或 ` cmd`)
35
- const after = makefileText.slice(head.index + head[0].length)
36
- const cont = after.match(/^[ \t]+([^\n#]+?)\s*(?:#.*)?$/m)
37
- if (cont && cont[1]) return cont[1].trim()
38
- return 'make test'
39
- }
40
-
41
- /**
42
- * 嗅探 workdir 的项目类型与默认 commands(纯 fs,零 AI / 零 token)。
43
- *
44
- * 嗅探规则(按顺序,命中即返回):
45
- * - package.json → nodejs(npm run build / npm test / npm run lint)
46
- * - pom.xml → maven(mvn compile / mvn test / mvn checkstyle:check)
47
- * - build.gradle → gradle(./gradlew build / test / check)
48
- * - Makefile → make(test 命令从 test: 目标解析;build/lint 无则不写)
49
- * - 都没有 → generic(commands 为空对象)
50
- *
51
- * @param {string} workdir - 项目根目录
52
- * @returns {{
53
- * project: { type: 'nodejs'|'maven'|'gradle'|'make'|'generic' },
54
- * commands: { build?: string, test?: string, lint?: string }
55
- * }}
56
- */
57
- export function detectLocalYaml(workdir) {
58
- // 1. nodejs
59
- if (existsSync(join(workdir, 'package.json'))) {
60
- return {
61
- project: { type: 'nodejs' },
62
- commands: {
63
- build: 'npm run build',
64
- test: 'npm test',
65
- lint: 'npm run lint',
66
- },
67
- }
68
- }
69
-
70
- // 2. maven
71
- if (existsSync(join(workdir, 'pom.xml'))) {
72
- return {
73
- project: { type: 'maven' },
74
- commands: {
75
- build: 'mvn compile',
76
- test: 'mvn test',
77
- lint: 'mvn checkstyle:check',
78
- },
79
- }
80
- }
81
-
82
- // 3. gradle
83
- if (existsSync(join(workdir, 'build.gradle'))) {
84
- return {
85
- project: { type: 'gradle' },
86
- commands: {
87
- build: './gradlew build',
88
- test: './gradlew test',
89
- lint: './gradlew check',
90
- },
91
- }
92
- }
93
-
94
- // 4. make(test 命令从 Makefile 解析;build/lint 无则省略)
95
- const makefilePath = join(workdir, 'Makefile')
96
- if (existsSync(makefilePath)) {
97
- const makefileText = readFileSync(makefilePath, 'utf8')
98
- const test = parseMakefileTestCommand(makefileText)
99
- return {
100
- project: { type: 'make' },
101
- commands: { test },
102
- }
103
- }
104
-
105
- // 5. generic
106
- return {
107
- project: { type: 'generic' },
108
- commands: {},
109
- }
110
- }
1
+ /**
2
+ * Local Detect — 纯 fs 项目类型嗅探
3
+ *
4
+ * 把"生成本地配置"里的项目类型判定从 scan(半小时 + 大量 token)抽出来,
5
+ * 变成纯 fs 同步读几秒可完成的独立探测。create/gate 只需 project.type +
6
+ * commands,不该被迫跑完整 scan。
7
+ *
8
+ * 纯函数:不 spawn 任何子进程、不调用任何 AI/LLM、不消耗 token。
9
+ * 不写 local.yaml 到磁盘——只返回数据结构,落盘由调用方(CLI 路由 / scan.js)负责。
10
+ * 不引 yaml 依赖。
11
+ */
12
+
13
+ import { existsSync, readFileSync } from 'fs'
14
+ import { join } from 'path'
15
+
16
+ /**
17
+ * 从 Makefile 文本提取 test 目标的命令(轻量正则,与 verify-postcheck 同风格)。
18
+ * 支持两种常见写法:
19
+ * - 同行命令:`test: pytest` → 'pytest'
20
+ * - tab 续行:`test:\n\tpytest\n` → 'pytest'(命令在下一 tab 开头行)
21
+ * 边界:
22
+ * - 纯空目标(`test:` 无同行命令、无 tab 续行)→ 回退 'make test'
23
+ * - 命令行内 `#` 注释截断;取第一个非空命令行
24
+ * @param {string} makefileText
25
+ * @returns {string}
26
+ */
27
+ function parseMakefileTestCommand(makefileText) {
28
+ if (!makefileText) return 'make test'
29
+ // 定位 `test:` 行;捕获 `:` 后同行内容(同行命令或空)
30
+ const head = makefileText.match(/^test:\s*([^\n#]*?)\s*(?:#.*)?$/m)
31
+ if (!head) return 'make test'
32
+ const inline = (head[1] || '').trim()
33
+ if (inline) return inline
34
+ // 同行为空 → 找该行之后第一个 tab 续行命令(`\tcmd` 或 ` cmd`)
35
+ const after = makefileText.slice(head.index + head[0].length)
36
+ const cont = after.match(/^[ \t]+([^\n#]+?)\s*(?:#.*)?$/m)
37
+ if (cont && cont[1]) return cont[1].trim()
38
+ return 'make test'
39
+ }
40
+
41
+ /**
42
+ * 嗅探 workdir 的项目类型与默认 commands(纯 fs,零 AI / 零 token)。
43
+ *
44
+ * 嗅探规则(按顺序,命中即返回):
45
+ * - package.json → nodejs(核验 scripts.{build,test,lint} 存在才写对应键;缺失不写)
46
+ * - pom.xml → maven(mvn compile / mvn test / mvn checkstyle:check)
47
+ * - build.gradle → gradle(gradlew 存在用 ./gradlew 否则 gradle;build / test / check)
48
+ * - Makefile → make(test 命令从 test: 目标解析;build/lint 无则不写)
49
+ * - 都没有 → generic(commands 为空对象)
50
+ *
51
+ * @param {string} workdir - 项目根目录
52
+ * @returns {{
53
+ * project: { type: 'nodejs'|'maven'|'gradle'|'make'|'generic' },
54
+ * commands: { build?: string, test?: string, lint?: string }
55
+ * }}
56
+ */
57
+ export function detectLocalYaml(workdir) {
58
+ // 1. nodejs
59
+ if (existsSync(join(workdir, 'package.json'))) {
60
+ const pkgPath = join(workdir, 'package.json')
61
+ const raw = readFileSync(pkgPath, 'utf8')
62
+ let pkg
63
+ try {
64
+ pkg = JSON.parse(raw)
65
+ } catch (e) {
66
+ throw new Error('package.json 解析失败:' + pkgPath)
67
+ }
68
+ const scripts = (pkg && pkg.scripts) || {}
69
+ const commands = {}
70
+ if (scripts.build) commands.build = 'npm run build'
71
+ if (scripts.test) commands.test = 'npm test'
72
+ if (scripts.lint) commands.lint = 'npm run lint'
73
+ return {
74
+ project: { type: 'nodejs' },
75
+ commands,
76
+ }
77
+ }
78
+
79
+ // 2. maven
80
+ if (existsSync(join(workdir, 'pom.xml'))) {
81
+ return {
82
+ project: { type: 'maven' },
83
+ commands: {
84
+ build: 'mvn compile',
85
+ test: 'mvn test',
86
+ lint: 'mvn checkstyle:check',
87
+ },
88
+ }
89
+ }
90
+
91
+ // 3. gradle
92
+ if (existsSync(join(workdir, 'build.gradle'))) {
93
+ const gradlePrefix = existsSync(join(workdir, 'gradlew')) ? './gradlew' : 'gradle'
94
+ return {
95
+ project: { type: 'gradle' },
96
+ commands: {
97
+ build: `${gradlePrefix} build`,
98
+ test: `${gradlePrefix} test`,
99
+ lint: `${gradlePrefix} check`,
100
+ },
101
+ }
102
+ }
103
+
104
+ // 4. make(test 命令从 Makefile 解析;build/lint 无则省略)
105
+ const makefilePath = join(workdir, 'Makefile')
106
+ if (existsSync(makefilePath)) {
107
+ const makefileText = readFileSync(makefilePath, 'utf8')
108
+ const test = parseMakefileTestCommand(makefileText)
109
+ return {
110
+ project: { type: 'make' },
111
+ commands: { test },
112
+ }
113
+ }
114
+
115
+ // 5. generic
116
+ return {
117
+ project: { type: 'generic' },
118
+ commands: {},
119
+ }
120
+ }
@@ -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,15 +55,46 @@ 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);
62
67
  }
63
68
  }
64
69
 
70
+ /**
71
+ * 更新变更的人类可读元信息(title / quicklog_id),让 quick-<hex> 行可读、DB↔QUICKLOG 可对账。
72
+ * quick 启动时回填(title 从任务描述、quicklog_id 用分配的 qlId);--done 时从 step3「需求:」刷新 title。
73
+ * 部分更新(只传 title 不动 quicklog_id,反之亦然)。不调 _touchLocalModified:title/quicklog_id 是
74
+ * 本地展示用元信息,纳入脏度会扰动平台同步(平台 changes 表无此列)。
75
+ * @param {string} cwd
76
+ * @param {string} changeName
77
+ * @param {{ title?: string, quicklogId?: string }} meta
78
+ */
79
+ updateChangeMeta(cwd, changeName, meta) {
80
+ if (!changeName || !meta) return;
81
+ const db = this.pm._ensureDB(cwd);
82
+ try {
83
+ db.transaction(() => {
84
+ const sqlDb = db.getDb();
85
+ const sets = [];
86
+ const params = [];
87
+ if (meta.title !== undefined) { sets.push('title = ?'); params.push(meta.title); }
88
+ if (meta.quicklogId !== undefined) { sets.push('quicklog_id = ?'); params.push(meta.quicklogId); }
89
+ if (sets.length === 0) return;
90
+ params.push(changeName);
91
+ sqlDb.prepare(`UPDATE changes SET ${sets.join(', ')} WHERE name = ?`).run(...params);
92
+ });
93
+ } catch (err) {
94
+ console.warn('⚠️ 更新 change 元信息失败:', err.message);
95
+ }
96
+ }
97
+
65
98
  /**
66
99
  * 读取变更的隔离状态
67
100
  * @param {string} cwd - 项目根目录
@@ -117,6 +150,8 @@ export class ChangeRegistry {
117
150
  status === 'approved' ? now : null,
118
151
  status === 'rejected' ? reason : null,
119
152
  );
153
+ // 本地脏度(D-013 / task-04):审批状态变更也是本地推进
154
+ this.pm._touchLocalModified(cwd, changeName, now);
120
155
  });
121
156
  }
122
157
 
@@ -167,6 +202,8 @@ export class ChangeRegistry {
167
202
  db.transaction(() => {
168
203
  const sqlDb = db.getDb();
169
204
  sqlDb.prepare(`UPDATE changes SET name = ?, last_active = ? WHERE name = ?`).run(newName, now, oldName);
205
+ // 本地脏度(D-013 / task-04):重命名也是本地推进(标新名)
206
+ this.pm._touchLocalModified(cwd, newName, now);
170
207
  });
171
208
  } catch (e) {
172
209
  console.error(`❌ 重命名失败:更新数据库时出错(${e.message})`);
@@ -183,6 +220,8 @@ export class ChangeRegistry {
183
220
  db.transaction(() => {
184
221
  const sqlDb = db.getDb();
185
222
  sqlDb.prepare(`UPDATE changes SET name = ?, last_active = ? WHERE name = ?`).run(oldName, now, newName);
223
+ // 回滚也是写(恢复 oldName),对称标脏旧名
224
+ this.pm._touchLocalModified(cwd, oldName, now);
186
225
  });
187
226
  console.error(`❌ 重命名失败:移动目录出错(${e.message}),已回滚数据库`);
188
227
  } catch (rollbackErr) {
@@ -213,6 +252,8 @@ export class ChangeRegistry {
213
252
  sqlDb.prepare(
214
253
  `UPDATE changes SET status = 'archived', last_active = ? WHERE name = ?`
215
254
  ).run(now, changeName);
255
+ // 本地脏度(D-013 / task-04):归档也是本地状态推进
256
+ this.pm._touchLocalModified(cwd, changeName, now);
216
257
  });
217
258
  }
218
259
  }
@@ -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 = 5;
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