sillyspec 3.27.9 → 3.27.12

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
@@ -363,6 +363,23 @@ export async function enforceDepsGate(stageName, cwd, changeName, step, steps, c
363
363
  try {
364
364
  worktreeGone = !!(wm && changeName) && !existsSync(wm.getWorktreePath(changeName))
365
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
+ }
366
383
  // ── fail-loud 块(Phase 3,D-005@v1:仅改拒绝侧 stderr)──
367
384
  console.error('❌ ── deps 门控阻断(本次 --done 未完成,进度未推进)──')
368
385
  if (worktreeGone) {
@@ -559,6 +576,19 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
559
576
  }
560
577
  }
561
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
+
562
592
  // verify 产物校验通过 + 结论非 FAIL(否则上面已阻断)。
563
593
  // 再由 CLI 亲自执行 local.yaml 的测试命令,与 verify-result.md 的自报告对账:
564
594
  // 自报告 PASS 但实测失败 → 阻断(防止"文案通过"绕过验证)。
@@ -576,16 +606,23 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
576
606
  const { collectForeignDeclaredFiles } = await import('../verify-postcheck.js')
577
607
  const { gitQuiet } = await import('../git-helper.js')
578
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)
579
613
  if (foreignMap.size > 0) {
580
- const dirty = (gitQuiet(cwd, ['diff', '--name-only', 'HEAD']) || '')
581
- .split('\n').filter(Boolean).map(f => f.replace(/\\/g, '/'))
582
614
  const hitForeign = dirty.filter(f => foreignMap.has(f))
583
615
  if (hitForeign.length > 0) {
584
616
  console.error(` ℹ️ 归因提示:主仓检出 ${hitForeign.length} 个并行会话声明的在途文件(${hitForeign.slice(0, 5).join(', ')}${hitForeign.length > 5 ? ' 等' : ''})——实测失败可能混入他者 WIP 而非本变更问题。待其提交/收尾后复验,仍失败才是本变更的。`)
585
617
  }
586
618
  }
587
619
  // 第二判据(坑 derived-artifact-stale-baseline):本变更 apply 的文件(apply-pathspec 落盘)
588
- // 与主仓近期提交重叠——本变更旧基线的生成产物可能覆盖了已合入内容,先在新基线重跑生成命令再复验
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
+ // - 剔除他者声明集:那是归因提示①的领地(待其收尾后复验),不是生成产物过期。
589
626
  try {
590
627
  const pathspecFile = join(resolveRuntimeRoot(platformOpts, specBase), `apply-pathspec-${changeName}.txt`)
591
628
  if (changeName && existsSync(pathspecFile)) {
@@ -594,8 +631,9 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
594
631
  const recent = (gitQuiet(cwd, ['log', '-n', '10', '--name-only', '--pretty=format:']) || '')
595
632
  .split('\n').map(l => l.trim().replace(/\\/g, '/')).filter(Boolean)
596
633
  const overlap = [...new Set(recent)].filter(f => ownFiles.has(f))
597
- if (overlap.length > 0) {
598
- 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} 个,其余未在工作区改动或属他者会话在途文件,已归提示①/无需处理)`)
599
637
  }
600
638
  }
601
639
  }
@@ -1112,6 +1150,25 @@ export async function completeStageGates({ stageName, cwd, changeName, platformO
1112
1150
  }
1113
1151
 
1114
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
+
1115
1172
  /**
1116
1173
  * verify 服务进程回收(verify --done 收尾调用)。
1117
1174
  *
package/src/run/prompt.js CHANGED
@@ -26,6 +26,7 @@ import { resolvePromptIncludes, resolveRuntimeRoot, safeGit, WAIT_MARKER_RE, QUI
26
26
  import { renderStageContract } from '../stage-contract-spec.js'
27
27
  import { nowWallClock } from '../datetime.js'
28
28
  import { parseModuleMapSimple } from '../modules.js'
29
+ import { readModuleRecentChanges } from '../module-changelog.js'
29
30
  import { REVIEW_SCHEMA_VERSION, isValidExecuteRunId } from '../task-review.js'
30
31
 
31
32
  /**
@@ -111,6 +112,12 @@ function buildModuleContextInjection(taskDescription, moduleIndex, specBase, pro
111
112
  if (deps.length > 0) injection += `- **依赖**: ${deps.join(', ')}\n`
112
113
  const usedBy = data.used_by || []
113
114
  if (usedBy.length > 0) injection += `- **被引用**: ${usedBy.join(', ')}\n`
115
+ // 最近变更(模块卡「变更索引」惯例解析,2026-08-31 变更关联审计):三阶段免扫描即知
116
+ // 该模块最近被谁动过。懒读命中模块的卡 + sidecar(matched 通常 ≤3 个,不做全目录扫描)
117
+ const recentChanges = readModuleRecentChanges(join(specBase, 'docs', projectName, 'modules'), moduleId)
118
+ if (recentChanges.length > 0) {
119
+ injection += `- **最近变更**: ${recentChanges.slice(0, 3).map(e => e.name).join('、')}\n`
120
+ }
114
121
  injection += '\n'
115
122
  }
116
123
 
@@ -868,6 +875,10 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
868
875
  const mayNeedWait = WAIT_MARKER_RE.test(stepPrompt) || requiresWait || conditionalWait
869
876
 
870
877
  console.log(`\n### 完成后执行`)
878
+ // requiresConfirm 步骤(如 archive「确认归档」)的完成命令必须带 --confirm——坑
879
+ // archive-batch-31-tool-notes ②:通用 --done 模板不带该 flag,agent 照抄执行撞
880
+ // 「请添加 --confirm」确认门,误以为参数没带。
881
+ const confirmFlag = step.requiresConfirm === true ? ' --confirm' : ''
871
882
  if (requiresWait) {
872
883
  console.log(`本步骤必须等待用户输入,不能直接 --done:`)
873
884
  console.log(`sillyspec run ${stageName} --wait --reason "${step.waitReason || '等待用户输入'}" --options "${(step.waitOptions || ['确认']).join(',')}"${changeFlag} --output "你的问题/方案摘要"`)
@@ -882,7 +893,7 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
882
893
  console.log(``)
883
894
  console.log(`如果不需要用户决策,正常完成:`)
884
895
  }
885
- console.log(`sillyspec run ${stageName} --done${changeFlag} --input "用户原始需求/反馈" --output "你的摘要"`)
896
+ console.log(`sillyspec run ${stageName} --done${confirmFlag}${changeFlag} --input "用户原始需求/反馈" --output "你的摘要"`)
886
897
  }
