@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.
- package/README.ko.md +12 -2
- package/README.md +11 -1
- package/commands/cancel-ralph.md +3 -8
- package/commands/loop-plan.md +43 -0
- package/commands/loop-stop.md +5 -13
- package/commands/loop.md +11 -2
- 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 +206 -2
- package/dist/commands/cancel-ralph.md +3 -8
- package/dist/commands/loop-plan.md +43 -0
- package/dist/commands/loop-stop.md +5 -13
- package/dist/commands/loop.md +11 -2
- 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.d.ts +30 -0
- package/dist/core/update-checker.js +173 -0
- 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
|
@@ -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,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
|
|
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-
|
|
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
|
|
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 };
|
|
@@ -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
|
+
}
|
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
|
+
}
|