sillyspec 3.28.8 → 3.28.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 (61) hide show
  1. package/.claude/skills/sillyspec-execute/SKILL.md +222 -224
  2. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  3. package/.claude/skills/sillyspec-quick/SKILL.md +118 -117
  4. package/AGENTS.md +3 -2
  5. package/README.md +35 -11
  6. package/SKILL.md +24 -10
  7. package/SillySpec-/350/203/275/345/212/233/344/272/256/347/202/271/345/205/250/346/231/257-2026-09-14.pptx +0 -0
  8. package/package.json +1 -1
  9. package/src/change-risk-profile.js +20 -0
  10. package/src/config-schema.js +32 -0
  11. package/src/db.js +11 -3
  12. package/src/decision-distill.js +15 -3
  13. package/src/design-facts.js +33 -3
  14. package/src/docs-gate.js +105 -6
  15. package/src/doctor-diagnostics.js +224 -2
  16. package/src/friction-ledger.js +171 -0
  17. package/src/hooks/worktree-guard.js +13 -1
  18. package/src/index.js +188 -17
  19. package/src/knowledge-classify.js +363 -0
  20. package/src/knowledge-hits.js +74 -0
  21. package/src/knowledge-stats.js +180 -0
  22. package/src/progress/change-registry.js +183 -8
  23. package/src/progress/shared.js +3 -2
  24. package/src/progress.js +100 -8
  25. package/src/quick-gate-profile.js +251 -0
  26. package/src/quicklog.js +155 -14
  27. package/src/review-dispatch.js +2 -1
  28. package/src/run/command.js +107 -12
  29. package/src/run/complete-handlers.js +511 -21
  30. package/src/run/complete.js +54 -16
  31. package/src/run/concurrent-detect.js +98 -0
  32. package/src/run/gates.js +50 -9
  33. package/src/run/prompt.js +134 -5
  34. package/src/run/quick-audit.js +111 -13
  35. package/src/run/shared.js +76 -10
  36. package/src/run/stage.js +6 -0
  37. package/src/scan-diff.js +61 -30
  38. package/src/scan-postcheck.js +74 -0
  39. package/src/scan-refresh.js +477 -0
  40. package/src/scan-staleness.js +25 -13
  41. package/src/scope-audit.js +365 -35
  42. package/src/stage-contract.js +185 -2
  43. package/src/stage-review.js +23 -7
  44. package/src/stages/brainstorm.js +6 -0
  45. package/src/stages/execute.js +85 -3
  46. package/src/stages/knowledge.js +52 -3
  47. package/src/stages/quick.js +1 -1
  48. package/src/stages/verify.js +1 -0
  49. package/src/sync-noise.js +114 -6
  50. package/src/sync.js +135 -7
  51. package/src/task-review.js +292 -21
  52. package/src/verify-postcheck.js +20 -0
  53. package/src/verify-probes.js +340 -4
  54. package/src/worktree-apply.js +538 -44
  55. package/src/worktree-deps.js +100 -2
  56. package/src/worktree.js +117 -5
  57. package/templates/agents-instruction.md +2 -2
  58. package/templates/prompts/testcase-design.md +2 -1
  59. package/templates/prompts/verify-probes.md +4 -1
  60. package/.patch16-tmp.mjs +0 -169
  61. package/sa-rename-probe/src/new.js +0 -4
