Merge remote-tracking branch 'origin/dev' into fix/task-id-prompt-surface

# Conflicts:
#	src/agents/atlas/default-prompt-sections.ts
#	src/agents/atlas/gemini-prompt-sections.ts
#	src/agents/atlas/gpt-prompt-sections.ts
#	src/agents/hephaestus/gpt-5-3-codex.ts
This commit is contained in:
Disaster-Terminator
2026-05-17 10:17:24 +08:00
1312 changed files with 98022 additions and 12089 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
# src/hooks/atlas/ — Master Boulder Orchestrator
**Generated:** 2026-04-18
**Generated:** 2026-05-15
## OVERVIEW
@@ -42,7 +42,7 @@ session.idle event
| `session-last-agent.ts` | Determine which agent owns the session |
| `recent-model-resolver.ts` | Resolve model used in recent messages |
| `subagent-session-id.ts` | Detect if session is a subagent session |
| `sisyphus-path.ts` | Resolve `.sisyphus/` directory path |
| `omo-path.ts` | Resolve `.omo/` directory path |
| `is-abort-error.ts` | Detect abort signals in session output |
| `types.ts` | `SessionState`, `AtlasHookOptions`, `AtlasContext` |
+17 -2
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 {
@@ -21,7 +22,21 @@ export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) {
return {
handler: createAtlasEventHandler({ ctx, options, sessions, getState }),
"tool.execute.before": createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs }),
"tool.execute.after": createToolExecuteAfterHandler({ ctx, pendingFilePaths, pendingTaskRefs, autoCommit, getState }),
"tool.execute.before": createToolExecuteBeforeHandler({
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, 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,
boulderState.active_plan,
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"
}
}
+64 -55
View File
@@ -7,6 +7,8 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { createAtlasHook } from "./atlas-hook"
import { clearBoulderState, writeBoulderState } from "../../features/boulder-state"
import { _resetForTesting, clearSessionAgent, registerAgentName, setSessionAgent } from "../../features/claude-code-session-state"
import { DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS } from "../../shared/prompt-async-gate"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
// Force process isolation in CI runner (globalThis.setTimeout override conflicts with other atlas tests)
mock.module("../../shared/opencode-storage-detection", () => ({
@@ -23,6 +25,8 @@ describe("atlas background task retry", () => {
let nextFakeTimerId = 1000
const originalSetTimeout = globalThis.setTimeout
const originalClearTimeout = globalThis.clearTimeout
const originalDateNow = Date.now
let fakeNow = 0
async function flushMicrotasks(): Promise<void> {
await Promise.resolve()
@@ -51,6 +55,7 @@ describe("atlas background task retry", () => {
}
capturedTimers.delete(id)
fakeNow += 6000
await entry.callback()
}
await flushMicrotasks()
@@ -66,6 +71,8 @@ describe("atlas background task retry", () => {
capturedTimers.clear()
nextFakeTimerId = 1000
fakeNow = 10_000
Date.now = () => fakeNow
globalThis.setTimeout = ((callback: Parameters<typeof setTimeout>[0], delay?: number, ...args: unknown[]) => {
const normalizedDelay = typeof delay === "number" ? delay : 0
@@ -73,21 +80,22 @@ describe("atlas background task retry", () => {
return originalSetTimeout(callback, delay, ...args)
}
if (normalizedDelay >= 5000) {
if (normalizedDelay >= 5000 && normalizedDelay !== DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS) {
const id = nextFakeTimerId++
capturedTimers.set(id, {
callback: () => (callback as LongTimerCallback)(...args),
cleared: false,
})
return id as unknown as ReturnType<typeof setTimeout>
return unsafeTestValue<ReturnType<typeof setTimeout>>(id)
}
return originalSetTimeout(callback, delay, ...args)
}) as typeof setTimeout
globalThis.clearTimeout = ((id?: number | ReturnType<typeof setTimeout>) => {
if (typeof id === "number" && capturedTimers.has(id)) {
capturedTimers.get(id)!.cleared = true
const timerEntry = typeof id === "number" ? capturedTimers.get(id) : undefined
if (timerEntry) {
timerEntry.cleared = true
capturedTimers.delete(id)
return
}
@@ -99,6 +107,7 @@ describe("atlas background task retry", () => {
afterEach(() => {
globalThis.setTimeout = originalSetTimeout
globalThis.clearTimeout = originalClearTimeout
Date.now = originalDateNow
_resetForTesting()
clearBoulderState(testDir)
if (existsSync(testDir)) {
@@ -120,7 +129,7 @@ describe("atlas background task retry", () => {
let backgroundRunning = true
const promptMock = mock(async () => ({}))
const hook = createAtlasHook({
const hook = createAtlasHook(unsafeTestValue<PluginInput>({
directory: testDir,
client: {
session: {
@@ -128,13 +137,13 @@ describe("atlas background task retry", () => {
messages: async () => ({ data: [] }),
},
},
} as unknown as PluginInput, {
}), {
directory: testDir,
backgroundManager: {
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
},
}>({
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
}),
})
// when
@@ -161,7 +170,7 @@ describe("atlas background task retry", () => {
let backgroundRunning = true
const promptMock = mock(async () => ({}))
const hook = createAtlasHook({
const hook = createAtlasHook(unsafeTestValue<PluginInput>({
directory: testDir,
client: {
session: {
@@ -169,13 +178,13 @@ describe("atlas background task retry", () => {
messages: async () => ({ data: [] }),
},
},
} as unknown as PluginInput, {
}), {
directory: testDir,
backgroundManager: {
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
},
}>({
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
}),
})
// when
@@ -204,7 +213,7 @@ describe("atlas background task retry", () => {
let remainingRunningRetries = 2
const promptMock = mock(async () => ({}))
const hook = createAtlasHook({
const hook = createAtlasHook(unsafeTestValue<PluginInput>({
directory: testDir,
client: {
session: {
@@ -212,9 +221,11 @@ describe("atlas background task retry", () => {
messages: async () => ({ data: [] }),
},
},
} as unknown as PluginInput, {
}), {
directory: testDir,
backgroundManager: {
backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
}>({
getTasksByParentSession: () => {
if (remainingRunningRetries > 0) {
remainingRunningRetries -= 1
@@ -223,9 +234,7 @@ describe("atlas background task retry", () => {
return []
},
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
},
}),
})
// when
@@ -258,7 +267,7 @@ describe("atlas background task retry", () => {
const promptAsyncMock = mock(async () => ({}))
let backgroundCheckCount = 0
const hook = createAtlasHook({
const hook = createAtlasHook(unsafeTestValue<PluginInput>({
directory: testDir,
client: {
session: {
@@ -266,9 +275,11 @@ describe("atlas background task retry", () => {
messages: async () => ({ data: [] }),
},
},
} as unknown as PluginInput, {
}), {
directory: testDir,
backgroundManager: {
backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
}>({
getTasksByParentSession: () => {
backgroundCheckCount += 1
if (backgroundCheckCount === 1) {
@@ -281,9 +292,7 @@ describe("atlas background task retry", () => {
return []
},
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
},
}),
})
// when
@@ -313,7 +322,7 @@ describe("atlas background task retry", () => {
let backgroundRunning = true
const promptAsyncMock = mock(async () => ({}))
const hook = createAtlasHook({
const hook = createAtlasHook(unsafeTestValue<PluginInput>({
directory: testDir,
client: {
session: {
@@ -321,13 +330,13 @@ describe("atlas background task retry", () => {
messages: async () => ({ data: [] }),
},
},
} as unknown as PluginInput, {
}), {
directory: testDir,
backgroundManager: {
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
},
}>({
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
}),
})
// when
@@ -366,7 +375,7 @@ describe("atlas background task retry", () => {
let backgroundRunning = true
let descendantAgent = "atlas"
const promptAsyncMock = mock(async () => ({}))
const hook = createAtlasHook({
const hook = createAtlasHook(unsafeTestValue<PluginInput>({
directory: testDir,
client: {
session: {
@@ -384,18 +393,18 @@ describe("atlas background task retry", () => {
}),
},
},
} as unknown as PluginInput, {
}), {
directory: testDir,
backgroundManager: {
backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
}>({
getTasksByParentSession: (currentSessionID: string) => {
if (currentSessionID !== descendantSessionID) {
return []
}
return backgroundRunning ? [{ status: "running" }] : []
},
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
},
}),
})
// when
@@ -422,9 +431,9 @@ describe("atlas background task retry", () => {
agent: "atlas",
})
const deferredPrompt = createDeferred<{}>()
const deferredPrompt = createDeferred<unknown>()
const promptAsyncMock = mock(() => deferredPrompt.promise)
const hook = createAtlasHook({
const hook = createAtlasHook(unsafeTestValue<PluginInput>({
directory: testDir,
client: {
session: {
@@ -432,7 +441,7 @@ describe("atlas background task retry", () => {
messages: async () => ({ data: [] }),
},
},
} as unknown as PluginInput)
}))
// when
const firstIdle = hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
@@ -462,7 +471,7 @@ describe("atlas background task retry", () => {
promptAsyncMock.mockImplementationOnce(() => deferredPrompt.promise)
promptAsyncMock.mockImplementationOnce(async () => ({}))
const hook = createAtlasHook({
const hook = createAtlasHook(unsafeTestValue<PluginInput>({
directory: testDir,
client: {
session: {
@@ -470,13 +479,13 @@ describe("atlas background task retry", () => {
messages: async () => ({ data: [] }),
},
},
} as unknown as PluginInput, {
}), {
directory: testDir,
backgroundManager: {
getTasksByParentSession: () => [],
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
},
}>({
getTasksByParentSession: () => [],
}),
})
// when
@@ -515,7 +524,7 @@ describe("atlas background task retry", () => {
})
promptAsyncMock.mockImplementationOnce(async () => ({}))
const hook = createAtlasHook({
const hook = createAtlasHook(unsafeTestValue<PluginInput>({
directory: testDir,
client: {
session: {
@@ -523,13 +532,13 @@ describe("atlas background task retry", () => {
messages: async () => ({ data: [] }),
},
},
} as unknown as PluginInput, {
}), {
directory: testDir,
backgroundManager: {
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
} as unknown as NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
backgroundManager: unsafeTestValue<NonNullable<Parameters<typeof createAtlasHook>[1]>["backgroundManager"] & {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
},
}>({
getTasksByParentSession: () => backgroundRunning ? [{ status: "running" }] : [],
}),
})
// when
@@ -2,6 +2,7 @@ import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import { registerAgentName, _resetForTesting } from "../../features/claude-code-session-state"
import { injectBoulderContinuation } from "./boulder-continuation-injector"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("injectBoulderContinuation", () => {
beforeEach(() => {
@@ -20,7 +21,7 @@ describe("injectBoulderContinuation", () => {
const promptAsyncMock = mock(async (_request: unknown) => undefined)
const messagesMock = mock(async () => ({ data: [] }))
const ctx = {
const ctx = unsafeTestValue<PluginInput>({
directory: "/tmp",
client: {
session: {
@@ -28,7 +29,7 @@ describe("injectBoulderContinuation", () => {
promptAsync: promptAsyncMock,
},
},
} as unknown as PluginInput
})
// when
const result = await injectBoulderContinuation({
@@ -60,7 +61,7 @@ describe("injectBoulderContinuation", () => {
const messagesMock = mock(async () => ({ data: [] }))
const sessionState = { promptFailureCount: 2, lastContinuationInjectedAt: 123 }
const ctx = {
const ctx = unsafeTestValue<PluginInput>({
directory: "/tmp",
client: {
session: {
@@ -68,7 +69,7 @@ describe("injectBoulderContinuation", () => {
promptAsync: promptAsyncMock,
},
},
} as unknown as PluginInput
})
// when
const result = await injectBoulderContinuation({
@@ -78,9 +79,9 @@ describe("injectBoulderContinuation", () => {
remaining: 1,
total: 2,
agent: "atlas",
backgroundManager: {
backgroundManager: unsafeTestValue<Parameters<typeof injectBoulderContinuation>[0]["backgroundManager"]>({
getTasksByParentSession: () => [{ status: "running" }],
} as unknown as Parameters<typeof injectBoulderContinuation>[0]["backgroundManager"],
}),
sessionState,
})
@@ -91,12 +92,14 @@ describe("injectBoulderContinuation", () => {
expect(sessionState.lastContinuationInjectedAt).toBe(123)
})
test("#given the continuation agent is unavailable #when injector runs #then it reports skipped agent unavailable without prompting", async () => {
test("#given a background task is still pending session creation #when injector checks again #then it still skips continuation", async () => {
// given
registerAgentName("atlas")
const promptAsyncMock = mock(async (_request: unknown) => undefined)
const messagesMock = mock(async () => ({ data: [] }))
const sessionState = { promptFailureCount: 1, lastContinuationInjectedAt: 456 }
const ctx = {
const ctx = unsafeTestValue<PluginInput>({
directory: "/tmp",
client: {
session: {
@@ -104,7 +107,43 @@ describe("injectBoulderContinuation", () => {
promptAsync: promptAsyncMock,
},
},
} as unknown as PluginInput
})
// when
const result = await injectBoulderContinuation({
ctx,
sessionID: "ses_test_pending",
planName: "test-plan",
remaining: 1,
total: 2,
agent: "atlas",
backgroundManager: unsafeTestValue<Parameters<typeof injectBoulderContinuation>[0]["backgroundManager"]>({
getTasksByParentSession: () => [{ status: "pending" }],
}),
sessionState,
})
// then
expect(result).toBe("skipped_background_tasks")
expect(promptAsyncMock).not.toHaveBeenCalled()
expect(sessionState.promptFailureCount).toBe(1)
expect(sessionState.lastContinuationInjectedAt).toBe(456)
})
test("#given the continuation agent is unavailable #when injector runs #then it reports skipped agent unavailable without prompting", async () => {
// given
const promptAsyncMock = mock(async (_request: unknown) => undefined)
const messagesMock = mock(async () => ({ data: [] }))
const ctx = unsafeTestValue<PluginInput>({
directory: "/tmp",
client: {
session: {
messages: messagesMock,
promptAsync: promptAsyncMock,
},
},
})
// when
const result = await injectBoulderContinuation({
@@ -129,6 +168,11 @@ describe("injectBoulderContinuation", () => {
body?: {
model?: { providerID: string; modelID: string }
variant?: string
noReply?: boolean
parts?: Array<{
synthetic?: boolean
metadata?: Record<string, unknown>
}>
}
}> = []
const promptAsyncMock = mock(async (request: unknown) => {
@@ -151,7 +195,7 @@ describe("injectBoulderContinuation", () => {
}],
}))
const ctx = {
const ctx = unsafeTestValue<PluginInput>({
directory: "/tmp",
client: {
session: {
@@ -159,7 +203,7 @@ describe("injectBoulderContinuation", () => {
promptAsync: promptAsyncMock,
},
},
} as unknown as PluginInput
})
// when
const result = await injectBoulderContinuation({
@@ -180,5 +224,9 @@ describe("injectBoulderContinuation", () => {
modelID: "claude-sonnet-4-20250514",
})
expect(capturedRequests[0]?.body?.variant).toBe("max")
expect(capturedRequests[0]?.body?.noReply).toBeUndefined()
const promptPart = capturedRequests[0]?.body?.parts?.[0]
expect(promptPart?.synthetic).toBe(true)
expect(promptPart?.metadata?.compaction_continue).toBe(true)
})
})
@@ -1,17 +1,25 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent"
import {
isAgentRegistered,
resolveRegisteredAgentName,
} from "../../features/claude-code-session-state"
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
import { log } from "../../shared/logger"
import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared"
import { createInternalAgentContinuationTextPart, resolveInheritedPromptTools } from "../../shared"
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
import { HOOK_NAME } from "./hook-name"
import { BOULDER_CONTINUATION_PROMPT } from "./system-reminder-templates"
import { resolveRecentPromptContextForSession } from "./recent-model-resolver"
import type { SessionState } from "./types"
import type { BackgroundTaskStatusProvider, SessionState } from "./types"
export type BoulderContinuationResult = "injected" | "skipped_background_tasks" | "skipped_agent_unavailable" | "failed"
export type BoulderContinuationResult =
| "injected"
| "skipped_active_session"
| "skipped_background_tasks"
| "skipped_agent_unavailable"
| "failed"
const ACTIVE_BACKGROUND_TASK_STATUSES = new Set(["pending", "running"])
export async function injectBoulderContinuation(input: {
ctx: PluginInput
@@ -23,8 +31,9 @@ export async function injectBoulderContinuation(input: {
worktreePath?: string
preferredTaskSessionId?: string
preferredTaskTitle?: string
backgroundManager?: BackgroundManager
backgroundManager?: BackgroundTaskStatusProvider
sessionState: SessionState
idleSettleMs?: number
}): Promise<BoulderContinuationResult> {
const {
ctx,
@@ -38,10 +47,11 @@ export async function injectBoulderContinuation(input: {
preferredTaskTitle,
backgroundManager,
sessionState,
idleSettleMs,
} = input
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some((t: { status: string }) => t.status === "running")
? backgroundManager.getTasksByParentSession(sessionID).some((t: { status: string }) => ACTIVE_BACKGROUND_TASK_STATUSES.has(t.status))
: false
if (hasRunningBgTasks) {
@@ -58,20 +68,21 @@ export async function injectBoulderContinuation(input: {
`\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` +
preferredSessionContext +
worktreeContext
const continuationAgent = resolveRegisteredAgentName(
const resolvedContinuationAgent = resolveRegisteredAgentName(
agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined),
)
const continuationAgent = resolvedContinuationAgent ? stripAgentListSortPrefix(resolvedContinuationAgent) : resolvedContinuationAgent
if (!continuationAgent || !isAgentRegistered(continuationAgent)) {
log(`[${HOOK_NAME}] Skipped injection: continuation agent unavailable`, {
sessionID,
agent: continuationAgent ?? agent ?? "unknown",
})
return "skipped_agent_unavailable"
}
return "skipped_agent_unavailable"
}
try {
log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining })
try {
log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining })
const promptContext = await resolveRecentPromptContextForSession(ctx, sessionID)
const inheritedTools = resolveInheritedPromptTools(sessionID, promptContext.tools)
@@ -81,17 +92,33 @@ export async function injectBoulderContinuation(input: {
: undefined
const launchVariant = promptContext.model?.variant
await ctx.client.session.promptAsync({
const promptResult = await promptAsyncAfterSessionIdle({
client: ctx.client,
sessionID,
source: HOOK_NAME,
settleMs: idleSettleMs,
input: {
path: { id: sessionID },
body: {
agent: continuationAgent,
...(launchModel ? { model: launchModel } : {}),
...(launchVariant ? { variant: launchVariant } : {}),
...(inheritedTools ? { tools: inheritedTools } : {}),
parts: [createInternalAgentTextPart(prompt)],
parts: [createInternalAgentContinuationTextPart(prompt)],
},
query: { directory: ctx.directory },
},
})
if (promptResult.status === "failed") {
throw promptResult.error
}
if (promptResult.status !== "dispatched") {
log(`[${HOOK_NAME}] Boulder continuation skipped by promptAsync gate`, {
sessionID,
status: promptResult.status,
})
return "skipped_active_session"
}
sessionState.promptFailureCount = 0
log(`[${HOOK_NAME}] Boulder continuation injected`, { sessionID })
+25 -11
View File
@@ -1,5 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared/logger"
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
import { HOOK_NAME } from "./hook-name"
import { isAbortError } from "./is-abort-error"
import { handleAtlasSessionIdle } from "./idle-event"
@@ -17,7 +18,7 @@ export function createAtlasEventHandler(input: {
const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined
const sessionID = resolveSessionEventID(props)
if (!sessionID) return
const state = getState(sessionID)
@@ -25,11 +26,21 @@ export function createAtlasEventHandler(input: {
state.lastEventWasAbortError = isAbort
log(`[${HOOK_NAME}] session.error`, { sessionID, isAbort })
if (!isAbort) {
const previousInjectedAt = state.lastContinuationInjectedAt
await handleAtlasSessionIdle({ ctx, options, getState, sessionID })
if (
state.lastContinuationInjectedAt !== undefined
&& state.lastContinuationInjectedAt !== previousInjectedAt
) {
state.skipNextIdleAfterRuntimeErrorRetry = true
}
}
return
}
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
const sessionID = resolveSessionEventID(props)
if (!sessionID) return
await handleAtlasSessionIdle({ ctx, options, getState, sessionID })
return
@@ -37,13 +48,14 @@ export function createAtlasEventHandler(input: {
if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
const sessionID = resolveMessageEventSessionID(props)
const role = info?.role as string | undefined
if (!sessionID) return
const state = sessions.get(sessionID)
if (state) {
state.lastEventWasAbortError = false
state.skipNextIdleAfterRuntimeErrorRetry = false
if (role === "user") {
state.waitingForFinalWaveApproval = false
}
@@ -53,44 +65,46 @@ export function createAtlasEventHandler(input: {
if (event.type === "message.part.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
const sessionID = resolveMessageEventSessionID(props)
const role = info?.role as string | undefined
if (sessionID && role === "assistant") {
const state = sessions.get(sessionID)
if (state) {
state.lastEventWasAbortError = false
state.skipNextIdleAfterRuntimeErrorRetry = false
}
}
return
}
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
const sessionID = props?.sessionID as string | undefined
const sessionID = resolveMessageEventSessionID(props)
if (sessionID) {
const state = sessions.get(sessionID)
if (state) {
state.lastEventWasAbortError = false
state.skipNextIdleAfterRuntimeErrorRetry = false
}
}
return
}
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
const deletedState = sessions.get(sessionInfo.id)
const sessionID = resolveSessionEventID(props)
if (sessionID) {
const deletedState = sessions.get(sessionID)
if (deletedState?.pendingRetryTimer) {
clearTimeout(deletedState.pendingRetryTimer)
}
sessions.delete(sessionInfo.id)
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id })
sessions.delete(sessionID)
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID })
}
return
}
if (event.type === "session.compacted") {
const sessionID = (props?.sessionID ?? (props?.info as { id?: string } | undefined)?.id) as string | undefined
const sessionID = resolveSessionEventID(props)
if (sessionID) {
const compactedState = sessions.get(sessionID)
if (compactedState?.pendingRetryTimer) {
@@ -113,7 +113,7 @@ describe("Atlas final-wave approval gate regressions", () => {
beforeEach(() => {
testDirectory = join(tmpdir(), `atlas-final-wave-regression-${randomUUID()}`)
mkdirSync(join(testDirectory, ".sisyphus"), { recursive: true })
mkdirSync(join(testDirectory, ".omo"), { recursive: true })
clearBoulderState(testDirectory)
})
@@ -149,7 +149,10 @@ describe("Atlas final-wave approval gate regressions", () => {
- [ ] All tests pass
`)
const hook = createAtlasHook(createMockPluginInput())
const hook = createAtlasHook(createMockPluginInput(), {
directory: testDirectory,
isCallerOrchestrator: async () => true,
})
const toolOutput = {
title: "Sisyphus Task",
output: `Tasks [1/1 compliant] | Contamination [CLEAN] | Unaccounted [CLEAN] | VERDICT: APPROVE
@@ -186,7 +189,10 @@ session_id: ses_nested_scope_review
- [ ] F4. **Scope Fidelity Check** - \`deep\`
`)
const hook = createAtlasHook(createMockPluginInput())
const hook = createAtlasHook(createMockPluginInput(), {
directory: testDirectory,
isCallerOrchestrator: async () => true,
})
const firstThreeOutputs = [1, 2, 3].map((index) => ({
title: `Final review ${index}`,
output: `Reviewer ${index} | VERDICT: APPROVE
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, mock, test, afterAll } from "bun:test"
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import { randomUUID } from "node:crypto"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
@@ -7,32 +7,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk"
import type { AssistantMessage, Session } from "@opencode-ai/sdk"
import type { BoulderState } from "../../features/boulder-state"
import { clearBoulderState, writeBoulderState } from "../../features/boulder-state"
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-final-wave-storage-${randomUUID()}`)
const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
const TEST_PART_STORAGE = join(TEST_STORAGE_ROOT, "part")
mock.module("../../features/hook-message-injector/constants", () => ({
OPENCODE_STORAGE: TEST_STORAGE_ROOT,
MESSAGE_STORAGE: TEST_MESSAGE_STORAGE,
PART_STORAGE: TEST_PART_STORAGE,
}))
mock.module("../../shared/opencode-message-dir", () => ({
getMessageDir: (sessionID: string) => {
const directoryPath = join(TEST_MESSAGE_STORAGE, sessionID)
return existsSync(directoryPath) ? directoryPath : null
},
}))
mock.module("../../shared/opencode-storage-detection", () => ({
isSqliteBackend: () => false,
}))
afterAll(() => { mock.restore() })
const { createAtlasHook } = await import("./index")
const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector")
import { createAtlasHook } from "./index"
type AtlasHookContext = Parameters<typeof createAtlasHook>[0]
type PromptMock = ReturnType<typeof mock>
@@ -89,31 +64,9 @@ describe("Atlas final verification approval gate", () => {
}
}
function setupMessageStorage(sessionID: string): void {
const messageDirectory = join(MESSAGE_STORAGE, sessionID)
if (!existsSync(messageDirectory)) {
mkdirSync(messageDirectory, { recursive: true })
}
writeFileSync(
join(messageDirectory, "msg_test001.json"),
JSON.stringify({
agent: "atlas",
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
}),
)
}
function cleanupMessageStorage(sessionID: string): void {
const messageDirectory = join(MESSAGE_STORAGE, sessionID)
if (existsSync(messageDirectory)) {
rmSync(messageDirectory, { recursive: true, force: true })
}
}
beforeEach(() => {
testDirectory = join(tmpdir(), `atlas-final-wave-test-${randomUUID()}`)
mkdirSync(join(testDirectory, ".sisyphus"), { recursive: true })
mkdirSync(join(testDirectory, ".omo"), { recursive: true })
clearBoulderState(testDirectory)
})
@@ -127,7 +80,6 @@ describe("Atlas final verification approval gate", () => {
test("waits for explicit user approval after the last final-wave approval arrives", async () => {
// given
const sessionID = "atlas-final-wave-session"
setupMessageStorage(sessionID)
const planPath = join(testDirectory, "final-wave-plan.md")
writeFileSync(
@@ -155,7 +107,7 @@ describe("Atlas final verification approval gate", () => {
writeBoulderState(testDirectory, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createAtlasHook(mockInput, { directory: testDirectory, isCallerOrchestrator: async () => true })
const toolOutput = {
title: "Sisyphus Task",
output: `Tasks [4/4 compliant] | Contamination [CLEAN] | Unaccounted [CLEAN] | VERDICT: APPROVE
@@ -176,13 +128,11 @@ session_id: ses_final_wave_review
expect(toolOutput.output).not.toContain("STEP 8: PROCEED TO NEXT TASK")
expect(mockInput._promptMock).not.toHaveBeenCalled()
cleanupMessageStorage(sessionID)
})
test("keeps normal auto-continue instructions for non-final tasks", async () => {
// given
const sessionID = "atlas-non-final-session"
setupMessageStorage(sessionID)
const planPath = join(testDirectory, "implementation-plan.md")
writeFileSync(
@@ -210,7 +160,10 @@ session_id: ses_final_wave_review
}
writeBoulderState(testDirectory, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createAtlasHook(createMockPluginInput(), {
directory: testDirectory,
isCallerOrchestrator: async () => true,
})
const toolOutput = {
title: "Sisyphus Task",
output: `Implementation finished successfully
@@ -229,6 +182,5 @@ session_id: ses_feature_task
expect(toolOutput.output).toContain("STEP 8: PROCEED TO NEXT TASK")
expect(toolOutput.output).not.toContain("FINAL WAVE APPROVAL GATE")
cleanupMessageStorage(sessionID)
})
})
@@ -0,0 +1,79 @@
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"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
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(unsafeTestValue<Parameters<typeof createAtlasHook>[0]>({
directory: testDirectory,
client: {
session: {
get: async () => ({ data: { id: sessionID } }),
messages: async () => ({ data: [] }),
prompt: async () => ({ data: {} }),
promptAsync: async () => ({ data: {} }),
},
},
}))
// 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)
})
})
+3 -2
View File
@@ -7,6 +7,7 @@ import { join } from "node:path"
import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
import type { BoulderState } from "../../features/boulder-state"
import { _resetForTesting, registerAgentName, setSessionAgent, subagentSessions } from "../../features/claude-code-session-state"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
const { createAtlasHook } = await import("./index")
@@ -32,7 +33,7 @@ describe("atlas hook idle-event session lineage", () => {
}
function createHook(parentSessionIDs?: Record<string, string | undefined>) {
return createAtlasHook({
return createAtlasHook(unsafeTestValue<Parameters<typeof createAtlasHook>[0]>({
directory: testDirectory,
client: {
session: {
@@ -52,7 +53,7 @@ describe("atlas hook idle-event session lineage", () => {
},
},
},
} as unknown as Parameters<typeof createAtlasHook>[0])
}))
}
beforeEach(() => {
@@ -8,6 +8,7 @@ import { randomUUID } from "node:crypto"
import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
import { _resetForTesting, registerAgentName } from "../../features/claude-code-session-state"
import type { BoulderState } from "../../features/boulder-state"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-persisted-lineage-storage-${randomUUID()}`)
const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
@@ -58,7 +59,7 @@ describe("atlas hook idle-event persisted lineage", () => {
parentSessionIDs?: Record<string, string | undefined>,
messagesBySession?: Record<string, Array<{ info: { agent: string; providerID: string; modelID: string } }>>,
) {
return createAtlasHook({
return createAtlasHook(unsafeTestValue<Parameters<typeof createAtlasHook>[0]>({
directory: testDirectory,
client: {
session: {
@@ -79,7 +80,7 @@ describe("atlas hook idle-event persisted lineage", () => {
},
},
},
} as unknown as Parameters<typeof createAtlasHook>[0])
}))
}
beforeEach(() => {
@@ -173,7 +174,7 @@ describe("atlas hook idle-event persisted lineage", () => {
},
})
const hook = createAtlasHook({
const hook = createAtlasHook(unsafeTestValue<Parameters<typeof createAtlasHook>[0]>({
directory: testDirectory,
client: {
session: {
@@ -193,7 +194,7 @@ describe("atlas hook idle-event persisted lineage", () => {
},
},
},
} as unknown as Parameters<typeof createAtlasHook>[0])
}))
// when
await hook.handler({
+147
View File
@@ -0,0 +1,147 @@
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"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
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?: {
noReply?: boolean
parts?: Array<{
text?: string
synthetic?: boolean
metadata?: Record<string, unknown>
}>
}
}> = []
const promptAsyncMock = mock(async (request: {
body?: {
noReply?: boolean
parts?: Array<{
text?: string
synthetic?: boolean
metadata?: Record<string, unknown>
}>
}
}) => {
promptRequests.push(request)
return { data: {} }
})
const ctx = unsafeTestValue<PluginInput>({
directory: testDirectory,
client: {
session: {
promptAsync: promptAsyncMock,
},
},
})
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}")
expect(promptRequests[0]?.body?.noReply).toBeUndefined()
expect(promptRequests[0]?.body?.parts?.[0]?.synthetic).toBe(true)
expect(promptRequests[0]?.body?.parts?.[0]?.metadata?.compaction_continue).toBe(true)
const persistedState = getState(SESSION_ID)
expect(persistedState.boulderCompletionNudgedAt?.[workId]).toBeNumber()
expect(readBoulderState(testDirectory)?.works?.[workId]?.status).toBe("completed")
})
})
+119 -4
View File
@@ -1,18 +1,30 @@
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 { createInternalAgentContinuationTextPart } from "../../shared"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { log } from "../../shared/logger"
import { shouldPromptAfterSessionIdle } from "../shared/session-idle-settle"
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
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
@@ -20,6 +32,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
@@ -36,6 +53,7 @@ async function injectContinuation(input: {
progress: { total: number; completed: number }
agent?: string
worktreePath?: string
idleSettleMs?: number
}): Promise<void> {
const remaining = input.progress.total - input.progress.completed
if (input.sessionState.isInjectingContinuation) {
@@ -52,8 +70,12 @@ async function injectContinuation(input: {
try {
const currentBoulder = readBoulderState(input.ctx.directory)
const currentPlanPath = currentBoulder
? resolveBoulderPlanPath(input.ctx.directory, currentBoulder)
: null
const currentTask = currentBoulder
? readCurrentTopLevelTask(currentBoulder.active_plan)
&& currentPlanPath
? readCurrentTopLevelTask(currentPlanPath)
: null
const preferredTaskSession = currentTask
? getTaskSessionState(input.ctx.directory, currentTask.key)
@@ -90,6 +112,7 @@ async function injectContinuation(input: {
preferredTaskTitle: preferredTaskSession?.task_title,
backgroundManager: input.options?.backgroundManager,
sessionState: input.sessionState,
idleSettleMs: input.idleSettleMs,
})
if (result === "injected") {
@@ -163,7 +186,7 @@ function scheduleRetry(input: {
if (!currentBoulder) return
if (!currentBoulder.session_ids?.includes(sessionID)) return
const currentProgress = getPlanProgress(currentBoulder.active_plan)
const currentProgress = getPlanProgress(resolveBoulderPlanPath(ctx.directory, currentBoulder))
if (currentProgress.isComplete) return
if (options?.isContinuationStopped?.(sessionID)) return
const canContinueSession = await canContinueTrackedBoulderSession({
@@ -199,6 +222,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 })
@@ -214,6 +238,86 @@ 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)) {
if (!(await shouldPromptAfterSessionIdle(ctx.client, sessionID, options?.idleSettleMs))) {
log(`[${HOOK_NAME}] Boulder completion nudge skipped because session is active`, { sessionID })
return
}
const promptResult = await promptAsyncAfterSessionIdle({
client: ctx.client,
sessionID,
source: HOOK_NAME,
settleMs: options?.idleSettleMs,
input: {
path: { id: sessionID },
body: {
agent: atlasAgent,
parts: [createInternalAgentContinuationTextPart(prompt)],
},
query: { directory: ctx.directory },
},
})
if (promptResult.status !== "dispatched") {
log(`[${HOOK_NAME}] Boulder completion nudge skipped by promptAsync gate`, {
sessionID,
status: promptResult.status,
})
return
}
sessionState.boulderCompletionNudgedAt = {
...(sessionState.boulderCompletionNudgedAt ?? {}),
[work.work_id]: Date.now(),
}
}
log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name })
return
}
@@ -240,7 +344,6 @@ export async function handleAtlasSessionIdle(input: {
return
}
const sessionState = getState(sessionID)
const now = Date.now()
if (sessionState.waitingForFinalWaveApproval) {
@@ -254,6 +357,12 @@ export async function handleAtlasSessionIdle(input: {
return
}
if (sessionState.skipNextIdleAfterRuntimeErrorRetry) {
sessionState.skipNextIdleAfterRuntimeErrorRetry = false
log(`[${HOOK_NAME}] Skipped: stale idle after runtime error retry`, { sessionID })
return
}
if (sessionState.promptFailureCount >= MAX_CONSECUTIVE_PROMPT_FAILURES) {
const timeSinceLastFailure =
sessionState.lastFailureAt !== undefined ? now - sessionState.lastFailureAt : Number.POSITIVE_INFINITY
@@ -291,6 +400,11 @@ export async function handleAtlasSessionIdle(input: {
return
}
if (!(await shouldPromptAfterSessionIdle(ctx.client, sessionID, options?.idleSettleMs))) {
log(`[${HOOK_NAME}] Skipped: session became active during idle settle`, { sessionID })
return
}
await injectContinuation({
ctx,
sessionID,
@@ -300,6 +414,7 @@ export async function handleAtlasSessionIdle(input: {
progress,
agent: boulderState.agent,
worktreePath: boulderState.worktree_path,
idleSettleMs: options?.idleSettleMs ?? 0,
})
}
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, test } from "bun:test"
import { isOmoPath } from "./omo-path"
describe("isOmoPath", () => {
test("#given a path under an omo directory #when checking the path #then it matches the omo segment", () => {
expect(isOmoPath(".omo/plans/work.md")).toBe(true)
expect(isOmoPath("/repo/.omo/plans/work.md")).toBe(true)
expect(isOmoPath(String.raw`C:\repo\.omo\plans\work.md`)).toBe(true)
})
test("#given a path whose directory merely ends with omo #when checking the path #then it does not match", () => {
expect(isOmoPath("/repo/work.omo/plans/work.md")).toBe(false)
expect(isOmoPath("/repo/.omo-backup/plans/work.md")).toBe(false)
expect(isOmoPath("/repo/notes.omo")).toBe(false)
})
})
+8
View File
@@ -0,0 +1,8 @@
/**
* Cross-platform check if a path is inside .omo/ directory.
* Handles both forward slashes (Unix) and backslashes (Windows).
* Uses path segment matching instead of substring matching.
*/
export function isOmoPath(filePath: string): boolean {
return /(^|[/\\])\.omo([/\\]|$)/.test(filePath)
}
@@ -1,26 +1,30 @@
declare const require: (name: string) => any
const { describe, expect, mock, test, afterAll } = require("bun:test")
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { afterAll, describe, expect, test } from "bun:test"
import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { resolveRecentPromptContextForSession } from "./recent-model-resolver"
import type { ModelInfo } from "./types"
const testDirs: string[] = []
const TEST_STORAGE_ROOT = join(tmpdir(), `recent-model-fallback-${Date.now()}`)
const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
mock.module("../../shared/opencode-storage-detection", () => ({
isSqliteBackend: () => false,
}))
function findNearestTestMessage(messageDir: string): { model?: ModelInfo; tools?: Record<string, boolean> } | null {
const [message] = readdirSync(messageDir)
.filter((fileName) => fileName.endsWith(".json"))
.map((fileName) => {
const content = readFileSync(join(messageDir, fileName), "utf-8")
const parsed = JSON.parse(content) as { model?: ModelInfo; tools?: Record<string, boolean>; time?: { created?: number } }
return {
message: parsed,
createdAt: parsed.time?.created ?? Number.NEGATIVE_INFINITY,
fileName,
}
})
.sort((left, right) => right.createdAt - left.createdAt || right.fileName.localeCompare(left.fileName))
mock.module("../../shared/opencode-message-dir", () => ({
getMessageDir: (sessionID: string) => {
const directPath = join(TEST_MESSAGE_STORAGE, sessionID)
return require("node:fs").existsSync(directPath) ? directPath : null
},
}))
return message?.message ?? null
}
afterAll(() => {
mock.restore()
while (testDirs.length > 0) {
const directory = testDirs.pop()
if (directory) {
@@ -34,8 +38,10 @@ describe("resolveRecentPromptContextForSession fallback ordering", () => {
// given
const sessionID = "ses_recent_model_fallback"
const directory = mkdtempSync(join(tmpdir(), "recent-model-fallback-dir-"))
const storageRoot = mkdtempSync(join(tmpdir(), "recent-model-fallback-storage-"))
testDirs.push(directory)
const messageDir = join(TEST_MESSAGE_STORAGE, sessionID)
testDirs.push(storageRoot)
const messageDir = join(storageRoot, sessionID)
mkdirSync(messageDir, { recursive: true })
writeFileSync(join(messageDir, "msg_ffff0000_000001.json"), JSON.stringify({
agent: "atlas",
@@ -50,8 +56,6 @@ describe("resolveRecentPromptContextForSession fallback ordering", () => {
time: { created: 100 },
}), "utf-8")
const { resolveRecentPromptContextForSession } = await import("./recent-model-resolver")
const ctx = {
client: {
session: {
@@ -63,7 +67,12 @@ describe("resolveRecentPromptContextForSession fallback ordering", () => {
}
// when
const result = await resolveRecentPromptContextForSession(ctx as never, sessionID)
const result = await resolveRecentPromptContextForSession(ctx as never, sessionID, {
isSqliteBackend: () => false,
getMessageDir: () => messageDir,
findNearestMessageWithFields: findNearestTestMessage,
findNearestMessageWithFieldsFromSDK: async () => null,
})
// then
expect(result.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" })
@@ -1,11 +1,12 @@
import { describe, expect, mock, test } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import { resolveRecentPromptContextForSession } from "./recent-model-resolver"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("resolveRecentPromptContextForSession", () => {
test("uses message time.created rather than SDK array order for recent prompt context", async () => {
// given
const ctx = {
const ctx = unsafeTestValue<PluginInput>({
client: {
session: {
messages: mock(async () => ({
@@ -32,7 +33,7 @@ describe("resolveRecentPromptContextForSession", () => {
})),
},
},
} as unknown as PluginInput
})
// when
const result = await resolveRecentPromptContextForSession(ctx, "ses_123")
+20 -5
View File
@@ -11,9 +11,24 @@ type PromptContext = {
tools?: Record<string, boolean>
}
type RecentPromptContextDeps = {
isSqliteBackend: typeof isSqliteBackend
getMessageDir: typeof getMessageDir
findNearestMessageWithFields: typeof findNearestMessageWithFields
findNearestMessageWithFieldsFromSDK: typeof findNearestMessageWithFieldsFromSDK
}
const defaultDeps: RecentPromptContextDeps = {
isSqliteBackend,
getMessageDir,
findNearestMessageWithFields,
findNearestMessageWithFieldsFromSDK,
}
export async function resolveRecentPromptContextForSession(
ctx: PluginInput,
sessionID: string
sessionID: string,
deps: RecentPromptContextDeps = defaultDeps,
): Promise<PromptContext> {
try {
const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } })
@@ -59,11 +74,11 @@ export async function resolveRecentPromptContextForSession(
}
let currentMessage = null
if (isSqliteBackend()) {
currentMessage = await findNearestMessageWithFieldsFromSDK(ctx.client, sessionID)
if (deps.isSqliteBackend()) {
currentMessage = await deps.findNearestMessageWithFieldsFromSDK(ctx.client, sessionID)
} else {
const messageDir = getMessageDir(sessionID)
currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
const messageDir = deps.getMessageDir(sessionID)
currentMessage = messageDir ? deps.findNearestMessageWithFields(messageDir) : null
}
const model = currentMessage?.model
const tools = normalizePromptTools(currentMessage?.tools)
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { dirname, join } from "node:path"
import { randomUUID } from "node:crypto"
import { clearBoulderState, writeBoulderState } from "../../features/boulder-state"
import { resolveActiveBoulderSession } from "./resolve-active-boulder-session"
@@ -96,4 +96,112 @@ describe("resolveActiveBoulderSession", () => {
expect(result?.progress.isComplete).toBe(false)
expect(result?.boulderState.session_ids).toContain("ses_appended")
})
test("returns complete progress when a mirrored worktree plan is complete", async () => {
// given
const mainPlanPath = join(testDirectory, ".omo", "plans", "worktree-plan.md")
const worktreeDirectory = join(tmpdir(), `resolve-active-boulder-worktree-${randomUUID()}`)
const worktreePlanPath = join(worktreeDirectory, ".omo", "plans", "worktree-plan.md")
mkdirSync(dirname(mainPlanPath), { recursive: true })
mkdirSync(dirname(worktreePlanPath), { recursive: true })
writeFileSync(mainPlanPath, "# Plan\n- [ ] Main repo task\n", "utf-8")
writeFileSync(worktreePlanPath, "# Plan\n- [x] Worktree task\n", "utf-8")
writeBoulderState(testDirectory, {
active_plan: mainPlanPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: ["ses_tracked"],
session_origins: { ses_tracked: "direct" },
plan_name: "worktree-plan",
worktree_path: worktreeDirectory,
})
try {
// when
const result = await resolveActiveBoulderSession({
client: { session: { get: async () => ({ data: {} }) } } as never,
directory: testDirectory,
sessionID: "ses_tracked",
})
// then
expect(result).not.toBeNull()
expect(result?.progress.isComplete).toBe(true)
expect(result?.progress.completed).toBe(1)
} finally {
rmSync(worktreeDirectory, { recursive: true, force: true })
}
})
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 } 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(boulderState.active_plan)
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 }
}
-8
View File
@@ -1,8 +0,0 @@
/**
* Cross-platform check if a path is inside .sisyphus/ directory.
* Handles both forward slashes (Unix) and backslashes (Windows).
* Uses path segment matching (not substring) to avoid false positives like "not-sisyphus/file.txt"
*/
export function isSisyphusPath(filePath: string): boolean {
return /\.sisyphus[/\\]/.test(filePath)
}
@@ -1,6 +1,8 @@
import { describe, it, expect } from "bun:test"
import {
BOULDER_COMPLETE_PROMPT,
BOULDER_CONTINUATION_PROMPT,
SINGLE_TASK_DIRECTIVE,
VERIFICATION_REMINDER,
VERIFICATION_REMINDER_GEMINI,
} from "./system-reminder-templates"
@@ -32,8 +34,8 @@ describe("BOULDER_CONTINUATION_PROMPT", () => {
expect(checkboxMarkingMatch).not.toBeNull()
expect(proceedMatch).not.toBeNull()
const checkboxPosition = checkboxMarkingMatch!.index
const proceedPosition = proceedMatch!.index
const checkboxPosition = checkboxMarkingMatch!.index ?? -1
const proceedPosition = proceedMatch!.index ?? -1
expect(checkboxPosition).toBeLessThan(proceedPosition)
})
@@ -46,8 +48,32 @@ 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")
})
})
describe("SINGLE_TASK_DIRECTIVE", () => {
it("does not contain refusal language", () => {
// given
const lowerCaseDirective = SINGLE_TASK_DIRECTIVE.toLowerCase()
// when / then
expect(lowerCaseDirective).not.toContain("refuse")
expect(SINGLE_TASK_DIRECTIVE).not.toContain("I refuse")
})
it("contains systematic execution guidance", () => {
expect(SINGLE_TASK_DIRECTIVE).toContain("EXECUTION PROTOCOL")
expect(SINGLE_TASK_DIRECTIVE).toContain("VERIFICATION IS MANDATORY")
})
})
+58 -66
View File
@@ -6,24 +6,18 @@ export const DIRECT_WORK_REMINDER = `
${createSystemDirective(SystemDirectiveTypes.DELEGATION_REQUIRED)}
You just performed direct file modifications outside \`.sisyphus/\`.
**You just edited a source file directly.**
**You are an ORCHESTRATOR, not an IMPLEMENTER.**
Did you ACTUALLY need to be the one doing that?
As an orchestrator, you should:
- **DELEGATE** implementation work to subagents via \`task\`
- **VERIFY** the work done by subagents
- **COORDINATE** multiple tasks and ensure completion
- If this was a tiny verification fix during subagent review → fine, continue.
- If this was implementation work of any size → **you violated orchestrator protocol.** Real work goes through \`task()\`. Revert the change and delegate it via \`task()\`. The subagent has the context, the tools, and the model for that work — you do not.
You should NOT:
- Write code directly (except for \`.sisyphus/\` files like plans and notepads)
- Make direct file edits outside \`.sisyphus/\`
- Implement features yourself
**Atlas does not implement. Atlas orchestrates.** Every direct edit erodes the
delegation pipeline you exist to run, and steals work the subagent is paid to do.
**If you need to make changes:**
1. Use \`task\` to delegate to an appropriate subagent
2. Provide clear instructions in the prompt
3. Verify the subagent's work after completion
Going forward: \`task()\` for implementation. Fan out in PARALLEL when independent
tasks remain — do not dispatch them one at a time.
---
`
@@ -35,10 +29,21 @@ You have an active work plan with incomplete tasks. Continue working.
RULES:
- **FIRST**: Read the plan file NOW. If the last completed task is still unchecked, mark it \`- [x]\` IMMEDIATELY before anything else
- Proceed without asking for permission
- Use the notepad at .sisyphus/notepads/{PLAN_NAME}/ to record learnings
- Use the notepad at .omo/notepads/{PLAN_NAME}/ to record learnings
- 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,
@@ -168,47 +173,41 @@ export const ORCHESTRATOR_DELEGATION_REQUIRED = `
${createSystemDirective(SystemDirectiveTypes.DELEGATION_REQUIRED)}
**STOP. YOU ARE VIOLATING ORCHESTRATOR PROTOCOL.**
**STOP. Atlas does not edit source code.**
You (Atlas) are attempting to directly modify a file outside \`.sisyphus/\`.
Path attempted: \`$FILE_PATH\`
**Path attempted:** $FILE_PATH
Ask yourself, honestly, before this write goes through:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. **Do you ACTUALLY need to be the one doing this?**
If a subagent could do it via \`task()\` — and the answer is almost always yes — you are stealing the subagent's work.
**THIS IS FORBIDDEN** (except for VERIFICATION purposes)
2. **Is this STRICTLY a small verification fix on subagent output?**
(≤ a couple of lines, fixing something the subagent left wrong during review.)
If yes, fine. If no — STOP this edit. Delegate it.
As an ORCHESTRATOR, you MUST:
1. **DELEGATE** all implementation work via \`task\`
2. **VERIFY** the work done by subagents (reading files is OK)
3. **COORDINATE** - you orchestrate, you don't implement
If you are about to write more than a trivial verification patch, or you are touching code no subagent has produced yet, **you are implementing**. That is forbidden.
**ALLOWED direct file operations:**
- Files inside \`.sisyphus/\` (plans, notepads, drafts)
- Reading files for verification
- Running diagnostics/tests
**Implementing yourself is the single most expensive failure mode of this role.**
Atlas is paid to ORCHESTRATE. The subagents are paid to IMPLEMENT. Every direct edit erodes the delegation pipeline you exist to run.
**FORBIDDEN direct file operations:**
- Writing/editing source code
- Creating new files outside \`.sisyphus/\`
- Any implementation work
Correct action — delegate via \`task()\`. Fan out in PARALLEL when multiple independent items remain (one message, multiple \`task()\` calls — never one-by-one):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
**IF THIS IS FOR VERIFICATION:**
Proceed if you are verifying subagent work by making a small fix.
But for any substantial changes, USE \`task\`.
**CORRECT APPROACH:**
\`\`\`
\`\`\`typescript
task(
category="...",
category="quick",
load_skills=[],
prompt="[specific single task with clear acceptance criteria]"
run_in_background=false,
prompt="[6 sections: TASK / EXPECTED OUTCOME / REQUIRED TOOLS / MUST DO / MUST NOT DO / CONTEXT]"
)
\`\`\`
DELEGATE. DON'T IMPLEMENT.
Allowed direct operations:
- \`.omo/\` files (plans, notepads)
- Reading any file (verification)
- Running commands (verification)
Everything else: DELEGATE.
---
`
@@ -217,33 +216,26 @@ export const SINGLE_TASK_DIRECTIVE = `
${createSystemDirective(SystemDirectiveTypes.SINGLE_TASK_ONLY)}
**STOP. READ THIS BEFORE PROCEEDING.**
**EXECUTION PROTOCOL**
If you were given **multiple genuinely independent goals** (unrelated tasks, parallel workstreams, separate features), you MUST:
1. **IMMEDIATELY REFUSE** this request
2. **DEMAND** the orchestrator provide a single goal
Work systematically. Each unit must be verified before proceeding.
**What counts as multiple independent tasks (REFUSE):**
- "Implement feature A. Also, add feature B."
- "Fix bug X. Then refactor module Y. Also update the docs."
- Multiple unrelated changes bundled into one request
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
**What is a single task with sequential steps (PROCEED):**
- A single goal broken into numbered steps (e.g., "Implement X by: 1. finding files, 2. adding logic, 3. writing tests")
- Multi-step context where all steps serve ONE objective
- Orchestrator-provided context explaining approach for a single deliverable
| Step | Action | Verification |
|------|--------|--------------|
| 1 | Identify first atomic unit | Smallest complete piece of work |
| 2 | Execute fully | Implement the change |
| 3 | Verify | \`lsp_diagnostics\`, tests, build |
| 4 | Report | State what's done, what remains |
| 5 | Continue | Next unit, or await if scope unclear |
**Your response if genuinely independent tasks are detected:**
> "I refuse to proceed. You provided multiple independent tasks. Each task needs full attention.
>
> PROVIDE EXACTLY ONE GOAL. One deliverable. One clear outcome.
>
> Batching unrelated tasks causes: incomplete work, missed edge cases, broken tests, wasted context."
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
**WARNING TO ORCHESTRATOR:**
- Bundling unrelated tasks RUINS deliverables
- Each independent goal needs FULL attention and PROPER verification
- Batch delegation of separate concerns = sloppy work = rework = wasted tokens
**VERIFICATION IS MANDATORY.** No skipping. No batching completions.
**REFUSE genuinely multi-task requests. ALLOW single-goal multi-step workflows.**
**IF SCOPE SEEMS BROAD:**
Complete the first logical unit. Report progress. Await further instruction if needed.
**REMEMBER:** Prometheus already decomposed the work. Execute what you receive.
`
@@ -8,6 +8,7 @@ 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"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
const isCallerOrchestratorMock = mock(async () => true)
const collectGitDiffStatsMock = mock(() => ({
@@ -15,15 +16,7 @@ const collectGitDiffStatsMock = mock(() => ({
insertions: 0,
deletions: 0,
}))
mock.module("../../shared/session-utils", () => ({
isCallerOrchestrator: isCallerOrchestratorMock,
}))
mock.module("../../shared/git-worktree", () => ({
collectGitDiffStats: collectGitDiffStatsMock,
formatFileChanges: mock(() => "No file changes"),
}))
const formatFileChangesMock = mock(() => "No file changes")
afterAll(() => { mock.restore() })
@@ -49,6 +42,7 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
isCallerOrchestratorMock.mockClear()
collectGitDiffStatsMock.mockClear()
formatFileChangesMock.mockClear()
})
afterEach(() => {
@@ -80,11 +74,11 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
function createHandler(parentSessionIDs?: Record<string, string | undefined>) {
const project = createProject()
const client = {
const client = unsafeTestValue<PluginInput["client"]>({
session: {
get: async (input: SessionGetInput) => createSessionGetResult(parentSessionIDs?.[input.path.id]),
},
} as unknown as PluginInput["client"]
})
if (parentSessionIDs) {
spyOn(client.session, "get").mockImplementation((input) => Promise.resolve(
@@ -107,6 +101,9 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
pendingTaskRefs: new Map(),
autoCommit: true,
getState: () => ({ promptFailureCount: 0 }),
isCallerOrchestrator: isCallerOrchestratorMock,
collectGitDiffStats: collectGitDiffStatsMock as never,
formatFileChanges: formatFileChangesMock as never,
})
}
@@ -141,11 +138,11 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
const childSessionID = "ses_child123"
const planPath = join(testDirectory, "background-launch-plan.md")
const project = createProject()
const client = {
const client = unsafeTestValue<PluginInput["client"]>({
session: {
get: async () => createSessionGetResult(undefined),
},
} as unknown as PluginInput["client"]
})
spyOn(client.session, "get").mockImplementation((input) => Promise.resolve(
createSessionGetResult(input?.path?.id === childSessionID ? sessionID : undefined),
@@ -174,13 +171,21 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
serverUrl: new URL("https://example.com"),
$: Bun.$,
} satisfies PluginInput
const beforeHandler = createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs })
const beforeHandler = createToolExecuteBeforeHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
isCallerOrchestrator: isCallerOrchestratorMock,
})
const afterHandler = createToolExecuteAfterHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
autoCommit: true,
getState: () => ({ promptFailureCount: 0 }),
isCallerOrchestrator: isCallerOrchestratorMock,
collectGitDiffStats: collectGitDiffStatsMock as never,
formatFileChanges: formatFileChangesMock as never,
})
await beforeHandler(
@@ -215,11 +220,11 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
const childSessionID = "ses_child_lookup_failure"
const planPath = join(testDirectory, "background-launch-plan.md")
const project = createProject()
const client = {
const client = unsafeTestValue<PluginInput["client"]>({
session: {
get: async () => createSessionGetResult(undefined),
},
} as unknown as PluginInput["client"]
})
spyOn(client.session, "get").mockImplementation((input) => {
if (input?.path?.id === childSessionID) {
@@ -251,13 +256,21 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
serverUrl: new URL("https://example.com"),
$: Bun.$,
} satisfies PluginInput
const beforeHandler = createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs })
const beforeHandler = createToolExecuteBeforeHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
isCallerOrchestrator: isCallerOrchestratorMock,
})
const afterHandler = createToolExecuteAfterHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
autoCommit: true,
getState: () => ({ promptFailureCount: 0 }),
isCallerOrchestrator: isCallerOrchestratorMock,
collectGitDiffStats: collectGitDiffStatsMock as never,
formatFileChanges: formatFileChangesMock as never,
})
await beforeHandler(
@@ -288,11 +301,11 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
const childSessionID = "ses_outside_lineage"
const planPath = join(testDirectory, "background-launch-plan.md")
const project = createProject()
const client = {
const client = unsafeTestValue<PluginInput["client"]>({
session: {
get: async () => createSessionGetResult(undefined),
},
} as unknown as PluginInput["client"]
})
spyOn(client.session, "get").mockImplementation((input) => Promise.resolve(
createSessionGetResult(input?.path?.id === childSessionID ? "ses_unrelated_parent" : undefined),
@@ -321,13 +334,21 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
serverUrl: new URL("https://example.com"),
$: Bun.$,
} satisfies PluginInput
const beforeHandler = createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs })
const beforeHandler = createToolExecuteBeforeHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
isCallerOrchestrator: isCallerOrchestratorMock,
})
const afterHandler = createToolExecuteAfterHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
autoCommit: true,
getState: () => ({ promptFailureCount: 0 }),
isCallerOrchestrator: isCallerOrchestratorMock,
collectGitDiffStats: collectGitDiffStatsMock as never,
formatFileChanges: formatFileChangesMock as never,
})
await beforeHandler(
@@ -358,11 +379,11 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
const childSessionID = "ses_unrelated_child"
const planPath = join(testDirectory, "background-launch-plan.md")
const project = createProject()
const client = {
const client = unsafeTestValue<PluginInput["client"]>({
session: {
get: async () => createSessionGetResult(undefined),
},
} as unknown as PluginInput["client"]
})
spyOn(client.session, "get").mockImplementation((input) => Promise.resolve(
createSessionGetResult(input?.path?.id === childSessionID ? sessionID : undefined),
@@ -392,13 +413,21 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
serverUrl: new URL("https://example.com"),
$: Bun.$,
} satisfies PluginInput
const beforeHandler = createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs })
const beforeHandler = createToolExecuteBeforeHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
isCallerOrchestrator: isCallerOrchestratorMock,
})
const afterHandler = createToolExecuteAfterHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
autoCommit: true,
getState: () => ({ promptFailureCount: 0 }),
isCallerOrchestrator: isCallerOrchestratorMock,
collectGitDiffStats: collectGitDiffStatsMock as never,
formatFileChanges: formatFileChangesMock as never,
})
await beforeHandler(
@@ -424,6 +453,102 @@ 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 = unsafeTestValue<PluginInput["client"]>({
session: {
get: async () => createSessionGetResult(undefined),
},
})
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,
isCallerOrchestrator: isCallerOrchestratorMock,
})
const afterHandler = createToolExecuteAfterHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
autoCommit: true,
getState: () => ({ promptFailureCount: 0 }),
isCallerOrchestrator: isCallerOrchestratorMock,
collectGitDiffStats: collectGitDiffStatsMock as never,
formatFileChanges: formatFileChangesMock as never,
})
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,432 @@
/// <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,
}))
const formatFileChangesMock = 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()
formatFileChangesMock.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,
isCallerOrchestrator: isCallerOrchestratorMock,
}),
afterHandler: createToolExecuteAfterHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
pendingPlanSnapshots,
autoCommit: true,
getState: () => ({ promptFailureCount: 0 }),
isCallerOrchestrator: isCallerOrchestratorMock,
collectGitDiffStats: collectGitDiffStatsMock as never,
formatFileChanges: formatFileChangesMock as never,
}),
}
}
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, ".omo", "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")
})
})
+185 -25
View File
@@ -1,11 +1,17 @@
import type { PluginInput } from "@opencode-ai/plugin"
import {
appendSessionId,
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"
@@ -13,7 +19,7 @@ import { collectGitDiffStats, formatFileChanges } from "../../shared/git-worktre
import { shouldPauseForFinalWaveApproval } from "./final-wave-approval-gate"
import { HOOK_NAME } from "./hook-name"
import { DIRECT_WORK_REMINDER } from "./system-reminder-templates"
import { isSisyphusPath } from "./sisyphus-path"
import { isOmoPath } from "./omo-path"
import { resolvePreferredSessionId, resolveTaskContext } from "./task-context"
import { extractSessionIdFromMetadata, extractSessionIdFromOutput, validateSubagentSessionId } from "./subagent-session-id"
import {
@@ -26,33 +32,150 @@ 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
}): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput) => Promise<void> {
const { ctx, pendingFilePaths, pendingTaskRefs, autoCommit, getState } = input
isCallerOrchestrator?: (sessionID: string | undefined) => Promise<boolean>
collectGitDiffStats?: typeof collectGitDiffStats
formatFileChanges?: typeof formatFileChanges
}): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput | undefined) => Promise<void> {
const { ctx, pendingFilePaths, pendingTaskRefs, pendingPlanSnapshots, autoCommit, getState } = input
const resolveIsCallerOrchestrator = input.isCallerOrchestrator ?? ((sessionID) => isCallerOrchestrator(sessionID, ctx.client))
const collectGitDiffStatsImpl = input.collectGitDiffStats ?? collectGitDiffStats
const formatFileChangesImpl = input.formatFileChanges ?? formatFileChanges
return async (toolInput, toolOutput): Promise<void> => {
// Guard against undefined output (e.g., from /review command - see issue #1035)
if (!toolOutput) {
return
}
if (!(await isCallerOrchestrator(toolInput.sessionID, ctx.client))) {
if (!(await resolveIsCallerOrchestrator(toolInput.sessionID))) {
return
}
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 && !isSisyphusPath(filePath)) {
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 && !isOmoPath(filePath)) {
toolOutput.output = (toolOutput.output || "") + DIRECT_WORK_REMINDER
log(`[${HOOK_NAME}] Direct work reminder appended`, {
sessionID: toolInput.sessionID,
@@ -93,23 +216,46 @@ export function createToolExecuteAfterHandler(input: {
if (toolOutput.output && typeof toolOutput.output === "string") {
const worktreePath = boulderState?.worktree_path?.trim()
const verificationDirectory = worktreePath ? worktreePath : ctx.directory
const gitStats = collectGitDiffStats(verificationDirectory)
const fileChanges = formatFileChanges(gitStats)
const gitStats = collectGitDiffStatsImpl(verificationDirectory)
const fileChanges = formatFileChangesImpl(gitStats)
const extractedSessionId = metadataSessionId ?? extractSessionIdFromOutput(toolOutput.output)
if (boulderState) {
const progress = getPlanProgress(boulderState.active_plan)
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,
shouldSkipTaskSessionUpdate,
shouldIgnoreCurrentSessionId,
} = resolveTaskContext(pendingTaskRef, boulderState.active_plan)
} = resolveTaskContext(pendingTaskRef, planPath)
const trackedTaskSession = currentTask
? getTaskSessionState(ctx.directory, currentTask.key)
: 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,
@@ -117,14 +263,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(
@@ -136,7 +296,7 @@ export function createToolExecuteAfterHandler(input: {
const originalResponse = toolOutput.output
const shouldPauseForApproval = sessionState
? shouldPauseForFinalWaveApproval({
planPath: boulderState.active_plan,
planPath,
taskOutput: originalResponse,
sessionState,
})
@@ -152,11 +312,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>
@@ -178,8 +338,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,
+105 -18
View File
@@ -2,29 +2,77 @@ import { log } from "../../shared/logger"
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
import { isCallerOrchestrator } from "../../shared/session-utils"
import type { PluginInput } from "@opencode-ai/plugin"
import { readBoulderState, readCurrentTopLevelTask } from "../../features/boulder-state"
import { 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 { isOmoPath } from "./omo-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 {
pendingTaskRefs.set(callID, { kind: "track", task })
}
return async (toolInput, toolOutput): Promise<void> => {
if (!(await isCallerOrchestrator(toolInput.sessionID, ctx.client))) {
if (!(await resolveIsCallerOrchestrator(toolInput.sessionID))) {
return
}
@@ -32,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 (!isOmoPath(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`, {
@@ -58,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(boulderState.active_plan)
? 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)
}
}
}
+9 -2
View File
@@ -1,14 +1,19 @@
import type { AgentOverrides } from "../../config"
import type { BackgroundManager } from "../../features/background-agent"
import type { TopLevelTaskRef } from "../../features/boulder-state"
export type ModelInfo = { providerID: string; modelID: string; variant?: string }
export interface BackgroundTaskStatusProvider {
getTasksByParentSession: (sessionID: string) => Array<{ status: string }>
}
export interface AtlasHookOptions {
directory: string
backgroundManager?: BackgroundManager
backgroundManager?: BackgroundTaskStatusProvider
isContinuationStopped?: (sessionID: string) => boolean
isCallerOrchestrator?: (sessionID: string | undefined) => Promise<boolean>
agentOverrides?: AgentOverrides
idleSettleMs?: number
/** Enable auto-commit after each atomic task completion (default: true) */
autoCommit?: boolean
}
@@ -34,6 +39,7 @@ export type PendingTaskRef =
export interface SessionState {
lastEventWasAbortError?: boolean
skipNextIdleAfterRuntimeErrorRetry?: boolean
lastContinuationInjectedAt?: number
isInjectingContinuation?: boolean
promptFailureCount: number
@@ -42,4 +48,5 @@ export interface SessionState {
waitingForFinalWaveApproval?: boolean
pendingFinalWaveTaskCount?: number
approvedFinalWaveTaskCount?: number
boulderCompletionNudgedAt?: Record<string, number>
}
@@ -26,7 +26,7 @@ describe("buildCompletionGate", () => {
then("gate interpolates the plan name path", () => {
expect(gate).toContain(planName)
expect(gate).toContain(`.sisyphus/plans/${planName}.md`)
expect(gate).toContain(`.omo/plans/${planName}.md`)
})
then("gate includes Edit instructions", () => {
+5 -5
View File
@@ -15,13 +15,13 @@ export function buildCompletionGate(planName: string, sessionId: string): string
Your completion will NOT be recorded until you complete ALL of the following:
1. **Edit** the plan file \`.sisyphus/plans/${planName}.md\`:
1. **Edit** the plan file \`.omo/plans/${planName}.md\`:
- Change \`- [ ]\` to \`- [x]\` for the completed task
- Use \`Edit\` tool to modify the checkbox
2. **Read** the plan file AGAIN:
\`\`\`
Read(".sisyphus/plans/${planName}.md")
Read(".omo/plans/${planName}.md")
\`\`\`
- Verify the checkbox count changed (more \`- [x]\` than before)
@@ -88,7 +88,7 @@ ${includeCompletionGate ? `${buildCompletionGate(planName, sessionId)}
The subagent was instructed to record findings in notepad files. Read them NOW:
\`\`\`
Glob(".sisyphus/notepads/${planName}/*.md")
Glob(".omo/notepads/${planName}/*.md")
\`\`\`
Then \`Read\` each file found - especially:
- **learnings.md**: Patterns, conventions, successful approaches discovered
@@ -104,7 +104,7 @@ Then \`Read\` each file found - especially:
Do NOT rely on cached progress. Read the plan file NOW:
\`\`\`
Read(".sisyphus/plans/${planName}.md")
Read(".omo/plans/${planName}.md")
\`\`\`
Count exactly: how many \`- [ ]\` remain? How many \`- [x]\` completed?
This is YOUR ground truth. Use it to decide what comes next.
@@ -143,7 +143,7 @@ The last Final Verification Wave result just passed.
This is the ONLY point where approval-style user interaction is required.
1. Read \
\`.sisyphus/plans/${planName}.md\` again and confirm every remaining unchecked **top-level** task belongs to F1-F4.
\`.omo/plans/${planName}.md\` again and confirm every remaining unchecked **top-level** task belongs to F1-F4.
Ignore nested checkboxes under Acceptance Criteria, Evidence, or Final Checklist sections.
2. Consolidate the F1-F4 verdicts into a short summary for the user.
3. Tell the user all final reviewers approved.
+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)