sillyspec 3.26.7 → 3.26.9

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 (62) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +1 -2
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +1 -2
  3. package/.claude/skills/sillyspec-doctor/SKILL.md +2 -0
  4. package/.claude/skills/sillyspec-execute/SKILL.md +10 -2
  5. package/.claude/skills/sillyspec-explore/SKILL.md +0 -1
  6. package/.claude/skills/sillyspec-plan/SKILL.md +1 -2
  7. package/.claude/skills/sillyspec-quick/SKILL.md +1 -2
  8. package/.claude/skills/sillyspec-scan/SKILL.md +2 -2
  9. package/.claude/skills/sillyspec-status/SKILL.md +0 -1
  10. package/.claude/skills/sillyspec-verify/SKILL.md +1 -2
  11. package/.husky/pre-push +9 -2
  12. package/README.md +5 -9
  13. package/bin/sillyspec.js +1 -1
  14. package/package.json +2 -2
  15. package/packages/dashboard/server/index.js +14 -8
  16. package/src/change-risk-profile.js +5 -207
  17. package/src/config-schema.js +310 -287
  18. package/src/constants.js +97 -106
  19. package/src/db-engine.js +1 -1
  20. package/src/docs-check.js +362 -0
  21. package/src/docs-debt.js +226 -0
  22. package/src/docs-gate.js +106 -0
  23. package/src/doctor-diagnostics.js +23 -2
  24. package/src/git-helper.js +2 -2
  25. package/src/hooks/worktree-guard.js +1 -1
  26. package/src/index.js +1963 -1787
  27. package/src/init.js +95 -17
  28. package/src/machine-interface.js +7 -4
  29. package/src/modules.js +490 -486
  30. package/src/progress/stage-machine.js +3 -0
  31. package/src/progress.js +37 -13
  32. package/src/quicklog.js +182 -1
  33. package/src/run/command.js +154 -26
  34. package/src/run/complete-handlers.js +1254 -1128
  35. package/src/run/complete.js +9 -33
  36. package/src/run/gates.js +85 -1
  37. package/src/run/prompt.js +82 -1
  38. package/src/run/quick-audit.js +23 -0
  39. package/src/run/shared.js +273 -5
  40. package/src/run/stage.js +17 -1
  41. package/src/scan-diff.js +388 -0
  42. package/src/scan-staleness.js +95 -0
  43. package/src/stage-contract-spec.js +16 -1
  44. package/src/stage-contract.js +8 -5
  45. package/src/stage-review.js +1 -1
  46. package/src/stages/brainstorm-auto.js +0 -1
  47. package/src/stages/brainstorm.js +3 -3
  48. package/src/stages/execute.js +35 -11
  49. package/src/stages/plan-postcheck.js +93 -36
  50. package/src/stages/plan.js +22 -7
  51. package/src/stages/quick.js +1 -1
  52. package/src/stages/scan.js +2 -2
  53. package/src/stages/verify.js +11 -12
  54. package/src/sync.js +39 -17
  55. package/src/task-review.js +14 -5
  56. package/src/workflow.js +8 -185
  57. package/src/worktree-apply.js +128 -3
  58. package/src/worktree.js +2 -2
  59. package/templates/claude-instruction.md +1 -1
  60. package/templates/prompts/taskcard-rules.md +1 -0
  61. package/templates/prompts/verify-probes.md +2 -2
  62. package/templates/skills/sillyspec-onboard/SKILL.md +0 -92
