diff --git a/src/hooks/start-work/context-info-builder.test.ts b/src/hooks/start-work/context-info-builder.test.ts new file mode 100644 index 000000000..ffc08978b --- /dev/null +++ b/src/hooks/start-work/context-info-builder.test.ts @@ -0,0 +1,171 @@ +/// + +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { randomUUID } from "node:crypto" +import { join } from "node:path" +import { tmpdir } from "node:os" +import { buildStartWorkContextInfo } from "./context-info-builder" +import { + addBoulderWork, + createBoulderState, + getBoulderFilePath, + getWorkByPlanName, + readBoulderState, + writeBoulderState, +} from "../../features/boulder-state" +import * as boulderState from "../../features/boulder-state" + +describe("buildStartWorkContextInfo", () => { + let testDirectory = "" + + function createPluginInput() { + return { + directory: testDirectory, + } as never + } + + function writePlan(planName: string, content: string): string { + const plansDirectory = join(testDirectory, ".sisyphus", "plans") + mkdirSync(plansDirectory, { recursive: true }) + const planPath = join(plansDirectory, `${planName}.md`) + writeFileSync(planPath, content) + return planPath + } + + function readExistingState() { + return readBoulderState(testDirectory) + } + + beforeEach(() => { + testDirectory = join(tmpdir(), `context-info-builder-${randomUUID()}`) + mkdirSync(testDirectory, { recursive: true }) + }) + + afterEach(() => { + if (existsSync(testDirectory)) { + rmSync(testDirectory, { recursive: true, force: true }) + } + }) + + test("lists multiple active works and asks agent to choose resume vs new when no explicit plan", () => { + // given + const clearSpy = spyOn(boulderState, "clearBoulderState") + const planAPath = writePlan("plan-alpha", "## TODOs\n- [ ] 1. Alpha") + const planBPath = writePlan("plan-beta", "## TODOs\n- [ ] 1. Beta") + const initialState = createBoulderState(planAPath, "session-a", "atlas", "/tmp/worktree-a") + writeBoulderState(testDirectory, initialState) + addBoulderWork(testDirectory, { + planPath: planBPath, + sessionId: "session-b", + agent: "atlas", + worktreePath: "/tmp/worktree-b", + }) + + // when + const contextInfo = buildStartWorkContextInfo({ + ctx: createPluginInput(), + explicitPlanName: null, + existingState: readExistingState(), + sessionId: "session-current", + timestamp: "2026-05-11T00:00:00.000Z", + activeAgent: "atlas", + worktreePath: undefined, + worktreeBlock: "", + }) + + // then + expect(contextInfo).toContain("plan-alpha") + expect(contextInfo).toContain("plan-beta") + expect(contextInfo).toContain("Use the Question tool") + expect(clearSpy).toHaveBeenCalledTimes(0) + }) + + test("auto-resumes when exactly one active work exists and no explicit plan", () => { + // given + const clearSpy = spyOn(boulderState, "clearBoulderState") + const planPath = writePlan("single-active-plan", "## TODOs\n- [ ] 1. Single task") + const initialState = createBoulderState(planPath, "session-a", "atlas", "/tmp/worktree-single") + writeBoulderState(testDirectory, initialState) + + // when + const contextInfo = buildStartWorkContextInfo({ + ctx: createPluginInput(), + explicitPlanName: null, + existingState: readExistingState(), + sessionId: "session-current", + timestamp: "2026-05-11T00:00:00.000Z", + activeAgent: "atlas", + worktreePath: undefined, + worktreeBlock: "", + }) + + // then + expect(contextInfo).toContain("RESUMING existing work") + expect(contextInfo).toContain("single-active-plan") + expect(contextInfo).not.toContain("Use the Question tool") + expect(clearSpy).toHaveBeenCalledTimes(0) + }) + + test("explicit plan selects matching work only and never clears boulder state", () => { + // given + const clearSpy = spyOn(boulderState, "clearBoulderState") + const planAPath = writePlan("explicit-plan-a", "## TODOs\n- [ ] 1. A") + const planBPath = writePlan("explicit-plan-b", "## TODOs\n- [ ] 1. B") + const initialState = createBoulderState(planAPath, "session-a", "atlas", "/tmp/worktree-a") + writeBoulderState(testDirectory, initialState) + addBoulderWork(testDirectory, { + planPath: planBPath, + sessionId: "session-b", + agent: "atlas", + worktreePath: "/tmp/worktree-b", + }) + + // when + const contextInfo = buildStartWorkContextInfo({ + ctx: createPluginInput(), + explicitPlanName: "explicit-plan-a", + existingState: readExistingState(), + sessionId: "session-current", + timestamp: "2026-05-11T00:00:00.000Z", + activeAgent: "atlas", + worktreePath: "/tmp/worktree-a", + worktreeBlock: "", + }) + + // then + expect(contextInfo).toContain("explicit-plan-a") + expect(contextInfo).not.toContain("explicit-plan-b") + expect(clearSpy).toHaveBeenCalledTimes(0) + + const selectedWork = getWorkByPlanName(testDirectory, "explicit-plan-a", { worktreePath: "/tmp/worktree-a" }) + const nextState = readBoulderState(testDirectory) + expect(selectedWork).not.toBeNull() + expect(nextState?.active_work_id).toBe(selectedWork?.work_id) + }) + + test("falls back to auto-select latest plan when no works exist", () => { + // given + const clearSpy = spyOn(boulderState, "clearBoulderState") + const coldStartPlanPath = writePlan("cold-start-plan", "## TODOs\n- [ ] 1. Cold start") + + // when + const contextInfo = buildStartWorkContextInfo({ + ctx: createPluginInput(), + explicitPlanName: null, + existingState: null, + sessionId: "session-current", + timestamp: "2026-05-11T00:00:00.000Z", + activeAgent: "atlas", + worktreePath: undefined, + worktreeBlock: "", + }) + + // then + expect(contextInfo).toContain("Auto-Selected Plan") + expect(contextInfo).toContain("cold-start-plan") + expect(contextInfo).toContain(coldStartPlanPath) + expect(existsSync(getBoulderFilePath(testDirectory))).toBe(true) + expect(clearSpy).toHaveBeenCalledTimes(0) + }) +}) diff --git a/src/hooks/start-work/context-info-builder.ts b/src/hooks/start-work/context-info-builder.ts index 4ad7859c0..5ea4d8fce 100644 --- a/src/hooks/start-work/context-info-builder.ts +++ b/src/hooks/start-work/context-info-builder.ts @@ -1,13 +1,17 @@ import { statSync } from "node:fs" import { appendSessionId, - clearBoulderState, + addBoulderWork, createBoulderState, findPrometheusPlans, + getActiveWorks, getPlanName, getPlanProgress, + getWorkByPlanName, + getWorkResumeOptions, readBoulderState, resolveBoulderPlanPath, + selectActiveWork, writeBoulderState, } from "../../features/boulder-state" import { log } from "../../shared/logger" @@ -99,9 +103,73 @@ Ask the user which plan to work on.` No incomplete plans available. Create a new plan using the Prometheus agent.` } +function formatElapsedHuman(elapsedMs: number | undefined): string { + if (typeof elapsedMs !== "number" || elapsedMs <= 0) { + return "running" + } + + const totalSeconds = Math.floor(elapsedMs / 1000) + const seconds = totalSeconds % 60 + const totalMinutes = Math.floor(totalSeconds / 60) + const minutes = totalMinutes % 60 + const hours = Math.floor(totalMinutes / 60) + if (hours > 0) { + return `${hours}h ${minutes}m ${seconds}s` + } + if (minutes > 0) { + return `${minutes}m ${seconds}s` + } + return `${seconds}s` +} + +function buildMultipleActiveWorksContext(params: { + resumeOptions: ReturnType + sessionId: string + timestamp: string +}): string { + const { resumeOptions, sessionId, timestamp } = params + const optionList = resumeOptions + .map((option, index) => `${index + 1}. ${option.plan_name} - ${option.progress.completed}/${option.progress.total} (${option.progress.total === 0 ? 0 : Math.floor((option.progress.completed / option.progress.total) * 100)}%) - elapsed: ${formatElapsedHuman(option.elapsed_ms)} - worktree: ${option.worktree_path ?? "current directory"} - sessions: ${option.session_count}`) + .join("\n") + + return ` + +## Multiple Active Works Found + +Current Time: ${timestamp} +Session ID: ${sessionId} + +${optionList} + +Use the Question tool to ask the user which plan to resume. +- If the user chooses one option, run /start-work {plan-name} for that plan. +- If the user chooses to start a new plan, proceed with cold-start auto-selection flow. +` +} + +function createNewWorkOrInitialize(params: { + directory: string + planPath: string + sessionId: string + activeAgent: string + worktreePath: string | undefined +}): void { + const { directory, planPath, sessionId, activeAgent, worktreePath } = params + const created = addBoulderWork(directory, { + planPath, + sessionId, + agent: activeAgent, + worktreePath, + }) + + if (!created) { + const initializedState = createBoulderState(planPath, sessionId, activeAgent, worktreePath) + writeBoulderState(directory, initializedState) + } +} + function buildExplicitPlanContext(params: { explicitPlanName: string - existingState: ReturnType sessionId: string timestamp: string activeAgent: string @@ -109,9 +177,24 @@ function buildExplicitPlanContext(params: { worktreeBlock: string directory: string }): string { - const { explicitPlanName, existingState, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock, directory } = params + const { explicitPlanName, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock, directory } = params log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, { sessionID: sessionId }) + const matchedWork = getWorkByPlanName(directory, explicitPlanName, { worktreePath }) + if (matchedWork) { + const selectedState = selectActiveWork(directory, matchedWork.work_id) + if (selectedState) { + return buildExistingSessionContext({ + existingState: selectedState, + sessionId, + activeAgent, + worktreePath, + worktreeBlock, + directory, + }) + } + } + const allPlans = findPrometheusPlans(directory) const matchedPlan = findPlanByName(allPlans, explicitPlanName) if (!matchedPlan) { @@ -127,9 +210,13 @@ function buildExplicitPlanContext(params: { All ${progress.total} tasks are done. Create a new plan using the Prometheus agent.` } - if (existingState) { - clearBoulderState(directory) - } + createNewWorkOrInitialize({ + directory, + planPath: matchedPlan, + sessionId, + activeAgent, + worktreePath, + }) return buildAutoSelectedPlanContext({ planPath: matchedPlan, @@ -287,11 +374,48 @@ export function buildStartWorkContextInfo(params: { }): string { const { ctx, explicitPlanName, existingState, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock } = params + const resumeOptions = getWorkResumeOptions(ctx.directory) + .filter((option) => option.status === "active" || option.status === "paused") + + if (!explicitPlanName && resumeOptions.length > 1) { + return buildMultipleActiveWorksContext({ + resumeOptions, + sessionId, + timestamp, + }) + } + + if (!explicitPlanName && resumeOptions.length === 1) { + const onlyOption = resumeOptions[0] + const selectedState = selectActiveWork(ctx.directory, onlyOption.work_id) + if (selectedState) { + return buildExistingSessionContext({ + existingState: selectedState, + sessionId, + activeAgent, + worktreePath, + worktreeBlock, + directory: ctx.directory, + }) + } + } + + if (!explicitPlanName && resumeOptions.length === 0 && getActiveWorks(ctx.directory).length === 0) { + return buildPlanDiscoveryContext({ + contextInfo: "", + sessionId, + timestamp, + activeAgent, + worktreePath, + worktreeBlock, + directory: ctx.directory, + }) + } + let contextInfo = "" if (explicitPlanName) { contextInfo = buildExplicitPlanContext({ explicitPlanName, - existingState, sessionId, timestamp, activeAgent,