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.
@@ -20,7 +20,7 @@ import { WorktreeManager } from './worktree.js';
20
20
  import { getCrossWorktreeMeta, cleanupCrossWorktrees } from './worktree-cross.js';
21
21
  import { parseFileChangeList, parseFileChangeListDetailed, pathMatches } from './change-list.js';
22
22
  import { parseAllowedPaths, parseRepo } from './stages/plan-postcheck.js';
23
- import { git, gitQuiet, safeGit } from './git-helper.js';
23
+ import { git, gitQuiet, safeGit, unquoteGitPath } from './git-helper.js';
24
24
  import { resolveLatestExecuteRunId, resolveLatestExecuteRunIdWithTasks, readReview, normalizeRepoKey } from './task-review.js';
25
25
  import { collectActiveQuickGuardFiles } from './quicklog.js';
26
26
  import { detectCommittedDrift, formatCommittedDriftWarning } from './run/concurrent-detect.js';
@@ -43,6 +43,40 @@ function consumeCleanupResult(cr, result) {
43
43
  return cr;
44
44
  }
45
45
 
46
+ /**
47
+ * 工具/平台脚手架分类(scope-audit-cross-repo-blindness 改进点 1,2026-09-15):
48
+ * CLI/平台自己写进仓的文件,不是本变更的交付改动。分硬排(filterDeliverableFiles
49
+ * 直接滤除)与软桶(classifyToolScaffold 打标、scope-audit 归入「工具/平台设施」
50
+ * 不占「计划外」)两档——区分依据是 apply 语义:filterDeliverableFiles 的滤除会让
51
+ * apply 不回放该文件(坑 apply-glob-manifest 静默丢失类),故只有「绝无交付语义、
52
+ * 回放反而有害」的进硬排;可能被 design 清单正当声明的(SKILL.md/CLAUDE.md 改写
53
+ * 本身是合法交付)只打标不滤除。
54
+ *
55
+ * 硬排(platform 设施,绝不交付):
56
+ * - .worktrees/:嵌套 worktree 目录(cp 回放会把整个 worktree 副本塞进主仓)
57
+ * - .sillyspec-platform 系根标记文件(含 -cleaned / -managed / .json 变体):平台元数据
58
+ * - .sillyspec/knowledge/:CLI 自维护知识库(路由 INDEX 等,机器本地态)
59
+ * - .sillyspec/local.yaml:机器本地配置(gitignore 约定;回放会覆盖主仓本地配置)
60
+ * 软桶(tool 脚手架,可能正当交付 → 只打标):
61
+ * - .claude/skills/(含 sillyspec-*):CLI 自装 skill 脚手架
62
+ * - CLAUDE.md:CLI/平台维护的 agent 指引
63
+ * - attachments/:brainstorm 需求附件(docx/html/截图等流程产物)
64
+ * @param {string} f 仓库根相对路径(正斜杠)
65
+ * @returns {'platform'|'tool'|null}
66
+ */
67
+ export function classifyToolScaffold(f) {
68
+ const p = String(f || '').replace(/\\/g, '/');
69
+ if (!p) return null;
70
+ if (p.startsWith('.worktrees/')
71
+ || /^\.sillyspec-platform(?:-cleaned|-managed)?(?:\.json)?$/.test(p)
72
+ || p.startsWith('.sillyspec/knowledge/')
73
+ || p === '.sillyspec/local.yaml') return 'platform';
74
+ if (p.startsWith('.claude/skills/')
75
+ || p === 'CLAUDE.md'
76
+ || p.startsWith('attachments/')) return 'tool';
77
+ return null;
78
+ }
79
+
46
80
  /**
47
81
  * 过滤掉 worktree 基础设施文件(非交付物),让 apply 只关心真正的变更产出:
48
82
  * - meta.json:worktree 元数据,baseline commit 中被跟踪、working-tree 被 CLI 改写
@@ -54,14 +88,19 @@ function consumeCleanupResult(cr, result) {
54
88
  * - .sillyspec/.runtime/:运行时产物(进度库/锁/review 产物,非源码)。
55
89
  * - .sillyspec/quicklog/:quicklog 条目(worktree 进度,非交付物)。
56
90
  * 保留 .sillyspec/docs/(dogfood 模块规范文档 = 交付物,apply 回主仓)。
57
- * 对 modified-tracked(git diff)与 untracked(ls-files --others)一视同仁。
91
+ * - classifyToolScaffold 的 platform 档(.worktrees/、.sillyspec-platform 系根标记、
92
+ * .sillyspec/knowledge/、.sillyspec/local.yaml):工具/平台设施,绝无交付语义
93
+ * (scope-audit-cross-repo-blindness 改进点 1——锚点窗口内 CLI 自装文件曾全量
94
+ * 落「计划外」;回放有害类直接硬排)。
95
+ * 对 modified-tracked(git diff)与 untracked(ls-files --others)一视同仁。
58
96
  */
59
97
  export function filterDeliverableFiles(files) {
60
98
  return files.filter(f =>
61
99
  !f.startsWith('.sillyspec/changes/') &&
62
100
  !f.startsWith('.sillyspec/.runtime/') &&
63
101
  !f.startsWith('.sillyspec/quicklog/') &&
64
- f !== 'meta.json'
102
+ f !== 'meta.json' &&
103
+ classifyToolScaffold(f) !== 'platform'
65
104
  );
66
105
  }
67
106
 
@@ -424,6 +463,69 @@ export function chunkPaths(paths, maxChars = 8000) {
424
463
  return batches
425
464
  }
426
465
 
