Merge pull request #3943 from code-yeongyu/feature/boulder-evolution-and-discipline-agents
feat: boulder evolution + discipline agents (multi-work, timings, CLI, hooks, Oracle phase gates, no-excuses retry)
This commit is contained in:
@@ -8,6 +8,7 @@ export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) {
|
||||
const sessions = new Map<string, SessionState>()
|
||||
const pendingFilePaths = new Map<string, string>()
|
||||
const pendingTaskRefs = new Map<string, PendingTaskRef>()
|
||||
const pendingPlanSnapshots = new Map<string, string>()
|
||||
const autoCommit = options?.autoCommit ?? true
|
||||
|
||||
function getState(sessionID: string): SessionState {
|
||||
@@ -25,12 +26,14 @@ export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) {
|
||||
ctx,
|
||||
pendingFilePaths,
|
||||
pendingTaskRefs,
|
||||
pendingPlanSnapshots,
|
||||
isCallerOrchestrator: options?.isCallerOrchestrator,
|
||||
}),
|
||||
"tool.execute.after": createToolExecuteAfterHandler({
|
||||
ctx,
|
||||
pendingFilePaths,
|
||||
pendingTaskRefs,
|
||||
pendingPlanSnapshots,
|
||||
autoCommit,
|
||||
getState,
|
||||
isCallerOrchestrator: options?.isCallerOrchestrator,
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { appendSessionId, type BoulderState, resolveBoulderPlanPath, upsertTaskSessionState } from "../../features/boulder-state"
|
||||
import {
|
||||
appendSessionId,
|
||||
appendSessionIdForWork,
|
||||
getWorkForSession,
|
||||
type BoulderState,
|
||||
resolveBoulderPlanPath,
|
||||
resolveBoulderPlanPathForWork,
|
||||
upsertTaskSessionState,
|
||||
upsertTaskSessionStateForWork,
|
||||
} from "../../features/boulder-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
import { extractSessionIdFromOutput, validateSubagentSessionId } from "./subagent-session-id"
|
||||
@@ -19,8 +28,13 @@ export async function syncBackgroundLaunchSessionTracking(input: {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof toolInput.sessionID !== "string") {
|
||||
return
|
||||
}
|
||||
|
||||
const trackedWork = getWorkForSession(ctx.directory, toolInput.sessionID)
|
||||
const extractedSessionId = metadataSessionId ?? extractSessionIdFromOutput(toolOutput.output)
|
||||
const lineageSessionIDs = boulderState.session_ids
|
||||
const lineageSessionIDs = trackedWork?.session_ids ?? boulderState.session_ids
|
||||
const subagentSessionId = await validateSubagentSessionId({
|
||||
client: ctx.client,
|
||||
sessionID: extractedSessionId,
|
||||
@@ -36,22 +50,39 @@ export async function syncBackgroundLaunchSessionTracking(input: {
|
||||
return
|
||||
}
|
||||
|
||||
appendSessionId(ctx.directory, trackedSessionId, "appended")
|
||||
if (trackedWork) {
|
||||
appendSessionIdForWork(ctx.directory, trackedWork.work_id, trackedSessionId, "appended")
|
||||
} else {
|
||||
appendSessionId(ctx.directory, trackedSessionId, "appended")
|
||||
}
|
||||
|
||||
const { currentTask, shouldSkipTaskSessionUpdate } = resolveTaskContext(
|
||||
pendingTaskRef,
|
||||
resolveBoulderPlanPath(ctx.directory, boulderState),
|
||||
trackedWork
|
||||
? resolveBoulderPlanPathForWork(ctx.directory, trackedWork)
|
||||
: resolveBoulderPlanPath(ctx.directory, boulderState),
|
||||
)
|
||||
|
||||
if (currentTask && !shouldSkipTaskSessionUpdate) {
|
||||
upsertTaskSessionState(ctx.directory, {
|
||||
taskKey: currentTask.key,
|
||||
taskLabel: currentTask.label,
|
||||
taskTitle: currentTask.title,
|
||||
sessionId: trackedSessionId,
|
||||
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
|
||||
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
|
||||
})
|
||||
if (trackedWork) {
|
||||
upsertTaskSessionStateForWork(ctx.directory, trackedWork.work_id, {
|
||||
taskKey: currentTask.key,
|
||||
taskLabel: currentTask.label,
|
||||
taskTitle: currentTask.title,
|
||||
sessionId: trackedSessionId,
|
||||
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
|
||||
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
|
||||
})
|
||||
} else {
|
||||
upsertTaskSessionState(ctx.directory, {
|
||||
taskKey: currentTask.key,
|
||||
taskLabel: currentTask.label,
|
||||
taskTitle: currentTask.title,
|
||||
sessionId: trackedSessionId,
|
||||
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
|
||||
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Background launch session tracked`, {
|
||||
@@ -81,17 +112,3 @@ async function resolveFallbackTrackedSessionId(input: {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveSessionOrigin(
|
||||
ctx: PluginInput,
|
||||
sessionID: string,
|
||||
): Promise<"direct" | "appended"> {
|
||||
try {
|
||||
const session = await ctx.client.session.get({ path: { id: sessionID } })
|
||||
return typeof session.data?.parentID === "string" && session.data.parentID.length > 0
|
||||
? "appended"
|
||||
: "direct"
|
||||
} catch {
|
||||
return "appended"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
|
||||
|
||||
const { createAtlasHook } = await import("./index")
|
||||
|
||||
describe("atlas hook idle-event complete boulder", () => {
|
||||
let testDirectory = ""
|
||||
|
||||
beforeEach(() => {
|
||||
testDirectory = join(tmpdir(), `atlas-idle-complete-${randomUUID()}`)
|
||||
if (!existsSync(testDirectory)) {
|
||||
mkdirSync(testDirectory, { recursive: true })
|
||||
}
|
||||
clearBoulderState(testDirectory)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearBoulderState(testDirectory)
|
||||
if (existsSync(testDirectory)) {
|
||||
rmSync(testDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("marks work completed with ended_at and elapsed_ms when progress is complete", async () => {
|
||||
// given
|
||||
const sessionID = "ses_complete"
|
||||
const planPath = join(testDirectory, "complete-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [x] 1. Done\n", "utf-8")
|
||||
writeBoulderState(testDirectory, {
|
||||
schema_version: 2,
|
||||
active_work_id: "work-complete",
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00.000Z",
|
||||
session_ids: [sessionID],
|
||||
plan_name: "complete-plan",
|
||||
works: {
|
||||
"work-complete": {
|
||||
work_id: "work-complete",
|
||||
active_plan: planPath,
|
||||
plan_name: "complete-plan",
|
||||
started_at: "2026-01-02T10:00:00.000Z",
|
||||
session_ids: [sessionID],
|
||||
status: "active",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const hook = createAtlasHook({
|
||||
directory: testDirectory,
|
||||
client: {
|
||||
session: {
|
||||
get: async () => ({ data: { id: sessionID } }),
|
||||
messages: async () => ({ data: [] }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
promptAsync: async () => ({ data: {} }),
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createAtlasHook>[0])
|
||||
|
||||
// when
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID },
|
||||
},
|
||||
})
|
||||
|
||||
// then
|
||||
const work = readBoulderState(testDirectory)?.works?.["work-complete"]
|
||||
expect(work?.status).toBe("completed")
|
||||
expect(work?.ended_at).toBeString()
|
||||
expect((work?.elapsed_ms ?? 0) > 0).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { createBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
|
||||
import { _resetForTesting, registerAgentName } from "../../features/claude-code-session-state"
|
||||
import { handleAtlasSessionIdle } from "./idle-event"
|
||||
import type { SessionState } from "./types"
|
||||
|
||||
describe("handleAtlasSessionIdle completion nudge", () => {
|
||||
const SESSION_ID = "session-main-1"
|
||||
|
||||
let testDirectory = ""
|
||||
|
||||
beforeEach(() => {
|
||||
testDirectory = join(tmpdir(), `atlas-idle-complete-${randomUUID()}`)
|
||||
if (!existsSync(testDirectory)) {
|
||||
mkdirSync(testDirectory, { recursive: true })
|
||||
}
|
||||
_resetForTesting()
|
||||
registerAgentName("atlas")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(testDirectory)) {
|
||||
rmSync(testDirectory, { recursive: true, force: true })
|
||||
}
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
it("injects BOULDER COMPLETE prompt once per work with substituted elapsed and task breakdown", async () => {
|
||||
// given
|
||||
const planPath = join(testDirectory, "plan.md")
|
||||
writeFileSync(planPath, "## TODOs\n- [x] 1. Parse input\n- [x] 2. Save output\n")
|
||||
|
||||
const boulder = createBoulderState(planPath, SESSION_ID, "atlas")
|
||||
const workId = boulder.active_work_id
|
||||
if (!workId) {
|
||||
throw new Error("Expected active_work_id")
|
||||
}
|
||||
|
||||
const work = boulder.works?.[workId]
|
||||
if (!work) {
|
||||
throw new Error("Expected active work")
|
||||
}
|
||||
|
||||
work.elapsed_ms = 65_000
|
||||
boulder.elapsed_ms = 65_000
|
||||
work.task_sessions = {
|
||||
"todo:2": {
|
||||
task_key: "todo:2",
|
||||
task_label: "2",
|
||||
task_title: "Save output",
|
||||
session_id: "sub-2",
|
||||
elapsed_ms: 4_000,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
"todo:1": {
|
||||
task_key: "todo:1",
|
||||
task_label: "1",
|
||||
task_title: "Parse input",
|
||||
session_id: "sub-1",
|
||||
elapsed_ms: 61_000,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
boulder.task_sessions = work.task_sessions
|
||||
|
||||
writeBoulderState(testDirectory, boulder)
|
||||
|
||||
const promptRequests: Array<{ body?: { parts?: Array<{ text?: string }> } }> = []
|
||||
const promptAsyncMock = mock(async (request: { body?: { parts?: Array<{ text?: string }> } }) => {
|
||||
promptRequests.push(request)
|
||||
return { data: {} }
|
||||
})
|
||||
|
||||
const ctx = {
|
||||
directory: testDirectory,
|
||||
client: {
|
||||
session: {
|
||||
promptAsync: promptAsyncMock,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginInput
|
||||
|
||||
const sessionStateById = new Map<string, SessionState>()
|
||||
const getState = (sessionId: string): SessionState => {
|
||||
let state = sessionStateById.get(sessionId)
|
||||
if (!state) {
|
||||
state = { promptFailureCount: 0 }
|
||||
sessionStateById.set(sessionId, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// when
|
||||
await handleAtlasSessionIdle({
|
||||
ctx,
|
||||
sessionID: SESSION_ID,
|
||||
getState,
|
||||
})
|
||||
|
||||
await handleAtlasSessionIdle({
|
||||
ctx,
|
||||
sessionID: SESSION_ID,
|
||||
getState,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(promptAsyncMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
const promptText = promptRequests[0]?.body?.parts?.[0]?.text ?? ""
|
||||
expect(promptText).toContain("BOULDER COMPLETE")
|
||||
expect(promptText).toContain("Total elapsed: 1m 5s")
|
||||
expect(promptText).toContain("- 1 Parse input: 1m 1s")
|
||||
expect(promptText).toContain("- 2 Save output: 4s")
|
||||
expect(promptText).not.toContain("{ELAPSED_HUMAN}")
|
||||
|
||||
const persistedState = getState(SESSION_ID)
|
||||
expect(persistedState.boulderCompletionNudgedAt?.[workId]).toBeNumber()
|
||||
expect(readBoulderState(testDirectory)?.works?.[workId]?.status).toBe("completed")
|
||||
})
|
||||
})
|
||||
@@ -1,20 +1,29 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import {
|
||||
completeBoulder,
|
||||
formatDurationHuman,
|
||||
getPlanProgress,
|
||||
getWorkForSession,
|
||||
getTaskSessionState,
|
||||
readBoulderState,
|
||||
readCurrentTopLevelTask,
|
||||
resolveBoulderPlanPath,
|
||||
} from "../../features/boulder-state"
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import {
|
||||
getSessionAgent,
|
||||
isAgentRegistered,
|
||||
resolveRegisteredAgentName,
|
||||
} from "../../features/claude-code-session-state"
|
||||
import { getLastAgentFromSession } from "./session-last-agent"
|
||||
import { isSessionInBoulderLineage } from "./boulder-session-lineage"
|
||||
import { createInternalAgentTextPart } from "../../shared"
|
||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
||||
import { log } from "../../shared/logger"
|
||||
import { settleAfterSessionIdle } from "../shared/session-idle-settle"
|
||||
import { injectBoulderContinuation } from "./boulder-continuation-injector"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
import { resolveActiveBoulderSession } from "./resolve-active-boulder-session"
|
||||
import { BOULDER_COMPLETE_PROMPT } from "./system-reminder-templates"
|
||||
import type { AtlasHookOptions, SessionState } from "./types"
|
||||
|
||||
const CONTINUATION_COOLDOWN_MS = 5000
|
||||
@@ -22,6 +31,11 @@ const FAILURE_BACKOFF_MS = 5 * 60 * 1000
|
||||
const MAX_CONSECUTIVE_PROMPT_FAILURES = 10
|
||||
const RETRY_DELAY_MS = CONTINUATION_COOLDOWN_MS + 1000
|
||||
|
||||
function getTaskLabelSortValue(taskLabel: string): number {
|
||||
const parsed = Number.parseInt(taskLabel.replace(/[^0-9]/g, ""), 10)
|
||||
return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed
|
||||
}
|
||||
|
||||
function hasRunningBackgroundTasks(sessionID: string, options?: AtlasHookOptions): boolean {
|
||||
const backgroundManager = options?.backgroundManager
|
||||
return backgroundManager
|
||||
@@ -205,6 +219,7 @@ export async function handleAtlasSessionIdle(input: {
|
||||
sessionID: string
|
||||
}): Promise<void> {
|
||||
const { ctx, options, getState, sessionID } = input
|
||||
const sessionState = getState(sessionID)
|
||||
|
||||
log(`[${HOOK_NAME}] session.idle`, { sessionID })
|
||||
|
||||
@@ -220,6 +235,68 @@ export async function handleAtlasSessionIdle(input: {
|
||||
|
||||
const { boulderState, progress, appendedSession } = activeBoulderSession
|
||||
if (progress.isComplete) {
|
||||
const work = getWorkForSession(ctx.directory, sessionID)
|
||||
if (work) {
|
||||
completeBoulder(ctx.directory, work.work_id)
|
||||
} else {
|
||||
completeBoulder(ctx.directory, boulderState.active_work_id)
|
||||
}
|
||||
|
||||
if (!work || work.status === "abandoned") {
|
||||
log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name })
|
||||
return
|
||||
}
|
||||
|
||||
if (sessionState.boulderCompletionNudgedAt?.[work.work_id]) {
|
||||
log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name })
|
||||
return
|
||||
}
|
||||
|
||||
const elapsedMilliseconds = work.elapsed_ms ?? (Date.now() - new Date(work.started_at).getTime())
|
||||
const elapsedHuman = formatDurationHuman(elapsedMilliseconds)
|
||||
|
||||
const taskBreakdown = Object.values(work.task_sessions ?? {})
|
||||
.sort((left, right) => {
|
||||
const leftSortValue = getTaskLabelSortValue(left.task_label)
|
||||
const rightSortValue = getTaskLabelSortValue(right.task_label)
|
||||
if (leftSortValue !== rightSortValue) {
|
||||
return leftSortValue - rightSortValue
|
||||
}
|
||||
|
||||
return left.task_label.localeCompare(right.task_label)
|
||||
})
|
||||
.map((task) => {
|
||||
if (typeof task.elapsed_ms === "number") {
|
||||
return `- ${task.task_label} ${task.task_title}: ${formatDurationHuman(task.elapsed_ms)}`
|
||||
}
|
||||
|
||||
return `- ${task.task_label} ${task.task_title}: (no timing)`
|
||||
})
|
||||
.join("\n")
|
||||
|
||||
const prompt = BOULDER_COMPLETE_PROMPT
|
||||
.replace(/{PLAN_NAME}/g, work.plan_name)
|
||||
.replace(/{ELAPSED_HUMAN}/g, elapsedHuman)
|
||||
.replace(/{TASK_BREAKDOWN}/g, taskBreakdown.length > 0 ? taskBreakdown : "- (no task timings)")
|
||||
|
||||
const atlasAgent = resolveRegisteredAgentName(
|
||||
boulderState.agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined),
|
||||
)
|
||||
if (atlasAgent && isAgentRegistered(atlasAgent)) {
|
||||
await ctx.client.session.promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: atlasAgent,
|
||||
parts: [createInternalAgentTextPart(prompt)],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
sessionState.boulderCompletionNudgedAt = {
|
||||
...(sessionState.boulderCompletionNudgedAt ?? {}),
|
||||
[work.work_id]: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name })
|
||||
return
|
||||
}
|
||||
@@ -246,7 +323,6 @@ export async function handleAtlasSessionIdle(input: {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionState = getState(sessionID)
|
||||
const now = Date.now()
|
||||
|
||||
if (sessionState.waitingForFinalWaveApproval) {
|
||||
|
||||
@@ -1490,7 +1490,7 @@ session_id: ses_untrusted_999
|
||||
expect(callArgs.body.parts[0].text).toContain("2 remaining")
|
||||
})
|
||||
|
||||
test("should not inject when boulder plan is complete", async () => {
|
||||
test("should inject completion nudge when boulder plan is complete", async () => {
|
||||
// given - boulder state with complete plan
|
||||
const planPath = join(TEST_DIR, "complete-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n- [x] Task 1\n- [x] Task 2")
|
||||
@@ -1514,11 +1514,11 @@ session_id: ses_untrusted_999
|
||||
},
|
||||
})
|
||||
|
||||
// then - should not call prompt
|
||||
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
||||
// then
|
||||
expect(mockInput._promptMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test("should not inject when the mirrored worktree plan is complete even if the main repo plan is stale", async () => {
|
||||
test("should inject completion nudge when 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()}`)
|
||||
@@ -1549,7 +1549,7 @@ session_id: ses_untrusted_999
|
||||
})
|
||||
|
||||
// then
|
||||
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
||||
expect(mockInput._promptMock).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
rmSync(worktreeDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
@@ -131,4 +131,77 @@ describe("resolveActiveBoulderSession", () => {
|
||||
rmSync(worktreeDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("uses work resolved by session id when works map is present", async () => {
|
||||
// given
|
||||
const legacyPlanPath = join(testDirectory, "legacy-plan.md")
|
||||
const workAPlanPath = join(testDirectory, "work-a-plan.md")
|
||||
const workBPlanPath = join(testDirectory, "work-b-plan.md")
|
||||
writeFileSync(legacyPlanPath, "# Plan\n- [ ] Legacy\n", "utf-8")
|
||||
writeFileSync(workAPlanPath, "# Plan\n- [ ] Work A\n", "utf-8")
|
||||
writeFileSync(workBPlanPath, "# Plan\n- [x] Work B\n", "utf-8")
|
||||
|
||||
writeBoulderState(testDirectory, {
|
||||
schema_version: 2,
|
||||
active_work_id: "work-a",
|
||||
active_plan: legacyPlanPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["ses_legacy"],
|
||||
plan_name: "legacy-plan",
|
||||
works: {
|
||||
"work-a": {
|
||||
work_id: "work-a",
|
||||
active_plan: workAPlanPath,
|
||||
plan_name: "work-a-plan",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["ses_work_a"],
|
||||
status: "active",
|
||||
},
|
||||
"work-b": {
|
||||
work_id: "work-b",
|
||||
active_plan: workBPlanPath,
|
||||
plan_name: "work-b-plan",
|
||||
started_at: "2026-01-02T11:00:00Z",
|
||||
session_ids: ["ses_work_b"],
|
||||
status: "active",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// when
|
||||
const result = await resolveActiveBoulderSession({
|
||||
client: { session: { get: async () => ({ data: {} }) } } as never,
|
||||
directory: testDirectory,
|
||||
sessionID: "ses_work_b",
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.boulderState.active_plan).toBe(workBPlanPath)
|
||||
expect(result?.progress.isComplete).toBe(true)
|
||||
})
|
||||
|
||||
test("falls back to top-level mirror when works map is missing", async () => {
|
||||
// given
|
||||
const legacyPlanPath = join(testDirectory, "legacy-only-plan.md")
|
||||
writeFileSync(legacyPlanPath, "# Plan\n- [ ] Task 1\n", "utf-8")
|
||||
writeBoulderState(testDirectory, {
|
||||
active_plan: legacyPlanPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["ses_legacy_only"],
|
||||
plan_name: "legacy-only-plan",
|
||||
})
|
||||
|
||||
// when
|
||||
const result = await resolveActiveBoulderSession({
|
||||
client: { session: { get: async () => ({ data: {} }) } } as never,
|
||||
directory: testDirectory,
|
||||
sessionID: "ses_legacy_only",
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.boulderState.active_plan).toBe(legacyPlanPath)
|
||||
expect(result?.progress.isComplete).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { getPlanProgress, readBoulderState, resolveBoulderPlanPath } from "../../features/boulder-state"
|
||||
import {
|
||||
getPlanProgress,
|
||||
getWorkForSession,
|
||||
readBoulderState,
|
||||
resolveBoulderPlanPath,
|
||||
resolveBoulderPlanPathForWork,
|
||||
} from "../../features/boulder-state"
|
||||
import type { BoulderState, PlanProgress } from "../../features/boulder-state"
|
||||
|
||||
export async function resolveActiveBoulderSession(input: {
|
||||
@@ -16,14 +22,37 @@ export async function resolveActiveBoulderSession(input: {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!boulderState.session_ids.includes(input.sessionID)) {
|
||||
const sessionWork = getWorkForSession(input.directory, input.sessionID)
|
||||
if (!sessionWork && !boulderState.session_ids.includes(input.sessionID)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const progress = getPlanProgress(resolveBoulderPlanPath(input.directory, boulderState))
|
||||
const nextBoulderState: BoulderState = sessionWork
|
||||
? {
|
||||
...boulderState,
|
||||
active_plan: sessionWork.active_plan,
|
||||
plan_name: sessionWork.plan_name,
|
||||
status: sessionWork.status,
|
||||
started_at: sessionWork.started_at,
|
||||
ended_at: sessionWork.ended_at,
|
||||
elapsed_ms: sessionWork.elapsed_ms,
|
||||
updated_at: sessionWork.updated_at,
|
||||
session_ids: [...sessionWork.session_ids],
|
||||
session_origins: sessionWork.session_origins ? { ...sessionWork.session_origins } : {},
|
||||
agent: sessionWork.agent,
|
||||
worktree_path: sessionWork.worktree_path,
|
||||
task_sessions: sessionWork.task_sessions ? { ...sessionWork.task_sessions } : {},
|
||||
}
|
||||
: boulderState
|
||||
|
||||
const progress = getPlanProgress(
|
||||
sessionWork
|
||||
? resolveBoulderPlanPathForWork(input.directory, sessionWork)
|
||||
: resolveBoulderPlanPath(input.directory, nextBoulderState),
|
||||
)
|
||||
if (progress.isComplete) {
|
||||
return { boulderState, progress, appendedSession: false }
|
||||
return { boulderState: nextBoulderState, progress, appendedSession: false }
|
||||
}
|
||||
|
||||
return { boulderState, progress, appendedSession: false }
|
||||
return { boulderState: nextBoulderState, progress, appendedSession: false }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import {
|
||||
BOULDER_COMPLETE_PROMPT,
|
||||
BOULDER_CONTINUATION_PROMPT,
|
||||
SINGLE_TASK_DIRECTIVE,
|
||||
VERIFICATION_REMINDER,
|
||||
@@ -47,6 +48,14 @@ describe("VERIFICATION_REMINDER", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("BOULDER_COMPLETE_PROMPT", () => {
|
||||
it("contains the required placeholders", () => {
|
||||
expect(BOULDER_COMPLETE_PROMPT).toContain("{PLAN_NAME}")
|
||||
expect(BOULDER_COMPLETE_PROMPT).toContain("{ELAPSED_HUMAN}")
|
||||
expect(BOULDER_COMPLETE_PROMPT).toContain("{TASK_BREAKDOWN}")
|
||||
})
|
||||
})
|
||||
|
||||
describe("VERIFICATION_REMINDER_GEMINI", () => {
|
||||
it("contains node_modules exclusion pathspec in git diff command", () => {
|
||||
expect(VERIFICATION_REMINDER_GEMINI).toContain(":!node_modules")
|
||||
|
||||
@@ -33,6 +33,17 @@ RULES:
|
||||
- Do not stop until all tasks are complete
|
||||
- If blocked, document the blocker and move to the next task`
|
||||
|
||||
export const BOULDER_COMPLETE_PROMPT = `<system-reminder>
|
||||
BOULDER COMPLETE: plan "{PLAN_NAME}" is fully checked.
|
||||
|
||||
Total elapsed: {ELAPSED_HUMAN}
|
||||
|
||||
Per-task breakdown:
|
||||
{TASK_BREAKDOWN}
|
||||
|
||||
Per your <boulder_completion_response> instructions, print the final ORCHESTRATION COMPLETE summary in your next turn. This nudge fires at most once.
|
||||
</system-reminder>`
|
||||
|
||||
export const VERIFICATION_REMINDER = `**THE SUBAGENT JUST CLAIMED THIS TASK IS DONE. THEY ARE PROBABLY LYING.**
|
||||
|
||||
Subagents say "done" when code has errors, tests pass trivially, logic is wrong,
|
||||
|
||||
@@ -424,6 +424,94 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
|
||||
expect(readBoulderState(testDirectory)?.session_ids).not.toContain(sessionID)
|
||||
expect(readBoulderState(testDirectory)?.session_ids).not.toContain(childSessionID)
|
||||
})
|
||||
|
||||
it("#then it should append launched child to the session-resolved work", async () => {
|
||||
const parentSessionID = "ses_parent_for_work"
|
||||
const childSessionID = "ses_child_for_work"
|
||||
const planPathA = join(testDirectory, "background-launch-work-a.md")
|
||||
const planPathB = join(testDirectory, "background-launch-work-b.md")
|
||||
const project = createProject()
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => createSessionGetResult(undefined),
|
||||
},
|
||||
} as unknown as PluginInput["client"]
|
||||
|
||||
spyOn(client.session, "get").mockImplementation((input) => Promise.resolve(
|
||||
createSessionGetResult(input?.path?.id === childSessionID ? parentSessionID : undefined),
|
||||
) as never)
|
||||
|
||||
writeFileSync(planPathA, "# Plan\n\n## TODOs\n- [ ] 1. Work A\n")
|
||||
writeFileSync(planPathB, "# Plan\n\n## TODOs\n- [ ] 1. Work B\n")
|
||||
|
||||
writeBoulderState(testDirectory, {
|
||||
schema_version: 2,
|
||||
active_work_id: "work-a",
|
||||
active_plan: planPathA,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["ses_unrelated_active"],
|
||||
plan_name: "background-launch-work-a",
|
||||
works: {
|
||||
"work-a": {
|
||||
work_id: "work-a",
|
||||
active_plan: planPathA,
|
||||
plan_name: "background-launch-work-a",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["ses_unrelated_active"],
|
||||
status: "active",
|
||||
},
|
||||
"work-b": {
|
||||
work_id: "work-b",
|
||||
active_plan: planPathB,
|
||||
plan_name: "background-launch-work-b",
|
||||
started_at: "2026-01-02T10:05:00Z",
|
||||
session_ids: [parentSessionID],
|
||||
status: "active",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const pendingFilePaths = new Map<string, string>()
|
||||
const pendingTaskRefs = new Map()
|
||||
const ctx = {
|
||||
client,
|
||||
project,
|
||||
directory: testDirectory,
|
||||
worktree: testDirectory,
|
||||
serverUrl: new URL("https://example.com"),
|
||||
$: Bun.$,
|
||||
} satisfies PluginInput
|
||||
const beforeHandler = createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs })
|
||||
const afterHandler = createToolExecuteAfterHandler({
|
||||
ctx,
|
||||
pendingFilePaths,
|
||||
pendingTaskRefs,
|
||||
autoCommit: true,
|
||||
getState: () => ({ promptFailureCount: 0 }),
|
||||
})
|
||||
|
||||
await beforeHandler(
|
||||
{ tool: "task", sessionID: parentSessionID, callID: "call-bg-work" },
|
||||
{ args: { prompt: "Work B" } },
|
||||
)
|
||||
|
||||
await afterHandler(
|
||||
{ tool: "task", sessionID: parentSessionID, callID: "call-bg-work" },
|
||||
{
|
||||
title: "Sisyphus Task",
|
||||
output: "Background task launched.\n\nBackground Task ID: bg_work\n\n<task_metadata>\nsession_id: ses_child_for_work\n</task_metadata>",
|
||||
metadata: {
|
||||
sessionId: childSessionID,
|
||||
agent: "sisyphus-junior",
|
||||
category: "deep",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const boulderState = readBoulderState(testDirectory)
|
||||
expect(boulderState?.works?.["work-b"]?.session_ids).toContain(childSessionID)
|
||||
expect(boulderState?.works?.["work-a"]?.session_ids).not.toContain(childSessionID)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { Project } from "@opencode-ai/sdk"
|
||||
import { readBoulderState, writeBoulderState } from "../../features/boulder-state"
|
||||
import { createToolExecuteBeforeHandler } from "./tool-execute-before"
|
||||
|
||||
const isCallerOrchestratorMock = mock(async () => true)
|
||||
const collectGitDiffStatsMock = mock(() => ({
|
||||
filesChanged: 0,
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
}))
|
||||
|
||||
mock.module("../../shared/session-utils", () => ({
|
||||
isCallerOrchestrator: isCallerOrchestratorMock,
|
||||
}))
|
||||
|
||||
mock.module("../../shared/git-worktree", () => ({
|
||||
collectGitDiffStats: collectGitDiffStatsMock,
|
||||
formatFileChanges: mock(() => "No file changes"),
|
||||
}))
|
||||
|
||||
afterAll(() => { mock.restore() })
|
||||
|
||||
const { createToolExecuteAfterHandler } = await import("./tool-execute-after")
|
||||
|
||||
type SessionGetInput = { path: { id: string } }
|
||||
type SessionGetResult = {
|
||||
data: { parentID: string | undefined }
|
||||
error?: undefined
|
||||
request: Request
|
||||
response: Response
|
||||
}
|
||||
|
||||
describe("createToolExecuteAfterHandler task timers", () => {
|
||||
let testDirectory = ""
|
||||
|
||||
beforeEach(() => {
|
||||
testDirectory = join(tmpdir(), `atlas-task-timers-${crypto.randomUUID()}`)
|
||||
if (!existsSync(testDirectory)) {
|
||||
mkdirSync(testDirectory, { recursive: true })
|
||||
}
|
||||
isCallerOrchestratorMock.mockClear()
|
||||
collectGitDiffStatsMock.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (testDirectory && existsSync(testDirectory)) {
|
||||
rmSync(testDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function createProject(): Project {
|
||||
return {
|
||||
id: "project-1",
|
||||
worktree: testDirectory,
|
||||
time: { created: Date.now() },
|
||||
}
|
||||
}
|
||||
|
||||
function createSessionGetResult(parentID: string | undefined): SessionGetResult {
|
||||
return {
|
||||
data: { parentID },
|
||||
error: undefined,
|
||||
request: new Request("https://example.com/session"),
|
||||
response: new Response(null, { status: 200 }),
|
||||
} as SessionGetResult
|
||||
}
|
||||
|
||||
function createHandlers(parentSessionIDs?: Record<string, string | undefined>) {
|
||||
const project = createProject()
|
||||
const client = {
|
||||
session: {
|
||||
get: async (input: SessionGetInput) => createSessionGetResult(parentSessionIDs?.[input.path.id]),
|
||||
},
|
||||
} as PluginInput["client"]
|
||||
|
||||
if (parentSessionIDs) {
|
||||
spyOn(client.session, "get").mockImplementation((input) => Promise.resolve(
|
||||
createSessionGetResult(parentSessionIDs[input?.path?.id ?? ""]),
|
||||
) as never)
|
||||
}
|
||||
|
||||
const pendingFilePaths = new Map<string, string>()
|
||||
const pendingTaskRefs = new Map()
|
||||
const pendingPlanSnapshots = new Map<string, string>()
|
||||
const ctx = {
|
||||
client,
|
||||
project,
|
||||
directory: testDirectory,
|
||||
worktree: testDirectory,
|
||||
serverUrl: new URL("https://example.com"),
|
||||
$: Bun.$,
|
||||
} satisfies PluginInput
|
||||
|
||||
return {
|
||||
beforeHandler: createToolExecuteBeforeHandler({
|
||||
ctx,
|
||||
pendingFilePaths,
|
||||
pendingTaskRefs,
|
||||
pendingPlanSnapshots,
|
||||
}),
|
||||
afterHandler: createToolExecuteAfterHandler({
|
||||
ctx,
|
||||
pendingFilePaths,
|
||||
pendingTaskRefs,
|
||||
pendingPlanSnapshots,
|
||||
autoCommit: true,
|
||||
getState: () => ({ promptFailureCount: 0 }),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
it("starts task timer for todo:1 when delegated task session is tracked", async () => {
|
||||
// given
|
||||
const parentSessionID = "ses_parent"
|
||||
const childSessionID = "ses_child"
|
||||
const planPath = join(testDirectory, "task-timer-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [ ] 1. Implement auth flow\n", "utf-8")
|
||||
writeBoulderState(testDirectory, {
|
||||
schema_version: 2,
|
||||
active_work_id: "work-1",
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [parentSessionID],
|
||||
plan_name: "task-timer-plan",
|
||||
works: {
|
||||
"work-1": {
|
||||
work_id: "work-1",
|
||||
active_plan: planPath,
|
||||
plan_name: "task-timer-plan",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [parentSessionID],
|
||||
status: "active",
|
||||
},
|
||||
},
|
||||
})
|
||||
const { beforeHandler, afterHandler } = createHandlers({
|
||||
[childSessionID]: parentSessionID,
|
||||
})
|
||||
|
||||
await beforeHandler(
|
||||
{ tool: "task", sessionID: parentSessionID, callID: "call-task-timer-1" },
|
||||
{ args: { prompt: "Implement auth flow" } },
|
||||
)
|
||||
|
||||
// when
|
||||
await afterHandler(
|
||||
{ tool: "task", sessionID: parentSessionID, callID: "call-task-timer-1" },
|
||||
{
|
||||
title: "Sisyphus Task",
|
||||
output: "Task completed\n<task_metadata>\nsession_id: ses_child\n</task_metadata>",
|
||||
metadata: {
|
||||
sessionId: childSessionID,
|
||||
agent: "sisyphus-junior",
|
||||
category: "deep",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// then
|
||||
const taskSession = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions?.["todo:1"]
|
||||
expect(taskSession).toBeDefined()
|
||||
expect(taskSession?.started_at).toBeString()
|
||||
expect(taskSession?.status).toBe("running")
|
||||
expect(taskSession?.session_id).toBe(childSessionID)
|
||||
})
|
||||
|
||||
it("ends task timer when todo:1 checkbox transitions to checked", async () => {
|
||||
// given
|
||||
const parentSessionID = "ses_parent_2"
|
||||
const childSessionID = "ses_child_2"
|
||||
const planPath = join(testDirectory, "task-timer-complete-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [ ] 1. Implement auth flow\n", "utf-8")
|
||||
writeBoulderState(testDirectory, {
|
||||
schema_version: 2,
|
||||
active_work_id: "work-1",
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [parentSessionID],
|
||||
plan_name: "task-timer-complete-plan",
|
||||
works: {
|
||||
"work-1": {
|
||||
work_id: "work-1",
|
||||
active_plan: planPath,
|
||||
plan_name: "task-timer-complete-plan",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [parentSessionID],
|
||||
status: "active",
|
||||
},
|
||||
},
|
||||
})
|
||||
const { beforeHandler, afterHandler } = createHandlers({
|
||||
[childSessionID]: parentSessionID,
|
||||
})
|
||||
|
||||
await beforeHandler(
|
||||
{ tool: "task", sessionID: parentSessionID, callID: "call-task-timer-2" },
|
||||
{ args: { prompt: "Implement auth flow" } },
|
||||
)
|
||||
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [x] 1. Implement auth flow\n", "utf-8")
|
||||
|
||||
// when
|
||||
await afterHandler(
|
||||
{ tool: "task", sessionID: parentSessionID, callID: "call-task-timer-2" },
|
||||
{
|
||||
title: "Sisyphus Task",
|
||||
output: "Task completed\n<task_metadata>\nsession_id: ses_child_2\n</task_metadata>",
|
||||
metadata: {
|
||||
sessionId: childSessionID,
|
||||
agent: "sisyphus-junior",
|
||||
category: "deep",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// then
|
||||
const taskSession = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions?.["todo:1"]
|
||||
expect(taskSession).toBeDefined()
|
||||
expect(taskSession?.ended_at).toBeString()
|
||||
expect(taskSession?.status).toBe("completed")
|
||||
expect(typeof taskSession?.elapsed_ms).toBe("number")
|
||||
})
|
||||
|
||||
it("ends task timer when plan checkbox flips to checked via edit tool", async () => {
|
||||
// given
|
||||
const parentSessionID = "ses_parent_3"
|
||||
const planDirectory = join(testDirectory, ".sisyphus", "plans")
|
||||
mkdirSync(planDirectory, { recursive: true })
|
||||
const planPath = join(planDirectory, "task-timer-edit-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [ ] 1. Implement auth flow\n", "utf-8")
|
||||
writeBoulderState(testDirectory, {
|
||||
schema_version: 2,
|
||||
active_work_id: "work-1",
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [parentSessionID],
|
||||
plan_name: "task-timer-edit-plan",
|
||||
task_sessions: {
|
||||
"todo:1": {
|
||||
task_key: "todo:1",
|
||||
task_label: "1",
|
||||
task_title: "Implement auth flow",
|
||||
session_id: "ses_child_3",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
status: "running",
|
||||
updated_at: "2026-01-02T10:00:00Z",
|
||||
},
|
||||
},
|
||||
works: {
|
||||
"work-1": {
|
||||
work_id: "work-1",
|
||||
active_plan: planPath,
|
||||
plan_name: "task-timer-edit-plan",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [parentSessionID],
|
||||
status: "active",
|
||||
task_sessions: {},
|
||||
},
|
||||
},
|
||||
})
|
||||
const { beforeHandler, afterHandler } = createHandlers()
|
||||
|
||||
await beforeHandler(
|
||||
{ tool: "edit", sessionID: parentSessionID, callID: "call-task-timer-edit-1" },
|
||||
{ args: { filePath: planPath, oldString: "- [ ] 1. Implement auth flow", newString: "- [x] 1. Implement auth flow" } },
|
||||
)
|
||||
|
||||
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [x] 1. Implement auth flow\n", "utf-8")
|
||||
|
||||
// when
|
||||
await afterHandler(
|
||||
{ tool: "edit", sessionID: parentSessionID, callID: "call-task-timer-edit-1" },
|
||||
{
|
||||
title: "Edit",
|
||||
output: "Updated file",
|
||||
metadata: {
|
||||
filePath: planPath,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// then
|
||||
const taskSession = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions?.["todo:1"]
|
||||
expect(taskSession).toBeDefined()
|
||||
expect(taskSession?.ended_at).toBeString()
|
||||
expect(taskSession?.status).toBe("completed")
|
||||
expect(typeof taskSession?.elapsed_ms).toBe("number")
|
||||
expect((taskSession?.elapsed_ms ?? 0) > 0).toBe(true)
|
||||
})
|
||||
|
||||
it("tracks parallel delegated tasks by task label from TASK section", async () => {
|
||||
// given
|
||||
const parentSessionID = "ses_parent_parallel"
|
||||
const planPath = join(testDirectory, "task-timer-parallel-plan.md")
|
||||
writeFileSync(
|
||||
planPath,
|
||||
"# Plan\n\n## TODOs\n- [ ] 1. First task\n- [ ] 2. Add tests\n- [ ] 3. Write docs\n",
|
||||
"utf-8",
|
||||
)
|
||||
writeBoulderState(testDirectory, {
|
||||
schema_version: 2,
|
||||
active_work_id: "work-1",
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [parentSessionID],
|
||||
plan_name: "task-timer-parallel-plan",
|
||||
works: {
|
||||
"work-1": {
|
||||
work_id: "work-1",
|
||||
active_plan: planPath,
|
||||
plan_name: "task-timer-parallel-plan",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [parentSessionID],
|
||||
status: "active",
|
||||
},
|
||||
},
|
||||
})
|
||||
const { beforeHandler, afterHandler } = createHandlers({
|
||||
ses_child_parallel_2: parentSessionID,
|
||||
ses_child_parallel_3: parentSessionID,
|
||||
})
|
||||
|
||||
await beforeHandler(
|
||||
{ tool: "task", sessionID: parentSessionID, callID: "call-task-parallel-2" },
|
||||
{
|
||||
args: {
|
||||
prompt: "## 1. TASK\n- [ ] 2. Add tests\n\n## 2. CONTEXT\n...",
|
||||
},
|
||||
},
|
||||
)
|
||||
await beforeHandler(
|
||||
{ tool: "task", sessionID: parentSessionID, callID: "call-task-parallel-3" },
|
||||
{
|
||||
args: {
|
||||
prompt: "## 1. TASK\n- [ ] 3. Write docs\n\n## 2. CONTEXT\n...",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// when
|
||||
await afterHandler(
|
||||
{ tool: "task", sessionID: parentSessionID, callID: "call-task-parallel-2" },
|
||||
{
|
||||
title: "Sisyphus Task",
|
||||
output: "Task completed\n<task_metadata>\nsession_id: ses_child_parallel_2\n</task_metadata>",
|
||||
metadata: {
|
||||
sessionId: "ses_child_parallel_2",
|
||||
agent: "sisyphus-junior",
|
||||
category: "deep",
|
||||
},
|
||||
},
|
||||
)
|
||||
await afterHandler(
|
||||
{ tool: "task", sessionID: parentSessionID, callID: "call-task-parallel-3" },
|
||||
{
|
||||
title: "Sisyphus Task",
|
||||
output: "Task completed\n<task_metadata>\nsession_id: ses_child_parallel_3\n</task_metadata>",
|
||||
metadata: {
|
||||
sessionId: "ses_child_parallel_3",
|
||||
agent: "sisyphus-junior",
|
||||
category: "deep",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// then
|
||||
const taskSessions = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions
|
||||
expect(taskSessions?.["todo:2"]?.task_key).toBe("todo:2")
|
||||
expect(taskSessions?.["todo:3"]?.task_key).toBe("todo:3")
|
||||
expect(taskSessions?.["todo:1"]).toBeUndefined()
|
||||
})
|
||||
|
||||
it("falls back to current top-level task when TASK section label is missing", async () => {
|
||||
// given
|
||||
const parentSessionID = "ses_parent_fallback"
|
||||
const childSessionID = "ses_child_fallback"
|
||||
const planPath = join(testDirectory, "task-timer-fallback-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [ ] 1. First task\n", "utf-8")
|
||||
writeBoulderState(testDirectory, {
|
||||
schema_version: 2,
|
||||
active_work_id: "work-1",
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [parentSessionID],
|
||||
plan_name: "task-timer-fallback-plan",
|
||||
works: {
|
||||
"work-1": {
|
||||
work_id: "work-1",
|
||||
active_plan: planPath,
|
||||
plan_name: "task-timer-fallback-plan",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [parentSessionID],
|
||||
status: "active",
|
||||
},
|
||||
},
|
||||
})
|
||||
const { beforeHandler, afterHandler } = createHandlers({
|
||||
[childSessionID]: parentSessionID,
|
||||
})
|
||||
|
||||
await beforeHandler(
|
||||
{ tool: "task", sessionID: parentSessionID, callID: "call-task-fallback-1" },
|
||||
{
|
||||
args: {
|
||||
prompt: "No structured header in this prompt",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// when
|
||||
await afterHandler(
|
||||
{ tool: "task", sessionID: parentSessionID, callID: "call-task-fallback-1" },
|
||||
{
|
||||
title: "Sisyphus Task",
|
||||
output: "Task completed\n<task_metadata>\nsession_id: ses_child_fallback\n</task_metadata>",
|
||||
metadata: {
|
||||
sessionId: childSessionID,
|
||||
agent: "sisyphus-junior",
|
||||
category: "deep",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// then
|
||||
const taskSessions = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions
|
||||
expect(taskSessions?.["todo:1"]?.task_key).toBe("todo:1")
|
||||
})
|
||||
|
||||
})
|
||||
@@ -1,11 +1,17 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import {
|
||||
endTaskTimer,
|
||||
getWorkForSession,
|
||||
getPlanProgress,
|
||||
getTaskSessionState,
|
||||
readBoulderState,
|
||||
resolveBoulderPlanPath,
|
||||
resolveBoulderPlanPathForWork,
|
||||
startTaskTimer,
|
||||
upsertTaskSessionState,
|
||||
} from "../../features/boulder-state"
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { resolve } from "node:path"
|
||||
import { log } from "../../shared/logger"
|
||||
import { isCallerOrchestrator } from "../../shared/session-utils"
|
||||
import { syncBackgroundLaunchSessionTracking } from "./background-launch-session-tracking"
|
||||
@@ -26,15 +32,105 @@ import { isWriteOrEditToolName } from "./write-edit-tool-policy"
|
||||
import type { PendingTaskRef, SessionState } from "./types"
|
||||
import type { ToolExecuteAfterInput, ToolExecuteAfterOutput } from "./types"
|
||||
|
||||
function isTrackedTaskChecked(planPath: string, taskKey: string): boolean {
|
||||
if (!existsSync(planPath)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const [section, label] = taskKey.split(":")
|
||||
if (!section || !label) {
|
||||
return false
|
||||
}
|
||||
|
||||
const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
const matcher = section === "todo"
|
||||
? new RegExp(`^\\s*[-*]\\s*\\[[xX]\\]\\s*${escapedLabel}\\.\\s+`, "m")
|
||||
: section === "final-wave"
|
||||
? new RegExp(`^\\s*[-*]\\s*\\[[xX]\\]\\s*${escapedLabel.toUpperCase()}\\.\\s+`, "m")
|
||||
: null
|
||||
if (!matcher) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(planPath, "utf-8")
|
||||
return matcher.test(content)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const TODO_HEADING_PATTERN = /^##\s+TODOs\b/i
|
||||
const FINAL_VERIFICATION_HEADING_PATTERN = /^##\s+Final Verification Wave\b/i
|
||||
const SECOND_LEVEL_HEADING_PATTERN = /^##\s+/
|
||||
const CHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[[xX]\]\s*(.+)$/
|
||||
const TODO_TASK_PATTERN = /^(\d+)\.\s+(.+)$/
|
||||
const FINAL_WAVE_TASK_PATTERN = /^(F\d+)\.\s+(.+)$/i
|
||||
|
||||
function parseCheckedTopLevelTaskKeys(planContent: string): Set<string> {
|
||||
const checkedKeys = new Set<string>()
|
||||
const lines = planContent.split(/\r?\n/)
|
||||
let section: "todo" | "final-wave" | "other" = "other"
|
||||
|
||||
for (const line of lines) {
|
||||
if (SECOND_LEVEL_HEADING_PATTERN.test(line)) {
|
||||
section = TODO_HEADING_PATTERN.test(line)
|
||||
? "todo"
|
||||
: FINAL_VERIFICATION_HEADING_PATTERN.test(line)
|
||||
? "final-wave"
|
||||
: "other"
|
||||
continue
|
||||
}
|
||||
|
||||
if (section !== "todo" && section !== "final-wave") {
|
||||
continue
|
||||
}
|
||||
|
||||
const checkedMatch = line.match(CHECKED_CHECKBOX_PATTERN)
|
||||
if (!checkedMatch || checkedMatch[1].length > 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const taskBody = checkedMatch[2].trim()
|
||||
if (section === "todo") {
|
||||
const taskMatch = taskBody.match(TODO_TASK_PATTERN)
|
||||
if (taskMatch?.[1]) {
|
||||
checkedKeys.add(`todo:${taskMatch[1]}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const taskMatch = taskBody.match(FINAL_WAVE_TASK_PATTERN)
|
||||
if (taskMatch?.[1]) {
|
||||
checkedKeys.add(`final-wave:${taskMatch[1].toLowerCase()}`)
|
||||
}
|
||||
}
|
||||
|
||||
return checkedKeys
|
||||
}
|
||||
|
||||
function readCheckedTaskKeysFromPlan(planPath: string): Set<string> {
|
||||
if (!existsSync(planPath)) {
|
||||
return new Set<string>()
|
||||
}
|
||||
|
||||
try {
|
||||
return parseCheckedTopLevelTaskKeys(readFileSync(planPath, "utf-8"))
|
||||
} catch {
|
||||
return new Set<string>()
|
||||
}
|
||||
}
|
||||
|
||||
export function createToolExecuteAfterHandler(input: {
|
||||
ctx: PluginInput
|
||||
pendingFilePaths: Map<string, string>
|
||||
pendingTaskRefs: Map<string, PendingTaskRef>
|
||||
pendingPlanSnapshots?: Map<string, string>
|
||||
autoCommit: boolean
|
||||
getState: (sessionID: string) => SessionState
|
||||
isCallerOrchestrator?: (sessionID: string | undefined) => Promise<boolean>
|
||||
}): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput | undefined) => Promise<void> {
|
||||
const { ctx, pendingFilePaths, pendingTaskRefs, autoCommit, getState } = input
|
||||
const { ctx, pendingFilePaths, pendingTaskRefs, pendingPlanSnapshots, autoCommit, getState } = input
|
||||
const resolveIsCallerOrchestrator = input.isCallerOrchestrator ?? ((sessionID) => isCallerOrchestrator(sessionID, ctx.client))
|
||||
return async (toolInput, toolOutput): Promise<void> => {
|
||||
// Guard against undefined output (e.g., from /review command - see issue #1035)
|
||||
@@ -48,12 +144,33 @@ export function createToolExecuteAfterHandler(input: {
|
||||
|
||||
if (isWriteOrEditToolName(toolInput.tool)) {
|
||||
let filePath = toolInput.callID ? pendingFilePaths.get(toolInput.callID) : undefined
|
||||
const planSnapshot = toolInput.callID && pendingPlanSnapshots
|
||||
? pendingPlanSnapshots.get(toolInput.callID)
|
||||
: undefined
|
||||
if (toolInput.callID) {
|
||||
pendingFilePaths.delete(toolInput.callID)
|
||||
pendingPlanSnapshots?.delete(toolInput.callID)
|
||||
}
|
||||
if (!filePath) {
|
||||
filePath = toolOutput.metadata?.filePath as string | undefined
|
||||
}
|
||||
|
||||
if (filePath && toolInput.sessionID) {
|
||||
const sessionWork = getWorkForSession(ctx.directory, toolInput.sessionID)
|
||||
if (sessionWork) {
|
||||
const planPath = resolveBoulderPlanPathForWork(ctx.directory, sessionWork)
|
||||
if (resolve(filePath) === resolve(planPath) && planSnapshot !== undefined) {
|
||||
const beforeCheckedKeys = parseCheckedTopLevelTaskKeys(planSnapshot)
|
||||
const afterCheckedKeys = readCheckedTaskKeysFromPlan(planPath)
|
||||
for (const taskKey of afterCheckedKeys) {
|
||||
if (!beforeCheckedKeys.has(taskKey)) {
|
||||
endTaskTimer(ctx.directory, sessionWork.work_id, taskKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filePath && !isSisyphusPath(filePath)) {
|
||||
toolOutput.output = (toolOutput.output || "") + DIRECT_WORK_REMINDER
|
||||
log(`[${HOOK_NAME}] Direct work reminder appended`, {
|
||||
@@ -100,7 +217,29 @@ export function createToolExecuteAfterHandler(input: {
|
||||
const extractedSessionId = metadataSessionId ?? extractSessionIdFromOutput(toolOutput.output)
|
||||
|
||||
if (boulderState) {
|
||||
const planPath = resolveBoulderPlanPath(ctx.directory, boulderState)
|
||||
const sessionWork = toolInput.sessionID
|
||||
? getWorkForSession(ctx.directory, toolInput.sessionID)
|
||||
: null
|
||||
const planPath = sessionWork
|
||||
? resolveBoulderPlanPathForWork(ctx.directory, sessionWork)
|
||||
: resolveBoulderPlanPath(ctx.directory, boulderState)
|
||||
const workScopedBoulderState = sessionWork
|
||||
? {
|
||||
...boulderState,
|
||||
active_plan: sessionWork.active_plan,
|
||||
plan_name: sessionWork.plan_name,
|
||||
status: sessionWork.status,
|
||||
started_at: sessionWork.started_at,
|
||||
ended_at: sessionWork.ended_at,
|
||||
elapsed_ms: sessionWork.elapsed_ms,
|
||||
updated_at: sessionWork.updated_at,
|
||||
session_ids: [...sessionWork.session_ids],
|
||||
session_origins: sessionWork.session_origins ? { ...sessionWork.session_origins } : {},
|
||||
agent: sessionWork.agent,
|
||||
worktree_path: sessionWork.worktree_path,
|
||||
task_sessions: sessionWork.task_sessions ? { ...sessionWork.task_sessions } : {},
|
||||
}
|
||||
: boulderState
|
||||
const progress = getPlanProgress(planPath)
|
||||
const {
|
||||
currentTask,
|
||||
@@ -112,7 +251,7 @@ export function createToolExecuteAfterHandler(input: {
|
||||
: null
|
||||
const sessionState = toolInput.sessionID ? getState(toolInput.sessionID) : undefined
|
||||
|
||||
const lineageSessionIDs = boulderState.session_ids
|
||||
const lineageSessionIDs = sessionWork?.session_ids ?? boulderState.session_ids
|
||||
const subagentSessionId = await validateSubagentSessionId({
|
||||
client: ctx.client,
|
||||
sessionID: extractedSessionId,
|
||||
@@ -120,14 +259,28 @@ export function createToolExecuteAfterHandler(input: {
|
||||
})
|
||||
|
||||
if (currentTask && subagentSessionId && !shouldSkipTaskSessionUpdate) {
|
||||
upsertTaskSessionState(ctx.directory, {
|
||||
taskKey: currentTask.key,
|
||||
taskLabel: currentTask.label,
|
||||
taskTitle: currentTask.title,
|
||||
sessionId: subagentSessionId,
|
||||
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
|
||||
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
|
||||
})
|
||||
if (sessionWork) {
|
||||
startTaskTimer(ctx.directory, sessionWork.work_id, {
|
||||
taskKey: currentTask.key,
|
||||
taskLabel: currentTask.label,
|
||||
taskTitle: currentTask.title,
|
||||
sessionId: subagentSessionId,
|
||||
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
|
||||
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
|
||||
})
|
||||
if (isTrackedTaskChecked(planPath, currentTask.key)) {
|
||||
endTaskTimer(ctx.directory, sessionWork.work_id, currentTask.key)
|
||||
}
|
||||
} else {
|
||||
upsertTaskSessionState(ctx.directory, {
|
||||
taskKey: currentTask.key,
|
||||
taskLabel: currentTask.label,
|
||||
taskTitle: currentTask.title,
|
||||
sessionId: subagentSessionId,
|
||||
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
|
||||
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const preferredSessionId = resolvePreferredSessionId(
|
||||
@@ -155,11 +308,11 @@ export function createToolExecuteAfterHandler(input: {
|
||||
}
|
||||
|
||||
const leadReminder = shouldPauseForApproval
|
||||
? buildFinalWaveApprovalReminder(boulderState.plan_name, progress, preferredSessionId)
|
||||
: buildCompletionGate(boulderState.plan_name, preferredSessionId)
|
||||
? buildFinalWaveApprovalReminder(workScopedBoulderState.plan_name, progress, preferredSessionId)
|
||||
: buildCompletionGate(workScopedBoulderState.plan_name, preferredSessionId)
|
||||
const followupReminder = shouldPauseForApproval
|
||||
? null
|
||||
: buildOrchestratorReminder(boulderState.plan_name, progress, preferredSessionId, autoCommit, false)
|
||||
: buildOrchestratorReminder(workScopedBoulderState.plan_name, progress, preferredSessionId, autoCommit, false)
|
||||
|
||||
toolOutput.output = `
|
||||
<system-reminder>
|
||||
@@ -181,8 +334,8 @@ ${
|
||||
? ""
|
||||
: `<system-reminder>\n${followupReminder}\n</system-reminder>`
|
||||
}`
|
||||
log(`[${HOOK_NAME}] Output transformed for orchestrator mode (boulder)`, {
|
||||
plan: boulderState.plan_name,
|
||||
log(`[${HOOK_NAME}] Output transformed for orchestrator mode (boulder)`, {
|
||||
plan: workScopedBoulderState.plan_name,
|
||||
progress: `${progress.completed}/${progress.total}`,
|
||||
fileCount: gitStats.length,
|
||||
preferredSessionId,
|
||||
|
||||
@@ -2,23 +2,69 @@ 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, resolveBoulderPlanPath } from "../../features/boulder-state"
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { resolve } from "node:path"
|
||||
import { getWorkForSession, readBoulderState, readCurrentTopLevelTask, resolveBoulderPlanPath, resolveBoulderPlanPathForWork } 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"
|
||||
import type { PendingTaskRef, TrackedTopLevelTaskRef } from "./types"
|
||||
import { isWriteOrEditToolName } from "./write-edit-tool-policy"
|
||||
|
||||
const TASK_SECTION_HEADER_PATTERN = /^##\s*1\.\s*TASK\s*$/i
|
||||
const TODO_TASK_LINE_PATTERN = /^(?:[-*]\s*\[\s*\]\s*)?(\d+)\.\s+(.+)$/
|
||||
const FINAL_WAVE_TASK_LINE_PATTERN = /^(?:[-*]\s*\[\s*\]\s*)?(F\d+)\.\s+(.+)$/i
|
||||
|
||||
function parseTrackedTaskFromPrompt(prompt: string): TrackedTopLevelTaskRef | null {
|
||||
const lines = prompt.split(/\r?\n/)
|
||||
const taskHeaderIndex = lines.findIndex((line) => TASK_SECTION_HEADER_PATTERN.test(line.trim()))
|
||||
if (taskHeaderIndex < 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const startIndex = taskHeaderIndex + 1
|
||||
const endIndex = Math.min(lines.length, startIndex + 5)
|
||||
for (let index = startIndex; index < endIndex; index += 1) {
|
||||
const candidate = lines[index]?.trim()
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
|
||||
const finalWaveMatch = candidate.match(FINAL_WAVE_TASK_LINE_PATTERN)
|
||||
if (finalWaveMatch?.[1] && finalWaveMatch[2]) {
|
||||
const label = finalWaveMatch[1].toUpperCase()
|
||||
return {
|
||||
key: `final-wave:${label.toLowerCase()}`,
|
||||
label,
|
||||
title: finalWaveMatch[2].trim(),
|
||||
}
|
||||
}
|
||||
|
||||
const todoMatch = candidate.match(TODO_TASK_LINE_PATTERN)
|
||||
if (todoMatch?.[1] && todoMatch[2]) {
|
||||
const label = todoMatch[1]
|
||||
return {
|
||||
key: `todo:${label}`,
|
||||
label,
|
||||
title: todoMatch[2].trim(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function createToolExecuteBeforeHandler(input: {
|
||||
ctx: PluginInput
|
||||
pendingFilePaths: Map<string, string>
|
||||
pendingTaskRefs: Map<string, PendingTaskRef>
|
||||
pendingPlanSnapshots?: Map<string, string>
|
||||
isCallerOrchestrator?: (sessionID: string | undefined) => Promise<boolean>
|
||||
}): (
|
||||
toolInput: { tool: string; sessionID?: string; callID?: string },
|
||||
toolOutput: { args: Record<string, unknown>; message?: string }
|
||||
) => Promise<void> {
|
||||
const { ctx, pendingFilePaths, pendingTaskRefs } = input
|
||||
const { ctx, pendingFilePaths, pendingTaskRefs, pendingPlanSnapshots } = input
|
||||
const resolveIsCallerOrchestrator = input.isCallerOrchestrator ?? ((sessionID) => isCallerOrchestrator(sessionID, ctx.client))
|
||||
|
||||
function trackTask(callID: string, task: TrackedTopLevelTaskRef): void {
|
||||
@@ -34,11 +80,35 @@ export function createToolExecuteBeforeHandler(input: {
|
||||
// Warn-only policy: Atlas guides orchestrators toward delegation but doesn't block, allowing flexibility for urgent fixes
|
||||
if (isWriteOrEditToolName(toolInput.tool)) {
|
||||
const filePath = (toolOutput.args.filePath ?? toolOutput.args.path ?? toolOutput.args.file) as string | undefined
|
||||
if (filePath && !isSisyphusPath(filePath)) {
|
||||
// Store filePath for use in tool.execute.after
|
||||
if (toolInput.callID) {
|
||||
pendingFilePaths.set(toolInput.callID, filePath)
|
||||
if (!filePath || !toolInput.callID) {
|
||||
return
|
||||
}
|
||||
|
||||
// Store filePath for use in tool.execute.after
|
||||
pendingFilePaths.set(toolInput.callID, filePath)
|
||||
|
||||
const sessionID = toolInput.sessionID
|
||||
const sessionWork = sessionID
|
||||
? getWorkForSession(ctx.directory, sessionID)
|
||||
: null
|
||||
const state = sessionWork ? null : readBoulderState(ctx.directory)
|
||||
const planPath = sessionWork
|
||||
? resolveBoulderPlanPathForWork(ctx.directory, sessionWork)
|
||||
: state
|
||||
? resolveBoulderPlanPath(ctx.directory, state)
|
||||
: null
|
||||
|
||||
if (planPath && resolve(filePath) === resolve(planPath) && pendingPlanSnapshots) {
|
||||
try {
|
||||
if (existsSync(planPath)) {
|
||||
pendingPlanSnapshots.set(toolInput.callID, readFileSync(planPath, "utf-8"))
|
||||
}
|
||||
} catch {
|
||||
pendingPlanSnapshots.delete(toolInput.callID)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSisyphusPath(filePath)) {
|
||||
const warning = ORCHESTRATOR_DELEGATION_REQUIRED.replace("$FILE_PATH", filePath)
|
||||
toolOutput.message = (toolOutput.message || "") + warning
|
||||
log(`[${HOOK_NAME}] Injected delegation warning for direct file modification`, {
|
||||
@@ -60,33 +130,48 @@ export function createToolExecuteBeforeHandler(input: {
|
||||
reason: "explicit_resume",
|
||||
})
|
||||
} else {
|
||||
const prompt = typeof toolOutput.args.prompt === "string" ? toolOutput.args.prompt : ""
|
||||
const taskFromPrompt = parseTrackedTaskFromPrompt(prompt)
|
||||
const boulderState = readBoulderState(ctx.directory)
|
||||
const currentTask = boulderState
|
||||
? readCurrentTopLevelTask(resolveBoulderPlanPath(ctx.directory, boulderState))
|
||||
: null
|
||||
if (currentTask) {
|
||||
const task = {
|
||||
key: currentTask.key,
|
||||
label: currentTask.label,
|
||||
title: currentTask.title,
|
||||
const resolvedTask = taskFromPrompt ?? (currentTask
|
||||
? {
|
||||
key: currentTask.key,
|
||||
label: currentTask.label,
|
||||
title: currentTask.title,
|
||||
}
|
||||
: null)
|
||||
if (resolvedTask) {
|
||||
if (!taskFromPrompt) {
|
||||
log(`[${HOOK_NAME}] TASK section parse failed; falling back to current top-level task`, {
|
||||
sessionID: toolInput.sessionID,
|
||||
callID: toolInput.callID,
|
||||
})
|
||||
}
|
||||
const trackedTask = {
|
||||
key: resolvedTask.key,
|
||||
label: resolvedTask.label,
|
||||
title: resolvedTask.title,
|
||||
}
|
||||
const hasExistingClaim = [...pendingTaskRefs.values()].some((pendingTaskRef) => (
|
||||
pendingTaskRef.kind === "track" && pendingTaskRef.task.key === task.key
|
||||
pendingTaskRef.kind === "track" && pendingTaskRef.task.key === trackedTask.key
|
||||
))
|
||||
|
||||
if (hasExistingClaim) {
|
||||
pendingTaskRefs.set(toolInput.callID, {
|
||||
kind: "skip",
|
||||
reason: "ambiguous_task_key",
|
||||
task,
|
||||
task: trackedTask,
|
||||
})
|
||||
log(`[${HOOK_NAME}] Skipping task session persistence for ambiguous task key`, {
|
||||
sessionID: toolInput.sessionID,
|
||||
callID: toolInput.callID,
|
||||
taskKey: task.key,
|
||||
taskKey: trackedTask.key,
|
||||
})
|
||||
} else {
|
||||
trackTask(toolInput.callID, task)
|
||||
trackTask(toolInput.callID, trackedTask)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,4 +48,5 @@ export interface SessionState {
|
||||
waitingForFinalWaveApproval?: boolean
|
||||
pendingFinalWaveTaskCount?: number
|
||||
approvedFinalWaveTaskCount?: number
|
||||
boulderCompletionNudgedAt?: Record<string, number>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit"]
|
||||
const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit", "hashline_edit"]
|
||||
|
||||
export function isWriteOrEditToolName(toolName: string): boolean {
|
||||
return WRITE_EDIT_TOOLS.includes(toolName)
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
test("keeps existing works when explicit new plan is started", () => {
|
||||
// given
|
||||
writePlan("work-a", "## TODOs\n- [ ] 1. Work A")
|
||||
const workBPath = writePlan("work-b", "## TODOs\n- [ ] 1. Work B")
|
||||
writePlan("new-plan-c", "## TODOs\n- [ ] 1. Work C")
|
||||
|
||||
const initialState = createBoulderState(
|
||||
join(testDirectory, ".sisyphus", "plans", "work-a.md"),
|
||||
"session-a",
|
||||
"atlas",
|
||||
"/tmp/worktree-a",
|
||||
)
|
||||
writeBoulderState(testDirectory, initialState)
|
||||
|
||||
const workAId = initialState.active_work_id!
|
||||
const withSecondWork = addBoulderWork(testDirectory, {
|
||||
planPath: workBPath,
|
||||
sessionId: "session-b",
|
||||
agent: "atlas",
|
||||
worktreePath: "/tmp/worktree-b",
|
||||
})
|
||||
expect(withSecondWork).not.toBeNull()
|
||||
const workBId = Object.keys(withSecondWork!.works!).find((workId) => workId !== workAId)
|
||||
expect(workBId).toBeDefined()
|
||||
|
||||
// when
|
||||
buildStartWorkContextInfo({
|
||||
ctx: createPluginInput(),
|
||||
explicitPlanName: "new-plan-c",
|
||||
existingState: readExistingState(),
|
||||
sessionId: "session-c",
|
||||
timestamp: "2026-05-11T00:00:00.000Z",
|
||||
activeAgent: "atlas",
|
||||
worktreePath: undefined,
|
||||
worktreeBlock: "",
|
||||
})
|
||||
|
||||
// then
|
||||
const nextState = readBoulderState(testDirectory)
|
||||
const workIds = Object.keys(nextState?.works ?? {})
|
||||
expect(workIds.length).toBe(3)
|
||||
expect(workIds).toContain(workAId)
|
||||
expect(workIds).toContain(workBId!)
|
||||
const workC = getWorkByPlanName(testDirectory, "new-plan-c")
|
||||
expect(workC).not.toBeNull()
|
||||
expect(workIds).toContain(workC!.work_id)
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
@@ -44,19 +48,14 @@ function findPlanByName(plans: string[], requestedName: string): string | null {
|
||||
return normalizedPartialMatch || null
|
||||
}
|
||||
|
||||
function buildAutoSelectedPlanContext(params: {
|
||||
function buildAutoSelectedPlanContextInfoOnly(params: {
|
||||
planPath: string
|
||||
sessionId: string
|
||||
timestamp: string
|
||||
activeAgent: string
|
||||
worktreePath: string | undefined
|
||||
worktreeBlock: string
|
||||
directory: string
|
||||
}): string {
|
||||
const { planPath, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock, directory } = params
|
||||
const { planPath, sessionId, timestamp, worktreeBlock } = params
|
||||
const progress = getPlanProgress(planPath)
|
||||
const newState = createBoulderState(planPath, sessionId, activeAgent, worktreePath)
|
||||
writeBoulderState(directory, newState)
|
||||
|
||||
return `
|
||||
## Auto-Selected Plan
|
||||
@@ -71,6 +70,27 @@ ${worktreeBlock}
|
||||
boulder.json has been created. Read the plan and begin execution.`
|
||||
}
|
||||
|
||||
function buildAutoSelectedPlanContextWithStateInit(params: {
|
||||
planPath: string
|
||||
sessionId: string
|
||||
timestamp: string
|
||||
activeAgent: string
|
||||
worktreePath: string | undefined
|
||||
worktreeBlock: string
|
||||
directory: string
|
||||
}): string {
|
||||
const { planPath, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock, directory } = params
|
||||
const newState = createBoulderState(planPath, sessionId, activeAgent, worktreePath)
|
||||
writeBoulderState(directory, newState)
|
||||
|
||||
return buildAutoSelectedPlanContextInfoOnly({
|
||||
planPath,
|
||||
sessionId,
|
||||
timestamp,
|
||||
worktreeBlock,
|
||||
})
|
||||
}
|
||||
|
||||
function buildMissingPlanContext(explicitPlanName: string, allPlans: string[]): string {
|
||||
const incompletePlans = allPlans.filter((p) => !getPlanProgress(p).isComplete)
|
||||
if (incompletePlans.length > 0) {
|
||||
@@ -99,9 +119,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<typeof getWorkResumeOptions>
|
||||
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 `
|
||||
<system-reminder>
|
||||
## 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.
|
||||
</system-reminder>`
|
||||
}
|
||||
|
||||
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<typeof readBoulderState>
|
||||
sessionId: string
|
||||
timestamp: string
|
||||
activeAgent: string
|
||||
@@ -109,9 +193,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,18 +226,19 @@ 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({
|
||||
return buildAutoSelectedPlanContextInfoOnly({
|
||||
planPath: matchedPlan,
|
||||
sessionId,
|
||||
timestamp,
|
||||
activeAgent,
|
||||
worktreePath,
|
||||
worktreeBlock,
|
||||
directory,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -241,7 +341,7 @@ function buildPlanDiscoveryContext(params: {
|
||||
}
|
||||
|
||||
if (incompletePlans.length === 1) {
|
||||
return contextInfo + buildAutoSelectedPlanContext({
|
||||
return contextInfo + buildAutoSelectedPlanContextWithStateInit({
|
||||
planPath: incompletePlans[0],
|
||||
sessionId,
|
||||
timestamp,
|
||||
@@ -287,11 +387,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,
|
||||
|
||||
Reference in New Issue
Block a user