feat(start-work): add plan name normalization and quote stripping
- Add WRAPPING_QUOTES_PATTERN to parse-user-request.ts to strip quotes from plan names
- Add normalizePlanLookupValue() to context-info-builder.ts for slug normalization
- Enhanced findPlanByName() with normalized exact and partial matching
- Allows human-readable plan names (e.g., "my feature plan") to match slugged filenames (e.g., my-feature-plan.md)
🤖 Generated with OhMyOpenCode assistance
This commit is contained in:
@@ -17,12 +17,33 @@ import { createWorktreeActiveBlock } from "./worktree-block"
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import { HOOK_NAME } from "./start-work-hook"
|
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 {
|
function findPlanByName(plans: string[], requestedName: string): string | null {
|
||||||
const lowerName = requestedName.toLowerCase()
|
const lowerName = requestedName.toLowerCase()
|
||||||
|
const normalizedRequestedName = normalizePlanLookupValue(requestedName)
|
||||||
const exactMatch = plans.find((p) => getPlanName(p).toLowerCase() === lowerName)
|
const exactMatch = plans.find((p) => getPlanName(p).toLowerCase() === lowerName)
|
||||||
if (exactMatch) return exactMatch
|
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))
|
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: {
|
function buildAutoSelectedPlanContext(params: {
|
||||||
|
|||||||
@@ -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("2026-01-15-feature-implementation")
|
||||||
expect(output.parts[0].text).toContain("Auto-Selected Plan")
|
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", () => {
|
describe("session agent management", () => {
|
||||||
@@ -453,7 +482,7 @@ You are starting a Sisyphus work session.
|
|||||||
)
|
)
|
||||||
|
|
||||||
// then
|
// 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 () => {
|
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
|
// 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")
|
expect(sessionState.getSessionAgent("ses-sisyphus-to-atlas")).toBe("atlas")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -496,7 +525,7 @@ You are starting a Sisyphus work session.
|
|||||||
)
|
)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(output.message.agent).toBe("sisyphus")
|
expect(output.message.agent).toBe("Sisyphus - Ultraworker")
|
||||||
expect(sessionState.getSessionAgent("ses-prometheus-to-sisyphus")).toBe("sisyphus")
|
expect(sessionState.getSessionAgent("ses-prometheus-to-sisyphus")).toBe("sisyphus")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -524,7 +553,7 @@ You are starting a Sisyphus work session.
|
|||||||
)
|
)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(output.message.agent).toBe("sisyphus")
|
expect(output.message.agent).toBe("Sisyphus - Ultraworker")
|
||||||
expect(sessionState.getSessionAgent("ses-prometheus-to-worker")).toBe("sisyphus")
|
expect(sessionState.getSessionAgent("ses-prometheus-to-worker")).toBe("sisyphus")
|
||||||
expect(readBoulderState(testDir)?.agent).toBe("sisyphus")
|
expect(readBoulderState(testDir)?.agent).toBe("sisyphus")
|
||||||
})
|
})
|
||||||
@@ -559,7 +588,7 @@ You are starting a Sisyphus work session.
|
|||||||
)
|
)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(output.message.agent).toBe("sisyphus")
|
expect(output.message.agent).toBe("Sisyphus - Ultraworker")
|
||||||
expect(readBoulderState(testDir)?.agent).toBe("sisyphus")
|
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" } } })
|
await atlasHook.handler({ event: { type: "session.idle", properties: { sessionID: "session-123" } } })
|
||||||
|
|
||||||
// then
|
// 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)?.session_ids).toContain("session-123")
|
||||||
expect(readBoulderState(testDir)?.agent).toBe("atlas")
|
expect(readBoulderState(testDir)?.agent).toBe("atlas")
|
||||||
expect(promptAsyncMock).toHaveBeenCalledTimes(1)
|
expect(promptAsyncMock).toHaveBeenCalledTimes(1)
|
||||||
@@ -684,7 +713,7 @@ You are starting a Sisyphus work session.
|
|||||||
await firePendingTimers()
|
await firePendingTimers()
|
||||||
|
|
||||||
// then
|
// 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)?.session_ids).toContain("session-123")
|
||||||
expect(readBoulderState(testDir)?.agent).toBe("atlas")
|
expect(readBoulderState(testDir)?.agent).toBe("atlas")
|
||||||
expect(promptAsyncMock).toHaveBeenCalledTimes(1)
|
expect(promptAsyncMock).toHaveBeenCalledTimes(1)
|
||||||
|
|||||||
@@ -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("<user-request>\"my feature plan\"</user-request>")
|
||||||
|
expect(result.planName).toBe("my feature plan")
|
||||||
|
expect(result.explicitWorktreePath).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("when --worktree flag has no path", () => {
|
describe("when --worktree flag has no path", () => {
|
||||||
test("#given --worktree without path #when parsing #then worktree path is null", () => {
|
test("#given --worktree without path #when parsing #then worktree path is null", () => {
|
||||||
const result = parseUserRequest("<user-request>--worktree</user-request>")
|
const result = parseUserRequest("<user-request>--worktree</user-request>")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const KEYWORD_PATTERN = /\b(ultrawork|ulw)\b/gi
|
const KEYWORD_PATTERN = /\b(ultrawork|ulw)\b/gi
|
||||||
const WORKTREE_FLAG_PATTERN = /--worktree(?:\s+(\S+))?/
|
const WORKTREE_FLAG_PATTERN = /--worktree(?:\s+(\S+))?/
|
||||||
|
const WRAPPING_QUOTES_PATTERN = /^(["'`])([\s\S]*)\1$/
|
||||||
|
|
||||||
export interface ParsedUserRequest {
|
export interface ParsedUserRequest {
|
||||||
planName: string | null
|
planName: string | null
|
||||||
@@ -21,9 +22,11 @@ export function parseUserRequest(promptText: string): ParsedUserRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cleanedArg = rawArg.replace(KEYWORD_PATTERN, "").trim()
|
const cleanedArg = rawArg.replace(KEYWORD_PATTERN, "").trim()
|
||||||
|
const quotedPlanMatch = cleanedArg.match(WRAPPING_QUOTES_PATTERN)
|
||||||
|
const normalizedPlanName = quotedPlanMatch ? quotedPlanMatch[2].trim() : cleanedArg
|
||||||
|
|
||||||
return {
|
return {
|
||||||
planName: cleanedArg || null,
|
planName: normalizedPlanName || null,
|
||||||
explicitWorktreePath,
|
explicitWorktreePath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user