Skip to content

Commit 10e8ea5

Browse files
committed
feat(scripts): add run-next and run-loop commands to task runner
Introduce new commands to automate AFK tasks. Use run-next to invoke Claude Code with an autonomous prompt for the next issue, and run-loop to sequentially process issues up to a limit.
1 parent 3152520 commit 10e8ea5

1 file changed

Lines changed: 64 additions & 32 deletions

File tree

scripts/afk-task.sh

Lines changed: 64 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,22 @@ Usage:
77
bash scripts/afk-task.sh <issue-number>
88
bash scripts/afk-task.sh next
99
bash scripts/afk-task.sh autonomous-prompt [issue-number|next]
10+
bash scripts/afk-task.sh run-next
11+
bash scripts/afk-task.sh run-loop [max-issues]
1012
1113
Examples:
1214
bash scripts/afk-task.sh 5
1315
bash scripts/afk-task.sh next
1416
bash scripts/afk-task.sh autonomous-prompt next
17+
bash scripts/afk-task.sh run-next
18+
bash scripts/afk-task.sh run-loop 3
1519
1620
Commands:
1721
<issue-number> Print an agent-executable implementation brief for one AFK GitHub issue.
1822
next Find the lowest-numbered open AFK issue with no open blockers and print its brief.
1923
autonomous-prompt target Print a full autonomous implementation prompt for Claude Code.
24+
run-next Invoke Claude Code to implement the next unblocked AFK issue, then stop.
25+
run-loop [max-issues] Repeatedly invoke Claude Code for unblocked AFK issues until blocked or max is reached.
2026
2127
The issue body must include:
2228
## Blocked by
@@ -53,7 +59,8 @@ else
5359
exit 1
5460
fi
5561

