sillyspec 3.25.8 → 3.25.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +8 -13
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +8 -6
  3. package/.claude/skills/sillyspec-continue/SKILL.md +3 -3
  4. package/.claude/skills/sillyspec-doctor/SKILL.md +1 -1
  5. package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
  6. package/.claude/skills/sillyspec-plan/SKILL.md +1 -1
  7. package/.claude/skills/sillyspec-verify/SKILL.md +1 -1
  8. package/README.md +9 -0
  9. package/package.json +2 -2
  10. package/src/change-list.js +11 -8
  11. package/src/change-risk-profile.js +22 -0
  12. package/src/db.js +160 -159
  13. package/src/dispatch/backends/local-agent.js +152 -0
  14. package/src/dispatch/backends/sillyhub-mcp.js +242 -0
  15. package/src/dispatch/probe.js +208 -0
  16. package/src/dispatch/strategy.js +121 -0
  17. package/src/doctor-diagnostics.js +19 -25
  18. package/src/fs-atomic.js +69 -69
  19. package/src/git-helper.js +66 -0
  20. package/src/hooks/worktree-guard.js +64 -55
  21. package/src/index.js +215 -34
  22. package/src/init.js +7 -14
  23. package/src/machine-interface.js +54 -19
  24. package/src/migrate.js +14 -4
  25. package/src/modules.js +16 -5
  26. package/src/progress/change-registry.js +72 -70
  27. package/src/progress/consistency-doctor.js +71 -14
  28. package/src/progress/shared.js +6 -3
  29. package/src/progress/stage-machine.js +61 -65
  30. package/src/progress/step-store.js +62 -67
  31. package/src/progress.js +155 -233
  32. package/src/quicklog.js +13 -5
  33. package/src/run/command.js +65 -37
  34. package/src/run/complete-handlers.js +1005 -889
  35. package/src/run/complete.js +115 -208
  36. package/src/run/concurrent-detect.js +132 -0
  37. package/src/run/gates.js +289 -17
  38. package/src/run/prompt.js +22 -68
  39. package/src/run/quick-audit.js +10 -9
  40. package/src/run/shared.js +648 -600
  41. package/src/run/stage.js +43 -24
  42. package/src/scan-postcheck.js +9 -31
  43. package/src/sillyhub-mcp/client.js +389 -0
  44. package/src/spec-dir-typo.js +71 -0
  45. package/src/stage-contract-engine.js +22 -1
  46. package/src/stage-contract-spec.js +11 -4
  47. package/src/stage-contract.js +26 -3
  48. package/src/stage-review.js +13 -9
  49. package/src/stages/archive.js +4 -6
  50. package/src/stages/brainstorm-auto.js +26 -20
  51. package/src/stages/brainstorm.js +29 -5
  52. package/src/stages/cmd-existence.js +145 -0
  53. package/src/stages/doctor.js +9 -6
  54. package/src/stages/execute.js +61 -20
  55. package/src/stages/plan-postcheck.js +152 -0
  56. package/src/stages/plan.js +16 -31
  57. package/src/stages/quick.js +1 -1
  58. package/src/stages/scan.js +9 -3
  59. package/src/stages/status.js +0 -1
  60. package/src/stages/verify.js +12 -7
  61. package/src/sync.js +4 -4
  62. package/src/task-review.js +30 -1
  63. package/src/verify-postcheck.js +933 -862
  64. package/src/version.js +19 -0
  65. package/src/worktree-apply.js +85 -68
  66. package/src/worktree-deps.js +89 -5
  67. package/src/worktree.js +213 -111
  68. package/templates/prompts/taskcard-rules.md +27 -0
  69. package/templates/prompts/verify-probes.md +3 -3
  70. package/.claude/skills/sillyspec-propose/SKILL.md +0 -87
  71. package/src/stages/propose.js +0 -181
@@ -7,7 +7,7 @@
7
7
  * 设计原则(见 change 2026-07-09-machine-interface-v1 design.md §2/§3):
8
8
  * - 方案 B:独立模块单点封装,不污染既有 run/progress 输出
9
9
  * - 只聚合不新增校验:复用 stage-contract / task-review / verify-postcheck 既有策略引擎
10
- * - 只读语义边界(D-002@v1):只调 ProgressManager 读路径,不写 sillyspec.db / gate-status.json
10
+ * - 只读语义边界(D-002@v1):只调 ProgressManager 读路径,不写 sillyspec.db
11
11
  * - stdout 纯 JSON(D-005@v1):--json 模式下由 CLI 层(src/index.js 的 withJsonOutput)
12
12
  * 在调用 runGate/runDerive 期间劫持 console.log 到 stderr,防被调模块污染机器输出
13
13
  * - 退出码 0/1/2 三段语义(D-004@v1):0=通过,1=事实阻断,2=无法核验
@@ -18,8 +18,9 @@
18
18
  import { existsSync, readFileSync } from 'fs';
19
19
  import { join } from 'path';
20
20
  import { ProgressManager, resolveSpecDir } from './progress.js';
21
+ import { resolveRuntimeRoot } from './run/shared.js';
21
22
  import { runValidators, checkTransition, checkExecuteCodeEvidence } from './stage-contract.js';
22
- import { validateTaskReviews } from './task-review.js';
23
+ import { validateTaskReviews, resolveLatestExecuteRunId } from './task-review.js';
23
24
  import { runVerifyTestCheck } from './verify-postcheck.js';
24
25
 
