ratchet agent 规则遵循检查器
Ratchet 把 coding agent 的“请保持简单”从提示词变成 PostToolUse 之后的可度量反馈:它读取本轮真实 diff,识别新增依赖、wrapper、YAGNI 抽象、重复符号和预算超限,再把结果回灌给正在工作的 agent。
它解决什么,凭什么值得拆
Ratchet 的入口不是一个静态 linter,而是 Claude hook 事件链:sources/ratchet/hooks/hooks.json:29 定义 PostToolUse,sources/ratchet/hooks/hooks.json:31 把 matcher 限定在真实文件编辑工具,sources/ratchet/hooks/hooks.json:35 调用 guard 脚本。进入运行时后,sources/ratchet/hooks/ratchet-guard.js:84 读取 stdin payload,sources/ratchet/hooks/ratchet-guard.js:89 反推仓库根目录,sources/ratchet/hooks/ratchet-guard.js:95 解析当前模式和预算。这组入口说明它关心的是“本轮实际写了什么”,不是预先说服模型。
核心检测也很克制:sources/ratchet/hooks/ratchet-guard.js:100 从 payload 提取变更,sources/ratchet/hooks/ratchet-guard.js:117 调用 inspect,sources/ratchet/hooks/ratchet-guard.js:121 再用 baseline 过滤旧债。inspect 内部由 sources/ratchet/hooks/lib/detect.js:279 统一调度,sources/ratchet/hooks/lib/detect.js:286 到 sources/ratchet/hooks/lib/detect.js:290 依次跑 manifest dependency、模式规则、wrapper、抽象和重复符号。最后 sources/ratchet/hooks/lib/detect.js:300 排序,保证反馈先给确定性更高的问题。
▸核心数据
深度阅读 → 入口展开。
Hook 驱动的闭环架构
架构可以从 sources/ratchet/hooks/hooks.json:1 开始读:SessionStart 注入规则,UserPromptSubmit 接受模式切换,PostToolUse 才真正检查编辑。sources/ratchet/hooks/hooks.json:43 还覆盖 SubagentStart,sources/ratchet/hooks/hooks.json:55 在 Stop 阶段输出报告,所以它把一次 agent 会话拆成启动、提示、编辑、子代理、收尾五个触点。
▸系统拓扑
▸模块清单
sources/ratchet/hooks/hooks.json:29 PostToolUse 只匹配 Edit/MultiEdit/Write,所以 Ratchet 的检查点明确落在真实编辑之后。sources/ratchet/hooks/ratchet-guard.js:45 Guard 先把 Claude hook payload 转成 change 对象,再按 manifest 与普通文件分流。sources/ratchet/hooks/lib/detect.js:279 inspect() 把新增行、依赖、wrapper、YAGNI、重复符号和 suppress 行合并成有序 findings。sources/ratchet/hooks/lib/state.js:24 session 记录新增行、新文件、依赖和已报告 finding,避免同一条反馈在长会话里刷屏。sources/ratchet/hooks/lib/baseline.js:21 baseline 把既有债务 fingerprint 化,只让新引入的问题触发反馈。sources/ratchet/bin/install.js:299 install() 写入 hook、初始化 mark、建立 baseline,并在 Windows 上尝试打开 UI。Ratchet 的入口不是一个静态 linter,而是 Claude hook 事件链:sources/ratchet/hooks/hooks.json:29 定义 PostToolUse,sources/ratchet/hooks/hooks.json:31 把 matcher 限定在真实文件编辑工具,sources/ratchet/hooks/hooks.json:35 调用 guard 脚本。进入运行时后,sources/ratchet/hooks/ratchet-guard.js:84 读取 stdin payload,sources/ratchet/hooks/ratchet-guard.js:89 反推仓库根目录,sources/ratchet/hooks/ratchet-guard.js:95 解析当前模式和预算。这组入口说明它关心的是“本轮实际写了什么”,不是预先说服模型。
核心检测也很克制:sources/ratchet/hooks/ratchet-guard.js:100 从 payload 提取变更,sources/ratchet/hooks/ratchet-guard.js:117 调用 inspect,sources/ratchet/hooks/ratchet-guard.js:121 再用 baseline 过滤旧债。inspect 内部由 sources/ratchet/hooks/lib/detect.js:279 统一调度,sources/ratchet/hooks/lib/detect.js:286 到 sources/ratchet/hooks/lib/detect.js:290 依次跑 manifest dependency、模式规则、wrapper、抽象和重复符号。最后 sources/ratchet/hooks/lib/detect.js:300 排序,保证反馈先给确定性更高的问题。
▸关键数据结构
24function emptySession(sessionId, mode, root) { 25 return { 26 sessionId: sessionId || 'default', 27 mode, 28 root: root || '', 29 startedAt: Date.now(), 30 addedLines: 0, 31 removedLines: 0, 32 newFiles: [], 33 touchedFiles: [], 34 deps: [], 35 findings: [], 36 reported: [], 37 }; 38}
Guard、detector 与 baseline 三件套
▸ratchet-guard:编辑事件的主控制面
Guard loop 的关键是把 hook payload 转为可度量 diff。sources/ratchet/hooks/ratchet-guard.js:45 从 tool_input 找到路径,sources/ratchet/hooks/ratchet-guard.js:50 对 manifest 强制读取整文件,sources/ratchet/hooks/ratchet-guard.js:55 兼容 MultiEdit,sources/ratchet/hooks/ratchet-guard.js:60 兼容 Write 内容。随后 sources/ratchet/hooks/ratchet-guard.js:111 遍历每个 change,sources/ratchet/hooks/ratchet-guard.js:114 记录新文件,sources/ratchet/hooks/ratchet-guard.js:118 与 sources/ratchet/hooks/ratchet-guard.js:119 累加新增和删除行。这不是全仓扫描,而是 session 粒度的行为账本。
84async function main() { 85 const payload = await readStdin(); 86 if (!EDIT_TOOLS.has(payload.tool_name)) return; 87 if (payload.tool_response && payload.tool_response.error) return; 88 89 const root = repoRoot(payload.cwd || process.cwd()); 90 useRoot(root); 91 const stored = readSession(payload.session_id); 92 const mode = (stored && normalizeMode(stored.mode)) || resolveMode(root); 93 if (mode === 'off') return; 94 95 const budget = resolveBudget(mode, root); 96 const session = stored || emptySession(payload.session_id, mode, root); 97 session.mode = mode; 98 session.removedLines = session.removedLines || 0; 99 100 const changes = changesFrom(payload, root); 101 if (!changes.length) return; 102 103 const context = { 104 root, 105 symbols: buildIndex(root), 106 countImplementations: (name) => countImplementations(root, name), 107 }; 108 const accepted = baseline.read(root); 109 const fresh = []; 110 111 for (const change of changes) { 112 const relative = relativePosix(root, change.path); 113 if (relative.startsWith('..') || isTestPath(relative)) continue; 114 if (isNewFile(root, change.path, session)) session.newFiles.push(relative); 115 if (!session.touchedFiles.includes(relative)) session.touchedFiles.push(relative); 116 117 const result = inspect(change, context); 118 session.addedLines += result.added.length; 119 session.removedLines += result.removed.length; 120 121 const surviving = baseline.filter(result.findings, accepted, () => relative); 122 for (const finding of surviving) { 123 if (finding.dep && !session.deps.includes(finding.dep)) session.deps.push(finding.dep); 124 const key = fingerprint(finding, relative); 125 if (session.reported.includes(key)) continue; 126 session.reported.push(key); 127 finding.path = relative; 128 session.findings.push(finding); 129 fresh.push(finding); 130 } 131 } 132 133 const overruns = budget.enforce === 'silent' ? [] : budgetFindings(session, budget); 134 writeSession(session); 135 136 const shown = [...fresh, ...overruns].slice(0, MAX_REPORTED); 137 if (!shown.length) return; 138 139 const hidden = fresh.length + overruns.length - shown.length; 140 const body = 141 headline(session, budget, mode) + 142 '\n\n' + 143 alignedFindings(shown) + 144 (hidden > 0 ? '\n\n' + hidden + ' more not shown' : ''); 145 146 const blocking = 147 budget.enforce === 'block' && 148 (overruns.length > 0 || fresh.some((finding) => finding.confidence === CERTAIN)); 149 150 if (blocking) { 151 block('PostToolUse', body + '\n\nStrict mode. Revert or shrink this change, or ask the user to raise the budget.'); 152 return; 153 } 154 155 emit( 156 'PostToolUse', 157 body + '\n\nFix what applies, then continue. Wrong call? say so, or mark the line with: ratchet-ignore: reason' 158 );
▸detect.js:小规则组合成 finding pipeline
Detector 的设计更像“小而硬”的规则集合。sources/ratchet/hooks/lib/changes.js:9 用 before/after 的行池计算新增行,sources/ratchet/hooks/lib/changes.js:25 到 sources/ratchet/hooks/lib/changes.js:33 只把常见依赖 manifest 纳入确定性 dependency 检测。wrapper 检测从 sources/ratchet/hooks/lib/detect.js:209 开始,YAGNI 抽象从 sources/ratchet/hooks/lib/detect.js:231 开始,重复符号从 sources/ratchet/hooks/lib/detect.js:250 开始,fingerprint 在 sources/ratchet/hooks/lib/detect.js:275 把数字归一化,避免行号移动导致 baseline 失效。
279function inspect(change, context = {}) { 280 const added = addedLines(change.before, change.after); 281 const removed = addedLines(change.after, change.before); 282 if (!added.length && !isManifest(change.path)) return { added: [], removed, findings: [] }; 283 284 const suppressed = suppressedLines(change.after); 285 const findings = [ 286 ...scanManifest(change), 287 ...scanPatterns(change, added), 288 ...scanWrappers(change, added), 289 ...scanAbstractions(change, added, context), 290 ...scanDuplicates(change, added, context), 291 ].filter((finding) => !suppressed.has(finding.line)); 292 293 const unique = new Map(); 294 for (const finding of findings) { 295 const key = finding.tag + '|' + finding.what; 296 const previous = unique.get(key); 297 if (!previous || RANK[finding.confidence] < RANK[previous.confidence]) unique.set(key, finding); 298 } 299 300 const ordered = [...unique.values()].sort( 301 (a, b) => RANK[a.confidence] - RANK[b.confidence] || a.line - b.line 302 ); 303 return { added, removed, findings: ordered }; 304} 305 306function budgetFindings(session, budget) { 307 if (!budget) return []; 308 const findings = []; 309 const net = session.addedLines - (session.removedLines || 0); 310 311 if (session.newFiles.length > budget.newFiles) { 312 findings.push({ 313 tag: 'budget', confidence: CERTAIN, path: '', line: 0, 314 what: session.newFiles.length + ' new files, budget is ' + budget.newFiles, 315 fix: 'fold the extras into files that already exist', 316 }); 317 } 318 if (session.deps.length > budget.newDeps) { 319 findings.push({ 320 tag: 'budget', confidence: CERTAIN, path: '', line: 0, 321 what: session.deps.length + ' new dependencies, budget is ' + budget.newDeps, 322 fix: 'drop ' + session.deps.slice(budget.newDeps).join(', '), 323 }); 324 } 325 if (net > budget.addedLines) { 326 findings.push({ 327 tag: 'budget', confidence: CERTAIN, path: '', line: 0, 328 what: net + ' net added lines, budget is ' + budget.addedLines, 329 fix: 'stop adding and start deleting, or raise the budget explicitly', 330 }); 331 } 332 return findings; 333} 334 335function format(finding) { 336 const where = finding.path ? finding.path + ':' + finding.line + ' ' : ''; 337 return where + finding.tag + ': ' + finding.what + '. ' + finding.fix + '.'; 338} 339 340module.exports = { 341 CERTAIN, HEURISTIC, LIKELY, PATTERNS, RANK, 342 budgetFindings, fingerprint, format, inspect, splitLines, suppressedLines, 343};
从编辑事件到会话账本
运行链路分两条:第一条是实时 guard,从 sources/ratchet/hooks/ratchet-guard.js:84 到 sources/ratchet/hooks/ratchet-guard.js:158 在编辑后回灌反馈;第二条是安装和审计,从 sources/ratchet/bin/install.js:299 到 sources/ratchet/bin/install.js:355 注册 hook、初始化 mark 和 baseline,再由 sources/ratchet/bin/install.js:466 与 sources/ratchet/bin/install.js:487 支持离线 audit/baseline。会话结束时 sources/ratchet/hooks/ratchet-report.js:24 汇总,sources/ratchet/hooks/lib/state.js:132 写 ledger,形成趋势记录。
▸实时编辑检查
▸安装、审计与趋势
验证路径与 doctor 自检
Ratchet 的验证入口分散在 CLI doctor、audit 和 report 中,适合先用源码确认行为,再运行 npm test 或 ratchet doctor 做端到端冒烟。
▸测试覆盖
这个仓库没有传统 tests 目录,但 CLI 内置 doctor 自测路径:sources/ratchet/bin/install.js:586 开始构造检查,sources/ratchet/bin/install.js:623 生成 wrapper 样例,sources/ratchet/bin/install.js:637 判断 session hook 是否注入规则,sources/ratchet/bin/install.js:643 生成重复符号样例,sources/ratchet/bin/install.js:663 检查 ledger 是否落盘。源码级阅读时要把 doctor 看成冒烟测试,把 README 的 115 tests 徽标当作待核验声明。
586function doctor() { 587 const root = projectRoot(); 588 let failed = false; 589 590 say(heading('environment')); 591 say(''); 592 const major = Number(process.versions.node.split('.')[0]); 593 line('node', process.versions.node, major >= 20 ? 'good' : 'bad'); 594 if (major < 20) failed = true; 595 const hasGit = which('git'); 596 line('git', hasGit ? 'found' : 'missing, the measurement half will not run', hasGit ? 'good' : 'warn'); 597 598 const projectSettings = path.join(root, '.claude', 'settings.json'); 599 const globalSettings = path.join(claudeDir(), 'settings.json'); 600 const installedHere = (readJsonStrict(projectSettings, null) || {}).hooks; 601 const installedGlobally = (readJsonStrict(globalSettings, null) || {}).hooks; 602 const scope = JSON.stringify(installedHere || {}).includes('ratchet-') 603 ? 'project ' + projectSettings 604 : JSON.stringify(installedGlobally || {}).includes('ratchet-') 605 ? 'global ' + globalSettings 606 : ''; 607 line('install', scope || 'not installed here, run: ratchet', scope ? 'good' : 'warn'); 608 609 say(''); 610 say(heading('the loop, driven with real payloads')); 611 say(''); 612 613 const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'ratchet-doctor-')); 614 const home = path.join(scratch, 'home'); 615 const env = { CLAUDE_CONFIG_DIR: home, RATCHET_MODE: 'guard' }; 616 617 try { 618 fs.mkdirSync(path.join(scratch, 'src'), { recursive: true }); 619 fs.mkdirSync(path.join(scratch, '.ratchet'), { recursive: true }); 620 fs.writeFileSync(path.join(scratch, 'package.json'), '{"name":"scratch","dependencies":{}}'); 621 fs.writeFileSync( 622 path.join(scratch, 'src', 'time.js'), 623 'export function formatDuration(ms) {\n return ms + "ms";\n}\n' 624 ); 625 626 if (hasGit) { 627 const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' }); 628 git('init', '-q'); 629 git('config', 'user.email', 'doctor@ratchet'); 630 git('config', 'user.name', 'doctor'); 631 git('add', '-A'); 632 git('commit', '-qm', 'init'); 633 } 634 635 const session = driveHook('ratchet-session.js', { session_id: 'doctor', cwd: scratch, source: 'startup' }, env); 636 const ruleset = session.includes('RATCHET ACTIVE') && session.includes('first rung'); 637 line('session hook', ruleset ? 'injected the ruleset and the budget' : 'no ruleset came back', ruleset ? 'good' : 'bad'); 638 if (!ruleset) failed = true; 639 640 const planted = [ 641 "import moment from 'moment';", 642 '', 643 'export function format_duration(ms) {', 644 " return ms + 'ms';", 645 '}', 646 '', 647 'const snapshot = JSON.parse(JSON.stringify(state));', 648 '', 649 ].join('\n'); 650 fs.writeFileSync(path.join(scratch, 'src', 'report.js'), planted); 651 652 const guard = driveHook('ratchet-guard.js', { 653 session_id: 'doctor', 654 cwd: scratch, 655 tool_name: 'Write', 656 tool_input: { file_path: path.join(scratch, 'src', 'report.js'), content: planted }, 657 }, env); 658 const tags = ['dep', 'native', 'exists'].filter((tag) => guard.includes(tag + ' ')); 659 line('guard hook', tags.length ? 'caught ' + tags.join(', ') + ' in a planted file' : 'caught nothing', tags.length >= 2 ? 'good' : 'bad'); 660 if (tags.length < 2) failed = true; 661 662 const report = driveHook('ratchet-report.js', { session_id: 'doctor', cwd: scratch }, env); 663 const ledger = fs.existsSync(path.join(scratch, '.ratchet', 'ledger.jsonl')); 664 line('report hook', report.includes('ratchet guard session') ? (ledger ? 'summarised and wrote a ledger row' : 'summarised, no ledger') : 'silent', report ? 'good' : 'bad');
▸已知边界 / 失败模式
sources/ratchet/hooks/ratchet-guard.js:150strict mode 只在预算超限或 certain finding 时 block,likely/heuristic 主要用于提醒,团队需要接受这个偏保守策略。sources/ratchet/hooks/lib/detect.js:177suppress 规则会同时跳过当前行和下一行,适合局部豁免,但如果滥用会让 ledger 变成形式主义。sources/ratchet/hooks/lib/state.js:75依赖 git ls-files,未初始化仓库时只能注入规则,mark、ledger 和 audit 都会退化。
技术亮点
sources/ratchet/hooks/ratchet-guard.js:111 针对本轮 edit payload 做检查,反馈仍在同一 agent session 内。sources/ratchet/hooks/lib/detect.js:275 的 fingerprint 把数字归一,sources/ratchet/hooks/lib/baseline.js:33 只过滤已接受项,减少历史债务噪音。9function addedLines(before, after) { 10 const pool = new Map(); 11 for (const line of splitLines(before)) { 12 const key = line.trim(); 13 pool.set(key, (pool.get(key) || 0) + 1); 14 } 15 return splitLines(after).reduce((added, line, index) => { 16 const key = line.trim(); 17 if (!key) return added; 18 const remaining = pool.get(key) || 0; 19 if (remaining > 0) pool.set(key, remaining - 1); 20 else added.push({ line: index + 1, text: line }); 21 return added; 22 }, []);
21function write(root, fingerprints, note) { 22 const file = baselinePath(root); 23 fs.mkdirSync(path.dirname(file), { recursive: true }); 24 const payload = { 25 recordedAt: new Date().toISOString(), 26 note: note || 'findings that predate the ratchet', 27 accepted: [...new Set(fingerprints)].sort(), 28 }; 29 fs.writeFileSync(file, JSON.stringify(payload, null, 2) + '\n', 'utf8'); 30 return payload; 31} 32 33function filter(findings, accepted, relativeFor) { 34 if (!accepted || accepted.size === 0) return findings; 35 return findings.filter((finding) => !accepted.has(fingerprint(finding, relativeFor(finding))));
技术亮点之外,别忽略这些
风险主要集中在 hook host 和启发式边界。sources/ratchet/hooks/lib/host.js:12 读取 stdin 有 timeout,异常时 guard 会静默退出;sources/ratchet/hooks/ratchet-guard.js:161 也选择 catch 后 exit 0,避免破坏主会话但可能吞掉工具故障。符号索引在 sources/ratchet/hooks/lib/index-symbols.js:123 做缓存,sources/ratchet/hooks/lib/audit.js:36 对大文件设 maxBytes;这些都是实用取舍,但也意味着 Ratchet 更适合增量治理,不适合替代完整静态分析。
sources/ratchet/hooks/lib/detect.js:200 的 pattern scan 很适合即时反馈,但不能替代语言服务器和安全扫描器。sources/ratchet/hooks/ratchet-guard.js:161 选择失败不阻断会话,这对开发体验友好,但团队要用 doctor/report 发现 hook 健康问题。