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
1113Examples:
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
1620Commands:
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
2127The issue body must include:
2228 ## Blocked by
5359 exit 1
5460fi
5561
56- " ${python_cmd[@]} " - " $repo_root " " $command_arg " " $target_arg " << 'PY '
62+ run_python () {
63+ " ${python_cmd[@]} " - " $repo_root " " $1 " " ${2:- } " << 'PY '
5764from __future__ import annotations
5865
5966import json
@@ -80,15 +87,11 @@ def gh(args: list[str]) -> str:
8087
8188
8289def 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
8893def 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
9497def parse_type(body: str) -> str:
@@ -97,11 +100,7 @@ def parse_type(body: str) -> str:
97100
98101
99102def 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]:
130129def 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
146143def 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
152147def find_next_afk_issue() -> dict:
@@ -161,25 +156,17 @@ def find_next_afk_issue() -> dict:
161156
162157
163158def 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
174163def 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
185172You are implementing one AFK issue in this repository.
@@ -234,14 +221,13 @@ Proceed autonomously within this issue only:
2342216. Close GitHub issue #{issue['number']} with a comment summarizing files changed, acceptance criteria completed, and checks run.
2352227. 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
241228def 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
260244selected_issue = resolve_target(command_arg, target_arg)
261245if 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="")
263250else:
264251 print(build_brief(selected_issue), end="")
265252PY
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