sillyspec 3.29.2 → 3.29.4

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 (42) hide show
  1. package/analyze-transcript.cjs +38 -0
  2. package/asset-audit-2026-09-20/README.md +42 -0
  3. package/asset-audit-2026-09-20/asset-audit.cjs +253 -0
  4. package/asset-audit-2026-09-20/extract-candidates.cjs +37 -0
  5. package/asset-audit-2026-09-20/platform-archive.csv +294 -0
  6. package/asset-audit-2026-09-20/platform-fr.csv +12 -0
  7. package/asset-audit-2026-09-20/platform-gotchas.csv +6 -0
  8. package/asset-audit-2026-09-20/platform-knownissues.csv +23 -0
  9. package/asset-audit-2026-09-20/platform-modules.csv +222 -0
  10. package/asset-audit-2026-09-20/platform-quicklog.csv +1076 -0
  11. package/asset-audit-2026-09-20/platform-troubleshooting-candidates.csv +88 -0
  12. package/asset-audit-2026-09-20/platform-troubleshooting.csv +1 -0
  13. package/asset-audit-2026-09-20/sillyspec-archive.csv +108 -0
  14. package/asset-audit-2026-09-20/sillyspec-fr.csv +51 -0
  15. package/asset-audit-2026-09-20/sillyspec-gotchas.csv +4 -0
  16. package/asset-audit-2026-09-20/sillyspec-knownissues.csv +34 -0
  17. package/asset-audit-2026-09-20/sillyspec-modules.csv +30 -0
  18. package/asset-audit-2026-09-20/sillyspec-quicklog.csv +460 -0
  19. package/asset-audit-2026-09-20/sillyspec-troubleshooting.csv +69 -0
  20. package/asset-audit-2026-09-20/summary.json +241 -0
  21. package/package.json +1 -1
  22. package/src/ceremony-config.js +64 -0
  23. package/src/ceremony-tier.js +66 -13
  24. package/src/config-schema.js +16 -3
  25. package/src/cross-repo-reconcile.js +232 -46
  26. package/src/fr-index.js +80 -2
  27. package/src/index.js +2 -2
  28. package/src/review-tier.js +5 -2
  29. package/src/run/bg-sync.js +231 -0
  30. package/src/run/complete-handlers.js +208 -5
  31. package/src/run/gates.js +10 -5
  32. package/src/run/prompt.js +1 -1
  33. package/src/run/shared.js +32 -0
  34. package/src/run/verify-quality-scan.js +131 -3
  35. package/src/scope-audit.js +303 -20
  36. package/src/stages/brainstorm.js +6 -0
  37. package/src/stages/execute.js +1 -0
  38. package/src/stages/plan-postcheck.js +66 -0
  39. package/src/stages/plan.js +1 -0
  40. package/src/stages/quick.js +2 -0
  41. package/src/sync.js +12 -0
  42. package/src/verify-postcheck.js +92 -8
