@graypark/loophaus 3.7.0 → 3.8.1

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 CHANGED
@@ -153,11 +153,13 @@ loophaus는 세 개의 주요 코딩 에이전트 플랫폼을 지원합니다:
153
153
  | 기능 | Claude Code | Codex CLI | Kiro CLI |
154
154
  | --- | --- | --- | --- |
155
155
  | 자동 감지 설치 | `~/.claude/` | `~/.codex/` | `~/.kiro/` |
156
- | Stop hook | bash 기반 | Node.js 기반 | bash 기반 |
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 # 296개 테스트
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 # 296 test cases
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
@@ -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 Bash: `test -f .claude/ralph-loop.local.md && echo "EXISTS" || echo "NOT_FOUND"`
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 Bash: `rm .claude/ralph-loop.local.md`
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 this silently before starting:
16
+ Run `loophaus update-check` silently before starting.
17
17
 
18
- ```bash
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 (never block the user).
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: `test -f .loophaus/state.json && echo "EXISTS" || echo "NOT_FOUND"`
17
- - If not found, also check legacy path: `test -f .claude/ralph-loop.local.md && echo "LEGACY" || echo "NOT_FOUND"`
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: `rm .loophaus/state.json`
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: `rm .claude/ralph-loop.local.md`
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(${CLAUDE_PLUGIN_ROOT}/scripts/setup-ralph-loop.sh:*)"]
4
+ allowed-tools: ["Bash"]
5
5
  ---
6
6
 
7
7
  # /loop — Start Iterative Dev Loop
8
8
 
9
9
  ## Pre-flight
10
10
 
11
- ```bash
12
- _UPDATE=$(loophaus update-check 2>/dev/null | grep "UPGRADE_AVAILABLE" || true)
13
- ```
14
- If update available and autoUpgrade enabled: run `loophaus upgrade` silently.
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-ralph-loop.sh" $ARGUMENTS
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(${CLAUDE_PLUGIN_ROOT}/scripts/setup-ralph-loop.sh:*)"]
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-ralph-loop.sh" $ARGUMENTS
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 | bash 기반 | Node.js 기반 | bash 기반 |
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 # 296개 테스트
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 # 296 test cases
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
@@ -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 = join(process.env.HOME || "~", ".loophaus");
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 execFileAsync("npm", ["install", "-g", `@graypark/loophaus@${result.latest}`], { timeout: 120_000 });
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 execFileAsync("loophaus", ["install", "--force"], { timeout: 60_000 });
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 Bash: `test -f .claude/ralph-loop.local.md && echo "EXISTS" || echo "NOT_FOUND"`
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 Bash: `rm .claude/ralph-loop.local.md`
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 this silently before starting:
16
+ Run `loophaus update-check` silently before starting.
17
17
 
18
- ```bash
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 (never block the user).
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: `test -f .loophaus/state.json && echo "EXISTS" || echo "NOT_FOUND"`
17
- - If not found, also check legacy path: `test -f .claude/ralph-loop.local.md && echo "LEGACY" || echo "NOT_FOUND"`
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: `rm .loophaus/state.json`
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: `rm .claude/ralph-loop.local.md`
20
+ - Remove it with a cross-platform Node file command
29
21
  - Report: "Stopped loop at iteration N. (migrated from legacy path)"
@@ -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(${CLAUDE_PLUGIN_ROOT}/scripts/setup-ralph-loop.sh:*)"]
4
+ allowed-tools: ["Bash"]
5
5
  ---
6
6
 
7
7
  # /loop — Start Iterative Dev Loop
8
8
 
9
9
  ## Pre-flight
10
10
 
11
- ```bash
12
- _UPDATE=$(loophaus update-check 2>/dev/null | grep "UPGRADE_AVAILABLE" || true)
13
- ```
14
- If update available and autoUpgrade enabled: run `loophaus upgrade` silently.
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-ralph-loop.sh" $ARGUMENTS
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(${CLAUDE_PLUGIN_ROOT}/scripts/setup-ralph-loop.sh:*)"]
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-ralph-loop.sh" $ARGUMENTS
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.
@@ -1,10 +1,23 @@
1
1
  // core/benchmark.ts
2
2
  // Project-level quality measurement (autoresearch pattern: val_bpb → project score)
