#!/usr/bin/env node /** * Patch L1b: Cross-Boost Arbitration (2026-04-25) * * 上下文: * L1 修复让虚拟 agent 注入成功 (self-auditor base 0.702->1.032 +47%), * 但实测 project-audit-expert 仍以 conf=1.0 抢占 primary. * * 真实根因 (与 brief 描述不同): * "Phase 3 顺序依赖" 是误诊. Phase 3 仅在 boosted/penalty 列表存在交集 * 时强制排名, R27 与 R81/R84/R86 的 penalty 数组互不包含对方 boost 目标, * Phase 3 根本未介入. 真实 bug 在 Phase 2: * - R27 fires: project-audit-expert.score = base_pae * (1+boost_R27) * - R81 fires: self-auditor.score = base_sa(virtual=_maxScore*0.6) * (1+boost_R81) * pae 直接命中 系统/自检/审计 全部 BM25 高权重词, base_pae >> base_sa*0.6, * 即使两者各自被 boost, pae 仍胜出. 无任何顺序敏感, 是分数基线不对等. * * 修复 (Phase 2.5 跨域仲裁): * 1. 收集本轮所有 fired 规则中实际 boost 生效的 (skillName, ruleWeight*specificity) 元组 * 2. 检测跨域冲突: 多个不同 skillName 同被 boost, 且相互不在对方 penalty 列表 * 3. 按 (rule.weight * specificity) 仲裁: 最强规则的 boost 目标保持原分, * 其他被 boost 目标按相对权重比例缩放: score *= max(0.6, weakWeight/strongWeight) * 4. 同时把"输家" boost 目标的 score 上限钳制到不超过赢家 score * * 顺序无关性: * 仲裁仅依赖 (boostVotes Map + firedRules + rule 静态属性), 不依赖遍历顺序. * sort + Map.entries 保证确定性. * * Fail-close: * 异常时跳过仲裁, stderr 警告, 不破坏现有 Phase 2/3. * * 使用: * node patch-l1b-cross-boost-arbitration.js # dry-run * node patch-l1b-cross-boost-arbitration.js --apply # 实际写入 */ 'use strict'; const fs = require('fs'); const path = require('path'); const os = require('os'); const { spawnSync } = require('child_process'); const TARGET = path.join(os.homedir(), '.claude', 'scripts', 'route-analyzer.js'); const SENTINEL = '// L1b-CROSS-BOOST-ARBITRATION (2026-04-25)'; const APPLY = process.argv.includes('--apply'); const ANCHOR = ' // Phase 3: 排名强制 — boosted 技能必须排在其 penalized 对手前面'; const INJECTION = ` // ${SENTINEL.replace('// ', '')} // Phase 2.5: 跨域 boost 仲裁 — 防止两条 fired rule 各自 boost 不同 skill // 但相互不在对方 penalty 列表中 (Phase 3 不介入), 导致基线分数高的胜出. // 顺序无关: 仅依赖 boostVotes + 规则静态属性, 不依赖遍历次序. try { if (boostVotes.size >= 2) { const _boostMeta = new Map(); for (const _rule of DISAMBIGUATION_RULES) { if (!firedRules.includes(_rule.id)) continue; if (!_rule.boost || !boostVotes.has(_rule.boost)) continue; const _spec = computeRuleSpecificity(_rule.trigger.source); const _w = (_rule.weight || 0) * (0.5 + _spec * 0.5); const _prev = _boostMeta.get(_rule.boost); if (!_prev || _w > _prev.weight) { _boostMeta.set(_rule.boost, { weight: _w, ruleId: _rule.id, penaltySet: new Set(_rule.penalty || []) }); } } if (_boostMeta.size >= 2) { const _ranked = Array.from(_boostMeta.entries()) .sort((a, b) => b[1].weight - a[1].weight); const [_winnerName, _winnerMeta] = _ranked[0]; const _winner = results.find(r => r.name === _winnerName && r.score > 0); if (_winner) { for (let _i = 1; _i < _ranked.length; _i++) { const [_loserName, _loserMeta] = _ranked[_i]; const _crossPenalty = _winnerMeta.penaltySet.has(_loserName) || _loserMeta.penaltySet.has(_winnerName); if (_crossPenalty) continue; const _loser = results.find(r => r.name === _loserName && r.score > 0); if (!_loser) continue; const _ratio = Math.max(0.6, _loserMeta.weight / Math.max(_winnerMeta.weight, 1e-6)); const _newScore = _loser.score * _ratio; _loser.score = Math.min(_newScore, _winner.score * 0.95); _loser._arbitratedBy = _winnerMeta.ruleId; _loser._arbitrationRatio = Math.round(_ratio * 1000) / 1000; } } } } } catch (_e) { try { process.stderr.write('[route-analyzer] L1b cross-boost arbitration skipped: ' + (_e && _e.message ? _e.message : String(_e)) + '\\n'); } catch (_) {} } `; function syntaxCheck(code) { const tmp = path.join(os.tmpdir(), 'l1b-syncheck-' + process.pid + '.js'); fs.writeFileSync(tmp, code, 'utf8'); const r = spawnSync(process.execPath, ['--check', tmp], { encoding: 'utf8' }); try { fs.unlinkSync(tmp); } catch (_) {} return { ok: r.status === 0, stderr: r.stderr || '' }; } function main() { if (!fs.existsSync(TARGET)) { console.error('[L1b] target not found:', TARGET); process.exit(2); } const src = fs.readFileSync(TARGET, 'utf8'); if (src.includes(SENTINEL)) { console.log('[L1b] already applied (sentinel found). Skip.'); return; } const idx = src.indexOf(ANCHOR); if (idx < 0) { console.error('[L1b] anchor not found, cannot patch.'); process.exit(3); } const patched = src.slice(0, idx) + INJECTION + src.slice(idx); const chk = syntaxCheck(patched); if (!chk.ok) { console.error('[L1b] generated code failed node --check:', chk.stderr); process.exit(4); } if (!APPLY) { console.log('[L1b] DRY-RUN ok: would inject', INJECTION.split('\n').length, 'lines at offset', idx); console.log('[L1b] use --apply to write'); return; } const bak = TARGET + '.bak.l1b.' + Date.now(); fs.copyFileSync(TARGET, bak); const tmp = TARGET + '.tmp.l1b.' + process.pid; fs.writeFileSync(tmp, patched, 'utf8'); fs.renameSync(tmp, TARGET); console.log('[L1b] APPLIED. backup:', path.basename(bak)); } main();