sillyspec 3.25.4 → 3.25.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/.claude/skills/sillyspec-brainstorm/SKILL.md +27 -1
- package/.claude/skills/sillyspec-execute/SKILL.md +56 -0
- package/.claude/skills/sillyspec-plan/SKILL.md +26 -0
- package/.claude/skills/sillyspec-propose/SKILL.md +26 -0
- package/.claude/skills/sillyspec-scan/SKILL.md +12 -7
- package/.claude/skills/sillyspec-verify/SKILL.md +2 -2
- package/package.json +1 -1
- package/src/change-list.js +114 -12
- package/src/change-risk-profile.js +36 -1
- package/src/constants.js +11 -0
- package/src/index.js +4 -1
- package/src/machine-interface.js +5 -1
- package/src/progress/stage-machine.js +17 -14
- package/src/run/command.js +58 -3
- package/src/run/complete-handlers.js +9 -3
- package/src/run/complete.js +130 -33
- package/src/run/gates.js +37 -0
- package/src/run/prompt.js +20 -0
- package/src/run/quick-audit.js +8 -1
- package/src/run/scan-profile.js +29 -10
- package/src/run/shared.js +161 -4
- package/src/run/stage.js +4 -0
- package/src/scan-postcheck.js +25 -11
- package/src/stage-contract.js +39 -8
- package/src/stage-review.js +16 -5
- package/src/stages/archive.js +13 -9
- package/src/stages/brainstorm-auto.js +3 -2
- package/src/stages/brainstorm.js +13 -16
- package/src/stages/execute.js +4 -3
- package/src/stages/plan-postcheck.js +1 -1
- package/src/stages/plan.js +13 -0
- package/src/stages/propose.js +3 -2
- package/src/stages/quick.js +31 -31
- package/src/stages/scan.js +2 -1
- package/src/stages/status.js +2 -2
- package/src/stages/verify.js +19 -8
- package/src/task-review.js +308 -3
- package/src/verify-postcheck.js +353 -38
- package/src/worktree-apply.js +134 -67
- package/src/worktree.js +106 -32
- package/templates/prompts/verify-probes.md +13 -2
package/src/worktree-apply.js
CHANGED
|
@@ -18,7 +18,8 @@ import { join, resolve } from 'path';
|
|
|
18
18
|
import { tmpdir } from 'os';
|
|
19
19
|
import { createHash } from 'crypto';
|
|
20
20
|
import { WorktreeManager } from './worktree.js';
|
|
21
|
-
import { parseFileChangeList } from './change-list.js';
|
|
21
|
+
import { parseFileChangeList, parseFileChangeListDetailed, pathMatches } from './change-list.js';
|
|
22
|
+
import { parseAllowedPaths } from './stages/plan-postcheck.js';
|
|
22
23
|
|
|
23
24
|
const CHANGES_REL = '.sillyspec/changes';
|
|
24
25
|
|
|
@@ -48,6 +49,20 @@ export function filterDeliverableFiles(files) {
|
|
|
48
49
|
return files.filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json');
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
/**
|
|
53
|
+
* 校验变更文件是否都在 design.md 清单内——容差匹配(与 plan-postcheck validateDesignFileCoverage
|
|
54
|
+
* 同语义):design 清单写 glob(双星通配)或目录前缀(如 src/ 子树)也能覆盖 git diff 出的具体路径,
|
|
55
|
+
* 避免「plan 阶段放过、apply 阶段卡死」逼用户回去补字面文件名。
|
|
56
|
+
*
|
|
57
|
+
* @param {string[]} changedFiles git diff 出的具体变更路径
|
|
58
|
+
* @param {Set<string>} allowSet design.md 清单项(可能含 glob / 目录前缀)
|
|
59
|
+
* @returns {string[]} 不被任何清单项覆盖的文件(违规项,apply 将据此 BLOCK)
|
|
60
|
+
*/
|
|
61
|
+
export function classifyAllowListViolations(changedFiles, allowSet) {
|
|
62
|
+
const allow = [...allowSet];
|
|
63
|
+
return changedFiles.filter(f => !allow.some(ap => pathMatches(f, ap)));
|
|
64
|
+
}
|
|
65
|
+
|
|
51
66
|
/**
|
|
52
67
|
* 批量获取多个文件在某个 treeish 中的 blob hash(一次 git ls-tree 替代 N 次 rev-parse)。
|
|
53
68
|
*
|
|
@@ -170,29 +185,32 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
170
185
|
|
|
171
186
|
// --- 4. 校验:变更文件 ⊆ 清单(无清单则跳过)---
|
|
172
187
|
if (hasAllowList) {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
if (result.extraFiles.length > 0) {
|
|
188
|
+
const violations = classifyAllowListViolations(changedFiles, allowSet);
|
|
189
|
+
if (violations.length > 0) {
|
|
190
|
+
result.extraFiles.push(...violations);
|
|
179
191
|
result.errors.push(
|
|
180
|
-
`文件清单校验失败:以下变更文件不在 design.md 清单中:\n ${
|
|
192
|
+
`文件清单校验失败:以下变更文件不在 design.md 清单中:\n ${violations.join('\n ')}`
|
|
181
193
|
);
|
|
182
|
-
|
|
194
|
+
// checkOnly(assess)模式不短路:继续跑 Gate3,收集所有道供一次报全(坑 worktree-execute-apply-friction 坑4)。
|
|
195
|
+
// 真实 apply(checkOnly=false)仍短路,保安全。
|
|
196
|
+
if (!checkOnly) return result;
|
|
183
197
|
}
|
|
184
198
|
}
|
|
185
199
|
|
|
186
|
-
// --- 4.
|
|
187
|
-
//
|
|
200
|
+
// --- 4.6 显式 --merge:用户显式选择 git merge 兜底(主干已提交推进重叠时的三方合并) ---
|
|
201
|
+
// 触发点从「4.5 baseline 漂移自动降级」改为「用户显式 --merge flag」(D-001 保留,触发方式变化)。
|
|
202
|
+
// 用 --merge 时跳过未提交 dirty 拦截——merge 同样要求工作区相对干净,此处仅提示风险,真正失败由 applyByMerge 报告。
|
|
203
|
+
if (merge && !checkOnly) {
|
|
204
|
+
return applyByMerge(result, changeName, projectRoot, wm);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// --- 4.5 校验:主工作区是否有「未提交」脏改动(未提交 dirty 是 git apply --3way 危险区,必须拦)---
|
|
208
|
+
// 分工:4.5(整树 baselineHash)+ 5a(脏∩changedFiles)挡「未提交」dirty;5b 管「已提交」HEAD 分叉(已放宽,见下)。
|
|
209
|
+
// 实测:主干未提交 dirty 时,git apply --3way 报 `does not match index` 且行为不一致(可能报错/可能半应用,
|
|
210
|
+
// 哪怕脏文件与 patch 不重叠)——这是 git 硬约束,故此处友好拦截,引导用户先 commit/stash。
|
|
188
211
|
// 排除非交付物的元数据/文档 churn(execute 自身改的 + 多操作者常改的 agent 指引/文档),
|
|
189
212
|
// 否则别人改 CLAUDE.md/docs/.claude → 整树 hash 变 → apply 误阻断(多操作者仓库高频踩坑)。
|
|
190
|
-
//
|
|
191
|
-
// 做精确 per-file 校验,不读这里的 exclude。
|
|
192
|
-
// - .sillyspec/:plan/design 蓝图 + runtime 产物
|
|
193
|
-
// - .claude/:agent 配置/skills/CLAUDE.md
|
|
194
|
-
// - docs/:文档(deliverable 文档冲突由 step5a/5b 兜底)
|
|
195
|
-
// - CLAUDE.md:根 agent 指引
|
|
213
|
+
// 注意:必须和 computeBaselineHash (worktree.js) 使用相同的排除规则。
|
|
196
214
|
if (meta.baselineHash) {
|
|
197
215
|
const exclude = '-- . ":(exclude).sillyspec/" ":(exclude).claude/" ":(exclude)docs/" ":(exclude)CLAUDE.md"';
|
|
198
216
|
const staged = gitQuiet(projectRoot, `diff --cached ${exclude}`) || '';
|
|
@@ -201,17 +219,19 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
201
219
|
const raw = `staged:${staged}\nunstaged:${unstaged}\nuntracked:${untracked}`;
|
|
202
220
|
const currentHash = createHash('sha256').update(raw).digest('hex').slice(0, 16);
|
|
203
221
|
if (currentHash !== meta.baselineHash) {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
222
|
+
// 未提交 dirty 拦截:列脏文件 + 指引先 commit/stash(git --3way 对 dirty 工作区不稳,merge 同理,故不再提 --merge)
|
|
223
|
+
const dirtyFiles = [...new Set(
|
|
224
|
+
((gitQuiet(projectRoot, 'diff --name-only HEAD') || '').split('\n').filter(Boolean))
|
|
225
|
+
.concat((gitQuiet(projectRoot, 'ls-files --others --exclude-standard') || '').split('\n').filter(Boolean))
|
|
226
|
+
)].filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json');
|
|
208
227
|
result.errors.push(
|
|
209
|
-
|
|
210
|
-
|
|
228
|
+
`主工作区有未提交的改动(execute 前后 baseline 不一致),git apply 无法安全应用。\n` +
|
|
229
|
+
(dirtyFiles.length > 0 ? `未提交文件:\n ${dirtyFiles.join('\n ')}\n` : '') +
|
|
230
|
+
`请先提交或暂存这些改动,再重新 apply:\n git add -A && git commit -m "..." 或 git stash\n` +
|
|
211
231
|
`execute 前 baseline: ${meta.baselineHash}\n` +
|
|
212
232
|
`当前 baseline: ${currentHash}`
|
|
213
233
|
);
|
|
214
|
-
return result;
|
|
234
|
+
if (!checkOnly) return result; // checkOnly 收集不短路(一次报全);真实 apply 短路
|
|
215
235
|
}
|
|
216
236
|
}
|
|
217
237
|
|
|
@@ -226,13 +246,17 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
226
246
|
result.errors.push(
|
|
227
247
|
`主工作区有以下未 commit 的变更,会影响 apply:\n ${conflictDirty.join('\n ')}\n请先 commit 或 stash 这些变更。`
|
|
228
248
|
);
|
|
229
|
-
return result;
|
|
249
|
+
if (!checkOnly) return result; // checkOnly 收集不短路(一次报全);真实 apply 短路
|
|
230
250
|
}
|
|
231
251
|
}
|
|
232
252
|
|
|
233
253
|
// 5b. 对比 worktree 的 baseHash 和主工作区 HEAD 中每个清单文件的 blob hash
|
|
234
254
|
// 批量化:两次 ls-tree(worktree 的 baseHash + 主仓库 HEAD)各建一张 path→hash Map,
|
|
235
255
|
// 替代 per-file getFileBlobHash × 2(原 2N spawn → 固定 2)。语义等价见 getBlobHashMap。
|
|
256
|
+
// 放宽(2026-07):blob 不一致不再 BLOCKED——它意味着主干「已提交」推进改了同文件,
|
|
257
|
+
// 而 git apply --3way 能自动三路合并这种场景(不同区域直接合,同区域留冲突标记)。
|
|
258
|
+
// 故仅记录为风险提示(hashMismatchFiles → assess WARNING / summary 展示),放行交 step7 --3way 实测。
|
|
259
|
+
// 真重叠时 --3way 冲突,由 step7 回滚并提示 --merge 兜底。
|
|
236
260
|
const targetFiles = hasAllowList ? [...allowSet] : changedFiles;
|
|
237
261
|
const wtHashMap = getBlobHashMap(worktreePath, baseHash, targetFiles);
|
|
238
262
|
const mainHashMap = getBlobHashMap(projectRoot, 'HEAD', targetFiles);
|
|
@@ -244,17 +268,10 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
244
268
|
if (wtBlob === null && mainBlob === null) continue;
|
|
245
269
|
// 两者一致 → OK
|
|
246
270
|
if (wtBlob === mainBlob) continue;
|
|
247
|
-
// 不一致 →
|
|
271
|
+
// 不一致 → 主干已提交推进改了此文件,记风险提示(不拦截,交 --3way)
|
|
248
272
|
result.hashMismatchFiles.push(f);
|
|
249
273
|
}
|
|
250
274
|
|
|
251
|
-
if (result.hashMismatchFiles.length > 0) {
|
|
252
|
-
result.errors.push(
|
|
253
|
-
`base hash 不一致:以下文件在主工作区已被修改:\n ${result.hashMismatchFiles.join('\n ')}`
|
|
254
|
-
);
|
|
255
|
-
return result;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
275
|
// --- 6. checkOnly 模式:到此返回 ---
|
|
259
276
|
if (checkOnly) {
|
|
260
277
|
result.ok = true;
|
|
@@ -323,19 +340,33 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
323
340
|
|
|
324
341
|
writeFileSync(patchPath, patchContent);
|
|
325
342
|
|
|
326
|
-
// apply --check
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
343
|
+
// apply --check 预检失败不再拦截(--check 只测 clean apply,--3way 能处理 clean apply 失败的
|
|
344
|
+
// 三路合并场景——主干已提交推进时 --check 恒失败但 --3way 可合)。故跳过 --check,直接试 --3way。
|
|
345
|
+
|
|
346
|
+
// 回滚准备:记录 patch 涉及的 tracked 文件(--3way 冲突后需恢复 HEAD 版,不留半成品冲突标记)。
|
|
347
|
+
// tracked 文件冲突 → git checkout -- <f> 还原;untracked 新建文件(--3way 不会对其冲突)不在回滚范围,
|
|
348
|
+
// 但若 --3way 部分成功后冲突,已创建的新文件需删,故记录 untracked 集合。
|
|
349
|
+
const trackedPatchFiles = patchFiles.filter(f => {
|
|
350
|
+
// 该文件在主仓库 HEAD 存在 → 是 tracked(--3way 冲突时留标记需 checkout 还原)
|
|
351
|
+
return gitQuiet(projectRoot, `cat-file -e HEAD:${f}`) === null ? false : true;
|
|
352
|
+
});
|
|
353
|
+
const newPatchFiles = patchFiles.filter(f => !trackedPatchFiles.includes(f));
|
|
333
354
|
|
|
334
|
-
// apply --3way
|
|
355
|
+
// apply --3way 正式应用(主干已提交推进时自动三路合并)
|
|
335
356
|
try {
|
|
336
357
|
git(projectRoot, `apply --3way ${patchPath}`);
|
|
337
358
|
} catch (e) {
|
|
338
|
-
|
|
359
|
+
// --3way 冲突(exit 1,工作区已留冲突标记):回滚到 apply 前干净状态,不留半成品
|
|
360
|
+
const rollback = rollbackApply(projectRoot, trackedPatchFiles, newPatchFiles);
|
|
361
|
+
result.errors.push(
|
|
362
|
+
`apply --3way 冲突:以下文件与主干「已提交」推进重叠,无法自动合并:\n` +
|
|
363
|
+
` ${rollback.conflicts.length > 0 ? rollback.conflicts.join('\n ') : '(未能获取冲突文件列表)'}\n` +
|
|
364
|
+
`已回滚工作区到 apply 前状态(无半成品冲突标记)。\n` +
|
|
365
|
+
`可选:用 --merge 自动三方合并兜底(git merge sillyspec/${changeName},会引入合并提交):\n` +
|
|
366
|
+
` sillyspec worktree apply ${changeName} --merge\n` +
|
|
367
|
+
`或手动解决后重试。`
|
|
368
|
+
);
|
|
369
|
+
if (rollback.error) result.warnings = (result.warnings || []).concat([`回滚警告: ${rollback.error}`]);
|
|
339
370
|
return result;
|
|
340
371
|
}
|
|
341
372
|
|
|
@@ -360,6 +391,40 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
360
391
|
return result;
|
|
361
392
|
}
|
|
362
393
|
|
|
394
|
+
/**
|
|
395
|
+
* 回滚 --3way 冲突后的工作区到 apply 前状态(不留半成品冲突标记)。
|
|
396
|
+
* tracked 文件:git checkout -- <f> 还原 HEAD 版(清除冲突标记)。
|
|
397
|
+
* 新文件(apply 前不存在):删除(--3way 可能部分创建)。
|
|
398
|
+
* @param {string} projectRoot
|
|
399
|
+
* @param {string[]} trackedFiles 主仓库 HEAD 已存在、patch 触及的文件
|
|
400
|
+
* @param {string[]} newFiles apply 前不存在、patch 可能新建的文件
|
|
401
|
+
* @returns {{ conflicts: string[], error: string|null }} conflicts=冲突文件列表
|
|
402
|
+
*/
|
|
403
|
+
function rollbackApply(projectRoot, trackedFiles, newFiles) {
|
|
404
|
+
let error = null;
|
|
405
|
+
// 冲突文件:git status 里带冲突标记(UU/AA 等)的文件
|
|
406
|
+
let conflicts = [];
|
|
407
|
+
try {
|
|
408
|
+
const unmerged = gitQuiet(projectRoot, 'diff --name-only --diff-filter=U') || '';
|
|
409
|
+
conflicts = unmerged.split('\n').filter(Boolean);
|
|
410
|
+
} catch {}
|
|
411
|
+
// 回滚 tracked 文件到 HEAD(强制从 HEAD 还原——--3way 冲突标记同时污染工作区和 index,
|
|
412
|
+
// `checkout -- f` 从 index 还原会拿到冲突版,必须 `checkout HEAD -- f` 才能还原干净)
|
|
413
|
+
for (const f of trackedFiles) {
|
|
414
|
+
try { gitQuiet(projectRoot, `checkout HEAD -- ${f}`); } catch (e) { error = (error ? error + '; ' : '') + `checkout ${f}: ${e.message}`; }
|
|
415
|
+
}
|
|
416
|
+
// 删除 --3way 可能新建的文件(apply 前不存在)
|
|
417
|
+
for (const f of newFiles) {
|
|
418
|
+
try {
|
|
419
|
+
const p = join(projectRoot, f);
|
|
420
|
+
if (existsSync(p)) unlinkSync(p);
|
|
421
|
+
} catch (e) { error = (error ? error + '; ' : '') + `delete ${f}: ${e.message}`; }
|
|
422
|
+
}
|
|
423
|
+
// 兜底:若 index 处于 unmerged 状态,重置 index(不影响工作区已还原的文件)
|
|
424
|
+
try { gitQuiet(projectRoot, 'reset --quiet'); } catch {}
|
|
425
|
+
return { conflicts, error };
|
|
426
|
+
}
|
|
427
|
+
|
|
363
428
|
/**
|
|
364
429
|
* baseline 漂移时的 merge 降级路径(D-001)。
|
|
365
430
|
*
|
|
@@ -456,19 +521,18 @@ export function assessApplyRisk(changeName, { cwd } = {}) {
|
|
|
456
521
|
// 先跑 --check-only 模式的 applyWorktree 获取变更文件列表
|
|
457
522
|
const checkResult = applyWorktree(changeName, { cwd: projectRoot, checkOnly: true });
|
|
458
523
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
reasons: checkResult.errors,
|
|
464
|
-
warnings: [],
|
|
465
|
-
stats: { additions: 0, deletions: 0 }
|
|
466
|
-
};
|
|
467
|
-
}
|
|
524
|
+
// applyWorktree(checkOnly) 已收集所有道(Gate1/3 不短路)——其 errors 纳入 reasons,
|
|
525
|
+
// 继续跑 Gate2/4/6 一次报全(坑 worktree-execute-apply-friction 坑4:原此处提前 return 致逐道挤牙膏)。
|
|
526
|
+
reasons.push(...checkResult.errors);
|
|
527
|
+
if (checkResult.warnings?.length) warnings.push(...checkResult.warnings);
|
|
468
528
|
|
|
469
529
|
const changedFiles = checkResult.changedFiles;
|
|
470
530
|
|
|
471
531
|
if (changedFiles.length === 0) {
|
|
532
|
+
// 无变更:若仍有 Gate 错误(如主区 dirty)则 BLOCKED,否则 SAFE
|
|
533
|
+
if (reasons.length > 0) {
|
|
534
|
+
return { decision: 'BLOCKED', changedFiles: [], reasons, warnings, stats: { additions: 0, deletions: 0 } };
|
|
535
|
+
}
|
|
472
536
|
return {
|
|
473
537
|
decision: 'SAFE',
|
|
474
538
|
changedFiles: [],
|
|
@@ -478,36 +542,39 @@ export function assessApplyRisk(changeName, { cwd } = {}) {
|
|
|
478
542
|
};
|
|
479
543
|
}
|
|
480
544
|
|
|
481
|
-
// 解析 TaskCard allowed_paths
|
|
545
|
+
// 解析 TaskCard allowed_paths(复用 plan-postcheck.parseAllowedPaths,消除内联重复实现漂移)
|
|
482
546
|
const wm = new WorktreeManager({ cwd: projectRoot });
|
|
483
547
|
const meta = wm.getMeta(changeName);
|
|
484
548
|
const tasksDir = join(projectRoot, CHANGES_REL, changeName, 'tasks');
|
|
485
549
|
const allowedPaths = new Set();
|
|
486
550
|
if (existsSync(tasksDir)) {
|
|
487
551
|
for (const tf of readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f))) {
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
if (!fmMatch) continue;
|
|
491
|
-
const fm = fmMatch[1];
|
|
492
|
-
const inline = fm.match(/allowed_paths:\s*\[([^\]]*)\]/);
|
|
493
|
-
if (inline) {
|
|
494
|
-
inline[1].split(',').forEach(s => { const v = s.trim().replace(/['"]/g, ''); if (v) allowedPaths.add(v); });
|
|
495
|
-
}
|
|
496
|
-
const block = fm.match(/allowed_paths:\s*\n((?:\s+-\s+.+\n?)+)/);
|
|
497
|
-
if (block) {
|
|
498
|
-
block[1].match(/-\s+(.+)/g)?.forEach(s => { const v = s.replace(/^-\s+/, '').trim().replace(/['"]/g, ''); if (v) allowedPaths.add(v); });
|
|
552
|
+
for (const p of parseAllowedPaths(readFileSync(join(tasksDir, tf), 'utf8'))) {
|
|
553
|
+
if (p) allowedPaths.add(p);
|
|
499
554
|
}
|
|
500
555
|
}
|
|
501
556
|
}
|
|
502
557
|
|
|
503
|
-
//
|
|
558
|
+
// design §6 标记为「顺带修复」的文件(坑 worktree-execute-apply-friction 坑1):合规修预存债,
|
|
559
|
+
// 不属任何 task 边界,assess 豁免 allowed_paths 严格校验(降级 warning),避免被迫 cherry-pick 绕过。
|
|
560
|
+
const designPath = join(projectRoot, CHANGES_REL, changeName, 'design.md');
|
|
561
|
+
const incidentalSet = new Set(
|
|
562
|
+
parseFileChangeListDetailed(designPath).filter(e => e.incidental).map(e => e.path)
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
// 检查 2: 变更在 allowed_paths 内(仅在 TaskCard 存在时);顺带修复文件豁免。
|
|
566
|
+
// 匹配换 pathMatches(与 Gate1/plan-postcheck 同语义容差),消除原字面前缀弱匹配漂移。
|
|
504
567
|
if (allowedPaths.size > 0) {
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
);
|
|
568
|
+
const isIncidental = f => [...incidentalSet].some(ap => pathMatches(f, ap));
|
|
569
|
+
const outsideAll = changedFiles.filter(f => ![...allowedPaths].some(allowed => pathMatches(f, allowed)));
|
|
570
|
+
const outsidePaths = outsideAll.filter(f => !isIncidental(f));
|
|
571
|
+
const exempted = outsideAll.filter(f => isIncidental(f));
|
|
508
572
|
if (outsidePaths.length > 0) {
|
|
509
573
|
reasons.push(`变更文件超出 allowed_paths:\n ${outsidePaths.join('\n ')}`);
|
|
510
574
|
}
|
|
575
|
+
if (exempted.length > 0) {
|
|
576
|
+
warnings.push(`顺带修复文件(已豁免 allowed_paths,来源 design §6 标记):${exempted.join(', ')}`);
|
|
577
|
+
}
|
|
511
578
|
}
|
|
512
579
|
|
|
513
580
|
// 检查 4+5: 高风险文件模式
|
package/src/worktree.js
CHANGED
|
@@ -138,10 +138,11 @@ function printSyncReport(diag, baseHash, changeName) {
|
|
|
138
138
|
const ch = changeName || '<change>';
|
|
139
139
|
if (diag.status === 'behind') {
|
|
140
140
|
console.log(`⚠️ base 同步检测:落后 origin/${b} ${diag.behind} 个 commit(base ${short})`);
|
|
141
|
-
console.log(`
|
|
141
|
+
console.log(` apply 时 git --3way 会自动三路合并主干已提交推进;只有同文件同区域重叠才冲突。`);
|
|
142
|
+
console.log(` 建议先对齐以减小冲突面:`);
|
|
142
143
|
console.log(` git merge --ff-only origin/${b}`);
|
|
143
144
|
console.log(` sillyspec worktree cleanup ${ch} && sillyspec run execute --change ${ch}`);
|
|
144
|
-
console.log(`
|
|
145
|
+
console.log(` 或继续,重叠冲突时用 --merge 兜底(会引入合并提交)。`);
|
|
145
146
|
} else if (diag.status === 'diverged') {
|
|
146
147
|
console.log(`⚠️ base 同步检测:与 origin/${b} 分叉(领先 ${diag.ahead} / 落后 ${diag.behind},base ${short})`);
|
|
147
148
|
console.log(` apply 回 main 大概率冲突。建议先在主仓库对齐(rebase 或 merge origin/${b}),`);
|
|
@@ -170,11 +171,12 @@ function sleepMs(ms) {
|
|
|
170
171
|
export function computeBaselineHash(cwd) {
|
|
171
172
|
// 排除非交付物的元数据/文档 churn,避免多操作者仓库里别人改这些 → 整树 hash 变 → apply 误阻断
|
|
172
173
|
// (execute 自身也会改 .sillyspec/docs.sillyspec,否则每个 execute 收尾都因自身改动 BLOCKED)。
|
|
173
|
-
// 安全:coarse hash
|
|
174
|
-
//
|
|
174
|
+
// 安全:coarse hash 只挡「未提交」dirty(git apply --3way 危险区,实测必拦);真冲突由
|
|
175
|
+
// applyWorktree step5a(未提交∩changedFiles 精确点名)+step7(--3way 冲突回滚)兜底,
|
|
176
|
+
// 「已提交」推进不再拦(step5b 已放宽交 --3way 自动三路合并),不读这里的 exclude。
|
|
175
177
|
// - .sillyspec/:brainstorm/plan 蓝图 + runtime 产物
|
|
176
178
|
// - .claude/:agent 配置/skills/CLAUDE.md(多操作者 agent 指引 churn)
|
|
177
|
-
// - docs
|
|
179
|
+
// - docs/:文档(非代码交付物)
|
|
178
180
|
// - CLAUDE.md:根 agent 指引(多操作者常改)
|
|
179
181
|
// 必须和 applyWorktree step 4.5 (worktree-apply.js) 使用相同的排除规则。
|
|
180
182
|
const exclude = '-- . ":(exclude).sillyspec/" ":(exclude).claude/" ":(exclude)docs/" ":(exclude)CLAUDE.md"';
|
|
@@ -247,15 +249,16 @@ export class WorktreeManager {
|
|
|
247
249
|
|
|
248
250
|
let root = this.cwd;
|
|
249
251
|
try {
|
|
250
|
-
// git-common-dir 在主仓库内 = <main>/.git
|
|
251
|
-
//
|
|
252
|
+
// git-common-dir 在主仓库内 = <main>/.git,在 linked worktree 内 = <main>/.git(git 共享 .git 目录)。
|
|
253
|
+
// 但 git 可能返回**相对路径**(如 `.git`)。必须相对 this.cwd 绝对化,否则下面的
|
|
254
|
+
// existsSync/statSync 会相对 process.cwd() 解析——当 process.cwd 是另一个 git 仓库
|
|
255
|
+
// (未 chdir 的脚本、或 CLI 在别处跑)时,worktreeBase 会错解析到 process.cwd 仓库,
|
|
256
|
+
// getMeta 读错位置返回 null。resolve() 对绝对参数原样返回、对相对参数相对 this.cwd 解析,两者皆稳。
|
|
252
257
|
const commonDir = gitQuiet(this.cwd, 'rev-parse --git-common-dir');
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
if (st.isDirectory()) {
|
|
258
|
-
root = dirname(commonDir);
|
|
258
|
+
if (commonDir) {
|
|
259
|
+
const absCommonDir = resolve(this.cwd, commonDir);
|
|
260
|
+
if (existsSync(absCommonDir) && statSync(absCommonDir).isDirectory()) {
|
|
261
|
+
root = dirname(absCommonDir);
|
|
259
262
|
}
|
|
260
263
|
}
|
|
261
264
|
} catch (e) {
|
|
@@ -989,7 +992,12 @@ export class WorktreeManager {
|
|
|
989
992
|
}
|
|
990
993
|
|
|
991
994
|
/**
|
|
992
|
-
* 检查 worktree
|
|
995
|
+
* 检查 worktree 是否有尚未落到主工作区的交付变更。
|
|
996
|
+
*
|
|
997
|
+
* 语义:worktree 相对 baseline 的交付变更(tracked + untracked,排除 .sillyspec//meta.json)里,
|
|
998
|
+
* 哪些还没 byte-identical 出现在主工作区 HEAD。全部已在 main HEAD(含 cherry-pick/rebase/merge/apply
|
|
999
|
+
* 落地)→ hasChanges:false(调用方可安全 cleanup);否则 hasChanges:true(保留 worktree)。
|
|
1000
|
+
* 检测失败/拿不准 → 保守 hasChanges:true(防误删未落代码)。
|
|
993
1001
|
* @param {string} changeName
|
|
994
1002
|
* @returns {{ hasChanges: boolean, changedFiles: string[], reason?: string }}
|
|
995
1003
|
*/
|
|
@@ -1007,36 +1015,102 @@ export class WorktreeManager {
|
|
|
1007
1015
|
if (meta.mode === 'in-place-fallback') {
|
|
1008
1016
|
return { hasChanges: false, changedFiles: [], reason: 'in-place mode' };
|
|
1009
1017
|
}
|
|
1018
|
+
// native-worktree 是用户外部隔离环境,不纳入"未应用"判定(与 execute 完成路径一致)
|
|
1019
|
+
if (meta.mode === 'native-worktree') {
|
|
1020
|
+
return { hasChanges: false, changedFiles: [], reason: 'native-worktree (external)' };
|
|
1021
|
+
}
|
|
1010
1022
|
|
|
1011
1023
|
const diffBase = meta.baselineCommit || meta.baseHash;
|
|
1012
1024
|
if (!diffBase) {
|
|
1013
1025
|
return { hasChanges: false, changedFiles: [], reason: 'no diff base' };
|
|
1014
1026
|
}
|
|
1015
1027
|
|
|
1028
|
+
const isDeliverable = f => f && !f.startsWith('.sillyspec/') && f !== 'meta.json';
|
|
1016
1029
|
try {
|
|
1017
|
-
//
|
|
1018
|
-
const
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1030
|
+
// 1) 候选交付变更(worktree 工作区相对 diffBase)。--no-renames:rename 退化成 D+A,两侧文件都进集
|
|
1031
|
+
const tracked = (gitQuiet(worktreePath, `diff --no-renames --name-only ${diffBase}`) || '')
|
|
1032
|
+
.split('\n').filter(Boolean).filter(isDeliverable);
|
|
1033
|
+
const untracked = (gitQuiet(worktreePath, `ls-files --others --exclude-standard`) || '')
|
|
1034
|
+
.split('\n').filter(Boolean).filter(isDeliverable);
|
|
1035
|
+
|
|
1036
|
+
if (tracked.length === 0 && untracked.length === 0) {
|
|
1037
|
+
return { hasChanges: false, changedFiles: [], reason: 'no changes in worktree' };
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// 2) 候选集中尚未落到主工作区 HEAD 的子集
|
|
1041
|
+
const pending = this._changesAlreadyOnMain(worktreePath, tracked, untracked);
|
|
1042
|
+
if (pending.length === 0) {
|
|
1043
|
+
return { hasChanges: false, changedFiles: [], reason: 'all changes already on main HEAD' };
|
|
1044
|
+
}
|
|
1045
|
+
return { hasChanges: true, changedFiles: pending };
|
|
1046
|
+
} catch (e) {
|
|
1047
|
+
// 检测失败时保守处理:视为有变更,保留 worktree
|
|
1048
|
+
return { hasChanges: true, changedFiles: [], reason: `check failed: ${e.message}` };
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* 判定候选变更里哪些还没到主工作区 HEAD。
|
|
1054
|
+
* - tracked:`git -C worktree diff --no-renames --name-only <mainHead> -- <files>`,
|
|
1055
|
+
* 比较 worktree 工作区(含未提交)vs main HEAD,限定到候选集;空 = 已在 main HEAD。
|
|
1056
|
+
* - untracked:worktree `hash-object`(默认带 filter,与 tree blob 同口径)vs main `ls-tree HEAD` blob;
|
|
1057
|
+
* 不等 = 该新文件未在 main HEAD。HEAD-only,不查 main 工作区未提交副本(防误删)。
|
|
1058
|
+
* 三种 mode 共享主 repo 对象库,mainHead 可在 worktree cwd 解析。
|
|
1059
|
+
* @private
|
|
1060
|
+
* @param {string} worktreePath
|
|
1061
|
+
* @param {string[]} trackedFiles
|
|
1062
|
+
* @param {string[]} untrackedFiles
|
|
1063
|
+
* @returns {string[]} 尚未落到 main HEAD 的文件
|
|
1064
|
+
*/
|
|
1065
|
+
_changesAlreadyOnMain(worktreePath, trackedFiles, untrackedFiles) {
|
|
1066
|
+
const mainHead = git(this.cwd, 'rev-parse HEAD'); // 失败即抛 → 外层 catch fail-safe
|
|
1067
|
+
const pending = [];
|
|
1068
|
+
|
|
1069
|
+
if (trackedFiles.length > 0) {
|
|
1070
|
+
const diverged = (gitQuiet(worktreePath,
|
|
1071
|
+
`diff --no-renames --name-only ${mainHead} -- ${trackedFiles.join(' ')}`) || '')
|
|
1072
|
+
.split('\n').filter(Boolean);
|
|
1073
|
+
pending.push(...diverged);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
if (untrackedFiles.length > 0) {
|
|
1077
|
+
// hash-object 按 argv 顺序逐行输出 blob hash;某文件不存在则整命令失败 → gitQuiet 返回 null
|
|
1078
|
+
const wtHashes = (gitQuiet(worktreePath, `hash-object -- ${untrackedFiles.join(' ')}`) || '')
|
|
1079
|
+
.split('\n');
|
|
1080
|
+
const mainBlobs = this._lsTreeBlobs(this.cwd, 'HEAD', untrackedFiles);
|
|
1081
|
+
for (let i = 0; i < untrackedFiles.length; i++) {
|
|
1082
|
+
// wtHashes[i] 缺失(命令失败/行数不齐)→ 视为未应用,保守保留
|
|
1083
|
+
if (wtHashes[i] !== (mainBlobs.get(untrackedFiles[i]) ?? null)) {
|
|
1084
|
+
pending.push(untrackedFiles[i]);
|
|
1025
1085
|
}
|
|
1026
1086
|
}
|
|
1087
|
+
}
|
|
1027
1088
|
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
const untrackedFiles = untrackedRaw
|
|
1031
|
-
? untrackedRaw.split('\n').filter(Boolean).filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json')
|
|
1032
|
-
: [];
|
|
1089
|
+
return [...new Set(pending)];
|
|
1090
|
+
}
|
|
1033
1091
|
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1092
|
+
/**
|
|
1093
|
+
* 一次 `git ls-tree <treeish> -- <files>` → Map<path, blobHash>(仅 tree 中存在者)。
|
|
1094
|
+
* 与 worktree-apply.js 的 getBlobHashMap 同逻辑;此处内联私有,避免反向 import 循环依赖。
|
|
1095
|
+
* @private
|
|
1096
|
+
* @param {string} cwd
|
|
1097
|
+
* @param {string} treeish
|
|
1098
|
+
* @param {string[]} files
|
|
1099
|
+
* @returns {Map<string, string>}
|
|
1100
|
+
*/
|
|
1101
|
+
_lsTreeBlobs(cwd, treeish, files) {
|
|
1102
|
+
const map = new Map();
|
|
1103
|
+
if (!files || files.length === 0) return map; // 空 pathspec 会列整棵树,必须拦
|
|
1104
|
+
const raw = gitQuiet(cwd, `ls-tree ${treeish} -- ${files.join(' ')}`);
|
|
1105
|
+
if (!raw) return map;
|
|
1106
|
+
for (const line of raw.split('\n')) {
|
|
1107
|
+
if (!line) continue;
|
|
1108
|
+
const tab = line.indexOf('\t');
|
|
1109
|
+
if (tab === -1) continue;
|
|
1110
|
+
const hash = line.slice(0, tab).split(' ')[2]; // "<mode> <type> <hash>"
|
|
1111
|
+
if (hash) map.set(line.slice(tab + 1), hash);
|
|
1039
1112
|
}
|
|
1113
|
+
return map;
|
|
1040
1114
|
}
|
|
1041
1115
|
|
|
1042
1116
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
### 自动探针(必须先执行)
|
|
2
|
-
|
|
2
|
+
在检查前,依次运行以下六个探针,将结果作为验证输入:
|
|
3
3
|
|
|
4
4
|
**探针 1:未实现标记扫描(仅变更文件)**
|
|
5
5
|
只扫描本次变更涉及的文件,不要全项目扫描——历史 TODO 与本次变更无关,徒增噪音与 token。变更文件 = design.md「文件变更清单」列出的源码文件(你已在「加载规范」步骤读取 design.md,glob 路径需展开为具体文件):
|
|
@@ -18,7 +18,7 @@ grep -rl "<关键词>" <源码目录>/ --include="*.java" --include="*.js" --inc
|
|
|
18
18
|
|
|
19
19
|
**探针 3:验收标准测试覆盖探针**
|
|
20
20
|
1. 读取变更目录下的 tasks.md,提取所有 checkbox 任务
|
|
21
|
-
2. 对每个 task
|
|
21
|
+
2. 对每个 task,**递归**检查对应模块目录及其**所有子目录**下是否存在测试文件(*test*、*spec*、*Test*、*Spec*)——包括模块内 co-located 的 tests/ 目录(如 `backend/app/modules/*/tests/`),**不要只看顶层 `tests/`**,否则会把测试就在模块内部的项目误判为“无测试”
|
|
22
22
|
3. 没有测试文件的 task 标记为 ⚠️ 缺少测试
|
|
23
23
|
|
|
24
24
|
**探针 4:决策追踪覆盖探针(如存在 decisions.md)**
|
|
@@ -51,3 +51,14 @@ grep -rl "<关键词>" <源码目录>/ --include="*.java" --include="*.js" --inc
|
|
|
51
51
|
| ❌ missing | GET /api/ppm/project-plan/{param}/plan-nodes | — | frontend/src/lib/ppm/plan.ts |
|
|
52
52
|
|
|
53
53
|
如果发现 Missing backend endpoint,必须在验证报告中标记为 ❌ contract gap。
|
|
54
|
+
|
|
55
|
+
**探针 6:代码删除对账(切斯特顿栅栏护栏)**
|
|
56
|
+
静默删除代码是 verify 的盲区——agent 删一段它看不懂的旧代码,只要路径合规、不碰风险关键词,其他探针都不会响。用 git 事实客观对账,不要凭记忆:
|
|
57
|
+
1. 运行 `git diff --name-status HEAD`(本次变更已 apply 到主仓、未 commit,删除的文件在工作树消失但仍在 HEAD,显示 `D`)
|
|
58
|
+
2. 筛出状态以 `D` 开头的行(删除);`R`/`C` 开头的旧路径等价删除
|
|
59
|
+
3. 对每个删除文件,核对 design.md「文件变更清单」声明的操作:
|
|
60
|
+
- design 声明「删除」→ ✅ 合规(预期删除)
|
|
61
|
+
- design 声明「新增/修改」却整文件删除 → ❌ 高风险(声明与事实矛盾)
|
|
62
|
+
- design 清单未列出 → ⚠️ 未声明删除
|
|
63
|
+
4. 排除 `.sillyspec/` 与 `meta.json`(文档 churn 不算删除信号)
|
|
64
|
+
5. verify --done 时 CLI 会用同一 git 事实独立复核,谎报无效——务必如实记录
|