sillyspec 3.29.5 → 3.30.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.
Files changed (110) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +54 -54
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +2 -2
  3. package/.claude/skills/sillyspec-commit/SKILL.md +1 -1
  4. package/.claude/skills/sillyspec-flow/SKILL.md +57 -0
  5. package/.claude/skills/sillyspec-quick/SKILL.md +18 -116
  6. package/AGENTS.md +38 -20
  7. package/SKILL.md +9 -3
  8. package/assets/command-cards/flow.md +32 -0
  9. package/assets/command-cards/run-brainstorm.md +1 -1
  10. package/assets/command-cards/run-quick.md +8 -25
  11. package/package.json +2 -1
  12. package/packages/dashboard/server/executor.js +2 -1
  13. package/packages/dashboard/server/parser.js +7 -7
  14. package/r7-test-env/README-R7-TEST.md +93 -0
  15. package/r7-test-env/demo-project/lib/calc.js +6 -0
  16. package/r7-test-env/demo-project/package.json +1 -0
  17. package/r7-test-env/demo-project/test.js +10 -0
  18. package/r7-test-env/prompt-R7-SMOKE.md +41 -0
  19. package/r7-test-env/sillyspec-r7.cmd +3 -0
  20. package/round4/account.mjs +18 -5
  21. package/round4/b1-analyze-task08.mjs +40 -0
  22. package/round4/b1-b1-target-validation.md +32 -0
  23. package/round4/b3a-prompt-audit-result.md +66 -0
  24. package/round4/b3a-prompt-audit.mjs +89 -0
  25. package/round4/optimization-plan.md +165 -0
  26. package/round4/r4-final-report.html +347 -0
  27. package/round4/r5-scenario-matrix.md +46 -0
  28. package/src/change-list.js +9 -0
  29. package/src/classify-change.js +6 -0
  30. package/src/command-cards.js +2 -1
  31. package/src/commit-guard.js +2 -0
  32. package/src/config-schema.js +64 -12
  33. package/src/db.js +35 -4
  34. package/src/decision-distill.js +9 -3
  35. package/src/docs-check.js +1 -1
  36. package/src/doctor-diagnostics.js +44 -2
  37. package/src/flow-draft.js +748 -0
  38. package/src/flow-parity.js +236 -0
  39. package/src/flow-review.js +184 -0
  40. package/src/flow.js +1492 -0
  41. package/src/foreign-declared.js +17 -1
  42. package/src/fr-index.js +396 -22
  43. package/src/fs-atomic.js +5 -1
  44. package/src/git-helper.js +2 -2
  45. package/src/greenfield-bootstrap.js +98 -0
  46. package/src/handoff.js +120 -7
  47. package/src/hunk-attribution.js +191 -0
  48. package/src/index.js +570 -16
  49. package/src/knowledge-classify.js +47 -1
  50. package/src/knowledge-digest.js +162 -0
  51. package/src/knowledge-quicklog.js +246 -0
  52. package/src/machine-draft.js +128 -0
  53. package/src/machine-interface.js +107 -5
  54. package/src/mcp-server.js +1 -0
  55. package/src/module-resolve.js +3 -1
  56. package/src/preview-progress.js +160 -0
  57. package/src/progress/change-registry.js +11 -3
  58. package/src/progress/shared.js +3 -2
  59. package/src/progress.js +56 -13
  60. package/src/quicklog.js +44 -6
  61. package/src/redomain.js +147 -0
  62. package/src/review-material-pack.js +204 -1
  63. package/src/run/archive-distill.js +4 -2
  64. package/src/run/bg-sync.js +67 -0
  65. package/src/run/command.js +123 -10
  66. package/src/run/complete-handlers.js +327 -72
  67. package/src/run/complete.js +350 -71
  68. package/src/run/gate-snapshot-ledger.js +213 -0
  69. package/src/run/gate-snapshot.js +250 -34
  70. package/src/run/gates.js +370 -233
  71. package/src/run/green-cache.js +123 -0
  72. package/src/run/junction-rm.js +70 -0
  73. package/src/run/next.js +14 -0
  74. package/src/run/prompt.js +185 -55
  75. package/src/run/quick-audit.js +185 -12
  76. package/src/run/shared.js +120 -3
  77. package/src/run/stage.js +182 -216
  78. package/src/run/test-ledger.js +264 -0
  79. package/src/run/verify-quality-scan.js +84 -7
  80. package/src/scope-audit.js +6 -1
  81. package/src/sentinel-assertions.js +114 -0
  82. package/src/setup.js +3 -3
  83. package/src/spec-sync.js +41 -1
  84. package/src/stage-contract.js +26 -8
  85. package/src/stage-review-checklist.js +2 -0
  86. package/src/stage-review.js +7 -2
  87. package/src/stage-templates.js +157 -0
  88. package/src/stages/archive.js +2 -0
  89. package/src/stages/brainstorm.js +24 -106
  90. package/src/stages/execute.js +221 -84
  91. package/src/stages/knowledge.js +74 -1
  92. package/src/stages/plan-postcheck.js +352 -1
  93. package/src/stages/plan.js +23 -1
  94. package/src/stages/quick.js +10 -2
  95. package/src/stages/verify.js +4 -2
  96. package/src/sync.js +81 -23
  97. package/src/task-done.js +181 -0
  98. package/src/task-review.js +8 -55
  99. package/src/test-bindings.js +549 -0
  100. package/src/timeline.js +218 -0
  101. package/src/ui-visual.js +175 -0
  102. package/src/verify-draft.js +214 -0
  103. package/src/verify-postcheck.js +860 -158
  104. package/src/verify-probes.js +59 -3
  105. package/src/watcher.js +1031 -0
  106. package/src/worktree-apply.js +44 -4
  107. package/src/worktree-deps.js +22 -8
  108. package/src/worktree-policy.js +42 -0
  109. package/src/worktree.js +102 -15
  110. package/templates/agents-instruction.md +37 -18
