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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.28.9",
3
+ "version": "3.28.10",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
@@ -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)
@@ -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,14 @@ 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
+ },
139
155
  {
140
156
  id: 'auto_mode',
141
157
  title: '变更规模自动分类',
@@ -343,12 +359,26 @@ semantic_guard:
343
359
  known_failures:
344
360
  - tests/some-old-stable-failure
345
361
 
362
+ # ── 等效验证先例库(标准测试/构建命令在本仓不可用时的既有替代口径;verify prompt 自动注入)──
363
+ verify_precedents:
364
+ - id: standard-test-command-blocked
365
+ standard_command: mvn test
366
+ reason: 框架 parent pom 硬编码 surefire skip=true,-D 覆盖无效
367
+ equivalent: mvn dependency:build-classpath + javac + JUnitCore 直跑
368
+ established_by: <确立该等效口径的变更名>
369
+
346
370
  # ── worktree-guard 只读命令扩展(execute worktree 隔离期内放行额外只读命令)──
347
371
  worktree-hook:
348
372
  readonlyCommands:
349
373
  - rg
350
374
  - fd
351
375
 
376
+ # ── worktree 生成物供给(gitignore 生成物不随 git 树/untracked overlay 进 worktree,create 期从主仓复制)──
377
+ # 精确路径或 glob(* 单层 / ** 多层),相对仓根;展开上限 200 文件超出截断警告;缺省不供给零行为变化。
378
+ # worktree:
379
+ # supplyFiles:
380
+ # - src/build-id.ts
381
+
352
382
  # ── 变更规模自动分类(sillyspec run auto 时按需求描述强制 quick/full;正则数组,i 大小写无关)──
353
383
  auto_mode:
354
384
  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
+ }
@@ -25,6 +25,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'
25
25
  import { join } from 'node:path'
26
26
  import { parseDecisions } from './decision-distill.js'
27
27
  import { parseFileChangeListDetailed } from './change-list.js'
28
+ import { parseDesignCoverageByRepo, parseRepoRegistry } from './stages/plan-postcheck.js'
28
29
  import { parseModuleMapSimple } from './modules.js'
29
30
 
30
31
  // ---------------------------------------------------------------------------
