Add herdr-spawn plugin: spawn a briefed agent in another project #1

Merged
forgejo-admin merged 2 commits from add-project-agent-spawn into main 2026-07-31 19:28:21 +00:00

Turns "spawn an agent in the Y project to work on X" into one command.

spawn new celilo fix-dns-source-ip "Fix public DNS source IP" "<detailed brief>"

Resolves the project against configured globs, fetches origin/main, creates an unfocused worktree workspace based on it, starts a Claude agent, and hands off the brief over herdr-mail — so the new agent gets a working reply address for free.

What's here

path
herdr-spawn/bin/spawn the CLI — ~270 lines, stdlib only, matches bin/mail's idiom
herdr-spawn/skill/SKILL.md agent-facing doc
herdr-spawn/README.md operator-facing doc
herdr-spawn/herdr-plugin.toml identity only — see below
openspec/specs/agent-spawn-*/ 3 capabilities, 15 requirements, 35 scenarios

herdr-mail is unchanged. Cross-workspace unicast (--to pane:<id>) already worked — the delivery handler's pane lookup has no workspace filter.

Decisions worth reviewing

The source checkout is never mutated. The obvious reading of "switch to main, pull latest" would git checkout main && git pull in the source repo — which may be dirty, or have another agent actively working in it. Instead freshness comes from git fetch origin main plus worktree create --base origin/main. Accepted trade: your local main stays stale until you pull it yourself; worktree branches are always current.

Ambiguous project names are an error, never a guess. Two collisions exist today (build-your-own-internet, playwright-msw are in both ~/dev and ~/hobby). Spawn lists the candidates and stops. spawn doctor finds them up front. Silently picking one would eventually create a branch in the wrong repository.

The recipient's phone mode is left alone. Pre-setting interrupt would inject the full brief, but phone mode deliberately belongs to the recipient. Tested instead of assumed: the default notify doorbell is enough — the spawned agent read it unprompted and started work.

Not an [[actions]] surface. Plugin actions are invoked with no arguments, and this verb is inherently parameterized, so it's a CLI — same shape as herdr-mail's send/read/reply. The manifest exists so herdr plugin link registers it, which is also how bin/spawn locates bin/mail at runtime (neither is on $PATH, and bare mail on macOS is BSD mailx).

Documented trap

herdr agent wait <pane> --until idle    # ✗ times out even on success
herdr agent wait <pane>                 # ✓ matches idle | done | blocked

A spawned agent that finishes its turn settles into done, not idle. This cost 90s during the spike; it's now a requirement in the spec and a table row in the skill.

Verification

check result
spawn selftest unique / zero / ambiguous match, .git-as-file, non-repo dir
spawn doctor found both real collisions, exit 1
ambiguous / not-found / non-repo / branch-exists all exit 1 before any side effect
full spawn end to end 4.3s to a briefed agent; read the doorbell unprompted, worked, replied
source checkout HEAD, branch, index, status --porcelain byte-identical — and dirty throughout
focus never moved; new workspace created unfocused
base worktree branch off origin/main
teardown worktree, workspace, branch all removed cleanly

Also verified separately that git fetch origin main really does refresh refs/remotes/origin/main (not just FETCH_HEAD), since --base origin/main depends on it.

v1 limits

  • No reaping. Every spawn leaves a workspace, checkout, and branch. Teardown is two commands (documented in the plugin README) because worktree remove leaves the branch behind.
  • Claude only; --kind is not plumbed through yet.
  • Assumes origin and main. Other default branches fail at the fetch step rather than guessing.

Full rationale, alternatives considered, and spike evidence: openspec/changes/archive/2026-07-31-add-project-agent-spawn/design.md.

🤖 Generated with Claude Code

