From d9c7693e68024d6a6d3db1eddd5bc8f5813d3feb Mon Sep 17 00:00:00 2001 From: Sollan Systems Date: Sat, 4 Jul 2026 05:18:28 -0400 Subject: [PATCH 1/5] feat(s0): bundle-first resource resolution for wheel installs --- loop/_resources.py | 43 +++++++++++++++++++++++++++++++++++++++ scripts/test_resources.py | 34 +++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 loop/_resources.py create mode 100644 scripts/test_resources.py diff --git a/loop/_resources.py b/loop/_resources.py new file mode 100644 index 0000000..14f47ea --- /dev/null +++ b/loop/_resources.py @@ -0,0 +1,43 @@ +"""Bundle-first resource resolution (S0). + +A built wheel carries schemas/, templates/, and the CLI-needed tool scripts as +package data under loop/_bundle/ (see [tool.hatch.build.targets.wheel.force-include] +in pyproject.toml). An editable install / repo checkout has no _bundle, so each +resolver falls back to the historical repo-relative layout. Wheels install as +real directories, so Traversable -> Path is safe here. +""" + +from __future__ import annotations + +from importlib import resources +from pathlib import Path + +_REPO_FALLBACKS = {"schemas": "schemas", "templates": "templates", "tools": "scripts"} + + +def _bundle_root() -> Path | None: + try: + return Path(str(resources.files("loop") / "_bundle")) + except Exception: + return None + + +def _data_dir(kind: str) -> Path: + root = _bundle_root() + if root is not None: + bundled = root / kind + if bundled.is_dir(): + return bundled + return Path(__file__).resolve().parent.parent / _REPO_FALLBACKS[kind] + + +def schemas_dir() -> Path: + return _data_dir("schemas") + + +def templates_dir() -> Path: + return _data_dir("templates") + + +def tools_dir() -> Path: + return _data_dir("tools") diff --git a/scripts/test_resources.py b/scripts/test_resources.py new file mode 100644 index 0000000..89854f4 --- /dev/null +++ b/scripts/test_resources.py @@ -0,0 +1,34 @@ +"""PR1/S0: resource resolution must be importlib.resources-first (wheel) with the +repo-relative checkout as the editable-install fallback. In this checkout no +loop/_bundle exists, so every resolver must land on the repo directories.""" + +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def test_repo_checkout_resolves_to_repo_dirs(): + from loop import _resources + + assert _resources.schemas_dir() == REPO_ROOT / "schemas" + assert _resources.templates_dir() == REPO_ROOT / "templates" + assert _resources.tools_dir() == REPO_ROOT / "scripts" + + +def test_resolved_dirs_hold_the_expected_artifacts(): + from loop import _resources + + assert (_resources.schemas_dir() / "terminal.schema.json").is_file() + assert (_resources.templates_dir() / "manifest.yaml.tmpl").is_file() + for tool in ("inspect_loop.py", "metrics.py", "holdout_gate.py", "anticheat_scan.py"): + assert (_resources.tools_dir() / tool).is_file() + + +def test_bundle_wins_when_present(tmp_path, monkeypatch): + """When loop/_bundle/ exists (the wheel layout), it wins over the repo path.""" + from loop import _resources + + bundle = tmp_path / "_bundle" / "schemas" + bundle.mkdir(parents=True) + monkeypatch.setattr(_resources, "_bundle_root", lambda: tmp_path / "_bundle") + assert _resources.schemas_dir() == bundle From e1f31905a6cf543ac50507181b4ccc0fabad058d Mon Sep 17 00:00:00 2001 From: Sollan Systems Date: Sat, 4 Jul 2026 05:24:59 -0400 Subject: [PATCH 2/5] =?UTF-8?q?feat(s0):=20self-contained=20wheel=20?= =?UTF-8?q?=E2=80=94=20bundle=20schemas/templates/tools,=20add=20loop-engi?= =?UTF-8?q?neer=20console=20script?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- loop/__main__.py | 10 +++++++--- loop/contract.py | 7 ++++--- loop/scaffold.py | 13 ++++++++----- pyproject.toml | 24 ++++++++++++++++-------- 4 files changed, 35 insertions(+), 19 deletions(-) diff --git a/loop/__main__.py b/loop/__main__.py index d80b422..407028e 100644 --- a/loop/__main__.py +++ b/loop/__main__.py @@ -77,7 +77,7 @@ def _print_json(report: dict) -> int: def _run_metrics(argv: list[str]) -> int: """`metrics [--baseline] ` — parses its own flag, then delegates to - scripts/metrics.py (imported repo-relative, the QW8 editable-install path).""" + scripts/metrics.py (resolved bundle-first, repo-relative fallback).""" unknown = [a for a in argv if a.startswith("-") and a != "--baseline"] if unknown: print(f"metrics: unknown option: {unknown[0]}", file=sys.stderr) @@ -96,7 +96,9 @@ def _run_metrics(argv: list[str]) -> int: file=sys.stderr, ) return 2 - scripts_dir = Path(__file__).resolve().parent.parent / "scripts" + from ._resources import tools_dir + + scripts_dir = tools_dir() sys.path.insert(0, str(scripts_dir)) import metrics # type: ignore @@ -159,7 +161,9 @@ def main(argv: list[str] | None = None) -> int: # command == "inspect": keep the historical inspector script as the scoring # UI over the same contract artifacts; import lazily to avoid making # scripts/ a package. - scripts_dir = Path(__file__).resolve().parent.parent / "scripts" + from ._resources import tools_dir + + scripts_dir = tools_dir() sys.path.insert(0, str(scripts_dir)) import inspect_loop # type: ignore diff --git a/loop/contract.py b/loop/contract.py index 645a45b..960643b 100644 --- a/loop/contract.py +++ b/loop/contract.py @@ -264,9 +264,10 @@ def _check_stub_verify_scripts(paths: LoopPaths, issues: list[dict]) -> None: def _schemas_dir() -> Path: - # Resolve schemas/ relative to the loop package's repo root, the same pattern - # the pyproject comment describes for scripts/ (editable install is supported). - return Path(__file__).resolve().parent.parent / "schemas" + # Bundle-first (wheel package data), repo-relative editable-install fallback. + from ._resources import schemas_dir + + return schemas_dir() def _load_schema(name: str) -> dict[str, Any]: diff --git a/loop/scaffold.py b/loop/scaffold.py index 8d2f338..2008d8f 100644 --- a/loop/scaffold.py +++ b/loop/scaffold.py @@ -5,9 +5,12 @@ from pathlib import Path from typing import Any -# Resolve the bundled templates/ relative to the repo root, the same way -# __main__ resolves scripts/ — so scaffold works from an editable install. -_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates" +from ._resources import templates_dir + + +def _templates_dir() -> Path: + return templates_dir() + _PLACEHOLDER_RE = re.compile(r"\{\{[A-Z0-9_]+\}\}") @@ -114,14 +117,14 @@ def scaffold(target: str | Path) -> dict[str, Any]: for template_name, rel in _FILLED_FILES.items(): dest = target / rel dest.parent.mkdir(parents=True, exist_ok=True) - text = (_TEMPLATES_DIR / template_name).read_text(encoding="utf-8") + text = (_templates_dir() / template_name).read_text(encoding="utf-8") dest.write_text(_fill(text, mapping), encoding="utf-8") written.append(rel) for template_name, rel in _VERIFY_SCRIPTS.items(): dest = target / rel dest.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(_TEMPLATES_DIR / template_name, dest) + shutil.copyfile(_templates_dir() / template_name, dest) dest.chmod(0o755) written.append(rel) diff --git a/pyproject.toml b/pyproject.toml index c81e90a..a83eed2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,20 +22,28 @@ keywords = ["agent", "loop", "agentic", "verification", "harness", "orchestratio yaml = ["pyyaml>=6"] schemas = ["jsonschema>=4"] -# Console entry point. `loop.__main__:main` resolves the bundled scripts/ dir -# relative to its own __file__, so this is EDITABLE-INSTALL ONLY: `pip install -e .` -# keeps loop/ pointing at the repo (where scripts/ lives). A non-editable wheel -# does not ship scripts/, so `inspect`/`metrics` would not resolve — see the -# [tool.hatch.build.targets.wheel] note below. +# Console entry points. Both install modes work: `loop.__main__:main` resolves +# schemas/templates/tool-scripts bundle-first via loop/_resources.py (wheel +# package data under loop/_bundle/), with the repo checkout as the editable +# fallback. `loop-engineer` is the uvx-visible alias matching the PyPI project. [project.scripts] loop = "loop.__main__:main" +loop-engineer = "loop.__main__:main" [project.urls] Homepage = "https://github.com/SollanSystems/loop-engineer" Repository = "https://github.com/SollanSystems/loop-engineer" -# Editable install is the supported mode: `python3 -m loop inspect` resolves the -# bundled scripts/ dir relative to the repo, so install with `pip install -e .` -# to run the CLI from any directory. +# The wheel is self-contained: schemas/, templates/, and the CLI-needed tool +# scripts ship as package data under loop/_bundle/ (resolved by loop/_resources.py, +# importlib.resources-first with the repo checkout as editable-install fallback). [tool.hatch.build.targets.wheel] packages = ["loop"] + +[tool.hatch.build.targets.wheel.force-include] +"schemas" = "loop/_bundle/schemas" +"templates" = "loop/_bundle/templates" +"scripts/inspect_loop.py" = "loop/_bundle/tools/inspect_loop.py" +"scripts/metrics.py" = "loop/_bundle/tools/metrics.py" +"scripts/holdout_gate.py" = "loop/_bundle/tools/holdout_gate.py" +"scripts/anticheat_scan.py" = "loop/_bundle/tools/anticheat_scan.py" From 34b28a12aeb0b71546338aa2d866c29d87c6fc02 Mon Sep 17 00:00:00 2001 From: Sollan Systems Date: Sat, 4 Jul 2026 05:32:04 -0400 Subject: [PATCH 3/5] test(s0): wheel self-containment acceptance gate --- scripts/test_wheel_selfcontained.py | 81 +++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 scripts/test_wheel_selfcontained.py diff --git a/scripts/test_wheel_selfcontained.py b/scripts/test_wheel_selfcontained.py new file mode 100644 index 0000000..6ebcbd5 --- /dev/null +++ b/scripts/test_wheel_selfcontained.py @@ -0,0 +1,81 @@ +# scripts/test_wheel_selfcontained.py +"""S0 acceptance: a built wheel must be self-contained. Build the wheel, install +it into a fresh venv, and run scaffold/doctor/inspect from a temp cwd where the +repo checkout is not importable. Env-guarded: building needs pip + network for +the hatchling backend, so this skips in offline/pip-less local envs and runs in CI. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +import venv +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def _pip_available() -> bool: + proc = subprocess.run( + [sys.executable, "-m", "pip", "--version"], capture_output=True, text=True + ) + return proc.returncode == 0 + + +pytestmark = pytest.mark.skipif( + not _pip_available(), reason="pip unavailable in this interpreter (wheel build env guard)" +) + + +@pytest.fixture(scope="module") +def wheel_env(tmp_path_factory): + tmp = tmp_path_factory.mktemp("wheel") + build = subprocess.run( + [sys.executable, "-m", "pip", "wheel", "--no-deps", "-w", str(tmp), str(REPO_ROOT)], + capture_output=True, text=True, + ) + if build.returncode != 0: + pytest.skip(f"wheel build unavailable here (offline?): {build.stderr[-400:]}") + wheel = next(tmp.glob("loop_engineer-*.whl")) + + venv_dir = tmp / "venv" + venv.EnvBuilder(with_pip=True).create(venv_dir) + py = venv_dir / ("Scripts" if sys.platform == "win32" else "bin") / "python" + install = subprocess.run( + [str(py), "-m", "pip", "install", "--no-index", str(wheel)], + capture_output=True, text=True, + ) + assert install.returncode == 0, install.stderr + return py + + +def _run(py: Path, args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]: + # cwd is OUTSIDE the repo, so the checkout is absent from sys.path. + return subprocess.run([str(py), "-m", "loop", *args], cwd=cwd, capture_output=True, text=True) + + +def test_scaffold_doctor_inspect_from_wheel_only(wheel_env, tmp_path): + workspace = tmp_path / "fresh-loop" + + scaffolded = _run(wheel_env, ["scaffold", str(workspace)], cwd=tmp_path) + assert scaffolded.returncode == 0, scaffolded.stderr + + doctored = _run(wheel_env, ["doctor", str(workspace)], cwd=tmp_path) + assert doctored.returncode == 0, doctored.stdout + doctored.stderr + assert json.loads(doctored.stdout)["ok"] is True + + inspected = _run(wheel_env, ["inspect", str(workspace)], cwd=tmp_path) + report = json.loads(inspected.stdout) + assert report["verdict"] in ("weak", "ok", "strong") + + +def test_both_console_scripts_are_installed(wheel_env, tmp_path): + bindir = wheel_env.parent + for name in ("loop", "loop-engineer"): + exe = bindir / name + proc = subprocess.run([str(exe), "--version"], cwd=tmp_path, capture_output=True, text=True) + assert proc.returncode == 0, f"{name}: {proc.stderr}" + assert proc.stdout.strip() From 5d90611fd3498bc4f69095c3cf956007c9ad74a7 Mon Sep 17 00:00:00 2001 From: Sollan Systems Date: Sat, 4 Jul 2026 05:38:50 -0400 Subject: [PATCH 4/5] test(s0): wheel gate asserts bundle manifest; CI build failure fails not skips --- scripts/test_wheel_selfcontained.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/scripts/test_wheel_selfcontained.py b/scripts/test_wheel_selfcontained.py index 6ebcbd5..e59ff37 100644 --- a/scripts/test_wheel_selfcontained.py +++ b/scripts/test_wheel_selfcontained.py @@ -2,15 +2,18 @@ """S0 acceptance: a built wheel must be self-contained. Build the wheel, install it into a fresh venv, and run scaffold/doctor/inspect from a temp cwd where the repo checkout is not importable. Env-guarded: building needs pip + network for -the hatchling backend, so this skips in offline/pip-less local envs and runs in CI. +the hatchling backend, so this skips in offline/pip-less local envs; in CI a +wheel build failure fails the test rather than skipping it. """ from __future__ import annotations import json +import os import subprocess import sys import venv +import zipfile from pathlib import Path import pytest @@ -38,9 +41,19 @@ def wheel_env(tmp_path_factory): capture_output=True, text=True, ) if build.returncode != 0: + if os.environ.get("CI"): + pytest.fail(f"wheel build failed in CI: {build.stderr[-1500:]}") pytest.skip(f"wheel build unavailable here (offline?): {build.stderr[-400:]}") wheel = next(tmp.glob("loop_engineer-*.whl")) + names = zipfile.ZipFile(wheel).namelist() + for expected in ( + "loop/_bundle/schemas/", + "loop/_bundle/templates/", + "loop/_bundle/tools/", + ): + assert any(n.startswith(expected) for n in names), f"wheel missing {expected}" + venv_dir = tmp / "venv" venv.EnvBuilder(with_pip=True).create(venv_dir) py = venv_dir / ("Scripts" if sys.platform == "win32" else "bin") / "python" From b704d54a6d77afd8fa11a90212782561c619b03a Mon Sep 17 00:00:00 2001 From: Sollan Systems Date: Sat, 4 Jul 2026 05:43:54 -0400 Subject: [PATCH 5/5] ci(s0): tag-triggered PyPI publish via trusted publishing --- .github/workflows/publish.yml | 58 +++++++++++++++++++++++++++++++++++ CHANGELOG.md | 30 ++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..baf2c63 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,58 @@ +name: publish + +on: + push: + tags: ["v*"] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Tag matches pyproject version + run: | + tag="${GITHUB_REF_NAME#v}" + version="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])')" + if [ "$tag" != "$version" ]; then + echo "tag v$tag != pyproject version $version" >&2 + exit 1 + fi + + - name: Build sdist + wheel + run: | + python -m pip install --upgrade build + python -m build + + - name: Smoke-test the wheel from a temp dir + run: | + python -m venv /tmp/smoke + /tmp/smoke/bin/pip install --no-index dist/*.whl + cd /tmp + /tmp/smoke/bin/loop-engineer --version + /tmp/smoke/bin/loop scaffold /tmp/smoke-loop + /tmp/smoke/bin/loop doctor /tmp/smoke-loop + /tmp/smoke/bin/loop inspect /tmp/smoke-loop || true + + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e71faa..3ab4619 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,36 @@ All notable changes to `loop-engineer` are documented here. `WORKFLOW.md` and `README.md` are reworded to describe the mechanism; the 0.3.4 history is left intact. +## Unreleased + +**PyPI substrate.** `loop-engineer` becomes a self-contained wheel that runs from +any directory — the CLI no longer depends on being executed from a source +checkout — and ships to PyPI on a version tag through trusted publishing, with no +token or secret stored in the repo. + +### Added +- **Self-contained wheel** — the schemas, contract templates, and CLI-needed tool + scripts the loop reads at runtime are bundled into the wheel under + `loop/_bundle/` (via `[tool.hatch.build.targets.wheel.force-include]`) and + resolved through an `importlib.resources`-first resolver (`loop/_resources.py`) + that falls back to the repo-relative layout for editable installs / source + checkouts. `loop` invocations no longer break when run outside the repo tree. +- **`loop-engineer` console script** — a second `[project.scripts]` entry point + alongside `loop` (both map to `loop.__main__:main`), so `uvx loop-engineer` + funnels straight to the CLI under the PyPI project name. +- **Wheel self-containment acceptance test** + (`scripts/test_wheel_selfcontained.py`) — builds the wheel and asserts its zip + manifest carries the bundled `schemas/`, `templates/`, and `tools/` resources, + so a regression that drops a runtime resource from the wheel fails the suite + (env-guarded: skips when `pip`/`build` are unavailable locally, hard-fails the + build under CI). +- **Tag-triggered PyPI publish workflow** (`.github/workflows/publish.yml`) — on a + `v*` tag push it guards that the tag matches the `pyproject` version, builds the + sdist + wheel, smoke-tests the wheel from a throwaway venv (`loop-engineer + --version`, then `loop scaffold`/`doctor`/`inspect`), and publishes via PyPI + **trusted publishing** (`id-token: write`, the `pypi` environment, + `pypa/gh-action-pypi-publish`) — no API token or secret anywhere in the repo. + ## 0.6.0 — 2026-07-03 "Metrics real": false-completion-rate (FCR) and repair-productivity (RP) graduate