@graypark/loophaus 2.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/.claude-plugin/plugin.json +11 -0
- package/LICENSE +21 -0
- package/README.ko.md +358 -0
- package/README.md +282 -0
- package/bin/install.mjs +10 -0
- package/bin/loophaus.mjs +192 -0
- package/bin/uninstall.mjs +233 -0
- package/codex/commands/cancel-ralph.md +30 -0
- package/codex/commands/ralph-loop.md +73 -0
- package/commands/cancel-ralph.md +23 -0
- package/commands/help.md +96 -0
- package/commands/loop-plan.md +55 -0
- package/commands/loop-pulse.md +38 -0
- package/commands/loop-stop.md +29 -0
- package/commands/loop.md +17 -0
- package/commands/ralph-loop.md +18 -0
- package/core/engine.mjs +84 -0
- package/core/event-logger.mjs +37 -0
- package/core/loop.schema.json +29 -0
- package/hooks/hooks.json +15 -0
- package/hooks/stop-hook.mjs +79 -0
- package/lib/paths.mjs +91 -0
- package/lib/state.mjs +46 -0
- package/lib/stop-hook-core.mjs +162 -0
- package/package.json +57 -0
- package/platforms/claude-code/adapter.mjs +20 -0
- package/platforms/claude-code/installer.mjs +165 -0
- package/platforms/codex-cli/adapter.mjs +20 -0
- package/platforms/codex-cli/installer.mjs +131 -0
- package/platforms/kiro-cli/adapter.mjs +21 -0
- package/platforms/kiro-cli/installer.mjs +115 -0
- package/scripts/setup-ralph-loop.sh +145 -0
- package/skills/ralph-claude-cancel/SKILL.md +23 -0
- package/skills/ralph-claude-interview/SKILL.md +178 -0
- package/skills/ralph-claude-loop/SKILL.md +101 -0
- package/skills/ralph-claude-orchestrator/SKILL.md +129 -0
- package/skills/ralph-interview/SKILL.md +275 -0
- package/skills/ralph-orchestrator/SKILL.md +254 -0
- package/store/state-store.mjs +80 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Check loop status"
|
|
3
|
+
allowed-tools:
|
|
4
|
+
[
|
|
5
|
+
"Read(.loophaus/state.json)",
|
|
6
|
+
"Read(.claude/ralph-loop.local.md)",
|
|
7
|
+
"Read(prd.json)",
|
|
8
|
+
"Read(progress.txt)",
|
|
9
|
+
]
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# /loop-pulse — Check Loop Status
|
|
13
|
+
|
|
14
|
+
1. Read `.loophaus/state.json` (or legacy `.claude/ralph-loop.local.md`)
|
|
15
|
+
- If neither exists: "No active loop."
|
|
16
|
+
|
|
17
|
+
2. If active, display:
|
|
18
|
+
```
|
|
19
|
+
Loop Status
|
|
20
|
+
───────────
|
|
21
|
+
Active: yes
|
|
22
|
+
Iteration: 5/20
|
|
23
|
+
Promise: TASK COMPLETE
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
3. If `prd.json` exists, also show:
|
|
27
|
+
```
|
|
28
|
+
Stories
|
|
29
|
+
───────
|
|
30
|
+
✓ US-001 Add login API
|
|
31
|
+
✓ US-002 Add auth middleware
|
|
32
|
+
→ US-003 Add JWT refresh (in progress)
|
|
33
|
+
US-004 Add logout endpoint
|
|
34
|
+
|
|
35
|
+
Progress: 2/4 done
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
4. If `progress.txt` exists, show last 5 lines.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Stop active loop"
|
|
3
|
+
allowed-tools:
|
|
4
|
+
[
|
|
5
|
+
"Bash(test -f .loophaus/state.json:*)",
|
|
6
|
+
"Bash(rm .loophaus/state.json)",
|
|
7
|
+
"Read(.loophaus/state.json)",
|
|
8
|
+
"Bash(test -f .claude/ralph-loop.local.md:*)",
|
|
9
|
+
"Bash(rm .claude/ralph-loop.local.md)",
|
|
10
|
+
"Read(.claude/ralph-loop.local.md)",
|
|
11
|
+
]
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# /loop-stop — Stop Active Loop
|
|
15
|
+
|
|
16
|
+
1. Check if `.loophaus/state.json` exists: `test -f .loophaus/state.json && echo "EXISTS" || echo "NOT_FOUND"`
|
|
17
|
+
- If not found, also check legacy path: `test -f .claude/ralph-loop.local.md && echo "LEGACY" || echo "NOT_FOUND"`
|
|
18
|
+
|
|
19
|
+
2. **If NOT_FOUND** on both: Say "No active loop found."
|
|
20
|
+
|
|
21
|
+
3. **If EXISTS** (.loophaus/state.json):
|
|
22
|
+
- Read the file to get `currentIteration`
|
|
23
|
+
- Remove it: `rm .loophaus/state.json`
|
|
24
|
+
- Report: "Stopped loop at iteration N."
|
|
25
|
+
|
|
26
|
+
4. **If LEGACY** (.claude/ralph-loop.local.md):
|
|
27
|
+
- Read it to get the iteration field
|
|
28
|
+
- Remove it: `rm .claude/ralph-loop.local.md`
|
|
29
|
+
- Report: "Stopped loop at iteration N. (migrated from legacy path)"
|
package/commands/loop.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Start iterative dev loop"
|
|
3
|
+
argument-hint: "PROMPT [--max-iterations N] [--completion-promise TEXT]"
|
|
4
|
+
allowed-tools: ["Bash(${CLAUDE_PLUGIN_ROOT}/scripts/setup-ralph-loop.sh:*)"]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# /loop — Start Iterative Dev Loop
|
|
8
|
+
|
|
9
|
+
Execute the setup script to initialize the loop:
|
|
10
|
+
|
|
11
|
+
```!
|
|
12
|
+
"${CLAUDE_PLUGIN_ROOT}/scripts/setup-ralph-loop.sh" $ARGUMENTS
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Work on the task. When you try to exit, the stop hook feeds the SAME PROMPT back for the next iteration. Your previous work persists in files and git history.
|
|
16
|
+
|
|
17
|
+
CRITICAL: If a completion promise is set, ONLY output it when genuinely complete. Do not output false promises to escape the loop.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Start Ralph Loop in current session"
|
|
3
|
+
argument-hint: "PROMPT [--max-iterations N] [--completion-promise TEXT]"
|
|
4
|
+
allowed-tools: ["Bash(${CLAUDE_PLUGIN_ROOT}/scripts/setup-ralph-loop.sh:*)"]
|
|
5
|
+
hide-from-slash-command-tool: "true"
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Ralph Loop Command
|
|
9
|
+
|
|
10
|
+
Execute the setup script to initialize the Ralph loop:
|
|
11
|
+
|
|
12
|
+
```!
|
|
13
|
+
"${CLAUDE_PLUGIN_ROOT}/scripts/setup-ralph-loop.sh" $ARGUMENTS
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Please work on the task. When you try to exit, the Ralph loop will feed the SAME PROMPT back to you for the next iteration. You'll see your previous work in files and git history, allowing you to iterate and improve.
|
|
17
|
+
|
|
18
|
+
CRITICAL RULE: If a completion promise is set, you may ONLY output it when the statement is completely and unequivocally TRUE. Do not output false promises to escape the loop, even if you think you're stuck or should exit for other reasons. The loop is designed to continue until genuine completion.
|
package/core/engine.mjs
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Pure function: no I/O, no side effects. Returns decision + next state + events.
|
|
2
|
+
|
|
3
|
+
export function evaluateStopHook(input, state) {
|
|
4
|
+
const events = [];
|
|
5
|
+
const nextState = { ...state };
|
|
6
|
+
|
|
7
|
+
if (!nextState.active) {
|
|
8
|
+
return { decision: "allow", nextState, events, output: null };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (nextState.sessionId && input.session_id && nextState.sessionId !== input.session_id) {
|
|
12
|
+
return { decision: "allow", nextState, events, output: null };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
nextState.currentIteration += 1;
|
|
16
|
+
events.push({ event: "iteration", iteration: nextState.currentIteration });
|
|
17
|
+
|
|
18
|
+
if (nextState.maxIterations > 0 && nextState.currentIteration > nextState.maxIterations) {
|
|
19
|
+
nextState.active = false;
|
|
20
|
+
events.push({ event: "stop", reason: "max_iterations" });
|
|
21
|
+
return {
|
|
22
|
+
decision: "allow",
|
|
23
|
+
nextState,
|
|
24
|
+
events,
|
|
25
|
+
output: null,
|
|
26
|
+
message: `Loop: max iterations (${nextState.maxIterations}) reached.`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (nextState.completionPromise && input.last_assistant_text) {
|
|
31
|
+
if (extractPromise(input.last_assistant_text, nextState.completionPromise)) {
|
|
32
|
+
nextState.active = false;
|
|
33
|
+
events.push({ event: "stop", reason: "completion_promise", promise: nextState.completionPromise });
|
|
34
|
+
return {
|
|
35
|
+
decision: "allow",
|
|
36
|
+
nextState,
|
|
37
|
+
events,
|
|
38
|
+
output: null,
|
|
39
|
+
message: `Loop: completion promise "${nextState.completionPromise}" detected.`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (input.stop_hook_active === true) {
|
|
45
|
+
if (!input.has_pending_stories) {
|
|
46
|
+
nextState.active = false;
|
|
47
|
+
events.push({ event: "stop", reason: "all_stories_done" });
|
|
48
|
+
return {
|
|
49
|
+
decision: "allow",
|
|
50
|
+
nextState,
|
|
51
|
+
events,
|
|
52
|
+
output: null,
|
|
53
|
+
message: "Loop: no pending stories. Allowing exit.",
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
events.push({ event: "continue", reason: "pending_stories" });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
events.push({ event: "state_change", from: "running", to: "running" });
|
|
60
|
+
|
|
61
|
+
const iterInfo = nextState.maxIterations > 0
|
|
62
|
+
? `${nextState.currentIteration}/${nextState.maxIterations}`
|
|
63
|
+
: `${nextState.currentIteration}`;
|
|
64
|
+
|
|
65
|
+
const reason = [nextState.prompt, "", "---", `Loop iteration ${iterInfo}. Continue working on the task above.`].join("\n");
|
|
66
|
+
|
|
67
|
+
const output = { decision: "block", reason };
|
|
68
|
+
if (nextState.completionPromise) {
|
|
69
|
+
output.systemMessage = `Loop iteration ${iterInfo} | To stop: output <promise>${nextState.completionPromise}</promise> (ONLY when TRUE)`;
|
|
70
|
+
} else {
|
|
71
|
+
output.systemMessage = `Loop iteration ${iterInfo} | No completion promise — loop runs until max iterations`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { decision: "block", nextState, events, output };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function extractPromise(text, promisePhrase) {
|
|
78
|
+
const regex = new RegExp(`<promise>\\s*${escapeRegex(promisePhrase)}\\s*</promise>`, "s");
|
|
79
|
+
return regex.test(text);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function escapeRegex(str) {
|
|
83
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
84
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Best-effort trace logger. Failures never affect stop-hook decisions.
|
|
2
|
+
|
|
3
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
|
|
6
|
+
export function getTracePath(cwd) {
|
|
7
|
+
return join(cwd || process.cwd(), ".loophaus", "trace.jsonl");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function logEvents(events, metadata = {}, cwd) {
|
|
11
|
+
try {
|
|
12
|
+
const tracePath = getTracePath(cwd);
|
|
13
|
+
await mkdir(dirname(tracePath), { recursive: true });
|
|
14
|
+
|
|
15
|
+
const ts = new Date().toISOString();
|
|
16
|
+
const lines = events.map(e =>
|
|
17
|
+
JSON.stringify({ ts, ...metadata, ...e })
|
|
18
|
+
).join("\n") + "\n";
|
|
19
|
+
|
|
20
|
+
await appendFile(tracePath, lines, "utf-8");
|
|
21
|
+
} catch {
|
|
22
|
+
// Best-effort: silently ignore failures
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function readTrace(cwd) {
|
|
27
|
+
const tracePath = getTracePath(cwd);
|
|
28
|
+
try {
|
|
29
|
+
const { readFile } = await import("node:fs/promises");
|
|
30
|
+
const raw = await readFile(tracePath, "utf-8");
|
|
31
|
+
return raw.trim().split("\n").map(line => {
|
|
32
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
33
|
+
}).filter(Boolean);
|
|
34
|
+
} catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"title": "Loophaus Loop Protocol",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"required": ["protocol_version", "states", "stop_conditions"],
|
|
6
|
+
"properties": {
|
|
7
|
+
"protocol_version": { "const": "1.0" },
|
|
8
|
+
"loop_id": { "type": "string" },
|
|
9
|
+
"states": {
|
|
10
|
+
"type": "array",
|
|
11
|
+
"items": {
|
|
12
|
+
"type": "object",
|
|
13
|
+
"required": ["name"],
|
|
14
|
+
"properties": {
|
|
15
|
+
"name": { "enum": ["discovery", "plan", "implement", "verify", "commit", "done"] },
|
|
16
|
+
"transitions": { "type": "array", "items": { "type": "string" } }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"stop_conditions": {
|
|
21
|
+
"type": "object",
|
|
22
|
+
"properties": {
|
|
23
|
+
"max_iterations": { "type": "integer", "minimum": 0 },
|
|
24
|
+
"completion_promise": { "type": "string" },
|
|
25
|
+
"all_stories_done": { "type": "boolean" }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
package/hooks/hooks.json
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { evaluateStopHook } from "../core/engine.mjs";
|
|
6
|
+
import { read as readState, write as writeState } from "../store/state-store.mjs";
|
|
7
|
+
import { logEvents } from "../core/event-logger.mjs";
|
|
8
|
+
|
|
9
|
+
async function readStdin() {
|
|
10
|
+
const chunks = [];
|
|
11
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
12
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
13
|
+
}
|
|
14
|
+
|
|
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
|
+
async function main() {
|
|
47
|
+
let hookInput = {};
|
|
48
|
+
try {
|
|
49
|
+
const raw = await readStdin();
|
|
50
|
+
if (raw.trim()) hookInput = JSON.parse(raw);
|
|
51
|
+
} catch { /* empty input */ }
|
|
52
|
+
|
|
53
|
+
const cwd = hookInput.cwd || process.cwd();
|
|
54
|
+
const state = await readState(cwd);
|
|
55
|
+
|
|
56
|
+
const lastText = hookInput.last_assistant_message ||
|
|
57
|
+
await getLastAssistantText(hookInput.transcript_path || null);
|
|
58
|
+
const pending = await hasPendingStories(cwd);
|
|
59
|
+
|
|
60
|
+
const input = {
|
|
61
|
+
...hookInput,
|
|
62
|
+
last_assistant_text: lastText,
|
|
63
|
+
has_pending_stories: pending,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const result = evaluateStopHook(input, state);
|
|
67
|
+
|
|
68
|
+
await writeState(result.nextState, cwd);
|
|
69
|
+
await logEvents(result.events, { adapter: "auto", loop_id: state.sessionId || "unknown" }, cwd);
|
|
70
|
+
|
|
71
|
+
if (result.message) process.stderr.write(result.message + "\n");
|
|
72
|
+
if (result.output) process.stdout.write(JSON.stringify(result.output));
|
|
73
|
+
process.exit(0);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
main().catch((err) => {
|
|
77
|
+
process.stderr.write(`loophaus stop-hook error: ${err.message}\n`);
|
|
78
|
+
process.exit(0);
|
|
79
|
+
});
|
package/lib/paths.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = dirname(__filename);
|
|
8
|
+
|
|
9
|
+
let _cachedVersion = null;
|
|
10
|
+
export function getPackageVersion() {
|
|
11
|
+
if (_cachedVersion) return _cachedVersion;
|
|
12
|
+
try {
|
|
13
|
+
const pkg = JSON.parse(
|
|
14
|
+
readFileSync(join(__dirname, "..", "package.json"), "utf-8"),
|
|
15
|
+
);
|
|
16
|
+
_cachedVersion = pkg.version;
|
|
17
|
+
} catch {
|
|
18
|
+
_cachedVersion = "0.0.0";
|
|
19
|
+
}
|
|
20
|
+
return _cachedVersion;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isWindows() {
|
|
24
|
+
return process.platform === "win32";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// --- Codex CLI paths ---
|
|
28
|
+
|
|
29
|
+
export function getCodexHome() {
|
|
30
|
+
if (process.env.CODEX_HOME) {
|
|
31
|
+
return process.env.CODEX_HOME;
|
|
32
|
+
}
|
|
33
|
+
return join(homedir(), ".codex");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getHooksJsonPath() {
|
|
37
|
+
return join(getCodexHome(), "hooks.json");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getPluginInstallDir() {
|
|
41
|
+
return join(getCodexHome(), "plugins", "loophaus");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getSkillsDir() {
|
|
45
|
+
return join(getCodexHome(), "skills");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getLocalCodexDir() {
|
|
49
|
+
return join(process.cwd(), ".codex");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getLocalPluginDir() {
|
|
53
|
+
return join(getLocalCodexDir(), "plugins", "loophaus");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getLocalHooksJsonPath() {
|
|
57
|
+
return join(getLocalCodexDir(), "hooks.json");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getLocalSkillsDir() {
|
|
61
|
+
return join(getLocalCodexDir(), "skills");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- Claude Code paths ---
|
|
65
|
+
|
|
66
|
+
export function getClaudeHome() {
|
|
67
|
+
return join(homedir(), ".claude");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function getClaudePluginsDir() {
|
|
71
|
+
return join(getClaudeHome(), "plugins");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function getClaudePluginCacheDir(version) {
|
|
75
|
+
const v = version || getPackageVersion();
|
|
76
|
+
return join(
|
|
77
|
+
getClaudePluginsDir(),
|
|
78
|
+
"cache",
|
|
79
|
+
"loophaus-marketplace",
|
|
80
|
+
"loophaus",
|
|
81
|
+
v,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function getClaudeSettingsPath() {
|
|
86
|
+
return join(getClaudeHome(), "settings.json");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function getClaudeInstalledPluginsPath() {
|
|
90
|
+
return join(getClaudePluginsDir(), "installed_plugins.json");
|
|
91
|
+
}
|
package/lib/state.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
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
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function processStopHook(hookInput, readStateFn, writeStateFn) {
|
|
66
|
+
const stderr = [];
|
|
67
|
+
const state = await readStateFn();
|
|
68
|
+
|
|
69
|
+
// Not active — allow exit
|
|
70
|
+
if (!state.active) {
|
|
71
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Session isolation
|
|
75
|
+
if (
|
|
76
|
+
state.sessionId &&
|
|
77
|
+
hookInput.session_id &&
|
|
78
|
+
state.sessionId !== hookInput.session_id
|
|
79
|
+
) {
|
|
80
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
81
|
+
}
|
|
82
|
+
|
|
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
|
+
}
|
|
95
|
+
|
|
96
|
+
// Check completion promise
|
|
97
|
+
if (state.completionPromise) {
|
|
98
|
+
const lastText =
|
|
99
|
+
hookInput.last_assistant_message ||
|
|
100
|
+
(await getLastAssistantText(hookInput.transcript_path || null));
|
|
101
|
+
|
|
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
|
+
}
|
|
111
|
+
|
|
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);
|
|
118
|
+
|
|
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("") };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
stderr.push(
|
|
129
|
+
"Loop: stop_hook_active=true, pending stories found. Continuing.\n",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
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
|
+
};
|
|
162
|
+
}
|