sillyspec 3.32.3 → 3.32.5

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.
@@ -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];
@@ -27,56 +27,117 @@
27
27
  */
28
28
 
29
29
  import { existsSync, readFileSync, readdirSync, mkdirSync } from 'fs';
30
- import { join } from 'path';
30
+ import { join, dirname, resolve as resolvePath, isAbsolute, relative as relativePath } from 'path';
31
31
  import { git, gitQuiet } from './git-helper.js';
32
32
  import { writeAtomicSync } from './fs-atomic.js';
33
33
  import { WorktreeManager, unlinkNodeModulesLinks, safeRemoveWorktreeDir } from './worktree.js';
34
34
  import { provisionDeps } from './worktree-deps.js';
35
35
  import { aggregateDeclaredRepos } from './run/shared.js';
36
- import { parseRepoRegistry } from './stages/plan-postcheck.js';
36
+ import { parseRepoRegistry, parseRepoWorktreePlacements } from './stages/plan-postcheck.js';
37
+ import {
38
+ crossWorktreePath as _placementCrossPath,
39
+ crossWorktreeDirName,
40
+ resolveCrossWorktreePath,
41
+ resolveCrossWorktreePathCandidates,
42
+ readCrossPlacementConfig,
43
+ resolvePlacementRoot,
44
+ readPlacementRegistry,
45
+ updatePlacementRegistry,
46
+ removePlacementRegistryEntry,
47
+ sweepPlacementRegistry,
48
+ isWslSplit,
49
+ } from './cross-placement.js';
37
50
 
38
51
  const META_FILE = 'meta.json';
39
52
  const BRANCH_PREFIX = 'sillyspec/';
40
53
 
41
54
  /**
42
- * 跨仓 worktree 路径(唯一路径公式;multi-repo-context.js 的 meta 只读逻辑与此同步)。
55
+ * 跨仓 worktree 路径(唯一路径公式;实现已下沉 cross-placement 叶子模块——multi-repo-context.js
56
+ * 经该叶子模块共用,不再与 worktree-cross 经 run/shared 成环)。
57
+ * placementRoot 可选(坑 cross-wt-toolchain-split 落位配置):给定时时 <placementRoot>/<change>--<repoKey>。
43
58
  * @param {string} specBase 主仓 spec 根(<主仓>/.sillyspec)
44
59
  * @param {string} changeName 变更名
45
60
  * @param {string} repoKey 跨仓 repo 键(local.yaml repos: 段的 key)
61
+ * @param {string|null} [placementRoot] 自定义落位根(null/缺省走默认公式——三参调用零回归)
46
62
  */
