sillyspec 3.28.9 → 3.28.11

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 (55) hide show
  1. package/.claude/skills/sillyspec-execute/SKILL.md +223 -222
  2. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  3. package/.claude/skills/sillyspec-quick/SKILL.md +8 -10
  4. package/.claude/skills/sillyspec-verify/SKILL.md +91 -87
  5. package/package.json +1 -1
  6. package/src/change-list.js +73 -20
  7. package/src/change-risk-profile.js +51 -3
  8. package/src/config-schema.js +51 -0
  9. package/src/contract-matrix.js +84 -5
  10. package/src/cross-repo-reconcile.js +134 -0
  11. package/src/design-facts.js +94 -18
  12. package/src/docs-check.js +14 -10
  13. package/src/docs-debt.js +7 -2
  14. package/src/docs-gate.js +42 -15
  15. package/src/endpoint-extractor.js +50 -2
  16. package/src/foreign-declared.js +25 -16
  17. package/src/hooks/worktree-guard.js +75 -2
  18. package/src/index.js +147 -2
  19. package/src/knowledge-classify.js +20 -1
  20. package/src/machine-interface.js +26 -0
  21. package/src/probe7-anchor-check.js +87 -0
  22. package/src/progress/stage-machine.js +34 -5
  23. package/src/progress.js +1 -1
  24. package/src/quicklog.js +240 -5
  25. package/src/run/command.js +25 -2
  26. package/src/run/complete-handlers.js +172 -1
  27. package/src/run/complete.js +143 -11
  28. package/src/run/gate-snapshot.js +372 -2
  29. package/src/run/gates.js +130 -12
  30. package/src/run/prompt.js +166 -7
  31. package/src/run/shared.js +81 -1
  32. package/src/scope-audit.js +101 -31
  33. package/src/stage-contract-spec.js +14 -0
  34. package/src/stage-contract.js +314 -8
  35. package/src/stage-review-checklist.js +3 -1
  36. package/src/stage-review.js +137 -2
  37. package/src/stages/archive.js +9 -5
  38. package/src/stages/brainstorm.js +7 -1
  39. package/src/stages/cmd-existence.js +9 -3
  40. package/src/stages/execute.js +62 -8
  41. package/src/stages/knowledge.js +24 -3
  42. package/src/stages/plan-postcheck.js +74 -10
  43. package/src/stages/plan.js +8 -1
  44. package/src/stages/quick.js +5 -5
  45. package/src/stages/verify.js +3 -2
  46. package/src/sync-noise.js +57 -1
  47. package/src/sync.js +77 -4
  48. package/src/task-review.js +84 -29
  49. package/src/verify-facts-schema.js +78 -9
  50. package/src/verify-postcheck.js +439 -68
  51. package/src/verify-probes.js +1156 -45
  52. package/src/worktree-apply.js +323 -8
  53. package/src/worktree-deps.js +24 -1
  54. package/src/worktree.js +274 -10
  55. package/src/write-audit.js +55 -0