@@ -99,7 +99,7 @@ export function computeQualityScanFingerprint({ cwd, specBase }) {
99
99
  * 保持 1——存量记录无 smoke 段照常读回(smokeResult 兜底 null);未配置 smoke 时段形态为
100
100
  * configured=false(记录仍写,供 --done PASS 封顶判 not-configured)。
101
101
  */
102
- export function storeQualityScan({ specBase, cwd, changeName, testResult, lintResult, smokeResult }) {
102
+ export function storeQualityScan({ specBase, cwd, changeName, testResult, lintResult, smokeResult, usedSnapshot = false }) {
103
103
  const path = qualityScanRecordPath(specBase, changeName)
104
104
  if (!path) return null
105
105
  const fingerprint = computeQualityScanFingerprint({ cwd, specBase })
@@ -110,6 +110,10 @@ export function storeQualityScan({ specBase, cwd, changeName, testResult, lintRe
110
110
  source: 'cli-noai',
111
111
  change: changeName,
112
112
  fingerprint,
113
+ // 失败签名去重面(ql-20260920-010 修复一 d):additive 字段,存量记录无这两键 →
114
+ // shouldReuseLastFailedScan 判 no-dedup-key 保守重跑一次,之后新记录即携带
115
+ dedupKey: computeQualityScanDedupKey({ cwd, specBase, fingerprint }),
116
+ usedSnapshot: Boolean(usedSnapshot),
113
117
  ranAt: new Date().toISOString(),
114
118
  testResult,
115
119
  lintResult: lintResult || null,
@@ -143,6 +147,93 @@ export function loadReusableQualityScan({ specBase, cwd, changeName }) {
143
147
  }
144
148
  }
145
149
 
150
+ /**
151
+ * 去重键(ql-20260920-010 修复一 d,对撞三轮三连假红重试 8~10 分钟×3 的根治):
152
+ * 指纹 + known_failures 块哈希。指纹只哈希 commands 段 + test_strategy 行——不含
153
+ * known_failures;而 agent 对假红的常见补救正是加豁免条目(不改代码、指纹不动)。
154
+ * 去重若只看指纹会把「合法补救后复跑」误判成「什么都没改」而拒绝重跑——键独立补
155
+ * 豁免面哈希,豁免一动即失配。git 不可用 → null(fail-closed 同指纹口径)。
156
+ */
157
+ export function computeQualityScanDedupKey({ cwd, specBase, fingerprint = null }) {
158
+ const fp = fingerprint ?? computeQualityScanFingerprint({ cwd, specBase })
159
+ if (!fp) return null
160
+ let kf = 'none'
161
+ try {
162
+ const localPath = join(specBase, 'local.yaml')
163
+ if (existsSync(localPath)) {
164
+ const lines = readFileSync(localPath, 'utf8').replace(/\r\n?/g, '\n').split('\n')
165
+ const start = lines.findIndex((l) => /^\s*known_failures:\s*(#.*)?$/.test(l))
166
+ if (start !== -1) {
167
+ const body = []
168
+ for (let i = start + 1; i < lines.length && !/^\S/.test(lines[i]); i++) body.push(lines[i])
169
+ kf = body.join('\n')
170
+ }
171
+ }
172
+ } catch { /* 读不到按无豁免面处理 */ }
173
+ return createHash('sha256').update(fp + '\n##known-failures##\n' + kf).digest('hex')
174
+ }
175
+
176
+ /**
177
+ * 读回最近一条扫描记录(不论成败)——失败去重重放用。loadReusableQualityScan 拒绝
178
+ * failed 记录(复用是优化不是正确性依赖),去重恰恰要消费 failed 态;schema/source/
179
+ * testResult 校验同 load 口径,读不回 → null。
180
+ */
181
+ export function loadLastQualityScanRecord({ specBase, changeName }) {
182
+ const path = qualityScanRecordPath(specBase, changeName)
183
+ if (!path || !existsSync(path)) return null
184
+ try {
185
+ const rec = JSON.parse(readFileSync(path, 'utf8'))
186
+ if (!rec || rec.schemaVersion !== RECORD_SCHEMA_VERSION || rec.source !== 'cli-noai') return null
187
+ if (!rec.testResult || !rec.testResult.status) return null
188
+ return rec
189
+ } catch {
190
+ return null
191
+ }
192
+ }
193
+
194
+ /**
195
+ * 失败签名去重判定(纯函数)。reuse=true 语义:代码指纹 + 豁免面 + 快照口径均未变 →
196
+ * 上次失败必然原样复现,重跑是纯等待浪费。四路不重跑:forceRerun 逃生阀 / 无失败记录 /
197
+ * 存量记录无 dedupKey(本版起才有,老记录保守重跑一次)/ 键或口径失配。
198
+ */
199
+ export function shouldReuseLastFailedScan({ lastRecord, currentDedupKey, plannedSnapshot, forceRerun = false }) {
200
+ if (forceRerun) return { reuse: false, reason: 'force-rerun(逃生阀 SILLYSPEC_VERIFY_QUALITY_SCAN_RERUN)' }
201
+ if (!lastRecord || !lastRecord.testResult || lastRecord.testResult.status !== 'failed') {
202
+ return { reuse: false, reason: 'no-failed-record' }
203
+ }
204
+ if (!lastRecord.dedupKey || !currentDedupKey) return { reuse: false, reason: 'no-dedup-key(存量记录 / git 不可用)' }
205
+ if (lastRecord.dedupKey !== currentDedupKey) return { reuse: false, reason: 'dedup-key-mismatch(代码/配置/豁免面/HEAD 已变化)' }
206
+ if (Boolean(lastRecord.usedSnapshot) !== Boolean(plannedSnapshot)) return { reuse: false, reason: 'snapshot-scope-changed' }
207
+ return { reuse: true, reason: null }
208
+ }
209
+
210
+ /**
211
+ * 伪影分诊编排(ql-20260920-010 修复一 a):测试失败 → classify 分类 → 命中且本次是
212
+ * 快照口径 → 主树口径单点复跑对照。复跑过 = 伪影坐实:返回主树结果 + artifactTriage
213
+ * 证据(放行有据,disclosure 走 console + 台账,不改 passed.reason 语义);复跑仍挂 =
214
+ * 真失败:保留快照失败结果 + 两口径对照注记(拦截照常,归因已对照过)。主树口径失败
215
+ * (snap=null)只注记 advice 不复跑(本身就是对照口径)。classify/rerunInMainScope
216
+ * 可注入——纯函数化供测试;分诊永不单独放行(放行必经主树实测复证)。
217
+ */
218
+ export function applyArtifactTriageRerun({ testCheck, classify, snap, rerunInMainScope }) {
219
+ if (!testCheck || testCheck.status !== 'failed') return testCheck
220
+ const triage = classify(testCheck)
221
+ if (!triage || !triage.matched) return testCheck
222
+ if (!snap) {
223
+ console.warn(`🔬 伪影分诊命中(${triage.kind},本次已主树口径):${triage.advice}`)
224
+ return { ...testCheck, reason: `${testCheck.reason || ''}[伪影分诊命中(${triage.kind}):${triage.advice}]`, artifactTriage: triage }
225
+ }
226
+ console.warn(`🔬 伪影分诊命中(${triage.kind})——快照口径失败疑似沙箱伪影,主树口径单点复跑对照…`)
227
+ for (const ev of triage.evidence) console.warn(` · ${ev}`)
228
+ const mainRetry = rerunInMainScope()
229
+ if (mainRetry.status === 'passed') {
230
+ console.warn(' 主树复跑通过——伪影坐实:按主树口径结果放行(快照失败输出与分诊证据随台账落盘)')
231
+ return { ...mainRetry, artifactTriage: triage }
232
+ }
233
+ console.warn(' 主树复跑仍失败——非沙箱伪影,按真失败拦截(两口径输出均见台账)')
234
+ return { ...testCheck, reason: `${testCheck.reason || ''}[伪影分诊:两口径(快照/主树)均失败,非沙箱伪影]`, artifactTriage: triage }
235
+ }
236
+
146
237
  /**
147
238
  * 并行 WIP 归因鉴别(前置二):迁自 gates.js verify 实测失败归因 inline 块,输出逐字保持。
148
239
  * 判据①他者声明文件命中 dirty 集;判据②本变更 apply 的文件与主仓近期提交重叠且旧内容
@@ -401,7 +492,37 @@ function warnIfMainRepoDirtyForGate(cwd) {
401
492
  * `run verify` 复入本步重新实测(指纹随代码修复失配,--done 不会复用旧失败结果)。
402
493
  */
403
494
  export async function executeVerifyQualityScan({ cwd, specBase, changeName, platformOpts }) {
404
- const { runVerifyTestCheck, printVerifyTestCheck, runVerifyLintCheck, printVerifyLintCheck, shouldBlockVerifyLint } = await import('../verify-postcheck.js')
495
+ const { runVerifyTestCheck, printVerifyTestCheck, runVerifyLintCheck, printVerifyLintCheck, shouldBlockVerifyLint, classifyTestFailureArtifact } = await import('../verify-postcheck.js')
496
+ // ── 失败签名去重(ql-20260920-010 修复一 d)──
497
+ // 复入本步前先对账上次失败:代码指纹 + known_failures 豁免面 + 快照口径都没变 → 失败
498
+ // 必然原样复现,重跑是纯等待(对撞三轮三连 8~10 分钟假红重试的根治)。复用只消费
499
+ // failed 态记录(passed 态 --done 复用归 loadReusableQualityScan),逃生阀
500
+ // SILLYSPEC_VERIFY_QUALITY_SCAN_RERUN=1。
501
+ if (process.env.SILLYSPEC_VERIFY_QUALITY_SCAN_RERUN !== '1') {
502
+ const lastRecord = loadLastQualityScanRecord({ specBase, changeName })
503
+ const verdict = shouldReuseLastFailedScan({
504
+ lastRecord,
505
+ currentDedupKey: computeQualityScanDedupKey({ cwd, specBase }),
506
+ plannedSnapshot: !process.env.SILLYSPEC_VERIFY_GATE_SNAPSHOT_OFF,
507
+ })
508
+ if (verdict.reuse) {
509
+ const t = lastRecord.testResult
510
+ console.error(`\n🛑 检测到上次质量扫描失败且失败签名未变(代码指纹 / known_failures 豁免面 / 快照口径均未变,${lastRecord.ranAt ? '上次运行 ' + lastRecord.ranAt : ''})——本次跳过重跑,直接复用上次失败结果:`)
511
+ console.error(` 上次实测:\`${t.command}\` — ${(t.reason || '').split('\n')[0]}`)
512
+ if (t.outputTail) {
513
+ for (const line of t.outputTail.split('\n').slice(-8)) console.error(` | ${line}`)
514
+ }
515
+ const replayTriage = classifyTestFailureArtifact(t)
516
+ if (t.artifactTriage) {
517
+ console.error(` 🔬 上次已做过伪影分诊(${t.artifactTriage.kind})且主树对照复跑仍失败——非沙箱伪影,别再换口径重试`)
518
+ } else if (replayTriage.matched) {
519
+ console.error(` 🔬 失败签名命中伪影形态(${replayTriage.kind}):${replayTriage.advice}`)
520
+ console.error(' (上次运行早于分诊机制——本次重跑会自动做主树口径对照;如需立即重跑设 SILLYSPEC_VERIFY_QUALITY_SCAN_RERUN=1)')
521
+ }
522
+ console.error(' 出路(按序):① 修代码(指纹即失配,下次自动重测)② 补 known_failures 豁免(豁免面即失配)③ 换快照口径(设/删 SILLYSPEC_VERIFY_GATE_SNAPSHOT_OFF,口径变化即失配)④ 环境确已修好后强制重跑:SILLYSPEC_VERIFY_QUALITY_SCAN_RERUN=1')
523
+ throw new Error(`noAI 质量扫描复用上次失败结果(失败签名未变,跳过重跑)——${(t.reason || '测试失败').split('\n')[0]}。修复后重跑 sillyspec run verify${changeName ? ` --change ${changeName}` : ''},或按上方出路处置。`)
524
+ }
525
+ }
405
526
  // ── 隔离快照定向跑(2026-09-11 驾驭第十二批,用户第三次撞上:noAI 质量扫描是 verify 的
406
527
  // 第一实测执行点,第八批只接了 gates.js verify 块——本步仍跑 main 工作区,并行 WIP 在此弄红
407
528
  // 无辜变更)。与 gates.js 同款:createVerifyGateSnapshot(HEAD + 本变更文件集,native worktree
@@ -429,6 +550,13 @@ export async function executeVerifyQualityScan({ cwd, specBase, changeName, plat
429
550
  let smokeResult = null
430
551
  try {
431
552
  testCheck = runVerifyTestCheck({ cwd: gateCwd, specBase: gateSpecBase, changeName })
553
+ // ── 伪影分诊(ql-20260920-010 修复一 a):失败先分诊,命中 → 主树口径单点复跑对照 ──
554
+ testCheck = applyArtifactTriageRerun({
555
+ testCheck,
556
+ classify: classifyTestFailureArtifact,
557
+ snap,
558
+ rerunInMainScope: () => runVerifyTestCheck({ cwd, specBase, changeName }),
559
+ })
432
560
  printVerifyTestCheck(testCheck)
433
561
  lintCheck = runVerifyLintCheck({ cwd: gateCwd, specBase: gateSpecBase, timeoutMs: snap ? 5 * 60 * 1000 : undefined })
434
562
  // 快照 lint 超时回退主仓(2026-09-12 dogfood 两连实证:junction I/O 病态慢,3min/5min 均被
@@ -463,7 +591,7 @@ export async function executeVerifyQualityScan({ cwd, specBase, changeName, plat
463
591
  } finally {
464
592
  if (snap) { try { snap.cleanup() } catch {} }
465
593
  }
466
- storeQualityScan({ specBase, cwd, changeName, testResult: testCheck, lintResult: lintCheck, smokeResult, coverageCheck })
594
+ storeQualityScan({ specBase, cwd, changeName, testResult: testCheck, lintResult: lintCheck, smokeResult, coverageCheck, usedSnapshot: Boolean(snap) })
467
595
  const testFailed = testCheck.status === 'failed'
468
596
  const lintBlocked = shouldBlockVerifyLint(lintCheck)
469
597
  // 摩擦计数(friction-signal-hint task-03):记录条件与 throw 条件**刻意解耦**——advisory 档
@@ -23,6 +23,13 @@
23
23
  *
24
24
  * 全 advisory(D-006):不抛错阻断、不写门禁状态;任何异常 catch 后并入 degradedReason。
25
25
  * 纯读:不落盘、不改 guard/进度库。Windows 路径 \\→/ 归一,ESM-only,零新依赖。
26
+ *
27
+ * 跨仓分仓真实对账(2026-09-20 scope-audit-cross-repo task-02,design Wave 2):full-flow
28
+ * 非预执行形态下,计划侧跨仓条目(parseFileChangeListDetailed 的 .repo 标注)按 repoKey 分
29
+ * 组调 collectRepoActual 共享内核(cross-repo-reconcile.js,task-01 单一真相源)在该仓取
30
+ * actual——跨仓行从 v1 恒 untouched(⊘ 本表不含)升级为真实三态 + 行数 + crossRepo 归属;
31
+ * 信封增量 repos[](main 首位 + 各仓锚点/三态计数/degraded 态,--json 契约 v2 平台消费面)。
32
+ * degraded 仓退 v1 ⊘ 形态不炸主仓表;预执行形态不调内核(跨仓行保持清单形态)。
26
33
  */
27
34
  import { existsSync, readFileSync, readdirSync } from 'node:fs'
28
35
  import { createHash } from 'node:crypto'
@@ -32,8 +39,12 @@ import { computeGateProfile, resolveGateThresholds } from './quick-gate-profile.
32
39
  import { loadSpanRiskPatternsAllProjects } from './span-risk-surface.js'
33
40
  import { safeGit } from './git-helper.js'
34
41
  import { parseFileChangeListDetailed, pathMatches } from './change-list.js'
35
- import { classifyToolScaffold } from './worktree-apply.js'
42
+ import { classifyToolScaffold, filterDeliverableFiles } from './worktree-apply.js'
36
43
  import { parseRepoRegistry } from './stages/plan-postcheck.js'
44
+ // 跨仓 per-repo 采集共享内核(2026-09-20 scope-audit-cross-repo task-02 消费 task-01 产物)。
45
+ // 无环确认:cross-repo-reconcile 的静态依赖链(git-helper/plan-postcheck/worktree-apply/
46
+ // task-review 及其传递闭包)不 import 本模块——内核绝不 import scope-audit(评审 G3 定稿)。
47
+ import { collectRepoActual } from './cross-repo-reconcile.js'
37
48
  import { _readWorktreeMeta } from './contract-matrix.js'
38
49
 
39
50
  /** quick 会话 id 形态(与 run/command.js :652 QUICK_SID_RE 同款——crypto.randomUUID 前 8 hex) */
@@ -572,6 +583,25 @@ function sumTotals(rows) {
572
583
  return { additions, deletions }
573
584
  }
574
585
 
586
+ /**
587
+ * per-repo 汇总(信封 repos[].totals,2026-09-20 scope-audit-cross-repo task-02 --json 契约 v2):
588
+ * files/additions/deletions 沿 sumTotals 口径(binary 与 null 降级不计行数)+ 三态计数
589
+ * (verdict === 'planned'/'unplanned'/'untouched' 各自计数;行无 verdict 时不计三态——计划侧
590
+ * 降级形态的主仓行只有 files/additions/deletions,不出伪三态)。main 条目只喂主仓行、跨仓
591
+ * 条目只喂该仓行(调用方保证切片,函数本身不区分)。
592
+ */
593
+ function repoTotals(rows) {
594
+ const list = Array.isArray(rows) ? rows : []
595
+ const t = { files: list.length, ...sumTotals(list.filter(r => r && typeof r === 'object')), planned: 0, unplanned: 0, untouched: 0 }
596
+ for (const r of list) {
597
+ if (!r || typeof r !== 'object') continue
598
+ if (r.verdict === 'planned') t.planned++
599
+ else if (r.verdict === 'unplanned') t.unplanned++
600
+ else if (r.verdict === 'untouched') t.untouched++
601
+ }
602
+ return t
603
+ }
604
+
575
605
  /**
576
606
  * quick 模式对账:重跑 auditQuickCompletion 同款窗口归属(不改其判定语义,只消费结果)。
577
607
  * 他者会话声明索引与 --done 收尾同源实时采集(complete-handlers :1050 同款 merge),保证
@@ -926,20 +956,34 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
926
956
  const snap = readScopeSnapshot(changeDirInfo.dir, runtimeRoot, changeName)
927
957
  if (snap && snap.rows.length > 0) {
928
958
  const settleLabel = changeDirInfo.archived ? '已归档' : 'execute 已收尾(分支已清理,待 verify/archive)'
959
+ // repos[] 回放透传(2026-09-20 scope-audit-cross-repo,D-002@v1):新快照(跨仓对账上线后
960
+ // execute --done 落盘,complete.js `...snap` 展开零改动自动携带)原样回放;旧快照无该键
961
+ // → undefined(JSON.stringify 自动省略——单仓/旧快照零新增字段,additive 契约)。
962
+ const snapRepos = Array.isArray(snap.repos) ? snap.repos : undefined
963
+ // 旧快照跨仓注记(R-07):快照冻结于跨仓对账上线前(跨仓行恒 untouched 的 v1 ⊘ 形态、
964
+ // 无 repos 键)——照旧回放不重算(「快照说什么是什么」契约不破),注记指明人工渠道
965
+ // (该仓锚点锡点冻结在 execute-runs reviews,可人工 git diff 核实)。
966
+ const legacyCrossNote = !snapRepos && snap.rows.some(r => r && r.crossRepo)
967
+ ? ';快照冻结于跨仓对账上线前——跨仓段未按仓对账(⊘ 行为历史冻结值;如需跨仓真实态,该仓锚点锡点在 execute-runs reviews,可人工 git diff)'
968
+ : ''
929
969
  // 行数补采:快照可能落盘于 apply 后、tag 锚落地前的窗口(行数列全 —);tag 锚
930
970
  // (quick-df1fed77)恢复 merge-base 后,对冻结文件集按锚补采行数——文件集不重算
931
971
  // (冻结语义不变),仅行数从 — 复活;含主仓后续演进(同文件被再改会计入),口径标注。
932
972
  let rows = snap.rows
933
973
  let snapDegraded = snap.degradedReason || null
934
- const needsStats = rows.some(r => r && !Number.isFinite(r.additions) && r.kind !== 'binary')
974
+ // 补采只针对主仓行(评审 G1):needsStats 排除 crossRepo 行——补采对主仓根跑,跨仓行
975
+ // null 档会被主仓盘面误判成 {0,0,'deleted'} 伪数据(违反「不出伪数据」);跨仓行数
976
+ // 维持快照冻结值。
977
+ const needsStats = rows.some(r => r && !r.crossRepo && !Number.isFinite(r.additions) && r.kind !== 'binary')
935
978
  if (needsStats && actual && actual.ok && actual.baseAnchor) {
936
- const paths = rows.map(r => (r && r.path ? toPosix(r.path) : '')).filter(Boolean)
979
+ const paths = rows.filter(r => r && !r.crossRepo).map(r => (r && r.path ? toPosix(r.path) : '')).filter(Boolean)
937
980
  // 审查 C-F04:补采执行根走共享 resolveDiffRoot(form=worktree 时必须对 worktree 跑,
938
981
  // 与主链路 :629/:636 同源配对——直用 cwd 会基点×根错位)
939
982
  const stats = collectNumstatByPath(resolveDiffRoot(sb, changeName, actual.form, cwd), paths, { baseRef: actual.baseAnchor })
940
983
  let recovered = 0
941
984
  rows = rows.map(r => {
942
985
  if (!r || !r.path || Number.isFinite(r.additions) || r.kind === 'binary') return r
986
+ if (r.crossRepo) return r // 跨仓行维持快照冻结值(G1:主仓根补采会产伪数据)
943
987
  const st = stats.get(toPosix(r.path))
944
988
  if (!st) return r
945
989
  recovered++
@@ -953,12 +997,13 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
953
997
  baseAnchor: actual.baseAnchor,
954
998
  totals: { files: rows.length, ...sumTotals(rows) },
955
999
  rows,
1000
+ repos: snapRepos,
956
1001
  excluded: snap.excluded && Array.isArray(snap.excluded.foreignDeclared)
957
1002
  ? snap.excluded
958
1003
  : { foreignDeclared: [] },
959
1004
  patchSha256: snap.patchSha256 || null,
960
1005
  patchStatus: snap.patchStatus || null,
961
- note: `${settleLabel}——execute --done 时点冻结快照${snap.savedAt ? '(' + String(snap.savedAt).replace('T', ' ').slice(0, 19) + ' 落盘)' : ''}:文件集封闭在 apply 时点,主仓后续新文件不进表;行数按锚 ${actual.baseAnchor.slice(0, 7)}→当前工作树 补采(同文件后续演进会计入)`,
1006
+ note: `${settleLabel}——execute --done 时点冻结快照${snap.savedAt ? '(' + String(snap.savedAt).replace('T', ' ').slice(0, 19) + ' 落盘)' : ''}:文件集封闭在 apply 时点,主仓后续新文件不进表;行数按锚 ${actual.baseAnchor.slice(0, 7)}→当前工作树 补采(同文件后续演进会计入)${legacyCrossNote}`,
962
1007
  }
963
1008
  }
964
1009
  return {
@@ -970,12 +1015,13 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
970
1015
  ? snap.totals
971
1016
  : { files: snap.rows.length, ...sumTotals(snap.rows) },
972
1017
  rows: snap.rows,
1018
+ repos: snapRepos,
973
1019
  excluded: snap.excluded && Array.isArray(snap.excluded.foreignDeclared)
974
1020
  ? snap.excluded
975
1021
  : { foreignDeclared: [] },
976
1022
  patchSha256: snap.patchSha256 || null,
977
1023
  patchStatus: snap.patchStatus || null,
978
- note: `${settleLabel}——execute --done 时点冻结快照${snap.savedAt ? '(' + String(snap.savedAt).replace('T', ' ').slice(0, 19) + ' 落盘)' : ''}:范围封闭在 apply 时点,主仓后续改动不反映到本表;需要看当前工作区实时状态请跑 git status`,
1024
+ note: `${settleLabel}——execute --done 时点冻结快照${snap.savedAt ? '(' + String(snap.savedAt).replace('T', ' ').slice(0, 19) + ' 落盘)' : ''}:范围封闭在 apply 时点,主仓后续改动不反映到本表;需要看当前工作区实时状态请跑 git status${legacyCrossNote}`,
979
1025
  }
980
1026
  }
981
1027
  // 快照缺失 → 实时开放区间兜底(下方主链路),note 追加漂移警告
@@ -1018,12 +1064,90 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
1018
1064
  notes.push(`实际侧另有 ${actual.foreignExcluded} 个文件按他者会话声明退栈未进本表——「计划未动」行标注「疑似他者已实现·已退栈」者即此类(声明即归属,git diff 核实)`)
1019
1065
  }
1020
1066
 
1021
- // 计划侧跨仓段汇总(改进点 2):跨仓条目不进本仓对账(actual 只采主仓 git),行级标
1022
- // crossRepo + note 交代去对应仓对账。
1067
+ // —— 计划侧跨仓段分仓真实对账(2026-09-20 scope-audit-cross-repo task-02,design Wave 2)——
1068
+ // 旧形态「⊘ 行不在本仓对账面(实际侧只采主仓 git)」升级:plannedEntries 按 e.repo 分组,
1069
+ // 每组调 collectRepoActual 共享内核(task-01)在**该仓**取 actual(锚点四级:A reviews-range
1070
+ // > B head~1-window > C head-uncommitted-window > degraded),主仓同款三类差集出真实三态行
1071
+ // + 行数;degraded 仓(未注册/路径不可达/git 双源失败)退 v1 ⊘ 形态(untouched + crossRepo)
1072
+ // + 降级注记进 note,不炸主仓表(D-006 fail-soft)。
1073
+ // 行数由本集成层对内核产物跑 collectNumstatByPath(内核不 import scope-audit 防环,评审
1074
+ // G3——调用方职责);anchor.base 为 null(B/C 档降级锚)→ numstat 空 Map → degradedStat
1075
+ // 该仓根兜底(行数 null 降级档,不出伪数据)。跨仓行过滤口径与主仓 actual 侧一致
1076
+ // (filterDeliverableFiles:排 .sillyspec 运行时面/平台自装脚手架),unplanned 行的
1077
+ // classifyToolScaffold 软桶与主仓 unplanned 行同款。
1023
1078
  const crossRepoKeys = [...new Set(plannedEntries.map(e => e.repo).filter(Boolean))]
1079
+ const crossRepoRows = []
1080
+ const crossRepoEntries = []
1024
1081
  if (crossRepoKeys.length > 0) {
1025
1082
  const crossCount = plannedEntries.filter(e => e.repo).length
1026
- notes.push(`计划侧含 ${crossCount} 个跨仓文件(repo:${crossRepoKeys.join('、')})——⊘ 行不在本仓对账面(实际侧只采主仓 git),请到对应仓跑 scope-audit 对账`)
1083
+ notes.push(`计划侧含 ${crossCount} 个跨仓文件(repo:${crossRepoKeys.join('、')})——已按 local.yaml repos 注册表分仓对账(各仓锚点档见 repos[].anchor)`)
1084
+ for (const repoKey of crossRepoKeys) {
1085
+ const entries = plannedEntries.filter(e => e.repo === repoKey)
1086
+ const repoRows = []
1087
+ let repoActual = null
1088
+ let repoFailure = null
1089
+ try {
1090
+ repoActual = collectRepoActual({ repoKey, specBase: sb, cwd, runtimeRoot, changeName })
1091
+ } catch (e) {
1092
+ repoFailure = e && e.message ? String(e.message).split('\n')[0] : String(e)
1093
+ }
1094
+ if (repoFailure || !repoActual || repoActual.degradedReason) {
1095
+ // degraded 仓(内核 fail-soft 产物或调用点异常):v1 ⊘ 形态回退——该组行恒 untouched,
1096
+ // 降级原因进 note 与 repos[] 条目(平台消费方可感知),主仓表不受影响。
1097
+ const reason = repoFailure
1098
+ ? `内核采集异常: ${repoFailure}`
1099
+ : (repoActual && repoActual.degradedReason) || '跨仓采集不可用'
1100
+ for (const e of entries) {
1101
+ repoRows.push({ path: e.path, planned: e.operation || null, additions: 0, deletions: 0, kind: 'modified', verdict: 'untouched', crossRepo: repoKey })
1102
+ }
1103
+ notes.push(`跨仓 ${repoKey} 对账降级: ${reason}`)
1104
+ crossRepoEntries.push({
1105
+ key: repoKey,
1106
+ repoPath: repoActual ? repoActual.repoPath : null,
1107
+ anchor: repoActual ? repoActual.anchor : { source: 'degraded', base: null, head: null, label: 'degraded' },
1108
+ totals: repoTotals(repoRows),
1109
+ degraded: true,
1110
+ degradedReason: reason,
1111
+ })
1112
+ crossRepoRows.push(...repoRows)
1113
+ continue
1114
+ }
1115
+ // 真实三态:该仓 actual(filterDeliverableFiles 过滤,主仓口径)× 该组声明面 pathMatches
1116
+ // 双向容差(glob/目录前缀兼容,design 清单可写 glob)→ planned / unplanned / untouched
1117
+ const repoFiles = [...new Set(filterDeliverableFiles(repoActual.files).filter(Boolean))].map(toPosix).sort()
1118
+ const repoStats = collectNumstatByPath(repoActual.repoPath, repoFiles, { baseRef: repoActual.anchor.base })
1119
+ const repoMatched = new Set()
1120
+ for (const f of repoFiles) {
1121
+ const path = toPosix(f)
1122
+ const st = repoStats.get(path) || degradedStat(repoActual.repoPath, path)
1123
+ const row = { path, planned: null, additions: st.additions, deletions: st.deletions, kind: st.kind, verdict: 'unplanned', crossRepo: repoKey }
1124
+ const entry = entries.find(e => pathMatches(path, e.path))
1125
+ if (entry) {
1126
+ repoMatched.add(entry.path)
1127
+ row.planned = entry.operation || null
1128
+ row.verdict = 'planned'
1129
+ } else {
1130
+ // 工具/平台脚手架软桶(主仓 unplanned 行同款):CLI/平台自装文件不占「计划外」逐条清单
1131
+ const facility = classifyToolScaffold(path)
1132
+ if (facility) row.facility = facility
1133
+ }
1134
+ repoRows.push(row)
1135
+ }
1136
+ // 声明未动:该仓清单文件无实际改动 → 补行(行数 0/0,主仓补行同款;crossRepo 恒带)
1137
+ for (const e of entries) {
1138
+ if (repoMatched.has(e.path)) continue
1139
+ repoRows.push({ path: e.path, planned: e.operation || null, additions: 0, deletions: 0, kind: 'modified', verdict: 'untouched', crossRepo: repoKey })
1140
+ }
1141
+ crossRepoEntries.push({
1142
+ key: repoKey,
1143
+ repoPath: repoActual.repoPath,
1144
+ anchor: repoActual.anchor,
1145
+ totals: repoTotals(repoRows),
1146
+ degraded: false,
1147
+ degradedReason: null,
1148
+ })
1149
+ crossRepoRows.push(...repoRows)
1150
+ }
1027
1151
  }
1028
1152
 
1029
1153
  // —— 三态判定 ——
@@ -1055,19 +1179,40 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
1055
1179
  }
1056
1180
  // 计划未动:清单文件无实际改动 → 补行(行数 0/0);计划侧降级时不产(plannedEntries 为空天然不跑)
1057
1181
  for (const e of plannedEntries) {
1182
+ // 跨仓条目:由上方跨仓链路产出行(真实三态或 degraded ⊘ 形态)——主仓补行不接管
1183
+ // (旧行为的 untouched+crossRepo 补行即跨仓恒「计划未动」的失真源头,task-02 接管点)
1184
+ if (e.repo) continue
1058
1185
  if (matchedPlanned.has(e.path)) continue
1059
1186
  const row = { path: e.path, planned: e.operation || null, additions: 0, deletions: 0, kind: 'modified', verdict: 'untouched' }
1060
- if (e.repo) {
1061
- // 跨仓条目(改进点 2):不是「未动」是「本表不含」
1062
- row.crossRepo = e.repo
1063
- } else {
1064
- // 退栈排除 + 盘面已存在 → 他者会话已写盘(改进点 3);NEW: 前缀剥除后比对(pathMatches 同款口径)
1065
- const norm = toPosix(e.path).replace(/^NEW:/, '')
1066
- if (foreignExcludedSet.has(norm) && existsSync(join(numstatRoot, norm))) row.suspectedForeignDone = true
1067
- }
1187
+ // 退栈排除 + 盘面已存在 → 他者会话已写盘(改进点 3);NEW: 前缀剥除后比对(pathMatches 同款口径)
1188
+ const norm = toPosix(e.path).replace(/^NEW:/, '')
1189
+ if (foreignExcludedSet.has(norm) && existsSync(join(numstatRoot, norm))) row.suspectedForeignDone = true
1068
1190
  rows.push(row)
1069
1191
  }
1070
1192
 
1193
+ // 信封 repos[](--json 契约 v2,仅计划侧含跨仓条目时输出——单仓变更/预执行视图零新增字段,
1194
+ // additive):main 条目首位(repoPath=null 不冗余;anchor.source='main-<form>' 包装主仓
1195
+ // baseAnchor;totals 只计主仓行——跨仓行并入前取)+ 各跨仓仓条目(内核透传锚点/该仓行三态
1196
+ // 计数/degraded 态)。顶层 totals 保持全表合计(下方 sumTotals(rows)——跨仓行并入后自然
1197
+ // 计入,多仓变更合计随真实化变化正是修复目标:现状恒 0/0 是失真)。
1198
+ let reposOut = null
1199
+ if (crossRepoKeys.length > 0) {
1200
+ reposOut = [
1201
+ {
1202
+ key: 'main',
1203
+ repoPath: null,
1204
+ anchor: { source: `main-${actual.form}`, base: actual.baseAnchor || null, head: null, label: `${actual.form} 主仓锚` },
1205
+ totals: repoTotals(rows),
1206
+ degraded: false,
1207
+ degradedReason: null,
1208
+ },
1209
+ ...crossRepoEntries,
1210
+ ]
1211
+ }
1212
+ // 跨仓行并入总表(主仓行在前、跨仓组按 repoKey 声明序追加——顶层 totals 与 rows 消费方
1213
+ // 无序假设,渲染面 task-03 按 crossRepo 分组)
1214
+ rows.push(...crossRepoRows)
1215
+
1071
1216
  // 冻结 patch(collectPatch,execute --done 落盘方消费):与行数同根同锚(numstatRoot +
1072
1217
  // baseAnchor;无锚 HEAD 兜底同口径),全窗口 diff + untracked 自拼 hunk(buildFrozenPatch)
1073
1218
  const frozenPatch = collectPatch
@@ -1081,6 +1226,7 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
1081
1226
  baseAnchor: actual.baseAnchor || (usedHeadFallback ? 'head-uncommitted-window' : null),
1082
1227
  totals: { files: rows.length, ...sumTotals(rows) },
1083
1228
  rows,
1229
+ ...(reposOut ? { repos: reposOut } : {}),
1084
1230
  excluded: { foreignDeclared: [] },
1085
1231
  frozenPatch,
1086
1232
  note: notes.length > 0 ? notes.join(';') : null,
@@ -1112,8 +1258,19 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
1112
1258
  * 增量字段:跨仓条目标 repoKey / 退栈排除但已写盘的 planned 文件 / 计划外行里的工具平台
1113
1259
  * 脚手架桶——均为按存在性读取,缺省无字段零回归)
1114
1260
  * excluded: { foreignDeclared: Array<{ file: string, sessions: string[] }> },
1261
+ * repos?: Array<{ key: 'main'|string, repoPath: string|null,
1262
+ * anchor: { source: string, base: string|null, head: string|null, label: string },
1263
+ * totals: { files: number, additions: number, deletions: number,
1264
+ * planned: number, unplanned: number, untouched: number },
1265
+ * degraded: boolean, degradedReason: string|null }>,
1115
1266
  * note?: string|null,
1116
1267
  * gateProfile?: object|null }>}
1268
+ * repos(2026-09-20 scope-audit-cross-repo task-02,--json 契约 v2 增量信封,消费方=平台):
1269
+ * 仅 full-flow 模式且计划侧含跨仓条目、非预执行形态时输出(单仓变更/预执行视图零新增字段,
1270
+ * additive);main 条目始终首位(只计主仓行),跨仓条目按内核透传锚点档 + 该仓行三态计数,
1271
+ * degraded 仓(未注册/不可达/git 不可用)totals 恒全 untouched、degradedReason 非空。跨仓行
1272
+ * verdict 为真实三态(design 接口定义「行级」表);degraded 仓/预执行/旧快照回放行保持 v1
1273
+ * ⊘ 语义(恒 untouched + crossRepo)。settled 快照回放原样透传 snap.repos(旧快照无键不输出)。
1117
1274
  * gateProfile(2026-09-14-quick-exit-tiered-gates task-03 增量字段,消费方按存在性读取):
1118
1275
  * 仅 quick 模式携带——实时态透传 auditQuickCompletion 挂的 review.gateProfile(不重复计算);
1119
1276
  * 冻结重放态透传 quicklog/patches 记录冻结值(旧记录无 → rows 文件清单现算重放)。full-flow
@@ -1177,6 +1334,11 @@ export async function computeChangeScopeAudit({ cwd, specBase, changeName, platf
1177
1334
  * 出完整变化内容(git 原生 diff 格式)。锚点/执行根与表格行数同口径——worktree 形态 A 对
1178
1335
  * worktree 工作树跑、quick 对会话根跑、归档冻结形态用快照补采基点;无锚时 HEAD 未提交窗口兜底。
1179
1336
  *
1337
+ * 跨仓行路由(2026-09-20 scope-audit-cross-repo task-03,D-002@v1):filePath 命中结果 rows 的
1338
+ * crossRepo 行 → 跳过主仓冻结 patch 捷径与主仓锚链,在该仓根出 diff——A 档(reviews 锡点
1339
+ * base..head 封闭区间=跨仓版冻结档)优先,B 档实时窗口兜底,C 档注记看本体;旧快照(无 repos
1340
+ * 键)锚点不可得 → 提示人工渠道。主仓行路径零变化。
1341
+ *
1180
1342
  * untracked 新文件不在 git diff 内(整个文件都是新增)→ note 提示看文件本体;该锚点窗口
1181
1343
  * 无改动 → diff 空串 + note。纯读 fail-soft。
1182
1344
  *
@@ -1193,6 +1355,87 @@ export async function getFileDiff({ cwd, specBase, changeName, platformOpts, fil
1193
1355
  const norm = toPosix(filePath)
1194
1356
  const sb = specBase || join(cwd, '.sillyspec')
1195
1357
 
1358
+ // —— 跨仓行路由(2026-09-20 scope-audit-cross-repo task-03,D-002@v1)——
1359
+ // filePath 命中结果 rows 的跨仓行(crossRepo 字段)→ **先于主仓冻结 patch 捷径**改在该仓
1360
+ // 出 diff(防两仓同相对路径被主仓 patch 误切 + 跨仓内容不在主仓 patch 内会被误报「窗口外
1361
+ // 文件」,评审交叉点⑤)。A 档(repos[].anchor 的 base+head = execute task reviews 锡点
1362
+ // 区间)优先 `git diff base..head -- file` 在该仓根执行(封闭区间 = 跨仓版冻结档,收尾后
1363
+ // 不漂);B 档实时窗口兜底;C 档仅 status 面(无 diff 锚)→ 注记看本体。主仓行不进本分支,
1364
+ // 全链零变化。
1365
+ const crossRow = Array.isArray(result.rows)
1366
+ ? result.rows.find(x => x && typeof x === 'object' && toPosix(x.path) === norm && x.crossRepo)
1367
+ : null
1368
+ if (crossRow) {
1369
+ const repoKey = crossRow.crossRepo
1370
+ // 无 repos 键 = 旧快照回放态(v1:跨仓行恒 untouched 无锚)——该仓锚点不可得,不猜:
1371
+ // 按快照契约提示人工渠道(R-07 同款语义:锚点锡点冻在 execute-runs reviews)
1372
+ if (!Array.isArray(result.repos)) {
1373
+ return { ok: true, mode: result.mode, root: cwd, baseRef: null, anchorLabel: null, diff: null,
1374
+ note: '跨仓行快照冻结于跨仓对账上线前——请到该仓人工 git diff' }
1375
+ }
1376
+ const repoEntry = result.repos.find(x => x && typeof x === 'object' && x.key === repoKey)
1377
+ if (!repoEntry) {
1378
+ return { ok: false, mode: result.mode, root: cwd, baseRef: null, anchorLabel: null, diff: null,
1379
+ note: `跨仓 ${repoKey} 的 repos 信封条目缺失——锚点不可得` }
1380
+ }
1381
+ if (repoEntry.degraded || !repoEntry.repoPath) {
1382
+ return { ok: false, mode: result.mode, root: repoEntry.repoPath || cwd, baseRef: null, anchorLabel: null, diff: null,
1383
+ note: `跨仓 ${repoKey} 不可达${repoEntry.degradedReason ? '——' + repoEntry.degradedReason : ''}` }
1384
+ }
1385
+ const anchor = repoEntry.anchor && typeof repoEntry.anchor === 'object' ? repoEntry.anchor : null
1386
+ // A 档:reviews base..head 封闭区间(冻结语义,无后续演进混入)
1387
+ if (anchor && anchor.base && anchor.head) {
1388
+ const range = `${anchor.base}..${anchor.head}`
1389
+ const rangeLabel = '跨仓 reviews 锡点区间(冻结语义,无后续演进混入)'
1390
+ const rd = safeGit(repoEntry.repoPath, ['diff', '--no-color', range, '--', norm], { timeout: 30 * 1000 })
1391
+ if (rd.error) {
1392
+ return { ok: false, mode: result.mode, root: repoEntry.repoPath, baseRef: range, anchorLabel: '跨仓 reviews 锡点区间', diff: null,
1393
+ note: `git diff 执行失败: ${rd.error}` }
1394
+ }
1395
+ const diff = rd.value || ''
1396
+ if (diff) {
1397
+ return { ok: true, mode: result.mode, root: repoEntry.repoPath, baseRef: range, anchorLabel: rangeLabel, diff, note: null }
1398
+ }
1399
+ // 空 diff:盘上存在且未跟踪 → 新文件看本体(tracked 查询同主仓逻辑);否则区间内无改动
1400
+ if (existsSync(join(repoEntry.repoPath, norm))) {
1401
+ const tracked = safeGit(repoEntry.repoPath, ['ls-files', '--', norm], { timeout: 15 * 1000 })
1402
+ if (!tracked || !tracked.value || !tracked.value.trim()) {
1403
+ return { ok: true, mode: result.mode, root: repoEntry.repoPath, baseRef: range, anchorLabel: '跨仓 reviews 锡点区间', diff: null,
1404
+ note: '未跟踪新文件——不在 git diff 内,文件全部行为新增;直接查看文件本体' }
1405
+ }
1406
+ }
1407
+ return { ok: true, mode: result.mode, root: repoEntry.repoPath, baseRef: range, anchorLabel: '跨仓 reviews 锡点区间', diff: '',
1408
+ note: '该文件在此区间无 diff(未改动)' }
1409
+ }
1410
+ // C 档:仅 status 面(该仓无 diff 锚——未提交窗口),git diff 无意义 → 直接指引看本体
1411
+ if (anchor && anchor.source === 'head-uncommitted-window') {
1412
+ return { ok: true, mode: result.mode, root: repoEntry.repoPath, baseRef: 'HEAD', anchorLabel: anchor.label || 'HEAD 未提交窗口', diff: null,
1413
+ note: '该仓无 diff 锚(未提交窗口)——直接查看文件本体' }
1414
+ }
1415
+ // B 档(head~1-window)及未知降级锚(base=null):实时窗口兜底——diff HEAD 与主仓无锚
1416
+ // 兜底同口径,anchorLabel 带该仓降级锚档位、note 注明含后续演进
1417
+ const liveLabel = anchor && typeof anchor.label === 'string' && anchor.label ? anchor.label : 'HEAD 未提交窗口(降级锚兜底)'
1418
+ const ld = safeGit(repoEntry.repoPath, ['diff', '--no-color', 'HEAD', '--', norm], { timeout: 30 * 1000 })
1419
+ if (ld.error) {
1420
+ return { ok: false, mode: result.mode, root: repoEntry.repoPath, baseRef: 'HEAD', anchorLabel: liveLabel, diff: null,
1421
+ note: `git diff 执行失败: ${ld.error}` }
1422
+ }
1423
+ const liveDiff = ld.value || ''
1424
+ if (liveDiff) {
1425
+ return { ok: true, mode: result.mode, root: repoEntry.repoPath, baseRef: 'HEAD', anchorLabel: liveLabel, diff: liveDiff,
1426
+ note: '跨仓降级锚(非 reviews 锡点区间)——实时窗口 diff,含该仓后续演进' }
1427
+ }
1428
+ if (existsSync(join(repoEntry.repoPath, norm))) {
1429
+ const tracked = safeGit(repoEntry.repoPath, ['ls-files', '--', norm], { timeout: 15 * 1000 })
1430
+ if (!tracked || !tracked.value || !tracked.value.trim()) {
1431
+ return { ok: true, mode: result.mode, root: repoEntry.repoPath, baseRef: 'HEAD', anchorLabel: liveLabel, diff: null,
1432
+ note: '未跟踪新文件——不在 git diff 内,文件全部行为新增;直接查看文件本体' }
1433
+ }
1434
+ }
1435
+ return { ok: true, mode: result.mode, root: repoEntry.repoPath, baseRef: 'HEAD', anchorLabel: liveLabel, diff: '',
1436
+ note: '该文件在此锚点窗口内无 diff(未改动)' }
1437
+ }
1438
+
1196
1439
  // —— 真·当时内容比对(quick-359a48f1):已收尾变更优先冻结 patch 切片 ——
1197
1440
  // ① quick 记录态自带 frozenPatchPath;② full-flow 变更目录 scope-audit.patch。
1198
1441
  // patch 冻结在收尾时点,不含后续演进——实时锚 diff 只作 patch 缺失的兜底(口径标注)。
@@ -1333,12 +1576,28 @@ export function renderScopeAuditTable(result, opts = {}) {
1333
1576
 
1334
1577
  const maxRows = Number.isFinite(opts.maxRows) && opts.maxRows > 0 ? opts.maxRows : Infinity
1335
1578
  const shown = rows.slice(0, maxRows)
1579
+ // ── 跨仓行 label 判定基(2026-09-20 scope-audit-cross-repo task-03,design Wave 2 渲染面)──
1580
+ // repos[] 信封存在(task-02 起 full-flow 真实对账产出 / 新快照回放)→ 非 degraded 仓的跨仓行
1581
+ // 出真实三态带仓标(`✓ 计划内 [key]` 形态);degraded 仓与**无 repos 键**的旧形态(v1 快照
1582
+ // 回放 / 预执行清单视图——行 verdict 恒 untouched,与真实 untouched 在行内不可区分)保留
1583
+ // v1 ⊘ 标记(历史形态兼容,不假装对账过)。
1584
+ const reposEnvelope = Array.isArray(r.repos) ? r.repos.filter(x => x && typeof x === 'object') : null
1585
+ const crossEnvelopeEntries = reposEnvelope && reposEnvelope.length > 0
1586
+ ? reposEnvelope.filter(x => x.key && x.key !== 'main')
1587
+ : []
1588
+ const hasCrossEnvelope = crossEnvelopeEntries.length > 0
1589
+ const degradedRepoKeys = new Set(crossEnvelopeEntries.filter(x => x.degraded).map(x => x.key))
1590
+ const crossRepoRowLabel = (row) => {
1591
+ if (!hasCrossEnvelope || degradedRepoKeys.has(row.crossRepo)) return CROSS_REPO_LABEL
1592
+ const base = row.facility ? FACILITY_LABEL : VERDICT_LABEL[row.verdict]
1593
+ return base ? `${base} [${row.crossRepo}]` : CROSS_REPO_LABEL
1594
+ }
1336
1595
  for (const row of shown) {
1337
1596
  if (!row || typeof row !== 'object') continue
1338
1597
  const stat = row.kind === 'binary'
1339
1598
  ? { a: 'BIN', d: 'BIN' }
1340
1599
  : { a: fmtCount(row.additions) ?? '—', d: fmtCount(row.deletions) ?? '—' }
1341
- const label = row.crossRepo ? CROSS_REPO_LABEL
1600
+ const label = row.crossRepo ? crossRepoRowLabel(row)
1342
1601
  : row.suspectedForeignDone ? SUSPECTED_FOREIGN_LABEL
1343
1602
  : row.facility ? FACILITY_LABEL
1344
1603
  : VERDICT_LABEL[row.verdict] || ATTRIBUTION_LABEL[row.attribution] || ''
@@ -1353,8 +1612,11 @@ export function renderScopeAuditTable(result, opts = {}) {
1353
1612
  // ⚠️ 出口指引(full-flow 三态面):计划外补声明、计划未动确认遗漏。
1354
1613
  // 行级标注三分(scope-audit-cross-repo-blindness 改进点 1/2/3):跨仓行/疑似他者已实现行/
1355
1614
  // 工具设施桶各有去向说明,不混入笼统的「计划外/计划未动」计数逼用户排查噪音。
1356
- const unplanned = rows.filter(x => x && x.verdict === 'unplanned' && !x.facility).length
1357
- const facility = rows.filter(x => x && x.verdict === 'unplanned' && x.facility).length
1615
+ // 跨仓行不占主仓笼统 ⚠️ 计数(task-03):非 degraded 仓行已有 per-repo 汇总(见下方跨仓
1616
+ // 汇总段)、degraded 仓行有 ⊘ 降级行——planned/unplanned/untouched 三态与 facility 桶的
1617
+ // 主仓笼统计数一律排除 crossRepo 行(现状 unplanned 未排除致跨仓行冒充主仓计划外)。
1618
+ const unplanned = rows.filter(x => x && x.verdict === 'unplanned' && !x.facility && !x.crossRepo).length
1619
+ const facility = rows.filter(x => x && x.verdict === 'unplanned' && x.facility && !x.crossRepo).length
1358
1620
  const untouchedReal = rows.filter(x => x && x.verdict === 'untouched' && !x.suspectedForeignDone && !x.crossRepo).length
1359
1621
  const suspectedForeign = rows.filter(x => x && x.suspectedForeignDone).length
1360
1622
  const crossRepo = rows.filter(x => x && x.crossRepo).length
@@ -1371,7 +1633,28 @@ export function renderScopeAuditTable(result, opts = {}) {
1371
1633
  lines.push(` ℹ️ 工具/平台设施 ${facility} 文件(CLI/平台自装脚手架,非本变更改动,不占计划外)`)
1372
1634
  }
1373
1635
  if (crossRepo > 0) {
1374
- lines.push(` ℹ️ 跨仓 ${crossRepo} 文件(⊘ 行——本表不含,请到对应仓跑 scope-audit 对账)`)
1636
+ // 跨仓汇总段(task-03,design Wave 2):有 repos[] 信封 → 聚合计数行 + 逐仓一行真实汇总
1637
+ // (锚点档 anchor.label + 三态计数 + 行数);degraded 仓逐仓一行降级原因。无 repos 键
1638
+ // (v1 快照回放 / 预执行清单视图——跨仓行 ⊘ 本表不含)保留现行单行文案(历史形态)。
1639
+ // 全 degraded 形态下所有跨仓行仍是 ⊘(本表不含)→ 聚合行保留 v1 文案(计数可见 + 逐字
1640
+ // 兼容既有断言),非全 degraded 时行已真实进表 → 聚合行改「已按仓对账」措辞。
1641
+ if (hasCrossEnvelope) {
1642
+ const allDegraded = crossEnvelopeEntries.every(x => x.degraded)
1643
+ lines.push(allDegraded
1644
+ ? ` ℹ️ 跨仓 ${crossRepo} 文件(⊘ 行——本表不含,请到对应仓跑 scope-audit 对账)`
1645
+ : ` ℹ️ 跨仓 ${crossRepo} 文件——已按 local.yaml repos 分仓对账(逐仓汇总如下)`)
1646
+ for (const re of crossEnvelopeEntries) {
1647
+ if (re.degraded) {
1648
+ lines.push(` ℹ️ 跨仓 ${re.key}:⊘ 对账降级——${re.degradedReason || '未知原因'}`)
1649
+ continue
1650
+ }
1651
+ const t = re.totals && typeof re.totals === 'object' ? re.totals : {}
1652
+ const anchorTxt = re.anchor && typeof re.anchor.label === 'string' && re.anchor.label ? re.anchor.label : '—'
1653
+ lines.push(` ℹ️ 跨仓 ${re.key}:锚点 ${anchorTxt}——✓ ${t.planned ?? 0} / ⚠️ 计划外 ${t.unplanned ?? 0} / ⚠️ 未动 ${t.untouched ?? 0}(+${t.additions ?? 0}/-${t.deletions ?? 0})`)
1654
+ }
1655
+ } else {
1656
+ lines.push(` ℹ️ 跨仓 ${crossRepo} 文件(⊘ 行——本表不含,请到对应仓跑 scope-audit 对账)`)
1657
+ }
1375
1658
  }
1376
1659
  const undeclared = rows.filter(x => x && x.attribution === 'undeclared').length
1377
1660
  if (undeclared > 0) lines.push(` ⚠️ 未声明 ${undeclared} 文件——超出 allowedFiles 声明面,补 --files 声明或注明原因`)