3
- import { execFile } from "node:child_process";
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
- const execFileAsync = promisify(execFile);
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 execFileAsync("npx", ["vitest", "run", "--reporter=json"], { cwd: dir, timeout: 120_000 });
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 execFileAsync("npx", ["tsc", "--noEmit"], { cwd: dir, timeout: 60_000 });
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 execFileAsync("npm", ["run", "build"], { cwd: dir, timeout: 60_000 });
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 execFileAsync("npx", ["vitest", "run", "--coverage"], { cwd: dir, timeout: 120_000 });
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
- const { stdout } = await execFileAsync("du", ["-sk", distDir], { timeout: 10_000 });
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 execFileAsync("git", ["rev-parse", "--short", "HEAD"], { timeout: 5_000 });
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("\n").slice(1); // skip header
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
- const execFileAsync = promisify(execFile);
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 execFileAsync("sh", ["-c", config.testCommand], { cwd, timeout: 120_000 });
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 execFileAsync("sh", ["-c", config.typecheckCommand], { cwd, timeout: 60_000 });
46
+ await runShellCommand(config.typecheckCommand, { cwd, timeout: 60_000 });
48
47
  results.typecheck = 10;
49
48
  }
50
49
  catch (err) {
51
- const errorCount = (err.stdout || "").split("\n").filter(l => l.includes("error")).length;
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 execFileAsync("sh", ["-c", config.lintCommand], { cwd, timeout: 60_000 });
57
+ await runShellCommand(config.lintCommand, { cwd, timeout: 60_000 });
58
58
  results.lint = 10;
59
59
  }
60
60
  catch (err) {
61
- const warnings = (err.stdout || "").split("\n").filter(l => l.includes("warning") || l.includes("error")).length;
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 execFileAsync("sh", ["-c", config.verifyScript], { cwd, timeout: 60_000 });
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 execFileAsync("git", ["diff", "--stat", "HEAD~1"], { cwd, timeout: 10_000 });
76
- const lines = stdout.trim().split("\n");
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("\n").slice(1);
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 join(cwd || process.env.HOME || "~", ".loophaus");
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(join(path, ".."), { recursive: true });
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() {
@@ -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("\n")) {
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.split("/").pop(),
98
+ name: basename(e.path),
99
99
  path: e.path,
100
100
  branch: e.branch || "",
101
101
  head: e.head || "",
@@ -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 execFileAsync("sh", ["-c", story.testCommand], { cwd, timeout: 60_000 });
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 { execFile } = await import("node:child_process");
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 };
@@ -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
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@graypark/loophaus",
3
- "version": "3.7.0",
3
+ "version": "3.8.1",
4
4
  "type": "module",
5
5
  "description": "loophaus — Control plane for coding agents. Iterative dev loops with multi-agent orchestration.",
6
6
  "license": "MIT",
7
7
  "author": "graypark",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "https://github.com/vcz-Gray/loophaus"
10
+ "url": "git+https://github.com/vcz-Gray/loophaus.git"
11
11
  },
12
12
  "homepage": "https://github.com/vcz-Gray/loophaus#readme",
13
13
  "bugs": {
@@ -17,7 +17,7 @@
17
17
  "access": "public"
18
18
  },
19
19
  "bin": {
20
- "loophaus": "./dist/bin/loophaus.js"
20
+ "loophaus": "dist/bin/loophaus.js"
21
21
  },
22
22
  "files": [
23
23
  "dist/",
@@ -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
+ });
@@ -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 execFileAsync("sh", ["-c", story.testCommand], { cwd, timeout: 60_000 });
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 { execFile } = await import("node:child_process");
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
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@graypark/loophaus",
3
- "version": "3.7.0",
3
+ "version": "3.8.1",
4
4
  "type": "module",
5
5
  "description": "loophaus — Control plane for coding agents. Iterative dev loops with multi-agent orchestration.",
6
6
  "license": "MIT",
7
7
  "author": "graypark",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "https://github.com/vcz-Gray/loophaus"
10
+ "url": "git+https://github.com/vcz-Gray/loophaus.git"
11
11
  },
12
12
  "homepage": "https://github.com/vcz-Gray/loophaus#readme",
13
13
  "bugs": {
@@ -17,7 +17,7 @@
17
17
  "access": "public"
18
18
  },
19
19
  "bin": {
20
- "loophaus": "./dist/bin/loophaus.js"
20
+ "loophaus": "dist/bin/loophaus.js"
21
21
  },
22
22
  "files": [
23
23
  "dist/",
@@ -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
+ });