SLOPSHOPPER

loop-detective

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

newpaneguardcommandtoaststatus
★ 879v0.1.0no licenseupdated 2026-10-13YeonwooSung/my-claude-code-mods/loop-detective
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · loop-detective
│ ┃ Loop Detective ✕ › fix the failing auth test and add an audit log call │ ┃ Loop Detective — main agent │ ┃ Threshold: 3 · 0 active case(s) ⏺ Read(src/auth.ts) │ ┃ Observed lexical repetition only; repeated ⎿ Read 6 lines │ ┃ failure does not establish a lack of ⏺ Update(src/auth.ts) │ ┃ progress. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ No active cases in the retained ⎿ 3 pass, 1 fail │ ┃ observations. │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /loop-report │ ⎿ loop-detective: Loop Detective — main agent │ ⎿ loop-detective: Threshold: 3 · 0 active case(s) │ ⎿ loop-detective: Observed lexical repetition only; repeated failu │ ⎿ loop-detective: │ ⎿ loop-detective: No active cases in the retained observations. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Loop Detective
Loop Detective — main agent Threshold: 3 · 0 active case(s) Observed lexical repetition only; repeated failure does not establish a lack of progress. No active cases in the retained observations.
README

Loop Detective

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.

Install

/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.

Commands

CommandBehavior
/loop-reportMain-agent active cases, evidence and possible next checks
/loop-report --agentsInclude subagents, tracked independently
/loop-report --historyInclude up to 100 closed cases; combine with --agents
/loop-paneMain-agent live case pane in terminal and desktop
/loop-resetClear 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.

Detection rules

  • Group by agent, session cwd and exact command hash. Different arguments, quoting, whitespace and cwd start separate investigations. A missing cwd skips detection and breaks existing streaks for that agent/command as unknown. Shell cd is part of the exact command; cwd here is the session cwd, not inferred shell state.
  • Open after 3 consecutive identical failures by default. With at least two transitions containing successful Edit/MultiEdit/Write/NotebookEdit calls, label the case 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.
  • Failure evidence is an explicit engine error, a finite nonzero structured exit code, or positive Node TAP/Jest/Vitest/pytest/Cargo/Go test-failure summary. Printing error alone is insufficient. A zero shell exit code does not override a recognized failing-test summary.
  • Match the complete bounded output after ANSI SGR and CRLF normalization, plus narrowly recognized test timings (TAP duration fields, Jest/Vitest runtime summaries, pytest/Cargo/Go elapsed times and parenthesized millisecond timings). Keep assertion values, failure counts, locations and test names. Decreasing failure counts or different assertion values restart the streak.
  • Success for the same command closes a case as 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.
  • Reserve invocation order and edit epoch at hook entry, before looking up cwd. Results of overlapping invocations are processed in that order, using bounded per-agent/command metadata queues shared across cwd (only bookkeeping waits). Expire idle cases after 200 later tool starts with no activity in their group. Eviction and queue overflow also expire old evidence; late results cannot revive it.

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.

Settings

Set in the /plugin settings menu or settings pluginConfigs.

KeyDefaultMeaning
threshold3Integer 3–10; invalid values use 3. Applies when the mod loads or observations reset
toastAlertstrueNotify once per new active case

Retention and privacy

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.

Development

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.

Source 4 files
hooks/register.tsx 109 lines
1import 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}
109
hooks/core/detector.ts 191 lines
1import { 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}
191
hooks/core/fingerprint.ts 87 lines
1export 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}
87
hooks/core/report.ts 46 lines
1import 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