Merge pull request #3600 from hackerh3/hackerh3/start-work-session-affinity

fix(start-work): prefer current session plan
This commit is contained in:
YeonGyu-Kim
2026-05-21 00:55:57 +09:00
committed by GitHub
4 changed files with 380 additions and 41 deletions
+106 -40
View File
@@ -53,9 +53,11 @@ function buildAutoSelectedPlanContextInfoOnly(params: {
sessionId: string sessionId: string
timestamp: string timestamp: string
worktreeBlock: string worktreeBlock: string
reason?: string
}): string { }): string {
const { planPath, sessionId, timestamp, worktreeBlock } = params const { planPath, sessionId, timestamp, worktreeBlock, reason } = params
const progress = getPlanProgress(planPath) const progress = getPlanProgress(planPath)
const reasonLine = reason ? `**Reason**: ${reason}\n` : ""
return ` return `
## Auto-Selected Plan ## Auto-Selected Plan
@@ -65,7 +67,7 @@ function buildAutoSelectedPlanContextInfoOnly(params: {
**Progress**: ${progress.completed}/${progress.total} tasks **Progress**: ${progress.completed}/${progress.total} tasks
**Session ID**: ${sessionId} **Session ID**: ${sessionId}
**Started**: ${timestamp} **Started**: ${timestamp}
${worktreeBlock} ${reasonLine}${worktreeBlock}
boulder.json has been created. Read the plan and begin execution.` boulder.json has been created. Read the plan and begin execution.`
} }
@@ -78,8 +80,9 @@ function buildAutoSelectedPlanContextWithStateInit(params: {
worktreePath: string | undefined worktreePath: string | undefined
worktreeBlock: string worktreeBlock: string
directory: string directory: string
reason?: string
}): 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) const newState = createBoulderState(planPath, sessionId, activeAgent, worktreePath)
writeBoulderState(directory, newState) writeBoulderState(directory, newState)
@@ -88,26 +91,44 @@ function buildAutoSelectedPlanContextWithStateInit(params: {
sessionId, sessionId,
timestamp, timestamp,
worktreeBlock, 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 { function buildMissingPlanContext(explicitPlanName: string, allPlans: string[]): string {
const incompletePlans = allPlans.filter((p) => !getPlanProgress(p).isComplete) const incompletePlans = allPlans.filter((p) => !getPlanProgress(p).isComplete)
if (incompletePlans.length > 0) { 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 ` return `
## Plan Not Found ## Plan Not Found
Could not find a plan matching "${explicitPlanName}". Could not find a plan matching "${explicitPlanName}".
Available incomplete plans: Available incomplete plans:
${planList} ${formatIncompletePlanList(incompletePlans, false)}
Ask the user which plan to work on.` 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.` Read the plan file and continue from the first unchecked task.`
} }
function shouldResumeExistingState(input: {
existingState: ReturnType<typeof readBoulderState>
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( function shouldDiscoverPlans(
directory: string, directory: string,
existingState: ReturnType<typeof readBoulderState>, existingState: ReturnType<typeof readBoulderState>,
explicitPlanName: string | null, explicitPlanName: string | null,
preferredPlanPath: string | null,
): boolean { ): boolean {
return (!existingState && !explicitPlanName) return !explicitPlanName && !shouldResumeExistingState({ existingState, preferredPlanPath })
|| (
existingState !== null
&& !explicitPlanName
&& getPlanProgress(resolveBoulderPlanPath(directory, existingState)).isComplete
)
} }
function buildPlanDiscoveryContext(params: { function buildPlanDiscoveryContext(params: {
@@ -319,10 +356,12 @@ function buildPlanDiscoveryContext(params: {
worktreePath: string | undefined worktreePath: string | undefined
worktreeBlock: string worktreeBlock: string
directory: string directory: string
preferredPlanPath: string | null
}): string { }): 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 plans = findPrometheusPlans(directory)
const incompletePlans = plans.filter((p) => !getPlanProgress(p).isComplete) const incompletePlans = plans.filter((p) => !getPlanProgress(p).isComplete)
const preferredIncompletePlan = pickPreferredIncompletePlan(incompletePlans, preferredPlanPath)
if (plans.length === 0) { if (plans.length === 0) {
return contextInfo + ` return contextInfo + `
@@ -340,6 +379,19 @@ function buildPlanDiscoveryContext(params: {
All ${plans.length} plan(s) are complete. Create a new plan using the Prometheus agent.` 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) { if (incompletePlans.length === 1) {
return contextInfo + buildAutoSelectedPlanContextWithStateInit({ return contextInfo + buildAutoSelectedPlanContextWithStateInit({
planPath: incompletePlans[0], 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 + ` return contextInfo + `
<system-reminder> <system-reminder>
@@ -368,7 +412,7 @@ function buildPlanDiscoveryContext(params: {
Current Time: ${timestamp} Current Time: ${timestamp}
Session ID: ${sessionId} Session ID: ${sessionId}
${planList} ${formatIncompletePlanList(incompletePlans, true)}
Ask the user which plan to work on. Present the options above and wait for their response. Ask the user which plan to work on. Present the options above and wait for their response.
${worktreeBlock} ${worktreeBlock}
@@ -384,8 +428,19 @@ export function buildStartWorkContextInfo(params: {
activeAgent: string activeAgent: string
worktreePath: string | undefined worktreePath: string | undefined
worktreeBlock: string worktreeBlock: string
preferredPlanPath?: string | null
}): string { }): 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) const resumeOptions = getWorkResumeOptions(ctx.directory)
.filter((option) => option.status === "active" || option.status === "paused") .filter((option) => option.status === "active" || option.status === "paused")
@@ -400,16 +455,19 @@ export function buildStartWorkContextInfo(params: {
if (!explicitPlanName && resumeOptions.length === 1) { if (!explicitPlanName && resumeOptions.length === 1) {
const onlyOption = resumeOptions[0] const onlyOption = resumeOptions[0]
const selectedState = selectActiveWork(ctx.directory, onlyOption.work_id) const matchesPreferred = !preferredPlanPath || onlyOption.active_plan === preferredPlanPath
if (selectedState) { if (matchesPreferred) {
return buildExistingSessionContext({ const selectedState = selectActiveWork(ctx.directory, onlyOption.work_id)
existingState: selectedState, if (selectedState) {
sessionId, return buildExistingSessionContext({
activeAgent, existingState: selectedState,
worktreePath, sessionId,
worktreeBlock, activeAgent,
directory: ctx.directory, worktreePath,
}) worktreeBlock,
directory: ctx.directory,
})
}
} }
} }
@@ -422,6 +480,7 @@ export function buildStartWorkContextInfo(params: {
worktreePath, worktreePath,
worktreeBlock, worktreeBlock,
directory: ctx.directory, directory: ctx.directory,
preferredPlanPath,
}) })
} }
@@ -436,7 +495,7 @@ export function buildStartWorkContextInfo(params: {
worktreeBlock, worktreeBlock,
directory: ctx.directory, directory: ctx.directory,
}) })
} else if (existingState) { } else if (existingState && shouldResumeExistingState({ existingState, preferredPlanPath })) {
contextInfo = buildExistingSessionContext({ contextInfo = buildExistingSessionContext({
existingState, existingState,
sessionId, sessionId,
@@ -445,9 +504,15 @@ export function buildStartWorkContextInfo(params: {
worktreeBlock, worktreeBlock,
directory: ctx.directory, 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({ return buildPlanDiscoveryContext({
contextInfo, contextInfo,
sessionId, sessionId,
@@ -456,6 +521,7 @@ export function buildStartWorkContextInfo(params: {
worktreePath, worktreePath,
worktreeBlock, worktreeBlock,
directory: ctx.directory, directory: ctx.directory,
preferredPlanPath,
}) })
} }
+155 -1
View File
@@ -25,7 +25,11 @@ describe("start-work hook", () => {
function createMockPluginInput() { function createMockPluginInput() {
return { return {
directory: testDir, directory: testDir,
client: {}, client: {
session: {
messages: async () => ({ data: [] }),
},
},
} as Parameters<typeof createStartWorkHook>[0] } as Parameters<typeof createStartWorkHook>[0]
} }
@@ -176,6 +180,156 @@ You are starting a Sisyphus work session.
expect(output.parts[0].text).toContain("test-plan") 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<typeof createStartWorkHook>[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<typeof createStartWorkHook>[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 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<typeof createStartWorkHook>[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 () => { test("should replace $SESSION_ID placeholder", async () => {
// given - hook and message with placeholder // given - hook and message with placeholder
const hook = createStartWorkHook(createMockPluginInput()) const hook = createStartWorkHook(createMockPluginInput())
@@ -0,0 +1,108 @@
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<string, unknown>
}
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 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<string, unknown> | 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))
return [...new Set([...nestedCandidates, ...directCandidates])]
}
export async function findRecentSessionPlanPath(input: {
client: PluginInput["client"]
directory: string
sessionID: string
availablePlans: string[]
}): Promise<string | null> {
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
}
+11
View File
@@ -20,6 +20,7 @@ import { detectWorktreePath } from "./worktree-detector"
import { parseUserRequest } from "./parse-user-request" import { parseUserRequest } from "./parse-user-request"
import { buildStartWorkContextInfo } from "./context-info-builder" import { buildStartWorkContextInfo } from "./context-info-builder"
import { createWorktreeActiveBlock } from "./worktree-block" import { createWorktreeActiveBlock } from "./worktree-block"
import { findRecentSessionPlanPath } from "./session-plan-affinity"
export const HOOK_NAME = "start-work" as const export const HOOK_NAME = "start-work" as const
const START_WORK_TEMPLATE_MARKER = "You are starting a Sisyphus work session." 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 { planName: explicitPlanName, explicitWorktreePath } = parseUserRequest(promptText)
const { worktreePath, block: worktreeBlock } = resolveWorktreeContext(explicitWorktreePath) 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({ const contextInfo = buildStartWorkContextInfo({
ctx, ctx,
@@ -103,6 +112,7 @@ export function createStartWorkHook(ctx: PluginInput) {
activeAgent, activeAgent,
worktreePath, worktreePath,
worktreeBlock, worktreeBlock,
preferredPlanPath,
}) })
const idx = output.parts.findIndex((p) => p.type === "text" && p.text) const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
@@ -117,6 +127,7 @@ export function createStartWorkHook(ctx: PluginInput) {
log(`[${HOOK_NAME}] Context injected`, { log(`[${HOOK_NAME}] Context injected`, {
sessionID: input.sessionID, sessionID: input.sessionID,
hasExistingState: !!existingState, hasExistingState: !!existingState,
preferredPlanPath,
worktreePath, worktreePath,
}) })
} }