- VERSION file as authoritative version source - export.mjs reads VERSION with package.json fallback - bw-ota.ps1 DryRun mode for safe testing - auto-setup.ps1 bumped to v3.2.0 (Phase 8 OTA)
148 lines
5.8 KiB
JavaScript
148 lines
5.8 KiB
JavaScript
#!/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();
|