sillyspec 3.28.5 → 3.28.7

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.
@@ -19,8 +19,9 @@
19
19
  * 纯读:不落盘、不改 guard/进度库。Windows 路径 \\→/ 归一,ESM-only,零新依赖。
20
20
  */
21
21
  import { existsSync, readFileSync, readdirSync } from 'node:fs'
22
+ import { createHash } from 'node:crypto'
22
23
  import { join, dirname } from 'node:path'
23
- import { locateQuickSessionGuard, auditQuickCompletion, resolveRuntimeRoot, collectOtherQuickSessionDeclarations } from './run/shared.js'
24
+ import { locateQuickSessionGuard, auditQuickCompletion, resolveRuntimeRoot, collectOtherQuickSessionDeclarations, ancestorSpecDirs } from './run/shared.js'
24
25
  import { safeGit } from './git-helper.js'
25
26
  import { parseFileChangeListDetailed, pathMatches } from './change-list.js'
26
27
 
@@ -41,7 +42,9 @@ function toPosix(p) {
41
42
  function extractRenameTarget(raw) {
42
43
  const brace = raw.match(/^(.*)\{([^{}]*)=>([^{}]*)\}(.*)$/)
43
44
  if (brace) {
44
- const merged = brace[1] + brace[3] + brace[4]
45
+ // git 花括号形态 `src/{old => new}.js`:组3 带前导对齐空格须剥(审查 C-F03——
46
+ // 不剥则拼出 `src/ new.js` 键错,改名文件落 untracked wc-l 档记成整文件新增)
47
+ const merged = brace[1] + brace[3].replace(/^ /, '') + brace[4]
45
48
  return merged.replace(/\/{2,}/g, '/')
46
49
  }
47
50
  const arrow = raw.indexOf(' => ')
@@ -212,9 +215,16 @@ function resolveDiffRoot(sb, changeName, form, cwd) {
212
215
 
213
216
  /**
214
217
  * execute --done 时点快照读取(归档记录态数据源):结构即 computeFullFlowAudit 返回值 +
215
- * savedAt。读失败/结构畸形返回 null(调用方降级,不出伪数据)。
218
+ * savedAt。读取链(quick-359a48f1):变更目录 scope-audit.json(审计级,随归档入库)→
219
+ * .runtime/scope-audit-<change>.json(存量兼容)→ null(调用方降级,不出伪数据)。
216
220
  */
217
- function readScopeSnapshot(runtimeRoot, changeName) {
221
+ function readScopeSnapshot(changeDir, runtimeRoot, changeName) {
222
+ if (changeDir) {
223
+ try {
224
+ const snap = JSON.parse(readFileSync(join(changeDir, 'scope-audit.json'), 'utf8'))
225
+ if (snap && typeof snap === 'object' && Array.isArray(snap.rows)) return snap
226
+ } catch { /* 缺失/损坏 → 下一源 */ }
227
+ }
218
228
  try {
219
229
  const snap = JSON.parse(readFileSync(join(runtimeRoot, `scope-audit-${changeName}.json`), 'utf8'))
220
230
  if (snap && typeof snap === 'object' && Array.isArray(snap.rows)) return snap
@@ -222,6 +232,137 @@ function readScopeSnapshot(runtimeRoot, changeName) {
222
232
  return null
223
233
  }
224
234
 
235
+ /**
236
+ * 冻结 patch 读取(审计级存储,--file 已收尾切片的数据源):变更目录 scope-audit.patch。
237
+ * @returns {string|null}
238
+ */
239
+ function readFrozenPatch(changeDir) {
240
+ if (!changeDir) return null
241
+ try {
242
+ const text = readFileSync(join(changeDir, 'scope-audit.patch'), 'utf8')
243
+ return text && text.trim() ? text : null
244
+ } catch { return null }
245
+ }
246
+
247
+ /**
248
+ * patch 段过滤(quick-90015473):全量 diff 按 `diff --git a/<old> b/<new>` 段头切分,
249
+ * 只保留 files 集合内的段——json rows(退栈归属集)与 patch 同口径,多会话仓不把并行
250
+ * 会话改动重复冻结进每份 patch(实测 341KB 中 98% 为并行文件)。
251
+ */
252
+ function filterPatchForFiles(patchText, filesSet) {
253
+ if (!patchText) return ''
254
+ const lines = patchText.split('\n')
255
+ const out = []
256
+ let capturing = false
257
+ for (const line of lines) {
258
+ if (line.startsWith('diff --git ')) {
259
+ const m = line.match(/^diff --git a\/(.+) b\/(.+)$/)
260
+ const p = m ? toPosix(m[2]) : null
261
+ capturing = !!(p && filesSet.has(p))
262
+ }
263
+ if (capturing) out.push(line)
264
+ }
265
+ return out.join('\n')
266
+ }
267
+
268
+ /**
269
+ /** patch 内容 sha256(A-F01 防篡改锚——写入方存 json.patchSha256,读取方校验) */
270
+ function sha256Text(text) {
271
+ return createHash('sha256').update(text, 'utf8').digest('hex')
272
+ }
273
+
274
+ /**
275
+ * 冻结 patch 完整性校验(A-F01):json 记录的 patchSha256 与 patch 实文比对。
276
+ * @returns {boolean|null} true=一致;false=不匹配(可能被篡改);null=未记录 hash(旧记录)
277
+ */
278
+ function verifyPatchIntegrity(recordedSha, patchText) {
279
+ if (!recordedSha || typeof recordedSha !== 'string') return null
280
+ return sha256Text(patchText) === recordedSha
281
+ }
282
+
283
+ /**
284
+ * 冻结 patch 生成(quick-359a48f1 审计级 / quick-90015473 归属口径修正):tracked 改动 =
285
+ * `git diff --no-color <baseRef>` 后**按 files 过滤段**(与 rows 同归属口径,不含并行会话
286
+ * 文件);untracked 新文件(git diff 天然不含)自拼 new file hunk(避 `diff --no-index`
287
+ * exit 1 坑),binary 只记标记行(防 base64 体积爆炸)。
288
+ * @param {string} root diff 执行根(execute=worktree 根 / quick=会话根 / 主仓根)
289
+ * @param {string[]} files 归属文件集(tracked 段过滤 + untracked 判定双重来源)
290
+ * @param {{ baseRef: string }} opts
291
+ * @returns {string|null} null=采集失败(调用方 fail-soft 不落伪 patch)
292
+ */
293
+ export function buildFrozenPatch(root, files, { baseRef } = {}) {
294
+ if (!root || !baseRef) return null
295
+ const wanted = new Set((Array.isArray(files) ? files : []).map(toPosix).filter(Boolean))
296
+ const parts = []
297
+ try {
298
+ const r = safeGit(root, ['diff', '--no-color', baseRef], { timeout: 60 * 1000 })
299
+ const filtered = filterPatchForFiles(r.error ? null : r.value, wanted)
300
+ if (filtered) parts.push(filtered)
301
+ const trackedOut = safeGit(root, ['ls-files'], { timeout: 30 * 1000 })
302
+ const trackedSet = new Set(String(trackedOut.value || '').split('\n').map(toPosix).filter(Boolean))
303
+ for (const f of (Array.isArray(files) ? files : []).map(toPosix).filter(Boolean)) {
304
+ if (trackedSet.has(f)) continue
305
+ const abs = join(root, f)
306
+ if (!existsSync(abs)) continue
307
+ let buf
308
+ try { buf = readFileSync(abs) } catch { continue }
309
+ if (buf.includes(0)) {
310
+ parts.push(`diff --git a/${f} b/${f}\nnew file mode 100644\nBinary file ${f} differs\n`)
311
+ continue
312
+ }
313
+ const lines = buf.toString('utf8').split('\n')
314
+ if (lines.length && lines[lines.length - 1] === '') lines.pop()
315
+ const body = lines.map(l => '+' + l).join('\n')
316
+ 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`)
317
+ }
318
+ } catch {
319
+ return null
320
+ }
321
+ return parts.length > 0 ? parts.join('\n') : ''
322
+ }
323
+
324
+ /**
325
+ * patch 切片(--file 已收尾优先路径):从全量 patch 文本切出目标文件的 hunk 段
326
+ * (`diff --git a/<old> b/<new>` 到下一个 diff 头)。按 b/ 新路径匹配(rename 兼容旧路径)。
327
+ * @returns {string|null}
328
+ */
329
+ function slicePatchForFile(patchText, filePath) {
330
+ if (!patchText || !filePath) return null
331
+ const norm = toPosix(filePath)
332
+ const lines = patchText.split('\n')
333
+ const out = []
334
+ let capturing = false
335
+ for (const line of lines) {
336
+ if (line.startsWith('diff --git ')) {
337
+ const m = line.match(/^diff --git a\/(.+) b\/(.+)$/)
338
+ capturing = !!(m && (toPosix(m[2]) === norm || toPosix(m[1]) === norm))
339
+ }
340
+ if (capturing) out.push(line)
341
+ }
342
+ return out.length > 0 ? out.join('\n') : null
343
+ }
344
+
345
+ /**
346
+ * quick 记录态反查(guard 清理后):扫祖先链各 specBase 的 quicklog/patches/*.json,
347
+ * 按 json 内冗余的 sessionId 匹配。返回记录 + patch 路径(--file 切片源)。
348
+ */
349
+ function findQuickPatchRecord(cwd, sessionId) {
350
+ for (const sb of ancestorSpecDirs(cwd)) {
351
+ const dir = join(sb, 'quicklog', 'patches')
352
+ let files
353
+ try { files = readdirSync(dir) } catch { continue }
354
+ for (const f of files.filter(x => x.endsWith('.json'))) {
355
+ try {
356
+ const j = JSON.parse(readFileSync(join(dir, f), 'utf8'))
357
+ if (j && j.sessionId === sessionId && Array.isArray(j.rows)) {
358
+ return { record: j, patchPath: join(dir, f.replace(/\.json$/, '.patch')), qlId: j.qlId || f.replace(/\.json$/, '') }
359
+ }
360
+ } catch { /* 单文件损坏跳过 */ }
361
+ }
362
+ }
363
+ return null
364
+ }
365
+
225
366
  /** 汇总非 null 行数(binary 与降级 null 不计入,不出伪数据) */
226
367
  function sumTotals(rows) {
227
368
  let additions = 0
@@ -238,7 +379,7 @@ function sumTotals(rows) {
238
379
  * 他者会话声明索引与 --done 收尾同源实时采集(complete-handlers :1050 同款 merge),保证
239
380
  * foreignDeclared 豁免面与门禁口径一致(R-04:行数只对归属本会话的文件采集)。
240
381
  */
241
- async function computeQuickAudit({ cwd, platformOpts, sessionId, located }) {
382
+ async function computeQuickAudit({ cwd, platformOpts, sessionId, located, collectPatch }) {
242
383
  const guardSpecBase = located.specBase
243
384
  const guard = located.guard
244
385
  // 审计在「创建会话的项目根」跑(located.specBase 父目录):cd 漂移后 cwd 可能是子目录,
@@ -315,6 +456,14 @@ async function computeQuickAudit({ cwd, platformOpts, sessionId, located }) {
315
456
  }
316
457
  }
317
458
 
459
+ // 审查 C-F05:patch 文件集对齐落盘 rows(declared+soft)——undeclared 可能是并行会话的
460
+ // 改动,冻结进本会话审计 patch 违背 filterPatchForFiles 的归属契约(json rows 落盘时
461
+ // complete-handlers 滤 undeclared,patch 必须同口径)
462
+ const patchFiles = [...new Set([...attributed, ...softSet])]
463
+ const frozenPatch = collectPatch
464
+ ? buildFrozenPatch(sessionRoot, patchFiles, { baseRef: 'HEAD' })
465
+ : undefined
466
+
318
467
  return {
319
468
  ...base,
320
469
  totals,
@@ -324,6 +473,7 @@ async function computeQuickAudit({ cwd, platformOpts, sessionId, located }) {
324
473
  ? audit.foreignSessionDeclared.filter(x => x && typeof x === 'object')
325
474
  : [],
326
475
  },
476
+ frozenPatch,
327
477
  note,
328
478
  }
329
479
  }
@@ -334,11 +484,67 @@ async function computeQuickAudit({ cwd, platformOpts, sessionId, located }) {
334
484
  * only;baseAnchor 缺失 → 行数按 HEAD 未提交窗口兜底(quick 模式同口径,不再恒 —);
335
485
  * 归档且实时窗口空 → execute 时点快照记录态(快照也缺 → 空表诚实说明)。
336
486
  */
337
- async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts }) {
487
+ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, collectPatch, freshActual }) {
338
488
  const sb = specBase || join(cwd, '.sillyspec')
339
489
  const runtimeRoot = resolveRuntimeRoot(platformOpts, sb)
340
490
  const changeDirInfo = resolveChangeDir(sb, changeName)
341
491
 
492
+ // —— 预执行形态(quick-bbb5037c):执行证据三信号(worktree meta / 分支 sillyspec/<名> /
493
+ // 审计 tag)全无 = 尚未进入 execute——实际侧不存在(B2 会吞整个工作区脏文件误归属本变更,
494
+ // 2026-09-11-cross-change-decision-guard 实证 28 个「计划外」全是并行在途)。出计划清单
495
+ // 视图:rows=design 清单 untouched(待实现),工作区改动归属各自会话不进表。
496
+ const hasWorktreeMeta = existsSync(join(sb, '.runtime', 'worktrees', changeName, 'meta.json'))
497
+ const hasBranch = !!safeGit(cwd, ['rev-parse', '--verify', '--quiet', `sillyspec/${changeName}^{commit}`], { timeout: 15 * 1000 }).value
498
+ const hasAuditTag = !!safeGit(cwd, ['rev-parse', '--verify', '--quiet', `sillyspec-audit/sillyspec/${changeName}^{commit}`], { timeout: 15 * 1000 }).value
499
+ // 审查 C-F02:预执行判定必须排除归档(归档=流程走完,58/77 归档变更三信号全缺被误判
500
+ // 「未执行」实证——tag 只在分支被 review 引用时才打)+ 补四类证据:快照文件本身 /
501
+ // execute-runs change 戳(task-review readExecuteRunChangeStamp 同口径)/ apply-pathspec /
502
+ // execute-cleanup 回执。任一命中 = 进过 execute。
503
+ const hasSnapshotFile = changeDirInfo
504
+ ? existsSync(join(changeDirInfo.dir, 'scope-audit.json')) || existsSync(join(runtimeRoot, `scope-audit-${changeName}.json`))
505
+ : existsSync(join(runtimeRoot, `scope-audit-${changeName}.json`))
506
+ const hasApplyPathspec = existsSync(join(runtimeRoot, `apply-pathspec-${changeName}.txt`))
507
+ const hasCleanupReceipt = existsSync(join(runtimeRoot, `execute-cleanup-${changeName}.json`))
508
+ let hasExecuteRunStamp = false
509
+ if (!hasWorktreeMeta && !hasBranch && !hasAuditTag && !hasSnapshotFile && !hasApplyPathspec && !hasCleanupReceipt) {
510
+ try {
511
+ const runsDir = join(runtimeRoot, 'execute-runs')
512
+ if (existsSync(runsDir)) {
513
+ for (const e of readdirSync(runsDir)) {
514
+ try {
515
+ if (readFileSync(join(runsDir, e, 'change'), 'utf8').trim() === changeName) { hasExecuteRunStamp = true; break }
516
+ } catch { /* 无戳 run 跳过 */ }
517
+ }
518
+ }
519
+ } catch { /* 扫描失败视为无证据(fail-open 到预执行视图,note 有 hedge) */ }
520
+ }
521
+ const isArchived = !!(changeDirInfo && changeDirInfo.archived)
522
+ if (!isArchived && !hasWorktreeMeta && !hasBranch && !hasAuditTag && !hasSnapshotFile && !hasApplyPathspec && !hasCleanupReceipt && !hasExecuteRunStamp) {
523
+ const emptyBase = {
524
+ mode: 'full-flow', ok: true, degradedReason: null, baseAnchor: null,
525
+ totals: { files: 0, additions: 0, deletions: 0 }, rows: [],
526
+ excluded: { foreignDeclared: [] }, frozenPatch: undefined,
527
+ }
528
+ let prePlanned = []
529
+ try {
530
+ prePlanned = parseFileChangeListDetailed(
531
+ join(changeDirInfo ? changeDirInfo.dir : join(sb, 'changes', changeName), 'design.md'),
532
+ { keepSillyspecDocs: true })
533
+ } catch { /* 清单解析失败 → 空清单 + 说明 */ }
534
+ const preRows = prePlanned.map(e => ({
535
+ path: e.path, planned: e.operation || null, additions: 0, deletions: 0,
536
+ kind: 'modified', verdict: 'untouched',
537
+ }))
538
+ return {
539
+ ...emptyBase,
540
+ totals: { files: preRows.length, additions: 0, deletions: 0 },
541
+ rows: preRows,
542
+ note: preRows.length > 0
543
+ ? '变更尚未进入 execute(无 worktree/分支/审计 tag/快照/execute-run 戳/apply 记录)——下表为 design 清单(全部待实现);工作区其他未提交改动归属各自会话/变更,不在本表(若确已执行过但全部执行证据被清理,此处会误判——git log 核实)'
544
+ : '变更尚未进入 execute(无 worktree/分支/审计 tag/快照/execute-run 戳/apply 记录)且 design 无可解析清单——暂无可对账内容',
545
+ }
546
+ }
547
+
342
548
  // —— 计划侧(单一真相:change-list.js,禁自研表格解析;归档形态目录兼容)——
343
549
  const designMdPath = join(changeDirInfo ? changeDirInfo.dir : join(sb, 'changes', changeName), 'design.md')
344
550
  let plannedEntries = []
@@ -380,7 +586,7 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts })
380
586
  excluded: { foreignDeclared: [] },
381
587
  note: null,
382
588
  }
383
- let archivedNoSnapshot = false
589
+ let noSnapshotDrift = false
384
590
 
385
591
  if (!actual || actual.ok === false) {
386
592
  // 实际侧整体失败:三态无锚不出表(fail-soft 单行提示由注入点兑现),计划侧降级信息一并带出
@@ -390,14 +596,20 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts })
390
596
  return { ...base, degradedReason: [reason, planDegraded].filter(Boolean).join(';') }
391
597
  }
392
598
 
393
- // —— 归档形态·冻结快照优先(真实 > 冻结记录 > 实时开放区间):归档变更的范围应封闭在
394
- // apply 时点,不随后续主仓改动漂移(quick-f5acdeeb)。execute --done 落的快照即该时点
395
- // 冻结记录(含行数)——archived 一律先出快照;实时窗口(开放区间,含并行 WIP 与后续演进)
396
- // 只作快照缺失的兜底,note 明示会漂。tag 封闭区间不可用:分支上仅 baseline checkpoint
397
- // (并行 WIP 快照),本变更改动经工作树 apply 从未 commit 到分支。 ——
398
- if (changeDirInfo && changeDirInfo.archived) {
399
- const snap = readScopeSnapshot(runtimeRoot, changeName)
599
+ // —— 已收尾变更·冻结快照优先(真实 > 冻结记录 > 实时开放区间):归档变更的范围应封闭在
600
+ // apply 时点,不随后续主仓改动漂移(quick-f5acdeeb);quick-bd84b852 放宽——**post-apply
601
+ // (分支已删 = execute 已 apply 收尾)的活跃变更同样收尾**,此后实时区间只会被并行演进泡大
602
+ // (multi-agent-platform 2026-09-04 滞留变更实证:741 文件零提示刷屏)。execute --done 落的
603
+ // 快照即冻结记录(含行数)——已收尾一律先出快照;实时窗口(开放区间)只作快照缺失的兜底,
604
+ // note 明示会漂。tag 封闭区间不可用:分支上仅 baseline checkpoint(并行 WIP 快照),本变更
605
+ // 改动经工作树 apply 从未 commit 到分支。 ——
606
+ // freshActual(审查 C-F01):verify 漂移 current 侧走快照捷径会变成快照比快照恒「一致」
607
+ // (假阴性);execute --done 采集重跑也会陈旧回写——两处传 true 绕过,快照捷径只留给查询面
608
+ const settled = !freshActual && changeDirInfo && (changeDirInfo.archived || actual.form === 'post-apply')
609
+ if (settled) {
610
+ const snap = readScopeSnapshot(changeDirInfo.dir, runtimeRoot, changeName)
400
611
  if (snap && snap.rows.length > 0) {
612
+ const settleLabel = changeDirInfo.archived ? '已归档' : 'execute 已收尾(分支已清理,待 verify/archive)'
401
613
  // 行数补采:快照可能落盘于 apply 后、tag 锚落地前的窗口(行数列全 —);tag 锚
402
614
  // (quick-df1fed77)恢复 merge-base 后,对冻结文件集按锚补采行数——文件集不重算
403
615
  // (冻结语义不变),仅行数从 — 复活;含主仓后续演进(同文件被再改会计入),口径标注。
@@ -406,7 +618,9 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts })
406
618
  const needsStats = rows.some(r => r && !Number.isFinite(r.additions) && r.kind !== 'binary')
407
619
  if (needsStats && actual && actual.ok && actual.baseAnchor) {
408
620
  const paths = rows.map(r => (r && r.path ? toPosix(r.path) : '')).filter(Boolean)
409
- const stats = collectNumstatByPath(cwd, paths, { baseRef: actual.baseAnchor })
621
+ // 审查 C-F04:补采执行根走共享 resolveDiffRoot(form=worktree 时必须对 worktree 跑,
622
+ // 与主链路 :629/:636 同源配对——直用 cwd 会基点×根错位)
623
+ const stats = collectNumstatByPath(resolveDiffRoot(sb, changeName, actual.form, cwd), paths, { baseRef: actual.baseAnchor })
410
624
  let recovered = 0
411
625
  rows = rows.map(r => {
412
626
  if (!r || !r.path || Number.isFinite(r.additions) || r.kind === 'binary') return r
@@ -426,7 +640,9 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts })
426
640
  excluded: snap.excluded && Array.isArray(snap.excluded.foreignDeclared)
427
641
  ? snap.excluded
428
642
  : { foreignDeclared: [] },
429
- note: `已归档——execute --done 时点冻结快照${snap.savedAt ? '(' + String(snap.savedAt).replace('T', ' ').slice(0, 19) + ' 落盘)' : ''}:文件集封闭在 apply 时点,主仓后续新文件不进表;行数按锚 ${actual.baseAnchor.slice(0, 7)}→当前工作树 补采(同文件后续演进会计入)`,
643
+ patchSha256: snap.patchSha256 || null,
644
+ patchStatus: snap.patchStatus || null,
645
+ note: `${settleLabel}——execute --done 时点冻结快照${snap.savedAt ? '(' + String(snap.savedAt).replace('T', ' ').slice(0, 19) + ' 落盘)' : ''}:文件集封闭在 apply 时点,主仓后续新文件不进表;行数按锚 ${actual.baseAnchor.slice(0, 7)}→当前工作树 补采(同文件后续演进会计入)`,
430
646
  }
431
647
  }
432
648
  return {
@@ -441,19 +657,22 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts })
441
657
  excluded: snap.excluded && Array.isArray(snap.excluded.foreignDeclared)
442
658
  ? snap.excluded
443
659
  : { foreignDeclared: [] },
444
- note: `已归档——execute --done 时点冻结快照${snap.savedAt ? '(' + String(snap.savedAt).replace('T', ' ').slice(0, 19) + ' 落盘)' : ''}:范围封闭在 apply 时点,主仓后续改动不反映到本表;需要看当前工作区实时状态请跑 git status`,
660
+ patchSha256: snap.patchSha256 || null,
661
+ patchStatus: snap.patchStatus || null,
662
+ note: `${settleLabel}——execute --done 时点冻结快照${snap.savedAt ? '(' + String(snap.savedAt).replace('T', ' ').slice(0, 19) + ' 落盘)' : ''}:范围封闭在 apply 时点,主仓后续改动不反映到本表;需要看当前工作区实时状态请跑 git status`,
445
663
  }
446
664
  }
447
665
  // 快照缺失 → 实时开放区间兜底(下方主链路),note 追加漂移警告
448
- archivedNoSnapshot = true
666
+ noSnapshotDrift = true
449
667
  }
450
668
 
451
669
  const degraded = []
452
670
  if (planDegraded) degraded.push(planDegraded)
453
671
  const notes = []
454
- // 归档但快照缺失(上方分支置位):实时开放区间兜底,明示漂移语义
455
- if (archivedNoSnapshot) {
456
- notes.push('已归档但 execute 快照缺失——下表为实时开放区间(基点→当前工作树,含并行会话与后续演进,随主仓改动漂移),非本变更冻结范围;重建记录态看归档 verify-result.md / git log')
672
+ // 已收尾但快照缺失(上方分支置位):实时开放区间兜底,明示漂移语义——快照机制上线
673
+ // (2026-09-10)前收尾的旧变更冻结记录无法重建,如实告知替代渠道。
674
+ if (noSnapshotDrift) {
675
+ notes.push('快照缺失(变更已收尾——已归档或 execute 分支已清理;快照机制上线前收尾的变更无法重建冻结记录)——下表为实时开放区间(基点→当前工作树,含并行会话与后续演进,随主仓改动漂移),非本变更冻结范围;记录态参考 verify-result.md / git log')
457
676
  }
458
677
 
459
678
  // —— 行数采集根与基点 ——
@@ -507,6 +726,12 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts })
507
726
  rows.push({ path: e.path, planned: e.operation || null, additions: 0, deletions: 0, kind: 'modified', verdict: 'untouched' })
508
727
  }
509
728
 
729
+ // 冻结 patch(collectPatch,execute --done 落盘方消费):与行数同根同锚(numstatRoot +
730
+ // baseAnchor;无锚 HEAD 兜底同口径),全窗口 diff + untracked 自拼 hunk(buildFrozenPatch)
731
+ const frozenPatch = collectPatch
732
+ ? buildFrozenPatch(numstatRoot, actual.files, { baseRef: actual.baseAnchor || 'HEAD' })
733
+ : undefined
734
+
510
735
  return {
511
736
  ...base,
512
737
  ok: true,
@@ -515,6 +740,7 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts })
515
740
  totals: { files: rows.length, ...sumTotals(rows) },
516
741
  rows,
517
742
  excluded: { foreignDeclared: [] },
743
+ frozenPatch,
518
744
  note: notes.length > 0 ? notes.join(';') : null,
519
745
  }
520
746
  }
@@ -542,7 +768,7 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts })
542
768
  * excluded: { foreignDeclared: Array<{ file: string, sessions: string[] }> },
543
769
  * note?: string|null }>}
544
770
  */
545
- export async function computeChangeScopeAudit({ cwd, specBase, changeName, platformOpts } = {}) {
771
+ export async function computeChangeScopeAudit({ cwd, specBase, changeName, platformOpts, collectPatch, freshActual } = {}) {
546
772
  const empty = {
547
773
  mode: 'full-flow', ok: false, degradedReason: null, baseAnchor: null,
548
774
  totals: { files: 0, additions: 0, deletions: 0 },
@@ -556,15 +782,35 @@ export async function computeChangeScopeAudit({ cwd, specBase, changeName, platf
556
782
  if (QUICK_SID_RE.test(changeName)) {
557
783
  const located = locateQuickSessionGuard(cwd, changeName)
558
784
  if (located) {
559
- return await computeQuickAudit({ cwd, platformOpts, sessionId: changeName, located })
785
+ return await computeQuickAudit({ cwd, platformOpts, sessionId: changeName, located, collectPatch })
786
+ }
787
+ // guard 已清理(会话收尾)→ quicklog/patches/ 记录态反查(quick-359a48f1):
788
+ // 命中出冻结记录(行数为 --done 时点真值),patch 可供 --file 切片
789
+ const rec = findQuickPatchRecord(cwd, changeName)
790
+ if (rec) {
791
+ return {
792
+ mode: 'quick', ok: true, degradedReason: null,
793
+ baseAnchor: rec.record.baseAnchor || `quick-window:${changeName}`,
794
+ totals: rec.record.totals && typeof rec.record.totals === 'object'
795
+ ? rec.record.totals
796
+ : { files: rec.record.rows.length, ...sumTotals(rec.record.rows) },
797
+ rows: rec.record.rows,
798
+ excluded: rec.record.excluded && Array.isArray(rec.record.excluded.foreignDeclared)
799
+ ? rec.record.excluded
800
+ : { foreignDeclared: [] },
801
+ frozenPatchPath: rec.patchPath,
802
+ patchSha256: rec.record.patchSha256 || null,
803
+ patchStatus: rec.record.patchStatus || null,
804
+ note: `quick 会话已收尾——记录态(quicklog/patches/${rec.qlId},${rec.record.savedAt ? String(rec.record.savedAt).replace('T', ' ').slice(0, 19) + ' 落盘' : '--done 时点冻结'})`,
805
+ }
560
806
  }
561
807
  return {
562
808
  ...empty,
563
809
  mode: 'quick',
564
- degradedReason: `quick 会话 ${changeName} 不存在(祖先链各 specBase 下均无 guard.json——会话已清理或从未创建)`,
810
+ degradedReason: `quick 会话 ${changeName} 不存在(guard 已清理且 quicklog/patches/ 无记录——会话未产生改动或早于记录机制)`,
565
811
  }
566
812
  }
567
- return await computeFullFlowAudit({ cwd, specBase, changeName, platformOpts })
813
+ return await computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, collectPatch, freshActual })
568
814
  } catch (e) {
569
815
  // fail-soft 兜底(D-006 / 兼容策略):注入点只打一行提示,不阻断阶段完成
570
816
  return {
@@ -592,6 +838,43 @@ export async function getFileDiff({ cwd, specBase, changeName, platformOpts, fil
592
838
  }
593
839
  try {
594
840
  const result = await computeChangeScopeAudit({ cwd, specBase, changeName, platformOpts })
841
+ const norm = toPosix(filePath)
842
+ const sb = specBase || join(cwd, '.sillyspec')
843
+
844
+ // —— 真·当时内容比对(quick-359a48f1):已收尾变更优先冻结 patch 切片 ——
845
+ // ① quick 记录态自带 frozenPatchPath;② full-flow 变更目录 scope-audit.patch。
846
+ // patch 冻结在收尾时点,不含后续演进——实时锚 diff 只作 patch 缺失的兜底(口径标注)。
847
+ // A-F01:读取时按 json 记录的 patchSha256 校验,不匹配显式告警(防低级篡改)。
848
+ const patchRecord = result.frozenPatchPath
849
+ ? { path: result.frozenPatchPath, sha: result.patchSha256 || null }
850
+ : (() => { const d = resolveChangeDir(sb, changeName); return d ? { path: join(d.dir, 'scope-audit.patch'), sha: null } : null })()
851
+ let recordedSha = patchRecord ? patchRecord.sha : null
852
+ if (patchRecord && !recordedSha && result.rows) {
853
+ // json 里未直接带回 hash(记录态捷径不透传)→ 读 json 原文取(快照/quick 记录都存该字段)
854
+ try {
855
+ const snapRaw = JSON.parse(readFileSync(result.frozenPatchPath
856
+ ? result.frozenPatchPath.replace(/\.patch$/, '.json')
857
+ : join(resolveChangeDir(sb, changeName).dir, 'scope-audit.json'), 'utf8'))
858
+ recordedSha = snapRaw.patchSha256 || null
859
+ } catch { /* json 不可读 → 无 hash 可校验(null=不校验) */ }
860
+ }
861
+ const frozenPatch = (patchRecord
862
+ && (() => { try { return readFileSync(patchRecord.path, 'utf8') } catch { return null } })())
863
+ if (frozenPatch !== null && frozenPatch !== undefined && patchRecord) {
864
+ const integrity = verifyPatchIntegrity(recordedSha, frozenPatch)
865
+ if (integrity === false) {
866
+ return { ok: false, mode: result.mode, root: cwd, baseRef: null, anchorLabel: '冻结 patch', diff: null, note: '⚠️ 冻结 patch 与快照记录的 patchSha256 不匹配——记录可能被篡改,审计前人工核验(git 历史比对)' }
867
+ }
868
+ const sliced = slicePatchForFile(frozenPatch, norm)
869
+ if (sliced) {
870
+ return { ok: true, mode: result.mode, root: cwd, baseRef: null, anchorLabel: '冻结 patch(收尾时点内容,无后续演进混入)', diff: sliced, note: null }
871
+ }
872
+ return { ok: true, mode: result.mode, root: cwd, baseRef: null, anchorLabel: '冻结 patch', diff: null, note: '该文件不在冻结 patch 内(窗口外文件)' }
873
+ }
874
+ if (result.patchStatus === 'failed') {
875
+ return { ok: true, mode: result.mode, root: cwd, baseRef: null, anchorLabel: '冻结记录', diff: null, note: '冻结 patch 当时采集失败(非无改动)——退实时锚比对' }
876
+ }
877
+
595
878
  // 锚点解析(语义锚 head-uncommitted-window → 实际 baseRef 'HEAD';无锚同兜底并标注)
596
879
  let baseRef = 'HEAD'
597
880
  let anchorLabel = 'HEAD 未提交窗口(无锚点兜底)'
@@ -604,7 +887,6 @@ export async function getFileDiff({ cwd, specBase, changeName, platformOpts, fil
604
887
 
605
888
  // 执行根:quick=会话创建根(防 cd 漂移);full-flow=resolveDiffRoot 共享口径
606
889
  let root = cwd
607
- const sb = specBase || join(cwd, '.sillyspec')
608
890
  if (result.mode === 'quick') {
609
891
  const located = locateQuickSessionGuard(cwd, changeName)
610
892
  if (located) root = dirname(located.specBase)
@@ -613,7 +895,6 @@ export async function getFileDiff({ cwd, specBase, changeName, platformOpts, fil
613
895
  root = resolveDiffRoot(sb, changeName, form, cwd)
614
896
  }
615
897
 
616
- const norm = toPosix(filePath)
617
898
  const r = safeGit(root, ['diff', '--no-color', baseRef, '--', norm], { timeout: 30 * 1000 })
618
899
  if (r.error) {
619
900
  return { ok: false, mode: result.mode, root, baseRef, anchorLabel, diff: null, note: `git diff 执行失败: ${r.error}` }