@@ -0,0 +1,388 @@
1
+ /**
2
+ * scan-diff — scan 文档 vs 源码漂移清单(change: 2026-08-16-scan-diff-command, task-01)
3
+ *
4
+ * 定位:scan 文档是架构快照(基线 = 生成时源码 commit,frontmatter source_commit),源码持续演进。
5
+ * scan-diff 算出「基线之后源码变了什么、哪些模块的 scan 文档需要核对/刷新」,按四分类输出:
6
+ * A 新增文件 → scan 文档缺记(缺文档)
7
+ * D 已删除文件 → scan 文档多记(多文档)
8
+ * M / R / C 修改 / 重命名 / 复制 → scan 文档过时
9
+ * 其余(T 等)→ unknown(不判漂移,只展示)
10
+ * 扫描范围缺省 = module-map paths 覆盖集(parseModuleMapSimple 收集全部 paths 去重),
11
+ * 非 src/-only——范围外的源码变更与模块文档无涉,不计入漂移。
12
+ *
13
+ * 分层:
14
+ * computeScanDiff —— 计算层(git 只读 + frontmatter/module-map 读取;无渲染无落盘)。
15
+ * 硬错误返回 { ok:false, error }(无效 base / git 失败 / 无 source_commit);
16
+ * advisory 问题进 warnings(非祖先 base / rev-list 降级 / module-map 缺失)。
17
+ * runScanDiff —— IO 面(终端聚合渲染 + --report 落盘 scan-diff-report.md),返回退出码。
18
+ *
19
+ * 复用(先读源码确认签名后使用):parseSourceCommit(scan-staleness)、
20
+ * matchFilesToModules(docs-debt,含裸名兜底)、safeGit(git-helper)、
21
+ * parseModuleMapSimple(modules.js);零新依赖。
22
+ */
23
+ import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs'
24
+ import { join } from 'node:path'
25
+ import { safeGit } from './git-helper.js'
26
+ import { parseSourceCommit } from './scan-staleness.js'
27
+ import { matchFilesToModules } from './docs-debt.js'
28
+ import { parseModuleMapSimple } from './modules.js'
29
+
30
+ /** safeGit 超时(仿 docs-debt 的 GIT_TIMEOUT_MS 模式:大 diff 慢时降级不挂起) */
31
+ const GIT_TIMEOUT_MS = 10000
32
+
33
+ /** 每模块终端渲染默认上限(--full 展开全部) */
34
+ const DEFAULT_LIST_CAP = 5
35
+
36
+ /**
37
+ * 计算层:scan 文档 vs 源码漂移事实。
38
+ * @param {{ projectRoot: string, specBase: string, projectName: string, base?: string|null }} opts
39
+ * projectRoot:git 锚(diff/rev-list 的 -C 目录)
40
+ * specBase / projectName:scan 文档与 module-map 定位(specBase/docs/<projectName>/...)
41
+ * base:基线 commit(短/长哈希、引用均可);缺省读 scan 文档 frontmatter 的 source_commit
42
+ * @returns {{ ok: true, base, baseSource, head, behindCommits, daysSinceScan, scope,
43
+ * added, deleted, modified, unknown, unmapped, outOfScope, renameMap, byModule, warnings, driftCount }
44
+ * | { ok: false, error: string }}
45
+ * ok:false = 硬错误(参数缺失 / 无 source_commit / 无效 base / git diff 失败)
46
+ * added/deleted/modified/unknown:范围内变更文件(POSIX 相对路径;R/C 记新路径)
47
+ * unmapped:范围内未匹配到模块的文件;outOfScope:范围外变更(不计入漂移)
48
+ * renameMap:newPath → oldPath(R 归 modified 且保留 rename 细节)
49
+ * byModule:{ moduleId: { doc, added, deleted, modified, files } }(A/D/M 计数 + 文件清单)
50
+ * driftCount = added + deleted + modified
51
+ */
52
+ export function computeScanDiff({ projectRoot, specBase, projectName, base } = {}) {
53
+ if (!projectRoot || !specBase || !projectName) {
54
+ return { ok: false, error: 'projectRoot / specBase / projectName 必填,缺一不可' }
55
+ }
56
+ const warnings = []
57
+ let baseSource = '--base 显式指定'
58
+
59
+ // ── 基线:缺省读 specBase/docs/<projectName>/scan/*.md frontmatter 的 source_commit ──
60
+ if (!base) {
61
+ const sourceCommit = readSourceCommit(specBase, projectName)
62
+ if (!sourceCommit) {
63
+ return {
64
+ ok: false, error:
65
+ 'scan 文档无 source_commit 字段(绿地项目 / 旧版生成 / 无 scan 文档),无法定基线——请用 --base <commit> 显式指定基线',
66
+ }
67
+ }
68
+ base = sourceCommit
69
+ baseSource = 'scan 文档 frontmatter source_commit'
70
+ }
71
+ const baseDisplay = displayBase(base)
72
+
73
+ // ── base 有效性守卫(仿 computeScanStaleness 的 isAncestor 守卫)──
74
+ // merge-base --is-ancestor 对「无效 commit」与「有效但非祖先」都返回非 0(safeGit 记为 error),
75
+ // 无法直接区分——先用 rev-parse 验有效性:无效 → 报错;有效但非祖先 → 警告(diff 按两快照对比仍可算)。
76
+ const revRes = safeGit(projectRoot, ['rev-parse', '--verify', `${base}^{commit}`])
77
+ if (revRes.error || !revRes.value) {
78
+ return { ok: false, error: `基线 ${baseDisplay} 不是有效 commit(${revRes.error || '无输出'}),跳过 scan-diff` }
79
+ }
80
+ const resolvedBase = revRes.value.trim()
81
+ const headRes = safeGit(projectRoot, ['rev-parse', 'HEAD'])
82
+ const headShort = headRes.error ? 'HEAD' : headRes.value.trim().slice(0, 7)
83
+ const isAncestor = safeGit(projectRoot, ['merge-base', '--is-ancestor', resolvedBase, 'HEAD'])
84
+ if (isAncestor.error) {
85
+ warnings.push(`基线 ${baseDisplay} 不是 HEAD(${headShort})祖先(rebase / 分支切换)——diff 按两快照对比,删除后重建可能呈假新增`)
86
+ }
87
+
88
+ // ── 落后计数(advisory:超时/失败降级 null,不抛)──
89
+ const countRes = safeGit(projectRoot, ['rev-list', '--count', `${resolvedBase}..HEAD`], { timeout: GIT_TIMEOUT_MS })
90
+ let behindCommits = countRes.error ? null : parseInt(countRes.value, 10)
91
+ if (behindCommits === null || Number.isNaN(behindCommits)) {
92
+ behindCommits = null
93
+ warnings.push('rev-list 计数失败(git 降级),behindCommits=null')
94
+ }
95
+ const dateRes = safeGit(projectRoot, ['log', '-1', '--format=%cI', resolvedBase])
96
+ let daysSinceScan = null
97
+ if (!dateRes.error && dateRes.value) {
98
+ daysSinceScan = Math.floor((Date.now() - new Date(dateRes.value.trim()).getTime()) / 86400000)
99
+ }
100
+
101
+ // ── 扫描范围:module-map paths 覆盖集(默认范围,非 src/-only)──
102
+ const cardsDir = join(specBase, 'docs', projectName, 'modules')
103
+ const mapPath = join(cardsDir, '_module-map.yaml')
104
+ let moduleIndex = null
105
+ if (existsSync(mapPath)) {
106
+ try { moduleIndex = parseModuleMapSimple(readFileSync(mapPath, 'utf8')) } catch { moduleIndex = null }
107
+ }
108
+ const scope = collectScope(moduleIndex)
109
+ if (scope.length === 0) {
110
+ warnings.push('module-map 缺失 / 解析为空(跑 sillyspec modules rebuild 生成)——扫描范围退化=全部变更文件,无法归模块')
111
+ }
112
+
113
+ // ── diff:只算路径(--find-renames 检出重命名;超时降级为错误返回)──
114
+ const diffRes = safeGit(projectRoot, ['diff', '--name-status', '--find-renames', `${resolvedBase}..HEAD`], { timeout: GIT_TIMEOUT_MS })
115
+ if (diffRes.error) {
116
+ return { ok: false, error: `git diff ${baseDisplay}..HEAD 失败(${diffRes.error}),跳过 scan-diff` }
117
+ }
118
+
119
+ // ── 四分类 + 范围过滤 ──
120
+ const added = [], deleted = [], modified = [], unknown = [], outOfScope = []
121
+ const renameMap = {} // newPath → oldPath(R 归 modified 且保留 rename 细节)
122
+ const scopeFilter = scope.length > 0 ? scope : null
123
+ for (const item of parseNameStatus(diffRes.value)) {
124
+ if (scopeFilter && !inScope(item.path, scopeFilter)) { outOfScope.push(item.path); continue }
125
+ switch (item.status) {
126
+ case 'A': added.push(item.path); break
127
+ case 'D': deleted.push(item.path); break
128
+ case 'M': modified.push(item.path); break
129
+ case 'R': modified.push(item.path); renameMap[item.path] = item.oldPath; break
130
+ case 'C': modified.push(item.path); break // 复制目标(新路径)视同过时
131
+ default: unknown.push(item.path); break // T / B 等
132
+ }
133
+ }
134
+
135
+ // ── 归模块(matchFilesToModules,含裸名兜底;结果 = 同文件清单直接调用一致)──
136
+ const inScopeFiles = [...added, ...deleted, ...modified, ...unknown]
137
+ let byModuleMap = new Map()
138
+ let unmapped = inScopeFiles
139
+ if (inScopeFiles.length > 0) {
140
+ try {
141
+ const matched = matchFilesToModules(inScopeFiles, moduleIndex || {}, { cardsDir })
142
+ byModuleMap = matched.byModule
143
+ unmapped = matched.unmapped
144
+ } catch {
145
+ warnings.push('matchFilesToModules 归模块失败(降级为全部未归模块)')
146
+ }
147
+ } else {
148
+ unmapped = []
149
+ }
150
+
151
+ // ── byModule 聚合 A/D/M 计数 ──
152
+ const addedSet = new Set(added)
153
+ const deletedSet = new Set(deleted)
154
+ const modifiedSet = new Set(modified)
155
+ const byModule = {}
156
+ for (const [id, entry] of byModuleMap) {
157
+ let a = 0, d = 0, m = 0
158
+ for (const f of entry.files) {
159
+ if (addedSet.has(f)) a++
160
+ else if (deletedSet.has(f)) d++
161
+ else if (modifiedSet.has(f)) m++
162
+ }
163
+ byModule[id] = { doc: entry.doc || '', added: a, deleted: d, modified: m, files: entry.files }
164
+ }
165
+
166
+ return {
167
+ ok: true,
168
+ base: baseDisplay,
169
+ baseSource,
170
+ head: headShort,
171
+ behindCommits,
172
+ daysSinceScan,
173
+ scope,
174
+ added, deleted, modified, unknown, unmapped, outOfScope,
175
+ renameMap,
176
+ byModule,
177
+ warnings,
178
+ driftCount: added.length + deleted.length + modified.length,
179
+ }
180
+ }
181
+
182
+ /**
183
+ * IO 面:终端渲染 + 可选落盘报告。
184
+ * @param {{ projectRoot: string, specBase: string, projectName: string, base?: string|null,
185
+ * full?: boolean, report?: boolean }} opts
186
+ * full:终端展开每模块全部文件(缺省每模块 ≤5 条);report:落盘 scan-diff-report.md
187
+ * @returns {number} 退出码:0 无漂移 / 1 有漂移 / 2 错误(无效 base、git 失败、无 source_commit)
188
+ */
189
+ export function runScanDiff(opts = {}) {
190
+ const { projectRoot, specBase, projectName, base = null, full = false, report = false } = opts
191
+ const result = computeScanDiff({ projectRoot, specBase, projectName, base })
192
+ if (!result.ok) {
193
+ console.error(`scan-diff:${result.error}`)
194
+ return 2
195
+ }
196
+ for (const w of result.warnings) console.warn(` ⚠️ ${w}`)
197
+
198
+ console.log(`scan diff: ${result.base}..HEAD(${fmtNum(result.behindCommits)} commit / ${fmtNum(result.daysSinceScan)} 天)`)
199
+ if (result.driftCount === 0) {
200
+ console.log('scan 文档与源码一致(0 漂移)')
201
+ if (result.unknown.length > 0) {
202
+ console.log(` 无法归类(unknown)${result.unknown.length} 条:${result.unknown.join(', ')}`)
203
+ }
204
+ } else {
205
+ renderTerminal(result, { full })
206
+ }
207
+
208
+ if (report) {
209
+ const p = writeReport(result, { specBase, projectName })
210
+ if (p) console.log(`scan-diff 报告已落盘:${p}`)
211
+ else console.warn(' ⚠️ scan-diff-report.md 写入失败')
212
+ }
213
+ return result.driftCount > 0 ? 1 : 0
214
+ }
215
+
216
+ /** 终端聚合渲染:每模块计数 + 文件清单(缺省 ≤5 条,--full 展开全部) */
217
+ function renderTerminal(r, { full }) {
218
+ const cap = full ? Number.MAX_SAFE_INTEGER : DEFAULT_LIST_CAP
219
+ console.log(`扫描范围:module-map paths 覆盖集(${r.scope.length} 条路径)——范围外的变更不计入漂移`)
220
+ const moduleIds = Object.keys(r.byModule).sort()
221
+ if (moduleIds.length > 0) {
222
+ console.log('按模块聚合漂移(A=缺文档 D=多文档 M=过时,--full 展开全部):')
223
+ for (const id of moduleIds) {
224
+ const m = r.byModule[id]
225
+ const doc = m.doc ? `(${m.doc})` : ''
226
+ console.log(` [${id}] A${m.added} D${m.deleted} M${m.modified}${doc}`)
227
+ for (const f of m.files.slice(0, cap)) {
228
+ console.log(` - ${statusLetter(r, f)} ${f}${r.renameMap[f] ? `(原 ${r.renameMap[f]})` : ''}`)
229
+ }
230
+ if (m.files.length > cap) console.log(` ... 其余 ${m.files.length - cap} 条,--full 展开`)
231
+ }
232
+ }
233
+ if (r.unmapped.length > 0) {
234
+ console.log(`未归模块(unmapped,${r.unmapped.length} 条):`)
235
+ for (const f of r.unmapped.slice(0, cap)) {
236
+ console.log(` - ${statusLetter(r, f)} ${f}`)
237
+ }
238
+ if (r.unmapped.length > cap) console.log(` ... 其余 ${r.unmapped.length - cap} 条,--full 展开`)
239
+ }
240
+ if (r.unknown.length > 0) {
241
+ console.log(`无法归类(unknown,${r.unknown.length} 条):${r.unknown.slice(0, cap).join(', ')}`)
242
+ }
243
+ console.log(`漂移合计:${r.driftCount} 条(A${r.added.length} / D${r.deleted.length} / M${r.modified.length})`)
244
+ }
245
+
246
+ /** 文件归属状态字母(渲染用):R(renameMap 命中)> A > D > M */
247
+ function statusLetter(r, f) {
248
+ if (r.renameMap[f]) return 'R'
249
+ if (r.added.includes(f)) return 'A'
250
+ if (r.deleted.includes(f)) return 'D'
251
+ return 'M'
252
+ }
253
+
254
+ /** 落盘 scan-diff-report.md(markdown 完整清单,不截断) */
255
+ function writeReport(r, { specBase, projectName }) {
256
+ const dir = join(specBase, 'docs', projectName, 'scan')
257
+ try { mkdirSync(dir, { recursive: true }) } catch { return null }
258
+ const lines = [
259
+ '# scan-diff 报告',
260
+ '',
261
+ `- 生成时间:${new Date().toISOString()}`,
262
+ `- 项目:${projectName}`,
263
+ `- 基线:${r.base}(${r.baseSource})`,
264
+ `- 对比:\`${r.base}..HEAD\``,
265
+ `- 落后:${fmtNum(r.behindCommits)} commit / ${fmtNum(r.daysSinceScan)} 天`,
266
+ `- 扫描范围:module-map paths 覆盖集(${r.scope.length} 条路径)`,
267
+ '',
268
+ '## 漂移总览',
269
+ '',
270
+ '| 分类 | 含义 | 数量 |',
271
+ '| --- | --- | --- |',
272
+ `| added | 源码新增,scan 文档缺记 | ${r.added.length} |`,
273
+ `| deleted | 源码已删,scan 文档多记 | ${r.deleted.length} |`,
274
+ `| modified | 源码已变,scan 文档过时 | ${r.modified.length} |`,
275
+ `| unknown | 其他状态 | ${r.unknown.length} |`,
276
+ `| unmapped | 范围内未匹配模块 | ${r.unmapped.length} |`,
277
+ '',
278
+ '## 按模块聚合',
279
+ '',
280
+ ]
281
+ if (Object.keys(r.byModule).length === 0) {
282
+ lines.push('(无模块命中)', '')
283
+ } else {
284
+ for (const id of Object.keys(r.byModule).sort()) {
285
+ const m = r.byModule[id]
286
+ lines.push(`### ${id}(${m.doc || '无 doc'})`, '')
287
+ lines.push(`- added:${m.added}`, `- deleted:${m.deleted}`, `- modified:${m.modified}`, '- 文件:')
288
+ for (const f of m.files) {
289
+ const ren = r.renameMap[f] ? `,原 ${r.renameMap[f]}` : ''
290
+ lines.push(` - \`${f}\`(${statusLetter(r, f)}${ren})`)
291
+ }
292
+ lines.push('')
293
+ }
294
+ }
295
+ if (r.unmapped.length > 0) {
296
+ lines.push('## 未归模块(unmapped)', '')
297
+ for (const f of r.unmapped) lines.push(`- \`${f}\``)
298
+ lines.push('')
299
+ }
300
+ if (r.outOfScope.length > 0) {
301
+ lines.push('## 范围外变更(不计入漂移)', '')
302
+ for (const f of r.outOfScope) lines.push(`- \`${f}\``)
303
+ lines.push('')
304
+ }
305
+ if (r.warnings.length > 0) {
306
+ lines.push('## 提示', '')
307
+ for (const w of r.warnings) lines.push(`- ${w}`)
308
+ lines.push('')
309
+ }
310
+ lines.push('## 结论', '')
311
+ lines.push(`- 漂移合计:${r.driftCount} 条(A${r.added.length} / D${r.deleted.length} / M${r.modified.length})`)
312
+ if (r.driftCount === 0) {
313
+ lines.push('- scan 文档与源码一致(0 漂移)')
314
+ } else {
315
+ lines.push('- 存在漂移:请按需核对并刷新对应 scan 文档(sillyspec run scan)')
316
+ }
317
+ lines.push('')
318
+ lines.push('> 由 sillyspec scan-diff 自动生成;改动源码后请按需核对 scan 文档。')
319
+ const outPath = join(dir, 'scan-diff-report.md')
320
+ try {
321
+ writeFileSync(outPath, lines.join('\n') + '\n', 'utf8')
322
+ return outPath
323
+ } catch {
324
+ return null
325
+ }
326
+ }
327
+
328
+ /** 读 scan 目录任一 .md 的 source_commit(同 modules.js 写入同值,取首个命中;全失败 → null) */
329
+ function readSourceCommit(specBase, projectName) {
330
+ const scanDir = join(specBase, 'docs', projectName, 'scan')
331
+ if (!existsSync(scanDir)) return null
332
+ try {
333
+ for (const f of readdirSync(scanDir)) {
334
+ if (!f.endsWith('.md')) continue
335
+ const c = parseSourceCommit(readFileSync(join(scanDir, f), 'utf8'))
336
+ if (c) return c
337
+ }
338
+ } catch { /* 读失败 → null */ }
339
+ return null
340
+ }
341
+
342
+ /** 收集 module-map 全部 paths 去重(归一 Windows 反斜杠 + 去尾部斜杠;无 paths → 空数组) */
343
+ function collectScope(moduleIndex) {
344
+ const set = new Set()
345
+ for (const m of Object.values(moduleIndex || {})) {
346
+ if (!Array.isArray(m.paths)) continue
347
+ for (const raw of m.paths) {
348
+ if (typeof raw !== 'string' || !raw) continue
349
+ set.add(raw.replace(/\\/g, '/').replace(/\/+$/, ''))
350
+ }
351
+ }
352
+ return [...set]
353
+ }
354
+
355
+ /** 路径是否落在范围(p 可为目录或文件:精确相等或目录前缀) */
356
+ function inScope(filePath, scope) {
357
+ return scope.some((p) => p && (filePath === p || filePath.startsWith(p + '/')))
358
+ }
359
+
360
+ /** 解析 `git diff --name-status --find-renames` 输出 → [{ status, path, oldPath? }](R/C 双路径) */
361
+ function parseNameStatus(out) {
362
+ const items = []
363
+ if (!out) return items
364
+ for (const line of out.split('\n')) {
365
+ const l = line.replace(/\r$/, '')
366
+ if (!l) continue
367
+ const tab = l.indexOf('\t')
368
+ if (tab === -1) continue
369
+ const m = l.slice(0, tab).match(/^([A-Z])(\d*)$/)
370
+ if (!m) continue
371
+ const status = m[1]
372
+ const parts = l.slice(tab + 1).split('\t')
373
+ const item = { status, path: parts[parts.length - 1] }
374
+ if ((status === 'R' || status === 'C') && parts.length >= 2) item.oldPath = parts[0]
375
+ items.push(item)
376
+ }
377
+ return items
378
+ }
379
+
380
+ /** 40 位全哈希缩成 7 位显示;引用/短哈希原样 */
381
+ function displayBase(b) {
382
+ return /^[0-9a-f]{40}$/i.test(b) ? b.slice(0, 7) : b
383
+ }
384
+
385
+ /** null/NaN → '?'(降级显示) */
386
+ function fmtNum(n) {
387
+ return n === null || Number.isNaN(n) ? '?' : String(n)
388
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * scan-staleness — scan 文档新鲜度提示(债单 D-7 方案 A,change: 2026-08-15 ql-20260815-013;
3
+ * 2026-08-16 ql-20260816-009-fb44 修正判定语义)
4
+ *
5
+ * 原则(doc-consistency-debt 第六节用户裁决):CLI 算事实注入——git 算出 scan 文档落后程度,
6
+ * 注入给 agent 的是一行结论不是一段劝说指令。brainstorm「加载项目上下文」读 scan 文档前
7
+ * 就知道"我读的 scan 文档可能没跟上近期结构变更",按提示核对或重扫,而非盲信。
8
+ *
9
+ * 判定语义(2026-08-16 修正,与第七节「behind 计数不参与判定」对齐):source_commit vs HEAD
10
+ * 的 `rev-list --count` 落后数只是「建议核对/重扫」的提示信号(旧快照可能未收录近期新增模块),
11
+ * 不是「文档失效/失真」的判据——文档引用是否失效由 docs-check(直接信号,src/docs-check.js)
12
+ * 负责,与 docs-gate 原则一致。status 三态 fresh / needs-refresh / unknown:needs-refresh =
13
+ * 该刷新/核对,不是文档判错。
14
+ *
15
+ * 纯读操作(git 只读 + frontmatter 解析),任何失败降级 null/unknown(advisory 信号不阻断流程)。
16
+ */
17
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
18
+ import { join } from 'node:path'
19
+ import { safeGit } from './git-helper.js'
20
+
21
+ /** 刷新提示阈值(commit 数 / 天数,超过任一即 needs-refresh;advisory 提示允许保守默认) */
22
+ export const STALENESS_THRESHOLDS = { commits: 50, days: 14 }
23
+
24
+ /**
25
+ * 纯函数:从 scan 文档内容解析 frontmatter 的 source_commit。
26
+ * @param {string} content 文档全文
27
+ * @returns {string|null} commit hex(短/长均可);无字段/无 frontmatter → null
28
+ */
29
+ export function parseSourceCommit(content) {
30
+ if (!content || typeof content !== 'string') return null
31
+ // \r? 容错 CRLF($ 前的 \r 会致 LF 正则失配)
32
+ const m = content.match(/^---\r?\n[\s\S]*?^source_commit:\s*([0-9a-f]{7,40})\s*\r?$/m)
33
+ return m ? m[1] : null
34
+ }
35
+
36
+ /**
37
+ * 计算单个 project 的 scan 文档漂移事实。
38
+ * @param {{ projectRoot: string, specBase: string, projectName: string, thresholds?: { commits: number, days: number } }} opts
39
+ * @returns {{ status: 'fresh'|'needs-refresh'|'unknown', behindCommits: number|null, daysSinceScan: number|null, sourceCommit: string|null, message: string }|null}
40
+ * null = 无 scan 文档目录(绿地项目等),调用方直接跳过;unknown = 有文档但算不出(无
41
+ * source_commit 字段 / git 失败),message 说明降级原因。
42
+ */
43
+ export function computeScanStaleness(opts) {
44
+ const { projectRoot, specBase, projectName, thresholds = STALENESS_THRESHOLDS } = opts || {}
45
+ if (!projectRoot || !specBase || !projectName) return null
46
+ const scanDir = join(specBase, 'docs', projectName, 'scan')
47
+ if (!existsSync(scanDir)) return null
48
+
49
+ // 任一 scan 文档带 source_commit 即可代表整批(modules.js 同批写入同值)
50
+ let sourceCommit = null
51
+ let checked = 0
52
+ for (const f of readdirSync(scanDir)) {
53
+ if (!f.endsWith('.md')) continue
54
+ checked++
55
+ const c = parseSourceCommit(readFileSync(join(scanDir, f), 'utf8'))
56
+ if (c) { sourceCommit = c; break }
57
+ }
58
+ if (checked === 0) return null
59
+ if (!sourceCommit) {
60
+ return { status: 'unknown', behindCommits: null, daysSinceScan: null, sourceCommit: null,
61
+ message: 'scan 文档无 source_commit 字段(旧版生成),无法算漂移提示——文档引用是否失效以 docs check 为准;如架构已大改建议重跑 sillyspec run scan --standard' }
62
+ }
63
+
64
+ const head = safeGit(projectRoot, ['rev-parse', 'HEAD'])
65
+ if (head.error || !head.value) {
66
+ return { status: 'unknown', behindCommits: null, daysSinceScan: null, sourceCommit,
67
+ message: `git 读取失败(${head.error || '无 HEAD'}),跳过漂移判定` }
68
+ }
69
+ const isAncestor = safeGit(projectRoot, ['merge-base', '--is-ancestor', sourceCommit, head.value.trim()])
70
+ if (isAncestor.error) {
71
+ return { status: 'unknown', behindCommits: null, daysSinceScan: null, sourceCommit,
72
+ message: `source_commit ${sourceCommit.slice(0, 7)} 不在当前分支历史(分支切换/rebase),跳过漂移判定` }
73
+ }
74
+ const countRes = safeGit(projectRoot, ['rev-list', '--count', `${sourceCommit}..HEAD`])
75
+ const behindCommits = countRes.error ? null : parseInt(countRes.value, 10)
76
+ if (behindCommits === null || Number.isNaN(behindCommits)) {
77
+ return { status: 'unknown', behindCommits: null, daysSinceScan: null, sourceCommit,
78
+ message: 'rev-list 计数失败,跳过漂移判定' }
79
+ }
80
+ const dateRes = safeGit(projectRoot, ['log', '-1', '--format=%cI', sourceCommit])
81
+ const daysSinceScan = dateRes.error ? null
82
+ : Math.floor((Date.now() - new Date(dateRes.value.trim()).getTime()) / 86400000)
83
+
84
+ const stale = behindCommits >= thresholds.commits || (daysSinceScan !== null && daysSinceScan >= thresholds.days)
85
+ if (!stale) {
86
+ return { status: 'fresh', behindCommits, daysSinceScan, sourceCommit,
87
+ message: `scan 文档无需刷新(落后 ${behindCommits} commit / ${daysSinceScan ?? '?'} 天,低于提示阈值)` }
88
+ }
89
+ return {
90
+ status: 'needs-refresh', behindCommits, daysSinceScan, sourceCommit,
91
+ message: `⚠️ scan 文档已落后源码 ${behindCommits} commit / ${daysSinceScan ?? '?'} 天(基线 ${sourceCommit.slice(0, 7)})。` +
92
+ `落后数≠文档错误——文档引用是否失效由 docs check 判定;旧快照可能未收录近期新增模块/流程,` +
93
+ `涉及结构/模块判断建议核对源码,或跑 sillyspec run scan --standard --force-rescan 刷新基线`,
94
+ }
95
+ }
@@ -39,7 +39,7 @@ import { hasAllSections, hasNoPlaceholderLine, lineCount, meetsMinLines, missing
39
39
  import { SCAN_REQUIRED_DOCS } from './constants.js'
40
40
 
41
41
  // custom kind 集合:判定算法留在各 validator(复杂条件),引擎跳过不计入 errors/warnings。
42
- export const CUSTOM_KINDS = new Set([
42
+ const CUSTOM_KINDS = new Set([
43
43
  'lifecycle-exemption', // design.md 生命周期关键词触发 + 多层豁免(validateBrainstormOutputs)
44
44
  'entry-point-wiring', // design.md 入口实例化 → task allowed_paths 覆盖对账(validatePlanOutputs)
45
45
  'id-traceability', // FR/D 引用追踪(extractIds / warnMissingIds)
@@ -47,6 +47,7 @@ export const CUSTOM_KINDS = new Set([
47
47
  'cross-task-contract', // task 卡片 provides/expects_from 对账(plan-postcheck)
48
48
  'design-file-coverage', // design.md 文件清单 vs allowed_paths 覆盖(plan-postcheck)
49
49
  'task-id-continuity', // task-NN id 连续性
50
+ 'task-plan-reconciliation', // plan.md 声明任务 ↔ tasks/ 卡片双向对账(plan-postcheck)
50
51
  'design-readiness', // design.md 进入 plan 前的章节就绪检查(validateDesignForPlan)
51
52
  'task-card-fields', // task-NN.md 卡片字段存在性(validateBlueprintConsistency + validatePlanFeasibility)
52
53
  'verify-conclusion-gate', // verify-result.md 结论 PASS/FAIL 门控
@@ -321,6 +322,20 @@ const PLAN_RULES = [
321
322
  spec: 'design.md「文件变更清单」中的每个源码文件,必须被至少一个 task 的 allowed_paths 覆盖(前缀/glob 容差匹配),否则 execute 子代理无权改它→必然漏改。有 task 卡片但 design 缺「文件变更清单」章节也阻断(清单是覆盖对账的基准)。',
322
323
  failMessage: 'design.md 文件变更清单中 ${count} 个文件未被任何 task 的 allowed_paths 覆盖:\n${files}\n 这些文件在 execute 阶段将无 task 有权修改 → 必然漏改。\n 修复:为每个遗漏文件新建/补充 task 并在其 allowed_paths 声明,或在 design.md「不修改文件」章节说明不改原因。',
323
324
  },
325
+ // task-plan-reconciliation(custom):plan.md 声明任务 ↔ tasks/ 卡片双向对账(债单 D-2b)。
326
+ // 修复场景:plan 列 17 个任务只生成 12 张卡(尾部文档同步类任务漏卡),execute 审计只看
327
+ // 卡片 → 漏卡任务零失败信号(sillyhub conversation-driven T-13~T-17 实证)。
328
+ // ${declared}/${cards} replaceAll。
329
+ {
330
+ id: 'plan.task-plan-reconciliation', stage: 'plan', source: 'validateBlueprintConsistency', severity: 'error', kind: 'task-plan-reconciliation',
331
+ target: { root: 'change', path: 'tasks' },
332
+ data: {
333
+ messageMissingCards: 'plan.md 声明了 ${declared} 个任务,但 tasks/ 只有部分卡片。缺卡: ${missing}\n 无卡片的任务不进 Wave、不受 execute 审计(allowed_paths/review.json 都不覆盖)→ 必然漏做(典型:尾部文档同步类任务)。\n 修复:为每个缺卡任务补 task-NN.md(含 allowed_paths/验收标准),或从 plan.md 删除该任务。',
334
+ messageOrphanCards: 'tasks/ 存在 plan.md 未声明的卡片: ${orphans}\n 这些任务不进任何 Wave,execute 不会执行。修复:在 plan.md 补声明,或删除孤儿卡片。',
335
+ },
336
+ spec: 'plan.md 中声明的每个 task-NN 必须有对应 tasks/task-NN.md 卡片(反向亦然),否则阻断——无卡片任务不受 execute 审计,必然漏做。',
337
+ failMessage: 'plan.md 任务与 tasks/ 卡片不对账',
338
+ },
324
339
  // task-id-continuity(custom):task-NN id 从 task-01 起连续。算法留 validatePlanFeasibility。
325
340
  // ${expected}/${actual} replaceAll(零填充 2 位)。
326
341
  {
@@ -12,6 +12,7 @@ import { detectChangeRisk, checkIntegrationEvidence, VERIFICATION_NEEDS, RISK_LE
12
12
  import { SCAN_REQUIRED_DOCS, AUXILIARY_STAGES } from './constants.js'
13
13
  import { evaluateRules } from './stage-contract-engine.js'
14
14
  import { getRule } from './stage-contract-spec.js'
15
+ import { parseAllowedPaths } from './stages/plan-postcheck.js'
15
16
 
16
17
  /**
17
18
  * 校验结果
@@ -381,11 +382,13 @@ function validatePlanOutputs(cwd, changeName, context = {}) {
381
382
  const taskFiles = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/i.test(f))
382
383
  for (const taskFile of taskFiles) {
383
384
  const taskContent = readFileSync(join(tasksDir, taskFile), 'utf8')
384
- const allowedSection = taskContent.match(/allowed_paths:\s*\n((?:\s+-\s+.+\n?)+)/)
385
- if (allowedSection) {
386
- const paths = allowedSection[1].match(/-\s+(.+)/g) || []
387
- for (const p of paths) allAllowedPaths.add(p.replace(/^-\s+/, '').trim().toLowerCase())
388
- }
385
+ // 复用 parseAllowedPaths(坑6①:认 inline 数组/缩进块/顶格块 + 剥反引号 + CRLF 归一,
386
+ // 消除此处内联正则与 plan-postcheck 的解析漂移)。卡片无 frontmatter 时退全文扫描
387
+ // (包裹伪 frontmatter 复用同一解析器)——原内联正则是全文匹配语义,entry-point-wiring
388
+ // 只关心路径覆盖,卡片格式合法性归 plan-postcheck 把关,此处不收紧。
389
+ const paths = parseAllowedPaths(taskContent)
390
+ const effective = paths.length > 0 ? paths : parseAllowedPaths(`---\n${taskContent}\n---`)
391
+ for (const p of effective) allAllowedPaths.add(p.toLowerCase())
389
392
  }
390
393
  }
391
394
  // 也从 plan.md 文件变更清单中收集
@@ -21,7 +21,7 @@ import { detectSpecDirTypo } from './spec-dir-typo.js'
21
21
  import { resolveRuntimeRoot } from './run/shared.js'
22
22
 
23
23
  // 文档型 stage review 的合法 reviewType
24
- export const STAGE_REVIEW_TYPES = ['design', 'plan', 'proposal', 'code', 'acceptance']
24
+ const STAGE_REVIEW_TYPES = ['design', 'plan', 'proposal', 'code', 'acceptance']
25
25
 
26
26
  // checklist 每项 result 的合法值
27
27
  const CHECKLIST_RESULTS = ['pass', 'gap', 'fail']
@@ -66,7 +66,6 @@ export const definition = {
66
66
  maxWaitRounds: 5,
67
67
  waitReason: '等待用户回答需求问题',
68
68
  waitOptions: ['回答见--answer', '信息够了,进入设计'],
69
- waitFreeAnswer: true, // 开放回答型:--answer 内容为自由文本(需求澄清回答),非封闭单选,豁免 enforceWaitChoice
70
69
  prompt: `分析需求,必要时追问,然后设计方案。
71
70
 
72
71
  ### 操作
@@ -32,6 +32,7 @@ export const definition = {
32
32
 
33
33
  ### 操作
34
34
  1. 读取项目总览 \`{SPEC_ROOT}/docs/<project>/scan/PROJECT.md\` + 共享规范 + 子项目上下文
35
+ - 漂移事实(CLI 算):{SCAN_STALENESS}
35
36
  2. 加载项目信息:\`cat {SPEC_ROOT}/projects/*.yaml 2>/dev/null\`
36
37
  3. 加载本地配置:\`cat {SPEC_ROOT}/local.yaml 2>/dev/null\`
37
38
  4. 棕地项目:读取 {SPEC_ROOT}/docs/<project>/scan/ 下的 STRUCTURE.md、CONVENTIONS.md、ARCHITECTURE.md
@@ -79,7 +80,6 @@ export const definition = {
79
80
  maxWaitRounds: 8,
80
81
  waitReason: '等待用户回答需求问题或澄清',
81
82
  waitOptions: ['回答见--answer', '信息够了,进入方案讨论'],
82
- waitFreeAnswer: true, // 开放回答型:--answer 内容为自由文本(需求澄清回答),非封闭单选,豁免 enforceWaitChoice
83
83
  prompt: `通过对话探索需求细节、分析原型(如有)、判断拆分/批量、并对歧义点做需求澄清 Grill。本步骤合并了原有的对话探索、原型分析、需求范围评估、需求澄清 Grill 四个环节——按下方 A→D 顺序按需执行。提问遵守「一次只问一个」(见 D 节追问策略):必须问的问题逐个问,能查代码/文档解决的不问;不要把多个歧义打包成一屏清单一次抛给用户(用户记不住、答不全,反而漏掉关键约束);也不要为凑数把单个问题拖成多轮。
84
84
 
85
85
  ### A. 原型/设计图分析(如用户提供)
@@ -330,8 +330,8 @@ design.md 第一行标题必须用中文:# 设计文档(Design)— <变更
330
330
  - 长期术语只在 archive/scan 时再提升到 \`{SPEC_ROOT}/docs/<project>/glossary.md\`
331
331
  4. 格式自检(只查章节齐全;语义一致性/可行性/YAGNI 不在本步查,交给下一步 Design Grill 独立审查):
332
332
  - design.md 含全部必填章节(背景/设计目标/非目标/总体方案/文件变更清单/接口定义/风险登记)
333
- - design.md 头部 YAML frontmatter 字段齐全(author/created_at/scale 等)——step8 完成契约硬要求,审查前就补齐避免后续 docHash 漂移
334
- - design.md 含「自审」字面命中章节(自审 / Self-Review / Self-review)——step8 完成契约 brainstorm.design.self-review 硬要求,写在本步自审段即可
333
+ - design.md 头部 YAML frontmatter 字段齐全(author/created_at/scale 等)——「生成规范文件」步完成契约硬要求,审查前就补齐避免后续 docHash 漂移
334
+ - design.md 含「自审」字面命中章节(自审 / Self-Review / Self-review)——「生成规范文件」步完成契约 brainstorm.design.self-review 硬要求,写在本步自审段即可
335
335
  - 如存在 decisions.md,design.md 是否引用所有当前版本 D-xxx@vN
336
336
  - 涉及 session/lease/agent_run/daemon/lifecycle 等关键词时,是否含「生命周期契约表」
337
337
  5. 缺章节 → 补齐后重检;章节齐全 → 进入下一步(Design Grill 做语义层交叉审查)