Catches Claude going in circles, nudges it out, and tells you when it can't.

Status: skeleton (v0.0.1). The mod loads and answers
/loop-breaker. Detection, nudges and the band arrive in later phases (plan).
Claude Code sometimes goes in circles: re-running the same failing test, flipping a file between two "fixes", retrying a command you declined, or hammering a missing tool. Loop Breaker watches every tool call and detects these loops with cheap, deterministic rules. It first nudges Claude privately with a note on the failing result. If that doesn't help, or the loop is something Claude can't fix, it shows you one band with Stop / Hint / Ignore. Otherwise it stays out of sight.
Screenshot or recording to come with Phase 5 (the band).
A Claude Code mod: a plugin of function hooks running inside the session.
| Folder | Role |
|---|---|
hooks/register.tsx | the hooks module the engine loads; it only wires events |
hooks/core/ | pure detection logic, no $ (testable in Node too) |
hooks/adapter/ | everything that talks to the engine ($) |
hooks/ui/ | band and pane drawing |
tests/ | claude plugin test suites |
From the repository root:
npm install # dev tooling only; the mod itself has no dependencies
npm run check # format, typecheck, lint, validate, test
claude --plugin-dir ./mods/loop-breaker
Then type /loop-breaker in the session.
Dev loop. A --plugin-dir folder is watched. Saving a file reloads the module: register runs again, values in $.state survive, module variables do not. Trace hooks with claude --debug-file ./mod-debug.log --plugin-dir ./mods/loop-breaker. npm run types regenerates .claude-plugin/types/ (gitignored) by loading the mod once headlessly through its own command, which needs no model turn.
npm run check after upgrading./loop-breaker. /loops is a name Claude Code ships and refuses.None at runtime. Dev tooling only: TypeScript, ESLint (typescript-eslint), Prettier.
hooks/register.tsx 228 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import {
5 DEBUG_PREFIX,
6 DEBUG_TIMING_ON,
7 ENGINE_ORIGIN,
8 ESCALATION_TOAST_MS,
9 LOOP_BREAKER_COMMAND_DESCRIPTION,
10 PERSON_PROMPT_KINDS,
11 SKELETON_REPLY,
12 TIMELINE_CAP,
13} from './adapter/constants.ts'
14import {
15 flushStats,
16 loadSettings,
17 onConversationReset,
18 onPersonPrompt,
19 onTurnComplete,
20} from './adapter/lifecycle.ts'
21import { logOnce, observeToolCall, reportOverhead } from './adapter/observer.ts'
22import type { EnginePort } from './adapter/ports.ts'
23import { settingsFrom } from './adapter/settings.ts'
24import { addStats, emptyStats } from './adapter/stats.ts'
25
26// Session state, declared in types/index.d.ts. `plugin` and `key` are quoted
27// literals in consts of this file: validate reads state references only here.
28const detectionAtom = atom({ plugin: 'loop-breaker', key: 'detection' } as const, ``)
29const alertAtom = atom({ plugin: 'loop-breaker', key: 'alert' } as const, null)
30const timelineAtom = atom({ plugin: 'loop-breaker', key: 'timeline' } as const, [])
31const mainTurnIdAtom = atom({ plugin: 'loop-breaker', key: 'mainTurnId' } as const, null)
32const pendingStatsAtom = atom(
33 { plugin: 'loop-breaker', key: 'pendingStats' } as const,
34 emptyStats(),
35)
36
37/**
38 * The engine port over `$`: every engine call Loop Breaker makes, each a
39 * literal `$.noun.method(...)` call. It lives in this file because
40 * `claude plugin validate` follows `$` only into functions declared here;
41 * the adapter modules receive this port instead of `$`.
42 */
43function portFor($: EngineInterface): EnginePort {
44 return {
45 now: () => $.clock.now(),
46 updateDetection: async change => {
47 await update($, detectionAtom, change)
48 },
49 log: text => {
50 $.ui.log(`${DEBUG_PREFIX} ${text}`, { to: `debug` })
51 },
52 toast: text => {
53 $.ui.toast(text, { timeoutMs: ESCALATION_TOAST_MS })
54 },
55 alertedSignatures: async () => {
56 const timeline = await read($, timelineAtom)
57 return new Set(timeline.map(entry => entry.signature))
58 },
59 agentName: async agentId => {
60 try {
61 const agent = (await $.agent.list()).find(candidate => candidate.id === agentId)
62 return agent === undefined ? null : (agent.name ?? agent.type)
63 } catch {
64 return null
65 }
66 },
67 setAlert: async alert => {
68 await update($, alertAtom, () => alert)
69 },
70 clearAlertFor: async signatures => {
71 await update($, alertAtom, current =>
72 current !== null && signatures.includes(current.signature) ? null : current,
73 )
74 },
75 addTimeline: async entries => {
76 if (entries.length === 0) return
77 await update($, timelineAtom, timeline => [...timeline, ...entries].slice(-TIMELINE_CAP))
78 },
79 clearTimeline: async () => {
80 await update($, timelineAtom, () => [])
81 },
82 addPendingStats: async delta => {
83 await update($, pendingStatsAtom, pending => addStats(pending, delta))
84 },
85 takePendingStats: async () => {
86 let taken = emptyStats()
87 await update($, pendingStatsAtom, pending => {
88 taken = pending
89 return emptyStats()
90 })
91 return taken
92 },
93 appendNote: async (text, agentId) => {
94 await $.session.append({
95 message: { type: `user`, content: [{ type: `text`, text }] },
96 ...(agentId === undefined ? {} : { agentId }),
97 })
98 },
99 setMainTurnId: async turnId => {
100 await update($, mainTurnIdAtom, () => turnId)
101 },
102 storeGet: key => $.store.get(key),
103 storeSet: (key, value) => $.store.set(key, value),
104 // A literal name: validate lists the variables a module reads.
105 isDebugTimingOn: async () => (await $.env.get('LOOP_BREAKER_DEBUG')) === DEBUG_TIMING_ON,
106 }
107}
108
109/**
110 * Loop Breaker's hooks module: the only entry point the engine loads.
111 *
112 * It wires engine events to the adapter layer and holds no logic of its own
113 * beyond the engine port above. Detection lives in `core/` (pure, no `$`),
114 * orchestration in `adapter/` (through the port, never `$`), and drawing in
115 * `ui/`. This keeps the core testable in Node and keeps every `$` call
116 * literal, which `claude plugin validate` relies on.
117 *
118 * Every hook here is fail-open: its own work is wrapped so that an error is
119 * logged once (debug log) and the engine's behaviour goes on unchanged. Loop
120 * Breaker never denies a call, never rewrites an input, and changes a result
121 * only by appending its nudge for the model.
122 *
123 * Engine-analysed literals: the command's registration `name` and the
124 * `command.run` matcher are plain quoted strings ('loop-breaker'), not template
125 * literals or constants, which deliberately breaks the backtick string style.
126 * `claude plugin validate` only recognises a hook that answers its own
127 * command when both are quoted literals; otherwise it treats the hook as a
128 * gate on every command (Phase 1 spike finding). `LOOP_BREAKER_COMMAND` in
129 * `adapter/constants.ts` mirrors the name.
130 */
131export const register: Register = (on, options) => {
132 // Re-read on every load: a `/config` change reloads the module with new options.
133 let settings = settingsFrom(options)
134
135 on(`session.start`, async ($, e, next) => {
136 const port = portFor($)
137 try {
138 await $.command.register({
139 // Quoted literal, not a template or constant: required by validate (see above).
140 name: 'loop-breaker',
141 description: LOOP_BREAKER_COMMAND_DESCRIPTION,
142 immediate: true,
143 })
144 } catch (error) {
145 logOnce(port, `command registration`, error)
146 }
147 try {
148 settings = await loadSettings(port, settingsFrom(options))
149 } catch (error) {
150 logOnce(port, `session.start`, error)
151 }
152 return next(e)
153 })
154
155 // `/clear`, a resume or a fork starts a new conversation without a new `session.start`.
156 on(`classic.SessionStart`, { source: [`clear`, `resume`, `fork`] }, async ($, e, next) => {
157 const port = portFor($)
158 try {
159 await onConversationReset(port)
160 } catch (error) {
161 logOnce(port, `classic.SessionStart`, error)
162 }
163 return next(e)
164 })
165
166 // One store write, nothing slow: `session.end` hooks share a 1.5 s budget.
167 on(`session.end`, async ($, e, next) => {
168 const port = portFor($)
169 try {
170 if (settings.isTimingOn) reportOverhead(port)
171 await flushStats(port)
172 } catch (error) {
173 logOnce(port, `session.end`, error)
174 }
175 return next(e)
176 })
177
178 // The person's own prompt resets every streak; a notification or a peer's message does not.
179 // Observing only: the prompt goes in first, and a dropped prompt resets nothing (review I1).
180 on(`prompt.submit`, async ($, e, next) => {
181 const entered = await next(e)
182 if (entered.drop === undefined && PERSON_PROMPT_KINDS.has(e.origin.kind)) {
183 const port = portFor($)
184 try {
185 await onPersonPrompt(port)
186 } catch (error) {
187 logOnce(port, `prompt.submit`, error)
188 }
189 }
190 return entered
191 })
192
193 on(`turn.start`, async ($, e, next) => {
194 const port = portFor($)
195 try {
196 await port.setMainTurnId(e.turnId)
197 } catch (error) {
198 logOnce(port, `turn.start`, error)
199 }
200 return next(e)
201 })
202
203 // The main loop's turn only: a subagent's turn ending neither frees the budget nor flushes.
204 on(`turn.complete`, async ($, e, next) => {
205 const completed = await next(e)
206 if (e.agentId === undefined) {
207 const port = portFor($)
208 try {
209 await onTurnComplete(port)
210 } catch (error) {
211 logOnce(port, `turn.complete`, error)
212 }
213 }
214 return completed
215 })
216
217 // The observer: the tool always runs first; only the model's own calls are counted (R10).
218 on(`tool.call`, async ($, e, next) => {
219 if (next.origin.plugin !== ENGINE_ORIGIN) return next(e)
220 const startedAt = await $.clock.now()
221 const ran = await next(e)
222 return observeToolCall(portFor($), e, ran, startedAt, settings)
223 }).catch((_$, e, next) => next(e))
224
225 // Quoted literal matcher, as above. It must equal the registered name.
226 on(`command.run`, { command: 'loop-breaker' }, () => ({ text: SKELETON_REPLY }))
227}
228hooks/adapter/constants.ts 62 lines1/**
2 * Names, keys and limits the adapter layer shares with the engine and the
3 * tests, kept in one module so no literal is repeated.
4 */
5import type { PromptOrigin } from 'claude-code'
6
7/**
8 * The slash command Loop Breaker serves, without its slash.
9 *
10 * `register.tsx` must spell this name as a plain quoted literal (see the note
11 * there), so this constant mirrors it for tests and messages. Keep the two
12 * equal. `loops` is not used: Claude Code ships that name and refuses it.
13 */
14export const LOOP_BREAKER_COMMAND = `loop-breaker`
15
16/** The one line the typeahead and `/help` show for {@link LOOP_BREAKER_COMMAND}. */
17export const LOOP_BREAKER_COMMAND_DESCRIPTION = `Loop Breaker: show detected loops, stats and controls`
18
19/** What the command answers until the pane and stats land (Phase 5). */
20export const SKELETON_REPLY = `Loop Breaker is active (skeleton).`
21
22/** `next.origin.plugin` of a call the model made (spike R10); other mods' calls are not counted. */
23export const ENGINE_ORIGIN = `engine`
24
25/** Store key of the cross-session counts (versioned; counts only, never content). */
26export const STATS_STORE_KEY = `stats.v1`
27
28/** Store key of the substrings the person chose to always ignore (versioned). */
29export const IGNORE_STORE_KEY = `ignorePatterns.v1`
30
31/** Newest timeline entries kept in session state. */
32export const TIMELINE_CAP = 100
33
34/** How long the escalation toast stays, in milliseconds. */
35export const ESCALATION_TOAST_MS = 8000
36
37/** Prefix of every debug-log line, so `claude --debug` output is easy to filter. */
38export const DEBUG_PREFIX = `loop-breaker:`
39
40/**
41 * Prompt origins that are the person's own: their prompt resets every streak.
42 * A task notification, a peer's message or a schedule is not the person engaging.
43 */
44export const PERSON_PROMPT_KINDS: ReadonlySet<PromptOrigin[`kind`]> = new Set([
45 `composer`,
46 `bridge`,
47 `sdk`,
48 `slack-ping`,
49])
50
51/** Longest error message written to the debug log when a hook's own work fails (no call content). */
52export const ERROR_TEXT_CAP = 200
53
54/** The value of `LOOP_BREAKER_DEBUG` that turns on overhead timing (the name is a literal in `register.tsx`). */
55export const DEBUG_TIMING_ON = `1`
56
57/** Observed calls between two overhead reports when debug timing is on. */
58export const TIMING_REPORT_EVERY = 100
59
60/** Percentiles of the overhead report. */
61export const TIMING_PERCENTILES = [0.5, 0.95] as const
62hooks/adapter/lifecycle.ts 76 lines1/**
2 * What Loop Breaker does at the session's and the turn's edges: load the
3 * person's stored choices, reset streaks when the person speaks, reset the
4 * per-turn nudge budget, and flush counts to the store. Every function takes
5 * the engine port, never `$`.
6 */
7import { processTurnEnd, processUserPrompt } from '../core/pipeline.ts'
8import { emptyDetectionState } from '../core/window.ts'
9import { IGNORE_STORE_KEY, STATS_STORE_KEY } from './constants.ts'
10import type { EnginePort } from './ports.ts'
11import type { Settings } from './settings.ts'
12import { withStoredIgnores } from './settings.ts'
13import { parseDetection, serializeDetection } from './state.ts'
14import { addStats, hasCounts, statsFrom } from './stats.ts'
15
16/**
17 * Settings with the store's ignore list and the debug-timing switch read.
18 * Overlong `ignoreCommands` entries are reported once in the debug log (ER-5).
19 */
20export async function loadSettings(port: EnginePort, settings: Settings): Promise<Settings> {
21 if (settings.rejectedIgnoreCount > 0) {
22 port.log(
23 `ignored ${String(settings.rejectedIgnoreCount)} overlong ignoreCommands entries (set in /config)`,
24 )
25 }
26 const stored = await port.storeGet(IGNORE_STORE_KEY)
27 return { ...withStoredIgnores(settings, stored), isTimingOn: await port.isDebugTimingOn() }
28}
29
30/** The person spoke: every loop's streak starts over and the alert is taken down. */
31export async function onPersonPrompt(port: EnginePort): Promise<void> {
32 const now = await port.now()
33 await port.updateDetection(json =>
34 serializeDetection(processUserPrompt(parseDetection(json), now)),
35 )
36 await port.setAlert(null)
37}
38
39/**
40 * Adds the session's pending counts to the store's. The store is shared by
41 * every session on the machine and get-then-set is not atomic, so the key is
42 * read right before the write; a rare lost increment is accepted (the counts
43 * are informational). Counts taken but not written are put back for the next flush.
44 */
45export async function flushStats(port: EnginePort): Promise<void> {
46 const taken = await port.takePendingStats()
47 if (!hasCounts(taken)) return
48 try {
49 const stored = statsFrom(await port.storeGet(STATS_STORE_KEY))
50 await port.storeSet(STATS_STORE_KEY, addStats(stored, taken))
51 } catch (error) {
52 await port.addPendingStats(taken)
53 throw error
54 }
55}
56
57/** The main turn ended: the per-turn nudge budget resets and the counts are flushed. */
58export async function onTurnComplete(port: EnginePort): Promise<void> {
59 await port.updateDetection(json => serializeDetection(processTurnEnd(parseDetection(json))))
60 await port.setMainTurnId(null)
61 await flushStats(port)
62}
63
64/**
65 * A new conversation in the same process (`/clear`, a resume, a fork): no
66 * `session.start` fires, so detection, the alert and the timeline start over
67 * here, whether or not the host kept `$.state` (spike R9 pending). Counts
68 * were flushed by `session.end` just before.
69 */
70export async function onConversationReset(port: EnginePort): Promise<void> {
71 await port.updateDetection(() => serializeDetection(emptyDetectionState()))
72 await port.setAlert(null)
73 await port.clearTimeline()
74 await port.setMainTurnId(null)
75}
76hooks/adapter/observer.ts 168 lines1/**
2 * The tool-call observer: runs after the engine answered a call, feeds the
3 * core, carries out its decisions, and hands the model's result back with
4 * nothing changed but an appended nudge.
5 *
6 * Fail-open is the rule: any error in here is logged once and the engine's
7 * result is returned as it came. The observer never denies a call and never
8 * rewrites its input (in auto mode a rewritten input is denied).
9 *
10 * The nudge comes first. It is built from the decisions before anything else
11 * is awaited, and no later failure (the timeline, the alert, the counts, a
12 * refusal's note) can cost the model its note (Phase 4 review, I2).
13 */
14import type { ToolCallInput, ToolCallResult } from 'claude-code'
15
16import { MAIN_LOOP } from '../core/constants.ts'
17import { processRecord, recordFor } from '../core/pipeline.ts'
18import type { PipelineStep } from '../core/pipeline.ts'
19import type { Decision } from '../core/types.ts'
20import { ERROR_TEXT_CAP, TIMING_PERCENTILES, TIMING_REPORT_EVERY } from './constants.ts'
21import { observationOf } from './observation.ts'
22import type { EffectsPlan } from './plan.ts'
23import { planFor } from './plan.ts'
24import type { EnginePort } from './ports.ts'
25import type { Settings } from './settings.ts'
26import type { DetectionRead } from './state.ts'
27import { readDetection, serializeDetection } from './state.ts'
28
29/** Failures already logged in this load: each kind is logged once, not per call. */
30const loggedFailures = new Set<string>()
31
32/**
33 * Overheads measured since the last report, in milliseconds (debug timing
34 * only). Measured from the result's arrival to the observer's return: the one
35 * clock read before the tool runs is not included.
36 */
37const overheads: number[] = []
38
39/** Logs a failure of `site` once per load, without any call content. */
40export function logOnce(port: EnginePort, site: string, error: unknown): void {
41 if (loggedFailures.has(site)) return
42 loggedFailures.add(site)
43 const reason =
44 error instanceof Error ? `${error.name}: ${error.message.slice(0, ERROR_TEXT_CAP)}` : `unknown`
45 port.log(`${site} failed and was skipped (fail-open): ${reason}`)
46}
47
48/** The value at fraction `percentile` of sorted `values`. */
49function percentileOf(values: readonly number[], percentile: number): number {
50 const sorted = [...values].sort((a, b) => a - b)
51 return sorted[Math.min(sorted.length - 1, Math.floor(percentile * sorted.length))] ?? 0
52}
53
54/** Logs the percentiles of the overheads measured so far, then starts over (debug timing only). */
55export function reportOverhead(port: EnginePort): void {
56 if (overheads.length === 0) return
57 const report = TIMING_PERCENTILES.map(
58 percentile => `p${String(percentile * 100)} ${String(percentileOf(overheads, percentile))} ms`,
59 ).join(`, `)
60 port.log(`observer overhead over ${String(overheads.length)} calls: ${report}`)
61 overheads.length = 0
62}
63
64/** Records one call's overhead; every `TIMING_REPORT_EVERY` calls, logs the percentiles. */
65async function recordOverhead(port: EnginePort, since: number): Promise<void> {
66 overheads.push((await port.now()) - since)
67 if (overheads.length >= TIMING_REPORT_EVERY) reportOverhead(port)
68}
69
70/** The notes for the model among the decisions. */
71function nudgesOf(decisions: readonly Decision[]): readonly string[] {
72 return decisions.flatMap(decision => (decision.kind === `nudge` ? [decision.text] : []))
73}
74
75/**
76 * The result with the nudges attached for the model: appended to `context`
77 * (spike R1), or, for a refused call whose result takes none, appended as a
78 * row of its own (spike R1b). A failed append is logged; the refusal itself
79 * is returned as it came either way.
80 */
81async function withNudges(
82 ran: ToolCallResult,
83 nudges: readonly string[],
84 port: EnginePort,
85 agentId: string | undefined,
86): Promise<ToolCallResult> {
87 if (nudges.length === 0) return ran
88 if (ran.deny === undefined) return { ...ran, context: [...(ran.context ?? []), ...nudges] }
89 try {
90 for (const note of nudges) await port.appendNote(note, agentId)
91 } catch (error) {
92 logOnce(port, `refusal note`, error)
93 }
94 return ran
95}
96
97/** Carries out a plan's alert, timeline and counts. */
98async function carryOut(port: EnginePort, plan: EffectsPlan, loopId: string): Promise<void> {
99 const { escalation } = plan
100 if (escalation !== undefined) {
101 const agentName = loopId === MAIN_LOOP ? null : await port.agentName(loopId)
102 await port.setAlert({ ...escalation, agentName })
103 // The engine shows a toast under the plugin's name already.
104 port.toast(escalation.title)
105 } else if (plan.resolved.length > 0) {
106 await port.clearAlertFor(plan.resolved)
107 }
108 await port.addTimeline(plan.timeline)
109 await port.addPendingStats(plan.stats)
110}
111
112/** Signatures that already alerted, or none when the timeline can't be read. */
113async function alertedOrNone(port: EnginePort): Promise<ReadonlySet<string>> {
114 try {
115 return await port.alertedSignatures()
116 } catch (error) {
117 logOnce(port, `timeline read`, error)
118 return new Set()
119 }
120}
121
122/**
123 * Observes one finished tool call of the model's. Never throws: on any error
124 * the engine's result is returned unchanged.
125 *
126 * @param startedAt when the call was issued (read before `next`), so calls
127 * issued together count as one attempt
128 */
129export async function observeToolCall(
130 port: EnginePort,
131 e: ToolCallInput,
132 ran: ToolCallResult,
133 startedAt: number,
134 settings: Settings,
135): Promise<ToolCallResult> {
136 try {
137 const at = await port.now()
138 // Built once, outside the compare-and-swap below: hashing a large file is never repeated.
139 const record = recordFor(observationOf(e, ran, { startedAt, at }), settings.config)
140 if (record === undefined) return ran
141 let step: PipelineStep | undefined
142 let read: DetectionRead | undefined
143 await port.updateDetection(json => {
144 read = readDetection(json)
145 step = processRecord(read.state, record, settings.config)
146 return serializeDetection(step.state)
147 })
148 if (read?.wasDiscarded === true)
149 port.log(`stored detection state was unreadable and started over`)
150 const decisions = step?.decisions ?? []
151 if (decisions.length === 0) {
152 if (settings.isTimingOn) await recordOverhead(port, at)
153 return ran
154 }
155 const answered = await withNudges(ran, nudgesOf(decisions), port, e.agentId)
156 try {
157 await carryOut(port, planFor(decisions, at, await alertedOrNone(port)), record.loopId)
158 } catch (error) {
159 logOnce(port, `decision effects`, error)
160 }
161 if (settings.isTimingOn) await recordOverhead(port, at)
162 return answered
163 } catch (error) {
164 logOnce(port, `tool.call observer`, error)
165 return ran
166 }
167}
168hooks/adapter/ports.ts 45 lines1/**
2 * The engine port: everything the adapter needs from Claude Code, as plain
3 * functions. `register.tsx` implements it with literal `$` calls, because
4 * `claude plugin validate` follows `$` only into functions of that file,
5 * never across an import. Every other adapter module takes this port, so it
6 * never sees `$` and tests can hand it a fake.
7 */
8import type {
9 LoopBreakerAlert,
10 LoopBreakerStats,
11 LoopBreakerTimelineEntry,
12} from '../../types/index.d.ts'
13
14/** What the adapter does to, and reads from, the engine. */
15export interface EnginePort {
16 /** The time now, in milliseconds (`$.clock.now`). */
17 readonly now: () => Promise<number>
18 /** Reads the detection JSON, applies `change`, writes it back (retried on a version miss). */
19 readonly updateDetection: (change: (json: string) => string) => Promise<void>
20 /** A line in the debug log, never the transcript. */
21 readonly log: (text: string) => void
22 /** A toast under the plugin's name. */
23 readonly toast: (text: string) => void
24 /** Signatures that already alerted this session, from the timeline. */
25 readonly alertedSignatures: () => Promise<ReadonlySet<string>>
26 /** A subagent's name or type, best effort: `null` when unknown. */
27 readonly agentName: (agentId: string) => Promise<string | null>
28 readonly setAlert: (alert: LoopBreakerAlert | null) => Promise<void>
29 /** Takes the alert down when it is about one of `signatures`. */
30 readonly clearAlertFor: (signatures: readonly string[]) => Promise<void>
31 readonly addTimeline: (entries: readonly LoopBreakerTimelineEntry[]) => Promise<void>
32 /** Empties the timeline: a new conversation (`/clear`) starts its own. */
33 readonly clearTimeline: () => Promise<void>
34 readonly addPendingStats: (delta: LoopBreakerStats) => Promise<void>
35 /** Takes the pending counts, leaving zeros behind (one atomic swap). */
36 readonly takePendingStats: () => Promise<LoopBreakerStats>
37 /** A note for the model on its own row: for a refused call, whose result takes no context. */
38 readonly appendNote: (text: string, agentId: string | undefined) => Promise<void>
39 readonly setMainTurnId: (turnId: string | null) => Promise<void>
40 readonly storeGet: (key: string) => Promise<unknown>
41 readonly storeSet: (key: string, value: unknown) => Promise<void>
42 /** Whether `LOOP_BREAKER_DEBUG=1` asks for overhead timing in the debug log. */
43 readonly isDebugTimingOn: () => Promise<boolean>
44}
45hooks/adapter/settings.ts 46 lines1/**
2 * What the person configured: the `userConfig` options (`register(on,
3 * options)`) plus the substrings they chose to always ignore, kept in the
4 * store. Resolved once per load; a `/config` change reloads the module.
5 */
6import type { PluginOptions } from 'claude-code'
7
8import { parseIgnoreList, resolveConfig } from '../core/config.ts'
9import type { DetectorConfig } from '../core/config.ts'
10import { IGNORE_ENTRY_MAX_COUNT } from '../core/constants.ts'
11
12/** Everything the hooks read about the person's choices. */
13export interface Settings {
14 readonly config: DetectorConfig
15 /** Whether overhead timing goes to the debug log (`LOOP_BREAKER_DEBUG`). */
16 readonly isTimingOn: boolean
17 /** `ignoreCommands` entries dropped as overlong: reported once in the debug log (ER-5). */
18 readonly rejectedIgnoreCount: number
19}
20
21/** Settings from the manifest options alone. */
22export function settingsFrom(options: PluginOptions): Settings {
23 return {
24 config: resolveConfig(options),
25 isTimingOn: false,
26 rejectedIgnoreCount: parseIgnoreList(options.ignoreCommands).rejected.length,
27 }
28}
29
30/**
31 * Settings with the store's ignore list added. Whatever the store holds is
32 * checked: only strings count, overlong ones are dropped, at most
33 * `IGNORE_ENTRY_MAX_COUNT`. A damaged value adds nothing.
34 */
35export function withStoredIgnores(settings: Settings, stored: unknown): Settings {
36 if (!Array.isArray(stored)) return settings
37 const text = stored
38 .filter((entry): entry is string => typeof entry === `string` && !entry.includes(`,`))
39 .slice(0, IGNORE_ENTRY_MAX_COUNT)
40 .join(`,`)
41 const extra = parseIgnoreList(text).entries
42 if (extra.length === 0) return settings
43 const ignoreCommands = [...new Set([...settings.config.ignoreCommands, ...extra])]
44 return { ...settings, config: { ...settings.config, ignoreCommands } }
45}
46hooks/adapter/stats.ts 93 lines1/**
2 * Effectiveness counts: how often Loop Breaker nudged, escalated and
3 * stopped, and how often a nudge resolved the loop. Counts only, never a
4 * command, an output or a file (non-negotiable: nothing persisted past the
5 * session but numbers). Pure functions; the store access is in `lifecycle.ts`.
6 */
7import type { LoopBreakerStats } from '../../types/index.d.ts'
8
9/** Counts that are all zero. */
10export function emptyStats(): LoopBreakerStats {
11 return {
12 loopsDetected: 0,
13 nudges: 0,
14 nudgesResolved: 0,
15 escalations: 0,
16 stops: 0,
17 ignores: 0,
18 byDetector: {},
19 }
20}
21
22/** A number field of an unknown record, or 0. */
23function countOf(value: unknown, key: string): number {
24 if (typeof value !== `object` || value === null) return 0
25 const field = (value as Readonly<Record<string, unknown>>)[key]
26 return typeof field === `number` && Number.isFinite(field) && field >= 0 ? field : 0
27}
28
29/**
30 * Counts read back from the store, whatever was there: unknown or damaged
31 * fields count as 0, so a bad value can never break a flush.
32 */
33export function statsFrom(value: unknown): LoopBreakerStats {
34 const byDetector: Record<string, { nudges: number; escalations: number }> = {}
35 const rawByDetector =
36 typeof value === `object` && value !== null
37 ? (value as Readonly<Record<string, unknown>>).byDetector
38 : undefined
39 if (typeof rawByDetector === `object` && rawByDetector !== null) {
40 for (const [detector, counts] of Object.entries(rawByDetector)) {
41 byDetector[detector] = {
42 nudges: countOf(counts, `nudges`),
43 escalations: countOf(counts, `escalations`),
44 }
45 }
46 }
47 return {
48 loopsDetected: countOf(value, `loopsDetected`),
49 nudges: countOf(value, `nudges`),
50 nudgesResolved: countOf(value, `nudgesResolved`),
51 escalations: countOf(value, `escalations`),
52 stops: countOf(value, `stops`),
53 ignores: countOf(value, `ignores`),
54 byDetector,
55 }
56}
57
58/** The sum of two sets of counts. */
59export function addStats(base: LoopBreakerStats, delta: LoopBreakerStats): LoopBreakerStats {
60 const byDetector: Record<string, { nudges: number; escalations: number }> = {
61 ...base.byDetector,
62 }
63 for (const [detector, counts] of Object.entries(delta.byDetector)) {
64 const current = byDetector[detector] ?? { nudges: 0, escalations: 0 }
65 byDetector[detector] = {
66 nudges: current.nudges + counts.nudges,
67 escalations: current.escalations + counts.escalations,
68 }
69 }
70 return {
71 loopsDetected: base.loopsDetected + delta.loopsDetected,
72 nudges: base.nudges + delta.nudges,
73 nudgesResolved: base.nudgesResolved + delta.nudgesResolved,
74 escalations: base.escalations + delta.escalations,
75 stops: base.stops + delta.stops,
76 ignores: base.ignores + delta.ignores,
77 byDetector,
78 }
79}
80
81/** Whether any count is above zero (nothing to flush otherwise). */
82export function hasCounts(stats: LoopBreakerStats): boolean {
83 return (
84 stats.loopsDetected +
85 stats.nudges +
86 stats.nudgesResolved +
87 stats.escalations +
88 stats.stops +
89 stats.ignores >
90 0
91 )
92}
93hooks/core/pipeline.ts 128 lines1/**
2 * The core's entry points: what the adapter calls on each engine event.
3 * Each is a pure function from the detection state (one `$.state` value) to
4 * the next state plus the decisions to carry out.
5 */
6import { NEVER_COUNTED_TOOLS } from './constants.ts'
7import type { DetectorConfig } from './config.ts'
8import { DETECTORS } from './detectors/index.ts'
9import { toRecord } from './fingerprint.ts'
10import { tailOf } from './normalize.ts'
11import {
12 applyRecord,
13 applySignal,
14 applyTurnEnd,
15 applyUserPrompt,
16 ignoreSignature,
17 pruneLoops,
18 setPaused,
19} from './policy.ts'
20import type { CallRecord, Decision, DetectionState, ToolObservation } from './types.ts'
21import { appendRecord, resetAllForUserPrompt, windowOf, withLoop } from './window.ts'
22
23/** The next state and what the adapter should do about it. */
24export interface PipelineStep {
25 readonly state: DetectionState
26 readonly decisions: readonly Decision[]
27 /** The record built for the observation, when it was counted. */
28 readonly record?: CallRecord
29}
30
31/** Whether one of the user's `ignoreCommands` substrings appears in the call's command or label. */
32function matchesIgnoreList(
33 record: CallRecord,
34 observation: ToolObservation,
35 config: DetectorConfig,
36): boolean {
37 if (config.ignoreCommands.length === 0) return false
38 const command =
39 typeof observation.input.command === `string` ? tailOf(observation.input.command) : ``
40 const haystacks = [command.toLowerCase(), record.label.toLowerCase()]
41 return config.ignoreCommands.some(entry => haystacks.some(haystack => haystack.includes(entry)))
42}
43
44/** Whether an observation stays out of detection entirely. */
45function isUncounted(observation: ToolObservation): boolean {
46 return (
47 NEVER_COUNTED_TOOLS.has(observation.tool) ||
48 observation.outcome === `interrupted` ||
49 observation.outcome === `background`
50 )
51}
52
53/**
54 * The record for one finished tool call, or `undefined` when the call stays
55 * out of detection (never-counted tools, interrupts, background launches,
56 * the ignore list). Pure and independent of the state, so the adapter builds
57 * it once, outside its state compare-and-swap, however often that retries.
58 */
59export function recordFor(
60 observation: ToolObservation,
61 config: DetectorConfig,
62): CallRecord | undefined {
63 if (isUncounted(observation)) return undefined
64 const record = toRecord(observation)
65 return matchesIgnoreList(record, observation, config) ? undefined : record
66}
67
68/**
69 * Processes one record: append it to its loop's window, resolve the
70 * signatures it breaks, run the detectors (first in precedence wins) and
71 * apply the policy.
72 */
73export function processRecord(
74 state: DetectionState,
75 record: CallRecord,
76 config: DetectorConfig,
77): PipelineStep {
78 const window = appendRecord(windowOf(state, record.loopId, record.at), record)
79 const withWindow = withLoop(state, record.loopId, window)
80 const livePolicy = pruneLoops(withWindow.policy, new Set(Object.keys(withWindow.loops)))
81 const resolved = applyRecord(livePolicy, record)
82
83 const signal = DETECTORS.map(detector => detector.detect(window, record, config)).find(
84 found => found !== undefined,
85 )
86 const applied =
87 signal === undefined
88 ? { policy: resolved.policy, decisions: [] }
89 : applySignal(resolved.policy, signal, config, record.at)
90
91 return {
92 state: { ...withWindow, policy: applied.policy },
93 decisions: [...resolved.decisions, ...applied.decisions],
94 record,
95 }
96}
97
98/** Processes one finished tool call: `recordFor`, then `processRecord`. */
99export function processObservation(
100 state: DetectionState,
101 observation: ToolObservation,
102 config: DetectorConfig,
103): PipelineStep {
104 const record = recordFor(observation, config)
105 return record === undefined ? { state, decisions: [] } : processRecord(state, record, config)
106}
107
108/** A prompt from the person: every loop's streaks start over. */
109export function processUserPrompt(state: DetectionState, now: number): DetectionState {
110 const reset = resetAllForUserPrompt(state, now)
111 return { ...reset, policy: applyUserPrompt(reset.policy) }
112}
113
114/** The turn ended: the per-turn nudge budget resets. */
115export function processTurnEnd(state: DetectionState): DetectionState {
116 return { ...state, policy: applyTurnEnd(state.policy) }
117}
118
119/** The person pressed Ignore on a signature. */
120export function processIgnore(state: DetectionState, signature: string): DetectionState {
121 return { ...state, policy: ignoreSignature(state.policy, signature) }
122}
123
124/** `/loop-breaker off|on`. */
125export function processPause(state: DetectionState, isPaused: boolean): DetectionState {
126 return { ...state, policy: setPaused(state.policy, isPaused) }
127}
128hooks/core/window.ts 139 lines1/**
2 * Loop windows: the bounded, per-loop record of calls since the last user
3 * prompt, plus each loop's file edit histories. Everything is plain data with
4 * pure updates, so the whole detection state round-trips through `$.state`
5 * (it survives hot reloads) and through JSON.
6 */
7import {
8 DETECTION_STATE_VERSION,
9 MAIN_LOOP,
10 MAX_LOOPS,
11 MAX_TRACKED_FILES,
12 WINDOW_CAP,
13} from './constants.ts'
14import { emptyFileHistory, recordFileChange } from './file-tracker.ts'
15import type {
16 CallRecord,
17 DetectionState,
18 FileHistory,
19 LoopId,
20 LoopWindow,
21 PolicyState,
22} from './types.ts'
23
24/** A loop with nothing recorded yet. */
25export function emptyWindow(now: number): LoopWindow {
26 return { records: [], files: {}, touchedAt: now }
27}
28
29/** A policy that remembers nothing. */
30export function emptyPolicy(): PolicyState {
31 return { signatures: {}, nudgesThisTurn: 0, covered: {}, ignored: [], isPaused: false }
32}
33
34/** The state a session starts with. */
35export function emptyDetectionState(): DetectionState {
36 return { version: DETECTION_STATE_VERSION, loops: {}, policy: emptyPolicy() }
37}
38
39/** Drops the least recently touched entries beyond `limit`. */
40function keepMostRecent<T extends { readonly touchedAt: number }>(
41 entries: Readonly<Record<string, T>>,
42 limit: number,
43): Readonly<Record<string, T>> {
44 const keys = Object.keys(entries)
45 if (keys.length <= limit) return entries
46 const kept = Object.entries(entries)
47 .sort(([, a], [, b]) => b.touchedAt - a.touchedAt)
48 .slice(0, limit)
49 return Object.fromEntries(kept)
50}
51
52/** Updates the edit history of the record's file, when it changed one. */
53function withFileChange(
54 window: LoopWindow,
55 record: CallRecord,
56): Readonly<Record<string, FileHistory>> {
57 if (!record.isEdit || record.file === undefined) return window.files
58 const history = window.files[record.file] ?? emptyFileHistory(record.at)
59 const files = { ...window.files, [record.file]: recordFileChange(history, record) }
60 return keepMostRecent(files, MAX_TRACKED_FILES)
61}
62
63/** Appends a record (oldest dropped past `WINDOW_CAP`) and updates file histories. */
64export function appendRecord(window: LoopWindow, record: CallRecord): LoopWindow {
65 const records = [...window.records, record]
66 return {
67 records: records.length > WINDOW_CAP ? records.slice(-WINDOW_CAP) : records,
68 files: withFileChange(window, record),
69 touchedAt: record.at,
70 }
71}
72
73/**
74 * A fresh window for a new user prompt: streaks, cycles and edit histories
75 * start over (prior-art consensus: detection scope is since the last user message).
76 */
77export function resetForUserPrompt(_window: LoopWindow, now: number): LoopWindow {
78 return emptyWindow(now)
79}
80
81/** The window of `loopId`, or an empty one. */
82export function windowOf(state: DetectionState, loopId: LoopId, now: number): LoopWindow {
83 return state.loops[loopId] ?? emptyWindow(now)
84}
85
86/** Whether a window holds a failure or denial: a streak that eviction would erase. */
87function hasFailure(window: LoopWindow): boolean {
88 return window.records.some(record => record.outcome === `error` || record.outcome === `denied`)
89}
90
91/**
92 * The subagent windows to keep: those with failures first, then the most
93 * recently touched. Plain least-recently-used eviction thrashes when more
94 * subagents than slots take turns (a 9-way fan-out evicts each one just
95 * before its next call), and a looping subagent would never build a streak.
96 */
97function keepSubagents(
98 subagents: Readonly<Record<LoopId, LoopWindow>>,
99 limit: number,
100): Readonly<Record<LoopId, LoopWindow>> {
101 const entries = Object.entries(subagents)
102 if (entries.length <= limit) return subagents
103 const kept = entries
104 .sort(
105 ([, a], [, b]) => Number(hasFailure(b)) - Number(hasFailure(a)) || b.touchedAt - a.touchedAt,
106 )
107 .slice(0, limit)
108 return Object.fromEntries(kept)
109}
110
111/**
112 * Stores a loop's window, evicting subagent loops beyond `MAX_LOOPS` (healthy,
113 * least recently touched ones first). The main loop is never evicted: many
114 * short-lived subagents must not erase the main conversation's streak.
115 */
116export function withLoop(
117 state: DetectionState,
118 loopId: LoopId,
119 window: LoopWindow,
120): DetectionState {
121 const { [MAIN_LOOP]: main, ...subagents } = { ...state.loops, [loopId]: window }
122 const keptSubagents = keepSubagents(subagents, main === undefined ? MAX_LOOPS : MAX_LOOPS - 1)
123 return {
124 ...state,
125 loops: main === undefined ? keptSubagents : { [MAIN_LOOP]: main, ...keptSubagents },
126 }
127}
128
129/** Every loop window reset for a new user prompt. */
130export function resetAllForUserPrompt(state: DetectionState, now: number): DetectionState {
131 const loops = Object.fromEntries(
132 Object.entries(state.loops).map(([loopId, window]) => [
133 loopId,
134 resetForUserPrompt(window, now),
135 ]),
136 )
137 return { ...state, loops }
138}
139hooks/adapter/state.ts 63 lines1/**
2 * The detection core's state as Loop Breaker keeps it in `$.state`: one JSON
3 * string, so the observer makes a single write per tool call (spike R7), and
4 * so a value written by an older build that no longer parses is replaced by
5 * a fresh state instead of breaking detection (fail-safe).
6 *
7 * The `$.state` atoms themselves are declared in `register.tsx`: `claude
8 * plugin validate` reads a state reference only from a const of that file.
9 */
10import { DETECTION_STATE_VERSION } from '../core/constants.ts'
11import type { DetectionState } from '../core/types.ts'
12import { emptyDetectionState } from '../core/window.ts'
13
14/** Whether a value is a plain object. */
15function isObject(value: unknown): value is Readonly<Record<string, unknown>> {
16 return typeof value === `object` && value !== null && !Array.isArray(value)
17}
18
19/** Whether parsed JSON has the detection state's outline (written by this build). */
20function isDetectionState(value: unknown): value is DetectionState {
21 if (!isObject(value) || value.version !== DETECTION_STATE_VERSION || !isObject(value.loops))
22 return false
23 const { policy } = value
24 return (
25 isObject(policy) &&
26 isObject(policy.signatures) &&
27 isObject(policy.covered) &&
28 Array.isArray(policy.ignored) &&
29 typeof policy.nudgesThisTurn === `number` &&
30 typeof policy.isPaused === `boolean`
31 )
32}
33
34/** The detection state read back, and whether a stored value had to be thrown away. */
35export interface DetectionRead {
36 readonly state: DetectionState
37 /** A non-empty value that did not parse or was of another version (it is logged once). */
38 readonly wasDiscarded: boolean
39}
40
41/** The detection state stored as JSON, or a fresh one when it is absent or unreadable. */
42export function readDetection(json: string): DetectionRead {
43 if (json === ``) return { state: emptyDetectionState(), wasDiscarded: false }
44 try {
45 const value: unknown = JSON.parse(json)
46 return isDetectionState(value)
47 ? { state: value, wasDiscarded: false }
48 : { state: emptyDetectionState(), wasDiscarded: true }
49 } catch {
50 return { state: emptyDetectionState(), wasDiscarded: true }
51 }
52}
53
54/** The detection state stored as JSON (see `readDetection`). */
55export function parseDetection(json: string): DetectionState {
56 return readDetection(json).state
57}
58
59/** The detection state as stored. */
60export function serializeDetection(state: DetectionState): string {
61 return JSON.stringify(state)
62}
63hooks/core/constants.ts 109 lines1/**
2 * Limits and lists the detection core shares. Every number here was chosen in
3 * the plan (phase-2.md) from prior art; Phase 3 calibrates the thresholds in
4 * `config.ts`, not these structural limits.
5 */
6
7/** The loop id of the main conversation; subagents use their `agentId`. */
8export const MAIN_LOOP = `main`
9
10/** The detection state's format version; a state of another version is replaced, not read. */
11export const DETECTION_STATE_VERSION = 1
12
13/** Calls kept per loop, since the last user prompt (OpenHands 20, OpenClaw 30). */
14export const WINDOW_CAP = 30
15
16/** Loops tracked at once; beyond this, healthy subagent loops are evicted, least recently touched first. */
17export const MAX_LOOPS = 8
18
19/** Files whose edit history is tracked per loop. */
20export const MAX_TRACKED_FILES = 20
21
22/** States remembered per tracked file. */
23export const FILE_HISTORY_CAP = 20
24
25/** Characters of tool output the core ever looks at: the tail, where errors surface. */
26export const OUTPUT_TAIL_CHARS = 4096
27
28/** Error-looking lines kept from one output when building fingerprints. */
29export const MAX_ERROR_LINES = 20
30
31/** Longest human snippet carried into state, the band and nudges. */
32export const SNIPPET_CAP = 200
33
34/** Longest action label (`npm test`, `Edit src/a.ts`). */
35export const LABEL_CAP = 60
36
37/** Characters of a generic tool input serialised into its args key. */
38export const ARGS_KEY_INPUT_CAP = 2048
39
40/** Calls back an error class must recur within to count as an environment blocker. */
41export const ENV_BLOCKER_SPAN = 10
42
43/** Model-facing nudges allowed per turn, across all loops and signatures. */
44export const MAX_NUDGES_PER_TURN = 2
45
46/** Share of a new signal's evidence already covered by an earlier nudge that suppresses it. */
47export const EVIDENCE_OVERLAP_LIMIT = 0.5
48
49/** Evidence ids remembered per loop for overlap suppression. */
50export const COVERED_EVIDENCE_CAP = 60
51
52/** Extra recurrences after escalation before an opt-in auto-stop. */
53export const AUTO_STOP_DELTA = 2
54
55/** Periods the cycle detector looks for (period 1 is a plain repeat). */
56export const CYCLE_PERIODS = [2, 3, 4] as const
57
58/** Tools that never count: planning, bookkeeping and asking the user. */
59export const NEVER_COUNTED_TOOLS: ReadonlySet<string> = new Set([
60 `TodoWrite`,
61 `ToolSearch`,
62 `AskUserQuestion`,
63 `ScheduleWakeup`,
64 `EnterPlanMode`,
65 `ExitPlanMode`,
66 `TaskCreate`,
67 `TaskUpdate`,
68])
69
70/** Tools whose whole purpose is waiting on something: the poll class. */
71export const POLL_TOOLS: ReadonlySet<string> = new Set([
72 `Monitor`,
73 `TaskGet`,
74 `GetTask`,
75 `TaskList`,
76 `Poll`,
77 `TaskOutput`,
78 `BashOutput`,
79])
80
81/** Tools that change files and so count as "progress" between verification runs. */
82export const EDIT_TOOLS: ReadonlySet<string> = new Set([`Edit`, `Write`, `NotebookEdit`])
83
84/** Model-facing text is tagged with this, so the transcript says who wrote it. */
85export const NUDGE_TAG = `[Loop Breaker]`
86
87/** Segments of an absolute path kept after normalisation (`<abs>/proj/src/users.ts`; `/api/v1/users` ≠ `/api/v2/users`). */
88export const ABSOLUTE_PATH_KEPT_SEGMENTS = 3
89
90/** UTF-16 high-surrogate range: a cut must not leave one dangling. */
91export const HIGH_SURROGATE_FIRST = 0xd800
92export const HIGH_SURROGATE_LAST = 0xdbff
93
94/** Longest nudge sent to the model. */
95export const NUDGE_CAP = 400
96
97/** Longest band title. */
98export const TITLE_CAP = 120
99
100/** Signatures the user ignored, remembered per session. */
101export const IGNORED_CAP = 100
102
103/** Signatures the policy remembers at once; the least recently seen go first. */
104export const MAX_SIGNATURES = 200
105
106/** User ignore entries kept, and the longest entry accepted. */
107export const IGNORE_ENTRY_MAX_COUNT = 50
108export const IGNORE_ENTRY_MAX_LENGTH = 200
109hooks/core/types.ts 215 lines1import type { DETECTION_STATE_VERSION } from './constants.ts'
2
3/**
4 * The detection core's vocabulary. Every type is plain JSON-serialisable data
5 * so the adapter can keep the whole detection state in one `$.state` value
6 * (one write per tool call, spike R7) and the core can run unchanged in Node.
7 */
8
9/** `main` for the main conversation, else the subagent's `agentId`. */
10export type LoopId = string
11
12/** How a tool call ended, as far as loop detection cares. */
13export type Outcome = `ok` | `error` | `denied` | `interrupted` | `background`
14
15/** Failure classes the model usually cannot fix by itself, plus timeouts. */
16export type ErrorClass =
17 | `command-not-found`
18 | `permission`
19 | `network`
20 | `auth`
21 | `rate-limit`
22 | `missing-module`
23 | `disk`
24 | `timeout`
25
26/** The detectors, in precedence order (see `detectors/index.ts`). */
27export type DetectorId =
28 | `env-blocker`
29 | `denied-retry`
30 | `repeat-failure`
31 | `same-error`
32 | `edit-thrash`
33 | `cycle`
34 | `idle-repeat`
35 | `poll`
36
37/**
38 * One finished tool call, described without any engine type. The adapter
39 * builds it from `tool.call`'s input and result (Phase 4).
40 */
41export interface ToolObservation {
42 /** The engine's `tool_use_id`. */
43 readonly id: string
44 readonly loopId: LoopId
45 readonly tool: string
46 readonly input: Readonly<Record<string, unknown>>
47 readonly outcome: Outcome
48 /** What the model read: the error text on failure (starts `Exit code N` for Bash), else the output. */
49 readonly text?: string
50 /** Edit only: the file's content before the edit (`result.originalFile`), `null` for none. */
51 readonly originalFile?: string | null
52 /** The engine's own read-only verdict for this call. */
53 readonly isReadOnly: boolean
54 /** Milliseconds, as the adapter's clock reads them: when the result arrived. */
55 readonly at: number
56 /**
57 * When the model issued the call. Absent means after every earlier result;
58 * earlier means it was issued together with others (one message, several calls).
59 */
60 readonly startedAt?: number
61}
62
63/** A tool call after normalisation: everything detectors need, nothing they don't. */
64export interface CallRecord {
65 readonly id: string
66 readonly loopId: LoopId
67 readonly tool: string
68 /** Tool plus key-sorted, normalised input minus cosmetic fields. */
69 readonly argsKey: string
70 /** Short human label: `npm test`, `Edit src/api/users.ts`. */
71 readonly label: string
72 readonly outcome: Outcome
73 /** Exact normalised outcome (keeps `file:line:col`); for repeats, cycles, polls. */
74 readonly outcomeFp: string
75 /** Coarse failure identity (line numbers stripped); failures only. */
76 readonly errorSig?: string
77 readonly errorClass?: ErrorClass
78 /** The token an environment error names, e.g. `pnpm` for `command not found: pnpm`. */
79 readonly errorToken?: string
80 /** Most specific error line, redacted and capped; failures only. */
81 readonly snippet?: string
82 /** Edit/Write: the file the call changed. */
83 readonly file?: string
84 /** Edit/Write that succeeded: content hash after the call, when computable. */
85 readonly fileHashAfter?: string
86 /** Edit/Write: content hash before the call, when known. */
87 readonly fileHashBefore?: string
88 /** Edit: hashes of the replaced and replacing text, for inverse-edit checks. */
89 readonly editPair?: readonly [string, string]
90 readonly isEdit: boolean
91 readonly isReadOnly: boolean
92 readonly isVerification: boolean
93 readonly isPoll: boolean
94 /** Bash that only moves between versions (`git checkout`, `stash`, `bisect`…). */
95 readonly isNavigation: boolean
96 /** A PreToolUse hook refused the call (recorded as `denied`): a procedural block, not a person's no. */
97 readonly isGuardBlock: boolean
98 readonly at: number
99 /** See `ToolObservation.startedAt`. */
100 readonly startedAt?: number
101}
102
103/** One successful content change of a file. */
104export interface FileEdit {
105 readonly id: string
106 /** Content hash before the change, when known. */
107 readonly before?: string
108 /** Content hash after the change, when computable. */
109 readonly after?: string
110 /** Edit only: `[hash(old_string), hash(new_string)]`. */
111 readonly pair?: readonly [string, string]
112}
113
114/** Edit history of one file within a loop's current scope. */
115export interface FileHistory {
116 /** Successful, content-changing edits in order (failed and no-op ones are skipped). */
117 readonly edits: readonly FileEdit[]
118 readonly touchedAt: number
119}
120
121/** One loop's detection window, since the last user prompt. */
122export interface LoopWindow {
123 readonly records: readonly CallRecord[]
124 readonly files: Readonly<Record<string, FileHistory>>
125 readonly touchedAt: number
126}
127
128/** When a nudged or escalated signature counts as resolved. */
129export type ResolveWhen =
130 | { readonly kind: `action-succeeds`; readonly argsKey: string }
131 | {
132 readonly kind: `error-changes`
133 readonly errorSig: string
134 readonly argsKeys: readonly string[]
135 }
136 | { readonly kind: `output-changes`; readonly argsKey: string; readonly outcomeFp: string }
137 | { readonly kind: `user-prompt` }
138
139/** A detector's finding: this signature has recurred `count` times. */
140export interface Signal {
141 readonly detector: DetectorId
142 /** `detector:key`, stable across reloads; the unit of nudging, ignoring and escalating. */
143 readonly signature: string
144 readonly loopId: LoopId
145 readonly count: number
146 readonly nudgeAt: number
147 readonly escalateAt: number
148 /** Call ids that make up the streak. */
149 readonly evidence: readonly string[]
150 /** What repeated, for people: `npm test`, `Edit src/a.ts`, `pnpm`. */
151 readonly label: string
152 readonly snippet?: string
153 readonly errorClass?: ErrorClass
154 readonly errorToken?: string
155 readonly resolveWhen: ResolveWhen
156}
157
158/**
159 * Where a signature stands on the ladder. `watching`: reached the nudge
160 * threshold but was not nudged (steering off, or the per-turn nudge budget
161 * spent), so it waits for the escalation threshold. `shadowed`: its evidence
162 * was already covered by another signature's nudge, so it stays silent.
163 */
164export type Stage = `nudged` | `watching` | `escalated` | `stopped` | `shadowed`
165
166/** The policy's memory of one signature. */
167export interface SignatureState {
168 readonly signature: string
169 readonly detector: DetectorId
170 readonly loopId: LoopId
171 readonly stage: Stage
172 readonly count: number
173 readonly wasNudged: boolean
174 readonly resolveWhen: ResolveWhen
175 readonly firstAt: number
176 readonly lastAt: number
177}
178
179/** Everything the policy remembers between calls. */
180export interface PolicyState {
181 readonly signatures: Readonly<Record<string, SignatureState>>
182 readonly nudgesThisTurn: number
183 /** Per loop, evidence already covered by a nudge or escalation. */
184 readonly covered: Readonly<Record<LoopId, readonly string[]>>
185 readonly ignored: readonly string[]
186 readonly isPaused: boolean
187}
188
189/** The whole detection state: what the adapter keeps in one `$.state` value. */
190export interface DetectionState {
191 readonly version: typeof DETECTION_STATE_VERSION
192 readonly loops: Readonly<Record<LoopId, LoopWindow>>
193 readonly policy: PolicyState
194}
195
196/** Copy for the user-facing band (Phase 5 draws it). */
197export interface AlertCopy {
198 readonly title: string
199 readonly detail: string
200 readonly hintTemplate: string
201}
202
203/** What the policy asks the adapter to do. */
204export type Decision =
205 | { readonly kind: `nudge`; readonly signal: Signal; readonly text: string }
206 | { readonly kind: `escalate`; readonly signal: Signal; readonly alert: AlertCopy }
207 | { readonly kind: `stop`; readonly signal: Signal }
208 | {
209 readonly kind: `resolve`
210 readonly signature: string
211 readonly detector: DetectorId
212 readonly loopId: LoopId
213 readonly wasNudged: boolean
214 }
215