loopgate 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,47 @@
1
+ # Used by the git hooks so they can run checks.
2
+ # e.g.
3
+ # . "$(dirname "$0")/_resolve"
4
+ # exec "$HARNESS" preflight
5
+ #
6
+ # Loads the harness executable.
7
+ # The harness executable was created in cli.py at `def record_harness`` called with `harness install`.
8
+ #
9
+ # $HARNESS is the path recorded by `record_harness`.
10
+ #
11
+ # Then, git hook does not require `uv` or `pip` or any specific environment layout.
12
+ #
13
+ # if is-agent-in-loop, check harness executable found via harness-path
14
+ RALPH_LOOP="${RALPH_LOOP:-0}"
15
+
16
+ if [ "$RALPH_LOOP" = "0" ]; then
17
+ exit 0
18
+ fi
19
+
20
+ recorded="$(git rev-parse --git-common-dir)/harness-path"
21
+ HARNESS=""
22
+
23
+ if [ -r "$recorded" ]; then
24
+ HARNESS="$(cat "$recorded")"
25
+ fi
26
+
27
+ if [ ! -x "$HARNESS" ]; then
28
+ missing_harness="$HARNESS"
29
+ HARNESS="$(command -v harness 2>/dev/null || true)"
30
+ if [ ! -x "$HARNESS" ]; then
31
+ printf "\nloopgate: hooks are not installed. Run 'harness install' in this repo.\n" >&2
32
+ exit 1
33
+ fi
34
+
35
+ if [ -n "$missing_harness" ]; then
36
+ printf \
37
+ "\nloopgate: recorded harness executable %s is unavailable.\nRecording fallback %s in '.git/harness-path'.\n\n" \
38
+ "$missing_harness" "$HARNESS" >&2
39
+ else
40
+ printf \
41
+ "\nloopgate: no recorded harness executable was found.\nRecording fallback %s in '.git/harness-path'.\n\n" \
42
+ "$HARNESS" >&2
43
+ fi
44
+ printf '%s\n' "$HARNESS" > "$recorded"
45
+ fi
46
+
47
+ export PATH="$(dirname "$HARNESS")${PATH:+:$PATH}"
@@ -0,0 +1,45 @@
1
+ #!/bin/sh
2
+ # Install loopgate hooks into a repo's .githooks without clobbering existing hooks.
3
+ set -eu
4
+
5
+ source_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
6
+ target_dir=.githooks
7
+ if [ "$source_dir" = "$(CDPATH= cd -- "$target_dir" 2>/dev/null && pwd)" ]; then
8
+ exit 0
9
+ fi
10
+ cp "$source_dir/_resolve" "$target_dir/_resolve"
11
+
12
+ for name in pre-commit pre-push prepare-commit-msg; do
13
+ source=$source_dir/$name
14
+ hook=$target_dir/$name
15
+ legacy_hook=$target_dir/loopgate-$name
16
+ legacy_call="\"\$(dirname \"\$0\")/loopgate-$name\" \"\$@\" || exit # loopgate"
17
+ case $name in
18
+ pre-commit) command='exec "$HARNESS" preflight' ;;
19
+ pre-push) command='exec "$HARNESS" gate' ;;
20
+ prepare-commit-msg) command='exec "$HARNESS" prepare-commit-msg "$@"' ;;
21
+ esac
22
+ if [ -e "$hook" ] && grep -Fqx "$legacy_call" "$hook"; then
23
+ tmp=$(mktemp "$target_dir/.hoist.XXXXXX")
24
+ grep -Fvx "$legacy_call" "$hook" > "$tmp"
25
+ mv "$tmp" "$hook"
26
+ if [ "$(wc -l < "$hook")" -eq 1 ] && [ "$(head -n 1 "$hook")" = '#!/bin/sh' ]; then
27
+ cp "$source" "$hook"
28
+ fi
29
+ fi
30
+ rm -f "$legacy_hook"
31
+ if [ ! -e "$hook" ]; then
32
+ cp "$source" "$hook"
33
+ elif ! cmp -s "$source" "$hook" && ! grep -Fqx ') || exit # loopgate' "$hook"; then
34
+ case $(head -n 1 "$hook") in "#!"*sh*) ;; *) echo "hoist: $hook is not a shell hook" >&2; exit 1 ;; esac
35
+ tmp=$(mktemp "$target_dir/.hoist.XXXXXX")
36
+ {
37
+ IFS= read -r first
38
+ printf '%s\n%s\n%s\n%s\n%s\n' \
39
+ "$first" '(' ' . "$(dirname "$0")/_resolve"' " $command" ') || exit # loopgate'
40
+ cat
41
+ } < "$hook" > "$tmp"
42
+ mv "$tmp" "$hook"
43
+ fi
44
+ chmod +x "$hook"
45
+ done
@@ -0,0 +1,7 @@
1
+ #!/bin/sh
2
+ # Ralph commit preflight: fast lint (blocking) + a format report (informative), plus loop containment.
3
+ # Bypassing this hook is forbidden by AGENTS.md.
4
+ set -eu
5
+
6
+ . "$(dirname "$0")/_resolve"
7
+ exec "$HARNESS" preflight
@@ -0,0 +1,10 @@
1
+ #!/bin/sh
2
+ # Ralph pre-push gate: lint, types, security (semgrep), tests, 100% coverage (blocking),
3
+ # plus a format report (informative, never blocks)
4
+ # on what is about to be pushed. The pre-commit hook is kept fast; this is the heavy
5
+ # pass, and the local backstop before code leaves the machine.
6
+ # Bypassing this hook is forbidden by AGENTS.md.
7
+ set -eu
8
+
9
+ . "$(dirname "$0")/_resolve"
10
+ exec "$HARNESS" gate
@@ -0,0 +1,7 @@
1
+ #!/bin/sh
2
+ # Agent containment on the proposed commit message. Runs even under --no-verify.
3
+ # Bypassing this hook is forbidden by AGENTS.md.
4
+ set -eu
5
+
6
+ . "$(dirname "$0")/_resolve"
7
+ exec "$HARNESS" prepare-commit-msg "$@"
harness/__init__.py ADDED
File without changes
harness/cli.py ADDED
@@ -0,0 +1,532 @@
1
+ """Command-line interface for the ralph harness. Plain pass-through commands, no objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ from collections.abc import Callable
10
+ from configparser import ConfigParser
11
+ from copy import deepcopy
12
+ from datetime import datetime, timezone
13
+ from importlib import util
14
+ from pathlib import Path
15
+ from shutil import copy2, rmtree, which
16
+ from typing import Annotated, Any
17
+
18
+ from rich import print as rprint
19
+ from rich.json import JSON
20
+ from rich.table import Table, box
21
+ from tomlkit import TOMLDocument, document, dumps, parse, table
22
+ from typer import Argument, Exit, Option, Typer, colors, confirm, echo, secho, style
23
+
24
+ from harness.config import ASSETS, CATEGORIES, CLAUDE_RULES, CODEX_RULES, PHASES, TOOLS
25
+ from harness.gate import console, gates, run_git
26
+
27
+ app = Typer(
28
+ name="loopgate",
29
+ help="Commands to harness loops into meeting quality standards",
30
+ no_args_is_help=True,
31
+ add_completion=False,
32
+ rich_markup_mode=None if os.environ.get("RALPH_LOOP") else "rich",
33
+ )
34
+ REPO_ROOT = gates().repo_root
35
+ REPO_ROOT_STR = str(REPO_ROOT)
36
+ IS_WINDOWS = sys.platform == "win32"
37
+
38
+
39
+ def setup_git_hooks(env_bin: Path) -> Path:
40
+ """Saves the installed `harness` executable's PATH for Git hooks to run. `pyproject.toml [project.scripts]
41
+ harness = "harness.cli:main"` creates an executable and we record its path here. A hook uses a path "
42
+ instead of needing e.g. active `.venv` or calling `uv run`
43
+
44
+ Arguments:
45
+ env_bin: bin directory of the environment the dependency install just populated
46
+
47
+ Returns:
48
+ The path of the file that records the harness command.
49
+ """
50
+ rprint("\n[cyan2]setting git hooks[/cyan2] `git config core.hooksPath .githooks`")
51
+ subprocess.run(["git", "config", "core.hooksPath", ".githooks"], cwd=REPO_ROOT_STR, check=True)
52
+ binary = env_bin / ("harness.exe" if IS_WINDOWS else "harness")
53
+ recorded = REPO_ROOT / run_git(["rev-parse", "--git-common-dir"]).strip() / "harness-path"
54
+ recorded.write_text(f"{binary.as_posix()}\n", encoding="utf-8", newline="\n")
55
+ if IS_WINDOWS:
56
+ rprint("Windows is experimental. Reoprt issues https://github.com/rxdt/loopgate_harness/issues")
57
+ else:
58
+ subprocess.run(("ls", "-l", ".githooks"), cwd=REPO_ROOT_STR, check=True)
59
+ rprint(f"\nRecorded in {recorded} is the path to executable {env_bin}")
60
+
61
+ return recorded
62
+
63
+
64
+ def run_worker(command: list[str], log: Path, verbose: bool) -> int:
65
+ """Run the worker command, always saving stdout and optionally streaming it live.
66
+
67
+ ralph.sh gets the prompt as a string to pass to the worker in the command
68
+
69
+ Args:
70
+ command: The worker argv to execute.
71
+ log: File path that always receives the raw stdout.
72
+ verbose: When True, also stream compacted output live to the terminal.
73
+
74
+ Returns:
75
+ The worker process's exit code.
76
+ """
77
+ with log.open("w", encoding="utf-8") as handle:
78
+ if not verbose:
79
+ return subprocess.run(command, cwd=REPO_ROOT_STR, stdout=handle, check=False).returncode
80
+ with subprocess.Popen(command, cwd=REPO_ROOT_STR, stdout=subprocess.PIPE, text=True) as process:
81
+ for line in process.stdout or ():
82
+ handle.write(line)
83
+ handle.flush()
84
+ try:
85
+ rendered = JSON(line, indent=None)
86
+ except json.JSONDecodeError:
87
+ rendered = line
88
+ with console.capture() as captured:
89
+ console.print(rendered, end="\n")
90
+ sys.stdout.write(captured.get())
91
+ sys.stdout.flush()
92
+ return process.wait()
93
+
94
+
95
+ def check(name: str, command: Callable[[], dict[str, list[str]]]) -> dict[str, list[str]]:
96
+ """Run a named phase (preflight or gate), render its summary, and exit by its verdict.
97
+
98
+ Args:
99
+ name: Phase label shown in the summary (e.g. "preflight" or "gate").
100
+ command: Callable that runs the phase for a repo. Returns pass/fail buckets.
101
+
102
+ Raises:
103
+ typer.Exit: always — code 1 if anything failed, else code 0.
104
+ """
105
+ results = command()
106
+ summary = Table(title="\nHarness Summary\n", title_style="bold grey82", box=None, padding=(0, 5))
107
+ summary.add_column("RESULT")
108
+ summary.add_column("CHECK", style="bold dim white")
109
+ for passed in results["pass"]:
110
+ summary.add_row("[green]PASSED[/]", passed)
111
+ for fail in results["fail"]:
112
+ summary.add_row("[bold red]FAILED[/]", fail)
113
+ for warn in results["warn"]:
114
+ summary.add_row("[yellow]WARNED[/]", warn)
115
+ console.print(summary, justify="center")
116
+ final = "\n[bold red]rejected by harness[/]" if results["fail"] else f"[green]ok: {name} pass[/]"
117
+ console.print(final, justify="center")
118
+ raise Exit(code=1 if results["fail"] else 0)
119
+
120
+
121
+ @app.command(help="Fast pre-commit checks (lint/format) plus agent containment")
122
+ def preflight() -> None:
123
+ """Dumb pass-through to the fast pre-commit gate."""
124
+ check("preflight", gates().run_preflight)
125
+
126
+
127
+ @app.command(help="Pre-push checks match the CI gate exactly (lint, types, security, etc.)")
128
+ def gate() -> None:
129
+ """Dumb pass-through to the full pre-push gate; exit nonzero if anything fails."""
130
+ check("gate", gates().run_gate)
131
+
132
+
133
+ @app.command(hidden=True, help="Git prepare-commit-msg hook. Called by .githooks, not by people.")
134
+ def prepare_commit_msg(args: Annotated[list[str] | None, Argument(help="What git passes the hook")] = None) -> None:
135
+ """Dumb pass-through to prepare_commit_msg hook logic. Hidden git-only usage, not a human command.
136
+
137
+ Args:
138
+ args: The hook's own arguments: message file, then optionally the source and its commit.
139
+
140
+ Raises:
141
+ typer.Exit: the hook's status; git aborts the commit on 1.
142
+ """
143
+ raise Exit(code=gates().prepare_commit_msg(["prepare-commit-msg", *(args or [])]))
144
+
145
+
146
+ @app.command(help="Show harness configuration and capabilitie in pyproject.toml")
147
+ def info() -> None:
148
+ """Print everything the harness reads from [tool.harness] so nobody has to open pyproject.toml."""
149
+ config = Table(
150
+ title="\n[cyan2]Basic Harness Configuration Settings[/]\n[dim cyan2]See pyproject.toml for more[/]",
151
+ box=box.MINIMAL,
152
+ )
153
+ for title, checks in PHASES:
154
+ config.add_row(f"[bold cyan]{title}[/]", "")
155
+ for name, command in checks.items():
156
+ config.add_row(f" {name}", f"[dim]{' '.join(command)}[/]")
157
+ console.print(config)
158
+
159
+
160
+ @app.command(help="Count agent run logs under scratchpad/runs")
161
+ def status() -> None:
162
+ """Count run logs and point at the newest one."""
163
+ runs = REPO_ROOT / "scratchpad" / "runs"
164
+ logs = sorted(runs.glob("*.jsonl")) if runs.is_dir() else [""]
165
+ secho(f"{len(logs)} run log(s) in {runs}\nnewest: {logs[-1]}", fg=colors.CYAN, bold=True)
166
+
167
+
168
+ def cleanup(cwd: Path) -> bool:
169
+ """Cleans the new local repository of old loopgate things.
170
+
171
+ Arguments:
172
+ cwd: the current working directory to leave a clean template in
173
+
174
+ Returns:
175
+ bool True if successful
176
+ """
177
+ if not ((cwd / "README.template.md").is_file() and (cwd / "harness" / "temp.pyproject.toml").is_file()):
178
+ return False
179
+ clean_tree = not run_git(["status", "--porcelain"], cwd).strip()
180
+ (cwd / "README.template.md").replace(cwd / "README.md")
181
+ (cwd / "harness" / "temp.pyproject.toml").replace(cwd / "pyproject.toml")
182
+ for file_name in ("mutation-score.json", ".github/workflows/publish.yml", "CONTRIBUTING.md"):
183
+ (cwd / file_name).unlink(missing_ok=True)
184
+ for directory in (cwd / "harness" / "tests", cwd / ".assets", cwd / ".*cache"):
185
+ if directory.exists():
186
+ rmtree(directory)
187
+ if run_git(["rev-list", "--count", "HEAD"], cwd).strip() == "1" and clean_tree:
188
+ run_git(["commit", "-a", "--amend", "--no-edit"], cwd)
189
+ return True
190
+
191
+
192
+ @app.command(
193
+ help="Only run this if setting up a project from the template cloned from Github at project root: injects"
194
+ " project name in pyproject.toml, syncs dependencies, adds githooks, DELETES unecessary files!"
195
+ )
196
+ def install() -> None:
197
+ """Used by template cloned from Github. Syncs dependencies, and activates the git hooks."""
198
+ rprint("\n[cyan2]installing dependencies[/cyan2]")
199
+ env_bin = infer_env_manager_and_install()
200
+ cleanup(REPO_ROOT)
201
+ setup_git_hooks(env_bin)
202
+ check_for_timeout_and_prompt()
203
+ rprint("\n[cyan2]If install left git dirty, commit unstaged changes[/]")
204
+
205
+
206
+ def infer_env_manager_and_install() -> Path:
207
+ """Infer the dependency manager, install dependencies, and return its bin directory."""
208
+ python_path = sys.executable
209
+ env = os.environ.get("VIRTUAL_ENV", "")
210
+ env_bin = Path(python_path).parent
211
+ scripts = "Scripts" if IS_WINDOWS else "bin"
212
+ if (
213
+ (REPO_ROOT / "uv.lock").is_file()
214
+ or any(key.startswith("UV_") for key in os.environ)
215
+ or which("uv") is not None
216
+ or "uv" in Path(env).name.lower()
217
+ ):
218
+ env_bin = REPO_ROOT / ".venv" / scripts
219
+ args = ["uv", "sync"]
220
+ elif (REPO_ROOT / "poetry.lock").is_file() or "pypoetry" in env or "pypoetry" in python_path:
221
+ args = ["poetry", "install"]
222
+ else:
223
+ args = [python_path, "-m", "pip", "install", "-r", "requirements.txt", "-e", "."]
224
+ subprocess.run(tuple(args), cwd=REPO_ROOT_STR, check=True)
225
+ if args[0] == "poetry":
226
+ poetry_python = subprocess.run(
227
+ ["poetry", "env", "info", "--executable"], cwd=REPO_ROOT_STR, check=True, capture_output=True, text=True
228
+ ).stdout.strip()
229
+ env_bin = Path(poetry_python).parent
230
+
231
+ return env_bin
232
+
233
+
234
+ @app.command(hidden=True)
235
+ def check_for_timeout_and_prompt() -> str | None:
236
+ """Offer install when macOS lacks a timeout tool. Linux has `timeout`, macOS needs coreutils.gtimeout.
237
+ Returns:
238
+ which gtimeout/timeout a Linux or MocOS user has installed
239
+ """
240
+ if IS_WINDOWS:
241
+ return None
242
+ if not (which("gtimeout") or which("timeout")):
243
+ rprint("\n[yellow]macOS harness needs timeout/gtimeout from coreutils to run loops[/yellow]")
244
+ if not which("brew"):
245
+ rprint("Get Homebrew https://brew.sh then run `brew install coreutils` or `sudo port install`")
246
+ elif (
247
+ confirm("\nInstall `brew install coreutils` now?", abort=True)
248
+ and subprocess.run(("brew", "install", "coreutils"), check=False).returncode == 0
249
+ ):
250
+ rprint(
251
+ "\nIf timeout or gtimeout is installed, you can run loops with `harness run`"
252
+ "\nIf using `uv` or `pip` activate env to use [green]`harness`[/green] commands[turquoise2]"
253
+ )
254
+ return which("gtimeout") or which("timeout")
255
+
256
+
257
+ @app.command(
258
+ help="Run one harnessed ralph loop with <agent>, e.g. harness run claude 3 20.\n\n"
259
+ f"Agents in pyproject.toml (from tool.harness.agents): {', '.join(gates().agents)}"
260
+ )
261
+ def run(
262
+ agent: str,
263
+ num_iterations: Annotated[int, Argument()] = 2,
264
+ max_minutes: Annotated[int, Argument()] = 20,
265
+ verbose: Annotated[bool, Argument()] = True,
266
+ model: Annotated[str | None, Option(help="Override the agent's model")] = None,
267
+ ) -> None:
268
+ """ralph.sh runs once for one agent.
269
+
270
+ Args:
271
+ agent: Agent key to run.
272
+ num_iterations: Number of ralph loop iterations.
273
+ max_minutes: Wall-clock budget per run in minutes.
274
+ verbose: When True, stream the worker's output live to the terminal.
275
+ model: Optional model id replaces the default
276
+
277
+ Raises:
278
+ typer.Exit: code 2 for an unknown agent or non-positive counts, else the worker's exit code.
279
+ """
280
+ raise_issues(agent, num_iterations, max_minutes)
281
+ cwd = Path.cwd()
282
+ runs = cwd / "scratchpad" / "runs" / datetime.now(tz=timezone.utc).strftime("%Y%m%d") / agent
283
+ runs.mkdir(parents=True, exist_ok=True)
284
+ worker_id = f"{max((int(p.stem) for p in runs.glob('[0-9][0-9][0-9][0-9].jsonl')), default=0) + 1:04d}"
285
+ prompt = (cwd / "docs" / "PROMPT.md").read_text(encoding="utf-8").rstrip("\n") # hand agent fixed ID
286
+ os.environ["RALPH_PROMPT"] = f"Your agent id prefix is `{agent}-{worker_id}`\n\n{prompt}"
287
+ log = runs / f"{worker_id}.jsonl" # each log file is one run / ralph invocation, not one iteration
288
+ loop_dir = Path(__file__).resolve().parent
289
+ launcher = (
290
+ ["powershell.exe", "-NoProfile", "-File", str(loop_dir / "ralph.ps1")]
291
+ if IS_WINDOWS # support windows with twin script
292
+ else [str(loop_dir / "ralph.sh")]
293
+ )
294
+ agent_argv = [tok.replace("{log_path}", str(log)) for tok in gates().agents[agent]]
295
+ if model:
296
+ agent_argv[agent_argv.index("--model") + 1] = model
297
+ command = [*launcher, str(num_iterations), str(max_minutes), *agent_argv]
298
+ echo(f"harness: {' '.join(command)} -> {log}", err=True)
299
+ raise Exit(code=run_worker(command, log, verbose))
300
+
301
+
302
+ def raise_issues(agent: str, num_iterations: int, max_minutes: int):
303
+ """Raise issues with input or otherwise
304
+
305
+ Args:
306
+ agent: Agent name to loop. Case-folded and looked up in AGENTS.
307
+ num_iterations: Number of ralph loop iterations. Must be >= 1.
308
+ max_minutes: Wall-clock budget per run in minutes. Must be >= 1.
309
+
310
+ Raises:
311
+ Exit: typer.Exit(code=2) for an unknown agent or non-positive counts, else the worker's exit code.
312
+
313
+ Returns:
314
+ true if successfully set env var and no issues raised
315
+ """
316
+ msg1 = msg2 = msg3 = ""
317
+ agent = agent.casefold()
318
+ if agent not in gates().agents:
319
+ msg1 = f"Unknown agent name '{agent}'"
320
+ if num_iterations < 1 or max_minutes < 1:
321
+ msg2 = "iterations and max_minutes must be >= 1"
322
+ timeout = check_for_timeout_and_prompt()
323
+ if not IS_WINDOWS and not timeout:
324
+ msg3 = "gtimeout or timeout is required"
325
+ if msg1 or msg2 or msg3:
326
+ secho(f"{msg1} {msg2} {msg3}", err=True, fg=colors.MAGENTA, bold=True)
327
+ raise Exit(code=2)
328
+ os.environ["TIMEOUT"] = timeout or ""
329
+ return bool(os.environ["TIMEOUT"])
330
+
331
+
332
+ @app.command(
333
+ help="Bootstraps harness into an existing repository. Set up your project for loops and gates. Run this "
334
+ "if you've installed into an existing project and are setting up for the first time. Adds to configs."
335
+ )
336
+ def init() -> None:
337
+ """Add harness assets, merge tool config, write the CI gate, and enable hooks"""
338
+ if not confirm(
339
+ style("\n1. Confirm loopgate can read configs and write configs to wire checks:", fg=10), default=True
340
+ ):
341
+ secho("Run `harness init` to configure loopgate", fg=colors.MAGENTA, bold=True)
342
+ raise Exit(code=0)
343
+ if write_harness_config():
344
+ confirm(style("\n2. Can we wire githooks so quality checks run?", fg=10), default=True, abort=True)
345
+ hoisted = hoist()
346
+ hooks = setup_git_hooks(Path(sys.executable).parent)
347
+ timeout = check_for_timeout_and_prompt() or IS_WINDOWS
348
+ agents = configure_agents()
349
+ rprint(
350
+ f"\n[bold cyan2]RESULT:[/]\nfiles added: {hoisted}\ngit hooks available via path: {hooks}"
351
+ f"\ntimeout-ready: {timeout}\nagents configured: {agents}"
352
+ f"\n[bold cyan2]Can likely run loops: [/]{bool(hoisted and hooks and timeout)}\n"
353
+ "\n[italic]Ensure your environemnt is activated to use the `harness run` command[/]\n"
354
+ )
355
+
356
+
357
+ def write_harness_config() -> bool:
358
+ """Takes user's configs and creates a pyproject.toml or appends to an existing pyproject.toml."""
359
+ pyproject_path = REPO_ROOT / "pyproject.toml"
360
+ user_pyproject: TOMLDocument = document()
361
+ user_pyproject_tools: dict[str, Any] = {}
362
+ if pyproject_path.is_file():
363
+ user_pyproject: TOMLDocument = parse(pyproject_path.read_text(encoding="utf-8"))
364
+ user_pyproject_tools: dict[str, Any] = user_pyproject.setdefault("tool", table())
365
+ user_harness = user_pyproject_tools.get("harness", {})
366
+ checks = user_harness.get("gate", {})
367
+ if checks and checks.get("test"):
368
+ confirm(style("Seems loopgate may be wired already. Continue?", fg=10), default=True, abort=True)
369
+ template: Path = Path(__file__).resolve().with_name("temp.pyproject.toml")
370
+ template_contents: TOMLDocument = parse(template.read_text(encoding="utf-8"))
371
+ for t in template_contents["tool"]:
372
+ if t in user_pyproject_tools:
373
+ template_contents["tool"][t] = deepcopy(user_pyproject_tools[t])
374
+ contents = ConfigParser(interpolation=None)
375
+ contents.read([(REPO_ROOT / "tox.ini"), (REPO_ROOT / "setup.cfg")], encoding="utf-8")
376
+ section_names = contents.sections()
377
+ inspect_configs(section_names, user_pyproject_tools, deepcopy(CATEGORIES), template_contents["tool"])
378
+ user_pyproject.setdefault("tool", {}).update(template_contents["tool"])
379
+ return bool(pyproject_path.write_text(dumps(user_pyproject), encoding="utf-8"))
380
+
381
+
382
+ def find_configured_command(
383
+ tool_name: str,
384
+ tool_config: dict[str, Any],
385
+ section_names: list[str],
386
+ user_pyproject_tools: dict[str, Any],
387
+ test_configured: bool,
388
+ ) -> tuple[list[str], bool] | None:
389
+ """Find a configured command using the configuration file precedence.
390
+
391
+ Args:
392
+ tool_name: Name of the tool being inspected.
393
+ tool_config: Harness defaults for the tool.
394
+ section_names: Sections in tox.ini and setup.cfg, if found.
395
+ user_pyproject_tools: The tool's tables found in a user's pyproject.toml, if it exists.
396
+ test_configured: Whether a test tool was already added to the harness config.
397
+
398
+ Returns:
399
+ Command arguments and whether to keep their template table, or None if no configuration was found.
400
+ """
401
+ args = tool_config["args"]
402
+ if any((REPO_ROOT / filename).is_file() for filename in tool_config.get("filenames", [])):
403
+ return args, False
404
+ if args[0] in user_pyproject_tools:
405
+ return args, True
406
+ if tool_name in section_names:
407
+ return args, False
408
+ tox_fallback = not test_configured and tool_name == "pytest" and "testenv" in section_names and which("tox")
409
+ if tox_fallback:
410
+ args = ["tox"]
411
+ return (args, False) if tox_fallback or f"tool:{args[0]}" in section_names else None
412
+
413
+
414
+ def inspect_configs(
415
+ section_names: list[str],
416
+ user_pyproject_tools: dict[str, Any],
417
+ categories: dict[str, str],
418
+ template: dict[str, Any],
419
+ ) -> None:
420
+ """For each tool in a pre-built internal map, see if user has the tool installed and configured.
421
+ Args:
422
+ section_names: Configuration section names found outside pyproject.toml.
423
+ user_pyproject_tools: Tool configurations from the user's pyproject.toml.
424
+ categories: Primary template check remaining for each category.
425
+ template: Tool configurations from the harness template.
426
+ """
427
+ for tool_name, tool_config in TOOLS.items():
428
+ category: str = tool_config["category"]
429
+ harness_stage = template["harness"]["preflight" if category in {"complexity", "format", "lint"} else "gate"]
430
+ args = tool_config.get("args")
431
+ if not args:
432
+ continue
433
+ if util.find_spec(tool_name) is None and which(args[0]) is None:
434
+ harness_stage.pop(tool_name, None)
435
+ template.pop(args[0], None)
436
+ continue
437
+ configured_command = find_configured_command(
438
+ tool_name, tool_config, section_names, user_pyproject_tools, "test" not in categories
439
+ )
440
+ if configured_command is None:
441
+ continue
442
+ args, keep_template_table = configured_command
443
+ replaced_check = categories.pop(category, tool_name)
444
+ current_args = harness_stage.pop(replaced_check, args)
445
+ harness_stage[category if replaced_check == category else tool_name] = args
446
+ if not keep_template_table:
447
+ template.pop(current_args[0], None)
448
+ template.pop(args[0], None)
449
+
450
+
451
+ def hoist() -> bool:
452
+ """Hoist files expected for loops to root of repo."""
453
+ if not (ASSETS["docs"][0].is_dir() and ASSETS["githooks"][0].is_dir()):
454
+ rprint("Harness is missing required assets: `docs/` and `githooks/`")
455
+ return False
456
+ rprint(
457
+ "\n[bold yellow]We will need to add these files[/]\n* Git hooks are what ensure quality checks run"
458
+ "\n* Mutation tests promote good tests.\n* `preferences` allow for checks beyond what tooling catches"
459
+ "and demonstrate Hypothesis property tests\n* `docs` contain the instructions and memory for loops "
460
+ "\n*`scratchpad/` allows local agent use and contains a `runs/` directory for logs."
461
+ )
462
+ confirm(
463
+ style(
464
+ "3. Confirm, loopgate can add those files? Pre-existing files in the expected paths will remain "
465
+ "and loopgate will skip adding them.",
466
+ fg=10,
467
+ ),
468
+ default=True,
469
+ abort=True,
470
+ )
471
+ repo_root = REPO_ROOT
472
+ ASSETS["githooks"][1].mkdir(parents=True, exist_ok=True)
473
+ hoist_script: str = (ASSETS["githooks"][0] / "hoist").as_posix()
474
+ run_git(["-c", 'alias.loopgate-hoist=!f() { sh "$1"; }; f', "loopgate-hoist", hoist_script], REPO_ROOT)
475
+ console.print(f"[green]\n4. Ran {hoist_script}[/]\n")
476
+ for key, paths in ASSETS.items():
477
+ source, destination = paths
478
+ for source_path in source.rglob("*"):
479
+ destination_path = destination / source_path.relative_to(source)
480
+ if source_path.is_dir():
481
+ destination_path.mkdir(parents=True, exist_ok=True)
482
+ elif not destination_path.exists():
483
+ destination_path.parent.mkdir(parents=True, exist_ok=True)
484
+ copy2(source_path, destination_path)
485
+ rprint(f"`{key}/` exists {destination.exists()}")
486
+ (repo_root / "scratchpad" / "runs").mkdir(parents=True, exist_ok=True)
487
+ (repo_root / "scratchpad" / "runs" / ".gitkeep").touch()
488
+ rprint(f"`scratchpad/` and `runs/` also added at {repo_root}")
489
+ return True
490
+
491
+
492
+ @app.command(
493
+ help="This will set an env variable in Claude and Codex configuration files and add rules that they "
494
+ "cannot edit that variable. This ensures interactive agents (e.g. in IDEs) are held to the same standards"
495
+ " as headless agents. It makes gates un-bypassable. You can always return to rerun this later too."
496
+ )
497
+ def configure_agents() -> bool:
498
+ """Configure Claude and Codex for contained loops."""
499
+ home = Path.home()
500
+ claude_path, codex_path, bak = (
501
+ home / ".claude/settings.json",
502
+ home / ".codex/config.toml",
503
+ home / ".codex/config.toml.bak",
504
+ )
505
+ claude = json.loads(claude_path.read_text("utf-8") if claude_path.is_file() else "{}")
506
+ codex = parse(codex_path.read_text("utf-8") if codex_path.is_file() else "")
507
+ cl_confirm = confirm(style("\n5.1. Can we update CLAUDE rules and settings?", fg=10), default=True, abort=True)
508
+ if cl_confirm and claude_path.is_file():
509
+ rprint(f"settings.json edited. Original copy at {copy2(claude_path, f'{claude_path}.bak')}")
510
+ claude.setdefault("env", {})["RALPH_LOOP"] = "1"
511
+ permissions: dict[str, Any] = claude.setdefault("permissions", {})
512
+ permissions["deny"] = list(set(permissions.get("deny", [])) | CLAUDE_RULES)
513
+ cx_confirm = confirm(style("5.2. Can we update CODEX rules and settings?", fg=10), default=True, abort=True)
514
+ if cx_confirm and codex_path.is_file():
515
+ rprint(f"config.toml edited. Original backup at {copy2(codex_path, bak)}\n")
516
+ codex.setdefault("shell_environment_policy", {}).setdefault("set", {})["RALPH_LOOP"] = "1"
517
+ claude_path.parent.mkdir(parents=True, exist_ok=True)
518
+ claude_path.write_text(f"{json.dumps(claude, indent=2)}\n", encoding="utf-8")
519
+ codex_path.parent.mkdir(parents=True, exist_ok=True)
520
+ codex_path.write_text(dumps(codex), encoding="utf-8")
521
+ codex_rules_path = home / ".codex" / "rules" / "loopgate.rules"
522
+ codex_rules_path.parent.mkdir(parents=True, exist_ok=True)
523
+ codex_rules_path.write_text(CODEX_RULES, encoding="utf-8")
524
+ return True
525
+
526
+
527
+ def main(argv: list[str] | None = None) -> None:
528
+ """Console-script entry point: run the app so typer.Exit sets the process exit code.
529
+ Args:
530
+ argv: Optional command-line arguments passed to the Typer app.
531
+ """
532
+ app(args=argv)