THE ORCHESTRATOR (#600)

* feat(background-agent): add ConcurrencyManager for model-based limits

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* fix(background-agent): set default concurrency to 5

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(background-agent): support 0 as unlimited concurrency

Setting concurrency to 0 means unlimited (Infinity).
Works for defaultConcurrency, providerConcurrency, and modelConcurrency.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(hooks): use auto flag for session resumption after compaction

- executor.ts: Added `auto: true` to summarize body, removed subsequent prompt_async call
- preemptive-compaction/index.ts: Added `auto: true` to summarize body, removed subsequent promptAsync call
- executor.test.ts: Updated test expectation to include `auto: true`

Instead of sending 'Continue' prompt after compaction, use SessionCompaction's `auto: true` feature which auto-resumes the session.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* refactor(agents): update sisyphus orchestrator

Update Sisyphus agent orchestrator with latest changes.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* refactor(features): update background agent manager

Update background agent manager with latest configuration changes.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* refactor(features): update init-deep template

Update initialization template with latest configuration.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* refactor(hooks): update hook constants and configuration

Update hook constants and configuration across agent-usage-reminder, keyword-detector, and claude-code-hooks.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* refactor(tools): remove background-task tool

Remove background-task tool module completely:
- src/tools/background-task/constants.ts
- src/tools/background-task/index.ts
- src/tools/background-task/tools.ts
- src/tools/background-task/types.ts

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* refactor(tools): update tool exports and main plugin entry

Update tool index exports and main plugin entry point after background-task tool removal.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(auth): update constants to match CLIProxyAPI (50min buffer, 2 endpoints)

- Changed ANTIGRAVITY_TOKEN_REFRESH_BUFFER_MS from 60,000ms (1min) to 3,000,000ms (50min)
- Removed autopush endpoint from ANTIGRAVITY_ENDPOINT_FALLBACKS (now 2 endpoints: daily → prod)
- Added comprehensive test suite with 6 tests covering all updated constants
- Updated comments to reflect CLIProxyAPI parity

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(auth): remove PKCE to match CLIProxyAPI

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* feat(auth): implement port 51121 with OS fallback

Add port fallback logic to OAuth callback server:
- Try port 51121 (ANTIGRAVITY_CALLBACK_PORT) first
- Fallback to OS-assigned port on EADDRINUSE
- Add redirectUri property to CallbackServerHandle
- Return actual bound port in handle.port

Add comprehensive port handling tests (5 new tests):
- Should prefer port 51121
- Should return actual bound port
- Should fallback when port occupied
- Should cleanup and release port on close
- Should provide redirect URI with actual port

All 16 tests passing (11 existing + 5 new).

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* test(auth): add token expiry tests for 50-min buffer

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* feat(agents): add Prometheus system prompt and planner methodology

Add prometheus-prompt.ts with comprehensive planner agent system prompt.
Update plan-prompt.ts with streamlined Prometheus workflow including:
- Context gathering via explore/librarian agents
- Metis integration for AI slop guardrails
- Structured plan output format

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(agents): add Metis plan consultant agent

Add Metis agent for pre-planning analysis that identifies:
- Hidden requirements and implicit constraints
- AI failure points and common mistakes
- Clarifying questions before planning begins

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(agents): add Momus plan reviewer agent

Add Momus agent for rigorous plan review against:
- Clarity and verifiability standards
- Completeness checks
- AI slop detection

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(agents): add Sisyphus-Junior focused executor agent

Add Sisyphus-Junior agent for focused task execution:
- Same discipline as Sisyphus, no delegation capability
- Used for category-based task spawning via sisyphus_task tool

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(agents): add orchestrator-sisyphus agent

Add orchestrator-sisyphus agent for complex workflow orchestration:
- Manages multi-agent workflows
- Coordinates between specialized agents
- Handles start-work command execution

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(skill-loader): add skill-content resolver for agent skills

Add resolveMultipleSkills() for resolving skill content to prepend to agent prompts.
Includes test coverage for resolution logic.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(agents): add category and skills support to buildAgent

Extend buildAgent() to support:
- category: inherit model/temperature from DEFAULT_CATEGORIES
- skills: prepend resolved skill content to agent prompt

Includes comprehensive test coverage for new functionality.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(agents): register new agents in index and types

- Export Metis, Momus, orchestrator-sisyphus in builtinAgents
- Add new agent names to BuiltinAgentName type
- Update AGENTS.md documentation with new agents

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(features): add boulder-state persistence

Add boulder-state feature for persisting workflow state:
- storage.ts: File I/O operations for state persistence
- types.ts: State interfaces
- Includes test coverage

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(skills): add frontend-ui-ux builtin skill

Add frontend-ui-ux skill for designer-turned-developer UI work:
- SKILL.md with comprehensive design principles
- skills.ts updated with skill template

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(tools): add sisyphus_task tool for category-based delegation

Add sisyphus_task tool supporting:
- Category-based task delegation (visual, business-logic, etc.)
- Direct agent targeting
- Background execution with resume capability
- DEFAULT_CATEGORIES configuration

Includes test coverage.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(background-agent): add resume capability and model field

- Add resume() method for continuing existing agent sessions
- Add model field to BackgroundTask and LaunchInput types
- Update launch() to pass model to session.prompt()
- Comprehensive test coverage for resume functionality

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(hooks): add task-resume-info hook

Add hook for injecting task resume information into tool outputs.
Enables seamless continuation of background agent sessions.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(hooks): add prometheus-md-only write restriction hook

Add hook that restricts Prometheus planner to writing only .md files
in the .sisyphus/ directory. Prevents planners from implementing.
Includes test coverage.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(hooks): add start-work hook for Sisyphus workflow

Add hook for detecting /start-work command and triggering
orchestrator-sisyphus agent for plan execution.
Includes test coverage.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(hooks): add sisyphus-orchestrator hook

Add hook for orchestrating Sisyphus agent workflows:
- Coordinates task execution between agents
- Manages workflow state persistence
- Handles agent handoffs

Includes comprehensive test coverage.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(hooks): export new hooks in index

Export new hooks:
- createPrometheusMdOnlyHook
- createTaskResumeInfoHook
- createStartWorkHook
- createSisyphusOrchestratorHook

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(todo-enforcer): add skipAgents option and improve permission check

- Add skipAgents option to skip continuation for specified agents
- Default skip: Prometheus (Planner)
- Improve tool permission check to handle 'allow'/'deny' string values
- Add agent name detection from session messages

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(config): add categories, new agents and hooks to schema

Update Zod schema with:
- CategoryConfigSchema for task delegation categories
- CategoriesConfigSchema for user category overrides
- New agents: Metis (Plan Consultant)
- New hooks: prometheus-md-only, start-work, sisyphus-orchestrator
- New commands: start-work
- Agent category and skills fields

Includes schema test coverage.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(commands): add start-work command

Add /start-work command for executing Prometheus plans:
- start-work.ts: Command template for orchestrator-sisyphus
- commands.ts: Register command with agent binding
- types.ts: Add command name to type union

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* refactor(migration): add backup creation and category migration

- Create timestamped backup before migration writes
- Add migrateAgentConfigToCategory() for model→category migration
- Add shouldDeleteAgentConfig() for cleanup when matching defaults
- Add Prometheus and Metis to agent name map
- Comprehensive test coverage for new functionality

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(config-handler): add Sisyphus-Junior and orchestrator support

- Add Sisyphus-Junior agent creation
- Add orchestrator-sisyphus tool restrictions
- Rename Planner-Sisyphus to Prometheus (Planner)
- Use PROMETHEUS_SYSTEM_PROMPT and PROMETHEUS_PERMISSION

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(cli): add categories config for Antigravity auth

Add category model overrides for Gemini Antigravity authentication:
- visual: gemini-3-pro-high
- artistry: gemini-3-pro-high
- writing: gemini-3-pro-high

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* refactor(sisyphus): update to use sisyphus_task and add resume docs

- Update example code from background_task to sisyphus_task
- Add 'Resume Previous Agent' documentation section
- Remove model name from Oracle section heading
- Disable call_omo_agent tool for Sisyphus

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* refactor: update tool references from background_task to sisyphus_task

Update all references across:
- agent-usage-reminder: Update AGENT_TOOLS and REMINDER_MESSAGE
- claude-code-hooks: Update comment
- call-omo-agent: Update constants and tool restrictions
- init-deep template: Update example code
- tools/index.ts: Export sisyphus_task, remove background_task

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(hook-message-injector): add ToolPermission type support

Add ToolPermission type union: boolean | 'allow' | 'deny' | 'ask'
Update StoredMessage and related interfaces for new permission format.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(main): wire up new tools, hooks and agents

Wire up in main plugin entry:
- Import and create sisyphus_task tool
- Import and wire taskResumeInfo, startWork, sisyphusOrchestrator hooks
- Update tool restrictions from background_task to sisyphus_task
- Pass userCategories to createSisyphusTask

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* docs: update documentation for Prometheus and new features

Update documentation across all language versions:
- Rename Planner-Sisyphus to Prometheus (Planner)
- Add Metis (Plan Consultant) agent documentation
- Add Categories section with usage examples
- Add sisyphus_task tool documentation
- Update AGENTS.md with new structure and complexity hotspots
- Update src/tools/AGENTS.md with sisyphus_task category

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* build: regenerate schema.json with new types

Update JSON schema with:
- New agents: Prometheus (Planner), Metis (Plan Consultant)
- New hooks: prometheus-md-only, start-work, sisyphus-orchestrator
- New commands: start-work
- New skills: frontend-ui-ux
- CategoryConfigSchema for task delegation
- Agent category and skills fields

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* skill

* feat: add toast notifications for task execution

- Display toast when background task starts in BackgroundManager
- Display toast when sisyphus_task sync task starts
- Wire up prometheus-md-only hook initialization in main plugin

This provides user feedback in OpenCode TUI where task TUI is not visible.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(hooks): add read-only warning injection for Prometheus task delegation

When Prometheus (Planner) spawns subagents via task tools (sisyphus_task, task, call_omo_agent), a system directive is injected into the prompt to ensure subagents understand they are in a planning consultation context and must NOT modify files.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(hooks): add mandatory hands-on verification enforcement for orchestrated tasks

- sisyphus-orchestrator: Add verification reminder with tool matrix (playwright/interactive_bash/curl)

- start-work: Inject detailed verification workflow with deliverable-specific guidance

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) assistance

