sillyspec 3.32.4 → 3.32.6

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/src/spec-sync.js CHANGED
@@ -594,11 +594,29 @@ export function isFollowerSetChanged(specRoot, key, list) {
594
594
  * @param {string} changeName - 当前变更名
595
595
  * @returns {Promise<{synced: number, conflict?: boolean, serverVersions?: object}>}
596
596
  */
597
+ /**
598
+ * 冲突路径 → 变更名归因标签(2026-10-11-sync-conflict-tri-fix FR-03 全树同步入口用):
599
+ * changes/(archive/)?<名>/ 前缀提取,全部路径归因到同一变更 → 用该变更名(冲突文件
600
+ * spec-sync-conflict-<名>.json 与 per-change 流程兼容,resolve/status UX 不变);
601
+ * 跨多变更或无可归因路径 → 'full-tree'(单文件合并裁决,resolve 整体 force-push)。
602
+ */
603
+ function attributeChangeLabel(paths) {
604
+ const names = new Set()
605
+ for (const p of paths) {
606
+ const m = p.match(/^changes\/(?:archive\/)?([^/]+)\//)
607
+ if (m) names.add(m[1])
608
+ }
609
+ return names.size === 1 ? [...names][0] : 'full-tree'
610
+ }
611
+
597
612
  export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
598
613
  if (!platform || !platform.url || !platform.token) {
599
614
  debugLog('[spec-sync] 未连接平台(本地合法状态);跳过 spec 树增量同步');
600
615
  return { synced: 0 };
601
616
  }
617
+ // 全树入口(platform sync 无 --change,2026-10-11-sync-conflict-tri-fix FR-03)
618
+ // changeName=null:日志用 full-tree 标签,冲突归因见下方 conflictLabel。
619
+ const logLabel = changeName || 'full-tree';
602
620
  // 绑定连接类失败噪音闸(sync-noise.js):本目录所有 [spec-sync]/[sync] 失败 warn 的
603
621
  // 跨进程静默窗口 marker 落这里(与 SyncManager 构造器绑定同目录)。
604
622
  try { bindSyncNoiseRoot(join(specRoot, '.runtime')); } catch { /* 直通降级 */ }
@@ -622,7 +640,7 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
622
640
  // 8 分钟才被后续步骤的同步补上)。失败可见、成功不打扰。
623
641
  // 连接类失败(404=端点未就绪 / 5xx)走噪音闸(sync-noise.js):平台未就绪期每步
624
642
  // 自动同步重刷同一行,淹没门禁输出;其余状态码(4xx 业务态)每轮该看见。
625
- const line = `[spec-sync] 拉取清单失败 HTTP ${res.status}(文件树本次未同步,下次自动重试): ${changeName}`;
643
+ const line = `[spec-sync] 拉取清单失败 HTTP ${res.status}(文件树本次未同步,下次自动重试): ${logLabel}`;
626
644
  if (isConnectionClassStatus(res.status)) syncConnectionWarn(line);
627
645
  else console.warn(line);
628
646
  return { synced: 0 };
@@ -630,7 +648,7 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
630
648
  const body = await res.json().catch(() => ({}));
631
649
  serverManifest = body.files || {};
632
650
  } catch (err) {
633
- const line = `[spec-sync] 拉取清单异常(文件树本次未同步,下次自动重试): ${changeName}: ${describeSyncError(err)}`;
651
+ const line = `[spec-sync] 拉取清单异常(文件树本次未同步,下次自动重试): ${logLabel}: ${describeSyncError(err)}`;
634
652
  syncConnectionWarn(line); // 网络/超时/熔断均连接类
635
653
  return { synced: 0 };
636
654
  }
@@ -671,7 +689,7 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
671
689
  }
672
690
 
673
691
  if (ops.length === 0) {
674
- debugLog(`[spec-sync] 无差异,跳过同步: ${changeName}`);
692
+ debugLog(`[spec-sync] 无差异,跳过同步: ${logLabel}`);
675
693
  clearStaleTombstoneMarkers(specRoot);
676
694
  return { synced: 0 };
677
695
  }
@@ -688,7 +706,7 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
688
706
  signal: withSignal(30000),
689
707
  });
