Guard-rail reminders for Claude: when the same tool fails twice in a row for the same reason it is asked to find the cause first; when it says done after…

English | 繁體中文
Small mods that keep Claude Code from losing your work, your secrets and your tokens.
A trim tab is the small flap on a rudder that steers the whole ship. These mods do the same for Claude Code: each one is a few hooks that sit quietly in the session and step in only at the moment something would go wrong. Install the ones you want; none of them depends on another.
rm commands show what they would destroy before they run, and Claude is asked to verify before it calls edited code done.session-keeper and the prompt cache stays warm while you step away, and a large context is handed off to a fresh session instead of dragging on./clear, and with project-memory on, what you learn in one session is waiting in the next.| Mod | What it does for you |
|---|---|
secret-scrub | Masks API keys, tokens and private keys in your prompt before it is sent, so they stay out of the transcript and the prompt cache. |
tool-guard | Holds git reset --hard, force pushes, rm -rf and migrations, lists exactly which files or commits they would lose (or which migrations would run), and lets you decide. Harmless runs pass straight through. |
output-trimmer | When a Bash command prints thousands of lines, the model reads only the head, the tail and the errors; the full output is saved to a file. |
subagent-dispatch | Brings interrupted background agents back with one key, and shows where your tokens went: main session, subagents, models, every spawn. |
open-items | Keeps undecided questions and deferred items outside the context, counts them above the prompt box, and quotes one back in a key press. |
session-keeper | Shows the prompt cache state on desktop, and, once turned on, keeps a 1h cache warm while you are away and offers a handoff to a fresh session when the context gets expensive. |
self-check | Asks Claude to find the cause when the same tool fails twice the same way, and to verify before it says "done" after editing code. |
project-memory | Once turned on, distills each conversation in the background into Claude Code's own memory and a progress file; rules confirmed across sessions are proposed for CLAUDE.md. |
Requires Claude Code 2.1.296 or later, in the CLI or desktop app (cloud sessions cannot load mods yet).
claude plugin install all --marketplace wen-hsiu-hsu/cc-mod-trimtab
This installs every mod, secret-scrub first so that it sees every prompt before other mods do. To install only some of them, or to drop one later, see Install and update.
The mods work right after install, with safe defaults. Features that call a model (in session-keeper and project-memory) stay off until you run /<mod> on or turn them on in /config. Run /<mod> status to see what a mod is set to.
MIT. Some code is adapted from cablate/ctx-handoff-mod (MIT). See NOTICE.
hooks/register.ts 161 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { configCommand, LANGUAGE, loadConfig, pick, resolve, type ConfigIO, type Resolved, type Specs } from './config'
4import { doneCheck, freshWork, noteCall, trackFailure } from './loops'
5import type { Streak, Work } from './loops'
6
7const MOD = 'self-check'
8
9export const SPECS: Specs = {
10 retry_nudge: {
11 category: 'plain',
12 type: 'boolean',
13 default: true,
14 repo: 'any',
15 description: {
16 en: 'When the same tool fails twice in a row for the same reason, ask Claude to find the cause first',
17 'zh-TW': '同一個工具連續兩次因同樣原因失敗時,請 Claude 先找出原因',
18 },
19 },
20 done_check: {
21 category: 'plain',
22 type: 'boolean',
23 default: true,
24 repo: 'any',
25 description: {
26 en: 'When Claude says it is done after editing code with no check run, block the stop once and ask it to verify',
27 'zh-TW': '改過程式檔、沒跑檢查就說完成時,擋下停止一次請 Claude 驗證',
28 },
29 },
30 language: LANGUAGE,
31}
32
33// --- configIO: the same block in every mod (scripts/check-copies.sh) ---
34function configIO($: EngineInterface): ConfigIO {
35 return {
36 read: async path => {
37 try {
38 return await $.fs.read(path)
39 } catch {
40 return ''
41 }
42 },
43 write: (path, text) => $.fs.write(path, text),
44 isDir: async path => (await $.fs.stat(path).catch(() => undefined))?.kind === 'dir',
45 configDir: async () => (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`,
46 root: () => $.session.root(),
47 repoRoot: async () => (await $.session.repo())?.root,
48 sessionId: () => $.session.id(),
49 now: () => $.clock.now(),
50 storeGet: key => $.store.get(key),
51 storeSet: (key, value) => $.store.set(key, value),
52 }
53}
54// --- end configIO ---
55
56// Both nudges speak to Claude, never block the user, and are skipped when they fail.
57// Their records live in the module: a reload loses at most one nudge.
58export const register: Register = (on, options) => {
59 // Re-read at session start and after `/self-check set`; `/config` changes reload the module.
60 let config: Resolved = resolve(SPECS, { options, user: {}, repo: {}, local: {}, session: {} })
61 const isRetryNudgeOn = () => config.values.retry_nudge === true
62 const isDoneCheckOn = () => config.values.done_check === true
63 const streaks = new Map<string, Streak>()
64 let work: Work = freshWork()
65
66 on('session.start', async ($, e, next) => {
67 try {
68 config = await loadConfig(configIO($), MOD, SPECS, options)
69 } catch (error) {
70 $.ui.log(`session.start: settings not read: ${String(error)}`, { to: 'debug' })
71 }
72 const lang = config.lang
73 try {
74 await $.command.register({
75 name: MOD,
76 description: pick(lang, { en: 'View or change self-check settings', 'zh-TW': '看或改 self-check 的設定' }),
77 argumentHint: 'status | set <key> <value> | unset <key>',
78 })
79 } catch (error) {
80 $.ui.log(`session.start: /${MOD} not registered: ${String(error)}`, { to: 'debug' })
81 $.ui.toast(pick(lang, { en: `${MOD}: /${MOD} was not registered (${String(error)})`, 'zh-TW': `${MOD}:/${MOD} 指令沒有註冊成功(${String(error)})` }))
82 }
83
84 return next(e)
85 })
86
87 on('command.run', { command: MOD }, async ($, e) => {
88 const text = await configCommand(configIO($), MOD, SPECS, options, e.args, resolved => {
89 config = resolved
90 })
91
92 const usage = pick(config.lang, {
93 en: `Usage: /${MOD} status, /${MOD} set <key> <value> [--session|--local|--repo], /${MOD} unset <key>`,
94 'zh-TW': `用法:/${MOD} status、/${MOD} set <key> <value> [--session|--local|--repo]、/${MOD} unset <key>`,
95 })
96
97 return { text: text ?? usage }
98 })
99
100 on('tool.call', async ($, e, next) => {
101 const result = await next(e)
102 try {
103 if (result.deny !== undefined) {
104 return result
105 }
106 const isFailed = result.isError === true
107 // Only the main thread's own calls make up its turn: a subagent's edits and checks are not counted.
108 if (isDoneCheckOn() && e.agentId === undefined) {
109 noteCall(work, e.tool, e as unknown as Record<string, unknown>, isFailed)
110 }
111 if (!isRetryNudgeOn()) {
112 return result
113 }
114 const text = result.text ?? (typeof result.result === 'string' ? result.result : undefined)
115 const nudge = trackFailure(streaks, `${e.agentId ?? ''}|${e.tool}`, e.tool, isFailed, text)
116
117 return nudge === undefined ? result : { ...result, context: [...(result.context ?? []), nudge] }
118 } catch {
119 return result
120 }
121 }).catch(($, e, next) => next(e))
122
123 // An interrupted turn raises no Stop: drop its record so the next turn starts clean.
124 on('turn.complete', async ($, e, next) => {
125 const result = await next(e)
126 try {
127 if (e.agentId === undefined && e.reason !== 'answer') {
128 work = freshWork()
129 }
130 } catch {
131 // Keep the record; the turn's result goes on unchanged.
132 }
133
134 return result
135 }).catch(($, e, next) => next(e))
136
137 on('classic.Stop', async ($, e, next) => {
138 const result = await next(e)
139 try {
140 if (!isDoneCheckOn() || e.agent_id !== undefined) {
141 return result
142 }
143 // Another Stop hook asked to go on: the turn has not ended, so judge the stop that does end it.
144 if (result.block !== undefined) {
145 return result
146 }
147 const reason = doneCheck(work, e.last_assistant_message, e.stop_hook_active === true, config.lang)
148 if (reason !== undefined) {
149 $.ui.log(pick(config.lang, { en: 'self-check: code was edited and not verified before saying done; asking Claude to verify first', 'zh-TW': 'self-check:改了程式檔卻沒驗證就說完成,請 Claude 先驗證' }))
150
151 return { ...result, block: reason }
152 }
153 work = freshWork()
154 } catch {
155 // Skip the nudge; the stop goes through.
156 }
157
158 return result
159 }).catch(($, e, next) => next(e))
160}
161hooks/config.ts 792 lines1// Layered settings, shared by every mod: the same file sits in each mod's hooks folder (a mod
2// cannot import outside its own folder), and scripts/check-copies.sh keeps the copies equal.
3//
4// Precedence, highest first: session (`/<mod> set --session`), `.claude/cc-mod-trimtab.local.json`,
5// `.claude/cc-mod-trimtab.json` (committed, held to each key's repo rule), the user layer
6// (`/config` for single values, `~/.claude/cc-mod-trimtab.json` for lists), the default.
7// Every file holds `{ "<mod>": { "<key>": value } }`, and may hold a `"*"` section beside them: of
8// that one only `language` is read, as the value every mod shares (a mod's own section wins).
9//
10// A feature that spends tokens is a key with a `toggle`: `/<mod> on [feature] [duration]` sets its
11// session value until a time (2h by default), `/<mod> off [feature]` takes it back.
12
13import type { PluginOptions } from 'claude-code'
14
15export type Value = boolean | number | string | readonly string[]
16
17// The language of everything shown on screen. Text for the model is English whatever this is.
18export type Lang = 'en' | 'zh-TW'
19
20export type Text = Readonly<Record<Lang, string>>
21
22export function pick(lang: Lang, text: Text): string {
23 return text[lang]
24}
25
26// A description is one string (tests) or a pair.
27export function say(lang: Lang, text: string | Text): string {
28 return typeof text === 'string' ? text : text[lang]
29}
30
31// Where the model writes for the person (memories, handoffs), the prompt names the language.
32export function writeIn(lang: Lang): string {
33 return lang === 'zh-TW' ? 'Write in Traditional Chinese (zh-TW).' : 'Write in English.'
34}
35
36export type Layer = 'default' | 'user' | 'repo' | 'local' | 'session'
37
38// What the committed repo file may do: a clone must not loosen a protection or switch on spending.
39// `append` adds to the list below it; `never` leaves the key to the person's own layers.
40export type RepoRule = 'any' | 'only-true' | 'only-false' | 'only-lower' | 'only-higher' | 'append' | 'never'
41
42export type Category = 'protect' | 'cost' | 'plain'
43
44export type KeySpec = {
45 category: Category
46 type: 'boolean' | 'number' | 'string' | 'list'
47 default: Value
48 repo: RepoRule
49 description: string | Text
50 min?: number
51 max?: number
52 // The values a string may take.
53 choices?: readonly string[]
54 // A feature `/<mod> on|off` switches: the value each one sets.
55 toggle?: { on: Value; off: Value }
56}
57
58export type Specs = Readonly<Record<string, KeySpec>>
59
60// The key every mod has: the language of what it shows. One value may stand for all mods in a
61// settings file's `"*"` section. It is the person's own taste, so the repo file cannot set it.
62export const LANGUAGE: KeySpec = {
63 category: 'plain',
64 type: 'string',
65 default: 'en',
66 repo: 'never',
67 choices: ['en', 'zh-TW'],
68 description: {
69 en: 'Language of what this mod shows: en or zh-TW. Put {"*": {"language": "..."}} in a settings file to set it for every mod.',
70 'zh-TW': '這個 mod 顯示的語言:en 或 zh-TW。在設定檔放 {"*": {"language": "..."}} 可一次設定所有 mod。',
71 },
72}
73
74export type Ignored = { key: string; layer: Layer; reason: string }
75
76export type Resolved = {
77 // The language the values come to (`language`, English without it).
78 lang: Lang
79 values: Readonly<Record<string, Value>>
80 sources: Readonly<Record<string, Layer>>
81 ignored: readonly Ignored[]
82 // When each key switched on by `/<mod> on` goes back, for the keys whose value it decides.
83 until: Readonly<Record<string, number>>
84}
85
86export type Target = 'session' | 'local' | 'repo'
87
88type Raw = Readonly<Record<string, unknown>>
89
90export type Layers = {
91 options: PluginOptions
92 user: Raw
93 repo: Raw
94 local: Raw
95 session: Raw
96 // When the session values set by `/<mod> on` run out; the expired ones are left out already.
97 until?: Readonly<Record<string, number>>
98}
99
100export const REPO_FILE = '.claude/cc-mod-trimtab.json'
101export const LOCAL_FILE = '.claude/cc-mod-trimtab.local.json'
102const USER_FILE = 'cc-mod-trimtab.json'
103// Both keyed by session id: `$.store` is one file every session of the mod shares.
104const SESSION_KEY = 'config-sessions'
105const NOTICED_KEY = 'config-noticed-sessions'
106// The settings files' section for what every mod shares.
107const SHARED = '*'
108
109const DEFAULT_ON_MS = 2 * 3_600_000
110const MAX_ON_MS = 24 * 3_600_000
111// A session's entries are dropped once nothing wrote them for this long.
112const KEEP_SESSION_MS = 7 * 86_400_000
113
114const LAYER_NAMES: Readonly<Record<Layer, Text>> = {
115 default: { en: 'default', 'zh-TW': '預設' },
116 user: { en: 'user', 'zh-TW': '使用者' },
117 repo: { en: 'repo', 'zh-TW': 'repo' },
118 local: { en: 'local', 'zh-TW': 'local' },
119 session: { en: 'session', 'zh-TW': 'session' },
120}
121
122const CATEGORY_NAMES: Readonly<Record<Category, Text>> = {
123 protect: { en: 'protection', 'zh-TW': '保護' },
124 cost: { en: 'cost', 'zh-TW': '花費' },
125 plain: { en: 'general', 'zh-TW': '一般' },
126}
127
128const RULE_NAMES: Readonly<Record<RepoRule, Text>> = {
129 any: { en: 'repo may set freely', 'zh-TW': 'repo 可自由設定' },
130 'only-true': { en: 'repo may only turn on', 'zh-TW': 'repo 只能開啟' },
131 'only-false': { en: 'repo may only turn off', 'zh-TW': 'repo 只能關閉' },
132 'only-lower': { en: 'repo may only lower', 'zh-TW': 'repo 只能調低' },
133 'only-higher': { en: 'repo may only raise', 'zh-TW': 'repo 只能調高' },
134 append: { en: 'repo may only add items', 'zh-TW': 'repo 只能加項目' },
135 never: { en: 'repo may not set', 'zh-TW': 'repo 不能設定' },
136}
137
138function langOf(value: unknown): Lang {
139 return value === 'zh-TW' ? 'zh-TW' : 'en'
140}
141
142function listOf(lang: Lang, items: readonly string[]): string {
143 return items.join(lang === 'zh-TW' ? '、' : ', ')
144}
145
146// ---------- pure ----------
147
148// A stored value of the key's type, or why it is not one.
149export function parseValue(spec: KeySpec, raw: unknown, lang: Lang = 'en'): { value: Value } | { error: string } {
150 switch (spec.type) {
151 case 'boolean':
152 return typeof raw === 'boolean' ? { value: raw } : { error: pick(lang, { en: 'must be true or false', 'zh-TW': '要是 true 或 false' }) }
153 case 'number': {
154 if (typeof raw !== 'number' || !Number.isFinite(raw)) {
155 return { error: pick(lang, { en: 'must be a number', 'zh-TW': '要是數字' }) }
156 }
157 if ((spec.min !== undefined && raw < spec.min) || (spec.max !== undefined && raw > spec.max)) {
158 const min = spec.min ?? '-∞'
159 const max = spec.max ?? '∞'
160
161 return { error: pick(lang, { en: `must be between ${min} and ${max}`, 'zh-TW': `要在 ${min} 到 ${max} 之間` }) }
162 }
163
164 return { value: raw }
165 }
166 case 'string':
167 if (typeof raw !== 'string') {
168 return { error: pick(lang, { en: 'must be a string', 'zh-TW': '要是字串' }) }
169 }
170 if (spec.choices !== undefined && !spec.choices.includes(raw)) {
171 return { error: pick(lang, { en: `must be one of ${listOf(lang, spec.choices)}`, 'zh-TW': `要是 ${listOf(lang, spec.choices)} 之一` }) }
172 }
173
174 return { value: raw }
175 case 'list':
176 return Array.isArray(raw) && raw.every(item => typeof item === 'string')
177 ? { value: raw as readonly string[] }
178 : { error: pick(lang, { en: 'must be an array of strings', 'zh-TW': '要是字串陣列' }) }
179 }
180}
181
182// A value typed after `/<mod> set <key>`: on/off for booleans; for lists a JSON array, or the
183// whole text as one item (commas are common in a pattern, so they do not split).
184export function parseInput(spec: KeySpec, text: string, lang: Lang = 'en'): { value: Value } | { error: string } {
185 const t = text.trim()
186 switch (spec.type) {
187 case 'boolean': {
188 const on = ['true', 'on', 'yes', '1'].includes(t.toLowerCase())
189 const off = ['false', 'off', 'no', '0'].includes(t.toLowerCase())
190
191 return on || off ? { value: on } : { error: pick(lang, { en: 'must be on or off', 'zh-TW': '要是 on 或 off' }) }
192 }
193 case 'number':
194 return parseValue(spec, t === '' ? Number.NaN : Number(t), lang)
195 case 'string':
196 return parseValue(spec, t, lang)
197 case 'list': {
198 if (t.startsWith('[')) {
199 try {
200 return parseValue(spec, JSON.parse(t), lang)
201 } catch {
202 return { error: pick(lang, { en: 'not a valid JSON array', 'zh-TW': 'JSON 陣列格式不對' }) }
203 }
204 }
205
206 return { value: t === '' ? [] : [t] }
207 }
208 }
209}
210
211function same(a: Value, b: Value): boolean {
212 return JSON.stringify(a) === JSON.stringify(b)
213}
214
215// What the repo file's value comes to over the one below it, or why it is ignored.
216export function applyRepoRule(spec: KeySpec, below: Value, value: Value, lang: Lang = 'en'): { value: Value } | { error: string } {
217 // `never` refuses even the value below it, so `set --repo` cannot write the key at all.
218 if (spec.repo !== 'never' && (same(below, value) || spec.repo === 'any')) {
219 return { value }
220 }
221 switch (spec.repo) {
222 case 'only-true':
223 return value === true ? { value } : { error: pick(lang, { en: 'protection key: the repo file may only turn it on', 'zh-TW': '保護類,repo 檔案只能開啟' }) }
224 case 'only-false':
225 return value === false ? { value } : { error: pick(lang, { en: 'cost key: the repo file may only turn it off', 'zh-TW': '花費類,repo 檔案只能關閉' }) }
226 case 'only-lower':
227 return (value as number) < (below as number) ? { value } : { error: pick(lang, { en: 'the repo file may only lower it', 'zh-TW': 'repo 檔案只能調低' }) }
228 case 'only-higher':
229 return (value as number) > (below as number) ? { value } : { error: pick(lang, { en: 'the repo file may only raise it', 'zh-TW': 'repo 檔案只能調高' }) }
230 case 'append':
231 return { value: [...new Set([...(below as readonly string[]), ...(value as readonly string[])])] }
232 case 'never':
233 return { error: pick(lang, { en: 'the repo file may not set it; use the local file', 'zh-TW': 'repo 檔案不能設定,改用 local 檔案' }) }
234 }
235}
236
237// The one key whose user layer is the settings file, single value or not: `/config` cannot hold
238// a value shared by every mod.
239function isFileValue(key: string, spec: KeySpec): boolean {
240 return spec.type === 'list' || key === 'language'
241}
242
243function resolveKey(key: string, spec: KeySpec, layers: Layers, lang: Lang, ignored: Ignored[]): { value: Value; source: Layer } {
244 let value = spec.default
245 let source: Layer = 'default'
246 const take = (layer: Layer, raw: unknown) => {
247 if (raw === undefined) {
248 return
249 }
250 const parsed = parseValue(spec, raw, lang)
251 if ('error' in parsed) {
252 ignored.push({ key, layer, reason: parsed.error })
253
254 return
255 }
256 const applied = layer === 'repo' ? applyRepoRule(spec, value, parsed.value, lang) : parsed
257 if ('error' in applied) {
258 ignored.push({ key, layer, reason: applied.error })
259
260 return
261 }
262 if (layer === 'user' && same(applied.value, spec.default)) {
263 // `/config` hands over defaults filled in: the same value is no choice of the person's.
264 return
265 }
266 value = applied.value
267 source = layer
268 }
269 // Lists have no `/config` row; their user layer is the file.
270 take('user', isFileValue(key, spec) ? layers.user[key] : layers.options[key])
271 take('repo', layers.repo[key])
272 take('local', layers.local[key])
273 take('session', layers.session[key])
274
275 return { value, source }
276}
277
278export function resolve(specs: Specs, layers: Layers): Resolved {
279 const values: Record<string, Value> = {}
280 const sources: Record<string, Layer> = {}
281 const ignored: Ignored[] = []
282 // The language first: the other keys' reasons are worded in it.
283 const languageSpec = Object.hasOwn(specs, 'language') ? (specs.language as KeySpec) : LANGUAGE
284 const lang = langOf(resolveKey('language', languageSpec, layers, 'en', []).value)
285 for (const [key, spec] of Object.entries(specs)) {
286 const one = resolveKey(key, spec, layers, lang, ignored)
287 values[key] = one.value
288 sources[key] = one.source
289 }
290 for (const layer of ['user', 'repo', 'local', 'session'] as const) {
291 for (const key of Object.keys(layers[layer])) {
292 const spec = Object.hasOwn(specs, key) ? specs[key] : undefined
293 if (spec === undefined) {
294 ignored.push({ key, layer, reason: pick(lang, { en: 'no such setting', 'zh-TW': '沒有這個設定' }) })
295 } else if (layer === 'user' && !isFileValue(key, spec)) {
296 ignored.push({
297 key,
298 layer,
299 reason: pick(lang, { en: 'the user settings file holds lists only; use /config for a single value', 'zh-TW': '使用者設定檔只放清單,單一值用 /config' }),
300 })
301 }
302 }
303 }
304
305 const until: Record<string, number> = {}
306 for (const [key, at] of Object.entries(layers.until ?? {})) {
307 if (sources[key] === 'session' && Object.hasOwn(layers.session, key)) {
308 until[key] = at
309 }
310 }
311
312 return { lang, values, sources, ignored, until }
313}
314
315// `2h`, `90m`, `1h30m`, or a bare number of minutes.
316export function parseDuration(text: string): number | undefined {
317 const t = text.trim().toLowerCase()
318 const match = /^(?:(\d+)h)?(?:(\d+)m?)?$/.exec(t)
319 if (t === '' || match === null || (match[1] === undefined && match[2] === undefined)) {
320 return undefined
321 }
322 const ms = Number(match[1] ?? 0) * 3_600_000 + Number(match[2] ?? 0) * 60_000
323
324 return ms > 0 && ms <= MAX_ON_MS ? ms : undefined
325}
326
327// `1h42m`, `37m`, `<1m`.
328export function fmtLeft(ms: number): string {
329 const minutes = Math.ceil(ms / 60_000)
330 if (ms < 60_000) {
331 return '<1m'
332 }
333 const h = Math.floor(minutes / 60)
334 const m = minutes % 60
335
336 return h === 0 ? `${m}m` : m === 0 ? `${h}h` : `${h}h${m}m`
337}
338
339// The band line while a feature is on for a while: `<mod> on · keep_warm 1h42m`, '' when none is.
340export function onLine(mod: string, until: Readonly<Record<string, number>>, now: number): string {
341 const parts = Object.entries(until)
342 .filter(([, at]) => at > now)
343 .map(([key, at]) => `${key} ${fmtLeft(at - now)}`)
344
345 return parts.length === 0 ? '' : [`${mod} on`, ...parts].join(' · ')
346}
347
348// When the first of the features switched on for a while goes back.
349export function nextExpiry(resolved: Resolved): number | undefined {
350 const times = Object.values(resolved.until)
351
352 return times.length === 0 ? undefined : Math.min(...times)
353}
354
355function show(value: Value): string {
356 return Array.isArray(value) ? (value.length === 0 ? '[]' : value.join(', ')) : String(value)
357}
358
359export function statusText(mod: string, specs: Specs, resolved: Resolved, now = 0): string {
360 const lang = resolved.lang
361 const keys = Object.keys(specs)
362 if (keys.length === 0) {
363 return pick(lang, { en: `${mod} has no settings.`, 'zh-TW': `${mod} 沒有可調的設定。` })
364 }
365 const lines = [
366 pick(lang, {
367 en: `${mod} settings (precedence: session > local > repo > user > default)`,
368 'zh-TW': `${mod} 設定(優先:session > local > repo > 使用者 > 預設)`,
369 }),
370 ]
371 for (const key of keys) {
372 const spec = specs[key] as KeySpec
373 const value = resolved.values[key] as Value
374 const source = resolved.sources[key] as Layer
375 const at = resolved.until[key]
376 const left = at === undefined ? '' : pick(lang, { en: `, on temporarily for ${fmtLeft(at - now)}`, 'zh-TW': `,臨時開啟剩 ${fmtLeft(at - now)}` })
377 const kind = pick(lang, CATEGORY_NAMES[spec.category])
378 const rule = pick(lang, RULE_NAMES[spec.repo])
379 lines.push(` ${key} = ${show(value)} [${pick(lang, LAYER_NAMES[source])}${left}] ${kind}${lang === 'zh-TW' ? ',' : ', '}${rule}`)
380 lines.push(` ${say(lang, spec.description)}`)
381 }
382 if (resolved.ignored.length > 0) {
383 lines.push(pick(lang, { en: 'Ignored:', 'zh-TW': '已忽略:' }))
384 for (const item of resolved.ignored) {
385 const layer = pick(lang, LAYER_NAMES[item.layer])
386 lines.push(pick(lang, { en: ` ${item.key} in ${layer}: ${item.reason}`, 'zh-TW': ` ${layer} 的 ${item.key}:${item.reason}` }))
387 }
388 }
389 lines.push(
390 pick(lang, {
391 en: `Change: /${mod} set <key> <value> [--session|--local|--repo] (default --session); revert: /${mod} unset <key> [same flags]`,
392 'zh-TW': `改值:/${mod} set <key> <value> [--session|--local|--repo](預設 --session),還原:/${mod} unset <key> [同上]`,
393 }),
394 )
395 const toggles = toggleKeys(specs)
396 if (toggles.length > 0) {
397 const names = listOf(lang, toggles)
398 const feature = toggles.length === 1 ? '' : pick(lang, { en: ' <feature>', 'zh-TW': ' <功能>' })
399 lines.push(
400 pick(lang, {
401 en: `Features that use the model (${names}): /${mod} on${feature} [duration, default 2h] turns one on for a while, /${mod} off${feature} turns it off`,
402 'zh-TW': `用模型的功能(${names}):/${mod} on${feature} [時長,預設 2h] 臨時開啟,/${mod} off${feature} 關閉`,
403 }),
404 )
405 }
406
407 return lines.join('\n')
408}
409
410function modSection(text: string, mod: string): Raw {
411 if (text.trim() === '') {
412 return {}
413 }
414 const parsed: unknown = JSON.parse(text)
415 const section = typeof parsed === 'object' && parsed !== null ? (parsed as Raw)[mod] : undefined
416
417 return typeof section === 'object' && section !== null && !Array.isArray(section) ? (section as Raw) : {}
418}
419
420// The file's text with the key set (or removed when `value` is undefined), other mods kept.
421export function withSetting(text: string, mod: string, key: string, value: Value | undefined): string {
422 const parsed: unknown = text.trim() === '' ? {} : JSON.parse(text)
423 const all = typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? { ...(parsed as Raw) } : {}
424 const section = { ...modSection(text, mod) }
425 if (value === undefined) {
426 delete section[key]
427 } else {
428 section[key] = value
429 }
430 if (Object.keys(section).length === 0) {
431 delete all[mod]
432 } else {
433 all[mod] = section
434 }
435
436 return `${JSON.stringify(all, null, 2)}\n`
437}
438
439export function withLocalExclude(text: string): string | undefined {
440 const line = `/${LOCAL_FILE}`
441 if (text.split('\n').some(l => l.trim() === line)) {
442 return undefined
443 }
444
445 return `${text}${text === '' || text.endsWith('\n') ? '' : '\n'}# cc-mod-trimtab\n${line}\n`
446}
447
448const TARGET_FLAG = /(^|\s)--(session|local|repo)(?=\s|$)/g
449
450// `<key> [value] [--session|--local|--repo]`, the flag anywhere. The value keeps its spacing.
451export function parseSetArgs(args: string, lang: Lang = 'en'): { key: string; rest: string; target: Target } | { error: string } {
452 const targets = [...args.matchAll(TARGET_FLAG)].map(match => match[2] as Target)
453 if (targets.length > 1) {
454 return { error: pick(lang, { en: 'give only one of --session, --local or --repo', 'zh-TW': '只能加一個 --session、--local 或 --repo' }) }
455 }
456 const text = args.replace(TARGET_FLAG, '$1').trim()
457 const key = text.split(/\s+/)[0] ?? ''
458 if (key === '') {
459 return { error: pick(lang, { en: 'name a setting', 'zh-TW': '要指定設定名稱' }) }
460 }
461
462 return { key, rest: text.slice(key.length).trim(), target: targets[0] ?? 'session' }
463}
464
465// ---------- through the mod's I/O ----------
466
467// What the settings need from `$`. `$` cannot cross an import, so each mod builds this in its own
468// register file (the `configIO` block, kept equal across mods by scripts/check-copies.sh).
469export type ConfigIO = {
470 // The file's text, '' when it is missing.
471 read: (path: string) => Promise<string>
472 write: (path: string, text: string) => Promise<void>
473 isDir: (path: string) => Promise<boolean>
474 // `CLAUDE_CONFIG_DIR`, else `$HOME/.claude`.
475 configDir: () => Promise<string>
476 root: () => Promise<string>
477 // The main working tree, whose `.git/info/exclude` covers every worktree.
478 repoRoot: () => Promise<string | undefined>
479 sessionId: () => Promise<string>
480 now: () => Promise<number>
481 storeGet: (key: string) => Promise<unknown>
482 storeSet: (key: string, value: unknown) => Promise<void>
483}
484
485// What a file adds to the layers: the mod's section, with `language` from `"*"` when the section
486// has none of its own, and the problems found on the way.
487type Problem = { key: string; layer: Layer; reason: Text }
488
489function readSection(text: string, path: string, mod: string, layer: Layer, broken: Problem[]): Raw {
490 try {
491 const own = modSection(text, mod)
492 const shared = modSection(text, SHARED)
493 for (const key of Object.keys(shared)) {
494 if (key !== 'language') {
495 broken.push({
496 key: `${SHARED}.${key}`,
497 layer,
498 reason: { en: `only language is read from "${SHARED}"`, 'zh-TW': `"${SHARED}" 只讀 language` },
499 })
500 }
501 }
502
503 return shared.language === undefined || own.language !== undefined ? own : { ...own, language: shared.language }
504 } catch {
505 broken.push({ key: path, layer, reason: { en: 'not valid JSON; the whole file is not used', 'zh-TW': 'JSON 格式不對,整個檔案沒有採用' } })
506
507 return {}
508 }
509}
510
511type SessionEntry = { values: Raw; until?: Readonly<Record<string, number>> }
512type Sessions<T> = Readonly<Record<string, T & { written: number }>>
513
514// Every session's entry under `key`, with `id`'s replaced by `entry` and those not written for a
515// week dropped.
516async function writeSessions<T>(io: ConfigIO, key: string, id: string, entry: T): Promise<void> {
517 const now = await io.now()
518 const kept: Record<string, T & { written: number }> = {}
519 for (const [other, stored] of Object.entries(((await io.storeGet(key)) ?? {}) as Sessions<T>)) {
520 if (other !== id && now - stored.written < KEEP_SESSION_MS) {
521 kept[other] = stored
522 }
523 }
524 kept[id] = { ...entry, written: now }
525 await io.storeSet(key, kept)
526}
527
528async function sessionEntry<T>(io: ConfigIO, key: string, id: string): Promise<T | undefined> {
529 const stored = (await io.storeGet(key)) as Sessions<T> | undefined
530
531 return stored !== undefined && Object.hasOwn(stored, id) ? stored[id] : undefined
532}
533
534// This session's values; those `/<mod> on` set left out once they ran out.
535async function sessionValues(io: ConfigIO): Promise<{ values: Raw; until: Record<string, number> }> {
536 const stored = await sessionEntry<SessionEntry>(io, SESSION_KEY, await io.sessionId())
537 if (stored === undefined) {
538 return { values: {}, until: {} }
539 }
540 const timed = Object.entries(stored.until ?? {})
541 // Read only when needed: a mod without features to switch never asks the clock.
542 const now = timed.length === 0 ? 0 : await io.now()
543 const values: Record<string, unknown> = { ...stored.values }
544 const until: Record<string, number> = {}
545 for (const [key, at] of timed) {
546 if (at > now) {
547 until[key] = at
548 } else {
549 delete values[key]
550 }
551 }
552
553 return { values, until }
554}
555
556async function writeSession(io: ConfigIO, key: string, value: Value | undefined, until: number | undefined): Promise<void> {
557 const current = await sessionValues(io)
558 const values: Record<string, unknown> = { ...current.values }
559 const times = { ...current.until }
560 delete values[key]
561 delete times[key]
562 if (value !== undefined) {
563 values[key] = value
564 }
565 if (value !== undefined && until !== undefined) {
566 times[key] = until
567 }
568 await writeSessions(io, SESSION_KEY, await io.sessionId(), { values, until: times })
569}
570
571// True the first time a feature does its work in this session, for the one notice it gets.
572export async function firstUse(io: ConfigIO, key: string): Promise<boolean> {
573 const sessionId = await io.sessionId()
574 const keys = (await sessionEntry<{ keys: string[] }>(io, NOTICED_KEY, sessionId))?.keys ?? []
575 if (keys.includes(key)) {
576 return false
577 }
578 await writeSessions(io, NOTICED_KEY, sessionId, { keys: [...keys, key] })
579
580 return true
581}
582
583async function filePaths(io: ConfigIO): Promise<{ user: string; repo: string; local: string }> {
584 const root = (await io.root()).replace(/\/$/, '')
585
586 return { user: `${(await io.configDir()).replace(/\/$/, '')}/${USER_FILE}`, repo: `${root}/${REPO_FILE}`, local: `${root}/${LOCAL_FILE}` }
587}
588
589async function loadLayers(io: ConfigIO, mod: string, options: PluginOptions): Promise<{ layers: Layers; broken: Problem[] }> {
590 const paths = await filePaths(io)
591 const broken: Problem[] = []
592 const session = await sessionValues(io)
593 const layers = {
594 options,
595 user: readSection(await io.read(paths.user), paths.user, mod, 'user', broken),
596 repo: readSection(await io.read(paths.repo), paths.repo, mod, 'repo', broken),
597 local: readSection(await io.read(paths.local), paths.local, mod, 'local', broken),
598 session: session.values,
599 until: session.until,
600 }
601
602 return { layers, broken }
603}
604
605export async function loadConfig(io: ConfigIO, mod: string, specs: Specs, options: PluginOptions): Promise<Resolved> {
606 const { layers, broken } = await loadLayers(io, mod, options)
607 const resolved = resolve(specs, layers)
608
609 const problems = broken.map(item => ({ ...item, reason: pick(resolved.lang, item.reason) }))
610
611 return { ...resolved, ignored: [...problems, ...resolved.ignored] }
612}
613
614async function writeSetting(io: ConfigIO, mod: string, key: string, value: Value | undefined, target: Target): Promise<void> {
615 if (target === 'session') {
616 await writeSession(io, key, value, undefined)
617
618 return
619 }
620 const paths = await filePaths(io)
621 const path = target === 'repo' ? paths.repo : paths.local
622 await io.write(path, withSetting(await io.read(path), mod, key, value))
623 if (target === 'local') {
624 const repo = await io.repoRoot()
625 if (repo !== undefined && (await io.isDir(`${repo}/.git`))) {
626 const exclude = `${repo}/.git/info/exclude`
627 const added = withLocalExclude(await io.read(exclude))
628 if (added !== undefined) {
629 await io.write(exclude, added)
630 }
631 }
632 }
633}
634
635// Answers `/<mod> status|set|unset …`; undefined for any other sub-command. `reload` hands the
636// mod the new values after a change.
637export async function configCommand(
638 io: ConfigIO,
639 mod: string,
640 specs: Specs,
641 options: PluginOptions,
642 args: string,
643 reload: (resolved: Resolved) => void,
644): Promise<string | undefined> {
645 const sub = args.trim().split(/\s+/)[0] ?? ''
646 const rest = args.trim().slice(sub.length)
647 if (sub === 'status') {
648 const resolved = await loadConfig(io, mod, specs, options)
649 reload(resolved)
650
651 return statusText(mod, specs, resolved, Object.keys(resolved.until).length === 0 ? 0 : await io.now())
652 }
653 if ((sub === 'on' || sub === 'off') && toggleKeys(specs).length > 0) {
654 return switchFeature(io, mod, specs, options, sub, rest, reload)
655 }
656 if (sub !== 'set' && sub !== 'unset') {
657 return undefined
658 }
659 const before = await loadLayers(io, mod, options)
660 const lang = resolve(specs, before.layers).lang
661 const parsed = parseSetArgs(rest, lang)
662 if ('error' in parsed) {
663 return parsed.error
664 }
665 const spec = Object.hasOwn(specs, parsed.key) ? specs[parsed.key] : undefined
666 if (spec === undefined) {
667 return pick(lang, {
668 en: `${mod} has no setting ${parsed.key}; /${mod} status lists them all`,
669 'zh-TW': `${mod} 沒有 ${parsed.key} 這個設定;/${mod} status 列出全部`,
670 })
671 }
672 let value: Value | undefined
673 if (sub === 'set') {
674 const input = parseInput(spec, parsed.rest, lang)
675 if ('error' in input) {
676 return `${parsed.key} ${input.error}`
677 }
678 value = input.value
679 }
680 if (parsed.target === 'repo' && value !== undefined) {
681 // Do not commit a value every reader would ignore.
682 const { layers } = before
683 const trial = resolve(specs, { ...layers, repo: { ...layers.repo, [parsed.key]: value } })
684 const refused = trial.ignored.find(item => item.layer === 'repo' && item.key === parsed.key)
685 if (refused !== undefined) {
686 return pick(lang, { en: `Not written: ${refused.reason}`, 'zh-TW': `沒有寫入:${refused.reason}` })
687 }
688 }
689 await writeSetting(io, mod, parsed.key, value, parsed.target)
690 const resolved = await loadConfig(io, mod, specs, options)
691 reload(resolved)
692 const shown = show(resolved.values[parsed.key] as Value)
693 const from = pick(resolved.lang, LAYER_NAMES[resolved.sources[parsed.key] as Layer])
694 const now = pick(resolved.lang, { en: `${parsed.key} is now ${shown} (from ${from})`, 'zh-TW': `${parsed.key} 現在是 ${shown}(來自 ${from})` })
695 const where =
696 parsed.target === 'session' ? pick(resolved.lang, { en: 'this session only', 'zh-TW': '只在這個 session' }) : parsed.target === 'repo' ? REPO_FILE : LOCAL_FILE
697 const done = sub === 'set' ? pick(resolved.lang, { en: 'Set', 'zh-TW': '已設定' }) : pick(resolved.lang, { en: 'Removed', 'zh-TW': '已移除' })
698
699 return pick(resolved.lang, { en: `${done} (${where}). ${now}`, 'zh-TW': `${done}(${where})。${now}` })
700}
701
702export function toggleKeys(specs: Specs): string[] {
703 return Object.keys(specs).filter(key => specs[key]?.toggle !== undefined)
704}
705
706// `[feature] [duration]` in either order; the feature may go when the mod has one.
707export function parseOnArgs(specs: Specs, args: string, withDuration: boolean, lang: Lang = 'en'): { key: string; ms: number } | { error: string } {
708 const toggles = toggleKeys(specs)
709 let key: string | undefined
710 let ms: number | undefined
711 for (const word of args.trim().split(/\s+/).filter(w => w !== '')) {
712 if (key === undefined && toggles.includes(word)) {
713 key = word
714 continue
715 }
716 const duration = withDuration && ms === undefined ? parseDuration(word) : undefined
717 if (duration === undefined) {
718 const names = listOf(lang, toggles)
719 const hint = withDuration ? pick(lang, { en: '; a duration looks like 2h, 90m, 1h30m (24h at most)', 'zh-TW': ',時長像 2h、90m、1h30m(最多 24h)' }) : ''
720
721 return { error: pick(lang, { en: `Cannot read "${word}": the features are ${names}${hint}`, 'zh-TW': `看不懂「${word}」:功能是 ${names}${hint}` }) }
722 }
723 ms = duration
724 }
725 if (key === undefined && toggles.length === 1) {
726 key = toggles[0]
727 }
728 if (key === undefined) {
729 return { error: pick(lang, { en: `Name a feature: ${listOf(lang, toggles)}`, 'zh-TW': `要指定功能:${listOf(lang, toggles)}` }) }
730 }
731
732 return { key, ms: ms ?? DEFAULT_ON_MS }
733}
734
735async function switchFeature(
736 io: ConfigIO,
737 mod: string,
738 specs: Specs,
739 options: PluginOptions,
740 sub: 'on' | 'off',
741 args: string,
742 reload: (resolved: Resolved) => void,
743): Promise<string> {
744 const { layers } = await loadLayers(io, mod, options)
745 const lang = resolve(specs, layers).lang
746 const parsed = parseOnArgs(specs, args, sub === 'on', lang)
747 if ('error' in parsed) {
748 return parsed.error
749 }
750 const { key, ms } = parsed
751 const toggle = (specs[key] as KeySpec).toggle as { on: Value; off: Value }
752 const rest: Record<string, unknown> = { ...layers.session }
753 delete rest[key]
754 const below = resolve(specs, { ...layers, session: rest })
755 const belowValue = below.values[key] as Value
756 const isOnBelow = !same(belowValue, toggle.off)
757 const now = await io.now()
758 const from = pick(lang, LAYER_NAMES[below.sources[key] as Layer])
759 if (sub === 'on') {
760 if (isOnBelow) {
761 const wasOff = Object.hasOwn(layers.session, key)
762 await writeSession(io, key, undefined, undefined)
763 reload(await loadConfig(io, mod, specs, options))
764 const source = pick(lang, { en: `${show(belowValue)}, from ${from}`, 'zh-TW': `${show(belowValue)},來自 ${from}` })
765
766 return wasOff
767 ? pick(lang, { en: `${key}: this session's off is cancelled, back to always on (${source})`, 'zh-TW': `${key} 取消這個 session 的關閉,回到常開(${source})` })
768 : pick(lang, { en: `${key} is already always on (${source}); no need to turn it on for a while`, 'zh-TW': `${key} 已經常開(${source}),不用臨時開啟` })
769 }
770 await writeSession(io, key, toggle.on, now + ms)
771 reload(await loadConfig(io, mod, specs, options))
772 const at = new Date(now + ms)
773 const clock = `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
774 const off = `/${mod} off${toggleKeys(specs).length === 1 ? '' : ` ${key}`}`
775
776 return pick(lang, {
777 en: `${key} on for ${fmtLeft(ms)} (until ${clock}); it goes off by itself when that runs out or the session ends; turn off: ${off}`,
778 'zh-TW': `${key} 臨時開啟 ${fmtLeft(ms)}(到 ${clock}),到期或 session 結束自動關;關閉:${off}`,
779 })
780 }
781 // Off for the rest of the session; nothing to keep when the layers below are off already.
782 await writeSession(io, key, isOnBelow ? toggle.off : undefined, undefined)
783 reload(await loadConfig(io, mod, specs, options))
784
785 return isOnBelow
786 ? pick(lang, {
787 en: `${key} is off for this session (${from} has it on; it comes back next session)`,
788 'zh-TW': `${key} 在這個 session 關閉(${from} 設定是開啟,下個 session 恢復)`,
789 })
790 : pick(lang, { en: `${key} is already off`, 'zh-TW': `${key} 已關閉` })
791}
792hooks/loops.ts 169 lines1// The two nudges as pure logic (no $): the same failure twice in a row, and a claim of done with nothing verified.
2// Adapted from hooks/loops.ts of cablate/ctx-handoff-mod v0.5.0 (MIT, commit 3a06bc8).
3
4import { pick, type Lang } from './config'
5
6export const TAG = '[self-check]'
7
8// ---------- Repeated failure: one tool failing twice in a row for the same reason ----------
9
10export type Streak = { sig: string; count: number; isNudged: boolean }
11
12const MAX_SIG = 200
13const MAX_STREAKS = 200
14// Terminal color codes, built so no control character sits in the pattern source.
15const ANSI = new RegExp(String.raw`${String.fromCharCode(27)}\[[0-9;]*[A-Za-z]`, 'g')
16// The user interrupting or rejecting a call is not a failure of the tool.
17const USER_STOP = /doesn't want to proceed|user rejected|interrupted by user|request interrupted|user denied/i
18
19// Signature of an error: color codes, paths, numbers (lines, times, ids) and spacing removed, first 200 characters.
20export function failureSignature(text: string | undefined): string {
21 return (text ?? '')
22 .replace(ANSI, '')
23 .replace(/[A-Za-z]:[\\/][^\s'"`)>\]]*/g, '<p>')
24 .replace(/(?:\.{0,2}\/)?(?:[\w.@~-]+\/)+[\w.@~-]*/g, '<p>')
25 .replace(/\d+/g, '#')
26 .replace(/\s+/g, ' ')
27 .trim()
28 .slice(0, MAX_SIG)
29}
30
31// The error as quoted to Claude: its first non-empty line, up to 80 characters.
32function briefOf(text: string | undefined): string {
33 const line =
34 (text ?? '')
35 .split('\n')
36 .map(l => l.trim())
37 .find(Boolean) ?? ''
38
39 return line.length > 80 ? `${line.slice(0, 80)}…` : line
40}
41
42// Added to the tool result for Claude only, so always English.
43export function retryNudgeText(tool: string, text: string | undefined): string {
44 return `${TAG} This tool (${tool}) failed twice in a row for the same reason (${briefOf(text) || 'no error text'}). Find the cause and change your approach instead of retrying as is.`
45}
46
47// Records one tool result; the second failure with the same signature returns the nudge, once per streak.
48// key: the caller (main thread or subagent) plus the tool; a success clears it.
49export function trackFailure(
50 streaks: Map<string, Streak>,
51 key: string,
52 tool: string,
53 isError: boolean,
54 text: string | undefined,
55): string | undefined {
56 if (!isError || USER_STOP.test(text ?? '')) {
57 streaks.delete(key)
58
59 return undefined
60 }
61 const sig = failureSignature(text)
62 const prev = streaks.get(key)
63 if (!prev || prev.sig !== sig) {
64 // A new signature starts a new streak; the cap only bounds the map.
65 if (!prev && streaks.size >= MAX_STREAKS) {
66 streaks.clear()
67 }
68 streaks.set(key, { sig, count: 1, isNudged: false })
69
70 return undefined
71 }
72 prev.count += 1
73 if (prev.count < 2 || prev.isNudged) {
74 return undefined
75 }
76 prev.isNudged = true
77
78 return retryNudgeText(tool, text)
79}
80
81// ---------- Claimed done, with no check after the last edit ----------
82
83// The turn's record: seq counts finished tool calls; lastEdit and lastCheck are the seq of the last edit and check (0: none).
84export type Work = { seq: number; lastEdit: number; lastCheck: number; isReminded: boolean }
85
86export function freshWork(): Work {
87 return { seq: 0, lastEdit: 0, lastCheck: 0, isReminded: false }
88}
89
90// Edits made through Bash (sed -i, python3) do not count: the write detection waits on tool-guard's bash-edit-snapshot, deferred in ROADMAP.md.
91const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
92// Editing docs needs no test run.
93const DOC_FILE = /\.(md|mdx|txt|rst|adoc)$/i
94// Commands that only read or move files are no check.
95const NOT_RUNNING = new Set([
96 'ls', 'cat', 'echo', 'grep', 'rg', 'find', 'head', 'tail', 'git', 'cd', 'mkdir', 'rm', 'cp', 'mv',
97 'sed', 'awk', 'type', 'wc', 'diff', 'open', 'pwd', 'which', 'printf',
98])
99const CHECK_WORDS =
100 /\b(tests?|pytest|unittest|jest|vitest|mocha|rspec|phpunit|tsc|typecheck|lint|eslint|biome|ruff|mypy|pyright|flake8|clippy|check|build|compile|verify|validate|vet)\b/i
101
102// Whether a Bash or PowerShell command runs tests, a check or a build (any segment of it).
103export function isCheckCommand(command: string | undefined): boolean {
104 return (command ?? '').split(/&&|\|\||;|\||\n/).some(seg => {
105 const first = (seg.trim().split(/\s+/)[0] ?? '').replace(/^.*[\\/]/, '').toLowerCase()
106
107 return first !== '' && !NOT_RUNNING.has(first) && CHECK_WORDS.test(seg)
108 })
109}
110
111// Records a finished tool call. A failed edit is no edit; a failed test run still counts as a check.
112export function noteCall(work: Work, tool: string, input: Record<string, unknown>, isFailed: boolean): void {
113 work.seq += 1
114 if (EDIT_TOOLS.has(tool)) {
115 if (isFailed) {
116 return
117 }
118 const file = [input.file_path, input.notebook_path, input.path].find((p): p is string => typeof p === 'string')
119 if (file && DOC_FILE.test(file)) {
120 return
121 }
122 work.lastEdit = work.seq
123 } else if ((tool === 'Bash' || tool === 'PowerShell') && typeof input.command === 'string' && isCheckCommand(input.command)) {
124 work.lastCheck = work.seq
125 }
126}
127
128// Wording that claims done, in the opening and the end only; negated (未 沒 不 尚) claims do not count.
129// Matches Chinese and English whatever the language setting is: it reads Claude's words.
130const CLAIM =
131 /(?<![未沒不尚])(完成了|已完成|已經完成|做好了|做完了|改好了|都好了|已修好|修好了|已經修好|已修正|處理好了|搞定了?|全部完成)|\b(all done|all set|i(?:'ve| have) (?:finished|completed|fixed)|(?:is|are|now|it's) (?:done|fixed|complete(?:d)?|finished)|fixed it|everything (?:is )?(?:done|working|fixed))\b/i
132// A reply that already says what went unverified is not nudged.
133const HONEST =
134 /沒有?(?:跑|執行|做)?(?:驗證|測試|檢查)|未(?:經)?(?:驗證|測試)|無法(?:驗證|測試)|沒辦法(?:驗證|測試)|not (?:been )?(?:tested|verified)|couldn't (?:run|verify|test)|could not (?:run|verify|test)|unable to (?:run|verify|test)|haven't (?:run|tested|verified)/i
135
136export function claimsDone(message: string | undefined): boolean {
137 const text = (message ?? '').trim()
138 if (!text || /[??]$/.test(text)) {
139 return false
140 }
141 const ends = text.length <= 600 ? text : `${text.slice(0, 200)}\n${text.slice(-400)}`
142
143 return CLAIM.test(ends) && !HONEST.test(ends)
144}
145
146// The Stop block reason: the person sees it too, so it follows the language.
147export function doneCheckText(lang: Lang): string {
148 return pick(lang, {
149 en: `${TAG} You said it is done, but this turn edited code files and ran no tests, build or check afterwards. Run the relevant verification and report the result; if you cannot verify, say what was not verified.`,
150 'zh-TW': `${TAG} 你說完成了,但這一輪改了程式檔之後沒有跑任何測試、建置或檢查。請跑相關的驗證並附上結果;如果沒辦法驗證,改口說明哪些沒驗證。`,
151 })
152}
153
154// The reason to block the stop and ask for a check, at most once a turn; isStopHookActive: a Stop hook already blocked this stop.
155export function doneCheck(work: Work, message: string | undefined, isStopHookActive: boolean, lang: Lang = 'en'): string | undefined {
156 if (work.isReminded || isStopHookActive) {
157 return undefined
158 }
159 if (work.lastEdit === 0 || work.lastCheck > work.lastEdit) {
160 return undefined
161 }
162 if (!claimsDone(message)) {
163 return undefined
164 }
165 work.isReminded = true
166
167 return doneCheckText(lang)
168}
169