sillyspec 3.28.8 → 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.
Files changed (69) hide show
  1. package/.claude/full.json +1 -0
  2. package/.claude/skills/sillyspec-execute/SKILL.md +222 -224
  3. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  4. package/.claude/skills/sillyspec-quick/SKILL.md +118 -117
  5. package/AGENTS.md +3 -2
  6. package/README.md +35 -11
  7. package/SKILL.md +24 -10
  8. package/SillySpec-/350/203/275/345/212/233/344/272/256/347/202/271/345/205/250/346/231/257-2026-09-14.pptx +0 -0
  9. package/package.json +1 -1
  10. package/src/change-list.js +73 -20
  11. package/src/change-risk-profile.js +20 -0
  12. package/src/config-schema.js +62 -0
  13. package/src/contract-matrix.js +84 -5
  14. package/src/cross-repo-reconcile.js +134 -0
  15. package/src/db.js +11 -3
  16. package/src/decision-distill.js +15 -3
  17. package/src/design-facts.js +89 -20
  18. package/src/docs-gate.js +105 -6
  19. package/src/doctor-diagnostics.js +224 -2
  20. package/src/friction-ledger.js +171 -0
  21. package/src/hooks/worktree-guard.js +13 -1
  22. package/src/index.js +206 -18
  23. package/src/knowledge-classify.js +382 -0
  24. package/src/knowledge-hits.js +74 -0
  25. package/src/knowledge-stats.js +180 -0
  26. package/src/probe7-anchor-check.js +69 -0
  27. package/src/progress/change-registry.js +183 -8
  28. package/src/progress/shared.js +3 -2
  29. package/src/progress.js +100 -8
  30. package/src/quick-gate-profile.js +251 -0
  31. package/src/quicklog.js +164 -18
  32. package/src/review-dispatch.js +2 -1
  33. package/src/run/command.js +131 -13
  34. package/src/run/complete-handlers.js +511 -21
  35. package/src/run/complete.js +123 -23
  36. package/src/run/concurrent-detect.js +98 -0
  37. package/src/run/gate-snapshot.js +110 -2
  38. package/src/run/gates.js +141 -16
  39. package/src/run/prompt.js +221 -11
  40. package/src/run/quick-audit.js +111 -13
  41. package/src/run/shared.js +147 -11
  42. package/src/run/stage.js +6 -0
  43. package/src/scan-diff.js +61 -30
  44. package/src/scan-postcheck.js +74 -0
  45. package/src/scan-refresh.js +477 -0
  46. package/src/scan-staleness.js +25 -13
  47. package/src/scope-audit.js +456 -56
  48. package/src/stage-contract.js +185 -2
  49. package/src/stage-review.js +23 -7
  50. package/src/stages/brainstorm.js +6 -0
  51. package/src/stages/cmd-existence.js +9 -3
  52. package/src/stages/execute.js +85 -3
  53. package/src/stages/knowledge.js +76 -6
  54. package/src/stages/plan-postcheck.js +32 -5
  55. package/src/stages/quick.js +1 -1
  56. package/src/stages/verify.js +1 -0
  57. package/src/sync-noise.js +170 -6
  58. package/src/sync.js +141 -9
  59. package/src/task-review.js +366 -46
  60. package/src/verify-postcheck.js +212 -55
  61. package/src/verify-probes.js +727 -16
  62. package/src/worktree-apply.js +685 -48
  63. package/src/worktree-deps.js +124 -3
  64. package/src/worktree.js +391 -15
  65. package/templates/agents-instruction.md +2 -2
  66. package/templates/prompts/testcase-design.md +2 -1
  67. package/templates/prompts/verify-probes.md +4 -1
  68. package/.patch16-tmp.mjs +0 -169
  69. package/sa-rename-probe/src/new.js +0 -4
