etch-loop 0.2.1__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: etch-loop
3
- Version: 0.2.1
3
+ Version: 0.3.1
4
4
  Summary: Run Claude Code in a fix-break loop until your codebase is clean
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "etch-loop"
3
- version = "0.2.1"
3
+ version = "0.3.1"
4
4
  requires-python = ">=3.11"
5
5
  description = "Run Claude Code in a fix-break loop until your codebase is clean"
6
6
  readme = "README.md"
@@ -0,0 +1 @@
1
+ __version__ = "0.3.1"
@@ -72,6 +72,12 @@ def run(
72
72
  help="Skip git commits after fixer runs.",
73
73
  is_flag=True,
74
74
  ),
75
+ no_git: bool = typer.Option(
76
+ False,
77
+ "--no-git",
78
+ help="Disable all git operations (diff checks and commits).",
79
+ is_flag=True,
80
+ ),
75
81
  dry_run: bool = typer.Option(
76
82
  False,
77
83
  "--dry-run",
@@ -98,6 +104,7 @@ def run(
98
104
  prompt_path=prompt,
99
105
  max_iterations=max_iterations,
100
106
  no_commit=no_commit,
107
+ no_git=no_git,
101
108
  dry_run=dry_run,
102
109
  verbose=verbose,
103
110
  focus=focus,
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import threading
6
6
  import time
7
7
  from dataclasses import dataclass
8
+ from pathlib import Path
8
9
  from typing import Any, Callable, TypeVar
9
10
 
10
11
  from rich.columns import Columns
@@ -450,6 +451,43 @@ def run_with_scan(label: str, fn: Callable[[], _T]) -> _T:
450
451
  return result[0]
451
452
 
452
453
 
454
+ def print_summary(stats: dict[str, Any]) -> None:
455
+ """Standalone summary panel, called after the Live context exits."""
456
+ reason = stats.get("reason", "done")
457
+ elapsed = stats.get("elapsed", 0.0)
458
+ iterations = stats.get("iterations", 0)
459
+ fixes = stats.get("fixes", 0)
460
+ issues = stats.get("issues", 0)
461
+ elapsed_str = _format_elapsed(elapsed)
462
+
463
+ if reason == "clear":
464
+ title = f"[{GREEN}]+ all clear[/{GREEN}]"
465
+ elif reason == "interrupted":
466
+ title = f"[{AMBER}]- interrupted[/{AMBER}]"
467
+ elif reason == "max_iterations":
468
+ title = f"[{AMBER}]- stopped (max iterations)[/{AMBER}]"
469
+ elif reason == "no_changes":
470
+ title = f"[{GREEN}]+ clean — fixer found nothing[/{GREEN}]"
471
+ else:
472
+ title = f"[{FG}]done[/{FG}]"
473
+
474
+ body = (
475
+ f"[{DIM}]iterations[/{DIM}] [{FG}]{iterations}[/{FG}] "
476
+ f"[{DIM}]fixes[/{DIM}] [{FG}]{fixes}[/{FG}] "
477
+ f"[{DIM}]breaker issues[/{DIM}] [{FG}]{issues}[/{FG}] "
478
+ f"[{DIM}]{elapsed_str} elapsed[/{DIM}]"
479
+ )
480
+
481
+ _console.print(
482
+ Panel(
483
+ body,
484
+ title=title,
485
+ border_style=Style(color=BORDER),
486
+ style=Style(bgcolor=BG),
487
+ )
488
+ )
489
+
490
+
453
491
  def print_dry_run(prompt_text: str) -> None:
454
492
  _console.print(
455
493
  Panel(
@@ -497,6 +535,14 @@ def print_init_skip(filename: str) -> None:
497
535
  )
498
536
 
499
537
 
538
+ def print_report_saved(path: Path) -> None:
539
+ try:
540
+ display_path = path.relative_to(Path.cwd())
541
+ except ValueError:
542
+ display_path = path
543
+ _console.print(f"[{DIM}]- report -> {display_path}[/{DIM}]")
544
+
545
+
500
546
  # ── Utilities ─────────────────────────────────────────────────────────────────
501
547
 
502
548
 
@@ -54,7 +54,7 @@ def has_changes() -> bool:
54
54
  return bool(status.stdout.strip())
55
55
 
56
56
 
57
- def commit(message: str) -> None:
57
+ def commit(message: str, paths: list[str] | None = None) -> None:
58
58
  """Stage all changes and create a commit.
59
59
 
60
60
  Args:
@@ -66,10 +66,11 @@ def commit(message: str) -> None:
66
66
  if not message or not message.strip():
67
67
  raise GitError("Commit message must not be empty.")
68
68
 
69
- # Stage all changes
69
+ # Stage all changes (or specific paths)
70
+ add_cmd = ["git", "add"] + (paths if paths else ["-A"])
70
71
  try:
71
72
  add_result = subprocess.run(
72
- ["git", "add", "-A"],
73
+ add_cmd,
73
74
  capture_output=True,
74
75
  text=True,
75
76
  )
@@ -0,0 +1,239 @@
1
+ """Core fix-break loop logic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from pathlib import Path
7
+
8
+ from etch import agent, display, git, prompt, report, signals
9
+ from etch.agent import AgentError
10
+ from etch.git import GitError
11
+ from etch.prompt import PromptError, load_scan
12
+
13
+
14
+ def run(
15
+ prompt_path: str | Path,
16
+ max_iterations: int = 20,
17
+ no_commit: bool = False,
18
+ no_git: bool = False,
19
+ dry_run: bool = False,
20
+ verbose: bool = False,
21
+ focus: str | None = None,
22
+ ) -> None:
23
+ """Run the scan-fix-break loop."""
24
+ prompt_path = Path(prompt_path)
25
+
26
+ # ── Load prompts ──────────────────────────────────────────────────────────
27
+ try:
28
+ prompt_text = prompt.load(prompt_path)
29
+ except PromptError as exc:
30
+ display.print_error(str(exc))
31
+ return
32
+
33
+ if focus:
34
+ prompt_text += f"\n\n## User focus\n\nConcentrate specifically on: {focus}\n"
35
+
36
+ if dry_run:
37
+ display.print_dry_run(prompt_text)
38
+ return
39
+
40
+ try:
41
+ scan_text = prompt.load_scan(prompt_path)
42
+ except PromptError as exc:
43
+ display.print_error(str(exc))
44
+ return
45
+
46
+ try:
47
+ break_text = prompt.load_break(prompt_path)
48
+ except PromptError as exc:
49
+ display.print_error(str(exc))
50
+ return
51
+
52
+ if focus:
53
+ scan_text += f"\n\n## User focus\n\nConcentrate on: {focus}\n"
54
+ break_text += f"\n\n## User focus\n\nConcentrate your adversarial review on: {focus}\n"
55
+
56
+ start_time = time.monotonic()
57
+ stats: dict = {
58
+ "iterations": 0,
59
+ "fixes": 0,
60
+ "issues": 0,
61
+ "reason": "done",
62
+ "elapsed": 0.0,
63
+ }
64
+ last_breaker_signal: str | None = None
65
+ last_breaker_output: str | None = None
66
+ iteration_log: list[dict] = []
67
+
68
+ with display.EtchDisplay(target=str(prompt_path.parent)) as disp:
69
+ for iteration in range(1, max_iterations + 1):
70
+ stats["iterations"] = iteration
71
+ disp.start_iteration(iteration)
72
+ iter_entry: dict = {"n": iteration}
73
+
74
+ # ── Scanner phase ─────────────────────────────────────────────────
75
+ disp.start_phase("scanner")
76
+ scanner_start = time.monotonic()
77
+ try:
78
+ scanner_output = agent.run(scan_text, verbose=verbose)
79
+ except AgentError as exc:
80
+ disp.finish_phase("scanner", status="error", detail=str(exc),
81
+ duration=time.monotonic() - scanner_start, success=False)
82
+ stats["reason"] = "agent_error"
83
+ iteration_log.append(iter_entry)
84
+ break
85
+
86
+ scanner_duration = time.monotonic() - scanner_start
87
+ scanner_signal = signals.parse(scanner_output)
88
+ scanner_detail = (
89
+ signals.extract_summary(scanner_output)
90
+ or signals.extract_finding(scanner_output)
91
+ )
92
+
93
+ if scanner_signal == "clear":
94
+ disp.finish_phase("scanner", status="all clear",
95
+ detail=scanner_detail or "nothing to fix",
96
+ duration=scanner_duration, success=True)
97
+ iter_entry["scanner"] = {"status": "all clear", "detail": scanner_detail}
98
+ stats["reason"] = "no_changes"
99
+ iteration_log.append(iter_entry)
100
+ break
101
+
102
+ disp.finish_phase("scanner", status="issues found",
103
+ detail=scanner_detail or "issues found",
104
+ duration=scanner_duration, success=False)
105
+ iter_entry["scanner"] = {"status": "issues found", "detail": scanner_detail}
106
+
107
+ # ── Build fixer prompt ────────────────────────────────────────────
108
+ fixer_prompt = prompt_text
109
+ fixer_prompt += (
110
+ f"\n\n## Scanner findings\n\n{scanner_output.strip()}\n\n"
111
+ f"Fix these specific issues.\n"
112
+ )
113
+ if last_breaker_output:
114
+ fixer_prompt += (
115
+ f"\n\n## Breaker findings from previous iteration\n\n"
116
+ f"{last_breaker_output.strip()}\n\n"
117
+ f"Also address these if not already covered above.\n"
118
+ )
119
+
120
+ # ── Fixer phase ───────────────────────────────────────────────────
121
+ disp.start_phase("fixer")
122
+ fixer_start = time.monotonic()
123
+ try:
124
+ _fixer_output = agent.run(fixer_prompt, verbose=verbose)
125
+ except AgentError as exc:
126
+ disp.finish_phase("fixer", status="error", detail=str(exc),
127
+ duration=time.monotonic() - fixer_start, success=False)
128
+ stats["reason"] = "agent_error"
129
+ iteration_log.append(iter_entry)
130
+ break
131
+
132
+ fixer_duration = time.monotonic() - fixer_start
133
+
134
+ # ── Check for changes (skipped when no_git) ───────────────────────
135
+ if not no_git:
136
+ try:
137
+ changed = git.has_changes()
138
+ except GitError as exc:
139
+ disp.finish_phase("fixer", status="error", detail=str(exc),
140
+ duration=fixer_duration, success=False)
141
+ stats["reason"] = "git_error"
142
+ iteration_log.append(iter_entry)
143
+ break
144
+
145
+ if not changed:
146
+ disp.finish_phase("fixer", status="no changes", detail="nothing to fix",
147
+ duration=fixer_duration, success=True)
148
+ iter_entry["fixer"] = {"status": "no changes", "detail": "nothing to fix"}
149
+ if last_breaker_signal != "issues":
150
+ stats["reason"] = "no_changes"
151
+ iteration_log.append(iter_entry)
152
+ break
153
+ iteration_log.append(iter_entry)
154
+ # Fall through to breaker
155
+
156
+ # ── Commit ────────────────────────────────────────────────────────
157
+ fixer_summary = (
158
+ signals.extract_summary(_fixer_output)
159
+ or signals.extract_commit_message(_fixer_output, fallback="")
160
+ )
161
+ commit_msg = signals.extract_commit_message(
162
+ _fixer_output, fallback=f"fix(edge): iteration {iteration}"
163
+ )
164
+ if not no_git and not no_commit:
165
+ try:
166
+ git.commit(commit_msg)
167
+ except GitError as exc:
168
+ disp.finish_phase("fixer", status="commit error", detail=str(exc),
169
+ duration=fixer_duration, success=False)
170
+ stats["reason"] = "git_error"
171
+ iteration_log.append(iter_entry)
172
+ break
173
+
174
+ disp.record_fix()
175
+ stats["fixes"] += 1
176
+ status_label = "changed" if (no_git or no_commit) else "committed"
177
+ fixer_detail = fixer_summary or commit_msg
178
+ disp.finish_phase("fixer", status=status_label, detail=fixer_detail,
179
+ duration=fixer_duration, success=True)
180
+ iter_entry["fixer"] = {"status": status_label, "detail": fixer_detail}
181
+
182
+ # ── Breaker phase ─────────────────────────────────────────────────
183
+ disp.start_phase("breaker")
184
+ breaker_start = time.monotonic()
185
+ try:
186
+ breaker_output = agent.run(break_text, verbose=verbose)
187
+ except AgentError as exc:
188
+ disp.finish_phase("breaker", status="error", detail=str(exc),
189
+ duration=time.monotonic() - breaker_start, success=False)
190
+ stats["reason"] = "agent_error"
191
+ iteration_log.append(iter_entry)
192
+ break
193
+
194
+ breaker_duration = time.monotonic() - breaker_start
195
+ signal = signals.parse(breaker_output)
196
+ last_breaker_signal = signal
197
+ last_breaker_output = breaker_output if signal == "issues" else None
198
+ breaker_detail = (
199
+ signals.extract_summary(breaker_output)
200
+ or signals.extract_finding(breaker_output)
201
+ )
202
+
203
+ if signal == "clear":
204
+ disp.finish_phase("breaker", status="all clear",
205
+ detail=breaker_detail or "no issues found",
206
+ duration=breaker_duration, success=True)
207
+ iter_entry["breaker"] = {"status": "all clear", "detail": breaker_detail}
208
+ stats["reason"] = "clear"
209
+ iteration_log.append(iter_entry)
210
+ break
211
+ else:
212
+ disp.record_issue()
213
+ stats["issues"] += 1
214
+ disp.finish_phase("breaker", status="issues",
215
+ detail=breaker_detail or "issues found",
216
+ duration=breaker_duration, success=False)
217
+ iter_entry["breaker"] = {"status": "issues", "detail": breaker_detail}
218
+ stats["reason"] = "issues"
219
+ iteration_log.append(iter_entry)
220
+
221
+ else:
222
+ stats["reason"] = "max_iterations"
223
+
224
+ stats["elapsed"] = time.monotonic() - start_time
225
+
226
+ # Live panel is fully closed before printing anything below
227
+ display.print_summary(stats)
228
+
229
+ # ── Write report ──────────────────────────────────────────────────────────
230
+ try:
231
+ report_path = report.write(stats, iteration_log, output_dir=prompt_path.parent)
232
+ if not no_git and not no_commit and stats["fixes"] > 0:
233
+ try:
234
+ git.commit(f"etch: add run report {report_path.name}", paths=[str(report_path)])
235
+ except GitError:
236
+ pass # report write is best-effort
237
+ display.print_report_saved(report_path)
238
+ except Exception:
239
+ pass # report is best-effort, never fail the run over it
@@ -0,0 +1,89 @@
1
+ """Run report generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ def write(
11
+ stats: dict[str, Any],
12
+ iterations: list[dict],
13
+ output_dir: Path | None = None,
14
+ ) -> Path:
15
+ """Write a markdown report for a completed etch run.
16
+
17
+ Args:
18
+ stats: Final stats dict from the loop.
19
+ iterations: List of per-iteration dicts with scanner/fixer/breaker findings.
20
+ output_dir: Directory to write the report. Defaults to cwd.
21
+
22
+ Returns:
23
+ Path to the written report file.
24
+ """
25
+ output_dir = output_dir or Path.cwd()
26
+ reports_dir = output_dir / "etch-reports"
27
+ reports_dir.mkdir(exist_ok=True)
28
+ timestamp = datetime.datetime.now().strftime("%Y-%m-%d-%H-%M")
29
+ path = reports_dir / f"etch-report-{timestamp}.md"
30
+
31
+ lines: list[str] = []
32
+ lines.append(f"# etch run — {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}\n")
33
+
34
+ reason = stats.get("reason", "done")
35
+ elapsed = _fmt_elapsed(stats.get("elapsed", 0.0))
36
+ lines.append(f"**outcome:** {_reason_label(reason)} ")
37
+ lines.append(f"**iterations:** {stats.get('iterations', 0)} ")
38
+ lines.append(f"**fixes:** {stats.get('fixes', 0)} ")
39
+ lines.append(f"**breaker issues:** {stats.get('issues', 0)} ")
40
+ lines.append(f"**elapsed:** {elapsed}\n")
41
+
42
+ for entry in iterations:
43
+ n = entry.get("n", "?")
44
+ lines.append(f"---\n\n## iteration {n}\n")
45
+
46
+ scanner = entry.get("scanner")
47
+ if scanner:
48
+ status = scanner.get("status", "")
49
+ detail = scanner.get("detail", "")
50
+ lines.append(f"**scanner** — {status}")
51
+ if detail:
52
+ lines.append(f"\n> {detail}\n")
53
+
54
+ fixer = entry.get("fixer")
55
+ if fixer:
56
+ status = fixer.get("status", "")
57
+ detail = fixer.get("detail", "")
58
+ lines.append(f"**fixer** — {status}")
59
+ if detail:
60
+ lines.append(f"\n> {detail}\n")
61
+
62
+ breaker = entry.get("breaker")
63
+ if breaker:
64
+ status = breaker.get("status", "")
65
+ detail = breaker.get("detail", "")
66
+ lines.append(f"**breaker** — {status}")
67
+ if detail:
68
+ lines.append(f"\n> {detail}\n")
69
+
70
+ path.write_text("\n".join(lines), encoding="utf-8")
71
+ return path
72
+
73
+
74
+ def _reason_label(reason: str) -> str:
75
+ return {
76
+ "clear": "all clear",
77
+ "no_changes": "clean — nothing to fix",
78
+ "max_iterations": "stopped — max iterations reached",
79
+ "interrupted": "interrupted",
80
+ "agent_error": "stopped — agent error",
81
+ "git_error": "stopped — git error",
82
+ }.get(reason, reason)
83
+
84
+
85
+ def _fmt_elapsed(seconds: float) -> str:
86
+ seconds = max(0.0, seconds)
87
+ mins = int(seconds // 60)
88
+ secs = int(seconds % 60)
89
+ return f"{mins}m {secs}s" if mins else f"{secs}s"
@@ -66,6 +66,8 @@ def extract_commit_message(output: str, fallback: str) -> str:
66
66
  continue
67
67
  # Trim to a reasonable length and strip trailing punctuation
68
68
  msg = stripped[:72].rstrip(".,;:")
69
+ if not msg:
70
+ continue
69
71
  if not msg.lower().startswith("fix"):
70
72
  msg = f"fix(edge): {msg[0].lower()}{msg[1:]}"
71
73
  return msg
@@ -73,6 +75,23 @@ def extract_commit_message(output: str, fallback: str) -> str:
73
75
  return fallback
74
76
 
75
77
 
78
+ def extract_summary(output: str) -> str:
79
+ """Extract the ETCH_SUMMARY line written by an agent.
80
+
81
+ Agents are prompted to write a line like:
82
+ ETCH_SUMMARY: fixed 3 null-guard issues in auth.py
83
+
84
+ Returns the summary text, or empty string if not found.
85
+ """
86
+ if not isinstance(output, str):
87
+ return ""
88
+ for line in output.splitlines():
89
+ stripped = line.strip()
90
+ if stripped.startswith("ETCH_SUMMARY:"):
91
+ return stripped[len("ETCH_SUMMARY:"):].strip()
92
+ return ""
93
+
94
+
76
95
  def extract_finding(output: str) -> str:
77
96
  """Extract first meaningful line before the signal token.
78
97
 
@@ -19,7 +19,10 @@ Be adversarial — think like someone actively trying to make this code fail.
19
19
 
20
20
  1. DO NOT edit any files — read only
21
21
  2. Report your findings clearly, one per line
22
- 3. End your output with EXACTLY one of these tokens on its own line:
22
+ 3. Before the signal token, write one line starting with `ETCH_SUMMARY:` summarising what you found:
23
+ - `ETCH_SUMMARY: 2 issues — unguarded empty list in sorter.py:14, exception swallowed in loader.py:67`
24
+ - `ETCH_SUMMARY: no issues found — code looks solid`
25
+ 4. End your output with EXACTLY one of these tokens on its own line:
23
26
  - `ETCH_ISSUES_FOUND` — if you found anything worth fixing
24
27
  - `ETCH_ALL_CLEAR` — if the code looks solid
25
28
 
@@ -23,7 +23,11 @@ Scan the codebase for:
23
23
 
24
24
  Focus on: [edit this to narrow your scope, e.g. "src/auth/", "the payment module"]
25
25
 
26
- ## Commit format
26
+ ## Output format
27
+
28
+ After making your changes, write one line at the end of your output:
29
+ ETCH_SUMMARY: <concise summary, e.g. "fixed 3 issues — added null guards in auth.py, guarded empty input in parser.py">
30
+ ETCH_SUMMARY: no changes — nothing to fix
27
31
 
28
32
  The harness commits automatically. Each commit will be:
29
33
  fix(edge): <short description of what was fixed>
@@ -23,7 +23,10 @@ For each issue, include the file path, line number (if known), and a one-line de
23
23
  5. List each confirmed issue on its own line, e.g.:
24
24
  - src/auth.py:42 — crashes with empty token string (no guard)
25
25
  - src/api.js:108 — unhandled promise rejection will silently fail
26
- 6. End your output with EXACTLY one of these tokens on its own line:
26
+ 6. Before the signal token, write one line starting with `ETCH_SUMMARY:` summarising what you found in plain English:
27
+ - `ETCH_SUMMARY: found 3 issues — null dereference in auth.py:42, off-by-one in parser.py:88, unhandled OSError in git.py:31`
28
+ - `ETCH_SUMMARY: no confirmed bugs found`
29
+ 7. End your output with EXACTLY one of these tokens on its own line:
27
30
  - `ETCH_ISSUES_FOUND` — if you found confirmed bugs worth fixing
28
31
  - `ETCH_ALL_CLEAR` — if the code looks solid or you found nothing certain
29
32
 
@@ -1 +0,0 @@
1
- __version__ = "0.2.1"
@@ -1,261 +0,0 @@
1
- """Core fix-break loop logic."""
2
-
3
- from __future__ import annotations
4
-
5
- import time
6
- from pathlib import Path
7
-
8
- from etch import agent, display, git, prompt, signals
9
- from etch.agent import AgentError
10
- from etch.git import GitError
11
- from etch.prompt import PromptError, load_scan
12
-
13
-
14
- def run(
15
- prompt_path: str | Path,
16
- max_iterations: int = 20,
17
- no_commit: bool = False,
18
- dry_run: bool = False,
19
- verbose: bool = False,
20
- focus: str | None = None,
21
- ) -> None:
22
- """Run the fix-break loop.
23
-
24
- Args:
25
- prompt_path: Path to ETCH.md (fixer prompt).
26
- max_iterations: Maximum number of fix-break cycles to run.
27
- no_commit: If True, skip git commits after fixer runs.
28
- dry_run: If True, print the prompt and exit without running.
29
- verbose: If True, stream agent output to the terminal.
30
- """
31
- prompt_path = Path(prompt_path)
32
-
33
- # ── Load fixer prompt ──────────────────────────────────────────────────────
34
- try:
35
- prompt_text = prompt.load(prompt_path)
36
- except PromptError as exc:
37
- display.print_error(str(exc))
38
- return
39
-
40
- if focus:
41
- prompt_text += f"\n\n## User focus\n\nConcentrate specifically on: {focus}\n"
42
-
43
- # ── Dry run ───────────────────────────────────────────────────────────────
44
- if dry_run:
45
- display.print_dry_run(prompt_text)
46
- return
47
-
48
- # ── Load scanner + breaker prompts early to fail fast ────────────────────
49
- try:
50
- scan_text = prompt.load_scan(prompt_path)
51
- except PromptError as exc:
52
- display.print_error(str(exc))
53
- return
54
-
55
- try:
56
- break_text = prompt.load_break(prompt_path)
57
- except PromptError as exc:
58
- display.print_error(str(exc))
59
- return
60
-
61
- if focus:
62
- scan_text += f"\n\n## User focus\n\nConcentrate on: {focus}\n"
63
- break_text += f"\n\n## User focus\n\nConcentrate your adversarial review on: {focus}\n"
64
-
65
- start_time = time.monotonic()
66
- stats: dict = {
67
- "iterations": 0,
68
- "fixes": 0,
69
- "issues": 0,
70
- "reason": "done",
71
- "elapsed": 0.0,
72
- }
73
- last_breaker_signal: str | None = None # None = breaker hasn't run yet
74
- last_breaker_output: str | None = None
75
-
76
- with display.EtchDisplay(target=str(prompt_path.parent)) as disp:
77
- for iteration in range(1, max_iterations + 1):
78
- stats["iterations"] = iteration
79
- disp.start_iteration(iteration)
80
-
81
- # ── Scanner phase ─────────────────────────────────────────────────
82
- disp.start_phase("scanner")
83
- scanner_start = time.monotonic()
84
- try:
85
- scanner_output = agent.run(scan_text, verbose=verbose)
86
- except AgentError as exc:
87
- disp.finish_phase(
88
- "scanner",
89
- status="error",
90
- detail=str(exc),
91
- duration=time.monotonic() - scanner_start,
92
- success=False,
93
- )
94
- stats["reason"] = "agent_error"
95
- break
96
-
97
- scanner_duration = time.monotonic() - scanner_start
98
- scanner_signal = signals.parse(scanner_output)
99
- scanner_finding = signals.extract_finding(scanner_output)
100
-
101
- if scanner_signal == "clear":
102
- disp.finish_phase(
103
- "scanner",
104
- status="all clear",
105
- detail=scanner_finding or "nothing to fix",
106
- duration=scanner_duration,
107
- success=True,
108
- )
109
- stats["reason"] = "no_changes"
110
- break
111
-
112
- disp.finish_phase(
113
- "scanner",
114
- status="issues found",
115
- detail=scanner_finding or "issues found",
116
- duration=scanner_duration,
117
- success=False,
118
- )
119
-
120
- # ── Build fixer prompt with scanner + breaker findings ─────────────
121
- fixer_prompt = prompt_text
122
- fixer_prompt += (
123
- f"\n\n## Scanner findings\n\n"
124
- f"{scanner_output.strip()}\n\n"
125
- f"Fix these specific issues.\n"
126
- )
127
- if last_breaker_output:
128
- fixer_prompt += (
129
- f"\n\n## Breaker findings from previous iteration\n\n"
130
- f"{last_breaker_output.strip()}\n\n"
131
- f"Also address these if not already covered above.\n"
132
- )
133
-
134
- # ── Fixer phase ───────────────────────────────────────────────────
135
- disp.start_phase("fixer")
136
- fixer_start = time.monotonic()
137
- try:
138
- _fixer_output = agent.run(fixer_prompt, verbose=verbose)
139
- except AgentError as exc:
140
- disp.finish_phase(
141
- "fixer",
142
- status="error",
143
- detail=str(exc),
144
- duration=time.monotonic() - fixer_start,
145
- success=False,
146
- )
147
- stats["reason"] = "agent_error"
148
- break
149
-
150
- fixer_duration = time.monotonic() - fixer_start
151
-
152
- # ── Check for changes ─────────────────────────────────────────────
153
- try:
154
- changed = git.has_changes()
155
- except GitError as exc:
156
- disp.finish_phase(
157
- "fixer",
158
- status="error",
159
- detail=str(exc),
160
- duration=fixer_duration,
161
- success=False,
162
- )
163
- stats["reason"] = "git_error"
164
- break
165
-
166
- if not changed:
167
- disp.finish_phase(
168
- "fixer",
169
- status="no changes",
170
- detail="nothing to fix",
171
- duration=fixer_duration,
172
- success=True,
173
- )
174
- # If the breaker has never run (first iteration with no diff),
175
- # stop immediately — nothing was ever changed, nothing to challenge.
176
- # If the breaker previously found issues, run it once more to
177
- # confirm whether those issues are still present or now resolved.
178
- if last_breaker_signal != "issues":
179
- stats["reason"] = "no_changes"
180
- break
181
- # Fall through to run the breaker one final time
182
-
183
- # ── Commit ────────────────────────────────────────────────────────
184
- commit_msg = signals.extract_commit_message(
185
- _fixer_output, fallback=f"fix(edge): iteration {iteration}"
186
- )
187
- if not no_commit:
188
- try:
189
- git.commit(commit_msg)
190
- except GitError as exc:
191
- disp.finish_phase(
192
- "fixer",
193
- status="commit error",
194
- detail=str(exc),
195
- duration=fixer_duration,
196
- success=False,
197
- )
198
- stats["reason"] = "git_error"
199
- break
200
-
201
- disp.record_fix()
202
- stats["fixes"] += 1
203
- disp.finish_phase(
204
- "fixer",
205
- status="committed" if not no_commit else "changed",
206
- detail=commit_msg,
207
- duration=fixer_duration,
208
- success=True,
209
- )
210
-
211
- # ── Breaker phase ─────────────────────────────────────────────────
212
- disp.start_phase("breaker")
213
- breaker_start = time.monotonic()
214
- try:
215
- breaker_output = agent.run(break_text, verbose=verbose)
216
- except AgentError as exc:
217
- disp.finish_phase(
218
- "breaker",
219
- status="error",
220
- detail=str(exc),
221
- duration=time.monotonic() - breaker_start,
222
- success=False,
223
- )
224
- stats["reason"] = "agent_error"
225
- break
226
-
227
- breaker_duration = time.monotonic() - breaker_start
228
- signal = signals.parse(breaker_output)
229
- last_breaker_signal = signal
230
- last_breaker_output = breaker_output if signal == "issues" else None
231
- finding = signals.extract_finding(breaker_output)
232
-
233
- if signal == "clear":
234
- disp.finish_phase(
235
- "breaker",
236
- status="all clear",
237
- detail=finding or "no issues found",
238
- duration=breaker_duration,
239
- success=True,
240
- )
241
- stats["reason"] = "clear"
242
- break
243
- else:
244
- disp.record_issue()
245
- stats["issues"] += 1
246
- disp.finish_phase(
247
- "breaker",
248
- status="issues",
249
- detail=finding or "issues found",
250
- duration=breaker_duration,
251
- success=False,
252
- )
253
- stats["reason"] = "issues"
254
- # Continue to next iteration
255
-
256
- else:
257
- # Loop exhausted without break
258
- stats["reason"] = "max_iterations"
259
-
260
- stats["elapsed"] = time.monotonic() - start_time
261
- disp.print_summary(stats)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes