@graypark/loophaus 2.1.1 → 3.0.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
@@ -26,14 +26,21 @@ 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 loops
37
44
  npx @graypark/loophaus --version
38
45
 
39
46
  Hosts:
@@ -47,6 +54,7 @@ Options:
47
54
  --host <name> Target a specific host
48
55
  --claude Shorthand for --host claude-code
49
56
  --kiro Shorthand for --host kiro-cli
57
+ --name <loop> Target a named loop (multi-loop)
50
58
  --local Install to project-local .codex/ (Codex only)
51
59
  --force Overwrite existing installation
52
60
  --dry-run Preview changes without modifying files
@@ -117,10 +125,11 @@ async function runUninstall() {
117
125
  }
118
126
 
119
127
  async function runStatus() {
128
+ const name = getFlag("--name");
120
129
  const { read } = await import("../store/state-store.mjs");
121
- const state = await read();
130
+ const state = await read(undefined, name);
122
131
  if (!state.active) {
123
- console.log("No active loop.");
132
+ console.log(name ? `No active loop: ${name}` : "No active loop.");
124
133
  return;
125
134
  }
126
135
  const iterInfo = state.maxIterations > 0
@@ -128,6 +137,7 @@ async function runStatus() {
128
137
  : `${state.currentIteration}`;
129
138
  console.log(`Loop Status`);
130
139
  console.log(`\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`);
140
+ if (name) console.log(`Name: ${name}`);
131
141
  console.log(`Active: yes`);
132
142
  console.log(`Iteration: ${iterInfo}`);
133
143
  console.log(`Promise: ${state.completionPromise || "(none)"}`);
@@ -150,8 +160,22 @@ async function runStatus() {
150
160
  } catch { /* no prd.json */ }
151
161
  }
152
162
 
163
+ async function runLoops() {
164
+ const { listLoops } = await import("../core/loop-registry.mjs");
165
+ const loops = await listLoops();
166
+ if (loops.length === 0) { console.log("No active loops."); return; }
167
+ console.log("Active Loops");
168
+ console.log("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
169
+ for (const l of loops) {
170
+ const status = l.active ? "active" : "done";
171
+ const iter = l.maxIterations > 0 ? `${l.currentIteration}/${l.maxIterations}` : `${l.currentIteration}`;
172
+ console.log(` ${l.name} [${status}] iter ${iter}`);
173
+ }
174
+ }
175
+
153
176
  async function runStats() {
154
177
  const { readTrace } = await import("../core/event-logger.mjs");
178
+ const { formatCost } = await import("../core/cost-tracker.mjs");
155
179
  const events = await readTrace();
156
180
  if (events.length === 0) {
157
181
  console.log("No trace data found. Run a loop first.");
@@ -168,6 +192,13 @@ async function runStats() {
168
192
  console.log(`Last stop reason: ${lastStop.reason || "unknown"}`);
169
193
  console.log(`Last stop at: ${lastStop.ts || "unknown"}`);
170
194
  }
195
+
196
+ const costEvents = events.filter(e => e.event === "cost" || e.totalCost);
197
+ if (costEvents.length > 0) {
198
+ const totalCost = costEvents.reduce((s, e) => s + (e.totalCost || 0), 0);
199
+ console.log(`Estimated cost: ${formatCost(totalCost)}`);
200
+ }
201
+
171
202
  console.log(`Trace file: .loophaus/trace.jsonl (${events.length} events)`);
172
203
  }
173
204
 
@@ -177,6 +208,7 @@ try {
177
208
  case "uninstall": await runUninstall(); break;
178
209
  case "status": await runStatus(); break;
179
210
  case "stats": await runStats(); break;
211
+ case "loops": await runLoops(); break;
180
212
  default:
181
213
  if (command.startsWith("-")) {
182
214
  await runInstall();
package/bin/uninstall.mjs CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  getClaudeInstalledPluginsPath,
15
15
  getAgentsSkillsDir,
16
16
  } from "../lib/paths.mjs";
17
- import { getStatePath } from "../lib/state.mjs";
17
+ import { getStatePath } from "../store/state-store.mjs";
18
18
 
19
19
  const RALPH_HOOK_MARKER = "loophaus";
20
20
 
@@ -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}/lib/state.mjs';
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}/lib/state.mjs';
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;
@@ -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
+ // 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
+ }
@@ -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);
@@ -1,162 +1,42 @@
1
- import { readFile } from "node:fs/promises";
2
- import { join } from "node:path";
1
+ import { evaluateStopHook, extractPromise } from "../core/engine.mjs";
2
+ import { getLastAssistantText, hasPendingStories } from "../core/io-helpers.mjs";
3
3
 
