@graypark/loophaus 3.6.1 → 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.
@@ -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,15 +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
+ ## Pre-flight
10
+
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.
15
+
16
+ ---
17
+
9
18
  Execute the setup script to initialize the loop:
10
19
 
11
20
  ```!
12
- "${CLAUDE_PLUGIN_ROOT}/scripts/setup-ralph-loop.sh" $ARGUMENTS
21
+ node "${CLAUDE_PLUGIN_ROOT}/scripts/setup-loop.mjs" $ARGUMENTS
13
22
  ```
14
23
 
15
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 };
@@ -0,0 +1,30 @@
1
+ export type UpdateStatus = "up_to_date" | "upgrade_available" | "snoozed" | "disabled" | "error";
2
+ export interface UpdateCheckResult {
3
+ status: UpdateStatus;
4
+ current: string;
5
+ latest: string;
6
+ message?: string;
7
+ }
8
+ export interface UpdateCache {
9
+ checkedAt: string;
10
+ status: "up_to_date" | "upgrade_available";
11
+ current: string;
12
+ latest: string;
13
+ }
14
+ export interface SnoozeState {
15
+ version: string;
16
+ level: number;
17
+ snoozedAt: string;
18
+ }
19
+ export interface UpdateConfig {
20
+ updateCheck?: boolean;
21
+ autoUpgrade?: boolean;
22
+ }
23
+ export declare function compareVersions(current: string, latest: string): -1 | 0 | 1;
24
+ export declare function isCacheFresh(cache: UpdateCache, nowMs: number): boolean;
25
+ export declare function isSnoozed(snooze: SnoozeState, version: string, nowMs: number): boolean;
26
+ export declare function getSnoozeHours(level: number): number;
27
+ export declare function readConfig(cwd?: string): Promise<UpdateConfig>;
28
+ export declare function checkForUpdate(currentVersion: string, homeCwd?: string): Promise<UpdateCheckResult>;
29
+ export declare function snoozeUpdate(version: string, homeCwd?: string): Promise<SnoozeState>;
30
+ export declare function getUpdateStatus(currentVersion: string, homeCwd?: string): Promise<string>;
@@ -0,0 +1,173 @@
1
+ // core/update-checker.ts
2
+ // npm registry version check with cache + snooze (gstack-style)
3
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+ import { get } from "node:https";
6
+ import { getLoophausHome } from "../lib/paths.js";
7
+ const REGISTRY_URL = "https://registry.npmjs.org/@graypark/loophaus/latest";
8
+ const FETCH_TIMEOUT_MS = 5_000;
9
+ // Cache TTL in minutes
10
+ const TTL_UP_TO_DATE = 60;
11
+ const TTL_UPGRADE_AVAILABLE = 720;
12
+ // Snooze durations in hours
13
+ const SNOOZE_HOURS = [24, 48, 168]; // level 1, 2, 3+
14
+ function getLoophausDir(cwd) {
15
+ return getLoophausHome(cwd);
16
+ }
17
+ function getCachePath(cwd) {
18
+ return join(getLoophausDir(cwd), "update-cache.json");
19
+ }
20
+ function getSnoozePath(cwd) {
21
+ return join(getLoophausDir(cwd), "update-snoozed.json");
22
+ }
23
+ // --- Pure functions ---
24
+ export function compareVersions(current, latest) {
25
+ const a = current.split(".").map(Number);
26
+ const b = latest.split(".").map(Number);
27
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
28
+ const av = a[i] || 0;
29
+ const bv = b[i] || 0;
30
+ if (av < bv)
31
+ return -1;
32
+ if (av > bv)
33
+ return 1;
34
+ }
35
+ return 0;
36
+ }
37
+ export function isCacheFresh(cache, nowMs) {
38
+ const age = (nowMs - new Date(cache.checkedAt).getTime()) / 60_000;
39
+ const ttl = cache.status === "up_to_date" ? TTL_UP_TO_DATE : TTL_UPGRADE_AVAILABLE;
40
+ return age < ttl;
41
+ }
42
+ export function isSnoozed(snooze, version, nowMs) {
43
+ if (snooze.version !== version)
44
+ return false; // new version resets snooze
45
+ const level = Math.min(snooze.level, SNOOZE_HOURS.length) - 1;
46
+ const durationMs = (SNOOZE_HOURS[level] ?? SNOOZE_HOURS[SNOOZE_HOURS.length - 1]) * 3600_000;
47
+ const elapsed = nowMs - new Date(snooze.snoozedAt).getTime();
48
+ return elapsed < durationMs;
49
+ }
50
+ export function getSnoozeHours(level) {
51
+ const idx = Math.min(level, SNOOZE_HOURS.length) - 1;
52
+ return SNOOZE_HOURS[idx] ?? SNOOZE_HOURS[SNOOZE_HOURS.length - 1];
53
+ }
54
+ // --- I/O functions ---
55
+ async function readJson(path) {
56
+ try {
57
+ return JSON.parse(await readFile(path, "utf-8"));
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ async function writeJson(path, data) {
64
+ await mkdir(dirname(path), { recursive: true });
65
+ await writeFile(path, JSON.stringify(data, null, 2), "utf-8");
66
+ }
67
+ async function fetchLatestVersion() {
68
+ return new Promise((resolve) => {
69
+ const timer = setTimeout(() => resolve(null), FETCH_TIMEOUT_MS);
70
+ try {
71
+ const req = get(REGISTRY_URL, { timeout: FETCH_TIMEOUT_MS }, (res) => {
72
+ let data = "";
73
+ res.on("data", (chunk) => { data += chunk.toString(); });
74
+ res.on("end", () => {
75
+ clearTimeout(timer);
76
+ try {
77
+ const pkg = JSON.parse(data);
78
+ resolve(pkg.version || null);
79
+ }
80
+ catch {
81
+ resolve(null);
82
+ }
83
+ });
84
+ });
85
+ req.on("error", () => { clearTimeout(timer); resolve(null); });
86
+ }
87
+ catch {
88
+ clearTimeout(timer);
89
+ resolve(null);
90
+ }
91
+ });
92
+ }
93
+ export async function readConfig(cwd) {
94
+ const configPath = join(cwd || process.cwd(), ".loophaus", "config.json");
95
+ try {
96
+ const raw = await readFile(configPath, "utf-8");
97
+ return JSON.parse(raw);
98
+ }
99
+ catch {
100
+ return {};
101
+ }
102
+ }
103
+ export async function checkForUpdate(currentVersion, homeCwd) {
104
+ const dir = getLoophausDir(homeCwd);
105
+ const cachePath = getCachePath(homeCwd);
106
+ const snoozePath = getSnoozePath(homeCwd);
107
+ const now = Date.now();
108
+ // Check config
109
+ const config = await readConfig(homeCwd);
110
+ if (config.updateCheck === false) {
111
+ return { status: "disabled", current: currentVersion, latest: currentVersion };
112
+ }
113
+ // Check cache
114
+ const cache = await readJson(cachePath);
115
+ if (cache && isCacheFresh(cache, now)) {
116
+ if (cache.status === "up_to_date") {
117
+ return { status: "up_to_date", current: currentVersion, latest: cache.latest };
118
+ }
119
+ // Cache says upgrade available — check snooze
120
+ const snooze = await readJson(snoozePath);
121
+ if (snooze && isSnoozed(snooze, cache.latest, now)) {
122
+ const hours = getSnoozeHours(snooze.level);
123
+ return { status: "snoozed", current: currentVersion, latest: cache.latest, message: `Snoozed for ${hours}h (level ${snooze.level})` };
124
+ }
125
+ return { status: "upgrade_available", current: currentVersion, latest: cache.latest };
126
+ }
127
+ // Fetch from registry
128
+ const latest = await fetchLatestVersion();
129
+ if (!latest) {
130
+ return { status: "error", current: currentVersion, latest: currentVersion, message: "Could not reach npm registry" };
131
+ }
132
+ const cmp = compareVersions(currentVersion, latest);
133
+ const status = cmp < 0 ? "upgrade_available" : "up_to_date";
134
+ // Write cache
135
+ await mkdir(dir, { recursive: true });
136
+ const newCache = { checkedAt: new Date().toISOString(), status, current: currentVersion, latest };
137
+ await writeJson(cachePath, newCache);
138
+ if (status === "upgrade_available") {
139
+ // Check snooze for the new version
140
+ const snooze = await readJson(snoozePath);
141
+ if (snooze && isSnoozed(snooze, latest, now)) {
142
+ const hours = getSnoozeHours(snooze.level);
143
+ return { status: "snoozed", current: currentVersion, latest, message: `Snoozed for ${hours}h (level ${snooze.level})` };
144
+ }
145
+ }
146
+ return { status, current: currentVersion, latest };
147
+ }
148
+ export async function snoozeUpdate(version, homeCwd) {
149
+ const snoozePath = getSnoozePath(homeCwd);
150
+ const existing = await readJson(snoozePath);
151
+ let level = 1;
152
+ if (existing && existing.version === version) {
153
+ level = existing.level + 1;
154
+ }
155
+ const snooze = {
156
+ version,
157
+ level,
158
+ snoozedAt: new Date().toISOString(),
159
+ };
160
+ await writeJson(snoozePath, snooze);
161
+ return snooze;
162
+ }
163
+ export async function getUpdateStatus(currentVersion, homeCwd) {
164
+ const result = await checkForUpdate(currentVersion, homeCwd);
165
+ switch (result.status) {
166
+ case "up_to_date": return "";
167
+ case "upgrade_available": return `UPGRADE_AVAILABLE ${result.current} ${result.latest}`;
168
+ case "snoozed": return "";
169
+ case "disabled": return "";
170
+ case "error": return "";
171
+ default: return "";
172
+ }
173
+ }
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@graypark/loophaus",
3
- "version": "3.6.1",
3
+ "version": "3.8.0",
4
4
  "type": "module",
5
5
  "description": "loophaus — Control plane for coding agents. Iterative dev loops with multi-agent orchestration.",
6
6
  "license": "MIT",