Detect repeated Bash failures and edit/retry cycles with evidence, live cases and investigation prompts

English | 한국어
A Claude Code mod that opens a case when the same Bash command produces the same failure repeatedly. It connects observed edits to subsequent retries and presents possible next checks. It observes only: tool execution, permissions, prompts and files are never changed.
/plugin marketplace add YeonwooSung/my-claude-code-mods
/plugin install loop-detective@my-claude-code-mods
During development:
claude --plugin-dir ./loop-detective
Requires Claude Code 2.1.287+ (mods enabled by default). Older engines need CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. The engine suite runs locally on 2.1.280; TypeScript checking uses the repository's local 2.1.292 declarations. Interactive UI and simultaneous-mod compatibility need a separate live integration run.
| Command | Behavior |
|---|---|
/loop-report | Main-agent active cases, evidence and possible next checks |
/loop-report --agents | Include subagents, tracked independently |
/loop-report --history | Include up to 100 closed cases; combine with --agents |
/loop-pane | Main-agent live case pane in terminal and desktop |
/loop-reset | Clear observations; does not change files or conversation |
Status line: loop: 1 active · max 4 failures. Each case gets at most one optional toast when it opens. A case that has already closed while overlapping results are being drained is not toasted.
Example: three npm test failures, with successful file edits between the invocations:
Case #1 [edit-retry; active] — main
3 identical failures · calls #1–#5 · 2 edit/retry transitions
Command: npm test
Cwd: /repo
Failure: Error: expected 2, got 1
Observed edited files (not causal evidence): /repo/a.ts
Possible next checks: inspect test expectations, input data, dependencies,
or a different code path before another edit.
cd is part of the exact command; cwd here is the session cwd, not inferred shell state.edit-retry; otherwise retry. Count edits completed before each Bash invocation starts, never edits that finish during its execution. Changed file paths show activity, not blame.error alone is insufficient. A zero shell exit code does not override a recognized failing-test summary.recovered; a different failure closes as changed; unsupported/denied/interrupted/timed-out/background/truncated results close as unknown. Unknown does not mean recovered. Oversized output (over 65,536 characters) and empty errors are also unknown. Other commands succeeding do not close this case.These are observations and lexical estimates, not semantic judgments. Repeated failure does not prove a lack of progress, an infinite loop, a root cause, or that a particular hypothesis has not been tried. Fingerprints use noncryptographic hashes; collisions are possible.
Set in the /plugin settings menu or settings pluginConfigs.
| Key | Default | Meaning |
|---|---|---|
threshold | 3 | Integer 3–10; invalid values use 3. Applies when the mod loads or observations reset |
toastAlerts | true | Notify once per new active case |
State stays in memory; no export, automatic file writes, network calls, model calls or backfill. Reload, resume, /clear and /loop-reset start fresh. Caps: 512 groups, 128 agent contexts, 512 metadata queues with 16 in-flight calls each, 100 closed cases, 100 recent edit records per agent and 20 displayed edited paths per case. Commands, cwd, paths, agent labels and failure excerpts are displayed with common secrets masked and terminal controls removed before truncation (160 characters each).
Only hashes, counters and bounded display metadata are retained; no prompts, edit bodies or full tool output. Masking is best-effort, and hashes are not anonymization. ANSI/log changes outside the timing rules can create false negatives. Bash/external edits and edits by another agent are not credited to a case. Whole-command fingerprints cannot recognize equivalent commands or semantic paraphrases. VS Code chat and headless sessions use text reports; their UI may not display panes or status.
npm test
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test loop-detective
claude plugin validate loop-detective
npm run typecheck
The root Node suite runs pure core tests without an account or engine. Adapter tests run through the real engine's testing kit and verify passthrough, reset, edit/retry, reports, notifications and UI fault isolation. Typechecking requires engine-generated declarations in loop-detective/.claude-plugin/types/claude-code/index.d.ts, as for the other mods; use declarations for the engine being targeted. Core classes contain no engine dependency.
hooks/register.tsx 109 lines1import type { EngineInterface, Register } from 'claude-code'
2import { Detector, type Ticket } from './core/detector.ts'
3import { display } from './core/fingerprint.ts'
4import { paneLines, renderReport, statusLine } from './core/report.ts'
5
6const PANE = 'loop-detective'
7const COMMANDS = [
8 { name: 'loop-report', description: 'Inspect repeated Bash failures: [--agents] [--history]' },
9 { name: 'loop-pane', description: 'Open the live Loop Detective case pane' },
10 { name: 'loop-reset', description: 'Clear Loop Detective observations without changing files or conversation' },
11]
12let threshold: unknown = 3
13let detector = new Detector()
14let scheduled: Detector | undefined
15function safe(fn: () => void): void {
16 try { fn() } catch { /* Observation and UI must not affect execution. */ }
17}
18
19function paint($: EngineInterface): void {
20 const own = detector
21 if (scheduled === own) return
22 scheduled = own
23 try {
24 $.clock.after(0, () => {
25 if (scheduled === own) scheduled = undefined
26 if (own !== detector) return
27 safe(() => $.ui.status(statusLine(own.snapshot())))
28 safe(() => $.ui.invalidate('ui.render'))
29 })
30 } catch { if (scheduled === own) scheduled = undefined }
31}
32
33function reset($: EngineInterface): void {
34 detector = new Detector(threshold)
35 paint($)
36}
37
38export const register: Register = (on, options) => {
39 threshold = options.threshold
40 detector = new Detector(threshold)
41
42 on('session.start', async ($, e, next) => {
43 reset($)
44 for (const command of COMMANDS) {
45 try { await $.command.register(command) } catch { /* Still forward session startup. */ }
46 }
47 return next(e)
48 })
49
50 on('session.end', async ($, e, next) => {
51 const answer = await next(e)
52 if (e.reason === 'clear' || e.reason === 'resume') reset($)
53 return answer
54 })
55
56 on('tool.call', async ($, e, next) => {
57 const own = detector
58 let ticket: Ticket | undefined
59 try {
60 ticket = own.reserve(String(e.tool), e as unknown as Record<string, unknown>, e.agentId)
61 const cwd = e.tool === 'Bash' ? await $.session.cwd().catch(() => undefined) : undefined
62 own.locate(ticket, cwd)
63 } catch { /* Unsupported metadata cannot prevent the tool from running. */ }
64 let answer
65 try {
66 // Never attach a fallback that could execute this tool a second time.
67 answer = await next(e)
68 } catch (error) {
69 if (ticket) safe(() => own.finish(ticket!, {}))
70 if (own === detector) paint($)
71 throw error
72 }
73 if (ticket) safe(() => {
74 const opened = own.finish(ticket!, answer)
75 if (own !== detector || options.toastAlerts === false) return
76 // A drained queue can open and close a case together: notify only still-active cases.
77 const active = new Set(own.snapshot().active.map(c => c.id))
78 for (const c of opened) if (active.has(c.id)) safe(() => {
79 $.ui.toast(`Loop Detective: case #${c.id}, ${c.count} identical failures — /loop-report${c.isMain ? '' : ' --agents'}`)
80 })
81 })
82 if (own === detector) paint($)
83 return answer
84 })
85
86 on('turn.complete', async ($, e, next) => {
87 paint($)
88 return next(e)
89 })
90
91 on('command.run', { command: 'loop-report' }, async (_$, e) => ({ text: renderReport(detector.snapshot(), e.args ?? '') }))
92 on('command.run', { command: 'loop-reset' }, async $ => {
93 reset($)
94 return { text: 'Loop Detective observations cleared. Files and conversation unchanged.' }
95 })
96 on('command.run', { command: 'loop-pane' }, async $ => {
97 try {
98 await $.ui.open({ id: PANE, title: 'Loop Detective' })
99 return { text: 'Loop Detective pane opened (terminal and desktop app).' }
100 } catch (error) {
101 return { text: `Loop Detective pane unavailable: ${display(String(error))}. Use /loop-report.` }
102 }
103 })
104 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
105 const { Box, Text } = $.ui.resolve(e)
106 return <Box flexDirection="column">{paneLines(detector.snapshot()).map(line => <Text>{line || ' '}</Text>)}</Box>
107 })
108}
109hooks/core/detector.ts 191 lines1import { classifyResult, display, hash, type Outcome } from './fingerprint.ts'
2
3export type CaseStatus = 'active' | 'recovered' | 'changed' | 'unknown' | 'expired'
4export type Case = {
5 id: number; agent: string; isMain: boolean; cwd: string; command: string
6 kind: 'retry' | 'edit-retry'; status: CaseStatus; count: number
7 firstSeq: number; lastSeq: number; editRetries: number; files: string[]; excerpt: string
8}
9export type Snapshot = { active: Case[]; history: Case[]; groups: number; contexts: number; pending: number; queues: number; threshold: number }
10type Context = { alive: boolean; epoch: number; edits: { epoch: number; path: string }[] }
11type Group = {
12 key: string; baseKey: string; context: Context; agent: string; isMain: boolean; cwd: string; command: string
13 touched: number; signature?: string; count: number; firstSeq: number
14 previousEpoch: number; editRetries: number; files: string[]; case?: Case
15}
16export type Ticket = {
17 seq: number; epoch: number; context: Context; path?: string
18 completed: boolean; located: boolean; invalid?: boolean; outcome?: Outcome; groupKey?: string; cwd?: string
19 metadata?: { baseKey: string; queue: Ticket[]; agent: string; isMain: boolean; command: string }
20}
21const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
22const copy = (c: Case): Case => ({ ...c, files: [...c.files] })
23
24export class Detector {
25 private seq = 0
26 private caseId = 0
27 private readonly groups = new Map<string, Group>()
28 private readonly contexts = new Map<string, Context>()
29 // Reserve order before asynchronous cwd lookup; different directories may share a short queue.
30 private readonly waiting = new Map<string, Ticket[]>()
31 private readonly history: Case[] = []
32 readonly threshold: number
33
34 constructor(threshold: unknown = 3) {
35 this.threshold = typeof threshold === 'number' && Number.isInteger(threshold) && threshold >= 3 && threshold <= 10 ? threshold : 3
36 }
37
38 private close(group: Group, status: Exclude<CaseStatus, 'active'>): void {
39 if (group.case) {
40 this.history.push({ ...copy(group.case), status })
41 if (this.history.length > 100) this.history.shift()
42 }
43 group.case = undefined
44 group.signature = undefined
45 group.count = 0
46 group.editRetries = 0
47 group.files = []
48 }
49
50 private evict(group: Group): void {
51 this.close(group, 'expired')
52 this.groups.delete(group.key)
53 for (const queue of this.waiting.values()) for (const t of queue) if (t.groupKey === group.key) t.invalid = true
54 }
55
56 begin(tool: string, input: Record<string, unknown>, agent?: string, cwd?: string): Ticket {
57 const ticket = this.reserve(tool, input, agent)
58 this.locate(ticket, cwd)
59 return ticket
60 }
61
62 reserve(tool: string, input: Record<string, unknown>, agent?: string): Ticket {
63 this.seq++
64 for (const group of this.groups.values()) if (this.seq - group.touched >= 200) this.evict(group)
65 for (const [key, queue] of this.waiting) if (this.seq - queue[0]!.seq >= 200) this.waiting.delete(key)
66 const scope = agent === undefined ? '@main' : `agent:${hash(agent)}`
67 let context = this.contexts.get(scope)
68 if (!context) {
69 if (this.contexts.size >= 128) {
70 const [oldKey, old] = this.contexts.entries().next().value!
71 old.alive = false
72 for (const group of this.groups.values()) if (group.context === old) this.evict(group)
73 for (const [key, queue] of this.waiting) if (queue[0]!.context === old) this.waiting.delete(key)
74 this.contexts.delete(oldKey)
75 }
76 context = { alive: true, epoch: 0, edits: [] }
77 this.contexts.set(scope, context)
78 } else {
79 // Scope retention uses least recently started activity.
80 this.contexts.delete(scope)
81 this.contexts.set(scope, context)
82 }
83 const ticket: Ticket = { seq: this.seq, epoch: context.epoch, context, completed: false, located: false }
84 if (EDIT_TOOLS.has(tool)) {
85 const path = input.file_path ?? input.notebook_path
86 if (typeof path === 'string') ticket.path = display(path)
87 }
88 if (tool !== 'Bash' || typeof input.command !== 'string' || input.command.length > 65_536) return ticket
89 const baseKey = hash(JSON.stringify([scope, hash(input.command)]))
90 let queue = this.waiting.get(baseKey)
91 if (queue && queue.length >= 16) {
92 for (const g of this.groups.values()) if (g.baseKey === baseKey) this.evict(g)
93 this.waiting.delete(baseKey)
94 queue = undefined
95 }
96 if (!queue) {
97 if (this.waiting.size >= 512) this.waiting.delete(this.waiting.keys().next().value!)
98 queue = []
99 this.waiting.set(baseKey, queue)
100 }
101 ticket.metadata = { baseKey, queue, agent: agent === undefined ? 'main' : display(agent), isMain: agent === undefined, command: display(input.command) }
102 queue.push(ticket)
103 return ticket
104 }
105
106 locate(ticket: Ticket, cwd?: string): void {
107 if (ticket.located) return
108 ticket.located = true
109 if (!ticket.metadata || !cwd) return
110 ticket.cwd = display(cwd)
111 ticket.groupKey = hash(JSON.stringify([ticket.metadata.baseKey, hash(cwd)]))
112 const group = this.groups.get(ticket.groupKey)
113 if (group) group.touched = this.seq
114 }
115
116 finish(ticket: Ticket, answer: unknown): Case[] {
117 if (ticket.completed || !ticket.context.alive) return []
118 ticket.completed = true
119 if (ticket.path !== undefined) {
120 const a = answer !== null && typeof answer === 'object' ? answer as Record<string, unknown> : {}
121 if (a.result !== undefined && a.deny === undefined && a.isError !== true && a.isReadOnly !== true) {
122 const ctx = ticket.context
123 ctx.edits.push({ epoch: ++ctx.epoch, path: ticket.path })
124 if (ctx.edits.length > 100) ctx.edits.shift()
125 }
126 }
127 const metadata = ticket.metadata
128 if (!metadata || this.waiting.get(metadata.baseKey) !== metadata.queue) return []
129 ticket.outcome = classifyResult(answer)
130 const opened: Case[] = []
131 const queue = metadata.queue
132 while (queue[0]?.completed && queue[0].located) {
133 const t = queue.shift()!
134 if (t.invalid) continue
135 if (!t.groupKey) {
136 // Unknown location cannot justify carrying a previous failure streak through this call.
137 for (const g of this.groups.values()) if (g.baseKey === metadata.baseKey) this.close(g, 'unknown')
138 continue
139 }
140 let group = this.groups.get(t.groupKey)
141 if (!group) {
142 if (this.groups.size >= 512) this.evict(this.groups.values().next().value!)
143 const m = t.metadata!
144 group = {
145 key: t.groupKey, baseKey: m.baseKey, context: t.context, agent: m.agent, isMain: m.isMain,
146 command: m.command, cwd: t.cwd!, touched: this.seq, count: 0, firstSeq: t.seq,
147 previousEpoch: t.epoch, editRetries: 0, files: [],
148 }
149 this.groups.set(group.key, group)
150 }
151 group.touched = this.seq
152 const outcome = t.outcome!
153 if (outcome.kind !== 'failure') {
154 this.close(group, outcome.kind === 'success' ? 'recovered' : 'unknown')
155 continue
156 }
157 if (group.signature !== outcome.signature) {
158 this.close(group, 'changed')
159 group.signature = outcome.signature
160 group.firstSeq = t.seq
161 group.previousEpoch = t.epoch
162 } else if (t.epoch > group.previousEpoch) {
163 group.editRetries++
164 for (const edit of group.context.edits) {
165 if (edit.epoch > group.previousEpoch && edit.epoch <= t.epoch && group.files.length < 20 && !group.files.includes(edit.path)) group.files.push(edit.path)
166 }
167 }
168 group.previousEpoch = t.epoch
169 group.count++
170 if (group.count < this.threshold) continue
171 const fresh = group.case === undefined
172 group.case = {
173 id: group.case?.id ?? ++this.caseId, agent: group.agent, isMain: group.isMain, cwd: group.cwd, command: group.command,
174 kind: group.editRetries >= 2 ? 'edit-retry' : 'retry', status: 'active', count: group.count,
175 firstSeq: group.firstSeq, lastSeq: t.seq, editRetries: group.editRetries, files: [...group.files], excerpt: outcome.excerpt!,
176 }
177 if (fresh) opened.push(copy(group.case))
178 }
179 if (!queue.length) this.waiting.delete(metadata.baseKey)
180 return opened
181 }
182
183 snapshot(): Snapshot {
184 return {
185 active: [...this.groups.values()].flatMap(g => g.case ? [copy(g.case)] : []),
186 history: this.history.map(copy), groups: this.groups.size, contexts: this.contexts.size, threshold: this.threshold,
187 pending: [...this.waiting.values()].reduce((sum, q) => sum + q.length, 0), queues: this.waiting.size,
188 }
189 }
190}
191hooks/core/fingerprint.ts 87 lines1export type Outcome = { kind: 'failure' | 'success' | 'unknown'; signature?: string; excerpt?: string }
2const MAX_OUTPUT = 65_536
3const UNKNOWN: Outcome = { kind: 'unknown' }
4
5function record(value: unknown): Record<string, unknown> {
6 return value !== null && typeof value === 'object' ? value as Record<string, unknown> : {}
7}
8
9// Two seeded FNV-1a hashes over UTF-16, with a murmur finalizer. Not cryptographic.
10export function hash(text: string): string {
11 return [0, 0x9747b28c].map(seed => {
12 let h = (0x811c9dc5 ^ seed) >>> 0
13 for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193)
14 h = Math.imul(h ^ (h >>> 16), 0x85ebca6b)
15 h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35)
16 return ((h ^ (h >>> 16)) >>> 0).toString(16).padStart(8, '0')
17 }).join('')
18}
19
20const VALUE = String.raw`("[^"]*"|'[^']*'|[^\s'"]+)`
21export function display(text: string, max = 160): string {
22 const clean = text
23 .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
24 .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
25 .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, '')
26 .replace(/(\bauthorization\s*:\s*)[^\r\n]+/gi, '$1[REDACTED]')
27 .replace(/(\bbearer\s+)[A-Za-z0-9._~+/=-]+/gi, '$1[REDACTED]')
28 .replace(new RegExp(String.raw`(--(?:password|token|api-key)(?:=|\s+))` + VALUE, 'gi'), '$1[REDACTED]')
29 .replace(new RegExp(String.raw`\b([A-Za-z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)[A-Za-z0-9_]*)=` + VALUE, 'gi'), '$1=[REDACTED]')
30 .replace(new RegExp(String.raw`((?:["']?)(?:password|token|api[_-]?key|secret)(?:["']?)\s*:\s*)` + VALUE, 'gi'), '$1[REDACTED]')
31 .replace(/\s+/g, ' ').trim()
32 const limit = Math.max(1, Math.floor(max))
33 return clean.length > limit ? `${clean.slice(0, limit - 1)}…` : clean
34}
35
36function failedTests(text: string): boolean {
37 // Only framework summary shapes, never a generic occurrence of "error" or "failed".
38 return /^# fail [1-9]\d*\s*$/m.test(text)
39 || /^[ \t]*Tests(?::|[ \t]+)[^\n]*\b[1-9]\d* failed\b/m.test(text)
40 || /^=+[ \t]+[^\n]*\b[1-9]\d* (?:failed|errors?)\b[^\n]* in [\d.]+s\b/m.test(text)
41 || /^test result: FAILED\.[^\n]*\b[1-9]\d* failed\b/m.test(text)
42 || /^[ \t]*--- FAIL: \S+/m.test(text)
43}
44
45function normalized(text: string): string {
46 let out = text.replace(/\x1b\[[0-?]*[ -/]*m/g, '').replace(/\r\n/g, '\n').trim()
47 const tap = /^# (?:tests|pass|fail) \d+[ \t]*$/m.test(out)
48 const jest = /^Tests:[ \t]+[^\n]*\b\d+ (?:passed|failed|skipped)\b/m.test(out)
49 const vitest = /^[ \t]*Tests[ \t]+[^\n]*\b\d+ (?:passed|failed|skipped)\b/m.test(out)
50 if (tap) out = out.replace(/^([ \t]*duration_ms:[ \t]*)[\d.]+[ \t]*$/gm, '$1<time>')
51 .replace(/^(# duration_ms )[\d.]+[ \t]*$/gm, '$1<time>')
52 if (jest || vitest) out = out.replace(/^([ \t]*[✓✔×✕✖✗][ \t]+[^\n]* )\([\d.]+ ms\)[ \t]*$/gm, '$1(<time>)')
53 if (jest) out = out.replace(/^Time:[ \t]+[^\n]*$/gm, line => line.replace(/\b[\d.]+\s*(?:ms|s)\b/g, '<time>'))
54 if (vitest) out = out.replace(/^[ \t]*Duration[ \t]+[^\n]*$/gm, line => line.replace(/\b[\d.]+\s*(?:ms|s)\b/g, '<time>'))
55 return out
56 .replace(/^(=+[^\n]*\b(?:failed|passed|errors?|skipped)\b[^\n]* in )[\d.]+s/gm, '$1<time>')
57 .replace(/^(test result: [^\n]* finished in )[\d.]+s[ \t]*$/gm, '$1<time>')
58 .replace(/^(\s*--- (?:FAIL|PASS): [^\n]* )\([\d.]+s\)$/gm, '$1(<time>)')
59 .replace(/^((?:ok|FAIL)\s+\S+\s+)[\d.]+s\s*$/gm, '$1<time>')
60}
61
62export function classifyResult(answer: unknown): Outcome {
63 const a = record(answer)
64 const r = record(a.result)
65 if (a.deny !== undefined) return UNKNOWN
66 for (const o of [a, r]) {
67 if (['interrupted', 'cancelled', 'canceled', 'timedOut', 'timed_out', 'truncated', 'output_truncated'].some(k => o[k] === true)) return UNKNOWN
68 if (['backgroundTaskId', 'background_task_id', 'task_id', 'taskId'].some(k => o[k] !== undefined && o[k] !== null && o[k] !== '')) return UNKNOWN
69 }
70 const streams = [r.stdout, r.stderr].filter((v): v is string => typeof v === 'string')
71 if (streams.reduce((sum, v) => sum + v.length, 0) > MAX_OUTPUT) return UNKNOWN
72 const raw = typeof a.text === 'string' ? a.text
73 : typeof a.result === 'string' ? a.result : streams.join('\n')
74 if (raw.length > MAX_OUTPUT) return UNKNOWN
75 const text = normalized(raw)
76 const code = r.exitCode ?? r.exit_code
77 if (code !== undefined && (typeof code !== 'number' || !Number.isFinite(code))) return UNKNOWN
78 const frameworkText = raw.replace(/\x1b\[[0-?]*[ -/]*m/g, '').replace(/\r\n/g, '\n')
79 if (a.isError === true || (typeof code === 'number' && code !== 0) || failedTests(frameworkText)) {
80 if (!text) return UNKNOWN
81 const evidence = text.split('\n').find(line => /error|fail|expected|not ok/i.test(line)) ?? text
82 return { kind: 'failure', signature: hash(text), excerpt: display(evidence) }
83 }
84 if (code === 0 || a.isError === false || streams.length > 0) return { kind: 'success' }
85 return UNKNOWN
86}
87hooks/core/report.ts 46 lines1import type { Case, Snapshot } from './detector.ts'
2
3function caseLines(c: Case): string[] {
4 const lines = [
5 `Case #${c.id} [${c.kind}; ${c.status}] — ${c.agent}`,
6 ` ${c.count} identical failures · calls #${c.firstSeq}–#${c.lastSeq} · ${c.editRetries} edit/retry transitions`,
7 ` Command: ${c.command}`, ` Cwd: ${c.cwd}`, ` Failure: ${c.excerpt}`,
8 ]
9 if (c.files.length) lines.push(` Observed edited files (not causal evidence): ${c.files.join(', ')}`)
10 if (c.status === 'active') lines.push(c.kind === 'edit-retry'
11 ? ' Possible next checks: inspect test expectations, input data, dependencies, or a different code path before another edit.'
12 : ' Possible next checks: compare inputs and environment; formulate a different hypothesis before retrying.')
13 return lines
14}
15
16export function renderReport(snapshot: Snapshot, args: string): string {
17 const flags = args.trim().split(/\s+/).filter(Boolean)
18 if (flags.some(f => f !== '--agents' && f !== '--history')) return 'Usage: /loop-report [--agents] [--history]'
19 const all = flags.includes('--agents')
20 const visible = (c: Case) => all || c.isMain
21 const active = snapshot.active.filter(visible)
22 const lines = [
23 `Loop Detective — ${all ? 'all agents' : 'main agent'}`,
24 `Threshold: ${snapshot.threshold} · ${active.length} active case(s)`,
25 'Observed lexical repetition only; repeated failure does not establish a lack of progress.', '',
26 ]
27 if (!active.length) lines.push('No active cases in the retained observations.')
28 for (const c of active) lines.push(...caseLines(c), '')
29 if (flags.includes('--history')) {
30 lines.push('', 'Closed cases (last 100):')
31 const history = snapshot.history.filter(visible)
32 if (!history.length) lines.push('none yet')
33 for (const c of history) lines.push(...caseLines(c), '')
34 }
35 return lines.join('\n')
36}
37
38export function paneLines(snapshot: Snapshot): string[] {
39 return renderReport(snapshot, '').split('\n')
40}
41
42export function statusLine(snapshot: Snapshot): string {
43 const active = snapshot.active.filter(c => c.isMain)
44 return active.length ? `loop: ${active.length} active · max ${Math.max(...active.map(c => c.count))} failures` : 'loop: no active cases'
45}
46