sillyspec 3.28.5 → 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.
@@ -20,7 +20,7 @@
20
20
  */
21
21
  import { existsSync, readFileSync, readdirSync } from 'node:fs'
22
22
  import { join, dirname } from 'node:path'
23
- import { locateQuickSessionGuard, auditQuickCompletion, resolveRuntimeRoot, collectOtherQuickSessionDeclarations } from './run/shared.js'
23
+ import { locateQuickSessionGuard, auditQuickCompletion, resolveRuntimeRoot, collectOtherQuickSessionDeclarations, ancestorSpecDirs } from './run/shared.js'
24
24
  import { safeGit } from './git-helper.js'
25
25
  import { parseFileChangeListDetailed, pathMatches } from './change-list.js'
26
26
 
@@ -212,9 +212,16 @@ function resolveDiffRoot(sb, changeName, form, cwd) {
212
212
 
213
213
  /**
214
214
  * execute --done 时点快照读取(归档记录态数据源):结构即 computeFullFlowAudit 返回值 +
215
- * savedAt。读失败/结构畸形返回 null(调用方降级,不出伪数据)。
215
+ * savedAt。读取链(quick-359a48f1):变更目录 scope-audit.json(审计级,随归档入库)→
216
+ * .runtime/scope-audit-<change>.json(存量兼容)→ null(调用方降级,不出伪数据)。
216
217
  */
217
- function readScopeSnapshot(runtimeRoot, changeName) {
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
+ }
218
225
  try {
219
226
  const snap = JSON.parse(readFileSync(join(runtimeRoot, `scope-audit-${changeName}.json`), 'utf8'))
220
227
  if (snap && typeof snap === 'object' && Array.isArray(snap.rows)) return snap
@@ -222,6 +229,122 @@ function readScopeSnapshot(runtimeRoot, changeName) {
222
229
  return null
223
230
  }
224
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
+
225
348
  /** 汇总非 null 行数(binary 与降级 null 不计入,不出伪数据) */
226
349
  function sumTotals(rows) {
227
350
  let additions = 0
@@ -238,7 +361,7 @@ function sumTotals(rows) {
238
361
  * 他者会话声明索引与 --done 收尾同源实时采集(complete-handlers :1050 同款 merge),保证
239
362
  * foreignDeclared 豁免面与门禁口径一致(R-04:行数只对归属本会话的文件采集)。
240
363
  */
241
- async function computeQuickAudit({ cwd, platformOpts, sessionId, located }) {
364
+ async function computeQuickAudit({ cwd, platformOpts, sessionId, located, collectPatch }) {
242
365
  const guardSpecBase = located.specBase
243
366
  const guard = located.guard
244
367
  // 审计在「创建会话的项目根」跑(located.specBase 父目录):cd 漂移后 cwd 可能是子目录,
@@ -315,6 +438,10 @@ async function computeQuickAudit({ cwd, platformOpts, sessionId, located }) {
315
438
  }
316
439
  }
317
440
 
441
+ const frozenPatch = collectPatch
442
+ ? buildFrozenPatch(sessionRoot, rowFiles, { baseRef: 'HEAD' })
443
+ : undefined
444
+
318
445
  return {
319
446
  ...base,
320
447
  totals,
@@ -324,6 +451,7 @@ async function computeQuickAudit({ cwd, platformOpts, sessionId, located }) {
324
451
  ? audit.foreignSessionDeclared.filter(x => x && typeof x === 'object')
325
452
  : [],
326
453
  },
454
+ frozenPatch,
327
455
  note,
328
456
  }
329
457
  }
@@ -334,11 +462,44 @@ async function computeQuickAudit({ cwd, platformOpts, sessionId, located }) {
334
462
  * only;baseAnchor 缺失 → 行数按 HEAD 未提交窗口兜底(quick 模式同口径,不再恒 —);
335
463
  * 归档且实时窗口空 → execute 时点快照记录态(快照也缺 → 空表诚实说明)。
336
464
  */
337
- async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts }) {
465
+ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, collectPatch }) {
338
466
  const sb = specBase || join(cwd, '.sillyspec')
339
467
  const runtimeRoot = resolveRuntimeRoot(platformOpts, sb)
340
468
  const changeDirInfo = resolveChangeDir(sb, changeName)
341
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
+
342
503
  // —— 计划侧(单一真相:change-list.js,禁自研表格解析;归档形态目录兼容)——
343
504
  const designMdPath = join(changeDirInfo ? changeDirInfo.dir : join(sb, 'changes', changeName), 'design.md')
344
505
  let plannedEntries = []
@@ -380,7 +541,7 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts })
380
541
  excluded: { foreignDeclared: [] },
381
542
  note: null,
382
543
  }
