-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathagentloop.py
More file actions
527 lines (446 loc) · 18.7 KB
/
Copy pathagentloop.py
File metadata and controls
527 lines (446 loc) · 18.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
#!/usr/bin/env python3
"""Simple agentic loop over a Trello kanban board, powered by a Claude
subscription (Claude Code CLI, headless) instead of massive API parallelism.
Kanban mapping:
To Do -> picked up and moved to In Progress
In Progress -> implementer works here (writes code, commits)
In Review -> adversarial reviewer works here (reads the diff only)
Done -> reviewer approved
Needs Human -> too many review rounds without approval, stops looping
"""
from __future__ import annotations
import argparse
import os
import re
import sys
import time
from dotenv import load_dotenv
from claude_runner import (
ClaudeRunError,
ensure_worktree,
extract_json_block,
git,
merge_branch,
remove_worktree,
run_claude,
)
from trello_client import TrelloClient
SHA_MARKER_RE = re.compile(r"\[agentloop\] before=(\S+) after=(\S+)")
CLEANUP_PENDING_RE = re.compile(
r"^\[agentloop\] cleanup pending: branch=(\S+) attempts=(\d+)", re.MULTILINE)
CLEANUP_WORKTREE_RE = re.compile(r"^worktree=(.*)$", re.MULTILINE)
# After this many failed retries, stop retrying and leave a permanent
# comment instead of retrying silently forever.
MAX_CLEANUP_ATTEMPTS = 5
IMPLEMENTER_RULES = (
"You are operating unattended in headless mode as part of an automated "
"kanban pipeline. Rules:\n"
"- Never run `git reset --hard`, `git stash`, or `git clean -f`.\n"
"- Stage only the specific files you changed (`git add <file>`), never "
"`git add -A` or `git add .`.\n"
"- Make exactly one commit for this task before finishing.\n"
"- Do not leave stub functions or TODO placeholders — implement the task "
"fully.\n"
"- Do not write long comments justifying a workaround; fix the root "
"cause instead."
)
REVIEWER_RULES = (
"You are an adversarial code reviewer. Assume the diff below is wrong "
"until proven otherwise. Look for concrete bugs: logic errors, edge "
"cases, resource/memory issues, incorrect assumptions. Reject any "
"comment in the diff that merely justifies a workaround instead of "
"fixing the root cause. You are read-only — do not modify any files.\n\n"
"End your response with exactly one fenced json block:\n"
'```json\n{"verdict": "approve" or "request_changes", "summary": '
'"...", "issues": ["..."]}\n```'
)
REQUIRED_DOCS = ("CONVENTIONS.md", "CONTEXT.md")
def log(msg: str) -> None:
print(f"[agentloop] {msg}", flush=True)
def missing_docs(repo: str) -> list[str]:
docs_dir = os.path.join(repo, "docs")
return [name for name in REQUIRED_DOCS
if not os.path.isfile(os.path.join(docs_dir, name))]
def check_repo_or_exit(repo: str) -> None:
if not os.path.isdir(repo):
sys.exit(f"--repo path does not exist or is not a directory: {repo}")
if not os.path.exists(os.path.join(repo, ".git")):
sys.exit(f"--repo is not a git repository (no .git found): {repo}")
def check_docs_or_confirm(repo: str, assume_yes: bool, dry_run: bool) -> None:
missing = missing_docs(repo)
if not missing:
return
log(
f"warning: {repo} is missing docs/{' and docs/'.join(missing)}. "
"Without project conventions/context, the implementer is more likely "
"to hallucinate or diverge from how this codebase actually works."
)
if assume_yes:
log("--yes passed, continuing despite missing docs")
return
if dry_run:
log("--dry-run passed, continuing without prompting (dry runs never mutate state)")
return
if not sys.stdin.isatty():
raise SystemExit(
"Refusing to continue without docs/CONVENTIONS.md and docs/CONTEXT.md "
"in a non-interactive session. Add the missing file(s) or re-run with "
"--yes to proceed anyway."
)
try:
answer = input("Continue anyway? [y/N] ").strip().lower()
except EOFError:
raise SystemExit(
"Aborted: stdin closed before confirmation could be read"
)
if answer not in ("y", "yes"):
raise SystemExit("Aborted: missing docs/CONVENTIONS.md and/or docs/CONTEXT.md")
def resolve_list_ids(client: TrelloClient, names: dict[str, str]) -> dict[str, str | None]:
resolved = {}
for key, name in names.items():
list_id = client.list_id_by_name(name)
if list_id is None and key != "needs_human":
raise SystemExit(
f"Could not find Trello list named '{name}' on the board")
resolved[key] = list_id
return resolved
def latest_request_changes_comment(comments: list[str]) -> str | None:
for text in comments:
if text.startswith("[review] REQUEST_CHANGES"):
return text
return None
def count_request_changes(comments: list[str]) -> int:
return sum(1 for text in comments if text.startswith("[review] REQUEST_CHANGES"))
def parse_last_shas(comments: list[str]) -> tuple[str, str] | tuple[None, None]:
for text in comments:
match = SHA_MARKER_RE.search(text)
if match:
return match.group(1), match.group(2)
return None, None
def slugify(text: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
return slug[:40].strip("-") or "task"
def branch_name_for_card(card: dict) -> str:
return f"agentloop/{slugify(card['name'])}-{card['id'][:8]}"
def worktree_path_for(worktree_root: str, branch: str) -> str:
return os.path.join(worktree_root, branch.replace("/", "-"))
def build_implement_prompt(card: dict, feedback: str | None) -> str:
prompt = f"Task from Trello card '{card['name']}':\n\n{card.get('desc') or '(no description)'}"
if feedback:
prompt += f"\n\nA previous review requested changes:\n{feedback}\n\nAddress this feedback."
return prompt
def build_review_prompt(card: dict, diff: str) -> str:
return (
f"You are reviewing a code change for the Trello task '{card['name']}'.\n\n"
f"Task description:\n{card.get('desc') or '(no description)'}\n\n"
f"Diff to review:\n```diff\n{diff}\n```\n\n{REVIEWER_RULES}"
)
def implement(
client: TrelloClient,
card: dict,
repo: str,
list_ids: dict,
dry_run: bool,
base_branch: str,
worktree_root: str,
) -> None:
comments = client.comments(card["id"])
feedback = latest_request_changes_comment(comments)
prompt = build_implement_prompt(card, feedback)
branch = branch_name_for_card(card)
if dry_run:
log(
f"[dry-run] would implement '{card['name']}' on branch '{branch}' "
f"with prompt:\n{prompt}"
)
return
log(f"Implementing '{card['name']}' on branch '{branch}'")
worktree = ensure_worktree(repo, branch, base_branch, worktree_root)
before_sha = git(["rev-parse", "HEAD"], worktree)
try:
run_claude(
prompt,
cwd=worktree,
permission_mode="bypassPermissions",
append_system_prompt=IMPLEMENTER_RULES,
)
except ClaudeRunError as exc:
log(f"Implementer failed on '{card['name']}': {exc}")
client.add_comment(card["id"], f"[agentloop] implementer error: {exc}")
return
after_sha = git(["rev-parse", "HEAD"], worktree)
client.add_comment(
card["id"],
f"[agentloop] before={before_sha} after={after_sha} branch={branch}",
)
client.move_card(card["id"], list_ids["in_review"])
def review(
client: TrelloClient,
card: dict,
repo: str,
list_ids: dict,
max_review_rounds: int,
dry_run: bool,
base_branch: str,
worktree_root: str,
pending_cleanup: dict,
) -> None:
comments = client.comments(card["id"])
before_sha, after_sha = parse_last_shas(comments)
if before_sha is None:
log(f"'{card['name']}' is in In Review with no [agentloop] marker — skipping")
return
rounds_so_far = count_request_changes(comments)
if rounds_so_far >= max_review_rounds:
log(f"'{card['name']}' hit max review rounds ({max_review_rounds})")
if not dry_run and list_ids.get("needs_human"):
client.add_comment(
card["id"],
f"[agentloop] stuck after {rounds_so_far} review rounds, needs a human",
)
client.move_card(card["id"], list_ids["needs_human"])
return
diff = git(["diff", f"{before_sha}..{after_sha}"], repo)
prompt = build_review_prompt(card, diff)
if dry_run:
log(f"[dry-run] would review '{card['name']}' with diff of {len(diff)} chars")
return
branch = branch_name_for_card(card)
worktree = worktree_path_for(worktree_root, branch)
review_cwd = worktree if os.path.isdir(worktree) else repo
if review_cwd is repo:
log(
f"worktree for '{card['name']}' not found at {worktree}, "
"reviewer will fall back to reading --repo (may be stale)"
)
log(f"Reviewing '{card['name']}'")
try:
result = run_claude(prompt, cwd=review_cwd, permission_mode="plan")
except ClaudeRunError as exc:
log(f"Reviewer failed on '{card['name']}': {exc}")
return
verdict = extract_json_block(result.get("result", ""))
if verdict is None:
log(
f"Could not parse a verdict for '{card['name']}', leaving it in In Review")
return
if verdict.get("verdict") == "approve":
try:
merge_branch(repo, branch, base_branch)
except RuntimeError as exc:
log(f"Could not merge branch '{branch}' for '{card['name']}': {exc}")
client.add_comment(
card["id"],
f"[agentloop] approved but merge into {base_branch} failed, needs a human: {exc}",
)
if list_ids.get("needs_human"):
client.move_card(card["id"], list_ids["needs_human"])
return
client.add_comment(
card["id"], f"[review] APPROVE: {verdict.get('summary', '')}")
client.move_card(card["id"], list_ids["done"])
try:
remove_worktree(repo, worktree, branch)
except RuntimeError as exc:
log(f"Merged '{branch}' for '{card['name']}' but cleanup failed: {exc}")
client.add_comment(
card["id"],
f"[agentloop] cleanup pending: branch={branch} attempts=1\n"
f"worktree={worktree}\nerror={exc}",
)
pending_cleanup[card["id"]] = {
"card": card, "branch": branch, "worktree": worktree, "attempt": 1,
}
else:
issues = "\n".join(f"- {issue}" for issue in verdict.get("issues", []))
client.add_comment(
card["id"],
f"[review] REQUEST_CHANGES: {verdict.get('summary', '')}\n{issues}",
)
client.move_card(card["id"], list_ids["in_progress"])
def discover_pending_cleanups(client: TrelloClient, done_list_id: str) -> dict[str, dict]:
"""One-time scan of Done for cards whose cleanup is still pending, so a
restarted process reattaches to them. This is intentionally called once
at startup rather than every poll pass — scanning comments for every
card in Done on every pass would grow unbounded over the life of a
long-running loop.
Branch and worktree are parsed out of the literal comment text (the
values recorded at the moment the worktree/branch were actually
created), not recomputed from the card's current name — recomputing
would silently point at the wrong path if the card gets renamed while
cleanup is pending, since branch_name_for_card slugifies the card name."""
pending: dict[str, dict] = {}
for card in client.cards_in_list(done_list_id):
comments = client.comments(card["id"])
if any(
text.startswith("[agentloop] cleanup done")
or text.startswith("[agentloop] cleanup abandoned")
for text in comments
):
continue
marker = next(
(text for text in comments if text.startswith("[agentloop] cleanup pending")),
None,
)
if marker is None:
continue
header = CLEANUP_PENDING_RE.search(marker)
worktree_match = CLEANUP_WORKTREE_RE.search(marker)
if header is None or worktree_match is None:
continue
branch = header.group(1)
attempt = int(header.group(2))
worktree = worktree_match.group(1)
pending[card["id"]] = {
"card": card, "branch": branch, "worktree": worktree, "attempt": attempt,
}
return pending
def retry_pending_cleanup(
client: TrelloClient, card: dict, repo: str, branch: str, worktree: str, attempt: int
) -> int | None:
"""Retries worktree/branch removal for a merged card whose cleanup failed
earlier, using the literal branch/worktree recorded when the cleanup
first failed (see discover_pending_cleanups) rather than recomputing
them from the card. `attempt` is the number of attempts already made
before this call; this call makes the next one. Returns the updated
attempt count if cleanup is still pending, or None once it's resolved
(cleaned up, or abandoned after MAX_CLEANUP_ATTEMPTS)."""
current_attempt = attempt + 1
try:
remove_worktree(repo, worktree, branch)
except RuntimeError as exc:
if current_attempt >= MAX_CLEANUP_ATTEMPTS:
log(
f"Cleanup for '{card['name']}' failed {current_attempt} times, giving up: {exc}"
)
client.add_comment(
card["id"],
f"[agentloop] cleanup abandoned: branch={branch} attempts={current_attempt}\n"
f"worktree={worktree}\n"
f"failed permanently, remove manually\nerror={exc}",
)
return None
log(
f"Retry cleanup for '{card['name']}' still failing (attempt {current_attempt}): {exc}")
client.add_comment(
card["id"],
f"[agentloop] cleanup pending: branch={branch} attempts={current_attempt}\n"
f"worktree={worktree}\nerror={exc}",
)
return current_attempt
log(f"Cleaned up branch '{branch}' for '{card['name']}' on retry")
client.add_comment(card["id"], f"[agentloop] cleanup done: branch={branch}")
return None
def run_loop(
client: TrelloClient,
repo: str,
list_ids: dict,
args: argparse.Namespace,
base_branch: str,
worktree_root: str,
) -> None:
pending_cleanup: dict[str, dict] = (
{} if args.dry_run else discover_pending_cleanups(client, list_ids["done"])
)
while True:
todo_cards = client.cards_in_list(list_ids["todo"])
for card in todo_cards[: args.concurrency]:
log(f"Picking up '{card['name']}' from To Do")
if not args.dry_run:
client.move_card(card["id"], list_ids["in_progress"])
implement(client, card, repo, list_ids,
args.dry_run, base_branch, worktree_root)
for card in client.cards_in_list(list_ids["in_progress"]):
implement(client, card, repo, list_ids,
args.dry_run, base_branch, worktree_root)
for card in client.cards_in_list(list_ids["in_review"]):
review(client, card, repo, list_ids, args.max_review_rounds,
args.dry_run, base_branch, worktree_root, pending_cleanup)
if not args.dry_run:
for card_id in list(pending_cleanup.keys()):
entry = pending_cleanup[card_id]
next_attempt = retry_pending_cleanup(
client, entry["card"], repo, entry["branch"], entry["worktree"],
entry["attempt"])
if next_attempt is None:
del pending_cleanup[card_id]
else:
entry["attempt"] = next_attempt
if args.once:
break
time.sleep(args.interval)
def main() -> None:
load_dotenv()
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repo", required=True,
help="path to the local git checkout to work on")
parser.add_argument("--once", action="store_true",
help="run a single pass and exit")
parser.add_argument(
"--interval",
type=int,
default=int(os.environ.get("POLL_INTERVAL_SECONDS", "60")),
help="seconds to sleep between polling passes",
)
parser.add_argument(
"--concurrency",
type=int,
default=1,
help="max cards to pick up from To Do per pass (keep low for a subscription plan)",
)
parser.add_argument(
"--max-review-rounds",
type=int,
default=3,
help="after this many REQUEST_CHANGES rounds, move the card to Needs Human",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="don't call claude or mutate Trello, just print what would happen",
)
parser.add_argument(
"--base-branch",
default=os.environ.get("BASE_BRANCH"),
help="branch each card's feature worktree is created from and merged back "
"into on approval (default: --repo's current branch)",
)
parser.add_argument(
"--worktree-dir",
default=os.environ.get("WORKTREE_DIR"),
help="directory to hold per-card git worktrees "
"(default: a .agentloop-worktrees folder next to --repo)",
)
parser.add_argument(
"-y", "--yes",
action="store_true",
help="skip the confirmation prompt when --repo is missing "
"docs/CONVENTIONS.md or docs/CONTEXT.md",
)
args = parser.parse_args()
check_repo_or_exit(args.repo)
check_docs_or_confirm(args.repo, args.yes, args.dry_run)
api_key = os.environ.get("TRELLO_API_KEY")
token = os.environ.get("TRELLO_TOKEN")
board_id = os.environ.get("TRELLO_BOARD_ID")
if not (api_key and token and board_id):
sys.exit(
"TRELLO_API_KEY, TRELLO_TOKEN and TRELLO_BOARD_ID must be set (see .env.example)")
client = TrelloClient(api_key, token, board_id)
list_ids = resolve_list_ids(
client,
{
"todo": os.environ.get("TODO_LIST_NAME", "To Do"),
"in_progress": os.environ.get("IN_PROGRESS_LIST_NAME", "In Progress"),
"in_review": os.environ.get("IN_REVIEW_LIST_NAME", "In Review"),
"done": os.environ.get("DONE_LIST_NAME", "Done"),
"needs_human": os.environ.get("NEEDS_HUMAN_LIST_NAME", "Needs Human"),
},
)
base_branch = args.base_branch or git(
["rev-parse", "--abbrev-ref", "HEAD"], args.repo)
worktree_root = args.worktree_dir or os.path.join(
os.path.dirname(os.path.abspath(args.repo)), ".agentloop-worktrees")
run_loop(client, args.repo, list_ids, args, base_branch, worktree_root)
if __name__ == "__main__":
main()