sillyspec 3.26.8 → 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 (50) 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-execute/SKILL.md +10 -2
  4. package/.claude/skills/sillyspec-explore/SKILL.md +0 -1
  5. package/.claude/skills/sillyspec-plan/SKILL.md +1 -2
  6. package/.claude/skills/sillyspec-quick/SKILL.md +1 -2
  7. package/.claude/skills/sillyspec-scan/SKILL.md +2 -2
  8. package/.claude/skills/sillyspec-status/SKILL.md +0 -1
  9. package/.claude/skills/sillyspec-verify/SKILL.md +1 -2
  10. package/README.md +5 -9
  11. package/package.json +2 -2
  12. package/packages/dashboard/server/index.js +14 -8
  13. package/src/change-risk-profile.js +5 -207
  14. package/src/config-schema.js +6 -1
  15. package/src/constants.js +97 -106
  16. package/src/db-engine.js +1 -1
  17. package/src/docs-check.js +34 -5
  18. package/src/git-helper.js +2 -2
  19. package/src/hooks/worktree-guard.js +1 -1
  20. package/src/index.js +96 -13
  21. package/src/init.js +14 -3
  22. package/src/machine-interface.js +7 -4
  23. package/src/progress/stage-machine.js +3 -0
  24. package/src/quicklog.js +182 -1
  25. package/src/run/command.js +101 -3
  26. package/src/run/complete.js +9 -1
  27. package/src/run/gates.js +67 -1
  28. package/src/run/prompt.js +721 -713
  29. package/src/run/quick-audit.js +146 -137
  30. package/src/run/shared.js +1197 -1109
  31. package/src/run/stage.js +17 -1
  32. package/src/scan-diff.js +388 -0
  33. package/src/scan-staleness.js +18 -13
  34. package/src/stage-contract-spec.js +1 -1
  35. package/src/stage-review.js +1 -1
  36. package/src/stages/brainstorm.js +2 -2
  37. package/src/stages/execute.js +991 -985
  38. package/src/stages/plan.js +22 -7
  39. package/src/stages/quick.js +1 -1
  40. package/src/stages/scan.js +2 -2
  41. package/src/stages/verify.js +10 -11
  42. package/src/sync.js +12 -0
  43. package/src/task-review.js +12 -4
  44. package/src/workflow.js +8 -185
  45. package/src/worktree-apply.js +122 -0
  46. package/src/worktree.js +2 -2
  47. package/templates/claude-instruction.md +1 -1
  48. package/templates/prompts/taskcard-rules.md +1 -0
  49. package/templates/prompts/verify-probes.md +2 -2
  50. package/templates/skills/sillyspec-onboard/SKILL.md +0 -92
package/src/run/stage.js CHANGED
@@ -25,6 +25,7 @@ import { outputStep } from './prompt.js'
25
25
  import { allocateQuicklogEntry, deriveTitleFromLinkedChange, sanitizeDesc } from '../quicklog.js'
26
26
  import { createHash } from 'node:crypto'
27
27
  import { checkTransition } from '../stage-contract.js'
28
+ import { AUXILIARY_STAGES } from '../constants.js'
28
29
  import { completeStageGates } from './gates.js'
29
30
 
30
31
  export async function runStage(pm, progress, stageName, cwd, changeName, skipApproval = false, platformOpts = {}, quickOpts = {}) {
@@ -107,6 +108,15 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
107
108
  } catch {}
108
109
  if (!currentExecuteRunId) {
109
110
  currentExecuteRunId = generateExecuteRunId()
111
+ // D-001#1 主写入点:mkdir execute-runs/<runId>/tasks 先于 marker(不变量:marker 在则目录在,
112
+ // archive 完成度扫描/漂移兜底不再落到「有 marker 无目录」的空 run)。失败直接 throw——execute
113
+ // 启动即失败优于事后 review 错配(调用方 runCommand 冒到 CLI 顶层 exit 1,给出修复指引)。
114
+ try {
115
+ mkdirSync(join(runtimeRoot, 'execute-runs', currentExecuteRunId, 'tasks'), { recursive: true })
116
+ } catch (e) {
117
+ throw new Error(`execute run 目录创建失败(${join(runtimeRoot, 'execute-runs', currentExecuteRunId)}): ${e.message};` +
118
+ `请检查该路径是否为普通文件/只读,清理后重跑(sillyspec run execute --change ${changeName} --skip-approval)`)
119
+ }
110
120
  writeFileSync(runIdFile, currentExecuteRunId + '\n')
111
121
  }
112
122
  }
@@ -125,8 +135,12 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
125
135
  }
126
136
 
127
137
  // 用户显式调用 sillyspec run <stage>:把它标记为当前阶段
138
+ // (D-003@v1:auxiliary 阶段不写 currentStage,避免 scan/quick/explore/archive/status/doctor
139
+ // 执行后污染主流程当前阶段;lastActive 心跳与 pm._write/triggerSync 照常)
128
140
  if (progress.currentStage !== stageName) {
129
- progress.currentStage = stageName
141
+ if (!AUXILIARY_STAGES.includes(stageName)) {
142
+ progress.currentStage = stageName
143
+ }
130
144
  progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
131
145
  pm._write(cwd, progress, changeName)
132
146
  triggerSync(cwd, changeName, platformOpts)
@@ -375,6 +389,8 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
375
389
  // 阶段完成收尾共享管线(noAI 末步核心修复 S1:plan postcheck independent-tier review verdict=fail /
376
390
  // 平台 scan manifest 此前被绕过)。gate 失败已 rollback 为 in-progress,early-return(不 fall through 到末尾 return)。
377
391
  const _stageGatesResult = await completeStageGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx, outputText: null })
