fix(start-work): prefer current session plan

This commit is contained in:
Häcker, Henning
2026-04-23 22:36:40 +02:00
parent b430524a94
commit 063ba46681
3 changed files with 218 additions and 47 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 (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,
}) })
} }
+106 -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,107 @@ 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 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())
+6 -6
View File
@@ -124,12 +124,12 @@ export function createStartWorkHook(ctx: PluginInput) {
output.parts[idx].text += `\n\n---\n${contextInfo}` output.parts[idx].text += `\n\n---\n${contextInfo}`
} }
log(`[${HOOK_NAME}] Context injected`, { log(`[${HOOK_NAME}] Context injected`, {
sessionID: input.sessionID, sessionID: input.sessionID,
hasExistingState: !!existingState, hasExistingState: !!existingState,
preferredPlanPath, preferredPlanPath,
worktreePath, worktreePath,
}) })
} }
return { return {