Merge branch 'dev' into fix/ulw-boundary-and-skill-mcp-args
This commit is contained in:
+37
-49
@@ -1,66 +1,54 @@
|
||||
# HOOKS KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
22 lifecycle hooks intercepting/modifying agent behavior. Context injection, error recovery, output control, notifications.
|
||||
22+ lifecycle hooks intercepting/modifying agent behavior via PreToolUse, PostToolUse, UserPromptSubmit, and more.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
hooks/
|
||||
├── anthropic-context-window-limit-recovery/ # Auto-compact at token limit (554 lines)
|
||||
├── auto-slash-command/ # Detect and execute /command patterns
|
||||
├── auto-update-checker/ # Version notifications, startup toast
|
||||
├── background-notification/ # OS notify on task complete
|
||||
├── claude-code-hooks/ # settings.json PreToolUse/PostToolUse/etc
|
||||
├── comment-checker/ # Prevent excessive AI comments
|
||||
│ └── filters/ # docstring, directive, bdd, etc
|
||||
├── compaction-context-injector/ # Preserve context during compaction
|
||||
├── directory-agents-injector/ # Auto-inject AGENTS.md
|
||||
├── directory-readme-injector/ # Auto-inject README.md
|
||||
├── empty-message-sanitizer/ # Sanitize empty messages
|
||||
├── interactive-bash-session/ # Tmux session management
|
||||
├── keyword-detector/ # ultrawork/search keyword activation
|
||||
├── non-interactive-env/ # CI/headless handling
|
||||
├── preemptive-compaction/ # Pre-emptive at 85% usage
|
||||
├── ralph-loop/ # Self-referential dev loop
|
||||
├── anthropic-context-window-limit-recovery/ # Auto-summarize at token limit (555 lines)
|
||||
├── sisyphus-orchestrator/ # Main orchestration & agent delegation (677 lines)
|
||||
├── ralph-loop/ # Self-referential dev loop (364 lines)
|
||||
├── claude-code-hooks/ # settings.json hook compatibility layer
|
||||
├── comment-checker/ # Prevents AI slop/excessive comments
|
||||
├── auto-slash-command/ # Detects and executes /command patterns
|
||||
├── rules-injector/ # Conditional rules from .claude/rules/
|
||||
├── session-recovery/ # Recover from errors (430 lines)
|
||||
├── think-mode/ # Auto-detect thinking triggers
|
||||
├── agent-usage-reminder/ # Remind to use specialists
|
||||
├── context-window-monitor.ts # Monitor usage (standalone)
|
||||
├── session-notification.ts # OS notify on idle
|
||||
├── todo-continuation-enforcer.ts # Force TODO completion
|
||||
└── tool-output-truncator.ts # Truncate verbose outputs
|
||||
├── directory-agents-injector/ # Auto-injects local AGENTS.md files
|
||||
├── directory-readme-injector/ # Auto-injects local README.md files
|
||||
├── preemptive-compaction/ # Triggers summary at 85% usage
|
||||
├── edit-error-recovery/ # Recovers from tool execution failures
|
||||
├── thinking-block-validator/ # Ensures valid <thinking> format
|
||||
├── context-window-monitor.ts # Reminds agents of remaining headroom
|
||||
├── session-recovery/ # Auto-recovers from session crashes
|
||||
├── start-work/ # Initializes work sessions (ulw/ulw)
|
||||
├── think-mode/ # Dynamic thinking budget adjustment
|
||||
├── background-notification/ # OS notification on task completion
|
||||
├── todo-continuation-enforcer.ts # Force completion of [ ] items
|
||||
└── tool-output-truncator.ts # Prevents context bloat from verbose tools
|
||||
```
|
||||
|
||||
## HOOK EVENTS
|
||||
|
||||
| Event | Timing | Can Block | Use Case |
|
||||
|-------|--------|-----------|----------|
|
||||
| PreToolUse | Before tool | Yes | Validate, modify input |
|
||||
| PostToolUse | After tool | No | Add context, warnings |
|
||||
| UserPromptSubmit | On prompt | Yes | Inject messages, block |
|
||||
| Stop | Session idle | No | Inject follow-ups |
|
||||
| onSummarize | Compaction | No | Preserve context |
|
||||
| Event | Timing | Can Block | Description |
|
||||
|-------|--------|-----------|-------------|
|
||||
| PreToolUse | Before tool | Yes | Validate/modify inputs (e.g., directory-agents-injector) |
|
||||
| PostToolUse | After tool | No | Append context/warnings (e.g., edit-error-recovery) |
|
||||
| UserPromptSubmit | On prompt | Yes | Filter/modify user input (e.g., keyword-detector) |
|
||||
| Stop | Session idle | No | Auto-continue tasks (e.g., todo-continuation-enforcer) |
|
||||
| onSummarize | Compaction | No | State preservation (e.g., compaction-context-injector) |
|
||||
|
||||
## HOW TO ADD
|
||||
|
||||
1. Create `src/hooks/my-hook/`
|
||||
2. Files: `index.ts` (createMyHook), `constants.ts`, `types.ts` (optional)
|
||||
3. Return: `{ PreToolUse?, PostToolUse?, UserPromptSubmit?, Stop?, onSummarize? }`
|
||||
4. Export from `src/hooks/index.ts`
|
||||
1. Create `src/hooks/name/` with `index.ts` factory (e.g., `createMyHook`).
|
||||
2. Implement `PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `Stop`, or `onSummarize`.
|
||||
3. Register in `src/hooks/index.ts`.
|
||||
|
||||
## PATTERNS
|
||||
|
||||
- **Storage**: JSON file for persistent state across sessions
|
||||
- **Once-per-session**: Track injected paths in Set
|
||||
- **Message injection**: Return `{ messages: [...] }`
|
||||
- **Blocking**: Return `{ blocked: true, message: "..." }` from PreToolUse
|
||||
- **Context Injection**: Use `PreToolUse` to prepend instructions to tool inputs.
|
||||
- **Resilience**: Implement `edit-error-recovery` style logic to retry failed tools.
|
||||
- **Telegraphic UI**: Use `PostToolUse` to add brief warnings without bloating transcript.
|
||||
- **Statelessness**: Prefer local file storage for state that must persist across sessions.
|
||||
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- Heavy computation in PreToolUse (slows every tool call)
|
||||
- Blocking without actionable message
|
||||
- Duplicate injection (track what's injected)
|
||||
- Missing try/catch (don't crash session)
|
||||
- **Blocking**: Avoid blocking tools unless critical (use warnings in `PostToolUse` instead).
|
||||
- **Latency**: No heavy computation in `PreToolUse`; it slows every interaction.
|
||||
- **Redundancy**: Don't inject the same file multiple times; track state in session storage.
|
||||
- **Prose**: Never use verbose prose in hook outputs; keep it technical and brief.
|
||||
|
||||
@@ -15,6 +15,8 @@ export const TARGET_TOOLS = new Set([
|
||||
"safe_glob",
|
||||
"webfetch",
|
||||
"context7_resolve-library-id",
|
||||
"context7_query-docs",
|
||||
"websearch_web_search_exa",
|
||||
"context7_get-library-docs",
|
||||
"grep_app_searchgithub",
|
||||
]);
|
||||
@@ -22,7 +24,7 @@ export const TARGET_TOOLS = new Set([
|
||||
export const AGENT_TOOLS = new Set([
|
||||
"task",
|
||||
"call_omo_agent",
|
||||
"background_task",
|
||||
"sisyphus_task",
|
||||
]);
|
||||
|
||||
export const REMINDER_MESSAGE = `
|
||||
@@ -30,13 +32,13 @@ export const REMINDER_MESSAGE = `
|
||||
|
||||
You called a search/fetch tool directly without leveraging specialized agents.
|
||||
|
||||
RECOMMENDED: Use background_task with explore/librarian agents for better results:
|
||||
RECOMMENDED: Use sisyphus_task with explore/librarian agents for better results:
|
||||
|
||||
\`\`\`
|
||||
// Parallel exploration - fire multiple agents simultaneously
|
||||
background_task(agent="explore", prompt="Find all files matching pattern X")
|
||||
background_task(agent="explore", prompt="Search for implementation of Y")
|
||||
background_task(agent="librarian", prompt="Lookup documentation for Z")
|
||||
sisyphus_task(agent="explore", prompt="Find all files matching pattern X")
|
||||
sisyphus_task(agent="explore", prompt="Search for implementation of Y")
|
||||
sisyphus_task(agent="librarian", prompt="Lookup documentation for Z")
|
||||
|
||||
// Then continue your work while they run in background
|
||||
// System will notify you when each completes
|
||||
@@ -48,5 +50,5 @@ WHY:
|
||||
- Specialized agents have domain expertise
|
||||
- Reduces context window usage in main session
|
||||
|
||||
ALWAYS prefer: Multiple parallel background_task calls > Direct tool calls
|
||||
ALWAYS prefer: Multiple parallel sisyphus_task calls > Direct tool calls
|
||||
`;
|
||||
|
||||
@@ -257,7 +257,7 @@ describe("executeCompact lock management", () => {
|
||||
expect(mockClient.session.summarize).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: { id: sessionID },
|
||||
body: { providerID: "anthropic", modelID: "claude-opus-4-5" },
|
||||
body: { providerID: "anthropic", modelID: "claude-opus-4-5", auto: true },
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -320,7 +320,6 @@ export async function executeCompact(
|
||||
"todowrite",
|
||||
"todoread",
|
||||
"lsp_rename",
|
||||
"lsp_code_action_resolve",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -409,7 +408,7 @@ export async function executeCompact(
|
||||
try {
|
||||
await (client as Client).session.prompt_async({
|
||||
path: { id: sessionID },
|
||||
body: { parts: [{ type: "text", text: "Continue" }] },
|
||||
body: { auto: true } as never,
|
||||
query: { directory },
|
||||
});
|
||||
} catch {}
|
||||
@@ -497,21 +496,12 @@ export async function executeCompact(
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
const summarizeBody = { providerID, modelID, auto: true }
|
||||
await (client as Client).session.summarize({
|
||||
path: { id: sessionID },
|
||||
body: { providerID, modelID },
|
||||
body: summarizeBody as never,
|
||||
query: { directory },
|
||||
});
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (client as Client).session.prompt_async({
|
||||
path: { id: sessionID },
|
||||
body: { parts: [{ type: "text", text: "Continue" }] },
|
||||
query: { directory },
|
||||
});
|
||||
} catch {}
|
||||
}, 500);
|
||||
return;
|
||||
} catch {
|
||||
const delay =
|
||||
|
||||
@@ -11,7 +11,6 @@ const DEFAULT_PROTECTED_TOOLS = new Set([
|
||||
"todowrite",
|
||||
"todoread",
|
||||
"lsp_rename",
|
||||
"lsp_code_action_resolve",
|
||||
"session_read",
|
||||
"session_write",
|
||||
"session_search",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { getLatestVersion } from "./checker"
|
||||
|
||||
describe("auto-update-checker/checker", () => {
|
||||
describe("getLatestVersion", () => {
|
||||
test("accepts channel parameter", async () => {
|
||||
const result = await getLatestVersion("beta")
|
||||
|
||||
expect(typeof result === "string" || result === null).toBe(true)
|
||||
})
|
||||
|
||||
test("accepts latest channel", async () => {
|
||||
const result = await getLatestVersion("latest")
|
||||
|
||||
expect(typeof result === "string" || result === null).toBe(true)
|
||||
})
|
||||
|
||||
test("works without channel (defaults to latest)", async () => {
|
||||
const result = await getLatestVersion()
|
||||
|
||||
expect(typeof result === "string" || result === null).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -231,7 +231,7 @@ export function updatePinnedVersion(configPath: string, oldEntry: string, newVer
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLatestVersion(): Promise<string | null> {
|
||||
export async function getLatestVersion(channel: string = "latest"): Promise<string | null> {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT)
|
||||
|
||||
@@ -244,7 +244,7 @@ export async function getLatestVersion(): Promise<string | null> {
|
||||
if (!response.ok) return null
|
||||
|
||||
const data = (await response.json()) as NpmDistTags
|
||||
return data.latest ?? null
|
||||
return data[channel] ?? data.latest ?? null
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
@@ -264,24 +264,21 @@ export async function checkForUpdate(directory: string): Promise<UpdateCheckResu
|
||||
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false, isPinned: false }
|
||||
}
|
||||
|
||||
if (pluginInfo.isPinned) {
|
||||
log(`[auto-update-checker] Version pinned to ${pluginInfo.pinnedVersion}, skipping update check`)
|
||||
return { needsUpdate: false, currentVersion: pluginInfo.pinnedVersion, latestVersion: null, isLocalDev: false, isPinned: true }
|
||||
}
|
||||
|
||||
const currentVersion = getCachedVersion()
|
||||
const currentVersion = getCachedVersion() ?? pluginInfo.pinnedVersion
|
||||
if (!currentVersion) {
|
||||
log("[auto-update-checker] No cached version found")
|
||||
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false, isPinned: false }
|
||||
}
|
||||
|
||||
const latestVersion = await getLatestVersion()
|
||||
const { extractChannel } = await import("./index")
|
||||
const channel = extractChannel(pluginInfo.pinnedVersion ?? currentVersion)
|
||||
const latestVersion = await getLatestVersion(channel)
|
||||
if (!latestVersion) {
|
||||
log("[auto-update-checker] Failed to fetch latest version")
|
||||
return { needsUpdate: false, currentVersion, latestVersion: null, isLocalDev: false, isPinned: false }
|
||||
log("[auto-update-checker] Failed to fetch latest version for channel:", channel)
|
||||
return { needsUpdate: false, currentVersion, latestVersion: null, isLocalDev: false, isPinned: pluginInfo.isPinned }
|
||||
}
|
||||
|
||||
const needsUpdate = currentVersion !== latestVersion
|
||||
log(`[auto-update-checker] Current: ${currentVersion}, Latest: ${latestVersion}, NeedsUpdate: ${needsUpdate}`)
|
||||
return { needsUpdate, currentVersion, latestVersion, isLocalDev: false, isPinned: false }
|
||||
log(`[auto-update-checker] Current: ${currentVersion}, Latest (${channel}): ${latestVersion}, NeedsUpdate: ${needsUpdate}`)
|
||||
return { needsUpdate, currentVersion, latestVersion, isLocalDev: false, isPinned: pluginInfo.isPinned }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { isPrereleaseVersion, isDistTag, isPrereleaseOrDistTag, extractChannel } from "./index"
|
||||
|
||||
describe("auto-update-checker", () => {
|
||||
describe("isPrereleaseVersion", () => {
|
||||
test("returns true for beta versions", () => {
|
||||
// #given a beta version
|
||||
const version = "3.0.0-beta.1"
|
||||
|
||||
// #when checking if prerelease
|
||||
const result = isPrereleaseVersion(version)
|
||||
|
||||
// #then returns true
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for alpha versions", () => {
|
||||
// #given an alpha version
|
||||
const version = "1.0.0-alpha"
|
||||
|
||||
// #when checking if prerelease
|
||||
const result = isPrereleaseVersion(version)
|
||||
|
||||
// #then returns true
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for rc versions", () => {
|
||||
// #given an rc version
|
||||
const version = "2.0.0-rc.1"
|
||||
|
||||
// #when checking if prerelease
|
||||
const result = isPrereleaseVersion(version)
|
||||
|
||||
// #then returns true
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for stable versions", () => {
|
||||
// #given a stable version
|
||||
const version = "2.14.0"
|
||||
|
||||
// #when checking if prerelease
|
||||
const result = isPrereleaseVersion(version)
|
||||
|
||||
// #then returns false
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isDistTag", () => {
|
||||
test("returns true for beta dist-tag", () => {
|
||||
// #given beta dist-tag
|
||||
const version = "beta"
|
||||
|
||||
// #when checking if dist-tag
|
||||
const result = isDistTag(version)
|
||||
|
||||
// #then returns true
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for next dist-tag", () => {
|
||||
// #given next dist-tag
|
||||
const version = "next"
|
||||
|
||||
// #when checking if dist-tag
|
||||
const result = isDistTag(version)
|
||||
|
||||
// #then returns true
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for canary dist-tag", () => {
|
||||
// #given canary dist-tag
|
||||
const version = "canary"
|
||||
|
||||
// #when checking if dist-tag
|
||||
const result = isDistTag(version)
|
||||
|
||||
// #then returns true
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for semver versions", () => {
|
||||
// #given a semver version
|
||||
const version = "2.14.0"
|
||||
|
||||
// #when checking if dist-tag
|
||||
const result = isDistTag(version)
|
||||
|
||||
// #then returns false
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for latest (handled separately)", () => {
|
||||
// #given latest tag
|
||||
const version = "latest"
|
||||
|
||||
// #when checking if dist-tag
|
||||
const result = isDistTag(version)
|
||||
|
||||
// #then returns true (but latest is filtered before this check)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isPrereleaseOrDistTag", () => {
|
||||
test("returns false for null", () => {
|
||||
// #given null version
|
||||
const version = null
|
||||
|
||||
// #when checking
|
||||
const result = isPrereleaseOrDistTag(version)
|
||||
|
||||
// #then returns false
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns true for prerelease version", () => {
|
||||
// #given prerelease version
|
||||
const version = "3.0.0-beta.1"
|
||||
|
||||
// #when checking
|
||||
const result = isPrereleaseOrDistTag(version)
|
||||
|
||||
// #then returns true
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for dist-tag", () => {
|
||||
// #given dist-tag
|
||||
const version = "beta"
|
||||
|
||||
// #when checking
|
||||
const result = isPrereleaseOrDistTag(version)
|
||||
|
||||
// #then returns true
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for stable version", () => {
|
||||
// #given stable version
|
||||
const version = "2.14.0"
|
||||
|
||||
// #when checking
|
||||
const result = isPrereleaseOrDistTag(version)
|
||||
|
||||
// #then returns false
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractChannel", () => {
|
||||
test("extracts beta from dist-tag", () => {
|
||||
// #given beta dist-tag
|
||||
const version = "beta"
|
||||
|
||||
// #when extracting channel
|
||||
const result = extractChannel(version)
|
||||
|
||||
// #then returns beta
|
||||
expect(result).toBe("beta")
|
||||
})
|
||||
|
||||
test("extracts next from dist-tag", () => {
|
||||
// #given next dist-tag
|
||||
const version = "next"
|
||||
|
||||
// #when extracting channel
|
||||
const result = extractChannel(version)
|
||||
|
||||
// #then returns next
|
||||
expect(result).toBe("next")
|
||||
})
|
||||
|
||||
test("extracts canary from dist-tag", () => {
|
||||
// #given canary dist-tag
|
||||
const version = "canary"
|
||||
|
||||
// #when extracting channel
|
||||
const result = extractChannel(version)
|
||||
|
||||
// #then returns canary
|
||||
expect(result).toBe("canary")
|
||||
})
|
||||
|
||||
test("extracts beta from prerelease version", () => {
|
||||
// #given beta prerelease version
|
||||
const version = "3.0.0-beta.1"
|
||||
|
||||
// #when extracting channel
|
||||
const result = extractChannel(version)
|
||||
|
||||
// #then returns beta
|
||||
expect(result).toBe("beta")
|
||||
})
|
||||
|
||||
test("extracts alpha from prerelease version", () => {
|
||||
// #given alpha prerelease version
|
||||
const version = "1.0.0-alpha"
|
||||
|
||||
// #when extracting channel
|
||||
const result = extractChannel(version)
|
||||
|
||||
// #then returns alpha
|
||||
expect(result).toBe("alpha")
|
||||
})
|
||||
|
||||
test("extracts rc from prerelease version", () => {
|
||||
// #given rc prerelease version
|
||||
const version = "2.0.0-rc.1"
|
||||
|
||||
// #when extracting channel
|
||||
const result = extractChannel(version)
|
||||
|
||||
// #then returns rc
|
||||
expect(result).toBe("rc")
|
||||
})
|
||||
|
||||
test("returns latest for stable version", () => {
|
||||
// #given stable version
|
||||
const version = "2.14.0"
|
||||
|
||||
// #when extracting channel
|
||||
const result = extractChannel(version)
|
||||
|
||||
// #then returns latest
|
||||
expect(result).toBe("latest")
|
||||
})
|
||||
|
||||
test("returns latest for null", () => {
|
||||
// #given null version
|
||||
const version = null
|
||||
|
||||
// #when extracting channel
|
||||
const result = extractChannel(version)
|
||||
|
||||
// #then returns latest
|
||||
expect(result).toBe("latest")
|
||||
})
|
||||
|
||||
test("handles complex prerelease identifiers", () => {
|
||||
// #given complex prerelease
|
||||
const version = "3.0.0-beta.1.experimental"
|
||||
|
||||
// #when extracting channel
|
||||
const result = extractChannel(version)
|
||||
|
||||
// #then returns beta
|
||||
expect(result).toBe("beta")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,40 @@ import type { AutoUpdateCheckerOptions } from "./types"
|
||||
|
||||
const SISYPHUS_SPINNER = ["·", "•", "●", "○", "◌", "◦", " "]
|
||||
|
||||
export function isPrereleaseVersion(version: string): boolean {
|
||||
return version.includes("-")
|
||||
}
|
||||
|
||||
export function isDistTag(version: string): boolean {
|
||||
const startsWithDigit = /^\d/.test(version)
|
||||
return !startsWithDigit
|
||||
}
|
||||
|
||||
export function isPrereleaseOrDistTag(pinnedVersion: string | null): boolean {
|
||||
if (!pinnedVersion) return false
|
||||
return isPrereleaseVersion(pinnedVersion) || isDistTag(pinnedVersion)
|
||||
}
|
||||
|
||||
export function extractChannel(version: string | null): string {
|
||||
if (!version) return "latest"
|
||||
|
||||
if (isDistTag(version)) {
|
||||
return version
|
||||
}
|
||||
|
||||
if (isPrereleaseVersion(version)) {
|
||||
const prereleasePart = version.split("-")[1]
|
||||
if (prereleasePart) {
|
||||
const channelMatch = prereleasePart.match(/^(alpha|beta|rc|canary|next)/)
|
||||
if (channelMatch) {
|
||||
return channelMatch[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "latest"
|
||||
}
|
||||
|
||||
export function createAutoUpdateCheckerHook(ctx: PluginInput, options: AutoUpdateCheckerOptions = {}) {
|
||||
const { showStartupToast = true, isSisyphusEnabled = false, autoUpdate = true } = options
|
||||
|
||||
@@ -63,7 +97,7 @@ export function createAutoUpdateCheckerHook(ctx: PluginInput, options: AutoUpdat
|
||||
}
|
||||
|
||||
async function runBackgroundUpdateCheck(
|
||||
ctx: PluginInput,
|
||||
ctx: PluginInput,
|
||||
autoUpdate: boolean,
|
||||
getToastMessage: (isUpdate: boolean, latestVersion?: string) => string
|
||||
): Promise<void> {
|
||||
@@ -80,18 +114,19 @@ async function runBackgroundUpdateCheck(
|
||||
return
|
||||
}
|
||||
|
||||
const latestVersion = await getLatestVersion()
|
||||
const channel = extractChannel(pluginInfo.pinnedVersion ?? currentVersion)
|
||||
const latestVersion = await getLatestVersion(channel)
|
||||
if (!latestVersion) {
|
||||
log("[auto-update-checker] Failed to fetch latest version")
|
||||
log("[auto-update-checker] Failed to fetch latest version for channel:", channel)
|
||||
return
|
||||
}
|
||||
|
||||
if (currentVersion === latestVersion) {
|
||||
log("[auto-update-checker] Already on latest version")
|
||||
log("[auto-update-checker] Already on latest version for channel:", channel)
|
||||
return
|
||||
}
|
||||
|
||||
log(`[auto-update-checker] Update available: ${currentVersion} → ${latestVersion}`)
|
||||
log(`[auto-update-checker] Update available (${channel}): ${currentVersion} → ${latestVersion}`)
|
||||
|
||||
if (!autoUpdate) {
|
||||
await showUpdateAvailableToast(ctx, latestVersion, getToastMessage)
|
||||
@@ -112,7 +147,7 @@ async function runBackgroundUpdateCheck(
|
||||
invalidatePackage(PACKAGE_NAME)
|
||||
|
||||
const installSuccess = await runBunInstallSafe()
|
||||
|
||||
|
||||
if (installSuccess) {
|
||||
await showAutoUpdatedToast(ctx, currentVersion, latestVersion)
|
||||
log(`[auto-update-checker] Update installed: ${currentVersion} → ${latestVersion}`)
|
||||
@@ -180,7 +215,7 @@ async function showSpinnerToast(ctx: PluginInput, version: string, message: stri
|
||||
}
|
||||
|
||||
async function showUpdateAvailableToast(
|
||||
ctx: PluginInput,
|
||||
ctx: PluginInput,
|
||||
latestVersion: string,
|
||||
getToastMessage: (isUpdate: boolean, latestVersion?: string) => string
|
||||
): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
|
||||
interface CompactingInput {
|
||||
sessionID: string
|
||||
}
|
||||
|
||||
interface CompactingOutput {
|
||||
context: string[]
|
||||
prompt?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Background agent compaction hook - preserves task state during context compaction.
|
||||
*
|
||||
* When OpenCode compacts session context to save tokens, this hook injects
|
||||
* information about running and recently completed background tasks so the
|
||||
* agent doesn't lose awareness of delegated work.
|
||||
*/
|
||||
export function createBackgroundCompactionHook(manager: BackgroundManager) {
|
||||
return {
|
||||
"experimental.session.compacting": async (
|
||||
input: CompactingInput,
|
||||
output: CompactingOutput
|
||||
): Promise<void> => {
|
||||
const { sessionID } = input
|
||||
|
||||
// Get running tasks for this session
|
||||
const running = manager.getRunningTasks()
|
||||
.filter(t => t.parentSessionID === sessionID)
|
||||
.map(t => ({
|
||||
id: t.id,
|
||||
agent: t.agent,
|
||||
description: t.description,
|
||||
startedAt: t.startedAt,
|
||||
}))
|
||||
|
||||
// Get recently completed tasks (still in memory within 5-min retention)
|
||||
const completed = manager.getCompletedTasks()
|
||||
.filter(t => t.parentSessionID === sessionID)
|
||||
.slice(-10) // Last 10 completed
|
||||
.map(t => ({
|
||||
id: t.id,
|
||||
agent: t.agent,
|
||||
description: t.description,
|
||||
status: t.status,
|
||||
}))
|
||||
|
||||
// Early exit if nothing to preserve
|
||||
if (running.length === 0 && completed.length === 0) return
|
||||
|
||||
const sections: string[] = ["<background-tasks>"]
|
||||
|
||||
// Running tasks section
|
||||
if (running.length > 0) {
|
||||
sections.push("## Running Background Tasks")
|
||||
sections.push("")
|
||||
for (const t of running) {
|
||||
const elapsed = Math.floor((Date.now() - t.startedAt.getTime()) / 1000)
|
||||
sections.push(`- **\`${t.id}\`** (${t.agent}): ${t.description} [${elapsed}s elapsed]`)
|
||||
}
|
||||
sections.push("")
|
||||
sections.push("> **Note:** You WILL be notified when tasks complete.")
|
||||
sections.push("> Do NOT poll - continue productive work.")
|
||||
sections.push("")
|
||||
}
|
||||
|
||||
// Completed tasks section
|
||||
if (completed.length > 0) {
|
||||
sections.push("## Recently Completed Tasks")
|
||||
sections.push("")
|
||||
for (const t of completed) {
|
||||
const statusEmoji = t.status === "completed" ? "✅" : t.status === "error" ? "❌" : "⏱️"
|
||||
sections.push(`- ${statusEmoji} **\`${t.id}\`**: ${t.description}`)
|
||||
}
|
||||
sections.push("")
|
||||
}
|
||||
|
||||
sections.push("## Retrieval")
|
||||
sections.push('Use `background_output(task_id="<id>")` to retrieve task results.')
|
||||
sections.push("</background-tasks>")
|
||||
|
||||
output.context.push(sections.join("\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,12 @@ interface EventInput {
|
||||
event: Event
|
||||
}
|
||||
|
||||
/**
|
||||
* Background notification hook - handles event routing to BackgroundManager.
|
||||
*
|
||||
* Notifications are now delivered directly via session.prompt({ noReply })
|
||||
* from the manager, so this hook only needs to handle event routing.
|
||||
*/
|
||||
export function createBackgroundNotificationHook(manager: BackgroundManager) {
|
||||
const eventHandler = async ({ event }: EventInput) => {
|
||||
manager.handleEvent(event)
|
||||
|
||||
@@ -27,14 +27,17 @@ import { cacheToolInput, getToolInput } from "./tool-input-cache"
|
||||
import { recordToolUse, recordToolResult, getTranscriptPath, recordUserMessage } from "./transcript"
|
||||
import type { PluginConfig } from "./types"
|
||||
import { log, isHookDisabled } from "../../shared"
|
||||
import { injectHookMessage } from "../../features/hook-message-injector"
|
||||
import { detectKeywordsWithType, removeCodeBlocks } from "../keyword-detector"
|
||||
import type { ContextCollector } from "../../features/context-injector"
|
||||
|
||||
const sessionFirstMessageProcessed = new Set<string>()
|
||||
const sessionErrorState = new Map<string, { hasError: boolean; errorMessage?: string }>()
|
||||
const sessionInterruptState = new Map<string, { interrupted: boolean }>()
|
||||
|
||||
export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig = {}) {
|
||||
export function createClaudeCodeHooksHook(
|
||||
ctx: PluginInput,
|
||||
config: PluginConfig = {},
|
||||
contextCollector?: ContextCollector
|
||||
) {
|
||||
return {
|
||||
"experimental.session.compacting": async (
|
||||
input: { sessionID: string },
|
||||
@@ -138,25 +141,9 @@ export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig
|
||||
return
|
||||
}
|
||||
|
||||
const keywordMessages: string[] = []
|
||||
if (!config.keywordDetectorDisabled) {
|
||||
const detectedKeywords = detectKeywordsWithType(removeCodeBlocks(prompt), input.agent)
|
||||
keywordMessages.push(...detectedKeywords.map((k) => k.message))
|
||||
|
||||
if (keywordMessages.length > 0) {
|
||||
log("[claude-code-hooks] Detected keywords", {
|
||||
sessionID: input.sessionID,
|
||||
keywordCount: keywordMessages.length,
|
||||
types: detectedKeywords.map((k) => k.type),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const allMessages = [...keywordMessages, ...result.messages]
|
||||
|
||||
if (allMessages.length > 0) {
|
||||
const hookContent = allMessages.join("\n\n")
|
||||
log(`[claude-code-hooks] Injecting ${allMessages.length} messages (${keywordMessages.length} keyword + ${result.messages.length} hook)`, { sessionID: input.sessionID, contentLength: hookContent.length, isFirstMessage })
|
||||
if (result.messages.length > 0) {
|
||||
const hookContent = result.messages.join("\n\n")
|
||||
log(`[claude-code-hooks] Injecting ${result.messages.length} hook messages`, { sessionID: input.sessionID, contentLength: hookContent.length, isFirstMessage })
|
||||
|
||||
if (isFirstMessage) {
|
||||
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
|
||||
@@ -164,24 +151,31 @@ export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig
|
||||
output.parts[idx].text = `${hookContent}\n\n${output.parts[idx].text ?? ""}`
|
||||
log("UserPromptSubmit hooks prepended to first message parts directly", { sessionID: input.sessionID })
|
||||
}
|
||||
} else {
|
||||
const message = output.message as {
|
||||
agent?: string
|
||||
model?: { modelID?: string; providerID?: string }
|
||||
path?: { cwd?: string; root?: string }
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
|
||||
const success = injectHookMessage(input.sessionID, hookContent, {
|
||||
agent: message.agent,
|
||||
model: message.model,
|
||||
path: message.path ?? { cwd: ctx.directory, root: "/" },
|
||||
tools: message.tools,
|
||||
})
|
||||
|
||||
log(success ? "Hook message injected via file system" : "File injection failed", {
|
||||
} else if (contextCollector) {
|
||||
log("[DEBUG] Registering hook content to contextCollector", {
|
||||
sessionID: input.sessionID,
|
||||
contentLength: hookContent.length,
|
||||
contentPreview: hookContent.slice(0, 100),
|
||||
})
|
||||
contextCollector.register(input.sessionID, {
|
||||
id: "hook-context",
|
||||
source: "custom",
|
||||
content: hookContent,
|
||||
priority: "high",
|
||||
})
|
||||
|
||||
log("Hook content registered for synthetic message injection", {
|
||||
sessionID: input.sessionID,
|
||||
contentLength: hookContent.length,
|
||||
})
|
||||
} else {
|
||||
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
|
||||
if (idx >= 0) {
|
||||
output.parts[idx].text = `${hookContent}\n\n${output.parts[idx].text ?? ""}`
|
||||
log("Hook content prepended to message (fallback)", {
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -191,6 +185,30 @@ export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { args: Record<string, unknown> }
|
||||
): Promise<void> => {
|
||||
if (input.tool === "todowrite" && typeof output.args.todos === "string") {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(output.args.todos)
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[todowrite ERROR] Failed to parse todos string as JSON. ` +
|
||||
`Received: ${output.args.todos.length > 100 ? output.args.todos.slice(0, 100) + '...' : output.args.todos} ` +
|
||||
`Expected: Valid JSON array. Pass todos as an array, not a string.`
|
||||
)
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(
|
||||
`[todowrite ERROR] Parsed JSON is not an array. ` +
|
||||
`Received type: ${typeof parsed}. ` +
|
||||
`Expected: Array of todo objects. Pass todos as [{id, content, status, priority}, ...].`
|
||||
)
|
||||
}
|
||||
|
||||
output.args.todos = parsed
|
||||
log("todowrite: parsed todos string to array", { sessionID: input.sessionID })
|
||||
}
|
||||
|
||||
const claudeConfig = await loadClaudeHooksConfig()
|
||||
const extendedConfig = await loadPluginExtendedConfig()
|
||||
|
||||
@@ -239,7 +257,7 @@ export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig
|
||||
const cachedInput = getToolInput(input.sessionID, input.tool, input.callID) || {}
|
||||
|
||||
// Use metadata if available and non-empty, otherwise wrap output.output in a structured object
|
||||
// This ensures plugin tools (call_omo_agent, background_task, task) that return strings
|
||||
// This ensures plugin tools (call_omo_agent, sisyphus_task, task) that return strings
|
||||
// get their results properly recorded in transcripts instead of empty {}
|
||||
const metadata = output.metadata as Record<string, unknown> | undefined
|
||||
const hasMetadata = metadata && typeof metadata === "object" && Object.keys(metadata).length > 0
|
||||
|
||||
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, chmodSync, unlinkSync, appendFileSync } from "fs
|
||||
import { join } from "path"
|
||||
import { homedir, tmpdir } from "os"
|
||||
import { createRequire } from "module"
|
||||
import { extractZip } from "../../shared"
|
||||
|
||||
const DEBUG = process.env.COMMENT_CHECKER_DEBUG === "1"
|
||||
const DEBUG_FILE = join(tmpdir(), "comment-checker-debug.log")
|
||||
@@ -95,29 +96,7 @@ async function extractTarGz(archivePath: string, destDir: string): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract zip archive using system commands.
|
||||
*/
|
||||
async function extractZip(archivePath: string, destDir: string): Promise<void> {
|
||||
debugLog("Extracting zip:", archivePath, "to", destDir)
|
||||
|
||||
const proc = process.platform === "win32"
|
||||
? spawn(["powershell", "-command", `Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force`], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
: spawn(["unzip", "-o", archivePath, "-d", destDir], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
const exitCode = await proc.exited
|
||||
|
||||
if (exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
throw new Error(`zip extraction failed (exit ${exitCode}): ${stderr}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Download the comment-checker binary from GitHub Releases.
|
||||
|
||||
@@ -14,6 +14,7 @@ export { createThinkModeHook } from "./think-mode";
|
||||
export { createClaudeCodeHooksHook } from "./claude-code-hooks";
|
||||
export { createRulesInjectorHook } from "./rules-injector";
|
||||
export { createBackgroundNotificationHook } from "./background-notification"
|
||||
export { createBackgroundCompactionHook } from "./background-compaction"
|
||||
export { createAutoUpdateCheckerHook } from "./auto-update-checker";
|
||||
|
||||
export { createAgentUsageReminderHook } from "./agent-usage-reminder";
|
||||
@@ -25,3 +26,7 @@ export { createThinkingBlockValidatorHook } from "./thinking-block-validator";
|
||||
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 { createTaskResumeInfoHook } from "./task-resume-info";
|
||||
export { createStartWorkHook } from "./start-work";
|
||||
export { createSisyphusOrchestratorHook } from "./sisyphus-orchestrator";
|
||||
|
||||
@@ -101,14 +101,14 @@ TELL THE USER WHAT AGENTS YOU WILL LEVERAGE NOW TO SATISFY USER'S REQUEST.
|
||||
|
||||
## EXECUTION RULES
|
||||
- **TODO**: Track EVERY step. Mark complete IMMEDIATELY after each.
|
||||
- **PARALLEL**: Fire independent agent calls simultaneously via background_task - NEVER wait sequentially.
|
||||
- **BACKGROUND FIRST**: Use background_task for exploration/research agents (10+ concurrent if needed).
|
||||
- **PARALLEL**: Fire independent agent calls simultaneously via sisyphus_task(background=true) - NEVER wait sequentially.
|
||||
- **BACKGROUND FIRST**: Use sisyphus_task for exploration/research agents (10+ concurrent if needed).
|
||||
- **VERIFY**: Re-read request after completion. Check ALL requirements met before reporting done.
|
||||
- **DELEGATE**: Don't do everything yourself - orchestrate specialized agents for their strengths.
|
||||
|
||||
## WORKFLOW
|
||||
1. Analyze the request and identify required capabilities
|
||||
2. Spawn exploration/librarian agents via background_task in PARALLEL (10+ if needed)
|
||||
2. Spawn exploration/librarian agents via sisyphus_task(background=true) in PARALLEL (10+ if needed)
|
||||
3. Always Use Plan agent with gathered context to create detailed work breakdown
|
||||
4. Execute with continuous verification against original requirements
|
||||
|
||||
|
||||
@@ -1,7 +1,95 @@
|
||||
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
|
||||
import { createKeywordDetectorHook } from "./index"
|
||||
import { setMainSession } from "../../features/claude-code-session-state"
|
||||
import { ContextCollector } from "../../features/context-injector"
|
||||
import * as sharedModule from "../../shared"
|
||||
import * as sessionState from "../../features/claude-code-session-state"
|
||||
|
||||
describe("keyword-detector registers to ContextCollector", () => {
|
||||
let logCalls: Array<{ msg: string; data?: unknown }>
|
||||
let logSpy: ReturnType<typeof spyOn>
|
||||
let getMainSessionSpy: ReturnType<typeof spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
logCalls = []
|
||||
logSpy = spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => {
|
||||
logCalls.push({ msg, data })
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
logSpy?.mockRestore()
|
||||
getMainSessionSpy?.mockRestore()
|
||||
})
|
||||
|
||||
function createMockPluginInput() {
|
||||
return {
|
||||
client: {
|
||||
tui: {
|
||||
showToast: async () => {},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
}
|
||||
|
||||
test("should register ultrawork keyword to ContextCollector", async () => {
|
||||
// #given - a fresh ContextCollector and keyword-detector hook
|
||||
const collector = new ContextCollector()
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
const sessionID = "test-session-123"
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "ultrawork do something" }],
|
||||
}
|
||||
|
||||
// #when - keyword detection runs
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// #then - ultrawork context should be registered in collector
|
||||
expect(collector.hasPending(sessionID)).toBe(true)
|
||||
const pending = collector.getPending(sessionID)
|
||||
expect(pending.entries.length).toBeGreaterThan(0)
|
||||
expect(pending.entries[0].source).toBe("keyword-detector")
|
||||
expect(pending.entries[0].id).toBe("keyword-ultrawork")
|
||||
})
|
||||
|
||||
test("should register search keyword to ContextCollector", async () => {
|
||||
// #given - mock getMainSessionID to return our session (isolate from global state)
|
||||
const collector = new ContextCollector()
|
||||
const sessionID = "search-test-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "search for the bug" }],
|
||||
}
|
||||
|
||||
// #when - keyword detection runs
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// #then - search context should be registered in collector
|
||||
expect(collector.hasPending(sessionID)).toBe(true)
|
||||
const pending = collector.getPending(sessionID)
|
||||
expect(pending.entries.some((e) => e.id === "keyword-search")).toBe(true)
|
||||
})
|
||||
|
||||
test("should NOT register to collector when no keywords detected", async () => {
|
||||
// #given - no keywords in message
|
||||
const collector = new ContextCollector()
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
const sessionID = "test-session"
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "just a normal message" }],
|
||||
}
|
||||
|
||||
// #when - keyword detection runs
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// #then - nothing should be registered
|
||||
expect(collector.hasPending(sessionID)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("keyword-detector session filtering", () => {
|
||||
let logCalls: Array<{ msg: string; data?: unknown }>
|
||||
@@ -122,6 +210,28 @@ describe("keyword-detector session filtering", () => {
|
||||
expect(output.message.variant).toBe("max")
|
||||
expect(toastCalls).toContain("Ultrawork Mode Activated")
|
||||
})
|
||||
|
||||
test("should not override existing variant", async () => {
|
||||
// #given - main session set with pre-existing variant
|
||||
setMainSession("main-123")
|
||||
|
||||
const toastCalls: string[] = []
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput({ toastCalls }))
|
||||
const output = {
|
||||
message: { variant: "low" } as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "ultrawork mode" }],
|
||||
}
|
||||
|
||||
// #when - ultrawork keyword triggers
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "main-123" },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - existing variant should remain
|
||||
expect(output.message.variant).toBe("low")
|
||||
expect(toastCalls).toContain("Ultrawork Mode Activated")
|
||||
})
|
||||
})
|
||||
|
||||
describe("keyword-detector word boundary", () => {
|
||||
|
||||
@@ -2,12 +2,13 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { detectKeywordsWithType, extractPromptText, removeCodeBlocks } from "./detector"
|
||||
import { log } from "../../shared"
|
||||
import { getMainSessionID } from "../../features/claude-code-session-state"
|
||||
import type { ContextCollector } from "../../features/context-injector"
|
||||
|
||||
export * from "./detector"
|
||||
export * from "./constants"
|
||||
export * from "./types"
|
||||
|
||||
export function createKeywordDetectorHook(ctx: PluginInput) {
|
||||
export function createKeywordDetectorHook(ctx: PluginInput, collector?: ContextCollector) {
|
||||
return {
|
||||
"chat.message": async (
|
||||
input: {
|
||||
@@ -28,8 +29,6 @@ export function createKeywordDetectorHook(ctx: PluginInput) {
|
||||
return
|
||||
}
|
||||
|
||||
// Only ultrawork keywords work in non-main sessions
|
||||
// Other keywords (search, analyze, etc.) only work in main sessions
|
||||
const mainSessionID = getMainSessionID()
|
||||
const isNonMainSession = mainSessionID && input.sessionID !== mainSessionID
|
||||
|
||||
@@ -48,7 +47,9 @@ export function createKeywordDetectorHook(ctx: PluginInput) {
|
||||
if (hasUltrawork) {
|
||||
log(`[keyword-detector] Ultrawork mode activated`, { sessionID: input.sessionID })
|
||||
|
||||
output.message.variant = "max"
|
||||
if (output.message.variant === undefined) {
|
||||
output.message.variant = "max"
|
||||
}
|
||||
|
||||
ctx.client.tui
|
||||
.showToast({
|
||||
@@ -64,6 +65,17 @@ export function createKeywordDetectorHook(ctx: PluginInput) {
|
||||
)
|
||||
}
|
||||
|
||||
if (collector) {
|
||||
for (const keyword of detectedKeywords) {
|
||||
collector.register(input.sessionID, {
|
||||
id: `keyword-${keyword.type}`,
|
||||
source: "keyword-detector",
|
||||
content: keyword.message,
|
||||
priority: keyword.type === "ultrawork" ? "critical" : "high",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
log(`[keyword-detector] Detected ${detectedKeywords.length} keywords`, {
|
||||
sessionID: input.sessionID,
|
||||
types: detectedKeywords.map((k) => k.type),
|
||||
|
||||
@@ -169,9 +169,10 @@ export function createPreemptiveCompactionHook(
|
||||
})
|
||||
}
|
||||
|
||||
const summarizeBody = { providerID, modelID, auto: true }
|
||||
await ctx.client.session.summarize({
|
||||
path: { id: sessionID },
|
||||
body: { providerID, modelID },
|
||||
body: summarizeBody as never,
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
|
||||
@@ -187,22 +188,6 @@ export function createPreemptiveCompactionHook(
|
||||
.catch(() => {})
|
||||
|
||||
state.compactionInProgress.delete(sessionID)
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
const storedMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
|
||||
await ctx.client.session.promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: storedMessage?.agent,
|
||||
parts: [{ type: "text", text: "Continue" }],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
} catch {}
|
||||
}, 500)
|
||||
return
|
||||
} catch (err) {
|
||||
log("[preemptive-compaction] compaction failed", { sessionID, error: err })
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export const HOOK_NAME = "prometheus-md-only"
|
||||
|
||||
export const PROMETHEUS_AGENTS = ["Prometheus (Planner)"]
|
||||
|
||||
export const ALLOWED_EXTENSIONS = [".md"]
|
||||
|
||||
export const ALLOWED_PATH_PREFIX = ".sisyphus"
|
||||
|
||||
export const BLOCKED_TOOLS = ["Write", "Edit", "write", "edit"]
|
||||
|
||||
export const PLANNING_CONSULT_WARNING = `
|
||||
|
||||
---
|
||||
|
||||
[SYSTEM DIRECTIVE - READ-ONLY PLANNING CONSULTATION]
|
||||
|
||||
You are being invoked by Prometheus (Planner), a READ-ONLY planning agent.
|
||||
|
||||
**CRITICAL CONSTRAINTS:**
|
||||
- DO NOT modify any files (no Write, Edit, or any file mutations)
|
||||
- DO NOT execute commands that change system state
|
||||
- DO NOT create, delete, or rename files
|
||||
- ONLY provide analysis, recommendations, and information
|
||||
|
||||
**YOUR ROLE**: Provide consultation, research, and analysis to assist with planning.
|
||||
Return your findings and recommendations. The actual implementation will be handled separately after planning is complete.
|
||||
|
||||
---
|
||||
|
||||
`
|
||||
@@ -0,0 +1,485 @@
|
||||
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { createPrometheusMdOnlyHook } from "./index"
|
||||
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
|
||||
describe("prometheus-md-only", () => {
|
||||
const TEST_SESSION_ID = "test-session-prometheus"
|
||||
let testMessageDir: string
|
||||
|
||||
function createMockPluginInput() {
|
||||
return {
|
||||
client: {},
|
||||
directory: "/tmp/test",
|
||||
} as never
|
||||
}
|
||||
|
||||
function setupMessageStorage(sessionID: string, agent: string): void {
|
||||
testMessageDir = join(MESSAGE_STORAGE, sessionID)
|
||||
mkdirSync(testMessageDir, { recursive: true })
|
||||
const messageContent = {
|
||||
agent,
|
||||
model: { providerID: "test", modelID: "test-model" },
|
||||
}
|
||||
writeFileSync(
|
||||
join(testMessageDir, "msg_001.json"),
|
||||
JSON.stringify(messageContent)
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (testMessageDir) {
|
||||
try {
|
||||
rmSync(testMessageDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe("with Prometheus agent in message storage", () => {
|
||||
beforeEach(() => {
|
||||
setupMessageStorage(TEST_SESSION_ID, "Prometheus (Planner)")
|
||||
})
|
||||
|
||||
test("should block Prometheus from writing non-.md files", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "/path/to/file.ts" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files")
|
||||
})
|
||||
|
||||
test("should allow Prometheus to write .md files inside .sisyphus/", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "/tmp/test/.sisyphus/plans/work-plan.md" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("should block Prometheus from writing .md files outside .sisyphus/", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "/path/to/README.md" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files inside .sisyphus/")
|
||||
})
|
||||
|
||||
test("should block Edit tool for non-.md files", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Edit",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "/path/to/code.py" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files")
|
||||
})
|
||||
|
||||
test("should not affect non-Write/Edit tools", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Read",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "/path/to/file.ts" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("should handle missing filePath gracefully", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: {},
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("should inject read-only warning when Prometheus calls sisyphus_task", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "sisyphus_task",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { prompt: "Analyze this codebase" },
|
||||
}
|
||||
|
||||
// #when
|
||||
await hook["tool.execute.before"](input, output)
|
||||
|
||||
// #then
|
||||
expect(output.args.prompt).toContain("[SYSTEM DIRECTIVE - READ-ONLY PLANNING CONSULTATION]")
|
||||
expect(output.args.prompt).toContain("DO NOT modify any files")
|
||||
})
|
||||
|
||||
test("should inject read-only warning when Prometheus calls task", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "task",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { prompt: "Research this library" },
|
||||
}
|
||||
|
||||
// #when
|
||||
await hook["tool.execute.before"](input, output)
|
||||
|
||||
// #then
|
||||
expect(output.args.prompt).toContain("[SYSTEM DIRECTIVE - READ-ONLY PLANNING CONSULTATION]")
|
||||
})
|
||||
|
||||
test("should inject read-only warning when Prometheus calls call_omo_agent", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "call_omo_agent",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { prompt: "Find implementation examples" },
|
||||
}
|
||||
|
||||
// #when
|
||||
await hook["tool.execute.before"](input, output)
|
||||
|
||||
// #then
|
||||
expect(output.args.prompt).toContain("[SYSTEM DIRECTIVE - READ-ONLY PLANNING CONSULTATION]")
|
||||
})
|
||||
|
||||
test("should not double-inject warning if already present", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "sisyphus_task",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const promptWithWarning = "Some prompt [SYSTEM DIRECTIVE - READ-ONLY PLANNING CONSULTATION] already here"
|
||||
const output = {
|
||||
args: { prompt: promptWithWarning },
|
||||
}
|
||||
|
||||
// #when
|
||||
await hook["tool.execute.before"](input, output)
|
||||
|
||||
// #then
|
||||
const occurrences = (output.args.prompt as string).split("[SYSTEM DIRECTIVE - READ-ONLY PLANNING CONSULTATION]").length - 1
|
||||
expect(occurrences).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("with non-Prometheus agent in message storage", () => {
|
||||
beforeEach(() => {
|
||||
setupMessageStorage(TEST_SESSION_ID, "Sisyphus")
|
||||
})
|
||||
|
||||
test("should not affect non-Prometheus agents", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "/path/to/file.ts" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("should not inject warning for non-Prometheus agents calling sisyphus_task", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "sisyphus_task",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const originalPrompt = "Implement this feature"
|
||||
const output = {
|
||||
args: { prompt: originalPrompt },
|
||||
}
|
||||
|
||||
// #when
|
||||
await hook["tool.execute.before"](input, output)
|
||||
|
||||
// #then
|
||||
expect(output.args.prompt).toBe(originalPrompt)
|
||||
expect(output.args.prompt).not.toContain("[SYSTEM DIRECTIVE - READ-ONLY PLANNING CONSULTATION]")
|
||||
})
|
||||
})
|
||||
|
||||
describe("without message storage", () => {
|
||||
test("should handle missing session gracefully (no agent found)", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: "non-existent-session",
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "/path/to/file.ts" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("cross-platform path validation", () => {
|
||||
beforeEach(() => {
|
||||
setupMessageStorage(TEST_SESSION_ID, "Prometheus (Planner)")
|
||||
})
|
||||
|
||||
test("should allow Windows-style backslash paths under .sisyphus/", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: ".sisyphus\\plans\\work-plan.md" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("should allow mixed separator paths under .sisyphus/", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: ".sisyphus\\plans/work-plan.MD" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("should allow uppercase .MD extension", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: ".sisyphus/plans/work-plan.MD" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("should block paths outside workspace root even if containing .sisyphus", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "/other/project/.sisyphus/plans/x.md" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files inside .sisyphus/")
|
||||
})
|
||||
|
||||
test("should allow nested .sisyphus directories (ctx.directory may be parent)", async () => {
|
||||
// #given - when ctx.directory is parent of actual project, path includes project name
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "src/.sisyphus/plans/x.md" },
|
||||
}
|
||||
|
||||
// #when / #then - should allow because .sisyphus is in path
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("should block path traversal attempts", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: ".sisyphus/../secrets.md" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files inside .sisyphus/")
|
||||
})
|
||||
|
||||
test("should allow case-insensitive .SISYPHUS directory", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: ".SISYPHUS/plans/work-plan.md" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("should allow nested project path with .sisyphus (Windows real-world case)", async () => {
|
||||
// #given - simulates when ctx.directory is parent of actual project
|
||||
// User reported: xauusd-dxy-plan\.sisyphus\drafts\supabase-email-templates.md
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "xauusd-dxy-plan\\.sisyphus\\drafts\\supabase-email-templates.md" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("should allow nested project path with mixed separators", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "my-project/.sisyphus\\plans/task.md" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("should block nested project path without .sisyphus", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "my-project\\src\\code.ts" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { existsSync, readdirSync } from "node:fs"
|
||||
import { join, resolve, relative, isAbsolute } from "node:path"
|
||||
import { HOOK_NAME, PROMETHEUS_AGENTS, ALLOWED_EXTENSIONS, ALLOWED_PATH_PREFIX, BLOCKED_TOOLS, PLANNING_CONSULT_WARNING } from "./constants"
|
||||
import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export * from "./constants"
|
||||
|
||||
/**
|
||||
* Cross-platform path validator for Prometheus file writes.
|
||||
* Uses path.resolve/relative instead of string matching to handle:
|
||||
* - Windows backslashes (e.g., .sisyphus\\plans\\x.md)
|
||||
* - Mixed separators (e.g., .sisyphus\\plans/x.md)
|
||||
* - Case-insensitive directory/extension matching
|
||||
* - Workspace confinement (blocks paths outside root or via traversal)
|
||||
* - Nested project paths (e.g., parent/.sisyphus/... when ctx.directory is parent)
|
||||
*/
|
||||
function isAllowedFile(filePath: string, workspaceRoot: string): boolean {
|
||||
// 1. Resolve to absolute path
|
||||
const resolved = resolve(workspaceRoot, filePath)
|
||||
|
||||
// 2. Get relative path from workspace root
|
||||
const rel = relative(workspaceRoot, resolved)
|
||||
|
||||
// 3. Reject if escapes root (starts with ".." or is absolute)
|
||||
if (rel.startsWith("..") || isAbsolute(rel)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 4. Check if .sisyphus/ or .sisyphus\ exists anywhere in the path (case-insensitive)
|
||||
// This handles both direct paths (.sisyphus/x.md) and nested paths (project/.sisyphus/x.md)
|
||||
if (!/\.sisyphus[/\\]/i.test(rel)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 5. Check extension matches one of ALLOWED_EXTENSIONS (case-insensitive)
|
||||
const hasAllowedExtension = ALLOWED_EXTENSIONS.some(
|
||||
ext => resolved.toLowerCase().endsWith(ext.toLowerCase())
|
||||
)
|
||||
if (!hasAllowedExtension) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const TASK_TOOLS = ["sisyphus_task", "task", "call_omo_agent"]
|
||||
|
||||
function getAgentFromMessageFiles(sessionID: string): string | undefined {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
if (!messageDir) return undefined
|
||||
return findFirstMessageWithAgent(messageDir) ?? findNearestMessageWithFields(messageDir)?.agent
|
||||
}
|
||||
|
||||
function getAgentFromSession(sessionID: string): string | undefined {
|
||||
return getSessionAgent(sessionID) ?? getAgentFromMessageFiles(sessionID)
|
||||
}
|
||||
|
||||
export function createPrometheusMdOnlyHook(ctx: PluginInput) {
|
||||
return {
|
||||
"tool.execute.before": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { args: Record<string, unknown>; message?: string }
|
||||
): Promise<void> => {
|
||||
const agentName = getAgentFromSession(input.sessionID)
|
||||
|
||||
if (!agentName || !PROMETHEUS_AGENTS.includes(agentName)) {
|
||||
return
|
||||
}
|
||||
|
||||
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 - READ-ONLY PLANNING CONSULTATION]")) {
|
||||
output.args.prompt = prompt + PLANNING_CONSULT_WARNING
|
||||
log(`[${HOOK_NAME}] Injected read-only planning warning to ${toolName}`, {
|
||||
sessionID: input.sessionID,
|
||||
tool: toolName,
|
||||
agent: agentName,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!BLOCKED_TOOLS.includes(toolName)) {
|
||||
return
|
||||
}
|
||||
|
||||
const filePath = (output.args.filePath ?? output.args.path ?? output.args.file) as string | undefined
|
||||
if (!filePath) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isAllowedFile(filePath, ctx.directory)) {
|
||||
log(`[${HOOK_NAME}] Blocked: Prometheus can only write to .sisyphus/*.md`, {
|
||||
sessionID: input.sessionID,
|
||||
tool: toolName,
|
||||
filePath,
|
||||
agent: agentName,
|
||||
})
|
||||
throw new Error(
|
||||
`[${HOOK_NAME}] Prometheus (Planner) can only write/edit .md files inside .sisyphus/ directory. ` +
|
||||
`Attempted to modify: ${filePath}. ` +
|
||||
`Prometheus is a READ-ONLY planner. Use /start-work to execute the plan.`
|
||||
)
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Allowed: .sisyphus/*.md write permitted`, {
|
||||
sessionID: input.sessionID,
|
||||
tool: toolName,
|
||||
filePath,
|
||||
agent: agentName,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -591,6 +591,73 @@ describe("ralph-loop", () => {
|
||||
expect(hook.getState()).toBeNull()
|
||||
})
|
||||
|
||||
test("should allow starting new loop while previous loop is active (different session)", async () => {
|
||||
// #given - active loop in session A
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-A", "First task", { maxIterations: 10 })
|
||||
expect(hook.getState()?.session_id).toBe("session-A")
|
||||
expect(hook.getState()?.prompt).toBe("First task")
|
||||
|
||||
// #when - start new loop in session B (without completing A)
|
||||
hook.startLoop("session-B", "Second task", { maxIterations: 20 })
|
||||
|
||||
// #then - state should be overwritten with session B's loop
|
||||
expect(hook.getState()?.session_id).toBe("session-B")
|
||||
expect(hook.getState()?.prompt).toBe("Second task")
|
||||
expect(hook.getState()?.max_iterations).toBe(20)
|
||||
expect(hook.getState()?.iteration).toBe(1)
|
||||
|
||||
// #when - session B goes idle
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-B" } },
|
||||
})
|
||||
|
||||
// #then - continuation should be injected for session B
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(promptCalls[0].sessionID).toBe("session-B")
|
||||
expect(promptCalls[0].text).toContain("Second task")
|
||||
expect(promptCalls[0].text).toContain("2/20")
|
||||
|
||||
// #then - iteration incremented
|
||||
expect(hook.getState()?.iteration).toBe(2)
|
||||
})
|
||||
|
||||
test("should allow starting new loop in same session (restart)", async () => {
|
||||
// #given - active loop in session A at iteration 5
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-A", "First task", { maxIterations: 10 })
|
||||
|
||||
// Simulate some iterations
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-A" } },
|
||||
})
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-A" } },
|
||||
})
|
||||
expect(hook.getState()?.iteration).toBe(3)
|
||||
expect(promptCalls.length).toBe(2)
|
||||
|
||||
// #when - start NEW loop in same session (restart)
|
||||
hook.startLoop("session-A", "Restarted task", { maxIterations: 50 })
|
||||
|
||||
// #then - state should be reset to iteration 1 with new prompt
|
||||
expect(hook.getState()?.session_id).toBe("session-A")
|
||||
expect(hook.getState()?.prompt).toBe("Restarted task")
|
||||
expect(hook.getState()?.max_iterations).toBe(50)
|
||||
expect(hook.getState()?.iteration).toBe(1)
|
||||
|
||||
// #when - session goes idle
|
||||
promptCalls = [] // Reset to check new continuation
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-A" } },
|
||||
})
|
||||
|
||||
// #then - continuation should use new task
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(promptCalls[0].text).toContain("Restarted task")
|
||||
expect(promptCalls[0].text).toContain("2/50")
|
||||
})
|
||||
|
||||
test("should check transcript BEFORE API to optimize performance", async () => {
|
||||
// #given - transcript has completion promise
|
||||
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { log } from "../../shared/logger"
|
||||
import { readState, writeState, clearState, incrementIteration } from "./storage"
|
||||
import {
|
||||
@@ -9,6 +10,18 @@ import {
|
||||
} from "./constants"
|
||||
import type { RalphLoopState, RalphLoopOptions } from "./types"
|
||||
import { getTranscriptPath as getDefaultTranscriptPath } from "../claude-code-hooks/transcript"
|
||||
import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
|
||||
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 * from "./types"
|
||||
export * from "./constants"
|
||||
@@ -302,9 +315,18 @@ export function createRalphLoopHook(
|
||||
.catch(() => {})
|
||||
|
||||
try {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
const agent = currentMessage?.agent
|
||||
const 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 !== undefined ? { agent } : {}),
|
||||
...(model !== undefined ? { model } : {}),
|
||||
parts: [{ type: "text", text: continuationPrompt }],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
|
||||
@@ -0,0 +1,913 @@
|
||||
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("sisyphus-orchestrator hook", () => {
|
||||
const TEST_DIR = join(tmpdir(), "sisyphus-orchestrator-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-sisyphus_task tools", async () => {
|
||||
// #given - hook and non-sisyphus_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 orchestrator-sisyphus", async () => {
|
||||
// #given - boulder state exists but caller agent in message storage is not orchestrator
|
||||
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: "sisyphus_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 orchestrator", async () => {
|
||||
// #given - no boulder state, but caller is orchestrator
|
||||
const sessionID = "session-no-boulder-test"
|
||||
setupMessageStorage(sessionID, "orchestrator-sisyphus")
|
||||
|
||||
const hook = createSisyphusOrchestratorHook(createMockPluginInput())
|
||||
const output = {
|
||||
title: "Sisyphus Task",
|
||||
output: "Task completed successfully",
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
// #when
|
||||
await hook["tool.execute.after"](
|
||||
{ tool: "sisyphus_task", sessionID },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - standalone verification reminder appended
|
||||
expect(output.output).toContain("Task completed successfully")
|
||||
expect(output.output).toContain("MANDATORY VERIFICATION")
|
||||
expect(output.output).toContain("sisyphus_task(resume=")
|
||||
|
||||
cleanupMessageStorage(sessionID)
|
||||
})
|
||||
|
||||
test("should transform output when caller is orchestrator-sisyphus with boulder state", async () => {
|
||||
// #given - orchestrator-sisyphus caller with boulder state
|
||||
const sessionID = "session-transform-test"
|
||||
setupMessageStorage(sessionID, "orchestrator-sisyphus")
|
||||
|
||||
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: "sisyphus_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("SUBAGENTS LIE")
|
||||
expect(output.output).toContain("sisyphus_task(resume=")
|
||||
|
||||
cleanupMessageStorage(sessionID)
|
||||
})
|
||||
|
||||
test("should still transform when plan is complete (shows progress)", async () => {
|
||||
// #given - boulder state with complete plan, orchestrator caller
|
||||
const sessionID = "session-complete-plan-test"
|
||||
setupMessageStorage(sessionID, "orchestrator-sisyphus")
|
||||
|
||||
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: "sisyphus_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 left")
|
||||
|
||||
cleanupMessageStorage(sessionID)
|
||||
})
|
||||
|
||||
test("should append session ID to boulder state if not present", async () => {
|
||||
// #given - boulder state without session-append-test, orchestrator caller
|
||||
const sessionID = "session-append-test"
|
||||
setupMessageStorage(sessionID, "orchestrator-sisyphus")
|
||||
|
||||
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: "sisyphus_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, orchestrator caller
|
||||
const sessionID = "session-dup-test"
|
||||
setupMessageStorage(sessionID, "orchestrator-sisyphus")
|
||||
|
||||
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: "sisyphus_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, orchestrator caller
|
||||
const sessionID = "session-path-test"
|
||||
setupMessageStorage(sessionID, "orchestrator-sisyphus")
|
||||
|
||||
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: "sisyphus_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 left")
|
||||
|
||||
cleanupMessageStorage(sessionID)
|
||||
})
|
||||
|
||||
test("should include resume and checkbox instructions in reminder", async () => {
|
||||
// #given - boulder state, orchestrator caller
|
||||
const sessionID = "session-resume-test"
|
||||
setupMessageStorage(sessionID, "orchestrator-sisyphus")
|
||||
|
||||
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: "sisyphus_task", sessionID },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - should include resume instructions and verification
|
||||
expect(output.output).toContain("sisyphus_task(resume=")
|
||||
expect(output.output).toContain("[x]")
|
||||
expect(output.output).toContain("MANDATORY VERIFICATION")
|
||||
|
||||
cleanupMessageStorage(sessionID)
|
||||
})
|
||||
|
||||
describe("Write/Edit tool direct work reminder", () => {
|
||||
const ORCHESTRATOR_SESSION = "orchestrator-write-test"
|
||||
|
||||
beforeEach(() => {
|
||||
setupMessageStorage(ORCHESTRATOR_SESSION, "orchestrator-sisyphus")
|
||||
})
|
||||
|
||||
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("sisyphus_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, "orchestrator-sisyphus")
|
||||
})
|
||||
|
||||
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 orchestrator-sisyphus", async () => {
|
||||
// #given - boulder state with incomplete plan, but last agent is NOT orchestrator-sisyphus
|
||||
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 orchestrator-sisyphus
|
||||
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 orchestrator-sisyphus
|
||||
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,684 @@
|
||||
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 type { BackgroundManager } from "../../features/background-agent"
|
||||
|
||||
export const HOOK_NAME = "sisyphus-orchestrator"
|
||||
|
||||
/**
|
||||
* 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 = `
|
||||
|
||||
---
|
||||
|
||||
[SYSTEM REMINDER - 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 \`sisyphus_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 \`sisyphus_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 = `[SYSTEM REMINDER - 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 VERIFICATION - SUBAGENTS LIE**
|
||||
|
||||
Subagents FREQUENTLY claim completion when:
|
||||
- Tests are actually FAILING
|
||||
- Code has type/lint ERRORS
|
||||
- Implementation is INCOMPLETE
|
||||
- Patterns were NOT followed
|
||||
|
||||
**YOU MUST VERIFY EVERYTHING YOURSELF:**
|
||||
|
||||
1. Run \`lsp_diagnostics\` on changed files - Must be CLEAN
|
||||
2. Run tests yourself - Must PASS (not "agent said it passed")
|
||||
3. Read the actual code - Must match requirements
|
||||
4. Check build/typecheck - Must succeed
|
||||
|
||||
DO NOT TRUST THE AGENT'S SELF-REPORT.
|
||||
VERIFY EACH CLAIM WITH YOUR OWN TOOL CALLS.
|
||||
|
||||
**HANDS-ON QA REQUIRED (after ALL tasks complete):**
|
||||
|
||||
| Deliverable Type | Verification Tool | Action |
|
||||
|------------------|-------------------|--------|
|
||||
| **Frontend/UI** | \`/playwright\` skill | Navigate, interact, screenshot evidence |
|
||||
| **TUI/CLI** | \`interactive_bash\` (tmux) | Run interactively, verify output |
|
||||
| **API/Backend** | \`bash\` with curl | Send requests, verify responses |
|
||||
|
||||
Static analysis CANNOT catch: visual bugs, animation issues, user flow breakages, integration problems.
|
||||
**FAILURE TO DO HANDS-ON QA = INCOMPLETE WORK.**`
|
||||
|
||||
const ORCHESTRATOR_DELEGATION_REQUIRED = `
|
||||
|
||||
---
|
||||
|
||||
⚠️⚠️⚠️ [CRITICAL SYSTEM DIRECTIVE - DELEGATION REQUIRED] ⚠️⚠️⚠️
|
||||
|
||||
**STOP. YOU ARE VIOLATING ORCHESTRATOR PROTOCOL.**
|
||||
|
||||
You (orchestrator-sisyphus) 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 \`sisyphus_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 \`sisyphus_task\`.
|
||||
|
||||
**CORRECT APPROACH:**
|
||||
\`\`\`
|
||||
sisyphus_task(
|
||||
category="...",
|
||||
prompt="[specific single task with clear acceptance criteria]"
|
||||
)
|
||||
\`\`\`
|
||||
|
||||
⚠️⚠️⚠️ DELEGATE. DON'T IMPLEMENT. ⚠️⚠️⚠️
|
||||
|
||||
---
|
||||
`
|
||||
|
||||
const SINGLE_TASK_DIRECTIVE = `
|
||||
|
||||
[SYSTEM DIRECTIVE - 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:**
|
||||
\`\`\`
|
||||
sisyphus_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 `
|
||||
---
|
||||
|
||||
**State:** Plan: ${planName} | ${progress.completed}/${progress.total} done, ${remaining} left
|
||||
|
||||
---
|
||||
|
||||
${buildVerificationReminder(sessionId)}
|
||||
|
||||
ALL pass? → commit atomic unit, mark \`[x]\`, next task.`
|
||||
}
|
||||
|
||||
function buildStandaloneVerificationReminder(sessionId: string): string {
|
||||
return `
|
||||
---
|
||||
|
||||
${buildVerificationReminder(sessionId)}`
|
||||
}
|
||||
|
||||
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 === "orchestrator-sisyphus"
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
lastEventWasAbortError?: boolean
|
||||
}
|
||||
|
||||
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 })
|
||||
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
const 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: "orchestrator-sisyphus",
|
||||
...(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 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 sisyphus_task - inject single-task directive
|
||||
if (input.tool === "sisyphus_task") {
|
||||
const prompt = output.args.prompt as string | undefined
|
||||
if (prompt && !prompt.includes("[SYSTEM DIRECTIVE - SINGLE TASK ONLY]")) {
|
||||
output.args.prompt = prompt + `\n<system-reminder>${SINGLE_TASK_DIRECTIVE}</system-reminder>`
|
||||
log(`[${HOOK_NAME}] Injected single-task directive to sisyphus_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 !== "sisyphus_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,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
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/)
|
||||
})
|
||||
|
||||
test("should auto-select when only one incomplete plan among multiple plans", async () => {
|
||||
// #given - multiple plans but only one incomplete
|
||||
const plansDir = join(TEST_DIR, ".sisyphus", "plans")
|
||||
mkdirSync(plansDir, { recursive: true })
|
||||
|
||||
// Plan 1: complete (all checked)
|
||||
const plan1Path = join(plansDir, "plan-complete.md")
|
||||
writeFileSync(plan1Path, "# Plan Complete\n- [x] Task 1\n- [x] Task 2")
|
||||
|
||||
// Plan 2: incomplete (has unchecked)
|
||||
const plan2Path = join(plansDir, "plan-incomplete.md")
|
||||
writeFileSync(plan2Path, "# Plan Incomplete\n- [ ] Task 1\n- [x] Task 2")
|
||||
|
||||
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 auto-select the incomplete plan, not ask user
|
||||
expect(output.parts[0].text).toContain("Auto-Selected Plan")
|
||||
expect(output.parts[0].text).toContain("plan-incomplete")
|
||||
expect(output.parts[0].text).not.toContain("Multiple Plans Found")
|
||||
})
|
||||
|
||||
test("should wrap multiple plans message in system-reminder tag", async () => {
|
||||
// #given - multiple incomplete plans
|
||||
const plansDir = join(TEST_DIR, ".sisyphus", "plans")
|
||||
mkdirSync(plansDir, { recursive: true })
|
||||
|
||||
const plan1Path = join(plansDir, "plan-a.md")
|
||||
writeFileSync(plan1Path, "# Plan A\n- [ ] Task 1")
|
||||
|
||||
const plan2Path = join(plansDir, "plan-b.md")
|
||||
writeFileSync(plan2Path, "# Plan B\n- [ ] Task 2")
|
||||
|
||||
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 use system-reminder tag format
|
||||
expect(output.parts[0].text).toContain("<system-reminder>")
|
||||
expect(output.parts[0].text).toContain("</system-reminder>")
|
||||
expect(output.parts[0].text).toContain("Multiple Plans Found")
|
||||
})
|
||||
|
||||
test("should use 'ask user' prompt style for multiple plans", async () => {
|
||||
// #given - multiple incomplete plans
|
||||
const plansDir = join(TEST_DIR, ".sisyphus", "plans")
|
||||
mkdirSync(plansDir, { recursive: true })
|
||||
|
||||
const plan1Path = join(plansDir, "plan-x.md")
|
||||
writeFileSync(plan1Path, "# Plan X\n- [ ] Task 1")
|
||||
|
||||
const plan2Path = join(plansDir, "plan-y.md")
|
||||
writeFileSync(plan2Path, "# Plan Y\n- [ ] Task 2")
|
||||
|
||||
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 prompt agent to ask user, not ask directly
|
||||
expect(output.parts[0].text).toContain("Ask the user")
|
||||
expect(output.parts[0].text).not.toContain("Which plan would you like to work on?")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
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)
|
||||
const incompletePlans = plans.filter(p => !getPlanProgress(p).isComplete)
|
||||
|
||||
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 (incompletePlans.length === 0) {
|
||||
contextInfo += `
|
||||
|
||||
## All Plans Complete
|
||||
|
||||
All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your task"`
|
||||
} else if (incompletePlans.length === 1) {
|
||||
const planPath = incompletePlans[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 = incompletePlans.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 += `
|
||||
|
||||
<system-reminder>
|
||||
## Multiple Plans Found
|
||||
|
||||
Current Time: ${timestamp}
|
||||
Session ID: ${sessionId}
|
||||
|
||||
${planList}
|
||||
|
||||
Ask the user which plan to work on. Present the options above and wait for their response.
|
||||
</system-reminder>`
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
const TARGET_TOOLS = ["task", "Task", "call_omo_agent", "sisyphus_task"]
|
||||
|
||||
const SESSION_ID_PATTERNS = [
|
||||
/Session ID: (ses_[a-zA-Z0-9_-]+)/,
|
||||
/session_id: (ses_[a-zA-Z0-9_-]+)/,
|
||||
/<task_metadata>\s*session_id: (ses_[a-zA-Z0-9_-]+)/,
|
||||
/sessionId: (ses_[a-zA-Z0-9_-]+)/,
|
||||
]
|
||||
|
||||
function extractSessionId(output: string): string | null {
|
||||
for (const pattern of SESSION_ID_PATTERNS) {
|
||||
const match = output.match(pattern)
|
||||
if (match) return match[1]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function createTaskResumeInfoHook() {
|
||||
const toolExecuteAfter = async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
) => {
|
||||
if (!TARGET_TOOLS.includes(input.tool)) return
|
||||
if (output.output.startsWith("Error:") || output.output.startsWith("Failed")) return
|
||||
if (output.output.includes("\nto resume:")) return
|
||||
|
||||
const sessionId = extractSessionId(output.output)
|
||||
if (!sessionId) return
|
||||
|
||||
output.output = output.output.trimEnd() + `\n\nto resume: sisyphus_task(resume="${sessionId}", prompt="...")`
|
||||
}
|
||||
|
||||
return {
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
}
|
||||
}
|
||||
@@ -349,6 +349,25 @@ describe("todo-continuation-enforcer", () => {
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should accept skipAgents option without error", async () => {
|
||||
// #given - session with skipAgents configured for Prometheus
|
||||
const sessionID = "main-prometheus-option"
|
||||
setMainSession(sessionID)
|
||||
|
||||
// #when - create hook with skipAgents option (should not throw)
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
|
||||
skipAgents: ["Prometheus (Planner)", "custom-agent"],
|
||||
})
|
||||
|
||||
// #then - handler works without error
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
expect(toastCalls.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test("should show countdown toast updates", async () => {
|
||||
// #given - session with incomplete todos
|
||||
const sessionID = "main-toast"
|
||||
@@ -529,4 +548,263 @@ describe("todo-continuation-enforcer", () => {
|
||||
// #then - no continuation (abort error detected)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should skip injection when abort detected via session.error event (event-based, primary)", async () => {
|
||||
// #given - session with incomplete todos
|
||||
const sessionID = "main-event-abort"
|
||||
setMainSession(sessionID)
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant" } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - abort error event fires
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID, error: { name: "MessageAbortedError" } },
|
||||
},
|
||||
})
|
||||
|
||||
// #when - session goes idle immediately after
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation (abort detected via event)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should skip injection when AbortError detected via session.error event", async () => {
|
||||
// #given - session with incomplete todos
|
||||
const sessionID = "main-event-abort-dom"
|
||||
setMainSession(sessionID)
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant" } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - AbortError event fires
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID, error: { name: "AbortError" } },
|
||||
},
|
||||
})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation (abort detected via event)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should inject when abort flag is stale (>3s old)", async () => {
|
||||
// #given - session with incomplete todos and old abort timestamp
|
||||
const sessionID = "main-stale-abort"
|
||||
setMainSession(sessionID)
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant" } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - abort error fires
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID, error: { name: "MessageAbortedError" } },
|
||||
},
|
||||
})
|
||||
|
||||
// #when - wait >3s then idle fires
|
||||
await new Promise(r => setTimeout(r, 3100))
|
||||
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - continuation injected (abort flag is stale)
|
||||
expect(promptCalls.length).toBeGreaterThan(0)
|
||||
}, 10000)
|
||||
|
||||
test("should clear abort flag on user message activity", async () => {
|
||||
// #given - session with abort detected
|
||||
const sessionID = "main-clear-on-user"
|
||||
setMainSession(sessionID)
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant" } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - abort error fires
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID, error: { name: "MessageAbortedError" } },
|
||||
},
|
||||
})
|
||||
|
||||
// #when - user sends new message (clears abort flag)
|
||||
await new Promise(r => setTimeout(r, 600))
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: { info: { sessionID, role: "user" } },
|
||||
},
|
||||
})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - continuation injected (abort flag was cleared by user activity)
|
||||
expect(promptCalls.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("should clear abort flag on assistant message activity", async () => {
|
||||
// #given - session with abort detected
|
||||
const sessionID = "main-clear-on-assistant"
|
||||
setMainSession(sessionID)
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant" } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - abort error fires
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID, error: { name: "MessageAbortedError" } },
|
||||
},
|
||||
})
|
||||
|
||||
// #when - assistant starts responding (clears abort flag)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: { info: { sessionID, role: "assistant" } },
|
||||
},
|
||||
})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - continuation injected (abort flag was cleared by assistant activity)
|
||||
expect(promptCalls.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("should clear abort flag on tool execution", async () => {
|
||||
// #given - session with abort detected
|
||||
const sessionID = "main-clear-on-tool"
|
||||
setMainSession(sessionID)
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant" } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - abort error fires
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID, error: { name: "MessageAbortedError" } },
|
||||
},
|
||||
})
|
||||
|
||||
// #when - tool executes (clears abort flag)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "tool.execute.before",
|
||||
properties: { sessionID },
|
||||
},
|
||||
})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - continuation injected (abort flag was cleared by tool execution)
|
||||
expect(promptCalls.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("should use event-based detection even when API indicates no abort (event wins)", async () => {
|
||||
// #given - session with abort event but API shows no error
|
||||
const sessionID = "main-event-wins"
|
||||
setMainSession(sessionID)
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant" } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - abort error event fires (but API doesn't have it yet)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID, error: { name: "MessageAbortedError" } },
|
||||
},
|
||||
})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation (event-based detection wins over API)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should use API fallback when event is missed but API shows abort", async () => {
|
||||
// #given - session where event was missed but API shows abort
|
||||
const sessionID = "main-api-fallback"
|
||||
setMainSession(sessionID)
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant", error: { name: "MessageAbortedError" } } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - session goes idle without prior session.error event
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation (API fallback detected the abort)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,8 +11,11 @@ import { log } from "../shared/logger"
|
||||
|
||||
const HOOK_NAME = "todo-continuation-enforcer"
|
||||
|
||||
const DEFAULT_SKIP_AGENTS = ["Prometheus (Planner)"]
|
||||
|
||||
export interface TodoContinuationEnforcerOptions {
|
||||
backgroundManager?: BackgroundManager
|
||||
skipAgents?: string[]
|
||||
}
|
||||
|
||||
export interface TodoContinuationEnforcer {
|
||||
@@ -33,6 +36,7 @@ interface SessionState {
|
||||
countdownInterval?: ReturnType<typeof setInterval>
|
||||
isRecovering?: boolean
|
||||
countdownStartedAt?: number
|
||||
abortDetectedAt?: number
|
||||
}
|
||||
|
||||
const CONTINUATION_PROMPT = `[SYSTEM REMINDER - TODO CONTINUATION]
|
||||
@@ -89,7 +93,7 @@ export function createTodoContinuationEnforcer(
|
||||
ctx: PluginInput,
|
||||
options: TodoContinuationEnforcerOptions = {}
|
||||
): TodoContinuationEnforcer {
|
||||
const { backgroundManager } = options
|
||||
const { backgroundManager, skipAgents = DEFAULT_SKIP_AGENTS } = options
|
||||
const sessions = new Map<string, SessionState>()
|
||||
|
||||
function getState(sessionID: string): SessionState {
|
||||
@@ -184,34 +188,32 @@ export function createTodoContinuationEnforcer(
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
|
||||
const hasWritePermission = !prevMessage?.tools ||
|
||||
(prevMessage.tools.write !== false && prevMessage.tools.edit !== false)
|
||||
const agentName = prevMessage?.agent
|
||||
if (agentName && skipAgents.includes(agentName)) {
|
||||
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName })
|
||||
return
|
||||
}
|
||||
|
||||
const editPermission = prevMessage?.tools?.edit
|
||||
const writePermission = prevMessage?.tools?.write
|
||||
const hasWritePermission = !prevMessage?.tools ||
|
||||
((editPermission !== false && editPermission !== "deny") &&
|
||||
(writePermission !== false && writePermission !== "deny"))
|
||||
if (!hasWritePermission) {
|
||||
log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: prevMessage?.agent })
|
||||
return
|
||||
}
|
||||
|
||||
const agentName = prevMessage?.agent?.toLowerCase() ?? ""
|
||||
if (agentName === "plan" || agentName === "planner-sisyphus") {
|
||||
log(`[${HOOK_NAME}] Skipped: plan mode agent`, { sessionID, agent: prevMessage?.agent })
|
||||
return
|
||||
}
|
||||
|
||||
const prompt = `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - freshIncompleteCount}/${todos.length} completed, ${freshIncompleteCount} remaining]`
|
||||
|
||||
const modelField = prevMessage?.model?.providerID && prevMessage?.model?.modelID
|
||||
? { providerID: prevMessage.model.providerID, modelID: prevMessage.model.modelID }
|
||||
: undefined
|
||||
|
||||
try {
|
||||
log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: prevMessage?.agent, model: modelField, incompleteCount: freshIncompleteCount })
|
||||
log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: prevMessage?.agent, incompleteCount: freshIncompleteCount })
|
||||
|
||||
// Don't pass model - let OpenCode use session's existing lastModel
|
||||
await ctx.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: prevMessage?.agent,
|
||||
model: modelField,
|
||||
parts: [{ type: "text", text: prompt }],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
@@ -253,6 +255,13 @@ export function createTodoContinuationEnforcer(
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
const error = props?.error as { name?: string } | undefined
|
||||
if (error?.name === "MessageAbortedError" || error?.name === "AbortError") {
|
||||
const state = getState(sessionID)
|
||||
state.abortDetectedAt = Date.now()
|
||||
log(`[${HOOK_NAME}] Abort detected via session.error`, { sessionID, errorName: error.name })
|
||||
}
|
||||
|
||||
cancelCountdown(sessionID)
|
||||
log(`[${HOOK_NAME}] session.error`, { sessionID })
|
||||
return
|
||||
@@ -280,6 +289,18 @@ export function createTodoContinuationEnforcer(
|
||||
return
|
||||
}
|
||||
|
||||
// Check 1: Event-based abort detection (primary, most reliable)
|
||||
if (state.abortDetectedAt) {
|
||||
const timeSinceAbort = Date.now() - state.abortDetectedAt
|
||||
const ABORT_WINDOW_MS = 3000
|
||||
if (timeSinceAbort < ABORT_WINDOW_MS) {
|
||||
log(`[${HOOK_NAME}] Skipped: abort detected via event ${timeSinceAbort}ms ago`, { sessionID })
|
||||
state.abortDetectedAt = undefined
|
||||
return
|
||||
}
|
||||
state.abortDetectedAt = undefined
|
||||
}
|
||||
|
||||
const hasRunningBgTasks = backgroundManager
|
||||
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
|
||||
: false
|
||||
@@ -289,6 +310,7 @@ export function createTodoContinuationEnforcer(
|
||||
return
|
||||
}
|
||||
|
||||
// Check 2: API-based abort detection (fallback, for cases where event was missed)
|
||||
try {
|
||||
const messagesResp = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
@@ -297,7 +319,7 @@ export function createTodoContinuationEnforcer(
|
||||
const messages = (messagesResp as { data?: Array<{ info?: MessageInfo }> }).data ?? []
|
||||
|
||||
if (isLastAssistantMessageAborted(messages)) {
|
||||
log(`[${HOOK_NAME}] Skipped: last assistant message was aborted`, { sessionID })
|
||||
log(`[${HOOK_NAME}] Skipped: last assistant message was aborted (API fallback)`, { sessionID })
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -324,6 +346,28 @@ export function createTodoContinuationEnforcer(
|
||||
return
|
||||
}
|
||||
|
||||
let agentName: string | undefined
|
||||
try {
|
||||
const messagesResp = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
const messages = (messagesResp.data ?? []) as Array<{ info?: { agent?: string } }>
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].info?.agent) {
|
||||
agentName = messages[i].info?.agent
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Failed to fetch messages for agent check`, { sessionID, error: String(err) })
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName, skipAgents })
|
||||
if (agentName && skipAgents.includes(agentName)) {
|
||||
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName })
|
||||
return
|
||||
}
|
||||
|
||||
startCountdown(sessionID, incompleteCount, todos.length)
|
||||
return
|
||||
}
|
||||
@@ -344,10 +388,13 @@ export function createTodoContinuationEnforcer(
|
||||
return
|
||||
}
|
||||
}
|
||||
if (state) state.abortDetectedAt = undefined
|
||||
cancelCountdown(sessionID)
|
||||
}
|
||||
|
||||
if (role === "assistant") {
|
||||
const state = sessions.get(sessionID)
|
||||
if (state) state.abortDetectedAt = undefined
|
||||
cancelCountdown(sessionID)
|
||||
}
|
||||
return
|
||||
@@ -359,6 +406,8 @@ export function createTodoContinuationEnforcer(
|
||||
const role = info?.role as string | undefined
|
||||
|
||||
if (sessionID && role === "assistant") {
|
||||
const state = sessions.get(sessionID)
|
||||
if (state) state.abortDetectedAt = undefined
|
||||
cancelCountdown(sessionID)
|
||||
}
|
||||
return
|
||||
@@ -367,6 +416,8 @@ export function createTodoContinuationEnforcer(
|
||||
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.abortDetectedAt = undefined
|
||||
cancelCountdown(sessionID)
|
||||
}
|
||||
return
|
||||
|
||||
@@ -13,8 +13,7 @@ const TRUNCATABLE_TOOLS = [
|
||||
"Glob",
|
||||
"safe_glob",
|
||||
"lsp_find_references",
|
||||
"lsp_document_symbols",
|
||||
"lsp_workspace_symbols",
|
||||
"lsp_symbols",
|
||||
"lsp_diagnostics",
|
||||
"ast_grep_search",
|
||||
"interactive_bash",
|
||||
|
||||
Reference in New Issue
Block a user