56-
"${python_cmd[@]}" - "$repo_root" "$command_arg" "$target_arg" <<'PY'
62+
run_python() {
63+
"${python_cmd[@]}" - "$repo_root" "$1" "${2:-}" <<'PY'
5764
from __future__ import annotations
5865
5966
import json
@@ -80,15 +87,11 @@ def gh(args: list[str]) -> str:
8087
8188
8289
def issue_view(number: int | str) -> dict:
83-
return json.loads(
84-
gh(["issue", "view", str(number), "--json", "number,title,body,state,labels,url"])
85-
)
90+
return json.loads(gh(["issue", "view", str(number), "--json", "number,title,body,state,labels,url"]))
8691
8792
8893
def issue_state(number: int | str) -> dict:
89-
return json.loads(
90-
gh(["issue", "view", str(number), "--json", "number,title,state,url"])
91-
)
94+
return json.loads(gh(["issue", "view", str(number), "--json", "number,title,state,url"]))
9295
9396
9497
def parse_type(body: str) -> str:
@@ -97,11 +100,7 @@ def parse_type(body: str) -> str:
97100
98101
99102
def parse_blocked_by(body: str) -> str:
100-
match = re.search(
101-
r"^## Blocked by\s*\n\s*(.*?)(?=\n## |\Z)",
102-
body,
103-
flags=re.MULTILINE | re.DOTALL,
104-
)
103+
match = re.search(r"^## Blocked by\s*\n\s*(.*?)(?=\n## |\Z)", body, flags=re.MULTILINE | re.DOTALL)
105104
return match.group(1).strip() if match else "Unknown"
106105
107106
@@ -130,11 +129,9 @@ def open_blockers(blocked_by: str) -> list[str]:
130129
def validate_afk_issue(issue: dict) -> None:
131130
if issue["state"].upper() != "OPEN":
132131
fail(f"Issue {issue['number']} is {issue['state']}, not OPEN. Do not run unattended.")
133-
134132
metadata_type = parse_type(issue.get("body") or "")
135133
if metadata_type != "AFK":
136134
fail(f"Issue {issue['number']} is marked {metadata_type}, not AFK. Do not run unattended.")
137-
138135
blockers = open_blockers(parse_blocked_by(issue.get("body") or ""))
139136
if blockers:
140137
print(f"Issue {issue['number']} still has open blockers. Do not run unattended.", file=sys.stderr)
@@ -144,9 +141,7 @@ def validate_afk_issue(issue: dict) -> None:
144141
145142
146143
def list_open_issues() -> list[dict]:
147-
return json.loads(
148-
gh(["issue", "list", "--state", "open", "--limit", "100", "--json", "number,title,body,state,labels,url"])
149-
)
144+
return json.loads(gh(["issue", "list", "--state", "open", "--limit", "100", "--json", "number,title,body,state,labels,url"]))
150145
151146
152147
def find_next_afk_issue() -> dict:
@@ -161,25 +156,17 @@ def find_next_afk_issue() -> dict:
161156
162157
163158
def context_files() -> list[str]:
164-
candidates = [
165-
"CLAUDE.md",
166-
"docs/CODING_STANDARD.md",
167-
"docs/TASK_BREAKDOWN.md",
168-
"docs/business",
169-
"docs/technical-specs",
170-
]
159+
candidates = ["CLAUDE.md", "docs/CODING_STANDARD.md", "docs/TASK_BREAKDOWN.md", "docs/business", "docs/technical-specs"]
171160
return [candidate for candidate in candidates if (repo_root / candidate).exists()]
172161
173162
174163
def build_brief(issue: dict) -> str:
175164
validate_afk_issue(issue)
176-
177165
body = (issue.get("body") or "").strip()
178166
blocked_by = parse_blocked_by(body)
179167
stories = parse_stories(body)
180168
issue_labels = labels(issue)
181169
files = context_files()
182-
183170
return f"""# Agent-executable AFK task: GitHub issue #{issue['number']}
184171
185172
You are implementing one AFK issue in this repository.
@@ -234,14 +221,13 @@ Proceed autonomously within this issue only:
234221
6. Close GitHub issue #{issue['number']} with a comment summarizing files changed, acceptance criteria completed, and checks run.
235222
7. Stop after closing this issue. Do not start another issue unless explicitly instructed by the user or an outer loop.
236223
237-
Stop instead of committing or closing if checks fail, browser verification is required but cannot be completed, requirements are ambiguous, or external credentials/services are missing.
224+
Stop instead of committing or closing if checks fail, browser verification is required but cannot be completed, requirements are ambiguous, external credentials/services are missing, or the working tree contains unrelated user changes.
238225
"""
239226
240227
241228
def resolve_target(command: str, target: str) -> dict:
242-
if command == "next":
229+
if command in {"next", "next-number"}:
243230
return find_next_afk_issue()
244-
245231
if command == "autonomous-prompt":
246232
if not target:
247233
fail("autonomous-prompt requires an issue number or next.", 2)
@@ -250,16 +236,62 @@ def resolve_target(command: str, target: str) -> dict:
250236
if not target.isdigit():
251237
fail("Issue number must be numeric, or use next.", 2)
252238
return issue_view(target)
253-
254239
if command.isdigit():
255240
return issue_view(command)
256-
257-
fail("Unknown command. Use an issue number, next, or autonomous-prompt.", 2)
241+
fail("Unknown command. Use an issue number, next, autonomous-prompt, or next-number.", 2)
258242
259243
260244
selected_issue = resolve_target(command_arg, target_arg)
261245
if command_arg == "autonomous-prompt":
262246
print(build_autonomous_prompt(selected_issue), end="")
247+
elif command_arg == "next-number":
248+
validate_afk_issue(selected_issue)
249+
print(selected_issue["number"], end="")
263250
else:
264251
print(build_brief(selected_issue), end="")
265252
PY
253+
}
254+
255+
run_next() {
256+
if ! command -v claude >/dev/null 2>&1; then
257+
printf 'Cannot find claude. Install Claude Code or run this from an environment where claude is on PATH.\n' >&2
258+
exit 1
259+
fi
260+
261+
issue_number="$(run_python next-number)"
262+
printf 'Starting autonomous Claude Code run for issue #%s.\n' "$issue_number" >&2
263+
prompt="$(run_python autonomous-prompt "$issue_number")"
264+
claude "$prompt"
265+
}
266+
267+
run_loop() {
268+
local max_issues="${target_arg:-1}"
269+
case "$max_issues" in
270+
''|*[!0-9]*)
271+
printf 'run-loop max-issues must be numeric.\n' >&2
272+
exit 2
273+
;;
274+
esac
275+
276+
local completed=0
277+
while (( completed < max_issues )); do
278+
if ! run_next; then
279+
printf 'Autonomous loop stopped after %s completed run(s).\n' "$completed" >&2
280+
exit 1
281+
fi
282+
completed=$((completed + 1))
283+
done
284+
printf 'Autonomous loop reached max issue count: %s.\n' "$max_issues" >&2
285+
}
286+
287+
case "$command_arg" in
288+
run-next)
289+
run_next
290+
;;
291+
run-loop)
292+
run_loop
293+
;;
294+
*)
295+
run_python "$command_arg" "$target_arg"
296+
;;
297+
esac

0 commit comments

Comments
 (0)