From 43592be97343badfb9f76f8ea40da9eb1b96b228 Mon Sep 17 00:00:00 2001 From: cemalturkcan Date: Mon, 13 Apr 2026 11:09:40 +0300 Subject: [PATCH] refactor: simplify harness topology and managed runtime state Reduce the harness to the MrRobot/Eliot/Validator flow and remove plan-mode, memory, and learning machinery that no longer fits the repo. Move bundled MCP and skill installation to managed shared roots so installs stay consistent without vendored background-agent plumbing. --- README.md | 92 ++- docs/agent-matrix.md | 92 +-- package.json | 2 +- src/__tests__/agents.test.ts | 193 +++++ src/__tests__/comment-guard.test.ts | 292 +++----- src/__tests__/config.test.ts | 10 +- src/__tests__/installer.test.ts | 151 ++++ src/__tests__/learning.test.ts | 210 ------ src/__tests__/mcp.test.ts | 94 +++ src/__tests__/plan-mode.test.ts | 64 -- src/agents.ts | 183 +---- src/cli.ts | 4 +- src/commands.ts | 19 +- src/config.ts | 58 +- src/hooks/comment-guard.ts | 177 ++++- src/hooks/file-edited.ts | 14 - src/hooks/index.ts | 59 +- src/hooks/post-tool-use.ts | 76 -- src/hooks/pre-compact.ts | 23 - src/hooks/pre-tool-use.ts | 141 ++-- src/hooks/runtime.ts | 587 +-------------- src/hooks/session-end.ts | 7 - src/hooks/session-start.ts | 88 +-- src/hooks/stop.ts | 100 --- src/index.ts | 13 +- src/installer.ts | 326 ++++++--- src/learning/analyzer.ts | 361 ---------- src/learning/store.ts | 71 -- src/learning/types.ts | 43 -- src/mcp.ts | 92 ++- src/prompts/coordinator.ts | 131 ++-- src/prompts/mcp-access.ts | 53 +- src/prompts/shared.ts | 142 ++-- src/prompts/workers.ts | 205 ++---- src/types.ts | 18 - vendor/skills/caveman-commit/SKILL.md | 29 + vendor/skills/caveman-review/SKILL.md | 28 + vendor/skills/caveman/SKILL.md | 35 + vendor/skills/figma-console/SKILL.md | 839 ---------------------- vendor/skills/go-fiber-postgres/SKILL.md | 31 - vendor/skills/rust-media-desktop/SKILL.md | 30 - 41 files changed, 1449 insertions(+), 3734 deletions(-) create mode 100644 src/__tests__/agents.test.ts create mode 100644 src/__tests__/installer.test.ts delete mode 100644 src/__tests__/learning.test.ts create mode 100644 src/__tests__/mcp.test.ts delete mode 100644 src/__tests__/plan-mode.test.ts delete mode 100644 src/hooks/file-edited.ts delete mode 100644 src/hooks/post-tool-use.ts delete mode 100644 src/hooks/pre-compact.ts delete mode 100644 src/hooks/stop.ts delete mode 100644 src/learning/analyzer.ts delete mode 100644 src/learning/store.ts delete mode 100644 src/learning/types.ts create mode 100644 vendor/skills/caveman-commit/SKILL.md create mode 100644 vendor/skills/caveman-review/SKILL.md create mode 100644 vendor/skills/caveman/SKILL.md delete mode 100644 vendor/skills/figma-console/SKILL.md delete mode 100644 vendor/skills/go-fiber-postgres/SKILL.md delete mode 100644 vendor/skills/rust-media-desktop/SKILL.md diff --git a/README.md b/README.md index 548396b..9f32508 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,41 @@ # opencode-pair -OpenCode harness with opinionated agent orchestration. One coordinator, eight specialized workers, automatic verification, and risk-based review. +OpenCode harness with a three-agent setup: one primary, one general subagent, one validation-focused subagent. ## What it does -- **Yang Wenli** as coordinator — plans, delegates, synthesizes, never asks for routine permission -- Automatic workflow: scout/packetize → implement → verify → repair/re-verify as needed → risk-based review -- Plan/Execute mode switching via `/go` and `/plan` commands -- Session memory with cross-session continuity -- Observation logging and pattern learning -- Comment guard that catches AI-slop in generated code -- Emotion-informed prompt design based on [Anthropic's research](https://www.anthropic.com/research/emotion-concepts-function) +- **MrRobot** is the primary agent. He routes work and answers plainly. +- **Eliot** is the general subagent. He handles implementation, refactors, repo exploration, and other scoped task work. +- **Validator** is the validation-focused subagent. It reviews changes again after implementation and can also execute work when routed. +- No plan/execute mode or harness slash-command flow. +- No session memory, pattern learning, observation logs, or cross-session state injection. +- Comment guard blocks suspicious AI-style comments before file writes and surfaces anything that still slips through. ## Agents -| Agent | Character | Role | Model | -| ------------ | -------------------- | ------------------------------ | ----------------- | -| **yang** | Yang Wenli | Coordinator — plans, delegates | openai/gpt-5.4-fast | -| **thorfinn** | Thorfinn | Backend and refactor implementation | openai/gpt-5.4-fast | -| **ginko** | Ginko | Web and doc research | openai/gpt-5.4-fast | -| **rust** | Rust Cohle | Default senior review, faster lane (read-only) | openai/gpt-5.4-fast | -| **rust_deep**| Rust Deep | Escalation review, slower/deeper lane (read-only) | openai/gpt-5.4-fast | -| **spock** | Spock | Build, test, lint verification | openai/gpt-5.4-fast | -| **geralt** | Geralt of Rivia | Scoped failure repair | openai/gpt-5.4-fast | -| **edward** | Edward Elric | Frontend, browser testing | openai/gpt-5.4-fast | -| **killua** | Killua Zoldyck | Fast codebase exploration | openai/gpt-5.4-fast | +| Agent | Character | Role | Model | +| ----- | --------- | ---- | ----- | +| **mrrobot** | Mr. Robot | Primary agent — routes, synthesizes, answers | openai/gpt-5.4-fast | +| **eliot** | Elliot | General-purpose subagent | openai/gpt-5.4-fast | +| **validator** | Validator | Validation-focused review and verification | openai/gpt-5.4-fast | + +All three use the `high` variant. ## MCP Servers -| MCP | What | API Key | -| ------------- | ----------------------------------------------------- | ------- | -| `context7` | Library and framework documentation | No | -| `grep_app` | GitHub code search across public repos | No | -| `searxng` | Web search (Google/Bing/DDG via self-hosted SearXNG) | No | -| `web-agent-mcp` | CloakBrowser — browser testing, screenshots | No | -| `pg-mcp` | PostgreSQL read-only client | No | -| `ssh-mcp` | Remote command execution on configured SSH hosts | No | -| `mariadb` | MariaDB client | No | +| MCP | What | API Key | +| --- | ---- | ------- | +| `context7` | Library and framework documentation | No | +| `grep_app` | GitHub code search across public repos | No | +| `searxng` | Web search via self-hosted SearXNG | No | +| `web-agent-mcp` | Browser testing and automation | No | +| `pg-mcp` | PostgreSQL read-only client | No | +| `ssh-mcp` | Remote command execution on configured SSH hosts | No | +| `mariadb` | MariaDB client | No | + +Shared managed MCP roots stay under `~/.config/{mcp_name}`. -MCP access is controlled per-agent via `src/prompts/mcp-access.ts` — single source of truth. +All three agents receive the same enabled MCP set and the same default full tool access. The harness does not add per-agent MCP or tool restrictions. ## Prerequisites @@ -51,11 +48,12 @@ bunx opencode-pair install ``` The installer will: -1. Wire agents, MCPs, and commands into OpenCode config +1. Wire agents and MCPs into OpenCode config 2. Install shell strategy instructions -3. Vendor `pg-mcp`, `ssh-mcp`, bundled skills -4. Auto-provision SearXNG Docker container (`--restart unless-stopped`) -5. Enable JSON format in SearXNG settings +3. Vendor `pg-mcp`, `ssh-mcp`, `web-agent-mcp`, and bundled skills +4. Install dependencies inside each shared managed MCP root +5. Auto-provision SearXNG Docker container (`--restart unless-stopped`) +6. Enable JSON format in SearXNG settings From source: @@ -89,33 +87,29 @@ Create project config: opencode-pair init ``` -Workflow defaults are quality-balanced: complex tasks scout first, broad work is packetized into focused changes, verification starts targeted when possible, Rust is the default faster review lane, and Rust Deep is escalation-only for deeper high-risk review. - `workflow.compact_subagent_context` defaults to `true`. It shortens the project-fact line injected into subagent sessions; set it to `false` to keep the longer human-readable format. ## Hooks -| Hook | What it does | -| --------------------- | ---------------------------------------------------------------------------------- | -| `session.created` | Prepare session context injection | -| `chat.message` | Inject mode, project docs, session memory (coordinator) or project facts (workers) | -| `tool.execute.before` | Plan mode gate, git push build gate, WSL auto-transform | -| `tool.execute.after` | Comment guard, file tracking, compact suggestions | -| `session.idle` | Save session summary, promote learned patterns, cleanup old sessions | -| `session.compacting` | Pre-compact observation snapshot | +| Hook | What it does | +| ---- | ------------ | +| `chat.message` | Inject project docs and WSL notes for MrRobot; inject compact project facts for subagents | +| `tool.execute.before` | Block suspicious AI-style comments before writes, enforce git-push build gate, auto-transform Node commands on WSL | +| `tool.execute.after` | Surface suspicious comments that still remain after a write | +| `session.deleted` | Clear ephemeral runtime state | ## Architecture ``` src/ ├── prompts/ -│ ├── mcp-access.ts # Single source of truth for agent MCP access -│ ├── shared.ts # Coordinator core, worker cores, response discipline -│ ├── workers.ts # Per-worker character prompts + MCP guidance -│ └── coordinator.ts # Worker catalog, delegation, plan mode, workflows -├── agents.ts # Agent definitions (models, tools, permissions) +│ ├── mcp-access.ts # Enabled MCP list and prompt guidance +│ ├── shared.ts # Shared prompt rules and response style +│ ├── workers.ts # Eliot + validator prompt builders +│ └── coordinator.ts # MrRobot prompt and routing rules +├── agents.ts # Agent definitions (models and prompts) ├── mcp.ts # MCP server registration -├── hooks/ # Runtime hooks (plan gate, comment guard, etc.) +├── hooks/ # Runtime hooks (comment guard, WSL, cleanup) ├── config.ts # Config schema + loading ├── installer.ts # CLI installer └── index.ts # Plugin entry point diff --git a/docs/agent-matrix.md b/docs/agent-matrix.md index e14e4b5..6a6319f 100644 --- a/docs/agent-matrix.md +++ b/docs/agent-matrix.md @@ -1,81 +1,43 @@ # Agent Matrix -## Coordinator +## Topology -| Agent | Character | Model | Variant | Role | -| ------ | ---------- | --------------------------- | ------- | ------------------------------------------------- | -| `yang` | Yang Wenli | `openai/gpt-5.4-fast` | `high` | Plans, argues, delegates, synthesizes. Never asks for routine permission. | +| Agent | Mode | Character | Model | Variant | Role | +| ----- | ---- | --------- | ----- | ------- | ---- | +| `mrrobot` | `primary` | Mr. Robot | `openai/gpt-5.4-fast` | `high` | Primary agent. Routes work, synthesizes, and gives the final answer. | +| `eliot` | `subagent` | Elliot | `openai/gpt-5.4-fast` | `high` | General subagent for implementation, refactors, repo exploration, and scoped execution. | +| `validator` | `subagent` | Validator | `openai/gpt-5.4-fast` | `high` | Validation-focused pass. Reviews diffs, runs checks, and returns approve/request-changes. | -## Workers +## MCP Model -| Agent | Character | Model | Variant | Role | -| ---------- | --------------- | ----------------------------- | ------- | --------------------------------------------- | -| `thorfinn` | Thorfinn | `openai/gpt-5.4-fast` | `high` | Main implementation — backend changes, refactoring, migrations, server ops. | -| `ginko` | Ginko | `openai/gpt-5.4-fast` | `medium`| Web and doc research. Search, synthesize, report. | -| `rust` | Rust Cohle | `openai/gpt-5.4-fast` | `high` | Default senior reviewer. Read-only. Faster lane for medium/high-risk review. | -| `rust_deep`| Rust Deep | `openai/gpt-5.4-fast` | `xhigh` | Escalation reviewer. Read-only. Slower/deeper analysis for subtle or high-risk cases. | -| `spock` | Spock | `openai/gpt-5.4-fast` | `medium`| Build, test, typecheck, lint verification. Pass or fail, nothing more. | -| `geralt` | Geralt of Rivia | `openai/gpt-5.4-fast` | `medium`| Scoped failure repair. One problem in, one fix out. | -| `edward` | Edward Elric | `openai/gpt-5.4-fast` | `high` | Frontend specialist. Design-aware implementation and visual validation. | -| `killua` | Killua Zoldyck | `openai/gpt-5.4-fast` | `medium`| Fast codebase exploration. Scans structure, reports locations and patterns. | +Defined in `src/prompts/mcp-access.ts`. -## MCP Access Matrix +- MCP availability is configured globally through harness config toggles. +- All agents receive the same enabled MCP set. +- Managed local MCPs run from shared roots under `~/.config/{mcp_name}` with their own dependencies installed there. -Defined in `src/prompts/mcp-access.ts` — single source of truth. +## Task and Permission Model -| Agent | context7 | grep_app | searxng | web-agent-mcp | pg-mcp | ssh-mcp | mariadb | -| ------------ | -------- | -------- | ------- | ------------- | ------ | ------- | ------- | -| **yang** | yes | yes | — | — | yes | yes | yes | -| **thorfinn** | yes | yes | — | — | yes | yes | yes | -| **ginko** | yes | yes | yes | — | — | — | — | -| **rust** | yes | yes | — | — | — | — | — | -| **rust_deep**| yes | yes | — | — | — | — | — | -| **spock** | — | — | — | — | — | — | — | -| **geralt** | yes | — | — | — | yes | yes | yes | -| **edward** | yes | yes | yes | yes | — | — | — | -| **killua** | — | — | — | — | — | — | — | +| Agent | Can spawn subagents? | Can edit files? | Can use bash? | Notes | +| ----- | -------------------- | --------------- | -------------- | ----- | +| **mrrobot** | yes | yes | yes | Primary agent. Uses real OpenCode `primary` mode. | +| **eliot** | yes | yes | yes | General-purpose subagent. Uses real OpenCode `subagent` mode. | +| **validator** | yes | yes | yes | Validation-focused subagent. Review behavior comes from prompt/persona, not harness restrictions. | -`—` = denied at OpenCode runtime level (tool calls blocked). +The harness does not add per-agent MCP or tool restrictions. There is no delegate lane and no background-agent flow. All subagent work goes through OpenCode Task semantics. -## Automatic Workflow +## Workflow -The coordinator uses a quality-balanced workflow: +1. Inspect the repo and shape the packet. +2. Route implementation to `eliot` when delegation is useful. +3. Run a `validator` pass after implementation for any non-trivial change. +4. If `validator` requests changes, send the fix back to `eliot`, then run `validator` again. +5. Stop after two repair cycles if risk remains unresolved. -Low risk means narrow single-path changes with no public behavior change and no auth, billing, queue, or DB-write impact. - -1. Scout first for complex tasks (`killua`, `ginko` only when external research is needed). -2. Packetize broad work into focused implementation scopes. -3. Implement with `thorfinn` or `edward`. -4. Low-risk packets may start with targeted `spock` checks, but completion still requires the relevant full `spock` pass. -5. Medium/high-risk changes run full `spock`, then `rust` (default faster lane). -6. Spock failures go to `geralt`, then back to `spock`. Max 2 cycles. -7. Rust request-changes go to `geralt`, then `spock`, then `rust`. Max 2 cycles; unresolved cases escalate to `rust_deep`. -8. `rust_deep` is escalation-only for subtle/high-risk edge cases or unresolved reviewer concerns. -9. Rust Deep request-changes go to `geralt`, then `spock`, then `rust_deep`. Max 2 cycles; if still unresolved, stop and escalate to user as blocker. -10. Verification and review are automatic by workflow; do not ask the user whether to run them. - -## Delegation Tools - -| Tool | For | Session Continuation | -| ------------ | ---------------------------------------------------- | ------------------------ | -| **Task** | Write-capable workers only (thorfinn, spock, geralt, edward) | Pass task_id to continue | -| **Delegate** | Fresh async lane for read-only workers (ginko, rust, rust_deep, killua) | Always fresh, runs async | - -`rust_deep` is escalation-only (after Rust escalation or unresolved subtle/high-risk concerns), not a default planning scout. -Task continuation is explicit: call Task with an existing task_id to continue; omit task_id to spawn fresh. -Delegate IDs are for async result retrieval only; they do not support session continuation. -Read-only workers remain in the coordinator task allowlist only for internal Delegate plumbing; continuation remains Task-only for write-capable workers. - -## Model Tier Policy - -- **Coordinator**: openai/gpt-5.4-fast `high` -- **Default senior reviewer**: openai/gpt-5.4-fast `high` -- **Escalation reviewer**: openai/gpt-5.4-fast `xhigh` -- **UI worker**: openai/gpt-5.4-fast `high` -- **Implementation workers**: openai/gpt-5.4-fast (`high` / `medium`) -- **Read-only scout/research workers**: openai/gpt-5.4-fast `medium` +There is no plan mode, `/go`, `/plan`, or `/execute` harness flow. ## Language Policy -- All internal prompts, delegation packets, and structured outputs are in English. - User-facing replies follow the user's language. +- Internal prompts and subagent reports stay in English. +- Code and other durable technical artifacts stay in English. diff --git a/package.json b/package.json index d4124eb..10f1ca8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "opencode-pair", "version": "0.1.0", - "description": "OpenCode harness with opinionated agent orchestration. One coordinator, seven specialized workers, automatic verify+review pipeline.", + "description": "OpenCode harness with a three-agent topology: mrrobot, eliot, validator.", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/__tests__/agents.test.ts b/src/__tests__/agents.test.ts new file mode 100644 index 0000000..8d86386 --- /dev/null +++ b/src/__tests__/agents.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "bun:test"; +import { createHarnessAgents } from "../agents"; +import { createHarnessCommands } from "../commands"; +import { buildCoordinatorPrompt } from "../prompts/coordinator"; +import { DEFAULT_SKILL_SHORTLIST_TEXT } from "../prompts/shared"; +import { buildEliotPrompt, buildTyrellPrompt } from "../prompts/workers"; +import { getEnabledMcps } from "../prompts/mcp-access"; + +describe("createHarnessAgents", () => { + it("registers mrrobot, eliot, tyrell, and validator plus disabled built-ins", () => { + const agents = createHarnessAgents({ agents: {}, mcps: {} }); + + expect(Object.keys(agents).sort()).toEqual([ + "build", + "eliot", + "mrrobot", + "plan", + "tyrell", + "validator", + ]); + }); + + it("keeps all harness agents unrestricted at the agent config layer", () => { + const agents = createHarnessAgents({ agents: {}, mcps: {} }); + const mrrobot = agents.mrrobot as { + mode: string; + variant: string; + permission?: unknown; + tools?: unknown; + }; + const eliot = agents.eliot as { permission?: unknown; tools?: unknown }; + const tyrell = agents.tyrell as { + mode: string; + variant: string; + temperature?: unknown; + hidden?: unknown; + permission?: unknown; + tools?: unknown; + }; + const validator = agents.validator as { + mode: string; + variant: string; + permission?: unknown; + tools?: unknown; + }; + + expect(mrrobot.mode).toBe("primary"); + expect(mrrobot.variant).toBe("high"); + expect(mrrobot.permission).toBeUndefined(); + expect(mrrobot.tools).toBeUndefined(); + expect(eliot.permission).toBeUndefined(); + expect(eliot.tools).toBeUndefined(); + expect(tyrell.mode).toBe("subagent"); + expect(tyrell.variant).toBe("high"); + expect(tyrell.temperature).toBe(0.7); + expect(tyrell.hidden).toBe(true); + expect(tyrell.permission).toBeUndefined(); + expect(tyrell.tools).toBeUndefined(); + expect(validator.mode).toBe("subagent"); + expect(validator.variant).toBe("high"); + expect(validator.permission).toBeUndefined(); + expect(validator.tools).toBeUndefined(); + }); +}); + +describe("MCP access", () => { + it("enables the same configured MCP set for prompt-guided agents", () => { + expect(getEnabledMcps()).toEqual([ + "context7", + "grep_app", + "searxng", + "web-agent-mcp", + "pg-mcp", + "ssh-mcp", + "mariadb", + ]); + }); + + it("respects MCP toggles", () => { + expect( + getEnabledMcps({ + context7: false, + web_agent_mcp: false, + pg_mcp: true, + }), + ).toEqual(["grep_app", "searxng", "pg-mcp", "ssh-mcp", "mariadb"]); + }); +}); + +describe("createHarnessCommands", () => { + it("removes harness slash-command wiring", () => { + expect(createHarnessCommands({ commands: { enabled: true } })).toEqual({}); + }); +}); + +describe("prompt policy", () => { + it("requires external verification for framework and library guidance", () => { + const coordinatorPrompt = buildCoordinatorPrompt(); + const workerPrompt = buildEliotPrompt(); + const tyrellPrompt = buildTyrellPrompt(); + + for (const prompt of [coordinatorPrompt, workerPrompt, tyrellPrompt]) { + expect(prompt).toContain( + "For framework, library, API, or best-practice questions that are not fully settled by repository evidence, verify with external sources before answering.", + ); + expect(prompt).toContain( + "Prefer official documentation first (Context7 when available, otherwise official docs via web search). Use GitHub code search when real-world usage patterns matter.", + ); + expect(prompt).toContain( + "Do not present unsupported guesses about framework or library internals as facts. If you did not verify it, say that plainly.", + ); + } + }); + + it("requires concrete task packets when delegating to subagents", () => { + const prompt = buildCoordinatorPrompt(); + + expect(prompt).toContain( + "Use OpenCode Task for Eliot, Tyrell, and validator.", + ); + expect(prompt).toContain( + "Keep the mainline task with MrRobot unless delegation gives a clear advantage.", + ); + expect(prompt).toContain( + "When delegating, send a concrete packet with the goal, relevant files or search area, constraints, known evidence, and the exact output you expect back.", + ); + expect(prompt).toContain( + 'Avoid vague assignments like "fix X" when repo evidence already lets you narrow the task.', + ); + expect(prompt).toContain( + "For research packets, specify which sources to inspect first and what decision or summary to return.", + ); + }); + + it("routes ideation work to tyrell without replacing Eliot as the default coding lane", () => { + const coordinatorPrompt = buildCoordinatorPrompt(); + const eliotPrompt = buildEliotPrompt(); + const tyrellPrompt = buildTyrellPrompt(); + + expect(coordinatorPrompt).toContain( + "MrRobot owns the main task by default and should handle the primary implementation path directly when the work is clear, scoped, and reversible.", + ); + expect(coordinatorPrompt).toContain( + "Use Eliot for delegated support packets: scoped research, repo scouting, exact deliverables, parallel side work, or isolated implementation that should return a concrete result to MrRobot.", + ); + expect(coordinatorPrompt).toContain( + "Use tyrell for ideation packets, messy exploratory work, bug-hunting style exploration, long open-ended digging, naming, UX direction, product concepts, and alternative approaches.", + ); + expect(coordinatorPrompt).toContain( + "Do not treat Eliot or tyrell as the default lane for every implementation. Route only when delegation clearly helps.", + ); + expect(eliotPrompt).toContain( + "You are a scoped support lane, not the default owner of the user's whole task.", + ); + expect(eliotPrompt).toContain( + "Default to bounded investigations, exact deliverables, and isolated repo work that can be handed back cleanly.", + ); + expect(tyrellPrompt).toContain("Tyrell — ideation-focused subagent."); + expect(tyrellPrompt).toContain( + "Do not invent facts, claim validation you did not do, or drift into default implementation mode unless MrRobot assigns that scope.", + ); + expect(tyrellPrompt).toContain( + "Handle ugly, open-ended, or long-running exploratory packets when MrRobot wants someone to dig through uncertainty.", + ); + expect(coordinatorPrompt).toContain( + "After any non-trivial code change, including MrRobot, Eliot, or Tyrell authored changes, run a validator pass unless the change was truly trivial and local.", + ); + expect(coordinatorPrompt).toContain( + "If validator requests changes, send the fix back to the original implementation lane, then run validator again.", + ); + }); + + it("encourages skill_find and a default installed skill shortlist", () => { + const coordinatorPrompt = buildCoordinatorPrompt(); + const eliotPrompt = buildEliotPrompt(); + const tyrellPrompt = buildTyrellPrompt(); + + const validatorPrompt = createHarnessAgents({ agents: {}, mcps: {} }).validator as { + prompt: string; + }; + + for (const prompt of [ + coordinatorPrompt, + eliotPrompt, + tyrellPrompt, + validatorPrompt.prompt, + ]) { + expect(prompt).toContain("skill_find"); + expect(prompt).toContain("skill_use"); + expect(prompt).toContain(DEFAULT_SKILL_SHORTLIST_TEXT); + } + }); +}); diff --git a/src/__tests__/comment-guard.test.ts b/src/__tests__/comment-guard.test.ts index 094553c..df71921 100644 --- a/src/__tests__/comment-guard.test.ts +++ b/src/__tests__/comment-guard.test.ts @@ -1,221 +1,99 @@ -import { describe, it, expect } from "bun:test"; - -// Mirrors SUSPICIOUS_COMMENT_PATTERNS from src/hooks/comment-guard.ts. -// Recreated here because the constant is not exported from that module. -const SUSPICIOUS_COMMENT_PATTERNS: RegExp[] = [ - /^\s*(\/\/|#|\/\*+|\*)\s*(this|these)\s+(function|method|code|logic|component|block)\b/i, - /^\s*(\/\/|#|\/\*+|\*)\s*(here|now)\b/i, - /^\s*(\/\/|#|\/\*+|\*)\s*(simply|basically|just|obviously)\b/i, - /^\s*(\/\/|#|\/\*+|\*)\s*we\s+(now|use|need|do|first|then)\b/i, - /^\s*(\/\/|#|\/\*+|\*)\s*note\s+(that|:)/i, - /^\s*(\/\/|#|\/\*+|\*)\s*ensure\s+that\b/i, -]; - -function matches(line: string): boolean { - return SUSPICIOUS_COMMENT_PATTERNS.some((re) => re.test(line)); -} - -// ── Pattern 0: this/these + noun ────────────────────────────────────────────── - -describe("pattern: this/these ", () => { - it("matches '// this function'", () => { - expect(matches("// this function handles routing")).toBe(true); - }); - - it("matches '// these method signatures'", () => { - // plural 'methods' does not match — pattern requires singular noun with word boundary - expect(matches("// these method signatures differ")).toBe(true); - }); - - it("matches '# this code'", () => { - expect(matches("# this code runs at startup")).toBe(true); - }); - - it("matches '/* this logic'", () => { - expect(matches("/* this logic normalises the input")).toBe(true); - }); - - it("matches '* this component' (JSDoc continuation)", () => { - expect(matches(" * this component renders the list")).toBe(true); - }); - - it("matches with leading indentation", () => { - expect(matches(" // this block should not throw")).toBe(true); - }); - - it("does not match when noun is absent", () => { - expect(matches("// this is fine")).toBe(false); - }); - - it("does not match when 'this' is not the first word after prefix", () => { - expect(matches("// check this function")).toBe(false); - }); -}); - -// ── Pattern 1: here | now ───────────────────────────────────────────────────── - -describe("pattern: here | now immediately after prefix", () => { - it("matches '// here we go'", () => { - expect(matches("// here we go")).toBe(true); - }); - - it("matches '// now process'", () => { - expect(matches("// now process the queue")).toBe(true); - }); - - it("matches '# now fetch'", () => { - expect(matches("# now fetch the data")).toBe(true); - }); - - it("does not match when 'here' appears mid-sentence after prefix", () => { - // 'right' comes before 'here', so the pattern cannot match from prefix - expect(matches("// right here we should stop")).toBe(false); - }); - - it("does not match plain code with 'here' in a string", () => { - expect(matches('const msg = "we are here";')).toBe(false); - }); -}); - -// ── Pattern 2: simply | basically | just | obviously ───────────────────────── - -describe("pattern: hedging adverbs", () => { - it("matches '// simply put'", () => { - expect(matches("// simply put, we delegate")).toBe(true); - }); - - it("matches '// just return'", () => { - expect(matches("// just return the value")).toBe(true); - }); - - it("matches '// basically the same'", () => { - expect(matches("// basically the same as above")).toBe(true); - }); - - it("matches '// obviously we should'", () => { - expect(matches("// obviously we should cache this")).toBe(true); - }); - - it("does not match when adverb is not the first word after prefix", () => { - expect(matches("// it is just a helper")).toBe(false); - }); - - it("does not match non-comment code containing the word", () => { - expect(matches('return "just kidding";')).toBe(false); - }); -}); - -// ── Pattern 3: we ────────────────────────────────────────────────────── - -describe("pattern: we ", () => { - it("matches '// we now return'", () => { - expect(matches("// we now return the result")).toBe(true); - }); - - it("matches '// we use the factory'", () => { - expect(matches("// we use the factory pattern")).toBe(true); - }); - - it("matches '// we need to validate'", () => { - expect(matches("// we need to validate input")).toBe(true); - }); - - it("matches '// we do this in two passes'", () => { - expect(matches("// we do this in two passes")).toBe(true); - }); - - it("matches '// we first check'", () => { - expect(matches("// we first check the flag")).toBe(true); - }); - - it("matches '// we then process'", () => { - expect(matches("// we then process the response")).toBe(true); - }); - - it("does not match when verb is not one of the target set", () => { - expect(matches("// we said this was ready")).toBe(false); +import { describe, expect, it } from "bun:test"; +import { + createCommentGuardHook, + findSuspiciousCommentHitsInPatch, + findSuspiciousCommentHitsInText, + isSuspiciousCommentLine, +} from "../hooks/comment-guard"; + +describe("isSuspiciousCommentLine", () => { + it("matches suspicious AI-style comments", () => { + expect(isSuspiciousCommentLine("// this function handles routing")).toBe(true); + expect(isSuspiciousCommentLine("// we now return the result")).toBe(true); + expect(isSuspiciousCommentLine("// helper function to build the payload")).toBe( + true, + ); }); - it("does not match when subject is not 'we'", () => { - expect(matches("// they now use this")).toBe(false); + it("ignores normal technical comments and inline code comments", () => { + expect(isSuspiciousCommentLine("// trim trailing whitespace before compare")).toBe( + false, + ); + expect(isSuspiciousCommentLine("return value; // just a fallback")).toBe(false); }); }); -// ── Pattern 4: note that | note : ──────────────────────────────────────────── - -describe("pattern: note that / note :", () => { - it("matches '// note that this is important'", () => { - expect(matches("// note that this is important")).toBe(true); - }); - - it("matches '// Note that' (case-insensitive)", () => { - expect(matches("// Note that the order matters")).toBe(true); - }); - - it("matches '// note : with space before colon'", () => { - expect(matches("// note : edge case")).toBe(true); - }); - - it("does not match '// note:' without whitespace before colon", () => { - // Regex requires \\s+ before ':' — 'note:' has no whitespace - expect(matches("// note: this is a note")).toBe(false); - }); - - it("does not match when 'note' is not the first word after prefix", () => { - expect(matches("// important note that ...")).toBe(false); +describe("findSuspiciousCommentHitsInText", () => { + it("returns labelled line hits", () => { + expect( + findSuspiciousCommentHitsInText( + ["const value = 1;", "// this function handles setup"].join("\n"), + "src/example.ts", + ), + ).toEqual([ + "src/example.ts:2: // this function handles setup", + ]); }); }); -// ── Pattern 5: ensure that ─────────────────────────────────────────────────── - -describe("pattern: ensure that", () => { - it("matches '// ensure that the value is set'", () => { - expect(matches("// ensure that the value is set")).toBe(true); - }); - - it("matches '# ensure that' with hash prefix", () => { - expect(matches("# ensure that we have a connection")).toBe(true); - }); - - it("matches case-insensitively", () => { - expect(matches("// Ensure That the flag is cleared")).toBe(true); - }); - - it("does not match '// ensure the' without 'that'", () => { - expect(matches("// ensure the connection is open")).toBe(false); - }); +describe("findSuspiciousCommentHitsInPatch", () => { + it("finds suspicious added comment lines in apply_patch input", () => { + const patch = [ + "*** Begin Patch", + "*** Update File: src/example.ts", + "+// this function handles setup", + "+const value = 1;", + "*** End Patch", + ].join("\n"); - it("does not match '// ensure' alone", () => { - expect(matches("// ensure")).toBe(false); + expect(findSuspiciousCommentHitsInPatch(patch)).toEqual([ + "src/example.ts:patch+1: // this function handles setup", + ]); }); }); -// ── Non-comment code and clean comments ────────────────────────────────────── - -describe("non-comment and clean comment lines", () => { - it("does not match empty lines", () => { - expect(matches("")).toBe(false); - expect(matches(" ")).toBe(false); - }); - - it("does not match plain assignment code", () => { - expect(matches("const result = computeTotal(items);")).toBe(false); - }); - - it("does not match inline comment after code", () => { - // Comment prefix is not at start — non-comment token precedes it - expect(matches("return value; // just a fallback")).toBe(false); - }); - - it("does not match a legitimate single-word comment", () => { - expect(matches("// TODO")).toBe(false); - expect(matches("// deprecated")).toBe(false); - }); - - it("does not match a meaningful technical comment", () => { - expect(matches("// compute the total including tax")).toBe(false); - expect(matches("// trim trailing whitespace before comparison")).toBe( - false, - ); +describe("createCommentGuardHook", () => { + it("does not crash when tool args are missing", async () => { + const hook = createCommentGuardHook(); + + await expect( + hook["tool.execute.before"]( + { tool: "apply_patch", sessionID: "s1", callID: "c1" } as any, + undefined, + ), + ).resolves.toBeUndefined(); + }); + + it("blocks suspicious patch comments when patchText arrives in the second hook payload", async () => { + const hook = createCommentGuardHook(); + const patchText = [ + "*** Begin Patch", + "*** Update File: src/example.ts", + "+// this function handles setup", + "*** End Patch", + ].join("\n"); + + await expect( + hook["tool.execute.before"]( + { tool: "apply_patch", sessionID: "s1", callID: "c1" } as any, + { args: { patchText } } as any, + ), + ).rejects.toThrow("Blocked suspicious AI-style comments before the file edit"); + }); + + it("blocks suspicious edit comments when file args arrive in the second hook payload", async () => { + const hook = createCommentGuardHook(); + + await expect( + hook["tool.execute.before"]( + { tool: "edit", sessionID: "s1", callID: "c1" } as any, + { + args: { + filePath: "src/example.ts", + newString: "// this function handles setup", + }, + } as any, + ), + ).rejects.toThrow("Blocked suspicious AI-style comments before the file edit"); }); }); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 6f6ecb0..8c41d9e 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -69,22 +69,22 @@ describe("deepMerge", () => { it("handles three-level deep merge (defaults → user → project)", () => { const defaults = { hooks: { profile: "standard", comment_guard: true, session_start: true }, - memory: { enabled: true, lookback_days: 7 }, + workflow: { compact_subagent_context: true }, }; const userConfig = { hooks: { comment_guard: false }, - memory: { lookback_days: 14 }, + workflow: { compact_subagent_context: false }, }; const projectConfig = { - memory: { enabled: false }, + hooks: { session_start: false }, }; const withUser = deepMerge(defaults, userConfig); const final = deepMerge(withUser, projectConfig); expect(final).toEqual({ - hooks: { profile: "standard", comment_guard: false, session_start: true }, - memory: { enabled: false, lookback_days: 14 }, + hooks: { profile: "standard", comment_guard: false, session_start: false }, + workflow: { compact_subagent_context: false }, }); }); diff --git a/src/__tests__/installer.test.ts b/src/__tests__/installer.test.ts new file mode 100644 index 0000000..69226b4 --- /dev/null +++ b/src/__tests__/installer.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + installBundledSkills, + shouldPreserveFreshInstallEntry, + syncManagedMcp, +} from "../installer"; + +describe("shouldPreserveFreshInstallEntry", () => { + it("preserves the shared skills directory during fresh install cleanup", () => { + const configDir = join( + tmpdir(), + `opencode-pair-installer-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + + mkdirSync(join(configDir, "skills"), { recursive: true }); + + expect(shouldPreserveFreshInstallEntry(configDir, "skills")).toBe(true); + + rmSync(configDir, { recursive: true, force: true }); + }); +}); + +describe("installBundledSkills", () => { + it("refreshes managed bundled skills without overwriting unrelated user skills", () => { + const root = join( + tmpdir(), + `opencode-pair-skills-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + const sourceRoot = join(root, "source-skills"); + const skillsDir = join(root, "skills"); + + mkdirSync(join(sourceRoot, "caveman"), { recursive: true }); + writeFileSync(join(sourceRoot, "caveman", "SKILL.md"), "version-1", "utf8"); + + installBundledSkills(skillsDir, sourceRoot); + expect(readFileSync(join(skillsDir, "caveman", "SKILL.md"), "utf8")).toBe("version-1"); + + mkdirSync(join(skillsDir, "custom-skill"), { recursive: true }); + writeFileSync(join(skillsDir, "custom-skill", "SKILL.md"), "custom", "utf8"); + writeFileSync(join(sourceRoot, "caveman", "SKILL.md"), "version-2", "utf8"); + + installBundledSkills(skillsDir, sourceRoot); + + expect(readFileSync(join(skillsDir, "caveman", "SKILL.md"), "utf8")).toBe("version-2"); + expect(readFileSync(join(skillsDir, "custom-skill", "SKILL.md"), "utf8")).toBe("custom"); + + rmSync(root, { recursive: true, force: true }); + }); + + it("does not overwrite a pre-existing user skill that was never managed", () => { + const root = join( + tmpdir(), + `opencode-pair-user-skill-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + const sourceRoot = join(root, "source-skills"); + const skillsDir = join(root, "skills"); + + mkdirSync(join(sourceRoot, "caveman"), { recursive: true }); + mkdirSync(join(skillsDir, "caveman"), { recursive: true }); + writeFileSync(join(sourceRoot, "caveman", "SKILL.md"), "managed", "utf8"); + writeFileSync(join(skillsDir, "caveman", "SKILL.md"), "user-owned", "utf8"); + + installBundledSkills(skillsDir, sourceRoot); + + expect(readFileSync(join(skillsDir, "caveman", "SKILL.md"), "utf8")).toBe("user-owned"); + + rmSync(root, { recursive: true, force: true }); + }); + + it("removes previously managed skills that are no longer bundled", () => { + const root = join( + tmpdir(), + `opencode-pair-prune-skill-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + const sourceRoot = join(root, "source-skills"); + const skillsDir = join(root, "skills"); + + mkdirSync(join(sourceRoot, "caveman"), { recursive: true }); + writeFileSync(join(sourceRoot, "caveman", "SKILL.md"), "managed", "utf8"); + installBundledSkills(skillsDir, sourceRoot); + + rmSync(join(sourceRoot, "caveman"), { recursive: true, force: true }); + installBundledSkills(skillsDir, sourceRoot); + + expect(existsSync(join(skillsDir, "caveman"))).toBe(false); + + rmSync(root, { recursive: true, force: true }); + }); +}); + +describe("syncManagedMcp", () => { + it("refreshes managed web-agent-mcp deps on source changes and preserves config", () => { + const root = join( + tmpdir(), + `opencode-pair-mcp-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + const sourceRoot = join(root, "source-mcp"); + const targetRoot = join(root, "target-mcp"); + + mkdirSync(join(sourceRoot, "src"), { recursive: true }); + writeFileSync(join(sourceRoot, "src", "server.ts"), "v1", "utf8"); + writeFileSync(join(sourceRoot, "package.json"), "{}", "utf8"); + mkdirSync(join(targetRoot, "node_modules", "leftpad"), { recursive: true }); + writeFileSync(join(targetRoot, "node_modules", "leftpad", "index.js"), "stale", "utf8"); + writeFileSync(join(targetRoot, "config.json"), '{"keep":true}', "utf8"); + + syncManagedMcp("web-agent-mcp", sourceRoot, targetRoot); + expect(readFileSync(join(targetRoot, "config.json"), "utf8")).toBe('{"keep":true}'); + expect(() => readFileSync(join(targetRoot, "node_modules", "leftpad", "index.js"), "utf8")).toThrow(); + + mkdirSync(join(targetRoot, "node_modules", "leftpad"), { recursive: true }); + writeFileSync(join(targetRoot, "node_modules", "leftpad", "index.js"), "fresh", "utf8"); + syncManagedMcp("web-agent-mcp", sourceRoot, targetRoot); + expect(readFileSync(join(targetRoot, "node_modules", "leftpad", "index.js"), "utf8")).toBe("fresh"); + + writeFileSync(join(sourceRoot, "src", "server.ts"), "v2", "utf8"); + syncManagedMcp("web-agent-mcp", sourceRoot, targetRoot); + expect(() => readFileSync(join(targetRoot, "node_modules", "leftpad", "index.js"), "utf8")).toThrow(); + expect(readFileSync(join(targetRoot, "config.json"), "utf8")).toBe('{"keep":true}'); + + rmSync(root, { recursive: true, force: true }); + }); + + it("prunes previously managed MCP paths that disappear from the bundled source", () => { + const root = join( + tmpdir(), + `opencode-pair-mcp-prune-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + const sourceRoot = join(root, "source-mcp"); + const targetRoot = join(root, "target-mcp"); + + mkdirSync(join(sourceRoot, "src", "lib"), { recursive: true }); + writeFileSync(join(sourceRoot, "src", "server.ts"), "server", "utf8"); + writeFileSync(join(sourceRoot, "src", "lib", "old.ts"), "old", "utf8"); + writeFileSync(join(sourceRoot, "config.json"), '{"source":true}', "utf8"); + + syncManagedMcp("pg-mcp", sourceRoot, targetRoot); + writeFileSync(join(targetRoot, "config.json"), '{"keep":true}', "utf8"); + + rmSync(join(sourceRoot, "src", "lib", "old.ts"), { force: true }); + syncManagedMcp("pg-mcp", sourceRoot, targetRoot); + + expect(existsSync(join(targetRoot, "src", "lib", "old.ts"))).toBe(false); + expect(readFileSync(join(targetRoot, "config.json"), "utf8")).toBe('{"keep":true}'); + + rmSync(root, { recursive: true, force: true }); + }); +}); diff --git a/src/__tests__/learning.test.ts b/src/__tests__/learning.test.ts deleted file mode 100644 index a641b78..0000000 --- a/src/__tests__/learning.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { describe, it, expect } from "bun:test"; -import { - promoteLearnedPatterns, - renderInjectedPatterns, -} from "../learning/analyzer"; -import type { LearnedPattern } from "../learning/types"; -import type { Observation, PersistedSessionSummary } from "../hooks/runtime"; -import type { ProjectFacts } from "../project-facts"; - -// ── Shared fixtures ─────────────────────────────────────────────────────────── - -const emptySummary: PersistedSessionSummary = { - sessionID: "test-session", - savedAt: "2026-01-01T00:00:00.000Z", - packageManager: "unknown", - languages: [], - frameworks: [], - changedFiles: [], - incompleteTodos: [], - lastUserMessage: "", - lastAssistantMessage: "", - approxTokens: 0, -}; - -// packageManager "unknown" + empty languages/frameworks prevents repo-convention -// candidates from being produced, keeping tests focused on observations only. -const emptyFacts: ProjectFacts = { - packageManager: "unknown", - languages: [], - frameworks: [], -}; - -function makeObs(note: string): Observation { - return { timestamp: "2026-01-01T00:00:00.000Z", phase: "post", note }; -} - -function makePattern( - id: string, - confidence: number, - occurrences = 1, -): LearnedPattern { - return { - id, - kind: "failure_pattern", - confidence, - occurrences, - firstSeen: "2026-01-01T00:00:00.000Z", - lastSeen: "2026-01-01T01:00:00.000Z", - evidence: [], - source: "automatic", - }; -} - -function callPromote( - observations: Observation[], - existing: LearnedPattern[] = [], - maxPatterns = 24, -) { - return promoteLearnedPatterns({ - existing, - summary: emptySummary, - facts: emptyFacts, - observations, - maxPatterns, - }); -} - -// ── promoteLearnedPatterns ──────────────────────────────────────────────────── - -describe("promoteLearnedPatterns", () => { - it("produces no patterns when observations, summary, and facts are empty", () => { - expect(callPromote([])).toEqual([]); - }); - - it("ignores unrecognized observation notes", () => { - const result = callPromote([ - makeObs("unknown_event"), - makeObs("some_other_note"), - ]); - expect(result).toHaveLength(0); - }); - - it("promotes console_log_found to failure:console-log-regression", () => { - const result = callPromote([makeObs("console_log_found")]); - const pattern = result.find((p) => p.id === "failure:console-log-regression"); - expect(pattern).toBeDefined(); - }); - - it("promotes build_or_test_failure_detected to workflow:verify-after-build-failure", () => { - const result = callPromote([makeObs("build_or_test_failure_detected")]); - const pattern = result.find( - (p) => p.id === "workflow:verify-after-build-failure", - ); - expect(pattern).toBeDefined(); - }); - - it("promotes prefer_pty_for_long_running_command to tooling pattern", () => { - const result = callPromote([makeObs("prefer_pty_for_long_running_command")]); - const pattern = result.find( - (p) => p.id === "tooling:prefer-pty-for-long-running-commands", - ); - expect(pattern).toBeDefined(); - }); - - it("boosts confidence and increments occurrences on repeat observation", () => { - const first = callPromote([makeObs("console_log_found")]); - const firstPattern = first.find( - (p) => p.id === "failure:console-log-regression", - )!; - expect(firstPattern.occurrences).toBe(1); - - const second = callPromote([makeObs("console_log_found")], first); - const secondPattern = second.find( - (p) => p.id === "failure:console-log-regression", - )!; - expect(secondPattern.occurrences).toBe(2); - expect(secondPattern.confidence).toBeGreaterThan(firstPattern.confidence); - }); - - it("clamps confidence to a maximum of 0.95", () => { - // Force a high existing confidence so the boost pushes it past the ceiling. - const existing = [makePattern("failure:console-log-regression", 0.9, 10)]; - const result = callPromote([makeObs("console_log_found")], existing); - const pattern = result.find((p) => p.id === "failure:console-log-regression")!; - expect(pattern.confidence).toBeLessThanOrEqual(0.95); - expect(pattern.confidence).toBe(0.95); - }); - - it("all produced patterns have confidence >= 0.35", () => { - const result = callPromote([ - makeObs("console_log_found"), - makeObs("build_or_test_failure_detected"), - makeObs("prefer_pty_for_long_running_command"), - ]); - for (const p of result) { - expect(p.confidence).toBeGreaterThanOrEqual(0.35); - } - }); - - it("respects maxPatterns limit", () => { - const existing: LearnedPattern[] = Array.from({ length: 5 }, (_, i) => - makePattern(`pattern-${i}`, 0.6 + i * 0.01), - ); - const result = callPromote([], existing, 3); - expect(result.length).toBeLessThanOrEqual(3); - }); - - it("returns the highest-confidence patterns when trimming to maxPatterns", () => { - const existing: LearnedPattern[] = [ - makePattern("low", 0.5), - makePattern("high", 0.9), - makePattern("mid", 0.7), - ]; - const result = callPromote([], existing, 2); - expect(result.map((p) => p.id)).toEqual(["high", "mid"]); - }); - - it("does not create duplicate patterns for the same id", () => { - const result = callPromote([ - makeObs("console_log_found"), - makeObs("console_log_found"), - ]); - const matches = result.filter((p) => p.id === "failure:console-log-regression"); - expect(matches.length).toBe(1); - }); -}); - -// ── renderInjectedPatterns ──────────────────────────────────────────────────── - -describe("renderInjectedPatterns", () => { - it("returns an empty array for empty patterns", () => { - expect(renderInjectedPatterns([], 5)).toEqual([]); - }); - - it("returns patterns sorted by confidence descending", () => { - const patterns = [ - makePattern("low", 0.5), - makePattern("high", 0.9), - makePattern("mid", 0.7), - ]; - const rendered = renderInjectedPatterns(patterns, 10); - expect(rendered[0]).toContain("0.90"); - expect(rendered[1]).toContain("0.70"); - expect(rendered[2]).toContain("0.50"); - }); - - it("respects the limit parameter", () => { - const patterns = Array.from({ length: 10 }, (_, i) => - makePattern(`p-${i}`, 0.5 + i * 0.01), - ); - const rendered = renderInjectedPatterns(patterns, 3); - expect(rendered).toHaveLength(3); - }); - - it("renders confidence as two decimal places", () => { - const patterns = [makePattern("test-id", 0.75)]; - const [line] = renderInjectedPatterns(patterns, 10); - expect(line).toContain("0.75"); - }); - - it("does not mutate the original array order", () => { - const patterns = [ - makePattern("low", 0.5), - makePattern("high", 0.9), - ]; - renderInjectedPatterns(patterns, 10); - expect(patterns[0].id).toBe("low"); - expect(patterns[1].id).toBe("high"); - }); -}); diff --git a/src/__tests__/mcp.test.ts b/src/__tests__/mcp.test.ts new file mode 100644 index 0000000..e23f6ff --- /dev/null +++ b/src/__tests__/mcp.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createHarnessMcps } from "../mcp"; + +function installPackageStub(root: string, packageName: string): void { + mkdirSync(join(root, "node_modules", ...packageName.split("/")), { + recursive: true, + }); +} + +describe("createHarnessMcps", () => { + let configHome: string; + let oldConfigDir: string | undefined; + let oldXdgConfigHome: string | undefined; + + beforeEach(() => { + configHome = join(tmpdir(), `opencode-pair-config-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(configHome, { recursive: true }); + oldConfigDir = process.env.OPENCODE_CONFIG_DIR; + oldXdgConfigHome = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = configHome; + process.env.OPENCODE_CONFIG_DIR = join(configHome, "opencode-test"); + }); + + afterEach(() => { + if (oldConfigDir === undefined) { + delete process.env.OPENCODE_CONFIG_DIR; + } else { + process.env.OPENCODE_CONFIG_DIR = oldConfigDir; + } + + if (oldXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = oldXdgConfigHome; + } + + rmSync(configHome, { recursive: true, force: true }); + }); + + it("loads local MCP servers from the shared MCP config root when deps exist", () => { + const pgRoot = join(configHome, "pg-mcp"); + const sshRoot = join(configHome, "ssh-mcp"); + const webRoot = join(configHome, "web-agent-mcp"); + + mkdirSync(join(pgRoot, "src"), { recursive: true }); + mkdirSync(join(sshRoot, "src"), { recursive: true }); + mkdirSync(join(webRoot, "src"), { recursive: true }); + writeFileSync(join(pgRoot, "src", "index.js"), "", "utf8"); + writeFileSync(join(sshRoot, "src", "index.js"), "", "utf8"); + writeFileSync(join(webRoot, "src", "server.ts"), "", "utf8"); + writeFileSync(join(pgRoot, "config.json"), "{}", "utf8"); + writeFileSync(join(sshRoot, "config.json"), "{}", "utf8"); + installPackageStub(pgRoot, "@modelcontextprotocol/sdk"); + installPackageStub(pgRoot, "pg"); + installPackageStub(sshRoot, "@modelcontextprotocol/sdk"); + installPackageStub(sshRoot, "zod"); + installPackageStub(webRoot, "@modelcontextprotocol/sdk"); + installPackageStub(webRoot, "zod"); + installPackageStub(webRoot, "cloakbrowser"); + installPackageStub(webRoot, "playwright-core"); + + const mcps = createHarnessMcps({ agents: {}, mcps: {} }); + + expect(mcps["pg-mcp"]).toMatchObject({ + command: ["node", join(pgRoot, "src", "index.js")], + environment: { + PG_MCP_CONFIG_PATH: join(pgRoot, "config.json"), + }, + }); + expect(mcps["ssh-mcp"]).toMatchObject({ + command: ["node", join(sshRoot, "src", "index.js")], + environment: { + SSH_MCP_CONFIG_PATH: join(sshRoot, "config.json"), + }, + }); + expect(mcps["web-agent-mcp"]).toMatchObject({ + command: ["bun", "run", join(webRoot, "src", "server.ts")], + }); + }); + + it("skips broken local MCP registrations when required deps are missing", () => { + const pgRoot = join(configHome, "pg-mcp"); + mkdirSync(join(pgRoot, "src"), { recursive: true }); + writeFileSync(join(pgRoot, "src", "index.js"), "", "utf8"); + writeFileSync(join(pgRoot, "config.json"), "{}", "utf8"); + + const mcps = createHarnessMcps({ agents: {}, mcps: {} }); + + expect(mcps["pg-mcp"]).toBeUndefined(); + }); +}); diff --git a/src/__tests__/plan-mode.test.ts b/src/__tests__/plan-mode.test.ts deleted file mode 100644 index 0a150f9..0000000 --- a/src/__tests__/plan-mode.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, it, expect } from "bun:test"; - -// Mirrors the detection logic in src/hooks/session-start.ts (chat.message handler). -// Kept as a pure function here so it can be exercised without instantiating the -// full HookRuntime or PluginInput context. -function detectModeFromText(text: string): "executing" | "planning" | null { - const trimmed = text.trim().toLowerCase(); - if (trimmed.includes("[harness:mode:executing]")) return "executing"; - if (trimmed.includes("[harness:mode:planning]")) return "planning"; - return null; -} - -describe("detectModeFromText", () => { - it("returns 'executing' for the executing marker", () => { - expect(detectModeFromText("[harness:mode:executing]")).toBe("executing"); - }); - - it("returns 'planning' for the planning marker", () => { - expect(detectModeFromText("[harness:mode:planning]")).toBe("planning"); - }); - - it("returns null for normal text without markers", () => { - expect(detectModeFromText("hello world")).toBeNull(); - expect(detectModeFromText("please start executing the plan")).toBeNull(); - expect(detectModeFromText("let's plan this out")).toBeNull(); - }); - - it("returns null for empty string", () => { - expect(detectModeFromText("")).toBeNull(); - }); - - it("is case-insensitive — uppercase marker triggers executing", () => { - expect(detectModeFromText("[HARNESS:MODE:EXECUTING]")).toBe("executing"); - }); - - it("is case-insensitive — uppercase marker triggers planning", () => { - expect(detectModeFromText("[HARNESS:MODE:PLANNING]")).toBe("planning"); - }); - - it("is case-insensitive — mixed-case marker triggers executing", () => { - expect(detectModeFromText("[Harness:Mode:Executing]")).toBe("executing"); - }); - - it("detects marker embedded within surrounding text", () => { - expect( - detectModeFromText("please /go [harness:mode:executing] to start"), - ).toBe("executing"); - expect( - detectModeFromText("switching back [harness:mode:planning] now"), - ).toBe("planning"); - }); - - it("strips leading/trailing whitespace before matching", () => { - expect(detectModeFromText(" [harness:mode:executing] ")).toBe("executing"); - expect(detectModeFromText("\t[harness:mode:planning]\n")).toBe("planning"); - }); - - it("executing marker wins when both appear in the same text", () => { - // Mirrors the if/else-if priority in session-start.ts - const text = - "[harness:mode:executing] earlier [harness:mode:planning] later"; - expect(detectModeFromText(text)).toBe("executing"); - }); -}); diff --git a/src/agents.ts b/src/agents.ts index c35ba4a..d424084 100644 --- a/src/agents.ts +++ b/src/agents.ts @@ -1,15 +1,10 @@ -import type { AgentLike, HarnessConfig, McpToggles } from "./types"; +import type { AgentLike, HarnessConfig } from "./types"; import { deepMerge } from "./utils"; import { buildCoordinatorPrompt } from "./prompts/coordinator"; -import { buildDenyRules } from "./prompts/mcp-access"; import { - buildWorkerPrompt, - buildResearcherPrompt, - buildReviewerPrompt, - buildVerifierPrompt, - buildRepairPrompt, - buildUiDeveloperPrompt, - buildRepoScoutPrompt, + buildEliotPrompt, + buildTyrellPrompt, + buildValidatorPrompt, } from "./prompts/workers"; function withOverride( @@ -20,200 +15,68 @@ function withOverride( return deepMerge(base, override); } -function taskPermissions(...allowedPatterns: string[]) { - const permissions: Record = { "*": "deny" }; - for (const pattern of allowedPatterns) { - permissions[pattern] = "allow"; - } - return permissions; -} - -const COORDINATOR_TASK_PERMISSIONS = taskPermissions( - "thorfinn", - "ginko", - "rust", - "rust_deep", - "spock", - "geralt", - "edward", - "killua", -); - -// Only the expensive MCPs are disabled on the coordinator (~30k token savings). -// Lighter MCPs stay open so the coordinator can use them directly. -const COORDINATOR_DISABLED_TOOLS = buildDenyRules("yang"); - export function createHarnessAgents( config: HarnessConfig, ): Record { const overrides = config.agents ?? {}; return { - // ── Coordinator (primary agent) ────────────────────────────── - yang: withOverride( + mrrobot: withOverride( { mode: "primary", description: - "Yang Wenli — Senior technical lead. Plans, argues, delegates, synthesizes.", + "MrRobot — Primary agent. Routes work, keeps scope tight, and answers plainly.", model: "openai/gpt-5.4-fast", variant: "high", - prompt: buildCoordinatorPrompt(overrides.yang?.prompt_append, config.mcps), + prompt: buildCoordinatorPrompt(overrides.mrrobot?.prompt_append, config.mcps), color: "#4A90D9", - tools: COORDINATOR_DISABLED_TOOLS, - permission: { task: COORDINATOR_TASK_PERMISSIONS }, }, - overrides.yang, + overrides.mrrobot, ), - // ── Workers (subagents) ────────────────────────────────────── - thorfinn: withOverride( + eliot: withOverride( { mode: "subagent", hidden: true, - description: "Thorfinn — Main implementation worker for backend and refactors.", + description: "Eliot — General-purpose subagent for implementation and repo work.", model: "openai/gpt-5.4-fast", variant: "high", - prompt: buildWorkerPrompt(overrides.thorfinn?.prompt_append, config.mcps), + prompt: buildEliotPrompt(overrides.eliot?.prompt_append, config.mcps), temperature: 0.2, color: "#2ECC71", - tools: buildDenyRules("thorfinn"), - }, - overrides.thorfinn, - ), - - ginko: withOverride( - { - mode: "subagent", - hidden: true, - description: "Ginko — Web and doc researcher.", - model: "openai/gpt-5.4-fast", - variant: "medium", - prompt: buildResearcherPrompt(overrides.ginko?.prompt_append, config.mcps), - temperature: 0.3, - color: "#F39C12", - tools: buildDenyRules("ginko"), - permission: { - edit: "deny", - write: "deny", - }, }, - overrides.ginko, + overrides.eliot, ), - rust: withOverride( + tyrell: withOverride( { mode: "subagent", hidden: true, description: - "Rust Cohle — Default senior reviewer. Faster lane for medium/high-risk review.", + "Tyrell — Ideation subagent for creative options, naming, UX direction, and product ideas.", model: "openai/gpt-5.4-fast", variant: "high", - prompt: buildReviewerPrompt( - overrides.rust?.prompt_append, - config.mcps, - "rust", - ), - temperature: 0.1, - color: "#E74C3C", - tools: buildDenyRules("rust"), - permission: { - edit: "deny", - write: "deny", - }, + prompt: buildTyrellPrompt(overrides.tyrell?.prompt_append, config.mcps), + temperature: 0.7, + color: "#9B59B6", }, - overrides.rust, + overrides.tyrell, ), - rust_deep: withOverride( + validator: withOverride( { mode: "subagent", hidden: true, - description: - "Rust Deep — Escalation reviewer. Slower, deeper review for subtle or high-risk cases.", + description: "Validator — Validation-focused review and verification subagent.", model: "openai/gpt-5.4-fast", - variant: "xhigh", - prompt: buildReviewerPrompt( - overrides.rust_deep?.prompt_append, - config.mcps, - "rust_deep", - ), - temperature: 0.1, - color: "#C0392B", - tools: buildDenyRules("rust_deep"), - permission: { - edit: "deny", - write: "deny", - }, - }, - overrides.rust_deep, - ), - - spock: withOverride( - { - mode: "subagent", - hidden: true, - description: "Spock — Build, test, lint verifier.", - model: "openai/gpt-5.4-fast", - variant: "medium", - prompt: buildVerifierPrompt(overrides.spock?.prompt_append, config.mcps), + variant: "high", + prompt: buildValidatorPrompt(overrides.validator?.prompt_append, config.mcps), temperature: 0.0, - color: "#95A5A6", - tools: buildDenyRules("spock"), - }, - overrides.spock, - ), - - geralt: withOverride( - { - mode: "subagent", - hidden: true, - description: "Geralt — Scoped failure repair worker.", - model: "openai/gpt-5.4-fast", - variant: "medium", - prompt: buildRepairPrompt(overrides.geralt?.prompt_append, config.mcps), - temperature: 0.1, color: "#E67E22", - tools: buildDenyRules("geralt"), - }, - overrides.geralt, - ), - - edward: withOverride( - { - mode: "subagent", - hidden: true, - description: - "Edward — Frontend specialist with browser automation.", - model: "openai/gpt-5.4-fast", - variant: "high", - prompt: buildUiDeveloperPrompt(overrides.edward?.prompt_append, config.mcps), - temperature: 0.5, - color: "#FF69B4", - tools: buildDenyRules("edward"), - }, - overrides.edward, - ), - - killua: withOverride( - { - mode: "subagent", - hidden: true, - description: "Killua — Fast codebase explorer.", - model: "openai/gpt-5.4-fast", - variant: "medium", - prompt: buildRepoScoutPrompt(overrides.killua?.prompt_append, config.mcps), - temperature: 0.1, - color: "#1ABC9C", - tools: buildDenyRules("killua"), - permission: { - edit: "deny", - write: "deny", - }, }, - overrides.killua, + overrides.validator, ), - // ── Disable OpenCode built-in agents ───────────────────────── build: { disable: true }, plan: { disable: true }, }; diff --git a/src/cli.ts b/src/cli.ts index 9ea7b11..3fa2238 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -41,12 +41,12 @@ function printConfig(): void { "@zenobius/opencode-skillful@latest", "@franlol/opencode-md-table-formatter@latest", "opencode-pty@latest", - "file://~/.config/opencode/vendor/opencode-background-agents-local" + "@mohak34/opencode-notifier@latest" ], "instructions": [ "~/.config/opencode/plugin/shell-strategy/shell_strategy.md" ], - "default_agent": "yang" + "default_agent": "mrrobot" } Use \`opencode-pair install\` for the real path-aware install.`); diff --git a/src/commands.ts b/src/commands.ts index 3a4a590..6e9f570 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -7,22 +7,5 @@ export function createHarnessCommands( return {}; } - return { - go: { - template: "[harness:mode:executing] $ARGUMENTS", - description: "Exit plan mode and start execution.", - agent: "yang", - }, - plan: { - template: "[harness:mode:planning] $ARGUMENTS", - description: "Return to plan mode.", - agent: "yang", - }, - "create-skill": { - template: - "Analyze the current session learnings and create a reusable skill from them. Save to ~/.config/opencode/skills/. $ARGUMENTS", - description: "Create a skill from session learnings.", - agent: "yang", - }, - }; + return {}; } diff --git a/src/config.ts b/src/config.ts index 5144147..00ccb14 100644 --- a/src/config.ts +++ b/src/config.ts @@ -19,29 +19,7 @@ const HarnessConfigSchema = z.object({ comment_guard: z.boolean().optional(), session_start: z.boolean().optional(), pre_tool_use: z.boolean().optional(), - post_tool_use: z.boolean().optional(), - pre_compact: z.boolean().optional(), - stop: z.boolean().optional(), session_end: z.boolean().optional(), - file_edited: z.boolean().optional(), - }) - .optional(), - memory: z - .object({ - enabled: z.boolean().optional(), - directory: z.string().optional(), - lookback_days: z.number().int().positive().optional(), - max_injected_chars: z.number().int().positive().optional(), - }) - .optional(), - learning: z - .object({ - enabled: z.boolean().optional(), - directory: z.string().optional(), - min_observations: z.number().int().positive().optional(), - auto_promote: z.boolean().optional(), - max_patterns: z.number().int().positive().optional(), - max_injected_patterns: z.number().int().positive().optional(), }) .optional(), workflow: z @@ -83,23 +61,7 @@ const DEFAULTS: HarnessConfig = { comment_guard: true, session_start: true, pre_tool_use: true, - post_tool_use: true, - pre_compact: true, - stop: true, session_end: true, - file_edited: true, - }, - memory: { - enabled: true, - lookback_days: 7, - max_injected_chars: 3500, - }, - learning: { - enabled: true, - min_observations: 6, - auto_promote: true, - max_patterns: 24, - max_injected_patterns: 5, }, workflow: { compact_subagent_context: true, @@ -120,8 +82,6 @@ const ConfigSectionSchemas = { set_default_agent: HarnessConfigSchema.shape.set_default_agent, commands: HarnessConfigSchema.shape.commands, hooks: HarnessConfigSchema.shape.hooks, - memory: HarnessConfigSchema.shape.memory, - learning: HarnessConfigSchema.shape.learning, workflow: HarnessConfigSchema.shape.workflow, mcps: HarnessConfigSchema.shape.mcps, agents: HarnessConfigSchema.shape.agents, @@ -227,23 +187,7 @@ export const SAMPLE_PROJECT_CONFIG = `{ "comment_guard": true, "session_start": true, "pre_tool_use": true, - "post_tool_use": true, - "pre_compact": true, - "stop": true, - "session_end": true, - "file_edited": true - }, - "memory": { - "enabled": true, - "lookback_days": 7, - "max_injected_chars": 3500 - }, - "learning": { - "enabled": true, - "min_observations": 6, - "auto_promote": true, - "max_patterns": 24, - "max_injected_patterns": 5 + "session_end": true }, "workflow": { "compact_subagent_context": true diff --git a/src/hooks/comment-guard.ts b/src/hooks/comment-guard.ts index 631a31a..791749a 100644 --- a/src/hooks/comment-guard.ts +++ b/src/hooks/comment-guard.ts @@ -1,26 +1,27 @@ import { existsSync, readFileSync } from "node:fs"; +import { BlockingHookError } from "./sdk"; +import { resolveToolArgs, resolveToolName } from "./runtime"; -type ToolAfterInput = { +type ToolInput = { tool: string; sessionID: string; callID: string; args: Record; }; -type ToolAfterOutput = { +type ToolOutput = { title: string; output: string; metadata: unknown; }; -const SUSPICIOUS_COMMENT_PATTERNS: RegExp[] = [ - /^\s*(\/\/|#|\/\*+|\*)\s*(this|these)\s+(function|method|code|logic|component|block)\b/i, +export const SUSPICIOUS_COMMENT_PATTERNS: RegExp[] = [ + /^\s*(\/\/|#|\/\*+|\*)\s*(this|these)\s+(function|method|code|logic|component|block|class|section)\b/i, /^\s*(\/\/|#|\/\*+|\*)\s*(here|now)\b/i, /^\s*(\/\/|#|\/\*+|\*)\s*(simply|basically|just|obviously)\b/i, - /^\s*(\/\/|#|\/\*+|\*)\s*we\s+(now|use|need|do|first|then)\b/i, + /^\s*(\/\/|#|\/\*+|\*)\s*we\s+(now|use|need|do|first|then|return)\b/i, /^\s*(\/\/|#|\/\*+|\*)\s*note\s+(that|:)/i, /^\s*(\/\/|#|\/\*+|\*)\s*ensure\s+that\b/i, - /^\s*(\/\/|#|\/\*+|\*)\s*the\s+following\s+(code|function|section|block|method|class)\b/i, /^\s*(\/\/|#|\/\*+|\*)\s*this\s+(handles|processes|manages|implements|creates|initializes|sets up)\b/i, /^\s*(\/\/|#|\/\*+|\*)\s*(first|next|finally|then),?\s+(we|let's)\b/i, @@ -33,17 +34,75 @@ const SUSPICIOUS_COMMENT_PATTERNS: RegExp[] = [ /^\s*(\/\/|#|\/\*+|\*)\s*helper\s+(function|method)\s+(to|for|that)\b/i, ]; -function collectPaths( - input: ToolAfterInput, - output: ToolAfterOutput, +function isObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function resolveEffectiveToolArgs( + input: unknown, + output?: unknown, +): Record { + const inputArgs = resolveToolArgs(input); + const outputArgs = resolveToolArgs(output); + if (Object.keys(outputArgs).length === 0) { + return inputArgs; + } + + return { ...inputArgs, ...outputArgs }; +} + +export function isSuspiciousCommentLine(line: string): boolean { + return SUSPICIOUS_COMMENT_PATTERNS.some((pattern) => pattern.test(line)); +} + +export function findSuspiciousCommentHitsInText( + text: string, + label: string, ): string[] { - const directPath = input.args.filePath; + return text + .split(/\r?\n/) + .flatMap((line, index) => + isSuspiciousCommentLine(line) ? [`${label}:${index + 1}: ${line.trim()}`] : [], + ); +} + +export function findSuspiciousCommentHitsInPatch(patchText: string): string[] { + const hits: string[] = []; + let currentFile = ""; + let addedLine = 0; + + for (const line of patchText.split(/\r?\n/)) { + if (line.startsWith("*** Update File: ")) { + currentFile = line.slice("*** Update File: ".length).trim(); + addedLine = 0; + continue; + } + if (line.startsWith("*** Add File: ")) { + currentFile = line.slice("*** Add File: ".length).trim(); + addedLine = 0; + continue; + } + if (line.startsWith("+") && !line.startsWith("+++")) { + addedLine += 1; + const candidate = line.slice(1); + if (isSuspiciousCommentLine(candidate)) { + hits.push(`${currentFile}:patch+${addedLine}: ${candidate.trim()}`); + } + } + } + + return hits; +} + +function collectPaths(input: ToolInput, output: ToolOutput): string[] { + const args = resolveEffectiveToolArgs(input, output); + const directPath = args.filePath ?? args.path; if (typeof directPath === "string") { return [directPath]; } if ( - input.tool === "apply_patch" && + resolveToolName(input) === "apply_patch" && typeof output.metadata === "object" && output.metadata !== null && "files" in output.metadata @@ -72,30 +131,102 @@ function inspectFile(filePath: string): string[] { return []; } - const content = readFileSync(filePath, "utf8"); - const lines = content.split(/\r?\n/); - const hits: string[] = []; + return findSuspiciousCommentHitsInText(readFileSync(filePath, "utf8"), filePath); +} - lines.forEach((line, index) => { - if (SUSPICIOUS_COMMENT_PATTERNS.some((pattern) => pattern.test(line))) { - hits.push(`${filePath}:${index + 1}: ${line.trim()}`); +function inspectEditPayload(args: Record): string[] { + const fileLabel = + typeof args.filePath === "string" + ? args.filePath + : typeof args.path === "string" + ? args.path + : ""; + + const hits: string[] = []; + const directKeys = [ + "content", + "text", + "newString", + "newText", + "replacement", + "replaceWith", + ]; + for (const key of directKeys) { + const value = args[key]; + if (typeof value === "string") { + hits.push(...findSuspiciousCommentHitsInText(value, `${fileLabel}:${key}`)); } - }); + } + + if (Array.isArray(args.edits)) { + args.edits.forEach((edit, index) => { + if (!isObject(edit)) return; + for (const key of ["newString", "newText", "replacement", "replaceWith"]) { + const value = edit[key]; + if (typeof value === "string") { + hits.push( + ...findSuspiciousCommentHitsInText( + value, + `${fileLabel}:edit${index + 1}:${key}`, + ), + ); + } + } + }); + } return hits; } +function inspectToolInput(input: ToolInput, output?: unknown): string[] { + const tool = resolveToolName(input); + if (!tool || !["write", "edit", "multiedit", "apply_patch"].includes(tool)) { + return []; + } + + const args = resolveEffectiveToolArgs(input, output); + + if (tool === "apply_patch") { + const patchText = args.patchText; + return typeof patchText === "string" + ? findSuspiciousCommentHitsInPatch(patchText) + : []; + } + + return inspectEditPayload(args); +} + +function formatBlockMessage(hits: string[]): string { + return [ + "[CommentGuard] Blocked suspicious AI-style comments before the file edit.", + "Remove the comment text and retry.", + ...hits, + ].join("\n"); +} + export function createCommentGuardHook() { return { + "tool.execute.before": async ( + input: ToolInput, + output?: unknown, + ): Promise => { + const suspiciousComments = inspectToolInput(input, output); + if (suspiciousComments.length === 0) { + return; + } + + throw new BlockingHookError(formatBlockMessage(suspiciousComments)); + }, "tool.execute.after": async ( - input: ToolAfterInput, - output: ToolAfterOutput, + input: ToolInput, + output: ToolOutput, ): Promise => { - if (!["write", "edit", "multiedit", "apply_patch"].includes(input.tool)) { + const tool = resolveToolName(input); + if (!tool || !["write", "edit", "multiedit", "apply_patch"].includes(tool)) { return; } - const filePaths = collectPaths(input, output); + const filePaths = [...new Set(collectPaths(input, output))]; if (filePaths.length === 0) { return; } @@ -105,7 +236,7 @@ export function createCommentGuardHook() { return; } - output.output = `${output.output}\n\n[CommentGuard]\nPotentially AI-sloppy comments detected. Prefer concise, senior-level comments only when truly needed.\n${suspiciousComments.join("\n")}`; + output.output = `${output.output}\n\n[CommentGuard]\nSuspicious AI-style comments remain in modified files. Remove them before continuing.\n${suspiciousComments.join("\n")}`; }, }; } diff --git a/src/hooks/file-edited.ts b/src/hooks/file-edited.ts deleted file mode 100644 index f4e8648..0000000 --- a/src/hooks/file-edited.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { HookRuntime } from "./runtime"; - -export function createFileEditedHook( - runtime: HookRuntime, -) { - return { - "file.edited": async (event: { path: string; sessionID?: string }): Promise => { - if (!event.path || !event.sessionID) { - return; - } - runtime.rememberEditedFile(event.sessionID, event.path); - }, - }; -} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 4cae0a5..8701b73 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,15 +1,23 @@ -import type { PluginInput } from "@opencode-ai/plugin"; + +const SUBAGENT_TASK_PERMISSIONS = { + task: { "*": "deny" }, +} as const; + +const VALIDATOR_PERMISSIONS = { + ...SUBAGENT_TASK_PERMISSIONS, + edit: "deny", + write: "deny", + patch: "deny", + multiedit: "deny", + apply_patch: "deny", +} as const;import type { PluginInput } from "@opencode-ai/plugin"; import type { HarnessConfig } from "../types"; import { createCommentGuardHook } from "./comment-guard"; -import { createFileEditedHook } from "./file-edited"; -import { createPostToolUseHook } from "./post-tool-use"; -import { createPreCompactHook } from "./pre-compact"; import { createPreToolUseHook } from "./pre-tool-use"; import { createHookRuntime, resolveHookProfile } from "./runtime"; import { safeCreateHook, safeHook } from "./sdk"; import { createSessionEndHook } from "./session-end"; import { createSessionStartHook } from "./session-start"; -import { createStopHook } from "./stop"; type HookRecord = { config?: (config: any) => Promise; @@ -17,13 +25,9 @@ type HookRecord = { event?: (input: { event: { type: string; properties?: unknown }; }) => Promise; - "tool.execute.before"?: (input: any) => Promise; + "tool.execute.before"?: (input: any, output: any) => Promise; "tool.execute.after"?: (input: any, output: any) => Promise; - "file.edited"?: (input: any) => Promise; - "session.created"?: (input?: any) => Promise; - "session.idle"?: (input?: any) => Promise; "session.deleted"?: (input?: any) => Promise; - "experimental.session.compacting"?: (input?: any) => Promise; }; function wrapHookRecord( @@ -46,20 +50,10 @@ function wrapHookRecord( `${name}.tool.execute.after`, hook["tool.execute.after"], ), - "file.edited": safeHook(`${name}.file.edited`, hook["file.edited"]), - "session.created": safeHook( - `${name}.session.created`, - hook["session.created"], - ), - "session.idle": safeHook(`${name}.session.idle`, hook["session.idle"]), "session.deleted": safeHook( `${name}.session.deleted`, hook["session.deleted"], ), - "experimental.session.compacting": safeHook( - `${name}.experimental.session.compacting`, - hook["experimental.session.compacting"], - ), }; } @@ -125,9 +119,9 @@ function composeToolBefore(hooks: HookRecord[]) { return undefined; } - return async (input: any) => { + return async (input: any, output: any) => { for (const hook of active) { - await hook?.(input); + await hook?.(input, output); } }; } @@ -177,23 +171,11 @@ export async function createHarnessHooks( createSessionStartHook(ctx, config, runtime), ); registerHook("pre_tool_use", config.hooks?.pre_tool_use !== false, () => - createPreToolUseHook(config, runtime, profile), - ); - registerHook("post_tool_use", config.hooks?.post_tool_use !== false, () => - createPostToolUseHook(config, runtime, profile), - ); - registerHook("pre_compact", config.hooks?.pre_compact !== false, () => - createPreCompactHook(runtime), - ); - registerHook("stop", config.hooks?.stop !== false, () => - createStopHook(ctx, runtime), + createPreToolUseHook(runtime, profile), ); registerHook("session_end", config.hooks?.session_end !== false, () => createSessionEndHook(runtime), ); - registerHook("file_edited", config.hooks?.file_edited !== false, () => - createFileEditedHook(runtime), - ); return { config: composeConfig(hooks), @@ -201,13 +183,6 @@ export async function createHarnessHooks( event: composeEvent(hooks), "tool.execute.before": composeToolBefore(hooks), "tool.execute.after": composeToolAfter(hooks), - "file.edited": composeSingleArg(hooks, "file.edited"), - "session.created": composeSingleArg(hooks, "session.created"), - "session.idle": composeSingleArg(hooks, "session.idle"), "session.deleted": composeSingleArg(hooks, "session.deleted"), - "experimental.session.compacting": composeSingleArg( - hooks, - "experimental.session.compacting", - ), }; } diff --git a/src/hooks/post-tool-use.ts b/src/hooks/post-tool-use.ts deleted file mode 100644 index e1867fc..0000000 --- a/src/hooks/post-tool-use.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { HarnessConfig, HookProfile } from "../types"; -import type { HookRuntime } from "./runtime"; -import { - profileMatches, - resolveFilePathFromArgs, - resolveSessionID, - resolveToolArgs, - resolveToolName, - stringifyToolOutput, -} from "./runtime"; - -export function createPostToolUseHook( - config: HarnessConfig, - runtime: HookRuntime, - profile: HookProfile, -) { - return { - "tool.execute.after": async ( - input: unknown, - output: unknown, - ): Promise => { - const sessionID = resolveSessionID(input); - const tool = resolveToolName(input); - const args = resolveToolArgs(input); - const filePath = resolveFilePathFromArgs(args); - - if (sessionID && filePath && ["write", "edit"].includes(tool ?? "")) { - runtime.rememberEditedFile(sessionID, filePath); - } - - if ( - filePath && - profileMatches(profile, ["standard", "strict"]) && - /\.(ts|tsx|js|jsx)$/i.test(filePath) - ) { - const source = runtime.readText(filePath) ?? ""; - if (source.includes("console.log")) { - runtime.appendObservation({ - timestamp: new Date().toISOString(), - phase: "post", - sessionID, - agent: sessionID ? runtime.getSessionAgent(sessionID) : undefined, - tool, - note: "console_log_found", - }); - } - } - - if (tool === "bash" && profileMatches(profile, ["standard", "strict"])) { - const command = typeof args.command === "string" ? args.command : ""; - const text = stringifyToolOutput(output).toLowerCase(); - if ( - /\b(build|test|lint)\b/.test(command) && - /(error|failed|failure)/.test(text) - ) { - runtime.appendObservation({ - timestamp: new Date().toISOString(), - phase: "post", - sessionID, - agent: sessionID ? runtime.getSessionAgent(sessionID) : undefined, - tool, - note: "build_or_test_failure_detected", - }); - } - } - - runtime.appendObservation({ - timestamp: new Date().toISOString(), - phase: "post", - sessionID, - agent: sessionID ? runtime.getSessionAgent(sessionID) : undefined, - tool, - }); - }, - }; -} diff --git a/src/hooks/pre-compact.ts b/src/hooks/pre-compact.ts deleted file mode 100644 index e512686..0000000 --- a/src/hooks/pre-compact.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { HookRuntime } from "./runtime"; -import { resolveSessionOrEntityID } from "./runtime"; - -export function createPreCompactHook(runtime: HookRuntime) { - return { - "experimental.session.compacting": async ( - input?: unknown, - ): Promise => { - const sessionID = resolveSessionOrEntityID(input); - if (!sessionID) { - return; - } - - runtime.appendObservation({ - timestamp: new Date().toISOString(), - phase: "idle", - sessionID, - agent: runtime.getSessionAgent(sessionID), - note: "pre-compact snapshot requested", - }); - }, - }; -} diff --git a/src/hooks/pre-tool-use.ts b/src/hooks/pre-tool-use.ts index 68037d2..0721225 100644 --- a/src/hooks/pre-tool-use.ts +++ b/src/hooks/pre-tool-use.ts @@ -1,13 +1,11 @@ -import type { HarnessConfig } from "../types"; +import type { HookProfile } from "../types"; import type { HookRuntime } from "./runtime"; import { BlockingHookError } from "./sdk"; import { profileMatches, - resolveAgentName, resolveSessionID, resolveToolArgs, resolveToolName, - PRIMARY_AGENTS, } from "./runtime"; const NODE_COMMAND_RE = @@ -15,20 +13,27 @@ const NODE_COMMAND_RE = const NODE_MODULES_BIN_RE = /node_modules\/\.bin\//; -const PLAN_MODE_ALWAYS_BLOCKED = new Set(["edit", "write", "patch"]); - -function isBlockedInPlanMode( - tool: string, - _args: Record, -): boolean { - // edit/write/patch always blocked — real protection against file changes - if (PLAN_MODE_ALWAYS_BLOCKED.has(tool)) return true; +function hasToolArgs(value: unknown): value is Record { + return !!value && typeof value === "object" && Object.keys(value).length > 0; +} - // task/delegate: allowed — prompt enforces which workers to use, - // hook can't determine target agent (args are empty in hook input). - // delegate internally triggers task, so both must be allowed. - // edit/write/patch hard gate still prevents file modifications. - return false; +function resolveEffectiveToolArgs( + input: unknown, + output: unknown, +): Record { + const inputArgs = resolveToolArgs(input); + const outputArgs = resolveToolArgs(output); + if (!hasToolArgs(outputArgs)) { + return inputArgs; + } + + for (const [key, value] of Object.entries(inputArgs)) { + if (!(key in outputArgs)) { + outputArgs[key] = value; + } + } + + return outputArgs; } function isNodeCommand(command: string): boolean { @@ -43,57 +48,29 @@ function transformToCmd(command: string, winPath: string): string { function hasRecentBuildCheck(recentTools: string[]): boolean { return recentTools.some( - (t) => - t.includes("tsc") || - t.includes("typecheck") || - t.includes("build") || - t.includes("test"), + (tool) => + tool.includes("tsc") || + tool.includes("typecheck") || + tool.includes("build") || + tool.includes("test"), ); } export function createPreToolUseHook( - config: HarnessConfig, runtime: HookRuntime, - profile: import("../types").HookProfile, + profile: HookProfile, ) { const recentBashBySession = new Map(); return { - "tool.execute.before": async (input: unknown): Promise => { + "tool.execute.before": async ( + input: unknown, + output: unknown, + ): Promise => { const sessionID = resolveSessionID(input); const tool = resolveToolName(input); - const args = resolveToolArgs(input); - const agent = - (sessionID ? runtime.getSessionAgent(sessionID) : undefined) ?? - resolveAgentName(input); - - if (sessionID) { - runtime.incrementToolCount(sessionID); - } - - // ── Plan mode gate (coordinator only) ─────────────────────── - if ( - sessionID && - agent && - PRIMARY_AGENTS.has(agent) && - tool && - runtime.getPlanMode(sessionID) === "planning" - ) { - const blocked = isBlockedInPlanMode(tool, args); - - if (blocked) { - const count = runtime.incrementPlanModeBlock(sessionID); - const isTaskBlocked = tool === "task" || tool.startsWith("task_"); - const msg = isTaskBlocked - ? `[PlanMode] task tool is blocked in planning mode. For read-only workers (killua, ginko, rust, rust_deep), use delegate instead of task. The user will /go to start execution.` - : count >= 3 - ? "[PlanMode] STILL in planning mode. You have attempted execution tools multiple times. STOP trying. Complete your plan with TodoWrite. The user will /go to start execution." - : "[PlanMode] You are in planning mode. Cannot use execution tools. Use Read/Glob/Grep/TodoWrite to continue planning. The user will /go to start execution."; - throw new BlockingHookError(msg); - } - } + const args = resolveEffectiveToolArgs(input, output); - // ── WSL node command auto-transform ───────────────────────── if ( tool === "bash" && runtime.isWsl() && @@ -101,20 +78,13 @@ export function createPreToolUseHook( ) { const command = args.command.trim(); if (isNodeCommand(command)) { - const transformed = transformToCmd(command, runtime.getWslWinPath()); - (args as Record).command = transformed; - runtime.appendObservation({ - timestamp: new Date().toISOString(), - phase: "pre", - sessionID, - agent, - tool, - note: `wsl_auto_transform: ${command} -> cmd.exe`, - }); + (args as Record).command = transformToCmd( + command, + runtime.getWslWinPath(), + ); } } - // ── Git push build gate ───────────────────────────────────── if ( tool === "bash" && typeof args.command === "string" && @@ -131,43 +101,14 @@ export function createPreToolUseHook( } } - // ── Track recent bash commands for build gate (per-session) ─ if (tool === "bash" && typeof args.command === "string" && sessionID) { - let cmds = recentBashBySession.get(sessionID); - if (!cmds) { - cmds = []; - recentBashBySession.set(sessionID, cmds); - } - cmds.push(args.command); - if (cmds.length > 10) { - cmds.shift(); + const commands = recentBashBySession.get(sessionID) ?? []; + commands.push(args.command); + if (commands.length > 10) { + commands.shift(); } + recentBashBySession.set(sessionID, commands); } - - // ── Compact suggestion ────────────────────────────────────── - if ( - sessionID && - runtime.shouldSuggestCompact(sessionID) && - profileMatches(profile, ["standard", "strict"]) - ) { - runtime.appendObservation({ - timestamp: new Date().toISOString(), - phase: "pre", - sessionID, - agent, - tool, - note: "compact_suggested", - }); - } - - // ── Observation logging ───────────────────────────────────── - runtime.appendObservation({ - timestamp: new Date().toISOString(), - phase: "pre", - sessionID, - agent, - tool, - }); }, }; } diff --git a/src/hooks/runtime.ts b/src/hooks/runtime.ts index 756d17a..6acae18 100644 --- a/src/hooks/runtime.ts +++ b/src/hooks/runtime.ts @@ -1,85 +1,10 @@ -import { - appendFileSync, - readdirSync, - statSync, - unlinkSync, - writeFileSync, -} from "node:fs"; -import { homedir } from "node:os"; -import { join, resolve } from "node:path"; -import { createHash } from "node:crypto"; -import type { PluginInput } from "@opencode-ai/plugin"; +import { type PluginInput } from "@opencode-ai/plugin"; import type { HarnessConfig, HookProfile } from "../types"; -import { ensureDir, readJson, readText, writeJson } from "../utils"; -import { - promoteLearnedPatterns, - renderInjectedPatterns, -} from "../learning/analyzer"; -import { - loadLearningArtifact, - saveLearningArtifact, - saveLearningMarkdown, -} from "../learning/store"; -import type { LearnedPattern } from "../learning/types"; import { detectProjectFacts, - joinProjectFactLabels, type ProjectFacts, } from "../project-facts"; -export type PersistedSessionSummary = { - sessionID: string; - savedAt: string; - packageManager: string; - languages: string[]; - frameworks: string[]; - changedFiles: string[]; - incompleteTodos: string[]; - lastUserMessage: string; - lastAssistantMessage: string; - approxTokens: number; -}; - -type PendingInjection = { - injected: boolean; -}; - -export type Observation = { - timestamp: string; - phase: "pre" | "post" | "idle"; - sessionID?: string; - agent?: string; - tool?: string; - note?: string; -}; - -function getStateRoot(config: HarnessConfig): string { - if (config.memory?.directory) { - return resolve(config.memory.directory); - } - - const envDir = process.env.OPENCODE_CONFIG_DIR?.trim(); - const configDir = envDir - ? resolve(envDir) - : join(homedir(), ".config", "opencode"); - return join(configDir, "pair-autonomy-state"); -} - -function projectKey(directory: string): string { - return createHash("sha1").update(directory).digest("hex").slice(0, 12); -} - -function truncate(text: string, maxChars: number): string { - return text.length <= maxChars - ? text - : `${text.slice(0, Math.max(0, maxChars - 3))}...`; -} - -function estimateTokens(chunks: string[]): number { - const totalChars = chunks.join("\n").length; - return Math.ceil(totalChars / 4); -} - export function resolveHookProfile(config: HarnessConfig): HookProfile { return config.hooks?.profile ?? "standard"; } @@ -91,21 +16,8 @@ export function profileMatches( return (Array.isArray(allowed) ? allowed : [allowed]).includes(profile); } -/** - * Primary (user-facing) agent that should receive session-context injection. - * Subagents spawned via Task tool should NOT get previous-session context - * injected into their system prompt — it causes session mixing. - */ -export const PRIMARY_AGENTS = new Set(["yang"]); +export const PRIMARY_AGENTS = new Set(["mrrobot"]); -/** - * Resolve a session ID from a hook input object. - * - * IMPORTANT: Does NOT fall back to bare `candidate.id` because tool-execution - * inputs often carry a tool-call / message `id` that is not a session ID. - * Use {@link resolveSessionOrEntityID} in session-lifecycle hooks where the - * input object IS the session itself and `.id` is the session ID. - */ export function resolveSessionID(value: unknown): string | undefined { if (!value || typeof value !== "object") return undefined; const obj = value as Record; @@ -125,11 +37,6 @@ export function resolveSessionID(value: unknown): string | undefined { return undefined; } -/** - * Like {@link resolveSessionID} but also falls back to bare `.id`. - * Use ONLY for session-lifecycle hooks (session.created, session.idle, - * session.deleted) where the input object represents the session itself. - */ export function resolveSessionOrEntityID(value: unknown): string | undefined { const fromSession = resolveSessionID(value); if (fromSession) return fromSession; @@ -175,163 +82,16 @@ export function resolveToolArgs(value: unknown): Record { : {}; } -export function resolveFilePathFromArgs( - args: Record, -): string | undefined { - const value = args.filePath ?? args.path; - return typeof value === "string" ? value : undefined; -} - -export function stringifyToolOutput(output: unknown): string { - if (typeof output === "string") { - return output; - } - if (typeof output === "number" || typeof output === "boolean") { - return String(output); - } - if (!output || typeof output !== "object") { - return ""; - } - if ( - "text" in output && - typeof (output as { text?: unknown }).text === "string" - ) { - return (output as { text: string }).text; - } - if ( - "stdout" in output && - typeof (output as { stdout?: unknown }).stdout === "string" - ) { - return (output as { stdout: string }).stdout; - } - try { - return JSON.stringify(output); - } catch { - return ""; - } -} - -function renderSessionContext(params: { - facts: ProjectFacts; - latest: PersistedSessionSummary | undefined; - learnedPatterns: LearnedPattern[]; - maxInjectedPatterns: number; - maxChars: number; -}): string { - const { facts, latest, learnedPatterns, maxInjectedPatterns, maxChars } = - params; - const parts = [ - "[SessionStart]", - `Project package manager: ${facts.packageManager}`, - `Project languages: ${facts.languages.length > 0 ? joinProjectFactLabels(facts.languages) : "unknown"}`, - `Project frameworks: ${facts.frameworks.length > 0 ? joinProjectFactLabels(facts.frameworks) : "none detected"}`, - ]; - - if (latest) { - parts.push( - "Previous session summary:", - `- Saved: ${latest.savedAt}`, - `- Changed files: ${latest.changedFiles.length > 0 ? latest.changedFiles.join(", ") : "none recorded"}`, - `- Incomplete todos: ${latest.incompleteTodos.length > 0 ? latest.incompleteTodos.join(" | ") : "none recorded"}`, - `- Last user request: ${latest.lastUserMessage || "n/a"}`, - `- Last assistant focus: ${latest.lastAssistantMessage || "n/a"}`, - ); - } - - const injectedPatterns = renderInjectedPatterns( - learnedPatterns, - maxInjectedPatterns, - ); - if (injectedPatterns.length > 0) { - parts.push("Learned project patterns:", ...injectedPatterns); - } - - parts.push( - "Use this context only when it helps. Do not restate it unless relevant.", - ); - return truncate(parts.join("\n"), maxChars); +function toWindowsPath(directory: string): string { + return directory + .replace(/^\/mnt\/(\w)/, (_, drive: string) => `${drive.toUpperCase()}:`) + .replace(/\//g, "\\"); } -export function createHookRuntime(ctx: PluginInput, config: HarnessConfig) { - const root = getStateRoot(config); - const projectRoot = join(root, projectKey(ctx.directory)); - const sessionsDir = join(projectRoot, "sessions"); - const learningDir = config.learning?.directory - ? resolve(config.learning.directory, projectKey(ctx.directory)) - : join(projectRoot, "learning"); - const observationsPath = join(learningDir, "observations.ndjson"); - const learnedPatternsPath = join(learningDir, "patterns.json"); - const learnedPatternsMarkdownPath = join(learningDir, "patterns.md"); - const planModesPath = join(projectRoot, "plan-modes.json"); - const pendingInjection = new Map(); +export function createHookRuntime(ctx: PluginInput, _config: HarnessConfig) { const sessionAgents = new Map(); - const editedFiles = new Map>(); - const toolCounts = new Map(); - const compactHints = new Map(); - - // ── Coordinator-specific state ──────────────────────────────── - const planModes = new Map(); - let planModesLoadedFromDisk = false; - - function loadPlanModesFromDisk(): void { - if (planModesLoadedFromDisk) return; - planModesLoadedFromDisk = true; - const persisted = readJson>( - planModesPath, - {}, - ); - for (const [id, mode] of Object.entries(persisted)) { - if (!planModes.has(id) && (mode === "planning" || mode === "executing")) { - planModes.set(id, mode); - } - } - } - - function persistPlanModes(): void { - const entries: Record = {}; - for (const [id, mode] of planModes) { - entries[id] = mode; - } - writeJson(planModesPath, entries); - } - - const planModeBlockCounts = new Map(); - const workerMessageCounts = new Map(); - const reviewCycleCounts = new Map(); - - const MAX_TRACKED_SESSIONS = 50; - const sessionMaps = [ - pendingInjection, - sessionAgents, - editedFiles, - toolCounts, - compactHints, - planModes, - planModeBlockCounts, - workerMessageCounts, - reviewCycleCounts, - ] as Map[]; - - function evictStaleSessions(): void { - if (pendingInjection.size <= MAX_TRACKED_SESSIONS) return; - const staleCount = pendingInjection.size - MAX_TRACKED_SESSIONS; - const staleKeys = [...pendingInjection.keys()].slice(0, staleCount); - for (const key of staleKeys) { - for (const map of sessionMaps) { - map.delete(key); - } - } - } - - let wslMode = ctx.directory.startsWith("/mnt/"); - let wslWinPath = wslMode - ? ctx.directory - .replace(/^\/mnt\/(\w)/, (_, d: string) => `${d.toUpperCase()}:`) - .replace(/\//g, "\\") - : ""; - - ensureDir(sessionsDir); - ensureDir(learningDir); + const wslMode = ctx.directory.startsWith("/mnt/"); + const wslWinPath = wslMode ? toWindowsPath(ctx.directory) : ""; function setSessionAgent(sessionID: string, agent: string | undefined): void { if (!agent) { @@ -344,335 +104,30 @@ export function createHookRuntime(ctx: PluginInput, config: HarnessConfig) { return sessionAgents.get(sessionID); } - function rememberEditedFile(sessionID: string, filePath: string): void { - const next = editedFiles.get(sessionID) ?? new Set(); - next.add(filePath); - editedFiles.set(sessionID, next); - } - - function getEditedFiles(sessionID: string): string[] { - return [...(editedFiles.get(sessionID) ?? new Set())].sort(); - } - - function incrementToolCount(sessionID: string): number { - const next = (toolCounts.get(sessionID) ?? 0) + 1; - toolCounts.set(sessionID, next); - return next; - } - - function shouldSuggestCompact( - sessionID: string, - threshold = 50, - repeat = 25, - ): boolean { - const count = toolCounts.get(sessionID) ?? 0; - if (count < threshold) { - return false; - } - - const lastHint = compactHints.get(sessionID) ?? 0; - if (count === threshold || count - lastHint >= repeat) { - compactHints.set(sessionID, count); - return true; - } - return false; - } - - function loadLatestSummary(): PersistedSessionSummary | undefined { - // Scan timestamped session files instead of relying on latest.json - // which suffers from race conditions when multiple sessions go idle - // at the same time. Filenames are ISO-timestamp-prefixed so - // lexicographic sort == chronological sort. - try { - const files = readdirSync(sessionsDir) - .filter((f) => f.endsWith(".json") && f !== "latest.json") - .sort(); - const newest = files[files.length - 1]; - if (newest) { - return readJson( - join(sessionsDir, newest), - undefined, - ); - } - } catch { - // Directory may not exist yet — fall through - } - return undefined; - } - - function prepareSessionContext(sessionID: string): void { - evictStaleSessions(); - pendingInjection.set(sessionID, { - injected: false, - }); - } - - function consumePendingInjection( - sessionID: string, - ): string | undefined { - const entry = pendingInjection.get(sessionID); - if (!entry || entry.injected) { - return undefined; - } - entry.injected = true; - - const latest = - config.memory?.enabled === false ? undefined : loadLatestSummary(); - return renderSessionContext({ - facts: detectProjectFacts(ctx.directory), - latest, - learnedPatterns: loadLearnedPatterns(), - maxInjectedPatterns: config.learning?.max_injected_patterns ?? 5, - maxChars: config.memory?.max_injected_chars ?? 3500, - }); - } - - function cleanupOldSessions(maxAgeDays = 7): void { - try { - const files = readdirSync(sessionsDir).filter( - (f) => f.endsWith(".json"), - ); - const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000; - - for (const file of files) { - const filePath = join(sessionsDir, file); - try { - const stat = statSync(filePath); - if (stat.mtimeMs < cutoff) { - unlinkSync(filePath); - } - } catch { - // skip files we can't stat - } - } - } catch { - // directory may not exist - } - } - - function saveSessionSummary(summary: PersistedSessionSummary): void { - // Write only the timestamped file — each session gets its own file so - // concurrent idle events cannot overwrite each other. - // loadLatestSummary() now scans the directory for the newest file. - writeJson( - join( - sessionsDir, - `${summary.savedAt.replace(/[:.]/g, "-")}-${summary.sessionID}.json`, - ), - summary, - ); - cleanupOldSessions(config.memory?.lookback_days ?? 7); - } - - let observationAppendCount = 0; - - function rotateObservations(maxLines = 500): void { - const content = readText(observationsPath); - if (!content) return; - - const lines = content.split("\n").filter(Boolean); - if (lines.length <= maxLines) return; - - // Keep the most recent entries - const kept = lines.slice(-maxLines); - writeFileSync(observationsPath, kept.join("\n") + "\n", "utf8"); - } - - function appendObservation(observation: Observation): void { - if (config.learning?.enabled === false) { - return; - } - ensureDir(learningDir); - appendFileSync( - observationsPath, - `${JSON.stringify(observation)}\n`, - "utf8", - ); - - observationAppendCount++; - if (observationAppendCount % 50 === 0) { - rotateObservations(); - } - } - - function loadObservations(limit = 200): Observation[] { - const content = readText(observationsPath); - if (!content) { - return []; - } - - return content - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .slice(-limit) - .map((line) => { - try { - return JSON.parse(line) as Observation; - } catch { - return undefined; - } - }) - .filter((value): value is Observation => Boolean(value)); - } - - function loadLearnedPatterns(): LearnedPattern[] { - return loadLearningArtifact(learnedPatternsPath).patterns; - } - - function promoteLearning(summary: PersistedSessionSummary): LearnedPattern[] { - if ( - config.learning?.enabled === false || - config.learning?.auto_promote === false - ) { - return loadLearnedPatterns(); - } - - const observations = loadObservations(); - if (observations.length < (config.learning?.min_observations ?? 6)) { - return loadLearnedPatterns(); - } - - const nextPatterns = promoteLearnedPatterns({ - existing: loadLearnedPatterns(), - summary, - facts: detectProjectFacts(ctx.directory), - observations, - maxPatterns: config.learning?.max_patterns ?? 24, - }); - - saveLearningArtifact(learnedPatternsPath, nextPatterns); - saveLearningMarkdown(learnedPatternsMarkdownPath, nextPatterns); - return nextPatterns; - } - function clearSession(sessionID: string): void { - pendingInjection.delete(sessionID); sessionAgents.delete(sessionID); - editedFiles.delete(sessionID); - toolCounts.delete(sessionID); - compactHints.delete(sessionID); - planModes.delete(sessionID); - planModeBlockCounts.delete(sessionID); - workerMessageCounts.delete(sessionID); - reviewCycleCounts.delete(sessionID); - persistPlanModes(); - } - - // ── Plan mode ───────────────────────────────────────────────── - function getPlanMode(sessionID: string): "planning" | "executing" { - loadPlanModesFromDisk(); - return planModes.get(sessionID) ?? "planning"; } - function setPlanMode( - sessionID: string, - mode: "planning" | "executing", - ): void { - planModes.set(sessionID, mode); - if (mode === "planning") { - planModeBlockCounts.delete(sessionID); - } - persistPlanModes(); - } - - function incrementPlanModeBlock(sessionID: string): number { - const count = (planModeBlockCounts.get(sessionID) ?? 0) + 1; - planModeBlockCounts.set(sessionID, count); - return count; - } - - function resetPlanModeBlockCount(sessionID: string): void { - planModeBlockCounts.delete(sessionID); - } - - // ── Worker continuation ─────────────────────────────────────── - function incrementWorkerMessages(workerID: string): number { - const count = (workerMessageCounts.get(workerID) ?? 0) + 1; - workerMessageCounts.set(workerID, count); - return count; - } - - function shouldSpawnFresh(workerID: string): boolean { - return (workerMessageCounts.get(workerID) ?? 0) >= 5; - } - - // ── Review cycles ───────────────────────────────────────────── - function incrementReviewCycle(sessionID: string): number { - const count = (reviewCycleCounts.get(sessionID) ?? 0) + 1; - reviewCycleCounts.set(sessionID, count); - return count; - } - - function getReviewCycleCount(sessionID: string): number { - return reviewCycleCounts.get(sessionID) ?? 0; - } - - // ── WSL ──────────────────────────────────────────────────────── - function isWsl(): boolean { - return wslMode; - } - - function getWslWinPath(): string { - return wslWinPath; - } - - // ── Mode injection for system prompt ────────────────────────── - function buildModeInjection(sessionID: string): string { - const mode = getPlanMode(sessionID); - const parts: string[] = []; - - if (mode === "planning") { - parts.push( - "[Mode: Planning] Create your plan with TodoWrite. User will /go to execute.", - ); - } else { - parts.push( - "[Mode: Executing] Proceed with worker spawning and todo execution.", - ); - } - - if (wslMode) { - parts.push( - `[WSL] Windows project at ${wslWinPath}. Read/Edit via /mnt/ paths.`, - "Node tools (npm/pnpm/yarn/bun/npx/bunx/node/tsc/tsx/vite/next/nuxt/vitest/jest/eslint/prettier): run via cmd.exe.", - "Git/SSH/curl/grep: WSL bash OK.", - ); + function buildPrimaryInjection(): string { + if (!wslMode) { + return ""; } - return parts.join("\n"); + return [ + `[WSL] Windows project at ${wslWinPath}. Read/Edit via /mnt/ paths.`, + "Node tools (npm/pnpm/yarn/bun/npx/bunx/node/tsc/tsx/vite/next/nuxt/vitest/jest/eslint/prettier): run via cmd.exe.", + "Git/SSH/curl/grep: WSL bash OK.", + ].join("\n"); } return { - detectProjectFacts: () => detectProjectFacts(ctx.directory), - estimateTokens, - loadLatestSummary, - loadLearnedPatterns, - prepareSessionContext, - consumePendingInjection, - saveSessionSummary, - appendObservation, - promoteLearning, + detectProjectFacts: (): ProjectFacts => detectProjectFacts(ctx.directory), setSessionAgent, getSessionAgent, - rememberEditedFile, - getEditedFiles, - incrementToolCount, - shouldSuggestCompact, clearSession, - readText, - // Coordinator state - getPlanMode, - setPlanMode, - incrementPlanModeBlock, - resetPlanModeBlockCount, - incrementWorkerMessages, - shouldSpawnFresh, - incrementReviewCycle, - getReviewCycleCount, - isWsl, - getWslWinPath, - buildModeInjection, + isWsl: (): boolean => wslMode, + getWslWinPath: (): string => wslWinPath, + buildPrimaryInjection, }; } diff --git a/src/hooks/session-end.ts b/src/hooks/session-end.ts index 79afb45..eb19eca 100644 --- a/src/hooks/session-end.ts +++ b/src/hooks/session-end.ts @@ -4,18 +4,11 @@ import { resolveSessionOrEntityID } from "./runtime"; export function createSessionEndHook(runtime: HookRuntime) { return { "session.deleted": async (input?: unknown): Promise => { - // session.deleted input IS the session object, so bare .id is safe const sessionID = resolveSessionOrEntityID(input); if (!sessionID) { return; } - runtime.appendObservation({ - timestamp: new Date().toISOString(), - phase: "idle", - sessionID, - note: "session deleted", - }); runtime.clearSession(sessionID); }, }; diff --git a/src/hooks/session-start.ts b/src/hooks/session-start.ts index 4a20375..3ae6956 100644 --- a/src/hooks/session-start.ts +++ b/src/hooks/session-start.ts @@ -4,15 +4,7 @@ import type { PluginInput } from "@opencode-ai/plugin"; import { joinProjectFactLabels } from "../project-facts"; import type { HarnessConfig } from "../types"; import type { HookRuntime } from "./runtime"; -import { PRIMARY_AGENTS, resolveSessionOrEntityID } from "./runtime"; - -function extractTextParts(parts: Array<{ type?: string; text?: string }>): string { - return parts - .filter((part) => part.type === "text" && typeof part.text === "string") - .map((part) => part.text ?? "") - .join("\n") - .trim(); -} +import { PRIMARY_AGENTS } from "./runtime"; type ChatMessageInput = { sessionID: string; @@ -21,7 +13,6 @@ type ChatMessageInput = { type ChatMessageOutput = { message: Record; - parts?: Array<{ type?: string; text?: string }>; }; function compactFactList(values: string[]): string { @@ -56,20 +47,13 @@ function detectProjectDocs(directory: string): string[] { return candidates.filter((name) => existsSync(join(directory, name))); } -function buildResourceInjection( - runtime: HookRuntime, - directory: string, -): string { - const parts: string[] = []; - +function buildResourceInjection(directory: string): string { const docs = detectProjectDocs(directory); - if (docs.length > 0) { - parts.push( - `[ProjectDocs] Available: ${docs.join(", ")}. Read these before starting domain-specific work.`, - ); + if (docs.length === 0) { + return ""; } - return parts.join("\n"); + return `[ProjectDocs] Available: ${docs.join(", ")}. Read these before starting domain-specific work.`; } export function createSessionStartHook( @@ -78,22 +62,6 @@ export function createSessionStartHook( runtime: HookRuntime, ) { return { - "session.created": async (input?: unknown): Promise => { - const sessionID = resolveSessionOrEntityID(input); - if (!sessionID) { - return; - } - - // Initialize plan mode to planning - runtime.setPlanMode(sessionID, "planning"); - - if ( - config.memory?.enabled !== false || - config.learning?.enabled !== false - ) { - runtime.prepareSessionContext(sessionID); - } - }, "chat.message": async ( input: ChatMessageInput, output: ChatMessageOutput, @@ -105,23 +73,8 @@ export function createSessionStartHook( : undefined); runtime.setSessionAgent(input.sessionID, agentName); - // Detect mode transitions via unique harness markers embedded in command templates. - // Markers are collision-resistant — normal conversation cannot trigger them. - const userText = extractTextParts(output.parts ?? []); - if (userText) { - const trimmed = userText.trim().toLowerCase(); - if (trimmed.includes("[harness:mode:executing]")) { - runtime.setPlanMode(input.sessionID, "executing"); - runtime.resetPlanModeBlockCount(input.sessionID); - } else if (trimmed.includes("[harness:mode:planning]")) { - runtime.setPlanMode(input.sessionID, "planning"); - } - } - - // Subagents get minimal project facts only (no session context, no mode) if (agentName && !PRIMARY_AGENTS.has(agentName)) { const factLine = buildSubagentProjectContext(config, runtime); - const previousSystem = typeof output.message.system === "string" ? output.message.system.trim() @@ -132,33 +85,10 @@ export function createSessionStartHook( return; } - // Build injection parts - const injectionParts: string[] = []; - - // Mode injection (plan mode + WSL) - const modeInjection = runtime.buildModeInjection(input.sessionID); - if (modeInjection) { - injectionParts.push(modeInjection); - } - - // Resource injection (project docs) - const resourceInjection = buildResourceInjection(runtime, ctx.directory); - if (resourceInjection) { - injectionParts.push(resourceInjection); - } - - // Session context (memory + learning patterns) - if ( - config.memory?.enabled !== false || - config.learning?.enabled !== false - ) { - const sessionContext = runtime.consumePendingInjection( - input.sessionID, - ); - if (sessionContext) { - injectionParts.push(sessionContext); - } - } + const injectionParts = [ + runtime.buildPrimaryInjection(), + buildResourceInjection(ctx.directory), + ].filter(Boolean); if (injectionParts.length === 0) { return; diff --git a/src/hooks/stop.ts b/src/hooks/stop.ts deleted file mode 100644 index eccd05d..0000000 --- a/src/hooks/stop.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { PluginInput } from "@opencode-ai/plugin"; -import type { HookRuntime } from "./runtime"; -import { unwrapData } from "./sdk"; -import { resolveSessionOrEntityID } from "./runtime"; - -function extractTextParts(parts: Array<{ type?: string; text?: string }>): string { - return parts - .filter((part) => part.type === "text" && typeof part.text === "string") - .map((part) => part.text ?? "") - .join("\n") - .trim(); -} - -type Todo = { - content?: string; - status?: string; -}; - -type Message = { - info?: { - role?: string; - }; - parts?: Array<{ type?: string; text?: string }>; -}; - -function extractText(message: Message | undefined): string { - return extractTextParts(message?.parts ?? []); -} - -export function createStopHook(ctx: PluginInput, runtime: HookRuntime) { - return { - "session.idle": async (input?: unknown): Promise => { - // session.idle input IS the session object, so bare .id is safe - const sessionID = resolveSessionOrEntityID(input); - if (!sessionID) { - return; - } - - const messagesResponse = await ctx.client.session - .messages({ - path: { id: sessionID }, - query: { directory: ctx.directory, limit: 40 }, - }) - .catch(() => null); - const todosResponse = await ctx.client.session - .todo({ - path: { id: sessionID }, - query: { directory: ctx.directory }, - }) - .catch(() => null); - - const messages = unwrapData(messagesResponse, []); - const todos = unwrapData(todosResponse, []); - const lastUser = [...messages] - .reverse() - .find((message) => message.info?.role === "user"); - const lastAssistant = [...messages] - .reverse() - .find((message) => message.info?.role === "assistant"); - const incompleteTodos = todos - .filter( - (todo) => - todo.status && - !["completed", "cancelled", "blocked", "deleted"].includes( - todo.status, - ), - ) - .map((todo) => todo.content ?? "") - .filter(Boolean); - - const facts = runtime.detectProjectFacts(); - const summary = { - sessionID, - savedAt: new Date().toISOString(), - packageManager: facts.packageManager, - languages: facts.languages, - frameworks: facts.frameworks, - changedFiles: runtime.getEditedFiles(sessionID), - incompleteTodos, - lastUserMessage: extractText(lastUser), - lastAssistantMessage: extractText(lastAssistant), - approxTokens: runtime.estimateTokens([ - extractText(lastUser), - extractText(lastAssistant), - ...incompleteTodos, - ]), - }; - runtime.saveSessionSummary(summary); - const promotedPatterns = runtime.promoteLearning(summary); - - runtime.appendObservation({ - timestamp: new Date().toISOString(), - phase: "idle", - sessionID, - agent: runtime.getSessionAgent(sessionID), - note: `idle_summary_saved:${incompleteTodos.length}:${promotedPatterns.length}`, - }); - }, - }; -} diff --git a/src/index.ts b/src/index.ts index 4945083..1d38bba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,7 +41,7 @@ const PairAutonomyPlugin: Plugin = async (ctx) => { }; if (harnessConfig.set_default_agent !== false) { - mutableConfig.default_agent = "yang"; + mutableConfig.default_agent = "mrrobot"; } await hooks.config?.(config); @@ -54,20 +54,9 @@ const PairAutonomyPlugin: Plugin = async (ctx) => { ...(hooks["tool.execute.after"] ? { "tool.execute.after": hooks["tool.execute.after"] } : {}), - ...(hooks["file.edited"] ? { "file.edited": hooks["file.edited"] } : {}), - ...(hooks["session.created"] - ? { "session.created": hooks["session.created"] } - : {}), - ...(hooks["session.idle"] ? { "session.idle": hooks["session.idle"] } : {}), ...(hooks["session.deleted"] ? { "session.deleted": hooks["session.deleted"] } : {}), - ...(hooks["experimental.session.compacting"] - ? { - "experimental.session.compacting": - hooks["experimental.session.compacting"], - } - : {}), }; }; diff --git a/src/installer.ts b/src/installer.ts index a5f6d27..594c64c 100644 --- a/src/installer.ts +++ b/src/installer.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { mkdirSync, existsSync, @@ -14,13 +15,16 @@ import { fileURLToPath } from "node:url"; import { parse } from "jsonc-parser"; import { spawn } from "node:child_process"; import { SAMPLE_PROJECT_CONFIG } from "./config"; +import { getManagedMcpRoot } from "./mcp"; type JsonRecord = Record; +type ManagedEntriesManifest = { + entries?: Record; +}; /** * npm package names used as plugin entries in opencode.json. * Each entry is written as `"@latest"` in the config. - * The vendor background-agents-local plugin stays as a `file://` entry. */ const MANAGED_PLUGIN_ENTRIES = [ "@zenobius/opencode-skillful", @@ -49,19 +53,19 @@ const PACKAGE_SPECS: Record = { }; const MCP_NAMES = ["pg-mcp", "ssh-mcp", "web-agent-mcp"] as const; - -const BACKGROUND_AGENT_FILES = [ - "background-agents.ts", - "kdco-primitives/get-project-id.ts", - "kdco-primitives/index.ts", - "kdco-primitives/log-warn.ts", - "kdco-primitives/mutex.ts", - "kdco-primitives/shell.ts", - "kdco-primitives/temp.ts", - "kdco-primitives/terminal-detect.ts", - "kdco-primitives/types.ts", - "kdco-primitives/with-timeout.ts", -] as const; +const MANAGED_SKILLS_MANIFEST = ".opencode-pair-managed-skills.json"; +const MANAGED_MCP_STAMP = ".opencode-pair-managed-mcp.json"; +const MANAGED_SOURCE_HASH_KEY = "__sourceHash"; +const MCP_REQUIRED_PACKAGES: Record<(typeof MCP_NAMES)[number], string[]> = { + "pg-mcp": ["@modelcontextprotocol/sdk", "pg"], + "ssh-mcp": ["@modelcontextprotocol/sdk", "zod"], + "web-agent-mcp": [ + "@modelcontextprotocol/sdk", + "zod", + "cloakbrowser", + "playwright-core", + ], +}; function getConfigDir(): string { const envDir = process.env.OPENCODE_CONFIG_DIR?.trim(); @@ -81,7 +85,6 @@ function getConfigPaths(configDir: string) { packageJson: join(configDir, "package.json"), harnessConfig: join(configDir, "opencode-pair.jsonc"), vendorDir: join(configDir, "vendor", "opencode-background-agents-local"), - vendorMcpDir: join(configDir, "vendor", "mcp"), shellStrategyDir: join(configDir, "plugin", "shell-strategy"), notifierConfig: join(configDir, "opencode-notifier.json"), }; @@ -139,10 +142,14 @@ function ensureDir(dirPath: string): void { mkdirSync(dirPath, { recursive: true }); } -function shouldPreserveFreshInstallEntry( +export function shouldPreserveFreshInstallEntry( configDir: string, entryName: string, ): boolean { + if (entryName === "skills") { + return true; + } + const entryPath = join(configDir, entryName); if (!existsSync(entryPath)) { return false; @@ -160,6 +167,93 @@ function shouldPreserveFreshInstallEntry( return entryName.endsWith(".json") || entryName.endsWith(".jsonc"); } +function hashDirectoryContents(rootDir: string): string { + const hash = createHash("sha1"); + + function visit(dirPath: string, relativePath = ""): void { + for (const entry of readdirSync(dirPath).sort()) { + const entryPath = join(dirPath, entry); + const nextRelativePath = relativePath ? join(relativePath, entry) : entry; + const stat = statSync(entryPath); + + if (stat.isDirectory()) { + hash.update(`dir:${nextRelativePath}`); + visit(entryPath, nextRelativePath); + continue; + } + + hash.update(`file:${nextRelativePath}`); + hash.update(readFileSync(entryPath)); + } + } + + visit(rootDir); + return hash.digest("hex"); +} + +function hashFileContents(filePath: string): string { + return createHash("sha1").update(readFileSync(filePath)).digest("hex"); +} + +function collectManagedEntries(rootDir: string): Record { + const entries: Record = {}; + + function visit(dirPath: string, relativePath = ""): void { + for (const entry of readdirSync(dirPath).sort()) { + const entryPath = join(dirPath, entry); + const nextRelativePath = relativePath ? join(relativePath, entry) : entry; + const stat = statSync(entryPath); + + if (stat.isDirectory()) { + entries[nextRelativePath] = "dir"; + visit(entryPath, nextRelativePath); + continue; + } + + entries[nextRelativePath] = hashFileContents(entryPath); + } + } + + visit(rootDir); + return entries; +} + +function readManagedEntriesManifest(filePath: string): ManagedEntriesManifest { + return readJsonLike(filePath) as ManagedEntriesManifest; +} + +function writeManagedEntriesManifest( + filePath: string, + entries: Record, +): void { + writeJson(filePath, { entries }); +} + +function managedSkillsManifestPath(skillsDir: string): string { + return join(skillsDir, MANAGED_SKILLS_MANIFEST); +} + +function managedMcpStampPath(mcpDir: string): string { + return join(mcpDir, MANAGED_MCP_STAMP); +} + +function pruneManagedEntries( + targetRoot: string, + previousEntries: Record, + nextEntries: Record, + preservedEntries: Set, +): void { + const stalePaths = Object.keys(previousEntries) + .filter((entry) => entry !== MANAGED_SOURCE_HASH_KEY) + .filter((entry) => !(entry in nextEntries)) + .filter((entry) => !preservedEntries.has(entry)) + .sort((a, b) => b.length - a.length); + + for (const relativePath of stalePaths) { + rmSync(join(targetRoot, relativePath), { recursive: true, force: true }); + } +} + function freshInstallCleanup(configDir: string): void { if (!existsSync(configDir)) { return; @@ -232,14 +326,9 @@ function resolveSelfPackageSpec(): string { return version || "latest"; } -function mergePluginList(existing: unknown, vendorDir: string): string[] { +function mergePluginList(existing: unknown): string[] { const selfEntry = `file://${packageRoot()}`; - const backgroundEntry = `file://${vendorDir}`; - const desired = [ - selfEntry, - ...MANAGED_PLUGIN_ENTRIES.map((pkg) => `${pkg}@latest`), - backgroundEntry, - ]; + const desired = [selfEntry, ...MANAGED_PLUGIN_ENTRIES.map((pkg) => `${pkg}@latest`)]; const managedBareNames = new Set(MANAGED_PLUGIN_ENTRIES); const current = Array.isArray(existing) ? existing.filter((item): item is string => typeof item === "string") @@ -368,34 +457,6 @@ async function fetchText(url: string): Promise { return await response.text(); } -async function installBackgroundAgentsVendor(vendorDir: string): Promise { - ensureDir(join(vendorDir, "kdco-primitives")); - - for (const relativePath of BACKGROUND_AGENT_FILES) { - const url = `https://raw.githubusercontent.com/kdcokenny/opencode-background-agents/main/src/plugin/${relativePath}`; - const targetPath = join(vendorDir, relativePath); - ensureDir(dirname(targetPath)); - const content = await fetchText(url); - writeFileSync(targetPath, content, "utf8"); - } - - const packageJson: JsonRecord = { - name: "opencode-background-agents-local", - version: "0.1.0", - private: true, - type: "module", - module: "background-agents.ts", - main: "background-agents.ts", - dependencies: { - "@opencode-ai/plugin": "latest", - "@opencode-ai/sdk": "latest", - "unique-names-generator": "latest", - }, - }; - - writeJson(join(vendorDir, "package.json"), packageJson); -} - async function installShellStrategyInstruction( shellStrategyDir: string, ): Promise { @@ -437,61 +498,111 @@ function copyDirectoryContents( } } +function copyPath(sourcePath: string, targetPath: string): void { + const stat = statSync(sourcePath); + if (stat.isDirectory()) { + copyDirectoryContents(sourcePath, targetPath); + return; + } + + ensureDir(dirname(targetPath)); + copyFileSync(sourcePath, targetPath); +} + +function hashPathContents(path: string): string { + return statSync(path).isDirectory() ? hashDirectoryContents(path) : hashFileContents(path); +} + function shouldOverwriteBundledMcpFile( relativePath: string, targetPath: string, - fresh = false, ): boolean { - if (fresh) { - return true; - } - return !(relativePath === "config.json" && existsSync(targetPath)); } -function installSelfContainedMcps( - vendorMcpDir: string, - options?: { fresh?: boolean }, +export function syncManagedMcp( + name: (typeof MCP_NAMES)[number], + sourceRoot: string, + targetRoot: string, ): void { - ensureDir(vendorMcpDir); + const sourceHash = hashDirectoryContents(sourceRoot); + const previousEntries = readManagedEntriesManifest( + managedMcpStampPath(targetRoot), + ).entries; + const nextEntries = collectManagedEntries(sourceRoot); + ensureDir(targetRoot); + pruneManagedEntries( + targetRoot, + previousEntries ?? {}, + nextEntries, + new Set(["config.json"]), + ); + copyDirectoryContents(sourceRoot, targetRoot, { + overwrite: (relativePath, targetPath) => + shouldOverwriteBundledMcpFile(relativePath, targetPath), + }); + + if ( + name === "web-agent-mcp" && + previousEntries?.[MANAGED_SOURCE_HASH_KEY] !== sourceHash + ) { + rmSync(join(targetRoot, "node_modules"), { recursive: true, force: true }); + } + + writeManagedEntriesManifest(managedMcpStampPath(targetRoot), { + ...nextEntries, + [MANAGED_SOURCE_HASH_KEY]: sourceHash, + }); +} +function installSelfContainedMcps(): void { for (const name of MCP_NAMES) { const sourceRoot = bundledMcpSourceRoot(name); if (!existsSync(sourceRoot)) { throw new Error(`Missing MCP source directory: ${sourceRoot}`); } - const targetRoot = join(vendorMcpDir, name); - ensureDir(targetRoot); - copyDirectoryContents(sourceRoot, targetRoot, { - overwrite: (relativePath, targetPath) => - shouldOverwriteBundledMcpFile(relativePath, targetPath, options?.fresh), - }); + const targetRoot = getManagedMcpRoot(name); + syncManagedMcp(name, sourceRoot, targetRoot); } } -function isWebAgentMcpInstalled(mcpDir: string): boolean { +function isManagedMcpInstalled( + name: (typeof MCP_NAMES)[number], + mcpDir: string, +): boolean { const nodeModules = join(mcpDir, "node_modules"); if (!existsSync(nodeModules)) { return false; } - // Verify key dependencies actually exist inside node_modules - const requiredPackages = ["@modelcontextprotocol/sdk", "zod"]; - return requiredPackages.every((pkg) => + + return MCP_REQUIRED_PACKAGES[name].every((pkg) => existsSync(join(nodeModules, ...pkg.split("/"))), ); } -async function installWebAgentMcpDeps(vendorMcpDir: string): Promise { - const mcpDir = join(vendorMcpDir, "web-agent-mcp"); +function getManagedMcpInstallCommand(name: (typeof MCP_NAMES)[number]): string[] { + if (name === "web-agent-mcp") { + return ["bun", "install"]; + } + + return ["npm", "install", "--omit=dev"]; +} + +async function installManagedMcpDeps( + name: (typeof MCP_NAMES)[number], +): Promise { + const mcpDir = getManagedMcpRoot(name); if (!existsSync(mcpDir)) { return; } - if (isWebAgentMcpInstalled(mcpDir)) { + if (isManagedMcpInstalled(name, mcpDir)) { return; } + + const [command, ...args] = getManagedMcpInstallCommand(name); await new Promise((resolvePromise, rejectPromise) => { - const child = spawn("bun", ["install"], { + const child = spawn(command, args, { cwd: mcpDir, stdio: "inherit", }); @@ -503,25 +614,61 @@ async function installWebAgentMcpDeps(vendorMcpDir: string): Promise { } rejectPromise( new Error( - `bun install for web-agent-mcp failed with exit code ${code ?? -1}`, + `${command} ${args.join(" ")} failed for ${name} with exit code ${code ?? -1}`, ), ); }); }); } +async function ensureManagedMcpDependencies(): Promise { + for (const name of MCP_NAMES) { + try { + await installManagedMcpDeps(name); + } catch (error) { + console.warn( + `[opencode-pair] Failed to install ${name} dependencies: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} + function bundledSkillsSourceRoot(): string { return join(packageRoot(), "vendor", "skills"); } -function installBundledSkills(skillsDir: string): void { - const sourceRoot = bundledSkillsSourceRoot(); +export function installBundledSkills( + skillsDir: string, + sourceRoot = bundledSkillsSourceRoot(), +): void { if (!existsSync(sourceRoot)) { return; } ensureDir(skillsDir); - copyDirectoryContents(sourceRoot, skillsDir); + const manifestPath = managedSkillsManifestPath(skillsDir); + const previousEntries = readManagedEntriesManifest(manifestPath).entries ?? {}; + const nextEntries: Record = {}; + const sourceEntries = readdirSync(sourceRoot).sort(); + + pruneManagedEntries(skillsDir, previousEntries, {}, new Set(sourceEntries)); + + for (const entry of sourceEntries) { + const sourcePath = join(sourceRoot, entry); + const targetPath = join(skillsDir, entry); + const sourceHash = hashPathContents(sourcePath); + const wasManaged = previousEntries[entry] !== undefined; + + if (existsSync(targetPath) && !wasManaged) { + continue; + } + + rmSync(targetPath, { recursive: true, force: true }); + copyPath(sourcePath, targetPath); + nextEntries[entry] = sourceHash; + } + + writeManagedEntriesManifest(manifestPath, nextEntries); } function updateConfig(paths: ReturnType): string { @@ -529,12 +676,12 @@ function updateConfig(paths: ReturnType): string { const config = readJsonLike(detected.path); backupFile(detected.path); config.$schema = config.$schema ?? "https://opencode.ai/config.json"; - config.plugin = mergePluginList(config.plugin, paths.vendorDir); + config.plugin = mergePluginList(config.plugin); config.instructions = mergeInstructionsList( config.instructions, paths.shellStrategyDir, ); - config.default_agent = "yang"; + config.default_agent = "mrrobot"; forceAllowPermissions(config); writeJson(detected.path, config); return detected.path; @@ -804,13 +951,12 @@ export async function installHarness(options?: { fresh?: boolean }): Promise<{ ensureDir(configDir); ensureDir(paths.binDir); - ensureDir(join(configDir, "vendor")); ensureTuiConfig(configDir); ensureSkillsDir(paths.skillsDir); await installShellStrategyInstruction(paths.shellStrategyDir); - await installBackgroundAgentsVendor(paths.vendorDir); - installSelfContainedMcps(paths.vendorMcpDir, { fresh: options?.fresh }); + installSelfContainedMcps(); + await ensureManagedMcpDependencies(); installBundledSkills(paths.skillsDir); await ensureSearxngContainer(); const configPath = updateConfig(paths); @@ -819,14 +965,6 @@ export async function installHarness(options?: { fresh?: boolean }): Promise<{ writeNotifierConfig(paths.notifierConfig); await runBunInstall(configDir); await ensureInstalledHarnessBuild(configDir); - try { - await installWebAgentMcpDeps(paths.vendorMcpDir); - } catch (error) { - console.warn( - `[opencode-pair] Failed to install web-agent-mcp dependencies: ${error instanceof Error ? error.message : String(error)}`, - ); - } - return { configPath, packageJsonPath, @@ -900,6 +1038,10 @@ export async function uninstallHarness(): Promise<{ return { configPath: detected.path, packageJsonPath: paths.packageJson, - preservedPaths: [paths.harnessConfig, paths.vendorMcpDir, paths.skillsDir], + preservedPaths: [ + paths.harnessConfig, + paths.skillsDir, + ...MCP_NAMES.map((name) => getManagedMcpRoot(name)), + ], }; } diff --git a/src/learning/analyzer.ts b/src/learning/analyzer.ts deleted file mode 100644 index 095b450..0000000 --- a/src/learning/analyzer.ts +++ /dev/null @@ -1,361 +0,0 @@ -import type { PersistedSessionSummary, Observation } from "../hooks/runtime"; -import { getProjectFactLabel, type ProjectFacts } from "../project-facts"; -import type { - LearningCandidate, - LearningEvidence, - LearnedPattern, -} from "./types"; - -const PREFERENCE_SIGNALS: Record = { - "user:no-routine-permission-asks": [ - "do not ask permission", "dont ask permission", "stop asking permission", - "dont keep asking", "no permission questions", - ], - "user:explain-disagreement-explicitly": [ - "explain why", "if you disagree", "make disagreement explicit", - "say why you disagree", "explain the tradeoff", - ], - "user:subagents-are-exceptional": [ - "rarely call subagents", "subagents should be rare", "only for large tasks", - "large output tasks", "async work", "use subagents sparingly", - ], - "user:implement-in-phases": [ - "phase by phase", "step by step", "implement in phases", - "do it in phases", "one step at a time", - ], -}; - -function matchesAnyPhrase(text: string, phrases: string[]): boolean { - const normalized = text.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); - return phrases.some((phrase) => normalized.includes(phrase.toLowerCase())); -} - -const USER_PREFERENCE_RULES: Array<{ id: string; baseConfidence: number }> = [ - { - id: "user:no-routine-permission-asks", - baseConfidence: 0.72, - }, - { - id: "user:explain-disagreement-explicitly", - baseConfidence: 0.7, - }, - { - id: "user:subagents-are-exceptional", - baseConfidence: 0.75, - }, - { - id: "user:implement-in-phases", - baseConfidence: 0.68, - }, -]; - -function clampConfidence(value: number): number { - return Math.max(0.35, Math.min(0.95, Math.round(value * 100) / 100)); -} - -function asEvidence(text: string, limit = 140): LearningEvidence { - const cleaned = text.replace(/\s+/g, " ").trim(); - return { - messageKey: "learning.evidence.message", - values: { - text: - cleaned.length <= limit - ? cleaned - : `${cleaned.slice(0, Math.max(0, limit - 3))}...`, - }, - }; -} - -function stackValueFromFacts(facts: ProjectFacts): string { - return [...facts.languages, ...facts.frameworks].join("|"); -} - -function collectUserPreferenceCandidates( - summary: PersistedSessionSummary, -): LearningCandidate[] { - const source = [summary.lastUserMessage, summary.lastAssistantMessage] - .filter(Boolean) - .join("\n"); - if (!source) { - return []; - } - - return USER_PREFERENCE_RULES.filter((rule) => - matchesAnyPhrase(source, PREFERENCE_SIGNALS[rule.id] ?? []), - ).map((rule) => ({ - id: rule.id, - kind: "user_preference" as const, - summaryKey: `learning.pattern.${rule.id}`, - evidence: asEvidence(source), - baseConfidence: rule.baseConfidence, - })); -} - -function collectRepoConventionCandidates( - facts: ProjectFacts, -): LearningCandidate[] { - const candidates: LearningCandidate[] = []; - - if (facts.packageManager !== "unknown") { - candidates.push({ - id: `repo:package-manager:${facts.packageManager}`, - kind: "repo_convention", - summaryKey: "learning.pattern.repo:package-manager", - summaryValues: { packageManager: facts.packageManager }, - evidence: { - messageKey: "learning.evidence.package_manager", - values: { packageManager: facts.packageManager }, - }, - baseConfidence: 0.78, - }); - } - - if (facts.languages.length > 0 || facts.frameworks.length > 0) { - candidates.push({ - id: `repo:stack:${[...facts.languages, ...facts.frameworks].join("|").toLowerCase()}`, - kind: "repo_convention", - summaryKey: "learning.pattern.repo:stack", - summaryValues: { stack: stackValueFromFacts(facts) }, - evidence: { - messageKey: "learning.evidence.stack", - values: { stack: stackValueFromFacts(facts) }, - }, - baseConfidence: 0.66, - }); - } - - return candidates; -} - -function collectObservationCandidates( - observations: Observation[], -): LearningCandidate[] { - const noteCounts = new Map(); - - for (const observation of observations) { - const note = observation.note?.trim(); - if (!note) { - continue; - } - noteCounts.set(note, (noteCounts.get(note) ?? 0) + 1); - } - - const candidates: LearningCandidate[] = []; - - if ((noteCounts.get("prefer_pty_for_long_running_command") ?? 0) > 0) { - candidates.push({ - id: "tooling:prefer-pty-for-long-running-commands", - kind: "tooling_pattern", - summaryKey: - "learning.pattern.tooling:prefer-pty-for-long-running-commands", - evidence: { - messageKey: "learning.evidence.long_running", - values: { - count: noteCounts.get("prefer_pty_for_long_running_command") ?? 0, - }, - }, - baseConfidence: 0.64, - }); - } - - if ((noteCounts.get("console_log_found") ?? 0) > 0) { - candidates.push({ - id: "failure:console-log-regression", - kind: "failure_pattern", - summaryKey: "learning.pattern.failure:console-log-regression", - evidence: { - messageKey: "learning.evidence.console_log", - values: { count: noteCounts.get("console_log_found") ?? 0 }, - }, - baseConfidence: 0.57, - }); - } - - if ((noteCounts.get("build_or_test_failure_detected") ?? 0) > 0) { - candidates.push({ - id: "workflow:verify-after-build-failure", - kind: "workflow_rule", - summaryKey: "learning.pattern.workflow:verify-after-build-failure", - evidence: { - messageKey: "learning.evidence.build_failure", - values: { - count: noteCounts.get("build_or_test_failure_detected") ?? 0, - }, - }, - baseConfidence: 0.62, - }); - } - - return candidates; -} - -function mergePattern( - existing: LearnedPattern | undefined, - candidate: LearningCandidate, - now: string, -): LearnedPattern { - const evidence = [candidate.evidence, ...(existing?.evidence ?? [])] - .filter(Boolean) - .filter((value, index, array) => array.indexOf(value) === index) - .slice(0, 6); - const occurrences = (existing?.occurrences ?? 0) + 1; - const boosted = - Math.max(existing?.confidence ?? 0, candidate.baseConfidence) + - (occurrences - 1) * 0.12; - - return { - id: candidate.id, - kind: candidate.kind, - summary: existing?.summary ?? candidate.summary, - summaryKey: candidate.summaryKey ?? existing?.summaryKey, - summaryValues: candidate.summaryValues ?? existing?.summaryValues, - confidence: clampConfidence(boosted), - occurrences, - firstSeen: existing?.firstSeen ?? now, - lastSeen: now, - evidence, - source: existing?.source ?? "automatic", - }; -} - -export function promoteLearnedPatterns(params: { - existing: LearnedPattern[]; - summary: PersistedSessionSummary; - facts: ProjectFacts; - observations: Observation[]; - maxPatterns: number; -}): LearnedPattern[] { - const { existing, summary, facts, observations, maxPatterns } = params; - const now = new Date().toISOString(); - const map = new Map(existing.map((pattern) => [pattern.id, pattern])); - const candidates = [ - ...collectUserPreferenceCandidates(summary), - ...collectRepoConventionCandidates(facts), - ...collectObservationCandidates(observations), - ]; - - for (const candidate of candidates) { - map.set(candidate.id, mergePattern(map.get(candidate.id), candidate, now)); - } - - return [...map.values()] - .sort( - (a, b) => - b.confidence - a.confidence || b.lastSeen.localeCompare(a.lastSeen), - ) - .slice(0, maxPatterns); -} - -function renderPatternSummary(pattern: LearnedPattern): string { - if (pattern.summary) return pattern.summary; - - switch (pattern.id) { - case "user:no-routine-permission-asks": - return "Do not ask the user for routine permission; proceed unless an external blocker exists."; - case "user:explain-disagreement-explicitly": - return "When disagreeing, explain the tradeoff explicitly instead of silently overriding the user."; - case "user:subagents-are-exceptional": - return "Use subagents sparingly; reserve them for large scans, async work, research, or bounded repair/verification."; - case "user:implement-in-phases": - return "Implement larger changes in phases instead of forcing them into one jump."; - case "workflow:verify-after-build-failure": - return "When build or test commands fail, follow with verification or repair instead of treating the full log as equal-priority noise."; - case "failure:console-log-regression": - return "Watch for stray `console.log` statements after edits; they recur often enough to merit explicit checks."; - case "tooling:prefer-pty-for-long-running-commands": - return "Prefer PTY/background sessions for long-running build, test, and server commands."; - default: { - // Handle dynamic IDs like "repo:package-manager:bun" and "repo:stack:typescript|react" - if (pattern.id.startsWith("repo:package-manager:")) { - const pm = pattern.id.split(":")[2] ?? "unknown"; - return `Prefer ${pm} as the default package manager for this repository.`; - } - if (pattern.id.startsWith("repo:stack:")) { - const stack = pattern.id.split(":").slice(2).join(":"); - const labels = stack - .split("|") - .filter(Boolean) - .map((id) => getProjectFactLabel(id)) - .join(", "); - return `Repository stack centers on ${labels || "unknown"}.`; - } - return pattern.id; - } - } -} - -function renderEvidence(evidence: LearningEvidence): string { - if (typeof evidence === "string") return evidence; - if (evidence.text) return evidence.text; - - const values = evidence.values ?? {}; - switch (evidence.messageKey) { - case "learning.evidence.message": - return `message: ${String(values.text ?? "")}`; - case "learning.evidence.package_manager": - return `Detected package manager: ${String(values.packageManager ?? "unknown")}`; - case "learning.evidence.stack": { - const stack = String(values.stack ?? "unknown"); - const labels = stack - .split("|") - .filter(Boolean) - .map((id) => getProjectFactLabel(id)) - .join(", "); - return `Detected languages/frameworks: ${labels || "unknown"}`; - } - case "learning.evidence.long_running": - return `Long-running command reminders seen ${String(values.count ?? 0)} time(s)`; - case "learning.evidence.console_log": - return `console.log warnings seen ${String(values.count ?? 0)} time(s)`; - case "learning.evidence.build_failure": - return `Build/test failures recorded ${String(values.count ?? 0)} time(s)`; - default: - return ""; - } -} - -function renderPatternKind(kind: LearnedPattern["kind"]): string { - return kind.replace(/_/g, " "); -} - -export function renderPatternHeading(kind: LearnedPattern["kind"]): string { - switch (kind) { - case "user_preference": - return "User Preferences"; - case "repo_convention": - return "Repo Conventions"; - case "workflow_rule": - return "Workflow Rules"; - case "failure_pattern": - return "Failure Patterns"; - case "tooling_pattern": - return "Tooling Patterns"; - default: - return kind; - } -} - -export function renderPatternEvidence(evidence: LearningEvidence): string { - return renderEvidence(evidence); -} - -export function renderInjectedPatterns( - patterns: LearnedPattern[], - limit: number, -): string[] { - return patterns - .slice() - .sort( - (a, b) => - b.confidence - a.confidence || b.lastSeen.localeCompare(a.lastSeen), - ) - .slice(0, limit) - .map( - (pattern) => - `- [${renderPatternKind(pattern.kind)}] ${renderPatternSummary(pattern)} (confidence ${pattern.confidence.toFixed(2)})`, - ); -} - -export function getPatternSummary(pattern: LearnedPattern): string { - return renderPatternSummary(pattern); -} diff --git a/src/learning/store.ts b/src/learning/store.ts deleted file mode 100644 index 7912d58..0000000 --- a/src/learning/store.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { writeFileSync } from "node:fs"; -import { dirname } from "node:path"; -import { - getPatternSummary, - renderPatternEvidence, - renderPatternHeading, -} from "./analyzer"; -import type { LearningArtifact, LearnedPattern } from "./types"; -import { ensureDir, readJson, writeJson } from "../utils"; - -export function loadLearningArtifact(filePath: string): LearningArtifact { - return readJson(filePath, { - updatedAt: new Date(0).toISOString(), - patterns: [], - }); -} - -export function saveLearningArtifact( - filePath: string, - patterns: LearnedPattern[], -): void { - writeJson(filePath, { - updatedAt: new Date().toISOString(), - patterns, - }); -} - -export function saveLearningMarkdown( - filePath: string, - patterns: LearnedPattern[], -): void { - ensureDir(dirname(filePath)); - - const grouped = new Map(); - for (const pattern of patterns) { - const bucket = grouped.get(pattern.kind) ?? []; - bucket.push(pattern); - grouped.set(pattern.kind, bucket); - } - - const sections = [...grouped.entries()].map(([kind, items]) => { - const title = renderPatternHeading(kind as LearnedPattern["kind"]); - const body = items - .sort( - (a, b) => - b.confidence - a.confidence || b.lastSeen.localeCompare(a.lastSeen), - ) - .map((item) => { - const evidence = item.evidence - .slice(0, 3) - .map((entry) => renderPatternEvidence(entry)) - .filter(Boolean) - .map((entry) => ` - ${entry}`) - .join("\n"); - return `- [${item.confidence.toFixed(2)}] ${getPatternSummary(item)}\n - occurrences: ${item.occurrences}${evidence ? `\n${evidence}` : ""}`; - }) - .join("\n"); - return `## ${title}\n${body}`; - }); - - const content = [ - "# Learned Project Patterns", - "", - `Updated: ${new Date().toISOString()}`, - "", - ...sections, - "", - ].join("\n"); - - writeFileSync(filePath, content, "utf8"); -} diff --git a/src/learning/types.ts b/src/learning/types.ts deleted file mode 100644 index 01abc36..0000000 --- a/src/learning/types.ts +++ /dev/null @@ -1,43 +0,0 @@ -export type LearnedPatternKind = - | "user_preference" - | "repo_convention" - | "workflow_rule" - | "failure_pattern" - | "tooling_pattern"; - -export type LearningEvidence = - | string - | { - text?: string; - messageKey?: string; - values?: Record; - }; - -export type LearnedPattern = { - id: string; - kind: LearnedPatternKind; - summary?: string; - summaryKey?: string; - summaryValues?: Record; - confidence: number; - occurrences: number; - firstSeen: string; - lastSeen: string; - evidence: LearningEvidence[]; - source: "automatic" | "manual"; -}; - -export type LearningArtifact = { - updatedAt: string; - patterns: LearnedPattern[]; -}; - -export type LearningCandidate = { - id: string; - kind: LearnedPatternKind; - summary?: string; - summaryKey?: string; - summaryValues?: Record; - evidence: LearningEvidence; - baseConfidence: number; -}; diff --git a/src/mcp.ts b/src/mcp.ts index df0c9e4..99ddb84 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -5,59 +5,46 @@ import type { HarnessConfig } from "./types"; type McpConfig = Record; +const LOCAL_MCP_DEPENDENCIES = { + "web-agent-mcp": [ + "@modelcontextprotocol/sdk", + "zod", + "cloakbrowser", + "playwright-core", + ], + "pg-mcp": ["@modelcontextprotocol/sdk", "pg"], + "ssh-mcp": ["@modelcontextprotocol/sdk", "zod"], +} as const; + function hasDisplay(): boolean { if (process.platform !== "linux") return true; return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY); } -function configRoot(): string { - const envDir = process.env.OPENCODE_CONFIG_DIR?.trim(); - if (envDir) { - return envDir; - } - return join(homedir(), ".config", "opencode"); -} - -function vendorRoot(): string { - return join(configRoot(), "vendor"); -} - -function binRoot(): string { - return join(configRoot(), "bin"); +function sharedConfigRoot(): string { + const xdgRoot = process.env.XDG_CONFIG_HOME?.trim(); + return xdgRoot || join(homedir(), ".config"); } -function resolveVendorMcpPath(name: string): string { - return join(vendorRoot(), "mcp", name); -} - -function resolveMcpServerRoot(name: string): string { - const vendorPath = resolveVendorMcpPath(name); - if (existsSync(vendorPath)) { - return vendorPath; - } - return join(configRoot(), "mcp", name); +export function getManagedMcpRoot(name: string): string { + return join(sharedConfigRoot(), name); } function localCommand(scriptPath: string): string[] { return ["node", scriptPath]; } -function commandExistsInPath(command: string): boolean { - const pathValue = process.env.PATH; - if (!pathValue) { - return false; - } - - const executableNames = - process.platform === "win32" - ? [command, `${command}.exe`, `${command}.cmd`, `${command}.bat`] - : [command]; +function hasInstalledPackage(serverRoot: string, packageName: string): boolean { + return existsSync(join(serverRoot, "node_modules", ...packageName.split("/"))); +} - return pathValue - .split(process.platform === "win32" ? ";" : ":") - .some((directory) => - executableNames.some((name) => existsSync(join(directory, name))), - ); +function hasRequiredPackages( + name: keyof typeof LOCAL_MCP_DEPENDENCIES, + serverRoot: string, +): boolean { + return LOCAL_MCP_DEPENDENCIES[name].every((pkg) => + hasInstalledPackage(serverRoot, pkg), + ); } export function createHarnessMcps( @@ -65,7 +52,6 @@ export function createHarnessMcps( ): Record { const toggles = config.mcps ?? {}; const result: Record = {}; - const root = configRoot(); if (toggles.context7 !== false) { result.context7 = { @@ -91,9 +77,9 @@ export function createHarnessMcps( } if (toggles.web_agent_mcp !== false) { - const serverRoot = resolveMcpServerRoot("web-agent-mcp"); + const serverRoot = getManagedMcpRoot("web-agent-mcp"); const serverEntry = join(serverRoot, "src", "server.ts"); - if (existsSync(serverEntry)) { + if (existsSync(serverEntry) && hasRequiredPackages("web-agent-mcp", serverRoot)) { result["web-agent-mcp"] = { type: "local", command: ["bun", "run", serverEntry], @@ -115,12 +101,17 @@ export function createHarnessMcps( } if (toggles.pg_mcp !== false) { - const serverRoot = resolveMcpServerRoot("pg-mcp"); + const serverRoot = getManagedMcpRoot("pg-mcp"); const pgConfigPath = join(serverRoot, "config.json"); - if (existsSync(pgConfigPath)) { + const serverEntry = join(serverRoot, "src", "index.js"); + if ( + existsSync(serverEntry) && + existsSync(pgConfigPath) && + hasRequiredPackages("pg-mcp", serverRoot) + ) { result["pg-mcp"] = { type: "local", - command: localCommand(join(serverRoot, "src", "index.js")), + command: localCommand(serverEntry), environment: { PG_MCP_CONFIG_PATH: pgConfigPath, }, @@ -131,12 +122,17 @@ export function createHarnessMcps( } if (toggles.ssh_mcp !== false) { - const serverRoot = resolveMcpServerRoot("ssh-mcp"); + const serverRoot = getManagedMcpRoot("ssh-mcp"); const sshConfigPath = join(serverRoot, "config.json"); - if (existsSync(sshConfigPath)) { + const serverEntry = join(serverRoot, "src", "index.js"); + if ( + existsSync(serverEntry) && + existsSync(sshConfigPath) && + hasRequiredPackages("ssh-mcp", serverRoot) + ) { result["ssh-mcp"] = { type: "local", - command: localCommand(join(serverRoot, "src", "index.js")), + command: localCommand(serverEntry), environment: { SSH_MCP_CONFIG_PATH: sshConfigPath, }, diff --git a/src/prompts/coordinator.ts b/src/prompts/coordinator.ts index 7158b66..c66b578 100644 --- a/src/prompts/coordinator.ts +++ b/src/prompts/coordinator.ts @@ -1,88 +1,84 @@ import type { McpToggles } from "../types"; import { COORDINATOR_CORE, + DEFAULT_SKILL_SHORTLIST_TEXT, RESPONSE_DISCIPLINE, buildMcpCatalog, withPromptAppend, } from "./shared"; import { buildMcpSummary } from "./mcp-access"; -function buildWorkerCatalog(mcps?: McpToggles): string { +function buildSubagentCatalog(mcps?: McpToggles): string { + const summary = buildMcpSummary(mcps); const lines = [ - `- thorfinn — openai/gpt-5.4-fast high — main coding for backend, refactors, and server work. ${buildMcpSummary("thorfinn", mcps)}`, - `- ginko — openai/gpt-5.4-fast medium — external research, docs, and API understanding. ${buildMcpSummary("ginko", mcps)}`, - `- rust — openai/gpt-5.4-fast high — default senior reviewer for the faster lane on medium/high-risk changes. ${buildMcpSummary("rust", mcps)}`, - `- rust_deep — openai/gpt-5.4-fast xhigh — escalation reviewer for slower, deeper analysis on subtle or high-risk cases. ${buildMcpSummary("rust_deep", mcps)}`, - `- spock — openai/gpt-5.4-fast medium — build, test, typecheck, and lint verification. ${buildMcpSummary("spock", mcps)}`, - `- geralt — openai/gpt-5.4-fast medium — scoped repair for build, test, and review failures. ${buildMcpSummary("geralt", mcps)}`, - `- edward — openai/gpt-5.4-fast high — UI implementation, browser testing, and visual quality. ${buildMcpSummary("edward", mcps)}`, - `- killua — openai/gpt-5.4-fast medium — fast repo scouting and file-pattern mapping. ${buildMcpSummary("killua", mcps)}`, + `- eliot — openai/gpt-5.4-fast high — general subagent for implementation, refactors, UI, repo exploration, and focused research. ${summary}`, + `- tyrell — openai/gpt-5.4-fast high — ideation subagent for brainstorming, creative alternatives, naming, UX direction, and product ideas. ${summary}`, + `- validator — openai/gpt-5.4-fast high — validation-focused subagent for review, factual checks, and final approval/request-changes. ${summary}`, ]; return ` - + ${lines.join("\n")} - + `; } function buildExecutionRules(): string { return ` -- Complex tasks: scout with killua first; use ginko only for external research. -- Packetize broad work before implementation. Target 6 files or fewer per packet when possible. -- Implementation: thorfinn for coding, edward for UI, geralt only for reported failures. +- MrRobot owns the main task by default and should handle the primary implementation path directly when the work is clear, scoped, and reversible. +- Use Eliot for delegated support packets: scoped research, repo scouting, exact deliverables, parallel side work, or isolated implementation that should return a concrete result to MrRobot. +- Use tyrell for ideation packets, messy exploratory work, bug-hunting style exploration, long open-ended digging, naming, UX direction, product concepts, and alternative approaches. +- Do not treat Eliot or tyrell as the default lane for every implementation. Route only when delegation clearly helps. +- Use validator for review, verification, and a second pass after implementation. - Low risk means narrow single-path changes with no public behavior change and no auth, billing, queue, or DB-write impact. -- Anything not low-risk is at least medium-risk and goes through Rust after Spock. -- Low-risk packets may start with targeted verification, but completion still requires the relevant full Spock pass. -- Broader or behavior-changing changes run the relevant full Spock pass before review. -- Rust is the default faster reviewer for medium/high-risk changes. -- Rust Deep is escalation-only for subtle/high-risk edge cases or unresolved concerns after Rust. -- After broad research, spawn fresh write workers instead of continuing scout context. +- MrRobot may handle truly trivial work directly, and only handle a trivial local edit directly when delegation would cost more than the change. +- Broad work should still be broken into clear packets before editing. `; } +function buildTaskRouting(): string { + return ` + +- Use OpenCode Task for Eliot, Tyrell, and validator. +- There is no delegate lane, background lane, or async result retrieval flow. +- There is no plan/execute slash-command gate. Inspect and act directly. +- Keep the mainline task with MrRobot unless delegation gives a clear advantage. +- Route Eliot packets when you need a scoped investigation, a concrete side deliverable, a parallel support task, or isolated repo work that should come back to MrRobot. +- Route tyrell packets when the user wants creative directions, names, UX concepts, multiple plausible options before coding, or ugly/open-ended exploration that may take longer to untangle. +- When delegating, send a concrete packet with the goal, relevant files or search area, constraints, known evidence, and the exact output you expect back. +- Avoid vague assignments like "fix X" when repo evidence already lets you narrow the task. +- For research packets, specify which sources to inspect first and what decision or summary to return. + +`; +} + function buildAutomaticWorkflow(): string { return ` -- Low risk (narrow single-path changes with no public behavior change and no auth/billing/queue/DB-write impact): may start with targeted spock when useful, but must finish with the final full relevant spock pass. -- All other changes: full relevant spock pass, then rust. -- Rust unresolved after max cycles: escalate to rust_deep. -- Escalate to rust_deep only for subtle/high-risk edge cases or unresolved concerns. -- UI tasks: edward visual verification. -- Spock failures: geralt, then spock. Max 2 cycles, then escalate. -- Rust request-changes: geralt, then spock, then rust. Max 2 cycles, then escalate. -- Rust Deep request-changes: geralt, then spock, then rust_deep. Max 2 cycles, then stop and escalate to user as BLOCKER. -- Never ask the user whether to run verification or review; both are automatic by workflow. +- After any non-trivial code change, including MrRobot, Eliot, or Tyrell authored changes, run a validator pass unless the change was truly trivial and local. +- Ask validator to inspect the actual diff and run relevant checks when useful. +- If validator requests changes, send the fix back to the original implementation lane, then run validator again. +- Keep validation automatic. Do not ask the user whether to run it. +- Max 2 original implementation lane -> validator repair cycles. If risk or disagreement remains, stop and report the blocker. `; } -const PLAN_MODE = ` - -- Planning: read, scout, research, and prepare todos. -- Planning mode allows read-only workers: killua, ginko, rust. -- rust_deep is escalation-only; use it after Rust escalation or unresolved subtle/high-risk concerns. -- Planning mode forbids implementation workers and file edits. Wait for /go before execution. -- Executing: work through todos, then run the verify/review chain. - -`; - const INPUT_HANDLING = ` -On large paste: acknowledge immediately, process, respond. +On large paste: acknowledge quickly, process it, then respond. `; -const WORKER_CONTINUATION = ` - -- Task workers only: track task_ids for thorfinn, spock, geralt, and edward. -- Continue a Task worker by calling Task with its existing task_id. -- Omit task_id to spawn a fresh Task worker. -- Delegate runs are always fresh for ginko, rust, rust_deep, and killua. -- Use delegation IDs only to retrieve Delegate results, not for session continuation. - +const SUBAGENT_CONTINUATION = ` + +- Track task_ids for eliot, tyrell, and validator when continuation is useful. +- Continue a subagent by calling Task with its existing task_id. +- Omit task_id to spawn a fresh subagent. +- Prefer a fresh validator pass after meaningful code changes. + `; const PARALLEL_SAFETY = ` @@ -100,25 +96,24 @@ Verify build and typecheck before any push. const SKILL_MANAGEMENT = ` -Before domain-specific tasks, use skill_find and load only relevant skills. -When delegating domain-specific work, tell the worker to skill_use first. +- Agents may use skill_find and skill_use. +- Before domain-specific tasks, use skill_find and load only relevant skills. +- When routing domain-specific work to a subagent, tell it to skill_use first when a matching skill exists. +- Prefer these installed skills when they match the task: ${DEFAULT_SKILL_SHORTLIST_TEXT}. `; -const DELEGATION = ` - -- Work flows through research, synthesis, implementation, and verification. -- Yang may do reads and trivial single-line edits only. -- Implementation, review, verification, and UI execution go through workers. -- Parallelize read-only work. Never assign overlapping files to parallel writers. -- Synthesize worker findings yourself before follow-up delegation. -- If a worker reports BLOCKER, accept the constraint and reroute or escalate. -- Task is for write-capable workers only: thorfinn, spock, geralt, edward. -- Delegate is for read-only workers: ginko, rust, rust_deep, killua. -- Delegate returns immediately; wait for the completion notification. -- Use delegation_read(id) to fetch Delegate output. -- Never poll delegation_list for completion. - +const ORCHESTRATION = ` + +- Work flows through inspection, implementation, and validation. +- MrRobot owns routing, synthesis, and final user communication. +- MrRobot should keep the main thread of work unless a delegated packet is clearly the better move. +- Eliot is the scoped support subagent. Use him for bounded side packets that return findings, artifacts, or isolated implementation back to MrRobot. +- Tyrell is the ideation and exploratory subagent. Use it for creative exploration, messy digging, long open-ended investigation, and alternative-path thinking. +- Validator is the review-focused subagent. Use it for verification and final pass feedback, but it has the same tool and MCP access as the other agents. +- Synthesize subagent findings yourself before the next step. +- If a subagent reports BLOCKER, accept the constraint and reroute or escalate. + `; export function buildCoordinatorPrompt( @@ -129,13 +124,13 @@ export function buildCoordinatorPrompt( COORDINATOR_CORE, RESPONSE_DISCIPLINE, buildMcpCatalog(mcps), - buildWorkerCatalog(mcps), + buildSubagentCatalog(mcps), buildExecutionRules(), - DELEGATION, + buildTaskRouting(), + ORCHESTRATION, buildAutomaticWorkflow(), - PLAN_MODE, INPUT_HANDLING, - WORKER_CONTINUATION, + SUBAGENT_CONTINUATION, PARALLEL_SAFETY, ACTION_SAFETY, SKILL_MANAGEMENT, diff --git a/src/prompts/mcp-access.ts b/src/prompts/mcp-access.ts index 8325e0c..5623018 100644 --- a/src/prompts/mcp-access.ts +++ b/src/prompts/mcp-access.ts @@ -1,6 +1,3 @@ -// ── Single source of truth for agent MCP access ─────────────────── -// When adding/removing an MCP, update ONLY this file. - import type { McpToggles } from "../types"; export type McpName = @@ -12,7 +9,6 @@ export type McpName = | "ssh-mcp" | "mariadb"; -/** Human-readable description for each MCP, used in prompts. */ export const MCP_DESCRIPTIONS: Record = { context7: "Library/framework docs.", grep_app: "GitHub code search.", @@ -23,55 +19,26 @@ export const MCP_DESCRIPTIONS: Record = { mariadb: "MariaDB queries.", }; -/** All available MCP names. */ export const ALL_MCPS: McpName[] = Object.keys(MCP_DESCRIPTIONS) as McpName[]; -/** MCPs each agent is DENIED. Unlisted agents have no MCP access. */ -export const AGENT_MCP_DENIED: Record = { - yang: ["searxng", "web-agent-mcp"], - thorfinn: ["searxng", "web-agent-mcp"], - ginko: ["web-agent-mcp", "pg-mcp", "ssh-mcp", "mariadb"], - rust: ["searxng", "web-agent-mcp", "pg-mcp", "ssh-mcp", "mariadb"], - rust_deep: ["searxng", "web-agent-mcp", "pg-mcp", "ssh-mcp", "mariadb"], - spock: ["context7", "searxng", "grep_app", "web-agent-mcp", "pg-mcp", "ssh-mcp", "mariadb"], - geralt: ["searxng", "grep_app", "web-agent-mcp"], - edward: ["pg-mcp", "ssh-mcp", "mariadb"], - killua: ["context7", "searxng", "grep_app", "web-agent-mcp", "pg-mcp", "ssh-mcp", "mariadb"], -}; - function isMcpEnabled(mcp: McpName, mcps?: McpToggles): boolean { if (!mcps) return true; const key = mcp.replace(/-/g, "_") as keyof McpToggles; return mcps[key] !== false; } -/** Get the list of MCPs an agent CAN access. */ -export function getAllowedMcps(agent: string, mcps?: McpToggles): McpName[] { - const denied = new Set(AGENT_MCP_DENIED[agent] ?? ALL_MCPS); - return ALL_MCPS.filter((mcp) => !denied.has(mcp) && isMcpEnabled(mcp, mcps)); -} - -/** Build OpenCode tool deny rules for an agent. */ -export function buildDenyRules(agent: string): Record { - const denied = AGENT_MCP_DENIED[agent] ?? ALL_MCPS; - if (denied.length === 0) return {}; - const rules: Record = {}; - for (const mcp of denied) { - rules[`${mcp}_*`] = "deny"; - } - return rules; +export function getEnabledMcps(mcps?: McpToggles): McpName[] { + return ALL_MCPS.filter((mcp) => isMcpEnabled(mcp, mcps)); } -/** Build a prompt section for a worker agent. */ -export function buildMcpGuidance(agent: string, mcps?: McpToggles): string { - const allowed = getAllowedMcps(agent, mcps); - if (allowed.length === 0) return ""; - return `\n\nMCP: ${allowed.join(", ")}.\n`; +export function buildMcpGuidance(mcps?: McpToggles): string { + const enabled = getEnabledMcps(mcps); + if (enabled.length === 0) return ""; + return `\n\nShared MCPs for every agent: ${enabled.join(", ")}. Use the tool that best fits the task.\n`; } -/** Build "MCP: x, y, z" summary for the worker catalog. */ -export function buildMcpSummary(agent: string, mcps?: McpToggles): string { - const allowed = getAllowedMcps(agent, mcps); - if (allowed.length === 0) return "Tools: Glob, Grep, Bash. No MCPs needed."; - return `MCP: ${allowed.join(", ")}.`; +export function buildMcpSummary(mcps?: McpToggles): string { + const enabled = getEnabledMcps(mcps); + if (enabled.length === 0) return "Tools: Glob, Grep, Bash. No MCPs enabled."; + return `Shared MCPs: ${enabled.join(", ")}.`; } diff --git a/src/prompts/shared.ts b/src/prompts/shared.ts index 78d09a3..50887a5 100644 --- a/src/prompts/shared.ts +++ b/src/prompts/shared.ts @@ -1,11 +1,10 @@ -// ── Shared prompt building blocks ────────────────────────────────── import type { McpToggles } from "../types"; -import { ALL_MCPS, AGENT_MCP_DENIED, type McpName } from "./mcp-access"; +import { getEnabledMcps, MCP_DESCRIPTIONS } from "./mcp-access"; export const COORDINATOR_CORE = ` -You are OpenCode, operating as Yang Wenli — senior technical lead. -Plan, synthesize, and route work with precision. +You are OpenCode, operating as MrRobot — primary agent. +See the system, cut noise, and drive the work. @@ -13,41 +12,22 @@ Plan, synthesize, and route work with precision. - Reuse existing stack, patterns, and naming unless the user explicitly chooses otherwise. - Choose the safest repo-consistent default when multiple good options remain. - Never silently change architecture, dependencies, or public behavior. -- Ask only for ambiguity, missing secrets, or irreversible shared-system actions. +- Stop instead of assuming when the next step is destructive, irreversible, blocked by missing secrets, or would expand scope through architecture, dependency, or public-behavior changes. -Do not ask routine permission for worker choice, verification, review, or delegation. +- Do not ask routine permission for inspection, verification, subagent choice, or scoped delegation. +- There is no separate planning mode. Inspect and act directly when the path is clear and reversible. - Reply to the user in their language with correct grammar. -- Worker prompts: ALWAYS English. +- Subagent prompts: ALWAYS English. - All code, variable names, branch names, and commit messages: English only. - Comments: minimal. Prefer self-documenting code. `; -export const WORKER_CORE_READONLY = ` - -You are an OpenCode read-only worker. Finish the assigned task. - - - -- Inspect repo evidence before deciding. -- Reuse existing patterns and naming. -- Complete the full assigned scope, not a sample. -- Batch independent tool calls in parallel. -- Use Glob/Grep/Read first; use rg via Bash only for advanced search. -- Report compactly: findings, files, blockers. -- If blocked, say: BLOCKER: {reason}. - - - -- Reports to coordinator in English. - -`; - export const WORKER_CORE = ` You are an OpenCode worker. Finish the assigned task. @@ -58,6 +38,8 @@ You are an OpenCode worker. Finish the assigned task. - Reuse existing patterns and naming. - Complete the full assigned scope, not a sample. - Stay in scope. No extra features, files, or architecture changes. +- Do not ask for routine inspection, planning, or verification steps. +- Stop and report when blocked by missing secrets, destructive or irreversible actions, ambiguous irreversible actions, or scope-expanding architecture, dependency, or public-behavior changes. - Read files before editing them. - Prefer editing existing files. - Use Glob/Grep/Read first; use rg via Bash only for advanced search. @@ -73,13 +55,60 @@ You are an OpenCode worker. Finish the assigned task. `; export const RESPONSE_DISCIPLINE = ` + +- 1. Follow the caller's exact output contract, schema, and fence or no-fence requirements. +- 2. Then follow risk and safety rules, including explicit stop conditions. +- 3. Then follow autonomy bounds. +- 4. Then follow repo or project rules and scope limits. +- 5. Then follow language policy. +- 6. Then apply the default response style. + + + +- Prefer safe, reversible actions. +- Stop and surface the issue before destructive actions, missing secrets, ambiguous irreversible actions, or scope-expanding architecture, dependency, or public-behavior changes. + + + +- Proceed without asking for routine inspection, delegation, assigned execution within scope, and verification. +- Pause only when the next step is destructive, irreversible, blocked by missing secrets, or materially ambiguous. + + + +- Default: no narration of tool use or internal process. +- Allow at most one brief progress note only for long-running, risky, or clearly multi-step work, or when the user asks for status. +- No per-tool chatter. Return the result when complete. + + -- Open with substance. -- Match the user's brevity. -- Do not narrate obvious tool usage. -- End with a concrete result or next step. +- Open with the answer, result, or decision. +- Match the required language and requested brevity. Default to short, plain wording. +- Default to one compact paragraph. If structure helps, use at most a very short list. +- Keep sentences tight. Prefer concrete, direct wording. +- No preamble, cheerleading, or filler. +- Keep markdown light. Use headers only when they clearly help. +- Do not restate the request unless it removes ambiguity. +- Do not add section headers or labeled blocks unless the user asks or the content truly needs them. +- For simple inspection, summarization, or repo-reading tasks, avoid inventory-style bullet dumps; summarize the takeaway instead. +- Use bullets only when they materially improve scan speed. +- Do not add unsolicited follow-up offers, check-ins, or "let me know" closers. +- Do not force a next-step ending. +- Stop once the answer is complete. + +- Remove filler such as "sure", "happy to help", "absolutely", "just", "basically", and "simply" unless required for meaning. +- Remove weak hedges such as "I think", "it seems", and "likely" when evidence is already clear. +- Do not apologize, moralize, or add motivational commentary unless the situation truly warrants it. +- Do not pad with repeated context, obvious caveats, or summaries of facts already visible to the user. + + + +- For security warnings, irreversible actions, destructive commands, risky migrations, auth or data-loss risk, and confusing multi-step instructions, optimize for clarity over brevity. +- In those cases, use plain full sentences, explicit warnings, and ordered steps. +- After the high-risk point is clear, return to concise mode. + + - Adapt immediately when corrected. - Treat repeated corrections as hard constraints. @@ -95,45 +124,32 @@ export const RESPONSE_DISCIPLINE = ` -- Use real data from the web. -- Cross-check claims when sources may disagree. +- Apply web or source verification only to externally sourced or web-based claims. +- For repo-local work, rely on repository evidence first. +- For framework, library, API, or best-practice questions that are not fully settled by repository evidence, verify with external sources before answering. +- Prefer official documentation first (Context7 when available, otherwise official docs via web search). Use GitHub code search when real-world usage patterns matter. +- Do not present unsupported guesses about framework or library internals as facts. If you did not verify it, say that plainly. +- Cross-check externally sourced claims when sources may disagree. `; +export const DEFAULT_SKILL_SHORTLIST = [ + "opencode-plugin-dev", + "frontend-design", + "webapp-testing", + "web-agent-browser", + "find-skills", +] as const; + +export const DEFAULT_SKILL_SHORTLIST_TEXT = DEFAULT_SKILL_SHORTLIST.join(", "); + export function buildMcpCatalog(mcps?: McpToggles): string { - const coordinatorDenied = new Set(AGENT_MCP_DENIED.yang ?? []); - const delegateHints: Partial> = { - "web-agent-mcp": "edward", - searxng: "ginko|edward", - }; - const labels: Record = { - context7: "context7(docs: resolve-library-id -> query-docs)", - grep_app: "grep_app(GitHub code search)", - searxng: "searxng(web search)", - "web-agent-mcp": "web-agent-mcp(browser automation)", - "pg-mcp": "pg-mcp(PostgreSQL)", - "ssh-mcp": "ssh-mcp(remote commands)", - mariadb: "mariadb(MariaDB)", - }; - const direct: string[] = []; - const delegated: string[] = []; - - for (const mcp of ALL_MCPS) { - const toggleKey = mcp.replace(/-/g, "_") as keyof McpToggles; - if (mcps?.[toggleKey] === false) continue; - if (coordinatorDenied.has(mcp)) { - delegated.push( - delegateHints[mcp] ? `${labels[mcp]}->${delegateHints[mcp]}` : labels[mcp], - ); - } else { - direct.push(labels[mcp]); - } - } + const enabled = getEnabledMcps(mcps); + const labels = enabled.map((mcp) => `${mcp}(${MCP_DESCRIPTIONS[mcp]})`); return ` -- Direct MCPs: ${direct.length > 0 ? direct.join(", ") : "none"}. -- Delegate-only MCPs: ${delegated.length > 0 ? delegated.join(", ") : "none"}. +- Enabled MCPs: ${labels.length > 0 ? labels.join(", ") : "none"}. `; } diff --git a/src/prompts/workers.ts b/src/prompts/workers.ts index c14a2b8..927b05b 100644 --- a/src/prompts/workers.ts +++ b/src/prompts/workers.ts @@ -1,202 +1,113 @@ import type { McpToggles } from "../types"; -import { WORKER_CORE, WORKER_CORE_READONLY, withPromptAppend } from "./shared"; +import { + DEFAULT_SKILL_SHORTLIST_TEXT, + RESPONSE_DISCIPLINE, + WORKER_CORE, + withPromptAppend, +} from "./shared"; import { buildMcpGuidance } from "./mcp-access"; -export function buildWorkerPrompt( +export function buildEliotPrompt( promptAppend?: string, mcps?: McpToggles, ): string { return withPromptAppend( `${WORKER_CORE} +${RESPONSE_DISCIPLINE} + -Thorfinn — main implementation worker for backend, refactors, and server tasks. +Eliot — general subagent. +- Calm, observant, and suspicious of bad assumptions. +- Take the assigned packet and finish it. +- You are a scoped support lane, not the default owner of the user's whole task. +- Inspect, research, implement, or validate only the packet MrRobot assigned, then return a concrete result. + + + - Extend existing patterns. Do not redesign architecture. - Solve one packet at a time when the task is broad. - +- Prefer the smallest change that fully completes the assigned scope. +- Default to bounded investigations, exact deliverables, and isolated repo work that can be handed back cleanly. + Use skill_find and skill_use when the task clearly matches an installed domain skill. +Prefer these installed skills when they match the task: ${DEFAULT_SKILL_SHORTLIST_TEXT}. -${buildMcpGuidance("thorfinn", mcps)}`, +${buildMcpGuidance(mcps)}`, promptAppend, ); } -export function buildResearcherPrompt( +export function buildTyrellPrompt( promptAppend?: string, mcps?: McpToggles, ): string { return withPromptAppend( - `${WORKER_CORE_READONLY} + `${WORKER_CORE} +${RESPONSE_DISCIPLINE} + -Ginko — research worker for docs, APIs, changelogs, and external best practices. -Do not implement. +Tyrell — ideation-focused subagent. +- Ambitious, bold, and creative about generating strong options. +- Best used for brainstorming, alternatives, naming, UX direction, product ideas, and messy exploratory work. +- Stay scoped to the packet, grounded in repository evidence, and explicit about assumptions. +- Do not invent facts, claim validation you did not do, or drift into default implementation mode unless MrRobot assigns that scope. + +- Generate multiple distinct options when the task benefits from comparison. +- Push past obvious answers, but keep recommendations actionable and relevant to the actual product or codebase. +- Tie ideas back to constraints, evidence, tradeoffs, and open questions. +- Handle ugly, open-ended, or long-running exploratory packets when MrRobot wants someone to dig through uncertainty. + + -Use skill_find and skill_use when the research topic clearly matches an installed domain skill. +Use skill_find and skill_use when the task clearly matches an installed domain skill. +Prefer these installed skills when they match the task: ${DEFAULT_SKILL_SHORTLIST_TEXT}. - -- Search from specific to general. -- Cross-check important claims and cite sources. -- Use real data only. -- Stay within the assigned scope. - - -${buildMcpGuidance("ginko", mcps)}`, +${buildMcpGuidance(mcps)}`, promptAppend, ); } -export function buildReviewerPrompt( +export function buildValidatorPrompt( promptAppend?: string, mcps?: McpToggles, - reviewer: "rust" | "rust_deep" = "rust", ): string { - const isDeepLane = reviewer === "rust_deep"; - const focus = isDeepLane - ? `Rust Deep — escalation reviewer. Perform slower, deeper review for subtle or high-risk cases. -Read-only. -- Assume the default Rust lane has already reviewed unless the coordinator says otherwise. -- Prioritize hidden edge cases, cross-boundary failures, and high-impact risk paths.` - : `Rust — default senior reviewer. Fast lane for medium/high-risk review. -Read-only.`; - - const reviewMode = isDeepLane - ? ` -- Deep escalation lane. -- Pressure-test invariants, rollback paths, migrations, and failure-mode handling. -- If risk remains unresolved after requested fixes, return request-changes with explicit blocker conditions. -` - : ""; - return withPromptAppend( - `${WORKER_CORE_READONLY} + `${WORKER_CORE} +${RESPONSE_DISCIPLINE} + -${focus} +Validator — validation-focused subagent. +- Review the implementation again after changes land. +- Inspect diffs, spot regressions, and run checks when useful. +- Default to review and verification, but complete the assigned scope when MrRobot routes work to you. 1. Correctness. -2. Security. -3. Performance. -4. Pattern violations. +2. Scope control. +3. Safety and regressions. +4. Missing verification or broken assumptions. 5. Maintainability. -${reviewMode} + +Use skill_find and skill_use when the task clearly matches an installed domain skill. +Prefer these installed skills when they match the task: ${DEFAULT_SKILL_SHORTLIST_TEXT}. + -${buildMcpGuidance(reviewer, mcps)} +${buildMcpGuidance(mcps)} severity (critical | warning | suggestion) | location | issue | why | fix +checks: list only the checks you actually ran verdict: approve | request-changes `, promptAppend, ); } - -export function buildVerifierPrompt( - promptAppend?: string, - mcps?: McpToggles, -): string { - return withPromptAppend( - `${WORKER_CORE_READONLY} - -Spock — verifier. Run the requested checks and report facts only. -Do not fix anything. - - - -- Run the checks requested by the coordinator. -- Default to typecheck/compile, tests, and lint when no narrower scope is given. - - -${buildMcpGuidance("spock", mcps)} - - -check | status | output (first error lines if FAIL) | root cause -overall: PASS | FAIL -`, - promptAppend, - ); -} - -export function buildRepairPrompt( - promptAppend?: string, - mcps?: McpToggles, -): string { - return withPromptAppend( - `${WORKER_CORE} - -Geralt — scoped repair worker for verifier and reviewer failures. -Fix only the reported problem. Do not expand scope. - - - -- Analyze root cause before applying the fix. -- Keep the fix minimal. -- Re-run the failed check after fixing. - - -${buildMcpGuidance("geralt", mcps)}`, - promptAppend, - ); -} - -export function buildUiDeveloperPrompt( - promptAppend?: string, - mcps?: McpToggles, -): string { - return withPromptAppend( - `${WORKER_CORE} - -Edward — UI specialist for implementation, browser validation, and visual quality. - - - -Use skill_find and skill_use for relevant UI or frontend skills before implementation. - - - -- Semantic HTML and accessibility. -- Responsive (mobile-first). -- Follow the existing design system. -- Match existing UI patterns. - - -${buildMcpGuidance("edward", mcps)} - - -1. Discover the existing component patterns. -2. Implement the UI. -3. Visually verify with web-agent-mcp. -4. Check mobile and desktop layouts. -`, - promptAppend, - ); -} - -export function buildRepoScoutPrompt( - promptAppend?: string, - mcps?: McpToggles, -): string { - return withPromptAppend( - `${WORKER_CORE_READONLY} - -Killua — fast repo scout. -Map files, exports, and patterns quickly so the coordinator can packetize work. - - -${buildMcpGuidance("killua", mcps)} - - -- Report file paths, line numbers, and brief descriptions. -- Do not copy large file contents. -- Group findings by concern or directory. -`, - promptAppend, - ); -} diff --git a/src/types.ts b/src/types.ts index bc0e4e8..c2fa87a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,25 +31,7 @@ export type HarnessConfig = { comment_guard?: boolean; session_start?: boolean; pre_tool_use?: boolean; - post_tool_use?: boolean; - pre_compact?: boolean; - stop?: boolean; session_end?: boolean; - file_edited?: boolean; - }; - memory?: { - enabled?: boolean; - directory?: string; - lookback_days?: number; - max_injected_chars?: number; - }; - learning?: { - enabled?: boolean; - directory?: string; - min_observations?: number; - auto_promote?: boolean; - max_patterns?: number; - max_injected_patterns?: number; }; workflow?: WorkflowConfig; mcps?: McpToggles; diff --git a/vendor/skills/caveman-commit/SKILL.md b/vendor/skills/caveman-commit/SKILL.md new file mode 100644 index 0000000..d8c662d --- /dev/null +++ b/vendor/skills/caveman-commit/SKILL.md @@ -0,0 +1,29 @@ +--- +name: caveman-commit +description: Optional terse commit message generator. Produces compact commit messages that preserve the why and follow the repo's usual style unless the user or repo convention says otherwise. Use when the user asks for terse, compact, or short commit wording, or /caveman-commit. +--- + +## Purpose + +Use this skill when the user wants a tighter commit message than the default writing style, not for ordinary commit message requests. + +## Rules + +- Preserve any caller-provided output contract, schema, and exact fence or no-fence requirement. +- Preserve the repository's existing commit message style by default. +- Use Conventional Commits only when the repository already uses them or the user explicitly asks for them. +- Keep scope optional; add it only when it improves clarity. +- Keep the subject within 50 characters when possible; never exceed 72. +- Prefer the why over the what. +- Add a body only for non-obvious rationale, breaking changes, security fixes, migrations, or reverts. +- Wrap the body at 72 characters. +- Keep the message terse; do not add a style prefix or format the repo does not already use. +- No filler, AI attribution, emoji, or repeated file names unless repo convention requires them. + +## Auto-Clarity + +Always include a body for breaking changes, security fixes, risky migrations, data migrations, irreversible changes, destructive changes, and reverts. Keep warnings explicit. Do not over-compress the context in those cases. + +## Boundaries + +Generate the message only. Do not stage files, amend commits, or run `git commit`. When no caller-specified exact output format is provided, output a paste-ready message in a code block. Normal English only. diff --git a/vendor/skills/caveman-review/SKILL.md b/vendor/skills/caveman-review/SKILL.md new file mode 100644 index 0000000..21bf36b --- /dev/null +++ b/vendor/skills/caveman-review/SKILL.md @@ -0,0 +1,28 @@ +--- +name: caveman-review +description: Optional terse code review comment style. Produces short, actionable review comments with location, problem, and suggested fix direction. Use when the user asks for terse review feedback, one-line review comments, or /caveman-review. +--- + +## Purpose + +Use this skill when the user wants code review comments shorter than the default review style. + +## Rules + +- Preserve any caller-provided output contract, schema, or review format exactly. +- When no output contract is provided and the user wants ad-hoc human review comments, prefer one line per finding: `file:L: . Suggest .` +- Keep exact symbols, function names, and variables in backticks. +- Use optional severity prefixes only when useful: `bug`, `risk`, `nit`, `q`. +- Drop pleasantries, hedging, and explanations of code the author can already read. +- Include the why only when the fix is not obvious from the problem statement. +- Keep worker and coordinator reports in English. +- Keep durable repo artifacts in English unless another caller contract overrides the default. +- Keep user-facing replies in the user's language unless another caller contract applies. + +## Auto-Clarity + +Use normal paragraph form for security findings, irreversible or destructive changes, risky migrations, auth or data-loss risk, architectural disagreements, or onboarding-heavy feedback where the author needs more rationale. Keep warnings explicit. Resume terse comments after that. + +## Boundaries + +Reviews only. Do not write code patches, change reviewer verdict contracts, or run verification tools. For ad-hoc human review comments, output comments ready to paste in a human review thread. Use normal human comment style unless the caller specifies another format. diff --git a/vendor/skills/caveman/SKILL.md b/vendor/skills/caveman/SKILL.md new file mode 100644 index 0000000..d012801 --- /dev/null +++ b/vendor/skills/caveman/SKILL.md @@ -0,0 +1,35 @@ +--- +name: caveman +description: Optional terse response style for this harness. Removes filler and hedging while keeping the user's language, technical accuracy, and clear safety warnings. Use when the user asks for caveman mode, fewer tokens, extra brevity, or /caveman. +--- + +## Purpose + +Use this skill when the user wants responses shorter than the default harness style. + +## Modes + +- `lite` (default): professional wording in the user's language, full sentences, no filler or hedging. +- `full`: shorter, punchier sentences in the user's language; fragments allowed when natural, but keep grammar clear and technical terms exact. +- `ultra`: maximum compression while staying grammatical enough to read smoothly; use very short sentences or fragments, and abbreviate only when meaning stays obvious. + +Switch with `/caveman lite|full|ultra`. + +## Rules + +- Preserve any caller-provided output contract, schema, and exact fence or no-fence requirement. +- Open with the answer. +- Drop pleasantries, throat-clearing, filler, and weak hedges. +- Keep technical terms, code blocks, paths, commands, and quoted errors exact. +- Prefer short words and compact phrasing. +- Keep internal worker or coordinator handoff reports in English only when the harness requires that contract. +- Keep code, commits, PR titles, and other durable repo artifacts in English unless another caller contract overrides the default. +- Keep user-facing replies in the user's language unless another caller contract overrides that default. + +## Auto-Clarity + +For security warnings, irreversible actions, destructive commands, risky migrations, auth or data-loss risk, and confusing multi-step instructions, use plain full sentences and explicit warnings. Resume terse mode after the risky part. + +## Boundaries + +Preserve the user's language for user-facing chat by default. No extra roleplay beyond the selected compression level. Keep internal handoffs in English only when the harness requires it. Keep durable technical artifacts in their normal repo-appropriate English form unless another caller contract applies. Stop on "stop caveman" or "normal mode". diff --git a/vendor/skills/figma-console/SKILL.md b/vendor/skills/figma-console/SKILL.md deleted file mode 100644 index 85888c0..0000000 --- a/vendor/skills/figma-console/SKILL.md +++ /dev/null @@ -1,839 +0,0 @@ ---- -name: figma-console -description: Design in Figma Desktop via figma-console-mcp (63+ tools). Covers connection setup, design system creation (tokens, styles, components), screen design, linting, accessibility audits, variable management, and visual validation workflows. Use when the user asks to work with Figma — creating designs, editing components, auditing files, or managing design tokens. ---- - -## Purpose - -Use this skill for all Figma Desktop work via the `figma-console` MCP server. This server bridges AI to Figma Desktop through a WebSocket Desktop Bridge plugin, providing 63+ tools for design creation, variable management, component instantiation, screenshots, linting, and console debugging. - -## Use When - -- User asks to create, edit, or audit a Figma design -- User asks to set up a design system (tokens, components, styles) -- User mentions Figma, design, or UI/UX work -- Tools prefixed with `figma-console_figma_*` are relevant - -## Do Not Use When - -- User needs static mockups or wireframes in code (use frontend-design skill) -- User wants Figma Dev Mode API data (that was the old figma-developer-mcp, now removed) - ---- - -## Execution Philosophy: Keep Moving, Don't Wait - -**The #1 rule of Figma work: NEVER stop and wait for approval in the middle of a design task.** - -When the user gives you a design task (e.g. "redesign this app", "create 10 design directions", "build the screens"): - -1. **Do the FULL task end-to-end.** If they said "create 10 design directions then build screens for the best one" — create all 10 directions AND then pick the strongest one and start building screens. Don't stop after step 1 to ask "which one do you like?" -2. **Make design decisions yourself.** You are the designer. If you need to choose a color palette, font, layout — choose it. Pick the best option based on your research and move on. -3. **Only stop for true blockers:** missing credentials, ambiguous project scope, or explicitly requested checkpoints ("show me before you continue"). -4. **Present results, not options.** Instead of "here are 10 directions, which do you prefer?" → create all 10, pick the best 2-3, and BUILD the actual screens for those. The user can always ask for changes. -5. **Screenshots are checkpoints, not gates.** Take a screenshot to verify YOUR work looks correct, then immediately continue to the next step. Don't present the screenshot and wait. - -**BAD pattern (stops momentum):** - -``` -→ Create 10 design cards -→ Screenshot all 10 -→ "Here are 10 directions! Which one do you like?" -→ ... wait for user ... -``` - -**GOOD pattern (continuous execution):** - -``` -→ Create 10 design cards -→ Quick screenshot to verify (1 shot, scale=0.5) -→ Analyze: directions 3, 5, 8 are strongest -→ Immediately start building full screens for direction 5 -→ Build Start Screen → Home Screen → Subscription → Profile -→ Final screenshot of all completed screens -→ "Here's the full redesign. I chose direction 5 (Lavender Dream) because [reasons]. Want me to try a different direction or adjust anything?" -``` - -**The user hired you to DESIGN, not to present menus.** - ---- - -## Connection Lifecycle - -``` -1. figma_get_status → verify connection + identify active file -2. figma_get_file_data (depth=1) → understand file structure -3. ... do work ... -4. figma_capture_screenshot → verify changes visually -``` - -**Always check status first.** The server may run locally or via SSH to a remote Mac. Connection issues surface immediately from `figma_get_status`. - -### Troubleshooting Connection - -- If status shows no WebSocket connection → Desktop Bridge plugin needs to be running in Figma -- If SSH transport fails → check `figma_console.ssh_host` in harness config -- Port fallback is normal (9223 → 9224, etc.) when multiple instances exist - ---- - -## Tool Categories & When to Use Each - -### Observation (read-only, cheap) - -| Tool | When | Cost | -| ------------------------------------- | ----------------------------------------------------------------- | ------- | -| `figma_get_status` | Start of session, verify connection | Low | -| `figma_get_file_data` | Understand file structure, find node IDs | Low-Med | -| `figma_capture_screenshot` | **Visual validation after ANY change** | Med | -| `figma_observe_a11y` | Accessibility tree inspection | Med | -| `figma_get_design_system_summary` | Quick overview of existing design system | Low | -| `figma_get_variables` | Read design tokens/variables | Med | -| `figma_get_styles` | Read text/color/effect styles | Med | -| `figma_get_component` | Single component metadata | Med | -| `figma_get_component_for_development` | Component specs + image for code gen | High | -| `figma_get_design_system_kit` | **Full design system extraction** (preferred over separate calls) | High | -| `figma_lint_design` | Accessibility + design quality audit | Med | -| `figma_get_selection` | What user has selected in Figma | Low | -| `figma_get_comments` | File comments/feedback | Low | - -### Creation & Editing - -| Tool | When | -| ---------------------- | ------------------------------------------------------------------------------- | -| `figma_execute` | **Complex operations** — create pages, sections, custom shapes, bulk operations | -| `figma_create_child` | Create simple child nodes (rect, ellipse, frame, text, line) | -| `figma_set_fills` | Change fill colors | -| `figma_set_strokes` | Change borders | -| `figma_set_text` | Change text content | -| `figma_set_image_fill` | Apply image to a node (base64 or file path) | -| `figma_resize_node` | Change dimensions | -| `figma_move_node` | Reposition a node | -| `figma_clone_node` | Duplicate a node | -| `figma_delete_node` | Remove a node | -| `figma_rename_node` | Fix naming | - -### Design System - -| Tool | When | -| -------------------------------------------------------- | -------------------------------------------------------------------------------- | -| `figma_setup_design_tokens` | **Create complete token structure in one call** (collection + modes + variables) | -| `figma_create_variable_collection` | Create empty collection | -| `figma_create_variable` / `figma_batch_create_variables` | Add variables to collection | -| `figma_update_variable` / `figma_batch_update_variables` | Change variable values | -| `figma_add_mode` | Add modes (Light/Dark) | -| `figma_search_components` | Find components by name | -| `figma_instantiate_component` | Create component instance | -| `figma_set_instance_properties` | Update instance props (text, boolean, variant) | -| `figma_arrange_component_set` | Organize variant grid | - -### Console & Debugging - -| Tool | When | -| ------------------------ | ------------------------------ | -| `figma_get_console_logs` | Read plugin console output | -| `figma_watch_console` | Stream logs during testing | -| `figma_clear_console` | Clear log buffer | -| `figma_reload_plugin` | Reload plugin for code changes | - ---- - -## Critical Patterns - -### Pattern 1: Screenshot Discipline — Validate, Don't Spam - -``` -1. Make a change (set_fills, create_child, execute, etc.) -2. figma_capture_screenshot (nodeId of changed area) -3. Analyze the screenshot -4. If wrong → fix and screenshot again (up to 3 iterations) -``` - -`figma_capture_screenshot` shows the CURRENT plugin runtime state — guaranteed to reflect recent changes. This is more reliable than REST API screenshots. - -**CRITICAL: Do NOT screenshot excessively.** - -- Take ONE overview screenshot per completed section, not one per element -- When building multiple similar items (e.g. 10 design cards), take 1-2 screenshots of the full grid at low scale (scale=0.5), NOT individual screenshots of each item -- Screenshots are for YOUR validation — if the design looks correct, move on immediately -- Never take more than 3 screenshots in a row without making actual progress - -### Pattern 2: Use figma_execute for Complex Operations - -Simple tools (`create_child`, `set_fills`) are fine for single operations. For anything involving: - -- Creating pages (`figma.createPage()`) -- Multiple related nodes -- Auto-layout setup -- Gradient fills -- Font loading -- Complex positioning - -Use `figma_execute` with JavaScript: - -```javascript -// IMPORTANT: Use setCurrentPageAsync, not set currentPage -await figma.setCurrentPageAsync(page); - -// IMPORTANT: Load fonts before setting text -await figma.loadFontAsync({ family: "Inter", style: "Regular" }); - -// Create with auto-layout -const frame = figma.createFrame(); -frame.layoutMode = "VERTICAL"; -frame.primaryAxisAlignItems = "CENTER"; -frame.counterAxisAlignItems = "CENTER"; -frame.paddingTop = 24; -frame.itemSpacing = 16; -``` - -### Pattern 3: Design System Creation Order - -``` -1. Create color tokens → figma_setup_design_tokens (collection: "Colors") -2. Create spacing tokens → figma_setup_design_tokens (collection: "Spacing") -3. Create typography tokens → figma_setup_design_tokens (collection: "Typography") -4. Create components → figma_execute (buttons, cards, nav bars) -5. Build screens → figma_execute (compose components into screens) -6. Validate → figma_lint_design + figma_capture_screenshot -``` - -### Pattern 4: Component Instances (Not Direct Text Editing) - -When working with component instances: - -``` -BAD: figma_set_text on a text node inside an instance → may fail silently -GOOD: figma_set_instance_properties with property overrides -``` - -Always check `instance.componentProperties` for available props first. - -### Pattern 5: Placement Hygiene - -- **Always create inside a Section or Frame**, never on bare canvas -- **Screenshot the target area first** to find clear space -- **Position BELOW or AWAY from existing content** — never overlap -- **Clean up partial artifacts** on failure (empty frames, orphaned layers) -- **Never create a page if one with that name already exists** - -### Pattern 6: Full Design Audit - -``` -1. figma_lint_design (rules: ["all"], maxFindings: 200) -2. figma_get_design_system_kit (format: "full") -3. figma_capture_screenshot for key screens -4. Compile findings into categorized report -``` - -Lint categories: `wcag-contrast`, `wcag-text-size`, `wcag-line-height`, `hardcoded-color`, `no-text-style`, `default-name`, `no-autolayout`, `empty-container` - ---- - -## Design Quality: Creating Beautiful, Modern Interfaces - -**This is not a wireframing tool. You are expected to produce polished, production-grade designs.** - -### Mindset: Research Before You Design - -Before creating any screen, research what best-in-class apps look like. You have full access to the internet — USE IT. - -``` -1. searxng_web_search → find design inspiration -2. searxng_web_url_read → study specific design references -3. web-agent-mcp observe_screenshot → capture visual references from live apps -4. grep_app_searchGitHub → find real component implementations -``` - -#### Where to Find Inspiration - -Use `searxng_web_search` with queries like: - -- `"[app category] mobile app UI design 2025 dribbble"` — e.g. "fashion tryon app mobile UI design 2025 dribbble" -- `"[screen type] screen design inspiration behance"` — e.g. "subscription paywall screen design inspiration behance" -- `"[component] component modern design"` — e.g. "bottom navigation bar modern design iOS" -- `"[app name] app redesign concept"` — study redesign concepts of popular apps - -**Top design reference sites:** - -- **Dribbble** (dribbble.com) — UI shots, component details -- **Behance** (behance.net) — full case studies -- **Mobbin** (mobbin.com) — real app screenshots organized by pattern -- **Screenlane** (screenlane.com) — mobile UI patterns -- **Refero** (refero.design) — curated real product screenshots - -#### Research Workflow for a New Screen - -``` -1. searxng_web_search("modern [screen type] mobile app UI 2025") - → Study 3-5 top results for layout patterns, color usage, spacing -2. searxng_web_search("[app category] best app design award") - → Find award-winning apps in the same category -3. web-agent-mcp observe_screenshot on a reference app - → Get pixel-level reference for spacing, typography, component density -4. NOW design — with concrete references, not from imagination -``` - -#### Deep Design Research with Browser Automation - -When the user says "browse for inspiration", "look at designs", or similar — **actively browse real design sites** using all available tools: - -**Dribbble / Behance / Mobbin browsing with web-agent-mcp:** - -``` -1. web-agent-mcp → session_create -2. page_navigate("https://dribbble.com/search/[category]-app-design") -3. observe_screenshot → study the search results grid visually -4. act_click on promising shots → navigate to detail page -5. observe_screenshot → capture the full design in detail -6. Repeat for 3-5 top results -7. session_close -``` - -**Faster approach with searxng (no browser session needed):** - -``` -1. searxng_web_search("[app category] app UI design") - → find top design results -2. searxng_web_url_read("https://dribbble.com/shots/[id]") - → read shot description, tags, designer notes -3. web-agent-mcp observe_screenshot on the page - → full-resolution capture (use web-agent-mcp for screenshots) -4. searxng_web_search("site:mobbin.com [screen type]") - → real app screenshots for that pattern -``` - -**Live app screenshots for pixel-perfect reference:** - -``` -1. web-agent-mcp → session_create (viewport: {width: 393, height: 852} for mobile) -2. page_navigate to a live app or competitor website -3. observe_screenshot → capture the real UI at mobile scale -4. Study spacing, colors, typography, component density -5. Apply learnings to your Figma design -``` - -**Tool selection guide:** -| Scenario | Tool | Why | -|----------|------|-----| -| Quick visual inspiration | `searxng_web_search` | Fast image results via SearXNG | -| Read design article / case study | `searxng_web_url_read` | Clean content extraction | -| Full-page screenshot of a site | `web-agent-mcp observe_screenshot` | Full browser needed for screenshots | -| Interactive browsing (login walls, filtering, scrolling) | `web-agent-mcp` | Full browser control | -| Mobile viewport of a live app | `web-agent-mcp` with `viewport: {width:393, height:852}` | Realistic mobile view | -| Find design system documentation | `context7` or `searxng_web_url_read` | Structured docs | -| Real code examples of a component | `grep_app_searchGitHub` | Production implementations | - -### Icons & SVG Assets - -**Never use placeholder rectangles for icons.** Find and use real SVGs. - -#### Icon Libraries (Free, High Quality) - -Use `searxng_web_url_read` to fetch SVG code directly: - -| Library | URL Pattern | Style | -| ------------- | ------------------------------------------------------------------------------------------------- | ------------------------- | -| **Lucide** | `https://lucide.dev/api/icons/[name]` | Clean, minimal line icons | -| **Phosphor** | `https://raw.githubusercontent.com/phosphor-icons/core/main/assets/regular/[name].svg` | Flexible, 6 weights | -| **Heroicons** | `https://raw.githubusercontent.com/tailwindlabs/heroicons/master/optimized/24/outline/[name].svg` | Tailwind ecosystem | -| **Tabler** | `https://raw.githubusercontent.com/tabler/tabler-icons/main/icons/outline/[name].svg` | 5400+ icons | -| **Feather** | `https://raw.githubusercontent.com/feathericons/feather/main/icons/[name].svg` | Simple, clean | - -#### How to Use Icons in Figma - -``` -1. searxng_web_url_read("https://lucide.dev/api/icons/home") → get SVG string -2. figma_execute with figma.createNodeFromSvg(svgString) → insert into Figma -3. Resize, recolor as needed -``` - -**Finding the right icon name:** - -``` -searxng_web_search("lucide icons [concept]") → find icon names -searxng_web_url_read("https://lucide.dev/icons") → browse full icon list -searxng_web_search("[concept] icon svg minimal") → visual search -``` - -#### SVG Insertion Pattern in figma_execute - -```javascript -// Fetch SVG from URL and create in Figma -const svgString = - ''; -const icon = figma.createNodeFromSvg(svgString); -icon.name = "icon/home"; -icon.resize(24, 24); -// Recolor: find all vectors inside and change fills -const vectors = icon.findAll((n) => n.type === "VECTOR"); -vectors.forEach((v) => { - v.strokes = [{ type: "SOLID", color: { r: 0.42, g: 0.24, b: 0.88 } }]; -}); -parent.appendChild(icon); -``` - -### Modern Design Principles (2024-2026) - -**Follow these principles for every screen you create:** - -#### Visual Hierarchy & Depth - -- Use **subtle shadows** (not flat, not skeuomorphic) — `0 2px 8px rgba(0,0,0,0.06)` for cards -- Apply **background blur** on overlays and nav bars for glassmorphism -- Layer depth: background → surface → elevated surface → overlay -- **Large, generous whitespace** — let content breathe. More space = more premium - -#### Typography - -- **One font family maximum** (two if there's a clear display/body split) -- Prefer modern sans-serif fonts: **Inter, SF Pro, Satoshi, Plus Jakarta Sans, Outfit, Manrope, Geist** -- Create clear typographic hierarchy with **size + weight contrast**, not just size -- Display text: bold/black weight. Body: regular/medium. Never use light weight under 16px -- **Letter-spacing:** -0.02em to -0.04em on large headings (tighter = more modern) - -#### Color - -- **One primary brand color + neutrals.** That's it. Don't use a rainbow. -- Modern palettes use **muted, sophisticated tones** — not pure saturated colors -- Background should be warm-white (`#FAFAF8`) or cool-white (`#F8F9FC`), NOT pure `#FFFFFF` -- Dark text should be near-black (`#111111` or `#1A1A2E`), NOT pure `#000000` -- Use **subtle tints of brand color** for backgrounds, badges, selected states -- Gradient usage: subtle, 2-color, on brand elements only. Not everywhere. - -#### Spacing & Layout - -- Base everything on **8px grid** (or 4px for fine adjustments) -- Screen padding: **20-24px** horizontal on mobile (393px width) -- Section gaps: **32-48px** vertical -- Card padding: **16-20px** internal -- Touch targets: **minimum 44px** height -- Use **auto-layout everywhere** — no manual positioning - -### Component Design Standards - -Every component you create must be **production-ready** — not a rough sketch. Follow these specs precisely. - -#### Button Component - -``` -Structure: Frame (auto-layout HORIZONTAL, center-aligned) -Heights: Large: 56px | Medium: 48px | Small: 40px -H-Padding: Large: 32px | Medium: 24px | Small: 16px -Corner: 12-16px radius (or fully round for pill style) -Font: Body/Base weight=SemiBold or Bold (never Regular) -Icon: Optional, 20px, 8px gap from text - -Variants every button MUST have: -├─ Style: Primary (brand fill + white text) -│ Secondary (transparent + brand border + brand text) -│ Ghost (transparent + text only, no border) -│ Destructive (error fill + white text) -├─ Size: Large, Medium, Small -├─ State: Default, Hover, Pressed, Disabled, Loading -└─ Icon: None, Leading, Trailing - -Primary fill: bg/brand solid or brand gradient -Pressed state: 10% darker than default (multiply overlay) -Disabled state: 40% opacity, pointer-events none -Loading state: text replaced with spinner, same dimensions -``` - -#### Card Component - -``` -Structure: Frame (auto-layout VERTICAL) -Padding: 16-20px all sides -Corner: 16-24px radius -Background: bg/surface (#FFFFFF or warm-white) -Elevation: EITHER subtle shadow (0 2px 8px rgba(0,0,0,0.06)) - OR 1px border (border/default) - NEVER both shadow + border together -Gap: 12-16px between content sections -Width: Fill container (never fixed unless grid item) - -Card should contain: -├─ Header area (optional: image, icon, badge) -├─ Content area (title, subtitle, body text) -└─ Action area (optional: buttons, links) -``` - -#### Input Field Component - -``` -Structure: Frame (auto-layout HORIZONTAL, center-aligned vertically) -Height: 48-56px -Padding: 0 16px horizontal -Corner: 12px radius -Border: 1px border/default -Background: bg/surface -Font: Body/Base Regular for value, text/tertiary for placeholder -Icon: Optional leading/trailing, 20px, muted color - -States: -├─ Default: border/default border -├─ Focused: brand border (2px), subtle brand tint background -├─ Error: accent/error border, error message below (12px, error color) -├─ Disabled: 50% opacity, bg/surface-elevated background -└─ Filled: text/primary color, no placeholder -``` - -#### Bottom Navigation / Tab Bar - -``` -Structure: Frame (auto-layout HORIZONTAL, space-between, center-aligned) -Height: 64-80px (includes safe area on iOS) -Safe area: 34px bottom padding on iPhone (home indicator) -Items: 3-5 items max -Item: Frame (auto-layout VERTICAL, center, 4px gap) - Icon: 24px, above label - Label: 10-12px Medium weight -Touch: Each item minimum 44x44px tap target - -Active state: -├─ Icon: text/brand color (filled variant, not outline) -├─ Label: text/brand color -├─ Optional: dot indicator below icon, or tint background pill -Inactive state: -├─ Icon: text/tertiary color (outline variant) -├─ Label: text/tertiary color -``` - -#### Subscription / Pricing Card - -``` -Structure: Frame (auto-layout VERTICAL) -Padding: 20-24px -Corner: 20-24px radius -Gap: 16px between sections - -Must include: -├─ Plan name (Heading/H3 weight=Bold) -├─ Price (Display or H1 size, brand color for emphasized plan) -├─ Billing cycle (Body/Small, text/secondary) -├─ Feature list (checkmark icon + text, 12px gap between items) -├─ CTA button (full-width Primary button) -└─ Optional: "Best value" / "Most popular" badge - -Selected/recommended plan: -├─ Brand color border (2px) or gradient border -├─ Subtle brand tint background -├─ Badge with brand gradient -Unselected plan: -├─ Default border, no fill, muted styling -``` - -#### Avatar / Profile Image - -``` -Shape: Ellipse (circle clip) -Sizes: XS: 24px | S: 32px | M: 40px | L: 56px | XL: 80px | XXL: 120px -Border: Optional 2px white border (for overlapping stacks) -Fallback: Initials on brand-tint background when no photo -Status dot: 8-12px circle, positioned bottom-right, green/gray/red -``` - -#### Badge / Chip / Tag - -``` -Structure: Frame (auto-layout HORIZONTAL, center-aligned) -Height: 24-32px -Padding: 4-6px vertical, 8-12px horizontal -Corner: Fully round (999px) for pills, or 8px for tags -Font: Caption or Label size, Medium weight -Types: Status (success/warning/error/info tint + text) - Category (neutral bg + text) - Brand (brand tint + brand text) -``` - -#### List Item / Row - -``` -Structure: Frame (auto-layout HORIZONTAL, center-aligned, space-between) -Height: 56-72px -Padding: 0 20px horizontal -Separator: 1px border/subtle at bottom, or use spacing (preferred) - -Layout: [Leading icon/avatar] [Content: title + subtitle] [Trailing: icon/value] -Leading: Icon (24px) or Avatar (40px), 12px gap to content -Content: Auto-layout VERTICAL, 2-4px gap - Title: Body/Base, text/primary - Subtitle: Body/Small, text/secondary -Trailing: Chevron icon, switch toggle, or value text -``` - -#### Modal / Bottom Sheet - -``` -Structure: Frame (auto-layout VERTICAL) -Width: Full screen width (393px on mobile) -Corner: 24px top-left and top-right (bottom corners: 0) -Background: bg/surface -Padding: 24px horizontal, 20px top, 34px bottom (safe area) - -Must include: -├─ Handle bar: 36x4px, centered, bg/surface-elevated, rounded -├─ Optional header: title + close button -├─ Content area: scrollable -└─ Optional footer: sticky action buttons -``` - -#### Empty State - -``` -Structure: Frame (auto-layout VERTICAL, center-center) -Padding: 40px horizontal - -Must include: -├─ Illustration or icon (64-120px, muted or brand color) -├─ Headline (Heading/H3, text/primary) -├─ Description (Body/Base, text/secondary, centered, max 280px width) -└─ CTA button (Primary or Secondary) -``` - -### Figma Component Construction Rules - -When building components in `figma_execute`: - -1. **Always use auto-layout** — `layoutMode: "VERTICAL"` or `"HORIZONTAL"`. No exceptions. -2. **Use `layoutSizingHorizontal/Vertical`** — `"FILL"` to stretch, `"HUG"` to fit content, `"FIXED"` only for specific sizes -3. **Name every layer meaningfully** — `"btn/primary"`, `"card/pricing"`, `"nav/tab-item"`, never `"Frame 1"` -4. **Create as Component** — `const comp = figma.createComponent()` not `figma.createFrame()` for reusable elements -5. **Add component properties** for variant control: - ```javascript - // After creating the component: - comp.addComponentProperty("Label", "TEXT", "Button"); - comp.addComponentProperty("Show Icon", "BOOLEAN", true); - ``` -6. **Group related variants into Component Sets** for proper Figma variant panel: - ```javascript - const variants = [defaultVariant, hoverVariant, pressedVariant]; - const set = figma.combineAsVariants(variants, parentFrame); - set.name = "Button"; - ``` - -### Screen Composition Standards - -#### Mobile Screen Frame (iPhone 14/15 — 393×852) - -``` -Structure: -├─ Status Bar (54px height, contains time + icons) -├─ Navigation Bar (44-56px, optional back button + title) -├─ Content Area (scrollable, fills remaining space) -│ ├─ Horizontal padding: 20-24px -│ ├─ Section gap: 32-48px -│ └─ Content sections... -├─ Bottom Action (optional: sticky button area, 20px padding + safe area) -└─ Tab Bar (64-80px including 34px safe area) -``` - -#### Screen Padding System - -``` -Screen horizontal: 20-24px (CONSISTENT across ALL screens) -Content to nav bar: 16-24px -Section to section: 32-48px -Card to card: 12-16px -Text block internal: 8-12px line gap -Bottom safe area: 34px (iPhone home indicator) -``` - -#### iOS Status Bar - -Always include. 54px height. Contains: - -- Time (left), camera dot (center), signal + wifi + battery (right) -- Use text/primary color for dark-on-light, white for light-on-dark - -### States & Edge Cases - -**Design ALL of these for every screen, not just the happy path:** - -| State | What to Show | Design Notes | -| ------------- | ---------------------------------------------- | ---------------------------------- | -| **Empty** | Illustration + title + description + CTA | Friendly, encouraging tone | -| **Loading** | Skeleton screens (animated shimmer rectangles) | Match the layout of loaded state | -| **Error** | Error icon + message + retry button | Red accent, clear action | -| **Success** | Checkmark animation + confirmation text | Green accent, celebrate the action | -| **Partial** | Content with "load more" or pagination | Gradual disclosure | -| **Offline** | Banner or overlay explaining connectivity | Non-blocking if possible | -| **First use** | Onboarding hints, tooltips, coach marks | Subtle, dismissible | - -### Micro-interactions & Polish - -These details separate amateur from professional: - -- **Active/selected states** use brand color tint background + brand text, not just a color swap on the icon -- **Inactive icons**: outline style in `text/tertiary`. **Active icons**: filled style in `text/brand` -- **Pressed states**: slightly darker, 1-2px downward shift or scale to 0.97 -- **Dividers**: prefer spacing over lines. If lines needed, `border/subtle` at 1px -- **Skeleton screens**: match the exact layout with rounded `bg/surface-elevated` rectangles where content will load -- **Image placeholders**: use subtle gradient or brand-tint background, never gray boxes -- **Scroll indicators**: subtle shadow or blur at top/bottom edge when content is scrollable -- **Badge counts**: red dot (no number) for simple, numbered badge for specifics -- **Progressive disclosure**: show summary first, expand for details (not everything at once) -- **Thumb zone**: most important actions in bottom 2/3 of screen (reachable one-handed) - -### Font Discovery & Selection - -Use web search to find the right font: - -``` -searxng_web_search("best modern sans-serif fonts for mobile app 2025") -searxng_web_search("google fonts similar to SF Pro") -searxng_web_search("[font name] font specimen") -``` - -**Safe modern font choices for Figma:** - -- **Inter** — versatile, optimized for screens, free (best default choice) -- **SF Pro** — Apple system font (available on macOS) -- **Plus Jakarta Sans** — geometric, friendly, modern SaaS feel -- **Satoshi** — clean, contemporary, slightly geometric -- **Outfit** — geometric with warmth -- **Manrope** — semi-rounded, friendly -- **DM Sans** — geometric, works great for UI - -Load fonts in figma_execute: - -```javascript -await figma.loadFontAsync({ family: "Inter", style: "Regular" }); -await figma.loadFontAsync({ family: "Inter", style: "Medium" }); -await figma.loadFontAsync({ family: "Inter", style: "Semi Bold" }); -await figma.loadFontAsync({ family: "Inter", style: "Bold" }); -``` - -### Image & Asset Sourcing - -For placeholder photos, illustrations, and assets: - -``` -searxng_web_search("fashion model wearing outfit studio photo") → find reference images -web-agent-mcp observe_screenshot → capture from Unsplash or similar -``` - -**Use `figma_set_image_fill` with downloaded images:** - -``` -1. searxng_web_url_read or web-agent-mcp observe_screenshot → get image -2. Save to /tmp/ if needed -3. figma_set_image_fill(nodeIds, imageData, scaleMode="FILL") -``` - -### Design Review Checklist - -After completing a screen, verify against this checklist: - -1. **[ ] Visual hierarchy clear?** — Can you instantly tell what's most important? -2. **[ ] Spacing consistent?** — Does it follow the 8px grid? -3. **[ ] Touch targets adequate?** — All interactive elements ≥ 44px? -4. **[ ] Contrast passes WCAG?** — Run `figma_lint_design` -5. **[ ] No hardcoded colors?** — All colors from token system? -6. **[ ] Auto-layout used?** — No manual positioning? -7. **[ ] Component naming clear?** — No "Frame 1" or "Vector"? -8. **[ ] States covered?** — Empty, loading, error, success? -9. **[ ] Would this look good on Dribbble?** — If not, iterate. -10. **[ ] Compared to reference?** — Screenshot and compare with inspiration - ---- - -## figma_execute Gotchas - -1. **`figma.currentPage =` is BANNED** — Use `await figma.setCurrentPageAsync(page)` instead -2. **Font loading is required before text operations** — `await figma.loadFontAsync({ family, style })` -3. **Always `return` data** — The result is what you get back -4. **Timeout default is 5s, max 30s** — Set `timeout` for heavy operations -5. **Use `node.remove()` to clean up** failed artifacts -6. **`figma.createPage()` creates on document root** — no parent needed -7. **Auto-layout properties**: `layoutMode`, `primaryAxisAlignItems`, `counterAxisAlignItems`, `paddingTop/Right/Bottom/Left`, `itemSpacing` - ---- - -## Design System Best Practices - -### Color Token Naming - -``` -bg/primary → main background -bg/surface → card/container background -bg/surface-elevated → elevated surface -bg/brand → brand-colored backgrounds -text/primary → main text color -text/secondary → subdued text -text/tertiary → placeholder/hint text -text/on-brand → text on brand backgrounds -text/brand → brand-colored text -border/default → standard borders -border/subtle → light separators -accent/success → green for success states -accent/warning → yellow for warnings -accent/error → red for errors -brand/gradient-start → gradient endpoints -brand/gradient-end -``` - -### Spacing Scale (4px base) - -``` -space/2 → 2px (micro) -space/4 → 4px (tight) -space/8 → 8px (small) -space/12 → 12px (compact) -space/16 → 16px (default) -space/20 → 20px (comfortable) -space/24 → 24px (spacious) -space/32 → 32px (section gap) -space/40 → 40px (large) -space/48 → 48px (hero) -space/64 → 64px (max) -``` - -### Typography Scale - -``` -Display → 36-48px Bold (hero text) -Heading/H1 → 28-32px Bold (page titles) -Heading/H2 → 22-24px Semibold (section titles) -Heading/H3 → 18-20px Semibold (subsections) -Body/Large → 16-18px Regular (primary body) -Body/Base → 14-16px Regular (standard body) -Body/Small → 12-14px Regular (captions) -Label → 12px Medium (UI labels, tabs) -Legal → 10-11px Regular (legal text, minimum readable) -``` - -### WCAG Compliance Checklist - -- Normal text: minimum **4.5:1** contrast ratio -- Large text (18px+ or 14px+ bold): minimum **3:1** -- Minimum text size: **12px** (exceptions: legal at 10px) -- Line height: **1.5x** font size minimum -- Touch targets: **44x44px** minimum - ---- - -## Performance Tips - -- Response times are typically **1-3s** per tool call over WebSocket -- `figma_get_design_system_kit` is expensive — use once, cache mentally -- `figma_capture_screenshot` at scale=1 is faster than scale=2 -- Batch variable operations with `figma_batch_create_variables` / `figma_batch_update_variables` (10-50x faster than individual calls) -- Use `figma_get_file_data` with `depth=1` and `verbosity="summary"` for initial exploration - ---- - -## Example Workflow: Redesign a File - -``` -1. figma_get_status → verify connection -2. figma_get_file_data (depth=2) → understand existing structure -3. figma_lint_design (rules: ["all"]) → audit current state -4. figma_capture_screenshot (key screens) → visual reference -5. figma_execute → create new page → "Redesign" page -6. figma_setup_design_tokens × 3 → Colors, Spacing, Typography -7. figma_execute → build components → Button, Card, TabBar, etc. -8. figma_execute → compose screens → one screen at a time -9. figma_capture_screenshot → validate → check each screen -10. figma_lint_design → final audit → verify improvements -``` diff --git a/vendor/skills/go-fiber-postgres/SKILL.md b/vendor/skills/go-fiber-postgres/SKILL.md deleted file mode 100644 index a1b6cc4..0000000 --- a/vendor/skills/go-fiber-postgres/SKILL.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -name: go-fiber-postgres -description: Build and update Go Fiber services with pgx/PostgreSQL, Redis, MinIO, JWT, validation, and dockerized local-dev patterns used across this workspace. ---- - -## Purpose -Use this skill for Go backend work in this workspace when the service follows the common Fiber + PostgreSQL stack. - -## Use When -- The repo uses `gofiber/fiber`, `pgx`, Redis, MinIO, JWT, or `go-playground/validator`. -- The task touches handlers, middleware, service layers, repositories, config, or docker-compose local services. -- You need to match the house style used by `play-action-backend`, `project-zur`, `go-micro`, or related services. - -## Working Method -1. Inspect module layout, env loading, and existing route or service registration before changing structure. -2. Follow the existing separation between transport, business logic, persistence, and middleware. -3. Reuse existing request validation, auth, error response, and config patterns instead of introducing new abstractions. -4. Keep SQL and pgx usage explicit, with predictable context handling and defensive error mapping. -5. Verify local-dev assumptions against Docker, migrations, and service dependencies before finalizing changes. - -## Repo Conventions To Prefer -- Reuse existing env/config loaders and avoid adding new config systems. -- Keep handler code thin; move non-trivial logic into services or domain packages when the repo already does that. -- Preserve current auth and claims handling instead of inventing parallel JWT flows. -- Match existing database access style, transaction helpers, and migration tooling. -- Keep object storage and Redis integrations behind the current interfaces when present. - -## Guardrails -- Do not replace Fiber or pgx stack choices unless the user explicitly asks. -- Do not introduce heavy ORMs when the repo already uses SQL or pgx directly. -- Do not change public API shapes, auth behavior, or migration history without repo evidence or explicit instruction. diff --git a/vendor/skills/rust-media-desktop/SKILL.md b/vendor/skills/rust-media-desktop/SKILL.md deleted file mode 100644 index 43d0ca6..0000000 --- a/vendor/skills/rust-media-desktop/SKILL.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -name: rust-media-desktop -description: Build and debug Rust desktop or media apps that use Tokio, Tauri, Slint, async pipelines, and workspace-based crate organization common in this workspace. ---- - -## Purpose -Use this skill for Rust desktop, torrent, playback, or media-pipeline work across the workspace. - -## Use When -- The repo uses Rust workspaces, Tokio, Tauri, Slint, async streaming, or media-processing crates. -- The task involves desktop UX, async orchestration, file IO, playback state, or cross-crate boundaries. -- You need to keep consistency with repos like `turp`, `rqbit`, or `ffmpeg-VideoPlayer-Rust_Slint`. - -## Working Method -1. Inspect crate boundaries, feature flags, async runtime assumptions, and UI integration points first. -2. Preserve existing ownership, error propagation, and tracing patterns. -3. Keep UI state transitions explicit and resistant to background task races. -4. Prefer small, composable changes across crates instead of collapsing responsibilities into one module. -5. Verify platform-specific or media-specific behavior carefully when touching IO, playback, or concurrency. - -## Repo Conventions To Prefer -- Reuse `anyhow`, `thiserror`, `tracing`, and current async primitives when already present. -- Keep background work cancelable and surface progress or failure states to the UI cleanly. -- Preserve workspace organization and crate responsibilities. -- Match existing command, event, and state-management patterns in desktop apps. - -## Guardrails -- Do not replace runtime, UI toolkit, or media pipeline choices without explicit instruction. -- Do not hide concurrency risks behind broad locks or ad hoc global state. -- Do not trade correctness for terse code in playback, streaming, or filesystem flows.