sillyspec 3.28.9 → 3.28.10

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.
@@ -28,9 +28,10 @@ import { IR_STRICT_SINCE } from './constants.js'
28
28
  import { gitQuiet } from './git-helper.js'
29
29
  import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs'
30
30
  import { join } from 'path'
31
- import { verifyApiParity } from './contract-matrix.js'
31
+ import { verifyApiParity, _readWorktreeMeta } from './contract-matrix.js'
32
+ import { reconcileCrossRepoDeclarations } from './cross-repo-reconcile.js'
32
33
  import { parseFileChangeListDetailed, pathMatches } from './change-list.js'
33
- import { filterDeliverableFiles } from './worktree-apply.js'
34
+ import { filterDeliverableFiles, classifyToolScaffold } from './worktree-apply.js'
34
35
  // target_files 声明侧解析(Wave 1 已落地):依赖链已核实无环——plan-postcheck 不反向依赖本模块,
35
36
  // 且本模块已经 worktree-apply.js:21 间接依赖 plan-postcheck,此处改直连不引入新环(task-04)
36
37
  import { parseTargetFiles, parseRepo } from './stages/plan-postcheck.js'
@@ -605,6 +606,46 @@ function parseFlowValue(flowText, key) {
605
606
  return v.length > 0 ? v : null
606
607
  }
607
608
 
