diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3d7309..21c832c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,7 @@ jobs: build: runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: @@ -25,11 +26,57 @@ jobs: - name: Typecheck run: bun run typecheck + - name: Unit tests (engine, event log, config injection, guards) + run: bun test + - name: Build run: bun run build - - name: Smoke test + - name: Engine end-to-end (real git worktrees, real evidence capture) + run: bun run e2e + + - name: Plugin integration (built bundle, real hook shapes) + run: bun run integration + + - name: Installer smoke test run: bun run smoke - - name: Verify templates shipped - run: bun -e "import { existsSync } from 'node:fs'; const files = ['dist/cli/templates/orchestrator.md', 'dist/cli/templates/patterns/long-proof.md', 'dist/cli/templates/commands/teamwork.md', 'dist/cli/templates/prompts/orchestrator.txt']; for (const f of files) { if (!existsSync(f)) { console.error('Missing: ' + f); process.exit(1); } }" + - name: Verify templates and engine modules shipped + shell: bash + run: | + for f in \ + dist/cli/templates/sentinel.md \ + dist/cli/templates/patterns/long-proof.md \ + dist/cli/templates/commands/teamwork.md \ + dist/cli/templates/prompts/sentinel.txt \ + dist/engine.js \ + dist/events.js \ + dist/policy.js ; do + if [ ! -f "$f" ]; then echo "Missing: $f"; exit 1; fi + done + echo "all expected build outputs present" + + pack-install: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - run: bun install --frozen-lockfile + + - name: Build and pack + run: bun run build && npm pack + + - name: Install the packed tarball into a clean project + shell: bash + run: | + mkdir -p /tmp/packtest && cd /tmp/packtest + npm init -y >/dev/null + npm install "$GITHUB_WORKSPACE"/opencode-teamwork-*.tgz >/dev/null + test -f node_modules/opencode-teamwork/dist/engine.js + test -f node_modules/opencode-teamwork/dist/cli/templates/patterns/long-proof.md + test -f node_modules/opencode-teamwork/SKILL.md + node -e "const p=require('./node_modules/opencode-teamwork/package.json');console.log('packed package ok:',p.version);if(!p.files.includes('SKILL.md'))process.exit(1)" diff --git a/CHANGELOG.md b/CHANGELOG.md index ef02049..5d6b822 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,83 @@ All notable changes to opencode-teamwork are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [0.3.0] — 2026-09-19 + +**The run loop moved into code.** Until now the "DAG engine", worktree +isolation, cost tracking and checkpoint/resume were instructions in a prompt: +`src/worktree.ts`, `src/cost.ts`, `src/state.ts` and `src/artifacts.ts` were +imported by nothing, so none of those guarantees were enforced by the runtime. + +### Added +- **`src/engine.ts`** — a real scheduler: plan validation (cycles, dangling + dependencies, duplicate ids, unknown topologies), topological dispatch waves, + a concurrency cap, attempt accounting, per-round model escalation, dead-letter + after `maxRounds`, and budget enforcement that refuses dispatch at the cap. +- **`src/events.ts`** — append-only `events.jsonl` with a sha256 hash chain. + `state.json` is now a *derived* snapshot; `deriveSession()` replays a run and + `verifyChain()` refuses a tampered log. +- **Engine tools**: `teamwork_plan`, `teamwork_dispatch`, `teamwork_verify`, + `teamwork_status`, `teamwork_resume`. +- **`src/guard.ts`** — runtime role enforcement. Read-only roles + (verifier, falsifier, scout) cannot call write tools even if a host build + ignores part of the permission block. +- **`src/policy.ts`** — one source of truth for topology names, model ladders, + required checks and budget. A test asserts every topology resolves to a + pattern file. +- **Verification evidence**: checks now carry `cmd`, `exitCode`, `stdoutSha256` + and `durationMs`. A PASS with no executed check, or with a check that + contradicts its exit code, is rejected and does not count as a round. +- **Command flags parsed in code** — `--topology`, `--budget`, `--concurrency`, + `--session`, with an unknown topology reported instead of accepted. +- **Long runs survive compaction**: the `experimental.session.compacting` hook + re-injects the plan, task states and budget from the event log. +- **Tests**: 26 unit tests (`bun test`), a real end-to-end run against a git + repository (`bun run e2e`), and a built-bundle integration test that drives the + actual hook shapes OpenCode sends (`bun run integration`). CI runs all three on + Linux, macOS and Windows, plus a `npm pack` → clean-install check. +- `docs/self-improvement.md` — the design for improving the *policy* (not the + models) from the event log, with the guard metrics and failure modes. + +### Fixed +- **Agent frontmatter was inert.** The plugin passed each agent file's whole + markdown — frontmatter included — as the `prompt` string, so `mode` defaulted + to `all`, `permission: edit: deny` on the verifier did nothing, `temperature` + was ignored, and `hidden` is not a config key at all. Frontmatter is now + parsed and injected as real `AgentConfig` keys. +- **The plugin clobbered per-role models.** `config.agent[name] = { prompt }` + replaced whatever the installer had written, discarding the model map. Config + is now spread-merged, and a test asserts the user's model survives. +- **Three incompatible topology vocabularies.** The pattern files said + `long-proof` / `distributed-coding` / `document-review` while the sentinel + prompt and `state.ts` said `proof` / `large-swarm` / `doc-review` — four of + five names in the routing table matched no file. +- **Pattern files never reached the model.** `getAllPatterns()` had no call + sites and no prompt referenced `patterns/`. Orchestrating agents now get a + generated index with absolute paths. +- **A template language OpenCode cannot expand.** `{{if eq .sessionId ""}}` in + the `/teamwork` command was passed to the model as literal text (only + `$ARGUMENTS`, `$1..$N`, `` !`cmd` `` and `@file` are supported), and + `$ARGUMENTS` appeared twice, duplicating every request. +- **Shell injection surface in the worktree manager.** Git was invoked by + building command strings from model-produced names. All git calls now use + argv arrays with `shell: false`, and agent/session names are validated. +- **`cleanupSession` never cleaned up.** It ran `git worktree remove` against + the worktrees' *parent* directory (never a worktree), swallowed the failure, + then deleted directories git still had registered. It now removes real + worktrees, deletes the session's branches, prunes, and verifies. +- **`dist/` layout did not match `package.json` exports.** `build:support` + emitted `dist/src/*.js` while `exports` pointed at `dist/*.js`. +- **`SKILL.md` now ships.** It was in the repo but not in `files`. +- Leaf agents get `task: deny`; only the sentinel and v1 orchestrator can spawn + subagents, so a worker can no longer fan out its own swarm. + +### Changed +- `src/state.ts` is a compatibility shim over the event log; + `saveSessionState()` now throws with guidance rather than accepting a + model-authored snapshot. +- `bun run verify` is the single gate: typecheck → unit → build → smoke → e2e → + integration. `prepublishOnly` runs it. + ## [0.2.1] — 2026-08-28 CLI UX fix. diff --git a/README.md b/README.md index c66f8fe..c23acd5 100644 --- a/README.md +++ b/README.md @@ -1,283 +1,149 @@ # opencode-teamwork -> Antigravity-style multi-agent orchestration for OpenCode. 6 agents, 4 patterns, 5 slash commands. One-line install. +> Antigravity-style multi-agent orchestration for OpenCode. 10 agents, 6 topologies, 7 slash commands — and a **run engine owned by code**: append-only event log, topological dispatch, hashed verification evidence, budget enforcement. [![npm version](https://img.shields.io/npm/v/opencode-teamwork.svg)](https://www.npmjs.com/package/opencode-teamwork) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![OpenCode plugin](https://img.shields.io/badge/opencode-plugin-blueviolet)](https://opencode.ai) -This is a community replica of Google Antigravity's `/teamwork-preview`, -packaged as an opencode plugin. The team is opinionated: every run -goes scout → propose×N → falsify×N → synthesize → verify, with a -shared pitfall registry across rounds. You pick the pattern. You pick -the model per role. The loop is the same. +A community replica of Google Antigravity's `/teamwork-preview`, packaged as an +OpenCode plugin. ``` /teamwork "your hardest problem" ``` +The design rule is one line: **the LLM proposes, the runtime disposes.** +Agents plan and implement. Dispatch order, retries, the budget, terminal states +and the record of what happened belong to code, so a run survives a compaction, +a model swap, or a restart — and a PASS means "a command ran and exited 0", +not "a model felt good about it". + ## Quick install ```bash bunx opencode-teamwork@latest install -# or, if you don't have bun: +# or npx opencode-teamwork@latest install ``` Then run `opencode` and try: ``` -/teamwork-craft # Phase 1: 9-step wizard -/teamwork "your hardest problem" # v2: full DAG + worktrees + cost -/teamwork --topology small-focused "rename x" # fast path for tiny fixes -/team-orchestrate "fix the off-by-one" # v1: simpler propose/falsify loop -/teamwork --topology iterative-coding "fix the off-by-one in parser.ts" -/teamwork --topology distributed-coding "migrate the auth module to JWT" -/teamwork --topology long-proof "prove Knuth cycles for even n" -/teamwork --topology document-review "review https://arxiv.org/abs/2407.03262" -``` - -## What's in the box - -| | | -|---|---| -| **10 agents** | crafter (wizard), sentinel (DAG coordinator), worker, proof-worker, verifier, orchestrator (v1), proposer, falsifier, synthesizer, scout | -| **6 patterns** | small-focused, iterative-coding, distributed-coding, long-proof, massive-proof-swarm, document-review | -| **7 commands** | `/teamwork`, `/teamwork-craft`, `/team-orchestrate`, `/team-propose`, `/team-falsify`, `/team-synthesize`, `/team-review` | -| **5 presets** | anthropic, team, google, openai, free — or `custom` for per-role | -| **1 DAG engine** | Builds the task dependency graph; dispatches in topological order | -| **Git worktree isolation** | One worktree per agent, no cross-contamination | -| **Typed artifact bus** | `spec.json`, `plan.dag.json`, `patch.diff`, `verification_report.json`, Zod-validated | -| **Cost tracking** | Per-agent spend, session budget guardrails, halt at 80% of cap | -| **Session checkpointing** | `state.json` written on every transition; resume after interruption | -| **Pitfall registry** | Shared across rounds, distills verifier findings into answer-agnostic mistakes | - -## The v2 architecture (matches Antigravity's Teamwork) - -``` -PHASE 1 (crafter, primary, interactive) - ↓ 9-step elicitation wizard -prompt_draft.md (in .opencode/teamwork//) - -PHASE 2 (sentinel, primary, autonomous) - ↓ -[1] Load or inline the spec -[2] git worktree add .opencode/teamwork//worktrees/sentinel -[3] Build plan.dag.json — task dependency graph -[4] Provision worktrees per agent -[5] Dispatch workers in topological order (parallel where possible) -[6] Each worker returns: patch.diff + summary.md + cost.json -[7] Verifiers run against each patch.diff → verification_report.json -[8] If FAIL: feedback_for_worker.md → back to worker (loop) - If PASS: merge worker branch → sentinel branch -[9] On full success: merge sentinel → user's branch -[10] Write state.json + final.md on every transition (resume-safe) -``` - -The key insight from the Teamwork paper: implementers are biased. -The verification engine enforces strict segregation of duties — -the verifier sees the diff, not the worker's self-report. The -worker cannot certify its own work. - -## v1 (legacy) loop - -If you don't need the DAG engine or worktree isolation, the v1 loop -is a simpler propose → falsify → synthesize → verify cycle: - -``` - ┌──── scout (read-only, fast) ────┐ - │ │ - ┌────▼────┐ ┌────┐ ┌────┐ ┌────┐ │ - │ propose │─►│ c1 │ │ c2 │ │ c3 │ │ propose in parallel - └────┬────┘ └─┬──┘ └─┬──┘ └─┬──┘ │ different strategies - │ │ │ │ │ - ┌────▼────┐ ┌─▼──┐ ┌─▼──┐ ┌─▼──┐ │ - │ falsify │─►│crit│ │crit│ │crit│ │ falsify in parallel - └────┬────┘ └─┬──┘ └─┬──┘ └─┬──┘ │ adversarial - │ │ │ │ │ - │ ┌──▼──────▼──────▼──┐ │ - │ │ synthesize │ │ merge into one - │ └────────┬─────────┘ │ - │ │ │ - │ ┌────────▼─────────┐ │ - │ │ verify │ │ PASS or FAIL - │ └────────┬─────────┘ │ - │ │ │ - │ FAIL? ├──► pitfalls.md ──► re-propose (loop) - │ │ - └──────────────►│ - PASS ──► final.md -``` - -Use `/team-orchestrate` to invoke the v1 loop. Outputs go to -`.teamwork-runs//`. - -## Installation details - -The installer is `opencode-teamwork install`. It: - -1. Resolves your config path: `$OPENCODE_CONFIG_DIR/opencode.json` - or `~/.config/opencode/opencode.json`. -2. Asks which preset (or `custom` for per-role model selection). -3. Deep-merges the patch into your existing config. Doesn't touch - anything else. -4. Adds `opencode-teamwork@latest` to the `plugin` array. -5. Prints the model assignments and tells you to run `opencode`. - -### Flags - -```bash -# Use a preset -opencode-teamwork install --preset anthropic -opencode-teamwork install --preset team -opencode-teamwork install --preset google -opencode-teamwork install --preset openai -opencode-teamwork install --preset free - -# Overwrite existing config -opencode-teamwork install --reset - -# Print what would be written, then exit -opencode-teamwork install --print - -# Non-default config path -opencode-teamwork install --config /path/to/opencode.json -``` - -### What gets written - -```json -{ - "plugin": ["opencode-teamwork@latest"], - "agent": { - "team/orchestrator": { "model": "anthropic/claude-sonnet-4-5" }, - "team/proposer": { "model": "anthropic/claude-sonnet-4-5" }, - "team/falsifier": { "model": "anthropic/claude-sonnet-4-5" }, - "team/synthesizer": { "model": "anthropic/claude-sonnet-4-5" }, - "team/verifier": { "model": "anthropic/claude-sonnet-4-5" }, - "team/scout": { "model": "anthropic/claude-sonnet-4-5" } - } -} +/teamwork-craft # Phase 1: interactive spec wizard +/teamwork "your hardest problem" # plan → dispatch → verify +/teamwork --topology small-focused "rename x" # flags are parsed in code +/teamwork --topology long-proof --budget 30 "prove X" +/teamwork --topology distributed-coding --concurrency 4 "migrate auth to JWT" ``` -You can edit this file by hand. The plugin doesn't care which model -each role uses; it just dispatches. +## What is enforced in code -## Patterns +Every claim below is backed by a test. `bun run verify` runs all of them. -### v2 (DAG-based) patterns - -| Pattern | Topology | Use when | +| Guarantee | Mechanism | Test | |---|---|---| -| `small-focused` | 1 builder + 1 reviewer loop | Single self-contained fix | -| `distributed-coding` | N workers in parallel + verifiers | Multi-module build, migration | -| `long-proof` | 1 strategist + 3-5 searchers + formal checker | Math/TCS proof, Lean/Coq | -| `massive-proof-swarm` | meta-coord + 100+ searchers | Open conjecture, opt-in only | -| `document-review` | 1 chair + 3 critics + 1 aggregator | Paper / RFC / audit | - -### v1 (legacy) pattern - -`iterative-coding` — a single proposer + falsifier + verify loop, no -synthesis, no DAG. Use `/team-orchestrate --pattern iterative-coding` -to invoke. - -Force a topology: -```bash -/teamwork --topology long-proof "prove X" -``` - -If you don't force one, the sentinel infers from the spec and asks -one short question if it can't tell. - -## Manual install (no CLI) - -If you don't want to use the installer, you can drop the agent -files into your project's `.opencode/agents/team/` directory. The -files are in `src/cli/templates/` in this repo. The plugin won't -load the `team/*` slash commands that way (those require the plugin), -but the agents themselves will work. - -## Outputs - -### v2 (DAG-based) - -``` -.opencode/teamwork// -├── prompt_draft.md # from crafter -├── plan.dag.json # task dependency graph (Zod-validated) -├── state.json # sentinel state (for resume) -├── costs.json # running cost / budget tracker -├── worktrees/ -│ ├── sentinel/ # sentinel's worktree -│ ├── agent-builder-1/ # one per worker -│ └── agent-builder-N/ -├── spec-.json # scoped spec per worker -├── patch-.diff # worker's output -├── summary-.md # worker's self-report -├── verify-.json # verifier's report -└── final.md # the answer (sentinel writes) -``` - -### v1 (legacy) - -``` -.teamwork-runs/ -└── 2026-08-28T12-34-56-abc123/ - ├── scout-report.md - ├── candidates/ - │ ├── cand-1.md - │ ├── cand-2.md - │ └── cand-3.md - ├── critiques/ - │ ├── crit-1.md - │ ├── crit-2.md - │ └── crit-3.md - ├── synthesis/ - │ └── syn-1.md - ├── verify/ - │ └── v-1.md - ├── pitfalls.md # carried across rounds - └── final.md # the answer -``` - -`final.md` is the thing to read first. It links to the supporting -artifacts and lists the open pitfalls. - -## When NOT to use Teamwork - -- A simple, well-scoped coding task ("rename this function", "add - this log line", "explain this line"). The default opencode `build` - agent is faster. -- Anything that fits in one model context. Teamwork's overhead is - real; you spend tokens on 3-5 candidates when one would have - worked. -- Production deployment. Use the `build` agent for that. Teamwork is - for the exploration phase, not the ship phase. - -The orchestrator asks once: "Skipping Teamwork — I'll work as a -single agent. OK?" before falling back. - -## Comparison to upstream - -| | Antigravity `/teamwork-preview` | opencode-teamwork v0.2 | +| Dispatch follows the DAG | `Engine.dispatchable()` returns only tasks whose dependencies are COMPLETED, within the concurrency cap | `bun test` — topological order + cap | +| Cycles and dangling deps can't start a run | `validatePlan()` rejects them before the first event is written | `bun test` — plan validation | +| The verifier cannot edit code | `permission.edit: deny` injected as a real config key **and** a runtime guard that refuses write tools for read-only sessions | `plugin-integration` — "verifier edit refused" | +| A PASS must be evidence | the engine refuses a report with no executed check, or a check marked failed with `exitCode: 0` | `bun test` + `e2e` — "rubric-only PASS rejected" | +| Budget stops the run | `dispatchable()` returns nothing at the cap; `budget.exhausted` is logged | `e2e` — "dispatch stops once the cap is hit" | +| One bad task doesn't poison the run | `maxRounds` exhaustion dead-letters that task; the rest continue | `e2e` — "3 of 4 completed, 1 parked" | +| The record can't be rewritten | events are hash-chained; `teamwork_resume` refuses a tampered log | `bun test` + `e2e` — tamper detection | +| Long runs survive compaction | `experimental.session.compacting` re-injects plan, task states and budget from the log | `plugin-integration` — "plan re-injected" | +| Per-role models survive the plugin | config is merged, not replaced | `plugin-integration` — "verifier model preserved" | +| An agent name can't escape the run dir | `assertAgentName` + argv-based git calls (no shell) | `bun test` — worktree safety | + +## Architecture + +``` +/teamwork "…" + │ + ├─ command.execute.before ← flags parsed IN CODE, run id minted, + │ .opencode/teamwork/LATEST.json written + │ + ├─ team/sentinel (primary) ← picks a topology, calls the engine, merges + │ │ + │ ├─ teamwork_plan ← validate DAG, create run, write specs, worktrees + │ ├─ teamwork_dispatch ← "what runs now?" (topological + budget + cap) + │ ├─ team/worker ×N ← one git worktree each, returns patch.diff + summary + │ ├─ team/verifier ×N ← runs the actual commands, records cmd+exitCode+hash + │ ├─ teamwork_verify ← engine rules: COMPLETED | retry | dead-letter + │ ├─ teamwork_status ← derived from the log, never from memory + │ └─ teamwork_resume ← chain-verified rebuild after a restart + │ + └─ .opencode/teamwork// + ├─ events.jsonl ← append-only, hash-chained: the source of truth + ├─ state.json ← DERIVED snapshot (never hand-written) + ├─ plan.dag.json ← schema-validated + ├─ spec-.json ← schema-validated, one per task + ├─ request.md ← the raw request + parsed flags + ├─ worktrees/ ← one per agent + └─ final.md ← what the user reads +``` + +Implementers are biased; that's the premise. The verifier sees the diff and the +spec, never the worker's self-report, and its verdict only counts when it carries +the exit code of something that actually ran. + +## The 10 agents + +| Agent | Role | Mode | Can edit | +|---|---|---|---| +| `team/sentinel` | run coordinator, drives the engine | primary | yes | +| `team/crafter` | interactive spec elicitation | primary | ask | +| `team/orchestrator` | v1 propose/falsify loop | primary | yes | +| `team/worker` | implements one scoped spec in its own worktree | subagent | yes | +| `team/proof-worker` | same, for Lean/Coq/Isabelle proofs | subagent | yes | +| `team/verifier` | the forcing function | subagent | **no** | +| `team/falsifier` | attacks a candidate | subagent | **no** | +| `team/scout` | read-only context gathering | subagent | **no** | +| `team/proposer` | one candidate per invocation | subagent | ask | +| `team/synthesizer` | merges candidates + critiques | subagent | ask | + +Leaf agents get `task: deny` — a worker cannot fan out its own swarm. Only the +sentinel and the v1 orchestrator may invoke subagents. + +## The 6 topologies + +| Topology | Shape | Use when | |---|---|---| -| Patterns | 5 | 6 (added `small-focused`) | -| Models | Gemini 3.x only | Any opencode-supported model | -| Hosting | Closed, Antigravity account | Open source, your own opencode | -| Cost control | Bundled with Antigravity plan | You pay your provider directly; per-session budget guardrails | -| Round cap | Dynamic, expensive | Topology-defined, sane defaults | -| Verification | Anti-leak sandboxed oracles (Spike sandbox, etc.) | Whatever the user has set up; structured JSON reports | -| DAG engine | Yes | Yes (`plan.dag.json`, topological dispatch) | -| Worktree isolation | Yes (per agent) | Yes (per agent) | -| Typed artifact bus | Yes (spec, plan, patch, verification_report) | Yes (same shape, Zod-validated) | -| Cost tracking | Yes | Yes (`costs.json` per session) | -| Session checkpoint / resume | Yes | Yes (`state.json` on every transition) | -| Prompt crafter (9-step wizard) | Yes | Yes (`/teamwork-craft`) | - -We're not trying to out-Google Google. We're trying to make the -*pattern* usable. The interesting thing about Teamwork isn't the -Gemini integration — it's the falsifier loop and the pitfall -registry. Those are now yours. +| `small-focused` | 1 builder + 1 reviewer loop | one self-contained fix | +| `iterative-coding` | proposer + falsifier + verify | a bug in a known place | +| `distributed-coding` | N workers in parallel + verifiers | multi-module change, migration | +| `long-proof` | strategist + 3-5 searchers + formal checker | math/TCS proof | +| `massive-proof-swarm` | meta-coordinator + 100+ searchers | open conjecture (opt-in) | +| `document-review` | chair + 3 critics + aggregator | paper / RFC / audit | + +The definition files ship in `dist/cli/templates/patterns/`. The orchestrating +agents' prompts carry an index of their absolute paths — the names in the prompt +and the files on disk are generated from one source (`src/policy.ts`), so they +cannot drift apart. + +## When NOT to use this + +- A simple, well-scoped task ("rename this function", "add a log line"). The + default `build` agent is faster. +- Anything that fits in one context. Teamwork's overhead is real: you pay for + 3-5 candidates where one would have worked. +- Production deployment. This is for the exploration phase, not the ship phase. + +## Cost control + +`--budget 30` (USD) or a per-topology default. The engine sums recorded cost from +the event log, warns at 80%, and refuses further dispatch at 100%. A run that hits +the cap presents partial results and the dead-letter queue instead of retrying +forever. + +## Self-improving runs + +The event log is the prerequisite for improving the *policy* rather than the +models: topologies, model ladders, required checks. The design — attribution, +typed policy deltas, adversarial falsification of the hypothesis, shadow replay +on a frozen benchmark, canary + rollback, and the escape-rate metric that keeps +the loop honest — is in [docs/self-improvement.md](docs/self-improvement.md). +Nothing in it ships yet; it is written down so it can be built against the log +that now exists. ## Development @@ -285,24 +151,29 @@ registry. Those are now yours. git clone https://github.com/aditya0si/OpenCode-Team cd OpenCode-Team bun install -bun run build -bun dist/cli/index.js install --print # test the installer +bun run verify # typecheck → unit tests → build → smoke → e2e → plugin integration ``` -The build emits: -- `dist/index.js` — the opencode plugin entry (v1). -- `dist/server.js` — same, for v2 host compatibility. -- `dist/cli/index.js` — the `opencode-teamwork` CLI. +| Command | What it proves | +|---|---| +| `bun run typecheck` | no type errors | +| `bun test` | scheduler, event log, report validation, config injection, guard, flags | +| `bun run e2e` | the whole loop against a real git repo: worktrees, evidence capture, budget, dead-letter, resume, cleanup | +| `bun run integration` | the built bundle with the hook shapes OpenCode sends: config, commands, tools, guard, compaction | +| `bun run smoke` | the installer: install / re-install / uninstall / doctor / dry-run | ## Contributing -PRs welcome. Areas where help is useful: -- More patterns (security audit, perf optimization, etc.). -- More presets (Anthropic, Google, OpenAI, etc.). -- Better verifier heuristics. -- Smoke tests under `scripts/`. +Useful places to help: + +- New topologies (security audit, perf work, data migration) — add a pattern file + and one entry in `src/policy.ts`. +- A verification ladder (static → unit → property → integration) so cheap oracles + run before the expensive one. +- The learning loop in `docs/self-improvement.md`, starting with `trace.jsonl` + export from the existing event log. -See `CONTRIBUTING.md` for the conventions. +See `CONTRIBUTING.md`. ## License diff --git a/SKILL.md b/SKILL.md index cf004a9..6246b15 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,77 +1,85 @@ --- name: opencode-teamwork -description: "Antigravity-style multi-agent orchestration plugin for OpenCode. Use when the user wants to install, configure, or operate opencode-teamwork — including the 10 team/* agents (crafter, sentinel, worker, proof-worker, verifier, orchestrator, proposer, falsifier, synthesizer, scout), the 6 patterns (small-focused, long-proof, iterative-coding, distributed-coding, document-review, massive-proof-swarm), the 7 slash commands (/teamwork, /teamwork-craft, /team-orchestrate, /team-propose, /team-falsify, /team-synthesize, /team-review), the installer (`bunx opencode-teamwork@latest install`), DAG engine, git worktree isolation, typed artifact bus, cost tracking, and per-role model selection." -version: 0.2.0 +description: "Antigravity-style multi-agent orchestration plugin for OpenCode with a code-owned run engine. Use when the user wants to install, configure, or operate opencode-teamwork — including the 10 team/* agents (crafter, sentinel, worker, proof-worker, verifier, orchestrator, proposer, falsifier, synthesizer, scout), the 6 topologies (small-focused, long-proof, iterative-coding, distributed-coding, document-review, massive-proof-swarm), the 7 slash commands (/teamwork, /teamwork-craft, /team-orchestrate, /team-propose, /team-falsify, /team-synthesize, /team-review), the installer (`bunx opencode-teamwork@latest install`), the run engine tools (teamwork_plan, teamwork_dispatch, teamwork_verify, teamwork_status, teamwork_resume), the hash-chained event log, git worktree isolation, schema-validated artifacts, budget enforcement, and per-role model selection." +version: 0.3.0 author: Aditya Singh license: MIT platforms: [linux, macos, windows] metadata: hermes: - tags: [opencode, antigravity, teamwork, multi-agent, orchestration, plugin, npm, agent-roles, pattern-library, slash-commands, dag, worktree, cost-tracking, typed-artifacts] + tags: [opencode, antigravity, teamwork, multi-agent, orchestration, plugin, npm, agent-roles, pattern-library, slash-commands, dag, worktree, cost-tracking, typed-artifacts, event-log, verification] homepage: https://github.com/aditya0si/OpenCode-Team --- # opencode-teamwork — Antigravity-style multi-agent orchestration for OpenCode -A community replica of Google Antigravity's `/teamwork-preview`, -packaged as an opencode plugin. 10 agents, 6 patterns, 7 slash -commands, full DAG engine with git worktree isolation, typed -artifact bus, and cost tracking. Install with one command, mix and -match models per role, point it at your hardest problem. +A community replica of Google Antigravity's `/teamwork-preview`, packaged as an +OpenCode plugin. 10 agents, 6 topologies, 7 slash commands. -This skill is a hub. The body covers the high-level mental model. For -per-agent behavior, per-pattern adjustments, and per-command output, -load the relevant agent file or pattern file — they live inside the -installed plugin at `node_modules/opencode-teamwork/dist/cli/templates/`. +**The rule that shapes everything: the LLM proposes, the runtime disposes.** +Agents plan and implement; dispatch order, retries, the budget, terminal states +and the record of what happened are owned by code (`src/engine.ts`, +`src/events.ts`). A verifier PASS only counts when the report carries the exit +code of a command that actually ran. + +This skill is a hub. The body covers the mental model. For per-agent behavior and +per-topology adjustments, read the files inside the installed plugin at +`node_modules/opencode-teamwork/dist/cli/templates/`. ## The mental model -Teamwork is a loop, not a pipeline. The v2 (DAG-based) loop is: +Teamwork is a loop, not a pipeline. The v2 loop is: ``` -crafter (Phase 1: 9-step elicitation wizard) +crafter (Phase 1: interactive spec elicitation) + ↓ +command.execute.before ← flags parsed in code, run id minted + ↓ +sentinel (Phase 2: coordinator — calls the engine, does not decide order) ↓ -sentinel (Phase 2: dispatcher / coordinator) +teamwork_plan ← validate DAG, create run, write specs, worktrees ↓ -plan.dag.json (build the task dependency graph) +teamwork_dispatch ← "what runs NOW?" (topological + budget + cap) ↓ -worktree-per-agent (one isolated git worktree each) +worker[0..N] → patch.diff + summary.md (one git worktree each) ↓ -worker[0..N] → patch.diff + summary.md +verifier[0..N] → verification_report.json (cmd + exitCode + stdoutSha256) ↓ -verifier[0..N] → verification_report.json +teamwork_verify ← engine rules: COMPLETED | retry | dead-letter ↓ -FAIL → feedback_for_worker.md → back to worker (loop) -PASS → merge to base branch +final.md + the run directory ``` -The v1 (legacy) loop is a simpler propose-falsify-synthesize-verify -cycle for users who don't need the DAG engine or worktree isolation. -Both loops ship; the user picks via `/teamwork` (v2) or -`/team-orchestrate` (v1). +Everything the sentinel needs to know after a compaction comes from +`events.jsonl` (append-only, hash-chained) via `teamwork_status`, never from the +model's memory. `state.json` is a derived snapshot. + +The v1 loop (`/team-orchestrate`) is the simpler propose → falsify → synthesize → +verify cycle, without the DAG engine or worktrees. Both ship. ## The 10 agents -| Agent | Role | Hidden | Mode | +| Agent | Role | Mode | Can edit | |---|---|---|---| -| `team/crafter` | Phase-1 wizard. Runs the 9-step elicitation flow. Produces `prompt_draft.md`. | no | primary | -| `team/sentinel` | Phase-2 coordinator. Loads spec, builds DAG, dispatches workers + verifiers, iterates, merges. | no | primary | -| `team/worker` | Generic implementer. Own worktree, scoped spec, returns diff. | yes | subagent | -| `team/proof-worker` | Math/proof specialist. Lean/Coq/Isabelle. | yes | subagent | -| `team/verifier` | Forcing function. Runs the verification plan. Returns structured PASS/FAIL report. | yes | subagent | -| `team/orchestrator` | v1 loop coordinator. | no | primary | -| `team/proposer` | v1 candidate generator. | yes | subagent | -| `team/falsifier` | v1 adversarial critic. | yes | subagent | -| `team/synthesizer` | v1 merge step. | yes | subagent | -| `team/scout` | Read-only context gatherer. | yes | subagent | - -Hidden subagents don't appear in `@` autocomplete. They're invoked -by the Sentinel / Orchestrator via the `task` tool. You can still -@-mention them manually. - -## The 6 patterns - -| Pattern | Topology | Trigger | +| `team/crafter` | Phase-1 wizard. Runs the elicitation flow. Produces `prompt_draft.md`. | primary | ask | +| `team/sentinel` | Run coordinator. Calls the engine, dispatches workers + verifiers, merges. | primary | yes | +| `team/worker` | Generic implementer. Own worktree, scoped spec, returns diff + summary. | subagent | yes | +| `team/proof-worker` | Math/proof specialist. Lean/Coq/Isabelle. | subagent | yes | +| `team/verifier` | Forcing function. Runs the verification plan, returns a structured report. | subagent | **no** | +| `team/orchestrator` | v1 loop coordinator. | primary | yes | +| `team/proposer` | v1 candidate generator. | subagent | ask | +| `team/falsifier` | v1 adversarial critic. | subagent | **no** | +| `team/synthesizer` | v1 merge step. | subagent | ask | +| `team/scout` | Read-only context gatherer. | subagent | **no** | + +Read-only roles are enforced twice: `permission.edit: deny` is injected as a real +OpenCode config key, and a runtime guard refuses write-tool calls from those +sessions. Leaf agents also get `task: deny`, so a worker cannot fan out its own +swarm — only the sentinel and the v1 orchestrator may invoke subagents. + +## The 6 topologies + +| Topology | Shape | Trigger | |---|---|---| | `small-focused` | 1 builder + 1 reviewer loop | Single self-contained fix | | `iterative-coding` | 1 proposer → 1 falsifier (no synthesis) | Tight agent-test-refine loop | @@ -80,9 +88,11 @@ by the Sentinel / Orchestrator via the `task` tool. You can still | `massive-proof-swarm` | meta-coord + 100+ searchers | Open conjecture, opt-in only | | `document-review` | 1 chair + 3 critics + 1 aggregator | Paper / RFC / audit review | -The Sentinel picks from the spec. Force one with -`/teamwork --topology "..."`. If you don't, the Sentinel infers -from the spec and asks one question if it can't tell. +The names above are generated from `src/policy.ts`, and a test asserts every one +resolves to a pattern file on disk. The orchestrating agents' prompts carry the +absolute paths of those files, so no agent has to guess where the definition +lives. Force one with `/teamwork --topology "..."` — the flag is parsed in +code and an unknown name is reported, not silently accepted. ## The 7 slash commands @@ -98,23 +108,27 @@ from the spec and asks one question if it can't tell. ## The artifact bus -Raw conversation is never shared. Agents communicate through typed -artifacts on disk. Every artifact has a Zod schema in -`src/artifacts.ts`: +Raw conversation is never shared. Agents communicate through typed artifacts on +disk, and the engine validates them against the Zod schemas in `src/artifacts.ts` +before accepting them: -| File | Producer | Consumer | Schema | +| File | Producer | Consumer | Validated | |---|---|---|---| -| `prompt_draft.md` | crafter | sentinel | markdown (structure in crafter prompt) | -| `plan.dag.json` | sentinel | workers | `PlanDagSchema` | -| `state.json` | sentinel | sentinel (on resume) | `SessionState` | -| `costs.json` | every agent | sentinel | `CostState` | -| `spec.json` | sentinel | worker | `SpecSchema` | -| `patch.diff` | worker | verifier | (git diff) | -| `summary.md` | worker | sentinel | markdown | -| `verification_report.json` | verifier | sentinel | `VerificationReportSchema` | -| `feedback_for_worker.md` | verifier | worker (next round) | markdown | +| `events.jsonl` | engine | everything | hash chain verified on read | +| `state.json` | engine (derived) | sentinel (on resume) | written from the log, never hand-edited | +| `request.md` | plugin (`command.execute.before`) | sentinel | raw request + parsed flags | +| `plan.dag.json` | sentinel via `teamwork_plan` | engine, workers | `PlanDagSchema` | +| `spec-.json` | engine | worker | `SpecSchema` | +| `patch.diff` | worker | verifier | git diff (the file *is* the artifact) | +| `summary.md` | worker | verifier (as a hint only) | markdown | +| `verification_report.json` | verifier | engine via `teamwork_verify` | `VerificationReportSchema` + evidence rules | | `final.md` | sentinel | user | markdown | +A report is rejected if a `programmatic`/`adversarial` check has no `cmd` or no +`exitCode`, if a check claims `passed: false` while its command exited 0 (or the +reverse), or if a PASS contains no executed check at all. Fabricated PASSes were +the original architecture's weakest point; the exit code is what closes it. + ## Per-role model selection The installer ships 5 presets: `anthropic`, `team`, `google`, diff --git a/docs/self-improvement.md b/docs/self-improvement.md new file mode 100644 index 0000000..68e5a30 --- /dev/null +++ b/docs/self-improvement.md @@ -0,0 +1,159 @@ +# Self-improving runs — design + +Status: **designed, not built.** The prerequisite (`events.jsonl`, an +append-only trace of every run) now ships. This document describes the loop that +log makes possible, so it can be built against real data instead of guesses. + +The short version: **do not fine-tune anything.** The artifact worth improving is +the *orchestration policy* — which topology for which kind of task, which model +per role, which checks a task class must pass. That is small, versionable, +testable data, and improving it is measurable in a way weight updates are not for +a project this size. + +## The policy is data, not prose + +`src/policy.ts` already holds the routing table; `.teamwork/policy.json` in a +project overrides it. The fields the loop may touch: + +```jsonc +{ + "version": 7, + "parent": 6, + "budget": { "perTaskUsd": 2.5, "perSessionUsd": 20, "haltAtPct": 80 }, + "routing": { + "taskClass:bugfix-single-file": { + "ladder": ["anthropic/claude-sonnet-4-5", "anthropic/claude-opus-4-5"], + "topology": "small-focused", + "requiredChecks": [] + }, + "taskClass:auth-change": { + "ladder": ["anthropic/claude-sonnet-4-5"], + "requiredChecks": ["adversarial:privilege-escalation"] + } + } +} +``` + +Two rules make this safe to edit: + +1. **The verifier's rubric and the evaluation set are not in this file.** They + change only through human review. A loop that can edit both the work and the + checker will learn to satisfy the checker. +2. Every accepted change is a versioned diff with a benchmark table attached — + including the changes that were *rejected*. + +## The loop + +Stages L0–L6, each one a place where a claim can be falsified before it costs +anything. + +### L0 — Telemetry (exists today) + +`events.jsonl` already records, per task: dispatch with the model used, every +verifier round with each check's `name`, `type`, `passed`, `cmd`, `exitCode` and +`stdoutSha256`, cost, attempt number, and terminal state (completed, failed, +dead-letter) with the reason. Add two fields to make it a learning signal: + +- `humanAccepted` — one keystroke in the UI per run ("did this help? y/n/edit"). + This is the only ground truth about whether a PASS was *real*. +- `failureClass` — filled by L1, not by the agent. + +### L1 — Attribution + +Cluster `fatalFindings` into a closed label set: +`spec-ambiguity | missed-edge-case | wrong-interface | hallucinated-api | +test-not-run | scope-creep | merge-conflict | environment | budget-exhausted`. + +Then mine for *conditional* signatures, never global averages: + +- "hallucinated-api is 6× more likely when the task class has no type checker in + its ladder" → a policy delta. +- "escape rate triples when the verifier model is weaker than the worker" → a + routing constraint. + +Surface it as a report, not an action: `teamwork learn --since 30d` prints the +top patterns with counts and confidence intervals. + +### L2 — Proposer + +A "policy architect" role emits a **typed delta**, never prose: + +```jsonc +{ + "op": "add", + "path": "/routing/taskClass:auth-change/requiredChecks/-", + "value": "adversarial:privilege-escalation", + "hypothesis": "3 of 5 recorded auth defects passed unit checks; this check fails on those repros", + "expectedEffect": { "escapeRate": -0.15, "costDeltaUsdPerTask": 0.08 }, + "falsificationPlan": "replay the 12 recorded auth tasks; reject if pass rate drops or cost rises >20%" +} +``` + +**Constrain the delta grammar**: at most 3 fields per epoch, no rubric or +evaluation-set paths, no deletion of a check that has ever caught a real defect. +Bounded mutation rates are what stop a self-modifying loop from drifting. + +### L3 — Falsifier (on the hypothesis) + +An adversarial agent tries to kill the proposal *before* evaluation budget is +spent: + +- "n=3 — you cannot detect a 5% change." +- "This effect is confounded: the task mix changed that week." +- "This delta lets the worker choose its own verifier. That is self-certification + with extra steps." + +Only proposals that survive reach L4. This is the stage most self-improving +systems skip, and the reason they degrade. + +### L4 — Shadow replay + +Incumbent vs candidate, offline, on a frozen benchmark of recorded tasks: same +repo commits, same recorded tool outputs, same provider parameters, N≥3 repeats, +**stratified by task class**. Adopt only if all hold: + +- `passRate(candidate) ≥ passRate(incumbent)` per stratum, not just pooled; +- `escapeRate` not worse; +- `costPerSuccess ≤ +20%`; +- latency within budget. + +Write the table into `policy-changelog.md`, rejections included. + +### L5 — Canary and rollback + +`policy@next` serves a fraction of live runs. Roll back automatically on +escape-rate or cost regression. Promote after k clean canary runs. + +### L6 — Human gates + +Verifier rubric, evaluation set, and any change touching permissions require +human review. Not negotiable. + +## The metric that keeps it honest + +Track **escape rate**: the fraction of verifier PASSes that a human (or a +stronger independent judge) later rejects. Without it the loop optimises the only +thing it can see — the verifier — and "improvement" means "we taught the team to +satisfy the checker". It is also the metric that makes L4 able to reject a +candidate that raises the pass rate while raising escapes. + +## Failure modes to design against + +| Failure mode | Mechanism | Mitigation | +|---|---|---| +| Verifier capture / Goodhart | the loop edits prompts *and* the checker | rubric is human-owned; escape rate is first-class; check retirement needs evidence | +| Task-mix drift | "improvement" is really a change in the week's work | stratify by task class; freeze the benchmark | +| Luck / nondeterminism | adopted on one lucky run | N≥3, seeds, recorded provider params, effect size vs measured noise floor | +| Unfalsifiable deltas | "write better prompts" is not a hypothesis | typed JSON-Patch deltas with an explicit falsification plan (L2/L3) | +| Cost asymmetry | the learner costs more than the win | nightly epochs only; only for task classes with n≥20 runs; budget the learner itself | + +## Build order + +| Milestone | Content | Acceptance test | +|---|---|---| +| M3 | `trace.jsonl` export from `events.jsonl`, failure taxonomy, `teamwork learn` report | golden-set test: injected patterns in 20 recorded runs are found (precision/recall thresholds) | +| M4 | `policy.json` schema, `teamwork bench --candidate`, per-stratum comparison, changelog | a *rejected* delta in CI — a plausible change the benchmark proves is not an improvement | +| M5 | canary routing, auto-rollback, escape rate in the run summary | a simulated regression triggers rollback | + +M3 is mostly a projection of data that already exists on disk. That is the point +of having built the log first. diff --git a/package.json b/package.json index 25f59fe..e43134b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "opencode-teamwork", - "version": "0.2.1", - "description": "Antigravity-style Teamwork multi-agent orchestration for OpenCode — 10 agents, 6 patterns, 7 slash commands. Replica of Google Antigravity's /teamwork-preview.", + "version": "0.3.0", + "description": "Antigravity-style Teamwork multi-agent orchestration for OpenCode — 10 agents, 6 topologies, 7 slash commands, and a code-owned run engine (event log, DAG dispatch, verified evidence, budget). Replica of Google Antigravity's /teamwork-preview.", "main": "./dist/index.js", "types": "./dist/index.d.ts", "type": "module", @@ -13,6 +13,7 @@ "teamwork", "multi-agent", "orchestration", + "agent-orchestration", "ai", "agents", "claude", @@ -29,28 +30,52 @@ }, "./templates": { "import": "./dist/templates.js" + }, + "./engine": { + "import": "./dist/engine.js" + }, + "./events": { + "import": "./dist/events.js" + }, + "./policy": { + "import": "./dist/policy.js" + }, + "./worktree": { + "import": "./dist/worktree.js" + }, + "./artifacts": { + "import": "./dist/artifacts.js" } }, "bin": "./dist/cli/index.js", "files": [ "dist", "README.md", - "LICENSE" + "LICENSE", + "SKILL.md", + "CHANGELOG.md" ], + "engines": { + "node": ">=18" + }, "scripts": { "clean": "bun -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true })\"", "build:plugin": "bun build src/index.ts --outfile dist/index.js --target node --format esm --external @opencode-ai/plugin --external @opencode-ai/sdk --external zod", "build:server": "bun build src/index.ts --outfile dist/server.js --target node --format esm --external zod", "build:cli": "bun build src/cli/index.ts --outdir dist/cli --target node --format esm --external @opencode-ai/plugin --external @opencode-ai/sdk --external zod", "build:templates": "bun build src/templates.ts --outfile dist/templates.js --target node --format esm --external zod", - "build:support": "bun build src/worktree.ts src/cost.ts src/state.ts src/artifacts.ts --outdir dist --target node --format esm", + "build:support": "bun build src/worktree.ts src/cost.ts src/state.ts src/artifacts.ts src/events.ts src/engine.ts src/policy.ts src/guard.ts src/tools.ts --outdir dist --root src --target node --format esm --external @opencode-ai/plugin --external @opencode-ai/sdk --external zod", "build:assets": "bun -e \"import { cpSync, mkdirSync } from 'node:fs'; mkdirSync('dist/cli', { recursive: true }); cpSync('src/cli/templates', 'dist/cli/templates', { recursive: true });\"", "build": "bun run clean && bun run build:plugin && bun run build:server && bun run build:cli && bun run build:templates && bun run build:support && bun run build:assets && tsc --emitDeclarationOnly", "prepare": "bun run build", "typecheck": "tsc --noEmit", + "test": "bun test", + "e2e": "bun scripts/e2e-engine.ts", + "integration": "bun scripts/plugin-integration.ts", "dev": "bun run build && opencode", "smoke": "bun scripts/smoke.ts", - "prepublishOnly": "bun run build", + "verify": "bun run typecheck && bun run test && bun run build && bun run smoke && bun run e2e && bun run integration", + "prepublishOnly": "bun run verify", "release:patch": "npm version patch", "release:minor": "npm version minor", "release:major": "npm version major" diff --git a/scripts/e2e-engine.ts b/scripts/e2e-engine.ts new file mode 100644 index 0000000..41d163e --- /dev/null +++ b/scripts/e2e-engine.ts @@ -0,0 +1,282 @@ +/** + * End-to-end run of the engine against a REAL git repository. + * + * Unit tests prove the scheduler's logic; this proves the whole loop works on + * this machine: real worktrees, real branch creation, real evidence capture + * from a real command, a real event log, budget enforcement, retry, dead + * letter, and cleanup. No models are involved — a scripted "worker" writes a + * file and a scripted "verifier" runs the repo's check command and records its + * exit code, which is exactly the shape an agent-driven run produces. + * + * Usage: bun run e2e + */ + +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { appendFileSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Engine, type VerificationReport } from "../src/engine.js"; +import { readEvents, verifyChain } from "../src/events.js"; +import { createWorktreeManager, git, runDirFor } from "../src/worktree.js"; + +let failures = 0; +function check(label: string, condition: boolean, detail?: string): void { + if (condition) { + console.log(` ✓ ${label}`); + } else { + failures += 1; + console.error(` ✗ ${label}${detail ? ` — ${detail}` : ""}`); + } +} + +const sha = (text: string): string => createHash("sha256").update(text).digest("hex"); + +/** The repo's own acceptance check: exits 0 only when `ok.txt` exists. */ +const CHECK_SCRIPT = `import { existsSync } from "node:fs"; +if (!existsSync("ok.txt")) { console.error("ok.txt missing"); process.exit(1); } +console.log("ok"); +`; + +function makeRepo(): string { + const dir = mkdtempSync(join(tmpdir(), "teamwork-e2e-")); + const init = spawnSync("git", ["init"], { cwd: dir, encoding: "utf-8" }); + if (init.status !== 0) throw new Error(`git init failed: ${init.stderr}`); + writeFileSync(join(dir, "README.md"), "# fixture\n", "utf-8"); + writeFileSync(join(dir, "check.mjs"), CHECK_SCRIPT, "utf-8"); + writeFileSync(join(dir, ".gitignore"), ".opencode/\n", "utf-8"); + git(["add", "."], dir); + const commit = spawnSync( + "git", + ["-c", "user.email=e2e@example.com", "-c", "user.name=e2e", "commit", "-m", "chore: fixture"], + { cwd: dir, encoding: "utf-8" }, + ); + if (commit.status !== 0) throw new Error(`git commit failed: ${commit.stderr}`); + return dir; +} + +/** Stand-in for a team/worker: edits its own worktree and commits. */ +function fakeWorker(worktree: string, taskId: string, round: number, doesTheWork: boolean): void { + if (doesTheWork) { + if (round === 0) { + writeFileSync(join(worktree, "ok.txt"), `work for ${taskId}\n`, "utf-8"); + } else { + appendFileSync(join(worktree, "ok.txt"), `fixed in round ${round}\n`, "utf-8"); + } + } + git(["add", "-A"], worktree); + spawnSync( + "git", + ["-c", "user.email=e2e@example.com", "-c", "user.name=e2e", "commit", "-m", `${taskId} round ${round}`, "--allow-empty"], + { cwd: worktree, encoding: "utf-8" }, + ); +} + +/** + * Stand-in for a team/verifier: runs the real command, records cmd + exit code + * + stdout hash, and reports what actually happened. + */ +function fakeVerifier(worktree: string, taskId: string): VerificationReport { + const run = spawnSync("node", ["check.mjs"], { cwd: worktree, encoding: "utf-8" }); + const stdout = `${run.stdout ?? ""}${run.stderr ?? ""}`; + const exitCode = run.status ?? 1; + const passed = exitCode === 0; + + const status = spawnSync("git", ["status", "--porcelain"], { cwd: worktree, encoding: "utf-8" }); + const clean = (status.stdout ?? "").trim().length === 0; + + return { + taskId, + verifierAgent: "team/verifier", + verifierModel: "e2e/scripted", + timestamp: new Date().toISOString(), + status: passed ? "PASS" : "FAIL", + checks: [ + { + name: "acceptance:ok.txt", + type: "programmatic", + passed, + cmd: "node check.mjs", + exitCode, + stdoutSha256: sha(stdout), + output: stdout.slice(0, 200), + ...(passed ? {} : { error: "exercised command exited non-zero" }), + }, + { + name: "worktree-clean", + type: "programmatic", + passed: clean, + cmd: "git status --porcelain", + exitCode: status.status ?? 1, + stdoutSha256: sha(status.stdout ?? ""), + }, + ], + feedbackForWorker: passed ? "" : "run `node check.mjs` in your worktree and make it exit 0", + fatalFindings: passed + ? [] + : [{ where: "ok.txt", why: "missing", fix: "write ok.txt", reproduction: "node check.mjs" }], + }; +} + +const repo = makeRepo(); +const runId = "e2e-run"; +const runDir = runDirFor(repo, runId); + +try { + console.log(`\n[1] Plan a 4-task DAG with real worktrees (repo: ${repo})`); + const engine = Engine.create({ + runDir, + sessionId: runId, + topology: "distributed-coding", + tasks: [ + { taskId: "T1", title: "first", dependsOn: [] }, + { taskId: "T2", title: "depends on T1", dependsOn: ["T1"] }, + { taskId: "T3", title: "independent", dependsOn: [] }, + { taskId: "T4", title: "never satisfies its check", dependsOn: [], maxRounds: 2 }, + ], + maxConcurrency: 3, + budgetUsd: 5, + workingDirectory: repo, + }); + + const wm = createWorktreeManager(); + const sentinel = wm.initSession(runId, { cwd: repo }); + check("sentinel worktree exists", existsSync(sentinel.path), sentinel.path); + const worktrees: Record = {}; + for (const task of ["T1", "T2", "T3", "T4"]) { + const info = wm.addAgent(`builder-${task}`, runId, { cwd: repo }); + worktrees[task] = info.path; + check(`worktree for ${task} on branch ${info.branch}`, existsSync(info.path)); + } + check("run directory created", existsSync(runDir)); + + console.log("\n[2] The engine refuses an evidence-free PASS"); + const bogus = { + taskId: "T1", + verifierAgent: "team/verifier", + verifierModel: "e2e/scripted", + timestamp: new Date().toISOString(), + status: "PASS" as const, + checks: [{ name: "read the diff", type: "rubric" as const, passed: true }], + feedbackForWorker: "", + fatalFindings: [], + }; + const rejected = engine.recordRound({ taskId: "T1", report: bogus as never }); + check("rubric-only PASS rejected", !rejected.accepted, rejected.rejection ?? "was accepted"); + check("rejected report did not count as a round", engine.status().rounds === 0); + + console.log("\n[3] Drive the loop until the engine says stop"); + let safety = 0; + const dispatchOrder: string[] = []; + const roundLog: string[] = []; + while (safety < 20) { + safety += 1; + const ready = engine.dispatchable(); + if (ready.length === 0) break; + for (const task of ready) { + engine.dispatch(task.taskId); + dispatchOrder.push(task.taskId); + const wt = worktrees[task.taskId]!; + const attempt = engine.status().tasks.find((t) => t.taskId === task.taskId)?.attempts ?? 0; + // T4's worker never produces ok.txt, so its check really fails twice. + fakeWorker(wt, task.taskId, attempt, task.taskId !== "T4"); + const report = fakeVerifier(wt, task.taskId); + const outcome = engine.recordRound({ taskId: task.taskId, report, costUsd: 0.3 }); + roundLog.push(`${task.taskId}:${outcome.status}`); + check( + `${task.taskId} round ${outcome.attempt}/${outcome.maxRounds} -> ${outcome.status}`, + outcome.accepted, + outcome.rejection, + ); + } + } + console.log(` rounds: ${roundLog.join(" ")}`); + + console.log("\n[4] Assert the run's shape"); + const status = engine.status(); + check("T1 before T2 (dependency order)", dispatchOrder.indexOf("T1") < dispatchOrder.indexOf("T2")); + check( + "independent T3 ran in the first wave", + dispatchOrder.indexOf("T3") < dispatchOrder.indexOf("T2"), + ); + check("T1 completed", status.tasks.find((t) => t.taskId === "T1")?.status === "COMPLETED"); + check("T2 completed", status.tasks.find((t) => t.taskId === "T2")?.status === "COMPLETED"); + check("T3 completed", status.tasks.find((t) => t.taskId === "T3")?.status === "COMPLETED"); + check( + "T4 dead-lettered after 2 rounds", + status.deadletter.includes("T4"), + JSON.stringify(status.deadletter), + ); + check("session reached DONE", status.state === "DONE", status.state); + check("cost accumulated in the log", status.costUsd > 0, `cost=${status.costUsd}`); + check("hash chain intact", status.chain.ok, status.chain.reason); + const completed = status.tasks.filter((t) => t.status === "COMPLETED").length; + check("3 of 4 tasks completed, 1 parked for a human", completed === 3 && status.deadletter.length === 1); + + console.log("\n[5] Evidence is retrievable from the log"); + const reportEvent = engine + .events() + .find((e) => e.type === "verification.report" && e.taskId === "T3"); + const data = (reportEvent?.data ?? {}) as Record; + const checks = (data.report as { checks?: Array> } | undefined)?.checks ?? []; + check("T3's report carries a real command and exit code", checks.some((c) => c["cmd"] === "node check.mjs" && c["exitCode"] === 0)); + check("T3's stdout hash was recorded", typeof checks[0]?.["stdoutSha256"] === "string"); + + console.log("\n[6] Tamper detection and resume"); + const tampered = readEvents(runDir).map((e, i, all) => + i === all.length - 1 ? { ...e, data: { injected: true } } : e, + ); + check("chain check catches an altered event", verifyChain(tampered).ok === false); + + const resumed = Engine.resume(runDir); + const resumedStatus = resumed.status(); + check("resume replays identical task states", resumedStatus.deadletter.join() === status.deadletter.join()); + check("resume replays identical cost", Math.abs(resumedStatus.costUsd - status.costUsd) < 1e-9); + check("resumed run is not dispatchable", resumed.dispatchable().length === 0); + + console.log("\n[7] Budget enforcement"); + const capped = Engine.create({ + runDir: runDirFor(repo, "e2e-budget"), + sessionId: "e2e-budget", + topology: "small-focused", + tasks: [{ taskId: "B1", title: "x", dependsOn: [] }], + budgetUsd: 0.5, + }); + // A genuine FAIL: `node check.mjs` exits 1 in the base repo (no ok.txt). + const realFail = { + taskId: "B1", + verifierAgent: "team/verifier", + verifierModel: "e2e/scripted", + timestamp: new Date().toISOString(), + status: "FAIL" as const, + checks: [ + { + name: "acceptance:ok.txt", + type: "programmatic" as const, + passed: false, + cmd: "node check.mjs", + exitCode: 1, + }, + ], + feedbackForWorker: "write ok.txt", + fatalFindings: [], + }; + capped.recordRound({ taskId: "B1", report: realFail as never, costUsd: 0.75 }); + check("round recorded before the cap was hit", capped.status().rounds === 1); + check("dispatch stops once the cap is hit", capped.dispatchable().length === 0); + check("budget.exhausted was logged", capped.events().some((e) => e.type === "budget.exhausted")); + + console.log("\n[8] Cleanup"); + wm.cleanupSession(runId, { cwd: repo }); + const listed = wm.list(runId, { cwd: repo }); + check("worktrees removed and pruned", listed.length === 0, JSON.stringify(listed)); + check("sentinel worktree directory gone", !existsSync(sentinel.path)); +} finally { + rmSync(repo, { recursive: true, force: true }); +} + +if (failures > 0) { + console.error(`\n✗ e2e failed: ${failures} check(s)\n`); + process.exit(1); +} +console.log("\n✓ e2e passed — engine, worktrees, evidence capture, budget, dead-letter, resume, cleanup.\n"); diff --git a/scripts/plugin-integration.ts b/scripts/plugin-integration.ts new file mode 100644 index 0000000..e5bef64 --- /dev/null +++ b/scripts/plugin-integration.ts @@ -0,0 +1,166 @@ +/** + * Plugin integration test: drives the BUILT bundle (`dist/index.js`) the way + * OpenCode does — calls the returned hooks with the same shapes the host + * sends, and asserts on the config that comes out. + * + * This is the test that would have caught the original P0s: + * - frontmatter dumped into the prompt instead of real config keys + * - the user's/installer's `model` being clobbered + * - `--topology` documented but not parsed + * - hidden subagents that were not hidden (mode defaulted to "all") + * + * Usage: bun scripts/plugin-integration.ts (run `bun run build` first) + */ + +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { TeamPlugin } from "../dist/index.js"; + +let failures = 0; +function check(label: string, condition: boolean, detail?: string): void { + if (condition) { + console.log(` ✓ ${label}`); + } else { + failures += 1; + console.error(` ✗ ${label}${detail ? ` — ${detail}` : ""}`); + } +} + +const projectDir = mkdtempSync(join(tmpdir(), "teamwork-plugin-")); + +const logs: string[] = []; +const ctx = { + directory: projectDir, + worktree: projectDir, + project: { id: "test" }, + serverUrl: new URL("http://localhost"), + $: undefined, + client: { + app: { + log: async (input: { body: { message: string } }) => { + logs.push(input.body.message); + }, + }, + }, +} as never; + +const hooks = await TeamPlugin(ctx, {}); + +// ─── config hook ───────────────────────────────────────────────────── + +const userConfig: Record = { + $schema: "https://opencode.ai/config.json", + theme: "opencode", + agent: { + // What the installer or the user already wrote: a per-role model, plus an + // unrelated agent that must not be disturbed. + "team/sentinel": { model: "anthropic/claude-opus-4-5" }, + "team/verifier": { model: "google/gemini-3-flash" }, + build: { model: "anthropic/claude-sonnet-4-5" }, + }, + custom: { mine: true }, +}; +await hooks.config?.(userConfig as never); + +const agents = userConfig["agent"] as Record>; +console.log("\n[1] Agents are injected with real config keys"); + +check("all 10 team agents registered", Object.keys(agents).filter((k) => k.startsWith("team/")).length === 10); +check("unrelated user agents untouched", agents["build"]?.["model"] === "anthropic/claude-sonnet-4-5"); +check("unrelated top-level keys untouched", (userConfig["custom"] as { mine: boolean }).mine === true); + +const verifier = agents["team/verifier"]!; +check("verifier.permission.edit === deny (enforced, not narrated)", (verifier["permission"] as Record)["edit"] === "deny", JSON.stringify(verifier["permission"])); +check("verifier.permission.task === deny", (verifier["permission"] as Record)["task"] === "deny"); +check("verifier.mode === subagent (was defaulting to 'all')", verifier["mode"] === "subagent"); +check("verifier.temperature injected", verifier["temperature"] === 0); +check("verifier model preserved from user config", verifier["model"] === "google/gemini-3-flash"); +check("verifier prompt has no YAML frontmatter", !String(verifier["prompt"]).startsWith("---")); +check("verifier prompt keeps its body", String(verifier["prompt"]).includes("forcing function")); + +const sentinel = agents["team/sentinel"]!; +check("sentinel.mode === primary", sentinel["mode"] === "primary"); +check("sentinel model preserved", sentinel["model"] === "anthropic/claude-opus-4-5"); +check("sentinel may spawn subagents (task: allow)", (sentinel["permission"] as Record)["task"] === "allow"); +check("sentinel prompt carries the topology index", String(sentinel["prompt"]).includes("Topology library")); +check("topology index points at real files", String(sentinel["prompt"]).includes("long-proof.md")); + +console.log("\n[2] Commands"); +const commands = userConfig["command"] as Record>; +check("7 slash commands registered", Object.keys(commands).length === 7); +check("commands dispatch to team agents", Object.values(commands).every((c) => String(c["agent"]).startsWith("team/"))); +const teamwork = commands["teamwork"]!; +check("no unexpandable template language", !String(teamwork["template"]).includes("{{")); +check("$ARGUMENTS appears exactly once", String(teamwork["template"]).split("$ARGUMENTS").length - 1 === 1); +check("command points the model at the pre-parsed run pointer", String(teamwork["template"]).includes("LATEST.json")); + +console.log("\n[3] Tools are exposed to the model"); +const toolNames = Object.keys(hooks.tool ?? {}); +check("engine tools registered", ["teamwork_plan", "teamwork_dispatch", "teamwork_verify", "teamwork_status", "teamwork_resume"].every((t) => toolNames.includes(t)), toolNames.join(",")); + +console.log("\n[4] command.execute.before parses flags in code"); +await hooks["command.execute.before"]?.( + { command: "teamwork", sessionID: "sess-1", arguments: '--topology long-proof --budget 30 --concurrency=2 "prove X"' }, + { parts: [] } as never, +); +const pointerPath = join(projectDir, ".opencode", "teamwork", "LATEST.json"); +check("run pointer written", existsSync(pointerPath)); +const pointer = JSON.parse(readFileSync(pointerPath, "utf-8")) as Record; +check("topology parsed", pointer["topology"] === "long-proof"); +check("budget parsed", pointer["budgetUsd"] === 30); +check("concurrency parsed from the = form", pointer["maxConcurrency"] === 2); +check("request text keeps the prompt and drops the flags", String(pointer["request"]).includes("prove X") && !String(pointer["request"]).includes("--topology")); +check("session id minted", typeof pointer["sessionId"] === "string" && String(pointer["sessionId"]).length > 10); +const requestMd = readFileSync(join(projectDir, ".opencode", "teamwork", String(pointer["sessionId"]), "request.md"), "utf-8"); +check("request.md written into the run dir", requestMd.includes("prove X")); + +console.log("\n[5] Runtime guard: read-only roles cannot write"); +hooks["chat.message"]?.( { sessionID: "sess-verifier", agent: "team/verifier" } as never, { message: {}, parts: [] } as never); +hooks["chat.message"]?.( { sessionID: "sess-worker", agent: "team/worker" } as never, { message: {}, parts: [] } as never); +let blocked: string | null = null; +try { + await hooks["tool.execute.before"]?.({ tool: "edit", sessionID: "sess-verifier", callID: "c1" } as never, { args: {} } as never); +} catch (err) { + blocked = (err as Error).message; +} +check("verifier edit refused", blocked !== null && blocked.includes("read-only"), blocked ?? "not blocked"); +let workerBlocked = false; +try { + await hooks["tool.execute.before"]?.({ tool: "edit", sessionID: "sess-worker", callID: "c2" } as never, { args: {} } as never); +} catch { + workerBlocked = true; +} +check("worker edit allowed", !workerBlocked); + +const permission = { status: "ask" as string }; +await hooks["permission.ask"]?.({ sessionID: "sess-verifier", type: "edit", title: "edit", id: "p1", messageID: "m1", metadata: {}, time: { created: 0 } } as never, permission as never); +check("permission.ask downgrades to deny for the verifier", permission.status === "deny", permission.status); + +console.log("\n[6] Compaction keeps the run alive"); +// Create a real run so the hook has something to inject. +await hooks["command.execute.before"]?.( + { command: "teamwork", sessionID: "sess-2", arguments: '--session live-run "do the thing"' }, + { parts: [] } as never, +); +const { Engine } = await import("../dist/engine.js"); +Engine.create({ + runDir: join(projectDir, ".opencode", "teamwork", "live-run"), + sessionId: "live-run", + topology: "small-focused", + tasks: [{ taskId: "T1", title: "a", dependsOn: [] }], +}); +const compactContext: string[] = []; +await hooks["experimental.session.compacting"]?.({ sessionID: "sess-2" } as never, { context: compactContext } as never); +const injected = compactContext.join("\n"); +check("plan re-injected on compaction", injected.includes("Active Teamwork run: live-run"), injected.slice(0, 120)); +check("task state re-injected", injected.includes("T1")); +check("budget re-injected", injected.includes("cost")); + +rmSync(projectDir, { recursive: true, force: true }); + +if (failures > 0) { + console.error(`\n✗ plugin integration failed: ${failures} check(s)\n`); + process.exit(1); +} +console.log("\n✓ plugin integration passed — config injection, commands, tools, guard, compaction.\n"); diff --git a/src/artifacts.ts b/src/artifacts.ts index 0d3c5b8..bd6946a 100644 --- a/src/artifacts.ts +++ b/src/artifacts.ts @@ -1,32 +1,45 @@ /** * Typed artifact bus schemas for Teamwork. * - * Every agent communicates via JSON artifacts on disk, not raw - * conversation. The schemas here are the source of truth. The - * Zod schema is exported so plugins / users can validate. + * Every agent communicates via JSON artifacts on disk, not raw conversation. + * The schemas here are the source of truth, and they are now actually + * enforced: the engine's tools validate every spec, plan and verification + * report against them before the artifact is accepted (see `src/tools.ts`, + * `src/engine.ts#validateReport`). Malformed JSON used to pass silently — + * that is how a fabricated `verification_report.json` could be read as a PASS. + * + * Evidence fields (`cmd`, `exitCode`, `stdoutSha256`, `durationMs`) exist so a + * PASS can be tied to a command that actually ran. `validateReport` in the + * engine refuses a PASS without them. */ +import { createHash } from "node:crypto"; import { z } from "zod"; +import { TOPOLOGY_NAMES } from "./policy.js"; // spec.json — the worker's task spec (scoped from the global spec) export const SpecSchema = z.object({ - taskId: z.string(), - sessionId: z.string(), + taskId: z.string().min(1), + sessionId: z.string().min(1), title: z.string(), - description: z.string(), - requirements: z.array(z.string()), // $R_i$ for THIS task - acceptanceCriteria: z.array(z.string()), // markdown checkboxes - verification: z.object({ - programmatic: z.array(z.string()).default([]), // commands to run - adversarial: z.array(z.string()).default([]), // the verifier adds these - rubric: z.array(z.string()).optional(), // for non-code tasks - }), - localChecks: z.array(z.string()).default([]), // what the worker runs itself + description: z.string().default(""), + requirements: z.array(z.string()).default([]), // $R_i$ for THIS task + acceptanceCriteria: z.array(z.string()).default([]), // markdown checkboxes + verification: z + .object({ + programmatic: z.array(z.string()).default([]), // commands to run + adversarial: z.array(z.string()).default([]), // the verifier adds these + rubric: z.array(z.string()).optional(), // for non-code tasks + }) + .default({ programmatic: [], adversarial: [] }), + localChecks: z.array(z.string()).default([]), // what the worker runs itself assignedModel: z.string().optional(), - dependencies: z.array(z.string()).default([]), // taskIds + dependencies: z.array(z.string()).default([]), // taskIds worktreePath: z.string().optional(), - budget: z.object({ maxRounds: z.number().default(4), maxCostUsd: z.number().default(5) }).optional(), - pitfalls: z.array(z.string()).default([]), // from past rounds + budget: z + .object({ maxRounds: z.number().default(4), maxCostUsd: z.number().default(5) }) + .optional(), + pitfalls: z.array(z.string()).default([]), // from past rounds }); export type Spec = z.infer; @@ -38,64 +51,100 @@ export type Spec = z.infer; // verification_report.json — the verifier's structured report export const VerificationCheckSchema = z.object({ - name: z.string(), + name: z.string().min(1), type: z.enum(["programmatic", "adversarial", "rubric"]), passed: z.boolean(), + /** The command that was executed. Required for programmatic/adversarial. */ + cmd: z.string().optional(), + /** Exit code of that command. Required for programmatic/adversarial. */ + exitCode: z.number().int().optional(), + /** sha256 of the captured stdout — evidence can be re-checked later. */ + stdoutSha256: z.string().regex(/^[a-f0-9]{64}$/).optional(), + durationMs: z.number().nonnegative().optional(), output: z.string().max(2048).optional(), error: z.string().optional(), }); export const VerificationReportSchema = z.object({ - taskId: z.string(), - verifierAgent: z.string(), - verifierModel: z.string(), - timestamp: z.string(), + taskId: z.string().min(1), + verifierAgent: z.string().min(1), + verifierModel: z.string().min(1), + timestamp: z.string().min(1), status: z.enum(["PASS", "FAIL"]), - checks: z.array(VerificationCheckSchema), - feedbackForWorker: z.string(), - fatalFindings: z.array(z.object({ - where: z.string(), - why: z.string(), - fix: z.string().optional(), - reproduction: z.string().optional(), - })).default([]), + checks: z.array(VerificationCheckSchema).min(1), + feedbackForWorker: z.string().default(""), + fatalFindings: z + .array( + z.object({ + where: z.string(), + why: z.string(), + fix: z.string().optional(), + reproduction: z.string().optional(), + }), + ) + .default([]), }); export type VerificationReport = z.infer; // plan.dag.json — the task DAG built by the Sentinel export const DagTaskSchema = z.object({ - taskId: z.string(), + taskId: z.string().min(1), title: z.string(), description: z.string().optional(), - assignedWorker: z.string().optional(), // agent name + taskClass: z.string().optional(), + assignedWorker: z.string().optional(), // agent name assignedModel: z.string().optional(), worktreePath: z.string().optional(), dependencies: z.array(z.string()).default([]), - status: z.enum(["PENDING", "RUNNING", "VERIFYING", "COMPLETED", "FAILED"]).default("PENDING"), + status: z + .enum(["PENDING", "DISPATCHED", "VERIFYING", "COMPLETED", "FAILED", "DEADLETTER"]) + .default("PENDING"), acceptanceCriteria: z.array(z.string()).default([]), artifacts: z.array(z.string()).default([]), - metrics: z.object({ - tokensUsed: z.number().default(0), - costUsd: z.number().default(0), - }).optional(), + maxRounds: z.number().int().positive().optional(), + metrics: z + .object({ + tokensUsed: z.number().default(0), + costUsd: z.number().default(0), + }) + .optional(), }); export const PlanDagSchema = z.object({ - sessionId: z.string(), - topology: z.string(), - modelAllocation: z.object({ - sentinel: z.string().optional(), - defaultWorker: z.string().optional(), - verifier: z.string().optional(), + sessionId: z.string().min(1), + topology: z.string().refine((t) => TOPOLOGY_NAMES.includes(t), { + message: `topology must be one of ${TOPOLOGY_NAMES.join(", ")}`, }), + modelAllocation: z + .object({ + sentinel: z.string().optional(), + defaultWorker: z.string().optional(), + verifier: z.string().optional(), + }) + .default({}), budget: z.object({ - maxCostUsd: z.number(), - currentCostUsd: z.number().default(0), + maxCostUsd: z.number().nonnegative(), + currentCostUsd: z.number().nonnegative().default(0), }), - tasks: z.array(DagTaskSchema), + tasks: z.array(DagTaskSchema).min(1), }); export type PlanDag = z.infer; +/** Parse with a readable, actionable error. Used by the engine's tools. */ +export function parseArtifact( + schema: z.ZodType, + value: unknown, + label: string, +): { ok: true; value: T } | { ok: false; error: string } { + const result = schema.safeParse(value); + if (result.success) return { ok: true, value: result.data }; + const issues = result.error.issues + .slice(0, 8) + .map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`) + .join("; "); + return { ok: false, error: `${label} failed schema validation — ${issues}` }; +} + // prompt_draft.md — the Phase-1 spec from the crafter // (Markdown. The structure is in the crafter's prompt.) @@ -110,3 +159,6 @@ export const Artifact = { DagTask: DagTaskSchema, PlanDag: PlanDagSchema, }; + +export const sha256 = (text: string): string => + createHash("sha256").update(text).digest("hex"); diff --git a/src/cli/templates/commands/teamwork.md b/src/cli/templates/commands/teamwork.md index 8a797bd..5a7f8b0 100644 --- a/src/cli/templates/commands/teamwork.md +++ b/src/cli/templates/commands/teamwork.md @@ -1,53 +1,34 @@ --- -description: "Sentinel — main dispatcher. Loads a spec (or asks 1-2 questions), provisions a worktree, builds the DAG, dispatches workers + verifiers, iterates, merges. This is the user-facing /teamwork command." +description: "Sentinel — main dispatcher. Starts a Teamwork run: parse the request, plan the DAG with teamwork_plan, drive teamwork_dispatch / teamwork_verify, then present. This is the user-facing /teamwork command." agent: team/sentinel --- -$ARGUMENTS - -{{if eq .sessionId ""}} - Look in `.opencode/teamwork/` for a recent `prompt_draft.md`. If - you find one, ask the user to confirm the session ID. If not, ask - 1-2 short questions to pick the topology, then either run the - crafter (via /teamwork-craft) or inline the answers and proceed. -{{else}} - Load the spec from `.opencode/teamwork/{{.sessionId}}/prompt_draft.md`. -{{end}} - -Then: - -1. Provision the sentinel worktree: `git worktree add - .opencode/teamwork//worktrees/sentinel -b - teamwork/base-` (or skip if not in a git repo). -2. Build the DAG in `.opencode/teamwork//plan.dag.json`. - Independent tasks run in parallel; dependent ones in topological - order. -3. Dispatch workers in topological order. Each gets: - - Its own worktree - - A scoped `spec.json` - - Returns: `patch.diff` + `summary.md` + cost metrics -4. Dispatch verifiers against each worker's diff. Aggregated - results go in - `.opencode/teamwork//verify/summary.json`. -5. If FAIL, send the verifier's `feedback_for_worker.md` back to - the worker for the next round. Loop until PASS, maxRounds, or - budget exhausted. -6. On full PASS, merge the sentinel worktree back. On partial, - present the partial + the open verification reports. - -Hard caps (override in the spec if the user asks): -- 4 rounds per task (6 for proof, 1 for small-focused, 24h for - massive-proof) -- Default $20 session budget (configurable per run) - -When done, write `.opencode/teamwork//final.md` and -present. Tell the user: -- Which topology you chose -- How many rounds each task took -- Total cost (estimated) -- Path to the final artifact -- Any unresolved verification reports - -The user's full request was: +The user's request: $ARGUMENTS + +Do this: + +1. Read `.opencode/teamwork/LATEST.json`. It contains the pre-parsed session + id, `--topology` / `--budget` / `--concurrency` if the user passed them, + and the request. Read `.opencode/teamwork//request.md` for the + full request text. Flags are already parsed for you — do not re-parse them, + and do not invent a session id. +2. If the request is under-specified, ask at most two questions, or run the + crafter (`/teamwork-craft`) first. Otherwise write the spec to + `.opencode/teamwork//prompt_draft.md`. +3. Pick one topology from the topology library in your context (use the + parsed `--topology` if present). Read that topology's definition file + before planning. +4. Call `teamwork_plan` with the topology, the task list, and — when the user + passed them — `budgetUsd` and `maxConcurrency`. Every task needs + `acceptanceCriteria`, its real `dependsOn`, and a `taskClass`. +5. Loop: `teamwork_dispatch` → one `team/worker` per returned task → one + `team/verifier` per task → `teamwork_verify` with the report path. Keep + going until the engine reports nothing dispatchable. +6. Write `.opencode/teamwork//final.md` and present: + topology chosen, per-task status and rounds, dead-lettered tasks with + their reproductions, total cost against budget, and the run directory path. + +The engine owns dispatch order, retries, the budget and terminal states. +If `teamwork_dispatch` returns nothing, do not dispatch anyway. diff --git a/src/cli/templates/crafter.md b/src/cli/templates/crafter.md index 680a3a4..54b0ceb 100644 --- a/src/cli/templates/crafter.md +++ b/src/cli/templates/crafter.md @@ -7,6 +7,7 @@ permission: edit: ask bash: ask webfetch: allow + task: deny prompt: "{file:./prompts/team/crafter.txt}" color: "#8b5cf6" --- diff --git a/src/cli/templates/falsifier.md b/src/cli/templates/falsifier.md index fa1c2d1..69535d0 100644 --- a/src/cli/templates/falsifier.md +++ b/src/cli/templates/falsifier.md @@ -7,6 +7,7 @@ permission: edit: deny bash: deny webfetch: allow + task: deny prompt: "{file:./prompts/team/falsifier.txt}" color: "#ef4444" hidden: true diff --git a/src/cli/templates/proof-worker.md b/src/cli/templates/proof-worker.md index 81ccbd7..35527e9 100644 --- a/src/cli/templates/proof-worker.md +++ b/src/cli/templates/proof-worker.md @@ -7,6 +7,7 @@ permission: edit: allow bash: allow webfetch: allow + task: deny prompt: "{file:./prompts/team/proof-worker.txt}" color: "#0ea5e9" hidden: true diff --git a/src/cli/templates/proposer.md b/src/cli/templates/proposer.md index 74dfd65..3158cfe 100644 --- a/src/cli/templates/proposer.md +++ b/src/cli/templates/proposer.md @@ -7,6 +7,7 @@ permission: edit: ask bash: ask webfetch: allow + task: deny prompt: "{file:./prompts/team/proposer.txt}" color: "#3b82f6" hidden: true diff --git a/src/cli/templates/scout.md b/src/cli/templates/scout.md index 394a2bc..411c9d2 100644 --- a/src/cli/templates/scout.md +++ b/src/cli/templates/scout.md @@ -7,6 +7,7 @@ permission: edit: deny bash: allow webfetch: allow + task: deny prompt: "{file:./prompts/team/scout.txt}" color: "#06b6d4" hidden: true diff --git a/src/cli/templates/sentinel.md b/src/cli/templates/sentinel.md index 10d360a..a8f7a57 100644 --- a/src/cli/templates/sentinel.md +++ b/src/cli/templates/sentinel.md @@ -1,5 +1,5 @@ --- -description: "Sentinel / coordinator. The user-facing command for /teamwork. Loads a spec from the crafter (or asks the user 1-2 questions if no spec), picks a topology, dispatches the DAG, monitors workers, applies the verification engine, and merges on success. Equivalent to Antigravity's Sentinel role." +description: "Sentinel / coordinator. The user-facing command for /teamwork. Loads the spec, picks a topology, drives the run engine (teamwork_plan / _dispatch / _verify), and merges on success. Equivalent to Antigravity's Sentinel role." mode: primary model: anthropic/claude-sonnet-4-5 temperature: 0.2 @@ -14,91 +14,84 @@ color: "#a855f7" # Teamwork Sentinel -You are the Sentinel — the run-time coordinator. You do not solve the -problem yourself. You: - -1. **Load or generate the spec.** Look in - `.opencode/teamwork//prompt_draft.md`. If missing, ask - the user 1-2 short questions and use the crafter agent to produce - one (or inline the answers if the user wants speed). -2. **Provision a worktree.** `git worktree add - .opencode/teamwork//worktrees/sentinel -b - teamwork/base-` (or skip if not in a git repo). -3. **Pick the topology.** From the spec, route to: - - `small-focused` → 1 builder + 1 reviewer loop - - `large-swarm` → N workers in parallel + 1-2 verifiers + you - - `proof` → 1 strategist + 3-5 searchers + formal checker - - `massive-proof` → meta-coord + 100+ searchers (opt-in) - - `doc-review` → 1 chair + 3 critics + 1 aggregator -4. **Build the DAG.** Map requirements ($R_1 \dots R_N$) onto tasks - with dependencies. Independent tasks run in parallel. The DAG - shape lives in `.opencode/teamwork//plan.dag.json`. -5. **Dispatch workers** in topological order. Each worker: - - Gets its own worktree: `git worktree add .../worktrees/agent-N` - - Gets a `spec.json` (its slice of the spec, scoped to its task) - - Returns a `patch.diff` (the actual work) + a `summary.md` -6. **Adversarial verification.** Verifier agents run against worker - branches. They produce `verification_report.json` per task. You - aggregate into `.opencode/teamwork//verify/summary.json`. -7. **Iterate or merge.** If verification fails, send the verifier's - report back to the worker. Loop until PASS or budget exhausted. -8. **Checkpoint every state transition.** Write - `.opencode/teamwork//state.json` after every step. - Allows resume after token limits or network interruption. -9. **Merge or present.** On full success, merge the sentinel - worktree back to the user's branch. On partial success, present - the partial result and the open verification reports. +You are the Sentinel — the coordinator. You do not solve the problem +yourself, and you do not keep the run in your head. + +## The run is owned by the engine + +Dispatch order, attempt counts, retries, the budget cap and terminal states +are decided by code, not by you. Your job is to call the engine and obey it: + +| tool | when | +|---|---| +| `teamwork_plan` | once, at the start: validate the DAG, create the run, provision worktrees | +| `teamwork_dispatch` | repeatedly: ask which tasks are ready *now* | +| `teamwork_verify` | after each verifier returns: submit the report and read the ruling | +| `teamwork_status` | any time you need the truth about the run | +| `teamwork_resume` | only after a restart or a compaction you didn't expect | + +Never invent an order for tasks. If `teamwork_dispatch` returns nothing, +that is a fact, not a suggestion. + +## Phase 1 — spec + +Read `.opencode/teamwork/LATEST.json`. It holds the session id and any flags +the user passed (`--topology`, `--budget`, `--concurrency`), already parsed. +The request text is in `.opencode/teamwork//request.md`. + +If there is no usable spec, ask the user at most two questions or run the +crafter (`/teamwork-craft`). Then write the spec to +`.opencode/teamwork//prompt_draft.md`. + +## Phase 2 — plan + +1. Pick **one** topology from the topology library in your context. If the + user forced `--topology`, use that. If the spec is ambiguous, ask one + short question — do not guess between `long-proof` and + `massive-proof-swarm`, they differ by two orders of magnitude in cost. +2. Call `teamwork_plan` with the topology and the task list. Give every task + `acceptanceCriteria` (these become the verifier's floor), `dependsOn` + (only real dependencies — overstated ones serialise the whole run), and a + `taskClass` so the policy can pick the model ladder and required checks. +3. Read the returned waves. They are the schedule. + +## Phase 3 — run the loop + +Repeat until `teamwork_dispatch` reports nothing dispatchable: + +1. Call `teamwork_dispatch`. It returns the tasks that are ready. +2. For each returned task, spawn **one** `team/worker` subagent with: + - the spec path it printed, + - the worktree path, + - the required checks for PASS. + The worker edits only its own worktree and writes `patch.diff` + + `summary.md`. +3. Spawn **one** `team/verifier` per task against that worktree and patch. + The verifier runs the actual commands and writes + `verification_report.json` with `cmd` + `exitCode` for every executed + check. +4. Call `teamwork_verify` with the report path. The engine decides: + COMPLETED, retry with the verifier's feedback, or dead-letter after + `maxRounds`. ## What you never do -- Edit code yourself. Workers do that. -- Skip the verifier. Even if a worker is "obviously correct," the - verifier is the forcing function. -- Self-certify. The whole point of the architecture is that - implementers are biased. -- Exceed `budget.maxSessionCostUsd`. Halt non-essential workers - when you approach it; prompt the user before continuing. - -## State machine - -``` -LOAD_SPEC → WORKTREE_INIT → DAG_BUILD → DISPATCH_LOOP - ↓ - {for each task in topological order} - DISPATCH → WORKER_RUN → VERIFIER_RUN - ↓ ↓ - COLLECT_ARTIFACTS PASS/FAIL - ↓ - RETRY (loop) | GOTO NEXT - ↓ - MERGE | PRESENT_PARTIAL -``` - -Every state transition writes to `state.json`. On `RESUME` (the user -restarts the sentinel with the same session-id), you re-read -`state.json` and pick up where you left off. - -## Hard rules - -- **Workers in different worktrees.** Never let two workers edit the - same worktree. The merge is yours. -- **Verifier is read-only on the worker's worktree.** The verifier - sees the diff, not the conversation. This is the anti-leak - guarantee. -- **Token spend is tracked.** Every worker call reports - `tokensUsed` + `costUsd` (estimated). Sum to the session budget. - Halt when 80% of budget hit; ask user. -- **Deadlock guards.** If a worker loops >10 turns without - modifying a file, pause it and request diagnostics. +- Edit the implementation. Workers do that. You may write `final.md`. +- Skip the verifier, or accept a report whose checks carry no exit codes — + the engine will reject it anyway. +- Certify your own work, or a worker's summary as if it were evidence. +- Exceed the budget. The engine refuses to dispatch past the cap; when that + happens, present partial results instead of retrying. +- Re-plan from memory after a compaction. Call `teamwork_status`. ## Termination -Stop on: -- All tasks PASS verification. -- `maxRounds` reached (default 4 per task). -- `maxSessionCostUsd` reached. -- User says stop. -- A task is structurally infeasible (verifier reports FATAL with no - fix path). +Stop when: every task is COMPLETED; the dead-letter queue is non-empty and +nothing else is dispatchable; the budget is exhausted; or the user says stop. + +Write `.opencode/teamwork//final.md` and present: -Write `.opencode/teamwork//final.md` and present to user. +- the topology you used and why, +- per-task status, rounds taken and the checks that passed, +- everything dead-lettered, with the verifier's reproduction, +- total cost against budget, and the run directory. diff --git a/src/cli/templates/synthesizer.md b/src/cli/templates/synthesizer.md index 9b4ea9b..90b0e4c 100644 --- a/src/cli/templates/synthesizer.md +++ b/src/cli/templates/synthesizer.md @@ -7,6 +7,7 @@ permission: edit: ask bash: ask webfetch: allow + task: deny prompt: "{file:./prompts/team/synthesizer.txt}" color: "#10b981" hidden: true diff --git a/src/cli/templates/verifier.md b/src/cli/templates/verifier.md index a2b850d..0e769ac 100644 --- a/src/cli/templates/verifier.md +++ b/src/cli/templates/verifier.md @@ -7,6 +7,7 @@ permission: edit: deny bash: allow webfetch: deny + task: deny prompt: "{file:./prompts/team/verifier.txt}" color: "#f59e0b" hidden: true @@ -64,7 +65,12 @@ the worker's `patch.diff` and return a structured report. "name": "...", "type": "programmatic" | "adversarial" | "rubric", "passed": true, - "output": "..." + "cmd": "pytest -k parser", + "exitCode": 0, + "stdoutSha256": "", + "durationMs": 812, + "output": "... (truncated to 2KB)", + "error": "... (the failing assertion or stack trace, if any)" } ], "feedbackForWorker": "...", @@ -79,10 +85,21 @@ the worker's `patch.diff` and return a structured report. } ``` +`cmd`, `exitCode` and `stdoutSha256` are **required** on every +`programmatic` and `adversarial` check. The engine validates the report and +will reject it otherwise — and a PASS whose checks carry no exit code is +refused outright, because "I read the diff and it looks right" is not a +verification. Compute the hash with: +`sha256sum` / `shasum -a 256` / `Get-FileHash` on the captured output. + ## Hard rules - **Run the actual command, don't read code and judge.** The whole - point of the architecture is that "looks right" is not enough. + point of the architecture is that "looks right" is not enough. Record + `cmd`, `exitCode` and `stdoutSha256` for every executed check. +- **You are read-only.** Your `permission.edit` is `deny` and a runtime guard + enforces it: a write tool call from your session is refused. If a fix is + needed, report it; the sentinel re-dispatches a worker. - **Be strict on acceptance criteria.** If the spec says `- [ ] X` and X isn't met, FAIL. Do not soften. - **Adversarial by default.** The spec's verification is a floor, diff --git a/src/cli/templates/worker.md b/src/cli/templates/worker.md index 542eee5..0b23962 100644 --- a/src/cli/templates/worker.md +++ b/src/cli/templates/worker.md @@ -7,6 +7,7 @@ permission: edit: allow bash: ask webfetch: allow + task: deny prompt: "{file:./prompts/team/worker.txt}" color: "#3b82f6" hidden: true diff --git a/src/engine.ts b/src/engine.ts new file mode 100644 index 0000000..03d1eef --- /dev/null +++ b/src/engine.ts @@ -0,0 +1,763 @@ +/** + * The Teamwork run engine. + * + * This is the module that makes the repo's central claim true: the LLM + * proposes a plan and does the work, but the *run* — dispatch order, + * attempt accounting, budget enforcement, terminal states, the dead-letter + * queue — is owned by code. The engine never talks to a model; agents call + * these functions through the tools in `src/tools.ts`. + * + * Design notes: + * - Single writer per run directory. Transitions append to the event log + * (see `src/events.ts`); `state.json` is a derived snapshot. + * - Every function is pure with respect to the filesystem except the + * append, so the whole scheduler is unit-testable without a model, + * a network, or git. + * - Budget is enforced here, not in a prompt. `dispatchable()` returns + * nothing once the cap is hit, which is the only kind of budget control + * that survives a long run. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + appendEvent, + deriveSession, + readEvents, + verifyChain, + writeSnapshot, + type DerivedSession, + type RunEvent, +} from "./events.js"; +import { + DEFAULT_POLICY, + getTopology, + isTopology, + ladderRung, + resolveRoute, + type Policy, +} from "./policy.js"; + +export type TaskStatus = + | "PENDING" + | "DISPATCHED" + | "VERIFYING" + | "COMPLETED" + | "FAILED" + | "DEADLETTER"; + +export interface DagTask { + taskId: string; + title: string; + description?: string; + /** taskIds this task depends on. Must exist; cycles are rejected. */ + dependsOn?: string[]; + acceptanceCriteria?: string[]; + taskClass?: string; + assignedModel?: string; + maxRounds?: number; + /** Set by the engine on dispatch. */ + worktreePath?: string; +} + +export interface Plan { + sessionId: string; + topology: string; + tasks: DagTask[]; + workingDirectory?: string; + budgetUsd?: number; + maxConcurrency?: number; + haltAtPct?: number; +} + +export interface VerificationCheck { + name: string; + type: "programmatic" | "adversarial" | "rubric"; + passed: boolean; + /** For executed checks: the command, its exit code, its output hash. */ + cmd?: string; + exitCode?: number; + stdoutSha256?: string; + durationMs?: number; + output?: string; + error?: string; +} + +export interface VerificationReport { + taskId: string; + verifierAgent: string; + verifierModel: string; + timestamp: string; + status: "PASS" | "FAIL"; + checks: VerificationCheck[]; + feedbackForWorker: string; + fatalFindings?: Array<{ + where: string; + why: string; + fix?: string; + reproduction?: string; + }>; +} + +export interface RoundInput { + taskId: string; + report: VerificationReport; + costUsd?: number; + tokens?: { input: number; output: number }; + wallClockSeconds?: number; +} + +export interface RoundOutcome { + accepted: boolean; + taskId: string; + status: TaskStatus; + attempt: number; + maxRounds: number; + reason?: string; + /** Included when the report was rejected as evidence-free. */ + rejection?: string; + nextModel?: string; +} + +export interface StatusSummary { + sessionId: string; + topology?: string; + state: DerivedSession["state"]; + costUsd: number; + budgetUsd: number; + pctOfBudget: number; + rounds: number; + tasks: Array<{ + taskId: string; + title: string; + status: TaskStatus; + attempts: number; + dependsOn: string[]; + costUsd: number; + }>; + pending: string[]; + deadletter: string[]; + chain: { ok: boolean; length: number; tipHash: string; reason?: string }; +} + +// ─── Plan validation + scheduling ──────────────────────────────────── + +export interface PlanValidation { + ok: boolean + errors: string[]; +} + +export function validatePlan(plan: Plan): PlanValidation { + const errors: string[] = []; + if (!plan.sessionId) errors.push("sessionId is required"); + if (!isTopology(plan.topology)) { + errors.push( + `unknown topology "${plan.topology}" — expected one of ${["small-focused", "iterative-coding", "distributed-coding", "long-proof", "massive-proof-swarm", "document-review"].join(", ")}`, + ); + } + if (!Array.isArray(plan.tasks) || plan.tasks.length === 0) { + errors.push("plan must contain at least one task"); + return { ok: false, errors }; + } + + const ids = new Set(); + for (const t of plan.tasks) { + if (!t.taskId || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(t.taskId)) { + errors.push(`invalid taskId "${t.taskId}" (use 1-64 chars of [A-Za-z0-9._-])`); + continue; + } + if (ids.has(t.taskId)) errors.push(`duplicate taskId "${t.taskId}"`); + ids.add(t.taskId); + } + for (const t of plan.tasks) { + for (const dep of t.dependsOn ?? []) { + if (!ids.has(dep)) errors.push(`task "${t.taskId}" depends on unknown task "${dep}"`); + if (dep === t.taskId) errors.push(`task "${t.taskId}" depends on itself`); + } + } + const cycle = findCycle(plan.tasks); + if (cycle) errors.push(`dependency cycle: ${cycle.join(" -> ")}`); + + return { ok: errors.length === 0, errors }; +} + +/** Depth-first cycle detection. Returns the cycle path, or null. */ +export function findCycle(tasks: DagTask[]): string[] | null { + const byId = new Map(tasks.map((t) => [t.taskId, t])); + const WHITE = 0; + const GREY = 1; + const BLACK = 2; + const color = new Map(); + const stack: string[] = []; + + const visit = (id: string): string[] | null => { + const state = color.get(id) ?? WHITE; + if (state === BLACK) return null; + if (state === GREY) { + const at = stack.indexOf(id); + return [...stack.slice(at), id]; + } + color.set(id, GREY); + stack.push(id); + for (const dep of byId.get(id)?.dependsOn ?? []) { + if (!byId.has(dep)) continue; + const found = visit(dep); + if (found) return found; + } + stack.pop(); + color.set(id, BLACK); + return null; + }; + + for (const t of tasks) { + const found = visit(t.taskId); + if (found) return found; + } + return null; +} + +/** + * Group tasks into dispatch waves. Wave N contains every task whose + * dependencies are all in waves < N. Independent tasks land in the same + * wave, which is what makes them parallel. + */ +export function dispatchWaves(plan: Plan): string[][] { + const remaining = new Map(plan.tasks.map((t) => [t.taskId, new Set(t.dependsOn ?? [])])); + const waves: string[][] = []; + const placed = new Set(); + + while (remaining.size > 0) { + const wave: string[] = []; + for (const [id, deps] of remaining) { + let ready = true; + for (const d of deps) { + if (!placed.has(d)) ready = false; + } + if (ready) wave.push(id); + } + if (wave.length === 0) break; // cycle — validatePlan rejects these first + for (const id of wave) { + remaining.delete(id); + placed.add(id); + } + waves.push(wave); + } + return waves; +} + +// ─── Verification report validation (structure, not vibes) ─────────── + +/** + * A PASS is a claim that something executed. This is the code-level + * enforcement of the verifier's own rule ("run the actual command, don't + * read code and judge"): a PASS backed only by rubric opinions, or by + * programmatic checks with no recorded command/exit code, is rejected + * outright and the round does not count as verified. + */ +export function validateReport( + report: VerificationReport, + requiredChecks: string[] = [], +): { ok: boolean; errors: string[] } { + const errors: string[] = []; + if (!report.taskId) errors.push("report.taskId is required"); + if (report.status !== "PASS" && report.status !== "FAIL") { + errors.push('report.status must be "PASS" or "FAIL"'); + } + if (!Array.isArray(report.checks) || report.checks.length === 0) { + errors.push("report.checks must contain at least one check"); + return { ok: false, errors }; + } + + for (const check of report.checks) { + if (!check.name) errors.push("every check needs a name"); + if (!["programmatic", "adversarial", "rubric"].includes(check.type)) { + errors.push(`check "${check.name}" has invalid type "${check.type}"`); + } + const executed = check.type === "programmatic" || check.type === "adversarial"; + if (executed && (typeof check.exitCode !== "number" || !check.cmd)) { + errors.push( + `check "${check.name}" (${check.type}) must record cmd + exitCode — evidence-free checks cannot support a verdict`, + ); + } + if (check.type === "programmatic" && check.passed && check.exitCode !== 0) { + errors.push(`check "${check.name}" passed but exitCode is ${check.exitCode}`); + } + if (check.type === "programmatic" && !check.passed && check.exitCode === 0) { + errors.push(`check "${check.name}" failed but exitCode is 0`); + } + } + + if (report.status === "PASS") { + const executed = report.checks.filter( + (c) => (c.type === "programmatic" || c.type === "adversarial") && typeof c.exitCode === "number", + ); + if (executed.length === 0) { + errors.push( + "a PASS requires at least one executed check with a recorded exit code — reading the diff and judging is not a verification", + ); + } + if (executed.some((c) => !c.passed)) { + errors.push("a PASS cannot contain a failed executed check"); + } + const names = new Set(report.checks.map((c) => c.name)); + for (const required of requiredChecks) { + if (!names.has(required) && !report.checks.some((c) => c.type === required.split(":")[0])) { + errors.push(`required check "${required}" is missing from the report`); + } + } + } + + return { ok: errors.length === 0, errors }; +} + +// ─── The engine ────────────────────────────────────────────────────── + +export interface EngineOptions { + runDir: string; + sessionId: string; + topology: string; + tasks: DagTask[]; + policy?: Policy; + budgetUsd?: number; + maxConcurrency?: number; + haltAtPct?: number; + workingDirectory?: string; + now?: () => string; +} + +export class Engine { + readonly runDir: string; + readonly sessionId: string; + readonly topology: string; + readonly tasks: DagTask[]; + readonly policy: Policy; + readonly budgetUsd: number; + readonly maxConcurrency: number; + readonly haltAtPct: number; + readonly workingDirectory: string; + private readonly now: () => string; + + private constructor(opts: EngineOptions) { + this.runDir = opts.runDir; + this.sessionId = opts.sessionId; + this.topology = opts.topology; + this.tasks = opts.tasks; + this.policy = opts.policy ?? DEFAULT_POLICY; + const topo = getTopology(opts.topology); + this.budgetUsd = + opts.budgetUsd ?? this.policy.budget.perSessionUsd ?? topo?.defaultMaxCostUsd ?? 20; + this.maxConcurrency = opts.maxConcurrency ?? topo?.defaultConcurrency ?? 2; + this.haltAtPct = opts.haltAtPct ?? this.policy.budget.haltAtPct ?? 80; + this.workingDirectory = opts.workingDirectory ?? process.cwd(); + this.now = opts.now ?? (() => new Date().toISOString()); + } + + /** Start a new run: validates the plan, then writes plan + first events. */ + static create(opts: EngineOptions): Engine { + const engine = new Engine(opts); + const validation = validatePlan({ + sessionId: engine.sessionId, + topology: engine.topology, + tasks: engine.tasks, + budgetUsd: engine.budgetUsd, + maxConcurrency: engine.maxConcurrency, + }); + if (!validation.ok) { + throw new Error(`invalid plan:\n - ${validation.errors.join("\n - ")}`); + } + mkdirSync(engine.runDir, { recursive: true }); + appendEvent(engine.runDir, { + type: "session.start", + sessionId: engine.sessionId, + ts: engine.now(), + data: { + topology: engine.topology, + budgetUsd: engine.budgetUsd, + haltAtPct: engine.haltAtPct, + maxConcurrency: engine.maxConcurrency, + workingDirectory: engine.workingDirectory, + policyVersion: engine.policy.version, + }, + }); + appendEvent(engine.runDir, { + type: "plan.written", + sessionId: engine.sessionId, + ts: engine.now(), + data: { + tasks: engine.tasks.map((t) => ({ + taskId: t.taskId, + title: t.title, + dependsOn: t.dependsOn ?? [], + taskClass: t.taskClass ?? null, + assignedModel: t.assignedModel ?? null, + })), + }, + }); + writeFileSync( + join(engine.runDir, "plan.dag.json"), + `${JSON.stringify( + { + sessionId: engine.sessionId, + topology: engine.topology, + modelAllocation: { sentinel: undefined, defaultWorker: undefined, verifier: undefined }, + budget: { maxCostUsd: engine.budgetUsd, currentCostUsd: 0 }, + tasks: engine.tasks.map((t) => ({ + taskId: t.taskId, + title: t.title, + ...(t.description !== undefined ? { description: t.description } : {}), + ...(t.assignedModel !== undefined ? { assignedModel: t.assignedModel } : {}), + dependencies: t.dependsOn ?? [], + status: "PENDING" as const, + acceptanceCriteria: t.acceptanceCriteria ?? [], + artifacts: [], + })), + }, + null, + 2, + )}\n`, + "utf-8", + ); + engine.snapshot(); + return engine; + } + + /** Rehydrate from the event log. Throws if the log is missing/corrupt. */ + static resume(runDir: string, opts?: { policy?: Policy }): Engine { + const events = readEvents(runDir); + if (events.length === 0) throw new Error(`no events in ${runDir}`); + const chain = verifyChain(events); + if (!chain.ok) { + throw new Error(`event log is corrupt at seq ${chain.brokenAt}: ${chain.reason}`); + } + const derived = deriveSession(events); + const planPath = join(runDir, "plan.dag.json"); + let tasks: DagTask[] = []; + if (existsSync(planPath)) { + const plan = JSON.parse(readFileSync(planPath, "utf-8")) as { + tasks?: Array>; + }; + tasks = (plan.tasks ?? []).map((t) => ({ + taskId: String(t.taskId), + title: String(t.title ?? t.taskId), + ...(typeof t.description === "string" ? { description: t.description } : {}), + dependsOn: Array.isArray(t.dependencies) ? (t.dependencies as string[]) : [], + acceptanceCriteria: Array.isArray(t.acceptanceCriteria) + ? (t.acceptanceCriteria as string[]) + : [], + ...(typeof t.taskClass === "string" ? { taskClass: t.taskClass } : {}), + })); + } + const startEvent = events.find((e) => e.type === "session.start"); + const startData = (startEvent?.data ?? {}) as Record; + return new Engine({ + runDir, + sessionId: derived.sessionId, + topology: derived.topology ?? String(startData.topology ?? "small-focused"), + tasks, + ...(opts?.policy ? { policy: opts.policy } : {}), + budgetUsd: derived.budgetUsd, + haltAtPct: derived.haltAtPct, + ...(typeof startData.maxConcurrency === "number" + ? { maxConcurrency: startData.maxConcurrency } + : {}), + ...(typeof startData.workingDirectory === "string" + ? { workingDirectory: startData.workingDirectory } + : {}), + }); + } + + events(): RunEvent[] { + return readEvents(this.runDir); + } + + status(): StatusSummary { + const events = this.events(); + const derived = deriveSession(events); + const chain = verifyChain(events); + const tasks = derived.order.map((id) => { + const t = derived.tasks[id]!; + return { + taskId: t.taskId, + title: t.title, + status: t.status as TaskStatus, + attempts: t.attempts, + dependsOn: t.dependsOn, + costUsd: Number(t.costUsd.toFixed(4)), + }; + }); + return { + sessionId: derived.sessionId, + ...(derived.topology ? { topology: derived.topology } : {}), + state: derived.state, + costUsd: Number(derived.costUsd.toFixed(4)), + budgetUsd: derived.budgetUsd, + pctOfBudget: derived.budgetUsd > 0 ? (derived.costUsd / derived.budgetUsd) * 100 : 0, + rounds: derived.rounds, + tasks, + pending: tasks.filter((t) => t.status === "PENDING").map((t) => t.taskId), + deadletter: derived.deadletter, + chain: { + ok: chain.ok, + length: chain.length, + tipHash: chain.tipHash, + ...(chain.reason ? { reason: chain.reason } : {}), + }, + }; + } + + /** Persist the derived snapshot next to the log. */ + snapshot(): DerivedSession { + const derived = deriveSession(this.events()); + writeSnapshot(this.runDir, derived); + return derived; + } + + /** True when the budget cap (not the warning threshold) is reached. */ + budgetExhausted(): boolean { + const derived = deriveSession(this.events()); + return derived.budgetUsd > 0 && derived.costUsd >= derived.budgetUsd; + } + + haltReason(): string | null { + const derived = deriveSession(this.events()); + if (derived.budgetUsd > 0 && derived.costUsd >= derived.budgetUsd) { + return `budget exhausted ($${derived.costUsd.toFixed(2)} of $${derived.budgetUsd.toFixed(2)})`; + } + const status = this.status(); + const open = status.tasks.filter( + (t) => t.status !== "COMPLETED" && t.status !== "FAILED" && t.status !== "DEADLETTER", + ); + const pending = status.pending.length; + if (open.length === 0) return null; + if (pending === 0 && open.every((t) => t.status === "DEADLETTER" || t.status === "FAILED")) { + return "no dispatchable tasks remain"; + } + return null; + } + + /** + * Tasks ready to run: every dependency COMPLETED, task not terminal, + * within the concurrency cap, and under budget. This is the function the + * sentinel asks instead of deciding for itself. + */ + dispatchable(limit?: number): DagTask[] { + const derived = deriveSession(this.events()); + if (derived.budgetUsd > 0 && derived.costUsd >= derived.budgetUsd) { + appendEvent(this.runDir, { + type: "budget.exhausted", + sessionId: this.sessionId, + ts: this.now(), + data: { costUsd: derived.costUsd, budgetUsd: derived.budgetUsd }, + }); + return []; + } + + const inFlight = Object.values(derived.tasks).filter( + (t) => t.status === "DISPATCHED" || t.status === "VERIFYING", + ).length; + const capacity = Math.max(0, (limit ?? this.maxConcurrency) - inFlight); + if (capacity === 0) return []; + + const ready = this.tasks.filter((t) => { + const state = derived.tasks[t.taskId]; + if (!state) return (t.dependsOn ?? []).length === 0; + if (state.status !== "PENDING") return false; + return (t.dependsOn ?? []).every((d) => derived.tasks[d]?.status === "COMPLETED"); + }); + + return ready.slice(0, capacity); + } + + /** Model for this task's next attempt: cheapest rung first, escalate on failure. */ + modelFor(taskId: string, attempt: number): string | undefined { + const task = this.tasks.find((t) => t.taskId === taskId); + if (!task) return undefined; + const rule = resolveRoute(this.policy, task.taskClass); + return ladderRung(rule, attempt, task.assignedModel); + } + + requiredChecksFor(taskId: string): string[] { + const task = this.tasks.find((t) => t.taskId === taskId); + if (!task) return []; + return resolveRoute(this.policy, task.taskClass).requiredChecks ?? []; + } + + /** Record that a task was handed to a worker. */ + dispatch(taskId: string, meta?: Record): void { + const task = this.tasks.find((t) => t.taskId === taskId); + if (!task) throw new Error(`unknown task ${taskId}`); + const attempt = deriveSession(this.events()).tasks[taskId]?.attempts ?? 0; + appendEvent(this.runDir, { + type: "task.dispatched", + sessionId: this.sessionId, + taskId, + ts: this.now(), + data: { + attempt, + model: meta?.model ?? this.modelFor(taskId, attempt) ?? null, + worktreePath: task.worktreePath ?? null, + ...meta, + }, + }); + this.snapshot(); + } + + /** + * Record a verifier round. This is where retries, dead-lettering, budget + * warnings and terminal states are decided — all in code. + */ + recordRound(input: RoundInput): RoundOutcome { + const derived = deriveSession(this.events()); + const task = this.tasks.find((t) => t.taskId === input.taskId); + if (!task) { + return { + accepted: false, + taskId: input.taskId, + status: "FAILED", + attempt: 0, + maxRounds: 0, + rejection: `unknown task ${input.taskId}`, + }; + } + const maxRounds = task.maxRounds ?? getTopology(this.topology)?.defaultMaxRounds ?? 4; + + const validation = validateReport(input.report, this.requiredChecksFor(input.taskId)); + if (!validation.ok) { + return { + accepted: false, + taskId: input.taskId, + status: (derived.tasks[input.taskId]?.status ?? "PENDING") as TaskStatus, + attempt: derived.tasks[input.taskId]?.attempts ?? 0, + maxRounds, + rejection: validation.errors.join("; "), + }; + } + + const attempt = (derived.tasks[input.taskId]?.attempts ?? 0) + 1; + appendEvent(this.runDir, { + type: "verification.report", + sessionId: this.sessionId, + taskId: input.taskId, + ts: this.now(), + data: { + attempt, + status: input.report.status, + report: { + status: input.report.status, + verifierAgent: input.report.verifierAgent, + verifierModel: input.report.verifierModel, + checks: input.report.checks.map((c) => ({ + name: c.name, + type: c.type, + passed: c.passed, + cmd: c.cmd ?? null, + exitCode: c.exitCode ?? null, + stdoutSha256: c.stdoutSha256 ?? null, + })), + fatalFindings: input.report.fatalFindings ?? [], + }, + costUsd: input.costUsd ?? 0, + tokens: input.tokens ?? { input: 0, output: 0 }, + wallClockSeconds: input.wallClockSeconds ?? 0, + }, + }); + + let status: TaskStatus; + let reason: string | undefined; + if (input.report.status === "PASS") { + status = "COMPLETED"; + appendEvent(this.runDir, { + type: "task.completed", + sessionId: this.sessionId, + taskId: input.taskId, + ts: this.now(), + data: { attempt, costUsd: input.costUsd ?? 0 }, + }); + } else if (attempt >= maxRounds) { + status = "DEADLETTER"; + reason = `maxRounds (${maxRounds}) exhausted`; + appendEvent(this.runDir, { + type: "task.deadletter", + sessionId: this.sessionId, + taskId: input.taskId, + ts: this.now(), + data: { attempt, maxRounds, reason }, + }); + } else { + status = "PENDING"; + reason = `round ${attempt}/${maxRounds} failed — re-dispatch`; + appendEvent(this.runDir, { + type: "task.round", + sessionId: this.sessionId, + taskId: input.taskId, + ts: this.now(), + data: { attempt, maxRounds, pass: false, costUsd: input.costUsd ?? 0 }, + }); + } + + // Budget bookkeeping + terminal state. + const after = deriveSession(this.events()); + const pct = after.budgetUsd > 0 ? (after.costUsd / after.budgetUsd) * 100 : 0; + if (after.budgetUsd > 0 && pct >= this.haltAtPct && after.costUsd < after.budgetUsd) { + appendEvent(this.runDir, { + type: "budget.warning", + sessionId: this.sessionId, + ts: this.now(), + data: { costUsd: after.costUsd, budgetUsd: after.budgetUsd, pct: Number(pct.toFixed(1)) }, + }); + } + + const allTerminal = after.order.every((id) => { + const s = after.tasks[id]?.status; + return s === "COMPLETED" || s === "FAILED" || s === "DEADLETTER"; + }); + if (allTerminal && after.order.length > 0) { + appendEvent(this.runDir, { + type: "session.done", + sessionId: this.sessionId, + ts: this.now(), + data: { + completed: after.order.filter((id) => after.tasks[id]?.status === "COMPLETED").length, + deadletter: after.deadletter, + costUsd: after.costUsd, + }, + }); + } + this.snapshot(); + + return { + accepted: true, + taskId: input.taskId, + status, + attempt, + maxRounds, + ...(reason ? { reason } : {}), + ...(status === "PENDING" ? { nextModel: this.modelFor(input.taskId, attempt) } : {}), + }; + } + + /** Park every remaining dispatchable task (user stop, unrecoverable error). */ + abort(reason: string): void { + const derived = deriveSession(this.events()); + for (const id of derived.order) { + const t = derived.tasks[id]; + if (!t) continue; + if (t.status === "PENDING" || t.status === "DISPATCHED" || t.status === "VERIFYING") { + appendEvent(this.runDir, { + type: "task.deadletter", + sessionId: this.sessionId, + taskId: id, + ts: this.now(), + data: { reason: `aborted: ${reason}` }, + }); + } + } + appendEvent(this.runDir, { + type: "session.error", + sessionId: this.sessionId, + ts: this.now(), + data: { reason }, + }); + this.snapshot(); + } +} diff --git a/src/events.ts b/src/events.ts new file mode 100644 index 0000000..fbcd9d4 --- /dev/null +++ b/src/events.ts @@ -0,0 +1,332 @@ +/** + * Append-only, hash-chained run event log. + * + * This is the source of truth for a Teamwork run. `state.json` is now a + * DERIVED snapshot (see deriveSession) — the LLM never owns the record of + * what happened, because an LLM-written snapshot can silently skip or + * misreport transitions. + * + * Properties: + * - append-only: events are never rewritten, only appended + * - tamper-evident: each event carries prevHash + hash (sha256 over the + * canonical JSON of the event with its hash removed) + * - replayable: deriveSession(events) reconstructs the run state, so a + * crashed/resumed run can be rebuilt from disk + * + * One event per line, JSONL. `events.jsonl` lives in the run directory + * (`.opencode/teamwork//`). + */ + +import { createHash } from "node:crypto"; +import { + appendFileSync, + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; + +export const GENESIS_HASH = "0".repeat(64); + +export type EventType = + | "session.start" + | "plan.written" + | "worktree.created" + | "task.dispatched" + | "task.round" + | "verification.report" + | "task.completed" + | "task.failed" + | "task.deadletter" + | "budget.warning" + | "budget.exhausted" + | "session.done" + | "session.error"; + +export interface RunEvent { + seq: number; + ts: string; + type: EventType; + sessionId: string; + taskId?: string; + data?: Record; + prevHash: string; + hash: string; +} + +export interface TaskDerived { + taskId: string; + title: string; + status: "PENDING" | "DISPATCHED" | "VERIFYING" | "COMPLETED" | "FAILED" | "DEADLETTER"; + attempts: number; + costUsd: number; + dependsOn: string[]; + lastReport?: Record; +} + +export interface DerivedSession { + sessionId: string; + topology?: string; + state: "PENDING" | "RUNNING" | "DONE" | "ERROR"; + tasks: Record; + order: string[]; + budgetUsd: number; + haltAtPct: number; + costUsd: number; + rounds: number; + deadletter: string[]; + lastSeq: number; + tipHash: string; +} + +const TIP_CACHE = new Map(); + +export function eventLogPath(runDir: string): string { + return join(runDir, "events.jsonl"); +} + +/** Canonical JSON: keys sorted, so the hash is stable across engines. */ +function canonical(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null"; + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + const entries = Object.entries(value as Record) + .filter(([, v]) => v !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`; +} + +export function hashEvent(prevHash: string, event: Omit): string { + return createHash("sha256").update(`${prevHash}\n${canonical(event)}`).digest("hex"); +} + +function readTip(runDir: string): RunEvent | null { + const path = eventLogPath(runDir); + const key = path; + const cached = TIP_CACHE.get(key); + if (cached) return cached; + if (!existsSync(path)) return null; + const raw = readFileSync(path, "utf-8"); + const lines = raw.split("\n").filter((l) => l.trim().length > 0); + const last = lines[lines.length - 1]; + if (!last) return null; + const parsed = JSON.parse(last) as RunEvent; + TIP_CACHE.set(key, parsed); + return parsed; +} + +export interface AppendInput { + type: EventType; + sessionId: string; + taskId?: string; + data?: Record; + ts?: string; +} + +/** + * Append one event and return it (with seq/prevHash/hash filled in). + * Sequential by construction: the hash chain is built from the previous + * event's hash, so two concurrent appends to the same run must be + * serialized by the caller (the Engine does this — it is single-writer + * per run, which is also why only one process may own a run directory). + */ +export function appendEvent(runDir: string, input: AppendInput): RunEvent { + mkdirSync(runDir, { recursive: true }); + const tip = readTip(runDir); + const prevHash = tip?.hash ?? GENESIS_HASH; + const seq = (tip?.seq ?? 0) + 1; + + const draft: Omit = { + seq, + ts: input.ts ?? new Date().toISOString(), + type: input.type, + sessionId: input.sessionId, + ...(input.taskId !== undefined ? { taskId: input.taskId } : {}), + ...(input.data !== undefined ? { data: input.data } : {}), + prevHash, + }; + const event: RunEvent = { ...draft, hash: hashEvent(prevHash, draft) }; + appendFileSync(eventLogPath(runDir), `${JSON.stringify(event)}\n`, "utf-8"); + TIP_CACHE.set(eventLogPath(runDir), event); + return event; +} + +export function readEvents(runDir: string): RunEvent[] { + const path = eventLogPath(runDir); + if (!existsSync(path)) return []; + return readFileSync(path, "utf-8") + .split("\n") + .filter((l) => l.trim().length > 0) + .map((l) => JSON.parse(l) as RunEvent); +} + +export interface ChainCheck { + ok: boolean; + brokenAt?: number; + reason?: string; + length: number; + tipHash: string; +} + +/** Verify the hash chain end to end. A broken chain means the log was edited. */ +export function verifyChain(events: RunEvent[]): ChainCheck { + let prev = GENESIS_HASH; + let seq = 0; + for (const ev of events) { + seq += 1; + if (ev.seq !== seq) { + return { ok: false, brokenAt: ev.seq, reason: `expected seq ${seq}, found ${ev.seq}`, length: events.length, tipHash: prev }; + } + if (ev.prevHash !== prev) { + return { ok: false, brokenAt: ev.seq, reason: "prevHash does not match the previous event", length: events.length, tipHash: prev }; + } + const { hash, ...rest } = ev; + const expect = hashEvent(prev, rest as Omit); + if (expect !== hash) { + return { ok: false, brokenAt: ev.seq, reason: "hash mismatch (event was modified)", length: events.length, tipHash: prev }; + } + prev = hash; + } + const tip = events[events.length - 1]; + return { ok: true, length: events.length, tipHash: tip?.hash ?? GENESIS_HASH }; +} + +/** + * Replay the log into run state. This is what the sentinel, the CLI and + * the TUI all read — never `state.json`. + */ +export function deriveSession(events: RunEvent[]): DerivedSession { + const first = events[0]; + const sessionId = first?.sessionId ?? ""; + const tasks: Record = {}; + const order: string[] = []; + let topology: string | undefined; + let budgetUsd = 0; + let haltAtPct = 80; + let costUsd = 0; + let rounds = 0; + let state: DerivedSession["state"] = "PENDING"; + + const ensure = (taskId: string): TaskDerived => { + const existing = tasks[taskId]; + if (existing) return existing; + const created: TaskDerived = { + taskId, + title: taskId, + status: "PENDING", + attempts: 0, + costUsd: 0, + dependsOn: [], + }; + tasks[taskId] = created; + order.push(taskId); + return created; + }; + + for (const ev of events) { + const d = (ev.data ?? {}) as Record; + switch (ev.type) { + case "session.start": + state = "RUNNING"; + if (typeof d.topology === "string") topology = d.topology; + if (typeof d.budgetUsd === "number") budgetUsd = d.budgetUsd; + if (typeof d.haltAtPct === "number") haltAtPct = d.haltAtPct; + break; + case "plan.written": { + const raw = d.tasks; + if (Array.isArray(raw)) { + for (const t of raw as Array>) { + const id = String(t.taskId ?? ""); + if (!id) continue; + const task = ensure(id); + if (typeof t.title === "string") task.title = t.title; + task.dependsOn = Array.isArray(t.dependsOn) ? (t.dependsOn as string[]) : []; + } + } + break; + } + case "task.dispatched": { + if (!ev.taskId) break; + const task = ensure(ev.taskId); + task.status = "DISPATCHED"; + break; + } + case "verification.report": { + // A round is one verifier verdict. `task.round` is an informational + // follow-up on the same round, so it must not be counted again. + if (!ev.taskId) break; + const task = ensure(ev.taskId); + rounds += 1; + if (typeof d.costUsd === "number") { + task.costUsd += d.costUsd; + costUsd += d.costUsd; + } + if (typeof d.attempt === "number") task.attempts = d.attempt; + if (d.report && typeof d.report === "object") { + task.lastReport = d.report as Record; + } + task.status = "VERIFYING"; + break; + } + case "task.round": { + if (!ev.taskId) break; + const task = ensure(ev.taskId); + if (typeof d.attempt === "number") task.attempts = d.attempt; + task.status = "PENDING"; + break; + } + case "task.completed": + if (ev.taskId) ensure(ev.taskId).status = "COMPLETED"; + break; + case "task.failed": + if (ev.taskId) ensure(ev.taskId).status = "FAILED"; + break; + case "task.deadletter": + if (ev.taskId) ensure(ev.taskId).status = "DEADLETTER"; + break; + case "budget.exhausted": + if (typeof d.costUsd === "number") costUsd = Math.max(costUsd, d.costUsd); + break; + case "session.done": + state = "DONE"; + break; + case "session.error": + state = "ERROR"; + break; + default: + break; + } + } + + const deadletter = order.filter((id) => tasks[id]?.status === "DEADLETTER"); + const tip = events[events.length - 1]; + return { + sessionId, + ...(topology ? { topology } : {}), + state, + tasks, + order, + budgetUsd, + haltAtPct, + costUsd, + rounds, + deadletter, + lastSeq: tip?.seq ?? 0, + tipHash: tip?.hash ?? GENESIS_HASH, + }; +} + +/** Write the derived snapshot. Derived data only — never hand-edited. */ +export function writeSnapshot(runDir: string, derived: DerivedSession): void { + mkdirSync(runDir, { recursive: true }); + writeFileSync( + join(runDir, "state.json"), + `${JSON.stringify(derived, null, 2)}\n`, + "utf-8", + ); +} + +/** Test/resume helper: forget the cached tip so the file is re-read. */ +export function resetTipCache(): void { + TIP_CACHE.clear(); +} diff --git a/src/guard.ts b/src/guard.ts new file mode 100644 index 0000000..d925007 --- /dev/null +++ b/src/guard.ts @@ -0,0 +1,121 @@ +/** + * Role enforcement. + * + * OpenCode's agent `permission` config is the first line of defence, but it + * depends on the frontmatter being parsed into real config keys (see + * `agentConfigFor` in templates.ts). This module is the second line: a + * session → role registry plus hooks that refuse write tools for roles whose + * contract is read-only, so "the verifier cannot edit code" is true even if a + * user's older OpenCode build ignores part of the permission block. + * + * Roles come from the agent templates' `permission.edit` field, so there is + * exactly one place to declare who may write. + */ + +import { getAllAgentBodies } from "./templates.js"; +import { agentPermissions, type AgentPermissions } from "./templates.js"; + +export type Role = + | "team/crafter" + | "team/sentinel" + | "team/worker" + | "team/proof-worker" + | "team/verifier" + | "team/orchestrator" + | "team/proposer" + | "team/falsifier" + | "team/synthesizer" + | "team/scout" + | string; + +/** Tools that mutate the workspace. Blocked for read-only roles. */ +export const WRITE_TOOLS: readonly string[] = [ + "edit", + "write", + "patch", + "apply_patch", + "multiedit", + "multi_edit", + "notebook_edit", +]; + +/** Agents allowed to drive the run engine. Everything else is a worker/critic. */ +export const ENGINE_ROLES: readonly string[] = ["team/sentinel", "team/orchestrator"]; + +export interface RolePolicy { + /** May this role call write tools? */ + canEdit: boolean; + /** May this role dispatch tasks / drive the DAG? */ + canOrchestrate: boolean; + /** May this role run shell commands? */ + canBash: boolean; +} + +/** Read the declared permissions straight from the agent templates. */ +export function policyForRole(role: string, effective?: AgentPermissions): RolePolicy { + const short = role.startsWith("team/") ? role.slice("team/".length) : role; + const declared = effective ?? agentPermissions(short); + const permission = declared ?? { edit: "ask", bash: "ask", webfetch: "allow", task: "allow" }; + return { + canEdit: permission.edit !== "deny", + canBash: permission.bash !== "deny", + canOrchestrate: ENGINE_ROLES.includes(role), + }; +} + +export function isWriteTool(toolName: string): boolean { + return WRITE_TOOLS.includes(toolName.toLowerCase()); +} + +/** Session → role registry, populated from chat.message + tool contexts. */ +export class RoleRegistry { + private readonly roles = new Map(); + private readonly effective = new Map(); + + remember(sessionID: string, agent: string | undefined): void { + if (sessionID && agent) this.roles.set(sessionID, agent); + } + + roleFor(sessionID: string): string | undefined { + return this.roles.get(sessionID); + } + + /** + * Record the permissions actually injected into the config (after any user + * override), so the runtime guard agrees with what OpenCode enforces. + */ + setPermissions(role: string, permissions: AgentPermissions): void { + this.effective.set(role, permissions); + } + + effectivePermissions(role: string): AgentPermissions | undefined { + return this.effective.get(role); + } + + /** + * Decide whether a write tool call should be blocked. Unknown sessions fail + * open (we don't want to break a user's normal `build` agent), but known + * read-only roles fail closed. + */ + blockReason(sessionID: string, toolName: string): string | null { + const role = this.roles.get(sessionID); + if (!role) return null; + if (!role.startsWith("team/")) return null; + if (!isWriteTool(toolName)) return null; + const declared = this.effective.get(role) ?? agentPermissions(role.slice("team/".length)); + if (!declared || declared.edit !== "deny") return null; + return ( + `teamwork: ${role} is declared read-only (permission.edit: deny) and may not call "${toolName}". ` + + `Report the finding to the sentinel instead; the sentinel re-dispatches a worker.` + ); + } + + /** Agents that may edit, for diagnostics. */ + roster(): Record { + const out: Record = {}; + for (const body of Object.keys(getAllAgentBodies())) { + out[`team/${body}`] = policyForRole(`team/${body}`, this.effective.get(`team/${body}`)); + } + return out; + } +} diff --git a/src/index.ts b/src/index.ts index f4a35d5..117ca3d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,42 +1,176 @@ -import type { Plugin } from "@opencode-ai/plugin"; -import { - getAllAgentBodies, - getAllCommands, -} from "./templates.js"; - /** - * opencode-teamwork plugin entry. Two responsibilities: + * opencode-teamwork plugin entry. * - * 1. Register the 6 team/* agents (orchestrator as primary, the rest - * as hidden subagents that the orchestrator dispatches to). - * 2. Register the 5 team/* slash commands. + * Responsibilities: * - * Everything else (model selection, run state, pitfall registry) is - * handled by the agents themselves via file I/O under - * .teamwork-runs/. We intentionally do NOT touch the LLM loop — the - * orchestrator agent owns the run loop in its prompt. + * 1. Register the team/* agents with REAL config keys — mode, permission, + * temperature, color, description — instead of dumping the markdown + * frontmatter into the system prompt. `permission.edit: deny` on the + * verifier is what makes "the verifier cannot write" true. + * 2. Register the run engine as tools (teamwork_plan / _dispatch / _verify / + * _status / _resume) so dispatch order, retries, budget and terminal + * state are owned by code rather than by the model's memory. + * 3. Enforce the role contracts at runtime: read-only roles cannot call write + * tools, whatever the prompt says or the user's OpenCode version honours. + * 4. Parse command flags in code (`--topology`, `--budget`, `--concurrency`) + * and hand the model a pre-resolved run context. + * 5. Keep a long run alive across compaction by re-injecting the plan, the + * open tasks and the budget from the event log. */ -export const TeamPlugin: Plugin = async (_ctx) => { - const agentBodies = getAllAgentBodies(); - const commands = getAllCommands(); - - // The opencode plugin config hook receives the resolved config - // and a draft object that lets us mutate it. We inject our agents - // and commands by replacing the `prompt` body to include both the - // frontmatter (which opencode reads for mode/model/etc) AND the - // body (which becomes the system prompt). + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { Plugin } from "@opencode-ai/plugin"; +import { deriveSession, readEvents, verifyChain } from "./events.js"; +import { RoleRegistry } from "./guard.js"; +import { assertTopologiesResolve, DEFAULT_POLICY, isTopology, TOPOLOGY_NAMES } from "./policy.js"; +import { getAllCommands, agentConfigs } from "./templates.js"; +import { TEAMWORK_TOOLS } from "./tools.js"; +import { runDirFor } from "./worktree.js"; + +export interface ParsedCommandFlags { + sessionId?: string; + topology?: string; + budgetUsd?: number; + maxConcurrency?: number; + request: string; + warnings: string[]; +} + +/** + * Parse the flags the README has always advertised but nothing implemented. + * Unknown topologies are reported, not silently accepted: the model is told + * the valid set and the engine rejects the plan if it still gets it wrong. + */ +export function parseCommandFlags(input: string, mint: () => string): ParsedCommandFlags { + const warnings: string[] = []; + const tokens = input.split(/\s+/).filter((t) => t.length > 0); + const rest: string[] = []; + let sessionId: string | undefined; + let topology: string | undefined; + let budgetUsd: number | undefined; + let maxConcurrency: number | undefined; + + const valueOf = (token: string, flag: string, next: string | undefined): string | undefined => { + if (token === flag) return next; + if (token.startsWith(`${flag}=`)) return token.slice(flag.length + 1); + return undefined; + }; + + for (let i = 0; i < tokens.length; i += 1) { + const token = tokens[i]!; + const lookahead = tokens[i + 1]; + let consumed = false; + + for (const [flag, assign] of [ + ["--topology", (v: string) => { topology = v; }], + ["--session", (v: string) => { sessionId = v; }], + ["--budget", (v: string) => { budgetUsd = Number(v); }], + ["--concurrency", (v: string) => { maxConcurrency = Number.parseInt(v, 10); }], + ] as Array<[string, (v: string) => void]>) { + const value = valueOf(token, flag, lookahead); + if (value !== undefined) { + assign(value); + consumed = true; + // `--flag value` consumes the next token; `--flag=value` does not. + if (token === flag) i += 1; + break; + } + } + + if (!consumed) rest.push(token); + } + + if (topology && !isTopology(topology)) { + warnings.push(`unknown --topology "${topology}"; valid: ${TOPOLOGY_NAMES.join(", ")}. Ignoring it.`); + topology = undefined; + } + if (budgetUsd !== undefined && (!Number.isFinite(budgetUsd) || budgetUsd <= 0)) { + warnings.push(`--budget must be a positive number; got "${budgetUsd}". Ignoring it.`); + budgetUsd = undefined; + } + if (maxConcurrency !== undefined && (!Number.isInteger(maxConcurrency) || maxConcurrency <= 0)) { + warnings.push(`--concurrency must be a positive integer. Ignoring it.`); + maxConcurrency = undefined; + } + return { - config: async (config: any) => { - config.agent = config.agent ?? {}; - config.command = config.command ?? {}; + ...(sessionId ? { sessionId } : { sessionId: mint() }), + ...(topology ? { topology } : {}), + ...(budgetUsd !== undefined ? { budgetUsd } : {}), + ...(maxConcurrency !== undefined ? { maxConcurrency } : {}), + request: rest.join(" ").trim(), + warnings, + }; +} + +interface RunPointer { + sessionId: string; + topology?: string; + budgetUsd?: number; + maxConcurrency?: number; + request?: string; + createdAt: string; + opencodeSessionID?: string; +} + +function latestPointerPath(projectDir: string): string { + return join(projectDir, ".opencode", "teamwork", "LATEST.json"); +} + +function writeRunPointer(projectDir: string, pointer: RunPointer): void { + const dir = join(projectDir, ".opencode", "teamwork"); + mkdirSync(dir, { recursive: true }); + writeFileSync(latestPointerPath(projectDir), `${JSON.stringify(pointer, null, 2)}\n`, "utf-8"); +} + +function readRunPointer(projectDir: string): RunPointer | null { + const path = latestPointerPath(projectDir); + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, "utf-8")) as RunPointer; + } catch { + return null; + } +} - // Inject agents - for (const [name, body] of Object.entries(agentBodies)) { - config.agent[`team/${name}`] = { prompt: body }; +export const TeamPlugin: Plugin = async (ctx) => { + const roles = new RoleRegistry(); + const runBySession = new Map(); // opencode sessionID -> run sessionId + + return { + /** + * Inject agents (structured) and commands. Merges with what is already in + * the user's config instead of replacing it — the previous version + * clobbered the per-role `model` that the installer had just written. + */ + config: async (config) => { + const existing = (config.agent ?? {}) as Record>; + config.agent = { ...existing }; + for (const [name, injected] of Object.entries(agentConfigs(existing))) { + const prior = existing[name] ?? {}; + const priorPermission = + prior["permission"] && typeof prior["permission"] === "object" + ? (prior["permission"] as Record) + : {}; + const model = { ...prior, ...injected, permission: { ...injected.permission, ...priorPermission } }; + config.agent[name] = model as never; + roles.setPermissions( + name, + { + edit: (model.permission as Record)["edit"] as "allow" | "deny" | "ask", + bash: (model.permission as Record)["bash"] as "allow" | "deny" | "ask", + webfetch: (model.permission as Record)["webfetch"] as + | "allow" + | "deny" + | "ask", + task: (model.permission as Record)["task"] as "allow" | "deny" | "ask", + }, + ); } - // Inject commands - for (const cmd of commands) { + config.command = config.command ?? {}; + for (const cmd of getAllCommands()) { config.command[cmd.name] = { template: cmd.template, description: cmd.description, @@ -44,13 +178,151 @@ export const TeamPlugin: Plugin = async (_ctx) => { }; } }, + + /** The run engine, as tools. */ + tool: TEAMWORK_TOOLS, + + /** Remember which agent owns which session (drives the write guard). */ + "chat.message": async (input) => { + roles.remember(input.sessionID, input.agent); + }, + + /** Refuse write tools for read-only roles. */ + "tool.execute.before": async (input) => { + const reason = roles.blockReason(input.sessionID, input.tool); + if (reason) throw new Error(reason); + }, + + /** Second line of defence: deny at the permission prompt. */ + "permission.ask": async (input, output) => { + const role = roles.roleFor(input.sessionID); + if (!role) return; + if (!/^edit|write|patch/i.test(input.type)) return; + if (roles.effectivePermissions(role)?.edit === "deny") output.status = "deny"; + }, + + /** + * Parse flags in code, mint the run id, and leave a pointer the command + * template and the tools can both read. The command body itself contains + * no templating language — OpenCode only understands $ARGUMENTS, $1..$N, + * !`cmd` and @file, so anything else reached the model as literal braces. + */ + "command.execute.before": async (input) => { + if (!input.command.startsWith("teamwork") && !input.command.startsWith("team-")) return; + const flags = parseCommandFlags(input.arguments ?? "", () => { + const stamp = new Date().toISOString().replace(/[:.]/g, "-").replace("Z", ""); + return `${stamp}-${Math.random().toString(36).slice(2, 8)}`; + }); + const projectDir = ctx.directory; + const runDir = runDirFor(projectDir, flags.sessionId!); + mkdirSync(runDir, { recursive: true }); + writeFileSync( + join(runDir, "request.md"), + `# Request\n\n${flags.request || "(no request text)"}\n\n# Parsed flags\n\n\`\`\`json\n${JSON.stringify(flags, null, 2)}\n\`\`\`\n`, + "utf-8", + ); + writeRunPointer(projectDir, { + sessionId: flags.sessionId!, + ...(flags.topology ? { topology: flags.topology } : {}), + ...(flags.budgetUsd !== undefined ? { budgetUsd: flags.budgetUsd } : {}), + ...(flags.maxConcurrency !== undefined ? { maxConcurrency: flags.maxConcurrency } : {}), + request: flags.request, + createdAt: new Date().toISOString(), + opencodeSessionID: input.sessionID, + }); + runBySession.set(input.sessionID, flags.sessionId!); + await ctx.client.app + .log({ + body: { + service: "opencode-teamwork", + level: flags.warnings.length > 0 ? "warn" : "info", + message: `teamwork run ${flags.sessionId} prepared${flags.topology ? ` topology=${flags.topology}` : ""}`, + extra: { warnings: flags.warnings, runDir }, + }, + }) + .catch(() => undefined); + }, + + /** + * Long runs get compacted; a compacted model that has forgotten the DAG + * is worse than a slow one. Re-inject the plan, open tasks and budget + * straight from the event log. + */ + "experimental.session.compacting": async (input, output) => { + const runId = + runBySession.get(input.sessionID) ?? readRunPointer(ctx.directory)?.sessionId ?? undefined; + if (!runId) return; + const runDir = runDirFor(ctx.directory, runId); + if (!existsSync(runDir)) return; + const events = readEvents(runDir); + if (events.length === 0) return; + if (!verifyChain(events).ok) { + output.context.push( + `Teamwork run ${runId}: the event log failed its integrity check. Do not trust any remembered state; call teamwork_resume and report the corruption.`, + ); + return; + } + const derived = deriveSession(events); + const rows = derived.order.map( + (id) => `- ${id}: ${derived.tasks[id]?.status} (attempts ${derived.tasks[id]?.attempts ?? 0})`, + ); + output.context.push( + [ + `## Active Teamwork run: ${runId}`, + "", + `Topology: ${derived.topology ?? "unspecified"} | cost $${derived.costUsd.toFixed(2)} of $${derived.budgetUsd.toFixed(2)} | rounds ${derived.rounds}`, + "", + "Task state (from the event log — authoritative):", + ...rows, + derived.deadletter.length > 0 ? `Dead-lettered: ${derived.deadletter.join(", ")}` : "", + "", + "Dispatch order, retries and budget are owned by the engine. Call teamwork_status for the", + "authoritative next step; do not re-plan from memory.", + ] + .filter((l) => l !== "") + .join("\n"), + ); + }, + + event: async ({ event }) => { + if (event.type !== "session.error") return; + const sessionID = (event.properties as { sessionID?: string }).sessionID; + if (!sessionID) return; + await ctx.client.app + .log({ + body: { + service: "opencode-teamwork", + level: "error", + message: `session error in ${sessionID}`, + extra: { run: runBySession.get(sessionID) ?? null }, + }, + }) + .catch(() => undefined); + }, + + dispose: async () => { + const check = assertTopologiesResolve(); + if (!check.ok) { + await ctx.client.app + .log({ + body: { + service: "opencode-teamwork", + level: "warn", + message: `topology patterns missing: ${check.missing.join(", ")}`, + }, + }) + .catch(() => undefined); + } + }, }; }; export default TeamPlugin; -// Named exports for v1 / v2 dual compatibility (mirrors -// oh-my-opencode-slim's exports). +// Named exports for v1 / v2 dual compatibility. export const id = "opencode-teamwork"; export { TeamPlugin as server }; export { TeamPlugin as setup }; + +/** Re-exported for callers that want the policy defaults. */ +export { DEFAULT_POLICY }; diff --git a/src/policy.ts b/src/policy.ts new file mode 100644 index 0000000..78dfb2b --- /dev/null +++ b/src/policy.ts @@ -0,0 +1,192 @@ +/** + * The topology + routing policy registry. + * + * Single source of truth for topology names. Before this file existed the + * pattern *files* said `long-proof` / `distributed-coding` / `document-review` + * while the sentinel prompt and `state.ts` said `proof` / `large-swarm` / + * `doc-review` — four of five names in the routing table resolved to no file. + * Anything that needs a topology name imports it from here, and + * `assertTopologiesResolve()` (called at plugin load and in tests) fails hard + * if a name stops mapping to a pattern file. + * + * The policy is data: `routing` maps a task class to a model ladder and a + * topology. A later milestone lets the learning loop propose a typed delta + * over exactly these fields (see docs/self-improvement.md) — nothing here is + * hidden inside a prompt. + */ + +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export interface TopologyInfo { + name: string; + file: string; + summary: string; + defaultConcurrency: number; + defaultMaxRounds: number; + defaultMaxCostUsd: number; +} + +export const TOPOLOGIES = [ + { + name: "small-focused", + file: "small-focused.md", + summary: "1 builder + 1 reviewer loop", + defaultConcurrency: 1, + defaultMaxRounds: 2, + defaultMaxCostUsd: 3, + }, + { + name: "iterative-coding", + file: "iterative-coding.md", + summary: "single proposer + falsifier + verify loop (no synthesis)", + defaultConcurrency: 1, + defaultMaxRounds: 4, + defaultMaxCostUsd: 5, + }, + { + name: "distributed-coding", + file: "distributed-coding.md", + summary: "N workers in parallel + verifiers, merged by the sentinel", + defaultConcurrency: 4, + defaultMaxRounds: 4, + defaultMaxCostUsd: 20, + }, + { + name: "long-proof", + file: "long-proof.md", + summary: "1 strategist + 3-5 searchers + a formal checker", + defaultConcurrency: 3, + defaultMaxRounds: 6, + defaultMaxCostUsd: 40, + }, + { + name: "massive-proof-swarm", + file: "massive-proof-swarm.md", + summary: "meta-coordinator + 100+ searchers (opt-in)", + defaultConcurrency: 8, + defaultMaxRounds: 24, + defaultMaxCostUsd: 200, + }, + { + name: "document-review", + file: "document-review.md", + summary: "1 chair + 3 critics + 1 aggregator", + defaultConcurrency: 3, + defaultMaxRounds: 2, + defaultMaxCostUsd: 12, + }, +] as const satisfies readonly TopologyInfo[]; + +export type Topology = (typeof TOPOLOGIES)[number]["name"]; + +export const TOPOLOGY_NAMES: readonly string[] = TOPOLOGIES.map((t) => t.name); + +export function getTopology(name: string): TopologyInfo | undefined { + return TOPOLOGIES.find((t) => t.name === name); +} + +export function isTopology(name: string): name is Topology { + return TOPOLOGY_NAMES.includes(name); +} + +// ─── Model routing policy ──────────────────────────────────────────── + +export interface RouteRule { + /** Model ladder, cheapest first. The router escalates on repeated failure. */ + ladder: string[]; + topology?: Topology; + /** Extra verification checks this task class must run to PASS. */ + requiredChecks?: string[]; +} + +export interface Policy { + version: number; + budget: { perTaskUsd: number; perSessionUsd: number; haltAtPct: number }; + routing: Record; + /** Prompt fragment files, keyed by role (data, not code). */ + promptFragments: Record; +} + +export const DEFAULT_POLICY: Policy = { + version: 1, + budget: { perTaskUsd: 2.5, perSessionUsd: 20, haltAtPct: 80 }, + routing: { + // Cheapest-first ladders. The engine escalates a task one rung per + // failed round and records the rung it used, so the learning loop can + // measure "escalate on repeat failure" against the traces. + "default": { ladder: ["anthropic/claude-sonnet-4-5"] }, + "taskClass:bugfix-single-file": { + ladder: ["anthropic/claude-sonnet-4-5", "anthropic/claude-opus-4-5"], + topology: "small-focused", + }, + "taskClass:refactor-multi-file": { + ladder: ["google/gemini-3-flash", "anthropic/claude-sonnet-4-5"], + topology: "distributed-coding", + }, + "taskClass:proof": { + ladder: ["google/gemini-3-flash", "google/gemini-3.1-pro"], + topology: "long-proof", + }, + "taskClass:auth-change": { + ladder: ["anthropic/claude-sonnet-4-5"], + requiredChecks: ["adversarial:privilege-escalation"], + }, + }, + promptFragments: {}, +}; + +export function resolveRoute(policy: Policy, taskClass: string | undefined): RouteRule { + if (taskClass && policy.routing[taskClass]) return policy.routing[taskClass]; + return policy.routing["default"] ?? { ladder: [] }; +} + +/** Pick the ladder rung for a given attempt (0-based). Escalates, then holds. */ +export function ladderRung(rule: RouteRule, attempt: number, fallbackModel?: string): string | undefined { + if (rule.ladder.length === 0) return fallbackModel; + const idx = Math.min(Math.max(attempt, 0), rule.ladder.length - 1); + return rule.ladder[idx] ?? fallbackModel; +} + +// ─── Pattern file resolution ───────────────────────────────────────── + +/** + * Where the pattern markdown lives once built: + * dist/cli/templates/patterns/*.md (cpSync'd from src/cli/templates/patterns) + * `import.meta.url` resolves to dist/policy.js or dist/cli/index.js depending + * on the entry point, so we probe a couple of roots rather than guessing. + */ +export function patternsDirCandidates(): string[] { + const here = dirname(fileURLToPath(import.meta.url)); + return [ + join(here, "cli", "templates", "patterns"), // dist/policy.js → dist/cli/templates/patterns + join(here, "..", "cli", "templates", "patterns"), // dist/x/y.js + join(here, "templates", "patterns"), // dist/cli/index.js → dist/cli/templates/patterns + join(here, "..", "..", "src", "cli", "templates", "patterns"), // dev checkout + join(here, "..", "src", "cli", "templates", "patterns"), + ]; +} + +export function resolvePatternsDir(): string | null { + for (const dir of patternsDirCandidates()) { + if (existsSync(join(dir, "small-focused.md"))) return dir; + } + return null; +} + +export interface TopologyResolution { + ok: boolean; + missing: string[]; + resolvedDir: string | null; +} + +/** Every topology name must map to a real pattern file. Fails closed. */ +export function assertTopologiesResolve(): TopologyResolution { + const dir = resolvePatternsDir(); + if (!dir) { + return { ok: false, missing: TOPOLOGIES.map((t) => t.file), resolvedDir: null }; + } + const missing = TOPOLOGIES.filter((t) => !existsSync(join(dir, t.file))).map((t) => t.file); + return { ok: missing.length === 0, missing, resolvedDir: dir }; +} diff --git a/src/state.ts b/src/state.ts index 531bfb0..26ee3cc 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,18 +1,35 @@ /** - * Session state machine for Teamwork. + * Compatibility layer for run state. * - * Persists the Sentinel's progress to - * `.opencode/teamwork//state.json` after every - * transition. On RESUME, the Sentinel re-reads this file and - * picks up where it left off. + * The old module claimed to be a "session state machine" and had the LLM + * write `state.json` after every step. That made the record of what happened + * a model claim rather than a fact, and an interrupted run could resume into a + * state that never occurred. * - * Pure data — no I/O outside of readFileSync/writeFileSync on - * the well-known path. + * State now lives in the append-only hash-chained event log (`events.jsonl`, + * see `src/events.ts`), and `state.json` is a DERIVED snapshot written by the + * engine. Nothing model-authored is authoritative. + * + * These exports are kept so existing callers keep compiling; new code should + * use `src/events.ts` (deriveSession / appendEvent) and `src/engine.ts`. */ -import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + deriveSession, + readEvents, + verifyChain, + writeSnapshot, + type DerivedSession, + type EventType, +} from "./events.js"; +import type { Topology } from "./policy.js"; +/** + * @deprecated The sentinel's state is derived from the event log. Kept only so + * legacy callers can name the phases. + */ export type SentinelState = | "PENDING" | "LOAD_SPEC" @@ -27,89 +44,75 @@ export type SentinelState = | "PRESENT_PARTIAL" | "DONE"; -export interface TaskState { - taskId: string; - status: "PENDING" | "RUNNING" | "VERIFYING" | "COMPLETED" | "FAILED"; - attempts: number; - lastVerifierReport?: string; // path - lastPatch?: string; // path - assignedAgent?: string; - assignedModel?: string; - worktreePath?: string; - dependencies: string[]; - acceptanceCriteria: string[]; - artifacts: string[]; - metrics?: { tokensUsed: number; costUsd: number }; -} - -export interface SessionState { - sessionId: string; - topology: "small-focused" | "large-swarm" | "proof" | "massive-proof" | "doc-review"; - integrityMode: "development" | "demo" | "benchmark"; - workingDirectory: string; - state: SentinelState; - startedAt: string; - updatedAt: string; - tasks: TaskState[]; - pitfalls: string[]; // answer-agnostic mistakes from past rounds - notes: string[]; // free-form observations +/** + * @deprecated Use `DerivedSession` from `src/events.ts`. The legacy shape + * carried an `integrityMode` that nothing read and a topology union that did + * not match any pattern file. + */ +export interface SessionState extends DerivedSession { + /** @deprecated pitfalls live in the run artifacts, not in derived state. */ + notes: string[]; } function statePath(sessionDir: string): string { return join(sessionDir, "state.json"); } +/** + * Read the run state. Prefers the event log (authoritative, replayable) and + * falls back to the last snapshot for runs created before the log existed. + */ export function loadSessionState(sessionDir: string): SessionState | null { + const events = readEvents(sessionDir); + if (events.length > 0) { + const derived = deriveSession(events); + return { ...derived, notes: [] }; + } const path = statePath(sessionDir); if (!existsSync(path)) return null; - return JSON.parse(readFileSync(path, "utf-8")); + try { + const parsed = JSON.parse(readFileSync(path, "utf-8")) as DerivedSession; + return { ...parsed, notes: [] }; + } catch { + return null; + } } -export function saveSessionState(sessionDir: string, state: SessionState): void { - mkdirSync(sessionDir, { recursive: true }); - state.updatedAt = new Date().toISOString(); - writeFileSync(statePath(sessionDir), JSON.stringify(state, null, 2)); +/** Replay the log and refresh the snapshot. */ +export function refreshSnapshot(sessionDir: string): DerivedSession | null { + const events = readEvents(sessionDir); + if (events.length === 0) return null; + const derived = deriveSession(events); + writeSnapshot(sessionDir, derived); + return derived; } -export function newSessionState(opts: { - sessionId: string; - topology: SessionState["topology"]; - integrityMode: SessionState["integrityMode"]; - workingDirectory: string; -}): SessionState { - const now = new Date().toISOString(); - return { - sessionId: opts.sessionId, - topology: opts.topology, - integrityMode: opts.integrityMode, - workingDirectory: opts.workingDirectory, - state: "PENDING", - startedAt: now, - updatedAt: now, - tasks: [], - pitfalls: [], - notes: [], - }; +/** + * @deprecated Callers must not write state. Use `appendEvent` (or one of the + * engine methods, which validate and append for you). + */ +export function saveSessionState(_sessionDir: string, _state: SessionState): never { + throw new Error( + "saveSessionState is removed: run state is derived from the append-only event log. " + + "Use appendEvent() from src/events.ts, or the engine methods (dispatch/recordRound).", + ); } -export function addPitfall(sessionDir: string, pitfall: string): void { - const state = loadSessionState(sessionDir); - if (!state) return; - if (!state.pitfalls.includes(pitfall)) { - state.pitfalls.push(pitfall); - saveSessionState(sessionDir, state); - } +export interface LogIntegrity { + ok: boolean; + length: number; + reason?: string; } -export function transition( - sessionDir: string, - nextState: SentinelState, - notes?: string, -): SessionState { - const state = loadSessionState(sessionDir); - if (!state) throw new Error(`No session state in ${sessionDir}`); - state.state = nextState; - if (notes) state.notes.push(notes); - saveSessionState(sessionDir, state); - return state; +/** Convenience: is this run's log intact? */ +export function checkLog(sessionDir: string): LogIntegrity { + const events = readEvents(sessionDir); + const chain = verifyChain(events); + return { + ok: chain.ok, + length: chain.length, + ...(chain.reason ? { reason: chain.reason } : {}), + }; } + +export type { DerivedSession, EventType, Topology }; diff --git a/src/templates.ts b/src/templates.ts index 1e930f2..7baa47e 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -1,11 +1,35 @@ -import { readFileSync } from "node:fs"; +/** + * Agent / command / pattern templates. + * + * The important change here: agent frontmatter is PARSED and injected as real + * OpenCode config keys (`mode`, `permission`, `temperature`, `color`, + * `description`), not embedded in the system prompt as text. + * + * Previously the plugin did `config.agent[name] = { prompt: }`, + * which meant `permission: edit: deny` on the verifier did nothing, `mode` + * defaulted to `all` (so every "hidden" subagent showed up in the agent menu), + * and `temperature` was ignored — the segregation-of-duties claim was a + * paragraph, not a boundary. See `agentConfigs()`. + */ + +import { existsSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { TOPOLOGIES, resolvePatternsDir, type TopologyInfo } from "./policy.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const TEMPLATES_DIR = join(__dirname, "cli", "templates"); const PROMPTS_DIR = join(TEMPLATES_DIR, "prompts"); +export type PermissionAction = "allow" | "deny" | "ask"; + +export interface AgentPermissions { + edit: PermissionAction; + bash: PermissionAction; + webfetch: PermissionAction; + task: PermissionAction; +} + export interface AgentTemplate { name: string; description: string; @@ -14,125 +38,218 @@ export interface AgentTemplate { temperature: number; hidden: boolean; color: string; - body: string; // the full markdown (frontmatter + body) - promptFile: string; // path inside the package - permissions: { - edit: "allow" | "deny" | "ask"; - bash: "allow" | "deny" | "ask"; - webfetch: "allow" | "deny" | "ask"; - task: "allow" | "deny" | "ask"; - }; + /** Markdown body with the frontmatter and the prompt placeholder removed. */ + prompt: string; + permissions: AgentPermissions; } export interface CommandTemplate { name: string; description: string; agent: string; - body: string; -} - -export interface PatternTemplate { - name: string; - body: string; -} - -function loadTemplateFile(relPath: string): string { - return readFileSync(join(TEMPLATES_DIR, relPath), "utf-8"); + template: string; } -function loadPromptFile(name: string): string { - return readFileSync(join(PROMPTS_DIR, `${name}.txt`), "utf-8"); -} +// ─── Frontmatter parsing ───────────────────────────────────────────── function splitFrontmatter(md: string): { frontmatter: string; body: string } { - // Frontmatter is between the first pair of --- lines - const match = md.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + const match = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); if (!match) return { frontmatter: "", body: md }; return { frontmatter: match[1] ?? "", body: match[2] ?? "" }; } function parseFrontmatter(fm: string): Record { const out: Record = {}; - for (const line of fm.split("\n")) { + for (const line of fm.split(/\r?\n/)) { const m = line.match(/^([a-zA-Z_-]+):\s*(.*)$/); - if (m) out[m[1]!] = m[2]!.trim(); + if (m && m[1]) out[m[1]] = (m[2] ?? "").trim().replace(/^["']|["']$/g, ""); } return out; } -function parsePermissionBlock(fm: string): AgentTemplate["permissions"] { - // crude but sufficient: find the `permission:` block, then the - // children until the next top-level key (no leading spaces). +function parsePermissionBlock(fm: string): AgentPermissions { + const fallback: AgentPermissions = { edit: "ask", bash: "ask", webfetch: "allow", task: "allow" }; const start = fm.indexOf("permission:"); - if (start < 0) { - return { edit: "ask", bash: "ask", webfetch: "allow", task: "allow" }; - } - const rest = fm.slice(start); - const lines = rest.split("\n").slice(1); + if (start < 0) return fallback; + const lines = fm.slice(start).split(/\r?\n/).slice(1); const block: Record = {}; for (const line of lines) { if (line && !line.startsWith(" ")) break; // next top-level key const m = line.match(/^\s+([a-zA-Z]+):\s*(.+?)\s*$/); - if (m) block[m[1]!] = m[2]!.replace(/^["']|["']$/g, ""); + if (m && m[1]) block[m[1]] = (m[2] ?? "").replace(/^["']|["']$/g, ""); } + const asAction = (v: string | undefined, dflt: PermissionAction): PermissionAction => + v === "allow" || v === "deny" || v === "ask" ? v : dflt; return { - edit: (block.edit as "allow" | "deny" | "ask") ?? "ask", - bash: (block.bash as "allow" | "deny" | "ask") ?? "ask", - webfetch: (block.webfetch as "allow" | "deny" | "ask") ?? "allow", - task: (block.task as "allow" | "deny" | "ask") ?? "allow", + edit: asAction(block.edit, fallback.edit), + bash: asAction(block.bash, fallback.bash), + webfetch: asAction(block.webfetch, fallback.webfetch), + task: asAction(block.task, fallback.task), }; } +function loadTemplateFile(relPath: string): string { + return readFileSync(join(TEMPLATES_DIR, relPath), "utf-8"); +} + +/** Resolve `{file:./prompts/team/x.txt}` to the absolute installed path. */ +function resolvePromptPlaceholder(body: string, name: string): string { + const abs = join(PROMPTS_DIR, `${name}.txt`).replace(/\\/g, "/"); + return body + .replace(/\{file:\.\/prompts\/team\/[^}]+\}/g, abs) + .replace(/\{file:[^}]+\}/g, abs) + .trim(); +} + function loadAgent(file: string, promptName: string): AgentTemplate { const raw = loadTemplateFile(file); const { frontmatter, body } = splitFrontmatter(raw); const fm = parseFrontmatter(frontmatter); + const mode = fm.mode === "primary" || fm.mode === "all" ? fm.mode : "subagent"; return { name: file.replace(/\.md$/, ""), description: fm.description ?? "", - mode: (fm.mode as "primary" | "subagent" | "all") ?? "subagent", + mode, model: fm.model ?? "anthropic/claude-sonnet-4-5", - temperature: Number(fm.temperature ?? "0.2"), + temperature: Number.isFinite(Number(fm.temperature)) ? Number(fm.temperature) : 0.2, hidden: fm.hidden === "true", color: fm.color ?? "#7c3aed", - body: raw, - promptFile: `./prompts/team/${promptName}.txt`, + prompt: resolvePromptPlaceholder(body, promptName), permissions: parsePermissionBlock(frontmatter), }; } -export const AGENT_TEMPLATES: AgentTemplate[] = [ - loadAgent("crafter.md", "crafter"), - loadAgent("sentinel.md", "sentinel"), - loadAgent("worker.md", "worker"), - loadAgent("proof-worker.md", "proof-worker"), - loadAgent("verifier.md", "verifier"), - loadAgent("orchestrator.md", "orchestrator"), - loadAgent("proposer.md", "proposer"), - loadAgent("falsifier.md", "falsifier"), - loadAgent("synthesizer.md", "synthesizer"), - loadAgent("scout.md", "scout"), +const AGENT_FILES: Array<[string, string]> = [ + ["crafter.md", "crafter"], + ["sentinel.md", "sentinel"], + ["worker.md", "worker"], + ["proof-worker.md", "proof-worker"], + ["verifier.md", "verifier"], + ["orchestrator.md", "orchestrator"], + ["proposer.md", "proposer"], + ["falsifier.md", "falsifier"], + ["synthesizer.md", "synthesizer"], + ["scout.md", "scout"], ]; +export const AGENT_TEMPLATES: AgentTemplate[] = AGENT_FILES.map(([f, p]) => loadAgent(f, p)); + export function getAgent(name: string): AgentTemplate | undefined { return AGENT_TEMPLATES.find((a) => a.name === name); } -export function getAgentBodyWithPromptPath(name: string): string { - const agent = getAgent(name); - if (!agent) throw new Error(`Unknown agent: ${name}`); - // Replace the {file:./prompts/team/.txt} placeholder with the - // absolute path inside the installed package so opencode can resolve it. - const absPath = join(TEMPLATES_DIR, "prompts", `${name}.txt`).replace( - /\\/g, - "/", - ); - return agent.body.replace(agent.promptFile, absPath); +/** Declared permissions for a role, used by the runtime guard. */ +export function agentPermissions(name: string): AgentPermissions | undefined { + return getAgent(name)?.permissions; +} + +// ─── Pattern library (so the 6 pattern files actually reach the model) ── + +export interface PatternTemplate { + name: string; + summary: string; + path: string | null; + body: string; +} + +/** + * The topology index injected into the orchestrating agents' prompts. + * Absolute paths, because the model runs in the user's project while the + * patterns live in the installed package — before this, the sentinel was + * told to pick a topology whose definition it had no way to read, using names + * that matched no file. + */ +export function topologyIndex(): string { + const dir = resolvePatternsDir(); + const rows = TOPOLOGIES.map((t: TopologyInfo) => { + const p = dir ? join(dir, t.file).replace(/\\/g, "/") : `(missing: ${t.file})`; + return `| \`${t.name}\` | ${t.summary} | \`${p}\` |`; + }); + return [ + "## Topology library", + "", + "Pick exactly one. Read its file before planning — the file is the definition, this table is only an index.", + "If a name is not in this table it does not exist; do not invent one.", + "", + "| topology | shape | definition |", + "|---|---|---|", + ...rows, + ].join("\n"); +} + +export function getAllPatterns(): PatternTemplate[] { + const dir = resolvePatternsDir(); + return TOPOLOGIES.map((t) => { + const path = dir ? join(dir, t.file) : null; + return { + name: t.name, + summary: t.summary, + path, + body: path && existsSync(path) ? readFileSync(path, "utf-8") : "", + }; + }); +} + +// ─── Agent config injection (the P0 fix) ───────────────────────────── + +export interface InjectedAgentConfig { + description?: string; + mode: "primary" | "subagent" | "all"; + model?: string; + temperature: number; + color?: string; + permission: AgentPermissions; + prompt: string; + hidden?: boolean; + /** Only the sentinel/orchestrator may invoke subagents; leaves may not. */ + tools?: Record; +} + +/** + * Build the config for one agent. + * + * `existing` is whatever the user (or the CLI installer) already put in + * `opencode.json` for this agent — crucially `model`. The old code replaced + * that object wholesale, which silently discarded the per-role model the + * installer had just written. + */ +export function agentConfigFor(agent: AgentTemplate, existing?: Record): InjectedAgentConfig { + const config: InjectedAgentConfig = { + mode: agent.mode, + temperature: agent.temperature, + permission: agent.permissions, + // Orchestrating agents get the topology library appended: they are the + // ones who must pick a topology, and the pattern files live in the + // installed package rather than in the user's project. + prompt: agent.mode === "primary" ? `${agent.prompt}\n\n${topologyIndex()}\n` : agent.prompt, + }; + if (agent.description) config.description = agent.description; + if (agent.color) config.color = agent.color; + const model = typeof existing?.["model"] === "string" ? (existing["model"] as string) : agent.model; + if (model) config.model = model; + return config; +} + +export function agentConfigs(existing?: Record>): Record { + const out: Record = {}; + for (const agent of AGENT_TEMPLATES) { + const prior = existing?.[`team/${agent.name}`]; + out[`team/${agent.name}`] = agentConfigFor(agent, prior); + } + return out; } +/** + * Agent bodies with the topology index appended, keyed by agent name. + * Kept for callers that want the prompt text only. + */ export function getAllAgentBodies(): Record { const out: Record = {}; - for (const a of AGENT_TEMPLATES) out[a.name] = getAgentBodyWithPromptPath(a.name); + const index = topologyIndex(); + for (const agent of AGENT_TEMPLATES) { + const orchestrating = agent.mode === "primary"; + out[agent.name] = orchestrating ? `${agent.prompt}\n\n${index}\n` : agent.prompt; + } return out; } @@ -148,32 +265,8 @@ const COMMAND_FILES = [ "team-review.md", ]; -export function getAllCommandBodies(): Record { - const out: Record = {}; - for (const file of COMMAND_FILES) { - const name = file.replace(/\.md$/, ""); - const raw = loadTemplateFile(`commands/${file}`); - const { frontmatter } = splitFrontmatter(raw); - const fm = parseFrontmatter(frontmatter); - out[name] = { - template: raw, - description: fm.description ?? "", - agent: fm.agent ?? "team/orchestrator", - } as unknown as string; - } - return out; -} - -// We need a richer shape — re-export as parsed. -export interface ParsedCommand { - name: string; - description: string; - agent: string; - template: string; -} - -export function getAllCommands(): ParsedCommand[] { - const out: ParsedCommand[] = []; +export function getAllCommands(): CommandTemplate[] { + const out: CommandTemplate[] = []; for (const file of COMMAND_FILES) { const name = file.replace(/\.md$/, ""); const raw = loadTemplateFile(`commands/${file}`); @@ -182,106 +275,44 @@ export function getAllCommands(): ParsedCommand[] { out.push({ name, description: fm.description ?? "", - agent: fm.agent ?? "team/orchestrator", + agent: fm.agent ?? "team/sentinel", template: body.trim(), }); } return out; } -// ─── Patterns ──────────────────────────────────────────────────────── - -export function getAllPatterns(): PatternTemplate[] { - const out: PatternTemplate[] = []; - for (const name of [ - "small-focused", - "long-proof", - "iterative-coding", - "distributed-coding", - "document-review", - "massive-proof-swarm", - ]) { - out.push({ - name, - body: loadTemplateFile(`patterns/${name}.md`), - }); - } - return out; -} - // ─── SKILL.md body ─────────────────────────────────────────────────── export const SKILL_BODY = `# Teamwork — Multi-Agent Orchestration for OpenCode -A replica of Google Antigravity's \`/teamwork-preview\`, packaged as an -opencode plugin. 6 agents, 4 patterns, 5 slash commands. +${AGENT_TEMPLATES.length} agents, ${TOPOLOGIES.length} topologies, +${COMMAND_FILES.length} slash commands. The run is owned by code: agents propose +and work, the engine decides order, retries, budget and terminal state. -## The 6 agents +## The ${AGENT_TEMPLATES.length} agents -| Agent | Role | Hidden | +| Agent | Role | Mode | |---|---|---| -| \`team/orchestrator\` | Lead coordinator. Picks a pattern, runs the loop. | no (primary) | -| \`team/proposer\` | Generates candidate solutions. | yes | -| \`team/falsifier\` | Attacks a candidate. Adversarial by design. | yes | -| \`team/synthesizer\` | Merges candidates + critiques. | yes | -| \`team/verifier\` | Final correctness gate. | yes | -| \`team/scout\` | Read-only context gatherer. Runs first. | yes | - -The \`hidden: true\` flag on the sub-agents means they don't appear in -the \`@\` autocomplete menu — they're invoked by the orchestrator via the -\`task\` tool. You can still @-mention them manually if you want. - -## The 4 patterns - -| Pattern | When | -|---|---| -| \`long-proof\` | "prove", "show that", open problem, Lean/Coq | -| \`iterative-coding\` | "fix this bug", "add a feature to this function" | -| \`distributed-coding\` | "build this across N files", parallel workers | -| \`document-review\` | "review this paper", "what does this say" | - -The orchestrator picks the pattern from the prompt. You can force one -with \`/teamwork --pattern iterative-coding "fix the bug in foo.ts"\`. - -## The 5 slash commands - -| Command | Does | -|---|---| -| \`/teamwork [args]\` | Full Teamwork run. | -| \`/team-propose [args]\` | Just the propose step. | -| \`/team-falsify [args]\` | Just the falsify step on existing candidates. | -| \`/team-synthesize [args]\` | Just the synthesis step. | -| \`/team-review [args]\` | Just the verify step. | - -The split commands let you inspect intermediate artifacts before -committing to the next phase. Useful when you want to see what -candidates look like before deciding whether to merge them. - -## Per-role model selection - -Each agent has a \`model\` field. The plugin's defaults are -anthropic/claude-sonnet-4-5 across the board (cheap + smart), but the -installer lets you mix and match. Common mix: - -- \`orchestrator\`: opus-4.5 (smartest, slowest) -- \`proposer\`: sonnet-4.5 (cheap, run many) -- \`falsifier\`: sonnet-4.5 (need smart, but cheap) -- \`synthesizer\`: opus-4.5 (smartest merge) -- \`verifier\`: sonnet-4.5 (cheap, run many) -- \`scout\`: haiku-4.5 (read-only, fast) - -You can also mix providers. \`team/proposer\` on Gemini Flash, -\`team/falsifier\` on Claude Sonnet, etc. This is the same pattern -Google's research used (Flash + Pro 3.1 hit 71% on TCSBench). - -## When to skip Teamwork - -If the user prompt is a single, simple task ("rename this function", -"add a console.log", "explain this line"), do not use Teamwork. Use -the default opencode build agent. Teamwork is overkill for everyday -work — it's for the problems that take hours or days and where one -model gets it wrong. - -The orchestrator asks once: "Skipping Teamwork — I'll work as a single -agent. OK?" before falling back. +${AGENT_TEMPLATES.map((a) => `| \`team/${a.name}\` | ${a.description.split(".")[0] ?? ""} | ${a.mode}${a.permissions.edit === "deny" ? " (read-only)" : ""} |`).join("\n")} + +Read-only roles are enforced twice: as an OpenCode \`permission.edit: deny\` +and by a runtime guard that refuses write tools for those sessions. + +## The ${TOPOLOGIES.length} topologies + +${TOPOLOGIES.map((t) => `- \`${t.name}\` — ${t.summary}`).join("\n")} + +## The engine + +The sentinel never decides dispatch order in prose. It calls: + +- \`teamwork_plan\` — validate the DAG, create the run, provision worktrees +- \`teamwork_dispatch\` — ask which tasks are ready now (topological + budget) +- \`teamwork_verify\` — submit a verifier report; the engine rules on it +- \`teamwork_status\` / \`teamwork_resume\` — derive state from the event log + +A PASS is only accepted when the report contains at least one executed check +that recorded a command and its exit code. Reading the diff and judging is not +a verification. `; diff --git a/src/tools.ts b/src/tools.ts new file mode 100644 index 0000000..3a98926 --- /dev/null +++ b/src/tools.ts @@ -0,0 +1,534 @@ +/** + * The Teamwork tool surface. + * + * These are the tools the sentinel/orchestrator agent calls instead of + * "remembering" the run in its context. Everything a run needs — the event + * log, the DAG, attempt accounting, the budget, the dead-letter queue — lives + * behind them, so the run survives context compaction, model swaps and + * restarts. + * + * Contract: + * teamwork_plan — validate a plan, create the run, provision worktrees + * teamwork_dispatch — ask "what should run now?" (topological + budget-aware) + * teamwork_verify — submit a verifier report; the engine decides the outcome + * teamwork_status — derive the run state from the event log + * teamwork_resume — rebuild a run after a restart + * + * Only ENGINE_ROLES may drive the engine; a worker calling `teamwork_dispatch` + * gets an error string, not a task. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tool, type ToolContext, type ToolDefinition } from "@opencode-ai/plugin"; +import { + PlanDagSchema, + SpecSchema, + VerificationReportSchema, + parseArtifact, +} from "./artifacts.js"; +import { Engine, dispatchWaves, type DagTask, type VerificationReport } from "./engine.js"; +import { verifyChain, readEvents, deriveSession, writeSnapshot } from "./events.js"; +import { ENGINE_ROLES } from "./guard.js"; +import { DEFAULT_POLICY, TOPOLOGY_NAMES, type Policy } from "./policy.js"; +import { createWorktreeManager, runDirFor } from "./worktree.js"; + +// ─── Shared helpers ────────────────────────────────────────────────── + +export function mintSessionId(now = new Date()): string { + const stamp = now.toISOString().replace(/[:.]/g, "-").replace("Z", ""); + const rand = Math.random().toString(36).slice(2, 8); + return `${stamp}-${rand}`; +} + +export function loadPolicy(projectDir: string): Policy { + const path = join(projectDir, ".teamwork", "policy.json"); + if (!existsSync(path)) return DEFAULT_POLICY; + try { + const raw = JSON.parse(readFileSync(path, "utf-8")) as Partial; + return { + ...DEFAULT_POLICY, + ...raw, + budget: { ...DEFAULT_POLICY.budget, ...(raw.budget ?? {}) }, + routing: { ...DEFAULT_POLICY.routing, ...(raw.routing ?? {}) }, + promptFragments: { ...DEFAULT_POLICY.promptFragments, ...(raw.promptFragments ?? {}) }, + }; + } catch { + return DEFAULT_POLICY; + } +} + +function openRun(projectDir: string, sessionId: string): Engine { + return Engine.resume(runDirFor(projectDir, sessionId), { policy: loadPolicy(projectDir) }); +} + +function gate(context: ToolContext, toolName: string): string | null { + if (ENGINE_ROLES.includes(context.agent)) return null; + return ( + `teamwork: "${toolName}" may only be called by an orchestrating agent ` + + `(${ENGINE_ROLES.join(", ")}). You are "${context.agent}". ` + + `Do your task and return your result; the sentinel drives the run.` + ); +} + +function statusLine(engine: Engine): string { + const s = engine.status(); + const counts = s.tasks.reduce>((acc, t) => { + acc[t.status] = (acc[t.status] ?? 0) + 1; + return acc; + }, {}); + const parts = Object.entries(counts) + .map(([k, v]) => `${v} ${k.toLowerCase()}`) + .join(", "); + return `state=${s.state} tasks: ${parts || "none"} | rounds=${s.rounds} | cost=$${s.costUsd.toFixed(2)}/$${s.budgetUsd.toFixed(2)} (${s.pctOfBudget.toFixed(0)}%) | log=${s.chain.length} events, chain ${s.chain.ok ? "ok" : "BROKEN"}`; +} + +function nextActions(engine: Engine): string { + const status = engine.status(); + const dispatchable = engine.dispatchable(); + const lines: string[] = []; + if (status.deadletter.length > 0) { + lines.push(`dead-lettered: ${status.deadletter.join(", ")} (report these to the user, do not retry)`); + } + if (engine.budgetExhausted()) { + lines.push("BUDGET EXHAUSTED — stop dispatching, present partial results"); + return lines.join("\n"); + } + if (dispatchable.length === 0 && status.pending.length > 0) { + lines.push(`blocked: ${status.pending.join(", ")} waiting on dependencies or concurrency cap`); + } + if (dispatchable.length > 0) { + lines.push(`next: ${dispatchable.map((t) => t.taskId).join(", ")}`); + } + if (status.tasks.every((t) => t.status === "COMPLETED" || t.status === "DEADLETTER" || t.status === "FAILED")) { + lines.push("all tasks terminal — write final.md and present"); + } + return lines.join("\n") || "nothing to do"; +} + +// ─── teamwork_plan ─────────────────────────────────────────────────── + +const taskArg = tool.schema.object({ + taskId: tool.schema.string().describe("stable id, [A-Za-z0-9._-]{1,64}"), + title: tool.schema.string(), + description: tool.schema.string().optional(), + dependsOn: tool.schema.array(tool.schema.string()).optional(), + acceptanceCriteria: tool.schema.array(tool.schema.string()).optional(), + taskClass: tool.schema + .string() + .optional() + .describe("policy key, e.g. taskClass:bugfix-single-file — selects the model ladder and required checks"), + maxRounds: tool.schema.number().int().positive().optional(), +}); + +export const teamworkPlan: ToolDefinition = tool({ + description: + "Start a Teamwork run: validate the task DAG, create the run directory, write plan.dag.json and per-task spec.json, provision one git worktree per worker, and report the dispatch waves. Call this ONCE, before any dispatch. The engine rejects cycles, unknown dependencies, and unknown topologies.", + args: { + topology: tool.schema.enum(TOPOLOGY_NAMES as unknown as [string, ...string[]]), + tasks: tool.schema.array(taskArg).min(1), + sessionId: tool.schema.string().optional().describe("reuse an id to resume; omit to mint one"), + budgetUsd: tool.schema.number().positive().optional(), + maxConcurrency: tool.schema.number().int().positive().optional(), + worktrees: tool.schema.boolean().optional().describe("default true in a git repo"), + }, + async execute(args, context) { + const denied = gate(context, "teamwork_plan"); + if (denied) return denied; + + const sessionId = args.sessionId ?? mintSessionId(); + const runDir = runDirFor(context.directory, sessionId); + const policy = loadPolicy(context.directory); + + const tasks: DagTask[] = args.tasks.map((t) => ({ + taskId: t.taskId, + title: t.title, + ...(t.description !== undefined ? { description: t.description } : {}), + ...(t.dependsOn !== undefined ? { dependsOn: t.dependsOn } : {}), + ...(t.acceptanceCriteria !== undefined ? { acceptanceCriteria: t.acceptanceCriteria } : {}), + ...(t.taskClass !== undefined ? { taskClass: t.taskClass } : {}), + ...(t.maxRounds !== undefined ? { maxRounds: t.maxRounds } : {}), + })); + + let engine: Engine; + try { + engine = Engine.create({ + runDir, + sessionId, + topology: args.topology, + tasks, + policy, + ...(args.budgetUsd !== undefined ? { budgetUsd: args.budgetUsd } : {}), + ...(args.maxConcurrency !== undefined ? { maxConcurrency: args.maxConcurrency } : {}), + workingDirectory: context.directory, + }); + } catch (err) { + return `plan rejected — ${(err as Error).message}`; + } + + // Plan artifact is schema-validated on the way out, so a downstream + // reader can trust it (this replaces "the model wrote some JSON"). + const planDag = { + sessionId, + topology: args.topology, + modelAllocation: {}, + budget: { maxCostUsd: engine.budgetUsd, currentCostUsd: 0 }, + tasks: tasks.map((t) => ({ + taskId: t.taskId, + title: t.title, + ...(t.description !== undefined ? { description: t.description } : {}), + ...(t.taskClass !== undefined ? { taskClass: t.taskClass } : {}), + ...(t.maxRounds !== undefined ? { maxRounds: t.maxRounds } : {}), + dependencies: t.dependsOn ?? [], + status: "PENDING" as const, + acceptanceCriteria: t.acceptanceCriteria ?? [], + artifacts: [], + })), + }; + const parsedPlan = parseArtifact(PlanDagSchema, planDag, "plan.dag.json"); + if (!parsedPlan.ok) return `plan artifact is invalid — ${parsedPlan.error}`; + + // Worktrees: one per task, plus the sentinel's. + const wm = createWorktreeManager(); + const worktreeNotes: string[] = []; + const wantWorktrees = args.worktrees ?? true; + if (wantWorktrees && wm.isGitRepo({ cwd: context.directory })) { + const sentinel = wm.initSession(sessionId, { cwd: context.directory }); + worktreeNotes.push(`sentinel: ${sentinel.path}`); + for (const task of tasks) { + const name = `builder-${task.taskId}`.slice(0, 32); + try { + const info = wm.addAgent(name, sessionId, { cwd: context.directory }); + task.worktreePath = info.path; + worktreeNotes.push(`${task.taskId}: ${info.path} (branch ${info.branch})`); + } catch (err) { + worktreeNotes.push(`${task.taskId}: worktree failed — ${(err as Error).message}`); + } + } + } else { + worktreeNotes.push("skipped (not a git repo, or worktrees: false)"); + } + + // Scoped specs, one per task, schema-validated. + const specErrors: string[] = []; + for (const task of tasks) { + const spec = { + taskId: task.taskId, + sessionId, + title: task.title, + description: task.description ?? "", + requirements: task.acceptanceCriteria ?? [], + acceptanceCriteria: task.acceptanceCriteria ?? [], + verification: { programmatic: [], adversarial: [], rubric: [] }, + localChecks: [], + dependencies: task.dependsOn ?? [], + pitfalls: [], + ...(task.worktreePath ? { worktreePath: task.worktreePath } : {}), + budget: { maxRounds: task.maxRounds ?? 4, maxCostUsd: policy.budget.perTaskUsd }, + }; + const parsed = parseArtifact(SpecSchema, spec, `spec-${task.taskId}.json`); + if (!parsed.ok) { + specErrors.push(parsed.error); + continue; + } + writeFileSync( + join(runDir, `spec-${task.taskId}.json`), + `${JSON.stringify(parsed.value, null, 2)}\n`, + "utf-8", + ); + } + if (specErrors.length > 0) { + engine.abort(`spec validation failed: ${specErrors.join("; ")}`); + return `run aborted — ${specErrors.join("; ")}`; + } + + const waves = dispatchWaves({ + sessionId, + topology: args.topology, + tasks, + }); + + return [ + `run created: ${sessionId}`, + `run dir: ${runDir}`, + `topology: ${args.topology} | concurrency cap: ${engine.maxConcurrency} | budget: $${engine.budgetUsd.toFixed(2)} (halt at ${engine.haltAtPct}%)`, + `waves: ${waves.map((w, i) => `[${i + 1}] ${w.join(" + ")}`).join(" ")}`, + worktreeNotes.length > 0 ? `worktrees:\n ${worktreeNotes.join("\n ")}` : "", + `specs written: ${tasks.map((t) => `spec-${t.taskId}.json`).join(", ")}`, + "", + statusLine(engine), + "", + "Next: call teamwork_dispatch to get the first wave.", + ] + .filter((l) => l !== "") + .join("\n"); + }, +}); + +// ─── teamwork_dispatch ─────────────────────────────────────────────── + +export const teamworkDispatch: ToolDefinition = tool({ + description: + "Ask the engine which tasks run now. Returns ONLY tasks whose dependencies are COMPLETED, that are under the concurrency cap, and that the budget still allows — with the model to use, the spec path and the worktree. Then spawn one worker subagent per returned task. Call this repeatedly; do not decide order yourself.", + args: { + sessionId: tool.schema.string(), + taskId: tool.schema.string().optional().describe("dispatch a specific task instead of the next wave"), + limit: tool.schema.number().int().positive().optional(), + }, + async execute(args, context) { + const denied = gate(context, "teamwork_dispatch"); + if (denied) return denied; + + let engine: Engine; + try { + engine = openRun(context.directory, args.sessionId); + } catch (err) { + return `cannot open run ${args.sessionId} — ${(err as Error).message}`; + } + if (engine.budgetExhausted()) { + return `BUDGET EXHAUSTED — stop. ${statusLine(engine)}`; + } + + const ready = args.taskId + ? engine.dispatchable(engine.maxConcurrency).filter((t) => t.taskId === args.taskId) + : engine.dispatchable(args.limit); + if (ready.length === 0) { + const single = args.taskId + ? engine.dispatchable(engine.maxConcurrency).some((t) => t.taskId === args.taskId) + : false; + if (args.taskId && !single) { + return `${args.taskId} is not dispatchable — its dependencies are unfinished, it is already terminal, or the cap is full.\n${statusLine(engine)}\n${nextActions(engine)}`; + } + return `nothing dispatchable right now.\n${statusLine(engine)}\n${nextActions(engine)}`; + } + + const runDir = runDirFor(context.directory, args.sessionId); + const lines: string[] = []; + for (const task of ready) { + const attempt = deriveSession(readEvents(runDir)).tasks[task.taskId]?.attempts ?? 0; + const model = engine.modelFor(task.taskId, attempt); + const checks = engine.requiredChecksFor(task.taskId); + engine.dispatch(task.taskId, { model: model ?? null }); + lines.push( + [ + `task ${task.taskId} (attempt ${attempt + 1})`, + task.title ? ` title: ${task.title}` : "", + ` spec: ${join(runDir, `spec-${task.taskId}.json`)}`, + task.worktreePath ? ` worktree: ${task.worktreePath}` : "", + model ? ` model: ${model}` : "", + checks.length > 0 ? ` required checks for PASS: ${checks.join(", ")}` : "", + ] + .filter((l) => l !== "") + .join("\n"), + ); + } + + return [ + `dispatching ${ready.length} task(s):`, + ...lines, + "", + "For each: spawn a team/worker subagent with the spec path and worktree. When it returns,", + "spawn team/verifier against the same worktree + patch, then call teamwork_verify with the report.", + "", + statusLine(engine), + ].join("\n"); + }, +}); + +// ─── teamwork_verify ───────────────────────────────────────────────── + +const checkArg = tool.schema.object({ + name: tool.schema.string(), + type: tool.schema.enum(["programmatic", "adversarial", "rubric"]), + passed: tool.schema.boolean(), + cmd: tool.schema.string().optional().describe("the command that was executed"), + exitCode: tool.schema.number().int().optional().describe("its exit code (required for programmatic/adversarial)"), + stdoutSha256: tool.schema.string().optional().describe("sha256 of the captured stdout"), + durationMs: tool.schema.number().optional(), + output: tool.schema.string().optional(), + error: tool.schema.string().optional(), +}); + +export const teamworkVerify: ToolDefinition = tool({ + description: + "Submit a verifier report for a task. The engine validates it (a PASS must contain at least one executed check with a recorded exit code), then decides: COMPLETED, re-dispatch with feedback, or dead-letter after maxRounds. Returns what to do next.", + args: { + sessionId: tool.schema.string(), + taskId: tool.schema.string(), + reportPath: tool.schema + .string() + .optional() + .describe("path to verification_report.json written by the verifier (preferred)"), + status: tool.schema.enum(["PASS", "FAIL"]).optional().describe("required if reportPath is omitted"), + verifierAgent: tool.schema.string().optional(), + verifierModel: tool.schema.string().optional(), + checks: tool.schema.array(checkArg).optional(), + feedbackForWorker: tool.schema.string().optional(), + fatalFindings: tool.schema + .array( + tool.schema.object({ + where: tool.schema.string(), + why: tool.schema.string(), + fix: tool.schema.string().optional(), + reproduction: tool.schema.string().optional(), + }), + ) + .optional(), + costUsd: tool.schema.number().nonnegative().optional(), + }, + async execute(args, context) { + const denied = gate(context, "teamwork_verify"); + if (denied) return denied; + + let engine: Engine; + try { + engine = openRun(context.directory, args.sessionId); + } catch (err) { + return `cannot open run ${args.sessionId} — ${(err as Error).message}`; + } + + let raw: unknown; + if (args.reportPath) { + if (!existsSync(args.reportPath)) { + return `no report at ${args.reportPath} — the verifier must write it before you submit`; + } + try { + raw = JSON.parse(readFileSync(args.reportPath, "utf-8")); + } catch (err) { + return `report at ${args.reportPath} is not valid JSON — ${(err as Error).message}`; + } + } else { + raw = { + taskId: args.taskId, + verifierAgent: args.verifierAgent ?? "team/verifier", + verifierModel: args.verifierModel ?? "unknown", + timestamp: new Date().toISOString(), + status: args.status, + checks: args.checks ?? [], + feedbackForWorker: args.feedbackForWorker ?? "", + fatalFindings: args.fatalFindings ?? [], + }; + } + + const parsed = parseArtifact(VerificationReportSchema, raw, "verification_report.json"); + if (!parsed.ok) return `report rejected — ${parsed.error}`; + + const outcome = engine.recordRound({ + taskId: args.taskId, + report: parsed.value as VerificationReport, + ...(args.costUsd !== undefined ? { costUsd: args.costUsd } : {}), + }); + + if (!outcome.accepted) { + return [ + `report REJECTED for ${args.taskId} (round not counted):`, + ` ${outcome.rejection}`, + "", + "Fix the evidence and resubmit. A PASS needs at least one executed check with cmd + exitCode.", + ].join("\n"); + } + + const head = + outcome.status === "COMPLETED" + ? `task ${args.taskId} COMPLETED (round ${outcome.attempt}/${outcome.maxRounds})` + : outcome.status === "DEADLETTER" + ? `task ${args.taskId} DEAD-LETTERED after ${outcome.attempt}/${outcome.maxRounds} rounds — ${outcome.reason}` + : `task ${args.taskId} FAILED round ${outcome.attempt}/${outcome.maxRounds}`; + + return [ + head, + outcome.nextModel ? ` next attempt escalates to: ${outcome.nextModel}` : "", + outcome.status === "PENDING" ? ` feed the verifier's feedback back to a fresh worker` : "", + "", + statusLine(engine), + nextActions(engine), + ] + .filter((l) => l !== "") + .join("\n"); + }, +}); + +// ─── teamwork_status / teamwork_resume ─────────────────────────────── + +export const teamworkStatus: ToolDefinition = tool({ + description: + "Derive the run state from the append-only event log: per-task status, attempts, cost against budget, dead-letter queue, and whether the hash chain is intact. Use this instead of remembering state in context.", + args: { + sessionId: tool.schema.string(), + }, + async execute(args, context) { + const runDir = runDirFor(context.directory, args.sessionId); + if (!existsSync(runDir)) return `no run at ${runDir}`; + let engine: Engine; + try { + engine = openRun(context.directory, args.sessionId); + } catch (err) { + return `run ${args.sessionId} could not be replayed — ${(err as Error).message}`; + } + const s = engine.status(); + const rows = s.tasks.map( + (t) => + ` ${t.taskId.padEnd(24)} ${t.status.padEnd(11)} attempts=${t.attempts} dep=[${t.dependsOn.join(",")}] $${t.costUsd.toFixed(3)}`, + ); + return [ + statusLine(engine), + `session: ${s.sessionId} | topology: ${s.topology ?? "?"} | chain ${s.chain.ok ? "verified" : `BROKEN (${s.chain.reason})`}`, + ...rows, + "", + nextActions(engine), + ].join("\n"); + }, +}); + +export const teamworkResume: ToolDefinition = tool({ + description: + "Rebuild a run from its event log after a restart, compaction or crash. Verifies the hash chain first (a tampered log is refused), rewrites the derived state.json, and reports exactly where the run stopped and what to do next.", + args: { + sessionId: tool.schema.string(), + list: tool.schema.boolean().optional().describe("list every run in this project instead"), + }, + async execute(args, context) { + if (args.list) { + const base = join(context.directory, ".opencode", "teamwork"); + if (!existsSync(base)) return "no runs in this project"; + const { readdirSync } = await import("node:fs"); + const ids = readdirSync(base).filter((name) => existsSync(join(base, name, "events.jsonl"))); + return ids.length === 0 ? "no runs in this project" : `runs:\n${ids.map((i) => ` ${i}`).join("\n")}`; + } + const runDir = runDirFor(context.directory, args.sessionId); + if (!existsSync(runDir)) return `no run at ${runDir}`; + const events = readEvents(runDir); + if (events.length === 0) return `run ${args.sessionId} has no events`; + + const chain = verifyChain(events); + if (!chain.ok) { + return `REFUSING to resume ${args.sessionId}: event log is corrupt at seq ${chain.brokenAt} (${chain.reason}). Investigate before continuing — the log is the only record of what ran.`; + } + const derived = deriveSession(events); + writeSnapshot(runDir, derived); + const engine = openRun(context.directory, args.sessionId); + const last = events[events.length - 1]!; + return [ + `resumed ${args.sessionId} from ${events.length} events (chain verified)`, + `last event: ${last.type}${last.taskId ? ` (${last.taskId})` : ""} at ${last.ts}`, + "", + statusLine(engine), + nextActions(engine), + ].join("\n"); + }, +}); + +export const TEAMWORK_TOOLS: Record = { + teamwork_plan: teamworkPlan, + teamwork_dispatch: teamworkDispatch, + teamwork_verify: teamworkVerify, + teamwork_status: teamworkStatus, + teamwork_resume: teamworkResume, +}; + +/** Ensure the run directory exists before tools are used. */ +export function ensureRunDir(projectDir: string, sessionId: string): string { + const dir = runDirFor(projectDir, sessionId); + mkdirSync(dir, { recursive: true }); + return dir; +} diff --git a/src/worktree.ts b/src/worktree.ts index 4532835..91e3695 100644 --- a/src/worktree.ts +++ b/src/worktree.ts @@ -1,44 +1,93 @@ /** * Worktree manager for Teamwork. * - * Each agent (Sentinel, builder, verifier, searcher) gets its own - * git worktree so concurrent file edits don't collide. The Sentinel - * owns the orchestration; the workers never touch each other's - * worktrees. + * Each agent (sentinel, builder, verifier, searcher) gets its own git + * worktree so concurrent edits can't collide and the verifier can inspect a + * worker's branch without touching it. * - * This module is intentionally pure-Node — no shell-out where we - * can avoid it. The `git worktree add` step is the one place we - * shell out, because there's no Node API for it. + * Cross-platform + injection-safe by construction: every git call is spawned + * with an argv array and `shell: false`. The previous implementation built + * shell strings and escaped them POSIX-style, which is wrong on Windows + * (where `execSync` goes through cmd.exe) and dangerous because agent names + * come from a model-produced plan. `assertAgentName` fails closed on + * anything that isn't a plain identifier, so a hostile plan can't escape the + * run directory via `../`. */ -import { execSync } from "node:child_process"; -import { existsSync, mkdirSync, rmSync } from "node:fs"; -import { join, resolve, basename } from "node:path"; - -const SHELL_QUOTE = (s: string) => `"${s.replace(/(["\\$`])/g, "\\$1")}"`; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { basename, join, resolve, sep } from "node:path"; export interface WorktreeInfo { agentName: string; - path: string; // absolute path - branch: string; // e.g. "teamwork/agent-builder" - baseBranch: string; // e.g. "teamwork/base-" + path: string; // absolute path + branch: string; // e.g. "teamwork/agent-builder-" + baseBranch: string; // e.g. "teamwork/base-" } export interface WorktreeManager { - /** Create the base branch + Sentinel worktree for a session. */ initSession(sessionId: string, options?: { cwd?: string }): WorktreeInfo; - /** Create a worker worktree from a base branch. */ addAgent(agentName: string, sessionId: string, options?: { cwd?: string }): WorktreeInfo; - /** Remove an agent's worktree and prune. */ removeAgent(agentName: string, sessionId: string, options?: { cwd?: string }): void; - /** Remove all worktrees for a session (cleanup). */ cleanupSession(sessionId: string, options?: { cwd?: string }): void; - /** List worktrees. */ list(sessionId: string, options?: { cwd?: string }): WorktreeInfo[]; - /** Check if the current directory is a git repo. */ isGitRepo(options?: { cwd?: string }): boolean; } +const AGENT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/; +const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; + +export class WorktreeError extends Error { + constructor(message: string, readonly stderr?: string) { + super(message); + this.name = "WorktreeError"; + } +} + +/** Agent names end up in branch names and filesystem paths. Fail closed. */ +export function assertAgentName(name: string): void { + if (!AGENT_NAME_RE.test(name)) { + throw new WorktreeError( + `invalid agent name "${name}" — expected 1-32 chars of [A-Za-z0-9_-]`, + ); + } +} + +export function assertSessionId(sessionId: string): void { + if (!SESSION_ID_RE.test(sessionId)) { + throw new WorktreeError( + `invalid session id "${sessionId}" — expected 1-128 chars of [A-Za-z0-9._-]`, + ); + } +} + +interface GitResult { + ok: boolean; + stdout: string; + stderr: string; + status: number | null; +} + +/** Run git with an argv array. No shell, no string interpolation. */ +export function git(args: string[], cwd?: string): GitResult { + const res = spawnSync("git", args, { + cwd, + encoding: "utf-8", + shell: false, + windowsHide: true, + }); + const stdout = res.stdout ?? ""; + const stderr = res.stderr ?? ""; + if (res.error) { + return { ok: false, stdout, stderr: `${stderr}${res.error.message}`, status: null }; + } + return { ok: res.status === 0, stdout, stderr, status: res.status }; +} + +export function runDirFor(cwd: string, sessionId: string): string { + return join(cwd, ".opencode", "teamwork", sessionId); +} + function sessionDir(sessionId: string): string { return join(".opencode", "teamwork", sessionId); } @@ -51,137 +100,174 @@ function agentBranch(agentName: string, sessionId: string): string { return `teamwork/agent-${agentName}-${sessionId.slice(0, 8)}`; } -function run(cmd: string, cwd?: string): string { - try { - return execSync(cmd, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim(); - } catch (err) { - const e = err as { stdout?: string; stderr?: string; status?: number }; - throw new Error( - `Command failed: ${cmd}\n` + - `exit: ${e.status}\n` + - `stderr: ${e.stderr}\n` + - `stdout: ${e.stdout ?? ""}`, - ); +function worktreesRoot(cwd: string, sessionId: string): string { + return resolve(cwd, sessionDir(sessionId), "worktrees"); +} + +/** Refuse to touch a path outside the run's worktrees directory. */ +function assertInside(root: string, target: string): void { + const normalizedRoot = resolve(root); + const normalizedTarget = resolve(target); + if (normalizedTarget !== normalizedRoot && !normalizedTarget.startsWith(normalizedRoot + sep)) { + throw new WorktreeError(`refusing to operate outside ${normalizedRoot}: ${normalizedTarget}`); } } export function createWorktreeManager(): WorktreeManager { return { isGitRepo(opts) { - try { - run("git rev-parse --git-dir", opts?.cwd); - return true; - } catch { - return false; - } + return git(["rev-parse", "--git-dir"], opts?.cwd).ok; }, initSession(sessionId, opts) { + assertSessionId(sessionId); const cwd = opts?.cwd ?? process.cwd(); + const dir = join(worktreesRoot(cwd, sessionId), "sentinel"); + if (!this.isGitRepo({ cwd })) { - // No git repo — we still create the .opencode/teamwork - // directory, but skip the worktree. The user can initialize - // git later. - const dir = resolve(cwd, sessionDir(sessionId), "worktrees", "sentinel"); + // Not a git repo: keep the directory layout so artifacts and paths + // stay stable, but skip git entirely. The caller is told via the + // branch name being the base branch. mkdirSync(dir, { recursive: true }); return { agentName: "sentinel", path: dir, branch: baseBranch(sessionId), baseBranch: baseBranch(sessionId) }; } const base = baseBranch(sessionId); - // Create the base branch from current HEAD. If it already - // exists, this is a resume — just check it out. - try { - run(`git rev-parse --verify ${SHELL_QUOTE(base)}`, cwd); - } catch { - run(`git branch ${SHELL_QUOTE(base)}`, cwd); + const exists = git(["rev-parse", "--verify", base], cwd).ok; + if (!exists) { + const created = git(["branch", base], cwd); + if (!created.ok) throw new WorktreeError(`git branch ${base} failed`, created.stderr); } - const dir = resolve(cwd, sessionDir(sessionId), "worktrees", "sentinel"); - try { - run(`git worktree add ${SHELL_QUOTE(dir)} ${SHELL_QUOTE(base)}`, cwd); - } catch (err) { - // Worktree already exists — that's OK on resume. - if (!(err as Error).message.includes("already exists")) throw err; + mkdirSync(worktreesRoot(cwd, sessionId), { recursive: true }); + const added = git(["worktree", "add", dir, base], cwd); + if (!added.ok && !/already exists|is already registered/i.test(added.stderr)) { + throw new WorktreeError(`git worktree add failed for sentinel`, added.stderr); } return { agentName: "sentinel", path: dir, branch: base, baseBranch: base }; }, addAgent(agentName, sessionId, opts) { + assertAgentName(agentName); + assertSessionId(sessionId); const cwd = opts?.cwd ?? process.cwd(); const base = baseBranch(sessionId); const branch = agentBranch(agentName, sessionId); - const dir = resolve(cwd, sessionDir(sessionId), "worktrees", `agent-${agentName}`); - mkdirSync(resolve(cwd, sessionDir(sessionId), "worktrees"), { recursive: true }); - try { - run(`git worktree add ${SHELL_QUOTE(dir)} -b ${SHELL_QUOTE(branch)} ${SHELL_QUOTE(base)}`, cwd); - } catch (err) { - if (!(err as Error).message.includes("already exists")) throw err; - // Resume: branch exists, just re-add the worktree - try { - run(`git worktree add ${SHELL_QUOTE(dir)} ${SHELL_QUOTE(branch)}`, cwd); - } catch (err2) { - if (!(err2 as Error).message.includes("already exists")) throw err2; + const root = worktreesRoot(cwd, sessionId); + const dir = join(root, `agent-${agentName}`); + assertInside(root, dir); + mkdirSync(root, { recursive: true }); + + const added = git(["worktree", "add", dir, "-b", branch, base], cwd); + if (!added.ok) { + if (!/already exists|is already registered/i.test(added.stderr)) { + throw new WorktreeError(`git worktree add failed for ${agentName}`, added.stderr); + } + // Resume path: branch exists, worktree may or may not. + if (!existsSync(dir)) { + const reattach = git(["worktree", "add", dir, branch], cwd); + if (!reattach.ok && !/already exists|is already registered/i.test(reattach.stderr)) { + throw new WorktreeError(`git worktree re-attach failed for ${agentName}`, reattach.stderr); + } } } return { agentName, path: dir, branch, baseBranch: base }; }, removeAgent(agentName, sessionId, opts) { + assertAgentName(agentName); + assertSessionId(sessionId); const cwd = opts?.cwd ?? process.cwd(); - const branch = agentBranch(agentName, sessionId); - const dir = resolve(cwd, sessionDir(sessionId), "worktrees", `agent-${agentName}`); - try { - run(`git worktree remove --force ${SHELL_QUOTE(dir)}`, cwd); - } catch { - // best effort - } - try { - run(`git branch -D ${SHELL_QUOTE(branch)}`, cwd); - } catch { - // best effort - } - try { - run(`git worktree prune`, cwd); - } catch { - // best effort - } + const root = worktreesRoot(cwd, sessionId); + const dir = join(root, `agent-${agentName}`); + assertInside(root, dir); + git(["worktree", "remove", "--force", dir], cwd); // best effort + git(["branch", "-D", agentBranch(agentName, sessionId)], cwd); // best effort + git(["worktree", "prune"], cwd); }, cleanupSession(sessionId, opts) { + assertSessionId(sessionId); const cwd = opts?.cwd ?? process.cwd(); - const dir = resolve(cwd, sessionDir(sessionId), "worktrees"); - if (existsSync(dir)) { - run(`git worktree remove --force ${SHELL_QUOTE(dir)} 2>/dev/null || true`, cwd); - rmSync(dir, { recursive: true, force: true }); + const root = worktreesRoot(cwd, sessionId); + + // Remove worktrees git actually knows about. The old implementation ran + // `git worktree remove` against the *parent* directory (never a + // worktree), swallowed the failure, then rmSync'd directories git still + // had registered — leaving stale worktree metadata behind. + for (const info of this.list(sessionId, { cwd })) { + git(["worktree", "remove", "--force", info.path], cwd); } - // Delete all session branches - try { - run(`git branch -D $(git branch --list "teamwork/*-${sessionId.slice(0, 8)}") 2>/dev/null || true`, cwd); - } catch { - // best effort + if (existsSync(root)) { + for (const entry of readWorktreeDirs(root)) { + assertInside(root, entry); + rmSync(entry, { recursive: true, force: true }); + } + rmSync(root, { recursive: true, force: true }); } - try { - run(`git worktree prune`, cwd); - } catch { - // best effort + // Delete session branches, then prune. Branch names come from git, not + // from a shell expansion (the old code used $(...) which is a no-op on + // Windows and unquoted elsewhere). + const suffix = sessionId.slice(0, 8); + const branches = git(["branch", "--list", `teamwork/*-${suffix}`], cwd); + if (branches.ok) { + for (const line of branches.stdout.split("\n")) { + const name = line.replace(/^\*?\s*/, "").trim(); + if (!name) continue; + git(["branch", "-D", name], cwd); + } } + const base = baseBranch(sessionId); + if (git(["rev-parse", "--verify", base], cwd).ok) { + git(["branch", "-D", base], cwd); + } + git(["worktree", "prune"], cwd); }, list(sessionId, opts) { + assertSessionId(sessionId); const cwd = opts?.cwd ?? process.cwd(); - const out = run(`git worktree list --porcelain`, cwd); - const out2: WorktreeInfo[] = []; - const prefix = sessionDir(sessionId); - for (const block of out.split("\n\n")) { - const pathLine = block.split("\n").find((l) => l.startsWith("worktree ")); - const branchLine = block.split("\n").find((l) => l.startsWith("branch ")); + const out = git(["worktree", "list", "--porcelain"], cwd); + const results: WorktreeInfo[] = []; + if (!out.ok) return results; + const prefix = sessionDir(sessionId).split(sep).join("/"); + for (const block of out.stdout.split(/\r?\n\r?\n/)) { + const lines = block.split(/\r?\n/); + const pathLine = lines.find((l) => l.startsWith("worktree ")); + const branchLine = lines.find((l) => l.startsWith("branch ")); if (!pathLine || !branchLine) continue; - const path = pathLine.replace(/^worktree /, "").trim(); - if (!path.includes(prefix)) continue; - const branch = branchLine.replace(/^branch /, "").replace(/^refs\/heads\//, "").trim(); - const agentName = basename(path).replace(/^agent-/, ""); - out2.push({ agentName, path, branch, baseBranch: baseBranch(sessionId) }); + const path = pathLine.slice("worktree ".length).trim(); + const normalized = path.split(sep).join("/"); + if (!normalized.includes(prefix)) continue; + const branch = branchLine + .slice("branch ".length) + .trim() + .replace(/^refs\/heads\//, ""); + const agentName = basename(path).replace(/^agent-/, "") || "sentinel"; + results.push({ agentName, path, branch, baseBranch: baseBranch(sessionId) }); } - return out2; + return results; }, }; } + +function readWorktreeDirs(root: string): string[] { + try { + return readdirSync(root).map((name) => join(root, name)); + } catch { + return []; + } +} + +/** + * Read a file from a worktree at a recorded commit-ish, without checking it + * out. Used by the verifier to hash evidence deterministically. + */ +export function readFromWorktree(worktreePath: string, relPath: string): string | null { + const target = join(worktreePath, relPath); + if (!existsSync(target)) return null; + try { + return readFileSync(target, "utf-8"); + } catch { + return null; + } +} diff --git a/test/engine.test.ts b/test/engine.test.ts new file mode 100644 index 0000000..4ec8bc7 --- /dev/null +++ b/test/engine.test.ts @@ -0,0 +1,473 @@ +/** + * Engine, event-log and config-injection tests. + * + * These run without a model, a network, or git: the whole point of moving the + * run loop into code is that the scheduler becomes testable. `bun test`. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + Engine, + dispatchWaves, + findCycle, + validatePlan, + validateReport, + type DagTask, +} from "../src/engine.js"; +import { + appendEvent, + deriveSession, + readEvents, + verifyChain, +} from "../src/events.js"; +import { agentConfigFor, AGENT_TEMPLATES, getAllCommands } from "../src/templates.js"; +import { TOPOLOGIES, assertTopologiesResolve, getTopology, isTopology } from "../src/policy.js"; +import { RoleRegistry, isWriteTool } from "../src/guard.js"; +import { assertAgentName, assertSessionId } from "../src/worktree.js"; +import { parseCommandFlags } from "../src/index.js"; + +let root: string; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "teamwork-test-")); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +const runDir = (id = "s1") => join(root, ".opencode", "teamwork", id); + +function tasks(...specs: Array<[string, string[]]>): DagTask[] { + return specs.map(([id, deps]) => ({ taskId: id, title: `task ${id}`, dependsOn: deps })); +} + +function passReport(taskId: string) { + return { + taskId, + verifierAgent: "team/verifier", + verifierModel: "test/model", + timestamp: new Date().toISOString(), + status: "PASS" as const, + checks: [ + { + name: "unit", + type: "programmatic" as const, + passed: true, + cmd: "bun test", + exitCode: 0, + stdoutSha256: "0".repeat(64), + }, + ], + feedbackForWorker: "", + fatalFindings: [], + }; +} + +function failReport(taskId: string) { + return { + ...passReport(taskId), + status: "FAIL" as const, + checks: [ + { + name: "unit", + type: "programmatic" as const, + passed: false, + cmd: "bun test", + exitCode: 1, + output: "AssertionError: expected 5, got 4", + }, + ], + feedbackForWorker: "off-by-one in the loop bound", + }; +} + +// ─── Scheduling ────────────────────────────────────────────────────── + +describe("plan validation and scheduling", () => { + test("groups independent tasks into the same wave", () => { + const plan = { + sessionId: "s1", + topology: "distributed-coding", + tasks: tasks(["T1", []], ["T2", ["T1"]], ["T3", []], ["T4", ["T2", "T3"]]), + }; + expect(dispatchWaves(plan)).toEqual([["T1", "T3"], ["T2"], ["T4"]]); + }); + + test("rejects cycles, unknown deps, duplicates and bad topologies", () => { + const cycle = { + sessionId: "s1", + topology: "small-focused", + tasks: tasks(["A", ["B"]], ["B", ["A"]]), + }; + expect(findCycle(cycle.tasks)).toEqual(["A", "B", "A"]); + const v = validatePlan(cycle); + expect(v.ok).toBe(false); + expect(v.errors.join(" ")).toContain("cycle"); + + const bad = validatePlan({ + sessionId: "s1", + topology: "proof", // the old, non-existent name + tasks: tasks(["A", ["ghost"]], ["A", []]), + }); + expect(bad.ok).toBe(false); + expect(bad.errors.join(" ")).toContain("unknown topology"); + expect(bad.errors.join(" ")).toContain("unknown task"); + expect(bad.errors.join(" ")).toContain("duplicate"); + }); + + test("every topology name maps to a pattern file on disk", () => { + const check = assertTopologiesResolve(); + expect(check.resolvedDir).not.toBeNull(); + expect(check.ok).toBe(true); + expect(check.missing).toEqual([]); + for (const t of TOPOLOGIES) { + expect(isTopology(t.name)).toBe(true); + expect(getTopology(t.name)?.file).toBe(t.file); + } + }); +}); + +// ─── Engine ────────────────────────────────────────────────────────── + +describe("engine", () => { + test("dispatches in topological order and honours the concurrency cap", () => { + const engine = Engine.create({ + runDir: runDir(), + sessionId: "s1", + topology: "distributed-coding", + tasks: tasks(["T1", []], ["T2", ["T1"]], ["T3", []]), + maxConcurrency: 2, + now: () => "2026-09-19T00:00:00.000Z", + }); + + const first = engine.dispatchable(); + expect(first.map((t) => t.taskId)).toEqual(["T1", "T3"]); // cap of 2, T2 blocked + for (const t of first) engine.dispatch(t.taskId); + + // Both in flight -> no capacity. + expect(engine.dispatchable()).toEqual([]); + + engine.recordRound({ taskId: "T1", report: passReport("T1") }); + engine.recordRound({ taskId: "T3", report: passReport("T3") }); + + const second = engine.dispatchable(); + expect(second.map((t) => t.taskId)).toEqual(["T2"]); // dependency now met + }); + + test("retries on FAIL, then dead-letters after maxRounds", () => { + const engine = Engine.create({ + runDir: runDir(), + sessionId: "s1", + topology: "small-focused", + tasks: [{ taskId: "T1", title: "flaky", dependsOn: [], maxRounds: 3 }], + }); + + const r1 = engine.recordRound({ taskId: "T1", report: failReport("T1") }); + expect(r1.accepted).toBe(true); + expect(r1.status).toBe("PENDING"); + expect(r1.attempt).toBe(1); + expect(r1.nextModel).toBeDefined(); + + const r2 = engine.recordRound({ taskId: "T1", report: failReport("T1") }); + expect(r2.status).toBe("PENDING"); + expect(r2.attempt).toBe(2); + + const r3 = engine.recordRound({ taskId: "T1", report: failReport("T1") }); + expect(r3.status).toBe("DEADLETTER"); + expect(r3.reason).toContain("maxRounds"); + + const status = engine.status(); + expect(status.deadletter).toEqual(["T1"]); + expect(status.tasks[0]?.attempts).toBe(3); + }); + + test("escalates the model per failed round via the ladder", () => { + const engine = Engine.create({ + runDir: runDir(), + sessionId: "s1", + topology: "distributed-coding", + tasks: [ + { taskId: "T1", title: "x", dependsOn: [], taskClass: "taskClass:bugfix-single-file" }, + ], + }); + expect(engine.modelFor("T1", 0)).toBe("anthropic/claude-sonnet-4-5"); + expect(engine.modelFor("T1", 1)).toBe("anthropic/claude-opus-4-5"); + expect(engine.modelFor("T1", 9)).toBe("anthropic/claude-opus-4-5"); // holds at the top rung + }); + + test("stops dispatching when the budget is exhausted, in code", () => { + const engine = Engine.create({ + runDir: runDir(), + sessionId: "s1", + topology: "distributed-coding", + tasks: tasks(["T1", []], ["T2", []]), + budgetUsd: 1, + }); + expect(engine.dispatchable().length).toBeGreaterThan(0); + + engine.recordRound({ taskId: "T1", report: passReport("T1"), costUsd: 1.5 }); + expect(engine.budgetExhausted()).toBe(true); + expect(engine.dispatchable()).toEqual([]); + + const types = engine.events().map((e) => e.type); + expect(types).toContain("budget.exhausted"); + }); + + test("marks the session done when every task is terminal", () => { + const engine = Engine.create({ + runDir: runDir(), + sessionId: "s1", + topology: "small-focused", + tasks: tasks(["T1", []], ["T2", ["T1"]]), + }); + engine.recordRound({ taskId: "T1", report: passReport("T1") }); + engine.recordRound({ taskId: "T2", report: passReport("T2") }); + expect(engine.status().state).toBe("DONE"); + }); +}); + +// ─── The PASS must be evidence ─────────────────────────────────────── + +describe("verification report validation", () => { + test("refuses a PASS with no executed check", () => { + const report = { + ...passReport("T1"), + checks: [ + { name: "looks right", type: "rubric" as const, passed: true, output: "seems fine" }, + ], + }; + const v = validateReport(report); + expect(v.ok).toBe(false); + expect(v.errors.join(" ")).toContain("at least one executed check"); + }); + + test("refuses a PASS whose executed check has no exit code", () => { + const report = { + ...passReport("T1"), + checks: [{ name: "unit", type: "programmatic" as const, passed: true }], + }; + const v = validateReport(report); + expect(v.ok).toBe(false); + expect(v.errors.join(" ")).toContain("must record cmd + exitCode"); + }); + + test("refuses a PASS containing a failed executed check", () => { + const report = { + ...passReport("T1"), + checks: [ + ...passReport("T1").checks, + { name: "adversarial", type: "adversarial" as const, passed: false, cmd: "fuzz", exitCode: 1 }, + ], + }; + expect(validateReport(report).ok).toBe(false); + }); + + test("requires the policy's mandatory checks for the task class", () => { + const v = validateReport(passReport("T1"), ["adversarial:privilege-escalation"]); + expect(v.ok).toBe(false); + expect(v.errors.join(" ")).toContain("required check"); + }); + + test("a rejected report does not count as a round", () => { + const engine = Engine.create({ + runDir: runDir(), + sessionId: "s1", + topology: "small-focused", + tasks: tasks(["T1", []]), + }); + const bad = { + ...passReport("T1"), + checks: [{ name: "vibes", type: "rubric" as const, passed: true }], + }; + const outcome = engine.recordRound({ taskId: "T1", report: bad as never }); + expect(outcome.accepted).toBe(false); + expect(outcome.rejection).toContain("executed check"); + expect(engine.status().tasks[0]?.attempts).toBe(0); + }); +}); + +// ─── Event log ─────────────────────────────────────────────────────── + +describe("event log", () => { + test("chains events and detects tampering", () => { + const dir = runDir(); + appendEvent(dir, { type: "session.start", sessionId: "s1" }); + appendEvent(dir, { type: "plan.written", sessionId: "s1" }); + appendEvent(dir, { type: "task.completed", sessionId: "s1", taskId: "T1" }); + + const events = readEvents(dir); + expect(events.length).toBe(3); + expect(verifyChain(events).ok).toBe(true); + expect(events[2]?.prevHash).toBe(events[1]?.hash); + + // Rewrite history: flip a task id and keep the old hash. + const lines = readFileSync(join(dir, "events.jsonl"), "utf-8") + .split("\n") + .filter((l) => l.trim()); + const altered = JSON.parse(lines[2]!); + altered.taskId = "T9"; + lines[2] = JSON.stringify(altered); + writeFileSync(join(dir, "events.jsonl"), `${lines.join("\n")}\n`, "utf-8"); + + const check = verifyChain(readEvents(dir)); + expect(check.ok).toBe(false); + expect(check.brokenAt).toBe(3); + }); + + test("replays a run: resume rebuilds state after a restart", () => { + const dir = runDir(); + const first = Engine.create({ + runDir: dir, + sessionId: "s1", + topology: "small-focused", + tasks: tasks(["T1", []], ["T2", ["T1"]]), + }); + first.recordRound({ taskId: "T1", report: passReport("T1"), costUsd: 0.25 }); + + const resumed = Engine.resume(dir); + const status = resumed.status(); + expect(status.tasks.find((t) => t.taskId === "T1")?.status).toBe("COMPLETED"); + expect(status.tasks.find((t) => t.taskId === "T2")?.status).toBe("PENDING"); + expect(status.costUsd).toBeCloseTo(0.25, 5); + expect(resumed.dispatchable().map((t) => t.taskId)).toEqual(["T2"]); + }); + + test("deriveSession sums cost and rounds from events only", () => { + const dir = runDir(); + const engine = Engine.create({ + runDir: dir, + sessionId: "s1", + topology: "small-focused", + tasks: tasks(["T1", []]), + }); + engine.recordRound({ taskId: "T1", report: failReport("T1"), costUsd: 0.1 }); + engine.recordRound({ taskId: "T1", report: passReport("T1"), costUsd: 0.2 }); + const derived = deriveSession(readEvents(dir)); + expect(derived.rounds).toBe(2); + expect(derived.costUsd).toBeCloseTo(0.3, 5); + expect(derived.tasks["T1"]?.status).toBe("COMPLETED"); + }); +}); + +// ─── Config injection (the P0 regression) ──────────────────────────── + +describe("agent config injection", () => { + test("injects real config keys instead of frontmatter-in-a-prompt", () => { + const verifier = AGENT_TEMPLATES.find((a) => a.name === "verifier"); + expect(verifier).toBeDefined(); + const config = agentConfigFor(verifier!); + + expect(config.mode).toBe("subagent"); + expect(config.permission.edit).toBe("deny"); // enforced, not narrated + expect(config.permission.task).toBe("deny"); // no runaway fan-out + expect(config.temperature).toBe(0); + // The prompt must NOT contain the YAML frontmatter any more. + expect(config.prompt.startsWith("---")).toBe(false); + expect(config.prompt).not.toContain("permission:"); + expect(config.description?.length ?? 0).toBeGreaterThan(10); + }); + + test("preserves a model the user already configured (installer regression)", () => { + const sentinel = AGENT_TEMPLATES.find((a) => a.name === "sentinel")!; + const config = agentConfigFor(sentinel, { model: "anthropic/claude-opus-4-5" }); + expect(config.model).toBe("anthropic/claude-opus-4-5"); // not clobbered by the template default + }); + + test("every team agent declares an explicit mode and permission block", () => { + for (const agent of AGENT_TEMPLATES) { + const config = agentConfigFor(agent); + expect(["primary", "subagent", "all"]).toContain(config.mode); + expect(["allow", "deny", "ask"]).toContain(config.permission.edit); + expect(config.prompt.length).toBeGreaterThan(200); + } + }); + + test("commands carry no template language OpenCode cannot expand", () => { + for (const cmd of getAllCommands()) { + expect(cmd.template).not.toContain("{{"); + expect(cmd.agent.startsWith("team/")).toBe(true); + } + const teamwork = getAllCommands().find((c) => c.name === "teamwork")!; + const occurrences = teamwork.template.split("$ARGUMENTS").length - 1; + expect(occurrences).toBe(1); // was duplicated in the old template + }); +}); + +// ─── Runtime guard ─────────────────────────────────────────────────── + +describe("role guard", () => { + test("blocks write tools for read-only roles and allows workers", () => { + const roles = new RoleRegistry(); + roles.remember("sess-verifier", "team/verifier"); + roles.remember("sess-worker", "team/worker"); + roles.remember("sess-build", "build"); + + expect(roles.blockReason("sess-verifier", "edit")).toContain("read-only"); + expect(roles.blockReason("sess-verifier", "write")).toContain("read-only"); + expect(roles.blockReason("sess-verifier", "bash")).toBeNull(); // the verifier must run tests + expect(roles.blockReason("sess-worker", "edit")).toBeNull(); + expect(roles.blockReason("sess-build", "edit")).toBeNull(); // users' own agents untouched + expect(isWriteTool("apply_patch")).toBe(true); + }); + + test("a user override of the permission block is respected", () => { + const roles = new RoleRegistry(); + roles.remember("s", "team/verifier"); + roles.setPermissions("team/verifier", { + edit: "allow", + bash: "allow", + webfetch: "allow", + task: "allow", + }); + expect(roles.blockReason("s", "edit")).toBeNull(); + }); +}); + +// ─── Worktree safety ───────────────────────────────────────────────── + +describe("worktree safety", () => { + test("rejects agent names that could escape the run directory", () => { + expect(() => assertAgentName("builder-T1")).not.toThrow(); + expect(() => assertAgentName("../../etc/passwd")).toThrow(); + expect(() => assertAgentName("a; rm -rf /")).toThrow(); + expect(() => assertAgentName("$(whoami)")).toThrow(); + expect(() => assertSessionId("2026-09-19T00-00-00-abc123")).not.toThrow(); + expect(() => assertSessionId("../../x")).toThrow(); + }); +}); + +// ─── Command flag parsing ──────────────────────────────────────────── + +describe("command flags", () => { + const mint = () => "minted-id"; + + test("parses --topology, --budget and --concurrency in code", () => { + const f = parseCommandFlags( + '--topology long-proof --budget 42 --concurrency=3 "prove Knuth cycles"', + mint, + ); + expect(f.topology).toBe("long-proof"); + expect(f.budgetUsd).toBe(42); + expect(f.maxConcurrency).toBe(3); + expect(f.request).toBe('"prove Knuth cycles"'); + expect(f.warnings).toEqual([]); + }); + + test("mints a session id and warns about an unknown topology", () => { + const f = parseCommandFlags("--topology proof fix the bug", mint); + expect(f.sessionId).toBe("minted-id"); + expect(f.topology).toBeUndefined(); + expect(f.warnings[0]).toContain("unknown --topology"); + expect(f.request).toBe("fix the bug"); + }); + + test("rejects non-numeric budgets", () => { + const f = parseCommandFlags("--budget lots fix it", mint); + expect(f.budgetUsd).toBeUndefined(); + expect(f.warnings.join(" ")).toContain("--budget"); + }); +});