소스 검색

fix: keep todo hygiene cache-friendly

dhaern 3 달 전
부모
커밋
8a60edb79a

+ 303 - 151
src/hooks/todo-continuation/index.test.ts

@@ -84,6 +84,14 @@ describe('createTodoContinuationHook', () => {
     };
   }
 
+  function allMessageText(output: {
+    messages: Array<{ parts: Array<{ text?: string }> }>;
+  }): string {
+    return output.messages
+      .flatMap((message) => message.parts.map((part) => part.text ?? ''))
+      .join('\n');
+  }
+
   describe('tool toggle', () => {
     test('calling auto_continue execute with { enabled: true } sets state', async () => {
       const ctx = createMockContext();
@@ -115,15 +123,13 @@ describe('createTodoContinuationHook', () => {
         },
       });
       const hook = createTodoContinuationHook(ctx);
-      const system = { system: ['base'] };
+      const output = userMessages('continue previous work', 'sub1', 'explorer');
 
-      await hook.handleMessagesTransform(
-        userMessages('continue previous work', 'sub1', 'explorer'),
-      );
+      await hook.handleMessagesTransform(output);
       await hook.handleToolExecuteAfter({ tool: 'task', sessionID: 'sub1' });
-      await hook.handleChatSystemTransform({ sessionID: 'sub1' }, system);
+      await hook.handleMessagesTransform(output);
 
-      expect(system.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
+      expect(allMessageText(output)).not.toContain(TODO_HYGIENE_REMINDER);
     });
 
     test('does not inject anything at request start before todowrite', async () => {
@@ -140,21 +146,40 @@ describe('createTodoContinuationHook', () => {
         },
       });
       const hook = createTodoContinuationHook(ctx);
+      const output = userMessages(
+        'continue with the unfinished work',
+        'main1',
+        'orchestrator',
+      );
+
+      await hook.handleMessagesTransform(output);
+      await hook.handleMessagesTransform(output);
+
+      expect(allMessageText(output)).not.toContain(TODO_HYGIENE_REMINDER);
+      expect(allMessageText(output)).not.toContain(TODO_FINAL_ACTIVE_REMINDER);
+    });
+
+    test('chat system transform does not inject dynamic hygiene reminder', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx);
+      const output = userMessages('haz esto', 'main1', 'orchestrator');
       const system = { system: ['base'] };
 
-      await hook.handleMessagesTransform(
-        userMessages(
-          'continue with the unfinished work',
-          'main1',
-          'orchestrator',
-        ),
-      );
+      await hook.handleMessagesTransform(output);
+      await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 'main1' });
+      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, system);
 
       expect(system.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
-      expect(system.system.join('\n')).not.toContain(
-        TODO_FINAL_ACTIVE_REMINDER,
-      );
+
+      await hook.handleMessagesTransform(output);
+      expect(allMessageText(output)).toContain(TODO_HYGIENE_REMINDER);
     });
 
     test('new requests clear stale pending reminder state', async () => {
@@ -166,31 +191,35 @@ describe('createTodoContinuationHook', () => {
         },
       });
       const hook = createTodoContinuationHook(ctx);
-      const blocked = { system: ['base'] };
-      const allowed = { system: ['base'] };
-
-      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' });
-      await hook.handleMessagesTransform(
-        userMessages('segunda request distinta', 'main1', 'orchestrator'),
-      );
-      await hook.handleChatSystemTransform({ sessionID: 'main1' }, blocked);
+      await hook.handleMessagesTransform(blocked);
 
       await hook.handleToolExecuteAfter({
         tool: 'todowrite',
         sessionID: 'main1',
       });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
-      await hook.handleChatSystemTransform({ sessionID: 'main1' }, allowed);
+      await hook.handleMessagesTransform(allowed);
 
-      expect(blocked.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
-      expect(allowed.system.join('\n')).toContain(TODO_HYGIENE_REMINDER);
+      expect(allMessageText(blocked)).not.toContain(TODO_HYGIENE_REMINDER);
+      expect(allMessageText(allowed)).toContain(TODO_HYGIENE_REMINDER);
     });
 
     test('attachment-only requests still reset stale pending reminder state', async () => {
@@ -202,31 +231,32 @@ describe('createTodoContinuationHook', () => {
         },
       });
       const hook = createTodoContinuationHook(ctx);
-      const blocked = { system: ['base'] };
-      const allowed = { system: ['base'] };
+      const first = userMessages('primera request', 'main1', 'orchestrator');
+      const blocked = userMessages('', 'main1', 'orchestrator', [
+        { type: 'image' },
+      ]);
+      const allowed = 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' });
-      await hook.handleMessagesTransform(
-        userMessages('', 'main1', 'orchestrator', [{ type: 'image' }]),
-      );
-      await hook.handleChatSystemTransform({ sessionID: 'main1' }, blocked);
+      await hook.handleMessagesTransform(blocked);
 
       await hook.handleToolExecuteAfter({
         tool: 'todowrite',
         sessionID: 'main1',
       });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
-      await hook.handleChatSystemTransform({ sessionID: 'main1' }, allowed);
+      await hook.handleMessagesTransform(allowed);
 
-      expect(blocked.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
-      expect(allowed.system.join('\n')).toContain(TODO_HYGIENE_REMINDER);
+      expect(allMessageText(blocked)).not.toContain(TODO_HYGIENE_REMINDER);
+      expect(allMessageText(allowed)).not.toContain(TODO_HYGIENE_REMINDER);
+      expect(allowed.messages[0].parts).toHaveLength(1);
     });
 
     test('falls back to known orchestrator session when transform message lacks sessionID', async () => {
@@ -243,25 +273,25 @@ describe('createTodoContinuationHook', () => {
         },
       });
       const hook = createTodoContinuationHook(ctx);
-      const system = { system: ['base'] };
-
-      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',
       });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
