From b430524a94da14a39a8389d258f6909880e7313f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A4cker=2C=20Henning?= Date: Thu, 23 Apr 2026 22:36:13 +0200 Subject: [PATCH 1/4] feat(start-work): add session plan affinity lookup --- src/hooks/start-work/session-plan-affinity.ts | 92 +++++++++++++++++++ src/hooks/start-work/start-work-hook.ts | 21 ++++- 2 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 src/hooks/start-work/session-plan-affinity.ts diff --git a/src/hooks/start-work/session-plan-affinity.ts b/src/hooks/start-work/session-plan-affinity.ts new file mode 100644 index 000000000..9ba14c291 --- /dev/null +++ b/src/hooks/start-work/session-plan-affinity.ts @@ -0,0 +1,92 @@ +import { isAbsolute, resolve } from "node:path" +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 + +interface SessionMessagePart { + text?: string + output?: string + input?: Record +} + +interface SessionMessage { + parts?: SessionMessagePart[] +} + +function normalizePlanPath(directory: string, candidate: string): string { + const trimmedCandidate = candidate.trim().replace(/^["'`]+|["'`]+$/g, "") + if (isAbsolute(trimmedCandidate) || /^[A-Za-z]:[\\/]/.test(trimmedCandidate)) { + return resolve(trimmedCandidate) + } + + return resolve(directory, trimmedCandidate) +} + +function extractPlanPathsFromText(directory: string, text: string): string[] { + const matches = text.match(PLAN_PATH_PATTERN) ?? [] + return matches.map((match) => normalizePlanPath(directory, match)) +} + +function extractPlanPathsFromInput(directory: string, input: Record | undefined): string[] { + if (!input) { + return [] + } + + const directCandidates = [input.filePath, input.path, input.file] + .filter((value): value is string => typeof value === "string") + .flatMap((value) => extractPlanPathsFromText(directory, value)) + + if (directCandidates.length > 0) { + return directCandidates + } + + return extractPlanPathsFromText(directory, JSON.stringify(input)) +} + +export async function findRecentSessionPlanPath(input: { + client: PluginInput["client"] + directory: string + sessionID: string + availablePlans: string[] +}): Promise { + if (typeof input.client.session?.messages !== "function") { + return null + } + + const availablePlans = new Set(input.availablePlans.map((planPath) => resolve(planPath))) + if (availablePlans.size === 0) { + return null + } + + try { + const response = await input.client.session.messages({ path: { id: input.sessionID } }) + const messages = normalizeSDKResponse(response, [] as SessionMessage[]) + + for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) { + const parts = messages[messageIndex]?.parts ?? [] + + for (let partIndex = parts.length - 1; partIndex >= 0; partIndex -= 1) { + const part = parts[partIndex] + const planCandidates = [ + ...extractPlanPathsFromText(input.directory, part.text ?? ""), + ...extractPlanPathsFromText(input.directory, part.output ?? ""), + ...extractPlanPathsFromInput(input.directory, part.input), + ] + + const matchedPlan = planCandidates.find((planPath) => availablePlans.has(resolve(planPath))) + if (matchedPlan) { + return resolve(matchedPlan) + } + } + } + } catch (error) { + log("[start-work] Failed to inspect session history for preferred plan", { + sessionID: input.sessionID, + error: String(error), + }) + } + + return null +} diff --git a/src/hooks/start-work/start-work-hook.ts b/src/hooks/start-work/start-work-hook.ts index ec8a5011b..8a1d7c2b5 100644 --- a/src/hooks/start-work/start-work-hook.ts +++ b/src/hooks/start-work/start-work-hook.ts @@ -20,6 +20,7 @@ import { detectWorktreePath } from "./worktree-detector" import { parseUserRequest } from "./parse-user-request" import { buildStartWorkContextInfo } from "./context-info-builder" import { createWorktreeActiveBlock } from "./worktree-block" +import { findRecentSessionPlanPath } from "./session-plan-affinity" export const HOOK_NAME = "start-work" as const const START_WORK_TEMPLATE_MARKER = "You are starting a Sisyphus work session." @@ -93,6 +94,14 @@ export function createStartWorkHook(ctx: PluginInput) { const { planName: explicitPlanName, explicitWorktreePath } = parseUserRequest(promptText) const { worktreePath, block: worktreeBlock } = resolveWorktreeContext(explicitWorktreePath) + const preferredPlanPath = explicitPlanName + ? null + : await findRecentSessionPlanPath({ + client: ctx.client, + directory: ctx.directory, + sessionID: sessionId, + availablePlans: findPrometheusPlans(ctx.directory), + }) const contextInfo = buildStartWorkContextInfo({ ctx, @@ -103,6 +112,7 @@ export function createStartWorkHook(ctx: PluginInput) { activeAgent, worktreePath, worktreeBlock, + preferredPlanPath, }) const idx = output.parts.findIndex((p) => p.type === "text" && p.text) @@ -114,11 +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, - worktreePath, - }) + log(`[${HOOK_NAME}] Context injected`, { + sessionID: input.sessionID, + hasExistingState: !!existingState, + preferredPlanPath, + worktreePath, + }) } return { 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 2/4] 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 { From d22bd71d0dd3a5f5d5306e161a14c222f364b6c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A4cker=2C=20Henning?= Date: Fri, 24 Apr 2026 15:58:05 +0200 Subject: [PATCH 3/4] fix(start-work): narrow existing state before resume --- src/hooks/start-work/context-info-builder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/start-work/context-info-builder.ts b/src/hooks/start-work/context-info-builder.ts index 394b75d35..c91ad6ec2 100644 --- a/src/hooks/start-work/context-info-builder.ts +++ b/src/hooks/start-work/context-info-builder.ts @@ -495,7 +495,7 @@ export function buildStartWorkContextInfo(params: { worktreeBlock, directory: ctx.directory, }) - } else if (shouldResumeExistingState({ existingState, preferredPlanPath })) { + } else if (existingState && shouldResumeExistingState({ existingState, preferredPlanPath })) { contextInfo = buildExistingSessionContext({ existingState, sessionId, From 771242ea3d3cb67400366a89546d8ef3db7360dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A4cker=2C=20Henning?= Date: Fri, 24 Apr 2026 16:16:22 +0200 Subject: [PATCH 4/4] fix(start-work): prefer nested session plan refs --- src/hooks/start-work/index.test.ts | 49 +++++++++++++++++++ src/hooks/start-work/session-plan-affinity.ts | 26 ++++++++-- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/hooks/start-work/index.test.ts b/src/hooks/start-work/index.test.ts index ff715e868..70b25596e 100644 --- a/src/hooks/start-work/index.test.ts +++ b/src/hooks/start-work/index.test.ts @@ -281,6 +281,55 @@ You are starting a Sisyphus work session. expect(state?.active_plan).toBe(newPlanPath) }) + test("should still find nested plan references when direct input fields contain a different plan path", async () => { + // given - direct path points to plan-a but nested serialized input also references newer 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 A") + writeFileSync(planBPath, "# Plan B\n- [ ] Task B") + + const hook = createStartWorkHook({ + directory: testDir, + client: { + session: { + messages: async () => ({ + data: [ + { + parts: [ + { + input: { + path: `Legacy reference ${planAPath}`, + metadata: { + selectedPlan: `Current reference ${planBPath}`, + }, + }, + }, + ], + }, + ], + }), + }, + }, + } as Parameters[0]) + const output = { + parts: [{ type: "text", text: createStartWorkPrompt() }], + } + + // when + await hook["chat.message"]( + { sessionID: "session-123" }, + output, + ) + + // then - latest nested reference should still be discoverable and selected + const state = readBoulderState(testDir) + expect(state?.active_plan).toBe(planBPath) + expect(output.parts[0].text).toContain("plan-b") + }) + test("should replace $SESSION_ID placeholder", async () => { // given - hook and message with placeholder const hook = createStartWorkHook(createMockPluginInput()) diff --git a/src/hooks/start-work/session-plan-affinity.ts b/src/hooks/start-work/session-plan-affinity.ts index 9ba14c291..5de2e1162 100644 --- a/src/hooks/start-work/session-plan-affinity.ts +++ b/src/hooks/start-work/session-plan-affinity.ts @@ -29,20 +29,36 @@ function extractPlanPathsFromText(directory: string, text: string): string[] { return matches.map((match) => normalizePlanPath(directory, match)) } +function extractPlanPathsFromValue(directory: string, value: unknown): string[] { + if (typeof value === "string") { + return extractPlanPathsFromText(directory, value) + } + + if (Array.isArray(value)) { + return value.flatMap((item) => extractPlanPathsFromValue(directory, item)) + } + + if (value && typeof value === "object") { + return Object.values(value).flatMap((item) => extractPlanPathsFromValue(directory, item)) + } + + return [] +} + function extractPlanPathsFromInput(directory: string, input: Record | undefined): string[] { if (!input) { return [] } + const nestedCandidates = Object.entries(input) + .filter(([key]) => key !== "filePath" && key !== "path" && key !== "file") + .flatMap(([, value]) => extractPlanPathsFromValue(directory, value)) + const directCandidates = [input.filePath, input.path, input.file] .filter((value): value is string => typeof value === "string") .flatMap((value) => extractPlanPathsFromText(directory, value)) - if (directCandidates.length > 0) { - return directCandidates - } - - return extractPlanPathsFromText(directory, JSON.stringify(input)) + return [...new Set([...nestedCandidates, ...directCandidates])] } export async function findRecentSessionPlanPath(input: {