sillyspec 3.28.4 → 3.28.6

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 (52) hide show
  1. package/AGENTS.md +1 -0
  2. package/package.json +1 -1
  3. package/results-review-tier.md +18 -0
  4. package/sa-rename-probe/src/new.js +4 -0
  5. package/src/agent-session-log.js +4 -3
  6. package/src/archive-delta.js +2 -2
  7. package/src/change-list.js +11 -1
  8. package/src/commit-suggest.js +52 -1
  9. package/src/config-cat.js +6 -1
  10. package/src/config-schema.js +13 -0
  11. package/src/decision-distill.js +53 -7
  12. package/src/decisions-io.js +96 -0
  13. package/src/design-facts.js +3 -0
  14. package/src/dispatch/probe.js +31 -8
  15. package/src/docs-check.js +77 -8
  16. package/src/friction-tally.js +221 -0
  17. package/src/index.js +430 -18
  18. package/src/mcp-server.js +144 -0
  19. package/src/module-impact.js +8 -1
  20. package/src/modules.js +46 -34
  21. package/src/progress/change-registry.js +20 -0
  22. package/src/progress.js +16 -1
  23. package/src/quicklog.js +60 -6
  24. package/src/review-dispatch.js +377 -0
  25. package/src/run/archive-distill.js +48 -0
  26. package/src/run/command.js +35 -5
  27. package/src/run/complete-handlers.js +136 -3
  28. package/src/run/complete.js +170 -2
  29. package/src/run/gate-snapshot.js +159 -0
  30. package/src/run/gates.js +166 -69
  31. package/src/run/prompt.js +54 -9
  32. package/src/run/quick-audit.js +78 -16
  33. package/src/run/shared.js +138 -7
  34. package/src/run/stage.js +31 -2
  35. package/src/run/verify-quality-scan.js +383 -0
  36. package/src/scope-audit.js +941 -0
  37. package/src/sillyhub-mcp/client.js +58 -3
  38. package/src/stage-review-checklist.js +44 -0
  39. package/src/stage-review.js +122 -4
  40. package/src/stages/archive.js +57 -164
  41. package/src/stages/brainstorm.js +8 -12
  42. package/src/stages/execute.js +13 -10
  43. package/src/stages/plan.js +3 -10
  44. package/src/stages/verify.js +14 -33
  45. package/src/sync.js +51 -10
  46. package/src/task-review.js +108 -16
  47. package/src/taskcard.js +46 -3
  48. package/src/validate-artifacts.js +163 -0
  49. package/src/verify-postcheck.js +90 -10
  50. package/src/verify-probes.js +134 -12
  51. package/src/worktree-apply.js +119 -14
  52. package/templates/agents-instruction.md +1 -0
