sillyspec 3.28.3 → 3.28.4

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 (56) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +54 -54
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
  3. package/.claude/skills/sillyspec-continue/SKILL.md +17 -17
  4. package/.claude/skills/sillyspec-execute/SKILL.md +222 -222
  5. package/.claude/skills/sillyspec-knowledge/SKILL.md +40 -40
  6. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  7. package/.claude/skills/sillyspec-propose/SKILL.md +16 -16
  8. package/.claude/skills/sillyspec-resume/SKILL.md +21 -21
  9. package/.claude/skills/sillyspec-state/SKILL.md +25 -25
  10. package/.claude/skills/sillyspec-workspace/SKILL.md +33 -33
  11. package/AGENTS.md +24 -0
  12. package/INSTRUCTIONS.md +12 -0
  13. package/package.json +1 -1
  14. package/src/archive-delta.js +82 -0
  15. package/src/change-delete.js +2 -1
  16. package/src/change-list.js +7 -2
  17. package/src/change-risk-profile.js +77 -6
  18. package/src/config-cat.js +12 -1
  19. package/src/constants.js +3 -1
  20. package/src/docs-check.js +34 -0
  21. package/src/doctor-diagnostics.js +441 -5
  22. package/src/foreign-declared.js +53 -2
  23. package/src/index.js +181 -6
  24. package/src/module-impact.js +77 -12
  25. package/src/plan-adopt-waves.js +13 -5
  26. package/src/progress/change-registry.js +16 -0
  27. package/src/progress.js +62 -1
  28. package/src/quick-recommend.js +6 -1
  29. package/src/quicklog.js +10 -2
  30. package/src/run/command.js +30 -9
  31. package/src/run/complete-handlers.js +152 -6
  32. package/src/run/complete.js +45 -0
  33. package/src/run/gates.js +94 -6
  34. package/src/run/prompt.js +96 -5
  35. package/src/run/shared.js +74 -6
  36. package/src/run/stage.js +35 -0
  37. package/src/runtime-hygiene.js +84 -0
  38. package/src/spec-sync.js +63 -8
  39. package/src/stage-contract.js +83 -3
  40. package/src/stage-review.js +3 -0
  41. package/src/stages/archive.js +4 -4
  42. package/src/stages/brainstorm.js +2 -8
  43. package/src/stages/doctor.js +41 -515
  44. package/src/stages/plan-postcheck.js +191 -7
  45. package/src/stages/plan.js +5 -26
  46. package/src/stages/quick.js +11 -11
  47. package/src/stages/verify.js +10 -6
  48. package/src/sync-noise.js +158 -0
  49. package/src/sync.js +42 -17
  50. package/src/task-review.js +25 -1
  51. package/src/verify-facts-schema.js +141 -0
  52. package/src/verify-postcheck.js +257 -19
  53. package/src/verify-probes.js +142 -13
  54. package/src/workflow.js +6 -0
  55. package/src/worktree-apply.js +51 -13
  56. package/src/worktree.js +5 -1
package/src/index.js CHANGED
@@ -98,11 +98,13 @@ SillySpec CLI — 规范驱动开发工具包
98
98
  单 task review.json 命令式写入:verdict/notes 你给,base/head/changedFiles/diffPaths CLI 代算
99
99
  sillyspec symbol-impact --change <name> 生成 symbol-impact.md 逐 task <!--TODO--> 骨架(gate 拒绝未替换占位,防骨架直接过门)
100
100
  sillyspec design-init --change <name> [--force] 从 decisions.md 生成 design.md 十三章节骨架(决策追踪表预填;已存在不覆盖)
101
+ sillyspec fourpiece-init --change <name> 生成 proposal/requirements/decisions 骨架(frontmatter+章节+模板占位;已存在不覆盖)
101
102
  sillyspec delta --change <name> [--json] 生成变更 delta.md(Before/Delta/After 三段式聚合;幂等覆盖重跑即刷新)
102
103
  sillyspec next 项目状态探测:输出当前状态 + 下一步命令 + 依据(吸收 continue/resume 手工探测表)
103
104
  sillyspec commit [--json] 智能提交建议:收集 QUICKLOG/已勾 task/阶段产出语义,生成建议 message(只建议不执行)
104
105
  sillyspec verify-probes --change <name> [--init] verify 机械探针(TODO 标记/测试覆盖/API 对账/删除对账);--init 生成 verify-result.md 骨架
105
106
  sillyspec module-impact --change <name> 生成 module-impact.md 骨架(文件×模块归属按 module-map 预填 + 未匹配清单)
107
+ sillyspec module-docs-sync --change <name> [--note ...] diff 归属模块 → sidecar 追加变更索引行 + 卡 updated_at 戳(幂等)
106
108
  sillyspec endpoints extract --change <name> [--task task-NN | --all-tasks] [--dir <dir>|--files <a.py,b.js>] 静态扫描路由装饰器生成 endpoints.json
107
109
  sillyspec endpoints baseline --change <name> [--spec-dir <path>] [--json] 拍变更前端点基线(幂等不覆盖;worktree 内跑自动锚主仓;归档 delta 端点增删 before 侧)
108
110
  sillyspec scan-fix-headers [--project <名>] scan 文档补 author/created_at header(幂等)
@@ -117,6 +119,7 @@ SillySpec CLI — 规范驱动开发工具包
117
119
  (无子命令) 跑诊断,列出问题
118
120
  --align-execute-progress [--change <名>] [--confirm] 基于 plan.md 对齐 execute 派生戳(默认 dry-run)
119
121
  --cleanup-remnant [--confirm] 删除 0 字节空占位 db(默认 dry-run,仅 --confirm 落盘)
122
+ --gc-unstamped-runs [--confirm] 回收已归档变更留下的无 change 戳 execute-runs(默认 dry-run)
120
123
  --dump-db --path <db 路径> dump 指定 db 内容到文件(取证用)
