@graypark/loophaus 2.1.2 → 3.1.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/bin/loophaus.mjs +208 -4
- package/bin/uninstall.mjs +1 -1
- package/codex/commands/cancel-ralph.md +1 -1
- package/codex/commands/ralph-loop.md +1 -1
- package/core/cost-tracker.mjs +44 -0
- package/core/engine.mjs +16 -0
- package/core/events.mjs +44 -0
- package/core/io-helpers.mjs +33 -0
- package/core/loop-registry.mjs +37 -0
- package/core/state.schema.json +24 -0
- package/core/trace-analyzer.mjs +51 -0
- package/core/validate.mjs +51 -0
- package/hooks/stop-hook.mjs +16 -33
- package/lib/stop-hook-core.mjs +22 -142
- package/package.json +1 -1
- package/store/state-store.mjs +30 -7
- package/lib/state.mjs +0 -46
package/bin/loophaus.mjs
CHANGED
|
@@ -26,14 +26,24 @@ function getHost() {
|
|
|
26
26
|
|
|
27
27
|
const host = getHost();
|
|
28
28
|
|
|
29
|
+
function getFlag(flag) {
|
|
30
|
+
const idx = args.indexOf(flag);
|
|
31
|
+
if (idx !== -1 && args[idx + 1] && !args[idx + 1].startsWith("-")) return args[idx + 1];
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
29
35
|
if (showHelp || command === "help") {
|
|
30
36
|
console.log(`loophaus — Control plane for coding agents
|
|
31
37
|
|
|
32
38
|
Usage:
|
|
33
39
|
npx @graypark/loophaus install [--host <name>] [--force] [--dry-run]
|
|
34
40
|
npx @graypark/loophaus uninstall [--host <name>]
|
|
35
|
-
npx @graypark/loophaus status
|
|
36
|
-
npx @graypark/loophaus stats
|
|
41
|
+
npx @graypark/loophaus status [--name <loop>]
|
|
42
|
+
npx @graypark/loophaus stats [--name <loop>]
|
|
43
|
+
npx @graypark/loophaus watch
|
|
44
|
+
npx @graypark/loophaus replay <trace-file> [--speed 2]
|
|
45
|
+
npx @graypark/loophaus compare <trace1> <trace2>
|
|
46
|
+
npx @graypark/loophaus loops
|
|
37
47
|
npx @graypark/loophaus --version
|
|
38
48
|
|
|
39
49
|
Hosts:
|
|
@@ -47,6 +57,7 @@ Options:
|
|
|
47
57
|
--host <name> Target a specific host
|
|
48
58
|
--claude Shorthand for --host claude-code
|
|
49
59
|
--kiro Shorthand for --host kiro-cli
|
|
60
|
+
--name <loop> Target a named loop (multi-loop)
|
|
50
61
|
--local Install to project-local .codex/ (Codex only)
|
|
51
62
|
--force Overwrite existing installation
|
|
52
63
|
--dry-run Preview changes without modifying files
|
|
@@ -117,10 +128,11 @@ async function runUninstall() {
|
|
|
117
128
|
}
|
|
118
129
|
|
|
119
130
|
async function runStatus() {
|
|
131
|
+
const name = getFlag("--name");
|
|
120
132
|
const { read } = await import("../store/state-store.mjs");
|
|
121
|
-
const state = await read();
|
|
133
|
+
const state = await read(undefined, name);
|
|
122
134
|
if (!state.active) {
|
|
123
|
-
console.log("No active loop.");
|
|
135
|
+
console.log(name ? `No active loop: ${name}` : "No active loop.");
|
|
124
136
|
return;
|
|
125
137
|
}
|
|
126
138
|
const iterInfo = state.maxIterations > 0
|
|
@@ -128,6 +140,7 @@ async function runStatus() {
|
|
|
128
140
|
: `${state.currentIteration}`;
|
|
129
141
|
console.log(`Loop Status`);
|
|
130
142
|
console.log(`\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`);
|
|
143
|
+
if (name) console.log(`Name: ${name}`);
|
|
131
144
|
console.log(`Active: yes`);
|
|
132
145
|
console.log(`Iteration: ${iterInfo}`);
|
|
133
146
|
console.log(`Promise: ${state.completionPromise || "(none)"}`);
|
|
@@ -150,8 +163,22 @@ async function runStatus() {
|
|
|
150
163
|
} catch { /* no prd.json */ }
|
|
151
164
|
}
|
|
152
165
|
|
|
166
|
+
async function runLoops() {
|
|
167
|
+
const { listLoops } = await import("../core/loop-registry.mjs");
|
|
168
|
+
const loops = await listLoops();
|
|
169
|
+
if (loops.length === 0) { console.log("No active loops."); return; }
|
|
170
|
+
console.log("Active Loops");
|
|
171
|
+
console.log("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
172
|
+
for (const l of loops) {
|
|
173
|
+
const status = l.active ? "active" : "done";
|
|
174
|
+
const iter = l.maxIterations > 0 ? `${l.currentIteration}/${l.maxIterations}` : `${l.currentIteration}`;
|
|
175
|
+
console.log(` ${l.name} [${status}] iter ${iter}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
153
179
|
async function runStats() {
|
|
154
180
|
const { readTrace } = await import("../core/event-logger.mjs");
|
|
181
|
+
const { formatCost } = await import("../core/cost-tracker.mjs");
|
|
155
182
|
const events = await readTrace();
|
|
156
183
|
if (events.length === 0) {
|
|
157
184
|
console.log("No trace data found. Run a loop first.");
|
|
@@ -168,15 +195,192 @@ async function runStats() {
|
|
|
168
195
|
console.log(`Last stop reason: ${lastStop.reason || "unknown"}`);
|
|
169
196
|
console.log(`Last stop at: ${lastStop.ts || "unknown"}`);
|
|
170
197
|
}
|
|
198
|
+
|
|
199
|
+
const costEvents = events.filter(e => e.event === "cost" || e.totalCost);
|
|
200
|
+
if (costEvents.length > 0) {
|
|
201
|
+
const totalCost = costEvents.reduce((s, e) => s + (e.totalCost || 0), 0);
|
|
202
|
+
console.log(`Estimated cost: ${formatCost(totalCost)}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
171
205
|
console.log(`Trace file: .loophaus/trace.jsonl (${events.length} events)`);
|
|
172
206
|
}
|
|
173
207
|
|
|
208
|
+
async function runWatch() {
|
|
209
|
+
const { getTracePath } = await import("../core/event-logger.mjs");
|
|
210
|
+
const { watch: fsWatch } = await import("node:fs");
|
|
211
|
+
const { readFile, stat } = await import("node:fs/promises");
|
|
212
|
+
const tracePath = getTracePath();
|
|
213
|
+
|
|
214
|
+
console.log(`Watching ${tracePath}...`);
|
|
215
|
+
console.log("(Ctrl+C to stop)\n");
|
|
216
|
+
|
|
217
|
+
let lastSize = 0;
|
|
218
|
+
try {
|
|
219
|
+
const s = await stat(tracePath);
|
|
220
|
+
lastSize = s.size;
|
|
221
|
+
} catch { /* file doesn't exist yet */ }
|
|
222
|
+
|
|
223
|
+
const COLORS = {
|
|
224
|
+
iteration: "\x1b[36m",
|
|
225
|
+
stop: "\x1b[31m",
|
|
226
|
+
continue: "\x1b[32m",
|
|
227
|
+
error: "\x1b[31m",
|
|
228
|
+
cost: "\x1b[33m",
|
|
229
|
+
state_change: "\x1b[35m",
|
|
230
|
+
verify_script: "\x1b[32m",
|
|
231
|
+
verify_failed: "\x1b[31m",
|
|
232
|
+
story_complete: "\x1b[32m",
|
|
233
|
+
loop_start: "\x1b[36m",
|
|
234
|
+
loop_end: "\x1b[36m",
|
|
235
|
+
};
|
|
236
|
+
const RESET = "\x1b[0m";
|
|
237
|
+
|
|
238
|
+
function printEvent(line) {
|
|
239
|
+
try {
|
|
240
|
+
const e = JSON.parse(line);
|
|
241
|
+
const color = COLORS[e.event] || "";
|
|
242
|
+
const time = e.ts ? new Date(e.ts).toLocaleTimeString() : "";
|
|
243
|
+
const detail = e.iteration ? ` iter=${e.iteration}` : e.reason ? ` reason=${e.reason}` : "";
|
|
244
|
+
console.log(`${color}[${time}] ${e.event}${detail}${RESET}`);
|
|
245
|
+
} catch { /* skip malformed */ }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
const raw = await readFile(tracePath, "utf-8");
|
|
250
|
+
const lines = raw.trim().split("\n").slice(-20);
|
|
251
|
+
for (const line of lines) printEvent(line);
|
|
252
|
+
if (lines.length > 0) console.log("--- live ---\n");
|
|
253
|
+
} catch { /* no file yet */ }
|
|
254
|
+
|
|
255
|
+
const { dirname: pathDirname } = await import("node:path");
|
|
256
|
+
const dir = pathDirname(tracePath);
|
|
257
|
+
try {
|
|
258
|
+
fsWatch(dir, { recursive: false }, async () => {
|
|
259
|
+
try {
|
|
260
|
+
const s = await stat(tracePath);
|
|
261
|
+
if (s.size > lastSize) {
|
|
262
|
+
const raw = await readFile(tracePath, "utf-8");
|
|
263
|
+
const lines = raw.trim().split("\n");
|
|
264
|
+
const newLines = [];
|
|
265
|
+
let pos = 0;
|
|
266
|
+
for (const line of lines) {
|
|
267
|
+
pos += Buffer.byteLength(line + "\n");
|
|
268
|
+
if (pos > lastSize) newLines.push(line);
|
|
269
|
+
}
|
|
270
|
+
for (const line of newLines) printEvent(line);
|
|
271
|
+
lastSize = s.size;
|
|
272
|
+
}
|
|
273
|
+
} catch { /* read error */ }
|
|
274
|
+
});
|
|
275
|
+
} catch {
|
|
276
|
+
console.log("Cannot watch file. Make sure .loophaus/ directory exists.");
|
|
277
|
+
process.exit(1);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
process.stdin.resume();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function runReplay() {
|
|
284
|
+
const file = args[1];
|
|
285
|
+
if (!file) {
|
|
286
|
+
console.log("Usage: loophaus replay <trace-file> [--speed 2]");
|
|
287
|
+
process.exit(1);
|
|
288
|
+
}
|
|
289
|
+
const speedRaw = getFlag("--speed") || "1";
|
|
290
|
+
const speed = speedRaw === "instant" ? 999999 : (parseFloat(speedRaw) || 1);
|
|
291
|
+
const speedLabel = speed >= 999999 ? "instant" : `${speed}x`;
|
|
292
|
+
|
|
293
|
+
const { readTrace } = await import("../core/event-logger.mjs");
|
|
294
|
+
const { replayTrace, analyzeTrace } = await import("../core/trace-analyzer.mjs");
|
|
295
|
+
|
|
296
|
+
let events;
|
|
297
|
+
if (file === ".loophaus/trace.jsonl" || file === "trace.jsonl") {
|
|
298
|
+
events = await readTrace();
|
|
299
|
+
} else {
|
|
300
|
+
const { readFile } = await import("node:fs/promises");
|
|
301
|
+
const raw = await readFile(file, "utf-8");
|
|
302
|
+
events = raw.trim().split("\n").map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (events.length === 0) { console.log("No events found."); return; }
|
|
306
|
+
|
|
307
|
+
const replayed = replayTrace(events, speed);
|
|
308
|
+
const analysis = analyzeTrace(events);
|
|
309
|
+
|
|
310
|
+
console.log(`Replaying ${events.length} events (${speedLabel})\n`);
|
|
311
|
+
|
|
312
|
+
const COLORS = { iteration: "\x1b[36m", stop: "\x1b[31m", continue: "\x1b[32m", error: "\x1b[31m", cost: "\x1b[33m", state_change: "\x1b[35m", verify_script: "\x1b[32m", verify_failed: "\x1b[31m", story_complete: "\x1b[32m", loop_start: "\x1b[36m", loop_end: "\x1b[36m" };
|
|
313
|
+
const RESET = "\x1b[0m";
|
|
314
|
+
|
|
315
|
+
let prevMs = 0;
|
|
316
|
+
for (const e of replayed) {
|
|
317
|
+
const delay = speed >= 999999 ? 0 : e.relativeMs - prevMs;
|
|
318
|
+
if (delay > 0) await new Promise(r => setTimeout(r, delay));
|
|
319
|
+
prevMs = e.relativeMs;
|
|
320
|
+
|
|
321
|
+
const color = COLORS[e.event] || "";
|
|
322
|
+
const time = e.ts ? new Date(e.ts).toLocaleTimeString() : "";
|
|
323
|
+
const detail = e.iteration ? ` iter=${e.iteration}` : e.reason ? ` reason=${e.reason}` : "";
|
|
324
|
+
console.log(`${color}[${time}] ${e.event}${detail}${RESET}`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
console.log(`\n--- Summary ---`);
|
|
328
|
+
console.log(`Iterations: ${analysis.iterations}`);
|
|
329
|
+
console.log(`Duration: ${Math.round(analysis.durationMs / 1000)}s`);
|
|
330
|
+
if (analysis.totalCost > 0) console.log(`Cost: $${analysis.totalCost.toFixed(4)}`);
|
|
331
|
+
if (analysis.lastStopReason) console.log(`Stop reason: ${analysis.lastStopReason}`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function runCompare() {
|
|
335
|
+
const file1 = args[1];
|
|
336
|
+
const file2 = args[2];
|
|
337
|
+
if (!file1 || !file2) {
|
|
338
|
+
console.log("Usage: loophaus compare <trace1> <trace2>");
|
|
339
|
+
process.exit(1);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const { readFile } = await import("node:fs/promises");
|
|
343
|
+
const { compareTraces } = await import("../core/trace-analyzer.mjs");
|
|
344
|
+
|
|
345
|
+
function loadTrace(file) {
|
|
346
|
+
return readFile(file, "utf-8").then(raw =>
|
|
347
|
+
raw.trim().split("\n").map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean)
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const [t1, t2] = await Promise.all([loadTrace(file1), loadTrace(file2)]);
|
|
352
|
+
const result = compareTraces(t1, t2);
|
|
353
|
+
|
|
354
|
+
console.log("Loop Comparison");
|
|
355
|
+
console.log("═══════════════\n");
|
|
356
|
+
|
|
357
|
+
const fmt = (label, v1, v2, diff, unit = "") => {
|
|
358
|
+
const arrow = diff > 0 ? `+${diff}` : `${diff}`;
|
|
359
|
+
const color = diff > 0 ? "\x1b[31m" : diff < 0 ? "\x1b[32m" : "";
|
|
360
|
+
const reset = "\x1b[0m";
|
|
361
|
+
console.log(` ${label.padEnd(20)} ${String(v1).padStart(8)}${unit} vs ${String(v2).padStart(8)}${unit} ${color}(${arrow}${unit})${reset}`);
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
fmt("Iterations", result.trace1.iterations, result.trace2.iterations, result.diff.iterations);
|
|
365
|
+
fmt("Duration", Math.round(result.trace1.durationMs / 1000), Math.round(result.trace2.durationMs / 1000), Math.round(result.diff.durationMs / 1000), "s");
|
|
366
|
+
fmt("Stories done", result.trace1.storiesCompleted, result.trace2.storiesCompleted, result.diff.storiesCompleted);
|
|
367
|
+
if (result.trace1.totalCost || result.trace2.totalCost) {
|
|
368
|
+
fmt("Cost", result.trace1.totalCost.toFixed(4), result.trace2.totalCost.toFixed(4), result.diff.totalCost.toFixed(4), "$");
|
|
369
|
+
}
|
|
370
|
+
fmt("Errors", result.trace1.errors, result.trace2.errors, result.diff.errors);
|
|
371
|
+
console.log("");
|
|
372
|
+
}
|
|
373
|
+
|
|
174
374
|
try {
|
|
175
375
|
switch (command) {
|
|
176
376
|
case "install": await runInstall(); break;
|
|
177
377
|
case "uninstall": await runUninstall(); break;
|
|
178
378
|
case "status": await runStatus(); break;
|
|
179
379
|
case "stats": await runStats(); break;
|
|
380
|
+
case "loops": await runLoops(); break;
|
|
381
|
+
case "watch": await runWatch(); break;
|
|
382
|
+
case "replay": await runReplay(); break;
|
|
383
|
+
case "compare": await runCompare(); break;
|
|
180
384
|
default:
|
|
181
385
|
if (command.startsWith("-")) {
|
|
182
386
|
await runInstall();
|
package/bin/uninstall.mjs
CHANGED
|
@@ -17,7 +17,7 @@ To cancel the Ralph loop:
|
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
19
|
node -e "
|
|
20
|
-
import { readState, writeState } from '${RALPH_CODEX_ROOT}/
|
|
20
|
+
import { readState, writeState } from '${RALPH_CODEX_ROOT}/store/state-store.mjs';
|
|
21
21
|
const state = await readState();
|
|
22
22
|
const iter = state.currentIteration;
|
|
23
23
|
state.active = false;
|
|
@@ -20,7 +20,7 @@ Run this command to initialize the Ralph loop state file:
|
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
22
|
node -e "
|
|
23
|
-
import { writeState } from '${RALPH_CODEX_ROOT}/
|
|
23
|
+
import { writeState } from '${RALPH_CODEX_ROOT}/store/state-store.mjs';
|
|
24
24
|
await writeState({
|
|
25
25
|
active: true,
|
|
26
26
|
prompt: PROMPT_HERE,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// core/cost-tracker.mjs — Token cost estimation. Prices in USD per 1M tokens.
|
|
2
|
+
|
|
3
|
+
const MODEL_PRICES = {
|
|
4
|
+
"claude-opus-4": { input: 15, output: 75 },
|
|
5
|
+
"claude-sonnet-4": { input: 3, output: 15 },
|
|
6
|
+
"claude-haiku-4": { input: 0.8, output: 4 },
|
|
7
|
+
"gpt-4.1": { input: 2, output: 8 },
|
|
8
|
+
"gpt-4.1-mini": { input: 0.4, output: 1.6 },
|
|
9
|
+
"gpt-4o": { input: 2.5, output: 10 },
|
|
10
|
+
"o4-mini": { input: 1.1, output: 4.4 },
|
|
11
|
+
"default": { input: 3, output: 15 },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function estimateCost(model, inputTokens, outputTokens) {
|
|
15
|
+
const prices = MODEL_PRICES[model] || MODEL_PRICES["default"];
|
|
16
|
+
const inputCost = (inputTokens / 1_000_000) * prices.input;
|
|
17
|
+
const outputCost = (outputTokens / 1_000_000) * prices.output;
|
|
18
|
+
return { inputCost, outputCost, totalCost: inputCost + outputCost };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function formatCost(cost) {
|
|
22
|
+
if (cost < 0.01) return `$${(cost * 100).toFixed(2)}¢`;
|
|
23
|
+
return `$${cost.toFixed(4)}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createTracker() {
|
|
27
|
+
const records = [];
|
|
28
|
+
return {
|
|
29
|
+
record(label, model, inputTokens, outputTokens) {
|
|
30
|
+
const cost = estimateCost(model, inputTokens, outputTokens);
|
|
31
|
+
records.push({ label, model, inputTokens, outputTokens, ...cost, ts: new Date().toISOString() });
|
|
32
|
+
return cost;
|
|
33
|
+
},
|
|
34
|
+
summary() {
|
|
35
|
+
const totalInput = records.reduce((s, r) => s + r.inputTokens, 0);
|
|
36
|
+
const totalOutput = records.reduce((s, r) => s + r.outputTokens, 0);
|
|
37
|
+
const totalCost = records.reduce((s, r) => s + r.totalCost, 0);
|
|
38
|
+
return { totalInput, totalOutput, totalCost, records: records.length };
|
|
39
|
+
},
|
|
40
|
+
getRecords() { return [...records]; },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export { MODEL_PRICES };
|
package/core/engine.mjs
CHANGED
|
@@ -41,6 +41,22 @@ export function evaluateStopHook(input, state) {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// Check verify script result (pre-computed by caller)
|
|
45
|
+
if (nextState.verifyScript && input.verify_result) {
|
|
46
|
+
if (input.verify_result.passed) {
|
|
47
|
+
nextState.active = false;
|
|
48
|
+
events.push({ event: "stop", reason: "verify_script", script: nextState.verifyScript });
|
|
49
|
+
return {
|
|
50
|
+
decision: "allow",
|
|
51
|
+
nextState,
|
|
52
|
+
events,
|
|
53
|
+
output: null,
|
|
54
|
+
message: `Loop: verify script passed.`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
events.push({ event: "verify_failed", script: nextState.verifyScript, output: input.verify_result.output || "" });
|
|
58
|
+
}
|
|
59
|
+
|
|
44
60
|
if (input.stop_hook_active === true) {
|
|
45
61
|
if (!input.has_pending_stories) {
|
|
46
62
|
nextState.active = false;
|
package/core/events.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Event type constants and filter utilities
|
|
2
|
+
|
|
3
|
+
export const EventType = {
|
|
4
|
+
ITERATION: "iteration",
|
|
5
|
+
STOP: "stop",
|
|
6
|
+
CONTINUE: "continue",
|
|
7
|
+
DECISION: "decision",
|
|
8
|
+
STORY_COMPLETE: "story_complete",
|
|
9
|
+
TEST_RESULT: "test_result",
|
|
10
|
+
COST: "cost",
|
|
11
|
+
VERIFY_SCRIPT: "verify_script",
|
|
12
|
+
VERIFY_FAILED: "verify_failed",
|
|
13
|
+
LOOP_START: "loop_start",
|
|
14
|
+
LOOP_END: "loop_end",
|
|
15
|
+
CHECKPOINT: "checkpoint",
|
|
16
|
+
ERROR: "error",
|
|
17
|
+
STATE_CHANGE: "state_change",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function filterByType(events, type) {
|
|
21
|
+
return events.filter(e => e.event === type);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function filterByTimeRange(events, start, end) {
|
|
25
|
+
return events.filter(e => {
|
|
26
|
+
const ts = new Date(e.ts).getTime();
|
|
27
|
+
return ts >= start.getTime() && ts <= end.getTime();
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function filterByLoopId(events, loopId) {
|
|
32
|
+
return events.filter(e => e.loop_id === loopId);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function summarizeEvents(events) {
|
|
36
|
+
const counts = {};
|
|
37
|
+
for (const e of events) {
|
|
38
|
+
counts[e.event] = (counts[e.event] || 0) + 1;
|
|
39
|
+
}
|
|
40
|
+
const first = events[0];
|
|
41
|
+
const last = events[events.length - 1];
|
|
42
|
+
const durationMs = first && last ? new Date(last.ts).getTime() - new Date(first.ts).getTime() : 0;
|
|
43
|
+
return { counts, total: events.length, durationMs, firstTs: first?.ts, lastTs: last?.ts };
|
|
44
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export async function getLastAssistantText(transcriptPath) {
|
|
5
|
+
if (!transcriptPath) return "";
|
|
6
|
+
try {
|
|
7
|
+
const raw = await readFile(transcriptPath, "utf-8");
|
|
8
|
+
const lines = raw.trim().split("\n");
|
|
9
|
+
const recent = lines.filter((line) => {
|
|
10
|
+
try { return JSON.parse(line).role === "assistant"; } catch { return false; }
|
|
11
|
+
}).slice(-100);
|
|
12
|
+
for (let i = recent.length - 1; i >= 0; i--) {
|
|
13
|
+
try {
|
|
14
|
+
const obj = JSON.parse(recent[i]);
|
|
15
|
+
const contents = obj.message?.content || obj.content;
|
|
16
|
+
if (Array.isArray(contents)) {
|
|
17
|
+
for (let j = contents.length - 1; j >= 0; j--) {
|
|
18
|
+
if (contents[j].type === "text" && contents[j].text) return contents[j].text;
|
|
19
|
+
}
|
|
20
|
+
} else if (typeof contents === "string") return contents;
|
|
21
|
+
} catch { /* skip */ }
|
|
22
|
+
}
|
|
23
|
+
} catch { /* not found */ }
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function hasPendingStories(cwd) {
|
|
28
|
+
try {
|
|
29
|
+
const raw = await readFile(join(cwd || process.cwd(), "prd.json"), "utf-8");
|
|
30
|
+
const prd = JSON.parse(raw);
|
|
31
|
+
return Array.isArray(prd.userStories) && prd.userStories.some((s) => s.passes === false);
|
|
32
|
+
} catch { return false; }
|
|
33
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// core/loop-registry.mjs — Multi-loop registry for named loop instances
|
|
2
|
+
|
|
3
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
export async function listLoops(cwd) {
|
|
7
|
+
const loopsDir = join(cwd || process.cwd(), ".loophaus", "loops");
|
|
8
|
+
const loops = [];
|
|
9
|
+
try {
|
|
10
|
+
const entries = await readdir(loopsDir, { withFileTypes: true });
|
|
11
|
+
for (const entry of entries) {
|
|
12
|
+
if (!entry.isDirectory()) continue;
|
|
13
|
+
const statePath = join(loopsDir, entry.name, "state.json");
|
|
14
|
+
try {
|
|
15
|
+
const raw = await readFile(statePath, "utf-8");
|
|
16
|
+
const state = JSON.parse(raw);
|
|
17
|
+
loops.push({ name: entry.name, ...state });
|
|
18
|
+
} catch {
|
|
19
|
+
loops.push({ name: entry.name, active: false, error: "unreadable" });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
} catch { /* no loops dir */ }
|
|
23
|
+
|
|
24
|
+
const defaultPath = join(cwd || process.cwd(), ".loophaus", "state.json");
|
|
25
|
+
try {
|
|
26
|
+
const raw = await readFile(defaultPath, "utf-8");
|
|
27
|
+
const state = JSON.parse(raw);
|
|
28
|
+
loops.unshift({ name: "(default)", ...state });
|
|
29
|
+
} catch { /* no default */ }
|
|
30
|
+
|
|
31
|
+
return loops;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function getLoop(name, cwd) {
|
|
35
|
+
const loops = await listLoops(cwd);
|
|
36
|
+
return loops.find(l => l.name === name) || null;
|
|
37
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"title": "Loophaus State",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"required": ["active", "prompt", "maxIterations", "currentIteration"],
|
|
6
|
+
"properties": {
|
|
7
|
+
"active": { "type": "boolean" },
|
|
8
|
+
"prompt": { "type": "string" },
|
|
9
|
+
"completionPromise": { "type": "string" },
|
|
10
|
+
"maxIterations": { "type": "integer", "minimum": 0 },
|
|
11
|
+
"currentIteration": { "type": "integer", "minimum": 0 },
|
|
12
|
+
"sessionId": { "type": "string" },
|
|
13
|
+
"name": { "type": "string", "description": "Loop name for multi-loop support" },
|
|
14
|
+
"verifyScript": { "type": "string", "description": "Path to verification script" },
|
|
15
|
+
"startedAt": { "type": "string", "format": "date-time" },
|
|
16
|
+
"cost": {
|
|
17
|
+
"type": "object",
|
|
18
|
+
"properties": {
|
|
19
|
+
"totalTokens": { "type": "integer" },
|
|
20
|
+
"estimatedCost": { "type": "number" }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Pure functions for trace analysis
|
|
2
|
+
|
|
3
|
+
import { EventType, filterByType, summarizeEvents } from "./events.mjs";
|
|
4
|
+
|
|
5
|
+
export function analyzeTrace(events) {
|
|
6
|
+
const summary = summarizeEvents(events);
|
|
7
|
+
const iterations = filterByType(events, EventType.ITERATION);
|
|
8
|
+
const stops = filterByType(events, EventType.STOP);
|
|
9
|
+
const errors = filterByType(events, EventType.ERROR);
|
|
10
|
+
const stories = filterByType(events, EventType.STORY_COMPLETE);
|
|
11
|
+
const costs = filterByType(events, EventType.COST);
|
|
12
|
+
|
|
13
|
+
const totalCost = costs.reduce((s, e) => s + (e.totalCost || 0), 0);
|
|
14
|
+
const lastStop = stops[stops.length - 1];
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
...summary,
|
|
18
|
+
iterations: iterations.length,
|
|
19
|
+
stops: stops.length,
|
|
20
|
+
errors: errors.length,
|
|
21
|
+
storiesCompleted: stories.length,
|
|
22
|
+
totalCost,
|
|
23
|
+
lastStopReason: lastStop?.reason || null,
|
|
24
|
+
avgIterationMs: iterations.length > 1 ? summary.durationMs / iterations.length : 0,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function compareTraces(trace1, trace2) {
|
|
29
|
+
const a1 = analyzeTrace(trace1);
|
|
30
|
+
const a2 = analyzeTrace(trace2);
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
trace1: a1,
|
|
34
|
+
trace2: a2,
|
|
35
|
+
diff: {
|
|
36
|
+
iterations: a2.iterations - a1.iterations,
|
|
37
|
+
totalCost: a2.totalCost - a1.totalCost,
|
|
38
|
+
durationMs: a2.durationMs - a1.durationMs,
|
|
39
|
+
storiesCompleted: a2.storiesCompleted - a1.storiesCompleted,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function replayTrace(events, speed = 1) {
|
|
45
|
+
if (events.length === 0) return [];
|
|
46
|
+
const firstTs = new Date(events[0].ts).getTime();
|
|
47
|
+
return events.map(e => ({
|
|
48
|
+
...e,
|
|
49
|
+
relativeMs: Math.round((new Date(e.ts).getTime() - firstTs) / speed),
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// core/validate.mjs — Zero-dep runtime schema validation
|
|
2
|
+
|
|
3
|
+
const STATE_REQUIRED = {
|
|
4
|
+
active: "boolean",
|
|
5
|
+
prompt: "string",
|
|
6
|
+
maxIterations: "number",
|
|
7
|
+
currentIteration: "number",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const STATE_OPTIONAL = {
|
|
11
|
+
completionPromise: "string",
|
|
12
|
+
sessionId: "string",
|
|
13
|
+
name: "string",
|
|
14
|
+
verifyScript: "string",
|
|
15
|
+
startedAt: "string",
|
|
16
|
+
cost: "object",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function validateState(obj) {
|
|
20
|
+
const errors = [];
|
|
21
|
+
if (typeof obj !== "object" || obj === null) {
|
|
22
|
+
return { valid: false, errors: ["State must be an object"] };
|
|
23
|
+
}
|
|
24
|
+
for (const [key, type] of Object.entries(STATE_REQUIRED)) {
|
|
25
|
+
if (!(key in obj)) errors.push(`Missing required field: ${key}`);
|
|
26
|
+
else if (typeof obj[key] !== type) errors.push(`${key} must be ${type}, got ${typeof obj[key]}`);
|
|
27
|
+
}
|
|
28
|
+
for (const [key, type] of Object.entries(STATE_OPTIONAL)) {
|
|
29
|
+
if (key in obj && obj[key] !== undefined && obj[key] !== null && typeof obj[key] !== type) {
|
|
30
|
+
errors.push(`${key} must be ${type}, got ${typeof obj[key]}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (typeof obj.maxIterations === "number" && obj.maxIterations < 0) {
|
|
34
|
+
errors.push("maxIterations must be >= 0");
|
|
35
|
+
}
|
|
36
|
+
if (typeof obj.currentIteration === "number" && obj.currentIteration < 0) {
|
|
37
|
+
errors.push("currentIteration must be >= 0");
|
|
38
|
+
}
|
|
39
|
+
return { valid: errors.length === 0, errors };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function validateLoopConfig(obj) {
|
|
43
|
+
const errors = [];
|
|
44
|
+
if (typeof obj !== "object" || obj === null) {
|
|
45
|
+
return { valid: false, errors: ["Config must be an object"] };
|
|
46
|
+
}
|
|
47
|
+
if (obj.protocol_version && obj.protocol_version !== "1.0") {
|
|
48
|
+
errors.push(`Unsupported protocol version: ${obj.protocol_version}`);
|
|
49
|
+
}
|
|
50
|
+
return { valid: errors.length === 0, errors };
|
|
51
|
+
}
|
package/hooks/stop-hook.mjs
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { readFile } from "node:fs/promises";
|
|
4
|
-
import { join } from "node:path";
|
|
5
3
|
import { evaluateStopHook } from "../core/engine.mjs";
|
|
4
|
+
import { getLastAssistantText, hasPendingStories } from "../core/io-helpers.mjs";
|
|
6
5
|
import { read as readState, write as writeState } from "../store/state-store.mjs";
|
|
7
6
|
import { logEvents } from "../core/event-logger.mjs";
|
|
8
7
|
|
|
@@ -12,37 +11,6 @@ async function readStdin() {
|
|
|
12
11
|
return Buffer.concat(chunks).toString("utf-8");
|
|
13
12
|
}
|
|
14
13
|
|
|
15
|
-
async function getLastAssistantText(transcriptPath) {
|
|
16
|
-
if (!transcriptPath) return "";
|
|
17
|
-
try {
|
|
18
|
-
const raw = await readFile(transcriptPath, "utf-8");
|
|
19
|
-
const lines = raw.trim().split("\n");
|
|
20
|
-
const recent = lines.filter((line) => {
|
|
21
|
-
try { return JSON.parse(line).role === "assistant"; } catch { return false; }
|
|
22
|
-
}).slice(-100);
|
|
23
|
-
for (let i = recent.length - 1; i >= 0; i--) {
|
|
24
|
-
try {
|
|
25
|
-
const obj = JSON.parse(recent[i]);
|
|
26
|
-
const contents = obj.message?.content || obj.content;
|
|
27
|
-
if (Array.isArray(contents)) {
|
|
28
|
-
for (let j = contents.length - 1; j >= 0; j--) {
|
|
29
|
-
if (contents[j].type === "text" && contents[j].text) return contents[j].text;
|
|
30
|
-
}
|
|
31
|
-
} else if (typeof contents === "string") return contents;
|
|
32
|
-
} catch { /* skip */ }
|
|
33
|
-
}
|
|
34
|
-
} catch { /* not found */ }
|
|
35
|
-
return "";
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
async function hasPendingStories(cwd) {
|
|
39
|
-
try {
|
|
40
|
-
const raw = await readFile(join(cwd || process.cwd(), "prd.json"), "utf-8");
|
|
41
|
-
const prd = JSON.parse(raw);
|
|
42
|
-
return Array.isArray(prd.userStories) && prd.userStories.some((s) => s.passes === false);
|
|
43
|
-
} catch { return false; }
|
|
44
|
-
}
|
|
45
|
-
|
|
46
14
|
async function main() {
|
|
47
15
|
let hookInput = {};
|
|
48
16
|
try {
|
|
@@ -57,10 +25,25 @@ async function main() {
|
|
|
57
25
|
await getLastAssistantText(hookInput.transcript_path || null);
|
|
58
26
|
const pending = await hasPendingStories(cwd);
|
|
59
27
|
|
|
28
|
+
// Run verify script if configured
|
|
29
|
+
let verifyResult = null;
|
|
30
|
+
if (state.verifyScript) {
|
|
31
|
+
try {
|
|
32
|
+
const { execFile } = await import("node:child_process");
|
|
33
|
+
const { promisify } = await import("node:util");
|
|
34
|
+
const execFileAsync = promisify(execFile);
|
|
35
|
+
const { stdout: vOut } = await execFileAsync(state.verifyScript, [], { cwd, timeout: 30_000 });
|
|
36
|
+
verifyResult = { passed: true, output: vOut.trim() };
|
|
37
|
+
} catch (err) {
|
|
38
|
+
verifyResult = { passed: false, output: err.stderr || err.message };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
60
42
|
const input = {
|
|
61
43
|
...hookInput,
|
|
62
44
|
last_assistant_text: lastText,
|
|
63
45
|
has_pending_stories: pending,
|
|
46
|
+
verify_result: verifyResult,
|
|
64
47
|
};
|
|
65
48
|
|
|
66
49
|
const result = evaluateStopHook(input, state);
|
package/lib/stop-hook-core.mjs
CHANGED
|
@@ -1,162 +1,42 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { evaluateStopHook, extractPromise } from "../core/engine.mjs";
|
|
2
|
+
import { getLastAssistantText, hasPendingStories } from "../core/io-helpers.mjs";
|
|
3
3
|
|
|
4
|
-
export
|
|
5
|
-
const regex = new RegExp(
|
|
6
|
-
`<promise>\\s*${escapeRegex(promisePhrase)}\\s*</promise>`,
|
|
7
|
-
"s",
|
|
8
|
-
);
|
|
9
|
-
return regex.test(text);
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function escapeRegex(str) {
|
|
13
|
-
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export async function getLastAssistantText(transcriptPath) {
|
|
17
|
-
if (!transcriptPath) return "";
|
|
18
|
-
try {
|
|
19
|
-
const raw = await readFile(transcriptPath, "utf-8");
|
|
20
|
-
const lines = raw.trim().split("\n");
|
|
21
|
-
const assistantLines = lines.filter((line) => {
|
|
22
|
-
try {
|
|
23
|
-
const obj = JSON.parse(line);
|
|
24
|
-
return obj.role === "assistant";
|
|
25
|
-
} catch {
|
|
26
|
-
return false;
|
|
27
|
-
}
|
|
28
|
-
});
|
|
29
|
-
const recent = assistantLines.slice(-100);
|
|
30
|
-
for (let i = recent.length - 1; i >= 0; i--) {
|
|
31
|
-
try {
|
|
32
|
-
const obj = JSON.parse(recent[i]);
|
|
33
|
-
const contents = obj.message?.content || obj.content;
|
|
34
|
-
if (Array.isArray(contents)) {
|
|
35
|
-
for (let j = contents.length - 1; j >= 0; j--) {
|
|
36
|
-
if (contents[j].type === "text" && contents[j].text) {
|
|
37
|
-
return contents[j].text;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
} else if (typeof contents === "string") {
|
|
41
|
-
return contents;
|
|
42
|
-
}
|
|
43
|
-
} catch {
|
|
44
|
-
// skip malformed lines
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
} catch {
|
|
48
|
-
// transcript not found or unreadable
|
|
49
|
-
}
|
|
50
|
-
return "";
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export async function hasPendingStories(cwd) {
|
|
54
|
-
const prdPath = join(cwd || process.cwd(), "prd.json");
|
|
55
|
-
try {
|
|
56
|
-
const raw = await readFile(prdPath, "utf-8");
|
|
57
|
-
const prd = JSON.parse(raw);
|
|
58
|
-
if (!Array.isArray(prd.userStories)) return false;
|
|
59
|
-
return prd.userStories.some((s) => s.passes === false);
|
|
60
|
-
} catch {
|
|
61
|
-
return false;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
4
|
+
export { extractPromise, getLastAssistantText, hasPendingStories };
|
|
64
5
|
|
|
65
6
|
export async function processStopHook(hookInput, readStateFn, writeStateFn) {
|
|
66
|
-
const stderr = [];
|
|
67
7
|
const state = await readStateFn();
|
|
68
8
|
|
|
69
|
-
// Not active — allow exit
|
|
70
9
|
if (!state.active) {
|
|
71
10
|
return { exitCode: 0, stdout: "", stderr: "" };
|
|
72
11
|
}
|
|
73
12
|
|
|
74
|
-
|
|
75
|
-
if (
|
|
76
|
-
state.sessionId &&
|
|
77
|
-
hookInput.session_id &&
|
|
78
|
-
state.sessionId !== hookInput.session_id
|
|
79
|
-
) {
|
|
13
|
+
if (state.sessionId && hookInput.session_id && state.sessionId !== hookInput.session_id) {
|
|
80
14
|
return { exitCode: 0, stdout: "", stderr: "" };
|
|
81
15
|
}
|
|
82
16
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
// Check max iterations
|
|
87
|
-
if (state.maxIterations > 0 && state.currentIteration > state.maxIterations) {
|
|
88
|
-
stderr.push(
|
|
89
|
-
`Loop: max iterations (${state.maxIterations}) reached.\n`,
|
|
90
|
-
);
|
|
91
|
-
state.active = false;
|
|
92
|
-
await writeStateFn(state);
|
|
93
|
-
return { exitCode: 0, stdout: "", stderr: stderr.join("") };
|
|
94
|
-
}
|
|
17
|
+
const lastText = hookInput.last_assistant_message ||
|
|
18
|
+
await getLastAssistantText(hookInput.transcript_path || null);
|
|
19
|
+
const pending = await hasPendingStories(hookInput.cwd || process.cwd());
|
|
95
20
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
21
|
+
const input = {
|
|
22
|
+
...hookInput,
|
|
23
|
+
last_assistant_text: lastText,
|
|
24
|
+
has_pending_stories: pending,
|
|
25
|
+
};
|
|
101
26
|
|
|
102
|
-
|
|
103
|
-
stderr.push(
|
|
104
|
-
`Loop: completion promise "${state.completionPromise}" detected.\n`,
|
|
105
|
-
);
|
|
106
|
-
state.active = false;
|
|
107
|
-
await writeStateFn(state);
|
|
108
|
-
return { exitCode: 0, stdout: "", stderr: stderr.join("") };
|
|
109
|
-
}
|
|
110
|
-
}
|
|
27
|
+
const result = evaluateStopHook(input, state);
|
|
111
28
|
|
|
112
|
-
|
|
113
|
-
// When true, the agent already continued from a previous block.
|
|
114
|
-
// Check prd.json for pending stories — only block if work remains.
|
|
115
|
-
if (hookInput.stop_hook_active === true) {
|
|
116
|
-
const cwd = hookInput.cwd || process.cwd();
|
|
117
|
-
const pending = await hasPendingStories(cwd);
|
|
29
|
+
await writeStateFn(result.nextState);
|
|
118
30
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
await writeStateFn(state);
|
|
125
|
-
return { exitCode: 0, stdout: "", stderr: stderr.join("") };
|
|
31
|
+
const stderrParts = [];
|
|
32
|
+
if (result.message) stderrParts.push(result.message);
|
|
33
|
+
for (const ev of result.events || []) {
|
|
34
|
+
if (ev.event === "continue" && ev.reason === "pending_stories") {
|
|
35
|
+
stderrParts.push("Loop: stop_hook_active=true, pending stories found. Continuing.");
|
|
126
36
|
}
|
|
127
|
-
|
|
128
|
-
stderr.push(
|
|
129
|
-
"Loop: stop_hook_active=true, pending stories found. Continuing.\n",
|
|
130
|
-
);
|
|
131
37
|
}
|
|
38
|
+
const stderr = stderrParts.length ? stderrParts.join("\n") + "\n" : "";
|
|
39
|
+
const stdout = result.output ? JSON.stringify(result.output) : "";
|
|
132
40
|
|
|
133
|
-
|
|
134
|
-
await writeStateFn(state);
|
|
135
|
-
|
|
136
|
-
// Build continuation prompt
|
|
137
|
-
const iterInfo =
|
|
138
|
-
state.maxIterations > 0
|
|
139
|
-
? `${state.currentIteration}/${state.maxIterations}`
|
|
140
|
-
: `${state.currentIteration}`;
|
|
141
|
-
|
|
142
|
-
const reason = [
|
|
143
|
-
state.prompt,
|
|
144
|
-
"",
|
|
145
|
-
"---",
|
|
146
|
-
`Loop iteration ${iterInfo}. Continue working on the task above.`,
|
|
147
|
-
].join("\n");
|
|
148
|
-
|
|
149
|
-
const output = { decision: "block", reason };
|
|
150
|
-
|
|
151
|
-
if (state.completionPromise) {
|
|
152
|
-
output.systemMessage = `Loop iteration ${iterInfo} | To stop: output <promise>${state.completionPromise}</promise> (ONLY when TRUE)`;
|
|
153
|
-
} else {
|
|
154
|
-
output.systemMessage = `Loop iteration ${iterInfo} | No completion promise — loop runs until max iterations`;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
return {
|
|
158
|
-
exitCode: 0,
|
|
159
|
-
stdout: JSON.stringify(output),
|
|
160
|
-
stderr: stderr.join(""),
|
|
161
|
-
};
|
|
41
|
+
return { exitCode: 0, stdout, stderr };
|
|
162
42
|
}
|
package/package.json
CHANGED
package/store/state-store.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile, writeFile, mkdir, rename } from "node:fs/promises";
|
|
2
2
|
import { join, dirname } from "node:path";
|
|
3
|
+
import { validateState } from "../core/validate.mjs";
|
|
3
4
|
|
|
4
5
|
const DEFAULT_STATE = {
|
|
5
6
|
active: false,
|
|
@@ -10,10 +11,12 @@ const DEFAULT_STATE = {
|
|
|
10
11
|
sessionId: "",
|
|
11
12
|
};
|
|
12
13
|
|
|
13
|
-
export function getStatePath(cwd) {
|
|
14
|
+
export function getStatePath(cwd, name) {
|
|
14
15
|
if (process.env.LOOPHAUS_STATE_FILE) return process.env.LOOPHAUS_STATE_FILE;
|
|
15
16
|
if (process.env.RALPH_STATE_FILE) return process.env.RALPH_STATE_FILE;
|
|
16
|
-
|
|
17
|
+
const base = cwd || process.cwd();
|
|
18
|
+
if (name) return join(base, ".loophaus", "loops", name, "state.json");
|
|
19
|
+
return join(base, ".loophaus", "state.json");
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
const LEGACY_PATHS = [
|
|
@@ -21,12 +24,17 @@ const LEGACY_PATHS = [
|
|
|
21
24
|
(cwd) => join(cwd, ".claude", "ralph-loop.local.md"),
|
|
22
25
|
];
|
|
23
26
|
|
|
24
|
-
export async function read(cwd) {
|
|
25
|
-
const primary = getStatePath(cwd);
|
|
27
|
+
export async function read(cwd, name) {
|
|
28
|
+
const primary = getStatePath(cwd, name);
|
|
26
29
|
|
|
27
30
|
try {
|
|
28
31
|
const raw = await readFile(primary, "utf-8");
|
|
29
|
-
|
|
32
|
+
const state = { ...DEFAULT_STATE, ...JSON.parse(raw) };
|
|
33
|
+
const result = validateState(state);
|
|
34
|
+
if (!result.valid) {
|
|
35
|
+
process.stderr.write(`loophaus: state validation warning: ${result.errors.join(", ")}\n`);
|
|
36
|
+
}
|
|
37
|
+
return state;
|
|
30
38
|
} catch {
|
|
31
39
|
// Primary not found, try legacy paths
|
|
32
40
|
}
|
|
@@ -48,8 +56,12 @@ export async function read(cwd) {
|
|
|
48
56
|
return { ...DEFAULT_STATE };
|
|
49
57
|
}
|
|
50
58
|
|
|
51
|
-
export async function write(state, cwd) {
|
|
52
|
-
const
|
|
59
|
+
export async function write(state, cwd, name) {
|
|
60
|
+
const result = validateState(state);
|
|
61
|
+
if (!result.valid) {
|
|
62
|
+
process.stderr.write(`loophaus: writing invalid state: ${result.errors.join(", ")}\n`);
|
|
63
|
+
}
|
|
64
|
+
const statePath = getStatePath(cwd, name);
|
|
53
65
|
await mkdir(dirname(statePath), { recursive: true });
|
|
54
66
|
const tmp = statePath + ".tmp";
|
|
55
67
|
await writeFile(tmp, JSON.stringify(state, null, 2), "utf-8");
|
|
@@ -77,4 +89,15 @@ function migrateMdFormat(raw) {
|
|
|
77
89
|
return state;
|
|
78
90
|
}
|
|
79
91
|
|
|
92
|
+
// Backward-compatible exports (matching lib/state.mjs interface)
|
|
93
|
+
export async function readState(cwd) { return read(cwd); }
|
|
94
|
+
export async function writeState(state, cwd) { return write(state, cwd); }
|
|
95
|
+
export async function resetState(cwd) { return reset(cwd); }
|
|
96
|
+
export async function incrementIteration(cwd) {
|
|
97
|
+
const state = await read(cwd);
|
|
98
|
+
state.currentIteration += 1;
|
|
99
|
+
await write(state, cwd);
|
|
100
|
+
return state;
|
|
101
|
+
}
|
|
102
|
+
|
|
80
103
|
export { DEFAULT_STATE };
|
package/lib/state.mjs
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
-
import { join, dirname } from "node:path";
|
|
3
|
-
|
|
4
|
-
const DEFAULT_STATE = {
|
|
5
|
-
active: false,
|
|
6
|
-
prompt: "",
|
|
7
|
-
completionPromise: "TADA",
|
|
8
|
-
maxIterations: 20,
|
|
9
|
-
currentIteration: 0,
|
|
10
|
-
sessionId: "",
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
export function getStatePath() {
|
|
14
|
-
return (
|
|
15
|
-
process.env.LOOPHAUS_STATE_FILE ||
|
|
16
|
-
process.env.RALPH_STATE_FILE ||
|
|
17
|
-
join(process.cwd(), ".loophaus", "state.json")
|
|
18
|
-
);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export async function readState() {
|
|
22
|
-
const statePath = getStatePath();
|
|
23
|
-
try {
|
|
24
|
-
const raw = await readFile(statePath, "utf-8");
|
|
25
|
-
return { ...DEFAULT_STATE, ...JSON.parse(raw) };
|
|
26
|
-
} catch {
|
|
27
|
-
return { ...DEFAULT_STATE };
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export async function writeState(state) {
|
|
32
|
-
const statePath = getStatePath();
|
|
33
|
-
await mkdir(dirname(statePath), { recursive: true });
|
|
34
|
-
await writeFile(statePath, JSON.stringify(state, null, 2), "utf-8");
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export async function resetState() {
|
|
38
|
-
await writeState({ ...DEFAULT_STATE });
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export async function incrementIteration() {
|
|
42
|
-
const state = await readState();
|
|
43
|
-
state.currentIteration += 1;
|
|
44
|
-
await writeState(state);
|
|
45
|
-
return state;
|
|
46
|
-
}
|