Merge pull request #3600 from hackerh3/hackerh3/start-work-session-affinity
fix(start-work): prefer current session plan
This commit is contained in:
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user