-      await hook.handleChatSystemTransform({ sessionID: 'main1' }, system);
+      await hook.handleMessagesTransform(output);
 
-      expect(system.system.join('\n')).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 () => {
@@ -273,18 +303,14 @@ describe('createTodoContinuationHook', () => {
         },
       });
       const hook = createTodoContinuationHook(ctx);
-      const system = { system: ['base'] };
+      const output = userMessages('continue previous work', 'sub1');
 
-      await hook.handleMessagesTransform(
-        userMessages('continue previous work', 'sub1'),
-      );
+      await hook.handleMessagesTransform(output);
       await hook.handleToolExecuteAfter({ tool: 'task', sessionID: 'sub1' });
-      await hook.handleChatSystemTransform({ sessionID: 'sub1' }, system);
+      await hook.handleMessagesTransform(output);
 
-      expect(system.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
-      expect(system.system.join('\n')).not.toContain(
-        TODO_FINAL_ACTIVE_REMINDER,
-      );
+      expect(allMessageText(output)).not.toContain(TODO_HYGIENE_REMINDER);
+      expect(allMessageText(output)).not.toContain(TODO_FINAL_ACTIVE_REMINDER);
     });
 
     test('known orchestrator sessions still process request boundaries when agent metadata is missing', async () => {
@@ -301,20 +327,18 @@ describe('createTodoContinuationHook', () => {
         },
       });
       const hook = createTodoContinuationHook(ctx);
-      const system = { system: ['base'] };
+      const output = userMessages('new request boundary', 'main1');
 
       hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
-      await hook.handleMessagesTransform(
-        userMessages('new request boundary', 'main1'),
-      );
+      await hook.handleMessagesTransform(output);
       await hook.handleToolExecuteAfter({
         tool: 'todowrite',
         sessionID: 'main1',
       });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
-      await hook.handleChatSystemTransform({ sessionID: 'main1' }, system);
+      await hook.handleMessagesTransform(output);
 
-      expect(system.system.join('\n')).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 () => {
@@ -326,23 +350,14 @@ describe('createTodoContinuationHook', () => {
         },
       });
       const hook = createTodoContinuationHook(ctx);
-      const system = { system: ['base'] };
-
-      await hook.handleMessagesTransform(
-        userMessages(
-          'request boundary',
-          'main1',
-          'orchestrator',
-          undefined,
-          'u1',
-        ),
+      const initial = userMessages(
+        'request boundary',
+        'main1',
+        'orchestrator',
+        undefined,
+        'u1',
       );
-      await hook.handleToolExecuteAfter({
-        tool: 'todowrite',
-        sessionID: 'main1',
-      });
-      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
-      await hook.handleMessagesTransform({
+      const shifted = {
         messages: [
           {
             info: { role: 'assistant', sessionID: 'main1' },
@@ -358,10 +373,17 @@ describe('createTodoContinuationHook', () => {
             parts: [{ type: 'text', text: 'request boundary' }],
           },
         ],
+      };
+
+      await hook.handleMessagesTransform(initial);
+      await hook.handleToolExecuteAfter({
+        tool: 'todowrite',
+        sessionID: 'main1',
       });
-      await hook.handleChatSystemTransform({ sessionID: 'main1' }, system);
+      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
+      await hook.handleMessagesTransform(shifted);
 
-      expect(system.system.join('\n')).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 () => {
@@ -373,31 +395,45 @@ describe('createTodoContinuationHook', () => {
         },
       });
       const hook = createTodoContinuationHook(ctx);
-      const blocked = { system: ['base'] };
-      const allowed = { system: ['base'] };
-
-      await hook.handleMessagesTransform(
-        userMessages('same text', 'main1', 'orchestrator', undefined, 'u1'),
+      const first = userMessages(
+        'same text',
+        'main1',
+        'orchestrator',
+        undefined,
+        'u1',
+      );
+      const blocked = userMessages(
+        'same text',
+        'main1',
+        'orchestrator',
+        undefined,
+        'u2',
       );
+      const allowed = userMessages(
+        'same text',
+        'main1',
+        'orchestrator',
+        undefined,
+        'u2',
+      );
+
+      await hook.handleMessagesTransform(first);
       await hook.handleToolExecuteAfter({
         tool: 'todowrite',
         sessionID: 'main1',
       });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
-      await hook.handleMessagesTransform(
-        userMessages('same text', 'main1', 'orchestrator', undefined, 'u2'),
-      );
-      await hook.handleChatSystemTransform({ sessionID: 'main1' }, blocked);
+      await hook.handleMessagesTransform(blocked);
 
       await hook.handleToolExecuteAfter({
         tool: 'todowrite',
         sessionID: 'main1',
       });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
-      await hook.handleChatSystemTransform({ sessionID: 'main1' }, allowed);
+      await hook.handleMessagesTransform(allowed);
 
-      expect(blocked.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
-      expect(allowed.system.join('\n')).toContain(TODO_HYGIENE_REMINDER);
+      expect(allMessageText(blocked)).not.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 () => {
@@ -409,18 +445,8 @@ describe('createTodoContinuationHook', () => {
         },
       });
       const hook = createTodoContinuationHook(ctx);
-      const blocked = { system: ['base'] };
-      const allowed = { system: ['base'] };
-
-      await hook.handleMessagesTransform(
-        userMessages('same text', 'main1', 'orchestrator'),
-      );
-      await hook.handleToolExecuteAfter({
-        tool: 'todowrite',
-        sessionID: 'main1',
-      });
-      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
-      await hook.handleMessagesTransform({
+      const first = userMessages('same text', 'main1', 'orchestrator');
+      const blocked = {
         messages: [
           {
             info: { role: 'user', agent: 'orchestrator', sessionID: 'main1' },
@@ -435,18 +461,26 @@ describe('createTodoContinuationHook', () => {
             parts: [{ type: 'text', text: 'same text' }],
           },
         ],
+      };
+      const allowed = structuredClone(blocked);
+
+      await hook.handleMessagesTransform(first);
+      await hook.handleToolExecuteAfter({
+        tool: 'todowrite',
+        sessionID: 'main1',
       });
-      await hook.handleChatSystemTransform({ sessionID: 'main1' }, blocked);
+      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
+      await hook.handleMessagesTransform(blocked);
 
       await hook.handleToolExecuteAfter({
         tool: 'todowrite',
         sessionID: 'main1',
       });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
-      await hook.handleChatSystemTransform({ sessionID: 'main1' }, allowed);
+      await hook.handleMessagesTransform(allowed);
 
-      expect(blocked.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
-      expect(allowed.system.join('\n')).toContain(TODO_HYGIENE_REMINDER);
+      expect(allMessageText(blocked)).not.toContain(TODO_HYGIENE_REMINDER);
+      expect(allMessageText(allowed)).toContain(TODO_HYGIENE_REMINDER);
     });
 
     test('messages without inferable sessionID clear stale state for known orchestrators', async () => {
@@ -458,32 +492,34 @@ describe('createTodoContinuationHook', () => {
         },
       });
       const hook = createTodoContinuationHook(ctx);
-      const system = { system: ['base'] };
-
-      hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
-      hook.handleChatMessage({ sessionID: 'main2', agent: 'orchestrator' });
-      await hook.handleMessagesTransform(
-        userMessages('first request', 'main1', 'orchestrator', undefined, 'u1'),
+      const first = userMessages(
+        'first request',
+        'main1',
+        'orchestrator',
+        undefined,
+        'u1',
       );
-      await hook.handleToolExecuteAfter({
-        tool: 'todowrite',
-        sessionID: 'main1',
-      });
-      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
-      await hook.handleMessagesTransform({
+      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' });
+      await hook.handleMessagesTransform(first);
+      await hook.handleToolExecuteAfter({
+        tool: 'todowrite',
+        sessionID: 'main1',
       });
-      await hook.handleChatSystemTransform({ sessionID: 'main1' }, system);
+      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
+      await hook.handleMessagesTransform(unknown);
 
-      expect(system.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
-      expect(system.system.join('\n')).not.toContain(
-        TODO_FINAL_ACTIVE_REMINDER,
-      );
+      expect(allMessageText(unknown)).not.toContain(TODO_HYGIENE_REMINDER);
+      expect(allMessageText(unknown)).not.toContain(TODO_FINAL_ACTIVE_REMINDER);
     });
 
     test('does not inject from continuation-like wording alone', async () => {
@@ -500,21 +536,17 @@ describe('createTodoContinuationHook', () => {
         },
       });
       const hook = createTodoContinuationHook(ctx);
-      const system = { system: ['base'] };
-
-      await hook.handleMessagesTransform(
-        userMessages(
-          'sigue este formato pero empieza de cero',
-          'main1',
-          'orchestrator',
-        ),
+      const output = userMessages(
+        'sigue este formato pero empieza de cero',
+        'main1',
+        'orchestrator',
       );
-      await hook.handleChatSystemTransform({ sessionID: 'main1' }, system);
 
-      expect(system.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
-      expect(system.system.join('\n')).not.toContain(
-        TODO_FINAL_ACTIVE_REMINDER,
-      );
+      await hook.handleMessagesTransform(output);
+      await hook.handleMessagesTransform(output);
+
+      expect(allMessageText(output)).not.toContain(TODO_HYGIENE_REMINDER);
+      expect(allMessageText(output)).not.toContain(TODO_FINAL_ACTIVE_REMINDER);
     });
 
     test('rearms on activity after todowrite even if request wording is continuation-like', async () => {
@@ -531,19 +563,21 @@ describe('createTodoContinuationHook', () => {
         },
       });
       const hook = createTodoContinuationHook(ctx);
-      const system = { system: ['base'] };
-
-      await hook.handleMessagesTransform(
-        userMessages('finish the previous work', 'main1', 'orchestrator'),
+      const output = userMessages(
+        'finish the previous work',
+        'main1',
+        'orchestrator',
       );
+
+      await hook.handleMessagesTransform(output);
       await hook.handleToolExecuteAfter({
         tool: 'todowrite',
         sessionID: 'main1',
       });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
-      await hook.handleChatSystemTransform({ sessionID: 'main1' }, system);
+      await hook.handleMessagesTransform(output);
 
-      expect(system.system.join('\n')).toContain(TODO_FINAL_ACTIVE_REMINDER);
+      expect(allMessageText(output)).toContain(TODO_FINAL_ACTIVE_REMINDER);
     });
 
     test('final active todo after todowrite uses the stronger finishing reminder', async () => {
@@ -560,19 +594,137 @@ describe('createTodoContinuationHook', () => {
         },
       });
       const hook = createTodoContinuationHook(ctx);
-      const system = { system: ['base'] };
+      const output = userMessages('haz esto', 'main1', 'orchestrator');
 
-      await hook.handleMessagesTransform(
-        userMessages('haz esto', 'main1', 'orchestrator'),
-      );
+      await hook.handleMessagesTransform(output);
       await hook.handleToolExecuteAfter({
         tool: 'todowrite',
         sessionID: 'main1',
       });
-      await hook.handleChatSystemTransform({ sessionID: 'main1' }, system);
+      await hook.handleMessagesTransform(output);
 
-      expect(system.system.join('\n')).toContain(TODO_FINAL_ACTIVE_REMINDER);
-      expect(system.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
+      expect(allMessageText(output)).toContain(TODO_FINAL_ACTIVE_REMINDER);
+      expect(allMessageText(output)).not.toContain(TODO_HYGIENE_REMINDER);
+    });
+
+    test('wraps reminders in a todo_hygiene instruction at the user message tail', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx);
+      const output = userMessages('haz esto', 'main1', 'orchestrator');
+
+      await hook.handleMessagesTransform(output);
+      await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 'main1' });
+      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
+      await hook.handleMessagesTransform(output);
+
+      expect(output.messages[0].parts[0].text).toEndWith(
+        `<instruction name="todo_hygiene">\n${TODO_HYGIENE_REMINDER}\n</instruction>`,
+      );
+    });
+
+    test('strips trailing todo_hygiene instruction before appending another reminder', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx);
+      const output = userMessages('haz esto', 'main1', 'orchestrator');
+
+      await hook.handleMessagesTransform(output);
+      await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 'main1' });
+      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
+      await hook.handleMessagesTransform(output);
+      await hook.handleToolExecuteAfter({ tool: 'grep', sessionID: 'main1' });
+      await hook.handleMessagesTransform(output);
+
+      const text = allMessageText(output);
+      expect(text.match(/<instruction name="todo_hygiene">/g)).toHaveLength(1);
+      expect(text).toContain(TODO_HYGIENE_REMINDER);
+    });
+
+    test('strips stale todo_hygiene instruction when same request has no pending reminder', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx);
+      const output = userMessages('haz esto', 'main1', 'orchestrator');
+
+      await hook.handleMessagesTransform(output);
+      await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 'main1' });
+      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
+      await hook.handleMessagesTransform(output);
+      expect(allMessageText(output)).toContain(TODO_HYGIENE_REMINDER);
+
+      await hook.handleMessagesTransform(output);
+
+      expect(allMessageText(output)).not.toContain(TODO_HYGIENE_REMINDER);
+      expect(allMessageText(output)).not.toContain(
+        '<instruction name="todo_hygiene">',
+      );
+    });
+
+    test('does not append synthetic text parts for attachment-only payloads', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx);
+      const output = userMessages('', 'main1', 'orchestrator', [
+        { type: 'image' },
+      ]);
+
+      await hook.handleMessagesTransform(output);
+      await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 'main1' });
+      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
+      await hook.handleMessagesTransform(output);
+
+      expect(output.messages[0].parts).toHaveLength(1);
+      expect(allMessageText(output)).not.toContain(TODO_HYGIENE_REMINDER);
+    });
+
+    test('messages transform injects pending reminder without todo lookup', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx);
+      const output = userMessages('haz esto', 'main1', 'orchestrator');
+
+      await hook.handleMessagesTransform(output);
+      await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 'main1' });
+      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
+      const todoCallsBeforeTransform = ctx.client.session.todo.mock.calls.length;
+      ctx.client.session.todo.mockImplementationOnce(
+        () => new Promise(() => undefined),
+      );
+
+      const start = Date.now();
+      await hook.handleMessagesTransform(output);
+
+      expect(Date.now() - start).toBeLessThan(2_000);
+      expect(ctx.client.session.todo.mock.calls.length).toBe(
+        todoCallsBeforeTransform,
+      );
+      expect(allMessageText(output)).toContain(TODO_HYGIENE_REMINDER);
     });
   });
 

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

