Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 91 additions & 8 deletions src/ouroboros/cli/commands/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from ouroboros.cli.formatters.panels import print_error, print_info, print_success, print_warning
from ouroboros.config.loader import get_max_parallel_workers
from ouroboros.core.errors import ConfigError
from ouroboros.core.project_paths import resolve_seed_project_path
from ouroboros.core.project_paths import resolve_path_against_base, resolve_seed_project_path
from ouroboros.core.security import InputValidator
from ouroboros.core.worktree import (
TaskWorkspace,
Expand Down Expand Up @@ -113,18 +113,83 @@ def _load_seed_from_yaml(seed_file: Path) -> dict[str, Any]:
raise typer.Exit(1) from e


def _resolve_cli_project_dir(seed: "Seed", seed_file: Path) -> Path:
def _resolve_raw_metadata_project_dir(
seed_data: dict[str, Any],
*,
stable_base: Path,
) -> Path | None:
"""Resolve legacy raw metadata project_dir/working_directory fields."""
metadata = seed_data.get("metadata")
if not isinstance(metadata, dict):
return None

raw_project_dir = metadata.get("project_dir") or metadata.get("working_directory")
if not isinstance(raw_project_dir, str) or not raw_project_dir.strip():
return None

resolved = resolve_path_against_base(
raw_project_dir,
stable_base=stable_base,
enforce_containment=True,
)
if resolved is None:
print_error(
"Seed metadata encodes a project_dir/working_directory path that escapes "
f"the seed stable project directory ({stable_base}). Refusing to fall back "
"silently — edit the seed metadata to use a path inside the seed directory "
"or rerun with --project-dir pointing at the target project."
)
raise typer.Exit(1)
return resolved


def _resolve_brownfield_target_dir(seed_data: dict[str, Any]) -> Path | None:
"""Return an existing brownfield target_dir from raw seed data, if present."""
brownfield_context = seed_data.get("brownfield_context")
if not isinstance(brownfield_context, dict):
return None

raw_target_dir = brownfield_context.get("target_dir")
if not isinstance(raw_target_dir, str) or not raw_target_dir.strip():
return None

target_dir = Path(raw_target_dir).expanduser().resolve()
return target_dir if target_dir.is_dir() else None


def _directory_for_runtime(path: Path) -> Path:
"""Normalize a resolved project candidate into a runtime cwd."""
return path.parent if path.is_file() else path


def _resolve_cli_project_dir(
seed: "Seed",
seed_file: Path,
*,
seed_data: dict[str, Any] | None = None,
project_dir: Path | None = None,
) -> Path:
"""Resolve the project directory for CLI execution and verification."""
stable_base = seed_file.parent.resolve()
if project_dir is not None:
return project_dir.expanduser().resolve()

seed_data = seed_data or {}
seed_base = seed_file.parent.resolve()
metadata_project_dir = _resolve_raw_metadata_project_dir(seed_data, stable_base=seed_base)
if metadata_project_dir is not None:
return _directory_for_runtime(metadata_project_dir)

target_dir = _resolve_brownfield_target_dir(seed_data)
stable_base = target_dir or seed_base
resolution = resolve_seed_project_path(seed, stable_base=stable_base)
if resolution.path is not None:
return resolution.path
return _directory_for_runtime(resolution.path)
if resolution.rejected:
print_error(
"Seed encodes a project_dir/brownfield path that escapes the seed "
f"file's directory ({stable_base}). Refusing to fall back silently — "
"edit the seed to use a path inside the seed directory or rerun "
"with the seed copied next to the target project."
f"stable project directory ({stable_base}). Refusing to fall back silently — "
"edit the seed to use a path inside the project directory or rerun "
"with --project-dir pointing at the target project."
)
raise typer.Exit(1)
return stable_base
Expand Down Expand Up @@ -380,6 +445,7 @@ async def _run_orchestrator(
runtime_backend: str | None = None,
max_decomposition_depth: int | None = None,
skip_completed: str | None = None,
project_dir: Path | None = None,
) -> None:
"""Run workflow via orchestrator mode.

Expand All @@ -394,6 +460,7 @@ async def _run_orchestrator(
runtime_backend: Optional orchestrator runtime backend override.
max_decomposition_depth: Optional recursive decomposition depth cap override.
skip_completed: Optional path to a marker file for already-satisfied ACs.
project_dir: Optional explicit project directory for seed path resolution.
"""
from ouroboros.core.seed import Seed
from ouroboros.orchestrator import OrchestratorRunner, create_agent_runtime
Expand Down Expand Up @@ -448,7 +515,12 @@ async def _run_orchestrator(
event_store = EventStore(f"sqlite+aiosqlite:///{db_path}")
await event_store.initialize()

project_dir = _resolve_cli_project_dir(seed, seed_file)
project_dir = _resolve_cli_project_dir(
seed,
seed_file,
seed_data=seed_data,
project_dir=project_dir,
)
session_repo = SessionRepository(event_store)
workspace: TaskWorkspace | None = None
execution_id: str | None = None
Expand Down Expand Up @@ -630,6 +702,16 @@ def workflow(
help="Prefix to add to all MCP tool names (e.g., 'mcp_').",
),
] = "",
project_dir: Annotated[
Path | None,
typer.Option(
"--project-dir",
help="Explicit project directory for resolving seed-relative paths.",
file_okay=False,
dir_okay=True,
resolve_path=True,
),
] = None,
dry_run: Annotated[
bool,
typer.Option("--dry-run", "-n", help="Validate seed without executing."),
Expand Down Expand Up @@ -752,6 +834,7 @@ def workflow(
runtime_backend=runtime.value if runtime else None,
max_decomposition_depth=max_decomposition_depth,
skip_completed=skip_completed,
project_dir=project_dir,
)
)
except (ValueError, NotImplementedError) as e:
Expand Down
138 changes: 138 additions & 0 deletions tests/unit/cli/test_run_qa.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@

from ouroboros.cli.commands.run import (
_load_skip_completed_markers,
_resolve_cli_project_dir,
_resolve_fat_harness_mode,
_resolve_max_decomposition_depth,
_resolve_max_parallel_workers,
_resolve_resume_fat_harness_mode,
_run_orchestrator,
)
from ouroboros.core.seed import Seed
from ouroboros.core.types import Result
from ouroboros.evaluation.verification_artifacts import VerificationArtifacts
from ouroboros.mcp.types import ContentType, MCPContentItem, MCPToolResult
Expand Down Expand Up @@ -78,6 +80,142 @@
)


