Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 195 additions & 0 deletions src/hooks/prometheus-md-only/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>; 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<string, unknown>; 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<string, unknown>; 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<string, unknown>; 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<string, unknown>; 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<string, unknown>; 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<string, unknown>; 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<string, unknown>; 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<string, unknown>; 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<string, unknown>; 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<string, unknown>; message?: string } = {
args: { filePath: draftPath },
}

// when / #then - multiple writes to drafts should be allowed
await expect(
hook["tool.execute.before"](secondInput, secondOutput)
).resolves.toBeUndefined()
})
})
})
67 changes: 65 additions & 2 deletions src/hooks/prometheus-md-only/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Set<string>>()

function getWrittenFiles(sessionID: string): Set<string> {
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 },
Expand Down Expand Up @@ -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,
Expand All @@ -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<string, unknown> | 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)
}
}
},
}
}