@@ -22,6 +22,7 @@ import { resolveRuntimeRoot } from './run/shared.js';
22
22
  import { runValidators, checkTransition, checkExecuteCodeEvidence } from './stage-contract.js';
23
23
  import { validateTaskReviews, resolveLatestExecuteRunId } from './task-review.js';
24
24
  import { runVerifyTestCheck } from './verify-postcheck.js';
25
+ import { computeGateFingerprint, lookupGreenCache, storeGreenCache, greenCacheNotice } from './run/green-cache.js';
25
26
  import { checkCode } from './diagnostic-codes.js';
26
27
 
27
28
  // ============ 退出码常量(D-004@v1)============
@@ -116,7 +117,31 @@ export function buildEnvelope({
116
117
  * 让两函数按 ctx 切跨仓 gitDir / per-repo cwd。null 时两函数按既有单仓行为零回归。
117
118
  * @returns {Promise<{ envelope: object, exitCode: number }>}
118
119
  */
119
- export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, specDriftAnchor, ctx = null } = {}) {
120
+ /**
121
+ * verify 门禁 check 的绿结果缓存包装(R8 对撞修复 2026-09-23,实现见 src/run/green-cache.js)。
122
+ * 同指纹(HEAD+代码脏面+local.yaml)近期真跑的绿结果复用:命中 → synth(hit) 合成等价结果
123
+ * (带 cached 披露字段,调用方进 warnings——「结果是真的、本次没跑」必须明示);未命中 →
124
+ * 真跑,status==='passed' 时写缓存。fail-open:无根/指纹失败/缓存异常一律回退真跑(零回归)。
125
+ */
126
+ function greenCachedCheck({ kind, greenRoot, cwd, specBase, changeName }, runReal, synth) {
127
+ if (!greenRoot) return runReal();
128
+ let fingerprint = null;
129
+ try { fingerprint = computeGateFingerprint({ cwd, specBase }); } catch { fingerprint = null; }
130
+ if (!fingerprint) return runReal();
131
+ const scope = `change:${changeName}`;
132
+ let hit = null;
133
+ try { hit = lookupGreenCache({ runtimeRoot: greenRoot, scope, kind, fingerprint }); } catch { hit = null; }
134
+ if (hit) return synth(hit);
135
+ const result = runReal();
136
+ try {
137
+ if (result && result.status === 'passed') {
138
+ storeGreenCache({ runtimeRoot: greenRoot, scope, kind, fingerprint, result: { status: 'passed', exitCode: result.exitCode ?? 0, mode: result.mode ?? null } });
139
+ }
140
+ } catch { /* best-effort 写缓存 */ }
141
+ return result;
142
+ }
143
+
144
+ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, specDriftAnchor, ctx = null, full = false } = {}) {
120
145
  const specRoot = specBase || resolveSpecDir(cwd);
121
146
  // A4:pm 用 specRoot(而非默认 resolveSpecDir(cwd))——--spec-dir/平台模式下 specBase 是真实
122
147
  // specDir,默认解析会指向本地孤儿库,与下方 planContent/changeDir 的 specRoot 两套事实源混拼。
@@ -288,8 +313,14 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
288
313
 
289
314
  // ── d. verify 阶段追加 verify-test(CLI 实测 local.yaml commands.test)──
290
315
  if (stage === 'verify') {
291
- const vt = runVerifyTestCheck({ cwd, specBase: specRoot, changeName, ctx });
316
+ // 绿缓存根:与 runtimeRoot 解析同源(drift 锚主仓口径);解析失败 null → 不缓存不命中
317
+ let greenRoot = null;
318
+ try { greenRoot = resolveRuntimeRoot({ runtimeRoot, specDriftAnchor }, specRoot); } catch { greenRoot = null; }
319
+ const vt = greenCachedCheck({ kind: 'test', greenRoot, cwd, specBase: specRoot, changeName },
320
+ () => runVerifyTestCheck({ cwd, specBase: specRoot, changeName, ctx }),
321
+ (hit) => ({ status: 'passed', exitCode: 0, durationMs: 0, resultPath: null, mode: null, fallbackReason: null, exemptedCount: 0, cached: true, reason: greenCacheNotice(hit, 'verify-test') }));
292
322
  const vtWarnings = [];
323
+ if (vt.cached) vtWarnings.push(vt.reason);
293
324
  if (vt.status === 'skipped') {
294
325
  vtWarnings.push(`⚠️ verify-test SKIPPED — gate 未核验测试(${vt.reason || 'local.yaml 未配置 commands.test 或显式无测试'})。本次 gate 结论不含测试客观核验,driver 不应据 exit 0 判定测试通过;integration-critical 变更应在 verify 阶段降级 FAIL`);
295
326
  }
@@ -311,6 +342,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
311
342
  warnings: vtWarnings,
312
343
  data: {
313
344
  status: vt.status,
345
+ cached: vt.cached === true,
314
346
  exitCode: vt.exitCode,
315
347
  durationMs: vt.durationMs,
316
348
  resultPath: vt.resultPath,
@@ -327,7 +359,9 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
327
359
  // 不一致时以 --done 为准。lint 未配置/不可跑 → informational,不阻断只读查询。
328
360
  try {
329
361
  const { runVerifyLintCheck, triageLintOwnership, resolveVerifyChangedFiles } = await import('./verify-postcheck.js');
330
- const lc = runVerifyLintCheck({ cwd, specBase: specRoot });
362
+ const lc = greenCachedCheck({ kind: 'lint', greenRoot, cwd, specBase: specRoot, changeName },
363
+ () => runVerifyLintCheck({ cwd, specBase: specRoot }),
364
+ (hit) => ({ status: 'passed', exitCode: 0, durationMs: 0, failureFiles: [], cached: true, reason: greenCacheNotice(hit, 'verify-lint') }));
331
365
  if (lc.status === 'skipped') {
332
366
  checks.push({
333
367
  id: 'verify-lint',
@@ -341,6 +375,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
341
375
  const lcWarnings = [
342
376
  '口径说明:gate verify 的 lint 在工作树直跑;verify --done 的 lint 在隔离快照(HEAD+归属文件 overlay)跑——两口径不一致时以 --done 为准。',
343
377
  ];
378
+ if (lc.cached) lcWarnings.push(lc.reason);
344
379
  let ownership = null;
345
380
  let lintOk = lc.status !== 'failed';
346
381
  if (lc.status === 'failed') {
@@ -359,6 +394,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
359
394
  warnings: lcWarnings,
360
395
  data: {
361
396
  status: lc.status,
397
+ cached: lc.cached === true,
362
398
  exitCode: lc.exitCode,
363
399
  durationMs: lc.durationMs,
364
400
  ownership: ownership ? ownership.verdict : null,
@@ -378,6 +414,65 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
378
414
  }
379
415
  }
380
416
 
417
+ // ── e. --full 只读预检档(P1 / FR-01 / D-002@batch3,2026-09-21-r5-efficiency-batch3 task-03)──
418
+ // 覆盖 --done 独有的贵门(batch2 实证四类盲区:reconcile 两轮/stage review 缺失各吃一整 agent
419
+ // 回合):①target_files reconcile 只读跑(reconcileTargetFiles 纯计算不落盘,--done 的写结果
420
+ // 文件在其调用方);②stage review 在场探测(marker+review.json——缺则报预期路径,同 --done
421
+ // Stage Review Gate 口径,只报缺不生成)。全只读零副作用;未传 full 零行为变化。
422
+ if (full && (stage === 'verify' || stage === 'execute')) {
423
+ // e1. reconcile(verify --done 的 target_files 对账门)
424
+ if (stage === 'verify') {
425
+ try {
426
+ const { reconcileTargetFiles } = await import('./verify-postcheck.js');
427
+ const rec = reconcileTargetFiles({ cwd, specBase: specRoot, changeName });
428
+ const recOk = rec.status !== 'missing_declared';
429
+ checks.push({
430
+ id: 'full-target-files-reconcile',
431
+ code: checkCode('full-target-files-reconcile') || 'full_target_files_reconcile',
432
+ ok: recOk,
433
+ errors: !recOk
434
+ ? (rec.missing || []).map((m) => `task-${m.task || '?'}: ${m.path} 声明未做(②类计划落空)——补做交付或按声明修正通道改卡(与 review 结论一致)`)
435
+ : [],
436
+ warnings: (rec.undeclared || []).length > 0
437
+ ? [`${(rec.undeclared || []).length} 个未声明文件(warning 级,${(rec.undeclared || []).slice(0, 3).map((u) => u.path).join('、')}${(rec.undeclared || []).length > 3 ? ' 等' : ''})——并行会话归因噪音或漏声明,verify 报告须裁决`]
438
+ : [],
439
+ data: { status: rec.status, matched: (rec.matched || []).length, missing: (rec.missing || []).length, undeclared: (rec.undeclared || []).length },
440
+ });
441
+ } catch (e) {
442
+ checks.push({
443
+ id: 'full-target-files-reconcile',
444
+ code: 'full_target_files_reconcile',
445
+ ok: true,
446
+ informational: true,
447
+ warnings: [`--full reconcile 检查装配失败(fail-open): ${e && e.message ? e.message : e}`],
448
+ });
449
+ }
450
+ }
451
+ // e2. stage review 在场探测(verify/execute --done 的 Stage Review Gate)
452
+ try {
453
+ const { getLatestStageReviewRunId } = await import('./stage-review.js');
454
+ const rrRoot = runtimeRoot || join(specRoot, '.runtime');
455
+ const runId = getLatestStageReviewRunId(rrRoot, stage, changeName);
456
+ checks.push({
457
+ id: 'full-stage-review',
458
+ code: checkCode('full-stage-review') || 'full_stage_review_missing',
459
+ ok: !!runId,
460
+ errors: !runId
461
+ ? [`缺 ${stage} 阶段的 stage review.json(tier=independent 要求独立审查产出)——--done 将被 Stage Review Gate 拦;可先 sillyspec register-stage-review --change ${changeName} --stage ${stage} 生成骨架再填(降级条款照 review.json reviewerNotes 首行留痕)`]
462
+ : [],
463
+ data: { runId: runId || null },
464
+ });
465
+ } catch (e) {
466
+ checks.push({
467
+ id: 'full-stage-review',
468
+ code: 'full_stage_review_missing',
469
+ ok: true,
470
+ informational: true,
471
+ warnings: [`--full stage review 探测装配失败(fail-open): ${e && e.message ? e.message : e}`],
472
+ });
473
+ }
474
+ }
475
+
381
476
  // ── 综合结论:所有非 informational check 均 ok ──
382
477
  const ok = checks.filter((c) => !c.informational).every((c) => c.ok);
383
478
  const exitCode = ok ? EXIT_OK : EXIT_BLOCKED;
@@ -519,9 +614,14 @@ export async function runDerive(facet, changeName, { cwd, specBase, runtimeRoot,
519
614
 
520
615
  // ── b. verify-test:真实执行测试命令并取证 ──
521
616
  case 'verify-test': {
522
- const vt = runVerifyTestCheck({ cwd, specBase: specRoot, changeName, ctx });
617
+ let greenRoot = null;
618
+ try { greenRoot = resolveRuntimeRoot({ runtimeRoot, specDriftAnchor }, specRoot); } catch { greenRoot = null; }
619
+ const vt = greenCachedCheck({ kind: 'test', greenRoot, cwd, specBase: specRoot, changeName },
620
+ () => runVerifyTestCheck({ cwd, specBase: specRoot, changeName, ctx }),
621
+ (hit) => ({ status: 'passed', exitCode: 0, durationMs: 0, resultPath: null, mode: null, fallbackReason: null, exemptedCount: 0, cached: true, reason: greenCacheNotice(hit, 'verify-test') }));
523
622
  data = {
524
623
  status: vt.status,
624
+ cached: vt.cached === true,
525
625
  exitCode: vt.exitCode,
526
626
  durationMs: vt.durationMs,
527
627
  resultPath: vt.resultPath,
@@ -530,7 +630,9 @@ export async function runDerive(facet, changeName, { cwd, specBase, runtimeRoot,
530
630
  };
531
631
  ok = vt.status !== 'failed';
532
632
  errors = vt.status === 'failed' ? [`测试失败: ${vt.reason || ''}`] : [];
533
- warnings = vt.status === 'skipped'
633
+ warnings = vt.cached
634
+ ? [vt.reason]
635
+ : vt.status === 'skipped'
534
636
  ? ['测试被跳过']
535
637
  : (vt.mode === 'full' && vt.fallbackReason)
536
638
  ? [`⚠️ verify-test 跑的是全量 commands.test(${vt.fallbackReason});失败可能含未变更模块的预存错误`]
package/src/mcp-server.js CHANGED
@@ -93,6 +93,7 @@ function runCli(cliArgs) {
93
93
  cwd: process.cwd(),
94
94
  env: process.env,
95
95
  stdio: ['ignore', 'pipe', 'pipe'],
96
+ windowsHide: true,
96
97
  })
97
98
  let out = ''
98
99
  let err = ''
@@ -78,8 +78,10 @@ export function collectModuleMaps({ cwd, specBase }) {
78
78
  /**
79
79
  * 路径归一:反斜杠→正斜杠,剥尾部 glob(/** 与 /*)与目录斜杠。
80
80
  * `backend/**` / `backend/` / `backend` → `backend`。
81
+ * (导出供 flow-parity.reconcileModuleDocs 复用——2026-09-27-thin-module-scope-persist:
82
+ * 模块对账与模块卡解析同一归一口径,防两处漂移)
81
83
  */
82
- function normalizeMapPath(raw) {
84
+ export function normalizeMapPath(raw) {
83
85
  return String(raw).replace(/\\/g, '/').replace(/\/\*\*$/, '').replace(/\/\*$/, '').replace(/\/$/, '')
84
86
  }
85
87
 
@@ -0,0 +1,160 @@
1
+ /**
2
+ * preview-progress.js — watcher 预览进度账本写路径(2026-09-23-watcher-preview-progress task-02)。
3
+ *
4
+ * 职责:把 watcher 快照的阶段级推断态投影为 progress 库 stages 预览行(authority='watcher')。
5
+ * 设计契约(decisions D-004/D-005,design §3):
6
+ * - 粒度=阶段级(工件签名→progress 阶段映射;steps 级不投影,D-004);
7
+ * - 写纪律=短连接(open→写→close)+ 自带 PRAGMA busy_timeout(连接级,openDatabase 不自动——
8
+ * 影子审查 gap①)+ 存在性条件进 SQL + maxRows 写放大护栏 + 全程 fail-open(任何异常返回 false,
9
+ * 绝不影响 watcher 事件流主循环)。WHERE 认领语义(D-005@v2 执行期修正:initChange 预建
10
+ * 全套 cli/pending 行,纯 authority='watcher' 条件永远空转):watcher 行可自刷;**CLI 从未
11
+ * 触碰的 pending 行(status='pending' 且 started_at IS NULL)可被预览认领**(flip 为 watcher,
12
+ * 语义无损——pending 空行不含 CLI 任何 authored 信息);CLI 已触碰行(started/completed/
13
+ * reopened)SQL 层不可中招;CLI --done 归章(task-01 fail① 修复)把认领行收回 cli。
14
+ * - archived 拍不投影(终态只认 CLI)。
15
+ * 读侧出口在 progress.js readPreviewProgress(task-03);读侧保险丝在 progress.js 读方法(task-01)。
16
+ */
17
+ import { join } from 'node:path'
18
+ import { openDatabase } from './db-engine.js'
19
+
20
+ /** 工件签名 → progress 阶段映射(纯静态;watcher 快照 files 的键为工件基名)。 */
21
+ export const PREVIEW_STAGE_SOURCES = {
22
+ brainstorm: ['proposal.md', 'requirements.md', 'decisions.md', 'design.md'],
23
+ plan: ['plan.md'],
24
+ execute: ['tasks.md'],
25
+ verify: ['verify-result.md', 'verify-facts.json'],
26
+ }
27
+
28
+ /**
29
+ * 纯函数:快照差分 → 阶段级预览行集合。
30
+ * @param {{ ts:number, archived:boolean, files?:Object }} snapshot 当前拍
31
+ * @param {{ files?:Object }=} prevSnapshot 上一拍(缺省全量视角)
32
+ * @returns {{ stage:string, status:'in-progress', startedAt:string, evidence:Object }[]}
33
+ * evidence = { ts, files: 命中的工件清单, changed: 本拍有无内容变化(差分信号) }
34
+ */
35
+ export function projectPreviewStages({ snapshot, prevSnapshot = null } = {}) {
36
+ if (!snapshot || snapshot.archived) return []
37
+ const files = snapshot.files || {}
38
+ const prevFiles = (prevSnapshot && prevSnapshot.files) || {}
39
+ const rows = []
40
+ for (const [stage, sources] of Object.entries(PREVIEW_STAGE_SOURCES)) {
41
+ const hit = sources.filter((k) => Object.prototype.hasOwnProperty.call(files, k))
42
+ if (hit.length === 0) continue
43
+ const changed = hit.some((k) => {
44
+ const a = files[k]; const b = prevFiles[k]
45
+ if (b === undefined) return true // 出现
46
+ return JSON.stringify(a) !== JSON.stringify(b) // 内容变化(checked 增/键值漂移)
47
+ })
48
+ rows.push({
49
+ stage,
50
+ status: 'in-progress',
51
+ startedAt: new Date(snapshot.ts || Date.now()).toISOString(),
52
+ evidence: { ts: snapshot.ts || null, files: hit, changed },
53
+ })
54
+ }
55
+ return rows
56
+ }
57
+
58
+ /**
59
+ * 短连接写入预览行(best-effort,全程 fail-open)。
60
+ * @returns {boolean} true=至少尝试写入(库在/变更注册);false=跳过(库缺失/变更未注册/异常)
61
+ */
62
+ export function writePreviewStages({ specDir, changeName, rows, maxRows = 8 } = {}) {
63
+ if (!specDir || !changeName || !Array.isArray(rows) || rows.length === 0) return false
64
+ let db = null
65
+ try {
66
+ db = openDatabase(join(specDir, '.runtime', 'sillyspec.db'), {})
67
+ // 连接级 PRAGMA(影子审查 gap①):openDatabase 不自动带,缺省 0 = 撞锁立即 BUSY
68
+ db.exec('PRAGMA busy_timeout = 5000')
69
+ const changeRow = db.prepare('SELECT id FROM changes WHERE name = ?').get(changeName)
70
+ if (!changeRow) return false // 变更未注册(未跑过任何阶段)——无从挂预览,安静跳过
71
+ const cid = changeRow.id
72
+ const stmt = db.prepare(
73
+ `INSERT INTO stages (change_id, stage, status, started_at, authority, preview_evidence)
74
+ VALUES (?, ?, ?, ?, 'watcher', ?)
75
+ ON CONFLICT(change_id, stage) DO UPDATE SET
76
+ status = excluded.status,
77
+ started_at = COALESCE(stages.started_at, excluded.started_at),
78
+ preview_evidence = excluded.preview_evidence,
79
+ authority = 'watcher'
80
+ WHERE stages.authority = 'watcher'
81
+ OR (stages.authority = 'cli' AND stages.status = 'pending' AND stages.started_at IS NULL)`
82
+ )
83
+ for (const r of rows.slice(0, maxRows)) {
84
+ stmt.run(cid, r.stage, r.status, r.startedAt, JSON.stringify(r.evidence || {}))
85
+ }
86
+ return true
87
+ } catch {
88
+ return false // fail-open:锁竞争/库损坏/迁移未达——事件流不受影响,下轮重投影
89
+ } finally {
90
+ if (db) { try { db.close() } catch { /* 关闭失败不放大 */ } }
91
+ }
92
+ }
93
+
94
+ /** 步骤级工件映射:变更工件基名 → (阶段, 步骤名) 的对应——watcher 步骤节拍的数据源。 */
95
+ export const PREVIEW_STEP_SOURCES = {
96
+ 'proposal.md': { stage: 'brainstorm', step: '写设计文档并自审' },
97
+ 'requirements.md': { stage: 'brainstorm', step: '写设计文档并自审' },
98
+ 'design.md': { stage: 'brainstorm', step: '写设计文档并自审' },
99
+ 'decisions.md': { stage: 'brainstorm', step: '写设计文档并自审' },
100
+ 'plan.md': { stage: 'plan', step: '生成分级计划' },
101
+ 'verify-result.md': { stage: 'verify', step: '输出验证报告' },
102
+ 'verify-facts.json': { stage: 'verify', step: '输出验证报告' },
103
+ 'module-impact.md': { stage: 'archive', step: 'extract-module-impact 与归档语义收尾' },
104
+ }
105
+
106
+ /**
107
+ * 纯函数:快照差分 → 步骤级预览行集合(预览账本 v2,D-004 兑现)。
108
+ * 粒度=工件信号可映射的步骤(tasks.md 勾选→execute 逐任务步不可靠映射,不做——纯思考
109
+ * 型步骤由 --done 批量自述,burst 已支持)。返回行带 stage+step 双键。
110
+ */
111
+ export function projectPreviewSteps({ snapshot, prevSnapshot = null } = {}) {
112
+ if (!snapshot || snapshot.archived) return []
113
+ const files = snapshot.files || {}
114
+ const prevFiles = (prevSnapshot && prevSnapshot.files) || {}
115
+ const rows = []
116
+ for (const [fname, target] of Object.entries(PREVIEW_STEP_SOURCES)) {
117
+ if (!Object.prototype.hasOwnProperty.call(files, fname)) continue
118
+ const changed = prevFiles[fname] === undefined || JSON.stringify(files[fname]) !== JSON.stringify(prevFiles[fname])
119
+ rows.push({
120
+ stage: target.stage,
121
+ step: target.step,
122
+ status: 'in-progress',
123
+ startedAt: new Date(snapshot.ts || Date.now()).toISOString(),
124
+ evidence: { ts: snapshot.ts || null, file: fname, changed },
125
+ })
126
+ }
127
+ return rows
128
+ }
129
+
130
+ /**
131
+ * 写步骤级预览行(短连接,fail-open):steps 表 authority='watcher',仅当该步骤
132
+ * 不存在或也是 watcher 行时写入(CLI 步骤行不可中招)。
133
+ */
134
+ export function writePreviewSteps({ specDir, changeName, rows, maxRows = 12 } = {}) {
135
+ if (!specDir || !changeName || !Array.isArray(rows) || rows.length === 0) return false
136
+ let db = null
137
+ try {
138
+ db = openDatabase(join(specDir, '.runtime', 'sillyspec.db'), {})
139
+ db.exec('PRAGMA busy_timeout = 5000')
140
+ const changeRow = db.prepare('SELECT id FROM changes WHERE name = ?').get(changeName)
141
+ if (!changeRow) return false
142
+ const cid = changeRow.id
143
+ const stageStmt = db.prepare("SELECT id FROM stages WHERE change_id = ? AND stage = ? AND (authority = 'watcher' OR authority = 'cli' OR authority IS NULL)")
144
+ const insStmt = db.prepare(
145
+ `INSERT INTO steps (stage_id, name, status, completed_at, ordering, authority, preview_evidence)
146
+ VALUES (?, ?, 'in-progress', NULL, 0, 'watcher', ?)
147
+ ON CONFLICT DO NOTHING`
148
+ )
149
+ for (const r of rows.slice(0, maxRows)) {
150
+ const stageRow = stageStmt.get(cid, r.stage)
151
+ if (!stageRow) continue // 阶段行不在场——CLI 尚未进入该阶段,不预建
152
+ insStmt.run(stageRow.id, r.step, JSON.stringify(r.evidence || {}))
153
+ }
154
+ return true
155
+ } catch {
156
+ return false
157
+ } finally {
158
+ if (db) { try { db.close() } catch {} }
159
+ }
160
+ }
@@ -613,9 +613,9 @@ export class ChangeRegistry {
613
613
  const row = sqlDb.prepare('SELECT id FROM changes WHERE name = ?').get(changeName);
614
614
  if (row) {
615
615
  sqlDb.prepare(
616
- `INSERT INTO stages (change_id, stage, status, started_at, completed_at)
617
- VALUES (?, 'archive', 'completed', ?, ?)
618
- ON CONFLICT(change_id, stage) DO UPDATE SET status = 'completed', completed_at = excluded.completed_at`
616
+ `INSERT INTO stages (change_id, stage, status, started_at, completed_at, authority)
617
+ VALUES (?, 'archive', 'completed', ?, ?, 'cli')
618
+ ON CONFLICT(change_id, stage) DO UPDATE SET status = 'completed', completed_at = excluded.completed_at, authority = 'cli'`
619
619
  ).run(row.id, now, now);
620
620
  const stageRow = sqlDb.prepare(
621
621
  'SELECT id FROM stages WHERE change_id = ? AND stage = ?'
@@ -638,6 +638,14 @@ export class ChangeRegistry {
638
638
  }
639
639
  }
640
640
  }
641
+ // 预览账本 GC(2026-09-23-watcher-preview-progress task-04 / FR-06):归档收尾清 watcher
642
+ // 预览行(steps 随 stages 的 ON DELETE CASCADE 级联自动清)。fail-open:失败 warn 不阻断归档。
643
+ try {
644
+ const prow = sqlDb.prepare('SELECT id FROM changes WHERE name = ?').get(changeName);
645
+ if (prow) sqlDb.prepare("DELETE FROM stages WHERE change_id = ? AND authority = 'watcher'").run(prow.id);
646
+ } catch (e) {
647
+ console.warn(`⚠️ 预览行 GC 失败(fail-open 不阻断归档):${(e && e.message) || e}`);
648
+ }
641
649
  // 本地脏度(D-013 / task-04):归档也是本地状态推进
642
650
  this.pm._touchLocalModified(cwd, changeName, now);
643
651
  });
@@ -27,8 +27,9 @@ export const STAGE_ORDER = ['scan', 'brainstorm', 'plan', 'execute', 'verify', '
27
27
  export const MAIN_FLOW_ORDER = ['brainstorm', 'plan', 'execute', 'verify', 'archive'];
28
28
 
29
29
  // progress 数据版本(v3 = SQLite;v4 = changes 加 base_ts / 本地脏度两列,platform-sync §8;
30
- // v6 = changes 加 owner_session 所有权列,2026-09-14-change-ownership-guards D-005@v1)
31
- export const CURRENT_VERSION = 6;
30
+ // v6 = changes 加 owner_session 所有权列,2026-09-14-change-ownership-guards D-005@v1;
31
+ // v8 = 出生阶段 'scan'→'brainstorm' + 存量出生默认行迁移,2026-09-27-change-birth-stage-brainstorm)
32
+ export const CURRENT_VERSION = 8;
32
33
 
33
34
  // 滞留提示阈值(天,2026-08-30 用户反馈②:7 个「代码全落地但流程没收口」的变更挂 38 天
34
35
  // 无人发现):活跃变更 last_active 距今超该天数且流程未收口(archive 未完成)时,progress
package/src/progress.js CHANGED
@@ -15,6 +15,7 @@ import { join, basename, dirname, resolve, sep } from 'path';
15
15
  import { tmpdir, hostname } from 'os';
16
16
  import { writeAtomicSync } from './fs-atomic.js';
17
17
  import { DB } from './db.js';
18
+ import { openDatabase } from './db-engine.js';
18
19
  import { checkExecuteCodeEvidence } from './stage-contract.js';
19
20
  import { ConsistencyDoctor } from './progress/consistency-doctor.js';
20
21
  import { ChangeRegistry } from './progress/change-registry.js';
@@ -270,6 +271,36 @@ const CHANGES_SUBDIR = 'changes';
270
271
 
271
272
  // ── ProgressManager ──
272
273
 
274
+ /**
275
+ * 预览进度读出口(2026-09-23-watcher-preview-progress task-03 / FR-04):
276
+ * 读 watcher 预览行(authority='watcher',读侧保险丝之外的显式出口)+ evidence 解析。
277
+ * 纯只读短连接;库缺失/变更未注册/异常 → { exists:false, stages:[] }(fail-open)。
278
+ */
279
+ export function readPreviewProgress(specDir, changeName) {
280
+ if (!specDir || !changeName) return { exists: false, stages: [] }
281
+ let db = null
282
+ try {
283
+ db = openDatabase(join(specDir, '.runtime', 'sillyspec.db'), { readOnly: true })
284
+ const changeRow = db.prepare('SELECT id FROM changes WHERE name = ?').get(changeName)
285
+ if (!changeRow) return { exists: false, stages: [] }
286
+ const rows = db.prepare(
287
+ "SELECT stage, status, started_at, preview_evidence FROM stages WHERE change_id = ? AND authority = 'watcher' ORDER BY stage"
288
+ ).all(changeRow.id)
289
+ return {
290
+ exists: true,
291
+ stages: rows.map((r) => {
292
+ let evidence = null
293
+ try { evidence = r.preview_evidence ? JSON.parse(r.preview_evidence) : null } catch { evidence = null }
294
+ return { stage: r.stage, status: r.status, startedAt: r.started_at, evidence }
295
+ }),
296
+ }
297
+ } catch {
298
+ return { exists: false, stages: [] }
299
+ } finally {
300
+ if (db) { try { db.close() } catch { /* 只读关闭失败不放大 */ } }
301
+ }
302
+ }
303
+
273
304
  export class ProgressManager {
274
305
  /** 模块级 DB 连接池:Map<dbPath, DB>(SEC-09/PERF-10,见 _ensureDB 注释) */
275
306
  static _dbPool = new Map();
@@ -401,7 +432,7 @@ export class ProgressManager {
401
432
  const { id: changeId, name: cName, current_stage: currentStage, no_worktree: noWorktree, last_active: lastActive } = changeRow;
402
433
 
403
434
  // 2. 从 stages 表获取所有阶段(含 revision 列)
404
- const stageRows = sqlDb.prepare('SELECT id, stage, status, started_at, completed_at, revision, reopened_from_step, reopened_at, stale_reason FROM stages WHERE change_id = ? ORDER BY id').all(changeId);
435
+ const stageRows = sqlDb.prepare(`SELECT id, stage, status, started_at, completed_at, revision, reopened_from_step, reopened_at, stale_reason FROM stages WHERE change_id = ? AND (authority = 'cli' OR authority IS NULL) ORDER BY id`).all(changeId);
405
436
  const stageMap = {};
406
437
  const stageIds = [];
407
438
  for (const row of stageRows) {
@@ -420,7 +451,7 @@ export class ProgressManager {
420
451
  if (stageIds.length > 0) {
421
452
  const placeholders = stageIds.map(() => '?').join(',');
422
453
  const stepRows = sqlDb.prepare(
423
- `SELECT stage_id, name, status, output, completed_at, ordering, wait_reason, wait_options, wait_answer, waited_at, wait_answers, wait_round, max_wait_rounds FROM steps WHERE stage_id IN (${placeholders}) ORDER BY stage_id, ordering`
454
+ `SELECT stage_id, name, status, output, completed_at, ordering, wait_reason, wait_options, wait_answer, waited_at, wait_answers, wait_round, max_wait_rounds FROM steps WHERE stage_id IN (${placeholders}) AND (authority = 'cli' OR authority IS NULL) ORDER BY stage_id, ordering`
424
455
  ).all(...stageIds);
425
456
  // 按阶段分组步骤
426
457
  for (const row of stepRows) {
@@ -490,7 +521,9 @@ export class ProgressManager {
490
521
  }
491
522
 
492
523
  const result = {
493
- _version: 6,
524
+ // 版本单一源(2026-09-27-change-birth-stage-brainstorm 收敛):此前是字面量 6,
525
+ // v7 bump(watcher-preview-progress)漏改此处成「四处一致」之外的第五处漂移。
526
+ _version: CURRENT_VERSION,
494
527
  project: projectName,
495
528
  currentChange: cName,
496
529
  currentStage: currentStage || '',
@@ -572,7 +605,14 @@ export class ProgressManager {
572
605
 
573
606
  // 3. stages(该 change 下,用 change_name+stage 表达外键)
574
607
  const stageRows = sqlDb.prepare(
575
- `SELECT id, stage, status, started_at, completed_at, revision, reopened_from_step, reopened_at, stale_reason
608
+ // 权威视图渲染(D-007 钉,2026-09-23-watcher-preview-progress task-05):被 watcher 认领的行
609
+ // 在平台载荷里按其 CLI 已知态(pending/未起止)投影——载荷与无预览的干净态逐字节一致,
610
+ // 不因预览认领缺行(隔离钉 T1 实证:纯过滤会整行剔除该阶段)。
611
+ `SELECT id, stage,
612
+ CASE WHEN authority = 'watcher' THEN 'pending' ELSE status END AS status,
613
+ CASE WHEN authority = 'watcher' THEN NULL ELSE started_at END AS started_at,
614
+ CASE WHEN authority = 'watcher' THEN NULL ELSE completed_at END AS completed_at,
615
+ revision, reopened_from_step, reopened_at, stale_reason
576
616
  FROM stages WHERE change_id = ? ORDER BY id`
577
617
  ).all(changeId);
578
618
  const stageJson = stageRows.map(r => ({
@@ -596,7 +636,7 @@ export class ProgressManager {
596
636
  const stepRows = sqlDb.prepare(
597
637
  `SELECT stage_id, name, status, output, completed_at, ordering, wait_reason, wait_options,
598
638
  wait_answer, waited_at, wait_answers, wait_round, max_wait_rounds
599
- FROM steps WHERE stage_id IN (${placeholders}) ORDER BY stage_id, ordering`
639
+ FROM steps WHERE stage_id IN (${placeholders}) AND (authority = 'cli' OR authority IS NULL) ORDER BY stage_id, ordering`
600
640
  ).all(...stageIds);
601
641
  stepJson = stepRows.map(r => ({
602
642
  change_name: changeRow.name,
@@ -921,8 +961,8 @@ export class ProgressManager {
921
961
  for (const [stageName, stageData] of Object.entries(data.stages)) {
922
962
  // UPSERT stages 行(含 revision 列)
923
963
  sqlDb.prepare(
924
- `INSERT INTO stages (change_id, stage, status, started_at, completed_at, revision, reopened_from_step, reopened_at, stale_reason)
925
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
964
+ `INSERT INTO stages (change_id, stage, status, started_at, completed_at, revision, reopened_from_step, reopened_at, stale_reason, authority)
965
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'cli')
926
966
  ON CONFLICT(change_id, stage) DO UPDATE SET
927
967
  status = excluded.status,
928
968
  started_at = excluded.started_at,
@@ -930,7 +970,8 @@ export class ProgressManager {
930
970
  revision = COALESCE(excluded.revision, stages.revision),
931
971
  reopened_from_step = excluded.reopened_from_step,
932
972
  reopened_at = excluded.reopened_at,
933
- stale_reason = excluded.stale_reason`
973
+ stale_reason = excluded.stale_reason,
974
+ authority = 'cli'`
934
975
  ).run(changeId, stageName, stageData.status || 'pending', stageData.startedAt || null, stageData.completedAt || null,
935
976
  stageData.revision || 0, stageData.reopenedFromStep || null, stageData.reopenedAt || null, stageData.staleReason || null);
936
977
 
@@ -1125,10 +1166,12 @@ export class ProgressManager {
1125
1166
  // 检查变更是否已存在
1126
1167
  const existing = sqlDb.prepare('SELECT id FROM changes WHERE name = ?').get(changeName);
1127
1168
  if (existing === undefined) {
1128
- // 插入 changes 行
1169
+ // 插入 changes 行。出生阶段=brainstorm(2026-09-27 用户裁定「起步就是头脑风暴」):
1170
+ // scan 是 auxiliary(shared.js AUXILIARY_STAGES 语义),从未是变更主流程起点;
1171
+ // 旧默认 'scan' 让 thin/quick 等不进主流程的变更全生命周期显示「🔍 代码扫描」误导。
1129
1172
  sqlDb.prepare(
1130
1173
  `INSERT INTO changes (name, current_stage, status, created_at, last_active, title, quicklog_id)
1131
- VALUES (?, 'scan', 'active', ?, ?, ?, ?)`
1174
+ VALUES (?, 'brainstorm', 'active', ?, ?, ?, ?)`
1132
1175
  ).run(changeName, now, now, meta.title || null, meta.quicklogId || null);
1133
1176
  }
1134
1177
 
@@ -1203,7 +1246,7 @@ export class ProgressManager {
1203
1246
  const sqlDb = db.getDb();
1204
1247
  const now = new Date().toISOString();
1205
1248
  const ins = sqlDb.prepare(
1206
- `INSERT OR IGNORE INTO changes (name, current_stage, status, created_at, last_active) VALUES (?, 'scan', 'active', ?, ?)`
1249
+ `INSERT OR IGNORE INTO changes (name, current_stage, status, created_at, last_active) VALUES (?, 'brainstorm', 'active', ?, ?)`
1207
1250
  ).run(changeName, now, now);
1208
1251
  const changeRow = sqlDb.prepare('SELECT id FROM changes WHERE name = ?').get(changeName);
1209
1252
  if (changeRow !== undefined) {
@@ -1468,7 +1511,7 @@ export class ProgressManager {
1468
1511
 
1469
1512
  // 3. stages
1470
1513
  const stageRows = sqlDb.prepare(
1471
- 'SELECT id, stage, status, started_at, completed_at, revision FROM stages WHERE change_id = ? ORDER BY id'
1514
+ `SELECT id, stage, status, started_at, completed_at, revision FROM stages WHERE change_id = ? AND (authority = 'cli' OR authority IS NULL) ORDER BY id`
1472
1515
  ).all(changeId);
1473
1516
 
1474
1517
  const stages = {};
@@ -1481,7 +1524,7 @@ export class ProgressManager {
1481
1524
  if (stageRows.length > 0) {
1482
1525
  const placeholders = stageRows.map(() => '?').join(', ');
1483
1526
  const allStepRows = sqlDb.prepare(
1484
- `SELECT stage_id, name, status, output, completed_at FROM steps WHERE stage_id IN (${placeholders}) ORDER BY stage_id, ordering`
1527
+ `SELECT stage_id, name, status, output, completed_at FROM steps WHERE stage_id IN (${placeholders}) AND (authority = 'cli' OR authority IS NULL) ORDER BY stage_id, ordering`
1485
1528
  ).all(...stageRows.map(r => r.id));
1486
1529
  for (const sr of allStepRows) {
1487
1530
  if (!stepsByStage.has(sr.stage_id)) stepsByStage.set(sr.stage_id, []);