mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
@@ -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?,
|
||||
|
||||
+44
-5
@@ -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
@@ -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
@@ -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"
|
||||
@@ -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.
|
||||
|
||||
|
||||
Executable
+70
@@ -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"
|
||||
Executable
+46
@@ -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"
|
||||
Reference in New Issue
Block a user