Stop "You're absolutely right!": a tone rule in the system prompt, praise openers stripped from what the model reads back, and /tics to count the ones that…

A Claude Code mod against "You're absolutely right!": it tells the model to stop praising and agreeing, removes the praise opener from what the model reads back, and counts the tics that still get through.
> /tics
absolutely-not: Tics by phrase:
phrase session all time
You're absolutely right 1 9
Perfect 2 6
load-bearing 0 3
total 3 18
Rule: on. The system prompt carries this section:
# Tone
- Do not open with praise or agreement ("You're absolutely right", "Great question", ...). Start with the answer.
- When the user is wrong, say so plainly and explain why. Agree only when they are right, and then just continue.
- Do not end with filler offers or check-ins ("Let me know if...", "Hope this helps").
- Never use these words: load-bearing, delve, seamless, robust, leverage, comprehensive.
The opener is a running joke about Claude Code, and people read it as a sign that the model agrees instead of checking:
The tic costs more than a sentence. An agent that opens with "You're absolutely right!" has agreed before it checked. Every opener it writes also stays in the conversation, so the model reads it back on every later request, as an example of how it talks.
| Part | What |
|---|---|
| Tone rule | A short section in the system prompt: no praise or agreement openers, say so when the user is wrong and why, no filler closers, none of the banned words |
| Strip | Removes a leading praise opener from each response before it is stored, so the model never reads its own tic back |
| Count | Counts openers and banned words in every finished turn, per session and all time |
/tics | Prints the counts by phrase (session and all time) and the rule text the system prompt carries |
| Status line (optional) | tics: 3 for this session |
You need Claude Code with mods (function hooks); tested on 2.1.285 and 2.1.291. Mods are in early access: if the CLI says hooks modules are not turned on, start it as CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude.
From the marketplace in this repo:
/plugin marketplace add Jvrd97/claude-absolutely-not
/plugin install absolutely-not@absolutely-not
Or straight from disk, for one session:
git clone https://github.com/Jvrd97/claude-absolutely-not.git
claude --plugin-dir ./claude-absolutely-not
Change them in /config under the plugin, or in settings.json under pluginConfigs:
| Field | Default | Meaning |
|---|---|---|
rule | true | Add the tone section to the system prompt |
strip | true | Remove a leading praise opener from each response before it is stored |
bannedWords | load-bearing, delve, seamless, robust, leverage, comprehensive | Words the rule forbids and /tics counts, comma-separated; empty turns the list off |
extraOpeners | empty | Openers to strip and count on top of the built-in ones, comma-separated |
status | false | Show tics: N for this session in the status line |
The built-in openers are "You're absolutely right", "You are absolutely right", "Great question", "Excellent point", "Good catch", "Perfect" and "Absolutely". Matching ignores case and accepts a straight or curly apostrophe.
prompt.section for the environment section (env_info_simple, or env_info_static in SDK sessions). Claude Code 2.1.285 has no event that lets a plugin add a section of its own, so the rule is appended after that section's text as its own # Tone block. Anthropic's text is not changed. The section is cached for the session and sits after the prompt cache boundary, so the rule does not break the shared cache. A --bare session has no environment section and gets no rule.session.append for rows that come in by the response door. The mod looks at the first text block of the row and removes the opener sentences at its start: the phrase, then ! or ., or nothing more on that line. It never touches thinking, tool_use or later text blocks.turn.complete the mod also scans e.answer and takes the larger count per phrase, so a tic is counted once whether the answer arrives stripped or not. Openers count where a sentence starts. Banned words count anywhere in prose, with common forms ("delves", "leveraging"). Fenced code and inline code are skipped. Subagent turns are not counted.$.state. All-time counts are one key (counts) in the plugin's own $.store, a JSON file Claude Code keeps for the plugin under your Claude configuration directory. The mod sends nothing anywhere and writes no other files.What it can't do:
claude -p output (2.1.285). Claude Code's API notes say a surface, an SDK stream or Remote Control may show a row just before its rewrite, so expect to see the opener there sometimes. The transcript file and the model only ever get the stripped text./tics shows how often it still happens.bannedWords if it is for you.Perfect!) are neither stripped nor counted.Inside Claude Code run /plugin-types .claude/types once: it writes the API types tsc reads. Then:
tsc -p .
claude plugin validate .claude-plugin/plugin.json
claude plugin test .
Мод борется с угодливостью Claude: «You're absolutely right!», «Great question!», «load-bearing».
/tics показывает, сколько тиков было в этой сессии и за всё время.MIT
hooks/register.ts 123 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { TicCounts } from '../types'
5import {
6 DEFAULT_BANNED_WORDS,
7 addCounts,
8 countTics,
9 countsFrom,
10 maxCounts,
11 openersOf,
12 parseList,
13 report,
14 ruleText,
15 statusText,
16 stripFirstTextBlock,
17 textOf,
18 withRule,
19} from './logic'
20import type { Tics } from './logic'
21
22const COMMAND = 'tics'
23/**
24 * The environment section: the engine composes it for every full system prompt,
25 * after the cache boundary. A plugin cannot add a section of its own on 2.1.285
26 * (no `prompt.compose` there), so the rule rides after this one's text.
27 */
28const HOST_SECTIONS = ['env_info_simple', 'env_info_static']
29const STORE_KEY = 'counts'
30const NO_COUNTS: TicCounts = {}
31
32const sessionCounts = atom({ plugin: 'absolutely-not', key: 'session' } as const, NO_COUNTS)
33const pending = atom({ plugin: 'absolutely-not', key: 'pending' } as const, NO_COUNTS)
34
35type Settings = { tics: Tics; rule: string | null; strips: boolean; showsStatus: boolean }
36
37const settingsOf = (options: Record<string, unknown>): Settings => {
38 const openers = openersOf(String(options.extraOpeners ?? ''))
39 const bannedWords = parseList(String(options.bannedWords ?? DEFAULT_BANNED_WORDS))
40
41 return {
42 tics: { openers, bannedWords },
43 rule: options.rule === false ? null : ruleText(openers, bannedWords),
44 strips: options.strip !== false,
45 showsStatus: options.status === true,
46 }
47}
48
49async function noteSeen($: EngineInterface, counts: TicCounts): Promise<void> {
50 if (Object.keys(counts).length > 0) {
51 await update($, pending, seen => addCounts(seen, counts))
52 }
53}
54
55/** Folds a finished turn's tics into the session's counts and the all-time store. */
56async function commitTurn($: EngineInterface, answer: string, settings: Settings): Promise<void> {
57 const seen = await read($, pending)
58 const counts = maxCounts(seen, countTics(answer, settings.tics))
59 await update($, pending, () => NO_COUNTS)
60
61 if (Object.keys(counts).length > 0) {
62 await update($, sessionCounts, session => addCounts(session, counts))
63 const allTime = countsFrom(await $.store.get(STORE_KEY))
64 await $.store.set(STORE_KEY, addCounts(allTime, counts))
65 }
66
67 if (settings.showsStatus) {
68 $.ui.status(statusText(await read($, sessionCounts)))
69 }
70}
71
72export const register: Register = (on, options) => {
73 const settings = settingsOf(options)
74
75 on('session.start', async ($, e, next) => {
76 await $.command.register({ name: COMMAND, description: 'Count sycophantic openers and banned words, this session and all time' })
77
78 if (settings.showsStatus) {
79 $.ui.status(statusText(await read($, sessionCounts)))
80 }
81
82 return next(e)
83 })
84
85 on('prompt.section', { name: HOST_SECTIONS }, async ($, e, next) => {
86 const section = await next(e)
87
88 return settings.rule === null ? section : { ...section, text: withRule(section.text, settings.rule) }
89 })
90
91 on('session.append', { door: 'response' }, async ($, e, next) => {
92 if (e.message.type !== 'assistant') {
93 return next(e)
94 }
95
96 if (e.agentId === undefined) {
97 await noteSeen($, countTics(textOf(e.message.content), settings.tics))
98 }
99
100 if (!settings.strips) {
101 return next(e)
102 }
103
104 const result = stripFirstTextBlock(e.message.content, settings.tics.openers)
105
106 return result.stripped.length === 0 ? next(e) : next({ ...e, message: { ...e.message, content: result.content } })
107 })
108
109 on('turn.complete', async ($, e, next) => {
110 if (e.agentId === undefined) {
111 await commitTurn($, e.answer, settings)
112 }
113
114 return next(e)
115 })
116
117 on('command.run', { command: COMMAND }, async $ => {
118 const allTime = countsFrom(await $.store.get(STORE_KEY))
119
120 return { text: report(await read($, sessionCounts), allTime, settings.rule) }
121 })
122}
123hooks/logic.ts 284 lines1import type { TicCounts } from '../types'
2
3/** Praise and agreement openers stripped and counted with no configuration. */
4export const DEFAULT_OPENERS: readonly string[] = [
5 "You're absolutely right",
6 'You are absolutely right',
7 'Great question',
8 'Excellent point',
9 'Good catch',
10 'Perfect',
11 'Absolutely',
12]
13
14/** Words counted (and named in the rule) with no configuration. */
15export const DEFAULT_BANNED_WORDS = 'load-bearing, delve, seamless, robust, leverage, comprehensive'
16
17/** "Great question! You're absolutely right!" is two openers; more in a row than this is no longer an opener. */
18const MAX_LEADING_OPENERS = 3
19/** A word this short that ends in "e" keeps its "e" in every form ("use" is not stemmed to "us"). */
20const MIN_STEM_LENGTH = 4
21const NAME_COLUMN_MIN = 12
22const NUMBER_COLUMN = 8
23
24const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
25
26/** One phrase as a pattern: any run of spaces between words, a straight or curly apostrophe. */
27const phraseSource = (phrase: string): string =>
28 phrase
29 .trim()
30 .split(/\s+/)
31 .map(word => escapeRegExp(word).replace(/'/g, "['’]"))
32 .join('\\s+')
33
34/** Comma-separated settings text as a list: trimmed, empties dropped, duplicates (any case) dropped. */
35export const parseList = (text: string): string[] => {
36 const seen = new Set<string>()
37 const list: string[] = []
38
39 for (const part of text.split(',')) {
40 const item = part.trim()
41 const key = item.toLowerCase()
42
43 if (item === '' || seen.has(key)) {
44 continue
45 }
46
47 seen.add(key)
48 list.push(item)
49 }
50
51 return list
52}
53
54/** The default openers plus the ones the `extraOpeners` setting adds. */
55export const openersOf = (extra: string): string[] => parseList([...DEFAULT_OPENERS, extra].join(','))
56
57/**
58 * A leading opener: the phrase, then "!" or "." and whitespace, or nothing more
59 * on its line. A comma or a following word ("You're right that X fails because…",
60 * "Absolutely not") means the phrase starts a sentence with content, so it is kept.
61 */
62const leadingOpener = (phrase: string): RegExp =>
63 new RegExp(`^\\s*${phraseSource(phrase)}(?:[!.]+(?=\\s|$)|[ \\t]*(?=\\n|$))\\s*`, 'i')
64
65export type Strip = { text: string; stripped: string[] }
66
67/**
68 * Removes the opener sentences at the start of a text, and nothing after them.
69 * A text that is only an opener ("Absolutely!" as a whole answer) is the answer,
70 * so it is left as it is.
71 */
72export const stripOpeners = (text: string, openers: readonly string[]): Strip => {
73 const patterns = openers.map(phrase => ({ phrase, pattern: leadingOpener(phrase) }))
74 const stripped: string[] = []
75 let rest = text
76
77 for (let round = 0; round < MAX_LEADING_OPENERS; round++) {
78 const hit = patterns.map(({ phrase, pattern }) => ({ phrase, match: pattern.exec(rest) })).find(({ match }) => match !== null)
79
80 if (hit === undefined || hit.match === null) {
81 break
82 }
83
84 stripped.push(hit.phrase)
85 rest = rest.slice(hit.match[0].length)
86 }
87
88 if (stripped.length === 0 || rest.trim() === '') {
89 return { text, stripped: [] }
90 }
91
92 return { text: rest, stripped }
93}
94
95/** A content block as the engine hands it: a kind and that kind's fields. */
96export type Block = { type: string; [field: string]: unknown }
97
98export type BlocksStrip = { content: Block[]; stripped: string[] }
99
100/**
101 * Strips the openers from the first text block of a response row. Every other
102 * block (thinking, tool_use, later text) is returned as the same object.
103 */
104export const stripFirstTextBlock = (content: readonly Block[], openers: readonly string[]): BlocksStrip => {
105 const index = content.findIndex(block => block.type === 'text')
106 const block = content[index]
107
108 if (block === undefined || typeof block.text !== 'string') {
109 return { content: [...content], stripped: [] }
110 }
111
112 const result = stripOpeners(block.text, openers)
113
114 if (result.stripped.length === 0) {
115 return { content: [...content], stripped: [] }
116 }
117
118 const next = [...content]
119 next[index] = { ...block, text: result.text }
120
121 return { content: next, stripped: result.stripped }
122}
123
124/** The text blocks of a row, joined; the rest (thinking, tool_use) is not prose the person reads. */
125export const textOf = (content: readonly Block[]): string =>
126 content
127 .filter(block => block.type === 'text' && typeof block.text === 'string')
128 .map(block => String(block.text))
129 .join('\n')
130
131/** Prose without code: fenced blocks and inline code spans become line breaks. */
132export const proseOf = (text: string): string => text.replace(/```[\s\S]*?(?:```|$)/g, '\n').replace(/`[^`\n]*`/g, ' ')
133
134/** A banned word and its common forms: "delve" also finds "delves", "delved", "delving". */
135const wordSource = (word: string): string => {
136 const base = phraseSource(word)
137
138 if (word.length >= MIN_STEM_LENGTH && /e$/i.test(word)) {
139 return `${base.slice(0, -1)}(?:e|es|ed|ing)`
140 }
141
142 return `${base}(?:s|es|ed|ing)?`
143}
144
145const countMatches = (text: string, pattern: RegExp): number => text.match(pattern)?.length ?? 0
146
147/** An opener where a sentence starts, followed by "!", "." or "," or the end of its line. */
148const openerAnywhere = (phrase: string): RegExp =>
149 new RegExp(`(?:^|[.!?]\\s+|\\n)\\s*${phraseSource(phrase)}(?=[!.,]|[ \\t]*$)`, 'gim')
150
151const bannedWord = (word: string): RegExp => new RegExp(`(?<![\\w-])${wordSource(word)}(?![\\w-])`, 'gi')
152
153export type Tics = { openers: readonly string[]; bannedWords: readonly string[] }
154
155/** Every opener and banned word in a text's prose, by phrase; phrases not found are left out. */
156export const countTics = (text: string, tics: Tics): TicCounts => {
157 const prose = proseOf(text)
158 const counts: TicCounts = {}
159 const add = (phrase: string, pattern: RegExp): void => {
160 const count = countMatches(prose, pattern)
161
162 if (count > 0) {
163 counts[phrase] = (counts[phrase] ?? 0) + count
164 }
165 }
166
167 for (const phrase of tics.openers) {
168 add(phrase, openerAnywhere(phrase))
169 }
170
171 for (const word of tics.bannedWords) {
172 add(word, bannedWord(word))
173 }
174
175 return counts
176}
177
178export const addCounts = (a: TicCounts, b: TicCounts): TicCounts => {
179 const sum: TicCounts = { ...a }
180
181 for (const [phrase, count] of Object.entries(b)) {
182 sum[phrase] = (sum[phrase] ?? 0) + count
183 }
184
185 return sum
186}
187
188/**
189 * Per phrase, the larger of two counts of the same text seen twice: the response
190 * rows as they arrived, and the turn's answer. Whichever saw more saw the tic.
191 */
192export const maxCounts = (a: TicCounts, b: TicCounts): TicCounts => {
193 const most: TicCounts = { ...a }
194
195 for (const [phrase, count] of Object.entries(b)) {
196 most[phrase] = Math.max(most[phrase] ?? 0, count)
197 }
198
199 return most
200}
201
202export const totalOf = (counts: TicCounts): number => Object.values(counts).reduce((sum, count) => sum + count, 0)
203
204/** A store value read back as counts; anything else (unset, hand-edited) counts as none. */
205export const countsFrom = (value: unknown): TicCounts => {
206 if (value === null || typeof value !== 'object' || Array.isArray(value)) {
207 return {}
208 }
209
210 const counts: TicCounts = {}
211
212 for (const [phrase, count] of Object.entries(value)) {
213 if (typeof count === 'number' && Number.isFinite(count) && count > 0) {
214 counts[phrase] = Math.floor(count)
215 }
216 }
217
218 return counts
219}
220
221const quoted = (items: readonly string[]): string => items.map(item => `"${item}"`).join(', ')
222
223/** The section the system prompt carries while the `rule` setting is on. */
224export const ruleText = (openers: readonly string[], bannedWords: readonly string[]): string => {
225 const lines = [
226 '# Tone',
227 '',
228 `- Do not open with praise or agreement (${quoted(openers)}). Start with the answer.`,
229 '- When the user is wrong, say so plainly and explain why. Agree only when they are right, and then just continue.',
230 '- Do not end with filler offers or check-ins ("Let me know if...", "Hope this helps").',
231 ]
232
233 if (bannedWords.length > 0) {
234 lines.push(`- Never use these words: ${bannedWords.join(', ')}.`)
235 }
236
237 return lines.join('\n')
238}
239
240/** The host section's text with the rule after it, once, whatever a hook above already did. */
241export const withRule = (text: string | null, rule: string): string => {
242 if (text !== null && text.includes(rule)) {
243 return text
244 }
245
246 return text === null || text.trim() === '' ? rule : `${text}\n\n${rule}`
247}
248
249/** What `/tics` prints: counts by phrase for this session and all time, then the rule. */
250export const report = (session: TicCounts, allTime: TicCounts, rule: string | null): string => {
251 const phrases = Object.keys({ ...allTime, ...session }).sort(
252 (a, b) => (allTime[b] ?? 0) - (allTime[a] ?? 0) || (session[b] ?? 0) - (session[a] ?? 0) || a.localeCompare(b),
253 )
254 const lines: string[] = []
255
256 if (phrases.length === 0) {
257 lines.push('No tics counted yet.')
258 } else {
259 const width = Math.max(NAME_COLUMN_MIN, ...phrases.map(phrase => phrase.length))
260 const row = (name: string, a: string, b: string): string =>
261 `${name.padEnd(width)} ${a.padStart(NUMBER_COLUMN)} ${b.padStart(NUMBER_COLUMN)}`
262
263 lines.push('Tics by phrase:', '', row('phrase', 'session', 'all time'))
264
265 for (const phrase of phrases) {
266 lines.push(row(phrase, String(session[phrase] ?? 0), String(allTime[phrase] ?? 0)))
267 }
268
269 lines.push(row('total', String(totalOf(session)), String(totalOf(allTime))))
270 }
271
272 lines.push('')
273
274 if (rule === null) {
275 lines.push('Rule: off (the system prompt is unchanged).')
276 } else {
277 lines.push('Rule: on. The system prompt carries this section:', '', rule)
278 }
279
280 return lines.join('\n')
281}
282
283export const statusText = (session: TicCounts): string => `tics: ${totalOf(session)}`
284types/index.d.ts 14 lines1/** Tic counts by phrase, as `/tics` prints them: the phrase as configured, its count. */
2export type TicCounts = Record<string, number>
3
4declare module 'claude-code' {
5 interface PluginState {
6 'absolutely-not': {
7 /** Tics counted in this session's finished turns. */
8 session: TicCounts
9 /** Tics seen in the running turn's response rows, before any strip; folded in at turn.complete. */
10 pending: TicCounts
11 }
12 }
13}
14