diff --git a/python/agentize/cli.md b/python/agentize/cli.md index 174d57f2..5205df24 100644 --- a/python/agentize/cli.md +++ b/python/agentize/cli.md @@ -20,8 +20,10 @@ The Python CLI supports the same commands as the shell implementation: | `usage` | Report Claude Code token usage statistics (--cache, --cost) | | `claude-clean` | Remove stale project entries from `~/.claude.json` | | `version` | Display version information | +| `plan` | Run multi-agent debate pipeline (optional `--dry-run`, `--verbose`) | | `impl` | Issue-to-implementation loop (Python workflow, optional `--wait-for-ci`) | | `simp` | Simplify code without changing semantics (optional `--focus`, `--issue`) | +| `rebase` | Rebase current branch onto target branch (optional `--target-branch`) | ## Top-level Flags diff --git a/python/agentize/cli.py b/python/agentize/cli.py index 1eafe990..5187e418 100644 --- a/python/agentize/cli.py +++ b/python/agentize/cli.py @@ -107,6 +107,52 @@ def handle_simp(args: argparse.Namespace) -> int: return 1 +def handle_rebase(args: argparse.Namespace) -> int: + """Handle rebase command — fetch and rebase onto target branch.""" + import subprocess + + target_branch = args.target_branch or "main" + remote = "origin" + + # Detect default branch if not explicitly provided + if not args.target_branch: + for candidate in ("main", "master"): + result = subprocess.run( + ["git", "rev-parse", "--verify", f"{remote}/{candidate}"], + capture_output=True, + ) + if result.returncode == 0: + target_branch = candidate + break + + # Fetch + print(f"Fetching from {remote}...") + fetch_result = subprocess.run(["git", "fetch", remote], capture_output=True, text=True) + if fetch_result.returncode != 0: + print(f"Error: Failed to fetch from {remote}", file=sys.stderr) + if fetch_result.stderr: + print(fetch_result.stderr, file=sys.stderr) + return 1 + + # Rebase + ref = f"{remote}/{target_branch}" + print(f"Rebasing onto {ref}...") + rebase_result = subprocess.run(["git", "rebase", ref], capture_output=True, text=True) + if rebase_result.returncode != 0: + print(f"Error: Rebase conflict detected against {ref}.", file=sys.stderr) + if rebase_result.stderr: + print(rebase_result.stderr, file=sys.stderr) + # Abort the failed rebase to leave the branch clean + subprocess.run(["git", "rebase", "--abort"], capture_output=True) + return 1 + + if "is up to date" in (rebase_result.stdout or ""): + print(f"Already up to date with {ref}.") + else: + print(f"Successfully rebased onto {ref}.") + return 0 + + def handle_usage(args: argparse.Namespace) -> int: """Handle usage command.""" mode = "week" if args.week else "today" @@ -253,6 +299,16 @@ def main() -> int: help="Issue number to publish the report when approved", ) + # rebase command + rebase_parser = subparsers.add_parser( + "rebase", help="Rebase current branch onto target branch" + ) + rebase_parser.add_argument( + "--target-branch", + dest="target_branch", + help="Target branch to rebase onto (default: auto-detect main/master)", + ) + # version command subparsers.add_parser("version", help="Display version information") @@ -282,6 +338,8 @@ def main() -> int: return handle_impl(args) elif args.command == "simp": return handle_simp(args) + elif args.command == "rebase": + return handle_rebase(args) elif args.command == "version": return handle_version(agentize_home) else: diff --git a/python/agentize/workflow/impl/impl.md b/python/agentize/workflow/impl/impl.md index 6b6ec8aa..180ab666 100644 --- a/python/agentize/workflow/impl/impl.md +++ b/python/agentize/workflow/impl/impl.md @@ -83,7 +83,7 @@ flowchart LR pr -->|pr_fail_fixable| impl pr -->|pr_fail_need_rebase| rebase[rebase] pr -->|6x failures| fatal - rebase -->|rebase_ok| impl + rebase -->|rebase_ok| review rebase -->|rebase_conflict| fatal ``` diff --git a/python/agentize/workflow/impl/kernels.md b/python/agentize/workflow/impl/kernels.md index e0a2634a..452ed9fe 100644 --- a/python/agentize/workflow/impl/kernels.md +++ b/python/agentize/workflow/impl/kernels.md @@ -325,7 +325,8 @@ kernel return values and the current state. - `pr_stage_kernel(context)`: Calls `pr_kernel()`, passes through event, tracks `pr_attempts` (limit 6), emits `EVENT_PR_PASS`/`EVENT_PR_FAIL_*`/`EVENT_FATAL` - `rebase_stage_kernel(context)`: Calls `rebase_kernel()`, passes through event, - tracks `rebase_attempts` (limit 3), emits `EVENT_REBASE_OK`/`EVENT_REBASE_CONFLICT`/`EVENT_FATAL` + tracks `rebase_attempts` (limit 3), resets `review_attempts`/`review_fail_streak`/`last_review_score` + on `EVENT_REBASE_OK` (so review starts fresh after rebase), emits `EVENT_REBASE_OK`/`EVENT_REBASE_CONFLICT`/`EVENT_FATAL` - `simp_stage_kernel(context)`: Handles `enable_simp=False` → `EVENT_SIMP_PASS`, calls `simp_kernel()`, tracks `simp_attempts` (limit 3), emits `EVENT_SIMP_PASS`/`EVENT_SIMP_FAIL` diff --git a/python/agentize/workflow/impl/kernels.py b/python/agentize/workflow/impl/kernels.py index 0d61d94a..adf0d787 100644 --- a/python/agentize/workflow/impl/kernels.py +++ b/python/agentize/workflow/impl/kernels.py @@ -294,6 +294,9 @@ def rebase_stage_kernel(context: WorkflowContext) -> StageResult: if event == EVENT_REBASE_OK: state.iteration += 1 + context.data["review_fail_streak"] = 0 + context.data["review_attempts"] = 0 + context.data["last_review_score"] = None return StageResult(event=event, reason=message) diff --git a/python/agentize/workflow/impl/transition.md b/python/agentize/workflow/impl/transition.md index bc53ebaa..90c673ca 100644 --- a/python/agentize/workflow/impl/transition.md +++ b/python/agentize/workflow/impl/transition.md @@ -31,7 +31,7 @@ The table includes the expected implementation flow: - `review -> simp/impl/fatal` - `simp -> pr/impl/fatal` - `pr -> finish/impl/rebase/fatal` -- `rebase -> impl/fatal` +- `rebase -> review/fatal` Terminal stages (`finish`, `fatal`) currently only accept `fatal` as an explicit fallback route. diff --git a/python/agentize/workflow/impl/transition.py b/python/agentize/workflow/impl/transition.py index 7217281f..908a34a6 100644 --- a/python/agentize/workflow/impl/transition.py +++ b/python/agentize/workflow/impl/transition.py @@ -49,7 +49,7 @@ (STAGE_PR, EVENT_PR_FAIL_FIXABLE): STAGE_IMPL, (STAGE_PR, EVENT_PR_FAIL_NEED_REBASE): STAGE_REBASE, (STAGE_PR, EVENT_FATAL): STAGE_FATAL, - (STAGE_REBASE, EVENT_REBASE_OK): STAGE_IMPL, + (STAGE_REBASE, EVENT_REBASE_OK): STAGE_REVIEW, (STAGE_REBASE, EVENT_REBASE_CONFLICT): STAGE_FATAL, (STAGE_REBASE, EVENT_FATAL): STAGE_FATAL, (STAGE_FINISH, EVENT_FATAL): STAGE_FATAL, diff --git a/python/tests/test_impl_fsm.py b/python/tests/test_impl_fsm.py index c335d84a..71318d12 100644 --- a/python/tests/test_impl_fsm.py +++ b/python/tests/test_impl_fsm.py @@ -89,6 +89,7 @@ def test_next_stage_resolves_expected_edges(self): assert next_stage(STAGE_SIMP, EVENT_SIMP_PASS) == STAGE_PR assert next_stage(STAGE_SIMP, EVENT_SIMP_FAIL) == STAGE_IMPL assert next_stage(STAGE_PR, EVENT_PR_PASS) == STAGE_FINISH + assert next_stage(STAGE_REBASE, EVENT_REBASE_OK) == STAGE_REVIEW def test_next_stage_raises_on_unknown_stage(self): with pytest.raises(TransitionError, match="Unknown stage"): @@ -158,6 +159,44 @@ def pr_kernel(ctx: WorkflowContext) -> StageResult: assert any("stage=simp event=simp_pass" in line for line in logs) assert any("stage=pr event=pr_pass" in line for line in logs) + def test_run_fsm_orchestrator_rebase_ok_reaches_review_then_finish(self): + context = WorkflowContext(plan="p", upstream_instruction="u") + context.current_stage = STAGE_REBASE + logs: list[str] = [] + + def rebase_kernel(ctx: WorkflowContext) -> StageResult: + return StageResult(event=EVENT_REBASE_OK, reason="rebased") + + def review_kernel(ctx: WorkflowContext) -> StageResult: + return StageResult(event=EVENT_REVIEW_PASS, reason="review ok") + + def simp_kernel(ctx: WorkflowContext) -> StageResult: + return StageResult(event=EVENT_SIMP_PASS, reason="simp ok") + + def pr_kernel(ctx: WorkflowContext) -> StageResult: + return StageResult(event=EVENT_PR_PASS, reason="pr ok") + + kernels = { + STAGE_REBASE: rebase_kernel, + STAGE_REVIEW: review_kernel, + STAGE_SIMP: simp_kernel, + STAGE_PR: pr_kernel, + } + + result = run_fsm_orchestrator( + context, + kernels=kernels, + logger=logs.append, + max_steps=10, + ) + + assert result.current_stage == STAGE_FINISH + assert result.final_status == "finish" + assert any("stage=rebase event=rebase_ok" in line for line in logs) + assert any("stage=review event=review_pass" in line for line in logs) + assert any("stage=simp event=simp_pass" in line for line in logs) + assert any("stage=pr event=pr_pass" in line for line in logs) + def test_run_fsm_orchestrator_marks_fatal_on_missing_kernel(self): context = WorkflowContext(plan="p", upstream_instruction="u") result = run_fsm_orchestrator(context, kernels={}, logger=lambda _msg: None) @@ -539,6 +578,25 @@ def test_rebase_ok_passthrough(self, tmp_path: Path, monkeypatch): assert result.event == EVENT_REBASE_OK assert state.iteration == initial_iter + 1 + def test_rebase_ok_resets_review_counters(self, tmp_path: Path, monkeypatch): + context = _make_impl_context( + tmp_path, + review_fail_streak=2, + review_attempts=3, + last_review_score=50, + ) + + monkeypatch.setattr( + "agentize.workflow.impl.kernels.rebase_kernel", + lambda state, **kw: (EVENT_REBASE_OK, "Rebased", tmp_path / "rebase.json"), + ) + + result = rebase_stage_kernel(context) + assert result.event == EVENT_REBASE_OK + assert context.data["review_fail_streak"] == 0 + assert context.data["review_attempts"] == 0 + assert context.data["last_review_score"] is None + def test_rebase_attempts_4_returns_fatal(self, tmp_path: Path): context = _make_impl_context(tmp_path, rebase_attempts=3) diff --git a/src/cli/lol/commands.sh b/src/cli/lol/commands.sh index aeec2c7f..c0a9d356 100644 --- a/src/cli/lol/commands.sh +++ b/src/cli/lol/commands.sh @@ -24,3 +24,4 @@ source "$_LOL_COMMANDS_DIR/commands/usage.sh" source "$_LOL_COMMANDS_DIR/commands/plan.sh" source "$_LOL_COMMANDS_DIR/commands/impl.sh" source "$_LOL_COMMANDS_DIR/commands/simp.sh" +source "$_LOL_COMMANDS_DIR/commands/rebase.sh" diff --git a/src/cli/lol/commands/README.md b/src/cli/lol/commands/README.md index 320d4c53..9b4d0453 100644 --- a/src/cli/lol/commands/README.md +++ b/src/cli/lol/commands/README.md @@ -17,6 +17,7 @@ Per-command implementation files for the `lol` CLI. Each file exports exactly on | `usage.sh` | `_lol_cmd_usage` | Report Claude Code token usage statistics | | `plan.sh` | `_lol_cmd_plan` | Run multi-agent debate pipeline | | `impl.sh` | `_lol_cmd_impl` | Automate issue-to-implementation loop | +| `rebase.sh` | `_lol_cmd_rebase` | Rebase current branch onto target branch | ## Design diff --git a/src/cli/lol/commands/rebase.sh b/src/cli/lol/commands/rebase.sh new file mode 100644 index 00000000..8b409ff6 --- /dev/null +++ b/src/cli/lol/commands/rebase.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# lol rebase command implementation +# Delegates to the Python CLI rebase handler + +# Main _lol_cmd_rebase function +# Arguments: +# $1 - target_branch: Target branch to rebase onto (optional, auto-detects main/master) +_lol_cmd_rebase() { + local target_branch="$1" + + if [ -n "$target_branch" ]; then + python -m agentize.cli rebase --target-branch "$target_branch" + else + python -m agentize.cli rebase + fi +} diff --git a/src/cli/lol/completion.sh b/src/cli/lol/completion.sh index dd9984fe..b7847191 100644 --- a/src/cli/lol/completion.sh +++ b/src/cli/lol/completion.sh @@ -19,6 +19,7 @@ _lol_complete() { echo "plan" echo "impl" echo "simp" + echo "rebase" ;; upgrade-flags) echo "--keep-branch" @@ -64,6 +65,9 @@ _lol_complete() { echo "--focus" echo "--issue" ;; + rebase-flags) + echo "--target-branch" + ;; *) # Unknown topic, return empty return 0 diff --git a/src/cli/lol/dispatch.sh b/src/cli/lol/dispatch.sh index f1a028e8..f2016db4 100644 --- a/src/cli/lol/dispatch.sh +++ b/src/cli/lol/dispatch.sh @@ -91,6 +91,9 @@ lol() { usage) _lol_parse_usage "$@" ;; + rebase) + _lol_parse_rebase "$@" + ;; version) _lol_log_version _lol_cmd_version @@ -114,6 +117,7 @@ lol() { echo " lol simp [file] --editor" echo " lol impl [--backend ] [--max-iterations ] [--yolo] [--wait-for-ci]" echo " lol usage [--today | --week] [--cache] [--cost]" + echo " lol rebase [--target-branch ]" echo " lol claude-clean [--dry-run]" echo "" echo "Flags:" diff --git a/src/cli/lol/parsers.sh b/src/cli/lol/parsers.sh index ee3e6642..82fe3e6c 100644 --- a/src/cli/lol/parsers.sh +++ b/src/cli/lol/parsers.sh @@ -615,3 +615,47 @@ _lol_parse_simp() { _lol_cmd_simp "$file_path" "$issue_number" "$focus" } + +# Parse rebase command arguments and call _lol_cmd_rebase +_lol_parse_rebase() { + local target_branch="" + + # Handle --help + if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then + echo "lol rebase: Rebase current branch onto target branch" + echo "" + echo "Usage: lol rebase [--target-branch ]" + echo "" + echo "Options:" + echo " --target-branch Target branch to rebase onto (default: auto-detect main/master)" + echo " --help Show this help message" + return 0 + fi + + while [ $# -gt 0 ]; do + case "$1" in + --target-branch) + shift + if [ -z "$1" ]; then + echo "Error: --target-branch requires a branch name" >&2 + echo "Usage: lol rebase [--target-branch ]" >&2 + return 1 + fi + target_branch="$1" + shift + ;; + -*) + echo "Error: Unknown option '$1'" >&2 + echo "Usage: lol rebase [--target-branch ]" >&2 + return 1 + ;; + *) + echo "Error: Unexpected argument '$1'" >&2 + echo "Usage: lol rebase [--target-branch ]" >&2 + return 1 + ;; + esac + done + + _lol_cmd_rebase "$target_branch" +}