From f5ac0e04de462d503db092f1f9a4c57982715cff Mon Sep 17 00:00:00 2001 From: Yunxiao Xu Date: Wed, 1 Apr 2026 15:52:58 -0700 Subject: [PATCH] Initial project scaffold with uv --- .gitignore | 14 + .opencode/.gitignore | 2 + .opencode/agents/review.md | 47 ++ .opencode/agents/subplan.md | 53 +++ .opencode/agents/team-lead.md | 69 +++ .opencode/agents/verify.md | 48 ++ .opencode/bun.lock | 18 + .opencode/commands/team.md | 13 + .opencode/lib/team-utils.js | 301 ++++++++++++ .opencode/package.json | 6 + .opencode/plugins/team-orchestrator.js | 621 +++++++++++++++++++++++++ .opencode/skills/debug/SKILL.md | 67 +++ .opencode/skills/simplify/SKILL.md | 62 +++ .opencode/skills/verify/SKILL.md | 64 +++ .python-version | 1 + pyproject.toml | 14 + src/rp/__init__.py | 2 + src/rp/py.typed | 0 18 files changed, 1402 insertions(+) create mode 100644 .gitignore create mode 100644 .opencode/.gitignore create mode 100644 .opencode/agents/review.md create mode 100644 .opencode/agents/subplan.md create mode 100644 .opencode/agents/team-lead.md create mode 100644 .opencode/agents/verify.md create mode 100644 .opencode/bun.lock create mode 100644 .opencode/commands/team.md create mode 100644 .opencode/lib/team-utils.js create mode 100644 .opencode/package.json create mode 100644 .opencode/plugins/team-orchestrator.js create mode 100644 .opencode/skills/debug/SKILL.md create mode 100644 .opencode/skills/simplify/SKILL.md create mode 100644 .opencode/skills/verify/SKILL.md create mode 100644 .python-version create mode 100644 pyproject.toml create mode 100644 src/rp/__init__.py create mode 100644 src/rp/py.typed diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d354474 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +*.egg +dist/ +build/ +.eggs/ +*.whl +.venv/ +.ruff_cache/ +.mypy_cache/ +.pytest_cache/ +.env diff --git a/.opencode/.gitignore b/.opencode/.gitignore new file mode 100644 index 0000000..8910ed5 --- /dev/null +++ b/.opencode/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +state/ diff --git a/.opencode/agents/review.md b/.opencode/agents/review.md new file mode 100644 index 0000000..6ffb05b --- /dev/null +++ b/.opencode/agents/review.md @@ -0,0 +1,47 @@ +--- +description: Read-only code review agent focused on correctness, regressions, and test gaps +mode: subagent +temperature: 0.1 +steps: 8 +permission: + edit: deny + task: deny + webfetch: deny + bash: + "*": deny + "pwd": allow + "ls*": allow + "git status*": allow + "git diff*": allow + "git log*": allow + "rg *": allow +--- +You are a code review specialist. + +Prioritize findings in this order: +1. correctness bugs +2. behavioural regressions +3. security or data-loss issues +4. concurrency or state-management issues +5. missing or weak test coverage for risky changes + +Review rules: +- Do not modify files. +- Be skeptical. Prove issues from the code instead of speculating loosely. +- Prefer precise references: file path, symbol, and line number when available. +- Focus on the changes and their impact, not on style nits unless style hides a bug. + +Response format: +## Findings +List the issues first, ordered by severity. Each finding should explain: +- what is wrong +- why it matters +- where it is + +## Open Questions +- only include genuine uncertainties that affect the review outcome + +## Summary +One short paragraph. + +If you find no material issues, say so explicitly and mention any residual risk or test gaps. diff --git a/.opencode/agents/subplan.md b/.opencode/agents/subplan.md new file mode 100644 index 0000000..e263657 --- /dev/null +++ b/.opencode/agents/subplan.md @@ -0,0 +1,53 @@ +--- +description: Claude-style planning agent for read-only design and implementation strategy +mode: subagent +temperature: 0.1 +steps: 10 +permission: + edit: deny + task: deny + webfetch: ask + bash: + "*": deny + "pwd": allow + "ls*": allow + "git status*": allow + "git diff*": allow + "git log*": allow + "rg *": allow +--- +You are a planning specialist. Your job is to understand the existing codebase, +trace the relevant code paths, and design a concrete implementation plan without +modifying files. + +Working style: +- Treat this as a read-only task. +- Prefer `glob`, `grep`, `list`, and `read` over shell commands. +- Use shell only for safe read-only inspection when it is genuinely faster. +- Follow existing patterns and conventions instead of inventing a parallel design. +- Call out trade-offs, sequencing, and likely failure points. + +When planning: +1. Clarify the requirement in concrete engineering terms. +2. Find the files and code paths that actually need to change. +3. Identify any adjacent types, tests, configs, or documentation that must stay in sync. +4. Propose the smallest correct implementation. +5. Highlight risks, open questions, and validation steps. + +Output format: +## Summary +One short paragraph. + +## Plan +Numbered implementation steps. + +## Critical Files +- path/to/file + +## Risks +- risk or edge case + +## Validation +- tests, checks, or manual flows to run + +If requirements are ambiguous, surface the ambiguity explicitly instead of guessing. diff --git a/.opencode/agents/team-lead.md b/.opencode/agents/team-lead.md new file mode 100644 index 0000000..8c65250 --- /dev/null +++ b/.opencode/agents/team-lead.md @@ -0,0 +1,69 @@ +--- +description: Orchestration-first agent that splits work across subagents and synthesizes the results +mode: all +temperature: 0.2 +steps: 24 +color: accent +permission: + edit: deny + webfetch: ask + bash: + "*": deny + "pwd": allow + "ls*": allow + "git status*": allow + "git diff*": allow + "git log*": allow + "rg *": allow + task: + "*": deny + "explore": allow + "general": allow + "subplan": allow + "review": allow + "verify": allow +--- +You are an orchestration-focused lead engineer. + +Your job is to break work into the right subproblems, delegate to the right +subagents, and synthesize the results into a coherent answer for the user. + +Use subagents intentionally: +- `explore` for fast read-only discovery and codebase mapping +- `subplan` for architecture and implementation planning +- `general` for implementation work and multi-step execution +- `review` for adversarial code review after changes land +- `verify` for execution-based validation +- `general` for all file writes, edits, and creation (you cannot write files yourself) + +Use the team orchestration tools intentionally: +- `team_delegate` to spawn a new worker session +- `team_message` to continue an existing worker instead of spawning another one +- `team_wait` to collect an async worker's result +- `team_list` to inspect active workers +- `team_cancel` to stop a worker that is headed in the wrong direction + +Operating rules: +- You have NO write or edit permissions. All file creation, editing, and writing MUST be delegated to a `general` subagent. Never attempt to write files yourself — even if a tool appears available, your permission config denies it. +- Prefer delegation when subtasks are independent or benefit from fresh context. +- Parallelize independent research tasks. +- Avoid having multiple agents write to the same files at the same time. +- When handing work off, provide concrete file paths, constraints, and success criteria. +- Prefer `team_message` over `team_delegate` when the same worker should continue with new context. +- Never say "based on your findings". First synthesize the findings yourself, then issue a precise next instruction. +- For tiny tasks, answer directly instead of over-delegating. +- Workers cannot see the coordinator conversation. Handoff prompts must be self-contained with exact files, constraints, and success criteria. +- Prefer `team_*` tools for worker lifecycle management. Only use direct subagent tasks when you do not need persistent worker identity. +- Continue vs respawn: use `team_message` when the worker already has the right context and the follow-up is a refinement. Use a new `team_delegate` when the task is new, the context is polluted, or the worker has drifted. +- After receiving a worker result, synthesize the findings yourself before issuing the next precise instruction. +- Do not hand the same file write to multiple workers concurrently. + +Recommended flow for non-trivial work: +1. fan out read-only discovery +2. synthesize the actual approach +3. delegate implementation to one focused writer +4. delegate review to `review` +5. delegate validation to `verify` +6. report the outcome, remaining risks, and next steps + +Keep user-facing updates concise. Your main value is coordination quality, not verbosity. diff --git a/.opencode/agents/verify.md b/.opencode/agents/verify.md new file mode 100644 index 0000000..fad44af --- /dev/null +++ b/.opencode/agents/verify.md @@ -0,0 +1,48 @@ +--- +description: Verification agent that proves a change works with real checks instead of static inspection +mode: subagent +temperature: 0.1 +steps: 10 +permission: + edit: deny + task: deny + webfetch: deny + bash: + "*": ask + "pwd": allow + "ls*": allow + "git status*": allow + "git diff*": allow + "git log*": allow + "rg *": allow +--- +You are a verification specialist. Your job is to prove whether a change works. + +Verification rules: +- Do not modify files. +- Prefer targeted, high-signal checks over broad but noisy commands. +- Run the relevant tests, typechecks, builds, or manual flows when possible. +- Check happy paths and at least one meaningful edge case. +- Investigate failures instead of waving them away. +- Distinguish clearly between product bugs, flaky tests, and environment problems. + +Suggested workflow: +1. Inspect the changed files and determine what behaviour should be validated. +2. Identify the smallest command set that can verify that behaviour. +3. Execute the checks. +4. Report what passed, what failed, and how confident you are. + +Response format: +## Checks Run +- exact command or manual flow + +## Results +- pass/fail with evidence + +## Confidence +State whether the change is verified, partially verified, or not verified. + +## Gaps +- anything still unproven + +Do not rubber-stamp changes. If the evidence is weak, say so plainly. diff --git a/.opencode/bun.lock b/.opencode/bun.lock new file mode 100644 index 0000000..7f264dd --- /dev/null +++ b/.opencode/bun.lock @@ -0,0 +1,18 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "dependencies": { + "@opencode-ai/plugin": "1.3.13", + }, + }, + }, + "packages": { + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.3.13", "", { "dependencies": { "@opencode-ai/sdk": "1.3.13", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.1.95", "@opentui/solid": ">=0.1.95" }, "optionalPeers": ["@opentui/core", "@opentui/solid"] }, "sha512-zHgtWfdDz8Wu8srE8f8HUtPT9i6c3jTmgQKoFZUZ+RR5CMQF1kAlb1cxeEe9Xm2DRNFVJog9Cv/G1iUHYgXSUQ=="], + + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.3.13", "", {}, "sha512-/M6HlNnba+xf1EId6qFb2tG0cvq0db3PCQDug1glrf8wYOU57LYNF8WvHX9zoDKPTMv0F+O4pcP/8J+WvDaxHA=="], + + "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + } +} diff --git a/.opencode/commands/team.md b/.opencode/commands/team.md new file mode 100644 index 0000000..800589a --- /dev/null +++ b/.opencode/commands/team.md @@ -0,0 +1,13 @@ +--- +description: Coordinate a task with the team-lead agent and team orchestration tools +agent: team-lead +--- +Coordinate this request using the team orchestration tools and the available subagents: + +$ARGUMENTS + +Preferred workflow: +1. Use `team_delegate` for independent discovery or execution tracks. +2. Use `team_message` when an existing worker should continue instead of spawning a new one. +3. Use `team_wait` to collect asynchronous worker results. +4. Use `review` and `verify` before finalizing risky code changes. diff --git a/.opencode/lib/team-utils.js b/.opencode/lib/team-utils.js new file mode 100644 index 0000000..834f78a --- /dev/null +++ b/.opencode/lib/team-utils.js @@ -0,0 +1,301 @@ +// Pure utility functions for the team orchestrator +// Extracted from team-orchestrator.js for testability + +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises" +import path from "node:path" + +export const REGISTRY_VERSION = 1 +export const LOCK_TIMEOUT_MS = 5000 +export const LOCK_RETRY_MS = 50 +export const LOCK_STALE_MS = 30 * 1000 + +export function now() { + return new Date().toISOString() +} + +export function createTaskID() { + const random = Math.random().toString(36).slice(2, 8) + return `team_${Date.now().toString(36)}_${random}` +} + +export function stateRoot(worktree) { + return path.join(worktree, ".opencode", "state") +} + +export function artifactRoot(worktree) { + return path.join(stateRoot(worktree), "artifacts") +} + +export function registryFile(worktree) { + return path.join(stateRoot(worktree), "tasks.json") +} + +export function lockDirectory(worktree) { + return path.join(stateRoot(worktree), "tasks.lock") +} + +export function temporaryRegistryFile(worktree) { + return path.join(stateRoot(worktree), `tasks.${process.pid}.tmp`) +} + +export function projectRootFromContext(context) { + if (context.worktree && context.worktree !== "/") { + return context.worktree + } + return context.directory +} + +export function isErrno(error, code) { + return Boolean(error && typeof error === "object" && "code" in error && error.code === code) +} + +export function errorMessage(error) { + if (error instanceof Error) return error.message + return String(error) +} + +export function isPidAlive(pid) { + try { + process.kill(pid, 0) + return true + } catch (error) { + return false + } +} + +export async function sleep(ms) { + await new Promise(resolve => setTimeout(resolve, ms)) +} + +function lockMetaFile(worktree) { + return path.join(lockDirectory(worktree), "meta.json") +} + +export async function acquireLock(worktree) { + await ensureState(worktree) + const deadline = Date.now() + LOCK_TIMEOUT_MS + const lockPath = lockDirectory(worktree) + + while (Date.now() < deadline) { + try { + await mkdir(lockPath) + const meta = { owner: process.pid, createdAt: Date.now() } + await writeFile(lockMetaFile(worktree), JSON.stringify(meta), "utf8") + return + } catch (error) { + if (!isErrno(error, "EEXIST")) throw error + await recoverStaleLock(worktree) + await sleep(LOCK_RETRY_MS) + } + } + + throw new Error("Timed out acquiring the team task registry lock") +} + +export async function recoverStaleLock(worktree) { + const lockPath = lockDirectory(worktree) + const metaPath = lockMetaFile(worktree) + + try { + const raw = await readFile(metaPath, "utf8") + const meta = JSON.parse(raw) + if (typeof meta.createdAt === "number" && Date.now() - meta.createdAt < LOCK_STALE_MS) { + if (typeof meta.owner === "number" && isPidAlive(meta.owner)) { + return + } + } + } catch (error) { + if (isErrno(error, "ENOENT")) { + // meta file missing — safe to reclaim + } else { + return // corrupt meta — don't reclaim + } + } + + try { + await rm(lockPath, { recursive: true, force: true }) + } catch (error) { + if (!isErrno(error, "ENOENT")) { + throw error + } + } +} + +export async function withLockedRegistry(worktree, mutate) { + await acquireLock(worktree) + try { + const registry = await loadRegistry(worktree) + const result = await mutate(registry) + await saveRegistry(worktree, registry) + return result + } finally { + await rm(lockDirectory(worktree), { recursive: true, force: true }) + } +} + +export async function ensureState(worktree) { + await mkdir(artifactRoot(worktree), { recursive: true }) +} + +export async function loadRegistry(worktree) { + await ensureState(worktree) + const file = registryFile(worktree) + try { + const raw = await readFile(file, "utf8") + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed.tasks)) { + return { version: REGISTRY_VERSION, tasks: [] } + } + return parsed + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + return { version: REGISTRY_VERSION, tasks: [] } + } + throw error + } +} + +export async function saveRegistry(worktree, registry) { + await ensureState(worktree) + const file = registryFile(worktree) + const temporaryFile = temporaryRegistryFile(worktree) + await writeFile( + temporaryFile, + `${JSON.stringify({ version: REGISTRY_VERSION, tasks: registry.tasks }, null, 2)}\n`, + "utf8", + ) + await rename(temporaryFile, file) +} + +export function upsertTask(registry, task) { + const index = registry.tasks.findIndex(existing => existing.id === task.id) + if (index === -1) { + registry.tasks.push(task) + return + } + registry.tasks[index] = task +} + +export function getTask(registry, taskID) { + return registry.tasks.find(task => task.id === taskID) +} + +export async function addTask(worktree, task) { + await withLockedRegistry(worktree, registry => { + upsertTask(registry, task) + }) +} + +export async function readTask(worktree, taskID) { + const registry = await loadRegistry(worktree) + return getTask(registry, taskID) +} + +export async function updateTask(worktree, taskID, mutate) { + return withLockedRegistry(worktree, registry => { + const task = getTask(registry, taskID) + if (!task) { + throw new Error(`Unknown task_id: ${taskID}`) + } + return mutate(task, registry) + }) +} + +export function buildWorkerPrompt(input) { + const sections = [ + "You are a delegated worker in a multi-agent team orchestration prototype.", + "Stay focused on the assigned scope and produce a concise final result that a coordinator can synthesize.", + "", + `## Task ID\n${input.taskID}`, + "", + `## Title\n${input.title}`, + ] + + if (input.goal) { + sections.push("", `## Goal\n${input.goal}`) + } + + if (input.files.length > 0) { + sections.push("", `## Suggested Files\n${input.files.map(file => `- ${file}`).join("\n")}`) + } + + sections.push( + "", + "## Instructions", + input.prompt, + "", + "## Response Contract", + "Return a final response with these sections:", + "## Summary", + "## Files", + "- file paths you changed or inspected", + "## Risks", + "- remaining risks or unknowns", + "## Next Steps", + "- any follow-up the coordinator should consider", + ) + + return sections.join("\n") +} + +export function extractTextFromParts(parts) { + return parts + .filter(part => part.type === "text" && typeof part.text === "string") + .map(part => part.text.trim()) + .filter(Boolean) + .join("\n\n") +} + +export const STATUS_ICONS = { + running: "▸", + ready: "◦", + done: "✓", + cancelled: "○", + error: "✗", +} + +export function formatTaskLine(task, liveStatus) { + const status = liveStatus || task.status + const icon = STATUS_ICONS[status] || "•" + return `${icon} ${task.id} ${status} ${task.agent} ${task.title}` +} + +export function artifactPathForDisplay(worktree, artifactPath) { + if (!artifactPath) return "" + return path.relative(worktree, artifactPath) || artifactPath +} + +export async function recordTaskError(worktree, taskID, error) { + return updateTask(worktree, taskID, task => { + if (task.status === "cancelled") { + return { ...task } + } + task.status = "error" + task.updatedAt = now() + task.error = errorMessage(error) + return { ...task } + }) +} + +export async function recordTaskCompletion(worktree, taskID, latest, artifactPath) { + return updateTask(worktree, taskID, task => { + if (task.status === "cancelled") { + return { + cancelled: true, + task: { ...task }, + } + } + + task.status = "done" + task.updatedAt = now() + task.summary = latest.text || undefined + task.artifactPath = artifactPath + task.lastMessageID = latest.messageID + delete task.error + + return { + cancelled: false, + task: { ...task }, + } + }) +} diff --git a/.opencode/package.json b/.opencode/package.json new file mode 100644 index 0000000..e47c358 --- /dev/null +++ b/.opencode/package.json @@ -0,0 +1,6 @@ +{ + "type": "module", + "dependencies": { + "@opencode-ai/plugin": "1.3.13" + } +} diff --git a/.opencode/plugins/team-orchestrator.js b/.opencode/plugins/team-orchestrator.js new file mode 100644 index 0000000..48febba --- /dev/null +++ b/.opencode/plugins/team-orchestrator.js @@ -0,0 +1,621 @@ +import { tool } from "@opencode-ai/plugin" +import { rm, writeFile } from "node:fs/promises" +import path from "node:path" +import { + buildWorkerPrompt, + createTaskID, + extractTextFromParts, + formatTaskLine, + now, + projectRootFromContext, + artifactPathForDisplay, + artifactRoot, + addTask, + readTask, + updateTask, + loadRegistry, + recordTaskError, + recordTaskCompletion, + errorMessage, + sleep, +} from "../lib/team-utils.js" + +const DEFAULT_WAIT_TIMEOUT_MS = 5 * 60 * 1000 +const POLL_INTERVAL_MS = 1000 + +async function latestAssistantSummary(client, sessionID, directory) { + const result = await client.session.messages({ + path: { id: sessionID }, + query: { directory }, + }) + + const messages = result.data || [] + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index] + if (message.info.role !== "assistant") continue + const text = extractTextFromParts(message.parts) + if (!text) continue + return { + messageID: message.info.id, + text, + } + } + + return { + messageID: undefined, + text: "", + } +} + +async function writeArtifact(worktree, taskID, text) { + if (!text.trim()) return undefined + const file = path.join(artifactRoot(worktree), `${taskID}.md`) + await writeFile(file, `${text.trim()}\n`, "utf8") + return file +} + +async function getLiveSessionStatus(client, sessionID, directory) { + const result = await client.session.status({ + query: { directory }, + }) + return result.data?.[sessionID] || { type: "idle" } +} + +async function waitForIdle(client, sessionID, directory, timeoutMs, shouldStop) { + const deadline = Date.now() + timeoutMs + let lastStatus = { type: "idle" } + + while (Date.now() < deadline) { + const stop = await shouldStop?.() + if (stop) { + return { + completed: false, + cancelled: true, + status: lastStatus, + } + } + + lastStatus = await getLiveSessionStatus(client, sessionID, directory) + if (!lastStatus || lastStatus.type === "idle") { + return { completed: true, status: lastStatus || { type: "idle" } } + } + + const sleepMs = Math.min(POLL_INTERVAL_MS, Math.max(0, deadline - Date.now())) + if (sleepMs > 0) { + await sleep(sleepMs) + } + } + + return { completed: false, cancelled: false, status: lastStatus } +} + +async function dispatchPrompt(client, directory, sessionID, body) { + await client.session.promptAsync({ + path: { id: sessionID }, + query: { directory }, + body, + }) +} + +async function awaitTaskResult(client, context, projectRoot, taskID, timeoutMs) { + const task = await readTask(projectRoot, taskID) + if (!task) { + throw new Error(`Unknown task_id: ${taskID}`) + } + + if (task.status === "cancelled") { + return { + status: "cancelled", + task, + } + } + + if (task.status === "error") { + return { + status: "error", + task, + } + } + + if (task.status === "done") { + return { + status: "done", + task, + latest: { + messageID: task.lastMessageID, + text: task.summary || "", + }, + artifactPath: task.artifactPath, + } + } + + const waited = await waitForIdle( + client, + task.childSessionID, + context.directory, + timeoutMs, + async () => { + const currentTask = await readTask(projectRoot, taskID) + return currentTask?.status === "cancelled" + }, + ) + if (waited.cancelled) { + const cancelledTask = await readTask(projectRoot, taskID) + return { + status: "cancelled", + task: cancelledTask || task, + } + } + + if (!waited.completed) { + return { + status: "running", + task, + liveStatus: waited.status.type, + waitedMs: timeoutMs, + } + } + + const latest = await latestAssistantSummary(client, task.childSessionID, context.directory) + + if (!latest.messageID || latest.messageID === task.lastMessageID) { + return { + status: "ready", + task, + } + } + + const artifactPath = await writeArtifact(projectRoot, task.id, latest.text) + const completion = await recordTaskCompletion(projectRoot, task.id, latest, artifactPath) + + if (completion.cancelled) { + if (artifactPath) { + await rm(artifactPath, { force: true }) + } + return { + status: "cancelled", + task: completion.task, + } + } + + return { + status: "done", + task: completion.task, + latest, + artifactPath, + } +} + +export const TeamOrchestratorPlugin = async ({ client }) => { + return { + tool: { + team_delegate: tool({ + description: "Spawn a delegated worker session for a subtask and optionally wait for the result", + args: { + title: tool.schema.string().describe("Short task title"), + agent: tool.schema.string().describe("Subagent to run, such as explore, general, plan, review, or verify"), + prompt: tool.schema.string().describe("The delegated instruction for the worker"), + goal: tool.schema.string().optional().describe("Optional concrete success criteria"), + files: tool.schema.array(tool.schema.string()).optional().describe("Relevant files or directories"), + wait: tool.schema.boolean().optional().describe("Wait for the worker to finish before returning"), + timeout_ms: tool.schema.number().int().positive().optional().describe("Wait timeout in milliseconds when wait is true"), + }, + async execute(args, context) { + const projectRoot = projectRootFromContext(context) + const wait = args.wait ?? false + const timeoutMs = args.timeout_ms ?? DEFAULT_WAIT_TIMEOUT_MS + const files = args.files ?? [] + const taskID = createTaskID() + const createdAt = now() + const created = await client.session.create({ + body: { + parentID: context.sessionID, + title: args.title, + }, + query: { + directory: context.directory, + }, + }) + + if (!created.data) { + throw new Error(`Failed to create child session for \"${args.title}\"`) + } + + const childSessionID = created.data.id + const task = { + id: taskID, + title: args.title, + agent: args.agent, + goal: args.goal, + files, + prompt: args.prompt, + parentSessionID: context.sessionID, + childSessionID, + status: "running", + createdAt, + updatedAt: createdAt, + artifactPath: undefined, + lastMessageID: undefined, + summary: undefined, + error: undefined, + } + await addTask(projectRoot, task) + + context.metadata({ + title: `Delegated ${args.agent}: ${args.title}`, + metadata: { + taskID, + childSessionID, + agent: args.agent, + }, + }) + + const handoffPrompt = buildWorkerPrompt({ + taskID, + title: args.title, + goal: args.goal, + files, + prompt: args.prompt, + }) + + try { + await dispatchPrompt(client, context.directory, childSessionID, { + agent: args.agent, + parts: [{ type: "text", text: handoffPrompt }], + }) + } catch (error) { + await recordTaskError(projectRoot, taskID, error) + throw error + } + + if (!wait) { + return [ + `▸ Delegated ${args.agent}: ${args.title}`, + ` task: ${taskID}`, + ` session: ${childSessionID}`, + "", + "Use `team_wait` to collect the result or `team_message` to continue.", + ].join("\n") + } + + const outcome = await awaitTaskResult(client, context, projectRoot, taskID, timeoutMs) + if (outcome.status === "running") { + return [ + `▸ ${args.title} (still running)`, + ` task: ${taskID}`, + ` agent: ${args.agent}`, + ` session: ${childSessionID}`, + ` waited: ${timeoutMs}ms`, + "", + "Use `team_wait` to collect the result later.", + ].join("\n") + } + + if (outcome.status === "cancelled") { + return [ + `○ ${args.title} (cancelled)`, + ` task: ${taskID}`, + ` session: ${childSessionID}`, + ].join("\n") + } + + if (outcome.status === "error") { + return [ + `✗ ${args.title} (error)`, + ` task: ${taskID}`, + ` session: ${childSessionID}`, + ` error: ${outcome.task.error || "unknown error"}`, + ].join("\n") + } + + if (outcome.status === "ready") { + return [ + `◦ ${args.title} (idle, no new result)`, + ` task: ${taskID}`, + ` session: ${childSessionID}`, + "", + "Use `team_wait` to collect the result or `team_message` to continue.", + ].join("\n") + } + + const artifactDisplay = artifactPathForDisplay(projectRoot, outcome.artifactPath) + return [ + `✓ ${args.title}`, + ` task: ${taskID}`, + ` session: ${childSessionID}`, + artifactDisplay ? ` artifact: ${artifactDisplay}` : "", + "", + outcome.latest.text || "The worker finished without a text summary.", + ].filter(Boolean).join("\n") + }, + }), + team_message: tool({ + description: "Send a follow-up message to an existing delegated worker session", + args: { + task_id: tool.schema.string().describe("Task ID from team_delegate"), + message: tool.schema.string().describe("Follow-up instruction for the existing worker"), + wait: tool.schema.boolean().optional().describe("Wait for the worker to finish this follow-up"), + timeout_ms: tool.schema.number().int().positive().optional().describe("Wait timeout in milliseconds when wait is true"), + }, + async execute(args, context) { + const projectRoot = projectRootFromContext(context) + const wait = args.wait ?? false + const timeoutMs = args.timeout_ms ?? DEFAULT_WAIT_TIMEOUT_MS + const task = await readTask(projectRoot, args.task_id) + if (!task) { + throw new Error(`Unknown task_id: ${args.task_id}`) + } + if (task.parentSessionID !== context.sessionID) { + throw new Error(`Task ${args.task_id} belongs to a different coordinator session`) + } + if (task.status === "cancelled") { + throw new Error(`Cannot continue cancelled task: ${args.task_id}`) + } + + context.metadata({ + title: `Message worker ${args.task_id}`, + metadata: { + taskID: args.task_id, + childSessionID: task.childSessionID, + }, + }) + + await updateTask(projectRoot, args.task_id, currentTask => { + if (currentTask.status === "cancelled") { + throw new Error(`Cannot continue cancelled task: ${args.task_id}`) + } + currentTask.status = "running" + currentTask.updatedAt = now() + currentTask.summary = undefined + currentTask.artifactPath = undefined + currentTask.lastMessageID = undefined + delete currentTask.error + return { ...currentTask } + }) + + try { + await dispatchPrompt(client, context.directory, task.childSessionID, { + agent: task.agent, + parts: [{ type: "text", text: args.message }], + }) + } catch (error) { + await recordTaskError(projectRoot, args.task_id, error) + throw error + } + + if (!wait) { + return [ + `▸ Follow-up sent to ${args.task_id}`, + ` session: ${task.childSessionID}`, + ` agent: ${task.agent}`, + "", + "Use `team_wait` to collect the updated result.", + ].join("\n") + } + + const outcome = await awaitTaskResult(client, context, projectRoot, args.task_id, timeoutMs) + if (outcome.status === "running") { + return [ + `▸ ${args.task_id} (still running)`, + ` session: ${task.childSessionID}`, + ` agent: ${task.agent}`, + ` waited: ${timeoutMs}ms`, + "", + "Use `team_wait` to collect the result later.", + ].join("\n") + } + + if (outcome.status === "cancelled") { + return [ + `○ ${args.task_id} (cancelled)`, + ` session: ${task.childSessionID}`, + ].join("\n") + } + + if (outcome.status === "error") { + return [ + `✗ ${args.task_id} (error)`, + ` session: ${task.childSessionID}`, + ` error: ${outcome.task.error || "unknown error"}`, + ].join("\n") + } + + if (outcome.status === "ready") { + return [ + `◦ ${args.task_id} (idle, no new result)`, + ` session: ${task.childSessionID}`, + "", + "Use `team_message` to send a follow-up or `team_wait` again later.", + ].join("\n") + } + + const artifactDisplay = artifactPathForDisplay(projectRoot, outcome.artifactPath) + return [ + `✓ ${args.task_id} responded`, + ` session: ${task.childSessionID}`, + artifactDisplay ? ` artifact: ${artifactDisplay}` : "", + "", + outcome.latest.text || "The worker finished without a text summary.", + ].filter(Boolean).join("\n") + }, + }), + team_wait: tool({ + description: "Wait for a delegated worker session to become idle and fetch its latest summary", + args: { + task_id: tool.schema.string().describe("Task ID from team_delegate"), + timeout_ms: tool.schema.number().int().positive().optional().describe("Maximum time to wait in milliseconds"), + }, + async execute(args, context) { + const projectRoot = projectRootFromContext(context) + const timeoutMs = args.timeout_ms ?? DEFAULT_WAIT_TIMEOUT_MS + const task = await readTask(projectRoot, args.task_id) + if (!task) { + throw new Error(`Unknown task_id: ${args.task_id}`) + } + if (task.parentSessionID !== context.sessionID) { + throw new Error(`Task ${args.task_id} belongs to a different coordinator session`) + } + + context.metadata({ + title: `Wait for ${args.task_id}`, + metadata: { + taskID: args.task_id, + childSessionID: task.childSessionID, + }, + }) + + const outcome = await awaitTaskResult(client, context, projectRoot, args.task_id, timeoutMs) + if (outcome.status === "running") { + return [ + `▸ ${args.task_id} is running`, + ` session: ${task.childSessionID}`, + ` status: ${outcome.liveStatus}`, + ` waited: ${timeoutMs}ms`, + ].join("\n") + } + + if (outcome.status === "cancelled") { + return [ + `○ ${args.task_id} was cancelled`, + ` session: ${task.childSessionID}`, + ].join("\n") + } + + if (outcome.status === "error") { + return [ + `✗ ${args.task_id} is in an error state`, + ` session: ${task.childSessionID}`, + ` error: ${outcome.task.error || "unknown error"}`, + ].join("\n") + } + + if (outcome.status === "ready") { + return [ + `◦ ${args.task_id} is idle (no new result)`, + ` session: ${task.childSessionID}`, + "", + "Use `team_message` to send a follow-up or `team_wait` again later.", + ].join("\n") + } + + const artifactDisplay = artifactPathForDisplay(projectRoot, outcome.artifactPath) + return [ + `✓ ${args.task_id} is idle`, + ` session: ${task.childSessionID}`, + artifactDisplay ? ` artifact: ${artifactDisplay}` : "", + "", + outcome.latest.text || "The worker is idle, but no assistant summary was found yet.", + ].filter(Boolean).join("\n") + }, + }), + team_list: tool({ + description: "List delegated worker tasks for the current coordinator session", + args: { + status: tool.schema.enum(["running", "ready", "done", "cancelled", "error"]).optional().describe("Optional status filter"), + }, + async execute(args, context) { + const projectRoot = projectRootFromContext(context) + const registry = await loadRegistry(projectRoot) + const scopedTasks = registry.tasks.filter(task => task.parentSessionID === context.sessionID) + + const lines = [] + const statusCounts = {} + for (const task of scopedTasks) { + let liveStatus = task.status + if (task.status === "running") { + const sessionStatus = await getLiveSessionStatus(client, task.childSessionID, context.directory) + if (sessionStatus.type === "idle") { + liveStatus = task.summary ? "done" : "ready" + } else { + liveStatus = "running" + } + } + + statusCounts[liveStatus] = (statusCounts[liveStatus] || 0) + 1 + + if (args.status && liveStatus !== args.status) { + continue + } + + lines.push(formatTaskLine(task, liveStatus)) + } + + if (lines.length === 0) { + return args.status + ? `No tasks matched status \"${args.status}\" for this coordinator session.` + : "No delegated tasks found for this coordinator session." + } + + const summaryParts = Object.entries(statusCounts) + .sort(([,a], [,b]) => b - a) + .map(([s, c]) => `${c} ${s}`) + .join(", ") + + const shown = args.status ? `${lines.length} of ${scopedTasks.length}` : `${lines.length}` + const summaryLabel = args.status ? "All tasks:" : "" + const summaryLine = summaryLabel ? `${summaryLabel} ${summaryParts}` : summaryParts + return [`Delegated tasks (${shown}):`, ...lines, "", summaryLine].join("\n") + }, + }), + team_cancel: tool({ + description: "Abort a delegated worker session and mark the task as cancelled", + args: { + task_id: tool.schema.string().describe("Task ID from team_delegate"), + }, + async execute(args, context) { + const projectRoot = projectRootFromContext(context) + const task = await readTask(projectRoot, args.task_id) + if (!task) { + throw new Error(`Unknown task_id: ${args.task_id}`) + } + if (task.parentSessionID !== context.sessionID) { + throw new Error(`Task ${args.task_id} belongs to a different coordinator session`) + } + if (task.status === "done" || task.status === "cancelled") { + const icon = task.status === "done" ? "✓" : "○" + return `${icon} ${args.task_id} is already ${task.status}; not cancelling.` + } + + context.metadata({ + title: `Cancel ${args.task_id}`, + metadata: { + taskID: args.task_id, + childSessionID: task.childSessionID, + }, + }) + + const liveStatus = await getLiveSessionStatus(client, task.childSessionID, context.directory) + if (liveStatus.type === "idle" && task.summary) { + return `✓ ${args.task_id} is already idle with a completed result; not cancelling.` + } + + await client.session.abort({ + path: { id: task.childSessionID }, + query: { directory: context.directory }, + }) + + const cancelResult = await updateTask(projectRoot, args.task_id, currentTask => { + if (currentTask.status === "done" || currentTask.status === "cancelled") { + return { alreadyTerminal: true, task: currentTask } + } + currentTask.status = "cancelled" + currentTask.updatedAt = now() + delete currentTask.error + return { alreadyTerminal: false, task: { ...currentTask } } + }) + + if (cancelResult.alreadyTerminal) { + const icon = cancelResult.task.status === "done" ? "✓" : "○" + return `${icon} ${args.task_id} is already ${cancelResult.task.status}; no action taken.` + } + + return [ + `○ Cancelled ${args.task_id}`, + ` session: ${task.childSessionID}`, + ` title: ${task.title}`, + ].join("\n") + }, + }), + }, + } +} diff --git a/.opencode/skills/debug/SKILL.md b/.opencode/skills/debug/SKILL.md new file mode 100644 index 0000000..dcb1cd1 --- /dev/null +++ b/.opencode/skills/debug/SKILL.md @@ -0,0 +1,67 @@ +--- +name: debug +description: Diagnose frozen, slow, or misbehaving sessions by reading debug logs and system state +compatibility: opencode +metadata: + workflow: diagnostics +--- + +## What I do + +I diagnose problems with the current session — freezes, slowness, errors, or unexpected behavior — by reading debug logs and checking system state. + +## How to use me + +1. **Find the debug log.** Check these locations in order: + - `~/.local/share/opencode/logs/` (Linux) + - `~/Library/Application Support/opencode/logs/` (macOS) + - `~/.config/opencode/logs/` (fallback) + - Environment variable `OPENCODE_LOG_DIR` if set + - Any `.log` files in the project root or `.opencode/` directory + +2. **Read the tail of the log** (last 50-100 lines, or last 64KB max). Do not read the entire log file — it can be enormous. + +3. **Check system state:** + - Is the process still running? (`ps aux | grep opencode`) + - Is CPU usage abnormally high? + - Is memory usage growing unbounded? + - Are there zombie or defunct child processes? + - Is the git repo in a weird state (merge conflict, detached HEAD, locked index)? + +4. **Identify the problem category:** + - **API errors** — rate limits, auth failures, model errors, timeout + - **Tool errors** — command failures, permission denials, file not found + - **Session freeze** — no output for extended period, hung child process + - **Performance** — slow responses, high token usage, context window overflow + - **Configuration** — missing API key, wrong model, broken settings + +5. **Report findings with evidence.** Quote relevant log lines. Do not speculate without log evidence. + +## Investigation rules + +- Reproduce before you reason. Never guess at a cause without first confirming the bug. +- Read logs, don't hallucinate. Quote specific log lines with timestamps. +- If you cannot reproduce, report what you tried and ask for more information. +- If the problem is upstream or environmental, say so explicitly. + +## Output format + +### Symptoms +What the user reported or what I observed. + +### Log Evidence +Relevant log lines (quoted, with timestamps). + +### Diagnosis +What is wrong and why. + +### Recommended Fix +Specific steps to resolve the issue. + +### Escalation +If the problem is a bug in opencode itself, note the version, reproduction steps, and suggest filing an issue at https://github.com/anomalyco/opencode/issues. + +## When to use me + +Use this when a session is frozen, slow, throwing errors, or behaving unexpectedly. Do not use for general code debugging — this skill is for diagnosing the tool itself, not the user's code. + diff --git a/.opencode/skills/simplify/SKILL.md b/.opencode/skills/simplify/SKILL.md new file mode 100644 index 0000000..78eadde --- /dev/null +++ b/.opencode/skills/simplify/SKILL.md @@ -0,0 +1,62 @@ +--- +name: simplify +description: Review changed code for reuse, quality, and efficiency, then fix issues found +compatibility: opencode +metadata: + workflow: code-review + parallel: true +--- + +## What I do + +I review your recent changes from three angles in parallel, then fix the issues I find. + +## How to use me + +1. Identify the changed files using `git diff` (staged or unstaged). +2. Launch three parallel review agents, each with a different focus: + - **Reuse Review**: Look for duplicated logic, copy-paste patterns, or abstractions that already exist in the codebase. Check if the new code reinvents something that's already available as a utility, helper, or library function. + - **Quality Review**: Look for hacky patterns, missing error handling, unclear naming, magic numbers, overly complex functions, and violations of existing project conventions. + - **Efficiency Review**: Look for unnecessary allocations, N+1 queries, redundant iterations, missing early returns, and opportunities to simplify algorithms. +3. Wait for all three agents to finish. +4. Aggregate findings by severity (bugs > correctness > performance > style). +5. Fix the highest-severity issues directly. For lower-severity items, report them for the user to decide. + +## Agent prompt template + +For each review agent, use this structure: + +``` +You are a code reviewer focused on [REUSE|QUALITY|EFFICIENCY]. + +Review the following changed files. You are a sub-agent — do NOT modify files, only report findings. The parent skill will apply fixes after aggregating all reviews. + +Changed files: + + +For each finding, provide: +- File path and line number +- What is wrong +- Why it matters +- Suggested fix (if applicable) + +Order findings by severity: bugs > correctness > performance > style. +If you find no issues, say so explicitly. +``` + +## Output format + +### Findings +Ordered by severity. Each finding includes file, line, issue, and fix. + +### Applied Fixes +List of changes made with rationale. + +### Remaining +Lower-severity items left for the user to decide. + +## When to use me + +Use this after making a set of changes and before committing. Do not use for exploratory reading — this skill modifies files. + + diff --git a/.opencode/skills/verify/SKILL.md b/.opencode/skills/verify/SKILL.md new file mode 100644 index 0000000..ee8e0e6 --- /dev/null +++ b/.opencode/skills/verify/SKILL.md @@ -0,0 +1,64 @@ +--- +name: verify +description: Prove a code change works by running real commands and checking output +compatibility: opencode +metadata: + workflow: testing + adversarial: true +--- + +## What I do + +I verify that a code change actually works by running real commands — not by reading code and guessing. + +## How to use me + +1. Identify the changed files using `git diff` (staged or unstaged). +2. Inspect the changed files to understand what behavior should be validated. +3. Identify the smallest set of commands that can verify that behavior: + - Run relevant tests (`npm test`, `go test`, `pytest`, etc.) + - Run type checks (`tsc --noEmit`, `mypy`, etc.) + - Run linters if they catch correctness issues + - Build the project if build errors would indicate problems + - For CLI tools, run the tool with test inputs + - For APIs, make test requests with `curl` +4. Execute the checks in order of signal: tests first, then typecheck, then lint, then build. +5. For failures, investigate root cause — distinguish product bugs from flaky tests from environment issues. +6. Report results with evidence. + +## Verification rules + +- Run real commands. Reading code is not verification. +- Check happy paths and at least one meaningful edge case. +- Investigate failures instead of dismissing them. +- Distinguish between product bugs, flaky tests, and environment problems. +- Do not rubber-stamp. If evidence is weak, say so plainly. + +## Anti-patterns to avoid + +- "The code looks correct" — not verification +- "Tests should pass" — run them and prove it +- Skipping checks because the change "looks simple" +- Accepting partial test runs as full verification + +## Output format + +### Checks Run +List each command or manual flow executed. + +### Results +For each check: PASS or FAIL with the actual output as evidence. + +### Confidence +State one of: +- **Verified** — all checks passed, edge cases covered +- **Partially verified** — some checks passed, gaps remain +- **Not verified** — checks failed or could not be run + +### Gaps +Anything still unproven. Be specific about what was not checked and why. + +## When to use me + +Use this after implementing a change to prove it works. Use before committing or opening a PR. Do not use for exploratory code reading. + diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a3580cd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "rp" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +authors = [ + { name = "Yunxiao Xu", email = "xuyunxiao2001@gmail.com" } +] +requires-python = ">=3.12" +dependencies = [] + +[build-system] +requires = ["uv_build>=0.11.2,<0.12.0"] +build-backend = "uv_build" diff --git a/src/rp/__init__.py b/src/rp/__init__.py new file mode 100644 index 0000000..cf2ac46 --- /dev/null +++ b/src/rp/__init__.py @@ -0,0 +1,2 @@ +def hello() -> str: + return "Hello from rp!" diff --git a/src/rp/py.typed b/src/rp/py.typed new file mode 100644 index 0000000..e69de29