466
+ /**
467
+ * no-op 文件检测(FR-02 / D-002@v1,坑② assess-noop-blocked):判定候选文件中
468
+ * 「worktree 工作区内容 = 主仓 HEAD blob」者。execute 期主仓 HEAD 前进后,worktree 内
469
+ * 自救/重同步成主仓最新内容的文件对 baseline checkpoint diff 非空,apply 回主仓实为
470
+ * no-op,却被 assess 误判「变更文件超出 allowed_paths」BLOCKED——由 applyWorktree
471
+ * step 2 单点 choke point 调用(apply 与 assess checkOnly 复用同函数,自动同口径)。
472
+ *
473
+ * 比对:worktree 工作区 `git hash-object`(默认带 clean filter,与 tree blob 同口径)
474
+ * 按 argv 分批(chunkPaths,worktree.js _changesAlreadyOnMain 同型),批次输出按行序
475
+ * 拼接与候选索引对齐;主仓 HEAD blob 复用 getBlobHashMap(ls-tree)。路径对照键统一
476
+ * quotepath 剥引号(unquoteGitPath)+ 反斜杠归一正斜杠,仅用于比对不回写(changedFiles
477
+ * 路径字面保持现状口径,下游 pathMatches/gates/archive-delta 零改动)。
478
+ *
479
+ * fail-safe(D-002 故障面契约):hash-object 某批失败 / 行数不齐 / 整段异常 → 该批
480
+ * (或全部)保守不剔,保留 changed——退回现状误报而非误放行。每调用现算不缓存(主仓
481
+ * HEAD 在 assess 与 apply 间可能推进,以各自当下事实为准)。
482
+ *
483
+ * @param {string} projectRoot 主仓根(HEAD blob 读取处)
484
+ * @param {string} worktreePath worktree 根(工作区 hash-object 执行处)
485
+ * @param {string[]} candidates 候选文件(调用方已保证 worktree 工作区存在——删除类天然出局)
486
+ * @returns {string[]} 判定为 no-op 的文件子集(candidates 原字面);判定不可得时返回 []
487
+ */
488
+ function detectNoOpFiles(projectRoot, worktreePath, candidates) {
489
+ try {
490
+ if (!candidates || candidates.length === 0) return [];
491
+ // 主仓 HEAD 树 blob map:路径不在 HEAD 树 → 不在 Map → 主仓无该路径的新文件天然非 no-op
492
+ const mainBlobs = getBlobHashMap(projectRoot, 'HEAD', candidates);
493
+ if (mainBlobs.size === 0) return [];
494
+ const pathKey = (p) => {
495
+ let s = String(p);
496
+ if (s.startsWith('"') && s.endsWith('"') && s.length >= 2) s = unquoteGitPath(s.slice(1, -1));
497
+ return s.replace(/\\/g, '/');
498
+ };
499
+ const mainBlobByKey = new Map();
500
+ for (const [p, h] of mainBlobs) mainBlobByKey.set(pathKey(p), h);
501
+ const present = candidates.filter(f => mainBlobByKey.has(pathKey(f)));
502
+ if (present.length === 0) return [];
503
+
504
+ // worktree 工作区 blob:hash-object 按 argv 顺序逐行输出;某批失败(文件被并发删/权限等)
505
+ // → 占位空串填充保批次间索引对齐(空串永不等于 blob hash → 该批各文件保守不剔)
506
+ const wtHashes = [];
507
+ for (const batch of chunkPaths(present)) {
508
+ const out = gitQuiet(worktreePath, ['hash-object', '--', ...batch], { timeout: 30000 });
509
+ const lines = out === null ? null : out.split('\n');
510
+ if (!lines || lines.length !== batch.length) {
511
+ console.warn(`⚠️ no-op 检测:hash-object 批失败/行数不齐,该批 ${batch.length} 个文件保守保留在 changedFiles`);
512
+ for (let i = 0; i < batch.length; i++) wtHashes.push('');
513
+ continue;
514
+ }
515
+ wtHashes.push(...lines);
516
+ }
517
+ const noopFiles = [];
518
+ for (let i = 0; i < present.length; i++) {
519
+ if (wtHashes[i] && wtHashes[i] === mainBlobByKey.get(pathKey(present[i]))) noopFiles.push(present[i]);
520
+ }
521
+ return noopFiles;
522
+ } catch (e) {
523
+ // 意外异常整体保守不剔(fail-safe:宁可误报 BLOCKED,不可误放行剔除)
524
+ console.warn(`⚠️ no-op 文件检测失败(保守保留 changedFiles):${(e.message || '').split('\n')[0]}`);
525
+ return [];
526
+ }
527
+ }
528
+
427
529
  /**
428
530
  * apply 的文件清单 = design §6 清单 ∪ plan TaskCard allowed_paths(execute 复盘 c)。
429
531
  *
@@ -879,7 +981,14 @@ function applyCrossRepoWorktrees(changeName, projectRoot, ctx, { checkOnly = fal
879
981
  }
880
982
  }
881
983
  const untrackedRaw = gitQuiet(cm.worktreePath, ['ls-files', '--others', '--exclude-standard']) || '';
882
- changedFiles = filterDeliverableFiles([...new Set([...statusFiles, ...untrackedRaw.split('\n').filter(Boolean)])]);
984
+ const allFiles = [...new Set([...statusFiles, ...untrackedRaw.split('\n').filter(Boolean)])];
985
+ changedFiles = filterDeliverableFiles(allFiles);
986
+ // 硬排可见性(scope-audit-cross-repo-blindness 改进点 1):被滤除的平台设施文件列
987
+ // warning 不静默——apply 不回放它们;若某文件确为交付物(极少),人工落主仓。
988
+ const dropped = allFiles.filter(f => !changedFiles.includes(f) && classifyToolScaffold(f) === 'platform');
989
+ if (dropped.length > 0) {
990
+ out.warnings.push(`跨仓 ${repoKey}:${dropped.length} 个工具/平台设施文件未纳入 apply(${dropped.slice(0, 5).join(', ')}${dropped.length > 5 ? ' 等' : ''})——非交付物不回放,确需交付请人工处理`);
991
+ }
883
992
  } catch (e) {
884
993
  out.errors.push(`跨仓 ${repoKey}:获取变更文件列表失败(${cm.worktreePath}): ${e.message}`);
885
994
  continue;
@@ -1159,7 +1268,41 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
1159
1268
  // 排除 worktree 基础设施文件(meta.json / .sillyspec/,见 filterDeliverableFiles)。
1160
1269
  // 对 modified-tracked(statusFiles)与 untracked 一视同仁——否则 modified 的 meta.json
1161
1270
  // 会被误算入 changedFiles,触发 design.md 清单校验失败(assess 恒 BLOCKED)。
1162
- changedFiles = filterDeliverableFiles([...new Set([...statusFiles, ...untrackedFiles])]);
1271
+ const allChangedRaw = [...new Set([...statusFiles, ...untrackedFiles])];
1272
+ changedFiles = filterDeliverableFiles(allChangedRaw);
1273
+
1274
+ // no-op 文件剔除(FR-02 / D-002@v1):主仓 HEAD 在 execute 期间前进后,worktree 内
1275
+ // 「内容=主仓 HEAD」的自救/重同步文件对 deliverableBase diff 非空,apply 回主仓实为
1276
+ // no-op,却被 assess 误判「变更文件超出 allowed_paths」BLOCKED。候选收窄:allChangedRaw
1277
+ // 中 worktree 工作区存在者(existsSync,删除类天然出局)∩ 主仓 HEAD 树存在者(helper
1278
+ // 内反查);判定失败保守不剔。diff-size/Gate 判定消费的 changedFiles 自然跟随收窄,
1279
+ // patch 面(resolvePatchFiles ⊆ changedFiles)同步不再回放该类文件。
1280
+ const noopCandidates = allChangedRaw.filter(f => existsSync(join(worktreePath, f)));
1281
+ const noopFiles = noopCandidates.length > 0 ? detectNoOpFiles(projectRoot, worktreePath, noopCandidates) : [];
1282
+ // 只报实际从三集剔出的文件(候选含 filterDeliverableFiles 已硬排的平台设施时,
1283
+ // 那部分本就不在 changedFiles,不计入告警面)
1284
+ const changedSetForNoop = new Set([...changedFiles, ...deletedFiles, ...absentAfterMerge]);
1285
+ const removedNoopFiles = noopFiles.filter(f => changedSetForNoop.has(f));
1286
+ if (removedNoopFiles.length > 0) {
1287
+ const noopSet = new Set(removedNoopFiles);
1288
+ changedFiles = changedFiles.filter(f => !noopSet.has(f));
1289
+ // 防御性剔除(候选已限工作区存在,正常不命中后两集;过滤保三集不变式)
1290
+ for (let i = deletedFiles.length - 1; i >= 0; i--) if (noopSet.has(deletedFiles[i])) deletedFiles.splice(i, 1);
1291
+ for (let i = absentAfterMerge.length - 1; i >= 0; i--) if (noopSet.has(absentAfterMerge[i])) absentAfterMerge.splice(i, 1);
1292
+ result.warnings = result.warnings || [];
1293
+ result.warnings.push(
1294
+ `${removedNoopFiles.length} 个文件内容与主仓 HEAD 一致(apply 为 no-op,不计入 changedFiles):${removedNoopFiles.slice(0, 5).join(', ')}${removedNoopFiles.length > 5 ? ' 等' : ''}`
1295
+ );
1296
+ }
1297
+
1298
+ // 硬排可见性(scope-audit-cross-repo-blindness 改进点 1):平台设施被滤出不静默——
1299
+ // apply 不回放它们(.worktrees//.sillyspec-platform*/knowledge/local.yaml);确为交付物
1300
+ // 的极少数场景(如刻意改 knowledge 模板)人工落主仓。
1301
+ const droppedScaffold = allChangedRaw.filter(f => !changedFiles.includes(f) && classifyToolScaffold(f) === 'platform');
1302
+ if (droppedScaffold.length > 0) {
1303
+ result.warnings = result.warnings || [];
1304
+ result.warnings.push(`${droppedScaffold.length} 个工具/平台设施文件未纳入 apply(${droppedScaffold.slice(0, 5).join(', ')}${droppedScaffold.length > 5 ? ' 等' : ''})——非交付物不回放,确需交付请人工处理`);
1305
+ }
1163
1306
  } catch (e) {
1164
1307
  result.errors.push(`获取变更文件列表失败: ${e.message}`);
1165
1308
  return result;
@@ -616,10 +616,33 @@ export function provisionDeps(worktreePath, mainCwd, opts = {}) {
616
616
  // generic monorepo(如 multi-agent-platform)worktree 子模块 frontend/sillyhub-daemon 无 node_modules,
617
617
  // 跑 pnpm test 失败。读 local.yaml modules 块,对 nodejs 子模块 tryLink main 的 node_modules。
618
618
  const modulePaths = extractModulePaths(yamlText);
619
+ // 注册仓根集合(坑 deps-sibling-repo-false-reject,2026-09-15 EHS 生产实证:modules 里登记的
620
+ // `../sub-grid-security` 是跨仓测试命令的合法配置,被「path 越界拒绝 link」当攻击面误报——该
621
+ // 目录本来就不在 worktree 内、无 main/worktree 双份语义,node_modules link 天然不适用,正确
622
+ // 动作是分类跳过+准确理由。未注册的越界路径维持 fail-closed 拒绝)。轻量解析(不引
623
+ // plan-postcheck 依赖边,条目形状 <key>: <path> 与 parseRepoRegistry 对齐)。
624
+ const registeredRepoRoots = (() => {
625
+ const roots = [];
626
+ const lines = String(yamlText || '').replace(/\r\n?/g, '\n').split('\n');
627
+ const start = lines.findIndex(l => /^repos:\s*(?:#.*)?$/.test(l));
628
+ if (start === -1) return roots;
629
+ for (let i = start + 1; i < lines.length; i++) {
630
+ if (lines[i].length > 0 && !/\s/.test(lines[i][0]) && lines[i].trim() !== '' && !lines[i].startsWith('#')) break;
631
+ const m = lines[i].match(/^\s+([A-Za-z0-9_.\-]+):\s*(\S+)/);
632
+ if (m && !m[2].startsWith('#')) roots.push(m[2].replace(/^['"]|['"]$/g, ''));
633
+ }
634
+ return roots;
635
+ })();
619
636
  const moduleResults = [];
620
637
  for (const mp of modulePaths) {
621
638
  if (!isSafeModulePath(mp)) {
622
- moduleResults.push({ path: mp, status: 'skipped', reason: 'path 越界(绝对路径或 .. 段),拒绝 link' });
639
+ const resolvedMp = resolvePath(worktreePath, mp);
640
+ const hitRepo = registeredRepoRoots.some(rRoot => {
641
+ try { return resolvePath(mainCwd || worktreePath, rRoot) === resolvedMp } catch { return false }
642
+ });
643
+ moduleResults.push({ path: mp, status: 'skipped', reason: hitRepo
644
+ ? '跨仓注册仓路径(repos 已注册)——不在 worktree 内,node_modules link 不适用;其测试命令经 modules.<name>.test 由 verify 直接在该仓执行'
645
+ : 'path 越界(绝对路径或 .. 段),拒绝 link' });
623
646
  continue;
624
647
  }
625
648
  const wtDir = join(worktreePath, mp);
package/src/worktree.js CHANGED
@@ -16,7 +16,10 @@ import { provisionDeps, checkDepsFreshness, detectEditableInstallEscape, sweepFo
16
16
  import { resolveRuntimeRoot } from './run/shared.js';
17
17
  import { writeAtomicSync } from './fs-atomic.js';
18
18
  import { openDatabase } from './db-engine.js';
19
- import { git, gitQuiet } from './git-helper.js';
19
+ import { git, gitQuiet, unquoteGitPath } from './git-helper.js';
20
+ // foreign-declared 零环独立模块(仅依赖 change-list/git-helper/stages/plan-postcheck,
21
+ // 链上无回边)——contract-matrix/verify-probes/task-review 等处已有同款 import 先例
22
+ import { splitOwnVsForeignDiffFiles } from './foreign-declared.js';
20
23
 
21
24
  // meta.json 会被 hook 进程与其它 CLI 进程并发读取(worktree-guard / getMeta / create 幽灵判定),
22
25
  // 必须原子写:半截 JSON 会让 getMeta 返回 null → 触发幽灵 worktree 强删(可能丢 gitignored 改动)。
@@ -44,6 +47,85 @@ function _chunkPathsPrivate(paths, maxChars = 8000) {
44
47
  return batches
45
48
  }
46
49
 
50
+ // ── supplyFiles 生成物供给(坑③ / FR-03 / D-003@v1)──
51
+ // .gitignore 生成物(如 src/build-id.ts)不在 git 树也不进 untracked overlay
52
+ // (ls-files --others --exclude-standard 尊重 .gitignore),worktree 缺失致构建炸
53
+ // Failed to load url——create step 5.9 按 local.yaml worktree.supplyFiles 从主仓复制供给。
54
+ const SUPPLY_FILES_MAX = 200; // R-04 展开上限帽:超出警告并截断(防 dist/** 类误配展开风暴)
55
+ const SUPPLY_WALK_MAX_DEPTH = 6; // glob 枚举递归深度帽(跳过 node_modules/.git 后的务实兜底)
56
+
57
+ /**
58
+ * supplyFiles glob → 锚定全路径的 RegExp(自实现,零新依赖——D-003@v1)。
59
+ * 分隔符统一 /;正则元字符先转义,再还原通配语义:** → .*(跨层)、* → [^/]*(单层)。
60
+ * @param {string} pattern 相对仓根的 glob(如 src/build-*.ts、dist/** 、gen 目录多层 id.ts)
61
+ * @returns {RegExp} 锚定(^…$)的全路径匹配
62
+ */
63
+ function supplyGlobToRegExp(pattern) {
64
+ const norm = String(pattern).replace(/\\/g, '/');
65
+ let re = '^';
66
+ for (let i = 0; i < norm.length; i++) {
67
+ const ch = norm[i];
68
+ if (ch === '*' && norm[i + 1] === '*') { re += '.*'; i++; }
69
+ else if (ch === '*') re += '[^/]*';
70
+ else re += ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
71
+ }
72
+ return new RegExp(re + '$');
73
+ }
74
+
75
+ /**
76
+ * 读主仓 .sillyspec/local.yaml 的 worktree.supplyFiles(string[])。
77
+ * 沿 hooks/worktree-guard.js parseSimpleYaml 同款轻量手写解析(本文件不引新 reader):
78
+ * 只取这一个白名单键路径(worktree: 段 → supplyFiles: 子键 → 缩进块列表 / inline flow 数组),
79
+ * 其余段一概不碰(无原型污染面)。文件缺失/解析异常/形态不符 → [](供给步空转,fail-open)。
80
+ * @param {string} mainCwd 主仓根
81
+ * @returns {string[]} 供给清单(配置原文,未展开)
82
+ */
83
+ function readSupplyFilesConfig(mainCwd) {
84
+ let content;
85
+ try {
86
+ content = readFileSync(join(mainCwd, '.sillyspec', 'local.yaml'), 'utf8');
87
+ } catch {
88
+ return [];
89
+ }
90
+ const items = [];
91
+ const stripQuotes = (v) => (
92
+ v.length >= 2 && ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) ? v.slice(1, -1) : v
93
+ );
94
+ let inWorktree = false;
95
+ let inList = false;
96
+ for (const raw of content.split('\n')) {
97
+ const noComment = raw.replace(/\s+#.*$/, ''); // 尾注释剥离(与 worktree-guard 同口径)
98
+ const trimmed = noComment.trim();
99
+ if (!trimmed || trimmed.startsWith('#')) continue;
100
+ const indent = noComment.length - noComment.trimStart().length;
101
+ if (indent === 0) {
102
+ inWorktree = /^worktree:\s*$/.test(trimmed); // worktree-hook: 等其他段不误入
103
+ inList = false;
104
+ continue;
105
+ }
106
+ if (!inWorktree) continue;
107
+ if (indent === 2) {
108
+ const m = trimmed.match(/^supplyFiles:\s*(.*)$/);
109
+ if (!m) { inList = false; continue; }
110
+ inList = true;
111
+ const rest = m[1].trim();
112
+ if (rest.startsWith('[') && rest.endsWith(']')) {
113
+ // inline flow 数组(supplyFiles: [a, b])——块列表之外的另一种常见写法,一并兼容
114
+ for (const part of rest.slice(1, -1).split(',')) {
115
+ const v = stripQuotes(part.trim());
116
+ if (v) items.push(v);
117
+ }
118
+ }
119
+ continue;
120
+ }
121
+ if (inList && trimmed.startsWith('- ')) {
122
+ const v = stripQuotes(trimmed.slice(2).trim());
123
+ if (v) items.push(v);
124
+ }
125
+ }
126
+ return items;
127
+ }
128
+
47
129
  /**
48
130
  * 解除 worktree 内全部 node_modules junction/symlink(根 + meta.depsModules 各子模块)。
49
131
  *
@@ -722,7 +804,8 @@ export class WorktreeManager {
722
804
  printSyncReport(syncDiagnostic, baseHash, name);
723
805
 
724
806
  // 5.6 Dirty baseline overlay:将主工作区未提交变更同步到 worktree
725
- const baselineResult = this._overlayBaseline(this.cwd, worktreePath);
807
+ // (传变更名启用并行会话声明隔离——他者在途文件不进本变更 baseline,D-001@v1)
808
+ const baselineResult = this._overlayBaseline(this.cwd, worktreePath, name);
726
809
  const baselineFiles = baselineResult.files;
727
810
  const baselineHash = baselineResult.baselineHash;
728
811
 
@@ -746,6 +829,18 @@ export class WorktreeManager {
746
829
  deps = { depsStatus: 'failed', depsError: `provisionDeps crashed: ${e.message}` };
747
830
  }
748
831
 
832
+ // 5.9 生成物供给(坑③ / FR-03 / D-003@v1):.gitignore 生成物不在 git 树也不进 untracked
833
+ // overlay(ls-files --exclude-standard 遵循 .gitignore),worktree 缺失致构建炸
834
+ // Failed to load url——按 local.yaml worktree.supplyFiles 从主仓复制供给,实供清单记
835
+ // meta.supplyFiles。未配置时供给步空转零行为变化;供给物天然不进 assess/apply 面。
836
+ let suppliedFiles = [];
837
+ try {
838
+ suppliedFiles = this._supplyGeneratedFiles(worktreePath);
839
+ } catch (e) {
840
+ // fail-open:供给步整体异常只警告,绝不阻断 create(R-04 约束)
841
+ console.warn(`⚠️ worktree supplyFiles 供给步异常(fail-open 不阻断 create):${e.message}`);
842
+ }
843
+
749
844
  // 6. 写入 meta.json
750
845
  const meta = {
751
846
  name_zh: 'worktree 元数据',
@@ -767,6 +862,8 @@ export class WorktreeManager {
767
862
  depsLockHash: deps.depsLockHash || null,
768
863
  depsCheckedAt: deps.depsCheckedAt || null,
769
864
  ...(deps.depsError ? { depsError: deps.depsError } : {}),
865
+ // 5.9 实供清单(纯记录面,doctor/审计可读;空供给省略字段——存量 meta 缺省兼容)
866
+ ...(suppliedFiles.length > 0 ? { supplyFiles: suppliedFiles } : {}),
770
867
  ...(adoptedBranch ? { adoptedBranch: true } : {}),
771
868
  };
772
869
 
@@ -865,7 +962,7 @@ export class WorktreeManager {
865
962
  baseHash = git(this.cwd, ['rev-parse', 'HEAD']);
866
963
  }
867
964
 
868
- const baselineResult = this._overlayBaseline(this.cwd, this.cwd);
965
+ const baselineResult = this._overlayBaseline(this.cwd, this.cwd, name);
869
966
  const baselineFiles = baselineResult.files;
870
967
  const baselineHash = baselineResult.baselineHash;
871
968
 
@@ -904,6 +1001,117 @@ export class WorktreeManager {
904
1001
  return { branch: meta.branch, worktreePath, baseHash, mode: meta.mode };
905
1002
  }
906
1003
 
1004
+ /**
1005
+ * 生成物供给步(create step 5.9,坑③ / FR-03 / D-003@v1)。
1006
+ *
1007
+ * 读主仓 local.yaml 的 worktree.supplyFiles(string[],精确路径 + glob * 单层 / ** 多层,
1008
+ * 相对仓根),主仓侧展开枚举后逐文件复制进 worktree(mkdir 父目录 + copyFileSync)。
1009
+ * 约束:
1010
+ * - 未配置 / 零匹配:完全静默空转(零行为变化);glob 零命中按缺失警告(不阻断)。
1011
+ * - 展开上限帽 SUPPLY_FILES_MAX=200:超出警告并截断(R-04,防 dist/** 误配展开风暴)。
1012
+ * - 单文件复制异常:warn 后继续,绝不阻断 create(fail-open,不回滚已复制文件)。
1013
+ * - 目标已存在且内容不同:警告后覆盖(baseline overlay / 已检出版本可能已在场)。
1014
+ * - 枚举跳过 node_modules/.git,递归深度帽 SUPPLY_WALK_MAX_DEPTH=6;匹配源只列文件
1015
+ * (statSync 跟随 junction/symlink,与供给语义一致)。
1016
+ * - 供给物是 gitignore 物,天然不进 assess/apply 面(ls-files --exclude-standard),无需过滤。
1017
+ *
1018
+ * @private
1019
+ * @param {string} worktreePath worktree 根目录(源恒为主仓 this.cwd)
1020
+ * @returns {string[]} 实供清单(POSIX 相对路径,写入 meta.supplyFiles)
1021
+ */
1022
+ _supplyGeneratedFiles(worktreePath) {
1023
+ const patterns = readSupplyFilesConfig(this.cwd);
1024
+ if (!Array.isArray(patterns) || patterns.length === 0) return [];
1025
+ const mainCwd = this.cwd;
1026
+ const matches = []; // 展开命中(去重后,跨配置项合并)
1027
+ const missing = []; // 精确路径缺失 / glob 零命中 / 越界形态
1028
+ const seen = new Set();
1029
+ for (const raw of patterns) {
1030
+ if (typeof raw !== 'string' || !raw.trim()) continue;
1031
+ const pattern = raw.trim().replace(/\\/g, '/').replace(/^\.\//, '');
1032
+ // 越界形态(绝对路径 / 盘符 / .. 段)不供给——供给面必须落在主仓根内
1033
+ if (!pattern || pattern.startsWith('/') || /^[a-zA-Z]:/.test(pattern) || pattern.split('/').includes('..')) {
1034
+ missing.push(pattern || JSON.stringify(raw));
1035
+ continue;
1036
+ }
1037
+ if (!pattern.includes('*')) {
1038
+ // 精确路径:直接查主仓存在性(只列文件)
1039
+ let isFile = false;
1040
+ try { isFile = statSync(join(mainCwd, pattern)).isFile(); } catch {}
1041
+ if (isFile) {
1042
+ if (!seen.has(pattern)) { seen.add(pattern); matches.push(pattern); }
1043
+ } else {
1044
+ missing.push(pattern);
1045
+ }
1046
+ continue;
1047
+ }
1048
+ // glob:静态前缀(首个含 * 段之前)作枚举根,正则锚定全路径匹配。
1049
+ // 深度帽:** 限静态根下 SUPPLY_WALK_MAX_DEPTH 层;纯 * 模式路径段数固定,超段即停。
1050
+ const segs = pattern.split('/');
1051
+ let baseLen = 0;
1052
+ while (baseLen < segs.length && !segs[baseLen].includes('*')) baseLen++;
1053
+ const baseRel = segs.slice(0, baseLen).join('/');
1054
+ const re = supplyGlobToRegExp(pattern);
1055
+ const depthCap = pattern.includes('**') ? baseLen + SUPPLY_WALK_MAX_DEPTH : segs.length;
1056
+ const found = [];
1057
+ const walk = (absDir, relDir, segCount) => {
1058
+ if (segCount >= depthCap) return;
1059
+ let entries;
1060
+ try { entries = readdirSync(absDir, { withFileTypes: true }); } catch { return; }
1061
+ for (const e of entries) {
1062
+ if (e.name === 'node_modules' || e.name === '.git') continue;
1063
+ const rel = relDir ? `${relDir}/${e.name}` : e.name;
1064
+ if (e.isDirectory()) {
1065
+ walk(join(absDir, e.name), rel, segCount + 1);
1066
+ continue;
1067
+ }
1068
+ let isFile = false;
1069
+ try { isFile = statSync(join(absDir, e.name)).isFile(); } catch {}
1070
+ if (isFile && re.test(rel)) found.push(rel);
1071
+ }
1072
+ };
1073
+ walk(join(mainCwd, baseRel), baseRel, baseLen);
1074
+ if (found.length === 0) {
1075
+ missing.push(pattern); // glob 零命中同缺失口径(GWT-2)
1076
+ continue;
1077
+ }
1078
+ for (const rel of found) {
1079
+ if (!seen.has(rel)) { seen.add(rel); matches.push(rel); }
1080
+ }
1081
+ }
1082
+ if (missing.length > 0) {
1083
+ console.warn(`⚠️ worktree supplyFiles 主仓侧未命中 ${missing.length} 项(不阻断 create,按需核对清单):${missing.join('、')}`);
1084
+ }
1085
+ // R-04 展开上限帽:排序保截断稳定可复现
1086
+ matches.sort();
1087
+ const capped = matches.slice(0, SUPPLY_FILES_MAX);
1088
+ if (matches.length > SUPPLY_FILES_MAX) {
1089
+ console.warn(`⚠️ worktree supplyFiles 展开命中 ${matches.length} 文件超上限 ${SUPPLY_FILES_MAX},已截断只供给前 ${SUPPLY_FILES_MAX}(检查 glob 是否误配,如 dist/** 全量展开)`);
1090
+ }
1091
+ const supplied = [];
1092
+ for (const rel of capped) {
1093
+ const srcAbs = join(mainCwd, rel);
1094
+ const dstAbs = join(worktreePath, rel);
1095
+ try {
1096
+ if (existsSync(dstAbs)) {
1097
+ // 目标已存在且内容不同 → 警告后覆盖(内容比对失败不拦复制)
1098
+ try {
1099
+ if (!readFileSync(dstAbs).equals(readFileSync(srcAbs))) {
1100
+ console.warn(`⚠️ worktree supplyFiles 覆盖 worktree 内已存在且内容不同的文件:${rel}`);
1101
+ }
1102
+ } catch {}
1103
+ }
1104
+ mkdirSync(dirname(dstAbs), { recursive: true });
1105
+ copyFileSync(srcAbs, dstAbs);
1106
+ supplied.push(rel);
1107
+ } catch (e) {
1108
+ // fail-open:单文件复制异常 warn 继续,不回滚已复制文件(R-04 约束)
1109
+ console.warn(`⚠️ worktree supplyFiles 复制失败(跳过继续):${rel} — ${e.message}`);
1110
+ }
1111
+ }
1112
+ return supplied;
1113
+ }
1114
+
907
1115
  /**
908
1116
  * 构建 isolation 信息对象,用于写入 sillyspec.db(_writeIsolationToDB,isolation_status 列)
909
1117
  * @param {string} changeName
@@ -1899,11 +2107,18 @@ export class WorktreeManager {
1899
2107
  * baseline,apply 回 main 即随本变更交付,需人工隔离。spec 文档不参与 worktree 内
1900
2108
  * 构建/测试(execute 的 spec 读写经 specDriftAnchor 锚回主仓),排除零功能损失;
1901
2109
  * 排除清单显式打印保持可见(哪些跨变更文件被隔离、去哪了)。
2110
+ *
2111
+ * 并行会话声明隔离(坑 execute-baseline-overlay-carries-broken-parallel-wip,D-001@v1):
2112
+ * changeName 非空时,他者**显式声明**(他 quick 会话 guard.allowedFiles / 他变更 design
2113
+ * §6,own 优先)的在途文件三道剔除——staged/unstaged patch 道 :(exclude) pathspec 收窄
2114
+ * (worktree 保持基线 HEAD 版本)、untracked 道不复制;隔离清单(文件←归属者)显式打印
2115
+ * 一行。oracle 异常 fail-open 退回现状全量 overlay,绝不让 create 抛错。
1902
2116
  * @param {string} mainCwd - 主工作区路径
1903
2117
  * @param {string} worktreePath - worktree 路径
2118
+ * @param {string} [changeName] - 本变更名;缺省/空串不切分(存量两参直调路径零回归)
1904
2119
  * @returns {Array<string>} overlay 的文件列表
1905
2120
  */
1906
- _overlayBaseline(mainCwd, worktreePath) {
2121
+ _overlayBaseline(mainCwd, worktreePath, changeName = null) {
1907
2122
  const files = [];
1908
2123
  const errors = [];
1909
2124
  // git pathspec 排除(execFileSync 数组传参不经 shell,字面安全):.sillyspec/ 整目录
@@ -1914,18 +2129,53 @@ export class WorktreeManager {
1914
2129
  if (norm === '.sillyspec' || norm.startsWith('.sillyspec/')) excluded.push(f)
1915
2130
  }
1916
2131
  }
2132
+ // quotepath 归一:diff --name-only / ls-files 输出的非 ASCII 路径带双引号包裹,
2133
+ // 与声明集比对、生成 pathspec 排除串前先按 unquoteGitPath 口径剥引号再反斜杠→正斜杠
2134
+ const normalizeGitPath = (raw) => {
2135
+ let p = raw;
2136
+ if (p.length >= 2 && p.startsWith('"') && p.endsWith('"')) p = unquoteGitPath(p.slice(1, -1));
2137
+ return p.replace(/\\/g, '/');
2138
+ }
2139
+ const isSpecPath = (n) => n === '.sillyspec' || n.startsWith('.sillyspec/');
2140
+ // 他者声明文件的每道切分(staged/unstaged/untracked 各调一次,非逐文件):
2141
+ // 对该道全量输出的非 .sillyspec 集跑 own/foreign oracle,foreign → pathspec 排除串 +
2142
+ // Map(路径→owners[])+ 隔离明细;oracle 抛错 fail-open 退回该道全量(不阻断 create)
2143
+ const foreignIsolated = []; // 「文件←归属者」明细(三道汇总,尾部去重打印)
2144
+ const splitForeignLane = (rawAll) => {
2145
+ const excludeSpecs = [];
2146
+ const foreignSet = new Map();
2147
+ if (!changeName) return { excludeSpecs, foreignSet };
2148
+ try {
2149
+ const candidates = rawAll.split('\n').filter(Boolean).map(normalizeGitPath).filter(n => !isSpecPath(n));
2150
+ if (candidates.length === 0) return { excludeSpecs, foreignSet };
2151
+ const { foreign } = splitOwnVsForeignDiffFiles(mainCwd, changeName, candidates);
2152
+ for (const e of foreign) {
2153
+ foreignSet.set(e.file, e.owners);
2154
+ // 剥引号后的实际路径入 pathspec(git() 数组传参字面安全,与 :(exclude).sillyspec 同机制)
2155
+ excludeSpecs.push(':(exclude)' + e.file);
2156
+ foreignIsolated.push(`${e.file}←${e.owners.join(',')}`);
2157
+ }
2158
+ } catch (e) {
2159
+ console.warn(`⚠️ baseline overlay 并行会话声明切分失败(${e.message}),本道退回全量 overlay`);
2160
+ }
2161
+ return { excludeSpecs, foreignSet };
2162
+ }
1917
2163
 
1918
2164
  try {
1919
2165
  const excludedSpecFiles = [];
1920
- // staged 变更(pathspec 排除 .sillyspec/——跨变更 spec 文档不进 baseline)
1921
- const staged = gitQuiet(mainCwd, ['diff', '--cached', '--name-only', '--', '.', EXCLUDE_PATHSPEC], { timeout: 30000 }) || ''
2166
+ // staged 变更(pathspec 排除 .sillyspec/——跨变更 spec 文档不进 baseline;
2167
+ // 他者声明文件切分后一并排除,--name-only 与 --binary 两组 diff 命令同步收窄,
2168
+ // files 清单只收 own——staged 输出即已剔除 foreign 的剩余部分)
1922
2169
  const stagedAll = gitQuiet(mainCwd, ['diff', '--cached', '--name-only'], { timeout: 30000 }) || ''
1923
2170
  collectExcluded(stagedAll, excludedSpecFiles)
2171
+ const stagedForeign = splitForeignLane(stagedAll)
2172
+ const stagedPathspec = ['--', '.', EXCLUDE_PATHSPEC, ...stagedForeign.excludeSpecs]
2173
+ const staged = gitQuiet(mainCwd, ['diff', '--cached', '--name-only', ...stagedPathspec], { timeout: 30000 }) || ''
1924
2174
  if (staged) {
1925
2175
  try {
1926
2176
  // 用 Buffer 模式读取,避免二进制 patch 被 UTF-8 解码损坏
1927
2177
  // QUAL-01 收口:git-helper 新增 encoding:'buffer' 支持(二进制输出专用,跳过 trim)
1928
- const patchBuf = git(mainCwd, ['diff', '--cached', '--binary', '--', '.', EXCLUDE_PATHSPEC], { encoding: 'buffer', timeout: 30000 });
2178
+ const patchBuf = git(mainCwd, ['diff', '--cached', '--binary', ...stagedPathspec], { encoding: 'buffer', timeout: 30000 });
1929
2179
  if (patchBuf && patchBuf.length > 0) {
1930
2180
  const patchFile = join(worktreePath, '.sillyspec-baseline-staged.patch');
1931
2181
  try {
@@ -1943,14 +2193,16 @@ export class WorktreeManager {
1943
2193
  files.push(...staged.split('\n').filter(Boolean));
1944
2194
  }
1945
2195
 
1946
- // unstaged 变更(同上 pathspec 排除)
1947
- const unstaged = gitQuiet(mainCwd, ['diff', '--name-only', '--', '.', EXCLUDE_PATHSPEC], { timeout: 30000 }) || ''
2196
+ // unstaged 变更(同上 pathspec 排除 + 他者声明剔除;--binary 不带 --cached)
1948
2197
  const unstagedAll = gitQuiet(mainCwd, ['diff', '--name-only'], { timeout: 30000 }) || ''
1949
2198
  collectExcluded(unstagedAll, excludedSpecFiles)
2199
+ const unstagedForeign = splitForeignLane(unstagedAll)
2200
+ const unstagedPathspec = ['--', '.', EXCLUDE_PATHSPEC, ...unstagedForeign.excludeSpecs]
2201
+ const unstaged = gitQuiet(mainCwd, ['diff', '--name-only', ...unstagedPathspec], { timeout: 30000 }) || ''
1950
2202
  if (unstaged) {
1951
2203
  try {
1952
2204
  // 用 Buffer 模式读取,避免二进制 patch 被 UTF-8 解码损坏(QUAL-01 收口,同上)
1953
- const patchBuf = git(mainCwd, ['diff', '--binary', '--', '.', EXCLUDE_PATHSPEC], { encoding: 'buffer', timeout: 30000 });
2205
+ const patchBuf = git(mainCwd, ['diff', '--binary', ...unstagedPathspec], { encoding: 'buffer', timeout: 30000 });
1954
2206
  if (patchBuf && patchBuf.length > 0) {
1955
2207
  const patchFile = join(worktreePath, '.sillyspec-baseline-unstaged.patch');
1956
2208
  try {
@@ -1971,6 +2223,8 @@ export class WorktreeManager {
1971
2223
  const untracked = gitQuiet(mainCwd, ['ls-files', '--others', '--exclude-standard'], { timeout: 30000 }) || '';
1972
2224
  const skippedDirs = [];
1973
2225
  const skippedArtifacts = [];
2226
+ // untracked 道 foreign 切分(一次全量非 .sillyspec 集,循环内仅 Map 查询)
2227
+ const untrackedForeign = splitForeignLane(untracked)
1974
2228
  if (untracked) {
1975
2229
  for (const f of untracked.split('\n').filter(Boolean)) {
1976
2230
  const norm = f.replace(/\\/g, '/')
@@ -1979,6 +2233,9 @@ export class WorktreeManager {
1979
2233
  // baseline checkpoint——289MB tar.gz 入分支(FF 破坏 + 二进制永久入 git)的实际代价兜底
1980
2234
  const artifactWhy = isCheckpointSkippableArtifact(join(mainCwd, f));
1981
2235
  if (artifactWhy) { skippedArtifacts.push(`${f}(${artifactWhy})`); continue }
2236
+ // 他者会话声明的在途文件不复制(与 skipped-dir/artifact 同型跳过;明细已在切分时
2237
+ // 计入隔离清单)——quotepath 引号路径先剥引号归一再比对
2238
+ if (untrackedForeign.foreignSet.has(normalizeGitPath(f))) continue;
1982
2239
  const r = copyUntrackedEntry(join(mainCwd, f), join(worktreePath, f));
1983
2240
  if (r.status === 'copied') files.push(f);
1984
2241
  else if (r.status === 'skipped-dir') skippedDirs.push(f);
@@ -1999,6 +2256,13 @@ export class WorktreeManager {
1999
2256
  const preview = excludedUnique.slice(0, 8).join(', ') + (excludedUnique.length > 8 ? ` …(共 ${excludedUnique.length} 个)` : '')
2000
2257
  console.log(`🧹 baseline overlay 已隔离 ${excludedUnique.length} 个 .sillyspec/ 未提交文件(跨变更 spec 文档不进本变更 baseline,留在主仓各自归属):${preview}`)
2001
2258
  }
2259
+ // 并行会话声明隔离可见性:foreign 文件去重截断一行(对齐上方 .sillyspec/ 隔离打印
2260
+ // 样式)——哪些在途文件被隔离、归属谁,create 时刻可查,不需事后考古
2261
+ const foreignUnique = [...new Set(foreignIsolated)]
2262
+ if (foreignUnique.length > 0) {
2263
+ const preview = foreignUnique.slice(0, 8).join(', ') + (foreignUnique.length > 8 ? ` …(共 ${foreignUnique.length} 个)` : '')
2264
+ console.log(`🧹 baseline overlay 已隔离 ${foreignUnique.length} 个并行会话声明的在途文件(worktree 取基线 HEAD 版本,不随本变更 apply):${preview}`)
2265
+ }
2002
2266
 
2003
2267
  if (files.length > 0) {
2004
2268
  console.log(`📁 baseline overlay: ${files.length} 个未提交文件已同步到 worktree`);