@@ -12,6 +12,12 @@ 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_NAME = 'todo_hygiene';
+const TODO_HYGIENE_INSTRUCTION_OPEN = `<instruction name="${TODO_HYGIENE_INSTRUCTION_NAME}">`;
+const TODO_HYGIENE_INSTRUCTION_CLOSE = '</instruction>';
+const TODO_HYGIENE_INSTRUCTION_PATTERN = new RegExp(
+  `\\n*${TODO_HYGIENE_INSTRUCTION_OPEN.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${TODO_HYGIENE_INSTRUCTION_CLOSE.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`,
+);
 
 // Suppress window after user abort (Esc/Ctrl+C) to avoid immediately
 // re-continuing something the user explicitly stopped
@@ -90,6 +96,13 @@ interface ChatTransformMessage {
   parts: MessagePart[];
 }
 
+interface LastExternalUserMessage {
+  sessionID?: string;
+  agent?: string;
+  signature: string;
+  message: ChatTransformMessage;
+}
+
 interface Message {
   info?: MessageInfo;
   parts?: MessagePart[];
@@ -112,6 +125,40 @@ function resetState(state: ContinuationState): void {
   state.notificationBusyUntilBySession.clear();
 }
 
+function stripTodoHygieneInstruction(text: string): string {
+  return text.replace(TODO_HYGIENE_INSTRUCTION_PATTERN, '').trimEnd();
+}
+
+function appendTodoHygieneInstruction(
+  message: ChatTransformMessage,
+  reminder: string,
+): void {
+  const instruction = `${TODO_HYGIENE_INSTRUCTION_OPEN}\n${reminder}\n${TODO_HYGIENE_INSTRUCTION_CLOSE}`;
+  const textPart = message.parts
+    .slice()
+    .reverse()
+    .find((part) => part.type === 'text' && typeof part.text === 'string');
+
+  if (textPart) {
+    const baseText = stripTodoHygieneInstruction(textPart.text ?? '');
+    textPart.text = baseText ? `${baseText}\n\n${instruction}` : instruction;
+    return;
+  }
+}
+
+function stripTodoHygieneInstructionFromMessage(
+  message: ChatTransformMessage,
+): void {
+  const textPart = message.parts
+    .slice()
+    .reverse()
+    .find((part) => part.type === 'text' && typeof part.text === 'string');
+
+  if (textPart) {
+    textPart.text = stripTodoHygieneInstruction(textPart.text ?? '');
+  }
+}
+
 export function createTodoContinuationHook(
   ctx: PluginInput,
   config?: {
@@ -247,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)) {
@@ -263,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';
         })
