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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ Options:
--spec PATH Build a single spec file (skip discovery)
--dry-run Discover specs and print plan, no execution
--max-commits-per-pr N PR commit cap (default: 8, max: 100)
--max-loc-per-pr N PR line-of-code cap (default: 400, range: 50-5000)
--max-loc-per-pr N PR line-of-code cap (default: 250, range: 50-5000)
--platform PLATFORM github, gitlab, or auto (default: auto)
--parallel N Concurrent agentic loops, HF engine only (default: 1)
--skip-auth-check Skip gh/glab auth validation (for CI with GITHUB_TOKEN)
Expand All @@ -146,7 +146,7 @@ Environment variables:
Codelicious produces small, focused, human-reviewable PRs by default:

- **8 commits max per PR** (`--max-commits-per-pr`, range 1–100)
- **400 LOC max per PR** (`--max-loc-per-pr`, range 50–5000)
- **250 LOC max per PR** (`--max-loc-per-pr`, range 50–5000)

When either cap is hit, the current PR is transitioned to review and a
continuation branch (`<branch>-part-2`, `-part-3`, ...) is opened so work
Expand Down
12 changes: 6 additions & 6 deletions docs/specs/28_bite_sized_continuous_prs_v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,17 +42,17 @@ many small PRs rather than a few large ones.
the old default
- [x] Update the banner print line that echoes the cap so it reflects the
new default
- [x] Add a `--max-loc-per-pr` CLI flag, default `400`, range 50–5000,
- [x] Add a `--max-loc-per-pr` CLI flag, default `250`, range 50–5000,
mapped via the same `_INT_KEYS` mechanism as `max_commits_per_pr`
- [x] Pass `max_loc_per_pr` into `V2Orchestrator(...)` constructor
(constructor accepts the kwarg now; cap-enforcement logic lands in Phase 2.2)

**Claude Code prompt:**
> Open `src/codelicious/cli.py`. In `_parse_args`, change the default
> for `max_commits_per_pr` from 50 to 8. Add a new option
> `max_loc_per_pr` with default 400, validated to be between 50 and 5000,
> `max_loc_per_pr` with default 250, validated to be between 50 and 5000,
> and add the flag `--max-loc-per-pr` to the arg map. Update `_INT_KEYS`
> to include it. In `main()`, pass `max_loc_per_pr=opts.get("max_loc_per_pr", 400)`
> to include it. In `main()`, pass `max_loc_per_pr=opts.get("max_loc_per_pr", 250)`
> when constructing `V2Orchestrator`. Update the banner to print both caps.
> Do not change behavior when the operator explicitly sets the cap.

Expand Down Expand Up @@ -97,7 +97,7 @@ many small PRs rather than a few large ones.

**File:** `src/codelicious/orchestrator.py`

- [x] Add `max_loc_per_pr: int = 400` parameter to `V2Orchestrator.__init__`
- [x] Add `max_loc_per_pr: int = 250` parameter to `V2Orchestrator.__init__`
(done in Phase 1.1)
- [x] In `run()`, after the existing commit-cap check (around line 1204),
add a parallel check: if `max_loc_per_pr > 0` and
Expand All @@ -111,7 +111,7 @@ many small PRs rather than a few large ones.

**Claude Code prompt:**
> In `src/codelicious/orchestrator.py`, extend `V2Orchestrator`. Add
> `max_loc_per_pr: int = 400` to `__init__`. Extract the existing
> `max_loc_per_pr: int = 250` to `__init__`. Extract the existing
> commit-cap split logic in `run()` (around line 1206) into a helper
> method `_split_pr_and_continue` that takes `spec_id_str`, `spec_title`,
> current `pr_part`, and returns `(new_pr_part, new_pr_number)`. Then
Expand Down Expand Up @@ -256,7 +256,7 @@ and `TestSkipCredentialProbeFlag` (2 cases). Full suite: 1901 passed.
## Acceptance Criteria

- [ ] Running `codelicious .` on a multi-task spec produces PRs of ≤ 8 commits
and ≤ 400 LOC each, splitting into part-2 / part-3 branches as needed.
and ≤ 250 LOC each, splitting into part-2 / part-3 branches as needed.
*Verified via unit tests; live end-to-end run requires a real repo +
`gh` auth and is left as a manual smoke test.*
- [x] Existing tests still pass: `pytest` → **1901 passed**.
Expand Down
599 changes: 599 additions & 0 deletions docs/specs/spec-v29_gap_closure_v1.md

Large diffs are not rendered by default.