609
+ /**
610
+ * module 子集测试面的依赖测试自动发现(坑 module-test-face-rot,2026-09-16 本会话 5325f55
611
+ * 实证:local.yaml modules 的 test 命令是硬编码文件清单,src/verify-probes.js 命中 cli-core
612
+ * 但其断言测试 verify-probes-facts.test.mjs 不在清单 → quick --done 的 module 收窄实测漏掉
613
+ * 全量断言,回归漏到合并态才暴露)。发现口径:变更 src 文件的**直接 import 测试**(test/
614
+ * 下 *.test.* 内含 `../src/<changed>` 引用——静态/动态 import 通吃,子串匹配只可能多跑不漏)
615
+ * ∪ 变更的 test 文件本身;已被命中模块命令串覆盖的(命令串含该文件名)排除防重复。
616
+ * @param {{cwd: string, changedFiles: string[], coveredCommands?: string[]}} args
617
+ * @returns {string[]} 相对 cwd 的测试文件路径(posix 形态,已排序;无可发现 → [])
618
+ */
619
+ export function discoverModuleDependentTests({ cwd, changedFiles, coveredCommands = [] }) {
620
+ if (!Array.isArray(changedFiles) || changedFiles.length === 0) return []
621
+ const norm = (p) => String(p).replace(/\\/g, '/')
622
+ const changedSrc = [...new Set(changedFiles.map(norm))]
623
+ .filter(p => p.startsWith('src/') && /\.(js|mjs|cjs|ts)$/.test(p))
624
+ const changedTests = [...new Set(changedFiles.map(norm))]
625
+ .filter(p => /^test\//.test(p) && /\.test\.[cm]?js$/.test(p))
626
+ if (changedSrc.length === 0 && changedTests.length === 0) return []
627
+ const covered = coveredCommands.filter(Boolean).join(' ')
628
+ const found = new Set()
629
+ const testDir = join(cwd, 'test')
630
+ let entries = []
631
+ try { entries = readdirSync(testDir) } catch { entries = [] }
632
+ for (const f of entries) {
633
+ if (!/\.test\.[cm]?js$/.test(f)) continue
634
+ if (covered.includes(f)) continue
635
+ let content = ''
636
+ try { content = readFileSync(join(testDir, f), 'utf8') } catch { continue }
637
+ for (const src of changedSrc) {
638
+ if (content.includes(`../${src}`)) { found.add(`test/${f}`); break }
639
+ }
640
+ }
641
+ for (const t of changedTests) {
642
+ const fname = t.split('/').pop()
643
+ if (covered.includes(fname)) continue
644
+ if (existsSync(join(cwd, t))) found.add(t)
645
+ }
646
+ return [...found].sort()
647
+ }
648
+
608
649
  /**
609
650
  * 根据变更文件列表 + modules 映射,算出被命中的模块(去重保序)。
610
651
  * 文件路径以 module.path 为前缀(含子目录)即视为命中。
@@ -1049,20 +1090,20 @@ export function resolveVerifyChangedFiles(cwd, changeName, ctx = null, opts = {}
1049
1090
 
1050
1091
  // 并入 worktree 未提交改动(坑 module-subset-zero-hit-uncommitted,2026-08-21 实证:
1051
1092
  // 子代理默认不 commit,真实改动全在 worktree working-tree——只看 base..HEAD commit diff
1052
- // 时 module 映射 0 命中直接跳过(frontend/** 变更未命中 frontend 模块)。与
1053
- // generateTaskReviewDrafts 的并入口径同源:meta.worktreePath 下 status --porcelain 文件
1093
+ // 时 module 映射 0 命中直接跳过(frontend/** 变更未命中 frontend 模块)。口径与
1094
+ // collectWorktreeChangedFiles(task-review.js,D-004@v1 单一真相 helper)同源,但本段是
1095
+ // form A 专属变体(滤网保 .sillyspec/docs/** 交付物)——已知残留 R-09,待下次触碰统一。
1054
1096
  // (排除 .sillyspec/ 运行时产物)。opt-in(默认关):d drafts 有自己的并入点,避免双并。
1055
1097
  if (includeWorkingTree && mainFiles !== null) {
1056
1098
  try {
1057
- // P1 修复(2026-09-07):metaPath 吃 opts.specBase——此前硬编码 join(cwd,'.sillyspec'),
1058
- // 平台模式(specRoot 与 source_root 分离)下 worktree meta 静默读不到 → 形态 A 并入失效 → ②类假红。
1059
- // 与下方 resolveMainChangedFiles 的 specBase 兜底同口径。
1060
- const metaPath = join(opts.specBase || join(cwd, '.sillyspec'), '.runtime', 'worktrees', changeName, 'meta.json')
1061
- if (changeName && existsSync(metaPath)) {
1062
- const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
1063
- const wtGitDir = (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath))
1064
- ? meta.worktreePath
1065
- : cwd
1099
+ // 双根读取(坑 platform-dual-root-form-misjudge,2026-09-15 复盘实证):P1 修复
1100
+ // (2026-09-07)只吃 opts.specBase 单路径——平台模式(specRoot 与 source_root 分离)下
1101
+ // meta 若落项目侧 .sillyspec/.runtime(resolveRuntimeRoot 的 localSpecBase 兜底形)仍读
1102
+ // 不到 → 形态 A 并入失效 → ②类假红。_readWorktreeMeta 双候选(specBase 优先 +
1103
+ // cwd/.sillyspec 兜底,contract-matrix 既有样板)两形全覆盖。
1104
+ const found = changeName ? _readWorktreeMeta(opts.specBase || null, cwd, changeName) : null
1105
+ if (found) {
1106
+ const wtGitDir = found.gitDir
1066
1107
  const wtStatus = gitQuiet(wtGitDir, ['status', '--porcelain', '--untracked-files=all'], { timeout: 30000, trim: false })
1067
1108
  // P1 修复(2026-09-07):过滤器改 filterDeliverableFiles 口径(worktree-apply.js 同款)——
1068
1109
  // 此前 `.sillyspec/` 一刀切把 docs 交付物也滤掉,声明了模块文档的 task 在形态 A 下落②类假红
@@ -1152,16 +1193,16 @@ export function resolveVerifyChangedFiles(cwd, changeName, ctx = null, opts = {}
1152
1193
  function resolveMainChangedFiles(cwd, changeName, specBase = null) {
1153
1194
  const sb = specBase || join(cwd, '.sillyspec')
1154
1195
  if (changeName) {
1155
- const metaPath = join(sb, '.runtime', 'worktrees', changeName, 'meta.json')
1156
- if (existsSync(metaPath)) {
1157
- let meta = null
1158
- try { meta = JSON.parse(readFileSync(metaPath, 'utf8')) } catch {}
1196
+ // 双根读取(坑 platform-dual-root-form-misjudge):specBase(平台模式=specRoot)单路径在
1197
+ // meta 落项目侧 .sillyspec 时静默 miss → 锚点 diff 整链失效落主仓 fallback。双候选
1198
+ // (specBase 优先 + cwd/.sillyspec 兜底)两形全覆盖;sb 仍作他者声明过滤根(原语义不动)。
1199
+ const found = _readWorktreeMeta(specBase || null, cwd, changeName)
1200
+ if (found) {
1201
+ const meta = found.meta
1159
1202
  // 优先 baselineCommit/actualBaseHash(baseline checkpoint 之后),回退 baseHash
1160
1203
  const diffBase = meta?.baselineCommit || meta?.actualBaseHash || meta?.baseHash
1161
1204
  if (diffBase) {
1162
- const gitDir = (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath))
1163
- ? meta.worktreePath
1164
- : cwd
1205
+ const gitDir = found.gitDir
1165
1206
  const files = runGitDiffNameOnly(gitDir, `${diffBase}..HEAD`)
1166
1207
  // 坑 verify-evidence-account-diff-misses-committed-changes(2026-09-13 实证):
1167
1208
  // wt-commit 流程把本变更提交落在**主仓 HEAD**,worktree 分支不前移——仅查 worktree
@@ -1326,7 +1367,7 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
1326
1367
  },
1327
1368
  })
1328
1369
  } else if (action === 'module-subset') {
1329
- mainResult = runModuleSubset({ cwd, specBase, changeName, hits, knownFailures })
1370
+ mainResult = runModuleSubset({ cwd, specBase, changeName, hits, knownFailures, changedFiles: lastChangedFiles })
1330
1371
  } else if (action === 'module-zero-hit-skip') {
1331
1372
  // module 模式 0 命中:不静默回退注定超时/含预存失败的全量(坑 verify-worktree-... 修复方向 3)。
1332
1373
  // 据 verify-result.md 自报告判定;想跑全量请显式设 test_strategy: full。
@@ -1657,12 +1698,21 @@ function runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fa
1657
1698
  * 串行跑命中的模块子集,聚合结果。
1658
1699
  * 返回 shape 与 runFullCommand 一致(status/command/exitCode/durationMs/outputTail/reason/resultPath)。
1659
1700
  */
1660
- function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [] }) {
1701
+ function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [], changedFiles = [] }) {
1661
1702
  const subsetStartedAt = Date.now()
1662
1703
  const perModule = hits.map(h => runOneModule(h.name, h.test, cwd, knownFailures))
1704
+ // 依赖测试伪模块(module-test-face-rot,2026-09-16 本会话实证):硬编码模块测试清单不含
1705
+ // 变更 src 文件的直接断言测试 → module 收窄漏全量断言。附加执行「import 变更 src 的测试 ∪
1706
+ // 变更的 test 文件」中未被命中模块命令覆盖的部分;无额外文件零行为(不新增失败面)。
1707
+ const deps = discoverModuleDependentTests({ cwd, changedFiles, coveredCommands: hits.map(h => h.test) })
1708
+ if (deps.length > 0) {
1709
+ perModule.push(runOneModule('deps(auto)', `node --test ${deps.slice(0, 30).join(' ')}`, cwd, knownFailures))
1710
+ console.log(`ℹ️ module 子集已附加依赖测试伪模块 deps(auto):${deps.length} 个(import 变更 src/变更的 test 本体,未被模块命令串覆盖——治硬编码清单腐烂)`)
1711
+ }
1663
1712
  const status = aggregateStatus(perModule)
1664
1713
 
1665
- const command = `module[${hits.map(h => h.name).join(',')}]`
1714
+ let command = `module[${hits.map(h => h.name).join(',')}]`
1715
+ if (deps.length > 0) command += `+deps(${Math.min(deps.length, 30)})`
1666
1716
  const exitCode = status === 'passed' ? 0 : 1
1667
1717
  const durationMs = Date.now() - subsetStartedAt
1668
1718
 
@@ -2094,6 +2144,8 @@ export function runVerifyRequiredEvidenceCheck({ cwd, specBase, changeName = nul
2094
2144
  * 天然不覆盖该类路径,硬要求交集必假红)。verifyStartAt 缺省走 R-05 fallback(design.md
2095
2145
  * created_at 之后宽容 + warning)。status 语义扩 'blocked'(missing 无豁免 / satisfied 核验
2096
2146
  * 不过)——gates 接线(task-03)据此阻断 verify 完成。
2147
+ * 逐文件存在性/mtime 双根取数(坑⑤ / D-005@v1):候选根 [cwd, worktree 根]——apply 前
2148
+ * 新文件只在 worktree,单查主仓必误报「文件不存在」;meta 缺失/in-place 退单根(零回归)。
2097
2149
  */
2098
2150
  function runRequiredEvidenceCheckV2({ items, slots, cwd, specBase, changeName, verifyStartAt }) {
2099
2151
  const warnings = []
@@ -2121,6 +2173,26 @@ function runRequiredEvidenceCheckV2({ items, slots, cwd, specBase, changeName, v
2121
2173
  changedSet = new Set((changed || []).map(p => String(p).replace(/\\/g, '/')))
2122
2174
  } catch { changedSet = null }
2123
2175
 
2176
+ // ── 候选根解析(坑⑤ / D-005@v1,消费侧双根):apply 前新文件只在 worktree,逐文件核验
2177
+ // 单查主仓 join(cwd, vf) 必误报「文件不存在」错误阻断 verify。worktree 根口径与
2178
+ // resolveVerifyChangedFiles(~1061 metaPath 段)同源:meta 在场 && 非 in-place-fallback
2179
+ // && worktreePath 目录存在 → roots = [cwd, worktreePath];任一不满足退 [cwd] 单根
2180
+ // (零回归)。解析异常 fail-open 退单根 + warning(不新增阻断路径)。静默双根——对齐
2181
+ // buildAcceptanceHints(verify-probes.js:223)双根先例,核验明细 root 字段已可辨。──
2182
+ let roots = [cwd]
2183
+ try {
2184
+ const metaPath = join(specBase || join(cwd, '.sillyspec'), '.runtime', 'worktrees', changeName, 'meta.json')
2185
+ if (existsSync(metaPath)) {
2186
+ const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
2187
+ if (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath)) {
2188
+ roots = [cwd, meta.worktreePath]
2189
+ }
2190
+ }
2191
+ } catch (e) {
2192
+ roots = [cwd]
2193
+ warnings.push(`worktree meta 解析失败(${e.message}),evidence 双根核验退主仓单根`)
2194
+ }
2195
+
2124
2196
  const detailed = []
2125
2197
  let blockedCount = 0
2126
2198
  for (const item of items) {
@@ -2143,11 +2215,17 @@ function runRequiredEvidenceCheckV2({ items, slots, cwd, specBase, changeName, v
2143
2215
  }
2144
2216
  for (const vf of slot.verifiedFiles) {
2145
2217
  const pathClass = classifyVerifiedFile(vf)
2146
- const abs = join(cwd, vf)
2147
- const filesExist = existsSync(abs)
2218
+ // 双根取数(坑⑤ / D-005@v1,形态沿 buildAcceptanceHints 双根先例):任一根存在即
2219
+ // filesExist=true;双根都在取 worktree 根(R-05:新改动所在,主仓旧 mtime 不误判
2220
+ // 过期)——倒序找命中根(roots=[cwd] 时即单根现状;roots=[cwd, wt] 时先试 wt 再回退)。
2221
+ let hitRoot = null
2222
+ for (let i = roots.length - 1; i >= 0; i--) {
2223
+ if (existsSync(join(roots[i], vf))) { hitRoot = roots[i]; break }
2224
+ }
2225
+ const filesExist = hitRoot !== null
2148
2226
  let mtimeOk = null
2149
2227
  if (filesExist && startAt) {
2150
- try { mtimeOk = statSync(abs).mtimeMs >= startAt - 60_000 } catch { mtimeOk = null }
2228
+ try { mtimeOk = statSync(join(hitRoot, vf)).mtimeMs >= startAt - 60_000 } catch { mtimeOk = null }
2151
2229
  }
2152
2230
  let diffHit = null
2153
2231
  if (pathClass === 'code') {
@@ -2157,7 +2235,7 @@ function runRequiredEvidenceCheckV2({ items, slots, cwd, specBase, changeName, v
2157
2235
  }
2158
2236
  const ok = filesExist && mtimeOk !== false && diffHit !== false
2159
2237
  if (!ok) allOk = false
2160
- perFile.push({ path: vf, pathClass, filesExist, mtimeOk, diffHit,
2238
+ perFile.push({ path: vf, pathClass, filesExist, mtimeOk, diffHit, root: hitRoot,
2161
2239
  reason: !filesExist ? '文件不存在'
2162
2240
  : (mtimeOk === false ? `mtime 早于 verifyStartAt(${new Date(startAt).toISOString()})`
2163
2241
  : (diffHit === false ? '不在本变更 git diff 内' : null)) })
@@ -2245,7 +2323,7 @@ function normalizeReviewChangedFile(p) {
2245
2323
  * @returns {{ cardCount: number, declarations: Array<{task:string, path:string, isNew:boolean, raw:string, invalid:string|null}>, crossRepoCards: string[], noDeclarationCount: number }}
2246
2324
  */
2247
2325
  function collectDeclaredTargetFiles(specBase, changeName) {
2248
- const out = { cardCount: 0, declarations: [], crossRepoCards: [], noDeclarationCount: 0 }
2326
+ const out = { cardCount: 0, declarations: [], crossRepoCards: [], crossRepoDeclarations: {}, noDeclarationCount: 0 }
2249
2327
  const tasksDir = join(specBase, 'changes', changeName, 'tasks')
2250
2328
  if (!existsSync(tasksDir)) return out
2251
2329
  // task-NN.md 过滤 + 排序:与 plan-postcheck / task-review 的枚举口径一致,输出确定性
@@ -2257,7 +2335,18 @@ function collectDeclaredTargetFiles(specBase, changeName) {
2257
2335
  const fm = content.match(/^---\n([\s\S]*?)\n---/)?.[1] || ''
2258
2336
  const task = (fm.match(/^id:\s*(.+)/m)?.[1] || '').trim() || file.replace(/\.md$/, '')
2259
2337
  const repo = parseRepo(content)
2260
- if (repo) { out.crossRepoCards.push(`${task}(repo: ${repo})`); continue }
2338
+ if (repo) {
2339
+ out.crossRepoCards.push(`${task}(repo: ${repo})`)
2340
+ // 跨仓声明留存(坑 cross-repo-reconcile-blindness,2026-09-15 复盘兑现 D-004 分期):
2341
+ // 主仓对账仍剔除跨仓卡(主仓 actual 永远对不上跨仓声明),但声明不丢弃——
2342
+ // reconcileCrossRepoDeclarations 到各自仓对账(advisory)
2343
+ const crossEntries = parseTargetFiles(content).entries || []
2344
+ if (crossEntries.length > 0) {
2345
+ ;(out.crossRepoDeclarations[repo] = out.crossRepoDeclarations[repo] || [])
2346
+ .push(...crossEntries.map(e => ({ task, path: e.path, isNew: e.isNew })))
2347
+ }
2348
+ continue
2349
+ }
2261
2350
  const { entries, missing } = parseTargetFiles(content)
2262
2351
  if (missing) { out.noDeclarationCount++; continue }
2263
2352
  for (const e of entries) {
@@ -2324,14 +2413,22 @@ function parsePorcelainFilePaths(raw) {
2324
2413
  * resolveMainChangedFiles 同口径,三锚全缺 null);形态 B 取 merge-base hash(分支不存在/
2325
2414
  * merge-base 不可得 null);降级返回恒 null。
2326
2415
  *
2327
- * @returns={{ ok: boolean, form: 'worktree'|'post-apply', files: string[], sources: string[], foreignExcluded: number, degradedReason: string|null, baseAnchor: string|null }}
2416
+ * @returns={{ ok: boolean, form: 'worktree'|'post-apply', files: string[], sources: string[], foreignExcluded: number, foreignExcludedFiles: string[], degradedReason: string|null, baseAnchor: string|null }}
2328
2417
  */
2329
2418
  export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, changeName }) {
2330
- const metaPath = join(specBase, '.runtime', 'worktrees', changeName, 'meta.json')
2331
- const form = existsSync(metaPath) ? 'worktree' : 'post-apply'
2419
+ // 形态判定双根化(坑 platform-dual-root-form-misjudge,2026-09-15 复盘实证:平台模式
2420
+ // specBase=specRoot 时项目侧 .sillyspec 的 meta 读不到 → 误判 post-apply → 按主仓 git
2421
+ // 三源对账 → ②类 21 条全量假红阻断 verify 收尾)。_readWorktreeMeta 双候选(specBase
2422
+ // 优先 + cwd/.sillyspec 兜底,contract-matrix 既有样板)为形态判定的唯一权威入口。
2423
+ const wtMeta = _readWorktreeMeta(specBase || null, cwd, changeName)
2424
+ const form = wtMeta ? 'worktree' : 'post-apply'
2332
2425
  const sources = []
2333
2426
  const union = new Set()
2334
2427
  let foreignExcluded = 0
2428
+ // 退栈文件名单(scope-audit-cross-repo-blindness 改进点 3):原只回计数,scope-audit 侧
2429
+ // 无法区分「真未动」与「已写盘但被退栈」——补名单(仅文件名,owners 不重复携带),
2430
+ // 消费方按「planned ∩ excluded ∩ 盘面存在」标「疑似他者已实现」。
2431
+ const foreignExcludedFiles = []
2335
2432
  let baseAnchor = null
2336
2433
 
2337
2434
  if (form === 'worktree') {
@@ -2339,15 +2436,12 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
2339
2436
  // working-tree 并入、fail-open 均为该函数既有语义,不另造口径)——
2340
2437
  const files = resolveVerifyChangedFiles(cwd, changeName, null, { includeWorkingTree: true, specBase })
2341
2438
  if (files === null) {
2342
- return { ok: false, form, files: [], sources, foreignExcluded,
2439
+ return { ok: false, form, files: [], sources, foreignExcluded, foreignExcludedFiles,
2343
2440
  degradedReason: 'worktree 锚点 diff 与主仓 fallback 均失败(git 不可用 / 非仓库)', baseAnchor: null }
2344
2441
  }
2345
- // baseAnchor:读同一份 worktree meta.json 锚点(baselineCommit>actualBaseHash>baseHash,
2346
- // resolveMainChangedFiles 同口径;解析失败/三锚全缺 → null)
2347
- try {
2348
- const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
2349
- baseAnchor = meta?.baselineCommit || meta?.actualBaseHash || meta?.baseHash || null
2350
- } catch {}
2442
+ // baseAnchor:读形态判定命中的同一份 worktree meta.json 锚点(baselineCommit>
2443
+ // actualBaseHash>baseHash,resolveMainChangedFiles 同口径;三锚全缺 → null)
2444
+ baseAnchor = wtMeta?.meta?.baselineCommit || wtMeta?.meta?.actualBaseHash || wtMeta?.meta?.baseHash || null
2351
2445
  sources.push('worktree:diff-base..HEAD', 'worktree:status-porcelain(uncommitted)')
2352
2446
  for (const f of files) union.add(normalizeReconcilePath(f))
2353
2447
  } else {
@@ -2386,7 +2480,10 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
2386
2480
  let files = parsePorcelainFilePaths(statusRaw)
2387
2481
  if (files.length > 0) {
2388
2482
  const { own, foreign } = splitOwnVsForeignDiffFiles(cwd, changeName, files, { specBase })
2389
- if (foreign.length > 0) foreignExcluded = foreign.length
2483
+ if (foreign.length > 0) {
2484
+ foreignExcluded = foreign.length
2485
+ for (const fo of foreign) foreignExcludedFiles.push(normalizeReconcilePath(fo.file))
2486
+ }
2390
2487
  files = own
2391
2488
  }
2392
2489
  for (const f of files) union.add(normalizeReconcilePath(f))
@@ -2403,13 +2500,13 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
2403
2500
  } catch { /* 兜底源失败 → 忽略 */ }
2404
2501
  }
2405
2502
  if (!diffOk && !statusOk) {
2406
- return { ok: false, form, files: [], sources, foreignExcluded,
2503
+ return { ok: false, form, files: [], sources, foreignExcluded, foreignExcludedFiles,
2407
2504
  degradedReason: 'merge-base 锚定 diff 未得且主仓 status 失败(git 不可用 / 非仓库 / 无锚点分支)', baseAnchor: null }
2408
2505
  }
2409
2506
  }
2410
2507
 
2411
2508
  const files = [...new Set(filterDeliverableFiles([...union]).filter(Boolean))].sort()
2412
- return { ok: true, form, files, sources, foreignExcluded, degradedReason: null, baseAnchor }
2509
+ return { ok: true, form, files, sources, foreignExcluded, foreignExcludedFiles, degradedReason: null, baseAnchor }
2413
2510
  }
2414
2511
 
2415
2512
  /**
@@ -2419,13 +2516,15 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
2419
2516
  * - 归属过滤:run 目录带 change 戳且不等值 → 他变更的 run 跳过(task-NN 跨变更同名,混扫必
2420
2517
  * 错归因;无戳旧 run 保留——向后兼容,task-review.js resolveLatestExecuteRunIdWithTasks 同款)。
2421
2518
  * - runId 形如 exec-YYYY-MM-DD-HHMMSS:字典序倒序 = 新 run 优先(review 以最新执行为准)。
2422
- * - changedFiles 是 agent 手写(注记/相对路径常态),归一后 exact match、首个命中即止——
2423
- * 归因只是③类报告的附注,错比漏代价低,不追求穷举。
2519
+ * - changedFiles 是 agent 手写(注记/相对路径常态),归一后 exact match、全命中收集(多归属,
2520
+ * 坑④ worktree-tick-guard-commit-only:原「首个命中即止」吞掉同文件多 task 的归属)——
2521
+ * 归因只是③类报告的附注,错比漏代价低;同一 taskId 去重,顺序 = 扫描序(run 倒序新优先 +
2522
+ * task 目录字典序)。
2424
2523
  *
2425
2524
  * @param {string} runtimeRoot .sillyspec/.runtime
2426
2525
  * @param {string} changeName
2427
2526
  * @param {string[]} paths 待归因的③类路径(git 口径的干净路径)
2428
- * @returns {Map<string, string>} normalized(path) → taskId
2527
+ * @returns {Map<string, string[]>} normalized(path) → taskId[](全命中收集,保序去重)
2429
2528
  */
2430
2529
  function attributeSuspectTasks(runtimeRoot, changeName, paths) {
2431
2530
  const map = new Map()
@@ -2452,7 +2551,10 @@ function attributeSuspectTasks(runtimeRoot, changeName, paths) {
2452
2551
  const taskId = (typeof review.task === 'string' && review.task.trim()) || t
2453
2552
  for (const cf of Array.isArray(review.changedFiles) ? review.changedFiles : []) {
2454
2553
  const n = normalizeReviewChangedFile(cf)
2455
- if (n && wantSet.has(n) && !map.has(n)) map.set(n, taskId)
2554
+ if (!n || !wantSet.has(n)) continue
2555
+ // 多归属:同文件被多 task 声明时全收集(D-004@v1,坑④)——旧单归属在此吞掉后续 task
2556
+ const list = map.get(n)
2557
+ if (list) { if (!list.includes(taskId)) list.push(taskId) } else map.set(n, [taskId])
2456
2558
  }
2457
2559
  }
2458
2560
  }
@@ -2477,7 +2579,7 @@ function attributeSuspectTasks(runtimeRoot, changeName, paths) {
2477
2579
  * status: 'ok'|'missing_declared'|'undeclared'|'skipped'|'degraded',
2478
2580
  * matched: string[], // ①交集(唯一路径排序)
2479
2581
  * missing: Array<{task: string, path: string, isNew?: boolean}>, // ②声明没做(isNew 标 NEW: 声明,剥前缀 path)
2480
- * undeclared: Array<{path: string, suspectTask?: string}>, // ③做了没声明(suspectTask=review.json 尽力归因)
2582
+ * undeclared: Array<{path: string, suspectTask?: string}>, // ③做了没声明(suspectTask=review.json 尽力归因,多归属 join('、') 后的 string)
2481
2583
  * skipReason: string|null, // skipped/degraded 的原因(其余状态为 null)
2482
2584
  * notes: string[], // 跨仓卡剔除 / 并行 WIP 剔除等提示(additive)
2483
2585
  * form: 'worktree'|'post-apply'|null, // actual 形态(skipped 无 actual 时为 null;additive)
@@ -2499,8 +2601,24 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
2499
2601
 
2500
2602
  // —— 声明侧 ——
2501
2603
  const decl = collectDeclaredTargetFiles(sb, changeName)
2502
- for (const label of decl.crossRepoCards) {
2503
- notes.push(`跨仓 task 卡 ${label} 已剔除——跨仓对账不在本变更范围(D-004),其声明留待后续分期`)
2604
+ // —— 跨仓 per-repo 对账(坑 cross-repo-reconcile-blindness,2026-09-15 复盘兑现 D-004 分期:
2605
+ // 此前 22 个跨仓文件在归档表全标「计划未动」、跨仓全靠人工到对应仓解释)——advisory 不阻断:
2606
+ // 锚点是各仓 HEAD~1..HEAD 最近提交窗口(多 task 同仓只反映最近一笔,锚点脆弱期会产②类假
2607
+ // 信号),只报告不翻转主仓状态。结果经 crossRepo 字段(gates 渲染明细)与 notes(摘要随
2608
+ // 各状态出口流动)双通道出口。
2609
+ let crossRepo = []
2610
+ if (decl.crossRepoCards.length > 0) {
2611
+ try {
2612
+ crossRepo = reconcileCrossRepoDeclarations({ specBase: sb, cwd, declarationsByRepo: decl.crossRepoDeclarations })
2613
+ } catch { crossRepo = [] /* 跨仓对账异常不拖垮主仓链(advisory 定位) */ }
2614
+ for (const label of decl.crossRepoCards) {
2615
+ notes.push(`跨仓 task 卡 ${label} 不进主仓对账(主仓 actual 对不上跨仓声明,D-004)——其声明已转 per-repo 对账(见下方跨仓对账段)`)
2616
+ }
2617
+ for (const r of crossRepo) {
2618
+ notes.push(r.degradedReason
2619
+ ? `跨仓 ${r.repo}:${r.degradedReason}`
2620
+ : `跨仓 ${r.repo} 对账:声明 ${r.declaredCount} / 实测 ${r.actualCount} / 对上 ${r.matched.length} / ②缺 ${r.missing.length} / ③多 ${r.undeclared.length}${r.scaffoldCount > 0 ? `(另有 ${r.scaffoldCount} 脚手架已聚合)` : ''}——advisory(锚点=该仓最近提交窗口)`)
2621
+ }
2504
2622
  }
2505
2623
  if (decl.cardCount === 0) {
2506
2624
  return { status: 'skipped', matched, missing, undeclared,
@@ -2514,7 +2632,7 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
2514
2632
  && decl.noDeclarationCount === decl.cardCount - decl.crossRepoCards.length) {
2515
2633
  return { status: 'skipped', matched, missing, undeclared,
2516
2634
  skipReason: `严格模式变更(created_at ≥ IR_STRICT_SINCE)主仓 task 卡全部未声明 target_files(${decl.noDeclarationCount}/${decl.cardCount - decl.crossRepoCards.length} 张主仓卡${decl.crossRepoCards.length > 0 ? `,另 ${decl.crossRepoCards.length} 张跨仓卡已剔除` : ''})`,
2517
- notes, form: null, sources: [],
2635
+ notes, form: null, sources: [], crossRepo,
2518
2636
  strictViolation: {
2519
2637
  code: 'target_files_all_missing_strict',
2520
2638
  message: `严格模式要求每个主仓 task 卡声明 target_files(计划改动的文件清单,taskcard 骨架已预置字段)——逐卡 Edit 填入:已存在文件写仓根相对精确路径,新建文件加 NEW: 前缀;填毕重跑 --done(进度不丢)`,
@@ -2526,7 +2644,7 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
2526
2644
  : `所有 task 卡均未声明 target_files(${decl.noDeclarationCount}/${decl.cardCount} 张无声明` +
2527
2645
  `${decl.crossRepoCards.length > 0 ? `,另 ${decl.crossRepoCards.length} 张跨仓卡已剔除` : ''})` +
2528
2646
  `——存量卡可忽略;新卡请补:每条为仓根相对精确文件路径,不存在的文件加 NEW: 前缀`
2529
- return { status: 'skipped', matched, missing, undeclared, skipReason: reason, notes, form: null, sources: [] }
2647
+ return { status: 'skipped', matched, missing, undeclared, skipReason: reason, notes, form: null, sources: [], crossRepo }
2530
2648
  }
2531
2649
 
2532
2650
  // —— actual 侧(三源并集,两形态)——
@@ -2538,7 +2656,7 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
2538
2656
  // 降级(fail-soft 不误红):git 全部不可用 → actual 不可得,不产生 missing/undeclared
2539
2657
  return { status: 'degraded', matched, missing, undeclared,
2540
2658
  skipReason: `git 全部不可用,actual 不可得,对账降级跳过——${actual.degradedReason}`,
2541
- notes, form: actual.form, sources: actual.sources }
2659
+ notes, form: actual.form, sources: actual.sources, crossRepo }
2542
2660
  }
2543
2661
 
2544
2662
  // —— 三类差集 ——
@@ -2567,17 +2685,36 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
2567
2685
  missing.push(d.isNew ? { task: d.task, path: d.path, isNew: true } : { task: d.task, path: d.path })
2568
2686
  }
2569
2687
  }
2570
- // ③做了没声明(scope creep,WARNING):actual − 声明集;suspectTask 尽力归因仅报告(D-002)
2688
+ // ③做了没声明(scope creep,WARNING):actual − 声明集;suspectTask 尽力归因仅报告(D-002)。
2689
+ // 归因多归属(attributeSuspectTasks 返 string[],坑④):组装边界 join('、') 成 string——
2690
+ // gates.js / archive-delta.js 等下游渲染零改动(suspectTask 对外形态保持 string)。
2571
2691
  const suspect = attributeSuspectTasks(rt, changeName, actual.files.filter(p => !declaredKeySet.has(pathKey(p))))
2692
+ // 脚手架软桶(坑 reconcile-undeclared-scaffold-flood,2026-09-15 复盘实证:worktree baseline
2693
+ // 拷入的 .claude/skills、attachments 等设施文件 58 条逐条进③类、每轮 verify 重复刷屏):
2694
+ // classifyToolScaffold(worktree-apply / scope-audit 同款口径)命中的工具/平台设施文件聚合
2695
+ // 为一行 note——环境设施面非交付物,逐条列③类只会淹没真 scope creep。全为脚手架时③类
2696
+ // 判 ok(脚手架不构成 scope creep 语义)。
2697
+ const scaffoldBuckets = { platform: 0, tool: 0 }
2698
+ const scaffoldSamples = []
2572
2699
  for (const path of actual.files) {
2573
2700
  if (declaredKeySet.has(pathKey(path))) continue
2701
+ const bucket = classifyToolScaffold(path)
2702
+ if (bucket) {
2703
+ scaffoldBuckets[bucket] = (scaffoldBuckets[bucket] || 0) + 1
2704
+ if (scaffoldSamples.length < 5) scaffoldSamples.push(path)
2705
+ continue
2706
+ }
2574
2707
  const s = suspect.get(normalizeReviewChangedFile(path))
2575
- undeclared.push(s ? { path, suspectTask: s } : { path })
2708
+ undeclared.push(Array.isArray(s) && s.length > 0 ? { path, suspectTask: s.join('、') } : { path })
2709
+ }
2710
+ const scaffoldTotal = scaffoldBuckets.platform + scaffoldBuckets.tool
2711
+ if (scaffoldTotal > 0) {
2712
+ notes.push(`③类另有 ${scaffoldTotal} 个工具/平台脚手架文件(platform ${scaffoldBuckets.platform} / tool ${scaffoldBuckets.tool},如 ${scaffoldSamples.join('、')}${scaffoldTotal > scaffoldSamples.length ? ' 等' : ''})已聚合不逐条列——非交付物(scope-audit 设施桶同口径)`)
2576
2713
  }
2577
2714
 
2578
2715
  // 状态聚合:②在场即 ERROR 态优先(gates 阻断语义靠它兑现);仅③ → WARNING 态
2579
2716
  const status = missing.length > 0 ? 'missing_declared' : (undeclared.length > 0 ? 'undeclared' : 'ok')
2580
- return { status, matched, missing, undeclared, skipReason: null, notes, form: actual.form, sources: actual.sources }
2717
+ return { status, matched, missing, undeclared, undeclaredScaffold: scaffoldTotal, skipReason: null, notes, form: actual.form, sources: actual.sources, crossRepo }
2581
2718
  }
2582
2719
 
2583
2720
  // ─────────────────────────────────────────────────────────────────────────────