887
898
  /**
888
899
  * 替换 prompt 文本中的路径根占位符 {SPEC_ROOT}/{DOCS_ROOT}/{PROJECTS_ROOT}/
package/src/run/shared.js CHANGED
@@ -616,6 +616,38 @@ export async function triggerSync(cwd, changeName, platformOpts = {}, opts = {})
616
616
  }
617
617
  }
618
618
 
619
+ /**
620
+ * 触发步骤开始上报(X3 / design §8.2,变更 2026-08-29-change-delete-closure-and-spec-pull task-13)。
621
+ *
622
+ * 步骤启动时同端点同结构补推一次 progress:CLI 状态机的步骤行 DB 侧种子是 pending,
623
+ * 本推送在载荷层把 current_stage 的第一个未完成步投影为 in-progress(sync.js
624
+ * _projectStepStart,不写 DB,--done 推送路径不受影响)。真实效果 = 平台
625
+ * last_pushed_at 以步骤起点刷新(投影 current_step_status 对下一个 pending 步本就
626
+ * 推导为 active,X3 不改变其值,只让「最后信号」时间可信,停滞判定不再失真)。
627
+ * 与 triggerSync 同款契约:quick 会话降级跳过(无 progress 实体)、未连接平台在
628
+ * sync() 内部静默跳过、8s 总熔断(raceWithAbort + abort 在飞请求)、失败只 warn 不抛。
629
+ *
630
+ * 接线说明(本变更 allowed_paths 约束):步骤 prompt 渲染点(run/stage.js outputStep
631
+ * 前段 / run/prompt.js)不在本变更可改文件集内——本导出即「步骤开始钩子」本体,
632
+ * 渲染侧一行 `triggerStepStartSync(cwd, changeName, platformOpts)` 的接线由后续
633
+ * 变更补上;execute 阶段的任务粒度信号已由 X4(stages/execute.js Wave prompt
634
+ * 每任务上报指引)覆盖。
635
+ */
636
+ export async function triggerStepStartSync(cwd, changeName, platformOpts = {}, opts = {}) {
637
+ try {
638
+ const tsSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
639
+ if (changeName && QUICK_SID_RE.test(changeName) && !existsSync(join(tsSpecBase, 'changes', changeName))) {
640
+ return // quick 会话无 progress 实体(与 triggerSync 同判),无步骤可报
641
+ }
642
+ // shared.js 在 src/run/,sync.js 在 src/ → 退一层
643
+ const syncMod = await import('../sync.js')
644
+ await raceWithAbort((sig) => syncMod.sync(changeName, cwd, { ...sig, stepStart: true }), opts.timeoutMs)
645
+ } catch (e) {
646
+ // Best Effort:步骤开始上报失败不影响主流程(下次 --done 的 triggerSync 兜底)
647
+ console.warn('⚠️ 步骤开始上报失败:', e.message)
648
+ }
649
+ }
650
+
619
651
  /**
620
652
  * 自动 pull 节流(体检 PERF-02):triggerPull/triggerPullActiveChange 在每条 stage 命令
621
653
  * 启动时注入,agent 命令往往是秒级连发——每次都发 HTTP GET(daemon 挂/慢时单命令最多
@@ -813,6 +845,54 @@ export function isQuickMetadata(p, linkedChanges = []) {
813
845
  return false
814
846
  }
815
847
 
848
+ // 危险文件清单(auditQuickCompletion 危险门与 predictProtectedQuickFiles 预告共用单一真相源)。
849
+ // SillySpec 核心流程代码。W6 重构后 src/run.js(23 行 barrel)/ src/progress.js(facade)
850
+ // 把真正逻辑下沉到 src/run/、src/progress/ 子目录——旧的精确文件名匹配
851
+ // (file === 'src/run.js')命中不到 src/run/command.js(. 与 / 不可混同),致危险文件门
852
+ // 静默失效。改用「目录前缀(带尾斜杠,startsWith 不会误伤 src/runtime-* 等同名前缀)+
853
+ // barrel/facade 本体精确名」双重覆盖。重构 src/ 模块时须同步本清单(multi-agent-review Q5)。
854
+ const QUICK_DANGEROUS_PATTERNS = [
855
+ 'package.json',
856
+ 'package-lock.json',
857
+ 'yarn.lock',
858
+ 'pnpm-lock.yaml',
859
+ '.eslintrc',
860
+ 'tsconfig.json',
861
+ 'src/run.js',
862
+ 'src/run/',
863
+ 'src/progress.js',
864
+ 'src/progress/',
865
+ 'src/db.js',
866
+ 'src/stage-contract.js',
867
+ 'src/stage-contract-spec.js',
868
+ 'src/worktree.js',
869
+ 'src/worktree-apply.js',
870
+ 'src/hooks/',
871
+ ]
872
+
873
+ /**
874
+ * 预告:--files 声明中哪些文件会在 --done 审计被危险/基线门拦截(坑 quick-protected-late-hint,
875
+ * 2026-08-28 用户实证:scan 类文档 ARCHITECTURE/CONCERNS 属受保护基线,--files 声明了照样拦、
876
+ * 必须 --force-baseline——设计合理但提示太晚,要等审计轮才发现,白跑一轮往返)。
877
+ * 判定与 auditQuickCompletion 危险门逐字同口径(isQuickMetadata 豁免 + 关联变更目录退栈 +
878
+ * DANGEROUS_PATTERNS 前缀/精确匹配),forceBaseline 已带 → 无预告。
879
+ * @param {string[]} files 声明文件(--files)
880
+ * @param {{ linkedChanges?: string[], forceBaseline?: boolean }} opts
881
+ * @returns {string[]} 将被拦截的声明文件子集
882
+ */
883
+ export function predictProtectedQuickFiles(files, { linkedChanges = [], forceBaseline = false } = {}) {
884
+ if (forceBaseline) return []
885
+ const linked = (Array.isArray(linkedChanges) ? linkedChanges : []).map(c => String(c).replace(/\\/g, '/'))
886
+ const isUnderLinkedChange = (f) => linked.some(lc => f.startsWith(`.sillyspec/changes/${lc}/`))
887
+ return (files || [])
888
+ .filter(Boolean)
889
+ .map(f => String(f).replace(/\\/g, '/'))
890
+ .filter(f => {
891
+ if (f.startsWith('.sillyspec/') && !isQuickMetadata(f, linked) && !isUnderLinkedChange(f)) return true
892
+ return QUICK_DANGEROUS_PATTERNS.some(p => p === f || f.startsWith(p))
893
+ })
894
+ }
895
+
816
896
  /**
817
897
  * quick 完成审计:对比 baseline 与实际变更。
818
898
  * @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 +1070,7 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
990
1070
  }
991
1071
  return foreignDeclaredIndex.get(f) || null
992
1072
  }
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
- ]
1073
+ const DANGEROUS_PATTERNS = QUICK_DANGEROUS_PATTERNS
1016
1074
 
1017
1075
  for (const entry of currentEntries) {
1018
1076
  // 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'
@@ -328,6 +328,15 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
328
328
  // (D-003@v1:顶层 quickGuard 不跨进程持久化;agent 在 step 间用 `run quick` 取下一步
329
329
  // prompt 时每个新进程都进此块,按文件判幂等才不会重复分配 ql-ID / 重复写条目)。
330
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
+ }
331
340
  // quick-sessions 目录经 resolveQuickSessionsDir 单一解析(multi-agent-review Q4):
332
341
  // 平台模式 runtimeRoot 与 specBase/.runtime 不同时,写/读须对齐,否则 guard 写一处、收尾读另一处。
333
342
  const sessionGuardDir = join(resolveQuickSessionsDir(platformOpts, specBase), changeName)
@@ -348,6 +357,13 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
348
357
  const known = new Set(progress.quickGuard.allowedFiles || [])
349
358
  const added = resumeFiles.filter(f => !known.has(f))
350
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)
351
367
  progress.quickGuard.allowedFiles = [...(progress.quickGuard.allowedFiles || []), ...added]
352
368
  progress.quickGuard.allowedFilesHash = { ...(progress.quickGuard.allowedFilesHash || {}) }
353
369
  for (const f of added) {
@@ -385,6 +401,10 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
385
401
  const allowDelete = quickOpts?.isAllowDelete || false
386
402
  const forceBaseline = quickOpts?.isForceBaseline || false
387
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)
388
408
  // CLI 接管:分配 ql-ID + 写 QUICKLOG「进行中」条目 + 关联 tasks.md(持锁、当天唯一)
389
409
  const gitUser = safeGit(cwd, ['config', 'user.name']).value || 'unknown'
390
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
  };
@@ -177,6 +177,11 @@ created_at: <now-datetime>
177
177
  ### 输出
178
178
  归档完成 + archive 目录路径`,
179
179
  outputHint: '归档确认',
180
+ // 坑 archive-batch-31-tool-notes ②:完成本步必须带 --confirm(complete-handlers 门控),
181
+ // 但 outputStep 机器生成的「完成后执行」提示原是通用 --done 模板不带 --confirm——agent
182
+ // 照抄执行就撞「请添加 --confirm」。requiresConfirm 让提示行带上该 flag(数据驱动,其他
183
+ // 阶段未来有确认步同样声明即可)。
184
+ requiresConfirm: true,
180
185
  optional: false
181
186
  },
182
187
  {
@@ -1214,6 +1214,7 @@ ${taskList}
1214
1214
  4. 每个任务完成后:
1215
1215
  - **在 worktree 内 git add -A && git commit -m "<task-NN 摘要>"**(坑 subagent-uncommitted-newfile-apply3way,2026-08-22 实证:纯新增文件不 commit 时 apply 的 git apply --3way 报 "does not exist in index" 直接炸——未 commit 的新文件不在 base commit 也不在 index,patch 生成取不到。commit 后 base..HEAD diff 完整、apply 顺畅,review.json 的 head 也有真实锚点)
1216
1216
  - **先写 review.json 再勾选 checkbox**(见下方 Task Review Gate)
1217
+ - **任务边界上报(每任务一次,主仓根目录)**:勾选 checkbox 后跑一次 \`sillyspec platform sync --change <change-name>\`——以任务粒度把「最后信号」(last_pushed_at)与 tasks.md 勾选状态推上平台(变更中心「进行中」可见性);未连接平台时该命令静默跳过,无需先检查连接状态
1217
1218
  - **既跑 lint check 也跑 formatter**:凡变更涉及的源码跑项目的 lint 检查 **和** 格式化(如 \`ruff format\` / \`prettier --write\`),不要只跑 check——只 check 不 format 会把格式问题留到 commit 时被 pre-commit hook 拦截(worktree 内二进制可能缺失,先 \`which <bin>\` 确认,缺则 \`uv tool install\` / \`uv sync\`)
1218
1219
  - 记录改动文件和测试结果
1219
1220
  5. 遇到 BLOCKED → 记录原因,选择:重试/跳过/停止
@@ -1265,10 +1266,11 @@ task-XX 对应:{SPEC_ROOT}/.runtime/execute-runs/{EXECUTE_RUN_ID}/tasks/task-X
1265
1266
 
1266
1267
  ### 完成后
1267
1268
  1. 为每个后端 router task 生成 API 端点 artifact——**用 CLI 命令,勿手扫装饰器手写(易漏 endpoint)**:
1268
- - 逐 task 运行:sillyspec endpoints extract --change <change> --task <task-NN>(<change> = 当前变更名)
1269
+ - 聚合一次跑全:sillyspec endpoints extract --change <change> --all-tasks(逐 task 卡 allowed_paths 各自提取落盘,<change> = 当前变更名;与 verify 探针 5 聚合对账口径一致)
1269
1270
  - CLI 静态扫描变更文件(FastAPI @router.* / Express router.* / Spring @*Mapping)生成
1270
1271
  {SPEC_ROOT}/.runtime/contract-artifacts/<task-name>/endpoints.json
1271
1272
  - 格式: { "task": "task-XX", "type": "backend_endpoints", "endpoints": [{ "method": "GET", "path": "/api/ppm/xxx" }] }(CLI 已按此格式写好,verify 探针 5 直接消费)
1273
+ - 只提单 task(--task task-NN)会让探针 5 用局部端点集对账放大假 missing——多 task 变更必用 --all-tasks
1272
1274
  `
1273
1275
  }
1274
1276