From 063ba4668121854e8ab6afa50b08783ec4f6c2a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A4cker=2C=20Henning?= Date: Thu, 23 Apr 2026 22:36:40 +0200 Subject: [PATCH] fix(start-work): prefer current session plan --- src/hooks/start-work/context-info-builder.ts | 146 ++++++++++++++----- src/hooks/start-work/index.test.ts | 107 +++++++++++++- src/hooks/start-work/start-work-hook.ts | 12 +- 3 files changed, 218 insertions(+), 47 deletions(-) diff --git a/src/hooks/start-work/context-info-builder.ts b/src/hooks/start-work/context-info-builder.ts index 9fc8e0fd4..394b75d35 100644 --- a/src/hooks/start-work/context-info-builder.ts +++ b/src/hooks/start-work/context-info-builder.ts @@ -53,9 +53,11 @@ function buildAutoSelectedPlanContextInfoOnly(params: { sessionId: string timestamp: string worktreeBlock: string + reason?: string }): string { - const { planPath, sessionId, timestamp, worktreeBlock } = params + const { planPath, sessionId, timestamp, worktreeBlock, reason } = params const progress = getPlanProgress(planPath) + const reasonLine = reason ? `**Reason**: ${reason}\n` : "" return ` ## Auto-Selected Plan @@ -65,7 +67,7 @@ function buildAutoSelectedPlanContextInfoOnly(params: { **Progress**: ${progress.completed}/${progress.total} tasks **Session ID**: ${sessionId} **Started**: ${timestamp} -${worktreeBlock} +${reasonLine}${worktreeBlock} boulder.json has been created. Read the plan and begin execution.` } @@ -78,8 +80,9 @@ function buildAutoSelectedPlanContextWithStateInit(params: { worktreePath: string | undefined worktreeBlock: string directory: string + reason?: string }): string { - const { planPath, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock, directory } = params + const { planPath, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock, directory, reason } = params const newState = createBoulderState(planPath, sessionId, activeAgent, worktreePath) writeBoulderState(directory, newState) @@ -88,26 +91,44 @@ function buildAutoSelectedPlanContextWithStateInit(params: { sessionId, timestamp, worktreeBlock, + reason, }) } +function pickPreferredIncompletePlan( + incompletePlans: string[], + preferredPlanPath: string | null, +): string | null { + if (!preferredPlanPath) { + return null + } + + return incompletePlans.find((planPath) => planPath === preferredPlanPath) ?? null +} + +function formatIncompletePlanList(plans: string[], includeModifiedTime: boolean): string { + return plans + .map((planPath, index) => { + const progress = getPlanProgress(planPath) + const modified = includeModifiedTime + ? ` - Modified: ${new Date(statSync(planPath).mtimeMs).toISOString()}` + : "" + + return `${index + 1}. [${getPlanName(planPath)}]${modified} - Progress: ${progress.completed}/${progress.total}` + }) + .join("\n") +} + function buildMissingPlanContext(explicitPlanName: string, allPlans: string[]): string { 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") - return ` ## Plan Not Found Could not find a plan matching "${explicitPlanName}". Available incomplete plans: -${planList} +${formatIncompletePlanList(incompletePlans, false)} Ask the user which plan to work on.` } @@ -298,17 +319,33 @@ The current session (${sessionId}) has been added to session_ids. Read the plan file and continue from the first unchecked task.` } +function shouldResumeExistingState(input: { + existingState: ReturnType + preferredPlanPath: string | null +}): boolean { + const { existingState, preferredPlanPath } = input + if (!existingState) { + return false + } + + if (getPlanProgress(existingState.active_plan).isComplete) { + return false + } + + if (preferredPlanPath && existingState.active_plan !== preferredPlanPath) { + return false + } + + return true +} + function shouldDiscoverPlans( directory: string, existingState: ReturnType, explicitPlanName: string | null, + preferredPlanPath: string | null, ): boolean { - return (!existingState && !explicitPlanName) - || ( - existingState !== null - && !explicitPlanName - && getPlanProgress(resolveBoulderPlanPath(directory, existingState)).isComplete - ) + return !explicitPlanName && !shouldResumeExistingState({ existingState, preferredPlanPath }) } function buildPlanDiscoveryContext(params: { @@ -319,10 +356,12 @@ function buildPlanDiscoveryContext(params: { worktreePath: string | undefined worktreeBlock: string directory: string + preferredPlanPath: string | null }): string { - const { contextInfo, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock, directory } = params + const { contextInfo, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock, directory, preferredPlanPath } = params const plans = findPrometheusPlans(directory) const incompletePlans = plans.filter((p) => !getPlanProgress(p).isComplete) + const preferredIncompletePlan = pickPreferredIncompletePlan(incompletePlans, preferredPlanPath) if (plans.length === 0) { return contextInfo + ` @@ -340,6 +379,19 @@ function buildPlanDiscoveryContext(params: { All ${plans.length} plan(s) are complete. Create a new plan using the Prometheus agent.` } + if (preferredIncompletePlan) { + return contextInfo + buildAutoSelectedPlanContextWithStateInit({ + planPath: preferredIncompletePlan, + sessionId, + timestamp, + activeAgent, + worktreePath, + worktreeBlock, + directory, + reason: "Most recently referenced plan in this session", + }) + } + if (incompletePlans.length === 1) { return contextInfo + buildAutoSelectedPlanContextWithStateInit({ planPath: incompletePlans[0], @@ -352,14 +404,6 @@ function buildPlanDiscoveryContext(params: { }) } - const planList = incompletePlans - .map((p, i) => { - const progress = getPlanProgress(p) - const modified = new Date(statSync(p).mtimeMs).toISOString() - return `${i + 1}. [${getPlanName(p)}] - Modified: ${modified} - Progress: ${progress.completed}/${progress.total}` - }) - .join("\n") - return contextInfo + ` @@ -368,7 +412,7 @@ function buildPlanDiscoveryContext(params: { Current Time: ${timestamp} Session ID: ${sessionId} -${planList} +${formatIncompletePlanList(incompletePlans, true)} Ask the user which plan to work on. Present the options above and wait for their response. ${worktreeBlock} @@ -384,8 +428,19 @@ export function buildStartWorkContextInfo(params: { activeAgent: string worktreePath: string | undefined worktreeBlock: string + preferredPlanPath?: string | null }): string { - const { ctx, explicitPlanName, existingState, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock } = params + const { + ctx, + explicitPlanName, + existingState, + sessionId, + timestamp, + activeAgent, + worktreePath, + worktreeBlock, + preferredPlanPath = null, + } = params const resumeOptions = getWorkResumeOptions(ctx.directory) .filter((option) => option.status === "active" || option.status === "paused") @@ -400,16 +455,19 @@ export function buildStartWorkContextInfo(params: { if (!explicitPlanName && resumeOptions.length === 1) { const onlyOption = resumeOptions[0] - const selectedState = selectActiveWork(ctx.directory, onlyOption.work_id) - if (selectedState) { - return buildExistingSessionContext({ - existingState: selectedState, - sessionId, - activeAgent, - worktreePath, - worktreeBlock, - directory: ctx.directory, - }) + const matchesPreferred = !preferredPlanPath || onlyOption.active_plan === preferredPlanPath + if (matchesPreferred) { + const selectedState = selectActiveWork(ctx.directory, onlyOption.work_id) + if (selectedState) { + return buildExistingSessionContext({ + existingState: selectedState, + sessionId, + activeAgent, + worktreePath, + worktreeBlock, + directory: ctx.directory, + }) + } } } @@ -422,6 +480,7 @@ export function buildStartWorkContextInfo(params: { worktreePath, worktreeBlock, directory: ctx.directory, + preferredPlanPath, }) } @@ -436,7 +495,7 @@ export function buildStartWorkContextInfo(params: { worktreeBlock, directory: ctx.directory, }) - } else if (existingState) { + } else if (shouldResumeExistingState({ existingState, preferredPlanPath })) { contextInfo = buildExistingSessionContext({ existingState, sessionId, @@ -445,9 +504,15 @@ export function buildStartWorkContextInfo(params: { worktreeBlock, directory: ctx.directory, }) + } else if (existingState && !getPlanProgress(existingState.active_plan).isComplete) { + log(`[${HOOK_NAME}] Ignoring unrelated active boulder state for this session`, { + sessionID: sessionId, + activePlan: existingState.active_plan, + preferredPlanPath, + }) } - if (shouldDiscoverPlans(ctx.directory, existingState, explicitPlanName)) { + if (shouldDiscoverPlans(ctx.directory, existingState, explicitPlanName, preferredPlanPath)) { return buildPlanDiscoveryContext({ contextInfo, sessionId, @@ -456,6 +521,7 @@ export function buildStartWorkContextInfo(params: { worktreePath, worktreeBlock, directory: ctx.directory, + preferredPlanPath, }) } diff --git a/src/hooks/start-work/index.test.ts b/src/hooks/start-work/index.test.ts index e4a981218..ff715e868 100644 --- a/src/hooks/start-work/index.test.ts +++ b/src/hooks/start-work/index.test.ts @@ -25,7 +25,11 @@ describe("start-work hook", () => { function createMockPluginInput() { return { directory: testDir, - client: {}, + client: { + session: { + messages: async () => ({ data: [] }), + }, + }, } as Parameters[0] } @@ -176,6 +180,107 @@ You are starting a Sisyphus work session. expect(output.parts[0].text).toContain("test-plan") }) + test("should prefer the plan most recently referenced in the current session", async () => { + // given - two incomplete plans and current session recently referenced plan-b + const plansDir = join(testDir, ".sisyphus", "plans") + mkdirSync(plansDir, { recursive: true }) + + const planAPath = join(plansDir, "plan-a.md") + const planBPath = join(plansDir, "plan-b.md") + writeFileSync(planAPath, "# Plan A\n- [ ] Task 1") + writeFileSync(planBPath, "# Plan B\n- [ ] Task 2") + + const hook = createStartWorkHook({ + directory: testDir, + client: { + session: { + messages: async () => ({ + data: [ + { + parts: [ + { + text: `Plan saved to: ${planBPath}`, + }, + ], + }, + ], + }), + }, + }, + } as Parameters[0]) + const output = { + parts: [{ type: "text", text: createStartWorkPrompt() }], + } + + // when + await hook["chat.message"]( + { sessionID: "session-123" }, + output, + ) + + // then - should auto-select plan-b instead of prompting for multiple plans + expect(output.parts[0].text).toContain("Auto-Selected Plan") + expect(output.parts[0].text).toContain("plan-b") + expect(output.parts[0].text).toContain("Most recently referenced plan in this session") + expect(output.parts[0].text).not.toContain("Multiple Plans Found") + + const state = readBoulderState(testDir) + expect(state?.active_plan).toBe(planBPath) + }) + + test("should ignore unrelated active boulder state when current session references another plan", async () => { + // given - active boulder points to old plan, current session most recently referenced new plan + const plansDir = join(testDir, ".sisyphus", "plans") + mkdirSync(plansDir, { recursive: true }) + + const oldPlanPath = join(plansDir, "old-plan.md") + const newPlanPath = join(plansDir, "new-plan.md") + writeFileSync(oldPlanPath, "# Old Plan\n- [ ] Legacy task") + writeFileSync(newPlanPath, "# New Plan\n- [ ] Fresh task") + + writeBoulderState(testDir, { + active_plan: oldPlanPath, + started_at: "2026-01-01T00:00:00Z", + session_ids: ["different-session"], + plan_name: "old-plan", + }) + + const hook = createStartWorkHook({ + directory: testDir, + client: { + session: { + messages: async () => ({ + data: [ + { + parts: [ + { + output: `Plan saved to: ${newPlanPath}`, + }, + ], + }, + ], + }), + }, + }, + } as Parameters[0]) + const output = { + parts: [{ type: "text", text: createStartWorkPrompt() }], + } + + // when + await hook["chat.message"]( + { sessionID: "session-123" }, + output, + ) + + // then - should select the session-preferred new plan, not resume the unrelated one + expect(output.parts[0].text).toContain("new-plan") + expect(output.parts[0].text).not.toContain("RESUMING existing work") + + const state = readBoulderState(testDir) + expect(state?.active_plan).toBe(newPlanPath) + }) + test("should replace $SESSION_ID placeholder", async () => { // given - hook and message with placeholder const hook = createStartWorkHook(createMockPluginInput()) diff --git a/src/hooks/start-work/start-work-hook.ts b/src/hooks/start-work/start-work-hook.ts index 8a1d7c2b5..af5c1f41f 100644 --- a/src/hooks/start-work/start-work-hook.ts +++ b/src/hooks/start-work/start-work-hook.ts @@ -124,12 +124,12 @@ export function createStartWorkHook(ctx: PluginInput) { output.parts[idx].text += `\n\n---\n${contextInfo}` } - log(`[${HOOK_NAME}] Context injected`, { - sessionID: input.sessionID, - hasExistingState: !!existingState, - preferredPlanPath, - worktreePath, - }) + log(`[${HOOK_NAME}] Context injected`, { + sessionID: input.sessionID, + hasExistingState: !!existingState, + preferredPlanPath, + worktreePath, + }) } return {