feat(start-work): add --worktree flag support in hook

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim 2026-02-26 00:40:01 +09:00
parent f500fb0286
commit f872f5e171
3 changed files with 254 additions and 72 deletions

View File

@ -7,9 +7,12 @@ import { createStartWorkHook } from "./index"
import { import {
writeBoulderState, writeBoulderState,
clearBoulderState, clearBoulderState,
readBoulderState,
} from "../../features/boulder-state" } from "../../features/boulder-state"
import type { BoulderState } from "../../features/boulder-state" import type { BoulderState } from "../../features/boulder-state"
import * as sessionState from "../../features/claude-code-session-state" import * as sessionState from "../../features/claude-code-session-state"
import * as worktreeDetector from "./worktree-detector"
import * as worktreeDetector from "./worktree-detector"
describe("start-work hook", () => { describe("start-work hook", () => {
let testDir: string let testDir: string
@ -402,4 +405,152 @@ describe("start-work hook", () => {
updateSpy.mockRestore() updateSpy.mockRestore()
}) })
}) })
describe("worktree support", () => {
let detectSpy: ReturnType<typeof spyOn>
beforeEach(() => {
detectSpy = spyOn(worktreeDetector, "detectWorktreePath").mockReturnValue(null)
})
afterEach(() => {
detectSpy.mockRestore()
})
test("should inject model-decides instructions when no --worktree flag", async () => {
// given - single plan, no worktree flag
const plansDir = join(testDir, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
writeFileSync(join(plansDir, "my-plan.md"), "# Plan\n- [ ] Task 1")
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [{ type: "text", text: "<session-context></session-context>" }],
}
// when
await hook["chat.message"]({ sessionID: "session-123" }, output)
// then - model-decides instructions should appear
expect(output.parts[0].text).toContain("Worktree Setup Required")
expect(output.parts[0].text).toContain("git worktree list --porcelain")
expect(output.parts[0].text).toContain("git worktree add")
})
test("should inject worktree path when --worktree flag is valid", async () => {
// given - single plan + valid worktree path
const plansDir = join(testDir, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
writeFileSync(join(plansDir, "my-plan.md"), "# Plan\n- [ ] Task 1")
detectSpy.mockReturnValue("/validated/worktree")
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [{ type: "text", text: "<session-context>\n<user-request>--worktree /validated/worktree</user-request>\n</session-context>" }],
}
// when
await hook["chat.message"]({ sessionID: "session-123" }, output)
// then - validated path shown, no model-decides instructions
expect(output.parts[0].text).toContain("**Worktree**: /validated/worktree")
expect(output.parts[0].text).not.toContain("Worktree Setup Required")
})
test("should store worktree_path in boulder when --worktree is valid", async () => {
// given - plan + valid worktree
const plansDir = join(testDir, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
writeFileSync(join(plansDir, "my-plan.md"), "# Plan\n- [ ] Task 1")
detectSpy.mockReturnValue("/valid/wt")
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [{ type: "text", text: "<session-context>\n<user-request>--worktree /valid/wt</user-request>\n</session-context>" }],
}
// when
await hook["chat.message"]({ sessionID: "session-123" }, output)
// then - boulder.json has worktree_path
const state = readBoulderState(testDir)
expect(state?.worktree_path).toBe("/valid/wt")
})
test("should NOT store worktree_path when --worktree path is invalid", async () => {
// given - plan + invalid worktree path (detectWorktreePath returns null)
const plansDir = join(testDir, ".sisyphus", "plans")
mkdirSync(plansDir, { recursive: true })
writeFileSync(join(plansDir, "my-plan.md"), "# Plan\n- [ ] Task 1")
// detectSpy already returns null by default
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [{ type: "text", text: "<session-context>\n<user-request>--worktree /nonexistent/wt</user-request>\n</session-context>" }],
}
// when
await hook["chat.message"]({ sessionID: "session-123" }, output)
// then - worktree_path absent, setup instructions present
const state = readBoulderState(testDir)
expect(state?.worktree_path).toBeUndefined()
expect(output.parts[0].text).toContain("needs setup")
expect(output.parts[0].text).toContain("git worktree add /nonexistent/wt")
})
test("should update boulder worktree_path on resume when new --worktree given", async () => {
// given - existing boulder with old worktree, user provides new worktree
const planPath = join(testDir, "plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1")
const existingState: BoulderState = {
active_plan: planPath,
started_at: "2026-01-01T00:00:00Z",
session_ids: ["old-session"],
plan_name: "plan",
worktree_path: "/old/wt",
}
writeBoulderState(testDir, existingState)
detectSpy.mockReturnValue("/new/wt")
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [{ type: "text", text: "<session-context>\n<user-request>--worktree /new/wt</user-request>\n</session-context>" }],
}
// when
await hook["chat.message"]({ sessionID: "session-456" }, output)
// then - boulder reflects updated worktree and new session appended
const state = readBoulderState(testDir)
expect(state?.worktree_path).toBe("/new/wt")
expect(state?.session_ids).toContain("session-456")
})
test("should show existing worktree on resume when no --worktree flag", async () => {
// given - existing boulder already has worktree_path, no flag given
const planPath = join(testDir, "plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1")
const existingState: BoulderState = {
active_plan: planPath,
started_at: "2026-01-01T00:00:00Z",
session_ids: ["old-session"],
plan_name: "plan",
worktree_path: "/existing/wt",
}
writeBoulderState(testDir, existingState)
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [{ type: "text", text: "<session-context></session-context>" }],
}
// when
await hook["chat.message"]({ sessionID: "session-789" }, output)
// then - shows existing worktree, no model-decides instructions
expect(output.parts[0].text).toContain("/existing/wt")
expect(output.parts[0].text).not.toContain("Worktree Setup Required")
})
})
}) })

