Browse Source

improve round-based todo hygiene and request boundary handling (#300)

* Improve round-based todo hygiene

* Refine todo hygiene boundary helpers
Raxxoor 1 day ago
parent
commit
8dfdd87a47

+ 343 - 5
src/hooks/todo-continuation/index.test.ts

@@ -67,6 +67,23 @@ describe('createTodoContinuationHook', () => {
     return call;
   }
 
+  function userMessages(
+    text: string,
+    sessionID = 'main1',
+    agent?: string,
+    parts?: Array<{ type: string; text?: string }>,
+    id?: string,
+  ) {
+    return {
+      messages: [
+        {
+          info: { id, role: 'user', agent, sessionID },
+          parts: parts ?? [{ type: 'text', text }],
+        },
+      ],
+    };
+  }
+
   describe('tool toggle', () => {
     test('calling auto_continue execute with { enabled: true } sets state', async () => {
       const ctx = createMockContext();
@@ -100,13 +117,39 @@ describe('createTodoContinuationHook', () => {
       const hook = createTodoContinuationHook(ctx);
       const system = { system: ['base'] };
 
+      await hook.handleMessagesTransform(userMessages('continue previous work', 'sub1', 'explorer'));
       await hook.handleToolExecuteAfter({ tool: 'task', sessionID: 'sub1' });
       await hook.handleChatSystemTransform({ sessionID: 'sub1' }, system);
 
       expect(system.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
     });
 
-    test('injects hygiene reminder only for orchestrator session', async () => {
+    test('does not inject anything at request start before todowrite', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            {
+              id: '1',
+              content: 'todo1',
+              status: 'in_progress',
+              priority: 'high',
+            },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx);
+      const system = { system: ['base'] };
+
+      await hook.handleMessagesTransform(
+        userMessages('continue with the unfinished work', '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);
+    });
+
+    test('new requests clear stale pending reminder state', async () => {
       const ctx = createMockContext({
         todoResult: {
           data: [
@@ -115,16 +158,90 @@ describe('createTodoContinuationHook', () => {
         },
       });
       const hook = createTodoContinuationHook(ctx);
+      const blocked = { system: ['base'] };
+      const allowed = { system: ['base'] };
+
+      await hook.handleMessagesTransform(
+        userMessages('primera request', 'main1', 'orchestrator'),
+      );
+      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.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 'main1' });
+      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
+      await hook.handleChatSystemTransform({ sessionID: 'main1' }, allowed);
+
+      expect(blocked.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
+      expect(allowed.system.join('\n')).toContain(TODO_HYGIENE_REMINDER);
+    });
+
+    test('attachment-only requests still reset stale pending reminder state', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx);
+      const blocked = { system: ['base'] };
+      const allowed = { system: ['base'] };
+
+      await hook.handleMessagesTransform(
+        userMessages('primera request', 'main1', 'orchestrator'),
+      );
+      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.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 'main1' });
+      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
+      await hook.handleChatSystemTransform({ sessionID: 'main1' }, allowed);
+
+      expect(blocked.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
+      expect(allowed.system.join('\n')).toContain(TODO_HYGIENE_REMINDER);
+    });
+
+    test('falls back to known orchestrator session when transform message lacks sessionID', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            {
+              id: '1',
+              content: 'todo1',
+              status: 'in_progress',
+              priority: 'high',
+            },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx);
       const system = { system: ['base'] };
 
       hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
-      await hook.handleToolExecuteAfter({ tool: 'task', sessionID: 'main1' });
+      await hook.handleMessagesTransform({
+        messages: [
+          {
+            info: { role: 'user', agent: 'orchestrator' },
+            parts: [{ type: 'text', text: 'new request boundary' }],
+          },
+        ],
+      });
+      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')).toContain(TODO_HYGIENE_REMINDER);
+      expect(system.system.join('\n')).toContain(TODO_FINAL_ACTIVE_REMINDER);
     });
 
-    test('normal read-only work can arm hygiene reminder after todowrite reset', async () => {
+    test('does not promote sessions with missing agent metadata to orchestrator', async () => {
       const ctx = createMockContext({
         todoResult: {
           data: [
@@ -135,14 +252,233 @@ describe('createTodoContinuationHook', () => {
       const hook = createTodoContinuationHook(ctx);
       const system = { system: ['base'] };
 
+      await hook.handleMessagesTransform(userMessages('continue previous work', 'sub1'));
+      await hook.handleToolExecuteAfter({ tool: 'task', sessionID: 'sub1' });
+      await hook.handleChatSystemTransform({ sessionID: 'sub1' }, system);
+
+      expect(system.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
+      expect(system.system.join('\n')).not.toContain(TODO_FINAL_ACTIVE_REMINDER);
+    });
+
+    test('known orchestrator sessions still process request boundaries when agent metadata is missing', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            {
+              id: '1',
+              content: 'todo1',
+              status: 'in_progress',
+              priority: 'high',
+            },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx);
+      const system = { system: ['base'] };
+
       hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
+      await hook.handleMessagesTransform(userMessages('new request boundary', 'main1'));
       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')).toContain(TODO_FINAL_ACTIVE_REMINDER);
+    });
+
+    test('the same user message id does not reset the request when its array index shifts', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx);
+      const system = { system: ['base'] };
+
+      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({
+        messages: [
+          {
+            info: { role: 'assistant', sessionID: 'main1' },
+            parts: [{ type: 'text', text: 'intermediate output' }],
+          },
+          {
+            info: {
+              id: 'u1',
+              role: 'user',
+              agent: 'orchestrator',
+              sessionID: 'main1',
+            },
+            parts: [{ type: 'text', text: 'request boundary' }],
+          },
+        ],
+      });
+      await hook.handleChatSystemTransform({ sessionID: 'main1' }, system);
+
       expect(system.system.join('\n')).toContain(TODO_HYGIENE_REMINDER);
     });
 
+    test('a new user message id resets the request even if the text is unchanged', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx);
+      const blocked = { system: ['base'] };
+      const allowed = { system: ['base'] };
+
+      await hook.handleMessagesTransform(
+        userMessages('same text', 'main1', 'orchestrator', undefined, 'u1'),
+      );
+      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.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 'main1' });
+      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
+      await hook.handleChatSystemTransform({ sessionID: 'main1' }, allowed);
+
+      expect(blocked.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
+      expect(allowed.system.join('\n')).toContain(TODO_HYGIENE_REMINDER);
+    });
+
+    test('a repeated text without message ids still resets when a later user turn appears', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+          ],
+        },
+      });
+      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({
+        messages: [
+          {
+            info: { role: 'user', agent: 'orchestrator', sessionID: 'main1' },
+            parts: [{ type: 'text', text: 'same text' }],
+          },
+          {
+            info: { role: 'assistant', sessionID: 'main1' },
+            parts: [{ type: 'text', text: 'intermediate output' }],
+          },
+          {
+            info: { role: 'user', agent: 'orchestrator', sessionID: 'main1' },
+            parts: [{ type: 'text', text: 'same text' }],
+          },
+        ],
+      });
+      await hook.handleChatSystemTransform({ sessionID: 'main1' }, blocked);
+
+      await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 'main1' });
+      await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
+      await hook.handleChatSystemTransform({ sessionID: 'main1' }, allowed);
+
+      expect(blocked.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
+      expect(allowed.system.join('\n')).toContain(TODO_HYGIENE_REMINDER);
+    });
+
+    test('messages without inferable sessionID clear stale state for known orchestrators', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+          ],
+        },
+      });
+      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'),
+      );
+      await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 'main1' });
+      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.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);
+    });
+
+    test('does not inject from continuation-like wording alone', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            {
+              id: '1',
+              content: 'todo1',
+              status: 'in_progress',
+              priority: 'high',
+            },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx);
+      const system = { system: ['base'] };
+
+      await hook.handleMessagesTransform(
+        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);
+    });
+
+    test('rearms on activity after todowrite even if request wording is continuation-like', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            {
+              id: '1',
+              content: 'todo1',
+              status: 'in_progress',
+              priority: 'high',
+            },
+          ],
+        },
+      });
+      const hook = createTodoContinuationHook(ctx);
+      const system = { system: ['base'] };
+
+      await hook.handleMessagesTransform(
+        userMessages('finish the previous work', 'main1', 'orchestrator'),
+      );
+      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')).toContain(TODO_FINAL_ACTIVE_REMINDER);
+    });
+
     test('final active todo after todowrite uses the stronger finishing reminder', async () => {
       const ctx = createMockContext({
         todoResult: {
@@ -159,7 +495,9 @@ describe('createTodoContinuationHook', () => {
       const hook = createTodoContinuationHook(ctx);
       const system = { system: ['base'] };
 
-      hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
+      await hook.handleMessagesTransform(
+        userMessages('haz esto', 'main1', 'orchestrator'),
+      );
       await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 'main1' });
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, system);
 

+ 151 - 1
src/hooks/todo-continuation/index.ts

@@ -1,6 +1,6 @@
 import type { PluginInput } from '@opencode-ai/plugin';
 import { tool } from '@opencode-ai/plugin/tool';
-import { createInternalAgentTextPart, log } from '../../utils';
+import { SLIM_INTERNAL_INITIATOR_MARKER, createInternalAgentTextPart, log } from '../../utils';
 import { createTodoHygiene } from './todo-hygiene';
 
 const HOOK_NAME = 'todo-continuation';
@@ -76,6 +76,16 @@ interface MessagePart {
   [key: string]: unknown;
 }
 
+interface ChatTransformMessage {
+  info: {
+    id?: string;
+    role?: string;
+    agent?: string;
+    sessionID?: string;
+  };
+  parts: MessagePart[];
+}
+
 interface Message {
   info?: MessageInfo;
   parts?: MessagePart[];
@@ -116,6 +126,9 @@ export function createTodoContinuationHook(
     input: { sessionID?: string },
     output: { system: string[] },
   ) => Promise<void>;
+  handleMessagesTransform: (output: {
+    messages: ChatTransformMessage[];
+  }) => Promise<void>;
   handleEvent: (input: {
     event: { type: string; properties?: Record<string, unknown> };
   }) => Promise<void>;
@@ -133,6 +146,7 @@ export function createTodoContinuationHook(
   const cooldownMs = config?.cooldownMs ?? 3000;
   const autoEnable = config?.autoEnable ?? false;
   const autoEnableThreshold = config?.autoEnableThreshold ?? 4;
+  const requestSignatureBySession = new Map<string, string>();
 
   const state: ContinuationState = {
     enabled: false,
@@ -170,6 +184,140 @@ export function createTodoContinuationHook(
     log: (message, meta) => log(`[${HOOK_NAME}] ${message}`, meta),
   });
 
+  function inferSessionID(messages: ChatTransformMessage[], index: number): string | undefined {
+    const direct = messages[index]?.info.sessionID;
+    if (direct) {
+      return direct;
+    }
+
+    for (let i = index - 1; i >= 0; i--) {
+      const sessionID = messages[i]?.info.sessionID;
+      if (sessionID) {
+        return sessionID;
+      }
+    }
+
+    for (let i = index + 1; i < messages.length; i++) {
+      const sessionID = messages[i]?.info.sessionID;
+      if (sessionID) {
+        return sessionID;
+      }
+    }
+
+    if (state.orchestratorSessionIds.size === 1) {
+      return Array.from(state.orchestratorSessionIds)[0];
+    }
+
+    return undefined;
+  }
+
+  function isExternalUserMessage(message: ChatTransformMessage): boolean {
+    if (message.info.role !== 'user') {
+      return false;
+    }
+
+    const visibleText = message.parts
+      .filter(
+        (part) =>
+          part.type === 'text' &&
+          typeof part.text === 'string' &&
+          !part.text.includes(SLIM_INTERNAL_INITIATOR_MARKER),
+      )
+      .map((part) => part.text?.trim() ?? '')
+      .filter(Boolean)
+      .join('\n');
+    const hasNonTextPart = message.parts.some((part) => part.type !== 'text');
+
+    return !(
+      !visibleText &&
+      !hasNonTextPart &&
+      message.parts.some(
+        (part) =>
+          part.type === 'text' &&
+          typeof part.text === 'string' &&
+          part.text.includes(SLIM_INTERNAL_INITIATOR_MARKER),
+      )
+    );
+  }
+
+  function getLastExternalUserMessage(messages: ChatTransformMessage[]): {
+    sessionID?: string;
+    agent?: string;
+    signature: string;
+  } | null {
+    for (let i = messages.length - 1; i >= 0; i--) {
+      const message = messages[i];
+      if (!isExternalUserMessage(message)) {
+        continue;
+      }
+
+      const sessionID = inferSessionID(messages, i);
+
+      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()}`;
+          }
+          return part.type ?? 'unknown';
+        })
+        .join('|');
+      const ordinal = messages
+        .slice(0, i + 1)
+        .filter((item) => isExternalUserMessage(item)).length;
+
+      return {
+        sessionID,
+        agent: message.info.agent,
+        signature: message.info.id
+          ? `${message.info.id}:${partSignature}`
+          : `${ordinal}:${partSignature}`,
+      };
+    }
+
+    return null;
+  }
+
+  async function handleMessagesTransform(output: {
+    messages: ChatTransformMessage[];
+  }): Promise<void> {
+    const lastUserMessage = getLastExternalUserMessage(output.messages);
+    if (!lastUserMessage) {
+      return;
+    }
+
+    if (lastUserMessage.agent && lastUserMessage.agent !== 'orchestrator') {
+      return;
+    }
+
+    if (!lastUserMessage.sessionID) {
+      for (const sessionID of state.orchestratorSessionIds) {
+        requestSignatureBySession.delete(sessionID);
+        hygiene.handleRequestStart({ sessionID });
+      }
+      return;
+    }
+
+    const knownOrchestrator = isOrchestratorSession(lastUserMessage.sessionID);
+    if (lastUserMessage.agent === 'orchestrator') {
+      registerOrchestratorSession(lastUserMessage.sessionID);
+    } else if (!knownOrchestrator) {
+      return;
+    }
+
+    if (
+      requestSignatureBySession.get(lastUserMessage.sessionID) ===
+      lastUserMessage.signature
+    ) {
+      return;
+    }
+
+    requestSignatureBySession.set(
+      lastUserMessage.sessionID,
+      lastUserMessage.signature,
+    );
+    hygiene.handleRequestStart({ sessionID: lastUserMessage.sessionID });
+  }
+
   function markNotificationStarted(sessionID: string): void {
     state.notifyingSessionIds.add(sessionID);
   }
@@ -553,6 +701,7 @@ export function createTodoContinuationHook(
         (properties.sessionID as string);
 
       if (deletedSessionId && isOrchestratorSession(deletedSessionId)) {
+        requestSignatureBySession.delete(deletedSessionId);
         if (state.pendingTimerSessionId === deletedSessionId) {
           cancelPendingTimer(state);
           log(`[${HOOK_NAME}] Cancelled pending timer on orchestrator delete`, {
@@ -661,6 +810,7 @@ export function createTodoContinuationHook(
     tool: { auto_continue: autoContinue },
     handleToolExecuteAfter: hygiene.handleToolExecuteAfter,
     handleChatSystemTransform: hygiene.handleChatSystemTransform,
+    handleMessagesTransform,
     handleEvent,
     handleChatMessage,
     handleCommandExecuteBefore,

+ 222 - 143
src/hooks/todo-continuation/todo-hygiene.test.ts

@@ -1,265 +1,344 @@
 import { describe, expect, test } from 'bun:test';
 import {
+  TODO_DELEGATION_RESUME_REMINDER,
   TODO_FINAL_ACTIVE_REMINDER,
   TODO_HYGIENE_REMINDER,
   createTodoHygiene,
 } from './todo-hygiene';
 
+function createState(overrides?: Partial<{
+  hasOpenTodos: boolean;
+  openCount: number;
+  inProgressCount: number;
+  pendingCount: number;
+}>) {
+  return {
+    hasOpenTodos: overrides?.hasOpenTodos ?? true,
+    openCount: overrides?.openCount ?? 1,
+    inProgressCount: overrides?.inProgressCount ?? 0,
+    pendingCount: overrides?.pendingCount ?? 1,
+  };
+}
+
 describe('todo hygiene', () => {
-  test('injects once after a normal tool when todos stay open', async () => {
+  test('new request clears pending state from the previous turn', async () => {
     const hook = createTodoHygiene({
-      getTodoState: async () => ({
-        hasOpenTodos: true,
-        openCount: 1,
-        inProgressCount: 0,
-        pendingCount: 1,
-      }),
+      getTodoState: async () => createState(),
     });
-    const first = { system: ['base'] };
-    const second = { system: ['base'] };
+    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' });
-    await hook.handleChatSystemTransform({ sessionID: 's1' }, first);
-    await hook.handleToolExecuteAfter({ tool: 'grep', sessionID: 's1' });
-    await hook.handleChatSystemTransform({ sessionID: 's1' }, second);
+    hook.handleRequestStart({ sessionID: 's1' });
+    await hook.handleChatSystemTransform({ sessionID: 's1' }, stale);
 
-    expect(first.system.join('\n')).toContain(TODO_HYGIENE_REMINDER);
-    expect(second.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
+    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
+    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
+    await hook.handleChatSystemTransform({ sessionID: 's1' }, fresh);
+
+    expect(stale.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
+    expect(fresh.system.join('\n')).toContain(TODO_HYGIENE_REMINDER);
   });
 
-  test('multiple tools are deduplicated while reminder is pending', async () => {
-    let count = 0;
+  test('does not arm before the current request calls todowrite', async () => {
     const hook = createTodoHygiene({
-      getTodoState: async () => {
-        count++;
-        return {
-          hasOpenTodos: true,
-          openCount: 1,
-          inProgressCount: 0,
-          pendingCount: 1,
-        };
-      },
+      getTodoState: async () => createState(),
     });
+    const system = { system: ['base'] };
 
-    await hook.handleToolExecuteAfter({ tool: 'task', sessionID: 's1' });
+    hook.handleRequestStart({ sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'background_output', sessionID: 's1' });
+    await hook.handleChatSystemTransform({ sessionID: 's1' }, system);
 
-    expect(count).toBe(1);
+    expect(system.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
   });
 
-  test('does not re-arm until todowrite resets the cycle', async () => {
+  test('arms after the first relevant tool following todowrite', async () => {
     const hook = createTodoHygiene({
-      getTodoState: async () => ({
-        hasOpenTodos: true,
-        openCount: 1,
-        inProgressCount: 0,
-        pendingCount: 1,
-      }),
+      getTodoState: async () => 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.handleChatSystemTransform({ sessionID: 's1' }, system);
+
+    expect(system.system.join('\n')).toContain(TODO_HYGIENE_REMINDER);
+  });
+
+  test('multiple tools in the same round still inject only one reminder', async () => {
+    const hook = createTodoHygiene({
+      getTodoState: async () => 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.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
+    await hook.handleToolExecuteAfter({ tool: 'glob', sessionID: 's1' });
+    await hook.handleChatSystemTransform({ sessionID: 's1' }, system);
+
+    expect(system.system.filter((item) => item === TODO_HYGIENE_REMINDER)).toHaveLength(1);
+  });
+
+  test('injects again on a later round after new activity', async () => {
+    const hook = createTodoHygiene({
+      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);
+
     await hook.handleToolExecuteAfter({ tool: 'grep', sessionID: 's1' });
+    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
     await hook.handleChatSystemTransform({ sessionID: 's1' }, second);
 
     expect(first.system.join('\n')).toContain(TODO_HYGIENE_REMINDER);
-    expect(second.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
+    expect(second.system.join('\n')).toContain(TODO_HYGIENE_REMINDER);
   });
 
-  test('consumes pending reminder when shouldInject rejects the session', async () => {
+  test('upgrades to final-active on a later round', async () => {
+    let call = 0;
     const hook = createTodoHygiene({
-      getTodoState: async () => ({
-        hasOpenTodos: true,
-        openCount: 1,
-        inProgressCount: 0,
-        pendingCount: 1,
-      }),
-      shouldInject: () => false,
+      getTodoState: async () => {
+        call++;
+        if (call <= 4) {
+          return createState();
+        }
+        return createState({
+          openCount: 1,
+          inProgressCount: 1,
+          pendingCount: 0,
+        });
+      },
     });
     const first = { system: ['base'] };
     const second = { system: ['base'] };
 
-    await hook.handleToolExecuteAfter({ tool: 'task', sessionID: 's1' });
+    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.handleToolExecuteAfter({ tool: 'grep', sessionID: 's1' });
+    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
     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(first.system.join('\n')).toContain(TODO_HYGIENE_REMINDER);
+    expect(second.system.join('\n')).toContain(TODO_FINAL_ACTIVE_REMINDER);
   });
 
-  test('todowrite clears a pending reminder', async () => {
+  test('todowrite can arm final-active immediately', async () => {
     const hook = createTodoHygiene({
-      getTodoState: async () => ({
-        hasOpenTodos: true,
-        openCount: 1,
-        inProgressCount: 0,
-        pendingCount: 1,
-      }),
+      getTodoState: async () =>
+        createState({
+          openCount: 1,
+          inProgressCount: 1,
+          pendingCount: 0,
+        }),
     });
     const system = { system: ['base'] };
 
-    await hook.handleToolExecuteAfter({ tool: 'task', sessionID: 's1' });
+    hook.handleRequestStart({ sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
     await hook.handleChatSystemTransform({ sessionID: 's1' }, system);
 
-    expect(system.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
+    expect(system.system.join('\n')).toContain(TODO_FINAL_ACTIVE_REMINDER);
   });
 
-  test('todowrite re-enables the cycle when todos remain open', async () => {
+  test('once final-active is armed, later tools skip extra todo lookups in the same round', async () => {
+    let calls = 0;
     const hook = createTodoHygiene({
-      getTodoState: async () => ({
-        hasOpenTodos: true,
-        openCount: 1,
-        inProgressCount: 0,
-        pendingCount: 1,
-      }),
+      getTodoState: async () => {
+        calls++;
+        return createState({
+          openCount: 1,
+          inProgressCount: 1,
+          pendingCount: 0,
+        });
+      },
+    });
+
+    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(1);
+  });
+
+  test('shouldInject rejection consumes the pending reminder', async () => {
+    const hook = createTodoHygiene({
+      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.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'grep', sessionID: 's1' });
     await hook.handleChatSystemTransform({ sessionID: 's1' }, second);
 
-    expect(first.system.join('\n')).toContain(TODO_HYGIENE_REMINDER);
-    expect(second.system.join('\n')).toContain(TODO_HYGIENE_REMINDER);
+    expect(first.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
+    expect(second.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
   });
 
-  test('cleans pending reminder on session.deleted', async () => {
+  test('background_output gets the delegation reminder once for that round', async () => {
     const hook = createTodoHygiene({
-      getTodoState: async () => ({
-        hasOpenTodos: true,
-        openCount: 1,
-        inProgressCount: 0,
-        pendingCount: 1,
-      }),
+      getTodoState: async () => createState(),
     });
     const system = { system: ['base'] };
 
-    await hook.handleToolExecuteAfter({ tool: 'task', sessionID: 's1' });
-    hook.handleEvent({
-      type: 'session.deleted',
-      properties: { info: { id: 's1' } },
-    });
+    hook.handleRequestStart({ sessionID: 's1' });
+    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
+    await hook.handleToolExecuteAfter({ tool: 'background_output', sessionID: 's1' });
+    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
     await hook.handleChatSystemTransform({ sessionID: 's1' }, system);
 
+    expect(system.system.join('\n')).toContain(TODO_DELEGATION_RESUME_REMINDER);
     expect(system.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
   });
 
-  test('handleChatSystemTransform failures are best-effort and do not reject', async () => {
-    let calls = 0;
+  test('final-active overrides delegation reminder in the same round', async () => {
     const hook = createTodoHygiene({
-      getTodoState: async () => {
-        calls++;
-        if (calls === 1) {
-          return {
-            hasOpenTodos: true,
-            openCount: 1,
-            inProgressCount: 0,
-            pendingCount: 1,
-          };
-        }
-        throw new Error('boom');
-      },
+      getTodoState: async () =>
+        createState({
+          openCount: 1,
+          inProgressCount: 1,
+          pendingCount: 0,
+        }),
     });
     const system = { system: ['base'] };
 
-    await expect(
-      hook.handleToolExecuteAfter({ tool: 'task', sessionID: 's1' }),
-    ).resolves.toBeUndefined();
-    await expect(
-      hook.handleChatSystemTransform({ sessionID: 's1' }, system),
-    ).resolves.toBeUndefined();
+    hook.handleRequestStart({ sessionID: 's1' });
+    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
+    await hook.handleToolExecuteAfter({ tool: 'background_output', sessionID: 's1' });
+    await hook.handleChatSystemTransform({ sessionID: 's1' }, system);
 
-    expect(system.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
-    expect(system.system.join('\n')).not.toContain(TODO_FINAL_ACTIVE_REMINDER);
+    expect(system.system.join('\n')).toContain(TODO_FINAL_ACTIVE_REMINDER);
+    expect(system.system.join('\n')).not.toContain(TODO_DELEGATION_RESUME_REMINDER);
   });
 
-  test('todowrite state lookup failure clears stale pending state', async () => {
+  test('transform lookup failures are best-effort and do not drop later reminders', async () => {
     let fail = false;
     const hook = createTodoHygiene({
       getTodoState: async () => {
         if (fail) {
           throw new Error('boom');
         }
-        return {
-          hasOpenTodos: true,
-          openCount: 1,
-          inProgressCount: 0,
-          pendingCount: 1,
-        };
+        return createState();
       },
     });
-    const system = { system: ['base'] };
+    const failed = { system: ['base'] };
+    const recovered = { system: ['base'] };
 
-    await hook.handleToolExecuteAfter({ tool: 'task', sessionID: 's1' });
+    hook.handleRequestStart({ sessionID: 's1' });
+    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
+    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
     fail = true;
-    await expect(
-      hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' }),
-    ).resolves.toBeUndefined();
+    await hook.handleChatSystemTransform({ sessionID: 's1' }, failed);
+
+    fail = false;
+    await hook.handleToolExecuteAfter({ tool: 'grep', sessionID: 's1' });
+    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
+    await hook.handleChatSystemTransform({ sessionID: 's1' }, recovered);
+
+    expect(failed.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
+    expect(recovered.system.join('\n')).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 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')).not.toContain(TODO_HYGIENE_REMINDER);
-    expect(system.system.join('\n')).not.toContain(TODO_FINAL_ACTIVE_REMINDER);
+    expect(system.system.join('\n')).toContain(TODO_HYGIENE_REMINDER);
   });
 
-  test('uses the final-active reminder when only one in_progress remains', async () => {
+  test('todowrite lookup failures do not disable the current request', async () => {
+    let fail = false;
     const hook = createTodoHygiene({
-      getTodoState: async () => ({
-        hasOpenTodos: true,
-        openCount: 1,
-        inProgressCount: 1,
-        pendingCount: 0,
-      }),
+      getTodoState: async () => {
+        if (fail) {
+          throw new Error('boom');
+        }
+        return createState();
+      },
     });
     const system = { system: ['base'] };
 
-    await hook.handleToolExecuteAfter({ tool: 'task', sessionID: 's1' });
+    hook.handleRequestStart({ sessionID: 's1' });
+    fail = true;
+    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
+    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_FINAL_ACTIVE_REMINDER);
-    expect(system.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
+    expect(system.system.join('\n')).toContain(TODO_HYGIENE_REMINDER);
   });
 
-  test('todowrite rearms the final-active reminder when only one in_progress remains', async () => {
+  test('non-injectable sessions are fully cleared after a rejected round', async () => {
+    let calls = 0;
     const hook = createTodoHygiene({
-      getTodoState: async () => ({
-        hasOpenTodos: true,
-        openCount: 1,
-        inProgressCount: 1,
-        pendingCount: 0,
-      }),
+      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.handleToolExecuteAfter({ tool: 'grep', sessionID: 's1' });
 
-    expect(system.system.join('\n')).toContain(TODO_FINAL_ACTIVE_REMINDER);
+    expect(calls).toBe(1);
     expect(system.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
   });
 
-  test('does not use final-active reminder when another open status exists', async () => {
+  test('session.deleted clears all state', async () => {
     const hook = createTodoHygiene({
-      getTodoState: async () => ({
-        hasOpenTodos: true,
-        openCount: 2,
-        inProgressCount: 1,
-        pendingCount: 0,
-      }),
+      getTodoState: async () => createState(),
     });
     const system = { system: ['base'] };
 
-    await hook.handleToolExecuteAfter({ tool: 'task', sessionID: 's1' });
+    hook.handleRequestStart({ sessionID: 's1' });
+    await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
+    await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
+    hook.handleEvent({
+      type: 'session.deleted',
+      properties: { info: { id: 's1' } },
+    });
     await hook.handleChatSystemTransform({ sessionID: 's1' }, system);
 
-    expect(system.system.join('\n')).toContain(TODO_HYGIENE_REMINDER);
-    expect(system.system.join('\n')).not.toContain(TODO_FINAL_ACTIVE_REMINDER);
+    expect(system.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
   });
 });

+ 80 - 30
src/hooks/todo-continuation/todo-hygiene.ts

@@ -2,9 +2,14 @@ export const TODO_HYGIENE_REMINDER =
   'If the active task changed or finished, update the todo list to match the current work state.';
 export const TODO_FINAL_ACTIVE_REMINDER =
   'If you are finishing now, do not leave the active todo in_progress. Mark it completed, or move unfinished work back to pending.';
+export const TODO_DELEGATION_RESUME_REMINDER =
+  'A delegated result just returned. Reconcile the todo list before continuing or delegating again.';
 
 const RESET = new Set(['todowrite']);
 const IGNORE = new Set(['auto_continue']);
+const DELEGATION = new Set(['background_output']);
+
+type Reason = 'general' | 'delegation_resume' | 'final_active';
 
 interface ToolInput {
   tool: string;
@@ -27,6 +32,10 @@ interface EventInput {
   };
 }
 
+interface RequestStartInput {
+  sessionID: string;
+}
+
 interface Options {
   getTodoState: (sessionID: string) => Promise<{
     hasOpenTodos: boolean;
@@ -39,12 +48,16 @@ interface Options {
 }
 
 export function createTodoHygiene(options: Options) {
-  const pending = new Set<string>();
-  const done = new Set<string>();
+  const pending = new Map<string, Set<Reason>>();
+  const active = new Set<string>();
 
-  function clear(sessionID: string): void {
+  function clearCycle(sessionID: string): void {
     pending.delete(sessionID);
-    done.delete(sessionID);
+  }
+
+  function clear(sessionID: string): void {
+    clearCycle(sessionID);
+    active.delete(sessionID);
   }
 
   function isFinalActive(state: {
@@ -59,7 +72,29 @@ export function createTodoHygiene(options: Options) {
     );
   }
 
+  function mark(sessionID: string, reason: Reason): void {
+    const reasons = pending.get(sessionID) ?? new Set<Reason>();
+    reasons.add(reason);
+    pending.set(sessionID, reasons);
+  }
+
+  function pick(reasons: Set<Reason>): string {
+    if (reasons.has('final_active')) {
+      return TODO_FINAL_ACTIVE_REMINDER;
+    }
+
+    if (reasons.has('delegation_resume')) {
+      return TODO_DELEGATION_RESUME_REMINDER;
+    }
+
+    return TODO_HYGIENE_REMINDER;
+  }
+
   return {
+    handleRequestStart(input: RequestStartInput): void {
+      clear(input.sessionID);
+    },
+
     async handleToolExecuteAfter(input: ToolInput): Promise<void> {
       if (!input.sessionID) {
         return;
@@ -72,9 +107,11 @@ export function createTodoHygiene(options: Options) {
 
       try {
         if (RESET.has(tool)) {
+          active.add(input.sessionID);
+          clearCycle(input.sessionID);
           const state = await options.getTodoState(input.sessionID);
           if (!state.hasOpenTodos) {
-            clear(input.sessionID);
+            active.delete(input.sessionID);
             options.log?.('Cleared todo hygiene cycle', {
               sessionID: input.sessionID,
               tool,
@@ -82,42 +119,55 @@ export function createTodoHygiene(options: Options) {
             return;
           }
 
-          pending.delete(input.sessionID);
-          done.delete(input.sessionID);
-
-          if (isFinalActive(state)) {
-            pending.add(input.sessionID);
-            options.log?.('Armed final-active todo hygiene reminder', {
+          if (!isFinalActive(state)) {
+            options.log?.('Reset todo hygiene cycle', {
               sessionID: input.sessionID,
               tool,
             });
             return;
           }
 
-          options.log?.('Reset todo hygiene cycle', {
+          mark(input.sessionID, 'final_active');
+          options.log?.('Armed final-active todo hygiene reminder', {
             sessionID: input.sessionID,
             tool,
           });
           return;
         }
 
-        if (pending.has(input.sessionID) || done.has(input.sessionID)) {
+        if (!active.has(input.sessionID)) {
+          return;
+        }
+
+        if (pending.get(input.sessionID)?.has('final_active')) {
+          return;
+        }
+
+        if (options.shouldInject && !options.shouldInject(input.sessionID)) {
+          clear(input.sessionID);
           return;
         }
 
-        if (!(await options.getTodoState(input.sessionID)).hasOpenTodos) {
+        const state = await options.getTodoState(input.sessionID);
+        if (!state.hasOpenTodos) {
+          clear(input.sessionID);
           return;
         }
 
-        pending.add(input.sessionID);
+        if (isFinalActive(state)) {
+          mark(input.sessionID, 'final_active');
+        } else if (DELEGATION.has(tool)) {
+          mark(input.sessionID, 'delegation_resume');
+        } else {
+          mark(input.sessionID, 'general');
+        }
+
         options.log?.('Armed todo hygiene reminder', {
           sessionID: input.sessionID,
           tool,
+          reasons: Array.from(pending.get(input.sessionID) ?? []),
         });
       } catch (error) {
-        if (RESET.has(tool)) {
-          clear(input.sessionID);
-        }
         options.log?.('Skipped todo hygiene reminder: failed to inspect todos', {
           sessionID: input.sessionID,
           tool,
@@ -130,13 +180,19 @@ export function createTodoHygiene(options: Options) {
       input: SystemInput,
       output: SystemOutput,
     ): Promise<void> {
-      if (!input.sessionID || !pending.has(input.sessionID)) {
+      if (!input.sessionID) {
         return;
       }
 
+      const reasons = pending.get(input.sessionID);
+      if (!reasons || reasons.size === 0) {
+        return;
+      }
+
+      const reminder = pick(reasons);
+
       if (options.shouldInject && !options.shouldInject(input.sessionID)) {
-        pending.delete(input.sessionID);
-        done.add(input.sessionID);
+        clear(input.sessionID);
         return;
       }
 
@@ -147,20 +203,15 @@ export function createTodoHygiene(options: Options) {
           return;
         }
 
-        const finalActive = isFinalActive(state);
-        const reminder = finalActive
-          ? TODO_FINAL_ACTIVE_REMINDER
-          : TODO_HYGIENE_REMINDER;
-
         pending.delete(input.sessionID);
-        done.add(input.sessionID);
         output.system.push(reminder);
         options.log?.('Injected todo hygiene reminder', {
           sessionID: input.sessionID,
-          reminder: finalActive ? 'final-active' : 'general',
+          reminder,
+          reasons: Array.from(reasons),
         });
       } catch (error) {
-        clear(input.sessionID);
+        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),
@@ -180,6 +231,5 @@ export function createTodoHygiene(options: Options) {
 
       clear(sessionID);
     },
-
   };
 }

+ 3 - 1
src/index.ts

@@ -163,7 +163,6 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   const delegateTaskRetryHook = createDelegateTaskRetryHook(ctx);
 
   const applyPatchHook = createApplyPatchHook(ctx);
-
   // Initialize JSON parse error recovery hook
   const jsonErrorRecoveryHook = createJsonErrorRecoveryHook(ctx);
 
@@ -576,6 +575,9 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
           }>;
         }>;
       };
+      await todoContinuationHook.handleMessagesTransform({
+        messages: typedOutput.messages,
+      });
       await phaseReminderHook['experimental.chat.messages.transform'](
         input,
         typedOutput,