sillyspec 3.29.1 → 3.29.3

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.
@@ -99,7 +99,7 @@ export function computeQualityScanFingerprint({ cwd, specBase }) {
99
99
  * 保持 1——存量记录无 smoke 段照常读回(smokeResult 兜底 null);未配置 smoke 时段形态为
100
100
  * configured=false(记录仍写,供 --done PASS 封顶判 not-configured)。
101
101
  */
102
- export function storeQualityScan({ specBase, cwd, changeName, testResult, lintResult, smokeResult }) {
102
+ export function storeQualityScan({ specBase, cwd, changeName, testResult, lintResult, smokeResult, usedSnapshot = false }) {
103
103
  const path = qualityScanRecordPath(specBase, changeName)
104
104
  if (!path) return null
105
105
  const fingerprint = computeQualityScanFingerprint({ cwd, specBase })
@@ -110,6 +110,10 @@ export function storeQualityScan({ specBase, cwd, changeName, testResult, lintRe
110
110
  source: 'cli-noai',
111
111
  change: changeName,
112
112
  fingerprint,
113
+ // 失败签名去重面(ql-20260920-010 修复一 d):additive 字段,存量记录无这两键 →
114
+ // shouldReuseLastFailedScan 判 no-dedup-key 保守重跑一次,之后新记录即携带
115
+ dedupKey: computeQualityScanDedupKey({ cwd, specBase, fingerprint }),
116
+ usedSnapshot: Boolean(usedSnapshot),
113
117
  ranAt: new Date().toISOString(),
114
118
  testResult,
115
119
  lintResult: lintResult || null,
@@ -143,6 +147,93 @@ export function loadReusableQualityScan({ specBase, cwd, changeName }) {
143
147
  }
144
148
  }
145
149
 
150
+ /**
151
+ * 去重键(ql-20260920-010 修复一 d,对撞三轮三连假红重试 8~10 分钟×3 的根治):
152
+ * 指纹 + known_failures 块哈希。指纹只哈希 commands 段 + test_strategy 行——不含
153
+ * known_failures;而 agent 对假红的常见补救正是加豁免条目(不改代码、指纹不动)。
154
+ * 去重若只看指纹会把「合法补救后复跑」误判成「什么都没改」而拒绝重跑——键独立补
155
+ * 豁免面哈希,豁免一动即失配。git 不可用 → null(fail-closed 同指纹口径)。
156
+ */
157
+ export function computeQualityScanDedupKey({ cwd, specBase, fingerprint = null }) {
158
+ const fp = fingerprint ?? computeQualityScanFingerprint({ cwd, specBase })
159
+ if (!fp) return null
160
+ let kf = 'none'
161
+ try {
162
+ const localPath = join(specBase, 'local.yaml')
163
+ if (existsSync(localPath)) {
164
+ const lines = readFileSync(localPath, 'utf8').replace(/\r\n?/g, '\n').split('\n')
165
+ const start = lines.findIndex((l) => /^\s*known_failures:\s*(#.*)?$/.test(l))
166
+ if (start !== -1) {
167
+ const body = []
168
+ for (let i = start + 1; i < lines.length && !/^\S/.test(lines[i]); i++) body.push(lines[i])
169
+ kf = body.join('\n')
170
+ }
171
+ }
172
+ } catch { /* 读不到按无豁免面处理 */ }
173
+ return createHash('sha256').update(fp + '\n##known-failures##\n' + kf).digest('hex')
174
+ }
175
+
176
+ /**
177
+ * 读回最近一条扫描记录(不论成败)——失败去重重放用。loadReusableQualityScan 拒绝
178
+ * failed 记录(复用是优化不是正确性依赖),去重恰恰要消费 failed 态;schema/source/
179
+ * testResult 校验同 load 口径,读不回 → null。
180
+ */
181
+ export function loadLastQualityScanRecord({ specBase, changeName }) {
182
+ const path = qualityScanRecordPath(specBase, changeName)
183
+ if (!path || !existsSync(path)) return null
184
+ try {
185
+ const rec = JSON.parse(readFileSync(path, 'utf8'))
186
+ if (!rec || rec.schemaVersion !== RECORD_SCHEMA_VERSION || rec.source !== 'cli-noai') return null
187
+ if (!rec.testResult || !rec.testResult.status) return null
188
+ return rec
189
+ } catch {
190
+ return null
191
+ }
192
+ }
193
+
194
+ /**
195
+ * 失败签名去重判定(纯函数)。reuse=true 语义:代码指纹 + 豁免面 + 快照口径均未变 →
196
+ * 上次失败必然原样复现,重跑是纯等待浪费。四路不重跑:forceRerun 逃生阀 / 无失败记录 /
197
+ * 存量记录无 dedupKey(本版起才有,老记录保守重跑一次)/ 键或口径失配。
198
+ */
199
+ export function shouldReuseLastFailedScan({ lastRecord, currentDedupKey, plannedSnapshot, forceRerun = false }) {
200
+ if (forceRerun) return { reuse: false, reason: 'force-rerun(逃生阀 SILLYSPEC_VERIFY_QUALITY_SCAN_RERUN)' }
201
+ if (!lastRecord || !lastRecord.testResult || lastRecord.testResult.status !== 'failed') {
202
+ return { reuse: false, reason: 'no-failed-record' }
203
+ }
204
+ if (!lastRecord.dedupKey || !currentDedupKey) return { reuse: false, reason: 'no-dedup-key(存量记录 / git 不可用)' }
205
+ if (lastRecord.dedupKey !== currentDedupKey) return { reuse: false, reason: 'dedup-key-mismatch(代码/配置/豁免面/HEAD 已变化)' }
206
+ if (Boolean(lastRecord.usedSnapshot) !== Boolean(plannedSnapshot)) return { reuse: false, reason: 'snapshot-scope-changed' }
207
+ return { reuse: true, reason: null }
208
+ }
209
+
210
+ /**
211
+ * 伪影分诊编排(ql-20260920-010 修复一 a):测试失败 → classify 分类 → 命中且本次是
212
+ * 快照口径 → 主树口径单点复跑对照。复跑过 = 伪影坐实:返回主树结果 + artifactTriage
213
+ * 证据(放行有据,disclosure 走 console + 台账,不改 passed.reason 语义);复跑仍挂 =
214
+ * 真失败:保留快照失败结果 + 两口径对照注记(拦截照常,归因已对照过)。主树口径失败
215
+ * (snap=null)只注记 advice 不复跑(本身就是对照口径)。classify/rerunInMainScope
216
+ * 可注入——纯函数化供测试;分诊永不单独放行(放行必经主树实测复证)。
217
+ */
218
+ export function applyArtifactTriageRerun({ testCheck, classify, snap, rerunInMainScope }) {
219
+ if (!testCheck || testCheck.status !== 'failed') return testCheck
220
+ const triage = classify(testCheck)
221
+ if (!triage || !triage.matched) return testCheck
222
+ if (!snap) {
223
+ console.warn(`🔬 伪影分诊命中(${triage.kind},本次已主树口径):${triage.advice}`)
224
+ return { ...testCheck, reason: `${testCheck.reason || ''}[伪影分诊命中(${triage.kind}):${triage.advice}]`, artifactTriage: triage }
225
+ }
226
+ console.warn(`🔬 伪影分诊命中(${triage.kind})——快照口径失败疑似沙箱伪影,主树口径单点复跑对照…`)
227
+ for (const ev of triage.evidence) console.warn(` · ${ev}`)
228
+ const mainRetry = rerunInMainScope()
229
+ if (mainRetry.status === 'passed') {
230
+ console.warn(' 主树复跑通过——伪影坐实:按主树口径结果放行(快照失败输出与分诊证据随台账落盘)')
231
+ return { ...mainRetry, artifactTriage: triage }
232
+ }
233
+ console.warn(' 主树复跑仍失败——非沙箱伪影,按真失败拦截(两口径输出均见台账)')
234
+ return { ...testCheck, reason: `${testCheck.reason || ''}[伪影分诊:两口径(快照/主树)均失败,非沙箱伪影]`, artifactTriage: triage }
235
+ }
236
+
146
237
  /**
147
238
  * 并行 WIP 归因鉴别(前置二):迁自 gates.js verify 实测失败归因 inline 块,输出逐字保持。
148
239
  * 判据①他者声明文件命中 dirty 集;判据②本变更 apply 的文件与主仓近期提交重叠且旧内容
@@ -401,7 +492,37 @@ function warnIfMainRepoDirtyForGate(cwd) {
401
492
  * `run verify` 复入本步重新实测(指纹随代码修复失配,--done 不会复用旧失败结果)。
402
493
  */
403
494
  export async function executeVerifyQualityScan({ cwd, specBase, changeName, platformOpts }) {
404
- const { runVerifyTestCheck, printVerifyTestCheck, runVerifyLintCheck, printVerifyLintCheck, shouldBlockVerifyLint } = await import('../verify-postcheck.js')
495
+ const { runVerifyTestCheck, printVerifyTestCheck, runVerifyLintCheck, printVerifyLintCheck, shouldBlockVerifyLint, classifyTestFailureArtifact } = await import('../verify-postcheck.js')
496
+ // ── 失败签名去重(ql-20260920-010 修复一 d)──
497
+ // 复入本步前先对账上次失败:代码指纹 + known_failures 豁免面 + 快照口径都没变 → 失败
498
+ // 必然原样复现,重跑是纯等待(对撞三轮三连 8~10 分钟假红重试的根治)。复用只消费
499
+ // failed 态记录(passed 态 --done 复用归 loadReusableQualityScan),逃生阀
500
+ // SILLYSPEC_VERIFY_QUALITY_SCAN_RERUN=1。
501
+ if (process.env.SILLYSPEC_VERIFY_QUALITY_SCAN_RERUN !== '1') {
502
+ const lastRecord = loadLastQualityScanRecord({ specBase, changeName })
503
+ const verdict = shouldReuseLastFailedScan({
504
+ lastRecord,
505
+ currentDedupKey: computeQualityScanDedupKey({ cwd, specBase }),
506
+ plannedSnapshot: !process.env.SILLYSPEC_VERIFY_GATE_SNAPSHOT_OFF,
507
+ })
508
+ if (verdict.reuse) {
509
+ const t = lastRecord.testResult
510
+ console.error(`\n🛑 检测到上次质量扫描失败且失败签名未变(代码指纹 / known_failures 豁免面 / 快照口径均未变,${lastRecord.ranAt ? '上次运行 ' + lastRecord.ranAt : ''})——本次跳过重跑,直接复用上次失败结果:`)
511
+ console.error(` 上次实测:\`${t.command}\` — ${(t.reason || '').split('\n')[0]}`)
512
+ if (t.outputTail) {
513
+ for (const line of t.outputTail.split('\n').slice(-8)) console.error(` | ${line}`)
514
+ }
515
+ const replayTriage = classifyTestFailureArtifact(t)
516
+ if (t.artifactTriage) {
517
+ console.error(` 🔬 上次已做过伪影分诊(${t.artifactTriage.kind})且主树对照复跑仍失败——非沙箱伪影,别再换口径重试`)
518
+ } else if (replayTriage.matched) {
519
+ console.error(` 🔬 失败签名命中伪影形态(${replayTriage.kind}):${replayTriage.advice}`)
520
+ console.error(' (上次运行早于分诊机制——本次重跑会自动做主树口径对照;如需立即重跑设 SILLYSPEC_VERIFY_QUALITY_SCAN_RERUN=1)')
521
+ }
522
+ console.error(' 出路(按序):① 修代码(指纹即失配,下次自动重测)② 补 known_failures 豁免(豁免面即失配)③ 换快照口径(设/删 SILLYSPEC_VERIFY_GATE_SNAPSHOT_OFF,口径变化即失配)④ 环境确已修好后强制重跑:SILLYSPEC_VERIFY_QUALITY_SCAN_RERUN=1')
523
+ throw new Error(`noAI 质量扫描复用上次失败结果(失败签名未变,跳过重跑)——${(t.reason || '测试失败').split('\n')[0]}。修复后重跑 sillyspec run verify${changeName ? ` --change ${changeName}` : ''},或按上方出路处置。`)
524
+ }
525
+ }
405
526
  // ── 隔离快照定向跑(2026-09-11 驾驭第十二批,用户第三次撞上:noAI 质量扫描是 verify 的
406
527
  // 第一实测执行点,第八批只接了 gates.js verify 块——本步仍跑 main 工作区,并行 WIP 在此弄红
407
528
  // 无辜变更)。与 gates.js 同款:createVerifyGateSnapshot(HEAD + 本变更文件集,native worktree
@@ -429,6 +550,13 @@ export async function executeVerifyQualityScan({ cwd, specBase, changeName, plat
429
550
  let smokeResult = null
430
551
  try {
431
552
  testCheck = runVerifyTestCheck({ cwd: gateCwd, specBase: gateSpecBase, changeName })
553
+ // ── 伪影分诊(ql-20260920-010 修复一 a):失败先分诊,命中 → 主树口径单点复跑对照 ──
554
+ testCheck = applyArtifactTriageRerun({
555
+ testCheck,
556
+ classify: classifyTestFailureArtifact,
557
+ snap,
558
+ rerunInMainScope: () => runVerifyTestCheck({ cwd, specBase, changeName }),
559
+ })
432
560
  printVerifyTestCheck(testCheck)
433
561
  lintCheck = runVerifyLintCheck({ cwd: gateCwd, specBase: gateSpecBase, timeoutMs: snap ? 5 * 60 * 1000 : undefined })
434
562
  // 快照 lint 超时回退主仓(2026-09-12 dogfood 两连实证:junction I/O 病态慢,3min/5min 均被
@@ -463,7 +591,7 @@ export async function executeVerifyQualityScan({ cwd, specBase, changeName, plat
463
591
  } finally {
464
592
  if (snap) { try { snap.cleanup() } catch {} }
465
593
  }
466
- storeQualityScan({ specBase, cwd, changeName, testResult: testCheck, lintResult: lintCheck, smokeResult, coverageCheck })
594
+ storeQualityScan({ specBase, cwd, changeName, testResult: testCheck, lintResult: lintCheck, smokeResult, coverageCheck, usedSnapshot: Boolean(snap) })
467
595
  const testFailed = testCheck.status === 'failed'
468
596
  const lintBlocked = shouldBlockVerifyLint(lintCheck)
469
597
  // 摩擦计数(friction-signal-hint task-03):记录条件与 throw 条件**刻意解耦**——advisory 档
@@ -401,6 +401,12 @@ tier: {REVIEW_TIER}({REVIEW_TIER_REASON})
401
401
  - 时间盒收敛:审查子代理的调研是收敛动作不是发散——必读材料读完即逐条出结论并落盘 review.json;仅个别结论存疑时定向补证,禁止循环扩大核验面(连续读文件 10+ 次仍零结论 = 立即停止扩展、基于已读材料收敛)。
402
402
  - 写通道降级:子代理写操作持续被平台拒绝(session not in running turn 类故障)→ 重试 ≤3 次即停,把完整审查结论(含 review.json 全文)作为最终文本回传主代理,由主代理代为落盘并在 reviewerNotes 首行留痕「代落盘:子代理写通道故障」;禁止长时间空转重试。
403
403
 
404
+ **审查经济学三律(2026-09-20 对撞实验实测驱动——前置阶段 23 分钟审查等待的结构性收口)**:
405
+ - **请求预算硬钳(时间盒的数字化形态)**:子代理审查是「按清单作答」不是「自由调研」——请求预算硬上限 **12 次**(设计审查 ≤8 次必读+≤4 次定向补证);到达即基于已读材料出 verdict,禁止「再确认一下」式续读(2026-09-20 实证:48 请求审一份 287 行 design = 17 分钟纯等待,根因是审查者陷入自由调研)。派发 prompt 必须明示此预算。
406
+ - **FAIL 后增量复审(禁全文重审)**:首轮 verdict=fail → 你修复阻断项后,**resume 同一审查子代理**(SendMessage 回原 agent)只发修复说明与 diff,要求它仅验证「首轮阻断项是否解决」——不重开全文审(2026-09-20 实证:resume 增量复审 64s/8 调用 vs 新子代理全文审 322s/26 调用 = 5 倍差距)。复审 review.json 的 checklist 追加增量复核行(item 含「增量复审:首轮阻断项 N 条」),verdict 按增量结果改判。原 agent 已不可 resume(会话过期等)才允许新开子代理(此时 prompt 里附首轮 review.json 阻断项,仍只验阻断项)。
407
+ - **Grill 并行化(fire-and-forget)**:tier=independent 时派发审查子代理用 **run_in_background: true**,然后**立即进入 Step 8 生成规范文件**——不阻塞等待审查结果(Grill 平均可省 15 分钟墙钟)。Step 8 产出完成、即将 \`--done\` 本步骤前,回收子代理结果:verdict=pass → 直接完成;verdict=fail → 走上条增量复审流程;子代理仍在跑 → 轮询等待(它有 12 请求预算不会发散太久)。Stage Review Gate 在 \`--done\` 时仍硬校验 review.json 在场——并行化省的是墙钟不是门。
408
+ - **审查只读纪律(审查员是法医不是外科医生——并行化的前置安全条件)**:审查子代理的工具面**只读**(Read/Grep/只读 Bash 如 cat/ls/git log/git diff);**禁止 Edit/Write 任何被审产物**(design.md/四件套/源码),唯一可写文件是 review.json。发现问题 → 写进 review.json 的 checklist/blockers 由主代理修复(2026-09-20 实证:Grill 审查员在审查窗口直接 Edit design.md 15 处——主会话零请求窗口铁证——三重危害:独立性破坏〔自己审自己改,pass 有效性打折〕、责任链断裂〔修改未经主代理复核即生效〕、回合数爆炸〔边审边改 48 回合 vs 正常 12~15〕)。与上条并行化叠加时本纪律是硬前提:审查员后台改文件的同时主链在前台写后续产物 = 同文件双写竞态。
409
+
404
410
  ### 仪式档位菜单(ceremony_tier 按 risk 客观定价——注入的 tier 行已标注当前档)
405
411
  - S0:CLI 清单核验——按下方交叉审查模型/交叉点清单机械逐项核对(零 token 发散),结论逐条附证据锚点,无需独立子代理
406
412
  - S1:CLI 清单核验 + 定向探针抽查——机械项全查之外,对最高风险的 1-2 个交叉点做定向源码探针(读相关实现验证设计假设),无需独立子代理
@@ -439,6 +439,7 @@ ${REVIEW_CHECKLISTS.execute.map((item, index) => ' ' + (index + 1) + '. ' + ite
439
439
  **产物唯一化(省重复消耗)**:本步逐项对照结论**只落盘一份**——直接写进 review.json 的 \`checklist\` 数组(item=设计要点/FR/决策,note=实现状态 ✅/⚠️/❌ + 偏差说明 + commit 锚点),reviewerNotes 写汇总。**不要**另写独立的 design-check.md 长文(同一份 design×diff 二次消费;2026-08-22 实测该重复一遍 ≈8 分钟全量重读)。
440
440
  **执行纪律(坑 review-subagent-stall,2026-09-15 wp EHS 会话实证)**:brainstorm/plan 阶段已实证的结论(既往 stage-review checklist pass 项、file:line 锚点)直接引用勿重验;必读材料读完即逐条出结论落盘,仅结论存疑时定向补证,禁止循环扩大核验面(连续读文件 10+ 次仍零结论 = 基于已读材料立即收敛)。QA 子代理写操作持续被平台拒绝(session not in running turn 类)→ 重试 ≤3 次即停,完整结论(含 review.json 全文)回传主代理代落盘,reviewerNotes 首行留痕「代落盘:子代理写通道故障」;禁止长时间空转重试。
441
441
  **gate 重试修复**:review.json 落盘后若 design.md 又有改版,gate 会**自动机械重算 docHash 放行**(verdict/checklist 保留,结论是否仍适用于新文档需人工确认)——**不要重做审查**(重做=同一材料第三遍);主文档路径错/缺失不会被自动修复,按 gate 报错修正 reviewedFiles[0],或跑 \`sillyspec register-stage-review --change <变更名> --stage execute --refresh-hash\`。
442
+ **审查经济学三律(2026-09-20 对撞实验驱动,同 brainstorm/plan 审查步)**:①请求预算硬钳——QA 子代理请求上限 **15 次**(材料包+抽查 task 的 diff 文件+全量重审 task 的核心文件),到达即出 verdict;②FAIL 后增量复审——resume 原 QA 子代理只发修复说明+diff 验证阻断项(不全文重审,5 倍差实证),checklist 追加增量复核行;③并行化——tier=independent 时 run_in_background: true 派发后主代理继续下一知识库审阅步骤,回收在即将 --done 代码审查步骤前;④**审查只读纪律**——QA 子代理工具面只读(Read/Grep/只读 Bash),禁止 Edit/Write 被审产物(源码/四件套/review.json 之外的生成物),唯一可写 review.json——发现问题写 checklist/blockers 由主代理/对应 task 修复(2026-09-20 实证 Grill 越权改 design 15 处:独立性破坏+责任链断裂+回合爆炸;③并行化下同文件双写竞态的前置防线)。
442
443
 
443
444
  ### 操作(材料包口径——2026-09-19-review-material-cli-wiring)
444
445
  1. **评审材料包(基准面)**:CLI 已机械组装注入下方(src/review-material-pack.js assembleStageReviewMaterials('execute-qa'):diff 摘要+design 热区+验收清单)——checklist 逐条只对包作答;包外文件可定向查证但须列明(禁全量扫读);包不足以作答→cannot_verify+requiredEvidence 列缺件。task review 双 pass 抽查、未双 pass 全量重审的分级照旧(上方「审查范围分级」);tier=independent 时把整包贴进 QA 子代理派发 prompt:
@@ -1562,6 +1562,51 @@ function validateWaveProposal(changeDir, waves, repoRegistry = null) {
1562
1562
  /** plan_level 客观复核阈值单点(2026-09-09-plan-derived FR-03,D-003@v1) */
1563
1563
  const PLAN_LEVEL_SIGNALS = { files: 8, modules: 2 }
1564
1564
 
1565
+ /**
1566
+ * Wave 结构分诊(ql-20260920-010 修复二,对撞三轮 execute 108 分钟串行的收口):
1567
+ * 显式 Wave 排布 vs depends_on 拓扑最小波数的差值判定。判据=**可合并相邻波对**:
1568
+ * 相邻 (W_i, W_{i+1}) 之间无依赖边(W_{i+1} 任务不 depends_on W_i 任务)且两波文件面
1569
+ * (allowed_paths 并集)无交集 → 合并不破坏任何约束,是纯手工串行化。刻意不用
1570
+ * 「波数 ≥5 且平均每波 <2.5」的粗暴启发式——它会误伤真依赖链(task-N 依赖 task-(N-1)
1571
+ * 全链波数=N≥5、平均=1,但串行合法必须放行);可合并对计数对真链恒为 0(拓扑最小波
1572
+ * 的每对相邻波间必有依赖边),天然免疫。
1573
+ * - 可合并对 ≥2 → error(伪并行串行链:三轮形态=13 任务 6 波全串行、平均 2.2/波、
1574
+ * Wave 5 单波 30 分钟,并行度归零);
1575
+ * - 显式波数=拓扑最小(diff=0)且波数 ≥5 → advisory 深链提示(真串行合法放行,
1576
+ * 但 depends_on 可能过声明——task-07 只需 task-01 产物却声明依赖 task-06 那种;
1577
+ * 阻断归阻断、提示归提示,不混淆)。
1578
+ * 文件面交集的相邻对不算可合并(同 Wave 共享文件=双写竞态,分离合法)——与
1579
+ * validateWaveProposal 的「卡片读取失败按无重叠证据」同口径,卡片缺/未声明 → 空面。
1580
+ * 纯函数(文件面由调用方注入,测试不依赖卡片读盘)。
1581
+ * @param {{ existingWaves: string[][], topoWaves: string[][], depMap: Map<string,string[]>, wavePathSets?: (Set<string>|null)[] }} args
1582
+ * @returns {{ mergeablePairs: Array<[number,number]>, error: string|null, advisory: string|null }}
1583
+ */
1584
+ export function assessWaveStructure({ existingWaves, topoWaves, depMap, wavePathSets = [] }) {
1585
+ const mergeablePairs = []
1586
+ for (let i = 0; i + 1 < existingWaves.length; i++) {
1587
+ const cur = existingWaves[i]
1588
+ const next = existingWaves[i + 1]
1589
+ const curSet = new Set(cur)
1590
+ const hasDepEdge = next.some(t => (depMap.get(t) || []).some(d => curSet.has(d)))
1591
+ if (hasDepEdge) continue
1592
+ const a = wavePathSets[i] || new Set()
1593
+ const b = wavePathSets[i + 1] || new Set()
1594
+ let overlap = false
1595
+ for (const p of a) { if (b.has(p)) { overlap = true; break } }
1596
+ if (overlap) continue
1597
+ mergeablePairs.push([i, i + 1])
1598
+ }
1599
+ let error = null
1600
+ let advisory = null
1601
+ if (mergeablePairs.length >= 2) {
1602
+ const detail = mergeablePairs.map(([a, b]) => `Wave ${b + 1} 可并入 Wave ${a + 1}`).join('、')
1603
+ error = `planPostcheck: Wave 伪并行串行链——${detail}(相邻波间无 depends_on 依赖边且文件面无交集,合并不破坏任何约束),手工排布比依赖拓扑多出 ${mergeablePairs.length} 段纯串行化:同层无依赖的波必须合并(execute 按 Wave 串行推进,碎片化波=并行度归零);或回 tasks 卡核对 depends_on 是否过声明。sillyspec plan-adopt-waves --change <变更名> 可按 depends_on 拓扑一键重排(重排后同波文件面有交集会被拒绝,需手工拆分)`
1604
+ } else if (existingWaves.length >= 5 && existingWaves.length === topoWaves.length) {
1605
+ advisory = `Wave 链深 ${existingWaves.length}(与 depends_on 拓扑最小一致,串行合法放行)——若任务间实际没有这么强的依赖,回 tasks 卡核对 depends_on 是否过声明(如 task-07 只需 task-01 的产物却声明依赖 task-06);砍掉过声明依赖可提升并行度`
1606
+ }
1607
+ return { mergeablePairs, error, advisory }
1608
+ }
1609
+
1565
1610
  /**
1566
1611
  * plan_level 第二把尺子(不接管,仅 warning):design 文件清单数 / 模块跨度 / task 数
1567
1612
  * 与声明档位比对。fail-open(design/map 读取失败 → null 零输出)。
@@ -1919,6 +1964,27 @@ export async function executePlanPostcheck(context) {
1919
1964
  }
1920
1965
  // 结构不一致但方向合法(手工保守串行)→ 静默放行(2026-09-09-plan-derived:
1921
1966
  // 合法安全模式消每轮提示噪音;手动对齐出口保留 sillyspec plan-adopt-waves)
1967
+
1968
+ // ── 伪并行串行链守卫(ql-20260920-010 修复二)──
1969
+ // 「静默放行」被三轮对撞实证穿透:plan 把 13 任务手排 6 波全串行(方向合法)→
1970
+ // execute 108 分钟并行度归零。静默放行只对「保守但收敛」的排布安全,对碎片化
1971
+ // 串行链是漏门——此处按可合并相邻波对分诊(见 assessWaveStructure):≥2 可合并
1972
+ // → 硬拦;深链(diff=0 且波数≥5)→ advisory 提示核 depends_on,不阻断。
1973
+ const wavePathSets = existingWaves.map(ws => {
1974
+ const s = new Set()
1975
+ for (const t of ws) {
1976
+ try {
1977
+ for (const p of parseAllowedPaths(readFileSync(pJoin(tasksDir, `${t}.md`), 'utf8'))) s.add(p)
1978
+ } catch { /* 卡片缺失/读失败按空面(同 validateWaveProposal 口径) */ }
1979
+ }
1980
+ return s
1981
+ })
1982
+ const structure = assessWaveStructure({ existingWaves, topoWaves: waves, depMap, wavePathSets })
1983
+ if (structure.advisory) console.warn(` ⚠️ ${structure.advisory}`)
1984
+ if (structure.error) {
1985
+ console.error(`\n❌ ${structure.error}`)
1986
+ throw new Error(structure.error)
1987
+ }
1922
1988
  }
1923
1989
  }
1924
1990
  }
@@ -368,6 +368,7 @@ prompt 要点:
368
368
  4. verdict=fail 时在 reviewerNotes 写明阻断项
369
369
  5. 执行纪律(坑 review-subagent-stall,2026-09-15 wp EHS 会话实证:plan 审查重核三仓源码 94 分钟不收敛——brainstorm 阶段已两轮实证的结论被全量重验):brainstorm 阶段 design review 已实证的结论(checklist pass 项、file:line 锚点)**直接引用勿重验**,本阶段只审 plan 特有面(任务拆分/依赖/契约/规模映射);必读材料读完即逐条出结论落盘,仅结论存疑时定向补证,禁止循环扩大核验面(连续读文件 10+ 次仍零结论 = 基于已读材料立即收敛)。
370
370
  6. 写通道降级:写操作持续被平台拒绝(session not in running turn 类)→ 重试 ≤3 次即停,完整结论(含 review.json 全文)作为最终文本回传主代理代落盘,reviewerNotes 首行留痕「代落盘:子代理写通道故障」;禁止长时间空转重试。
371
+ 7. **审查经济学三律(2026-09-20 对撞实验驱动,同 brainstorm Grill 步)**:①请求预算硬钳——子代理请求上限 **10 次**(材料包基准面 + plan.md + ≤3 次定向补证),到达即出 verdict 禁「再确认一下」续读,派发 prompt 明示此预算;②FAIL 后增量复审——修复阻断项后 **resume 同一子代理**只发修复说明+diff 验证「阻断项解决否」,不重开全文审(64s vs 322s = 5 倍差实证),review.json checklist 追加增量复核行;原 agent 不可 resume 才新开(prompt 附首轮阻断项仍只验阻断项);③**并行化**——派发用 run_in_background: true,主代理立即继续 Step 4(生成 TaskCard),回收在即将 --done 本步骤前(pass → 完成 / fail → 增量复审 / 在跑 → 等待),Stage Review Gate 照常硬校验;④**审查只读纪律**——子代理工具面只读(Read/Grep/只读 Bash),禁止 Edit/Write 被审产物(design/plan/四件套/源码),唯一可写 review.json,发现问题写 checklist/blockers 由主代理修(2026-09-20 实证 Grill 越权改 design 15 处;③并行化下同文件双写竞态的前置防线,详见 brainstorm Grill 步全文)。
371
372
 
372
373
  ### module-impact.md 首版(scale≠small 时:CLI 自动生成,审查步勿手写)
373
374
  module-impact.md 首版**由 CLI 在本阶段 --done 时自动生成**——文件×模块归属按 _module-map.yaml 前缀匹配机械预填,章节含「## 模块影响矩阵」「## 未匹配文件」「## 更新结果」表骨架(每受影响模块一行 pending),影响类型列留 <!--TODO--> 由 execute/verify 按实际 diff 回填。已存在不覆盖。手写整份首版是历史返工根源(章节标题变体会被 archive contains_sections 硬拦),**本步不要手写**。
@@ -39,6 +39,7 @@ import { resolveChangeRisk } from './change-risk-profile.js'
39
39
  import { loadBlastDeclarationsAllProjects } from './blast-surface.js'
40
40
  import { loadSpanRiskPatternsAllProjects } from './span-risk-surface.js'
41
41
  import { reconcileDualRun, computeCeremonyTier, CEREMONY_TIERS } from './ceremony-tier.js'
42
+ import { readCeremonyPricingConfig } from './ceremony-config.js'
42
43
  import { mergeFrictionEntry } from './friction-ledger.js'
43
44
  import { discoverModuleIndex } from './decision-distill.js'
44
45
  import { writeAtomicSync } from './fs-atomic.js'
@@ -952,6 +953,71 @@ export function judgeWithKnownFailures(exitCode, output, baseReason, knownFailur
952
953
  }
953
954
  }
954
955
 
956
+ // ═══ 沙箱伪影分诊(ql-20260920-010 修复一 a,对撞三轮 68 分钟单步收口)═══
957
+ // 三轮实测:verify 质量扫描三连假红重试(9.7+8.2+7.8 分钟)根因全是工具/环境自身缺陷
958
+ // 而非被测代码——四类已知形态:沙箱环境缺件(venv dev 依赖/工具二进制不在)、overlay
959
+ // 半成品缺模块、CRLF 字节差(autocrlf 检出 vs 快照期望)、判账正则把通过/汇总行误判
960
+ // 失败行(PER_TEST_FAIL_RE 残留形态)。本函数只做**签名分类**(纯函数零 IO),处置归
961
+ // 消费侧(run/verify-quality-scan.js executeVerifyQualityScan):快照口径失败且命中 →
962
+ // 主树口径单点复跑对照,复跑过 = 伪影坐实按主树结果放行;复跑仍挂 = 真失败照常拦截。
963
+ // 分诊永不单独放行——只改「要不要换口径再测一次」与「给 agent 的归因提示」。
964
+ const ARTIFACT_ENV_MISSING_RES = [
965
+ // 条目 H 签名族:pytest-xdist 缺失 → usage error(unrecognized -n/--dist)
966
+ /ERROR:\s*usage:\s*py(?:test|thon)[^\n]*(?:-n\b|--dist|xdist|unrecognized argument)/i,
967
+ /ModuleNotFoundError:\s*No module named\s+'?(?:pytest|pytest_xdist|xdist|_pytest)'?/i,
968
+ /(?:command not found|not recognized as an internal or external command)[^\n]*(?:pytest|vitest|jest|mocha|ruff|tsc|eslint|python)/i,
969
+ ]
970
+ const ARTIFACT_MODULE_IMPORT_RES = [
971
+ // overlay 部分态:改动引用方没改被引用方 → 本地顶层包 import 不到(①已先排除 pytest 家族)
972
+ /ModuleNotFoundError:\s*No module named\s+'?[A-Za-z_][\w.]*'?/i,
973
+ /ImportError:\s*cannot import name\s+\S+\s+from\s+/i,
974
+ ]
975
+ const ARTIFACT_CRLF_RES = [
976
+ /\\r\\n/i, // 断言差异里字面 \r\n 转义可见(jest/vitest 快照与精确串断言)
977
+ /\bCRLF\b/i,
978
+ /line endings?\s*(?:mismatch|differ)/i,
979
+ ]
980
+
981
+ /**
982
+ * 失败结果伪影分诊。优先消费 failureRemaining(判账行集,不受 tail 截断),缺失时回退
983
+ * outputTail。返回 { matched, kind, evidence, advice };matched=false 时其余字段为
984
+ * null/[]。分类顺序刻意:env-missing 先于 module-import(pytest 家族两处命中算环境缺件)。
985
+ * @param {{ status:string, failureRemaining?:string[], outputTail?:string|null }} result
986
+ * @returns {{ matched:boolean, kind:string|null, evidence:string[], advice:string|null }}
987
+ */
988
+ export function classifyTestFailureArtifact(result) {
989
+ const empty = { matched: false, kind: null, evidence: [], advice: null }
990
+ if (!result || result.status !== 'failed') return empty
991
+ const lines = (Array.isArray(result.failureRemaining) && result.failureRemaining.length > 0)
992
+ ? result.failureRemaining.map(String)
993
+ : String(result.outputTail || '').split(/\r?\n/).filter(Boolean)
994
+ if (lines.length === 0) return empty
995
+ const hit = (res) => lines.filter(l => res.some(re => re.test(l.replace(ANSI_RE, ''))))
996
+ const pick = (ev) => ev.slice(0, 3).map(l => { const t = l.trim(); return t.length > 120 ? t.slice(0, 120) + '…' : t })
997
+
998
+ const envEv = hit(ARTIFACT_ENV_MISSING_RES)
999
+ if (envEv.length > 0) {
1000
+ return { matched: true, kind: 'sandbox-env-missing', evidence: pick(envEv),
1001
+ advice: '疑似沙箱环境缺件(venv dev 依赖 / 工具二进制不在 PATH)——先主树口径对照复跑再定论;若主仓同样缺件,补环境(uv sync --group dev / 安装工具)而非改代码' }
1002
+ }
1003
+ const impEv = hit(ARTIFACT_MODULE_IMPORT_RES)
1004
+ if (impEv.length > 0) {
1005
+ return { matched: true, kind: 'overlay-module-missing', evidence: pick(impEv),
1006
+ advice: '疑似快照 overlay 部分态缺模块(改了引用方没改被引用方)——主树口径对照复跑可分辨;主树也挂才是真失败' }
1007
+ }
1008
+ const crlfEv = hit(ARTIFACT_CRLF_RES)
1009
+ if (crlfEv.length > 0) {
1010
+ return { matched: true, kind: 'crlf-diff', evidence: pick(crlfEv),
1011
+ advice: '疑似 CRLF/LF 字节差(autocrlf 检出口径 vs 断言期望)——主树口径对照复跑可分辨;确认后统一行尾口径(.gitattributes / 编辑器设置)' }
1012
+ }
1013
+ // 判账假阳性:所有「失败行」剥 ANSI 后全是通过/汇总/捕获块形态——失败标记本身误判
1014
+ if (lines.every(l => { const b = l.replace(ANSI_RE, ''); return PASS_LINE_RE.test(b) || SUMMARY_LINE_RE.test(b) || CONSOLE_CAPTURE_RE.test(b) })) {
1015
+ return { matched: true, kind: 'parser-false-positive', evidence: pick(lines),
1016
+ advice: '判账疑似把通过/汇总行误判为失败行(PER_TEST_FAIL_RE 残留形态)——请把失败输出原文上报工具仓修判定正则;exitCode 非零的根因另在输出里' }
1017
+ }
1018
+ return empty
1019
+ }
1020
+
955
1021
  /**
956
1022
  * 决定 verify 实测的执行动作(纯函数,便于测试;坑 verify-worktree-... 修复方向 3)。
957
1023
  * - skip → 'skip'(真跳过,D-005@v2——不落 full 兜底;evidence-auto 经
@@ -1392,11 +1458,20 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
1392
1458
  console.warn(`📋 known_failures 豁免清单已加载:${knownFailures.length} 条模式(local.yaml)`)
1393
1459
  }
1394
1460
 
1461
+ // —— 缺省收窄(ql-20260920-010 修复一 c,对撞三轮 68 分钟单步构成之一=缺省全量)——
1462
+ // modules: 已配置且未显式 test_strategy → 缺省按 module 子集实测。未配置 modules: 的仓
1463
+ // 保持缺省 full 零打扰;显式 test_strategy: full 是用户有意跑全量,不受影响。
1464
+ let defaultedToModule = false
1465
+ if (rawStrategy === null && extractModules(yamlText)) {
1466
+ defaultedToModule = true
1467
+ console.warn('ℹ️ test_strategy 未配置但 modules: 已配置——缺省按 module 子集实测(v3.29.3 起收窄;显式写 test_strategy: full 恢复全量)')
1468
+ }
1469
+
1395
1470
  // —— evidence-auto 生效策略解析(D-005@v2 / task-11)——
1396
1471
  // 按变更目录 module-impact.md 影响面取生效策略(行为→module、纯文档/门禁→skip、
1397
1472
  // 缺失/不可解析→降级 module 并注记)再进既有链路;full/module/skip/缺省四路径
1398
1473
  // 不经此分支(消费语义逐字不变)。
1399
- let strategy = rawStrategy
1474
+ let strategy = defaultedToModule ? 'module' : rawStrategy
1400
1475
  let evidenceAuto = null
1401
1476
  if (rawStrategy === 'evidence-auto') {
1402
1477
  const changeDir = changeName ? join(specBase, 'changes', changeName) : null
@@ -1503,7 +1578,8 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
1503
1578
  durationMs: null,
1504
1579
  outputTail: null,
1505
1580
  reason: 'test_strategy: module 但本次变更未命中任何已配置 modules(0 命中)。为避免回退到注定超时/含预存失败的全量 commands.test,CLI 未自动跑全量——据 verify-result.md 自报告判定测试。若需全量覆盖,显式设 test_strategy: full。' +
1506
- ` 诊断:${diagLines.join(';')}` + eaNote,
1581
+ ` 诊断:${diagLines.join(';')}` + eaNote
1582
+ + (defaultedToModule ? '(注:module 为缺省收窄——local.yaml 未显式配置 test_strategy;常跑全量请显式写 test_strategy: full)' : ''),
1507
1583
  resultPath: null,
1508
1584
  mode: 'module-zero-hit',
1509
1585
  fallbackReason: null,
@@ -1515,6 +1591,7 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
1515
1591
  // evidence-auto 解析为 module 后落全量兜底时追加推荐来源注记(eaNote;其余路径空串零变化)。
1516
1592
  let fallbackReason = computeFullFallbackReason({ strategy, modulesPresent, hitCount })
1517
1593
  if (fallbackReason && eaNote) fallbackReason = fallbackReason + eaNote
1594
+ if (fallbackReason && defaultedToModule) fallbackReason = fallbackReason + '(注:module 为缺省收窄——local.yaml 未显式配置 test_strategy)'
1518
1595
  mainResult = runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fallbackReason, knownFailures })
1519
1596
  }
1520
1597
 
@@ -3075,9 +3152,12 @@ export async function runCeremonyDualRunCheck({ cwd, specBase = null, changeName
3075
3152
  try { factModuleIndex = discoverModuleIndex(join(sb, 'knowledge')) } catch { /* 缺 map → span 跨模块维跳过 */ }
3076
3153
 
3077
3154
  // —— 引擎对账(mismatch 判定单点)+ 命中明细(同入参跑 computeCeremonyTier 取 reasons,供披露)——
3155
+ // pricing 配置注入(2026-09-20 项目化定价):双跑事实面与 classifyReviewTier 声明面同源读
3156
+ // local.yaml ceremony: 段——两侧配置不一致会制造假 mismatch,此处与 review-tier 同 specBase 口径。
3157
+ const factPricingConfig = readCeremonyPricingConfig(sb)
3078
3158
  const verdict = reconcileDualRun({ declaredTier, factBlastTier: factRisk.tier, factFiles: actual.files, factModuleIndex, factSpanRiskPatterns })
3079
3159
  const factDetail = computeCeremonyTier({
3080
- blastTier: factRisk.tier, declaredFiles: actual.files, moduleIndex: factModuleIndex, spanRiskPatterns: factSpanRiskPatterns, frictionCounts: {},
3160
+ blastTier: factRisk.tier, declaredFiles: actual.files, moduleIndex: factModuleIndex, spanRiskPatterns: factSpanRiskPatterns, frictionCounts: {}, config: factPricingConfig,
3081
3161
  })
3082
3162
  if (verdict.severity === 'warn') {
3083
3163
  notes.push(`[advisory] 高报记账(D-005):声明档 ${String(declaredTier)} > 事实档 ${verdict.factTier}——只记账不阻断不记摩擦(高报代价是更重仪式,自罚机制在场)`)