383
- let archivedNoSnapshot = false
544
+ let noSnapshotDrift = false
384
545
 
385
546
  if (!actual || actual.ok === false) {
386
547
  // 实际侧整体失败:三态无锚不出表(fail-soft 单行提示由注入点兑现),计划侧降级信息一并带出
@@ -390,14 +551,18 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts })
390
551
  return { ...base, degradedReason: [reason, planDegraded].filter(Boolean).join(';') }
391
552
  }
392
553
 
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)
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)
400
564
  if (snap && snap.rows.length > 0) {
565
+ const settleLabel = changeDirInfo.archived ? '已归档' : 'execute 已收尾(分支已清理,待 verify/archive)'
401
566
  // 行数补采:快照可能落盘于 apply 后、tag 锚落地前的窗口(行数列全 —);tag 锚
402
567
  // (quick-df1fed77)恢复 merge-base 后,对冻结文件集按锚补采行数——文件集不重算
403
568
  // (冻结语义不变),仅行数从 — 复活;含主仓后续演进(同文件被再改会计入),口径标注。
@@ -426,7 +591,7 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts })
426
591
  excluded: snap.excluded && Array.isArray(snap.excluded.foreignDeclared)
427
592
  ? snap.excluded
428
593
  : { foreignDeclared: [] },
429
- note: `已归档——execute --done 时点冻结快照${snap.savedAt ? '(' + String(snap.savedAt).replace('T', ' ').slice(0, 19) + ' 落盘)' : ''}:文件集封闭在 apply 时点,主仓后续新文件不进表;行数按锚 ${actual.baseAnchor.slice(0, 7)}→当前工作树 补采(同文件后续演进会计入)`,
594
+ note: `${settleLabel}——execute --done 时点冻结快照${snap.savedAt ? '(' + String(snap.savedAt).replace('T', ' ').slice(0, 19) + ' 落盘)' : ''}:文件集封闭在 apply 时点,主仓后续新文件不进表;行数按锚 ${actual.baseAnchor.slice(0, 7)}→当前工作树 补采(同文件后续演进会计入)`,
430
595
  }
431
596
  }
432
597
  return {
@@ -441,19 +606,20 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts })
441
606
  excluded: snap.excluded && Array.isArray(snap.excluded.foreignDeclared)
442
607
  ? snap.excluded
443
608
  : { foreignDeclared: [] },
444
- note: `已归档——execute --done 时点冻结快照${snap.savedAt ? '(' + String(snap.savedAt).replace('T', ' ').slice(0, 19) + ' 落盘)' : ''}:范围封闭在 apply 时点,主仓后续改动不反映到本表;需要看当前工作区实时状态请跑 git status`,
609
+ note: `${settleLabel}——execute --done 时点冻结快照${snap.savedAt ? '(' + String(snap.savedAt).replace('T', ' ').slice(0, 19) + ' 落盘)' : ''}:范围封闭在 apply 时点,主仓后续改动不反映到本表;需要看当前工作区实时状态请跑 git status`,
445
610
  }
446
611
  }
447
612
  // 快照缺失 → 实时开放区间兜底(下方主链路),note 追加漂移警告
448
- archivedNoSnapshot = true
613
+ noSnapshotDrift = true
449
614
  }
450
615
 
451
616
  const degraded = []
