sillyspec 3.27.9 → 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.
@@ -92,7 +92,7 @@ execute 完成时,每个 task 必须有 `review.json` 且 verdict 通过,否
92
92
 
93
93
  > 🔧 **mechanics 字段不要手算**:`schemaVersion`/`task`/`base`/`head`/`changedFiles`(/跨仓 `repo`) 全部可由 CLI 从 git + task 卡推导。子代理/你只需写**语义字段**(`specVerdict`/`qualityVerdict`/`reviewerNotes`/`requiredEvidence`),mechanics 字段可以瞎填占位(如 `"base": "TODO"`),写完跑 `sillyspec backfill-reviews --change <变更名> --adopt` 一键重算代填(verdict 原样保留)。gate 拦下 mechanics 错误时也跑同一条命令修复。
94
94
 
95
- - 后端 router task 另需产出 API 端点清单:`sillyspec endpoints extract --change <变更名> --task <task-NN>`(CLI 静态扫描 FastAPI/Express/Spring 路由装饰器生成 endpoints.json,verify 探针 5 消费;扫描面默认取 task 卡 allowed_paths ∪ design 清单,也可 `--dir`/`--files` 显式指定)。勿手扫装饰器手写——易漏 endpoint。
95
+ - 后端 router task 另需产出 API 端点清单:`sillyspec endpoints extract --change <变更名> --all-tasks`(聚合逐 task 卡 allowed_paths 提取,与 verify 探针 5 聚合对账口径一致;单 task 可 `--task task-NN`,也可 `--dir`/`--files` 显式指定)。勿手扫装饰器手写——易漏 endpoint;勿只提单 task——局部端点集对账放大假 missing。
96
96
 
97
97
  ### Stage Review Gate(execute 末尾的 acceptance review.json)
98
98
 
package/.npmrc-temp ADDED
@@ -0,0 +1 @@
1
+ //registry.npmjs.org/:_authToken=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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.27.9",
3
+ "version": "3.27.11",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
@@ -71,6 +71,28 @@ function classifyStagePath(p) {
71
71
  return null
72
72
  }
73
73
 
74
+ /**
75
+ * 暂存区快照(坑 same-main-staging-pollution,2026-08-28 用户实证:同 main 两个活跃会话
76
+ * 共享同一暂存区,A 的 commit 把 B 暂存的文件扫入——本轮竞态事故根因,无任何防护)。
77
+ * 防护 = 提交前可见:commit 提示里带 `git diff --cached --name-only` 快照 + 他者文件告警。
78
+ * @param {{ cwd: string, ownFiles?: string[]|null }} opts
79
+ * ownFiles:本会话/本变更的精确文件集(apply-pathspec 等);提供时对快照做差集,
80
+ * 不在集内的暂存文件 = 他者会话暂存(告警对象)。null/空数组 = 只出快照不做归属判定。
81
+ * @returns {{ available: boolean, staged: string[], foreign: string[] }}
82
+ * available=false:git 不可用/非仓库(调用方静默跳过,不误报)。
83
+ */
84
+ export function collectStagedArea({ cwd, ownFiles = null }) {
85
+ const raw = gitQuiet(cwd, ['diff', '--cached', '--name-only'])
86
+ if (raw === null) return { available: false, staged: [], foreign: [] }
87
+ const staged = raw.split('\n').filter(Boolean).map(f => f.replace(/\\/g, '/'))
88
+ let foreign = []
89
+ if (Array.isArray(ownFiles) && ownFiles.length > 0) {
90
+ const own = new Set(ownFiles.map(f => String(f).replace(/\\/g, '/')))
91
+ foreign = staged.filter(f => !own.has(f))
92
+ }
93
+ return { available: true, staged, foreign }
94
+ }
95
+
74
96
  /**
75
97
  * 收集提交上下文并生成建议 message。
76
98
  * @param {{ cwd: string, specDir?: string|null }} opts
@@ -392,10 +392,13 @@ export function _readWorktreeMeta(specBase, scanRoot, changeName) {
392
392
 
393
393
  /**
394
394
  * 取本变更 diff 文件集(parity 前端调用收窄用,坑 probe5-fullrepo-frontend-noise)。
395
- * 优先 worktree meta 锚点 diff(与 verify 同源);无 meta 退主仓 git diff HEAD。
395
+ * 优先 worktree meta 锚点 diff(与 verify 同源);无 meta 退主仓 git diff HEAD;
396
+ * 两者皆空再退 apply-pathspec-<change>.txt(apply 落盘的精确清单)。
397
+ * 兜底链每级都空才返回 null/[](调用方回退全仓——坑 probe5-single-task-artifact-scope,
398
+ * 2026-08-28 实证:apply+commit/cleanup 后 diff 为空 → 全仓调用 × 局部端点 = 150 条假 missing)。
396
399
  * @returns {string[]|null}
397
400
  */
