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:
YeonGyu-Kim
2026-04-02 19:18:48 +09:00
parent 34a37dc946
commit 60cec65a21
2 changed files with 300 additions and 5 deletions
+255
View File
@@ -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", () => {
+45 -5
View File
@@ -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)