@@ -275,6 +321,7 @@ export function createTodoContinuationHook(
       return {
         sessionID,
         agent: message.info.agent,
+        message,
         signature: message.info.id
           ? `${message.info.id}:${partSignature}`
           : `${ordinal}:${partSignature}`,
@@ -286,6 +333,18 @@ export function createTodoContinuationHook(
 
   async function handleMessagesTransform(output: {
     messages: ChatTransformMessage[];
+  }): Promise<void> {
+    try {
+      await handleMessagesTransformSafe(output);
+    } catch (error) {
+      log(`[${HOOK_NAME}] Skipped messages transform`, {
+        error: error instanceof Error ? error.message : String(error),
+      });
+    }
+  }
+
+  async function handleMessagesTransformSafe(output: {
+    messages: ChatTransformMessage[];
   }): Promise<void> {
     const lastUserMessage = getLastExternalUserMessage(output.messages);
     if (!lastUserMessage) {
@@ -315,6 +374,12 @@ 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;
     }
 
@@ -322,6 +387,7 @@ export function createTodoContinuationHook(
       lastUserMessage.sessionID,
       lastUserMessage.signature,
     );
+    stripTodoHygieneInstructionFromMessage(lastUserMessage.message);
     hygiene.handleRequestStart({ sessionID: lastUserMessage.sessionID });
   }
 

+ 139 - 77
src/hooks/todo-continuation/todo-hygiene.test.ts

@@ -21,43 +21,57 @@ function createState(
   };
 }
 
+function createHook(options: Parameters<typeof createTodoHygiene>[0]) {
+  return createTodoHygiene({ reminderDebounceMs: 0, ...options });
+}
+
 describe('todo hygiene', () => {
   test('new request clears pending state from the previous turn', async () => {
-    const hook = createTodoHygiene({
+    const hook = createHook({
       getTodoState: async () => createState(),
     });
-    const stale = { system: ['base'] };
-    const fresh = { system: ['base'] };
 
     hook.handleRequestStart({ sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
     hook.handleRequestStart({ sessionID: 's1' });
-    await hook.handleChatSystemTransform({ sessionID: 's1' }, stale);
+    const stale = await hook.consumePendingReminder('s1');
 
     await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
-    await hook.handleChatSystemTransform({ sessionID: 's1' }, fresh);
+    const fresh = await hook.consumePendingReminder('s1');
 
-    expect(stale.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
-    expect(fresh.system.join('\n')).toContain(TODO_HYGIENE_REMINDER);
+    expect(stale).toBeNull();
+    expect(fresh).toBe(TODO_HYGIENE_REMINDER);
   });
 
   test('does not arm before the current request calls todowrite', async () => {
-    const hook = createTodoHygiene({
+    const hook = createHook({
       getTodoState: async () => createState(),
     });
-    const system = { system: ['base'] };
 
     hook.handleRequestStart({ sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
-    await hook.handleChatSystemTransform({ sessionID: 's1' }, system);
 
-    expect(system.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
+    expect(await hook.consumePendingReminder('s1')).toBeNull();
   });
 
   test('arms after the first relevant tool following todowrite', async () => {
-    const hook = createTodoHygiene({
+    const hook = createHook({
+      getTodoState: async () => createState(),
+    });
+
+    hook.handleRequestStart({ sessionID: 's1' });
+    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
+    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
+
+    expect(await hook.consumePendingReminder('s1')).toBe(
+      TODO_HYGIENE_REMINDER,
+    );
+  });
+
+  test('chat system transform is a cache-friendly no-op', async () => {
+    const hook = createHook({
       getTodoState: async () => createState(),
     });
     const system = { system: ['base'] };
@@ -67,14 +81,16 @@ describe('todo hygiene', () => {
     await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
     await hook.handleChatSystemTransform({ sessionID: 's1' }, system);
 
-    expect(system.system.join('\n')).toContain(TODO_HYGIENE_REMINDER);
+    expect(system.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
+    expect(await hook.consumePendingReminder('s1')).toBe(
+      TODO_HYGIENE_REMINDER,
+    );
   });
 
-  test('multiple tools in the same round still inject only one reminder', async () => {
-    const hook = createTodoHygiene({
+  test('multiple tools in the same round still consume only one reminder', async () => {
+    const hook = createHook({
       getTodoState: async () => createState(),
     });
-    const system = { system: ['base'] };
 
     hook.handleRequestStart({ sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
@@ -82,39 +98,37 @@ describe('todo hygiene', () => {
     await hook.handleToolExecuteAfter({ tool: 'grep', sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'glob', sessionID: 's1' });
-    await hook.handleChatSystemTransform({ sessionID: 's1' }, system);
 
-    expect(
-      system.system.filter((item) => item.includes(TODO_HYGIENE_REMINDER)),
-    ).toHaveLength(1);
+    expect(await hook.consumePendingReminder('s1')).toBe(
+      TODO_HYGIENE_REMINDER,
+    );
+    expect(await hook.consumePendingReminder('s1')).toBeNull();
   });
 
   test('injects again on a later round after new activity', async () => {
-    const hook = createTodoHygiene({
+    const hook = createHook({
       getTodoState: async () => createState(),
     });
-    const first = { system: ['base'] };
-    const second = { system: ['base'] };
 
     hook.handleRequestStart({ sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
-    await hook.handleChatSystemTransform({ sessionID: 's1' }, first);
+    const first = await hook.consumePendingReminder('s1');
 
     await hook.handleToolExecuteAfter({ tool: 'grep', sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
-    await hook.handleChatSystemTransform({ sessionID: 's1' }, second);
+    const second = await hook.consumePendingReminder('s1');
 
-    expect(first.system.join('\n')).toContain(TODO_HYGIENE_REMINDER);
-    expect(second.system.join('\n')).toContain(TODO_HYGIENE_REMINDER);
+    expect(first).toBe(TODO_HYGIENE_REMINDER);
+    expect(second).toBe(TODO_HYGIENE_REMINDER);
   });
 
   test('upgrades to final-active on a later round', async () => {
     let call = 0;
-    const hook = createTodoHygiene({
+    const hook = createHook({
       getTodoState: async () => {
         call++;
-        if (call <= 4) {
+        if (call <= 3) {
           return createState();
         }
         return createState({
@@ -124,24 +138,22 @@ describe('todo hygiene', () => {
         });
       },
     });
-    const first = { system: ['base'] };
-    const second = { system: ['base'] };
 
     hook.handleRequestStart({ sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
-    await hook.handleChatSystemTransform({ sessionID: 's1' }, first);
+    const first = await hook.consumePendingReminder('s1');
 
     await hook.handleToolExecuteAfter({ tool: 'grep', sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
-    await hook.handleChatSystemTransform({ sessionID: 's1' }, second);
+    const second = await hook.consumePendingReminder('s1');
 
-    expect(first.system.join('\n')).toContain(TODO_HYGIENE_REMINDER);
-    expect(second.system.join('\n')).toContain(TODO_FINAL_ACTIVE_REMINDER);
+    expect(first).toBe(TODO_HYGIENE_REMINDER);
+    expect(second).toBe(TODO_FINAL_ACTIVE_REMINDER);
   });
 
   test('todowrite can arm final-active immediately', async () => {
-    const hook = createTodoHygiene({
+    const hook = createHook({
       getTodoState: async () =>
         createState({
           openCount: 1,
@@ -149,18 +161,18 @@ describe('todo hygiene', () => {
           pendingCount: 0,
         }),
     });
-    const system = { system: ['base'] };
 
     hook.handleRequestStart({ sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
-    await hook.handleChatSystemTransform({ sessionID: 's1' }, system);
 
-    expect(system.system.join('\n')).toContain(TODO_FINAL_ACTIVE_REMINDER);
+    expect(await 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 () => {
     let calls = 0;
-    const hook = createTodoHygiene({
+    const hook = createHook({
       getTodoState: async () => {
         calls++;
         return createState({
@@ -180,25 +192,21 @@ describe('todo hygiene', () => {
   });
 
   test('shouldInject rejection consumes the pending reminder', async () => {
-    const hook = createTodoHygiene({
+    const hook = createHook({
       getTodoState: async () => createState(),
       shouldInject: () => false,
     });
-    const first = { system: ['base'] };
-    const second = { system: ['base'] };
 
     hook.handleRequestStart({ sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
-    await hook.handleChatSystemTransform({ sessionID: 's1' }, first);
-    await hook.handleChatSystemTransform({ sessionID: 's1' }, second);
 
-    expect(first.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
-    expect(second.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
+    expect(await hook.consumePendingReminder('s1')).toBeNull();
+    expect(await hook.consumePendingReminder('s1')).toBeNull();
   });
 
   test('final-active reminder wins when only one active todo remains', async () => {
-    const hook = createTodoHygiene({
+    const hook = createHook({
       getTodoState: async () =>
         createState({
           openCount: 1,
@@ -206,18 +214,18 @@ describe('todo hygiene', () => {
           pendingCount: 0,
         }),
     });
-    const system = { system: ['base'] };
 
     hook.handleRequestStart({ sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
-    await hook.handleChatSystemTransform({ sessionID: 's1' }, system);
 
-    expect(system.system.join('\n')).toContain(TODO_FINAL_ACTIVE_REMINDER);
+    expect(await hook.consumePendingReminder('s1')).toBe(
+      TODO_FINAL_ACTIVE_REMINDER,
+    );
   });
 
-  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({
+    const hook = createHook({
       getTodoState: async () => {
         if (fail) {
           throw new Error('boom');
@@ -225,27 +233,25 @@ describe('todo hygiene', () => {
         return createState();
       },
     });
-    const failed = { system: ['base'] };
-    const recovered = { system: ['base'] };
 
     hook.handleRequestStart({ sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
     fail = true;
-    await hook.handleChatSystemTransform({ sessionID: 's1' }, failed);
+    const failed = await hook.consumePendingReminder('s1');
 
     fail = false;
     await hook.handleToolExecuteAfter({ tool: 'grep', sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
-    await hook.handleChatSystemTransform({ sessionID: 's1' }, recovered);
+    const recovered = await hook.consumePendingReminder('s1');
 
-    expect(failed.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
-    expect(recovered.system.join('\n')).toContain(TODO_HYGIENE_REMINDER);
+    expect(failed).toBe(TODO_HYGIENE_REMINDER);
+    expect(recovered).toBe(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({
+    const hook = createHook({
       getTodoState: async () => {
         call++;
         if (call === 3) {
@@ -254,20 +260,20 @@ describe('todo hygiene', () => {
         return createState();
       },
     });
-    const system = { system: ['base'] };
 
     hook.handleRequestStart({ sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'grep', sessionID: 's1' });
-    await hook.handleChatSystemTransform({ sessionID: 's1' }, system);
 
-    expect(system.system.join('\n')).toContain(TODO_HYGIENE_REMINDER);
+    expect(await hook.consumePendingReminder('s1')).toBe(
+      TODO_HYGIENE_REMINDER,
+    );
   });
 
   test('todowrite lookup failures do not disable the current request', async () => {
     let fail = false;
-    const hook = createTodoHygiene({
+    const hook = createHook({
       getTodoState: async () => {
         if (fail) {
           throw new Error('boom');
@@ -275,7 +281,6 @@ describe('todo hygiene', () => {
         return createState();
       },
     });
-    const system = { system: ['base'] };
 
     hook.handleRequestStart({ sessionID: 's1' });
     fail = true;
@@ -283,37 +288,57 @@ describe('todo hygiene', () => {
     fail = false;
     await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'grep', sessionID: 's1' });
-    await hook.handleChatSystemTransform({ sessionID: 's1' }, system);
 
-    expect(system.system.join('\n')).toContain(TODO_HYGIENE_REMINDER);
+    expect(await hook.consumePendingReminder('s1')).toBe(
+      TODO_HYGIENE_REMINDER,
+    );
   });
 
-  test('non-injectable sessions are fully cleared after a rejected round', async () => {
+  test('non-injectable sessions are ignored before todo lookups', async () => {
     let calls = 0;
-    const hook = createTodoHygiene({
+    const hook = createHook({
       getTodoState: async () => {
         calls++;
         return createState();
       },
       shouldInject: () => false,
     });
-    const system = { system: ['base'] };
 
     hook.handleRequestStart({ sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
-    await hook.handleChatSystemTransform({ sessionID: 's1' }, system);
+    await hook.consumePendingReminder('s1');
     await hook.handleToolExecuteAfter({ tool: 'grep', sessionID: 's1' });
 
-    expect(calls).toBe(1);
-    expect(system.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
+    expect(calls).toBe(0);
+  });
+
+  test('non-injectable todowrite sessions skip reset lookup and do not arm state', async () => {
+    let calls = 0;
+    const hook = createHook({
+      getTodoState: async () => {
+        calls++;
+        return createState({
+          openCount: 1,
+          inProgressCount: 1,
+          pendingCount: 0,
+        });
+      },
+      shouldInject: () => false,
+    });
+
+    hook.handleRequestStart({ sessionID: 's1' });
+    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
+    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
+
+    expect(calls).toBe(0);
+    expect(await hook.consumePendingReminder('s1')).toBeNull();
   });
 
   test('session.deleted clears all state', async () => {
-    const hook = createTodoHygiene({
+    const hook = createHook({
       getTodoState: async () => createState(),
     });
-    const system = { system: ['base'] };
 
     hook.handleRequestStart({ sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
@@ -322,8 +347,45 @@ describe('todo hygiene', () => {
       type: 'session.deleted',
       properties: { info: { id: 's1' } },
     });
-    await hook.handleChatSystemTransform({ sessionID: 's1' }, system);
 
-    expect(system.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
+    expect(await hook.consumePendingReminder('s1')).toBeNull();
+  });
+
+  test('default debounce avoids repeated todo lookups while a reminder is pending', async () => {
+    let calls = 0;
+    const hook = createTodoHygiene({
+      getTodoState: async () => {
+        calls++;
+        return createState();
+      },
+    });
+
+    hook.handleRequestStart({ sessionID: 's1' });
+    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
+    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
+    await hook.handleToolExecuteAfter({ tool: 'grep', sessionID: 's1' });
+    await hook.handleToolExecuteAfter({ tool: 'glob', sessionID: 's1' });
+
+    expect(calls).toBe(2);
+    expect(await hook.consumePendingReminder('s1')).toBe(
+      TODO_HYGIENE_REMINDER,
+    );
+  });
+
+  test('reminderDebounceMs=0 permits immediate reinspection while pending', async () => {
+    let calls = 0;
+    const hook = createHook({
+      getTodoState: async () => {
+        calls++;
+        return createState();
+      },
+    });
+
+    hook.handleRequestStart({ sessionID: 's1' });
+    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
+    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
+    await hook.handleToolExecuteAfter({ tool: 'grep', sessionID: 's1' });
+
+    expect(calls).toBe(3);
   });
 });

+ 47 - 35
src/hooks/todo-continuation/todo-hygiene.ts

@@ -41,15 +41,22 @@ interface Options {
     pendingCount: number;
   }>;
   shouldInject?: (sessionID: string) => boolean;
+  reminderDebounceMs?: number;
   log?: (message: string, meta?: Record<string, unknown>) => void;
 }
 
+const DEFAULT_REMINDER_DEBOUNCE_MS = 1_000;
+
 export function createTodoHygiene(options: Options) {
   const pending = new Map<string, Set<Reason>>();
+  const nextPendingInspectionAt = new Map<string, number>();
   const active = new Set<string>();
+  const reminderDebounceMs =
+    options.reminderDebounceMs ?? DEFAULT_REMINDER_DEBOUNCE_MS;
 
   function clearCycle(sessionID: string): void {
     pending.delete(sessionID);
+    nextPendingInspectionAt.delete(sessionID);
   }
 
   function clear(sessionID: string): void {
@@ -73,6 +80,10 @@ export function createTodoHygiene(options: Options) {
     const reasons = pending.get(sessionID) ?? new Set<Reason>();
     reasons.add(reason);
     pending.set(sessionID, reasons);
+
+    if (reminderDebounceMs > 0) {
+      nextPendingInspectionAt.set(sessionID, Date.now() + reminderDebounceMs);
+    }
   }
 
   function pick(reasons: Set<Reason>): string {
@@ -100,6 +111,11 @@ export function createTodoHygiene(options: Options) {
 
       try {
         if (RESET.has(tool)) {
+          if (options.shouldInject && !options.shouldInject(input.sessionID)) {
+            clear(input.sessionID);
+            return;
+          }
+
           active.add(input.sessionID);
           clearCycle(input.sessionID);
           const state = await options.getTodoState(input.sessionID);
@@ -141,6 +157,16 @@ export function createTodoHygiene(options: Options) {
           return;
         }
 
+        const pendingReasons = pending.get(input.sessionID);
+        if (
+          pendingReasons &&
+          pendingReasons.size > 0 &&
+          reminderDebounceMs > 0 &&
+          Date.now() < (nextPendingInspectionAt.get(input.sessionID) ?? 0)
+        ) {
+          return;
+        }
+
         const state = await options.getTodoState(input.sessionID);
         if (!state.hasOpenTodos) {
           clear(input.sessionID);
@@ -171,49 +197,35 @@ export function createTodoHygiene(options: Options) {
     },
 
     async handleChatSystemTransform(
-      input: SystemInput,
-      output: SystemOutput,
+      _input: SystemInput,
+      _output: SystemOutput,
     ): Promise<void> {
-      if (!input.sessionID) {
-        return;
-      }
+      // Dynamic todo hygiene reminders are injected ephemerally in
+      // experimental.chat.messages.transform so system prompt output remains
+      // cache-friendly.
+    },
 
-      const reasons = pending.get(input.sessionID);
+    consumePendingReminder(sessionID: string): string | null {
+      const reasons = pending.get(sessionID);
       if (!reasons || reasons.size === 0) {
-        return;
+        return null;
       }
 
-      const reminder = pick(reasons);
-
-      if (options.shouldInject && !options.shouldInject(input.sessionID)) {
-        clear(input.sessionID);
-        return;
+      if (options.shouldInject && !options.shouldInject(sessionID)) {
+        clear(sessionID);
+        return null;
       }
 
-      try {
-        const state = await options.getTodoState(input.sessionID);
-        if (!state.hasOpenTodos) {
-          clear(input.sessionID);
-          return;
-        }
+      const reminder = pick(reasons);
 
-        pending.delete(input.sessionID);
-        output.system.push(reminder);
-        options.log?.('Injected todo hygiene reminder', {
-          sessionID: input.sessionID,
-          reminder,
-          reasons: Array.from(reasons),
-        });
-      } catch (error) {
-        pending.delete(input.sessionID);
-        options.log?.(
-          'Skipped todo hygiene reminder: failed to inspect todos',
-          {
-            sessionID: input.sessionID,
-            error: error instanceof Error ? error.message : String(error),
-          },
-        );
-      }
+      pending.delete(sessionID);
+      nextPendingInspectionAt.delete(sessionID);
+      options.log?.('Injected todo hygiene reminder', {
+        sessionID,
+        reminder,
+        reasons: Array.from(reasons),
+      });
+      return reminder;
     },
 
     handleEvent(event: EventInput): void {