398
- function _resolveDiffFilesForParity(specBase, scanRoot, changeName) {
401
+ function _resolveDiffFilesForParity(specBase, scanRoot, changeName, runtimeRoot) {
399
402
  try {
400
403
  const wt = _readWorktreeMeta(specBase, scanRoot, changeName)
401
404
  if (wt) {
@@ -429,7 +432,20 @@ function _resolveDiffFilesForParity(specBase, scanRoot, changeName) {
429
432
  if (foreign.length > 0) {
430
433
  console.warn(`⚠️ parity 对账已排除 ${foreign.length} 个并行会话声明的文件(${foreign.slice(0, 5).map(x => x.file).join(', ')}${foreign.length > 5 ? ' 等' : ''})`)
431
434
  }
432
- return own
435
+ if (own.length > 0) return own
436
+ // 主仓 diff 空(apply 后已 commit / cleanup 完毕)→ apply-pathspec 兜底:apply 成功时落盘的
437
+ // 本变更精确文件清单(与 gates 归因提示②同源)。仍拿不到才回 null(调用方退全仓)。
438
+ try {
439
+ const pathspecFile = join(runtimeRoot || join(specBase, '.runtime'), `apply-pathspec-${changeName}.txt`)
440
+ if (existsSync(pathspecFile)) {
441
+ const fromPathspec = readFileSync(pathspecFile, 'utf8').split('\n').map(l => l.trim()).filter(Boolean)
442
+ if (fromPathspec.length > 0) {
443
+ console.log(`ℹ️ parity 前端范围用 apply-pathspec 兜底(主仓 diff 为空,${fromPathspec.length} 文件)`)
444
+ return fromPathspec
445
+ }
446
+ }
447
+ } catch { /* 兜底失败 → null 交调用方全仓 */ }
448
+ return null
433
449
  } catch { return null }
434
450
  }
435
451
 
@@ -526,7 +542,7 @@ export function verifyApiParity(specBase, scanRoot, runtimeRoot, changeName = nu
526
542
  const frontendRootLabel = frontendRoot === scanRoot ? 'scan-root' : 'worktree'
527
543
  if (changeName) {
528
544
  try {
529
- const changed = _resolveDiffFilesForParity(specBase, scanRoot, changeName)
545
+ const changed = _resolveDiffFilesForParity(specBase, scanRoot, changeName, runtimeRoot)
530
546
  if (changed && changed.length > 0) {
531
547
  const changedSet = new Set(changed.map(f => f.replace(/\\/g, '/')))
532
548
  frontendCalls = scanFrontendApiCalls(frontendRoot).filter(c => {
@@ -22,7 +22,7 @@
22
22
  * 零输出:无 decisions.md 或无入选条目 → skipped 带原因,不写任何文件、不动 INDEX。
23
23
  */
24
24
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
25
- import { dirname, join } from 'node:path'
25
+ import { basename, dirname, join } from 'node:path'
26
26
 
27
27
  /** FR-02:有实现影响的五类 type(scope 与未知 type 的 confirmed/accepted 不入选) */
28
28
  const IMPLEMENTED_TYPES = new Set(['architecture', 'compatibility', 'boundary', 'definition', 'process'])
@@ -244,10 +244,14 @@ function newDomainPreamble(domain) {
244
244
  ]
245
245
  }
246
246
 
247
- /** 单条目段落渲染(design 接口定义的条目格式,逐字对齐:状态/锚点/最近确认/理由 + rejected 专属两行) */
248
- function renderBlockLines(entry, headHash, supersedesNote) {
247
+ /** 单条目段落渲染(design 接口定义的条目格式,逐字对齐:状态/锚点/最近确认/理由 + rejected 专属两行)。
248
+ * 变更:行(2026-08-28,坑 distill-cross-change-supersede):D-xxx 编号是变更内局部序号,
249
+ * 跨变更同号(两个 D-002)互不相干——条目必须携带变更名限定,消费方(knowledge-match/
250
+ * docs-check 的字段行解析)只认各自标签,新增字段行是增量安全。 */
251
+ function renderBlockLines(entry, headHash, supersedesNote, changeName) {
249
252
  const lines = [`## ${entry.id} ${oneLine(entry.title)}`.replace(/\s+$/, '')]
250
253
  lines.push(`状态:${entry.selected}`)
254
+ if (changeName) lines.push(`变更:${oneLine(changeName)}`)
251
255
  lines.push(`锚点:${oneLine(entry.anchor) || NOT_RECORDED}`)
252
256
  lines.push(`最近确认:${oneLine(headHash) || NOT_RECORDED}`)
253
257
  lines.push(`理由:${oneLine(entry.answer || entry.normalizedRequirement || entry.question)}`)
@@ -259,7 +263,9 @@ function renderBlockLines(entry, headHash, supersedesNote) {
259
263
  return lines
260
264
  }
261
265
 
262
- /** 知识文件切段:preamble 行 + D-xxx@vN 条目段(段边界 = 下一 `## D-` 头) */
266
+ /** 知识文件切段:preamble 行 + D-xxx@vN 条目段(段边界 = 下一 `## D-` 头)。
267
+ * 段的变更归属取段内 `变更:<name>` 行;无该行的历史条目 → change=null(legacy:
268
+ * 升级前落库的条目,不参与新条目的同号匹配/supersede——只共存不误删)。 */
263
269
  function splitKnowledgeSections(content) {
264
270
  const sections = []
265
271
  const preamble = []
@@ -275,7 +281,11 @@ function splitKnowledgeSections(content) {
275
281
  else preamble.push(line)
276
282
  }
277
283
  if (cur) sections.push(cur)
278
- for (const s of sections) s.id = `${s.number}@v${s.version}`
284
+ for (const s of sections) {
285
+ s.id = `${s.number}@v${s.version}`
286
+ const cm = s.lines.find(l => /^变更[::]/.test(l))
287
+ s.change = cm ? cm.replace(/^变更[::]\s*/, '').trim() || null : null
288
+ }
279
289
  return { preamble, sections }
280
290
  }
281
291
 
@@ -293,15 +303,20 @@ function joinKnowledgeFile(preamble, sections) {
293
303
 
294
304
  /**
295
305
  * 单条目写入一个域文件的段落集(就地修改 sections)。
296
- * @returns {{ action: 'append'|'update'|'supersede' }|null} null = 高版本已落库,迟到低版本不降级。
306
+ * 坑 distill-cross-change-supersede(2026-08-28 用户实证:跨变更同号 D-002 在 knowledge
307
+ * 里互相 supersede):幂等键从「号」改为「号+变更」——同号匹配、版本守卫、旧版本段清除
308
+ * 全部限定在 entry.change 内;他者变更的同号段不参与(共存)。legacy 段(change=null,
309
+ * 升级前落库)不与任何新条目互认,只共存不误删。
310
+ * @returns {{ action: 'append'|'update'|'supersede' }|null} null = 同变更同号高版本已落库,迟到低版本不降级。
297
311
  */
298
312
  function applyEntryToSections(sections, entry, headHash) {
299
- const sameNumber = sections.filter(s => s.number === entry.number)
313
+ const change = entry.change || null
314
+ const sameNumber = sections.filter(s => s.number === entry.number && (s.change || null) === change)
300
315
  if (sameNumber.some(s => s.version > entry.version)) return null
301
316
  let action
302
317
  let supersedesNote = oneLine(entry.supersedes) || null
303
318
  if (sameNumber.length === 0) {
304
- sections.push({ number: entry.number, version: entry.version, id: entry.id, title: entry.title, lines: renderBlockLines(entry, headHash, supersedesNote) })
319
+ sections.push({ number: entry.number, version: entry.version, id: entry.id, title: entry.title, change, lines: renderBlockLines(entry, headHash, supersedesNote, change || undefined) })
305
320
  action = 'append'
306
321
  } else {
307
322
  const same = sameNumber.find(s => s.version === entry.version)
@@ -309,10 +324,10 @@ function applyEntryToSections(sections, entry, headHash) {
309
324
  if (!same && !supersedesNote) supersedesNote = `${slot.number}@v${slot.version}`
310
325
  slot.version = entry.version
311
326
  slot.title = entry.title
312
- slot.lines = renderBlockLines(entry, headHash, supersedesNote)
313
- // 同号旧版本段(< 新版本)就地清除——同文件内同号只留一个段
327
+ slot.lines = renderBlockLines(entry, headHash, supersedesNote, change || undefined)
328
+ // 同变更同号旧版本段(< 新版本)就地清除——同文件内同变更同号只留一个段
314
329
  for (let i = sections.length - 1; i >= 0; i--) {
315
- if (sections[i].number === entry.number && sections[i] !== slot) sections.splice(i, 1)
330
+ if (sections[i].number === entry.number && (sections[i].change || null) === change && sections[i] !== slot) sections.splice(i, 1)
316
331
  }
317
332
  action = same ? 'update' : 'supersede'
318
333
  }
@@ -412,6 +427,9 @@ function syncIndexRoutingLines(knowledgeRoot) {
412
427
  * 缺否决理由/复潮条件被拦下(该条目未写盘,其余条目照常提炼)。
413
428
  */
414
429
  export function distillIntoKnowledge(changeDir, knowledgeRoot, headHash, moduleIndex = null) {
430
+ // 变更名限定(坑 distill-cross-change-supersede):条目幂等键 = 号+变更。changeDir 末段
431
+ // 即变更名(changes/<name> 与 archive 后的 changes/archive/<name> 同为 <name>)。
432
+ const changeName = basename(changeDir)
415
433
  const parsed = parseDecisions(changeDir)
416
434
  if (parsed.missing) {
417
435
  return { written: [], skipped: `decisions.md 不存在(${join(changeDir, 'decisions.md')}),零输出`, needsWait: null }
@@ -450,7 +468,7 @@ export function distillIntoKnowledge(changeDir, knowledgeRoot, headHash, moduleI
450
468
  const taken = new Set()
451
469
  for (const e of distillable) {
452
470
  const keep = highestByNumber.get(e.number)
453
- if (keep && !taken.has(e.number)) { taken.add(e.number); batch.push(keep) }
471
+ if (keep && !taken.has(e.number)) { taken.add(e.number); keep.change = changeName; batch.push(keep) }
454
472
  }
455
473
 
456
474
  const decisionsDir = join(knowledgeRoot, 'decisions')
@@ -489,7 +507,8 @@ export function distillIntoKnowledge(changeDir, knowledgeRoot, headHash, moduleI
489
507
  }
490
508
  }
491
509
 
492
- // 跨文件清理:同号 ≤ 新版本的旧段散落其他域文件时移除(同号全局只留最高版本/目标文件)
510
+ // 跨文件清理:同变更同号 ≤ 新版本的旧段散落其他域文件时移除(同变更同号全局只留最高
511
+ // 版本/目标文件);他者变更的同号段不参与(共存,坑 distill-cross-change-supersede)
493
512
  let onDisk = []
494
513
  try { onDisk = readdirSync(decisionsDir).filter(f => f.endsWith('.md')).map(f => f.replace(/\.md$/, '')) } catch { /* 刚创建,走 store */ }
495
514
  const allDomains = [...new Set([...onDisk, ...store.keys()])]
@@ -498,7 +517,7 @@ export function distillIntoKnowledge(changeDir, knowledgeRoot, headHash, moduleI
498
517
  for (const d of allDomains) {
499
518
  if (targets.has(d)) continue
500
519
  const st = loadDomain(d)
501
- const removed = st.sections.filter(s => s.number === entry.number && s.version <= entry.version)
520
+ const removed = st.sections.filter(s => s.number === entry.number && s.version <= entry.version && (s.change || null) === (entry.change || null))
502
521
  if (removed.length === 0) continue
503
522
  st.sections = st.sections.filter(s => !removed.includes(s))
504
523
  st.dirty = true
package/src/docs-check.js CHANGED
@@ -630,10 +630,20 @@ function extractKnownFailureKeys(yamlText) {
630
630
  if (inline) {
631
631
  return inline[1].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
632
632
  }
633
- const block = yaml.match(/^known_failures:\s*\n((?:[ \t]+-[ \t].+\n?)+)/m)
633
+ // 坑 verify-known-failures-comment-line-truncation 同款(与 verify-postcheck.extractKnownFailures
634
+ // 口径互指对齐):块内注释行/空行打断连续列表项捕获链 → 注释后的键静默丢失。块定义放行
635
+ // 注释行与空行;提取侧只认 - 项行;行尾注释须 # 前有空白(裸 # 不截);引号值原样保留。
636
+ const block = yaml.match(/^known_failures:\s*\n((?:[ \t]+-[ \t].+\n?|[ \t]*#[^\n]*\n?|[ \t]*\n)+)/m)
634
637
  if (block) {
635
638
  return (block[1].match(/^[ \t]+-[ \t]+(.+)/gm) || [])
636
- .map(s => s.replace(/^[ \t]+-[ \t]+/, '').trim().replace(/^['"]|['"]$/g, '').replace(/#.*$/, '').trim())
639
+ .map(s => {
640
+ const item = s.replace(/^[ \t]+-[ \t]+/, '').trim()
641
+ const full = item.match(/^(['"])(.*)\1$/)
642
+ if (full) return full[2]
643
+ const stripped = item.replace(/[ \t]+#.*$/, '').trim()
644
+ const q2 = stripped.match(/^(['"])(.*)\1$/)
645
+ return q2 ? q2[2] : stripped
646
+ })
637
647
  .filter(Boolean)
638
648
  }
639
649
  return []
@@ -0,0 +1,170 @@
1
+ /**
2
+ * facade-hint.js — plan 门「透传必经文件」候选探测(advisory)。
3
+ *
4
+ * 坑 plan-facade-files-manual-backfill(2026-08-28 用户实证 ×2):facade 文件(如
5
+ * daemon/service.py——新端点/新模块必须在它里面挂载/转发才对外生效)不进 design §6 /
6
+ * TaskCard allowed_paths,执行期子代理按现实改它 → apply Gate1 拦截 → 人工回补
7
+ * allowed_paths 两轮。本模块在 plan 完成时做静态引用扫描,把候选透传/聚合文件提前
8
+ * 亮给 agent(warning 不阻断):判定是语义,agent 拍板补不补。
9
+ *
10
+ * 两个信号(语言覆盖 py/js/ts/mjs/cjs/jsx/tsx/java 的 import/require 静态文本匹配):
11
+ * - direct:仓内文件 import 了 allowed_paths 里的模块(改被 facade 引用的模块,
12
+ * facade 常需同步改——转发签名/挂载点);
13
+ * - aggregate:仓内文件 import 了该模块同目录下 ≥2 个模块(聚合器/注册表形态,
14
+ * 新增同目录模块几乎必然要在此登记——service.py 挂 router 即此形态)。
15
+ *
16
+ * 启发式 → 恒 advisory:候选只列不改,由 agent 判定补 allowed_paths / design §6,
17
+ * 或确认无关忽略。性能护栏:allowed 条目 > 100 或仓内源码文件 > 5000 时静默跳过。
18
+ */
19
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
20
+ import { join, dirname, basename, extname, resolve } from 'path'
21
+
22
+ const SOURCE_EXTS = new Set(['.py', '.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs', '.java'])
23
+ const SKIP_DIRS = new Set([
24
+ '.git', 'node_modules', 'dist', 'build', 'out', '.next', '__pycache__',
25
+ '.venv', 'venv', 'env', '.sillyspec', '.worktrees', '.claude', '.runtime',
26
+ 'coverage', 'target', '.idea', '.vscode',
27
+ ])
28
+ const MAX_ALLOWED_ENTRIES = 100
29
+ const MAX_REPO_FILES = 5000
30
+ const MAX_FILE_BYTES = 512 * 1024
31
+
32
+ const toPosix = (p) => String(p).replace(/\\/g, '/')
33
+
34
+ /** 递归收集仓内源码文件(posix 相对路径),带总量护栏。 */
35
+ function walkSourceFiles(root, out = []) {
36
+ if (out.length > MAX_REPO_FILES) return out
37
+ let names
38
+ try { names = readdirSync(root) } catch { return out }
39
+ for (const name of names) {
40
+ if (SKIP_DIRS.has(name)) continue
41
+ const abs = join(root, name)
42
+ let st
43
+ try { st = statSync(abs) } catch { continue }
44
+ if (st.isDirectory()) walkSourceFiles(abs, out)
45
+ else if (st.isFile() && SOURCE_EXTS.has(extname(name).toLowerCase()) && st.size <= MAX_FILE_BYTES) {
46
+ out.push(toPosix(join(root, name)))
47
+ if (out.length > MAX_REPO_FILES) return out
48
+ }
49
+ }
50
+ return out
51
+ }
52
+
53
+ /** 从源码文本提取 import/require 说明符(py dotted + js/ts path-ish)。 */
54
+ export function extractImportSpecifiers(text, ext) {
55
+ const specs = new Set()
56
+ if (ext === '.py') {
57
+ // from X import a, b → 记 pkg 本身 + 逐名拼 'X.a'/'X.b'(前者保 from X.y import sym 的
58
+ // 直接引用形态,后者保 service.py 聚合挂载形态:from daemon.routers import users, orders)
59
+ for (const m of text.matchAll(/\bfrom\s+([.\w]+)\s+import\s+([^\n#]+)/g)) {
60
+ specs.add(m[1])
61
+ const names = m[2].split(',')
62
+ .map(s => s.trim().split(/\s+as\s+/i)[0].trim())
63
+ .filter(n => /^\w+$/.test(n))
64
+ for (const n of names) specs.add(`${m[1]}.${n}`)
65
+ }
66
+ for (const m of text.matchAll(/(?:^|\n)\s*import\s+([\w.]+)/g)) specs.add(m[1])
67
+ } else if (ext === '.java') {
68
+ for (const m of text.matchAll(/\bimport\s+([\w.]+)\s*;/g)) specs.add(m[1])
69
+ } else {
70
+ for (const m of text.matchAll(/(?:from\s+|require\(\s*|import\(\s*)['"]([^'"]+)['"]/g)) specs.add(m[1])
71
+ }
72
+ return [...specs]
73
+ }
74
+
75
+ /** 说明符是否指向模块名 mod(`m` / `pkg.m` / `./m` / `../d/m` 等尾部命中形态)。 */
76
+ export function specRefsModule(spec, mod) {
77
+ if (!spec || !mod) return false
78
+ if (spec === mod) return true
79
+ return spec.endsWith('.' + mod) || spec.endsWith('/' + mod)
80
+ }
81
+
82
+ /**
83
+ * 探测 allowed_paths 之外的 facade/聚合候选文件。
84
+ *
85
+ * @param {{ cwd: string, allowedPaths: string[] }} opts
86
+ * @returns {{ skipped: boolean, reason: string|null, candidates: Array<{file: string, via: string[]}> }}
87
+ */
88
+ export function findFacadeCandidates({ cwd, allowedPaths }) {
89
+ const root = cwd || process.cwd()
90
+ const allowed = (allowedPaths || []).map(toPosix).filter(p => !p.includes('*'))
91
+ if (allowed.length === 0) return { skipped: true, reason: 'no-allowed-paths', candidates: [] }
92
+ if (allowed.length > MAX_ALLOWED_ENTRIES) return { skipped: true, reason: 'allowed-too-many', candidates: [] }
93
+
94
+ const files = walkSourceFiles(root)
95
+ if (files.length > MAX_REPO_FILES) return { skipped: true, reason: 'repo-too-large', candidates: [] }
96
+
97
+ const allowedSet = new Set(allowed)
98
+ const isAllowed = (abs) => allowed.some(a => abs === a || abs.endsWith('/' + a) || a.endsWith('/' + abs) || abs.replace(/\.[^.]+$/, '') === a.replace(/\.[^.]+$/, ''))
99
+
100
+ // 目录 → 该目录下模块名索引(聚合信号用:R import 同目录 ≥2 模块 = 聚合器)
101
+ const dirModules = new Map()
102
+ for (const f of files) {
103
+ const d = dirname(f)
104
+ if (!dirModules.has(d)) dirModules.set(d, new Set())
105
+ dirModules.get(d).add(basename(f, extname(f)))
106
+ }
107
+
108
+ // 预读一遍源码 import(file → specs),两信号共用
109
+ const fileSpecs = []
110
+ for (const f of files) {
111
+ if (isAllowed(f)) continue
112
+ let text
113
+ try { text = readFileSync(f, 'utf8') } catch { continue }
114
+ fileSpecs.push({ file: f, specs: extractImportSpecifiers(text, extname(f).toLowerCase()) })
115
+ }
116
+
117
+ // 逐 allowed 文件探测 direct + aggregate 命中
118
+ const byFile = new Map()
119
+ for (const p of allowed) {
120
+ const mod = basename(p, extname(p))
121
+ // allowed 是相对仓根路径,目录索引键是绝对路径(walk 产物)——resolve 对齐再查
122
+ const dirKey = toPosix(resolve(root, dirname(p)))
123
+ const dirLabel = toPosix(dirname(p))
124
+ const dirModSet = dirModules.get(dirKey) || new Set()
125
+ for (const { file, specs } of fileSpecs) {
126
+ const via = []
127
+ if (specs.some(s => specRefsModule(s, mod))) via.push(`direct import of ${mod}`)
128
+ // aggregate:R 引用 mod 同目录 ≥2 个不同仓内模块(mod 自身可不在——新增模块形态)
129
+ const hitMods = new Set()
130
+ for (const s of specs) {
131
+ for (const b of dirModSet) {
132
+ if (specRefsModule(s, b)) { hitMods.add(b); break }
133
+ }
134
+ }
135
+ if (hitMods.size >= 2) via.push(`aggregates ≥2 modules of ${dirLabel === '.' ? './' : dirLabel + '/'}`)
136
+ if (via.length === 0) continue
137
+ const existing = byFile.get(file) || []
138
+ for (const v of via) if (!existing.includes(v)) existing.push(v)
139
+ byFile.set(file, existing)
140
+ }
141
+ }
142
+ const candidates = [...byFile.entries()]
143
+ .map(([file, via]) => ({ file: toPosix(file), via }))
144
+ .sort((a, b) => a.file.localeCompare(b.file))
145
+ return { skipped: false, reason: null, candidates }
146
+ }
147
+
148
+ /**
149
+ * plan 门消费入口:探测 + 打印(advisory,不阻断)。allowed 集与 apply Gate1 同源
150
+ * (resolveApplyAllowSet 的主仓切片),确保「预检亮出的候选」与「执行期会拦的门」
151
+ * 用同一份清单口径。
152
+ */
153
+ export function warnFacadeCandidateFiles({ cwd, changeName, allowSet }) {
154
+ try {
155
+ const r = findFacadeCandidates({ cwd, allowedPaths: [...(allowSet || [])] })
156
+ if (r.skipped || r.candidates.length === 0) return r
157
+ console.warn(`\n⚠️ plan 预检:${r.candidates.length} 个 allowed_paths 外的「透传/聚合」候选文件(advisory,不阻断):`)
158
+ for (const c of r.candidates.slice(0, 10)) {
159
+ console.warn(` - ${c.file}(${[...new Set(c.via)].join('; ')})`)
160
+ }
161
+ if (r.candidates.length > 10) console.warn(` …还有 ${r.candidates.length - 10} 个`)
162
+ console.warn(' 若属「透传必经文件」(facade 转发/路由挂载/注册登记——改 allowed 模块必须同步改它才生效):')
163
+ console.warn(' 现在补进对应 TaskCard allowed_paths 或 design §6 清单,避免执行期 apply Gate1 拦截后手工回补;')
164
+ console.warn(' 确认无关则忽略。执行期有据越界另有出口(review.json 声明 changedFiles,apply 有审计放行)。')
165
+ return r
166
+ } catch (e) {
167
+ console.warn(`⚠️ facade 候选探测失败(advisory 跳过): ${e.message}`)
168
+ return { skipped: true, reason: 'error', candidates: [] }
169
+ }
170
+ }
package/src/index.js CHANGED
@@ -98,7 +98,7 @@ SillySpec CLI — 规范驱动开发工具包
98
98
  sillyspec commit [--json] 智能提交建议:收集 QUICKLOG/已勾 task/阶段产出语义,生成建议 message(只建议不执行)
99
99
  sillyspec verify-probes --change <name> [--init] verify 机械探针(TODO 标记/测试覆盖/API 对账/删除对账);--init 生成 verify-result.md 骨架
100
100
  sillyspec module-impact --change <name> 生成 module-impact.md 骨架(文件×模块归属按 module-map 预填 + 未匹配清单)
101
- sillyspec endpoints extract --change <name> [--task task-NN] [--dir <dir>|--files <a.py,b.js>] 静态扫描路由装饰器生成 endpoints.json
101
+ sillyspec endpoints extract --change <name> [--task task-NN | --all-tasks] [--dir <dir>|--files <a.py,b.js>] 静态扫描路由装饰器生成 endpoints.json
102
102
  sillyspec scan-fix-headers [--project <名>] scan 文档补 author/created_at header(幂等)
103
103
  sillyspec plan-adopt-waves --change <名> [--dry-run] plan.md Wave 段一键重排为 depends_on 拓扑分组(同步任务总表 W 列,幂等)
104
104
  sillyspec workspace add <名> <路径> [--role] [--repo] | remove <名> | status 多项目工作区登记与状态探测
@@ -743,6 +743,20 @@ async function main() {
743
743
  }
744
744
  console.log(`📦 待提交变更(${cc.changedCount} 个文件${cc.untrackedCount > 0 ? `,其中未跟踪 ${cc.untrackedCount} 个` : ''};上方 stat 仅列已跟踪文件的改动)`);
745
745
  if (cc.stat) console.log(cc.stat.split('\n').map(l => ' ' + l).join('\n'));
746
+ // 暂存区快照(坑 same-main-staging-pollution,2026-08-28 实证:同 main 双会话共享暂存区,
747
+ // A 的 commit 扫入 B 暂存的文件):git commit 只含暂存内容——提交前把快照亮出来供核对。
748
+ {
749
+ const { collectStagedArea } = await import('./commit-suggest.js');
750
+ const staged = collectStagedArea({ cwd: dir });
751
+ if (staged.available && staged.staged.length > 0) {
752
+ console.log(`\n📌 暂存区快照(git diff --cached --name-only,你的下一次 git commit 将恰好含这 ${staged.staged.length} 个文件):`);
753
+ for (const f of staged.staged.slice(0, 15)) console.log(` - ${f}`);
754
+ if (staged.staged.length > 15) console.log(` … 共 ${staged.staged.length} 个`);
755
+ console.log(` ⚠️ 同 main 并行会话共享同一暂存区——快照里非本会话的文件请先 git restore --staged <file> 剔除,否则会被你的 commit 扫入`);
756
+ } else if (staged.available) {
757
+ console.log(`\n📌 暂存区为空(git commit 前需先 git add;用 pathspec 级 add 防扫入无关文件)`);
758
+ }
759
+ }
746
760
  if (cc.quickEntries.length > 0) {
747
761
  console.log(`\n📝 QUICKLOG(上次提交 ${cc.lastCommitTime || '(无)'} 之后 ${cc.quickEntries.length} 条):`);
748
762
  for (const q of cc.quickEntries) {
@@ -892,15 +906,19 @@ async function main() {
892
906
  // 此前靠 agent 手扫装饰器手写(易漏 endpoint)。CLI 复用 endpoint-extractor 全套提取器
893
907
  // (FastAPI @router.* / Express router.* / Spring @*Mapping)静态扫描生成,
894
908
  // verify 探针 5(verifyApiParity)直接消费该产物。
909
+ // --all-tasks(坑 probe5-single-task-artifact-scope,2026-08-28 实证:单 task 产物对账
910
+ // 产生大量假 missing):聚合模式——逐 task 卡提取,各自落 contract-artifacts/<change>/<task>/,
911
+ // 与 verifyApiParity 的聚合读侧(contract-artifacts/<change>/*/endpoints.json 全量并集)对齐。
895
912
  const epSub = filteredArgs[1];
896
913
  if (epSub !== 'extract') {
897
- console.error('用法: sillyspec endpoints extract --change <name> [--task task-NN] [--dir <backendRoot> | --files <a.py,b.js...>] [--spec-dir <path>]\n 静态扫描后端路由装饰器生成 endpoints.json(FastAPI/Express/Spring);verify 探针 5 消费');
914
+ console.error('用法: sillyspec endpoints extract --change <name> [--task task-NN | --all-tasks] [--dir <backendRoot> | --files <a.py,b.js...>] [--spec-dir <path>]\n 静态扫描后端路由装饰器生成 endpoints.json(FastAPI/Express/Spring);verify 探针 5 消费。\n --all-tasks:聚合逐 task 卡 allowed_paths 各自提取(对账口径与探针 5 聚合读侧一致,推荐)');
898
915
  process.exit(2);
899
916
  }
900
917
  const epChangeIdx = args.indexOf('--change');
901
918
  const epChange = epChangeIdx >= 0 && args[epChangeIdx + 1] ? args[epChangeIdx + 1] : null;
902
919
  const epTaskIdx = args.indexOf('--task');
903
920
  const epTask = epTaskIdx >= 0 && args[epTaskIdx + 1] ? args[epTaskIdx + 1] : null;
921
+ const epAllTasks = args.includes('--all-tasks');
904
922
  const epDirIdx = args.indexOf('--dir');
905
923
  const epDir = epDirIdx >= 0 && args[epDirIdx + 1] ? args[epDirIdx + 1] : null;
906
924
  const epFilesIdx = args.indexOf('--files');
@@ -909,6 +927,10 @@ async function main() {
909
927
  console.error('❌ 缺 --change <变更名>');
910
928
  process.exit(2);
911
929
  }
930
+ if (epAllTasks && (epTask || epDir || epFilesRaw)) {
931
+ console.error('❌ --all-tasks 与 --task/--dir/--files 互斥(聚合模式逐 task 卡自动取 allowed_paths)');
932
+ process.exit(2);
933
+ }
912
934
  assertSafeChangeName(epChange, '--change 变更名');
913
935
  const { extractFastApiEndpoints, extractExpressEndpoints, extractSpringEndpoints, scanBackendEndpoints } = await import('./endpoint-extractor.js');
914
936
  const endpoints = [];
@@ -928,10 +950,56 @@ async function main() {
928
950
  else if (ext === '.java') endpoints.push(...extractSpringEndpoints(abs))
929
951
  } catch { /* 单文件解析失败不阻断整体 */ }
930
952
  }
953
+ // 单 task 产物写盘(taskName 归属目录);返回提取数
954
+ const epRuntimeRoot = (await import('./run/shared.js')).resolveRuntimeRoot({ specRoot: specDir }, epSpecBase);
955
+ const writeArtifact = (taskName, eps) => {
956
+ const artifact = {
957
+ generated_at: new Date().toISOString().slice(0, 19).replace('T', ' '),
958
+ generator: 'sillyspec endpoints extract',
959
+ endpoints: eps.map(e => ({ method: e.method, path: e.path, source: e.source })),
960
+ }
961
+ const epOutDir = join(epRuntimeRoot, 'contract-artifacts', epChange, taskName);
962
+ mkdirSync(epOutDir, { recursive: true });
963
+ const epOutPath = join(epOutDir, 'endpoints.json');
964
+ writeFileSync(epOutPath, JSON.stringify(artifact, null, 2) + '\n');
965
+ return epOutPath;
966
+ }
931
967
  if (epDir) {
932
- endpoints.push(...scanBackendEndpoints(resolve(epDir)))
968
+ endpoints.push(...scanBackendEndpoints(resolve(epDir)));
933
969
  } else if (epFilesRaw) {
934
970
  for (const f of epFilesRaw.split(',').map(s => s.trim()).filter(Boolean)) collectFile(f)
971
+ } else if (epAllTasks) {
972
+ // 聚合模式:逐 task 卡 allowed_paths 提取,各自落产物目录(对齐 verifyApiParity 聚合读侧)
973
+ const { parseAllowedPaths } = await import('./stages/plan-postcheck.js')
974
+ const tasksDir = join(epChangeDir, 'tasks')
975
+ const taskCards = existsSync(tasksDir)
976
+ ? readdirSync(tasksDir).filter(f => /^task-[\w.-]+\.md$/.test(f)).sort()
977
+ : []
978
+ if (taskCards.length === 0) {
979
+ console.error(`❌ --all-tasks 无 task 卡(${tasksDir} 不存在或无 task-*.md)——先跑 plan 生成 TaskCard`);
980
+ process.exit(1);
981
+ }
982
+ let total = 0
983
+ for (const card of taskCards) {
984
+ const taskName = card.replace(/\.md$/, '')
985
+ const eps = []
986
+ const perFile = (f) => {
987
+ const abs = resolve(f)
988
+ if (!existsSync(abs)) return
989
+ const ext = extname(abs).toLowerCase()
990
+ try {
991
+ if (ext === '.py') eps.push(...extractFastApiEndpoints(abs))
992
+ else if (ext === '.js' || ext === '.ts') eps.push(...extractExpressEndpoints(abs))
993
+ else if (ext === '.java') eps.push(...extractSpringEndpoints(abs))
994
+ } catch { /* 单文件解析失败不阻断整体 */ }
995
+ }
996
+ for (const p of parseAllowedPaths(readFileSync(join(tasksDir, card), 'utf8'))) perFile(p)
997
+ const outPath = writeArtifact(taskName, eps)
998
+ total += eps.length
999
+ console.log(`ℹ️ ${taskName}: ${eps.length} 个端点 → ${outPath}`)
1000
+ }
1001
+ console.log(`✅ 聚合提取完成:${taskCards.length} 个 task 卡共 ${total} 个端点(探针 5 对账时全量并集消费)`);
1002
+ break;
935
1003
  } else {
936
1004
  // 默认扫描面:task 卡 allowed_paths ∪ design §6 清单里的具体源码文件
937
1005
  const { parseFileChangeListDetailed } = await import('./change-list.js')
@@ -957,22 +1025,13 @@ async function main() {
957
1025
  collectFile(p)
958
1026
  }
959
1027
  if (candidates.size === 0) {
960
- console.error('❌ 无扫描面:--task 卡无 allowed_paths 且 design.md 无具体文件清单——显式传 --dir <backendRoot> 或 --files <a.py,b.js>');
1028
+ console.error('❌ 无扫描面:--task 卡无 allowed_paths 且 design.md 无具体文件清单——显式传 --dir <backendRoot> 或 --files <a.py,b.js>,或用 --all-tasks 聚合逐卡提取');
961
1029
  process.exit(1);
962
1030
  }
963
1031
  console.log(`ℹ️ 扫描 ${scanned}/${candidates.size} 个清单文件(不存在的跳过)`);
964
1032
  }
965
- const artifact = {
966
- generated_at: new Date().toISOString().slice(0, 19).replace('T', ' '),
967
- generator: 'sillyspec endpoints extract',
968
- endpoints: endpoints.map(e => ({ method: e.method, path: e.path, source: e.source })),
969
- }
970
- const epRuntimeRoot = (await import('./run/shared.js')).resolveRuntimeRoot({ specRoot: specDir }, epSpecBase);
971
- const epOutDir = join(epRuntimeRoot, 'contract-artifacts', epChange, epTask || 'scan');
972
- mkdirSync(epOutDir, { recursive: true });
973
- const epOutPath = join(epOutDir, 'endpoints.json');
974
- writeFileSync(epOutPath, JSON.stringify(artifact, null, 2) + '\n');
975
- console.log(`✅ 提取 ${endpoints.length} 个端点 → ${epOutPath}`);
1033
+ const outPath = writeArtifact(epTask || 'scan', endpoints);
1034
+ console.log(`✅ 提取 ${endpoints.length} 个端点 → ${outPath}`);
976
1035
  console.log(` verify 阶段探针 5(API 契约对账)自动消费该产物;FastAPI/Express/Spring 装饰器均支持。`);
977
1036
  break;
978
1037
  }
@@ -1719,6 +1778,20 @@ SillySpec worktree — git worktree 隔离管理
1719
1778
  console.log(` git add --pathspec-from-file="${result.pathspecFile}"`);
1720
1779
  }
1721
1780
  console.log(` (完整清单 ${result.commitPathspec.length} 个文件已存 ${result.pathspecFile},可 --pathspec-from-file 复用)`);
1781
+ // 他者暂存差集(坑 same-main-staging-pollution):暂存区已有非本变更文件时,
1782
+ // 普通 git commit 会把它们一并扫入——提交前亮出来。pathspec 级 commit
1783
+ // (git commit -m <msg> -- <paths>)只提交指定路径、不动他者暂存,双出口都给。
1784
+ try {
1785
+ const { collectStagedArea } = await import('./commit-suggest.js');
1786
+ const stagedNow = collectStagedArea({ cwd: dir, ownFiles: result.commitPathspec });
1787
+ if (stagedNow.available && stagedNow.foreign.length > 0) {
1788
+ console.warn(`\n⚠️ 暂存区已有 ${stagedNow.foreign.length} 个非本变更文件(很可能为他者并行会话暂存,普通 git commit 会扫入):`);
1789
+ for (const f of stagedNow.foreign.slice(0, 10)) console.warn(` - ${f}`);
1790
+ if (stagedNow.foreign.length > 10) console.warn(` … 共 ${stagedNow.foreign.length} 个`);
1791
+ console.warn(` 推荐用 pathspec 级 commit(不动他者暂存):git commit -m "<msg>" --pathspec-from-file="${result.pathspecFile}"`);
1792
+ console.warn(` 或先剔除:git restore --staged -- <文件>(工作区内容不丢,对方可重新 add)`);
1793
+ }
1794
+ } catch { /* 暂存区快照失败不影响 apply 结果展示 */ }
1722
1795
  }
1723
1796
  }
1724
1797
  if (result.warnings && result.warnings.length > 0) {
@@ -75,6 +75,38 @@ export function detectSpaceSeparatedFiles(flags) {
75
75
  return suspects
76
76
  }
77
77
 
78
+ /**
79
+ * 检测 prose 参数值是否疑似被 Git Bash(MSYS) 路径转换污染(坑 quick-req-msys-path-mangling)。
80
+ *
81
+ * MSYS2 对以 / 开头的命令行参数做 POSIX→Windows 自动转换:--req "/sessions 页修复" 到达
82
+ * CLI 时已是 "<Git 安装目录>/sessions 页修复"(如 E:/Software/Git/sessions 页修复),无感
83
+ * 写入 QUICKLOG 标题与「需求:」行(该标题也是平台「快速修复」列表展示标题)。启发式:
84
+ * 盘符绝对路径开头 + 紧随空白与中文正文——人写的 Windows 路径引用不会以「盘符路径+中文
85
+ * 句子」形态开头;纯英文正文检不出(v1 取零误报优先,本仓主流为中文文案)。
86
+ *
87
+ * 纯函数:无副作用,供 command.js 解析层告警 + 单测复用。只告警不阻断——无法确定性区分
88
+ * 误报(合法值确可能以盘符路径开头),由 agent 看到 stderr 告警后自查重发。
89
+ * @param {string|null} v 待检值
90
+ * @returns {boolean} 疑似污染返回 true
91
+ */
92
+ export function looksLikeMsysMangledPath(v) {
93
+ if (typeof v !== 'string' || v.length === 0) return false
94
+ const m = v.match(/^[A-Za-z]:[\\/]\S+/) // 盘符绝对路径 token(吃到首个空白)
95
+ if (!m) return false
96
+ const rest = v.slice(m[0].length)
97
+ return /^\s/.test(rest) && /[\u4e00-\u9fff]/.test(rest)
98
+ }
99
+
100
+ /**
101
+ * MSYS 污染告警出口:命中嗅探时向 stderr 打 flag 名 + 值前缀 + 修复指引(不阻断)。
102
+ */
103
+ function warnMsysMangledFlag(flag, value) {
104
+ if (!looksLikeMsysMangledPath(value)) return
105
+ console.error(`⚠️ ${flag} 的值疑似被 Git Bash(MSYS) 路径转换污染:「${value.slice(0, 60)}${value.length > 60 ? '…' : ''}」`)
106
+ console.error(' 以 / 开头的文案在 Git Bash 下会被展开成 <Git 安装目录>/… 绝对路径后才传入 CLI。')
107
+ console.error(' 非本意 → 去掉前导 / 或改写表述后重发本命令;确需原样 → 命令前加 MSYS_NO_PATHCONV=1。')
108
+ }
109
+
78
110
  /**
79
111
  * 从 progress 或变更目录推导变更名
80
112
  */
@@ -473,6 +505,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
473
505
  let outputText = null
474
506
  const outputValue = getFlagValue('--output')
475
507
  if (outputValue !== null) outputText = outputValue
508
+ warnMsysMangledFlag('--output', outputValue)
476
509
 
477
510
  // 解析 quick 末步四字段参数(2026-08-21 agent-手工产出审计第二批 F6):--req/--cause/
478
511
  // --solution/--result 各传一项,CLI 合成单行四字段 outputText(quicklog 落盘侧
@@ -492,6 +525,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
492
525
  console.error(`❌ quick 末步四字段参数缺项:${missing.join(' ')}(--req/--cause/--solution/--result 必须全给,或缺字段 --done 会被拒)`);
493
526
  process.exit(2)
494
527
  }
528
+ for (const [f] of FOUR) warnMsysMangledFlag(f, getFlagValue(f))
495
529
  outputText = FOUR.map(([f, label]) => `${label}${getFlagValue(f)}`).join(' ')
496
530
  }
497
531
  }
@@ -514,6 +548,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
514
548
  break
515
549
  }
516
550
  }
551
+ warnMsysMangledFlag('--input', inputText)
517
552
 
518
553
  // 解析 --linked-changes <a,b|none>(quick 专用:显式声明关联变更,CI/脚本友好)
519
554
  // 与 --change 解耦:--linked-changes 语义清晰(关联变更),不与「指定变更名」混淆。
@@ -1365,6 +1365,20 @@ export async function handleExecuteWorktreeCleanup({ stageName, changeName, cwd
1365
1365
  console.log(` 下一步: sillyspec worktree apply ${changeName}`);
1366
1366
  } else {
1367
1367
  console.log(`🔗 Worktree: ${cleanResult.result.result}`);
1368
+ // 正当清理回执(坑 deps-gate-cleanup-order,2026-08-28 实证:cleanup 后重试 --done 被
1369
+ // deps 门拦「worktree 不可用」,逼 doctor --align-execute-progress 手工对齐):execute 完成时的
1370
+ // 正当清理(无未应用变更)落 durable 标记,enforceDepsGate 读它区分「正当收尾」与「意外丢失」。
1371
+ // apply 路径不写——apply-pathspec-<change>.txt 已是既定 apply 凭据,双标记冗余。
1372
+ if (['cleaned', 'force-cleaned', 'partial'].includes(cleanResult.result.result)) {
1373
+ try {
1374
+ const runtimeDir = join(cwd, '.sillyspec', '.runtime');
1375
+ mkdirSync(runtimeDir, { recursive: true });
1376
+ writeFileSync(join(runtimeDir, `execute-cleanup-${changeName}.json`), JSON.stringify({
1377
+ change: changeName, cleanedAt: new Date().toISOString(),
1378
+ reason: 'execute-completed-no-unapplied-changes',
1379
+ }, null, 2) + '\n', 'utf8');
1380
+ } catch { /* 回执失败只损失 deps 门的放行判据,不影响 cleanup 结果 */ }
1381
+ }
1368
1382
  if (cleanResult.result.residual?.length > 0) {
1369
1383
  console.warn(` ⚠️ 清理残留: ${cleanResult.result.residual.join('; ')}`);
1370
1384
  console.warn(` 手动处理: sillyspec worktree cleanup ${changeName} --force`);
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/shared.js CHANGED
@@ -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'
@@ -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
  };
@@ -1265,10 +1265,11 @@ task-XX 对应:{SPEC_ROOT}/.runtime/execute-runs/{EXECUTE_RUN_ID}/tasks/task-X
1265
1265
 
1266
1266
  ### 完成后
1267
1267
  1. 为每个后端 router task 生成 API 端点 artifact——**用 CLI 命令,勿手扫装饰器手写(易漏 endpoint)**:
1268
- - 逐 task 运行:sillyspec endpoints extract --change <change> --task <task-NN>(<change> = 当前变更名)
1268
+ - 聚合一次跑全:sillyspec endpoints extract --change <change> --all-tasks(逐 task 卡 allowed_paths 各自提取落盘,<change> = 当前变更名;与 verify 探针 5 聚合对账口径一致)
1269
1269
  - CLI 静态扫描变更文件(FastAPI @router.* / Express router.* / Spring @*Mapping)生成
1270
1270
  {SPEC_ROOT}/.runtime/contract-artifacts/<task-name>/endpoints.json
1271
1271
  - 格式: { "task": "task-XX", "type": "backend_endpoints", "endpoints": [{ "method": "GET", "path": "/api/ppm/xxx" }] }(CLI 已按此格式写好,verify 探针 5 直接消费)
1272
+ - 只提单 task(--task task-NN)会让探针 5 用局部端点集对账放大假 missing——多 task 变更必用 --all-tasks
1272
1273
  `
1273
1274
  }
1274
1275
 
@@ -579,10 +579,21 @@ export function extractKnownFailures(yamlText) {
579
579
  if (inline) {
580
580
  return inline[1].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
581
581
  }
582
- const block = yaml.match(/^known_failures:\s*\n((?:[ \t]+-[ \t].+\n?)+)/m)
582
+ // 坑 verify-known-failures-comment-line-truncation(2026-08-28 连续踩两次):块内注释行/空行
583
+ // 打断「连续列表项」的捕获链 → 只取到注释前的项,注释后的项静默丢失 → 清单残缺、
584
+ // verify 实测 remaining>0 假红。块定义放行注释行与空行(提取侧本就只认 `- ` 项行,
585
+ // 吸收进块的注释不影响结果)。
586
+ const block = yaml.match(/^known_failures:\s*\n((?:[ \t]+-[ \t].+\n?|[ \t]*#[^\n]*\n?|[ \t]*\n)+)/m)
583
587
  if (block) {
584
588
  return (block[1].match(/^[ \t]+-[ \t]+(.+)/gm) || [])
585
- .map(s => s.replace(/^[ \t]+-[ \t]+/, '').trim().replace(/^['"]|['"]$/g, '').replace(/#.*$/, '').trim())
589
+ .map(s => {
590
+ const item = s.replace(/^[ \t]+-[ \t]+/, '').trim()
591
+ const full = item.match(/^(['"])(.*)\1$/)
592
+ if (full) return full[2] // 整体引号值原样保留(内部 # 不是注释)
593
+ const stripped = item.replace(/[ \t]+#.*$/, '').trim() // 行尾注释须 # 前有空白(YAML 语义),裸 # 不截
594
+ const q2 = stripped.match(/^(['"])(.*)\1$/)
595
+ return q2 ? q2[2] : stripped
596
+ })
586
597
  .filter(Boolean)
587
598
  }
588
599
  return []
@@ -591,9 +602,25 @@ export function extractKnownFailures(yamlText) {
591
602
  // 单条失败行标记(跨 pytest/jest/vitest/go/generic)。大小写不敏感。
592
603
  // 刻意用 FAIL/FAILED + 配合 SUMMARY_LINE_RE 排除汇总行:否则 pytest/jest 的
593
604
  // "N failed" 汇总会被误判为「需豁免的失败行」→ 永远 remaining>0 → known_failures 失效。
594
- const PER_TEST_FAIL_RE = /(FAILED|\bFAIL\b|✕|✗|✘|panic\s*:|assertionerror|traceback|---\s*fail|error:|exception)/i
595
- // 汇总/计数行(非单条失败):pytest === 框、jest "Tests:"、裸 "N failed/passed"、go "FAILED in Ns" 等。
596
- const SUMMARY_LINE_RE = /(={2,}.*={2,}|^\s*\d+\s+(failed|passed|skipped|pending)\b|tests?\s*:|test\s+suites?\s*:|failed\s+in\s+\d)/i
605
+ // ×(U+00D7) 是 vitest 失败行的实际前缀标记(✕✗✘ 是 jest/mocha 形态)。
606
+ const PER_TEST_FAIL_RE = /(FAILED|\bFAIL\b|✕|✗|✘|×|panic\s*:|assertionerror|traceback|---\s*fail|error:|exception)/i
607
+ // 汇总/计数行(非单条失败):pytest === 框、jest "Tests:"、vitest "Test Files N failed"/"Tests N failed | M passed"
608
+ // (无冒号形态)、裸 "N failed/passed"、go "FAILED in Ns"、pnpm "ELIFECYCLE Test failed" 退出横幅等。
609
+ const SUMMARY_LINE_RE = /(={2,}.*={2,}|^\s*\d+\s+(failed|passed|skipped|pending)\b|tests?\s*:|test\s+suites?\s*:|^\s*(?:test\s+files|tests?)\s+\d|failed\s+in\s+\d|failed\s+tests\s+\d|elifecycle)/i
610
+ // 控制台捕获噪声行(非失败信号):vitest 把测试内 console 输出捕获为
611
+ // `stderr | <file> > <测试名>` 横幅 + 后续内容行——挂在通过用例上的噪声里常含
612
+ // error:/failed 字样(jsdom「Not implemented」警告、用例名本身带 failed 等),
613
+ // 与失败信号(× 行 / FAIL 横幅 / 汇总计数)无关,全部剔除。
614
+ const CONSOLE_CAPTURE_RE = /^\s*(?:stdout|stderr)\s*\|/
615
+ const ENV_NOISE_RE = /not\s+implemented\s*:/i
616
+ // 通过行(行首标记,剥 ANSI 后判定):vitest/jest/mocha 的 ✓√✔ 前缀行 + jest 的 PASS 文件行。
617
+ // 坑 verify-known-failures-pass-line-false-positive:FAILED/error:/exception 是子串匹配,
618
+ // 通过行用例名恰含这些字样(如「✓ … 超时后 syncStatus=failed」)会被误判失败行——
619
+ // 2710 用例套件 382 个"失败行"里 378 假阳性,known_failures 无法逐条枚举而实质失效。
620
+ // 框架输出里通过行恒以通过标记开头、失败行恒以失败标记开头,行首判定即足以分离两类。
621
+ const PASS_LINE_RE = /^\s*(?:[✓√✔]|PASS\b)/
622
+ // ANSI 色码剥离(分类用):TTY 捕获的输出里 ✓/× 前缀可能被色码包裹,行首锚定会失配。
623
+ const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g
597
624
 
598
625
  /**
599
626
  * 把测试输出按行筛出「失败行」,再按 known_failures 模式分为已豁免 / 未豁免。
@@ -603,7 +630,15 @@ const SUMMARY_LINE_RE = /(={2,}.*={2,}|^\s*\d+\s+(failed|passed|skipped|pending)
603
630
  */
604
631
  export function partitionFailures(output, knownFailures) {
605
632
  const lines = String(output || '').split(/\r?\n/)
606
- const failureLines = lines.filter(l => PER_TEST_FAIL_RE.test(l) && !SUMMARY_LINE_RE.test(l))
633
+ // 分类在剥 ANSI 后的行上做,返回保留原文(remaining 展示给 agent 时不变形)
634
+ const failureLines = lines.filter(l => {
635
+ if (PASS_LINE_RE.test(l)) return false
636
+ const bare = l.replace(ANSI_RE, '')
637
+ if (PASS_LINE_RE.test(bare)) return false
638
+ if (CONSOLE_CAPTURE_RE.test(bare)) return false
639
+ if (ENV_NOISE_RE.test(bare)) return false
640
+ return PER_TEST_FAIL_RE.test(bare) && !SUMMARY_LINE_RE.test(bare)
641
+ })
607
642
  const pats = (knownFailures || []).map(p => String(p).toLowerCase()).filter(Boolean)
608
643
  const exempted = []
609
644
  const remaining = []
@@ -1528,6 +1563,12 @@ export function printVerifyParityCheck(result) {
1528
1563
  console.warn(` - ${m.method} ${m.path} ← ${m.consumerFile}:${m.consumerLine}`)
1529
1564
  }
1530
1565
  if (result.missingBackend.length > 20) console.warn(` …还有 ${result.missingBackend.length - 20} 个`)
1566
+ // 全仓口径告警(坑 probe5-single-task-artifact-scope,2026-08-28 实证:diff/apply 清单都不可得
1567
+ // 时回退全仓,全仓调用 × 本变更局部端点 = 大量假 missing,agent 照单记 FAIL 误导归档判定)
1568
+ if (/\[scope: full-repo\]/.test(result.summary || '')) {
1569
+ console.warn(' ⚠️ 本次 scope 为 full-repo(diff 与 apply-pathspec 均不可得时的兜底)——missing 大概率是口径错配噪音而非真实 contract gap。')
1570
+ console.warn(' 先收窄复跑:sillyspec verify-probes --change <变更名>(change-diff/apply-pathspec 口径)再判定。')
1571
+ }
1531
1572
  console.warn(' 提示:检查是否后端漏实现,或前端调用了尚未实现的端点。确认无误可在 design.md 标注豁免。')
1532
1573
  }
1533
1574
 
@@ -1377,6 +1377,60 @@ function rollbackApply(projectRoot, trackedFiles, newFiles, rawStderr = '') {
1377
1377
  * @param {object} p.result - applyWorktree 的 result(预对齐信息写入 result.warnings,可追溯,约束④)
1378
1378
  * @returns {{ aligned: string[], commit: string|null }} aligned=本次对齐的文件(空数组=未对齐)
1379
1379
  */
1380
+ /**
1381
+ * merge 前自动 commit worktree 未提交交付物(坑 apply-merge-uncommitted-noop,2026-08-28 实证:
1382
+ * 子代理默认不 commit,分支 tip 只有 baseline checkpoint → git merge 空转「Already up to date」/
1383
+ * --no-ff 空合并,交付零落地,落地校验报错后只能手工 cherry-pick/cp 补救)。
1384
+ * 默认 patch 路径本就能吃未提交内容(git diff <base> 到工作区);只有 --merge/降级 merge 路径
1385
+ * 只看已提交——在此补上衔接。与 preAlignBaselineToMain 同款 pathspec commit(坑
1386
+ * git-commit-sweeps-prestaged:不扫入其他 staged 改动)。
1387
+ *
1388
+ * best-effort:commit 失败只 warning,交由下方落地校验兜底报错(不劣于原行为)。
1389
+ * @returns {{ committed: string[], skipped: boolean, shortHash: string|null }}
1390
+ */
1391
+ function autoCommitWorktreeWip({ meta, result, changeName }) {
1392
+ const out = { committed: [], skipped: true, shortHash: null };
1393
+ const worktreePath = meta?.worktreePath;
1394
+ if (!worktreePath || !existsSync(worktreePath)) return out;
1395
+ if (meta.mode && meta.mode !== 'worktree') return out;
1396
+
1397
+ // 未提交交付物 = tracked-modified(vs HEAD)∪ untracked,过 filterDeliverableFiles(排 meta.json/.sillyspec)
1398
+ let wip = [];
1399
+ try {
1400
+ const modRaw = gitQuiet(worktreePath, ['diff', '--name-only', 'HEAD']);
1401
+ const untrackedRaw = gitQuiet(worktreePath, ['ls-files', '--others', '--exclude-standard']);
1402
+ wip = filterDeliverableFiles([
1403
+ ...new Set([...(modRaw || '').split('\n'), ...(untrackedRaw || '').split('\n')].filter(Boolean)),
1404
+ ]);
1405
+ } catch { return out; }
1406
+ if (wip.length === 0) return out;
1407
+
1408
+ try {
1409
+ git(worktreePath, ['add', '-A', '--', ...wip]);
1410
+ try {
1411
+ git(worktreePath, ['commit', '--no-verify', '-m',
1412
+ `sillyspec: auto-commit worktree WIP (${wip.length} files, apply --merge)`, '--', ...wip]);
1413
+ } catch {
1414
+ // 仓级 git 身份缺失时 commit 拒绝——补 -c 身份重试一次(worktree 继承不到全局配置的形态)
1415
+ git(worktreePath, ['-c', 'user.name=sillyspec', '-c', 'user.email=sillyspec@apply.local',
1416
+ 'commit', '--no-verify', '-m',
1417
+ `sillyspec: auto-commit worktree WIP (${wip.length} files, apply --merge)`, '--', ...wip]);
1418
+ }
1419
+ out.committed = wip;
1420
+ out.skipped = false;
1421
+ out.shortHash = gitQuiet(worktreePath, ['rev-parse', '--short', 'HEAD']);
1422
+ result.warnings = result.warnings || [];
1423
+ result.warnings.push(
1424
+ `worktree 有 ${wip.length} 个未提交交付文件(子代理未 commit),已在分支自动 commit` +
1425
+ `${out.shortHash ? `(${out.shortHash})` : ''} 再 merge:${wip.slice(0, 5).join(', ')}${wip.length > 5 ? ' 等' : ''}`
1426
+ );
1427
+ } catch (e) {
1428
+ result.warnings = result.warnings || [];
1429
+ result.warnings.push(`worktree WIP 自动 commit 失败(merge 可能空转,落地校验将报未落地):${(e.message || '').split('\n')[0]}`);
1430
+ }
1431
+ return out;
1432
+ }
1433
+
1380
1434
  function preAlignBaselineToMain({ meta, branch, projectRoot, result }) {
1381
1435
  const pushWarning = (msg) => {
1382
1436
  result.warnings = result.warnings || [];
@@ -1495,6 +1549,10 @@ export function applyByMerge(result, changeName, projectRoot, wm, opts = {}) {
1495
1549
  // --- merge 前预对齐(D-002@v1 / task-02):baseline 并行旧文件对齐 main 版,消除 merge 冲突主因 ---
1496
1550
  preAlignBaselineToMain({ meta, branch, projectRoot, result });
1497
1551
 
1552
+ // --- merge 前自动 commit 未提交交付物(坑 apply-merge-uncommitted-noop):预对齐在前——
1553
+ // 它跳过 dirty 文件(不覆盖 WIP)且只提交对齐集;WIP commit 在后,两者不混提交 ---
1554
+ autoCommitWorktreeWip({ meta, result, changeName });
1555
+
1498
1556
  try {
1499
1557
  git(projectRoot, ['merge', '--no-ff', branch], { timeout: 30000 });
1500
1558
  } catch (e) {
@@ -26,7 +26,8 @@ grep -rl "<关键词>" <源码目录>/ --include="*.java" --include="*.js" --inc
26
26
  6. 任意 P0/P1 unresolved/blocking 决策标记为 FAIL blocker
27
27
 
28
28
  **探针 5 补充(CLI 已出对账表,你做诚实判定)**
29
- CLI 的 missing backend endpoint 是 advisory(不硬阻断归档)——但 contract gap 是真实集成缺陷,应诚实标 FAIL 并回 execute 补端点,勿因 CLI 不拦而放行。后端 router 的端点清单可用 `sillyspec endpoints extract --change <变更名> --task <task-NN>` 重新静态提取(勿手扫装饰器)。
29
+ CLI 的 missing backend endpoint 是 advisory(不硬阻断归档)——但 contract gap 是真实集成缺陷,应诚实标 FAIL 并回 execute 补端点,勿因 CLI 不拦而放行。后端 router 的端点清单用 `sillyspec endpoints extract --change <变更名> --all-tasks` 聚合提取(逐 task 卡各自落产物,与对账聚合口径一致;勿手扫装饰器、勿只提单 task——单 task 产物对账会放大假 missing)。
30
+ 先看对账表 scope 标注:`change-diff`(含 apply-pathspec 兜底)口径可信;若显示 `full-repo`(diff 与 apply 清单都不可得时的兜底),missing 大概率是「全仓调用 × 本变更局部端点」的口径错配噪音——先收窄复跑(`sillyspec verify-probes --change <变更名>`)再判定,不要把口径噪音直接记成 contract gap。
30
31
 
31
32
  **探针 6 补充(CLI 已出三态判定,你做终审)**
32
33
  以 git 事实为准(真实 > 声明);CLI 的三态(合规/高风险/未声明)是机械判定,是否构成 FAIL blocker 由你诚实判定,务必如实记录——静默删除代码是 verify 的最大盲区。