@@ -0,0 +1,941 @@
1
+ /**
2
+ * 变更范围对账纯函数模块(变更 2026-09-10-change-scope-audit task-01,design §总体方案 Wave 1)。
3
+ *
4
+ * 「计划改动 × 实际改动」机械对账的单一数据源(D-003 同源):scope-audit 命令(task-03)与
5
+ * execute --done / verify --done / archive --confirm / quick --done 四处注入(task-04 至 task-06)
6
+ * 全部只 import 本模块三个导出,禁止各自实现采集。
7
+ *
8
+ * 双模式自动判定(design 接口定义):
9
+ * - quick:changeName 匹配 quick-<8hex> 且 locateQuickSessionGuard 命中 → 重跑
10
+ * auditQuickCompletion 同款窗口归属(baseline 剔除/他者退栈/软归属),行数对未提交
11
+ * 工作区采集(git diff HEAD --numstat + untracked wc-l),baseAnchor='quick-window:<id>'。
12
+ * - full-flow:resolveReconcileActualFiles(verify-postcheck.js,task-02 已 export+baseAnchor)
13
+ * 出实际文件集与 diff 基点锚;计划侧解析 design.md 文件清单复用 change-list.js(零自研)。
14
+ *
15
+ * 行数三档(D-002):tracked → numstat;untracked 新文件 → wc-l 记全 + 行;binary(numstat
16
+ * 两列 '-')→ additions/deletions=null。三态(full-flow):planned / unplanned / untouched。
17
+ *
18
+ * 全 advisory(D-006):不抛错阻断、不写门禁状态;任何异常 catch 后并入 degradedReason。
19
+ * 纯读:不落盘、不改 guard/进度库。Windows 路径 \\→/ 归一,ESM-only,零新依赖。
20
+ */
21
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
22
+ import { join, dirname } from 'node:path'
23
+ import { locateQuickSessionGuard, auditQuickCompletion, resolveRuntimeRoot, collectOtherQuickSessionDeclarations, ancestorSpecDirs } from './run/shared.js'
24
+ import { safeGit } from './git-helper.js'
25
+ import { parseFileChangeListDetailed, pathMatches } from './change-list.js'
26
+
27
+ /** quick 会话 id 形态(与 run/command.js :652 QUICK_SID_RE 同款——crypto.randomUUID 前 8 hex) */
28
+ const QUICK_SID_RE = /^quick-[0-9a-f]{8}$/
29
+
30
+ /** 归一:git/porcelain/numstat 产物与调用方入参统一正斜杠(Windows 兼容) */
31
+ function toPosix(p) {
32
+ return String(p || '').replace(/\\/g, '/')
33
+ }
34
+
35
+ /**
36
+ * numstat rename 路径提取:`old => new`(整段)或 `prefix/{old => new}/suffix`(花括号折叠)
37
+ * 两种形态都取 rename 后的新路径;非 rename 原样返回。
38
+ * @param {string} raw numstat 第三列原文
39
+ * @returns {string}
40
+ */
41
+ function extractRenameTarget(raw) {
42
+ const brace = raw.match(/^(.*)\{([^{}]*)=>([^{}]*)\}(.*)$/)
43
+ if (brace) {
44
+ const merged = brace[1] + brace[3] + brace[4]
45
+ return merged.replace(/\/{2,}/g, '/')
46
+ }
47
+ const arrow = raw.indexOf(' => ')
48
+ if (arrow >= 0) return raw.slice(arrow + 4).trim()
49
+ return raw
50
+ }
51
+
52
+ /**
53
+ * wc -l 语义计行(untracked 新文件档):统计 \n 字符数,末行无换行符也计一行(逻辑行口径,
54
+ * advisory 展示够用)。0x0A 在合法 UTF-8 多字节序列中不出现,String 解码后计数等价于字节扫描。
55
+ * 读失败返回 null(不出伪数据)。
56
+ * @param {string} absPath
57
+ * @returns {number|null}
58
+ */
59
+ function countLinesOnDisk(absPath) {
60
+ try {
61
+ const buf = readFileSync(absPath)
62
+ if (buf.length === 0) return 0
63
+ const text = buf.toString('utf8')
64
+ let n = 0
65
+ for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) n++
66
+ return text.charCodeAt(text.length - 1) === 10 ? n : n + 1
67
+ } catch {
68
+ return null
69
+ }
70
+ }
71
+
72
+ /**
73
+ * 行数三档采集(quick 与 full-flow 共用,D-002 / R-03 单次 git 调用非逐文件)。
74
+ *
75
+ * - tracked 改动:`git diff --numstat <baseRef>` 单次调用(不带 pathspec:数百文件 pathspec
76
+ * 会超 Windows CreateProcess 命令行长度上限,全量输出后按 paths 过滤等价且更稳)。
77
+ * - numstat 不含 untracked:paths 中不在 numstat 输出的文件按盘上存在性走 wc-l 档
78
+ * (additions=全 + 行 / deletions=0 / kind='new')或 deleted 兜底(0/0)。
79
+ * - binary(numstat 两列 '-'):additions/deletions=null,kind='binary'。
80
+ * - numstat 命中且盘上已不存在 → kind='deleted'(tracked 删除);存在 → 'modified'。
81
+ *
82
+ * 降级:baseRef 缺失 / git 调用失败 → 空 Map(调用方按「行数列不可得」降级,不出伪数据)。
83
+ *
84
+ * @param {string} cwd git 调用与文件读取根(仓库根;full-flow 形态 A 传 worktree 根)
85
+ * @param {string[]} paths 仓库根相对路径(反斜杠自动归一)
86
+ * @param {{ baseRef?: string }} [opts] diff 基点(full-flow=baseAnchor hash;quick='HEAD')
87
+ * @returns {Map<string, {additions: number|null, deletions: number|null, kind: 'binary'|'new'|'modified'|'deleted'}>}
88
+ */
89
+ export function collectNumstatByPath(cwd, paths, { baseRef } = {}) {
90
+ const result = new Map()
91
+ const wanted = [...new Set((Array.isArray(paths) ? paths : []).map(toPosix).filter(Boolean))]
92
+ if (!cwd || wanted.length === 0 || !baseRef) return result
93
+
94
+ const numstatIndex = new Map()
95
+ const r = safeGit(cwd, ['diff', '--numstat', baseRef], { timeout: 30 * 1000 })
96
+ if (!r.error && typeof r.value === 'string') {
97
+ for (const line of r.value.split('\n')) {
98
+ if (!line) continue
99
+ const cols = line.split('\t')
100
+ if (cols.length < 3) continue
101
+ const additions = cols[0]
102
+ const deletions = cols[1]
103
+ const rawPath = cols.slice(2).join('\t')
104
+ const path = toPosix(extractRenameTarget(rawPath.trim()))
105
+ if (!path) continue
106
+ if (additions === '-' && deletions === '-') {
107
+ numstatIndex.set(path, { additions: null, deletions: null, kind: 'binary' })
108
+ } else {
109
+ const a = /^\d+$/.test(additions) ? Number(additions) : null
110
+ const d = /^\d+$/.test(deletions) ? Number(deletions) : null
111
+ // kind 由 addedSet/deletedSet 精判(下方采集),先占位 modified
112
+ numstatIndex.set(path, { additions: a, deletions: d, kind: 'modified' })
113
+ }
114
+ }
115
+ }
116
+
117
+ // 相对基点的新增/删除集合(kind 精判:已提交/已暂存的新 tracked 文件 numstat 命中但语义是
118
+ // 'new' 档)。固定常数次 git 调用(非逐文件,R-03 不破坏);失败 → 空 Set 走盘面兜底。
119
+ const listedSet = (filter) => {
120
+ const out = new Set()
121
+ const lr = safeGit(cwd, ['diff', '--name-only', '--diff-filter=' + filter, baseRef], { timeout: 30 * 1000 })
122
+ if (!lr.error && typeof lr.value === 'string') {
123
+ for (let p of lr.value.split('\n')) {
124
+ p = toPosix(p.replace(/^"|"$/g, '').trim())
125
+ if (p) out.add(p)
126
+ }
127
+ }
128
+ return out
129
+ }
130
+ const addedSet = listedSet('A')
131
+ const deletedSet = listedSet('D')
132
+
133
+ for (const [path, entry] of numstatIndex) {
134
+ if (entry.kind === 'binary') continue
135
+ if (addedSet.has(path)) entry.kind = 'new'
136
+ else if (deletedSet.has(path)) entry.kind = 'deleted'
137
+ else entry.kind = existsSync(join(cwd, path)) ? 'modified' : 'deleted'
138
+ }
139
+
140
+ for (const p of wanted) {
141
+ const hit = numstatIndex.get(p)
142
+ if (hit) { result.set(p, hit); continue }
143
+ // numstat 不含 untracked(git diff 只看 tracked):盘上存在 → 新文件 wc-l 档;不存在 → 删除兜底
144
+ if (existsSync(join(cwd, p))) {
145
+ const lines = countLinesOnDisk(join(cwd, p))
146
+ result.set(p, { additions: lines, deletions: 0, kind: 'new' })
147
+ } else {
148
+ result.set(p, { additions: 0, deletions: 0, kind: 'deleted' })
149
+ }
150
+ }
151
+ return result
152
+ }
153
+
154
+ /**
155
+ * QUICKLOG 是否已落该会话条目(「quick 已提交」降级判定):guard.quicklogId 在
156
+ * <specBase>/quicklog/*.md 中有条目头(`## <qlId> | ...`,quicklog.js allocateQuicklogEntry 契约)。
157
+ * @param {string} guardSpecBase guard 锚定的 spec 根(locateQuickSessionGuard 产物)
158
+ * @param {string|null} qlId guard.quicklogId
159
+ * @returns {boolean}
160
+ */
161
+ function quicklogHasEntry(guardSpecBase, qlId) {
162
+ if (!guardSpecBase || !qlId) return false
163
+ const dir = join(guardSpecBase, 'quicklog')
164
+ let files
165
+ try {
166
+ files = readdirSync(dir).filter(f => f.endsWith('.md'))
167
+ } catch {
168
+ return false
169
+ }
170
+ const marker = `## ${qlId} `
171
+ for (const f of files) {
172
+ try {
173
+ if (readFileSync(join(dir, f), 'utf8').includes(marker)) return true
174
+ } catch { /* 单文件读失败跳过 */ }
175
+ }
176
+ return false
177
+ }
178
+
179
+ /** 三态行数兜底档(baseAnchor=null / numstat 不可得时):不出伪行数,kind 从盘面可判则判 */
180
+ function degradedStat(root, path) {
181
+ return { additions: null, deletions: null, kind: existsSync(join(root, path)) ? 'modified' : 'deleted' }
182
+ }
183
+
184
+ /**
185
+ * 变更目录解析(活跃 → 归档两形态):归档后目录在 changes/archive/<名>/,计划侧 design.md
186
+ * 与「已归档」判定都从这里取(quick-8aa52289:归档变更 scope-audit 可查)。
187
+ * @returns {{ dir: string, archived: boolean }|null}
188
+ */
189
+ function resolveChangeDir(sb, changeName) {
190
+ const active = join(sb, 'changes', changeName)
191
+ if (existsSync(active)) return { dir: active, archived: false }
192
+ const archived = join(sb, 'changes', 'archive', changeName)
193
+ if (existsSync(archived)) return { dir: archived, archived: true }
194
+ return null
195
+ }
196
+
197
+ /**
198
+ * diff 执行根解析(computeFullFlowAudit 行数采集与 getFileDiff 单文件 diff 共用,防双实现):
199
+ * 形态 A(worktree 存活)→ worktree 工作树(改动在那、主仓工作树不是对比面);其余 → cwd。
200
+ */
201
+ function resolveDiffRoot(sb, changeName, form, cwd) {
202
+ if (form === 'worktree') {
203
+ try {
204
+ const meta = JSON.parse(readFileSync(join(sb, '.runtime', 'worktrees', changeName, 'meta.json'), 'utf8'))
205
+ if (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath)) {
206
+ return meta.worktreePath
207
+ }
208
+ } catch { /* 读失败 → cwd 兜底(advisory) */ }
209
+ }
210
+ return cwd
211
+ }
212
+
213
+ /**
214
+ * execute --done 时点快照读取(归档记录态数据源):结构即 computeFullFlowAudit 返回值 +
215
+ * savedAt。读取链(quick-359a48f1):变更目录 scope-audit.json(审计级,随归档入库)→
216
+ * .runtime/scope-audit-<change>.json(存量兼容)→ null(调用方降级,不出伪数据)。
217
+ */
218
+ function readScopeSnapshot(changeDir, runtimeRoot, changeName) {
219
+ if (changeDir) {
220
+ try {
221
+ const snap = JSON.parse(readFileSync(join(changeDir, 'scope-audit.json'), 'utf8'))
222
+ if (snap && typeof snap === 'object' && Array.isArray(snap.rows)) return snap
223
+ } catch { /* 缺失/损坏 → 下一源 */ }
224
+ }
225
+ try {
226
+ const snap = JSON.parse(readFileSync(join(runtimeRoot, `scope-audit-${changeName}.json`), 'utf8'))
227
+ if (snap && typeof snap === 'object' && Array.isArray(snap.rows)) return snap
228
+ } catch { /* 缺失/损坏 → null */ }
229
+ return null
230
+ }
231
+
232
+ /**
233
+ * 冻结 patch 读取(审计级存储,--file 已收尾切片的数据源):变更目录 scope-audit.patch。
234
+ * @returns {string|null}
235
+ */
236
+ function readFrozenPatch(changeDir) {
237
+ if (!changeDir) return null
238
+ try {
239
+ const text = readFileSync(join(changeDir, 'scope-audit.patch'), 'utf8')
240
+ return text && text.trim() ? text : null
241
+ } catch { return null }
242
+ }
243
+
244
+ /**
245
+ * patch 段过滤(quick-90015473):全量 diff 按 `diff --git a/<old> b/<new>` 段头切分,
246
+ * 只保留 files 集合内的段——json rows(退栈归属集)与 patch 同口径,多会话仓不把并行
247
+ * 会话改动重复冻结进每份 patch(实测 341KB 中 98% 为并行文件)。
248
+ */
249
+ function filterPatchForFiles(patchText, filesSet) {
250
+ if (!patchText) return ''
251
+ const lines = patchText.split('\n')
252
+ const out = []
253
+ let capturing = false
254
+ for (const line of lines) {
255
+ if (line.startsWith('diff --git ')) {
256
+ const m = line.match(/^diff --git a\/(.+) b\/(.+)$/)
257
+ const p = m ? toPosix(m[2]) : null
258
+ capturing = !!(p && filesSet.has(p))
259
+ }
260
+ if (capturing) out.push(line)
261
+ }
262
+ return out.join('\n')
263
+ }
264
+
265
+ /**
266
+ * 冻结 patch 生成(quick-359a48f1 审计级 / quick-90015473 归属口径修正):tracked 改动 =
267
+ * `git diff --no-color <baseRef>` 后**按 files 过滤段**(与 rows 同归属口径,不含并行会话
268
+ * 文件);untracked 新文件(git diff 天然不含)自拼 new file hunk(避 `diff --no-index`
269
+ * exit 1 坑),binary 只记标记行(防 base64 体积爆炸)。
270
+ * @param {string} root diff 执行根(execute=worktree 根 / quick=会话根 / 主仓根)
271
+ * @param {string[]} files 归属文件集(tracked 段过滤 + untracked 判定双重来源)
272
+ * @param {{ baseRef: string }} opts
273
+ * @returns {string|null} null=采集失败(调用方 fail-soft 不落伪 patch)
274
+ */
275
+ export function buildFrozenPatch(root, files, { baseRef } = {}) {
276
+ if (!root || !baseRef) return null
277
+ const wanted = new Set((Array.isArray(files) ? files : []).map(toPosix).filter(Boolean))
278
+ const parts = []
279
+ try {
280
+ const r = safeGit(root, ['diff', '--no-color', baseRef], { timeout: 60 * 1000 })
281
+ const filtered = filterPatchForFiles(r.error ? null : r.value, wanted)
282
+ if (filtered) parts.push(filtered)
283
+ const trackedOut = safeGit(root, ['ls-files'], { timeout: 30 * 1000 })
284
+ const trackedSet = new Set(String(trackedOut.value || '').split('\n').map(toPosix).filter(Boolean))
285
+ for (const f of (Array.isArray(files) ? files : []).map(toPosix).filter(Boolean)) {
286
+ if (trackedSet.has(f)) continue
287
+ const abs = join(root, f)
288
+ if (!existsSync(abs)) continue
289
+ let buf
290
+ try { buf = readFileSync(abs) } catch { continue }
291
+ if (buf.includes(0)) {
292
+ parts.push(`diff --git a/${f} b/${f}\nnew file mode 100644\nBinary file ${f} differs\n`)
293
+ continue
294
+ }
295
+ const lines = buf.toString('utf8').split('\n')
296
+ if (lines.length && lines[lines.length - 1] === '') lines.pop()
297
+ const body = lines.map(l => '+' + l).join('\n')
298
+ parts.push(`diff --git a/${f} b/${f}\nnew file mode 100644\n--- /dev/null\n+++ b/${f}\n@@ -0,0 +1,${lines.length} @@\n${body}\n`)
299
+ }
300
+ } catch {
301
+ return null
302
+ }
303
+ return parts.length > 0 ? parts.join('\n') : ''
304
+ }
305
+
306
+ /**
307
+ * patch 切片(--file 已收尾优先路径):从全量 patch 文本切出目标文件的 hunk 段
308
+ * (`diff --git a/<old> b/<new>` 到下一个 diff 头)。按 b/ 新路径匹配(rename 兼容旧路径)。
309
+ * @returns {string|null}
310
+ */
311
+ function slicePatchForFile(patchText, filePath) {
312
+ if (!patchText || !filePath) return null
313
+ const norm = toPosix(filePath)
314
+ const lines = patchText.split('\n')
315
+ const out = []
316
+ let capturing = false
317
+ for (const line of lines) {
318
+ if (line.startsWith('diff --git ')) {
319
+ const m = line.match(/^diff --git a\/(.+) b\/(.+)$/)
320
+ capturing = !!(m && (toPosix(m[2]) === norm || toPosix(m[1]) === norm))
321
+ }
322
+ if (capturing) out.push(line)
323
+ }
324
+ return out.length > 0 ? out.join('\n') : null
325
+ }
326
+
327
+ /**
328
+ * quick 记录态反查(guard 清理后):扫祖先链各 specBase 的 quicklog/patches/*.json,
329
+ * 按 json 内冗余的 sessionId 匹配。返回记录 + patch 路径(--file 切片源)。
330
+ */
331
+ function findQuickPatchRecord(cwd, sessionId) {
332
+ for (const sb of ancestorSpecDirs(cwd)) {
333
+ const dir = join(sb, 'quicklog', 'patches')
334
+ let files
335
+ try { files = readdirSync(dir) } catch { continue }
336
+ for (const f of files.filter(x => x.endsWith('.json'))) {
337
+ try {
338
+ const j = JSON.parse(readFileSync(join(dir, f), 'utf8'))
339
+ if (j && j.sessionId === sessionId && Array.isArray(j.rows)) {
340
+ return { record: j, patchPath: join(dir, f.replace(/\.json$/, '.patch')), qlId: j.qlId || f.replace(/\.json$/, '') }
341
+ }
342
+ } catch { /* 单文件损坏跳过 */ }
343
+ }
344
+ }
345
+ return null
346
+ }
347
+
348
+ /** 汇总非 null 行数(binary 与降级 null 不计入,不出伪数据) */
349
+ function sumTotals(rows) {
350
+ let additions = 0
351
+ let deletions = 0
352
+ for (const r of rows) {
353
+ if (Number.isFinite(r.additions)) additions += r.additions
354
+ if (Number.isFinite(r.deletions)) deletions += r.deletions
355
+ }
356
+ return { additions, deletions }
357
+ }
358
+
359
+ /**
360
+ * quick 模式对账:重跑 auditQuickCompletion 同款窗口归属(不改其判定语义,只消费结果)。
361
+ * 他者会话声明索引与 --done 收尾同源实时采集(complete-handlers :1050 同款 merge),保证
362
+ * foreignDeclared 豁免面与门禁口径一致(R-04:行数只对归属本会话的文件采集)。
363
+ */
364
+ async function computeQuickAudit({ cwd, platformOpts, sessionId, located, collectPatch }) {
365
+ const guardSpecBase = located.specBase
366
+ const guard = located.guard
367
+ // 审计在「创建会话的项目根」跑(located.specBase 父目录):cd 漂移后 cwd 可能是子目录,
368
+ // git 状态对根/子目录等价(同仓),但 wc-l / existsSync 需要仓库根。
369
+ const sessionRoot = dirname(guardSpecBase)
370
+
371
+ let audit = null
372
+ let auditFailure = null
373
+ try {
374
+ const mergedGuard = {
375
+ ...guard,
376
+ otherSessionsDeclared: collectOtherQuickSessionDeclarations(platformOpts, guardSpecBase, sessionId),
377
+ }
378
+ audit = await auditQuickCompletion(sessionRoot, mergedGuard, {})
379
+ } catch (e) {
380
+ auditFailure = e && e.message ? String(e.message).split('\n')[0] : String(e)
381
+ }
382
+
383
+ const base = {
384
+ mode: 'quick',
385
+ ok: true,
386
+ degradedReason: null,
387
+ baseAnchor: `quick-window:${sessionId}`,
388
+ totals: { files: 0, additions: 0, deletions: 0 },
389
+ rows: [],
390
+ excluded: { foreignDeclared: [] },
391
+ note: null,
392
+ }
393
+
394
+ if (!audit || typeof audit !== 'object') {
395
+ return { ...base, ok: false, degradedReason: `quick 窗口审计不可用: ${auditFailure || '未知异常'}` }
396
+ }
397
+ // auditQuickCompletion 的 git 锚点失败(status 读不到)会把「审计失败」推入 reasons——此时
398
+ // changedFiles 空是「读不到」不是「没改动」,不可当空表冒充实时。
399
+ const hardFailure = (audit.reasons || []).find(r => typeof r === 'string' && r.startsWith('审计失败'))
400
+ if (hardFailure) {
401
+ return { ...base, ok: false, degradedReason: `quick 窗口审计失败: ${hardFailure}` }
402
+ }
403
+
404
+ // 归属切分(消费 auditQuickCompletion 产物,不重算):attributedFiles(含 sameFileHits)→
405
+ // declared;undeclaredFiles − softTestFiles → undeclared;softTestFiles → soft。
406
+ const attributed = Array.isArray(audit.attributedFiles) ? audit.attributedFiles.map(toPosix) : []
407
+ const undeclared = Array.isArray(audit.undeclaredFiles) ? audit.undeclaredFiles.map(toPosix) : []
408
+ const softSet = new Set((Array.isArray(audit.softTestFiles) ? audit.softTestFiles : []).map(toPosix))
409
+ const attrSet = new Set(attributed)
410
+
411
+ const rowFiles = [...new Set([...attributed, ...undeclared])]
412
+ const stats = collectNumstatByPath(sessionRoot, rowFiles, { baseRef: 'HEAD' })
413
+
414
+ const rows = rowFiles.map(f => {
415
+ const st = stats.get(f) || degradedStat(sessionRoot, f)
416
+ const attribution = attrSet.has(f) ? 'declared' : (softSet.has(f) ? 'soft' : 'undeclared')
417
+ return {
418
+ path: f,
419
+ declared: attribution === 'declared',
420
+ additions: st.additions,
421
+ deletions: st.deletions,
422
+ kind: st.kind,
423
+ attribution,
424
+ }
425
+ })
426
+
427
+ const totals = { files: rows.length, ...sumTotals(rows) }
428
+
429
+ // 「quick 已提交」降级(design 总体方案):窗口空(changedFiles 空)且 QUICKLOG 已有该会话
430
+ // 条目 → 记录态提示读 QUICKLOG,不以空表冒充实时。
431
+ let note = null
432
+ if (rowFiles.length === 0) {
433
+ const qlId = guard.quicklogId || null
434
+ if (quicklogHasEntry(guardSpecBase, qlId)) {
435
+ note = `quick 会话无未提交改动(窗口已提交)——记录态请读 QUICKLOG 条目 ${qlId}(${toPosix(join(guardSpecBase, 'quicklog'))}/)`
436
+ } else {
437
+ note = 'quick 会话当前无未提交改动(QUICKLOG 亦无该会话条目——会话未产生改动或已被清理)'
438
+ }
439
+ }
440
+
441
+ const frozenPatch = collectPatch
442
+ ? buildFrozenPatch(sessionRoot, rowFiles, { baseRef: 'HEAD' })
443
+ : undefined
444
+
445
+ return {
446
+ ...base,
447
+ totals,
448
+ rows,
449
+ excluded: {
450
+ foreignDeclared: Array.isArray(audit.foreignSessionDeclared)
451
+ ? audit.foreignSessionDeclared.filter(x => x && typeof x === 'object')
452
+ : [],
453
+ },
454
+ frozenPatch,
455
+ note,
456
+ }
457
+ }
458
+
459
+ /**
460
+ * full-flow 模式对账:design.md 清单(change-list.js 解析,活跃/归档目录均可)×
461
+ * resolveReconcileActualFiles 实际文件集 → 三态 + 行数。降级路径:清单解析失败 → 实际侧
462
+ * only;baseAnchor 缺失 → 行数按 HEAD 未提交窗口兜底(quick 模式同口径,不再恒 —);
463
+ * 归档且实时窗口空 → execute 时点快照记录态(快照也缺 → 空表诚实说明)。
464
+ */
465
+ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, collectPatch }) {
466
+ const sb = specBase || join(cwd, '.sillyspec')
467
+ const runtimeRoot = resolveRuntimeRoot(platformOpts, sb)
468
+ const changeDirInfo = resolveChangeDir(sb, changeName)
469
+
470
+ // —— 预执行形态(quick-bbb5037c):执行证据三信号(worktree meta / 分支 sillyspec/<名> /
471
+ // 审计 tag)全无 = 尚未进入 execute——实际侧不存在(B2 会吞整个工作区脏文件误归属本变更,
472
+ // 2026-09-11-cross-change-decision-guard 实证 28 个「计划外」全是并行在途)。出计划清单
473
+ // 视图:rows=design 清单 untouched(待实现),工作区改动归属各自会话不进表。
474
+ const hasWorktreeMeta = existsSync(join(sb, '.runtime', 'worktrees', changeName, 'meta.json'))
475
+ const hasBranch = !!safeGit(cwd, ['rev-parse', '--verify', '--quiet', `sillyspec/${changeName}^{commit}`], { timeout: 15 * 1000 }).value
476
+ const hasAuditTag = !!safeGit(cwd, ['rev-parse', '--verify', '--quiet', `sillyspec-audit/sillyspec/${changeName}^{commit}`], { timeout: 15 * 1000 }).value
477
+ if (!hasWorktreeMeta && !hasBranch && !hasAuditTag) {
478
+ const emptyBase = {
479
+ mode: 'full-flow', ok: true, degradedReason: null, baseAnchor: null,
480
+ totals: { files: 0, additions: 0, deletions: 0 }, rows: [],
481
+ excluded: { foreignDeclared: [] }, frozenPatch: undefined,
482
+ }
483
+ let prePlanned = []
484
+ try {
485
+ prePlanned = parseFileChangeListDetailed(
486
+ join(changeDirInfo ? changeDirInfo.dir : join(sb, 'changes', changeName), 'design.md'),
487
+ { keepSillyspecDocs: true })
488
+ } catch { /* 清单解析失败 → 空清单 + 说明 */ }
489
+ const preRows = prePlanned.map(e => ({
490
+ path: e.path, planned: e.operation || null, additions: 0, deletions: 0,
491
+ kind: 'modified', verdict: 'untouched',
492
+ }))
493
+ return {
494
+ ...emptyBase,
495
+ totals: { files: preRows.length, additions: 0, deletions: 0 },
496
+ rows: preRows,
497
+ note: preRows.length > 0
498
+ ? '变更尚未进入 execute(无 worktree/分支/审计 tag)——下表为 design 清单(全部待实现);工作区其他未提交改动归属各自会话/变更,不在本表(若确已执行过但三信号均被手工清理,此处会误判——git log 核实)'
499
+ : '变更尚未进入 execute(无 worktree/分支/审计 tag)且 design 无可解析清单——暂无可对账内容',
500
+ }
501
+ }
502
+
503
+ // —— 计划侧(单一真相:change-list.js,禁自研表格解析;归档形态目录兼容)——
504
+ const designMdPath = join(changeDirInfo ? changeDirInfo.dir : join(sb, 'changes', changeName), 'design.md')
505
+ let plannedEntries = []
506
+ let planDegraded = null
507
+ try {
508
+ // keepSillyspecDocs=true:与 actual 侧 filterDeliverableFiles 保留 .sillyspec/docs/** 交付物
509
+ // 的口径对齐(dogfood 模块文档=交付物)
510
+ plannedEntries = parseFileChangeListDetailed(designMdPath, { keepSillyspecDocs: true })
511
+ } catch (e) {
512
+ planDegraded = `design.md 清单解析异常: ${e && e.message ? String(e.message).split('\n')[0] : e}`
513
+ }
514
+ if (!planDegraded && plannedEntries.length === 0) {
515
+ planDegraded = existsSync(designMdPath)
516
+ ? 'design.md 无「文件变更清单」章节或清单为空——降级实际侧 only 视图(不出三态列)'
517
+ : `design.md 不存在(${toPosix(designMdPath)})——降级实际侧 only 视图(不出三态列)`
518
+ }
519
+
520
+ // —— 实际侧(verify-postcheck.js task-02 契约:files + baseAnchor;动态 import 隔离
521
+ // 并行时序——task-02 未落地时降级说明而非炸)——
522
+ let actual = null
523
+ let actualFailure = null
524
+ try {
525
+ const mod = await import('./verify-postcheck.js')
526
+ if (typeof mod.resolveReconcileActualFiles !== 'function') {
527
+ throw new Error('resolveReconcileActualFiles 未导出(依赖契约未就绪)')
528
+ }
529
+ actual = mod.resolveReconcileActualFiles({ cwd, specBase: sb, runtimeRoot, changeName })
530
+ } catch (e) {
531
+ actualFailure = e && e.message ? String(e.message).split('\n')[0] : String(e)
532
+ }
533
+
534
+ const base = {
535
+ mode: 'full-flow',
536
+ ok: false,
537
+ degradedReason: null,
538
+ baseAnchor: null,
539
+ totals: { files: 0, additions: 0, deletions: 0 },
540
+ rows: [],
541
+ excluded: { foreignDeclared: [] },
542
+ note: null,
543
+ }
544
+ let noSnapshotDrift = false
545
+
546
+ if (!actual || actual.ok === false) {
547
+ // 实际侧整体失败:三态无锚不出表(fail-soft 单行提示由注入点兑现),计划侧降级信息一并带出
548
+ const reason = actualFailure
549
+ || (actual && actual.degradedReason)
550
+ || '实际侧文件集解析失败'
551
+ return { ...base, degradedReason: [reason, planDegraded].filter(Boolean).join(';') }
552
+ }
553
+
554
+ // —— 已收尾变更·冻结快照优先(真实 > 冻结记录 > 实时开放区间):归档变更的范围应封闭在
555
+ // apply 时点,不随后续主仓改动漂移(quick-f5acdeeb);quick-bd84b852 放宽——**post-apply
556
+ // (分支已删 = execute 已 apply 收尾)的活跃变更同样收尾**,此后实时区间只会被并行演进泡大
557
+ // (multi-agent-platform 2026-09-04 滞留变更实证:741 文件零提示刷屏)。execute --done 落的
558
+ // 快照即冻结记录(含行数)——已收尾一律先出快照;实时窗口(开放区间)只作快照缺失的兜底,
559
+ // note 明示会漂。tag 封闭区间不可用:分支上仅 baseline checkpoint(并行 WIP 快照),本变更
560
+ // 改动经工作树 apply 从未 commit 到分支。 ——
561
+ const settled = changeDirInfo && (changeDirInfo.archived || actual.form === 'post-apply')
562
+ if (settled) {
563
+ const snap = readScopeSnapshot(changeDirInfo.dir, runtimeRoot, changeName)
564
+ if (snap && snap.rows.length > 0) {
565
+ const settleLabel = changeDirInfo.archived ? '已归档' : 'execute 已收尾(分支已清理,待 verify/archive)'
566
+ // 行数补采:快照可能落盘于 apply 后、tag 锚落地前的窗口(行数列全 —);tag 锚
567
+ // (quick-df1fed77)恢复 merge-base 后,对冻结文件集按锚补采行数——文件集不重算
568
+ // (冻结语义不变),仅行数从 — 复活;含主仓后续演进(同文件被再改会计入),口径标注。
569
+ let rows = snap.rows
570
+ let snapDegraded = snap.degradedReason || null
571
+ const needsStats = rows.some(r => r && !Number.isFinite(r.additions) && r.kind !== 'binary')
572
+ if (needsStats && actual && actual.ok && actual.baseAnchor) {
573
+ const paths = rows.map(r => (r && r.path ? toPosix(r.path) : '')).filter(Boolean)
574
+ const stats = collectNumstatByPath(cwd, paths, { baseRef: actual.baseAnchor })
575
+ let recovered = 0
576
+ rows = rows.map(r => {
577
+ if (!r || !r.path || Number.isFinite(r.additions) || r.kind === 'binary') return r
578
+ const st = stats.get(toPosix(r.path))
579
+ if (!st) return r
580
+ recovered++
581
+ return { ...r, additions: st.additions, deletions: st.deletions, kind: st.kind }
582
+ })
583
+ if (recovered > 0) snapDegraded = null
584
+ return {
585
+ ...base,
586
+ ok: true,
587
+ degradedReason: snapDegraded,
588
+ baseAnchor: actual.baseAnchor,
589
+ totals: { files: rows.length, ...sumTotals(rows) },
590
+ rows,
591
+ excluded: snap.excluded && Array.isArray(snap.excluded.foreignDeclared)
592
+ ? snap.excluded
593
+ : { foreignDeclared: [] },
594
+ note: `${settleLabel}——execute --done 时点冻结快照${snap.savedAt ? '(' + String(snap.savedAt).replace('T', ' ').slice(0, 19) + ' 落盘)' : ''}:文件集封闭在 apply 时点,主仓后续新文件不进表;行数按锚 ${actual.baseAnchor.slice(0, 7)}→当前工作树 补采(同文件后续演进会计入)`,
595
+ }
596
+ }
597
+ return {
598
+ ...base,
599
+ ok: true,
600
+ degradedReason: snapDegraded,
601
+ baseAnchor: typeof snap.baseAnchor === 'string' ? snap.baseAnchor : null,
602
+ totals: snap.totals && typeof snap.totals === 'object'
603
+ ? snap.totals
604
+ : { files: snap.rows.length, ...sumTotals(snap.rows) },
605
+ rows: snap.rows,
606
+ excluded: snap.excluded && Array.isArray(snap.excluded.foreignDeclared)
607
+ ? snap.excluded
608
+ : { foreignDeclared: [] },
609
+ note: `${settleLabel}——execute --done 时点冻结快照${snap.savedAt ? '(' + String(snap.savedAt).replace('T', ' ').slice(0, 19) + ' 落盘)' : ''}:范围封闭在 apply 时点,主仓后续改动不反映到本表;需要看当前工作区实时状态请跑 git status`,
610
+ }
611
+ }
612
+ // 快照缺失 → 实时开放区间兜底(下方主链路),note 追加漂移警告
613
+ noSnapshotDrift = true
614
+ }
615
+
616
+ const degraded = []
617
+ if (planDegraded) degraded.push(planDegraded)
618
+ const notes = []
619
+ // 已收尾但快照缺失(上方分支置位):实时开放区间兜底,明示漂移语义——快照机制上线
620
+ // (2026-09-10)前收尾的旧变更冻结记录无法重建,如实告知替代渠道。
621
+ if (noSnapshotDrift) {
622
+ notes.push('快照缺失(变更已收尾——已归档或 execute 分支已清理;快照机制上线前收尾的变更无法重建冻结记录)——下表为实时开放区间(基点→当前工作树,含并行会话与后续演进,随主仓改动漂移),非本变更冻结范围;记录态参考 verify-result.md / git log')
623
+ }
624
+
625
+ // —— 行数采集根与基点 ——
626
+ // 形态 A(worktree 存活):改动在 worktree 工作树,baseAnchor 是 meta 锚 commit——numstat
627
+ // 必须对 worktree 跑(worktree 共享主仓对象库可解析锚 commit;对主仓跑会把主仓工作树当对比面)。
628
+ // 判定与 resolveVerifyChangedFiles :1048 同款(读同一份 meta,非另造口径)。
629
+ const numstatRoot = resolveDiffRoot(sb, changeName, actual.form, cwd)
630
+
631
+ // 无锚点兜底(post-apply 分支已清理 / 归档形态):行数按 HEAD 未提交窗口采集——与 quick 模式
632
+ // 同口径(git diff HEAD --numstat 只含未提交改动,untracked 走 wc-l 档);不再恒降级 —。
633
+ let stats = new Map()
634
+ let usedHeadFallback = false
635
+ if (actual.baseAnchor) {
636
+ stats = collectNumstatByPath(numstatRoot, actual.files, { baseRef: actual.baseAnchor })
637
+ } else if ((actual.files || []).length > 0) {
638
+ stats = collectNumstatByPath(numstatRoot, actual.files, { baseRef: 'HEAD' })
639
+ usedHeadFallback = true
640
+ notes.push('baseAnchor 缺失——行数按 HEAD 未提交窗口采集(不含已提交改动,quick 模式同口径;numstat 采集失败时行列为 —)')
641
+ }
642
+
643
+ // 他者声明排除面可见(R-04 同精神):resolveReconcileActualFiles 只回计数不回名单,退栈的
644
+ // 计划内文件会误显「计划未动」——note 点破归属重叠可能性,让 ⚠️ 行可解释。
645
+ if (actual.foreignExcluded > 0) {
646
+ notes.push(`实际侧另有 ${actual.foreignExcluded} 个文件按他者会话声明退栈未进本表——「计划未动」行先怀疑归属重叠(声明即归属,git diff 核实)`)
647
+ }
648
+
649
+ // —— 三态判定 ——
650
+ const rows = []
651
+ const matchedPlanned = new Set()
652
+ for (const f of (Array.isArray(actual.files) ? actual.files : [])) {
653
+ const path = toPosix(f)
654
+ const st = stats.get(path) || degradedStat(numstatRoot, path)
655
+ const row = { path, additions: st.additions, deletions: st.deletions, kind: st.kind }
656
+ if (!planDegraded) {
657
+ // 双向容差匹配(change-list.js pathMatches:相等/目录前缀/glob——design 清单可写 glob)
658
+ const entry = plannedEntries.find(e => pathMatches(path, e.path))
659
+ if (entry) {
660
+ matchedPlanned.add(entry.path)
661
+ row.planned = entry.operation || null
662
+ row.verdict = 'planned'
663
+ } else {
664
+ row.planned = null
665
+ row.verdict = 'unplanned'
666
+ }
667
+ }
668
+ rows.push(row)
669
+ }
670
+ // 计划未动:清单文件无实际改动 → 补行(行数 0/0);计划侧降级时不产(plannedEntries 为空天然不跑)
671
+ for (const e of plannedEntries) {
672
+ if (matchedPlanned.has(e.path)) continue
673
+ rows.push({ path: e.path, planned: e.operation || null, additions: 0, deletions: 0, kind: 'modified', verdict: 'untouched' })
674
+ }
675
+
676
+ // 冻结 patch(collectPatch,execute --done 落盘方消费):与行数同根同锚(numstatRoot +
677
+ // baseAnchor;无锚 HEAD 兜底同口径),全窗口 diff + untracked 自拼 hunk(buildFrozenPatch)
678
+ const frozenPatch = collectPatch
679
+ ? buildFrozenPatch(numstatRoot, actual.files, { baseRef: actual.baseAnchor || 'HEAD' })
680
+ : undefined
681
+
682
+ return {
683
+ ...base,
684
+ ok: true,
685
+ degradedReason: degraded.length > 0 ? degraded.join(';') : null,
686
+ baseAnchor: actual.baseAnchor || (usedHeadFallback ? 'head-uncommitted-window' : null),
687
+ totals: { files: rows.length, ...sumTotals(rows) },
688
+ rows,
689
+ excluded: { foreignDeclared: [] },
690
+ frozenPatch,
691
+ note: notes.length > 0 ? notes.join(';') : null,
692
+ }
693
+ }
694
+
695
+ /**
696
+ * 变更范围对账纯函数(scope-audit 命令与三处阶段注入的唯一数据源,D-003)。
697
+ *
698
+ * 模式自动判定:changeName 匹配 quick-<8hex> 且 locateQuickSessionGuard 命中 → quick;
699
+ * 形态命中但 guard 缺失 → ok=false(命令层 exit 1 提示会话不存在);否则 full-flow。
700
+ *
701
+ * 全 advisory(D-006):不抛错、不写门禁状态;任何内部异常 catch 后并入 degradedReason。
702
+ * 纯读无落盘。返回结构见 design.md 接口定义(--json 直接序列化)。
703
+ *
704
+ * @param {{ cwd: string, specBase?: string|null, changeName: string, platformOpts?: object|null }} opts
705
+ * - cwd:仓库根(git 调用根;quick 模式实际用会话创建根——祖先链定位防 cd 漂移)
706
+ * - specBase:.sillyspec 根(缺省 join(cwd,'.sillyspec');平台模式传 specRoot)
707
+ * - platformOpts:{ runtimeRoot?, specDriftAnchor? }(resolveRuntimeRoot 消费,可 null)
708
+ * @returns {Promise<{
709
+ * mode: 'quick'|'full-flow', ok: boolean, degradedReason: string|null, baseAnchor: string|null,
710
+ * totals: { files: number, additions: number, deletions: number },
711
+ * rows: Array<{ path: string, planned?: string|null, declared?: boolean,
712
+ * additions: number|null, deletions: number|null,
713
+ * kind: 'binary'|'new'|'modified'|'deleted',
714
+ * verdict?: 'planned'|'unplanned'|'untouched', attribution?: 'declared'|'soft'|'undeclared' }>,
715
+ * excluded: { foreignDeclared: Array<{ file: string, sessions: string[] }> },
716
+ * note?: string|null }>}
717
+ */
718
+ export async function computeChangeScopeAudit({ cwd, specBase, changeName, platformOpts, collectPatch } = {}) {
719
+ const empty = {
720
+ mode: 'full-flow', ok: false, degradedReason: null, baseAnchor: null,
721
+ totals: { files: 0, additions: 0, deletions: 0 },
722
+ rows: [], excluded: { foreignDeclared: [] }, note: null,
723
+ }
724
+ try {
725
+ if (!cwd || !changeName) {
726
+ return { ...empty, degradedReason: '参数缺失:cwd 与 changeName 必填' }
727
+ }
728
+ // quick 模式判定(pattern + guard 双条件,与 run/command.js :140/:652 口径一致)
729
+ if (QUICK_SID_RE.test(changeName)) {
730
+ const located = locateQuickSessionGuard(cwd, changeName)
731
+ if (located) {
732
+ return await computeQuickAudit({ cwd, platformOpts, sessionId: changeName, located, collectPatch })
733
+ }
734
+ // guard 已清理(会话收尾)→ quicklog/patches/ 记录态反查(quick-359a48f1):
735
+ // 命中出冻结记录(行数为 --done 时点真值),patch 可供 --file 切片
736
+ const rec = findQuickPatchRecord(cwd, changeName)
737
+ if (rec) {
738
+ return {
739
+ mode: 'quick', ok: true, degradedReason: null,
740
+ baseAnchor: rec.record.baseAnchor || `quick-window:${changeName}`,
741
+ totals: rec.record.totals && typeof rec.record.totals === 'object'
742
+ ? rec.record.totals
743
+ : { files: rec.record.rows.length, ...sumTotals(rec.record.rows) },
744
+ rows: rec.record.rows,
745
+ excluded: rec.record.excluded && Array.isArray(rec.record.excluded.foreignDeclared)
746
+ ? rec.record.excluded
747
+ : { foreignDeclared: [] },
748
+ frozenPatchPath: rec.patchPath,
749
+ note: `quick 会话已收尾——记录态(quicklog/patches/${rec.qlId},${rec.record.savedAt ? String(rec.record.savedAt).replace('T', ' ').slice(0, 19) + ' 落盘' : '--done 时点冻结'})`,
750
+ }
751
+ }
752
+ return {
753
+ ...empty,
754
+ mode: 'quick',
755
+ degradedReason: `quick 会话 ${changeName} 不存在(guard 已清理且 quicklog/patches/ 无记录——会话未产生改动或早于记录机制)`,
756
+ }
757
+ }
758
+ return await computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, collectPatch })
759
+ } catch (e) {
760
+ // fail-soft 兜底(D-006 / 兼容策略):注入点只打一行提示,不阻断阶段完成
761
+ return {
762
+ ...empty,
763
+ degradedReason: `scope-audit 内部异常: ${e && e.message ? String(e.message).split('\n')[0] : e}`,
764
+ }
765
+ }
766
+ }
767
+
768
+ /**
769
+ * 单文件 diff 内容查看(quick-63776328):按对账同源锚点跑 `git diff --no-color <baseRef> -- <file>`
770
+ * 出完整变化内容(git 原生 diff 格式)。锚点/执行根与表格行数同口径——worktree 形态 A 对
771
+ * worktree 工作树跑、quick 对会话根跑、归档冻结形态用快照补采基点;无锚时 HEAD 未提交窗口兜底。
772
+ *
773
+ * untracked 新文件不在 git diff 内(整个文件都是新增)→ note 提示看文件本体;该锚点窗口
774
+ * 无改动 → diff 空串 + note。纯读 fail-soft。
775
+ *
776
+ * @param {{ cwd: string, specBase?: string|null, changeName: string, platformOpts?: object|null, filePath: string }} opts
777
+ * @returns {Promise<{ ok: boolean, mode: string, root: string, baseRef: string|null,
778
+ * anchorLabel: string|null, diff: string|null, note: string|null }>}
779
+ */
780
+ export async function getFileDiff({ cwd, specBase, changeName, platformOpts, filePath } = {}) {
781
+ if (!cwd || !changeName || !filePath) {
782
+ return { ok: false, mode: 'full-flow', root: cwd || '', baseRef: null, anchorLabel: null, diff: null, note: '参数缺失:cwd / changeName / filePath 必填' }
783
+ }
784
+ try {
785
+ const result = await computeChangeScopeAudit({ cwd, specBase, changeName, platformOpts })
786
+ const norm = toPosix(filePath)
787
+ const sb = specBase || join(cwd, '.sillyspec')
788
+
789
+ // —— 真·当时内容比对(quick-359a48f1):已收尾变更优先冻结 patch 切片 ——
790
+ // ① quick 记录态自带 frozenPatchPath;② full-flow 变更目录 scope-audit.patch。
791
+ // patch 冻结在收尾时点,不含后续演进——实时锚 diff 只作 patch 缺失的兜底(口径标注)。
792
+ const frozenPatch = (result.frozenPatchPath
793
+ && (() => { try { return readFileSync(result.frozenPatchPath, 'utf8') } catch { return null } })())
794
+ || readFrozenPatch(resolveChangeDir(sb, changeName)?.dir || null)
795
+ if (frozenPatch) {
796
+ const sliced = slicePatchForFile(frozenPatch, norm)
797
+ if (sliced) {
798
+ return { ok: true, mode: result.mode, root: cwd, baseRef: null, anchorLabel: '冻结 patch(收尾时点内容,无后续演进混入)', diff: sliced, note: null }
799
+ }
800
+ return { ok: true, mode: result.mode, root: cwd, baseRef: null, anchorLabel: '冻结 patch', diff: null, note: '该文件不在冻结 patch 内(窗口外文件)' }
801
+ }
802
+
803
+ // 锚点解析(语义锚 head-uncommitted-window → 实际 baseRef 'HEAD';无锚同兜底并标注)
804
+ let baseRef = 'HEAD'
805
+ let anchorLabel = 'HEAD 未提交窗口(无锚点兜底)'
806
+ if (result.baseAnchor && /^[0-9a-f]{7,40}$/.test(result.baseAnchor)) {
807
+ baseRef = result.baseAnchor
808
+ anchorLabel = result.baseAnchor
809
+ } else if (result.baseAnchor === 'head-uncommitted-window') {
810
+ anchorLabel = 'HEAD 未提交窗口'
811
+ }
812
+
813
+ // 执行根:quick=会话创建根(防 cd 漂移);full-flow=resolveDiffRoot 共享口径
814
+ let root = cwd
815
+ if (result.mode === 'quick') {
816
+ const located = locateQuickSessionGuard(cwd, changeName)
817
+ if (located) root = dirname(located.specBase)
818
+ } else {
819
+ const form = existsSync(join(sb, '.runtime', 'worktrees', changeName, 'meta.json')) ? 'worktree' : 'post-apply'
820
+ root = resolveDiffRoot(sb, changeName, form, cwd)
821
+ }
822
+
823
+ const r = safeGit(root, ['diff', '--no-color', baseRef, '--', norm], { timeout: 30 * 1000 })
824
+ if (r.error) {
825
+ return { ok: false, mode: result.mode, root, baseRef, anchorLabel, diff: null, note: `git diff 执行失败: ${r.error}` }
826
+ }
827
+ const diff = r.value || ''
828
+ if (diff) return { ok: true, mode: result.mode, root, baseRef, anchorLabel, diff, note: null }
829
+ // 空 diff:窗口内未改 / untracked 新文件(git diff 不含 untracked)
830
+ if (existsSync(join(root, norm))) {
831
+ const tracked = safeGit(root, ['ls-files', '--', norm], { timeout: 15 * 1000 })
832
+ if (!tracked || !tracked.value || !tracked.value.trim()) {
833
+ return { ok: true, mode: result.mode, root, baseRef, anchorLabel, diff: null, note: '未跟踪新文件——不在 git diff 内,文件全部行为新增;直接查看文件本体' }
834
+ }
835
+ }
836
+ return { ok: true, mode: result.mode, root, baseRef, anchorLabel, diff: '', note: '该文件在此锚点窗口内无 diff(未改动)' }
837
+ } catch (e) {
838
+ return { ok: false, mode: 'full-flow', root: cwd, baseRef: null, anchorLabel: null, diff: null, note: `getFileDiff 内部异常: ${e && e.message ? String(e.message).split('\n')[0] : e}` }
839
+ }
840
+ }
841
+
842
+ /** verdict / attribution → 展示标记(人类可读面;BIN 为 binary 行数占位,— 为降级占位) */
843
+ const VERDICT_LABEL = {
844
+ planned: '✓ 计划内',
845
+ unplanned: '⚠️ 计划外',
846
+ untouched: '⚠️ 计划未动',
847
+ }
848
+ const ATTRIBUTION_LABEL = {
849
+ declared: '✓ 已声明',
850
+ soft: '🔍 软归属',
851
+ undeclared: '⚠️ 未声明',
852
+ }
853
+
854
+ function fmtCount(n) {
855
+ if (Number.isFinite(n)) return String(n)
856
+ return null // binary / 降级 → 由调用侧定占位
857
+ }
858
+
859
+ /**
860
+ * 人类可读表渲染(含汇总行与 ⚠️ 标记;design 接口定义 renderScopeAuditTable)。
861
+ *
862
+ * 防御式:result 任意字段缺失/畸形不抛(advisory 展示层)。opts.maxRows 截断 + 指引行
863
+ * (R-03:大表注入场景由调用方传 60,命令直跑缺省全表)。
864
+ *
865
+ * @param {object} result computeChangeScopeAudit 返回值
866
+ * @param {{ maxRows?: number }} [opts] 可选截断(缺省不截)
867
+ * @returns {string}
868
+ */
869
+ export function renderScopeAuditTable(result, opts = {}) {
870
+ const r = result && typeof result === 'object' ? result : {}
871
+ const rows = Array.isArray(r.rows) ? r.rows : []
872
+ const totals = r.totals && typeof r.totals === 'object' ? r.totals : {}
873
+ const excluded = r.excluded && typeof r.excluded === 'object' ? r.excluded : {}
874
+ const foreignDeclared = Array.isArray(excluded.foreignDeclared) ? excluded.foreignDeclared : []
875
+ const lines = []
876
+
877
+ const modeLabel = r.mode === 'quick' ? `quick 会话(${r.baseAnchor || 'quick-window'})` : 'full-flow 变更'
878
+ lines.push(`📋 变更范围对账(${modeLabel})`)
879
+
880
+ if (r.ok === false) {
881
+ lines.push(` ⚠️ ${r.degradedReason || '对账不可用'}`)
882
+ return lines.join('\n')
883
+ }
884
+
885
+ lines.push(` 基点: ${r.baseAnchor || '(无锚点——行数列降级)'}`)
886
+ if (r.degradedReason) lines.push(` ⚠️ 降级: ${r.degradedReason}`)
887
+
888
+ if (rows.length === 0) {
889
+ lines.push(' (无对账行)')
890
+ if (r.note) lines.push(` ℹ️ ${r.note}`)
891
+ return lines.join('\n')
892
+ }
893
+
894
+ // 表头:full-flow 出三态列(计划侧降级时 rows 无 verdict → 走归属列兜底展示路径)
895
+ const hasVerdict = rows.some(x => x && x.verdict)
896
+ const hasAttribution = rows.some(x => x && x.attribution)
897
+ if (hasVerdict) lines.push(' 文件 三态 +行 -行 类型')
898
+ else if (hasAttribution) lines.push(' 文件 归属 +行 -行 类型')
899
+ else lines.push(' 文件 +行 -行 类型')
900
+
901
+ const maxRows = Number.isFinite(opts.maxRows) && opts.maxRows > 0 ? opts.maxRows : Infinity
902
+ const shown = rows.slice(0, maxRows)
903
+ for (const row of shown) {
904
+ if (!row || typeof row !== 'object') continue
905
+ const stat = row.kind === 'binary'
906
+ ? { a: 'BIN', d: 'BIN' }
907
+ : { a: fmtCount(row.additions) ?? '—', d: fmtCount(row.deletions) ?? '—' }
908
+ const label = VERDICT_LABEL[row.verdict] || ATTRIBUTION_LABEL[row.attribution] || ''
909
+ lines.push(` ${row.path || '(未知路径)'}${' '.repeat(Math.max(1, 40 - String(row.path || '').length))}${label}${' '.repeat(Math.max(1, 12 - label.length))}${String(stat.a).padStart(5)} ${String(stat.d).padStart(5)} ${row.kind || ''}`)
910
+ }
911
+ if (rows.length > shown.length) {
912
+ lines.push(` …(其余 ${rows.length - shown.length} 行截断——完整表跑 sillyspec scope-audit --change <name>)`)
913
+ }
914
+
915
+ lines.push(` 合计:${totals.files ?? rows.length} 文件 +${totals.additions ?? 0} / -${totals.deletions ?? 0}`)
916
+
917
+ // ⚠️ 出口指引(full-flow 三态面):计划外补声明、计划未动确认遗漏
918
+ const unplanned = rows.filter(x => x && x.verdict === 'unplanned').length
919
+ const untouched = rows.filter(x => x && x.verdict === 'untouched').length
920
+ if (unplanned > 0 || untouched > 0) {
921
+ const parts = []
922
+ if (unplanned > 0) parts.push(`计划外 ${unplanned} 文件`)
923
+ if (untouched > 0) parts.push(`计划未动 ${untouched} 文件`)
924
+ lines.push(` ⚠️ ${parts.join('、')}——计划外请补 design.md 声明或 --output 注明原因;计划未动请确认是否遗漏`)
925
+ }
926
+ const undeclared = rows.filter(x => x && x.attribution === 'undeclared').length
927
+ if (undeclared > 0) lines.push(` ⚠️ 未声明 ${undeclared} 文件——超出 allowedFiles 声明面,补 --files 声明或注明原因`)
928
+
929
+ // 他者会话声明排除面(R-04:不进 rows,单列可见)
930
+ if (foreignDeclared.length > 0) {
931
+ lines.push(` 他者会话声明(不进上表,归属对应会话审计):`)
932
+ for (const x of foreignDeclared) {
933
+ if (!x || typeof x !== 'object') continue
934
+ const sessions = Array.isArray(x.sessions) ? x.sessions.join(', ') : String(x.sessions || '')
935
+ lines.push(` - ${x.file || '(未知文件)'}(${sessions})`)
936
+ }
937
+ }
938
+
939
+ if (r.note) lines.push(` ℹ️ ${r.note}`)
940
+ return lines.join('\n')
941
+ }