From 60cec65a2116ea6d6c31e2f75066fa0da84a742c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 2 Apr 2026 19:18:48 +0900 Subject: [PATCH] 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 --- src/hooks/start-work/index.test.ts | 255 ++++++++++++++++++++++++ src/hooks/start-work/start-work-hook.ts | 50 ++++- 2 files changed, 300 insertions(+), 5 deletions(-) diff --git a/src/hooks/start-work/index.test.ts b/src/hooks/start-work/index.test.ts index 193d83bb8..ce55e6fd4 100644 --- a/src/hooks/start-work/index.test.ts +++ b/src/hooks/start-work/index.test.ts @@ -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", () => { diff --git a/src/hooks/start-work/start-work-hook.ts b/src/hooks/start-work/start-work-hook.ts index d0445d7f1..49ea69eab 100644 --- a/src/hooks/start-work/start-work-hook.ts +++ b/src/hooks/start-work/start-work-hook.ts @@ -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)