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:
@@ -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` |
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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({
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+434
-157
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
Reference in New Issue
Block a user