* docs(agents): clarify oracle and metis agent descriptions emphasizing read-only consultation roles

- Oracle: high-IQ reasoning specialist for debugging and architecture (read-only)
- Metis: updated description to align with oracle's consultation-only model
- Updated AGENTS.md with clarified agent responsibilities

* docs(orchestrator): emphasize oracle as read-only consultation agent

- Updated orchestrator-sisyphus agent descriptions
- Updated sisyphus-prompt-builder to highlight oracle's read-only consultation role
- Clarified that oracle provides high-IQ reasoning without write operations

* docs(refactor,root): update oracle consultation model in feature templates and root docs

- Updated refactor command template to emphasize oracle's read-only role
- Updated root AGENTS.md with oracle agent description emphasizing high-IQ debugging and architecture consultation
- Clarified oracle as non-write agent for design and debugging support

* feat(features): add TaskToastManager for consolidated task notifications

- Create task-toast-manager feature with singleton pattern

- Show running task list (newest first) when new task starts

- Track queued tasks status from ConcurrencyManager

- Integrate with BackgroundManager and sisyphus-task tool

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) assistance

* feat(hooks): add resume session_id to verification reminders for orchestrator subagent work

When subagent work fails verification, show exact sisyphus_task(resume="...")
command with session_id for immediate retry. Consolidates verification workflow
across boulder and standalone modes.

