diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c8920f..b0966ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,3 +60,23 @@ jobs: run: python -m pip install --upgrade pip pyyaml pytest jsonschema langgraph - name: LangGraph recipe end-to-end run: python -B -m pytest -q -p no:cacheprovider scripts/test_langgraph_recipe.py + + action-dogfood: + name: action (dogfood on flagship example) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # The repo root's live .loop/ is gitignored, so it does not exist in a fresh + # CI checkout — the tracked flagship example is the contract the action gates + # (doctor-clean, inspect 90/strong in the action's dependency-free install). + - uses: ./ + with: + path: "examples/coverage-repair" + fail-under-score: "90" + + - name: pre-commit consumer fixture + # bare `python` is on PATH here because the preceding `uses: ./` composite step ran setup-python + # (persisted via GITHUB_PATH) — keep the action step before this one. + run: | + python -m pip install --quiet pre-commit pytest pyyaml + python -B -m pytest -q -p no:cacheprovider scripts/test_precommit_hook.py diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml new file mode 100644 index 0000000..a992d6b --- /dev/null +++ b/.pre-commit-hooks.yaml @@ -0,0 +1,7 @@ +- id: loop-doctor + name: loop doctor (loop-contract validity gate) + description: "Validate the repo's .loop/ contract objects; fails on a dishonest or malformed contract." + entry: loop doctor . + language: python + pass_filenames: false + always_run: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 6116702..dc2b415 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,32 @@ firewall never locks a session — and a strict no-op for every repo without a (`python3 ${CLAUDE_PLUGIN_ROOT}/hooks/stop_firewall.py`), so a marketplace install gets the firewall with zero configuration. +**C1 — the CI gate.** The proof-of-done gate at the two boundaries where a +foreign repo already runs its checks: a GitHub Action for pull-request CI and a +pre-commit hook for the local commit. Both wrap the same `loop doctor` honesty +gate the runtime enforces, so a consumer adopts the wedge without adopting the +loop-engineer runtime — and the repo dogfoods both on its own contract in CI. + +### Added +- **Composite GitHub Action** (`action.yml`, id `loop-engineer gate`) — runs + `loop doctor` as a hard gate and `loop inspect` as a scorecard (warn-only until + `fail-under-score` is set), installing loop-engineer from PyPI (`version:`) or + from the action's own checkout by default. Writes the scorecard to the job + summary and, given a `github-token`, an optional PR comment. The `action-dogfood` + CI job runs it against the tracked flagship example contract + (`examples/coverage-repair`) at `fail-under-score: 90` — the repo root's live + `.loop/` is gitignored and absent in a fresh CI checkout. +- **`.pre-commit-hooks.yaml`** — a `language: python` hook id `loop-doctor` + (`entry: loop doctor .`, `always_run`, `pass_filenames: false`) that a consumer + wires in with three lines of `.pre-commit-config.yaml`; PR1's self-contained + wheel is what makes the `language: python` install work from any consumer repo. +- **Pre-commit acceptance test** (`scripts/test_precommit_hook.py`) — asserts the + hook definition is sound and its entry matches a declared console script, plus a + consumer-fixture path that scaffolds a fresh contract and runs the hook through + `pre-commit try-repo` end-to-end. Env-guarded on the `pre-commit` tool (skips + when absent); the `action-dogfood` CI job installs it and runs the fixture for + real on the PR checkout. + ## 0.6.1 — 2026-07-04 **PyPI substrate.** `loop-engineer` becomes a self-contained wheel that runs from diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..11de31e --- /dev/null +++ b/action.yml @@ -0,0 +1,98 @@ +name: "loop-engineer gate" +description: "Proof-of-done gate for agent-loop contracts: hard-fails on doctor, scores with inspect (warn-only by default)." +branding: + icon: "check-circle" + color: "green" + +inputs: + path: + description: "Workspace (or .loop dir) holding the loop contract" + required: false + default: "." + version: + description: "loop-engineer version to install from PyPI (e.g. 0.6.1). Empty installs from the action's own checkout." + required: false + default: "" + fail-under-score: + description: "Fail the job when the inspect score (0-100) is below this. 0 keeps inspect warn-only." + required: false + default: "0" + python-version: + description: "Python version for the gate" + required: false + default: "3.12" + github-token: + description: "Token for the optional PR scorecard comment. Empty skips the comment." + required: false + default: "" + +runs: + using: "composite" + steps: + - uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python-version }} + + - name: Install loop-engineer + shell: bash + env: + LOOP_VERSION: "${{ inputs.version }}" + run: | + if [ -n "$LOOP_VERSION" ]; then + python -m pip install --quiet "loop-engineer==$LOOP_VERSION" + else + python -m pip install --quiet "${{ github.action_path }}" + fi + + - name: loop doctor (hard gate) + shell: bash + env: + LOOP_PATH: "${{ inputs.path }}" + run: loop doctor "$LOOP_PATH" + + - name: loop inspect (scorecard) + shell: bash + env: + LOOP_PATH: "${{ inputs.path }}" + LOOP_FAIL_UNDER: "${{ inputs.fail-under-score }}" + run: | + set +e + loop inspect "$LOOP_PATH" > "${RUNNER_TEMP}/inspect.json" + set -e + python - "${RUNNER_TEMP}/inspect.json" "$LOOP_FAIL_UNDER" <<'PY' + import json, os, sys + + report = json.load(open(sys.argv[1])) + fail_under = int(sys.argv[2]) + score, verdict = report.get("score", 0), report.get("verdict", "?") + lines = [ + "## loop-engineer scorecard", + "", + f"| metric | value |", + f"|---|---|", + f"| verdict | **{verdict}** |", + f"| score | {score}/100 |", + f"| gaps | {len(report.get('gaps', []))} |", + "", + ] + for gap in report.get("gaps", [])[:10]: + lines.append(f"- {gap}") + summary = "\n".join(lines) + "\n" + with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as fh: + fh.write(summary) + open(os.path.join(os.environ["RUNNER_TEMP"], "scorecard.md"), "w", encoding="utf-8").write(summary) + if verdict == "weak": + print(f"::warning::loop inspect verdict is weak (score {score}/100)") + if fail_under and score < fail_under: + print(f"::error::inspect score {score} < fail-under-score {fail_under}") + raise SystemExit(1) + PY + + - name: PR scorecard comment (optional) + if: ${{ inputs.github-token != '' && github.event_name == 'pull_request' }} + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + run: | + gh api "repos/${GITHUB_REPOSITORY}/issues/${{ github.event.pull_request.number }}/comments" \ + -f body="$(cat "${RUNNER_TEMP}/scorecard.md")" || echo "::warning::PR comment failed (non-fatal)" diff --git a/scripts/test_precommit_hook.py b/scripts/test_precommit_hook.py new file mode 100644 index 0000000..03533e3 --- /dev/null +++ b/scripts/test_precommit_hook.py @@ -0,0 +1,49 @@ +"""C1 acceptance: the pre-commit hook definition is sound (always), and it runs +from a consumer-side .pre-commit-config.yaml fixture (env-guarded on the +pre-commit tool; the CI dogfood job installs it and runs this for real).""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent + +sys.path.insert(0, str(REPO_ROOT)) +from loop.scaffold import scaffold # noqa: E402 + + +def _hooks() -> list[dict]: + yaml = pytest.importorskip("yaml") + return yaml.safe_load((REPO_ROOT / ".pre-commit-hooks.yaml").read_text(encoding="utf-8")) + + +def test_hook_definition_is_sound(): + (hook,) = _hooks() + assert hook["id"] == "loop-doctor" + assert hook["entry"] == "loop doctor ." + assert hook["language"] == "python" + assert hook["pass_filenames"] is False + assert hook["always_run"] is True + + +def test_entry_command_matches_a_declared_console_script(): + text = (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8") + assert 'loop = "loop.__main__:main"' in text + + +@pytest.mark.skipif(shutil.which("pre-commit") is None, reason="pre-commit tool not installed") +def test_hook_runs_from_a_consumer_fixture(tmp_path): + consumer = tmp_path / "consumer" + scaffold(consumer) + subprocess.run(["git", "init", "-q"], cwd=consumer, check=True) + subprocess.run(["git", "add", "-A"], cwd=consumer, check=True) + proc = subprocess.run( + ["pre-commit", "try-repo", str(REPO_ROOT), "loop-doctor", "--all-files"], + cwd=consumer, capture_output=True, text=True, timeout=600, + ) + assert proc.returncode == 0, proc.stdout + proc.stderr