452
617
  if (planDegraded) degraded.push(planDegraded)
453
618
  const notes = []
454
- // 归档但快照缺失(上方分支置位):实时开放区间兜底,明示漂移语义
455
- if (archivedNoSnapshot) {
456
- notes.push('已归档但 execute 快照缺失——下表为实时开放区间(基点→当前工作树,含并行会话与后续演进,随主仓改动漂移),非本变更冻结范围;重建记录态看归档 verify-result.md / git log')
619
+ // 已收尾但快照缺失(上方分支置位):实时开放区间兜底,明示漂移语义——快照机制上线
620
+ // (2026-09-10)前收尾的旧变更冻结记录无法重建,如实告知替代渠道。
621
+ if (noSnapshotDrift) {
622
+ notes.push('快照缺失(变更已收尾——已归档或 execute 分支已清理;快照机制上线前收尾的变更无法重建冻结记录)——下表为实时开放区间(基点→当前工作树,含并行会话与后续演进,随主仓改动漂移),非本变更冻结范围;记录态参考 verify-result.md / git log')
457
623
  }
458
624
 
459
625
  // —— 行数采集根与基点 ——
@@ -507,6 +673,12 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts })
507
673
  rows.push({ path: e.path, planned: e.operation || null, additions: 0, deletions: 0, kind: 'modified', verdict: 'untouched' })
508
674
  }
509
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
+
510
682
  return {
511
683
  ...base,
512
684
  ok: true,
@@ -515,6 +687,7 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts })
515
687
  totals: { files: rows.length, ...sumTotals(rows) },
516
688
  rows,
517
689
  excluded: { foreignDeclared: [] },
690
+ frozenPatch,
518
691
  note: notes.length > 0 ? notes.join(';') : null,
519
692
  }
520
693
  }
@@ -542,7 +715,7 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts })
542
715
  * excluded: { foreignDeclared: Array<{ file: string, sessions: string[] }> },
543
716
  * note?: string|null }>}
544
717
  */
