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
122 changes: 122 additions & 0 deletions src/forge_loop/product_vision.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Product Brainstormer — typed discovery of the product "north star".

Reads two files from ``<repo_path>/.forge/``:

* ``product-vision.md`` — free-form markdown describing what the product
is for and who it's for. We don't parse it; we just store the raw text
so downstream prompts can quote it verbatim.
* ``axes.yaml`` — a strict, schema-validated list of "axes of value".
Each axis says who the customer is, what counts as valuable, what kinds
of work are acceptable, and what is rejected as cosmetic.

Either file missing or invalid raises :class:`MissingVisionError` with a
human-readable reason that includes the absolute file path. The
brainstormer must refuse to run when discovery fails — silent
fabrication of direction is the failure mode we are defending against.

See issue #122 (part of epic #121).
"""

from __future__ import annotations

from pathlib import Path

import yaml
from pydantic import BaseModel, ConfigDict, Field, ValidationError

__all__ = ["Axis", "ProductVision", "MissingVisionError", "discover"]


class MissingVisionError(RuntimeError):
"""Raised when ``.forge/product-vision.md`` or ``.forge/axes.yaml``
is missing, unparseable, or schema-invalid.

The message always contains an absolute path so operators can fix
without grepping.
"""


class Axis(BaseModel):
"""A single axis of value. Extra keys are ignored for forward-compat."""

model_config = ConfigDict(extra="ignore")

name: str
customer: str
valuable_means: str
acceptable_work: list[str] = Field(min_length=1)
rejected_as_cosmetic: list[str] = Field(default_factory=list)


class ProductVision(BaseModel):
"""The brainstormer's "north star" — raw vision text plus axes."""

model_config = ConfigDict(extra="ignore")

vision_markdown: str
axes: list[Axis] = Field(min_length=1)


def _format_validation_error(exc: ValidationError, axes_path: Path) -> str:
"""Render a pydantic ValidationError as a single operator-readable line."""
parts: list[str] = []
for err in exc.errors():
loc = ".".join(str(p) for p in err["loc"])
parts.append(f"{loc}: {err['msg']}")
joined = "; ".join(parts) if parts else str(exc)
return f"axes.yaml schema-invalid at {axes_path}: {joined}"


def discover(repo_path: Path) -> ProductVision:
"""Load ``.forge/product-vision.md`` and ``.forge/axes.yaml``.

Returns a validated :class:`ProductVision`. Never returns ``None`` —
missing or invalid inputs raise :class:`MissingVisionError`.
"""
repo_path = Path(repo_path)
forge_dir = (repo_path / ".forge").resolve() if (repo_path / ".forge").exists() else repo_path / ".forge"
vision_path = (forge_dir / "product-vision.md")
axes_path = (forge_dir / "axes.yaml")

# Resolve to absolute paths for error messages, even when they don't exist.
vision_abs = vision_path.resolve() if vision_path.exists() else vision_path.absolute()
axes_abs = axes_path.resolve() if axes_path.exists() else axes_path.absolute()

if not vision_path.exists():
raise MissingVisionError(
f"product-vision.md not found at {vision_abs}"
)
if not axes_path.exists():
raise MissingVisionError(
f"axes.yaml not found at {axes_abs}"
)

vision_text = vision_path.read_text(encoding="utf-8")
if not vision_text.strip():
raise MissingVisionError(
f"vision markdown is empty at {vision_abs}"
)

try:
axes_raw = yaml.safe_load(axes_path.read_text(encoding="utf-8"))
except yaml.YAMLError as exc:
raise MissingVisionError(
f"axes.yaml YAML parse failure at {axes_abs}: {exc}"
) from exc

if not isinstance(axes_raw, dict) or "axes" not in axes_raw:
raise MissingVisionError(
f"axes.yaml schema-invalid at {axes_abs}: missing top-level 'axes' list"
)

try:
vision = ProductVision(vision_markdown=vision_text, axes=axes_raw.get("axes") or [])
except ValidationError as exc:
# Distinguish empty axes list for a clearer reason.
if axes_raw.get("axes") == []:
raise MissingVisionError(
f"axes.yaml schema-invalid at {axes_abs}: axes list cannot be empty"
) from exc
raise MissingVisionError(_format_validation_error(exc, axes_abs)) from exc

return vision
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
axes:
- name: empty-work
customer: c
valuable_means: v
acceptable_work: []
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Minimal Vision

We help solo operators ship.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
axes: []
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Minimal Vision

We help solo operators ship.
6 changes: 6 additions & 0 deletions tests/fixtures/product_vision/empty_vision/.forge/axes.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
axes:
- name: shipping
customer: solo operator
valuable_means: feature reaches production
acceptable_work:
- write code
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@



Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
axes:
- name: missing-customer
valuable_means: x
acceptable_work:
- y
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Minimal Vision

