diff --git a/src/hooks/start-work/index.test.ts b/src/hooks/start-work/index.test.ts index 31f73fdfb..64f09e151 100644 --- a/src/hooks/start-work/index.test.ts +++ b/src/hooks/start-work/index.test.ts @@ -236,5 +236,148 @@ describe("start-work hook", () => { expect(output.parts[0].text).toContain("Ask the user") expect(output.parts[0].text).not.toContain("Which plan would you like to work on?") }) + + test("should select explicitly specified plan name from user-request, ignoring existing boulder state", async () => { + // #given - existing boulder state pointing to old plan + const plansDir = join(TEST_DIR, ".sisyphus", "plans") + mkdirSync(plansDir, { recursive: true }) + + // Old plan (in boulder state) + const oldPlanPath = join(plansDir, "old-plan.md") + writeFileSync(oldPlanPath, "# Old Plan\n- [ ] Old Task 1") + + // New plan (user wants this one) + const newPlanPath = join(plansDir, "new-plan.md") + writeFileSync(newPlanPath, "# New Plan\n- [ ] New Task 1") + + // Set up stale boulder state pointing to old plan + const staleState: BoulderState = { + active_plan: oldPlanPath, + started_at: "2026-01-01T10:00:00Z", + session_ids: ["old-session"], + plan_name: "old-plan", + } + writeBoulderState(TEST_DIR, staleState) + + const hook = createStartWorkHook(createMockPluginInput()) + const output = { + parts: [ + { + type: "text", + text: `Start Sisyphus work session + +new-plan +`, + }, + ], + } + + // #when - user explicitly specifies new-plan + await hook["chat.message"]( + { sessionID: "session-123" }, + output + ) + + // #then - should select new-plan, NOT resume old-plan + expect(output.parts[0].text).toContain("new-plan") + expect(output.parts[0].text).not.toContain("RESUMING") + expect(output.parts[0].text).not.toContain("old-plan") + }) + + test("should strip ultrawork/ulw keywords from plan name argument", async () => { + // #given - plan with ultrawork keyword in user-request + const plansDir = join(TEST_DIR, ".sisyphus", "plans") + mkdirSync(plansDir, { recursive: true }) + + const planPath = join(plansDir, "my-feature-plan.md") + writeFileSync(planPath, "# My Feature Plan\n- [ ] Task 1") + + const hook = createStartWorkHook(createMockPluginInput()) + const output = { + parts: [ + { + type: "text", + text: `Start Sisyphus work session + +my-feature-plan ultrawork +`, + }, + ], + } + + // #when - user specifies plan with ultrawork keyword + await hook["chat.message"]( + { sessionID: "session-123" }, + output + ) + + // #then - should find plan without ultrawork suffix + expect(output.parts[0].text).toContain("my-feature-plan") + expect(output.parts[0].text).toContain("Auto-Selected Plan") + }) + + test("should strip ulw keyword from plan name argument", async () => { + // #given - plan with ulw keyword in user-request + const plansDir = join(TEST_DIR, ".sisyphus", "plans") + mkdirSync(plansDir, { recursive: true }) + + const planPath = join(plansDir, "api-refactor.md") + writeFileSync(planPath, "# API Refactor\n- [ ] Task 1") + + const hook = createStartWorkHook(createMockPluginInput()) + const output = { + parts: [ + { + type: "text", + text: `Start Sisyphus work session + +api-refactor ulw +`, + }, + ], + } + + // #when + await hook["chat.message"]( + { sessionID: "session-123" }, + output + ) + + // #then - should find plan without ulw suffix + expect(output.parts[0].text).toContain("api-refactor") + expect(output.parts[0].text).toContain("Auto-Selected Plan") + }) + + test("should match plan by partial name", async () => { + // #given - user specifies partial plan name + const plansDir = join(TEST_DIR, ".sisyphus", "plans") + mkdirSync(plansDir, { recursive: true }) + + const planPath = join(plansDir, "2026-01-15-feature-implementation.md") + writeFileSync(planPath, "# Feature Implementation\n- [ ] Task 1") + + const hook = createStartWorkHook(createMockPluginInput()) + const output = { + parts: [ + { + type: "text", + text: `Start Sisyphus work session + +feature-implementation +`, + }, + ], + } + + // #when + await hook["chat.message"]( + { sessionID: "session-123" }, + output + ) + + // #then - should find plan by partial match + expect(output.parts[0].text).toContain("2026-01-15-feature-implementation") + expect(output.parts[0].text).toContain("Auto-Selected Plan") + }) }) }) diff --git a/src/hooks/start-work/index.ts b/src/hooks/start-work/index.ts index d7a8c6922..0ba3768c8 100644 --- a/src/hooks/start-work/index.ts +++ b/src/hooks/start-work/index.ts @@ -7,11 +7,14 @@ import { getPlanProgress, createBoulderState, getPlanName, + clearBoulderState, } from "../../features/boulder-state" import { log } from "../../shared/logger" export const HOOK_NAME = "start-work" +const KEYWORD_PATTERN = /\b(ultrawork|ulw)\b/gi + interface StartWorkHookInput { sessionID: string messageID?: string @@ -21,6 +24,27 @@ interface StartWorkHookOutput { parts: Array<{ type: string; text?: string }> } +function extractUserRequestPlanName(promptText: string): string | null { + const userRequestMatch = promptText.match(/\s*([\s\S]*?)\s*<\/user-request>/i) + if (!userRequestMatch) return null + + const rawArg = userRequestMatch[1].trim() + if (!rawArg) return null + + const cleanedArg = rawArg.replace(KEYWORD_PATTERN, "").trim() + return cleanedArg || null +} + +function findPlanByName(plans: string[], requestedName: string): string | null { + const lowerName = requestedName.toLowerCase() + + const exactMatch = plans.find(p => getPlanName(p).toLowerCase() === lowerName) + if (exactMatch) return exactMatch + + const partialMatch = plans.find(p => getPlanName(p).toLowerCase().includes(lowerName)) + return partialMatch || null +} + export function createStartWorkHook(ctx: PluginInput) { return { "chat.message": async ( @@ -51,8 +75,70 @@ export function createStartWorkHook(ctx: PluginInput) { const timestamp = new Date().toISOString() let contextInfo = "" + + const explicitPlanName = extractUserRequestPlanName(promptText) + + if (explicitPlanName) { + log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, { + sessionID: input.sessionID, + }) + + const allPlans = findPrometheusPlans(ctx.directory) + const matchedPlan = findPlanByName(allPlans, explicitPlanName) + + if (matchedPlan) { + const progress = getPlanProgress(matchedPlan) + + if (progress.isComplete) { + contextInfo = ` +## Plan Already Complete - if (existingState) { +The requested plan "${getPlanName(matchedPlan)}" has been completed. +All ${progress.total} tasks are done. Create a new plan with: /plan "your task"` + } else { + if (existingState) { + clearBoulderState(ctx.directory) + } + const newState = createBoulderState(matchedPlan, sessionId) + writeBoulderState(ctx.directory, newState) + + contextInfo = ` +## Auto-Selected Plan + +**Plan**: ${getPlanName(matchedPlan)} +**Path**: ${matchedPlan} +**Progress**: ${progress.completed}/${progress.total} tasks +**Session ID**: ${sessionId} +**Started**: ${timestamp} + +boulder.json has been created. Read the plan and begin execution.` + } + } else { + const incompletePlans = allPlans.filter(p => !getPlanProgress(p).isComplete) + if (incompletePlans.length > 0) { + const planList = incompletePlans.map((p, i) => { + const prog = getPlanProgress(p) + return `${i + 1}. [${getPlanName(p)}] - Progress: ${prog.completed}/${prog.total}` + }).join("\n") + + contextInfo = ` +## Plan Not Found + +Could not find a plan matching "${explicitPlanName}". + +Available incomplete plans: +${planList} + +Ask the user which plan to work on.` + } else { + contextInfo = ` +## Plan Not Found + +Could not find a plan matching "${explicitPlanName}". +No incomplete plans available. Create a new plan with: /plan "your task"` + } + } + } else if (existingState) { const progress = getPlanProgress(existingState.active_plan) if (!progress.isComplete) { @@ -78,7 +164,7 @@ Looking for new plans...` } } - if (!existingState || getPlanProgress(existingState.active_plan).isComplete) { + if ((!existingState && !explicitPlanName) || (existingState && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete)) { const plans = findPrometheusPlans(ctx.directory) const incompletePlans = plans.filter(p => !getPlanProgress(p).isComplete)