diff --git a/src/hooks/start-work/context-info-builder.ts b/src/hooks/start-work/context-info-builder.ts index 17642ca73..2fe074429 100644 --- a/src/hooks/start-work/context-info-builder.ts +++ b/src/hooks/start-work/context-info-builder.ts @@ -17,12 +17,33 @@ import { createWorktreeActiveBlock } from "./worktree-block" import type { PluginInput } from "@opencode-ai/plugin" import { HOOK_NAME } from "./start-work-hook" +function normalizePlanLookupValue(value: string): string { + return value + .trim() + .replace(/^["'`]+|["'`]+$/g, "") + .toLowerCase() + .replace(/[\s_]+/g, "-") + .replace(/[^a-z0-9-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, "") +} + function findPlanByName(plans: string[], requestedName: string): string | null { const lowerName = requestedName.toLowerCase() + const normalizedRequestedName = normalizePlanLookupValue(requestedName) const exactMatch = plans.find((p) => getPlanName(p).toLowerCase() === lowerName) if (exactMatch) return exactMatch + const normalizedExactMatch = plans.find((planPath) => + normalizePlanLookupValue(getPlanName(planPath)) === normalizedRequestedName, + ) + if (normalizedExactMatch) return normalizedExactMatch const partialMatch = plans.find((p) => getPlanName(p).toLowerCase().includes(lowerName)) - return partialMatch || null + if (partialMatch) return partialMatch + + const normalizedPartialMatch = plans.find((planPath) => + normalizePlanLookupValue(getPlanName(planPath)).includes(normalizedRequestedName), + ) + return normalizedPartialMatch || null } function buildAutoSelectedPlanContext(params: { diff --git a/src/hooks/start-work/index.test.ts b/src/hooks/start-work/index.test.ts index 9957b9608..1c1c20ae6 100644 --- a/src/hooks/start-work/index.test.ts +++ b/src/hooks/start-work/index.test.ts @@ -415,6 +415,35 @@ 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 quoted human-readable plan names to slugged filenames", async () => { + // given - saved plan uses a slugged filename + 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 + expect(output.parts[0].text).toContain("my-feature-plan") + expect(output.parts[0].text).toContain("Auto-Selected Plan") + }) }) describe("session agent management", () => { @@ -453,7 +482,7 @@ You are starting a Sisyphus work session. ) // then - expect(output.message.agent).toBe("atlas") + expect(output.message.agent).toBe(getAgentListDisplayName("atlas")) }) test("should switch to Atlas even when current session is Sisyphus (regression: #3155)", async () => { @@ -473,7 +502,7 @@ You are starting a Sisyphus work session. ) // atlas is registered in beforeEach, so it must be selected - expect(output.message.agent).toBe("atlas") + expect(output.message.agent).toBe(getAgentListDisplayName("atlas")) expect(sessionState.getSessionAgent("ses-sisyphus-to-atlas")).toBe("atlas") }) @@ -496,7 +525,7 @@ You are starting a Sisyphus work session. ) // then - expect(output.message.agent).toBe("sisyphus") + expect(output.message.agent).toBe("Sisyphus - Ultraworker") expect(sessionState.getSessionAgent("ses-prometheus-to-sisyphus")).toBe("sisyphus") }) @@ -524,7 +553,7 @@ You are starting a Sisyphus work session. ) // then - expect(output.message.agent).toBe("sisyphus") + expect(output.message.agent).toBe("Sisyphus - Ultraworker") expect(sessionState.getSessionAgent("ses-prometheus-to-worker")).toBe("sisyphus") expect(readBoulderState(testDir)?.agent).toBe("sisyphus") }) @@ -559,7 +588,7 @@ You are starting a Sisyphus work session. ) // then - expect(output.message.agent).toBe("sisyphus") + expect(output.message.agent).toBe("Sisyphus - Ultraworker") expect(readBoulderState(testDir)?.agent).toBe("sisyphus") }) @@ -594,7 +623,7 @@ You are starting a Sisyphus work session. await atlasHook.handler({ event: { type: "session.idle", properties: { sessionID: "session-123" } } }) // then - expect(output.message.agent).toBe("atlas") + expect(output.message.agent).toBe(getAgentListDisplayName("atlas")) expect(readBoulderState(testDir)?.session_ids).toContain("session-123") expect(readBoulderState(testDir)?.agent).toBe("atlas") expect(promptAsyncMock).toHaveBeenCalledTimes(1) @@ -684,7 +713,7 @@ You are starting a Sisyphus work session. await firePendingTimers() // then - expect(output.message.agent).toBe("atlas") + expect(output.message.agent).toBe(getAgentListDisplayName("atlas")) expect(readBoulderState(testDir)?.session_ids).toContain("session-123") expect(readBoulderState(testDir)?.agent).toBe("atlas") expect(promptAsyncMock).toHaveBeenCalledTimes(1) diff --git a/src/hooks/start-work/parse-user-request.test.ts b/src/hooks/start-work/parse-user-request.test.ts index e5d61a4c5..b675faa76 100644 --- a/src/hooks/start-work/parse-user-request.test.ts +++ b/src/hooks/start-work/parse-user-request.test.ts @@ -50,6 +50,14 @@ describe("parseUserRequest", () => { }) }) + describe("when plan name is wrapped in quotes", () => { + test("#given quoted plan name #when parsing #then strips wrapping quotes", () => { + const result = parseUserRequest("\"my feature plan\"") + expect(result.planName).toBe("my feature plan") + expect(result.explicitWorktreePath).toBeNull() + }) + }) + describe("when --worktree flag has no path", () => { test("#given --worktree without path #when parsing #then worktree path is null", () => { const result = parseUserRequest("--worktree") diff --git a/src/hooks/start-work/parse-user-request.ts b/src/hooks/start-work/parse-user-request.ts index 627deb67a..0dc56b78c 100644 --- a/src/hooks/start-work/parse-user-request.ts +++ b/src/hooks/start-work/parse-user-request.ts @@ -1,5 +1,6 @@ const KEYWORD_PATTERN = /\b(ultrawork|ulw)\b/gi const WORKTREE_FLAG_PATTERN = /--worktree(?:\s+(\S+))?/ +const WRAPPING_QUOTES_PATTERN = /^(["'`])([\s\S]*)\1$/ export interface ParsedUserRequest { planName: string | null @@ -21,9 +22,11 @@ export function parseUserRequest(promptText: string): ParsedUserRequest { } const cleanedArg = rawArg.replace(KEYWORD_PATTERN, "").trim() + const quotedPlanMatch = cleanedArg.match(WRAPPING_QUOTES_PATTERN) + const normalizedPlanName = quotedPlanMatch ? quotedPlanMatch[2].trim() : cleanedArg return { - planName: cleanedArg || null, + planName: normalizedPlanName || null, explicitWorktreePath, } }