fix: normalize plan name matching across separators and auto-select single incomplete plan
- Add normalizePlanName() to treat spaces, underscores, and hyphens as equivalent - Support reverse partial matching (longer request containing shorter plan name) - Auto-select when only one incomplete plan exists and explicit name doesn't match - Add 7 new test cases covering all scenarios Closes #2926
This commit is contained in:
@@ -412,6 +412,261 @@ You are starting a Sisyphus work session.
|
||||
expect(output.parts[0].text).toContain("2026-01-15-feature-implementation")
|
||||
expect(output.parts[0].text).toContain("Auto-Selected Plan")
|
||||
})
|
||||
|
||||
test("should match plan name when request uses underscores and plan uses hyphens", async () => {
|
||||
// given - user uses underscores for a hyphenated plan name
|
||||
const plansDir = join(testDir, ".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: createStartWorkPrompt({ userRequest: "my_feature_plan" }),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// when
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "session-123" },
|
||||
output
|
||||
)
|
||||
|
||||
// then - should normalize separators before matching
|
||||
expect(output.parts[0].text).toContain("my-feature-plan")
|
||||
expect(output.parts[0].text).toContain("Auto-Selected Plan")
|
||||
})
|
||||
|
||||
test("should match plan name when request uses spaces and plan uses hyphens", async () => {
|
||||
// given - user uses spaces for a hyphenated plan name
|
||||
const plansDir = join(testDir, ".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: createStartWorkPrompt({ userRequest: "my feature plan" }),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// when
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "session-123" },
|
||||
output
|
||||
)
|
||||
|
||||
// then - should normalize separators before matching
|
||||
expect(output.parts[0].text).toContain("my-feature-plan")
|
||||
expect(output.parts[0].text).toContain("Auto-Selected Plan")
|
||||
})
|
||||
|
||||
test("should match shorter plan name when requested name is longer", async () => {
|
||||
// given - requested name contains the plan name as a subset
|
||||
const plansDir = join(testDir, ".sisyphus", "plans")
|
||||
mkdirSync(plansDir, { recursive: true })
|
||||
|
||||
const planPath = join(plansDir, "feature-impl.md")
|
||||
writeFileSync(planPath, "# Feature Impl\n- [ ] Task 1")
|
||||
|
||||
const hook = createStartWorkHook(createMockPluginInput())
|
||||
const output = {
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: createStartWorkPrompt({ userRequest: "2026 01 15 feature impl extra" }),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// when
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "session-123" },
|
||||
output
|
||||
)
|
||||
|
||||
// then - should support reverse partial matching
|
||||
expect(output.parts[0].text).toContain("feature-impl")
|
||||
expect(output.parts[0].text).toContain("Auto-Selected Plan")
|
||||
})
|
||||
|
||||
test("should auto-select single incomplete plan when explicit plan name matches nothing", async () => {
|
||||
// given - one complete plan and one incomplete plan
|
||||
const plansDir = join(testDir, ".sisyphus", "plans")
|
||||
mkdirSync(plansDir, { recursive: true })
|
||||
|
||||
const completePlanPath = join(plansDir, "plan-done.md")
|
||||
writeFileSync(completePlanPath, "# Plan Done\n- [x] Task 1")
|
||||
|
||||
const incompletePlanPath = join(plansDir, "plan-todo.md")
|
||||
writeFileSync(incompletePlanPath, "# Plan Todo\n- [ ] Task 1")
|
||||
|
||||
const hook = createStartWorkHook(createMockPluginInput())
|
||||
const output = {
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: createStartWorkPrompt({ userRequest: "nonexistent-plan" }),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// when
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "session-123" },
|
||||
output
|
||||
)
|
||||
|
||||
// then - should auto-select instead of asking the user
|
||||
expect(output.parts[0].text).toContain("plan-todo")
|
||||
expect(output.parts[0].text).toContain("Auto-Selected Plan")
|
||||
expect(output.parts[0].text).not.toContain("Ask the user")
|
||||
})
|
||||
|
||||
test("should ask user when explicit plan name matches nothing and multiple incomplete plans exist", async () => {
|
||||
// given - multiple incomplete plans remain ambiguous
|
||||
const plansDir = join(testDir, ".sisyphus", "plans")
|
||||
mkdirSync(plansDir, { recursive: true })
|
||||
|
||||
const planAlphaPath = join(plansDir, "plan-alpha.md")
|
||||
writeFileSync(planAlphaPath, "# Plan Alpha\n- [ ] Task 1")
|
||||
|
||||
const planBetaPath = join(plansDir, "plan-beta.md")
|
||||
writeFileSync(planBetaPath, "# Plan Beta\n- [ ] Task 1")
|
||||
|
||||
const hook = createStartWorkHook(createMockPluginInput())
|
||||
const output = {
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: createStartWorkPrompt({ userRequest: "nonexistent-plan" }),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// when
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "session-123" },
|
||||
output
|
||||
)
|
||||
|
||||
// then - should still ask the user to choose
|
||||
expect(output.parts[0].text).toContain("Plan Not Found")
|
||||
expect(output.parts[0].text).toContain("Ask the user")
|
||||
expect(output.parts[0].text).not.toContain("Auto-Selected Plan")
|
||||
})
|
||||
|
||||
test("should prefer the longest reverse partial match over newer shorter matches", async () => {
|
||||
// given - reverse partial candidates with different specificity
|
||||
const plansDir = join(testDir, ".sisyphus", "plans")
|
||||
mkdirSync(plansDir, { recursive: true })
|
||||
|
||||
const specificPlanPath = join(plansDir, "feature-impl.md")
|
||||
writeFileSync(specificPlanPath, "# Feature Impl\n- [ ] Task 1")
|
||||
|
||||
const broadPlanPath = join(plansDir, "feature.md")
|
||||
writeFileSync(broadPlanPath, "# Feature\n- [ ] Task 1")
|
||||
|
||||
const hook = createStartWorkHook(createMockPluginInput())
|
||||
const output = {
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: createStartWorkPrompt({ userRequest: "2026 01 15 feature impl extra" }),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// when
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "session-123" },
|
||||
output
|
||||
)
|
||||
|
||||
// then - should pick the most specific reverse partial match
|
||||
expect(output.parts[0].text).toContain("feature-impl")
|
||||
expect(output.parts[0].text).toContain("Auto-Selected Plan")
|
||||
expect(output.parts[0].text).not.toContain("**Plan**: feature\n")
|
||||
})
|
||||
|
||||
test("should prefer exact matches over broader partial and reverse partial candidates", async () => {
|
||||
// given - exact, partial, and reverse partial candidates all exist
|
||||
const plansDir = join(testDir, ".sisyphus", "plans")
|
||||
mkdirSync(plansDir, { recursive: true })
|
||||
|
||||
const broadPlanPath = join(plansDir, "feature.md")
|
||||
writeFileSync(broadPlanPath, "# Feature\n- [ ] Task 1")
|
||||
|
||||
const partialPlanPath = join(plansDir, "feature-impl.md")
|
||||
writeFileSync(partialPlanPath, "# Feature Impl\n- [ ] Task 1")
|
||||
|
||||
const exactPlanPath = join(plansDir, "feature-impl-extra.md")
|
||||
writeFileSync(exactPlanPath, "# Feature Impl Extra\n- [ ] Task 1")
|
||||
|
||||
const hook = createStartWorkHook(createMockPluginInput())
|
||||
const output = {
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: createStartWorkPrompt({ userRequest: "feature impl extra" }),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// when
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "session-123" },
|
||||
output
|
||||
)
|
||||
|
||||
// then - should resolve to the exact normalized match first
|
||||
expect(output.parts[0].text).toContain("feature-impl-extra")
|
||||
expect(output.parts[0].text).toContain("Auto-Selected Plan")
|
||||
expect(output.parts[0].text).not.toContain("**Plan**: feature-impl\n")
|
||||
})
|
||||
|
||||
test("should avoid selecting a complete shorter reverse match over an incomplete specific plan", async () => {
|
||||
// given - a broader reverse match is complete but the specific match is incomplete
|
||||
const plansDir = join(testDir, ".sisyphus", "plans")
|
||||
mkdirSync(plansDir, { recursive: true })
|
||||
|
||||
const specificPlanPath = join(plansDir, "feature-impl.md")
|
||||
writeFileSync(specificPlanPath, "# Feature Impl\n- [ ] Task 1")
|
||||
|
||||
const broadPlanPath = join(plansDir, "feature.md")
|
||||
writeFileSync(broadPlanPath, "# Feature\n- [x] Task 1")
|
||||
|
||||
const hook = createStartWorkHook(createMockPluginInput())
|
||||
const output = {
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: createStartWorkPrompt({ userRequest: "2026 01 15 feature impl extra" }),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// when
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "session-123" },
|
||||
output
|
||||
)
|
||||
|
||||
// then - should still select the incomplete specific plan
|
||||
expect(output.parts[0].text).toContain("feature-impl")
|
||||
expect(output.parts[0].text).toContain("Auto-Selected Plan")
|
||||
expect(output.parts[0].text).not.toContain("Plan Already Complete")
|
||||
})
|
||||
})
|
||||
|
||||
describe("session agent management", () => {
|
||||
|
||||
@@ -43,12 +43,33 @@ interface StartWorkHookOutput {
|
||||
parts: Array<{ type: string; text?: string }>
|
||||
}
|
||||
|
||||
function normalizePlanName(name: string): string {
|
||||
return name.toLowerCase().replace(/[\s_-]+/g, " ").trim()
|
||||
}
|
||||
|
||||
function findPlanByName(plans: string[], requestedName: string): string | null {
|
||||
const lowerName = requestedName.toLowerCase()
|
||||
const exactMatch = plans.find((p) => getPlanName(p).toLowerCase() === lowerName)
|
||||
const normalizedRequestedName = normalizePlanName(requestedName)
|
||||
const exactMatch = plans.find((p) => normalizePlanName(getPlanName(p)) === normalizedRequestedName)
|
||||
if (exactMatch) return exactMatch
|
||||
const partialMatch = plans.find((p) => getPlanName(p).toLowerCase().includes(lowerName))
|
||||
return partialMatch || null
|
||||
|
||||
const partialMatch = plans.find((p) =>
|
||||
normalizePlanName(getPlanName(p)).includes(normalizedRequestedName)
|
||||
)
|
||||
if (partialMatch) return partialMatch
|
||||
|
||||
const reversePartialMatches = plans.filter((p) =>
|
||||
normalizedRequestedName.includes(normalizePlanName(getPlanName(p)))
|
||||
)
|
||||
if (reversePartialMatches.length === 0) return null
|
||||
|
||||
const longestMatchLength = Math.max(
|
||||
...reversePartialMatches.map((p) => normalizePlanName(getPlanName(p)).length),
|
||||
)
|
||||
const bestReversePartialMatches = reversePartialMatches.filter(
|
||||
(p) => normalizePlanName(getPlanName(p)).length === longestMatchLength,
|
||||
)
|
||||
|
||||
return bestReversePartialMatches.length === 1 ? bestReversePartialMatches[0] : null
|
||||
}
|
||||
|
||||
function createWorktreeActiveBlock(worktreePath: string): string {
|
||||
@@ -165,7 +186,26 @@ boulder.json has been created. Read the plan and begin execution.`
|
||||
}
|
||||
} else {
|
||||
const incompletePlans = allPlans.filter((p) => !getPlanProgress(p).isComplete)
|
||||
if (incompletePlans.length > 0) {
|
||||
if (incompletePlans.length === 1) {
|
||||
const planPath = incompletePlans[0]
|
||||
const progress = getPlanProgress(planPath)
|
||||
|
||||
if (existingState) clearBoulderState(ctx.directory)
|
||||
const newState = createBoulderState(planPath, sessionId, activeAgent, worktreePath)
|
||||
writeBoulderState(ctx.directory, newState)
|
||||
|
||||
contextInfo = `
|
||||
## Auto-Selected Plan
|
||||
|
||||
**Plan**: ${getPlanName(planPath)}
|
||||
**Path**: ${planPath}
|
||||
**Progress**: ${progress.completed}/${progress.total} tasks
|
||||
**Session ID**: ${sessionId}
|
||||
**Started**: ${timestamp}
|
||||
${worktreeBlock}
|
||||
|
||||
boulder.json has been created. Read the plan and begin execution.`
|
||||
} else if (incompletePlans.length > 1) {
|
||||
const planList = incompletePlans
|
||||
.map((p, i) => {
|
||||
const prog = getPlanProgress(p)
|
||||
|
||||
Reference in New Issue
Block a user