diff --git a/packages/boulder-state/src/storage/plan-progress.ts b/packages/boulder-state/src/storage/plan-progress.ts index fb8f40b1e..63d70faed 100644 --- a/packages/boulder-state/src/storage/plan-progress.ts +++ b/packages/boulder-state/src/storage/plan-progress.ts @@ -11,19 +11,23 @@ const UNCHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[\s*\]\s*(.+)$/ const CHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[[xX]\]\s*(.+)$/ const TODO_TASK_PATTERN = /^\d+\.\s+/ const FINAL_WAVE_TASK_PATTERN = /^F\d+\.\s+/i +const LEGACY_PROMETHEUS_PLANS_DIR = ".sisyphus/plans" +const PROMETHEUS_PLAN_DIRS = [PROMETHEUS_PLANS_DIR, LEGACY_PROMETHEUS_PLANS_DIR] as const type ProgressSection = "todo" | "final-wave" | "other" export function findPrometheusPlans(directory: string): string[] { - const plansDir = join(directory, PROMETHEUS_PLANS_DIR) - if (!existsSync(plansDir)) { - return [] - } - try { - return readdirSync(plansDir) - .filter((file) => file.endsWith(".md")) - .map((file) => join(plansDir, file)) + return PROMETHEUS_PLAN_DIRS.flatMap((planDir) => { + const plansDir = join(directory, planDir) + if (!existsSync(plansDir)) { + return [] + } + + return readdirSync(plansDir) + .filter((file) => file.endsWith(".md")) + .map((file) => join(plansDir, file)) + }) .sort((left, right) => statSync(right).mtimeMs - statSync(left).mtimeMs) } catch { return [] diff --git a/src/hooks/notepad-write-guard/index.test.ts b/src/hooks/notepad-write-guard/index.test.ts index 9b0e664a0..794216543 100644 --- a/src/hooks/notepad-write-guard/index.test.ts +++ b/src/hooks/notepad-write-guard/index.test.ts @@ -15,65 +15,64 @@ async function invoke( ) } +async function expectWriteBlocked(hook: Hook, filePath: string): Promise { + let caughtMessage = "" + try { + await invoke(hook, { tool: "write", filePath }) + } catch (err) { + if (err instanceof Error) { + caughtMessage = err.message + } else { + throw err + } + } + + expect(caughtMessage).toContain(REFUSED_PREFIX) +} + describe("createNotepadWriteGuardHook", () => { test("#given notepad decisions.md #when write executes #then rejects with actionable error", async () => { const hook = createNotepadWriteGuardHook() - await expect( - invoke(hook, { - tool: "write", - filePath: ".sisyphus/notepads/foo/decisions.md", - }), - ).rejects.toThrow(REFUSED_PREFIX) + await expectWriteBlocked(hook, ".sisyphus/notepads/foo/decisions.md") }) test("#given notepad state.json #when write executes #then rejects (entire notepad subtree blocked)", async () => { const hook = createNotepadWriteGuardHook() - await expect( - invoke(hook, { - tool: "write", - filePath: ".sisyphus/notepads/foo/state.json", - }), - ).rejects.toThrow(REFUSED_PREFIX) + await expectWriteBlocked(hook, ".sisyphus/notepads/foo/state.json") + }) + + test("#given current omo notepad file #when write executes #then rejects", async () => { + const hook = createNotepadWriteGuardHook() + await expectWriteBlocked(hook, ".omo/notepads/foo/decisions.md") }) test("#given regular src file #when write executes #then allows (not intercepted)", async () => { const hook = createNotepadWriteGuardHook() - await expect( - invoke(hook, { - tool: "write", - filePath: "src/index.ts", - }), - ).resolves.toBeUndefined() + await invoke(hook, { + tool: "write", + filePath: "src/index.ts", + }) }) test("#given non-write tool on notepad path #when executes #then allows", async () => { const hook = createNotepadWriteGuardHook() - await expect( - invoke(hook, { - tool: "read", - filePath: ".sisyphus/notepads/foo/decisions.md", - }), - ).resolves.toBeUndefined() + await invoke(hook, { + tool: "read", + filePath: ".sisyphus/notepads/foo/decisions.md", + }) }) test("#given sisyphus plans file (not notepads) #when write executes #then allows", async () => { const hook = createNotepadWriteGuardHook() - await expect( - invoke(hook, { - tool: "write", - filePath: ".sisyphus/plans/my-plan.md", - }), - ).resolves.toBeUndefined() + await invoke(hook, { + tool: "write", + filePath: ".sisyphus/plans/my-plan.md", + }) }) test("#given absolute notepad path #when write executes #then rejects", async () => { const hook = createNotepadWriteGuardHook() - await expect( - invoke(hook, { - tool: "write", - filePath: "/home/user/project/.sisyphus/notepads/plan/decisions.md", - }), - ).rejects.toThrow(REFUSED_PREFIX) + await expectWriteBlocked(hook, "/home/user/project/.sisyphus/notepads/plan/decisions.md") }) test("#given error message #when rejected #then message names the file and gives guidance", async () => { diff --git a/src/hooks/notepad-write-guard/index.ts b/src/hooks/notepad-write-guard/index.ts index 2302e27a3..e558026e0 100644 --- a/src/hooks/notepad-write-guard/index.ts +++ b/src/hooks/notepad-write-guard/index.ts @@ -1,11 +1,20 @@ import type { Hooks } from "@opencode-ai/plugin" -import { normalize } from "path" +import { normalize, sep } from "path" -const NOTEPAD_SEGMENT = `${normalize(".sisyphus/notepads")}/` +const NOTEPAD_ROOTS = [ + normalize(".sisyphus/notepads"), + normalize(".omo/notepads"), +] as const -function isNotebookPath(filePath: string): boolean { - const normalised = normalize(filePath) - return normalised.includes(`/.sisyphus/notepads/`) || normalised.startsWith(NOTEPAD_SEGMENT) +function hasNotepadRoot(normalizedPath: string, notepadRoot: string): boolean { + return normalizedPath === notepadRoot + || normalizedPath.startsWith(`${notepadRoot}${sep}`) + || normalizedPath.includes(`${sep}${notepadRoot}${sep}`) +} + +function isNotepadPath(filePath: string): boolean { + const normalizedPath = normalize(filePath) + return NOTEPAD_ROOTS.some((notepadRoot) => hasNotepadRoot(normalizedPath, notepadRoot)) } function resolveFilePath(args: unknown): string | undefined { @@ -27,7 +36,7 @@ export function createNotepadWriteGuardHook(): Hooks { const filePath = resolveFilePath(outputRecord?.args) if (!filePath) return - if (isNotebookPath(filePath)) { + if (isNotepadPath(filePath)) { throw new Error( `Refused: Write to ${filePath} is blocked because notepad files are append-only and Write would destroy history. Report the original Edit failure to the user and ask for guidance instead.`, ) diff --git a/src/hooks/start-work/session-plan-affinity.test.ts b/src/hooks/start-work/session-plan-affinity.test.ts new file mode 100644 index 000000000..6dbf1033d --- /dev/null +++ b/src/hooks/start-work/session-plan-affinity.test.ts @@ -0,0 +1,40 @@ +/// + +import { describe, expect, test } from "bun:test" +import { join } from "node:path" +import { tmpdir } from "node:os" +import { findRecentSessionPlanPath } from "./session-plan-affinity" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" + +type FindRecentSessionPlanPathInput = Parameters[0] + +describe("findRecentSessionPlanPath", () => { + test("#given session history references omo plan path #when finding recent plan #then returns matching plan", async () => { + const directory = join(tmpdir(), "session-plan-affinity-test") + const planPath = join(directory, ".omo", "plans", "foo-bar.md") + const client = unsafeTestValue({ + session: { + messages: async () => ({ + data: [ + { + parts: [ + { + text: "Plan saved to .omo/plans/foo-bar.md", + }, + ], + }, + ], + }), + }, + }) + + const result = await findRecentSessionPlanPath({ + client, + directory, + sessionID: "session-123", + availablePlans: [planPath], + }) + + expect(result).toBe(planPath) + }) +}) diff --git a/src/hooks/start-work/session-plan-affinity.ts b/src/hooks/start-work/session-plan-affinity.ts index 5de2e1162..d174b6eef 100644 --- a/src/hooks/start-work/session-plan-affinity.ts +++ b/src/hooks/start-work/session-plan-affinity.ts @@ -3,7 +3,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import { normalizeSDKResponse } from "../../shared" import { log } from "../../shared/logger" -const PLAN_PATH_PATTERN = /[A-Za-z0-9_./\\:-]*\.sisyphus[\\/]plans[\\/][A-Za-z0-9._/\\-]+\.md/gi +const PLAN_PATH_PATTERN = /[A-Za-z0-9_./\\:-]*\.(?:sisyphus|omo)[\\/]plans[\\/][A-Za-z0-9._/\\-]+\.md/gi interface SessionMessagePart { text?: string diff --git a/src/plugin/tool-execute-before-notepad-guard.test.ts b/src/plugin/tool-execute-before-notepad-guard.test.ts new file mode 100644 index 000000000..8e33b5b27 --- /dev/null +++ b/src/plugin/tool-execute-before-notepad-guard.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test" + +import { createNotepadWriteGuardHook } from "../hooks/notepad-write-guard" +import { unsafeTestValue } from "../../test-support/unsafe-test-value" +import { createToolExecuteBeforeHandler } from "./tool-execute-before" +import type { CreatedHooks } from "../create-hooks" +import type { PluginContext } from "./types" + +const REFUSED_PREFIX = "Refused: Write to" + +function createContext(): PluginContext { + return unsafeTestValue({ + client: { + session: { + messages: async () => ({ data: [] }), + }, + }, + }) +} + +function createHooks(): CreatedHooks { + return unsafeTestValue({ + notepadWriteGuard: createNotepadWriteGuardHook(), + }) +} + +async function runTool(args: { readonly tool: string; readonly filePath: string }): Promise { + const handler = createToolExecuteBeforeHandler({ + ctx: createContext(), + hooks: createHooks(), + }) + + await handler( + { tool: args.tool, sessionID: "ses_notepad", callID: "call_notepad" }, + { args: { file_path: args.filePath } }, + ) +} + +async function expectBlocked(args: { readonly tool: string; readonly filePath: string }): Promise { + let caughtMessage = "" + try { + await runTool(args) + } catch (error) { + if (error instanceof Error) { + caughtMessage = error.message + } else { + throw error + } + } + + expect(caughtMessage).toContain(REFUSED_PREFIX) +} + +describe("tool.execute.before notepad-write-guard dispatch", () => { + test("#given guard enabled #when Write targets current .omo notepad #then blocks", async () => { + await expectBlocked({ + tool: "Write", + filePath: ".omo/notepads/plan.md", + }) + }) + + test("#given guard enabled #when Write targets other .omo path #then allows", async () => { + await runTool({ + tool: "Write", + filePath: ".omo/somewhere-else.md", + }) + }) + + test("#given guard enabled #when Edit targets current .omo notepad #then allows", async () => { + await runTool({ + tool: "Edit", + filePath: ".omo/notepads/plan.md", + }) + }) +}) diff --git a/src/plugin/tool-execute-before.ts b/src/plugin/tool-execute-before.ts index a0d0a22d2..7662d1b89 100644 --- a/src/plugin/tool-execute-before.ts +++ b/src/plugin/tool-execute-before.ts @@ -76,6 +76,7 @@ export function createToolExecuteBeforeHandler(args: { } await hooks.writeExistingFileGuard?.["tool.execute.before"]?.(input, output) + await hooks.notepadWriteGuard?.["tool.execute.before"]?.(input, output) await hooks.questionLabelTruncator?.["tool.execute.before"]?.(input, output) await hooks.claudeCodeHooks?.["tool.execute.before"]?.(input, output) await hooks.nonInteractiveEnv?.["tool.execute.before"]?.(input, output)