* refactor(hooks): remove duplicate verification enforcement from start-work hook

Verification reminders are now centralized in sisyphus-orchestrator hook,
eliminating redundant code in start-work. The orchestrator hook handles all
verification messaging across both boulder and standalone modes.

* test(hooks): update prometheus-md-only test assertions and formatting

Updated test structure and assertions to match current output format.
Improved test clarity while maintaining complete coverage of markdown
validation and write restriction behavior.

* orchestrator

* feat(skills): add git-master skill for atomic commits and history management

- Add comprehensive git-master skill for commit, rebase, and history operations
- Implements atomic commit strategy with multi-file splitting rules
- Includes style detection, branch analysis, and history search capabilities
- Provides three modes: COMMIT, REBASE, HISTORY_SEARCH

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* docs(agents): add pre-delegation planning section to Sisyphus prompt

- Add SISYPHUS_PRE_DELEGATION_PLANNING section with mandatory declaration rules
- Implements 3-step decision tree: Identify → Select → Declare
- Forces explicit category/agent/skill declaration before every sisyphus_task call
- Includes mandatory 4-part format: Category/Agent, Reason, Skills, Expected Outcome
- Provides examples (CORRECT vs WRONG) and enforcement rules
- Follows prompt engineering best practices: Clear, CoT, Structured, Examples

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* refactor(tools): rename agent parameter to subagent_type in sisyphus_task

