@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.
Files changed (39) hide show
  1. package/.claude-plugin/plugin.json +11 -0
  2. package/LICENSE +21 -0
  3. package/README.ko.md +358 -0
  4. package/README.md +282 -0
  5. package/bin/install.mjs +10 -0
  6. package/bin/loophaus.mjs +192 -0
  7. package/bin/uninstall.mjs +233 -0
  8. package/codex/commands/cancel-ralph.md +30 -0
  9. package/codex/commands/ralph-loop.md +73 -0
  10. package/commands/cancel-ralph.md +23 -0
  11. package/commands/help.md +96 -0
  12. package/commands/loop-plan.md +55 -0
  13. package/commands/loop-pulse.md +38 -0
  14. package/commands/loop-stop.md +29 -0
  15. package/commands/loop.md +17 -0
  16. package/commands/ralph-loop.md +18 -0
  17. package/core/engine.mjs +84 -0
  18. package/core/event-logger.mjs +37 -0
  19. package/core/loop.schema.json +29 -0
  20. package/hooks/hooks.json +15 -0
  21. package/hooks/stop-hook.mjs +79 -0
  22. package/lib/paths.mjs +91 -0
  23. package/lib/state.mjs +46 -0
  24. package/lib/stop-hook-core.mjs +162 -0
  25. package/package.json +57 -0
  26. package/platforms/claude-code/adapter.mjs +20 -0
  27. package/platforms/claude-code/installer.mjs +165 -0
  28. package/platforms/codex-cli/adapter.mjs +20 -0
  29. package/platforms/codex-cli/installer.mjs +131 -0
  30. package/platforms/kiro-cli/adapter.mjs +21 -0
  31. package/platforms/kiro-cli/installer.mjs +115 -0
  32. package/scripts/setup-ralph-loop.sh +145 -0
  33. package/skills/ralph-claude-cancel/SKILL.md +23 -0
  34. package/skills/ralph-claude-interview/SKILL.md +178 -0
  35. package/skills/ralph-claude-loop/SKILL.md +101 -0
  36. package/skills/ralph-claude-orchestrator/SKILL.md +129 -0
  37. package/skills/ralph-interview/SKILL.md +275 -0
  38. package/skills/ralph-orchestrator/SKILL.md +254 -0
  39. 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)"
@@ -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.
@@ -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
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "description": "loophaus stop hook for iterative coding loops",
3
+ "hooks": {
4
+ "Stop": [
5
+ {
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.mjs"
10
+ }
11
+ ]
12
+ }
13
+ ]
14
+ }
15
+ }
@@ -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
+ }