fix(start-work): honor explicit plan name and strip ultrawork keywords
When user types '/start-work my-plan ultrawork', the hook now: 1. Extracts plan name from <user-request> section 2. Strips ultrawork/ulw keywords from the plan name 3. Searches for matching plan (exact then partial match) 4. Uses the matched plan instead of resuming stale boulder state This fixes the bug where '/start-work [PLAN] ultrawork' would: - Include 'ultrawork' as part of the plan name argument - Ignore the explicit plan and resume an old stale plan from boulder.json Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -236,5 +236,148 @@ describe("start-work hook", () => {
|
||||
expect(output.parts[0].text).toContain("Ask the user")
|
||||
expect(output.parts[0].text).not.toContain("Which plan would you like to work on?")
|
||||
})
|
||||
|
||||
test("should select explicitly specified plan name from user-request, ignoring existing boulder state", async () => {
|
||||
// #given - existing boulder state pointing to old plan
|
||||
const plansDir = join(TEST_DIR, ".sisyphus", "plans")
|
||||
mkdirSync(plansDir, { recursive: true })
|
||||
|
||||
// Old plan (in boulder state)
|
||||
const oldPlanPath = join(plansDir, "old-plan.md")
|
||||
writeFileSync(oldPlanPath, "# Old Plan\n- [ ] Old Task 1")
|
||||
|
||||
// New plan (user wants this one)
|
||||
const newPlanPath = join(plansDir, "new-plan.md")
|
||||
writeFileSync(newPlanPath, "# New Plan\n- [ ] New Task 1")
|
||||
|
||||
// Set up stale boulder state pointing to old plan
|
||||
const staleState: BoulderState = {
|
||||
active_plan: oldPlanPath,
|
||||
started_at: "2026-01-01T10:00:00Z",
|
||||
session_ids: ["old-session"],
|
||||
plan_name: "old-plan",
|
||||
}
|
||||
writeBoulderState(TEST_DIR, staleState)
|
||||
|
||||
const hook = createStartWorkHook(createMockPluginInput())
|
||||
const output = {
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Start Sisyphus work session
|
||||
<user-request>
|
||||
new-plan
|
||||
</user-request>`,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// #when - user explicitly specifies new-plan
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "session-123" },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - should select new-plan, NOT resume old-plan
|
||||
expect(output.parts[0].text).toContain("new-plan")
|
||||
expect(output.parts[0].text).not.toContain("RESUMING")
|
||||
expect(output.parts[0].text).not.toContain("old-plan")
|
||||
})
|
||||
|
||||
test("should strip ultrawork/ulw keywords from plan name argument", async () => {
|
||||
// #given - plan with ultrawork keyword in user-request
|
||||
const plansDir = join(TEST_DIR, ".sisyphus", "plans")
|
||||
mkdirSync(plansDir, { recursive: true })
|
||||
|
||||
const planPath = join(plansDir, "my-feature-plan.md")
|
||||
writeFileSync(planPath, "# My Feature Plan\n- [ ] Task 1")
|
||||
|
||||
const hook = createStartWorkHook(createMockPluginInput())
|
||||
const output = {
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Start Sisyphus work session
|
||||
<user-request>
|
||||
my-feature-plan ultrawork
|
||||
</user-request>`,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// #when - user specifies plan with ultrawork keyword
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "session-123" },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - should find plan without ultrawork suffix
|
||||
expect(output.parts[0].text).toContain("my-feature-plan")
|
||||
expect(output.parts[0].text).toContain("Auto-Selected Plan")
|
||||
})
|
||||
|
||||
test("should strip ulw keyword from plan name argument", async () => {
|
||||
// #given - plan with ulw keyword in user-request
|
||||
const plansDir = join(TEST_DIR, ".sisyphus", "plans")
|
||||
mkdirSync(plansDir, { recursive: true })
|
||||
|
||||
const planPath = join(plansDir, "api-refactor.md")
|
||||
writeFileSync(planPath, "# API Refactor\n- [ ] Task 1")
|
||||
|
||||
const hook = createStartWorkHook(createMockPluginInput())
|
||||
const output = {
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Start Sisyphus work session
|
||||
<user-request>
|
||||
api-refactor ulw
|
||||
</user-request>`,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// #when
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "session-123" },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - should find plan without ulw suffix
|
||||
expect(output.parts[0].text).toContain("api-refactor")
|
||||
expect(output.parts[0].text).toContain("Auto-Selected Plan")
|
||||
})
|
||||
|
||||
test("should match plan by partial name", async () => {
|
||||
// #given - user specifies partial plan name
|
||||
const plansDir = join(TEST_DIR, ".sisyphus", "plans")
|
||||
mkdirSync(plansDir, { recursive: true })
|
||||
|
||||
const planPath = join(plansDir, "2026-01-15-feature-implementation.md")
|
||||
writeFileSync(planPath, "# Feature Implementation\n- [ ] Task 1")
|
||||
|
||||
const hook = createStartWorkHook(createMockPluginInput())
|
||||
const output = {
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Start Sisyphus work session
|
||||
<user-request>
|
||||
feature-implementation
|
||||
</user-request>`,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// #when
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "session-123" },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - should find plan by partial match
|
||||
expect(output.parts[0].text).toContain("2026-01-15-feature-implementation")
|
||||
expect(output.parts[0].text).toContain("Auto-Selected Plan")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,11 +7,14 @@ import {
|
||||
getPlanProgress,
|
||||
createBoulderState,
|
||||
getPlanName,
|
||||
clearBoulderState,
|
||||
} from "../../features/boulder-state"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export const HOOK_NAME = "start-work"
|
||||
|
||||
const KEYWORD_PATTERN = /\b(ultrawork|ulw)\b/gi
|
||||
|
||||
interface StartWorkHookInput {
|
||||
sessionID: string
|
||||
messageID?: string
|
||||
@@ -21,6 +24,27 @@ interface StartWorkHookOutput {
|
||||
parts: Array<{ type: string; text?: string }>
|
||||
}
|
||||
|
||||
function extractUserRequestPlanName(promptText: string): string | null {
|
||||
const userRequestMatch = promptText.match(/<user-request>\s*([\s\S]*?)\s*<\/user-request>/i)
|
||||
if (!userRequestMatch) return null
|
||||
|
||||
const rawArg = userRequestMatch[1].trim()
|
||||
if (!rawArg) return null
|
||||
|
||||
const cleanedArg = rawArg.replace(KEYWORD_PATTERN, "").trim()
|
||||
return cleanedArg || null
|
||||
}
|
||||
|
||||
function findPlanByName(plans: string[], requestedName: string): string | null {
|
||||
const lowerName = requestedName.toLowerCase()
|
||||
|
||||
const exactMatch = plans.find(p => getPlanName(p).toLowerCase() === lowerName)
|
||||
if (exactMatch) return exactMatch
|
||||
|
||||
const partialMatch = plans.find(p => getPlanName(p).toLowerCase().includes(lowerName))
|
||||
return partialMatch || null
|
||||
}
|
||||
|
||||
export function createStartWorkHook(ctx: PluginInput) {
|
||||
return {
|
||||
"chat.message": async (
|
||||
@@ -51,8 +75,70 @@ export function createStartWorkHook(ctx: PluginInput) {
|
||||
const timestamp = new Date().toISOString()
|
||||
|
||||
let contextInfo = ""
|
||||
|
||||
const explicitPlanName = extractUserRequestPlanName(promptText)
|
||||
|
||||
if (explicitPlanName) {
|
||||
log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, {
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
|
||||
const allPlans = findPrometheusPlans(ctx.directory)
|
||||
const matchedPlan = findPlanByName(allPlans, explicitPlanName)
|
||||
|
||||
if (matchedPlan) {
|
||||
const progress = getPlanProgress(matchedPlan)
|
||||
|
||||
if (progress.isComplete) {
|
||||
contextInfo = `
|
||||
## Plan Already Complete
|
||||
|
||||
if (existingState) {
|
||||
The requested plan "${getPlanName(matchedPlan)}" has been completed.
|
||||
All ${progress.total} tasks are done. Create a new plan with: /plan "your task"`
|
||||
} else {
|
||||
if (existingState) {
|
||||
clearBoulderState(ctx.directory)
|
||||
}
|
||||
const newState = createBoulderState(matchedPlan, sessionId)
|
||||
writeBoulderState(ctx.directory, newState)
|
||||
|
||||
contextInfo = `
|
||||
## Auto-Selected Plan
|
||||
|
||||
**Plan**: ${getPlanName(matchedPlan)}
|
||||
**Path**: ${matchedPlan}
|
||||
**Progress**: ${progress.completed}/${progress.total} tasks
|
||||
**Session ID**: ${sessionId}
|
||||
**Started**: ${timestamp}
|
||||
|
||||
boulder.json has been created. Read the plan and begin execution.`
|
||||
}
|
||||
} else {
|
||||
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")
|
||||
|
||||
contextInfo = `
|
||||
## Plan Not Found
|
||||
|
||||
Could not find a plan matching "${explicitPlanName}".
|
||||
|
||||
Available incomplete plans:
|
||||
${planList}
|
||||
|
||||
Ask the user which plan to work on.`
|
||||
} else {
|
||||
contextInfo = `
|
||||
## Plan Not Found
|
||||
|
||||
Could not find a plan matching "${explicitPlanName}".
|
||||
No incomplete plans available. Create a new plan with: /plan "your task"`
|
||||
}
|
||||
}
|
||||
} else if (existingState) {
|
||||
const progress = getPlanProgress(existingState.active_plan)
|
||||
|
||||
if (!progress.isComplete) {
|
||||
@@ -78,7 +164,7 @@ Looking for new plans...`
|
||||
}
|
||||
}
|
||||
|
||||
if (!existingState || getPlanProgress(existingState.active_plan).isComplete) {
|
||||
if ((!existingState && !explicitPlanName) || (existingState && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete)) {
|
||||
const plans = findPrometheusPlans(ctx.directory)
|
||||
const incompletePlans = plans.filter(p => !getPlanProgress(p).isComplete)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user