def test_resolve_cli_project_dir_prefers_explicit_project_dir(tmp_path: Path) -> None:
"""--project-dir should be the highest-priority run boundary."""
seed_file = tmp_path / "seeds" / "seed.yaml"
seed_file.parent.mkdir()
seed_file.write_text("goal: ignored\n", encoding="utf-8")
explicit_project = tmp_path / "project"
explicit_project.mkdir()
seed = Seed.from_dict(VALID_SEED_DATA)

assert (
_resolve_cli_project_dir(
seed,
seed_file,
seed_data=VALID_SEED_DATA,
project_dir=explicit_project,
)
== explicit_project.resolve()
)


def test_resolve_cli_project_dir_uses_brownfield_target_dir_when_present(
tmp_path: Path,
) -> None:
"""Seeds in a central library may target an external brownfield repo."""
seed_file = tmp_path / "seed-library" / "seed.yaml"
seed_file.parent.mkdir()
seed_file.write_text("goal: ignored\n", encoding="utf-8")
target_dir = tmp_path / "work" / "myproject"
target_dir.mkdir(parents=True)
seed_data = {
**VALID_SEED_DATA,
"brownfield_context": {
"project_type": "brownfield",
"target_dir": str(target_dir),
"context_references": [
{"path": "main.py", "role": "primary", "summary": "target file"},
],
},
}
(target_dir / "main.py").write_text("print('hi')\n", encoding="utf-8")
seed = Seed.from_dict(seed_data)

