refactor(hooks): rename sisyphus-orchestrator to atlas

Hook name is now 'atlas' (not 'atlas-orchestrator'). Directory renamed to src/hooks/atlas/. All references updated.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
justsisyphus
2026-01-20 15:52:13 +09:00
parent 96bcd9788a
commit c4b862cbc4
5 changed files with 93 additions and 89 deletions
+953
View File
@@ -0,0 +1,953 @@
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { createSisyphusOrchestratorHook } from "./index"
import {
writeBoulderState,
clearBoulderState,
readBoulderState,
} from "../../features/boulder-state"
import type { BoulderState } from "../../features/boulder-state"
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
describe("atlas hook", () => {
const TEST_DIR = join(tmpdir(), "atlas-test-" + Date.now())
const SISYPHUS_DIR = join(TEST_DIR, ".sisyphus")
function createMockPluginInput(overrides?: { promptMock?: ReturnType<typeof mock> }) {
const promptMock = overrides?.promptMock ?? mock(() => Promise.resolve())
return {
directory: TEST_DIR,
client: {
session: {
prompt: promptMock,
},
},
_promptMock: promptMock,
} as unknown as Parameters<typeof createSisyphusOrchestratorHook>[0] & { _promptMock: ReturnType<typeof mock> }
}
function setupMessageStorage(sessionID: string, agent: string): void {
const messageDir = join(MESSAGE_STORAGE, sessionID)
if (!existsSync(messageDir)) {
mkdirSync(messageDir, { recursive: true })
}
const messageData = {
agent,
model: { providerID: "anthropic", modelID: "claude-opus-4-5" },
}
writeFileSync(join(messageDir, "msg_test001.json"), JSON.stringify(messageData))
}
function cleanupMessageStorage(sessionID: string): void {
const messageDir = join(MESSAGE_STORAGE, sessionID)
if (existsSync(messageDir)) {
rmSync(messageDir, { recursive: true, force: true })
}
}
beforeEach(() => {
if (!existsSync(TEST_DIR)) {
mkdirSync(TEST_DIR, { recursive: true })
}
if (!existsSync(SISYPHUS_DIR)) {
mkdirSync(SISYPHUS_DIR, { recursive: true })
}
clearBoulderState(TEST_DIR)
})
afterEach(() => {
clearBoulderState(TEST_DIR)
if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true })
}
})
describe("tool.execute.after handler", () => {
test("should ignore non-delegate_task tools", async () => {
// #given - hook and non-delegate_task tool
const hook = createSisyphusOrchestratorHook(createMockPluginInput())
const output = {
title: "Test Tool",
output: "Original output",
metadata: {},
}
// #when
await hook["tool.execute.after"](
{ tool: "other_tool", sessionID: "session-123" },
output
)
// #then - output unchanged
expect(output.output).toBe("Original output")
})
test("should not transform when caller is not atlas", async () => {
// #given - boulder state exists but caller agent in message storage is not atlas
const sessionID = "session-non-orchestrator-test"
setupMessageStorage(sessionID, "other-agent")
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: ["session-1"],
plan_name: "test-plan",
}
writeBoulderState(TEST_DIR, state)
const hook = createSisyphusOrchestratorHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task completed successfully",
metadata: {},
}
// #when
await hook["tool.execute.after"](
{ tool: "delegate_task", sessionID },
output
)
// #then - output unchanged because caller is not orchestrator
expect(output.output).toBe("Task completed successfully")
cleanupMessageStorage(sessionID)
})
test("should append standalone verification when no boulder state but caller is atlas", async () => {
// #given - no boulder state, but caller is atlas
const sessionID = "session-no-boulder-test"
setupMessageStorage(sessionID, "atlas")
const hook = createSisyphusOrchestratorHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task completed successfully",
metadata: {},
}
// #when
await hook["tool.execute.after"](
{ tool: "delegate_task", sessionID },
output
)
// #then - standalone verification reminder appended
expect(output.output).toContain("Task completed successfully")
expect(output.output).toContain("MANDATORY:")
expect(output.output).toContain("delegate_task(resume=")
cleanupMessageStorage(sessionID)
})
test("should transform output when caller is atlas with boulder state", async () => {
// #given - atlas caller with boulder state
const sessionID = "session-transform-test"
setupMessageStorage(sessionID, "atlas")
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [x] Task 2")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: ["session-1"],
plan_name: "test-plan",
}
writeBoulderState(TEST_DIR, state)
const hook = createSisyphusOrchestratorHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task completed successfully",
metadata: {},
}
// #when
await hook["tool.execute.after"](
{ tool: "delegate_task", sessionID },
output
)
// #then - output should be transformed (original output preserved for debugging)
expect(output.output).toContain("Task completed successfully")
expect(output.output).toContain("SUBAGENT WORK COMPLETED")
expect(output.output).toContain("test-plan")
expect(output.output).toContain("LIE")
expect(output.output).toContain("delegate_task(resume=")
cleanupMessageStorage(sessionID)
})
test("should still transform when plan is complete (shows progress)", async () => {
// #given - boulder state with complete plan, atlas caller
const sessionID = "session-complete-plan-test"
setupMessageStorage(sessionID, "atlas")
const planPath = join(TEST_DIR, "complete-plan.md")
writeFileSync(planPath, "# Plan\n- [x] Task 1\n- [x] Task 2")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: ["session-1"],
plan_name: "complete-plan",
}
writeBoulderState(TEST_DIR, state)
const hook = createSisyphusOrchestratorHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Original output",
metadata: {},
}
// #when
await hook["tool.execute.after"](
{ tool: "delegate_task", sessionID },
output
)
// #then - output transformed even when complete (shows 2/2 done)
expect(output.output).toContain("SUBAGENT WORK COMPLETED")
expect(output.output).toContain("2/2 done")
expect(output.output).toContain("0 remaining")
cleanupMessageStorage(sessionID)
})
test("should append session ID to boulder state if not present", async () => {
// #given - boulder state without session-append-test, atlas caller
const sessionID = "session-append-test"
setupMessageStorage(sessionID, "atlas")
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: ["session-1"],
plan_name: "test-plan",
}
writeBoulderState(TEST_DIR, state)
const hook = createSisyphusOrchestratorHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task output",
metadata: {},
}
// #when
await hook["tool.execute.after"](
{ tool: "delegate_task", sessionID },
output
)
// #then - sessionID should be appended
const updatedState = readBoulderState(TEST_DIR)
expect(updatedState?.session_ids).toContain(sessionID)
cleanupMessageStorage(sessionID)
})
test("should not duplicate existing session ID", async () => {
// #given - boulder state already has session-dup-test, atlas caller
const sessionID = "session-dup-test"
setupMessageStorage(sessionID, "atlas")
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [sessionID],
plan_name: "test-plan",
}
writeBoulderState(TEST_DIR, state)
const hook = createSisyphusOrchestratorHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task output",
metadata: {},
}
// #when
await hook["tool.execute.after"](
{ tool: "delegate_task", sessionID },
output
)
// #then - should still have only one sessionID
const updatedState = readBoulderState(TEST_DIR)
const count = updatedState?.session_ids.filter((id) => id === sessionID).length
expect(count).toBe(1)
cleanupMessageStorage(sessionID)
})
test("should include boulder.json path and notepad path in transformed output", async () => {
// #given - boulder state, atlas caller
const sessionID = "session-path-test"
setupMessageStorage(sessionID, "atlas")
const planPath = join(TEST_DIR, "my-feature.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2\n- [x] Task 3")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: ["session-1"],
plan_name: "my-feature",
}
writeBoulderState(TEST_DIR, state)
const hook = createSisyphusOrchestratorHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task completed",
metadata: {},
}
// #when
await hook["tool.execute.after"](
{ tool: "delegate_task", sessionID },
output
)
// #then - output should contain plan name and progress
expect(output.output).toContain("my-feature")
expect(output.output).toContain("1/3 done")
expect(output.output).toContain("2 remaining")
cleanupMessageStorage(sessionID)
})
test("should include resume and checkbox instructions in reminder", async () => {
// #given - boulder state, atlas caller
const sessionID = "session-resume-test"
setupMessageStorage(sessionID, "atlas")
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: ["session-1"],
plan_name: "test-plan",
}
writeBoulderState(TEST_DIR, state)
const hook = createSisyphusOrchestratorHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task completed",
metadata: {},
}
// #when
await hook["tool.execute.after"](
{ tool: "delegate_task", sessionID },
output
)
// #then - should include resume instructions and verification
expect(output.output).toContain("delegate_task(resume=")
expect(output.output).toContain("[x]")
expect(output.output).toContain("MANDATORY:")
cleanupMessageStorage(sessionID)
})
describe("Write/Edit tool direct work reminder", () => {
const ORCHESTRATOR_SESSION = "orchestrator-write-test"
beforeEach(() => {
setupMessageStorage(ORCHESTRATOR_SESSION, "atlas")
})
afterEach(() => {
cleanupMessageStorage(ORCHESTRATOR_SESSION)
})
test("should append delegation reminder when orchestrator writes outside .sisyphus/", async () => {
// #given
const hook = createSisyphusOrchestratorHook(createMockPluginInput())
const output = {
title: "Write",
output: "File written successfully",
metadata: { filePath: "/path/to/code.ts" },
}
// #when
await hook["tool.execute.after"](
{ tool: "Write", sessionID: ORCHESTRATOR_SESSION },
output
)
// #then
expect(output.output).toContain("DELEGATION REQUIRED")
expect(output.output).toContain("ORCHESTRATOR, not an IMPLEMENTER")
expect(output.output).toContain("delegate_task")
})
test("should append delegation reminder when orchestrator edits outside .sisyphus/", async () => {
// #given
const hook = createSisyphusOrchestratorHook(createMockPluginInput())
const output = {
title: "Edit",
output: "File edited successfully",
metadata: { filePath: "/src/components/button.tsx" },
}
// #when
await hook["tool.execute.after"](
{ tool: "Edit", sessionID: ORCHESTRATOR_SESSION },
output
)
// #then
expect(output.output).toContain("DELEGATION REQUIRED")
})
test("should NOT append reminder when orchestrator writes inside .sisyphus/", async () => {
// #given
const hook = createSisyphusOrchestratorHook(createMockPluginInput())
const originalOutput = "File written successfully"
const output = {
title: "Write",
output: originalOutput,
metadata: { filePath: "/project/.sisyphus/plans/work-plan.md" },
}
// #when
await hook["tool.execute.after"](
{ tool: "Write", sessionID: ORCHESTRATOR_SESSION },
output
)
// #then
expect(output.output).toBe(originalOutput)
expect(output.output).not.toContain("DELEGATION REQUIRED")
})
test("should NOT append reminder when non-orchestrator writes outside .sisyphus/", async () => {
// #given
const nonOrchestratorSession = "non-orchestrator-session"
setupMessageStorage(nonOrchestratorSession, "Sisyphus-Junior")
const hook = createSisyphusOrchestratorHook(createMockPluginInput())
const originalOutput = "File written successfully"
const output = {
title: "Write",
output: originalOutput,
metadata: { filePath: "/path/to/code.ts" },
}
// #when
await hook["tool.execute.after"](
{ tool: "Write", sessionID: nonOrchestratorSession },
output
)
// #then
expect(output.output).toBe(originalOutput)
expect(output.output).not.toContain("DELEGATION REQUIRED")
cleanupMessageStorage(nonOrchestratorSession)
})
test("should NOT append reminder for read-only tools", async () => {
// #given
const hook = createSisyphusOrchestratorHook(createMockPluginInput())
const originalOutput = "File content"
const output = {
title: "Read",
output: originalOutput,
metadata: { filePath: "/path/to/code.ts" },
}
// #when
await hook["tool.execute.after"](
{ tool: "Read", sessionID: ORCHESTRATOR_SESSION },
output
)
// #then
expect(output.output).toBe(originalOutput)
})
test("should handle missing filePath gracefully", async () => {
// #given
const hook = createSisyphusOrchestratorHook(createMockPluginInput())
const originalOutput = "File written successfully"
const output = {
title: "Write",
output: originalOutput,
metadata: {},
}
// #when
await hook["tool.execute.after"](
{ tool: "Write", sessionID: ORCHESTRATOR_SESSION },
output
)
// #then
expect(output.output).toBe(originalOutput)
})
describe("cross-platform path validation (Windows support)", () => {
test("should NOT append reminder when orchestrator writes inside .sisyphus\\ (Windows backslash)", async () => {
// #given
const hook = createSisyphusOrchestratorHook(createMockPluginInput())
const originalOutput = "File written successfully"
const output = {
title: "Write",
output: originalOutput,
metadata: { filePath: ".sisyphus\\plans\\work-plan.md" },
}
// #when
await hook["tool.execute.after"](
{ tool: "Write", sessionID: ORCHESTRATOR_SESSION },
output
)
// #then
expect(output.output).toBe(originalOutput)
expect(output.output).not.toContain("DELEGATION REQUIRED")
})
test("should NOT append reminder when orchestrator writes inside .sisyphus with mixed separators", async () => {
// #given
const hook = createSisyphusOrchestratorHook(createMockPluginInput())
const originalOutput = "File written successfully"
const output = {
title: "Write",
output: originalOutput,
metadata: { filePath: ".sisyphus\\plans/work-plan.md" },
}
// #when
await hook["tool.execute.after"](
{ tool: "Write", sessionID: ORCHESTRATOR_SESSION },
output
)
// #then
expect(output.output).toBe(originalOutput)
expect(output.output).not.toContain("DELEGATION REQUIRED")
})
test("should NOT append reminder for absolute Windows path inside .sisyphus\\", async () => {
// #given
const hook = createSisyphusOrchestratorHook(createMockPluginInput())
const originalOutput = "File written successfully"
const output = {
title: "Write",
output: originalOutput,
metadata: { filePath: "C:\\Users\\test\\project\\.sisyphus\\plans\\x.md" },
}
// #when
await hook["tool.execute.after"](
{ tool: "Write", sessionID: ORCHESTRATOR_SESSION },
output
)
// #then
expect(output.output).toBe(originalOutput)
expect(output.output).not.toContain("DELEGATION REQUIRED")
})
test("should append reminder for Windows path outside .sisyphus\\", async () => {
// #given
const hook = createSisyphusOrchestratorHook(createMockPluginInput())
const output = {
title: "Write",
output: "File written successfully",
metadata: { filePath: "C:\\Users\\test\\project\\src\\code.ts" },
}
// #when
await hook["tool.execute.after"](
{ tool: "Write", sessionID: ORCHESTRATOR_SESSION },
output
)
// #then
expect(output.output).toContain("DELEGATION REQUIRED")
})
})
})
})
describe("session.idle handler (boulder continuation)", () => {
const MAIN_SESSION_ID = "main-session-123"
beforeEach(() => {
mock.module("../../features/claude-code-session-state", () => ({
getMainSessionID: () => MAIN_SESSION_ID,
subagentSessions: new Set<string>(),
}))
setupMessageStorage(MAIN_SESSION_ID, "atlas")
})
afterEach(() => {
cleanupMessageStorage(MAIN_SESSION_ID)
})
test("should inject continuation when boulder has incomplete tasks", async () => {
// #given - boulder state with incomplete plan
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [x] Task 2\n- [ ] Task 3")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
}
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createSisyphusOrchestratorHook(mockInput)
// #when
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
// #then - should call prompt with continuation
expect(mockInput._promptMock).toHaveBeenCalled()
const callArgs = mockInput._promptMock.mock.calls[0][0]
expect(callArgs.path.id).toBe(MAIN_SESSION_ID)
expect(callArgs.body.parts[0].text).toContain("BOULDER CONTINUATION")
expect(callArgs.body.parts[0].text).toContain("2 remaining")
})
test("should not inject when no boulder state exists", async () => {
// #given - no boulder state
const mockInput = createMockPluginInput()
const hook = createSisyphusOrchestratorHook(mockInput)
// #when
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
// #then - should not call prompt
expect(mockInput._promptMock).not.toHaveBeenCalled()
})
test("should not inject when boulder plan is complete", async () => {
// #given - boulder state with complete plan
const planPath = join(TEST_DIR, "complete-plan.md")
writeFileSync(planPath, "# Plan\n- [x] Task 1\n- [x] Task 2")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "complete-plan",
}
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createSisyphusOrchestratorHook(mockInput)
// #when
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
// #then - should not call prompt
expect(mockInput._promptMock).not.toHaveBeenCalled()
})
test("should skip when abort error occurred before idle", async () => {
// #given - boulder state with incomplete plan
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
}
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createSisyphusOrchestratorHook(mockInput)
// #when - send abort error then idle
await hook.handler({
event: {
type: "session.error",
properties: {
sessionID: MAIN_SESSION_ID,
error: { name: "AbortError", message: "aborted" },
},
},
})
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
// #then - should not call prompt
expect(mockInput._promptMock).not.toHaveBeenCalled()
})
test("should skip when background tasks are running", async () => {
// #given - boulder state with incomplete plan
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
}
writeBoulderState(TEST_DIR, state)
const mockBackgroundManager = {
getTasksByParentSession: () => [{ status: "running" }],
}
const mockInput = createMockPluginInput()
const hook = createSisyphusOrchestratorHook(mockInput, {
directory: TEST_DIR,
backgroundManager: mockBackgroundManager as any,
})
// #when
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
// #then - should not call prompt
expect(mockInput._promptMock).not.toHaveBeenCalled()
})
test("should clear abort state on message.updated", async () => {
// #given - boulder with incomplete plan
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
}
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createSisyphusOrchestratorHook(mockInput)
// #when - abort error, then message update, then idle
await hook.handler({
event: {
type: "session.error",
properties: {
sessionID: MAIN_SESSION_ID,
error: { name: "AbortError" },
},
},
})
await hook.handler({
event: {
type: "message.updated",
properties: { info: { sessionID: MAIN_SESSION_ID, role: "user" } },
},
})
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
// #then - should call prompt because abort state was cleared
expect(mockInput._promptMock).toHaveBeenCalled()
})
test("should include plan progress in continuation prompt", async () => {
// #given - boulder state with specific progress
const planPath = join(TEST_DIR, "progress-plan.md")
writeFileSync(planPath, "# Plan\n- [x] Task 1\n- [x] Task 2\n- [ ] Task 3\n- [ ] Task 4")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "progress-plan",
}
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createSisyphusOrchestratorHook(mockInput)
// #when
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
// #then - should include progress
const callArgs = mockInput._promptMock.mock.calls[0][0]
expect(callArgs.body.parts[0].text).toContain("2/4 completed")
expect(callArgs.body.parts[0].text).toContain("2 remaining")
})
test("should not inject when last agent is not atlas", async () => {
// #given - boulder state with incomplete plan, but last agent is NOT atlas
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
}
writeBoulderState(TEST_DIR, state)
// #given - last agent is NOT atlas
cleanupMessageStorage(MAIN_SESSION_ID)
setupMessageStorage(MAIN_SESSION_ID, "Sisyphus")
const mockInput = createMockPluginInput()
const hook = createSisyphusOrchestratorHook(mockInput)
// #when
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
// #then - should NOT call prompt because agent is not atlas
expect(mockInput._promptMock).not.toHaveBeenCalled()
})
test("should debounce rapid continuation injections (prevent infinite loop)", async () => {
// #given - boulder state with incomplete plan
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
}
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createSisyphusOrchestratorHook(mockInput)
// #when - fire multiple idle events in rapid succession (simulating infinite loop bug)
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
// #then - should only call prompt ONCE due to debouncing
expect(mockInput._promptMock).toHaveBeenCalledTimes(1)
})
test("should cleanup on session.deleted", async () => {
// #given - boulder state
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
}
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createSisyphusOrchestratorHook(mockInput)
// #when - create abort state then delete
await hook.handler({
event: {
type: "session.error",
properties: {
sessionID: MAIN_SESSION_ID,
error: { name: "AbortError" },
},
},
})
await hook.handler({
event: {
type: "session.deleted",
properties: { info: { id: MAIN_SESSION_ID } },
},
})
// Re-create boulder after deletion
writeBoulderState(TEST_DIR, state)
// Trigger idle - should inject because state was cleaned up
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
// #then - should call prompt because session state was cleaned
expect(mockInput._promptMock).toHaveBeenCalled()
})
})
})
+771
View File
@@ -0,0 +1,771 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { execSync } from "node:child_process"
import { existsSync, readdirSync } from "node:fs"
import { join } from "node:path"
import {
readBoulderState,
appendSessionId,
getPlanProgress,
} from "../../features/boulder-state"
import { getMainSessionID, subagentSessions } from "../../features/claude-code-session-state"
import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector"
import { log } from "../../shared/logger"
import { createSystemDirective, SYSTEM_DIRECTIVE_PREFIX, SystemDirectiveTypes } from "../../shared/system-directive"
import type { BackgroundManager } from "../../features/background-agent"
export const HOOK_NAME = "atlas"
/**
* Cross-platform check if a path is inside .sisyphus/ directory.
* Handles both forward slashes (Unix) and backslashes (Windows).
*/
function isSisyphusPath(filePath: string): boolean {
return /\.sisyphus[/\\]/.test(filePath)
}
const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit"]
const DIRECT_WORK_REMINDER = `
---
${createSystemDirective(SystemDirectiveTypes.DELEGATION_REQUIRED)}
You just performed direct file modifications outside \`.sisyphus/\`.
**You are an ORCHESTRATOR, not an IMPLEMENTER.**
As an orchestrator, you should:
- **DELEGATE** implementation work to subagents via \`delegate_task\`
- **VERIFY** the work done by subagents
- **COORDINATE** multiple tasks and ensure completion
You should NOT:
- Write code directly (except for \`.sisyphus/\` files like plans and notepads)
- Make direct file edits outside \`.sisyphus/\`
- Implement features yourself
**If you need to make changes:**
1. Use \`delegate_task\` to delegate to an appropriate subagent
2. Provide clear instructions in the prompt
3. Verify the subagent's work after completion
---
`
const BOULDER_CONTINUATION_PROMPT = `${createSystemDirective(SystemDirectiveTypes.BOULDER_CONTINUATION)}
You have an active work plan with incomplete tasks. Continue working.
RULES:
- Proceed without asking for permission
- Mark each checkbox [x] in the plan file when done
- Use the notepad at .sisyphus/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`
const VERIFICATION_REMINDER = `**MANDATORY: WHAT YOU MUST DO RIGHT NOW**
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⚠️ CRITICAL: Subagents FREQUENTLY LIE about completion.
Tests FAILING, code has ERRORS, implementation INCOMPLETE - but they say "done".
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
**STEP 1: VERIFY WITH YOUR OWN TOOL CALLS (DO THIS NOW)**
Run these commands YOURSELF - do NOT trust agent's claims:
1. \`lsp_diagnostics\` on changed files → Must be CLEAN
2. \`bash\` to run tests → Must PASS
3. \`bash\` to run build/typecheck → Must succeed
4. \`Read\` the actual code → Must match requirements
**STEP 2: DETERMINE IF HANDS-ON QA IS NEEDED**
| Deliverable Type | QA Method | Tool |
|------------------|-----------|------|
| **Frontend/UI** | Browser interaction | \`/playwright\` skill |
| **TUI/CLI** | Run interactively | \`interactive_bash\` (tmux) |
| **API/Backend** | Send real requests | \`bash\` with curl |
Static analysis CANNOT catch: visual bugs, animation issues, user flow breakages.
**STEP 3: IF QA IS NEEDED - ADD TO TODO IMMEDIATELY**
\`\`\`
todowrite([
{ id: "qa-X", content: "HANDS-ON QA: [specific verification action]", status: "pending", priority: "high" }
])
\`\`\`
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
**BLOCKING: DO NOT proceed to Step 4 until Steps 1-3 are VERIFIED.**`
const ORCHESTRATOR_DELEGATION_REQUIRED = `
---
⚠️⚠️⚠️ ${createSystemDirective(SystemDirectiveTypes.DELEGATION_REQUIRED)} ⚠️⚠️⚠️
**STOP. YOU ARE VIOLATING ORCHESTRATOR PROTOCOL.**
You (atlas) are attempting to directly modify a file outside \`.sisyphus/\`.
**Path attempted:** $FILE_PATH
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🚫 **THIS IS FORBIDDEN** (except for VERIFICATION purposes)
As an ORCHESTRATOR, you MUST:
1. **DELEGATE** all implementation work via \`delegate_task\`
2. **VERIFY** the work done by subagents (reading files is OK)
3. **COORDINATE** - you orchestrate, you don't implement
**ALLOWED direct file operations:**
- Files inside \`.sisyphus/\` (plans, notepads, drafts)
- Reading files for verification
- Running diagnostics/tests
**FORBIDDEN direct file operations:**
- Writing/editing source code
- Creating new files outside \`.sisyphus/\`
- Any implementation work
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
**IF THIS IS FOR VERIFICATION:**
Proceed if you are verifying subagent work by making a small fix.
But for any substantial changes, USE \`delegate_task\`.
**CORRECT APPROACH:**
\`\`\`
delegate_task(
category="...",
prompt="[specific single task with clear acceptance criteria]"
)
\`\`\`
⚠️⚠️⚠️ DELEGATE. DON'T IMPLEMENT. ⚠️⚠️⚠️
---
`
const SINGLE_TASK_DIRECTIVE = `
${createSystemDirective(SystemDirectiveTypes.SINGLE_TASK_ONLY)}
**STOP. READ THIS BEFORE PROCEEDING.**
If you were NOT given **exactly ONE atomic task**, you MUST:
1. **IMMEDIATELY REFUSE** this request
2. **DEMAND** the orchestrator provide a single, specific task
**Your response if multiple tasks detected:**
> "I refuse to proceed. You provided multiple tasks. An orchestrator's impatience destroys work quality.
>
> PROVIDE EXACTLY ONE TASK. One file. One change. One verification.
>
> Your rushing will cause: incomplete work, missed edge cases, broken tests, wasted context."
**WARNING TO ORCHESTRATOR:**
- Your hasty batching RUINS deliverables
- Each task needs FULL attention and PROPER verification
- Batch delegation = sloppy work = rework = wasted tokens
**REFUSE multi-task requests. DEMAND single-task clarity.**
`
function buildVerificationReminder(sessionId: string): string {
return `${VERIFICATION_REMINDER}
---
**If ANY verification fails, use this immediately:**
\`\`\`
delegate_task(resume="${sessionId}", prompt="fix: [describe the specific failure]")
\`\`\``
}
function buildOrchestratorReminder(planName: string, progress: { total: number; completed: number }, sessionId: string): string {
const remaining = progress.total - progress.completed
return `
---
**BOULDER STATE:** Plan: \`${planName}\` | ${progress.completed}/${progress.total} done | ${remaining} remaining
---
${buildVerificationReminder(sessionId)}
**STEP 4: MARK COMPLETION IN PLAN FILE (IMMEDIATELY)**
RIGHT NOW - Do not delay. Verification passed → Mark IMMEDIATELY.
Update the plan file \`.sisyphus/tasks/${planName}.yaml\`:
- Change \`[ ]\` to \`[x]\` for the completed task
- Use \`Edit\` tool to modify the checkbox
**DO THIS BEFORE ANYTHING ELSE. Unmarked = Untracked = Lost progress.**
**STEP 5: COMMIT ATOMIC UNIT**
- Stage ONLY the verified changes
- Commit with clear message describing what was done
**STEP 6: PROCEED TO NEXT TASK**
- Read the plan file to identify the next \`[ ]\` task
- Start immediately - DO NOT STOP
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
**${remaining} tasks remain. Keep bouldering.**`
}
function buildStandaloneVerificationReminder(sessionId: string): string {
return `
---
${buildVerificationReminder(sessionId)}
**STEP 4: UPDATE TODO STATUS (IMMEDIATELY)**
RIGHT NOW - Do not delay. Verification passed → Mark IMMEDIATELY.
1. Run \`todoread\` to see your todo list
2. Mark the completed task as \`completed\` using \`todowrite\`
**DO THIS BEFORE ANYTHING ELSE. Unmarked = Untracked = Lost progress.**
**STEP 5: EXECUTE QA TASKS (IF ANY)**
If QA tasks exist in your todo list:
- Execute them BEFORE proceeding
- Mark each QA task complete after successful verification
**STEP 6: PROCEED TO NEXT PENDING TASK**
- Identify the next \`pending\` task from your todo list
- Start immediately - DO NOT STOP
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
**NO TODO = NO TRACKING = INCOMPLETE WORK. Use todowrite aggressively.**`
}
function extractSessionIdFromOutput(output: string): string {
const match = output.match(/Session ID:\s*(ses_[a-zA-Z0-9]+)/)
return match?.[1] ?? "<session_id>"
}
interface GitFileStat {
path: string
added: number
removed: number
status: "modified" | "added" | "deleted"
}
function getGitDiffStats(directory: string): GitFileStat[] {
try {
const output = execSync("git diff --numstat HEAD", {
cwd: directory,
encoding: "utf-8",
timeout: 5000,
}).trim()
if (!output) return []
const statusOutput = execSync("git status --porcelain", {
cwd: directory,
encoding: "utf-8",
timeout: 5000,
}).trim()
const statusMap = new Map<string, "modified" | "added" | "deleted">()
for (const line of statusOutput.split("\n")) {
if (!line) continue
const status = line.substring(0, 2).trim()
const filePath = line.substring(3)
if (status === "A" || status === "??") {
statusMap.set(filePath, "added")
} else if (status === "D") {
statusMap.set(filePath, "deleted")
} else {
statusMap.set(filePath, "modified")
}
}
const stats: GitFileStat[] = []
for (const line of output.split("\n")) {
const parts = line.split("\t")
if (parts.length < 3) continue
const [addedStr, removedStr, path] = parts
const added = addedStr === "-" ? 0 : parseInt(addedStr, 10)
const removed = removedStr === "-" ? 0 : parseInt(removedStr, 10)
stats.push({
path,
added,
removed,
status: statusMap.get(path) ?? "modified",
})
}
return stats
} catch {
return []
}
}
function formatFileChanges(stats: GitFileStat[], notepadPath?: string): string {
if (stats.length === 0) return "[FILE CHANGES SUMMARY]\nNo file changes detected.\n"
const modified = stats.filter((s) => s.status === "modified")
const added = stats.filter((s) => s.status === "added")
const deleted = stats.filter((s) => s.status === "deleted")
const lines: string[] = ["[FILE CHANGES SUMMARY]"]
if (modified.length > 0) {
lines.push("Modified files:")
for (const f of modified) {
lines.push(` ${f.path} (+${f.added}, -${f.removed})`)
}
lines.push("")
}
if (added.length > 0) {
lines.push("Created files:")
for (const f of added) {
lines.push(` ${f.path} (+${f.added})`)
}
lines.push("")
}
if (deleted.length > 0) {
lines.push("Deleted files:")
for (const f of deleted) {
lines.push(` ${f.path} (-${f.removed})`)
}
lines.push("")
}
if (notepadPath) {
const notepadStat = stats.find((s) => s.path.includes("notepad") || s.path.includes(".sisyphus"))
if (notepadStat) {
lines.push("[NOTEPAD UPDATED]")
lines.push(` ${notepadStat.path} (+${notepadStat.added})`)
lines.push("")
}
}
return lines.join("\n")
}
interface ToolExecuteAfterInput {
tool: string
sessionID?: string
callID?: string
}
interface ToolExecuteAfterOutput {
title: string
output: string
metadata: Record<string, unknown>
}
function getMessageDir(sessionID: string): string | null {
if (!existsSync(MESSAGE_STORAGE)) return null
const directPath = join(MESSAGE_STORAGE, sessionID)
if (existsSync(directPath)) return directPath
for (const dir of readdirSync(MESSAGE_STORAGE)) {
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
if (existsSync(sessionPath)) return sessionPath
}
return null
}
function isCallerOrchestrator(sessionID?: string): boolean {
if (!sessionID) return false
const messageDir = getMessageDir(sessionID)
if (!messageDir) return false
const nearest = findNearestMessageWithFields(messageDir)
return nearest?.agent === "atlas"
}
interface SessionState {
lastEventWasAbortError?: boolean
lastContinuationInjectedAt?: number
}
const CONTINUATION_COOLDOWN_MS = 5000
export interface SisyphusOrchestratorHookOptions {
directory: string
backgroundManager?: BackgroundManager
}
function isAbortError(error: unknown): boolean {
if (!error) return false
if (typeof error === "object") {
const errObj = error as Record<string, unknown>
const name = errObj.name as string | undefined
const message = (errObj.message as string | undefined)?.toLowerCase() ?? ""
if (name === "MessageAbortedError" || name === "AbortError") return true
if (name === "DOMException" && message.includes("abort")) return true
if (message.includes("aborted") || message.includes("cancelled") || message.includes("interrupted")) return true
}
if (typeof error === "string") {
const lower = error.toLowerCase()
return lower.includes("abort") || lower.includes("cancel") || lower.includes("interrupt")
}
return false
}
export function createSisyphusOrchestratorHook(
ctx: PluginInput,
options?: SisyphusOrchestratorHookOptions
) {
const backgroundManager = options?.backgroundManager
const sessions = new Map<string, SessionState>()
const pendingFilePaths = new Map<string, string>()
function getState(sessionID: string): SessionState {
let state = sessions.get(sessionID)
if (!state) {
state = {}
sessions.set(sessionID, state)
}
return state
}
async function injectContinuation(sessionID: string, planName: string, remaining: number, total: number): Promise<void> {
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
: false
if (hasRunningBgTasks) {
log(`[${HOOK_NAME}] Skipped injection: background tasks running`, { sessionID })
return
}
const prompt = BOULDER_CONTINUATION_PROMPT
.replace(/{PLAN_NAME}/g, planName) +
`\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]`
try {
log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining })
let model: { providerID: string; modelID: string } | undefined
try {
const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } })
const messages = (messagesResp.data ?? []) as Array<{
info?: { model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string }
}>
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info
const msgModel = info?.model
if (msgModel?.providerID && msgModel?.modelID) {
model = { providerID: msgModel.providerID, modelID: msgModel.modelID }
break
}
if (info?.providerID && info?.modelID) {
model = { providerID: info.providerID, modelID: info.modelID }
break
}
}
} catch {
const messageDir = getMessageDir(sessionID)
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
: undefined
}
await ctx.client.session.prompt({
path: { id: sessionID },
body: {
agent: "atlas",
...(model !== undefined ? { model } : {}),
parts: [{ type: "text", text: prompt }],
},
query: { directory: ctx.directory },
})
log(`[${HOOK_NAME}] Boulder continuation injected`, { sessionID })
} catch (err) {
log(`[${HOOK_NAME}] Boulder continuation failed`, { sessionID, error: String(err) })
}
}
return {
handler: async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
const state = getState(sessionID)
const isAbort = isAbortError(props?.error)
state.lastEventWasAbortError = isAbort
log(`[${HOOK_NAME}] session.error`, { sessionID, isAbort })
return
}
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
log(`[${HOOK_NAME}] session.idle`, { sessionID })
// Read boulder state FIRST to check if this session is part of an active boulder
const boulderState = readBoulderState(ctx.directory)
const isBoulderSession = boulderState?.session_ids.includes(sessionID) ?? false
const mainSessionID = getMainSessionID()
const isMainSession = sessionID === mainSessionID
const isBackgroundTaskSession = subagentSessions.has(sessionID)
// Allow continuation if: main session OR background task OR boulder session
if (mainSessionID && !isMainSession && !isBackgroundTaskSession && !isBoulderSession) {
log(`[${HOOK_NAME}] Skipped: not main, background task, or boulder session`, { sessionID })
return
}
const state = getState(sessionID)
if (state.lastEventWasAbortError) {
state.lastEventWasAbortError = false
log(`[${HOOK_NAME}] Skipped: abort error immediately before idle`, { sessionID })
return
}
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
: false
if (hasRunningBgTasks) {
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
return
}
if (!boulderState) {
log(`[${HOOK_NAME}] No active boulder`, { sessionID })
return
}
if (!isCallerOrchestrator(sessionID)) {
log(`[${HOOK_NAME}] Skipped: last agent is not orchestrator-sisyphus`, { sessionID })
return
}
const progress = getPlanProgress(boulderState.active_plan)
if (progress.isComplete) {
log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name })
return
}
const now = Date.now()
if (state.lastContinuationInjectedAt && now - state.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) {
log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, { sessionID, cooldownRemaining: CONTINUATION_COOLDOWN_MS - (now - state.lastContinuationInjectedAt) })
return
}
state.lastContinuationInjectedAt = now
const remaining = progress.total - progress.completed
injectContinuation(sessionID, boulderState.plan_name, remaining, progress.total)
return
}
if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
if (!sessionID) return
const state = sessions.get(sessionID)
if (state) {
state.lastEventWasAbortError = false
}
return
}
if (event.type === "message.part.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
const role = info?.role as string | undefined
if (sessionID && role === "assistant") {
const state = sessions.get(sessionID)
if (state) {
state.lastEventWasAbortError = false
}
}
return
}
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
const sessionID = props?.sessionID as string | undefined
if (sessionID) {
const state = sessions.get(sessionID)
if (state) {
state.lastEventWasAbortError = false
}
}
return
}
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
sessions.delete(sessionInfo.id)
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id })
}
return
}
},
"tool.execute.before": async (
input: { tool: string; sessionID?: string; callID?: string },
output: { args: Record<string, unknown>; message?: string }
): Promise<void> => {
if (!isCallerOrchestrator(input.sessionID)) {
return
}
// Check Write/Edit tools for orchestrator - inject strong warning
if (WRITE_EDIT_TOOLS.includes(input.tool)) {
const filePath = (output.args.filePath ?? output.args.path ?? output.args.file) as string | undefined
if (filePath && !isSisyphusPath(filePath)) {
// Store filePath for use in tool.execute.after
if (input.callID) {
pendingFilePaths.set(input.callID, filePath)
}
const warning = ORCHESTRATOR_DELEGATION_REQUIRED.replace("$FILE_PATH", filePath)
output.message = (output.message || "") + warning
log(`[${HOOK_NAME}] Injected delegation warning for direct file modification`, {
sessionID: input.sessionID,
tool: input.tool,
filePath,
})
}
return
}
// Check delegate_task - inject single-task directive
if (input.tool === "delegate_task") {
const prompt = output.args.prompt as string | undefined
if (prompt && !prompt.includes(SYSTEM_DIRECTIVE_PREFIX)) {
output.args.prompt = prompt + `\n<system-reminder>${SINGLE_TASK_DIRECTIVE}</system-reminder>`
log(`[${HOOK_NAME}] Injected single-task directive to delegate_task`, {
sessionID: input.sessionID,
})
}
}
},
"tool.execute.after": async (
input: ToolExecuteAfterInput,
output: ToolExecuteAfterOutput
): Promise<void> => {
if (!isCallerOrchestrator(input.sessionID)) {
return
}
if (WRITE_EDIT_TOOLS.includes(input.tool)) {
let filePath = input.callID ? pendingFilePaths.get(input.callID) : undefined
if (input.callID) {
pendingFilePaths.delete(input.callID)
}
if (!filePath) {
filePath = output.metadata?.filePath as string | undefined
}
if (filePath && !isSisyphusPath(filePath)) {
output.output = (output.output || "") + DIRECT_WORK_REMINDER
log(`[${HOOK_NAME}] Direct work reminder appended`, {
sessionID: input.sessionID,
tool: input.tool,
filePath,
})
}
return
}
if (input.tool !== "delegate_task") {
return
}
const outputStr = output.output && typeof output.output === "string" ? output.output : ""
const isBackgroundLaunch = outputStr.includes("Background task launched") || outputStr.includes("Background task resumed")
if (isBackgroundLaunch) {
return
}
if (output.output && typeof output.output === "string") {
const gitStats = getGitDiffStats(ctx.directory)
const fileChanges = formatFileChanges(gitStats)
const subagentSessionId = extractSessionIdFromOutput(output.output)
const boulderState = readBoulderState(ctx.directory)
if (boulderState) {
const progress = getPlanProgress(boulderState.active_plan)
if (input.sessionID && !boulderState.session_ids.includes(input.sessionID)) {
appendSessionId(ctx.directory, input.sessionID)
log(`[${HOOK_NAME}] Appended session to boulder`, {
sessionID: input.sessionID,
plan: boulderState.plan_name,
})
}
// Preserve original subagent response - critical for debugging failed tasks
const originalResponse = output.output
output.output = `
## SUBAGENT WORK COMPLETED
${fileChanges}
---
**Subagent Response:**
${originalResponse}
<system-reminder>
${buildOrchestratorReminder(boulderState.plan_name, progress, subagentSessionId)}
</system-reminder>`
log(`[${HOOK_NAME}] Output transformed for orchestrator mode (boulder)`, {
plan: boulderState.plan_name,
progress: `${progress.completed}/${progress.total}`,
fileCount: gitStats.length,
})
} else {
output.output += `\n<system-reminder>\n${buildStandaloneVerificationReminder(subagentSessionId)}\n</system-reminder>`
log(`[${HOOK_NAME}] Verification reminder appended for orchestrator`, {
sessionID: input.sessionID,
fileCount: gitStats.length,
})
}
}
},
}
}