Skip to content
Open
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
2 changes: 2 additions & 0 deletions python/agentize/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
58 changes: 58 additions & 0 deletions python/agentize/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion python/agentize/workflow/impl/impl.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
3 changes: 2 additions & 1 deletion python/agentize/workflow/impl/kernels.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
3 changes: 3 additions & 0 deletions python/agentize/workflow/impl/kernels.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion python/agentize/workflow/impl/transition.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion python/agentize/workflow/impl/transition.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
58 changes: 58 additions & 0 deletions python/tests/test_impl_fsm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions src/cli/lol/commands.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
1 change: 1 addition & 0 deletions src/cli/lol/commands/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions src/cli/lol/commands/rebase.sh
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 4 additions & 0 deletions src/cli/lol/completion.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ _lol_complete() {
echo "plan"
echo "impl"
echo "simp"
echo "rebase"
;;
upgrade-flags)
echo "--keep-branch"
Expand Down Expand Up @@ -64,6 +65,9 @@ _lol_complete() {
echo "--focus"
echo "--issue"
;;
rebase-flags)
echo "--target-branch"
;;
*)
# Unknown topic, return empty
return 0
Expand Down
4 changes: 4 additions & 0 deletions src/cli/lol/dispatch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ lol() {
usage)
_lol_parse_usage "$@"
;;
rebase)
_lol_parse_rebase "$@"
;;
version)
_lol_log_version
_lol_cmd_version
Expand All @@ -114,6 +117,7 @@ lol() {
echo " lol simp [file] --editor"
echo " lol impl <issue-no> [--backend <provider:model>] [--max-iterations <N>] [--yolo] [--wait-for-ci]"
echo " lol usage [--today | --week] [--cache] [--cost]"
echo " lol rebase [--target-branch <branch>]"
echo " lol claude-clean [--dry-run]"
echo ""
echo "Flags:"
Expand Down
44 changes: 44 additions & 0 deletions src/cli/lol/parsers.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <branch>]"
echo ""
echo "Options:"
echo " --target-branch <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 <branch>]" >&2
return 1
fi
target_branch="$1"
shift
;;
-*)
echo "Error: Unknown option '$1'" >&2
echo "Usage: lol rebase [--target-branch <branch>]" >&2
return 1
;;
*)
echo "Error: Unexpected argument '$1'" >&2
echo "Usage: lol rebase [--target-branch <branch>]" >&2
return 1
;;
esac
done

_lol_cmd_rebase "$target_branch"
}