sillyspec 3.27.7 → 3.27.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/run/gates.js CHANGED
@@ -321,16 +321,65 @@ export async function enforceDepsGate(stageName, cwd, changeName, step, steps, c
321
321
  console.warn(`⚠️ worktree meta 读取失败(deps gate 将走物理目录判定): ${e.message}`)
322
322
  }
323
323
  const depsStatus = meta?.depsStatus
324
- if (['linked', 'installed', 'n/a'].includes(depsStatus)) return true
324
+ const mainOk = ['linked', 'installed', 'n/a'].includes(depsStatus)
325
+ if (mainOk) {
326
+ // ── 跨仓 worktree deps 校验(坑 cross-repo-no-worktree-isolation)──
327
+ // 跨仓仓供给失败同样无构建/测试能力(前端仓无 node_modules 建不了 build)。只校验已建
328
+ // worktree 的跨仓仓(legacy 直写模式无 meta,维持原语义,下游 Task Review 兜底)。
329
+ let crossFailed = []
330
+ try {
331
+ const crossSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
332
+ const { listCrossWorktreeMetas } = await import('../worktree-cross.js')
333
+ crossFailed = listCrossWorktreeMetas(crossSpecBase, changeName)
334
+ .filter(({ meta: cm }) => cm.depsStatus && !['linked', 'installed', 'n/a'].includes(cm.depsStatus))
335
+ } catch (e) {
336
+ console.warn(`⚠️ 跨仓 worktree deps 校验跳过(${e.message})`)
337
+ }
338
+ if (crossFailed.length === 0) return true
339
+ if (steps && steps[currentIdx]) {
340
+ steps[currentIdx].status = 'blocked'
341
+ steps[currentIdx].blockReason = `跨仓 deps 门控阻断(${crossFailed.map(f => f.repoKey).join(', ')}):修复后重试 --done`
342
+ }
343
+ console.error('❌ ── deps 门控阻断(本次 --done 未完成,进度未推进)──')
344
+ for (const { repoKey, meta: cm } of crossFailed) {
345
+ console.error(` 跨仓 repo ${repoKey} 依赖未就绪(depsStatus=${cm.depsStatus || 'unknown'})——workdir: ${cm.worktreePath}`)
346
+ if (cm.depsError) console.error(` 上次供给错误:${cm.depsError}`)
347
+ }
348
+ console.error(' 修复:进上述跨仓 worktree 手动安装依赖(用该仓的包管理器),或重跑 sillyspec run execute 让 CLI 重试供给后 --done。')
349
+ if (persist) { try { await persist() } catch { /* 落盘失败不吞阻断语义 */ } }
350
+ process.exit(1)
351
+ }
325
352
  const changeDir = changeName ? join(specBase, 'changes', changeName) : null
326
353
  if (isCurrentWaveAllNoDepsVerify(step?.name, changeDir)) return true
327
- if (steps && steps[currentIdx]) steps[currentIdx].status = 'blocked'
354
+ if (steps && steps[currentIdx]) {
355
+ steps[currentIdx].status = 'blocked'
356
+ // 阻断原因持久化(坑 deps-gate-blocked-invisible):--status/诊断时可见,不依赖阻断当刻的
357
+ // stderr(跨进程即失忆)。completed 时由 completeStep 主流程覆盖整个步骤行,残留字段不外泄。
358
+ steps[currentIdx].blockReason = `deps 门控阻断(depsStatus=${depsStatus || 'unknown'}):worktree doctor --fix 修复后重试 --done`
359
+ }
328
360
  // ── 诊断分支(Phase 2,G2/R3 修正:判定基于物理目录而非 !meta)──
329
361
  // getMeta 对"目录不存在"与"meta 损坏"都返回 null,后者会误判终态 → 用物理目录存在性判定。
330
362
  let worktreeGone = true
331
363
  try {
332
364
  worktreeGone = !!(wm && changeName) && !existsSync(wm.getWorktreePath(changeName))
333
365
  } catch {}
366
+ // ── 正当收尾放行(坑 deps-gate-cleanup-order,2026-08-28 实证:apply 后 / execute 空变更
367
+ // cleanup 后重试 --done,被本门拦「worktree 不可用」逼 doctor --align-execute-progress 手工
368
+ // 对齐——顺序矛盾:cleanup 是流程自己做的收尾,门却当意外丢失拦)。两凭据二选一:
369
+ // - apply-pathspec-<change>.txt:applyWorktree 成功后落盘(apply 后自动 cleanup 删 meta);
370
+ // - execute-cleanup-<change>.json:handleExecuteWorktreeCleanup 正当清理回执。
371
+ // 有凭据 = 隔离期已正当结束(交付已落主仓或本无交付),deps 能力要求不再适用 → 放行。
372
+ if (worktreeGone && changeName) {
373
+ try {
374
+ const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
375
+ const legitimatelyEnded = existsSync(join(runtimeRoot, `apply-pathspec-${changeName}.txt`))
376
+ || existsSync(join(runtimeRoot, `execute-cleanup-${changeName}.json`))
377
+ if (legitimatelyEnded) {
378
+ console.log('ℹ️ worktree 已在 apply/execute 收尾时正当清理(凭据在 .runtime),deps 门放行')
379
+ return true
380
+ }
381
+ } catch { /* 凭据读取失败 → 维持原判定 */ }
382
+ }
334
383
  // ── fail-loud 块(Phase 3,D-005@v1:仅改拒绝侧 stderr)──
335
384
  console.error('❌ ── deps 门控阻断(本次 --done 未完成,进度未推进)──')
