mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +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 {
|
dependencies {
|
||||||
implementation(project(":quartz"))
|
implementation(project(":quartz"))
|
||||||
implementation(project(":commons"))
|
implementation(project(":commons"))
|
||||||
|
|||||||
@@ -827,6 +827,8 @@ private fun printUsage() {
|
|||||||
| buzz workflow trigger RELAY WFID … trigger a Buzz workflow run (kind-46020)
|
| 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 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 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)
|
| buzz agent serve RELAY --exec CMD run a parallel backlog scheduler (worktree-isolated)
|
||||||
|
|
|
|
||||||
|Marmot (MLS group messaging):
|
|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.nip01Core.signers.EventTemplate
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
|
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
|
||||||
|
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@@ -84,6 +85,10 @@ import java.util.concurrent.TimeUnit
|
|||||||
object BuzzAgentCommands {
|
object BuzzAgentCommands {
|
||||||
private val USAGE =
|
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
|
|amy buzz agent serve RELAY --exec CMD run a backlog scheduler
|
||||||
| [--channel GID] only handle jobs scoped to this channel
|
| [--channel GID] only handle jobs scoped to this channel
|
||||||
| [--accept-from npub,npub] allowlist of requester keys
|
| [--accept-from npub,npub] allowlist of requester keys
|
||||||
@@ -114,10 +119,172 @@ object BuzzAgentCommands {
|
|||||||
tail,
|
tail,
|
||||||
USAGE,
|
USAGE,
|
||||||
mapOf(
|
mapOf(
|
||||||
|
"up" to { rest -> up(dataDir, rest) },
|
||||||
|
"doctor" to { rest -> doctor(dataDir, rest) },
|
||||||
"serve" to { rest -> serve(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(
|
private class Opts(
|
||||||
val relay: NormalizedRelayUrl,
|
val relay: NormalizedRelayUrl,
|
||||||
val exec: String?,
|
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.relay.normalizer.NormalizedRelayUrl
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||||
|
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.coroutineScope
|
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 deny RELAY RUNID [--note N] deny the approval gate (46031)
|
||||||
|amy buzz workflow run RELAY --exec CMD --channel GID run the workflow runner
|
|amy buzz workflow run RELAY --exec CMD --channel GID run the workflow runner
|
||||||
| --approver NPUB [--on-approve CMD] [--worktree REPODIR] agent work → 46010 gate →
|
| --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()
|
""".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(
|
suspend fun dispatch(
|
||||||
dataDir: DataDir,
|
dataDir: DataDir,
|
||||||
tail: Array<String>,
|
tail: Array<String>,
|
||||||
@@ -298,12 +307,16 @@ object BuzzWorkflowCommands {
|
|||||||
decodePublicKeyAsHexOrNull(approverInput.trim())?.takeIf { it.isValid() }
|
decodePublicKeyAsHexOrNull(approverInput.trim())?.takeIf { it.isValid() }
|
||||||
?: return Output.error("bad_args", "invalid --approver key: $approverInput")
|
?: 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 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 baseRef = args.flag("base-ref") ?: "HEAD"
|
||||||
val once = args.bool("once")
|
val once = args.bool("once")
|
||||||
val pollSecs = args.flag("poll")?.toLongOrNull() ?: 5
|
val pollSecs = args.flag("poll")?.toLongOrNull() ?: 5
|
||||||
val timeoutSecs = args.flag("timeout")?.toLongOrNull() ?: 8
|
val timeoutSecs = args.flag("timeout")?.toLongOrNull() ?: 8
|
||||||
val acceptFrom =
|
val fromChannel = args.bool("accept-from-channel")
|
||||||
|
val explicitAccept =
|
||||||
args
|
args
|
||||||
.flag("accept-from")
|
.flag("accept-from")
|
||||||
?.split(",")
|
?.split(",")
|
||||||
@@ -311,13 +324,23 @@ object BuzzWorkflowCommands {
|
|||||||
?.map {
|
?.map {
|
||||||
decodePublicKeyAsHexOrNull(it)?.takeIf { hex -> hex.isValid() } ?: return Output.error("bad_args", "invalid --accept-from key: $it")
|
decodePublicKeyAsHexOrNull(it)?.takeIf { hex -> hex.isValid() } ?: return Output.error("bad_args", "invalid --accept-from key: $it")
|
||||||
}?.toSet()
|
}?.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 ->
|
Context.open(dataDir).use { ctx ->
|
||||||
ctx.prepare()
|
ctx.prepare()
|
||||||
val me = ctx.identity.pubKeyHex
|
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 started = mutableSetOf<HexKey>() // triggers we've begun
|
||||||
val awaiting = mutableMapOf<HexKey, AwaitingRun>() // runId -> worktree while at the gate
|
val awaiting = mutableMapOf<HexKey, AwaitingRun>() // runId -> worktree while at the gate
|
||||||
val decided = mutableSetOf<HexKey>()
|
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 const val MAX = 60_000
|
||||||
private val LIFECYCLE_KINDS =
|
private val LIFECYCLE_KINDS =
|
||||||
listOf(
|
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
|
Its steps: read task → run agent → verify a diff exists → commit → push the branch → open (or
|
||||||
reuse) the PR → print the URL.
|
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
|
```bash
|
||||||
amy buzz agent serve wss://your-buzz-relay <channel-uuid> \
|
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
|
--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`
|
`--worktree` is **required** (the wrapper needs `BUZZ_BRANCH`/`BUZZ_WORKTREE`). `--parallel N`
|
||||||
runs N jobs at once, each in its own worktree+branch.
|
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