fix(notepad-guard,start-work): wire dispatch and match .omo paths
notepad-write-guard: - The hook was created by create-tool-guard-hooks but tool-execute-before never invoked it, so the guard was inert. - It also only matched .sisyphus/notepads, missing the current .omo/notepads layout introduced by the workspace migration. - Add the dispatch call alongside writeExistingFileGuard, and extend NOTEPAD_ROOTS to cover both paths via normalize() + sep. New integration test pins the wire and the .omo block; the existing unit test now asserts both paths. start-work session-plan-affinity: - PLAN_PATH_PATTERN only matched .sisyphus/plans, so sessions referring to plans under .omo/plans returned null and start-work missed the current session's own plan. - Extend the regex to .(sisyphus|omo)/plans and add findPrometheusPlans in packages/boulder-state to scan both directories during the transition. New regression test pins .omo/plans matching; legacy .sisyphus/plans coverage preserved.
This commit is contained in:
@@ -11,19 +11,23 @@ const UNCHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[\s*\]\s*(.+)$/
|
|||||||
const CHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[[xX]\]\s*(.+)$/
|
const CHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[[xX]\]\s*(.+)$/
|
||||||
const TODO_TASK_PATTERN = /^\d+\.\s+/
|
const TODO_TASK_PATTERN = /^\d+\.\s+/
|
||||||
const FINAL_WAVE_TASK_PATTERN = /^F\d+\.\s+/i
|
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"
|
type ProgressSection = "todo" | "final-wave" | "other"
|
||||||
|
|
||||||
export function findPrometheusPlans(directory: string): string[] {
|
export function findPrometheusPlans(directory: string): string[] {
|
||||||
const plansDir = join(directory, PROMETHEUS_PLANS_DIR)
|
|
||||||
if (!existsSync(plansDir)) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return readdirSync(plansDir)
|
return PROMETHEUS_PLAN_DIRS.flatMap((planDir) => {
|
||||||
.filter((file) => file.endsWith(".md"))
|
const plansDir = join(directory, planDir)
|
||||||
.map((file) => join(plansDir, file))
|
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)
|
.sort((left, right) => statSync(right).mtimeMs - statSync(left).mtimeMs)
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -15,65 +15,64 @@ async function invoke(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expectWriteBlocked(hook: Hook, filePath: string): Promise<void> {
|
||||||
|
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", () => {
|
describe("createNotepadWriteGuardHook", () => {
|
||||||
test("#given notepad decisions.md #when write executes #then rejects with actionable error", async () => {
|
test("#given notepad decisions.md #when write executes #then rejects with actionable error", async () => {
|
||||||
const hook = createNotepadWriteGuardHook()
|
const hook = createNotepadWriteGuardHook()
|
||||||
await expect(
|
await expectWriteBlocked(hook, ".sisyphus/notepads/foo/decisions.md")
|
||||||
invoke(hook, {
|
|
||||||
tool: "write",
|
|
||||||
filePath: ".sisyphus/notepads/foo/decisions.md",
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(REFUSED_PREFIX)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("#given notepad state.json #when write executes #then rejects (entire notepad subtree blocked)", async () => {
|
test("#given notepad state.json #when write executes #then rejects (entire notepad subtree blocked)", async () => {
|
||||||
const hook = createNotepadWriteGuardHook()
|
const hook = createNotepadWriteGuardHook()
|
||||||
await expect(
|
await expectWriteBlocked(hook, ".sisyphus/notepads/foo/state.json")
|
||||||
invoke(hook, {
|
})
|
||||||
tool: "write",
|
|
||||||
filePath: ".sisyphus/notepads/foo/state.json",
|
test("#given current omo notepad file #when write executes #then rejects", async () => {
|
||||||
}),
|
const hook = createNotepadWriteGuardHook()
|
||||||
).rejects.toThrow(REFUSED_PREFIX)
|
await expectWriteBlocked(hook, ".omo/notepads/foo/decisions.md")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("#given regular src file #when write executes #then allows (not intercepted)", async () => {
|
test("#given regular src file #when write executes #then allows (not intercepted)", async () => {
|
||||||
const hook = createNotepadWriteGuardHook()
|
const hook = createNotepadWriteGuardHook()
|
||||||
await expect(
|
await invoke(hook, {
|
||||||
invoke(hook, {
|
tool: "write",
|
||||||
tool: "write",
|
filePath: "src/index.ts",
|
||||||
filePath: "src/index.ts",
|
})
|
||||||
}),
|
|
||||||
).resolves.toBeUndefined()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("#given non-write tool on notepad path #when executes #then allows", async () => {
|
test("#given non-write tool on notepad path #when executes #then allows", async () => {
|
||||||
const hook = createNotepadWriteGuardHook()
|
const hook = createNotepadWriteGuardHook()
|
||||||
await expect(
|
await invoke(hook, {
|
||||||
invoke(hook, {
|
tool: "read",
|
||||||
tool: "read",
|
filePath: ".sisyphus/notepads/foo/decisions.md",
|
||||||
filePath: ".sisyphus/notepads/foo/decisions.md",
|
})
|
||||||
}),
|
|
||||||
).resolves.toBeUndefined()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("#given sisyphus plans file (not notepads) #when write executes #then allows", async () => {
|
test("#given sisyphus plans file (not notepads) #when write executes #then allows", async () => {
|
||||||
const hook = createNotepadWriteGuardHook()
|
const hook = createNotepadWriteGuardHook()
|
||||||
await expect(
|
await invoke(hook, {
|
||||||
invoke(hook, {
|
tool: "write",
|
||||||
tool: "write",
|
filePath: ".sisyphus/plans/my-plan.md",
|
||||||
filePath: ".sisyphus/plans/my-plan.md",
|
})
|
||||||
}),
|
|
||||||
).resolves.toBeUndefined()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("#given absolute notepad path #when write executes #then rejects", async () => {
|
test("#given absolute notepad path #when write executes #then rejects", async () => {
|
||||||
const hook = createNotepadWriteGuardHook()
|
const hook = createNotepadWriteGuardHook()
|
||||||
await expect(
|
await expectWriteBlocked(hook, "/home/user/project/.sisyphus/notepads/plan/decisions.md")
|
||||||
invoke(hook, {
|
|
||||||
tool: "write",
|
|
||||||
filePath: "/home/user/project/.sisyphus/notepads/plan/decisions.md",
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(REFUSED_PREFIX)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("#given error message #when rejected #then message names the file and gives guidance", async () => {
|
test("#given error message #when rejected #then message names the file and gives guidance", async () => {
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import type { Hooks } from "@opencode-ai/plugin"
|
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 {
|
function hasNotepadRoot(normalizedPath: string, notepadRoot: string): boolean {
|
||||||
const normalised = normalize(filePath)
|
return normalizedPath === notepadRoot
|
||||||
return normalised.includes(`/.sisyphus/notepads/`) || normalised.startsWith(NOTEPAD_SEGMENT)
|
|| 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 {
|
function resolveFilePath(args: unknown): string | undefined {
|
||||||
@@ -27,7 +36,7 @@ export function createNotepadWriteGuardHook(): Hooks {
|
|||||||
const filePath = resolveFilePath(outputRecord?.args)
|
const filePath = resolveFilePath(outputRecord?.args)
|
||||||
if (!filePath) return
|
if (!filePath) return
|
||||||
|
|
||||||
if (isNotebookPath(filePath)) {
|
if (isNotepadPath(filePath)) {
|
||||||
throw new Error(
|
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.`,
|
`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.`,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
|
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<typeof findRecentSessionPlanPath>[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<FindRecentSessionPlanPathInput["client"]>({
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,7 +3,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
|||||||
import { normalizeSDKResponse } from "../../shared"
|
import { normalizeSDKResponse } from "../../shared"
|
||||||
import { log } from "../../shared/logger"
|
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 {
|
interface SessionMessagePart {
|
||||||
text?: string
|
text?: string
|
||||||
|
|||||||
@@ -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<PluginContext>({
|
||||||
|
client: {
|
||||||
|
session: {
|
||||||
|
messages: async () => ({ data: [] }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHooks(): CreatedHooks {
|
||||||
|
return unsafeTestValue<CreatedHooks>({
|
||||||
|
notepadWriteGuard: createNotepadWriteGuardHook(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTool(args: { readonly tool: string; readonly filePath: string }): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -76,6 +76,7 @@ export function createToolExecuteBeforeHandler(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await hooks.writeExistingFileGuard?.["tool.execute.before"]?.(input, output)
|
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.questionLabelTruncator?.["tool.execute.before"]?.(input, output)
|
||||||
await hooks.claudeCodeHooks?.["tool.execute.before"]?.(input, output)
|
await hooks.claudeCodeHooks?.["tool.execute.before"]?.(input, output)
|
||||||
await hooks.nonInteractiveEnv?.["tool.execute.before"]?.(input, output)
|
await hooks.nonInteractiveEnv?.["tool.execute.before"]?.(input, output)
|
||||||
|
|||||||
Reference in New Issue
Block a user