- Update parameter name from 'agent' to 'subagent_type' for consistency with call_omo_agent
- Update all references and error messages
- Remove deprecated 'agent' field from SisyphusTaskArgs interface
- Update git-master skill documentation to reflect parameter name change

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(agents): change orchestrator-sisyphus default model to claude-sonnet-4-5

- Update orchestrator-sisyphus model from opus-4-5 to sonnet-4-5 for better cost efficiency
- Keep Prometheus using opus-4-5 for planning tasks

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* refactor(config): make Prometheus model independent from plan agent config

- Prometheus no longer inherits model from plan agent configuration
- Fallback chain: session default model -> claude-opus-4-5
- Removes coupling between Prometheus and legacy plan agent settings

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* fix(momus): allow system directives in input validation

System directives (XML tags like <system-reminder>) are automatically
injected and should be ignored during input validation. Only reject
when there's actual user text besides the file path.

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(prometheus): enhance high accuracy mode with mandatory Momus loop

When user requests high accuracy:
- Momus review loop is now mandatory until 'OKAY'
- No excuses allowed - must fix ALL issues
- No maximum retry limit - keep looping until approved
- Added clear explanation of what 'OKAY' means

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(prometheus): enhance reference section with detailed guidance

References now include:
- Pattern references (existing code to follow)
- API/Type references (contracts to implement)
- Test references (testing patterns)
- Documentation references (specs and requirements)
- External references (libraries and frameworks)
- Explanation of WHY each reference matters

The executor has no interview context - references are their only guide.

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(git-master): add configurable commit footer and co-author options

Add git_master config with commit_footer and include_co_authored_by flags.
Users can disable Sisyphus attribution in commits via oh-my-opencode.json.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* feat(hooks): add single-task directive and system-reminder tags to orchestrator

Inject SINGLE_TASK_DIRECTIVE when orchestrator calls sisyphus_task to enforce
atomic task delegation. Wrap verification reminders in <system-reminder> tags
for better LLM attention.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* refactor: use ContextCollector for hook injection and remove unused background tools

Split changes:
- Replace injectHookMessage with ContextCollector.register() pattern for improved hook content injection
- Remove unused background task tools infrastructure (createBackgroundOutput, createBackgroundCancel)

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)

* chore(context-injector): add debug logging for context injection tracing

Add DEBUG log statements to trace context injection flow:
- Log message transform hook invocations
- Log sessionID extraction from message info
- Log hasPending checks for context collector
- Log hook content registration to contextCollector

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) assistance

* fix(context-injector): prepend to user message instead of separate synthetic message

- Change from creating separate synthetic user message to prepending context
  directly to last user message's text part
- Separate synthetic messages were ignored by model (treated as previous turn)
- Prepending to clone ensures: UI shows original, model receives prepended content
- Update tests to reflect new behavior

* feat(prometheus): enforce mandatory todo registration on plan generation trigger

* fix(sisyphus-task): add proper error handling for sync mode and implement BackgroundManager.resume()

- Add try-catch for session.prompt() in sync mode with detailed error messages
- Sort assistant messages by time to get the most recent response
- Add 'No assistant response found' error handling
- Implement BackgroundManager.resume() method for task resumption
- Fix ConcurrencyManager type mismatch (model → concurrencyKey)

* docs(sisyphus-task): clarify resume usage with session_id and add when-to-use guidance

- Fix terminology: 'Task ID' → 'Session ID' in resume parameter docs
- Add clear 'WHEN TO USE resume' section with concrete scenarios
- Add example usage pattern in Sisyphus agent prompt
- Emphasize token savings and context preservation benefits

* fix(agents): block task/sisyphus_task/call_omo_agent from explore and librarian

Exploration agents should not spawn other agents - they are leaf nodes
in the agent hierarchy for codebase search only.

* refactor(oracle): change default model from GPT-5.2 to Claude Opus 4.5

* feat(oracle): change default model to claude-opus-4-5

