Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ Artifacts:
- durable reports: `docs/test-reports/agentic-bug-sweep/`
- ephemeral execution state: `target/agentic-bug-sweep/`

## Solve-Bug Entrypoints

Use `bash ai/run-codex-solve-bug.sh` or `bash ai/run-claude-solve-bug.sh` when you want a headless agent to pick one actionable bug or bug-like issue, fix it, and drive the repository-local PR workflow.

If there are effectively no open bug or bug-like issues, the workflow should terminate cleanly with no code changes and no PR creation.

## Testing

Run unit and integration tests with nextest, and keep doctests as a separate step:
Expand Down
15 changes: 15 additions & 0 deletions ai/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,21 @@
"source": "scripts/sync-agent-assets.sh",
"target": "scripts/sync-agent-assets.sh"
},
{
"id": "run-codex-solve-bug-script",
"source": "ai/run-codex-solve-bug.sh",
"target": "ai/run-codex-solve-bug.sh"
},
{
"id": "run-claude-solve-bug-script",
"source": "ai/run-claude-solve-bug.sh",
"target": "ai/run-claude-solve-bug.sh"
},
{
"id": "solve-bug-issue-prompt",
"source": "ai/solve_bug_issue.md",
"target": "ai/solve_bug_issue.md"
},
{
"id": "check-repo-settings-script",
"source": "scripts/check-repo-settings.sh",
Expand Down
116 changes: 116 additions & 0 deletions ai/run-claude-solve-bug.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
set -euo pipefail

usage() {
cat <<'EOF'
Usage: run-claude-solve-bug.sh [options] [-- <extra claude args>]

Run Claude Code headlessly from the repository root using a prompt file under ai/.

Options:
--prompt PATH Prompt file. Relative paths are resolved from this script's directory.
Default: solve_bug_issue.md
--model MODEL Pass --model MODEL to claude.
--run-dir PATH Directory for logs.
Default: a fresh temporary directory.
--text Disable stream-json output and print plain text instead.
-h, --help Show this help.
EOF
}

script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
repo_root="$(git -C "$script_dir" rev-parse --show-toplevel)"

prompt_arg="solve_bug_issue.md"
model=""
run_dir=""
json_mode=1
extra_args=()

while (($# > 0)); do
case "$1" in
--prompt)
prompt_arg="${2:?missing value for --prompt}"
shift 2
;;
--model)
model="${2:?missing value for --model}"
shift 2
;;
--run-dir)
run_dir="${2:?missing value for --run-dir}"
shift 2
;;
--text)
json_mode=0
shift
;;
-h|--help)
usage
exit 0
;;
--)
shift
extra_args=("$@")
break
;;
*)
echo "unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done