assert _resolve_cli_project_dir(seed, seed_file, seed_data=seed_data) == target_dir.resolve()


def test_resolve_cli_project_dir_falls_back_to_seed_parent_without_project_hints(
tmp_path: Path,
) -> None:
"""Back-compat path remains the seed file directory."""
seed_file = tmp_path / "seeds" / "seed.yaml"
seed_file.parent.mkdir()
seed_file.write_text("goal: ignored\n", encoding="utf-8")
seed = Seed.from_dict(VALID_SEED_DATA)

assert (
_resolve_cli_project_dir(seed, seed_file, seed_data=VALID_SEED_DATA)
== seed_file.parent.resolve()
)


def test_resolve_cli_project_dir_keeps_seed_relative_metadata_project_dir(
tmp_path: Path,
) -> None:
"""metadata.project_dir keeps working with the existing seed-relative behavior."""
seed_file = tmp_path / "seeds" / "seed.yaml"
seed_file.parent.mkdir()
seed_file.write_text("goal: ignored\n", encoding="utf-8")
seed_data = {
**VALID_SEED_DATA,
"metadata": {**VALID_SEED_DATA["metadata"], "project_dir": "repo-root"},
}
seed = Seed.from_dict(seed_data)

assert (
_resolve_cli_project_dir(seed, seed_file, seed_data=seed_data)
== (seed_file.parent / "repo-root").resolve()
)


@pytest.mark.parametrize("metadata_field", ["project_dir", "working_directory"])
def test_resolve_cli_project_dir_rejects_raw_metadata_project_escape(
tmp_path: Path, metadata_field: str
) -> None:
"""Raw metadata project fields must not silently fall back after rejection."""
seed_file = tmp_path / "seeds" / "seed.yaml"
seed_file.parent.mkdir()
seed_file.write_text("goal: ignored\n", encoding="utf-8")
outside_project = tmp_path / "outside-project"
seed_data = {
**VALID_SEED_DATA,
"metadata": {
**VALID_SEED_DATA["metadata"],
metadata_field: str(outside_project),
},
}
seed = Seed.from_dict(seed_data)

with patch("ouroboros.cli.commands.run.print_error") as mock_print:
with pytest.raises(typer.Exit) as exc_info:
_resolve_cli_project_dir(seed, seed_file, seed_data=seed_data)

assert exc_info.value.exit_code == 1
assert mock_print.call_count == 1
assert "escapes" in mock_print.call_args[0][0]


def test_resolve_cli_project_dir_uses_parent_when_context_reference_is_file(
tmp_path: Path,
) -> None:
"""A primary file reference should not become the runtime cwd itself."""
seed_file = tmp_path / "seed-library" / "seed.yaml"
seed_file.parent.mkdir()
seed_file.write_text("goal: ignored\n", encoding="utf-8")
target_dir = tmp_path / "work" / "myproject"
target_dir.mkdir(parents=True)
source_file = target_dir / "src" / "main.py"
source_file.parent.mkdir()
source_file.write_text("print('hi')\n", encoding="utf-8")
seed_data = {
**VALID_SEED_DATA,
"brownfield_context": {
"project_type": "brownfield",
"target_dir": str(target_dir),
"context_references": [
{"path": "src/main.py", "role": "primary", "summary": "target file"},
],
},
}
seed = Seed.from_dict(seed_data)

assert (
_resolve_cli_project_dir(seed, seed_file, seed_data=seed_data)
== source_file.parent.resolve()
)


def test_resolve_fat_harness_mode_defaults_to_enabled() -> None:
"""The #920 PR-5 default flip enables fat-harness without seed opt-in."""
assert _resolve_fat_harness_mode(VALID_SEED_DATA) is True
Expand Down
Loading