@@ -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,17 +1193,37 @@ 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`)
1207
+ // 坑 verify-evidence-account-diff-misses-committed-changes(2026-09-13 实证):
1208
+ // wt-commit 流程把本变更提交落在**主仓 HEAD**,worktree 分支不前移——仅查 worktree
1209
+ // 侧 diffBase..HEAD 时已提交文件全部 miss(7 task diffHit=false 连环假红,verify 两连
1210
+ // 阻断只能走人工豁免)。并集**主仓同区间** diff(对齐 wt-commit 产出形态,坑文档建议的
1211
+ // 「change 基线 commit..HEAD」口径);主仓区间含并行会话提交 → 过他者声明过滤
1212
+ //(splitOwnVsForeignDiffFiles,与下方 fallback 同款)防 diffHit 假阳。主仓查询失败
1213
+ // 不损原 worktree 结果。
1214
+ if (files !== null && gitDir !== cwd) {
1215
+ try {
1216
+ const mainFiles = runGitDiffNameOnly(cwd, `${diffBase}..HEAD`)
1217
+ if (Array.isArray(mainFiles)) {
1218
+ const merged = [...new Set([...files, ...mainFiles])]
1219
+ const { own, foreign } = splitOwnVsForeignDiffFiles(cwd, changeName, merged, { specBase: sb })
1220
+ if (foreign.length > 0) {
1221
+ console.warn(`⚠️ verify 对账(主仓区间并集)已排除 ${foreign.length} 个并行会话声明的文件:${foreign.slice(0, 5).map(x => `${x.file}←${x.owners[0]}`).join(', ')}${foreign.length > 5 ? ' 等' : ''}`)
1222
+ }
1223
+ return own
1224
+ }
1225
+ } catch { /* 主仓区间查询失败 → 保持 worktree 结果 */ }
1226
+ }
1166
1227
  if (files !== null) return files
1167
1228
  // worktree diff 异常 → 落主仓兜底(保持与原 gitChangedFiles 相同的 null 语义)
1168
1229
  }
@@ -1306,7 +1367,7 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
1306
1367
  },
1307
1368
  })
1308
1369
  } else if (action === 'module-subset') {
1309
- mainResult = runModuleSubset({ cwd, specBase, changeName, hits, knownFailures })
1370
+ mainResult = runModuleSubset({ cwd, specBase, changeName, hits, knownFailures, changedFiles: lastChangedFiles })
1310
1371
  } else if (action === 'module-zero-hit-skip') {
1311
1372
  // module 模式 0 命中:不静默回退注定超时/含预存失败的全量(坑 verify-worktree-... 修复方向 3)。
1312
1373
  // 据 verify-result.md 自报告判定;想跑全量请显式设 test_strategy: full。
@@ -1637,12 +1698,21 @@ function runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fa
1637
1698
  * 串行跑命中的模块子集,聚合结果。
1638
1699
  * 返回 shape 与 runFullCommand 一致(status/command/exitCode/durationMs/outputTail/reason/resultPath)。
1639
1700
  */
1640
- function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [] }) {
1701
+ function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [], changedFiles = [] }) {
1641
1702
  const subsetStartedAt = Date.now()
1642
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
+ }
1643
1712
  const status = aggregateStatus(perModule)
1644
1713
 
1645
- 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)})`
1646
1716
  const exitCode = status === 'passed' ? 0 : 1
1647
1717
  const durationMs = Date.now() - subsetStartedAt
1648
1718
 
