fix(start-work): restore atlas handoff

Keep native /start-work resolvable on Sisyphus, but switch the work session back to Atlas when Atlas is registered. Stamp the outgoing agent with Atlas's actual list-display key so config→start-work execution resolves correctly and still falls back to Sisyphus when Atlas is unavailable.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-03-31 20:02:00 -07:00
parent d029bc7621
commit 7f846b2da3
8 changed files with 503 additions and 153 deletions
@@ -59,6 +59,16 @@ describe("loadBuiltinCommands", () => {
//#then //#then
expect(commands.handoff.description).toContain("context summary") expect(commands.handoff.description).toContain("context summary")
}) })
test("should preassign Sisyphus as the native agent for start-work", () => {
//#given - no disabled commands
//#when
const commands = loadBuiltinCommands()
//#then
expect(commands["start-work"].agent).toBe("sisyphus")
})
}) })
describe("loadBuiltinCommands — remove-ai-slops", () => { describe("loadBuiltinCommands — remove-ai-slops", () => {
+1 -1
View File
@@ -58,7 +58,7 @@ ${REFACTOR_TEMPLATE}
}, },
"start-work": { "start-work": {
description: "(builtin) Start Sisyphus work session from Prometheus plan", description: "(builtin) Start Sisyphus work session from Prometheus plan",
agent: "atlas", agent: "sisyphus",
template: `<command-instruction> template: `<command-instruction>
${START_WORK_TEMPLATE} ${START_WORK_TEMPLATE}
</command-instruction> </command-instruction>
+59 -32
View File
@@ -4,6 +4,7 @@ import { join } from "node:path"
import { tmpdir } from "node:os" import { tmpdir } from "node:os"
import { randomUUID } from "node:crypto" import { randomUUID } from "node:crypto"
import { createStartWorkHook } from "./index" import { createStartWorkHook } from "./index"
import { getAgentListDisplayName } from "../../shared/agent-display-names"
import { import {
writeBoulderState, writeBoulderState,
clearBoulderState, clearBoulderState,
@@ -24,6 +25,22 @@ describe("start-work hook", () => {
} as Parameters<typeof createStartWorkHook>[0] } as Parameters<typeof createStartWorkHook>[0]
} }
function createStartWorkPrompt(options?: {
sessionContext?: string
userRequest?: string
}): string {
const sessionContext = options?.sessionContext ?? ""
const userRequest = options?.userRequest ?? ""
return `<command-instruction>
You are starting a Sisyphus work session.
</command-instruction>
<session-context>${sessionContext}</session-context>${userRequest ? `
<user-request>${userRequest}</user-request>` : ""}`
}
beforeEach(() => { beforeEach(() => {
sessionState._resetForTesting() sessionState._resetForTesting()
sessionState.registerAgentName("atlas") sessionState.registerAgentName("atlas")
@@ -65,6 +82,24 @@ describe("start-work hook", () => {
expect(output.parts[0].text).toBe("Just a regular message") expect(output.parts[0].text).toBe("Just a regular message")
}) })
test("should ignore plain session-context blocks without the start-work marker", async () => {
// given
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [{ type: "text", text: "<session-context>Some context here</session-context>" }],
}
// when
await hook["chat.message"](
{ sessionID: "session-123" },
output
)
// then
expect(output.parts[0].text).toBe("<session-context>Some context here</session-context>")
expect(readBoulderState(testDir)).toBeNull()
})
test("should detect start-work command via session-context tag", async () => { test("should detect start-work command via session-context tag", async () => {
// given - hook and start-work message // given - hook and start-work message
const hook = createStartWorkHook(createMockPluginInput()) const hook = createStartWorkHook(createMockPluginInput())
@@ -72,7 +107,7 @@ describe("start-work hook", () => {
parts: [ parts: [
{ {
type: "text", type: "text",
text: "<session-context>Some context here</session-context>", text: createStartWorkPrompt({ sessionContext: "Some context here" }),
}, },
], ],
} }
@@ -102,7 +137,7 @@ describe("start-work hook", () => {
const hook = createStartWorkHook(createMockPluginInput()) const hook = createStartWorkHook(createMockPluginInput())
const output = { const output = {
parts: [{ type: "text", text: "<session-context></session-context>" }], parts: [{ type: "text", text: createStartWorkPrompt() }],
} }
// when // when
@@ -123,7 +158,7 @@ describe("start-work hook", () => {
parts: [ parts: [
{ {
type: "text", type: "text",
text: "<session-context>Session: $SESSION_ID</session-context>", text: createStartWorkPrompt({ sessionContext: "Session: $SESSION_ID" }),
}, },
], ],
} }
@@ -146,7 +181,7 @@ describe("start-work hook", () => {
parts: [ parts: [
{ {
type: "text", type: "text",
text: "<session-context>Time: $TIMESTAMP</session-context>", text: createStartWorkPrompt({ sessionContext: "Time: $TIMESTAMP" }),
}, },
], ],
} }
@@ -177,7 +212,7 @@ describe("start-work hook", () => {
const hook = createStartWorkHook(createMockPluginInput()) const hook = createStartWorkHook(createMockPluginInput())
const output = { const output = {
parts: [{ type: "text", text: "<session-context></session-context>" }], parts: [{ type: "text", text: createStartWorkPrompt() }],
} }
// when // when
@@ -205,7 +240,7 @@ describe("start-work hook", () => {
const hook = createStartWorkHook(createMockPluginInput()) const hook = createStartWorkHook(createMockPluginInput())
const output = { const output = {
parts: [{ type: "text", text: "<session-context></session-context>" }], parts: [{ type: "text", text: createStartWorkPrompt() }],
} }
// when // when
@@ -233,7 +268,7 @@ describe("start-work hook", () => {
const hook = createStartWorkHook(createMockPluginInput()) const hook = createStartWorkHook(createMockPluginInput())
const output = { const output = {
parts: [{ type: "text", text: "<session-context></session-context>" }], parts: [{ type: "text", text: createStartWorkPrompt() }],
} }
// when // when
@@ -274,9 +309,7 @@ describe("start-work hook", () => {
parts: [ parts: [
{ {
type: "text", type: "text",
text: `<session-context> text: createStartWorkPrompt({ userRequest: "new-plan" }),
<user-request>new-plan</user-request>
</session-context>`,
}, },
], ],
} }
@@ -306,9 +339,7 @@ describe("start-work hook", () => {
parts: [ parts: [
{ {
type: "text", type: "text",
text: `<session-context> text: createStartWorkPrompt({ userRequest: "my-feature-plan ultrawork" }),
<user-request>my-feature-plan ultrawork</user-request>
</session-context>`,
}, },
], ],
} }
@@ -337,9 +368,7 @@ describe("start-work hook", () => {
parts: [ parts: [
{ {
type: "text", type: "text",
text: `<session-context> text: createStartWorkPrompt({ userRequest: "api-refactor ulw" }),
<user-request>api-refactor ulw</user-request>
</session-context>`,
}, },
], ],
} }
@@ -368,9 +397,7 @@ describe("start-work hook", () => {
parts: [ parts: [
{ {
type: "text", type: "text",
text: `<session-context> text: createStartWorkPrompt({ userRequest: "feature-implementation" }),
<user-request>feature-implementation</user-request>
</session-context>`,
}, },
], ],
} }
@@ -394,7 +421,7 @@ describe("start-work hook", () => {
const hook = createStartWorkHook(createMockPluginInput()) const hook = createStartWorkHook(createMockPluginInput())
const output = { const output = {
parts: [{ type: "text", text: "<session-context></session-context>" }], parts: [{ type: "text", text: createStartWorkPrompt() }],
} }
// when // when
@@ -408,12 +435,12 @@ describe("start-work hook", () => {
updateSpy.mockRestore() updateSpy.mockRestore()
}) })
test("should stamp the outgoing message with Atlas so follow-up events keep the handoff", async () => { test("should stamp the outgoing message with Atlas list key so follow-up events keep the handoff", async () => {
// given // given
const hook = createStartWorkHook(createMockPluginInput()) const hook = createStartWorkHook(createMockPluginInput())
const output = { const output = {
message: {} as Record<string, unknown>, message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "<session-context></session-context>" }], parts: [{ type: "text", text: createStartWorkPrompt() }],
} }
// when // when
@@ -423,7 +450,7 @@ describe("start-work hook", () => {
) )
// then // then
expect(output.message.agent).toBe("Atlas (Plan Executor)") expect(output.message.agent).toBe(getAgentListDisplayName("atlas"))
}) })
test("should keep the current agent when Atlas is unavailable", async () => { test("should keep the current agent when Atlas is unavailable", async () => {
@@ -435,7 +462,7 @@ describe("start-work hook", () => {
const hook = createStartWorkHook(createMockPluginInput()) const hook = createStartWorkHook(createMockPluginInput())
const output = { const output = {
message: {} as Record<string, unknown>, message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "<session-context></session-context>" }], parts: [{ type: "text", text: createStartWorkPrompt() }],
} }
// when // when
@@ -463,7 +490,7 @@ describe("start-work hook", () => {
const hook = createStartWorkHook(createMockPluginInput()) const hook = createStartWorkHook(createMockPluginInput())
const output = { const output = {
message: {} as Record<string, unknown>, message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "<session-context></session-context>" }], parts: [{ type: "text", text: createStartWorkPrompt() }],
} }
// when // when
@@ -498,7 +525,7 @@ describe("start-work hook", () => {
const hook = createStartWorkHook(createMockPluginInput()) const hook = createStartWorkHook(createMockPluginInput())
const output = { const output = {
message: {} as Record<string, unknown>, message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "<session-context></session-context>" }], parts: [{ type: "text", text: createStartWorkPrompt() }],
} }
// when // when
@@ -532,7 +559,7 @@ describe("start-work hook", () => {
const hook = createStartWorkHook(createMockPluginInput()) const hook = createStartWorkHook(createMockPluginInput())
const output = { const output = {
parts: [{ type: "text", text: "<session-context></session-context>" }], parts: [{ type: "text", text: createStartWorkPrompt() }],
} }
// when // when
@@ -553,7 +580,7 @@ describe("start-work hook", () => {
const hook = createStartWorkHook(createMockPluginInput()) const hook = createStartWorkHook(createMockPluginInput())
const output = { const output = {
parts: [{ type: "text", text: "<session-context>\n<user-request>--worktree /validated/worktree</user-request>\n</session-context>" }], parts: [{ type: "text", text: createStartWorkPrompt({ userRequest: "--worktree /validated/worktree" }) }],
} }
// when // when
@@ -575,7 +602,7 @@ describe("start-work hook", () => {
const hook = createStartWorkHook(createMockPluginInput()) const hook = createStartWorkHook(createMockPluginInput())
const output = { const output = {
parts: [{ type: "text", text: "<session-context>\n<user-request>--worktree /valid/wt</user-request>\n</session-context>" }], parts: [{ type: "text", text: createStartWorkPrompt({ userRequest: "--worktree /valid/wt" }) }],
} }
// when // when
@@ -595,7 +622,7 @@ describe("start-work hook", () => {
const hook = createStartWorkHook(createMockPluginInput()) const hook = createStartWorkHook(createMockPluginInput())
const output = { const output = {
parts: [{ type: "text", text: "<session-context>\n<user-request>--worktree /nonexistent/wt</user-request>\n</session-context>" }], parts: [{ type: "text", text: createStartWorkPrompt({ userRequest: "--worktree /nonexistent/wt" }) }],
} }
// when // when
@@ -624,7 +651,7 @@ describe("start-work hook", () => {
const hook = createStartWorkHook(createMockPluginInput()) const hook = createStartWorkHook(createMockPluginInput())
const output = { const output = {
parts: [{ type: "text", text: "<session-context>\n<user-request>--worktree /new/wt</user-request>\n</session-context>" }], parts: [{ type: "text", text: createStartWorkPrompt({ userRequest: "--worktree /new/wt" }) }],
} }
// when // when
@@ -651,7 +678,7 @@ describe("start-work hook", () => {
const hook = createStartWorkHook(createMockPluginInput()) const hook = createStartWorkHook(createMockPluginInput())
const output = { const output = {
parts: [{ type: "text", text: "<session-context></session-context>" }], parts: [{ type: "text", text: createStartWorkPrompt() }],
} }
// when // when
+159 -118
View File
@@ -11,18 +11,33 @@ import {
clearBoulderState, clearBoulderState,
} from "../../features/boulder-state" } from "../../features/boulder-state"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { getAgentConfigKey, getAgentDisplayName } from "../../shared/agent-display-names" import {
import { getSessionAgent, isAgentRegistered, updateSessionAgent } from "../../features/claude-code-session-state" getAgentConfigKey,
getAgentDisplayName,
getAgentListDisplayName,
} from "../../shared/agent-display-names"
import {
getSessionAgent,
isAgentRegistered,
updateSessionAgent,
} from "../../features/claude-code-session-state"
import { detectWorktreePath } from "./worktree-detector" import { detectWorktreePath } from "./worktree-detector"
import { parseUserRequest } from "./parse-user-request" import { parseUserRequest } from "./parse-user-request"
export const HOOK_NAME = "start-work" as const export const HOOK_NAME = "start-work" as const
const START_WORK_TEMPLATE_MARKER = "You are starting a Sisyphus work session."
interface StartWorkHookInput { interface StartWorkHookInput {
sessionID: string sessionID: string
messageID?: string messageID?: string
} }
interface StartWorkCommandExecuteBeforeInput {
sessionID: string
command: string
arguments: string
}
interface StartWorkHookOutput { interface StartWorkHookOutput {
message?: Record<string, unknown> message?: Record<string, unknown>
parts: Array<{ type: string; text?: string }> parts: Array<{ type: string; text?: string }>
@@ -67,61 +82,76 @@ function resolveWorktreeContext(
} }
export function createStartWorkHook(ctx: PluginInput) { export function createStartWorkHook(ctx: PluginInput) {
return { const processStartWork = async (
"chat.message": async (input: StartWorkHookInput, output: StartWorkHookOutput): Promise<void> => { input: StartWorkHookInput,
const parts = output.parts output: StartWorkHookOutput,
const promptText = ): Promise<void> => {
parts const parts = output.parts
?.filter((p) => p.type === "text" && p.text) const promptText =
.map((p) => p.text) parts
.join("\n") ?.filter((p) => p.type === "text" && p.text)
.trim() || "" .map((p) => p.text)
.join("\n")
.trim() || ""
if (!promptText.includes("<session-context>")) return if (
!promptText.includes("<session-context>")
|| !promptText.includes(START_WORK_TEMPLATE_MARKER)
) {
return
}
log(`[${HOOK_NAME}] Processing start-work command`, { sessionID: input.sessionID }) log(`[${HOOK_NAME}] Processing start-work command`, { sessionID: input.sessionID })
const currentSessionAgent = getSessionAgent(input.sessionID) const currentSessionAgent = getSessionAgent(input.sessionID)
const activeAgent = isAgentRegistered("atlas") const currentSessionAgentKey = currentSessionAgent
? "atlas" ? getAgentConfigKey(currentSessionAgent)
: currentSessionAgent && getAgentConfigKey(currentSessionAgent) !== "prometheus" : undefined
? currentSessionAgent const activeAgent = currentSessionAgent
&& currentSessionAgentKey
&& currentSessionAgentKey !== "prometheus"
&& currentSessionAgentKey !== "atlas"
? currentSessionAgent
: isAgentRegistered("atlas")
? "atlas"
: "sisyphus" : "sisyphus"
const activeAgentDisplayName = getAgentDisplayName(activeAgent) const activeAgentDisplayName = activeAgent === "atlas"
updateSessionAgent(input.sessionID, activeAgent) ? getAgentListDisplayName(activeAgent)
if (output.message) { : getAgentDisplayName(activeAgent)
output.message["agent"] = activeAgentDisplayName updateSessionAgent(input.sessionID, activeAgent)
} if (output.message) {
output.message["agent"] = activeAgentDisplayName
}
const existingState = readBoulderState(ctx.directory) const existingState = readBoulderState(ctx.directory)
const sessionId = input.sessionID const sessionId = input.sessionID
const timestamp = new Date().toISOString() const timestamp = new Date().toISOString()
const { planName: explicitPlanName, explicitWorktreePath } = parseUserRequest(promptText) const { planName: explicitPlanName, explicitWorktreePath } = parseUserRequest(promptText)
const { worktreePath, block: worktreeBlock } = resolveWorktreeContext(explicitWorktreePath) const { worktreePath, block: worktreeBlock } = resolveWorktreeContext(explicitWorktreePath)
let contextInfo = "" let contextInfo = ""
if (explicitPlanName) { if (explicitPlanName) {
log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, { sessionID: input.sessionID }) log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, { sessionID: input.sessionID })
const allPlans = findPrometheusPlans(ctx.directory) const allPlans = findPrometheusPlans(ctx.directory)
const matchedPlan = findPlanByName(allPlans, explicitPlanName) const matchedPlan = findPlanByName(allPlans, explicitPlanName)
if (matchedPlan) { if (matchedPlan) {
const progress = getPlanProgress(matchedPlan) const progress = getPlanProgress(matchedPlan)
if (progress.isComplete) { if (progress.isComplete) {
contextInfo = ` contextInfo = `
## Plan Already Complete ## Plan Already Complete
The requested plan "${getPlanName(matchedPlan)}" has been completed. The requested plan "${getPlanName(matchedPlan)}" has been completed.
All ${progress.total} tasks are done. Create a new plan with: /plan "your task"` All ${progress.total} tasks are done. Create a new plan with: /plan "your task"`
} else { } else {
if (existingState) clearBoulderState(ctx.directory) if (existingState) clearBoulderState(ctx.directory)
const newState = createBoulderState(matchedPlan, sessionId, activeAgent, worktreePath) const newState = createBoulderState(matchedPlan, sessionId, activeAgent, worktreePath)
writeBoulderState(ctx.directory, newState) writeBoulderState(ctx.directory, newState)
contextInfo = ` contextInfo = `
## Auto-Selected Plan ## Auto-Selected Plan
**Plan**: ${getPlanName(matchedPlan)} **Plan**: ${getPlanName(matchedPlan)}
@@ -132,18 +162,18 @@ All ${progress.total} tasks are done. Create a new plan with: /plan "your task"`
${worktreeBlock} ${worktreeBlock}
boulder.json has been created. Read the plan and begin execution.` boulder.json has been created. Read the plan and begin execution.`
} }
} else { } else {
const incompletePlans = allPlans.filter((p) => !getPlanProgress(p).isComplete) const incompletePlans = allPlans.filter((p) => !getPlanProgress(p).isComplete)
if (incompletePlans.length > 0) { if (incompletePlans.length > 0) {
const planList = incompletePlans const planList = incompletePlans
.map((p, i) => { .map((p, i) => {
const prog = getPlanProgress(p) const prog = getPlanProgress(p)
return `${i + 1}. [${getPlanName(p)}] - Progress: ${prog.completed}/${prog.total}` return `${i + 1}. [${getPlanName(p)}] - Progress: ${prog.completed}/${prog.total}`
}) })
.join("\n") .join("\n")
contextInfo = ` contextInfo = `
## Plan Not Found ## Plan Not Found
Could not find a plan matching "${explicitPlanName}". Could not find a plan matching "${explicitPlanName}".
@@ -152,39 +182,39 @@ Available incomplete plans:
${planList} ${planList}
Ask the user which plan to work on.` Ask the user which plan to work on.`
} else { } else {
contextInfo = ` contextInfo = `
## Plan Not Found ## Plan Not Found
Could not find a plan matching "${explicitPlanName}". Could not find a plan matching "${explicitPlanName}".
No incomplete plans available. Create a new plan with: /plan "your task"` No incomplete plans available. Create a new plan with: /plan "your task"`
}
} }
} else if (existingState) { }
const progress = getPlanProgress(existingState.active_plan) } else if (existingState) {
const progress = getPlanProgress(existingState.active_plan)
if (!progress.isComplete) { if (!progress.isComplete) {
const effectiveWorktree = worktreePath ?? existingState.worktree_path const effectiveWorktree = worktreePath ?? existingState.worktree_path
const sessionAlreadyTracked = existingState.session_ids.includes(sessionId) const sessionAlreadyTracked = existingState.session_ids.includes(sessionId)
const updatedSessions = sessionAlreadyTracked const updatedSessions = sessionAlreadyTracked
? existingState.session_ids ? existingState.session_ids
: [...existingState.session_ids, sessionId] : [...existingState.session_ids, sessionId]
const shouldRewriteState = existingState.agent !== activeAgent || worktreePath !== undefined const shouldRewriteState = existingState.agent !== activeAgent || worktreePath !== undefined
if (shouldRewriteState) { if (shouldRewriteState) {
writeBoulderState(ctx.directory, { writeBoulderState(ctx.directory, {
...existingState, ...existingState,
agent: activeAgent, agent: activeAgent,
...(worktreePath !== undefined ? { worktree_path: worktreePath } : {}), ...(worktreePath !== undefined ? { worktree_path: worktreePath } : {}),
session_ids: updatedSessions, session_ids: updatedSessions,
}) })
} else if (!sessionAlreadyTracked) { } else if (!sessionAlreadyTracked) {
appendSessionId(ctx.directory, sessionId) appendSessionId(ctx.directory, sessionId)
} }
const worktreeDisplay = effectiveWorktree ? createWorktreeActiveBlock(effectiveWorktree) : worktreeBlock const worktreeDisplay = effectiveWorktree ? createWorktreeActiveBlock(effectiveWorktree) : worktreeBlock
contextInfo = ` contextInfo = `
## Active Work Session Found ## Active Work Session Found
**Status**: RESUMING existing work **Status**: RESUMING existing work
@@ -197,41 +227,41 @@ ${worktreeDisplay}
The current session (${sessionId}) has been added to session_ids. The current session (${sessionId}) has been added to session_ids.
Read the plan file and continue from the first unchecked task.` Read the plan file and continue from the first unchecked task.`
} else { } else {
contextInfo = ` contextInfo = `
## Previous Work Complete ## Previous Work Complete
The previous plan (${existingState.plan_name}) has been completed. The previous plan (${existingState.plan_name}) has been completed.
Looking for new plans...` Looking for new plans...`
}
} }
}
if ( if (
(!existingState && !explicitPlanName) || (!existingState && !explicitPlanName) ||
(existingState && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete) (existingState && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete)
) { ) {
const plans = findPrometheusPlans(ctx.directory) const plans = findPrometheusPlans(ctx.directory)
const incompletePlans = plans.filter((p) => !getPlanProgress(p).isComplete) const incompletePlans = plans.filter((p) => !getPlanProgress(p).isComplete)
if (plans.length === 0) { if (plans.length === 0) {
contextInfo += ` contextInfo += `
## No Plans Found ## No Plans Found
No Prometheus plan files found at .sisyphus/plans/ No Prometheus plan files found at .sisyphus/plans/
Use Prometheus to create a work plan first: /plan "your task"` Use Prometheus to create a work plan first: /plan "your task"`
} else if (incompletePlans.length === 0) { } else if (incompletePlans.length === 0) {
contextInfo += ` contextInfo += `
## All Plans Complete ## All Plans Complete
All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your task"` All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your task"`
} else if (incompletePlans.length === 1) { } else if (incompletePlans.length === 1) {
const planPath = incompletePlans[0] const planPath = incompletePlans[0]
const progress = getPlanProgress(planPath) const progress = getPlanProgress(planPath)
const newState = createBoulderState(planPath, sessionId, activeAgent, worktreePath) const newState = createBoulderState(planPath, sessionId, activeAgent, worktreePath)
writeBoulderState(ctx.directory, newState) writeBoulderState(ctx.directory, newState)
contextInfo += ` contextInfo += `
## Auto-Selected Plan ## Auto-Selected Plan
@@ -243,16 +273,16 @@ All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your ta
${worktreeBlock} ${worktreeBlock}
boulder.json has been created. Read the plan and begin execution.` boulder.json has been created. Read the plan and begin execution.`
} else { } else {
const planList = incompletePlans const planList = incompletePlans
.map((p, i) => { .map((p, i) => {
const progress = getPlanProgress(p) const progress = getPlanProgress(p)
const modified = new Date(statSync(p).mtimeMs).toISOString() const modified = new Date(statSync(p).mtimeMs).toISOString()
return `${i + 1}. [${getPlanName(p)}] - Modified: ${modified} - Progress: ${progress.completed}/${progress.total}` return `${i + 1}. [${getPlanName(p)}] - Modified: ${modified} - Progress: ${progress.completed}/${progress.total}`
}) })
.join("\n") .join("\n")
contextInfo += ` contextInfo += `
<system-reminder> <system-reminder>
## Multiple Plans Found ## Multiple Plans Found
@@ -265,23 +295,34 @@ ${planList}
Ask the user which plan to work on. Present the options above and wait for their response. Ask the user which plan to work on. Present the options above and wait for their response.
${worktreeBlock} ${worktreeBlock}
</system-reminder>` </system-reminder>`
}
} }
}
const idx = output.parts.findIndex((p) => p.type === "text" && p.text) const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
if (idx >= 0 && output.parts[idx].text) { if (idx >= 0 && output.parts[idx].text) {
output.parts[idx].text = output.parts[idx].text output.parts[idx].text = output.parts[idx].text
.replace(/\$SESSION_ID/g, sessionId) .replace(/\$SESSION_ID/g, sessionId)
.replace(/\$TIMESTAMP/g, timestamp) .replace(/\$TIMESTAMP/g, timestamp)
output.parts[idx].text += `\n\n---\n${contextInfo}` output.parts[idx].text += `\n\n---\n${contextInfo}`
} }
log(`[${HOOK_NAME}] Context injected`, { log(`[${HOOK_NAME}] Context injected`, {
sessionID: input.sessionID, sessionID: input.sessionID,
hasExistingState: !!existingState, hasExistingState: !!existingState,
worktreePath, worktreePath,
}) })
}
return {
"chat.message": async (input: StartWorkHookInput, output: StartWorkHookOutput): Promise<void> => {
await processStartWork(input, output)
},
"command.execute.before": async (
input: StartWorkCommandExecuteBeforeInput,
output: StartWorkHookOutput,
): Promise<void> => {
await processStartWork(input, output)
}, },
} }
} }
+172
View File
@@ -0,0 +1,172 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { randomUUID } from "node:crypto"
import { createPluginInterface } from "./plugin-interface"
import { createAutoSlashCommandHook } from "./hooks/auto-slash-command"
import { createStartWorkHook } from "./hooks/start-work"
import { getAgentListDisplayName } from "./shared/agent-display-names"
import { readBoulderState } from "./features/boulder-state"
import {
_resetForTesting,
getSessionAgent,
registerAgentName,
updateSessionAgent,
} from "./features/claude-code-session-state"
describe("createPluginInterface - command.execute.before", () => {
let testDir = ""
beforeEach(() => {
testDir = join(tmpdir(), `plugin-interface-start-work-${randomUUID()}`)
mkdirSync(join(testDir, ".sisyphus", "plans"), { recursive: true })
writeFileSync(join(testDir, ".sisyphus", "plans", "worker-plan.md"), "# Plan\n- [ ] Task 1")
_resetForTesting()
registerAgentName("prometheus")
registerAgentName("sisyphus")
})
afterEach(() => {
_resetForTesting()
rmSync(testDir, { recursive: true, force: true })
})
test("executes start-work side effects for native command execution", async () => {
// given
updateSessionAgent("ses-command-before", "prometheus")
const pluginInterface = createPluginInterface({
ctx: {
directory: testDir,
client: { tui: { showToast: async () => {} } },
} as never,
pluginConfig: {} as never,
firstMessageVariantGate: {
shouldOverride: () => false,
markApplied: () => {},
markSessionCreated: () => {},
clear: () => {},
},
managers: {} as never,
hooks: {
autoSlashCommand: createAutoSlashCommandHook({ skills: [] }),
startWork: createStartWorkHook({
directory: testDir,
client: { tui: { showToast: async () => {} } },
} as never),
} as never,
tools: {},
})
const output = {
parts: [{ type: "text", text: "original" }],
}
// when
await pluginInterface["command.execute.before"]?.(
{
command: "start-work",
sessionID: "ses-command-before",
arguments: "",
},
output as never
)
// then
expect(pluginInterface["command.execute.before"]).toBeDefined()
expect(output.parts[0]?.text).toContain("Auto-Selected Plan")
expect(output.parts[0]?.text).toContain("boulder.json has been created")
expect(getSessionAgent("ses-command-before")).toBe("sisyphus")
expect(readBoulderState(testDir)?.agent).toBe("sisyphus")
})
test("does not run start-work side effects for other native commands with session context", async () => {
// given
updateSessionAgent("ses-handoff", "prometheus")
const pluginInterface = createPluginInterface({
ctx: {
directory: testDir,
client: { tui: { showToast: async () => {} } },
} as never,
pluginConfig: {} as never,
firstMessageVariantGate: {
shouldOverride: () => false,
markApplied: () => {},
markSessionCreated: () => {},
clear: () => {},
},
managers: {} as never,
hooks: {
autoSlashCommand: createAutoSlashCommandHook({ skills: [] }),
startWork: createStartWorkHook({
directory: testDir,
client: { tui: { showToast: async () => {} } },
} as never),
} as never,
tools: {},
})
const output = {
parts: [{ type: "text", text: "original" }],
}
// when
await pluginInterface["command.execute.before"]?.(
{
command: "handoff",
sessionID: "ses-handoff",
arguments: "",
},
output as never
)
// then
expect(output.parts[0]?.text).toContain("HANDOFF CONTEXT")
expect(readBoulderState(testDir)).toBeNull()
expect(getSessionAgent("ses-handoff")).toBe("prometheus")
})
test("switches native start-work to Atlas when Atlas is registered in config", async () => {
// given
registerAgentName("atlas")
updateSessionAgent("ses-command-atlas", "prometheus")
const pluginInterface = createPluginInterface({
ctx: {
directory: testDir,
client: { tui: { showToast: async () => {} } },
} as never,
pluginConfig: {} as never,
firstMessageVariantGate: {
shouldOverride: () => false,
markApplied: () => {},
markSessionCreated: () => {},
clear: () => {},
},
managers: {} as never,
hooks: {
autoSlashCommand: createAutoSlashCommandHook({ skills: [] }),
startWork: createStartWorkHook({
directory: testDir,
client: { tui: { showToast: async () => {} } },
} as never),
} as never,
tools: {},
})
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "/start-work" }],
}
// when
await pluginInterface["chat.message"]?.(
{
sessionID: "ses-command-atlas",
agent: "prometheus",
} as never,
output as never
)
// then
expect(output.message.agent).toBe(getAgentListDisplayName("atlas"))
expect(getSessionAgent("ses-command-atlas")).toBe("atlas")
expect(readBoulderState(testDir)?.agent).toBe("atlas")
})
})
+5
View File
@@ -4,6 +4,7 @@ import type { OhMyOpenCodeConfig } from "./config"
import { createChatParamsHandler } from "./plugin/chat-params" import { createChatParamsHandler } from "./plugin/chat-params"
import { createChatHeadersHandler } from "./plugin/chat-headers" import { createChatHeadersHandler } from "./plugin/chat-headers"
import { createChatMessageHandler } from "./plugin/chat-message" import { createChatMessageHandler } from "./plugin/chat-message"
import { createCommandExecuteBeforeHandler } from "./plugin/command-execute-before"
import { createMessagesTransformHandler } from "./plugin/messages-transform" import { createMessagesTransformHandler } from "./plugin/messages-transform"
import { createSystemTransformHandler } from "./plugin/system-transform" import { createSystemTransformHandler } from "./plugin/system-transform"
import { createEventHandler } from "./plugin/event" import { createEventHandler } from "./plugin/event"
@@ -42,6 +43,10 @@ export function createPluginInterface(args: {
"chat.headers": createChatHeadersHandler({ ctx }), "chat.headers": createChatHeadersHandler({ ctx }),
"command.execute.before": createCommandExecuteBeforeHandler({
hooks,
}),
"chat.message": createChatMessageHandler({ "chat.message": createChatMessageHandler({
ctx, ctx,
pluginConfig, pluginConfig,
+58 -2
View File
@@ -1,7 +1,14 @@
import { afterEach, describe, test, expect } from "bun:test" import { afterEach, beforeEach, describe, test, expect } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { randomUUID } from "node:crypto"
import { createChatMessageHandler } from "./chat-message" import { createChatMessageHandler } from "./chat-message"
import { _resetForTesting, setMainSession, subagentSessions } from "../features/claude-code-session-state" import { createAutoSlashCommandHook } from "../hooks/auto-slash-command"
import { createStartWorkHook } from "../hooks/start-work"
import { readBoulderState } from "../features/boulder-state"
import { _resetForTesting, setMainSession, subagentSessions, registerAgentName, updateSessionAgent, getSessionAgent } from "../features/claude-code-session-state"
import { clearSessionModel, getSessionModel, setSessionModel } from "../shared/session-model-state" import { clearSessionModel, getSessionModel, setSessionModel } from "../shared/session-model-state"
type ChatMessagePart = { type: string; text?: string; [key: string]: unknown } type ChatMessagePart = { type: string; text?: string; [key: string]: unknown }
@@ -39,6 +46,55 @@ afterEach(() => {
clearSessionModel("subagent-session") clearSessionModel("subagent-session")
}) })
describe("createChatMessageHandler - /start-work integration", () => {
let testDir = ""
let originalWorkingDirectory = ""
beforeEach(() => {
testDir = join(tmpdir(), `chat-message-start-work-${randomUUID()}`)
originalWorkingDirectory = process.cwd()
mkdirSync(join(testDir, ".sisyphus", "plans"), { recursive: true })
writeFileSync(join(testDir, ".sisyphus", "plans", "worker-plan.md"), "# Plan\n- [ ] Task 1")
process.chdir(testDir)
_resetForTesting()
registerAgentName("prometheus")
registerAgentName("sisyphus")
})
afterEach(() => {
process.chdir(originalWorkingDirectory)
rmSync(testDir, { recursive: true, force: true })
})
test("falls back to Sisyphus through the full chat.message slash-command path when Atlas is unavailable", async () => {
// given
updateSessionAgent("test-session", "prometheus")
const args = createMockHandlerArgs()
args.hooks.autoSlashCommand = createAutoSlashCommandHook({ skills: [] })
args.hooks.startWork = createStartWorkHook({
directory: testDir,
client: { tui: { showToast: async () => {} } },
} as never)
const handler = createChatMessageHandler(args)
const input = createMockInput("prometheus")
const output: ChatMessageHandlerOutput = {
message: {},
parts: [{ type: "text", text: "/start-work" }],
}
// when
await handler(input, output)
// then
expect(output.message["agent"]).toBe("Sisyphus (Ultraworker)")
expect(output.parts[0].text).toContain("<auto-slash-command>")
expect(output.parts[0].text).toContain("Auto-Selected Plan")
expect(output.parts[0].text).toContain("boulder.json has been created")
expect(getSessionAgent("test-session")).toBe("sisyphus")
expect(readBoulderState(testDir)?.agent).toBe("sisyphus")
})
})
function createMockInput(agent?: string, model?: { providerID: string; modelID: string }) { function createMockInput(agent?: string, model?: { providerID: string; modelID: string }) {
return { return {
sessionID: "test-session", sessionID: "test-session",
+39
View File
@@ -0,0 +1,39 @@
import type { CreatedHooks } from "../create-hooks"
type CommandExecuteBeforeInput = {
command: string
sessionID: string
arguments: string
}
type CommandExecuteBeforeOutput = {
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
}
function hasPartsOutput(value: unknown): value is CommandExecuteBeforeOutput {
if (typeof value !== "object" || value === null) return false
const record = value as Record<string, unknown>
const parts = record["parts"]
return Array.isArray(parts)
}
export function createCommandExecuteBeforeHandler(args: {
hooks: CreatedHooks
}): (
input: CommandExecuteBeforeInput,
output: CommandExecuteBeforeOutput,
) => Promise<void> {
const { hooks } = args
return async (input, output): Promise<void> => {
await hooks.autoSlashCommand?.["command.execute.before"]?.(input, output)
if (
hooks.startWork
&& input.command.toLowerCase() === "start-work"
&& hasPartsOutput(output)
) {
await hooks.startWork["command.execute.before"]?.(input, output)
}
}
}