690
708
  if (!res.ok) {
691
- const line = `[spec-sync] 同步请求失败 HTTP ${res.status}(文件树本次未同步,下次自动重试): ${changeName}`;
709
+ const line = `[spec-sync] 同步请求失败 HTTP ${res.status}(文件树本次未同步,下次自动重试): ${logLabel}`;
692
710
  if (isConnectionClassStatus(res.status)) syncConnectionWarn(line);
693
711
  else console.warn(line);
694
712
  return { synced: 0 };
@@ -719,6 +737,9 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
719
737
  ? { followers: [], real: serverVersions }
720
738
  : partitionConflictPaths(serverVersions, localFiles, baseHashes, lastSyncTs);
721
739
  const realPaths = Object.keys(real).filter((p) => !tombSet.has(p));
740
+ // 全树入口(changeName=null)冲突归因:单一变更 → 该变更名(冲突文件/markers 与
741
+ // per-change 流程同名兼容);跨变更 → 'full-tree' 单文件合并裁决。
742
+ const conflictLabel = changeName || attributeChangeLabel(realPaths);
722
743
 
723
744
  // 纯墓碑形态:版本冲突面为空、拒收全是墓碑 → 不写/不清通用冲突标记(clear 会把
724
745
  // 状态洗成「已消解」误导 status),单独落冲突文件 + 正确恢复指引。
@@ -743,13 +764,13 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
743
764
  // 自愈」连环刷屏。冲突文件在且冲突集未变 = 用户已被告知:重复轮次单行提示(可见但不
744
765
  // 吓人),不重写文件(created_at 稳定,status 红标语义不变);冲突集变化才重写+全幅。
745
766
  // forcePush(resolve --keep-local 刚裁决完重推)不参与去重——需真实判定与文件刷新。
746
- const pendingSame = !opts.forcePush && realPaths.length > 0 && isSamePendingSpecConflict(specRoot, changeName, realPaths);
767
+ const pendingSame = !opts.forcePush && realPaths.length > 0 && isSamePendingSpecConflict(specRoot, conflictLabel, realPaths);
747
768
  if (pendingSame) {
748
769
  if (followers.length > 0) {
749
770
  debugLog(`[spec-sync] ${followers.length} 个本地未改动文件自动跟随服务器: ${followers.slice(0, 5).join(', ')}${followers.length > 5 ? ' 等' : ''}`);
750
771
  }
751
- console.warn(`⚠️ [spec-sync] 变更 ${changeName} 存在未决 spec 树冲突(${realPaths.length} 个文件待裁决,本轮非冲突改动已照常同步)。处理:sillyspec platform resolve ${changeName} --keep-local | --take-platform | --abort`);
752
- return { synced: 0, conflict: true, serverVersions: real, autoResolved: followers.length, conflictPath: join(specRoot, '.runtime', `spec-sync-conflict-${changeName}.json`), suppressed: true };
772
+ console.warn(`⚠️ [spec-sync] 变更 ${conflictLabel} 存在未决 spec 树冲突(${realPaths.length} 个文件待裁决,本轮非冲突改动已照常同步)。处理:sillyspec platform resolve ${conflictLabel} --keep-local | --take-platform | --abort`);
773
+ return { synced: 0, conflict: true, serverVersions: real, autoResolved: followers.length, conflictPath: join(specRoot, '.runtime', `spec-sync-conflict-${conflictLabel}.json`), suppressed: true };
753
774
  }
754
775
  if (followers.length > 0) {
755
776
  console.warn(`[spec-sync] ${followers.length} 个本地未改动文件自动跟随服务器(正常多端前进,无需人工裁决): ${followers.slice(0, 5).join(', ')}${followers.length > 5 ? ' 等' : ''}`);
@@ -757,18 +778,18 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
757
778
  if (realPaths.length === 0) {
758
779
  writeLastSyncTs(specRoot);
759
780
  writeBaseSnapshot(specRoot, localFiles);
760
- clearSpecConflictMarker(specRoot, changeName);
781
+ clearSpecConflictMarker(specRoot, conflictLabel);
761
782
  noteSyncSuccess();
762
- console.log(`[spec-sync] 冲突已自动消解(0 个需人工裁决;本会话改动 ${ops.length - followers.length > 0 ? '已同步' : '无差异'}): ${changeName}`);
783
+ console.log(`[spec-sync] 冲突已自动消解(0 个需人工裁决;本会话改动 ${ops.length - followers.length > 0 ? '已同步' : '无差异'}): ${conflictLabel}`);
763
784
  return { synced: Math.max(ops.length - followers.length, 0), conflict: false, autoResolved: followers.length };
764
785
  }
765
786
  let conflictPath = null;
766
787
  try {
767
788
  const runtimeDir = join(specRoot, '.runtime');
768
789
  mkdirSync(runtimeDir, { recursive: true });
769
- conflictPath = join(runtimeDir, `spec-sync-conflict-${changeName}.json`);
790
+ conflictPath = join(runtimeDir, `spec-sync-conflict-${conflictLabel}.json`);
770
791
  writeFileSync(conflictPath, JSON.stringify({
771
- change: changeName,
792
+ change: conflictLabel,
772
793
  kind: 'spec-tree',
773
794
  created_at: new Date().toISOString(),
774
795
  server_versions: real,
@@ -786,20 +807,28 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
786
807
  console.warn('');
787
808
  console.warn(`⚠️ [spec-sync] 检测到 spec 树冲突(${realPaths.length} 个文件: ${shownPaths};逐文件服务器版本见冲突详情文件)`);
788
809
  if (tombNote) console.warn(`⚠️ [spec-sync]${tombNote.trim()}`);
789
- console.warn(`⚠️ 处置:sillyspec platform resolve ${changeName} --keep-local | --take-platform | --abort(冲突详情: ${conflictPath || '(写入失败)'})`);
810
+ console.warn(`⚠️ 处置:sillyspec platform resolve ${conflictLabel} --keep-local | --take-platform | --abort(冲突详情: ${conflictPath || '(写入失败)'})`);
790
811
  return { synced: 0, conflict: true, serverVersions: real, autoResolved: followers.length, conflictPath };
791
812
  }
792
813
  noteSyncSuccess();
793
- console.log(`[spec-sync] 已同步 ${ops.length} 个文件变更: ${changeName}`);
814
+ console.log(`[spec-sync] 已同步 ${ops.length} 个文件变更: ${logLabel}`);
794
815
  // 本轮无冲突 = 此前的未决 spec 树冲突已不存在,清残留标记(否则 status 永久红标、
795
- // 横幅去重单行提示不消——坑 spec-sync-conflict-banner-spam)
796
- clearSpecConflictMarker(specRoot, changeName);
816
+ // 横幅去重单行提示不消——坑 spec-sync-conflict-banner-spam)。全树入口
817
+ // (changeName=null,2026-10-11-sync-conflict-tri-fix FR-03)按归因清:只清本轮
818
+ // ops 触及变更的标记——未触及变更的未决冲突不因全树绿而误清。
819
+ if (changeName) {
820
+ clearSpecConflictMarker(specRoot, changeName);
821
+ } else {
822
+ for (const name of new Set(ops.map((o) => attributeChangeLabel([o.path])))) {
823
+ if (name !== 'full-tree') clearSpecConflictMarker(specRoot, name);
824
+ }
825
+ }
797
826
  clearStaleTombstoneMarkers(specRoot);
798
827
  writeLastSyncTs(specRoot);
799
828
  writeBaseSnapshot(specRoot, localFiles);
800
829
  return { synced: ops.length };
801
830
  } catch (err) {
802
- const line = `[spec-sync] 同步异常(文件树本次未同步,下次自动重试): ${changeName}: ${describeSyncError(err)}`;
831
+ const line = `[spec-sync] 同步异常(文件树本次未同步,下次自动重试): ${logLabel}: ${describeSyncError(err)}`;
803
832
  syncConnectionWarn(line); // 网络/超时/熔断均连接类
804
833
  return { synced: 0 };
805
834
  }
@@ -261,6 +261,34 @@ export function detectDuplicateTopKeys(fmText) {
261
261
  * @returns {Map<string, string>} repoKey → absolutePath
262
262
  */
263
263
  export function parseRepoRegistry(yamlText) {
264
+ return _parseRepoEntries(yamlText, 'path')
265
+ }
266
+
267
+ /**
268
+ * repos 段条目的 worktree 子键 → Map<repoKey, 落位根目录>(坑 cross-wt-toolchain-split 配置
269
+ * 简化,2026-10-10 用户反馈:独立 worktree.crossPlacement 段与仓注册分居两处形态重——落位
270
+ * 直接内联在注册条目上)。
271
+ *
272
+ * 只认对象形态条目的 worktree 子键;字符串条目(值即路径)无落位语义不入 Map。清洗规则
273
+ * (CRLF/行内注释/引号)与 parseRepoRegistry 同源。
274
+ * @param {string} yamlText
275
+ * @returns {Map<string, string>} repoKey → 落位根目录(未配置的仓不在 Map)
276
+ */
277
+ export function parseRepoWorktreePlacements(yamlText) {
278
+ return _parseRepoEntries(yamlText, 'worktree')
279
+ }
280
+
281
+ /**
282
+ * repos 段统一解析内核:subkey='path' → 注册表(字符串条目值即 path);subkey='worktree' →
283
+ * 落位内联(仅对象条目)。条目三形态:
284
+ * urgent: /mnt/e/repo # 字符串(值即 path,worktree 不适用)
285
+ * urgent: {path: /mnt/e/repo, worktree: /mnt/e/wt} # inline flow 对象
286
+ * urgent: # 块式对象
287
+ * path: /mnt/e/repo
288
+ * worktree: /mnt/e/wt
289
+ * @returns {Map<string, string>}
290
+ */
291
+ function _parseRepoEntries(yamlText, subkey) {
264
292
  const reg = new Map()
265
293
  if (!yamlText) return reg
266
294
  // CRLF 归一(坑 register-repo-crlf-idempotent-loop,2026-08-23 实证:Windows 下 agent Write
@@ -269,6 +297,7 @@ export function parseRepoRegistry(yamlText) {
269
297
  // 幂等跳过不落盘 → 死循环。同文件 parseAllowedPaths/parseDependsOn/parseBaseCommit 均有此
270
298
  // 归一,本函数是唯一缺口)
271
299
  const lines = String(yamlText).replace(/\r\n?/g, '\n').split('\n')
300
+ const cleanVal = (v) => String(v || '').replace(/\s+#.*$/, '').trim().replace(/^['"]|['"]$/g, '')
272
301
  let startIdx = -1
273
302
  for (let i = 0; i < lines.length; i++) {
274
303
  if (/^repos:\s*(?:#.*)?$/.test(lines[i])) { startIdx = i; break }
@@ -281,15 +310,44 @@ export function parseRepoRegistry(yamlText) {
281
310
  // 条目格式: <key>: <value>(key 限 [A-Za-z0-9_.\-],与 parseLocalYamlModules 对齐)
282
311
  const entry = line.match(/^\s+([A-Za-z0-9_.\-]+):\s*(.*)$/)
283
312
  if (!entry) continue
284
- const value = (entry[2] || '').trim()
285
- if (value === '' || value.startsWith('#')) continue
286
- // 去可选行内注释(`value # comment`)—— 注意路径不含 ` #`,注释前必带空格
287
- const cleaned = value.replace(/\s+#.*$/, '').trim()
288
- if (cleaned === '') continue
289
- // 去首尾可选引号(YAML 字符串引用),保留路径内反斜杠原样
290
- const path = cleaned.replace(/^['"]|['"]$/g, '')
291
- if (path === '') continue
292
- reg.set(entry[1], path)
313
+ const key = entry[1]
314
+ const indent = line.length - line.trimStart().length
315
+ const rawValue = (entry[2] || '').trim()
316
+
317
+ if (rawValue !== '' && !rawValue.startsWith('#')) {
318
+ if (rawValue.startsWith('{') && rawValue.endsWith('}')) {
319
+ // inline flow 对象:拆逗号取目标子键(值内逗号不支持——路径含逗号请用块式)
320
+ let hit = null
321
+ for (const part of rawValue.slice(1, -1).split(',')) {
322
+ const idx = part.indexOf(':')
323
+ if (idx === -1) continue
324
+ if (part.slice(0, idx).trim() === subkey) { hit = cleanVal(part.slice(idx + 1)); break }
325
+ }
326
+ if (hit) reg.set(key, hit)
327
+ } else if (subkey === 'path') {
328
+ // 字符串条目:值即 path(worktree 子键语义不适用于字符串形态)
329
+ const cleaned = cleanVal(rawValue)
330
+ if (cleaned !== '') reg.set(key, cleaned)
331
+ }
332
+ continue
333
+ }
334
+
335
+ // 块式对象条目(value 空):消费整个子键块(缩进更深的连续行),取目标子键首个命中——
336
+ // 整块消费防子键行(path:/worktree:)被外层循环误当条目再匹配('path'/'worktree' 混进
337
+ // registry 污染 Map)
338
+ let j = i + 1
339
+ for (; j < lines.length; j++) {
340
+ const l = lines[j]
341
+ if (l.trim() === '' || l.trim().startsWith('#')) continue
342
+ const subIndent = l.length - l.trimStart().length
343
+ if (subIndent <= indent) break // 离开本条目块(下一条目或段结束)
344
+ const sub = l.match(/^\s+([A-Za-z0-9_.\-]+):\s*(.*)$/)
345
+ if (sub && sub[1] === subkey && !reg.has(key)) {
346
+ const cleaned = cleanVal(sub[2])
347
+ if (cleaned !== '' && !cleaned.startsWith('#')) reg.set(key, cleaned)
348
+ }
349
+ }
350
+ i = j - 1 // 整块已消费;外层 ++ 落在块尾后第一行(下一条目或段边界)
293
351
  }
294
352
  return reg
295
353
  }
package/src/sync.js CHANGED
@@ -2333,6 +2333,9 @@ export function listConflictFiles(cwd) {
2333
2333
  // 清单为锚做全树 diff,与变更目录无关——triggerSync 对 quick 会话降级只调本函数,
2334
2334
  // 不走 sync()(后者第二道 existsSync 门会以「变更不存在」提前 return)。
2335
2335
  // 未连接平台 → {synced: 0} 静默(本地合法状态,与 syncSpecTree 内部口径一致)。
2336
+ // 2026-10-11-sync-conflict-tri-fix FR-03:changeName=null 是合法形态——CLI 手动
2337
+ // 全树同步入口(platform sync 无 --change)经此走全树 diff;冲突归由 syncSpecTree
2338
+ // 内部 attributeChangeLabel 按路径归因(单一变更名或 'full-tree')。
2336
2339
  export async function syncSpecTreeOnly(changeName, cwd, opts = {}) {
2337
2340
  const sm = new SyncManager(cwd);
2338
2341
  const platform = sm._getPlatform();
@@ -58,7 +58,21 @@ import { runVerifyProbes, readSmokeResultRecord, deriveSmokeSectionState, scanSm
58
58
  import { parseEvidenceSlots, classifyVerifiedFile } from './verify-facts-schema.js'
59
59
 
60
60
  // 测试命令最长执行时间;超时视为失败(防止 CLI 被挂起的测试卡死)
61
- const TEST_TIMEOUT_MS = Number(process.env.SILLYSPEC_TEST_TIMEOUT_MS) || 10 * 60 * 1000
61
+ // 单位统一(坑 active-pitfalls SILLYSPEC_TEST_TIMEOUT_MS 单位二义,2026-10-10 实证):
62
+ // 此前本常量裸读 env 当毫秒,而 resolveTestTimeoutMs 按文档契约(config-schema
63
+ // test_timeout_sec 同效)当秒×1000——同一变量两读法,按文档传秒值(如 1800)被批执行器
64
+ // 当 1.8s 帽、deps 批瞬时 ETIMEDOUT。统一走 parseTestTimeoutEnvMs:秒契约为准;
65
+ // ≥10000 的值按毫秒兼容解释(实证期的规避口径)并告警促改秒。
66
+ function parseTestTimeoutEnvMs(raw) {
67
+ const e = Number(raw)
68
+ if (!Number.isFinite(e) || e <= 0) return null
69
+ if (e >= 10_000) {
70
+ console.warn(`⚠️ SILLYSPEC_TEST_TIMEOUT_MS=${e} 按毫秒解释(= ${(e / 1000).toFixed(0)}s)——本变量契约单位是秒(与 test_timeout_sec 同效),建议改传秒值。`)
71
+ return e
72
+ }
73
+ return e * 1000
74
+ }
75
+ const TEST_TIMEOUT_MS = parseTestTimeoutEnvMs(process.env.SILLYSPEC_TEST_TIMEOUT_MS) || 10 * 60 * 1000
62
76
 
63
77
  /**
64
78
  * 测试超时配置链(R9 实证 2026-09-23):local.yaml commands.test_timeout_sec > env
@@ -69,8 +83,7 @@ const TEST_TIMEOUT_MS = Number(process.env.SILLYSPEC_TEST_TIMEOUT_MS) || 10 * 60
69
83
  export function resolveTestTimeoutMs(yamlText, env = process.env) {
70
84
  const m = String(yamlText || '').match(/^\s*test_timeout_sec:\s*([0-9]+)\s*(?:#.*)?$/m)
71
85
  if (m && Number(m[1]) > 0) return Number(m[1]) * 1000
72
- const e = Number(env.SILLYSPEC_TEST_TIMEOUT_MS)
73
- return Number.isFinite(e) && e > 0 ? e * 1000 : TEST_TIMEOUT_MS
86
+ return parseTestTimeoutEnvMs(env.SILLYSPEC_TEST_TIMEOUT_MS) || TEST_TIMEOUT_MS
74
87
  }
75
88
  const OUTPUT_TAIL_CHARS = 4000
76
89
  // 判账失败行台账上限(坑 verify-test-reconcile-tail-blindspot):对账按完整输出判、tail 只存
@@ -1936,7 +1949,29 @@ function resolveMainChangedFiles(cwd, changeName, specBase = null) {
1936
1949
  const diffBase = meta?.baselineCommit || meta?.actualBaseHash || meta?.baseHash
1937
1950
  if (diffBase) {
1938
1951
  const gitDir = found.gitDir
1939
- const files = runGitDiffNameOnly(gitDir, `${diffBase}..HEAD`)
1952
+ // 基点 merge-base 化(坑 verify-reconcile-base-stale-parallel-drift,2026-10-10 实证:
1953
+ // 执行期把 main 合入 worktree 分支后,立项 diffBase..HEAD 把主仓并行演进算成本变更
1954
+ // 产出——对账 21 条 undeclared 噪声淹没真实信号。改锚 merge-base(<主分支>, HEAD):
1955
+ // 只含本分支自身提交;未合 main 时 merge-base == 立项点行为零变化。不放大守卫:
1956
+ // 仅当 mb 是 diffBase 的后代(窗口只收窄)才采用,否则退旧区间。
1957
+ let anchor = String(diffBase)
1958
+ try {
1959
+ const headHash = String(gitQuiet(gitDir, ['rev-parse', 'HEAD'], { timeout: 15000 }) || '').trim()
1960
+ const mainRef = typeof meta?.baseBranch === 'string' && meta.baseBranch.trim() ? meta.baseBranch.trim() : 'main'
1961
+ let mb = gitQuiet(gitDir, ['merge-base', mainRef, 'HEAD'], { timeout: 15000 })
1962
+ if (!(typeof mb === 'string' && mb.trim())) mb = gitQuiet(gitDir, ['merge-base', 'master', 'HEAD'], { timeout: 15000 })
1963
+ // 三重守卫:mb == HEAD(提交落主分支本身/in-place 形态——主分支即本变更线,
1964
+ // 收窄到 HEAD 会清空 diff 面)或 mb == 立项点(零变化)或 mb 非立项点后代
1965
+ // (窗口放大)时一律保持旧锚。仅「严格收窄」形态才采用 merge-base。
1966
+ if (typeof mb === 'string' && mb.trim()) {
1967
+ const mbHash = mb.trim()
1968
+ if (mbHash !== String(diffBase) && mbHash !== headHash && headHash) {
1969
+ const isDesc = gitQuiet(gitDir, ['merge-base', '--is-ancestor', String(diffBase), mbHash], { timeout: 15000 })
1970
+ if (isDesc === '') anchor = mbHash
1971
+ }
1972
+ }
1973
+ } catch { /* 退旧区间(不放大) */ }
1974
+ const files = runGitDiffNameOnly(gitDir, `${anchor}..HEAD`)
1940
1975
  // 坑 verify-evidence-account-diff-misses-committed-changes(2026-09-13 实证):
1941
1976
  // wt-commit 流程把本变更提交落在**主仓 HEAD**,worktree 分支不前移——仅查 worktree
1942
1977
  // 侧 diffBase..HEAD 时已提交文件全部 miss(7 task diffHit=false 连环假红,verify 两连
@@ -2801,8 +2836,19 @@ function buildDepsBatches({ deps, changedFiles = [], hits = [], cwd = null, prio
2801
2836
  const SUITE_META_RE = /(^|\/)run-tests\.mjs$/
2802
2837
  const jsMeta = js.filter(f => SUITE_META_RE.test(norm(f)))
2803
2838
  const jsList = js.filter(f => !SUITE_META_RE.test(norm(f)))
2839
+ // 非测试形态拆批(坑 verify-dynamic-subset-node-test-ts-source,2026-10-10 平台侧实证,
2840
+ // 用户授权修复):deps 面(FR 绑定 / 增量账本 / 依赖发现)可携带非测试文件——node --test
2841
+ // 语义「文件即测试」直跑源码必败(TS 源码 import './x.js' 无 resolver 映射
2842
+ // ERR_MODULE_NOT_FOUND)、vitest/jest 点名非测试文件 No test suite found、pytest 点名
2843
+ // 非测试 .py 0 collected exit 5——三执行批统一只收测试形态(isTestFilePath 同口径),
2844
+ // 被拆文件 nontest-skip 批点名披露(不跑不拦,绑定面脏数据可见可治理)。
2845
+ const pyNonTest = py.filter(f => !isTestFilePath(f))
2846
+ const pyTest = py.filter(f => isTestFilePath(f))
2847
+ const jsNonTest = jsList.filter(f => !isTestFilePath(f))
2848
+ const jsTest = jsList.filter(f => isTestFilePath(f))
2849
+ const nonTestFiles = [...pyNonTest, ...jsNonTest]
2804
2850
  const CAP = 30
2805
- const pyCap = py.length === 0 ? 0 : jsList.length === 0 ? CAP : Math.max(5, Math.min(CAP - 5, Math.round(CAP * py.length / (py.length + jsList.length))))
2851
+ const pyCap = pyTest.length === 0 ? 0 : jsTest.length === 0 ? CAP : Math.max(5, Math.min(CAP - 5, Math.round(CAP * pyTest.length / (pyTest.length + jsTest.length))))
2806
2852
  const jsCap = CAP - pyCap
2807
2853
  // 配额制组卷(2026-10-06-fr-regress-cap-drop):优先面整跑豁免帽,普通依赖填剩余席位;
2808
2854
  // dropped 只计普通依赖弃置(优先面零弃置是构造保证),组内序保持「优先前缀+字母序」。
@@ -2812,8 +2858,8 @@ function buildDepsBatches({ deps, changedFiles = [], hits = [], cwd = null, prio
2812
2858
  const ordinaryRun = ordinary.slice(0, Math.max(0, quota - prioInGroup.length))
2813
2859
  return { run: [...prioInGroup, ...ordinaryRun], prioCount: prioInGroup.length, dropped: ordinary.length - ordinaryRun.length, prioDropped: 0 }
2814
2860
  }
2815
- const pyQ = quotaFill(py, pyCap)
2816
- const jsQ = quotaFill(jsList, jsCap)
2861
+ const pyQ = quotaFill(pyTest, pyCap)
2862
+ const jsQ = quotaFill(jsTest, jsCap)
2817
2863
  const pyRun = pyQ.run
2818
2864
  const jsRun = jsQ.run
2819
2865
  // .py 运行器推断双源(2026-09-26-dynamic-test-inference):① 命中模块命令串(旧路径,兼容期);
@@ -2883,8 +2929,10 @@ function buildDepsBatches({ deps, changedFiles = [], hits = [], cwd = null, prio
2883
2929
  // dropped 只计普通依赖弃置、prioDropped=防御路径计数(构造上恒 0——quotaFill 保证优先面零弃置,
2884
2930
  // >0 即组卷逻辑被破坏,消费面 loud 披露)。js 批经 jsNative/jsProject 内容分流后计数随分流面。
2885
2931
  if (pyRun.length > 0) batches.push({ name: 'deps(auto-py)', short: 'py', command: `${pyRunner} ${pyRun.map(rebase).join(' ')}`, count: pyRun.length, prioCount: pyQ.prioCount, dropped: pyQ.dropped, prioDropped: pyQ.prioDropped, files: pyRun })
2886
- if (jsNative.length > 0) batches.push({ name: 'deps(auto-js)', short: 'js', command: `node --test --test-reporter=spec --test-reporter-destination=stderr --test-reporter=tap --test-reporter-destination=stdout ${jsNative.join(' ')}`, count: jsNative.length, prioCount: jsNative.filter(f => prio(f) === 0).length, dropped: jsList.length - jsRun.length, prioDropped: jsQ.prioDropped, tap: true, files: jsNative })
2932
+ if (jsNative.length > 0) batches.push({ name: 'deps(auto-js)', short: 'js', command: `node --test --test-reporter=spec --test-reporter-destination=stderr --test-reporter=tap --test-reporter-destination=stdout ${jsNative.join(' ')}`, count: jsNative.length, prioCount: jsNative.filter(f => prio(f) === 0).length, dropped: jsTest.length - jsRun.length, prioDropped: jsQ.prioDropped, tap: true, files: jsNative })
2887
2933
  if (jsMeta.length > 0) batches.push({ name: 'deps(auto-js-meta-skip)', short: 'js-meta-skip', command: null, count: jsMeta.length, dropped: 0, skip: true, files: jsMeta, reason: '套件编排器(run-tests.mjs=npm test 组卷入口)非单测文件——内嵌执行=递归全量套件(嵌套 runner 污染假败);全量门由收口实测/CI 的 npm test 承担' })
2934
+
2935
+ if (nonTestFiles.length > 0) batches.push({ name: 'deps(auto-nontest-skip)', short: 'nontest-skip', command: null, count: nonTestFiles.length, dropped: 0, skip: true, files: nonTestFiles, reason: `非测试形态文件 ${nonTestFiles.length} 个不进单测执行批(node --test 语义「文件即测试」/ vitest No test suite found / pytest 0 collected exit 5——直跑必假败;坑 verify-dynamic-subset-node-test-ts-source):${nonTestFiles.slice(0, 3).join('、')}${nonTestFiles.length > 3 ? ' 等' : ''}——FR 绑定面脏数据可 sillyspec tests --unbind --tests <路径> 清行` })
2888
2936
  if (jsProjectRun.length > 0) {
2889
2937
  if (jsxRunner) {
2890
2938
  const isVitest = /vitest/.test(jsxRunner)
@@ -1758,15 +1758,22 @@ export function parseTaskAcceptance(content) {
1758
1758
  }
1759
1759
 
1760
1760
  /**
1761
- * allowed_paths 条目是否测试形态(结构归属面):test/ 前缀,或文件名含 .test. / _test. / spec
1762
- * 惯例((?:^|[._-])test(?:[._-]|$) 覆盖 foo.test.mjs / foo_test.js / test-foo.js 三形)。
1763
- * 比探针 3 的存在性面(isTestFileName 文件名形态判定,宽松存在性提示)严格——这是 3/7 口径差异的落点。
1764
- */
1765
- function isProbe7TestPath(p) {
1761
+ * allowed_paths 条目是否测试形态(结构归属面):tests?/ 或 __tests__/ 目录段,或文件名
1762
+ * .test./.spec. js 系后缀锚,或 python test_*.py / *_test.py——与 verify-postcheck 执行侧
1763
+ * isTestFilePath 同口径(坑 verify-dynamic-subset-node-test-ts-source,2026-10-10 平台侧
1764
+ * 实证:旧 /spec/i 裸子串把 spec-sync.ts 等「文件名含 spec 字样」的源码误判为测试路径,
1765
+ * 随 trace 落盘进 FR 机器绑定行,后续变更经 FR 回归面被 node --test 直跑假败)。
1766
+ * 漏检边界(宁紧勿松):foo_test.js 连字形态不再预填——落 agent 手查是 fail-visible,
1767
+ * 误绑产假败是 fail-hidden 更糟。比探针 3 的存在性面(isTestFileName 宽松提示)严格——
1768
+ * 3/7 口径差异落点。
1769
+ */
1770
+ export function isProbe7TestPath(p) {
1766
1771
  const posix = String(p).split('\\').join('/')
1767
- if (/^test\//.test(posix)) return true
1772
+ if (/(^|\/)(tests?|__tests__)\//.test(posix)) return true
1768
1773
  const name = basename(posix)
1769
- return /(?:^|[._-])test(?:[._-]|$)/i.test(name) || /spec/i.test(name)
1774
+ return /\.(test|spec)\.[cm]?(js|jsx|ts|tsx)$/.test(name)
1775
+ || /(^|\/)test_[^\/]+\.py$/.test(posix)
1776
+ || /(^|\/)[^\/]+_test\.py$/.test(posix)
1770
1777
  }
1771
1778
 
1772
1779
  /**
@@ -1046,6 +1046,43 @@ export async function withMainRepoLock(projectRoot, changeName, purpose, fn, opt
1046
1046
  }
1047
1047
  }
1048
1048
 
1049
+ /**
1050
+ * 跨仓主副本直写检测(FR-06,D-003@v1——坑 cross-wt-toolchain-split 2026-10-10 用户实证:
1051
+ * agent 绕开 worktree 回主副本直写 master,5 个 fire commit 落主干、空 worktree 被静默清理,
1052
+ * 全程零提示)。
1053
+ *
1054
+ * 判定:主副本 base 锚(meta.baselineCommit||baseHash,与 worktree 交付 diff 同锚)之后有
1055
+ * commit 推进,且推进触及的文件与本变更该仓声明文件面(resolveApplyAllowSet 切片)以
1056
+ * pathMatches 容差相交 → 返回 { commits, files }(交集文件,声明面命中口径与清单校验一致)。
1057
+ * 纯检测不写盘:advisory,调用方(applyCrossRepoWorktrees)负责出 warning。
1058
+ *
1059
+ * fail-open(FR-07):git 不可达 / baseHash 缺失 / allowSet 空 / 任何异常 → null(调用方静默
1060
+ * 跳过——并行会话无关推进是合法态,误报成本高于漏报面已由 D-003 故障面接受)。
1061
+ *
1062
+ * @param {string} crossRoot 跨仓主副本仓根
1063
+ * @param {string} baseHash base 锚(worktree meta 的 baselineCommit||baseHash)
1064
+ * @param {Set<string>} allowSet 该仓声明文件面(相对跨仓仓根;空 = 声明面缺失不检测)
1065
+ * @returns {{ commits: number, files: string[] }|null} null = 无推进/无交集/检测不可得
1066
+ */
1067
+ export function detectCrossMainCopyBypass(crossRoot, baseHash, allowSet) {
1068
+ try {
1069
+ if (!crossRoot || !baseHash || !(allowSet instanceof Set) || allowSet.size === 0) return null;
1070
+ const countRaw = gitQuiet(crossRoot, ['rev-list', '--count', `${baseHash}..HEAD`]);
1071
+ if (countRaw === null) return null; // git 失败(非仓/锚悬空)→ fail-open
1072
+ const commits = Number(String(countRaw).trim());
1073
+ if (!Number.isFinite(commits) || commits <= 0) return null; // 无推进 → 合法态
1074
+ const raw = gitQuiet(crossRoot, ['diff', '--name-only', baseHash, 'HEAD']);
1075
+ if (raw === null) return null;
1076
+ const touched = raw.split('\n').map(s => s.trim()).filter(Boolean);
1077
+ if (touched.length === 0) return null;
1078
+ const files = touched.filter(f => [...allowSet].some(ap => pathMatches(f, ap)));
1079
+ if (files.length === 0) return null; // 推进但与声明面无交集 → 并行会话,不告警
1080
+ return { commits, files };
1081
+ } catch {
1082
+ return null;
1083
+ }
1084
+ }
1085
+
1049
1086
  /**
1050
1087
  * 跨仓 worktree 真实 apply(坑 cross-repo-no-worktree-isolation,2026-08-27)。
1051
1088
  *
@@ -1104,6 +1141,31 @@ function applyCrossRepoWorktrees(changeName, projectRoot, ctx, { checkOnly = fal
1104
1141
  continue;
1105
1142
  }
1106
1143
 
1144
+ // 声明面解析(提前到空清理分支前——直写检测需要 allowSet 覆盖「空 worktree 被静默清理」
1145
+ // 的 D-007 签名场景;清单校验段复用同一份,零行为差)。
1146
+ // specBase 显式传(平台模式 changes/ 在 specRoot;本地模式与旧硬编码同值零回归)。
1147
+ // review 声明收紧(D-003@v1,2026-09-14-change-ownership-guards task-03):声明文件须与
1148
+ // allow 面(design 清单 ∪ 各 task target_files/allowed_paths ∪ linked-change 声明 =
1149
+ // resolveApplyAllowSet 现有并集)相交(pathMatches 容差)才计入;不相交的外来声明剔除出
1150
+ // 放行面、进 violations 报告行(「review 声明了越权文件」嫌疑标注),admission 增量归零。
1151
+ const allowMap = resolveApplyAllowSet(projectRoot, changeName, { specBase });
1152
+ const allowSet = allowMap.get(repoKey) || new Set();
1153
+ const reviewDeclared = collectReviewDeclaredFiles(projectRoot, changeName, { runtimeRoot: join(specBase, '.runtime') }).get(repoKey);
1154
+
1155
+ // 跨仓主副本直写检测(FR-06,D-003@v1 advisory——坑 cross-wt-toolchain-split 2026-10-10
1156
+ // 实证:agent 绕开 worktree 回主副本直写 master,空 worktree 在此被静默清理零提示):
1157
+ // 主副本 base 锚之后有提交推进且推进文件与声明面相交 = 本变更签名,warning 列证。
1158
+ // 交集空/无推进/声明面缺失不告警(并行会话无关推进合法);检测异常 fail-open(FR-07)。
1159
+ try {
1160
+ const bypass = detectCrossMainCopyBypass(crossRoot, deliverableBase, allowSet);
1161
+ if (bypass) {
1162
+ out.warnings.push(
1163
+ `跨仓 ${repoKey}:疑似绕过 worktree 直写主干——base ${String(deliverableBase).slice(0, 8)} 之后主副本有 ${bypass.commits} 个 commit,以下文件与本变更声明面相交:\n ${bypass.files.join('\n ')}\n` +
1164
+ `(worktree 隔离被绕开:请核对该仓执行期决策留痕;若属误写应把提交摘到分支重走 apply。advisory 警告不阻断 apply。)`
1165
+ );
1166
+ }
1167
+ } catch { /* 直写检测 fail-open,不阻断 apply */ }
1168
+
1107
1169
  if (changedFiles.length === 0) {
1108
1170
  if (!checkOnly) {
1109
1171
  const cr = cleanupCrossWorktrees({ cwd: crossRoot, changeName, specBase, force: true });
@@ -1117,14 +1179,6 @@ function applyCrossRepoWorktrees(changeName, projectRoot, ctx, { checkOnly = fal
1117
1179
  }
1118
1180
 
1119
1181
  // 清单校验(per-repo 切片 + review 声明相交过滤,与主仓 Gate1/3b 同语义)。
1120
- // specBase 显式传(平台模式 changes/ 在 specRoot;本地模式与旧硬编码同值零回归)。
1121
- // review 声明收紧(D-003@v1,2026-09-14-change-ownership-guards task-03):声明文件须与
1122
- // allow 面(design 清单 ∪ 各 task target_files/allowed_paths ∪ linked-change 声明 =
1123
- // resolveApplyAllowSet 现有并集)相交(pathMatches 容差)才计入;不相交的外来声明剔除出
1124
- // 放行面、进 violations 报告行(「review 声明了越权文件」嫌疑标注),admission 增量归零。
1125
- const allowMap = resolveApplyAllowSet(projectRoot, changeName, { specBase });
1126
- const allowSet = allowMap.get(repoKey) || new Set();
1127
- const reviewDeclared = collectReviewDeclaredFiles(projectRoot, changeName, { runtimeRoot: join(specBase, '.runtime') }).get(repoKey);
1128
1182
  const reviewForeign = [];
1129
1183
  if (reviewDeclared && allowSet.size > 0) {
1130
1184
  const face = [...allowSet];