Turns "spawn an agent in the Y project to work on X" into one command. ```sh spawn new celilo fix-dns-source-ip "Fix public DNS source IP" "<detailed brief>" ``` Resolves the project against configured globs, fetches `origin/main`, creates an **unfocused** worktree workspace based on it, starts a Claude agent, and hands off the brief over herdr-mail — so the new agent gets a working reply address for free. ## What's here | path | | |---|---| | `herdr-spawn/bin/spawn` | the CLI — ~270 lines, stdlib only, matches `bin/mail`'s idiom | | `herdr-spawn/skill/SKILL.md` | agent-facing doc | | `herdr-spawn/README.md` | operator-facing doc | | `herdr-spawn/herdr-plugin.toml` | identity only — see below | | `openspec/specs/agent-spawn-*/` | 3 capabilities, 15 requirements, 35 scenarios | **herdr-mail is unchanged.** Cross-workspace unicast (`--to pane:<id>`) already worked — the delivery handler's pane lookup has no workspace filter. ## Decisions worth reviewing **The source checkout is never mutated.** The obvious reading of "switch to main, pull latest" would `git checkout main && git pull` in the source repo — which may be dirty, or have another agent actively working in it. Instead freshness comes from `git fetch origin main` plus `worktree create --base origin/main`. Accepted trade: your local `main` stays stale until you pull it yourself; worktree branches are always current. **Ambiguous project names are an error, never a guess.** Two collisions exist today (`build-your-own-internet`, `playwright-msw` are in both `~/dev` and `~/hobby`). Spawn lists the candidates and stops. `spawn doctor` finds them up front. Silently picking one would eventually create a branch in the wrong repository. **The recipient's phone mode is left alone.** Pre-setting `interrupt` would inject the full brief, but phone mode deliberately belongs to the *recipient*. Tested instead of assumed: the default `notify` doorbell is enough — the spawned agent read it unprompted and started work. **Not an `[[actions]]` surface.** Plugin actions are invoked with no arguments, and this verb is inherently parameterized, so it's a CLI — same shape as herdr-mail's `send`/`read`/`reply`. The manifest exists so `herdr plugin link` registers it, which is also how `bin/spawn` locates `bin/mail` at runtime (neither is on `$PATH`, and bare `mail` on macOS is BSD mailx). ## Documented trap ```sh herdr agent wait <pane> --until idle # ✗ times out even on success herdr agent wait <pane> # ✓ matches idle | done | blocked ``` A spawned agent that finishes its turn settles into `done`, not `idle`. This cost 90s during the spike; it's now a requirement in the spec and a table row in the skill. ## Verification | check | result | |---|---| | `spawn selftest` | unique / zero / ambiguous match, `.git`-as-file, non-repo dir | | `spawn doctor` | found both real collisions, exit 1 | | ambiguous / not-found / non-repo / branch-exists | all exit 1 **before any side effect** | | full spawn end to end | **4.3s** to a briefed agent; read the doorbell unprompted, worked, replied | | source checkout | HEAD, branch, index, `status --porcelain` **byte-identical** — and dirty throughout | | focus | never moved; new workspace created unfocused | | base | worktree branch off `origin/main` | | teardown | worktree, workspace, branch all removed cleanly | Also verified separately that `git fetch origin main` really does refresh `refs/remotes/origin/main` (not just `FETCH_HEAD`), since `--base origin/main` depends on it. ## v1 limits - **No reaping.** Every spawn leaves a workspace, checkout, and branch. Teardown is two commands (documented in the plugin README) because `worktree remove` leaves the branch behind. - **Claude only**; `--kind` is not plumbed through yet. - **Assumes `origin` and `main`.** Other default branches fail at the fetch step rather than guessing. Full rationale, alternatives considered, and spike evidence: `openspec/changes/archive/2026-07-31-add-project-agent-spawn/design.md`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
One command turns a project name, a slug, and a brief into a working agent
in its own herdr workspace, on its own branch, off the latest origin/main.

    spawn new celilo fix-dns "Fix public DNS source IP" "<brief>"

Resolves the project against configured globs (default ~/dev/* and ~/hobby/*),
fetches origin/main, creates an unfocused worktree workspace based on it,
starts a Claude agent, and hands off the brief over herdr-mail so the new
agent gets a working reply address for free.

Notable decisions:

- The source checkout is never mutated. Freshness comes from `git fetch` plus
  `worktree create --base origin/main`, not checkout/pull, because the source
  checkout may be dirty or have another agent working in it. Verified: HEAD,
  branch, index and porcelain status are byte-identical across a spawn.
- Ambiguous project names are an error listing candidates, never a guess.
  `spawn doctor` reports collisions up front (two exist today).
- The recipient's herdr-mail phone mode is left alone. The default notify
  doorbell is enough to start the new agent, and the mode belongs to the
  recipient.
- Not an [[actions]] surface: plugin actions take no arguments, so the verb
  is a CLI, like herdr-mail's send/read/reply.
- herdr-mail is unchanged. Cross-workspace unicast already worked.

Documented trap: `herdr agent wait <pane> --until idle` times out on a
spawned agent, which settles into `done`. Use bare `herdr agent wait`.

Verified end to end: 4.3s from command to briefed agent; it read the doorbell
unprompted, did the work, and replied cross-workspace. Torn down clean.

Change archived at openspec/changes/archive/2026-07-31-add-project-agent-spawn/
with specs synced to openspec/specs/ (3 capabilities, 15 requirements,
35 scenarios).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`~/dev/build-your-own-internet` is a symlink to `~/hobby/build-your-own-internet`.
Both glob expansions matched, so resolution reported an ambiguity and refused to
spawn into a repo that only exists once — and `doctor` flagged a collision that
was not real.

Resolve candidates through realpath before deduping. This also matches the
behavior of the explicit-path branch, which already used Path.resolve().

Adds a selftest case, and a scenario to the agent-spawn-projects spec.
`doctor` now reports only the one genuine collision (playwright-msw).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
forgejo-admin/herdrmastr!1
No description provided.