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
70 changes: 53 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
[![mutation](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Frxdt%2Floopgate_harness%2Fmain%2Fmutation-score.json)](https://github.com/rxdt/loopgate_harness/actions/workflows/mutation.yml)



</div>

---
Expand Down Expand Up @@ -72,7 +73,8 @@ Each run starts fresh, has clear limits, saves its logs, protects key files, and

---

## Default Tools
## Default Tool Dependencies
_Edit at will_
- [ruff](https://docs.astral.sh/ruff/) lints and formats Python code, fast
- [pylint](https://pypi.org/project/pylint/) catches code errors and style problems
- [pydoclint](https://pypi.org/project/pydoclint/0.9.1/) checks docstrings match function signatures
Expand All @@ -88,14 +90,20 @@ Each run starts fresh, has clear limits, saves its logs, protects key files, and
- [preferences.py](preferences/preferences.py) A custom AST-parser to optionally expand. It catches e.g. a [style preference](https://google.github.io/styleguide/pyguide) that tools don't.
- Forbidden paths set in [[tool.harness]](pyproject.toml)
- Update `[tool.harness.gate]` or `[tool.harness.gate]` in [pyproject](pyproject.toml) to change what is checked before a commit or push.
- Failing checks block by default. If a fresh drop-in fails across the board, set [`behavior = "warn"`](pyproject.toml#L87) to report without blocking, then flip back to `"fail"`.

#### The Gate: Tiered Checks
### The Gate: Tiered Checks

⚡ `harness preflight` _(pre-commit)_ are the fast checks to run often. Lint + check format for everyone, _plus_ **containment** for the agents.
> [!NOTE]
> **Self-heals by un-staging forbidden files.**
> **A pre-commit or gate phase self-heals by un-staging forbidden files.**

⚡ `harness preflight` _(pre-commit)_

are the fast checks to run often. Lint + check format for everyone, _plus_ **containment** for the agents.

✅ `harness gate` _(pre-push)_ = _(pre-commit)_ checks **+** type-checks, security audit, dependency audit, complexity analysis, full test coverage, prompt to run mutmut
✅ `harness gate` _(pre-push)_ = _(pre-commit)_ checks **+**

adds type-checks, security audit, dependency audit, AST-scan, complexity analysis, full test coverage, prompt to run mutmut

Only humans can bypass triggered gates and commit, always. Only humans can use flag `--no-verify`.

Expand All @@ -109,13 +117,14 @@ Note that `semgrep --config auto` needs network for semgrep registry rules.

## Details

[docs/plan.md](docs/plan.md) is where you define what you want the end product to be. You must be _very_ clear on what the finished product should and should **not** contain. Do **not** let agents guess.
[plan.md](docs/plan.md) is where you define what you want the end product to be. You must be _VERY_ clear on what the finished product should and should **not** contain. Do **not** let agents guess. Tell agents *exactly* what the finished "product" should be.

`docs/PROMPT.md` tells each agent to pick a `spec` and build. `docs/specs/` say _what_ to build. The agent decides _what next_. You keep `docs/plan.md` current, and specs get rewritten from it (agent is told in `docs/PROMPT.md` to update the specs). Each iteration the agent updates its spec and `PROJECT_STATUS`.
[`PROMPT`](docs/PROMPT.md) tells each agent to pick a spec.md and build. [`specs/`](docs/specs) say _what_ to build. The agent decides _what next_. You keep [`plan.md`](docs/plan.md) current, and specs get rewritten from it. The agent is told in `docs/PROMPT.md` to update the specs. Each iteration the agent updates its spec and [`PROJECT_STATUS`](docs/PROJECT_STATUS.md).

> [!IMPORTANT]
> Default configuration is in [`pyproject.toml`](pyproject.toml). Update tool settings, add agent commands, change checks, or leave it as is.
### In summary, your job, the bare minimum:
<mark>write something into the plan</mark>

## Start a project
## Start a project

1. `gh repo create <your-github-username>/<your-new-app-name> --template rxdt/loopgate_harness --private --clone` **or**
Expand All @@ -133,6 +142,9 @@ Note that `semgrep --config auto` needs network for semgrep registry rules.
10. Configurations for Ruff linting, type-checking Pyright, Complexipy, Pytest coverage, etcetera are set in [`pyproject.toml`](pyproject.toml).
11. Your coding quirks go in [`preferences/preferences.py`](preferences/preferences.py). Delete functions that don't serve you. Add your own.

> [!IMPORTANT]
> Default configuration is in [`pyproject.toml`](pyproject.toml). Update tool settings, add agent commands, change checks, or leave it as is.

### Works with `uv`, `poetry`, or `pip`

```sh
Expand Down Expand Up @@ -202,10 +214,6 @@ harness run copilot 2 20
```
#### To run LoopGate with any agent, the worker must be installed and authenticated separately.

### Run logs

Every run is saved as a log file in `scratchpad/runs/`. `harness status` shows how many logs you have and the path to the newest one. Open that file to read what the agent thought and did. _(Metrics and audited logs coming soon.)_

### Add a mutation score badge

Run `uv run mutmut run && uv run mutmut export-cicd-stats`, then use [check_mutmut.py](mutation/check_mutmut.py) to write `mutation-score.json` for the Shields badge.
Expand Down Expand Up @@ -234,15 +242,21 @@ harness/ the gate, loop runner, CLI (🤖 forbi
cli.py command-line entry point
js-scaffold javascript example to build upon
preferences/ user-defined preferences not covered by tools (🤖 forbidden directory)
mutation/ get your mutation score and learn how to run mutmut (🤖 forbidden directory)
check_mutant.py
tests/
preferences/ (🤖 tests/preferences is forbidden directory)
.githooks/ pre-commit / pre-push gate hooks (🤖 forbidden directory)
pyproject.toml project + tooling config (🤖 forbidden)
pyproject.toml project + tooling config (🤖 forbidden file)
docs/ PROMPT, specs/, your plan (agent and human maintained)
scratchpad/ scratch dir agents can use for temp files (For the 🤖 to play)
src/ your product/source code (add to coverage source)
```

### Run logs

Every run is saved as a log file in `scratchpad/runs/`. `harness status` shows how many logs you have and the path to the newest one. Open that file to read what the agent thought and did. _(Metrics and audited logs coming soon.)_

[`pyproject.toml`](pyproject.toml) is the single source of harness configuration. Humans own it and [`preferences/`](preferences/); both are agent-protected.

If an agent edits a forbidden file, the file will be unstaged (not allowed to commit). A forbidden pattern by an agent (e.g. `# noqa` or `nosemgrep` will also prevent their commit and force them to fix it.)
Expand All @@ -265,7 +279,7 @@ If an agent edits a forbidden file, the file will be unstaged (not allowed to co
```yaml
- run: uv sync && uv run harness gate
```
- Add or remove coding preferences [preferences.py](preferences/preferences.py) that only agents in loops **must** respect. Current preferences:
- Add or remove coding preferences [preferences.py](preferences/preferences.py) that only agents **must** respect. Current "preferences":

```py
function_argument_assignment_has_star # agents use non-specific `def fun(*)`
Expand Down Expand Up @@ -300,9 +314,17 @@ LoopGate does not install or log in agent CLIs. Install and authenticate the wor

A **gate** is a workflow checkpoint that evaluates code and decides whether it is allowed to land in your commits. A **sandbox** is an isolated OS-level environment designed to prevent code from modifying your underlying machine. LoopGate uses gates to control your git history, but it does _not_ provide a secure OS sandbox.

- **What if I don't want to use those agents?**

Remove or add commands to enable using different agents. Let's say you're going _exclusive_ with Mistral and will _only_ use Mistral 3. Set this and only this in [`[tool.harness.agents]`](pyproject.toml#L91-L123)
```
vibe = ["vibe", "--auto-approve", "--output", "streaming"]
```
Then run it with `harness run vibe <loop-count> <max-minutes-per-loop`

- **What if I don't want to build an app in Python?**

You don’t have to. The loop runner, Ralph, and the CLI take a prompt, launch agents pointed at markdown files. LoopGate is language-agnostic at the agent-loop level, but the template is configured to be Python-specific at [pyproject.toml](pyproject.toml). Add your language and commands for your checks to run there.
You don’t have to. The loop runner, Ralph, and the CLI take a prompt, launch agents pointed at markdown files. LoopGate is language-agnostic at the agent-loop level, only this template repo is configured to be Python-specific at [pyproject.toml](pyproject.toml). You can add _your_ language and your _tool commands_ for your checks to run there.

- **Javascript?**

Expand All @@ -315,7 +337,20 @@ npm run --prefix harness/js-scaffold preflight

- **Why not just a shell loop?**

A shell loop only reruns an agent. LoopGate ensures fresh context, durable repo state, time and iteration limits, protected paths, and quality gates that stop bad changes _before_ they land.
A shell loop only reruns an agent. LoopGate ensures fresh context, durable repo state, time and iteration limits, protected paths, and quality gates that stop bad changes _before_ they land. Also, we like to keep our shell loops dumb round these parts. And Let the configs do the lifting.

#### LoopGate Glossary

Short definitions of common LoopGate terms.

- harness: the LoopGate tool that runs agents, manages loops, and checks changes.
- loop: one cycle of work where the worker reads instructions, works on a spec, makes changes, runs checks, and records progress.
- worker: the coding agent that does the work, such as Claude, Codex, or Copilot.
- gate: the checks that decide whether changes can be accepted. LoopGate runs these checks locally and in CI. aka pre-push, what happens right before a diff is pushed to origin.
- preflight: the quick checks that run before the full gate to catch common problems early. aka 'pre-commit', what happens right before files are git committed.
- prompt: the instructions in `docs/PROMPT.md` that tell the worker what to do in each loop.
- spec: a file in `docs/specs/` that describes what needs to be built.
- Ralph: LoopGate's loop runner. It starts the worker, gives it the prompt, and runs the workflow for each iteration.

#### LoopGate Glossary

Expand Down Expand Up @@ -385,6 +420,7 @@ Short definitions of common LoopGate terms.
<summary>

## Read before Infinity Loops </summary>
## Read before Infinity Loops </summary>

1. **This harness does not sandbox agents.** It tries to harness bad code in loops via gates. Sandboxing agents will, e.g. prevent them from maintaining git, running Playwright, being seen as trustworthy by semgrep leading to cyclical failures, etc.

Expand Down
1 change: 1 addition & 0 deletions README.template.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ harness run copilot 2 20
Every run is saved as a log file in `scratchpad/runs/`. `harness status` shows how many logs you have and the path to the newest one — open that file to read everything the agent did.

Tool commands are defined in `[tool.harness]` in [pyproject.toml](pyproject.toml). The gate and CI both derive them from there.
Failing checks block by default. If your project fails across the board at first, set [`behavior = "warn"`](pyproject.toml#L87) to report without blocking, then flip back to `"fail"`.

#### The Gate: Tiered Checks

Expand Down
2 changes: 1 addition & 1 deletion harness/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ def check_for_timeout_and_prompt() -> str | None:
"""
if IS_WINDOWS:
return None
if not which("gtimeout") or not which("timeout"):
if not (which("gtimeout") or which("timeout")):
rprint("\n[yellow]macOS harness needs timeout/gtimeout from coreutils to run loops[/yellow]")
if not which("brew"):
rprint("Get Homebrew https://brew.sh then run `brew install coreutils` or `sudo port install`")
Expand Down
15 changes: 8 additions & 7 deletions harness/gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,16 @@ def __init__(self, root: Path) -> None:
Path(__file__).with_name("temp.pyproject.toml").read_text(encoding="utf-8")
)
harness = defaults["tool"]["harness"]
self.settings = harness.get(
"settings", {"behavior": "fail", "error_diff_lines": 500, "languages": ["py"]}
)
self.forbidden: dict[str, list[str]] = harness.get("FORBIDDEN", {})
self.languages: tuple[str, ...] = harness.get("languages", {})
self.agents: dict[str, list[str]] = harness.get("agents", {})
self.commit_checks: dict[str, list[str]] = harness.get("preflight", {})
self.gate_checks: dict[str, list[str]] = harness.get("gate", {}) | self.commit_checks
self.forbidden_files: tuple[str, ...] = tuple(self.forbidden.get("FILES", []))
self.forbidden_dirs: tuple[str, ...] = tuple(self.forbidden.get("DIRS", []))
self.forbidden_patterns: tuple[str, ...] = tuple(self.forbidden.get("PATTERNS", []))
self.error_diff_lines: int = harness.get("error_diff_lines")

def run_checks(self, checks: dict[str, list[str]]) -> dict[str, list[str]]:
"""Run each named command, streaming its output live under a phase header.
Expand All @@ -80,7 +81,7 @@ def run_checks(self, checks: dict[str, list[str]]) -> dict[str, list[str]]:
elif "format" in name:
results["warn"].append(name)
else:
results["fail"].append(name)
results[self.settings["behavior"]].append(name)
key = "fail" if os.environ.get("RALPH_LOOP") else "warn"
colorize("AGENT CHECKs", "running non-human agent checks")
self._run_non_human_checks(results, key)
Expand Down Expand Up @@ -173,16 +174,16 @@ def _check_diff_size(self, ref: str, results: dict[str, list[str]], key: str):
inserted, deleted, path = line.split("\t", 2)
if not (inserted == "-" or path.lower().endswith(".lock")): # binary or lockfile
total += int(inserted) + int(deleted)
warn_at_75: int = round(self.error_diff_lines * 0.75)
warn_at_75: int = round(self.settings["error_diff_lines"] * 0.75)
msg = (
f"{total} lines of code modified (insertions + deletions in staged files). Agents get WARN at "
f"75% {warn_at_75}, ERROR at {self.error_diff_lines}."
f"75% {warn_at_75}, ERROR at {self.settings['error_diff_lines']}."
)
do_better = (
"\nRefactor bloat, reduce mis-direction, re-use fixtures, cut duplication, slim down "
"code. More code does not mean good code."
)
if total > self.error_diff_lines:
if total > self.settings["error_diff_lines"]:
results[key].append(msg + do_better)
elif total > warn_at_75:
results["warn"].append(msg + do_better)
Expand All @@ -196,7 +197,7 @@ def _check_for_preferences(self) -> str:
The preferences violations and filepath found in staged files.
"""
problems: list[str] = []
if "py" in self.languages:
if "py" in self.settings["languages"]:
staged = run_git([
"diff",
"--cached",
Expand Down
96 changes: 64 additions & 32 deletions harness/ralph.ps1
Original file line number Diff line number Diff line change
@@ -1,52 +1,84 @@
# Ralph (Windows twin of ralph.sh). Hand docs/PROMPT.md to a fresh-context agent and loop.
# Keep Ralph Dumb: start the worker, give it the prompt, print a line, repeat. Nothing else.
# Windows has no POSIX `timeout`, so this uses Wait-Process + taskkill /T to bound each iteration.
# Windows has no POSIX `timeout`, so this uses Process.WaitForExit + taskkill /T.
#
# Usage: pwsh -File ralph.ps1 <max_iterations> <max_minutes_per_iteration> <agent command...>
# Usage:
# powershell.exe -File ralph.ps1 <max_iterations> <max_minutes_per_iteration> <agent command...>

$ErrorActionPreference = "Stop"
[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)
$env:RALPH_LOOP = "1" # mark loop commits so the gate applies containment to the worker
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)

# Mark loop commits so the gate (run by the git hooks) applies containment to the worker.
$env:RALPH_LOOP = "1"

function ConvertTo-WindowsArgument([string]$argument) {
$escaped = [regex]::Replace($argument, '(\\*)"', '$1$1\"')
return '"' + $escaped + [regex]::Match($argument, '(\\*)$').Groups[1].Value + '"'
}

function Write-RalphEvent([System.Collections.IDictionary]$payload) {
[Console]::Out.WriteLine(($payload | ConvertTo-Json -Compress))
[Console]::Out.Flush()
}

if ($args.Count -lt 3) {
[Console]::Error.WriteLine(
"Usage: ralph.ps1 <max_iterations> <max_minutes_per_iteration> <agent command...>"
)
exit 2
}

$maxIterations = [int]$args[0]
$maxMinutes = [double]$args[1]
$rest = @($args | Select-Object -Skip 2)
$worker = @($args | Select-Object -Skip 2)
$timeoutMilliseconds = [int][Math]::Ceiling($maxMinutes * 60 * 1000)

for ($i = 1; $i -le $maxIterations; $i++) {
# Receipt line, same stdout contract as ralph.sh: `harness run` saves stdout as the run's .jsonl.
# The worker inherits this handle, so flush before starting it or the records interleave.
$timestamp = (Get-Date).ToString("yyyy-MM-ddTHH:mm")
[Console]::Out.WriteLine("{""type"":""ralph"",""iteration"":$i,""max_iterations"":$maxIterations,""max_minutes"":$maxMinutes,""timestamp"":""$timestamp""}")
[Console]::Out.Flush()
$stdin = "$($env:RALPH_PROMPT)`n`nRALPH_ITERATION=$i/$maxIterations`n"
$psi = [System.Diagnostics.ProcessStartInfo]::new()
$psi.FileName = $rest[0]
if ($rest.Count -gt 1) {
$psi.Arguments = (($rest[1..($rest.Count - 1)] | ForEach-Object { ConvertTo-WindowsArgument $_ }) -join ' ')
$iteration = 1
while ($iteration -le $maxIterations) {
Write-RalphEvent ([ordered]@{
type = "ralph"
iteration = $iteration
max_iterations = $maxIterations
max_minutes = $maxMinutes
timestamp = (Get-Date).ToString("yyyy-MM-ddTHH:mm")
})

$startInfo = [System.Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = $worker[0]
if ($worker.Count -gt 1) {
$startInfo.Arguments = (($worker[1..($worker.Count - 1)] | ForEach-Object {
ConvertTo-WindowsArgument $_
}) -join ' ')
}
$psi.RedirectStandardInput = $true
$psi.UseShellExecute = $false
$proc = [System.Diagnostics.Process]::Start($psi)
# feed the prompt on stdin, then bound the run; taskkill /T kills the agent AND its children
$proc.StandardInput.Write($stdin); $proc.StandardInput.Close()
# Bound the run. Like ralph.sh's `set -e` + timeout: a timeout or a nonzero worker exit stops the
# loop and propagates failure, so `harness run` never reports success for a failed iteration.
$timeoutMilliseconds = [int][Math]::Ceiling($maxMinutes * 60 * 1000)
if (-not $proc.WaitForExit($timeoutMilliseconds)) {
taskkill.exe /F /T /PID $proc.Id | Out-Null
$proc.WaitForExit()
exit 124 # match GNU timeout's exit code
$startInfo.RedirectStandardInput = $true
$startInfo.UseShellExecute = $false

$process = [System.Diagnostics.Process]::Start($startInfo)
$prompt = "$($env:RALPH_PROMPT)`n`nRALPH_ITERATION=$iteration/$maxIterations`n"
$process.StandardInput.Write($prompt)
$process.StandardInput.Close()

if (-not $process.WaitForExit($timeoutMilliseconds)) {
taskkill.exe /F /T /PID $process.Id | Out-Null
$process.WaitForExit()
$process.Dispose()
exit 124
}
if ($proc.ExitCode -ne 0) {
exit $proc.ExitCode

$process.WaitForExit()
$exitCode = $process.ExitCode
$process.Dispose()
if ($exitCode -ne 0) {
exit $exitCode
}

$iteration += 1
}

$timestamp = (Get-Date).ToString("yyyy-MM-ddTHH:mm")
[Console]::Out.WriteLine("{""type"":""ralph"",""completed"":$maxIterations,""max_minutes"":$maxMinutes,""timestamp"":""$timestamp""}")
[Console]::Out.Flush()
Write-RalphEvent ([ordered]@{
type = "ralph"
completed = $iteration - 1
max_minutes = $maxMinutes
timestamp = (Get-Date).ToString("yyyy-MM-ddTHH:mm")
})
Loading
Loading