* fix(sisyphus-orchestrator): check boulder session_ids before filtering sessions

Bug: continuation was not triggered even when boulder.json existed with
session_ids because the session filter ran BEFORE reading boulder state.

Fix: Read boulder state first, then include boulder sessions in the
allowed sessions for continuation.

* feat(task-toast): display skills and concurrency info in toast

- Add skills field to TrackedTask and LaunchInput types
- Show skills in task list message as [skill1, skill2]
- Add concurrency slot info [running/limit] in Running header
- Pass skills from sisyphus_task to toast manager (sync & background)
- Add unit tests for new toast features

* refactor(categories): rename high-iq to ultrabrain

* feat(sisyphus-task): add skillContent support to background agent launching

- Add optional skillContent field to LaunchInput type
- Implement buildSystemContent utility to combine skill and category prompts
- Update BackgroundManager to pass skillContent as system parameter
- Add comprehensive tests for skillContent optionality and buildSystemContent logic

🤖 Generated with assistance of oh-my-opencode

* Revert "refactor(tools): remove background-task tool"

This reverts commit 6dbc4c095badd400e024510554a42a0dc018ae42.

* refactor(sisyphus-task): rename background to run_in_background

* fix(oracle): use gpt-5.2 as default model

* test(sisyphus-task): add resume with background parameter tests

* feat(start-work): auto-select single incomplete plan and use system-reminder format

- Auto-select when only one incomplete plan exists among multiple
- Wrap multiple plans message in <system-reminder> tag
- Change prompt to 'ask user' style for agent guidance
- Add 'All Plans Complete' state handling

* feat(sisyphus-task): make skills parameter required

- Add validation for skills parameter (must be provided, use [] if empty)
- Update schema to remove .optional()
- Update type definition to make skills non-optional
- Fix existing tests to include skills parameter

* fix: prevent session model change when sending notifications

- background-agent: use only parentModel, remove prevMessage fallback
- todo-continuation: don't pass model to preserve session's lastModel
- Remove unused imports (findNearestMessageWithFields, fs, path)

Root cause: session.prompt with model param changes session's lastModel

* fix(sisyphus-orchestrator): register handler in event loop for boulder continuation

* fix(sisyphus_task): use promptAsync for sync mode to preserve main session

- session.prompt() changes the active session, causing UI model switch
- Switch to promptAsync + polling to avoid main session state change
- Matches background-agent pattern for consistency

* fix(sisyphus-orchestrator): only trigger boulder continuation for orchestrator-sisyphus agent

* feat(background-agent): add parentAgent tracking to preserve agent context in background tasks

- Add parentAgent field to BackgroundTask, LaunchInput, and ResumeInput interfaces
- Pass parentAgent through background task manager to preserve agent identity
- Update sisyphus-orchestrator to set orchestrator-sisyphus agent context
- Add session tracking for background agents to prevent context loss
- Propagate agent context in background-task and sisyphus-task tools

This ensures background/subagent spawned tasks maintain proper agent context for notifications and continuity.

🤖 Generated with assistance of oh-my-opencode

* fix(antigravity): sync plugin.ts with PKCE-removed oauth.ts API

Remove decodeState import and update OAuth flow to use simple state
string comparison for CSRF protection instead of PKCE verifier.
Update exchangeCode calls to match new signature (code, redirectUri,
clientId, clientSecret).

* fix(hook-message-injector): preserve agent info with two-pass message lookup

findNearestMessageWithFields now has a fallback pass that returns
messages with ANY useful field (agent OR model) instead of requiring
ALL fields. This prevents parentAgent from being lost when stored
messages don't have complete model info.

* fix(sisyphus-task): use SDK session.messages API for parent agent lookup

Background task notifications were showing 'build' agent instead of the
actual parent agent (e.g., 'Sisyphus'). The hook-injected message storage
only contains limited info; the actual agent name is in the SDK session.

Changes:
- Add getParentAgentFromSdk() to query SDK messages API
- Look up agent from SDK first, fallback to hook-injected messages
- Ensures background tasks correctly preserve parent agent context

* fix(sisyphus-task): use ctx.agent directly for parentAgent

The tool context already provides the agent name via ctx.agent.
The previous SDK session.messages lookup was completely wrong -
SDK messages don't store agent info per message.

Removes useless getParentAgentFromSdk function.

