Running AI sessions in parallel

Series · Part 6 — “Building task management for AI agents.” Last time was why concurrent writes don’t break. This time is how development is actually run on top of that.

Part 1 listed, among the dead ends of working with AI agents, “split the work across several and the tasks collide.” This part is the answer to it — how the development of Amenbo (https://amenbo.work/) itself is actually run in parallel.

On a normal day, two or three AI sessions run at once. One session doesn’t wait for another to finish. But line them up with nothing prepared and they break fast.

Two places where things collide

Two problems dominate when you run sessions in parallel.

There are two things to split, so there are two countermeasures. Files are split with git’s worktree, tasks with a reservation.

The two collision points in parallel sessions. On the left, two sessions share one working tree and their half-written changes mix — a file collision. On the right, two sessions pick up the same task — a task collision. The former is split with a worktree, the latter with a reservation.
The collisions are files and tasks. Two things to split, so two countermeasures

Splitting files with a worktree

git’s worktree checks out a different branch into a different folder from a single repository at the same time. Unlike re-cloning, the history stays one, shared.

One worktree is prepared per task. The task number becomes both the folder name and the branch name.

Thing Location / name
worktree <repo>/../<repo-name>-worktrees/<task-number>
branch task/<task-number>

Because the names are fixed by the task number, trying to start the same task twice always collides on the same path and branch name and is rejected. “I’d accidentally double-started it” surfaces as a name collision.

Putting the worktree outside the repo

Placing it next to the parent folder is deliberate. There must be no .amenbo in the worktree’s ancestry.

As Part 4 described, the folder’s .amenbo was the AI’s reach. Its absence means that amenbo typed from inside the worktree does not reach the real backlog. Using that property, two concerns are physically separated.

A dev build under way never accidentally touches the real backlog. Just separating the location makes it so.

The layout. The main repo has a .amenbo, and from there the prod amenbo drives the backlog — reserve, comment, done. Outside the repo, in a sibling folder, per-task-number worktrees sit side by side, each checked out on branch task/<number>. Because there is no .amenbo in the worktree's ancestry, amenbo typed there falls to a throwaway store and doesn't reach the backlog. Code verification happens there.
Management from the main repo, verification in the outside worktree. With no .amenbo in the ancestry, the dev build doesn't reach the backlog

Splitting tasks with a reservation

Now the other collision. Each session picks one unstarted task assigned to it (assignee:me-ai status:todo ready:yes) and reserves it before starting. Reserving means setting the status to in_progress.

Here is the crux: that transition is a compare-and-swap that only succeeds from todo. If someone has already made it in_progress, the second reservation is refused with already_reserved, and the command fails and ends.

The reservation compare-and-swap. Two sessions pick the same task from the unstarted list and try to move it to in_progress almost simultaneously. Only the first, entering from todo, succeeds and takes the reservation; the second, no longer at todo, is refused with already_reserved and the command fails. The second session moves to another task.
The reservation passes only from todo. The loser doesn't succeed — it's clearly refused

What matters is that the loser does not silently pass through. Normally, re-setting something to the same state tends to be treated as success (“it’s already so”). In that case the second session would think it took the reservation and merrily start building the same task. Being refused is exactly what makes the collision visible on the spot, so the session can move to the next task.

Starting is one command

The whole procedure is bundled into a small self-made command called devtool (full source at the end of this article). Running the devtool task start 696 command once does this:

  1. Reserve task 696 and re-verify that it really became in_progress.
  2. Create the worktree and branch task/696.
  3. If the task includes the GUI, also run npm ci (it doesn’t stop on failure; it only warns).
  4. Pour in, on the spot, the task’s body, notes, linked decisions, and latest comments.
  5. Print a cd to enter the worktree.

The fourth step is exactly the aim written up in Part 1. At the moment of starting, “what it assumes, what was decided, and what it’s stuck on right now” appears in front of you. There’s no effort of going to search for it, and you can’t start writing without searching either.

The output is eval-able, so a session starts like this:

eval "$(devtool task start 696)"   # reserve, then enter the worktree

The two meanings of “a worktree already exists”

Running in parallel, you hit a case where the task you’re about to start already has a worktree. On disk, these two look exactly the same.

Situation What’s actually happening What to do
in_progress in the backlog Another session is working Hands off. Take another task
Not in_progress Your own leftover Clean up, then restart

So when refusing, the command names which one it is. Without naming it, you’d judge “probably an old one” and go delete it. A worktree another session is working in: don’t look inside it, don’t judge whether it’s stale, don’t delete it. Those three are written straight into the refusal message.

Cleanup is part of one task

When you’re done, fold it with the devtool task finish 696 command. Here too it refuses unless the conditions are met.

Both can be forced through with --force, but the default is to refuse. If it passes, it deletes the worktree and branch, and if no other task’s worktree remains, it cleans up the parent folder too. One task = one commit, so what remains is just one commit.

Not a cure-all

This doesn’t make collisions vanish. Line up tasks that touch the same place and the final merge fights. How many you can line up is set by how independent the tasks are — that is, a question of how the backlog is sliced. In fact, even after splitting a largish feature into fine pieces, adjacent tasks have collided.

Still, crushing the two collisions — files and tasks — up front leaves only the visible kind: “the merge fought.” The invisible breakage — half-written files mixing so that the test’s green means nothing — is gone.

devtool source

devtool is a small internal command, written only to dogfood Amenbo. Five Go files, about 580 lines total. A single binary with no runtime and no virtualenv, so it drops into any project regardless of language.

It runs with just go build. The full text is below (tests omitted).

The only Amenbo dependency is the reservation part in amenbo.go; the rest is plain git operations. To do the same idea with a different task manager, swap just that.

main.go — the command body. start goes reserve → worktree → print cd; finish holds the teardown refusal conditions
// Command devtool is amenbo's portable developer-support CLI: a single static
// Go binary (no runtime, no venv) that can be dropped into any project. Today it
// stamps out and tears down per-task git worktrees so several implementation
// sessions can run in parallel without stepping on each other.
//
// A task's worktree lives OUTSIDE the repo, in a sibling dir:
//
//	<repo>/../<repo-name>-worktrees/<id>/   git worktree checkout on task/<id>
//
// Outside-the-repo is deliberate: it is a pure development environment. With no
// repo `.amenbo` in its ancestry, amenbo commands run there (e.g. running the
// dev build for debug verification) cannot reach the real backlog — they fall
// to an isolated/throwaway store. That keeps two concerns physically apart:
//
//   - Project management (status/comment/done) → the PROD `amenbo` binary
//     run from the MAIN repo, against the real backlog. devtool's own reservation
//     does exactly this (prod binary, anchored to the main worktree root).
//   - Debug verification (does my code work) → the worktree's dev build against
//     a throwaway store (e.g. `make verify`), inside the outside worktree.
//
// devtool does NOT provision any amenbo store; isolation comes from the worktree
// living outside the repo plus `make verify`'s mktemp store.
package main

import (
	"errors"
	"flag"
	"fmt"
	"os"
	"path/filepath"
	"strings"
)

func main() {
	args := os.Args[1:]
	if len(args) == 0 {
		usage()
		os.Exit(2)
	}
	switch args[0] {
	case "task":
		taskCmd(args[1:])
	case "help", "-h", "--help":
		usage()
	default:
		logf("devtool: unknown command %q", args[0])
		usage()
		os.Exit(2)
	}
}

func usage() {
	logf(`devtool — amenbo developer-support CLI

Usage:
  devtool task start  <id> [--base main] [--no-reserve] [--no-deps]
  devtool task finish <id> [--base main] [--force] [--reset]

task start   reserve <id> (todo→in_progress, prod amenbo from the main repo) and
             add a git worktree on branch task/<id> in a sibling dir OUTSIDE the
             repo — a pure dev env. For a GUI checkout it also runs a best-effort
             'npm ci' in app/ (skip with --no-deps). Manage the backlog
             (comment/done) from the main repo; verify code there with
             'make verify'.
task finish  safely tear it down: refuse unless the worktree is clean and the
             branch is merged into --base (override with --force).`)
}

func taskCmd(args []string) {
	if len(args) == 0 {
		usage()
		os.Exit(2)
	}
	sub := args[0]
	rest := args[1:]
	// id is the first positional, before any flags: `task start <id> --flag`.
	var id string
	if len(rest) > 0 && !strings.HasPrefix(rest[0], "-") {
		id, rest = rest[0], rest[1:]
	}

	switch sub {
	case "start":
		fs := flag.NewFlagSet("task start", flag.ExitOnError)
		base := fs.String("base", "main", "branch to base the worktree on")
		noReserve := fs.Bool("no-reserve", false, "assume the task is already in_progress; only verify")
		noDeps := fs.Bool("no-deps", false, "skip the best-effort `npm ci` for GUI app/ checkouts")
		fs.Parse(rest)
		id = mustID(id)
		if err := taskStart(id, *base, *noReserve, *noDeps); err != nil {
			logf("devtool: %v", err)
			os.Exit(1)
		}
	case "finish":
		fs := flag.NewFlagSet("task finish", flag.ExitOnError)
		base := fs.String("base", "main", "branch the task must be merged into")
		force := fs.Bool("force", false, "tear down even if dirty or unmerged")
		rel := fs.Bool("reset", false, "also return the task to todo (amenbo status)")
		fs.Parse(rest)
		id = mustID(id)
		if err := taskFinish(id, *base, *force, *rel); err != nil {
			logf("devtool: %v", err)
			os.Exit(1)
		}
	default:
		logf("devtool: unknown task subcommand %q", sub)
		usage()
		os.Exit(2)
	}
}

// mustID validates the task reference and returns its canonical form: the
// conversational number (digits only), with an optional leading '#' stripped.
//
// Requiring the number — not a ULID or an id-prefix — is what keeps the worktree
// dir and branch name canonical (`task/<number>`). paths()/branchName() derive
// those names verbatim from this string, so two `task start` invocations for the
// same task in different reference forms (e.g. `696` vs its ULID) would otherwise
// produce two differently-named worktrees and slip past the "already exists"
// guard — silently double-starting the task in parallel sessions. Pinning the
// name to the number makes that second start collide on the same path/branch and
// be rejected.
func mustID(id string) string {
	canon, err := canonicalID(id)
	if err != nil {
		logf("devtool: %v", err)
		os.Exit(2)
	}
	return canon
}

// canonicalID normalizes a task reference to its conversational number (digits
// only), stripping an optional leading '#'. It rejects any other form (ULID,
// id-prefix, empty) — that rejection is the whole point (see mustID's doc).
func canonicalID(id string) (string, error) {
	id = strings.TrimPrefix(id, "#")
	if id == "" {
		return "", fmt.Errorf("missing <id>")
	}
	for _, r := range id {
		if r < '0' || r > '9' {
			return "", fmt.Errorf("task ref %q must be the conversational number (digits only, e.g. 696 or #696) — not a ULID or id-prefix, so the worktree/branch name stays canonical and a double-start is caught", id)
		}
	}
	return id, nil
}

// paths resolves the main repo root and the per-task worktree dir for id. The
// worktree lives OUTSIDE the repo, in a sibling `<repo-name>-worktrees/` dir, so
// it has no repo `.amenbo` in its ancestry (see the package doc).
func paths(id string) (root, base, worktree string, err error) {
	cwd, err := os.Getwd()
	if err != nil {
		return
	}
	root, err = gitRoot(cwd)
	if err != nil {
		return "", "", "", fmt.Errorf("not inside a git repository: %w", err)
	}
	base = filepath.Join(filepath.Dir(root), filepath.Base(root)+"-worktrees")
	worktree = filepath.Join(base, id)
	return
}

func branchName(id string) string { return "task/" + id }

// verifyReserved confirms the backlog holds the task in_progress before we spend
// work on a worktree. Double-work is guarded by `status` alone, so
// status==in_progress (set by us via task status) is the whole check. Reservation
// is blind to who asks: any session that finds it todo can reserve it, and
// in_progress is the advisory "someone is on it" signal.
func verifyReserved(id string, t task) error {
	if t.Status != "in_progress" {
		return fmt.Errorf("task %s is %q, not in_progress (reserve failed?)", id, t.Status)
	}
	return nil
}

// existingWorktreeError names WHICH accident a pre-existing worktree is, because the
// two look identical on disk and read the same to whoever hits the refusal. A worktree
// on a task the backlog holds in_progress is another session at work: the refusal has
// to say so, or it reads as the harmless one — the stale worktree of a task already
// handed back — and gets waved past. It is only advisory: the backlog is the authority
// on who reserved what, and a task can legitimately change hands mid-worktree.
func existingWorktreeError(root, id, worktree string) error {
	t, err := show(root, id)
	if err != nil {
		return worktreeConflict(id, worktree, "backlog unreadable")
	}
	return worktreeConflict(id, worktree, t.Status)
}

// worktreeConflict is the refusal itself, split from the lookup so both arms are
// testable. in_progress is the one status that means someone else is working here.
func worktreeConflict(id, worktree, status string) error {
	if status == "in_progress" {
		return fmt.Errorf(`ANOTHER SESSION IS PROBABLY ON TASK %s — hands off.

  %s exists and the backlog holds the task in_progress.

  Do not look inside it, do not judge whether it is stale, do not delete it, and do
  not ask whether to take it over. Take a different task (`+"`amenbo agent --json`"+`).

  Only if you know the worktree is your own leftover: `+"`devtool task finish %s`"+`.`, id, worktree, id)
	}
	return fmt.Errorf(`%s already exists, and the backlog does not hold task %s in_progress (status: %s).

  That reads as a worktree you left behind. Tear it down before starting again:
  `+"`devtool task finish %s`"+`.`, worktree, id, status, id)
}

func taskStart(id, base string, noReserve, noDeps bool) error {
	root, wtBase, worktree, err := paths(id)
	if err != nil {
		return err
	}
	if _, err := os.Stat(worktree); err == nil {
		return existingWorktreeError(root, id, worktree)
	}

	// 1. Reserve the task (todo→in_progress) and verify against the backlog: status
	// must be in_progress. `task status in_progress` is the reservation, and
	// status==in_progress is the whole check.
	if !noReserve {
		if _, err := setStatus(root, id, "in_progress"); err != nil {
			var ae *amErr
			if errors.As(err, &ae) && ae.Code == "already_reserved" {
				return fmt.Errorf(`ANOTHER SESSION RESERVED TASK %s FIRST — hands off.

  The reservation is a compare-and-swap: it only takes from todo, and this one
  did not. Someone else is on this task.

  Take a different task (`+"`amenbo agent --json`"+`). Do not reserve it by another
  route, and do not start work on it here.`, id)
			}
			return fmt.Errorf("reserve: %w", err)
		}
	}
	t, err := show(root, id)
	if err != nil {
		return fmt.Errorf("verify: %w", err)
	}
	if err := verifyReserved(id, t); err != nil {
		return err
	}

	// 2. Create the worktree on a fresh branch.
	if branchExists(root, branchName(id)) {
		return fmt.Errorf("branch %s already exists — finish or delete it first", branchName(id))
	}
	if err := os.MkdirAll(wtBase, 0o755); err != nil {
		return err
	}
	if err := worktreeAdd(root, worktree, branchName(id), base); err != nil {
		return fmt.Errorf("worktree add: %w", err)
	}

	// 3. Best-effort: warm the GUI app's node_modules so a GUI task's worktree is
	// ready for `cd app && npm run typecheck/build/test` without a manual `npm ci`.
	// Never fatal — the worktree/branch/reservation already exist (see ensureAppDeps).
	if !noDeps {
		ensureAppDeps(worktree)
	}

	// Human summary to stderr; an eval-able `cd` to stdout so callers can
	// `eval "$(devtool task start <id>)"` to enter the worktree.
	logf("✓ task %s ready: %s", id, t.Title)
	logf("  dev env : %s  (branch %s)", worktree, branchName(id))
	logf("            code/build/test here; debug-verify with `make verify`")
	logf("  backlog : run amenbo (status/comment/done) from the MAIN repo: %s", root)
	// Front-load the context an agent must read before coding: the task's notes, the
	// decisions linked to it (the "why"), and the latest comments — surfaced by
	// `amenbo task show` in its human form. Printing it at reserve time means it can't
	// be skipped by reading notes alone. Best-effort: a show failure never fails start
	// (the worktree/branch are already in place).
	if ctx, err := showHuman(root, id); err == nil {
		if ctx = strings.TrimRight(ctx, "\n"); ctx != "" {
			logf("  context : read before coding (notes / linked decisions / latest comments) —")
			for _, line := range strings.Split(ctx, "\n") {
				logf("    %s", line)
			}
		}
	}
	fmt.Printf("cd %s\n", shellQuote(worktree))
	return nil
}

func taskFinish(id, base string, force, rel bool) error {
	root, wtBase, worktree, err := paths(id)
	if err != nil {
		return err
	}
	if _, err := os.Stat(worktree); err != nil {
		return fmt.Errorf("no worktree for task %s (%s missing)", id, worktree)
	}

	if !force {
		clean, err := isClean(worktree)
		if err != nil {
			return fmt.Errorf("check worktree: %w (use --force to override)", err)
		}
		if !clean {
			return fmt.Errorf("worktree %s has uncommitted changes — commit them or use --force", worktree)
		}
		merged, err := isMerged(root, branchName(id), base)
		if err != nil {
			return err
		}
		if !merged {
			return fmt.Errorf("branch %s is not merged into %s — merge it or use --force", branchName(id), base)
		}
	}

	if err := worktreeRemove(root, worktree, force); err != nil {
		return fmt.Errorf("worktree remove: %w", err)
	}
	if err := branchDelete(root, branchName(id), force); err != nil {
		return fmt.Errorf("branch delete: %w", err)
	}
	if rel {
		if err := unreserve(root, id); err != nil {
			logf("devtool: warning: returning the task to todo failed: %v", err)
		}
	}
	// git worktree remove deletes the dir; clean up any leftover defensively.
	if err := os.RemoveAll(worktree); err != nil {
		return fmt.Errorf("remove %s: %w", worktree, err)
	}
	// Prune the base dir when it is now empty; a non-empty error means another
	// task's worktree still lives there, so leave it be.
	_ = os.Remove(wtBase)

	logf("✓ torn down task %s (worktree + branch %s removed)", id, branchName(id))
	if !rel {
		logf("  note: the task's in_progress status was left as-is (use --reset, or `amenbo task done %s`)", id)
	}
	return nil
}

// shellQuote single-quotes a path for safe eval in a POSIX shell.
func shellQuote(s string) string {
	return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
amenbo.go — the reservation part. This is the only place that touches Amenbo
package main

import (
	"encoding/json"
	"fmt"
	"os"
)

// amenboBin is the backlog binary. The backlog (status/show) lives in the PROD
// store reached via the repo's `.amenbo` pointer, so the default is `amenbo`.
// Override with AMENBO_BIN (e.g. amenbo-dev) for isolated testing.
func amenboBin() string {
	if b := os.Getenv("AMENBO_BIN"); b != "" {
		return b
	}
	return "amenbo"
}

// amErr mirrors amenbo's `{ "error": { code, message, hint } }` envelope.
type amErr struct {
	Code    string `json:"code"`
	Message string `json:"message"`
	Hint    string `json:"hint"`
}

func (e *amErr) Error() string {
	if e.Hint != "" {
		return e.Message + " (" + e.Hint + ")"
	}
	return e.Message
}

// task holds the fields we need to drive worktree isolation and verify the
// reservation. amenbo emits many more; we decode only these. Double-work is
// guarded by `status` alone, so worktree isolation reserves a task by moving it
// todo→in_progress.
type task struct {
	Title  string `json:"title"`
	Status string `json:"status"`
}

// setStatus runs `amenbo task status <id> <status>` in repoDir (so the `.amenbo`
// pointer resolves to the backlog store) and returns the updated task. `task
// status --json` wraps the task as `{ ok, task: {...} }`. in_progress reserves,
// todo releases.
func setStatus(repoDir, id, status string) (task, error) {
	out, err := run(repoDir, amenboBin(), "task", "status", id, status, "--actor", "ai", "--json")
	if err != nil {
		return task{}, err
	}
	var env struct {
		OK    bool   `json:"ok"`
		Task  task   `json:"task"`
		Error *amErr `json:"error"`
	}
	if err := json.Unmarshal([]byte(out), &env); err != nil {
		return task{}, fmt.Errorf("parse status output: %w", err)
	}
	if env.Error != nil {
		return task{}, env.Error
	}
	return env.Task, nil
}

// show runs `amenbo task show`. Unlike add/status, `task show --json` emits the
// task fields at the TOP level (no `task` wrapper), so we decode into a struct
// that embeds task alongside the optional error envelope.
func show(repoDir, id string) (task, error) {
	out, err := run(repoDir, amenboBin(), "task", "show", id, "--json")
	if err != nil {
		return task{}, err
	}
	var res struct {
		task
		Error *amErr `json:"error"`
	}
	if err := json.Unmarshal([]byte(out), &res); err != nil {
		return task{}, fmt.Errorf("parse show output: %w", err)
	}
	if res.Error != nil {
		return task{}, res.Error
	}
	return res.task, nil
}

// showHuman runs `amenbo task show <id>` in its human form and returns the text. Unlike show
// (which parses --json fields), this is the operator-facing rendering that bundles the four things an
// agent must read before coding — body, notes, the linked decisions (the "why"), and the latest
// comments. task start front-loads it so the context can't be skipped by reading notes alone.
func showHuman(repoDir, id string) (string, error) {
	return run(repoDir, amenboBin(), "task", "show", id)
}

// unreserve moves a task back to todo (used on teardown of an unfinished task).
// todo is how you hand a reservation back.
func unreserve(repoDir, id string) error {
	_, err := run(repoDir, amenboBin(), "task", "status", id, "todo", "--actor", "ai", "--json")
	return err
}
git.go — worktree and branch operations. The crux is "anchor to the main worktree root"
package main

import (
	"path/filepath"
	"strings"
)

// gitRoot returns the MAIN worktree root for the repo containing dir — not the
// current linked worktree. This is what makes devtool behave the same whether
// it is invoked from the main checkout or from inside a per-task worktree
// (where the naive --show-toplevel would point at the worktree itself and the
// .worktrees/<id> layout would resolve wrong). --git-common-dir resolves to the
// main repo's `.git`, whose parent is the main worktree root.
func gitRoot(dir string) (string, error) {
	common, err := run(dir, "git", "rev-parse", "--path-format=absolute", "--git-common-dir")
	if err != nil {
		return "", err
	}
	return filepath.Dir(common), nil
}

// worktreeAdd creates a new worktree at path checked out to a fresh branch
// branched from base.
func worktreeAdd(root, path, branch, base string) error {
	_, err := run(root, "git", "worktree", "add", path, "-b", branch, base)
	return err
}

// worktreeRemove detaches the worktree at path. force discards local changes.
func worktreeRemove(root, path string, force bool) error {
	args := []string{"worktree", "remove", path}
	if force {
		args = append(args, "--force")
	}
	_, err := run(root, "git", args...)
	return err
}

// branchExists reports whether refs/heads/branch is present.
func branchExists(root, branch string) bool {
	_, err := run(root, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
	return err == nil
}

// branchDelete deletes branch. force allows deleting an unmerged branch.
func branchDelete(root, branch string, force bool) error {
	flag := "-d"
	if force {
		flag = "-D"
	}
	_, err := run(root, "git", "branch", flag, branch)
	return err
}

// isClean reports whether the worktree at path has no uncommitted changes.
func isClean(path string) (bool, error) {
	out, err := run(path, "git", "status", "--porcelain")
	if err != nil {
		return false, err
	}
	return strings.TrimSpace(out) == "", nil
}

// isMerged reports whether branch is fully contained in base (its tip is an
// ancestor of base), i.e. safe to delete.
func isMerged(root, branch, base string) (bool, error) {
	_, err := run(root, "git", "merge-base", "--is-ancestor", branch, base)
	if err == nil {
		return true, nil
	}
	// `--is-ancestor` exits 1 (non-fatal) when not an ancestor; treat as "not
	// merged" rather than a hard error.
	return false, nil
}
deps.go — the GUI task's npm ci. A failure doesn't stop start
package main

import (
	"os"
	"os/exec"
	"path/filepath"
)

// ensureAppDeps installs the GUI app's node_modules in a freshly created
// worktree so `cd app && npm run typecheck/build/test` works without a manual
// `npm ci`. Each worktree keeps a real (gitignored) node_modules — no symlink —
// so parallel sessions stay isolated.
//
// It is strictly best-effort: a non-GUI checkout (no app/package.json), a
// missing npm, or a failed install must NEVER fail `task start`. The
// worktree/branch/reservation are already in place by the time this runs, so on any
// problem we only warn and let the developer run `npm ci` by hand.
func ensureAppDeps(worktree string) {
	app := filepath.Join(worktree, "app")
	if _, err := os.Stat(filepath.Join(app, "package.json")); err != nil {
		return // not a GUI checkout (core/CLI task) — nothing to install.
	}
	if _, err := exec.LookPath("npm"); err != nil {
		logf("  deps    : skipped — npm not found; run `cd app && npm ci` by hand")
		return
	}
	logf("  deps    : installing app/node_modules (npm ci)…")
	if _, err := run(app, "npm", "ci"); err != nil {
		logf("  deps    : warning — npm ci failed (%v); run `cd app && npm ci` by hand", err)
		return
	}
	logf("  deps    : app/node_modules ready")
}
run.go — running external commands. It returns a cd, so diagnostics go to stderr
package main

import (
	"bytes"
	"fmt"
	"os"
	"os/exec"
	"strings"
)

// logf writes diagnostics to stderr so stdout stays reserved for eval-able output.
func logf(format string, a ...any) {
	fmt.Fprintf(os.Stderr, format+"\n", a...)
}

// run executes a command in dir and returns its trimmed stdout. On failure the
// error carries the captured stderr so callers can surface the real cause.
func run(dir, name string, args ...string) (string, error) {
	cmd := exec.Command(name, args...)
	cmd.Dir = dir
	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		msg := strings.TrimSpace(stderr.String())
		if msg == "" {
			msg = strings.TrimSpace(stdout.String())
		}
		return "", fmt.Errorf("%s %s: %v: %s", name, strings.Join(args, " "), err, msg)
	}
	return strings.TrimSpace(stdout.String()), nil
}

As you can see, it does nothing grand. It calls git commands in order, checks the reservation result, and refuses where it should refuse. That’s all. Even so, whether these ~580 lines exist changed whether sessions could be lined up.

☕ Tip me