545
- export async function computeChangeScopeAudit({ cwd, specBase, changeName, platformOpts } = {}) {
718
+ export async function computeChangeScopeAudit({ cwd, specBase, changeName, platformOpts, collectPatch } = {}) {
546
719
  const empty = {
547
720
  mode: 'full-flow', ok: false, degradedReason: null, baseAnchor: null,
548
721
  totals: { files: 0, additions: 0, deletions: 0 },
@@ -556,15 +729,33 @@ export async function computeChangeScopeAudit({ cwd, specBase, changeName, platf
556
729
  if (QUICK_SID_RE.test(changeName)) {
557
730
  const located = locateQuickSessionGuard(cwd, changeName)
558
731
  if (located) {
559
- return await computeQuickAudit({ cwd, platformOpts, sessionId: changeName, 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
+ }
560
751
  }
561
752
  return {
562
753
  ...empty,
563
754
  mode: 'quick',
564
- degradedReason: `quick 会话 ${changeName} 不存在(祖先链各 specBase 下均无 guard.json——会话已清理或从未创建)`,
755
+ degradedReason: `quick 会话 ${changeName} 不存在(guard 已清理且 quicklog/patches/ 无记录——会话未产生改动或早于记录机制)`,
565
756
  }
566
757
  }
567
- return await computeFullFlowAudit({ cwd, specBase, changeName, platformOpts })
758
+ return await computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, collectPatch })
568
759
  } catch (e) {
569
760
  // fail-soft 兜底(D-006 / 兼容策略):注入点只打一行提示,不阻断阶段完成
570
761
  return {
@@ -592,6 +783,23 @@ export async function getFileDiff({ cwd, specBase, changeName, platformOpts, fil
592
783
  }
593
784
  try {
594
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
+
595
803
  // 锚点解析(语义锚 head-uncommitted-window → 实际 baseRef 'HEAD';无锚同兜底并标注)
596
804
  let baseRef = 'HEAD'
597
805
  let anchorLabel = 'HEAD 未提交窗口(无锚点兜底)'
@@ -604,7 +812,6 @@ export async function getFileDiff({ cwd, specBase, changeName, platformOpts, fil
604
812
 
605
813
  // 执行根:quick=会话创建根(防 cd 漂移);full-flow=resolveDiffRoot 共享口径
606
814
  let root = cwd
607
- const sb = specBase || join(cwd, '.sillyspec')
608
815
  if (result.mode === 'quick') {
609
816
  const located = locateQuickSessionGuard(cwd, changeName)
610
817
  if (located) root = dirname(located.specBase)
@@ -613,7 +820,6 @@ export async function getFileDiff({ cwd, specBase, changeName, platformOpts, fil
613
820
  root = resolveDiffRoot(sb, changeName, form, cwd)
614
821
  }
615
822
 
616
- const norm = toPosix(filePath)
617
823
  const r = safeGit(root, ['diff', '--no-color', baseRef, '--', norm], { timeout: 30 * 1000 })
618
824
  if (r.error) {
619
825
  return { ok: false, mode: result.mode, root, baseRef, anchorLabel, diff: null, note: `git diff 执行失败: ${r.error}` }
@@ -229,7 +229,7 @@ export const definition = {
229
229
  1. 简单项目:几句话整体描述
230
230
  2. 复杂项目:按模块/Phase 分段展示,每段 200-300 字
231
231
  3. 展示完整设计方案(不要逐段停顿,一次性展示)
232
- 4. 确认变更名(格式:\`YYYY-MM-DD-<简短描述>\`,例如 \`2026-05-13-user-auth\`)
232
+ 4. 确认变更名(格式:\`YYYY-MM-DD-<简短描述>\`,例如 \`2026-05-13-user-auth\`——CLI 已强制:新建/改名不带日期前缀直接 exit 2)
233
233
  5. 按「HTML 原型分级」生成原型或写出跳过原因(二选一必填,见下)
234
234
  6. 暂停等待用户确认或修改意见
235
235
 
@@ -263,7 +263,7 @@ export const definition = {
263
263
 
264
264
  ### 注意
265
265
  - 不要一次输出大段文字,按模块/Phase 分段
266
- - 变更名必须以当天日期开头(YYYY-MM-DD-),后跟英文短横线分隔的简短描述`,
266
+ - 变更名必须以当天日期开头(YYYY-MM-DD-),后跟英文短横线分隔的简短描述(CLI 强制:不合规 exit 2)`,
267
267
  outputHint: '用户确认的设计方案',
268
268
  optional: false
269
269
  },
@@ -342,7 +342,7 @@ design.md 第一行标题必须用中文:# 设计文档(Design)— <变更
342
342
 
343
343
  ### 操作
344
344
  1. 确认变更目录存在:\`mkdir -p {SPEC_ROOT}/changes/<change-name>\`(Windows 用 \`mkdir {SPEC_ROOT}/changes\\<变更名>\` 或 PowerShell \`New-Item -ItemType Directory -Force -Path {SPEC_ROOT}/changes/<change-name>\`)
345
- - 变更名格式必须为 \`YYYY-MM-DD-<简短描述>\`(如 \`2026-05-13-user-auth\`)
345
+ - 变更名格式必须为 \`YYYY-MM-DD-<简短描述>\`(如 \`2026-05-13-user-auth\`;CLI 强制,不合规 exit 2)
346
346
  2. 将确认的设计写入 \`{SPEC_ROOT}/changes/<change-name>/design.md\`——优先跑 \`sillyspec design-init --change <change-name>\` 生成骨架再填散文(决策追踪表已按 decisions.md 当前版本预填,逐行补覆盖点后把「待确认」改「已覆盖」;文件清单表骨架就位);存量手写路径仍合法(design-init 对已存在的 design.md 不覆盖)
347
347
  3. 对账整理 \`{SPEC_ROOT}/changes/<change-name>/decisions.md\`(「对话式探索」「提出方案」步已按增量规则随答落盘,本步做对账而非从零写入):
348
348
  - decisions.md 是本次变更的决策台账,不是长期术语表