diff --git a/src/hooks/prometheus-md-only/index.test.ts b/src/hooks/prometheus-md-only/index.test.ts index d564424027..3894821791 100644 --- a/src/hooks/prometheus-md-only/index.test.ts +++ b/src/hooks/prometheus-md-only/index.test.ts @@ -568,4 +568,199 @@ describe("prometheus-md-only", () => { ).rejects.toThrow("can only write/edit .md files") }) }) + + describe("plan file overwrite protection", () => { + beforeEach(() => { + setupMessageStorage(TEST_SESSION_ID, "prometheus") + }) + + test("should allow first Write to a plan file", async () => { + // given + const hook = createPrometheusMdOnlyHook(createMockPluginInput()) + const input = { + tool: "Write", + sessionID: TEST_SESSION_ID, + callID: "call-1", + } + const output: { args: Record; message?: string } = { + args: { filePath: "/tmp/test/.sisyphus/plans/my-plan.md" }, + } + + // when / #then - first write should succeed + await expect( + hook["tool.execute.before"](input, output) + ).resolves.toBeUndefined() + }) + + test("should block second Write to same plan file and suggest Edit", async () => { + // given + const hook = createPrometheusMdOnlyHook(createMockPluginInput()) + const planPath = "/tmp/test/.sisyphus/plans/my-plan.md" + + // first write + const firstInput = { + tool: "Write", + sessionID: TEST_SESSION_ID, + callID: "call-1", + } + const firstOutput: { args: Record; message?: string } = { + args: { filePath: planPath }, + } + await hook["tool.execute.before"](firstInput, firstOutput) + + // second write to same file + const secondInput = { + tool: "Write", + sessionID: TEST_SESSION_ID, + callID: "call-2", + } + const secondOutput: { args: Record; message?: string } = { + args: { filePath: planPath }, + } + + // when / #then - second write should be blocked + await expect( + hook["tool.execute.before"](secondInput, secondOutput) + ).rejects.toThrow("use Edit tool to append") + }) + + test("should allow Write to different plan files", async () => { + // given + const hook = createPrometheusMdOnlyHook(createMockPluginInput()) + + // first write to plan-a + const firstInput = { + tool: "Write", + sessionID: TEST_SESSION_ID, + callID: "call-1", + } + const firstOutput: { args: Record; message?: string } = { + args: { filePath: "/tmp/test/.sisyphus/plans/plan-a.md" }, + } + await hook["tool.execute.before"](firstInput, firstOutput) + + // second write to different file plan-b + const secondInput = { + tool: "Write", + sessionID: TEST_SESSION_ID, + callID: "call-2", + } + const secondOutput: { args: Record; message?: string } = { + args: { filePath: "/tmp/test/.sisyphus/plans/plan-b.md" }, + } + + // when / #then - write to different file should succeed + await expect( + hook["tool.execute.before"](secondInput, secondOutput) + ).resolves.toBeUndefined() + }) + + test("should allow Edit after Write to same plan file", async () => { + // given + const hook = createPrometheusMdOnlyHook(createMockPluginInput()) + const planPath = "/tmp/test/.sisyphus/plans/my-plan.md" + + // first write + const writeInput = { + tool: "Write", + sessionID: TEST_SESSION_ID, + callID: "call-1", + } + const writeOutput: { args: Record; message?: string } = { + args: { filePath: planPath }, + } + await hook["tool.execute.before"](writeInput, writeOutput) + + // Edit to same file should be allowed + const editInput = { + tool: "Edit", + sessionID: TEST_SESSION_ID, + callID: "call-2", + } + const editOutput: { args: Record; message?: string } = { + args: { filePath: planPath }, + } + + // when / #then - Edit should succeed + await expect( + hook["tool.execute.before"](editInput, editOutput) + ).resolves.toBeUndefined() + }) + + test("should track writes per session (different sessions are independent)", async () => { + // given + const hook = createPrometheusMdOnlyHook(createMockPluginInput()) + const planPath = "/tmp/test/.sisyphus/plans/my-plan.md" + const otherSessionID = "other-session-prometheus" + + // Setup second session + const otherMessageDir = join(MESSAGE_STORAGE, otherSessionID) + mkdirSync(otherMessageDir, { recursive: true }) + writeFileSync( + join(otherMessageDir, "msg_001.json"), + JSON.stringify({ agent: "prometheus", model: { providerID: "test", modelID: "test-model" } }) + ) + + // first session writes to plan + const firstSessionInput = { + tool: "Write", + sessionID: TEST_SESSION_ID, + callID: "call-1", + } + const firstSessionOutput: { args: Record; message?: string } = { + args: { filePath: planPath }, + } + await hook["tool.execute.before"](firstSessionInput, firstSessionOutput) + + // second session writes to same plan path (should succeed - different session) + const secondSessionInput = { + tool: "Write", + sessionID: otherSessionID, + callID: "call-2", + } + const secondSessionOutput: { args: Record; message?: string } = { + args: { filePath: planPath }, + } + + // when / #then - different session should be allowed + await expect( + hook["tool.execute.before"](secondSessionInput, secondSessionOutput) + ).resolves.toBeUndefined() + + // cleanup + rmSync(otherMessageDir, { recursive: true, force: true }) + }) + + test("should NOT apply overwrite protection to draft files", async () => { + // given + const hook = createPrometheusMdOnlyHook(createMockPluginInput()) + const draftPath = "/tmp/test/.sisyphus/drafts/notes.md" + + // first write to draft + const firstInput = { + tool: "Write", + sessionID: TEST_SESSION_ID, + callID: "call-1", + } + const firstOutput: { args: Record; message?: string } = { + args: { filePath: draftPath }, + } + await hook["tool.execute.before"](firstInput, firstOutput) + + // second write to same draft should succeed (drafts are working memory) + const secondInput = { + tool: "Write", + sessionID: TEST_SESSION_ID, + callID: "call-2", + } + const secondOutput: { args: Record; message?: string } = { + args: { filePath: draftPath }, + } + + // when / #then - multiple writes to drafts should be allowed + await expect( + hook["tool.execute.before"](secondInput, secondOutput) + ).resolves.toBeUndefined() + }) + }) }) diff --git a/src/hooks/prometheus-md-only/index.ts b/src/hooks/prometheus-md-only/index.ts index 1c311dc024..af1f10ee4b 100644 --- a/src/hooks/prometheus-md-only/index.ts +++ b/src/hooks/prometheus-md-only/index.ts @@ -74,7 +74,33 @@ function getAgentFromSession(sessionID: string): string | undefined { return getSessionAgent(sessionID) ?? getAgentFromMessageFiles(sessionID) } +function isPlanFile(normalizedPath: string): boolean { + return normalizedPath.includes(".sisyphus/plans/") || normalizedPath.includes(".sisyphus\\plans\\") +} + +function normalizeFilePath(filePath: string, workspaceRoot: string): string { + const resolved = resolve(workspaceRoot, filePath) + return resolved.toLowerCase().replace(/\\/g, "/") +} + export function createPrometheusMdOnlyHook(ctx: PluginInput) { + const writtenPlanFiles = new Map>() + + function getWrittenFiles(sessionID: string): Set { + if (!writtenPlanFiles.has(sessionID)) { + writtenPlanFiles.set(sessionID, new Set()) + } + return writtenPlanFiles.get(sessionID)! + } + + function markFileWritten(sessionID: string, normalizedPath: string): void { + getWrittenFiles(sessionID).add(normalizedPath) + } + + function hasBeenWritten(sessionID: string, normalizedPath: string): boolean { + return getWrittenFiles(sessionID).has(normalizedPath) + } + return { "tool.execute.before": async ( input: { tool: string; sessionID: string; callID: string }, @@ -140,8 +166,29 @@ export function createPrometheusMdOnlyHook(ctx: PluginInput) { ) } - const normalizedPath = filePath.toLowerCase().replace(/\\/g, "/") - if (normalizedPath.includes(".sisyphus/plans/") || normalizedPath.includes(".sisyphus\\plans\\")) { + const normalizedPath = normalizeFilePath(filePath, ctx.directory) + const isWriteTool = toolName.toLowerCase() === "write" + const isPlan = isPlanFile(normalizedPath) + + if (isWriteTool && isPlan && hasBeenWritten(input.sessionID, normalizedPath)) { + log(`[${HOOK_NAME}] Blocked: Plan file already written, use Edit to append`, { + sessionID: input.sessionID, + tool: toolName, + filePath, + agent: agentName, + }) + throw new Error( + `[${HOOK_NAME}] Plan file "${filePath}" has already been written in this session. ` + + `The Write tool OVERWRITES content. To add more sections, use Edit tool to append. ` + + `Example: Edit(filePath="${filePath}", oldString="## Success Criteria", newString="## More TODOs\\n...\\n## Success Criteria")` + ) + } + + if (isWriteTool && isPlan) { + markFileWritten(input.sessionID, normalizedPath) + } + + if (isPlan) { log(`[${HOOK_NAME}] Injecting workflow reminder for plan write`, { sessionID: input.sessionID, tool: toolName, @@ -158,5 +205,21 @@ export function createPrometheusMdOnlyHook(ctx: PluginInput) { agent: agentName, }) }, + "event": async ({ event }: { event: { type: string; properties?: unknown } }) => { + const props = event.properties as Record | undefined + if (event.type === "session.deleted") { + const sessionInfo = props?.info as { id?: string } | undefined + if (sessionInfo?.id) { + writtenPlanFiles.delete(sessionInfo.id) + } + } + if (event.type === "session.compacted") { + const sessionID = (props?.sessionID ?? + (props?.info as { id?: string } | undefined)?.id) as string | undefined + if (sessionID) { + writtenPlanFiles.delete(sessionID) + } + } + }, } }