@graypark/loophaus 3.2.0 → 3.3.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 +26 -0
- package/core/engine.mjs +23 -0
- package/core/policy.mjs +58 -0
- package/core/session.mjs +66 -0
- package/hooks/stop-hook.mjs +49 -0
- package/package.json +1 -1
package/bin/loophaus.mjs
CHANGED
|
@@ -46,6 +46,8 @@ Usage:
|
|
|
46
46
|
npx @graypark/loophaus loops
|
|
47
47
|
npx @graypark/loophaus worktree <create|remove|list>
|
|
48
48
|
npx @graypark/loophaus parallel <prd.json> [--count N] [--base branch]
|
|
49
|
+
npx @graypark/loophaus sessions
|
|
50
|
+
npx @graypark/loophaus resume <session-id>
|
|
49
51
|
npx @graypark/loophaus --version
|
|
50
52
|
|
|
51
53
|
Hosts:
|
|
@@ -408,6 +410,28 @@ async function runWorktree() {
|
|
|
408
410
|
}
|
|
409
411
|
}
|
|
410
412
|
|
|
413
|
+
async function runSessions() {
|
|
414
|
+
const { listSessions } = await import("../core/session.mjs");
|
|
415
|
+
const sessions = await listSessions();
|
|
416
|
+
if (sessions.length === 0) { console.log("No saved sessions."); return; }
|
|
417
|
+
console.log("Sessions");
|
|
418
|
+
console.log("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
419
|
+
for (const s of sessions) {
|
|
420
|
+
const age = Math.round((Date.now() - new Date(s.savedAt).getTime()) / 60000);
|
|
421
|
+
console.log(` ${s.sessionId} iter=${s.currentIteration || 0} ${age}m ago`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function runResume() {
|
|
426
|
+
const id = args[1];
|
|
427
|
+
if (!id) { console.log("Usage: loophaus resume <session-id>"); return; }
|
|
428
|
+
const { resumeSession } = await import("../core/session.mjs");
|
|
429
|
+
const state = await resumeSession(id);
|
|
430
|
+
if (!state) { console.log(`Session not found: ${id}`); return; }
|
|
431
|
+
console.log(`Resumed session ${id} at iteration ${state.currentIteration}`);
|
|
432
|
+
console.log(`Loop is now active. The stop hook will continue from here.`);
|
|
433
|
+
}
|
|
434
|
+
|
|
411
435
|
async function runParallelCmd() {
|
|
412
436
|
const prdPath = args[1] || "prd.json";
|
|
413
437
|
const count = parseInt(getFlag("--count") || "2", 10);
|
|
@@ -436,6 +460,8 @@ try {
|
|
|
436
460
|
case "compare": await runCompare(); break;
|
|
437
461
|
case "worktree": await runWorktree(); break;
|
|
438
462
|
case "parallel": await runParallelCmd(); break;
|
|
463
|
+
case "sessions": await runSessions(); break;
|
|
464
|
+
case "resume": await runResume(); break;
|
|
439
465
|
default:
|
|
440
466
|
if (command.startsWith("-")) {
|
|
441
467
|
await runInstall();
|
package/core/engine.mjs
CHANGED
|
@@ -27,6 +27,19 @@ export function evaluateStopHook(input, state) {
|
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
if (input.policy_result && input.policy_result.shouldStop) {
|
|
31
|
+
nextState.active = false;
|
|
32
|
+
events.push({ event: "stop", reason: "policy_violation", violations: input.policy_result.violations });
|
|
33
|
+
const reasons = input.policy_result.violations.map(v => `${v.type}: ${v.current}/${v.limit}`).join(", ");
|
|
34
|
+
return {
|
|
35
|
+
decision: "allow",
|
|
36
|
+
nextState,
|
|
37
|
+
events,
|
|
38
|
+
output: null,
|
|
39
|
+
message: `Loop: policy violation (${reasons}).`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
30
43
|
if (nextState.completionPromise && input.last_assistant_text) {
|
|
31
44
|
if (extractPromise(input.last_assistant_text, nextState.completionPromise)) {
|
|
32
45
|
nextState.active = false;
|
|
@@ -57,6 +70,16 @@ export function evaluateStopHook(input, state) {
|
|
|
57
70
|
events.push({ event: "verify_failed", script: nextState.verifyScript, output: input.verify_result.output || "" });
|
|
58
71
|
}
|
|
59
72
|
|
|
73
|
+
if (input.test_results && input.test_results.length > 0) {
|
|
74
|
+
const allPassed = input.test_results.every(r => r.passed);
|
|
75
|
+
if (allPassed) {
|
|
76
|
+
events.push({ event: "test_result", status: "all_passed", results: input.test_results });
|
|
77
|
+
} else {
|
|
78
|
+
const failed = input.test_results.filter(r => !r.passed);
|
|
79
|
+
events.push({ event: "test_result", status: "some_failed", failed: failed.map(f => f.storyId) });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
60
83
|
if (input.stop_hook_active === true) {
|
|
61
84
|
if (!input.has_pending_stories) {
|
|
62
85
|
nextState.active = false;
|
package/core/policy.mjs
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_POLICY = {
|
|
5
|
+
id: "default",
|
|
6
|
+
conditions: [
|
|
7
|
+
{ type: "max_iterations", value: 20 },
|
|
8
|
+
],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export async function loadPolicy(cwd) {
|
|
12
|
+
const policyPath = join(cwd || process.cwd(), ".loophaus", "policy.json");
|
|
13
|
+
try {
|
|
14
|
+
const raw = await readFile(policyPath, "utf-8");
|
|
15
|
+
return JSON.parse(raw);
|
|
16
|
+
} catch {
|
|
17
|
+
return DEFAULT_POLICY;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function evaluatePolicy(policy, state, context = {}) {
|
|
22
|
+
const violations = [];
|
|
23
|
+
|
|
24
|
+
for (const condition of policy.conditions || []) {
|
|
25
|
+
switch (condition.type) {
|
|
26
|
+
case "max_iterations":
|
|
27
|
+
if (state.currentIteration > condition.value) {
|
|
28
|
+
violations.push({ type: "max_iterations", limit: condition.value, current: state.currentIteration });
|
|
29
|
+
}
|
|
30
|
+
break;
|
|
31
|
+
case "max_cost":
|
|
32
|
+
if (context.totalCost && context.totalCost > condition.value) {
|
|
33
|
+
violations.push({ type: "max_cost", limit: condition.value, current: context.totalCost });
|
|
34
|
+
}
|
|
35
|
+
break;
|
|
36
|
+
case "max_time_minutes":
|
|
37
|
+
if (state.startedAt) {
|
|
38
|
+
const elapsed = (Date.now() - new Date(state.startedAt).getTime()) / 60000;
|
|
39
|
+
if (elapsed > condition.value) {
|
|
40
|
+
violations.push({ type: "max_time_minutes", limit: condition.value, current: Math.round(elapsed) });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
break;
|
|
44
|
+
case "max_errors":
|
|
45
|
+
if (context.errorCount && context.errorCount > condition.value) {
|
|
46
|
+
violations.push({ type: "max_errors", limit: condition.value, current: context.errorCount });
|
|
47
|
+
}
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
shouldStop: violations.length > 0,
|
|
54
|
+
violations,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export { DEFAULT_POLICY };
|
package/core/session.mjs
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { readFile, writeFile, readdir, mkdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
function getSessionsDir(cwd) {
|
|
5
|
+
return join(cwd || process.cwd(), ".loophaus", "sessions");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function saveCheckpoint(sessionId, data, cwd) {
|
|
9
|
+
const dir = getSessionsDir(cwd);
|
|
10
|
+
await mkdir(dir, { recursive: true });
|
|
11
|
+
const checkpoint = {
|
|
12
|
+
sessionId,
|
|
13
|
+
savedAt: new Date().toISOString(),
|
|
14
|
+
...data,
|
|
15
|
+
};
|
|
16
|
+
await writeFile(join(dir, `${sessionId}.json`), JSON.stringify(checkpoint, null, 2), "utf-8");
|
|
17
|
+
return checkpoint;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function loadCheckpoint(sessionId, cwd) {
|
|
21
|
+
const dir = getSessionsDir(cwd);
|
|
22
|
+
try {
|
|
23
|
+
const raw = await readFile(join(dir, `${sessionId}.json`), "utf-8");
|
|
24
|
+
return JSON.parse(raw);
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function listSessions(cwd) {
|
|
31
|
+
const dir = getSessionsDir(cwd);
|
|
32
|
+
try {
|
|
33
|
+
const files = await readdir(dir);
|
|
34
|
+
const sessions = [];
|
|
35
|
+
for (const file of files) {
|
|
36
|
+
if (!file.endsWith(".json")) continue;
|
|
37
|
+
try {
|
|
38
|
+
const raw = await readFile(join(dir, file), "utf-8");
|
|
39
|
+
const data = JSON.parse(raw);
|
|
40
|
+
sessions.push(data);
|
|
41
|
+
} catch { /* skip malformed */ }
|
|
42
|
+
}
|
|
43
|
+
return sessions.sort((a, b) => new Date(b.savedAt).getTime() - new Date(a.savedAt).getTime());
|
|
44
|
+
} catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function resumeSession(sessionId, cwd) {
|
|
50
|
+
const checkpoint = await loadCheckpoint(sessionId, cwd);
|
|
51
|
+
if (!checkpoint) return null;
|
|
52
|
+
|
|
53
|
+
const { write } = await import("../store/state-store.mjs");
|
|
54
|
+
const state = {
|
|
55
|
+
active: true,
|
|
56
|
+
prompt: checkpoint.prompt || "",
|
|
57
|
+
completionPromise: checkpoint.completionPromise || "TADA",
|
|
58
|
+
maxIterations: checkpoint.maxIterations || 20,
|
|
59
|
+
currentIteration: checkpoint.currentIteration || 0,
|
|
60
|
+
sessionId: checkpoint.sessionId,
|
|
61
|
+
name: checkpoint.name || "",
|
|
62
|
+
startedAt: checkpoint.startedAt || new Date().toISOString(),
|
|
63
|
+
};
|
|
64
|
+
await write(state, cwd, checkpoint.name);
|
|
65
|
+
return state;
|
|
66
|
+
}
|
package/hooks/stop-hook.mjs
CHANGED
|
@@ -4,6 +4,32 @@ import { evaluateStopHook } from "../core/engine.mjs";
|
|
|
4
4
|
import { getLastAssistantText, hasPendingStories } from "../core/io-helpers.mjs";
|
|
5
5
|
import { read as readState, write as writeState } from "../store/state-store.mjs";
|
|
6
6
|
import { logEvents } from "../core/event-logger.mjs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
async function runStoryTests(cwd) {
|
|
10
|
+
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
|
+
const prdPath = join(cwd, "prd.json");
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const prd = JSON.parse(await readFile(prdPath, "utf-8"));
|
|
18
|
+
if (!Array.isArray(prd.userStories)) return [];
|
|
19
|
+
|
|
20
|
+
const results = [];
|
|
21
|
+
for (const story of prd.userStories) {
|
|
22
|
+
if (!story.testCommand || story.passes) continue;
|
|
23
|
+
try {
|
|
24
|
+
await execFileAsync("sh", ["-c", story.testCommand], { cwd, timeout: 60_000 });
|
|
25
|
+
results.push({ storyId: story.id, passed: true });
|
|
26
|
+
} catch (err) {
|
|
27
|
+
results.push({ storyId: story.id, passed: false, error: err.message });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return results;
|
|
31
|
+
} catch { return []; }
|
|
32
|
+
}
|
|
7
33
|
|
|
8
34
|
async function readStdin() {
|
|
9
35
|
const chunks = [];
|
|
@@ -39,11 +65,21 @@ async function main() {
|
|
|
39
65
|
}
|
|
40
66
|
}
|
|
41
67
|
|
|
68
|
+
// Run story tests if prd.json has testCommand fields
|
|
69
|
+
const testResults = await runStoryTests(cwd);
|
|
70
|
+
|
|
71
|
+
// Evaluate loop policy
|
|
72
|
+
const { loadPolicy, evaluatePolicy } = await import("../core/policy.mjs");
|
|
73
|
+
const policy = await loadPolicy(cwd);
|
|
74
|
+
const policyResult = evaluatePolicy(policy, state, { totalCost: 0, errorCount: 0 });
|
|
75
|
+
|
|
42
76
|
const input = {
|
|
43
77
|
...hookInput,
|
|
44
78
|
last_assistant_text: lastText,
|
|
45
79
|
has_pending_stories: pending,
|
|
46
80
|
verify_result: verifyResult,
|
|
81
|
+
test_results: testResults,
|
|
82
|
+
policy_result: policyResult,
|
|
47
83
|
};
|
|
48
84
|
|
|
49
85
|
const result = evaluateStopHook(input, state);
|
|
@@ -51,6 +87,19 @@ async function main() {
|
|
|
51
87
|
await writeState(result.nextState, cwd);
|
|
52
88
|
await logEvents(result.events, { adapter: "auto", loop_id: state.sessionId || "unknown" }, cwd);
|
|
53
89
|
|
|
90
|
+
// Save session checkpoint (best-effort)
|
|
91
|
+
try {
|
|
92
|
+
const { saveCheckpoint } = await import("../core/session.mjs");
|
|
93
|
+
await saveCheckpoint(result.nextState.sessionId || `auto-${Date.now()}`, {
|
|
94
|
+
prompt: result.nextState.prompt,
|
|
95
|
+
completionPromise: result.nextState.completionPromise,
|
|
96
|
+
maxIterations: result.nextState.maxIterations,
|
|
97
|
+
currentIteration: result.nextState.currentIteration,
|
|
98
|
+
name: result.nextState.name,
|
|
99
|
+
startedAt: result.nextState.startedAt,
|
|
100
|
+
}, cwd);
|
|
101
|
+
} catch { /* best-effort */ }
|
|
102
|
+
|
|
54
103
|
if (result.message) process.stderr.write(result.message + "\n");
|
|
55
104
|
if (result.output) process.stdout.write(JSON.stringify(result.output));
|
|
56
105
|
process.exit(0);
|