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:
YeonGyu-Kim
2026-05-11 14:54:58 +09:00
committed by GitHub
44 changed files with 3496 additions and 134 deletions
+3
View File
@@ -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)
})
})
+125
View File
@@ -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")
})
})
+78 -2
View File
@@ -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) {
+5 -5
View File
@@ -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")
})
})
+169 -16
View File
@@ -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,
+100 -15
View File
@@ -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)
}
}
}
+1
View File
@@ -48,4 +48,5 @@ export interface SessionState {
waitingForFinalWaveApproval?: boolean
pendingFinalWaveTaskCount?: number
approvedFinalWaveTaskCount?: number
boulderCompletionNudgedAt?: Record<string, number>
}
+1 -1
View File
@@ -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)
})
})
+156 -19
View File
@@ -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,