if [[ "$prompt_arg" = /* ]]; then
prompt_path="$prompt_arg"
else
prompt_path="$script_dir/$prompt_arg"
fi

if [[ ! -f "$prompt_path" ]]; then
echo "prompt file not found: $prompt_path" >&2
exit 1
fi

if [[ -z "$run_dir" ]]; then
run_dir="$(mktemp -d "${TMPDIR:-/tmp}/claude-solve-bug.XXXXXX")"
else
mkdir -p "$run_dir"
run_dir="$(cd -- "$run_dir" && pwd -P)"
fi

log_path="$run_dir/output.log"
if ((json_mode)); then
log_path="$run_dir/events.jsonl"
fi

prompt_text="$(cat "$prompt_path")"

cmd=(
claude
--print
--dangerously-skip-permissions
)

if ((json_mode)); then
cmd+=(--output-format stream-json)
else
cmd+=(--output-format text)
fi

if [[ -n "$model" ]]; then
cmd+=(--model "$model")
fi

cmd+=("${extra_args[@]}" "$prompt_text")

echo "repo_root=$repo_root"
echo "prompt_path=$prompt_path"
echo "run_dir=$run_dir"
echo "log_path=$log_path"

(
cd "$repo_root"
"${cmd[@]}"
) | tee "$log_path"
110 changes: 110 additions & 0 deletions ai/run-codex-solve-bug.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
set -euo pipefail

usage() {
cat <<'EOF'
Usage: run-codex-solve-bug.sh [options] [-- <extra codex args>]

Run Codex headlessly from the repository root using a prompt file under ai/.

Options:
--prompt PATH Prompt file. Relative paths are resolved from this script's directory.
Default: solve_bug_issue.md
--model MODEL Pass --model MODEL to codex exec.
--run-dir PATH Directory for logs and final message output.
Default: a fresh temporary directory.
--text Disable JSONL output and stream plain Codex terminal output instead.
-h, --help Show this help.
EOF
}

script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
repo_root="$(git -C "$script_dir" rev-parse --show-toplevel)"

prompt_arg="solve_bug_issue.md"
model=""
run_dir=""
json_mode=1
extra_args=()

while (($# > 0)); do
case "$1" in
--prompt)
prompt_arg="${2:?missing value for --prompt}"
shift 2
;;
--model)
model="${2:?missing value for --model}"
shift 2
;;
--run-dir)
run_dir="${2:?missing value for --run-dir}"
shift 2
;;
--text)
json_mode=0
shift
;;
-h|--help)
usage
exit 0
;;
--)
shift
extra_args=("$@")
break
;;
*)
echo "unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done

if [[ "$prompt_arg" = /* ]]; then
prompt_path="$prompt_arg"
else
prompt_path="$script_dir/$prompt_arg"
fi

if [[ ! -f "$prompt_path" ]]; then
echo "prompt file not found: $prompt_path" >&2
exit 1
fi

if [[ -z "$run_dir" ]]; then
run_dir="$(mktemp -d "${TMPDIR:-/tmp}/codex-solve-bug.XXXXXX")"
else
mkdir -p "$run_dir"
run_dir="$(cd -- "$run_dir" && pwd -P)"
fi

log_path="$run_dir/output.log"
last_message_path="$run_dir/final.txt"

cmd=(
codex exec
--cd "$repo_root"
--dangerously-bypass-approvals-and-sandbox
--output-last-message "$last_message_path"
)

if ((json_mode)); then
cmd+=(--json)
log_path="$run_dir/events.jsonl"
fi

if [[ -n "$model" ]]; then
cmd+=(--model "$model")
fi

cmd+=("${extra_args[@]}" -)

echo "repo_root=$repo_root"
echo "prompt_path=$prompt_path"
echo "run_dir=$run_dir"
echo "log_path=$log_path"
echo "last_message_path=$last_message_path"

"${cmd[@]}" < "$prompt_path" | tee "$log_path"
24 changes: 24 additions & 0 deletions ai/solve_bug_issue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
You are running one iteration of an automated bug-fix workflow for this repository.

Your job is to:

1. Inspect open bug and bug-like issues.
2. Choose exactly one actionable workstream.
3. If open bug / bug-like issues are effectively zero, terminate cleanly with no code changes and no PR creation. In other words, if effectively no open bug or bug-like issues remain, end the run without mutation.
4. Otherwise, fix the issue or close it only when it is clearly irrelevant, duplicate, or already fixed.
5. Use the repository-local PR helpers:
- `bash scripts/create-pr.sh`
- `bash scripts/monitor-pr-checks.sh`
6. Continue until merge or a defined stop condition.
7. Restore the local checkout as part of normal cleanup.

Hard rules:

- Work from the latest `origin/main`.
- Use a dedicated worktree only after selecting a real candidate.
- Do not touch unrelated work.
- Stop after two core fix attempts.
- Do not ask the user questions.
- Do not create a PR if there is nothing actionable to fix.

When you terminate cleanly without action, summarize that no worthwhile bug or bug-like issue was available and that no code was changed.
59 changes: 59 additions & 0 deletions tests/test_solve_bug_entrypoints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import os
import subprocess
import unittest
from pathlib import Path


REPO_ROOT = Path(__file__).resolve().parents[1]
RUN_CODEX = REPO_ROOT / "ai" / "run-codex-solve-bug.sh"
RUN_CLAUDE = REPO_ROOT / "ai" / "run-claude-solve-bug.sh"
PROMPT = REPO_ROOT / "ai" / "solve_bug_issue.md"
MANIFEST = REPO_ROOT / "ai" / "manifest.json"


class TemplateSolveBugEntrypointsTests(unittest.TestCase):
def test_prompt_contract(self) -> None:
self.assertTrue(RUN_CODEX.is_file(), msg=f"missing file: {RUN_CODEX}")
self.assertTrue(RUN_CLAUDE.is_file(), msg=f"missing file: {RUN_CLAUDE}")
self.assertTrue(PROMPT.is_file(), msg=f"missing file: {PROMPT}")
self.assertTrue(MANIFEST.is_file(), msg=f"missing file: {MANIFEST}")

prompt = PROMPT.read_text(encoding="utf-8")
manifest = MANIFEST.read_text(encoding="utf-8")

self.assertIn("bash scripts/create-pr.sh", prompt)
self.assertIn("bash scripts/monitor-pr-checks.sh", prompt)
self.assertIn("effectively no open bug or bug-like issues", prompt)
self.assertIn("run-codex-solve-bug.sh", manifest)
self.assertIn("run-claude-solve-bug.sh", manifest)
self.assertIn("solve_bug_issue.md", manifest)

def test_run_codex_help(self) -> None:
result = self._run_wrapper_help(RUN_CODEX)
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertIn("--prompt", result.stdout)
self.assertIn("--run-dir", result.stdout)
self.assertIn("--text", result.stdout)

def test_run_claude_help(self) -> None:
result = self._run_wrapper_help(RUN_CLAUDE)
self.assertEqual(result.returncode, 0, msg=result.stderr)
self.assertIn("--prompt", result.stdout)
self.assertIn("--run-dir", result.stdout)
self.assertIn("--text", result.stdout)

def _run_wrapper_help(self, source_script: Path) -> subprocess.CompletedProcess[str]:
self.assertTrue(source_script.is_file(), msg=f"missing file: {source_script}")
env = os.environ.copy()
return subprocess.run(
["bash", str(source_script), "--help"],
cwd=REPO_ROOT,
text=True,
capture_output=True,
env=env,
check=False,
)


if __name__ == "__main__":
unittest.main()
Loading