etch-loop 0.5.2__tar.gz → 0.5.3__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.
- {etch_loop-0.5.2 → etch_loop-0.5.3}/PKG-INFO +1 -1
- etch_loop-0.5.3/etch-loop/BREAK.md +41 -0
- etch_loop-0.5.3/etch-loop/ETCH.md +40 -0
- etch_loop-0.5.3/etch-loop/RUN.md +36 -0
- etch_loop-0.5.3/etch-loop/SCAN.md +45 -0
- {etch_loop-0.5.2 → etch_loop-0.5.3}/pyproject.toml +1 -1
- etch_loop-0.5.3/src/etch/__init__.py +1 -0
- {etch_loop-0.5.2 → etch_loop-0.5.3}/src/etch/agent.py +21 -4
- {etch_loop-0.5.2 → etch_loop-0.5.3}/src/etch/analyze.py +14 -4
- {etch_loop-0.5.2 → etch_loop-0.5.3}/src/etch/display.py +16 -4
- {etch_loop-0.5.2 → etch_loop-0.5.3}/src/etch/git.py +23 -1
- {etch_loop-0.5.2 → etch_loop-0.5.3}/src/etch/loop.py +24 -10
- {etch_loop-0.5.2 → etch_loop-0.5.3}/src/etch/prompt.py +17 -5
- {etch_loop-0.5.2 → etch_loop-0.5.3}/src/etch/report.py +1 -1
- {etch_loop-0.5.2 → etch_loop-0.5.3}/src/etch/signals.py +5 -2
- {etch_loop-0.5.2 → etch_loop-0.5.3}/src/etch/templates/BREAK.md +1 -0
- {etch_loop-0.5.2 → etch_loop-0.5.3}/src/etch/templates/ETCH.md +1 -0
- {etch_loop-0.5.2 → etch_loop-0.5.3}/src/etch/templates/RUN.md +6 -1
- {etch_loop-0.5.2 → etch_loop-0.5.3}/src/etch/templates/SCAN.md +1 -0
- {etch_loop-0.5.2 → etch_loop-0.5.3}/uv.lock +1 -1
- etch_loop-0.5.2/src/etch/__init__.py +0 -1
- {etch_loop-0.5.2 → etch_loop-0.5.3}/.github/workflows/workflow.yml +0 -0
- {etch_loop-0.5.2 → etch_loop-0.5.3}/README.md +0 -0
- {etch_loop-0.5.2 → etch_loop-0.5.3}/src/etch/cli.py +0 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# BREAK — breaker prompt
|
|
2
|
+
|
|
3
|
+
You are an adversarial code reviewer. Your job is to find anything that could go wrong.
|
|
4
|
+
|
|
5
|
+
## Your mission
|
|
6
|
+
|
|
7
|
+
Scan the entire codebase with fresh eyes. Do not limit yourself to recent changes.
|
|
8
|
+
|
|
9
|
+
Look for:
|
|
10
|
+
- Edge cases and boundary conditions that are unhandled anywhere in the code
|
|
11
|
+
- Functions that assume valid input without checking
|
|
12
|
+
- Error paths that are silently swallowed or ignored
|
|
13
|
+
- Race conditions, off-by-one errors, null/empty/zero not guarded
|
|
14
|
+
- Anything that would cause unexpected behavior in production
|
|
15
|
+
|
|
16
|
+
Be adversarial — think like someone actively trying to make this code fail.
|
|
17
|
+
|
|
18
|
+
## Rules
|
|
19
|
+
|
|
20
|
+
1. DO NOT edit any files — read only
|
|
21
|
+
**IGNORE the `etch-loop/` directory entirely** — it contains etch tool metadata, not production code
|
|
22
|
+
2. Report your findings clearly, one per line
|
|
23
|
+
3. Before the signal token, write your summary in this exact format — it appears directly in the terminal:
|
|
24
|
+
`<etch_summary>2 issues — unguarded empty list in sorter.py:14, exception swallowed in loader.py:67</etch_summary>`
|
|
25
|
+
`<etch_summary>no issues found — code looks solid</etch_summary>`
|
|
26
|
+
**IMPORTANT: write `<etch_summary>` ONLY in your text response — never inside any file you read or edit.**
|
|
27
|
+
4. End with EXACTLY one of these on its own line:
|
|
28
|
+
`ETCH_ISSUES_FOUND`
|
|
29
|
+
`ETCH_ALL_CLEAR`
|
|
30
|
+
|
|
31
|
+
## Scope
|
|
32
|
+
|
|
33
|
+
- `loop.py`: The no_git/no_commit branch logic at line 181 (`if no_git or changed`) can reach the commit block even when `changed` is False if `no_git=True`, silently skipping the `git.has_changes()` call entirely. The `last_breaker_signal` fall-through at line 174 (fixer sees "no changes" but breaker had prior issues) proceeds to breaker without committing — the iteration state machine has several interacting flags that can produce silent no-ops.
|
|
34
|
+
|
|
35
|
+
- `agent.py`: The `stderr_reader` thread is joined with a 10-second timeout but its aliveness is never checked; a hung stderr drain can silently drop error output. If `process.kill()` is called after the stdout reader times out, `process.wait()` is called but `stderr_reader` may still be running, causing a race on `stderr_lines`.
|
|
36
|
+
|
|
37
|
+
- `signals.py` / `extract_commit_message`: The heuristic line-picker can return a token string (e.g. `ETCH_ISSUES_FOUND`) as a commit message if the token appears after other text rather than on its own line, since `parse()` requires exact-line match but `extract_commit_message` does not exclude token lines.
|
|
38
|
+
|
|
39
|
+
- `git.py` / `has_changes`: Exit code 128 (no commits yet) falls through to `git status --porcelain`, which correctly handles new repos, but `git diff --quiet HEAD` on an empty repo also silently ignores any staged changes — newly staged files in a repo with no HEAD commit would still register as changes, so the two-step detection logic is correct but fragile.
|
|
40
|
+
|
|
41
|
+
- `prompt.py`: `load_break` and `load_scan` deduplicate the cwd candidate only when `path` is already cwd — if `path.parent == cwd`, both the sibling candidate and the cwd fallback point to the same file, causing it to be read twice in the loop (harmless but surprising). More critically, `load_run` returns `None` for an empty `RUN.md` rather than raising, silently skipping the runner phase with no feedback.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# ETCH — fixer prompt
|
|
2
|
+
|
|
3
|
+
You are a surgical code reviewer focused on edge cases and robustness.
|
|
4
|
+
|
|
5
|
+
## Your mission
|
|
6
|
+
|
|
7
|
+
Scan the codebase for:
|
|
8
|
+
- Unhandled edge cases and boundary conditions
|
|
9
|
+
- Missing null/None/empty checks
|
|
10
|
+
- Unhandled exceptions and error paths
|
|
11
|
+
- Off-by-one errors
|
|
12
|
+
- Race conditions or unsafe concurrent access
|
|
13
|
+
- Missing input validation at system boundaries
|
|
14
|
+
|
|
15
|
+
## Rules
|
|
16
|
+
|
|
17
|
+
0. **IGNORE the `etch-loop/` directory entirely** — it contains etch tool metadata, not production code
|
|
18
|
+
1. Fix only what you find — do not refactor, rename, or reorganize
|
|
19
|
+
2. Do not add comments explaining what you fixed
|
|
20
|
+
3. If you find nothing, make no changes
|
|
21
|
+
|
|
22
|
+
## Scope
|
|
23
|
+
|
|
24
|
+
- `loop.py`: The no_git/no_commit branch logic at line 181 (`if no_git or changed`) can reach the commit block even when `changed` is False if `no_git=True`, silently skipping the `git.has_changes()` call entirely. The `last_breaker_signal` fall-through at line 174 (fixer sees "no changes" but breaker had prior issues) proceeds to breaker without committing — the iteration state machine has several interacting flags that can produce silent no-ops.
|
|
25
|
+
|
|
26
|
+
- `agent.py`: The `stderr_reader` thread is joined with a 10-second timeout but its aliveness is never checked; a hung stderr drain can silently drop error output. If `process.kill()` is called after the stdout reader times out, `process.wait()` is called but `stderr_reader` may still be running, causing a race on `stderr_lines`.
|
|
27
|
+
|
|
28
|
+
- `signals.py` / `extract_commit_message`: The heuristic line-picker can return a token string (e.g. `ETCH_ISSUES_FOUND`) as a commit message if the token appears after other text rather than on its own line, since `parse()` requires exact-line match but `extract_commit_message` does not exclude token lines.
|
|
29
|
+
|
|
30
|
+
- `git.py` / `has_changes`: Exit code 128 (no commits yet) falls through to `git status --porcelain`, which correctly handles new repos, but `git diff --quiet HEAD` on an empty repo also silently ignores any staged changes — newly staged files in a repo with no HEAD commit would still register as changes, so the two-step detection logic is correct but fragile.
|
|
31
|
+
|
|
32
|
+
- `prompt.py`: `load_break` and `load_scan` deduplicate the cwd candidate only when `path` is already cwd — if `path.parent == cwd`, both the sibling candidate and the cwd fallback point to the same file, causing it to be read twice in the loop (harmless but surprising). More critically, `load_run` returns `None` for an empty `RUN.md` rather than raising, silently skipping the runner phase with no feedback.
|
|
33
|
+
|
|
34
|
+
## Terminal output (required)
|
|
35
|
+
|
|
36
|
+
After making changes (or deciding there is nothing to fix), write your summary in this exact format — it appears in the terminal and is used as the commit message:
|
|
37
|
+
`<etch_summary>fixed 3 issues — null guard in auth.py, bounds check in parser.py, timeout in agent.py</etch_summary>`
|
|
38
|
+
`<etch_summary>nothing to fix — all reported issues were already handled</etch_summary>`
|
|
39
|
+
|
|
40
|
+
**IMPORTANT: write `<etch_summary>` ONLY in your text response — never inside any file you edit or create.**
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# RUN — test writer and build validator
|
|
2
|
+
|
|
3
|
+
You are a test engineer. The fixer has just made changes. Your job is to write tests for what was changed, then run the full suite to confirm everything works.
|
|
4
|
+
|
|
5
|
+
## Your mission
|
|
6
|
+
|
|
7
|
+
1. **Write or update tests** — look at what the fixer changed and write targeted tests covering:
|
|
8
|
+
- The specific edge cases that were fixed
|
|
9
|
+
- Boundary conditions around the changed code
|
|
10
|
+
- Any regression paths that could break silently
|
|
11
|
+
Write tests in the project's existing test style and location.
|
|
12
|
+
|
|
13
|
+
2. **Run the test suite** — run the full build and test commands to confirm everything passes.
|
|
14
|
+
|
|
15
|
+
## Build and test commands
|
|
16
|
+
|
|
17
|
+
- `python -m pytest`
|
|
18
|
+
|
|
19
|
+
## Rules
|
|
20
|
+
|
|
21
|
+
0. **IGNORE the `etch-loop/` directory entirely** — it contains etch tool metadata, not production code
|
|
22
|
+
1. You MAY edit test files — that is your job
|
|
23
|
+
2. Do NOT touch production code — only tests
|
|
24
|
+
3. If tests fail because of flawed test logic, fix the test and re-run before reporting
|
|
25
|
+
4. **After tests pass, clean up everything you created during this session:**
|
|
26
|
+
- Delete every test file you wrote
|
|
27
|
+
- Delete any `__pycache__` directories inside the test directory
|
|
28
|
+
- If you created the test directory itself, remove it entirely (e.g. `rm -rf tests/`)
|
|
29
|
+
- Leave no temporary files or empty directories behind
|
|
30
|
+
5. When done, write your summary in this exact format — it appears directly in the terminal:
|
|
31
|
+
`<etch_summary>wrote 4 tests, all 51 passed</etch_summary>`
|
|
32
|
+
`<etch_summary>2 tests failed — TypeError in test_auth.py:38, production bug in token.py:12</etch_summary>`
|
|
33
|
+
**IMPORTANT: write `<etch_summary>` ONLY in your text response — never inside any file you edit or create.**
|
|
34
|
+
6. End with EXACTLY one of these on its own line:
|
|
35
|
+
`ETCH_ALL_CLEAR` — if all tests pass
|
|
36
|
+
`ETCH_ISSUES_FOUND` — if tests reveal a bug in production code
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# SCAN — scanner prompt
|
|
2
|
+
|
|
3
|
+
You are a code analyst. Your job is to find genuine bugs before the fixer runs.
|
|
4
|
+
|
|
5
|
+
## Your mission
|
|
6
|
+
|
|
7
|
+
Read the codebase and produce a precise, actionable list of real issues:
|
|
8
|
+
- Unhandled edge cases and boundary conditions
|
|
9
|
+
- Missing null/None/empty checks that will cause crashes or wrong results
|
|
10
|
+
- Unhandled exceptions and error paths
|
|
11
|
+
- Off-by-one errors
|
|
12
|
+
- Race conditions or unsafe concurrent access
|
|
13
|
+
- Missing input validation at system boundaries
|
|
14
|
+
|
|
15
|
+
For each issue, include the file path, line number (if known), and a one-line description of what will go wrong.
|
|
16
|
+
|
|
17
|
+
## Rules
|
|
18
|
+
|
|
19
|
+
1. DO NOT edit any files — read only
|
|
20
|
+
**IGNORE the `etch-loop/` directory entirely** — it contains etch tool metadata, not production code
|
|
21
|
+
2. Only report issues you are confident are genuine bugs — not observations, not style, not "could be cleaner"
|
|
22
|
+
3. If something is already handled correctly, do NOT report it — even if the handling is unusual
|
|
23
|
+
4. If you are unsure whether something is a bug, leave it out
|
|
24
|
+
5. List each confirmed issue on its own line, e.g.:
|
|
25
|
+
- src/auth.py:42 — crashes with empty token string (no guard)
|
|
26
|
+
- src/api.js:108 — unhandled promise rejection will silently fail
|
|
27
|
+
6. Before the signal token, write your summary in this exact format — it appears directly in the terminal:
|
|
28
|
+
`<etch_summary>3 bugs found — null deref in auth.py:42, off-by-one in parser.py:88</etch_summary>`
|
|
29
|
+
`<etch_summary>no confirmed bugs found</etch_summary>`
|
|
30
|
+
**IMPORTANT: write `<etch_summary>` ONLY in your text response — never inside any file you read or edit.**
|
|
31
|
+
7. End with EXACTLY one of these on its own line:
|
|
32
|
+
`ETCH_ISSUES_FOUND`
|
|
33
|
+
`ETCH_ALL_CLEAR`
|
|
34
|
+
|
|
35
|
+
## Scope
|
|
36
|
+
|
|
37
|
+
- `loop.py`: The no_git/no_commit branch logic at line 181 (`if no_git or changed`) can reach the commit block even when `changed` is False if `no_git=True`, silently skipping the `git.has_changes()` call entirely. The `last_breaker_signal` fall-through at line 174 (fixer sees "no changes" but breaker had prior issues) proceeds to breaker without committing — the iteration state machine has several interacting flags that can produce silent no-ops.
|
|
38
|
+
|
|
39
|
+
- `agent.py`: The `stderr_reader` thread is joined with a 10-second timeout but its aliveness is never checked; a hung stderr drain can silently drop error output. If `process.kill()` is called after the stdout reader times out, `process.wait()` is called but `stderr_reader` may still be running, causing a race on `stderr_lines`.
|
|
40
|
+
|
|
41
|
+
- `signals.py` / `extract_commit_message`: The heuristic line-picker can return a token string (e.g. `ETCH_ISSUES_FOUND`) as a commit message if the token appears after other text rather than on its own line, since `parse()` requires exact-line match but `extract_commit_message` does not exclude token lines.
|
|
42
|
+
|
|
43
|
+
- `git.py` / `has_changes`: Exit code 128 (no commits yet) falls through to `git status --porcelain`, which correctly handles new repos, but `git diff --quiet HEAD` on an empty repo also silently ignores any staged changes — newly staged files in a repo with no HEAD commit would still register as changes, so the two-step detection logic is correct but fragile.
|
|
44
|
+
|
|
45
|
+
- `prompt.py`: `load_break` and `load_scan` deduplicate the cwd candidate only when `path` is already cwd — if `path.parent == cwd`, both the sibling candidate and the cwd fallback point to the same file, causing it to be read twice in the loop (harmless but surprising). More critically, `load_run` returns `None` for an empty `RUN.md` rather than raising, silently skipping the runner phase with no feedback.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.5.3"
|
|
@@ -68,10 +68,18 @@ def run(
|
|
|
68
68
|
if stdin_writer.is_alive():
|
|
69
69
|
process.kill()
|
|
70
70
|
process.wait()
|
|
71
|
+
try:
|
|
72
|
+
process.stdin.close()
|
|
73
|
+
except OSError:
|
|
74
|
+
pass
|
|
71
75
|
raise AgentError("Timed out writing prompt to claude stdin")
|
|
72
76
|
if stdin_exc:
|
|
73
77
|
process.kill()
|
|
74
78
|
process.wait()
|
|
79
|
+
try:
|
|
80
|
+
process.stdin.close()
|
|
81
|
+
except OSError:
|
|
82
|
+
pass
|
|
75
83
|
raise AgentError(f"Failed to write prompt to claude stdin: {stdin_exc[0]}") from stdin_exc[0]
|
|
76
84
|
|
|
77
85
|
output_lines: list[str] = []
|
|
@@ -89,7 +97,8 @@ def run(
|
|
|
89
97
|
|
|
90
98
|
def read_stderr() -> None:
|
|
91
99
|
for line in process.stderr:
|
|
92
|
-
|
|
100
|
+
with lock:
|
|
101
|
+
stderr_lines.append(line)
|
|
93
102
|
|
|
94
103
|
reader = threading.Thread(target=read_stdout, daemon=True)
|
|
95
104
|
stderr_reader = threading.Thread(target=read_stderr, daemon=True)
|
|
@@ -99,10 +108,12 @@ def run(
|
|
|
99
108
|
if reader.is_alive():
|
|
100
109
|
process.kill()
|
|
101
110
|
process.wait()
|
|
111
|
+
stderr_reader.join(timeout=10)
|
|
112
|
+
if stderr_reader.is_alive():
|
|
113
|
+
with lock:
|
|
114
|
+
stderr_lines.append("[stderr reader timed out]")
|
|
102
115
|
raise AgentError("claude subprocess timed out (output reader still running)")
|
|
103
116
|
|
|
104
|
-
stderr_reader.join(timeout=10)
|
|
105
|
-
|
|
106
117
|
try:
|
|
107
118
|
process.wait(timeout=10)
|
|
108
119
|
except subprocess.TimeoutExpired:
|
|
@@ -110,7 +121,13 @@ def run(
|
|
|
110
121
|
process.wait()
|
|
111
122
|
raise AgentError("claude subprocess timed out waiting for exit")
|
|
112
123
|
|
|
113
|
-
|
|
124
|
+
stderr_reader.join(timeout=10)
|
|
125
|
+
if stderr_reader.is_alive():
|
|
126
|
+
with lock:
|
|
127
|
+
stderr_lines.append("[stderr reader timed out]")
|
|
128
|
+
|
|
129
|
+
with lock:
|
|
130
|
+
stderr_output = "".join(stderr_lines).strip()
|
|
114
131
|
|
|
115
132
|
if process.returncode != 0:
|
|
116
133
|
detail = stderr_output or "(no stderr)"
|
|
@@ -173,6 +173,7 @@ For each issue, include the file path, line number (if known), and a one-line de
|
|
|
173
173
|
## Rules
|
|
174
174
|
|
|
175
175
|
1. DO NOT edit any files — read only
|
|
176
|
+
**IGNORE the `etch-loop/` directory entirely** — it contains etch tool metadata, not production code
|
|
176
177
|
2. Only report issues you are confident are genuine bugs — not observations, not style, not "could be cleaner"
|
|
177
178
|
3. If something is already handled correctly, do NOT report it — even if the handling is unusual
|
|
178
179
|
4. If you are unsure whether something is a bug, leave it out
|
|
@@ -213,6 +214,7 @@ Scan the codebase for:
|
|
|
213
214
|
|
|
214
215
|
## Rules
|
|
215
216
|
|
|
217
|
+
0. **IGNORE the `etch-loop/` directory entirely** — it contains etch tool metadata, not production code
|
|
216
218
|
1. Fix only what you find — do not refactor, rename, or reorganize
|
|
217
219
|
2. Do not add comments explaining what you fixed
|
|
218
220
|
3. If you find nothing, make no changes
|
|
@@ -255,6 +257,7 @@ Be adversarial — think like someone actively trying to make this code fail.
|
|
|
255
257
|
## Rules
|
|
256
258
|
|
|
257
259
|
1. DO NOT edit any files — read only
|
|
260
|
+
**IGNORE the `etch-loop/` directory entirely** — it contains etch tool metadata, not production code
|
|
258
261
|
2. Report your findings clearly, one per line
|
|
259
262
|
3. Before the signal token, write your summary in this exact format — it appears directly in the terminal:
|
|
260
263
|
`<etch_summary>2 issues — unguarded empty list in sorter.py:14, exception swallowed in loader.py:67</etch_summary>`
|
|
@@ -300,10 +303,15 @@ You are a test engineer. The fixer has just made changes. Your job is to write t
|
|
|
300
303
|
|
|
301
304
|
## Rules
|
|
302
305
|
|
|
306
|
+
0. **IGNORE the `etch-loop/` directory entirely** — it contains etch tool metadata, not production code
|
|
303
307
|
1. You MAY edit test files — that is your job
|
|
304
308
|
2. Do NOT touch production code — only tests
|
|
305
309
|
3. If tests fail because of flawed test logic, fix the test and re-run before reporting
|
|
306
|
-
4. **After tests pass,
|
|
310
|
+
4. **After tests pass, clean up everything you created during this session:**
|
|
311
|
+
- Delete every test file you wrote
|
|
312
|
+
- Delete any `__pycache__` directories inside the test directory
|
|
313
|
+
- If you created the test directory itself, remove it entirely (e.g. `rm -rf tests/`)
|
|
314
|
+
- Leave no temporary files or empty directories behind
|
|
307
315
|
5. When done, write your summary in this exact format — it appears directly in the terminal:
|
|
308
316
|
`<etch_summary>wrote 4 tests, all 51 passed</etch_summary>`
|
|
309
317
|
`<etch_summary>2 tests failed — TypeError in test_auth.py:38, production bug in token.py:12</etch_summary>`
|
|
@@ -324,7 +332,9 @@ def _detect_run_commands(root: Path) -> list[str]:
|
|
|
324
332
|
if (root / "package.json").exists():
|
|
325
333
|
try:
|
|
326
334
|
pkg = json.loads((root / "package.json").read_text(encoding="utf-8"))
|
|
327
|
-
scripts = pkg.get("scripts"
|
|
335
|
+
scripts = pkg.get("scripts") or {}
|
|
336
|
+
if not isinstance(scripts, dict):
|
|
337
|
+
scripts = {}
|
|
328
338
|
if "build" in scripts:
|
|
329
339
|
commands.append("npm run build")
|
|
330
340
|
if "test" in scripts:
|
|
@@ -379,14 +389,14 @@ def _list_files(root: Path) -> list[str]:
|
|
|
379
389
|
)
|
|
380
390
|
if result.returncode == 0 and result.stdout.strip():
|
|
381
391
|
return result.stdout.strip().splitlines()
|
|
382
|
-
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
392
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
383
393
|
pass
|
|
384
394
|
|
|
385
395
|
# Fallback: walk filesystem
|
|
386
396
|
files = []
|
|
387
397
|
try:
|
|
388
398
|
for p in root.rglob("*"):
|
|
389
|
-
if p.is_file() and not any(part in _SKIP_DIRS for part in p.parts):
|
|
399
|
+
if p.is_file() and not any(part in _SKIP_DIRS for part in p.relative_to(root).parts):
|
|
390
400
|
try:
|
|
391
401
|
files.append(str(p.relative_to(root)))
|
|
392
402
|
except ValueError:
|
|
@@ -287,8 +287,10 @@ class EtchDisplay:
|
|
|
287
287
|
)
|
|
288
288
|
|
|
289
289
|
def _refresh(self) -> None:
|
|
290
|
-
|
|
291
|
-
|
|
290
|
+
rendered = self._render()
|
|
291
|
+
with self._lock:
|
|
292
|
+
if self._live is not None:
|
|
293
|
+
self._live.update(rendered)
|
|
292
294
|
|
|
293
295
|
# ── Ticker thread ─────────────────────────────────────────────────────────
|
|
294
296
|
|
|
@@ -303,6 +305,9 @@ class EtchDisplay:
|
|
|
303
305
|
self._ticker_stop.set()
|
|
304
306
|
if self._ticker_thread is not None:
|
|
305
307
|
self._ticker_thread.join(timeout=1.0)
|
|
308
|
+
if self._ticker_thread.is_alive():
|
|
309
|
+
with self._lock:
|
|
310
|
+
self._live = None
|
|
306
311
|
self._ticker_thread = None
|
|
307
312
|
|
|
308
313
|
def _ticker_loop(self) -> None:
|
|
@@ -401,8 +406,10 @@ class InitDisplay:
|
|
|
401
406
|
)
|
|
402
407
|
|
|
403
408
|
def _refresh(self) -> None:
|
|
404
|
-
|
|
405
|
-
|
|
409
|
+
rendered = self._render()
|
|
410
|
+
with self._lock:
|
|
411
|
+
if self._live is not None:
|
|
412
|
+
self._live.update(rendered)
|
|
406
413
|
|
|
407
414
|
def _start_ticker(self) -> None:
|
|
408
415
|
self._ticker_stop.clear()
|
|
@@ -413,6 +420,9 @@ class InitDisplay:
|
|
|
413
420
|
self._ticker_stop.set()
|
|
414
421
|
if self._ticker_thread is not None:
|
|
415
422
|
self._ticker_thread.join(timeout=1.0)
|
|
423
|
+
if self._ticker_thread.is_alive():
|
|
424
|
+
with self._lock:
|
|
425
|
+
self._live = None
|
|
416
426
|
self._ticker_thread = None
|
|
417
427
|
|
|
418
428
|
def _ticker_loop(self) -> None:
|
|
@@ -478,6 +488,8 @@ def print_summary(stats: dict[str, Any]) -> None:
|
|
|
478
488
|
title = f"[{RED}]x agent error[/{RED}]"
|
|
479
489
|
elif reason == "git_error":
|
|
480
490
|
title = f"[{RED}]x git error[/{RED}]"
|
|
491
|
+
elif reason == "stalled":
|
|
492
|
+
title = f"[{AMBER}]- stalled (fixer found nothing)[/{AMBER}]"
|
|
481
493
|
else:
|
|
482
494
|
title = f"[{FG}]done[/{FG}]"
|
|
483
495
|
|
|
@@ -56,6 +56,28 @@ def has_changes() -> bool:
|
|
|
56
56
|
return bool(status.stdout.strip())
|
|
57
57
|
|
|
58
58
|
|
|
59
|
+
def changed_files(since_commits: int = 1) -> list[str]:
|
|
60
|
+
"""Return files changed in the last N commits.
|
|
61
|
+
|
|
62
|
+
Used to focus the breaker on files the fixer actually touched,
|
|
63
|
+
rather than the entire codebase.
|
|
64
|
+
|
|
65
|
+
Returns an empty list if git is unavailable or no commits exist.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
result = subprocess.run(
|
|
69
|
+
["git", "diff", "--name-only", f"HEAD~{since_commits}", "HEAD"],
|
|
70
|
+
capture_output=True,
|
|
71
|
+
text=True,
|
|
72
|
+
timeout=10,
|
|
73
|
+
)
|
|
74
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
75
|
+
return result.stdout.strip().splitlines()
|
|
76
|
+
except (OSError, FileNotFoundError, subprocess.TimeoutExpired):
|
|
77
|
+
pass
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
|
|
59
81
|
def commit(message: str, paths: list[str] | None = None) -> None:
|
|
60
82
|
"""Stage all changes and create a commit.
|
|
61
83
|
|
|
@@ -69,7 +91,7 @@ def commit(message: str, paths: list[str] | None = None) -> None:
|
|
|
69
91
|
raise GitError("Commit message must not be empty.")
|
|
70
92
|
|
|
71
93
|
# Stage all changes (or specific paths)
|
|
72
|
-
add_cmd = ["git", "add"] + (paths if paths
|
|
94
|
+
add_cmd = ["git", "add"] + (paths if paths else ["--all"])
|
|
73
95
|
try:
|
|
74
96
|
add_result = subprocess.run(
|
|
75
97
|
add_cmd,
|
|
@@ -157,6 +157,7 @@ def run(
|
|
|
157
157
|
fixer_duration = time.monotonic() - fixer_start
|
|
158
158
|
|
|
159
159
|
# ── Check for changes (skipped when no_git) ───────────────────────
|
|
160
|
+
changed = False
|
|
160
161
|
if not no_git:
|
|
161
162
|
try:
|
|
162
163
|
changed = git.has_changes()
|
|
@@ -171,11 +172,9 @@ def run(
|
|
|
171
172
|
disp.finish_phase("fixer", status="no changes", detail="nothing to fix",
|
|
172
173
|
duration=fixer_duration, success=True)
|
|
173
174
|
iter_entry["fixer"] = {"status": "no changes", "detail": "nothing to fix"}
|
|
174
|
-
if last_breaker_signal
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
break
|
|
178
|
-
# Fall through to breaker
|
|
175
|
+
stats["reason"] = "stalled" if last_breaker_signal == "issues" else "no_changes"
|
|
176
|
+
iteration_log.append(iter_entry)
|
|
177
|
+
break
|
|
179
178
|
|
|
180
179
|
# ── Commit ────────────────────────────────────────────────────────
|
|
181
180
|
if no_git or changed:
|
|
@@ -196,9 +195,10 @@ def run(
|
|
|
196
195
|
iteration_log.append(iter_entry)
|
|
197
196
|
break
|
|
198
197
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
198
|
+
if changed or (no_git and fixer_summary and not fixer_summary.lower().startswith("nothing")):
|
|
199
|
+
disp.record_fix()
|
|
200
|
+
stats["fixes"] += 1
|
|
201
|
+
status_label = "no-git" if no_git else ("changed" if no_commit else "committed")
|
|
202
202
|
fixer_detail = fixer_summary or commit_msg
|
|
203
203
|
disp.finish_phase("fixer", status=status_label, detail=fixer_detail,
|
|
204
204
|
duration=fixer_duration, success=True)
|
|
@@ -207,8 +207,22 @@ def run(
|
|
|
207
207
|
# ── Breaker phase ─────────────────────────────────────────────────
|
|
208
208
|
disp.start_phase("breaker")
|
|
209
209
|
breaker_start = time.monotonic()
|
|
210
|
+
# Focus the breaker only on files the fixer actually changed.
|
|
211
|
+
# This prevents the breaker from finding brand-new issues in
|
|
212
|
+
# untouched files, which causes the loop to thrash.
|
|
213
|
+
effective_break_text = break_text
|
|
214
|
+
if not no_git:
|
|
215
|
+
recent_files = git.changed_files(since_commits=1)
|
|
216
|
+
if recent_files:
|
|
217
|
+
files_list = "\n".join(f"- {f}" for f in recent_files)
|
|
218
|
+
effective_break_text = break_text + (
|
|
219
|
+
f"\n\n## Scope for this iteration\n\n"
|
|
220
|
+
f"The fixer just changed these files — review ONLY these:\n"
|
|
221
|
+
f"{files_list}\n\n"
|
|
222
|
+
f"Do not scan files that were not changed.\n"
|
|
223
|
+
)
|
|
210
224
|
try:
|
|
211
|
-
breaker_output = agent.run(
|
|
225
|
+
breaker_output = agent.run(effective_break_text, verbose=verbose)
|
|
212
226
|
except AgentError as exc:
|
|
213
227
|
disp.finish_phase("breaker", status="error", detail=str(exc),
|
|
214
228
|
duration=time.monotonic() - breaker_start, success=False)
|
|
@@ -255,7 +269,7 @@ def run(
|
|
|
255
269
|
stats["reason"] = "max_iterations"
|
|
256
270
|
|
|
257
271
|
# ── Runner — final step, only when loop ended cleanly ─────────────────
|
|
258
|
-
if run_text and stats["reason"]
|
|
272
|
+
if run_text and stats["reason"] == "clear":
|
|
259
273
|
disp.start_phase("runner")
|
|
260
274
|
runner_start = time.monotonic()
|
|
261
275
|
try:
|
|
@@ -25,7 +25,10 @@ def load(path: str | Path) -> str:
|
|
|
25
25
|
if not p.is_file():
|
|
26
26
|
raise PromptError(f"Prompt path is not a file: {p}")
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
try:
|
|
29
|
+
content = p.read_text(encoding="utf-8")
|
|
30
|
+
except FileNotFoundError:
|
|
31
|
+
raise PromptError(f"Prompt file not found: {p}")
|
|
29
32
|
if not content.strip():
|
|
30
33
|
raise PromptError(f"Prompt file is empty: {p}")
|
|
31
34
|
|
|
@@ -66,7 +69,10 @@ def load_break(path: str | Path | None = None) -> str:
|
|
|
66
69
|
|
|
67
70
|
for candidate in candidates:
|
|
68
71
|
if candidate.exists() and candidate.is_file():
|
|
69
|
-
|
|
72
|
+
try:
|
|
73
|
+
content = candidate.read_text(encoding="utf-8")
|
|
74
|
+
except FileNotFoundError:
|
|
75
|
+
continue
|
|
70
76
|
if not content.strip():
|
|
71
77
|
raise PromptError(f"BREAK.md is empty: {candidate}")
|
|
72
78
|
return content
|
|
@@ -97,9 +103,12 @@ def load_run(path: str | Path | None = None) -> str | None:
|
|
|
97
103
|
|
|
98
104
|
for candidate in candidates:
|
|
99
105
|
if candidate.exists() and candidate.is_file():
|
|
100
|
-
|
|
106
|
+
try:
|
|
107
|
+
content = candidate.read_text(encoding="utf-8")
|
|
108
|
+
except FileNotFoundError:
|
|
109
|
+
continue
|
|
101
110
|
if not content.strip():
|
|
102
|
-
|
|
111
|
+
raise PromptError(f"RUN.md is empty: {candidate}")
|
|
103
112
|
return content
|
|
104
113
|
|
|
105
114
|
return None # Optional phase — no error if absent
|
|
@@ -126,7 +135,10 @@ def load_scan(path: str | Path | None = None) -> str:
|
|
|
126
135
|
|
|
127
136
|
for candidate in candidates:
|
|
128
137
|
if candidate.exists() and candidate.is_file():
|
|
129
|
-
|
|
138
|
+
try:
|
|
139
|
+
content = candidate.read_text(encoding="utf-8")
|
|
140
|
+
except FileNotFoundError:
|
|
141
|
+
continue
|
|
130
142
|
if not content.strip():
|
|
131
143
|
raise PromptError(f"SCAN.md is empty: {candidate}")
|
|
132
144
|
return content
|
|
@@ -25,7 +25,7 @@ def write(
|
|
|
25
25
|
output_dir = output_dir or Path.cwd()
|
|
26
26
|
reports_dir = output_dir / "etch-reports"
|
|
27
27
|
reports_dir.mkdir(exist_ok=True)
|
|
28
|
-
timestamp = datetime.datetime.now().strftime("%Y-%m-%d-%H-%M")
|
|
28
|
+
timestamp = datetime.datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
|
|
29
29
|
path = reports_dir / f"etch-report-{timestamp}.md"
|
|
30
30
|
|
|
31
31
|
lines: list[str] = []
|
|
@@ -65,6 +65,9 @@ def extract_commit_message(output: str, fallback: str) -> str:
|
|
|
65
65
|
stripped = line.strip().lstrip("-*•").strip().strip("`").strip()
|
|
66
66
|
if not stripped or len(stripped) < 8:
|
|
67
67
|
continue
|
|
68
|
+
bare = line.strip().strip("`").strip()
|
|
69
|
+
if bare == _TOKEN_CLEAR or bare == _TOKEN_ISSUES:
|
|
70
|
+
break
|
|
68
71
|
if stripped.startswith("#"):
|
|
69
72
|
continue
|
|
70
73
|
if all(c in _PUNCTUATION_ONLY for c in stripped):
|
|
@@ -97,7 +100,7 @@ def extract_summary(output: str) -> str:
|
|
|
97
100
|
return ""
|
|
98
101
|
m = re.search(r"<etch_summary>(.*?)</etch_summary>", output, re.DOTALL)
|
|
99
102
|
if m:
|
|
100
|
-
return m.group(1).
|
|
103
|
+
return " ".join(m.group(1).split())
|
|
101
104
|
return ""
|
|
102
105
|
|
|
103
106
|
|
|
@@ -113,7 +116,7 @@ def extract_finding(output: str) -> str:
|
|
|
113
116
|
lines_before: list[str] = []
|
|
114
117
|
for line in output.splitlines():
|
|
115
118
|
stripped = line.strip().strip("`").strip()
|
|
116
|
-
if stripped
|
|
119
|
+
if stripped == _TOKEN_CLEAR or stripped == _TOKEN_ISSUES:
|
|
117
120
|
break
|
|
118
121
|
lines_before.append(line)
|
|
119
122
|
|
|
@@ -18,6 +18,7 @@ Be adversarial — think like someone actively trying to make this code fail.
|
|
|
18
18
|
## Rules
|
|
19
19
|
|
|
20
20
|
1. DO NOT edit any files — read only
|
|
21
|
+
**IGNORE the `etch-loop/` directory entirely** — it contains etch tool metadata, not production code
|
|
21
22
|
2. Report your findings clearly, one per line
|
|
22
23
|
3. Before the signal token, write your summary in this exact format — it appears directly in the terminal:
|
|
23
24
|
`<etch_summary>2 issues — unguarded empty list in sorter.py:14, exception swallowed in loader.py:67</etch_summary>`
|
|
@@ -14,6 +14,7 @@ Scan the codebase for:
|
|
|
14
14
|
|
|
15
15
|
## Rules
|
|
16
16
|
|
|
17
|
+
0. **IGNORE the `etch-loop/` directory entirely** — it contains etch tool metadata, not production code
|
|
17
18
|
1. Fix only what you find — do not refactor, rename, or reorganize
|
|
18
19
|
2. Do not add comments explaining what you fixed
|
|
19
20
|
3. If you find nothing, make no changes
|
|
@@ -18,10 +18,15 @@ You are a test engineer. The fixer has just made changes. Your job is to write t
|
|
|
18
18
|
|
|
19
19
|
## Rules
|
|
20
20
|
|
|
21
|
+
0. **IGNORE the `etch-loop/` directory entirely** — it contains etch tool metadata, not production code
|
|
21
22
|
1. You MAY edit test files — that is your job
|
|
22
23
|
2. Do NOT touch production code — only tests
|
|
23
24
|
3. If tests fail because of flawed test logic, fix the test and re-run before reporting
|
|
24
|
-
4. **After tests pass,
|
|
25
|
+
4. **After tests pass, clean up everything you created during this session:**
|
|
26
|
+
- Delete every test file you wrote
|
|
27
|
+
- Delete any `__pycache__` directories inside the test directory
|
|
28
|
+
- If you created the test directory itself, remove it entirely (e.g. `rm -rf tests/`)
|
|
29
|
+
- Leave no temporary files or empty directories behind
|
|
25
30
|
5. When done, write your summary in this exact format — it appears directly in the terminal:
|
|
26
31
|
`<etch_summary>wrote 4 tests, all 51 passed</etch_summary>`
|
|
27
32
|
`<etch_summary>2 tests failed — TypeError in test_auth.py:38, production bug in token.py:12</etch_summary>`
|
|
@@ -17,6 +17,7 @@ For each issue, include the file path, line number (if known), and a one-line de
|
|
|
17
17
|
## Rules
|
|
18
18
|
|
|
19
19
|
1. DO NOT edit any files — read only
|
|
20
|
+
**IGNORE the `etch-loop/` directory entirely** — it contains etch tool metadata, not production code
|
|
20
21
|
2. Only report issues you are confident are genuine bugs — not observations, not style, not "could be cleaner"
|
|
21
22
|
3. If something is already handled correctly, do NOT report it — even if the handling is unusual
|
|
22
23
|
4. If you are unsure whether something is a bug, leave it out
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.5.2"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|