Initial project scaffold with uv
This commit is contained in:
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.egg-info/
|
||||||
|
*.egg
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.eggs/
|
||||||
|
*.whl
|
||||||
|
.venv/
|
||||||
|
.ruff_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
.env
|
||||||
2
.opencode/.gitignore
vendored
Normal file
2
.opencode/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
state/
|
||||||
47
.opencode/agents/review.md
Normal file
47
.opencode/agents/review.md
Normal file
@@ -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.
|
||||||
53
.opencode/agents/subplan.md
Normal file
53
.opencode/agents/subplan.md
Normal file
@@ -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.
|
||||||
69
.opencode/agents/team-lead.md
Normal file
69
.opencode/agents/team-lead.md
Normal file
@@ -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.
|
||||||
48
.opencode/agents/verify.md
Normal file
48
.opencode/agents/verify.md
Normal file
@@ -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.
|
||||||
18
.opencode/bun.lock
Normal file
18
.opencode/bun.lock
Normal file
@@ -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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
13
.opencode/commands/team.md
Normal file
13
.opencode/commands/team.md
Normal file
@@ -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.
|
||||||
301
.opencode/lib/team-utils.js
Normal file
301
.opencode/lib/team-utils.js
Normal file
@@ -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 },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
6
.opencode/package.json
Normal file
6
.opencode/package.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@opencode-ai/plugin": "1.3.13"
|
||||||
|
}
|
||||||
|
}
|
||||||
621
.opencode/plugins/team-orchestrator.js
Normal file
621
.opencode/plugins/team-orchestrator.js
Normal file
@@ -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")
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
67
.opencode/skills/debug/SKILL.md
Normal file
67
.opencode/skills/debug/SKILL.md
Normal file
@@ -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.
|
||||||
|
|
||||||
62
.opencode/skills/simplify/SKILL.md
Normal file
62
.opencode/skills/simplify/SKILL.md
Normal file
@@ -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:
|
||||||
|
<list files from git diff>
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
|
||||||
64
.opencode/skills/verify/SKILL.md
Normal file
64
.opencode/skills/verify/SKILL.md
Normal file
@@ -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.
|
||||||
|
|
||||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
14
pyproject.toml
Normal file
14
pyproject.toml
Normal file
@@ -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"
|
||||||
2
src/rp/__init__.py
Normal file
2
src/rp/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
def hello() -> str:
|
||||||
|
return "Hello from rp!"
|
||||||
0
src/rp/py.typed
Normal file
0
src/rp/py.typed
Normal file
Reference in New Issue
Block a user