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 {