@@ -168,6 +168,37 @@ function looksLikePath(p) {
168
168
  */
169
169
  const INCIDENTAL_RE = /顺带修复|附带修复|顺带|drive-?by|incidental/i
170
170
 
171
+ /**
172
+ * 从清单子段标题 / 路径前缀解析跨仓 repoKey(scope-audit-cross-repo-blindness 改进点 2,
173
+ * 2026-09-15):多仓变更 design 清单按「main 仓 / sub-xxx / spdemo」分段书写,主仓跑
174
+ * scope-audit 时这些条目不是主仓路径,须识别出来标注「跨仓(本表不含)」而非恒「计划未动」。
175
+ *
176
+ * 两种书写形态:
177
+ * ① 子段标题含注册 repoKey(`### sub-grid-security`、`### sub-grid-security 新增文件`、
178
+ * `### spdemo 仓`)——标题 token 化后含 key,或以 key 开头且带「仓/仓库」后缀;
179
+ * ② 路径 cell 显式前缀 `cross-repo:<repo-key>:`(_module-map.yaml 同款约定)。
180
+ * 只认调用方传入的注册表 repoKeys(local.yaml repos 段)——未注册的 key 不剥前缀不标仓,
181
+ * fail-closed 防把普通路径误判跨仓。repoKeys 缺省 null → 全部按主仓解析(零回归)。
182
+ */
183
+ function detectRepoFromSubsection(title, repoKeys) {
184
+ if (!Array.isArray(repoKeys) || repoKeys.length === 0) return null
185
+ const tokens = String(title || '').split(/[\s()()::,,、/\\]+/)
186
+ for (const k of repoKeys) {
187
+ if (tokens.includes(k)) return k
188
+ if (new RegExp(`^${k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\s*仓|\\s*仓库)$`).test(String(title || '').trim())) return k
189
+ }
190
+ return null
191
+ }
192
+
193
+ /** `cross-repo:<key>:` 前缀拆分(key 须在注册表内);无前缀/未注册 → null */
194
+ function splitCrossRepoPrefix(rawPath, repoKeys) {
195
+ if (!Array.isArray(repoKeys) || repoKeys.length === 0) return null
196
+ const m = String(rawPath || '').match(/^cross-repo:([A-Za-z0-9_.\-]+):(.*)$/)
197
+ if (!m) return null
198
+ if (!repoKeys.includes(m[1])) return null
199
+ return { repo: m[1], path: m[2].trim() }
200
+ }
201
+
171
202
  /**
172
203
  * 从 design.md 解析文件变更清单(含 incidental 标记)。兼容两种真实写法:
173
204
  * ① 表格:`| 操作 | 文件路径 | 说明 |`(brainstorm 模板默认)
@@ -175,13 +206,15 @@ const INCIDENTAL_RE = /顺带修复|附带修复|顺带|drive-?by|incidental/i
175
206
  * 表头列顺序自适应(定位「文件/路径/file/path」列,列顺序写反时不会把操作名当路径);
176
207
  * 忽略 `.sillyspec/`(keepSillyspecDocs=true 时保留 `.sillyspec/docs/`)与占位符(`—`/`-`/`N/A`/`无`);「不修改/暂缓」子段下的路径会被排除;
177
208
  * CRLF 容错。incidental 嗅探:表格非路径列(说明列)+ 路径 cell 原始值(剥注释前的括号内容)+ 列表项原文。
209
+ * repoKeys 传入时识别跨仓子段 / `cross-repo:` 前缀(detectRepoFromSubsection),命中的条目
210
+ * repo 字段携带 repoKey(默认 null = 主仓)——scope-audit 计划侧跨仓标注的数据源。
178
211
  *
179
212
  * 内核函数:parseFileChangeList(Set 包装,向后兼容)与 parseFileChangeListDetailed 共用,
180
213
  * 单一真相源,避免两处各自重写清单解析漂移。
181
214
  * @param {string} designMdPath - design.md 文件路径
182
- * @returns {Array<{ path: string, operation: string|null, incidental: boolean }>}(顺序按首次出现,exclude 子段移除)
215
+ * @returns {Array<{ path: string, operation: string|null, incidental: boolean, repo: string|null }>}(顺序按首次出现,exclude 子段移除)
183
216
  */
184
- function _parseFileListDetailed(designMdPath, { keepSillyspecDocs = false } = {}) {
217
+ function _parseFileListDetailed(designMdPath, { keepSillyspecDocs = false, repoKeys = null } = {}) {
185
218
  if (!designMdPath || !existsSync(designMdPath)) return []
186
219
 
187
220
  const content = readFileSync(designMdPath, 'utf8').replace(/\r\n/g, '\n')
@@ -202,10 +235,11 @@ function _parseFileListDetailed(designMdPath, { keepSillyspecDocs = false } = {}
202
235
  let opColIdx = -1 // 操作列下标(表头扫描后定位;-1 = 无操作列 → operation=null)
203
236
  let listMode = 'include' // include | exclude(分类列表子段)
204
237
  let currentOp = null // 分类列表子标题推导的 operation(表格模式不用)
205
- const entries = new Map() // path -> { path, operation, incidental }(exclude 子段 delete)
238
+ let currentRepo = null // 分类列表子标题推导的跨仓 repoKey(detectRepoFromSubsection)
239
+ const entries = new Map() // path -> { path, operation, incidental, repo }(exclude 子段 delete)
206
240
 
207
241
  for (const line of lines) {
208
- // 分类列表子标题:### 新增文件 / ### 修改文件 / ### 不修改文件
242
+ // 分类列表子标题:### 新增文件 / ### 修改文件 / ### 不修改文件 / ### sub-grid-security(跨仓段)
209
243
  const subHeader = line.match(/^###\s+(.+?)\s*$/)
210
244
  if (subHeader) {
211
245
  // ⚠️ EXCLUDE 优先:「### 不修改文件」含「修改」二字,会被 classifySubsectionOp 误匹配。
@@ -213,9 +247,13 @@ function _parseFileListDetailed(designMdPath, { keepSillyspecDocs = false } = {}
213
247
  if (EXCLUDE_SUBSECTION_RE.test(subHeader[1])) {
214
248
  listMode = 'exclude'
215
249
  currentOp = null
250
+ currentRepo = null
216
251
  } else {
217
252
  listMode = 'include'
218
253
  currentOp = classifySubsectionOp(subHeader[1])
254
+ // 跨仓段识别(scope-audit-cross-repo-blindness 改进点 2):子段标题含注册 repoKey
255
+ // 或以「<key> 仓/仓库」收尾 → 其下条目标 repo;不含 key 的普通操作子段 → 回主仓。
256
+ currentRepo = detectRepoFromSubsection(subHeader[1], repoKeys)
219
257
  }
220
258
  continue
221
259
  }
@@ -237,7 +275,12 @@ function _parseFileListDetailed(designMdPath, { keepSillyspecDocs = false } = {}
237
275
  continue
238
276
  }
239
277
 
240
- const filePath = normalizePath(cells[pathColIdx] || '')
278
+ const filePathRaw = normalizePath(cells[pathColIdx] || '')
279
+ // 跨仓显式前缀(scope-audit-cross-repo-blindness 改进点 2):`cross-repo:<key>:` 剥前缀
280
+ // 标 repo;无前缀/未注册 key → 按当前子段 repo(缺省主仓 null)
281
+ const crossRepo = splitCrossRepoPrefix(filePathRaw, repoKeys)
282
+ const filePath = crossRepo ? crossRepo.path : filePathRaw
283
+ const entryRepo = crossRepo ? crossRepo.repo : currentRepo
241
284
  // 列定位兜底:取到纯操作词(表头未命中且列顺序异常)→ 跳过,避免把「修改」当路径
242
285
  if (/^(新增|修改|删除|重命名|new|modify|update|delete|create|rename)$/i.test(filePath)) continue
243
286
  if (isPlaceholder(filePath) || (filePath.startsWith('.sillyspec/') && !(keepSillyspecDocs && filePath.startsWith('.sillyspec/docs/')))) continue
@@ -252,29 +295,37 @@ function _parseFileListDetailed(designMdPath, { keepSillyspecDocs = false } = {}
252
295
  // 校验器)。拆分符:+ / / / | / 、 / ; / ;——每个 token 独立过校验入表;单路径零变化。
253
296
  const pathTokens = splitCombinedPaths(filePath)
254
297
  if (listMode === 'exclude') { for (const t of pathTokens) entries.delete(t); continue }
255
- for (const t of pathTokens) entries.set(t, { path: t, operation, incidental })
298
+ for (const t of pathTokens) entries.set(t, { path: t, operation, incidental, repo: entryRepo })
256
299
  continue
257
300
  }
258
301
 
259
302
  // 分类列表项:`- path` / `- \`path\``
260
303
  const listItem = line.match(/^\s*-\s+(.+)/)
261
304
  if (listItem) {
262
- // 坑 brainstorm-gate-agent-unavailable-and-list-path-parse 坑2:列表项「路径:描述」
263
- // 整行当路径 existsSync 假阴性(文件存在却报幻觉路径)——剥首个中/英冒号及之后描述。
264
- // 仅列表分支剥、且冒号前段须 looksLikePath:normalizePath 全局剥会毁 Windows 绝对路径
265
- // (C:\...),列表项是仓根相对路径无盘符,安全。
305
+ // 跨仓显式前缀(改进点 2,同表格分支口径)须先于「路径:描述」剥离判——
306
+ // `cross-repo:<key>:<path>` 的首个冒号在 key 段内,先剥描述会把整条砍成 `cross-repo`。
307
+ const preCross = splitCrossRepoPrefix(normalizePath(listItem[1]), repoKeys)
266
308
  let rawItem = listItem[1]
267
- const colonIdx = rawItem.search(/[::]/)
268
- if (colonIdx > 0) {
269
- const head = rawItem.slice(0, colonIdx).trim()
270
- if (looksLikePath(head)) rawItem = head
309
+ if (!preCross) {
310
+ // 坑 brainstorm-gate-agent-unavailable-and-list-path-parse 坑2:列表项「路径:描述」
311
+ // 整行当路径 existsSync 假阴性(文件存在却报幻觉路径)——剥首个中/英冒号及之后描述。
312
+ // 仅列表分支剥、且冒号前段须 looksLikePath:normalizePath 全局剥会毁 Windows 绝对路径
313
+ // (C:\...),列表项是仓根相对路径无盘符,安全。
314
+ const colonIdx = rawItem.search(/[::]/)
315
+ if (colonIdx > 0) {
316
+ const head = rawItem.slice(0, colonIdx).trim()
317
+ if (looksLikePath(head)) rawItem = head
318
+ }
271
319
  }
272
- const filePath = normalizePath(rawItem)
320
+ const itemNorm = normalizePath(rawItem)
321
+ const itemCross = preCross || splitCrossRepoPrefix(itemNorm, repoKeys)
322
+ const filePath = itemCross ? itemCross.path : itemNorm
323
+ const itemRepo = itemCross ? itemCross.repo : currentRepo
273
324
  if (isPlaceholder(filePath) || (filePath.startsWith('.sillyspec/') && !(keepSillyspecDocs && filePath.startsWith('.sillyspec/docs/')))) continue
274
325
  if (!looksLikePath(filePath)) continue // 脏描述兜底
275
326
  const incidental = INCIDENTAL_RE.test(listItem[1])
276
327
  if (listMode === 'exclude') { entries.delete(filePath); continue }
277
- entries.set(filePath, { path: filePath, operation: currentOp, incidental })
328
+ entries.set(filePath, { path: filePath, operation: currentOp, incidental, repo: itemRepo })
278
329
  }
279
330
  }
280
331
 
@@ -285,8 +336,8 @@ function _parseFileListDetailed(designMdPath, { keepSillyspecDocs = false } = {}
285
336
  * 从 design.md 解析文件变更清单(路径集合,向后兼容)。
286
337
  * @param {string} designMdPath - design.md 文件路径
287
338
  * @param {string} designMdPath - design.md 文件路径
288
- * @param {{ keepSillyspecDocs?: boolean }} [opts] - keepSillyspecDocs=true 时保留 `.sillyspec/docs/`(dogfood 模块文档=交付物,apply gate 需识别;默认 false 跳过全部 .sillyspec/ 保持 fileCount 判档不变)
289
- * @returns {Set<string>} 文件路径集合(相对路径,如 "src/worktree.js")
339
+ * @param {{ keepSillyspecDocs?: boolean, repoKeys?: string[]|null }} [opts] - keepSillyspecDocs=true 时保留 `.sillyspec/docs/`(dogfood 模块文档=交付物,apply gate 需识别;默认 false 跳过全部 .sillyspec/ 保持 fileCount 判档不变);repoKeys=注册仓 key 清单(local.yaml repos 段)时识别跨仓子段 / `cross-repo:` 前缀
340
+ * @returns {Set<string>} 文件路径集合(相对路径,如 "src/worktree.js";跨仓前缀已剥)
290
341
  */
291
342
  export function parseFileChangeList(designMdPath, opts) {
292
343
  return new Set(_parseFileListDetailed(designMdPath, opts).map(e => e.path))
@@ -296,9 +347,11 @@ export function parseFileChangeList(designMdPath, opts) {
296
347
  * 从 design.md 解析文件变更清单(含 operation + incidental 标记)。
297
348
  * operation 供 verify 删除探针对账(声明「新增/修改」却整文件删除 = 高风险);
298
349
  * incidental 供 assess allowed_paths 豁免。
350
+ * repo(跨仓标注,scope-audit-cross-repo-blindness 改进点 2):命中注册跨仓子段标题或
351
+ * `cross-repo:<key>:` 前缀的条目标 repoKey,否则 null(= 主仓)。
299
352
  * @param {string} designMdPath - design.md 文件路径
300
- * @param {{ keepSillyspecDocs?: boolean }} [opts] - 同 parseFileChangeList
301
- * @returns {Array<{ path: string, operation: string|null, incidental: boolean }>}
353
+ * @param {{ keepSillyspecDocs?: boolean, repoKeys?: string[]|null }} [opts] - 同 parseFileChangeList
354
+ * @returns {Array<{ path: string, operation: string|null, incidental: boolean, repo: string|null }>}
302
355
  */
303
356
  export function parseFileChangeListDetailed(designMdPath, opts) {
304
357
  return _parseFileListDetailed(designMdPath, opts)
@@ -301,6 +301,33 @@ export function detectChangeRisk({ designContent = '', planContent = '', changed
301
301
  return { level, triggers, suppressedTriggers, requiredVerification }
302
302
  }
303
303
 
304
+ // ============ 回执来源分类打标(X-10 / D-002 / D-006,2026-09-17-pass-cap-semantics task-02) ============
305
+ //
306
+ // EHS 实证(D-002):mvn compile + JUnitCore 纯单测的三条 log 通过了集成回执四条件校验——
307
+ // 单测/编译类回执被当成集成实测。打标口径:只认回执 command 的命令来源声明(quality-scan
308
+ // 记录的命令与 verify_precedents 声明同思想),不解析日志内容猜测(X-10);未定类默认
309
+ // 'build'(fail-closed 侧:build/unit 回执不构成「集成实测已跑」绿判据,宁可触发封顶要求
310
+ // handover——stage-contract 侧 validatePassEligibility(evaluatePassEligibility 纯函数 +
311
+ // resolveFactsExpected 判定)消费本口径对结论=PASS 封顶;verify-probes producer 侧
312
+ // classifyReceiptCommandSource(facts.integrationRan 判定)与本分类同族口径,两处正则族
313
+ // 保持同步演进)。smoke 冒烟命令族计 cross-layer(D-006 退役判据:批次 C commands.smoke
314
+ // 落地后由 smoke 回执一票判定「集成实测已跑」——起服务冒烟即跨层实测,不得默认 build 误拦金路径)。
315
+ const RECEIPT_SOURCE_CROSS_LAYER_RE = new RegExp([
316
+ '\\bcurl\\b', '\\bwget\\b', '\\bhttpie\\b', '\\bInvoke-WebRequest\\b', '\\bInvoke-RestMethod\\b',
317
+ '\\biwr\\b', '\\birm\\b', 'https?://',
318
+ 'spring-boot:run', '\\bjava\\b[^|\\n]*\\s-jar\\b',
319
+ '\\b(?:npm|pnpm|yarn|bun)\\s+(?:run\\s+)?(?:dev|start|serve|smoke)\\b',
320
+ '\\bdotnet\\s+run\\b', '\\bflask\\s+run\\b', '\\buvicorn\\b', '\\bgunicorn\\b',
321
+ '\\b(?:nc|netcat|telnet|socat)\\b',
322
+ ].join('|'), 'i')
323
+ const RECEIPT_SOURCE_UNIT_RE = /\bJUnitCore\b|\bnode\s+--test\b|\bmocha\b|\bjest\b|\bvitest\b|\bpytest\b|\bphpunit\b|\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test\b/i
324
+ function classifyReceiptSourceTag(command) {
325
+ const cmd = String(command || '')
326
+ if (RECEIPT_SOURCE_CROSS_LAYER_RE.test(cmd)) return 'cross-layer'
327
+ if (RECEIPT_SOURCE_UNIT_RE.test(cmd)) return 'unit'
328
+ return 'build'
329
+ }
330
+
304
331
  /**
305
332
  * 检查 verify-result.md 是否包含集成验证证据
306
333
  *
@@ -308,6 +335,10 @@ export function detectChangeRisk({ designContent = '', planContent = '', changed
308
335
  * CLI 结构化回执文本(如 verify-services.receipt.json 的服务回收回执)——它随 verifyContent
309
336
  * 一起参与 literals 匹配,agent 真实起过服务且 CLI 回收过(有回执)时不再依赖其自然语言
310
337
  * 措辞恰好含字面词,表述差异不再误拦。
338
+ *
339
+ * opts.sourceTag(X-10 / D-006,2026-09-17-pass-cap-semantics task-02):可选回执来源声明
340
+ * ('cross-layer' | 'build' | 'unit'),透传给 auditRuntimeReceipt 优先于按 command 就地
341
+ * 分类;非法值忽略。build/unit 回执不作集成实测绿判据(缺省按 command 分类,未定类 build)。
311
342
  */
312
343
  export function checkIntegrationEvidence(verifyContent, requiredVerification, opts = {}) {
313
344
  const errors = []
@@ -333,7 +364,7 @@ export function checkIntegrationEvidence(verifyContent, requiredVerification, op
333
364
  const hasGreen = greenReceipts.length > 0
334
365
  if (needsIntegration && !hasGreen) {
335
366
  const bad = receiptAudit.filter(a => !a.green)
336
- errors.push(`integration-critical 变更无绿回执(${bad.length} 条回执校验不过:${bad.map(a => a.reason).join(';') || '无有效回执'})——绿判据:log 存在 × mtime ∈ verify 窗口 × 日志无失败签名 × exit 0`)
367
+ errors.push(`integration-critical 变更无绿回执(${bad.length} 条回执校验不过:${bad.map(a => a.reason).join(';') || '无有效回执'})——绿判据:log 存在 × mtime ∈ verify 窗口 × 日志无失败签名 × exit 0 × 来源 cross-layer(build/unit 回执不作集成实测判据,D-006)`)
337
368
  }
338
369
  if (needsLogEvidence && !hasGreen) {
339
370
  errors.push('integration-critical 变更的回执未通过一致性校验(Runtime Evidence 等价物)')
@@ -394,9 +425,19 @@ export function checkIntegrationEvidence(verifyContent, requiredVerification, op
394
425
  * 单条回执一致性校验(FR-04 绿判据)。噪声剔除:签名行首匹配 + 剔除「0 errors」类良性行
395
426
  * (本仓 verify-postcheck.js 测试输出解析先例同款)。fail-soft:校验依赖缺失(opts 无
396
427
  * cwd/verifyStartAt)时只核 exitCode 与日志可读性,不假装跑了文件校验。
428
+ *
429
+ * opts.sourceTag(X-10 / D-006,2026-09-17-pass-cap-semantics task-02):可选回执来源声明,
430
+ * 缺省按回执 command 来源就地分类(classifyReceiptSourceTag),未定类默认 'build'(向后
431
+ * 兼容的 fail-closed 缺省);sourceTag ∈ {build, unit} 的回执不作集成实测绿判据(四条件
432
+ * 全过也只到 non-green,reason 点名来源)——verify-postcheck 不在本调用链上,不受影响。
397
433
  */
398
434
  function auditRuntimeReceipt(r, opts) {
399
- const out = { claim: r && r.claim, logPath: r && r.logPath, green: false, logExists: null, mtimeInWindow: null, failSignatures: 0, exitCode: r && typeof r.exitCode === 'number' ? r.exitCode : null, reason: '' }
435
+ const out = { claim: r && r.claim, logPath: r && r.logPath, sourceTag: null, green: false, logExists: null, mtimeInWindow: null, failSignatures: 0, exitCode: r && typeof r.exitCode === 'number' ? r.exitCode : null, reason: '' }
436
+ // 打标依据 = 命令来源声明(不解析日志内容,X-10):opts.sourceTag 调用方声明源优先,
437
+ // 否则按回执 command 分类;非法/缺省走分类,未定类默认 build。
438
+ const declaredSource = opts && typeof opts.sourceTag === 'string'
439
+ && ['cross-layer', 'build', 'unit'].includes(opts.sourceTag) ? opts.sourceTag : null
440
+ out.sourceTag = declaredSource || classifyReceiptSourceTag(r && r.command)
400
441
  if (!r || !r.logPath) { out.reason = '回执缺 logPath'; return out }
401
442
  let content = null
402
443
  try {
@@ -424,7 +465,14 @@ function auditRuntimeReceipt(r, opts) {
424
465
  }
425
466
  if (out.failSignatures > 0) { out.reason = `日志含 ${out.failSignatures} 行失败签名(行首 error/exception/traceback/fatal,已剔除良性计数行)`; return out }
426
467
  if (out.exitCode !== 0) { out.reason = `exitCode=${out.exitCode}(非 0 不可用作在场证据)`; return out }
468
+ // 来源门(D-002/D-006,2026-09-17-pass-cap-semantics task-02):四条件全过但来源 build/unit
469
+ // (编译/单测/构建类命令)→ 不作集成实测绿判据(EHS 实证口径),排在前置硬伤(缺日志/
470
+ // 出窗/签名/exit≠0)之后不抢 reason 优先级。
471
+ if (out.sourceTag !== 'cross-layer') {
472
+ out.reason = `回执来源 ${out.sourceTag}(command:${(r && r.command) || ''})不构成「集成实测已跑」——build/unit 类命令(编译/单测/构建)不作集成绿判据(D-006)。出路:提供跨层实测回执(起服务/HTTP/进程对进程/smoke 冒烟命令),或降级 PASS WITH NOTES 并在「## 移交项(结构化)」承载缺口`
473
+ return out
474
+ }
427
475
  out.green = true
428
- out.reason = '绿回执(log 存在 × mtime 窗口 × 无失败签名 × exit 0)'
476
+ out.reason = '绿回执(log 存在 × mtime 窗口 × 无失败签名 × exit 0 × 来源 cross-layer)'
429
477
  return out
430
478
  }
@@ -128,6 +128,14 @@ export const LOCAL_YAML_SCHEMA = {
128
128
  { path: 'known_failures', type: 'array', optional: true, status: 'live', readers: ['extractKnownFailures (src/verify-postcheck.js)'], desc: '失败行模式列表(匹配测试输出失败行)。', example: 'tests/some-old-test' },
129
129
  ],
130
130
  },
131
+ {
132
+ id: 'verify_precedents',
133
+ title: '等效验证先例库',
134
+ note: '标准测试/构建命令在本仓不可用(框架硬编码 skip、依赖外部基础设施等)时,登记已验证的等效验证口径,verify prompt 自动注入提示复用(2026-09-15 EHS 实证:mvn test 被框架 parent pom 硬编码 surefire skip,等效口径第二次靠 agent 翻旧 verify-result 正文续命——先例只活在散文里换 agent 即断档)。',
135
+ keys: [
136
+ { path: 'verify_precedents', type: 'array', optional: true, status: 'live', readers: ['parseVerifyPrecedents (src/run/prompt.js)'], desc: '等效验证先例对象数组(id/standard_command/reason/equivalent/established_by/notes)。verify 阶段 prompt 与 test_strategy 预检提示同挂点注入;行动指引=把等效命令配进 commands.test 照常全量实测对账,而非只靠 test_strategy: skip 放行。', example: '- id: mvn-test-surefire-skip\n standard_command: mvn test\n reason: parent pom 硬编码 surefire skip=true\n equivalent: dependency:build-classpath + javac + JUnitCore 直跑\n established_by: 2026-08-26-building-area-ledger' },
137
+ ],
138
+ },
131
139
  {
132
140
  id: 'worktree-hook',
133
141
  title: 'worktree-guard 扩展',
@@ -136,6 +144,23 @@ export const LOCAL_YAML_SCHEMA = {
136
144
  { path: 'worktree-hook.readonlyCommands', type: 'array', optional: true, status: 'live', readers: ['loadLocalConfig (src/hooks/worktree-guard.js)'], desc: '额外放行的只读命令名列表(如 rg/fd),绕过 worktree-guard 写操作拦截。', example: 'rg' },
137
145
  ],
138
146
  },
147
+ {
148
+ id: 'worktree',
149
+ title: 'worktree 生成物供给',
150
+ note: 'gitignore 生成物(如构建期产出的 src/build-id.ts)不在 git 树也不进 untracked overlay(ls-files --others --exclude-standard 尊重 .gitignore),worktree 缺失致构建炸 Failed to load url——create 期按本清单从主仓复制供给(2026-09-15-worktree-dual-truth-gates 坑③,D-003@v1)。块列表或 inline flow 数组均可解析。',
151
+ keys: [
152
+ { path: 'worktree.supplyFiles', type: 'array', optional: true, status: 'live', readers: ['readSupplyFilesConfig + _supplyGeneratedFiles (src/worktree.js — create step 5.9 供给步)'], desc: '随 worktree create 从主仓复制的生成物清单:精确路径或 glob(* 单层 / ** 多层),相对仓根。展开上限 200 文件超出截断警告;实供清单记 meta.supplyFiles;缺省 [] = 供给步空转零行为变化。', example: 'src/build-id.ts' },
153
+ ],
154
+ },
155
+ {
156
+ id: 'gate_snapshot',
157
+ title: '门禁隔离快照 copy 面',
158
+ note: '门禁快照(gate-snapshot)= HEAD worktree + 会话文件 overlay + 环境目录 junction——gitignored 生成物(api-types/generated 类)不进 HEAD 也不在会话集,快照内 lint/test 环境性假败(2026-09-16 驾驭小结④,friction5-hardening R4 / D-002@v1)。按本清单在快照构建期从主仓 junction 链接补齐(失败回退复制);快照内 overlay 已覆盖的同名路径跳过(本变更最新态优先)。⚠️ junction 是活链接:快照内再跑生成命令会写穿到主仓该目录。',
159
+ keys: [
160
+ { path: 'gate_snapshot.copy', type: 'array', optional: true, status: 'live', readers: ['createGateSnapshot (src/run/gate-snapshot.js)'], desc: '门禁隔离快照的生成物/额外路径 copy 面(元素 string,相对仓根):HEAD 快照缺 gitignored 生成物导致 lint/test 环境性假败;声明后快照构建期从主仓 junction 链接(失败回退复制)。⚠️ junction 是活链接,快照内再跑生成命令会写穿到主仓该目录。未配置/空清单 = 全段空转零行为变化。', example: 'gate_snapshot:\n copy:\n - src/generated' },
161
+ { path: 'gate_snapshot.commands', type: 'array', optional: true, status: 'live', readers: ['parseGateSnapshotCommands + runGateSnapshotCommands (src/run/gate-snapshot.js — createGateSnapshot 环境预检后 copy 面前)'], desc: '门禁隔离快照的命令面(元素 string):快照构建期在环境目录链接后、copy 面前于快照根逐条执行(cwd=快照根、300s/条超时帽、非零退出/超时只 warn 继续——fail-open 不作废快照),产出「本仓应然态」生成物,收口主仓生成物缺失/过期(fresh clone 未跑 postinstall / build-id 类产物)时快照内全量 lint/test 必挂的坑(D-002@v2,2026-09-17-feedback-hardening FR-01)。⚠️ 环境目录(node_modules/venv)是活链接,命令写它们会穿透主仓——只放生成物命令(npm run gen:build-id 类),不放 install/build 全家桶。未配置 = 全段空转零行为。', example: 'npm run gen:build-id' },
162
+ ],
163
+ },
139
164
  {
140
165
  id: 'auto_mode',
141
166
  title: '变更规模自动分类',
@@ -343,12 +368,38 @@ semantic_guard:
343
368
  known_failures:
344
369
  - tests/some-old-stable-failure
345
370
 
371
+ # ── 等效验证先例库(标准测试/构建命令在本仓不可用时的既有替代口径;verify prompt 自动注入)──
372
+ verify_precedents:
373
+ - id: standard-test-command-blocked
374
+ standard_command: mvn test
375
+ reason: 框架 parent pom 硬编码 surefire skip=true,-D 覆盖无效
376
+ equivalent: mvn dependency:build-classpath + javac + JUnitCore 直跑
377
+ established_by: <确立该等效口径的变更名>
378
+
346
379
  # ── worktree-guard 只读命令扩展(execute worktree 隔离期内放行额外只读命令)──
347
380
  worktree-hook:
348
381
  readonlyCommands:
349
382
  - rg
350
383
  - fd
351
384
 
385
+ # ── worktree 生成物供给(gitignore 生成物不随 git 树/untracked overlay 进 worktree,create 期从主仓复制)──
386
+ # 精确路径或 glob(* 单层 / ** 多层),相对仓根;展开上限 200 文件超出截断警告;缺省不供给零行为变化。
387
+ # worktree:
388
+ # supplyFiles:
389
+ # - src/build-id.ts
390
+
391
+ # ── 门禁隔离快照 copy 面(HEAD 快照缺 gitignored 生成物 → 快照内 lint/test 环境性假败;构建期从主仓 junction 补齐)──
392
+ # 相对仓根路径列表;失败回退复制;快照内 overlay 已覆盖的同名路径跳过。
393
+ # ⚠️ junction 是活链接:快照内再跑生成命令会写穿到主仓该目录。
394
+ # commands 面(D-002@v2):快照构建期在快照根逐条执行的生成物命令(postinstall/build-id 类,
395
+ # 产出本仓应然态;300s/条超时帽,失败 warn 继续)。⚠️ 命令不得改写 node_modules/venv 等
396
+ # 环境目录(活链接,写穿透主仓)——只放生成物命令。
397
+ # gate_snapshot:
398
+ # copy:
399
+ # - src/generated
400
+ # commands:
401
+ # - npm run gen:build-id
402
+
352
403
  # ── 变更规模自动分类(sillyspec run auto 时按需求描述强制 quick/full;正则数组,i 大小写无关)──
353
404
  auto_mode:
354
405
  force_full_patterns:
@@ -9,9 +9,10 @@
9
9
  */
10
10
 
11
11
  import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs'
12
- import { join, resolve, basename, dirname, relative } from 'path'
12
+ import { join, resolve, basename, dirname, relative, isAbsolute } from 'path'
13
13
  import { gitQuiet } from './git-helper.js'
14
14
  import { splitOwnVsForeignDiffFiles } from './foreign-declared.js'
15
+ import { parseFileChangeListDetailed } from './change-list.js'
15
16
  import {
16
17
  scanBackendEndpoints,
17
18
  scanFrontendApiCalls,
@@ -19,7 +20,7 @@ import {
19
20
  normalizePath,
20
21
  diffApiParity,
21
22
  } from './endpoint-extractor.js'
22
- import { parseTaskContracts } from './stages/plan-postcheck.js'
23
+ import { parseTaskContracts, parseRepoRegistry, parseAllowedPaths } from './stages/plan-postcheck.js'
23
24
 
24
25
  // ─── 关键词检测 ─────────────────────────────────────────────────────────
25
26
 
@@ -382,7 +383,9 @@ export function _readWorktreeMeta(specBase, scanRoot, changeName) {
382
383
  for (const metaPath of candidates) {
383
384
  try {
384
385
  if (!existsSync(metaPath)) continue
385
- const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
386
+ // BOM 容错(worktree.js:283 同坑):Windows 编辑器写入的 UTF-8 BOM 会让 JSON.parse
387
+ // 失败——双候选下会静默跳根,先剥 BOM 再解析
388
+ const meta = JSON.parse(String(readFileSync(metaPath, 'utf8')).replace(/^\uFEFF/, ''))
386
389
  const gitDir = (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath))
387
390
  ? meta.worktreePath : scanRoot
388
391
  return { meta, gitDir }
@@ -559,6 +562,74 @@ export function verifyApiParity(specBase, scanRoot, runtimeRoot, changeName = nu
559
562
  frontendCalls = scanFrontendApiCalls(frontendRoot)
560
563
  }
561
564
 
565
+ // ── 跨仓前端调用并集(坑 probe5-cross-repo-frontend-blind,2026-09-15 EHS 生产实证:三端
566
+ // 全栈变更的前端在兄弟仓(cross-repo: 前缀声明),主仓 change-diff 永远不含它们 → 报告
567
+ // 「0 frontend calls」假象、本变更端点全落 unused、契约对账只能靠人工逐条对齐 11 端点)。
568
+ // 声明源双取:task 卡 allowed_paths 的 cross-repo:<key>: 前缀 + design 清单跨仓条目
569
+ // (change-list repoKeys 解析,与探针 1 清单同源);仓根经 local.yaml repos 注册表解析
570
+ // (resolveCrossRepoDeclarations 同款)。各仓根扫前端调用并按「该仓声明文件集」收窄后并入
571
+ // frontendCalls——「探针已知口径限制」注记从人肉兜底变机器对账。fail-soft:仓未注册/
572
+ // 不可达 → crossRepoNotes 一行注记不炸整体;无跨仓声明/无注册表零行为(存量单仓流不受影响)。
573
+ const crossRepoNotes = []
574
+ if (changeName && specBase) {
575
+ try {
576
+ const declaredByRepo = {}
577
+ const addDecl = (repo, p) => {
578
+ const n = String(p || '').trim().replace(/^\.\//, '').replace(/\\/g, '/')
579
+ if (repo && n) (declaredByRepo[repo] = declaredByRepo[repo] || new Set()).add(n)
580
+ }
581
+ // 源1:task 卡 allowed_paths 的 cross-repo:<key>: 前缀(与 _module-map.yaml 约定一致)——
582
+ // 不按注册表过滤:未注册 key 也要现身「未注册」注记(漏登记可见,而非静默消失)
583
+ const tasksDir = join(specBase, 'changes', changeName, 'tasks')
584
+ if (existsSync(tasksDir)) {
585
+ for (const f of readdirSync(tasksDir)) {
586
+ if (!/^task-\d+\.md$/.test(f)) continue
587
+ try {
588
+ for (const p of parseAllowedPaths(readFileSync(join(tasksDir, f), 'utf8'))) {
589
+ const m = String(p).match(/^cross-repo:([A-Za-z0-9_.\-]+):(.*)$/)
590
+ if (m) addDecl(m[1], m[2])
591
+ }
592
+ } catch { /* 单卡读取失败不拖垮 */ }
593
+ }
594
+ }
595
+ const registry = parseRepoRegistry(existsSync(join(specBase, 'local.yaml')) ? readFileSync(join(specBase, 'local.yaml'), 'utf8') : '')
596
+ // 源2:design.md 文件清单跨仓子段/前缀(change-list repoKeys 解析需注册表在——
597
+ // 注册表空时源2 不可判跨仓,跳过;源1 已保证未注册声明仍可见)。注意
598
+ // parseFileChangeListDetailed 吃文件路径非文本(内部 existsSync+readFileSync)。
599
+ if (registry.size > 0) {
600
+ const designPath = join(specBase, 'changes', changeName, 'design.md')
601
+ if (existsSync(designPath)) {
602
+ for (const e of parseFileChangeListDetailed(designPath, { repoKeys: [...registry.keys()] })) {
603
+ if (e.repo) addDecl(e.repo, e.path)
604
+ }
605
+ }
606
+ }
607
+ for (const [key, declared] of Object.entries(declaredByRepo)) {
608
+ const raw = registry.get(key)
609
+ if (!raw) {
610
+ crossRepoNotes.push(`repo「${key}」未在 local.yaml repos 注册——该仓前端调用未进对账(register-repo 登记后自动并入)`)
611
+ continue
612
+ }
613
+ const repoRoot = isAbsolute(raw) ? raw : resolve(scanRoot, raw)
614
+ if (!existsSync(repoRoot)) {
615
+ crossRepoNotes.push(`repo「${key}」注册路径不可达:${repoRoot}——该仓前端调用未进对账`)
616
+ continue
617
+ }
618
+ const calls = scanFrontendApiCalls(repoRoot).filter(c => {
619
+ // source 是绝对路径且 Windows 反斜杠形态——与主仓 change-diff 过滤同款手工归一
620
+ // (normalizePath 是 URL 路径归一器,不碰反斜杠,此处不能用)
621
+ const src = String(c.source || '').replace(/\\/g, '/')
622
+ return [...declared].some(dp => src === dp || src.endsWith('/' + dp) || dp.endsWith('/' + src))
623
+ })
624
+ if (calls.length > 0) frontendCalls.push(...calls)
625
+ crossRepoNotes.push(`repo「${key}」@ ${repoRoot}:${calls.length} 前端调用(跨仓声明 ${declared.size} 文件收窄)`)
626
+ frontendScope += ` + cross-repo:${key}(${calls.length})`
627
+ }
628
+ } catch (e) {
629
+ crossRepoNotes.push(`跨仓前端扫描失败(fail-soft 跳过):${e && e.message ? e.message : e}`)
630
+ }
631
+ }
632
+
562
633
  // ── 挂载前缀收集(坑 endpoints-mount-prefix-gap,2026-08-31 用户实证):endpoints 提取的是
563
634
  // 「router 自身前缀 + 装饰器路径」,不含挂载点前缀(main.py 的 include_router(prefix=)/app.use
564
635
  // 与 router 文件分离,静态扫描不做导入图关联)→ 前端全路径调用(/api/xxx)对不上欠前缀端点
@@ -575,6 +646,14 @@ export function verifyApiParity(specBase, scanRoot, runtimeRoot, changeName = nu
575
646
  const key = `${u.method} ${u.path}`
576
647
  return liveKeys.has(key) || liveEndpoints.length === 0
577
648
  })
649
+ // unused 分层(坑 probe5-unused-stock-noise,2026-09-15 EHS 生产实证:490 个 unused 全是
650
+ // urgent 等他模块存量端点——全仓 live 扫描 × 本变更对账的口径噪音刷屏)。本变更相关 =
651
+ // contract artifact 端点集内(本变更换的端点漏配才是真信号);存量其余折叠计数。无
652
+ // artifact(非契约流)回退全列零回归。
653
+ const artifactKeys = new Set(allProviderEndpoints.map(e => `${e.method} ${e.path}`))
654
+ const hasArtifacts = allProviderEndpoints.length > 0
655
+ const unusedChangeRelevant = hasArtifacts ? narrowedUnused.filter(u => artifactKeys.has(`${u.method} ${u.path}`)) : narrowedUnused
656
+ const unusedStockCount = narrowedUnused.length - unusedChangeRelevant.length
578
657
 
579
658
  const ok = missingBackend.length === 0
580
659
  const liveRootSummary = liveByRoot.map(r => `${r.label} ${r.endpoints.length}`).join(' + ')
@@ -583,11 +662,11 @@ export function verifyApiParity(specBase, scanRoot, runtimeRoot, changeName = nu
583
662
  : `❌ API parity check failed: ${missingBackend.length} frontend calls have no matching backend endpoint [scope: ${frontendScope}]`
584
663
 
585
664
  if (narrowedUnused.length > 0) {
586
- summary += ` | ${narrowedUnused.length} backend endpoints unused by frontend`
665
+ summary += ` | ${unusedChangeRelevant.length} backend endpoints unused by frontend${unusedStockCount > 0 ? ` (+${unusedStockCount} stock noise collapsed)` : ''}`
587
666
  }
588
667
  if (prefixAlignedCount > 0) {
589
668
  summary += ` | ${prefixAlignedCount} calls matched after mount-prefix alignment (artifact paths exclude include_router/app.use prefixes)`
590
669
  }
591
670
 
592
- return { ok, missingBackend, unusedBackend: narrowedUnused, summary, backendCount: mergedProviderEndpoints.length, frontendCount: frontendCalls.length, prefixAlignedCount, scanRoots: liveByRoot.map(r => r.root) }
671
+ return { ok, missingBackend, unusedBackend: narrowedUnused, unusedChangeRelevant, unusedStockCount, crossRepoNotes, summary, backendCount: mergedProviderEndpoints.length, frontendCount: frontendCalls.length, prefixAlignedCount, scanRoots: liveByRoot.map(r => r.root) }
593
672
  }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * cross-repo-reconcile.js — 跨仓 task 卡 target_files 的 per-repo 对账(零环新模块)。
3
+ *
4
+ * 坑 cross-repo-reconcile-blindness(2026-09-15 复盘实证:三端变更的对账表只有主仓机器可见,
5
+ * 22 个跨仓文件在归档表全标「计划未动」、探针5 报「0 frontend calls」,跨仓全靠人工到对应仓
6
+ * 解释):reconcileTargetFiles 主链按 D-004 把跨仓卡从声明侧剔除——主仓 actual 永远对不上跨仓
7
+ * 声明,剔除本身正确;但「留待后续分期」一直没有兑现,跨仓侧零机器可见性。
8
+ *
9
+ * 本模块兑现该分期:对每张跨仓卡声明的 repo key,读 local.yaml repos 注册表解析仓根,在**该仓**
10
+ * 取 actual(diff HEAD~1..HEAD ∪ status porcelain untracked,与 resolveVerifyChangedFiles 跨仓
11
+ * 分支同源口径),按主仓同款三类差集对账(matched / missing / undeclared + 脚手架软桶)。
12
+ *
13
+ * 定位与边界(与主仓对账的差异,显式声明防误读):
14
+ * - **advisory 不阻断**:跨仓 diff 锚点是 HEAD~1..HEAD 最近提交窗口(多 task 同仓时只反映最近
15
+ * 一笔,精确范围要 task 卡 base/head 锡点——verify-postcheck:1119 先例同款限制),锚点脆弱期
16
+ * (已 commit 多笔 / 全在 working-tree)会产②类假信号,故只报告不翻转 verify 状态。
17
+ * - 仓未注册 / 路径不可达 / 非 git 仓 → 该 repo degraded 一行说明,不炸整体。
18
+ * - 不 import verify-postcheck(后者 import 本模块,反向成环);路径归一/大小写折叠与主仓
19
+ * normalizeReconcilePath/pathKey 同口径本地实现,注释锚定不漂移。
20
+ */
21
+ import { existsSync, readFileSync } from 'fs'
22
+ import { join, isAbsolute, resolve } from 'path'
23
+ import { gitQuiet } from './git-helper.js'
24
+ import { parseRepoRegistry } from './stages/plan-postcheck.js'
25
+ import { filterDeliverableFiles, classifyToolScaffold } from './worktree-apply.js'
26
+
27
+ // 与 verify-postcheck normalizeReconcilePath 同口径(本地实现防环):剥 ./ 前缀、反斜杠归一
28
+ function normalizeRepoPath(p) {
29
+ return String(p || '').trim().replace(/^\.\//, '').replace(/\\/g, '/')
30
+ }
31
+
32
+ // 与 verify-postcheck reconcileTargetFiles 的 foldCase 同口径:win32/darwin 文件系统大小写
33
+ // 不敏感,两形指同一文件;Linux 保持敏感
34
+ const foldCase = process.platform === 'win32' || process.platform === 'darwin'
35
+ const pathKey = (p) => {
36
+ const n = normalizeRepoPath(p)
37
+ return foldCase ? n.toLowerCase() : n
38
+ }
39
+
40
+ // 与 verify-postcheck parsePorcelainFilePaths 同口径(本地实现防环):剥 XY 状态列、rename 取
41
+ // -> 后新路径、剥包裹引号、反斜杠归一
42
+ function parsePorcelain(raw) {
43
+ return String(raw || '').split('\n')
44
+ .map(l => l.slice(3).trim())
45
+ .map(p => (p.includes(' -> ') ? p.slice(p.lastIndexOf(' -> ') + 4) : p).trim())
46
+ .map(p => p.replace(/^"|"$/g, ''))
47
+ .map(p => p.replace(/\\/g, '/'))
48
+ .filter(Boolean)
49
+ }
50
+
51
+ /**
52
+ * 跨仓声明对账。declarationsByRepo 由声明侧收集(collectDeclaredTargetFiles 跨仓卡分支):
53
+ * `{ [repoKey]: Array<{task, path, isNew}> }`——path 相对该仓根(跨仓卡口径)。
54
+ *
55
+ * @param {{ specBase: string, cwd: string, declarationsByRepo: Record<string, Array<{task:string,path:string,isNew:boolean}>> }} args
56
+ * @returns {Array<{repo:string, repoPath:string|null, declaredCount:number, actualCount:number,
57
+ * matched:string[], missing:Array<{task:string,path:string,isNew?:boolean}>,
58
+ * undeclared:string[], scaffoldCount:number, degradedReason:string|null}>}
59
+ * 每 repo 一项;degradedReason 非空时 matched/missing/undeclared 为空数组(该仓无结论)。
60
+ */
61
+ export function reconcileCrossRepoDeclarations({ specBase, cwd, declarationsByRepo }) {
62
+ const results = []
63
+ const repos = declarationsByRepo && typeof declarationsByRepo === 'object' ? Object.keys(declarationsByRepo) : []
64
+ if (repos.length === 0) return results
65
+
66
+ let registry = new Map()
67
+ try {
68
+ const yamlPath = join(specBase || '', 'local.yaml')
69
+ if (specBase && existsSync(yamlPath)) {
70
+ registry = parseRepoRegistry(readFileSync(yamlPath, 'utf8'))
71
+ }
72
+ } catch { /* local.yaml 不可读 → 空注册表,各 repo 落「未注册」degraded */ }
73
+
74
+ for (const repoKey of repos) {
75
+ const declarations = declarationsByRepo[repoKey] || []
76
+ const base = { repo: repoKey, repoPath: null, declaredCount: declarations.length, actualCount: 0,
77
+ matched: [], missing: [], undeclared: [], scaffoldCount: 0, degradedReason: null }
78
+
79
+ const rawPath = registry.get(repoKey)
80
+ if (!rawPath) {
81
+ results.push({ ...base, degradedReason: `repo key「${repoKey}」未在 local.yaml repos 注册——跨仓对账不可达,请人工到对应仓核对` })
82
+ continue
83
+ }
84
+ const repoPath = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath)
85
+ base.repoPath = repoPath
86
+ if (!existsSync(repoPath)) {
87
+ results.push({ ...base, degradedReason: `注册路径不可达:${repoPath}` })
88
+ continue
89
+ }
90
+
91
+ // actual 双源(resolveVerifyChangedFiles 跨仓分支同源):已提交窗口 + working-tree 未提交
92
+ const union = new Set()
93
+ let anySource = false
94
+ const diffOut = gitQuiet(repoPath, ['diff', '--name-only', 'HEAD~1..HEAD'], { timeout: 30 * 1000 })
95
+ if (diffOut !== null) {
96
+ anySource = true
97
+ for (const f of String(diffOut).split('\n')) {
98
+ const n = normalizeRepoPath(f)
99
+ if (n) union.add(n)
100
+ }
101
+ }
102
+ const statusOut = gitQuiet(repoPath, ['status', '--porcelain', '--untracked-files=all'], { timeout: 30 * 1000, trim: false })
103
+ if (statusOut !== null) {
104
+ anySource = true
105
+ for (const f of parsePorcelain(statusOut)) union.add(f)
106
+ }
107
+ if (!anySource) {
108
+ results.push({ ...base, degradedReason: '该仓 git 不可用/非仓库(diff 与 status 双失败)' })
109
+ continue
110
+ }
111
+
112
+ const actualFiles = [...new Set(filterDeliverableFiles([...union]).filter(Boolean))].sort()
113
+ base.actualCount = actualFiles.length
114
+ const actualKeySet = new Set(actualFiles.map(pathKey))
115
+ const declaredPaths = [...new Set(declarations.map(d => d.path))].sort()
116
+ const declaredKeySet = new Set(declaredPaths.map(pathKey))
117
+ for (const path of declaredPaths) {
118
+ if (actualKeySet.has(pathKey(path))) base.matched.push(path)
119
+ }
120
+ for (const d of declarations) {
121
+ if (!actualKeySet.has(pathKey(d.path))) {
122
+ base.missing.push(d.isNew ? { task: d.task, path: d.path, isNew: true } : { task: d.task, path: d.path })
123
+ }
124
+ }
125
+ // 脚手架软桶(主仓 reconcileTargetFiles 同口径):工具/平台设施文件不进③类逐条清单
126
+ for (const path of actualFiles) {
127
+ if (declaredKeySet.has(pathKey(path))) continue
128
+ if (classifyToolScaffold(path)) { base.scaffoldCount++; continue }
129
+ base.undeclared.push(path)
130
+ }
131
+ results.push(base)
132
+ }
133
+ return results
134
+ }