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
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<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(
directory: string,
existingState: ReturnType<typeof readBoulderState>,
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 + `
<system-reminder>
@@ -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,
})
}
+106 -1
View File
@@ -25,7 +25,11 @@ describe("start-work hook", () => {
function createMockPluginInput() {
return {
directory: testDir,
client: {},
client: {
session: {
messages: async () => ({ data: [] }),
},
},
} as Parameters<typeof createStartWorkHook>[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<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 () => {
// given - hook and message with placeholder
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}`
}
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 {