diff --git a/CLAUDE.md b/CLAUDE.md index 444dc82..459c588 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,8 @@ its status — there is no other source of truth. │ │ put anything project-specific here. │ ├── VERSION, board.py, config.py … httpd.py, board.html │ ├── prompts/ ← Default agent prompt templates - │ ├── adapters/ ← Agent-vendor integrations (claude/ ships; README) + │ ├── adapters/ ← Agent-vendor integrations (claude/ and + │ │ opencode/ ship; contract in README) │ └── driver.example/ └── local/ ← This project's half. Updates never touch it. ├── .env ← Settings (gitignored; defaults in core/.env.example) @@ -60,8 +61,13 @@ config → state → taskfiles → events / github / drive → agents → watch Headless jobs run through an adapter (`BOARD_AGENT_ADAPTER`, default `claude`), so the manager works with other coding agents too. An adapter is a directory with `run` (execute one job: `AGENT_PROMPT` + `AGENT_MODE` -work|review in, stdout = the log, markers parsed from it) and `wire` -(idempotently give the host project live-session visibility). Adapters +work|act-pr|review + `AGENT_COMMANDS` in, stdout = the log, markers parsed +from it) and `wire` (idempotently give the host project live-session +visibility). Headless jobs answer no permission prompts, so each intent is +granted exactly the side effects its prompt demands — commit and test for +work, push for act-pr, posting PR verdicts for review — with the project's +own runnable commands coming from `BOARD_AGENT_COMMANDS` as neutral +prefixes each adapter renders in its vendor's rule syntax. Adapters translate their vendor's events into the board's normalized schema at the edge — core never sees vendor payloads. The full contract, including the event schema, lives in `core/adapters/README.md`. @@ -153,7 +159,7 @@ the command again while it is already up just reopens that tab rather than failing on a port clash. All settings live in `manager/core/.env.example` with their defaults documented — -the port, the claude binary agents launch with, the agent permission mode, +the port, the binaries agents launch with, the commands agents may run, the worktrees directory, the watch interval and the in-memory caps. Copy it to `manager/local/.env` (gitignored) to override locally; real environment variables beat `.env`, which beats the defaults. The hook bridge reads the @@ -231,9 +237,11 @@ only then does work start — the server refuses launches from anywhere else. checkout anyway.) 2. The agent works in the worktree: implements, tests, commits. Its hook events stream to the board like any session. -3. On clean exit the board moves the card to `review/`; on failure it stays - in `in-progress/` and the exit is narrated in the ticker. Stdout is kept in - `.agent/logs/`. +3. On clean exit with commits on the branch the board moves the card to + `review/`; on failure it stays in `in-progress/` and the exit is narrated + in the ticker. A clean exit that committed *nothing* also stays in + `in-progress/` and is called out loudly — an empty branch reaching + review/ is how a broken launch hides. Stdout is kept in `.agent/logs/`. ## Pull requests diff --git a/manager/core/.env.example b/manager/core/.env.example index 2936699..10a5475 100644 --- a/manager/core/.env.example +++ b/manager/core/.env.example @@ -7,12 +7,21 @@ BOARD_PORT=26071 # Which agent adapter runs headless jobs (core/adapters/, overridable -# in local/adapters/). Ships with: claude. +# in local/adapters/). Ships with: claude, opencode. BOARD_AGENT_ADAPTER=claude -# claude adapter: the binary jobs are launched with. Point it at a stub -# script to test the board's plumbing without spending tokens. +# The binary each adapter launches jobs with. Point them at stub scripts +# to test the board's plumbing without spending tokens. BOARD_CLAUDE_BIN=claude +BOARD_OPENCODE_BIN=opencode + +# Command prefixes headless agents may run in their worktree — the +# project's test/check commands, comma-separated, in plain neutral form +# (each adapter renders them into its vendor's permission rules; the +# git/gh grants per launch intent are built in). Headless runs have no +# human at a permission prompt, so a test runner missing from this list +# is a test the work agent cannot run. +BOARD_AGENT_COMMANDS=python3 -m unittest,python3 -m pytest # Where work-agent worktrees are created, relative to the repo root. BOARD_WORKTREES=.worktrees diff --git a/manager/core/adapters/README.md b/manager/core/adapters/README.md index 5d13aa2..4a4dc0c 100644 --- a/manager/core/adapters/README.md +++ b/manager/core/adapters/README.md @@ -2,9 +2,10 @@ An adapter makes the task manager work with a particular coding agent. Core never speaks any vendor's language — it launches jobs and ingests -normalized events; adapters translate at the edge. `claude/` ships as the -default. Select with `BOARD_AGENT_ADAPTER` in `local/.env`; a directory of -the same name under `local/adapters/` overrides the core one. +normalized events; adapters translate at the edge. Two ship as reference +implementations: `claude/` (the default) and `opencode/`. Select with +`BOARD_AGENT_ADAPTER` in `local/.env`; a directory of the same name under +`local/adapters/` overrides the core one. ## The contract @@ -12,26 +13,59 @@ An adapter is a directory with two executables: ### `run` — execute one headless job to completion -- env in: `AGENT_PROMPT` (the full prompt), `AGENT_MODE` (`work` = may - mutate the checkout, `review` = read-only — map this intent to whatever - permission mechanism your agent has), `AGENT_CWD`, and the `BOARD_*` - passthrough (`BOARD_AGENT_ID`, `BOARD_TASK`, `BOARD_PORT`) which your - event bridge must forward with every event. +- env in: `AGENT_PROMPT` (the full prompt), `AGENT_MODE` (the launch + intent, below), `AGENT_COMMANDS` (the project's allowed command + prefixes, below), `AGENT_CWD`, and the `BOARD_*` passthrough + (`BOARD_AGENT_ID`, `BOARD_TASK`, `BOARD_PORT`) which your event bridge + must forward with every event. - stdout is captured by the board as the job log. The prompts instruct the agent to end with marker lines (`NOT READY:`, `RELEVANCE REVIEW:`, `PR REVIEW:`, `ADDRESSED:`) — the board parses them from this output, so the agent's final text must reach stdout. - exit 0 = completed; anything else = failed. +### Launch intents (`AGENT_MODE`) + +Core signals *intent*; every adapter maps it to its vendor's permission +mechanism. Headless runs have no human at a permission prompt, so +anything not auto-approved is denied — grant each intent exactly the +side effects its prompt demands, and never a blanket allow-everything +(the worktree is isolated, the shell is not): + +- `work` — implement, test, commit in an isolated worktree. May edit + files, run local git bookkeeping (`git add/commit/status/diff`) and + the project's `AGENT_COMMANDS`. No push. +- `act-pr` — the work stance, plus `git push` (the PR must update) and + reading the PR's reviews and line comments (`gh pr view`, `gh pr + diff`, `gh api`). +- `review` — read-only on the working tree: no edit tools, no commits. + May read a PR (`gh pr view`, `gh pr diff`, read-only git) and post the + verdict (`gh pr review`, `gh pr comment`). + +### The project's allowed commands (`AGENT_COMMANDS`) + +The git/`gh` grants above are universal; which test/check commands a +project's agents run is project knowledge. It arrives as comma-separated +plain command *prefixes* — `BOARD_AGENT_COMMANDS` in `local/.env`, e.g. +`python3 -m unittest,npm test` — never in any vendor's rule syntax. Each +adapter renders them natively; both shipped rule languages are +prefix-pattern based, so the translation is mechanical: + +- claude → `Bash(git commit:*)`-style allow-rules in the generated + settings JSON (`claude/hook_settings.py`) +- opencode → `"permission": {"bash": {"*": "deny", "git commit *": + "allow"}}` in a generated config, wildcard rules, last match wins + (`opencode/permission_config.py`) + ### `wire` — wire live-session visibility into the host project Called by `install.py` with the project root as argv[1] (plus `--dry-run`). Idempotently make the project's own interactive sessions report events — -however your platform allows (Claude Code: hooks; opencode: a plugin -subscribing to its event bus). Print a report; exit 0 on ok/fixed. If the -platform has no way to observe sessions, be a no-op with an honest message: -the board still runs headless jobs via `run`, you just lose the live -play-by-play. +however your platform allows (Claude Code: hooks in `.claude/settings.json`; +opencode: a plugin shim in `.opencode/plugin/` subscribing to its event +bus). Print a report; exit 0 on ok/fixed. If the platform has no way to +observe sessions, be a no-op with an honest message: the board still runs +headless jobs via `run`, you just lose the live play-by-play. ### Events — the normalized schema (v1) @@ -47,11 +81,17 @@ the live line, not appended to the timeline); follow it with the completed event. `kind: idle` = finished responding; `kind: end` = session over. Classification happens in YOUR emitter — core never sees vendor payloads. -## Writing one (e.g. for opencode) +## Writing one -- `run`: `opencode run "$AGENT_PROMPT"` with its permission config mapped - from `AGENT_MODE`; make sure the final output lands on stdout. -- `wire`: drop a plugin into the project that subscribes to tool events and - POSTs the normalized schema with the `BOARD_*` env forwarded. +Read the two shipped adapters side by side — they are small and map the +same three intents onto very different vendor mechanisms. The essentials: + +- `run`: launch your agent headlessly with permissions generated from + `AGENT_MODE` + `AGENT_COMMANDS`; make sure the final output lands on + stdout and the exit code passes through. +- `wire`: install your platform's observer (hook, plugin) into the + project so sessions POST the normalized schema with the `BOARD_*` env + forwarded. - Events beat perfection: start with `session`/`end` plus a generic - `command` per tool call, refine kinds later. + `command` per tool call, refine kinds later — `opencode/plugin.js` + starts exactly that coarse on purpose. diff --git a/manager/core/adapters/claude/hook_settings.py b/manager/core/adapters/claude/hook_settings.py index bc17272..503c5d2 100644 --- a/manager/core/adapters/claude/hook_settings.py +++ b/manager/core/adapters/claude/hook_settings.py @@ -1,17 +1,83 @@ #!/usr/bin/env python3 -"""Print the --settings JSON that wires this adapter's event bridge into a -headless Claude session. Absolute path to emit.py, so it works from any -worktree regardless of what that checkout contains.""" +"""Print the --settings JSON for one headless launch: the event-bridge +hooks plus the permission allowlist for the launch's intent. + +Usage: hook_settings.py [work|act-pr|review] (no argument = hooks only) + +Headless runs have no human at a permission prompt, so anything not +auto-approved is auto-denied. Each intent is granted exactly the side +effects its own prompt demands — never bypassPermissions: the worktree +is isolated, the shell is not. + + work file edits (acceptEdits in `run`) + local git bookkeeping + (add/commit/status/diff) + the project's test/check commands. + No push. + act-pr the work stance + `git push` (the PR must update) + reading + the PR's reviews and line comments through gh. + review read-only (edit tools disallowed in `run`) + reading the PR + it judges + posting the verdict with gh pr review/comment. + +The project's test/check commands arrive in AGENT_COMMANDS as comma- +separated neutral command prefixes (set BOARD_AGENT_COMMANDS in +local/.env; contract in core/adapters/README.md). This adapter renders +them as Claude Code Bash() allow-rules; other adapters render the same +prefixes in their own rule syntax. + +Absolute path to emit.py, so the hooks work from any worktree regardless +of what that checkout contains. +""" import json +import os +import sys from pathlib import Path -EMIT = Path(__file__).resolve().parent / "emit.py" -hook = {"type": "command", "command": f'python3 "{EMIT}"', "timeout": 5} -plain = [{"hooks": [hook]}] -print(json.dumps({"hooks": { - "SessionStart": plain, - "Stop": plain, - "SessionEnd": plain, - "PreToolUse": [{"matcher": "Bash", "hooks": [hook]}], - "PostToolUse": [{"matcher": "*", "hooks": [hook]}], -}})) +# Universal git/gh prefixes per intent; project commands are appended. +MODE_PREFIXES = { + "work": ["git add", "git commit", "git status", "git diff"], + "act-pr": ["git add", "git commit", "git status", "git diff", + "git push", "gh pr view", "gh pr diff", "gh api"], + "review": ["git status", "git diff", "git log", "git show", + "gh pr view", "gh pr diff", "gh pr review", "gh pr comment"], +} +# Which intents run the project's own test/check commands. +MODES_WITH_PROJECT_COMMANDS = {"work", "act-pr"} + + +def split_commands(raw: str) -> list[str]: + """AGENT_COMMANDS: comma-separated neutral command prefixes.""" + return [p.strip() for p in (raw or "").split(",") if p.strip()] + + +def allow_rules(mode: str, commands: list[str]) -> list[str]: + """Bash() allow-rules for one intent: the exact prefix and any longer + command starting with it.""" + prefixes = list(MODE_PREFIXES.get(mode, [])) + if mode in MODES_WITH_PROJECT_COMMANDS: + prefixes += [c for c in commands if c not in prefixes] + rules = [] + for prefix in prefixes: + rules += [f"Bash({prefix})", f"Bash({prefix}:*)"] + return rules + + +def settings(mode: str, commands: list[str]) -> dict: + emit = Path(__file__).resolve().parent / "emit.py" + hook = {"type": "command", "command": f'python3 "{emit}"', "timeout": 5} + plain = [{"hooks": [hook]}] + out: dict = {"hooks": { + "SessionStart": plain, + "Stop": plain, + "SessionEnd": plain, + "PreToolUse": [{"matcher": "Bash", "hooks": [hook]}], + "PostToolUse": [{"matcher": "*", "hooks": [hook]}], + }} + rules = allow_rules(mode, commands) + if rules: + out["permissions"] = {"allow": rules} + return out + + +if __name__ == "__main__": + mode = sys.argv[1] if len(sys.argv) > 1 else "" + commands = split_commands(os.environ.get("AGENT_COMMANDS", "")) + print(json.dumps(settings(mode, commands))) diff --git a/manager/core/adapters/claude/run b/manager/core/adapters/claude/run index 5d550ea..9c769a9 100755 --- a/manager/core/adapters/claude/run +++ b/manager/core/adapters/claude/run @@ -2,25 +2,35 @@ # Claude adapter: run one headless job to completion. # # Contract (same for every adapter): -# env in: AGENT_PROMPT the full prompt -# AGENT_MODE work | review ("may mutate" vs "read-only") -# AGENT_CWD working directory (already set as cwd by the board) -# BOARD_* passthrough for the event bridge +# env in: AGENT_PROMPT the full prompt +# AGENT_MODE work | act-pr | review — the launch intent +# (see core/adapters/README.md) +# AGENT_COMMANDS comma-separated neutral command prefixes the +# project lets agents run (tests/checks) +# AGENT_CWD working directory (already set as cwd by the board) +# BOARD_* passthrough for the event bridge # stdout: captured by the board as the job log; the closing report's # marker lines (NOT READY:, PR REVIEW:, ...) are parsed from it # exit: 0 = completed; anything else = failed # +# Headless runs have no human to answer permission prompts: whatever the +# generated settings do not allow is denied. hook_settings.py grants each +# intent exactly what its own prompt demands (commit and test for work, +# push for act-pr, gh pr review for review) — never bypassPermissions. +# # BOARD_CLAUDE_BIN overrides the binary (used by the test stubs). set -euo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" BIN="${BOARD_CLAUDE_BIN:-claude}" -SETTINGS="$(python3 "$HERE/hook_settings.py")" +MODE="${AGENT_MODE:-work}" +SETTINGS="$(python3 "$HERE/hook_settings.py" "$MODE")" -if [ "${AGENT_MODE:-work}" = "review" ]; then +if [ "$MODE" = "review" ]; then exec "$BIN" -p "$AGENT_PROMPT" --settings "$SETTINGS" \ --permission-mode default \ --disallowedTools Edit Write MultiEdit NotebookEdit else + # work and act-pr both mutate the worktree; the allowlist differs. exec "$BIN" -p "$AGENT_PROMPT" --settings "$SETTINGS" \ --permission-mode acceptEdits fi diff --git a/manager/core/adapters/opencode/permission_config.py b/manager/core/adapters/opencode/permission_config.py new file mode 100644 index 0000000..94ac91f --- /dev/null +++ b/manager/core/adapters/opencode/permission_config.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Print the opencode config JSON for one headless launch: the permission +rules for the launch's intent. + +Usage: permission_config.py [work|act-pr|review] + +Same three stances as every adapter (the contract is in +core/adapters/README.md), rendered in opencode's native rule language: +glob patterns over the whole command line, last match wins, so "*" deny +comes first and the specific allows override it. Headless runs have no +human at a prompt — "ask" would hang — so every rule is allow or deny, +and never a blanket allow: the worktree is isolated, the shell is not. + + work "edit": "allow" + git bookkeeping (add/commit/status/diff) and + the project's test/check commands. No push. + act-pr the work stance + `git push` + reading the PR's reviews and + line comments through gh. + review "edit": "deny" + reading the PR it judges + posting the + verdict with gh pr review/comment. Everything else denied. + +The project's test/check commands arrive in AGENT_COMMANDS as comma- +separated neutral command prefixes (set BOARD_AGENT_COMMANDS in +local/.env); here each becomes "" and " *" allow rules. +""" +import json +import os +import sys + +# Universal git/gh prefixes per intent; project commands are appended. +MODE_PREFIXES = { + "work": ["git add", "git commit", "git status", "git diff"], + "act-pr": ["git add", "git commit", "git status", "git diff", + "git push", "gh pr view", "gh pr diff", "gh api"], + "review": ["git status", "git diff", "git log", "git show", + "gh pr view", "gh pr diff", "gh pr review", "gh pr comment"], +} +# Which intents run the project's own test/check commands. +MODES_WITH_PROJECT_COMMANDS = {"work", "act-pr"} + + +def split_commands(raw: str) -> list[str]: + """AGENT_COMMANDS: comma-separated neutral command prefixes.""" + return [p.strip() for p in (raw or "").split(",") if p.strip()] + + +def bash_rules(mode: str, commands: list[str]) -> dict: + """Deny everything, then allow each prefix exactly and any longer + command starting with it. Insertion order is the rule order.""" + prefixes = list(MODE_PREFIXES.get(mode, [])) + if mode in MODES_WITH_PROJECT_COMMANDS: + prefixes += [c for c in commands if c not in prefixes] + rules = {"*": "deny"} + for prefix in prefixes: + rules[prefix] = "allow" + rules[f"{prefix} *"] = "allow" + return rules + + +def build_config(mode: str, commands: list[str]) -> dict: + return { + "$schema": "https://opencode.ai/config.json", + "permission": { + "edit": "deny" if mode == "review" else "allow", + "bash": bash_rules(mode, commands), + }, + } + + +if __name__ == "__main__": + mode = sys.argv[1] if len(sys.argv) > 1 else "work" + commands = split_commands(os.environ.get("AGENT_COMMANDS", "")) + print(json.dumps(build_config(mode, commands))) diff --git a/manager/core/adapters/opencode/plugin.js b/manager/core/adapters/opencode/plugin.js new file mode 100644 index 0000000..c68735d --- /dev/null +++ b/manager/core/adapters/opencode/plugin.js @@ -0,0 +1,96 @@ +// opencode → board bridge (the opencode adapter's event edge). +// +// Subscribes to opencode's event bus and tool hooks, translates them into +// the board's NORMALIZED event schema (v1) — the fixed contract every +// adapter speaks, documented in core/adapters/README.md — and POSTs them +// to /api/events with the BOARD_* env forwarded. +// +// Coverage is deliberately coarse for now (session/idle/end plus one +// event per tool call, kinds mapped from the tool name); refine kinds +// before inventing new ones. Fails silently and fast: a session must +// never slow down or break because the board isn't running. + +import fs from "node:fs" +import path from "node:path" + +function boardPort(directory) { + if (process.env.BOARD_PORT) return process.env.BOARD_PORT + try { + const env = fs.readFileSync( + path.join(directory, ".task-manager/manager/local/.env"), "utf8") + for (const line of env.split("\n")) { + const m = line.match(/^\s*BOARD_PORT\s*=\s*['"]?(\d+)/) + if (m) return m[1] + } + } catch {} + return "26071" +} + +// opencode tool name → normalized kind (anything else: "command"). +const KINDS = { + read: "read", list: "read", glob: "search", grep: "search", + edit: "edit", write: "edit", patch: "edit", + bash: "command", webfetch: "web", todowrite: "plan", task: "subagent", +} + +export const BenchBoard = async ({ directory }) => { + const port = boardPort(directory) + const seen = new Set() + + const post = async (session, body) => { + try { + await fetch(`http://127.0.0.1:${port}/api/events`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + v: 1, + session: session || "unknown", + agent: process.env.BOARD_AGENT_ID, + task: process.env.BOARD_TASK, + ...body, + }), + signal: AbortSignal.timeout(1000), + }) + } catch {} + } + + // The bus has no single "session started" moment we can rely on across + // versions, so announce a session the first time we see its id. + const announce = (id) => { + if (!id || seen.has(id)) return + seen.add(id) + post(id, { kind: "session", summary: "session started" }) + } + + return { + event: async ({ event }) => { + const id = event?.properties?.sessionID || event?.properties?.info?.id + if (event?.type === "session.idle") { + announce(id) + post(id, { kind: "idle", summary: "finished responding — idle" }) + } else if (event?.type === "session.deleted") { + post(id, { kind: "end", summary: "session ended" }) + } else if (event?.type === "session.updated") { + announce(id) + } + }, + "tool.execute.before": async (input) => { + announce(input?.sessionID) + post(input?.sessionID, { + kind: "command", running: true, + summary: `running: ${input?.tool || "tool"}`, + }) + }, + "tool.execute.after": async (input, output) => { + const tool = input?.tool || "tool" + const kind = KINDS[tool] || "command" + const title = typeof output?.title === "string" ? output.title : "" + const body = { + kind, + summary: `${tool}${title ? `: ${title}` : ""}`.slice(0, 120), + } + if ((kind === "edit" || kind === "read") && title) body.file = title + post(input?.sessionID, body) + }, + } +} diff --git a/manager/core/adapters/opencode/run b/manager/core/adapters/opencode/run new file mode 100755 index 0000000..dbe901d --- /dev/null +++ b/manager/core/adapters/opencode/run @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# opencode adapter: run one headless job to completion. +# +# Contract (same for every adapter): +# env in: AGENT_PROMPT the full prompt +# AGENT_MODE work | act-pr | review — the launch intent +# (see core/adapters/README.md) +# AGENT_COMMANDS comma-separated neutral command prefixes the +# project lets agents run (tests/checks) +# AGENT_CWD working directory (already set as cwd by the board) +# BOARD_* passthrough for the event bridge +# stdout: captured by the board as the job log; `opencode run` prints +# the agent's final text there, so the closing report's marker +# lines (NOT READY:, PR REVIEW:, ...) parse unchanged +# exit: passes through from opencode; 0 = completed +# +# permission_config.py renders the intent as an opencode config — +# last-match-wins glob rules over bash, edit allow/deny — handed to the +# launch via OPENCODE_CONFIG so nothing is written into the worktree. +# +# Events: opencode loads project plugins from .opencode/plugin/, so a +# worktree of a repo wired by this adapter (`wire` installs the shim, +# committed to the repo) reports events with the BOARD_* env forwarded +# through the process environment. An unwired repo just runs silently. +# +# BOARD_OPENCODE_BIN overrides the binary (used by the test stubs). +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BIN="${BOARD_OPENCODE_BIN:-opencode}" +MODE="${AGENT_MODE:-work}" + +CONFIG_FILE="$(mktemp "${TMPDIR:-/tmp}/bench-opencode-XXXXXX.json")" +trap 'rm -f "$CONFIG_FILE"' EXIT +python3 "$HERE/permission_config.py" "$MODE" > "$CONFIG_FILE" +export OPENCODE_CONFIG="$CONFIG_FILE" + +"$BIN" run "$AGENT_PROMPT" diff --git a/manager/core/adapters/opencode/wire b/manager/core/adapters/opencode/wire new file mode 100755 index 0000000..db7244c --- /dev/null +++ b/manager/core/adapters/opencode/wire @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""opencode adapter: wire live-session visibility into the host project. + +Contract (same for every adapter): called by install.py with the project +root as argv[1] (and optionally --dry-run). Idempotently ensures the +project's own sessions report events to the board; prints a report; exit +0 on ok/fixed, 1 on a project that cannot be wired. + +For opencode that means a plugin. opencode loads project plugins from +.opencode/plugin/, so this installs .opencode/plugin/bench-board.js — a +one-line re-export of this adapter's plugin.js, which stays under +manager/core/ where updates replace it wholesale. The shim only has to +exist; committed to the repo, it also rides along into every work +agent's worktree, so headless jobs report events too. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +SHIM_NAME = "bench-board.js" + + +def shim_text(target: Path) -> str: + rel = os.path.relpath(HERE / "plugin.js", target.parent).replace(os.sep, "/") + return ( + "// Bench board bridge — installed by the opencode adapter's `wire`.\n" + "// The implementation lives with the task manager core, so updates\n" + "// replace it in place; this shim only has to exist.\n" + f'export {{ BenchBoard }} from "{rel}"\n' + ) + + +def main() -> int: + args = [a for a in sys.argv[1:] if a != "--dry-run"] + dry_run = "--dry-run" in sys.argv[1:] + project = Path(args[0]).resolve() if args else Path.cwd() + + if not project.is_dir(): + print(f"{project} is not a directory — nothing to wire.") + return 1 + if not (HERE / "plugin.js").is_file(): + print(f"error: {HERE / 'plugin.js'} is missing — the adapter looks broken.") + return 1 + + target = project / ".opencode" / "plugin" / SHIM_NAME + wanted = shim_text(target) + current = target.read_text(encoding="utf-8") if target.is_file() else None + status = "ok" if current == wanted else ("added" if current is None else "repaired") + + print(f"adapter: opencode\nplugin: {target}\n") + print(f" plugin {SHIM_NAME:<20} {status}") + + if status == "ok": + print("\nEverything already in place — nothing to do.") + return 0 + if dry_run: + print("\nDry run — no changes written.") + return 0 + + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(wanted, encoding="utf-8") + print("\nWrote the plugin shim. Note: running opencode sessions load " + "plugins at startup — restart them to pick this up. Commit " + ".opencode/ so agent worktrees carry it too.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/manager/core/agents.py b/manager/core/agents.py index f929b46..bcbab95 100644 --- a/manager/core/agents.py +++ b/manager/core/agents.py @@ -109,9 +109,11 @@ def _validate(filename: str, stage: str, allowed: set[str], why: str | None = No def _launch(mode: str, prompt: str, cwd: Path, agent_id: str, filename: str, log_path: Path): """Run one headless job through the configured agent adapter. - The adapter contract: `run` gets AGENT_PROMPT and AGENT_MODE - (work = may mutate, review = read-only) plus the BOARD_* passthrough - for its event bridge; its stdout is the job log; exit 0 = completed. + The adapter contract: `run` gets AGENT_PROMPT, AGENT_MODE (the intent: + work = mutate and commit, act-pr = work + push, review = read-only + + post PR verdicts) and AGENT_COMMANDS (the project's runnable command + prefixes) plus the BOARD_* passthrough for its event bridge; its + stdout is the job log; exit 0 = completed. """ adapter = config.adapter_dir() if adapter is None: @@ -122,6 +124,7 @@ def _launch(mode: str, prompt: str, cwd: Path, agent_id: str, filename: str, log env.update({ "AGENT_PROMPT": prompt, "AGENT_MODE": mode, + "AGENT_COMMANDS": config.AGENT_COMMANDS, "AGENT_CWD": str(cwd), "BOARD_AGENT_ID": agent_id, "BOARD_TASK": filename, @@ -250,12 +253,20 @@ def _declined_reason(log_path: str) -> str | None: return None -def _discard_untouched_worktree(record: dict) -> bool: - """Remove worktree + branch, but only if the agent committed nothing.""" +def _no_new_commits(record: dict) -> bool: + """True iff the worktree's HEAD is still the commit the agent started + from — i.e. the run produced no commits on the branch.""" + if not record.get("worktree") or not record.get("base"): + return False head = subprocess.run( ["git", "-C", record["worktree"], "rev-parse", "HEAD"], capture_output=True, text=True) - if head.returncode != 0 or head.stdout.strip() != record["base"]: + return head.returncode == 0 and head.stdout.strip() == record["base"] + + +def _discard_untouched_worktree(record: dict) -> bool: + """Remove worktree + branch, but only if the agent committed nothing.""" + if not _no_new_commits(record): return False subprocess.run(["git", "-C", str(config.REPO), "worktree", "remove", "--force", record["worktree"]], capture_output=True) @@ -295,11 +306,6 @@ def _reap_agent(agent_id: str, proc: subprocess.Popen, log_file) -> None: summary = (f"{name} declined {filename} — not ready: {declined}" + ("" if cleaned else f" (worktree {record['worktree']} kept: it has commits)")) elif rc == 0 and not stopped: - if find_stage_of(filename) == "in-progress": - try: - move_task(filename, "in-progress", "review", actor="agent") - except ValueError: - pass try: report = _clean_log(Path(record["log"]).read_text(encoding="utf-8", errors="replace")) @@ -307,7 +313,19 @@ def _reap_agent(agent_id: str, proc: subprocess.Popen, log_file) -> None: report = "" _file_report(record, "Work report", report) _session_report(record, report) - summary = f"{name} finished {filename} — review branch {branch}" + if _no_new_commits(record): + # A "clean" exit with an empty branch is how permission bugs + # hide: nothing reaches review/ silently. + summary = (f"{name} exited cleanly on {filename} but committed " + f"NOTHING to {branch} — card stays in in-progress; " + f"read the report before relaunching") + else: + if find_stage_of(filename) == "in-progress": + try: + move_task(filename, "in-progress", "review", actor="agent") + except ValueError: + pass + summary = f"{name} finished {filename} — review branch {branch}" elif stopped: summary = f"{name} was held on {filename} — nothing is lost" else: @@ -384,7 +402,8 @@ def start_pr_fix(filename: str, stage: str) -> dict: prompt = config.prompt("act-pr.md").format( filename=filename, branch=branch, pr=task["pr"], body=task["body"]) - proc, log_file = _launch("work", prompt, worktree, agent_id, filename, log_path) + # act-pr is the one intent allowed to push: the PR must update. + proc, log_file = _launch("act-pr", prompt, worktree, agent_id, filename, log_path) record = { "id": agent_id, "task": filename, "branch": branch, diff --git a/manager/core/config.py b/manager/core/config.py index 88b2ba8..106fd58 100644 --- a/manager/core/config.py +++ b/manager/core/config.py @@ -88,6 +88,13 @@ WORKTREES = REPO / setting("BOARD_WORKTREES", ".worktrees") # Which agent adapter runs headless jobs. Resolution ladder: local wins. ADAPTER = setting("BOARD_AGENT_ADAPTER", "claude") +# Command prefixes headless agents may run in a worktree (the project's +# test/check commands) — neutral, comma-separated; each adapter renders +# them in its own permission-rule syntax. The universal git/gh grants are +# the adapter's own knowledge; this list is the project's half. +AGENT_COMMANDS = setting("BOARD_AGENT_COMMANDS", + "python3 -m unittest,python3 -m pytest") + # GitHub plumbing: the gh CLI (stub-able for tests) and the git remote PRs # go to. Empty remote = auto-detect the first remote; no remote = no PRs. GH_BIN = setting("BOARD_GH_BIN", "gh") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_adapter_permissions.py b/tests/test_adapter_permissions.py new file mode 100644 index 0000000..2502679 --- /dev/null +++ b/tests/test_adapter_permissions.py @@ -0,0 +1,233 @@ +"""The adapters' permission generation: each launch intent is granted +exactly the side effects its own prompt demands, in the vendor's native +rule syntax. Run with: python3 -m unittest discover -s tests + +The `run` scripts are exercised end-to-end against stub binaries +(BOARD_CLAUDE_BIN / BOARD_OPENCODE_BIN), the same seam a live board uses +— so what is asserted here is what a real launch passes to the vendor. +""" + +import importlib.util +import json +import os +import stat +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +CLAUDE = REPO / "manager" / "core" / "adapters" / "claude" +OPENCODE = REPO / "manager" / "core" / "adapters" / "opencode" + + +def _load(name, path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +hook_settings = _load("hook_settings", CLAUDE / "hook_settings.py") +permission_config = _load("permission_config", OPENCODE / "permission_config.py") + +COMMANDS = ["python3 -m unittest", "npm test"] + + +class ClaudeAllowRules(unittest.TestCase): + def test_work_commits_and_tests_but_never_pushes(self): + rules = hook_settings.allow_rules("work", COMMANDS) + for prefix in ["git add", "git commit", "git status", "git diff", + "python3 -m unittest", "npm test"]: + self.assertIn(f"Bash({prefix})", rules) + self.assertIn(f"Bash({prefix}:*)", rules) + joined = " ".join(rules) + self.assertNotIn("git push", joined) + self.assertNotIn("gh pr review", joined) + + def test_act_pr_is_work_plus_push_and_reading_the_pr(self): + rules = hook_settings.allow_rules("act-pr", COMMANDS) + work = hook_settings.allow_rules("work", COMMANDS) + self.assertTrue(set(work) <= set(rules)) + for prefix in ["git push", "gh pr view", "gh pr diff", "gh api"]: + self.assertIn(f"Bash({prefix}:*)", rules) + + def test_review_posts_verdicts_but_writes_nothing_locally(self): + rules = hook_settings.allow_rules("review", COMMANDS) + for prefix in ["gh pr review", "gh pr comment", "gh pr view", + "gh pr diff", "git log", "git diff"]: + self.assertIn(f"Bash({prefix}:*)", rules) + joined = " ".join(rules) + for forbidden in ["git add", "git commit", "git push", + "python3 -m unittest", "npm test"]: + self.assertNotIn(forbidden, joined) + + def test_settings_carry_hooks_and_allowlist_in_one_file(self): + settings = hook_settings.settings("work", COMMANDS) + self.assertIn("SessionStart", settings["hooks"]) + self.assertIn("PostToolUse", settings["hooks"]) + self.assertIn("Bash(git commit:*)", settings["permissions"]["allow"]) + + def test_no_mode_means_hooks_only(self): + self.assertNotIn("permissions", hook_settings.settings("", [])) + + +class OpencodeConfig(unittest.TestCase): + def test_work_allows_edits_commits_and_tests_only(self): + config = permission_config.build_config("work", COMMANDS) + self.assertEqual(config["permission"]["edit"], "allow") + bash = config["permission"]["bash"] + self.assertEqual(next(iter(bash)), "*") # last match wins: deny first + self.assertEqual(bash["*"], "deny") + for prefix in ["git add", "git commit", "python3 -m unittest", "npm test"]: + self.assertEqual(bash[prefix], "allow") + self.assertEqual(bash[f"{prefix} *"], "allow") + self.assertNotIn("git push *", bash) + + def test_act_pr_adds_push(self): + bash = permission_config.build_config("act-pr", COMMANDS)["permission"]["bash"] + self.assertEqual(bash["git push *"], "allow") + self.assertEqual(bash["gh pr view *"], "allow") + + def test_review_cannot_edit_and_bash_default_denies(self): + config = permission_config.build_config("review", COMMANDS) + self.assertEqual(config["permission"]["edit"], "deny") + bash = config["permission"]["bash"] + self.assertEqual(bash["*"], "deny") + self.assertEqual(bash["gh pr review *"], "allow") + self.assertEqual(bash["gh pr comment *"], "allow") + for forbidden in ["git commit *", "git push *", "python3 -m unittest *"]: + self.assertNotIn(forbidden, bash) + + +def _write_stub(directory: Path, name: str, script: str) -> Path: + stub = directory / name + stub.write_text(script, encoding="utf-8") + stub.chmod(stub.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + return stub + + +class ClaudeRunScript(unittest.TestCase): + """The generated settings actually reach the claude launch.""" + + def _run(self, mode: str) -> list[str]: + with tempfile.TemporaryDirectory() as tmp: + capture = Path(tmp) / "args.json" + stub = _write_stub(Path(tmp), "claude-stub", + "#!/usr/bin/env python3\n" + "import json, sys\n" + f"open({str(capture)!r}, 'w').write(json.dumps(sys.argv[1:]))\n") + wrapper = _write_stub(Path(tmp), "bin", + f"#!/usr/bin/env bash\nexec python3 {stub} \"$@\"\n") + env = dict(os.environ) + env.update({"BOARD_CLAUDE_BIN": str(wrapper), + "AGENT_PROMPT": "do the task", "AGENT_MODE": mode, + "AGENT_COMMANDS": "python3 -m unittest"}) + result = subprocess.run(["bash", str(CLAUDE / "run")], env=env, + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + return json.loads(capture.read_text()) + + def _settings(self, args: list[str]) -> dict: + return json.loads(args[args.index("--settings") + 1]) + + def test_work_launch_accepts_edits_and_allows_commits(self): + args = self._run("work") + self.assertIn("acceptEdits", args) + self.assertNotIn("--disallowedTools", args) + allow = self._settings(args)["permissions"]["allow"] + self.assertIn("Bash(git commit:*)", allow) + self.assertIn("Bash(python3 -m unittest:*)", allow) + self.assertNotIn("Bash(git push:*)", allow) + + def test_act_pr_launch_may_push(self): + args = self._run("act-pr") + self.assertIn("acceptEdits", args) + self.assertIn("Bash(git push:*)", self._settings(args)["permissions"]["allow"]) + + def test_review_launch_disallows_edit_tools_and_may_post_verdicts(self): + args = self._run("review") + self.assertIn("default", args) + self.assertIn("--disallowedTools", args) + for tool in ["Edit", "Write", "MultiEdit", "NotebookEdit"]: + self.assertIn(tool, args) + allow = self._settings(args)["permissions"]["allow"] + self.assertIn("Bash(gh pr review:*)", allow) + self.assertNotIn("Bash(git commit:*)", allow) + + +class OpencodeRunScript(unittest.TestCase): + """The generated config reaches the opencode launch via OPENCODE_CONFIG, + and the exit code passes through.""" + + def _run(self, mode: str, stub_exit: int = 0): + with tempfile.TemporaryDirectory() as tmp: + capture = Path(tmp) / "capture.json" + stub = _write_stub(Path(tmp), "opencode-stub", + "#!/usr/bin/env python3\n" + "import json, os, sys\n" + "payload = {'argv': sys.argv[1:],\n" + " 'config': json.load(open(os.environ['OPENCODE_CONFIG']))}\n" + f"open({str(capture)!r}, 'w').write(json.dumps(payload))\n" + f"sys.exit({stub_exit})\n") + wrapper = _write_stub(Path(tmp), "bin", + f"#!/usr/bin/env bash\nexec python3 {stub} \"$@\"\n") + env = dict(os.environ) + env.update({"BOARD_OPENCODE_BIN": str(wrapper), + "AGENT_PROMPT": "do the task", "AGENT_MODE": mode, + "AGENT_COMMANDS": "python3 -m unittest"}) + result = subprocess.run(["bash", str(OPENCODE / "run")], env=env, + capture_output=True, text=True) + payload = json.loads(capture.read_text()) if capture.is_file() else None + return result, payload + + def test_work_launch_carries_the_permission_config(self): + result, payload = self._run("work") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(payload["argv"], ["run", "do the task"]) + bash = payload["config"]["permission"]["bash"] + self.assertEqual(bash["*"], "deny") + self.assertEqual(bash["git commit *"], "allow") + self.assertEqual(bash["python3 -m unittest *"], "allow") + + def test_review_launch_cannot_edit(self): + result, payload = self._run("review") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(payload["config"]["permission"]["edit"], "deny") + + def test_exit_code_passes_through(self): + result, _ = self._run("work", stub_exit=3) + self.assertEqual(result.returncode, 3) + + +class OpencodeWire(unittest.TestCase): + def test_wire_installs_the_plugin_shim_idempotently(self): + with tempfile.TemporaryDirectory() as tmp: + project = Path(tmp) + first = subprocess.run( + ["python3", str(OPENCODE / "wire"), str(project)], + capture_output=True, text=True) + self.assertEqual(first.returncode, 0, first.stdout + first.stderr) + shim = project / ".opencode" / "plugin" / "bench-board.js" + self.assertTrue(shim.is_file()) + self.assertIn("BenchBoard", shim.read_text()) + + again = subprocess.run( + ["python3", str(OPENCODE / "wire"), str(project)], + capture_output=True, text=True) + self.assertEqual(again.returncode, 0) + self.assertIn("nothing to do", again.stdout) + + def test_dry_run_writes_nothing(self): + with tempfile.TemporaryDirectory() as tmp: + project = Path(tmp) + result = subprocess.run( + ["python3", str(OPENCODE / "wire"), str(project), "--dry-run"], + capture_output=True, text=True) + self.assertEqual(result.returncode, 0) + self.assertFalse((project / ".opencode").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_empty_branch_guard.py b/tests/test_empty_branch_guard.py new file mode 100644 index 0000000..1e00507 --- /dev/null +++ b/tests/test_empty_branch_guard.py @@ -0,0 +1,64 @@ +"""The zero-commit detection behind the board's empty-branch guard: a +work agent that exits cleanly without committing must not advance its +card, so _no_new_commits has to tell an untouched branch from a worked +one.""" + +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO / "manager" / "core")) + +import agents # noqa: E402 + + +def _git(cwd: Path, *args: str) -> str: + return subprocess.check_output( + ["git", "-C", str(cwd), *args], text=True, + stderr=subprocess.DEVNULL).strip() + + +class NoNewCommits(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.repo = Path(self._tmp.name) / "repo" + self.repo.mkdir() + _git(self.repo, "init", "-q", "-b", "main") + _git(self.repo, "-c", "user.email=t@t", "-c", "user.name=t", + "commit", "-q", "--allow-empty", "-m", "root") + self.base = _git(self.repo, "rev-parse", "HEAD") + self.worktree = Path(self._tmp.name) / "wt" + _git(self.repo, "worktree", "add", "-q", "-b", "task/x", + str(self.worktree)) + + def tearDown(self): + self._tmp.cleanup() + + def record(self, **overrides): + record = {"worktree": str(self.worktree), "base": self.base} + record.update(overrides) + return record + + def test_untouched_branch_is_flagged(self): + self.assertTrue(agents._no_new_commits(self.record())) + + def test_a_commit_clears_the_flag(self): + (self.worktree / "f").write_text("x") + _git(self.worktree, "add", "f") + _git(self.worktree, "-c", "user.email=t@t", "-c", "user.name=t", + "commit", "-q", "-m", "work") + self.assertFalse(agents._no_new_commits(self.record())) + + def test_unknown_base_or_worktree_never_flags(self): + # Better to advance a card than to hold it on bad bookkeeping. + self.assertFalse(agents._no_new_commits(self.record(base=None))) + self.assertFalse(agents._no_new_commits(self.record(worktree=None))) + self.assertFalse(agents._no_new_commits( + self.record(worktree=str(self.worktree / "gone")))) + + +if __name__ == "__main__": + unittest.main()