47
- export function crossWorktreePath(specBase, changeName, repoKey) {
48
- return join(specBase, '.runtime', 'worktrees', `${changeName}--${repoKey}`);
63
+ export function crossWorktreePath(specBase, changeName, repoKey, placementRoot = null) {
64
+ return _placementCrossPath(specBase, changeName, repoKey, placementRoot);
49
65
  }
50
66
 
51
67
  /**
52
68
  * 读跨仓 worktree meta(不存在/损坏返 null = legacy 直写模式)。
69
+ * 寻址经 resolveCrossWorktreePath(注册表优先、公式兜底)——worktree 挪到 crossPlacement
70
+ * 自定义落位后 meta 仍可达(坑 cross-wt-toolchain-split)。
53
71
  * @returns {object|null}
54
72
  */
55
73
  export function getCrossWorktreeMeta(specBase, changeName, repoKey) {
56
- const metaPath = join(crossWorktreePath(specBase, changeName, repoKey), META_FILE);
57
- if (!existsSync(metaPath)) return null;
58
- try {
59
- return JSON.parse(readFileSync(metaPath, 'utf8'));
60
- } catch {
61
- return null;
74
+ // repoRoot 从 repos 注册表解析(无 repoKey 注册/解析失败 → null,候选列表少一级)
75
+ const mainRoot = dirname(specBase);
76
+ const repoRoot = parseRepoRegistry(readLocalYamlText(mainRoot)).get(repoKey) || null;
77
+ const resolvedRoot = repoRoot && !isAbsolute(repoRoot) ? resolvePath(mainRoot, repoRoot) : repoRoot;
78
+ // 寻址候选逐级探测:注册表 → 仓内新公式 → 主仓 specBase 旧公式(legacy 存量兜底)
79
+ for (const dir of resolveCrossWorktreePathCandidates(specBase, changeName, repoKey, resolvedRoot)) {
80
+ const metaPath = join(dir, META_FILE);
81
+ if (!existsSync(metaPath)) continue; // 未命中 → 下一候选
82
+ try {
83
+ const m = JSON.parse(readFileSync(metaPath, 'utf8'));
84
+ return m && m.isCross ? m : null; // 非跨仓/损坏 = 命中但无效
85
+ } catch {
86
+ return null;
87
+ }
62
88
  }
89
+ return null;
63
90
  }
64
91
 
65
92
  /**
66
- * 列出某变更已建的全部跨仓 worktree meta(扫描 worktrees 目录下 `<change>--*` 且 isCross 的 meta)。
93
+ * 列出某变更已建的全部跨仓 worktree meta(双源:worktrees 目录 `<change>--*` 扫描 ∪ 落位
94
+ * 注册表条目——坑 cross-wt-toolchain-split:worktree 挪到 crossPlacement 自定义目录后不在
95
+ * 默认存储目录下,纯目录扫描盲区)。注册表只提供位置:条目指向处须读到可解析且 isCross 的
96
+ * meta.json 才入列(与目录扫描同判据——不合成无 meta 条目,消费方 gates.js 解引用
97
+ * cm.depsStatus 零 TypeError 面)。按 repoKey 去重(注册表与目录扫描可能同指一处)。
67
98
  * @returns {Array<{repoKey: string, meta: object}>}
68
99
  */
69
100
  export function listCrossWorktreeMetas(specBase, changeName) {
70
101
  const base = join(specBase, '.runtime', 'worktrees');
71
- if (!existsSync(base)) return [];
102
+ const candidates = []; // {dir}
103
+ const scanDir = (dir) => {
104
+ if (!existsSync(dir)) return;
105
+ try {
106
+ for (const name of readdirSync(dir)) {
107
+ if (!name.startsWith(`${changeName}--`)) continue;
108
+ candidates.push(join(dir, name));
109
+ }
110
+ } catch { /* 目录不可读跳过 */ }
111
+ };
112
+ // 源① 主仓 specBase 旧默认(legacy-main-specbase 存量)
113
+ scanDir(base);
114
+ // 源② 仓内新默认(<repoRoot>/.sillyspec/.runtime/worktrees——按 local.yaml repos 注册表枚举各仓)
115
+ const mainRoot = dirname(specBase);
116
+ const registryYaml = readLocalYamlText(mainRoot);
117
+ for (const repoRoot of parseRepoRegistry(registryYaml).values()) {
118
+ const root = isAbsolute(repoRoot) ? repoRoot : resolvePath(mainRoot, repoRoot);
119
+ scanDir(join(root, '.sillyspec', '.runtime', 'worktrees'));
120
+ }
121
+ // 源③ 注册表(注册表读取 fail-open 返回 {}——损坏降级纯目录扫描)
122
+ const registry = readPlacementRegistry(specBase);
123
+ const prefix = `${changeName}--`;
124
+ for (const [key, entry] of Object.entries(registry)) {
125
+ if (!key.startsWith(prefix)) continue;
126
+ if (entry && typeof entry.worktreePath === 'string' && entry.worktreePath) {
127
+ candidates.push(entry.worktreePath);
128
+ }
129
+ }
72
130
  const out = [];
73
- for (const name of readdirSync(base)) {
74
- if (!name.startsWith(`${changeName}--`)) continue;
75
- const metaPath = join(base, name, META_FILE);
76
- if (!existsSync(metaPath)) continue;
131
+ const seen = new Set();
132
+ for (const dir of candidates) {
133
+ const metaPath = join(dir, META_FILE);
134
+ if (!existsSync(metaPath)) continue; // 无 meta 不入列(含注册表悬挂键——sweep 前的过渡态)
77
135
  try {
78
136
  const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
79
- if (meta && meta.isCross && meta.repoKey) out.push({ repoKey: meta.repoKey, meta });
137
+ if (meta && meta.isCross && meta.repoKey && !seen.has(meta.repoKey)) {
138
+ seen.add(meta.repoKey);
139
+ out.push({ repoKey: meta.repoKey, meta });
140
+ }
80
141
  } catch { /* 损坏 meta 跳过(cleanup 阶段仍按目录处理) */ }
81
142
  }
82
143
  return out;
@@ -105,17 +166,55 @@ function aggregateCrossRepoKeys(specBase, changeName) {
105
166
  return aggregateDeclaredRepos(planContent).filter(k => k !== 'main');
106
167
  }
107
168
 
169
+ /**
170
+ * 读主仓 local.yaml 文本(路径口径与 run/shared.js readLocalYamlRaw 一致:cwd/.sillyspec/local.yaml)。
171
+ * @returns {string}
172
+ */
173
+ function readLocalYamlText(cwd) {
174
+ const p = join(cwd, '.sillyspec', 'local.yaml');
175
+ if (!existsSync(p)) return '';
176
+ try { return readFileSync(p, 'utf8') } catch { return '' }
177
+ }
178
+
179
+ /** worktreePath 是否落在主仓 specBase 旧默认(legacy-main-specbase 形态——该形态不写注册表,公式可寻址) */
180
+ function isLegacyMainSpecbasePlacement(worktreePath, specBase) {
181
+ try {
182
+ return resolvePath(dirname(worktreePath)) === resolvePath(join(specBase, '.runtime', 'worktrees'));
183
+ } catch { return false }
184
+ }
185
+
186
+ /**
187
+ * 仓内落位的 untracked 保障(坑 cross-wt-repo-local-placement):.git/info/exclude 幂等追加
188
+ * 条目——本仓生效、不进版本库(不碰用户 .gitignore 工作区文件、无 pull 冲突面)。创建 worktree
189
+ * 前调用,worktree 目录一出现即被 git 忽略。追加失败(只读仓等)仅 warn 不阻断——最坏回落
190
+ * 「worktree 成 untracked 噪音」,apply 侧清单校验本就过滤。
191
+ * @param {string} repoRoot 跨仓仓根
192
+ * @param {string} entry exclude 条目(如 '.sillyspec/')
193
+ */
194
+ function ensureRepoLocalExclude(repoRoot, entry) {
195
+ try {
196
+ const excludePath = join(repoRoot, '.git', 'info', 'exclude');
197
+ let content = '';
198
+ if (existsSync(excludePath)) {
199
+ try { content = readFileSync(excludePath, 'utf8') } catch { content = '' }
200
+ }
201
+ const entryRe = new RegExp(`^${entry.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm');
202
+ if (entryRe.test(content)) return; // 幂等:已有条目不动
203
+ const sep = content && !content.endsWith('\n') ? '\n' : '';
204
+ const stamp = `${sep}# sillyspec cross-repo worktree placement (auto)\n${entry}\n`;
205
+ mkdirSync(join(repoRoot, '.git', 'info'), { recursive: true });
206
+ writeAtomicSync(excludePath, content + stamp);
207
+ } catch (e) {
208
+ console.warn(`⚠️ 跨仓仓 .git/info/exclude 追加失败(${e.message})——worktree 目录可能成 untracked 噪音,apply 清单校验仍会过滤,建议手工补 ignore。`);
209
+ }
210
+ }
211
+
108
212
  /**
109
213
  * 读主仓 local.yaml 的 repos: 段(路径口径与 run/shared.js readLocalYamlRaw 一致:cwd/.sillyspec/local.yaml)。
110
214
  * @returns {Map<string,string>}
111
215
  */
112
216
  function readRepoRegistry(cwd) {
113
- const p = join(cwd, '.sillyspec', 'local.yaml');
114
- let text = '';
115
- if (existsSync(p)) {
116
- try { text = readFileSync(p, 'utf8') } catch { text = '' }
117
- }
118
- return parseRepoRegistry(text);
217
+ return parseRepoRegistry(readLocalYamlText(cwd));
119
218
  }
120
219
 
121
220
  /**
@@ -132,6 +231,11 @@ export function ensureCrossWorktrees({ cwd, changeName, specBase }) {
132
231
  const base = specBase || join(cwd, '.sillyspec');
133
232
  const keys = aggregateCrossRepoKeys(base, changeName);
134
233
  const registry = readRepoRegistry(cwd);
234
+ // 落位配置双源(坑 cross-wt-toolchain-split 配置简化,2026-10-10 用户反馈):推荐 repos 条目
235
+ // 内联(repos.<key>: {path, worktree}——注册与落位一处声明);worktree.crossPlacement 段兼容
236
+ // 保留作 legacy 读面。优先级:repos.<key>.worktree > worktree.crossPlacement.<key> > 默认公式。
237
+ const inlinePlacements = parseRepoWorktreePlacements(readLocalYamlText(cwd));
238
+ const placementCfg = readCrossPlacementConfig(cwd);
135
239
  const created = [];
136
240
  const reused = [];
137
241
  const skippedLegacy = [];
@@ -156,9 +260,36 @@ export function ensureCrossWorktrees({ cwd, changeName, specBase }) {
156
260
  try { gitQuiet(repoRoot, ['worktree', 'prune'], { timeout: 30000 }) } catch {}
157
261
  }
158
262
 
159
- const worktreePath = crossWorktreePath(base, changeName, key);
263
+ // 落位解析(FR-01/FR-02):显式(repos.<key>.worktree 内联 > crossPlacement.<key> legacy)>
264
+ // 新默认=仓内 <repoRoot>/.sillyspec/.runtime/worktrees(坑 cross-wt-repo-local-placement:
265
+ // 天然同盘同文件系统,WSL 主仓+Windows 盘跨仓的工具链分裂自动消解——用户 2026-10-10 提议,
266
+ // 对齐主仓 worktree 的同构形态)。旧默认(主仓 specBase 下)降 legacy 兜底(resolve/扫描
267
+ // 仍认,存量 worktree 全链可达)。
268
+ // 显式配置指向仓根内维持拒绝(手配仓内=误配——untracked 噪音面);新默认仓内合法,untracked
269
+ // 面由 ensureRepoLocalExclude 的 .git/info/exclude 保障(不动用户 .gitignore、不进版本库)。
270
+ const rawPlacement = inlinePlacements.get(key) || placementCfg.get(key) || null;
271
+ let placementRoot = null;
272
+ let placementMode = 'repo-local';
273
+ if (rawPlacement) {
274
+ placementRoot = resolvePlacementRoot(rawPlacement, cwd);
275
+ placementMode = inlinePlacements.has(key) ? 'explicit-inline' : 'explicit-legacy';
276
+ const rel = relativePath(resolvePath(repoRoot), placementRoot);
277
+ if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) {
278
+ throw new Error(
279
+ `跨仓 repo "${key}" 的落位配置(${placementRoot})落在该仓仓根(${repoRoot})之内——` +
280
+ `显式落位应选该仓之外的目录(仓内落位请删掉该配置走默认)。重跑 execute 前修正配置。`
281
+ );
282
+ }
283
+ }
284
+ const worktreePath = crossWorktreePath(base, changeName, key, placementRoot || join(repoRoot, '.sillyspec', '.runtime', 'worktrees'));
160
285
  const branch = BRANCH_PREFIX + changeName;
161
286
 
287
+ // 仓内落位的 untracked 保障:.git/info/exclude 幂等追加 .sillyspec/(本仓生效、不进版本库、
288
+ // 不碰用户 .gitignore)。创建 worktree 前落盘——worktree 目录一出现即被忽略。
289
+ if (!rawPlacement) {
290
+ ensureRepoLocalExclude(repoRoot, '.sillyspec/');
291
+ }
292
+
162
293
  // base 快照:跨仓仓当前 HEAD(与主仓 create 的默认 base 语义一致)
163
294
  let baseHash;
164
295
  try {
@@ -175,13 +306,27 @@ export function ensureCrossWorktrees({ cwd, changeName, specBase }) {
175
306
  ` ③ 换变更名重跑。`
176
307
  );
177
308
  }
178
- mkdirSync(join(base, '.runtime', 'worktrees'), { recursive: true });
309
+ if (rawPlacement) {
310
+ mkdirSync(placementRoot, { recursive: true });
311
+ } else {
312
+ mkdirSync(join(repoRoot, '.sillyspec', '.runtime', 'worktrees'), { recursive: true });
313
+ }
179
314
  try {
180
315
  git(repoRoot, ['worktree', 'add', worktreePath, '-b', branch, baseHash], { timeout: 120000 });
181
316
  } catch (e) {
182
317
  throw new Error(`跨仓仓 ${key} worktree 创建失败(${worktreePath}):${e.stderr || e.message}`);
183
318
  }
184
319
 
320
+ // WSL 跨文件系统分裂警告(FR-03,advisory 不阻断——坑 cross-wt-toolchain-split:主仓在
321
+ // WSL /root、跨仓在 /mnt/<盘>/ Windows 盘时,worktree 默认落 WSL 原生 fs,该仓 Windows
322
+ // 侧工具链(mvn/JDK)够不着,隔离形同虚设——fire-equipment 2026-10-10 实证被迫绕开
323
+ // worktree 直写主干。配置同侧落位后本警告自然消音)。
324
+ if (isWslSplit(repoRoot, worktreePath)) {
325
+ console.warn(`⚠️ 跨仓 ${key}: 工具链分裂风险——仓根在 WSL automount Windows 盘(${repoRoot})而 worktree 落在 WSL 原生文件系统(${worktreePath})。`);
326
+ console.warn(` 该仓若依赖 Windows 侧构建工具链(mvn/JDK/IDE),将无法访问 worktree——2026-10-10 实证此形态下 agent 被迫绕开 worktree 直写主副本。`);
327
+ console.warn(` 建议:local.yaml 配 worktree.crossPlacement.${key} 到该仓可达的目录(如同盘符路径),重跑 execute 前先 sillyspec worktree cleanup ${changeName} --force。`);
328
+ }
329
+
185
330
  const meta = {
186
331
  name_zh: 'worktree 元数据',
187
332
  changeName,
@@ -195,6 +340,7 @@ export function ensureCrossWorktrees({ cwd, changeName, specBase }) {
195
340
  createdAt: new Date().toISOString(),
196
341
  worktreePath,
197
342
  mode: 'worktree',
343
+ placementMode,
198
344
  };
199
345
  writeAtomicSync(join(worktreePath, META_FILE), JSON.stringify(meta, null, 2) + '\n');
200
346
 
@@ -209,6 +355,12 @@ export function ensureCrossWorktrees({ cwd, changeName, specBase }) {
209
355
  console.log(`ℹ️ 跨仓 ${key}: 跳过 dirty baseline checkpoint(跨仓主副本在途改动不进本变更分支——含并行会话外来文件时防污染;锚点 = 跨仓仓 HEAD ${String(baseHash).slice(0, 8)})`);
210
356
  writeAtomicSync(join(worktreePath, META_FILE), JSON.stringify(meta, null, 2) + '\n');
211
357
 
358
+ // 落位注册表(FR-02):仓内新默认与显式落位都记录位置(新默认位置依赖 repoRoot,读取方
359
+ // 无 repoRoot 上下文时公式兜底不可用——注册表是主寻址);落主仓 specBase 的旧默认不写。
360
+ if (placementRoot || !isLegacyMainSpecbasePlacement(worktreePath, base)) {
361
+ updatePlacementRegistry(base, changeName, key, { worktreePath, placementRoot: placementRoot || join(repoRoot, '.sillyspec', '.runtime', 'worktrees') });
362
+ }
363
+
212
364
  // deps 供给:specBase 传 null —— sniff worktree 自身(主仓 local.yaml 的 project.type/install
213
365
  // 描述的是主仓,跨仓仓类型常不同,如实测 maven 主仓 + nodejs 前端仓,沿用会把 mvn 命令
214
366
  // 打到前端仓上)。失败不阻断(与主仓 create 同语义,doctor --fix 可修)
@@ -229,7 +381,7 @@ export function ensureCrossWorktrees({ cwd, changeName, specBase }) {
229
381
  writeAtomicSync(join(worktreePath, META_FILE), JSON.stringify(meta, null, 2) + '\n');
230
382
  }
231
383
 
232
- created.push({ repoKey: key, worktreePath });
384
+ created.push({ repoKey: key, worktreePath, meta });
233
385
  }
234
386
  return { created, reused, skippedLegacy };
235
387
  }
@@ -255,6 +407,7 @@ export function cleanupCrossWorktrees({ cwd, changeName, specBase, force = false
255
407
  const wtPath = meta.worktreePath || crossWorktreePath(base, changeName, repoKey);
256
408
  if (!existsSync(wtPath)) {
257
409
  try { if (repoRoot) gitQuiet(repoRoot, ['worktree', 'prune'], { timeout: 30000 }) } catch {}
410
+ removePlacementRegistryEntry(base, changeName, repoKey); // 挪位清理后注册表键回收(FR-02)
258
411
  results.push({ repoKey, result: 'skipped', details: ['目录不存在(已清理)'], residual });
259
412
  continue;
260
413
  }
@@ -282,7 +435,15 @@ export function cleanupCrossWorktrees({ cwd, changeName, specBase, force = false
282
435
  try { gitQuiet(repoRoot, ['worktree', 'prune'], { timeout: 30000 }) } catch { residual.push(`git worktree prune 失败(${repoRoot})`) }
283
436
  }
284
437
  details.push(`分支 ${meta.branch} 保留作 review 锚点(确认无需回溯后可 git -C ${repoRoot} branch -D ${meta.branch})`);
438
+ if (residual.length === 0) {
439
+ removePlacementRegistryEntry(base, changeName, repoKey); // cleaned 后注册表键回收(FR-02)
440
+ }
285
441
  results.push({ repoKey, result: residual.length > 0 ? 'partial' : 'cleaned', details, residual });
286
442
  }
443
+ // 差集 sweep(评审 P2 吸收):cleanup 迭代源=listCrossWorktreeMetas 只列有 meta 条目,
444
+ // 「worktree remove 连目录带 meta 同亡、仅注册表键存」的悬挂键够不着——末尾对本变更名下
445
+ // 全部键按「worktreePath 处无 meta.json」差集清(活键不删)。
446
+ const swept = sweepPlacementRegistry(base, changeName);
447
+ if (swept.length > 0) results.push({ repoKey: '(registry-sweep)', result: 'cleaned', details: [`悬挂注册表键已清: ${swept.join('、')}`], residual: [] });
287
448
  return { results };
288
449
  }
@@ -38,21 +38,24 @@ const DEFAULT_TIMEOUT_MS = 300 * 1000;
38
38
  */
39
39
  const ECOSYSTEMS = [
40
40
  {
41
+ // JVM 系(maven/gradle)install=null(坑 maven-provision-runs-tests,2026-10-10 用户实证):
42
+ // 依赖在用户级仓库(~/.m2、~/.gradle),worktree 内无本地产物可供给——根供给诚实落 n/a
43
+ // (deps 门控放行集含 n/a)。旧默认 'mvn -o test' 把跑测试当供给:冷缓存 + 离线 + 工具链
44
+ // 不在 PATH 必败 → depsStatus=failed 卡死 execute --done(agent 被迫手改 meta)。构建/测试
45
+ // 归 task 级验证(mvn compile / gradle build 在任务验收面显式执行);显式 commands.install
46
+ // 配置时仍按白名单执行(inferInstallCommand 优先级不变)。
41
47
  type: 'maven',
42
48
  manifests: ['pom.xml'],
43
49
  marker: null,
44
50
  detect: (d) => existsSync(join(d, 'pom.xml')),
45
- install: () => 'mvn -o test',
51
+ install: null,
46
52
  },
47
53
  {
48
54
  type: 'gradle',
49
55
  manifests: ['build.gradle.kts', 'build.gradle', 'settings.gradle', 'gradle.lockfile'],
50
56
  marker: null,
51
57
  detect: (d) => existsSync(join(d, 'build.gradle')) || existsSync(join(d, 'build.gradle.kts')),
52
- // win32 cmd.exe 跑不了 ./gradlew(体检 BUG-08 同型):优先 gradlew.bat,否则全局 gradle
53
- install: (d) => (process.platform === 'win32'
54
- ? (existsSync(join(d, 'gradlew.bat')) ? 'gradlew.bat test' : 'gradle test')
55
- : './gradlew test'),
58
+ install: null,
56
59
  },
57
60
  {
58
61
  type: 'nodejs',
@@ -210,7 +213,10 @@ export function detectProjectType(worktreePath, specBase) {
210
213
  export function inferInstallCommand(projectType, worktreePath, userInstall) {
211
214
  if (userInstall) return userInstall;
212
215
  const eco = ECOSYSTEMS.find(e => e.type === projectType);
213
- return eco ? eco.install(worktreePath) : null;
216
+ // install:null 表项(maven/gradle——依赖在用户级仓库无 worktree 内供给面)与表外类型同归
217
+ // null → 根供给 n/a(坑 maven-provision-runs-tests)
218
+ if (!eco || typeof eco.install !== 'function') return null;
219
+ return eco.install(worktreePath);
214
220
  }
215
221
 
216
222
  /** 在 worktreePath 创建 node_modules 链接到 mainNodeModules;失败回退 */
@@ -640,10 +646,45 @@ export function provisionDeps(worktreePath, mainCwd, opts = {}) {
640
646
  const lines = String(yamlText || '').replace(/\r\n?/g, '\n').split('\n');
641
647
  const start = lines.findIndex(l => /^repos:\s*(?:#.*)?$/.test(l));
642
648
  if (start === -1) return roots;
649
+ const cleanVal = (v) => String(v || '').replace(/\s+#.*$/, '').trim().replace(/^['"]|['"]$/g, '');
643
650
  for (let i = start + 1; i < lines.length; i++) {
644
- if (lines[i].length > 0 && !/\s/.test(lines[i][0]) && lines[i].trim() !== '' && !lines[i].startsWith('#')) break;
645
- const m = lines[i].match(/^\s+([A-Za-z0-9_.\-]+):\s*(\S+)/);
646
- if (m && !m[2].startsWith('#')) roots.push(m[2].replace(/^['"]|['"]$/g, ''));
651
+ const line = lines[i];
652
+ if (line.length > 0 && !/\s/.test(line[0]) && line.trim() !== '' && !line.startsWith('#')) break;
653
+ const m = line.match(/^\s+([A-Za-z0-9_.\-]+):\s*(.*)$/);
654
+ if (!m) continue;
655
+ const value = (m[2] || '').trim();
656
+ if (value !== '' && !value.startsWith('#')) {
657
+ if (value.startsWith('{') && value.endsWith('}')) {
658
+ // inline 对象条目(repos.<key>: {path: .., worktree: ..})——只收 path 子键
659
+ for (const part of value.slice(1, -1).split(',')) {
660
+ const idx = part.indexOf(':');
661
+ if (idx !== -1 && part.slice(0, idx).trim() === 'path') {
662
+ const p = cleanVal(part.slice(idx + 1));
663
+ if (p) roots.push(p);
664
+ break;
665
+ }
666
+ }
667
+ } else {
668
+ roots.push(cleanVal(value));
669
+ }
670
+ continue;
671
+ }
672
+ // 块式对象条目(value 空):消费子键块,只收 path 子键值(worktree 落位根不是仓根,
673
+ // 混入会放宽越界豁免面——坑 repo-inline-worktree-placement 收口)
674
+ const indent = line.length - line.trimStart().length;
675
+ let j = i + 1;
676
+ for (; j < lines.length; j++) {
677
+ const l = lines[j];
678
+ if (l.trim() === '' || l.trim().startsWith('#')) continue;
679
+ const subIndent = l.length - l.trimStart().length;
680
+ if (subIndent <= indent) break;
681
+ const sub = l.match(/^\s+([A-Za-z0-9_.\-]+):\s*(.*)$/);
682
+ if (sub && sub[1] === 'path') {
683
+ const p = cleanVal(sub[2]);
684
+ if (p && !p.startsWith('#')) roots.push(p);
685
+ }
686
+ }
687
+ i = j - 1;
647
688
  }
648
689
  return roots;
649
690
  })();
package/src/worktree.js CHANGED
@@ -1326,9 +1326,17 @@ export class WorktreeManager {
1326
1326
  * 写进 worktree 内的 .sillyspec——而 apply 的 filterDeliverableFiles 把 .sillyspec/changes/ 排除在
1327
1327
  * 交付外(spec 文档不进代码 patch),cleanup 删除 worktree 这些产物即蒸发、主仓流程看不到。
1328
1328
  * 删除前扫 worktree 内两棵子树做最后打捞:
1329
- * changes/<name>/** → 主仓缺失则 copy 回(verify-result.md 等核心场景);同名不同内容仅列
1330
- * 清单 warn 不覆盖(主仓版本可能是更新的,覆盖会倒退)
1331
- * docs/** → 主仓缺失则 copy 回(模块文档卡片;同名差异不提示——tracked checkout 副本常态)
1329
+ * changes/<name>/** → 三态判定(坑 archive-resurrect,2026-10-10 下游实证
1330
+ * 2026-10-09-attachment-inline-reference 残留 13 文件:归档把 changes/<name>/
1331
+ * 整体 rename 进 changes/archive/ 后,原路径缺失被误判 worktree 独有,旧快照
1332
+ * 整批复制回原路径复活已归档目录):
1333
+ * · 原路径同名存在 → 内容不同仅列清单 warn 不覆盖(主仓版本可能是更新的)
1334
+ * · 原路径缺、changes/archive/<name>/ 有副本 → 判归档搬运,跳过不复制
1335
+ * (不复活原路径、不覆盖 archive 权威副本)
1336
+ * · 原路径与 archive 均缺 → 真 worktree 独有照捞:归档态捞进
1337
+ * changes/archive/<name>/(原路径已注销禁止重建),未归档态捞回原路径
1338
+ * docs/** → 主仓缺失则 copy 回(模块文档卡片;同名差异不提示——tracked checkout 副本常态;
1339
+ * docs 不参与归档搬运,无三态)
1332
1340
  * 只扫本变更子树(其它 change 的副本文件不越权搬运)。异常只 warn 不阻断清理。
1333
1341
  * @private
1334
1342
  */
@@ -1340,7 +1348,11 @@ export class WorktreeManager {
1340
1348
  if (resolve(wtSpec) === resolve(mainSpec)) return; // in-place:同一目录,无需打捞
1341
1349
  const salvaged = [];
1342
1350
  const conflicts = [];
1343
- const salvageTree = (subDir, reportConflict) => {
1351
+ const archivedSkipped = [];
1352
+ const salvagedIntoArchive = [];
1353
+ const archivedBase = join('changes', 'archive', name);
1354
+ const archivedBaseExists = existsSync(join(mainSpec, archivedBase));
1355
+ const salvageTree = (subDir, reportConflict, trackArchive) => {
1344
1356
  const srcRoot = join(wtSpec, subDir);
1345
1357
  if (!existsSync(srcRoot)) return;
1346
1358
  const walk = (dir) => {
@@ -1349,28 +1361,50 @@ export class WorktreeManager {
1349
1361
  if (entry.isDirectory()) { walk(src); continue; }
1350
1362
  if (!entry.isFile()) continue;
1351
1363
  const rel = relative(wtSpec, src);
1364
+ const rest = relative(srcRoot, src);
1352
1365
  const dst = join(mainSpec, rel);
1366
+ const archivedDst = trackArchive ? join(mainSpec, archivedBase, rest) : null;
1353
1367
  try {
1354
- if (!existsSync(dst)) {
1368
+ if (existsSync(dst)) {
1369
+ if (reportConflict && readFileSync(src, 'utf8') !== readFileSync(dst, 'utf8')) {
1370
+ conflicts.push(rel);
1371
+ }
1372
+ } else if (archivedDst && existsSync(archivedDst)) {
1373
+ archivedSkipped.push(rest);
1374
+ } else if (archivedDst && archivedBaseExists) {
1375
+ mkdirSync(dirname(archivedDst), { recursive: true });
1376
+ copyFileSync(src, archivedDst);
1377
+ salvagedIntoArchive.push(rest);
1378
+ } else {
1355
1379
  mkdirSync(dirname(dst), { recursive: true });
1356
1380
  copyFileSync(src, dst);
1357
1381
  salvaged.push(rel);
1358
- } else if (reportConflict && readFileSync(src, 'utf8') !== readFileSync(dst, 'utf8')) {
1359
- conflicts.push(rel);
1360
1382
  }
1361
1383
  } catch { /* 单文件失败不拖垮整体打捞 */ }
1362
1384
  }
1363
1385
  };
1364
1386
  walk(srcRoot);
1365
1387
  };
1366
- salvageTree(join('changes', name), true);
1367
- salvageTree('docs', false);
1388
+ salvageTree(join('changes', name), true, true);
1389
+ salvageTree('docs', false, false);
1368
1390
  if (salvaged.length > 0) {
1369
1391
  console.warn(`⚠️ worktree 清理前打捞:${salvaged.length} 个 spec 流程产物只存在于 worktree 副本,已复制回主仓(apply 不搬运 .sillyspec/changes,不捞即随清理蒸发):`);
1370
1392
  for (const f of salvaged.slice(0, 20)) console.warn(` ${join(mainSpec, f)}`);
1371
1393
  if (salvaged.length > 20) console.warn(` …等共 ${salvaged.length} 个`);
1372
1394
  details.push(`salvaged ${salvaged.length} spec artifacts to main repo`);
1373
1395
  }
1396
+ if (salvagedIntoArchive.length > 0) {
1397
+ console.warn(`⚠️ worktree 清理前打捞:${salvagedIntoArchive.length} 个真独有产物(原路径与归档副本均缺)已捞进归档副本(变更已归档,原路径不重建):`);
1398
+ for (const f of salvagedIntoArchive.slice(0, 20)) console.warn(` ${join(mainSpec, archivedBase, f)}`);
1399
+ if (salvagedIntoArchive.length > 20) console.warn(` …等共 ${salvagedIntoArchive.length} 个`);
1400
+ details.push(`salvaged ${salvagedIntoArchive.length} spec artifacts into archive copy`);
1401
+ }
1402
+ if (archivedSkipped.length > 0) {
1403
+ console.warn(`⏭️ worktree 清理前打捞:${archivedSkipped.length} 个流程产物判定为归档搬运(主仓 changes/${name}/ 原路径不存在、changes/archive/${name}/ 已有副本),跳过不复制——防复活已归档目录:`);
1404
+ for (const f of archivedSkipped.slice(0, 20)) console.warn(` ${join(mainSpec, archivedBase, f)}`);
1405
+ if (archivedSkipped.length > 20) console.warn(` …等共 ${archivedSkipped.length} 个`);
1406
+ details.push(`skipped ${archivedSkipped.length} archived-moved spec artifacts (resurrect guard: changes/${name}/ not rebuilt)`);
1407
+ }
1374
1408
  if (conflicts.length > 0) {
1375
1409
  console.warn(`⚠️ worktree 与主仓存在同名但内容不同的流程产物(不覆盖,请人工确认是否需要合并):`);
1376
1410
  for (const f of conflicts.slice(0, 20)) console.warn(` ${join(mainSpec, f)}`);
@@ -1,20 +0,0 @@
1
- ---
2
- name: sillyspec:run-quick
3
- description: 已退役重定向 — quick 通道已拒绝新会话(v3.31.0 起),小修补请用 /sillyspec:flow
4
- ---
5
- # 通道已退役
6
-
7
- quick 通道已于 v3.31.0 直接退役:`sillyspec run quick` 对新会话一律拒绝(exit 1 并指路 flow start)。本卡只保留触发词重定向,勿执行任何 quick 新会话命令。
8
-
9
- 范围明确的小修补请走**轻量变更**(/sillyspec:flow):
10
-
11
- ```bash
12
- sillyspec flow start --change <变更名> --input "<动机与背景>
13
-
14
- 成功标准:
15
- - <可验证标准>"
16
- # → 直接干活(改代码写测试,治理工件 CLI 机器起草)
17
- sillyspec flow done --change <变更名>
18
- ```
19
-
20
- 升级前遗留的在途 quick 会话仍可收尾:`sillyspec run quick --change <会话ID>` 续跑 / `--done` 收口 / `--cancel` 取消(以 CLI 拒绝文案里的指引为准)。