25
26
  // ============ 退出码常量(D-004@v1)============
@@ -101,16 +102,35 @@ export function buildEnvelope({
101
102
  * @param {object} opts
102
103
  * @param {string} opts.cwd - 项目根目录
103
104
  * @param {string} [opts.specBase] - .sillyspec(或平台 specRoot)目录;默认 resolveSpecDir(cwd)
104
- * @param {string} [opts.runtimeRoot] - .runtime 目录;默认 join(specBase, '.runtime')
105
+ * @param {string} [opts.runtimeRoot] - .runtime 目录;默认 resolveRuntimeRoot 三级优先级解析
106
+ * @param {string} [opts.specDriftAnchor] - drift 场景主仓 specBase 锚点(坑 execute-runs-isolation)。
107
+ * drift 守卫命中时由调用方传入,task-reviews 的 execute-run-id marker 落点经
108
+ * resolveRuntimeRoot 锚主仓 .runtime,不读 worktree 副本(与 contract-matrix 调用方同语义)。
105
109
  * @returns {Promise<{ envelope: object, exitCode: number }>}
106
110
  */
107
- export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot } = {}) {
111
+ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, specDriftAnchor } = {}) {
108
112
  const specRoot = specBase || resolveSpecDir(cwd);
109
113
  const pm = new ProgressManager();
110
114
 
111
115
  try {
116
+ // ── 只读契约守卫(D-002@v1):db 不存在时 fail-closed 返回 exit 2,不触发建库落盘 ──
117
+ // pm.read → _ensureDB → db.init() 在 db 不存在/schema 戳落后时会 _createSchema + _save 落盘
118
+ // (db.js:init),这会让一个声明只读的查询命令在 spec 目录凭空建 sillyspec.db,违反只读契约。
119
+ // db 还没建 = 该变更从未跑过流程,无从核验 → 显式返回「无法核验」而非建库。
120
+ const dbPath = join(resolveSpecDir(cwd), '.runtime', 'sillyspec.db');
121
+ if (!existsSync(dbPath)) {
122
+ const envelope = buildEnvelope({
123
+ command: 'gate',
124
+ stage,
125
+ change: changeName,
126
+ ok: false,
127
+ errors: [`无法核验:进度库不存在(${dbPath})——变更 ${changeName} 尚未运行过任何阶段,只读 gate 不会为其建库`],
128
+ });
129
+ return { envelope, exitCode: EXIT_UNKNOWN };
130
+ }
131
+
112
132
  // ── 读进度:变更不存在 → exit 2(D-004@v1)──
113
- const progress = await pm.read(cwd, changeName);
133
+ const progress = pm.read(cwd, changeName);
114
134
  if (!progress) {
115
135
  const envelope = buildEnvelope({
116
136
  command: 'gate',
@@ -181,13 +201,14 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot }
181
201
  }
182
202
  }
183
203
 
184
- const rtRoot = runtimeRoot || join(specRoot, '.runtime');
185
- const runIdFile = join(rtRoot, `current-execute-run-id-${changeName}`);
204
+ // drift 场景经 resolveRuntimeRoot 三级优先级解析(runtimeRoot > specDriftAnchor > 本地兜底),
205
+ // 锚主仓 .runtime,不读 worktree 副本(坑 execute-runs-isolation,与 contract-matrix 调用方同语义)
206
+ const rtRoot = resolveRuntimeRoot({ runtimeRoot, specDriftAnchor }, specRoot);
207
+ // marker 缺失/为空时先扫描 execute-runs/ 找回真实 runId(与 gates.js #6 同源),
208
+ // 避免拿 '' 进 validateTaskReviews 拼出 execute-runs//tasks/... 产出全量误导 errors。
186
209
  let executeRunId = '';
187
210
  try {
188
- if (existsSync(runIdFile)) {
189
- executeRunId = readFileSync(runIdFile, 'utf8').trim();
190
- }
211
+ executeRunId = resolveLatestExecuteRunId({ runtimeRoot: rtRoot, changeName }) || '';
191
212
  } catch {
192
213
  executeRunId = '';
193
214
  }
@@ -300,7 +321,7 @@ export const FACETS = ['execute-evidence', 'verify-test', 'task-reviews', 'artif
300
321
  * 与 gate 的差异:gate 聚合某阶段所有 check 的综合结论;derive 只返回单一 facet 的结构化 data,
301
322
  * daemon 用来做细粒度事实采集(如轮询 execute-evidence 判断代码是否变更)。
302
323
  *
303
- * 只读语义边界同 gate(D-002@v1):仅调 ProgressManager.read,不写 sillyspec.db / gate-status.json。
324
+ * 只读语义边界同 gate(D-002@v1):仅调 ProgressManager.read,不写 sillyspec.db。
304
325
  * 唯一例外是 verify-test 会真实执行测试并落盘 test-result.json(产物取证,非状态写入,design §3.3)。
305
326
  *
306
327
  * 退出码语义(D-004@v1):0=事实通过,1=事实性阻断,2=无法核验(用法错/变更不存在/内部异常)。
@@ -310,10 +331,12 @@ export const FACETS = ['execute-evidence', 'verify-test', 'task-reviews', 'artif
310
331
  * @param {object} opts
311
332
  * @param {string} opts.cwd - 项目根目录
312
333
  * @param {string} [opts.specBase] - .sillyspec(或平台 specRoot)目录;默认 resolveSpecDir(cwd)
313
- * @param {string} [opts.runtimeRoot] - .runtime 目录;默认 join(specBase, '.runtime')
334
+ * @param {string} [opts.runtimeRoot] - .runtime 目录;默认 resolveRuntimeRoot 三级优先级解析
335
+ * @param {string} [opts.specDriftAnchor] - drift 场景主仓 specBase 锚点(坑 execute-runs-isolation),
336
+ * task-reviews facet 的 execute-run-id marker 落点经 resolveRuntimeRoot 锚主仓 .runtime,不读副本。
314
337
  * @returns {Promise<{ envelope: object, exitCode: number }>}
315
338
  */
316
- export async function runDerive(facet, changeName, { cwd, specBase, runtimeRoot } = {}) {
339
+ export async function runDerive(facet, changeName, { cwd, specBase, runtimeRoot, specDriftAnchor } = {}) {
317
340
  // ── 非法 facet:用法错 → exit 2(D-004@v1)──
318
341
  if (!FACETS.includes(facet)) {
319
342
  const envelope = buildEnvelope({
@@ -330,8 +353,21 @@ export async function runDerive(facet, changeName, { cwd, specBase, runtimeRoot
330
353
  const pm = new ProgressManager();
331
354
 
332
355
  try {
356
+ // ── 只读契约守卫(D-002@v1,同 runGate):db 不存在时 fail-closed 返回 exit 2,不建库落盘 ──
357
+ const dbPath = join(resolveSpecDir(cwd), '.runtime', 'sillyspec.db');
358
+ if (!existsSync(dbPath)) {
359
+ const envelope = buildEnvelope({
360
+ command: 'derive',
361
+ facet,
362
+ change: changeName,
363
+ ok: false,
364
+ errors: [`无法核验:进度库不存在(${dbPath})——变更 ${changeName} 尚未运行过任何阶段,只读 derive 不会为其建库`],
365
+ });
366
+ return { envelope, exitCode: EXIT_UNKNOWN };
367
+ }
368
+
333
369
  // ── 读进度:变更不存在 → exit 2 ──
334
- const progress = await pm.read(cwd, changeName);
370
+ const progress = pm.read(cwd, changeName);
335
371
  if (!progress) {
336
372
  const envelope = buildEnvelope({
337
373
  command: 'derive',
@@ -399,13 +435,12 @@ export async function runDerive(facet, changeName, { cwd, specBase, runtimeRoot
399
435
  }
400
436
  }
401
437
 
402
- const rtRoot = runtimeRoot || join(specRoot, '.runtime');
403
- const runIdFile = join(rtRoot, `current-execute-run-id-${changeName}`);
438
+ // drift 场景经 resolveRuntimeRoot 三级优先级解析(同 runGate execute 段),锚主仓 .runtime 不读副本
439
+ const rtRoot = resolveRuntimeRoot({ runtimeRoot, specDriftAnchor }, specRoot);
440
+ // marker 缺失/为空时先扫描 execute-runs/ 找回真实 runId(同 runGate execute 段),避免拿 '' 误导校验
404
441
  let executeRunId = '';
405
442
  try {
406
- if (existsSync(runIdFile)) {
407
- executeRunId = readFileSync(runIdFile, 'utf8').trim();
408
- }
443
+ executeRunId = resolveLatestExecuteRunId({ runtimeRoot: rtRoot, changeName }) || '';
409
444
  } catch {
410
445
  executeRunId = '';
411
446
  }
package/src/migrate.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, renameSync, copyFileSync } from 'fs';
1
+ import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, renameSync, copyFileSync, statSync, cpSync } from 'fs';
2
2
  import { basename, join, resolve } from 'path';
3
3
  import chalk from 'chalk';
4
4
 
@@ -60,9 +60,19 @@ export function migrateDocs(projectDir) {
60
60
  const src = join(archiveDir, entry);
61
61
  const dest = join(targetDir, entry);
62
62
  if (!existsSync(dest)) {
63
- copyFileSync(src, dest);
64
- console.log(chalk.green(' ✅') + ` archive/${entry}`);
65
- migrated++;
63
+ // 归档变更本身是目录(含 design.md 等),copyFileSync 对目录源抛 EISDIR;
64
+ // 按类型分发:目录用 cpSync 递归、文件用 copyFileSync,单条失败 try/catch 不中断整体迁移
65
+ try {
66
+ if (statSync(src).isDirectory()) {
67
+ cpSync(src, dest, { recursive: true });
68
+ } else {
69
+ copyFileSync(src, dest);
70
+ }
71
+ console.log(chalk.green(' ✅') + ` archive/${entry}`);
72
+ migrated++;
73
+ } catch (err) {
74
+ console.log(chalk.yellow(' ⚠️') + ` archive/${entry} 迁移失败: ${err.message}`);
75
+ }
66
76
  } else {
67
77
  console.log(chalk.yellow(' ⏭️') + ` archive/${entry} (已存在)`);
68
78
  }
package/src/modules.js CHANGED
@@ -60,7 +60,7 @@ function parseModuleCardFrontmatter(content) {
60
60
  * - verify_commands: 验证命令(build/test/lint)
61
61
  * - related_docs: 关联文档路径
62
62
  */
63
- export async function rebuildModuleMap(cwd) {
63
+ export async function rebuildModuleMap(cwd, { force = false } = {}) {
64
64
  const mapPath = findModuleMapPath(cwd);
65
65
  if (!mapPath) {
66
66
  console.error('❌ 未找到 .sillyspec/docs/<project>/modules/ 目录');
@@ -162,6 +162,13 @@ export async function rebuildModuleMap(cwd) {
162
162
  yaml += `\n`;
163
163
  }
164
164
 
165
+ // 破坏性保护(multi-agent-platform 坑 modules-rebuild-destructive):默认 dry-run 只预览不写,--force 才覆盖。
166
+ if (!force && existingMap) {
167
+ console.log('⚠️ rebuild 默认不写入(破坏性保护):该命令会覆盖 _module-map.yaml,清空 tags/entrypoints/main_symbols/depends_on/used_by 等手动维护字段');
168
+ console.log(' 本次为预览,未写入磁盘。确认覆盖请运行:sillyspec modules rebuild --force');
169
+ return { dryRun: true, path: mapPath }
170
+ }
171
+
165
172
  writeFileSync(mapPath, yaml, 'utf8');
166
173
  console.log(`✅ _module-map.yaml 已重建:${mapPath}`);
167
174
  console.log(` 模块数量:${allModuleIds.size}`);
@@ -170,6 +177,7 @@ export async function rebuildModuleMap(cwd) {
170
177
  }
171
178
  console.log(`\n⚠️ 注意:rebuild 只重建骨架。tags/entrypoints/main_symbols/depends_on/used_by 需要重新运行 scan 或手动补充。`);
172
179
  console.log(`ℹ️ schema 已升级到 v2,支持 role/core_files/test_files/entrypoints/depends_on/risk_level/verify_commands 等字段。`);
180
+ return { dryRun: false, path: mapPath, modules: allModuleIds.size }
173
181
  }
174
182
 
175
183
  /**
@@ -278,7 +286,10 @@ export async function generateDependenciesMd(cwd) {
278
286
  console.log(`✅ dependencies.md 已生成`);
279
287
  }
280
288
 
281
- function parseModuleMapSimple(content) {
289
+ // _module-map.yaml 简单 YAML 解析(模块上下文索引)。canonical 单一真相源——run/prompt.js
290
+ // 的 loadModuleContextIndex 与本文件 generateDependenciesMd 共用此函数(合并历史两份分叉副本,
291
+ // 2026-08-07;字段集取并集:core_files/test_files/related_docs/verify_commands + role/risk_level)。
292
+ export function parseModuleMapSimple(content) {
282
293
  const modules = {};
283
294
  let currentModule = null;
284
295
  let currentKey = null;
@@ -299,7 +310,7 @@ function parseModuleMapSimple(content) {
299
310
  if (!currentModule) continue;
300
311
 
301
312
  // Array field like depends_on:\n - xxx
302
- const arrayFieldMatch = line.match(/^ (depends_on|used_by|paths|tags|aliases|entrypoints|main_symbols|review_reasons):$/);
313
+ const arrayFieldMatch = line.match(/^ (depends_on|used_by|paths|tags|aliases|entrypoints|main_symbols|review_reasons|core_files|test_files|related_docs|verify_commands):$/);
303
314
  if (arrayFieldMatch) {
304
315
  if (currentArray && currentKey) modules[currentModule][currentKey] = currentArray;
305
316
  currentKey = arrayFieldMatch[1];
@@ -308,7 +319,7 @@ function parseModuleMapSimple(content) {
308
319
  }
309
320
 
310
321
  // Inline array like tags: [a, b]
311
- const inlineArrayMatch = line.match(/^ (depends_on|used_by|paths|tags|aliases|entrypoints|main_symbols|review_reasons): \[(.*)\]$/);
322
+ const inlineArrayMatch = line.match(/^ (depends_on|used_by|paths|tags|aliases|entrypoints|main_symbols|review_reasons|core_files|test_files|related_docs|verify_commands): \[(.*)\]$/);
312
323
  if (inlineArrayMatch) {
313
324
  if (currentArray && currentKey) modules[currentModule][currentKey] = currentArray;
314
325
  const vals = inlineArrayMatch[2].split(',').map(v => v.trim()).filter(Boolean);
@@ -319,7 +330,7 @@ function parseModuleMapSimple(content) {
319
330
  }
320
331
 
321
332
  // Scalar field
322
- const scalarMatch = line.match(/^ (status|doc|needs_review): (.+)$/);
333
+ const scalarMatch = line.match(/^ (status|doc|needs_review|role|risk_level): (.+)$/);
323
334
  if (scalarMatch) {
324
335
  if (currentArray && currentKey) { modules[currentModule][currentKey] = currentArray; currentArray = null; currentKey = null; }
325
336
  modules[currentModule][scalarMatch[1]] = scalarMatch[2];
@@ -12,32 +12,31 @@ export class ChangeRegistry {
12
12
  * 列出所有活跃变更名
13
13
  * SQL: SELECT name FROM changes WHERE status = 'active'
14
14
  */
15
- async listChanges(cwd) {
16
- const db = await this.pm._ensureDB(cwd);
15
+ listChanges(cwd) {
16
+ const db = this.pm._ensureDB(cwd);
17
17
  const sqlDb = db.getDb();
18
- const rows = sqlDb.exec("SELECT name FROM changes WHERE status = 'active' ORDER BY name");
19
- if (!rows || rows.length === 0) return [];
20
- return rows[0].values.map(r => r[0]);
18
+ const rows = sqlDb.prepare("SELECT name FROM changes WHERE status = 'active' ORDER BY name").all();
19
+ return rows.map(r => r.name);
21
20
  }
22
21
 
23
22
  /**
24
23
  * 注册变更到活跃列表
25
24
  * SQL: INSERT OR IGNORE → 若已 archived 则 UPDATE status='active'
26
25
  */
27
- async registerChange(cwd, changeName) {
26
+ registerChange(cwd, changeName) {
28
27
  if (!changeName) {
29
28
  console.warn('⚠️ registerChange: changeName 为空,跳过');
30
29
  return;
31
30
  }
32
- const db = await this.pm._ensureDB(cwd);
33
- db.transaction((sqlDb) => {
31
+ const db = this.pm._ensureDB(cwd);
32
+ db.transaction(() => {
33
+ const sqlDb = db.getDb();
34
34
  const now = new Date().toISOString();
35
35
  // 尝试插入新行
36
- sqlDb.run(
36
+ sqlDb.prepare(
37
37
  `INSERT OR IGNORE INTO changes (name, created_at, last_active)
38
- VALUES (?, ?, ?)`,
39
- [changeName, now, now]
40
- );
38
+ VALUES (?, ?, ?)`
39
+ ).run(changeName, now, now);
41
40
  // 注意:不复活已归档的变更——归档是不可逆操作
42
41
  // 如果变更已存在且为 archived,保持 archived 状态不变
43
42
  });
@@ -49,15 +48,15 @@ export class ChangeRegistry {
49
48
  * @param {string} changeName - 变更名
50
49
  * @param {{ status: string, mode?: string, reason?: string }} isolation
51
50
  */
52
- async updateChangeIsolation(cwd, changeName, isolation) {
53
- const db = await this.pm._ensureDB(cwd);
54
- const sqlDb = db.getDb();
51
+ updateChangeIsolation(cwd, changeName, isolation) {
52
+ const db = this.pm._ensureDB(cwd);
55
53
  try {
56
- sqlDb.run(
57
- `UPDATE changes SET isolation_status = ?, isolation_mode = ?, isolation_reason = ?, last_active = ? WHERE name = ?`,
58
- [isolation.status, isolation.mode || null, isolation.reason || null, new Date().toISOString(), changeName]
59
- );
60
- db._save();
54
+ db.transaction(() => {
55
+ const sqlDb = db.getDb();
56
+ sqlDb.prepare(
57
+ `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);
59
+ });
61
60
  } catch (err) {
62
61
  console.warn('⚠️ 更新 isolation 状态失败:', err.message);
63
62
  }
@@ -69,55 +68,54 @@ export class ChangeRegistry {
69
68
  * @param {string} changeName - 变更名
70
69
  * @returns {{ status: string|null, mode: string|null, reason: string|null }|null}
71
70
  */
72
- async readChangeIsolation(cwd, changeName) {
73
- const db = await this.pm._ensureDB(cwd);
71
+ readChangeIsolation(cwd, changeName) {
72
+ const db = this.pm._ensureDB(cwd);
74
73
  const sqlDb = db.getDb();
75
74
  try {
76
- const rows = sqlDb.exec(
77
- `SELECT isolation_status, isolation_mode, isolation_reason FROM changes WHERE name = ?`,
78
- [changeName]
79
- );
80
- if (!rows || rows.length === 0 || rows[0].values.length === 0) return null;
81
- const [status, mode, reason] = rows[0].values[0];
75
+ const row = sqlDb.prepare(
76
+ `SELECT isolation_status, isolation_mode, isolation_reason FROM changes WHERE name = ?`
77
+ ).get(changeName);
78
+ if (row === undefined) return null;
79
+ const { isolation_status: status, isolation_mode: mode, isolation_reason: reason } = row;
82
80
  return { status: status || null, mode: mode || null, reason: reason || null };
83
81
  } catch {
84
82
  return null;
85
83
  }
86
84
  }
87
85
 
88
- async _updatePlatformLastSync(cwd, changeName) {
86
+ _updatePlatformLastSync(cwd, changeName) {
89
87
  if (!changeName) return;
90
- const db = await this.pm._ensureDB(cwd);
91
- db.transaction((sqlDb) => {
92
- sqlDb.run(
93
- 'UPDATE changes SET platform_last_sync = ?, platform_sync_enabled = 1 WHERE name = ?',
94
- [new Date().toISOString(), changeName]
95
- );
88
+ const db = this.pm._ensureDB(cwd);
89
+ db.transaction(() => {
90
+ const sqlDb = db.getDb();
91
+ sqlDb.prepare(
92
+ 'UPDATE changes SET platform_last_sync = ?, platform_sync_enabled = 1 WHERE name = ?'
93
+ ).run(new Date().toISOString(), changeName);
96
94
  });
97
95
  }
98
96
 
99
- async _updateApprovalStatus(cwd, changeName, status, reason = null) {
97
+ _updateApprovalStatus(cwd, changeName, status, reason = null) {
100
98
  if (!changeName || !status) return;
101
- const db = await this.pm._ensureDB(cwd);
102
- db.transaction((sqlDb) => {
103
- const rows = sqlDb.exec('SELECT id FROM changes WHERE name = ?', [changeName]);
104
- if (!rows || rows.length === 0 || rows[0].values.length === 0) return;
105
- const changeId = rows[0].values[0][0];
99
+ const db = this.pm._ensureDB(cwd);
100
+ db.transaction(() => {
101
+ const sqlDb = db.getDb();
102
+ const row = sqlDb.prepare('SELECT id FROM changes WHERE name = ?').get(changeName);
103
+ if (row === undefined) return;
104
+ const changeId = row.id;
106
105
  const now = new Date().toISOString();
107
- sqlDb.run(
106
+ sqlDb.prepare(
108
107
  `INSERT INTO approvals (change_id, status, requested_at, approved_at, rejection_reason)
109
108
  VALUES (?, ?, ?, ?, ?)
110
109
  ON CONFLICT(change_id) DO UPDATE SET
111
110
  status = excluded.status,
112
111
  approved_at = excluded.approved_at,
113
- rejection_reason = excluded.rejection_reason`,
114
- [
115
- changeId,
116
- status,
117
- now,
118
- status === 'approved' ? now : null,
119
- status === 'rejected' ? reason : null,
120
- ]
112
+ rejection_reason = excluded.rejection_reason`
113
+ ).run(
114
+ changeId,
115
+ status,
116
+ now,
117
+ status === 'approved' ? now : null,
118
+ status === 'rejected' ? reason : null,
121
119
  );
122
120
  });
123
121
  }
@@ -128,7 +126,7 @@ export class ChangeRegistry {
128
126
  * @param {string} oldName - 旧变更名
129
127
  * @param {string} newName - 新变更名
130
128
  */
131
- async renameChange(cwd, oldName, newName) {
129
+ renameChange(cwd, oldName, newName) {
132
130
  if (!oldName || !newName) {
133
131
  console.warn('⚠️ renameChange: 旧名或新名为空,跳过');
134
132
  return;
@@ -137,21 +135,23 @@ export class ChangeRegistry {
137
135
  console.warn('⚠️ renameChange: 新旧名称相同,跳过');
138
136
  return;
139
137
  }
140
- const db = await this.pm._ensureDB(cwd);
138
+ const db = this.pm._ensureDB(cwd);
141
139
  // 检查旧名是否存在
142
- const existing = db.transaction((sqlDb) => {
143
- const row = sqlDb.exec(`SELECT name, status FROM changes WHERE name = ?`, [oldName]);
144
- if (!row || !row[0] || row[0].values.length === 0) return null;
145
- return { name: row[0].values[0][0], status: row[0].values[0][1] };
140
+ const existing = db.transaction(() => {
141
+ const sqlDb = db.getDb();
142
+ const row = sqlDb.prepare(`SELECT name, status FROM changes WHERE name = ?`).get(oldName);
143
+ if (row === undefined) return null;
144
+ return { name: row.name, status: row.status };
146
145
  });
147
146
  if (!existing) {
148
147
  console.error(`❌ 变更 ${oldName} 不存在`);
149
148
  return;
150
149
  }
151
150
  // 检查新名是否已存在
152
- const conflict = db.transaction((sqlDb) => {
153
- const row = sqlDb.exec(`SELECT name FROM changes WHERE name = ?`, [newName]);
154
- return row && row[0] && row[0].values.length > 0;
151
+ const conflict = db.transaction(() => {
152
+ const sqlDb = db.getDb();
153
+ const row = sqlDb.prepare(`SELECT name FROM changes WHERE name = ?`).get(newName);
154
+ return row !== undefined;
155
155
  });
156
156
  if (conflict) {
157
157
  console.error(`❌ 变更 ${newName} 已存在`);
@@ -164,8 +164,9 @@ export class ChangeRegistry {
164
164
  const newDir = this.pm._changePath(cwd, newName);
165
165
  const now = new Date().toISOString();
166
166
  try {
167
- db.transaction((sqlDb) => {
168
- sqlDb.run(`UPDATE changes SET name = ?, last_active = ? WHERE name = ?`, [newName, now, oldName]);
167
+ db.transaction(() => {
168
+ const sqlDb = db.getDb();
169
+ sqlDb.prepare(`UPDATE changes SET name = ?, last_active = ? WHERE name = ?`).run(newName, now, oldName);
169
170
  });
170
171
  } catch (e) {
171
172
  console.error(`❌ 重命名失败:更新数据库时出错(${e.message})`);
@@ -179,8 +180,9 @@ export class ChangeRegistry {
179
180
  renamed = false;
180
181
  // FS 重命名失败:回滚 DB 恢复 oldName,保持 DB 与目录一致
181
182
  try {
182
- db.transaction((sqlDb) => {
183
- sqlDb.run(`UPDATE changes SET name = ?, last_active = ? WHERE name = ?`, [oldName, now, newName]);
183
+ db.transaction(() => {
184
+ const sqlDb = db.getDb();
185
+ sqlDb.prepare(`UPDATE changes SET name = ?, last_active = ? WHERE name = ?`).run(oldName, now, newName);
184
186
  });
185
187
  console.error(`❌ 重命名失败:移动目录出错(${e.message}),已回滚数据库`);
186
188
  } catch (rollbackErr) {
@@ -199,18 +201,18 @@ export class ChangeRegistry {
199
201
  * 从活跃列表移除变更(归档时调用,不物理删除)
200
202
  * SQL: UPDATE changes SET status = 'archived'
201
203
  */
202
- async unregisterChange(cwd, changeName) {
204
+ unregisterChange(cwd, changeName) {
203
205
  if (!changeName) {
204
206
  console.warn('⚠️ unregisterChange: changeName 为空,跳过');
205
207
  return;
206
208
  }
207
- const db = await this.pm._ensureDB(cwd);
208
- db.transaction((sqlDb) => {
209
+ const db = this.pm._ensureDB(cwd);
210
+ db.transaction(() => {
211
+ const sqlDb = db.getDb();
209
212
  const now = new Date().toISOString();
210
- sqlDb.run(
211
- `UPDATE changes SET status = 'archived', last_active = ? WHERE name = ?`,
212
- [now, changeName]
213
- );
213
+ sqlDb.prepare(
214
+ `UPDATE changes SET status = 'archived', last_active = ? WHERE name = ?`
215
+ ).run(now, changeName);
214
216
  });
215
217
  }
216
218
  }
@@ -1,9 +1,10 @@
1
1
  // W6 Step9a: Revision v1 状态一致性检查 + 修复 + --force 审计日志。
2
2
  // 从 src/progress.js 单体 ProgressManager 抽出。持有 pm 引用(构造注入),调 pm.read / pm._write /
3
3
  // pm._ensureRuntimeDir / pm._runtimePath(persistence-core 留 facade 本体)。
4
- import { appendFileSync } from 'fs';
5
- import { existsSync } from 'fs';
6
- import { STAGE_ORDER } from './shared.js';
4
+ import { appendFileSync, existsSync, readdirSync } from 'fs';
5
+ import { join } from 'path';
6
+ import { summarizeTaskCompletion } from '../task-review.js';
7
+ import { STAGE_ORDER, MAIN_FLOW_ORDER } from './shared.js';
7
8
 
8
9
  export class ConsistencyDoctor {
9
10
  constructor(pm) {
@@ -32,8 +33,8 @@ export class ConsistencyDoctor {
32
33
  * @param {string|null} changeName
33
34
  * @returns {{ ok: boolean, issues: string[], warnings: string[] }}
34
35
  */
35
- async checkConsistency(cwd, changeName = null) {
36
- const data = await this.pm.read(cwd, changeName);
36
+ checkConsistency(cwd, changeName = null) {
37
+ const data = this.pm.read(cwd, changeName);
37
38
  if (!data) {
38
39
  return { ok: false, issues: ['无法读取进度数据'], warnings: [] };
39
40
  }
@@ -71,9 +72,11 @@ export class ConsistencyDoctor {
71
72
  }
72
73
 
73
74
  // d. 下游 completed 不能出现在上游 stale/revising 之后
74
- const stageIdx = STAGE_ORDER.indexOf(stageName);
75
+ // 用 MAIN_FLOW_ORDER(不含 scan)算上下游:scan 是 auxiliary 永不要求 completed,
76
+ // 把 scan 当上游会让「scan stale/revising」误报 brainstorm 不该 completed(plan-c 同根因)。
77
+ const stageIdx = MAIN_FLOW_ORDER.indexOf(stageName);
75
78
  for (let i = 0; i < stageIdx; i++) {
76
- const upstream = STAGE_ORDER[i];
79
+ const upstream = MAIN_FLOW_ORDER[i];
77
80
  const upData = data.stages[upstream];
78
81
  if (upData && (upData.status === 'stale' || upData.status === 'revising')) {
79
82
  if (sd.status === 'completed') {
@@ -91,6 +94,12 @@ export class ConsistencyDoctor {
91
94
  }
92
95
  }
93
96
 
97
+ // FR-07 / AC-03 / design §7 / D-02:lost-update 间接信号
98
+ // (.runtime/worktrees/<change> 目录残留 vs DB current_stage≠execute)。
99
+ // 只读诊断并入 issues 报告,不写 DB / 不删 worktree / 不自动修复。
100
+ const lostUpdateSignals = this.detectLostUpdateSignals(cwd);
101
+ issues.push(...lostUpdateSignals);
102
+
94
103
  // 输出报告
95
104
  console.log('');
96
105
  console.log(' ═══════════════════════════════════════');
@@ -114,6 +123,55 @@ export class ConsistencyDoctor {
114
123
  return { ok: issues.length === 0, issues, warnings };
115
124
  }
116
125
 
126
+ /**
127
+ * 检测 lost-update 间接信号(FR-07 / AC-03 / design §7 / D-02)。
128
+ *
129
+ * 判据:.runtime/worktrees/<change> 目录存在但 DB current_stage ≠ 'execute'
130
+ * → worktree 残留但进度被回退,是并发场景下 lost-update 的间接痕迹。
131
+ *
132
+ * 信号定义严格遵循 design §7 / FR-07:仅 current_stage !== 'execute' 判信号,
133
+ * 不扩展到 quick 等其它 stage;DB 无对应行(data=null)的 worktree 目录不算信号。
134
+ *
135
+ * 只读诊断:不写 DB、不删 worktree 目录、不自动修复
136
+ * (修复仍走 doctor --align / repairConsistency 既有逻辑)。
137
+ *
138
+ * @param {string} cwd
139
+ * @returns {string[]} issue 描述数组(每条含 change 名、实际 current_stage、worktree 目录路径)
140
+ */
141
+ detectLostUpdateSignals(cwd) {
142
+ const worktreesRoot = this.pm._runtimePath(cwd, 'worktrees');
143
+ // 零信号兼容既有 fixture:worktrees 目录不存在或读取失败时直接返回空数组
144
+ if (!existsSync(worktreesRoot)) return [];
145
+
146
+ let entries;
147
+ try {
148
+ entries = readdirSync(worktreesRoot, { withFileTypes: true });
149
+ } catch {
150
+ return [];
151
+ }
152
+
153
+ const issues = [];
154
+ for (const entry of entries) {
155
+ // Dirent.isDirectory 在 Windows / Linux / macOS 上一致判定目录项
156
+ if (!entry.isDirectory()) continue;
157
+ const changeName = entry.name;
158
+ const worktreeDir = join(worktreesRoot, changeName);
159
+
160
+ // 复用 pm.read(task-08 已同步化,直接拿返回值,无 await),不新开 DB 连接
161
+ const data = this.pm.read(cwd, changeName);
162
+ // DB 无对应行(data=null)不算信号,跳过
163
+ if (!data) continue;
164
+ // 仅 current_stage !== 'execute' 判信号
165
+ if (data.currentStage !== 'execute') {
166
+ issues.push(
167
+ `lost-update 信号: change "${changeName}" 的 worktree 目录仍存在(${worktreeDir}),` +
168
+ `但 current_stage 为 "${data.currentStage}"(非 execute,疑进度被回退)`
169
+ );
170
+ }
171
+ }
172
+ return issues;
173
+ }
174
+
117
175
  /**
118
176
  * Revision v1.2 状态修复
119
177
  * 默认 dry-run,--apply 才真正修改 DB。
@@ -125,10 +183,10 @@ export class ConsistencyDoctor {
125
183
  * @param {string|null} [opts.changeName]
126
184
  * @returns {{ fixable: object[], manual: string[], applied: object[] }}
127
185
  */
128
- async repairConsistency(cwd, opts = {}) {
186
+ repairConsistency(cwd, opts = {}) {
129
187
  const { apply = false, changeName = null } = opts;
130
188
 
131
- const data = await this.pm.read(cwd, changeName);
189
+ const data = this.pm.read(cwd, changeName);
132
190
  if (!data) {
133
191
  console.log('❌ 无法读取进度数据');
134
192
  return { fixable: [], manual: ['无法读取进度数据'], applied: [] };
@@ -156,10 +214,10 @@ export class ConsistencyDoctor {
156
214
  });
157
215
  }
158
216
 
159
- // Fix b: 上游 stale/revising,下游仍 completed → cascade stale
160
- const stageIdx = STAGE_ORDER.indexOf(stageName);
217
+ // Fix b: 上游 stale/revising,下游仍 completed → cascade stale(同 d,用 MAIN_FLOW_ORDER 排除 scan)
218
+ const stageIdx = MAIN_FLOW_ORDER.indexOf(stageName);
161
219
  for (let i = 0; i < stageIdx; i++) {
162
- const upstream = STAGE_ORDER[i];
220
+ const upstream = MAIN_FLOW_ORDER[i];
163
221
  const upData = data.stages[upstream];
164
222
  if (upData && (upData.status === 'stale' || upData.status === 'revising')) {
165
223
  if (sd.status === 'completed') {
@@ -206,7 +264,6 @@ export class ConsistencyDoctor {
206
264
  const changeDir = this.pm._changePath(cwd, changeName);
207
265
  if (changeDir && existsSync(changeDir)) {
208
266
  const runtimeRoot = this.pm._runtimePath(cwd);
209
- const { summarizeTaskCompletion } = await import('../task-review.js');
210
267
  const summary = summarizeTaskCompletion({ changeDir, runtimeRoot, changeName });
211
268
  if (summary.source === 'review.json' && summary.total > 0 && summary.pending.length === 0) {
212
269
  const desc = `execute: ${badSteps.length} 个 step 状态脱钩(${badSteps.map(st => st.name).join(', ')})——review.json 客观产出全通过(${summary.completed}/${summary.total}),自动标 completed`;
@@ -286,7 +343,7 @@ export class ConsistencyDoctor {
286
343
 
287
344
  if (apply && applied.length > 0) {
288
345
  data.lastActive = now;
289
- await this.pm._write(cwd, data, changeName);
346
+ this.pm._write(cwd, data, changeName);
290
347
  console.log(`\n ✅ 已修复 ${applied.length} 项`);
291
348
  }
292
349
 
@@ -18,10 +18,13 @@ export const STAGE_LABELS = {
18
18
  archive: '📦 归档变更',
19
19
  };
20
20
 
21
- // 完整主流程顺序(含 scan),用于下游 cascade / 一致性检查
21
+ // 完整阶段顺序(含 scan),仅用于展示/迭代顺序
22
22
  export const STAGE_ORDER = ['scan', 'brainstorm', 'plan', 'execute', 'verify', 'archive'];
23
- // 主流程阶段(不含 scan/quick/explore 等辅助阶段)—— 当前与 STAGE_ORDER 同值
24
- export const MAIN_FLOW_ORDER = STAGE_ORDER;
23
+ // 主流程阶段(不含 scan/quick/explore 等辅助阶段)—— 用于下游 cascade / 一致性上下游判定。
24
+ // scan 是 auxiliary(AUXILIARY_STAGES,按需显式跑、可 failed_post_check、永不要求 completed),
25
+ // 把它算进主流程上下游会让「scan stale/revising」误报 brainstorm/plan/execute 不该 completed
26
+ // (consistency-doctor 坑 + prompt-control-debt plan-c 同根因)。
27
+ export const MAIN_FLOW_ORDER = ['brainstorm', 'plan', 'execute', 'verify', 'archive'];
25
28
 
26
29
  // progress 数据版本(v3 = SQLite)
27
30
  export const CURRENT_VERSION = 3;