@@ -0,0 +1,477 @@
1
+ /**
2
+ * scan-refresh — scan 文档增量刷新(change: 2026-09-14-scan-incremental-refresh)
3
+ *
4
+ * 定位:scan 产物漂移的「刷新执行闭环」。信号侧四路已通(scan-staleness / scan diff /
5
+ * archive-delta 建议 / last-delta 回灌),但 agent 定点补文档后 source_commit 不推进——
6
+ * 漂移信号永久重报旧账。本模块补闭环的另一半(D-7 设计稿「agent 按清单定点补」形态):
7
+ *
8
+ * 两拍交互(D-002@v1):
9
+ * ① sillyspec scan refresh —— 门控 + 受影响文档集 + 手术工单渲染 + guard 握手写
10
+ * (agent 按工单定点编辑文档正文,worktree-guard 白名单放行)
11
+ * ② sillyspec scan refresh --done —— 内容比对门 + per-doc bump 盖章 + postcheck + 审计
12
+ *
13
+ * 分层(仿 scan-diff.js):
14
+ * computeRefreshPlan —— 计算层(git 只读 + frontmatter/module-map 读取 + guard 原子写)。
15
+ * 硬错误(门控拒绝)返回 { ok:false, kind, error, hint };advisory 进 warnings。
16
+ * runRefresh / finalizeRefresh —— IO 面(终端渲染 / --done 收尾),task-06 接线。
17
+ *
18
+ * 口径(D-008@v1):
19
+ * dirtyCheck——scope 非空限 module-map scope;scope 空回退全仓源码面(排除
20
+ * .sillyspec/node_modules/dist/build/.git——scan 文档自身预期脏不阻断)。
21
+ * 受影响集变更集——全量变更集**不经 scope 过滤**(staleRefs 同语义:引用自带范围)。
22
+ * 软门漂移计数——scope 过滤后 driftCount(与 scan diff 同口径)。
23
+ *
24
+ * 检出极限(D-006@v1,如实声明):staleRefs 只发现带 file:line 引用的过期陈述;
25
+ * 无引用论断检不出。refresh 只声称「核对至 HEAD 的检出项已处理」,不声称「一致」。
26
+ */
27
+ import { existsSync, readFileSync, readdirSync, mkdirSync } from 'node:fs'
28
+ import { join } from 'node:path'
29
+ import { createHash } from 'node:crypto'
30
+ import { safeGit } from './git-helper.js'
31
+ import { writeAtomicSync } from './fs-atomic.js'
32
+ import { parseSourceCommit } from './scan-staleness.js'
33
+ import { collectStaleRefs, parseNameStatus } from './scan-diff.js'
34
+ import { parseModuleMapSimple } from './modules.js'
35
+ import { SCAN_REQUIRED_DOCS } from './constants.js'
36
+
37
+ const GIT_TIMEOUT_MS = 10000
38
+
39
+ /** 软门阈值(D-005@v1):超限告警建议全量(--force 可越;工程默认值,量级仿 staleness 50/14 惯例放大) */
40
+ const REFRESH_SOFT_GATE = { maxDriftFiles: 100, maxBehindCommits: 200 }
41
+
42
+ /** 工单材料体积上限(FR-8 / R-04):超限降级为提示 agent 自行 git diff */
43
+ const MAX_HUNK_LINES_PER_DOC = 200
44
+ const MAX_COMMIT_LINES = 30
45
+
46
+ /** dirtyCheck 豁免前缀(scope 空回退全仓源码面时排除:进度库/依赖/产物/版本控制——D-008@v1) */
47
+ const DIRTY_FALLBACK_EXCLUDE = ['.sillyspec/', 'node_modules/', 'dist/', 'build/', '.git/']
48
+
49
+ /** scope 空回退时仍阻断的源码扩展名面(粗粒度:非二进制/产物即视为源码面) */
50
+ const SOURCE_EXT = /\.(js|mjs|cjs|ts|tsx|jsx|py|java|go|rs|rb|php|c|cpp|h|vue|svelte|json|ya?ml|toml|xml|sql|sh|md)$/i
51
+
52
+ /**
53
+ * 计算层:增量刷新计划。
54
+ * @param {{ projectRoot: string, specBase: string, projectName: string, force?: boolean }} opts
55
+ * projectRoot:git 锚;specBase/projectName:scan 文档定位(specBase/docs/<projectName>/scan)
56
+ * force:只越软门(漂移过大告警),不越硬门(D-005@v1)
57
+ * @returns {{ ok: true, head, headShort, behindCommits, scope, affectedDocs, freshDocs,
58
+ * warnings, workOrderHeadline }
59
+ * | { ok: false, kind: 'missing-args'|'no-baseline'|'non-ancestor'|'quick-depth'|'dirty-worktree'|'git-error', error, hint? }}
60
+ * affectedDocs:[{ file, base, staleRefs: [{ref,change,file}], hunks, commits, hunksTruncated }]
61
+ * (guard 握手文件已原子写——mode=scan-refresh + refreshDocs 白名单 + docHashes 内容哈希)
62
+ */
63
+ export function computeRefreshPlan({ projectRoot, specBase, projectName, force = false } = {}) {
64
+ if (!projectRoot || !specBase || !projectName) {
65
+ return { ok: false, kind: 'missing-args', error: 'projectRoot / specBase / projectName 必填,缺一不可' }
66
+ }
67
+ const warnings = []
68
+ const scanDir = join(specBase, 'docs', projectName, 'scan')
69
+ if (!existsSync(scanDir)) {
70
+ return { ok: false, kind: 'no-baseline', error: `无 scan 文档目录 ${scanDir}`,
71
+ hint: '先跑 sillyspec run scan 生成基线,增量刷新无出发锚点' }
72
+ }
73
+
74
+ // ── per-doc 基线收集(含 scan_depth,quick 浅文档硬门用)──
75
+ // 基线载体只认 7 份标准文档(SCAN_REQUIRED_DOCS)——scan 目录内的其他 .md
76
+ //(scan-diff-report.md 报告、NOTES 草稿、_ 前缀底稿)不是基线载体,不参与门控不进受影响集
77
+ //(它们无 frontmatter 属正常,若硬拦会让 refresh 被杂项文件永久卡死)。
78
+ const docEntries = [] // { file, base, depth }
79
+ for (const f of readdirSync(scanDir)) {
80
+ if (!SCAN_REQUIRED_DOCS.includes(f)) continue
81
+ let content = ''
82
+ try { content = readFileSync(join(scanDir, f), 'utf8') } catch { continue }
83
+ const base = parseSourceCommit(content)
84
+ if (!base) {
85
+ return { ok: false, kind: 'no-baseline', error: `scan 文档 ${f} 无 source_commit(绿地/旧版生成)`,
86
+ hint: '无基线不可增量——跑 sillyspec run scan --standard --force-rescan 重建基线' }
87
+ }
88
+ const depthMatch = content.match(/^scan_depth:\s*(\S+)\s*$/m)
89
+ // 基线归一 7 位:盖章写短哈希、旧文档可能带 40 位——fresh 比对与分组都以短哈希为键
90
+ docEntries.push({ file: f, base: base.slice(0, 7), depth: depthMatch ? depthMatch[1] : null })
91
+ }
92
+ if (docEntries.length === 0) {
93
+ return { ok: false, kind: 'no-baseline', error: 'scan 目录内无 .md 文档', hint: '先跑 sillyspec run scan' }
94
+ }
95
+
96
+ // ── HEAD 解析(非 git 仓/失败 fail-soft 拒绝,R-06)──
97
+ const headRes = safeGit(projectRoot, ['rev-parse', 'HEAD'])
98
+ if (headRes.error || !headRes.value) {
99
+ return { ok: false, kind: 'git-error', error: `git rev-parse HEAD 失败(${headRes.error || '无输出'})`,
100
+ hint: '非 git 仓不可增量刷新——走全量 sillyspec run scan' }
101
+ }
102
+ const head = headRes.value.trim()
103
+ const headShortRes = safeGit(projectRoot, ['rev-parse', '--short', 'HEAD'])
104
+ const headShort = headShortRes.error ? head.slice(0, 7) : headShortRes.value.trim()
105
+
106
+ // ── 硬门②:任一基线非 HEAD 祖先(分支切换/rebase,diff 两快照呈假象)──
107
+ for (const e of docEntries) {
108
+ const anc = safeGit(projectRoot, ['merge-base', '--is-ancestor', e.base, head])
109
+ if (anc.error) {
110
+ return { ok: false, kind: 'non-ancestor', error: `scan 文档 ${e.file} 的基线 ${e.base.slice(0, 7)} 不在当前分支历史(分支切换/rebase)`,
111
+ hint: '增量窗口脱锚不可算——跑 sillyspec run scan --standard --force-rescan 重建基线' }
112
+ }
113
+ }
114
+
115
+ // ── dirtyCheck(D-004@v1 fail-closed;scope 口径 D-008@v1)──
116
+ const moduleIndex = loadModuleIndex(specBase, projectName, warnings)
117
+ const scope = collectScope(moduleIndex)
118
+ const dirtyRes = checkDirtyWorktree(projectRoot, scope)
119
+ if (dirtyRes.length > 0) {
120
+ return { ok: false, kind: 'dirty-worktree',
121
+ error: `in-scope 路径有未提交改动 ${dirtyRes.length} 个:${dirtyRes.slice(0, 5).join(', ')}${dirtyRes.length > 5 ? ' …' : ''}`,
122
+ hint: '增量刷新只核对已提交面(base..HEAD)——先提交再重试,或走全量 sillyspec run scan(读工作区口径)' }
123
+ }
124
+ if (scope.length === 0) warnings.push('module-map 缺失/为空——dirtyCheck 回退全仓源码面,漂移范围无法归模块(建议先跑 sillyspec modules rebuild)')
125
+
126
+ // ── per-doc 分组 diff(D-003@v2 核心:每文档从自己的基线算漂移窗)──
127
+ const groups = new Map() // base → { files: [], changedFull: Map }
128
+ for (const e of docEntries) {
129
+ if (e.base === head.slice(0, 7)) continue // fresh:已在当前 HEAD(7 位归一口径)
130
+ if (!groups.has(e.base)) {
131
+ const diffRes = safeGit(projectRoot, ['diff', '--name-status', '--find-renames', `${e.base}..HEAD`], { timeout: GIT_TIMEOUT_MS })
132
+ if (diffRes.error) {
133
+ return { ok: false, kind: 'git-error', error: `git diff ${e.base.slice(0, 7)}..HEAD 失败(${diffRes.error})`,
134
+ hint: 'git 异常不可增量——走全量 sillyspec run scan' }
135
+ }
136
+ const changedFull = new Map() // POSIX path → A/D/M/R(R 旧路径入集,与 scan-diff staleRefs 同构)
137
+ for (const item of parseNameStatus(diffRes.value)) {
138
+ if (item.status === 'A' || item.status === 'D') changedFull.set(item.path, item.status)
139
+ else if (item.status === 'M' || item.status === 'C') changedFull.set(item.path, 'M')
140
+ else if (item.status === 'R') { changedFull.set(item.path, 'R'); changedFull.set(item.oldPath, 'R') }
141
+ }
142
+ groups.set(e.base, { files: parseNameStatus(diffRes.value), changedFull })
143
+ }
144
+ }
145
+
146
+ // ── 受影响文档集:全量变更集 × 各文档 file:line 引用命中(staleRefs 单源复用)──
147
+ const staleByDoc = new Map() // file → [{ref,change,file}]
148
+ for (const [base, g] of groups) {
149
+ const hits = collectStaleRefs(scanDir, g.changedFull) // 返回 [{doc,ref,change,file}]
150
+ for (const h of hits) {
151
+ // 只保留属于本基线组的文档(他组文档的命中由其所属组的全量集重算——组间变更集不同)
152
+ if (!docEntries.some(e => e.file === h.doc && e.base === base)) continue
153
+ if (!staleByDoc.has(h.doc)) staleByDoc.set(h.doc, [])
154
+ staleByDoc.get(h.doc).push({ ref: h.ref, change: h.change, file: h.file })
155
+ }
156
+ }
157
+
158
+ // ── 硬门③:受影响文档含 scan_depth: quick(浅文档本就该 --deep 全量升级)──
159
+ const affected = docEntries.filter(e => staleByDoc.has(e.file))
160
+ for (const e of affected) {
161
+ if (e.depth === 'quick') {
162
+ return { ok: false, kind: 'quick-depth', error: `受影响文档 ${e.file} 是 scan_depth: quick 浅层版本`,
163
+ hint: '浅文档应全量升级——跑 sillyspec run scan --deep --force-rescan(会识别 quick 标记覆盖重写)' }
164
+ }
165
+ }
166
+
167
+ // ── 软门(scope 过滤后漂移 / 落后数;--force 可越,D-005@v1/D-008@v1)──
168
+ let driftCount = 0
169
+ for (const [, g] of groups) {
170
+ for (const item of g.files) {
171
+ if (item.status !== 'A' && item.status !== 'D' && item.status !== 'M' && item.status !== 'R' && item.status !== 'C') continue
172
+ if (scope.length === 0 || inScope(item.path, scope)) driftCount++
173
+ }
174
+ }
175
+ let behindCommits = 0
176
+ {
177
+ const countRes = safeGit(projectRoot, ['rev-list', '--count', `${docEntries[0].base}..HEAD`], { timeout: GIT_TIMEOUT_MS })
178
+ if (!countRes.error) behindCommits = parseInt(countRes.value, 10) || 0
179
+ // 多基线取最大(最坏情况;fail-soft:计数失败保底 0 不阻断)
180
+ for (const base of groups.keys()) {
181
+ const r = safeGit(projectRoot, ['rev-list', '--count', `${base}..HEAD`], { timeout: GIT_TIMEOUT_MS })
182
+ if (!r.error) behindCommits = Math.max(behindCommits, parseInt(r.value, 10) || 0)
183
+ }
184
+ }
185
+ const softHit = driftCount > REFRESH_SOFT_GATE.maxDriftFiles || behindCommits > REFRESH_SOFT_GATE.maxBehindCommits
186
+ if (softHit) {
187
+ const msg = `漂移过大(scope 内 ${driftCount} 文件 / 落后 ${behindCommits} commit,阈值 ${REFRESH_SOFT_GATE.maxDriftFiles}/${REFRESH_SOFT_GATE.maxBehindCommits})——增量收益消失,建议全量 sillyspec run scan --standard --force-rescan`
188
+ if (force) warnings.push(`软门已 --force 越过:${msg}`)
189
+ else return { ok: false, kind: 'soft-gate', error: msg, hint: '确认增量继续:sillyspec scan refresh --force' }
190
+ }
191
+
192
+ // ── 工单材料(FR-8/R-04 截断)──
193
+ const affectedDocs = []
194
+ for (const e of affected) {
195
+ const refs = staleByDoc.get(e.file)
196
+ const refFiles = [...new Set(refs.map(r => r.file))]
197
+ let hunks = ''
198
+ let hunksTruncated = false
199
+ if (refFiles.length > 0) {
200
+ const d = safeGit(projectRoot, ['diff', `${e.base}..HEAD`, '--', ...refFiles], { timeout: GIT_TIMEOUT_MS })
201
+ if (!d.error && d.value) {
202
+ const lines = d.value.split('\n')
203
+ hunksTruncated = lines.length > MAX_HUNK_LINES_PER_DOC
204
+ hunks = lines.slice(0, MAX_HUNK_LINES_PER_DOC).join('\n')
205
+ }
206
+ }
207
+ const logRes = safeGit(projectRoot, ['log', '--oneline', `-n`, `${MAX_COMMIT_LINES}`, `${e.base}..HEAD`], { timeout: GIT_TIMEOUT_MS })
208
+ affectedDocs.push({ file: e.file, base: e.base, staleRefs: refs, hunks, hunksTruncated, commits: logRes.error ? '' : logRes.value.trim() })
209
+ }
210
+ const freshDocs = docEntries.filter(e => e.base === head.slice(0, 7)).map(e => e.file)
211
+
212
+ // ── guard 握手(D-007@v1:原子写——hook 并发读,防半截 JSON fail-closed 窗口)──
213
+ // 白名单成员资格=哈希在手:读失败的文档**不进白名单**(无哈希无法过 --done 内容比对门,
214
+ // 白名单放行无凭据——push 必须在哈希成功之后,防 fail-open)。该文档仍出现在工单里
215
+ //(affectedDocs 已含),但 hook 不放行编辑、--done 也不推进——保守方向与 D-009 一致。
216
+ const docHashes = {}
217
+ const refreshDocs = []
218
+ for (const e of affected) {
219
+ const rel = `docs/${projectName}/scan/${e.file}`
220
+ let hash = null
221
+ try { hash = sha256File(join(scanDir, e.file)) } catch { /* 读失败 → 不进白名单(保守) */ }
222
+ if (hash === null) continue
223
+ refreshDocs.push(rel)
224
+ docHashes[rel] = hash
225
+ }
226
+ if (refreshDocs.length > 0) {
227
+ try {
228
+ const guardDir = join(specBase, '.runtime')
229
+ mkdirSync(guardDir, { recursive: true })
230
+ writeAtomicSync(join(guardDir, 'scan-guard.json'), JSON.stringify({
231
+ name_zh: '增量刷新守卫',
232
+ mode: 'scan-refresh',
233
+ refreshDocs,
234
+ docHashes,
235
+ sourceCommit: headShort,
236
+ startedAt: new Date().toISOString(),
237
+ forceRescan: false,
238
+ }, null, 2) + '\n')
239
+ } catch (e) {
240
+ return { ok: false, kind: 'git-error', error: `guard 握手文件写入失败(${e.message})`,
241
+ hint: '编辑拍将无 hook 放行通道——检查 .runtime/ 可写性后重试' }
242
+ }
243
+ }
244
+
245
+ return {
246
+ ok: true, head, headShort, behindCommits, scope,
247
+ affectedDocs, freshDocs, warnings,
248
+ workOrderHeadline: affected.length === 0
249
+ ? `零检出漂移(staleRefs 0 命中)——检出极限内无需刷新;无引用论断不在此保证(D-006)`
250
+ : `受影响文档 ${affected.length} 份 / 过时引用 ${affectedDocs.reduce((n, d) => n + d.staleRefs.length, 0)} 条——只编辑下列文档的受影响小节,未命中内容原样保留`,
251
+ }
252
+ }
253
+
254
+ // ── 内部工具 ─────────────────────────────────────────────────────
255
+
256
+ function sha256File(p) {
257
+ return createHash('sha256').update(readFileSync(p)).digest('hex')
258
+ }
259
+
260
+ function loadModuleIndex(specBase, projectName, warnings) {
261
+ const mapPath = join(specBase, 'docs', projectName, 'modules', '_module-map.yaml')
262
+ if (!existsSync(mapPath)) return null
263
+ try { return parseModuleMapSimple(readFileSync(mapPath, 'utf8')) } catch {
264
+ warnings.push('module-map 解析失败——按缺失处理(dirtyCheck 回退全仓源码面)')
265
+ return null
266
+ }
267
+ }
268
+
269
+ /** module-map 全部 paths 去重归一(与 scan-diff collectScope 同口径;本模块内联防双向耦合) */
270
+ function collectScope(moduleIndex) {
271
+ const set = new Set()
272
+ for (const m of Object.values(moduleIndex || {})) {
273
+ if (!Array.isArray(m.paths)) continue
274
+ for (const raw of m.paths) {
275
+ if (typeof raw !== 'string' || !raw) continue
276
+ set.add(raw.replace(/\\/g, '/').replace(/\/+$/, ''))
277
+ }
278
+ }
279
+ return [...set]
280
+ }
281
+
282
+ function inScope(filePath, scope) {
283
+ return scope.some((p) => p && (filePath === p || filePath.startsWith(p + '/')))
284
+ }
285
+
286
+ /** dirtyCheck:scope 非空限 scope;空回退全仓源码面(D-008@v1)。返回 in-scope 脏路径清单 */
287
+ function checkDirtyWorktree(projectRoot, scope) {
288
+ const st = safeGit(projectRoot, ['status', '--porcelain'], { timeout: GIT_TIMEOUT_MS })
289
+ if (st.error || !st.value) return [] // git 失败 fail-soft 放行(后续 diff/门控仍会暴露 git 问题)
290
+ const dirty = []
291
+ for (const line of st.value.split('\n')) {
292
+ const l = line.replace(/\r$/, '')
293
+ if (!l) continue
294
+ // porcelain v1:`XY path`——状态列与路径间空格数不定(不同 git 版本对未暂存改动
295
+ // 输出 `M path` 或 ` M path`,固定 slice 会错位);rename 形如 `R old -> new` 取新路径
296
+ const m = l.match(/^\S+\s+(.+)$/)
297
+ if (!m) continue
298
+ let p = m[1].trim().replace(/^"(.*)"$/, '$1')
299
+ if (p.includes(' -> ')) p = p.split(' -> ').pop()
300
+ p = p.replace(/\\/g, '/')
301
+ if (!p) continue
302
+ if (scope.length > 0) {
303
+ if (inScope(p, scope)) dirty.push(p)
304
+ } else {
305
+ if (DIRTY_FALLBACK_EXCLUDE.some((pre) => p.startsWith(pre))) continue
306
+ if (SOURCE_EXT.test(p)) dirty.push(p)
307
+ }
308
+ }
309
+ return dirty
310
+ }
311
+
312
+ // ── IO 面(task-06)──────────────────────────────────────────────
313
+
314
+ /** 状态字母 → 人话(工单渲染) */
315
+ function changeLabel(c) {
316
+ return c === 'D' ? '文件已删除' : c === 'A' ? '文件为基线后新增' : c === 'R' ? '文件已重命名' : '文件已变更'
317
+ }
318
+
319
+ /**
320
+ * IO 面:第①拍——渲染手术工单。
321
+ * @param {{ projectRoot, specBase, projectName, force?, json? }} opts
322
+ * @returns {number} 退出码:0=工单就绪/零检出漂移;2=门控拒绝或错误
323
+ */
324
+ export async function runRefresh(opts = {}) {
325
+ const { projectRoot, specBase, projectName, force = false, json = false } = opts
326
+ const r = computeRefreshPlan({ projectRoot, specBase, projectName, force })
327
+ if (json) {
328
+ console.log(JSON.stringify(r, null, 2))
329
+ return r.ok ? 0 : 2
330
+ }
331
+ if (!r.ok) {
332
+ console.error(`scan refresh:${r.error}`)
333
+ if (r.hint) console.error(` → ${r.hint}`)
334
+ return 2
335
+ }
336
+ for (const w of r.warnings) console.warn(` ⚠️ ${w}`)
337
+ // 顺带重跑 scan facts(全量幂等,D-002@v1;fail-soft 不阻断工单)
338
+ try {
339
+ const { buildAndWriteScanFacts } = await import('./scan-facts.js')
340
+ await buildAndWriteScanFacts({ cwd: projectRoot, specDir: specBase, projectName })
341
+ console.log(' 📄 _facts.md 已重跑刷新')
342
+ } catch (e) {
343
+ console.warn(` ⚠️ scan facts 重跑失败(不阻断):${String((e && e.message) || e).split('\n')[0]}`)
344
+ }
345
+ console.log(`scan refresh(基线后 ${r.behindCommits} commit · HEAD ${r.headShort}):${r.workOrderHeadline}`)
346
+ if (r.freshDocs.length > 0) console.log(` 已在当前 HEAD(跳过):${r.freshDocs.join(', ')}`)
347
+ if (r.affectedDocs.length === 0) {
348
+ console.log('检出极限:本判定只覆盖带 file:line 引用的检出项,无引用论断不在保证范围(staleness advisory 仍有效)。')
349
+ return 0
350
+ }
351
+ console.log('')
352
+ for (const d of r.affectedDocs) {
353
+ console.log(`## ${d.file}(基线 ${d.base.slice(0, 7)})`)
354
+ console.log(`过时引用 ${d.staleRefs.length} 条:`)
355
+ for (const s of d.staleRefs) console.log(` - \`${s.ref}\`(${changeLabel(s.change)}:${s.file})`)
356
+ if (d.commits) {
357
+ console.log('基线后提交:')
358
+ for (const l of d.commits.split('\n')) console.log(` ${l}`)
359
+ }
360
+ if (d.hunks) {
361
+ console.log(`相关 diff${d.hunksTruncated ? '(已截断,完整自行 git diff)' : ''}:`)
362
+ for (const l of d.hunks.split('\n')) console.log(` ${l}`)
363
+ }
364
+ console.log('编辑纪律:只动受影响小节;未命中内容原样保留;新引用须真实可核验(--done 时 postcheck 逐条核验)。')
365
+ console.log('')
366
+ }
367
+ console.log('编辑完成后收尾:sillyspec scan refresh --done(只推进核对过的文档;未编辑文档默认不盖章)')
368
+ console.log('检出极限:收尾后只声称「核对至 HEAD 的检出项已处理」,不声称文档与源码一致。')
369
+ return 0
370
+ }
371
+
372
+ /**
373
+ * IO 面:第②拍——内容比对门 + per-doc bump + postcheck + 审计落盘。
374
+ * @param {{ projectRoot, specBase, projectName, docs?: string[]|null, force?: boolean, platformOpts?: object|null }} opts
375
+ * docs:显式点名(scan 目录内文件名,如 ARCHITECTURE.md);缺省=①拍工单全部受影响文档
376
+ * @returns {{ code: number, bumped: string[], skipped: {file:string,reason:string}[], postCheckStatus: string, auditPath: string|null }}
377
+ * code:0=盖章成功;1=postCheck failed(文档留待修复重跑);2=参数/guard/错误
378
+ */
379
+ export async function finalizeRefresh(opts = {}) {
380
+ const { projectRoot, specBase, projectName, docs = null, force = false, platformOpts = null } = opts
381
+ const scanDir = join(specBase, 'docs', projectName, 'scan')
382
+ // ①拍 guard 会话态必须存在(握手文件=工单受影响集与内容哈希的唯一来源)
383
+ let guard = null
384
+ const guardPath = join(specBase, '.runtime', 'scan-guard.json')
385
+ try { guard = JSON.parse(readFileSync(guardPath, 'utf8')) } catch { /* 缺失/坏 JSON → 下方拒绝 */ }
386
+ if (!guard || guard.mode !== 'scan-refresh') {
387
+ console.error('scan refresh --done:未找到增量刷新会话态(.runtime/scan-guard.json 无 mode=scan-refresh)')
388
+ console.error(' → 先跑 sillyspec scan refresh(①拍出工单),编辑完成后再 --done')
389
+ return { code: 2, bumped: [], skipped: [], postCheckStatus: null, auditPath: null }
390
+ }
391
+ const relOf = (name) => `docs/${projectName}/scan/${name}`
392
+ const explicit = Array.isArray(docs) && docs.length > 0
393
+
394
+ // HEAD 一致性门(重审 P1 修复):①拍→--done 之间 HEAD 被他者推进时,中间 commit 未核对
395
+ // 却会被盖章成「已核对至新 HEAD」——dirtyCheck 管不到这个形态(未提交→已提交未核对)。
396
+ // guard.sourceCommit 是①拍工单的基线锚,不等即拒绝(硬门:重跑①拍很便宜,不给 --force 通道)。
397
+ const nowHeadRes = safeGit(projectRoot, ['rev-parse', '--short', 'HEAD'])
398
+ const nowHeadShort = nowHeadRes.error ? null : nowHeadRes.value.trim()
399
+ if (nowHeadShort && guard.sourceCommit
400
+ && String(nowHeadShort).slice(0, 7) !== String(guard.sourceCommit).slice(0, 7)) {
401
+ console.error(`scan refresh --done:HEAD 已推进(①拍基线 ${String(guard.sourceCommit).slice(0, 7)} → 当前 ${nowHeadShort})——中间新 commit 未核对,不可盖章`)
402
+ console.error(' → 重跑 sillyspec scan refresh(①拍基于新 HEAD 重出工单),编辑后再 --done')
403
+ return { code: 2, bumped: [], skipped: [], postCheckStatus: null, auditPath: null }
404
+ }
405
+
406
+ // --docs 裸文件名校验(重审 P2 修复):relOf 纯拼接 + bump join 会归一化 ../,带路径分隔符/
407
+ // .. 的名字能穿越出 scan 目录(违反 D-001 写面限界)。只接受 scan 目录内裸文件名。
408
+ const isBareName = (name) => typeof name === 'string' && name.length > 0
409
+ && !name.includes('/') && !name.includes('\\') && !name.includes('..')
410
+
411
+ // 内容比对门(D-009@v1):默认面=工单受影响文档且内容有变者;未编辑文档不盖章
412
+ const candidates = explicit
413
+ ? docs.map((name) => ({ name }))
414
+ : (guard.refreshDocs || []).map((rel) => ({ name: rel.split('/').pop() }))
415
+ const skipped = []
416
+ const toBump = []
417
+ for (const { name } of candidates) {
418
+ if (!isBareName(name)) {
419
+ skipped.push({ file: name, reason: '非法文档名(仅接受 scan 目录内裸文件名,如 ARCHITECTURE.md——防路径穿越出写面)' })
420
+ continue
421
+ }
422
+ const rel = relOf(name)
423
+ const baselineHash = guard.docHashes && guard.docHashes[rel]
424
+ if (!explicit && !force && baselineHash) {
425
+ let curHash = null
426
+ try { curHash = sha256File(join(scanDir, name)) } catch { /* 读失败按未变处理(保守跳过) */ }
427
+ if (curHash === baselineHash) {
428
+ skipped.push({ file: name, reason: '内容未变(未编辑即盖章)——显式 --docs 点名或 --force 才推进' })
429
+ continue
430
+ }
431
+ }
432
+ toBump.push(name)
433
+ }
434
+ if (toBump.length === 0) {
435
+ console.log('scan refresh --done:无文档推进(全部未编辑/已点名为空)。工单未完成请先编辑,或显式 --docs 点名。')
436
+ return { code: 0, bumped: [], skipped, postCheckStatus: null, auditPath: null }
437
+ }
438
+
439
+ // per-doc bump(task-03 单源)+ postcheck(specDir 转换口径对齐 executeScanFinalize,D-009@v1)
440
+ const { bumpScanDocBaselines, runScanPostCheck, printScanPostCheckResult } = await import('./scan-postcheck.js')
441
+ const headShort = nowHeadShort // HEAD 一致性门已重取并校验过,此处复用同值
442
+ const bump = bumpScanDocBaselines({ cwd: projectRoot, specDir: platformOpts?.specRoot || specBase, project: projectName, docs: toBump, headShort })
443
+ console.log(` 📝 基线推进 ${bump.bumped.length} 份文档(source_commit→${headShort || '?'} / generator→sillyspec-scan-refresh)`)
444
+ for (const s of bump.skipped) console.log(` ⏭️ ${s.file}:${s.reason}`)
445
+ // specDir 传平台 specRoot(平台模式)或 null(本地模式——bump 用 specBase 参数,postcheck 用 null 走本地轻量)
446
+ const postCheck = runScanPostCheck({ cwd: projectRoot, specDir: platformOpts?.specRoot || null, scanProfile: null })
447
+ printScanPostCheckResult(postCheck)
448
+
449
+ // 审计(只写不读,取证面;平台模式经 resolveRuntimeRoot 定根)
450
+ let auditPath = null
451
+ try {
452
+ const { resolveRuntimeRoot } = await import('./run/shared.js')
453
+ const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
454
+ mkdirSync(runtimeRoot, { recursive: true })
455
+ const ts = new Date().toISOString().replace(/[-:T.]/g, '').slice(0, 14)
456
+ auditPath = join(runtimeRoot, `scan-refresh-${ts}.json`)
457
+ writeAtomicSync(auditPath, JSON.stringify({
458
+ changeType: 'scan-refresh',
459
+ head: headShort,
460
+ project: projectName,
461
+ bumped: bump.bumped,
462
+ skippedBump: bump.skipped,
463
+ skippedUnedited: skipped,
464
+ postCheckStatus: postCheck.status,
465
+ guardSourceCommit: guard.sourceCommit || null,
466
+ detectionLimit: '只覆盖带 file:line 引用的检出项;无引用论断不在保证范围(staleness advisory 仍有效)',
467
+ finishedAt: new Date().toISOString(),
468
+ }, null, 2) + '\n')
469
+ } catch (e) {
470
+ console.warn(` ⚠️ 审计落盘失败(不阻断):${e.message}`)
471
+ }
472
+ const code = postCheck.status === 'failed' ? 1 : 0
473
+ if (code === 0) {
474
+ console.log('检出极限:本次只声称「核对至 HEAD 的检出项已处理」,不声称文档与源码一致(staleness advisory 仍有效)。')
475
+ }
476
+ return { code, bumped: bump.bumped, skipped: [...skipped, ...bump.skipped], postCheckStatus: postCheck.status, auditPath }
477
+ }
@@ -46,35 +46,47 @@ export function computeScanStaleness(opts) {
46
46
  const scanDir = join(specBase, 'docs', projectName, 'scan')
47
47
  if (!existsSync(scanDir)) return null
48
48
 
49
- // 任一 scan 文档带 source_commit 即可代表整批(modules.js 同批写入同值)
50
- let sourceCommit = null
49
+ // D-003@v2(2026-09-14-scan-incremental-refresh):全文档收集再聚合取「落后最多」——
50
+ // 旧版「任一文档代表整批 break 首个命中」在 per-doc bump(增量刷新)后按 readdirSync
51
+ // 顺序随机读到新/旧基线:读到新基线则误报 fresh 掩盖其余旧文档,读到旧基线则永久噪声。
52
+ // 落后最多=最坏情况口径(宁多提醒不漏报);任一基线非 HEAD 祖先(分支切换/rebase)→
53
+ // 整体 unknown 跳过判定(该文档历史已脱锚,计数无意义)。
51
54
  let checked = 0
55
+ const commits = []
52
56
  for (const f of readdirSync(scanDir)) {
53
57
  if (!f.endsWith('.md')) continue
54
58
  checked++
55
59
  const c = parseSourceCommit(readFileSync(join(scanDir, f), 'utf8'))
56
- if (c) { sourceCommit = c; break }
60
+ if (c) commits.push(c)
57
61
  }
58
62
  if (checked === 0) return null
59
- if (!sourceCommit) {
63
+ if (commits.length === 0) {
60
64
  return { status: 'unknown', behindCommits: null, daysSinceScan: null, sourceCommit: null,
61
65
  message: 'scan 文档无 source_commit 字段(旧版生成),无法算漂移提示——文档引用是否失效以 docs check 为准;如架构已大改建议重跑 sillyspec run scan --standard' }
62
66
  }
63
67
 
64
68
  const head = safeGit(projectRoot, ['rev-parse', 'HEAD'])
65
69
  if (head.error || !head.value) {
66
- return { status: 'unknown', behindCommits: null, daysSinceScan: null, sourceCommit,
70
+ return { status: 'unknown', behindCommits: null, daysSinceScan: null, sourceCommit: null,
67
71
  message: `git 读取失败(${head.error || '无 HEAD'}),跳过漂移判定` }
68
72
  }
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
+ const headFull = head.value.trim()
74
+ const distinct = [...new Set(commits)]
75
+ let sourceCommit = null
76
+ let behindCommits = null
77
+ for (const c of distinct) {
78
+ const isAncestor = safeGit(projectRoot, ['merge-base', '--is-ancestor', c, headFull])
79
+ if (isAncestor.error) {
80
+ return { status: 'unknown', behindCommits: null, daysSinceScan: null, sourceCommit: c,
81
+ message: `source_commit ${c.slice(0, 7)} 不在当前分支历史(分支切换/rebase),跳过漂移判定` }
82
+ }
83
+ const countRes = safeGit(projectRoot, ['rev-list', '--count', `${c}..HEAD`])
84
+ const n = countRes.error ? null : parseInt(countRes.value, 10)
85
+ if (n === null || Number.isNaN(n)) continue // 单基点计数失败降级跳过,其余基点继续
86
+ if (behindCommits === null || n > behindCommits) { behindCommits = n; sourceCommit = c }
73
87
  }
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,
88
+ if (sourceCommit === null) {
89
+ return { status: 'unknown', behindCommits: null, daysSinceScan: null, sourceCommit: distinct[0],
78
90
  message: 'rev-list 计数失败,跳过漂移判定' }
79
91
  }
80
92
  const dateRes = safeGit(projectRoot, ['log', '-1', '--format=%cI', sourceCommit])