When another coding agent (or a person) commits to your repo, Claude notices, runs mechanical checks (line endings, secrets, .env files) and audits the commits…

A Claude Code mod: when another coding agent commits to your repo, Claude notices and audits it by diff and tests instead of trusting its report.
I build with two agents. One writes code, and Claude reviews it. The second agent commits under the same git name and writes confident reports. In one week, three of its reports did not match the code:
Reviews only happen if someone notices the commits. This mod does the noticing.
Co-Authored-By: Claude, which Claude Code adds to its own commits) counts as another agent's. A yellow line above the prompt shows how many are waiting, and a toast appears when new ones arrive..env files that were committed/cross-check hands Claude the list with one instruction: check against the diffs and by running the relevant tests, and do not trust the commit messages or reports. Claude is told to report only, never to revert or rewrite./cross-check skip dismisses the waiting commits without a review.
On the first run it only records the current commit, so old history is never flagged. If history is rewritten (rebase, reset), it starts watching again from the current commit.
/plugin marketplace add naoanao/agent-cross-check
/plugin install agent-cross-check@agent-cross-check
| Setting | Default | What it means |
|---|---|---|
| Marker of your own commits | Co-Authored-By: Claude | Commits containing this text are yours. Use commas to list several markers. |
| Review automatically | off | Start the audit as soon as commits appear. |
| Usage ceiling (%) | 90 | Automatic review waits at or above this usage. |
| Check every (minutes) | 10 | How often to look while the session is open. |
git, read-only.rev-parse HEAD to find the current commitmerge-base --is-ancestor to detect rewritten historylog with a fixed format, for new commits since the last checkdiff-tree --name-only, plus show with --shortstat, --ignore-cr-at-eol --shortstat and the plain diff, for the mechanical checks/cross-check or turn on automatic review. The prompt is the review request described above. You can read it in full in hooks/logic.js (reviewPrompt).session.start and turn.complete, to check for commitscommand.run, for /cross-checkui.render, for the line above the promptclaude plugin test .
There are 10 tests: the rules on their own, plus the whole mod running in Claude Code's test engine with a fake git history, store and usage. Each of three guards was broken once on purpose to confirm its test fails for the right reason:
Made by nao, an AI consultant who builds. I design and run AI automation for small businesses, from first conversation to production. Portfolio: https://growl-ai.com/portfolio/en
Also by me: shared-repo-guard. It stops secret values from leaving your .env, blocks pushes to public remotes, and blocks git commands that would wipe another agent's uncommitted work.
MIT License.
別の AI が保存(commit)したら、Claude が気づいて、報告ではなく差分とテストで確かめる部品です。
Co-Authored-By: Claude)が無い保存は、別の AI の物として数えます。入力欄の上に件数が出ます。.env の混入を出します。/cross-check で頼みます。差分とテストで確かめさせ、元に戻したり書き換えたりはさせません。外には何も送りません。動かすのは読むだけの git です。
hooks/register.tsx 154 lines1import {
2 LOG_FORMAT, parseLog, foreignCommits, mayAutoReview, secretPatternHits, lineEndingChurn, shortstatLines, describe, reviewPrompt,
3} from './logic.js'
4
5let trusted = 'Co-Authored-By: Claude'
6let autoReview = false
7let ceiling = 90
8let everyMinutes = 10
9let pending = []
10let timer = null
11
12async function git($, args) {
13 try {
14 const r = await $.process.run(['git', ...args], { timeoutMs: 30000 })
15 return { ok: r.exitCode === 0, out: String(r.stdout || '') }
16 } catch {
17 return { ok: false, out: '' }
18 }
19}
20
21async function load($, key, fallback) {
22 try {
23 const v = await $.store.get(key)
24 return v === undefined || v === null ? fallback : v
25 } catch {
26 return fallback
27 }
28}
29
30async function save($, key, value) {
31 try {
32 await $.store.set(key, value)
33 } catch {
34 // nothing to keep it in: the next check starts over
35 }
36}
37
38async function facts($, hash) {
39 const files = (await git($, ['diff-tree', '--no-commit-id', '--name-only', '-r', '--root', hash])).out.split('\n').filter(Boolean)
40 const stat = (await git($, ['show', '--shortstat', '--format=', hash])).out
41 const statCr = (await git($, ['show', '--shortstat', '--ignore-cr-at-eol', '--format=', hash])).out
42 const diff = (await git($, ['show', '--format=', hash])).out
43 return {
44 files: files.length,
45 lines: shortstatLines(stat),
46 churn: lineEndingChurn(stat, statCr),
47 secrets: secretPatternHits(diff),
48 envFiles: files.filter((f) => /(^|\/)\.env(\.|$)/.test(f) && !/\.(example|sample|template)$/.test(f)),
49 }
50}
51
52// A command hook holds the turn, so a prompt submitted from it would wait on itself: submit from a timer instead.
53function submitSoon($, text) {
54 try {
55 $.clock.after(50, () => { void $.prompt.submit({ text }) })
56 } catch {
57 void $.prompt.fill({ text }).catch(() => {})
58 }
59}
60
61async function startReview($, auto) {
62 const commits = pending.slice()
63 const lines = []
64 for (const c of commits) lines.push(describe(c, await facts($, c.hash)))
65 const summary = lines.join('\n')
66 pending = []
67 await save($, 'pending', pending)
68 if (auto) await save($, 'lastAuto', await $.clock.now())
69 $.ui.invalidate('ui.render')
70 submitSoon($, reviewPrompt(commits, summary))
71 return summary
72}
73
74async function check($) {
75 const head = await git($, ['rev-parse', 'HEAD'])
76 if (!head.ok) return
77 const now = head.out.trim()
78 const last = await load($, 'lastSeen', '')
79 if (!last) {
80 await save($, 'lastSeen', now)
81 return
82 }
83 if (last === now) return
84 const ancestor = await git($, ['merge-base', '--is-ancestor', last, now])
85 if (!ancestor.ok) {
86 await save($, 'lastSeen', now)
87 $.ui.toast('agent-cross-check: history was rewritten, watching again from the current commit')
88 return
89 }
90 const log = await git($, ['log', '--reverse', '--format=' + LOG_FORMAT, last + '..' + now])
91 const found = foreignCommits(parseLog(log.out), trusted, pending)
92 await save($, 'lastSeen', now)
93 if (found.length === 0) return
94 pending = pending.concat(found)
95 await save($, 'pending', pending)
96 $.ui.invalidate('ui.render')
97 $.ui.toast('agent-cross-check: ' + found.length + ' new commit(s) from another agent')
98 if (!autoReview) return
99 let rateLimits = []
100 try {
101 rateLimits = (await $.session.usage()).rateLimits
102 } catch {
103 rateLimits = []
104 }
105 const may = mayAutoReview(await $.clock.now(), Number(await load($, 'lastAuto', 0)), rateLimits, ceiling)
106 if (may.ok) await startReview($, true)
107 else $.ui.toast('agent-cross-check: review waits (' + may.why + '). Run /cross-check when ready.')
108}
109
110export function register(on, options) {
111 trusted = String(options?.trustedMarker ?? trusted)
112 autoReview = options?.autoReview === true
113 ceiling = Number(options?.usageCeiling ?? ceiling) || 90
114 everyMinutes = Math.max(1, Number(options?.checkEveryMinutes ?? everyMinutes) || 10)
115
116 on('session.start', async ($, e, next) => {
117 const result = await next(e)
118 await $.command.register({ name: 'cross-check', description: 'Review commits another agent made: mechanical checks, then ask Claude to audit them (/cross-check skip to dismiss)' })
119 pending = await load($, 'pending', [])
120 await check($)
121 try {
122 timer = $.clock.every(everyMinutes * 60 * 1000, () => { void check($) })
123 } catch {
124 timer = null
125 }
126 return result
127 })
128
129 on('turn.complete', async ($, e, next) => {
130 await check($)
131 return next(e)
132 })
133
134 on('command.run', { command: 'cross-check' }, async ($, e) => {
135 await check($)
136 if (pending.length === 0) return { text: 'No unreviewed commits from another agent.' }
137 if (String(e.args || '').trim() === 'skip') {
138 const n = pending.length
139 pending = []
140 await save($, 'pending', pending)
141 $.ui.invalidate('ui.render')
142 return { text: 'Dismissed ' + n + ' commit(s) without review.' }
143 }
144 const summary = await startReview($, false)
145 return { text: summary + '\n\nAsked Claude to review these commits.' }
146 })
147
148 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
149 if (e.props.hasSurvey || pending.length === 0) return next(e)
150 const { Text } = $.ui.resolve(e)
151 return <Text color="yellow">agent-cross-check: {pending.length} commit(s) from another agent not reviewed yet (/cross-check)</Text>
152 })
153}
154hooks/logic.js 108 lines1// Pure decision functions. No engine calls here, so the tests can call them directly.
2
3const RS = '\x1e'
4const US = '\x1f'
5
6// The git log format the module asks for: hash, author, subject, body, separated so any text is safe.
7export const LOG_FORMAT = '%H' + US + '%an' + US + '%s' + US + '%B' + RS
8
9// Commits from `git log --format=LOG_FORMAT`, oldest first as git was asked.
10export function parseLog(out) {
11 return String(out || '')
12 .split(RS)
13 .map((r) => r.replace(/^\s+/, ''))
14 .filter(Boolean)
15 .map((r) => {
16 const [hash, author, subject, body] = r.split(US)
17 return { hash: (hash || '').trim(), author: author || '', subject: subject || '', body: body || '' }
18 })
19 .filter((c) => /^[0-9a-f]{7,40}$/.test(c.hash))
20}
21
22// A commit counts as this agent's own when its message carries the trusted marker (case-insensitive).
23export function isOwn(commit, trusted) {
24 const marks = String(trusted || '')
25 .split(',')
26 .map((s) => s.trim().toLowerCase())
27 .filter(Boolean)
28 if (marks.length === 0) return false
29 const text = (commit.subject + '\n' + commit.body).toLowerCase()
30 return marks.some((m) => text.includes(m))
31}
32
33// New commits that need a second look: not seen yet, not already pending, not this agent's own.
34export function foreignCommits(commits, trusted, pending) {
35 const have = new Set((pending || []).map((p) => p.hash))
36 return commits
37 .filter((c) => !isOwn(c, trusted) && !have.has(c.hash))
38 .map((c) => ({ hash: c.hash, author: c.author, subject: c.subject }))
39}
40
41// Whether an automatic review may start now: an hour apart, and neither usage window at or above the ceiling.
42export function mayAutoReview(nowMs, lastAutoMs, rateLimits, ceilingPercent) {
43 if (lastAutoMs && nowMs - lastAutoMs < 60 * 60 * 1000) return { ok: false, why: 'reviewed less than an hour ago' }
44 const high = (rateLimits || []).find((r) => (r.kind === 'five_hour' || r.kind === 'seven_day') && r.percentUsed >= ceilingPercent)
45 if (high) return { ok: false, why: 'usage ' + high.kind + ' at ' + high.percentUsed + '%' }
46 return { ok: true, why: '' }
47}
48
49// Known secret formats in the added lines of a diff.
50export function secretPatternHits(diffText) {
51 const pats = {
52 'Stripe live key': /sk_live_[A-Za-z0-9]{8,}/g,
53 'Stripe test key': /sk_test_[A-Za-z0-9]{8,}/g,
54 'JWT': /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/g,
55 'GitHub token': /gh[pousr]_[A-Za-z0-9]{20,}/g,
56 'OpenAI / Anthropic key': /sk-(ant-)?[A-Za-z0-9_-]{20,}/g,
57 'Google API key': /AIza[0-9A-Za-z_-]{30,}/g,
58 'AWS access key': /AKIA[0-9A-Z]{16}/g,
59 'Private key block': /-----BEGIN [A-Z ]*PRIVATE KEY-----/g,
60 }
61 const added = String(diffText || '')
62 .split(/\r?\n/)
63 .filter((l) => l.startsWith('+') && !l.startsWith('+++'))
64 .join('\n')
65 const out = []
66 for (const [name, re] of Object.entries(pats)) {
67 const n = (added.match(re) || []).length
68 if (n > 0) out.push(name + ' x' + n)
69 }
70 return out
71}
72
73export function shortstatLines(s) {
74 const ins = Number((String(s).match(/(\d+) insertion/) || [])[1] || 0)
75 const del = Number((String(s).match(/(\d+) deletion/) || [])[1] || 0)
76 return ins + del
77}
78
79export function lineEndingChurn(plain, ignoringCr) {
80 const a = shortstatLines(plain)
81 const b = shortstatLines(ignoringCr)
82 return a !== b ? { shown: a, real: b } : null
83}
84
85// One line per commit for the summary.
86export function describe(c, facts) {
87 const bits = [c.hash.slice(0, 7) + ' ' + c.subject + ' (' + c.author + ')']
88 bits.push(' ' + (facts.files || 0) + ' file(s), ' + (facts.lines || 0) + ' line(s) changed')
89 if (facts.churn) bits.push(' LINE ENDINGS CHANGED: diff shows ' + facts.churn.shown + ' lines, real content ' + facts.churn.real)
90 if (facts.secrets && facts.secrets.length) bits.push(' SECRET-LOOKING STRINGS: ' + facts.secrets.join(', '))
91 if (facts.envFiles && facts.envFiles.length) bits.push(' .env FILES COMMITTED: ' + facts.envFiles.join(', '))
92 return bits.join('\n')
93}
94
95// The prompt that asks Claude to review the other agent's commits.
96export function reviewPrompt(commits, summary) {
97 const list = commits.map((c) => c.hash.slice(0, 7)).join(' ')
98 return [
99 'Another agent (or a person) committed to this repository and nobody has reviewed it yet: ' + list + '.',
100 'Review these commits as an auditor. Check them against the diffs and by running the relevant tests. Do not rely on their commit messages or any report they wrote.',
101 'For each commit, report what actually changed, what is broken or risky, and what you could not verify.',
102 'Do not change, revert or rewrite these commits. Only report.',
103 '',
104 'Mechanical checks already run:',
105 summary,
106 ].join('\n')
107}
108