@graypark/loophaus 3.7.0 → 3.8.0
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.
- package/README.ko.md +12 -2
- package/README.md +11 -1
- package/commands/cancel-ralph.md +3 -8
- package/commands/loop-plan.md +3 -7
- package/commands/loop-stop.md +5 -13
- package/commands/loop.md +6 -8
- package/commands/ralph-loop.md +2 -2
- package/dist/README.ko.md +12 -2
- package/dist/README.md +11 -1
- package/dist/bin/loophaus.js +7 -6
- package/dist/commands/cancel-ralph.md +3 -8
- package/dist/commands/loop-plan.md +3 -7
- package/dist/commands/loop-stop.md +5 -13
- package/dist/commands/loop.md +6 -8
- package/dist/commands/ralph-loop.md +2 -2
- package/dist/core/benchmark.js +25 -14
- package/dist/core/quality-scorer.js +13 -12
- package/dist/core/update-checker.js +4 -3
- package/dist/core/worktree.js +3 -3
- package/dist/hooks/stop-hook.mjs +4 -9
- package/dist/lib/paths.d.ts +1 -0
- package/dist/lib/paths.js +3 -0
- package/dist/lib/runtime.d.ts +21 -0
- package/dist/lib/runtime.js +77 -0
- package/dist/package.json +1 -1
- package/dist/platforms/claude-code/installer.mjs +2 -0
- package/dist/platforms/codex-cli/installer.mjs +1 -1
- package/dist/scripts/setup-loop.mjs +129 -0
- package/hooks/stop-hook.mjs +4 -9
- package/package.json +1 -1
- package/platforms/claude-code/installer.mjs +2 -0
- package/platforms/codex-cli/installer.mjs +1 -1
- package/scripts/setup-loop.mjs +129 -0
package/README.ko.md
CHANGED
|
@@ -153,11 +153,13 @@ loophaus는 세 개의 주요 코딩 에이전트 플랫폼을 지원합니다:
|
|
|
153
153
|
| 기능 | Claude Code | Codex CLI | Kiro CLI |
|
|
154
154
|
| --- | --- | --- | --- |
|
|
155
155
|
| 자동 감지 설치 | `~/.claude/` | `~/.codex/` | `~/.kiro/` |
|
|
156
|
-
| Stop hook |
|
|
156
|
+
| Stop hook | Node.js 기반 | Node.js 기반 | Node.js 기반 |
|
|
157
157
|
| 루프 실행 | Skill tool | 네이티브 커맨드 | 네이티브 커맨드 |
|
|
158
158
|
| 멀티 에이전트 | Agent tool | 서브에이전트 | 서브에이전트 |
|
|
159
159
|
| 상태 파일 | `.loophaus/state.json` | `.loophaus/state.json` | `.loophaus/state.json` |
|
|
160
160
|
|
|
161
|
+
Windows에서도 PowerShell/CMD 기준으로 `install`, `upgrade`, `/loop` 초기화가 동작합니다. Git Bash나 WSL은 선택사항입니다.
|
|
162
|
+
|
|
161
163
|
## 설치
|
|
162
164
|
|
|
163
165
|
### 글로벌 설치 (권장)
|
|
@@ -167,6 +169,8 @@ npm install -g @graypark/loophaus
|
|
|
167
169
|
loophaus install
|
|
168
170
|
```
|
|
169
171
|
|
|
172
|
+
Windows에서는 전역 npm 실행 파일 경로(보통 `%AppData%\npm`)가 `PATH`에 포함되어 있어야 합니다.
|
|
173
|
+
|
|
170
174
|
### npx로 설치
|
|
171
175
|
|
|
172
176
|
```bash
|
|
@@ -366,6 +370,12 @@ Phase 2 — 순차 수정 (루프):
|
|
|
366
370
|
|
|
367
371
|
## 업데이트
|
|
368
372
|
|
|
373
|
+
```bash
|
|
374
|
+
loophaus upgrade
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
수동 업데이트가 필요하면:
|
|
378
|
+
|
|
369
379
|
```bash
|
|
370
380
|
npm install -g @graypark/loophaus@latest
|
|
371
381
|
loophaus install --force
|
|
@@ -381,7 +391,7 @@ npm uninstall -g @graypark/loophaus
|
|
|
381
391
|
## 개발
|
|
382
392
|
|
|
383
393
|
```bash
|
|
384
|
-
npm install && npm test
|
|
394
|
+
npm install && npm test
|
|
385
395
|
npm run typecheck # TypeScript strict 모드
|
|
386
396
|
npm run build # dist/로 컴파일
|
|
387
397
|
npx vitest # watch 모드
|
package/README.md
CHANGED
|
@@ -119,6 +119,8 @@ That's it. The interview generates a PRD, activates the loop, and starts impleme
|
|
|
119
119
|
|
|
120
120
|
All three platforms share the same core engine (`core/engine.ts`) and state store (`store/state-store.ts`). Platform-specific adapters handle the differences.
|
|
121
121
|
|
|
122
|
+
Native Windows is supported for install, upgrade, and `/loop` initialization via PowerShell or CMD. Git Bash and WSL are optional, not required.
|
|
123
|
+
|
|
122
124
|
## Installation
|
|
123
125
|
|
|
124
126
|
### Global install (recommended)
|
|
@@ -128,6 +130,8 @@ npm install -g @graypark/loophaus
|
|
|
128
130
|
loophaus install
|
|
129
131
|
```
|
|
130
132
|
|
|
133
|
+
On Windows, ensure your global npm bin directory (typically `%AppData%\npm`) is on `PATH`.
|
|
134
|
+
|
|
131
135
|
### Via npx
|
|
132
136
|
|
|
133
137
|
```bash
|
|
@@ -283,6 +287,12 @@ Each story is sized to complete in one iteration (one context window). Dependenc
|
|
|
283
287
|
|
|
284
288
|
## Update
|
|
285
289
|
|
|
290
|
+
```bash
|
|
291
|
+
loophaus upgrade
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Manual upgrade also works:
|
|
295
|
+
|
|
286
296
|
```bash
|
|
287
297
|
npm install -g @graypark/loophaus@latest
|
|
288
298
|
loophaus install --force
|
|
@@ -301,7 +311,7 @@ npm uninstall -g @graypark/loophaus
|
|
|
301
311
|
git clone https://github.com/vcz-Gray/loophaus.git
|
|
302
312
|
cd loophaus
|
|
303
313
|
npm install
|
|
304
|
-
npm test
|
|
314
|
+
npm test
|
|
305
315
|
npm run typecheck # TypeScript strict mode
|
|
306
316
|
npm run build # Compile to dist/
|
|
307
317
|
npx vitest # watch mode
|
package/commands/cancel-ralph.md
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: "Cancel active Ralph Loop"
|
|
3
|
-
allowed-tools:
|
|
4
|
-
[
|
|
5
|
-
"Bash(test -f .claude/ralph-loop.local.md:*)",
|
|
6
|
-
"Bash(rm .claude/ralph-loop.local.md)",
|
|
7
|
-
"Read(.claude/ralph-loop.local.md)",
|
|
8
|
-
]
|
|
3
|
+
allowed-tools: ["Bash", "Read"]
|
|
9
4
|
hide-from-slash-command-tool: "true"
|
|
10
5
|
---
|
|
11
6
|
|
|
@@ -13,11 +8,11 @@ hide-from-slash-command-tool: "true"
|
|
|
13
8
|
|
|
14
9
|
To cancel the Ralph loop:
|
|
15
10
|
|
|
16
|
-
1. Check if `.claude/ralph-loop.local.md` exists using
|
|
11
|
+
1. Check if `.claude/ralph-loop.local.md` exists using a cross-platform Node command.
|
|
17
12
|
|
|
18
13
|
2. **If NOT_FOUND**: Say "No active Ralph loop found."
|
|
19
14
|
|
|
20
15
|
3. **If EXISTS**:
|
|
21
16
|
- Read `.claude/ralph-loop.local.md` to get the current iteration number from the `iteration:` field
|
|
22
|
-
- Remove the file using
|
|
17
|
+
- Remove the file using a cross-platform Node file command
|
|
23
18
|
- Report: "Cancelled Ralph loop (was at iteration N)" where N is the iteration value
|
package/commands/loop-plan.md
CHANGED
|
@@ -13,13 +13,9 @@ The user runs `/loop-plan` once and gets a single merged branch with all work do
|
|
|
13
13
|
|
|
14
14
|
## Pre-flight: Update Check
|
|
15
15
|
|
|
16
|
-
Run
|
|
16
|
+
Run `loophaus update-check` silently before starting.
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
_UPDATE=$(loophaus update-check 2>/dev/null | grep "UPGRADE_AVAILABLE" || true)
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
If `_UPDATE` contains `UPGRADE_AVAILABLE`:
|
|
18
|
+
If the command reports an available update:
|
|
23
19
|
1. Show: `loophaus update available: v{current} → v{latest}`
|
|
24
20
|
2. Ask user:
|
|
25
21
|
- **A) Upgrade now** → run `loophaus upgrade`, then continue
|
|
@@ -30,7 +26,7 @@ If `_UPDATE` contains `UPGRADE_AVAILABLE`:
|
|
|
30
26
|
If auto-upgrade is enabled (`loophaus config get autoUpgrade` returns `true`):
|
|
31
27
|
- Skip prompt, run `loophaus upgrade` silently, continue.
|
|
32
28
|
|
|
33
|
-
If no update or check fails: continue silently
|
|
29
|
+
If no update is available or the check fails: continue silently and never block the user.
|
|
34
30
|
|
|
35
31
|
## Pre-flight: Skill Routing Check
|
|
36
32
|
|
package/commands/loop-stop.md
CHANGED
|
@@ -1,29 +1,21 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: "Stop active loop"
|
|
3
|
-
allowed-tools:
|
|
4
|
-
[
|
|
5
|
-
"Bash(test -f .loophaus/state.json:*)",
|
|
6
|
-
"Bash(rm .loophaus/state.json)",
|
|
7
|
-
"Read(.loophaus/state.json)",
|
|
8
|
-
"Bash(test -f .claude/ralph-loop.local.md:*)",
|
|
9
|
-
"Bash(rm .claude/ralph-loop.local.md)",
|
|
10
|
-
"Read(.claude/ralph-loop.local.md)",
|
|
11
|
-
]
|
|
3
|
+
allowed-tools: ["Bash", "Read"]
|
|
12
4
|
---
|
|
13
5
|
|
|
14
6
|
# /loop-stop — Stop Active Loop
|
|
15
7
|
|
|
16
|
-
1. Check if `.loophaus/state.json` exists
|
|
17
|
-
- If not found, also check legacy path
|
|
8
|
+
1. Check if `.loophaus/state.json` exists using a cross-platform Node command.
|
|
9
|
+
- If not found, also check legacy path `.claude/ralph-loop.local.md`.
|
|
18
10
|
|
|
19
11
|
2. **If NOT_FOUND** on both: Say "No active loop found."
|
|
20
12
|
|
|
21
13
|
3. **If EXISTS** (.loophaus/state.json):
|
|
22
14
|
- Read the file to get `currentIteration`
|
|
23
|
-
- Remove it
|
|
15
|
+
- Remove it with a cross-platform Node file command
|
|
24
16
|
- Report: "Stopped loop at iteration N."
|
|
25
17
|
|
|
26
18
|
4. **If LEGACY** (.claude/ralph-loop.local.md):
|
|
27
19
|
- Read it to get the iteration field
|
|
28
|
-
- Remove it
|
|
20
|
+
- Remove it with a cross-platform Node file command
|
|
29
21
|
- Report: "Stopped loop at iteration N. (migrated from legacy path)"
|
package/commands/loop.md
CHANGED
|
@@ -1,26 +1,24 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: "Start iterative dev loop"
|
|
3
3
|
argument-hint: "PROMPT [--max-iterations N] [--completion-promise TEXT]"
|
|
4
|
-
allowed-tools: ["Bash
|
|
4
|
+
allowed-tools: ["Bash"]
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# /loop — Start Iterative Dev Loop
|
|
8
8
|
|
|
9
9
|
## Pre-flight
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
If
|
|
15
|
-
If update available and not auto: show one-line notice `loophaus v{new} available. Run: loophaus upgrade`.
|
|
16
|
-
Otherwise: continue silently.
|
|
11
|
+
Run `loophaus update-check` quietly before starting.
|
|
12
|
+
If it reports an available update and auto-upgrade is enabled, run `loophaus upgrade` and continue.
|
|
13
|
+
If it reports an available update and auto-upgrade is disabled, show one-line notice: `loophaus update available. Run: loophaus upgrade`.
|
|
14
|
+
If the check fails or no update is available, continue silently.
|
|
17
15
|
|
|
18
16
|
---
|
|
19
17
|
|
|
20
18
|
Execute the setup script to initialize the loop:
|
|
21
19
|
|
|
22
20
|
```!
|
|
23
|
-
"${CLAUDE_PLUGIN_ROOT}/scripts/setup-
|
|
21
|
+
node "${CLAUDE_PLUGIN_ROOT}/scripts/setup-loop.mjs" $ARGUMENTS
|
|
24
22
|
```
|
|
25
23
|
|
|
26
24
|
Work on the task. When you try to exit, the stop hook feeds the SAME PROMPT back for the next iteration. Your previous work persists in files and git history.
|
package/commands/ralph-loop.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: "Start Ralph Loop in current session"
|
|
3
3
|
argument-hint: "PROMPT [--max-iterations N] [--completion-promise TEXT]"
|
|
4
|
-
allowed-tools: ["Bash
|
|
4
|
+
allowed-tools: ["Bash"]
|
|
5
5
|
hide-from-slash-command-tool: "true"
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -10,7 +10,7 @@ hide-from-slash-command-tool: "true"
|
|
|
10
10
|
Execute the setup script to initialize the Ralph loop:
|
|
11
11
|
|
|
12
12
|
```!
|
|
13
|
-
"${CLAUDE_PLUGIN_ROOT}/scripts/setup-
|
|
13
|
+
node "${CLAUDE_PLUGIN_ROOT}/scripts/setup-loop.mjs" $ARGUMENTS
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
Please work on the task. When you try to exit, the Ralph loop will feed the SAME PROMPT back to you for the next iteration. You'll see your previous work in files and git history, allowing you to iterate and improve.
|
package/dist/README.ko.md
CHANGED
|
@@ -153,11 +153,13 @@ loophaus는 세 개의 주요 코딩 에이전트 플랫폼을 지원합니다:
|
|
|
153
153
|
| 기능 | Claude Code | Codex CLI | Kiro CLI |
|
|
154
154
|
| --- | --- | --- | --- |
|
|
155
155
|
| 자동 감지 설치 | `~/.claude/` | `~/.codex/` | `~/.kiro/` |
|
|
156
|
-
| Stop hook |
|
|
156
|
+
| Stop hook | Node.js 기반 | Node.js 기반 | Node.js 기반 |
|
|
157
157
|
| 루프 실행 | Skill tool | 네이티브 커맨드 | 네이티브 커맨드 |
|
|
158
158
|
| 멀티 에이전트 | Agent tool | 서브에이전트 | 서브에이전트 |
|
|
159
159
|
| 상태 파일 | `.loophaus/state.json` | `.loophaus/state.json` | `.loophaus/state.json` |
|
|
160
160
|
|
|
161
|
+
Windows에서도 PowerShell/CMD 기준으로 `install`, `upgrade`, `/loop` 초기화가 동작합니다. Git Bash나 WSL은 선택사항입니다.
|
|
162
|
+
|
|
161
163
|
## 설치
|
|
162
164
|
|
|
163
165
|
### 글로벌 설치 (권장)
|
|
@@ -167,6 +169,8 @@ npm install -g @graypark/loophaus
|
|
|
167
169
|
loophaus install
|
|
168
170
|
```
|
|
169
171
|
|
|
172
|
+
Windows에서는 전역 npm 실행 파일 경로(보통 `%AppData%\npm`)가 `PATH`에 포함되어 있어야 합니다.
|
|
173
|
+
|
|
170
174
|
### npx로 설치
|
|
171
175
|
|
|
172
176
|
```bash
|
|
@@ -366,6 +370,12 @@ Phase 2 — 순차 수정 (루프):
|
|
|
366
370
|
|
|
367
371
|
## 업데이트
|
|
368
372
|
|
|
373
|
+
```bash
|
|
374
|
+
loophaus upgrade
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
수동 업데이트가 필요하면:
|
|
378
|
+
|
|
369
379
|
```bash
|
|
370
380
|
npm install -g @graypark/loophaus@latest
|
|
371
381
|
loophaus install --force
|
|
@@ -381,7 +391,7 @@ npm uninstall -g @graypark/loophaus
|
|
|
381
391
|
## 개발
|
|
382
392
|
|
|
383
393
|
```bash
|
|
384
|
-
npm install && npm test
|
|
394
|
+
npm install && npm test
|
|
385
395
|
npm run typecheck # TypeScript strict 모드
|
|
386
396
|
npm run build # dist/로 컴파일
|
|
387
397
|
npx vitest # watch 모드
|
package/dist/README.md
CHANGED
|
@@ -119,6 +119,8 @@ That's it. The interview generates a PRD, activates the loop, and starts impleme
|
|
|
119
119
|
|
|
120
120
|
All three platforms share the same core engine (`core/engine.ts`) and state store (`store/state-store.ts`). Platform-specific adapters handle the differences.
|
|
121
121
|
|
|
122
|
+
Native Windows is supported for install, upgrade, and `/loop` initialization via PowerShell or CMD. Git Bash and WSL are optional, not required.
|
|
123
|
+
|
|
122
124
|
## Installation
|
|
123
125
|
|
|
124
126
|
### Global install (recommended)
|
|
@@ -128,6 +130,8 @@ npm install -g @graypark/loophaus
|
|
|
128
130
|
loophaus install
|
|
129
131
|
```
|
|
130
132
|
|
|
133
|
+
On Windows, ensure your global npm bin directory (typically `%AppData%\npm`) is on `PATH`.
|
|
134
|
+
|
|
131
135
|
### Via npx
|
|
132
136
|
|
|
133
137
|
```bash
|
|
@@ -283,6 +287,12 @@ Each story is sized to complete in one iteration (one context window). Dependenc
|
|
|
283
287
|
|
|
284
288
|
## Update
|
|
285
289
|
|
|
290
|
+
```bash
|
|
291
|
+
loophaus upgrade
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Manual upgrade also works:
|
|
295
|
+
|
|
286
296
|
```bash
|
|
287
297
|
npm install -g @graypark/loophaus@latest
|
|
288
298
|
loophaus install --force
|
|
@@ -301,7 +311,7 @@ npm uninstall -g @graypark/loophaus
|
|
|
301
311
|
git clone https://github.com/vcz-Gray/loophaus.git
|
|
302
312
|
cd loophaus
|
|
303
313
|
npm install
|
|
304
|
-
npm test
|
|
314
|
+
npm test
|
|
305
315
|
npm run typecheck # TypeScript strict mode
|
|
306
316
|
npm run build # Compile to dist/
|
|
307
317
|
npx vitest # watch mode
|
package/dist/bin/loophaus.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import { resolve, dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { access } from "node:fs/promises";
|
|
6
|
+
import { getLoophausHome } from "../lib/paths.js";
|
|
7
|
+
import { getGlobalBinaryPath, runCommand } from "../lib/runtime.js";
|
|
6
8
|
const __filename = fileURLToPath(import.meta.url);
|
|
7
9
|
const __dirname = dirname(__filename);
|
|
8
10
|
const PROJECT_ROOT = resolve(__dirname, "..");
|
|
@@ -163,7 +165,7 @@ async function runInstall() {
|
|
|
163
165
|
const { getPackageVersion } = await import("../lib/paths.js");
|
|
164
166
|
const version = getPackageVersion();
|
|
165
167
|
const quiet = args.includes("--quiet");
|
|
166
|
-
const loophausDir =
|
|
168
|
+
const loophausDir = getLoophausHome();
|
|
167
169
|
const welcomePath = join(loophausDir, ".welcome-seen");
|
|
168
170
|
let targets = [];
|
|
169
171
|
if (host) {
|
|
@@ -763,9 +765,6 @@ async function runUpdateCheck() {
|
|
|
763
765
|
async function runUpgrade() {
|
|
764
766
|
const { getPackageVersion } = await import("../lib/paths.js");
|
|
765
767
|
const { checkForUpdate } = await import("../core/update-checker.js");
|
|
766
|
-
const { execFile: ef } = await import("node:child_process");
|
|
767
|
-
const { promisify } = await import("node:util");
|
|
768
|
-
const execFileAsync = promisify(ef);
|
|
769
768
|
const current = getPackageVersion();
|
|
770
769
|
const result = await checkForUpdate(current);
|
|
771
770
|
if (result.status === "up_to_date") {
|
|
@@ -779,12 +778,14 @@ async function runUpgrade() {
|
|
|
779
778
|
console.log(`Upgrading loophaus: v${result.current} → v${result.latest}`);
|
|
780
779
|
const s = spinner("Installing...");
|
|
781
780
|
try {
|
|
782
|
-
await
|
|
781
|
+
await runCommand("npm", ["install", "-g", `@graypark/loophaus@${result.latest}`], { timeout: 120_000 });
|
|
783
782
|
s.stop();
|
|
784
783
|
console.log(`\u2714 Installed v${result.latest}`);
|
|
785
784
|
const s2 = spinner("Reinstalling plugins...");
|
|
786
785
|
try {
|
|
787
|
-
await
|
|
786
|
+
const { stdout: prefixStdout } = await runCommand("npm", ["prefix", "-g"], { timeout: 30_000 });
|
|
787
|
+
const globalLoophaus = getGlobalBinaryPath(prefixStdout.trim(), "loophaus");
|
|
788
|
+
await runCommand(globalLoophaus, ["install", "--force"], { timeout: 60_000 });
|
|
788
789
|
s2.stop();
|
|
789
790
|
console.log("\u2714 Plugins reinstalled");
|
|
790
791
|
}
|
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: "Cancel active Ralph Loop"
|
|
3
|
-
allowed-tools:
|
|
4
|
-
[
|
|
5
|
-
"Bash(test -f .claude/ralph-loop.local.md:*)",
|
|
6
|
-
"Bash(rm .claude/ralph-loop.local.md)",
|
|
7
|
-
"Read(.claude/ralph-loop.local.md)",
|
|
8
|
-
]
|
|
3
|
+
allowed-tools: ["Bash", "Read"]
|
|
9
4
|
hide-from-slash-command-tool: "true"
|
|
10
5
|
---
|
|
11
6
|
|
|
@@ -13,11 +8,11 @@ hide-from-slash-command-tool: "true"
|
|
|
13
8
|
|
|
14
9
|
To cancel the Ralph loop:
|
|
15
10
|
|
|
16
|
-
1. Check if `.claude/ralph-loop.local.md` exists using
|
|
11
|
+
1. Check if `.claude/ralph-loop.local.md` exists using a cross-platform Node command.
|
|
17
12
|
|
|
18
13
|
2. **If NOT_FOUND**: Say "No active Ralph loop found."
|
|
19
14
|
|
|
20
15
|
3. **If EXISTS**:
|
|
21
16
|
- Read `.claude/ralph-loop.local.md` to get the current iteration number from the `iteration:` field
|
|
22
|
-
- Remove the file using
|
|
17
|
+
- Remove the file using a cross-platform Node file command
|
|
23
18
|
- Report: "Cancelled Ralph loop (was at iteration N)" where N is the iteration value
|
|
@@ -13,13 +13,9 @@ The user runs `/loop-plan` once and gets a single merged branch with all work do
|
|
|
13
13
|
|
|
14
14
|
## Pre-flight: Update Check
|
|
15
15
|
|
|
16
|
-
Run
|
|
16
|
+
Run `loophaus update-check` silently before starting.
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
_UPDATE=$(loophaus update-check 2>/dev/null | grep "UPGRADE_AVAILABLE" || true)
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
If `_UPDATE` contains `UPGRADE_AVAILABLE`:
|
|
18
|
+
If the command reports an available update:
|
|
23
19
|
1. Show: `loophaus update available: v{current} → v{latest}`
|
|
24
20
|
2. Ask user:
|
|
25
21
|
- **A) Upgrade now** → run `loophaus upgrade`, then continue
|
|
@@ -30,7 +26,7 @@ If `_UPDATE` contains `UPGRADE_AVAILABLE`:
|
|
|
30
26
|
If auto-upgrade is enabled (`loophaus config get autoUpgrade` returns `true`):
|
|
31
27
|
- Skip prompt, run `loophaus upgrade` silently, continue.
|
|
32
28
|
|
|
33
|
-
If no update or check fails: continue silently
|
|
29
|
+
If no update is available or the check fails: continue silently and never block the user.
|
|
34
30
|
|
|
35
31
|
## Pre-flight: Skill Routing Check
|
|
36
32
|
|
|
@@ -1,29 +1,21 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: "Stop active loop"
|
|
3
|
-
allowed-tools:
|
|
4
|
-
[
|
|
5
|
-
"Bash(test -f .loophaus/state.json:*)",
|
|
6
|
-
"Bash(rm .loophaus/state.json)",
|
|
7
|
-
"Read(.loophaus/state.json)",
|
|
8
|
-
"Bash(test -f .claude/ralph-loop.local.md:*)",
|
|
9
|
-
"Bash(rm .claude/ralph-loop.local.md)",
|
|
10
|
-
"Read(.claude/ralph-loop.local.md)",
|
|
11
|
-
]
|
|
3
|
+
allowed-tools: ["Bash", "Read"]
|
|
12
4
|
---
|
|
13
5
|
|
|
14
6
|
# /loop-stop — Stop Active Loop
|
|
15
7
|
|
|
16
|
-
1. Check if `.loophaus/state.json` exists
|
|
17
|
-
- If not found, also check legacy path
|
|
8
|
+
1. Check if `.loophaus/state.json` exists using a cross-platform Node command.
|
|
9
|
+
- If not found, also check legacy path `.claude/ralph-loop.local.md`.
|
|
18
10
|
|
|
19
11
|
2. **If NOT_FOUND** on both: Say "No active loop found."
|
|
20
12
|
|
|
21
13
|
3. **If EXISTS** (.loophaus/state.json):
|
|
22
14
|
- Read the file to get `currentIteration`
|
|
23
|
-
- Remove it
|
|
15
|
+
- Remove it with a cross-platform Node file command
|
|
24
16
|
- Report: "Stopped loop at iteration N."
|
|
25
17
|
|
|
26
18
|
4. **If LEGACY** (.claude/ralph-loop.local.md):
|
|
27
19
|
- Read it to get the iteration field
|
|
28
|
-
- Remove it
|
|
20
|
+
- Remove it with a cross-platform Node file command
|
|
29
21
|
- Report: "Stopped loop at iteration N. (migrated from legacy path)"
|
package/dist/commands/loop.md
CHANGED
|
@@ -1,26 +1,24 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: "Start iterative dev loop"
|
|
3
3
|
argument-hint: "PROMPT [--max-iterations N] [--completion-promise TEXT]"
|
|
4
|
-
allowed-tools: ["Bash
|
|
4
|
+
allowed-tools: ["Bash"]
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# /loop — Start Iterative Dev Loop
|
|
8
8
|
|
|
9
9
|
## Pre-flight
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
If
|
|
15
|
-
If update available and not auto: show one-line notice `loophaus v{new} available. Run: loophaus upgrade`.
|
|
16
|
-
Otherwise: continue silently.
|
|
11
|
+
Run `loophaus update-check` quietly before starting.
|
|
12
|
+
If it reports an available update and auto-upgrade is enabled, run `loophaus upgrade` and continue.
|
|
13
|
+
If it reports an available update and auto-upgrade is disabled, show one-line notice: `loophaus update available. Run: loophaus upgrade`.
|
|
14
|
+
If the check fails or no update is available, continue silently.
|
|
17
15
|
|
|
18
16
|
---
|
|
19
17
|
|
|
20
18
|
Execute the setup script to initialize the loop:
|
|
21
19
|
|
|
22
20
|
```!
|
|
23
|
-
"${CLAUDE_PLUGIN_ROOT}/scripts/setup-
|
|
21
|
+
node "${CLAUDE_PLUGIN_ROOT}/scripts/setup-loop.mjs" $ARGUMENTS
|
|
24
22
|
```
|
|
25
23
|
|
|
26
24
|
Work on the task. When you try to exit, the stop hook feeds the SAME PROMPT back for the next iteration. Your previous work persists in files and git history.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: "Start Ralph Loop in current session"
|
|
3
3
|
argument-hint: "PROMPT [--max-iterations N] [--completion-promise TEXT]"
|
|
4
|
-
allowed-tools: ["Bash
|
|
4
|
+
allowed-tools: ["Bash"]
|
|
5
5
|
hide-from-slash-command-tool: "true"
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -10,7 +10,7 @@ hide-from-slash-command-tool: "true"
|
|
|
10
10
|
Execute the setup script to initialize the Ralph loop:
|
|
11
11
|
|
|
12
12
|
```!
|
|
13
|
-
"${CLAUDE_PLUGIN_ROOT}/scripts/setup-
|
|
13
|
+
node "${CLAUDE_PLUGIN_ROOT}/scripts/setup-loop.mjs" $ARGUMENTS
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
Please work on the task. When you try to exit, the Ralph loop will feed the SAME PROMPT back to you for the next iteration. You'll see your previous work in files and git history, allowing you to iterate and improve.
|
package/dist/core/benchmark.js
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
// core/benchmark.ts
|
|
2
2
|
// Project-level quality measurement (autoresearch pattern: val_bpb → project score)
|
|
3
|
-
import {
|
|
4
|
-
import { promisify } from "node:util";
|
|
5
|
-
import { readFile, appendFile, mkdir, stat } from "node:fs/promises";
|
|
3
|
+
import { readFile, appendFile, mkdir, readdir, stat } from "node:fs/promises";
|
|
6
4
|
import { join, dirname } from "node:path";
|
|
7
|
-
|
|
5
|
+
import { runCommand } from "../lib/runtime.js";
|
|
6
|
+
async function getDirectorySizeBytes(dir) {
|
|
7
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
8
|
+
let total = 0;
|
|
9
|
+
for (const entry of entries) {
|
|
10
|
+
const fullPath = join(dir, entry.name);
|
|
11
|
+
if (entry.isDirectory()) {
|
|
12
|
+
total += await getDirectorySizeBytes(fullPath);
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (entry.isFile()) {
|
|
16
|
+
total += (await stat(fullPath)).size;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return total;
|
|
20
|
+
}
|
|
8
21
|
export function scoreBenchmark(metrics) {
|
|
9
22
|
const breakdown = {};
|
|
10
23
|
// Tests: 0-10 based on pass rate
|
|
@@ -51,7 +64,7 @@ export async function runBenchmark(cwd) {
|
|
|
51
64
|
// 1. Tests
|
|
52
65
|
const testStart = Date.now();
|
|
53
66
|
try {
|
|
54
|
-
const { stdout } = await
|
|
67
|
+
const { stdout } = await runCommand("npx", ["vitest", "run", "--reporter=json"], { cwd: dir, timeout: 120_000 });
|
|
55
68
|
metrics.testTimeMs = Date.now() - testStart;
|
|
56
69
|
try {
|
|
57
70
|
const json = JSON.parse(stdout);
|
|
@@ -72,7 +85,7 @@ export async function runBenchmark(cwd) {
|
|
|
72
85
|
}
|
|
73
86
|
catch (err) {
|
|
74
87
|
metrics.testTimeMs = Date.now() - testStart;
|
|
75
|
-
const output = err.stdout || "";
|
|
88
|
+
const output = err.stdout || err.stderr || "";
|
|
76
89
|
const passMatch = output.match(/(\d+) passed/);
|
|
77
90
|
if (passMatch)
|
|
78
91
|
metrics.testsPassed = parseInt(passMatch[1]);
|
|
@@ -83,7 +96,7 @@ export async function runBenchmark(cwd) {
|
|
|
83
96
|
}
|
|
84
97
|
// 2. Typecheck
|
|
85
98
|
try {
|
|
86
|
-
await
|
|
99
|
+
await runCommand("npx", ["tsc", "--noEmit"], { cwd: dir, timeout: 60_000 });
|
|
87
100
|
metrics.typecheckErrors = 0;
|
|
88
101
|
}
|
|
89
102
|
catch (err) {
|
|
@@ -93,7 +106,7 @@ export async function runBenchmark(cwd) {
|
|
|
93
106
|
}
|
|
94
107
|
// 3. Build
|
|
95
108
|
try {
|
|
96
|
-
await
|
|
109
|
+
await runCommand("npm", ["run", "build"], { cwd: dir, timeout: 60_000 });
|
|
97
110
|
metrics.buildSuccess = true;
|
|
98
111
|
}
|
|
99
112
|
catch {
|
|
@@ -109,7 +122,7 @@ export async function runBenchmark(cwd) {
|
|
|
109
122
|
catch {
|
|
110
123
|
// Run coverage if summary doesn't exist
|
|
111
124
|
try {
|
|
112
|
-
await
|
|
125
|
+
await runCommand("npx", ["vitest", "run", "--coverage"], { cwd: dir, timeout: 120_000 });
|
|
113
126
|
const summaryPath = join(dir, "coverage", "coverage-summary.json");
|
|
114
127
|
const raw = await readFile(summaryPath, "utf-8");
|
|
115
128
|
const summary = JSON.parse(raw);
|
|
@@ -124,9 +137,7 @@ export async function runBenchmark(cwd) {
|
|
|
124
137
|
const distDir = join(dir, "dist");
|
|
125
138
|
const s = await stat(distDir);
|
|
126
139
|
if (s.isDirectory()) {
|
|
127
|
-
|
|
128
|
-
const match = stdout.match(/^(\d+)/);
|
|
129
|
-
metrics.pkgSizeKb = match ? parseInt(match[1]) : 0;
|
|
140
|
+
metrics.pkgSizeKb = Math.ceil((await getDirectorySizeBytes(distDir)) / 1024);
|
|
130
141
|
}
|
|
131
142
|
}
|
|
132
143
|
catch {
|
|
@@ -143,7 +154,7 @@ export async function logBenchmark(result, cwd) {
|
|
|
143
154
|
await mkdir(dirname(benchPath), { recursive: true });
|
|
144
155
|
let commitHash = "unknown";
|
|
145
156
|
try {
|
|
146
|
-
const { stdout } = await
|
|
157
|
+
const { stdout } = await runCommand("git", ["rev-parse", "--short", "HEAD"], { timeout: 5_000 });
|
|
147
158
|
commitHash = stdout.trim();
|
|
148
159
|
}
|
|
149
160
|
catch { /* not in git */ }
|
|
@@ -182,7 +193,7 @@ export async function readBenchmarkHistory(cwd) {
|
|
|
182
193
|
const benchPath = getBenchmarkPath(cwd);
|
|
183
194
|
try {
|
|
184
195
|
const raw = await readFile(benchPath, "utf-8");
|
|
185
|
-
const lines = raw.trim().split(
|
|
196
|
+
const lines = raw.trim().split(/\r?\n/).slice(1); // skip header
|
|
186
197
|
return lines.map(line => {
|
|
187
198
|
const cols = line.split("\t");
|
|
188
199
|
return {
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
// core/quality-scorer.ts
|
|
2
2
|
// Quality scoring for story implementations (autoresearch pattern: val_bpb -> quality score)
|
|
3
|
-
import { execFile } from "node:child_process";
|
|
4
|
-
import { promisify } from "node:util";
|
|
5
3
|
import { readFile, stat } from "node:fs/promises";
|
|
6
4
|
import { join } from "node:path";
|
|
7
|
-
|
|
5
|
+
import { runCommand, runShellCommand } from "../lib/runtime.js";
|
|
8
6
|
const CRITERIA = {
|
|
9
7
|
tests: { weight: 3, max: 10 },
|
|
10
8
|
typecheck: { weight: 2, max: 10 },
|
|
@@ -33,9 +31,10 @@ export function scoreStory(results) {
|
|
|
33
31
|
}
|
|
34
32
|
export async function evaluateStory(storyId, cwd, config = {}) {
|
|
35
33
|
const results = {};
|
|
34
|
+
const splitLines = (value) => value.split(/\r?\n/);
|
|
36
35
|
if (config.testCommand) {
|
|
37
36
|
try {
|
|
38
|
-
await
|
|
37
|
+
await runShellCommand(config.testCommand, { cwd, timeout: 120_000 });
|
|
39
38
|
results.tests = 10;
|
|
40
39
|
}
|
|
41
40
|
catch {
|
|
@@ -44,27 +43,29 @@ export async function evaluateStory(storyId, cwd, config = {}) {
|
|
|
44
43
|
}
|
|
45
44
|
if (config.typecheckCommand) {
|
|
46
45
|
try {
|
|
47
|
-
await
|
|
46
|
+
await runShellCommand(config.typecheckCommand, { cwd, timeout: 60_000 });
|
|
48
47
|
results.typecheck = 10;
|
|
49
48
|
}
|
|
50
49
|
catch (err) {
|
|
51
|
-
const
|
|
50
|
+
const output = err.stdout || err.stderr || "";
|
|
51
|
+
const errorCount = splitLines(output).filter(line => line.includes("error")).length;
|
|
52
52
|
results.typecheck = Math.max(0, 10 - errorCount);
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
if (config.lintCommand) {
|
|
56
56
|
try {
|
|
57
|
-
await
|
|
57
|
+
await runShellCommand(config.lintCommand, { cwd, timeout: 60_000 });
|
|
58
58
|
results.lint = 10;
|
|
59
59
|
}
|
|
60
60
|
catch (err) {
|
|
61
|
-
const
|
|
61
|
+
const output = err.stdout || err.stderr || "";
|
|
62
|
+
const warnings = splitLines(output).filter(line => line.includes("warning") || line.includes("error")).length;
|
|
62
63
|
results.lint = Math.max(0, 10 - warnings);
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
if (config.verifyScript) {
|
|
66
67
|
try {
|
|
67
|
-
await
|
|
68
|
+
await runShellCommand(config.verifyScript, { cwd, timeout: 60_000 });
|
|
68
69
|
results.verify = 10;
|
|
69
70
|
}
|
|
70
71
|
catch {
|
|
@@ -72,8 +73,8 @@ export async function evaluateStory(storyId, cwd, config = {}) {
|
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
75
|
try {
|
|
75
|
-
const { stdout } = await
|
|
76
|
-
const lines = stdout.trim().split(
|
|
76
|
+
const { stdout } = await runCommand("git", ["diff", "--stat", "HEAD~1"], { cwd, timeout: 10_000 });
|
|
77
|
+
const lines = stdout.trim().split(/\r?\n/);
|
|
77
78
|
const lastLine = lines[lines.length - 1] || "";
|
|
78
79
|
const match = lastLine.match(/(\d+) insertion.+?(\d+) deletion/);
|
|
79
80
|
if (match) {
|
|
@@ -115,7 +116,7 @@ export async function readResults(cwd) {
|
|
|
115
116
|
const tsvPath = join(cwd || process.cwd(), ".loophaus", "results.tsv");
|
|
116
117
|
try {
|
|
117
118
|
const raw = await readFile(tsvPath, "utf-8");
|
|
118
|
-
const lines = raw.trim().split(
|
|
119
|
+
const lines = raw.trim().split(/\r?\n/).slice(1);
|
|
119
120
|
return lines.map(line => {
|
|
120
121
|
const [storyId, attempt, score, status, description, commit] = line.split("\t");
|
|
121
122
|
return { storyId, attempt: parseInt(attempt), score: parseInt(score), status, description, commit };
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// core/update-checker.ts
|
|
2
2
|
// npm registry version check with cache + snooze (gstack-style)
|
|
3
3
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
4
|
-
import { join } from "node:path";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
5
|
import { get } from "node:https";
|
|
6
|
+
import { getLoophausHome } from "../lib/paths.js";
|
|
6
7
|
const REGISTRY_URL = "https://registry.npmjs.org/@graypark/loophaus/latest";
|
|
7
8
|
const FETCH_TIMEOUT_MS = 5_000;
|
|
8
9
|
// Cache TTL in minutes
|
|
@@ -11,7 +12,7 @@ const TTL_UPGRADE_AVAILABLE = 720;
|
|
|
11
12
|
// Snooze durations in hours
|
|
12
13
|
const SNOOZE_HOURS = [24, 48, 168]; // level 1, 2, 3+
|
|
13
14
|
function getLoophausDir(cwd) {
|
|
14
|
-
return
|
|
15
|
+
return getLoophausHome(cwd);
|
|
15
16
|
}
|
|
16
17
|
function getCachePath(cwd) {
|
|
17
18
|
return join(getLoophausDir(cwd), "update-cache.json");
|
|
@@ -60,7 +61,7 @@ async function readJson(path) {
|
|
|
60
61
|
}
|
|
61
62
|
}
|
|
62
63
|
async function writeJson(path, data) {
|
|
63
|
-
await mkdir(
|
|
64
|
+
await mkdir(dirname(path), { recursive: true });
|
|
64
65
|
await writeFile(path, JSON.stringify(data, null, 2), "utf-8");
|
|
65
66
|
}
|
|
66
67
|
async function fetchLatestVersion() {
|
package/dist/core/worktree.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { mkdir, access } from "node:fs/promises";
|
|
6
|
-
import { join } from "node:path";
|
|
6
|
+
import { basename, join } from "node:path";
|
|
7
7
|
const execFileAsync = promisify(execFile);
|
|
8
8
|
async function fileExists(p) {
|
|
9
9
|
try {
|
|
@@ -72,7 +72,7 @@ export async function listWorktrees() {
|
|
|
72
72
|
const { stdout } = await execFileAsync("git", ["worktree", "list", "--porcelain"]);
|
|
73
73
|
const entries = [];
|
|
74
74
|
let current = {};
|
|
75
|
-
for (const line of stdout.split(
|
|
75
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
76
76
|
if (line.startsWith("worktree ")) {
|
|
77
77
|
if (current.path)
|
|
78
78
|
entries.push(current);
|
|
@@ -95,7 +95,7 @@ export async function listWorktrees() {
|
|
|
95
95
|
}
|
|
96
96
|
const loophausDir = join(root, ".loophaus", "worktrees");
|
|
97
97
|
return entries.filter(e => e.path && e.path.startsWith(loophausDir)).map(e => ({
|
|
98
|
-
name: e.path
|
|
98
|
+
name: basename(e.path),
|
|
99
99
|
path: e.path,
|
|
100
100
|
branch: e.branch || "",
|
|
101
101
|
head: e.head || "",
|
package/dist/hooks/stop-hook.mjs
CHANGED
|
@@ -5,12 +5,10 @@ import { getLastAssistantText, hasPendingStories } from "../core/io-helpers.js";
|
|
|
5
5
|
import { read as readState, write as writeState } from "../store/state-store.js";
|
|
6
6
|
import { logEvents } from "../core/event-logger.js";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
+
import { runShellCommand } from "../lib/runtime.js";
|
|
8
9
|
|
|
9
10
|
async function runStoryTests(cwd) {
|
|
10
11
|
const { readFile } = await import("node:fs/promises");
|
|
11
|
-
const { execFile } = await import("node:child_process");
|
|
12
|
-
const { promisify } = await import("node:util");
|
|
13
|
-
const execFileAsync = promisify(execFile);
|
|
14
12
|
const prdPath = join(cwd, "prd.json");
|
|
15
13
|
|
|
16
14
|
try {
|
|
@@ -21,10 +19,10 @@ async function runStoryTests(cwd) {
|
|
|
21
19
|
for (const story of prd.userStories) {
|
|
22
20
|
if (!story.testCommand || story.passes) continue;
|
|
23
21
|
try {
|
|
24
|
-
await
|
|
22
|
+
await runShellCommand(story.testCommand, { cwd, timeout: 60_000 });
|
|
25
23
|
results.push({ storyId: story.id, passed: true });
|
|
26
24
|
} catch (err) {
|
|
27
|
-
results.push({ storyId: story.id, passed: false, error: err.message });
|
|
25
|
+
results.push({ storyId: story.id, passed: false, error: err.stderr || err.message });
|
|
28
26
|
}
|
|
29
27
|
}
|
|
30
28
|
return results;
|
|
@@ -55,10 +53,7 @@ async function main() {
|
|
|
55
53
|
let verifyResult = null;
|
|
56
54
|
if (state.verifyScript) {
|
|
57
55
|
try {
|
|
58
|
-
const {
|
|
59
|
-
const { promisify } = await import("node:util");
|
|
60
|
-
const execFileAsync = promisify(execFile);
|
|
61
|
-
const { stdout: vOut } = await execFileAsync(state.verifyScript, [], { cwd, timeout: 30_000 });
|
|
56
|
+
const { stdout: vOut } = await runShellCommand(state.verifyScript, { cwd, timeout: 30_000 });
|
|
62
57
|
verifyResult = { passed: true, output: vOut.trim() };
|
|
63
58
|
} catch (err) {
|
|
64
59
|
verifyResult = { passed: false, output: err.stderr || err.message };
|
package/dist/lib/paths.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export declare function getPackageVersion(): string;
|
|
2
2
|
export declare function isWindows(): boolean;
|
|
3
|
+
export declare function getLoophausHome(homeDir?: string): string;
|
|
3
4
|
export declare function getCodexHome(): string;
|
|
4
5
|
export declare function getAgentsHome(): string;
|
|
5
6
|
export declare function getAgentsSkillsDir(): string;
|
package/dist/lib/paths.js
CHANGED
|
@@ -20,6 +20,9 @@ export function getPackageVersion() {
|
|
|
20
20
|
export function isWindows() {
|
|
21
21
|
return process.platform === "win32";
|
|
22
22
|
}
|
|
23
|
+
export function getLoophausHome(homeDir) {
|
|
24
|
+
return join(homeDir || homedir(), ".loophaus");
|
|
25
|
+
}
|
|
23
26
|
// --- Codex CLI paths (legacy ~/.codex + new ~/.agents) ---
|
|
24
27
|
export function getCodexHome() {
|
|
25
28
|
if (process.env.CODEX_HOME) {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ExecException } from "node:child_process";
|
|
2
|
+
export interface CommandOptions {
|
|
3
|
+
cwd?: string;
|
|
4
|
+
timeout?: number;
|
|
5
|
+
env?: NodeJS.ProcessEnv;
|
|
6
|
+
}
|
|
7
|
+
export interface CommandResult {
|
|
8
|
+
stdout: string;
|
|
9
|
+
stderr: string;
|
|
10
|
+
}
|
|
11
|
+
export interface CommandError extends ExecException {
|
|
12
|
+
stdout?: string;
|
|
13
|
+
stderr?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function getDefaultShell(platform?: NodeJS.Platform): string;
|
|
16
|
+
export declare function resolvePlatformCommand(command: string, platform?: NodeJS.Platform): string;
|
|
17
|
+
export declare function requiresShellExecution(command: string, platform?: NodeJS.Platform): boolean;
|
|
18
|
+
export declare function getGlobalBinDir(prefix: string, platform?: NodeJS.Platform): string;
|
|
19
|
+
export declare function getGlobalBinaryPath(prefix: string, binaryName: string, platform?: NodeJS.Platform): string;
|
|
20
|
+
export declare function runCommand(command: string, args?: string[], options?: CommandOptions): Promise<CommandResult>;
|
|
21
|
+
export declare function runShellCommand(command: string, options?: CommandOptions): Promise<CommandResult>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { exec, execFile } from "node:child_process";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { isWindows } from "./paths.js";
|
|
5
|
+
const execAsync = promisify(exec);
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const WINDOWS_BATCH_COMMANDS = new Set(["loophaus", "npm", "npx"]);
|
|
8
|
+
export function getDefaultShell(platform = process.platform) {
|
|
9
|
+
if (platform === "win32") {
|
|
10
|
+
return process.env.ComSpec || "cmd.exe";
|
|
11
|
+
}
|
|
12
|
+
return process.env.SHELL || "/bin/sh";
|
|
13
|
+
}
|
|
14
|
+
export function resolvePlatformCommand(command, platform = process.platform) {
|
|
15
|
+
if (platform !== "win32")
|
|
16
|
+
return command;
|
|
17
|
+
if (/[/\\]/.test(command) || /\.[A-Za-z0-9]+$/.test(command))
|
|
18
|
+
return command;
|
|
19
|
+
if (WINDOWS_BATCH_COMMANDS.has(command))
|
|
20
|
+
return `${command}.cmd`;
|
|
21
|
+
return command;
|
|
22
|
+
}
|
|
23
|
+
export function requiresShellExecution(command, platform = process.platform) {
|
|
24
|
+
if (platform !== "win32")
|
|
25
|
+
return false;
|
|
26
|
+
return /\.(cmd|bat)$/i.test(command);
|
|
27
|
+
}
|
|
28
|
+
export function getGlobalBinDir(prefix, platform = process.platform) {
|
|
29
|
+
return platform === "win32" ? prefix : join(prefix, "bin");
|
|
30
|
+
}
|
|
31
|
+
export function getGlobalBinaryPath(prefix, binaryName, platform = process.platform) {
|
|
32
|
+
const suffix = platform === "win32" ? ".cmd" : "";
|
|
33
|
+
return join(getGlobalBinDir(prefix, platform), `${binaryName}${suffix}`);
|
|
34
|
+
}
|
|
35
|
+
function createBaseExecOptions(options) {
|
|
36
|
+
return {
|
|
37
|
+
cwd: options.cwd,
|
|
38
|
+
env: options.env,
|
|
39
|
+
timeout: options.timeout,
|
|
40
|
+
encoding: "utf8",
|
|
41
|
+
windowsHide: isWindows(),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function quoteWindowsArg(value) {
|
|
45
|
+
if (value.length === 0)
|
|
46
|
+
return "\"\"";
|
|
47
|
+
if (!/[\s"&|<>^()]/.test(value))
|
|
48
|
+
return value;
|
|
49
|
+
return `"${value.replace(/"/g, "\"\"")}"`;
|
|
50
|
+
}
|
|
51
|
+
function buildShellCommand(command, args) {
|
|
52
|
+
if (!isWindows()) {
|
|
53
|
+
return [command, ...args].map((part) => {
|
|
54
|
+
if (/^[A-Za-z0-9_./:=+-]+$/.test(part))
|
|
55
|
+
return part;
|
|
56
|
+
return `'${part.replace(/'/g, `'\\''`)}'`;
|
|
57
|
+
}).join(" ");
|
|
58
|
+
}
|
|
59
|
+
return [command, ...args].map(quoteWindowsArg).join(" ");
|
|
60
|
+
}
|
|
61
|
+
export async function runCommand(command, args = [], options = {}) {
|
|
62
|
+
const resolved = resolvePlatformCommand(command);
|
|
63
|
+
const execOptions = createBaseExecOptions(options);
|
|
64
|
+
if (requiresShellExecution(resolved)) {
|
|
65
|
+
return execAsync(buildShellCommand(resolved, args), {
|
|
66
|
+
...execOptions,
|
|
67
|
+
shell: getDefaultShell(),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return execFileAsync(resolved, args, execOptions);
|
|
71
|
+
}
|
|
72
|
+
export async function runShellCommand(command, options = {}) {
|
|
73
|
+
return execAsync(command, {
|
|
74
|
+
...createBaseExecOptions(options),
|
|
75
|
+
shell: getDefaultShell(),
|
|
76
|
+
});
|
|
77
|
+
}
|
package/dist/package.json
CHANGED
|
@@ -71,6 +71,8 @@ export async function install({ dryRun = false, force = false } = {}) {
|
|
|
71
71
|
if (!dryRun) {
|
|
72
72
|
const sh = join(cacheDir, "scripts", "setup-ralph-loop.sh");
|
|
73
73
|
if (await fileExists(sh)) await chmod(sh, 0o755);
|
|
74
|
+
const nodeScript = join(cacheDir, "scripts", "setup-loop.mjs");
|
|
75
|
+
if (await fileExists(nodeScript)) await chmod(nodeScript, 0o755);
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
// Step 2: Register marketplace
|
|
@@ -185,7 +185,7 @@ export async function install({ dryRun = false, force = false, local = false } =
|
|
|
185
185
|
|
|
186
186
|
// Step 2: Copy files
|
|
187
187
|
console.log(`[2/${totalSteps}] Copying plugin files...`);
|
|
188
|
-
for (const dir of ["hooks", "codex/commands", "lib", "core", "store"]) {
|
|
188
|
+
for (const dir of ["hooks", "codex/commands", "scripts", "lib", "core", "store"]) {
|
|
189
189
|
const src = join(PROJECT_ROOT, dir);
|
|
190
190
|
if (!(await fileExists(src))) continue;
|
|
191
191
|
const destDir = dir === "codex/commands" ? "commands" : dir;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { access } from "node:fs/promises";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = dirname(__filename);
|
|
9
|
+
|
|
10
|
+
async function fileExists(path) {
|
|
11
|
+
try {
|
|
12
|
+
await access(path);
|
|
13
|
+
return true;
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function loadStateStore() {
|
|
20
|
+
const candidates = [
|
|
21
|
+
join(__dirname, "..", "store", "state-store.js"),
|
|
22
|
+
join(__dirname, "..", "dist", "store", "state-store.js"),
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
for (const candidate of candidates) {
|
|
26
|
+
if (await fileExists(candidate)) {
|
|
27
|
+
return import(pathToFileURL(candidate).href);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
throw new Error("Could not locate state-store.js");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function printHelp() {
|
|
35
|
+
console.log(`loophaus — Iterative coding loop
|
|
36
|
+
|
|
37
|
+
USAGE:
|
|
38
|
+
/loop [PROMPT...] [OPTIONS]
|
|
39
|
+
|
|
40
|
+
ARGUMENTS:
|
|
41
|
+
PROMPT... Initial prompt to start the loop
|
|
42
|
+
|
|
43
|
+
OPTIONS:
|
|
44
|
+
--max-iterations <n> Maximum iterations before auto-stop (default: unlimited)
|
|
45
|
+
--completion-promise <text> Promise phrase for explicit completion
|
|
46
|
+
-h, --help Show this help message`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseArgs(argv) {
|
|
50
|
+
const promptParts = [];
|
|
51
|
+
let maxIterations = 0;
|
|
52
|
+
let completionPromise = "";
|
|
53
|
+
|
|
54
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
55
|
+
const arg = argv[i];
|
|
56
|
+
if (arg === "-h" || arg === "--help") {
|
|
57
|
+
printHelp();
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
if (arg === "--max-iterations") {
|
|
61
|
+
const value = argv[i + 1];
|
|
62
|
+
if (!value) {
|
|
63
|
+
throw new Error("--max-iterations requires a number argument");
|
|
64
|
+
}
|
|
65
|
+
if (!/^\d+$/.test(value)) {
|
|
66
|
+
throw new Error(`--max-iterations must be a positive integer or 0, got: ${value}`);
|
|
67
|
+
}
|
|
68
|
+
maxIterations = Number.parseInt(value, 10);
|
|
69
|
+
i += 1;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (arg === "--completion-promise") {
|
|
73
|
+
const value = argv[i + 1];
|
|
74
|
+
if (!value) {
|
|
75
|
+
throw new Error("--completion-promise requires a text argument");
|
|
76
|
+
}
|
|
77
|
+
completionPromise = value;
|
|
78
|
+
i += 1;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
promptParts.push(arg);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const prompt = promptParts.join(" ").trim();
|
|
85
|
+
if (!prompt) {
|
|
86
|
+
throw new Error("No prompt provided");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { prompt, maxIterations, completionPromise };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function main() {
|
|
93
|
+
const { readState, writeState } = await loadStateStore();
|
|
94
|
+
const { prompt, maxIterations, completionPromise } = parseArgs(process.argv.slice(2));
|
|
95
|
+
const existingState = await readState(process.cwd());
|
|
96
|
+
const sessionId =
|
|
97
|
+
process.env.CLAUDE_CODE_SESSION_ID ||
|
|
98
|
+
process.env.CODEX_SESSION_ID ||
|
|
99
|
+
process.env.SESSION_ID ||
|
|
100
|
+
"";
|
|
101
|
+
|
|
102
|
+
await writeState({
|
|
103
|
+
...existingState,
|
|
104
|
+
active: true,
|
|
105
|
+
prompt,
|
|
106
|
+
completionPromise,
|
|
107
|
+
maxIterations,
|
|
108
|
+
currentIteration: 0,
|
|
109
|
+
sessionId,
|
|
110
|
+
startedAt: new Date().toISOString(),
|
|
111
|
+
}, process.cwd());
|
|
112
|
+
|
|
113
|
+
console.log("Loop activated!");
|
|
114
|
+
console.log("");
|
|
115
|
+
console.log("Iteration: 1");
|
|
116
|
+
console.log(`Max iterations: ${maxIterations > 0 ? maxIterations : "unlimited"}`);
|
|
117
|
+
console.log(`Completion promise: ${completionPromise || "none (runs until stopped or max iterations)"}`);
|
|
118
|
+
console.log("");
|
|
119
|
+
console.log("The stop hook is now active. When you try to exit, the same prompt will be fed back.");
|
|
120
|
+
console.log("To cancel: /loop-stop");
|
|
121
|
+
console.log("To monitor: read .loophaus/state.json");
|
|
122
|
+
console.log("");
|
|
123
|
+
console.log(prompt);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
main().catch((err) => {
|
|
127
|
+
console.error(`Error: ${err.message}`);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
});
|
package/hooks/stop-hook.mjs
CHANGED
|
@@ -5,12 +5,10 @@ import { getLastAssistantText, hasPendingStories } from "../core/io-helpers.js";
|
|
|
5
5
|
import { read as readState, write as writeState } from "../store/state-store.js";
|
|
6
6
|
import { logEvents } from "../core/event-logger.js";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
+
import { runShellCommand } from "../lib/runtime.js";
|
|
8
9
|
|
|
9
10
|
async function runStoryTests(cwd) {
|
|
10
11
|
const { readFile } = await import("node:fs/promises");
|
|
11
|
-
const { execFile } = await import("node:child_process");
|
|
12
|
-
const { promisify } = await import("node:util");
|
|
13
|
-
const execFileAsync = promisify(execFile);
|
|
14
12
|
const prdPath = join(cwd, "prd.json");
|
|
15
13
|
|
|
16
14
|
try {
|
|
@@ -21,10 +19,10 @@ async function runStoryTests(cwd) {
|
|
|
21
19
|
for (const story of prd.userStories) {
|
|
22
20
|
if (!story.testCommand || story.passes) continue;
|
|
23
21
|
try {
|
|
24
|
-
await
|
|
22
|
+
await runShellCommand(story.testCommand, { cwd, timeout: 60_000 });
|
|
25
23
|
results.push({ storyId: story.id, passed: true });
|
|
26
24
|
} catch (err) {
|
|
27
|
-
results.push({ storyId: story.id, passed: false, error: err.message });
|
|
25
|
+
results.push({ storyId: story.id, passed: false, error: err.stderr || err.message });
|
|
28
26
|
}
|
|
29
27
|
}
|
|
30
28
|
return results;
|
|
@@ -55,10 +53,7 @@ async function main() {
|
|
|
55
53
|
let verifyResult = null;
|
|
56
54
|
if (state.verifyScript) {
|
|
57
55
|
try {
|
|
58
|
-
const {
|
|
59
|
-
const { promisify } = await import("node:util");
|
|
60
|
-
const execFileAsync = promisify(execFile);
|
|
61
|
-
const { stdout: vOut } = await execFileAsync(state.verifyScript, [], { cwd, timeout: 30_000 });
|
|
56
|
+
const { stdout: vOut } = await runShellCommand(state.verifyScript, { cwd, timeout: 30_000 });
|
|
62
57
|
verifyResult = { passed: true, output: vOut.trim() };
|
|
63
58
|
} catch (err) {
|
|
64
59
|
verifyResult = { passed: false, output: err.stderr || err.message };
|
package/package.json
CHANGED
|
@@ -71,6 +71,8 @@ export async function install({ dryRun = false, force = false } = {}) {
|
|
|
71
71
|
if (!dryRun) {
|
|
72
72
|
const sh = join(cacheDir, "scripts", "setup-ralph-loop.sh");
|
|
73
73
|
if (await fileExists(sh)) await chmod(sh, 0o755);
|
|
74
|
+
const nodeScript = join(cacheDir, "scripts", "setup-loop.mjs");
|
|
75
|
+
if (await fileExists(nodeScript)) await chmod(nodeScript, 0o755);
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
// Step 2: Register marketplace
|
|
@@ -185,7 +185,7 @@ export async function install({ dryRun = false, force = false, local = false } =
|
|
|
185
185
|
|
|
186
186
|
// Step 2: Copy files
|
|
187
187
|
console.log(`[2/${totalSteps}] Copying plugin files...`);
|
|
188
|
-
for (const dir of ["hooks", "codex/commands", "lib", "core", "store"]) {
|
|
188
|
+
for (const dir of ["hooks", "codex/commands", "scripts", "lib", "core", "store"]) {
|
|
189
189
|
const src = join(PROJECT_ROOT, dir);
|
|
190
190
|
if (!(await fileExists(src))) continue;
|
|
191
191
|
const destDir = dir === "codex/commands" ? "commands" : dir;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { access } from "node:fs/promises";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = dirname(__filename);
|
|
9
|
+
|
|
10
|
+
async function fileExists(path) {
|
|
11
|
+
try {
|
|
12
|
+
await access(path);
|
|
13
|
+
return true;
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function loadStateStore() {
|
|
20
|
+
const candidates = [
|
|
21
|
+
join(__dirname, "..", "store", "state-store.js"),
|
|
22
|
+
join(__dirname, "..", "dist", "store", "state-store.js"),
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
for (const candidate of candidates) {
|
|
26
|
+
if (await fileExists(candidate)) {
|
|
27
|
+
return import(pathToFileURL(candidate).href);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
throw new Error("Could not locate state-store.js");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function printHelp() {
|
|
35
|
+
console.log(`loophaus — Iterative coding loop
|
|
36
|
+
|
|
37
|
+
USAGE:
|
|
38
|
+
/loop [PROMPT...] [OPTIONS]
|
|
39
|
+
|
|
40
|
+
ARGUMENTS:
|
|
41
|
+
PROMPT... Initial prompt to start the loop
|
|
42
|
+
|
|
43
|
+
OPTIONS:
|
|
44
|
+
--max-iterations <n> Maximum iterations before auto-stop (default: unlimited)
|
|
45
|
+
--completion-promise <text> Promise phrase for explicit completion
|
|
46
|
+
-h, --help Show this help message`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseArgs(argv) {
|
|
50
|
+
const promptParts = [];
|
|
51
|
+
let maxIterations = 0;
|
|
52
|
+
let completionPromise = "";
|
|
53
|
+
|
|
54
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
55
|
+
const arg = argv[i];
|
|
56
|
+
if (arg === "-h" || arg === "--help") {
|
|
57
|
+
printHelp();
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
if (arg === "--max-iterations") {
|
|
61
|
+
const value = argv[i + 1];
|
|
62
|
+
if (!value) {
|
|
63
|
+
throw new Error("--max-iterations requires a number argument");
|
|
64
|
+
}
|
|
65
|
+
if (!/^\d+$/.test(value)) {
|
|
66
|
+
throw new Error(`--max-iterations must be a positive integer or 0, got: ${value}`);
|
|
67
|
+
}
|
|
68
|
+
maxIterations = Number.parseInt(value, 10);
|
|
69
|
+
i += 1;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (arg === "--completion-promise") {
|
|
73
|
+
const value = argv[i + 1];
|
|
74
|
+
if (!value) {
|
|
75
|
+
throw new Error("--completion-promise requires a text argument");
|
|
76
|
+
}
|
|
77
|
+
completionPromise = value;
|
|
78
|
+
i += 1;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
promptParts.push(arg);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const prompt = promptParts.join(" ").trim();
|
|
85
|
+
if (!prompt) {
|
|
86
|
+
throw new Error("No prompt provided");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { prompt, maxIterations, completionPromise };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function main() {
|
|
93
|
+
const { readState, writeState } = await loadStateStore();
|
|
94
|
+
const { prompt, maxIterations, completionPromise } = parseArgs(process.argv.slice(2));
|
|
95
|
+
const existingState = await readState(process.cwd());
|
|
96
|
+
const sessionId =
|
|
97
|
+
process.env.CLAUDE_CODE_SESSION_ID ||
|
|
98
|
+
process.env.CODEX_SESSION_ID ||
|
|
99
|
+
process.env.SESSION_ID ||
|
|
100
|
+
"";
|
|
101
|
+
|
|
102
|
+
await writeState({
|
|
103
|
+
...existingState,
|
|
104
|
+
active: true,
|
|
105
|
+
prompt,
|
|
106
|
+
completionPromise,
|
|
107
|
+
maxIterations,
|
|
108
|
+
currentIteration: 0,
|
|
109
|
+
sessionId,
|
|
110
|
+
startedAt: new Date().toISOString(),
|
|
111
|
+
}, process.cwd());
|
|
112
|
+
|
|
113
|
+
console.log("Loop activated!");
|
|
114
|
+
console.log("");
|
|
115
|
+
console.log("Iteration: 1");
|
|
116
|
+
console.log(`Max iterations: ${maxIterations > 0 ? maxIterations : "unlimited"}`);
|
|
117
|
+
console.log(`Completion promise: ${completionPromise || "none (runs until stopped or max iterations)"}`);
|
|
118
|
+
console.log("");
|
|
119
|
+
console.log("The stop hook is now active. When you try to exit, the same prompt will be fed back.");
|
|
120
|
+
console.log("To cancel: /loop-stop");
|
|
121
|
+
console.log("To monitor: read .loophaus/state.json");
|
|
122
|
+
console.log("");
|
|
123
|
+
console.log(prompt);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
main().catch((err) => {
|
|
127
|
+
console.error(`Error: ${err.message}`);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
});
|