497 changes: 497 additions & 0 deletions docs/specs/spec-v30_operational_resilience_and_idempotency_v1.md

Large diffs are not rendered by default.

65 changes: 63 additions & 2 deletions src/codelicious/agent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import logging
import pathlib
import queue
import re
import shutil
import subprocess
import threading
Expand Down Expand Up @@ -152,11 +153,15 @@ def _build_agent_command(
list[str]
Command list suitable for subprocess.Popen.
"""
# spec v29 Step 9: chunk-level config can override the default
# ``--output-format`` and ``--allowedTools`` values from spec 27 §3.2.
output_format_attr = getattr(config, "output_format", "")
output_format = output_format_attr if isinstance(output_format_attr, str) and output_format_attr else "stream-json"
cmd: list[str] = [
claude_bin,
"--print",
"--output-format",
"stream-json",
output_format,
"--verbose",
# bypassPermissions lets the agent edit/write/run shell commands inside
# the project working directory without per-action prompts. Codelicious
Expand All @@ -167,6 +172,15 @@ def _build_agent_command(
"bypassPermissions",
]

allowed_tools = getattr(config, "allowed_tools", None)
# Accept only an explicit list/tuple of strings or a pre-joined CSV string;
# ignore everything else (including MagicMock attribute auto-creation).
if isinstance(allowed_tools, (list, tuple)) and allowed_tools:
allowed_tools_str = ",".join(str(t) for t in allowed_tools)
cmd.extend(["--allowedTools", allowed_tools_str])
elif isinstance(allowed_tools, str) and allowed_tools.strip():
cmd.extend(["--allowedTools", allowed_tools.strip()])

model = getattr(config, "model", "")
if model:
cmd.extend(["--model", model])
Expand All @@ -189,6 +203,51 @@ def _build_agent_command(
return cmd


# Bounds for parsed Claude rate-limit retry windows. Below 10 s is suspect
# (provider noise); above 1 hour we'd rather fail fast than block a build.
_CLAUDE_RETRY_AFTER_MIN_S: float = 10.0
_CLAUDE_RETRY_AFTER_MAX_S: float = 3600.0
_CLAUDE_RETRY_AFTER_DEFAULT_S: float = 60.0


def _parse_claude_reset_seconds(text: str) -> float | None:
"""Parse a Claude CLI rate-limit message for a reset window in seconds.

Recognises three common shapes that appear in Claude CLI output:

* ``resets in <N> seconds`` / ``reset in <N>s``
* ``try again in <N> minutes`` / ``try again in <N>m``
* ``Retry-After: <N>`` (seconds)

Returns the parsed delay clamped to
``[_CLAUDE_RETRY_AFTER_MIN_S, _CLAUDE_RETRY_AFTER_MAX_S]`` or ``None`` if
no recognised pattern is present.
"""
if not text:
return None
lowered = text.lower()

seconds_match = re.search(r"reset[s]?\s+in\s+(\d+)\s*(?:seconds?|secs?|s)\b", lowered)
if seconds_match:
return _clamp_retry_after(float(seconds_match.group(1)))

minutes_match = re.search(r"try\s+again\s+in\s+(\d+)\s*(?:minutes?|mins?|m)\b", lowered)
if minutes_match:
return _clamp_retry_after(float(minutes_match.group(1)) * 60.0)

# Retry-After header occasionally surfaces verbatim in stderr; match in
# the original (case-insensitive) text since header names are mixed-case.
header_match = re.search(r"retry-after\s*:\s*(\d+)", text, re.IGNORECASE)
if header_match:
return _clamp_retry_after(float(header_match.group(1)))

return None


def _clamp_retry_after(value: float) -> float:
return max(_CLAUDE_RETRY_AFTER_MIN_S, min(_CLAUDE_RETRY_AFTER_MAX_S, value))


def _check_agent_errors(
returncode: int,
stdout_lines: list[str],
Expand Down Expand Up @@ -258,9 +317,11 @@ def _check_agent_errors(
safe_stderr,
)
safe_combined = sanitize_message((stderr_text + stdout_text)[-500:])
retry_after = _parse_claude_reset_seconds(stderr_text + stdout_text)
retry_after_s = retry_after if retry_after is not None else _CLAUDE_RETRY_AFTER_DEFAULT_S
raise ClaudeRateLimitError(
f"Claude CLI rate limited (exit code {returncode}): {safe_combined}",
retry_after_s=60.0,
retry_after_s=retry_after_s,
)

logger.warning(
Expand Down
Loading
Loading