Browse Source

Keep todo hygiene reminders request-local

Alvin Unreal 3 months ago
parent
commit
e05766b75a

+ 157 - 181
src/hooks/todo-continuation/index.test.ts

@@ -84,6 +84,16 @@ describe('createTodoContinuationHook', () => {
     };
   }
 
+  function allMessageText(output: {
+    messages: Array<{ parts: Array<{ type?: string; text?: string }> }>;
+  }) {
+    return output.messages
+      .flatMap((message) => message.parts)
+      .filter((part) => part.type === 'text' && typeof part.text === 'string')
+      .map((part) => part.text)
+      .join('\n');
+  }
+
   describe('tool toggle', () => {
     test('calling auto_continue execute with { enabled: true } sets state', async () => {
       const ctx = createMockContext();
@@ -147,7 +157,7 @@ describe('createTodoContinuationHook', () => {
       expect('handleChatSystemTransform' in hook).toBe(false);
     });
 
-    test('injects hygiene reminder into tool output after todowrite activity', async () => {
+    test('injects hygiene reminder into latest user message after todowrite activity', async () => {
       const ctx = createMockContext({
         todoResult: {
           data: [
@@ -156,11 +166,10 @@ describe('createTodoContinuationHook', () => {
         },
       });
       const hook = createTodoContinuationHook(ctx);
+      const output = userMessages('primera request', 'main1', 'orchestrator');
       const toolOutput = { output: 'read result' };
 
-      await hook.handleMessagesTransform(
-        userMessages('primera request', 'main1', 'orchestrator'),
-      );
+      await hook.handleMessagesTransform(output);
       await hook.handleToolExecuteAfter({
         tool: 'todowrite',
         sessionID: 'main1',
@@ -169,12 +178,16 @@ describe('createTodoContinuationHook', () => {
         { tool: 'read', sessionID: 'main1' },
         toolOutput,
       );
+      await hook.handleMessagesTransform(output);
 
-      expect(toolOutput.output).toContain(TODO_HYGIENE_REMINDER);
-      expect(toolOutput.output).toContain('<internal_reminder>');
+      expect(toolOutput.output).toBe('read result');
+      expect(allMessageText(output)).toContain(TODO_HYGIENE_REMINDER);
+      expect(allMessageText(output)).toContain(
+        '<instruction name="todo_hygiene">',
+      );
     });
 
-    test('new requests clear stale pending reminder state', async () => {
+    test('new request clears stale pending reminder state', async () => {
       const ctx = createMockContext({
         todoResult: {
           data: [
@@ -183,40 +196,39 @@ describe('createTodoContinuationHook', () => {
         },
       });
       const hook = createTodoContinuationHook(ctx);
-      const firstOutput = { output: 'first read' };
-      const secondOutput = { output: 'second read' };
-
-      await hook.handleMessagesTransform(
-        userMessages('primera request', 'main1', 'orchestrator'),
+      const first = userMessages('primera request', 'main1', 'orchestrator');
+      const blocked = userMessages(
+        'segunda request distinta',
+        'main1',
+        'orchestrator',
       );
+      const allowed = userMessages(
+        'segunda request distinta',
+        'main1',
+        'orchestrator',
+      );
+
+      await hook.handleMessagesTransform(first);
       await hook.handleToolExecuteAfter({
         tool: 'todowrite',
         sessionID: 'main1',
       });
-      await hook.handleToolExecuteAfter(
-        { tool: 'read', sessionID: 'main1' },
-        firstOutput,
-      );
+      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
 
-      await hook.handleMessagesTransform(
-        userMessages('segunda request distinta', 'main1', 'orchestrator'),
-      );
-      // First output should have reminder from first round
-      expect(firstOutput.output).toContain(TODO_HYGIENE_REMINDER);
+      await hook.handleMessagesTransform(blocked);
+      expect(allMessageText(blocked)).not.toContain(TODO_HYGIENE_REMINDER);
 
       await hook.handleToolExecuteAfter({
         tool: 'todowrite',
         sessionID: 'main1',
       });
-      await hook.handleToolExecuteAfter(
-        { tool: 'read', sessionID: 'main1' },
-        secondOutput,
-      );
+      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
+      await hook.handleMessagesTransform(allowed);
 
-      expect(secondOutput.output).toContain(TODO_HYGIENE_REMINDER);
+      expect(allMessageText(allowed)).toContain(TODO_HYGIENE_REMINDER);
     });
 
-    test('attachment-only requests still reset stale pending reminder state', async () => {
+    test('attachment-only requests reset stale state without synthetic text parts', async () => {
       const ctx = createMockContext({
         todoResult: {
           data: [
@@ -225,37 +237,24 @@ describe('createTodoContinuationHook', () => {
         },
       });
       const hook = createTodoContinuationHook(ctx);
-      const firstOutput = { output: 'first read' };
-      const secondOutput = { output: 'second read' };
+      const first = userMessages('primera request', 'main1', 'orchestrator');
+      const attachmentOnly = userMessages('', 'main1', 'orchestrator', [
+        { type: 'image' },
+      ]);
 
-      await hook.handleMessagesTransform(
-        userMessages('primera request', 'main1', 'orchestrator'),
-      );
+      await hook.handleMessagesTransform(first);
       await hook.handleToolExecuteAfter({
         tool: 'todowrite',
         sessionID: 'main1',
       });
-      await hook.handleToolExecuteAfter(
-        { tool: 'read', sessionID: 'main1' },
-        firstOutput,
-      );
+      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
 
-      await hook.handleMessagesTransform(
-        userMessages('', 'main1', 'orchestrator', [{ type: 'image' }]),
-      );
-      // First output should have reminder
-      expect(firstOutput.output).toContain(TODO_HYGIENE_REMINDER);
+      await hook.handleMessagesTransform(attachmentOnly);
 
-      await hook.handleToolExecuteAfter({
-        tool: 'todowrite',
-        sessionID: 'main1',
-      });
-      await hook.handleToolExecuteAfter(
-        { tool: 'read', sessionID: 'main1' },
-        secondOutput,
+      expect(attachmentOnly.messages[0].parts).toHaveLength(1);
+      expect(allMessageText(attachmentOnly)).not.toContain(
+        TODO_HYGIENE_REMINDER,
       );
-
-      expect(secondOutput.output).toContain(TODO_HYGIENE_REMINDER);
     });
 
     test('falls back to known orchestrator session when transform message lacks sessionID', async () => {
@@ -272,27 +271,24 @@ describe('createTodoContinuationHook', () => {
         },
       });
       const hook = createTodoContinuationHook(ctx);
-      const todowriteOutput = { output: 'todowrite result' };
-
-      hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
-      await hook.handleMessagesTransform({
+      const output = {
         messages: [
           {
             info: { role: 'user', agent: 'orchestrator' },
             parts: [{ type: 'text', text: 'new request boundary' }],
           },
         ],
+      };
+
+      hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
+      await hook.handleMessagesTransform(output);
+      await hook.handleToolExecuteAfter({
+        tool: 'todowrite',
+        sessionID: 'main1',
       });
-      // Final-active reminder is injected directly into todowrite output when state is final-active
-      await hook.handleToolExecuteAfter(
-        {
-          tool: 'todowrite',
-          sessionID: 'main1',
-        },
-        todowriteOutput,
-      );
+      await hook.handleMessagesTransform(output);
 
-      expect(todowriteOutput.output).toContain(TODO_FINAL_ACTIVE_REMINDER);
+      expect(allMessageText(output)).toContain(TODO_FINAL_ACTIVE_REMINDER);
     });
 
     test('does not promote sessions with missing agent metadata to orchestrator', async () => {
@@ -333,25 +329,20 @@ describe('createTodoContinuationHook', () => {
         },
       });
       const hook = createTodoContinuationHook(ctx);
-      const todowriteOutput = { output: 'todowrite result' };
+      const output = userMessages('new request boundary', 'main1');
 
       hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
-      await hook.handleMessagesTransform(
-        userMessages('new request boundary', 'main1'),
-      );
-      // Final-active reminder is injected directly into todowrite output when state is final-active
-      await hook.handleToolExecuteAfter(
-        {
-          tool: 'todowrite',
-          sessionID: 'main1',
-        },
-        todowriteOutput,
-      );
+      await hook.handleMessagesTransform(output);
+      await hook.handleToolExecuteAfter({
+        tool: 'todowrite',
+        sessionID: 'main1',
+      });
+      await hook.handleMessagesTransform(output);
 
-      expect(todowriteOutput.output).toContain(TODO_FINAL_ACTIVE_REMINDER);
+      expect(allMessageText(output)).toContain(TODO_FINAL_ACTIVE_REMINDER);
     });
 
-    test('the same user message id does not reset the request when its array index shifts', async () => {
+    test('the same user message id consumes pending reminder even if array index shifts', async () => {
       const ctx = createMockContext({
         todoResult: {
           data: [
@@ -360,26 +351,7 @@ describe('createTodoContinuationHook', () => {
         },
       });
       const hook = createTodoContinuationHook(ctx);
-      const toolOutput = { output: 'read result' };
-
-      await hook.handleMessagesTransform(
-        userMessages(
-          'request boundary',
-          'main1',
-          'orchestrator',
-          undefined,
-          'u1',
-        ),
-      );
-      await hook.handleToolExecuteAfter({
-        tool: 'todowrite',
-        sessionID: 'main1',
-      });
-      await hook.handleToolExecuteAfter(
-        { tool: 'read', sessionID: 'main1' },
-        toolOutput,
-      );
-      await hook.handleMessagesTransform({
+      const shifted = {
         messages: [
           {
             info: { role: 'assistant', sessionID: 'main1' },
@@ -395,13 +367,28 @@ describe('createTodoContinuationHook', () => {
             parts: [{ type: 'text', text: 'request boundary' }],
           },
         ],
+      };
+
+      await hook.handleMessagesTransform(
+        userMessages(
+          'request boundary',
+          'main1',
+          'orchestrator',
+          undefined,
+          'u1',
+        ),
+      );
+      await hook.handleToolExecuteAfter({
+        tool: 'todowrite',
+        sessionID: 'main1',
       });
+      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
+      await hook.handleMessagesTransform(shifted);
 
-      // Tool output should still have reminder since it's the same request
-      expect(toolOutput.output).toContain(TODO_HYGIENE_REMINDER);
+      expect(allMessageText(shifted)).toContain(TODO_HYGIENE_REMINDER);
     });
 
-    test('a new user message id resets the request even if the text is unchanged', async () => {
+    test('a new user message id resets the request even if text is unchanged', async () => {
       const ctx = createMockContext({
         todoResult: {
           data: [
@@ -410,8 +397,20 @@ describe('createTodoContinuationHook', () => {
         },
       });
       const hook = createTodoContinuationHook(ctx);
-      const firstOutput = { output: 'first read' };
-      const secondOutput = { output: 'second read' };
+      const blocked = userMessages(
+        'same text',
+        'main1',
+        'orchestrator',
+        undefined,
+        'u2',
+      );
+      const allowed = userMessages(
+        'same text',
+        'main1',
+        'orchestrator',
+        undefined,
+        'u2',
+      );
 
       await hook.handleMessagesTransform(
         userMessages('same text', 'main1', 'orchestrator', undefined, 'u1'),
@@ -420,30 +419,22 @@ describe('createTodoContinuationHook', () => {
         tool: 'todowrite',
         sessionID: 'main1',
       });
-      await hook.handleToolExecuteAfter(
-        { tool: 'read', sessionID: 'main1' },
-        firstOutput,
-      );
+      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
 
-      await hook.handleMessagesTransform(
-        userMessages('same text', 'main1', 'orchestrator', undefined, 'u2'),
-      );
-      // First output should have reminder
-      expect(firstOutput.output).toContain(TODO_HYGIENE_REMINDER);
+      await hook.handleMessagesTransform(blocked);
+      expect(allMessageText(blocked)).not.toContain(TODO_HYGIENE_REMINDER);
 
       await hook.handleToolExecuteAfter({
         tool: 'todowrite',
         sessionID: 'main1',
       });
-      await hook.handleToolExecuteAfter(
-        { tool: 'read', sessionID: 'main1' },
-        secondOutput,
-      );
+      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
+      await hook.handleMessagesTransform(allowed);
 
-      expect(secondOutput.output).toContain(TODO_HYGIENE_REMINDER);
+      expect(allMessageText(allowed)).toContain(TODO_HYGIENE_REMINDER);
     });
 
-    test('a repeated text without message ids still resets when a later user turn appears', async () => {
+    test('a repeated text without message ids resets when a later user turn appears', async () => {
       const ctx = createMockContext({
         todoResult: {
           data: [
@@ -452,22 +443,7 @@ describe('createTodoContinuationHook', () => {
         },
       });
       const hook = createTodoContinuationHook(ctx);
-      const firstOutput = { output: 'first read' };
-      const secondOutput = { output: 'second read' };
-
-      await hook.handleMessagesTransform(
-        userMessages('same text', 'main1', 'orchestrator'),
-      );
-      await hook.handleToolExecuteAfter({
-        tool: 'todowrite',
-        sessionID: 'main1',
-      });
-      await hook.handleToolExecuteAfter(
-        { tool: 'read', sessionID: 'main1' },
-        firstOutput,
-      );
-
-      await hook.handleMessagesTransform({
+      const blocked = {
         messages: [
           {
             info: { role: 'user', agent: 'orchestrator', sessionID: 'main1' },
@@ -482,20 +458,29 @@ describe('createTodoContinuationHook', () => {
             parts: [{ type: 'text', text: 'same text' }],
           },
         ],
+      };
+      const allowed = structuredClone(blocked);
+
+      await hook.handleMessagesTransform(
+        userMessages('same text', 'main1', 'orchestrator'),
+      );
+      await hook.handleToolExecuteAfter({
+        tool: 'todowrite',
+        sessionID: 'main1',
       });
-      // First output should have reminder
-      expect(firstOutput.output).toContain(TODO_HYGIENE_REMINDER);
+      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
+
+      await hook.handleMessagesTransform(blocked);
+      expect(allMessageText(blocked)).not.toContain(TODO_HYGIENE_REMINDER);
 
       await hook.handleToolExecuteAfter({
         tool: 'todowrite',
         sessionID: 'main1',
       });
-      await hook.handleToolExecuteAfter(
-        { tool: 'read', sessionID: 'main1' },
-        secondOutput,
-      );
+      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
+      await hook.handleMessagesTransform(allowed);
 
-      expect(secondOutput.output).toContain(TODO_HYGIENE_REMINDER);
+      expect(allMessageText(allowed)).toContain(TODO_HYGIENE_REMINDER);
     });
 
     test('messages without inferable sessionID clear stale state for known orchestrators', async () => {
@@ -507,7 +492,14 @@ describe('createTodoContinuationHook', () => {
         },
       });
       const hook = createTodoContinuationHook(ctx);
-      const toolOutput = { output: 'read result' };
+      const unknown = {
+        messages: [
+          {
+            info: { role: 'user', agent: 'orchestrator' },
+            parts: [{ type: 'text', text: 'boundary without session id' }],
+          },
+        ],
+      };
 
       hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
       hook.handleChatMessage({ sessionID: 'main2', agent: 'orchestrator' });
@@ -518,22 +510,11 @@ describe('createTodoContinuationHook', () => {
         tool: 'todowrite',
         sessionID: 'main1',
       });
-      await hook.handleToolExecuteAfter(
-        { tool: 'read', sessionID: 'main1' },
-        toolOutput,
-      );
+      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
 
-      await hook.handleMessagesTransform({
-        messages: [
-          {
-            info: { role: 'user', agent: 'orchestrator' },
-            parts: [{ type: 'text', text: 'boundary without session id' }],
-          },
-        ],
-      });
+      await hook.handleMessagesTransform(unknown);
 
-      // Tool output should still have reminder from first round
-      expect(toolOutput.output).toContain(TODO_HYGIENE_REMINDER);
+      expect(allMessageText(unknown)).not.toContain(TODO_HYGIENE_REMINDER);
     });
 
     test('does not inject from continuation-like wording alone', async () => {
@@ -583,21 +564,20 @@ describe('createTodoContinuationHook', () => {
         },
       });
       const hook = createTodoContinuationHook(ctx);
-      const todowriteOutput = { output: 'todowrite result' };
-
-      await hook.handleMessagesTransform(
-        userMessages('finish the previous work', 'main1', 'orchestrator'),
-      );
-      // Final-active reminder is injected directly into todowrite output when state is final-active
-      await hook.handleToolExecuteAfter(
-        {
-          tool: 'todowrite',
-          sessionID: 'main1',
-        },
-        todowriteOutput,
+      const output = userMessages(
+        'finish the previous work',
+        'main1',
+        'orchestrator',
       );
 
-      expect(todowriteOutput.output).toContain(TODO_FINAL_ACTIVE_REMINDER);
+      await hook.handleMessagesTransform(output);
+      await hook.handleToolExecuteAfter({
+        tool: 'todowrite',
+        sessionID: 'main1',
+      });
+      await hook.handleMessagesTransform(output);
+
+      expect(allMessageText(output)).toContain(TODO_FINAL_ACTIVE_REMINDER);
     });
 
     test('final active todo after todowrite uses the stronger finishing reminder', async () => {
@@ -614,21 +594,17 @@ describe('createTodoContinuationHook', () => {
         },
       });
       const hook = createTodoContinuationHook(ctx);
-      const toolOutput = { output: 'todowrite result' };
+      const output = userMessages('haz esto', 'main1', 'orchestrator');
 
-      await hook.handleMessagesTransform(
-        userMessages('haz esto', 'main1', 'orchestrator'),
-      );
-      await hook.handleToolExecuteAfter(
-        {
-          tool: 'todowrite',
-          sessionID: 'main1',
-        },
-        toolOutput,
-      );
+      await hook.handleMessagesTransform(output);
+      await hook.handleToolExecuteAfter({
+        tool: 'todowrite',
+        sessionID: 'main1',
+      });
+      await hook.handleMessagesTransform(output);
 
-      expect(toolOutput.output).toContain(TODO_FINAL_ACTIVE_REMINDER);
-      expect(toolOutput.output).not.toContain(TODO_HYGIENE_REMINDER);
+      expect(allMessageText(output)).toContain(TODO_FINAL_ACTIVE_REMINDER);
+      expect(allMessageText(output)).not.toContain(TODO_HYGIENE_REMINDER);
     });
   });
 

+ 63 - 6
src/hooks/todo-continuation/index.ts

@@ -12,6 +12,8 @@ const COMMAND_NAME = 'auto-continue';
 
 const CONTINUATION_PROMPT =
   '[Auto-continue: enabled - there are incomplete todos remaining. Continue with the next uncompleted item. Press Esc to cancel. If you need user input or review for the next item, ask instead of proceeding.]';
+const TODO_HYGIENE_INSTRUCTION_OPEN = '<instruction name="todo_hygiene">';
+const TODO_HYGIENE_INSTRUCTION_CLOSE = '</instruction>';
 
 // Suppress window after user abort (Esc/Ctrl+C) to avoid immediately
 // re-continuing something the user explicitly stopped
@@ -90,6 +92,13 @@ interface ChatTransformMessage {
   parts: MessagePart[];
 }
 
+interface LastExternalUserMessage {
+  sessionID?: string;
+  agent?: string;
+  signature: string;
+  message: ChatTransformMessage;
+}
+
 interface Message {
   info?: MessageInfo;
   parts?: MessagePart[];
@@ -112,6 +121,45 @@ function resetState(state: ContinuationState): void {
   state.notificationBusyUntilBySession.clear();
 }
 
+function stripTodoHygieneInstruction(text: string): string {
+  const trimmed = text.trimEnd();
+  if (!trimmed.endsWith(TODO_HYGIENE_INSTRUCTION_CLOSE)) {
+    return trimmed;
+  }
+
+  const start = trimmed.lastIndexOf(TODO_HYGIENE_INSTRUCTION_OPEN);
+  if (start === -1) {
+    return trimmed;
+  }
+
+  return trimmed.slice(0, start).trimEnd();
+}
+
+function appendTodoHygieneInstruction(
+  message: ChatTransformMessage,
+  reminder: string,
+): void {
+  const textPart = [...message.parts]
+    .reverse()
+    .find((part) => part.type === 'text' && typeof part.text === 'string');
+  if (!textPart) return;
+
+  const baseText = stripTodoHygieneInstruction(textPart.text ?? '');
+  const instruction = `${TODO_HYGIENE_INSTRUCTION_OPEN}\n${reminder}\n${TODO_HYGIENE_INSTRUCTION_CLOSE}`;
+  textPart.text = baseText ? `${baseText}\n\n${instruction}` : instruction;
+}
+
+function stripTodoHygieneInstructionFromMessage(
+  message: ChatTransformMessage,
+): void {
+  const textPart = [...message.parts]
+    .reverse()
+    .find((part) => part.type === 'text' && typeof part.text === 'string');
+  if (!textPart) return;
+
+  textPart.text = stripTodoHygieneInstruction(textPart.text ?? '');
+}
+
 export function createTodoContinuationHook(
   ctx: PluginInput,
   config?: {
@@ -246,11 +294,9 @@ export function createTodoContinuationHook(
     );
   }
 
-  function getLastExternalUserMessage(messages: ChatTransformMessage[]): {
-    sessionID?: string;
-    agent?: string;
-    signature: string;
-  } | null {
+  function getLastExternalUserMessage(
+    messages: ChatTransformMessage[],
+  ): LastExternalUserMessage | null {
     for (let i = messages.length - 1; i >= 0; i--) {
       const message = messages[i];
       if (!isExternalUserMessage(message)) {
@@ -262,7 +308,8 @@ export function createTodoContinuationHook(
       const partSignature = message.parts
         .map((part) => {
           if (part.type === 'text' && typeof part.text === 'string') {
-            return `${part.type}:${part.text.includes(SLIM_INTERNAL_INITIATOR_MARKER) ? '<internal>' : part.text.trim()}`;
+            const text = stripTodoHygieneInstruction(part.text);
+            return `${part.type}:${text.includes(SLIM_INTERNAL_INITIATOR_MARKER) ? '<internal>' : text.trim()}`;
           }
           return part.type ?? 'unknown';
         })
@@ -274,6 +321,7 @@ export function createTodoContinuationHook(
       return {
         sessionID,
         agent: message.info.agent,
+        message,
         signature: message.info.id
           ? `${message.info.id}:${partSignature}`
           : `${ordinal}:${partSignature}`,
@@ -314,6 +362,14 @@ export function createTodoContinuationHook(
       requestSignatureBySession.get(lastUserMessage.sessionID) ===
       lastUserMessage.signature
     ) {
+      const reminder = hygiene.consumePendingReminder(
+        lastUserMessage.sessionID,
+      );
+      if (reminder) {
+        appendTodoHygieneInstruction(lastUserMessage.message, reminder);
+      } else {
+        stripTodoHygieneInstructionFromMessage(lastUserMessage.message);
+      }
       return;
     }
 
@@ -321,6 +377,7 @@ export function createTodoContinuationHook(
       lastUserMessage.sessionID,
       lastUserMessage.signature,
     );
+    stripTodoHygieneInstructionFromMessage(lastUserMessage.message);
     hygiene.handleRequestStart({ sessionID: lastUserMessage.sessionID });
   }
 

+ 36 - 262
src/hooks/todo-continuation/todo-hygiene.test.ts

@@ -21,131 +21,47 @@ function createState(
   };
 }
 
-function createToolOutput(output = 'tool result') {
-  return { output };
-}
-
 describe('todo hygiene', () => {
   test('new request clears pending state from the previous turn', async () => {
     const hook = createTodoHygiene({
       getTodoState: async () => createState(),
     });
-    const staleOutput = createToolOutput('stale tool result');
-    const freshOutput = createToolOutput('fresh tool result');
 
     hook.handleRequestStart({ sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
-    await hook.handleToolExecuteAfter(
-      { tool: 'read', sessionID: 's1' },
-      staleOutput,
-    );
-
+    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
     hook.handleRequestStart({ sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
-    await hook.handleToolExecuteAfter(
-      { tool: 'read', sessionID: 's1' },
-      freshOutput,
-    );
 
-    expect(staleOutput.output).toContain(TODO_HYGIENE_REMINDER);
-    expect(freshOutput.output).toContain(TODO_HYGIENE_REMINDER);
-  });
+    expect(hook.consumePendingReminder('s1')).toBeNull();
 
-  test('does not expose a system transform handler', async () => {
-    const hook = createTodoHygiene({
-      getTodoState: async () => createState(),
-    });
+    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
+    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
 
-    expect('handleChatSystemTransform' in hook).toBe(false);
+    expect(hook.consumePendingReminder('s1')).toBe(TODO_HYGIENE_REMINDER);
   });
 
   test('does not arm before the current request calls todowrite', async () => {
     const hook = createTodoHygiene({
       getTodoState: async () => createState(),
     });
-    const output = createToolOutput();
 
     hook.handleRequestStart({ sessionID: 's1' });
-    await hook.handleToolExecuteAfter(
-      { tool: 'read', sessionID: 's1' },
-      output,
-    );
+    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
 
-    expect(output.output).toBe('tool result');
-    expect(output.output).not.toContain(TODO_HYGIENE_REMINDER);
+    expect(hook.consumePendingReminder('s1')).toBeNull();
   });
 
   test('arms after the first relevant tool following todowrite', async () => {
     const hook = createTodoHygiene({
       getTodoState: async () => createState(),
     });
-    const output = createToolOutput();
-
-    hook.handleRequestStart({ sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
-    await hook.handleToolExecuteAfter(
-      { tool: 'read', sessionID: 's1' },
-      output,
-    );
-
-    expect(output.output).toContain(TODO_HYGIENE_REMINDER);
-    expect(output.output).toContain('<internal_reminder>');
-  });
-
-  test('multiple tools in the same round still inject only one reminder', async () => {
-    const hook = createTodoHygiene({
-      getTodoState: async () => createState(),
-    });
-    const output1 = createToolOutput('result 1');
-    const output2 = createToolOutput('result 2');
-    const output3 = createToolOutput('result 3');
-
-    hook.handleRequestStart({ sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
-    await hook.handleToolExecuteAfter(
-      { tool: 'read', sessionID: 's1' },
-      output1,
-    );
-    await hook.handleToolExecuteAfter(
-      { tool: 'grep', sessionID: 's1' },
-      output2,
-    );
-    await hook.handleToolExecuteAfter(
-      { tool: 'read', sessionID: 's1' },
-      output3,
-    );
-
-    // Only the first non-todowrite tool should get the reminder
-    expect(output1.output).toContain(TODO_HYGIENE_REMINDER);
-    expect(output2.output).not.toContain(TODO_HYGIENE_REMINDER);
-    expect(output3.output).not.toContain(TODO_HYGIENE_REMINDER);
-  });
-
-  test('injects again on a later round after new activity', async () => {
-    const hook = createTodoHygiene({
-      getTodoState: async () => createState(),
-    });
-    const firstOutput = createToolOutput('first result');
-    const secondOutput = createToolOutput('second result');
 
-    // First request cycle
     hook.handleRequestStart({ sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
-    await hook.handleToolExecuteAfter(
-      { tool: 'read', sessionID: 's1' },
-      firstOutput,
-    );
-
-    // Second request cycle - needs new request start to clear injected state
-    hook.handleRequestStart({ sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
-    await hook.handleToolExecuteAfter(
-      { tool: 'read', sessionID: 's1' },
-      secondOutput,
-    );
+    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
 
-    expect(firstOutput.output).toContain(TODO_HYGIENE_REMINDER);
-    expect(secondOutput.output).toContain(TODO_HYGIENE_REMINDER);
+    expect(hook.consumePendingReminder('s1')).toBe(TODO_HYGIENE_REMINDER);
+    expect(hook.consumePendingReminder('s1')).toBeNull();
   });
 
   test('upgrades to final-active on a later round', async () => {
@@ -153,9 +69,7 @@ describe('todo hygiene', () => {
     const hook = createTodoHygiene({
       getTodoState: async () => {
         call++;
-        if (call <= 3) {
-          return createState();
-        }
+        if (call <= 3) return createState();
         return createState({
           openCount: 1,
           inProgressCount: 1,
@@ -163,27 +77,16 @@ describe('todo hygiene', () => {
         });
       },
     });
-    const firstOutput = createToolOutput('first result');
-    const secondOutput = createToolOutput('second result');
 
-    // First request cycle - general reminder
     hook.handleRequestStart({ sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
-    await hook.handleToolExecuteAfter(
-      { tool: 'read', sessionID: 's1' },
-      firstOutput,
-    );
+    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
+    expect(hook.consumePendingReminder('s1')).toBe(TODO_HYGIENE_REMINDER);
 
-    // Second request cycle - final-active reminder (state changed)
     hook.handleRequestStart({ sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
-    await hook.handleToolExecuteAfter(
-      { tool: 'read', sessionID: 's1' },
-      secondOutput,
-    );
-
-    expect(firstOutput.output).toContain(TODO_HYGIENE_REMINDER);
-    expect(secondOutput.output).toContain(TODO_FINAL_ACTIVE_REMINDER);
+    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
+    expect(hook.consumePendingReminder('s1')).toBe(TODO_FINAL_ACTIVE_REMINDER);
   });
 
   test('todowrite can arm final-active immediately', async () => {
@@ -195,16 +98,11 @@ describe('todo hygiene', () => {
           pendingCount: 0,
         }),
     });
-    const output = createToolOutput();
 
     hook.handleRequestStart({ sessionID: 's1' });
-    await hook.handleToolExecuteAfter(
-      { tool: 'todowrite', sessionID: 's1' },
-      output,
-    );
+    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
 
-    expect(output.output).toContain(TODO_FINAL_ACTIVE_REMINDER);
-    expect(output.output).toContain('<internal_reminder>');
+    expect(hook.consumePendingReminder('s1')).toBe(TODO_FINAL_ACTIVE_REMINDER);
   });
 
   test('once final-active is armed, later tools skip extra todo lookups in the same round', async () => {
@@ -228,186 +126,67 @@ describe('todo hygiene', () => {
     expect(calls).toBe(1);
   });
 
-  test('shouldInject rejection consumes the pending reminder', async () => {
-    const hook = createTodoHygiene({
-      getTodoState: async () => createState(),
-      shouldInject: () => false,
-    });
-    const output = createToolOutput();
-
-    hook.handleRequestStart({ sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
-    await hook.handleToolExecuteAfter(
-      { tool: 'read', sessionID: 's1' },
-      output,
-    );
-
-    expect(output.output).toBe('tool result');
-    expect(output.output).not.toContain(TODO_HYGIENE_REMINDER);
-  });
-
-  test('shouldInject rejection prevents immediate final-active reminder', async () => {
+  test('shouldInject rejection prevents reset lookup and reminders', async () => {
+    let calls = 0;
     const hook = createTodoHygiene({
-      getTodoState: async () =>
-        createState({
+      getTodoState: async () => {
+        calls++;
+        return createState({
           openCount: 1,
           inProgressCount: 1,
           pendingCount: 0,
-        }),
+        });
+      },
       shouldInject: () => false,
     });
-    const output = createToolOutput();
 
     hook.handleRequestStart({ sessionID: 's1' });
-    await hook.handleToolExecuteAfter(
-      { tool: 'todowrite', sessionID: 's1' },
-      output,
-    );
-
-    expect(output.output).toBe('tool result');
-    expect(output.output).not.toContain(TODO_FINAL_ACTIVE_REMINDER);
-  });
-
-  test('final-active reminder wins when only one active todo remains', async () => {
-    const hook = createTodoHygiene({
-      getTodoState: async () =>
-        createState({
-          openCount: 1,
-          inProgressCount: 1,
-          pendingCount: 0,
-        }),
-    });
-    const output = createToolOutput();
-
-    hook.handleRequestStart({ sessionID: 's1' });
-    await hook.handleToolExecuteAfter(
-      { tool: 'todowrite', sessionID: 's1' },
-      output,
-    );
+    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
+    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
 
-    expect(output.output).toContain(TODO_FINAL_ACTIVE_REMINDER);
-    expect(output.output).not.toContain(TODO_HYGIENE_REMINDER);
+    expect(calls).toBe(0);
+    expect(hook.consumePendingReminder('s1')).toBeNull();
   });
 
-  test('transform lookup failures are best-effort and do not drop later reminders', async () => {
+  test('consuming a pending reminder does not inspect todos', async () => {
     let fail = false;
     const hook = createTodoHygiene({
       getTodoState: async () => {
-        if (fail) {
-          throw new Error('boom');
-        }
+        if (fail) throw new Error('boom');
         return createState();
       },
     });
-    const firstOutput = createToolOutput('first result');
-    const failedOutput = createToolOutput('failed result');
-    const recoveredOutput = createToolOutput('recovered result');
 
-    // First cycle - succeeds
     hook.handleRequestStart({ sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
-    await hook.handleToolExecuteAfter(
-      { tool: 'read', sessionID: 's1' },
-      firstOutput,
-    );
-
-    // Second cycle - todowrite fails but read succeeds
-    hook.handleRequestStart({ sessionID: 's1' });
+    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
     fail = true;
-    await hook.handleToolExecuteAfter(
-      { tool: 'todowrite', sessionID: 's1' },
-      failedOutput,
-    );
-    fail = false;
-    await hook.handleToolExecuteAfter(
-      { tool: 'read', sessionID: 's1' },
-      recoveredOutput,
-    );
-
-    expect(firstOutput.output).toContain(TODO_HYGIENE_REMINDER);
-    expect(failedOutput.output).toBe('failed result');
-    expect(failedOutput.output).not.toContain(TODO_HYGIENE_REMINDER);
-    expect(recoveredOutput.output).toContain(TODO_HYGIENE_REMINDER);
-  });
 
-  test('a late tool failure does not clear a reminder already armed for the round', async () => {
-    let call = 0;
-    const hook = createTodoHygiene({
-      getTodoState: async () => {
-        call++;
-        if (call === 3) {
-          throw new Error('boom');
-        }
-        return createState();
-      },
-    });
-    const output = createToolOutput();
-
-    hook.handleRequestStart({ sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
-    await hook.handleToolExecuteAfter(
-      { tool: 'read', sessionID: 's1' },
-      output,
-    );
-    await hook.handleToolExecuteAfter({ tool: 'grep', sessionID: 's1' });
-
-    expect(output.output).toContain(TODO_HYGIENE_REMINDER);
+    expect(hook.consumePendingReminder('s1')).toBe(TODO_HYGIENE_REMINDER);
   });
 
   test('todowrite lookup failures do not disable the current request', async () => {
     let fail = false;
     const hook = createTodoHygiene({
       getTodoState: async () => {
-        if (fail) {
-          throw new Error('boom');
-        }
+        if (fail) throw new Error('boom');
         return createState();
       },
     });
-    const output = createToolOutput();
 
     hook.handleRequestStart({ sessionID: 's1' });
     fail = true;
     await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
     fail = false;
-    await hook.handleToolExecuteAfter(
-      { tool: 'read', sessionID: 's1' },
-      output,
-    );
-    await hook.handleToolExecuteAfter({ tool: 'grep', sessionID: 's1' });
-
-    expect(output.output).toContain(TODO_HYGIENE_REMINDER);
-  });
-
-  test('non-injectable sessions are fully cleared after a rejected round', async () => {
-    let calls = 0;
-    const hook = createTodoHygiene({
-      getTodoState: async () => {
-        calls++;
-        return createState();
-      },
-      shouldInject: () => false,
-    });
-    const output = createToolOutput();
-
-    hook.handleRequestStart({ sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
-    await hook.handleToolExecuteAfter(
-      { tool: 'read', sessionID: 's1' },
-      output,
-    );
-    await hook.handleToolExecuteAfter({ tool: 'grep', sessionID: 's1' });
+    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
 
-    expect(calls).toBe(0);
-    expect(output.output).toBe('tool result');
-    expect(output.output).not.toContain(TODO_HYGIENE_REMINDER);
+    expect(hook.consumePendingReminder('s1')).toBe(TODO_HYGIENE_REMINDER);
   });
 
   test('session.deleted clears all state', async () => {
     const hook = createTodoHygiene({
       getTodoState: async () => createState(),
     });
-    const output = createToolOutput();
 
     hook.handleRequestStart({ sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
@@ -416,12 +195,7 @@ describe('todo hygiene', () => {
       type: 'session.deleted',
       properties: { info: { id: 's1' } },
     });
-    await hook.handleToolExecuteAfter(
-      { tool: 'grep', sessionID: 's1' },
-      output,
-    );
 
-    expect(output.output).toBe('tool result');
-    expect(output.output).not.toContain(TODO_HYGIENE_REMINDER);
+    expect(hook.consumePendingReminder('s1')).toBeNull();
   });
 });

+ 22 - 42
src/hooks/todo-continuation/todo-hygiene.ts

@@ -13,10 +13,6 @@ interface ToolInput {
   sessionID?: string;
 }
 
-interface ToolOutput {
-  output?: unknown;
-}
-
 interface EventInput {
   type: string;
   properties?: {
@@ -43,11 +39,9 @@ interface Options {
 export function createTodoHygiene(options: Options) {
   const pending = new Map<string, Set<Reason>>();
   const active = new Set<string>();
-  const injected = new Set<string>();
 
   function clearCycle(sessionID: string): void {
     pending.delete(sessionID);
-    injected.delete(sessionID);
   }
 
   function clear(sessionID: string): void {
@@ -81,27 +75,6 @@ export function createTodoHygiene(options: Options) {
     return TODO_HYGIENE_REMINDER;
   }
 
-  function appendReminder(
-    output: ToolOutput | undefined,
-    reminder: string,
-  ): void {
-    if (!output || typeof output.output !== 'string') {
-      return;
-    }
-
-    if (output.output.includes(reminder)) {
-      return;
-    }
-
-    output.output = [
-      output.output,
-      '',
-      '<internal_reminder>',
-      reminder,
-      '</internal_reminder>',
-    ].join('\n');
-  }
-
   return {
     handleRequestStart(input: RequestStartInput): void {
       clear(input.sessionID);
@@ -109,7 +82,7 @@ export function createTodoHygiene(options: Options) {
 
     async handleToolExecuteAfter(
       input: ToolInput,
-      output?: ToolOutput,
+      _output?: unknown,
     ): Promise<void> {
       if (!input.sessionID) {
         return;
@@ -148,9 +121,6 @@ export function createTodoHygiene(options: Options) {
           }
 
           mark(input.sessionID, 'final_active');
-          appendReminder(output, TODO_FINAL_ACTIVE_REMINDER);
-          pending.delete(input.sessionID);
-          injected.add(input.sessionID);
           options.log?.('Armed final-active todo hygiene reminder', {
             sessionID: input.sessionID,
             tool,
@@ -162,10 +132,6 @@ export function createTodoHygiene(options: Options) {
           return;
         }
 
-        if (injected.has(input.sessionID)) {
-          return;
-        }
-
         if (pending.get(input.sessionID)?.has('final_active')) {
           return;
         }
@@ -187,13 +153,6 @@ export function createTodoHygiene(options: Options) {
           mark(input.sessionID, 'general');
         }
 
-        const reasons = pending.get(input.sessionID);
-        if (reasons) {
-          appendReminder(output, pick(reasons));
-          pending.delete(input.sessionID);
-          injected.add(input.sessionID);
-        }
-
         options.log?.('Armed todo hygiene reminder', {
           sessionID: input.sessionID,
           tool,
@@ -211,6 +170,27 @@ export function createTodoHygiene(options: Options) {
       }
     },
 
+    consumePendingReminder(sessionID: string): string | null {
+      const reasons = pending.get(sessionID);
+      if (!reasons || reasons.size === 0) {
+        return null;
+      }
+
+      if (options.shouldInject && !options.shouldInject(sessionID)) {
+        clear(sessionID);
+        return null;
+      }
+
+      const reminder = pick(reasons);
+      pending.delete(sessionID);
+      options.log?.('Consumed todo hygiene reminder', {
+        sessionID,
+        reminder,
+        reasons: Array.from(reasons),
+      });
+      return reminder;
+    },
+
     handleEvent(event: EventInput): void {
       if (event.type !== 'session.deleted') {
         return;