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.
harness/gate.py ADDED
@@ -0,0 +1,297 @@
1
+ """1) Preflight pre-commit checks basic quality plus agent containment. `def run_preflight`
2
+
3
+ 2) Full gate on staged files.
4
+ `def run_gate` mirrors what will run on Github (CI runs this same `harness gate`).
5
+
6
+ All containment lists and check commands come from [tool.harness] in pyproject.toml, read once at
7
+ import into the constants below. A check is a (name, argv) pair; its `preflight`/`blocking` flags sort
8
+ it into the maps and sets this module runs on.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import subprocess
15
+ import sys
16
+ from functools import cache
17
+ from pathlib import Path
18
+
19
+ import tomlkit as tomllib
20
+ import typer
21
+ from rich.console import Console
22
+
23
+ from mutation.check_mutmut import MINIMUM_MUTATION_SCORE, analyze_mutmut_report_passed
24
+
25
+ try:
26
+ from preferences.preferences import preferences_violations as prefs
27
+ except ImportError: # humans can delete preferences.py
28
+ prefs = None
29
+
30
+ console = Console(force_terminal=True, color_system=None if os.environ.get("RALPH_LOOP") else "auto")
31
+
32
+
33
+ class Gate:
34
+ """Contains Gate configuration values and methods."""
35
+
36
+ EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" # universal empty tree hash
37
+
38
+ def __init__(self, root: Path) -> None:
39
+ self.repo_root = root
40
+ pyproject = self.repo_root / "pyproject.toml"
41
+ toml = tomllib.loads(pyproject.read_text(encoding="utf-8")).unwrap() if pyproject.is_file() else {}
42
+ harness = toml.get("tool", {}).get("harness")
43
+ if not harness:
44
+ defaults = tomllib.loads(Path(__file__).with_name("temp.pyproject.toml").read_text(encoding="utf-8"))
45
+ harness = defaults["tool"]["harness"]
46
+ self.settings = harness.get("settings", {"behavior": "fail", "error_diff_lines": 500, "languages": ["py"]})
47
+ self.forbidden: dict[str, list[str]] = harness.get("FORBIDDEN", {})
48
+ self.agents: dict[str, list[str]] = harness.get("agents", {})
49
+ self.commit_checks: dict[str, list[str]] = harness.get("preflight", {})
50
+ self.gate_checks: dict[str, list[str]] = harness.get("gate", {}) | self.commit_checks
51
+ self.forbidden_files: tuple[str, ...] = tuple(self.forbidden.get("FILES", []))
52
+ self.forbidden_dirs: tuple[str, ...] = tuple(self.forbidden.get("DIRS", []))
53
+ self.forbidden_patterns: tuple[str, ...] = tuple(self.forbidden.get("PATTERNS", []))
54
+
55
+ def run_checks(self, checks: dict[str, list[str]]) -> dict[str, list[str]]:
56
+ """Run each named command, streaming its output live under a phase header.
57
+ Reports what each command did and leaves the verdict to the caller.
58
+
59
+ Args:
60
+ checks: Mapping of check name to the argv that runs it.
61
+
62
+ Returns:
63
+ { "pass": [...], "warn": [...], "fail": [ problems ] } bucketing each check name by exit code.
64
+ If anything is in "fail", a commit is not allowed.
65
+ """
66
+ clean_env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")}
67
+ if not os.environ.get("RALPH_LOOP"):
68
+ clean_env.update({"FORCE_COLOR": "1", "CLICOLOR_FORCE": "1", "SEMGREP_FORCE_COLOR": "1"})
69
+ results: dict[str, list[str]] = {"pass": [], "fail": [], "warn": []}
70
+ for name, command in checks.items():
71
+ colorize(name, " ".join(command))
72
+ sys.stdout.flush()
73
+ with subprocess.Popen(command, cwd=self.repo_root, env=clean_env) as process:
74
+ exit_code = process.wait()
75
+ if exit_code == 0:
76
+ results["pass"].append(name)
77
+ elif "format" in name:
78
+ results["warn"].append(name)
79
+ else:
80
+ results[self.settings["behavior"]].append(name)
81
+ key = "fail" if os.environ.get("RALPH_LOOP") else "warn"
82
+ colorize("AGENT CHECKs", "running non-human agent checks")
83
+ self._run_non_human_checks(results, key)
84
+ mutmut_key = "pass" if analyze_mutmut_report_passed() >= MINIMUM_MUTATION_SCORE else key
85
+ results[mutmut_key].append("mutmut")
86
+ return results
87
+
88
+ def _run_non_human_checks(self, results: dict[str, list[str]], key: str):
89
+ """Runs checks on non-humans only. Checks things that linters or other chekcs to do not check.
90
+ Unstages files that should never be touched.
91
+
92
+ Arguments:
93
+ results: The original bucketing of each check name into "pass"/"fail"/"warn" lists
94
+ key: `results` dictionary will use "fail"/"warn" if an agent is being checked
95
+ """
96
+ ref = "HEAD" if run_git(["rev-parse", "--verify", "HEAD"], check=False).strip() else self.EMPTY_TREE
97
+ staged = run_git([
98
+ "diff",
99
+ "--cached",
100
+ "--name-only",
101
+ "--no-renames",
102
+ "--diff-filter=ACMRD",
103
+ ]).splitlines()
104
+ if not staged:
105
+ colorize("EMPTY COMMIT", "nothing staged: do real work, do not commit empty")
106
+ return
107
+ forbidden_paths: list[str] = [
108
+ path
109
+ for path in staged
110
+ if path.casefold() in self.forbidden_files or path.casefold().startswith(self.forbidden_dirs)
111
+ ]
112
+ if forbidden_paths:
113
+ out = "\n".join(forbidden_paths)
114
+ colorize("EJECTED", f"Would keep forbidden paths out of agent commit:[dim green]\n{out}[/]")
115
+ if key == "fail":
116
+ run_git(["reset", "-q", ref, "--", *forbidden_paths])
117
+ if forbidden_patterns := self._check_for_bad_patterns():
118
+ results[key].append("FORBIDDEN FOR AGENT:\n" + "\n".join(forbidden_patterns))
119
+ if ignored_preferences := self._check_for_preferences():
120
+ results[key].append(f"PREFERENCES IGNORED:\n{ignored_preferences}")
121
+ self._check_diff_size(ref, results, key)
122
+
123
+ def _check_for_bad_patterns(self) -> list[str]:
124
+ """Check staged files for banned patterns (agent-in-loop containment).
125
+ Does not unstage anything. Later, if any problem lands in { "fail": ... } the commit is blocked.
126
+
127
+ Banned patterns are flagged only on ADDED diff lines (a '+' line, never a '+++' header).
128
+
129
+ Returns:
130
+ The banned-pattern hits plus any preference violations found in the staged files.
131
+ """
132
+ staged_lines = run_git([
133
+ "diff",
134
+ "--cached",
135
+ "--unified=0",
136
+ "--output-indicator-new=a",
137
+ ":(exclude,icase,glob)**/*.toml",
138
+ ":(exclude,icase,glob)**/*.md",
139
+ ])
140
+ problems: set[str] = set()
141
+ current_file = ""
142
+ for line in staged_lines.splitlines():
143
+ if line.startswith("+++ b/"):
144
+ current_file = line.removeprefix("+++ b/")
145
+ elif line.startswith("a"):
146
+ problems.update(
147
+ f"{current_file}: '{pattern}'"
148
+ for pattern in self.forbidden_patterns
149
+ if pattern.casefold() in line.casefold()
150
+ )
151
+ colorize("BANNED PATTERNS FOR AGENT", f"check for banned patterns in staged files\nIssues:\n{problems}")
152
+ return list(problems)
153
+
154
+ def _check_diff_size(self, ref: str, results: dict[str, list[str]], key: str):
155
+ """Report size of staged diff and block a bloated commit if past Lines Of Code (LOC) cap.
156
+
157
+ LOC = added + deleted. Count diff lines, staged and unstaged. An edit is
158
+ one deletion plus one addition. Lockfiles and binaries are excluded.
159
+
160
+ Arguments:
161
+ ref: Git SHA to run git diff on
162
+ results: The full-checks result bucketing each check name into "pass"/"fail"/"warn" lists.
163
+ key: Whether to "fail" this check because an agent is being checked (not a human)
164
+ """
165
+ stats = run_git(["diff", ref, "--numstat", "--cached", "--find-renames"]).splitlines()
166
+ total = 0
167
+ for line in stats:
168
+ inserted, deleted, path = line.split("\t", 2)
169
+ if not (inserted == "-" or path.lower().endswith(".lock")): # binary or lockfile
170
+ total += int(inserted) + int(deleted)
171
+ warn_at_75: int = round(self.settings["error_diff_lines"] * 0.75)
172
+ msg = (
173
+ f"{total} lines of code modified (insertions + deletions in staged files). Agents get WARN at "
174
+ f"75% {warn_at_75}, ERROR at {self.settings['error_diff_lines']}."
175
+ )
176
+ do_better = (
177
+ "\nRefactor bloat, reduce mis-direction, re-use fixtures, cut duplication, slim down "
178
+ "code. More code does not mean good code."
179
+ )
180
+ if total > self.settings["error_diff_lines"]:
181
+ results[key].append(msg + do_better)
182
+ elif total > warn_at_75:
183
+ results["warn"].append(msg + do_better)
184
+ colorize("DIFF SIZE", msg)
185
+
186
+ def _check_for_preferences(self) -> str:
187
+ """Checks user preferences honored. Currently only a preferences.py file exists. New languages should
188
+ add their own branch.
189
+
190
+ Returns:
191
+ The preferences violations and filepath found in staged files.
192
+ """
193
+ problems: list[str] = []
194
+ if "py" in self.settings["languages"]:
195
+ staged = run_git([
196
+ "diff",
197
+ "--cached",
198
+ "--name-only",
199
+ "--diff-filter=d",
200
+ "--",
201
+ "*.py",
202
+ ]).splitlines()
203
+ if prefs:
204
+ for path in staged:
205
+ messages: str = prefs(path, run_git(["show", f":{path}"]))
206
+ if messages:
207
+ problems.append(messages)
208
+ colorize("REPO PREFERENCES", f"checking repo preferences are respected by agents\nIssues:\n{problems}")
209
+ return "\n".join(problems)
210
+
211
+ def run_preflight(self) -> dict[str, list[str]]:
212
+ """Pre-commit: checks plus an informational format report. For agents in the loop also
213
+ unstages forbidden filepaths and flags banned patterns and any human-preferences not honored.
214
+
215
+ Returns:
216
+ The commit-checks result with any containment problems in a "fail" list.
217
+ """
218
+ return self.run_checks(self.commit_checks)
219
+
220
+ def run_gate(self) -> dict[str, list[str]]:
221
+ """Pre-push / CI: lint, types, pylint, security, pytest/hypothesis (blocking), complexipy, plus an
222
+ informational format report.
223
+
224
+ Returns:
225
+ results: The full-checks result bucketing each check name into "pass"/"fail"/"warn" lists.
226
+ """
227
+ return self.run_checks(self.gate_checks)
228
+
229
+ def prepare_commit_msg(self, argv: list[str]) -> int:
230
+ """Logic for the git prepare-commit-msg hook applicable to agents in the loop.
231
+
232
+ Args:
233
+ argv: arguments used to invoke `git commit`
234
+
235
+ Returns:
236
+ Status code integer 0 or 1 (git blocks commit on code 1)
237
+ """
238
+ if not os.environ.get("RALPH_LOOP"):
239
+ return 0
240
+ commit_msg_file: str = argv[1] if len(argv) > 1 else ""
241
+ command = argv[2] if len(argv) > 2 else ""
242
+ msg = ""
243
+ if command in {"merge", "squash", "rebase", "reset", "clean", "filter-branch"}:
244
+ msg = f"You cannot use that git command `{command}`.\n"
245
+ ref = "HEAD" if run_git(["rev-parse", "--verify", "HEAD"], check=False).strip() else self.EMPTY_TREE
246
+ if not run_git(["diff-index", "--cached", "--name-only", ref]):
247
+ msg += "Empty commit detected. Stage real work, Don't use --allow-empty. Say if you're blocked\n"
248
+ if Path(commit_msg_file).exists():
249
+ content = Path(commit_msg_file).read_text(encoding="utf-8")
250
+ actual_text = "\n".join([line for line in content.splitlines() if not line.startswith("#")]).strip()
251
+ if not actual_text:
252
+ msg += "Commit message is blank. Provide an informative message with your agent ID.\n"
253
+ if msg:
254
+ colorize("prepare-commit-message", msg)
255
+ return 1 # Intercepts git
256
+ return 0
257
+
258
+
259
+ def run_git(args: list[str], repo: Path | None = None, check: bool = True) -> str:
260
+ """Run a git command in the repo and return its stdout.
261
+
262
+ Arguments:
263
+ args: Git subcommand and its arguments
264
+ repo: the repository directory to run the git command from. Defaults to REPO_ROOT.
265
+ check: If check is True and the exit code was non-zero, it raises a CalledProcessError.
266
+
267
+ Returns:
268
+ The command's raw stdout string (callers will .splitlines() as needed)
269
+ """
270
+ target = gates().repo_root if repo is None else repo
271
+ command = ["git", "-C", str(target), *args]
272
+ git_env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")}
273
+ result = subprocess.run(command, capture_output=True, text=True, check=check, env=git_env)
274
+ return result.stdout
275
+
276
+
277
+ def colorize(name: str, command: str) -> None:
278
+ """Rich consosle printing to signpost checks.
279
+
280
+ Args:
281
+ name: Phase name shown in the rule header.
282
+ command: The command string printed beneath the header.
283
+ """
284
+ if os.environ.get("RALPH_LOOP"): # loop agents get plain text (no ANSI)
285
+ typer.echo(f"PHASE: {name.upper()}\n{command}")
286
+ else:
287
+ console.rule(f"[bold cyan] PHASE: {name.upper()}[/]", style="blink cyan on grey15")
288
+ console.print(f"[dim italic]{command}[/dim italic]\n", justify="center")
289
+
290
+
291
+ @cache
292
+ def gates() -> Gate:
293
+ """Returns the singleton object containing gate checks and configs."""
294
+ root = run_git(["rev-parse", "--show-toplevel"], repo=Path.cwd(), check=False).strip()
295
+ if not root:
296
+ typer.echo("Run this inside a git repository")
297
+ return Gate(Path(root).resolve())
@@ -0,0 +1,8 @@
1
+ # JS Quiz Scaffold
2
+
3
+ A minimal vanilla JS quiz that checks answers and colors buttons.
4
+
5
+ ## Rules
6
+ - Do not change the correct answer without updating specs/quiz.md
7
+ - Tests must pass after every change: `npm test`
8
+ - Do not add frameworks or large dependencies
@@ -0,0 +1,12 @@
1
+ # JS Scaffold Example
2
+
3
+ Shows how Ralph works with a frontend JS project.
4
+
5
+ ## Structure
6
+ - `PROMPT.md` — rules Ralph must follow
7
+ - `specs/` — behavior specs Ralph must not break
8
+ - `npm test` — fails if behavior is broken
9
+
10
+ ## Usage
11
+ npm install
12
+ npm test
@@ -0,0 +1,47 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Quiz</title>
8
+ <script src="./quiz.js" type="module" defer></script>
9
+ <style>
10
+ body {
11
+ display: flex;
12
+ flex-direction: column;
13
+ justify-content: center;
14
+ align-items: center;
15
+ margin: 0;
16
+ }
17
+ h1{
18
+ font-size: 3.4rem;
19
+ }
20
+ p{
21
+ font-size: 1.5rem;
22
+ text-align: center
23
+ }
24
+ button{
25
+ padding-inline: 1.5rem;
26
+ width: 100%;
27
+ font-size: 1rem;
28
+ padding-block: 0.5rem;
29
+ display: block;
30
+ margin-bottom: 1rem;
31
+ }
32
+ </style>
33
+ </head>
34
+ <body>
35
+ <main>
36
+ <header>
37
+ <h1> Quiz Test </h1>
38
+ </header>
39
+ <section>
40
+ <p>NaN === NaN</p>
41
+ <button id="btn-true" type="button">True</button>
42
+ <button id="btn-false" type="button">False</button>
43
+ </section>
44
+ </main>
45
+ </body>
46
+
47
+ </html>
@@ -0,0 +1,176 @@
1
+ {
2
+ "name": "js-scaffold",
3
+ "lockfileVersion": 3,
4
+ "requires": true,
5
+ "packages": {
6
+ "": {
7
+ "name": "js-scaffold",
8
+ "devDependencies": {
9
+ "@biomejs/biome": "latest"
10
+ }
11
+ },
12
+ "node_modules/@biomejs/biome": {
13
+ "version": "2.5.4",
14
+ "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.4.tgz",
15
+ "integrity": "sha512-xy5FNE5kQJKyK5MR1gJy6ztXYx4WBAbYGlK04lMEgmyPRWKybY9NFwiG9yo0XdzOU8Xvhj41u034J1ywfoWfMw==",
16
+ "dev": true,
17
+ "license": "MIT OR Apache-2.0",
18
+ "bin": {
19
+ "biome": "bin/biome"
20
+ },
21
+ "engines": {
22
+ "node": ">=14.21.3"
23
+ },
24
+ "funding": {
25
+ "type": "opencollective",
26
+ "url": "https://opencollective.com/biome"
27
+ },
28
+ "optionalDependencies": {
29
+ "@biomejs/cli-darwin-arm64": "2.5.4",
30
+ "@biomejs/cli-darwin-x64": "2.5.4",
31
+ "@biomejs/cli-linux-arm64": "2.5.4",
32
+ "@biomejs/cli-linux-arm64-musl": "2.5.4",
33
+ "@biomejs/cli-linux-x64": "2.5.4",
34
+ "@biomejs/cli-linux-x64-musl": "2.5.4",
35
+ "@biomejs/cli-win32-arm64": "2.5.4",
36
+ "@biomejs/cli-win32-x64": "2.5.4"
37
+ }
38
+ },
39
+ "node_modules/@biomejs/cli-darwin-arm64": {
40
+ "version": "2.5.4",
41
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.4.tgz",
42
+ "integrity": "sha512-4o3NFRobXHynkgcFVrlZsoDAFtF2ldlEGN8sORSws5ZQqyY4PXnPUIylu4ksfyHuwkfvDREuWh3JK+niRwGq3w==",
43
+ "cpu": [
44
+ "arm64"
45
+ ],
46
+ "dev": true,
47
+ "license": "MIT OR Apache-2.0",
48
+ "optional": true,
49
+ "os": [
50
+ "darwin"
51
+ ],
52
+ "engines": {
53
+ "node": ">=14.21.3"
54
+ }
55
+ },
56
+ "node_modules/@biomejs/cli-darwin-x64": {
57
+ "version": "2.5.4",
58
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.4.tgz",
59
+ "integrity": "sha512-D32P5HkU2Y6PySuC/WsVDTOgsDwVFmujzhhhOQjajtATpVWFDXuVd3oRbsWNSEA+aaFzyzZm22szsyydBYlSyQ==",
60
+ "cpu": [
61
+ "x64"
62
+ ],
63
+ "dev": true,
64
+ "license": "MIT OR Apache-2.0",
65
+ "optional": true,
66
+ "os": [
67
+ "darwin"
68
+ ],
69
+ "engines": {
70
+ "node": ">=14.21.3"
71
+ }
72
+ },
73
+ "node_modules/@biomejs/cli-linux-arm64": {
74
+ "version": "2.5.4",
75
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.4.tgz",
76
+ "integrity": "sha512-pSEfW7B8kTsXUjUxC1xVVK+y85Ht3C5XxZ9gclmC7/3Ku9Vqz8jmI7k0p/BNIjQ6t4sFERI2sFeH73ybiZl6YQ==",
77
+ "cpu": [
78
+ "arm64"
79
+ ],
80
+ "dev": true,
81
+ "license": "MIT OR Apache-2.0",
82
+ "optional": true,
83
+ "os": [
84
+ "linux"
85
+ ],
86
+ "engines": {
87
+ "node": ">=14.21.3"
88
+ }
89
+ },
90
+ "node_modules/@biomejs/cli-linux-arm64-musl": {
91
+ "version": "2.5.4",
92
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.4.tgz",
93
+ "integrity": "sha512-Rpm5/AT1m+DlJmUoYvS4/vXc+0tXJPJ2NQz25TGPyHVF5JrWy75PE0GH6kVxsKtQDuCH4OgzquZq0R4kj/wCVg==",
94
+ "cpu": [
95
+ "arm64"
96
+ ],
97
+ "dev": true,
98
+ "license": "MIT OR Apache-2.0",
99
+ "optional": true,
100
+ "os": [
101
+ "linux"
102
+ ],
103
+ "engines": {
104
+ "node": ">=14.21.3"
105
+ }
106
+ },
107
+ "node_modules/@biomejs/cli-linux-x64": {
108
+ "version": "2.5.4",
109
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.4.tgz",
110
+ "integrity": "sha512-FNxojWJkL7EajAuzBgoLe0T2G0y112M4lBrDIFl/DomFTx8yqenYOIdsRLNXvOvBBofE8hJi85LjzLmBDpY7/Q==",
111
+ "cpu": [
112
+ "x64"
113
+ ],
114
+ "dev": true,
115
+ "license": "MIT OR Apache-2.0",
116
+ "optional": true,
117
+ "os": [
118
+ "linux"
119
+ ],
120
+ "engines": {
121
+ "node": ">=14.21.3"
122
+ }
123
+ },
124
+ "node_modules/@biomejs/cli-linux-x64-musl": {
125
+ "version": "2.5.4",
126
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.4.tgz",
127
+ "integrity": "sha512-aby/PohmmgbShcHqFsZVzG8H6D98+P+A6xRWRrQcLW1pCjabcov5UUlke4UqNQBYTkDQav+jB4zyyDDeKB2GaA==",
128
+ "cpu": [
129
+ "x64"
130
+ ],
131
+ "dev": true,
132
+ "license": "MIT OR Apache-2.0",
133
+ "optional": true,
134
+ "os": [
135
+ "linux"
136
+ ],
137
+ "engines": {
138
+ "node": ">=14.21.3"
139
+ }
140
+ },
141
+ "node_modules/@biomejs/cli-win32-arm64": {
142
+ "version": "2.5.4",
143
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.4.tgz",
144
+ "integrity": "sha512-emoXexPZIPAZkz2RKmA95WJUqK3I5MJNYtwEbL5ESciRzhmFMMyekDhNG8hpeOaK+ZGRDxAU4wvGuA5IHQ0h0w==",
145
+ "cpu": [
146
+ "arm64"
147
+ ],
148
+ "dev": true,
149
+ "license": "MIT OR Apache-2.0",
150
+ "optional": true,
151
+ "os": [
152
+ "win32"
153
+ ],
154
+ "engines": {
155
+ "node": ">=14.21.3"
156
+ }
157
+ },
158
+ "node_modules/@biomejs/cli-win32-x64": {
159
+ "version": "2.5.4",
160
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.4.tgz",
161
+ "integrity": "sha512-U1jaluLw1qQc2Tx7/CeSoL9N5XcqIH+GWjpUAy1ouB5nVjSCMNO+NNHdY3RAs8zxNurLWAdj6pehQdCA2zyU+Q==",
162
+ "cpu": [
163
+ "x64"
164
+ ],
165
+ "dev": true,
166
+ "license": "MIT OR Apache-2.0",
167
+ "optional": true,
168
+ "os": [
169
+ "win32"
170
+ ],
171
+ "engines": {
172
+ "node": ">=14.21.3"
173
+ }
174
+ }
175
+ }
176
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "js-scaffold",
3
+ "type": "module",
4
+ "scripts": {
5
+ "test": "node --test",
6
+ "lint": "npx @biomejs/biome check .",
7
+ "format": "npx @biomejs/biome check --write .",
8
+ "security": "semgrep scan --error --config auto --config p/secrets --exclude-rule yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag ../..",
9
+ "coverage": "node --test --experimental-test-coverage",
10
+ "preflight": "npm run lint && npm run format",
11
+ "gate": "npm run coverage && npm run security"
12
+ },
13
+ "devDependencies": {
14
+ "@biomejs/biome": "latest"
15
+ }
16
+ }
@@ -0,0 +1,21 @@
1
+ export function checkAnswer(answer) {
2
+ const correctAnswer = false;
3
+ return answer === correctAnswer;
4
+ }
5
+ function testQuiz() {
6
+ const trueBtn = document.getElementById("btn-true");
7
+ const falseBtn = document.getElementById("btn-false");
8
+ trueBtn.addEventListener("click", () => {
9
+ const isCorrect = checkAnswer(true);
10
+ trueBtn.style.background = isCorrect ? "#d4edda" : "#f8d7da";
11
+ falseBtn.style.background = "";
12
+ });
13
+ falseBtn.addEventListener("click", () => {
14
+ const isCorrect = checkAnswer(false);
15
+ falseBtn.style.background = isCorrect ? "#d4edda" : "#f8d7da";
16
+ trueBtn.style.background = "";
17
+ });
18
+ }
19
+ if (typeof document !== "undefined") {
20
+ testQuiz();
21
+ }
@@ -0,0 +1,6 @@
1
+ # Quiz Specs
2
+
3
+ ## checkAnswer(answer)
4
+ - returns true when answer matches the correct answer
5
+ - returns false otherwise
6
+ - correct answer for "NaN === NaN" is false
@@ -0,0 +1,8 @@
1
+ import assert from "node:assert";
2
+ import test from "node:test";
3
+ import { checkAnswer } from "./quiz.js";
4
+
5
+ test(" Quiz - checkAnswer function", () => {
6
+ assert.strictEqual(checkAnswer(false), true);
7
+ assert.strictEqual(checkAnswer(true), false);
8
+ });
harness/ralph.ps1 ADDED
@@ -0,0 +1,84 @@
1
+ # Ralph (Windows twin of ralph.sh). Hand docs/PROMPT.md to a fresh-context agent and loop.
2
+ # Keep Ralph Dumb: start the worker, give it the prompt, print a line, repeat. Nothing else.
3
+ # Windows has no POSIX `timeout`, so this uses Process.WaitForExit + taskkill /T.
4
+ #
5
+ # Usage:
6
+ # powershell.exe -File ralph.ps1 <max_iterations> <max_minutes_per_iteration> <agent command...>
7
+
8
+ $ErrorActionPreference = "Stop"
9
+ [Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)
10
+ [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
11
+
12
+ # Mark loop commits so the gate (run by the git hooks) applies containment to the worker.
13
+ $env:RALPH_LOOP = "1"
14
+
15
+ function ConvertTo-WindowsArgument([string]$argument) {
16
+ $escaped = [regex]::Replace($argument, '(\\*)"', '$1$1\"')
17
+ return '"' + $escaped + [regex]::Match($argument, '(\\*)$').Groups[1].Value + '"'
18
+ }
19
+
20
+ function Write-RalphEvent([System.Collections.IDictionary]$payload) {
21
+ [Console]::Out.WriteLine(($payload | ConvertTo-Json -Compress))
22
+ [Console]::Out.Flush()
23
+ }
24
+
25
+ if ($args.Count -lt 3) {
26
+ [Console]::Error.WriteLine(
27
+ "Usage: ralph.ps1 <max_iterations> <max_minutes_per_iteration> <agent command...>"
28
+ )
29
+ exit 2
30
+ }
31
+
32
+ $maxIterations = [int]$args[0]
33
+ $maxMinutes = [double]$args[1]
34
+ $worker = @($args | Select-Object -Skip 2)
35
+ $timeoutMilliseconds = [int][Math]::Ceiling($maxMinutes * 60 * 1000)
36
+
37
+ $iteration = 1
38
+ while ($iteration -le $maxIterations) {
39
+ Write-RalphEvent ([ordered]@{
40
+ type = "ralph"
41
+ iteration = $iteration
42
+ max_iterations = $maxIterations
43
+ max_minutes = $maxMinutes
44
+ timestamp = (Get-Date).ToString("yyyy-MM-ddTHH:mm")
45
+ })
46
+
47
+ $startInfo = [System.Diagnostics.ProcessStartInfo]::new()
48
+ $startInfo.FileName = $worker[0]
49
+ if ($worker.Count -gt 1) {
50
+ $startInfo.Arguments = (($worker[1..($worker.Count - 1)] | ForEach-Object {
51
+ ConvertTo-WindowsArgument $_
52
+ }) -join ' ')
53
+ }
54
+ $startInfo.RedirectStandardInput = $true
55
+ $startInfo.UseShellExecute = $false
56
+
57
+ $process = [System.Diagnostics.Process]::Start($startInfo)
58
+ $prompt = "$($env:RALPH_PROMPT)`n`nRALPH_ITERATION=$iteration/$maxIterations`n"
59
+ $process.StandardInput.Write($prompt)
60
+ $process.StandardInput.Close()
61
+
62
+ if (-not $process.WaitForExit($timeoutMilliseconds)) {
63
+ taskkill.exe /F /T /PID $process.Id | Out-Null
64
+ $process.WaitForExit()
65
+ $process.Dispose()
66
+ exit 124
67
+ }
68
+
69
+ $process.WaitForExit()
70
+ $exitCode = $process.ExitCode
71
+ $process.Dispose()
72
+ if ($exitCode -ne 0) {
73
+ exit $exitCode
74
+ }
75
+
76
+ $iteration += 1
77
+ }
78
+
79
+ Write-RalphEvent ([ordered]@{
80
+ type = "ralph"
81
+ completed = $iteration - 1
82
+ max_minutes = $maxMinutes
83
+ timestamp = (Get-Date).ToString("yyyy-MM-ddTHH:mm")
84
+ })