Launch headless agents with permissions that allow their own contract (task 05)

Work agents were launched under acceptEdits with no Bash allowlist, so
headless runs could edit files but never run tests or commit — the exact
contradiction card 05 documents. Each launch intent (work, act-pr,
review) now carries a permissions.allow list granting exactly what its
prompt demands, delivered through the same generated settings JSON as
the event hooks. Project test/check commands arrive as neutral prefixes
via BOARD_AGENT_COMMANDS; a new opencode adapter renders the same three
stances in its config language as the portability proof. A clean agent
exit with an empty branch no longer advances the card to review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
istos
2026-07-29 19:46:23 +02:00
co-authored by Claude Fable 5
parent 29cbb11eb5
commit f8d1bf252d
14 changed files with 795 additions and 61 deletions
+15 -7
View File
@@ -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
+12 -3
View File
@@ -7,12 +7,21 @@
BOARD_PORT=26071
# Which agent adapter runs headless jobs (core/adapters/<name>, overridable
# in local/adapters/<name>). Ships with: claude.
# in local/adapters/<name>). 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
+59 -19
View File
@@ -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.
+74 -8
View File
@@ -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": {
# 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)))
+13 -3
View File
@@ -3,24 +3,34 @@
#
# Contract (same for every adapter):
# env in: AGENT_PROMPT the full prompt
# AGENT_MODE work | review ("may mutate" vs "read-only")
# 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
@@ -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 "<prefix>" and "<prefix> *" 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)))
+96
View File
@@ -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)
},
}
}
+37
View File
@@ -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"
+73
View File
@@ -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())
+31 -12
View File
@@ -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,6 +313,18 @@ def _reap_agent(agent_id: str, proc: subprocess.Popen, log_file) -> None:
report = ""
_file_report(record, "Work report", report)
_session_report(record, report)
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"
@@ -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,
+7
View File
@@ -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")
View File
+233
View File
@@ -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()
+64
View File
@@ -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()