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
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
@@ -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)"
49 changes: 49 additions & 0 deletions scripts/test_precommit_hook.py
Original file line number Diff line number Diff line change
@@ -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
Loading