We help solo operators ship.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
axes: [unclosed
- foo: bar
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Minimal Vision

We help solo operators ship.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Minimal Vision

We help solo operators ship.
6 changes: 6 additions & 0 deletions tests/fixtures/product_vision/missing_vision/.forge/axes.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
axes:
- name: shipping
customer: solo operator
valuable_means: feature reaches production
acceptable_work:
- write code
20 changes: 20 additions & 0 deletions tests/fixtures/product_vision/valid_full/.forge/axes.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
axes:
- name: throughput
customer: solo operator
valuable_means: more issues closed per hour
acceptable_work:
- implement features
- fix bugs
- cut flakes
rejected_as_cosmetic:
- rename variables
- reflow whitespace
extra_unknown_key: ignored_for_forward_compat
- name: reliability
customer: downstream consumer
valuable_means: fewer rollbacks
acceptable_work:
- add tests
- harden error paths
rejected_as_cosmetic:
- tweak log levels
10 changes: 10 additions & 0 deletions tests/fixtures/product_vision/valid_full/.forge/product-vision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Full Vision

## Who
Solo operators who run autonomous coding loops.

## What
We give them a north star so the loop doesn't drift into cosmetic work.

## How
By forcing typed value-axes at startup.
6 changes: 6 additions & 0 deletions tests/fixtures/product_vision/valid_minimal/.forge/axes.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
axes:
- name: shipping
customer: solo operator
valuable_means: feature reaches production
acceptable_work:
- write code
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Minimal Vision

We help solo operators ship.
129 changes: 129 additions & 0 deletions tests/test_product_vision.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""Tests for ``forge_loop.product_vision`` — issue #122."""

from __future__ import annotations

from pathlib import Path

import pytest

from forge_loop.product_vision import (
Axis,
MissingVisionError,
ProductVision,
discover,
)

FIXTURES = Path(__file__).parent / "fixtures" / "product_vision"


# -- Happy paths ------------------------------------------------------------


def test_valid_minimal_returns_product_vision() -> None:
pv = discover(FIXTURES / "valid_minimal")
assert isinstance(pv, ProductVision)
assert len(pv.axes) == 1
axis = pv.axes[0]
assert isinstance(axis, Axis)
assert axis.name == "shipping"
assert axis.customer == "solo operator"
assert axis.valuable_means == "feature reaches production"
assert axis.acceptable_work == ["write code"]
assert axis.rejected_as_cosmetic == []
# vision_markdown is the raw file content verbatim
assert (
pv.vision_markdown
== (FIXTURES / "valid_minimal" / ".forge" / "product-vision.md").read_text(
encoding="utf-8"
)
)


def test_valid_full_multi_axis() -> None:
pv = discover(FIXTURES / "valid_full")
assert len(pv.axes) == 2
names = [a.name for a in pv.axes]
assert names == ["throughput", "reliability"]
assert pv.axes[0].rejected_as_cosmetic == ["rename variables", "reflow whitespace"]
assert "## Who" in pv.vision_markdown
assert "## How" in pv.vision_markdown


# -- Sad paths --------------------------------------------------------------


def test_missing_vision_file() -> None:
with pytest.raises(MissingVisionError) as ei:
discover(FIXTURES / "missing_vision")
msg = str(ei.value)
assert "product-vision.md" in msg
assert str((FIXTURES / "missing_vision" / ".forge" / "product-vision.md").absolute()) in msg


def test_missing_axes_file() -> None:
with pytest.raises(MissingVisionError) as ei:
discover(FIXTURES / "missing_axes")
msg = str(ei.value)
assert "axes.yaml" in msg
assert "missing_axes" in msg


def test_empty_vision() -> None:
with pytest.raises(MissingVisionError) as ei:
discover(FIXTURES / "empty_vision")
assert "vision markdown is empty" in str(ei.value)


def test_invalid_axes_yaml_parse_failure() -> None:
with pytest.raises(MissingVisionError) as ei:
discover(FIXTURES / "invalid_axes_yaml")
msg = str(ei.value)
assert "YAML parse" in msg or "parse failure" in msg
assert "axes.yaml" in msg


def test_invalid_axes_schema_missing_customer() -> None:
with pytest.raises(MissingVisionError) as ei:
discover(FIXTURES / "invalid_axes_schema")
msg = str(ei.value)
assert "customer" in msg


def test_empty_axes_list() -> None:
with pytest.raises(MissingVisionError) as ei:
discover(FIXTURES / "empty_axes_list")
msg = str(ei.value)
assert "axes" in msg
assert "empty" in msg.lower()


def test_acceptable_work_empty() -> None:
with pytest.raises(MissingVisionError) as ei:
discover(FIXTURES / "acceptable_work_empty")
msg = str(ei.value)
assert "acceptable_work" in msg


# -- Adversarial -----------------------------------------------------------


def test_missing_forge_dir_raises_missing_vision_error(tmp_path: Path) -> None:
"""No ``.forge/`` at all → MissingVisionError, not FileNotFoundError leak."""
with pytest.raises(MissingVisionError):
discover(tmp_path)


def test_symlinked_forge_dir_resolves(tmp_path: Path) -> None:
"""A repo whose ``.forge/`` is a symlink to a valid fixture must validate."""
target = (FIXTURES / "valid_minimal" / ".forge").resolve()
link = tmp_path / ".forge"
link.symlink_to(target, target_is_directory=True)
pv = discover(tmp_path)
assert pv.axes[0].name == "shipping"


def test_extra_keys_on_axis_are_ignored() -> None:
"""``valid_full`` includes ``extra_unknown_key`` on an axis — must be ignored."""
pv = discover(FIXTURES / "valid_full")
# If we got here, extra keys did not blow up validation.
assert pv.axes[0].name == "throughput"
Loading