feat(hooks): add start-work hook for Sisyphus workflow
Add hook for detecting /start-work command and triggering orchestrator-sisyphus agent for plan execution. Includes test coverage. 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
parent
ee2eb2174f
commit
a3594a41dd
155
src/hooks/start-work/index.test.ts
Normal file
155
src/hooks/start-work/index.test.ts
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
|
||||||
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { tmpdir, homedir } from "node:os"
|
||||||
|
import { createStartWorkHook } from "./index"
|
||||||
|
import {
|
||||||
|
writeBoulderState,
|
||||||
|
clearBoulderState,
|
||||||
|
} from "../../features/boulder-state"
|
||||||
|
import type { BoulderState } from "../../features/boulder-state"
|
||||||
|
|
||||||
|
describe("start-work hook", () => {
|
||||||
|
const TEST_DIR = join(tmpdir(), "start-work-test-" + Date.now())
|
||||||
|
const SISYPHUS_DIR = join(TEST_DIR, ".sisyphus")
|
||||||
|
|
||||||
|
function createMockPluginInput() {
|
||||||
|
return {
|
||||||
|
directory: TEST_DIR,
|
||||||
|
client: {},
|
||||||
|
} as Parameters<typeof createStartWorkHook>[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
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("chat.message handler", () => {
|
||||||
|
test("should ignore non-start-work commands", async () => {
|
||||||
|
// #given - hook and non-start-work message
|
||||||
|
const hook = createStartWorkHook(createMockPluginInput())
|
||||||
|
const output = {
|
||||||
|
parts: [{ type: "text", text: "Just a regular message" }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// #when
|
||||||
|
await hook["chat.message"](
|
||||||
|
{ sessionID: "session-123" },
|
||||||
|
output
|
||||||
|
)
|
||||||
|
|
||||||
|
// #then - output should be unchanged
|
||||||
|
expect(output.parts[0].text).toBe("Just a regular message")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should detect start-work command via session-context tag", async () => {
|
||||||
|
// #given - hook and start-work message
|
||||||
|
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 - output should be modified with context info
|
||||||
|
expect(output.parts[0].text).toContain("---")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should inject resume info when existing boulder state found", async () => {
|
||||||
|
// #given - existing boulder state with incomplete plan
|
||||||
|
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 = createStartWorkHook(createMockPluginInput())
|
||||||
|
const output = {
|
||||||
|
parts: [{ type: "text", text: "Start Sisyphus work session" }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// #when
|
||||||
|
await hook["chat.message"](
|
||||||
|
{ sessionID: "session-123" },
|
||||||
|
output
|
||||||
|
)
|
||||||
|
|
||||||
|
// #then - should show resuming status
|
||||||
|
expect(output.parts[0].text).toContain("RESUMING")
|
||||||
|
expect(output.parts[0].text).toContain("test-plan")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should replace $SESSION_ID placeholder", async () => {
|
||||||
|
// #given - hook and message with placeholder
|
||||||
|
const hook = createStartWorkHook(createMockPluginInput())
|
||||||
|
const output = {
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Start Sisyphus work session\nSession: $SESSION_ID",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// #when
|
||||||
|
await hook["chat.message"](
|
||||||
|
{ sessionID: "ses-abc123" },
|
||||||
|
output
|
||||||
|
)
|
||||||
|
|
||||||
|
// #then - placeholder should be replaced
|
||||||
|
expect(output.parts[0].text).toContain("ses-abc123")
|
||||||
|
expect(output.parts[0].text).not.toContain("$SESSION_ID")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should replace $TIMESTAMP placeholder", async () => {
|
||||||
|
// #given - hook and message with placeholder
|
||||||
|
const hook = createStartWorkHook(createMockPluginInput())
|
||||||
|
const output = {
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Start Sisyphus work session\nTime: $TIMESTAMP",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// #when
|
||||||
|
await hook["chat.message"](
|
||||||
|
{ sessionID: "session-123" },
|
||||||
|
output
|
||||||
|
)
|
||||||
|
|
||||||
|
// #then - placeholder should be replaced with ISO timestamp
|
||||||
|
expect(output.parts[0].text).not.toContain("$TIMESTAMP")
|
||||||
|
expect(output.parts[0].text).toMatch(/\d{4}-\d{2}-\d{2}T/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
144
src/hooks/start-work/index.ts
Normal file
144
src/hooks/start-work/index.ts
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import {
|
||||||
|
readBoulderState,
|
||||||
|
writeBoulderState,
|
||||||
|
appendSessionId,
|
||||||
|
findPrometheusPlans,
|
||||||
|
getPlanProgress,
|
||||||
|
createBoulderState,
|
||||||
|
getPlanName,
|
||||||
|
} from "../../features/boulder-state"
|
||||||
|
import { log } from "../../shared/logger"
|
||||||
|
|
||||||
|
export const HOOK_NAME = "start-work"
|
||||||
|
|
||||||
|
interface StartWorkHookInput {
|
||||||
|
sessionID: string
|
||||||
|
messageID?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StartWorkHookOutput {
|
||||||
|
parts: Array<{ type: string; text?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStartWorkHook(ctx: PluginInput) {
|
||||||
|
return {
|
||||||
|
"chat.message": async (
|
||||||
|
input: StartWorkHookInput,
|
||||||
|
output: StartWorkHookOutput
|
||||||
|
): Promise<void> => {
|
||||||
|
const parts = output.parts
|
||||||
|
const promptText = parts
|
||||||
|
?.filter((p) => p.type === "text" && p.text)
|
||||||
|
.map((p) => p.text)
|
||||||
|
.join("\n")
|
||||||
|
.trim() || ""
|
||||||
|
|
||||||
|
const isStartWorkCommand =
|
||||||
|
promptText.includes("Start Sisyphus work session") ||
|
||||||
|
promptText.includes("<session-context>")
|
||||||
|
|
||||||
|
if (!isStartWorkCommand) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`[${HOOK_NAME}] Processing start-work command`, {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
})
|
||||||
|
|
||||||
|
const existingState = readBoulderState(ctx.directory)
|
||||||
|
const sessionId = input.sessionID
|
||||||
|
const timestamp = new Date().toISOString()
|
||||||
|
|
||||||
|
let contextInfo = ""
|
||||||
|
|
||||||
|
if (existingState) {
|
||||||
|
const progress = getPlanProgress(existingState.active_plan)
|
||||||
|
|
||||||
|
if (!progress.isComplete) {
|
||||||
|
appendSessionId(ctx.directory, sessionId)
|
||||||
|
contextInfo = `
|
||||||
|
## Active Work Session Found
|
||||||
|
|
||||||
|
**Status**: RESUMING existing work
|
||||||
|
**Plan**: ${existingState.plan_name}
|
||||||
|
**Path**: ${existingState.active_plan}
|
||||||
|
**Progress**: ${progress.completed}/${progress.total} tasks completed
|
||||||
|
**Sessions**: ${existingState.session_ids.length + 1} (current session appended)
|
||||||
|
**Started**: ${existingState.started_at}
|
||||||
|
|
||||||
|
The current session (${sessionId}) has been added to session_ids.
|
||||||
|
Read the plan file and continue from the first unchecked task.`
|
||||||
|
} else {
|
||||||
|
contextInfo = `
|
||||||
|
## Previous Work Complete
|
||||||
|
|
||||||
|
The previous plan (${existingState.plan_name}) has been completed.
|
||||||
|
Looking for new plans...`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existingState || getPlanProgress(existingState.active_plan).isComplete) {
|
||||||
|
const plans = findPrometheusPlans(ctx.directory)
|
||||||
|
|
||||||
|
if (plans.length === 0) {
|
||||||
|
contextInfo += `
|
||||||
|
|
||||||
|
## No Plans Found
|
||||||
|
|
||||||
|
No Prometheus plan files found at .sisyphus/plans/
|
||||||
|
Use Prometheus to create a work plan first: /plan "your task"`
|
||||||
|
} else if (plans.length === 1) {
|
||||||
|
const planPath = plans[0]
|
||||||
|
const progress = getPlanProgress(planPath)
|
||||||
|
const newState = createBoulderState(planPath, sessionId)
|
||||||
|
writeBoulderState(ctx.directory, newState)
|
||||||
|
|
||||||
|
contextInfo += `
|
||||||
|
|
||||||
|
## Auto-Selected Plan
|
||||||
|
|
||||||
|
**Plan**: ${getPlanName(planPath)}
|
||||||
|
**Path**: ${planPath}
|
||||||
|
**Progress**: ${progress.completed}/${progress.total} tasks
|
||||||
|
**Session ID**: ${sessionId}
|
||||||
|
**Started**: ${timestamp}
|
||||||
|
|
||||||
|
boulder.json has been created. Read the plan and begin execution.`
|
||||||
|
} else {
|
||||||
|
const planList = plans.map((p, i) => {
|
||||||
|
const progress = getPlanProgress(p)
|
||||||
|
const stat = require("node:fs").statSync(p)
|
||||||
|
const modified = new Date(stat.mtimeMs).toISOString()
|
||||||
|
return `${i + 1}. [${getPlanName(p)}] - Modified: ${modified} - Progress: ${progress.completed}/${progress.total}`
|
||||||
|
}).join("\n")
|
||||||
|
|
||||||
|
contextInfo += `
|
||||||
|
|
||||||
|
## Multiple Plans Found
|
||||||
|
|
||||||
|
Current Time: ${timestamp}
|
||||||
|
Session ID: ${sessionId}
|
||||||
|
|
||||||
|
${planList}
|
||||||
|
|
||||||
|
Which plan would you like to work on? Reply with the number or plan name.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
|
||||||
|
if (idx >= 0 && output.parts[idx].text) {
|
||||||
|
output.parts[idx].text = output.parts[idx].text
|
||||||
|
.replace(/\$SESSION_ID/g, sessionId)
|
||||||
|
.replace(/\$TIMESTAMP/g, timestamp)
|
||||||
|
|
||||||
|
output.parts[idx].text += `\n\n---\n${contextInfo}`
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`[${HOOK_NAME}] Context injected`, {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
hasExistingState: !!existingState,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user