From dc40e56afb0cbbbfffc55e7d3d4813851a2b58fc Mon Sep 17 00:00:00 2001 From: Hiroshi Shinaoka Date: Thu, 19 Mar 2026 09:42:37 +0900 Subject: [PATCH] feat: add solve-bug entrypoints --- README.md | 6 ++ ai/manifest.json | 15 ++++ ai/run-claude-solve-bug.sh | 116 ++++++++++++++++++++++++++++ ai/run-codex-solve-bug.sh | 110 ++++++++++++++++++++++++++ ai/solve_bug_issue.md | 24 ++++++ tests/test_solve_bug_entrypoints.py | 59 ++++++++++++++ 6 files changed, 330 insertions(+) create mode 100755 ai/run-claude-solve-bug.sh create mode 100755 ai/run-codex-solve-bug.sh create mode 100644 ai/solve_bug_issue.md create mode 100644 tests/test_solve_bug_entrypoints.py diff --git a/README.md b/README.md index 952ffdf..db8cb25 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/ai/manifest.json b/ai/manifest.json index 073411c..cee6a02 100644 --- a/ai/manifest.json +++ b/ai/manifest.json @@ -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", diff --git a/ai/run-claude-solve-bug.sh b/ai/run-claude-solve-bug.sh new file mode 100755 index 0000000..1d1fdf2 --- /dev/null +++ b/ai/run-claude-solve-bug.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: run-claude-solve-bug.sh [options] [-- ] + +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" diff --git a/ai/run-codex-solve-bug.sh b/ai/run-codex-solve-bug.sh new file mode 100755 index 0000000..6a0a2d9 --- /dev/null +++ b/ai/run-codex-solve-bug.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: run-codex-solve-bug.sh [options] [-- ] + +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" diff --git a/ai/solve_bug_issue.md b/ai/solve_bug_issue.md new file mode 100644 index 0000000..6443b74 --- /dev/null +++ b/ai/solve_bug_issue.md @@ -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. diff --git a/tests/test_solve_bug_entrypoints.py b/tests/test_solve_bug_entrypoints.py new file mode 100644 index 0000000..44d674f --- /dev/null +++ b/tests/test_solve_bug_entrypoints.py @@ -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()