From a8b0b2c646dc8bfbbf7191625ab58d4344584e15 Mon Sep 17 00:00:00 2001 From: Eric Cao Date: Tue, 7 Jul 2026 15:03:47 +0800 Subject: [PATCH 1/3] feat(#58): complete issue #58 implementation with code review fixes --- src/owloop/_brand.py | 70 ++- src/owloop/cli.py | 783 +++++++++++++++++++++++++++++----- src/owloop/config.py | 181 ++++++++ src/owloop/engine.py | 148 ++++--- src/owloop/parallel.py | 27 +- src/owloop/reporter.py | 52 ++- src/owloop/tui.py | 89 +++- tests/test_brand.py | 43 +- tests/test_cli.py | 68 ++- tests/test_config.py | 310 ++++++++++++++ tests/test_engine_phase1.py | 20 +- tests/test_parallel.py | 34 +- tests/test_spec_from_issue.py | 20 +- 13 files changed, 1574 insertions(+), 271 deletions(-) create mode 100644 src/owloop/config.py create mode 100644 tests/test_config.py diff --git a/src/owloop/_brand.py b/src/owloop/_brand.py index ace7f1e..d8d9956 100644 --- a/src/owloop/_brand.py +++ b/src/owloop/_brand.py @@ -6,6 +6,7 @@ from __future__ import annotations +import subprocess from os import PathLike # ── palette ── @@ -134,14 +135,67 @@ def wake_message() -> str: return f"{OLLIE_NAME} is waking up..." -def exit_hints(branch: str, iterations: int, cwd: str | PathLike[str], main_repo_dir: str | PathLike[str]) -> list[str]: - hints = [f"Branch: {branch}"] - if str(cwd) != str(main_repo_dir): - hints = [ - f"Review: git log --oneline HEAD~{iterations}..HEAD", - f"Merge: cd {main_repo_dir} && git merge {branch}", - f"Discard: git worktree remove {cwd}", - ] +# Stopped reasons where the user can resume work rather than merge. +_RESUME_REASONS = { + "interrupted", + "stalled", + "exhausted", + "blocked", + "decide", + "fix_loop_blocked", + "max_iterations", + "max_duration", + "max_tokens", +} + + +def _default_branch(main_repo_dir: str | PathLike[str]) -> str: + """Return the current branch of the main repo, falling back to ``main``.""" + try: + result = subprocess.run( + ["git", "-C", str(main_repo_dir), "branch", "--show-current"], + capture_output=True, + text=True, + check=False, + ) + branch = result.stdout.strip() + if result.returncode == 0 and branch: + return branch + except Exception: + pass + return "main" + + +def exit_hints( + branch: str, + iterations: int, + cwd: str | PathLike[str], + main_repo_dir: str | PathLike[str], + stopped_reason: str = "", + report_path: str | None = None, +) -> list[str]: + if str(cwd) == str(main_repo_dir): + return [f"Branch: {branch}"] + + review_cmd = ( + "git log --oneline -1" + if iterations <= 0 + else f"git log --oneline HEAD~{iterations}..HEAD" + ) + + base_branch = _default_branch(main_repo_dir) + + hints: list[str] = [] + if report_path: + hints.append(f"Report: {report_path}") + hints.extend([ + f"Review: {review_cmd}", + f"Merge: cd {main_repo_dir} && git checkout {base_branch} && git merge {branch}", + f"Push: git push origin {base_branch}", + f"Cleanup: git worktree remove {cwd} && git branch -d {branch}", + ]) + if stopped_reason.lower() in _RESUME_REASONS: + hints.append("Resume: owloop run --resume") return hints diff --git a/src/owloop/cli.py b/src/owloop/cli.py index 87fe8b1..923aac2 100644 --- a/src/owloop/cli.py +++ b/src/owloop/cli.py @@ -1,26 +1,29 @@ """owloop CLI — entry point for uvx owloop / owloop commands.""" +import json import os import re +import shutil import subprocess import sys import threading import time from collections.abc import Callable from pathlib import Path -from typing import Any +from typing import Any, cast import click from rich.console import Console from rich.panel import Panel -from rich.prompt import Confirm +from rich.table import Table from rich.text import Text -from owloop import _brand +from owloop import _brand, git_stats from owloop.adapters import DEFAULT_IDLE_TIMEOUT, get_adapter from owloop.backpressure import discover_and_save -from owloop.engine import EngineConfig, OwloopEngine, RunSummary -from owloop.paths import resolve_specs_dir +from owloop.config import apply_config_defaults, load_run_config +from owloop.engine import EngineConfig, OwloopEngine, RunSummary, TerminalState +from owloop.paths import resolve_logs_dir, resolve_specs_dir from owloop.report import ReportGenerator from owloop.report_ai import AIReportInsightsGenerator from owloop.reporter import ConsoleReporter @@ -198,6 +201,113 @@ def _cli_options() -> tuple[bool, bool, bool, bool]: return bool(obj.get("ascii")), bool(obj.get("no_color")), bool(obj.get("compact")), bool(obj.get("verbose")) +def _run_config_path() -> Path: + """Return the persistent run configuration file path.""" + return Path.cwd() / ".owloop" / "config.toml" + + +def _load_and_apply_run_config(**cli_kwargs: Any) -> dict[str, Any]: + """Load ``.owloop/config.toml`` and merge its ``[run]`` defaults into CLI kwargs.""" + config = load_run_config(_run_config_path()) + return apply_config_defaults(cli_kwargs, config) + + +def _default_report_path() -> Path: + """Return the default quick-report output path.""" + return Path.cwd() / ".owloop" / "reports" / "owloop_report_latest.html" + + +def _generate_quick_report(summary: RunSummary) -> Path | None: + """Generate a fast, no-AI HTML report and return its path, or None on failure.""" + try: + report_path = _default_report_path() + report_path.parent.mkdir(parents=True, exist_ok=True) + generator = ReportGenerator(summary.main_repo_dir) + generator.generate(report_path, insights=None, use_tailwind=False) + return report_path + except Exception: + return None + + +def _format_spec_table(spec_paths: list[Path], verbose: bool = False) -> Table | list[Panel]: + """Return either a compact Table or full Panels for the generated specs.""" + if verbose: + return [ + Panel( + sp.read_text(encoding="utf-8"), + title=f"[bold]{sp.name}[/]", + border_style=_brand.AMBER, + padding=(1, 2), + ) + for sp in spec_paths + ] + + table = Table( + title="Generated specs", + border_style=_brand.AMBER, + show_lines=False, + padding=(0, 2), + ) + table.add_column("File", style="bold") + table.add_column("Title") + table.add_column("Priority", justify="center") + table.add_column("Status", justify="center") + + for sp in spec_paths: + content = sp.read_text(encoding="utf-8") + state = classify_spec(content) + status_text = { + "done": "[green]done[/]", + "in_progress": f"[{_brand.AMBER}]in progress[/]", + "pending": "[dim]pending[/]", + }[state] + priority = "—" + title: str | None = None + for line in content.splitlines(): + if line.strip().startswith("## Priority:"): + priority = line.split(":", 1)[-1].strip() + if line.startswith("# Spec:"): + title = line.split(":", 1)[-1].strip() + table.add_row(sp.name, title or "—", priority, status_text) + + return table + + +def _read_latest_session(project_dir: Path) -> dict[str, Any] | None: + """Load the latest session descriptor if it exists.""" + path = project_dir / ".owloop" / "logs" / "session_latest.json" + if not path.is_file(): + return None + try: + import json + data = json.loads(path.read_text(encoding="utf-8")) + if isinstance(data, dict): + return data + except (json.JSONDecodeError, OSError): + pass + return None + + +def _find_worktree_path(project_dir: Path, session: dict[str, Any] | None = None) -> Path | None: + """Return the worktree path from the session record or ``git worktree list``.""" + if session and session.get("path"): + return Path(session["path"]) + result = subprocess.run( + ["git", "worktree", "list", "--porcelain"], + cwd=project_dir, + capture_output=True, + text=True, + ) + if result.returncode != 0: + return None + for line in result.stdout.splitlines(): + if line.startswith("worktree "): + wt = Path(line.split(" ", 1)[1]) + if wt != project_dir and wt.name.startswith("owloop-"): + return wt + return None + + class AgentStreamDisplay: """Live display for streaming agent output. @@ -349,7 +459,7 @@ def _ensure_init(cwd: Path, console: Console, *, ascii: bool = False) -> None: (owloop_path / "logs").mkdir() gitignore = cwd / ".gitignore" - gitignore_entries = [".owloop/logs/", ".owloop/PROMPT_build.md"] + gitignore_entries = [".owloop/"] if gitignore.exists(): existing = gitignore.read_text(encoding="utf-8") to_add = [e for e in gitignore_entries if e not in existing] @@ -409,17 +519,13 @@ def _validate_agent(ctx: click.Context, param: click.Parameter, value: str) -> s return value -def _common_run_options(f: Callable[..., Any]) -> Callable[..., Any]: - """Shared options for the run and go commands.""" +def _agent_run_options(f: Callable[..., Any]) -> Callable[..., Any]: + """Shared non-model run options for run, go, and spec.""" f = click.option( "--agent", default="claude", metavar="AGENT", callback=_validate_agent, help="Coding agent preset (see `owloop agents` for the full list).", show_default=True, )(f) - f = click.option( - "--model", default=None, metavar="MODEL", - help="Model to use (defaults per agent; claude honors CLAUDE_MODEL).", - )(f) f = click.option( "--verifier-model", help="Claude model for the independent verifier agent (defaults to --model).", @@ -450,8 +556,98 @@ def _common_run_options(f: Callable[..., Any]) -> Callable[..., Any]: return f +def _common_run_options(f: Callable[..., Any]) -> Callable[..., Any]: + """Shared options for the run and go commands (includes --model default None).""" + f = click.option( + "--model", default=None, metavar="MODEL", + help="Model to use (defaults per agent; claude honors CLAUDE_MODEL).", + )(f) + return _agent_run_options(f) + + +def _extra_run_options(f: Callable[..., Any]) -> Callable[..., Any]: + """Additional run options forwarded by run, go, and spec.""" + f = click.option( + "--max-iterations", "-n", type=int, default=0, + help="Maximum iterations (0 = unlimited).", show_default=True, + )(f) + f = click.option( + "--resume", + is_flag=True, + default=False, + help="Resume the most recent owloop session (reuse its worktree and branch).", + )(f) + f = click.option( + "--dry-run", "--one-shot", "dry_run", + is_flag=True, + default=False, + help="Run exactly one iteration, print a pass/fail report, and skip push " + "(no committed changes are left behind). Use to validate specs without " + "burning a full overnight run.", + )(f) + f = click.option( + "--no-tui", "--plain", "no_tui", + is_flag=True, + default=False, + help="Bypass the full-screen TUI and print plain console output, even in a TTY.", + )(f) + f = click.option( + "--max-tokens-per-iteration", type=MaxTokensParamType(), default=0, + help="Kill a single iteration early if it exceeds N tokens (0 = unlimited; " + "supports k/w/m shorthand).", show_default=True, + )(f) + f = click.option( + "--max-turns-per-iteration", type=int, default=0, + help="Forward --max-turns N to `claude -p` so a single iteration is bounded " + "at the source (0 = unlimited; ignored on CLIs without the flag).", + show_default=True, + )(f) + f = click.option( + "--max-budget-usd", type=float, default=0.0, + help="Forward a per-iteration USD budget cap to the CLI when supported " + "(0 = unlimited).", show_default=True, + )(f) + f = click.option( + "--keep-retrying", is_flag=True, default=False, + help="Legacy behavior: warn and back off on repeated failures instead of " + "hard-stopping with a `stalled` terminal state.", + )(f) + f = click.option( + "--rollback/--no-rollback", default=True, show_default=True, + help="Reset the worktree to the last good commit after a failed iteration " + "(a discarded-diff patch is saved under .owloop/logs/).", + )(f) + f = click.option( + "--notify-webhook", default=None, metavar="URL", + help="POST a JSON completion notification to this webhook when the run stops " + "on an attention-worthy state (or set OWLOOP_NOTIFY_WEBHOOK).", + )(f) + f = click.option( + "--notify-desktop", is_flag=True, default=False, + help="Fire a native desktop notification when the run stops.", + )(f) + f = click.option( + "--converge", "converge_sweeps", type=int, default=0, metavar="N", + help="After the spec queue empties, run up to N audit sweeps that append gap " + "specs until the codebase converges on the goal (0 = disabled).", + show_default=True, + )(f) + f = click.option( + "--workers", type=int, default=1, metavar="N", + help="Run up to N file-disjoint specs concurrently, each in its own worktree " + "(1 = sequential). Specs need a `## Files` scope to be scheduled in parallel.", + show_default=True, + )(f) + return f + + @main.command() @click.argument("goal") +@click.option( + "--verbose", "-v", is_flag=True, default=False, + help="Show the full spec panel instead of the compact table.", +) +@_extra_run_options @_common_run_options def go( goal: str, @@ -463,6 +659,20 @@ def go( max_duration: int, max_tokens: int, worktree: bool, + 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, + verbose: bool, ) -> None: """One command: init → generate spec(s) → review → start the loop. @@ -470,7 +680,8 @@ def go( Example: owloop go "refactor error handling in the API layer" """ - ascii, no_color, compact, verbose = _cli_options() + ascii, no_color, compact, cli_verbose = _cli_options() + verbose = verbose or cli_verbose console = Console(no_color=no_color) project_dir = Path.cwd() @@ -511,29 +722,43 @@ def go( for sp in spec_paths: console.print(f" [{_brand.CYAN}]{sp.name}[/]") console.print() - for sp in spec_paths: - console.print(Panel( - sp.read_text(encoding="utf-8"), - title=f"[bold]{sp.name}[/]", - border_style=_brand.AMBER, - padding=(1, 2), - )) - if sys.stdin.isatty(): - start = Confirm.ask("\nStart the loop now?", default=True, console=console) + display = _format_spec_table(spec_paths, verbose=verbose) + if isinstance(display, Table): + console.print(display) else: - console.print("\n[dim]Non-interactive: run[/] [bold]owloop run[/] [dim]to start.[/]") - start = False + for panel in display: + console.print(panel) - if start: - console.print(f"\n[{_brand.AMBER}]Starting autonomous loop...[/]") - _run_engine( - 0, worktree, model, agent, - idle_timeout, max_duration, max_tokens, - ascii=ascii, no_color=no_color, compact=compact, - verifier_model=verifier_model, - subagents=subagents, - ) + run_kwargs = _load_and_apply_run_config( + max_iterations=max_iterations, + worktree=worktree, + model=model, + agent=agent, + idle_timeout=idle_timeout, + max_duration=max_duration, + max_tokens=max_tokens, + ascii=ascii, + no_color=no_color, + compact=compact, + verifier_model=verifier_model, + subagents=subagents, + resume=resume, + no_tui=no_tui, + dry_run=dry_run, + max_tokens_per_iteration=max_tokens_per_iteration, + max_turns_per_iteration=max_turns_per_iteration, + max_budget_usd=max_budget_usd, + keep_retrying=keep_retrying, + rollback=rollback, + notify_webhook=notify_webhook, + notify_desktop=notify_desktop, + converge_sweeps=converge_sweeps, + workers=workers, + ) + + console.print(f"\n[{_brand.AMBER}]Starting autonomous loop...[/]") + _run_engine(**run_kwargs) @main.command() @@ -577,7 +802,7 @@ def init(example: bool) -> None: created.append(".owloop/logs/") gitignore = cwd / ".gitignore" - gitignore_entries = [".owloop/logs/", ".owloop/PROMPT_build.md"] + gitignore_entries = [".owloop/"] if gitignore.exists(): existing = gitignore.read_text(encoding="utf-8") to_add = [e for e in gitignore_entries if e not in existing] @@ -633,8 +858,8 @@ def init(example: bool) -> None: @main.command() @click.argument("goal") @click.option( - "--model", default=DEFAULT_MODEL, - help="Claude model to use (or set CLAUDE_MODEL).", show_default=True, + "--model", default=None, + help="Claude model to use (or set CLAUDE_MODEL).", ) @click.option( "--max-rounds", type=int, default=3, @@ -644,14 +869,47 @@ def init(example: bool) -> None: "--yes", "-y", is_flag=True, default=False, help="Approve the generated spec and start the loop immediately.", ) -def spec(goal: str, model: str, max_rounds: int, yes: bool) -> None: +@click.option( + "--verbose", "-v", is_flag=True, default=False, + help="Show the full spec panel instead of the compact table.", +) +@_extra_run_options +@_agent_run_options +def spec( + goal: str, + model: str, + max_rounds: int, + yes: bool, + agent: str, + verifier_model: str | None, + subagents: bool, + idle_timeout: float, + max_duration: int, + max_tokens: int, + worktree: bool, + 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, + verbose: bool, +) -> None: """Turn a vague goal into a concrete spec via agent clarification. Claude scans the codebase, calibrates baselines, and drafts a complete constraint-oriented spec. The spec is shown for approval before the loop starts unless --yes is passed. """ - ascii, no_color, compact, verbose = _cli_options() + ascii, no_color, compact, cli_verbose = _cli_options() + verbose = verbose or cli_verbose console = Console(no_color=no_color) project_dir = Path.cwd() @@ -665,7 +923,7 @@ def spec(goal: str, model: str, max_rounds: int, yes: bool) -> None: "claude", model=model, claude_cmd=os.environ.get("CLAUDE_CMD", "claude"), - idle_timeout=DEFAULT_IDLE_TIMEOUT, + idle_timeout=idle_timeout, ) generator = SpecGenerator(project_dir, adapter) stream = AgentStreamDisplay(console, verbose=verbose) @@ -684,33 +942,56 @@ def spec(goal: str, model: str, max_rounds: int, yes: bool) -> None: for sp in spec_paths: console.print(f" [{_brand.CYAN}]{sp}[/]") console.print() - for sp in spec_paths: - console.print(Panel( - sp.read_text(encoding="utf-8"), - title=f"[bold]{sp.name}[/]", - border_style=_brand.AMBER, - padding=(1, 2), - )) + + display = _format_spec_table(spec_paths, verbose=verbose) + if isinstance(display, Table): + console.print(display) + else: + for panel in display: + console.print(panel) + + run_kwargs = _load_and_apply_run_config( + max_iterations=max_iterations, + worktree=worktree, + model=model, + agent=agent, + idle_timeout=idle_timeout, + max_duration=max_duration, + max_tokens=max_tokens, + ascii=ascii, + no_color=no_color, + compact=compact, + verifier_model=verifier_model, + subagents=subagents, + resume=resume, + no_tui=no_tui, + dry_run=dry_run, + max_tokens_per_iteration=max_tokens_per_iteration, + max_turns_per_iteration=max_turns_per_iteration, + max_budget_usd=max_budget_usd, + keep_retrying=keep_retrying, + rollback=rollback, + notify_webhook=notify_webhook, + notify_desktop=notify_desktop, + converge_sweeps=converge_sweeps, + workers=workers, + ) start_loop = yes if not yes: if sys.stdin.isatty(): - console.print("\n[bold]Start the loop now?[/] [y/N] ", end="") + console.print("\n[bold]Start the loop now?[/] [Y/n] ", end="") try: - reply = input().strip().lower() or "n" + reply = input().strip().lower() or "y" except EOFError: - reply = "n" + reply = "y" start_loop = reply.startswith("y") else: console.print("\n[dim]Non-interactive mode: review the spec and run[/] [bold]owloop run[/]") if start_loop: console.print(f"\n[{_brand.AMBER}]Starting autonomous loop...[/]") - _run_engine( - max_iterations=0, worktree=True, model=model, agent="claude", - idle_timeout=DEFAULT_IDLE_TIMEOUT, max_duration=0, max_tokens=0, - ascii=ascii, no_color=no_color, compact=compact, - ) + _run_engine(**run_kwargs) else: console.print("\n[dim]Review the spec, then run[/] [bold]owloop run[/]") console.print() @@ -767,21 +1048,27 @@ def agents() -> None: ) -# Terminal states that mean "the loop did not finish its work" — surfaced as a -# non-zero exit so unattended callers (CI, cron, wrappers) can detect it. An -# exhausted budget is explicitly NOT a success. -STOPPED_REASON_EXIT_1 = { - "preflight_failed", - "dirty_workspace_declined", - "max_iterations", - "max_duration", - "max_tokens", - "stalled", - "fix_loop_blocked", - "tampered", +# Three-tier exit codes for unattended callers (CI, cron, wrappers). +# 0 = finished successfully +# 1 = hard failure (engine/agent broke, spec tampered, preflight failed) +# 2 = needs human attention (blocked, decide, stalled, exhausted, interrupted) +_EXIT_CODE_MAP = { + TerminalState.SUCCESS: 0, + TerminalState.CLEAN_NO_OP: 0, + TerminalState.BLOCKED: 2, + TerminalState.DECIDE: 2, + TerminalState.STALLED: 2, + TerminalState.EXHAUSTED: 2, + TerminalState.TAMPERED: 1, + TerminalState.INTERRUPTED: 2, + TerminalState.FAILED: 1, } +def _exit_code_for_summary(summary: RunSummary) -> int: + return _EXIT_CODE_MAP.get(cast(TerminalState, summary.state), 1) + + def _run_engine( max_iterations: int, worktree: bool, model: str | None, agent: str, idle_timeout: float = DEFAULT_IDLE_TIMEOUT, max_duration: int = 0, max_tokens: int = 0, @@ -799,6 +1086,60 @@ def _run_engine( converge_sweeps: int = 0, workers: int = 1, ) -> None: + # Merge persistent config defaults before constructing the engine. + kwargs = _load_and_apply_run_config( + max_iterations=max_iterations, + worktree=worktree, + model=model, + agent=agent, + idle_timeout=idle_timeout, + max_duration=max_duration, + max_tokens=max_tokens, + ascii=ascii, + no_color=no_color, + compact=compact, + verifier_model=verifier_model, + subagents=subagents, + session_id=session_id, + resume=resume, + no_tui=no_tui, + dry_run=dry_run, + max_tokens_per_iteration=max_tokens_per_iteration, + max_turns_per_iteration=max_turns_per_iteration, + max_budget_usd=max_budget_usd, + keep_retrying=keep_retrying, + rollback=rollback, + notify_webhook=notify_webhook, + notify_desktop=notify_desktop, + converge_sweeps=converge_sweeps, + workers=workers, + ) + max_iterations = kwargs["max_iterations"] + worktree = kwargs["worktree"] + model = kwargs["model"] + agent = kwargs["agent"] + idle_timeout = kwargs["idle_timeout"] + max_duration = kwargs["max_duration"] + max_tokens = kwargs["max_tokens"] + ascii = kwargs["ascii"] + no_color = kwargs["no_color"] + compact = kwargs["compact"] + verifier_model = kwargs["verifier_model"] + subagents = kwargs["subagents"] + session_id = kwargs["session_id"] + resume = kwargs["resume"] + no_tui = kwargs["no_tui"] + dry_run = kwargs["dry_run"] + 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"] + keep_retrying = kwargs["keep_retrying"] + rollback = kwargs["rollback"] + notify_webhook = kwargs["notify_webhook"] + notify_desktop = kwargs["notify_desktop"] + converge_sweeps = kwargs["converge_sweeps"] + workers = kwargs["workers"] + resolved_webhook = notify_webhook or os.environ.get("OWLOOP_NOTIFY_WEBHOOK") or None if workers > 1: _run_parallel( @@ -846,27 +1187,11 @@ def _run_engine( project_dir=Path.cwd(), ) + report_path: Path | None = None + if not no_tui and sys.stdout.isatty(): tui = OwloopTUI(ascii=ascii, no_color=no_color, compact=compact) - def _confirm_dirty() -> bool: - # TUI 已在 setup_worktree() 触发的事件里暂停了 Live 渲染, - # 这里直接用 rich 的 Confirm 在普通终端上提问即可。 - tui.console.print( - f"[{_brand.AMBER}]⚠[/] Workspace has uncommitted changes that won't appear in the worktree." - ) - return Confirm.ask("Continue anyway?", default=False, console=tui.console) - - def _confirm_worktree() -> bool: - return Confirm.ask( - "Create an isolated worktree to protect your main repository?", - default=True, - console=tui.console, - ) - - config.confirm_dirty = _confirm_dirty - config.confirm_worktree = _confirm_worktree - try: with tui: engine = OwloopEngine(config, adapter, on_event=tui.on_event) @@ -875,7 +1200,9 @@ def _confirm_worktree() -> bool: console = Console(no_color=no_color) console.print("\n[dim]owloop stopped.[/]") raise SystemExit(0) from None - tui.print_exit_summary(summary) + if summary.iterations > 0 and not config.dry_run: + report_path = _generate_quick_report(summary) + tui.print_exit_summary(summary, report_path=str(report_path) if report_path else None) if config.dry_run: _print_dry_run_report(tui.console, summary) else: @@ -884,22 +1211,6 @@ def _confirm_worktree() -> bool: console.print(_banner_text(ascii=ascii, no_color=no_color)) console.print(f"[{_brand.AMBER}]Starting autonomous loop...[/]") - def _confirm_dirty_plain() -> bool: - console.print( - f"[{_brand.AMBER}]⚠[/] Workspace has uncommitted changes that won't appear in the worktree." - ) - return Confirm.ask("Continue anyway?", default=False, console=console) - - def _confirm_worktree_plain() -> bool: - return Confirm.ask( - "Create an isolated worktree to protect your main repository?", - default=True, - console=console, - ) - - config.confirm_dirty = _confirm_dirty_plain - config.confirm_worktree = _confirm_worktree_plain - reporter = ConsoleReporter(console, ascii=ascii) engine = OwloopEngine(config, adapter, on_event=reporter.on_event) try: @@ -907,12 +1218,18 @@ def _confirm_worktree_plain() -> bool: except KeyboardInterrupt: console.print("\n[dim]owloop stopped.[/]") raise SystemExit(0) from None - reporter.print_summary(summary) + if summary.iterations > 0 and not config.dry_run: + report_path = _generate_quick_report(summary) + reporter.print_summary(summary, report_path=str(report_path) if report_path else None) if config.dry_run: _print_dry_run_report(console, summary) + if report_path: + console.print(f"Report: {report_path}") + + exit_code = _exit_code_for_summary(summary) + if exit_code: + raise SystemExit(exit_code) - if summary.stopped_reason in STOPPED_REASON_EXIT_1: - raise SystemExit(1) def _run_parallel( @@ -952,8 +1269,9 @@ def _adapter_factory() -> Any: console.print("\n[dim]owloop stopped.[/]") raise SystemExit(0) from None reporter.print_summary(summary) - if summary.stopped_reason in STOPPED_REASON_EXIT_1: - raise SystemExit(1) + exit_code = _exit_code_for_summary(summary) + if exit_code: + raise SystemExit(exit_code) def _print_dry_run_report(console: Console, summary: RunSummary) -> None: @@ -1160,6 +1478,236 @@ def status() -> None: console.print(table) console.print() + # Runtime section + session = _read_latest_session(Path.cwd()) + if session: + from rich.table import Table as RuntimeTable + + runtime = RuntimeTable( + title="Runtime", + border_style=_brand.AMBER, + show_lines=False, + padding=(0, 2), + ) + runtime.add_column("Key", style="bold") + runtime.add_column("Value") + + session_id = session.get("session_id", "—") + branch = session.get("branch", "—") + session_status = session.get("status", "—") + stopped_reason = session.get("stopped_reason", session.get("status", "—")) + wt_path = _find_worktree_path(Path.cwd(), session) or Path("—") + resumable = session_status in ("running",) or stopped_reason in { + "interrupted", "stalled", "exhausted", "max_iterations_reached", + "max_duration_reached", "max_tokens_reached", "idle_timeout", + } + runtime.add_row("Session", str(session_id)) + runtime.add_row("Branch", str(branch)) + runtime.add_row("Worktree", str(wt_path)) + runtime.add_row("Stopped reason", str(stopped_reason)) + runtime.add_row("Resumable", "yes" if resumable else "no") + runtime.add_row("Iterations", str(session.get("iterations", "—"))) + runtime.add_row("Tokens used", str(session.get("tokens_used", "—"))) + console.print(runtime) + console.print() + + +@main.command() +@click.option( + "--auto", + is_flag=True, + default=False, + help="Automatically merge, push, and clean up the owloop session.", +) +def finish(auto: bool) -> None: + """Show the latest owloop session and optionally merge/push/cleanup.""" + ascii, no_color, _compact, _verbose = _cli_options() + console = Console(no_color=no_color) + project_dir = Path.cwd() + + session = _read_latest_session(project_dir) + if not session: + console.print("[red]Error:[/] No latest session found. Run [bold]owloop run[/] first.") + raise SystemExit(1) + + session_id = session.get("session_id", "—") + branch = session.get("branch", "—") + wt_path = _find_worktree_path(project_dir, session) + stopped_reason = session.get("stopped_reason", session.get("status", "—")) + iterations = session.get("iterations", 0) + + console.print() + console.print(_banner_text(ascii=ascii, no_color=no_color)) + + table = Table( + title="Latest owloop session", + border_style=_brand.AMBER, + show_lines=False, + padding=(0, 2), + ) + table.add_column("Key", style="bold") + table.add_column("Value") + table.add_row("Session", str(session_id)) + table.add_row("Branch", str(branch)) + table.add_row("Worktree", str(wt_path) if wt_path else "—") + table.add_row("Stopped reason", str(stopped_reason)) + table.add_row("Iterations", str(iterations)) + console.print(table) + console.print() + + # Diff summary + cwd = wt_path if wt_path and wt_path.is_dir() and branch.startswith("owloop/") else project_dir + commits = git_stats.get_recent_commits(cwd, iterations) + total_files, total_ins, total_del = git_stats.total_diff_stats(commits) + console.print( + f"Diff: {total_files} files · [green]+{total_ins}[/] · [red]-{total_del}[/]" + ) + for commit in commits: + console.print(f" [cyan]{commit.hash}[/] {commit.message}") + console.print() + + report_path = _default_report_path() + if report_path.exists(): + console.print(f"Report: {report_path}") + else: + console.print("Report: not generated") + console.print() + + # Base branch detection + base_branch = "main" + for candidate in ("main", "master"): + result = subprocess.run( + ["git", "show-ref", "--verify", f"refs/heads/{candidate}"], + cwd=project_dir, + capture_output=True, + ) + if result.returncode == 0: + base_branch = candidate + break + + console.print(f"[{_brand.AMBER}]Next steps:[/]") + console.print(f" review: git -C {project_dir} log --oneline {base_branch}..{branch}") + console.print(f" merge: git -C {project_dir} checkout {base_branch} && git merge {branch}") + console.print(f" push: git -C {project_dir} push origin {base_branch}") + if wt_path: + console.print(f" cleanup: git -C {project_dir} worktree remove {wt_path} && git -C {project_dir} branch -d {branch}") + console.print(" resume: owloop run --resume") + console.print() + + if not auto: + return + + # Auto mode: merge, push, remove worktree, delete branch. Stop on first failure. + console.print(f"[{_brand.AMBER}]Auto-finishing session...[/]") + + def _git_step(desc: str, cwd: Path, *args: str) -> bool: + console.print(f" {desc} ...", end=" ") + result = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True) + if result.returncode != 0: + console.print(f"[red]failed[/]\n{result.stderr.strip()}") + return False + console.print(f"[{_brand.GREEN}]ok[/]") + return True + + # Idempotent merge: skip if already merged. + merge_check = subprocess.run( + ["git", "merge-base", "--is-ancestor", branch, base_branch], + cwd=project_dir, + capture_output=True, + ) + already_merged = merge_check.returncode == 0 + + if not already_merged: + if not _git_step(f"checkout {base_branch}", project_dir, "checkout", base_branch): + raise SystemExit(1) + if not _git_step(f"merge {branch}", project_dir, "merge", "--no-ff", branch, "-m", f"owloop: merge {branch}"): + raise SystemExit(1) + else: + console.print(f" [dim]{branch} already merged into {base_branch}[/]") + + if not _git_step("push", project_dir, "push", "origin", base_branch): + raise SystemExit(1) + + if wt_path and wt_path.exists(): + # Remove worktree if it exists. + subprocess.run(["git", "worktree", "remove", "--force", str(wt_path)], cwd=project_dir, capture_output=True) + if wt_path.exists(): + shutil.rmtree(wt_path, ignore_errors=True) + + # Delete branch if it still exists. + branch_exists = subprocess.run( + ["git", "show-ref", "--verify", f"refs/heads/{branch.split('/', 1)[1]}"], + cwd=project_dir, + capture_output=True, + ).returncode == 0 + if branch_exists: + short_branch = branch.split("/", 1)[1] + if not _git_step(f"delete branch {short_branch}", project_dir, "branch", "-d", short_branch): + raise SystemExit(1) + + console.print(f"[{_brand.GREEN}]✓ Session finished.[/]") + console.print() + + +@main.command() +@click.option("--iter", "iter_n", type=int, default=None, help="Show the log for iteration N.") +@click.option("--events", is_flag=True, default=False, help="Show the last 50 events from events.jsonl.") +@click.option("--patch", is_flag=True, default=False, help="Show the latest discarded patch.") +def logs(iter_n: int | None, events: bool, patch: bool) -> None: + """Inspect owloop log files.""" + ascii, no_color, _compact, _verbose = _cli_options() + console = Console(no_color=no_color) + logs_dir = resolve_logs_dir(Path.cwd()) + + if not logs_dir.exists(): + console.print("[red]Error:[/] No logs directory. Run [bold]owloop run[/] first.") + raise SystemExit(1) + + if patch: + patches = sorted(logs_dir.glob("iter_*_discarded.patch")) + if not patches: + console.print("[dim]No discarded patches found.[/]") + raise SystemExit(0) + latest = patches[-1] + console.print(latest.read_text(encoding="utf-8")) + raise SystemExit(0) + + if events: + events_path = logs_dir / "events.jsonl" + if not events_path.is_file(): + console.print("[red]Error:[/] events.jsonl not found.") + raise SystemExit(1) + lines = events_path.read_text(encoding="utf-8").splitlines() + for line in lines[-50:]: + if not line.strip(): + continue + try: + record = json.loads(line) + ts = record.get("ts", "—") + kind = record.get("kind", "—") + data = record.get("data", {}) + console.print(f"[{ts}] {kind}: {data}") + except json.JSONDecodeError: + console.print(line) + raise SystemExit(0) + + if iter_n is not None: + candidates = [p for p in logs_dir.glob("iter_*.log") if str(iter_n) in p.name] + if not candidates: + console.print(f"[red]Error:[/] No log found for iteration {iter_n}.") + raise SystemExit(1) + target = sorted(candidates)[-1] + console.print(target.read_text(encoding="utf-8")) + raise SystemExit(0) + + # Default: latest iteration log + log_files = sorted(logs_dir.glob("iter_*.log")) + if not log_files: + console.print("[dim]No iteration logs found.[/]") + raise SystemExit(0) + latest = log_files[-1] + console.print(latest.read_text(encoding="utf-8")) + @main.command() def version() -> None: @@ -1374,9 +1922,12 @@ def spec_from_issue( dry_run: bool, ) -> None: """Generate a spec draft from a GitHub issue.""" - _ascii, no_color, _compact, _verbose = _cli_options() + ascii, no_color, _compact, _verbose = _cli_options() console = Console(no_color=no_color) project_dir = Path.cwd() + + _ensure_init(project_dir, console, ascii=ascii) + converter = IssueToSpecConverter(project_dir) try: @@ -1407,6 +1958,24 @@ def spec_from_issue( console.print(f"[{_brand.GREEN}]✓ Spec generated:[/] {spec_path}") console.print() + if sys.stdin.isatty() and not dry_run: + console.print("\n[bold]Run owloop check on this spec?[/] [y/N] ", end="") + try: + reply = input().strip().lower() or "n" + except EOFError: + reply = "n" + if reply.startswith("y"): + subprocess.run([sys.argv[0], "check"], cwd=project_dir) + return + + console.print("\n[bold]Start owloop run now?[/] [y/N] ", end="") + try: + reply = input().strip().lower() or "n" + except EOFError: + reply = "n" + if reply.startswith("y"): + subprocess.run([sys.argv[0], "run"], cwd=project_dir) + if __name__ == "__main__": main() diff --git a/src/owloop/config.py b/src/owloop/config.py new file mode 100644 index 0000000..35e8fb1 --- /dev/null +++ b/src/owloop/config.py @@ -0,0 +1,181 @@ +"""Persistent run configuration loader for owloop.""" + +from __future__ import annotations + +import sys +import warnings +from pathlib import Path +from typing import Any + +if sys.version_info >= (3, 11): + import tomllib +else: + try: + import tomli as tomllib + except ImportError: + tomllib = None # type: ignore[assignment, misc] + + +_RUN_CONFIG_TYPES: dict[str, type[Any] | tuple[type[Any], ...]] = { + "model": str, + "notify_desktop": bool, + "converge": int, + "workers": int, + "rollback": bool, + "max_iterations": int, + "max_duration": int, + "max_tokens": int, + "idle_timeout": (int, float), + "max_tokens_per_iteration": int, + "max_turns_per_iteration": int, + "keep_retrying": bool, + "notify_webhook": str, + "no_tui": bool, + "dry_run": 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"} +) + +# Numeric CLI options use 0 as the "not set" sentinel. +_NUMERIC_KEYS: frozenset[str] = frozenset( + { + "converge", + "workers", + "max_iterations", + "max_duration", + "max_tokens", + "idle_timeout", + "max_tokens_per_iteration", + "max_turns_per_iteration", + } +) + + +def _type_name(expected: type[Any] | tuple[type[Any], ...]) -> str: + """Return a human-readable name for an expected type or type union.""" + if isinstance(expected, tuple): + return " | ".join(t.__name__ for t in expected) + return expected.__name__ + + +def _is_valid_type(value: Any, expected: type[Any] | tuple[type[Any], ...]) -> bool: + """Check whether ``value`` has exactly the expected type(s). + + ``bool`` is not accepted where ``int`` is expected, because TOML booleans + and integers are distinct types. + """ + if isinstance(expected, tuple): + return type(value) in expected + return type(value) is expected + + +def load_run_config(path: Path) -> dict[str, Any]: + """Load the persistent ``[run]`` configuration from ``path``. + + Args: + path: Path to the TOML configuration file. + + Returns: + A flattened dictionary containing only the validated keys from the + ``[run]`` section. Returns an empty dictionary when the file is missing, + the TOML parser is unavailable, parsing fails, or the ``[run]`` section + is invalid. + """ + if not path.exists(): + return {} + + if tomllib is None: + warnings.warn( + "TOML parser not available. Install `tomli` for Python <3.11. " + f"Skipping configuration file: {path}", + RuntimeWarning, + stacklevel=2, + ) + return {} + + try: + with path.open("rb") as file: + data: dict[str, Any] = tomllib.load(file) + except Exception as exc: # noqa: BLE001 + warnings.warn( + f"Failed to parse {path}: {exc}", + RuntimeWarning, + stacklevel=2, + ) + return {} + + run_config = data.get("run", {}) + if not isinstance(run_config, dict): + warnings.warn( + f"Invalid `[run]` section in {path}: expected a table.", + RuntimeWarning, + stacklevel=2, + ) + return {} + + unknown_keys = [key for key in run_config if key not in _RUN_CONFIG_TYPES] + if unknown_keys: + warnings.warn( + f"Unknown keys in `[run]` section of {path}: {unknown_keys}", + RuntimeWarning, + stacklevel=2, + ) + + result: dict[str, Any] = {} + for key, value in run_config.items(): + if key not in _RUN_CONFIG_TYPES: + continue + expected = _RUN_CONFIG_TYPES[key] + if not _is_valid_type(value, expected): + warnings.warn( + f"Invalid type for `[run].{key}` in {path}: " + f"expected {_type_name(expected)}, got {type(value).__name__}", + RuntimeWarning, + stacklevel=2, + ) + continue + result[key] = value + + return result + + +def apply_config_defaults( + cli_kwargs: dict[str, Any], config: dict[str, Any] +) -> dict[str, Any]: + """Merge persistent config defaults into CLI kwargs. + + Only ``falsy-but-not-explicitly-set`` CLI values are replaced by config + values, so explicitly provided CLI arguments always win. + + Args: + cli_kwargs: Arguments received from the command line. + config: Validated persistent configuration from :func:`load_run_config`. + + Returns: + A new dictionary with config defaults applied. + """ + merged = dict(cli_kwargs) + + for key, config_value in config.items(): + if key not in _RUN_CONFIG_TYPES: + continue + + cli_value = merged.get(key) + + if key in {"model", "notify_webhook"}: + if cli_value is None: + merged[key] = config_value + elif key in _BOOL_FLAG_KEYS: + if cli_value is False: + merged[key] = config_value + elif key in _NUMERIC_KEYS: + if cli_value in (0, None): + merged[key] = config_value + else: + if cli_value in (None, 0, False, ""): + merged[key] = config_value + + return merged diff --git a/src/owloop/engine.py b/src/owloop/engine.py index 0be4aac..8039e34 100644 --- a/src/owloop/engine.py +++ b/src/owloop/engine.py @@ -23,7 +23,6 @@ import re import shutil import subprocess -import sys import time import uuid from collections.abc import Callable @@ -194,6 +193,35 @@ def classify_terminal_state(stopped_reason: str) -> TerminalState: return _STOPPED_REASON_TO_STATE.get(reason, TerminalState.FAILED) +_SPEC_TITLE_RE = re.compile(r"^#\s*Spec:\s*(.+)$", re.MULTILINE) + + +def _extract_spec_title(spec_path: Path | None) -> str | None: + """Return the ``# Spec: `` line from a spec file, if present.""" + if spec_path is None or not spec_path.is_file(): + return None + try: + text = spec_path.read_text(encoding="utf-8") + except OSError: + return None + match = _SPEC_TITLE_RE.search(text) + return match.group(1).strip() if match else None + + +def spec_commit_subject(spec_name: str | None, spec_path: Path | None, iteration: int = 0) -> str: + """Build a work-centric commit subject for an iteration. + + Uses the spec's ``# Spec: <title>`` when available, otherwise falls back + to a tool-centric message for backward compatibility. + """ + title = _extract_spec_title(spec_path) if spec_path else None + if title: + return title + if spec_name: + return f"owloop: complete {spec_name}" + return f"owloop: iteration {iteration}" + + _ERROR_HASH_RE = re.compile(r"\d+") @@ -629,8 +657,8 @@ def _init_session(self) -> None: Runs before ``setup_worktree`` so session identity and budget carry-over apply whether or not worktree isolation is enabled. - Never raises — ``_resolve_worktree_session`` remains the place that - surfaces a "nothing to resume" error when worktree isolation is on. + When ``resume`` is True but no previous session exists, raises a clear + error instead of silently starting a fresh session. """ if not self.config.resume: self.session_id = self.config.session_id or uuid.uuid4().hex[:8] @@ -651,10 +679,10 @@ def _init_session(self) -> None: self.resumed_from_session = self.session_id return - # Nothing to resume: fall back to a fresh session id so the run still - # has one. If worktree isolation is enabled, setup_worktree() will - # raise its own "no previous session found" error shortly after this. - self.session_id = self.config.session_id or uuid.uuid4().hex[:8] + raise RuntimeError( + "--resume requested but no previous owloop session found. " + "Run `owloop run` without --resume to start a new session." + ) def _persist_session_progress( self, *, status: str, iterations: int, current_spec: str | None @@ -702,6 +730,28 @@ def _latest_owloop_branch(self) -> str | None: branches = [b for b in result.stdout.splitlines() if b.strip()] return branches[0] if branches else None + def _session_slug(self) -> str: + """Derive a short URL-safe slug from the first incomplete spec title. + + Falls back to an empty slug when no specs are readable. The slug is + sanitized to ``[a-z0-9-]`` and capped at ~30 characters so branch and + worktree paths stay readable and filesystem-safe. + """ + specs = spec_queue.get_root_specs(self.specs_dir) + incomplete = spec_queue.get_incomplete_root_specs(self.specs_dir) + target = incomplete[0] if incomplete else (specs[0] if specs else None) + title = _extract_spec_title(target) if target else None + if not title: + return "" + + slug = title.lower() + # Drop common stop words and file extension noise. + slug = re.sub(r"[^a-z0-9]+", "-", slug).strip("-") + slug = re.sub(r"-+", "-", slug) + if len(slug) > 30: + slug = slug[:30].rsplit("-", 1)[0] + return slug + def _resolve_worktree_session(self) -> tuple[str, str, Path]: """Pick or resume a session id and derive branch/worktree path. @@ -718,8 +768,8 @@ def _resolve_worktree_session(self) -> tuple[str, str, Path]: raise RuntimeError( "--resume requested but no previous owloop session found." ) - # Branch format: owloop/<date>-<session_id> - session_id = branch.split("/", 1)[1].split("-", 1)[1] + # Branch format: owloop/<date>-<session_id> or owloop/<date>-<slug>-<session_id> + session_id = branch.split("/", 1)[1].rsplit("-", 1)[1] wt_path = wt_base / f"owloop-{branch.split('/', 1)[1]}" else: session_id = session["session_id"] @@ -731,14 +781,16 @@ def _resolve_worktree_session(self) -> tuple[str, str, Path]: # Reuse the id ``_init_session`` already resolved (if any) so this run # has a single consistent session id, instead of minting a second one. session_id = self.session_id or self.config.session_id or uuid.uuid4().hex[:8] - branch = f"owloop/{wt_date}-{session_id}" - wt_path = wt_base / f"owloop-{wt_date}-{session_id}" + slug = self._session_slug() + slug_part = f"{slug}-" if slug else "" + branch = f"owloop/{wt_date}-{slug_part}{session_id}" + wt_path = wt_base / f"owloop-{wt_date}-{slug_part}{session_id}" # Guard against an extremely unlikely collision. while self._branch_exists(branch): session_id = uuid.uuid4().hex[:8] - branch = f"owloop/{wt_date}-{session_id}" - wt_path = wt_base / f"owloop-{wt_date}-{session_id}" + branch = f"owloop/{wt_date}-{slug_part}{session_id}" + wt_path = wt_base / f"owloop-{wt_date}-{slug_part}{session_id}" self._save_session(session_id, branch, wt_path) self.session_id = session_id @@ -747,8 +799,9 @@ def _resolve_worktree_session(self) -> tuple[str, str, Path]: def setup_worktree(self) -> bool: """Create/enter an isolated git worktree unless disabled or already inside one. - Returns False if the run should abort entirely (user declined to - continue against a dirty main-repo workspace). + Worktree isolation is a core safety feature, so it is created silently + when enabled. A dirty main-repo workspace is harmless because the + worktree already isolates it; we warn and continue instead of blocking. """ if not self.config.worktree: self._emit("worktree_skipped", reason="disabled") @@ -764,42 +817,7 @@ def setup_worktree(self) -> bool: if self._is_dirty(): self._emit("dirty_workspace_warning") - if self.config.confirm_dirty is not None: - if not self.config.confirm_dirty(): - self._emit("dirty_workspace_declined") - return False - elif sys.stdin.isatty(): - try: - reply = input("> ").strip().lower() or "n" - except EOFError: - reply = "n" - if not reply.startswith("y"): - self._emit("dirty_workspace_declined") - return False - else: - self._emit("dirty_workspace_noninteractive_continue") - - if self.config.confirm_worktree is not None: - self._emit("worktree_prompt") - if not self.config.confirm_worktree(): - self._emit("worktree_declined") - return True - elif sys.stdin.isatty(): - self._emit("worktree_prompt") - try: - reply = input("> ").strip() or "Y" - except EOFError: - reply = "Y" - - if not reply.lower().startswith("y"): - self._emit("worktree_declined") - return True - else: - # Headless/CI/agent invocation: there's no one to answer the prompt, and - # skipping isolation here is the one outcome that defeats the whole point - # of an *unattended* loop. Default to creating the worktree rather than - # falling through to the (possibly dirty) main repo. - self._emit("worktree_auto_created", reason="non_interactive") + self._emit("dirty_workspace_noninteractive_continue") session_id, wt_branch, wt_path = self._resolve_worktree_session() self.session_id = session_id @@ -1066,17 +1084,15 @@ def _head(self) -> str: def _commit_iteration(self, iteration: int, spec_name: str | None) -> bool: """Stage and commit the verified work. Engine-owned, gate-gated. - Loop metadata (``.owloop/run-notes.md``) is unstaged before commit so - it can never enter project history even on repos that predate the - ``.gitignore`` entry. + All of ``.owloop/`` is unstaged before commit so loop metadata never + enters project history, even on repos that predate the ``.gitignore`` + entry. Working-tree changes (e.g. **Status**: COMPLETE markers) are + preserved. """ self._run_git("add", "-A") - self._run_git( - "reset", "--quiet", "--", - ".owloop/run-notes.md", ".owloop/logs", ".owloop/PROMPT_build.md", - ".owloop/last-failure.md", - ) - subject = f"owloop: complete {spec_name}" if spec_name else f"owloop: iteration {iteration}" + self._run_git("reset", "--quiet", "--", ".owloop/") + spec_path = self.specs_dir / spec_name if spec_name else None + subject = spec_commit_subject(spec_name, spec_path, iteration) committed = self._run_git("commit", "-m", subject) if committed.returncode == 0: self._emit("iteration_committed", iteration=iteration, spec=spec_name, subject=subject) @@ -1393,17 +1409,7 @@ def run(self) -> RunSummary: ) self._init_session() - - if not self.setup_worktree(): - return RunSummary( - iterations=0, - branch=self._current_branch(), - cwd=self.cwd, - main_repo_dir=self.main_repo_dir, - stopped_reason=StopReason.DIRTY_WORKSPACE_DECLINED, - session_id=self.session_id or "", - resumed_from_session=self.resumed_from_session, - ) + self.setup_worktree() self.log_dir.mkdir(parents=True, exist_ok=True) self.session_log = self.log_dir / f"owloop_build_session_{_file_timestamp()}.log" diff --git a/src/owloop/parallel.py b/src/owloop/parallel.py index ae71cc3..cd95f0f 100644 --- a/src/owloop/parallel.py +++ b/src/owloop/parallel.py @@ -28,7 +28,7 @@ from owloop import notifications, spec_queue, verification from owloop.adapters import AgentAdapter -from owloop.engine import RunSummary, StopReason, classify_terminal_state +from owloop.engine import RunSummary, StopReason, classify_terminal_state, spec_commit_subject from owloop.paths import resolve_owloop_dir, resolve_specs_dir from owloop.promise import parse_promise_signal @@ -151,7 +151,7 @@ def _run_worker(self, spec_name: str, base_head: str, index: int) -> WorkerResul if source.is_dir(): shutil.copytree(source, wt_path / ".owloop", dirs_exist_ok=True) - self._emit("worker_start", spec=spec_name, worker=index, branch=branch) + self._emit("worker_start", spec_name=spec_name, worker_id=index, branch=branch) wt_specs = resolve_specs_dir(wt_path) guard_before = verification.guarded_hash(wt_path, wt_specs, spec_name) @@ -167,17 +167,17 @@ def _run_worker(self, spec_name: str, base_head: str, index: int) -> WorkerResul state = parsed[0] if parsed else "" if state != "DONE": - self._emit("worker_no_done", spec=spec_name, worker=index) + self._emit("worker_no_done", spec_name=spec_name, worker_id=index) return WorkerResult(spec_name, False, branch, wt_path, tokens_used=tokens, reason="no_done_signal") gate = verification.run_gate(wt_path, wt_specs, spec_name, guard_before) if gate.tampered: - self._emit("spec_tampered", spec=spec_name, worker=index) + self._emit("spec_tampered", spec_name=spec_name, worker_id=index) return WorkerResult(spec_name, False, branch, wt_path, tampered=True, tokens_used=tokens, reason="tampered") if not gate.passed: - self._emit("verification_gate_failed", spec=spec_name, worker=index, + self._emit("verification_gate_failed", spec_name=spec_name, worker_id=index, failed=gate.failed_count) return WorkerResult(spec_name, False, branch, wt_path, tokens_used=tokens, reason="verification_failed") @@ -185,10 +185,11 @@ def _run_worker(self, spec_name: str, base_head: str, index: int) -> WorkerResul # Verified: mark complete + commit inside the worker's worktree. spec_queue.mark_spec_complete(wt_specs / spec_name) self._git("add", "-A", cwd=wt_path) - self._git("reset", "--quiet", "--", ".owloop/run-notes.md", ".owloop/logs", - ".owloop/PROMPT_build.md", cwd=wt_path) - self._git("commit", "-m", f"owloop: complete {spec_name}", cwd=wt_path) - self._emit("verification_gate_passed", spec=spec_name, worker=index, + # Unstage all owloop-internal metadata so it never enters project history. + self._git("reset", "--quiet", "--", ".owloop/", cwd=wt_path) + subject = spec_commit_subject(spec_name, wt_specs / spec_name) + self._git("commit", "-m", subject, cwd=wt_path) + self._emit("verification_gate_passed", spec_name=spec_name, worker_id=index, passed=gate.passed_count) return WorkerResult(spec_name, True, branch, wt_path, tokens_used=tokens) @@ -281,11 +282,15 @@ def _merge_passed(self, passed: list[WorkerResult], base_branch: str) -> None: merged = self._git("merge", "--no-ff", "-m", f"owloop: merge {r.spec_name}", r.branch) if merged.returncode == 0: - self._emit("worker_merged", spec=r.spec_name, branch=r.branch) + # The worker branch intentionally does not include .owloop/ + # metadata, so mark the spec complete in the base repo so the + # queue sees progress and we don't loop forever. + spec_queue.mark_spec_complete(self.specs_dir / r.spec_name) + self._emit("worker_merged", spec_name=r.spec_name, branch=r.branch) else: # Should not happen for disjoint scopes; abort and surface it. self._git("merge", "--abort") - self._emit("worker_merge_conflict", spec=r.spec_name, branch=r.branch) + self._emit("worker_merge_conflict", spec_name=r.spec_name, branch=r.branch) def _summary( self, stopped_reason: StopReason, *, rounds: int, branch: str, diff --git a/src/owloop/reporter.py b/src/owloop/reporter.py index ab614c4..f46f0f7 100644 --- a/src/owloop/reporter.py +++ b/src/owloop/reporter.py @@ -148,6 +148,28 @@ def on_event(self, kind: str, data: dict) -> None: c.print(f"[{_brand.AMBER}]{self._mark('warn')} push failed, creating remote branch {data['branch']}...[/]") elif kind == "interrupted": c.print("\n[dim]owloop stopped[/]") + elif kind == "parallel_session_info": + c.print(f"[{_brand.CYAN}]{self._mark('info')} parallel session: {data.get('workers', 0)} workers[/]") + elif kind == "worker_start": + worker = data.get("worker_id", "?") + spec = data.get("spec_name", "") + c.print(f"[{_brand.CYAN}]{self._mark('info')} worker {worker} started{f' on {spec}' if spec else ''}[/]") + elif kind == "round_start": + c.print(f"[{_brand.CYAN}]{self._mark('info')} parallel round {data.get('round', '?')} started[/]") + elif kind == "round_end": + c.print(f"[{_brand.CYAN}]{self._mark('info')} parallel round {data.get('round', '?')} ended[/]") + elif kind == "worker_merged": + worker = data.get("worker_id", "?") + spec = data.get("spec_name", "") + c.print(f"[{_brand.GREEN}]{self._mark('ok')} worker {worker} merged{f' ({spec})' if spec else ''}[/]") + elif kind == "worker_merge_conflict": + worker = data.get("worker_id", "?") + spec = data.get("spec_name", "") + c.print(f"[{_brand.RED}]{self._mark('fail')} worker {worker} merge conflict{f' ({spec})' if spec else ''}[/]") + elif kind == "worker_no_done": + worker = data.get("worker_id", "?") + spec = data.get("spec_name", "") + c.print(f"[{_brand.AMBER}]{self._mark('warn')} worker {worker} finished without done signal{f' ({spec})' if spec else ''}[/]") FAILED_REASONS = {"preflight_failed", "dirty_workspace_declined"} @@ -169,7 +191,7 @@ def _review_commands(self, iterations: int) -> list[str]: f"git diff --stat HEAD~{iterations}..HEAD", ] - def print_summary(self, summary: RunSummary) -> None: + def print_summary(self, summary: RunSummary, report_path: str | None = None) -> None: c = self.console failed = summary.stopped_reason in self.FAILED_REASONS @@ -219,6 +241,8 @@ def print_summary(self, summary: RunSummary) -> None: iterations=summary.iterations, cwd=str(summary.cwd), main_repo_dir=str(summary.main_repo_dir), + stopped_reason=summary.stopped_reason, + report_path=report_path, ) sections: list = [Align.center(facts)] @@ -263,14 +287,30 @@ def print_summary(self, summary: RunSummary) -> None: return # Headline honors the terminal state so "complete" is reserved for a - # genuine success — an exhausted or stalled run gets its own wording. - incomplete_states = {TerminalState.EXHAUSTED, TerminalState.STALLED} - if summary.state in incomplete_states: - headline = Text(f"{self._mark('warn')} owloop stopped without finishing", style=f"bold {_brand.RED}") + # genuine success. + if summary.state in (TerminalState.FAILED, TerminalState.TAMPERED, TerminalState.EXHAUSTED, TerminalState.STALLED): border = _brand.RED + if summary.state == TerminalState.FAILED: + headline_text = f"{self._mark('fail')} owloop failed" + elif summary.state == TerminalState.TAMPERED: + headline_text = f"{self._mark('warn')} owloop stopped — spec tampering detected" + elif summary.state == TerminalState.EXHAUSTED: + headline_text = f"{self._mark('warn')} owloop stopped — budget exhausted, work not finished" + else: + headline_text = f"{self._mark('warn')} owloop stalled — no progress" + headline = Text(headline_text, style=f"bold {_brand.RED}") + elif summary.state in (TerminalState.BLOCKED, TerminalState.DECIDE, TerminalState.INTERRUPTED): + border = _brand.AMBER + if summary.state == TerminalState.BLOCKED: + headline_text = f"{self._mark('warn')} owloop blocked — external blocker" + elif summary.state == TerminalState.DECIDE: + headline_text = f"{self._mark('warn')} owloop paused — decision needed" + else: + headline_text = f"{self._mark('warn')} owloop interrupted — resume with --resume" + headline = Text(headline_text, style=f"bold {_brand.AMBER}") else: - headline = Text(self._status("complete"), style=f"bold {_brand.AMBER}") border = _brand.AMBER + headline = Text(self._status("complete"), style=f"bold {_brand.AMBER}") owl.stylize(f"dim {_brand.AMBER}") body = Group(owl, Align.center(headline), *sections) c.print(Panel(body, border_style=border, padding=(1, 4), width=64)) diff --git a/src/owloop/tui.py b/src/owloop/tui.py index 7237bd1..e9424b8 100644 --- a/src/owloop/tui.py +++ b/src/owloop/tui.py @@ -29,6 +29,7 @@ from rich.table import Table from rich.text import Text +from owloop import git_stats from owloop._brand import ( AMBER, BRAND_BAR, @@ -350,6 +351,52 @@ def _handle(self, kind: str, data: dict) -> None: s.specs = data["specs"] s.phase = "working" s.current_action = "Preparing next iteration" + elif kind == "stalled": + s.phase = "stuck" + self._log(f"stalled: {data.get('reason', '')} ({data.get('failure_reason', '')})") + elif kind in ("tampered", "spec_tampered"): + s.phase = "error" + self._log("spec tampering detected (acceptance criteria / backpressure changed mid-iteration)") + elif kind == "verification_gate_passed": + self._log(f"verification gate passed ({data.get('passed', 0)} checks)") + elif kind == "verification_gate_failed": + s.phase = "stuck" + self._log(f"verification gate failed ({data.get('failed', 0)} of {data.get('passed', 0) + data.get('failed', 0)} checks)") + elif kind == "iteration_rolled_back": + self._log(f"rolled back to {data.get('to_commit')}") + elif kind == "iteration_exhausted": + self._log("iteration hit its native turn/token limit") + elif kind == "failure_feedback_written": + self._log("failure feedback written") + elif kind == "converge_sweep_start": + self._log(f"convergence sweep {data.get('sweep', data.get('n', 1))} starting") + elif kind == "converge_gap_specs": + self._log(f"found {data.get('count', 0)} gap spec(s)") + elif kind == "converged": + s.phase = "complete" + self._log(f"converged after {data.get('sweeps', 1)} sweep(s)") + elif kind == "parallel_session_info": + self._log(f"parallel session: {data.get('workers', 0)} workers") + elif kind == "worker_start": + worker = data.get("worker_id", "?") + spec = data.get("spec_name", "") + self._log(f"worker {worker} started{f' on {spec}' if spec else ''}") + elif kind == "round_start": + self._log(f"parallel round {data.get('round', '?')} started") + elif kind == "round_end": + self._log(f"parallel round {data.get('round', '?')} ended") + elif kind == "worker_merged": + worker = data.get("worker_id", "?") + spec = data.get("spec_name", "") + self._log(f"worker {worker} merged{f' ({spec})' if spec else ''}") + elif kind == "worker_merge_conflict": + worker = data.get("worker_id", "?") + spec = data.get("spec_name", "") + self._log(f"worker {worker} merge conflict{f' ({spec})' if spec else ''}") + elif kind == "worker_no_done": + worker = data.get("worker_id", "?") + spec = data.get("spec_name", "") + self._log(f"worker {worker} finished without done signal{f' ({spec})' if spec else ''}") elif kind == "interrupted": s.phase = "error" self._log("stopped (Ctrl+C)") @@ -613,7 +660,7 @@ def _render(self) -> None: FAILED_REASONS = {"preflight_failed", "dirty_workspace_declined"} - def print_exit_summary(self, summary: RunSummary) -> None: + def print_exit_summary(self, summary: RunSummary, report_path: str | None = None) -> None: s = self.state failed = summary.stopped_reason in self.FAILED_REASONS @@ -629,6 +676,9 @@ def print_exit_summary(self, summary: RunSummary) -> None: elapsed = time.monotonic() - s.start_time owl = Text("\n".join(OWL_SLEEP), style=f"dim {AMBER}", justify="center") + commits = git_stats.get_recent_commits(summary.cwd, summary.iterations) + total_files, total_ins, total_del = git_stats.total_diff_stats(commits) + facts = Table.grid(padding=(0, 2)) facts.add_column(style=f"dim {GRAY}", justify="right") facts.add_column(style=MOON_WHITE) @@ -642,6 +692,7 @@ def print_exit_summary(self, summary: RunSummary) -> None: facts.add_row("Blocker", summary.blocker) if summary.decision_question: facts.add_row("Decision", summary.decision_question) + facts.add_row("Diff", f"{total_files} files · +{total_ins} · -{total_del}") if summary.tokens_used: facts.add_row("Tokens", f"{summary.tokens_used:,}") if summary.estimated_cost_usd: @@ -650,20 +701,38 @@ def print_exit_summary(self, summary: RunSummary) -> None: hints_lines = [ Text(line, style=f"dim {GRAY}") - for line in exit_hints(summary.branch, summary.iterations, summary.cwd, summary.main_repo_dir) + for line in exit_hints( + summary.branch, + summary.iterations, + summary.cwd, + summary.main_repo_dir, + stopped_reason=summary.stopped_reason, + report_path=report_path, + ) ] - # `exhausted`/`stalled` are not successes — the headline and border must - # not read as a clean "complete". - if summary.state == TerminalState.EXHAUSTED: - headline = Text("⚠ owloop stopped — budget exhausted, work not finished", style=f"bold {RED}") - border = RED - elif summary.state == TerminalState.STALLED: - headline = Text("⚠ owloop stalled — no progress", style=f"bold {RED}") + # Classify terminal states so the headline/border match the outcome. + if summary.state in (TerminalState.FAILED, TerminalState.TAMPERED, TerminalState.EXHAUSTED, TerminalState.STALLED): border = RED + if summary.state == TerminalState.FAILED: + headline = Text("✗ owloop failed", style=f"bold {RED}") + elif summary.state == TerminalState.TAMPERED: + headline = Text("⚠ owloop stopped — spec tampering detected", style=f"bold {RED}") + elif summary.state == TerminalState.EXHAUSTED: + headline = Text("⚠ owloop stopped — budget exhausted, work not finished", style=f"bold {RED}") + else: + headline = Text("⚠ owloop stalled — no progress", style=f"bold {RED}") + elif summary.state in (TerminalState.BLOCKED, TerminalState.DECIDE, TerminalState.INTERRUPTED): + border = AMBER + if summary.state == TerminalState.BLOCKED: + headline = Text("⚠ owloop blocked — external blocker", style=f"bold {AMBER}") + elif summary.state == TerminalState.DECIDE: + headline = Text("⚠ owloop paused — decision needed", style=f"bold {AMBER}") + else: + headline = Text("⚠ owloop interrupted — resume with --resume", style=f"bold {AMBER}") else: - headline = Text("🌅 owloop complete", style=f"bold {AMBER}") border = AMBER + headline = Text("🌅 owloop complete", style=f"bold {AMBER}") body = Group( owl, diff --git a/tests/test_brand.py b/tests/test_brand.py index 28bac76..1090f9b 100644 --- a/tests/test_brand.py +++ b/tests/test_brand.py @@ -81,9 +81,44 @@ def test_exit_hints_in_main_repo(): def test_exit_hints_in_worktree(): hints = exit_hints( - branch="feat/x", iterations=3, cwd="/repo/.worktrees/feat-x", main_repo_dir="/repo" + branch="feat/x", + iterations=3, + cwd="/repo/.worktrees/feat-x", + main_repo_dir="/repo", + stopped_reason="max_iterations", ) - assert len(hints) == 3 + assert len(hints) == 5 assert "git log --oneline HEAD~3..HEAD" in hints[0] - assert "cd /repo && git merge feat/x" in hints[1] - assert "git worktree remove /repo/.worktrees/feat-x" in hints[2] + assert "cd /repo && git checkout main && git merge feat/x" in hints[1] + assert "git push origin main" in hints[2] + assert "git worktree remove /repo/.worktrees/feat-x && git branch -d feat/x" in hints[3] + assert "owloop run --resume" in hints[4] + + +def test_exit_hints_in_worktree_success_omits_resume(): + hints = exit_hints( + branch="feat/x", + iterations=3, + cwd="/repo/.worktrees/feat-x", + main_repo_dir="/repo", + stopped_reason="success", + ) + assert "owloop run --resume" not in hints + + +def test_exit_hints_with_report_path(): + hints = exit_hints( + branch="feat/x", + iterations=3, + cwd="/repo/.worktrees/feat-x", + main_repo_dir="/repo", + report_path="report.md", + ) + assert hints[0] == "Report: report.md" + + +def test_exit_hints_zero_iterations(): + hints = exit_hints( + branch="feat/x", iterations=0, cwd="/repo/.worktrees/feat-x", main_repo_dir="/repo" + ) + assert any("git log --oneline -1" in line for line in hints) diff --git a/tests/test_cli.py b/tests/test_cli.py index 413aa42..e937073 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -210,7 +210,7 @@ def test_run_engine_no_tui_bypasses_tui_and_uses_console_reporter(): branch="main", cwd=Path("."), main_repo_dir=Path("."), - stopped_reason="max_iterations_reached", + stopped_reason="success", ) fake_engine = MagicMock() fake_engine.run.return_value = summary @@ -229,41 +229,6 @@ def test_run_engine_no_tui_bypasses_tui_and_uses_console_reporter(): mock_engine_cls.assert_called_once() -def test_run_engine_no_tui_preserves_confirm_prompts(): - from pathlib import Path - from unittest.mock import MagicMock, patch - - from owloop.cli import _run_engine - from owloop.engine import RunSummary - - captured_config = {} - - def _fake_engine_ctor(config, adapter, on_event=None): - captured_config["config"] = config - engine = MagicMock() - engine.run.return_value = RunSummary( - iterations=0, branch="main", cwd=Path("."), main_repo_dir=Path("."), - stopped_reason="max_iterations_reached", - ) - return engine - - with ( - patch("owloop.cli.get_adapter"), - patch("owloop.cli.OwloopEngine", side_effect=_fake_engine_ctor), - patch("owloop.cli.ConsoleReporter"), - patch("sys.stdout.isatty", return_value=True), - patch("owloop.cli.Confirm.ask", return_value=True) as mock_confirm, - ): - _run_engine(0, True, "claude-model", "claude", no_tui=True) - - config = captured_config["config"] - assert config.confirm_dirty is not None - assert config.confirm_worktree is not None - assert config.confirm_dirty() is True - assert config.confirm_worktree() is True - assert mock_confirm.call_count == 2 - - def test_print_dry_run_report_shows_pass_fail_counts(): import io from pathlib import Path @@ -399,6 +364,21 @@ def test_go_help_exposes_run_options(): "--max-tokens", "--worktree", "--no-worktree", + "--resume", + "--rollback", + "--no-rollback", + "--notify-webhook", + "--notify-desktop", + "--converge", + "--workers", + "--max-tokens-per-iteration", + "--max-turns-per-iteration", + "--max-budget-usd", + "--keep-retrying", + "--dry-run", + "--one-shot", + "--no-tui", + "--plain", ): assert option in result.output, f"{option} should appear in owloop go --help" @@ -414,7 +394,6 @@ def test_go_forwards_options_to_engine_runner(): with ( patch.object(_NamedTextIOWrapper, "isatty", return_value=True), patch("owloop.cli._run_engine") as mock_engine, - patch("owloop.cli.Confirm.ask", return_value=True), patch("owloop.cli.SpecGenerator") as mock_gen, patch("owloop.cli.get_adapter") as mock_adapter, ): @@ -434,13 +413,14 @@ def test_go_forwards_options_to_engine_runner(): assert result.exit_code == 0, result.output mock_engine.assert_called_once() args, kwargs = mock_engine.call_args - assert args[0] == 0 - assert args[1] is False - assert args[2] == "test-model" - assert args[3] == "kimi" - assert args[4] == 120.0 - assert args[5] == 30 - assert args[6] == 10000 + assert args == () + assert kwargs.get("max_iterations") == 0 + assert kwargs.get("worktree") is False + assert kwargs.get("model") == "test-model" + assert kwargs.get("agent") == "kimi" + assert kwargs.get("idle_timeout") == 120.0 + assert kwargs.get("max_duration") == 30 + assert kwargs.get("max_tokens") == 10000 assert kwargs.get("verifier_model") == "v-model" assert kwargs.get("subagents") is True assert kwargs.get("ascii") is False diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..1bc9f73 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,310 @@ +"""Tests for owloop configuration loading.""" + +from __future__ import annotations + +import sys +import warnings +from pathlib import Path +from typing import Any + +import pytest + +from owloop.config import apply_config_defaults, load_run_config + + +@pytest.fixture +def config_path(tmp_path: Path) -> Path: + """Return a temporary path for a config file.""" + return tmp_path / ".owloop" / "config.toml" + + +def write_config(path: Path, content: str) -> None: + """Write ``content`` to ``path``, creating parent directories.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +class TestLoadRunConfig: + """Tests for :func:`load_run_config`.""" + + def test_missing_file_returns_empty_config(self, config_path: Path) -> None: + """A missing config file yields an empty dictionary.""" + result = load_run_config(config_path) + assert result == {} + + def test_valid_run_section_loads(self, config_path: Path) -> None: + """All supported ``[run]`` keys are loaded with correct types.""" + write_config( + config_path, + """ +[run] +model = "gpt-4o" +notify_desktop = true +converge = 3 +workers = 2 +rollback = false +max_iterations = 10 +max_duration = 300 +max_tokens = 100000 +idle_timeout = 1.5 +max_tokens_per_iteration = 4000 +max_turns_per_iteration = 5 +keep_retrying = true +notify_webhook = "https://example.com/hook" +no_tui = true +dry_run = true +""", + ) + result = load_run_config(config_path) + assert result == { + "model": "gpt-4o", + "notify_desktop": True, + "converge": 3, + "workers": 2, + "rollback": False, + "max_iterations": 10, + "max_duration": 300, + "max_tokens": 100000, + "idle_timeout": 1.5, + "max_tokens_per_iteration": 4000, + "max_turns_per_iteration": 5, + "keep_retrying": True, + "notify_webhook": "https://example.com/hook", + "no_tui": True, + "dry_run": True, + } + + def test_other_sections_ignored(self, config_path: Path) -> None: + """Sections other than ``[run]`` are not returned.""" + write_config( + config_path, + """ +[other] +model = "ignored" + +[run] +model = "claude-sonnet-4" +""", + ) + result = load_run_config(config_path) + assert result == {"model": "claude-sonnet-4"} + + def test_unknown_keys_warn_but_are_ignored(self, config_path: Path) -> None: + """Unknown keys under ``[run]`` trigger a warning and are skipped.""" + write_config( + config_path, + """ +[run] +model = "gpt-4o" +unknown_option = 123 +another_bad_key = "value" +""", + ) + with pytest.warns(RuntimeWarning, match="Unknown keys") as warning_info: + result = load_run_config(config_path) + + assert result == {"model": "gpt-4o"} + warning_message = str(warning_info[0].message) + assert "unknown_option" in warning_message + assert "another_bad_key" in warning_message + + def test_wrong_type_warns_and_skips_key(self, config_path: Path) -> None: + """Keys with wrong types trigger a warning and are not loaded.""" + write_config( + config_path, + """ +[run] +model = "gpt-4o" +converge = "not-a-number" +notify_desktop = "yes" +""", + ) + with warnings.catch_warnings(record=True) as warning_list: + warnings.simplefilter("always") + result = load_run_config(config_path) + + assert result == {"model": "gpt-4o"} + messages = [str(w.message) for w in warning_list] + assert any("converge" in m and "expected int" in m for m in messages) + assert any("notify_desktop" in m and "expected bool" in m for m in messages) + + def test_bool_not_accepted_as_int(self, config_path: Path) -> None: + """A boolean must not be accepted where an integer is expected.""" + write_config( + config_path, + """ +[run] +converge = true +""", + ) + with pytest.warns(RuntimeWarning, match="converge"): + result = load_run_config(config_path) + assert result == {} + + def test_invalid_toml_warns_and_returns_empty(self, config_path: Path) -> None: + """A malformed TOML file triggers a warning and yields an empty dict.""" + write_config(config_path, "[run\nmodel = \"broken\"") + with pytest.warns(RuntimeWarning, match="Failed to parse"): + result = load_run_config(config_path) + assert result == {} + + def test_run_section_not_a_table(self, config_path: Path) -> None: + """A non-table ``run`` value triggers a warning and yields an empty dict.""" + write_config(config_path, 'run = "not-a-table"') + with pytest.warns(RuntimeWarning, match="expected a table"): + result = load_run_config(config_path) + assert result == {} + + @pytest.mark.skipif(sys.version_info >= (3, 11), reason="tomli only used on Python <3.11") + def test_missing_tomli_warns_and_returns_empty(self, config_path: Path, monkeypatch: Any) -> None: + """On Python <3.11, missing ``tomli`` triggers a warning and yields empty config.""" + write_config(config_path, '[run]\nmodel = "gpt-4o"') + monkeypatch.setattr("owloop.config.tomllib", None) + with pytest.warns(RuntimeWarning, match="TOML parser not available"): + result = load_run_config(config_path) + assert result == {} + + +class TestApplyConfigDefaults: + """Tests for :func:`apply_config_defaults`.""" + + def test_returns_copy(self) -> None: + """The returned dict is a copy; the input is not mutated.""" + cli_kwargs: dict[str, Any] = {"model": None} + config: dict[str, Any] = {"model": "gpt-4o"} + merged = apply_config_defaults(cli_kwargs, config) + assert merged is not cli_kwargs + assert cli_kwargs == {"model": None} + + def test_model_only_overrides_none(self) -> None: + """``model`` from config is applied only when CLI value is ``None``.""" + config: dict[str, Any] = {"model": "config-model"} + assert apply_config_defaults({"model": None}, config) == {"model": "config-model"} + assert apply_config_defaults({"model": "cli-model"}, config) == {"model": "cli-model"} + assert apply_config_defaults({"model": ""}, config) == {"model": ""} + + def test_notify_webhook_only_overrides_none(self) -> None: + """``notify_webhook`` from config is applied only when CLI value is ``None``.""" + config: dict[str, Any] = {"notify_webhook": "https://config.example.com"} + assert apply_config_defaults({"notify_webhook": None}, config) == { + "notify_webhook": "https://config.example.com" + } + assert apply_config_defaults({"notify_webhook": "https://cli.example.com"}, config) == { + "notify_webhook": "https://cli.example.com" + } + + def test_boolean_flags_only_override_false(self) -> None: + """Boolean flags from config turn on only when CLI value is ``False``.""" + config: dict[str, Any] = { + "notify_desktop": True, + "rollback": True, + "keep_retrying": True, + "no_tui": True, + "dry_run": True, + } + cli_kwargs: dict[str, Any] = { + "notify_desktop": False, + "rollback": False, + "keep_retrying": False, + "no_tui": False, + "dry_run": False, + } + assert apply_config_defaults(cli_kwargs, config) == { + "notify_desktop": True, + "rollback": True, + "keep_retrying": True, + "no_tui": True, + "dry_run": True, + } + + def test_boolean_flags_preserve_explicit_true(self) -> None: + """Explicit ``True`` boolean CLI flags are not overridden.""" + config: dict[str, Any] = {"notify_desktop": False} + assert apply_config_defaults({"notify_desktop": True}, config) == { + "notify_desktop": True + } + + def test_numeric_options_override_zero_or_none(self) -> None: + """Numeric config options replace ``0`` or ``None`` CLI values.""" + config: dict[str, Any] = { + "converge": 3, + "workers": 2, + "max_iterations": 10, + "max_duration": 300, + "max_tokens": 100000, + "idle_timeout": 1.5, + "max_tokens_per_iteration": 4000, + "max_turns_per_iteration": 5, + } + cli_kwargs: dict[str, Any] = { + "converge": 0, + "workers": None, + "max_iterations": 0, + "max_duration": None, + "max_tokens": 0, + "idle_timeout": 0, + "max_tokens_per_iteration": None, + "max_turns_per_iteration": 0, + } + assert apply_config_defaults(cli_kwargs, config) == config + + def test_numeric_options_preserve_explicit_values(self) -> None: + """Explicitly provided numeric CLI values are not overridden.""" + config: dict[str, Any] = {"converge": 3, "idle_timeout": 1.5} + cli_kwargs: dict[str, Any] = {"converge": 5, "idle_timeout": 2.0} + assert apply_config_defaults(cli_kwargs, config) == cli_kwargs + + def test_full_merge(self) -> None: + """A realistic merge combines explicit CLI values with config defaults.""" + cli_kwargs: dict[str, Any] = { + "model": "cli-model", + "notify_desktop": False, + "rollback": False, + "keep_retrying": False, + "no_tui": False, + "dry_run": False, + "converge": 0, + "workers": 4, + "max_iterations": None, + "max_duration": 600, + "max_tokens": 0, + "idle_timeout": None, + "max_tokens_per_iteration": 0, + "max_turns_per_iteration": 0, + "notify_webhook": None, + } + config: dict[str, Any] = { + "model": "config-model", + "notify_desktop": True, + "rollback": True, + "keep_retrying": True, + "no_tui": True, + "dry_run": True, + "converge": 3, + "workers": 2, + "max_iterations": 10, + "max_duration": 300, + "max_tokens": 100000, + "idle_timeout": 1.5, + "max_tokens_per_iteration": 4000, + "max_turns_per_iteration": 5, + "notify_webhook": "https://config.example.com", + } + expected: dict[str, Any] = { + "model": "cli-model", + "notify_desktop": True, + "rollback": True, + "keep_retrying": True, + "no_tui": True, + "dry_run": True, + "converge": 3, + "workers": 4, + "max_iterations": 10, + "max_duration": 600, + "max_tokens": 100000, + "idle_timeout": 1.5, + "max_tokens_per_iteration": 4000, + "max_turns_per_iteration": 5, + "notify_webhook": "https://config.example.com", + } + assert apply_config_defaults(cli_kwargs, config) == expected diff --git a/tests/test_engine_phase1.py b/tests/test_engine_phase1.py index bb6d39a..67ecaeb 100644 --- a/tests/test_engine_phase1.py +++ b/tests/test_engine_phase1.py @@ -46,6 +46,21 @@ def _done(**kw) -> AgentResult: ) +def _adapter_with_file(path: Path | str = "src/change.py", **kw) -> MockAdapter: + """Return a MockAdapter that creates a source file before returning DONE.""" + adapter = MockAdapter(responses=[_done(**kw)]) + original_run = adapter.run + + def _run(prompt: str, cwd: Path, *, on_line=None): + target = cwd / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("# changed\n", encoding="utf-8") + return original_run(prompt, cwd, on_line=on_line) + + adapter.run = _run # type: ignore[method-assign] + return adapter + + def _make(repo: Path, adapter: MockAdapter, **kwargs) -> OwloopEngine: return OwloopEngine(EngineConfig(project_dir=repo, worktree=False, **kwargs), adapter) @@ -80,7 +95,7 @@ def test_gate_pass_commits_and_marks_complete(tmp_path: Path, monkeypatch) -> No _spec(repo / ".owloop" / "specs", "01-t.md", "true") # criterion passes monkeypatch.setattr("owloop.engine.time.sleep", lambda _: None) - engine = _make(repo, MockAdapter(responses=[_done()]), max_iterations=1) + engine = _make(repo, _adapter_with_file(), max_iterations=1) pushes: list[str] = [] monkeypatch.setattr(engine, "_push", lambda b: pushes.append(b)) @@ -94,7 +109,8 @@ def test_gate_pass_commits_and_marks_complete(tmp_path: Path, monkeypatch) -> No last = subprocess.run( ["git", "log", "-1", "--pretty=%s"], cwd=repo, capture_output=True, text=True ).stdout.strip() - assert last == "owloop: complete 01-t.md" + # Commit messages use the spec title from ``# Spec: <title>``. + assert last == "01-t.md" def test_gate_fail_no_commit_no_push(tmp_path: Path, monkeypatch) -> None: diff --git a/tests/test_parallel.py b/tests/test_parallel.py index 4df038b..268015d 100644 --- a/tests/test_parallel.py +++ b/tests/test_parallel.py @@ -49,6 +49,28 @@ def _done_factory(): return MockAdapter(responses=[_done()]) +def _file_creating_factory(): + """Return an adapter that touches a source file so the worker has a real commit.""" + adapter = MockAdapter(responses=[_done()]) + original_run = adapter.run + + def _run(prompt: str, cwd: Path, *, on_line=None): + # Create a small source file so the worker branch has a real change + # to commit and merge back into the base branch. + if "01-a" in prompt: + src = cwd / "src" / "a" / "file.py" + elif "02-b" in prompt: + src = cwd / "src" / "b" / "file.py" + else: + src = cwd / "src" / "file.py" + src.parent.mkdir(parents=True, exist_ok=True) + src.write_text("# changed\n", encoding="utf-8") + return original_run(prompt, cwd, on_line=on_line) + + adapter.run = _run # type: ignore[method-assign] + return adapter + + def _is_complete(repo: Path, spec: str) -> bool: from owloop.spec_queue import is_root_spec_complete @@ -77,15 +99,21 @@ def test_parallel_completes_disjoint_specs(tmp_path: Path) -> None: def test_parallel_merges_land_on_base_branch(tmp_path: Path) -> None: repo = _repo_with_specs(tmp_path, {"01-a.md": ["src/a/"], "02-b.md": ["src/b/"]}) - orch = ParallelOrchestrator(ParallelConfig(project_dir=repo, workers=2), _done_factory) + orch = ParallelOrchestrator( + ParallelConfig(project_dir=repo, workers=2), _file_creating_factory + ) orch.run() log = subprocess.run( ["git", "log", "--oneline"], cwd=repo, capture_output=True, text=True ).stdout - assert "owloop: complete 01-a.md" in log - assert "owloop: complete 02-b.md" in log + # Worker commits use the spec title from ``# Spec: <title>``. + assert "01-a.md" in log + assert "02-b.md" in log + # Merge commits land on the base branch. + assert "owloop: merge 01-a.md" in log + assert "owloop: merge 02-b.md" in log # Worktrees are cleaned up. assert subprocess.run( ["git", "worktree", "list"], cwd=repo, capture_output=True, text=True diff --git a/tests/test_spec_from_issue.py b/tests/test_spec_from_issue.py index c8aaa7a..fafbefe 100644 --- a/tests/test_spec_from_issue.py +++ b/tests/test_spec_from_issue.py @@ -37,7 +37,8 @@ def _write_template(project_dir: Path) -> None: """Write a minimal spec template for testing.""" - template_path = project_dir / "templates" / "spec-template.md" + from owloop.paths import resolve_templates_dir + template_path = resolve_templates_dir(project_dir) / "spec-template.md" template_path.parent.mkdir(parents=True, exist_ok=True) template_path.write_text(TEMPLATE_CONTENT, encoding="utf-8") @@ -280,7 +281,10 @@ def fake_from_github(issue: str, repo: str | None = None) -> dict[str, str]: ) with runner.isolated_filesystem() as fs: - _write_template(Path(fs)) + project_dir = Path(fs) + subprocess.run(["git", "init"], cwd=project_dir, check=True, capture_output=True) + (project_dir / ".owloop").mkdir(parents=True) + _write_template(project_dir) result = runner.invoke(main, ["spec-from-issue", "--dry-run", "42", "--repo", "acme/corp"]) assert result.exit_code == 0, result.output @@ -306,16 +310,20 @@ def fake_from_github(issue: str, repo: str | None = None) -> dict[str, str]: with runner.isolated_filesystem() as fs: project_dir = Path(fs) + subprocess.run(["git", "init"], cwd=project_dir, check=True, capture_output=True) + (project_dir / ".owloop").mkdir(parents=True) _write_template(project_dir) result = runner.invoke(main, ["spec-from-issue", "42", "--repo", "acme/corp"]) assert result.exit_code == 0, result.output assert "Spec generated" in result.output - assert (project_dir / "specs" / "001-add-dark-mode.md").exists() + assert (project_dir / ".owloop" / "specs" / "001-add-dark-mode.md").exists() def test_cli_spec_from_issue_missing_repo(): runner = CliRunner() - with runner.isolated_filesystem(): + with runner.isolated_filesystem() as fs: + project_dir = Path(fs) + subprocess.run(["git", "init"], cwd=project_dir, check=True, capture_output=True) result = runner.invoke(main, ["spec-from-issue", "42"]) assert result.exit_code == 1 assert "Could not determine" in result.output @@ -338,7 +346,9 @@ def fake_from_github(issue: str, repo: str | None = None) -> dict[str, str]: IssueToSpecConverter, "from_github", staticmethod(lambda issue, repo=None: fake_from_github(issue, repo)) ) - with runner.isolated_filesystem(): + with runner.isolated_filesystem() as fs: + project_dir = Path(fs) + subprocess.run(["git", "init"], cwd=project_dir, check=True, capture_output=True) result = runner.invoke(main, ["spec-from-issue", "42", "--repo", "acme/corp"]) assert result.exit_code == 1 From e8082d4f5d024e69ea4a0177a64f0b253cd57b87 Mon Sep 17 00:00:00 2001 From: Eric Cao <itsericsmail@gmail.com> Date: Tue, 7 Jul 2026 15:08:39 +0800 Subject: [PATCH 2/3] docs: sync README, CLAUDE.md, and site with #58 features --- CLAUDE.md | 5 +++-- README.md | 23 ++++++++++++++++++++++- site/index.html | 14 +++++++++++++- site/terminal-demo.js | 20 ++++++++++---------- 4 files changed, 48 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3979af9..911d6c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,8 +14,9 @@ owloop is a spec-driven autonomous coding loop for Claude Code — "Your code ev | Path | Purpose | |---|---| -| `src/owloop/cli.py` | Python CLI (`init` / `run` / `plan` / `status` / `version` subcommands), rich console output | +| `src/owloop/cli.py` | Python CLI (`init`, `run`, `go`, `spec`, `check`, `status`, `finish`, `logs`, `report`, `agents`, `discover`, `spec-from-issue`, `version`), rich console output | | `src/owloop/engine.py` | Python loop engine — spawns agent per iteration, manages worktree, drives spec queue | +| `src/owloop/config.py` | Persistent `.owloop/config.toml` `[run]` loader and CLI-default merging | | `src/owloop/verification.py` | The shared deterministic gate (acceptance criteria + backpressure via `subprocess`, tamper hash) used by both engine and parallel orchestrator | | `src/owloop/parallel.py` | File-disjoint parallel workers (`owloop run --workers N`): per-worker worktrees, shared gate, merge-back | | `src/owloop/notifications.py` | Best-effort completion notifications (webhook/desktop) when a run stops | @@ -59,7 +60,7 @@ owloop is a spec-driven autonomous coding loop for Claude Code — "Your code ev - **Auto Mode, not YOLO** — always `--permission-mode auto`, never `--dangerously-skip-permissions`. This is the core premise of the fork; don't regress it. On the ACP path this means owloop answers `session/request_permission` itself (`allow_once`) and never launches an agent in a bypass mode. - **ACP-first for new agents** — a new coding agent is integrated by adding an `AgentPreset` row in `presets.py`, not a new adapter class. Bespoke stream parsers are reserved for the pre-existing native adapters (`claude`, `kimi`). -- **Worktree isolation, zero extra deps** — the engine uses plain `git worktree add` / `git worktree list`, nothing beyond git itself. +- **Worktree isolation, zero extra deps** — the engine uses plain `git worktree add` / `git worktree list`. Worktrees are created next to the project as `{project_dir}-owloop-wt/owloop-{date}-{slug}-{id}`, nothing beyond git itself. - **Constraint-oriented specs** — every spec template carries Requirements, shell-verifiable Acceptance Criteria, Exclusions, Style, and Verification. Exclusions are what keep an unattended loop from wandering; never make that section optional. - **Fresh context per iteration** — each loop round is a brand-new `claude -p` process. State lives on disk (`.owloop/specs/`, `.owloop/logs/`), never accumulated in memory across iterations. Don't design features that assume continuity between rounds. A failed iteration is rolled back to the last good commit (`--no-rollback` opts out) so the next round starts clean; the discarded diff is saved as a patch under `.owloop/logs/`. - **Enforced verification, not trusted verification** — the engine, not the agent, owns git and completion. When the agent signals DONE, the engine runs the spec's Acceptance Criteria + `backpressure.json` commands itself (the deterministic gate) and only then commits, pushes, and marks the spec `COMPLETE`. The agent must not commit/push, mark status, or edit its own Acceptance Criteria/Verification (tamper detection fails the iteration). Never move these guarantees back into prompts. diff --git a/README.md b/README.md index 7e08820..76732fb 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,25 @@ owloop go "refactor error handling" # one command: init → spec → review That's it. owloop auto-initializes, scans your codebase, generates spec(s), asks for approval, and starts the loop. +## Persistent defaults + +Tired of repeating the same flags? Put them in `.owloop/config.toml` under `[run]`: + +```toml +[run] +model = "claude-sonnet-4" +max_iterations = 20 +max_tokens = 200000 +max_duration = 3600 +workers = 2 +notify_desktop = true +keep_retrying = false +``` + +Supported keys: `model`, `notify_desktop`, `converge`, `workers`, `rollback`, `max_iterations`, `max_duration`, `max_tokens`, `idle_timeout`, `max_tokens_per_iteration`, `max_turns_per_iteration`, `keep_retrying`, `notify_webhook`, `no_tui`, `dry_run`. + +CLI flags always win: an explicit `--max-iterations 5` overrides the config file. + ## Install as an Agent Skill Owloop also ships as a set of composable agent skills for any agentskills.io-compatible agent: @@ -69,7 +88,9 @@ Skills included: | `owloop run --max-tokens 200000` | Stop after token budget reached | | `owloop run --no-tui` / `--plain` | Print plain console output instead of the full-screen TUI | | `owloop check` | Validate all specs (pre-flight linter) | -| `owloop status` | Show specs and completion progress | +| `owloop status` | Show specs, completion progress, and the latest session/runtime summary | +| `owloop finish` | Show the latest session and optionally merge, push, and clean up | +| `owloop logs` | Inspect per-iteration logs, events, and discarded patches | | `owloop report` | Generate AI-powered HTML summary report | | `owloop -v go "goal"` | Verbose mode: show all agent output with timestamps | | `owloop report --no-ai` | Generate fast, offline report | diff --git a/site/index.html b/site/index.html index 2b9ac57..7f08aeb 100644 --- a/site/index.html +++ b/site/index.html @@ -186,7 +186,7 @@ <h2 class="section-title">From goal to commits, unattended.</h2> </div> </div> <div class="terminal-statusbar"> - <span>owloop v0.2.1</span> + <span>owloop v0.4.0</span> <span>model: claude-sonnet-5</span> <span>budget: 200k tokens</span> <button class="replay-btn" id="terminal-replay" type="button" title="Replay demo">↻ Replay</button> @@ -298,6 +298,18 @@ <h3>Fix-loop detection</h3> <h3>Token budget cap</h3> <p>Set <code>--max-tokens</code> and <code>--max-duration</code>. The loop stops before your wallet does.</p> </article> + <article class="feature-card"> + <div class="feature-glow"></div> + <div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div> + <h3>Persistent defaults</h3> + <p>Store your favorite flags in <code>.owloop/config.toml</code> under <code>[run]</code>. Stop typing the same model and budget every time.</p> + </article> + <article class="feature-card"> + <div class="feature-glow"></div> + <div class="feature-icon"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div> + <h3>Resume & finish</h3> + <p><code>owloop run --resume</code> picks up where it stopped. <code>owloop finish</code> merges, pushes, and cleans up the worktree when you're ready.</p> + </article> </div> <div class="report-preview"> diff --git a/site/terminal-demo.js b/site/terminal-demo.js index ade9521..7d78c83 100644 --- a/site/terminal-demo.js +++ b/site/terminal-demo.js @@ -112,27 +112,27 @@ // ── Rich-style TUI rendered as HTML panels ── var TUI_FRAMES = [ - { moon: "🌒", elapsed: "0:42", iter: "#1", branch: "owloop/refactor-errors", tokens: "2,140 / 200,000", specsDone: "0/3 done", progress: 0, current: "001-refactor-error-handling.md", status: "Working on spec", statusStyle: "amber", + { moon: "🌒", elapsed: "0:42", iter: "#1", branch: "owloop/20250707-refactor-errors-a1b2c3d4", tokens: "2,140 / 200,000", specsDone: "0/3 done", progress: 0, current: "001-refactor-error-handling.md", status: "Working on spec", statusStyle: "amber", specs: [["active","001-refactor-error-handling.md"],["pending","002-add-type-annotations.md"],["pending","003-unify-error-codes.md"]], action: "Reading the spec and codebase", detail: "Scanning backend/app/api/ for ValidationError patterns" }, - { moon: "🌒", elapsed: "1:15", iter: "#1", branch: "owloop/refactor-errors", tokens: "3,480 / 200,000", specsDone: "0/3 done", progress: 8, current: "001-refactor-error-handling.md", status: "Running acceptance criteria", statusStyle: "amber", + { moon: "🌒", elapsed: "1:15", iter: "#1", branch: "owloop/20250707-refactor-errors-a1b2c3d4", tokens: "3,480 / 200,000", specsDone: "0/3 done", progress: 8, current: "001-refactor-error-handling.md", status: "Running acceptance criteria", statusStyle: "amber", specs: [["active","001-refactor-error-handling.md"],["pending","002-add-type-annotations.md"],["pending","003-unify-error-codes.md"]], action: "Running acceptance criteria", detail: 'grep -c "except ValidationError" backend/app/api/*.py → 3 (≤5 ✓)' }, - { moon: "🌓", elapsed: "1:23", iter: "#1", branch: "owloop/refactor-errors", tokens: "4,120 / 200,000", specsDone: "0/3 done", progress: 15, current: "001-refactor-error-handling.md", status: "✓ done signal detected", statusStyle: "green", + { moon: "🌓", elapsed: "1:23", iter: "#1", branch: "owloop/20250707-refactor-errors-a1b2c3d4", tokens: "4,120 / 200,000", specsDone: "0/3 done", progress: 15, current: "001-refactor-error-handling.md", status: "✓ done signal detected", statusStyle: "green", specs: [["active","001-refactor-error-handling.md"],["pending","002-add-type-annotations.md"],["pending","003-unify-error-codes.md"]], action: "Committing changes", detail: "🌙 Loop closed on iteration 1" }, - { moon: "🌓", elapsed: "1:58", iter: "#2", branch: "owloop/refactor-errors", tokens: "6,820 / 200,000", specsDone: "1/3 done", progress: 33, current: "002-add-type-annotations.md", status: "Working on spec", statusStyle: "amber", + { moon: "🌓", elapsed: "1:58", iter: "#2", branch: "owloop/20250707-refactor-errors-a1b2c3d4", tokens: "6,820 / 200,000", specsDone: "1/3 done", progress: 33, current: "002-add-type-annotations.md", status: "Working on spec", statusStyle: "amber", specs: [["done","001-refactor-error-handling.md"],["active","002-add-type-annotations.md"],["pending","003-unify-error-codes.md"]], action: "Running verification commands", detail: "uv run pyright src/ --outputjson → 0 errors" }, - { moon: "🌔", elapsed: "2:12", iter: "#2", branch: "owloop/refactor-errors", tokens: "8,240 / 200,000", specsDone: "1/3 done", progress: 42, current: "002-add-type-annotations.md", status: "✓ done signal detected", statusStyle: "green", + { moon: "🌔", elapsed: "2:12", iter: "#2", branch: "owloop/20250707-refactor-errors-a1b2c3d4", tokens: "8,240 / 200,000", specsDone: "1/3 done", progress: 42, current: "002-add-type-annotations.md", status: "✓ done signal detected", statusStyle: "green", specs: [["done","001-refactor-error-handling.md"],["active","002-add-type-annotations.md"],["pending","003-unify-error-codes.md"]], action: "Committing changes", detail: "🌙 Loop closed on iteration 2" }, - { moon: "🌔", elapsed: "2:47", iter: "#3", branch: "owloop/refactor-errors", tokens: "10,540 / 200,000", specsDone: "2/3 done", progress: 66, current: "003-unify-error-codes.md", status: "Working on spec", statusStyle: "amber", + { moon: "🌔", elapsed: "2:47", iter: "#3", branch: "owloop/20250707-refactor-errors-a1b2c3d4", tokens: "10,540 / 200,000", specsDone: "2/3 done", progress: 66, current: "003-unify-error-codes.md", status: "Working on spec", statusStyle: "amber", specs: [["done","001-refactor-error-handling.md"],["done","002-add-type-annotations.md"],["active","003-unify-error-codes.md"]], action: "Running acceptance criteria", detail: "uv run pytest tests/ -q --tb=short → 148 passed" }, - { moon: "🌕", elapsed: "3:05", iter: "#3", branch: "owloop/refactor-errors", tokens: "12,380 / 200,000", specsDone: "3/3 done", progress: 100, current: "—", status: "🌅 All specs complete", statusStyle: "green", + { moon: "🌕", elapsed: "3:05", iter: "#3", branch: "owloop/20250707-refactor-errors-a1b2c3d4", tokens: "12,380 / 200,000", specsDone: "3/3 done", progress: 100, current: "—", status: "🌅 All specs complete", statusStyle: "green", specs: [["done","001-refactor-error-handling.md"],["done","002-add-type-annotations.md"],["done","003-unify-error-codes.md"]], - action: "3 commits pushed to owloop/refactor-errors", detail: "" }, + action: "3 commits pushed to owloop/20250707-refactor-errors-a1b2c3d4", detail: "" }, ]; function specIcon(state) { @@ -218,7 +218,7 @@ '</div>' + '<div class="rpt-cards">' + - '<div class="rpt-card"><div class="rpt-card-val">owloop/refactor-errors</div><div class="rpt-card-label">Branch</div></div>' + + '<div class="rpt-card"><div class="rpt-card-val">owloop/20250707-refactor-errors-a1b2c3d4</div><div class="rpt-card-label">Branch</div></div>' + '<div class="rpt-card"><div class="rpt-card-val">3</div><div class="rpt-card-label">Iterations</div></div>' + '<div class="rpt-card"><div class="rpt-card-val rpt-green">Completed</div><div class="rpt-card-label">Status</div></div>' + '<div class="rpt-card"><div class="rpt-card-val">8 files · <span class="rpt-green">+328</span> · <span class="rpt-red">-108</span></div><div class="rpt-card-label">Total diff</div></div>' + @@ -385,7 +385,7 @@ await printLines([ ["Ollie is waking up...", "dim"], - ["→ worktree: .worktrees/owloop-refactor-errors", "dim"], + ["→ worktree: ../flask-api-owloop-wt/owloop-20250707-refactor-errors-a1b2c3d4", "dim"], ["→ model: claude-sonnet-5 budget: 200k tokens", "dim"], ["→ specs: 3 queued", "dim"], ], LINE_PAUSE * 0.5); From 36d18fbb3725834f2aa03449a967b2cad7b7c26f Mon Sep 17 00:00:00 2001 From: Eric Cao <itsericsmail@gmail.com> Date: Tue, 7 Jul 2026 15:46:49 +0800 Subject: [PATCH 3/3] refactor(cli): split monolithic cli.py into commands package + fix Windows tests --- CLAUDE.md | 9 +- src/owloop/cli.py | 1683 ++---------------------- src/owloop/cli_config.py | 35 + src/owloop/cli_display.py | 246 ++++ src/owloop/cli_options.py | 205 +++ src/owloop/commands/__init__.py | 0 src/owloop/commands/agents.py | 56 + src/owloop/commands/check.py | 91 ++ src/owloop/commands/discover.py | 38 + src/owloop/commands/finish.py | 144 ++ src/owloop/commands/go.py | 132 ++ src/owloop/commands/init.py | 126 ++ src/owloop/commands/logs.py | 65 + src/owloop/commands/report.py | 72 + src/owloop/commands/run.py | 313 +++++ src/owloop/commands/spec.py | 143 ++ src/owloop/commands/spec_from_issue.py | 74 ++ src/owloop/commands/status.py | 108 ++ src/owloop/commands/version.py | 26 + src/owloop/sessions.py | 59 + tests/test_adapters.py | 8 +- tests/test_cli.py | 30 +- tests/test_engine.py | 7 +- 23 files changed, 2044 insertions(+), 1626 deletions(-) create mode 100644 src/owloop/cli_config.py create mode 100644 src/owloop/cli_display.py create mode 100644 src/owloop/cli_options.py create mode 100644 src/owloop/commands/__init__.py create mode 100644 src/owloop/commands/agents.py create mode 100644 src/owloop/commands/check.py create mode 100644 src/owloop/commands/discover.py create mode 100644 src/owloop/commands/finish.py create mode 100644 src/owloop/commands/go.py create mode 100644 src/owloop/commands/init.py create mode 100644 src/owloop/commands/logs.py create mode 100644 src/owloop/commands/report.py create mode 100644 src/owloop/commands/run.py create mode 100644 src/owloop/commands/spec.py create mode 100644 src/owloop/commands/spec_from_issue.py create mode 100644 src/owloop/commands/status.py create mode 100644 src/owloop/commands/version.py create mode 100644 src/owloop/sessions.py diff --git a/CLAUDE.md b/CLAUDE.md index 911d6c3..cd5c7b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,9 +14,14 @@ owloop is a spec-driven autonomous coding loop for Claude Code — "Your code ev | Path | Purpose | |---|---| -| `src/owloop/cli.py` | Python CLI (`init`, `run`, `go`, `spec`, `check`, `status`, `finish`, `logs`, `report`, `agents`, `discover`, `spec-from-issue`, `version`), rich console output | +| `src/owloop/cli.py` | Thin CLI entry point — defines the click `main` group and wires commands | +| `src/owloop/commands/` | One module per subcommand (`run`, `go`, `spec`, `check`, `status`, `finish`, `logs`, `report`, `agents`, `discover`, `spec-from-issue`, `version`, `init`) | +| `src/owloop/cli_options.py` | Shared click options/decorators (`--model`, `--max-tokens`, etc.) | +| `src/owloop/cli_display.py` | Banner, progress bar, spec table, `AgentStreamDisplay`, and other console helpers | +| `src/owloop/cli_config.py` | Persistent config path and default merging for `.owloop/config.toml` | +| `src/owloop/sessions.py` | Latest session discovery and worktree path lookup | | `src/owloop/engine.py` | Python loop engine — spawns agent per iteration, manages worktree, drives spec queue | -| `src/owloop/config.py` | Persistent `.owloop/config.toml` `[run]` loader and CLI-default merging | +| `src/owloop/config.py` | Persistent `.owloop/config.toml` `[run]` loader and validation | | `src/owloop/verification.py` | The shared deterministic gate (acceptance criteria + backpressure via `subprocess`, tamper hash) used by both engine and parallel orchestrator | | `src/owloop/parallel.py` | File-disjoint parallel workers (`owloop run --workers N`): per-worker worktrees, shared gate, merge-back | | `src/owloop/notifications.py` | Best-effort completion notifications (webhook/desktop) when a run stops | diff --git a/src/owloop/cli.py b/src/owloop/cli.py index 923aac2..48566da 100644 --- a/src/owloop/cli.py +++ b/src/owloop/cli.py @@ -1,477 +1,43 @@ -"""owloop CLI — entry point for uvx owloop / owloop commands.""" +"""owloop CLI — thin entry point that wires command functions to the click group.""" -import json -import os -import re -import shutil -import subprocess -import sys -import threading -import time -from collections.abc import Callable from pathlib import Path -from typing import Any, cast import click from rich.console import Console -from rich.panel import Panel -from rich.table import Table -from rich.text import Text -from owloop import _brand, git_stats -from owloop.adapters import DEFAULT_IDLE_TIMEOUT, get_adapter -from owloop.backpressure import discover_and_save -from owloop.config import apply_config_defaults, load_run_config -from owloop.engine import EngineConfig, OwloopEngine, RunSummary, TerminalState -from owloop.paths import resolve_logs_dir, resolve_specs_dir -from owloop.report import ReportGenerator -from owloop.report_ai import AIReportInsightsGenerator -from owloop.reporter import ConsoleReporter -from owloop.spec_from_issue import IssueToSpecConverter -from owloop.spec_generator import SpecGenerationError, SpecGenerator -from owloop.spec_linter import LintReport, SpecLinter -from owloop.spec_review import SpecReview -from owloop.tui import OwloopTUI - -DEFAULT_MODEL = os.environ.get("CLAUDE_MODEL", "claude-sonnet-5") - -SPEC_TEMPLATE = """\ -# Spec: {name} - -## Priority: {priority} - -## Requirements -- [ ] TODO: describe what needs to be done - -## Acceptance Criteria -- [ ] TODO: shell command → expected output - Verify: `echo "replace with real verification command"` - -## Exclusions -- Do NOT modify files outside the scope described above -- Do NOT change any external API behavior -- Do NOT modify pyproject.toml, uv.lock, or other config files - -## Style -- Follow existing project conventions - -## Verification -The loop re-runs the Acceptance Criteria commands itself and commits only when -they pass — you never commit, push, or mark this spec COMPLETE. Do not edit this -section or the Acceptance Criteria mid-iteration. - -Output when complete: `<promise>DONE</promise>` -""" - - -CHECKED_BOX_RE = re.compile(r"- \[[xX]\]") -# Mirrors the `**Status**: COMPLETE` convention spec_queue._COMPLETE_RE looks for, -# so `owloop status` classifies specs the same way the engine's queue does. -_STATUS_DONE_RE = re.compile(r"^(#{1,3} )?(\*\*)?status(\*\*)?:\s+complete", re.MULTILINE | re.IGNORECASE) -_STATUS_IN_PROGRESS_RE = re.compile( - r"^(#{1,3} )?(\*\*)?status(\*\*)?:\s+in progress", re.MULTILINE | re.IGNORECASE +from owloop import _brand +from owloop.cli_display import _banner_text +from owloop.cli_options import ( + DEFAULT_MODEL, + MaxTokensParamType, + _agent_run_options, + _common_run_options, + _extra_run_options, + parse_max_tokens, ) - -MAX_TOKENS_UNITS = { - "k": 1_000, - "w": 10_000, - "m": 1_000_000, -} - - -def parse_max_tokens(value: str) -> int: - """Parse a token limit, supporting shorthand like 10k, 1w, 2m. - - Args: - value: Raw user input. Plain integers pass through; suffixes - ``k`` (thousand), ``w`` (ten-thousand), and ``m`` (million) - are expanded. - - Returns: - Token count as an integer. - - Raises: - click.BadParameter: if the value cannot be parsed. - """ - value = value.strip().lower() - if not value: - raise click.BadParameter("token limit cannot be empty") - - if value.isdigit(): - return int(value) - - suffix = value[-1] - if suffix not in MAX_TOKENS_UNITS: - raise click.BadParameter( - f"invalid token limit: {value!r}. Use a number or add k/w/m (e.g. 10k, 1w, 2m)" - ) - - number_part = value[:-1] - if not number_part or not number_part.replace(".", "", 1).isdigit(): - raise click.BadParameter( - f"invalid token limit: {value!r}. Use a number or add k/w/m (e.g. 10k, 1w, 2m)" - ) - - number = float(number_part) - return int(number * MAX_TOKENS_UNITS[suffix]) - - -class MaxTokensParamType(click.ParamType): - """Click parameter type that parses token limit shorthand.""" - - name = "tokens" - - def convert(self, value: object, param: click.Parameter | None, ctx: click.Context | None) -> int: - if isinstance(value, int): - return value - return parse_max_tokens(str(value)) - - -def classify_spec(content: str) -> str: - if _STATUS_DONE_RE.search(content): - return "done" - if _STATUS_IN_PROGRESS_RE.search(content): - return "in_progress" - if CHECKED_BOX_RE.search(content): - return "in_progress" - return "pending" - - -def _banner_text(ascii: bool = False, no_color: bool = False) -> Text | str: - """Return the owloop banner.""" - joined = ( - _brand.BRAND_BAR_ASCII - if ascii - else f"{_brand.OWL_EMOJI} {_brand.BRAND_BAR}" - ) - if no_color: - return joined - return Text.from_markup(f"[bold {_brand.AMBER}]{joined}[/]") - - -def render_progress_bar(done: int, total: int, width: int = 20, ascii: bool = False) -> str: - filled = round(width * done / total) if total else 0 - filled = max(0, min(width, filled)) - pct = round(done / total * 100) if total else 0 - moon = _brand.ascii_moon_for_progress(done, total) if ascii else _brand.moon_for_progress(done, total) - return ( - f"{moon} [{_brand.AMBER}]{'█' * filled}[/][{_brand.GRAY}]{'░' * (width - filled)}[/] {pct}%" - ) - - -def _print_check_report(console: Console, report: LintReport, *, ascii: bool = False) -> None: - """Render a Rich report from a SpecLinter lint result.""" - check_icon = "+" if ascii else "✓" - error_icon = "x" if ascii else "✗" - warn_icon = "!" if ascii else "⚠" - - console.print() - console.print("[bold]owloop check[/]") - console.print(f"[dim]{'─' * 12}[/]") - console.print(f"{check_icon} {report.spec_count} specs scanned") - - if report.error_count or report.warning_count: - console.print( - f"[{_brand.RED}]{error_icon} {report.error_count} error" - f"{'s' if report.error_count != 1 else ''}[/], " - f"[{_brand.AMBER}]{warn_icon} {report.warning_count} warning" - f"{'s' if report.warning_count != 1 else ''}[/]" - ) - else: - console.print(f"[{_brand.GREEN}]{check_icon} 0 errors, 0 warnings[/]") - - for file_name, findings in report.results.items(): - if not findings: - continue - console.print() - console.print(file_name) - for finding in findings: - if finding.severity == "error": - icon = f"[{_brand.RED}]{error_icon}[/]" - else: - icon = f"[{_brand.AMBER}]{warn_icon}[/]" - console.print(f" {icon} {finding.message}") - console.print() - - -def _cli_options() -> tuple[bool, bool, bool, bool]: - """Read global --ascii / --no-color / --compact / --verbose flags from the current Click context.""" - ctx = click.get_current_context() - obj = ctx.ensure_object(dict) - return bool(obj.get("ascii")), bool(obj.get("no_color")), bool(obj.get("compact")), bool(obj.get("verbose")) - - -def _run_config_path() -> Path: - """Return the persistent run configuration file path.""" - return Path.cwd() / ".owloop" / "config.toml" - - -def _load_and_apply_run_config(**cli_kwargs: Any) -> dict[str, Any]: - """Load ``.owloop/config.toml`` and merge its ``[run]`` defaults into CLI kwargs.""" - config = load_run_config(_run_config_path()) - return apply_config_defaults(cli_kwargs, config) - - -def _default_report_path() -> Path: - """Return the default quick-report output path.""" - return Path.cwd() / ".owloop" / "reports" / "owloop_report_latest.html" - - -def _generate_quick_report(summary: RunSummary) -> Path | None: - """Generate a fast, no-AI HTML report and return its path, or None on failure.""" - try: - report_path = _default_report_path() - report_path.parent.mkdir(parents=True, exist_ok=True) - generator = ReportGenerator(summary.main_repo_dir) - generator.generate(report_path, insights=None, use_tailwind=False) - return report_path - except Exception: - return None - - -def _format_spec_table(spec_paths: list[Path], verbose: bool = False) -> Table | list[Panel]: - """Return either a compact Table or full Panels for the generated specs.""" - if verbose: - return [ - Panel( - sp.read_text(encoding="utf-8"), - title=f"[bold]{sp.name}[/]", - border_style=_brand.AMBER, - padding=(1, 2), - ) - for sp in spec_paths - ] - - table = Table( - title="Generated specs", - border_style=_brand.AMBER, - show_lines=False, - padding=(0, 2), - ) - table.add_column("File", style="bold") - table.add_column("Title") - table.add_column("Priority", justify="center") - table.add_column("Status", justify="center") - - for sp in spec_paths: - content = sp.read_text(encoding="utf-8") - state = classify_spec(content) - status_text = { - "done": "[green]done[/]", - "in_progress": f"[{_brand.AMBER}]in progress[/]", - "pending": "[dim]pending[/]", - }[state] - priority = "—" - title: str | None = None - for line in content.splitlines(): - if line.strip().startswith("## Priority:"): - priority = line.split(":", 1)[-1].strip() - if line.startswith("# Spec:"): - title = line.split(":", 1)[-1].strip() - table.add_row(sp.name, title or "—", priority, status_text) - - return table - - -def _read_latest_session(project_dir: Path) -> dict[str, Any] | None: - """Load the latest session descriptor if it exists.""" - path = project_dir / ".owloop" / "logs" / "session_latest.json" - if not path.is_file(): - return None - try: - import json - data = json.loads(path.read_text(encoding="utf-8")) - if isinstance(data, dict): - return data - except (json.JSONDecodeError, OSError): - pass - return None - - -def _find_worktree_path(project_dir: Path, session: dict[str, Any] | None = None) -> Path | None: - """Return the worktree path from the session record or ``git worktree list``.""" - if session and session.get("path"): - return Path(session["path"]) - result = subprocess.run( - ["git", "worktree", "list", "--porcelain"], - cwd=project_dir, - capture_output=True, - text=True, - ) - if result.returncode != 0: - return None - for line in result.stdout.splitlines(): - if line.startswith("worktree "): - wt = Path(line.split(" ", 1)[1]) - if wt != project_dir and wt.name.startswith("owloop-"): - return wt - return None - - -class AgentStreamDisplay: - """Live display for streaming agent output. - - Layout: gray output lines scroll above, status bar stays at the bottom. - - [reading backend/app/api/orders.py] ← scrolling gray - Found 23 repeated try/except blocks ← scrolling gray - [running: grep -c "except" *.py] ← scrolling gray - ⠋ 0:32 · ~1.2k tokens ← always at bottom - """ - - BURST_THRESHOLD = 8 - SPINNERS = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" - - def __init__(self, console: Console, *, verbose: bool = False) -> None: - self.console = console - self.verbose = verbose - self.start_time = time.monotonic() - self._last_output = time.monotonic() - self._burst_count = 0 - self._burst_suppressed = 0 - self._line_count = 0 - self._char_count = 0 - self._real_tokens = "" - self._has_status = False - self._frame = 0 - self._lock = threading.Lock() - self._stop = threading.Event() - self._ticker: threading.Thread | None = None - self._out = console.file or sys.stdout - - def start(self) -> None: - self._draw_status() - self._ticker = threading.Thread(target=self._tick, daemon=True) - self._ticker.start() - - def stop(self) -> None: - self._stop.set() - if self._ticker: - self._ticker.join(timeout=2) - with self._lock: - self._clear_status() - self._flush_burst() - - @staticmethod - def _is_noise(text: str) -> bool: - if len(text) < 3 or not any(c.isalnum() for c in text): - return True - return "<promise>" in text - - def on_line(self, line: str) -> None: - stripped = line.strip() - if not stripped or self._is_noise(stripped): - return - - with self._lock: - now = time.monotonic() - gap = now - self._last_output - self._last_output = now - self._line_count += 1 - self._char_count += len(stripped) - - if stripped.startswith("[usage:"): - self._real_tokens = stripped[8:-1] - self._clear_status() - self._draw_status() - return - - if self.verbose: - elapsed = now - self.start_time - self._clear_status() - self._print_line(f" [{elapsed:.1f}s] {stripped}") - self._draw_status() - return - - if gap < 0.05: - self._burst_count += 1 - if self._burst_count > self.BURST_THRESHOLD: - self._burst_suppressed += 1 - return - else: - if self._burst_suppressed > 0: - self._clear_status() - self._flush_burst() - self._burst_count = 0 - - self._clear_status() - self._print_line(f" {stripped}") - self._draw_status() - - def _flush_burst(self) -> None: - if self._burst_suppressed > 0: - self._print_line(f" ... ({self._burst_suppressed} lines)") - self._burst_suppressed = 0 - - def _print_line(self, text: str) -> None: - self._out.write(f"\033[90m{text}\033[0m\n") - self._out.flush() - - def _clear_status(self) -> None: - if self._has_status: - self._out.write("\r\033[K") - self._out.flush() - self._has_status = False - - def _build_status(self) -> str: - elapsed = int(time.monotonic() - self.start_time) - mins, secs = divmod(elapsed, 60) - self._frame = (self._frame + 1) % len(self.SPINNERS) - spinner = self.SPINNERS[self._frame] - parts = [f" {spinner} {mins}:{secs:02d}"] - if self._real_tokens: - parts.append(self._real_tokens) - else: - est = self._char_count // 4 - if est >= 1000: - parts.append(f"~{est / 1000:.1f}k tokens") - elif est > 0: - parts.append(f"~{est} tokens") - parts.append(f"{self._line_count} lines") - return " · ".join(parts) - - def _draw_status(self) -> None: - status = self._build_status() - self._out.write(f"\r\033[K{status}") - self._out.flush() - self._has_status = True - - def _tick(self) -> None: - while not self._stop.wait(0.5): - with self._lock: - if self._has_status: - self._out.write(f"\r\033[K{self._build_status()}") - self._out.flush() - - -def _ensure_init(cwd: Path, console: Console, *, ascii: bool = False) -> None: - """Auto-initialize .owloop/ if it doesn't exist (silent, no example spec).""" - owloop_path = cwd / ".owloop" - if owloop_path.exists(): - return - - if not (cwd / ".git").exists(): - console.print("[red]Error:[/] Not a git repository. Run [bold]git init[/] first.") - raise SystemExit(1) - - owloop_path.mkdir(parents=True) - (owloop_path / "specs").mkdir() - (owloop_path / "logs").mkdir() - - gitignore = cwd / ".gitignore" - gitignore_entries = [".owloop/"] - if gitignore.exists(): - existing = gitignore.read_text(encoding="utf-8") - to_add = [e for e in gitignore_entries if e not in existing] - if to_add: - with open(gitignore, "a", encoding="utf-8") as f: - f.write("\n# owloop\n") - for entry in to_add: - f.write(f"{entry}\n") - else: - gitignore.write_text("# owloop\n" + "\n".join(gitignore_entries) + "\n", encoding="utf-8") - - console.print(f"[{_brand.GREEN}]✓[/] Initialized .owloop/") +from owloop.commands.agents import agents_cmd +from owloop.commands.check import check_cmd +from owloop.commands.discover import discover_cmd +from owloop.commands.finish import finish_cmd +from owloop.commands.go import go_cmd +from owloop.commands.init import init_cmd +from owloop.commands.logs import logs_cmd +from owloop.commands.report import report_cmd +from owloop.commands.run import _print_dry_run_report, _run_engine, run_cmd +from owloop.commands.spec import spec_cmd +from owloop.commands.spec_from_issue import spec_from_issue_cmd +from owloop.commands.status import status_cmd +from owloop.commands.version import version_cmd +from owloop.sessions import classify_spec + +# Re-export public names used by tests. +__all__ = [ + "main", + "classify_spec", + "parse_max_tokens", + "_run_engine", + "_print_dry_run_report", +] @click.group(invoke_without_command=True) @@ -509,138 +75,6 @@ def main(ctx: click.Context, ascii: bool, no_color: bool, compact: bool, verbose console.print("[dim]Run[/] [bold]owloop <command> --help[/] [dim]for details.[/]") -def _validate_agent(ctx: click.Context, param: click.Parameter, value: str) -> str: - """Validate --agent against builtin + user presets (.owloop/agents.toml).""" - from owloop.presets import all_presets - - keys = sorted(all_presets(Path.cwd())) - if value not in keys: - raise click.BadParameter(f"unknown agent {value!r}. Available: {', '.join(keys)}") - return value - - -def _agent_run_options(f: Callable[..., Any]) -> Callable[..., Any]: - """Shared non-model run options for run, go, and spec.""" - f = click.option( - "--agent", default="claude", metavar="AGENT", callback=_validate_agent, - help="Coding agent preset (see `owloop agents` for the full list).", - show_default=True, - )(f) - f = click.option( - "--verifier-model", - help="Claude model for the independent verifier agent (defaults to --model).", - default=None, - )(f) - f = click.option( - "--subagents", - is_flag=True, - default=False, - help="Split large iterations into Orient/Implement/Verify subagent phases.", - )(f) - f = click.option( - "--idle-timeout", type=float, default=DEFAULT_IDLE_TIMEOUT, - help="Kill agent after N seconds without output.", show_default=True, - )(f) - f = click.option( - "--max-duration", type=int, default=0, - help="Stop loop after N minutes total (0 = unlimited).", show_default=True, - )(f) - f = click.option( - "--max-tokens", type=MaxTokensParamType(), default=0, - help="Stop loop after N total tokens (0 = unlimited; supports k/w/m shorthand).", show_default=True, - )(f) - f = click.option( - "--worktree/--no-worktree", default=True, - help="Run in an isolated git worktree.", show_default=True, - )(f) - return f - - -def _common_run_options(f: Callable[..., Any]) -> Callable[..., Any]: - """Shared options for the run and go commands (includes --model default None).""" - f = click.option( - "--model", default=None, metavar="MODEL", - help="Model to use (defaults per agent; claude honors CLAUDE_MODEL).", - )(f) - return _agent_run_options(f) - - -def _extra_run_options(f: Callable[..., Any]) -> Callable[..., Any]: - """Additional run options forwarded by run, go, and spec.""" - f = click.option( - "--max-iterations", "-n", type=int, default=0, - help="Maximum iterations (0 = unlimited).", show_default=True, - )(f) - f = click.option( - "--resume", - is_flag=True, - default=False, - help="Resume the most recent owloop session (reuse its worktree and branch).", - )(f) - f = click.option( - "--dry-run", "--one-shot", "dry_run", - is_flag=True, - default=False, - help="Run exactly one iteration, print a pass/fail report, and skip push " - "(no committed changes are left behind). Use to validate specs without " - "burning a full overnight run.", - )(f) - f = click.option( - "--no-tui", "--plain", "no_tui", - is_flag=True, - default=False, - help="Bypass the full-screen TUI and print plain console output, even in a TTY.", - )(f) - f = click.option( - "--max-tokens-per-iteration", type=MaxTokensParamType(), default=0, - help="Kill a single iteration early if it exceeds N tokens (0 = unlimited; " - "supports k/w/m shorthand).", show_default=True, - )(f) - f = click.option( - "--max-turns-per-iteration", type=int, default=0, - help="Forward --max-turns N to `claude -p` so a single iteration is bounded " - "at the source (0 = unlimited; ignored on CLIs without the flag).", - show_default=True, - )(f) - f = click.option( - "--max-budget-usd", type=float, default=0.0, - help="Forward a per-iteration USD budget cap to the CLI when supported " - "(0 = unlimited).", show_default=True, - )(f) - f = click.option( - "--keep-retrying", is_flag=True, default=False, - help="Legacy behavior: warn and back off on repeated failures instead of " - "hard-stopping with a `stalled` terminal state.", - )(f) - f = click.option( - "--rollback/--no-rollback", default=True, show_default=True, - help="Reset the worktree to the last good commit after a failed iteration " - "(a discarded-diff patch is saved under .owloop/logs/).", - )(f) - f = click.option( - "--notify-webhook", default=None, metavar="URL", - help="POST a JSON completion notification to this webhook when the run stops " - "on an attention-worthy state (or set OWLOOP_NOTIFY_WEBHOOK).", - )(f) - f = click.option( - "--notify-desktop", is_flag=True, default=False, - help="Fire a native desktop notification when the run stops.", - )(f) - f = click.option( - "--converge", "converge_sweeps", type=int, default=0, metavar="N", - help="After the spec queue empties, run up to N audit sweeps that append gap " - "specs until the codebase converges on the goal (0 = disabled).", - show_default=True, - )(f) - f = click.option( - "--workers", type=int, default=1, metavar="N", - help="Run up to N file-disjoint specs concurrently, each in its own worktree " - "(1 = sequential). Specs need a `## Files` scope to be scheduled in parallel.", - show_default=True, - )(f) - return f - - @main.command() @click.argument("goal") @click.option( @@ -674,78 +108,21 @@ def go( workers: int, verbose: bool, ) -> None: - """One command: init → generate spec(s) → review → start the loop. - - \b - Example: - owloop go "refactor error handling in the API layer" - """ - ascii, no_color, compact, cli_verbose = _cli_options() - verbose = verbose or cli_verbose - console = Console(no_color=no_color) - project_dir = Path.cwd() - - console.print() - console.print(_banner_text(ascii=ascii, no_color=no_color)) - console.print(f"[dim]Goal:[/] {goal}\n") - - _ensure_init(project_dir, console, ascii=ascii) - - # Spec generation always runs on Claude Code today; only forward --model - # when it was meant for Claude, not e.g. a GLM/DeepSeek model id. - adapter = get_adapter( - "claude", - model=model if agent == "claude" else None, - claude_cmd=os.environ.get("CLAUDE_CMD", "claude"), - idle_timeout=idle_timeout, - ) - generator = SpecGenerator(project_dir, adapter) - stream = AgentStreamDisplay(console, verbose=verbose) - - if verbose: - console.print( - f" [dim]→ spawning: claude -p --model {model or DEFAULT_MODEL} --permission-mode auto[/]" - ) - console.print(f" [dim]→ cwd: {project_dir}[/]") - - try: - stream.start() - spec_paths = generator.generate(goal, on_line=stream.on_line) - except SpecGenerationError as exc: - console.print(f"\n[red]Error:[/] {exc}") - raise SystemExit(1) from None - finally: - stream.stop() - - console.print() - console.print(f"[{_brand.GREEN}]✓ {len(spec_paths)} spec(s) generated:[/]") - for sp in spec_paths: - console.print(f" [{_brand.CYAN}]{sp.name}[/]") - console.print() - - display = _format_spec_table(spec_paths, verbose=verbose) - if isinstance(display, Table): - console.print(display) - else: - for panel in display: - console.print(panel) - - run_kwargs = _load_and_apply_run_config( - max_iterations=max_iterations, - worktree=worktree, - model=model, + """One command: init → generate spec(s) → review → start the loop.""" + go_cmd( + goal=goal, agent=agent, + model=model, + verifier_model=verifier_model, + subagents=subagents, idle_timeout=idle_timeout, max_duration=max_duration, max_tokens=max_tokens, - ascii=ascii, - no_color=no_color, - compact=compact, - verifier_model=verifier_model, - subagents=subagents, + worktree=worktree, + max_iterations=max_iterations, resume=resume, - no_tui=no_tui, dry_run=dry_run, + no_tui=no_tui, max_tokens_per_iteration=max_tokens_per_iteration, max_turns_per_iteration=max_turns_per_iteration, max_budget_usd=max_budget_usd, @@ -755,11 +132,9 @@ def go( notify_desktop=notify_desktop, converge_sweeps=converge_sweeps, workers=workers, + verbose=verbose, ) - console.print(f"\n[{_brand.AMBER}]Starting autonomous loop...[/]") - _run_engine(**run_kwargs) - @main.command() @click.option( @@ -769,90 +144,8 @@ def go( show_default=True, ) def init(example: bool) -> None: - """Initialize owloop in the current project. - - Creates a single ``.owloop/`` metadata directory in the project root. - All specs, logs, and runtime prompts live inside it so the original - project stays clean. - """ - ascii, no_color, _compact, verbose = _cli_options() - console = Console(no_color=no_color) - cwd = Path.cwd() - - if not (cwd / ".git").exists(): - console.print("[red]Error:[/] Not a git repository. Run [bold]git init[/] first.") - raise SystemExit(1) - - owloop_path = cwd / ".owloop" - specs_path = owloop_path / "specs" - logs_path = owloop_path / "logs" - - created = [] - - if not owloop_path.exists(): - owloop_path.mkdir(parents=True) - created.append(".owloop/") - - if not specs_path.exists(): - specs_path.mkdir(parents=True) - created.append(".owloop/specs/") - - if not logs_path.exists(): - logs_path.mkdir(parents=True) - created.append(".owloop/logs/") - - gitignore = cwd / ".gitignore" - gitignore_entries = [".owloop/"] - if gitignore.exists(): - existing = gitignore.read_text(encoding="utf-8") - to_add = [e for e in gitignore_entries if e not in existing] - if to_add: - with open(gitignore, "a", encoding="utf-8") as f: - f.write("\n# owloop\n") - for entry in to_add: - f.write(f"{entry}\n") - created.append(".gitignore (updated)") - else: - gitignore.write_text("# owloop\n" + "\n".join(gitignore_entries) + "\n", encoding="utf-8") - created.append(".gitignore") - - if example: - example_spec = specs_path / "01-example.md" - if not example_spec.exists(): - example_spec.write_text( - SPEC_TEMPLATE.format(name="example-task", priority=1), - encoding="utf-8", - ) - created.append(".owloop/specs/01-example.md") - - backpressure_path = owloop_path / "backpressure.json" - if not backpressure_path.exists(): - try: - _commands, bp_path = discover_and_save(cwd) - created.append(str(bp_path.relative_to(cwd))) - except Exception: - pass - - console.print() - console.print(_banner_text(ascii=ascii, no_color=no_color)) - - if created: - console.print( - Panel( - "\n".join(f" [green]✓[/] {f}" for f in created), - title="[bold]Initialized[/]", - border_style=_brand.AMBER, - padding=(1, 2), - ) - ) - else: - console.print("[dim]Already initialized — nothing to create.[/]") - - console.print() - console.print(f"[bold {_brand.AMBER}]Next steps:[/]") - console.print(" 1. Edit [bold].owloop/specs/01-example.md[/] with your task") - console.print(" 2. Run [bold]owloop run[/]") - console.print() + """Initialize owloop in the current project.""" + init_cmd(example=example) @main.command() @@ -902,208 +195,23 @@ def spec( workers: int, verbose: bool, ) -> None: - """Turn a vague goal into a concrete spec via agent clarification. - - Claude scans the codebase, calibrates baselines, and drafts a complete - constraint-oriented spec. The spec is shown for approval before the loop - starts unless --yes is passed. - """ - ascii, no_color, compact, cli_verbose = _cli_options() - verbose = verbose or cli_verbose - console = Console(no_color=no_color) - project_dir = Path.cwd() - - _ensure_init(project_dir, console, ascii=ascii) - - console.print() - console.print(_banner_text(ascii=ascii, no_color=no_color)) - console.print(f"[dim]Goal:[/] {goal}\n") - - adapter = get_adapter( - "claude", - model=model, - claude_cmd=os.environ.get("CLAUDE_CMD", "claude"), - idle_timeout=idle_timeout, - ) - generator = SpecGenerator(project_dir, adapter) - stream = AgentStreamDisplay(console, verbose=verbose) - - try: - stream.start() - spec_paths = generator.generate(goal, max_rounds=max_rounds, on_line=stream.on_line) - except SpecGenerationError as exc: - console.print(f"\n[red]Error:[/] {exc}") - raise SystemExit(1) from None - finally: - stream.stop() - - console.print() - console.print(f"[{_brand.GREEN}]✓ {len(spec_paths)} spec(s) generated:[/]") - for sp in spec_paths: - console.print(f" [{_brand.CYAN}]{sp}[/]") - console.print() - - display = _format_spec_table(spec_paths, verbose=verbose) - if isinstance(display, Table): - console.print(display) - else: - for panel in display: - console.print(panel) - - run_kwargs = _load_and_apply_run_config( - max_iterations=max_iterations, - worktree=worktree, + """Turn a vague goal into a concrete spec via agent clarification.""" + spec_cmd( + goal=goal, model=model, + max_rounds=max_rounds, + yes=yes, agent=agent, - idle_timeout=idle_timeout, - max_duration=max_duration, - max_tokens=max_tokens, - ascii=ascii, - no_color=no_color, - compact=compact, verifier_model=verifier_model, subagents=subagents, - resume=resume, - no_tui=no_tui, - dry_run=dry_run, - max_tokens_per_iteration=max_tokens_per_iteration, - max_turns_per_iteration=max_turns_per_iteration, - max_budget_usd=max_budget_usd, - keep_retrying=keep_retrying, - rollback=rollback, - notify_webhook=notify_webhook, - notify_desktop=notify_desktop, - converge_sweeps=converge_sweeps, - workers=workers, - ) - - start_loop = yes - if not yes: - if sys.stdin.isatty(): - console.print("\n[bold]Start the loop now?[/] [Y/n] ", end="") - try: - reply = input().strip().lower() or "y" - except EOFError: - reply = "y" - start_loop = reply.startswith("y") - else: - console.print("\n[dim]Non-interactive mode: review the spec and run[/] [bold]owloop run[/]") - - if start_loop: - console.print(f"\n[{_brand.AMBER}]Starting autonomous loop...[/]") - _run_engine(**run_kwargs) - else: - console.print("\n[dim]Review the spec, then run[/] [bold]owloop run[/]") - console.print() - - -@main.command() -def agents() -> None: - """List available coding-agent presets and whether they're ready to use. - - Two integration paths exist: native adapters (claude, kimi) and ACP — - the Agent Client Protocol (https://agentclientprotocol.com) — which - covers every other preset with a single implementation. Add your own - ACP agents in ``.owloop/agents.toml``. - """ - import shutil as _shutil - - from rich.table import Table - - from owloop.presets import all_presets - - _ascii, no_color, _compact, _verbose = _cli_options() - console = Console(no_color=no_color) - - table = Table(border_style=_brand.AMBER, header_style=f"bold {_brand.AMBER}") - table.add_column("agent") - table.add_column("kind") - table.add_column("command") - table.add_column("model") - table.add_column("status") - - for key, preset in sorted(all_presets(Path.cwd()).items()): - binary = preset.cmd[0] - problems: list[str] = [] - if not _shutil.which(binary): - problems.append(f"{binary} not found") - missing = [v for v in preset.required_env_vars() if os.environ.get(v) is None] - if missing: - problems.append(f"set {', '.join(missing)}") - status = f"[red]✗ {'; '.join(problems)}[/]" if problems else f"[{_brand.GREEN}]✓ ready[/]" - if preset.experimental and not problems: - status += " [dim](experimental)[/]" - table.add_row( - f"[bold]{key}[/]", - preset.kind, - " ".join(preset.cmd), - preset.default_model or "[dim]agent default[/]", - status, - ) - - console.print(table) - console.print( - "[dim]Use with[/] [bold]owloop run --agent <agent>[/]" - "[dim]; define custom agents in .owloop/agents.toml[/]" - ) - - -# Three-tier exit codes for unattended callers (CI, cron, wrappers). -# 0 = finished successfully -# 1 = hard failure (engine/agent broke, spec tampered, preflight failed) -# 2 = needs human attention (blocked, decide, stalled, exhausted, interrupted) -_EXIT_CODE_MAP = { - TerminalState.SUCCESS: 0, - TerminalState.CLEAN_NO_OP: 0, - TerminalState.BLOCKED: 2, - TerminalState.DECIDE: 2, - TerminalState.STALLED: 2, - TerminalState.EXHAUSTED: 2, - TerminalState.TAMPERED: 1, - TerminalState.INTERRUPTED: 2, - TerminalState.FAILED: 1, -} - - -def _exit_code_for_summary(summary: RunSummary) -> int: - return _EXIT_CODE_MAP.get(cast(TerminalState, summary.state), 1) - - -def _run_engine( - max_iterations: int, worktree: bool, model: str | None, agent: str, - idle_timeout: float = DEFAULT_IDLE_TIMEOUT, max_duration: int = 0, max_tokens: int = 0, - 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, - max_tokens_per_iteration: int = 0, - max_turns_per_iteration: int = 0, - max_budget_usd: float = 0.0, - keep_retrying: bool = False, - rollback: bool = True, - notify_webhook: str | None = None, - notify_desktop: bool = False, - converge_sweeps: int = 0, - workers: int = 1, -) -> None: - # Merge persistent config defaults before constructing the engine. - kwargs = _load_and_apply_run_config( - max_iterations=max_iterations, - worktree=worktree, - model=model, - agent=agent, idle_timeout=idle_timeout, max_duration=max_duration, max_tokens=max_tokens, - ascii=ascii, - no_color=no_color, - compact=compact, - verifier_model=verifier_model, - subagents=subagents, - session_id=session_id, + worktree=worktree, + max_iterations=max_iterations, resume=resume, - no_tui=no_tui, dry_run=dry_run, + no_tui=no_tui, max_tokens_per_iteration=max_tokens_per_iteration, max_turns_per_iteration=max_turns_per_iteration, max_budget_usd=max_budget_usd, @@ -1113,196 +221,14 @@ def _run_engine( notify_desktop=notify_desktop, converge_sweeps=converge_sweeps, workers=workers, + verbose=verbose, ) - max_iterations = kwargs["max_iterations"] - worktree = kwargs["worktree"] - model = kwargs["model"] - agent = kwargs["agent"] - idle_timeout = kwargs["idle_timeout"] - max_duration = kwargs["max_duration"] - max_tokens = kwargs["max_tokens"] - ascii = kwargs["ascii"] - no_color = kwargs["no_color"] - compact = kwargs["compact"] - verifier_model = kwargs["verifier_model"] - subagents = kwargs["subagents"] - session_id = kwargs["session_id"] - resume = kwargs["resume"] - no_tui = kwargs["no_tui"] - dry_run = kwargs["dry_run"] - 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"] - keep_retrying = kwargs["keep_retrying"] - rollback = kwargs["rollback"] - notify_webhook = kwargs["notify_webhook"] - notify_desktop = kwargs["notify_desktop"] - converge_sweeps = kwargs["converge_sweeps"] - workers = kwargs["workers"] - resolved_webhook = notify_webhook or os.environ.get("OWLOOP_NOTIFY_WEBHOOK") or None - if workers > 1: - _run_parallel( - workers=workers, model=model, agent=agent, idle_timeout=idle_timeout, - ascii=ascii, no_color=no_color, - notify_webhook=resolved_webhook, notify_desktop=notify_desktop, - ) - return - config = EngineConfig( - project_dir=Path.cwd(), - max_iterations=max_iterations, - max_duration_minutes=max_duration, - max_tokens=max_tokens, - max_tokens_per_iteration=max_tokens_per_iteration, - idle_timeout=idle_timeout, - worktree=worktree, - use_subagents=subagents, - session_id=session_id, - resume=resume, - dry_run=dry_run, - keep_retrying=keep_retrying, - rollback=rollback, - notify_webhook=resolved_webhook, - notify_desktop=notify_desktop, - converge_sweeps=converge_sweeps, - ) - adapter = get_adapter( - agent, - model=model, - claude_cmd=os.environ.get("CLAUDE_CMD", "claude"), - kimi_cmd=os.environ.get("KIMI_CMD", "kimi"), - idle_timeout=idle_timeout, - max_turns=max_turns_per_iteration or None, - max_budget_usd=max_budget_usd or None, - project_dir=Path.cwd(), - ) - - if verifier_model: - config.verifier_adapter = get_adapter( - agent, - model=verifier_model, - claude_cmd=os.environ.get("CLAUDE_CMD", "claude"), - kimi_cmd=os.environ.get("KIMI_CMD", "kimi"), - idle_timeout=idle_timeout, - project_dir=Path.cwd(), - ) - - report_path: Path | None = None - - if not no_tui and sys.stdout.isatty(): - tui = OwloopTUI(ascii=ascii, no_color=no_color, compact=compact) - - try: - with tui: - engine = OwloopEngine(config, adapter, on_event=tui.on_event) - summary = engine.run() - except KeyboardInterrupt: - console = Console(no_color=no_color) - console.print("\n[dim]owloop stopped.[/]") - raise SystemExit(0) from None - if summary.iterations > 0 and not config.dry_run: - report_path = _generate_quick_report(summary) - tui.print_exit_summary(summary, report_path=str(report_path) if report_path else None) - if config.dry_run: - _print_dry_run_report(tui.console, summary) - else: - console = Console(no_color=no_color) - console.print() - console.print(_banner_text(ascii=ascii, no_color=no_color)) - console.print(f"[{_brand.AMBER}]Starting autonomous loop...[/]") - - reporter = ConsoleReporter(console, ascii=ascii) - engine = OwloopEngine(config, adapter, on_event=reporter.on_event) - try: - summary = engine.run() - except KeyboardInterrupt: - console.print("\n[dim]owloop stopped.[/]") - raise SystemExit(0) from None - if summary.iterations > 0 and not config.dry_run: - report_path = _generate_quick_report(summary) - reporter.print_summary(summary, report_path=str(report_path) if report_path else None) - if config.dry_run: - _print_dry_run_report(console, summary) - if report_path: - console.print(f"Report: {report_path}") - exit_code = _exit_code_for_summary(summary) - if exit_code: - raise SystemExit(exit_code) - - - -def _run_parallel( - *, workers: int, model: str | None, agent: str, idle_timeout: float, - ascii: bool, no_color: bool, - notify_webhook: str | None, notify_desktop: bool, -) -> None: - """Run the file-disjoint parallel worker mode (`owloop run --workers N`).""" - from owloop.parallel import ParallelConfig, ParallelOrchestrator - - console = Console(no_color=no_color) - console.print() - console.print(_banner_text(ascii=ascii, no_color=no_color)) - console.print(f"[{_brand.AMBER}]Starting {workers} parallel workers...[/]") - - def _adapter_factory() -> Any: - # Fresh adapter per worker: adapters hold per-run streaming state, so - # concurrent workers must not share one instance. - return get_adapter( - agent, model=model, - claude_cmd=os.environ.get("CLAUDE_CMD", "claude"), - kimi_cmd=os.environ.get("KIMI_CMD", "kimi"), - idle_timeout=idle_timeout, - ) - - config = ParallelConfig( - project_dir=Path.cwd(), - workers=workers, - notify_webhook=notify_webhook, - notify_desktop=notify_desktop, - ) - reporter = ConsoleReporter(console, ascii=ascii) - orchestrator = ParallelOrchestrator(config, _adapter_factory, on_event=reporter.on_event) - try: - summary = orchestrator.run() - except KeyboardInterrupt: - console.print("\n[dim]owloop stopped.[/]") - raise SystemExit(0) from None - reporter.print_summary(summary) - exit_code = _exit_code_for_summary(summary) - if exit_code: - raise SystemExit(exit_code) - - -def _print_dry_run_report(console: Console, summary: RunSummary) -> None: - """Print the concise pass/fail report produced by ``--dry-run`` / ``--one-shot``.""" - report = summary.dry_run_report - if report is None: - return - - promise_line = ( - f"[{_brand.GREEN}]<promise>DONE</promise> emitted[/]" - if report.promise_done - else f"[{_brand.RED}]<promise>DONE</promise> not emitted[/]" - ) - lines = [promise_line] - if report.spec_name: - lines.append(f"Spec: {report.spec_name}") - lines.append( - f"Acceptance criteria: [{_brand.GREEN}]{report.acceptance_passed} passed[/] / " - f"[{_brand.RED}]{report.acceptance_failed} failed[/]" - ) - lines.append(f"Tokens used: {report.tokens_used}") - - console.print() - console.print( - Panel( - "\n".join(lines), - title="[bold]Dry-run report[/]", - border_style=_brand.AMBER, - padding=(1, 2), - ) - ) +@main.command() +def agents() -> None: + """List available coding-agent presets and whether they're ready to use.""" + agents_cmd() @main.command() @@ -1384,23 +310,11 @@ def run(max_iterations: int, resume: bool, dry_run: bool, no_tui: bool, max_toke 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() - specs_dir = resolve_specs_dir(Path.cwd()) - if not specs_dir.exists() or not list(specs_dir.glob("*.md")): - console = Console(no_color=no_color) - console.print("[red]Error:[/] No specs found. Create specs in [bold].owloop/specs/[/] first.") - console.print("[dim]Run [bold]owloop init[/] to get started.[/]") - raise SystemExit(1) - - _run_engine( - max_iterations, worktree, model, agent, - idle_timeout, max_duration, max_tokens, - ascii=ascii, no_color=no_color, compact=compact, - verifier_model=verifier_model, - subagents=subagents, + run_cmd( + max_iterations=max_iterations, resume=resume, - no_tui=no_tui, dry_run=dry_run, + no_tui=no_tui, max_tokens_per_iteration=max_tokens_per_iteration, max_turns_per_iteration=max_turns_per_iteration, max_budget_usd=max_budget_usd, @@ -1410,106 +324,21 @@ def run(max_iterations: int, resume: bool, dry_run: bool, no_tui: bool, max_toke notify_desktop=notify_desktop, converge_sweeps=converge_sweeps, workers=workers, + worktree=worktree, + model=model, + agent=agent, + verifier_model=verifier_model, + subagents=subagents, + idle_timeout=idle_timeout, + max_duration=max_duration, + max_tokens=max_tokens, ) @main.command() def status() -> None: """Show current specs and their completion status.""" - ascii, no_color, _compact, verbose = _cli_options() - console = Console(no_color=no_color) - specs_dir = resolve_specs_dir(Path.cwd()) - - if not specs_dir.exists(): - console.print("[dim]No specs directory. Run [bold]owloop init[/] first.[/]") - raise SystemExit(1) - - specs = sorted(specs_dir.glob("*.md")) - if not specs: - console.print("[dim]No spec files found in specs/.[/]") - raise SystemExit(0) - - console.print() - console.print(_banner_text(ascii=ascii, no_color=no_color)) - - from rich.table import Table - - table = Table( - title="Specs", - border_style=_brand.AMBER, - show_lines=False, - padding=(0, 2), - ) - table.add_column("", width=3) - table.add_column("File", style="bold") - table.add_column("Priority", justify="center") - table.add_column("Status", justify="center") - - state_display = { - "done": ("[green]✓[/]", "[green]done[/]"), - "in_progress": (f"[{_brand.AMBER}]🦉[/]", f"[{_brand.AMBER}]in progress[/]"), - "pending": ("[dim]○[/]", "[dim]pending[/]"), - } - - counts = {"done": 0, "in_progress": 0, "pending": 0} - - for spec_file in specs: - content = spec_file.read_text(encoding="utf-8") - state = classify_spec(content) - counts[state] += 1 - - priority = "—" - for line in content.splitlines(): - if line.strip().startswith("## Priority:"): - priority = line.split(":")[-1].strip() - break - - icon, status_text = state_display[state] - table.add_row(icon, spec_file.name, priority, status_text) - - total = len(specs) - console.print( - f" [green]✓ {counts['done']} done[/] · " - f"[{_brand.AMBER}]🦉 {counts['in_progress']} in progress[/] · " - f"[dim]○ {counts['pending']} pending[/]" - ) - console.print(f" {render_progress_bar(counts['done'], total, ascii=ascii)}") - console.print() - console.print(table) - console.print() - - # Runtime section - session = _read_latest_session(Path.cwd()) - if session: - from rich.table import Table as RuntimeTable - - runtime = RuntimeTable( - title="Runtime", - border_style=_brand.AMBER, - show_lines=False, - padding=(0, 2), - ) - runtime.add_column("Key", style="bold") - runtime.add_column("Value") - - session_id = session.get("session_id", "—") - branch = session.get("branch", "—") - session_status = session.get("status", "—") - stopped_reason = session.get("stopped_reason", session.get("status", "—")) - wt_path = _find_worktree_path(Path.cwd(), session) or Path("—") - resumable = session_status in ("running",) or stopped_reason in { - "interrupted", "stalled", "exhausted", "max_iterations_reached", - "max_duration_reached", "max_tokens_reached", "idle_timeout", - } - runtime.add_row("Session", str(session_id)) - runtime.add_row("Branch", str(branch)) - runtime.add_row("Worktree", str(wt_path)) - runtime.add_row("Stopped reason", str(stopped_reason)) - runtime.add_row("Resumable", "yes" if resumable else "no") - runtime.add_row("Iterations", str(session.get("iterations", "—"))) - runtime.add_row("Tokens used", str(session.get("tokens_used", "—"))) - console.print(runtime) - console.print() + status_cmd() @main.command() @@ -1521,132 +350,7 @@ def status() -> None: ) def finish(auto: bool) -> None: """Show the latest owloop session and optionally merge/push/cleanup.""" - ascii, no_color, _compact, _verbose = _cli_options() - console = Console(no_color=no_color) - project_dir = Path.cwd() - - session = _read_latest_session(project_dir) - if not session: - console.print("[red]Error:[/] No latest session found. Run [bold]owloop run[/] first.") - raise SystemExit(1) - - session_id = session.get("session_id", "—") - branch = session.get("branch", "—") - wt_path = _find_worktree_path(project_dir, session) - stopped_reason = session.get("stopped_reason", session.get("status", "—")) - iterations = session.get("iterations", 0) - - console.print() - console.print(_banner_text(ascii=ascii, no_color=no_color)) - - table = Table( - title="Latest owloop session", - border_style=_brand.AMBER, - show_lines=False, - padding=(0, 2), - ) - table.add_column("Key", style="bold") - table.add_column("Value") - table.add_row("Session", str(session_id)) - table.add_row("Branch", str(branch)) - table.add_row("Worktree", str(wt_path) if wt_path else "—") - table.add_row("Stopped reason", str(stopped_reason)) - table.add_row("Iterations", str(iterations)) - console.print(table) - console.print() - - # Diff summary - cwd = wt_path if wt_path and wt_path.is_dir() and branch.startswith("owloop/") else project_dir - commits = git_stats.get_recent_commits(cwd, iterations) - total_files, total_ins, total_del = git_stats.total_diff_stats(commits) - console.print( - f"Diff: {total_files} files · [green]+{total_ins}[/] · [red]-{total_del}[/]" - ) - for commit in commits: - console.print(f" [cyan]{commit.hash}[/] {commit.message}") - console.print() - - report_path = _default_report_path() - if report_path.exists(): - console.print(f"Report: {report_path}") - else: - console.print("Report: not generated") - console.print() - - # Base branch detection - base_branch = "main" - for candidate in ("main", "master"): - result = subprocess.run( - ["git", "show-ref", "--verify", f"refs/heads/{candidate}"], - cwd=project_dir, - capture_output=True, - ) - if result.returncode == 0: - base_branch = candidate - break - - console.print(f"[{_brand.AMBER}]Next steps:[/]") - console.print(f" review: git -C {project_dir} log --oneline {base_branch}..{branch}") - console.print(f" merge: git -C {project_dir} checkout {base_branch} && git merge {branch}") - console.print(f" push: git -C {project_dir} push origin {base_branch}") - if wt_path: - console.print(f" cleanup: git -C {project_dir} worktree remove {wt_path} && git -C {project_dir} branch -d {branch}") - console.print(" resume: owloop run --resume") - console.print() - - if not auto: - return - - # Auto mode: merge, push, remove worktree, delete branch. Stop on first failure. - console.print(f"[{_brand.AMBER}]Auto-finishing session...[/]") - - def _git_step(desc: str, cwd: Path, *args: str) -> bool: - console.print(f" {desc} ...", end=" ") - result = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True) - if result.returncode != 0: - console.print(f"[red]failed[/]\n{result.stderr.strip()}") - return False - console.print(f"[{_brand.GREEN}]ok[/]") - return True - - # Idempotent merge: skip if already merged. - merge_check = subprocess.run( - ["git", "merge-base", "--is-ancestor", branch, base_branch], - cwd=project_dir, - capture_output=True, - ) - already_merged = merge_check.returncode == 0 - - if not already_merged: - if not _git_step(f"checkout {base_branch}", project_dir, "checkout", base_branch): - raise SystemExit(1) - if not _git_step(f"merge {branch}", project_dir, "merge", "--no-ff", branch, "-m", f"owloop: merge {branch}"): - raise SystemExit(1) - else: - console.print(f" [dim]{branch} already merged into {base_branch}[/]") - - if not _git_step("push", project_dir, "push", "origin", base_branch): - raise SystemExit(1) - - if wt_path and wt_path.exists(): - # Remove worktree if it exists. - subprocess.run(["git", "worktree", "remove", "--force", str(wt_path)], cwd=project_dir, capture_output=True) - if wt_path.exists(): - shutil.rmtree(wt_path, ignore_errors=True) - - # Delete branch if it still exists. - branch_exists = subprocess.run( - ["git", "show-ref", "--verify", f"refs/heads/{branch.split('/', 1)[1]}"], - cwd=project_dir, - capture_output=True, - ).returncode == 0 - if branch_exists: - short_branch = branch.split("/", 1)[1] - if not _git_step(f"delete branch {short_branch}", project_dir, "branch", "-d", short_branch): - raise SystemExit(1) - - console.print(f"[{_brand.GREEN}]✓ Session finished.[/]") - console.print() + finish_cmd(auto=auto) @main.command() @@ -1655,106 +359,19 @@ def _git_step(desc: str, cwd: Path, *args: str) -> bool: @click.option("--patch", is_flag=True, default=False, help="Show the latest discarded patch.") def logs(iter_n: int | None, events: bool, patch: bool) -> None: """Inspect owloop log files.""" - ascii, no_color, _compact, _verbose = _cli_options() - console = Console(no_color=no_color) - logs_dir = resolve_logs_dir(Path.cwd()) - - if not logs_dir.exists(): - console.print("[red]Error:[/] No logs directory. Run [bold]owloop run[/] first.") - raise SystemExit(1) - - if patch: - patches = sorted(logs_dir.glob("iter_*_discarded.patch")) - if not patches: - console.print("[dim]No discarded patches found.[/]") - raise SystemExit(0) - latest = patches[-1] - console.print(latest.read_text(encoding="utf-8")) - raise SystemExit(0) - - if events: - events_path = logs_dir / "events.jsonl" - if not events_path.is_file(): - console.print("[red]Error:[/] events.jsonl not found.") - raise SystemExit(1) - lines = events_path.read_text(encoding="utf-8").splitlines() - for line in lines[-50:]: - if not line.strip(): - continue - try: - record = json.loads(line) - ts = record.get("ts", "—") - kind = record.get("kind", "—") - data = record.get("data", {}) - console.print(f"[{ts}] {kind}: {data}") - except json.JSONDecodeError: - console.print(line) - raise SystemExit(0) - - if iter_n is not None: - candidates = [p for p in logs_dir.glob("iter_*.log") if str(iter_n) in p.name] - if not candidates: - console.print(f"[red]Error:[/] No log found for iteration {iter_n}.") - raise SystemExit(1) - target = sorted(candidates)[-1] - console.print(target.read_text(encoding="utf-8")) - raise SystemExit(0) - - # Default: latest iteration log - log_files = sorted(logs_dir.glob("iter_*.log")) - if not log_files: - console.print("[dim]No iteration logs found.[/]") - raise SystemExit(0) - latest = log_files[-1] - console.print(latest.read_text(encoding="utf-8")) + logs_cmd(iter_n=iter_n, events=events, patch=patch) @main.command() def version() -> None: """Show the owloop version.""" - ascii, no_color, _compact, verbose = _cli_options() - console = Console(no_color=no_color) - from importlib.metadata import PackageNotFoundError - from importlib.metadata import version as pkg_version - - try: - v = pkg_version("owloop") - except PackageNotFoundError: - v = "0.0.0-dev" - - console.print() - console.print(_banner_text(ascii=ascii, no_color=no_color)) - console.print(f"[bold]owloop[/] [{_brand.AMBER}]v{v}[/]") - console.print() + version_cmd() @main.command() def discover() -> None: """Discover and save project verification commands.""" - ascii, no_color, _compact, verbose = _cli_options() - console = Console(no_color=no_color) - cwd = Path.cwd() - - try: - commands, path = discover_and_save(cwd) - except Exception as exc: - console.print(f"[red]Error:[/] failed to discover backpressure commands: {exc}") - raise SystemExit(1) from None - - console.print() - console.print(_banner_text(ascii=ascii, no_color=no_color)) - console.print(f"[bold]owloop[/] [{_brand.AMBER}]discover[/]") - console.print() - - if commands: - console.print(f"[green]✓ {len(commands)} command(s) discovered:[/]") - for cmd in commands: - console.print(f" [{_brand.CYAN}]{cmd.name}[/]: {cmd.command} [{_brand.GRAY}]{cmd.source}[/]") - else: - console.print("[dim]No verification commands discovered.[/]") - - console.print(f"[dim]Saved to[/] {path}") - console.print() + discover_cmd() @main.command() @@ -1771,46 +388,7 @@ def discover() -> None: ) def check(strict: bool, run_baseline: bool, review: bool) -> None: """Validate all specs before running the loop.""" - ascii, no_color, _compact, verbose = _cli_options() - console = Console(no_color=no_color) - specs_dir = resolve_specs_dir(Path.cwd()) - - if not specs_dir.is_dir(): - console.print() - console.print(_banner_text(ascii=ascii, no_color=no_color)) - console.print("[dim]No specs directory. Run [bold]owloop init[/] first.[/]") - console.print() - raise SystemExit(0) - - if review: - project_dir = specs_dir.parent - reviewer = SpecReview(specs_dir, project_dir=project_dir) - has_errors = False - has_warnings = False - for spec_file in sorted(specs_dir.glob("*.md")): - report = reviewer.review(spec_file, auto_fix=True) - console.print(f"\n[bold]Review:[/] {spec_file.name}") - if report.auto_fixed: - console.print(f" [green]✓ auto-fixed {len(report.auto_fixed)} issue(s)[/]") - for finding in report.findings: - icon = "✗" if finding.severity == "error" else "⚠" - color = _brand.RED if finding.severity == "error" else _brand.AMBER - console.print(f" [{color}]{icon}[/] {finding.message}") - if finding.severity == "error": - has_errors = True - else: - has_warnings = True - if has_errors or (strict and has_warnings): - raise SystemExit(1) - return - - linter = SpecLinter(specs_dir) - lint_report = linter.lint_all(run_baseline=run_baseline) - _print_check_report(console, lint_report, ascii=ascii) - - failed = lint_report.error_count > 0 or (strict and lint_report.warning_count > 0) - if failed: - raise SystemExit(1) + check_cmd(strict=strict, run_baseline=run_baseline, review=review) @main.command() @@ -1841,61 +419,8 @@ def check(strict: bool, run_baseline: bool, review: bool) -> None: show_default=True, ) def report(output: Path | None, ai: bool, open_report: bool, model: str) -> None: - """Generate an HTML summary report for the latest owloop run. - - By default the report includes AI-generated insights (summary, risks, - review focus) and is styled as a reviewable artifact. Use --no-ai for a - fast, offline report. - """ - ascii, no_color, _compact, verbose = _cli_options() - console = Console(no_color=no_color) - project_dir = Path.cwd() - - insights = None - if ai: - adapter = get_adapter( - "claude", - model=model, - claude_cmd=os.environ.get("CLAUDE_CMD", "claude"), - idle_timeout=DEFAULT_IDLE_TIMEOUT, - ) - ai_generator = AIReportInsightsGenerator(project_dir, adapter) - stream = AgentStreamDisplay(console, verbose=verbose) - - try: - stream.start() - insights = ai_generator.generate(on_line=stream.on_line) - except FileNotFoundError: - console.print("[red]Error:[/] No run summary found. Run [bold]owloop run[/] first.") - raise SystemExit(1) from None - except RuntimeError as exc: - console.print(f"[{_brand.AMBER}]⚠ AI insights failed:[/] {exc}") - console.print("[dim]Falling back to static report. Use --no-ai to skip AI.[/]") - finally: - stream.stop() - - if output is None and ai: - output = project_dir / ".owloop" / "reports" / "owloop_report.html" - - generator = ReportGenerator(project_dir) - try: - report_path = generator.generate(output, insights=insights, use_tailwind=ai) - except FileNotFoundError: - console.print("[red]Error:[/] No run summary found. Run [bold]owloop run[/] first.") - raise SystemExit(1) from None - - if open_report and ai: - import shutil - if shutil.which("lavish-axi"): - console.print(f"[{_brand.AMBER}]Opening report with lavish-axi...[/]") - subprocess.run(["lavish-axi", str(report_path)], check=False) - else: - console.print("[{_brand.AMBER}]⚠ lavish-axi not found:[/] report saved but not opened") - - console.print() - console.print(_banner_text(ascii=ascii, no_color=no_color)) - console.print(f"[{_brand.GREEN}]✓ Report generated:[/] {report_path}") - console.print() + """Generate an HTML summary report for the latest owloop run.""" + report_cmd(output=output, ai=ai, open_report=open_report, model=model) @main.command("spec-from-issue") @@ -1922,59 +447,7 @@ def spec_from_issue( dry_run: bool, ) -> None: """Generate a spec draft from a GitHub issue.""" - ascii, no_color, _compact, _verbose = _cli_options() - console = Console(no_color=no_color) - project_dir = Path.cwd() - - _ensure_init(project_dir, console, ascii=ascii) - - converter = IssueToSpecConverter(project_dir) - - try: - data = converter.from_github(issue, repo=repo) - except ValueError as exc: - console.print(f"[red]Error:[/] {exc}") - raise SystemExit(1) from None - except RuntimeError as exc: - console.print(f"[red]Error:[/] {exc}") - raise SystemExit(1) from None - - if dry_run: - try: - rendered = converter.render_spec(data) - except FileNotFoundError as exc: - console.print(f"[red]Error:[/] {exc}") - raise SystemExit(1) from None - console.print(rendered) - return - - try: - spec_path = converter.write_spec(data, output_path=output) - except FileNotFoundError as exc: - console.print(f"[red]Error:[/] {exc}") - raise SystemExit(1) from None - - console.print() - console.print(f"[{_brand.GREEN}]✓ Spec generated:[/] {spec_path}") - console.print() - - if sys.stdin.isatty() and not dry_run: - console.print("\n[bold]Run owloop check on this spec?[/] [y/N] ", end="") - try: - reply = input().strip().lower() or "n" - except EOFError: - reply = "n" - if reply.startswith("y"): - subprocess.run([sys.argv[0], "check"], cwd=project_dir) - return - - console.print("\n[bold]Start owloop run now?[/] [y/N] ", end="") - try: - reply = input().strip().lower() or "n" - except EOFError: - reply = "n" - if reply.startswith("y"): - subprocess.run([sys.argv[0], "run"], cwd=project_dir) + spec_from_issue_cmd(issue=issue, repo=repo, output=output, dry_run=dry_run) if __name__ == "__main__": diff --git a/src/owloop/cli_config.py b/src/owloop/cli_config.py new file mode 100644 index 0000000..0539179 --- /dev/null +++ b/src/owloop/cli_config.py @@ -0,0 +1,35 @@ +"""Persistent CLI configuration helpers.""" + +from pathlib import Path +from typing import Any + +from owloop.config import apply_config_defaults, load_run_config +from owloop.report import ReportGenerator + + +def _run_config_path() -> Path: + """Return the persistent run configuration file path.""" + return Path.cwd() / ".owloop" / "config.toml" + + +def _load_and_apply_run_config(**cli_kwargs: Any) -> dict[str, Any]: + """Load ``.owloop/config.toml`` and merge its ``[run]`` defaults into CLI kwargs.""" + config = load_run_config(_run_config_path()) + return apply_config_defaults(cli_kwargs, config) + + +def _default_report_path() -> Path: + """Return the default quick-report output path.""" + return Path.cwd() / ".owloop" / "reports" / "owloop_report_latest.html" + + +def _generate_quick_report(summary: Any) -> Path | None: + """Generate a fast, no-AI HTML report and return its path, or None on failure.""" + try: + report_path = _default_report_path() + report_path.parent.mkdir(parents=True, exist_ok=True) + generator = ReportGenerator(summary.main_repo_dir) + generator.generate(report_path, insights=None, use_tailwind=False) + return report_path + except Exception: + return None diff --git a/src/owloop/cli_display.py b/src/owloop/cli_display.py new file mode 100644 index 0000000..5ce2efb --- /dev/null +++ b/src/owloop/cli_display.py @@ -0,0 +1,246 @@ +"""Display helpers for the owloop CLI.""" + +import sys +import threading +import time +from pathlib import Path + +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +from owloop import _brand +from owloop.sessions import classify_spec + + +def _banner_text(ascii: bool = False, no_color: bool = False) -> Text | str: + """Return the owloop banner.""" + joined = ( + _brand.BRAND_BAR_ASCII + if ascii + else f"{_brand.OWL_EMOJI} {_brand.BRAND_BAR}" + ) + if no_color: + return joined + return Text.from_markup(f"[bold {_brand.AMBER}]{joined}[/]") + + +def render_progress_bar(done: int, total: int, width: int = 20, ascii: bool = False) -> str: + filled = round(width * done / total) if total else 0 + filled = max(0, min(width, filled)) + pct = round(done / total * 100) if total else 0 + moon = _brand.ascii_moon_for_progress(done, total) if ascii else _brand.moon_for_progress(done, total) + return ( + f"{moon} [{_brand.AMBER}]{'█' * filled}[/][{_brand.GRAY}]{'░' * (width - filled)}[/] {pct}%" + ) + + +def _format_spec_table(spec_paths: list[Path], verbose: bool = False) -> Table | list[Panel]: + """Return either a compact Table or full Panels for the generated specs.""" + if verbose: + return [ + Panel( + sp.read_text(encoding="utf-8"), + title=f"[bold]{sp.name}[/]", + border_style=_brand.AMBER, + padding=(1, 2), + ) + for sp in spec_paths + ] + + table = Table( + title="Generated specs", + border_style=_brand.AMBER, + show_lines=False, + padding=(0, 2), + ) + table.add_column("File", style="bold") + table.add_column("Title") + table.add_column("Priority", justify="center") + table.add_column("Status", justify="center") + + for sp in spec_paths: + content = sp.read_text(encoding="utf-8") + state = classify_spec(content) + status_text = { + "done": "[green]done[/]", + "in_progress": f"[{_brand.AMBER}]in progress[/]", + "pending": "[dim]pending[/]", + }[state] + priority = "—" + title: str | None = None + for line in content.splitlines(): + if line.strip().startswith("## Priority:"): + priority = line.split(":", 1)[-1].strip() + if line.startswith("# Spec:"): + title = line.split(":", 1)[-1].strip() + table.add_row(sp.name, title or "—", priority, status_text) + + return table + + +class AgentStreamDisplay: + """Live display for streaming agent output. + + Layout: gray output lines scroll above, status bar stays at the bottom. + + [reading backend/app/api/orders.py] ← scrolling gray + Found 23 repeated try/except blocks ← scrolling gray + [running: grep -c "except" *.py] ← scrolling gray + ⠋ 0:32 · ~1.2k tokens ← always at bottom + """ + + BURST_THRESHOLD = 8 + SPINNERS = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" + + def __init__(self, console: Console, *, verbose: bool = False) -> None: + self.console = console + self.verbose = verbose + self.start_time = time.monotonic() + self._last_output = time.monotonic() + self._burst_count = 0 + self._burst_suppressed = 0 + self._line_count = 0 + self._char_count = 0 + self._real_tokens = "" + self._has_status = False + self._frame = 0 + self._lock = threading.Lock() + self._stop = threading.Event() + self._ticker: threading.Thread | None = None + self._out = console.file or sys.stdout + + def start(self) -> None: + self._draw_status() + self._ticker = threading.Thread(target=self._tick, daemon=True) + self._ticker.start() + + def stop(self) -> None: + self._stop.set() + if self._ticker: + self._ticker.join(timeout=2) + with self._lock: + self._clear_status() + self._flush_burst() + + @staticmethod + def _is_noise(text: str) -> bool: + if len(text) < 3 or not any(c.isalnum() for c in text): + return True + return "<promise>" in text + + def on_line(self, line: str) -> None: + stripped = line.strip() + if not stripped or self._is_noise(stripped): + return + + with self._lock: + now = time.monotonic() + gap = now - self._last_output + self._last_output = now + self._line_count += 1 + self._char_count += len(stripped) + + if stripped.startswith("[usage:"): + self._real_tokens = stripped[8:-1] + self._clear_status() + self._draw_status() + return + + if self.verbose: + elapsed = now - self.start_time + self._clear_status() + self._print_line(f" [{elapsed:.1f}s] {stripped}") + self._draw_status() + return + + if gap < 0.05: + self._burst_count += 1 + if self._burst_count > self.BURST_THRESHOLD: + self._burst_suppressed += 1 + return + else: + if self._burst_suppressed > 0: + self._clear_status() + self._flush_burst() + self._burst_count = 0 + + self._clear_status() + self._print_line(f" {stripped}") + self._draw_status() + + def _flush_burst(self) -> None: + if self._burst_suppressed > 0: + self._print_line(f" ... ({self._burst_suppressed} lines)") + self._burst_suppressed = 0 + + def _print_line(self, text: str) -> None: + self._out.write(f"\033[90m{text}\033[0m\n") + self._out.flush() + + def _clear_status(self) -> None: + if self._has_status: + self._out.write("\r\033[K") + self._out.flush() + self._has_status = False + + def _build_status(self) -> str: + elapsed = int(time.monotonic() - self.start_time) + mins, secs = divmod(elapsed, 60) + self._frame = (self._frame + 1) % len(self.SPINNERS) + spinner = self.SPINNERS[self._frame] + parts = [f" {spinner} {mins}:{secs:02d}"] + if self._real_tokens: + parts.append(self._real_tokens) + else: + est = self._char_count // 4 + if est >= 1000: + parts.append(f"~{est / 1000:.1f}k tokens") + elif est > 0: + parts.append(f"~{est} tokens") + parts.append(f"{self._line_count} lines") + return " · ".join(parts) + + def _draw_status(self) -> None: + status = self._build_status() + self._out.write(f"\r\033[K{status}") + self._out.flush() + self._has_status = True + + def _tick(self) -> None: + while not self._stop.wait(0.5): + with self._lock: + if self._has_status: + self._out.write(f"\r\033[K{self._build_status()}") + self._out.flush() + + +def _ensure_init(cwd: Path, console: Console, *, ascii: bool = False) -> None: + """Auto-initialize .owloop/ if it doesn't exist (silent, no example spec).""" + owloop_path = cwd / ".owloop" + if owloop_path.exists(): + return + + if not (cwd / ".git").exists(): + console.print("[red]Error:[/] Not a git repository. Run [bold]git init[/] first.") + raise SystemExit(1) + + owloop_path.mkdir(parents=True) + (owloop_path / "specs").mkdir() + (owloop_path / "logs").mkdir() + + gitignore = cwd / ".gitignore" + gitignore_entries = [".owloop/"] + if gitignore.exists(): + existing = gitignore.read_text(encoding="utf-8") + to_add = [e for e in gitignore_entries if e not in existing] + if to_add: + with open(gitignore, "a", encoding="utf-8") as f: + f.write("\n# owloop\n") + for entry in to_add: + f.write(f"{entry}\n") + else: + gitignore.write_text("# owloop\n" + "\n".join(gitignore_entries) + "\n", encoding="utf-8") + + console.print(f"[{_brand.GREEN}]✓[/] Initialized .owloop/") diff --git a/src/owloop/cli_options.py b/src/owloop/cli_options.py new file mode 100644 index 0000000..cafa9bf --- /dev/null +++ b/src/owloop/cli_options.py @@ -0,0 +1,205 @@ +"""Shared Click options and parameter types for the owloop CLI.""" + +import os +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import click + +from owloop.adapters import DEFAULT_IDLE_TIMEOUT + +DEFAULT_MODEL = os.environ.get("CLAUDE_MODEL", "claude-sonnet-5") + +MAX_TOKENS_UNITS = { + "k": 1_000, + "w": 10_000, + "m": 1_000_000, +} + + +def parse_max_tokens(value: str) -> int: + """Parse a token limit, supporting shorthand like 10k, 1w, 2m. + + Args: + value: Raw user input. Plain integers pass through; suffixes + ``k`` (thousand), ``w`` (ten-thousand), and ``m`` (million) + are expanded. + + Returns: + Token count as an integer. + + Raises: + click.BadParameter: if the value cannot be parsed. + """ + value = value.strip().lower() + if not value: + raise click.BadParameter("token limit cannot be empty") + + if value.isdigit(): + return int(value) + + suffix = value[-1] + if suffix not in MAX_TOKENS_UNITS: + raise click.BadParameter( + f"invalid token limit: {value!r}. Use a number or add k/w/m (e.g. 10k, 1w, 2m)" + ) + + number_part = value[:-1] + if not number_part or not number_part.replace(".", "", 1).isdigit(): + raise click.BadParameter( + f"invalid token limit: {value!r}. Use a number or add k/w/m (e.g. 10k, 1w, 2m)" + ) + + number = float(number_part) + return int(number * MAX_TOKENS_UNITS[suffix]) + + +class MaxTokensParamType(click.ParamType): + """Click parameter type that parses token limit shorthand.""" + + name = "tokens" + + def convert(self, value: object, param: click.Parameter | None, ctx: click.Context | None) -> int: + if isinstance(value, int): + return value + return parse_max_tokens(str(value)) + + +def _cli_options() -> tuple[bool, bool, bool, bool]: + """Read global --ascii / --no-color / --compact / --verbose flags from the current Click context.""" + ctx = click.get_current_context() + obj = ctx.ensure_object(dict) + return bool(obj.get("ascii")), bool(obj.get("no_color")), bool(obj.get("compact")), bool(obj.get("verbose")) + + +def _validate_agent(ctx: click.Context, param: click.Parameter, value: str) -> str: + """Validate --agent against builtin + user presets (.owloop/agents.toml).""" + from owloop.presets import all_presets + + keys = sorted(all_presets(Path.cwd())) + if value not in keys: + raise click.BadParameter(f"unknown agent {value!r}. Available: {', '.join(keys)}") + return value + + +def _agent_run_options(f: Callable[..., Any]) -> Callable[..., Any]: + """Shared non-model run options for run, go, and spec.""" + f = click.option( + "--agent", default="claude", metavar="AGENT", callback=_validate_agent, + help="Coding agent preset (see `owloop agents` for the full list).", + show_default=True, + )(f) + f = click.option( + "--verifier-model", + help="Claude model for the independent verifier agent (defaults to --model).", + default=None, + )(f) + f = click.option( + "--subagents", + is_flag=True, + default=False, + help="Split large iterations into Orient/Implement/Verify subagent phases.", + )(f) + f = click.option( + "--idle-timeout", type=float, default=DEFAULT_IDLE_TIMEOUT, + help="Kill agent after N seconds without output.", show_default=True, + )(f) + f = click.option( + "--max-duration", type=int, default=0, + help="Stop loop after N minutes total (0 = unlimited).", show_default=True, + )(f) + f = click.option( + "--max-tokens", type=MaxTokensParamType(), default=0, + help="Stop loop after N total tokens (0 = unlimited; supports k/w/m shorthand).", show_default=True, + )(f) + f = click.option( + "--worktree/--no-worktree", default=True, + help="Run in an isolated git worktree.", show_default=True, + )(f) + return f + + +def _common_run_options(f: Callable[..., Any]) -> Callable[..., Any]: + """Shared options for the run and go commands (includes --model default None).""" + f = click.option( + "--model", default=None, metavar="MODEL", + help="Model to use (defaults per agent; claude honors CLAUDE_MODEL).", + )(f) + return _agent_run_options(f) + + +def _extra_run_options(f: Callable[..., Any]) -> Callable[..., Any]: + """Additional run options forwarded by run, go, and spec.""" + f = click.option( + "--max-iterations", "-n", type=int, default=0, + help="Maximum iterations (0 = unlimited).", show_default=True, + )(f) + f = click.option( + "--resume", + is_flag=True, + default=False, + help="Resume the most recent owloop session (reuse its worktree and branch).", + )(f) + f = click.option( + "--dry-run", "--one-shot", "dry_run", + is_flag=True, + default=False, + help="Run exactly one iteration, print a pass/fail report, and skip push " + "(no committed changes are left behind). Use to validate specs without " + "burning a full overnight run.", + )(f) + f = click.option( + "--no-tui", "--plain", "no_tui", + is_flag=True, + default=False, + help="Bypass the full-screen TUI and print plain console output, even in a TTY.", + )(f) + f = click.option( + "--max-tokens-per-iteration", type=MaxTokensParamType(), default=0, + help="Kill a single iteration early if it exceeds N tokens (0 = unlimited; " + "supports k/w/m shorthand).", show_default=True, + )(f) + f = click.option( + "--max-turns-per-iteration", type=int, default=0, + help="Forward --max-turns N to `claude -p` so a single iteration is bounded " + "at the source (0 = unlimited; ignored on CLIs without the flag).", + show_default=True, + )(f) + f = click.option( + "--max-budget-usd", type=float, default=0.0, + help="Forward a per-iteration USD budget cap to the CLI when supported " + "(0 = unlimited).", show_default=True, + )(f) + f = click.option( + "--keep-retrying", is_flag=True, default=False, + help="Legacy behavior: warn and back off on repeated failures instead of " + "hard-stopping with a `stalled` terminal state.", + )(f) + f = click.option( + "--rollback/--no-rollback", default=True, show_default=True, + help="Reset the worktree to the last good commit after a failed iteration " + "(a discarded-diff patch is saved under .owloop/logs/).", + )(f) + f = click.option( + "--notify-webhook", default=None, metavar="URL", + help="POST a JSON completion notification to this webhook when the run stops " + "on an attention-worthy state (or set OWLOOP_NOTIFY_WEBHOOK).", + )(f) + f = click.option( + "--notify-desktop", is_flag=True, default=False, + help="Fire a native desktop notification when the run stops.", + )(f) + f = click.option( + "--converge", "converge_sweeps", type=int, default=0, metavar="N", + help="After the spec queue empties, run up to N audit sweeps that append gap " + "specs until the codebase converges on the goal (0 = disabled).", + show_default=True, + )(f) + f = click.option( + "--workers", type=int, default=1, metavar="N", + help="Run up to N file-disjoint specs concurrently, each in its own worktree " + "(1 = sequential). Specs need a `## Files` scope to be scheduled in parallel.", + show_default=True, + )(f) + return f diff --git a/src/owloop/commands/__init__.py b/src/owloop/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/owloop/commands/agents.py b/src/owloop/commands/agents.py new file mode 100644 index 0000000..f17898e --- /dev/null +++ b/src/owloop/commands/agents.py @@ -0,0 +1,56 @@ +"""Implementation of the ``owloop agents`` command.""" + +import os +import shutil as _shutil +from pathlib import Path + +from rich.console import Console +from rich.table import Table + +from owloop import _brand +from owloop.cli_options import _cli_options +from owloop.presets import all_presets + + +def agents_cmd() -> None: + """List available coding-agent presets and whether they're ready to use. + + Two integration paths exist: native adapters (claude, kimi) and ACP — + the Agent Client Protocol (https://agentclientprotocol.com) — which + covers every other preset with a single implementation. Add your own + ACP agents in ``.owloop/agents.toml``. + """ + _ascii, no_color, _compact, _verbose = _cli_options() + console = Console(no_color=no_color) + + table = Table(border_style=_brand.AMBER, header_style=f"bold {_brand.AMBER}") + table.add_column("agent") + table.add_column("kind") + table.add_column("command") + table.add_column("model") + table.add_column("status") + + for key, preset in sorted(all_presets(Path.cwd()).items()): + binary = preset.cmd[0] + problems: list[str] = [] + if not _shutil.which(binary): + problems.append(f"{binary} not found") + missing = [v for v in preset.required_env_vars() if os.environ.get(v) is None] + if missing: + problems.append(f"set {', '.join(missing)}") + status = f"[red]✗ {'; '.join(problems)}[/]" if problems else f"[{_brand.GREEN}]✓ ready[/]" + if preset.experimental and not problems: + status += " [dim](experimental)[/]" + table.add_row( + f"[bold]{key}[/]", + preset.kind, + " ".join(preset.cmd), + preset.default_model or "[dim]agent default[/]", + status, + ) + + console.print(table) + console.print( + "[dim]Use with[/] [bold]owloop run --agent <agent>[/]" + "[dim]; define custom agents in .owloop/agents.toml[/]" + ) diff --git a/src/owloop/commands/check.py b/src/owloop/commands/check.py new file mode 100644 index 0000000..ced4bc3 --- /dev/null +++ b/src/owloop/commands/check.py @@ -0,0 +1,91 @@ +"""Implementation of the ``owloop check`` command.""" + +from pathlib import Path + +from rich.console import Console + +from owloop import _brand +from owloop.cli_display import _banner_text +from owloop.cli_options import _cli_options +from owloop.paths import resolve_specs_dir +from owloop.spec_linter import LintReport, SpecLinter +from owloop.spec_review import SpecReview + + +def _print_check_report(console: Console, report: LintReport, *, ascii: bool = False) -> None: + """Render a Rich report from a SpecLinter lint result.""" + check_icon = "+" if ascii else "✓" + error_icon = "x" if ascii else "✗" + warn_icon = "!" if ascii else "⚠" + + console.print() + console.print("[bold]owloop check[/]") + console.print(f"[dim]{'─' * 12}[/]") + console.print(f"{check_icon} {report.spec_count} specs scanned") + + if report.error_count or report.warning_count: + console.print( + f"[{_brand.RED}]{error_icon} {report.error_count} error" + f"{'s' if report.error_count != 1 else ''}[/], " + f"[{_brand.AMBER}]{warn_icon} {report.warning_count} warning" + f"{'s' if report.warning_count != 1 else ''}[/]" + ) + else: + console.print(f"[{_brand.GREEN}]{check_icon} 0 errors, 0 warnings[/]") + + for file_name, findings in report.results.items(): + if not findings: + continue + console.print() + console.print(file_name) + for finding in findings: + if finding.severity == "error": + icon = f"[{_brand.RED}]{error_icon}[/]" + else: + icon = f"[{_brand.AMBER}]{warn_icon}[/]" + console.print(f" {icon} {finding.message}") + console.print() + + +def check_cmd(strict: bool, run_baseline: bool, review: bool) -> None: + """Validate all specs before running the loop.""" + ascii, no_color, _compact, verbose = _cli_options() + console = Console(no_color=no_color) + specs_dir = resolve_specs_dir(Path.cwd()) + + if not specs_dir.is_dir(): + console.print() + console.print(_banner_text(ascii=ascii, no_color=no_color)) + console.print("[dim]No specs directory. Run [bold]owloop init[/] first.[/]") + console.print() + raise SystemExit(0) + + if review: + project_dir = specs_dir.parent + reviewer = SpecReview(specs_dir, project_dir=project_dir) + has_errors = False + has_warnings = False + for spec_file in sorted(specs_dir.glob("*.md")): + report = reviewer.review(spec_file, auto_fix=True) + console.print(f"\n[bold]Review:[/] {spec_file.name}") + if report.auto_fixed: + console.print(f" [green]✓ auto-fixed {len(report.auto_fixed)} issue(s)[/]") + for finding in report.findings: + icon = "✗" if finding.severity == "error" else "⚠" + color = _brand.RED if finding.severity == "error" else _brand.AMBER + console.print(f" [{color}]{icon}[/] {finding.message}") + if finding.severity == "error": + has_errors = True + else: + has_warnings = True + if has_errors or (strict and has_warnings): + raise SystemExit(1) + return + + linter = SpecLinter(specs_dir) + lint_report = linter.lint_all(run_baseline=run_baseline) + _print_check_report(console, lint_report, ascii=ascii) + + failed = lint_report.error_count > 0 or (strict and lint_report.warning_count > 0) + if failed: + raise SystemExit(1) diff --git a/src/owloop/commands/discover.py b/src/owloop/commands/discover.py new file mode 100644 index 0000000..56aa6e4 --- /dev/null +++ b/src/owloop/commands/discover.py @@ -0,0 +1,38 @@ +"""Implementation of the ``owloop discover`` command.""" + +from pathlib import Path + +from rich.console import Console + +from owloop import _brand +from owloop.backpressure import discover_and_save +from owloop.cli_display import _banner_text +from owloop.cli_options import _cli_options + + +def discover_cmd() -> None: + """Discover and save project verification commands.""" + ascii, no_color, _compact, verbose = _cli_options() + console = Console(no_color=no_color) + cwd = Path.cwd() + + try: + commands, path = discover_and_save(cwd) + except Exception as exc: + console.print(f"[red]Error:[/] failed to discover backpressure commands: {exc}") + raise SystemExit(1) from None + + console.print() + console.print(_banner_text(ascii=ascii, no_color=no_color)) + console.print(f"[bold]owloop[/] [{_brand.AMBER}]discover[/]") + console.print() + + if commands: + console.print(f"[green]✓ {len(commands)} command(s) discovered:[/]") + for cmd in commands: + console.print(f" [{_brand.CYAN}]{cmd.name}[/]: {cmd.command} [{_brand.GRAY}]{cmd.source}[/]") + else: + console.print("[dim]No verification commands discovered.[/]") + + console.print(f"[dim]Saved to[/] {path}") + console.print() diff --git a/src/owloop/commands/finish.py b/src/owloop/commands/finish.py new file mode 100644 index 0000000..abdbd25 --- /dev/null +++ b/src/owloop/commands/finish.py @@ -0,0 +1,144 @@ +"""Implementation of the ``owloop finish`` command.""" + +import shutil +import subprocess +from pathlib import Path + +from rich.console import Console +from rich.table import Table + +from owloop import _brand, git_stats +from owloop.cli_config import _default_report_path +from owloop.cli_display import _banner_text +from owloop.cli_options import _cli_options +from owloop.sessions import _find_worktree_path, _read_latest_session + + +def finish_cmd(auto: bool) -> None: + """Show the latest owloop session and optionally merge/push/cleanup.""" + ascii, no_color, _compact, _verbose = _cli_options() + console = Console(no_color=no_color) + project_dir = Path.cwd() + + session = _read_latest_session(project_dir) + if not session: + console.print("[red]Error:[/] No latest session found. Run [bold]owloop run[/] first.") + raise SystemExit(1) + + session_id = session.get("session_id", "—") + branch = session.get("branch", "—") + wt_path = _find_worktree_path(project_dir, session) + stopped_reason = session.get("stopped_reason", session.get("status", "—")) + iterations = session.get("iterations", 0) + + console.print() + console.print(_banner_text(ascii=ascii, no_color=no_color)) + + table = Table( + title="Latest owloop session", + border_style=_brand.AMBER, + show_lines=False, + padding=(0, 2), + ) + table.add_column("Key", style="bold") + table.add_column("Value") + table.add_row("Session", str(session_id)) + table.add_row("Branch", str(branch)) + table.add_row("Worktree", str(wt_path) if wt_path else "—") + table.add_row("Stopped reason", str(stopped_reason)) + table.add_row("Iterations", str(iterations)) + console.print(table) + console.print() + + # Diff summary + cwd = wt_path if wt_path and wt_path.is_dir() and branch.startswith("owloop/") else project_dir + commits = git_stats.get_recent_commits(cwd, iterations) + total_files, total_ins, total_del = git_stats.total_diff_stats(commits) + console.print( + f"Diff: {total_files} files · [green]+{total_ins}[/] · [red]-{total_del}[/]" + ) + for commit in commits: + console.print(f" [cyan]{commit.hash}[/] {commit.message}") + console.print() + + report_path = _default_report_path() + if report_path.exists(): + console.print(f"Report: {report_path}") + else: + console.print("Report: not generated") + console.print() + + # Base branch detection + base_branch = "main" + for candidate in ("main", "master"): + result = subprocess.run( + ["git", "show-ref", "--verify", f"refs/heads/{candidate}"], + cwd=project_dir, + capture_output=True, + ) + if result.returncode == 0: + base_branch = candidate + break + + console.print(f"[{_brand.AMBER}]Next steps:[/]") + console.print(f" review: git -C {project_dir} log --oneline {base_branch}..{branch}") + console.print(f" merge: git -C {project_dir} checkout {base_branch} && git merge {branch}") + console.print(f" push: git -C {project_dir} push origin {base_branch}") + if wt_path: + console.print(f" cleanup: git -C {project_dir} worktree remove {wt_path} && git -C {project_dir} branch -d {branch}") + console.print(" resume: owloop run --resume") + console.print() + + if not auto: + return + + # Auto mode: merge, push, remove worktree, delete branch. Stop on first failure. + console.print(f"[{_brand.AMBER}]Auto-finishing session...[/]") + + def _git_step(desc: str, cwd: Path, *args: str) -> bool: + console.print(f" {desc} ...", end=" ") + result = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True) + if result.returncode != 0: + console.print(f"[red]failed[/]\n{result.stderr.strip()}") + return False + console.print(f"[{_brand.GREEN}]ok[/]") + return True + + # Idempotent merge: skip if already merged. + merge_check = subprocess.run( + ["git", "merge-base", "--is-ancestor", branch, base_branch], + cwd=project_dir, + capture_output=True, + ) + already_merged = merge_check.returncode == 0 + + if not already_merged: + if not _git_step(f"checkout {base_branch}", project_dir, "checkout", base_branch): + raise SystemExit(1) + if not _git_step(f"merge {branch}", project_dir, "merge", "--no-ff", branch, "-m", f"owloop: merge {branch}"): + raise SystemExit(1) + else: + console.print(f" [dim]{branch} already merged into {base_branch}[/]") + + if not _git_step("push", project_dir, "push", "origin", base_branch): + raise SystemExit(1) + + if wt_path and wt_path.exists(): + # Remove worktree if it exists. + subprocess.run(["git", "worktree", "remove", "--force", str(wt_path)], cwd=project_dir, capture_output=True) + if wt_path.exists(): + shutil.rmtree(wt_path, ignore_errors=True) + + # Delete branch if it still exists. + branch_exists = subprocess.run( + ["git", "show-ref", "--verify", f"refs/heads/{branch.split('/', 1)[1]}"], + cwd=project_dir, + capture_output=True, + ).returncode == 0 + if branch_exists: + short_branch = branch.split("/", 1)[1] + if not _git_step(f"delete branch {short_branch}", project_dir, "branch", "-d", short_branch): + raise SystemExit(1) + + console.print(f"[{_brand.GREEN}]✓ Session finished.[/]") + console.print() diff --git a/src/owloop/commands/go.py b/src/owloop/commands/go.py new file mode 100644 index 0000000..5499978 --- /dev/null +++ b/src/owloop/commands/go.py @@ -0,0 +1,132 @@ +"""Implementation of the ``owloop go`` command.""" + +import os +from pathlib import Path + +from rich.console import Console +from rich.table import Table + +from owloop import _brand +from owloop.adapters import get_adapter +from owloop.cli_config import _load_and_apply_run_config +from owloop.cli_display import ( + AgentStreamDisplay, + _banner_text, + _ensure_init, + _format_spec_table, +) +from owloop.cli_options import DEFAULT_MODEL, _cli_options +from owloop.commands.run import _run_engine +from owloop.spec_generator import SpecGenerationError, SpecGenerator + + +def go_cmd( + goal: str, + agent: str, + model: str, + verifier_model: str | None, + subagents: bool, + idle_timeout: float, + max_duration: int, + max_tokens: int, + worktree: bool, + 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, + verbose: bool, +) -> None: + """One command: init → generate spec(s) → review → start the loop. + + \b + Example: + owloop go "refactor error handling in the API layer" + """ + ascii, no_color, compact, cli_verbose = _cli_options() + verbose = verbose or cli_verbose + console = Console(no_color=no_color) + project_dir = Path.cwd() + + console.print() + console.print(_banner_text(ascii=ascii, no_color=no_color)) + console.print(f"[dim]Goal:[/] {goal}\n") + + _ensure_init(project_dir, console, ascii=ascii) + + # Spec generation always runs on Claude Code today; only forward --model + # when it was meant for Claude, not e.g. a GLM/DeepSeek model id. + adapter = get_adapter( + "claude", + model=model if agent == "claude" else None, + claude_cmd=os.environ.get("CLAUDE_CMD", "claude"), + idle_timeout=idle_timeout, + ) + generator = SpecGenerator(project_dir, adapter) + stream = AgentStreamDisplay(console, verbose=verbose) + + if verbose: + console.print( + f" [dim]→ spawning: claude -p --model {model or DEFAULT_MODEL} --permission-mode auto[/]" + ) + console.print(f" [dim]→ cwd: {project_dir}[/]") + + try: + stream.start() + spec_paths = generator.generate(goal, on_line=stream.on_line) + except SpecGenerationError as exc: + console.print(f"\n[red]Error:[/] {exc}") + raise SystemExit(1) from None + finally: + stream.stop() + + console.print() + console.print(f"[{_brand.GREEN}]✓ {len(spec_paths)} spec(s) generated:[/]") + for sp in spec_paths: + console.print(f" [{_brand.CYAN}]{sp.name}[/]") + console.print() + + display = _format_spec_table(spec_paths, verbose=verbose) + if isinstance(display, Table): + console.print(display) + else: + for panel in display: + console.print(panel) + + run_kwargs = _load_and_apply_run_config( + max_iterations=max_iterations, + worktree=worktree, + model=model, + agent=agent, + idle_timeout=idle_timeout, + max_duration=max_duration, + max_tokens=max_tokens, + ascii=ascii, + no_color=no_color, + compact=compact, + verifier_model=verifier_model, + subagents=subagents, + resume=resume, + no_tui=no_tui, + dry_run=dry_run, + max_tokens_per_iteration=max_tokens_per_iteration, + max_turns_per_iteration=max_turns_per_iteration, + max_budget_usd=max_budget_usd, + keep_retrying=keep_retrying, + rollback=rollback, + notify_webhook=notify_webhook, + notify_desktop=notify_desktop, + converge_sweeps=converge_sweeps, + workers=workers, + ) + + console.print(f"\n[{_brand.AMBER}]Starting autonomous loop...[/]") + _run_engine(**run_kwargs) diff --git a/src/owloop/commands/init.py b/src/owloop/commands/init.py new file mode 100644 index 0000000..a8f523d --- /dev/null +++ b/src/owloop/commands/init.py @@ -0,0 +1,126 @@ +"""Implementation of the ``owloop init`` command.""" + +from pathlib import Path + +from rich.console import Console +from rich.panel import Panel + +from owloop import _brand +from owloop.backpressure import discover_and_save +from owloop.cli_display import _banner_text +from owloop.cli_options import _cli_options + +SPEC_TEMPLATE = """\ +# Spec: {name} + +## Priority: {priority} + +## Requirements +- [ ] TODO: describe what needs to be done + +## Acceptance Criteria +- [ ] TODO: shell command → expected output + Verify: `echo "replace with real verification command"` + +## Exclusions +- Do NOT modify files outside the scope described above +- Do NOT change any external API behavior +- Do NOT modify pyproject.toml, uv.lock, or other config files + +## Style +- Follow existing project conventions + +## Verification +The loop re-runs the Acceptance Criteria commands itself and commits only when +they pass — you never commit, push, or mark this spec COMPLETE. Do not edit this +section or the Acceptance Criteria mid-iteration. + +Output when complete: `<promise>DONE</promise>` +""" + + +def init_cmd(example: bool) -> None: + """Initialize owloop in the current project. + + Creates a single ``.owloop/`` metadata directory in the project root. + All specs, logs, and runtime prompts live inside it so the original + project stays clean. + """ + ascii, no_color, _compact, verbose = _cli_options() + console = Console(no_color=no_color) + cwd = Path.cwd() + + if not (cwd / ".git").exists(): + console.print("[red]Error:[/] Not a git repository. Run [bold]git init[/] first.") + raise SystemExit(1) + + owloop_path = cwd / ".owloop" + specs_path = owloop_path / "specs" + logs_path = owloop_path / "logs" + + created = [] + + if not owloop_path.exists(): + owloop_path.mkdir(parents=True) + created.append(".owloop/") + + if not specs_path.exists(): + specs_path.mkdir(parents=True) + created.append(".owloop/specs/") + + if not logs_path.exists(): + logs_path.mkdir(parents=True) + created.append(".owloop/logs/") + + gitignore = cwd / ".gitignore" + gitignore_entries = [".owloop/"] + if gitignore.exists(): + existing = gitignore.read_text(encoding="utf-8") + to_add = [e for e in gitignore_entries if e not in existing] + if to_add: + with open(gitignore, "a", encoding="utf-8") as f: + f.write("\n# owloop\n") + for entry in to_add: + f.write(f"{entry}\n") + created.append(".gitignore (updated)") + else: + gitignore.write_text("# owloop\n" + "\n".join(gitignore_entries) + "\n", encoding="utf-8") + created.append(".gitignore") + + if example: + example_spec = specs_path / "01-example.md" + if not example_spec.exists(): + example_spec.write_text( + SPEC_TEMPLATE.format(name="example-task", priority=1), + encoding="utf-8", + ) + created.append(".owloop/specs/01-example.md") + + backpressure_path = owloop_path / "backpressure.json" + if not backpressure_path.exists(): + try: + _commands, bp_path = discover_and_save(cwd) + created.append(str(bp_path.relative_to(cwd))) + except Exception: + pass + + console.print() + console.print(_banner_text(ascii=ascii, no_color=no_color)) + + if created: + console.print( + Panel( + "\n".join(f" [green]✓[/] {f}" for f in created), + title="[bold]Initialized[/]", + border_style=_brand.AMBER, + padding=(1, 2), + ) + ) + else: + console.print("[dim]Already initialized — nothing to create.[/]") + + console.print() + console.print(f"[bold {_brand.AMBER}]Next steps:[/]") + console.print(" 1. Edit [bold].owloop/specs/01-example.md[/] with your task") + console.print(" 2. Run [bold]owloop run[/]") + console.print() diff --git a/src/owloop/commands/logs.py b/src/owloop/commands/logs.py new file mode 100644 index 0000000..5650e2d --- /dev/null +++ b/src/owloop/commands/logs.py @@ -0,0 +1,65 @@ +"""Implementation of the ``owloop logs`` command.""" + +import json +from pathlib import Path + +from rich.console import Console + +from owloop.cli_options import _cli_options +from owloop.paths import resolve_logs_dir + + +def logs_cmd(iter_n: int | None, events: bool, patch: bool) -> None: + """Inspect owloop log files.""" + ascii, no_color, _compact, _verbose = _cli_options() + console = Console(no_color=no_color) + logs_dir = resolve_logs_dir(Path.cwd()) + + if not logs_dir.exists(): + console.print("[red]Error:[/] No logs directory. Run [bold]owloop run[/] first.") + raise SystemExit(1) + + if patch: + patches = sorted(logs_dir.glob("iter_*_discarded.patch")) + if not patches: + console.print("[dim]No discarded patches found.[/]") + raise SystemExit(0) + latest = patches[-1] + console.print(latest.read_text(encoding="utf-8")) + raise SystemExit(0) + + if events: + events_path = logs_dir / "events.jsonl" + if not events_path.is_file(): + console.print("[red]Error:[/] events.jsonl not found.") + raise SystemExit(1) + lines = events_path.read_text(encoding="utf-8").splitlines() + for line in lines[-50:]: + if not line.strip(): + continue + try: + record = json.loads(line) + ts = record.get("ts", "—") + kind = record.get("kind", "—") + data = record.get("data", {}) + console.print(f"[{ts}] {kind}: {data}") + except json.JSONDecodeError: + console.print(line) + raise SystemExit(0) + + if iter_n is not None: + candidates = [p for p in logs_dir.glob("iter_*.log") if str(iter_n) in p.name] + if not candidates: + console.print(f"[red]Error:[/] No log found for iteration {iter_n}.") + raise SystemExit(1) + target = sorted(candidates)[-1] + console.print(target.read_text(encoding="utf-8")) + raise SystemExit(0) + + # Default: latest iteration log + log_files = sorted(logs_dir.glob("iter_*.log")) + if not log_files: + console.print("[dim]No iteration logs found.[/]") + raise SystemExit(0) + latest = log_files[-1] + console.print(latest.read_text(encoding="utf-8")) diff --git a/src/owloop/commands/report.py b/src/owloop/commands/report.py new file mode 100644 index 0000000..0056dfd --- /dev/null +++ b/src/owloop/commands/report.py @@ -0,0 +1,72 @@ +"""Implementation of the ``owloop report`` command.""" + +import os +import shutil +import subprocess +from pathlib import Path + +from rich.console import Console + +from owloop import _brand +from owloop.adapters import DEFAULT_IDLE_TIMEOUT, get_adapter +from owloop.cli_display import AgentStreamDisplay, _banner_text +from owloop.cli_options import _cli_options +from owloop.report import ReportGenerator +from owloop.report_ai import AIReportInsightsGenerator + + +def report_cmd(output: Path | None, ai: bool, open_report: bool, model: str) -> None: + """Generate an HTML summary report for the latest owloop run. + + By default the report includes AI-generated insights (summary, risks, + review focus) and is styled as a reviewable artifact. Use --no-ai for a + fast, offline report. + """ + ascii, no_color, _compact, verbose = _cli_options() + console = Console(no_color=no_color) + project_dir = Path.cwd() + + insights = None + if ai: + adapter = get_adapter( + "claude", + model=model, + claude_cmd=os.environ.get("CLAUDE_CMD", "claude"), + idle_timeout=DEFAULT_IDLE_TIMEOUT, + ) + ai_generator = AIReportInsightsGenerator(project_dir, adapter) + stream = AgentStreamDisplay(console, verbose=verbose) + + try: + stream.start() + insights = ai_generator.generate(on_line=stream.on_line) + except FileNotFoundError: + console.print("[red]Error:[/] No run summary found. Run [bold]owloop run[/] first.") + raise SystemExit(1) from None + except RuntimeError as exc: + console.print(f"[{_brand.AMBER}]⚠ AI insights failed:[/] {exc}") + console.print("[dim]Falling back to static report. Use --no-ai to skip AI.[/]") + finally: + stream.stop() + + if output is None and ai: + output = project_dir / ".owloop" / "reports" / "owloop_report.html" + + generator = ReportGenerator(project_dir) + try: + report_path = generator.generate(output, insights=insights, use_tailwind=ai) + except FileNotFoundError: + console.print("[red]Error:[/] No run summary found. Run [bold]owloop run[/] first.") + raise SystemExit(1) from None + + if open_report and ai: + if shutil.which("lavish-axi"): + console.print(f"[{_brand.AMBER}]Opening report with lavish-axi...[/]") + subprocess.run(["lavish-axi", str(report_path)], check=False) + else: + console.print("[{_brand.AMBER}]⚠ lavish-axi not found:[/] report saved but not opened") + + console.print() + console.print(_banner_text(ascii=ascii, no_color=no_color)) + console.print(f"[{_brand.GREEN}]✓ Report generated:[/] {report_path}") + console.print() diff --git a/src/owloop/commands/run.py b/src/owloop/commands/run.py new file mode 100644 index 0000000..6613030 --- /dev/null +++ b/src/owloop/commands/run.py @@ -0,0 +1,313 @@ +"""Implementation of the ``owloop run`` command and engine runner helpers.""" + +import os +import sys +from pathlib import Path +from typing import Any, cast + +from rich.console import Console + +from owloop import _brand +from owloop.adapters import DEFAULT_IDLE_TIMEOUT, get_adapter +from owloop.cli_config import _generate_quick_report, _load_and_apply_run_config +from owloop.cli_display import _banner_text +from owloop.cli_options import _cli_options +from owloop.engine import EngineConfig, OwloopEngine, RunSummary, TerminalState +from owloop.paths import resolve_specs_dir +from owloop.reporter import ConsoleReporter +from owloop.tui import OwloopTUI + +# Three-tier exit codes for unattended callers (CI, cron, wrappers). +# 0 = finished successfully +# 1 = hard failure (engine/agent broke, spec tampered, preflight failed) +# 2 = needs human attention (blocked, decide, stalled, exhausted, interrupted) +_EXIT_CODE_MAP = { + TerminalState.SUCCESS: 0, + TerminalState.CLEAN_NO_OP: 0, + TerminalState.BLOCKED: 2, + TerminalState.DECIDE: 2, + TerminalState.STALLED: 2, + TerminalState.EXHAUSTED: 2, + TerminalState.TAMPERED: 1, + TerminalState.INTERRUPTED: 2, + TerminalState.FAILED: 1, +} + + +def _exit_code_for_summary(summary: RunSummary) -> int: + return _EXIT_CODE_MAP.get(cast(TerminalState, summary.state), 1) + + +def _run_engine( + max_iterations: int, worktree: bool, model: str | None, agent: str, + idle_timeout: float = DEFAULT_IDLE_TIMEOUT, max_duration: int = 0, max_tokens: int = 0, + 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, + max_tokens_per_iteration: int = 0, + max_turns_per_iteration: int = 0, + max_budget_usd: float = 0.0, + keep_retrying: bool = False, + rollback: bool = True, + notify_webhook: str | None = None, + notify_desktop: bool = False, + converge_sweeps: int = 0, + workers: int = 1, +) -> None: + # Merge persistent config defaults before constructing the engine. + kwargs = _load_and_apply_run_config( + max_iterations=max_iterations, + worktree=worktree, + model=model, + agent=agent, + idle_timeout=idle_timeout, + max_duration=max_duration, + max_tokens=max_tokens, + ascii=ascii, + no_color=no_color, + compact=compact, + verifier_model=verifier_model, + subagents=subagents, + session_id=session_id, + resume=resume, + no_tui=no_tui, + dry_run=dry_run, + max_tokens_per_iteration=max_tokens_per_iteration, + max_turns_per_iteration=max_turns_per_iteration, + max_budget_usd=max_budget_usd, + keep_retrying=keep_retrying, + rollback=rollback, + notify_webhook=notify_webhook, + notify_desktop=notify_desktop, + converge_sweeps=converge_sweeps, + workers=workers, + ) + max_iterations = kwargs["max_iterations"] + worktree = kwargs["worktree"] + model = kwargs["model"] + agent = kwargs["agent"] + idle_timeout = kwargs["idle_timeout"] + max_duration = kwargs["max_duration"] + max_tokens = kwargs["max_tokens"] + ascii = kwargs["ascii"] + no_color = kwargs["no_color"] + compact = kwargs["compact"] + verifier_model = kwargs["verifier_model"] + subagents = kwargs["subagents"] + session_id = kwargs["session_id"] + resume = kwargs["resume"] + no_tui = kwargs["no_tui"] + dry_run = kwargs["dry_run"] + 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"] + keep_retrying = kwargs["keep_retrying"] + rollback = kwargs["rollback"] + notify_webhook = kwargs["notify_webhook"] + notify_desktop = kwargs["notify_desktop"] + converge_sweeps = kwargs["converge_sweeps"] + workers = kwargs["workers"] + + resolved_webhook = notify_webhook or os.environ.get("OWLOOP_NOTIFY_WEBHOOK") or None + if workers > 1: + _run_parallel( + workers=workers, model=model, agent=agent, idle_timeout=idle_timeout, + ascii=ascii, no_color=no_color, + notify_webhook=resolved_webhook, notify_desktop=notify_desktop, + ) + return + config = EngineConfig( + project_dir=Path.cwd(), + max_iterations=max_iterations, + max_duration_minutes=max_duration, + max_tokens=max_tokens, + max_tokens_per_iteration=max_tokens_per_iteration, + idle_timeout=idle_timeout, + worktree=worktree, + use_subagents=subagents, + session_id=session_id, + resume=resume, + dry_run=dry_run, + keep_retrying=keep_retrying, + rollback=rollback, + notify_webhook=resolved_webhook, + notify_desktop=notify_desktop, + converge_sweeps=converge_sweeps, + ) + adapter = get_adapter( + agent, + model=model, + claude_cmd=os.environ.get("CLAUDE_CMD", "claude"), + kimi_cmd=os.environ.get("KIMI_CMD", "kimi"), + idle_timeout=idle_timeout, + max_turns=max_turns_per_iteration or None, + max_budget_usd=max_budget_usd or None, + project_dir=Path.cwd(), + ) + + if verifier_model: + config.verifier_adapter = get_adapter( + agent, + model=verifier_model, + claude_cmd=os.environ.get("CLAUDE_CMD", "claude"), + kimi_cmd=os.environ.get("KIMI_CMD", "kimi"), + idle_timeout=idle_timeout, + project_dir=Path.cwd(), + ) + + report_path: Path | None = None + + if not no_tui and sys.stdout.isatty(): + tui = OwloopTUI(ascii=ascii, no_color=no_color, compact=compact) + + try: + with tui: + engine = OwloopEngine(config, adapter, on_event=tui.on_event) + summary = engine.run() + except KeyboardInterrupt: + console = Console(no_color=no_color) + console.print("\n[dim]owloop stopped.[/]") + raise SystemExit(0) from None + if summary.iterations > 0 and not config.dry_run: + report_path = _generate_quick_report(summary) + tui.print_exit_summary(summary, report_path=str(report_path) if report_path else None) + if config.dry_run: + _print_dry_run_report(tui.console, summary) + else: + console = Console(no_color=no_color) + console.print() + console.print(_banner_text(ascii=ascii, no_color=no_color)) + console.print(f"[{_brand.AMBER}]Starting autonomous loop...[/]") + + reporter = ConsoleReporter(console, ascii=ascii) + engine = OwloopEngine(config, adapter, on_event=reporter.on_event) + try: + summary = engine.run() + except KeyboardInterrupt: + console.print("\n[dim]owloop stopped.[/]") + raise SystemExit(0) from None + if summary.iterations > 0 and not config.dry_run: + report_path = _generate_quick_report(summary) + reporter.print_summary(summary, report_path=str(report_path) if report_path else None) + if config.dry_run: + _print_dry_run_report(console, summary) + if report_path: + console.print(f"Report: {report_path}") + + exit_code = _exit_code_for_summary(summary) + if exit_code: + raise SystemExit(exit_code) + + + +def _run_parallel( + *, workers: int, model: str | None, agent: str, idle_timeout: float, + ascii: bool, no_color: bool, + notify_webhook: str | None, notify_desktop: bool, +) -> None: + """Run the file-disjoint parallel worker mode (`owloop run --workers N`).""" + from owloop.parallel import ParallelConfig, ParallelOrchestrator + + console = Console(no_color=no_color) + console.print() + console.print(_banner_text(ascii=ascii, no_color=no_color)) + console.print(f"[{_brand.AMBER}]Starting {workers} parallel workers...[/]") + + def _adapter_factory() -> Any: + # Fresh adapter per worker: adapters hold per-run streaming state, so + # concurrent workers must not share one instance. + return get_adapter( + agent, model=model, + claude_cmd=os.environ.get("CLAUDE_CMD", "claude"), + kimi_cmd=os.environ.get("KIMI_CMD", "kimi"), + idle_timeout=idle_timeout, + ) + + config = ParallelConfig( + project_dir=Path.cwd(), + workers=workers, + notify_webhook=notify_webhook, + notify_desktop=notify_desktop, + ) + reporter = ConsoleReporter(console, ascii=ascii) + orchestrator = ParallelOrchestrator(config, _adapter_factory, on_event=reporter.on_event) + try: + summary = orchestrator.run() + except KeyboardInterrupt: + console.print("\n[dim]owloop stopped.[/]") + raise SystemExit(0) from None + reporter.print_summary(summary) + exit_code = _exit_code_for_summary(summary) + if exit_code: + raise SystemExit(exit_code) + + +def _print_dry_run_report(console: Console, summary: RunSummary) -> None: + """Print the concise pass/fail report produced by ``--dry-run`` / ``--one-shot``.""" + from rich.panel import Panel + + report = summary.dry_run_report + if report is None: + return + + promise_line = ( + f"[{_brand.GREEN}]<promise>DONE</promise> emitted[/]" + if report.promise_done + else f"[{_brand.RED}]<promise>DONE</promise> not emitted[/]" + ) + lines = [promise_line] + if report.spec_name: + lines.append(f"Spec: {report.spec_name}") + lines.append( + f"Acceptance criteria: [{_brand.GREEN}]{report.acceptance_passed} passed[/] / " + f"[{_brand.RED}]{report.acceptance_failed} failed[/]" + ) + lines.append(f"Tokens used: {report.tokens_used}") + + console.print() + console.print( + Panel( + "\n".join(lines), + title="[bold]Dry-run report[/]", + border_style=_brand.AMBER, + padding=(1, 2), + ) + ) + + +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, +) -> None: + """Start the autonomous coding loop.""" + ascii, no_color, compact, verbose = _cli_options() + specs_dir = resolve_specs_dir(Path.cwd()) + if not specs_dir.exists() or not list(specs_dir.glob("*.md")): + console = Console(no_color=no_color) + console.print("[red]Error:[/] No specs found. Create specs in [bold].owloop/specs/[/] first.") + console.print("[dim]Run [bold]owloop init[/] to get started.[/]") + raise SystemExit(1) + + _run_engine( + max_iterations, worktree, model, agent, + idle_timeout, max_duration, max_tokens, + ascii=ascii, no_color=no_color, compact=compact, + verifier_model=verifier_model, + subagents=subagents, + resume=resume, + no_tui=no_tui, + dry_run=dry_run, + max_tokens_per_iteration=max_tokens_per_iteration, + max_turns_per_iteration=max_turns_per_iteration, + max_budget_usd=max_budget_usd, + keep_retrying=keep_retrying, + rollback=rollback, + notify_webhook=notify_webhook, + notify_desktop=notify_desktop, + converge_sweeps=converge_sweeps, + workers=workers, + ) diff --git a/src/owloop/commands/spec.py b/src/owloop/commands/spec.py new file mode 100644 index 0000000..64f15a3 --- /dev/null +++ b/src/owloop/commands/spec.py @@ -0,0 +1,143 @@ +"""Implementation of the ``owloop spec`` command.""" + +import os +import sys +from pathlib import Path + +from rich.console import Console +from rich.table import Table + +from owloop import _brand +from owloop.adapters import get_adapter +from owloop.cli_config import _load_and_apply_run_config +from owloop.cli_display import ( + AgentStreamDisplay, + _banner_text, + _ensure_init, + _format_spec_table, +) +from owloop.cli_options import _cli_options +from owloop.commands.run import _run_engine +from owloop.spec_generator import SpecGenerationError, SpecGenerator + + +def spec_cmd( + goal: str, + model: str, + max_rounds: int, + yes: bool, + agent: str, + verifier_model: str | None, + subagents: bool, + idle_timeout: float, + max_duration: int, + max_tokens: int, + worktree: bool, + 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, + verbose: bool, +) -> None: + """Turn a vague goal into a concrete spec via agent clarification. + + Claude scans the codebase, calibrates baselines, and drafts a complete + constraint-oriented spec. The spec is shown for approval before the loop + starts unless --yes is passed. + """ + ascii, no_color, compact, cli_verbose = _cli_options() + verbose = verbose or cli_verbose + console = Console(no_color=no_color) + project_dir = Path.cwd() + + _ensure_init(project_dir, console, ascii=ascii) + + console.print() + console.print(_banner_text(ascii=ascii, no_color=no_color)) + console.print(f"[dim]Goal:[/] {goal}\n") + + adapter = get_adapter( + "claude", + model=model, + claude_cmd=os.environ.get("CLAUDE_CMD", "claude"), + idle_timeout=idle_timeout, + ) + generator = SpecGenerator(project_dir, adapter) + stream = AgentStreamDisplay(console, verbose=verbose) + + try: + stream.start() + spec_paths = generator.generate(goal, max_rounds=max_rounds, on_line=stream.on_line) + except SpecGenerationError as exc: + console.print(f"\n[red]Error:[/] {exc}") + raise SystemExit(1) from None + finally: + stream.stop() + + console.print() + console.print(f"[{_brand.GREEN}]✓ {len(spec_paths)} spec(s) generated:[/]") + for sp in spec_paths: + console.print(f" [{_brand.CYAN}]{sp}[/]") + console.print() + + display = _format_spec_table(spec_paths, verbose=verbose) + if isinstance(display, Table): + console.print(display) + else: + for panel in display: + console.print(panel) + + run_kwargs = _load_and_apply_run_config( + max_iterations=max_iterations, + worktree=worktree, + model=model, + agent=agent, + idle_timeout=idle_timeout, + max_duration=max_duration, + max_tokens=max_tokens, + ascii=ascii, + no_color=no_color, + compact=compact, + verifier_model=verifier_model, + subagents=subagents, + resume=resume, + no_tui=no_tui, + dry_run=dry_run, + max_tokens_per_iteration=max_tokens_per_iteration, + max_turns_per_iteration=max_turns_per_iteration, + max_budget_usd=max_budget_usd, + keep_retrying=keep_retrying, + rollback=rollback, + notify_webhook=notify_webhook, + notify_desktop=notify_desktop, + converge_sweeps=converge_sweeps, + workers=workers, + ) + + start_loop = yes + if not yes: + if sys.stdin.isatty(): + console.print("\n[bold]Start the loop now?[/] [Y/n] ", end="") + try: + reply = input().strip().lower() or "y" + except EOFError: + reply = "y" + start_loop = reply.startswith("y") + else: + console.print("\n[dim]Non-interactive mode: review the spec and run[/] [bold]owloop run[/]") + + if start_loop: + console.print(f"\n[{_brand.AMBER}]Starting autonomous loop...[/]") + _run_engine(**run_kwargs) + else: + console.print("\n[dim]Review the spec, then run[/] [bold]owloop run[/]") + console.print() diff --git a/src/owloop/commands/spec_from_issue.py b/src/owloop/commands/spec_from_issue.py new file mode 100644 index 0000000..b2e1210 --- /dev/null +++ b/src/owloop/commands/spec_from_issue.py @@ -0,0 +1,74 @@ +"""Implementation of the ``owloop spec-from-issue`` command.""" + +import subprocess +import sys +from pathlib import Path + +from rich.console import Console + +from owloop import _brand +from owloop.cli_display import _ensure_init +from owloop.cli_options import _cli_options +from owloop.spec_from_issue import IssueToSpecConverter + + +def spec_from_issue_cmd( + issue: str, + repo: str | None, + output: Path | None, + dry_run: bool, +) -> None: + """Generate a spec draft from a GitHub issue.""" + ascii, no_color, _compact, _verbose = _cli_options() + console = Console(no_color=no_color) + project_dir = Path.cwd() + + _ensure_init(project_dir, console, ascii=ascii) + + converter = IssueToSpecConverter(project_dir) + + try: + data = converter.from_github(issue, repo=repo) + except ValueError as exc: + console.print(f"[red]Error:[/] {exc}") + raise SystemExit(1) from None + except RuntimeError as exc: + console.print(f"[red]Error:[/] {exc}") + raise SystemExit(1) from None + + if dry_run: + try: + rendered = converter.render_spec(data) + except FileNotFoundError as exc: + console.print(f"[red]Error:[/] {exc}") + raise SystemExit(1) from None + console.print(rendered) + return + + try: + spec_path = converter.write_spec(data, output_path=output) + except FileNotFoundError as exc: + console.print(f"[red]Error:[/] {exc}") + raise SystemExit(1) from None + + console.print() + console.print(f"[{_brand.GREEN}]✓ Spec generated:[/] {spec_path}") + console.print() + + if sys.stdin.isatty() and not dry_run: + console.print("\n[bold]Run owloop check on this spec?[/] [y/N] ", end="") + try: + reply = input().strip().lower() or "n" + except EOFError: + reply = "n" + if reply.startswith("y"): + subprocess.run([sys.argv[0], "check"], cwd=project_dir) + return + + console.print("\n[bold]Start owloop run now?[/] [y/N] ", end="") + try: + reply = input().strip().lower() or "n" + except EOFError: + reply = "n" + if reply.startswith("y"): + subprocess.run([sys.argv[0], "run"], cwd=project_dir) diff --git a/src/owloop/commands/status.py b/src/owloop/commands/status.py new file mode 100644 index 0000000..9587bf0 --- /dev/null +++ b/src/owloop/commands/status.py @@ -0,0 +1,108 @@ +"""Implementation of the ``owloop status`` command.""" + +from pathlib import Path + +from rich.console import Console +from rich.table import Table + +from owloop import _brand +from owloop.cli_display import _banner_text, render_progress_bar +from owloop.cli_options import _cli_options +from owloop.paths import resolve_specs_dir +from owloop.sessions import _find_worktree_path, _read_latest_session, classify_spec + + +def status_cmd() -> None: + """Show current specs and their completion status.""" + ascii, no_color, _compact, verbose = _cli_options() + console = Console(no_color=no_color) + specs_dir = resolve_specs_dir(Path.cwd()) + + if not specs_dir.exists(): + console.print("[dim]No specs directory. Run [bold]owloop init[/] first.[/]") + raise SystemExit(1) + + specs = sorted(specs_dir.glob("*.md")) + if not specs: + console.print("[dim]No spec files found in specs/.[/]") + raise SystemExit(0) + + console.print() + console.print(_banner_text(ascii=ascii, no_color=no_color)) + + table = Table( + title="Specs", + border_style=_brand.AMBER, + show_lines=False, + padding=(0, 2), + ) + table.add_column("", width=3) + table.add_column("File", style="bold") + table.add_column("Priority", justify="center") + table.add_column("Status", justify="center") + + state_display = { + "done": ("[green]✓[/]", "[green]done[/]"), + "in_progress": (f"[{_brand.AMBER}]🦉[/]", f"[{_brand.AMBER}]in progress[/]"), + "pending": ("[dim]○[/]", "[dim]pending[/]"), + } + + counts = {"done": 0, "in_progress": 0, "pending": 0} + + for spec_file in specs: + content = spec_file.read_text(encoding="utf-8") + state = classify_spec(content) + counts[state] += 1 + + priority = "—" + for line in content.splitlines(): + if line.strip().startswith("## Priority:"): + priority = line.split(":")[-1].strip() + break + + icon, status_text = state_display[state] + table.add_row(icon, spec_file.name, priority, status_text) + + total = len(specs) + console.print( + f" [green]✓ {counts['done']} done[/] · " + f"[{_brand.AMBER}]🦉 {counts['in_progress']} in progress[/] · " + f"[dim]○ {counts['pending']} pending[/]" + ) + console.print(f" {render_progress_bar(counts['done'], total, ascii=ascii)}") + console.print() + console.print(table) + console.print() + + # Runtime section + session = _read_latest_session(Path.cwd()) + if session: + from rich.table import Table as RuntimeTable + + runtime = RuntimeTable( + title="Runtime", + border_style=_brand.AMBER, + show_lines=False, + padding=(0, 2), + ) + runtime.add_column("Key", style="bold") + runtime.add_column("Value") + + session_id = session.get("session_id", "—") + branch = session.get("branch", "—") + session_status = session.get("status", "—") + stopped_reason = session.get("stopped_reason", session.get("status", "—")) + wt_path = _find_worktree_path(Path.cwd(), session) or Path("—") + resumable = session_status in ("running",) or stopped_reason in { + "interrupted", "stalled", "exhausted", "max_iterations_reached", + "max_duration_reached", "max_tokens_reached", "idle_timeout", + } + runtime.add_row("Session", str(session_id)) + runtime.add_row("Branch", str(branch)) + runtime.add_row("Worktree", str(wt_path)) + runtime.add_row("Stopped reason", str(stopped_reason)) + runtime.add_row("Resumable", "yes" if resumable else "no") + runtime.add_row("Iterations", str(session.get("iterations", "—"))) + runtime.add_row("Tokens used", str(session.get("tokens_used", "—"))) + console.print(runtime) + console.print() diff --git a/src/owloop/commands/version.py b/src/owloop/commands/version.py new file mode 100644 index 0000000..0a97cf9 --- /dev/null +++ b/src/owloop/commands/version.py @@ -0,0 +1,26 @@ +"""Implementation of the ``owloop version`` command.""" + +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as pkg_version + +from rich.console import Console + +from owloop import _brand +from owloop.cli_display import _banner_text +from owloop.cli_options import _cli_options + + +def version_cmd() -> None: + """Show the owloop version.""" + ascii, no_color, _compact, verbose = _cli_options() + console = Console(no_color=no_color) + + try: + v = pkg_version("owloop") + except PackageNotFoundError: + v = "0.0.0-dev" + + console.print() + console.print(_banner_text(ascii=ascii, no_color=no_color)) + console.print(f"[bold]owloop[/] [{_brand.AMBER}]v{v}[/]") + console.print() diff --git a/src/owloop/sessions.py b/src/owloop/sessions.py new file mode 100644 index 0000000..8b69784 --- /dev/null +++ b/src/owloop/sessions.py @@ -0,0 +1,59 @@ +"""Session helpers for the owloop CLI.""" + +import json +import re +import subprocess +from pathlib import Path +from typing import Any + +CHECKED_BOX_RE = re.compile(r"- \[[xX]\]") +# Mirrors the `**Status**: COMPLETE` convention spec_queue._COMPLETE_RE looks for, +# so `owloop status` classifies specs the same way the engine's queue does. +_STATUS_DONE_RE = re.compile(r"^(#{1,3} )?(\*\*)?status(\*\*)?:\s+complete", re.MULTILINE | re.IGNORECASE) +_STATUS_IN_PROGRESS_RE = re.compile( + r"^(#{1,3} )?(\*\*)?status(\*\*)?:\s+in progress", re.MULTILINE | re.IGNORECASE +) + + +def classify_spec(content: str) -> str: + if _STATUS_DONE_RE.search(content): + return "done" + if _STATUS_IN_PROGRESS_RE.search(content): + return "in_progress" + if CHECKED_BOX_RE.search(content): + return "in_progress" + return "pending" + + +def _read_latest_session(project_dir: Path) -> dict[str, Any] | None: + """Load the latest session descriptor if it exists.""" + path = project_dir / ".owloop" / "logs" / "session_latest.json" + if not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + if isinstance(data, dict): + return data + except (json.JSONDecodeError, OSError): + pass + return None + + +def _find_worktree_path(project_dir: Path, session: dict[str, Any] | None = None) -> Path | None: + """Return the worktree path from the session record or ``git worktree list``.""" + if session and session.get("path"): + return Path(session["path"]) + result = subprocess.run( + ["git", "worktree", "list", "--porcelain"], + cwd=project_dir, + capture_output=True, + text=True, + ) + if result.returncode != 0: + return None + for line in result.stdout.splitlines(): + if line.startswith("worktree "): + wt = Path(line.split(" ", 1)[1]) + if wt != project_dir and wt.name.startswith("owloop-"): + return wt + return None diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 9705675..7e7c03a 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -325,8 +325,12 @@ def test_kimi_adapter_extracts_usage_or_falls_back_to_heuristic(tmp_path: Path) def _fake_binary(tmp_path: Path) -> Path: binary = tmp_path / "bin" / "fake-claude" binary.parent.mkdir(parents=True, exist_ok=True) - binary.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") - binary.chmod(0o755) + if sys.platform == "win32": + binary = binary.with_suffix(".cmd") + binary.write_text("@echo off\r\nexit /b 0\r\n", encoding="utf-8") + else: + binary.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + binary.chmod(0o755) return binary diff --git a/tests/test_cli.py b/tests/test_cli.py index e937073..0c8fae2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -92,7 +92,7 @@ def test_run_dry_run_flag_forwards_to_engine_runner(): runner = CliRunner() with runner.isolated_filesystem(): _init_repo_with_spec() - with patch("owloop.cli._run_engine") as mock_engine: + with patch("owloop.commands.run._run_engine") as mock_engine: result = runner.invoke(main, ["run", "--dry-run"]) assert result.exit_code == 0, result.output mock_engine.assert_called_once() @@ -106,7 +106,7 @@ def test_run_one_shot_alias_forwards_dry_run(): runner = CliRunner() with runner.isolated_filesystem(): _init_repo_with_spec() - with patch("owloop.cli._run_engine") as mock_engine: + with patch("owloop.commands.run._run_engine") as mock_engine: result = runner.invoke(main, ["run", "--one-shot"]) assert result.exit_code == 0, result.output _args, kwargs = mock_engine.call_args @@ -119,7 +119,7 @@ def test_run_without_dry_run_flag_defaults_false(): runner = CliRunner() with runner.isolated_filesystem(): _init_repo_with_spec() - with patch("owloop.cli._run_engine") as mock_engine: + with patch("owloop.commands.run._run_engine") as mock_engine: result = runner.invoke(main, ["run"]) assert result.exit_code == 0, result.output _args, kwargs = mock_engine.call_args @@ -132,7 +132,7 @@ def test_run_no_tui_flag_forwards_to_engine_runner(): runner = CliRunner() with runner.isolated_filesystem(): _init_repo_with_spec() - with patch("owloop.cli._run_engine") as mock_engine: + with patch("owloop.commands.run._run_engine") as mock_engine: result = runner.invoke(main, ["run", "--no-tui"]) assert result.exit_code == 0, result.output _args, kwargs = mock_engine.call_args @@ -145,7 +145,7 @@ def test_run_plain_alias_forwards_no_tui(): runner = CliRunner() with runner.isolated_filesystem(): _init_repo_with_spec() - with patch("owloop.cli._run_engine") as mock_engine: + with patch("owloop.commands.run._run_engine") as mock_engine: result = runner.invoke(main, ["run", "--plain"]) assert result.exit_code == 0, result.output _args, kwargs = mock_engine.call_args @@ -158,7 +158,7 @@ def test_run_without_no_tui_flag_defaults_false(): runner = CliRunner() with runner.isolated_filesystem(): _init_repo_with_spec() - with patch("owloop.cli._run_engine") as mock_engine: + with patch("owloop.commands.run._run_engine") as mock_engine: result = runner.invoke(main, ["run"]) assert result.exit_code == 0, result.output _args, kwargs = mock_engine.call_args @@ -178,7 +178,7 @@ def test_run_parses_max_tokens_per_iteration(): runner = CliRunner() with runner.isolated_filesystem(): _init_repo_with_spec() - with patch("owloop.cli._run_engine") as mock_engine: + with patch("owloop.commands.run._run_engine") as mock_engine: result = runner.invoke(main, ["run", "--max-tokens-per-iteration", "10k"]) assert result.exit_code == 0, result.output _args, kwargs = mock_engine.call_args @@ -191,7 +191,7 @@ def test_run_without_max_tokens_per_iteration_defaults_zero(): runner = CliRunner() with runner.isolated_filesystem(): _init_repo_with_spec() - with patch("owloop.cli._run_engine") as mock_engine: + with patch("owloop.commands.run._run_engine") as mock_engine: result = runner.invoke(main, ["run"]) assert result.exit_code == 0, result.output _args, kwargs = mock_engine.call_args @@ -216,10 +216,10 @@ def test_run_engine_no_tui_bypasses_tui_and_uses_console_reporter(): fake_engine.run.return_value = summary with ( - patch("owloop.cli.get_adapter"), - patch("owloop.cli.OwloopEngine", return_value=fake_engine) as mock_engine_cls, - patch("owloop.cli.OwloopTUI") as mock_tui_cls, - patch("owloop.cli.ConsoleReporter") as mock_reporter_cls, + patch("owloop.commands.run.get_adapter"), + patch("owloop.commands.run.OwloopEngine", return_value=fake_engine) as mock_engine_cls, + patch("owloop.commands.run.OwloopTUI") as mock_tui_cls, + patch("owloop.commands.run.ConsoleReporter") as mock_reporter_cls, patch("sys.stdout.isatty", return_value=True), ): _run_engine(0, True, "claude-model", "claude", no_tui=True) @@ -393,9 +393,9 @@ def test_go_forwards_options_to_engine_runner(): with ( patch.object(_NamedTextIOWrapper, "isatty", return_value=True), - patch("owloop.cli._run_engine") as mock_engine, - patch("owloop.cli.SpecGenerator") as mock_gen, - patch("owloop.cli.get_adapter") as mock_adapter, + patch("owloop.commands.go._run_engine") as mock_engine, + patch("owloop.commands.go.SpecGenerator") as mock_gen, + patch("owloop.commands.go.get_adapter") as mock_adapter, ): mock_gen.return_value.generate.return_value = [] mock_adapter.return_value = MagicMock() diff --git a/tests/test_engine.py b/tests/test_engine.py index a18d13b..bf156c5 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -943,7 +943,7 @@ def test_gate_failure_writes_failure_feedback(tmp_path: Path, monkeypatch) -> No specs = repo / ".owloop" / "specs" specs.mkdir(parents=True) (specs / "01-test.md").write_text( - "# Spec\n\n## Acceptance Criteria\n- check: `echo broken-thing >&2; exit 3`\n", + """# Spec\n\n## Acceptance Criteria\n- check: `python -c "import sys; sys.stderr.write('broken-thing'); sys.exit(3)"`\n""", encoding="utf-8", ) monkeypatch.setattr("owloop.engine.time.sleep", lambda _: None) @@ -964,7 +964,10 @@ def test_gate_failure_writes_failure_feedback(tmp_path: Path, monkeypatch) -> No feedback = (repo / ".owloop" / "last-failure.md").read_text(encoding="utf-8") assert "verification_failed" in feedback - assert "echo broken-thing >&2; exit 3" in feedback # the failing command + assert ( + "python -c \"import sys; sys.stderr.write('broken-thing'); sys.exit(3)\"" + in feedback + ) # the failing command assert "(exit 3)" in feedback # its exit code assert "broken-thing" in feedback # its output tail