No claims without evidence: flags "tests pass", "CI green" and "verified" claims that nothing in the turn backs up.

█▀█ █▄ █ ▀█▀ ▀█▀ █▀▀ █ █ █▀▀ █▀█ ▀█▀
█▀█ █ ▀█ █ █ ▀▀▀ █ █▀█ █▀ █▀█ █
▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀
NO CLAIMS WITHOUT EVIDENCE

The problem: the model says "tests pass" or "CI green" after editing code without running anything again. In the study behind this library, 23% of 279 memory files were verification traps.
anti-cheat is the referee. During each main-loop turn it logs every file edit and every check command (test, lint/typecheck, build, CI, push), with whether it passed. When the turn ends, it reads the answer for claims and checks each one against that log. A claim with nothing behind it gets a foul flag above the prompt and a notice in the transcript. The check is deterministic regex. There are no model calls.
/plugin marketplace add pourya7/claude-code-mods
/plugin install anti-cheat@claude-code-mods
| Claim in the answer | Backed by | Must come after |
|---|---|---|
tests pass / passing / green | a passing test run: pytest, jest, vitest, mocha, go test, cargo test, npm/pnpm/yarn/bun (run) test, just test, make test, rspec, phpunit, uv run pytest | the last edit |
lint / typecheck clean | a passing eslint, ruff, tsc, mypy, pyright, lint or typecheck | the last edit |
build passes | a passing build (npm run build, cargo build, go build, ...) | the last edit |
CI green / passing / all checks pass | a passing gh pr checks, or gh run view / gh run watch with --exit-status | the last git push |
verified / confirmed it works | any passing check above | the last edit |
the last test run failed (pytest -x).pytest | tail -20, npm test || true and pytest; echo done exit 0 when the tests fail. Those runs never pass: the last test run's exit status was hidden by a pipe or a later command (pytest | tail -20). set -o pipefail makes a pipe count, and set -e makes a ; count.the last test run is still in the background (npm test).gh run view, gh run watch, a check-runs API read or a statusCheckRollup query exits 0 whatever CI concluded. It only reads the status, so on its own it backs no CI claim. It also never cancels a real check that came before it.npm install -D jest and pip install pytest mypy are not checks.The referee band above the prompt shows up only after a foul. It clears when you press a button or when the next turn starts.
▀▀▀▀▀▀▄ FOUL! REFEREE REVIEW · 2 UNVERIFIED CLAIMS
▀▀▀▀▀▀▀ ⚑ FOUL: "All tests pass" — no test ran after the last edit
▀ ⚑ FOUL: "CI is green" — no CI check ran after the last push
▀▄▄ [ CHALLENGE ] [ OK ]
FOUL! is red and the header is lime.c) sends the model this prompt: anti-cheat: you said "All tests pass" but no test ran after the last edit. Run the check now and report the real result.o) dismisses the band.+N MORE.anti-cheat ⚑ FOUL: "All tests pass" — no test ran after the last edit. If the session refuses the notice, a toast shows the same line instead.| Command | What it does |
|---|---|
/anti-cheat | Shows the mode, the last edited file, every check run since that edit (✓ passed, ✗ failed, ? result unknown: hidden, read-only, backgrounded or interrupted) and the current fouls. |
userConfig)| Field | Values | Default | Effect |
|---|---|---|---|
mode | flag, challenge | flag | flag shows the band and logs the notice. challenge also sends the challenge prompt by itself, at most once for each prompt a person sends (typed, through Remote Control, or an SDK turn). Background-task notifications, schedules and other plugins do not re-arm it, so a challenged turn cannot start a chain of challenges. |
Change it in /config or under pluginConfigs["anti-cheat"].options.mode in ~/.claude/settings.json.
| Network | Runs processes | Files | Calls a model | Auto-submits prompts | Data leaving the machine |
|---|---|---|---|---|---|
| None | None | None read or written. It keeps the edit/run log (paths and command lines, at most 200 entries) in session state ($.state) only. | No | Only in mode: challenge, at most once for each prompt a person sends. In flag mode a prompt is sent only when you press CHALLENGE. | None. A challenge prompt goes to your own session's model like any prompt you type. |
claude plugin validate anti-cheat
claude plugin test anti-cheat
The helpers are pure and covered by unit tests:
hooks/classify.ts: commands to check kindshooks/claims.ts: answer text to claimshooks/evidence.ts: claims plus log to foulshooks/sprite.ts: the half-block rendererhooks/register.tsx connects them to tool.call, turn.complete, the AbovePrompt band and /anti-cheat. tests/register.test.ts covers every acceptance bullet through the engine's $ and mounts the band on both terminal and desktop.
hooks/register.tsx 183 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { AntiCheatEntry, AntiCheatFoul } from '../types'
5import { analyzeCommand } from './classify'
6import { detectClaims } from './claims'
7import { appendEntry, challengeText, foulLine, unverifiedClaims } from './evidence'
8import type { NewEntry } from './evidence'
9import { PICO8, spriteRows } from './sprite'
10
11const log = atom({ plugin: 'anti-cheat', key: 'log' } as const, [] as AntiCheatEntry[])
12const fouls = atom({ plugin: 'anti-cheat', key: 'fouls' } as const, [] as AntiCheatFoul[])
13const isChallenging = atom({ plugin: 'anti-cheat', key: 'isChallenging' } as const, false)
14
15const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
16
17/** Prompt origins that are a person's own message. */
18const USER_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
19
20/** Fouls shown at once; the rest collapse into one "+N MORE" line. */
21const SHOWN_FOULS = 3
22
23/** The referee's penalty flag: red and yellow cloth on a grey pole, lime turf. */
24const FLAG = ['grryyr..', 'grryyrr.', 'gyyrryy.', 'gyyrry..', 'g.......', 'g.......', 'g.......', 'lll.....']
25const FLAG_PALETTE = { g: PICO8.lightGrey, r: PICO8.red, y: PICO8.yellow, l: PICO8.lime }
26const FLAG_ROWS = spriteRows(FLAG, FLAG_PALETTE)
27
28export const register: Register = (on, options) => {
29 const mode = options.mode === 'challenge' ? 'challenge' : 'flag'
30
31 on('session.start', async ($, e, next) => {
32 const started = await next(e)
33 await $.command.register({ name: 'anti-cheat', description: 'Show what anti-cheat has seen run since the last edit' })
34 return started
35 })
36
37 on('tool.call', async ($, e, next) => {
38 const ran = await next(e)
39 const isMainLoop = e.agentId === undefined
40 const isDone = ran.deny === undefined && ran.isError !== true
41
42 // An edit from any agent, subagents included, makes earlier evidence stale.
43 if (EDIT_TOOLS.has(e.tool) && isDone) {
44 const input = e as { file_path?: unknown; notebook_path?: unknown }
45 const path = String(input.file_path ?? input.notebook_path ?? '')
46 await update($, log, entries => appendEntry(entries ?? [], { type: 'edit', path }))
47 }
48
49 // Only the main loop's own runs back the main loop's claims.
50 if (isMainLoop && e.tool === 'Bash' && ran.deny === undefined) {
51 const command = String((e as { command?: unknown }).command ?? '')
52 const { checks, status } = analyzeCommand(command)
53 if (checks.length > 0) {
54 const result = (ran.result ?? {}) as { interrupted?: boolean; backgroundTaskId?: string }
55 const runNote = result.backgroundTaskId !== undefined ? 'background' : result.interrupted === true ? 'interrupted' : undefined
56 // One entry per way the kinds report: those whose exit status is the
57 // result, then those hidden by a pipe or later command, then CI reads.
58 const groups = (['exit', 'masked', 'read'] as const)
59 .map(how => ({ how, kinds: checks.filter(check => status[check] === how) }))
60 .filter(group => group.kinds.length > 0)
61 const added: NewEntry[] = groups.map(({ how, kinds }) => {
62 const note = runNote ?? (how === 'exit' ? undefined : how)
63 return { type: 'run', checks: kinds, command, isOk: isDone && note === undefined, ...(note === undefined ? {} : { note }) }
64 })
65 await update($, log, entries => added.reduce<AntiCheatEntry[]>((all, entry) => appendEntry(all, entry), entries ?? []))
66 }
67 }
68
69 return ran
70 })
71
72 on('prompt.submit', async ($, e, next) => {
73 // Only a prompt a person sent re-arms the auto-challenge: never this mod's
74 // own, a background task's notification, a schedule or another plugin.
75 if (USER_ORIGINS.has(e.origin?.kind ?? '')) await update($, isChallenging, () => false)
76 return next(e)
77 })
78
79 on('turn.start', async ($, e, next) => {
80 await update($, fouls, () => [])
81 return next(e)
82 })
83
84 on('turn.complete', async ($, e, next) => {
85 const completed = await next(e)
86 if (e.agentId !== undefined || e.reason !== 'answer') return completed
87
88 const found = unverifiedClaims(detectClaims(e.answer), await read($, log))
89 if (found.length === 0) return completed
90
91 await update($, fouls, () => found)
92 // The notice keeps the foul in the transcript; where the session refuses
93 // a plugin's row (or a test kit has none), a toast carries the same line.
94 try {
95 const appended = await $.session.append({
96 message: { type: 'system', content: [{ type: 'text', text: found.map(foul => `anti-cheat ${foulLine(foul)}`).join('\n') }] },
97 })
98 if (appended.deny !== undefined) throw new Error(appended.deny)
99 } catch {
100 const more = found.length > 1 ? ` (+${found.length - 1} more)` : ''
101 $.ui.toast(`${foulLine(found[0]!)}${more}`)
102 }
103
104 if (mode === 'challenge' && !(await read($, isChallenging))) {
105 await update($, isChallenging, () => true)
106 $.prompt.submit({ text: challengeText(found) }).catch(() => $.ui.toast('could not send the challenge'))
107 }
108
109 return completed
110 })
111
112 on('command.run', { command: 'anti-cheat' }, async $ => {
113 const entries = await read($, log)
114 const current = await read($, fouls)
115 const lastEdit = [...entries].reverse().find(entry => entry.type === 'edit')
116 const runs = entries.filter(entry => entry.type === 'run' && entry.seq > (lastEdit?.seq ?? 0))
117 const lines = [
118 `ANTI-CHEAT · mode ${mode}`,
119 lastEdit?.type === 'edit' ? `last edit: ${lastEdit.path}` : 'no edits this session',
120 runs.length === 0
121 ? 'no checks ran since'
122 : `checks since: ${runs.map(entry => (entry.type === 'run' ? `${entry.isOk ? '✓' : entry.note === undefined ? '✗' : '?'} ${entry.command}` : '')).join(', ')}`,
123 ...current.map(foulLine),
124 ]
125 return { text: lines.join('\n') }
126 })
127
128 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
129 const current = await read($, fouls)
130 if (e.props.hasSurvey || current.length === 0) return next(e)
131
132 const { Box, Button, Text } = $.ui.resolve(e)
133 const hidden = current.length - SHOWN_FOULS
134
135 const challenge = async () => {
136 const shown = await read($, fouls)
137 await update($, fouls, () => [])
138 if (shown.length > 0) await $.prompt.submit({ text: challengeText(shown) })
139 }
140 const dismiss = () => update($, fouls, () => [])
141
142 return (
143 <Box key="anti-cheat" flexDirection="row">
144 <Box flexDirection="column" marginRight={1}>
145 {FLAG_ROWS.map((runs, row) => (
146 <Box key={`flag-${row}`} flexDirection="row">
147 {runs.map((run, index) => (
148 <Text key={`flag-${row}-${index}`} color={run.color} backgroundColor={run.backgroundColor}>
149 {run.text}
150 </Text>
151 ))}
152 </Box>
153 ))}
154 </Box>
155 <Box flexDirection="column">
156 <Box flexDirection="row">
157 <Text key="title" bold color={PICO8.red}>
158 FOUL!
159 </Text>
160 <Text key="subtitle" color={PICO8.lime}>
161 {' '}REFEREE REVIEW · {current.length} UNVERIFIED CLAIM{current.length === 1 ? '' : 'S'}
162 </Text>
163 </Box>
164 {current.slice(0, SHOWN_FOULS).map((foul, index) => (
165 <Text key={`foul-${index}`} color={PICO8.white} wrap="truncate-end">
166 {foulLine(foul)}
167 </Text>
168 ))}
169 {hidden > 0 ? (
170 <Text key="more" color={PICO8.lightGrey}>
171 +{hidden} MORE
172 </Text>
173 ) : null}
174 <Box flexDirection="row" gap={1}>
175 <Button key="challenge" label="CHALLENGE" hotkey="c" variant="primary" onPress={challenge} />
176 <Button key="ok" label="OK" hotkey="o" role="dismiss" onPress={dismiss} />
177 </Box>
178 </Box>
179 </Box>
180 )
181 })
182}
183hooks/classify.ts 115 lines1import type { AntiCheatCheck } from '../types'
2
3/**
4 * Which kind of evidence a Bash command is, by the tool it runs. A compound
5 * command (`npm run lint && npm test`) can be several kinds at once.
6 */
7const CHECK_PATTERNS: readonly [AntiCheatCheck, RegExp][] = [
8 [
9 'test',
10 /\b(pytest|jest|vitest|mocha|go test|cargo test|(npm|pnpm|yarn|bun)( run)? test|just test|make test|rspec|phpunit)\b/,
11 ],
12 ['lint', /\b(eslint|ruff|tsc|mypy|pyright|lint|typecheck|type-check)\b/],
13 [
14 'build',
15 /\b((npm|pnpm|yarn|bun)( run)? build|cargo build|go build|make build|gradle( \S+)* build|mvn( \S+)* (package|install)|docker build|vite build|next build)\b/,
16 ],
17 ['ci', /\b(gh pr checks|gh run (view|watch))\b|check-runs|statusCheckRollup/],
18 ['push', /\bgit( -C \S+)? push\b/],
19]
20
21/**
22 * CI commands whose exit status is the CI result: `gh pr checks`, and
23 * `gh run view/watch` with `--exit-status`. Every other CI query (a plain
24 * `gh run view`, a check-runs or statusCheckRollup read) exits 0 whatever
25 * the runs concluded, so it only reads the status.
26 */
27const CI_RESULT = /\bgh pr checks\b|\bgh run (view|watch)\b.*--exit-status\b/
28
29/** Text a command merely mentions (quoted strings, comments) is not what it runs. */
30const stripQuoted = (command: string) =>
31 command.replace(/'[^']*'|"[^"]*"/g, '""').replace(/(^|\s)#.*$/gm, '$1')
32
33/** Commands that only search or print text that names a tool. */
34const READS_ONLY = /^\s*(grep|rg|ag|echo|printf|cat|less|head|tail|which|type|man)\b/
35
36/** Commands that install or add a tool rather than run it (`npm install -D jest`). */
37const INSTALLS =
38 /^\s*(sudo\s+)?((npm|pnpm|yarn|bun)\s+(install|i|add|ci|remove|rm|uninstall|update|upgrade)\b|(pip3?|pipx|uv\s+pip|uv\s+tool|python3?\s+-m\s+pip)\s+install\b|uv\s+(add|remove|sync)\b|poetry\s+(add|install|remove)\b|cargo\s+(install|add)\b|go\s+(install|get)\b|(brew|gem|apt|apt-get|dnf|yum|apk)\s+(install|add)\b)/
39
40/** The command separators bash runs one after another, longest first. */
41const SEPARATOR = /(\|\||&&|\|&|\||;|\n)/
42
43/** `set -o pipefail` (or `set -euo pipefail`) makes a pipeline fail when any part fails. */
44const PIPEFAIL = /\bpipefail\b/
45/** `set -e` stops a list at the first failing command, so `;` hides nothing. */
46const ERREXIT = /\bset\s+-\w*e/
47
48/** How one check kind in a command reports its result. */
49export type CheckStatus =
50 /** The command's exit status is this check's result. */
51 | 'exit'
52 /** A pipe, `||` or a later `;` command decides the exit status instead. */
53 | 'masked'
54 /** The check only reads a status that its exit code does not reflect. */
55 | 'read'
56
57export type CommandAnalysis = { checks: AntiCheatCheck[]; status: Partial<Record<AntiCheatCheck, CheckStatus>> }
58
59/** Rank when one kind appears in several segments: any masked one taints the run. */
60const RANK: Record<CheckStatus, number> = { read: 0, exit: 1, masked: 2 }
61
62/**
63 * The check kinds a command runs and, for each, whether its exit status is
64 * the result. Bash reports the status of the last command in a pipeline or
65 * list, so `pytest | tail`, `npm test || true` and `pytest; echo done` all
66 * exit 0 when the tests fail.
67 */
68export const analyzeCommand = (command: string): CommandAnalysis => {
69 const text = stripQuoted(command)
70 const parts = text.split(SEPARATOR)
71 const hasPipefail = PIPEFAIL.test(text)
72 const hasErrexit = ERREXIT.test(text)
73 const status: Partial<Record<AntiCheatCheck, CheckStatus>> = {}
74
75 // parts alternates segment, separator, segment, ...
76 for (let index = 0; index < parts.length; index += 2) {
77 const segment = parts[index] ?? ''
78 if (READS_ONLY.test(segment) || INSTALLS.test(segment)) continue
79
80 const kinds = CHECK_PATTERNS.filter(([, pattern]) => pattern.test(segment)).map(([check]) => check)
81 if (kinds.length === 0) continue
82
83 // Pipes bind tighter than && and ||, which bind tighter than ; and newlines.
84 // A failing check is hidden by a pipe straight after it (without pipefail),
85 // or by a later || or ; whose command then runs and sets the status. A
86 // later `&& next | more` is skipped when the check fails, so it hides nothing.
87 let isMasked = false
88 for (let after = index + 1; after < parts.length; after += 2) {
89 const separator = parts[after]
90 const rest = parts[after + 1] ?? ''
91 if (rest.trim() === '') continue
92 const isPipe = separator === '|' || separator === '|&'
93 if (isPipe && after === index + 1 && !hasPipefail) {
94 isMasked = true
95 break
96 }
97 if (isPipe || separator === '&&') continue
98 if ((separator === ';' || separator === '\n') && hasErrexit) continue
99 isMasked = true
100 break
101 }
102
103 for (const kind of kinds) {
104 const own: CheckStatus = kind === 'ci' && !CI_RESULT.test(segment) ? 'read' : isMasked ? 'masked' : 'exit'
105 const seen = status[kind]
106 status[kind] = seen === undefined || RANK[own] > RANK[seen] ? own : seen
107 }
108 }
109
110 const checks = CHECK_PATTERNS.map(([check]) => check).filter(check => status[check] !== undefined)
111 return { checks, status }
112}
113
114export const classifyCommand = (command: string): AntiCheatCheck[] => analyzeCommand(command).checks
115hooks/claims.ts 90 lines1import type { AntiCheatClaim, AntiCheatClaimKind } from '../types'
2
3const PASSED = '(?:pass(?:es|ed|ing)?|green|succeed(?:s|ed)?|clean(?:ly)?|ok)'
4const LINKS = "(?:\\s+(?:all|now|still|fully|are|is|were|was))*"
5
6/**
7 * One pattern per claim kind, written so the match is the claim as quoted:
8 * a subject, a few linking words, then a word for success.
9 */
10const CLAIM_PATTERNS: readonly [AntiCheatClaimKind, RegExp][] = [
11 ['ci', new RegExp(`\\b(?:(?:the )?CI|(?:all )?(?<!type[- ])checks|(?:the )?pipeline)${LINKS}\\s+${PASSED}\\b`, 'i')],
12 [
13 'lint',
14 new RegExp(
15 `\\b(?:lint(?:ing|er)?|type ?checks?|type-checks?|typecheck(?:ing)?|types|tsc|eslint|ruff|mypy|pyright)${LINKS}\\s+${PASSED}\\b`,
16 'i',
17 ),
18 ],
19 ['build', new RegExp(`\\b(?:the )?builds?${LINKS}\\s+${PASSED}\\b`, 'i')],
20 [
21 'test',
22 new RegExp(
23 `\\b(?:(?:the|all)\\s+)?(?:\\d+\\s+)?(?:(?:unit|integration|e2e|new)\\s+)?(?:tests?|specs|test suite|suite)${LINKS}\\s+${PASSED}\\b`,
24 'i',
25 ),
26 ],
27 [
28 'verified',
29 /\b(?:verified|confirmed)\b(?:\s+(?:that|it|this|the fix|the change))*\s+(?:works?|is working|fixed|fixes it)\b/i,
30 ],
31]
32
33/**
34 * Words that make a claim conditional or still to come. A condition reaches
35 * across "and" ("once the build and the tests pass"), so these are looked
36 * for in the whole clause, back to the last punctuation mark.
37 */
38const CONDITION = /\b(?:unless|until|once|if|whether|make sure|ensure|assuming)\b/i
39
40/**
41 * Words that make a claim untrue or unsure. These are looked for only close
42 * to the claim (its own words and the few before it, back to the last "and",
43 * "but" or "so"), so "fixed the failing test and all tests pass" is a claim.
44 */
45const NEGATION =
46 /\b(?:not|no|never|none|nor|without|fail(?:s|ed|ing)?|yet|should|shall|will|would|could|might|may|must|expect(?:ed)?|hope|probably|likely)\b|n't\b/i
47
48/** How many words before a claim the negation check reads. */
49const NEAR_WORDS = 4
50
51/** The clause a claim sits in, from the last punctuation mark before it. */
52const clauseBefore = (sentence: string, index: number) => {
53 const start = Math.max(...[',', ';', ':', '—', '–', '('].map(mark => sentence.lastIndexOf(mark, index - 1)))
54 return sentence.slice(start + 1, index)
55}
56
57/** The few words right before a claim, stopping at a conjunction. */
58const nearBefore = (clause: string) => {
59 const words = clause.trim().split(/\s+/).filter(word => word.length > 0)
60 const conjunction = words.map(word => word.toLowerCase()).findLastIndex(word => ['and', 'but', 'so', 'then'].includes(word))
61 return words.slice(conjunction + 1).slice(-NEAR_WORDS).join(' ')
62}
63
64/** Splits an answer into sentences, dropping fenced code and questions. */
65const sentencesOf = (answer: string) =>
66 answer
67 .replace(/```[\s\S]*?```/g, '\n')
68 .split(/(?<=[.!?])\s+|\n+/)
69 .map(sentence => sentence.trim())
70 .filter(sentence => sentence.length > 0 && !sentence.endsWith('?'))
71
72/** The claims an answer makes, one per kind, in the order the kinds are listed. */
73export const detectClaims = (answer: string): AntiCheatClaim[] => {
74 const claims = new Map<AntiCheatClaimKind, AntiCheatClaim>()
75
76 for (const sentence of sentencesOf(answer)) {
77 for (const [kind, pattern] of CLAIM_PATTERNS) {
78 if (claims.has(kind)) continue
79 const match = pattern.exec(sentence)
80 if (!match) continue
81 const clause = clauseBefore(sentence, match.index)
82 if (CONDITION.test(`${clause} ${match[0]}`)) continue
83 if (NEGATION.test(`${nearBefore(clause)} ${match[0]}`)) continue
84 claims.set(kind, { kind, quote: match[0].replace(/\s+/g, ' ').trim() })
85 }
86 }
87
88 return [...claims.values()]
89}
90hooks/evidence.ts 86 lines1import type { AntiCheatCheck, AntiCheatClaim, AntiCheatClaimKind, AntiCheatEntry, AntiCheatFoul, AntiCheatRunNote } from '../types'
2
3/** How many entries the session log keeps; older ones fall off the front. */
4export const LOG_LIMIT = 200
5
6export type NewEntry =
7 | { type: 'edit'; path: string }
8 | { type: 'run'; checks: AntiCheatCheck[]; command: string; isOk: boolean; note?: AntiCheatRunNote }
9
10/** Adds an entry with the next sequence number, keeping the newest LOG_LIMIT. */
11export const appendEntry = (log: readonly AntiCheatEntry[], entry: NewEntry): AntiCheatEntry[] => {
12 const seq = (log[log.length - 1]?.seq ?? 0) + 1
13 return [...log, { ...entry, seq } as AntiCheatEntry].slice(-LOG_LIMIT)
14}
15
16/** Which runs back a claim, and what the reasons call them. */
17const EVIDENCE: Record<AntiCheatClaimKind, { checks: AntiCheatCheck[]; noun: string; ranNoun: string }> = {
18 test: { checks: ['test'], noun: 'test', ranNoun: 'no test' },
19 lint: { checks: ['lint'], noun: 'lint or typecheck', ranNoun: 'no lint or typecheck' },
20 build: { checks: ['build'], noun: 'build', ranNoun: 'no build' },
21 ci: { checks: ['ci'], noun: 'CI check', ranNoun: 'no CI check' },
22 verified: { checks: ['test', 'lint', 'build', 'ci'], noun: 'check', ranNoun: 'no check' },
23}
24
25const lastSeq = (log: readonly AntiCheatEntry[], isMarker: (entry: AntiCheatEntry) => boolean) =>
26 [...log].reverse().find(isMarker)?.seq
27
28/** A push counts as made when it passed, or when a pipe hid whether it did. */
29const isPush = (entry: AntiCheatEntry) =>
30 entry.type === 'run' && entry.checks.includes('push') && (entry.isOk || entry.note === 'masked')
31
32const shorten = (text: string, limit: number) => (text.length > limit ? `${text.slice(0, limit - 3)}...` : text)
33
34/** Why the last run of a kind does not back a claim. */
35const whyNot = (note: AntiCheatRunNote | undefined, noun: string, command: string) => {
36 const quoted = `(${shorten(command, 40)})`
37 switch (note) {
38 case 'masked':
39 return `the last ${noun} run's exit status was hidden by a pipe or a later command ${quoted}`
40 case 'background':
41 return `the last ${noun} run is still in the background ${quoted}`
42 case 'interrupted':
43 return `the last ${noun} run was interrupted ${quoted}`
44 case 'read':
45 return `the CI status was only read ${quoted}, which exits 0 even when CI fails`
46 default:
47 return `the last ${noun} run failed ${quoted}`
48 }
49}
50
51/**
52 * The claims nothing in the log backs up. A claim needs a passing run of its
53 * kind after the last edit (for CI, after the last push), the last such run
54 * being the one that counts; with no edit (or push) at all, any run counts.
55 * A CI query that only reads a status never outweighs a real check, but on
56 * its own it backs nothing.
57 */
58export const unverifiedClaims = (claims: readonly AntiCheatClaim[], log: readonly AntiCheatEntry[]): AntiCheatFoul[] =>
59 claims.flatMap(claim => {
60 const { checks, noun, ranNoun } = EVIDENCE[claim.kind]
61 const isCi = claim.kind === 'ci'
62 const since = isCi ? lastSeq(log, isPush) : lastSeq(log, entry => entry.type === 'edit')
63 const runs = log.filter(
64 (entry): entry is AntiCheatEntry & { type: 'run' } =>
65 entry.type === 'run' && entry.seq > (since ?? 0) && entry.checks.some(check => checks.includes(check)),
66 )
67 const checked = runs.filter(entry => entry.note !== 'read')
68 const last = checked[checked.length - 1] ?? runs[runs.length - 1]
69
70 if (last === undefined) {
71 const when = since === undefined ? 'this session' : isCi ? 'after the last push' : 'after the last edit'
72 return [{ ...claim, reason: `${ranNoun} ran ${when}` }]
73 }
74 if (!last.isOk || last.note !== undefined) {
75 return [{ ...claim, reason: whyNot(last.note, noun, last.command) }]
76 }
77 return []
78 })
79
80/** The band's and the transcript notice's line for one foul. */
81export const foulLine = (foul: AntiCheatFoul) => `⚑ FOUL: "${foul.quote}" — ${foul.reason}`
82
83/** The prompt Challenge submits: every foul, then the ask. */
84export const challengeText = (fouls: readonly AntiCheatFoul[]) =>
85 `anti-cheat: ${fouls.map(foul => `you said "${foul.quote}" but ${foul.reason}`).join('; ')}. Run the check now and report the real result.`
86hooks/sprite.ts 61 lines1/** The PICO-8 palette: the only colours these mods draw with. */
2export const PICO8 = {
3 black: '#000000',
4 navy: '#1D2B53',
5 plum: '#7E2553',
6 green: '#008751',
7 brown: '#AB5236',
8 darkGrey: '#5F574F',
9 lightGrey: '#C2C3C7',
10 white: '#FFF1E8',
11 red: '#FF004D',
12 orange: '#FFA300',
13 yellow: '#FFEC27',
14 lime: '#00E436',
15 blue: '#29ADFF',
16 lavender: '#83769C',
17 pink: '#FF77A8',
18 peach: '#FFCCAA',
19} as const
20
21/** One run of same-styled cells: the props of one Text. */
22export type SpriteRun = { text: string; color?: string; backgroundColor?: string }
23
24/**
25 * Turns a pixel grid (one character per pixel, `.` transparent) into text
26 * rows of half blocks: each row holds two pixel rows, the top one drawn as
27 * the glyph's colour and the bottom one as its background.
28 */
29export const spriteRows = (grid: readonly string[], palette: Readonly<Record<string, string>>): SpriteRun[][] => {
30 const rows: SpriteRun[][] = []
31
32 for (let y = 0; y < grid.length; y += 2) {
33 const top = grid[y] ?? ''
34 const bottom = grid[y + 1] ?? ''
35 const runs: SpriteRun[] = []
36
37 for (let x = 0; x < Math.max(top.length, bottom.length); x += 1) {
38 const upper = palette[top[x] ?? '.']
39 const lower = palette[bottom[x] ?? '.']
40 const cell: SpriteRun =
41 upper && lower
42 ? { text: '▀', color: upper, backgroundColor: lower }
43 : upper
44 ? { text: '▀', color: upper }
45 : lower
46 ? { text: '▄', color: lower }
47 : { text: ' ' }
48 const previous = runs[runs.length - 1]
49
50 if (previous && previous.color === cell.color && previous.backgroundColor === cell.backgroundColor) {
51 previous.text += cell.text
52 } else {
53 runs.push(cell)
54 }
55 }
56 rows.push(runs)
57 }
58
59 return rows
60}
61types/index.d.ts 35 lines1/** What a check command proved: the kinds of evidence a Bash run counts as. */
2export type AntiCheatCheck = 'test' | 'lint' | 'build' | 'ci' | 'push'
3
4/** What an answer can claim. */
5export type AntiCheatClaimKind = 'test' | 'lint' | 'build' | 'ci' | 'verified'
6
7/**
8 * Why a run that is not a pass is not a fail either: its exit status was
9 * hidden by a pipe, `||` or a later command; it only read a CI status; it
10 * moved to the background; or it was interrupted.
11 */
12export type AntiCheatRunNote = 'masked' | 'read' | 'background' | 'interrupted'
13
14/** One row of the session's ordered evidence log. */
15export type AntiCheatEntry =
16 | { seq: number; type: 'edit'; path: string }
17 | { seq: number; type: 'run'; checks: AntiCheatCheck[]; command: string; isOk: boolean; note?: AntiCheatRunNote }
18
19/** One claim found in an answer, as quoted. */
20export type AntiCheatClaim = { kind: AntiCheatClaimKind; quote: string }
21
22/** A claim that nothing in the log backs up, and why. */
23export type AntiCheatFoul = AntiCheatClaim & { reason: string }
24
25declare module 'claude-code' {
26 interface PluginState {
27 'anti-cheat': {
28 log: AntiCheatEntry[]
29 fouls: AntiCheatFoul[]
30 /** True while the turn running was started by this mod's own auto-challenge. */
31 isChallenging: boolean
32 }
33 }
34}
35