336
385
  if (worktreeGone) {
@@ -411,7 +460,10 @@ export async function enforceReviewJsonGate(stageName, cwd, changeName, step, st
411
460
  }
412
461
  const result = validateCheckedTaskReviews({ planContent, runtimeRoot, executeRunId })
413
462
  if (result.ok) return true
414
- if (steps && steps[currentIdx]) steps[currentIdx].status = 'blocked'
463
+ if (steps && steps[currentIdx]) {
464
+ steps[currentIdx].status = 'blocked'
465
+ steps[currentIdx].blockReason = 'review.json 字段校验阻断:补齐后重试 --done(backfill-reviews --adopt 可代填 mechanics)'
466
+ }
415
467
  console.error('❌ ── review.json 字段校验阻断(本次 --done 未完成,进度未推进)──')
416
468
  console.error(' 已勾选 [x] 的 task review.json 不完整(铁律:勾 checkbox 前必须先写完整 review.json):')
417
469
  for (const f of result.failures) {
@@ -524,6 +576,19 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
524
576
  }
525
577
  }
526
578
 
579
+ // plan 门 facade 候选预检(坑 plan-facade-files-manual-backfill,2026-08-28 用户实证 ×2:
580
+ // daemon/service.py 类「透传必经文件」不在 allowed_paths → 执行期 apply Gate1 拦截 →
581
+ // 手工回补两轮)。allowed 集与 Gate1 同源(resolveApplyAllowSet 主仓切片),
582
+ // 静态引用/聚合扫描亮候选,advisory 不阻断。
583
+ if (stageName === 'plan' && changeName) {
584
+ try {
585
+ const { resolveApplyAllowSet } = await import('../worktree-apply.js')
586
+ const { warnFacadeCandidateFiles } = await import('../facade-hint.js')
587
+ const allowMap = resolveApplyAllowSet(cwd, changeName)
588
+ warnFacadeCandidateFiles({ cwd, changeName, allowSet: allowMap.get('main') })
589
+ } catch { /* 预检失败不阻断 plan 完成 */ }
590
+ }
591
+
527
592
  // verify 产物校验通过 + 结论非 FAIL(否则上面已阻断)。
528
593
  // 再由 CLI 亲自执行 local.yaml 的测试命令,与 verify-result.md 的自报告对账:
529
594
  // 自报告 PASS 但实测失败 → 阻断(防止"文案通过"绕过验证)。
@@ -541,16 +606,23 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
541
606
  const { collectForeignDeclaredFiles } = await import('../verify-postcheck.js')
542
607
  const { gitQuiet } = await import('../git-helper.js')
543
608
  const foreignMap = collectForeignDeclaredFiles(cwd, changeName, { specBase, runtimeRoot: resolveRuntimeRoot(platformOpts, specBase) })
609
+ // dirty 集提前算好:归因提示①(他者在途)与②(生成产物旧基线)共用
610
+ const dirty = (gitQuiet(cwd, ['diff', '--name-only', 'HEAD']) || '')
611
+ .split('\n').filter(Boolean).map(f => f.replace(/\\/g, '/'))
612
+ const dirtySet = new Set(dirty)
544
613
  if (foreignMap.size > 0) {
545
- const dirty = (gitQuiet(cwd, ['diff', '--name-only', 'HEAD']) || '')
546
- .split('\n').filter(Boolean).map(f => f.replace(/\\/g, '/'))
547
614
  const hitForeign = dirty.filter(f => foreignMap.has(f))
548
615
  if (hitForeign.length > 0) {
549
616
  console.error(` ℹ️ 归因提示:主仓检出 ${hitForeign.length} 个并行会话声明的在途文件(${hitForeign.slice(0, 5).join(', ')}${hitForeign.length > 5 ? ' 等' : ''})——实测失败可能混入他者 WIP 而非本变更问题。待其提交/收尾后复验,仍失败才是本变更的。`)
550
617
  }
551
618
  }
552
619
  // 第二判据(坑 derived-artifact-stale-baseline):本变更 apply 的文件(apply-pathspec 落盘)
