ratchetRatchet
§ 00 · Source-level Observatory

ratchet agent 规则遵循检查器

Ratchet 把 coding agent 的“请保持简单”从提示词变成 PostToolUse 之后的可度量反馈:它读取本轮真实 diff,识别新增依赖、wrapper、YAGNI 抽象、重复符号和预算超限,再把结果回灌给正在工作的 agent。

★ 430
GitHub Stars
3.7K
LOC
36
Files
same day
Last commit
§ 00 · OVERVIEW

它解决什么,凭什么值得拆

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 排序,保证反馈先给确定性更高的问题。

▸核心数据

架构模式
Claude hook → diff extraction → detector pipeline → baseline filter → session ledger → inline feedback。核心路径横跨 hooks.json、ratchet-guard.js、detect.js、baseline.js 和 state.js。
核心抽象
Finding fingerprint、session budget、mark/ledger、symbol index。Ratchet 不是通用 linter,而是面向 agent session 的复杂度单向阀。
性能/精度数据
符号索引在 README 中描述为冷启动数百毫秒、warm cache 约 4ms;源码上限通过 maxBuffer、maxBytes 和 3000 文件 cap 控制。
下游依赖
Claude Code plugin hooks、AGENTS.md/Skill/commands、Windows UI launcher、repo-local .ratchet state。
本文导航 — 6 个章节依次:概述 → 架构图 → 核心模块 → 关键流程 → 测试与验证 → 风险。子页面在每个 H3 末尾以 深度阅读 → 入口展开。
§ 01 · ARCHITECTURE

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 会话拆成启动、提示、编辑、子代理、收尾五个触点。

▸系统拓扑

User prompt -> Claude edit tool -> PostToolUse hook -> ratchet-guard.js -> changesFrom(payload) -> inspect(change) -> baseline.filter() -> session ledger -> emit feedback or block strict mode

▸模块清单

Hook wiring
sources/ratchet/hooks/hooks.json:29 PostToolUse 只匹配 Edit/MultiEdit/Write,所以 Ratchet 的检查点明确落在真实编辑之后。
Diff reader
sources/ratchet/hooks/ratchet-guard.js:45 Guard 先把 Claude hook payload 转成 change 对象,再按 manifest 与普通文件分流。
Detector engine
sources/ratchet/hooks/lib/detect.js:279 inspect() 把新增行、依赖、wrapper、YAGNI、重复符号和 suppress 行合并成有序 findings。
Session ledger
sources/ratchet/hooks/lib/state.js:24 session 记录新增行、新文件、依赖和已报告 finding,避免同一条反馈在长会话里刷屏。
Baseline store
sources/ratchet/hooks/lib/baseline.js:21 baseline 把既有债务 fingerprint 化,只让新引入的问题触发反馈。
CLI installer
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 排序,保证反馈先给确定性更高的问题。

▸关键数据结构

src/ratchet/hooks/lib/state.js · L24-38JavaScript
 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 loop 如何把编辑变成反馈
§ 02 · CORE MODULES

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 粒度的行为账本。

src/ratchet/hooks/ratchet-guard.js · L84-158JavaScript
 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 失效。

src/ratchet/hooks/lib/detect.js · L279-343JavaScript
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};
深度阅读 · Detector 与 baseline 如何避免噪音
§ 03 · KEY FLOW

从编辑事件到会话账本

运行链路分两条:第一条是实时 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,形成趋势记录。

▸实时编辑检查

Edit/MultiEdit/Write -> changesFrom() -> addedLines() -> inspect() -> baseline.filter() -> budgetFindings() -> emit/block

▸安装、审计与趋势

ratchet install -> settings hooks merge -> ensureMeasuring(mark) -> ensureBaseline(audit existing) -> session Stop -> appendLedger() -> ratchet report
深度阅读 · Baseline、fingerprint 与 ledger 的配合
§ 04 · TESTS & VALIDATION

验证路径与 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 徽标当作待核验声明。

tests/../sources/ratchet/bin/install.js · L586-664JavaScript
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:150 strict mode 只在预算超限或 certain finding 时 block,likely/heuristic 主要用于提醒,团队需要接受这个偏保守策略。
  • sources/ratchet/hooks/lib/detect.js:177 suppress 规则会同时跳过当前行和下一行,适合局部豁免,但如果滥用会让 ledger 变成形式主义。
  • sources/ratchet/hooks/lib/state.js:75 依赖 git ls-files,未初始化仓库时只能注入规则,mark、ledger 和 audit 都会退化。
§ 05 · HIGHLIGHTS

技术亮点

闭环位置精准
它不是提前扫描全仓,而是在 sources/ratchet/hooks/ratchet-guard.js:111 针对本轮 edit payload 做检查,反馈仍在同一 agent session 内。
Baseline 可迁移
sources/ratchet/hooks/lib/detect.js:275 的 fingerprint 把数字归一,sources/ratchet/hooks/lib/baseline.js:33 只过滤已接受项,减少历史债务噪音。
sources/ratchet/hooks/lib/changes.js · L9-22JavaScript
  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  }, []);
sources/ratchet/hooks/lib/baseline.js · L21-35JavaScript
 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))));
§ 06 · RISKS & GAPS

技术亮点之外,别忽略这些

风险主要集中在 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 很适合即时反馈,但不能替代语言服务器和安全扫描器。
Hook 故障偏静默
sources/ratchet/hooks/ratchet-guard.js:161 选择失败不阻断会话,这对开发体验友好,但团队要用 doctor/report 发现 hook 健康问题。
诚实记录 — 这部分列的是我在源码里看到、但作者没有明确文档化的限制。读者请按需取舍。