From a3594a41dd3756bbc23f5269ffa557de410dadd5 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 5 Jan 2026 13:51:02 +0900 Subject: [PATCH] feat(hooks): add start-work hook for Sisyphus workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/hooks/start-work/index.test.ts | 155 +++++++++++++++++++++++++++++ src/hooks/start-work/index.ts | 144 +++++++++++++++++++++++++++ 2 files changed, 299 insertions(+) create mode 100644 src/hooks/start-work/index.test.ts create mode 100644 src/hooks/start-work/index.ts diff --git a/src/hooks/start-work/index.test.ts b/src/hooks/start-work/index.test.ts new file mode 100644 index 000000000..3a12ca2c8 --- /dev/null +++ b/src/hooks/start-work/index.test.ts @@ -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[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: "Some context here", + }, + ], + } + + // #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/) + }) + }) +}) diff --git a/src/hooks/start-work/index.ts b/src/hooks/start-work/index.ts new file mode 100644 index 000000000..d58c2455d --- /dev/null +++ b/src/hooks/start-work/index.ts @@ -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 => { + 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("") + + 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, + }) + }, + } +}