553
- // 与主仓近期提交重叠——本变更旧基线的生成产物可能覆盖了已合入内容,先在新基线重跑生成命令再复验
620
+ // 与主仓近期提交重叠——本变更旧基线的生成产物可能覆盖了已合入内容,先在新基线重跑生成命令再复验。
621
+ // 坑 verify-attr2-foreign-wip-misdirection(2026-08-28 实证):并行会话在途/近期收尾文件
622
+ // 让重叠集虚高(51 文件),每轮误导 agent 重跑 gen:types,实际失败源是他者 WIP。两道过滤:
623
+ // - 必须 dirty:apply 落盘的旧内容此刻物理在场(verify gate 时本变更未提交,旧基线覆盖
624
+ // 必然表现为对 HEAD 的修改)——内容与 HEAD 一致时重跑生成命令改变不了本轮实测;
625
+ // - 剔除他者声明集:那是归因提示①的领地(待其收尾后复验),不是生成产物过期。
554
626
  try {
555
627
  const pathspecFile = join(resolveRuntimeRoot(platformOpts, specBase), `apply-pathspec-${changeName}.txt`)
556
628
  if (changeName && existsSync(pathspecFile)) {
@@ -559,8 +631,9 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
559
631
  const recent = (gitQuiet(cwd, ['log', '-n', '10', '--name-only', '--pretty=format:']) || '')
560
632
  .split('\n').map(l => l.trim().replace(/\\/g, '/')).filter(Boolean)
561
633
  const overlap = [...new Set(recent)].filter(f => ownFiles.has(f))
562
- if (overlap.length > 0) {
563
- console.error(` ℹ️ 归因提示②:本变更有 ${overlap.length} 个文件与主仓最近 10 条提交重叠(${overlap.slice(0, 5).join(', ')}${overlap.length > 5 ? ' 等' : ''})——若为生成产物(api-types 等),本变更可能在旧基线生成并覆盖了已合入内容;先在新基线重跑生成命令(如 gen:types)再复验。`)
634
+ const actionable = filterStaleBaselineOverlap(overlap, dirtySet, foreignMap)
635
+ if (actionable.length > 0) {
636
+ console.error(` ℹ️ 归因提示②:本变更有 ${actionable.length} 个文件与主仓最近 10 条提交重叠且旧内容仍在工作区(${actionable.slice(0, 5).join(', ')}${actionable.length > 5 ? ' 等' : ''})——若为生成产物(api-types 等),本变更可能在旧基线生成并覆盖了已合入内容;先在新基线重跑生成命令(如 gen:types)再复验。(重叠共 ${overlap.length} 个,其余未在工作区改动或属他者会话在途文件,已归提示①/无需处理)`)
564
637
  }
565
638
  }
566
639
  }
@@ -1077,6 +1150,25 @@ export async function completeStageGates({ stageName, cwd, changeName, platformO
1077
1150
  }
1078
1151
 
1079
1152
 
1153
+ /**
1154
+ * 归因提示②(生成产物旧基线覆盖)的重叠集过滤(坑 verify-attr2-foreign-wip-misdirection)。
1155
+ *
1156
+ * 原始重叠 = 本变更 apply-pathspec ∩ 主仓最近提交。并行会话场景下该集合被在途/刚收尾的
1157
+ * 他者文件虚高,逐轮误导 agent 重跑生成命令。只保留两个必要条件都成立的文件:
1158
+ * - dirty(此刻对 HEAD 有改动):verify gate 时本变更未提交,旧基线覆盖必然物理在场;
1159
+ * 内容与 HEAD 一致时重跑生成命令改变不了本轮实测。
1160
+ * - 非他者声明集(collectForeignDeclaredFiles):他者在途文件归归因提示①(待其收尾
1161
+ * 复验),不是生成产物过期。
1162
+ *
1163
+ * @param {string[]} overlap 原始重叠文件(POSIX 路径)
1164
+ * @param {Set<string>} dirtySet 主仓当前对 HEAD 的改动集
1165
+ * @param {Set<string>|Map} foreignDeclared 他者会话声明集(Map 亦可用 has 判定)
1166
+ * @returns {string[]} 可行动子集(空 = 不出提示②,避免误导)
1167
+ */
1168
+ export function filterStaleBaselineOverlap(overlap, dirtySet, foreignDeclared) {
1169
+ return (overlap || []).filter(f => dirtySet?.has(f) && !(foreignDeclared?.has(f)))
1170
+ }
1171
+
1080
1172
  /**
1081
1173
  * verify 服务进程回收(verify --done 收尾调用)。
1082
1174
  *
@@ -22,6 +22,8 @@
22
22
  * 不涉及生命周期契约(design §7.5):纯内存对象,不跨进程、不持久化、不入库。
23
23
  */
24
24
 
25
+ import { existsSync, readFileSync } from 'fs';
26
+ import { join } from 'path';
25
27
  import { git, gitQuiet } from '../git-helper.js';
26
28
 
27
29
  /**
@@ -133,7 +135,9 @@ export class MultiRepoContext {
133
135
  }
134
136
 
135
137
  /**
136
- * 跨仓 RepoEntry:isMain=false,gitDir=projectRoot=worktreePath=跨仓仓根。
138
+ * 跨仓 RepoEntry:有 worktree meta(worktree-cross.js 创建)→ gitDir=worktreePath=worktree,
139
+ * base 锚 meta.baseHash(与主仓同构,坑 cross-repo-no-worktree-isolation);无 meta(legacy
140
+ * 直写主干模式)→ gitDir=projectRoot=worktreePath=跨仓仓根,base 靠 taskBaseCommit 锡点。
137
141
  * 构造时 `git -C <crossRepo> rev-parse HEAD` 必须 succeed(约束② fail-closed),
138
142
  * 路径不存在/非 git 仓 → throw 阻断 execute。
139
143
  * @param {string} repoKey
@@ -148,14 +152,26 @@ export class MultiRepoContext {
148
152
  `请补全路径(约束② fail-closed)。`
149
153
  );
150
154
  }
155
+ // worktree 模式检测:meta 在 = worktree-cross.js 已建隔离(路径公式与其 crossWorktreePath
156
+ // 保持同步;execute 启动时创建,晚于本构造的调用会命中)。
157
+ const specBase = (this.platformOpts && this.platformOpts.specRoot) || join(this.cwd, '.sillyspec');
158
+ let crossMeta = null;
159
+ try {
160
+ // 同步读(构造函数是同步的,不 import worktree-cross 避免 shared↔multi-repo-context 循环)
161
+ const metaPath = join(specBase, '.runtime', 'worktrees', `${this.changeName}--${repoKey}`, 'meta.json');
162
+ if (existsSync(metaPath)) crossMeta = JSON.parse(readFileSync(metaPath, 'utf8'));
163
+ } catch { crossMeta = null }
164
+ const inWorktree = !!(crossMeta && crossMeta.isCross && crossMeta.worktreePath && existsSync(crossMeta.worktreePath));
165
+ const workRoot = inWorktree ? crossMeta.worktreePath : crossRepoPath;
166
+
151
167
  // 约束②:跨仓 git 必须可达。失败 = 路径不存在 / 非 git 仓 / git 异常,一律阻断。
152
168
  // 不沿用主仓 verifyReviewGitEvidence unavailable 降级(design §9 兼容策略表)。
153
169
  let headVerify;
154
170
  try {
155
- headVerify = git(crossRepoPath, ['rev-parse', 'HEAD']);
171
+ headVerify = git(workRoot, ['rev-parse', 'HEAD']);
156
172
  } catch (e) {
157
173
  throw new Error(
158
- `MultiRepoContext: 跨仓 repo "${repoKey}" git 不可达(路径:${crossRepoPath})。` +
174
+ `MultiRepoContext: 跨仓 repo "${repoKey}" git 不可达(路径:${workRoot}${inWorktree ? `(worktree,跨仓根 ${crossRepoPath})` : ''})。` +
159
175
  `rev-parse HEAD 失败:${e.message}。` +
160
176
  `请检查 local.yaml repos: 段路径是否正确、目录是否存在且为 git 仓(约束② fail-closed,配置错误不降级)。`
161
177
  );
@@ -164,17 +180,39 @@ export class MultiRepoContext {
164
180
  // git() 返回空串属异常态(无 commit 的空仓 rev-parse HEAD 会非零退出,已被 catch 兜底),
165
181
  // 此处仅作防御性双保险,触发同样走 fail-closed。
166
182
  throw new Error(
167
- `MultiRepoContext: 跨仓 repo "${repoKey}"(${crossRepoPath})rev-parse HEAD 返回空——` +
183
+ `MultiRepoContext: 跨仓 repo "${repoKey}"(${workRoot})rev-parse HEAD 返回空——` +
168
184
  `疑似空 git 仓(无 commit)。跨仓 task 需仓库已有至少一个 commit(约束② fail-closed)。`
169
185
  );
170
186
  }
171
187
 
188
+ if (inWorktree) {
189
+ // worktree 模式:子代理在跨仓 worktree 分支上改+commit,apply 阶段由 CLI patch 回跨仓
190
+ // 主工作区。base 锚 meta.baseHash(创建时快照),head 实时取 worktree HEAD(子代理
191
+ // commit 推进后反映最新);baseCommitHint 供派发侧优先取(HEAD 会推进,不能当 base)。
192
+ return {
193
+ repoKey,
194
+ gitDir: workRoot,
195
+ worktreePath: workRoot,
196
+ projectRoot: crossRepoPath,
197
+ isMain: false,
198
+ isWorktree: true,
199
+ baseCommitHint: crossMeta.baseHash || null,
200
+ resolveHead: () => git(workRoot, ['rev-parse', 'HEAD']),
201
+ resolveBase: (taskBaseCommit) => crossMeta.baseHash || taskBaseCommit || (() => {
202
+ throw new Error(
203
+ `MultiRepoContext: 跨仓 repo "${repoKey}"(worktree 模式)meta.baseHash 缺失且未传 taskBaseCommit。`
204
+ );
205
+ })(),
206
+ };
207
+ }
208
+
172
209
  return {
173
210
  repoKey,
174
211
  gitDir: crossRepoPath,
175
212
  worktreePath: crossRepoPath,
176
213
  projectRoot: crossRepoPath,
177
214
  isMain: false,
215
+ isWorktree: false,
178
216
  // 约束①:跨仓 HEAD 实时取,不缓存(task 推进后过期,每次 resolveHead 反映最新 HEAD)。
179
217
  resolveHead: () => git(crossRepoPath, ['rev-parse', 'HEAD']),
180
218
  // 约束①:跨仓 base = task 卡 base_commit 锡点(必传)。跨仓仓无 meta.json,
package/src/run/shared.js CHANGED
@@ -139,7 +139,7 @@ export function resolveSpecDir(cwd, opts = {}) {
139
139
  // 只缓存"git root 已找到"的结果——非 git 仓库不缓存,防测试流程中途 git init 后读到陈旧 null。
140
140
  const _ancestorCeilingCache = new Map()
141
141
 
142
- function resolveAncestorCeiling(resolved) {
142
+ export function resolveAncestorCeiling(resolved) {
143
143
  if (_ancestorCeilingCache.has(resolved)) return _ancestorCeilingCache.get(resolved)
144
144
  // 一次 spawn 拿两值(rev-parse 按参数序输出,每值一行)
145
145
  const out = safeGit(resolved, ['rev-parse', '--show-toplevel', '--git-common-dir']).value
@@ -813,6 +813,54 @@ export function isQuickMetadata(p, linkedChanges = []) {
813
813
  return false
814
814
  }
815
815
 
816
+ // 危险文件清单(auditQuickCompletion 危险门与 predictProtectedQuickFiles 预告共用单一真相源)。
817
+ // SillySpec 核心流程代码。W6 重构后 src/run.js(23 行 barrel)/ src/progress.js(facade)
818
+ // 把真正逻辑下沉到 src/run/、src/progress/ 子目录——旧的精确文件名匹配
819
+ // (file === 'src/run.js')命中不到 src/run/command.js(. 与 / 不可混同),致危险文件门
820
+ // 静默失效。改用「目录前缀(带尾斜杠,startsWith 不会误伤 src/runtime-* 等同名前缀)+
821
+ // barrel/facade 本体精确名」双重覆盖。重构 src/ 模块时须同步本清单(multi-agent-review Q5)。
822
+ const QUICK_DANGEROUS_PATTERNS = [
823
+ 'package.json',
824
+ 'package-lock.json',
825
+ 'yarn.lock',
826
+ 'pnpm-lock.yaml',
827
+ '.eslintrc',
828
+ 'tsconfig.json',
829
+ 'src/run.js',
830
+ 'src/run/',
831
+ 'src/progress.js',
832
+ 'src/progress/',
833
+ 'src/db.js',
834
+ 'src/stage-contract.js',
835
+ 'src/stage-contract-spec.js',
836
+ 'src/worktree.js',
837
+ 'src/worktree-apply.js',
838
+ 'src/hooks/',
839
+ ]
840
+
841
+ /**
842
+ * 预告:--files 声明中哪些文件会在 --done 审计被危险/基线门拦截(坑 quick-protected-late-hint,
843
+ * 2026-08-28 用户实证:scan 类文档 ARCHITECTURE/CONCERNS 属受保护基线,--files 声明了照样拦、
844
+ * 必须 --force-baseline——设计合理但提示太晚,要等审计轮才发现,白跑一轮往返)。
845
+ * 判定与 auditQuickCompletion 危险门逐字同口径(isQuickMetadata 豁免 + 关联变更目录退栈 +
846
+ * DANGEROUS_PATTERNS 前缀/精确匹配),forceBaseline 已带 → 无预告。
847
+ * @param {string[]} files 声明文件(--files)
848
+ * @param {{ linkedChanges?: string[], forceBaseline?: boolean }} opts
849
+ * @returns {string[]} 将被拦截的声明文件子集
850
+ */
851
+ export function predictProtectedQuickFiles(files, { linkedChanges = [], forceBaseline = false } = {}) {
852
+ if (forceBaseline) return []
853
+ const linked = (Array.isArray(linkedChanges) ? linkedChanges : []).map(c => String(c).replace(/\\/g, '/'))
854
+ const isUnderLinkedChange = (f) => linked.some(lc => f.startsWith(`.sillyspec/changes/${lc}/`))
855
+ return (files || [])
856
+ .filter(Boolean)
857
+ .map(f => String(f).replace(/\\/g, '/'))
858
+ .filter(f => {
859
+ if (f.startsWith('.sillyspec/') && !isQuickMetadata(f, linked) && !isUnderLinkedChange(f)) return true
860
+ return QUICK_DANGEROUS_PATTERNS.some(p => p === f || f.startsWith(p))
861
+ })
862
+ }
863
+
816
864
  /**
817
865
  * quick 完成审计:对比 baseline 与实际变更。
818
866
  * @returns {{ status: 'safe'|'warning'|'blocked', reasons: string[], changedFiles: string[], newFiles: string[], deletedFiles: string[], baselineHit: string[], stagedTotal: number, attributedFiles: string[], undeclaredFiles: string[], foreignSessionDeclared: Array<{file: string, sessions: string[]}> }}
@@ -990,29 +1038,7 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
990
1038
  }
991
1039
  return foreignDeclaredIndex.get(f) || null
992
1040
  }
993
- const DANGEROUS_PATTERNS = [
994
- 'package.json',
995
- 'package-lock.json',
996
- 'yarn.lock',
997
- 'pnpm-lock.yaml',
998
- '.eslintrc',
999
- 'tsconfig.json',
1000
- // SillySpec 核心流程代码。W6 重构后 src/run.js(23 行 barrel)/ src/progress.js(facade)
1001
- // 把真正逻辑下沉到 src/run/、src/progress/ 子目录——旧的精确文件名匹配
1002
- // (file === 'src/run.js')命中不到 src/run/command.js(. 与 / 不可混同),致危险文件门
1003
- // 静默失效。改用「目录前缀(带尾斜杠,startsWith 不会误伤 src/runtime-* 等同名前缀)+
1004
- // barrel/facade 本体精确名」双重覆盖。重构 src/ 模块时须同步本清单(multi-agent-review Q5)。
1005
- 'src/run.js',
1006
- 'src/run/',
1007
- 'src/progress.js',
1008
- 'src/progress/',
1009
- 'src/db.js',
1010
- 'src/stage-contract.js',
1011
- 'src/stage-contract-spec.js',
1012
- 'src/worktree.js',
1013
- 'src/worktree-apply.js',
1014
- 'src/hooks/',
1015
- ]
1041
+ const DANGEROUS_PATTERNS = QUICK_DANGEROUS_PATTERNS
1016
1042
 
1017
1043
  for (const entry of currentEntries) {
1018
1044
  // porcelain 两字符状态码(X=暂存区 Y=工作区)不能先 trim:' D'.trim()==='D' 使第二分支恒假、
package/src/run/stage.js CHANGED
@@ -19,7 +19,7 @@
19
19
  import { join, dirname } from 'node:path'
20
20
  import { existsSync, readdirSync, readFileSync, mkdirSync } from 'node:fs'
21
21
  import { writeAtomicSync } from '../fs-atomic.js'
22
- import { resolveSpecDir, resolveChangeDir, resolveRuntimeRoot, resolveQuickSessionsDir, triggerSync, safeGit, parsePorcelainPath, formatWaitOptions, checkApproval, getStageSteps, warnApprovalUnknown } from './shared.js'
22
+ import { resolveSpecDir, resolveChangeDir, resolveRuntimeRoot, resolveQuickSessionsDir, triggerSync, safeGit, parsePorcelainPath, formatWaitOptions, checkApproval, getStageSteps, warnApprovalUnknown, predictProtectedQuickFiles } from './shared.js'
23
23
  import { computeScanProfile, applyScanProfileSteps, executeScanPreflight, executeScanPostcheck } from './scan-profile.js'
24
24
  import { outputStep, collectStageWaitHistory } from './prompt.js'
25
25
  import { allocateQuicklogEntry, deriveTitleFromLinkedChange, sanitizeDesc } from '../quicklog.js'
@@ -93,6 +93,26 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
93
93
  const execSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
94
94
  await ensureDepsFreshness(cwd, effectiveChange, execSpecBase, existingMeta)
95
95
  }
96
+
97
+ // ── 跨仓仓 worktree 隔离(坑 cross-repo-no-worktree-isolation,2026-08-27 用户实证)──
98
+ // 主仓 worktree 就绪后,为 plan 声明的每个跨仓仓建同构 worktree(幂等)。此前跨仓 task 直写
99
+ // 跨仓主工作副本,与用户在途改动混流、无 base 锚、无回滚面。fail-closed:建不起来不开工
100
+ //(对齐主仓 create 语义)。
101
+ try {
102
+ const { ensureCrossWorktrees } = await import('../worktree-cross.js')
103
+ const execSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
104
+ const cross = ensureCrossWorktrees({ cwd, changeName: effectiveChange, specBase: execSpecBase })
105
+ for (const c of cross.created) {
106
+ console.log(`🔗 跨仓 worktree 已创建: repo=${c.repoKey} → ${c.worktreePath}`)
107
+ }
108
+ for (const r of cross.reused) {
109
+ console.log(`🔗 跨仓 worktree 已存在: repo=${r.repoKey} → ${r.worktreePath}`)
110
+ }
111
+ } catch (e) {
112
+ console.error(`❌ 跨仓 worktree 创建失败: ${e.message}`)
113
+ console.error(` 修复后重跑本命令(已建成功的仓会幂等复用)。`)
114
+ process.exit(1)
115
+ }
96
116
  }
97
117
 
98
118
  // ── execute 阶段启动时固定 executeRunId(绑定变更名,避免跨变更复用) ──
@@ -228,8 +248,14 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
228
248
 
229
249
  let currentIdx = steps.findIndex(s => s.status !== 'completed' && s.status !== 'skipped')
230
250
 
231
- // stale 步骤视为可执行(等同于 pending)
232
- if (currentIdx !== -1 && steps[currentIdx].status === 'stale') {
251
+ // stale/blocked 步骤视为可执行(等同于 pending):
252
+ // stale = reopen 后未重做(原语义);blocked = 门控(deps/review.json)阻断标记。
253
+ // blocked 必须一并转换(坑 deps-gate-blocked-invisible 的路径①失效面):reopen 后首个未完成
254
+ // 步骤若是 blocked(早前门控阻断污染),只转 stale 会让 completeStep 的 findIndex(pending)
255
+ // 跳过它、--done 永远撞 stale 门控报错——「逐个真实执行」指引失效。门控在 --done 时会
256
+ // 重新校验,此处拉回 pending 不会绕过任何校验。
257
+ if (currentIdx !== -1 && (steps[currentIdx].status === 'stale' || steps[currentIdx].status === 'blocked')) {
258
+ console.log(`⚠️ Step "${steps[currentIdx].name}" 处于 ${steps[currentIdx].status},已拉回待执行。`)
233
259
  steps[currentIdx].status = 'pending'
234
260
  pm._write(cwd, progress, changeName)
235
261
  triggerSync(cwd, changeName, platformOpts)
@@ -302,6 +328,15 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
302
328
  // (D-003@v1:顶层 quickGuard 不跨进程持久化;agent 在 step 间用 `run quick` 取下一步
303
329
  // prompt 时每个新进程都进此块,按文件判幂等才不会重复分配 ql-ID / 重复写条目)。
304
330
  if (stageName === 'quick') {
331
+ // 受保护文件预告打印(坑 quick-protected-late-hint,2026-08-28 用户实证:scan 类文档
332
+ // ARCHITECTURE/CONCERNS 属受保护基线,--files 声明了照样拦、必须 --force-baseline——
333
+ // 设计合理但提示太晚。step1 即预告哪些声明文件会触发拦截,省一轮跑到 --done 才发现的往返)
334
+ const warnProtectedQuickFiles = (protectedFiles) => {
335
+ console.warn(`⚠️ 声明文件中 ${protectedFiles.length} 个属受保护/危险范围,--done 审计将拦截(--files 只声明归属,不解锁拦截):`)
336
+ for (const f of protectedFiles) console.warn(` - ${f}`)
337
+ console.warn(` 确认要改这类文件:本会话收尾带 --force-baseline(sillyspec run quick --done --force-baseline ...);`)
338
+ console.warn(` 属误判或不该由本会话改:换出 --files 声明,或走完整流程(execute 有 review 把关)。`)
339
+ }
305
340
  // quick-sessions 目录经 resolveQuickSessionsDir 单一解析(multi-agent-review Q4):
306
341
  // 平台模式 runtimeRoot 与 specBase/.runtime 不同时,写/读须对齐,否则 guard 写一处、收尾读另一处。
307
342
  const sessionGuardDir = join(resolveQuickSessionsDir(platformOpts, specBase), changeName)
@@ -322,6 +357,13 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
322
357
  const known = new Set(progress.quickGuard.allowedFiles || [])
323
358
  const added = resumeFiles.filter(f => !known.has(f))
324
359
  if (added.length > 0) {
360
+ // 受保护文件预告(坑 quick-protected-late-hint):追加声明若含审计将拦的文件,
361
+ // 追加时即点破(省一轮跑到 --done 才发现)。--files 只声明归属,不解锁拦截。
362
+ const protectedPreview = predictProtectedQuickFiles(added, {
363
+ linkedChanges: progress.quickGuard.linkedChanges || [],
364
+ forceBaseline: quickOpts?.isForceBaseline || false,
365
+ })
366
+ if (protectedPreview.length > 0) warnProtectedQuickFiles(protectedPreview)
325
367
  progress.quickGuard.allowedFiles = [...(progress.quickGuard.allowedFiles || []), ...added]
326
368
  progress.quickGuard.allowedFilesHash = { ...(progress.quickGuard.allowedFilesHash || {}) }
327
369
  for (const f of added) {
@@ -359,6 +401,10 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
359
401
  const allowDelete = quickOpts?.isAllowDelete || false
360
402
  const forceBaseline = quickOpts?.isForceBaseline || false
361
403
  const linkedChanges = Array.isArray(quickOpts?.linkedChanges) ? quickOpts.linkedChanges : []
404
+ // 受保护文件预告(坑 quick-protected-late-hint):会话起步(step1)即点破,
405
+ // 判定与 --done 审计危险门同口径(predictProtectedQuickFiles)
406
+ const protectedPreview = predictProtectedQuickFiles(allowedFiles, { linkedChanges, forceBaseline })
407
+ if (protectedPreview.length > 0) warnProtectedQuickFiles(protectedPreview)
362
408
  // CLI 接管:分配 ql-ID + 写 QUICKLOG「进行中」条目 + 关联 tasks.md(持锁、当天唯一)
363
409
  const gitUser = safeGit(cwd, ['config', 'user.name']).value || 'unknown'
364
410
  // 标题回退:启动 quick 不带 --input 时,从关联变更的 proposal/design 提取语义标题,
package/src/spec-sync.js CHANGED
@@ -8,6 +8,11 @@
8
8
  *
9
9
  * 复用 daemon 已验证的排除口径(UPLOAD_EXCLUDE_TOP_BASE / UPLOAD_PRUNE_NAMES_BASE),
10
10
  * 与 daemon 共用同一套清单语义(base_version 乐观锁、conflict 提示但不阻塞)。
11
+ *
12
+ * 并行会话 fail-closed 护栏(computeSpecOps 空树/changes 整删守卫 + filterStaleUpdates
13
+ * 旧副本回推守卫):共享同一平台工作空间的多会话/多机场景下,错锚或滞后本地的破坏性
14
+ * ops(整删 changes/、旧版内容回推)在发出前拦下——服务器侧一旦被破坏会经同步链路
15
+ * 落地回各端本地(2026-08-26/27 两次实证)。
11
16
  */
12
17
 
13
18
  import { createHash } from 'crypto';
@@ -140,6 +145,20 @@ export function computeSpecOps(serverManifest, localFiles) {
140
145
  return [];
141
146
  }
142
147
 
148
+ // 并行会话护栏(坑 spec-sync-parallel-changes-wipe,2026-08-26/27 两次实证:平台同步
149
+ // 删整个 changes 目录):本地树非空但 changes/ 子树一个文件都没有、服务器却有——
150
+ // 几乎必然是错锚/新机未先落地,而非用户真删光了所有变更(archive 是移动不是清空)。
151
+ // 放行即服务器 changes/ 整树清空,再经 daemon/对端同步落地回本地(破坏本地)。
152
+ // 逐个变更目录的合法删除不受影响(本地仍有其他 changes/ 文件时不触发本护栏)。
153
+ const serverChanges = serverPaths.filter((p) => p.startsWith('changes/'));
154
+ const localChanges = localFiles.filter((f) => f.path.startsWith('changes/'));
155
+ if (localFiles.length > 0 && serverChanges.length > 0 && localChanges.length === 0) {
156
+ console.warn(
157
+ `[spec-sync] 本地 spec 树无 changes/ 文件但服务器有 ${serverChanges.length} 个,跳过同步(防并行会话/错锚整删 changes/;请检查 spec 目录锚点,新环境先完成平台侧文件落地)`
158
+ );
159
+ return [];
160
+ }
161
+
143
162
  // rename 检测:旧路径(服务器有、本地无)↔ 新路径(本地有、服务器无)hash 相同
144
163
  const renames = [];
145
164
  const consumedNew = new Set();
@@ -248,6 +267,62 @@ function collectChangeDir(dirs, p) {
248
267
  }
249
268
  }
250
269
 
270
+ /**
271
+ * 过滤「旧副本回推」型 update op(坑 spec-sync-parallel-stale-overwrite,2026-08-26/27
272
+ * 两次实证:平台同步把本地文件覆盖回旧版)。
273
+ *
274
+ * 判据:本地文件 mtime 早于上次成功同步(mtime + 1s 缓冲)= 该文件自上次同步后本地从未
275
+ * 改动——此刻服务器 hash 却不同,只可能是他者会话/他机推进了服务器内容。本地这份是旧
276
+ * 副本,照推 update 就是把服务器内容回退到旧版(再经 daemon/对端落地破坏各端本地)。
277
+ * 本地改过的文件(mtime 晚于上次同步)不受影响,正常 last-writer-wins。
278
+ *
279
+ * 被滤掉的文件如确需以本地为准覆盖服务器:本地重存(touch/再编辑)后重推。
280
+ *
281
+ * @param {Array} ops computeSpecOps 产物
282
+ * @param {Array} localFiles hashFiles 产物(含 mtime,Unix 秒)
283
+ * @param {number|null} lastSyncTs 上次成功同步完成时刻(ms);null/0 → 不过滤
284
+ * @returns {{ ops: Array, stale: string[] }} 过滤后 ops + 被拦下的路径
285
+ */
286
+ export function filterStaleUpdates(ops, localFiles, lastSyncTs) {
287
+ if (!lastSyncTs || !Array.isArray(ops) || ops.length === 0) {
288
+ return { ops, stale: [] };
289
+ }
290
+ const mtimeByPath = new Map(localFiles.map((f) => [f.path, f.mtime]));
291
+ const stale = [];
292
+ const kept = [];
293
+ for (const op of ops) {
294
+ if (op.op === 'update') {
295
+ const mtime = mtimeByPath.get(op.path);
296
+ if (mtime !== undefined && mtime * 1000 + 1000 <= lastSyncTs) {
297
+ stale.push(op.path);
298
+ continue;
299
+ }
300
+ }
301
+ kept.push(op);
302
+ }
303
+ return { ops: kept, stale };
304
+ }
305
+
306
+ // 上次成功同步标记(本地 .runtime 下,walk 排除不上传):filterStaleUpdates 的时间锚。
307
+ const LAST_SUCCESS_MARKER = 'spec-sync-last-success.json';
308
+
309
+ function readLastSyncTs(specRoot) {
310
+ try {
311
+ const raw = readFileSync(join(specRoot, '.runtime', LAST_SUCCESS_MARKER), 'utf8');
312
+ const ts = JSON.parse(raw).ts;
313
+ return Number.isFinite(ts) ? ts : null;
314
+ } catch {
315
+ return null;
316
+ }
317
+ }
318
+
319
+ function writeLastSyncTs(specRoot) {
320
+ try {
321
+ mkdirSync(join(specRoot, '.runtime'), { recursive: true });
322
+ writeFileSync(join(specRoot, '.runtime', LAST_SUCCESS_MARKER), JSON.stringify({ ts: Date.now() }) + '\n', 'utf8');
323
+ } catch { /* 标记失败只损失下次的防回推过滤,不影响本次同步结果 */ }
324
+ }
325
+
251
326
  /**
252
327
  * 同步本地 .sillyspec 树到平台(CLI 直跑增量同步入口)。
253
328
  *
@@ -292,7 +367,17 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
292
367
  // 2. walk/hash/diff
293
368
  const entries = walkSpecTree(specRoot);
294
369
  const localFiles = hashFiles(entries);
295
- const ops = computeSpecOps(serverManifest, localFiles);
370
+ let ops = computeSpecOps(serverManifest, localFiles);
371
+
372
+ // 旧副本回推防护(并行会话一致性):上次同步后本地从未改动的文件不回推覆盖服务器新内容
373
+ const lastSyncTs = readLastSyncTs(specRoot);
374
+ const filtered = filterStaleUpdates(ops, localFiles, lastSyncTs);
375
+ if (filtered.stale.length > 0) {
376
+ console.warn(
377
+ `[spec-sync] 拦下 ${filtered.stale.length} 个旧副本回推(本地自上次同步未改动而服务器已前进,如确需以本地为准:重存后重推): ${filtered.stale.slice(0, 5).join(', ')}${filtered.stale.length > 5 ? ' 等' : ''}`
378
+ );
379
+ }
380
+ ops = filtered.ops;
296
381
 
297
382
  if (ops.length === 0) {
298
383
  debugLog(`[spec-sync] 无差异,跳过同步: ${changeName}`);
@@ -342,6 +427,7 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
342
427
  return { synced: 0, conflict: true, serverVersions, conflictPath };
343
428
  }
344
429
  console.log(`[spec-sync] 已同步 ${ops.length} 个文件变更: ${changeName}`);
430
+ writeLastSyncTs(specRoot);
345
431
  return { synced: ops.length };
346
432
  } catch (err) {
347
433
  console.warn(`[spec-sync] 同步异常(文件树本次未同步,下次自动重试): ${changeName}: ${err.message}`);
@@ -354,5 +440,6 @@ export default {
354
440
  hashFiles,
355
441
  computeSpecOps,
356
442
  extractChangeDirs,
443
+ filterStaleUpdates,
357
444
  syncSpecTree,
358
445
  };
@@ -459,13 +459,26 @@ function validatePlanOutputs(cwd, changeName, context = {}) {
459
459
  * 历史教训:原正则锚定确切「## 结论」,用户写「## 验收结论:✅ PASS」不被识别。
460
460
  */
461
461
  function extractVerifyConclusion(verify) {
462
- const headingRe = /^##\s[^\n]*(?:结论|conclusion|result|结果)/im
463
- const headingMatch = verify.match(headingRe)
464
- if (!headingMatch) return ''
465
- const start = headingMatch.index
466
- const slice = verify.slice(start, start + 400)
467
- const kw = slice.match(/\b(PASS(?:\s+WITH\s+NOTES)?|FAIL)\b/i)
468
- return kw ? kw[1].toUpperCase().replace(/\s+/g, ' ') : ''
462
+ // 遍历所有含关键词的二级标题,取其 400 字符窗口内含 PASS/FAIL 的那个(坑
463
+ // verify-conclusion-heading-hijack:旧逻辑取首个匹配,"## 测试结果"等普通标题
464
+ // 排在真结论前时劫持识别)。优先精确匹配「## 结论」类窄标题。
465
+ const headingRe = /^##\s[^\n]*(?:结论|conclusion|result|结果)/gim
466
+ let best = null
467
+ let bestPriority = -1
468
+ for (const headingMatch of verify.matchAll(headingRe)) {
469
+ const start = headingMatch.index
470
+ const slice = verify.slice(start, start + 400)
471
+ const kw = slice.match(/\b(PASS(?:\s+WITH\s+NOTES)?|FAIL)\b/i)
472
+ if (!kw) continue
473
+ // 优先级:精确含「结论/conclusion」> 宽泛含「result/结果」
474
+ const text = headingMatch[0].toLowerCase()
475
+ const priority = (/结论|conclusion/i.test(text)) ? 1 : 0
476
+ if (priority > bestPriority || (priority === bestPriority && !best)) {
477
+ best = kw[1].toUpperCase().replace(/\s+/g, ' ')
478
+ bestPriority = priority
479
+ }
480
+ }
481
+ return best || ''
469
482
  }
470
483
 
471
484
  function validateVerifyOutputs(cwd, changeName, context = {}) {