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" },