121
124
  --json 结构化诊断 + 落盘 .sillyspec/.runtime/doctor-diagnosis.json
122
125
  sillyspec modules <rebuild | status | migrate>
@@ -942,8 +945,51 @@ async function main() {
942
945
  writeFileSync(vpReportPath, generateVerifyResultSkeleton(vpResult));
943
946
  console.log(`\n📄 已生成 verify-result.md 骨架: ${vpReportPath}(探针已预填;结论必须写明 PASS/FAIL,留待填会被 gate 判不过)`);
944
947
  }
945
- writeVerifyFacts(join(vpSpecBase, 'changes', vpChange), vpResult, vpChange); // P3b:facts 底稿随 --init 刷新(骨架已存在也刷新,最近快照语义)
948
+ writeVerifyFacts(join(vpSpecBase, 'changes', vpChange), vpResult, vpChange); // P3b:facts 底稿随 --init 刷新(骨架已存在也刷新;v2 起分段合并保留固化段)
949
+ // 2026-09-08-ir-verify-facts R-02:已存在 md 缺证据账/回执槽段时仅追加骨架(幂等二跑
950
+ // 零改动);task 行按 verify-required-evidence.json 预填(存在时)。
951
+ if (existsSync(vpReportPath)) {
952
+ let vpReqItems = []
953
+ try {
954
+ const vpReqPath = join(vpSpecBase, 'changes', vpChange, 'verify-required-evidence.json')
955
+ if (existsSync(vpReqPath)) {
956
+ const vpReq = JSON.parse(readFileSync(vpReqPath, 'utf8'))
957
+ vpReqItems = Array.isArray(vpReq.items) ? vpReq.items.map(it => ({ task: it.task, evidence: it.evidence || [] })) : []
958
+ }
959
+ } catch { /* 读取失败 → 空预填,不阻断 */ }
960
+ const { backfillMissingEvidenceSlots } = await import('./verify-probes.js')
961
+ const vpAdded = backfillMissingEvidenceSlots(vpReportPath, vpReqItems)
962
+ if (vpAdded.added.length > 0) {
963
+ console.log(`📄 已补齐缺失槽段(不触碰既有正文): ${vpAdded.added.join('、')}`)
964
+ }
965
+ }
966
+ }
967
+ break;
968
+ }
969
+ case 'module-docs-sync': {
970
+ // 模块文档 sidecar 同步命令化(2026-09-09 刀批):diff 归属模块 → sidecar 追加变更索引行 +
971
+ // 卡 updated_at 戳——机械部分 CLI 代算,卡片正文语义更新仍归 agent/归档流程。幂等。
972
+ const mdsChangeIdx = args.indexOf('--change');
973
+ const mdsChange = mdsChangeIdx >= 0 && args[mdsChangeIdx + 1] ? args[mdsChangeIdx + 1] : null;
974
+ const mdsNoteIdx = args.indexOf('--note');
975
+ const mdsNote = mdsNoteIdx >= 0 && args[mdsNoteIdx + 1] ? args[mdsNoteIdx + 1] : null;
976
+ if (!mdsChange) {
977
+ console.error('用法: sillyspec module-docs-sync --change <name> [--note "一句话"] [--json]\n diff 归属模块 → sidecar 追加变更索引行 + 卡 updated_at 戳(幂等;卡片正文语义更新仍归 agent)');
978
+ process.exit(2);
979
+ }
980
+ const { syncModuleDocSidecars } = await import('./module-impact.js');
981
+ const mdsResult = syncModuleDocSidecars({ cwd: dir, changeName: mdsChange, note: mdsNote, specDir });
982
+ if (json) {
983
+ console.log(JSON.stringify({ command: 'module-docs-sync', change: mdsChange, ...mdsResult }, null, 2));
984
+ break;
985
+ }
986
+ if (mdsResult.reason) console.log(mdsResult.reason);
987
+ if (mdsResult.synced.length > 0) {
988
+ console.log(String.fromCharCode(9989) + ' sidecar 已同步 ' + mdsResult.synced.length + ' 个模块: ' + mdsResult.synced.join(', '));
989
+ console.log(' 卡 updated_at 已戳;卡片正文语义更新(如需要)仍归归档流程/agent。');
946
990
  }
991
+ if (mdsResult.skipped.length > 0) console.log(String.fromCharCode(8505) + ' 已含本变更行跳过: ' + mdsResult.skipped.join(', '));
992
+ if (mdsResult.unmatched.length > 0) console.log(String.fromCharCode(9888) + ' 未匹配模块的文件 ' + mdsResult.unmatched.length + ' 个(不入 sidecar): ' + mdsResult.unmatched.slice(0, 5).join(', '));
947
993
  break;
948
994
  }
