diff --git a/CLAUDE.md b/CLAUDE.md
index 3979af9..cd5c7b6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -14,8 +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` / `plan` / `status` / `version` subcommands), 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 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 |
@@ -59,7 +65,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 @@
From goal to commits, unattended.
-
owloop v0.2.1
+
owloop v0.4.0
model: claude-sonnet-5
budget: 200k tokens
↻ Replay
@@ -298,6 +298,18 @@
Fix-loop detection
Token budget cap
Set --max-tokens and --max-duration. The loop stops before your wallet does.
+
+
+
+ Persistent defaults
+ Store your favorite flags in .owloop/config.toml under [run]. Stop typing the same model and budget every time.
+
+
+
+
+ Resume & finish
+ owloop run --resume picks up where it stopped. owloop finish merges, pushes, and cleans up the worktree when you're ready.
+
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 @@
'
' +
'' +
- '
owloop/refactor-errors
Branch
' +
+ '
owloop/20250707-refactor-errors-a1b2c3d4
Branch
' +
'
' +
'
' +
'
8 files · +328 · -108
Total diff
' +
@@ -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);
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..48566da 100644
--- a/src/owloop/cli.py
+++ b/src/owloop/cli.py
@@ -1,367 +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 os
-import re
-import subprocess
-import sys
-import threading
-import time
-from collections.abc import Callable
from pathlib import Path
-from typing import Any
import click
from rich.console import Console
-from rich.panel import Panel
-from rich.prompt import Confirm
-from rich.text import Text
from owloop import _brand
-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.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: `
DONE `
-"""
-
-
-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.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"))
-
-
-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 "
" 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/logs/", ".owloop/PROMPT_build.md"]
- 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)
@@ -399,59 +75,13 @@ def main(ctx: click.Context, ascii: bool, no_color: bool, compact: bool, verbose
console.print("[dim]Run[/] [bold]owloop --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 _common_run_options(f: Callable[..., Any]) -> Callable[..., Any]:
- """Shared options for the run and go commands."""
- 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).",
- 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
-
-
@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,77 +93,47 @@ 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.
-
- \b
- Example:
- owloop go "refactor error handling in the API layer"
- """
- ascii, no_color, compact, verbose = _cli_options()
- 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"),
+ """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,
+ worktree=worktree,
+ max_iterations=max_iterations,
+ resume=resume,
+ 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,
+ keep_retrying=keep_retrying,
+ rollback=rollback,
+ notify_webhook=notify_webhook,
+ notify_desktop=notify_desktop,
+ converge_sweeps=converge_sweeps,
+ workers=workers,
+ verbose=verbose,
)
- 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()
- 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)
- else:
- console.print("\n[dim]Non-interactive: run[/] [bold]owloop run[/] [dim]to start.[/]")
- start = False
-
- 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,
- )
@main.command()
@@ -544,97 +144,15 @@ 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/logs/", ".owloop/PROMPT_build.md"]
- 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()
@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,347 +162,73 @@ 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:
- """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()
- 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=DEFAULT_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()
- 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),
- ))
-
- 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 "n"
- except EOFError:
- reply = "n"
- 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,
- )
- 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 [/]"
- "[dim]; define custom agents in .owloop/agents.toml[/]"
- )
-
-
-# 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",
-}
-
-
-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,
+@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:
- 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,
+ """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,
+ verifier_model=verifier_model,
+ subagents=subagents,
idle_timeout=idle_timeout,
+ max_duration=max_duration,
+ max_tokens=max_tokens,
worktree=worktree,
- use_subagents=subagents,
- session_id=session_id,
+ max_iterations=max_iterations,
resume=resume,
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,
keep_retrying=keep_retrying,
rollback=rollback,
- notify_webhook=resolved_webhook,
+ notify_webhook=notify_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(),
- )
-
- 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)
- summary = engine.run()
- except KeyboardInterrupt:
- console = Console(no_color=no_color)
- console.print("\n[dim]owloop stopped.[/]")
- raise SystemExit(0) from None
- tui.print_exit_summary(summary)
- 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...[/]")
-
- 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:
- summary = engine.run()
- except KeyboardInterrupt:
- console.print("\n[dim]owloop stopped.[/]")
- raise SystemExit(0) from None
- reporter.print_summary(summary)
- if config.dry_run:
- _print_dry_run_report(console, summary)
-
- if summary.stopped_reason in STOPPED_REASON_EXIT_1:
- raise SystemExit(1)
-
-
-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,
+ verbose=verbose,
)
- 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)
- if summary.stopped_reason in STOPPED_REASON_EXIT_1:
- raise SystemExit(1)
-
-
-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}]DONE emitted[/]"
- if report.promise_done
- else f"[{_brand.RED}]DONE 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()
@@ -1066,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,
@@ -1092,121 +324,54 @@ 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
+ status_cmd()
- 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
+@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."""
+ finish_cmd(auto=auto)
- 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()
+@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."""
+ 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()
@@ -1223,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()
@@ -1293,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")
@@ -1374,38 +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()
- 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()
+ 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 "" 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 [/]"
+ "[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: `DONE `
+"""
+
+
+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}]DONE emitted[/]"
+ if report.promise_done
+ else f"[{_brand.RED}]DONE 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/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: `` 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/-
- session_id = branch.split("/", 1)[1].split("-", 1)[1]
+ # Branch format: owloop/- or owloop/--
+ 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/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/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_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_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..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
@@ -210,16 +210,16 @@ 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
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)
@@ -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"
@@ -413,10 +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.Confirm.ask", return_value=True),
- 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()
@@ -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.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
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: ``.
+ 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: ``.
+ 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