sillyspec 3.26.15 → 3.27.2

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 (47) hide show
  1. package/.claude/skills/sillyspec-explore/SKILL.md +10 -3
  2. package/.claude/skills/sillyspec-verify/SKILL.md +2 -0
  3. package/README.md +2 -2
  4. package/package.json +1 -1
  5. package/packages/dashboard/server/index.js +22 -6
  6. package/src/agent-session-log.js +763 -0
  7. package/src/change-list.js +22 -2
  8. package/src/change-risk-profile.js +21 -6
  9. package/src/contract-matrix.js +91 -7
  10. package/src/db.js +14 -3
  11. package/src/docs-check.js +598 -570
  12. package/src/doctor-diagnostics.js +53 -1
  13. package/src/endpoint-extractor.js +21 -2
  14. package/src/fs-atomic.js +4 -2
  15. package/src/git-helper.js +73 -5
  16. package/src/hooks/claude-pre-tool-use.cjs +10 -5
  17. package/src/hooks/worktree-guard.js +22 -3
  18. package/src/index.js +59 -7
  19. package/src/module-impact.js +21 -1
  20. package/src/module-resolve.js +211 -0
  21. package/src/modules.js +82 -2
  22. package/src/progress/step-store.js +12 -8
  23. package/src/progress.js +6 -2
  24. package/src/quicklog.js +11 -3
  25. package/src/run/command.js +40 -0
  26. package/src/run/complete-handlers.js +17 -2
  27. package/src/run/complete.js +81 -8
  28. package/src/run/gates.js +83 -14
  29. package/src/run/prompt.js +16 -2
  30. package/src/run/quick-audit.js +22 -2
  31. package/src/run/shared.js +70 -19
  32. package/src/run/stage.js +60 -3
  33. package/src/stage-contract.js +14 -1
  34. package/src/stages/archive.js +3 -1
  35. package/src/stages/execute.js +171 -18
  36. package/src/stages/explore.js +11 -2
  37. package/src/stages/plan-postcheck.js +3 -1
  38. package/src/stages/plan.js +3 -0
  39. package/src/stages/quick.js +4 -3
  40. package/src/stages/verify.js +9 -2
  41. package/src/sync.js +98 -3
  42. package/src/task-review.js +55 -7
  43. package/src/taskcard.js +52 -22
  44. package/src/verify-postcheck.js +223 -6
  45. package/src/worktree-apply.js +28 -10
  46. package/src/worktree-deps.js +71 -5
  47. package/src/worktree.js +70 -6
@@ -18,6 +18,22 @@ export function normalizePath(raw) {
18
18
  .trim()
19
19
  }
20
20
 
