sillyspec 3.28.8 → 3.28.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/full.json +1 -0
- package/.claude/skills/sillyspec-execute/SKILL.md +222 -224
- package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
- package/.claude/skills/sillyspec-quick/SKILL.md +118 -117
- package/AGENTS.md +3 -2
- package/README.md +35 -11
- package/SKILL.md +24 -10
- package/SillySpec-/350/203/275/345/212/233/344/272/256/347/202/271/345/205/250/346/231/257-2026-09-14.pptx +0 -0
- package/package.json +1 -1
- package/src/change-list.js +73 -20
- package/src/change-risk-profile.js +20 -0
- package/src/config-schema.js +62 -0
- package/src/contract-matrix.js +84 -5
- package/src/cross-repo-reconcile.js +134 -0
- package/src/db.js +11 -3
- package/src/decision-distill.js +15 -3
- package/src/design-facts.js +89 -20
- package/src/docs-gate.js +105 -6
- package/src/doctor-diagnostics.js +224 -2
- package/src/friction-ledger.js +171 -0
- package/src/hooks/worktree-guard.js +13 -1
- package/src/index.js +206 -18
- package/src/knowledge-classify.js +382 -0
- package/src/knowledge-hits.js +74 -0
- package/src/knowledge-stats.js +180 -0
- package/src/probe7-anchor-check.js +69 -0
- package/src/progress/change-registry.js +183 -8
- package/src/progress/shared.js +3 -2
- package/src/progress.js +100 -8
- package/src/quick-gate-profile.js +251 -0
- package/src/quicklog.js +164 -18
- package/src/review-dispatch.js +2 -1
- package/src/run/command.js +131 -13
- package/src/run/complete-handlers.js +511 -21
- package/src/run/complete.js +123 -23
- package/src/run/concurrent-detect.js +98 -0
- package/src/run/gate-snapshot.js +110 -2
- package/src/run/gates.js +141 -16
- package/src/run/prompt.js +221 -11
- package/src/run/quick-audit.js +111 -13
- package/src/run/shared.js +147 -11
- package/src/run/stage.js +6 -0
- package/src/scan-diff.js +61 -30
- package/src/scan-postcheck.js +74 -0
- package/src/scan-refresh.js +477 -0
- package/src/scan-staleness.js +25 -13
- package/src/scope-audit.js +456 -56
- package/src/stage-contract.js +185 -2
- package/src/stage-review.js +23 -7
- package/src/stages/brainstorm.js +6 -0
- package/src/stages/cmd-existence.js +9 -3
- package/src/stages/execute.js +85 -3
- package/src/stages/knowledge.js +76 -6
- package/src/stages/plan-postcheck.js +32 -5
- package/src/stages/quick.js +1 -1
- package/src/stages/verify.js +1 -0
- package/src/sync-noise.js +170 -6
- package/src/sync.js +141 -9
- package/src/task-review.js +366 -46
- package/src/verify-postcheck.js +212 -55
- package/src/verify-probes.js +727 -16
- package/src/worktree-apply.js +685 -48
- package/src/worktree-deps.js +124 -3
- package/src/worktree.js +391 -15
- package/templates/agents-instruction.md +2 -2
- package/templates/prompts/testcase-design.md +2 -1
- package/templates/prompts/verify-probes.md +4 -1
- package/.patch16-tmp.mjs +0 -169
- package/sa-rename-probe/src/new.js +0 -4
package/src/worktree-apply.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* 1. 读取 meta.json 获取 baseHash
|
|
6
6
|
* 2. git diff --name-only baseHash 获取 worktree 中所有变更文件
|
|
7
7
|
* 3. 从 design.md 解析文件变更清单(无清单 = 允许所有)
|
|
8
|
-
* 4. 校验:变更文件 ⊆
|
|
8
|
+
* 4. 校验:变更文件 ⊆ 清单(review 声明与 allow 面相交过滤,D-003:外来声明不并入放行面)
|
|
9
9
|
* 5. 校验:主工作区文件 base hash 一致
|
|
10
10
|
* 6. --check-only 模式只输出检查结果
|
|
11
11
|
* 7. 非 checkOnly:生成 patch → apply --check → apply --3way
|
|
@@ -15,12 +15,15 @@
|
|
|
15
15
|
import { existsSync, unlinkSync, writeFileSync, mkdtempSync, rmSync, readdirSync, readFileSync, mkdirSync } from 'fs';
|
|
16
16
|
import { join, resolve } from 'path';
|
|
17
17
|
import { tmpdir } from 'os';
|
|
18
|
+
import { createHash } from 'crypto';
|
|
18
19
|
import { WorktreeManager } from './worktree.js';
|
|
19
20
|
import { getCrossWorktreeMeta, cleanupCrossWorktrees } from './worktree-cross.js';
|
|
20
21
|
import { parseFileChangeList, parseFileChangeListDetailed, pathMatches } from './change-list.js';
|
|
21
22
|
import { parseAllowedPaths, parseRepo } from './stages/plan-postcheck.js';
|
|
22
|
-
import { git, gitQuiet } from './git-helper.js';
|
|
23
|
+
import { git, gitQuiet, safeGit, unquoteGitPath } from './git-helper.js';
|
|
23
24
|
import { resolveLatestExecuteRunId, resolveLatestExecuteRunIdWithTasks, readReview, normalizeRepoKey } from './task-review.js';
|
|
25
|
+
import { collectActiveQuickGuardFiles } from './quicklog.js';
|
|
26
|
+
import { detectCommittedDrift, formatCommittedDriftWarning } from './run/concurrent-detect.js';
|
|
24
27
|
|
|
25
28
|
const CHANGES_REL = '.sillyspec/changes';
|
|
26
29
|
|
|
@@ -40,6 +43,40 @@ function consumeCleanupResult(cr, result) {
|
|
|
40
43
|
return cr;
|
|
41
44
|
}
|
|
42
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
|
+
|
|
43
80
|
/**
|
|
44
81
|
* 过滤掉 worktree 基础设施文件(非交付物),让 apply 只关心真正的变更产出:
|
|
45
82
|
* - meta.json:worktree 元数据,baseline commit 中被跟踪、working-tree 被 CLI 改写
|
|
@@ -51,14 +88,19 @@ function consumeCleanupResult(cr, result) {
|
|
|
51
88
|
* - .sillyspec/.runtime/:运行时产物(进度库/锁/review 产物,非源码)。
|
|
52
89
|
* - .sillyspec/quicklog/:quicklog 条目(worktree 进度,非交付物)。
|
|
53
90
|
* 保留 .sillyspec/docs/(dogfood 模块规范文档 = 交付物,apply 回主仓)。
|
|
54
|
-
*
|
|
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)一视同仁。
|
|
55
96
|
*/
|
|
56
97
|
export function filterDeliverableFiles(files) {
|
|
57
98
|
return files.filter(f =>
|
|
58
99
|
!f.startsWith('.sillyspec/changes/') &&
|
|
59
100
|
!f.startsWith('.sillyspec/.runtime/') &&
|
|
60
101
|
!f.startsWith('.sillyspec/quicklog/') &&
|
|
61
|
-
f !== 'meta.json'
|
|
102
|
+
f !== 'meta.json' &&
|
|
103
|
+
classifyToolScaffold(f) !== 'platform'
|
|
62
104
|
);
|
|
63
105
|
}
|
|
64
106
|
|
|
@@ -151,7 +193,133 @@ export function mergeDirtyOverlapThreeWay({ projectRoot, worktreePath, baseHash
|
|
|
151
193
|
} finally {
|
|
152
194
|
try { rmSync(tmpDir, { recursive: true, force: true }) } catch {}
|
|
153
195
|
}
|
|
154
|
-
|
|
196
|
+
// 批末统一暂存(task-01 / FR-01 / D-001@v1 前半,2026-09-14-apply-conflict-hardening):
|
|
197
|
+
// 本写回点曾是全链路唯一未暂存的自动写点——未暂存文件对主仓工作区级 git 操作
|
|
198
|
+
// (restore / clean / checkout)零免疫(§64 事故的实际缺口)。对全部 clean 写回文件
|
|
199
|
+
// 显式 pathspec git add(safeGit 数组形式不经 shell;chunkPaths 分批防 Windows argv
|
|
200
|
+
// 上限,批 D-③ 同款)。fail-open:add 失败不抛(合并成果保留),console.warn 留痕 +
|
|
201
|
+
// 返回 stagedOk:false——manifest 侧对该批文件自动回退 readFile+归一 approx 口径。
|
|
202
|
+
let stagedOk = true
|
|
203
|
+
if (merged.length > 0) {
|
|
204
|
+
for (const batch of chunkPaths(merged)) {
|
|
205
|
+
const r = safeGit(projectRoot, ['add', '--', ...batch])
|
|
206
|
+
if (r && r.error) {
|
|
207
|
+
stagedOk = false
|
|
208
|
+
console.warn(`⚠️ merge 写回批末 git add 失败(fail-open:${merged.length} 个合并文件成果保留,但未暂存对 git restore/clean 不免疫,请尽快人工 git add):${String(r.error).split('\n')[0]}`)
|
|
209
|
+
break
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return { merged, conflicts, stagedOk }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 字节级 CRLF→LF 归一(ql-20260915-001 修复①,mergeMismatchThreeWay 专用)。
|
|
218
|
+
*
|
|
219
|
+
* 动机:本仓 core.autocrlf=true(2026-09-15 实测),git checkout 出的 worktree 磁盘内容是
|
|
220
|
+
* CRLF、blob(git show 取出)是 LF——ours(blob LF) 与 theirs(disk CRLF) 混形喂 merge-file 会
|
|
221
|
+
* 逐行假冲突(每一行都呈现为两侧各改一次)。三输入统一 LF 域合并,写回后 git add 按
|
|
222
|
+
* autocrlf 属性归一回 blob 域(磁盘 LF 形态 git status 亦不报幻影改动)。
|
|
223
|
+
*
|
|
224
|
+
* 字节级替换(latin1 语义,不经 utf8 解码)保任意非 UTF-8 文本字节不被替换字符污染;含 NUL
|
|
225
|
+
* (二进制标志)返回 null——二进制不可文本合并,调用方按冲突处理。
|
|
226
|
+
*
|
|
227
|
+
* @param {Buffer} buf
|
|
228
|
+
* @returns {Buffer|null} 归一后内容;二进制返回 null
|
|
229
|
+
*/
|
|
230
|
+
function _toLfBuffer(buf) {
|
|
231
|
+
if (!buf || buf.length === 0) return buf
|
|
232
|
+
if (buf.indexOf(0) !== -1) return null // 二进制
|
|
233
|
+
if (buf.indexOf(Buffer.from('\r\n')) === -1) return buf
|
|
234
|
+
const out = []
|
|
235
|
+
for (let i = 0; i < buf.length; i++) {
|
|
236
|
+
if (buf[i] === 0x0d && i + 1 < buf.length && buf[i + 1] === 0x0a) { out.push(0x0a); i++ }
|
|
237
|
+
else out.push(buf[i])
|
|
238
|
+
}
|
|
239
|
+
return Buffer.from(out)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* EXCLUDE-MISMATCH 前置自动三方合并(ql-20260915-001 修复①,坑 apply-archived-mismatch-no-merge:
|
|
244
|
+
* 2026-09-14 用户实证归档后补 apply 时主仓文件已被并行会话提交推进 → rescue EXCLUDE-MISMATCH
|
|
245
|
+
* 跳过不落地,被迫手工 cp + 锚点合并)。
|
|
246
|
+
*
|
|
247
|
+
* 与 mergeDirtyOverlapThreeWay 同实现形态,base/ours 取源不同:
|
|
248
|
+
* - base = apply 锚点(baseHash 形参,调用方传 deliverableBase=baselineCommit||baseHash)blob
|
|
249
|
+
* - **ours = 主仓 HEAD blob(已提交推进态,非脏读——mismatch 面主仓无未提交改动,
|
|
250
|
+
* 调用方已按 pre-stash dirty 集排除,HEAD 即主仓当前态)**
|
|
251
|
+
* - theirs = worktree 文件内容(磁盘读,子代理未 commit 的交付)
|
|
252
|
+
*
|
|
253
|
+
* clean 合并才写回主仓(两侧增量都保留)+ 批末 git add(chunkPaths 分批)+ 调用方从
|
|
254
|
+
* changedFiles/hashMismatchFiles 剔除(patch 不再重放,防 staged 内容与 patch 预像互踩);
|
|
255
|
+
* conflict / 异常 fail-soft 维持原路径(rescue EXCLUDE-MISMATCH 新文案 / step7 --3way 实测)。
|
|
256
|
+
* 覆写前备份主仓磁盘原文至 .sillyspec/.runtime/merge-backups/(与脏读版同一惯例)。
|
|
257
|
+
*
|
|
258
|
+
* @param {{ projectRoot: string, worktreePath: string, baseHash: string }} ctx
|
|
259
|
+
* @param {string[]} mismatchFiles mismatch ∩ changedFiles 且主仓无未提交改动的文件(仓库根相对路径)
|
|
260
|
+
* @returns {{ merged: string[], conflicts: string[], stagedOk: boolean }}
|
|
261
|
+
*/
|
|
262
|
+
export function mergeMismatchThreeWay({ projectRoot, worktreePath, baseHash }, mismatchFiles) {
|
|
263
|
+
const merged = []
|
|
264
|
+
const conflicts = []
|
|
265
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'sillyspec-3way-m-'))
|
|
266
|
+
try {
|
|
267
|
+
for (const f of mismatchFiles) {
|
|
268
|
+
try {
|
|
269
|
+
// ours/HEAD 缺失(主干侧删除等 modify/delete 形态)→ git show 抛错 → conflicts(fail-soft,
|
|
270
|
+
// 删除语义 merge-file 无法表达,交既有出口 --3way/--merge 裁决)
|
|
271
|
+
const baseRaw = git(projectRoot, ['show', `${baseHash}:${f}`], { encoding: 'buffer', timeout: 15000 })
|
|
272
|
+
const oursRaw = git(projectRoot, ['show', `HEAD:${f}`], { encoding: 'buffer', timeout: 15000 })
|
|
273
|
+
const wtRaw = readFileSync(join(worktreePath, f))
|
|
274
|
+
const baseN = _toLfBuffer(baseRaw)
|
|
275
|
+
const oursN = _toLfBuffer(oursRaw)
|
|
276
|
+
const theirsN = _toLfBuffer(wtRaw)
|
|
277
|
+
if (!baseN || !oursN || !theirsN) { conflicts.push(f); continue } // 二进制 → 原路径
|
|
278
|
+
const p = (tag) => join(tmpDir, `${tag}-${Math.random().toString(36).slice(2, 8)}`)
|
|
279
|
+
const baseP = p('base'); const oursP = p('ours'); const theirsP = p('theirs')
|
|
280
|
+
writeFileSync(baseP, baseN); writeFileSync(oursP, oursN); writeFileSync(theirsP, theirsN)
|
|
281
|
+
let outBuf, code = 0
|
|
282
|
+
try {
|
|
283
|
+
outBuf = git(projectRoot, ['merge-file', '-p', oursP, baseP, theirsP], { encoding: 'buffer', timeout: 15000 })
|
|
284
|
+
} catch (e) {
|
|
285
|
+
code = (typeof e.status === 'number') ? e.status : -1
|
|
286
|
+
outBuf = (e.stdout instanceof Buffer) ? e.stdout : Buffer.alloc(0)
|
|
287
|
+
if (code < 0) { conflicts.push(f); continue }
|
|
288
|
+
}
|
|
289
|
+
if (code === 0) {
|
|
290
|
+
// 覆写前备份主仓磁盘原文(merge-backups 惯例;ours=HEAD 理论可经 git show 重取,仍留备份保审计)
|
|
291
|
+
try {
|
|
292
|
+
const backupDir = join(projectRoot, '.sillyspec', '.runtime', 'merge-backups')
|
|
293
|
+
mkdirSync(backupDir, { recursive: true })
|
|
294
|
+
let pre = oursRaw
|
|
295
|
+
try { pre = readFileSync(join(projectRoot, f)) } catch { /* 磁盘缺文件用 HEAD 原文 */ }
|
|
296
|
+
writeFileSync(join(backupDir, `${Date.now()}-${f.replace(/[\\/]/g, '__')}`), pre)
|
|
297
|
+
} catch { /* 备份失败不阻断合并 */ }
|
|
298
|
+
writeFileSync(join(projectRoot, f), outBuf)
|
|
299
|
+
merged.push(f)
|
|
300
|
+
console.log(`🔀 EXCLUDE-MISMATCH 三方合并成功(clean): ${f}——主仓已提交推进与 worktree 交付均已保留(覆写前原文已备份至 .sillyspec/.runtime/merge-backups/)`)
|
|
301
|
+
} else {
|
|
302
|
+
conflicts.push(f)
|
|
303
|
+
}
|
|
304
|
+
} catch { conflicts.push(f) }
|
|
305
|
+
}
|
|
306
|
+
} finally {
|
|
307
|
+
try { rmSync(tmpDir, { recursive: true, force: true }) } catch {}
|
|
308
|
+
}
|
|
309
|
+
// 批末统一暂存(与 mergeDirtyOverlapThreeWay 同款:safeGit 数组形式 + chunkPaths 分批,
|
|
310
|
+
// fail-open:add 失败不抛、stagedOk:false 供调用方 warning 提示人工补 add)
|
|
311
|
+
let stagedOk = true
|
|
312
|
+
if (merged.length > 0) {
|
|
313
|
+
for (const batch of chunkPaths(merged)) {
|
|
314
|
+
const r = safeGit(projectRoot, ['add', '--', ...batch])
|
|
315
|
+
if (r && r.error) {
|
|
316
|
+
stagedOk = false
|
|
317
|
+
console.warn(`⚠️ mismatch merge 写回批末 git add 失败(fail-open:${merged.length} 个合并文件成果保留,但未暂存对 git restore/clean 不免疫,请尽快人工 git add):${String(r.error).split('\n')[0]}`)
|
|
318
|
+
break
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return { merged, conflicts, stagedOk }
|
|
155
323
|
}
|
|
156
324
|
|
|
157
325
|
export function generateRescueCommands({ changedFiles, dirtyFiles, hashMismatchFiles, deletedFiles = [], worktreePath, projectRoot }) {
|
|
@@ -177,7 +345,9 @@ export function generateRescueCommands({ changedFiles, dirtyFiles, hashMismatchF
|
|
|
177
345
|
continue;
|
|
178
346
|
}
|
|
179
347
|
if (mismatchSet.has(f)) {
|
|
180
|
-
|
|
348
|
+
// ql-20260915-001 修复①:文案更新——前置三方合并已尝试过(4.5b),此处剩的是合并冲突面,
|
|
349
|
+
// 给出两条实际出路(手动锚点合并 / 先 commit 后 --3way),不再误导「正常 apply 走 --3way」即可解
|
|
350
|
+
warnings.push(`跳过 ${f}:EXCLUDE-MISMATCH(主干已提交推进该文件,已尝试自动三方合并,冲突——手动锚点合并,或先 commit 主仓未提交改动后正常 apply 走 --3way)`);
|
|
181
351
|
excludedCount++;
|
|
182
352
|
continue;
|
|
183
353
|
}
|
|
@@ -185,6 +355,15 @@ export function generateRescueCommands({ changedFiles, dirtyFiles, hashMismatchF
|
|
|
185
355
|
cpFileCount++;
|
|
186
356
|
}
|
|
187
357
|
|
|
358
|
+
// D-003@v1(task-01,2026-09-14-apply-conflict-hardening):rescue 是人工落地路径,落地文件
|
|
359
|
+
// 不进 manifest 也不自动暂存——主仓工作区级 git 操作(restore/clean/checkout)可静默冲掉
|
|
360
|
+
// 未暂存落地(§64 事故实际路径)。commands 末尾追加一行「落地后立即 git add 锁定」指引
|
|
361
|
+
// (# 注释形态,整块复制粘贴仍是合法 shell;纯文案,commands/warnings 结构语义与
|
|
362
|
+
// cpFileCount/excludedCount 口径不变;commands 为空 = 无可落地文件,不加指引)。
|
|
363
|
+
if (commands.length > 0) {
|
|
364
|
+
commands.push(`# 全部执行后立即 git add -- <上方 cp 落地的各文件路径> 锁定暂存区(staged 对 git restore / git clean 免疫,防止并行会话的工作区级操作冲掉刚落地的文件;rm 删除的文件无需 add)`);
|
|
365
|
+
}
|
|
366
|
+
|
|
188
367
|
return { commands, warnings, cpFileCount, excludedCount };
|
|
189
368
|
}
|
|
190
369
|
|
|
@@ -284,6 +463,69 @@ export function chunkPaths(paths, maxChars = 8000) {
|
|
|
284
463
|
return batches
|
|
285
464
|
}
|
|
286
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
|
+
|
|
287
529
|
/**
|
|
288
530
|
* apply 的文件清单 = design §6 清单 ∪ plan TaskCard allowed_paths(execute 复盘 c)。
|
|
289
531
|
*
|
|
@@ -374,14 +616,127 @@ function _silentPointerSpecRoot(projectRoot) {
|
|
|
374
616
|
} catch { return null; }
|
|
375
617
|
}
|
|
376
618
|
|
|
619
|
+
/**
|
|
620
|
+
* apply 成功尾声写 apply-manifest.json(task-01 / FR-02 / D-001@v1 后半,
|
|
621
|
+
* 2026-09-14-apply-conflict-hardening)。
|
|
622
|
+
*
|
|
623
|
+
* 目的:apply 后交付文件在主仓被并行会话的工作区级 git 操作冲掉(新文件删除/修改回退)时
|
|
624
|
+
* 靠指纹可检测(doctor 漂移检查项消费,Wave 3)。CLI 全权写(agent 勿手改,verify-facts
|
|
625
|
+
* 同款契约);rescue 人工落地文件不进 manifest(design 非目标边界)。
|
|
626
|
+
*
|
|
627
|
+
* 哈希口径(design 接口定义定案,AGENTS.md 规则 13 Windows 兼容):**staged blob 内容**
|
|
628
|
+
* (git show :<path>,autocrlf 归一后的 LF 规范态)——写侧在 git add / apply --3way(隐含
|
|
629
|
+
* --index)之后取值,与 doctor 的 staged 态比对天然同基。文件未进暂存(如 merge 写回批末
|
|
630
|
+
* add 失败的 fail-open 面)→ 回退 readFile 后 CRLF→LF 归一计算(latin1 往返保字节),条目
|
|
631
|
+
* 加 approx:true 标记;staged 与磁盘皆无(apply 删除的文件——无内容可指纹)→ 不进 manifest。
|
|
632
|
+
*
|
|
633
|
+
* 落点:<changeDir>/apply-manifest.json(resolveActiveOrArchiveChangeDir 归档感知——与
|
|
634
|
+
* resolveApplyAllowSet 同源,归档后补 apply 的重放写在归档目录)。已存在则覆盖(重放 apply
|
|
635
|
+
* 以最新为准)。manifest 落 .sillyspec/changes/ 面 → filterDeliverableFiles 排除,不构成
|
|
636
|
+
* apply 交付物、不进自身 files。
|
|
637
|
+
*
|
|
638
|
+
* 本函数自身不 catch 写失败(交调用方 fail-open warning);files 为空时跳过不写(无落盘面
|
|
639
|
+
* 的成功出口不产 manifest,doctor 按存在性跳过)。
|
|
640
|
+
*
|
|
641
|
+
* @param {{ projectRoot: string, specBase?: string, changeName: string, baseHash?: string, files: string[] }} args
|
|
642
|
+
* specBase 缺省时与 resolveApplyAllowSet 同链回退(指针 specRoot > 本地 .sillyspec);
|
|
643
|
+
* baseHash 记 apply 锚点(deliverableBase 语义:baselineCommit || baseHash)
|
|
644
|
+
* @returns {{ written: boolean, file?: string, files: Array<{path:string, sha256:string, approx?:true}>, skipped?: string }}
|
|
645
|
+
*/
|
|
646
|
+
function writeApplyManifest({ projectRoot, specBase, changeName, baseHash, files }) {
|
|
647
|
+
const face = [...new Set((Array.isArray(files) ? files : []).filter(Boolean))];
|
|
648
|
+
if (face.length === 0) return { written: false, files: [], skipped: 'empty-files' };
|
|
649
|
+
const base = specBase || _silentPointerSpecRoot(projectRoot) || join(projectRoot, '.sillyspec');
|
|
650
|
+
const changeDir = resolveActiveOrArchiveChangeDir(base, changeName);
|
|
651
|
+
const entries = [];
|
|
652
|
+
for (const f of face) {
|
|
653
|
+
let sha256 = null;
|
|
654
|
+
let approx = false;
|
|
655
|
+
try {
|
|
656
|
+
// staged blob(LF 规范态):encoding buffer 保二进制原样;不在 index(删除/未 add)→ 抛错走回退
|
|
657
|
+
const blob = git(projectRoot, ['show', `:${f}`], { encoding: 'buffer', timeout: 15000 });
|
|
658
|
+
sha256 = createHash('sha256').update(blob).digest('hex');
|
|
659
|
+
} catch {
|
|
660
|
+
try {
|
|
661
|
+
const raw = readFileSync(join(projectRoot, f));
|
|
662
|
+
// CRLF→LF 归一(latin1 往返保字节,防 utf8 解码破坏二进制内容)
|
|
663
|
+
const norm = Buffer.from(raw.toString('latin1').replace(/\r\n/g, '\n'), 'latin1');
|
|
664
|
+
sha256 = createHash('sha256').update(norm).digest('hex');
|
|
665
|
+
approx = true;
|
|
666
|
+
} catch { continue; } // staged 与磁盘皆无(apply 删除面)→ 无内容可指纹,不进 manifest
|
|
667
|
+
}
|
|
668
|
+
entries.push(approx ? { path: f, sha256, approx: true } : { path: f, sha256 });
|
|
669
|
+
}
|
|
670
|
+
const manifest = {
|
|
671
|
+
schemaVersion: 1,
|
|
672
|
+
change: changeName,
|
|
673
|
+
appliedAt: new Date().toISOString(),
|
|
674
|
+
baseHash: baseHash || null,
|
|
675
|
+
files: entries,
|
|
676
|
+
};
|
|
677
|
+
mkdirSync(changeDir, { recursive: true });
|
|
678
|
+
const manifestPath = join(changeDir, 'apply-manifest.json');
|
|
679
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
680
|
+
return { written: true, file: manifestPath, files: entries };
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* 坑 mixed-baseline-drift-hint(ql-20260915-004):apply 成功尾声混合基线 advisory。
|
|
685
|
+
*
|
|
686
|
+
* 主仓自本变更基点(baseHash)后的已提交推进若触及本变更相关文件(含 .test./.spec. 变体),
|
|
687
|
+
* worktree 快照内验证过的测试断言可能基于旧内容——apply 落地的是混合基线,提示 agent 合并态
|
|
688
|
+
* 复跑相关测试(非阻断,result.committedDrift 留痕)。head 缺省 HEAD:patch 路径自身交付
|
|
689
|
+
* staged 未提交,baseHash..HEAD 即纯他者推进面;merge 路径传 preMergeHead(merge 提交后
|
|
690
|
+
* HEAD 含自身交付,不剔会把「自己合自己」误报成他者推进)。fail-soft:异常零影响 apply。
|
|
691
|
+
*/
|
|
692
|
+
function annotateCommittedDrift(result, { projectRoot, baseHash, files, head }) {
|
|
693
|
+
try {
|
|
694
|
+
if (!baseHash || !files || files.length === 0) return;
|
|
695
|
+
const drift = detectCommittedDrift({ projectRoot, baseHash, touchedFiles: files, head });
|
|
696
|
+
if (drift && drift.drift) {
|
|
697
|
+
result.committedDrift = drift;
|
|
698
|
+
console.warn(formatCommittedDriftWarning(drift));
|
|
699
|
+
}
|
|
700
|
+
} catch { /* fail-soft:drift 检测异常不影响 apply 结果 */ }
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* guard 相交预检(task-02 / FR-03 / D-002@v1,内部函数)。
|
|
705
|
+
*
|
|
706
|
+
* 活跃 quick 会话 guard.allowedFiles 与本次 apply 实际落盘集求交集——非空说明本变更文件正被
|
|
707
|
+
* 并行 quick 会话声明在途改动,此刻 apply 会互冲(2026-09-14 实证事故:apply 交付文件被并行
|
|
708
|
+
* 会话工作区 git 操作冲掉)。交集口径:字符串等值(guard.allowedFiles 与 changedFiles 同为
|
|
709
|
+
* 仓库根相对路径)。无活跃会话/无声明/交集空 → blocked=false(零行为变化)。
|
|
710
|
+
*
|
|
711
|
+
* @param {{ activeGuards: Map<string, string[]>, applyFiles: string[], selfChange?: string }} p
|
|
712
|
+
* - activeGuards:collectActiveQuickGuardFiles 产物(已按 excludeChange 排除自身)
|
|
713
|
+
* - applyFiles:changedFiles∪newPatchFiles(result 实际字段口径)
|
|
714
|
+
* - selfChange:防御性再排除(sessionId == 本次 changeName 不拦自身)
|
|
715
|
+
* @returns {{ overlaps: Array<{sessionId: string, file: string}>, blocked: boolean }}
|
|
716
|
+
*/
|
|
717
|
+
function checkGuardOverlap({ activeGuards, applyFiles, selfChange }) {
|
|
718
|
+
const overlaps = [];
|
|
719
|
+
const fileSet = new Set((applyFiles || []).filter(Boolean));
|
|
720
|
+
if (!activeGuards || fileSet.size === 0) return { overlaps, blocked: false };
|
|
721
|
+
for (const [sessionId, allowedFiles] of activeGuards) {
|
|
722
|
+
if (selfChange && sessionId === selfChange) continue;
|
|
723
|
+
for (const f of (allowedFiles || [])) {
|
|
724
|
+
if (fileSet.has(f)) overlaps.push({ sessionId, file: f });
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
return { overlaps, blocked: overlaps.length > 0 };
|
|
728
|
+
}
|
|
729
|
+
|
|
377
730
|
/**
|
|
378
731
|
* 收集最新 execute run 各 task review.json 的 changedFiles 声明 → Map<repoKey, string[]>。
|
|
379
732
|
*
|
|
380
733
|
* 坑 apply-undeclared-deviation-block(2026-08-24 用户反馈四期③):执行期有据越界文件
|
|
381
734
|
* (facade 转发/名单测试)不在 design §6 也不在 allowed_paths,Gate1 拦 apply 只能回改
|
|
382
735
|
* design.md。review.json changedFiles 是 reviewer 对实际改动的声明(Task Review Gate 的
|
|
383
|
-
* verifyReviewGitEvidence 已交叉校验其与真实 git diff
|
|
384
|
-
*
|
|
736
|
+
* verifyReviewGitEvidence 已交叉校验其与真实 git diff 相交)。D-003@v1 收紧(2026-09-14
|
|
737
|
+
* change-ownership-guards task-03):声明只与 allow 面相交(pathMatches 容差)才计入,
|
|
738
|
+
* 不相交的外来声明剔除出放行面、进 violations 报告行——本函数仍是声明收集口(口径不变),
|
|
739
|
+
* 相交过滤在主仓 3b/跨仓清单校验两消费点执行。口径与 complete-handlers 的
|
|
385
740
|
* collectExecuteChangedFiles 一致:change 戳归属 run、.sillyspec//meta.json 过滤、
|
|
386
741
|
* review.repo 切片(跨仓声明不进 main 集)。读不到 run/review → 空 Map(fail-closed 回退旧行为)。
|
|
387
742
|
*
|
|
@@ -510,9 +865,12 @@ function validateCrossRepoNoOp(ctx, projectRoot, changeName) {
|
|
|
510
865
|
* 可清,wm.cleanup 只作用于主仓 worktree)。主仓 task 走原 A5 完整 apply 不动(GOAL-2 单仓零回归)。
|
|
511
866
|
*
|
|
512
867
|
* @param {string} changeName - 变更名
|
|
513
|
-
* @param {{ cwd?: string, checkOnly?: boolean, merge?: boolean, ctx?: object }} opts
|
|
868
|
+
* @param {{ cwd?: string, checkOnly?: boolean, merge?: boolean, ctx?: object, force?: boolean, autoApply?: boolean }} opts
|
|
514
869
|
* - ctx:可选 MultiRepoContext(design §7.1)。缺省=单仓退化(仅主仓 apply,零行为变化,GOAL-2)。
|
|
515
870
|
* 提供 ctx 且含跨仓 entry 时,触发跨仓 no-op 校验(校验 review.head 真实 + 不 cleanup 跨仓)。
|
|
871
|
+
* - force(task-02 / FR-03):guard 相交预检命中时显式放行并留痕 result.overlapForced(仅人工 CLI --force)。
|
|
872
|
+
* - autoApply(task-02 / FR-03):assess 自动入口置 true——相交命中软跳过(result.overlapSkipped + warning,
|
|
873
|
+
* 不抛错不落盘);自动路径永不 force。
|
|
516
874
|
* @returns {{
|
|
517
875
|
* ok: boolean,
|
|
518
876
|
* changedFiles: string[],
|
|
@@ -583,7 +941,7 @@ export async function withMainRepoLock(projectRoot, changeName, purpose, fn, opt
|
|
|
583
941
|
*
|
|
584
942
|
* 对有 worktree meta 的跨仓仓(worktree-cross.js 创建)执行与主仓 A5 同构的 patch 回落:
|
|
585
943
|
* deliverables(diff baselineCommit||baseHash + untracked,filterDeliverableFiles 排除基础设施)
|
|
586
|
-
* → 清单校验(resolveApplyAllowSet 按 repo 切片
|
|
944
|
+
* → 清单校验(resolveApplyAllowSet 按 repo 切片 + review 声明相交过滤,D-003:不相交剔除)
|
|
587
945
|
* → 跨仓主工作副本 dirty 重叠拦截
|
|
588
946
|
* → patch(tracked diff + untracked add/diff --cached/reset,Buffer 防二进制损坏)
|
|
589
947
|
* → git apply --3way 回跨仓主工作副本;成功后 cleanup 该跨仓 worktree(分支保留作锚)。
|
|
@@ -623,7 +981,14 @@ function applyCrossRepoWorktrees(changeName, projectRoot, ctx, { checkOnly = fal
|
|
|
623
981
|
}
|
|
624
982
|
}
|
|
625
983
|
const untrackedRaw = gitQuiet(cm.worktreePath, ['ls-files', '--others', '--exclude-standard']) || '';
|
|
626
|
-
|
|
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
|
+
}
|
|
627
992
|
} catch (e) {
|
|
628
993
|
out.errors.push(`跨仓 ${repoKey}:获取变更文件列表失败(${cm.worktreePath}): ${e.message}`);
|
|
629
994
|
continue;
|
|
@@ -641,24 +1006,45 @@ function applyCrossRepoWorktrees(changeName, projectRoot, ctx, { checkOnly = fal
|
|
|
641
1006
|
continue;
|
|
642
1007
|
}
|
|
643
1008
|
|
|
644
|
-
// 清单校验(per-repo 切片 + review
|
|
1009
|
+
// 清单校验(per-repo 切片 + review 声明相交过滤,与主仓 Gate1/3b 同语义)。
|
|
645
1010
|
// specBase 显式传(平台模式 changes/ 在 specRoot;本地模式与旧硬编码同值零回归)。
|
|
1011
|
+
// review 声明收紧(D-003@v1,2026-09-14-change-ownership-guards task-03):声明文件须与
|
|
1012
|
+
// allow 面(design 清单 ∪ 各 task target_files/allowed_paths ∪ linked-change 声明 =
|
|
1013
|
+
// resolveApplyAllowSet 现有并集)相交(pathMatches 容差)才计入;不相交的外来声明剔除出
|
|
1014
|
+
// 放行面、进 violations 报告行(「review 声明了越权文件」嫌疑标注),admission 增量归零。
|
|
646
1015
|
const allowMap = resolveApplyAllowSet(projectRoot, changeName, { specBase });
|
|
647
|
-
|
|
1016
|
+
const allowSet = allowMap.get(repoKey) || new Set();
|
|
648
1017
|
const reviewDeclared = collectReviewDeclaredFiles(projectRoot, changeName, { runtimeRoot: join(specBase, '.runtime') }).get(repoKey);
|
|
649
|
-
|
|
650
|
-
|
|
1018
|
+
const reviewForeign = [];
|
|
1019
|
+
if (reviewDeclared && allowSet.size > 0) {
|
|
1020
|
+
const face = [...allowSet];
|
|
651
1021
|
for (const f of reviewDeclared) {
|
|
652
|
-
if (!
|
|
1022
|
+
if (!face.some(ap => pathMatches(f, ap))) reviewForeign.push(f);
|
|
653
1023
|
}
|
|
654
|
-
|
|
1024
|
+
}
|
|
1025
|
+
if (reviewForeign.length > 0) {
|
|
1026
|
+
out.warnings.push(
|
|
1027
|
+
`跨仓 ${repoKey}:${reviewForeign.length} 个 review 声明文件不在 allow 面(design 清单/任务卡 allowed_paths/linked 声明),已剔除出放行面(review 声明了越权文件——嫌疑:review 失实或并行会话在途文件混入声明):${reviewForeign.join(', ')}`
|
|
1028
|
+
);
|
|
655
1029
|
}
|
|
656
1030
|
if (allowSet.size > 0) {
|
|
657
1031
|
const violations = classifyAllowListViolations(changedFiles, allowSet);
|
|
658
1032
|
if (violations.length > 0) {
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
);
|
|
1033
|
+
const foreignSet = new Set(reviewForeign);
|
|
1034
|
+
const foreignViolations = violations.filter(f => foreignSet.has(f));
|
|
1035
|
+
const undeclaredViolations = violations.filter(f => !foreignSet.has(f));
|
|
1036
|
+
const lines = [];
|
|
1037
|
+
if (undeclaredViolations.length > 0) {
|
|
1038
|
+
lines.push(
|
|
1039
|
+
`跨仓 ${repoKey}:文件清单校验失败——以下变更文件不在 design.md 清单、也不在该仓 task review.json changedFiles 声明中:\n ${undeclaredViolations.join('\n ')}`
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
if (foreignViolations.length > 0) {
|
|
1043
|
+
lines.push(
|
|
1044
|
+
`跨仓 ${repoKey}:review 声明了越权文件——以下变更文件已被 review.json changedFiles 声明但不在 allow 面(design 清单/任务卡 allowed_paths/linked 声明),不予放行:\n ${foreignViolations.join('\n ')}\n(确属本变更交付:补进 design §6 清单或任务卡 allowed_paths 后重跑)`
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
out.errors.push(lines.join('\n'));
|
|
662
1048
|
if (!checkOnly) continue;
|
|
663
1049
|
}
|
|
664
1050
|
}
|
|
@@ -765,7 +1151,7 @@ function applyCrossRepoWorktrees(changeName, projectRoot, ctx, { checkOnly = fal
|
|
|
765
1151
|
return out;
|
|
766
1152
|
}
|
|
767
1153
|
|
|
768
|
-
export function applyWorktree(changeName, { cwd, checkOnly = false, merge = false, base = 'merge-base', ctx = null, skipOverlap = false, stashDirty = false } = {}) {
|
|
1154
|
+
export function applyWorktree(changeName, { cwd, checkOnly = false, merge = false, base = 'merge-base', ctx = null, skipOverlap = false, stashDirty = false, force = false, autoApply = false } = {}) {
|
|
769
1155
|
const projectRoot = cwd || process.cwd();
|
|
770
1156
|
const wm = new WorktreeManager({ cwd: projectRoot });
|
|
771
1157
|
const meta = wm.getMeta(changeName);
|
|
@@ -882,7 +1268,41 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
882
1268
|
// 排除 worktree 基础设施文件(meta.json / .sillyspec/,见 filterDeliverableFiles)。
|
|
883
1269
|
// 对 modified-tracked(statusFiles)与 untracked 一视同仁——否则 modified 的 meta.json
|
|
884
1270
|
// 会被误算入 changedFiles,触发 design.md 清单校验失败(assess 恒 BLOCKED)。
|
|
885
|
-
|
|
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
|
+
}
|
|
886
1306
|
} catch (e) {
|
|
887
1307
|
result.errors.push(`获取变更文件列表失败: ${e.message}`);
|
|
888
1308
|
return result;
|
|
@@ -912,27 +1332,30 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
912
1332
|
// (主仓 worktree diff 只含主仓文件,跨仓文件在跨仓仓不进主仓 worktree diff)。跨仓 allowed_paths
|
|
913
1333
|
// 仅作 Map 切片返回供上游用,主流程不消费(跨仓 apply=no-op,D-009)。
|
|
914
1334
|
const allowMap = resolveApplyAllowSet(projectRoot, changeName, { specBase: allowSpecBase });
|
|
915
|
-
// --- 3b. review.json
|
|
916
|
-
//
|
|
917
|
-
//
|
|
918
|
-
//
|
|
1335
|
+
// --- 3b. review.json 声明偏差文件相交过滤(坑 apply-undeclared-deviation-block → D-003@v1 收紧,
|
|
1336
|
+
// 2026-09-14-change-ownership-guards task-03 / FR-02)---
|
|
1337
|
+
// 旧语义:reviewer 声明的 changedFiles 整体并入 allow set(声明即放行)——troubleshooting §65
|
|
1338
|
+
// 事件②实证该通道无 allowed_paths 相交校验,11 个并行会话在途文件经 review 声明真实放行。
|
|
1339
|
+
// 新语义(D-003 翻转):review 声明只承认与 allow 面(design 清单 ∪ 各 task
|
|
1340
|
+
// target_files/allowed_paths ∪ linked-change 声明 = resolveApplyAllowSet 现有并集)相交的文件
|
|
1341
|
+
// (pathMatches 容差);不相交的外来声明剔除出放行面、进 violations 报告行(「review 声明了
|
|
1342
|
+
// 越权文件」嫌疑标注)——admission 增量归零,通道保留作审计报告位(外来声明显式列出)。
|
|
919
1343
|
const reviewDeclaredByRepo = collectReviewDeclaredFiles(projectRoot, changeName, { runtimeRoot: allowRuntimeRoot });
|
|
920
|
-
const
|
|
1344
|
+
const reviewForeignMain = [];
|
|
921
1345
|
for (const [repoKey, files] of reviewDeclaredByRepo) {
|
|
922
1346
|
const repoSet = allowMap.get(repoKey);
|
|
923
|
-
if (!repoSet) continue; // 该 repo 无 design
|
|
924
|
-
const
|
|
1347
|
+
if (!repoSet || repoSet.size === 0) continue; // 该 repo 无 design/任务卡清单(无清单=不校验),无相交可判
|
|
1348
|
+
const face = [...repoSet];
|
|
925
1349
|
for (const f of files) {
|
|
926
|
-
if (!
|
|
927
|
-
|
|
928
|
-
if (repoKey === 'main') reviewAdmittedFiles.push(f);
|
|
1350
|
+
if (!face.some(ap => pathMatches(f, ap))) {
|
|
1351
|
+
if (repoKey === 'main') reviewForeignMain.push(f);
|
|
929
1352
|
}
|
|
930
1353
|
}
|
|
931
1354
|
}
|
|
932
|
-
if (
|
|
933
|
-
result.
|
|
1355
|
+
if (reviewForeignMain.length > 0) {
|
|
1356
|
+
result.reviewOverdeclaredFiles = reviewForeignMain;
|
|
934
1357
|
result.warnings = (result.warnings || []).concat([
|
|
935
|
-
`${
|
|
1358
|
+
`${reviewForeignMain.length} 个 review 声明文件不在 allow 面(design 清单/任务卡 allowed_paths/linked 声明),已剔除出放行面(review 声明了越权文件——嫌疑:review 失实或并行会话在途文件混入声明):${reviewForeignMain.join(', ')};确属本变更交付请补进 design §6 清单或任务卡 allowed_paths`
|
|
936
1359
|
]);
|
|
937
1360
|
}
|
|
938
1361
|
const allowSet = allowMap.get('main') || new Set();
|
|
@@ -972,16 +1395,90 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
972
1395
|
if (hasAllowList) {
|
|
973
1396
|
const violations = classifyAllowListViolations(changedFiles, allowSet);
|
|
974
1397
|
if (violations.length > 0) {
|
|
1398
|
+
// D-003:violation 分流——已被 review 声明但不在 allow 面(越权声明)与完全未声明分报,
|
|
1399
|
+
// 前者带「review 声明了越权文件」嫌疑标注(外来声明剔除后的落地拦截报告行)。
|
|
1400
|
+
const foreignSet = new Set(result.reviewOverdeclaredFiles || []);
|
|
1401
|
+
const foreignViolations = violations.filter(f => foreignSet.has(f));
|
|
1402
|
+
const undeclaredViolations = violations.filter(f => !foreignSet.has(f));
|
|
975
1403
|
result.extraFiles.push(...violations);
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1404
|
+
const errLines = [];
|
|
1405
|
+
if (undeclaredViolations.length > 0) {
|
|
1406
|
+
errLines.push(
|
|
1407
|
+
`文件清单校验失败:以下变更文件不在 design.md 清单、也不在 task review.json changedFiles 声明中:\n ${undeclaredViolations.join('\n ')}\n(若属执行期合理偏差:在 design.md §6 清单或任务卡 allowed_paths 补行——review.json 的 changedFiles 声明已不再放行清单外文件,仅作审计对照)`
|
|
1408
|
+
);
|
|
1409
|
+
}
|
|
1410
|
+
if (foreignViolations.length > 0) {
|
|
1411
|
+
errLines.push(
|
|
1412
|
+
`review 声明了越权文件:以下变更文件已被 task review.json changedFiles 声明,但不在 allow 面(design 清单/任务卡 allowed_paths/linked 声明)——嫌疑:review 失实或并行会话在途文件混入声明,不予放行:\n ${foreignViolations.join('\n ')}\n(确属本变更交付:补进 design.md §6 清单或任务卡 allowed_paths 后重跑)`
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
result.errors.push(errLines.join('\n'));
|
|
979
1416
|
// checkOnly(assess)模式不短路:继续跑 Gate3,收集所有道供一次报全(坑 worktree-execute-apply-friction 坑4)。
|
|
980
1417
|
// 真实 apply(checkOnly=false)仍短路,保安全。
|
|
981
1418
|
if (!checkOnly) return result;
|
|
982
1419
|
}
|
|
983
1420
|
}
|
|
984
1421
|
|
|
1422
|
+
// --- 4.3 guard 相交预检(task-02 / FR-03 / D-002@v1):活跃 quick 会话 guard.allowedFiles ∩ 本次落盘集 ---
|
|
1423
|
+
// 内嵌 applyWorktree 主流程:CLI apply(index.js apply 分支)与 assess 自动 apply(index.js assess
|
|
1424
|
+
// 分支)全经此覆盖;真实 apply 由调用方 withMainRepoLock 包裹——判定与落盘同在锁内,无 TOCTOU。
|
|
1425
|
+
// 位置在一切主仓写动作之前(4.4 stash / 4.5 合并写回 / 4.6 --merge / step7 patch / ENOBUFS 降级)。
|
|
1426
|
+
// applyFiles=changedFiles∪newPatchFiles:newPatchFiles⊆patchFiles⊆changedFiles(resolvePatchFiles
|
|
1427
|
+
// 是 changedFiles 的过滤),并集即 changedFiles∪patchFiles,此处显式两集合并忠于 result 实际字段口径。
|
|
1428
|
+
// specBase/sessionsDir 复用上方 allowlist 同一解析链(allowSpecBase > 平台指针 > 本地),勿新造配置读取。
|
|
1429
|
+
// checkOnly 只读零写盘 → 跳过(零行为变化)。无活跃会话/无声明/交集空 → 全段空转零路径变化。
|
|
1430
|
+
if (!checkOnly) {
|
|
1431
|
+
const guardSpecBase = allowSpecBase || _silentPointerSpecRoot(projectRoot) || join(projectRoot, '.sillyspec');
|
|
1432
|
+
const guardSessionsDir = allowRuntimeRoot ? join(allowRuntimeRoot, 'quick-sessions') : undefined;
|
|
1433
|
+
const activeGuards = collectActiveQuickGuardFiles(guardSpecBase, { excludeChange: changeName, sessionsDir: guardSessionsDir });
|
|
1434
|
+
const guardApplyFiles = [...new Set([...changedFiles, ...resolvePatchFiles(changedFiles, allowSet, hasAllowList)])];
|
|
1435
|
+
const guardCheck = checkGuardOverlap({ activeGuards, applyFiles: guardApplyFiles, selfChange: changeName });
|
|
1436
|
+
if (guardCheck.blocked) {
|
|
1437
|
+
const sessions = [...new Set(guardCheck.overlaps.map(o => o.sessionId))];
|
|
1438
|
+
const files = [...new Set(guardCheck.overlaps.map(o => o.file))];
|
|
1439
|
+
const pairLines = guardCheck.overlaps.map(o => ` [${o.sessionId}] ${o.file}`);
|
|
1440
|
+
if (force) {
|
|
1441
|
+
// 显式解锁:放行并留痕(审计可见——自动路径永不带 force,overlapForced 只可能来自人工 --force)
|
|
1442
|
+
result.overlapForced = { sessions, files };
|
|
1443
|
+
result.warnings = (result.warnings || []).concat([
|
|
1444
|
+
`--force 越过 guard 相交预检:${guardCheck.overlaps.length} 个会话×文件对与活跃 quick 会话声明重叠——` +
|
|
1445
|
+
pairLines.join(';') + `(对方会话在途改动可能被覆盖,overlapForced 留痕)`
|
|
1446
|
+
]);
|
|
1447
|
+
} else if (autoApply) {
|
|
1448
|
+
// 软跳过:无人值守不越权(不落地互冲)也不阻断审计流——不抛错,指引人工评估
|
|
1449
|
+
result.overlapSkipped = true;
|
|
1450
|
+
result.warnings = (result.warnings || []).concat([
|
|
1451
|
+
`自动 apply 软跳过(未落盘):${files.length} 个文件与活跃 quick 会话在途声明重叠——` +
|
|
1452
|
+
pairLines.join(';') + `——请人工评估后显式 sillyspec worktree apply ${changeName}(确认无冲突),或等对方会话 --done 后重试`
|
|
1453
|
+
]);
|
|
1454
|
+
return result; // 安全返回:result.ok 保持 false,主仓零写动作(本预检位于一切写动作之前)
|
|
1455
|
+
} else {
|
|
1456
|
+
// 人工 CLI 入口(fail-closed):结构化错误含会话×文件对清单 + 串行化指引 + --force 提示
|
|
1457
|
+
result.errors.push(
|
|
1458
|
+
`guard 相交拦截:本次 apply 文件与 ${sessions.length} 个活跃 quick 会话的在途声明重叠(会话×文件对):\n` +
|
|
1459
|
+
pairLines.join('\n') +
|
|
1460
|
+
`\n串行化指引:等对方会话 sillyspec run quick --done 完成(guard 目录随之清理)后重试;` +
|
|
1461
|
+
`或确认无实际冲突后显式解锁:sillyspec worktree apply ${changeName} --force(overlapForced 留痕)`
|
|
1462
|
+
);
|
|
1463
|
+
return result; // index.js apply 分支对 result.errors 走 exit 1 展示
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
// ── ql-20260915-001 修复①:mismatch 前置合并的 dirty 排除集(4.4 stash 前取样)──
|
|
1469
|
+
// 4.5b 的 EXCLUDE-MISMATCH 前置合并只碰「主仓无未提交改动」的 mismatch 文件——dirty∩mismatch
|
|
1470
|
+
// 交 4.5 脏读合并处理(其 ours=主仓磁盘脏内容,已含已提交推进,语义更全)。排除集必须在
|
|
1471
|
+
// 4.4 --stash-dirty 之前取样:stash 会把脏文件收走,事后取 diff 恒空,会把在途文件误判成
|
|
1472
|
+
// 干净 mismatch 用 ours=HEAD 合并写回(stash 恢复时与合并写回互踩)。checkOnly 只读不合并,
|
|
1473
|
+
// 无 mismatch 时零开销,均不取样。untracked 不可能是 mismatch(不在 HEAD,mainBlob=null
|
|
1474
|
+
// 天然被合并函数的 ours=git show HEAD:f 失败挡住),此处只取 tracked-modified 口径即可。
|
|
1475
|
+
let preStashDirtySet = null
|
|
1476
|
+
if (!checkOnly && (result.hashMismatchFiles || []).length > 0) {
|
|
1477
|
+
preStashDirtySet = new Set(
|
|
1478
|
+
(gitQuiet(projectRoot, ['diff', '--name-only', 'HEAD']) || '').split('\n').filter(Boolean)
|
|
1479
|
+
)
|
|
1480
|
+
}
|
|
1481
|
+
|
|
985
1482
|
// --- 4.4 --stash-dirty:主仓在途改动自动 stash(坑 apply-main-dirty-no-first-class, ---
|
|
986
1483
|
// 2026-08-24 用户反馈四期①:主仓并行在途改动下默认 / --skip-overlap(全重叠「无可应用子集」)/
|
|
987
1484
|
// --merge(git 拒在脏树启动合并)三路死锁,手工 stash→3way→pop 流程未内置。flag 显式 opt-in
|
|
@@ -1023,7 +1520,45 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
1023
1520
|
// 用 --merge 时跳过未提交 dirty 拦截——merge 同样要求工作区相对干净,此处仅提示风险,真正失败由 applyByMerge 报告。
|
|
1024
1521
|
// keepConflicts:true——显式 --merge 冲突时保留现场供手工解决(不再直接 abort 丢上下文)。
|
|
1025
1522
|
if (merge && !checkOnly) {
|
|
1026
|
-
return applyByMerge(result, changeName, projectRoot, wm, { keepConflicts: true });
|
|
1523
|
+
return applyByMerge(result, changeName, projectRoot, wm, { keepConflicts: true, specBase: allowSpecBase });
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// --- 4.5b EXCLUDE-MISMATCH 前置三方合并(ql-20260915-001 修复①,坑 apply-archived-mismatch-no-merge,
|
|
1527
|
+
// 2026-09-14 用户实证:归档后补 apply,主仓已被并行会话提交推进 → rescue EXCLUDE-MISMATCH 跳过
|
|
1528
|
+
// 不落地,被迫手工 cp+锚点合并)---
|
|
1529
|
+
// 对 hashMismatch ∩ changedFiles 且主仓无未提交改动(pre-stash dirty 集排除,dirty 面交 4.5
|
|
1530
|
+
// 脏读合并)的文件逐个 merge-file:base=checkpoint(deliverableBase)blob、ours=主仓 HEAD blob、
|
|
1531
|
+
// theirs=worktree 文件。clean → 写回主仓 + 批末 add + mergedMismatchFiles 留痕 + 从
|
|
1532
|
+
// changedFiles/hashMismatchFiles/deletedFiles 剔除(后续 patch 不再重放——staged 合并内容若再进
|
|
1533
|
+
// patch,apply --3way 对 staged≠HEAD 的文件报 does not match index);conflict/异常 → 留原集走
|
|
1534
|
+
// 既有出口(rescue EXCLUDE-MISMATCH 新文案 / step7 --3way 实测 / --merge 兜底)。
|
|
1535
|
+
// checkOnly(assess 只读)零写盘不试合并;--merge 已在 4.6 早退不达此;位于 4.3 guard 预检
|
|
1536
|
+
// (一切主仓写动作之前)与 Gate1 清单校验之后,越权文件不会被合并落地。
|
|
1537
|
+
if (!checkOnly && (result.hashMismatchFiles || []).length > 0 && worktreePath && worktreePath !== projectRoot) {
|
|
1538
|
+
const mismatchCandidates = result.hashMismatchFiles.filter(f =>
|
|
1539
|
+
changedFiles.includes(f) && !(preStashDirtySet && preStashDirtySet.has(f)))
|
|
1540
|
+
if (mismatchCandidates.length > 0) {
|
|
1541
|
+
try {
|
|
1542
|
+
const m3m = mergeMismatchThreeWay({ projectRoot, worktreePath, baseHash: deliverableBase }, mismatchCandidates)
|
|
1543
|
+
if (m3m.merged.length > 0) {
|
|
1544
|
+
result.mergedMismatchFiles = (result.mergedMismatchFiles || []).concat(m3m.merged)
|
|
1545
|
+
if (m3m.stagedOk === false) {
|
|
1546
|
+
result.warnings = (result.warnings || []).concat([
|
|
1547
|
+
`EXCLUDE-MISMATCH 三方合并 ${m3m.merged.length} 个文件已写回主仓,但批末 git add 暂存失败(未暂存对 git restore / git clean 不免疫)——请尽快人工 git add -- <该批文件> 锁定`
|
|
1548
|
+
])
|
|
1549
|
+
}
|
|
1550
|
+
result.warnings = (result.warnings || []).concat([
|
|
1551
|
+
`EXCLUDE-MISMATCH 前置三方合并:${m3m.merged.length} 个主干已推进文件 clean 合并已写回并暂存(主仓已提交推进与 worktree 交付均保留,覆写前原文备份于 .sillyspec/.runtime/merge-backups/)`
|
|
1552
|
+
])
|
|
1553
|
+
const mergedMisSet = new Set(m3m.merged)
|
|
1554
|
+
changedFiles = changedFiles.filter(f => !mergedMisSet.has(f))
|
|
1555
|
+
result.changedFiles = changedFiles
|
|
1556
|
+
result.deletedFiles = deletedFiles.filter(f => !mergedMisSet.has(f))
|
|
1557
|
+
result.hashMismatchFiles = result.hashMismatchFiles.filter(f => !mergedMisSet.has(f))
|
|
1558
|
+
}
|
|
1559
|
+
// m3m.conflicts 面留在 result.hashMismatchFiles 原样(rescue 分类 / step7 --3way 既有路径)
|
|
1560
|
+
} catch { /* 合并链路异常 → mismatch 集原样,走既有出口(fail-soft 不阻断 apply) */ }
|
|
1561
|
+
}
|
|
1027
1562
|
}
|
|
1028
1563
|
|
|
1029
1564
|
// --- 4.5 校验:主工作区「未提交」脏文件是否与本次变更重叠(overlap-only 拦截)---
|
|
@@ -1052,10 +1587,14 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
1052
1587
|
// hash 必变,若仍比对会永久死锁(须手改 meta.baselineHash)。改判「排除规则下当前是否有未提交 dirty」。
|
|
1053
1588
|
const hasUncommittedDirty = staged !== '' || unstaged !== '' || untracked !== '';
|
|
1054
1589
|
if (hasUncommittedDirty) {
|
|
1590
|
+
// ql-20260915-001 修复①:4.5b 已合并写回并暂存的 mismatch 文件不出现在此脏清单——它们
|
|
1591
|
+
// 是本次 apply 自己的产物(非他者无关脏文件),列进「无关未提交文件」警告会误导。
|
|
1592
|
+
// 对 overlapDirty 无影响(合并文件已从 changedFiles 剔除,交集恒空);集合空时为恒等过滤。
|
|
1593
|
+
const mergedMisSet45 = new Set(result.mergedMismatchFiles || [])
|
|
1055
1594
|
const dirtyFiles = [...new Set(
|
|
1056
1595
|
((gitQuiet(projectRoot, ['diff', '--name-only', 'HEAD']) || '').split('\n').filter(Boolean))
|
|
1057
1596
|
.concat((gitQuiet(projectRoot, ['ls-files', '--others', '--exclude-standard']) || '').split('\n').filter(Boolean))
|
|
1058
|
-
)].filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json');
|
|
1597
|
+
)].filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json' && !mergedMisSet45.has(f));
|
|
1059
1598
|
const overlapDirty = dirtyFiles.filter(f => changedFiles.includes(f));
|
|
1060
1599
|
if (overlapDirty.length > 0) {
|
|
1061
1600
|
// 自动三方合并(坑 apply-dirty-block-no-merge,2026-09-10 驾驭小结第六批③):先对
|
|
@@ -1068,6 +1607,13 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
1068
1607
|
const m3 = mergeDirtyOverlapThreeWay({ projectRoot, worktreePath, baseHash: deliverableBase }, overlapDirty);
|
|
1069
1608
|
if (m3.merged.length > 0) {
|
|
1070
1609
|
result.mergedDirtyFiles = (result.mergedDirtyFiles || []).concat(m3.merged);
|
|
1610
|
+
if (m3.stagedOk === false) {
|
|
1611
|
+
// 批末 git add fail-open 留痕(task-01 / FR-01):写回成功但未暂存——result 面
|
|
1612
|
+
// 可见(console.warn 已在 mergeDirtyOverlapThreeWay 内打),提示人工补 add。
|
|
1613
|
+
result.warnings = (result.warnings || []).concat([
|
|
1614
|
+
`EXCLUDE-DIRTY 三方合并 ${m3.merged.length} 个文件已写回主仓,但批末 git add 暂存失败(未暂存对 git restore / git clean 不免疫)——请尽快人工 git add -- <该批文件> 锁定`
|
|
1615
|
+
]);
|
|
1616
|
+
}
|
|
1071
1617
|
const mergedSet = new Set(m3.merged);
|
|
1072
1618
|
changedFiles = changedFiles.filter(f => !mergedSet.has(f));
|
|
1073
1619
|
result.changedFiles = changedFiles;
|
|
@@ -1131,11 +1677,15 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
1131
1677
|
} else {
|
|
1132
1678
|
// 无关脏文件放行(只校验重叠文件):--3way 若因 autocrlf 报 does not match index 会被
|
|
1133
1679
|
// step7 catch 回滚(交集空前提下无损,见上注释),stash 后重试即可——不再硬挡 rescue 手动路径
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1680
|
+
// (ql-20260915-001 修复①:dirtyFiles 已剔除本次合并写回面,剔除后为空 = 探针命中的
|
|
1681
|
+
// 全是本 apply 自身 staged 产物——不打「0 个无关文件」的空警告)
|
|
1682
|
+
if (dirtyFiles.length > 0) {
|
|
1683
|
+
result.warnings = (result.warnings || []).concat([
|
|
1684
|
+
`主工作区有 ${dirtyFiles.length} 个与本次 apply 无关的未提交文件,已放行(只校验重叠文件):` +
|
|
1685
|
+
`${dirtyFiles.slice(0, 5).join(', ')}${dirtyFiles.length > 5 ? ' 等' : ''}` +
|
|
1686
|
+
`——若 apply --3way 因 CRLF/autocrlf 报 does not match index,可 git stash 后重试`
|
|
1687
|
+
])
|
|
1688
|
+
}
|
|
1139
1689
|
}
|
|
1140
1690
|
}
|
|
1141
1691
|
}
|
|
@@ -1259,6 +1809,26 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
1259
1809
|
if (patchContent.length === 0) {
|
|
1260
1810
|
// patch 为空(清单中部分文件可能没实际变更)
|
|
1261
1811
|
result.ok = true;
|
|
1812
|
+
// apply-manifest(task-01 出口③变体):4.5 三方合并全部消解重叠时,merge 写回面是
|
|
1813
|
+
// 本出口唯一落盘面——manifest 仍须覆盖(写回批末 add 已先行,指纹取 staged 态)。
|
|
1814
|
+
// ql-20260915-001 修复①:4.5b mismatch 前置合并同样可能消解全部 changedFiles(本出口
|
|
1815
|
+
// 另一来源),合并面并入 manifest files。
|
|
1816
|
+
const emptyPatchManifestFace = [...(result.mergedDirtyFiles || []), ...(result.mergedMismatchFiles || [])]
|
|
1817
|
+
if (emptyPatchManifestFace.length > 0) {
|
|
1818
|
+
try {
|
|
1819
|
+
const mf = writeApplyManifest({
|
|
1820
|
+
projectRoot, specBase: allowSpecBase, changeName,
|
|
1821
|
+
baseHash: deliverableBase, files: emptyPatchManifestFace,
|
|
1822
|
+
});
|
|
1823
|
+
if (mf && mf.written) result.applyManifest = mf.file;
|
|
1824
|
+
} catch (e) {
|
|
1825
|
+
result.warnings = (result.warnings || []).concat([
|
|
1826
|
+
`apply-manifest.json 写入失败(不影响 apply 结果;doctor 漂移检测将按无 manifest 跳过): ${(e.message || '').split('\n')[0]}`
|
|
1827
|
+
]);
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
// 坑 mixed-baseline-drift-hint(ql-20260915-004):三方合并消解型成功出口的混合基线提示
|
|
1831
|
+
annotateCommittedDrift(result, { projectRoot, baseHash: deliverableBase, files: emptyPatchManifestFace });
|
|
1262
1832
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
1263
1833
|
return result;
|
|
1264
1834
|
}
|
|
@@ -1309,12 +1879,43 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
1309
1879
|
|
|
1310
1880
|
result.ok = true;
|
|
1311
1881
|
|
|
1882
|
+
// --- 7.2 apply 成功尾声写 apply-manifest.json(task-01 / FR-02 / D-001@v1 后半)---
|
|
1883
|
+
// 三条成功出口之一(patch 主路径;含 4.5 三方合并写回后主流程继续至此的出口③)。指纹
|
|
1884
|
+
// 口径 = staged blob(git show :<path> 的 LF 规范态——apply --3way 隐含 --index 已暂存,
|
|
1885
|
+
// merge 写回批末 add 亦已先行)→ 与 doctor 漂移检测的 staged 态比对天然同基。files 面 =
|
|
1886
|
+
// patch 实际落盘集 ∪ 4.5 三方合并写回集(mergeDirtyFiles 已从 changedFiles 剔除,须并回);
|
|
1887
|
+
// apply 删除的文件 staged/磁盘皆无,函数内自然跳过。fail-open:写失败不阻断 apply
|
|
1888
|
+
// (warning 留痕)。已存在则覆盖(重放 apply 以最新为准)。锁内(调用方 withMainRepoLock)。
|
|
1889
|
+
// manifestFace 提升到 try 外(坑 mixed-baseline-drift-hint ql-20260915-004:drift 提示
|
|
1890
|
+
// 在 manifest 写点后消费同面,try 内 const 出块即失域)
|
|
1891
|
+
const manifestFace = [...new Set([...patchFiles, ...(result.mergedDirtyFiles || []), ...(result.mergedMismatchFiles || [])])];
|
|
1892
|
+
try {
|
|
1893
|
+
// ql-20260915-001 修复①:mergedMismatchFiles(4.5b 前置合并写回面)与 mergedDirtyFiles
|
|
1894
|
+
// 同为 staged 落盘集,一并进 manifest 指纹面与提交复用 pathspec(缺列 = doctor 漂移检测
|
|
1895
|
+
// 盲区 + agent 按 pathspec 提交漏文件)
|
|
1896
|
+
const mf = writeApplyManifest({
|
|
1897
|
+
projectRoot, specBase: allowSpecBase, changeName,
|
|
1898
|
+
baseHash: deliverableBase, files: manifestFace,
|
|
1899
|
+
});
|
|
1900
|
+
if (mf && mf.written) result.applyManifest = mf.file;
|
|
1901
|
+
} catch (e) {
|
|
1902
|
+
result.warnings = (result.warnings || []).concat([
|
|
1903
|
+
`apply-manifest.json 写入失败(不影响 apply 结果;doctor 漂移检测将按无 manifest 跳过): ${(e.message || '').split('\n')[0]}`
|
|
1904
|
+
]);
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
// 坑 mixed-baseline-drift-hint(ql-20260915-004):patch 主路径成功出口的混合基线提示
|
|
1908
|
+
//(自身交付 staged 未提交 → baseHash..HEAD 即纯他者推进面,精确/变体命中均为真信号)
|
|
1909
|
+
annotateCommittedDrift(result, { projectRoot, baseHash: deliverableBase, files: manifestFace });
|
|
1910
|
+
|
|
1312
1911
|
// --- 7.5 提交复用 pathspec(坑 apply-commit-pathspec-sweep,2026-08-21 实证)---
|
|
1313
1912
|
// apply 后主仓常混有无关未提交文件(他者会话/并行 quick),agent 习惯 `git add <目录>/`
|
|
1314
1913
|
// 会把无关文件扫进暂存需手工剔除。落盘本变更精确 pathspec 供提交直接复用:
|
|
1315
1914
|
// 文件 + result 字段(CLI 输出可照抄 git add -- … / 长清单用 --pathspec-from-file)。
|
|
1316
1915
|
try {
|
|
1317
|
-
|
|
1916
|
+
// ql-20260915-001 修复①:提交复用 pathspec 补并两个三方合并写回面(均为 staged 落盘、
|
|
1917
|
+
// 属本变更交付——缺列时 agent 按此 pathspec 提交会漏掉合并成果)
|
|
1918
|
+
const commitFiles = [...new Set([...patchFiles, ...(result.mergedDirtyFiles || []), ...(result.mergedMismatchFiles || [])])].sort().filter(Boolean);
|
|
1318
1919
|
result.commitPathspec = commitFiles;
|
|
1319
1920
|
const runtimeRoot = join(projectRoot, '.sillyspec', '.runtime');
|
|
1320
1921
|
mkdirSync(runtimeRoot, { recursive: true });
|
|
@@ -1361,7 +1962,7 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
1361
1962
|
);
|
|
1362
1963
|
try {
|
|
1363
1964
|
// keepConflicts:false——自动降级路径无人善后冲突状态,维持 abort 回滚干净态
|
|
1364
|
-
return applyByMerge(result, changeName, projectRoot, wm, { keepConflicts: false });
|
|
1965
|
+
return applyByMerge(result, changeName, projectRoot, wm, { keepConflicts: false, specBase: allowSpecBase });
|
|
1365
1966
|
} catch (mergeErr) {
|
|
1366
1967
|
result.errors.push(`ENOBUFS 降级 merge 也失败: ${mergeErr.message}`);
|
|
1367
1968
|
return result;
|
|
@@ -1715,8 +2316,11 @@ function preAlignBaselineToMain({ meta, branch, projectRoot, result }) {
|
|
|
1715
2316
|
// git add + git commit;或 git merge --abort 放弃)。worktree/分支/meta 本就保留。
|
|
1716
2317
|
// false(ENOBUFS 自动降级路径,默认)→ 维持 abort——自动降级无人善后冲突状态,回滚到
|
|
1717
2318
|
// 干净态让用户显式重来更安全。
|
|
2319
|
+
// opts.specBase(task-01,2026-09-14-apply-conflict-hardening):applyWorktree 调用方透传的
|
|
2320
|
+
// spec 基准目录(平台模式 ctx.platformOpts.specRoot),成功尾声 writeApplyManifest 用;缺省
|
|
2321
|
+
// 时函数内同链回退(指针 specRoot > 本地 .sillyspec)——外部直接调用零必填。
|
|
1718
2322
|
export function applyByMerge(result, changeName, projectRoot, wm, opts = {}) {
|
|
1719
|
-
const { keepConflicts = false } = opts;
|
|
2323
|
+
const { keepConflicts = false, specBase } = opts;
|
|
1720
2324
|
const meta = wm.getMeta(changeName);
|
|
1721
2325
|
const changedFiles = result.changedFiles || [];
|
|
1722
2326
|
// 用 meta.branch(native-worktree 模式分支名可能不是 sillyspec/<change>),不硬编码。
|
|
@@ -1729,6 +2333,10 @@ export function applyByMerge(result, changeName, projectRoot, wm, opts = {}) {
|
|
|
1729
2333
|
// 它跳过 dirty 文件(不覆盖 WIP)且只提交对齐集;WIP commit 在后,两者不混提交 ---
|
|
1730
2334
|
autoCommitWorktreeWip({ meta, result, changeName });
|
|
1731
2335
|
|
|
2336
|
+
// 坑 mixed-baseline-drift-hint(ql-20260915-004):merge 前 HEAD = 纯他者推进面锚点——
|
|
2337
|
+
// merge 提交后 HEAD 含自身交付,drift 检测须以 preMergeHead 为推进面终点(防自污染误报)
|
|
2338
|
+
const preMergeHead = gitQuiet(projectRoot, ['rev-parse', 'HEAD']);
|
|
2339
|
+
|
|
1732
2340
|
try {
|
|
1733
2341
|
git(projectRoot, ['merge', '--no-ff', branch], { timeout: 30000 });
|
|
1734
2342
|
} catch (e) {
|
|
@@ -1793,6 +2401,32 @@ export function applyByMerge(result, changeName, projectRoot, wm, opts = {}) {
|
|
|
1793
2401
|
}
|
|
1794
2402
|
|
|
1795
2403
|
result.ok = true;
|
|
2404
|
+
// apply 成功尾声写 apply-manifest.json(task-01 出口②:applyWorktree 显式 --merge 与
|
|
2405
|
+
// ENOBUFS 自动降级两条提前 return 入口共用本成功出口)。merge 已 commit → staged 态 =
|
|
2406
|
+
// HEAD = 合并内容,指纹口径(git show :<path> LF 规范态)与 patch 路径同基;files 面 =
|
|
2407
|
+
// changedFiles(落地校验过的交付集)∪ mergedDirtyFiles(ENOBUFS 路径下 4.5 可能已写回)
|
|
2408
|
+
// ∪ mergedMismatchFiles(ql-20260915-001 修复①:4.5b 前置合并写回面,同口径并入),
|
|
2409
|
+
// merge 删除面 staged/磁盘皆无、函数内自然跳过。fail-open:写失败不阻断 merge(warning 留痕)。
|
|
2410
|
+
// manifestFace 提升到 try 外(坑 mixed-baseline-drift-hint ql-20260915-004:drift 提示在
|
|
2411
|
+
// manifest 写点后消费同面,try 内 const 出块即失域)
|
|
2412
|
+
const manifestFace = [...new Set([...(result.changedFiles || []), ...(result.mergedDirtyFiles || []), ...(result.mergedMismatchFiles || [])])];
|
|
2413
|
+
try {
|
|
2414
|
+
const mf = writeApplyManifest({
|
|
2415
|
+
projectRoot, specBase, changeName,
|
|
2416
|
+
baseHash: meta.baselineCommit || meta.baseHash, files: manifestFace,
|
|
2417
|
+
});
|
|
2418
|
+
if (mf && mf.written) result.applyManifest = mf.file;
|
|
2419
|
+
} catch (e) {
|
|
2420
|
+
result.warnings = (result.warnings || []).concat([
|
|
2421
|
+
`apply-manifest.json 写入失败(不影响 merge 结果;doctor 漂移检测将按无 manifest 跳过): ${(e.message || '').split('\n')[0]}`
|
|
2422
|
+
]);
|
|
2423
|
+
}
|
|
2424
|
+
// 坑 mixed-baseline-drift-hint(ql-20260915-004):merge 成功出口的混合基线提示(推进面
|
|
2425
|
+
// 终点 = preMergeHead,剥离本 merge 自身交付,保精确命中为真信号)
|
|
2426
|
+
annotateCommittedDrift(result, {
|
|
2427
|
+
projectRoot, baseHash: meta.baselineCommit || meta.baseHash,
|
|
2428
|
+
files: manifestFace, head: preMergeHead || 'HEAD',
|
|
2429
|
+
});
|
|
1796
2430
|
try { result.mergeSummary = git(projectRoot, ['log', '--oneline', '-1']); } catch {}
|
|
1797
2431
|
try {
|
|
1798
2432
|
consumeCleanupResult(wm.cleanup(changeName, { force: true }), result);
|
|
@@ -1880,6 +2514,9 @@ export function assessApplyRisk(changeName, { cwd } = {}) {
|
|
|
1880
2514
|
);
|
|
1881
2515
|
// review.json 声明偏差文件(坑 apply-undeclared-deviation-block):与顺带修复同等待遇——
|
|
1882
2516
|
// reviewer 声明(已过 git 证据校验)的执行期偏差豁免 allowed_paths 严格校验,降 warning 注明来源。
|
|
2517
|
+
// D-003@v1(task-03)后本豁免仅作用于 Gate2 的 allowed_paths 归属道——allow 面(design∪task
|
|
2518
|
+
// 清单∪linked)之外的外来声明已在 Gate1(applyWorktree 3b 相交过滤)被剔除拦截,assess 整体
|
|
2519
|
+
// 判定以 Gate1 收集的 errors 为准(BLOCKED);两道口径不再重叠放行。
|
|
1883
2520
|
const reviewDeclaredSet = new Set(collectReviewDeclaredFiles(projectRoot, changeName, { runtimeRoot: join(assessSpecBase, '.runtime') }).get('main') || []);
|
|
1884
2521
|
|
|
1885
2522
|
// 检查 2: 变更在 allowed_paths 内(仅在 TaskCard 存在时);顺带修复/review 声明文件豁免。
|