diff --git a/src/features/builtin-commands/templates/ralph-loop.ts b/src/features/builtin-commands/templates/ralph-loop.ts
index 65846393..de4b8ca0 100644
--- a/src/features/builtin-commands/templates/ralph-loop.ts
+++ b/src/features/builtin-commands/templates/ralph-loop.ts
@@ -17,7 +17,7 @@ export const RALPH_LOOP_TEMPLATE = `You are starting a Ralph Loop - a self-refer
## Exit Conditions
-1. **Completion**: Output \`DONE\` (or custom promise text) when fully complete
+1. **Completion**: Output your completion promise tag when fully complete
2. **Max Iterations**: Loop stops automatically at limit
3. **Cancel**: User runs \`/cancel-ralph\` command
diff --git a/src/hooks/ralph-loop/index.test.ts b/src/hooks/ralph-loop/index.test.ts
index 3a6a77ed..bf02831b 100644
--- a/src/hooks/ralph-loop/index.test.ts
+++ b/src/hooks/ralph-loop/index.test.ts
@@ -459,7 +459,7 @@ describe("ralph-loop", () => {
})
hook.startLoop("session-123", "Build something", { completionPromise: "COMPLETE" })
- writeFileSync(transcriptPath, JSON.stringify({ content: "Task done COMPLETE" }))
+ writeFileSync(transcriptPath, JSON.stringify({ type: "tool_result", tool_name: "write", tool_output: { output: "Task done COMPLETE" } }) + "\n")
// #when - session goes idle (transcriptPath now derived from sessionID via getTranscriptPath)
await hook.event({
@@ -703,10 +703,105 @@ describe("ralph-loop", () => {
expect(promptCalls[0].text).toContain("2/50")
})
+ test("should NOT detect completion from user message in transcript (issue #622)", async () => {
+ // #given - transcript contains user message with template text that includes completion promise
+ // This reproduces the bug where the RALPH_LOOP_TEMPLATE instructional text
+ // containing `DONE` is recorded as a user message and
+ // falsely triggers completion detection
+ const transcriptPath = join(TEST_DIR, "transcript.jsonl")
+ const templateText = `You are starting a Ralph Loop...
+Output DONE when fully complete`
+ const userEntry = JSON.stringify({
+ type: "user",
+ timestamp: new Date().toISOString(),
+ content: templateText,
+ })
+ writeFileSync(transcriptPath, userEntry + "\n")
+
+ const hook = createRalphLoopHook(createMockPluginInput(), {
+ getTranscriptPath: () => transcriptPath,
+ })
+ hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
+
+ // #when - session goes idle
+ await hook.event({
+ event: {
+ type: "session.idle",
+ properties: { sessionID: "session-123" },
+ },
+ })
+
+ // #then - loop should CONTINUE (user message completion promise is instructional, not actual)
+ expect(promptCalls.length).toBe(1)
+ expect(hook.getState()?.iteration).toBe(2)
+ })
+
+ test("should NOT detect completion from continuation prompt in transcript (issue #622)", async () => {
+ // #given - transcript contains continuation prompt (also a user message) with completion promise
+ const transcriptPath = join(TEST_DIR, "transcript.jsonl")
+ const continuationText = `RALPH LOOP 2/100
+When FULLY complete, output: DONE
+Original task: Build something`
+ const userEntry = JSON.stringify({
+ type: "user",
+ timestamp: new Date().toISOString(),
+ content: continuationText,
+ })
+ writeFileSync(transcriptPath, userEntry + "\n")
+
+ const hook = createRalphLoopHook(createMockPluginInput(), {
+ getTranscriptPath: () => transcriptPath,
+ })
+ hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
+
+ // #when - session goes idle
+ await hook.event({
+ event: {
+ type: "session.idle",
+ properties: { sessionID: "session-123" },
+ },
+ })
+
+ // #then - loop should CONTINUE (continuation prompt text is not actual completion)
+ expect(promptCalls.length).toBe(1)
+ expect(hook.getState()?.iteration).toBe(2)
+ })
+
+ test("should detect completion from tool_result entry in transcript", async () => {
+ // #given - transcript contains a tool_result with completion promise
+ const transcriptPath = join(TEST_DIR, "transcript.jsonl")
+ const toolResultEntry = JSON.stringify({
+ type: "tool_result",
+ timestamp: new Date().toISOString(),
+ tool_name: "write",
+ tool_input: {},
+ tool_output: { output: "Task complete! DONE" },
+ })
+ writeFileSync(transcriptPath, toolResultEntry + "\n")
+
+ const hook = createRalphLoopHook(createMockPluginInput(), {
+ getTranscriptPath: () => transcriptPath,
+ })
+ hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
+
+ // #when - session goes idle
+ await hook.event({
+ event: {
+ type: "session.idle",
+ properties: { sessionID: "session-123" },
+ },
+ })
+
+ // #then - loop should complete (tool_result contains actual completion output)
+ expect(promptCalls.length).toBe(0)
+ expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
+ expect(hook.getState()).toBeNull()
+ })
+
test("should check transcript BEFORE API to optimize performance", async () => {
// #given - transcript has completion promise
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
- writeFileSync(transcriptPath, JSON.stringify({ content: "DONE" }))
+ writeFileSync(transcriptPath, JSON.stringify({ type: "tool_result", tool_name: "write", tool_output: { output: "DONE" } }) + "\n")
mockSessionMessages = [
{ info: { role: "assistant" }, parts: [{ type: "text", text: "No promise here" }] },
]
@@ -736,7 +831,7 @@ describe("ralph-loop", () => {
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => transcriptPath,
})
- writeFileSync(transcriptPath, JSON.stringify({ content: "DONE" }))
+ writeFileSync(transcriptPath, JSON.stringify({ type: "tool_result", tool_name: "write", tool_output: { output: "DONE" } }) + "\n")
hook.startLoop("test-id", "Build API", { ultrawork: true })
// #when - idle event triggered
@@ -754,7 +849,7 @@ describe("ralph-loop", () => {
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => transcriptPath,
})
- writeFileSync(transcriptPath, JSON.stringify({ content: "DONE" }))
+ writeFileSync(transcriptPath, JSON.stringify({ type: "tool_result", tool_name: "write", tool_output: { output: "DONE" } }) + "\n")
hook.startLoop("test-id", "Build API")
// #when - idle event triggered
diff --git a/src/hooks/ralph-loop/index.ts b/src/hooks/ralph-loop/index.ts
index 9f27f201..c961fe66 100644
--- a/src/hooks/ralph-loop/index.ts
+++ b/src/hooks/ralph-loop/index.ts
@@ -100,7 +100,18 @@ export function createRalphLoopHook(
const content = readFileSync(transcriptPath, "utf-8")
const pattern = new RegExp(`\\s*${escapeRegex(promise)}\\s*`, "is")
- return pattern.test(content)
+ const lines = content.split("\n").filter(l => l.trim())
+
+ for (const line of lines) {
+ try {
+ const entry = JSON.parse(line)
+ if (entry.type === "user") continue
+ if (pattern.test(line)) return true
+ } catch {
+ continue
+ }
+ }
+ return false
} catch {
return false
}