diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index 770cd13288..0da6af4bdb 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -23,6 +23,12 @@ sourceSets { } } +// `resources.srcDir(...)` above re-adds the default resource root, so every resource is registered +// twice (identical source + destination). Pick last-wins instead of failing the copy. +tasks.withType().configureEach { + duplicatesStrategy = DuplicatesStrategy.INCLUDE +} + dependencies { implementation(project(":quartz")) implementation(project(":commons")) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 04ae351ea4..5b14b8da61 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -827,6 +827,8 @@ private fun printUsage() { | buzz workflow trigger RELAY WFID … trigger a Buzz workflow run (kind-46020) | buzz workflow run RELAY --exec CMD … run the workflow runner (agent work → approval gate) | buzz workflow approve/deny RELAY RUNID grant/deny a run's approval gate (46030/46031) + | buzz agent up RELAY --repo DIR --approver NPUB one-command gated runner (bundled wrapper) + | buzz agent doctor [--repo DIR] preflight the host: gh token + branch protection | buzz agent serve RELAY --exec CMD run a parallel backlog scheduler (worktree-isolated) | |Marmot (MLS group messaging): diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzAgentCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzAgentCommands.kt index 73fdab05f4..590a5ee385 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzAgentCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzAgentCommands.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -84,6 +85,10 @@ import java.util.concurrent.TimeUnit object BuzzAgentCommands { private val USAGE = """ + |amy buzz agent up RELAY --repo DIR --approver NPUB one-command gated runner (recommended) + | [--channel GID] defaults to the relay's only channel + | [--base-ref REF] [--poll SECS] [--once] bundled wrapper; intake = channel members + |amy buzz agent doctor [--repo DIR] [--json] preflight: gh token scope + branch protection |amy buzz agent serve RELAY --exec CMD run a backlog scheduler | [--channel GID] only handle jobs scoped to this channel | [--accept-from npub,npub] allowlist of requester keys @@ -114,10 +119,172 @@ object BuzzAgentCommands { tail, USAGE, mapOf( + "up" to { rest -> up(dataDir, rest) }, + "doctor" to { rest -> doctor(dataDir, rest) }, "serve" to { rest -> serve(dataDir, rest) }, ), ) + // ---- one-command bundles ------------------------------------------------- + + /** + * `amy buzz agent up RELAY --repo DIR --approver NPUB [--channel GID] …` — the low-ceremony way to + * put a **gated** agent runner on a channel. It resolves the channel (the relay's only one unless + * `--channel` is given), defaults the worktree to `--repo` and intake to the channel roster, + * extracts the bundled agent/ship wrappers, and hands off to `buzz workflow run`. Everything it + * defaults stays overridable there; this just removes the eight flags and the two scripts for the + * common case. The one thing it can't default is `--approver` — a human must own the gate. + */ + private suspend fun up( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val usage = "buzz agent up RELAY --repo DIR --approver NPUB [--channel GID] [--base-ref REF] [--poll SECS] [--once]" + val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage) + val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl") + val repo = args.flag("repo") ?: return Output.error("bad_args", "pass --repo DIR (your git checkout — the agent works and opens PRs here)") + if (!File(repo).resolve(".git").exists()) return Output.error("bad_args", "--repo is not a git repository: $repo") + val approver = args.flag("approver") ?: return Output.error("bad_args", "pass --approver NPUB (the human who signs off each run)") + val baseRef = args.flag("base-ref") + val poll = args.flag("poll") + val timeout = args.flag("timeout") + val once = args.bool("once") + val explicitChannel = args.flag("channel") + args.rejectUnknown("repo", "approver", "channel", "base-ref", "poll", "timeout", "once") + + val channel = + explicitChannel + ?: Context.open(dataDir).use { ctx -> + ctx.prepare() + resolveSingleChannel(ctx, relay, timeout?.toLongOrNull() ?: 8) + ?: return Output.error("bad_args", "couldn't pick a channel automatically — pass --channel GID (this relay hosts none or several)") + } + + val (agentStep, shipStep) = extractWrappers() + + val runArgs = + buildList { + add(relayUrl) + add("--channel") + add(channel) + add("--approver") + add(approver) + add("--exec") + add(agentStep) + add("--on-approve") + add(shipStep) + add("--worktree") + add(repo) + add("--accept-from-channel") + baseRef?.let { + add("--base-ref") + add(it) + } + poll?.let { + add("--poll") + add(it) + } + timeout?.let { + add("--timeout") + add(it) + } + if (once) add("--once") + }.toTypedArray() + + System.err.println("[agent up] gated runner on ${relay.url} #$channel — repo $repo — approver $approver") + System.err.println("[agent up] wrappers: $agentStep + $shipStep (edit to customize, or re-run with your own --exec/--on-approve)") + return BuzzWorkflowCommands.runFromArgs(dataDir, runArgs) + } + + /** The relay's single hosted channel (its 39000 group id), or null when there are zero or many. */ + private suspend fun resolveSingleChannel( + ctx: Context, + relay: NormalizedRelayUrl, + timeoutSecs: Long, + ): String? = + ctx + .drain(mapOf(relay to listOf(Filter(kinds = listOf(GroupMetadataEvent.KIND)))), timeoutSecs * 1000, pendingOnAuthRequired = true) + .map { it.second } + .filterIsInstance() + .mapNotNull { it.groupId() } + .distinct() + .singleOrNull() + + /** Extract the bundled gated wrappers to ~/.amy/buzz-agent and return (agentStepPath, shipStepPath). */ + private fun extractWrappers(): Pair { + val dir = File(System.getProperty("user.home"), ".amy/buzz-agent").apply { mkdirs() } + + fun extract(name: String): String { + val out = File(dir, name) + ( + BuzzAgentCommands::class.java.getResourceAsStream("/buzz-agent/$name") + ?: error("bundled wrapper /buzz-agent/$name missing from the amy jar") + ).use { input -> out.outputStream().use { input.copyTo(it) } } + out.setExecutable(true) + return out.absolutePath + } + return extract("workflow-agent.sh") to extract("workflow-ship.sh") + } + + /** + * `amy buzz agent doctor [--repo DIR]` — preflight the host safety the gate relies on: `gh` is + * authenticated and its token can write to the repo, the default branch is protected against + * force-push, and the worktree is a clean git checkout. Turns the tutorial's security checklist + * into a green/red report; exits non-zero if anything is off. Honours `--json` like every verb. + */ + private suspend fun doctor( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val repo = args.flag("repo") ?: System.getProperty("user.dir") + args.rejectUnknown("repo") + + val checks = mutableListOf>() + + fun check( + name: String, + ok: Boolean, + detail: String, + ) = checks.add(mapOf("name" to name, "ok" to ok, "detail" to detail)) + + val isRepo = File(repo).resolve(".git").exists() + check("git repo", isRepo, if (isRepo) repo else "$repo is not a git checkout") + if (isRepo) { + val status = git(repo, "status", "--porcelain") + check("worktree clean", status.stdout.isBlank(), if (status.stdout.isBlank()) "no uncommitted changes" else "uncommitted changes present") + } + + val ghAuth = runExec("gh auth status", "", emptyMap(), 20, repo) + val ghOk = ghAuth.exit == 0 + check("gh authenticated", ghOk, if (ghOk) "ok" else "run: gh auth login") + + if (ghOk) { + val perm = runExec("gh repo view --json viewerPermission -q .viewerPermission", "", emptyMap(), 20, repo).stdout.trim() + val canWrite = perm == "WRITE" || perm == "MAINTAIN" || perm == "ADMIN" + check("token can write to repo", canWrite, if (canWrite) "permission: $perm" else "permission: ${perm.ifBlank { "unknown" }} — needs Contents:RW + Pull requests:RW on this repo") + + val def = runExec("gh repo view --json defaultBranchRef -q .defaultBranchRef.name", "", emptyMap(), 20, repo).stdout.trim().ifBlank { "main" } + val prot = runExec("gh api repos/{owner}/{repo}/branches/$def/protection --jq .allow_force_pushes.enabled", "", emptyMap(), 20, repo) + val isProtected = prot.exit == 0 + val forcePushOff = prot.stdout.trim() == "false" + check( + "default branch protected ($def)", + isProtected && forcePushOff, + when { + !isProtected -> "'$def' has no branch protection — require a PR + reviews and block force-push" + !forcePushOff -> "'$def' allows force-push — disable it in branch protection" + else -> "protected; force-push blocked" + }, + ) + } + + val allOk = checks.all { it["ok"] == true } + Output.emit(mapOf("ok" to allOk, "repo" to repo, "checks" to checks)) + return if (allOk) 0 else 1 + } + private class Opts( val relay: NormalizedRelayUrl, val exec: String?, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzWorkflowCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzWorkflowCommands.kt index 834082f3c3..5196584d1b 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzWorkflowCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BuzzWorkflowCommands.kt @@ -46,6 +46,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope @@ -82,9 +83,17 @@ object BuzzWorkflowCommands { |amy buzz workflow deny RELAY RUNID [--note N] deny the approval gate (46031) |amy buzz workflow run RELAY --exec CMD --channel GID run the workflow runner | --approver NPUB [--on-approve CMD] [--worktree REPODIR] agent work → 46010 gate → - | [--base-ref REF] [--accept-from npub,…] [--poll SECS] [--once] on grant: --on-approve → 46005 + | [--base-ref REF] [--accept-from npub,…] on grant: --on-approve → 46005 + | [--accept-from-channel] [--poll SECS] [--once] (--worktree defaults to cwd; + | --accept-from-channel = obey members) """.trimMargin() + /** Entry point for `amy buzz agent up` — build the flag list, then reuse the runner below. */ + internal suspend fun runFromArgs( + dataDir: DataDir, + rest: Array, + ): Int = run(dataDir, rest) + suspend fun dispatch( dataDir: DataDir, tail: Array, @@ -298,12 +307,16 @@ object BuzzWorkflowCommands { decodePublicKeyAsHexOrNull(approverInput.trim())?.takeIf { it.isValid() } ?: return Output.error("bad_args", "invalid --approver key: $approverInput") val onApprove = args.flag("on-approve") // push + open PR; runs in the worktree after grant - val worktreeBase = args.flag("worktree") + // The worktree defaults to the current directory: the runner isolates each run in its own + // git worktree off it, and the common case is "run me inside my checkout." Pass --worktree to + // point elsewhere. + val worktreeBase = args.flag("worktree") ?: System.getProperty("user.dir") val baseRef = args.flag("base-ref") ?: "HEAD" val once = args.bool("once") val pollSecs = args.flag("poll")?.toLongOrNull() ?: 5 val timeoutSecs = args.flag("timeout")?.toLongOrNull() ?: 8 - val acceptFrom = + val fromChannel = args.bool("accept-from-channel") + val explicitAccept = args .flag("accept-from") ?.split(",") @@ -311,13 +324,23 @@ object BuzzWorkflowCommands { ?.map { decodePublicKeyAsHexOrNull(it)?.takeIf { hex -> hex.isValid() } ?: return Output.error("bad_args", "invalid --accept-from key: $it") }?.toSet() - args.rejectUnknown("exec", "channel", "approver", "on-approve", "worktree", "base-ref", "once", "poll", "timeout", "accept-from") + args.rejectUnknown("exec", "channel", "approver", "on-approve", "worktree", "base-ref", "once", "poll", "timeout", "accept-from", "accept-from-channel") - if (worktreeBase != null && !File(worktreeBase).isDirectory) return Output.error("bad_args", "--worktree is not a directory: $worktreeBase") + if (!File(worktreeBase).isDirectory) return Output.error("bad_args", "--worktree is not a directory: $worktreeBase") Context.open(dataDir).use { ctx -> ctx.prepare() val me = ctx.identity.pubKeyHex + + // Intake allowlist: explicit --accept-from ∪ the channel's kind-39002 roster (when + // --accept-from-channel). Null = obey anyone who can post to the relay. On a shared + // community you own no relay for, --accept-from-channel scopes the agent to members. + val acceptFrom: Set? = + if (fromChannel) { + (explicitAccept ?: emptySet()) + channelMembers(ctx, relay, channel, timeoutSecs) + } else { + explicitAccept + } val started = mutableSetOf() // triggers we've begun val awaiting = mutableMapOf() // runId -> worktree while at the gate val decided = mutableSetOf() @@ -557,6 +580,22 @@ object BuzzWorkflowCommands { } } + /** Latest kind-39002 roster for [channel] → its member pubkeys (empty if the relay serves none). */ + private suspend fun channelMembers( + ctx: Context, + relay: NormalizedRelayUrl, + channel: String, + timeoutSecs: Long, + ): Set = + ctx + .drain(mapOf(relay to listOf(Filter(kinds = listOf(GroupMembersEvent.KIND), tags = mapOf("d" to listOf(channel))))), timeoutSecs * 1000, pendingOnAuthRequired = true) + .map { it.second } + .filterIsInstance() + .maxByOrNull { it.createdAt } + ?.members() + ?.toSet() + .orEmpty() + private const val MAX = 60_000 private val LIFECYCLE_KINDS = listOf( diff --git a/cli/src/main/resources/buzz-agent/workflow-agent.sh b/cli/src/main/resources/buzz-agent/workflow-agent.sh new file mode 100755 index 0000000000..32774003df --- /dev/null +++ b/cli/src/main/resources/buzz-agent/workflow-agent.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# +# workflow-agent.sh — the --exec step of `amy buzz workflow run` (the GATED path). +# +# The runner calls this once per workflow run to do the agent's work. It runs a coding agent +# (Claude Code by default) on the task inside the run's isolated git worktree and COMMITS the +# result — but it does NOT push. A human approves the 46010 gate first; then workflow-ship.sh +# (the --on-approve step) pushes the branch and opens the PR. +# +# Contract (set by the runner): +# stdin ............ the task text +# BUZZ_WORKTREE .... the run's git worktree (a fresh branch off BUZZ_BASE_REF) +# BUZZ_BRANCH ...... the run's branch name +# BUZZ_BASE_REF .... what the branch was cut from +# BUZZ_RUN ......... the run id; BUZZ_REQUESTER — who asked +# stdout ........... becomes the approver's gate note (46010) — we print a short summary +# non-zero exit .... fails the run before it ever reaches the gate; stderr is the detail +# +# Config knobs (env): AGENT_CMD (override the whole agent call; reads the prompt on stdin and as +# $AGENT_PROMPT), AGENT_ALLOWED_TOOLS (Claude Code --allowedTools), COMMIT_PREFIX. + +set -euo pipefail +log() { printf '%s\n' "$*" >&2; } +die() { printf 'error: %s\n' "$*" >&2; exit 1; } + +AGENT_CMD="${AGENT_CMD:-}" +AGENT_ALLOWED_TOOLS="${AGENT_ALLOWED_TOOLS:-Edit,Write,Read,Bash,Glob,Grep}" +COMMIT_PREFIX="${COMMIT_PREFIX:-feat}" + +[[ -n "${BUZZ_WORKTREE:-}" ]] || die "BUZZ_WORKTREE unset — run this under 'amy buzz workflow run'" +cd "$BUZZ_WORKTREE" || die "cannot cd into worktree $BUZZ_WORKTREE" +git rev-parse --is-inside-work-tree >/dev/null 2>&1 || die "worktree is not a git repo" + +# Pin the start commit so "did the agent change anything?" is correct even off a moving HEAD. +base_sha="$(git rev-parse HEAD)" + +task="$(cat)" +[[ -n "${task//[[:space:]]/}" ]] || die "empty task" +title="$(printf '%s' "$task" | head -n1 | cut -c1-72)" + +log "[workflow-agent] run ${BUZZ_RUN:-?}: $title" +prompt="You are working in a fresh git worktree on branch '${BUZZ_BRANCH:-?}' (off '${BUZZ_BASE_REF:-HEAD}'). +Implement the request below and then stop. Do NOT switch branches, push, or open a PR — the +wrapper handles git after a human approves. Keep changes scoped to this repository. + +TASK: +$task" + +if [[ -n "$AGENT_CMD" ]]; then + export AGENT_PROMPT="$prompt" + summary="$(printf '%s' "$prompt" | bash -c "$AGENT_CMD" 2>&1)" || die "agent command failed" +else + command -v claude >/dev/null 2>&1 || die "claude CLI not found (set AGENT_CMD to your agent)" + summary="$(claude -p "$prompt" --permission-mode acceptEdits --allowedTools "$AGENT_ALLOWED_TOOLS" 2>&1)" || + die "claude run failed" +fi + +# Verify the agent produced changes; commit anything it left uncommitted. No push here — the gate. +committed="$(git rev-list --count "$base_sha"..HEAD 2>/dev/null || echo 0)" +if [[ -z "$(git status --porcelain)" && "$committed" == "0" ]]; then + die "the agent produced no changes" +fi +if [[ -n "$(git status --porcelain)" ]]; then + git add -A + git -c user.name="Buzz Agent" -c user.email="agent@localhost" commit -q \ + -m "$COMMIT_PREFIX: $title" -m "Buzz run ${BUZZ_RUN:-unknown} — awaiting approval." +fi + +# stdout → the gate note the approver reads before granting. +printf 'Ready for review on %s.\n\n%s\n' "${BUZZ_BRANCH:-?}" "$summary" diff --git a/cli/src/main/resources/buzz-agent/workflow-ship.sh b/cli/src/main/resources/buzz-agent/workflow-ship.sh new file mode 100755 index 0000000000..a4140e5388 --- /dev/null +++ b/cli/src/main/resources/buzz-agent/workflow-ship.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# +# workflow-ship.sh — the --on-approve step of `amy buzz workflow run` (the GATED path). +# +# The runner calls this ONLY after a human grants the run's 46010 approval gate. It pushes the +# run's branch and opens (or reuses) the PR, printing the PR URL as the run's result (46005). It +# NEVER touches the default branch and NEVER force-pushes — the merge stays a human action on GitHub. +# +# Contract (set by the runner): +# BUZZ_WORKTREE .... the run's git worktree (already carries the agent's commits) +# BUZZ_BRANCH ...... the run's branch to push +# BUZZ_RUN ......... the run id +# stdout ........... the PR URL → the run result (46005) +# non-zero exit .... fails the run; stderr is the detail +# +# Host requirements: git + gh authenticated with a PR-ONLY token (Contents:RW + PRs:RW on this repo), +# and branch protection on the default branch. See README.md. + +set -euo pipefail +log() { printf '%s\n' "$*" >&2; } +die() { printf 'error: %s\n' "$*" >&2; exit 1; } + +[[ -n "${BUZZ_WORKTREE:-}" ]] || die "BUZZ_WORKTREE unset — run this under 'amy buzz workflow run'" +[[ -n "${BUZZ_BRANCH:-}" ]] || die "BUZZ_BRANCH unset" +cd "$BUZZ_WORKTREE" || die "cannot cd into worktree $BUZZ_WORKTREE" + +# The PR base = the repo's default branch. Never operate on it directly. +base_branch="$(gh repo view --json defaultBranchRef -q .defaultBranchRef.name 2>/dev/null || echo main)" +case "$BUZZ_BRANCH" in + "$base_branch" | main | master) die "refusing to operate on the default branch ($BUZZ_BRANCH)" ;; +esac + +title="$(git log -1 --format='%s' 2>/dev/null | cut -c1-72)" +[[ -n "$title" ]] || title="Buzz run ${BUZZ_RUN:-}" + +log "[workflow-ship] pushing $BUZZ_BRANCH" +git push -u origin "HEAD:$BUZZ_BRANCH" || die "push failed (is a PR-only token configured?)" + +pr_url="$(gh pr list --head "$BUZZ_BRANCH" --state open --json url -q '.[0].url' 2>/dev/null || true)" +if [[ -z "$pr_url" ]]; then + body="Approved via Buzz workflow run \`${BUZZ_RUN:-unknown}\`. Merge is a human action on GitHub." + pr_url="$(gh pr create --base "$base_branch" --head "$BUZZ_BRANCH" --title "$title" --body "$body" 2>/dev/null)" || + die "gh pr create failed (PR-only token + branch protection configured?)" +fi + +printf 'Opened PR: %s\n' "$pr_url" diff --git a/tools/buzz-agent/README.md b/tools/buzz-agent/README.md index 122831b9a7..4c62d4565d 100644 --- a/tools/buzz-agent/README.md +++ b/tools/buzz-agent/README.md @@ -21,7 +21,34 @@ See the design in [`cli/plans/2026-07-25-buzz-agent-support-channel.md`](../../c Its steps: read task → run agent → verify a diff exists → commit → push the branch → open (or reuse) the PR → print the URL. -## Usage +## Two paths: gated vs direct + +- **Direct (ungated) jobs** — `agent-exec.sh` (this file's subject) does agent → commit → push → PR + in one shot. Point `amy buzz agent serve` at it. +- **Gated workflow runs** — the work pauses on a human-approval gate before anything ships, so it's + split into two steps around the gate: **`workflow-agent.sh`** (the `--exec` step: agent → commit, + no push) and **`workflow-ship.sh`** (the `--on-approve` step: push → PR, runs only after a human + grants). Point `amy buzz workflow run` at them. + +## The easy button: `amy buzz agent up` + +For the gated path you don't need to wire any of this by hand. `amy` bundles `workflow-agent.sh` + +`workflow-ship.sh` and runs them for you: + +```bash +amy buzz agent up wss://your-buzz-relay --repo /path/to/your/checkout --approver npub1you… +``` + +It resolves the channel (the relay's only one, or pass `--channel`), defaults the worktree to +`--repo`, scopes intake to the channel roster (`--accept-from-channel`), and extracts the wrappers to +`~/.amy/buzz-agent/` (edit them there to customize, or pass your own `--exec`/`--on-approve`). Before +first run, check the host is safe: + +```bash +amy buzz agent doctor --repo /path/to/your/checkout # gh token scope + branch protection + clean tree +``` + +## Usage (manual / direct path) ```bash amy buzz agent serve wss://your-buzz-relay \ @@ -31,6 +58,9 @@ amy buzz agent serve wss://your-buzz-relay \ --parallel 2 ``` +The bundled copies `agent up` extracts live in `cli/src/main/resources/buzz-agent/`; the copies here +in `tools/buzz-agent/` are the readable, customizable reference (same content). + `--worktree` is **required** (the wrapper needs `BUZZ_BRANCH`/`BUZZ_WORKTREE`). `--parallel N` runs N jobs at once, each in its own worktree+branch. diff --git a/tools/buzz-agent/workflow-agent.sh b/tools/buzz-agent/workflow-agent.sh new file mode 100755 index 0000000000..32774003df --- /dev/null +++ b/tools/buzz-agent/workflow-agent.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# +# workflow-agent.sh — the --exec step of `amy buzz workflow run` (the GATED path). +# +# The runner calls this once per workflow run to do the agent's work. It runs a coding agent +# (Claude Code by default) on the task inside the run's isolated git worktree and COMMITS the +# result — but it does NOT push. A human approves the 46010 gate first; then workflow-ship.sh +# (the --on-approve step) pushes the branch and opens the PR. +# +# Contract (set by the runner): +# stdin ............ the task text +# BUZZ_WORKTREE .... the run's git worktree (a fresh branch off BUZZ_BASE_REF) +# BUZZ_BRANCH ...... the run's branch name +# BUZZ_BASE_REF .... what the branch was cut from +# BUZZ_RUN ......... the run id; BUZZ_REQUESTER — who asked +# stdout ........... becomes the approver's gate note (46010) — we print a short summary +# non-zero exit .... fails the run before it ever reaches the gate; stderr is the detail +# +# Config knobs (env): AGENT_CMD (override the whole agent call; reads the prompt on stdin and as +# $AGENT_PROMPT), AGENT_ALLOWED_TOOLS (Claude Code --allowedTools), COMMIT_PREFIX. + +set -euo pipefail +log() { printf '%s\n' "$*" >&2; } +die() { printf 'error: %s\n' "$*" >&2; exit 1; } + +AGENT_CMD="${AGENT_CMD:-}" +AGENT_ALLOWED_TOOLS="${AGENT_ALLOWED_TOOLS:-Edit,Write,Read,Bash,Glob,Grep}" +COMMIT_PREFIX="${COMMIT_PREFIX:-feat}" + +[[ -n "${BUZZ_WORKTREE:-}" ]] || die "BUZZ_WORKTREE unset — run this under 'amy buzz workflow run'" +cd "$BUZZ_WORKTREE" || die "cannot cd into worktree $BUZZ_WORKTREE" +git rev-parse --is-inside-work-tree >/dev/null 2>&1 || die "worktree is not a git repo" + +# Pin the start commit so "did the agent change anything?" is correct even off a moving HEAD. +base_sha="$(git rev-parse HEAD)" + +task="$(cat)" +[[ -n "${task//[[:space:]]/}" ]] || die "empty task" +title="$(printf '%s' "$task" | head -n1 | cut -c1-72)" + +log "[workflow-agent] run ${BUZZ_RUN:-?}: $title" +prompt="You are working in a fresh git worktree on branch '${BUZZ_BRANCH:-?}' (off '${BUZZ_BASE_REF:-HEAD}'). +Implement the request below and then stop. Do NOT switch branches, push, or open a PR — the +wrapper handles git after a human approves. Keep changes scoped to this repository. + +TASK: +$task" + +if [[ -n "$AGENT_CMD" ]]; then + export AGENT_PROMPT="$prompt" + summary="$(printf '%s' "$prompt" | bash -c "$AGENT_CMD" 2>&1)" || die "agent command failed" +else + command -v claude >/dev/null 2>&1 || die "claude CLI not found (set AGENT_CMD to your agent)" + summary="$(claude -p "$prompt" --permission-mode acceptEdits --allowedTools "$AGENT_ALLOWED_TOOLS" 2>&1)" || + die "claude run failed" +fi + +# Verify the agent produced changes; commit anything it left uncommitted. No push here — the gate. +committed="$(git rev-list --count "$base_sha"..HEAD 2>/dev/null || echo 0)" +if [[ -z "$(git status --porcelain)" && "$committed" == "0" ]]; then + die "the agent produced no changes" +fi +if [[ -n "$(git status --porcelain)" ]]; then + git add -A + git -c user.name="Buzz Agent" -c user.email="agent@localhost" commit -q \ + -m "$COMMIT_PREFIX: $title" -m "Buzz run ${BUZZ_RUN:-unknown} — awaiting approval." +fi + +# stdout → the gate note the approver reads before granting. +printf 'Ready for review on %s.\n\n%s\n' "${BUZZ_BRANCH:-?}" "$summary" diff --git a/tools/buzz-agent/workflow-ship.sh b/tools/buzz-agent/workflow-ship.sh new file mode 100755 index 0000000000..a4140e5388 --- /dev/null +++ b/tools/buzz-agent/workflow-ship.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# +# workflow-ship.sh — the --on-approve step of `amy buzz workflow run` (the GATED path). +# +# The runner calls this ONLY after a human grants the run's 46010 approval gate. It pushes the +# run's branch and opens (or reuses) the PR, printing the PR URL as the run's result (46005). It +# NEVER touches the default branch and NEVER force-pushes — the merge stays a human action on GitHub. +# +# Contract (set by the runner): +# BUZZ_WORKTREE .... the run's git worktree (already carries the agent's commits) +# BUZZ_BRANCH ...... the run's branch to push +# BUZZ_RUN ......... the run id +# stdout ........... the PR URL → the run result (46005) +# non-zero exit .... fails the run; stderr is the detail +# +# Host requirements: git + gh authenticated with a PR-ONLY token (Contents:RW + PRs:RW on this repo), +# and branch protection on the default branch. See README.md. + +set -euo pipefail +log() { printf '%s\n' "$*" >&2; } +die() { printf 'error: %s\n' "$*" >&2; exit 1; } + +[[ -n "${BUZZ_WORKTREE:-}" ]] || die "BUZZ_WORKTREE unset — run this under 'amy buzz workflow run'" +[[ -n "${BUZZ_BRANCH:-}" ]] || die "BUZZ_BRANCH unset" +cd "$BUZZ_WORKTREE" || die "cannot cd into worktree $BUZZ_WORKTREE" + +# The PR base = the repo's default branch. Never operate on it directly. +base_branch="$(gh repo view --json defaultBranchRef -q .defaultBranchRef.name 2>/dev/null || echo main)" +case "$BUZZ_BRANCH" in + "$base_branch" | main | master) die "refusing to operate on the default branch ($BUZZ_BRANCH)" ;; +esac + +title="$(git log -1 --format='%s' 2>/dev/null | cut -c1-72)" +[[ -n "$title" ]] || title="Buzz run ${BUZZ_RUN:-}" + +log "[workflow-ship] pushing $BUZZ_BRANCH" +git push -u origin "HEAD:$BUZZ_BRANCH" || die "push failed (is a PR-only token configured?)" + +pr_url="$(gh pr list --head "$BUZZ_BRANCH" --state open --json url -q '.[0].url' 2>/dev/null || true)" +if [[ -z "$pr_url" ]]; then + body="Approved via Buzz workflow run \`${BUZZ_RUN:-unknown}\`. Merge is a human action on GitHub." + pr_url="$(gh pr create --base "$base_branch" --head "$BUZZ_BRANCH" --title "$title" --body "$body" 2>/dev/null)" || + die "gh pr create failed (PR-only token + branch protection configured?)" +fi + +printf 'Opened PR: %s\n' "$pr_url"