diff --git a/CLAUDE.md b/CLAUDE.md
index 1889a2c..3979af9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -16,6 +16,9 @@ owloop is a spec-driven autonomous coding loop for Claude Code — "Your code ev
|---|---|
| `src/owloop/cli.py` | Python CLI (`init` / `run` / `plan` / `status` / `version` subcommands), rich console output |
| `src/owloop/engine.py` | Python loop engine — spawns agent per iteration, manages worktree, drives spec queue |
+| `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 |
| `src/owloop/adapters.py` | Agent adapter abstraction (`ClaudeCodeAdapter`, `KimiCodeAdapter`, `MockAdapter`) |
| `src/owloop/presets.py` | Agent preset registry — per-tool launch commands/env as data (user presets via `.owloop/agents.toml`) |
| `src/owloop/acp.py` | `AcpAdapter` — one Agent Client Protocol client covering all non-native agents |
@@ -63,3 +66,4 @@ owloop is a spec-driven autonomous coding loop for Claude Code — "Your code ev
- **Named terminal states, never infinite retry** — runs stop with a `TerminalState` (`success` / `blocked` / `decide` / `stalled` / `exhausted` / `tampered`). A stall (N consecutive failures or the same error N times) hard-stops with `stalled`; `--keep-retrying` restores the old warn-and-back-off behavior. `exhausted` (hit an iteration/duration/token budget) must never be rendered or exit-coded as success.
- **Don't stop silently** — an unattended run that halts at 2 a.m. must be able to reach the operator. `owloop run --notify-webhook URL` / `--notify-desktop` (or `OWLOOP_NOTIFY_WEBHOOK`) fire a best-effort completion notification (`notifications.py`, zero deps); a failed notification never changes the run outcome.
- **Empty queue ≠ goal met** — `owloop run --converge N` runs up to N post-queue audit sweeps that compare the codebase against the specs' collective intent and append *linted* gap specs until it converges (#36). The spec-generation clarify gate records unanswered questions as a `## Assumptions` section when non-interactive.
+- **Parallelism is file-disjoint, not lock-coordinated** — `owloop run --workers N` schedules specs whose declared `## Files` scopes don't overlap (`spec_queue.get_parallel_batch`), runs each in its own worktree via `parallel.py`, verifies with the *same* shared gate (`verification.run_gate`), and merges the passed branches back — disjoint scopes mean those merges never conflict. A spec with no `## Files` scope runs alone (correctness over parallelism). Never add lock coordination; invest in decomposition instead. The single verification gate lives in `verification.py` so the engine and the orchestrator grade work identically.
diff --git a/src/owloop/cli.py b/src/owloop/cli.py
index 5ec4e3c..87fe8b1 100644
--- a/src/owloop/cli.py
+++ b/src/owloop/cli.py
@@ -797,7 +797,16 @@ def _run_engine(
notify_webhook: str | None = None,
notify_desktop: bool = False,
converge_sweeps: int = 0,
+ workers: int = 1,
) -> 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,
@@ -812,7 +821,7 @@ def _run_engine(
dry_run=dry_run,
keep_retrying=keep_retrying,
rollback=rollback,
- notify_webhook=notify_webhook or os.environ.get("OWLOOP_NOTIFY_WEBHOOK") or None,
+ notify_webhook=resolved_webhook,
notify_desktop=notify_desktop,
converge_sweeps=converge_sweeps,
)
@@ -906,6 +915,47 @@ def _confirm_worktree_plain() -> bool:
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,
+ )
+ 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
@@ -1003,10 +1053,16 @@ def _print_dry_run_report(console: Console, summary: RunSummary) -> None:
"specs until the codebase converges on the goal (0 = disabled).",
show_default=True,
)
+@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,
+)
@_common_run_options
def run(max_iterations: int, resume: bool, dry_run: bool, no_tui: bool, max_tokens_per_iteration: int,
max_turns_per_iteration: int, max_budget_usd: float, keep_retrying: bool, rollback: bool,
- notify_webhook: str | None, notify_desktop: bool, converge_sweeps: int,
+ 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."""
@@ -1035,6 +1091,7 @@ def run(max_iterations: int, resume: bool, dry_run: bool, no_tui: bool, max_toke
notify_webhook=notify_webhook,
notify_desktop=notify_desktop,
converge_sweeps=converge_sweeps,
+ workers=workers,
)
diff --git a/src/owloop/engine.py b/src/owloop/engine.py
index 73f8b65..0be4aac 100644
--- a/src/owloop/engine.py
+++ b/src/owloop/engine.py
@@ -19,7 +19,6 @@
from __future__ import annotations
import contextlib
-import hashlib
import json
import re
import shutil
@@ -34,9 +33,8 @@
from pathlib import Path
from typing import Any
-from owloop import notifications, spec_queue
+from owloop import notifications, spec_queue, verification
from owloop.adapters import DEFAULT_IDLE_TIMEOUT, AgentAdapter, AgentResult
-from owloop.backpressure import load_backpressure
from owloop.learnings import (
append_learning,
extract_learnings,
@@ -1021,97 +1019,46 @@ def _push(self, branch: str) -> None:
self._emit("push_retry", branch=branch)
self._run_git("push", "-u", "origin", branch)
- def _run_commands(self, commands: list[str]) -> tuple[int, int, list[dict[str, Any]]]:
- """Run shell commands from the engine (not the agent).
- Returns ``(passed, failed, failures)`` where each failure records the
- command, its exit code, and an output tail — the raw material for the
- failure-feedback file that primes the next iteration's retry.
- """
- passed = 0
- failures: list[dict[str, Any]] = []
- for command in commands:
- result = subprocess.run( # noqa: S602 - spec-authored commands, same trust model as the agent's own execution
- command, shell=True, cwd=self.cwd, capture_output=True, text=True,
- )
- if result.returncode == 0:
- passed += 1
- else:
- output = f"{result.stdout or ''}\n{result.stderr or ''}".strip()
- tail = "\n".join(output.splitlines()[-30:])[-2000:]
- failures.append(
- {"command": command, "returncode": result.returncode, "output": tail}
- )
- return passed, len(failures), failures
-
- def _run_acceptance_criteria(
- self, spec_name: str | None
- ) -> tuple[int, int, list[dict[str, Any]]]:
- """Run a spec's Acceptance Criteria shell commands; count passes vs failures."""
- if not spec_name:
- return 0, 0, []
- commands = spec_queue.get_acceptance_criteria_commands(self.specs_dir / spec_name)
- return self._run_commands(commands)
def _guarded_hash(self, spec_name: str | None) -> str:
- """Hash the spec sections + backpressure file the agent must not edit.
-
- The engine snapshots this before an iteration and re-checks it after,
- so an agent that rewrites its own success conditions (acceptance
- criteria, verification section, or the project's backpressure
- commands) is caught regardless of whether the commands then "pass".
- """
- h = hashlib.sha256()
- if spec_name:
- section = spec_queue.get_acceptance_criteria_section(self.specs_dir / spec_name)
- h.update(section.encode("utf-8"))
- backpressure = resolve_owloop_dir(self.cwd) / "backpressure.json"
- if backpressure.is_file():
- h.update(backpressure.read_bytes())
- return h.hexdigest()
+ """Hash the spec sections + backpressure file the agent must not edit."""
+ return verification.guarded_hash(self.cwd, self.specs_dir, spec_name)
def _run_verification_gate(
self, iteration: int, spec_name: str | None, guard_before: str
) -> tuple[bool, bool, list[dict[str, Any]]]:
"""Deterministically verify an iteration outside the agent's control.
- Returns ``(passed, tampered, failures)``. The engine — never the agent
- — runs the spec's acceptance-criteria commands and the project's
- backpressure commands via ``subprocess`` and lets exit codes decide.
- A tampered guard region fails the gate immediately with a distinct
- ``spec_tampered`` event, regardless of command results. ``failures``
- carries per-command details for the next iteration's failure feedback.
+ Delegates to the shared gate in ``verification.py`` (the single
+ definition of "verified" used by both the sequential engine and the
+ parallel orchestrator) and emits the gate events. ``failures`` carries
+ per-command details for the next iteration's failure feedback. A
+ tampered guard region fails the iteration with a distinct
+ ``spec_tampered`` event.
"""
self._emit("verification_gate_start", iteration=iteration, spec=spec_name)
+ result = verification.run_gate(self.cwd, self.specs_dir, spec_name, guard_before)
- if self._guarded_hash(spec_name) != guard_before:
+ if result.tampered:
self._emit("spec_tampered", iteration=iteration, spec=spec_name)
return False, True, []
- acc_passed, acc_failed, acc_failures = self._run_acceptance_criteria(spec_name)
- bp_commands = [cmd.command for cmd in load_backpressure(self.cwd)]
- bp_passed, bp_failed, bp_failures = self._run_commands(bp_commands)
-
- passed_count = acc_passed + bp_passed
- failed_count = acc_failed + bp_failed
- failures = acc_failures + bp_failures
- gate_ok = failed_count == 0
-
- if gate_ok:
+ if result.passed:
self._emit(
"verification_gate_passed",
iteration=iteration,
- passed=passed_count,
+ passed=result.passed_count,
)
else:
self._emit(
"verification_gate_failed",
iteration=iteration,
- passed=passed_count,
- failed=failed_count,
- commands=[f["command"] for f in failures],
+ passed=result.passed_count,
+ failed=result.failed_count,
+ commands=[f["command"] for f in result.failures],
)
- return gate_ok, False, failures
+ return result.passed, False, result.failures
def _head(self) -> str:
return str(self._run_git("rev-parse", "HEAD").stdout).strip()
@@ -1541,8 +1488,8 @@ def run(self) -> RunSummary:
if self.config.dry_run:
self._append_run_note(iteration, result.success, result.summary)
- acceptance_passed, acceptance_failed, _ = self._run_acceptance_criteria(
- active_spec
+ acceptance_passed, acceptance_failed, _ = verification.run_acceptance_criteria(
+ self.cwd, self.specs_dir, active_spec
)
current_head = self._head()
if current_head and current_head != dry_run_original_head:
diff --git a/src/owloop/parallel.py b/src/owloop/parallel.py
new file mode 100644
index 0000000..ae71cc3
--- /dev/null
+++ b/src/owloop/parallel.py
@@ -0,0 +1,304 @@
+"""Parallel workers over file-disjoint specs (Phase 4 of the #37 roadmap).
+
+The community lesson for parallel Ralph-style loops is: invest in **file-disjoint
+spec decomposition**, not lock coordination — schedule specs whose declared
+``## Files`` scopes don't overlap so their worktrees can never conflict, then
+merge the successful branches back with a plain ``git merge``. And never
+parallelize before stall detection exists (Phase 1), because parallelism
+multiplies the cost of missing progress.
+
+Each worker runs one target spec in its own ``git worktree`` on its own branch:
+implement → the *shared* deterministic gate (``verification.run_gate``) →
+commit. The orchestrator then merges each passed branch into the base branch.
+Because a batch is file-disjoint, those merges apply cleanly.
+
+The gate is the same one the sequential engine uses, so the "harness verifies,
+never the agent" invariant holds identically here.
+"""
+
+from __future__ import annotations
+
+import shutil
+import subprocess
+import uuid
+from collections.abc import Callable
+from concurrent.futures import ThreadPoolExecutor
+from dataclasses import dataclass
+from pathlib import Path
+
+from owloop import notifications, spec_queue, verification
+from owloop.adapters import AgentAdapter
+from owloop.engine import RunSummary, StopReason, classify_terminal_state
+from owloop.paths import resolve_owloop_dir, resolve_specs_dir
+from owloop.promise import parse_promise_signal
+
+WORKER_PROMPT = """\
+# Owloop — Parallel Build Worker
+
+You are one of several workers running concurrently, each on a disjoint set of
+files. Implement EXACTLY ONE spec and nothing else:
+
+ {spec_name}
+
+Read `AGENTS.md` / `CLAUDE.md` if present. Implement the spec completely and
+run its `## Acceptance Criteria` commands yourself to check your work. Then
+output `DONE`.
+
+Stay strictly inside the files listed in that spec's `## Files` section — other
+workers own the other files right now, and touching them will lose their work
+at merge time. Do NOT work on any other spec.
+
+The loop — not you — owns git and completion:
+- Do NOT commit or push. The loop commits only after re-running the acceptance
+ criteria itself and they pass.
+- Do NOT add a `Status: COMPLETE` line, and do NOT edit the spec's
+ `## Acceptance Criteria` / `## Verification` sections or
+ `.owloop/backpressure.json`. Rewriting your own success conditions fails the
+ iteration.
+
+If you hit an external blocker, output `BLOCKED:reason`.
+"""
+
+EventCallback = Callable[[str, dict], None]
+AdapterFactory = Callable[[], AgentAdapter]
+
+
+@dataclass
+class WorkerResult:
+ spec_name: str
+ passed: bool
+ branch: str
+ worktree: Path
+ tampered: bool = False
+ tokens_used: int = 0
+ reason: str = ""
+
+
+@dataclass
+class ParallelConfig:
+ project_dir: Path
+ workers: int = 2
+ max_rounds: int = 0 # 0 = until queue empty / stall
+ max_consecutive_failed_rounds: int = 3
+ notify_webhook: str | None = None
+ notify_desktop: bool = False
+
+
+class ParallelOrchestrator:
+ """Run file-disjoint specs concurrently across per-worker git worktrees."""
+
+ def __init__(
+ self,
+ config: ParallelConfig,
+ adapter_factory: AdapterFactory,
+ on_event: EventCallback | None = None,
+ ) -> None:
+ self.config = config
+ self.adapter_factory = adapter_factory
+ self.on_event = on_event or (lambda *_a: None)
+ self.base_dir = config.project_dir
+ self.session_id = uuid.uuid4().hex[:8]
+ self.tokens_used = 0
+
+ # ── git helpers ──
+
+ def _git(self, *args: str, cwd: Path | None = None) -> subprocess.CompletedProcess:
+ return subprocess.run(
+ ["git", *args],
+ cwd=cwd or self.base_dir,
+ capture_output=True,
+ text=True,
+ )
+
+ def _is_git_repo(self) -> bool:
+ return self._git("rev-parse", "--is-inside-work-tree").returncode == 0
+
+ def _current_branch(self) -> str:
+ return self._git("branch", "--show-current").stdout.strip() or "main"
+
+ def _head(self) -> str:
+ return str(self._git("rev-parse", "HEAD").stdout).strip()
+
+ def _emit(self, kind: str, **data: object) -> None:
+ self.on_event(kind, data)
+
+ @property
+ def specs_dir(self) -> Path:
+ return resolve_specs_dir(self.base_dir)
+
+ def preflight(self) -> list[str]:
+ issues = self.adapter_factory().preflight()
+ if not self._is_git_repo():
+ issues.append("current directory is not a git repository")
+ if not spec_queue.get_root_specs(self.specs_dir):
+ issues.append("no .md files in specs/")
+ return issues
+
+ # ── worker ──
+
+ def _run_worker(self, spec_name: str, base_head: str, index: int) -> WorkerResult:
+ wt_base = self.base_dir.parent / f"{self.base_dir.name}-owloop-wt"
+ wt_base.mkdir(parents=True, exist_ok=True)
+ branch = f"owloop/{self.session_id}-w{index}"
+ wt_path = wt_base / f"owloop-{self.session_id}-w{index}"
+
+ created = self._git("worktree", "add", str(wt_path), "-b", branch, base_head)
+ if created.returncode != 0:
+ return WorkerResult(spec_name, False, branch, wt_path, reason="worktree_failed")
+
+ # Materialize loop metadata (specs/backpressure) that may be untracked.
+ source = resolve_owloop_dir(self.base_dir)
+ 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)
+
+ wt_specs = resolve_specs_dir(wt_path)
+ guard_before = verification.guarded_hash(wt_path, wt_specs, spec_name)
+
+ adapter = self.adapter_factory()
+ result = adapter.run(
+ WORKER_PROMPT.format(spec_name=spec_name),
+ cwd=wt_path,
+ on_line=lambda line: self._emit("output_line", worker=index, line=line),
+ )
+ tokens = result.tokens_used
+ parsed = parse_promise_signal(result.stdout)
+ state = parsed[0] if parsed else ""
+
+ if state != "DONE":
+ self._emit("worker_no_done", spec=spec_name, worker=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)
+ 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,
+ failed=gate.failed_count)
+ return WorkerResult(spec_name, False, branch, wt_path, tokens_used=tokens,
+ reason="verification_failed")
+
+ # 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,
+ passed=gate.passed_count)
+ return WorkerResult(spec_name, True, branch, wt_path, tokens_used=tokens)
+
+ def _cleanup_worker(self, result: WorkerResult, *, keep_branch: bool) -> None:
+ self._git("worktree", "remove", "--force", str(result.worktree))
+ if not keep_branch:
+ self._git("branch", "-D", result.branch)
+
+ # ── run ──
+
+ def run(self) -> RunSummary:
+ issues = self.preflight()
+ if issues:
+ self._emit("preflight_failed", issues=issues)
+ return self._summary(StopReason.PREFLIGHT_FAILED, rounds=0, branch="", issues=issues)
+
+ base_branch = self._current_branch()
+ stopped_reason: StopReason = StopReason.SUCCESS
+ rounds = 0
+ consecutive_failed_rounds = 0
+
+ self._emit("parallel_session_info", workers=self.config.workers,
+ branch=base_branch, session_id=self.session_id)
+
+ while True:
+ if self.config.max_rounds and rounds >= self.config.max_rounds:
+ stopped_reason = StopReason.MAX_ITERATIONS
+ break
+
+ batch = spec_queue.get_parallel_batch(self.specs_dir, self.config.workers)
+ if not batch:
+ stopped_reason = StopReason.SUCCESS
+ self._emit("all_specs_complete",
+ spec_count=spec_queue.count_root_specs(self.specs_dir))
+ break
+
+ rounds += 1
+ base_head = self._head()
+ names = [p.name for p in batch]
+ self._emit("round_start", round=rounds, specs=names, size=len(names))
+
+ results = self._run_batch(names, base_head)
+ for r in results:
+ self.tokens_used += r.tokens_used
+
+ passed = [r for r in results if r.passed]
+ self._merge_passed(passed, base_branch)
+ for r in results:
+ self._cleanup_worker(r, keep_branch=False)
+
+ self._emit("round_end", round=rounds, passed=[r.spec_name for r in passed],
+ failed=[r.spec_name for r in results if not r.passed])
+
+ if passed:
+ consecutive_failed_rounds = 0
+ else:
+ consecutive_failed_rounds += 1
+ if consecutive_failed_rounds >= self.config.max_consecutive_failed_rounds:
+ stopped_reason = StopReason.STALLED
+ self._emit("stalled", reason="no_progress_rounds",
+ rounds=consecutive_failed_rounds)
+ break
+
+ summary = self._summary(stopped_reason, rounds=rounds, branch=base_branch)
+ notifications.notify_run_complete(
+ summary,
+ webhook_url=self.config.notify_webhook,
+ desktop=self.config.notify_desktop,
+ emit=lambda kind, **data: self._emit(kind, **data),
+ )
+ return summary
+
+ def _run_batch(self, names: list[str], base_head: str) -> list[WorkerResult]:
+ if len(names) == 1:
+ return [self._run_worker(names[0], base_head, 0)]
+ with ThreadPoolExecutor(max_workers=len(names)) as pool:
+ futures = [
+ pool.submit(self._run_worker, name, base_head, i)
+ for i, name in enumerate(names)
+ ]
+ return [f.result() for f in futures]
+
+ def _merge_passed(self, passed: list[WorkerResult], base_branch: str) -> None:
+ """Merge each verified worker branch into the base branch.
+
+ The batch is file-disjoint, so these merges never conflict. Merges run
+ sequentially in the base worktree in deterministic (spec-name) order.
+ """
+ for r in sorted(passed, key=lambda r: r.spec_name):
+ 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)
+ 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)
+
+ def _summary(
+ self, stopped_reason: StopReason, *, rounds: int, branch: str,
+ issues: list[str] | None = None,
+ ) -> RunSummary:
+ return RunSummary(
+ iterations=rounds,
+ branch=branch,
+ cwd=self.base_dir,
+ main_repo_dir=self.base_dir,
+ stopped_reason=str(stopped_reason),
+ terminal_state=classify_terminal_state(stopped_reason),
+ issues=issues,
+ tokens_used=self.tokens_used,
+ session_id=self.session_id,
+ )
diff --git a/src/owloop/spec_generator.py b/src/owloop/spec_generator.py
index 5134724..d1bc681 100644
--- a/src/owloop/spec_generator.py
+++ b/src/owloop/spec_generator.py
@@ -63,6 +63,8 @@
- Touch 1-5 files / < 300 lines
- Be independently verifiable (has its own acceptance criteria)
- Have a clear dependency order (which specs must complete first)
+- Declare its file scope in a `## Files` section, and prefer DISJOINT scopes
+ across independent specs so they can be run concurrently by parallel workers
Plan the decomposition as an ordered list before writing any spec. Common
decomposition patterns:
@@ -112,6 +114,10 @@
## Depends On
- [list spec names this depends on, or "none"]
+## Files
+- [each file/dir this spec may touch, one per line — paths or globs. Keep scopes
+ DISJOINT across sibling specs so they can run in parallel (`--workers N`).]
+
## Requirements
- [ ] Concrete, scoped task description. Prefer EARS-style phrasing where it
fits — "WHEN , THE SYSTEM SHALL ", "WHILE , THE
diff --git a/src/owloop/spec_queue.py b/src/owloop/spec_queue.py
index 1ecfbd5..161d534 100644
--- a/src/owloop/spec_queue.py
+++ b/src/owloop/spec_queue.py
@@ -24,6 +24,7 @@
number, then the lexicographically earliest filename.
"""
+import fnmatch
import re
from pathlib import Path
@@ -43,6 +44,10 @@
r"^##\s+Verification\s*$\n(.*?)(?=^#{1,2}\s|\Z)",
re.IGNORECASE | re.MULTILINE | re.DOTALL,
)
+_FILES_SECTION_RE = re.compile(
+ r"^##\s+Files\s*$\n(.*?)(?=^#{1,2}\s|\Z)",
+ re.IGNORECASE | re.MULTILINE | re.DOTALL,
+)
DEFAULT_PRIORITY = 999
@@ -277,3 +282,108 @@ def get_next_ready_spec(specs_dir: Path) -> Path | None:
ready.sort(key=lambda spec: (get_spec_priority(spec), spec.name))
return ready[0]
+
+
+def get_ready_specs(specs_dir: Path) -> list[Path]:
+ """Return all incomplete specs whose dependencies are complete, in run order.
+
+ Same readiness rule as ``get_next_ready_spec`` but returns the whole ready
+ frontier, sorted by (Priority, filename). Because two *ready* specs can only
+ depend on already-complete specs, they are guaranteed dependency-independent
+ of each other — so parallel scheduling only has to reason about file scope.
+ """
+ all_specs = get_root_specs(specs_dir)
+ incomplete = get_incomplete_root_specs(specs_dir)
+ incomplete_set = set(incomplete)
+ ready = [
+ spec
+ for spec in incomplete
+ if all(dep not in incomplete_set for dep in get_spec_dependencies(spec, all_specs))
+ ]
+ ready.sort(key=lambda spec: (get_spec_priority(spec), spec.name))
+ return ready
+
+
+def get_spec_file_scope(spec_file: Path) -> list[str]:
+ """Return the path/glob tokens a spec declares under its ``## Files`` section.
+
+ This is the contract that makes safe parallelism possible: a spec that
+ declares which files it touches can be scheduled alongside other specs whose
+ scopes don't overlap. A missing/empty section yields ``[]`` — an unknown
+ scope, which the scheduler treats conservatively (never parallel-safe).
+ """
+ if not spec_file.is_file():
+ return []
+ content = spec_file.read_text(encoding="utf-8", errors="replace")
+ match = _FILES_SECTION_RE.search(content)
+ if match is None:
+ return []
+ scope: list[str] = []
+ for item in _LIST_ITEM_RE.findall(match.group(1)):
+ cleaned = item.strip().strip("`").strip()
+ if cleaned and cleaned.lower() != "none":
+ scope.append(cleaned)
+ return scope
+
+
+def _paths_conflict(a: str, b: str) -> bool:
+ """True if two path/glob tokens could touch a common file."""
+ if a == b:
+ return True
+ if fnmatch.fnmatch(a, b) or fnmatch.fnmatch(b, a):
+ return True
+ an, bn = a.rstrip("/"), b.rstrip("/")
+ # Directory-prefix containment (only for non-glob tokens).
+ if "*" not in a and "?" not in a and (bn == an or bn.startswith(an + "/")):
+ return True
+ if "*" not in b and "?" not in b and (an == bn or an.startswith(bn + "/")): # noqa: SIM103
+ return True
+ return False
+
+
+def scopes_overlap(scope_a: list[str], scope_b: list[str]) -> bool:
+ """True if any token in one scope could touch a file in the other."""
+ return any(_paths_conflict(a, b) for a in scope_a for b in scope_b)
+
+
+def specs_are_disjoint(spec_a: Path, spec_b: Path) -> bool:
+ """True if two specs declare non-empty, non-overlapping file scopes.
+
+ An empty (undeclared) scope is never disjoint — the safe default is that an
+ unscoped spec might touch anything, so it must not run in parallel.
+ """
+ scope_a = get_spec_file_scope(spec_a)
+ scope_b = get_spec_file_scope(spec_b)
+ if not scope_a or not scope_b:
+ return False
+ return not scopes_overlap(scope_a, scope_b)
+
+
+def get_parallel_batch(specs_dir: Path, max_workers: int) -> list[Path]:
+ """Select a batch of ready specs safe to run concurrently.
+
+ Greedy: start from the highest-priority ready spec and add further ready
+ specs whose declared ``## Files`` scope is disjoint from every spec already
+ in the batch, up to ``max_workers``. If the lead spec has no declared scope
+ the batch is just ``[lead]`` (run it alone) — correctness over parallelism.
+ Returns ``[]`` only when nothing is ready.
+ """
+ ready = get_ready_specs(specs_dir)
+ if not ready:
+ return []
+ if max_workers <= 1:
+ return [ready[0]]
+
+ lead = ready[0]
+ batch = [lead]
+ if not get_spec_file_scope(lead):
+ return batch # unknown scope → run alone
+
+ for candidate in ready[1:]:
+ if len(batch) >= max_workers:
+ break
+ if get_spec_file_scope(candidate) and all(
+ specs_are_disjoint(candidate, chosen) for chosen in batch
+ ):
+ batch.append(candidate)
+ return batch
diff --git a/src/owloop/verification.py b/src/owloop/verification.py
new file mode 100644
index 0000000..67a2a4c
--- /dev/null
+++ b/src/owloop/verification.py
@@ -0,0 +1,109 @@
+"""The deterministic verification gate — the single place work is graded.
+
+owloop's central invariant (#32): completion is decided by the *harness* running
+the spec's acceptance criteria + the project's backpressure commands via
+``subprocess``, outside the agent's control, never by the agent's own say-so. A
+snapshot hash of the sections the agent must not touch (acceptance criteria,
+verification, ``backpressure.json``) catches an agent that rewrites its own
+success conditions.
+
+Both the sequential engine and the parallel orchestrator call these functions,
+so the gate is shared, not duplicated — there is exactly one definition of
+"verified".
+"""
+
+from __future__ import annotations
+
+import hashlib
+import subprocess
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+from owloop import spec_queue
+from owloop.backpressure import load_backpressure
+from owloop.paths import resolve_owloop_dir
+
+
+@dataclass
+class GateResult:
+ passed: bool
+ tampered: bool
+ passed_count: int
+ failed_count: int
+ failures: list[dict[str, Any]] = field(default_factory=list)
+
+
+def run_commands(
+ cwd: Path, commands: list[str]
+) -> tuple[int, int, list[dict[str, Any]]]:
+ """Run shell commands from the harness (not the agent).
+
+ Returns ``(passed, failed, failures)`` where each failure records the
+ command, its exit code, and an output tail for failure feedback.
+ """
+ passed = failed = 0
+ failures: list[dict[str, Any]] = []
+ for command in commands:
+ result = subprocess.run( # noqa: S602 - spec-authored commands, same trust model as the agent's own execution
+ command, shell=True, cwd=cwd, capture_output=True, text=True,
+ )
+ if result.returncode == 0:
+ passed += 1
+ else:
+ failed += 1
+ output = f"{result.stdout or ''}\n{result.stderr or ''}".strip()
+ tail = "\n".join(output.splitlines()[-30:])[-2000:]
+ failures.append(
+ {"command": command, "returncode": result.returncode, "output": tail}
+ )
+ return passed, failed, failures
+
+
+def run_acceptance_criteria(
+ cwd: Path, specs_dir: Path, spec_name: str | None
+) -> tuple[int, int, list[dict[str, Any]]]:
+ """Run a spec's Acceptance Criteria shell commands; count passes vs failures."""
+ if not spec_name:
+ return 0, 0, []
+ commands = spec_queue.get_acceptance_criteria_commands(specs_dir / spec_name)
+ return run_commands(cwd, commands)
+
+
+def guarded_hash(cwd: Path, specs_dir: Path, spec_name: str | None) -> str:
+ """Hash the spec sections + backpressure file the agent must not edit."""
+ h = hashlib.sha256()
+ if spec_name:
+ section = spec_queue.get_acceptance_criteria_section(specs_dir / spec_name)
+ h.update(section.encode("utf-8"))
+ backpressure = resolve_owloop_dir(cwd) / "backpressure.json"
+ if backpressure.is_file():
+ h.update(backpressure.read_bytes())
+ return h.hexdigest()
+
+
+def run_gate(
+ cwd: Path, specs_dir: Path, spec_name: str | None, guard_before: str
+) -> GateResult:
+ """Deterministically verify one iteration's work outside the agent's control.
+
+ A guard region that changed since ``guard_before`` fails immediately as
+ ``tampered``; otherwise the acceptance-criteria and backpressure commands
+ decide pass/fail by exit code.
+ """
+ if guarded_hash(cwd, specs_dir, spec_name) != guard_before:
+ return GateResult(passed=False, tampered=True, passed_count=0, failed_count=0)
+
+ acc_passed, acc_failed, acc_failures = run_acceptance_criteria(cwd, specs_dir, spec_name)
+ bp_commands = [cmd.command for cmd in load_backpressure(cwd)]
+ bp_passed, bp_failed, bp_failures = run_commands(cwd, bp_commands)
+
+ passed_count = acc_passed + bp_passed
+ failed_count = acc_failed + bp_failed
+ return GateResult(
+ passed=failed_count == 0,
+ tampered=False,
+ passed_count=passed_count,
+ failed_count=failed_count,
+ failures=acc_failures + bp_failures,
+ )
diff --git a/templates/spec-template.md b/templates/spec-template.md
index c20579a..f514a27 100644
--- a/templates/spec-template.md
+++ b/templates/spec-template.md
@@ -2,6 +2,14 @@
## Priority: [1-5]
+## Files
+[The files/directories this spec is allowed to touch, one per line — paths or
+globs. This scope is what lets `owloop run --workers N` schedule this spec
+alongside others whose scopes don't overlap. Omit it and the spec runs alone.]
+
+- src/[module]/
+- tests/test_[module].py
+
## Requirements
[What to build — the functional description of the feature. EARS-style phrasing
maps cleanly onto shell-verifiable criteria: "WHEN , THE SYSTEM SHALL
diff --git a/tests/test_parallel.py b/tests/test_parallel.py
new file mode 100644
index 0000000..4df038b
--- /dev/null
+++ b/tests/test_parallel.py
@@ -0,0 +1,147 @@
+"""Tests for the file-disjoint parallel worker orchestrator (Phase 4)."""
+
+from __future__ import annotations
+
+import subprocess
+from pathlib import Path
+
+from owloop.adapters import AgentResult, MockAdapter
+from owloop.parallel import ParallelConfig, ParallelOrchestrator
+
+
+def _git(cwd: Path, *args: str) -> None:
+ subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True)
+
+
+def _repo_with_specs(tmp_path: Path, specs: dict[str, list[str]]) -> Path:
+ """Create a git repo with committed, file-scoped specs (acceptance: `true`)."""
+ repo = tmp_path / "repo"
+ repo.mkdir()
+ _git(repo, "init")
+ _git(repo, "config", "user.email", "t@example.com")
+ _git(repo, "config", "user.name", "T")
+ (repo / "README.md").write_text("# t", encoding="utf-8")
+ specs_dir = repo / ".owloop" / "specs"
+ specs_dir.mkdir(parents=True)
+ for name, files in specs.items():
+ files_block = "\n".join(f"- {f}" for f in files)
+ (specs_dir / name).write_text(
+ f"# Spec: {name}\n\n## Priority: 1\n\n## Files\n{files_block}\n\n"
+ f"## Acceptance Criteria\n- `true`\n\n## Exclusions\n- Do NOT touch other files\n",
+ encoding="utf-8",
+ )
+ _git(repo, "add", "-A")
+ _git(repo, "commit", "-m", "init with specs")
+ return repo
+
+
+def _done() -> AgentResult:
+ return AgentResult(
+ stdout="DONE",
+ returncode=0,
+ success=True,
+ has_completion_signal=True,
+ done_signal="DONE",
+ )
+
+
+def _done_factory():
+ return MockAdapter(responses=[_done()])
+
+
+def _is_complete(repo: Path, spec: str) -> bool:
+ from owloop.spec_queue import is_root_spec_complete
+
+ return is_root_spec_complete(repo / ".owloop" / "specs" / spec)
+
+
+def test_parallel_completes_disjoint_specs(tmp_path: Path) -> None:
+ repo = _repo_with_specs(tmp_path, {
+ "01-a.md": ["src/a/"],
+ "02-b.md": ["src/b/"],
+ })
+ config = ParallelConfig(project_dir=repo, workers=2)
+ events: list[tuple[str, dict]] = []
+ orch = ParallelOrchestrator(config, _done_factory, on_event=lambda k, d: events.append((k, d)))
+
+ summary = orch.run()
+
+ assert summary.stopped_reason == "success"
+ assert _is_complete(repo, "01-a.md")
+ assert _is_complete(repo, "02-b.md")
+ # First round scheduled both specs together (disjoint scopes).
+ round_starts = [d for k, d in events if k == "round_start"]
+ assert round_starts and round_starts[0]["size"] == 2
+ assert sum(1 for k, _ in events if k == "worker_merged") == 2
+
+
+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.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
+ # Worktrees are cleaned up.
+ assert subprocess.run(
+ ["git", "worktree", "list"], cwd=repo, capture_output=True, text=True
+ ).stdout.count("owloop-") == 0
+
+
+def test_parallel_overlapping_specs_run_in_separate_rounds(tmp_path: Path) -> None:
+ # Both specs touch src/shared/ → never batched together.
+ repo = _repo_with_specs(tmp_path, {
+ "01-a.md": ["src/shared/a.py"],
+ "02-b.md": ["src/shared/b.py", "src/shared/"],
+ })
+ # Force conflict: 02 declares the whole dir, 01 a file inside it.
+ events: list[tuple[str, dict]] = []
+ orch = ParallelOrchestrator(
+ ParallelConfig(project_dir=repo, workers=2), _done_factory,
+ on_event=lambda k, d: events.append((k, d)),
+ )
+ summary = orch.run()
+
+ assert summary.stopped_reason == "success"
+ sizes = [d["size"] for k, d in events if k == "round_start"]
+ assert all(s == 1 for s in sizes) # never parallelized
+ assert len(sizes) == 2 # two sequential rounds
+
+
+def test_parallel_stalls_when_no_worker_makes_progress(tmp_path: Path) -> None:
+ repo = _repo_with_specs(tmp_path, {"01-a.md": ["src/a/"], "02-b.md": ["src/b/"]})
+
+ # Workers never emit DONE → no spec ever completes.
+ def _fail_factory():
+ return MockAdapter(responses=[AgentResult(
+ stdout="nope", returncode=0, success=True, has_completion_signal=False,
+ )])
+
+ events: list[tuple[str, dict]] = []
+ orch = ParallelOrchestrator(
+ ParallelConfig(project_dir=repo, workers=2, max_consecutive_failed_rounds=2),
+ _fail_factory, on_event=lambda k, d: events.append((k, d)),
+ )
+ summary = orch.run()
+
+ assert summary.stopped_reason == "stalled"
+ assert summary.state == "stalled"
+ assert any(k == "stalled" for k, _ in events)
+ assert not _is_complete(repo, "01-a.md")
+
+
+def test_parallel_preflight_fails_without_specs(tmp_path: Path) -> None:
+ repo = tmp_path / "repo"
+ repo.mkdir()
+ _git(repo, "init")
+ _git(repo, "config", "user.email", "t@example.com")
+ _git(repo, "config", "user.name", "T")
+ (repo / ".owloop" / "specs").mkdir(parents=True)
+ orch = ParallelOrchestrator(ParallelConfig(project_dir=repo, workers=2), _done_factory)
+
+ summary = orch.run()
+ assert summary.stopped_reason == "preflight_failed"
diff --git a/tests/test_spec_queue.py b/tests/test_spec_queue.py
index 58106ce..fb15f04 100644
--- a/tests/test_spec_queue.py
+++ b/tests/test_spec_queue.py
@@ -209,3 +209,93 @@ def test_get_acceptance_criteria_section_missing_returns_empty(tmp_path: Path) -
spec = tmp_path / "01-t.md"
spec.write_text("# Spec\n\n## Requirements\n- x\n", encoding="utf-8")
assert spec_queue.get_acceptance_criteria_section(spec) == ""
+
+
+# ── file-disjoint parallel scheduling (Phase 4) ──
+
+
+def _scoped_spec(priority: int, files: list[str], depends_on: list[str] | None = None,
+ complete: bool = False) -> str:
+ lines = ["# Spec: test", ""]
+ if complete:
+ lines += ["**Status**: COMPLETE", ""]
+ lines += [f"## Priority: {priority}", ""]
+ if depends_on is not None:
+ lines += ["## Depends On"] + ([f"- {d}" for d in depends_on] or ["- none"]) + [""]
+ lines += ["## Files"] + [f"- {f}" for f in files] + [""]
+ lines += ["## Requirements", "Do a thing.", ""]
+ return "\n".join(lines)
+
+
+def test_get_spec_file_scope_parses_files_section(tmp_path: Path) -> None:
+ spec = _write(tmp_path, "001-a.md", _scoped_spec(1, ["src/a/", "`tests/test_a.py`"]))
+ assert spec_queue.get_spec_file_scope(spec) == ["src/a/", "tests/test_a.py"]
+
+
+def test_get_spec_file_scope_missing_section_is_empty(tmp_path: Path) -> None:
+ spec = _write(tmp_path, "001-a.md", _spec(priority=1))
+ assert spec_queue.get_spec_file_scope(spec) == []
+
+
+def test_specs_are_disjoint_true_for_separate_dirs(tmp_path: Path) -> None:
+ a = _write(tmp_path, "001-a.md", _scoped_spec(1, ["src/a/"]))
+ b = _write(tmp_path, "002-b.md", _scoped_spec(1, ["src/b/"]))
+ assert spec_queue.specs_are_disjoint(a, b) is True
+
+
+def test_specs_are_disjoint_false_on_overlap(tmp_path: Path) -> None:
+ a = _write(tmp_path, "001-a.md", _scoped_spec(1, ["src/a/"]))
+ b = _write(tmp_path, "002-b.md", _scoped_spec(1, ["src/a/util.py"]))
+ assert spec_queue.specs_are_disjoint(a, b) is False
+
+
+def test_specs_are_disjoint_false_on_glob_match(tmp_path: Path) -> None:
+ a = _write(tmp_path, "001-a.md", _scoped_spec(1, ["src/*.py"]))
+ b = _write(tmp_path, "002-b.md", _scoped_spec(1, ["src/main.py"]))
+ assert spec_queue.specs_are_disjoint(a, b) is False
+
+
+def test_specs_are_disjoint_false_when_scope_unknown(tmp_path: Path) -> None:
+ a = _write(tmp_path, "001-a.md", _scoped_spec(1, ["src/a/"]))
+ b = _write(tmp_path, "002-b.md", _spec(priority=1)) # no ## Files
+ assert spec_queue.specs_are_disjoint(a, b) is False
+
+
+def test_get_parallel_batch_groups_disjoint_specs(tmp_path: Path) -> None:
+ specs = tmp_path / "specs"
+ _write(specs, "001-a.md", _scoped_spec(1, ["src/a/"]))
+ _write(specs, "002-b.md", _scoped_spec(1, ["src/b/"]))
+ _write(specs, "003-c.md", _scoped_spec(1, ["src/a/deep.py"])) # conflicts with a
+ batch = spec_queue.get_parallel_batch(specs, max_workers=4)
+ names = {p.name for p in batch}
+ assert names == {"001-a.md", "002-b.md"} # c excluded (overlaps a)
+
+
+def test_get_parallel_batch_respects_max_workers(tmp_path: Path) -> None:
+ specs = tmp_path / "specs"
+ _write(specs, "001-a.md", _scoped_spec(1, ["src/a/"]))
+ _write(specs, "002-b.md", _scoped_spec(1, ["src/b/"]))
+ _write(specs, "003-c.md", _scoped_spec(1, ["src/c/"]))
+ batch = spec_queue.get_parallel_batch(specs, max_workers=2)
+ assert len(batch) == 2
+
+
+def test_get_parallel_batch_lead_without_scope_runs_alone(tmp_path: Path) -> None:
+ specs = tmp_path / "specs"
+ _write(specs, "001-a.md", _spec(priority=1)) # highest priority, no ## Files
+ _write(specs, "002-b.md", _scoped_spec(2, ["src/b/"]))
+ batch = spec_queue.get_parallel_batch(specs, max_workers=4)
+ assert [p.name for p in batch] == ["001-a.md"]
+
+
+def test_get_parallel_batch_max_workers_one_is_sequential(tmp_path: Path) -> None:
+ specs = tmp_path / "specs"
+ _write(specs, "001-a.md", _scoped_spec(1, ["src/a/"]))
+ _write(specs, "002-b.md", _scoped_spec(1, ["src/b/"]))
+ assert len(spec_queue.get_parallel_batch(specs, max_workers=1)) == 1
+
+
+def test_get_parallel_batch_empty_when_nothing_ready(tmp_path: Path) -> None:
+ specs = tmp_path / "specs"
+ _write(specs, "001-a.md", _scoped_spec(1, ["src/a/"], complete=True))
+ assert spec_queue.get_parallel_batch(specs, max_workers=4) == []
diff --git a/uv.lock b/uv.lock
index b23ad86..d27af61 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,5 +1,5 @@
version = 1
-revision = 3
+revision = 2
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.15'",
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]]
name = "ast-serialize"
version = "0.6.0"
-source = { registry = "https://pypi.org/simple" }
+source = { registry = "https://pypi.org/simple/" }
sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" },
@@ -50,7 +50,7 @@ wheels = [
[[package]]
name = "click"
version = "8.4.2"
-source = { registry = "https://pypi.org/simple" }
+source = { registry = "https://pypi.org/simple/" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
@@ -62,7 +62,7 @@ wheels = [
[[package]]
name = "colorama"
version = "0.4.6"
-source = { registry = "https://pypi.org/simple" }
+source = { registry = "https://pypi.org/simple/" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
@@ -71,7 +71,7 @@ wheels = [
[[package]]
name = "coverage"
version = "7.15.0"
-source = { registry = "https://pypi.org/simple" }
+source = { registry = "https://pypi.org/simple/" }
sdist = { url = "https://files.pythonhosted.org/packages/cc/8b/adeb62ea8951f13c4c7fef2e7a85e1a06b499c8d8237ea589d496029e53f/coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", size = 925362, upload-time = "2026-07-02T13:10:50.535Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/97/c52dc440c390b6cfa87be9432b141a956e2d56d9b9f5fc8bd71c5f471722/coverage-7.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:50913d4bf5ddafa6ca3693da5e4dd833dd1b772e0283c99ca7f7d287db67331a", size = 220539, upload-time = "2026-07-02T13:08:19.252Z" },
@@ -174,7 +174,7 @@ toml = [
[[package]]
name = "exceptiongroup"
version = "1.3.1"
-source = { registry = "https://pypi.org/simple" }
+source = { registry = "https://pypi.org/simple/" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
@@ -186,7 +186,7 @@ wheels = [
[[package]]
name = "iniconfig"
version = "2.3.0"
-source = { registry = "https://pypi.org/simple" }
+source = { registry = "https://pypi.org/simple/" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
@@ -195,7 +195,7 @@ wheels = [
[[package]]
name = "librt"
version = "0.12.0"
-source = { registry = "https://pypi.org/simple" }
+source = { registry = "https://pypi.org/simple/" }
sdist = { url = "https://files.pythonhosted.org/packages/c6/e0/dbd0f2a68a1c1a1991eb7921ff6014465d56608cdc9a9fb468a616210a37/librt-0.12.0.tar.gz", hash = "sha256:cb26faedbd09c6130e9c1b64d8000efec5076ffd18d606c6cd1cf02730e6d8b0", size = 203841, upload-time = "2026-06-30T16:14:29.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/66/c9d88366893b4b0df6b5375c27ebc9f14c43419d9e244b493be20e85bc74/librt-0.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fe3547407bbce45c09885591f90168325c5a31a6795b9a13f6b9ff3d25093d93", size = 144398, upload-time = "2026-06-30T16:12:03.947Z" },
@@ -282,7 +282,7 @@ wheels = [
[[package]]
name = "markdown-it-py"
version = "4.2.0"
-source = { registry = "https://pypi.org/simple" }
+source = { registry = "https://pypi.org/simple/" }
dependencies = [
{ name = "mdurl" },
]
@@ -294,7 +294,7 @@ wheels = [
[[package]]
name = "mdurl"
version = "0.1.2"
-source = { registry = "https://pypi.org/simple" }
+source = { registry = "https://pypi.org/simple/" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
@@ -303,7 +303,7 @@ wheels = [
[[package]]
name = "mypy"
version = "2.1.0"
-source = { registry = "https://pypi.org/simple" }
+source = { registry = "https://pypi.org/simple/" }
dependencies = [
{ name = "ast-serialize" },
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
@@ -362,7 +362,7 @@ wheels = [
[[package]]
name = "mypy-extensions"
version = "1.1.0"
-source = { registry = "https://pypi.org/simple" }
+source = { registry = "https://pypi.org/simple/" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
@@ -404,7 +404,7 @@ dev = [
[[package]]
name = "packaging"
version = "26.2"
-source = { registry = "https://pypi.org/simple" }
+source = { registry = "https://pypi.org/simple/" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
@@ -413,7 +413,7 @@ wheels = [
[[package]]
name = "pathspec"
version = "1.1.1"
-source = { registry = "https://pypi.org/simple" }
+source = { registry = "https://pypi.org/simple/" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
@@ -422,7 +422,7 @@ wheels = [
[[package]]
name = "pluggy"
version = "1.6.0"
-source = { registry = "https://pypi.org/simple" }
+source = { registry = "https://pypi.org/simple/" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
@@ -431,7 +431,7 @@ wheels = [
[[package]]
name = "pygments"
version = "2.20.0"
-source = { registry = "https://pypi.org/simple" }
+source = { registry = "https://pypi.org/simple/" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
@@ -440,7 +440,7 @@ wheels = [
[[package]]
name = "pytest"
version = "9.1.1"
-source = { registry = "https://pypi.org/simple" }
+source = { registry = "https://pypi.org/simple/" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
@@ -458,7 +458,7 @@ wheels = [
[[package]]
name = "pytest-cov"
version = "7.1.0"
-source = { registry = "https://pypi.org/simple" }
+source = { registry = "https://pypi.org/simple/" }
dependencies = [
{ name = "coverage", extra = ["toml"] },
{ name = "pluggy" },
@@ -472,7 +472,7 @@ wheels = [
[[package]]
name = "rich"
version = "15.0.0"
-source = { registry = "https://pypi.org/simple" }
+source = { registry = "https://pypi.org/simple/" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
@@ -485,7 +485,7 @@ wheels = [
[[package]]
name = "ruff"
version = "0.15.20"
-source = { registry = "https://pypi.org/simple" }
+source = { registry = "https://pypi.org/simple/" }
sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" },
@@ -510,7 +510,7 @@ wheels = [
[[package]]
name = "tomli"
version = "2.4.1"
-source = { registry = "https://pypi.org/simple" }
+source = { registry = "https://pypi.org/simple/" }
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
@@ -564,7 +564,7 @@ wheels = [
[[package]]
name = "typing-extensions"
version = "4.16.0"
-source = { registry = "https://pypi.org/simple" }
+source = { registry = "https://pypi.org/simple/" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },