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
19 changes: 14 additions & 5 deletions src/owloop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,13 @@ def agents() -> None:
"(no committed changes are left behind). Use to validate specs without "
"burning a full overnight run.",
)
@click.option(
"--no-push",
is_flag=True,
default=False,
help="Commit completed specs locally but do not push. Useful for "
"review-before-push workflows or CI jobs that should leave commits on disk.",
)
@click.option(
"--no-tui", "--plain", "no_tui",
is_flag=True,
Expand Down Expand Up @@ -304,16 +311,18 @@ def agents() -> None:
show_default=True,
)
@_common_run_options
def run(max_iterations: int, resume: bool, dry_run: bool, no_tui: bool, max_tokens_per_iteration: int,
max_turns_per_iteration: int, max_budget_usd: float, keep_retrying: bool, rollback: bool,
notify_webhook: str | None, notify_desktop: bool, converge_sweeps: int, workers: int,
worktree: bool, model: str, agent: str, verifier_model: str | None, subagents: bool,
idle_timeout: float, max_duration: int, max_tokens: int) -> None:
def run(max_iterations: int, resume: bool, dry_run: bool, no_push: bool, no_tui: bool,
max_tokens_per_iteration: int, max_turns_per_iteration: int, max_budget_usd: float,
keep_retrying: bool, rollback: bool, notify_webhook: str | None, notify_desktop: bool,
converge_sweeps: int, workers: int, worktree: bool, model: str, agent: str,
verifier_model: str | None, subagents: bool, idle_timeout: float, max_duration: int,
max_tokens: int) -> None:
"""Start the autonomous coding loop."""
run_cmd(
max_iterations=max_iterations,
resume=resume,
dry_run=dry_run,
no_push=no_push,
no_tui=no_tui,
max_tokens_per_iteration=max_tokens_per_iteration,
max_turns_per_iteration=max_turns_per_iteration,
Expand Down
4 changes: 3 additions & 1 deletion src/owloop/cli_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,9 @@ def _agent_run_options(f: Callable[..., Any]) -> Callable[..., Any]:
"--subagents",
is_flag=True,
default=False,
help="Split large iterations into Orient/Implement/Verify subagent phases.",
help="Split large iterations into Orient/Implement/Verify subagent phases. "
"Small/scoped specs (≤3 files by default) still run as a single agent "
"to save tokens.",
)(f)
f = click.option(
"--idle-timeout", type=float, default=DEFAULT_IDLE_TIMEOUT,
Expand Down
17 changes: 11 additions & 6 deletions src/owloop/commands/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def _run_engine(
ascii: bool = False, no_color: bool = False, compact: bool = False,
verifier_model: str | None = None, subagents: bool = False,
session_id: str | None = None, resume: bool = False,
no_tui: bool = False, dry_run: bool = False,
no_tui: bool = False, dry_run: bool = False, no_push: bool = False,
max_tokens_per_iteration: int = 0,
max_turns_per_iteration: int = 0,
max_budget_usd: float = 0.0,
Expand Down Expand Up @@ -73,6 +73,7 @@ def _run_engine(
resume=resume,
no_tui=no_tui,
dry_run=dry_run,
no_push=no_push,
max_tokens_per_iteration=max_tokens_per_iteration,
max_turns_per_iteration=max_turns_per_iteration,
max_budget_usd=max_budget_usd,
Expand All @@ -99,6 +100,7 @@ def _run_engine(
resume = kwargs["resume"]
no_tui = kwargs["no_tui"]
dry_run = kwargs["dry_run"]
no_push = kwargs["no_push"]
max_tokens_per_iteration = kwargs["max_tokens_per_iteration"]
max_turns_per_iteration = kwargs["max_turns_per_iteration"]
max_budget_usd = kwargs["max_budget_usd"]
Expand Down Expand Up @@ -129,6 +131,7 @@ def _run_engine(
session_id=session_id,
resume=resume,
dry_run=dry_run,
no_push=no_push,
keep_retrying=keep_retrying,
rollback=rollback,
notify_webhook=resolved_webhook,
Expand Down Expand Up @@ -277,11 +280,12 @@ def _print_dry_run_report(console: Console, summary: RunSummary) -> None:


def run_cmd(
max_iterations: int, resume: bool, dry_run: bool, no_tui: bool, max_tokens_per_iteration: int,
max_turns_per_iteration: int, max_budget_usd: float, keep_retrying: bool, rollback: bool,
notify_webhook: str | None, notify_desktop: bool, converge_sweeps: int, workers: int,
worktree: bool, model: str, agent: str, verifier_model: str | None, subagents: bool,
idle_timeout: float, max_duration: int, max_tokens: int,
max_iterations: int, resume: bool, dry_run: bool, no_push: bool, no_tui: bool,
max_tokens_per_iteration: int, max_turns_per_iteration: int, max_budget_usd: float,
keep_retrying: bool, rollback: bool, notify_webhook: str | None, notify_desktop: bool,
converge_sweeps: int, workers: int, worktree: bool, model: str, agent: str,
verifier_model: str | None, subagents: bool, idle_timeout: float, max_duration: int,
max_tokens: int,
) -> None:
"""Start the autonomous coding loop."""
ascii, no_color, compact, verbose = _cli_options()
Expand All @@ -301,6 +305,7 @@ def run_cmd(
resume=resume,
no_tui=no_tui,
dry_run=dry_run,
no_push=no_push,
max_tokens_per_iteration=max_tokens_per_iteration,
max_turns_per_iteration=max_turns_per_iteration,
max_budget_usd=max_budget_usd,
Expand Down
3 changes: 2 additions & 1 deletion src/owloop/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,12 @@
"notify_webhook": str,
"no_tui": bool,
"dry_run": bool,
"no_push": bool,
}

# Boolean CLI flags default to False; config turns them on.
_BOOL_FLAG_KEYS: frozenset[str] = frozenset(
{"notify_desktop", "rollback", "keep_retrying", "no_tui", "dry_run"}
{"notify_desktop", "rollback", "keep_retrying", "no_tui", "dry_run", "no_push"}
)

# Numeric CLI options use 0 as the "not set" sentinel.
Expand Down
123 changes: 107 additions & 16 deletions src/owloop/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ class TerminalState(str, Enum):
TAMPERED = "tampered"
INTERRUPTED = "interrupted"
FAILED = "failed"
SOFT_FAILURE = "soft_failure"

def __str__(self) -> str: # so f-strings / json render the value, not "TerminalState.SUCCESS"
return self.value
Expand Down Expand Up @@ -142,6 +143,7 @@ class StopReason(str, Enum):
INTERRUPTED = "interrupted"
PREFLIGHT_FAILED = "preflight_failed"
DIRTY_WORKSPACE_DECLINED = "dirty_workspace_declined"
SOFT_FAILURE = "soft_failure"

def __str__(self) -> str:
return self.value
Expand Down Expand Up @@ -181,6 +183,7 @@ def __str__(self) -> str:
StopReason.INTERRUPTED: TerminalState.INTERRUPTED,
StopReason.PREFLIGHT_FAILED: TerminalState.FAILED,
StopReason.DIRTY_WORKSPACE_DECLINED: TerminalState.FAILED,
StopReason.SOFT_FAILURE: TerminalState.SOFT_FAILURE,
}


Expand Down Expand Up @@ -349,6 +352,9 @@ class EngineConfig:
# When True, run exactly one iteration, skip push, revert any commit the
# iteration made, and produce a DryRunReport instead of looping.
dry_run: bool = False
# When True, commit and mark specs complete locally but never push. Useful
# for review-before-push workflows or CI dry runs that want real commits.
no_push: bool = False
# Completion notifications: fire a webhook and/or desktop notification when
# the run stops on an attention-worthy terminal state. None/False = off.
notify_webhook: str | None = None
Expand Down Expand Up @@ -532,8 +538,29 @@ def _log_line(self, text: str) -> None:
def _is_git_repo(self) -> bool:
return self._run_git("rev-parse", "--is-inside-work-tree").returncode == 0

_OWLOOP_OWNED_PREFIXES = (".owloop",)

def _is_path_owned(self, path: str) -> bool:
"""Return True if a path belongs to owloop's own state directories."""
for prefix in self._OWLOOP_OWNED_PREFIXES:
if path == prefix or path.startswith(prefix + "/"):
return True
return False

def _is_dirty(self) -> bool:
return bool(self._run_git("status", "--porcelain").stdout.strip())
result = self._run_git("status", "--porcelain")
for line in result.stdout.splitlines():
# Porcelain format: "XY path" or "XY orig -> dest".
path_part = line[3:].strip()
# If this is a rename, check both sides.
if " -> " in path_part:
source, _, dest = path_part.partition(" -> ")
if self._is_path_owned(source) and self._is_path_owned(dest):
continue
elif self._is_path_owned(path_part):
continue
return True
return False

def _resolve_main_repo_dir(self) -> Path:
if not self._is_git_repo():
Expand Down Expand Up @@ -1045,7 +1072,7 @@ def _guarded_hash(self, spec_name: str | None) -> str:

def _run_verification_gate(
self, iteration: int, spec_name: str | None, guard_before: str
) -> tuple[bool, bool, list[dict[str, Any]]]:
) -> verification.GateResult:
"""Deterministically verify an iteration outside the agent's control.

Delegates to the shared gate in ``verification.py`` (the single
Expand All @@ -1060,14 +1087,22 @@ def _run_verification_gate(

if result.tampered:
self._emit("spec_tampered", iteration=iteration, spec=spec_name)
return False, True, []
return verification.GateResult(passed=False, tampered=True, passed_count=0, failed_count=0)

if result.passed:
self._emit(
"verification_gate_passed",
iteration=iteration,
passed=result.passed_count,
)
elif result.soft_failure:
self._emit(
"verification_gate_soft_failure",
iteration=iteration,
passed=result.passed_count,
failed=result.failed_count,
commands=[f["command"] for f in result.failures],
)
else:
self._emit(
"verification_gate_failed",
Expand All @@ -1076,7 +1111,7 @@ def _run_verification_gate(
failed=result.failed_count,
commands=[f["command"] for f in result.failures],
)
return result.passed, False, result.failures
return result

def _head(self) -> str:
return str(self._run_git("rev-parse", "HEAD").stdout).strip()
Expand Down Expand Up @@ -1144,6 +1179,23 @@ def _rollback_iteration(self, iteration: int, last_good: str) -> None:
patch=str(patch_path) if diff.stdout.strip() else None,
)

def _should_use_subagents(self, target_spec: str | None) -> bool:
"""Skip expensive subagent orchestration for small/scoped specs.

Subagents are great for cross-file refactors, but for a single-file or
tightly-scoped change they burn tokens on coordination overhead. When
the spec declares a `## Files` scope that is at or below the threshold,
run a single agent iteration instead.
"""
if not self.config.use_subagents:
return False
if not target_spec:
return True
scope = spec_queue.get_spec_file_scope(self.specs_dir / target_spec)
if not scope:
return True
return len(scope) > self.config.subagent_file_threshold

def run_iteration(self, iteration: int, target_spec: str | None = None) -> IterationResult:
owloop_dir = resolve_owloop_dir(self.cwd)
prompt_file = owloop_dir / "PROMPT_build.md"
Expand Down Expand Up @@ -1173,7 +1225,7 @@ def _on_line(line: str) -> None:
raise IterationTokenLimitExceededError(iteration_tokens)

try:
if self.config.use_subagents:
if self._should_use_subagents(target_spec):
orchestrator = SubagentOrchestrator(
self.adapter, self.verifier_adapter, self.cwd, on_line=_on_line
)
Expand Down Expand Up @@ -1494,7 +1546,7 @@ def run(self) -> RunSummary:

if self.config.dry_run:
self._append_run_note(iteration, result.success, result.summary)
acceptance_passed, acceptance_failed, _ = verification.run_acceptance_criteria(
acceptance_passed, acceptance_failed, _, _, _ = verification.run_acceptance_criteria(
self.cwd, self.specs_dir, active_spec
)
current_head = self._head()
Expand Down Expand Up @@ -1531,23 +1583,26 @@ def run(self) -> RunSummary:
break

# ── Deterministic verification gate (engine-owned) ──
gate_passed = False
tampered = False
gate_failures: list[dict[str, Any]] = []
gate_result = verification.GateResult(passed=False, tampered=False, passed_count=0, failed_count=0)
if result.promise_state == "DONE":
gate_passed, tampered, gate_failures = self._run_verification_gate(
gate_result = self._run_verification_gate(
iteration, active_spec, guard_before
)
# Shell-first ordering: the expensive LLM verifier runs only
# on work that already survived the mechanical gate.
if (
gate_passed
gate_result.passed
and self.verifier_adapter is not None
and not self.config.use_subagents
):
gate_passed = self._apply_llm_verifier(iteration, result)
gate_result = verification.GateResult(
passed=self._apply_llm_verifier(iteration, result),
tampered=False,
passed_count=gate_result.passed_count,
failed_count=gate_result.failed_count,
)

if gate_passed:
if gate_result.passed:
# Verified success: only now does the engine commit, mark the
# spec complete, and push — never the agent.
consecutive_failures = 0
Expand All @@ -1563,7 +1618,10 @@ def run(self) -> RunSummary:
self._append_run_note(iteration, True, note_summary)
self._mark_spec_complete(active_spec)
self._commit_iteration(iteration, active_spec)
self._push(branch)
if self.config.no_push:
self._emit("push_skipped", branch=branch)
else:
self._push(branch)
spec_status = self._spec_status()
self._emit(
"iteration_end",
Expand Down Expand Up @@ -1602,8 +1660,22 @@ def run(self) -> RunSummary:
# iteration is minutes of work; waiting here buys nothing.
continue

# ── Soft failure: functional checks pass, meta-check fails ──
if gate_result.soft_failure:
self._append_run_note(iteration, False, result.summary)
diff = self._run_git("diff", last_good, "--", ".", ":!.owloop")
self._emit(
"soft_failure",
iteration=iteration,
spec=active_spec,
diff=diff.stdout or "",
commands=[f["command"] for f in gate_result.failures],
)
stopped_reason = StopReason.SOFT_FAILURE
break

# ── Failure: classify, record feedback, roll back, stop on a stall ──
if tampered:
if gate_result.tampered:
failure_reason = FailureReason.TAMPERED
elif result.timed_out:
failure_reason = FailureReason.TIMEOUT
Expand All @@ -1622,7 +1694,7 @@ def run(self) -> RunSummary:
self._append_run_note(iteration, False, result.summary)
# Written before rollback; .owloop/ is excluded from the reset,
# so the next iteration's prompt starts from this diagnosis.
self._write_failure_feedback(iteration, failure_reason, result, gate_failures)
self._write_failure_feedback(iteration, failure_reason, result, gate_result.failures)
self._rollback_iteration(iteration, last_good)

consecutive_failures += 1
Expand Down Expand Up @@ -1700,10 +1772,29 @@ def run(self) -> RunSummary:
dry_run_report=dry_run_report,
)
self._write_summary(summary)
self._generate_report()
self._notify(summary)
self._close_append_handles()
return summary

def _generate_report(self) -> None:
"""Generate a static HTML report inside the worktree (best-effort)."""
try:
from owloop.report import ReportGenerator
from owloop.report_ai import ReportInsights

report_path = self.cwd / ".owloop" / "reports" / "owloop_report_latest.html"
report_path.parent.mkdir(parents=True, exist_ok=True)
generator = ReportGenerator(self.cwd)
generator.generate(
output_path=report_path,
insights=ReportInsights(),
use_tailwind=False,
)
except Exception:
# Report generation must never change the run outcome.
pass

def _notify(self, summary: RunSummary) -> None:
"""Fire completion notifications for the finished run (best-effort)."""
if not self.config.notify_webhook and not self.config.notify_desktop:
Expand Down
Loading
Loading