View File

@ -1 +1,4 @@
export { HOOK_NAME, createStartWorkHook } from "./start-work-hook" export { HOOK_NAME, createStartWorkHook } from "./start-work-hook"
export { detectWorktreePath } from "./worktree-detector"
export type { ParsedUserRequest } from "./parse-user-request"
export { parseUserRequest } from "./parse-user-request"

View File

@ -1,3 +1,4 @@
import { statSync } from "node:fs"
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import { import {
readBoulderState, readBoulderState,
@ -11,11 +12,11 @@ import {
} from "../../features/boulder-state" } from "../../features/boulder-state"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { updateSessionAgent } from "../../features/claude-code-session-state" import { updateSessionAgent } from "../../features/claude-code-session-state"
import { detectWorktreePath } from "./worktree-detector"
import { parseUserRequest } from "./parse-user-request"
export const HOOK_NAME = "start-work" as const export const HOOK_NAME = "start-work" as const
const KEYWORD_PATTERN = /\b(ultrawork|ulw)\b/gi
interface StartWorkHookInput { interface StartWorkHookInput {
sessionID: string sessionID: string
messageID?: string messageID?: string
@ -25,73 +26,76 @@ interface StartWorkHookOutput {
parts: Array<{ type: string; text?: string }> parts: Array<{ type: string; text?: string }>
} }
function extractUserRequestPlanName(promptText: string): string | null {
const userRequestMatch = promptText.match(/<user-request>\s*([\s\S]*?)\s*<\/user-request>/i)
if (!userRequestMatch) return null
const rawArg = userRequestMatch[1].trim()
if (!rawArg) return null
const cleanedArg = rawArg.replace(KEYWORD_PATTERN, "").trim()
return cleanedArg || null
}
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 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 partialMatch = plans.find((p) => getPlanName(p).toLowerCase().includes(lowerName))
const partialMatch = plans.find(p => getPlanName(p).toLowerCase().includes(lowerName))
return partialMatch || null return partialMatch || null
} }
const MODEL_DECIDES_WORKTREE_BLOCK = `
## Worktree Setup Required
No worktree specified. Before starting work, you MUST choose or create one:
1. \`git worktree list --porcelain\` — list existing worktrees
2. Create if needed: \`git worktree add <absolute-path> <branch-or-HEAD>\`
3. Update \`.sisyphus/boulder.json\` — add \`"worktree_path": "<absolute-path>"\`
4. Work exclusively inside that worktree directory`
function resolveWorktreeContext(
explicitWorktreePath: string | null,
): { worktreePath: string | undefined; block: string } {
if (explicitWorktreePath === null) {
return { worktreePath: undefined, block: MODEL_DECIDES_WORKTREE_BLOCK }
}
const validatedPath = detectWorktreePath(explicitWorktreePath)
if (validatedPath) {
return { worktreePath: validatedPath, block: `\n**Worktree**: ${validatedPath}` }
}
return {
worktreePath: undefined,
block: `\n**Worktree** (needs setup): \`git worktree add ${explicitWorktreePath} <branch>\`, then add \`"worktree_path"\` to boulder.json`,
}
}
export function createStartWorkHook(ctx: PluginInput) { export function createStartWorkHook(ctx: PluginInput) {
return { return {
"chat.message": async ( "chat.message": async (input: StartWorkHookInput, output: StartWorkHookOutput): Promise<void> => {
input: StartWorkHookInput,
output: StartWorkHookOutput
): Promise<void> => {
const parts = output.parts const parts = output.parts
const promptText = parts const promptText =
?.filter((p) => p.type === "text" && p.text) parts
.map((p) => p.text) ?.filter((p) => p.type === "text" && p.text)
.join("\n") .map((p) => p.text)
.trim() || "" .join("\n")
.trim() || ""
// Only trigger on actual command execution (contains <session-context> tag) if (!promptText.includes("<session-context>")) return
// NOT on description text like "Start Sisyphus work session from Prometheus plan"
const isStartWorkCommand = promptText.includes("<session-context>")
if (!isStartWorkCommand) { log(`[${HOOK_NAME}] Processing start-work command`, { sessionID: input.sessionID })
return updateSessionAgent(input.sessionID, "atlas")
}
log(`[${HOOK_NAME}] Processing start-work command`, {
sessionID: input.sessionID,
})
updateSessionAgent(input.sessionID, "atlas") // Always switch: fixes #1298
const existingState = readBoulderState(ctx.directory) const existingState = readBoulderState(ctx.directory)
const sessionId = input.sessionID const sessionId = input.sessionID
const timestamp = new Date().toISOString() const timestamp = new Date().toISOString()
const { planName: explicitPlanName, explicitWorktreePath } = parseUserRequest(promptText)
const { worktreePath, block: worktreeBlock } = resolveWorktreeContext(explicitWorktreePath)
let contextInfo = "" let contextInfo = ""
const explicitPlanName = extractUserRequestPlanName(promptText)
if (explicitPlanName) { if (explicitPlanName) {
log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, { log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, { sessionID: input.sessionID })
sessionID: input.sessionID,
})
const allPlans = findPrometheusPlans(ctx.directory) const allPlans = findPrometheusPlans(ctx.directory)
const matchedPlan = findPlanByName(allPlans, explicitPlanName) const matchedPlan = findPlanByName(allPlans, explicitPlanName)
if (matchedPlan) { if (matchedPlan) {
const progress = getPlanProgress(matchedPlan) const progress = getPlanProgress(matchedPlan)
if (progress.isComplete) { if (progress.isComplete) {
contextInfo = ` contextInfo = `
## Plan Already Complete ## Plan Already Complete
@ -99,12 +103,10 @@ export function createStartWorkHook(ctx: PluginInput) {
The requested plan "${getPlanName(matchedPlan)}" has been completed. The requested plan "${getPlanName(matchedPlan)}" has been completed.
All ${progress.total} tasks are done. Create a new plan with: /plan "your task"` All ${progress.total} tasks are done. Create a new plan with: /plan "your task"`
} else { } else {
if (existingState) { if (existingState) clearBoulderState(ctx.directory)
clearBoulderState(ctx.directory) const newState = createBoulderState(matchedPlan, sessionId, "atlas", worktreePath)
}
const newState = createBoulderState(matchedPlan, sessionId, "atlas")
writeBoulderState(ctx.directory, newState) writeBoulderState(ctx.directory, newState)
contextInfo = ` contextInfo = `
## Auto-Selected Plan ## Auto-Selected Plan
@ -113,17 +115,20 @@ All ${progress.total} tasks are done. Create a new plan with: /plan "your task"`
**Progress**: ${progress.completed}/${progress.total} tasks **Progress**: ${progress.completed}/${progress.total} tasks
**Session ID**: ${sessionId} **Session ID**: ${sessionId}
**Started**: ${timestamp} **Started**: ${timestamp}
${worktreeBlock}
boulder.json has been created. Read the plan and begin execution.` boulder.json has been created. Read the plan and begin execution.`
} }
} else { } else {
const incompletePlans = allPlans.filter(p => !getPlanProgress(p).isComplete) const incompletePlans = allPlans.filter((p) => !getPlanProgress(p).isComplete)
if (incompletePlans.length > 0) { if (incompletePlans.length > 0) {
const planList = incompletePlans.map((p, i) => { const planList = incompletePlans
const prog = getPlanProgress(p) .map((p, i) => {
return `${i + 1}. [${getPlanName(p)}] - Progress: ${prog.completed}/${prog.total}` const prog = getPlanProgress(p)
}).join("\n") return `${i + 1}. [${getPlanName(p)}] - Progress: ${prog.completed}/${prog.total}`
})
.join("\n")
contextInfo = ` contextInfo = `
## Plan Not Found ## Plan Not Found
@ -143,9 +148,25 @@ No incomplete plans available. Create a new plan with: /plan "your task"`
} }
} else if (existingState) { } else if (existingState) {
const progress = getPlanProgress(existingState.active_plan) const progress = getPlanProgress(existingState.active_plan)
if (!progress.isComplete) { if (!progress.isComplete) {
appendSessionId(ctx.directory, sessionId) const effectiveWorktree = worktreePath ?? existingState.worktree_path
if (worktreePath !== undefined) {
const updatedSessions = existingState.session_ids.includes(sessionId)
? existingState.session_ids
: [...existingState.session_ids, sessionId]
writeBoulderState(ctx.directory, {
...existingState,
worktree_path: worktreePath,
session_ids: updatedSessions,
})
} else {
appendSessionId(ctx.directory, sessionId)
}
const worktreeDisplay = effectiveWorktree ? `\n**Worktree**: ${effectiveWorktree}` : worktreeBlock
contextInfo = ` contextInfo = `
## Active Work Session Found ## Active Work Session Found
@ -155,6 +176,7 @@ No incomplete plans available. Create a new plan with: /plan "your task"`
**Progress**: ${progress.completed}/${progress.total} tasks completed **Progress**: ${progress.completed}/${progress.total} tasks completed
**Sessions**: ${existingState.session_ids.length + 1} (current session appended) **Sessions**: ${existingState.session_ids.length + 1} (current session appended)
**Started**: ${existingState.started_at} **Started**: ${existingState.started_at}
${worktreeDisplay}
The current session (${sessionId}) has been added to session_ids. The current session (${sessionId}) has been added to session_ids.
Read the plan file and continue from the first unchecked task.` Read the plan file and continue from the first unchecked task.`
@ -167,13 +189,15 @@ Looking for new plans...`
} }
} }
if ((!existingState && !explicitPlanName) || (existingState && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete)) { if (
(!existingState && !explicitPlanName) ||
(existingState && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete)
) {
const plans = findPrometheusPlans(ctx.directory) const plans = findPrometheusPlans(ctx.directory)
const incompletePlans = plans.filter(p => !getPlanProgress(p).isComplete) const incompletePlans = plans.filter((p) => !getPlanProgress(p).isComplete)
if (plans.length === 0) { if (plans.length === 0) {
contextInfo += ` contextInfo += `
## No Plans Found ## No Plans Found
No Prometheus plan files found at .sisyphus/plans/ No Prometheus plan files found at .sisyphus/plans/
@ -187,7 +211,7 @@ All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your ta
} else if (incompletePlans.length === 1) { } else if (incompletePlans.length === 1) {
const planPath = incompletePlans[0] const planPath = incompletePlans[0]
const progress = getPlanProgress(planPath) const progress = getPlanProgress(planPath)
const newState = createBoulderState(planPath, sessionId, "atlas") const newState = createBoulderState(planPath, sessionId, "atlas", worktreePath)
writeBoulderState(ctx.directory, newState) writeBoulderState(ctx.directory, newState)
contextInfo += ` contextInfo += `
@ -199,15 +223,17 @@ All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your ta
**Progress**: ${progress.completed}/${progress.total} tasks **Progress**: ${progress.completed}/${progress.total} tasks
**Session ID**: ${sessionId} **Session ID**: ${sessionId}
**Started**: ${timestamp} **Started**: ${timestamp}
${worktreeBlock}
boulder.json has been created. Read the plan and begin execution.` boulder.json has been created. Read the plan and begin execution.`
} else { } else {
const planList = incompletePlans.map((p, i) => { const planList = incompletePlans
const progress = getPlanProgress(p) .map((p, i) => {
const stat = require("node:fs").statSync(p) const progress = getPlanProgress(p)
const modified = new Date(stat.mtimeMs).toISOString() const modified = new Date(statSync(p).mtimeMs).toISOString()
return `${i + 1}. [${getPlanName(p)}] - Modified: ${modified} - Progress: ${progress.completed}/${progress.total}` return `${i + 1}. [${getPlanName(p)}] - Modified: ${modified} - Progress: ${progress.completed}/${progress.total}`
}).join("\n") })
.join("\n")
contextInfo += ` contextInfo += `
@ -220,6 +246,7 @@ Session ID: ${sessionId}
${planList} ${planList}
Ask the user which plan to work on. Present the options above and wait for their response. Ask the user which plan to work on. Present the options above and wait for their response.
${worktreeBlock}
</system-reminder>` </system-reminder>`
} }
} }
@ -229,13 +256,14 @@ Ask the user which plan to work on. Present the options above and wait for their
output.parts[idx].text = output.parts[idx].text output.parts[idx].text = output.parts[idx].text
.replace(/\$SESSION_ID/g, sessionId) .replace(/\$SESSION_ID/g, sessionId)
.replace(/\$TIMESTAMP/g, timestamp) .replace(/\$TIMESTAMP/g, timestamp)
output.parts[idx].text += `\n\n---\n${contextInfo}` output.parts[idx].text += `\n\n---\n${contextInfo}`
} }
log(`[${HOOK_NAME}] Context injected`, { log(`[${HOOK_NAME}] Context injected`, {
sessionID: input.sessionID, sessionID: input.sessionID,
hasExistingState: !!existingState, hasExistingState: !!existingState,
worktreePath,
}) })
}, },
} }