* feat(prometheus-md-only): allow .md files anywhere, only block code files

Prometheus (Planner) can now write .md files anywhere, not just .sisyphus/.
Still blocks non-.md files (code) to enforce read-only planning for code.

This allows planners to write commentary and analysis in markdown format.

* Revert "feat(prometheus-md-only): allow .md files anywhere, only block code files"

This reverts commit c600111597.

* fix(momus): accept bracket-style system directives in input validation

Momus was rejecting inputs with bracket-style directives like [analyze-mode]
and [SYSTEM DIRECTIVE...] because it only recognized XML-style tags.

Now accepts:
- XML tags: <system-reminder>, <context>, etc.
- Bracket blocks: [analyze-mode], [SYSTEM DIRECTIVE...], [SYSTEM REMINDER...], etc.

* fix(sisyphus-orchestrator): inject delegation warning before Write/Edit outside .sisyphus

- Add ORCHESTRATOR_DELEGATION_REQUIRED strong warning in tool.execute.before
- Fix tool.execute.after filePath detection using pendingFilePaths Map
- before stores filePath by callID, after retrieves and deletes it
- Fixes bug where output.metadata.filePath was undefined

* docs: add orchestration, category-skill, and CLI guides

* fix(cli): correct category names in Antigravity migration (visual → visual-engineering)

* fix(sisyphus-task): prevent infinite polling when session removed from status

* fix(tests): update outdated test expectations

- constants.test.ts: Update endpoint count (2→3) and token buffer (50min→60sec)
- token.test.ts: Update expiry tests to use 60-second buffer
- sisyphus-orchestrator: Add fallback to output.metadata.filePath when callID missing

---------

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-01-09 02:24:43 +09:00
committed by GitHub
parent dffd892134
commit 5df243b5bc
92 changed files with 13771 additions and 672 deletions
+6 -6
View File
@@ -22,7 +22,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 +30,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 +48,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 },
}),
)
@@ -409,7 +409,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 +497,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 =
+30 -19
View File
@@ -27,14 +27,18 @@ 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 },
@@ -164,24 +168,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,
})
}
}
}
}
@@ -239,7 +250,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
+4
View File
@@ -25,3 +25,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";
+3 -3
View File
@@ -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
+2 -17
View File
@@ -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 })
+30
View File
@@ -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.
---
`
+298
View File
@@ -0,0 +1,298 @@
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: "/project/.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()
})
})
})
+97
View File
@@ -0,0 +1,97 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { existsSync, readdirSync } from "node:fs"
import { join } from "node:path"
import { HOOK_NAME, PROMETHEUS_AGENTS, ALLOWED_EXTENSIONS, ALLOWED_PATH_PREFIX, BLOCKED_TOOLS, PLANNING_CONSULT_WARNING } from "./constants"
import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector"
import { log } from "../../shared/logger"
export * from "./constants"
function isAllowedFile(filePath: string): boolean {
const hasAllowedExtension = ALLOWED_EXTENSIONS.some(ext => filePath.endsWith(ext))
const isInAllowedPath = filePath.includes(ALLOWED_PATH_PREFIX)
return hasAllowedExtension && isInAllowedPath
}
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 getAgentFromSession(sessionID: string): string | undefined {
const messageDir = getMessageDir(sessionID)
if (!messageDir) return undefined
return findNearestMessageWithFields(messageDir)?.agent
}
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)) {
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,
})
},
}
}
@@ -0,0 +1,829 @@
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 replaced)
expect(output.output).not.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("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()
})
})
})
+660
View File
@@ -0,0 +1,660 @@
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"
const ALLOWED_PATH_PREFIX = ".sisyphus/"
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 })
await ctx.client.session.prompt({
path: { id: sessionID },
body: {
agent: "orchestrator-sisyphus",
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 && !filePath.includes(ALLOWED_PATH_PREFIX)) {
// 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 && !filePath.includes(ALLOWED_PATH_PREFIX)) {
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,
})
}
output.output = `
## SUBAGENT WORK COMPLETED
${fileChanges}
<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,
})
}
}
},
}
}
+240
View File
@@ -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?")
})
})
})
+153
View File
@@ -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,
})
},
}
}
+36
View File
@@ -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"
+38 -15
View File
@@ -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 {
@@ -89,7 +92,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 +187,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 },
@@ -324,6 +325,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
}