949
995
  case 'plan-adopt-waves': {
@@ -1313,6 +1359,94 @@ async function main() {
1313
1359
  console.log(' 逐行替换 <!--TODO--> 为结论(无签名级变更也显式写「无」);gate 拒绝未替换的占位行。');
1314
1360
  break;
1315
1361
  }
1362
+ case 'fourpiece-init': {
1363
+ // 四件套骨架预生成(2026-09-09 ql-20260909-003,轮次经济学 §3.2):brainstorm 期 agent
1364
+ // 手写 proposal/requirements/decisions 的 frontmatter/章节骨架是格式返工根源——本命令
1365
+ // 逐文件生成骨架(已存在不覆盖),agent 只填语义。与 design-init 同族(幂等)。
1366
+ const fpChangeIdx = args.indexOf('--change');
1367
+ const fpChange = fpChangeIdx >= 0 && args[fpChangeIdx + 1] ? args[fpChangeIdx + 1] : null;
1368
+ if (!fpChange) {
1369
+ console.error('用法: sillyspec fourpiece-init --change <name> [--json] [--spec-dir <path>]\n 生成 proposal.md/requirements.md/decisions.md 骨架(frontmatter+章节标题+模板占位;已存在不覆盖;design.md 归 design-init)');
1370
+ process.exit(2);
1371
+ }
1372
+ const fpSpecBase = resolvePlatformSpecDir(dir, specDir) || join(dir, '.sillyspec');
1373
+ const fpDir = join(fpSpecBase, 'changes', fpChange);
1374
+ mkdirSync(fpDir, { recursive: true });
1375
+ const fpNow = new Date();
1376
+ const fpDate = fpNow.toISOString().slice(0, 10);
1377
+ const fpStamp = fpNow.toISOString().slice(0, 19).replace('T', ' ');
1378
+ const generated = [];
1379
+ const mk = (fname, body) => {
1380
+ const p = join(fpDir, fname);
1381
+ if (existsSync(p)) { console.log(`ℹ️ ${fname} 已存在,不覆盖`); return; }
1382
+ writeFileSync(p, body);
1383
+ generated.push(fname);
1384
+ console.log(`✅ 已生成 ${fname} 骨架: ${p}`);
1385
+ };
1386
+ mk('proposal.md', `---
1387
+ author: qinyi
1388
+ created_at: ${fpStamp}
1389
+ ---
1390
+ # 提案书(Proposal)
1391
+
1392
+ ## 动机
1393
+ <!--TODO: 为什么做、解决什么核心问题-->
1394
+
1395
+ ## 关键问题
1396
+ <!--TODO: 为什么现有方案不够(2-3 个痛点)-->
1397
+
1398
+ ## 变更范围
1399
+ <!--TODO: 本次做什么-->
1400
+
1401
+ ## 不在范围内(显式清单)
1402
+ - <!--TODO: 不做 X-->
1403
+
1404
+ ## 成功标准(可验证)
1405
+ - <!--TODO: 可验证条目-->
1406
+ `);
1407
+ mk('requirements.md', `---
1408
+ author: qinyi
1409
+ created_at: ${fpStamp}
1410
+ ---
1411
+ # 需求规格(Requirements)
1412
+
1413
+ ## 角色
1414
+ | 角色 | 说明 |
1415
+ |---|---|
1416
+ | <!--TODO--> | <!--TODO--> |
1417
+
1418
+ ## 功能需求
1419
+
1420
+ ### FR-01: <!--TODO-->
1421
+ Given <!--TODO-->
1422
+ When <!--TODO-->
1423
+ Then <!--TODO-->
1424
+
1425
+ ## 非功能需求
1426
+ - 兼容性:<!--TODO-->
1427
+
1428
+ ## 决策覆盖矩阵(如存在 decisions.md)
1429
+ | 决策 ID | 覆盖的 FR | 说明 |
1430
+ |---|---|---|
1431
+ `);
1432
+ mk('decisions.md', `---
1433
+ author: qinyi
1434
+ created_at: ${fpStamp}
1435
+ change: ${fpChange}
1436
+ ---
1437
+
1438
+ # 决策记录(Decisions)
1439
+
1440
+ <!-- 增量落盘:每解决一个有实现影响的问题当场追加一条(格式见 brainstorm Step 3 模板);幂等按 D-xxx@vN 判重 -->
1441
+ `);
1442
+ if (json) {
1443
+ console.log(JSON.stringify({ command: 'fourpiece-init', change: fpChange, generated }, null, 2));
1444
+ } else if (generated.length > 0) {
1445
+ console.log(`
1446
+ ${generated.length} 个骨架已就绪——逐节把 <!--TODO--> 替换为语义内容(骨架勿手删章节);design.md 用 sillyspec design-init。`);
1447
+ }
1448
+ break;
1449
+ }
1316
1450
  case 'design-init': {
1317
1451
  // design.md 十三章节骨架生成(change: 2026-09-07-ir-stage-p3c,task-02,FR-03):agent 此前
1318
1452
  // 从零手写整份设计文档,章节漏项到 stage-contract/plan 门禁才暴露。本命令从 decisions.md
@@ -1810,9 +1944,10 @@ async function main() {
1810
1944
  // slice(1) 去掉的是 'run' 字面量,这里 command 本身就是 stage 名不能丢。
1811
1945
  case 'doctor': {
1812
1946
  const doctorEffectiveDir = specDir ? dir : resolveEffectiveDir(dir);
1813
- // 执行流:--cleanup-remnant / --cleanup-ghosts / --dump-db(结构化诊断之外的修复/取证动作)
1947
+ // 执行流:--cleanup-remnant / --cleanup-ghosts / --gc-unstamped-runs / --dump-db(结构化诊断之外的修复/取证动作)
1814
1948
  const cleanupRemnant = filteredArgs.includes('--cleanup-remnant');
1815
1949
  const cleanupGhosts = filteredArgs.includes('--cleanup-ghosts');
1950
+ const gcUnstampedRuns = filteredArgs.includes('--gc-unstamped-runs');
1816
1951
  const dumpDbFlag = filteredArgs.includes('--dump-db');
1817
1952
  const doctorConfirm = filteredArgs.includes('--confirm');
1818
1953
  const pathIdx = filteredArgs.indexOf('--path');
@@ -1918,6 +2053,29 @@ async function main() {
1918
2053
  process.exitCode = r.errors.length > 0 ? 1 : 0;
1919
2054
  break;
1920
2055
  }
2056
+ if (gcUnstampedRuns) {
2057
+ // ql-20260908-006-5f04:无戳 execute-runs 一次性清扫。默认 dry-run,--confirm 才删。
2058
+ // 不进 archive 热路径(pruneArchivedChangeRuntime 故意不猜无戳 run)。
2059
+ const { gcUnstampedExecuteRuns } = await import('./doctor-diagnostics.js');
2060
+ const r = await gcUnstampedExecuteRuns({ cwd: doctorEffectiveDir, specDir, confirm: doctorConfirm });
2061
+ if (json) {
2062
+ console.log(JSON.stringify(r, null, 2));
2063
+ } else {
2064
+ const verb = doctorConfirm ? '已删除' : '待删除(dry-run)';
2065
+ console.log(`🧹 无戳 execute-runs(仅已归档变更、交叉核验通过)${verb}:${r.count} 个 [runtime: ${r.runtime_root || '(无)'}]`);
2066
+ for (const c of r.candidates || []) {
2067
+ console.log(` ${doctorConfirm ? '✅' : '-'} ${c.runId} ← ${c.owner}(via ${c.via})`);
2068
+ }
2069
+ for (const s of r.skipped || []) console.log(` ⏭️ ${s.runId}(${s.reason})`);
2070
+ for (const e of r.errors || []) console.log(` ❌ ${e.runId || ''}: ${e.error}`);
2071
+ if (!doctorConfirm && r.count > 0) {
2072
+ console.log(`\n加 --confirm 执行删除(只删上面列出的无戳 run,有 change 戳 / 命中活跃变更 / 归属不明一律不动)。`);
2073
+ }
2074
+ if (r.reason) console.log(`ℹ️ ${r.reason}`);
2075
+ }
2076
+ process.exitCode = (r.errors && r.errors.length > 0) ? 1 : 0;
2077
+ break;
2078
+ }
1921
2079
  if (dumpDbFlag) {
1922
2080
  if (!dbPath) { console.error('❌ --dump-db 需要 --path <db 路径>'); process.exitCode = 2; break; }
1923
2081
  const { dumpDb } = await import('./doctor-diagnostics.js');
@@ -1944,9 +2102,21 @@ async function main() {
1944
2102
  process.exitCode = output.overall_status === 'pass' ? 0 : 1;
1945
2103
  break;
1946
2104
  }
1947
- // 否则:保持原有 prompt 驱动的 bash 自检流程
1948
- const { runCommand } = await import('./run.js');
1949
- await runCommand([command, ...filteredArgs.slice(1)], doctorEffectiveDir, specDir, { json });
2105
+ // 2026-09-09-doctor-noai:--status 拦截(审查 B-03:等价只读渲染——阶段进度视图,
2106
+ // 零副作用);非 --json 其余形态改道直跑诊断 + renderDoctorSummary(不走
2107
+ // runCommand/initChange/不刷 lastActive——只读零副作用承诺保真)。
2108
+ if (filteredArgs.includes('--status')) {
2109
+ // 等价只读渲染:复用 progress show(cwd 无活跃变更时提示无——与原只读短路视图同语义级)
2110
+ const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(dir, specDir) });
2111
+ pm.show(doctorEffectiveDir);
2112
+ break;
2113
+ }
2114
+ {
2115
+ const { runDoctorDiagnostics, renderDoctorSummary, writeDoctorDiagnosis } = await import('./doctor-diagnostics.js')
2116
+ const diag = await runDoctorDiagnostics({ cwd: doctorEffectiveDir })
2117
+ console.log(renderDoctorSummary(diag))
2118
+ try { writeDoctorDiagnosis(diag, resolvePlatformSpecDir(dir, specDir) || join(dir, '.sillyspec')) } catch { /* fail-soft */ }
2119
+ }
1950
2120
  break;
1951
2121
  }
1952
2122
  case 'scan': {
@@ -2920,7 +3090,12 @@ checkbox 行;depends_on 自动反填行内注解 "(depends_on: task-01,02)";
2920
3090
 
2921
3091
  const { cmdTaskcard } = await import('./taskcard.js');
2922
3092
  try {
2923
- const result = cmdTaskcard(tcName, { cwd: dir, specDir, taskIds, title: titleVal, titleZh: titleZhVal, force, sets });
3093
+ // 坑 platform-sync-progress-rollback-and-db-corruption 坑4:taskcard 此前只走
3094
+ // resolveSpecDir(祖先遍历本地 .sillyspec),不感知平台接管指针——平台模式下
3095
+ // 仓库根裸跑报「变更目录不存在」,需显式 --spec-dir(与 run/plan 系列不一致)。
3096
+ // 对齐 endpoints 等命令:先 resolvePlatformSpecDir(指针 fail-closed 语义同源)。
3097
+ const tcPlatformSpecDir = resolvePlatformSpecDir(dir, specDir) || specDir;
3098
+ const result = cmdTaskcard(tcName, { cwd: dir, specDir: tcPlatformSpecDir, taskIds, title: titleVal, titleZh: titleZhVal, force, sets });
2924
3099
  for (const f of result.created) console.log(`✅ 已生成: ${f}`);
2925
3100
  for (const f of result.skipped) console.log(`⏭️ 已存在,跳过(--force 覆盖): ${f}`);
2926
3101
  if (result.created.length > 0) {
@@ -10,8 +10,10 @@
10
10
  * diff 源:resolveVerifyChangedFiles(worktree-aware,与 task-review 同源);
11
11
  * module-map:.sillyspec/docs/<p>/modules/_module-map.yaml 的 modules.<id>.paths
12
12
  * (目录条目以 / 结尾按前缀匹配,与模块上下文索引读侧口径一致)。
13
+ * 声明源(plan --done 首版,2026-09-08 刀②):design 文件变更清单——代码未写、无 diff,
14
+ * 归属分类同一套 module-map 前缀匹配,仅输入来源不同。
13
15
  */
14
- import { existsSync, readFileSync, readdirSync } from 'fs'
16
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs'
15
17
  import { join } from 'path'
16
18
  import { resolveVerifyChangedFiles } from './verify-postcheck.js'
17
19
  import { resolveSpecDir } from './run/shared.js'
@@ -68,11 +70,67 @@ function classifyFile(posixPath, modulePaths) {
68
70
 
69
71
  /**
70
72
  * 生成 module-impact.md 骨架。
71
- * @param {{ cwd: string, changeName: string, specDir?: string|null }} opts
73
+ * @param {{ cwd: string, changeName: string, specDir?: string|null, sourceFiles?: string[]|null, origin?: string|null }} opts
74
+ * sourceFiles:声明来源(plan --done 首版用 design 文件变更清单——此时代码未写、无 diff 可取);
75
+ * 缺省走 diff 来源(resolveVerifyChangedFiles,archive/execute 后的真实改动)。
76
+ * origin:骨架首行的生成方说明(区分 plan 首版与 CLI 命令生成,追溯来源用)。
72
77
  * @returns {{ markdown: string, matchedCount: number, unmatchedCount: number }|null}
73
- * 无 module-map / 无 diff → null(无可归类输入,agent 全手写)
78
+ * 无 module-map / 无可归类文件 → null(无可归类输入,agent 全手写)
74
79
  */
75
- export function generateModuleImpactSkeleton({ cwd, changeName, specDir = null }) {
80
+ /**
81
+ * 模块文档 sidecar 同步命令化(2026-09-09 刀批,轮次经济学 §3.1 模块文档同步项):
82
+ * quick/archive 两处 prompt 让 agent 手写 sidecar 追加行——机械部分(变更归属模块、
83
+ * sidecar 追加、卡 updated_at 戳)CLI 代算,agent 只留卡片正文语义更新。
84
+ * 幂等:sidecar 已含 `- <change> |` 行则跳过该模块;卡不存在只建 sidecar 不建卡(卡内容是语义)。
85
+ * @returns {{ synced: string[], unmatched: string[], skipped: string[] }}
86
+ */
87
+ export function syncModuleDocSidecars({ cwd, changeName, note, specDir = null }) {
88
+ const specBase = resolveSpecDir(cwd, { specDir })
89
+ // 复用 generateModuleImpactSkeleton 的 map 发现 + 归类(diff 来源)
90
+ const skel = generateModuleImpactSkeleton({ cwd, changeName, specDir })
91
+ if (!skel) return { synced: [], unmatched: [], skipped: [], reason: '无 module-map 或无 diff 可归类' }
92
+ const modulesDir = findModulesDir(specBase)
93
+ if (!modulesDir) return { synced: [], unmatched: [], skipped: [], reason: 'modules 目录不存在' }
94
+ // 从骨架 markdown 反解归类结果(命中模块集合 + 未匹配清单)
95
+ const hitModules = [...skel.markdown.matchAll(/^\| (\S+) \| `([^`]+)`/gm)].map(m => m[1])
96
+ const unmatched = [...skel.markdown.matchAll(/^- `([^`]+)` /gm)].map(m => m[1])
97
+ const synced = []
98
+ const skipped = []
99
+ for (const mod of [...new Set(hitModules)]) {
100
+ const sidecarPath = join(modulesDir, `${mod}.changelog.md`)
101
+ let sidecar = ''
102
+ try { sidecar = readFileSync(sidecarPath, 'utf8') } catch { sidecar = '' }
103
+ if (sidecar.includes(`- ${changeName} |`)) { skipped.push(mod); continue }
104
+ const NL = String.fromCharCode(10)
105
+ const line = `- ${changeName} | ${note || '(见该变更 quicklog/归档)'}`
106
+ const base = sidecar
107
+ ? sidecar.replace(/\n*$/, NL)
108
+ : `# ${mod} 变更索引` + NL + NL
109
+ writeFileSync(sidecarPath, base + line + NL)
110
+ // 卡 updated_at 戳(存在才戳)
111
+ const cardPath = join(modulesDir, `${mod}.md`)
112
+ try {
113
+ const card = readFileSync(cardPath, 'utf8')
114
+ const stamped = card.replace(/^updated_at:.*$/m, `updated_at: ${new Date().toISOString().slice(0, 19) + '+08:00'}`)
115
+ if (stamped !== card) writeFileSync(cardPath, stamped)
116
+ } catch { /* 卡不存在——sidecar 照写,卡内容留给 agent */ }
117
+ synced.push(mod)
118
+ }
119
+ return { synced, unmatched, skipped }
120
+ }
121
+
122
+ function findModulesDir(specBase) {
123
+ try {
124
+ for (const p of readdirSync(join(specBase, 'docs'), { withFileTypes: true })) {
125
+ if (!p.isDirectory()) continue
126
+ const cand = join(specBase, 'docs', p.name, 'modules')
127
+ if (existsSync(join(cand, '_module-map.yaml'))) return cand
128
+ }
129
+ } catch { /* 无 docs/ */ }
130
+ return null
131
+ }
132
+
133
+ export function generateModuleImpactSkeleton({ cwd, changeName, specDir = null, sourceFiles = null, origin = null }) {
76
134
  const specBase = resolveSpecDir(cwd, { specDir })
77
135
 
78
136
  // 找 _module-map.yaml(docs/<p>/modules/;多项目取首个含 modules 索引的)
@@ -92,15 +150,20 @@ export function generateModuleImpactSkeleton({ cwd, changeName, specDir = null }
92
150
  const modulePaths = parseModuleMapPaths(readFileSync(moduleMapPath, 'utf8'))
93
151
  if (modulePaths.size === 0) return null
94
152
 
95
- // includeWorkingTree 同 verify module 子集(坑 module-subset-zero-hit-uncommitted):
96
- // worktree 未提交改动也计入模块影响面(module-impact 按实际 diff 归属模块)
97
- const diffFiles = resolveVerifyChangedFiles(cwd, changeName, null, { includeWorkingTree: true, specBase }) || []
98
- const sourceFiles = diffFiles.map(f => f.split('\\').join('/')).filter(f => !f.startsWith('.sillyspec/'))
99
- if (sourceFiles.length === 0) return null
153
+ let sourceFileList
154
+ if (sourceFiles) {
155
+ sourceFileList = sourceFiles.map(f => f.split('\\').join('/')).filter(f => !f.startsWith('.sillyspec/'))
156
+ } else {
157
+ // includeWorkingTree 同 verify module 子集(坑 module-subset-zero-hit-uncommitted):
158
+ // worktree 未提交改动也计入模块影响面(module-impact 按实际 diff 归属模块)
159
+ const diffFiles = resolveVerifyChangedFiles(cwd, changeName, null, { includeWorkingTree: true, specBase }) || []
160
+ sourceFileList = diffFiles.map(f => f.split('\\').join('/')).filter(f => !f.startsWith('.sillyspec/'))
161
+ }
162
+ if (sourceFileList.length === 0) return null
100
163
 
101
164
  const byModule = new Map()
102
165
  const unmatched = []
103
- for (const f of sourceFiles) {
166
+ for (const f of sourceFileList) {
104
167
  const mod = classifyFile(f, modulePaths)
105
168
  if (mod) {
106
169
  if (!byModule.has(mod)) byModule.set(mod, [])
@@ -111,7 +174,9 @@ export function generateModuleImpactSkeleton({ cwd, changeName, specDir = null }
111
174
  }
112
175
 
113
176
  const L = [
114
- '# 模块影响分析(骨架由 `sillyspec module-impact --change <变更名>` 生成)',
177
+ origin
178
+ ? `# 模块影响分析(骨架由 ${origin} 生成)`
179
+ : '# 模块影响分析(骨架由 `sillyspec module-impact --change <变更名>` 生成)',
115
180
  '',
116
181
  '> 文件×模块归属由 CLI 按 _module-map.yaml paths 前缀匹配预填;',
117
182
  '> **影响类型**(逻辑变更/数据结构变更/接口变更/调用关系变更/配置变更/新增)与 review 标记是语义判断,',
@@ -162,5 +227,5 @@ export function generateModuleImpactSkeleton({ cwd, changeName, specDir = null }
162
227
  L.push('规则:execute/verify 完成文档同步后把对应行回填 done;确定不同步的行改 skipped 并在操作列写明原因。')
163
228
  L.push('')
164
229
 
165
- return { markdown: L.join('\n'), matchedCount: sourceFiles.length - unmatched.length, unmatchedCount: unmatched.length }
230
+ return { markdown: L.join('\n'), matchedCount: sourceFileList.length - unmatched.length, unmatchedCount: unmatched.length }
166
231
  }
@@ -60,12 +60,17 @@ function updateSummaryWaveColumn(lines, waveOf) {
60
60
  }
61
61
 
62
62
  /**
63
- * @param {{ changeDir: string, dryRun?: boolean }} opts
63
+ * @param {{ changeDir: string, dryRun?: boolean, mode?: 'write'|'proposal' }} opts
64
+ * mode='proposal'(2026-09-09-plan-derived):只读产拓扑布局提案——渲染 Wave 段草稿与
65
+ * W 列同步结果但不落盘,供 postcheck「提案-验证-落盘」自动修复(FR-01)验证后决断写否。
64
66
  * @returns {{ ok: boolean, error?: string, waves?: string[][], planPath: string, dryRun: boolean,
65
67
  * waveBlock?: string[], tableRowsUpdated?: number, tableRowsSkipped?: number,
68
+ * planMdDraft?: string, rewritten?: boolean,
66
69
  * postcheck?: { errors: string[], warnings: string[] } }}
70
+ * planMdDraft:proposal 档下的完整 plan.md 草稿文本;rewritten:与原文比较是否有实际变更
71
+ * (幂等重排零变更 → false,调用方静默);write 档沿用既有 postcheck 字段。
67
72
  */
68
- export function adoptPlanWaves({ changeDir, dryRun = false }) {
73
+ export function adoptPlanWaves({ changeDir, dryRun = false, mode = 'write' }) {
69
74
  const planPath = join(changeDir, 'plan.md')
70
75
  if (!existsSync(planPath)) return { ok: false, error: `plan.md 不存在: ${planPath}`, planPath, dryRun }
71
76
  const tasksDir = join(changeDir, 'tasks')
@@ -108,9 +113,11 @@ export function adoptPlanWaves({ changeDir, dryRun = false }) {
108
113
 
109
114
  const { updated, skipped } = updateSummaryWaveColumn(lines, waveOf)
110
115
  const content = lines.join('\n').replace(/\n*$/, '\n')
116
+ const rewritten = content !== original.replace(/\r\n/g, '\n').replace(/\n*$/, '\n')
111
117
 
112
- if (dryRun) {
113
- return { ok: true, waves, planPath, dryRun: true, waveBlock, tableRowsUpdated: updated, tableRowsSkipped: skipped }
118
+ if (dryRun || mode === 'proposal') {
119
+ return { ok: true, waves, planPath, dryRun: true, waveBlock, tableRowsUpdated: updated, tableRowsSkipped: skipped,
120
+ planMdDraft: content, rewritten }
114
121
  }
115
122
 
116
123
  writeFileSync(planPath, content, 'utf8')
@@ -122,5 +129,6 @@ export function adoptPlanWaves({ changeDir, dryRun = false }) {
122
129
  } catch (e) {
123
130
  postcheck = { errors: [`复跑校验异常: ${e?.message || e}`], warnings: [] }
124
131
  }
125
- return { ok: true, waves, planPath, dryRun: false, waveBlock, tableRowsUpdated: updated, tableRowsSkipped: skipped, postcheck }
132
+ return { ok: true, waves, planPath, dryRun: false, waveBlock, tableRowsUpdated: updated, tableRowsSkipped: skipped,
133
+ rewritten, postcheck }
126
134
  }
@@ -46,6 +46,22 @@ export class ChangeRegistry {
46
46
  * @param {string} changeName - 变更名
47
47
  * @returns {{ current_stage: string, status: string }|null} 无该行返回 null(未注册目录桩)
48
48
  */
49
+ /**
50
+ * 读某变更某阶段的 completed_at(2026-09-08-ir-verify-facts FR-02:verifyStartAt 基准——
51
+ * evidence 分类核验的 mtime 窗口下界,取 execute 行 completed_at)。只读不抛:无行/读失败
52
+ * 返回 null(调用方走 R-05 fallback,与 getChangeCreatedAt 同容错风格)。
53
+ */
54
+ getStageCompletedAt(cwd, changeName, stage) {
55
+ try {
56
+ const db = this.pm._ensureDB(cwd);
57
+ const row = db.getDb().prepare(
58
+ `SELECT s.completed_at FROM stages s JOIN changes c ON s.change_id = c.id
59
+ WHERE c.name = ? AND s.stage = ? ORDER BY s.completed_at DESC LIMIT 1`
60
+ ).get(changeName, stage);
61
+ return row && row.completed_at ? row.completed_at : null;
62
+ } catch { return null; }
63
+ }
64
+
49
65
  getChangeStage(cwd, changeName) {
50
66
  const db = this.pm._ensureDB(cwd);
51
67
  const sqlDb = db.getDb();
package/src/progress.js CHANGED
@@ -25,13 +25,19 @@ import { STAGE_ORDER, MAIN_FLOW_ORDER, VALID_STAGES, STAGE_LABELS, SPEC_DIR_NAME
25
25
  // resolveSpecDir 单一真相源在 src/run/shared.js(含 home 拒绝守卫,坑 cwd-correction-home-collision
26
26
  // 根治:home 下 .sillyspec 恒不命中,防 smoke/临时目录污染自我延续)。此处 re-export 保持
27
27
  // 既有 import 路径兼容;run/shared.js 对 progress.js 只有动态 import,无静态循环。
28
- import { resolveSpecDir, checkPlatformManaged, PLATFORM_MANAGED_FILENAME, isSelfReferentialSpecRoot } from './run/shared.js';
28
+ import { resolveSpecDir, checkPlatformManaged, PLATFORM_MANAGED_FILENAME, isSelfReferentialSpecRoot, isTempResidueSpecRoot } from './run/shared.js';
29
29
  export { resolveSpecDir };
30
30
 
31
31
  // 默认规范目录名(相对于 cwd)
32
32
  // SPEC_DIR_NAME → ./progress/shared.js(W6 Step9d)
33
33
  const RUNTIME_SUBDIR = '.runtime';
34
34
 
35
+ // import() 的 pre-import 快照保留份数。这类快照只服务「刚 pull 完发现平台盖错、要退回
36
+ // pull 前」这一个场景,最新一份即可覆盖;越旧的快照 schema 可能已 bump,恢复价值随时间衰减。
37
+ // 可用 SILLYSPEC_PREIMPORT_BAK_KEEP 覆盖(灾难排查期想多留几份时)。
38
+ const PRE_IMPORT_BAK_KEEP_DEFAULT = 1;
39
+ const PRE_IMPORT_BAK_RE = /^sillyspec\.db\.pre-import-.*\.bak$/;
40
+
35
41
  /**
36
42
  * 向上查找含 .sillyspec 目录的祖先目录——实现已收敛至 src/run/shared.js(单一真相源,
37
43
  * 含 home 拒绝守卫),此处 re-export。见顶部 import 说明。
@@ -95,6 +101,18 @@ export function resolvePlatformSpecDir(cwd, explicitSpecDir = null) {
95
101
  // 指针丢失不该静默当纯本地项目处理(会建本地进度库 → 状态分裂)。
96
102
  const decl = checkPlatformManaged(cwd);
97
103
  if (decl) {
104
+ // temp 残留降级(2026-09-08 temp 投毒治理):声明 specRoot 在系统 temp 且 cwd 不在
105
+ // temp → 可证伪的测试/联调残留(真实平台 spec 根永不在 temp),与指针路径的 temp
106
+ // 警告(下方 resolvedTmp 分支)及自指声明降级(run/command.js 恢复链)同类。warn +
107
+ // 自动清理声明后按本地模式继续——不清理则每次命令重复 warn,且 disconnect 会连带
108
+ // 清 local.yaml platform 段误伤真实平台连接,不适合作为残留清理手段。
109
+ // 删除失败仍按本地继续(best effort;下次命令重复 warn 提示手动清理)。
110
+ if (isTempResidueSpecRoot(cwd, decl.specRoot)) {
111
+ const declarationPath = join(resolve(cwd), PLATFORM_MANAGED_FILENAME);
112
+ console.warn(`⚠️ 检测到 temp 残留平台接管声明(specRoot 指向系统 temp 目录:${decl.specRoot},测试/联调投毒特征),已自动清理并按本地模式运行: ${declarationPath}`);
113
+ try { unlinkSync(declarationPath) } catch { /* best effort */ }
114
+ return resolveSpecDir(cwd);
115
+ }
98
116
  throw new PlatformManagedError({
99
117
  declarationPath: join(resolve(cwd), PLATFORM_MANAGED_FILENAME),
100
118
  specRoot: decl.specRoot,
@@ -613,6 +631,14 @@ export class ProgressManager {
613
631
  if (existsSync(walSidecar)) {
614
632
  try { copyFileSync(walSidecar, `${bakPath}-wal`); } catch { /* 侧车消失=已 checkpoint,无碍 */ }
615
633
  }
634
+ // 写完即回收旧快照:每次 pull / resolve --take-platform 都新增一份全库快照,无上限时
635
+ // 按同步次数无限累积(实证单仓 292 份 / 317MB)。裁剪放在写入侧而非独立 GC 命令——
636
+ // 靠人工记得跑 GC 等于没有回收。fail-open:卫生动作不能阻断 import 主流程。
637
+ try {
638
+ this._pruneImportBaks(cwd, bakPath);
639
+ } catch (e) {
640
+ console.warn(`⚠️ pre-import 快照裁剪失败(不阻断 import): ${e.message}`);
641
+ }
616
642
 
617
643
  const pushedAt = progressObj.pushed_at ?? null;
618
644
  const now = new Date().toISOString();
@@ -707,6 +733,38 @@ export class ProgressManager {
707
733
  return { ok: true, imported: cn, reason: undefined, bakPath };
708
734
  }
709
735
 
736
+ /**
737
+ * 滚动裁剪 .runtime/ 下的 pre-import 快照,只留最新 N 份(含各自 -wal 侧车)。
738
+ *
739
+ * 排序依据:文件名内嵌 ISO 时间戳(冒号/点已替换为 '-',见 import() 的 bakTs),
740
+ * 定宽且无进位歧义,字典序 == 时间序,无需 statSync(mtime 会被 copy/同步工具改写)。
741
+ *
742
+ * @param {string} cwd
743
+ * @param {string|null} keepPath - 本次刚写的快照路径,绝不裁(并发 import 互删护栏)
744
+ * @returns {number} 实际删除的快照份数
745
+ */
746
+ _pruneImportBaks(cwd, keepPath) {
747
+ let keep = Number(process.env.SILLYSPEC_PREIMPORT_BAK_KEEP);
748
+ if (!Number.isInteger(keep) || keep < 1) keep = PRE_IMPORT_BAK_KEEP_DEFAULT;
749
+
750
+ const runtimeDir = this._runtimePath(cwd);
751
+ const baks = readdirSync(runtimeDir).filter(f => PRE_IMPORT_BAK_RE.test(f)).sort();
752
+ const keepName = keepPath ? basename(keepPath) : null;
753
+ const doomed = baks.slice(0, Math.max(0, baks.length - keep)).filter(f => f !== keepName);
754
+
755
+ let removed = 0;
756
+ for (const f of doomed) {
757
+ // 单份删除失败(他进程持句柄 / 权限)不连坐其余份:跳过即可,下次 import 再收。
758
+ try { unlinkSync(join(runtimeDir, f)); } catch { continue; }
759
+ removed++;
760
+ const wal = join(runtimeDir, `${f}-wal`);
761
+ if (existsSync(wal)) {
762
+ try { unlinkSync(wal); } catch { /* 侧车残留无害:不参与恢复链,下次裁剪再收 */ }
763
+ }
764
+ }
765
+ return removed;
766
+ }
767
+
710
768
  /**
711
769
  * 本地脏度标记(D-013 / task-04):写入路径末尾更新 changes.last_local_modified_ts。
712
770
  * 在写方法的事务内调用则属于该事务(原子,失败整体回滚);import 是例外不调本方法
@@ -839,6 +897,9 @@ export class ProgressManager {
839
897
  // 单变更阶段查询(quick 轻量归档阶段闸):无行 null,读失败抛(调用方 fail-closed)
840
898
  getChangeStage(cwd, changeName) { return this._changeRegistry.getChangeStage(cwd, changeName); }
841
899
 
900
+ // verifyStartAt 基准只读(2026-09-08-ir-verify-facts):execute 行 completed_at;无行 null 走 R-05 fallback
901
+ getStageCompletedAt(cwd, changeName, stage) { return this._changeRegistry.getStageCompletedAt(cwd, changeName, stage); }
902
+
842
903
  registerChange(cwd, changeName) { return this._changeRegistry.registerChange(cwd, changeName); }
843
904
 
844
905
  updateChangeIsolation(cwd, changeName, isolation) { return this._changeRegistry.updateChangeIsolation(cwd, changeName, isolation); }
@@ -84,12 +84,17 @@ export function recommendChanges({
84
84
  taskDescription = '',
85
85
  }) {
86
86
  if (!activeChanges || activeChanges.length === 0) return []
87
+ // 活跃 quick 会话过滤(2026-09-09 §7-3):quick-<hex8> 是会话不是完整流程变更——无
88
+ // design/proposal 语义可关联(冒烟实证:新 quick 被自动挂上另一活跃 quick 当关联变更)。
89
+ // 形态单源 QUICK_SID_RE(run/shared.js);此处不 import 防把 fs 纯函数拖进 shared 依赖链。
90
+ const QUICK_SESSION_ID_RE = /^quick-[0-9a-f]{8}$/
91
+ const candidateChanges = activeChanges.filter((name) => !QUICK_SESSION_ID_RE.test(name))
87
92
 
88
93
  const dirtyFiles = [...new Set([...baselineFiles, ...quickFiles])]
89
94
  .filter(f => f && !f.startsWith('.sillyspec/'))
90
95
  const descTokens = [...new Set(tokenizeDescription(taskDescription))]
91
96
 
92
- const results = activeChanges.map(name => {
97
+ const results = candidateChanges.map(name => {
93
98
  const reasons = []
94
99
 
95
100
  // 信号 1:脏文件命中 design.md 文件变更清单
package/src/quicklog.js CHANGED
@@ -294,9 +294,17 @@ export function extractTitleFromResult(result) {
294
294
  if (!result) return ''
295
295
  const m = String(result).match(/需求:([^\n\r]*?)(?:\s+根因:|$)/)
296
296
  if (!m) return ''
297
- let t = m[1].replace(/[,。;,;].*$/, '').trim() // 截到首个标点,取核心句
297
+ // 不再截到首个标点(2026-09-08 用户反馈②):旧口径迫使 agent 刻意避开标点写标题,
298
+ // 「A,B」被截成「A」反而降低可读性。--req 的模板指引本就要求一句话短标题,
299
+ // 标点由写者自主掌控;CLI 只兜底超长截断。
300
+ let t = m[1].trim()
298
301
  if (!t) return ''
299
- if (t.length > 80) t = t.slice(0, 80) + '…'
302
+ if (t.length > 80) {
303
+ // 超长截断优先在标点/空格处断(标题残句可读),找不到就近边界才硬截。
304
+ const cut = t.slice(0, 80)
305
+ const m2 = cut.match(/^[\s\S]*[,。;:,;:]\s*/)
306
+ t = (m2 && m2[0].length >= 40 ? m2[0] : cut).replace(/[,。;:,;:\s]+$/, '') + '…'
307
+ }
300
308
  return t
301
309
  }
302
310