diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 7ecbd40c..a57bc2e0 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -76,6 +76,7 @@ "edit-error-recovery", "delegate-task-retry", "prometheus-md-only", + "sisyphus-junior-notepad", "start-work", "atlas" ] diff --git a/src/agents/sisyphus-junior.ts b/src/agents/sisyphus-junior.ts index a9f592dd..3dc0d28b 100644 --- a/src/agents/sisyphus-junior.ts +++ b/src/agents/sisyphus-junior.ts @@ -20,32 +20,6 @@ ALLOWED: call_omo_agent - You CAN spawn explore/librarian agents for research. You work ALONE for implementation. No delegation of implementation tasks. - -## Notepad Location (for recording learnings) -NOTEPAD PATH: .sisyphus/notepads/{plan-name}/ -- learnings.md: Record patterns, conventions, successful approaches -- issues.md: Record problems, blockers, gotchas encountered -- decisions.md: Record architectural choices and rationales -- problems.md: Record unresolved issues, technical debt - -You SHOULD append findings to notepad files after completing work. -IMPORTANT: Always APPEND to notepad files - never overwrite or use Edit tool. - -## Plan Location (READ ONLY) -PLAN PATH: .sisyphus/plans/{plan-name}.md - -CRITICAL RULE: NEVER MODIFY THE PLAN FILE - -The plan file (.sisyphus/plans/*.md) is SACRED and READ-ONLY. -- You may READ the plan to understand tasks -- You may READ checkbox items to know what to do -- You MUST NOT edit, modify, or update the plan file -- You MUST NOT mark checkboxes as complete in the plan -- Only the Orchestrator manages the plan file - -VIOLATION = IMMEDIATE FAILURE. The Orchestrator tracks plan state. - - TODO OBSESSION (NON-NEGOTIABLE): - 2+ steps → todowrite FIRST, atomic breakdown diff --git a/src/config/schema.ts b/src/config/schema.ts index 48bdb916..126355b7 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -83,6 +83,7 @@ export const HookNameSchema = z.enum([ "edit-error-recovery", "delegate-task-retry", "prometheus-md-only", + "sisyphus-junior-notepad", "start-work", "atlas", ]) diff --git a/src/hooks/atlas/index.ts b/src/hooks/atlas/index.ts index 8cd91cf8..bd20168b 100644 --- a/src/hooks/atlas/index.ts +++ b/src/hooks/atlas/index.ts @@ -11,6 +11,7 @@ import { getMainSessionID, subagentSessions } from "../../features/claude-code-s 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 { isCallerOrchestrator, getMessageDir } from "../../shared/session-utils" import type { BackgroundManager } from "../../features/background-agent" export const HOOK_NAME = "atlas" @@ -380,28 +381,6 @@ interface ToolExecuteAfterOutput { metadata: Record } -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?.toLowerCase() === "atlas" - } - interface SessionState { lastEventWasAbortError?: boolean lastContinuationInjectedAt?: number @@ -672,7 +651,7 @@ export function createAtlasHook( 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${SINGLE_TASK_DIRECTIVE}` + output.args.prompt = `${SINGLE_TASK_DIRECTIVE}\n` + prompt log(`[${HOOK_NAME}] Injected single-task directive to delegate_task`, { sessionID: input.sessionID, }) diff --git a/src/hooks/index.ts b/src/hooks/index.ts index d781f0df..dd38cc38 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -26,6 +26,7 @@ export { createRalphLoopHook, type RalphLoopHook } from "./ralph-loop"; export { createAutoSlashCommandHook } from "./auto-slash-command"; export { createEditErrorRecoveryHook } from "./edit-error-recovery"; export { createPrometheusMdOnlyHook } from "./prometheus-md-only"; +export { createSisyphusJuniorNotepadHook } from "./sisyphus-junior-notepad"; export { createTaskResumeInfoHook } from "./task-resume-info"; export { createStartWorkHook } from "./start-work"; export { createAtlasHook } from "./atlas"; diff --git a/src/hooks/prometheus-md-only/index.ts b/src/hooks/prometheus-md-only/index.ts index 645c0541..2487a1cc 100644 --- a/src/hooks/prometheus-md-only/index.ts +++ b/src/hooks/prometheus-md-only/index.ts @@ -89,10 +89,10 @@ export function createPrometheusMdOnlyHook(ctx: PluginInput) { const toolName = input.tool // Inject read-only warning for task tools called by Prometheus - if (TASK_TOOLS.includes(toolName)) { - const prompt = output.args.prompt as string | undefined - if (prompt && !prompt.includes(SYSTEM_DIRECTIVE_PREFIX)) { - output.args.prompt = prompt + PLANNING_CONSULT_WARNING + if (TASK_TOOLS.includes(toolName)) { + const prompt = output.args.prompt as string | undefined + if (prompt && !prompt.includes(SYSTEM_DIRECTIVE_PREFIX)) { + output.args.prompt = PLANNING_CONSULT_WARNING + prompt log(`[${HOOK_NAME}] Injected read-only planning warning to ${toolName}`, { sessionID: input.sessionID, tool: toolName, diff --git a/src/hooks/sisyphus-junior-notepad/constants.ts b/src/hooks/sisyphus-junior-notepad/constants.ts new file mode 100644 index 00000000..2abf733c --- /dev/null +++ b/src/hooks/sisyphus-junior-notepad/constants.ts @@ -0,0 +1,29 @@ +export const HOOK_NAME = "sisyphus-junior-notepad" + +export const NOTEPAD_DIRECTIVE = ` + +## Notepad Location (for recording learnings) +NOTEPAD PATH: .sisyphus/notepads/{plan-name}/ +- learnings.md: Record patterns, conventions, successful approaches +- issues.md: Record problems, blockers, gotchas encountered +- decisions.md: Record architectural choices and rationales +- problems.md: Record unresolved issues, technical debt + +You SHOULD append findings to notepad files after completing work. +IMPORTANT: Always APPEND to notepad files - never overwrite or use Edit tool. + +## Plan Location (READ ONLY) +PLAN PATH: .sisyphus/plans/{plan-name}.md + +CRITICAL RULE: NEVER MODIFY THE PLAN FILE + +The plan file (.sisyphus/plans/*.md) is SACRED and READ-ONLY. +- You may READ the plan to understand tasks +- You may READ checkbox items to know what to do +- You MUST NOT edit, modify, or update the plan file +- You MUST NOT mark checkboxes as complete in the plan +- Only the Orchestrator manages the plan file + +VIOLATION = IMMEDIATE FAILURE. The Orchestrator tracks plan state. + +` diff --git a/src/hooks/sisyphus-junior-notepad/index.ts b/src/hooks/sisyphus-junior-notepad/index.ts new file mode 100644 index 00000000..588df568 --- /dev/null +++ b/src/hooks/sisyphus-junior-notepad/index.ts @@ -0,0 +1,45 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { isCallerOrchestrator } from "../../shared/session-utils" +import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive" +import { log } from "../../shared/logger" +import { HOOK_NAME, NOTEPAD_DIRECTIVE } from "./constants" + +export * from "./constants" + +export function createSisyphusJuniorNotepadHook(ctx: PluginInput) { + return { + "tool.execute.before": async ( + input: { tool: string; sessionID: string; callID: string }, + output: { args: Record; message?: string } + ): Promise => { + // 1. Check if tool is delegate_task + if (input.tool !== "delegate_task") { + return + } + + // 2. Check if caller is Atlas (orchestrator) + if (!isCallerOrchestrator(input.sessionID)) { + return + } + + // 3. Get prompt from output.args + const prompt = output.args.prompt as string | undefined + if (!prompt) { + return + } + + // 4. Check for double injection + if (prompt.includes(SYSTEM_DIRECTIVE_PREFIX)) { + return + } + + // 5. Prepend directive + output.args.prompt = NOTEPAD_DIRECTIVE + prompt + + // 6. Log injection + log(`[${HOOK_NAME}] Injected notepad directive to delegate_task`, { + sessionID: input.sessionID, + }) + }, + } +} diff --git a/src/index.ts b/src/index.ts index d3375c42..3210602d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,7 @@ import { createStartWorkHook, createAtlasHook, createPrometheusMdOnlyHook, + createSisyphusJuniorNotepadHook, createQuestionLabelTruncatorHook, } from "./hooks"; import { @@ -204,6 +205,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { ? createPrometheusMdOnlyHook(ctx) : null; + const sisyphusJuniorNotepad = isHookEnabled("sisyphus-junior-notepad") + ? createSisyphusJuniorNotepadHook(ctx) + : null; + const questionLabelTruncator = createQuestionLabelTruncatorHook(); const taskResumeInfo = createTaskResumeInfoHook(); @@ -495,6 +500,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { await directoryReadmeInjector?.["tool.execute.before"]?.(input, output); await rulesInjector?.["tool.execute.before"]?.(input, output); await prometheusMdOnly?.["tool.execute.before"]?.(input, output); + await sisyphusJuniorNotepad?.["tool.execute.before"]?.(input, output); await atlasHook?.["tool.execute.before"]?.(input, output); if (input.tool === "task") { diff --git a/src/shared/index.ts b/src/shared/index.ts index 759fcd40..6c681a01 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -29,3 +29,4 @@ export * from "./model-requirements" export * from "./model-resolver" export * from "./model-availability" export * from "./case-insensitive" +export * from "./session-utils" diff --git a/src/shared/session-utils.ts b/src/shared/session-utils.ts new file mode 100644 index 00000000..eb983974 --- /dev/null +++ b/src/shared/session-utils.ts @@ -0,0 +1,27 @@ +import * as path from "node:path" +import * as os from "node:os" +import { existsSync, readdirSync } from "node:fs" +import { join } from "node:path" +import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../features/hook-message-injector" + +export 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 +} + +export function isCallerOrchestrator(sessionID?: string): boolean { + if (!sessionID) return false + const messageDir = getMessageDir(sessionID) + if (!messageDir) return false + const nearest = findNearestMessageWithFields(messageDir) + return nearest?.agent?.toLowerCase() === "atlas" +}