From 4fa2d3c5daf320edccc13e944217996b0d3a83a3 Mon Sep 17 00:00:00 2001 From: kmajdoub Date: Thu, 28 May 2026 17:13:16 +0200 Subject: [PATCH 1/2] feat(brainstormer): add product-vision discovery (#122) Introduces a typed, validated "north star" loader for the Product Brainstormer (epic #121). Without an explicit vision + axes, the brainstormer used to silently fabricate direction; this layer makes that impossible. * New ``forge_loop.product_vision`` module exposing ``Axis``, ``ProductVision``, ``MissingVisionError``, and ``discover()``. * Pydantic v2 models with ``acceptable_work`` min_length=1, ``axes`` min_length=1, and ``extra='ignore'`` on axis entries for forward-compat. * ``discover()`` reads ``/.forge/product-vision.md`` and ``.forge/axes.yaml``, returning a typed object or raising ``MissingVisionError`` with the offending absolute path and a specific reason for each failure mode (missing vision, missing axes, YAML parse failure, schema error with field path, empty vision, empty axes list, empty acceptable_work). * Twelve tests covering happy paths, every documented sad path, the no-``.forge/``-at-all case, symlinked ``.forge/``, and forward-compat extra keys. --- src/forge_loop/product_vision.py | 122 +++++++++++++++++++++++++++++ tests/test_product_vision.py | 129 +++++++++++++++++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 src/forge_loop/product_vision.py create mode 100644 tests/test_product_vision.py diff --git a/src/forge_loop/product_vision.py b/src/forge_loop/product_vision.py new file mode 100644 index 0000000..8cdbd7b --- /dev/null +++ b/src/forge_loop/product_vision.py @@ -0,0 +1,122 @@ +"""Product Brainstormer — typed discovery of the product "north star". + +Reads two files from ``/.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 diff --git a/tests/test_product_vision.py b/tests/test_product_vision.py new file mode 100644 index 0000000..398a029 --- /dev/null +++ b/tests/test_product_vision.py @@ -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" From 348811abcf956f345c57ed5df2c43938332ff9c6 Mon Sep 17 00:00:00 2001 From: kmajdoub Date: Thu, 28 May 2026 17:13:31 +0200 Subject: [PATCH 2/2] test(product_vision): add fixtures under tests/fixtures/product_vision/ (#122) The .forge/ pattern is gitignored at repo root; force-add the fixture tree so CI sees the same files the local pytest run did. --- .../acceptable_work_empty/.forge/axes.yaml | 5 +++++ .../.forge/product-vision.md | 3 +++ .../empty_axes_list/.forge/axes.yaml | 1 + .../empty_axes_list/.forge/product-vision.md | 3 +++ .../empty_vision/.forge/axes.yaml | 6 ++++++ .../empty_vision/.forge/product-vision.md | 3 +++ .../invalid_axes_schema/.forge/axes.yaml | 5 +++++ .../.forge/product-vision.md | 3 +++ .../invalid_axes_yaml/.forge/axes.yaml | 2 ++ .../.forge/product-vision.md | 3 +++ .../missing_axes/.forge/product-vision.md | 3 +++ .../missing_vision/.forge/axes.yaml | 6 ++++++ .../valid_full/.forge/axes.yaml | 20 +++++++++++++++++++ .../valid_full/.forge/product-vision.md | 10 ++++++++++ .../valid_minimal/.forge/axes.yaml | 6 ++++++ .../valid_minimal/.forge/product-vision.md | 3 +++ 16 files changed, 82 insertions(+) create mode 100644 tests/fixtures/product_vision/acceptable_work_empty/.forge/axes.yaml create mode 100644 tests/fixtures/product_vision/acceptable_work_empty/.forge/product-vision.md create mode 100644 tests/fixtures/product_vision/empty_axes_list/.forge/axes.yaml create mode 100644 tests/fixtures/product_vision/empty_axes_list/.forge/product-vision.md create mode 100644 tests/fixtures/product_vision/empty_vision/.forge/axes.yaml create mode 100644 tests/fixtures/product_vision/empty_vision/.forge/product-vision.md create mode 100644 tests/fixtures/product_vision/invalid_axes_schema/.forge/axes.yaml create mode 100644 tests/fixtures/product_vision/invalid_axes_schema/.forge/product-vision.md create mode 100644 tests/fixtures/product_vision/invalid_axes_yaml/.forge/axes.yaml create mode 100644 tests/fixtures/product_vision/invalid_axes_yaml/.forge/product-vision.md create mode 100644 tests/fixtures/product_vision/missing_axes/.forge/product-vision.md create mode 100644 tests/fixtures/product_vision/missing_vision/.forge/axes.yaml create mode 100644 tests/fixtures/product_vision/valid_full/.forge/axes.yaml create mode 100644 tests/fixtures/product_vision/valid_full/.forge/product-vision.md create mode 100644 tests/fixtures/product_vision/valid_minimal/.forge/axes.yaml create mode 100644 tests/fixtures/product_vision/valid_minimal/.forge/product-vision.md diff --git a/tests/fixtures/product_vision/acceptable_work_empty/.forge/axes.yaml b/tests/fixtures/product_vision/acceptable_work_empty/.forge/axes.yaml new file mode 100644 index 0000000..73cd68b --- /dev/null +++ b/tests/fixtures/product_vision/acceptable_work_empty/.forge/axes.yaml @@ -0,0 +1,5 @@ +axes: + - name: empty-work + customer: c + valuable_means: v + acceptable_work: [] diff --git a/tests/fixtures/product_vision/acceptable_work_empty/.forge/product-vision.md b/tests/fixtures/product_vision/acceptable_work_empty/.forge/product-vision.md new file mode 100644 index 0000000..bc33b7c --- /dev/null +++ b/tests/fixtures/product_vision/acceptable_work_empty/.forge/product-vision.md @@ -0,0 +1,3 @@ +# Minimal Vision + +We help solo operators ship. diff --git a/tests/fixtures/product_vision/empty_axes_list/.forge/axes.yaml b/tests/fixtures/product_vision/empty_axes_list/.forge/axes.yaml new file mode 100644 index 0000000..dcce026 --- /dev/null +++ b/tests/fixtures/product_vision/empty_axes_list/.forge/axes.yaml @@ -0,0 +1 @@ +axes: [] diff --git a/tests/fixtures/product_vision/empty_axes_list/.forge/product-vision.md b/tests/fixtures/product_vision/empty_axes_list/.forge/product-vision.md new file mode 100644 index 0000000..bc33b7c --- /dev/null +++ b/tests/fixtures/product_vision/empty_axes_list/.forge/product-vision.md @@ -0,0 +1,3 @@ +# Minimal Vision + +We help solo operators ship. diff --git a/tests/fixtures/product_vision/empty_vision/.forge/axes.yaml b/tests/fixtures/product_vision/empty_vision/.forge/axes.yaml new file mode 100644 index 0000000..2c354d9 --- /dev/null +++ b/tests/fixtures/product_vision/empty_vision/.forge/axes.yaml @@ -0,0 +1,6 @@ +axes: + - name: shipping + customer: solo operator + valuable_means: feature reaches production + acceptable_work: + - write code diff --git a/tests/fixtures/product_vision/empty_vision/.forge/product-vision.md b/tests/fixtures/product_vision/empty_vision/.forge/product-vision.md new file mode 100644 index 0000000..9da2eeb --- /dev/null +++ b/tests/fixtures/product_vision/empty_vision/.forge/product-vision.md @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/tests/fixtures/product_vision/invalid_axes_schema/.forge/axes.yaml b/tests/fixtures/product_vision/invalid_axes_schema/.forge/axes.yaml new file mode 100644 index 0000000..99f33d7 --- /dev/null +++ b/tests/fixtures/product_vision/invalid_axes_schema/.forge/axes.yaml @@ -0,0 +1,5 @@ +axes: + - name: missing-customer + valuable_means: x + acceptable_work: + - y diff --git a/tests/fixtures/product_vision/invalid_axes_schema/.forge/product-vision.md b/tests/fixtures/product_vision/invalid_axes_schema/.forge/product-vision.md new file mode 100644 index 0000000..bc33b7c --- /dev/null +++ b/tests/fixtures/product_vision/invalid_axes_schema/.forge/product-vision.md @@ -0,0 +1,3 @@ +# Minimal Vision + +We help solo operators ship. diff --git a/tests/fixtures/product_vision/invalid_axes_yaml/.forge/axes.yaml b/tests/fixtures/product_vision/invalid_axes_yaml/.forge/axes.yaml new file mode 100644 index 0000000..fd0788c --- /dev/null +++ b/tests/fixtures/product_vision/invalid_axes_yaml/.forge/axes.yaml @@ -0,0 +1,2 @@ +axes: [unclosed + - foo: bar diff --git a/tests/fixtures/product_vision/invalid_axes_yaml/.forge/product-vision.md b/tests/fixtures/product_vision/invalid_axes_yaml/.forge/product-vision.md new file mode 100644 index 0000000..bc33b7c --- /dev/null +++ b/tests/fixtures/product_vision/invalid_axes_yaml/.forge/product-vision.md @@ -0,0 +1,3 @@ +# Minimal Vision + +We help solo operators ship. diff --git a/tests/fixtures/product_vision/missing_axes/.forge/product-vision.md b/tests/fixtures/product_vision/missing_axes/.forge/product-vision.md new file mode 100644 index 0000000..bc33b7c --- /dev/null +++ b/tests/fixtures/product_vision/missing_axes/.forge/product-vision.md @@ -0,0 +1,3 @@ +# Minimal Vision + +We help solo operators ship. diff --git a/tests/fixtures/product_vision/missing_vision/.forge/axes.yaml b/tests/fixtures/product_vision/missing_vision/.forge/axes.yaml new file mode 100644 index 0000000..2c354d9 --- /dev/null +++ b/tests/fixtures/product_vision/missing_vision/.forge/axes.yaml @@ -0,0 +1,6 @@ +axes: + - name: shipping + customer: solo operator + valuable_means: feature reaches production + acceptable_work: + - write code diff --git a/tests/fixtures/product_vision/valid_full/.forge/axes.yaml b/tests/fixtures/product_vision/valid_full/.forge/axes.yaml new file mode 100644 index 0000000..1bcb91b --- /dev/null +++ b/tests/fixtures/product_vision/valid_full/.forge/axes.yaml @@ -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 diff --git a/tests/fixtures/product_vision/valid_full/.forge/product-vision.md b/tests/fixtures/product_vision/valid_full/.forge/product-vision.md new file mode 100644 index 0000000..a6ad396 --- /dev/null +++ b/tests/fixtures/product_vision/valid_full/.forge/product-vision.md @@ -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. diff --git a/tests/fixtures/product_vision/valid_minimal/.forge/axes.yaml b/tests/fixtures/product_vision/valid_minimal/.forge/axes.yaml new file mode 100644 index 0000000..2c354d9 --- /dev/null +++ b/tests/fixtures/product_vision/valid_minimal/.forge/axes.yaml @@ -0,0 +1,6 @@ +axes: + - name: shipping + customer: solo operator + valuable_means: feature reaches production + acceptable_work: + - write code diff --git a/tests/fixtures/product_vision/valid_minimal/.forge/product-vision.md b/tests/fixtures/product_vision/valid_minimal/.forge/product-vision.md new file mode 100644 index 0000000..bc33b7c --- /dev/null +++ b/tests/fixtures/product_vision/valid_minimal/.forge/product-vision.md @@ -0,0 +1,3 @@ +# Minimal Vision + +We help solo operators ship.