4
- export function extractPromise(text, promisePhrase) {
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
- // Session isolation
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
- // Increment iteration
84
- state.currentIteration += 1;
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
- // Check completion promise
97
- if (state.completionPromise) {
98
- const lastText =
99
- hookInput.last_assistant_message ||
100
- (await getLastAssistantText(hookInput.transcript_path || null));
21
+ const input = {
22
+ ...hookInput,
23
+ last_assistant_text: lastText,
24
+ has_pending_stories: pending,
25
+ };
101
26
 
102
- if (lastText && extractPromise(lastText, state.completionPromise)) {
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
- // Hybrid stop_hook_active handling:
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
- if (!pending) {
120
- stderr.push(
121
- "Loop: stop_hook_active=true, no pending stories. Allowing exit.\n",
122
- );
123
- state.active = false;
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
- // Save updated iteration
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graypark/loophaus",
3
- "version": "2.1.1",
3
+ "version": "3.0.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",
@@ -145,14 +145,15 @@ export async function install({ dryRun = false, force = false, local = false } =
145
145
  }
146
146
  }
147
147
 
148
- const totalSteps = local ? 4 : 5;
148
+ const totalSteps = 4;
149
149
 
150
- // Step 1: Clean up legacy ralph-* skills
150
+ // Step 1: Clean up legacy skills from ~/.codex/skills/
151
151
  console.log(`[1/${totalSteps}] Cleaning up legacy skills...`);
152
- for (const name of LEGACY_SKILLS) {
152
+ const CLEANUP_FROM_CODEX = [...LEGACY_SKILLS, "loop", "loop-stop", "loop-plan", "loop-pulse"];
153
+ for (const name of CLEANUP_FROM_CODEX) {
153
154
  const legacyDir = join(skillsDir, name);
154
155
  if (await fileExists(legacyDir)) {
155
- console.log(` > Remove legacy skill: ${name}`);
156
+ console.log(` > Remove from ~/.codex/skills/: ${name}`);
156
157
  if (!dryRun) {
157
158
  await rm(legacyDir, { recursive: true, force: true });
158
159
  }
@@ -192,10 +193,13 @@ export async function install({ dryRun = false, force = false, local = false } =
192
193
  await writeFile(hooksJsonPath, JSON.stringify(existing, null, 2), "utf-8");
193
194
  }
194
195
 
195
- // Step 4: Install skills to ~/.codex/skills/
196
- console.log(`[4/${totalSteps}] Installing skills to ~/.codex/skills/...`);
196
+ // Step 4: Install skills to standard path
197
+ // Global: ~/.agents/skills/ (new Codex CLI standard — avoids duplicates with ~/.codex/skills/)
198
+ // Local: .codex/skills/ (project-scoped)
199
+ const targetSkillsDir = local ? skillsDir : getAgentsSkillsDir();
200
+ console.log(`[4/${totalSteps}] Installing skills to ${local ? ".codex/skills/" : "~/.agents/skills/"}...`);
197
201
  for (const [name, skill] of Object.entries(CODEX_SKILLS)) {
198
- const skillDir = join(skillsDir, name);
202
+ const skillDir = join(targetSkillsDir, name);
199
203
  console.log(` > Install skill: ${name}`);
200
204
  if (!dryRun) {
201
205
  await mkdir(skillDir, { recursive: true });
@@ -207,24 +211,6 @@ export async function install({ dryRun = false, force = false, local = false } =
207
211
  }
208
212
  }
209
213
 
210
- // Step 5: Mirror skills to ~/.agents/skills/ (new Codex CLI standard path)
211
- if (!local) {
212
- const agentsSkillsDir = getAgentsSkillsDir();
213
- console.log(`[5/${totalSteps}] Installing skills to ~/.agents/skills/...`);
214
- for (const [name, skill] of Object.entries(CODEX_SKILLS)) {
215
- const skillDir = join(agentsSkillsDir, name);
216
- console.log(` > Install skill: ${name}`);
217
- if (!dryRun) {
218
- await mkdir(skillDir, { recursive: true });
219
- await writeFile(
220
- join(skillDir, "SKILL.md"),
221
- skill.content.replaceAll("${RALPH_CODEX_ROOT}", pluginDir).replaceAll("${LOOPHAUS_ROOT}", pluginDir),
222
- "utf-8",
223
- );
224
- }
225
- }
226
- }
227
-
228
214
  console.log("");
229
215
  if (dryRun) {
230
216
  console.log(" \u2714 Dry run complete. No files were modified.");
@@ -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
- return join(cwd || process.cwd(), ".loophaus", "state.json");
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
- return { ...DEFAULT_STATE, ...JSON.parse(raw) };
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 statePath = getStatePath(cwd);
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
- }