feat(cli): fewer steps to connect a Buzz agent — accept-from-channel, agent up, doctor

Collapses the operator setup from an 8-flag command + two hand-written scripts to
essentially two commands, without removing any of the safety.

- `buzz workflow run` gains `--accept-from-channel` (parity with the job
  scheduler): scope intake to the channel's kind-39002 roster instead of pasting
  every teammate key. `--worktree` now defaults to the current directory.
- Ship the gated reference wrappers (tools/buzz-agent/workflow-agent.sh →
  agent+commit; workflow-ship.sh → push+PR after the gate), split around the
  approval gate the way agent-exec.sh is the one-shot ungated version.
- `buzz agent up RELAY --repo DIR --approver NPUB` — one command: resolves the
  channel (the relay's only one, or --channel), defaults worktree/intake, extracts
  the bundled wrappers to ~/.amy/buzz-agent, and delegates to `workflow run`. The
  only thing it can't default is the human approver.
- `buzz agent doctor [--repo DIR]` — preflight that turns the security checklist
  into a green/red report: gh authenticated, token can write to the repo, default
  branch protected against force-push, worktree clean. Exits non-zero if not.
- cli build: set duplicatesStrategy on processResources (the explicit
  resources.srcDir re-adds the default root, which now doubles the bundled scripts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
This commit is contained in:
Claude
2026-07-27 15:40:50 +00:00
parent 1b33b1b986
commit 8e79abaa82
9 changed files with 482 additions and 6 deletions
+6
View File
@@ -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<ProcessResources>().configureEach {
duplicatesStrategy = DuplicatesStrategy.INCLUDE
}
dependencies {
implementation(project(":quartz"))
implementation(project(":commons"))
@@ -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):
@@ -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<String>,
): 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<GroupMetadataEvent>()
.mapNotNull { it.groupId() }
.distinct()
.singleOrNull()
/** Extract the bundled gated wrappers to ~/.amy/buzz-agent and return (agentStepPath, shipStepPath). */
private fun extractWrappers(): Pair<String, String> {
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<String>,
): Int {
val args = Args(rest)
val repo = args.flag("repo") ?: System.getProperty("user.dir")
args.rejectUnknown("repo")
val checks = mutableListOf<Map<String, Any?>>()
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?,
@@ -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<String>,
): Int = run(dataDir, rest)
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
@@ -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<HexKey>? =
if (fromChannel) {
(explicitAccept ?: emptySet()) + channelMembers(ctx, relay, channel, timeoutSecs)
} else {
explicitAccept
}
val started = mutableSetOf<HexKey>() // triggers we've begun
val awaiting = mutableMapOf<HexKey, AwaitingRun>() // runId -> worktree while at the gate
val decided = mutableSetOf<HexKey>()
@@ -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<HexKey> =
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<GroupMembersEvent>()
.maxByOrNull { it.createdAt }
?.members()
?.toSet()
.orEmpty()
private const val MAX = 60_000
private val LIFECYCLE_KINDS =
listOf(
+70
View File
@@ -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"
+46
View File
@@ -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"
+31 -1
View File
@@ -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 <channel-uuid> \
@@ -31,6 +58,9 @@ amy buzz agent serve wss://your-buzz-relay <channel-uuid> \
--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.
+70
View File
@@ -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"
+46
View File
@@ -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"