@@ -2074,6 +2144,8 @@ export function runVerifyRequiredEvidenceCheck({ cwd, specBase, changeName = nul
2074
2144
  * 天然不覆盖该类路径,硬要求交集必假红)。verifyStartAt 缺省走 R-05 fallback(design.md
2075
2145
  * created_at 之后宽容 + warning)。status 语义扩 'blocked'(missing 无豁免 / satisfied 核验
2076
2146
  * 不过)——gates 接线(task-03)据此阻断 verify 完成。
2147
+ * 逐文件存在性/mtime 双根取数(坑⑤ / D-005@v1):候选根 [cwd, worktree 根]——apply 前
2148
+ * 新文件只在 worktree,单查主仓必误报「文件不存在」;meta 缺失/in-place 退单根(零回归)。
2077
2149
  */
2078
2150
  function runRequiredEvidenceCheckV2({ items, slots, cwd, specBase, changeName, verifyStartAt }) {
2079
2151
  const warnings = []
@@ -2101,6 +2173,26 @@ function runRequiredEvidenceCheckV2({ items, slots, cwd, specBase, changeName, v
2101
2173
  changedSet = new Set((changed || []).map(p => String(p).replace(/\\/g, '/')))
2102
2174
  } catch { changedSet = null }
2103
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
+
2104
2196
  const detailed = []
2105
2197
  let blockedCount = 0
2106
2198
  for (const item of items) {
@@ -2123,11 +2215,17 @@ function runRequiredEvidenceCheckV2({ items, slots, cwd, specBase, changeName, v
2123
2215
  }
2124
2216
  for (const vf of slot.verifiedFiles) {
2125
2217
  const pathClass = classifyVerifiedFile(vf)
2126
- const abs = join(cwd, vf)
2127
- 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
2128
2226
  let mtimeOk = null
2129
2227
  if (filesExist && startAt) {
2130
- 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 }
2131
2229
  }
2132
2230
  let diffHit = null
2133
2231
  if (pathClass === 'code') {
@@ -2137,7 +2235,7 @@ function runRequiredEvidenceCheckV2({ items, slots, cwd, specBase, changeName, v
2137
2235
  }
2138
2236
  const ok = filesExist && mtimeOk !== false && diffHit !== false
2139
2237
  if (!ok) allOk = false
2140
- perFile.push({ path: vf, pathClass, filesExist, mtimeOk, diffHit,
2238
+ perFile.push({ path: vf, pathClass, filesExist, mtimeOk, diffHit, root: hitRoot,
2141
2239
  reason: !filesExist ? '文件不存在'
2142
2240
  : (mtimeOk === false ? `mtime 早于 verifyStartAt(${new Date(startAt).toISOString()})`
2143
2241
  : (diffHit === false ? '不在本变更 git diff 内' : null)) })
@@ -2225,7 +2323,7 @@ function normalizeReviewChangedFile(p) {
2225
2323
  * @returns {{ cardCount: number, declarations: Array<{task:string, path:string, isNew:boolean, raw:string, invalid:string|null}>, crossRepoCards: string[], noDeclarationCount: number }}
2226
2324
  */
2227
2325
  function collectDeclaredTargetFiles(specBase, changeName) {
2228
- const out = { cardCount: 0, declarations: [], crossRepoCards: [], noDeclarationCount: 0 }
2326
+ const out = { cardCount: 0, declarations: [], crossRepoCards: [], crossRepoDeclarations: {}, noDeclarationCount: 0 }
2229
2327
  const tasksDir = join(specBase, 'changes', changeName, 'tasks')
2230
2328
  if (!existsSync(tasksDir)) return out
2231
2329
  // task-NN.md 过滤 + 排序:与 plan-postcheck / task-review 的枚举口径一致,输出确定性
@@ -2237,7 +2335,18 @@ function collectDeclaredTargetFiles(specBase, changeName) {
2237
2335
  const fm = content.match(/^---\n([\s\S]*?)\n---/)?.[1] || ''
2238
2336
  const task = (fm.match(/^id:\s*(.+)/m)?.[1] || '').trim() || file.replace(/\.md$/, '')
2239
2337
  const repo = parseRepo(content)
2240
- 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
+ }
2241
2350
  const { entries, missing } = parseTargetFiles(content)
2242
2351
  if (missing) { out.noDeclarationCount++; continue }
2243
2352
  for (const e of entries) {
@@ -2304,14 +2413,22 @@ function parsePorcelainFilePaths(raw) {
2304
2413
  * resolveMainChangedFiles 同口径,三锚全缺 null);形态 B 取 merge-base hash(分支不存在/
2305
2414
  * merge-base 不可得 null);降级返回恒 null。
2306
2415
  *
2307
- * @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 }}
2308
2417
  */
2309
2418
  export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, changeName }) {
2310
- const metaPath = join(specBase, '.runtime', 'worktrees', changeName, 'meta.json')
2311
- 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'
2312
2425
  const sources = []
2313
2426
  const union = new Set()
2314
2427
  let foreignExcluded = 0
2428
+ // 退栈文件名单(scope-audit-cross-repo-blindness 改进点 3):原只回计数,scope-audit 侧
2429
+ // 无法区分「真未动」与「已写盘但被退栈」——补名单(仅文件名,owners 不重复携带),
2430
+ // 消费方按「planned ∩ excluded ∩ 盘面存在」标「疑似他者已实现」。
2431
+ const foreignExcludedFiles = []
2315
2432
  let baseAnchor = null
2316
2433
 
2317
2434
  if (form === 'worktree') {
@@ -2319,15 +2436,12 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
2319
2436
  // working-tree 并入、fail-open 均为该函数既有语义,不另造口径)——
2320
2437
  const files = resolveVerifyChangedFiles(cwd, changeName, null, { includeWorkingTree: true, specBase })
2321
2438
  if (files === null) {
2322
- return { ok: false, form, files: [], sources, foreignExcluded,
2439
+ return { ok: false, form, files: [], sources, foreignExcluded, foreignExcludedFiles,
2323
2440
  degradedReason: 'worktree 锚点 diff 与主仓 fallback 均失败(git 不可用 / 非仓库)', baseAnchor: null }
2324
2441
  }
2325
- // baseAnchor:读同一份 worktree meta.json 锚点(baselineCommit>actualBaseHash>baseHash,
2326
- // resolveMainChangedFiles 同口径;解析失败/三锚全缺 → null)
2327
- try {
2328
- const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
2329
- baseAnchor = meta?.baselineCommit || meta?.actualBaseHash || meta?.baseHash || null
2330
- } catch {}
2442
+ // baseAnchor:读形态判定命中的同一份 worktree meta.json 锚点(baselineCommit>
2443
+ // actualBaseHash>baseHash,resolveMainChangedFiles 同口径;三锚全缺 → null)
2444
+ baseAnchor = wtMeta?.meta?.baselineCommit || wtMeta?.meta?.actualBaseHash || wtMeta?.meta?.baseHash || null
2331
2445
  sources.push('worktree:diff-base..HEAD', 'worktree:status-porcelain(uncommitted)')
2332
2446
  for (const f of files) union.add(normalizeReconcilePath(f))
2333
2447
  } else {
@@ -2366,7 +2480,10 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
2366
2480
  let files = parsePorcelainFilePaths(statusRaw)
2367
2481
  if (files.length > 0) {
2368
2482
  const { own, foreign } = splitOwnVsForeignDiffFiles(cwd, changeName, files, { specBase })
2369
- 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
+ }
2370
2487
  files = own
2371
2488
  }
2372
2489
  for (const f of files) union.add(normalizeReconcilePath(f))
@@ -2383,13 +2500,13 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
2383
2500
  } catch { /* 兜底源失败 → 忽略 */ }
2384
2501
  }
2385
2502
  if (!diffOk && !statusOk) {
2386
- return { ok: false, form, files: [], sources, foreignExcluded,
2503
+ return { ok: false, form, files: [], sources, foreignExcluded, foreignExcludedFiles,
2387
2504
  degradedReason: 'merge-base 锚定 diff 未得且主仓 status 失败(git 不可用 / 非仓库 / 无锚点分支)', baseAnchor: null }
2388
2505
  }
2389
2506
  }
2390
2507
 
2391
2508
  const files = [...new Set(filterDeliverableFiles([...union]).filter(Boolean))].sort()
2392
- return { ok: true, form, files, sources, foreignExcluded, degradedReason: null, baseAnchor }
2509
+ return { ok: true, form, files, sources, foreignExcluded, foreignExcludedFiles, degradedReason: null, baseAnchor }
2393
2510
  }
2394
2511
 
2395
2512
  /**
@@ -2399,13 +2516,15 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
2399
2516
  * - 归属过滤:run 目录带 change 戳且不等值 → 他变更的 run 跳过(task-NN 跨变更同名,混扫必
2400
2517
  * 错归因;无戳旧 run 保留——向后兼容,task-review.js resolveLatestExecuteRunIdWithTasks 同款)。
2401
2518
  * - runId 形如 exec-YYYY-MM-DD-HHMMSS:字典序倒序 = 新 run 优先(review 以最新执行为准)。
2402
- * - changedFiles 是 agent 手写(注记/相对路径常态),归一后 exact match、首个命中即止——
2403
- * 归因只是③类报告的附注,错比漏代价低,不追求穷举。
2519
+ * - changedFiles 是 agent 手写(注记/相对路径常态),归一后 exact match、全命中收集(多归属,
2520
+ * 坑④ worktree-tick-guard-commit-only:原「首个命中即止」吞掉同文件多 task 的归属)——
2521
+ * 归因只是③类报告的附注,错比漏代价低;同一 taskId 去重,顺序 = 扫描序(run 倒序新优先 +
2522
+ * task 目录字典序)。
2404
2523
  *
2405
2524
  * @param {string} runtimeRoot .sillyspec/.runtime
2406
2525
  * @param {string} changeName
2407
2526
  * @param {string[]} paths 待归因的③类路径(git 口径的干净路径)
2408
- * @returns {Map<string, string>} normalized(path) → taskId
2527
+ * @returns {Map<string, string[]>} normalized(path) → taskId[](全命中收集,保序去重)
2409
2528
  */
2410
2529
  function attributeSuspectTasks(runtimeRoot, changeName, paths) {
2411
2530
  const map = new Map()
@@ -2432,7 +2551,10 @@ function attributeSuspectTasks(runtimeRoot, changeName, paths) {
2432
2551
  const taskId = (typeof review.task === 'string' && review.task.trim()) || t
2433
2552
  for (const cf of Array.isArray(review.changedFiles) ? review.changedFiles : []) {
2434
2553
  const n = normalizeReviewChangedFile(cf)
2435
- 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])
2436
2558
  }
2437
2559
  }
2438
2560
  }
@@ -2457,7 +2579,7 @@ function attributeSuspectTasks(runtimeRoot, changeName, paths) {
2457
2579
  * status: 'ok'|'missing_declared'|'undeclared'|'skipped'|'degraded',
2458
2580
  * matched: string[], // ①交集(唯一路径排序)
2459
2581
  * missing: Array<{task: string, path: string, isNew?: boolean}>, // ②声明没做(isNew 标 NEW: 声明,剥前缀 path)
2460
- * undeclared: Array<{path: string, suspectTask?: string}>, // ③做了没声明(suspectTask=review.json 尽力归因)
2582
+ * undeclared: Array<{path: string, suspectTask?: string}>, // ③做了没声明(suspectTask=review.json 尽力归因,多归属 join('、') 后的 string)
2461
2583
  * skipReason: string|null, // skipped/degraded 的原因(其余状态为 null)
2462
2584
  * notes: string[], // 跨仓卡剔除 / 并行 WIP 剔除等提示(additive)
2463
2585
  * form: 'worktree'|'post-apply'|null, // actual 形态(skipped 无 actual 时为 null;additive)
@@ -2479,8 +2601,24 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
2479
2601
 
2480
2602
  // —— 声明侧 ——
2481
2603
  const decl = collectDeclaredTargetFiles(sb, changeName)
2482
- for (const label of decl.crossRepoCards) {
2483
- 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
+ }
2484
2622
  }
2485
2623
  if (decl.cardCount === 0) {
2486
2624
  return { status: 'skipped', matched, missing, undeclared,
@@ -2494,7 +2632,7 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
2494
2632
  && decl.noDeclarationCount === decl.cardCount - decl.crossRepoCards.length) {
2495
2633
  return { status: 'skipped', matched, missing, undeclared,
2496
2634
  skipReason: `严格模式变更(created_at ≥ IR_STRICT_SINCE)主仓 task 卡全部未声明 target_files(${decl.noDeclarationCount}/${decl.cardCount - decl.crossRepoCards.length} 张主仓卡${decl.crossRepoCards.length > 0 ? `,另 ${decl.crossRepoCards.length} 张跨仓卡已剔除` : ''})`,
2497
- notes, form: null, sources: [],
2635
+ notes, form: null, sources: [], crossRepo,
2498
2636
  strictViolation: {
2499
2637
  code: 'target_files_all_missing_strict',
2500
2638
  message: `严格模式要求每个主仓 task 卡声明 target_files(计划改动的文件清单,taskcard 骨架已预置字段)——逐卡 Edit 填入:已存在文件写仓根相对精确路径,新建文件加 NEW: 前缀;填毕重跑 --done(进度不丢)`,
@@ -2506,7 +2644,7 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
2506
2644
  : `所有 task 卡均未声明 target_files(${decl.noDeclarationCount}/${decl.cardCount} 张无声明` +
2507
2645
  `${decl.crossRepoCards.length > 0 ? `,另 ${decl.crossRepoCards.length} 张跨仓卡已剔除` : ''})` +
2508
2646
  `——存量卡可忽略;新卡请补:每条为仓根相对精确文件路径,不存在的文件加 NEW: 前缀`
2509
- 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 }
2510
2648
  }
2511
2649
 
2512
2650
  // —— actual 侧(三源并集,两形态)——
@@ -2518,7 +2656,7 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
2518
2656
  // 降级(fail-soft 不误红):git 全部不可用 → actual 不可得,不产生 missing/undeclared
2519
2657
  return { status: 'degraded', matched, missing, undeclared,
2520
2658
  skipReason: `git 全部不可用,actual 不可得,对账降级跳过——${actual.degradedReason}`,
2521
- notes, form: actual.form, sources: actual.sources }
2659
+ notes, form: actual.form, sources: actual.sources, crossRepo }
2522
2660
  }
2523
2661
 
2524
2662
  // —— 三类差集 ——
@@ -2547,17 +2685,36 @@ export function reconcileTargetFiles({ cwd, specBase = null, changeName = null,
2547
2685
  missing.push(d.isNew ? { task: d.task, path: d.path, isNew: true } : { task: d.task, path: d.path })
2548
2686
  }
2549
2687
  }
2550
- // ③做了没声明(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)。
2551
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 = []
2552
2699
  for (const path of actual.files) {
2553
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
+ }
2554
2707
  const s = suspect.get(normalizeReviewChangedFile(path))
2555
- 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 设施桶同口径)`)
2556
2713
  }
2557
2714
 
2558
2715
  // 状态聚合:②在场即 ERROR 态优先(gates 阻断语义靠它兑现);仅③ → WARNING 态
2559
2716
  const status = missing.length > 0 ? 'missing_declared' : (undeclared.length > 0 ? 'undeclared' : 'ok')
2560
- 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 }
2561
2718
  }
2562
2719
 
2563
2720
  // ─────────────────────────────────────────────────────────────────────────────