Compare commits
14 Commits
v3.0.0-bet
...
v3.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31dfef85b8 | ||
|
|
0ce87085db | ||
|
|
753fd809b5 | ||
|
|
6d99b5c1fc | ||
|
|
255f535a50 | ||
|
|
2206d68523 | ||
|
|
b643dd4f19 | ||
|
|
0ed1d183d4 | ||
|
|
d13e8411f0 | ||
|
|
36b665ed89 | ||
|
|
987ae46841 | ||
|
|
74e9834797 | ||
|
|
5657c3aa28 | ||
|
|
c433e7397e |
150
AGENTS.md
150
AGENTS.md
@@ -1,29 +1,29 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** 2026-01-15T14:53:00+09:00
|
||||
**Commit:** 89fa9ff1
|
||||
**Generated:** 2026-01-17T21:55:00+09:00
|
||||
**Commit:** 255f535a
|
||||
**Branch:** dev
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
OpenCode plugin implementing Claude Code/AmpCode features. Multi-model agent orchestration (GPT-5.2, Claude, Gemini, Grok), LSP tools (11), AST-Grep search, MCP integrations (context7, websearch_exa, grep_app). "oh-my-zsh" for OpenCode.
|
||||
OpenCode plugin implementing multi-model agent orchestration (Claude Opus 4.5, GPT-5.2, Gemini 3, Grok, GLM-4.7). 31 lifecycle hooks, 20+ tools (LSP, AST-Grep, delegation), 10 specialized agents, Claude Code compatibility layer. "oh-my-zsh" for OpenCode.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
oh-my-opencode/
|
||||
├── src/
|
||||
│ ├── agents/ # AI agents (10+): Sisyphus, oracle, librarian, explore, frontend, document-writer, multimodal-looker, prometheus, metis, momus
|
||||
│ ├── hooks/ # 22+ lifecycle hooks - see src/hooks/AGENTS.md
|
||||
│ ├── tools/ # LSP, AST-Grep, Grep, Glob, session mgmt - see src/tools/AGENTS.md
|
||||
│ ├── features/ # Claude Code compat layer - see src/features/AGENTS.md
|
||||
│ ├── shared/ # Cross-cutting utilities - see src/shared/AGENTS.md
|
||||
│ ├── cli/ # CLI installer, doctor - see src/cli/AGENTS.md
|
||||
│ ├── mcp/ # MCP configs: context7, grep_app, websearch
|
||||
│ ├── agents/ # 10 AI agents (Sisyphus, oracle, librarian, explore, frontend, etc.) - see src/agents/AGENTS.md
|
||||
│ ├── hooks/ # 31 lifecycle hooks (PreToolUse, PostToolUse, Stop, etc.) - see src/hooks/AGENTS.md
|
||||
│ ├── tools/ # 20+ tools (LSP, AST-Grep, delegation, session) - see src/tools/AGENTS.md
|
||||
│ ├── features/ # Background agents, Claude Code compat layer - see src/features/AGENTS.md
|
||||
│ ├── shared/ # 43 cross-cutting utilities - see src/shared/AGENTS.md
|
||||
│ ├── cli/ # CLI installer, doctor, run - see src/cli/AGENTS.md
|
||||
│ ├── mcp/ # Built-in MCPs: websearch, context7, grep_app
|
||||
│ ├── config/ # Zod schema, TypeScript types
|
||||
│ └── index.ts # Main plugin entry (580 lines)
|
||||
├── script/ # build-schema.ts, publish.ts, generate-changelog.ts
|
||||
├── assets/ # JSON schema
|
||||
│ └── index.ts # Main plugin entry (568 lines)
|
||||
├── script/ # build-schema.ts, publish.ts, build-binaries.ts
|
||||
├── packages/ # 7 platform-specific binaries
|
||||
└── dist/ # Build output (ESM + .d.ts)
|
||||
```
|
||||
|
||||
@@ -31,46 +31,34 @@ oh-my-opencode/
|
||||
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Add agent | `src/agents/` | Create .ts, add to builtinAgents in index.ts, update types.ts |
|
||||
| Add hook | `src/hooks/` | Create dir with createXXXHook(), export from index.ts |
|
||||
| Add tool | `src/tools/` | Dir with index/types/constants/tools.ts, add to builtinTools |
|
||||
| Add MCP | `src/mcp/` | Create config, add to index.ts and types.ts |
|
||||
| Add skill | `src/features/builtin-skills/` | Create skill dir with SKILL.md |
|
||||
| Add agent | `src/agents/` | Create .ts with factory, add to `builtinAgents` in index.ts |
|
||||
| Add hook | `src/hooks/` | Create dir with `createXXXHook()`, register in index.ts |
|
||||
| Add tool | `src/tools/` | Dir with index/types/constants/tools.ts, add to `builtinTools` |
|
||||
| Add MCP | `src/mcp/` | Create config, add to index.ts |
|
||||
| Add skill | `src/features/builtin-skills/` | Create dir with SKILL.md |
|
||||
| LSP behavior | `src/tools/lsp/` | client.ts (connection), tools.ts (handlers) |
|
||||
| AST-Grep | `src/tools/ast-grep/` | napi.ts for @ast-grep/napi binding |
|
||||
| Config schema | `src/config/schema.ts` | Zod schema, run `bun run build:schema` after changes |
|
||||
| Claude Code compat | `src/features/claude-code-*-loader/` | Command, skill, agent, mcp loaders |
|
||||
| Background agents | `src/features/background-agent/` | manager.ts for task management |
|
||||
| Background agents | `src/features/background-agent/` | manager.ts (1165 lines) for task lifecycle |
|
||||
| Skill MCP | `src/features/skill-mcp-manager/` | MCP servers embedded in skills |
|
||||
| Interactive terminal | `src/tools/interactive-bash/` | tmux session management |
|
||||
| CLI installer | `src/cli/install.ts` | Interactive TUI installation |
|
||||
| Doctor checks | `src/cli/doctor/checks/` | Health checks for environment |
|
||||
| Shared utilities | `src/shared/` | Cross-cutting utilities |
|
||||
| Slash commands | `src/hooks/auto-slash-command/` | Auto-detect and execute `/command` patterns |
|
||||
| Ralph Loop | `src/hooks/ralph-loop/` | Self-referential dev loop until completion |
|
||||
| Orchestrator | `src/hooks/sisyphus-orchestrator/` | Main orchestration hook (684 lines) |
|
||||
| CLI installer | `src/cli/install.ts` | Interactive TUI (462 lines) |
|
||||
| Doctor checks | `src/cli/doctor/checks/` | 14 health checks across 6 categories |
|
||||
| Orchestrator | `src/hooks/sisyphus-orchestrator/` | Main orchestration hook (771 lines) |
|
||||
|
||||
## TDD (Test-Driven Development)
|
||||
|
||||
**MANDATORY for new features and bug fixes.** Follow RED-GREEN-REFACTOR:
|
||||
|
||||
```
|
||||
1. RED - Write failing test first (test MUST fail)
|
||||
2. GREEN - Write MINIMAL code to pass (nothing more)
|
||||
3. REFACTOR - Clean up while tests stay GREEN
|
||||
4. REPEAT - Next test case
|
||||
```
|
||||
|
||||
| Phase | Action | Verification |
|
||||
|-------|--------|--------------|
|
||||
| **RED** | Write test describing expected behavior | `bun test` -> FAIL (expected) |
|
||||
| **GREEN** | Implement minimum code to pass | `bun test` -> PASS |
|
||||
| **REFACTOR** | Improve code quality, remove duplication | `bun test` -> PASS (must stay green) |
|
||||
| **RED** | Write test describing expected behavior | `bun test` → FAIL (expected) |
|
||||
| **GREEN** | Implement minimum code to pass | `bun test` → PASS |
|
||||
| **REFACTOR** | Improve code quality, remove duplication | `bun test` → PASS (must stay green) |
|
||||
|
||||
**Rules:**
|
||||
- NEVER write implementation before test
|
||||
- NEVER delete failing tests to "pass" - fix the code
|
||||
- One test at a time - don't batch
|
||||
- Test file naming: `*.test.ts` alongside source
|
||||
- BDD comments: `#given`, `#when`, `#then` (same as AAA)
|
||||
|
||||
@@ -79,40 +67,37 @@ oh-my-opencode/
|
||||
- **Package manager**: Bun only (`bun run`, `bun build`, `bunx`)
|
||||
- **Types**: bun-types (not @types/node)
|
||||
- **Build**: `bun build` (ESM) + `tsc --emitDeclarationOnly`
|
||||
- **Exports**: Barrel pattern in index.ts; explicit named exports for tools/hooks
|
||||
- **Naming**: kebab-case directories, createXXXHook/createXXXTool factories
|
||||
- **Testing**: BDD comments `#given/#when/#then`, TDD workflow (RED-GREEN-REFACTOR), 80+ test files
|
||||
- **Exports**: Barrel pattern in index.ts; explicit named exports
|
||||
- **Naming**: kebab-case directories, `createXXXHook`/`createXXXTool` factories
|
||||
- **Testing**: BDD comments `#given/#when/#then`, 84 test files
|
||||
- **Temperature**: 0.1 for code agents, max 0.3
|
||||
|
||||
## ANTI-PATTERNS (THIS PROJECT)
|
||||
|
||||
- **npm/yarn**: Use bun exclusively
|
||||
- **@types/node**: Use bun-types
|
||||
- **Bash file ops**: Never mkdir/touch/rm/cp/mv for file creation in code
|
||||
- **Direct bun publish**: GitHub Actions workflow_dispatch only (OIDC provenance)
|
||||
- **Local version bump**: Version managed by CI workflow
|
||||
- **Year 2024**: NEVER use 2024 in code/prompts (use current year)
|
||||
- **Rush completion**: Never mark tasks complete without verification
|
||||
- **Over-exploration**: Stop searching when sufficient context found
|
||||
- **High temperature**: Don't use >0.3 for code-related agents
|
||||
- **Broad tool access**: Prefer explicit `include` over unrestricted access
|
||||
- **Sequential agent calls**: Use `delegate_task` for parallel execution
|
||||
- **Heavy PreToolUse logic**: Slows every tool call
|
||||
- **Self-planning for complex tasks**: Spawn planning agent (Prometheus) instead
|
||||
- **Trust agent self-reports**: ALWAYS verify results independently
|
||||
- **Skip TODO creation**: Multi-step tasks MUST have todos first
|
||||
- **Batch completions**: Mark TODOs complete immediately, don't group
|
||||
- **Giant commits**: 3+ files = 2+ commits minimum
|
||||
- **Separate test from impl**: Same commit always
|
||||
| Category | Forbidden |
|
||||
|----------|-----------|
|
||||
| **Package Manager** | npm, yarn - use Bun exclusively |
|
||||
| **Types** | @types/node - use bun-types |
|
||||
| **File Ops** | mkdir/touch/rm/cp/mv in code - agents use bash tool |
|
||||
| **Publishing** | Direct `bun publish` - use GitHub Actions workflow_dispatch |
|
||||
| **Versioning** | Local version bump - managed by CI |
|
||||
| **Date References** | Year 2024 - use current year |
|
||||
| **Type Safety** | `as any`, `@ts-ignore`, `@ts-expect-error` |
|
||||
| **Error Handling** | Empty catch blocks `catch(e) {}` |
|
||||
| **Testing** | Deleting failing tests to "pass" |
|
||||
| **Agent Calls** | Sequential agent calls - use `delegate_task` for parallel |
|
||||
| **Tool Access** | Broad tool access - prefer explicit `include` |
|
||||
| **Hook Logic** | Heavy PreToolUse computation - slows every tool call |
|
||||
| **Commits** | Giant commits (3+ files = 2+ commits), separate test from impl |
|
||||
| **Temperature** | >0.3 for code agents |
|
||||
| **Trust** | Trust agent self-reports - ALWAYS verify independently |
|
||||
|
||||
## UNIQUE STYLES
|
||||
|
||||
- **Platform**: Union type `"darwin" | "linux" | "win32" | "unsupported"`
|
||||
- **Optional props**: Extensive `?` for optional interface properties
|
||||
- **Flexible objects**: `Record<string, unknown>` for dynamic configs
|
||||
- **Error handling**: Consistent try/catch with async/await
|
||||
- **Agent tools**: `tools: { include: [...] }` or `tools: { exclude: [...] }`
|
||||
- **Temperature**: Most agents use `0.1` for consistency
|
||||
- **Hook naming**: `createXXXHook` function convention
|
||||
- **Factory pattern**: Components created via `createXXX()` functions
|
||||
|
||||
@@ -121,13 +106,13 @@ oh-my-opencode/
|
||||
| Agent | Default Model | Purpose |
|
||||
|-------|---------------|---------|
|
||||
| Sisyphus | anthropic/claude-opus-4-5 | Primary orchestrator with extended thinking |
|
||||
| oracle | openai/gpt-5.2 | Read-only consultation. High-IQ debugging, architecture |
|
||||
| librarian | opencode/glm-4.7-free | Multi-repo analysis, docs |
|
||||
| explore | opencode/grok-code | Fast codebase exploration |
|
||||
| frontend-ui-ux-engineer | google/gemini-3-pro-preview | UI generation |
|
||||
| document-writer | google/gemini-3-pro-preview | Technical docs |
|
||||
| oracle | openai/gpt-5.2 | Read-only consultation, high-IQ debugging |
|
||||
| librarian | opencode/glm-4.7-free | Multi-repo analysis, docs, GitHub search |
|
||||
| explore | opencode/grok-code | Fast codebase exploration (contextual grep) |
|
||||
| frontend-ui-ux-engineer | google/gemini-3-pro-preview | UI generation, visual design |
|
||||
| document-writer | google/gemini-3-flash | Technical documentation |
|
||||
| multimodal-looker | google/gemini-3-flash | PDF/image analysis |
|
||||
| Prometheus (Planner) | anthropic/claude-opus-4-5 | Strategic planning, interview-driven |
|
||||
| Prometheus (Planner) | anthropic/claude-opus-4-5 | Strategic planning, interview mode |
|
||||
| Metis (Plan Consultant) | anthropic/claude-sonnet-4-5 | Pre-planning analysis |
|
||||
| Momus (Plan Reviewer) | anthropic/claude-sonnet-4-5 | Plan validation |
|
||||
|
||||
@@ -138,7 +123,7 @@ bun run typecheck # Type check
|
||||
bun run build # ESM + declarations + schema
|
||||
bun run rebuild # Clean + Build
|
||||
bun run build:schema # Schema only
|
||||
bun test # Run tests (80+ test files, 2500+ BDD assertions)
|
||||
bun test # Run tests (84 test files)
|
||||
```
|
||||
|
||||
## DEPLOYMENT
|
||||
@@ -153,25 +138,23 @@ bun test # Run tests (80+ test files, 2500+ BDD assertions)
|
||||
|
||||
## CI PIPELINE
|
||||
|
||||
- **ci.yml**: Parallel test/typecheck, build verification, auto-commit schema on master, rolling `next` draft release
|
||||
- **publish.yml**: Manual workflow_dispatch, version bump, changelog, OIDC npm publish
|
||||
- **ci.yml**: Parallel test/typecheck → build → auto-commit schema on master → rolling `next` draft release
|
||||
- **publish.yml**: Manual workflow_dispatch → version bump → changelog → 8-package OIDC npm publish → force-push master
|
||||
|
||||
## COMPLEXITY HOTSPOTS
|
||||
|
||||
| File | Lines | Description |
|
||||
|------|-------|-------------|
|
||||
| `src/agents/orchestrator-sisyphus.ts` | 1485 | Orchestrator agent, 7-section delegation, accumulated wisdom |
|
||||
| `src/features/builtin-skills/skills.ts` | 1230 | Skill definitions (frontend-ui-ux, playwright) |
|
||||
| `src/agents/prometheus-prompt.ts` | 991 | Planning agent, interview mode, multi-agent validation |
|
||||
| `src/features/background-agent/manager.ts` | 928 | Task lifecycle, concurrency |
|
||||
| `src/cli/config-manager.ts` | 730 | JSONC parsing, multi-level config, env detection |
|
||||
| `src/hooks/sisyphus-orchestrator/index.ts` | 684 | Orchestrator hook impl |
|
||||
| `src/tools/sisyphus-task/tools.ts` | 667 | Category-based task delegation |
|
||||
| `src/agents/sisyphus.ts` | 643 | Main Sisyphus prompt |
|
||||
| `src/tools/lsp/client.ts` | 632 | LSP protocol, JSON-RPC |
|
||||
| `src/agents/orchestrator-sisyphus.ts` | 1531 | Orchestrator agent, 7-section delegation, wisdom accumulation |
|
||||
| `src/features/builtin-skills/skills.ts` | 1203 | Skill definitions (playwright, git-master, frontend-ui-ux) |
|
||||
| `src/agents/prometheus-prompt.ts` | 1196 | Planning agent, interview mode, Momus loop |
|
||||
| `src/features/background-agent/manager.ts` | 1165 | Task lifecycle, concurrency, notification batching |
|
||||
| `src/hooks/sisyphus-orchestrator/index.ts` | 771 | Orchestrator hook implementation |
|
||||
| `src/tools/delegate-task/tools.ts` | 761 | Category-based task delegation |
|
||||
| `src/cli/config-manager.ts` | 730 | JSONC parsing, multi-level config |
|
||||
| `src/agents/sisyphus.ts` | 640 | Main Sisyphus prompt |
|
||||
| `src/features/builtin-commands/templates/refactor.ts` | 619 | Refactoring command template |
|
||||
| `src/index.ts` | 580 | Main plugin, all hook/tool init |
|
||||
| `src/hooks/anthropic-context-window-limit-recovery/executor.ts` | 554 | Multi-stage recovery |
|
||||
| `src/tools/lsp/client.ts` | 596 | LSP protocol, JSON-RPC |
|
||||
|
||||
## MCP ARCHITECTURE
|
||||
|
||||
@@ -184,16 +167,15 @@ Three-tier MCP system:
|
||||
|
||||
- **Zod validation**: `src/config/schema.ts`
|
||||
- **JSONC support**: Comments and trailing commas
|
||||
- **Multi-level**: User (`~/.config/opencode/`) → Project (`.opencode/`)
|
||||
- **Multi-level**: Project (`.opencode/`) → User (`~/.config/opencode/`)
|
||||
- **CLI doctor**: Validates config and reports errors
|
||||
|
||||
## NOTES
|
||||
|
||||
- **Testing**: Bun native test (`bun test`), BDD-style `#given/#when/#then`, 80+ test files
|
||||
- **Testing**: Bun native test (`bun test`), BDD-style, 84 test files
|
||||
- **OpenCode**: Requires >= 1.0.150
|
||||
- **Multi-lang docs**: README.md (EN), README.ko.md (KO), README.ja.md (JA), README.zh-cn.md (ZH-CN)
|
||||
- **Config**: `~/.config/opencode/oh-my-opencode.json` (user) or `.opencode/oh-my-opencode.json` (project)
|
||||
- **Trusted deps**: @ast-grep/cli, @ast-grep/napi, @code-yeongyu/comment-checker
|
||||
- **JSONC support**: Config files support comments (`// comment`, `/* block */`) and trailing commas
|
||||
- **Claude Code Compat**: Full compatibility layer for settings.json hooks, commands, skills, agents, MCPs
|
||||
- **Skill MCP**: Skills can embed MCP server configs in YAML frontmatter
|
||||
- **Flaky tests**: 2 known flaky tests (ralph-loop CI timeout, session-state parallel pollution)
|
||||
|
||||
@@ -62,6 +62,7 @@ Yes, technically possible. But I cannot recommend using it.
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/stargazers)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/issues)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE.md)
|
||||
[](https://deepwiki.com/code-yeongyu/oh-my-opencode)
|
||||
|
||||
[English](README.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
|
||||
|
||||
|
||||
@@ -56,18 +56,14 @@
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@code-yeongyu/comment-checker": "^0.6.1",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@openauthjs/openauth": "^0.4.3",
|
||||
"@opencode-ai/plugin": "^1.1.19",
|
||||
"@opencode-ai/sdk": "^1.1.19",
|
||||
"commander": "^14.0.2",
|
||||
"detect-libc": "^2.0.0",
|
||||
"hono": "^4.10.4",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"open": "^11.0.0",
|
||||
"picocolors": "^1.1.1",
|
||||
"picomatch": "^4.0.2",
|
||||
"xdg-basedir": "^5.1.0",
|
||||
"zod": "^4.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -591,6 +591,14 @@
|
||||
"created_at": "2026-01-17T01:25:58Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 863
|
||||
},
|
||||
{
|
||||
"name": "G-hoon",
|
||||
"id": 26299556,
|
||||
"comment_id": 3764015966,
|
||||
"created_at": "2026-01-17T15:27:41Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 879
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,61 +1,71 @@
|
||||
# AGENTS KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
AI agent definitions for multi-model orchestration, delegating tasks to specialized experts.
|
||||
|
||||
10 AI agents for multi-model orchestration. Sisyphus (primary), oracle, librarian, explore, frontend, document-writer, multimodal-looker, Prometheus, Metis, Momus.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
agents/
|
||||
├── orchestrator-sisyphus.ts # Orchestrator agent (1485 lines) - 7-section delegation, wisdom
|
||||
├── sisyphus.ts # Main Sisyphus prompt (643 lines)
|
||||
├── sisyphus-junior.ts # Junior variant for delegated tasks
|
||||
├── oracle.ts # Strategic advisor (GPT-5.2)
|
||||
├── librarian.ts # Multi-repo research (GLM-4.7-free)
|
||||
├── explore.ts # Fast codebase grep (Grok Code)
|
||||
├── frontend-ui-ux-engineer.ts # UI generation (Gemini 3 Pro Preview)
|
||||
├── document-writer.ts # Technical docs (Gemini 3 Pro Preview)
|
||||
├── multimodal-looker.ts # PDF/image analysis (Gemini 3 Flash)
|
||||
├── prometheus-prompt.ts # Planning agent prompt (991 lines) - interview mode
|
||||
├── metis.ts # Plan Consultant agent - pre-planning analysis
|
||||
├── momus.ts # Plan Reviewer agent - plan validation
|
||||
├── build-prompt.ts # Shared build agent prompt
|
||||
├── plan-prompt.ts # Shared plan agent prompt
|
||||
├── sisyphus-prompt-builder.ts # Factory for orchestrator prompts
|
||||
├── types.ts # AgentModelConfig interface
|
||||
├── utils.ts # createBuiltinAgents(), getAgentName()
|
||||
└── index.ts # builtinAgents export
|
||||
├── orchestrator-sisyphus.ts # Orchestrator (1531 lines) - 7-phase delegation
|
||||
├── sisyphus.ts # Main prompt (640 lines)
|
||||
├── sisyphus-junior.ts # Delegated task executor
|
||||
├── sisyphus-prompt-builder.ts # Dynamic prompt generation
|
||||
├── oracle.ts # Strategic advisor (GPT-5.2)
|
||||
├── librarian.ts # Multi-repo research (GLM-4.7-free)
|
||||
├── explore.ts # Fast grep (Grok Code)
|
||||
├── frontend-ui-ux-engineer.ts # UI specialist (Gemini 3 Pro)
|
||||
├── document-writer.ts # Technical writer (Gemini 3 Flash)
|
||||
├── multimodal-looker.ts # Media analyzer (Gemini 3 Flash)
|
||||
├── prometheus-prompt.ts # Planning (1196 lines) - interview mode
|
||||
├── metis.ts # Plan consultant - pre-planning analysis
|
||||
├── momus.ts # Plan reviewer - validation
|
||||
├── types.ts # AgentModelConfig interface
|
||||
├── utils.ts # createBuiltinAgents(), getAgentName()
|
||||
└── index.ts # builtinAgents export
|
||||
```
|
||||
|
||||
## AGENT MODELS
|
||||
| Agent | Default Model | Purpose |
|
||||
|-------|---------------|---------|
|
||||
| Sisyphus | anthropic/claude-opus-4-5 | Primary orchestrator. 32k extended thinking budget. |
|
||||
| oracle | openai/gpt-5.2 | High-IQ debugging, architecture, strategic consultation. |
|
||||
| librarian | opencode/glm-4.7-free | Multi-repo analysis, docs research, GitHub examples. |
|
||||
| explore | opencode/grok-code | Fast contextual grep. Fallbacks: Gemini-3-Flash, Haiku-4-5. |
|
||||
| frontend-ui-ux | google/gemini-3-pro-preview | Production-grade UI/UX generation and styling. |
|
||||
| document-writer | google/gemini-3-pro-preview | Technical writing, guides, API documentation. |
|
||||
| Prometheus | anthropic/claude-opus-4-5 | Strategic planner. Interview mode, orchestrates Metis/Momus. |
|
||||
| Metis | anthropic/claude-sonnet-4-5 | Plan Consultant. Pre-planning risk/requirement analysis. |
|
||||
| Momus | anthropic/claude-sonnet-4-5 | Plan Reviewer. Validation and quality enforcement. |
|
||||
|
||||
## HOW TO ADD AN AGENT
|
||||
1. Create `src/agents/my-agent.ts` exporting `AgentConfig`.
|
||||
2. Add to `builtinAgents` in `src/agents/index.ts`.
|
||||
3. Update `types.ts` if adding new config interfaces.
|
||||
| Agent | Model | Temperature | Purpose |
|
||||
|-------|-------|-------------|---------|
|
||||
| Sisyphus | anthropic/claude-opus-4-5 | 0.1 | Primary orchestrator, todo-driven |
|
||||
| oracle | openai/gpt-5.2 | 0.1 | Read-only consultation, debugging |
|
||||
| librarian | opencode/glm-4.7-free | 0.1 | Docs, GitHub search, OSS examples |
|
||||
| explore | opencode/grok-code | 0.1 | Fast contextual grep |
|
||||
| frontend-ui-ux-engineer | google/gemini-3-pro-preview | 0.7 | UI generation, visual design |
|
||||
| document-writer | google/gemini-3-flash | 0.3 | Technical documentation |
|
||||
| multimodal-looker | google/gemini-3-flash | 0.1 | PDF/image analysis |
|
||||
| Prometheus | anthropic/claude-opus-4-5 | 0.1 | Strategic planning, interview mode |
|
||||
| Metis | anthropic/claude-sonnet-4-5 | 0.1 | Pre-planning gap analysis |
|
||||
| Momus | anthropic/claude-sonnet-4-5 | 0.1 | Plan validation |
|
||||
|
||||
## MODEL FALLBACK LOGIC
|
||||
`createBuiltinAgents()` handles resolution:
|
||||
1. User config override (`agents.{name}.model`).
|
||||
2. Environment-specific settings (max20, antigravity).
|
||||
3. Hardcoded defaults in `index.ts`.
|
||||
## HOW TO ADD
|
||||
|
||||
1. Create `src/agents/my-agent.ts` exporting `AgentConfig`
|
||||
2. Add to `builtinAgents` in `src/agents/index.ts`
|
||||
3. Update `AgentNameSchema` in `src/config/schema.ts`
|
||||
4. Register in `src/index.ts` initialization
|
||||
|
||||
## TOOL RESTRICTIONS
|
||||
|
||||
| Agent | Denied Tools |
|
||||
|-------|-------------|
|
||||
| oracle | write, edit, task, delegate_task |
|
||||
| librarian | write, edit, task, delegate_task, call_omo_agent |
|
||||
| explore | write, edit, task, delegate_task, call_omo_agent |
|
||||
| multimodal-looker | Allowlist: read, glob, grep |
|
||||
|
||||
## KEY PATTERNS
|
||||
|
||||
- **Factory**: `createXXXAgent(model?: string): AgentConfig`
|
||||
- **Metadata**: `XXX_PROMPT_METADATA: AgentPromptMetadata`
|
||||
- **Tool restrictions**: `permission: { edit: "deny", bash: "ask" }`
|
||||
- **Thinking**: 32k budget tokens for Sisyphus, Oracle, Prometheus
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- **Trusting reports**: NEVER trust subagent self-reports; always verify outputs.
|
||||
- **High temp**: Don't use >0.3 for code agents (Sisyphus/Prometheus use 0.1).
|
||||
- **Sequential calls**: Prefer `delegate_task` with `run_in_background` for parallelism.
|
||||
|
||||
## SHARED PROMPTS
|
||||
- **build-prompt.ts**: Unified base for Sisyphus and Builder variants.
|
||||
- **plan-prompt.ts**: Core planning logic shared across planning agents.
|
||||
- **orchestrator-sisyphus.ts**: Uses a 7-section prompt structure and "wisdom notepad" to preserve learnings across turns.
|
||||
- **Trust reports**: NEVER trust subagent "I'm done" - verify outputs
|
||||
- **High temp**: Don't use >0.3 for code agents
|
||||
- **Sequential calls**: Use `delegate_task` with `run_in_background`
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* OpenCode's default build agent system prompt.
|
||||
*
|
||||
* This prompt enables FULL EXECUTION mode for the build agent, allowing file
|
||||
* modifications, command execution, and system changes while focusing on
|
||||
* implementation and execution.
|
||||
*
|
||||
* Inspired by OpenCode's build agent behavior.
|
||||
*
|
||||
* @see https://github.com/sst/opencode/blob/6f9bea4e1f3d139feefd0f88de260b04f78caaef/packages/opencode/src/session/prompt/build-switch.txt
|
||||
* @see https://github.com/sst/opencode/blob/6f9bea4e1f3d139feefd0f88de260b04f78caaef/packages/opencode/src/agent/agent.ts#L118-L125
|
||||
*/
|
||||
export const BUILD_SYSTEM_PROMPT = `<system-reminder>
|
||||
# Build Mode - System Reminder
|
||||
|
||||
BUILD MODE ACTIVE - you are in EXECUTION phase. Your responsibility is to:
|
||||
- Implement features and make code changes
|
||||
- Execute commands and run tests
|
||||
- Fix bugs and refactor code
|
||||
- Deploy and build systems
|
||||
- Make all necessary file modifications
|
||||
|
||||
You have FULL permissions to edit files, run commands, and make system changes.
|
||||
This is the implementation phase - execute decisively and thoroughly.
|
||||
|
||||
---
|
||||
|
||||
## Responsibility
|
||||
|
||||
Your current responsibility is to implement, build, and execute. You should:
|
||||
- Write and modify code to accomplish the user's goals
|
||||
- Run tests and builds to verify your changes
|
||||
- Fix errors and issues that arise
|
||||
- Use all available tools to complete the task efficiently
|
||||
- Delegate to specialized agents when appropriate for better results
|
||||
|
||||
**NOTE:** You should ask the user for clarification when requirements are ambiguous,
|
||||
but once the path is clear, execute confidently. The goal is to deliver working,
|
||||
tested, production-ready solutions.
|
||||
|
||||
---
|
||||
|
||||
## Important
|
||||
|
||||
The user wants you to execute and implement. You SHOULD make edits, run necessary
|
||||
tools, and make changes to accomplish the task. Use your full capabilities to
|
||||
deliver excellent results.
|
||||
</system-reminder>
|
||||
`
|
||||
|
||||
/**
|
||||
* OpenCode's default build agent permission configuration.
|
||||
*
|
||||
* Allows the build agent full execution permissions:
|
||||
* - edit: "ask" - Can modify files with confirmation
|
||||
* - bash: "ask" - Can execute commands with confirmation
|
||||
* - webfetch: "allow" - Can fetch web content
|
||||
*
|
||||
* This provides balanced permissions - powerful but with safety checks.
|
||||
*
|
||||
* @see https://github.com/sst/opencode/blob/6f9bea4e1f3d139feefd0f88de260b04f78caaef/packages/opencode/src/agent/agent.ts#L57-L68
|
||||
* @see https://github.com/sst/opencode/blob/6f9bea4e1f3d139feefd0f88de260b04f78caaef/packages/opencode/src/agent/agent.ts#L118-L125
|
||||
*/
|
||||
export const BUILD_PERMISSION = {
|
||||
edit: "ask" as const,
|
||||
bash: "ask" as const,
|
||||
webfetch: "allow" as const,
|
||||
}
|
||||
@@ -52,13 +52,30 @@ But the plan only says: "Add authentication following auth/login.ts pattern."
|
||||
|
||||
## Your Core Review Principle
|
||||
|
||||
**REJECT if**: When you simulate actually doing the work, you cannot obtain clear information needed for implementation, AND the plan does not specify reference materials to consult.
|
||||
**ABSOLUTE CONSTRAINT - RESPECT THE IMPLEMENTATION DIRECTION**:
|
||||
You are a REVIEWER, not a DESIGNER. The implementation direction in the plan is **NOT NEGOTIABLE**. Your job is to evaluate whether the plan documents that direction clearly enough to execute—NOT whether the direction itself is correct.
|
||||
|
||||
**What you MUST NOT do**:
|
||||
- Question or reject the overall approach/architecture chosen in the plan
|
||||
- Suggest alternative implementations that differ from the stated direction
|
||||
- Reject because you think there's a "better way" to achieve the goal
|
||||
- Override the author's technical decisions with your own preferences
|
||||
|
||||
**What you MUST do**:
|
||||
- Accept the implementation direction as a given constraint
|
||||
- Evaluate only: "Is this direction documented clearly enough to execute?"
|
||||
- Focus on gaps IN the chosen approach, not gaps in choosing the approach
|
||||
|
||||
**REJECT if**: When you simulate actually doing the work **within the stated approach**, you cannot obtain clear information needed for implementation, AND the plan does not specify reference materials to consult.
|
||||
|
||||
**ACCEPT if**: You can obtain the necessary information either:
|
||||
1. Directly from the plan itself, OR
|
||||
2. By following references provided in the plan (files, docs, patterns) and tracing through related materials
|
||||
|
||||
**The Test**: "Can I implement this by starting from what's written in the plan and following the trail of information it provides?"
|
||||
**The Test**: "Given the approach the author chose, can I implement this by starting from what's written in the plan and following the trail of information it provides?"
|
||||
|
||||
**WRONG mindset**: "This approach is suboptimal. They should use X instead." → **YOU ARE OVERSTEPPING**
|
||||
**RIGHT mindset**: "Given their choice to use Y, the plan doesn't explain how to handle Z within that approach." → **VALID CRITICISM**
|
||||
|
||||
---
|
||||
|
||||
@@ -90,22 +107,29 @@ The plan author is intelligent but has ADHD. They constantly skip providing:
|
||||
- PASS: Plan says "follow auth/login.ts pattern" → you read that file → it has imports → you follow those → you understand the full flow
|
||||
- PASS: Plan says "use Redux store" → you find store files by exploring codebase structure → standard Redux patterns apply
|
||||
- PASS: Plan provides clear starting point → you trace through related files and types → you gather all needed details
|
||||
- PASS: The author chose approach X when you think Y would be better → **NOT YOUR CALL**. Evaluate X on its own merits.
|
||||
- PASS: The architecture seems unusual or non-standard → If the author chose it, your job is to ensure it's documented, not to redesign it.
|
||||
|
||||
**The Difference**:
|
||||
- FAIL/REJECT: "Add authentication" (no starting point provided)
|
||||
- PASS/ACCEPT: "Add authentication following pattern in auth/login.ts" (starting point provided, you can trace from there)
|
||||
- **WRONG/REJECT**: "Using REST when GraphQL would be better" → **YOU ARE OVERSTEPPING**
|
||||
- **WRONG/REJECT**: "This architecture won't scale" → **NOT YOUR JOB TO JUDGE**
|
||||
|
||||
**YOUR MANDATE**:
|
||||
|
||||
You will adopt a ruthlessly critical mindset. You will read EVERY document referenced in the plan. You will verify EVERY claim. You will simulate actual implementation step-by-step. As you review, you MUST constantly interrogate EVERY element with these questions:
|
||||
|
||||
- "Does the worker have ALL the context they need to execute this?"
|
||||
- "How exactly should this be done?"
|
||||
- "Does the worker have ALL the context they need to execute this **within the chosen approach**?"
|
||||
- "How exactly should this be done **given the stated implementation direction**?"
|
||||
- "Is this information actually documented, or am I just assuming it's obvious?"
|
||||
- **"Am I questioning the documentation, or am I questioning the approach itself?"** ← If the latter, STOP.
|
||||
|
||||
You are not here to be nice. You are not here to give the benefit of the doubt. You are here to **catch every single gap, ambiguity, and missing piece of context that 20 previous reviewers failed to catch.**
|
||||
|
||||
**However**: You must evaluate THIS plan on its own merits. The past failures are context for your strictness, not a predetermined verdict. If this plan genuinely meets all criteria, approve it. If it has critical gaps, reject it without mercy.
|
||||
**However**: You must evaluate THIS plan on its own merits. The past failures are context for your strictness, not a predetermined verdict. If this plan genuinely meets all criteria, approve it. If it has critical gaps **in documentation**, reject it without mercy.
|
||||
|
||||
**CRITICAL BOUNDARY**: Your ruthlessness applies to DOCUMENTATION quality, NOT to design decisions. The author's implementation direction is a GIVEN. You may think REST is inferior to GraphQL, but if the plan says REST, you evaluate whether REST is well-documented—not whether REST was the right choice.
|
||||
|
||||
---
|
||||
|
||||
@@ -294,6 +318,13 @@ Scan for auto-fail indicators:
|
||||
- Subjective success criteria
|
||||
- Tasks requiring unstated assumptions
|
||||
|
||||
**SELF-CHECK - Are you overstepping?**
|
||||
Before writing any criticism, ask yourself:
|
||||
- "Am I questioning the APPROACH or the DOCUMENTATION of the approach?"
|
||||
- "Would my feedback change if I accepted the author's direction as a given?"
|
||||
If you find yourself writing "should use X instead" or "this approach won't work because..." → **STOP. You are overstepping your role.**
|
||||
Rephrase to: "Given the chosen approach, the plan doesn't clarify..."
|
||||
|
||||
### Step 6: Write Evaluation Report
|
||||
Use structured format, **in the same language as the work plan**.
|
||||
|
||||
@@ -316,10 +347,19 @@ Use structured format, **in the same language as the work plan**.
|
||||
- Referenced file doesn't exist or contains different content than claimed
|
||||
- Task has vague action verbs AND no reference source
|
||||
- Core tasks missing acceptance criteria entirely
|
||||
- Task requires assumptions about business requirements or critical architecture
|
||||
- Task requires assumptions about business requirements or critical architecture **within the chosen approach**
|
||||
- Missing purpose statement or unclear WHY
|
||||
- Critical task dependencies undefined
|
||||
|
||||
### NOT Valid REJECT Reasons (DO NOT REJECT FOR THESE)
|
||||
- You disagree with the implementation approach
|
||||
- You think a different architecture would be better
|
||||
- The approach seems non-standard or unusual
|
||||
- You believe there's a more optimal solution
|
||||
- The technology choice isn't what you would pick
|
||||
|
||||
**Your role is DOCUMENTATION REVIEW, not DESIGN REVIEW.**
|
||||
|
||||
---
|
||||
|
||||
## Final Verdict Format
|
||||
@@ -344,8 +384,11 @@ Use structured format, **in the same language as the work plan**.
|
||||
- **Contextually complete** with critical information documented
|
||||
- **Strategically coherent** with purpose, background, and flow
|
||||
- **Reference integrity** with all files verified
|
||||
- **Direction-respecting** - you evaluated the plan WITHIN its stated approach
|
||||
|
||||
**Strike the right balance**: Prevent critical failures while empowering developer autonomy.
|
||||
|
||||
**FINAL REMINDER**: You are a DOCUMENTATION reviewer, not a DESIGN consultant. The author's implementation direction is SACRED. Your job ends at "Is this well-documented enough to execute?" - NOT "Is this the right approach?"
|
||||
`
|
||||
|
||||
export function createMomusAgent(model: string = DEFAULT_MODEL): AgentConfig {
|
||||
|
||||
@@ -278,41 +278,19 @@ Search **external references** (docs, OSS, web). Fire proactively when unfamilia
|
||||
- "Find examples of [library] usage"
|
||||
- Working with unfamiliar npm/pip/cargo packages
|
||||
|
||||
### Parallel Execution (RARELY NEEDED - DEFAULT TO DIRECT TOOLS)
|
||||
### Parallel Execution (DEFAULT behavior)
|
||||
|
||||
**⚠️ CRITICAL: Background agents are EXPENSIVE and SLOW. Use direct tools by default.**
|
||||
**Explore/Librarian = Grep, not consultants. Fire liberally.**
|
||||
|
||||
**ONLY use background agents when ALL of these conditions are met:**
|
||||
1. You need 5+ completely independent search queries
|
||||
2. Each query requires deep multi-file exploration (not simple grep)
|
||||
3. You have OTHER work to do while waiting (not just waiting for results)
|
||||
4. The task explicitly requires exhaustive research
|
||||
|
||||
**DEFAULT BEHAVIOR (90% of cases): Use direct tools**
|
||||
- \`grep\`, \`glob\`, \`lsp_*\`, \`ast_grep\` → Fast, immediate results
|
||||
- Single searches → ALWAYS direct tools
|
||||
- Known file locations → ALWAYS direct tools
|
||||
- Quick lookups → ALWAYS direct tools
|
||||
|
||||
**ANTI-PATTERN (DO NOT DO THIS):**
|
||||
\`\`\`typescript
|
||||
// ❌ WRONG: Background for simple searches
|
||||
delegate_task(agent="explore", prompt="Find where X is defined") // Just use grep!
|
||||
delegate_task(agent="librarian", prompt="How to use Y") // Just use context7!
|
||||
|
||||
// ✅ CORRECT: Direct tools for most cases
|
||||
grep(pattern="functionName", path="src/")
|
||||
lsp_goto_definition(filePath, line, character)
|
||||
context7_query-docs(libraryId, query)
|
||||
\`\`\`
|
||||
|
||||
**RARE EXCEPTION (only when truly needed):**
|
||||
\`\`\`typescript
|
||||
// Only for massive parallel research with 5+ independent queries
|
||||
// AND you have other implementation work to do simultaneously
|
||||
delegate_task(agent="explore", prompt="...") // Query 1
|
||||
delegate_task(agent="explore", prompt="...") // Query 2
|
||||
// ... continue implementing other code while these run
|
||||
// CORRECT: Always background, always parallel
|
||||
// Contextual Grep (internal)
|
||||
delegate_task(agent="explore", prompt="Find auth implementations in our codebase...")
|
||||
delegate_task(agent="explore", prompt="Find error handling patterns here...")
|
||||
// Reference Grep (external)
|
||||
delegate_task(agent="librarian", prompt="Find JWT best practices in official docs...")
|
||||
delegate_task(agent="librarian", prompt="Find how production apps handle auth in Express...")
|
||||
// Continue working immediately. Collect with background_output when needed.
|
||||
\`\`\`
|
||||
|
||||
### Background Result Collection:
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
/**
|
||||
* OhMyOpenCode Plan Agent System Prompt
|
||||
*
|
||||
* A streamlined planner that:
|
||||
* - SKIPS user dialogue/Q&A (no user questioning)
|
||||
* - KEEPS context gathering via explore/librarian agents
|
||||
* - Uses Metis ONLY for AI slop guardrails
|
||||
* - Outputs plan directly to user (no file creation)
|
||||
*
|
||||
* For the full Prometheus experience with user dialogue, use "Prometheus (Planner)" agent.
|
||||
*/
|
||||
export const PLAN_SYSTEM_PROMPT = `<system-reminder>
|
||||
# Plan Mode - System Reminder
|
||||
|
||||
## ABSOLUTE CONSTRAINTS (NON-NEGOTIABLE)
|
||||
|
||||
### 1. NO IMPLEMENTATION - PLANNING ONLY
|
||||
You are a PLANNER, NOT an executor. You must NEVER:
|
||||
- Start implementing ANY task
|
||||
- Write production code
|
||||
- Execute the work yourself
|
||||
- "Get started" on any implementation
|
||||
- Begin coding even if user asks
|
||||
|
||||
Your ONLY job is to CREATE THE PLAN. Implementation is done by OTHER agents AFTER you deliver the plan.
|
||||
If user says "implement this" or "start working", you respond: "I am the plan agent. I will create a detailed work plan for execution by other agents."
|
||||
|
||||
### 2. READ-ONLY FILE ACCESS
|
||||
You may NOT create or edit any files. You can only READ files for context gathering.
|
||||
- Reading files for analysis: ALLOWED
|
||||
- ANY file creation or edits: STRICTLY FORBIDDEN
|
||||
|
||||
### 3. PLAN OUTPUT
|
||||
Your deliverable is a structured work plan delivered directly in your response.
|
||||
You do NOT deliver code. You do NOT deliver implementations. You deliver PLANS.
|
||||
|
||||
ZERO EXCEPTIONS to these constraints.
|
||||
</system-reminder>
|
||||
|
||||
You are a strategic planner. You bring foresight and structure to complex work.
|
||||
|
||||
## Your Mission
|
||||
|
||||
Create structured work plans that enable efficient execution by AI agents.
|
||||
|
||||
## Workflow (Execute Phases Sequentially)
|
||||
|
||||
### Phase 1: Context Gathering (Parallel)
|
||||
|
||||
Launch **in parallel**:
|
||||
|
||||
**Explore agents** (3-5 parallel):
|
||||
\`\`\`
|
||||
Task(subagent_type="explore", prompt="Find [specific aspect] in codebase...")
|
||||
\`\`\`
|
||||
- Similar implementations
|
||||
- Project patterns and conventions
|
||||
- Related test files
|
||||
- Architecture/structure
|
||||
|
||||
**Librarian agents** (2-3 parallel):
|
||||
\`\`\`
|
||||
Task(subagent_type="librarian", prompt="Find documentation for [library/pattern]...")
|
||||
\`\`\`
|
||||
- Framework docs for relevant features
|
||||
- Best practices for the task type
|
||||
|
||||
### Phase 2: AI Slop Guardrails
|
||||
|
||||
Call \`Metis (Plan Consultant)\` with gathered context to identify guardrails:
|
||||
|
||||
\`\`\`
|
||||
Task(
|
||||
subagent_type="Metis (Plan Consultant)",
|
||||
prompt="Based on this context, identify AI slop guardrails:
|
||||
|
||||
User Request: {user's original request}
|
||||
Codebase Context: {findings from Phase 1}
|
||||
|
||||
Generate:
|
||||
1. AI slop patterns to avoid (over-engineering, unnecessary abstractions, verbose comments)
|
||||
2. Common AI mistakes for this type of task
|
||||
3. Project-specific conventions that must be followed
|
||||
4. Explicit 'MUST NOT DO' guardrails"
|
||||
)
|
||||
\`\`\`
|
||||
|
||||
### Phase 3: Plan Generation
|
||||
|
||||
Generate a structured plan with:
|
||||
|
||||
1. **Core Objective** - What we're achieving (1-2 sentences)
|
||||
2. **Concrete Deliverables** - Exact files/endpoints/features
|
||||
3. **Definition of Done** - Acceptance criteria
|
||||
4. **Must Have** - Required elements
|
||||
5. **Must NOT Have** - Forbidden patterns (from Metis guardrails)
|
||||
6. **Task Breakdown** - Sequential/parallel task flow
|
||||
7. **References** - Existing code to follow
|
||||
|
||||
## Key Principles
|
||||
|
||||
1. **Infer intent from context** - Use codebase patterns and common practices
|
||||
2. **Define concrete deliverables** - Exact outputs, not vague goals
|
||||
3. **Clarify what NOT to do** - Most important for preventing AI mistakes
|
||||
4. **References over instructions** - Point to existing code
|
||||
5. **Verifiable acceptance criteria** - Commands with expected outputs
|
||||
6. **Implementation + Test = ONE task** - NEVER separate
|
||||
7. **Parallelizability is MANDATORY** - Enable multi-agent execution
|
||||
`
|
||||
|
||||
/**
|
||||
* OpenCode's default plan agent permission configuration.
|
||||
*
|
||||
* Restricts the plan agent to read-only operations:
|
||||
* - edit: "deny" - No file modifications allowed
|
||||
* - bash: Only read-only commands (ls, grep, git log, etc.)
|
||||
* - webfetch: "allow" - Can fetch web content for research
|
||||
*
|
||||
* @see https://github.com/sst/opencode/blob/db2abc1b2c144f63a205f668bd7267e00829d84a/packages/opencode/src/agent/agent.ts#L63-L107
|
||||
*/
|
||||
export const PLAN_PERMISSION = {
|
||||
edit: "deny" as const,
|
||||
bash: {
|
||||
"cut*": "allow" as const,
|
||||
"diff*": "allow" as const,
|
||||
"du*": "allow" as const,
|
||||
"file *": "allow" as const,
|
||||
"find * -delete*": "ask" as const,
|
||||
"find * -exec*": "ask" as const,
|
||||
"find * -fprint*": "ask" as const,
|
||||
"find * -fls*": "ask" as const,
|
||||
"find * -fprintf*": "ask" as const,
|
||||
"find * -ok*": "ask" as const,
|
||||
"find *": "allow" as const,
|
||||
"git diff*": "allow" as const,
|
||||
"git log*": "allow" as const,
|
||||
"git show*": "allow" as const,
|
||||
"git status*": "allow" as const,
|
||||
"git branch": "allow" as const,
|
||||
"git branch -v": "allow" as const,
|
||||
"grep*": "allow" as const,
|
||||
"head*": "allow" as const,
|
||||
"less*": "allow" as const,
|
||||
"ls*": "allow" as const,
|
||||
"more*": "allow" as const,
|
||||
"pwd*": "allow" as const,
|
||||
"rg*": "allow" as const,
|
||||
"sort --output=*": "ask" as const,
|
||||
"sort -o *": "ask" as const,
|
||||
"sort*": "allow" as const,
|
||||
"stat*": "allow" as const,
|
||||
"tail*": "allow" as const,
|
||||
"tree -o *": "ask" as const,
|
||||
"tree*": "allow" as const,
|
||||
"uniq*": "allow" as const,
|
||||
"wc*": "allow" as const,
|
||||
"whereis*": "allow" as const,
|
||||
"which*": "allow" as const,
|
||||
"*": "ask" as const,
|
||||
},
|
||||
webfetch: "allow" as const,
|
||||
}
|
||||
@@ -1,57 +1,91 @@
|
||||
# CLI KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
CLI for oh-my-opencode: interactive installer, health diagnostics (doctor), runtime launcher. Entry: `bunx oh-my-opencode`.
|
||||
|
||||
CLI entry point: `bunx oh-my-opencode`. Interactive installer, doctor diagnostics, session runner. Uses Commander.js + @clack/prompts TUI.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
cli/
|
||||
├── index.ts # Commander.js entry, subcommand routing (146 lines)
|
||||
├── index.ts # Commander.js entry, 5 subcommands
|
||||
├── install.ts # Interactive TUI installer (462 lines)
|
||||
├── config-manager.ts # JSONC parsing, env detection (730 lines)
|
||||
├── types.ts # CLI-specific types
|
||||
├── doctor/ # Health check system
|
||||
├── config-manager.ts # JSONC parsing, multi-level merge (730 lines)
|
||||
├── types.ts # InstallArgs, InstallConfig, DetectedConfig
|
||||
├── doctor/
|
||||
│ ├── index.ts # Doctor command entry
|
||||
│ ├── runner.ts # Health check orchestration
|
||||
│ ├── constants.ts # Check categories
|
||||
│ ├── types.ts # Check result interfaces
|
||||
│ └── checks/ # 10 check modules (14 individual checks)
|
||||
├── get-local-version/ # Version detection
|
||||
└── run/ # OpenCode session launcher
|
||||
├── completion.ts # Completion logic
|
||||
└── events.ts # Event handling
|
||||
│ ├── runner.ts # Check orchestration
|
||||
│ ├── formatter.ts # Colored output, symbols
|
||||
│ ├── constants.ts # Check IDs, categories, symbols
|
||||
│ ├── types.ts # CheckResult, CheckDefinition
|
||||
│ └── checks/ # 14 checks across 6 categories
|
||||
│ ├── version.ts # OpenCode + plugin version
|
||||
│ ├── config.ts # JSONC validity, Zod validation
|
||||
│ ├── auth.ts # Anthropic, OpenAI, Google
|
||||
│ ├── dependencies.ts # AST-Grep, Comment Checker
|
||||
│ ├── lsp.ts # LSP server connectivity
|
||||
│ ├── mcp.ts # MCP server validation
|
||||
│ └── gh.ts # GitHub CLI availability
|
||||
├── run/
|
||||
│ ├── index.ts # Run command entry
|
||||
│ └── runner.ts # Session launcher
|
||||
└── get-local-version/
|
||||
├── index.ts # Version detection
|
||||
└── formatter.ts # Version output
|
||||
```
|
||||
|
||||
## CLI COMMANDS
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `install` | Interactive setup wizard with subscription detection |
|
||||
| `doctor` | Environment health checks (LSP, Auth, Config, Deps) |
|
||||
| `run` | Launch OpenCode session with todo/background completion enforcement |
|
||||
| `get-local-version` | Detect and return local plugin version & update status |
|
||||
| `install` | Interactive setup, subscription detection |
|
||||
| `doctor` | 14 health checks, `--verbose`, `--json`, `--category` |
|
||||
| `run` | Launch OpenCode session with completion enforcement |
|
||||
| `get-local-version` | Version detection, update checking |
|
||||
|
||||
## DOCTOR CHECKS
|
||||
14 checks in `doctor/checks/`:
|
||||
- `version.ts`: OpenCode >= 1.0.150 & plugin update status
|
||||
- `config.ts`: Plugin registration & JSONC validity
|
||||
- `dependencies.ts`: AST-Grep (CLI/NAPI), Comment Checker
|
||||
- `auth.ts`: Anthropic, OpenAI, Google (Antigravity)
|
||||
- `lsp.ts`, `mcp.ts`: Tool connectivity checks
|
||||
- `gh.ts`: GitHub CLI availability
|
||||
## DOCTOR CHECK CATEGORIES
|
||||
|
||||
## CONFIG-MANAGER
|
||||
- **JSONC**: Supports comments and trailing commas via `parseJsonc`
|
||||
- **Multi-source**: Merges User (`~/.config/opencode/`) + Project (`.opencode/`)
|
||||
- **Validation**: Strict Zod schema with error aggregation for `doctor`
|
||||
- **Env**: Detects `OPENCODE_CONFIG_DIR` for profile isolation
|
||||
| Category | Checks |
|
||||
|----------|--------|
|
||||
| installation | opencode, plugin registration |
|
||||
| configuration | config validity, Zod validation |
|
||||
| authentication | anthropic, openai, google |
|
||||
| dependencies | ast-grep CLI/NAPI, comment-checker |
|
||||
| tools | LSP, MCP connectivity |
|
||||
| updates | version comparison |
|
||||
|
||||
## HOW TO ADD CHECK
|
||||
1. Create `src/cli/doctor/checks/my-check.ts` returning `DoctorCheck`
|
||||
2. Export from `checks/index.ts` and add to `getAllCheckDefinitions()`
|
||||
3. Use `CheckContext` for shared utilities (LSP, Auth)
|
||||
|
||||
1. Create `src/cli/doctor/checks/my-check.ts`:
|
||||
```typescript
|
||||
export function getMyCheckDefinition(): CheckDefinition {
|
||||
return {
|
||||
id: "my-check",
|
||||
name: "My Check",
|
||||
category: "configuration",
|
||||
check: async () => ({ status: "pass", message: "OK" })
|
||||
}
|
||||
}
|
||||
```
|
||||
2. Export from `checks/index.ts`
|
||||
3. Add to `getAllCheckDefinitions()`
|
||||
|
||||
## TUI FRAMEWORK
|
||||
|
||||
- **@clack/prompts**: `select()`, `spinner()`, `intro()`, `outro()`, `note()`
|
||||
- **picocolors**: Colored terminal output
|
||||
- **Symbols**: ✓ (pass), ✗ (fail), ⚠ (warn), ○ (skip)
|
||||
|
||||
## CONFIG-MANAGER
|
||||
|
||||
- **JSONC**: Comments (`// ...`), block comments, trailing commas
|
||||
- **Multi-source**: User (`~/.config/opencode/`) + Project (`.opencode/`)
|
||||
- **Env override**: `OPENCODE_CONFIG_DIR` for profile isolation
|
||||
- **Validation**: Zod schema with error aggregation
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- Blocking prompts in non-TTY (check `process.stdout.isTTY`)
|
||||
- Direct `JSON.parse` (breaks JSONC compatibility)
|
||||
- Silent failures (always return `warn` or `fail` in `doctor`)
|
||||
- Environment-specific hardcoding (use `ConfigManager`)
|
||||
|
||||
- **Blocking in non-TTY**: Check `process.stdout.isTTY`
|
||||
- **Direct JSON.parse**: Use `parseJsonc()` for config
|
||||
- **Silent failures**: Always return warn/fail in doctor
|
||||
- **Hardcoded paths**: Use `ConfigManager`
|
||||
|
||||
@@ -1,42 +1,63 @@
|
||||
# FEATURES KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Claude Code compatibility layer + core feature modules. Commands, skills, agents, MCPs, hooks from Claude Code work seamlessly.
|
||||
|
||||
Core feature modules + Claude Code compatibility layer. Background agents, skill MCP, builtin skills/commands, and 5 loaders for Claude Code compat.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
features/
|
||||
├── background-agent/ # Task lifecycle, notifications (928 lines manager.ts)
|
||||
├── boulder-state/ # Boulder state persistence
|
||||
├── builtin-commands/ # Built-in slash commands
|
||||
│ └── templates/ # start-work, refactor, init-deep, ralph-loop
|
||||
├── builtin-skills/ # Built-in skills (1230 lines skills.ts)
|
||||
│ ├── git-master/ # Atomic commits, rebase, history search
|
||||
│ ├── playwright # Browser automation skill
|
||||
│ └── frontend-ui-ux/ # Designer-turned-developer skill
|
||||
├── background-agent/ # Task lifecycle (1165 lines manager.ts)
|
||||
│ ├── manager.ts # Launch → poll → complete orchestration
|
||||
│ ├── concurrency.ts # Per-provider/model limits
|
||||
│ └── types.ts # BackgroundTask, LaunchInput
|
||||
├── skill-mcp-manager/ # MCP client lifecycle
|
||||
│ ├── manager.ts # Lazy loading, idle cleanup
|
||||
│ └── types.ts # SkillMcpConfig, transports
|
||||
├── builtin-skills/ # Playwright, git-master, frontend-ui-ux
|
||||
│ └── skills.ts # 1203 lines of skill definitions
|
||||
├── builtin-commands/ # ralph-loop, refactor, init-deep
|
||||
│ └── templates/ # Command implementations
|
||||
├── claude-code-agent-loader/ # ~/.claude/agents/*.md
|
||||
├── claude-code-command-loader/ # ~/.claude/commands/*.md
|
||||
├── claude-code-mcp-loader/ # .mcp.json files
|
||||
│ └── env-expander.ts # ${VAR} expansion
|
||||
├── claude-code-mcp-loader/ # .mcp.json with ${VAR} expansion
|
||||
├── claude-code-plugin-loader/ # installed_plugins.json
|
||||
├── claude-code-session-state/ # Session state persistence
|
||||
├── context-injector/ # Context collection and injection
|
||||
├── opencode-skill-loader/ # Skills from OpenCode + Claude paths
|
||||
├── skill-mcp-manager/ # MCP servers in skill YAML
|
||||
├── task-toast-manager/ # Task toast notifications
|
||||
└── hook-message-injector/ # Inject messages into conversation
|
||||
├── opencode-skill-loader/ # Skills from 6 directories
|
||||
├── context-injector/ # AGENTS.md/README.md injection
|
||||
├── boulder-state/ # Todo state persistence
|
||||
├── task-toast-manager/ # Toast notifications
|
||||
└── hook-message-injector/ # Message injection
|
||||
```
|
||||
|
||||
## LOADER PRIORITY
|
||||
| Loader | Priority (highest first) |
|
||||
|--------|--------------------------|
|
||||
|
||||
| Type | Priority (highest first) |
|
||||
|------|--------------------------|
|
||||
| Commands | `.opencode/command/` > `~/.config/opencode/command/` > `.claude/commands/` > `~/.claude/commands/` |
|
||||
| Skills | `.opencode/skill/` > `~/.config/opencode/skill/` > `.claude/skills/` > `~/.claude/skills/` |
|
||||
| Agents | `.claude/agents/` > `~/.claude/agents/` |
|
||||
| MCPs | `.claude/.mcp.json` > `.mcp.json` > `~/.claude/.mcp.json` |
|
||||
|
||||
## BACKGROUND AGENT
|
||||
|
||||
- **Lifecycle**: `launch` → `poll` (2s interval) → `complete`
|
||||
- **Stability**: 3 consecutive polls with same message count = idle
|
||||
- **Concurrency**: Per-provider/model limits (e.g., max 3 Opus, max 10 Gemini)
|
||||
- **Notification**: Batched system reminders to parent session
|
||||
- **Cleanup**: 30m TTL, 3m stale timeout, signal handlers
|
||||
|
||||
## SKILL MCP
|
||||
|
||||
- **Lazy**: Clients created on first tool call
|
||||
- **Transports**: stdio (local process), http (SSE/Streamable)
|
||||
- **Environment**: `${VAR}` expansion in config
|
||||
- **Lifecycle**: 5m idle cleanup, session-scoped
|
||||
|
||||
## CONFIG TOGGLES
|
||||
```json
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"claude_code": {
|
||||
"mcp": false, // Skip .mcp.json
|
||||
@@ -48,20 +69,9 @@ features/
|
||||
}
|
||||
```
|
||||
|
||||
## BACKGROUND AGENT
|
||||
- Lifecycle: pending → running → completed/failed
|
||||
- Concurrency limits per provider/model (manager.ts)
|
||||
- `background_output` to retrieve results, `background_cancel` for cleanup
|
||||
- Automatic task expiration and cleanup logic
|
||||
|
||||
## SKILL MCP
|
||||
- MCP servers embedded in skill YAML frontmatter
|
||||
- Lazy client loading via `skill-mcp-manager`
|
||||
- `skill_mcp` tool for cross-skill tool discovery
|
||||
- Session-scoped MCP server lifecycle management
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- Sequential execution for independent tasks (use `delegate_task`)
|
||||
- Trusting agent self-reports without verification
|
||||
- Blocking main thread during loader initialization
|
||||
- Manual version bumping in `package.json`
|
||||
|
||||
- **Sequential delegation**: Use `delegate_task` for parallel
|
||||
- **Trust self-reports**: ALWAYS verify agent outputs
|
||||
- **Main thread blocks**: No heavy I/O in loader init
|
||||
- **Manual versioning**: CI manages package.json version
|
||||
|
||||
@@ -17,17 +17,28 @@ $ARGUMENTS
|
||||
</user-request>`,
|
||||
argumentHint: "[--create-new] [--max-depth=N]",
|
||||
},
|
||||
"ralph-loop": {
|
||||
description: "(builtin) Start self-referential development loop until completion",
|
||||
template: `<command-instruction>
|
||||
"ralph-loop": {
|
||||
description: "(builtin) Start self-referential development loop until completion",
|
||||
template: `<command-instruction>
|
||||
${RALPH_LOOP_TEMPLATE}
|
||||
</command-instruction>
|
||||
|
||||
<user-task>
|
||||
$ARGUMENTS
|
||||
</user-task>`,
|
||||
argumentHint: '"task description" [--completion-promise=TEXT] [--max-iterations=N]',
|
||||
},
|
||||
argumentHint: '"task description" [--completion-promise=TEXT] [--max-iterations=N]',
|
||||
},
|
||||
"ulw-loop": {
|
||||
description: "(builtin) Start ultrawork loop - continues until completion with ultrawork mode",
|
||||
template: `<command-instruction>
|
||||
${RALPH_LOOP_TEMPLATE}
|
||||
</command-instruction>
|
||||
|
||||
<user-task>
|
||||
$ARGUMENTS
|
||||
</user-task>`,
|
||||
argumentHint: '"task description" [--completion-promise=TEXT] [--max-iterations=N]',
|
||||
},
|
||||
"cancel-ralph": {
|
||||
description: "(builtin) Cancel active Ralph Loop",
|
||||
template: `<command-instruction>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CommandDefinition } from "../claude-code-command-loader"
|
||||
|
||||
export type BuiltinCommandName = "init-deep" | "ralph-loop" | "cancel-ralph" | "refactor" | "start-work"
|
||||
export type BuiltinCommandName = "init-deep" | "ralph-loop" | "cancel-ralph" | "ulw-loop" | "refactor" | "start-work"
|
||||
|
||||
export interface BuiltinCommandConfig {
|
||||
disabled_commands?: BuiltinCommandName[]
|
||||
|
||||
@@ -502,4 +502,110 @@ describe("SkillMcpManager", () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("operation retry logic", () => {
|
||||
it("should retry operation when 'Not connected' error occurs", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "retry-server",
|
||||
skillName: "retry-skill",
|
||||
sessionID: "session-retry-1",
|
||||
}
|
||||
const context: SkillMcpServerContext = {
|
||||
config: {
|
||||
url: "https://example.com/mcp",
|
||||
},
|
||||
skillName: "retry-skill",
|
||||
}
|
||||
|
||||
// Mock client that fails first time with "Not connected", then succeeds
|
||||
let callCount = 0
|
||||
const mockClient = {
|
||||
callTool: mock(async () => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
throw new Error("Not connected")
|
||||
}
|
||||
return { content: [{ type: "text", text: "success" }] }
|
||||
}),
|
||||
close: mock(() => Promise.resolve()),
|
||||
}
|
||||
|
||||
// Spy on getOrCreateClientWithRetry to inject mock client
|
||||
const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry")
|
||||
getOrCreateSpy.mockResolvedValue(mockClient)
|
||||
|
||||
// #when
|
||||
const result = await manager.callTool(info, context, "test-tool", {})
|
||||
|
||||
// #then
|
||||
expect(callCount).toBe(2) // First call fails, second succeeds
|
||||
expect(result).toEqual([{ type: "text", text: "success" }])
|
||||
expect(getOrCreateSpy).toHaveBeenCalledTimes(2) // Called twice due to retry
|
||||
})
|
||||
|
||||
it("should fail after 3 retry attempts", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "fail-server",
|
||||
skillName: "fail-skill",
|
||||
sessionID: "session-fail-1",
|
||||
}
|
||||
const context: SkillMcpServerContext = {
|
||||
config: {
|
||||
url: "https://example.com/mcp",
|
||||
},
|
||||
skillName: "fail-skill",
|
||||
}
|
||||
|
||||
// Mock client that always fails with "Not connected"
|
||||
const mockClient = {
|
||||
callTool: mock(async () => {
|
||||
throw new Error("Not connected")
|
||||
}),
|
||||
close: mock(() => Promise.resolve()),
|
||||
}
|
||||
|
||||
const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry")
|
||||
getOrCreateSpy.mockResolvedValue(mockClient)
|
||||
|
||||
// #when / #then
|
||||
await expect(manager.callTool(info, context, "test-tool", {})).rejects.toThrow(
|
||||
/Failed after 3 reconnection attempts/
|
||||
)
|
||||
expect(getOrCreateSpy).toHaveBeenCalledTimes(3) // Initial + 2 retries
|
||||
})
|
||||
|
||||
it("should not retry on non-connection errors", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "error-server",
|
||||
skillName: "error-skill",
|
||||
sessionID: "session-error-1",
|
||||
}
|
||||
const context: SkillMcpServerContext = {
|
||||
config: {
|
||||
url: "https://example.com/mcp",
|
||||
},
|
||||
skillName: "error-skill",
|
||||
}
|
||||
|
||||
// Mock client that fails with non-connection error
|
||||
const mockClient = {
|
||||
callTool: mock(async () => {
|
||||
throw new Error("Tool not found")
|
||||
}),
|
||||
close: mock(() => Promise.resolve()),
|
||||
}
|
||||
|
||||
const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry")
|
||||
getOrCreateSpy.mockResolvedValue(mockClient)
|
||||
|
||||
// #when / #then
|
||||
await expect(manager.callTool(info, context, "test-tool", {})).rejects.toThrow(
|
||||
"Tool not found"
|
||||
)
|
||||
expect(getOrCreateSpy).toHaveBeenCalledTimes(1) // No retry
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -415,9 +415,10 @@ export class SkillMcpManager {
|
||||
name: string,
|
||||
args: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
const client = await this.getOrCreateClientWithRetry(info, context.config)
|
||||
const result = await client.callTool({ name, arguments: args })
|
||||
return result.content
|
||||
return this.withOperationRetry(info, context.config, async (client) => {
|
||||
const result = await client.callTool({ name, arguments: args })
|
||||
return result.content
|
||||
})
|
||||
}
|
||||
|
||||
async readResource(
|
||||
@@ -425,9 +426,10 @@ export class SkillMcpManager {
|
||||
context: SkillMcpServerContext,
|
||||
uri: string
|
||||
): Promise<unknown> {
|
||||
const client = await this.getOrCreateClientWithRetry(info, context.config)
|
||||
const result = await client.readResource({ uri })
|
||||
return result.contents
|
||||
return this.withOperationRetry(info, context.config, async (client) => {
|
||||
const result = await client.readResource({ uri })
|
||||
return result.contents
|
||||
})
|
||||
}
|
||||
|
||||
async getPrompt(
|
||||
@@ -436,9 +438,53 @@ export class SkillMcpManager {
|
||||
name: string,
|
||||
args: Record<string, string>
|
||||
): Promise<unknown> {
|
||||
const client = await this.getOrCreateClientWithRetry(info, context.config)
|
||||
const result = await client.getPrompt({ name, arguments: args })
|
||||
return result.messages
|
||||
return this.withOperationRetry(info, context.config, async (client) => {
|
||||
const result = await client.getPrompt({ name, arguments: args })
|
||||
return result.messages
|
||||
})
|
||||
}
|
||||
|
||||
private async withOperationRetry<T>(
|
||||
info: SkillMcpClientInfo,
|
||||
config: ClaudeCodeMcpServer,
|
||||
operation: (client: Client) => Promise<T>
|
||||
): Promise<T> {
|
||||
const maxRetries = 3
|
||||
let lastError: Error | null = null
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const client = await this.getOrCreateClientWithRetry(info, config)
|
||||
return await operation(client)
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error))
|
||||
const errorMessage = lastError.message.toLowerCase()
|
||||
|
||||
if (!errorMessage.includes("not connected")) {
|
||||
throw lastError
|
||||
}
|
||||
|
||||
if (attempt === maxRetries) {
|
||||
throw new Error(
|
||||
`Failed after ${maxRetries} reconnection attempts: ${lastError.message}`
|
||||
)
|
||||
}
|
||||
|
||||
const key = this.getClientKey(info)
|
||||
const existing = this.clients.get(key)
|
||||
if (existing) {
|
||||
this.clients.delete(key)
|
||||
try {
|
||||
await existing.client.close()
|
||||
} catch { /* process may already be terminated */ }
|
||||
try {
|
||||
await existing.transport.close()
|
||||
} catch { /* transport may already be terminated */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error("Operation failed with unknown error")
|
||||
}
|
||||
|
||||
private async getOrCreateClientWithRetry(
|
||||
|
||||
@@ -1,54 +1,73 @@
|
||||
# HOOKS KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
22+ lifecycle hooks intercepting/modifying agent behavior via PreToolUse, PostToolUse, UserPromptSubmit, and more.
|
||||
|
||||
31 lifecycle hooks intercepting/modifying agent behavior. Events: PreToolUse, PostToolUse, UserPromptSubmit, Stop, onSummarize.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
hooks/
|
||||
├── sisyphus-orchestrator/ # Main orchestration & agent delegation (684 lines)
|
||||
├── anthropic-context-window-limit-recovery/ # Auto-summarize at token limit (554 lines)
|
||||
├── todo-continuation-enforcer.ts # Force completion of [ ] items (445 lines)
|
||||
├── ralph-loop/ # Self-referential dev loop (364 lines)
|
||||
├── claude-code-hooks/ # settings.json hook compatibility layer
|
||||
├── sisyphus-orchestrator/ # Main orchestration & delegation (771 lines)
|
||||
├── anthropic-context-window-limit-recovery/ # Auto-summarize at token limit
|
||||
├── todo-continuation-enforcer.ts # Force TODO completion
|
||||
├── ralph-loop/ # Self-referential dev loop until done
|
||||
├── claude-code-hooks/ # settings.json hook compat layer (13 files)
|
||||
├── comment-checker/ # Prevents AI slop/excessive comments
|
||||
├── auto-slash-command/ # Detects and executes /command patterns
|
||||
├── auto-slash-command/ # Detects /command patterns
|
||||
├── rules-injector/ # Conditional rules from .claude/rules/
|
||||
├── directory-agents-injector/ # Auto-injects local AGENTS.md files
|
||||
├── directory-readme-injector/ # Auto-injects local README.md files
|
||||
├── preemptive-compaction/ # Triggers summary at 85% usage
|
||||
├── edit-error-recovery/ # Recovers from tool execution failures
|
||||
├── directory-agents-injector/ # Auto-injects AGENTS.md files
|
||||
├── directory-readme-injector/ # Auto-injects README.md files
|
||||
├── preemptive-compaction/ # Triggers summary at 85% context
|
||||
├── edit-error-recovery/ # Recovers from tool failures
|
||||
├── thinking-block-validator/ # Ensures valid <thinking> format
|
||||
├── context-window-monitor.ts # Reminds agents of remaining headroom
|
||||
├── session-recovery/ # Auto-recovers from session crashes
|
||||
├── start-work/ # Initializes work sessions (ulw/ulw)
|
||||
├── think-mode/ # Dynamic thinking budget adjustment
|
||||
├── session-recovery/ # Auto-recovers from crashes
|
||||
├── think-mode/ # Dynamic thinking budget
|
||||
├── keyword-detector/ # ultrawork/search/analyze modes
|
||||
├── background-notification/ # OS notification on task completion
|
||||
└── tool-output-truncator.ts # Prevents context bloat from verbose tools
|
||||
└── tool-output-truncator.ts # Prevents context bloat
|
||||
```
|
||||
|
||||
## HOOK EVENTS
|
||||
| Event | Timing | Can Block | Description |
|
||||
|-------|--------|-----------|-------------|
|
||||
| PreToolUse | Before tool | Yes | Validate/modify inputs (e.g., directory-agents-injector) |
|
||||
| PostToolUse | After tool | No | Append context/warnings (e.g., edit-error-recovery) |
|
||||
| UserPromptSubmit | On prompt | Yes | Filter/modify user input (e.g., keyword-detector) |
|
||||
| Stop | Session idle | No | Auto-continue tasks (e.g., todo-continuation-enforcer) |
|
||||
| onSummarize | Compaction | No | State preservation (e.g., compaction-context-injector) |
|
||||
|
||||
| Event | Timing | Can Block | Use Case |
|
||||
|-------|--------|-----------|----------|
|
||||
| PreToolUse | Before tool | Yes | Validate/modify inputs, inject context |
|
||||
| PostToolUse | After tool | No | Append warnings, truncate output |
|
||||
| UserPromptSubmit | On prompt | Yes | Keyword detection, mode switching |
|
||||
| Stop | Session idle | No | Auto-continue (todo-continuation, ralph-loop) |
|
||||
| onSummarize | Compaction | No | Preserve critical state |
|
||||
|
||||
## EXECUTION ORDER
|
||||
|
||||
**chat.message**: keywordDetector → claudeCodeHooks → autoSlashCommand → startWork → ralphLoop
|
||||
|
||||
**tool.execute.before**: claudeCodeHooks → nonInteractiveEnv → commentChecker → directoryAgentsInjector → directoryReadmeInjector → rulesInjector
|
||||
|
||||
**tool.execute.after**: editErrorRecovery → delegateTaskRetry → commentChecker → toolOutputTruncator → emptyTaskResponseDetector → claudeCodeHooks
|
||||
|
||||
## HOW TO ADD
|
||||
1. Create `src/hooks/name/` with `index.ts` factory (e.g., `createMyHook`).
|
||||
2. Implement `PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `Stop`, or `onSummarize`.
|
||||
3. Register in `src/hooks/index.ts`.
|
||||
|
||||
1. Create `src/hooks/name/` with `index.ts` exporting `createMyHook(ctx)`
|
||||
2. Implement event handlers: `"tool.execute.before"`, `"tool.execute.after"`, etc.
|
||||
3. Add hook name to `HookNameSchema` in `src/config/schema.ts`
|
||||
4. Register in `src/index.ts`:
|
||||
```typescript
|
||||
const myHook = isHookEnabled("my-hook") ? createMyHook(ctx) : null
|
||||
// Add to event handlers
|
||||
```
|
||||
|
||||
## PATTERNS
|
||||
- **Context Injection**: Use `PreToolUse` to prepend instructions to tool inputs.
|
||||
- **Resilience**: Implement `edit-error-recovery` style logic to retry failed tools.
|
||||
- **Telegraphic UI**: Use `PostToolUse` to add brief warnings without bloating transcript.
|
||||
- **Statelessness**: Prefer local file storage for state that must persist across sessions.
|
||||
|
||||
- **Session-scoped state**: `Map<sessionID, Set<string>>` for tracking per-session
|
||||
- **Conditional execution**: Check `input.tool` before processing
|
||||
- **Output modification**: `output.output += "\n${REMINDER}"` to append context
|
||||
- **Async state**: Use promises for CLI path resolution, cache results
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- **Blocking**: Avoid blocking tools unless critical (use warnings in `PostToolUse` instead).
|
||||
- **Latency**: No heavy computation in `PreToolUse`; it slows every interaction.
|
||||
- **Redundancy**: Don't inject the same file multiple times; track state in session storage.
|
||||
- **Prose**: Never use verbose prose in hook outputs; keep it technical and brief.
|
||||
|
||||
- **Blocking non-critical**: Use PostToolUse warnings instead of PreToolUse blocks
|
||||
- **Heavy computation**: Keep PreToolUse light - slows every tool call
|
||||
- **Redundant injection**: Track injected files to prevent duplicates
|
||||
- **Verbose output**: Keep hook messages technical, brief
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import type { DynamicContextPruningConfig } from "../../config"
|
||||
import type { PruningState, PruningResult } from "./pruning-types"
|
||||
import { executeDeduplication } from "./pruning-deduplication"
|
||||
import { executeSupersedeWrites } from "./pruning-supersede"
|
||||
import { executePurgeErrors } from "./pruning-purge-errors"
|
||||
import { applyPruning } from "./pruning-storage"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
const DEFAULT_PROTECTED_TOOLS = new Set([
|
||||
"task",
|
||||
"todowrite",
|
||||
"todoread",
|
||||
"lsp_rename",
|
||||
"session_read",
|
||||
"session_write",
|
||||
"session_search",
|
||||
])
|
||||
|
||||
function createPruningState(): PruningState {
|
||||
return {
|
||||
toolIdsToPrune: new Set<string>(),
|
||||
currentTurn: 0,
|
||||
fileOperations: new Map(),
|
||||
toolSignatures: new Map(),
|
||||
erroredTools: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeDynamicContextPruning(
|
||||
sessionID: string,
|
||||
config: DynamicContextPruningConfig,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any
|
||||
): Promise<PruningResult> {
|
||||
const state = createPruningState()
|
||||
|
||||
const protectedTools = new Set([
|
||||
...DEFAULT_PROTECTED_TOOLS,
|
||||
...(config.protected_tools || []),
|
||||
])
|
||||
|
||||
log("[pruning-executor] starting DCP", {
|
||||
sessionID,
|
||||
notification: config.notification,
|
||||
turnProtection: config.turn_protection,
|
||||
})
|
||||
|
||||
let dedupCount = 0
|
||||
let supersedeCount = 0
|
||||
let purgeCount = 0
|
||||
|
||||
if (config.strategies?.deduplication?.enabled !== false) {
|
||||
dedupCount = executeDeduplication(
|
||||
sessionID,
|
||||
state,
|
||||
{ enabled: true },
|
||||
protectedTools
|
||||
)
|
||||
}
|
||||
|
||||
if (config.strategies?.supersede_writes?.enabled !== false) {
|
||||
supersedeCount = executeSupersedeWrites(
|
||||
sessionID,
|
||||
state,
|
||||
{
|
||||
enabled: true,
|
||||
aggressive: config.strategies?.supersede_writes?.aggressive || false,
|
||||
},
|
||||
protectedTools
|
||||
)
|
||||
}
|
||||
|
||||
if (config.strategies?.purge_errors?.enabled !== false) {
|
||||
purgeCount = executePurgeErrors(
|
||||
sessionID,
|
||||
state,
|
||||
{
|
||||
enabled: true,
|
||||
turns: config.strategies?.purge_errors?.turns || 5,
|
||||
},
|
||||
protectedTools
|
||||
)
|
||||
}
|
||||
|
||||
const totalPruned = state.toolIdsToPrune.size
|
||||
const tokensSaved = await applyPruning(sessionID, state)
|
||||
|
||||
log("[pruning-executor] DCP complete", {
|
||||
totalPruned,
|
||||
tokensSaved,
|
||||
deduplication: dedupCount,
|
||||
supersede: supersedeCount,
|
||||
purge: purgeCount,
|
||||
})
|
||||
|
||||
const result: PruningResult = {
|
||||
itemsPruned: totalPruned,
|
||||
totalTokensSaved: tokensSaved,
|
||||
strategies: {
|
||||
deduplication: dedupCount,
|
||||
supersedeWrites: supersedeCount,
|
||||
purgeErrors: purgeCount,
|
||||
},
|
||||
}
|
||||
|
||||
if (config.notification !== "off" && totalPruned > 0) {
|
||||
const message =
|
||||
config.notification === "detailed"
|
||||
? `Pruned ${totalPruned} tool outputs (~${Math.round(tokensSaved / 1000)}k tokens). Dedup: ${dedupCount}, Supersede: ${supersedeCount}, Purge: ${purgeCount}`
|
||||
: `Pruned ${totalPruned} tool outputs (~${Math.round(tokensSaved / 1000)}k tokens)`
|
||||
|
||||
await client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Dynamic Context Pruning",
|
||||
message,
|
||||
variant: "success",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { PruningState, ErroredToolCall } from "./pruning-types"
|
||||
import { estimateTokens } from "./pruning-types"
|
||||
import { log } from "../../shared/logger"
|
||||
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
|
||||
export interface PurgeErrorsConfig {
|
||||
enabled: boolean
|
||||
turns: number
|
||||
protectedTools?: string[]
|
||||
}
|
||||
|
||||
interface ToolPart {
|
||||
type: string
|
||||
callID?: string
|
||||
tool?: string
|
||||
state?: {
|
||||
input?: unknown
|
||||
output?: string
|
||||
status?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface MessagePart {
|
||||
type: string
|
||||
parts?: ToolPart[]
|
||||
}
|
||||
|
||||
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 readMessages(sessionID: string): MessagePart[] {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
if (!messageDir) return []
|
||||
|
||||
const messages: MessagePart[] = []
|
||||
|
||||
try {
|
||||
const files = readdirSync(messageDir).filter(f => f.endsWith(".json"))
|
||||
for (const file of files) {
|
||||
const content = readFileSync(join(messageDir, file), "utf-8")
|
||||
const data = JSON.parse(content)
|
||||
if (data.parts) {
|
||||
messages.push(data)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
export function executePurgeErrors(
|
||||
sessionID: string,
|
||||
state: PruningState,
|
||||
config: PurgeErrorsConfig,
|
||||
protectedTools: Set<string>
|
||||
): number {
|
||||
if (!config.enabled) return 0
|
||||
|
||||
const messages = readMessages(sessionID)
|
||||
|
||||
let currentTurn = 0
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg.parts) continue
|
||||
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "step-start") {
|
||||
currentTurn++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.currentTurn = currentTurn
|
||||
|
||||
let turnCounter = 0
|
||||
let prunedCount = 0
|
||||
let tokensSaved = 0
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg.parts) continue
|
||||
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "step-start") {
|
||||
turnCounter++
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type !== "tool" || !part.callID || !part.tool) continue
|
||||
|
||||
if (protectedTools.has(part.tool)) continue
|
||||
|
||||
if (config.protectedTools?.includes(part.tool)) continue
|
||||
|
||||
if (state.toolIdsToPrune.has(part.callID)) continue
|
||||
|
||||
if (part.state?.status !== "error") continue
|
||||
|
||||
const turnAge = currentTurn - turnCounter
|
||||
|
||||
if (turnAge >= config.turns) {
|
||||
state.toolIdsToPrune.add(part.callID)
|
||||
prunedCount++
|
||||
|
||||
const input = part.state.input
|
||||
if (input) {
|
||||
tokensSaved += estimateTokens(JSON.stringify(input))
|
||||
}
|
||||
|
||||
const errorInfo: ErroredToolCall = {
|
||||
callID: part.callID,
|
||||
toolName: part.tool,
|
||||
turn: turnCounter,
|
||||
errorAge: turnAge,
|
||||
}
|
||||
|
||||
state.erroredTools.set(part.callID, errorInfo)
|
||||
|
||||
log("[pruning-purge-errors] pruned old error", {
|
||||
tool: part.tool,
|
||||
callID: part.callID,
|
||||
turn: turnCounter,
|
||||
errorAge: turnAge,
|
||||
threshold: config.turns,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log("[pruning-purge-errors] complete", {
|
||||
prunedCount,
|
||||
tokensSaved,
|
||||
currentTurn,
|
||||
threshold: config.turns,
|
||||
})
|
||||
|
||||
return prunedCount
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { PruningState } from "./pruning-types"
|
||||
import { estimateTokens } from "./pruning-types"
|
||||
import { log } from "../../shared/logger"
|
||||
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
|
||||
function getMessageDir(sessionID: string): string | null {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||
|
||||
const directPath = join(MESSAGE_STORAGE, sessionID)
|
||||
if (existsSync(directPath)) return directPath
|
||||
|
||||
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
||||
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
||||
if (existsSync(sessionPath)) return sessionPath
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
interface ToolPart {
|
||||
type: string
|
||||
callID?: string
|
||||
tool?: string
|
||||
state?: {
|
||||
input?: unknown
|
||||
output?: string
|
||||
status?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface MessageData {
|
||||
parts?: ToolPart[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export async function applyPruning(
|
||||
sessionID: string,
|
||||
state: PruningState
|
||||
): Promise<number> {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
if (!messageDir) {
|
||||
log("[pruning-storage] message dir not found", { sessionID })
|
||||
return 0
|
||||
}
|
||||
|
||||
let totalTokensSaved = 0
|
||||
let filesModified = 0
|
||||
|
||||
try {
|
||||
const files = readdirSync(messageDir).filter(f => f.endsWith(".json"))
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = join(messageDir, file)
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
const data: MessageData = JSON.parse(content)
|
||||
|
||||
if (!data.parts) continue
|
||||
|
||||
let modified = false
|
||||
|
||||
for (const part of data.parts) {
|
||||
if (part.type !== "tool" || !part.callID) continue
|
||||
|
||||
if (!state.toolIdsToPrune.has(part.callID)) continue
|
||||
|
||||
if (part.state?.input) {
|
||||
const inputStr = JSON.stringify(part.state.input)
|
||||
totalTokensSaved += estimateTokens(inputStr)
|
||||
part.state.input = { __pruned: true, reason: "DCP" }
|
||||
modified = true
|
||||
}
|
||||
|
||||
if (part.state?.output) {
|
||||
totalTokensSaved += estimateTokens(part.state.output)
|
||||
part.state.output = "[Content pruned by Dynamic Context Pruning]"
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8")
|
||||
filesModified++
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log("[pruning-storage] error applying pruning", {
|
||||
sessionID,
|
||||
error: String(error),
|
||||
})
|
||||
}
|
||||
|
||||
log("[pruning-storage] applied pruning", {
|
||||
sessionID,
|
||||
filesModified,
|
||||
totalTokensSaved,
|
||||
})
|
||||
|
||||
return totalTokensSaved
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { PruningState, FileOperation } from "./pruning-types"
|
||||
import { estimateTokens } from "./pruning-types"
|
||||
import { log } from "../../shared/logger"
|
||||
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
|
||||
export interface SupersedeWritesConfig {
|
||||
enabled: boolean
|
||||
aggressive: boolean
|
||||
}
|
||||
|
||||
interface ToolPart {
|
||||
type: string
|
||||
callID?: string
|
||||
tool?: string
|
||||
state?: {
|
||||
input?: unknown
|
||||
output?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface MessagePart {
|
||||
type: string
|
||||
parts?: ToolPart[]
|
||||
}
|
||||
|
||||
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 readMessages(sessionID: string): MessagePart[] {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
if (!messageDir) return []
|
||||
|
||||
const messages: MessagePart[] = []
|
||||
|
||||
try {
|
||||
const files = readdirSync(messageDir).filter(f => f.endsWith(".json"))
|
||||
for (const file of files) {
|
||||
const content = readFileSync(join(messageDir, file), "utf-8")
|
||||
const data = JSON.parse(content)
|
||||
if (data.parts) {
|
||||
messages.push(data)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
function extractFilePath(toolName: string, input: unknown): string | null {
|
||||
if (!input || typeof input !== "object") return null
|
||||
|
||||
const inputObj = input as Record<string, unknown>
|
||||
|
||||
if (toolName === "write" || toolName === "edit" || toolName === "read") {
|
||||
if (typeof inputObj.filePath === "string") {
|
||||
return inputObj.filePath
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function executeSupersedeWrites(
|
||||
sessionID: string,
|
||||
state: PruningState,
|
||||
config: SupersedeWritesConfig,
|
||||
protectedTools: Set<string>
|
||||
): number {
|
||||
if (!config.enabled) return 0
|
||||
|
||||
const messages = readMessages(sessionID)
|
||||
const writesByFile = new Map<string, FileOperation[]>()
|
||||
const readsByFile = new Map<string, number[]>()
|
||||
|
||||
let currentTurn = 0
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg.parts) continue
|
||||
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "step-start") {
|
||||
currentTurn++
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type !== "tool" || !part.callID || !part.tool) continue
|
||||
|
||||
if (protectedTools.has(part.tool)) continue
|
||||
|
||||
if (state.toolIdsToPrune.has(part.callID)) continue
|
||||
|
||||
const filePath = extractFilePath(part.tool, part.state?.input)
|
||||
if (!filePath) continue
|
||||
|
||||
if (part.tool === "write" || part.tool === "edit") {
|
||||
if (!writesByFile.has(filePath)) {
|
||||
writesByFile.set(filePath, [])
|
||||
}
|
||||
writesByFile.get(filePath)!.push({
|
||||
callID: part.callID,
|
||||
tool: part.tool,
|
||||
filePath,
|
||||
turn: currentTurn,
|
||||
})
|
||||
|
||||
if (!state.fileOperations.has(filePath)) {
|
||||
state.fileOperations.set(filePath, [])
|
||||
}
|
||||
state.fileOperations.get(filePath)!.push({
|
||||
callID: part.callID,
|
||||
tool: part.tool,
|
||||
filePath,
|
||||
turn: currentTurn,
|
||||
})
|
||||
} else if (part.tool === "read") {
|
||||
if (!readsByFile.has(filePath)) {
|
||||
readsByFile.set(filePath, [])
|
||||
}
|
||||
readsByFile.get(filePath)!.push(currentTurn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let prunedCount = 0
|
||||
let tokensSaved = 0
|
||||
|
||||
for (const [filePath, writes] of writesByFile) {
|
||||
const reads = readsByFile.get(filePath) || []
|
||||
|
||||
if (config.aggressive) {
|
||||
for (const write of writes) {
|
||||
const superseded = reads.some(readTurn => readTurn > write.turn)
|
||||
if (superseded) {
|
||||
state.toolIdsToPrune.add(write.callID)
|
||||
prunedCount++
|
||||
|
||||
const input = findToolInput(messages, write.callID)
|
||||
if (input) {
|
||||
tokensSaved += estimateTokens(JSON.stringify(input))
|
||||
}
|
||||
|
||||
log("[pruning-supersede] pruned superseded write", {
|
||||
tool: write.tool,
|
||||
callID: write.callID,
|
||||
turn: write.turn,
|
||||
filePath,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (writes.length > 1) {
|
||||
for (const write of writes.slice(0, -1)) {
|
||||
const superseded = reads.some(readTurn => readTurn > write.turn)
|
||||
if (superseded) {
|
||||
state.toolIdsToPrune.add(write.callID)
|
||||
prunedCount++
|
||||
|
||||
const input = findToolInput(messages, write.callID)
|
||||
if (input) {
|
||||
tokensSaved += estimateTokens(JSON.stringify(input))
|
||||
}
|
||||
|
||||
log("[pruning-supersede] pruned superseded write (conservative)", {
|
||||
tool: write.tool,
|
||||
callID: write.callID,
|
||||
turn: write.turn,
|
||||
filePath,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log("[pruning-supersede] complete", {
|
||||
prunedCount,
|
||||
tokensSaved,
|
||||
filesTracked: writesByFile.size,
|
||||
mode: config.aggressive ? "aggressive" : "conservative",
|
||||
})
|
||||
|
||||
return prunedCount
|
||||
}
|
||||
|
||||
function findToolInput(messages: MessagePart[], callID: string): unknown | null {
|
||||
for (const msg of messages) {
|
||||
if (!msg.parts) continue
|
||||
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "tool" && part.callID === callID && part.state?.input) {
|
||||
return part.state.input
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -8,4 +8,5 @@ export const SLASH_COMMAND_PATTERN = /^\/([a-zA-Z][\w-]*)\s*(.*)/
|
||||
export const EXCLUDED_COMMANDS = new Set([
|
||||
"ralph-loop",
|
||||
"cancel-ralph",
|
||||
"ulw-loop",
|
||||
])
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
export const BDD_KEYWORDS = new Set([
|
||||
"given",
|
||||
"when",
|
||||
"then",
|
||||
"arrange",
|
||||
"act",
|
||||
"assert",
|
||||
"when & then",
|
||||
"when&then",
|
||||
])
|
||||
|
||||
export const TYPE_CHECKER_PREFIXES = [
|
||||
"type:",
|
||||
"noqa",
|
||||
"pyright:",
|
||||
"ruff:",
|
||||
"mypy:",
|
||||
"pylint:",
|
||||
"flake8:",
|
||||
"pyre:",
|
||||
"pytype:",
|
||||
"eslint-disable",
|
||||
"eslint-enable",
|
||||
"eslint-ignore",
|
||||
"prettier-ignore",
|
||||
"ts-ignore",
|
||||
"ts-expect-error",
|
||||
"ts-nocheck",
|
||||
"clippy::",
|
||||
"allow(",
|
||||
"deny(",
|
||||
"warn(",
|
||||
"forbid(",
|
||||
"nolint",
|
||||
"go:generate",
|
||||
"go:build",
|
||||
"go:embed",
|
||||
"coverage:",
|
||||
"c8 ignore",
|
||||
"biome-ignore",
|
||||
"region",
|
||||
"endregion",
|
||||
]
|
||||
|
||||
export const HOOK_MESSAGE_HEADER = `COMMENT/DOCSTRING DETECTED - IMMEDIATE ACTION REQUIRED
|
||||
|
||||
Your recent changes contain comments or docstrings, which triggered this hook.
|
||||
You need to take immediate action. You must follow the conditions below.
|
||||
(Listed in priority order - you must always act according to this priority order)
|
||||
|
||||
CRITICAL WARNING: This hook message MUST NEVER be ignored, even if you receive it multiple times.
|
||||
You MUST provide corresponding explanation or action for EACH occurrence of this message.
|
||||
Ignoring this message or failing to respond appropriately is strictly prohibited.
|
||||
|
||||
PRIORITY-BASED ACTION GUIDELINES:
|
||||
|
||||
1. This is a comment/docstring that already existed before
|
||||
\t-> Explain to the user that this is an existing comment/docstring and proceed (justify it)
|
||||
|
||||
2. This is a newly written comment: but it's in given, when, then format
|
||||
\t-> Tell the user it's a BDD comment and proceed (justify it)
|
||||
\t-> Note: This applies to comments only, not docstrings
|
||||
|
||||
3. This is a newly written comment/docstring: but it's a necessary comment/docstring
|
||||
\t-> Tell the user why this comment/docstring is absolutely necessary and proceed (justify it)
|
||||
\t-> Examples of necessary comments: complex algorithms, security-related, performance optimization, regex, mathematical formulas
|
||||
\t-> Examples of necessary docstrings: public API documentation, complex module/class interfaces
|
||||
\t-> IMPORTANT: Most docstrings are unnecessary if the code is self-explanatory. Only keep truly essential ones.
|
||||
|
||||
4. This is a newly written comment/docstring: but it's an unnecessary comment/docstring
|
||||
\t-> Apologize to the user and remove the comment/docstring.
|
||||
\t-> Make the code itself clearer so it can be understood without comments/docstrings.
|
||||
\t-> For verbose docstrings: refactor code to be self-documenting instead of adding lengthy explanations.
|
||||
|
||||
CODE SMELL WARNING: Using comments as visual separators (e.g., "// =========", "# ---", "// *** Section ***")
|
||||
is a code smell. If you need separators, your file is too long or poorly organized.
|
||||
Refactor into smaller modules or use proper code organization instead of comment-based section dividers.
|
||||
|
||||
MANDATORY REQUIREMENT: You must acknowledge this hook message and take one of the above actions.
|
||||
Review in the above priority order and take the corresponding action EVERY TIME this appears.
|
||||
|
||||
Detected comments/docstrings:
|
||||
`
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { CommentInfo, FilterResult } from "../types"
|
||||
import { BDD_KEYWORDS } from "../constants"
|
||||
|
||||
function stripCommentPrefix(text: string): string {
|
||||
let stripped = text.trim().toLowerCase()
|
||||
const prefixes = ["#", "//", "--", "/*", "*/"]
|
||||
for (const prefix of prefixes) {
|
||||
if (stripped.startsWith(prefix)) {
|
||||
stripped = stripped.slice(prefix.length).trim()
|
||||
}
|
||||
}
|
||||
return stripped
|
||||
}
|
||||
|
||||
export function filterBddComments(comment: CommentInfo): FilterResult {
|
||||
const normalized = stripCommentPrefix(comment.text)
|
||||
if (BDD_KEYWORDS.has(normalized)) {
|
||||
return { shouldSkip: true, reason: `BDD keyword: ${normalized}` }
|
||||
}
|
||||
return { shouldSkip: false }
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { CommentInfo, FilterResult } from "../types"
|
||||
import { TYPE_CHECKER_PREFIXES } from "../constants"
|
||||
|
||||
function stripCommentPrefix(text: string): string {
|
||||
let stripped = text.trim().toLowerCase()
|
||||
const prefixes = ["#", "//", "/*", "--"]
|
||||
for (const prefix of prefixes) {
|
||||
if (stripped.startsWith(prefix)) {
|
||||
stripped = stripped.slice(prefix.length).trim()
|
||||
}
|
||||
}
|
||||
stripped = stripped.replace(/^@/, "")
|
||||
return stripped
|
||||
}
|
||||
|
||||
export function filterDirectiveComments(comment: CommentInfo): FilterResult {
|
||||
const normalized = stripCommentPrefix(comment.text)
|
||||
for (const prefix of TYPE_CHECKER_PREFIXES) {
|
||||
if (normalized.startsWith(prefix.toLowerCase())) {
|
||||
return { shouldSkip: true, reason: `Directive: ${prefix}` }
|
||||
}
|
||||
}
|
||||
return { shouldSkip: false }
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { CommentInfo, FilterResult } from "../types"
|
||||
|
||||
export function filterDocstringComments(comment: CommentInfo): FilterResult {
|
||||
if (comment.isDocstring) {
|
||||
return { shouldSkip: true, reason: "Docstring" }
|
||||
}
|
||||
const trimmed = comment.text.trimStart()
|
||||
if (trimmed.startsWith("/**")) {
|
||||
return { shouldSkip: true, reason: "JSDoc/PHPDoc" }
|
||||
}
|
||||
return { shouldSkip: false }
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { CommentInfo, CommentFilter } from "../types"
|
||||
import { filterBddComments } from "./bdd"
|
||||
import { filterDirectiveComments } from "./directive"
|
||||
import { filterDocstringComments } from "./docstring"
|
||||
import { filterShebangComments } from "./shebang"
|
||||
|
||||
export { filterBddComments, filterDirectiveComments, filterDocstringComments, filterShebangComments }
|
||||
|
||||
const ALL_FILTERS: CommentFilter[] = [
|
||||
filterShebangComments,
|
||||
filterBddComments,
|
||||
filterDirectiveComments,
|
||||
filterDocstringComments,
|
||||
]
|
||||
|
||||
export function applyFilters(comments: CommentInfo[]): CommentInfo[] {
|
||||
return comments.filter((comment) => {
|
||||
for (const filter of ALL_FILTERS) {
|
||||
const result = filter(comment)
|
||||
if (result.shouldSkip) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { CommentInfo, FilterResult } from "../types"
|
||||
|
||||
export function filterShebangComments(comment: CommentInfo): FilterResult {
|
||||
const trimmed = comment.text.trimStart()
|
||||
if (trimmed.startsWith("#!")) {
|
||||
return { shouldSkip: true, reason: "Shebang" }
|
||||
}
|
||||
return { shouldSkip: false }
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { FileComments } from "../types"
|
||||
import { HOOK_MESSAGE_HEADER } from "../constants"
|
||||
import { buildCommentsXml } from "./xml-builder"
|
||||
|
||||
export function formatHookMessage(fileCommentsList: FileComments[]): string {
|
||||
if (fileCommentsList.length === 0) {
|
||||
return ""
|
||||
}
|
||||
const xml = buildCommentsXml(fileCommentsList)
|
||||
return `${HOOK_MESSAGE_HEADER}${xml}\n`
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { buildCommentsXml } from "./xml-builder"
|
||||
export { formatHookMessage } from "./formatter"
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { FileComments } from "../types"
|
||||
|
||||
function escapeXml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
|
||||
export function buildCommentsXml(fileCommentsList: FileComments[]): string {
|
||||
const lines: string[] = []
|
||||
|
||||
for (const fc of fileCommentsList) {
|
||||
lines.push(`<comments file="${escapeXml(fc.filePath)}">`)
|
||||
for (const comment of fc.comments) {
|
||||
lines.push(`\t<comment line-number="${comment.lineNumber}">${escapeXml(comment.text)}</comment>`)
|
||||
}
|
||||
lines.push(`</comments>`)
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
@@ -14,7 +14,6 @@ export { createThinkModeHook } from "./think-mode";
|
||||
export { createClaudeCodeHooksHook } from "./claude-code-hooks";
|
||||
export { createRulesInjectorHook } from "./rules-injector";
|
||||
export { createBackgroundNotificationHook } from "./background-notification"
|
||||
export { createBackgroundCompactionHook } from "./background-compaction"
|
||||
export { createAutoUpdateCheckerHook } from "./auto-update-checker";
|
||||
|
||||
export { createAgentUsageReminderHook } from "./agent-usage-reminder";
|
||||
|
||||
@@ -92,6 +92,27 @@ describe("ralph-loop", () => {
|
||||
expect(readResult?.session_id).toBe("test-session-123")
|
||||
})
|
||||
|
||||
test("should handle ultrawork field", () => {
|
||||
// #given - a state object with ultrawork enabled
|
||||
const state: RalphLoopState = {
|
||||
active: true,
|
||||
iteration: 1,
|
||||
max_iterations: 50,
|
||||
completion_promise: "DONE",
|
||||
started_at: "2025-12-30T01:00:00Z",
|
||||
prompt: "Build a REST API",
|
||||
session_id: "test-session-123",
|
||||
ultrawork: true,
|
||||
}
|
||||
|
||||
// #when - write and read state
|
||||
writeState(TEST_DIR, state)
|
||||
const readResult = readState(TEST_DIR)
|
||||
|
||||
// #then - ultrawork field should be preserved
|
||||
expect(readResult?.ultrawork).toBe(true)
|
||||
})
|
||||
|
||||
test("should return null for non-existent state", () => {
|
||||
// #given - no state file exists
|
||||
// #when - read state
|
||||
@@ -164,6 +185,30 @@ describe("ralph-loop", () => {
|
||||
expect(state?.session_id).toBe("session-123")
|
||||
})
|
||||
|
||||
test("should accept ultrawork option in startLoop", () => {
|
||||
// #given - hook instance
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
|
||||
// #when - start loop with ultrawork
|
||||
hook.startLoop("session-123", "Build something", { ultrawork: true })
|
||||
|
||||
// #then - state should have ultrawork=true
|
||||
const state = hook.getState()
|
||||
expect(state?.ultrawork).toBe(true)
|
||||
})
|
||||
|
||||
test("should handle missing ultrawork option in startLoop", () => {
|
||||
// #given - hook instance
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
|
||||
// #when - start loop without ultrawork
|
||||
hook.startLoop("session-123", "Build something")
|
||||
|
||||
// #then - state should have ultrawork=undefined
|
||||
const state = hook.getState()
|
||||
expect(state?.ultrawork).toBeUndefined()
|
||||
})
|
||||
|
||||
test("should inject continuation when loop active and no completion detected", async () => {
|
||||
// #given - active loop state
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
@@ -672,7 +717,10 @@ describe("ralph-loop", () => {
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-123" },
|
||||
},
|
||||
})
|
||||
|
||||
// #then - should complete via transcript (API not called when transcript succeeds)
|
||||
@@ -681,6 +729,70 @@ describe("ralph-loop", () => {
|
||||
// API should NOT be called since transcript found completion
|
||||
expect(messagesCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
test("should show ultrawork completion toast", async () => {
|
||||
// #given - hook with ultrawork mode and completion in transcript
|
||||
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||
getTranscriptPath: () => transcriptPath,
|
||||
})
|
||||
writeFileSync(transcriptPath, JSON.stringify({ content: "<promise>DONE</promise>" }))
|
||||
hook.startLoop("test-id", "Build API", { ultrawork: true })
|
||||
|
||||
// #when - idle event triggered
|
||||
await hook.event({ event: { type: "session.idle", properties: { sessionID: "test-id" } } })
|
||||
|
||||
// #then - ultrawork toast shown
|
||||
const completionToast = toastCalls.find(t => t.title === "ULTRAWORK LOOP COMPLETE!")
|
||||
expect(completionToast).toBeDefined()
|
||||
expect(completionToast!.message).toMatch(/JUST ULW ULW!/)
|
||||
})
|
||||
|
||||
test("should show regular completion toast when ultrawork disabled", async () => {
|
||||
// #given - hook without ultrawork
|
||||
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||
getTranscriptPath: () => transcriptPath,
|
||||
})
|
||||
writeFileSync(transcriptPath, JSON.stringify({ content: "<promise>DONE</promise>" }))
|
||||
hook.startLoop("test-id", "Build API")
|
||||
|
||||
// #when - idle event triggered
|
||||
await hook.event({ event: { type: "session.idle", properties: { sessionID: "test-id" } } })
|
||||
|
||||
// #then - regular toast shown
|
||||
expect(toastCalls.some(t => t.title === "Ralph Loop Complete!")).toBe(true)
|
||||
})
|
||||
|
||||
test("should prepend ultrawork to continuation prompt when ultrawork=true", async () => {
|
||||
// #given - hook with ultrawork mode enabled
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-123", "Build API", { ultrawork: true })
|
||||
|
||||
// #when - session goes idle (continuation triggered)
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
|
||||
// #then - prompt should start with "ultrawork "
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(promptCalls[0].text).toMatch(/^ultrawork /)
|
||||
})
|
||||
|
||||
test("should NOT prepend ultrawork to continuation prompt when ultrawork=false", async () => {
|
||||
// #given - hook without ultrawork mode
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-123", "Build API")
|
||||
|
||||
// #when - session goes idle (continuation triggered)
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
|
||||
// #then - prompt should NOT start with "ultrawork "
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(promptCalls[0].text).not.toMatch(/^ultrawork /)
|
||||
})
|
||||
})
|
||||
|
||||
describe("API timeout protection", () => {
|
||||
|
||||
@@ -61,7 +61,7 @@ export interface RalphLoopHook {
|
||||
startLoop: (
|
||||
sessionID: string,
|
||||
prompt: string,
|
||||
options?: { maxIterations?: number; completionPromise?: string }
|
||||
options?: { maxIterations?: number; completionPromise?: string; ultrawork?: boolean }
|
||||
) => boolean
|
||||
cancelLoop: (sessionID: string) => boolean
|
||||
getState: () => RalphLoopState | null
|
||||
@@ -150,7 +150,7 @@ export function createRalphLoopHook(
|
||||
const startLoop = (
|
||||
sessionID: string,
|
||||
prompt: string,
|
||||
loopOptions?: { maxIterations?: number; completionPromise?: string }
|
||||
loopOptions?: { maxIterations?: number; completionPromise?: string; ultrawork?: boolean }
|
||||
): boolean => {
|
||||
const state: RalphLoopState = {
|
||||
active: true,
|
||||
@@ -158,6 +158,7 @@ export function createRalphLoopHook(
|
||||
max_iterations:
|
||||
loopOptions?.maxIterations ?? config?.default_max_iterations ?? DEFAULT_MAX_ITERATIONS,
|
||||
completion_promise: loopOptions?.completionPromise ?? DEFAULT_COMPLETION_PROMISE,
|
||||
ultrawork: loopOptions?.ultrawork,
|
||||
started_at: new Date().toISOString(),
|
||||
prompt,
|
||||
session_id: sessionID,
|
||||
@@ -251,11 +252,18 @@ export function createRalphLoopHook(
|
||||
})
|
||||
clearState(ctx.directory, stateDir)
|
||||
|
||||
const title = state.ultrawork
|
||||
? "ULTRAWORK LOOP COMPLETE!"
|
||||
: "Ralph Loop Complete!"
|
||||
const message = state.ultrawork
|
||||
? `JUST ULW ULW! Task completed after ${state.iteration} iteration(s)`
|
||||
: `Task completed after ${state.iteration} iteration(s)`
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Ralph Loop Complete!",
|
||||
message: `Task completed after ${state.iteration} iteration(s)`,
|
||||
title,
|
||||
message,
|
||||
variant: "success",
|
||||
duration: 5000,
|
||||
},
|
||||
@@ -304,6 +312,10 @@ export function createRalphLoopHook(
|
||||
.replace("{{PROMISE}}", newState.completion_promise)
|
||||
.replace("{{PROMPT}}", newState.prompt)
|
||||
|
||||
const finalPrompt = newState.ultrawork
|
||||
? `ultrawork ${continuationPrompt}`
|
||||
: continuationPrompt
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
@@ -346,7 +358,7 @@ export function createRalphLoopHook(
|
||||
body: {
|
||||
...(agent !== undefined ? { agent } : {}),
|
||||
...(model !== undefined ? { model } : {}),
|
||||
parts: [{ type: "text", text: continuationPrompt }],
|
||||
parts: [{ type: "text", text: finalPrompt }],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
|
||||
@@ -48,6 +48,7 @@ export function readState(directory: string, customPath?: string): RalphLoopStat
|
||||
started_at: stripQuotes(data.started_at) || new Date().toISOString(),
|
||||
prompt: body.trim(),
|
||||
session_id: data.session_id ? stripQuotes(data.session_id) : undefined,
|
||||
ultrawork: data.ultrawork === true || data.ultrawork === "true" ? true : undefined,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
@@ -68,13 +69,14 @@ export function writeState(
|
||||
}
|
||||
|
||||
const sessionIdLine = state.session_id ? `session_id: "${state.session_id}"\n` : ""
|
||||
const ultraworkLine = state.ultrawork !== undefined ? `ultrawork: ${state.ultrawork}\n` : ""
|
||||
const content = `---
|
||||
active: ${state.active}
|
||||
iteration: ${state.iteration}
|
||||
max_iterations: ${state.max_iterations}
|
||||
completion_promise: "${state.completion_promise}"
|
||||
started_at: "${state.started_at}"
|
||||
${sessionIdLine}---
|
||||
${sessionIdLine}${ultraworkLine}---
|
||||
${state.prompt}
|
||||
`
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface RalphLoopState {
|
||||
started_at: string
|
||||
prompt: string
|
||||
session_id?: string
|
||||
ultrawork?: boolean
|
||||
}
|
||||
|
||||
export interface RalphLoopOptions {
|
||||
|
||||
27
src/index.ts
27
src/index.ts
@@ -525,9 +525,30 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
: undefined,
|
||||
completionPromise: promiseMatch?.[1],
|
||||
});
|
||||
} else if (command === "cancel-ralph" && sessionID) {
|
||||
ralphLoop.cancelLoop(sessionID);
|
||||
}
|
||||
} else if (command === "cancel-ralph" && sessionID) {
|
||||
ralphLoop.cancelLoop(sessionID);
|
||||
} else if (command === "ulw-loop" && sessionID) {
|
||||
const rawArgs =
|
||||
args?.command?.replace(/^\/?(ulw-loop)\s*/i, "") || "";
|
||||
const taskMatch = rawArgs.match(/^["'](.+?)["']/);
|
||||
const prompt =
|
||||
taskMatch?.[1] ||
|
||||
rawArgs.split(/\s+--/)[0]?.trim() ||
|
||||
"Complete the task as instructed";
|
||||
|
||||
const maxIterMatch = rawArgs.match(/--max-iterations=(\d+)/i);
|
||||
const promiseMatch = rawArgs.match(
|
||||
/--completion-promise=["']?([^"'\s]+)["']?/i
|
||||
);
|
||||
|
||||
ralphLoop.startLoop(sessionID, prompt, {
|
||||
ultrawork: true,
|
||||
maxIterations: maxIterMatch
|
||||
? parseInt(maxIterMatch[1], 10)
|
||||
: undefined,
|
||||
completionPromise: promiseMatch?.[1],
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -2,4 +2,5 @@ export const context7 = {
|
||||
type: "remote" as const,
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
enabled: true,
|
||||
oauth: false as const,
|
||||
}
|
||||
|
||||
@@ -2,4 +2,5 @@ export const grep_app = {
|
||||
type: "remote" as const,
|
||||
url: "https://mcp.grep.app",
|
||||
enabled: true,
|
||||
oauth: false as const,
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ type RemoteMcpConfig = {
|
||||
url: string
|
||||
enabled: boolean
|
||||
headers?: Record<string, string>
|
||||
oauth?: false
|
||||
}
|
||||
|
||||
const allBuiltinMcps: Record<McpName, RemoteMcpConfig> = {
|
||||
|
||||
@@ -5,4 +5,6 @@ export const websearch = {
|
||||
headers: process.env.EXA_API_KEY
|
||||
? { "x-api-key": process.env.EXA_API_KEY }
|
||||
: undefined,
|
||||
// Disable OAuth auto-detection - Exa uses API key header, not OAuth
|
||||
oauth: false as const,
|
||||
}
|
||||
|
||||
@@ -1,74 +1,63 @@
|
||||
# SHARED UTILITIES KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Cross-cutting utilities for path resolution, config management, text processing, and Claude Code compatibility.
|
||||
|
||||
43 cross-cutting utilities: path resolution, token truncation, config parsing, Claude Code compatibility.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
shared/
|
||||
├── index.ts # Barrel export
|
||||
├── agent-variant.ts # Agent model/prompt variation logic
|
||||
├── claude-config-dir.ts # ~/.claude resolution
|
||||
├── command-executor.ts # Shell exec with variable expansion
|
||||
├── config-errors.ts # Global error tracking
|
||||
├── config-path.ts # User/project config paths
|
||||
├── data-path.ts # XDG data directory
|
||||
├── deep-merge.ts # Type-safe recursive merge
|
||||
├── dynamic-truncator.ts # Token-aware truncation
|
||||
├── external-plugin-detector.ts # Detect marketplace plugins
|
||||
├── file-reference-resolver.ts # @filename syntax
|
||||
├── file-utils.ts # Symlink, markdown detection
|
||||
├── first-message-variant.ts # Initial prompt variations
|
||||
├── frontmatter.ts # YAML frontmatter parsing
|
||||
├── hook-disabled.ts # Check if hook disabled
|
||||
├── jsonc-parser.ts # JSON with Comments
|
||||
├── logger.ts # File-based logging
|
||||
├── migration.ts # Legacy name compat (omo → Sisyphus)
|
||||
├── model-sanitizer.ts # Normalize model names
|
||||
├── logger.ts # File-based logging (tmpdir/oh-my-opencode.log)
|
||||
├── permission-compat.ts # Agent tool restrictions (ask/allow/deny)
|
||||
├── dynamic-truncator.ts # Token-aware truncation (50% headroom)
|
||||
├── frontmatter.ts # YAML frontmatter parsing
|
||||
├── jsonc-parser.ts # JSON with Comments support
|
||||
├── data-path.ts # XDG-compliant storage (~/.local/share)
|
||||
├── opencode-config-dir.ts # ~/.config/opencode resolution
|
||||
├── opencode-version.ts # Version comparison logic
|
||||
├── pattern-matcher.ts # Tool name matching
|
||||
├── permission-compat.ts # Legacy permission mapping
|
||||
├── session-cursor.ts # Track message history pointer
|
||||
├── snake-case.ts # Case conversion
|
||||
├── tool-name.ts # PascalCase normalization
|
||||
└── zip-extractor.ts # Plugin installation utility
|
||||
├── claude-config-dir.ts # ~/.claude resolution
|
||||
├── migration.ts # Legacy config migration (omo → Sisyphus)
|
||||
├── opencode-version.ts # Version comparison (>= 1.0.150)
|
||||
├── external-plugin-detector.ts # OAuth spoofing detection
|
||||
├── env-expander.ts # ${VAR} expansion in configs
|
||||
├── system-directive.ts # System directive types
|
||||
├── hook-utils.ts # Hook helper functions
|
||||
└── *.test.ts # Test files (colocated)
|
||||
```
|
||||
|
||||
## WHEN TO USE
|
||||
|
||||
| Task | Utility |
|
||||
|------|---------|
|
||||
| Find ~/.claude | `getClaudeConfigDir()` |
|
||||
| Find ~/.config/opencode | `getOpenCodeConfigDir()` |
|
||||
| Merge configs | `deepMerge(base, override)` |
|
||||
| Parse user files | `parseJsonc()` |
|
||||
| Check hook enabled | `isHookDisabled(name, list)` |
|
||||
| Truncate output | `dynamicTruncate(text, budget)` |
|
||||
| Resolve @file | `resolveFileReferencesInText()` |
|
||||
| Execute shell | `resolveCommandsInText()` |
|
||||
| Legacy names | `migrateLegacyAgentNames()` |
|
||||
| Version check | `isOpenCodeVersionAtLeast(version)` |
|
||||
| Map permissions | `normalizePermission()` |
|
||||
| Track session | `SessionCursor` |
|
||||
| Debug logging | `log(message, data)` in `logger.ts` |
|
||||
| Limit context | `dynamicTruncate(ctx, sessionId, output)` |
|
||||
| Parse frontmatter | `parseFrontmatter(content)` |
|
||||
| Load JSONC config | `parseJsonc(text)` or `readJsoncFile(path)` |
|
||||
| Restrict agent tools | `createAgentToolAllowlist(tools)` |
|
||||
| Resolve paths | `getOpenCodeConfigDir()`, `getClaudeConfigDir()` |
|
||||
| Migrate config | `migrateConfigFile(path, rawConfig)` |
|
||||
| Compare versions | `isOpenCodeVersionAtLeast("1.1.0")` |
|
||||
|
||||
## KEY PATTERNS
|
||||
|
||||
## CRITICAL PATTERNS
|
||||
```typescript
|
||||
// Dynamic truncation with context budget
|
||||
const output = dynamicTruncate(result, remainingTokens, 0.5)
|
||||
// Token-aware truncation
|
||||
const { result } = await dynamicTruncate(ctx, sessionID, largeBuffer)
|
||||
|
||||
// Config resolution priority
|
||||
const final = deepMerge(deepMerge(defaults, userConfig), projectConfig)
|
||||
|
||||
// Safe JSONC parsing for user-edited files
|
||||
const { config, error } = parseJsoncSafe(content)
|
||||
// JSONC config loading
|
||||
const settings = readJsoncFile<Settings>(configPath)
|
||||
|
||||
// Version-gated features
|
||||
if (isOpenCodeVersionAtLeast('1.0.150')) { /* ... */ }
|
||||
if (isOpenCodeVersionAtLeast("1.1.0")) { /* new feature */ }
|
||||
|
||||
// Tool permission normalization
|
||||
const permissions = migrateToolsToPermission(legacyTools)
|
||||
```
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- Hardcoding paths (use `getClaudeConfigDir`, `getOpenCodeConfigDir`)
|
||||
- Using `JSON.parse` for user configs (always use `parseJsonc`)
|
||||
- Ignoring output size (large tool outputs MUST use `dynamicTruncate`)
|
||||
- Manual version parsing (use `opencode-version.ts` utilities)
|
||||
- Raw permission checks (use `permission-compat.ts`)
|
||||
|
||||
- **Raw JSON.parse**: Use `jsonc-parser.ts` for config files
|
||||
- **Hardcoded paths**: Use `*-config-dir.ts` utilities
|
||||
- **console.log**: Use `logger.ts` for background agents
|
||||
- **Unbounded output**: Always use `dynamic-truncator.ts`
|
||||
- **Manual version parse**: Use `opencode-version.ts`
|
||||
|
||||
@@ -1,60 +1,74 @@
|
||||
# TOOLS KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Custom tools extending agent capabilities: LSP (3 tools), AST-aware search/replace, background tasks, and multimodal analysis.
|
||||
|
||||
20+ tools: LSP (11), AST-Grep (2), Search (2), Session (4), Agent delegation (3), System (2). High-performance C++ bindings via @ast-grep/napi.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
tools/
|
||||
├── ast-grep/ # AST-aware search/replace (25 languages)
|
||||
│ ├── cli.ts # @ast-grep/cli fallback
|
||||
│ └── napi.ts # @ast-grep/napi native binding (preferred)
|
||||
├── background-task/ # Async agent task management
|
||||
├── call-omo-agent/ # Spawn explore/librarian agents
|
||||
├── glob/ # File pattern matching (timeout-safe)
|
||||
├── grep/ # Content search (timeout-safe)
|
||||
├── interactive-bash/ # Tmux session management
|
||||
├── look-at/ # Multimodal analysis (PDF, images)
|
||||
├── lsp/ # IDE-like code intelligence
|
||||
│ ├── client.ts # LSP connection lifecycle (632 lines)
|
||||
│ ├── tools.ts # Tool implementations
|
||||
│ └── config.ts, types.ts, utils.ts
|
||||
├── session-manager/ # OpenCode session history management
|
||||
├── sisyphus-task/ # Category-based delegation (667 lines)
|
||||
├── skill/ # Skill loading/execution
|
||||
├── skill-mcp/ # Skill-embedded MCP invocation
|
||||
├── slashcommand/ # Slash command execution
|
||||
└── index.ts # builtinTools export (75 lines)
|
||||
├── [tool-name]/
|
||||
│ ├── index.ts # Barrel export
|
||||
│ ├── tools.ts # Business logic, ToolDefinition
|
||||
│ ├── types.ts # Zod schemas
|
||||
│ └── constants.ts # Fixed values, descriptions
|
||||
├── lsp/ # 11 tools: goto_definition, references, symbols, diagnostics, rename
|
||||
├── ast-grep/ # 2 tools: search, replace (25 languages via NAPI)
|
||||
├── delegate-task/ # Category-based agent routing (761 lines)
|
||||
├── session-manager/ # 4 tools: list, read, search, info
|
||||
├── grep/ # Custom grep with timeout/truncation
|
||||
├── glob/ # Custom glob with 60s timeout, 100 file limit
|
||||
├── interactive-bash/ # Tmux session management
|
||||
├── look-at/ # Multimodal PDF/image analysis
|
||||
├── skill/ # Skill execution
|
||||
├── skill-mcp/ # Skill MCP operations
|
||||
├── slashcommand/ # Slash command dispatch
|
||||
├── call-omo-agent/ # Direct agent invocation
|
||||
└── background-task/ # background_output, background_cancel
|
||||
```
|
||||
|
||||
## TOOL CATEGORIES
|
||||
|
||||
| Category | Tools | Purpose |
|
||||
|----------|-------|---------|
|
||||
| LSP | lsp_diagnostics, lsp_prepare_rename, lsp_rename | IDE-grade code intelligence (3 tools) |
|
||||
| AST | ast_grep_search, ast_grep_replace | Structural pattern matching/rewriting |
|
||||
| Search | grep, glob | Timeout-safe file and content search |
|
||||
| Session | session_list, session_read, session_search, session_info | History navigation and retrieval |
|
||||
| Background | delegate_task, background_output, background_cancel | Parallel agent orchestration |
|
||||
| UI/Terminal | look_at, interactive_bash | Visual analysis and tmux control |
|
||||
| Execution | slashcommand, skill, skill_mcp | Command and skill-based extensibility |
|
||||
| **LSP** | lsp_goto_definition, lsp_find_references, lsp_symbols, lsp_diagnostics, lsp_prepare_rename, lsp_rename | Semantic code intelligence |
|
||||
| **Search** | ast_grep_search, ast_grep_replace, grep, glob | Pattern discovery |
|
||||
| **Session** | session_list, session_read, session_search, session_info | History navigation |
|
||||
| **Agent** | delegate_task, call_omo_agent, background_output, background_cancel | Task orchestration |
|
||||
| **System** | interactive_bash, look_at | CLI, multimodal |
|
||||
| **Skill** | skill, skill_mcp, slashcommand | Skill execution |
|
||||
|
||||
## HOW TO ADD A TOOL
|
||||
1. Create directory `src/tools/my-tool/`.
|
||||
2. Implement `tools.ts` (factory), `types.ts`, and `constants.ts`.
|
||||
3. Export via `index.ts` and register in `src/tools/index.ts`.
|
||||
## HOW TO ADD
|
||||
|
||||
1. Create `src/tools/[name]/` with standard files
|
||||
2. Use `tool()` from `@opencode-ai/plugin/tool`:
|
||||
```typescript
|
||||
export const myTool: ToolDefinition = tool({
|
||||
description: "...",
|
||||
args: { param: tool.schema.string() },
|
||||
execute: async (args) => { /* ... */ }
|
||||
})
|
||||
```
|
||||
3. Export from `src/tools/index.ts`
|
||||
4. Add to `builtinTools` object
|
||||
|
||||
## LSP SPECIFICS
|
||||
- **Lifecycle**: Lazy initialization on first call; auto-shutdown on idle.
|
||||
- **Config**: Merges `opencode.json` and `oh-my-opencode.json`.
|
||||
- **Capability**: Supports full LSP spec including `rename` and `prepareRename`.
|
||||
|
||||
- **Client**: `client.ts` manages stdio lifecycle, JSON-RPC
|
||||
- **Singleton**: `LSPServerManager` with ref counting
|
||||
- **Protocol**: Standard LSP methods mapped to tool responses
|
||||
- **Capabilities**: definition, references, symbols, diagnostics, rename
|
||||
|
||||
## AST-GREP SPECIFICS
|
||||
- **Precision**: Uses tree-sitter for structural matching (avoids regex pitfalls).
|
||||
- **Binding**: Uses `@ast-grep/napi` for performance; ensure patterns are valid AST nodes.
|
||||
- **Variables**: Supports `$VAR` and `$$$` meta-variables for capture.
|
||||
|
||||
- **Engine**: `@ast-grep/napi` for 25+ languages
|
||||
- **Patterns**: Meta-variables `$VAR` (single), `$$$` (multiple)
|
||||
- **Performance**: Rust/C++ layer for structural matching
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- **Sync Ops**: Never use synchronous file I/O; blocking the main thread kills responsiveness.
|
||||
- **No Timeouts**: Always wrap external CLI/LSP calls in timeouts (default 60s).
|
||||
- **Direct Subprocess**: Avoid raw `spawn` for ast-grep; use NAPI binding.
|
||||
- **Manual Pathing**: Use `shared/utils` for path normalization across platforms.
|
||||
|
||||
- **Sequential bash**: Use `&&` or delegation, not loops
|
||||
- **Raw file ops**: Never mkdir/touch in tool logic
|
||||
- **Sleep**: Use polling loops, tool-specific wait flags
|
||||
- **Heavy sync**: Keep PreToolUse light, computation in tools.ts
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { parse, Lang } from "@ast-grep/napi"
|
||||
import { NAPI_LANGUAGES } from "./constants"
|
||||
import type { NapiLanguage, AnalyzeResult, MetaVariable, Range } from "./types"
|
||||
|
||||
const LANG_MAP: Record<NapiLanguage, Lang> = {
|
||||
html: Lang.Html,
|
||||
javascript: Lang.JavaScript,
|
||||
tsx: Lang.Tsx,
|
||||
css: Lang.Css,
|
||||
typescript: Lang.TypeScript,
|
||||
}
|
||||
|
||||
export function parseCode(code: string, lang: NapiLanguage) {
|
||||
const parseLang = LANG_MAP[lang]
|
||||
if (!parseLang) {
|
||||
const supportedLangs = NAPI_LANGUAGES.join(", ")
|
||||
throw new Error(
|
||||
`Unsupported language for NAPI: "${lang}"\n` +
|
||||
`Supported languages: ${supportedLangs}\n\n` +
|
||||
`Use ast_grep_search for other languages (25 supported via CLI).`
|
||||
)
|
||||
}
|
||||
return parse(parseLang, code)
|
||||
}
|
||||
|
||||
export function findPattern(root: ReturnType<typeof parseCode>, pattern: string) {
|
||||
return root.root().findAll(pattern)
|
||||
}
|
||||
|
||||
function nodeToRange(node: ReturnType<ReturnType<typeof parseCode>["root"]>): Range {
|
||||
const range = node.range()
|
||||
return {
|
||||
start: { line: range.start.line, column: range.start.column },
|
||||
end: { line: range.end.line, column: range.end.column },
|
||||
}
|
||||
}
|
||||
|
||||
function extractMetaVariablesFromPattern(pattern: string): string[] {
|
||||
const matches = pattern.match(/\$[A-Z_][A-Z0-9_]*/g) || []
|
||||
return [...new Set(matches.map((m) => m.slice(1)))]
|
||||
}
|
||||
|
||||
export function extractMetaVariables(
|
||||
node: ReturnType<ReturnType<typeof parseCode>["root"]>,
|
||||
pattern: string
|
||||
): MetaVariable[] {
|
||||
const varNames = extractMetaVariablesFromPattern(pattern)
|
||||
const result: MetaVariable[] = []
|
||||
|
||||
for (const name of varNames) {
|
||||
const match = node.getMatch(name)
|
||||
if (match) {
|
||||
result.push({
|
||||
name,
|
||||
text: match.text(),
|
||||
kind: String(match.kind()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function analyzeCode(
|
||||
code: string,
|
||||
lang: NapiLanguage,
|
||||
pattern: string,
|
||||
shouldExtractMetaVars: boolean
|
||||
): AnalyzeResult[] {
|
||||
const root = parseCode(code, lang)
|
||||
const matches = findPattern(root, pattern)
|
||||
|
||||
return matches.map((node) => ({
|
||||
text: node.text(),
|
||||
range: nodeToRange(node),
|
||||
kind: String(node.kind()),
|
||||
metaVariables: shouldExtractMetaVars ? extractMetaVariables(node, pattern) : [],
|
||||
}))
|
||||
}
|
||||
|
||||
export function transformCode(
|
||||
code: string,
|
||||
lang: NapiLanguage,
|
||||
pattern: string,
|
||||
rewrite: string
|
||||
): { transformed: string; editCount: number } {
|
||||
const root = parseCode(code, lang)
|
||||
const matches = findPattern(root, pattern)
|
||||
|
||||
if (matches.length === 0) {
|
||||
return { transformed: code, editCount: 0 }
|
||||
}
|
||||
|
||||
const edits = matches.map((node) => {
|
||||
const metaVars = extractMetaVariables(node, pattern)
|
||||
let replacement = rewrite
|
||||
|
||||
for (const mv of metaVars) {
|
||||
replacement = replacement.replace(new RegExp(`\\$${mv.name}`, "g"), mv.text)
|
||||
}
|
||||
|
||||
return node.replace(replacement)
|
||||
})
|
||||
|
||||
const transformed = root.root().commitEdits(edits)
|
||||
return { transformed, editCount: edits.length }
|
||||
}
|
||||
|
||||
export function getRootInfo(code: string, lang: NapiLanguage): { kind: string; childCount: number } {
|
||||
const root = parseCode(code, lang)
|
||||
const rootNode = root.root()
|
||||
return {
|
||||
kind: String(rootNode.kind()),
|
||||
childCount: rootNode.children().length,
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ Approach:
|
||||
</Category_Context>
|
||||
|
||||
<Caller_Warning>
|
||||
⚠️ THIS CATEGORY USES A LESS CAPABLE MODEL (claude-haiku-4-5).
|
||||
THIS CATEGORY USES A LESS CAPABLE MODEL (claude-haiku-4-5).
|
||||
|
||||
The model executing this task has LIMITED reasoning capacity. Your prompt MUST be:
|
||||
|
||||
@@ -146,7 +146,7 @@ Approach:
|
||||
</Category_Context>
|
||||
|
||||
<Caller_Warning>
|
||||
⚠️ THIS CATEGORY USES A MID-TIER MODEL (claude-sonnet-4-5).
|
||||
THIS CATEGORY USES A MID-TIER MODEL (claude-sonnet-4-5).
|
||||
|
||||
While capable, this model benefits significantly from EXPLICIT instructions.
|
||||
|
||||
@@ -244,7 +244,7 @@ MUTUALLY EXCLUSIVE: Provide EITHER category OR agent, not both (unless resuming)
|
||||
- agent: Use specific agent directly (e.g., "oracle", "explore")
|
||||
- background: true=async (returns task_id), false=sync (waits for result). Default: false. Use background=true ONLY for parallel exploration with 5+ independent queries.
|
||||
- resume: Session ID to resume (from previous task output). Continues agent with FULL CONTEXT PRESERVED - saves tokens, maintains continuity.
|
||||
- skills: Array of skill names to prepend to prompt (e.g., ["playwright", "frontend-ui-ux"]). Skills will be resolved and their content prepended with a separator. Empty array [] is NOT allowed - use null if no skills needed.
|
||||
- skills: Array of skill names to prepend to prompt (e.g., ["playwright", "frontend-ui-ux"]). Use [] (empty array) if no skills needed.
|
||||
|
||||
**WHEN TO USE resume:**
|
||||
- Task failed/incomplete → resume with "fix: [specific issue]"
|
||||
|
||||
@@ -319,7 +319,7 @@ describe("sisyphus-task", () => {
|
||||
prompt: "Do something",
|
||||
category: "ultrabrain",
|
||||
run_in_background: true,
|
||||
skills: null,
|
||||
skills: [],
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
@@ -334,12 +334,11 @@ describe("sisyphus-task", () => {
|
||||
})
|
||||
|
||||
describe("skills parameter", () => {
|
||||
test("DELEGATE_TASK_DESCRIPTION documents skills parameter with null option", () => {
|
||||
test("DELEGATE_TASK_DESCRIPTION documents skills parameter with empty array option", () => {
|
||||
// #given / #when / #then
|
||||
expect(DELEGATE_TASK_DESCRIPTION).toContain("skills")
|
||||
expect(DELEGATE_TASK_DESCRIPTION).toContain("Array of skill names")
|
||||
expect(DELEGATE_TASK_DESCRIPTION).toContain("Empty array [] is NOT allowed")
|
||||
expect(DELEGATE_TASK_DESCRIPTION).toContain("null if no skills needed")
|
||||
expect(DELEGATE_TASK_DESCRIPTION).toContain("[] (empty array) if no skills needed")
|
||||
})
|
||||
|
||||
test("skills parameter is required - returns error when not provided", async () => {
|
||||
@@ -385,7 +384,7 @@ describe("sisyphus-task", () => {
|
||||
expect(result).toContain("REQUIRED")
|
||||
})
|
||||
|
||||
test("empty array [] returns error with available skills list", async () => {
|
||||
test("null skills returns error", async () => {
|
||||
// #given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
|
||||
@@ -412,26 +411,26 @@ describe("sisyphus-task", () => {
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
// #when - empty array passed
|
||||
// #when - null passed
|
||||
const result = await tool.execute(
|
||||
{
|
||||
description: "Test task",
|
||||
prompt: "Do something",
|
||||
category: "ultrabrain",
|
||||
run_in_background: false,
|
||||
skills: [],
|
||||
skills: null,
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
|
||||
// #then - should return error about empty array with guidance
|
||||
expect(result).toContain("❌")
|
||||
expect(result).toContain("Empty array []")
|
||||
expect(result).toContain("not allowed")
|
||||
// #then - should return error about null
|
||||
expect(result).toContain("Invalid arguments")
|
||||
expect(result).toContain("skills=null")
|
||||
expect(result).toContain("not allowed")
|
||||
expect(result).toContain("skills=[]")
|
||||
})
|
||||
|
||||
test("null skills is allowed and proceeds without skill content", async () => {
|
||||
test("empty array [] is allowed and proceeds without skill content", async () => {
|
||||
// #given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
let promptBody: any
|
||||
@@ -466,21 +465,20 @@ describe("sisyphus-task", () => {
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
// #when - null skills passed
|
||||
// #when - empty array skills passed
|
||||
await tool.execute(
|
||||
{
|
||||
description: "Test task",
|
||||
prompt: "Do something",
|
||||
category: "ultrabrain",
|
||||
run_in_background: false,
|
||||
skills: null,
|
||||
skills: [],
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
|
||||
// #then - should proceed without system content from skills
|
||||
expect(promptBody).toBeDefined()
|
||||
// system should not contain skill content (only category prompt append if any)
|
||||
}, { timeout: 20000 })
|
||||
})
|
||||
|
||||
@@ -540,7 +538,7 @@ describe("sisyphus-task", () => {
|
||||
prompt: "Continue the task",
|
||||
resume: "ses_resume_test",
|
||||
run_in_background: false,
|
||||
skills: null,
|
||||
skills: [],
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
@@ -595,7 +593,7 @@ describe("sisyphus-task", () => {
|
||||
prompt: "Continue in background",
|
||||
resume: "ses_bg_resume",
|
||||
run_in_background: true,
|
||||
skills: null,
|
||||
skills: [],
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
@@ -650,13 +648,12 @@ describe("sisyphus-task", () => {
|
||||
prompt: "Do something",
|
||||
category: "ultrabrain",
|
||||
run_in_background: false,
|
||||
skills: null,
|
||||
skills: [],
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
|
||||
// #then - should return detailed error message with args and stack trace
|
||||
expect(result).toContain("❌")
|
||||
expect(result).toContain("Send prompt failed")
|
||||
expect(result).toContain("JSON Parse error")
|
||||
expect(result).toContain("**Arguments**:")
|
||||
@@ -711,7 +708,7 @@ describe("sisyphus-task", () => {
|
||||
prompt: "Do something",
|
||||
category: "ultrabrain",
|
||||
run_in_background: false,
|
||||
skills: null,
|
||||
skills: [],
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
@@ -764,13 +761,12 @@ describe("sisyphus-task", () => {
|
||||
prompt: "Do something",
|
||||
category: "ultrabrain",
|
||||
run_in_background: false,
|
||||
skills: null,
|
||||
skills: [],
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
|
||||
// #then - should return agent not found error
|
||||
expect(result).toContain("❌")
|
||||
expect(result).toContain("not found")
|
||||
expect(result).toContain("registered")
|
||||
})
|
||||
@@ -819,7 +815,7 @@ describe("sisyphus-task", () => {
|
||||
prompt: "test",
|
||||
category: "custom-cat",
|
||||
run_in_background: false,
|
||||
skills: null
|
||||
skills: []
|
||||
}, toolContext)
|
||||
|
||||
// #then
|
||||
|
||||
@@ -64,7 +64,7 @@ function formatDetailedError(error: unknown, ctx: ErrorContext): string {
|
||||
const stack = error instanceof Error ? error.stack : undefined
|
||||
|
||||
const lines: string[] = [
|
||||
`❌ ${ctx.operation} failed`,
|
||||
`${ctx.operation} failed`,
|
||||
"",
|
||||
`**Error**: ${message}`,
|
||||
]
|
||||
@@ -181,37 +181,28 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
|
||||
subagent_type: tool.schema.string().optional().describe("Agent name directly (e.g., 'oracle', 'explore'). Mutually exclusive with category."),
|
||||
run_in_background: tool.schema.boolean().describe("Run in background. MUST be explicitly set. Use false for task delegation, true only for parallel exploration."),
|
||||
resume: tool.schema.string().optional().describe("Session ID to resume - continues previous agent session with full context"),
|
||||
skills: tool.schema.array(tool.schema.string()).nullable().describe("Array of skill names to prepend to the prompt. Use null if no skills needed. Empty array [] is NOT allowed."),
|
||||
skills: tool.schema.array(tool.schema.string()).describe("Array of skill names to prepend to the prompt. Use [] (empty array) if no skills needed."),
|
||||
},
|
||||
async execute(args: DelegateTaskArgs, toolContext) {
|
||||
const ctx = toolContext as ToolContextWithMetadata
|
||||
if (args.run_in_background === undefined) {
|
||||
return `❌ Invalid arguments: 'run_in_background' parameter is REQUIRED. Use run_in_background=false for task delegation, run_in_background=true only for parallel exploration.`
|
||||
return `Invalid arguments: 'run_in_background' parameter is REQUIRED. Use run_in_background=false for task delegation, run_in_background=true only for parallel exploration.`
|
||||
}
|
||||
if (args.skills === undefined) {
|
||||
return `❌ Invalid arguments: 'skills' parameter is REQUIRED. Use skills=null if no skills are needed, or provide an array of skill names.`
|
||||
return `Invalid arguments: 'skills' parameter is REQUIRED. Use skills=[] if no skills are needed, or provide an array of skill names.`
|
||||
}
|
||||
if (Array.isArray(args.skills) && args.skills.length === 0) {
|
||||
const allSkills = await discoverSkills({ includeClaudeCodePaths: true })
|
||||
const availableSkillsList = allSkills.map(s => ` - ${s.name}`).slice(0, 15).join("\n")
|
||||
return `❌ Invalid arguments: Empty array [] is not allowed for 'skills' parameter.
|
||||
|
||||
Use skills=null if this task genuinely requires no specialized skills.
|
||||
Otherwise, select appropriate skills from available options:
|
||||
|
||||
${availableSkillsList}${allSkills.length > 15 ? `\n ... and ${allSkills.length - 15} more` : ""}
|
||||
|
||||
If you believe no skills are needed, you MUST explicitly explain why to the user before using skills=null.`
|
||||
if (args.skills === null) {
|
||||
return `Invalid arguments: skills=null is not allowed. Use skills=[] (empty array) if no skills are needed.`
|
||||
}
|
||||
const runInBackground = args.run_in_background === true
|
||||
|
||||
let skillContent: string | undefined
|
||||
if (args.skills !== null && args.skills.length > 0) {
|
||||
if (args.skills.length > 0) {
|
||||
const { resolved, notFound } = await resolveMultipleSkillsAsync(args.skills, { gitMasterConfig })
|
||||
if (notFound.length > 0) {
|
||||
const allSkills = await discoverSkills({ includeClaudeCodePaths: true })
|
||||
const available = allSkills.map(s => s.name).join(", ")
|
||||
return `❌ Skills not found: ${notFound.join(", ")}. Available: ${available}`
|
||||
return `Skills not found: ${notFound.join(", ")}. Available: ${available}`
|
||||
}
|
||||
skillContent = Array.from(resolved.values()).join("\n\n")
|
||||
}
|
||||
@@ -334,7 +325,7 @@ Use \`background_output\` with task_id="${task.id}" to check progress.`
|
||||
toastManager.removeTask(taskId)
|
||||
}
|
||||
const errorMessage = promptError instanceof Error ? promptError.message : String(promptError)
|
||||
return `❌ Failed to send resume prompt: ${errorMessage}\n\nSession ID: ${args.resume}`
|
||||
return `Failed to send resume prompt: ${errorMessage}\n\nSession ID: ${args.resume}`
|
||||
}
|
||||
|
||||
// Wait for message stability after prompt completes
|
||||
@@ -372,7 +363,7 @@ Use \`background_output\` with task_id="${task.id}" to check progress.`
|
||||
if (toastManager) {
|
||||
toastManager.removeTask(taskId)
|
||||
}
|
||||
return `❌ Error fetching result: ${messagesResult.error}\n\nSession ID: ${args.resume}`
|
||||
return `Error fetching result: ${messagesResult.error}\n\nSession ID: ${args.resume}`
|
||||
}
|
||||
|
||||
const messages = ((messagesResult as { data?: unknown }).data ?? messagesResult) as Array<{
|
||||
@@ -390,7 +381,7 @@ Use \`background_output\` with task_id="${task.id}" to check progress.`
|
||||
}
|
||||
|
||||
if (!lastMessage) {
|
||||
return `❌ No assistant response found.\n\nSession ID: ${args.resume}`
|
||||
return `No assistant response found.\n\nSession ID: ${args.resume}`
|
||||
}
|
||||
|
||||
// Extract text from both "text" and "reasoning" parts (thinking models use "reasoning")
|
||||
@@ -409,11 +400,11 @@ ${textContent || "(No text output)"}`
|
||||
}
|
||||
|
||||
if (args.category && args.subagent_type) {
|
||||
return `❌ Invalid arguments: Provide EITHER category OR subagent_type, not both.`
|
||||
return `Invalid arguments: Provide EITHER category OR subagent_type, not both.`
|
||||
}
|
||||
|
||||
if (!args.category && !args.subagent_type) {
|
||||
return `❌ Invalid arguments: Must provide either category or subagent_type.`
|
||||
return `Invalid arguments: Must provide either category or subagent_type.`
|
||||
}
|
||||
|
||||
// Fetch OpenCode config at boundary to get system default model
|
||||
@@ -443,7 +434,7 @@ ${textContent || "(No text output)"}`
|
||||
systemDefaultModel,
|
||||
})
|
||||
if (!resolved) {
|
||||
return `❌ Unknown category: "${args.category}". Available: ${Object.keys({ ...DEFAULT_CATEGORIES, ...userCategories }).join(", ")}`
|
||||
return `Unknown category: "${args.category}". Available: ${Object.keys({ ...DEFAULT_CATEGORIES, ...userCategories }).join(", ")}`
|
||||
}
|
||||
|
||||
// Determine model source by comparing against the actual resolved model
|
||||
@@ -452,11 +443,11 @@ ${textContent || "(No text output)"}`
|
||||
const categoryDefaultModel = DEFAULT_CATEGORIES[args.category]?.model
|
||||
|
||||
if (!actualModel) {
|
||||
return `❌ No model configured. Set a model in your OpenCode config, plugin config, or use a category with a default model.`
|
||||
return `No model configured. Set a model in your OpenCode config, plugin config, or use a category with a default model.`
|
||||
}
|
||||
|
||||
if (!parseModelString(actualModel)) {
|
||||
return `❌ Invalid model format "${actualModel}". Expected "provider/model" format (e.g., "anthropic/claude-sonnet-4-5").`
|
||||
return `Invalid model format "${actualModel}". Expected "provider/model" format (e.g., "anthropic/claude-sonnet-4-5").`
|
||||
}
|
||||
|
||||
switch (actualModel) {
|
||||
@@ -484,7 +475,7 @@ ${textContent || "(No text output)"}`
|
||||
categoryPromptAppend = resolved.promptAppend || undefined
|
||||
} else {
|
||||
if (!args.subagent_type?.trim()) {
|
||||
return `❌ Agent name cannot be empty.`
|
||||
return `Agent name cannot be empty.`
|
||||
}
|
||||
const agentName = args.subagent_type.trim()
|
||||
agentToUse = agentName
|
||||
@@ -501,13 +492,13 @@ ${textContent || "(No text output)"}`
|
||||
if (!callableNames.includes(agentToUse)) {
|
||||
const isPrimaryAgent = agents.some((a) => a.name === agentToUse && a.mode === "primary")
|
||||
if (isPrimaryAgent) {
|
||||
return `❌ Cannot call primary agent "${agentToUse}" via delegate_task. Primary agents are top-level orchestrators.`
|
||||
return `Cannot call primary agent "${agentToUse}" via delegate_task. Primary agents are top-level orchestrators.`
|
||||
}
|
||||
|
||||
const availableAgents = callableNames
|
||||
.sort()
|
||||
.join(", ")
|
||||
return `❌ Unknown agent: "${agentToUse}". Available agents: ${availableAgents}`
|
||||
return `Unknown agent: "${agentToUse}". Available agents: ${availableAgents}`
|
||||
}
|
||||
} catch {
|
||||
// If we can't fetch agents, proceed anyway - the session.prompt will fail with a clearer error
|
||||
@@ -527,7 +518,7 @@ ${textContent || "(No text output)"}`
|
||||
parentModel,
|
||||
parentAgent,
|
||||
model: categoryModel,
|
||||
skills: args.skills ?? undefined,
|
||||
skills: args.skills.length > 0 ? args.skills : undefined,
|
||||
skillContent: systemContent,
|
||||
})
|
||||
|
||||
@@ -576,7 +567,7 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id
|
||||
})
|
||||
|
||||
if (createResult.error) {
|
||||
return `❌ Failed to create session: ${createResult.error}`
|
||||
return `Failed to create session: ${createResult.error}`
|
||||
}
|
||||
|
||||
const sessionID = createResult.data.id
|
||||
@@ -591,7 +582,7 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id
|
||||
description: args.description,
|
||||
agent: agentToUse,
|
||||
isBackground: false,
|
||||
skills: args.skills ?? undefined,
|
||||
skills: args.skills.length > 0 ? args.skills : undefined,
|
||||
modelInfo,
|
||||
})
|
||||
}
|
||||
@@ -713,7 +704,7 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id
|
||||
})
|
||||
|
||||
if (messagesResult.error) {
|
||||
return `❌ Error fetching result: ${messagesResult.error}\n\nSession ID: ${sessionID}`
|
||||
return `Error fetching result: ${messagesResult.error}\n\nSession ID: ${sessionID}`
|
||||
}
|
||||
|
||||
const messages = ((messagesResult as { data?: unknown }).data ?? messagesResult) as Array<{
|
||||
@@ -727,7 +718,7 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id
|
||||
const lastMessage = assistantMessages[0]
|
||||
|
||||
if (!lastMessage) {
|
||||
return `❌ No assistant response found.\n\nSession ID: ${sessionID}`
|
||||
return `No assistant response found.\n\nSession ID: ${sessionID}`
|
||||
}
|
||||
|
||||
// Extract text from both "text" and "reasoning" parts (thinking models use "reasoning")
|
||||
|
||||
@@ -5,5 +5,5 @@ export interface DelegateTaskArgs {
|
||||
subagent_type?: string
|
||||
run_in_background: boolean
|
||||
resume?: string
|
||||
skills: string[] | null
|
||||
skills: string[]
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export interface InteractiveBashArgs {
|
||||
tmux_command: string
|
||||
}
|
||||
@@ -208,7 +208,7 @@ export const lsp_diagnostics: ToolDefinition = tool({
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
return output
|
||||
throw new Error(output)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user