21
+ /**
22
+ * 组合路径单元格拆分(坑 design-combined-cell-mismatch,2026-08-22 实证:"router.py + service.py"
23
+ * 一行两文件整体当单路径,字面匹配对账不过)。拆分符:+ 、 / | ; ;——每 token 独立校验
24
+ * (looksLikePath 不过的丢弃,防自由文本混入);无分隔符返回单元素数组(零变化)。
25
+ * 不含空白要求("a.py+b.py" 与 "a.py + b.py" 都拆)。
26
+ * @param {string} cell 路径单元格(normalizePath 后)
27
+ * @returns {string[]}
28
+ */
29
+ function splitCombinedPaths(cell) {
30
+ const raw = String(cell || '').trim()
31
+ if (!raw) return []
32
+ const tokens = raw.split(/\s*[+|;;、]\s*|//).map(t => t.trim()).filter(Boolean)
33
+ const valid = tokens.filter(t => !isPlaceholder(t) && looksLikePath(t) && !t.startsWith('.sillyspec/'))
34
+ return valid.length > 0 ? valid : (looksLikePath(raw) ? [raw] : [])
35
+ }
36
+
21
37
  /**
22
38
  * glob → 正则:双星(globstar)跨零或多段目录(含零段,bash 语义)、
23
39
  * 单星 → 单段([^/]*)、问号 → 单字符([^/]),其余转义。
@@ -226,8 +242,12 @@ function _parseFileListDetailed(designMdPath, { keepSillyspecDocs = false } = {}
226
242
  // incidental:说明列(非路径列的所有 cell)+ 路径 cell 原始值(剥注释前的括号内容)
227
243
  const incidental = cells.some((c, i) => i !== pathColIdx && INCIDENTAL_RE.test(c))
228
244
  || INCIDENTAL_RE.test(cells[pathColIdx] || '')
229
- if (listMode === 'exclude') { entries.delete(filePath); continue }
230
- entries.set(filePath, { path: filePath, operation, incidental })
245
+ // 组合路径单元格拆分(坑 design-combined-cell-mismatch,2026-08-22 实证:单元格写
246
+ // "router.py + service.py" 一行两文件——整体当单路径字面匹配对账不过,逼文档写法服务
247
+ // 校验器)。拆分符:+ / / / | / 、 / ; / ;——每个 token 独立过校验入表;单路径零变化。
248
+ const pathTokens = splitCombinedPaths(filePath)
249
+ if (listMode === 'exclude') { for (const t of pathTokens) entries.delete(t); continue }
250
+ for (const t of pathTokens) entries.set(t, { path: t, operation, incidental })
231
251
  continue
232
252
  }
233
253
 
@@ -90,19 +90,29 @@ export const VERIFICATION_NEEDS = {
90
90
  },
91
91
  real_daemon_backend_integration: {
92
92
  desc: '真实 daemon↔backend 集成验证(非仅 mock 单测)。写「真实集成/端到端」的证据;',
93
- literals: ['端到端', 'integration test', 'e2e test', 'daemon.*backend', '真实集成', 'runtime evidence', '运行时证据'],
93
+ // 同义扩充(坑 verify-literal-evidence-mismatch,2026-08-22 实证:证据第一轮就齐但表述
94
+ // 不含字面词被误拦三轮)——覆盖常见自然表述 + PID 登记句式(verify prompt 要求照写)
95
+ literals: ['端到端', 'integration test', 'e2e test', 'daemon.*backend', '真实集成',
96
+ 'runtime evidence', '运行时证据', '联调', '打通', '实际请求', '真实请求', '跨进程',
97
+ 'PID 已登记', 'verify-services'],
94
98
  },
95
99
  runtime_log_evidence: {
96
100
  desc: 'Runtime Evidence section + 指向真实日志/证据片段。',
97
- literals: ['Runtime Evidence', '运行时证据', 'daemon log', '日志片段'],
101
+ literals: ['Runtime Evidence', '运行时证据', 'daemon log', '日志片段', '日志关键',
102
+ '日志:', '日志摘录', '日志', 'log 摘录', 'log 片段', '进程日志', 'PID 已登记', 'verify-services'],
98
103
  },
99
104
  real_startup_once: {
100
105
  desc: '真实启动一次本变更触及的部署/启动入口(服务入口、CLI 主入口、守护进程等——须是本变更实际改动的那一类入口,不能拿无关进程的启动来凑数)。',
101
- literals: ['启动.*一次', '实际.*启动', 'real startup', 'docker up', 'npm start', 'node server'],
106
+ // 同义扩充(同坑):自然表述 + CLI 回执句式;checkIntegrationEvidence 另拼 CLI 回执文本
107
+ //(verify-services.receipt.json,服务回收器落盘)——结构化信号不依赖 agent 措辞
108
+ literals: ['启动.*一次', '实际.*启动', 'real startup', 'docker up', 'npm start', 'node server',
109
+ '真实启动', '拉起', '已启动', '进程启动', '服务启动', '启动验证',
110
+ 'PID 已登记', 'verify-services'],
102
111
  },
103
112
  terminal_state_assertion: {
104
113
  desc: '终态断言(建议项,不阻断):AgentRun running→completed/failed、session/lease end 状态同步。',
105
- literals: ['terminal state', '终态', 'completed failed', 'session end', 'lease end'],
114
+ literals: ['terminal state', '终态', 'completed failed', 'session end', 'lease end',
115
+ '生命周期终态', '状态同步'],
106
116
  },
107
117
  }
108
118
 
@@ -202,11 +212,16 @@ export function detectChangeRisk({ designContent = '', planContent = '', changed
202
212
 
203
213
  /**
204
214
  * 检查 verify-result.md 是否包含集成验证证据
215
+ *
216
+ * opts.extraEvidenceText(坑 verify-literal-evidence-mismatch,2026-08-22):调用方可注入
217
+ * CLI 结构化回执文本(如 verify-services.receipt.json 的服务回收回执)——它随 verifyContent
218
+ * 一起参与 literals 匹配,agent 真实起过服务且 CLI 回收过(有回执)时不再依赖其自然语言
219
+ * 措辞恰好含字面词,表述差异不再误拦。
205
220
  */
206
- export function checkIntegrationEvidence(verifyContent, requiredVerification) {
221
+ export function checkIntegrationEvidence(verifyContent, requiredVerification, opts = {}) {
207
222
  const errors = []
208
223
  const warnings = []
209
- const lower = verifyContent.toLowerCase()
224
+ const lower = (String(verifyContent || '') + '\n' + String(opts.extraEvidenceText || '')).toLowerCase()
210
225
 
211
226
  // 字面证据正则从 VERIFICATION_NEEDS[k].literals 派生——与报错描述、prompt 事前契约严格同源,
212
227
  // 杜绝历史上"描述说 A、正则查 B"的分叉(checkIntegrationEvidence 正则曾比 VERIFICATION_NEEDS
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs'
12
12
  import { join, resolve, basename, relative } from 'path'
13
+ import { gitQuiet } from './git-helper.js'
13
14
  import {
14
15
  scanBackendEndpoints,
15
16
  scanFrontendApiCalls,
@@ -332,6 +333,43 @@ export function buildContractFieldInjection(changeDir, taskName) {
332
333
 
333
334
  // ─── Verify 阶段:parity check ──────────────────────────────────────────
334
335
 
336
+ /**
337
+ * 取本变更 diff 文件集(parity 前端调用收窄用,坑 probe5-fullrepo-frontend-noise)。
338
+ * 优先 worktree meta 锚点 diff(与 verify 同源);无 meta 退主仓 git diff HEAD。
339
+ * @returns {string[]|null}
340
+ */
341
+ function _resolveDiffFilesForParity(scanRoot, changeName) {
342
+ try {
343
+ const metaPath = join(scanRoot, '.sillyspec', '.runtime', 'worktrees', changeName, 'meta.json')
344
+ if (existsSync(metaPath)) {
345
+ const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
346
+ const diffBase = meta.baselineCommit || meta.actualBaseHash || meta.baseHash
347
+ const gitDir = (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath))
348
+ ? meta.worktreePath : scanRoot
349
+ if (diffBase) {
350
+ const out = gitQuiet(gitDir, ['diff', '--name-only', `${diffBase}..HEAD`], { timeout: 30000 })
351
+ if (out !== null) {
352
+ const files = out.split('\n').filter(Boolean)
353
+ // 并入 worktree 未提交(子代理默认不 commit 的形态,与 resolveVerifyChangedFiles 同口径)
354
+ try {
355
+ const wtStatus = gitQuiet(gitDir, ['status', '--porcelain'], { timeout: 30000, trim: false })
356
+ for (const line of String(wtStatus || '').split('\n')) {
357
+ if (!line || line.length < 4) continue
358
+ const p = line.slice(3).trim().split(' -> ').pop() || ''
359
+ const clean = p.replace(/^"|"$/g, '')
360
+ if (clean) files.push(clean)
361
+ }
362
+ } catch {}
363
+ return [...new Set(files)]
364
+ }
365
+ }
366
+ }
367
+ // 无 meta(brownfield/已 cleanup):主仓未提交 diff
368
+ const out = gitQuiet(scanRoot, ['diff', '--name-only', 'HEAD'], { timeout: 30000 })
369
+ return out !== null ? out.split('\n').filter(Boolean) : null
370
+ } catch { return null }
371
+ }
372
+
335
373
  /**
336
374
  * verify 阶段执行 API parity check
337
375
  * @param {string} specBase - .sillyspec 目录
@@ -372,17 +410,63 @@ export function verifyApiParity(specBase, scanRoot, runtimeRoot, changeName = nu
372
410
  return { ok: true, missingBackend: [], unusedBackend: [], summary: 'No scan root for parity check', backendCount: allProviderEndpoints.length, frontendCount: 0 }
373
411
  }
374
412
 
375
- const frontendCalls = scanFrontendApiCalls(scanRoot)
376
- const { missingBackend, unusedBackend } = diffApiParity(frontendCalls, allProviderEndpoints)
413
+ // ── 现算端点并入基线(坑 probe5-endpoint-baseline-stale,2026-08-22 实证:endpoints 基线
414
+ // 是 execute 时落的存量 artifacts,verify 时主仓已被并行会话/部署扰动推进——存量基线与当前
415
+ // 代码失配 → 前端调用(当前代码)对不上旧端点集 → missingBackend 全量误报。现算当前代码的
416
+ // 后端端点并入比对集:missingBackend 判定以「当前真实代码 ∪ 存量基线」为准(现算覆盖大部分、
417
+ // 存量补充跨仓/已删代码);unusedBackend 只对现算端点算(存量过期端点不再误报 unused)。
418
+ let liveEndpoints = []
419
+ try { liveEndpoints = scanBackendEndpoints(scanRoot) } catch { /* 现算失败退回纯存量基线 */ }
420
+ const liveKeys = new Set(liveEndpoints.map(e => `${e.method} ${e.path}`))
421
+ const mergedProviderEndpoints = [...allProviderEndpoints]
422
+ for (const e of liveEndpoints) {
423
+ const key = `${e.method} ${e.path}`
424
+ if (!allProviderEndpoints.some(p => `${p.method} ${p.path}` === key)) {
425
+ mergedProviderEndpoints.push(e)
426
+ }
427
+ }
428
+
429
+ // ── 前端调用收窄到本变更 diff(坑 probe5-fullrepo-frontend-noise,2026-08-23 实证:
430
+ // 「全仓前端调用 × 本变更局部登记」口径下,未受本变更影响的存量调用对不上局部端点集
431
+ // → 143 个 missing 全是误报噪音)。比对口径 = 本变更 diff 文件内的调用 ∪ 全仓(仅当
432
+ // 端点登记源也是全仓时——无 changeName 的 CLI contractScan 场景)。diff 不可得时退回
433
+ // 全仓(不静默跳过对账)。
434
+ let frontendCalls = []
435
+ let frontendScope = 'full-repo'
436
+ if (changeName) {
437
+ try {
438
+ const changed = _resolveDiffFilesForParity(scanRoot, changeName)
439
+ if (changed && changed.length > 0) {
440
+ const changedSet = new Set(changed.map(f => f.replace(/\\/g, '/')))
441
+ frontendCalls = scanFrontendApiCalls(scanRoot).filter(c => {
442
+ const src = String(c.source || '').replace(/\\/g, '/')
443
+ return changedSet.has(src) || [...changedSet].some(cf => src.endsWith(cf) || cf.endsWith(src))
444
+ })
445
+ frontendScope = `change-diff (${changed.length} files)`
446
+ } else {
447
+ frontendCalls = scanFrontendApiCalls(scanRoot)
448
+ }
449
+ } catch { frontendCalls = scanFrontendApiCalls(scanRoot) }
450
+ } else {
451
+ frontendCalls = scanFrontendApiCalls(scanRoot)
452
+ }
453
+
454
+ const { missingBackend, unusedBackend } = diffApiParity(frontendCalls, mergedProviderEndpoints)
455
+ // unusedBackend 收窄到现算端点(存量 artifact 的过期端点不参与——它不在当前代码里,
456
+ // 「前端未调用」可能是端点已被重构掉而非真泄漏)
457
+ const narrowedUnused = unusedBackend.filter(u => {
458
+ const key = `${u.method} ${u.path}`
459
+ return liveKeys.has(key) || liveEndpoints.length === 0
460
+ })
377
461
 
378
462
  const ok = missingBackend.length === 0
379
463
  let summary = ok
380
- ? `✅ API parity check passed: ${allProviderEndpoints.length} backend endpoints, ${frontendCalls.length} frontend calls`
381
- : `❌ API parity check failed: ${missingBackend.length} frontend calls have no matching backend endpoint`
464
+ ? `✅ API parity check passed: ${mergedProviderEndpoints.length} backend endpoints (live ${liveEndpoints.length} + artifact ${allProviderEndpoints.length}), ${frontendCalls.length} frontend calls [scope: ${frontendScope}]`
465
+ : `❌ API parity check failed: ${missingBackend.length} frontend calls have no matching backend endpoint [scope: ${frontendScope}]`
382
466
 
383
- if (unusedBackend.length > 0) {
384
- summary += ` | ${unusedBackend.length} backend endpoints unused by frontend`
467
+ if (narrowedUnused.length > 0) {
468
+ summary += ` | ${narrowedUnused.length} backend endpoints unused by frontend`
385
469
  }
386
470
 
387
- return { ok, missingBackend, unusedBackend, summary, backendCount: allProviderEndpoints.length, frontendCount: frontendCalls.length }
471
+ return { ok, missingBackend, unusedBackend: narrowedUnused, summary, backendCount: mergedProviderEndpoints.length, frontendCount: frontendCalls.length }
388
472
  }
package/src/db.js CHANGED
@@ -105,13 +105,19 @@ export class DB {
105
105
  if (!existsSync(p)) return null;
106
106
  return statSync(p).size === 0 ? 'empty' : 'ok';
107
107
  };
108
- // tryOpen: openDatabase(path) + prepare 探测双保险(防打开成功但内容非 SQLite)
108
+ // tryOpen: openDatabase(path) + prepare 探测双保险(防打开成功但内容非 SQLite)。
109
+ // 探测前先设 busy_timeout(2026-08-21 审查 BUG-7):init() 的 applyPragmas 在 _openWithFallback
110
+ // 之后才跑,探测 SELECT 此前以 busy_timeout=0(SQLite 默认)执行——他者进程 CHECKPOINT 改写
111
+ // 主库时探测立即 SQLITE_BUSY,3 次重试总窗口仅 350ms,大 WAL 下 checkpoint 秒级 → 健康库
112
+ // 被误判"损坏"硬抛错。先设 busy_timeout 让探测自身等待锁,重试只剩消化瞬时抖动。
109
113
  const tryOpen = (p) => {
110
114
  let db;
111
115
  try { db = openDatabase(p); }
112
116
  catch { return null; }
113
- try { db.prepare('SELECT count(*) FROM sqlite_master').get(); return db; }
114
- catch { try { db.close(); } catch { /* 关闭失败忽略 */ } return null; }
117
+ try {
118
+ db.exec('PRAGMA busy_timeout = 5000');
119
+ db.prepare('SELECT count(*) FROM sqlite_master').get(); return db;
120
+ } catch { try { db.close(); } catch { /* 关闭失败忽略 */ } return null; }
115
121
  };
116
122
 
117
123
  // 1. 主库(0 字节=截断信号,必须走回退,故门禁要求 'ok' 有内容)
@@ -144,6 +150,11 @@ export class DB {
144
150
  : 'sillyspec.db 损坏';
145
151
  console.warn(`⚠️ ${reason},已从 .bak 备份恢复。`);
146
152
  bakDb.close();
153
+ // 覆盖前救援快照(2026-08-21 审查 BUG-7):主库有内容但打不开("损坏")时,其中可能
154
+ // 含比 .bak 更新的数据——静默覆盖即静默丢。best-effort 留一份 .corrupt-<ts> 副本供人工恢复。
155
+ if (primaryRaw === 'ok') {
156
+ try { copyFileSync(this.dbPath, `${this.dbPath}.corrupt-${Date.now()}`); } catch { /* 救援失败不阻断恢复 */ }
157
+ }
147
158
  // node:sqlite 绑定路径,无 _save:把 .bak 内容 copy 回主库,统一用 dbPath。
148
159
  // 体检 BUG-18:主库可能正被其他进程(daemon/另一 CLI)打开——Windows 覆盖他人
149
160
  // 打开的文件抛 EPERM/EBUSY,短暂退避重试(持有者通常是瞬时句柄),仍失败 fail-loud