@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 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;
@@ -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 };
@@ -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
+ }
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graypark/loophaus",
3
- "version": "3.2.0",
3
+ "version": "3.3.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",