diff --git a/src/cli/run/completion-continuation.test.ts b/src/cli/run/completion-continuation.test.ts index 976277cca..6fd553527 100644 --- a/src/cli/run/completion-continuation.test.ts +++ b/src/cli/run/completion-continuation.test.ts @@ -105,6 +105,41 @@ describe("checkCompletionConditions continuation coverage", () => { expect(result).toBe(true) }) + it("returns true when the mirrored worktree plan is complete even if the main repo plan is stale", async () => { + // given + spyOn(console, "log").mockImplementation(() => {}) + const directory = createTempDir() + const mainPlanPath = join(directory, ".sisyphus", "plans", "done-in-worktree-plan.md") + const worktreeDirectory = createTempDir() + const worktreePlanPath = join(worktreeDirectory, ".sisyphus", "plans", "done-in-worktree-plan.md") + mkdirSync(join(directory, ".sisyphus", "plans"), { recursive: true }) + mkdirSync(join(worktreeDirectory, ".sisyphus", "plans"), { recursive: true }) + writeFileSync(mainPlanPath, "- [ ] stale main repo task\n", "utf-8") + writeFileSync(worktreePlanPath, "- [x] completed worktree task\n", "utf-8") + const sisyphusDir = join(directory, ".sisyphus") + mkdirSync(sisyphusDir, { recursive: true }) + writeFileSync( + join(sisyphusDir, "boulder.json"), + JSON.stringify({ + active_plan: mainPlanPath, + started_at: new Date().toISOString(), + session_ids: ["test-session"], + plan_name: "done-in-worktree-plan", + agent: "atlas", + worktree_path: worktreeDirectory, + }), + "utf-8", + ) + const ctx = createMockContext(directory) + const { checkCompletionConditions } = await import("./completion") + + // when + const result = await checkCompletionConditions(ctx) + + // then + expect(result).toBe(true) + }) + it("returns false when current session is an appended descendant of an active boulder session with unchecked plan items", async () => { // given spyOn(console, "log").mockImplementation(() => {}) diff --git a/src/cli/run/continuation-state.ts b/src/cli/run/continuation-state.ts index 07b530812..aeaa65dae 100644 --- a/src/cli/run/continuation-state.ts +++ b/src/cli/run/continuation-state.ts @@ -1,4 +1,4 @@ -import { getPlanProgress, readBoulderState } from "../../features/boulder-state" +import { getPlanProgress, readBoulderState, resolveBoulderPlanPath } from "../../features/boulder-state" import { getSessionAgent } from "../../features/claude-code-session-state" import { getActiveContinuationMarkerReason, @@ -45,7 +45,7 @@ async function hasActiveBoulderContinuation( const boulder = readBoulderState(directory) if (!boulder) return false - const progress = getPlanProgress(boulder.active_plan) + const progress = getPlanProgress(resolveBoulderPlanPath(directory, boulder)) if (progress.isComplete) return false if (!client) return false diff --git a/src/features/boulder-state/storage.test.ts b/src/features/boulder-state/storage.test.ts index 4326b42e0..c424e02eb 100644 --- a/src/features/boulder-state/storage.test.ts +++ b/src/features/boulder-state/storage.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test" import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs" -import { join } from "node:path" +import { dirname, join } from "node:path" import { tmpdir } from "node:os" import { readBoulderState, @@ -12,6 +12,7 @@ import { createBoulderState, findPrometheusPlans, getTaskSessionState, + resolveBoulderPlanPath, upsertTaskSessionState, } from "./storage" import type { BoulderState } from "./types" @@ -778,4 +779,46 @@ describe("boulder-state", () => { expect(state.agent).toBeUndefined() }) }) + + describe("resolveBoulderPlanPath", () => { + test("should prefer the mirrored worktree plan when it exists", () => { + // given + const planPath = join(TEST_DIR, ".sisyphus", "plans", "worktree-plan.md") + const worktreeDir = join(tmpdir(), `boulder-state-worktree-${Date.now()}`) + const worktreePlanPath = join(worktreeDir, ".sisyphus", "plans", "worktree-plan.md") + mkdirSync(dirname(planPath), { recursive: true }) + mkdirSync(dirname(worktreePlanPath), { recursive: true }) + writeFileSync(planPath, "# Plan\n- [ ] Main repo task\n") + writeFileSync(worktreePlanPath, "# Plan\n- [x] Worktree task\n") + + try { + // when + const resolvedPath = resolveBoulderPlanPath(TEST_DIR, { + active_plan: planPath, + worktree_path: worktreeDir, + }) + + // then + expect(resolvedPath).toBe(worktreePlanPath) + } finally { + rmSync(worktreeDir, { recursive: true, force: true }) + } + }) + + test("should fall back to the tracked plan when the mirrored worktree plan is missing", () => { + // given + const planPath = join(TEST_DIR, ".sisyphus", "plans", "fallback-plan.md") + mkdirSync(dirname(planPath), { recursive: true }) + writeFileSync(planPath, "# Plan\n- [ ] Main repo task\n") + + // when + const resolvedPath = resolveBoulderPlanPath(TEST_DIR, { + active_plan: planPath, + worktree_path: join(tmpdir(), `missing-worktree-${Date.now()}`), + }) + + // then + expect(resolvedPath).toBe(planPath) + }) + }) }) diff --git a/src/features/boulder-state/storage.ts b/src/features/boulder-state/storage.ts index d570ce525..e11aa31ca 100644 --- a/src/features/boulder-state/storage.ts +++ b/src/features/boulder-state/storage.ts @@ -5,7 +5,7 @@ */ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs" -import { dirname, join, basename } from "node:path" +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path" import type { BoulderState, PlanProgress, TaskSessionState } from "./types" import { BOULDER_DIR, BOULDER_FILE, PROMETHEUS_PLANS_DIR } from "./constants" @@ -15,6 +15,39 @@ export function getBoulderFilePath(directory: string): string { return join(directory, BOULDER_DIR, BOULDER_FILE) } +function resolveTrackedPath(baseDirectory: string, trackedPath: string): string { + return isAbsolute(trackedPath) + ? resolve(trackedPath) + : resolve(baseDirectory, trackedPath) +} + +export function resolveBoulderPlanPath( + directory: string, + state: Pick, +): string { + const absolutePlanPath = resolveTrackedPath(directory, state.active_plan) + const worktreePath = state.worktree_path?.trim() + if (!worktreePath) { + return absolutePlanPath + } + + const absoluteDirectory = resolve(directory) + const relativePlanPath = relative(absoluteDirectory, absolutePlanPath) + if ( + relativePlanPath.length === 0 + || relativePlanPath.startsWith("..") + || isAbsolute(relativePlanPath) + ) { + return absolutePlanPath + } + + const absoluteWorktreePath = resolveTrackedPath(directory, worktreePath) + const worktreePlanPath = resolve(absoluteWorktreePath, relativePlanPath) + return existsSync(worktreePlanPath) + ? worktreePlanPath + : absolutePlanPath +} + export function readBoulderState(directory: string): BoulderState | null { const filePath = getBoulderFilePath(directory) diff --git a/src/hooks/atlas/background-launch-session-tracking.ts b/src/hooks/atlas/background-launch-session-tracking.ts index 0e68d6a77..4fcb68864 100644 --- a/src/hooks/atlas/background-launch-session-tracking.ts +++ b/src/hooks/atlas/background-launch-session-tracking.ts @@ -1,5 +1,5 @@ import type { PluginInput } from "@opencode-ai/plugin" -import { appendSessionId, type BoulderState, upsertTaskSessionState } from "../../features/boulder-state" +import { appendSessionId, type BoulderState, resolveBoulderPlanPath, upsertTaskSessionState } from "../../features/boulder-state" import { log } from "../../shared/logger" import { HOOK_NAME } from "./hook-name" import { extractSessionIdFromOutput, validateSubagentSessionId } from "./subagent-session-id" @@ -40,7 +40,7 @@ export async function syncBackgroundLaunchSessionTracking(input: { const { currentTask, shouldSkipTaskSessionUpdate } = resolveTaskContext( pendingTaskRef, - boulderState.active_plan, + resolveBoulderPlanPath(ctx.directory, boulderState), ) if (currentTask && !shouldSkipTaskSessionUpdate) { diff --git a/src/hooks/atlas/idle-event.ts b/src/hooks/atlas/idle-event.ts index 41df724bb..9651b1699 100644 --- a/src/hooks/atlas/idle-event.ts +++ b/src/hooks/atlas/idle-event.ts @@ -4,6 +4,7 @@ import { getTaskSessionState, readBoulderState, readCurrentTopLevelTask, + resolveBoulderPlanPath, } from "../../features/boulder-state" import { getSessionAgent } from "../../features/claude-code-session-state" import { getLastAgentFromSession } from "./session-last-agent" @@ -52,8 +53,12 @@ async function injectContinuation(input: { try { const currentBoulder = readBoulderState(input.ctx.directory) + const currentPlanPath = currentBoulder + ? resolveBoulderPlanPath(input.ctx.directory, currentBoulder) + : null const currentTask = currentBoulder - ? readCurrentTopLevelTask(currentBoulder.active_plan) + && currentPlanPath + ? readCurrentTopLevelTask(currentPlanPath) : null const preferredTaskSession = currentTask ? getTaskSessionState(input.ctx.directory, currentTask.key) @@ -163,7 +168,7 @@ function scheduleRetry(input: { if (!currentBoulder) return if (!currentBoulder.session_ids?.includes(sessionID)) return - const currentProgress = getPlanProgress(currentBoulder.active_plan) + const currentProgress = getPlanProgress(resolveBoulderPlanPath(ctx.directory, currentBoulder)) if (currentProgress.isComplete) return if (options?.isContinuationStopped?.(sessionID)) return const canContinueSession = await canContinueTrackedBoulderSession({ diff --git a/src/hooks/atlas/index.test.ts b/src/hooks/atlas/index.test.ts index a2e80cf78..70f6a0591 100644 --- a/src/hooks/atlas/index.test.ts +++ b/src/hooks/atlas/index.test.ts @@ -1494,6 +1494,43 @@ session_id: ses_untrusted_999 expect(mockInput._promptMock).not.toHaveBeenCalled() }) + test("should not inject when the mirrored worktree plan is complete even if the main repo plan is stale", async () => { + // given + const mainPlanPath = join(TEST_DIR, ".sisyphus", "plans", "worktree-complete-plan.md") + const worktreeDir = join(tmpdir(), `atlas-worktree-${randomUUID()}`) + const worktreePlanPath = join(worktreeDir, ".sisyphus", "plans", "worktree-complete-plan.md") + mkdirSync(join(TEST_DIR, ".sisyphus", "plans"), { recursive: true }) + mkdirSync(join(worktreeDir, ".sisyphus", "plans"), { recursive: true }) + writeFileSync(mainPlanPath, "# Plan\n- [ ] Main repo task\n") + writeFileSync(worktreePlanPath, "# Plan\n- [x] Worktree task\n") + + writeBoulderState(TEST_DIR, { + active_plan: mainPlanPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: [MAIN_SESSION_ID], + plan_name: "worktree-complete-plan", + worktree_path: worktreeDir, + }) + + const mockInput = createMockPluginInput() + const hook = createAtlasHook(mockInput) + + try { + // when + await hook.handler({ + event: { + type: "session.idle", + properties: { sessionID: MAIN_SESSION_ID }, + }, + }) + + // then + expect(mockInput._promptMock).not.toHaveBeenCalled() + } finally { + rmSync(worktreeDir, { recursive: true, force: true }) + } + }) + test("should skip when abort error occurred before idle", async () => { // given - boulder state with incomplete plan const planPath = join(TEST_DIR, "test-plan.md") diff --git a/src/hooks/atlas/resolve-active-boulder-session.test.ts b/src/hooks/atlas/resolve-active-boulder-session.test.ts index b3eb28b13..7a300a517 100644 --- a/src/hooks/atlas/resolve-active-boulder-session.test.ts +++ b/src/hooks/atlas/resolve-active-boulder-session.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" -import { join } from "node:path" +import { dirname, join } from "node:path" import { randomUUID } from "node:crypto" import { clearBoulderState, writeBoulderState } from "../../features/boulder-state" import { resolveActiveBoulderSession } from "./resolve-active-boulder-session" @@ -96,4 +96,39 @@ describe("resolveActiveBoulderSession", () => { expect(result?.progress.isComplete).toBe(false) expect(result?.boulderState.session_ids).toContain("ses_appended") }) + + test("returns complete progress when a mirrored worktree plan is complete", async () => { + // given + const mainPlanPath = join(testDirectory, ".sisyphus", "plans", "worktree-plan.md") + const worktreeDirectory = join(tmpdir(), `resolve-active-boulder-worktree-${randomUUID()}`) + const worktreePlanPath = join(worktreeDirectory, ".sisyphus", "plans", "worktree-plan.md") + mkdirSync(dirname(mainPlanPath), { recursive: true }) + mkdirSync(dirname(worktreePlanPath), { recursive: true }) + writeFileSync(mainPlanPath, "# Plan\n- [ ] Main repo task\n", "utf-8") + writeFileSync(worktreePlanPath, "# Plan\n- [x] Worktree task\n", "utf-8") + writeBoulderState(testDirectory, { + active_plan: mainPlanPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: ["ses_tracked"], + session_origins: { ses_tracked: "direct" }, + plan_name: "worktree-plan", + worktree_path: worktreeDirectory, + }) + + try { + // when + const result = await resolveActiveBoulderSession({ + client: { session: { get: async () => ({ data: {} }) } } as never, + directory: testDirectory, + sessionID: "ses_tracked", + }) + + // then + expect(result).not.toBeNull() + expect(result?.progress.isComplete).toBe(true) + expect(result?.progress.completed).toBe(1) + } finally { + rmSync(worktreeDirectory, { recursive: true, force: true }) + } + }) }) diff --git a/src/hooks/atlas/resolve-active-boulder-session.ts b/src/hooks/atlas/resolve-active-boulder-session.ts index 7e8f3c4cd..7cf23e7ba 100644 --- a/src/hooks/atlas/resolve-active-boulder-session.ts +++ b/src/hooks/atlas/resolve-active-boulder-session.ts @@ -1,5 +1,5 @@ import type { PluginInput } from "@opencode-ai/plugin" -import { getPlanProgress, readBoulderState } from "../../features/boulder-state" +import { getPlanProgress, readBoulderState, resolveBoulderPlanPath } from "../../features/boulder-state" import type { BoulderState, PlanProgress } from "../../features/boulder-state" export async function resolveActiveBoulderSession(input: { @@ -20,7 +20,7 @@ export async function resolveActiveBoulderSession(input: { return null } - const progress = getPlanProgress(boulderState.active_plan) + const progress = getPlanProgress(resolveBoulderPlanPath(input.directory, boulderState)) if (progress.isComplete) { return { boulderState, progress, appendedSession: false } } diff --git a/src/hooks/atlas/tool-execute-after.ts b/src/hooks/atlas/tool-execute-after.ts index 5fd5808ed..586eaf088 100644 --- a/src/hooks/atlas/tool-execute-after.ts +++ b/src/hooks/atlas/tool-execute-after.ts @@ -4,6 +4,7 @@ import { getPlanProgress, getTaskSessionState, readBoulderState, + resolveBoulderPlanPath, upsertTaskSessionState, } from "../../features/boulder-state" import { log } from "../../shared/logger" @@ -98,12 +99,13 @@ export function createToolExecuteAfterHandler(input: { const extractedSessionId = metadataSessionId ?? extractSessionIdFromOutput(toolOutput.output) if (boulderState) { - const progress = getPlanProgress(boulderState.active_plan) + const planPath = resolveBoulderPlanPath(ctx.directory, boulderState) + const progress = getPlanProgress(planPath) const { currentTask, shouldSkipTaskSessionUpdate, shouldIgnoreCurrentSessionId, - } = resolveTaskContext(pendingTaskRef, boulderState.active_plan) + } = resolveTaskContext(pendingTaskRef, planPath) const trackedTaskSession = currentTask ? getTaskSessionState(ctx.directory, currentTask.key) : null @@ -136,7 +138,7 @@ export function createToolExecuteAfterHandler(input: { const originalResponse = toolOutput.output const shouldPauseForApproval = sessionState ? shouldPauseForFinalWaveApproval({ - planPath: boulderState.active_plan, + planPath, taskOutput: originalResponse, sessionState, }) diff --git a/src/hooks/atlas/tool-execute-before.ts b/src/hooks/atlas/tool-execute-before.ts index e00224d84..e6ccae501 100644 --- a/src/hooks/atlas/tool-execute-before.ts +++ b/src/hooks/atlas/tool-execute-before.ts @@ -2,7 +2,7 @@ import { log } from "../../shared/logger" import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive" import { isCallerOrchestrator } from "../../shared/session-utils" import type { PluginInput } from "@opencode-ai/plugin" -import { readBoulderState, readCurrentTopLevelTask } from "../../features/boulder-state" +import { readBoulderState, readCurrentTopLevelTask, resolveBoulderPlanPath } from "../../features/boulder-state" import { HOOK_NAME } from "./hook-name" import { ORCHESTRATOR_DELEGATION_REQUIRED, SINGLE_TASK_DIRECTIVE } from "./system-reminder-templates" import { isSisyphusPath } from "./sisyphus-path" @@ -60,7 +60,7 @@ export function createToolExecuteBeforeHandler(input: { } else { const boulderState = readBoulderState(ctx.directory) const currentTask = boulderState - ? readCurrentTopLevelTask(boulderState.active_plan) + ? readCurrentTopLevelTask(resolveBoulderPlanPath(ctx.directory, boulderState)) : null if (currentTask) { const task = { diff --git a/src/hooks/start-work/context-info-builder.ts b/src/hooks/start-work/context-info-builder.ts index e83474844..4ad7859c0 100644 --- a/src/hooks/start-work/context-info-builder.ts +++ b/src/hooks/start-work/context-info-builder.ts @@ -7,6 +7,7 @@ import { getPlanName, getPlanProgress, readBoulderState, + resolveBoulderPlanPath, writeBoulderState, } from "../../features/boulder-state" import { log } from "../../shared/logger" @@ -150,7 +151,8 @@ function buildExistingSessionContext(params: { directory: string }): string { const { existingState, sessionId, activeAgent, worktreePath, worktreeBlock, directory } = params - const progress = getPlanProgress(existingState.active_plan) + const planPath = resolveBoulderPlanPath(directory, existingState) + const progress = getPlanProgress(planPath) if (progress.isComplete) { return ` ## Previous Work Complete @@ -186,7 +188,7 @@ Looking for new plans...` **Status**: RESUMING existing work **Plan**: ${existingState.plan_name} -**Path**: ${existingState.active_plan} +**Path**: ${planPath} **Progress**: ${progress.completed}/${progress.total} tasks completed **Sessions**: ${existingState.session_ids.length + 1} (current session appended) **Started**: ${existingState.started_at} @@ -197,11 +199,16 @@ Read the plan file and continue from the first unchecked task.` } function shouldDiscoverPlans( + directory: string, existingState: ReturnType, explicitPlanName: string | null, ): boolean { return (!existingState && !explicitPlanName) - || (existingState !== null && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete) + || ( + existingState !== null + && !explicitPlanName + && getPlanProgress(resolveBoulderPlanPath(directory, existingState)).isComplete + ) } function buildPlanDiscoveryContext(params: { @@ -303,7 +310,7 @@ export function buildStartWorkContextInfo(params: { }) } - if (shouldDiscoverPlans(existingState, explicitPlanName)) { + if (shouldDiscoverPlans(ctx.directory, existingState, explicitPlanName)) { return buildPlanDiscoveryContext({ contextInfo, sessionId, diff --git a/src/hooks/start-work/index.test.ts b/src/hooks/start-work/index.test.ts index 36887ee8e..e33a4f6a7 100644 --- a/src/hooks/start-work/index.test.ts +++ b/src/hooks/start-work/index.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test" import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs" -import { join } from "node:path" +import { dirname, join } from "node:path" import { tmpdir } from "node:os" import { randomUUID } from "node:crypto" import { createStartWorkHook } from "./index" @@ -1013,5 +1013,39 @@ You are starting a Sisyphus work session. expect(output.parts[0].text).toContain("subagent") expect(output.parts[0].text).not.toContain("Worktree Setup Required") }) + + test("should show worktree plan progress and path when the mirrored plan exists", async () => { + // given + const mainPlanPath = join(testDir, ".sisyphus", "plans", "resume-worktree-plan.md") + const worktreeDir = join(testDir, "..", `resume-worktree-${randomUUID()}`) + const worktreePlanPath = join(worktreeDir, ".sisyphus", "plans", "resume-worktree-plan.md") + mkdirSync(dirname(mainPlanPath), { recursive: true }) + mkdirSync(dirname(worktreePlanPath), { recursive: true }) + writeFileSync(mainPlanPath, "# Plan\n- [ ] Main repo task\n") + writeFileSync(worktreePlanPath, "# Plan\n- [x] Worktree task 1\n- [ ] Worktree task 2\n") + writeBoulderState(testDir, { + active_plan: mainPlanPath, + started_at: "2026-01-01T00:00:00Z", + session_ids: ["old-session"], + plan_name: "resume-worktree-plan", + worktree_path: worktreeDir, + }) + + const hook = createStartWorkHook(createMockPluginInput()) + const output = { + parts: [{ type: "text", text: createStartWorkPrompt() }], + } + + try { + // when + await hook["chat.message"]({ sessionID: "session-worktree-progress" }, output) + + // then + expect(output.parts[0].text).toContain(worktreePlanPath) + expect(output.parts[0].text).toContain("1/2 tasks completed") + } finally { + rmSync(worktreeDir, { recursive: true, force: true }) + } + }) }) })