392
+ // task-04 / A5:gate 失败(stageCompleted===false)设进程退出码 1(与 completeStep/continueStep 同语义)。
393
+ if (_stageGatesResult?.stageCompleted === false) process.exitCode = 1
378
394
  if (_stageGatesResult) return _stageGatesResult
379
395
  // gate 全过:persist completed(task-02 移后;此处无 triggerSync,与同文件 noAI 末步语义一致)。
380
396
  pm._write(cwd, progress, changeName)
@@ -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
+ }
@@ -1,19 +1,24 @@
1
1
  /**
2
- * scan-staleness — scan 文档漂移检测(债单 D-7 方案 A,change: 2026-08-15 ql-20260815-013)
2
+ * scan-staleness — scan 文档新鲜度提示(债单 D-7 方案 A,change: 2026-08-15 ql-20260815-013;
3
+ * 2026-08-16 ql-20260816-009-fb44 修正判定语义)
3
4
  *
4
5
  * 原则(doc-consistency-debt 第六节用户裁决):CLI 算事实注入——git 算出 scan 文档落后程度,
5
6
  * 注入给 agent 的是一行结论不是一段劝说指令。brainstorm「加载项目上下文」读 scan 文档前
6
- * 就知道"我读的架构描述可能过期",不再盲信。
7
+ * 就知道"我读的 scan 文档可能没跟上近期结构变更",按提示核对或重扫,而非盲信。
7
8
  *
8
- * 判定口径:scan 文档 frontmatter 的 source_commit(modules.js 写入、worktree-guard 覆盖
9
- * 保护同源字段)vs 当前 HEAD——`git rev-list --count <source_commit>..HEAD` + 提交时间差。
10
- * 纯读操作(git 只读 + frontmatter 解析),任何失败降级 null(advisory 信号不阻断流程)。
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 信号不阻断流程)。
11
16
  */
12
17
  import { existsSync, readFileSync, readdirSync } from 'node:fs'
13
18
  import { join } from 'node:path'
14
19
  import { safeGit } from './git-helper.js'
15
20
 
16
- /** 漂移阈值(commit 数 / 天数,超过任一即 stale;advisory 性质允许保守默认) */
21
+ /** 刷新提示阈值(commit 数 / 天数,超过任一即 needs-refresh;advisory 提示允许保守默认) */
17
22
  export const STALENESS_THRESHOLDS = { commits: 50, days: 14 }
18
23
 
19
24
  /**
@@ -31,7 +36,7 @@ export function parseSourceCommit(content) {
31
36
  /**
32
37
  * 计算单个 project 的 scan 文档漂移事实。
33
38
  * @param {{ projectRoot: string, specBase: string, projectName: string, thresholds?: { commits: number, days: number } }} opts
34
- * @returns {{ status: 'fresh'|'stale'|'unknown', behindCommits: number|null, daysSinceScan: number|null, sourceCommit: string|null, message: string }|null}
39
+ * @returns {{ status: 'fresh'|'needs-refresh'|'unknown', behindCommits: number|null, daysSinceScan: number|null, sourceCommit: string|null, message: string }|null}
35
40
  * null = 无 scan 文档目录(绿地项目等),调用方直接跳过;unknown = 有文档但算不出(无
36
41
  * source_commit 字段 / git 失败),message 说明降级原因。
37
42
  */
@@ -53,7 +58,7 @@ export function computeScanStaleness(opts) {
53
58
  if (checked === 0) return null
54
59
  if (!sourceCommit) {
55
60
  return { status: 'unknown', behindCommits: null, daysSinceScan: null, sourceCommit: null,
56
- message: 'scan 文档无 source_commit 字段(旧版生成),无法判漂移——如架构已大改建议重跑 sillyspec run scan --standard' }
61
+ message: 'scan 文档无 source_commit 字段(旧版生成),无法算漂移提示——文档引用是否失效以 docs check 为准;如架构已大改建议重跑 sillyspec run scan --standard' }
57
62
  }
58
63
 
59
64
  const head = safeGit(projectRoot, ['rev-parse', 'HEAD'])
@@ -79,12 +84,12 @@ export function computeScanStaleness(opts) {
79
84
  const stale = behindCommits >= thresholds.commits || (daysSinceScan !== null && daysSinceScan >= thresholds.days)
80
85
  if (!stale) {
81
86
  return { status: 'fresh', behindCommits, daysSinceScan, sourceCommit,
82
- message: `scan 文档新鲜(落后 ${behindCommits} commit / ${daysSinceScan ?? '?'} 天)` }
87
+ message: `scan 文档无需刷新(落后 ${behindCommits} commit / ${daysSinceScan ?? '?'} 天,低于提示阈值)` }
83
88
  }
84
89
  return {
85
- status: 'stale', behindCommits, daysSinceScan, sourceCommit,
86
- message: `⚠️ scan 文档过期:停在 ${sourceCommit.slice(0, 7)}(${daysSinceScan ?? '?'} 天前),期间源码已推进 ${behindCommits} commit。` +
87
- `本次加载的架构/结构描述可能失真——涉及结构变更的 design 不要盲信 scan 文档,以实读代码为准;` +
88
- `如需刷新基线跑 sillyspec run scan --standard --force-rescan`,
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 刷新基线`,
89
94
  }
90
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)
@@ -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']
@@ -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 做语义层交叉审查)