diff --git a/src/config/schema.ts b/src/config/schema.ts index f6d15d61..34ec376b 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -87,6 +87,7 @@ export const HookNameSchema = z.enum([ "category-skill-reminder", "compaction-context-injector", + "compaction-todo-preserver", "claude-code-hooks", "auto-slash-command", "edit-error-recovery", diff --git a/src/hooks/compaction-context-injector/index.test.ts b/src/hooks/compaction-context-injector/index.test.ts index 7b862741..85348e09 100644 --- a/src/hooks/compaction-context-injector/index.test.ts +++ b/src/hooks/compaction-context-injector/index.test.ts @@ -56,4 +56,17 @@ describe("createCompactionContextInjector", () => { expect(prompt).toContain("Files already verified") }) }) + + it("restricts constraints to explicit verbatim statements", async () => { + //#given + const injector = createCompactionContextInjector() + + //#when + const prompt = injector() + + //#then + expect(prompt).toContain("Explicit Constraints (Verbatim Only)") + expect(prompt).toContain("Do NOT invent") + expect(prompt).toContain("Quote constraints verbatim") + }) }) diff --git a/src/hooks/compaction-context-injector/index.ts b/src/hooks/compaction-context-injector/index.ts index 81b6f1c3..d9fed61d 100644 --- a/src/hooks/compaction-context-injector/index.ts +++ b/src/hooks/compaction-context-injector/index.ts @@ -29,11 +29,11 @@ When summarizing this session, you MUST include the following sections in your s - **External References**: Documentation URLs, library APIs, or external resources being consulted - **State & Variables**: Important variable names, configuration values, or runtime state relevant to ongoing work -## 6. MUST NOT Do (Critical Constraints) -- Things that were explicitly forbidden -- Approaches that failed and should not be retried -- User's explicit restrictions or preferences -- Anti-patterns identified during the session +## 6. Explicit Constraints (Verbatim Only) +- Include ONLY constraints explicitly stated by the user or in existing AGENTS.md context +- Quote constraints verbatim (do not paraphrase) +- Do NOT invent, add, or modify constraints +- If no explicit constraints exist, write "None" ## 7. Agent Verification State (Critical for Reviewers) - **Current Agent**: What agent is running (momus, oracle, etc.) diff --git a/src/hooks/compaction-todo-preserver/index.test.ts b/src/hooks/compaction-todo-preserver/index.test.ts new file mode 100644 index 00000000..04cc577a --- /dev/null +++ b/src/hooks/compaction-todo-preserver/index.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, mock } from "bun:test" +import type { PluginInput } from "@opencode-ai/plugin" +import { createCompactionTodoPreserverHook } from "./index" + +const updateMock = mock(async () => {}) + +mock.module("opencode/session/todo", () => ({ + Todo: { + update: updateMock, + }, +})) + +type TodoSnapshot = { + id: string + content: string + status: "pending" | "in_progress" | "completed" | "cancelled" + priority?: "low" | "medium" | "high" +} + +function createMockContext(todoResponses: TodoSnapshot[][]): PluginInput { + let callIndex = 0 + return { + client: { + session: { + todo: async () => { + const current = todoResponses[Math.min(callIndex, todoResponses.length - 1)] ?? [] + callIndex += 1 + return { data: current } + }, + }, + }, + directory: "/tmp/test", + } as PluginInput +} + +describe("compaction-todo-preserver", () => { + it("restores todos after compaction when missing", async () => { + //#given + updateMock.mockClear() + const sessionID = "session-compaction-missing" + const todos = [ + { id: "1", content: "Task 1", status: "pending", priority: "high" }, + { id: "2", content: "Task 2", status: "in_progress", priority: "medium" }, + ] + const ctx = createMockContext([todos, []]) + const hook = createCompactionTodoPreserverHook(ctx) + + //#when + await hook.capture(sessionID) + await hook.event({ event: { type: "session.compacted", properties: { sessionID } } }) + + //#then + expect(updateMock).toHaveBeenCalledTimes(1) + expect(updateMock).toHaveBeenCalledWith({ sessionID, todos }) + }) + + it("skips restore when todos already present", async () => { + //#given + updateMock.mockClear() + const sessionID = "session-compaction-present" + const todos = [ + { id: "1", content: "Task 1", status: "pending", priority: "high" }, + ] + const ctx = createMockContext([todos, todos]) + const hook = createCompactionTodoPreserverHook(ctx) + + //#when + await hook.capture(sessionID) + await hook.event({ event: { type: "session.compacted", properties: { sessionID } } }) + + //#then + expect(updateMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/hooks/compaction-todo-preserver/index.ts b/src/hooks/compaction-todo-preserver/index.ts new file mode 100644 index 00000000..dc1a8721 --- /dev/null +++ b/src/hooks/compaction-todo-preserver/index.ts @@ -0,0 +1,127 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { log } from "../../shared/logger" + +interface TodoSnapshot { + id: string + content: string + status: "pending" | "in_progress" | "completed" | "cancelled" + priority?: "low" | "medium" | "high" +} + +type TodoWriter = (input: { sessionID: string; todos: TodoSnapshot[] }) => Promise + +const HOOK_NAME = "compaction-todo-preserver" + +function extractTodos(response: unknown): TodoSnapshot[] { + const payload = response as { data?: unknown } + if (Array.isArray(payload?.data)) { + return payload.data as TodoSnapshot[] + } + if (Array.isArray(response)) { + return response as TodoSnapshot[] + } + return [] +} + +async function resolveTodoWriter(): Promise { + try { + const loader = "opencode/session/todo" + const mod = (await import(loader)) as { + Todo?: { update?: TodoWriter } + } + const update = mod.Todo?.update + if (typeof update === "function") { + return update + } + } catch (err) { + log(`[${HOOK_NAME}] Failed to resolve Todo.update`, { error: String(err) }) + } + return null +} + +function resolveSessionID(props?: Record): string | undefined { + return (props?.sessionID ?? + (props?.info as { id?: string } | undefined)?.id) as string | undefined +} + +export interface CompactionTodoPreserver { + capture: (sessionID: string) => Promise + event: (input: { event: { type: string; properties?: unknown } }) => Promise +} + +export function createCompactionTodoPreserverHook( + ctx: PluginInput, +): CompactionTodoPreserver { + const snapshots = new Map() + + const capture = async (sessionID: string): Promise => { + if (!sessionID) return + try { + const response = await ctx.client.session.todo({ path: { id: sessionID } }) + const todos = extractTodos(response) + if (todos.length === 0) return + snapshots.set(sessionID, todos) + log(`[${HOOK_NAME}] Captured todo snapshot`, { sessionID, count: todos.length }) + } catch (err) { + log(`[${HOOK_NAME}] Failed to capture todos`, { sessionID, error: String(err) }) + } + } + + const restore = async (sessionID: string): Promise => { + const snapshot = snapshots.get(sessionID) + if (!snapshot || snapshot.length === 0) return + + let hasCurrent = false + let currentTodos: TodoSnapshot[] = [] + try { + const response = await ctx.client.session.todo({ path: { id: sessionID } }) + currentTodos = extractTodos(response) + hasCurrent = true + } catch (err) { + log(`[${HOOK_NAME}] Failed to fetch todos post-compaction`, { sessionID, error: String(err) }) + } + + if (hasCurrent && currentTodos.length > 0) { + snapshots.delete(sessionID) + log(`[${HOOK_NAME}] Skipped restore (todos already present)`, { sessionID, count: currentTodos.length }) + return + } + + const writer = await resolveTodoWriter() + if (!writer) { + log(`[${HOOK_NAME}] Skipped restore (Todo.update unavailable)`, { sessionID }) + return + } + + try { + await writer({ sessionID, todos: snapshot }) + log(`[${HOOK_NAME}] Restored todos after compaction`, { sessionID, count: snapshot.length }) + } catch (err) { + log(`[${HOOK_NAME}] Failed to restore todos`, { sessionID, error: String(err) }) + } finally { + snapshots.delete(sessionID) + } + } + + const event = async ({ event }: { event: { type: string; properties?: unknown } }): Promise => { + const props = event.properties as Record | undefined + + if (event.type === "session.deleted") { + const sessionID = resolveSessionID(props) + if (sessionID) { + snapshots.delete(sessionID) + } + return + } + + if (event.type === "session.compacted") { + const sessionID = resolveSessionID(props) + if (sessionID) { + await restore(sessionID) + } + return + } + } + + return { capture, event } +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index d99abf28..a964780c 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -35,6 +35,7 @@ export { createQuestionLabelTruncatorHook } from "./question-label-truncator"; export { createSubagentQuestionBlockerHook } from "./subagent-question-blocker"; export { createStopContinuationGuardHook, type StopContinuationGuard } from "./stop-continuation-guard"; export { createCompactionContextInjector } from "./compaction-context-injector"; +export { createCompactionTodoPreserverHook } from "./compaction-todo-preserver"; export { createUnstableAgentBabysitterHook } from "./unstable-agent-babysitter"; export { createPreemptiveCompactionHook } from "./preemptive-compaction"; export { createTasksTodowriteDisablerHook } from "./tasks-todowrite-disabler"; diff --git a/src/index.ts b/src/index.ts index 0aa5ce8c..d97bd739 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,7 @@ import { createSubagentQuestionBlockerHook, createStopContinuationGuardHook, createCompactionContextInjector, + createCompactionTodoPreserverHook, createUnstableAgentBabysitterHook, createPreemptiveCompactionHook, createTasksTodowriteDisablerHook, @@ -356,6 +357,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { ? safeCreateHook("compaction-context-injector", () => createCompactionContextInjector(), { enabled: safeHookEnabled }) : null; + const compactionTodoPreserver = isHookEnabled("compaction-todo-preserver") + ? safeCreateHook("compaction-todo-preserver", () => createCompactionTodoPreserverHook(ctx), { enabled: safeHookEnabled }) + : null; + const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer") ? safeCreateHook("todo-continuation-enforcer", () => createTodoContinuationEnforcer(ctx, { backgroundManager, @@ -730,6 +735,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { await interactiveBashSession?.event(input); await ralphLoop?.event(input); await stopContinuationGuard?.event(input); + await compactionTodoPreserver?.event(input); await atlasHook?.handler(input); const { event } = input; @@ -945,6 +951,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { _input: { sessionID: string }, output: { context: string[] }, ): Promise => { + await compactionTodoPreserver?.capture(_input.sessionID); if (!compactionContextInjector) { return; }