diff --git a/src/hooks/start-work/index.test.ts b/src/hooks/start-work/index.test.ts index e633e85a9..26b87eba4 100644 --- a/src/hooks/start-work/index.test.ts +++ b/src/hooks/start-work/index.test.ts @@ -7,9 +7,12 @@ import { createStartWorkHook } from "./index" import { writeBoulderState, clearBoulderState, + readBoulderState, } from "../../features/boulder-state" import type { BoulderState } from "../../features/boulder-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", () => { let testDir: string @@ -402,4 +405,152 @@ describe("start-work hook", () => { updateSpy.mockRestore() }) }) + + describe("worktree support", () => { + let detectSpy: ReturnType + + 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: "" }], + } + + // 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: "\n--worktree /validated/worktree\n" }], + } + + // 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: "\n--worktree /valid/wt\n" }], + } + + // 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: "\n--worktree /nonexistent/wt\n" }], + } + + // 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: "\n--worktree /new/wt\n" }], + } + + // 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: "" }], + } + + // 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") + }) + }) }) diff --git a/src/hooks/start-work/index.ts b/src/hooks/start-work/index.ts index 41cb0b1a4..ee270861a 100644 --- a/src/hooks/start-work/index.ts +++ b/src/hooks/start-work/index.ts @@ -1 +1,4 @@ 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" diff --git a/src/hooks/start-work/start-work-hook.ts b/src/hooks/start-work/start-work-hook.ts index 77c76d240..03cdb540e 100644 --- a/src/hooks/start-work/start-work-hook.ts +++ b/src/hooks/start-work/start-work-hook.ts @@ -1,3 +1,4 @@ +import { statSync } from "node:fs" import type { PluginInput } from "@opencode-ai/plugin" import { readBoulderState, @@ -11,11 +12,11 @@ import { } from "../../features/boulder-state" import { log } from "../../shared/logger" 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 -const KEYWORD_PATTERN = /\b(ultrawork|ulw)\b/gi - interface StartWorkHookInput { sessionID: string messageID?: string @@ -25,73 +26,76 @@ interface StartWorkHookOutput { parts: Array<{ type: string; text?: string }> } -function extractUserRequestPlanName(promptText: string): string | null { - const userRequestMatch = promptText.match(/\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 { 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 - - const partialMatch = plans.find(p => getPlanName(p).toLowerCase().includes(lowerName)) + const partialMatch = plans.find((p) => getPlanName(p).toLowerCase().includes(lowerName)) 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 \` +3. Update \`.sisyphus/boulder.json\` — add \`"worktree_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} \`, then add \`"worktree_path"\` to boulder.json`, + } +} + export function createStartWorkHook(ctx: PluginInput) { return { - "chat.message": async ( - input: StartWorkHookInput, - output: StartWorkHookOutput - ): Promise => { + "chat.message": async (input: StartWorkHookInput, output: StartWorkHookOutput): Promise => { const parts = output.parts - const promptText = parts - ?.filter((p) => p.type === "text" && p.text) - .map((p) => p.text) - .join("\n") - .trim() || "" + const promptText = + parts + ?.filter((p) => p.type === "text" && p.text) + .map((p) => p.text) + .join("\n") + .trim() || "" - // Only trigger on actual command execution (contains tag) - // NOT on description text like "Start Sisyphus work session from Prometheus plan" - const isStartWorkCommand = promptText.includes("") + if (!promptText.includes("")) return - if (!isStartWorkCommand) { - return - } - - log(`[${HOOK_NAME}] Processing start-work command`, { - sessionID: input.sessionID, - }) - - updateSessionAgent(input.sessionID, "atlas") // Always switch: fixes #1298 + log(`[${HOOK_NAME}] Processing start-work command`, { sessionID: input.sessionID }) + updateSessionAgent(input.sessionID, "atlas") const existingState = readBoulderState(ctx.directory) const sessionId = input.sessionID const timestamp = new Date().toISOString() + const { planName: explicitPlanName, explicitWorktreePath } = parseUserRequest(promptText) + const { worktreePath, block: worktreeBlock } = resolveWorktreeContext(explicitWorktreePath) + let contextInfo = "" - - const explicitPlanName = extractUserRequestPlanName(promptText) - + if (explicitPlanName) { - log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, { - sessionID: input.sessionID, - }) - + log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, { sessionID: input.sessionID }) + const allPlans = findPrometheusPlans(ctx.directory) const matchedPlan = findPlanByName(allPlans, explicitPlanName) - + if (matchedPlan) { const progress = getPlanProgress(matchedPlan) - + if (progress.isComplete) { contextInfo = ` ## Plan Already Complete @@ -99,12 +103,10 @@ export function createStartWorkHook(ctx: PluginInput) { The requested plan "${getPlanName(matchedPlan)}" has been completed. All ${progress.total} tasks are done. Create a new plan with: /plan "your task"` } else { - if (existingState) { - clearBoulderState(ctx.directory) - } - const newState = createBoulderState(matchedPlan, sessionId, "atlas") + if (existingState) clearBoulderState(ctx.directory) + const newState = createBoulderState(matchedPlan, sessionId, "atlas", worktreePath) writeBoulderState(ctx.directory, newState) - + contextInfo = ` ## 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 **Session ID**: ${sessionId} **Started**: ${timestamp} +${worktreeBlock} boulder.json has been created. Read the plan and begin execution.` } } else { - const incompletePlans = allPlans.filter(p => !getPlanProgress(p).isComplete) + const incompletePlans = allPlans.filter((p) => !getPlanProgress(p).isComplete) if (incompletePlans.length > 0) { - const planList = incompletePlans.map((p, i) => { - const prog = getPlanProgress(p) - return `${i + 1}. [${getPlanName(p)}] - Progress: ${prog.completed}/${prog.total}` - }).join("\n") - + const planList = incompletePlans + .map((p, i) => { + const prog = getPlanProgress(p) + return `${i + 1}. [${getPlanName(p)}] - Progress: ${prog.completed}/${prog.total}` + }) + .join("\n") + contextInfo = ` ## Plan Not Found @@ -143,9 +148,25 @@ No incomplete plans available. Create a new plan with: /plan "your task"` } } else if (existingState) { const progress = getPlanProgress(existingState.active_plan) - + 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 = ` ## 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 **Sessions**: ${existingState.session_ids.length + 1} (current session appended) **Started**: ${existingState.started_at} +${worktreeDisplay} The current session (${sessionId}) has been added to session_ids. 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 incompletePlans = plans.filter(p => !getPlanProgress(p).isComplete) - + const incompletePlans = plans.filter((p) => !getPlanProgress(p).isComplete) + if (plans.length === 0) { contextInfo += ` - ## No Plans Found 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) { const planPath = incompletePlans[0] const progress = getPlanProgress(planPath) - const newState = createBoulderState(planPath, sessionId, "atlas") + const newState = createBoulderState(planPath, sessionId, "atlas", worktreePath) writeBoulderState(ctx.directory, newState) 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 **Session ID**: ${sessionId} **Started**: ${timestamp} +${worktreeBlock} boulder.json has been created. Read the plan and begin execution.` } else { - const planList = incompletePlans.map((p, i) => { - const progress = getPlanProgress(p) - const stat = require("node:fs").statSync(p) - const modified = new Date(stat.mtimeMs).toISOString() - return `${i + 1}. [${getPlanName(p)}] - Modified: ${modified} - Progress: ${progress.completed}/${progress.total}` - }).join("\n") + const planList = incompletePlans + .map((p, i) => { + const progress = getPlanProgress(p) + const modified = new Date(statSync(p).mtimeMs).toISOString() + return `${i + 1}. [${getPlanName(p)}] - Modified: ${modified} - Progress: ${progress.completed}/${progress.total}` + }) + .join("\n") contextInfo += ` @@ -220,6 +246,7 @@ Session ID: ${sessionId} ${planList} Ask the user which plan to work on. Present the options above and wait for their response. +${worktreeBlock} ` } } @@ -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 .replace(/\$SESSION_ID/g, sessionId) .replace(/\$TIMESTAMP/g, timestamp) - + output.parts[idx].text += `\n\n---\n${contextInfo}` } log(`[${HOOK_NAME}] Context injected`, { sessionID: input.sessionID, hasExistingState: !!existingState, + worktreePath, }) }, }