@@ -423,25 +424,63 @@ export function validateDesignFileList({ changeDir, cwd } = {}) {
423
424
  if (!existsSync(designPath)) {
424
425
  return { ok: true, errors, warnings: ['design 清单核验跳过:design.md 不存在'] }
425
426
  }
426
- const entries = parseFileChangeListDetailed(designPath, { keepSillyspecDocs: true })
427
- if (!Array.isArray(entries) || entries.length === 0) {
428
- return { ok: true, errors, warnings: ['design 清单核验跳过:design.md 无文件变更清单段(small 变更可无清单)'] }
427
+
428
+ // 单段存在性核验内核:NEW:/glob/占位符口径与原实现一致,仅把「根」参数化
429
+ const checkAgainstRoot = (root, rawPaths, repoLabel) => {
430
+ for (const raw of rawPaths) {
431
+ const rawPath = String(raw || '').trim()
432
+ if (!rawPath) continue
433
+ if (rawPath.startsWith('NEW:')) continue // 计划新建豁免(与 validateTargetFiles 同语义)
434
+ if (rawPath.includes('*') || rawPath.includes('?')) {
435
+ warnings.push(`design 清单条目含 glob 字符跳过存在性核验:${rawPath}`)
436
+ continue
437
+ }
438
+ const normalized = stripPathPlaceholders(rawPath)
439
+ if (!normalized || normalized === '.') continue
440
+ if (!existsSync(join(root, normalized))) {
441
+ errors.push({
442
+ path: rawPath,
443
+ message: `design_file_ref_invalid:文件变更清单条目「${rawPath}」在${repoLabel}(${root})下不存在且无 NEW: 前缀(幻觉路径/书写错误)——修正路径,或计划新建的文件改为 NEW:${rawPath} 前缀`,
444
+ })
445
+ }
446
+ }
447
+ }
448
+
449
+ // 坑 design-file-ref-cross-repo-blind(2026-09-15 wp 会话实证,41 条误报):存在性核验
450
+ // 此前恒以主仓 cwd 为根——design §6 按仓分段(D-014)下的跨仓「修改」条目(如
451
+ // sub-grid-security 的 src/common/router.js)在其自己仓里明明存在,却被逼标 NEW: 前缀
452
+ // 过 gate(标「修改」的既有文件被迫声明为计划新建,语义撒谎)。对齐
453
+ // validateDesignFileCoverage 的分段口径:复用 parseDesignCoverageByRepo 按 `## <repo>
454
+ // 仓变更` 段头切分,main 段核主仓根,跨仓段核 local.yaml repos: 注册根;无段头时
455
+ // 整章归 main(原行为零回归)。
456
+ const { byRepo, hasSegmentHeader, malformedHeaders } = parseDesignCoverageByRepo(designPath)
457
+ for (const h of malformedHeaders) {
458
+ warnings.push(`design 清单疑似仓变更段头格式异常(该段文件可能被记到上一段仓,覆盖对账会点名):${h}`)
429
459
  }
430
- for (const e of entries) {
431
- const raw = String((e && e.path) || '').trim()
432
- if (!raw) continue
433
- if (raw.startsWith('NEW:')) continue // 计划新建豁免(与 validateTargetFiles 同语义)
434
- if (raw.includes('*') || raw.includes('?')) {
435
- warnings.push(`design 清单条目含 glob 字符跳过存在性核验:${raw}`)
436
- continue
460
+
461
+ if (!hasSegmentHeader) {
462
+ const entries = parseFileChangeListDetailed(designPath, { keepSillyspecDocs: true })
463
+ if (!Array.isArray(entries) || entries.length === 0) {
464
+ return { ok: true, errors, warnings: [...warnings, 'design 清单核验跳过:design.md 无文件变更清单段(small 变更可无清单)'] }
437
465
  }
438
- const normalized = stripPathPlaceholders(raw)
439
- if (!normalized || normalized === '.') continue
440
- if (!existsSync(join(cwd, normalized))) {
441
- errors.push({
442
- path: raw,
443
- message: `design_file_ref_invalid:文件变更清单条目「${raw}」既不存在也无 NEW: 前缀(幻觉路径/书写错误)——修正路径,或计划新建的文件改为 NEW:${raw} 前缀`,
444
- })
466
+ checkAgainstRoot(cwd, entries.map(e => e.path), '主仓')
467
+ return { ok: errors.length === 0, errors, warnings }
468
+ }
469
+
470
+ // 有段头:逐段按其仓根核验。跨仓注册表读侧口径 = <cwd>/.sillyspec/local.yaml
471
+ // (与 execute MultiRepoContext / register-repo 写侧一致,parseRepoRegistry 自带 CRLF 容差)
472
+ let registry = null
473
+ const localYamlPath = join(cwd, '.sillyspec', 'local.yaml')
474
+ if (existsSync(localYamlPath)) {
475
+ try { registry = parseRepoRegistry(readFileSync(localYamlPath, 'utf8')) } catch { registry = null }
476
+ }
477
+ for (const [repo, paths] of byRepo) {
478
+ if (repo === 'main') {
479
+ checkAgainstRoot(cwd, paths, '主仓')
480
+ } else if (registry && registry.has(repo)) {
481
+ checkAgainstRoot(registry.get(repo), paths, `${repo} 仓`)
482
+ } else {
483
+ warnings.push(`design 清单「${repo} 仓变更」段的 repo 未在 local.yaml repos: 注册,跳过该段存在性核验(execute 启动会 fail-closed 拦截,先跑 sillyspec local register-repo ${repo} <仓根路径>)`)
445
484
  }
446
485
  }
447
486
  return { ok: errors.length === 0, errors, warnings }
package/src/index.js CHANGED
@@ -4089,7 +4089,14 @@ SillySpec modules — 模块文档管理
4089
4089
  process.exit(1);
4090
4090
  }
4091
4091
  const { resolveLocalYamlWriteTarget } = await import('./config-cat.js');
4092
- const target = resolveLocalYamlWriteTarget(dir, { specBase: rrSpecBase });
4092
+ // 坑 register-repo-specbase-split-brain(2026-09-15 wp 平台模式会话实证):specBase
4093
+ // 优先的写候选链在平台模式下命中平台 spec 根 local.yaml,而 repos: 段的全部读侧
4094
+ // (execute MultiRepoContext / worktree-cross / run shared)恒读 <cwd>/.sillyspec/local.yaml
4095
+ // ——注册写进 spec 根 = 死配置,execute 启动 fail-closed 报未注册,agent 只能试出
4096
+ // --spec-dir 指向项目 .sillyspec 才能绕过。修复:默认(无显式 --spec-dir)写目标走
4097
+ // 读侧对齐链(不喂 specBase,cwd 祖先链命中项目 local.yaml,worktree 自动反推主仓);
4098
+ // 显式 --spec-dir 是用户明示选择,仍尊重。
4099
+ const target = resolveLocalYamlWriteTarget(dir, specDir ? { specBase: rrSpecBase } : {});
4093
4100
  const { registerRepoInLocalYaml } = await import('./local-register.js');
4094
4101
  let rrResult;
4095
4102
  try {
@@ -4101,6 +4108,16 @@ SillySpec modules — 模块文档管理
4101
4108
  const verb = rrResult.replaced ? '已更新(覆盖旧路径)' : '已注册';
4102
4109
  console.log(`✅ repos.${rrKey} ${verb}: ${absRepo.replace(/\\/g, '/')}`);
4103
4110
  console.log(` 写入: ${target.path}${rrResult.fileCreated ? '(新建文件)' : ''}${rrResult.sectionCreated ? '(新建 repos: 段)' : ''}`);
4111
+ // 同坑提醒:平台 spec 根也躺着一份 local.yaml 时,其 repos: 段不生效(读侧只读项目侧)
4112
+ // ——不Dual写(两份各自演化=再分裂),只提示用户那份是死配置可清理
4113
+ if (!specDir && rrSpecBase) {
4114
+ const altYaml = join(rrSpecBase, 'local.yaml');
4115
+ try {
4116
+ if (existsSync(altYaml) && resolve(altYaml) !== resolve(target.path)) {
4117
+ console.warn(`⚠️ 平台 spec 根另有一份 local.yaml(${altYaml})——repos: 注册只在项目 local.yaml 生效(execute 等读侧只读项目侧),spec 根那份的 repos: 段是死配置`);
4118
+ }
4119
+ } catch { /* 提醒失败不影响注册结果 */ }
4120
+ }
4104
4121
  console.log(' 查看: sillyspec config cat(真实配置); main 隐式=主仓不用注册');
4105
4122
  break;
4106
4123
  }