AI のセッションを、並行で回す

シリーズ・第 6 回 — 「AI エージェントのためのタスク管理をつくる」。前回は同時に書いても壊れない理由でした。今回は、その上で実際に開発をどう回しているかです。

第 1 回で、AI エージェントの手詰まりの一つに「複数に分ければタスクがぶつかる」を挙げました。今回はそこへの答え —— Amenbohttps://amenbo.work/)自身の開発で、実際にどう並行させているかを書きます。

普段は、AI のセッションを2〜3本、同時に走らせています。1本が終わるのを待たない。ただし、何も用意せずに並べると、すぐに壊れます。

ぶつかるのは、2か所

並行で困るのは、大きく2つです。

分けるものが2つあるので、対策も2つです。ファイルは git の worktree で、タスクは予約で分けます。

並行セッションでぶつかる2か所。左は、2本のセッションが1つの作業ツリーを共有し、書きかけの変更が混ざる「ファイルの衝突」。右は、2本のセッションが同じタスクを取ってしまう「タスクの衝突」。前者は worktree で分け、後者は予約で分ける。
ぶつかるのはファイルとタスク。分けるものが2つあるので、対策も2つ

ファイルは、worktree で分ける

git の worktree は、1つのリポジトリから、別のフォルダへ、別のブランチを同時にチェックアウトする機能です。クローンし直すのとは違い、履歴は1つのまま共有されます。

これをタスク1件につき1つ用意します。タスク番号がそのままフォルダ名とブランチ名になります。

もの 場所・名前
worktree <リポジトリ>/../<リポジトリ名>-worktrees/<タスク番号>
ブランチ task/<タスク番号>

名前がタスク番号で決まるので、同じタスクを2回始めようとすると、同じパスとブランチ名で必ずぶつかって弾かれます。「うっかり二重に始めていた」が、名前の衝突として表に出る。

worktree は、リポジトリの外に置く

置き場所が親フォルダの隣なのは、意図的です。worktree の祖先に .amenbo が無いようにしています。

第 4 回で書いたとおり、フォルダの .amenbo が AI の活動範囲でした。それが無いということは、worktree の中から amenbo を打っても、本物のバックログには届かないということです。この性質を使って、2つの関心事を物理的に離しています。

開発中のビルドが、うっかり本物のバックログを触ることはありません。置き場所を分けただけで、そうなります。

配置の図。メインのリポジトリには .amenbo があり、そこから本番の amenbo でバックログ(予約・コメント・完了)を操作する。リポジトリの外の兄弟フォルダに、タスク番号ごとの worktree が並び、それぞれ task/番号 のブランチをチェックアウトしている。worktree の祖先に .amenbo が無いので、そこから打った amenbo は使い捨てのストアに落ち、バックログには届かない。コードの検証はそこで行う。
管理はメインのリポジトリから、検証は外の worktree で。祖先に .amenbo が無いので、開発中のビルドはバックログに届かない

タスクは、予約で分ける

もう一方の衝突です。各セッションは、自分宛ての未着手タスク(assignee:me-ai status:todo ready:yes)から1件選び、予約してから始めます。予約はステータスを in_progress にすることです。

ここが肝で、この遷移は todo のときだけ成功する compare-and-swap になっています。すでに誰かが in_progress にしていたら、2本目の予約は already_reserved で断られ、コマンドは失敗して終わります。

予約の compare-and-swap。2本のセッションが未着手のタスク一覧から同じタスクを選び、ほぼ同時に in_progress へ変えようとする。todo から入った1本目だけが成功して予約を取り、2本目は todo ではなくなっているので already_reserved で断られ、コマンドが失敗する。2本目は別のタスクへ移る。
予約が通るのは todo からだけ。負けた側は成功せず、はっきり断られる

大事なのは、負けた側が静かに素通りしないことです。ふつう、同じ状態に置き直す操作は「もうそうなっている」で成功扱いになりがちです。それだと2本目は自分が予約を取れたと思い込み、同じタスクを平気で作り始める。断られるからこそ、衝突がその場で分かり、次のタスクへ移れます。

始めるのは、コマンド1つ

ここまでの段取りは、devtool という小さな自作コマンドにまとめてあります(ソースは記事の最後に全部載せました)。devtool task start 696 の1回で、こうなります。

  1. タスク 696 を予約し、本当に in_progress になったか確かめ直す。
  2. worktree とブランチ task/696 をつくる。
  3. GUI を含むなら npm ci まで済ませる(失敗しても止まらない。警告だけ出す)。
  4. タスクの本文・notes・紐づいた決定・最新のコメントを、その場に流し込む。
  5. worktree へ入る cd を出力する。

4つ目が、第 1 回に書いた狙いそのものです。着手の瞬間に「何を前提に、何が決まっていて、いま何で止まっているのか」が目の前に出る。探しにいく手間もいらないし、探さずに書き始めることもできない。

出力は eval できる形なので、セッションはこう始まります。

eval "$(devtool task start 696)"   # 予約して、worktree に入る

「すでに worktree がある」の2つの意味

並行で回していると、これから始めようとしたタスクに worktree がもうある、という場面が出ます。ディスク上では、この2つがまったく同じに見えます。

状況 実際に起きていること やること
バックログで in_progress 別のセッションが作業中 手を出さない。別のタスクを取る
in_progress ではない 自分の置き忘れ 片付けてから始め直す

なので、断るときにどちらなのかを名指しします。名指さないと、「たぶん古いやつだろう」と判断して消しにいくからです。他のセッションが作業中の worktree は、中を見ない・古いかどうか判断しない・消さない。この3つを、断り文にそのまま書いています。

片付けまでを含めて、1タスク

終わったら devtool task finish 696 で畳みます。ここも、条件を満たさないと断ります。

どちらも --force で押し切れますが、既定は断る側です。通れば worktree とブランチを消し、他のタスクの worktree が残っていなければ、親フォルダごと片付けます。1タスク=1コミットなので、残るのはコミット1つだけです。

万能ではない

これで衝突が消えるわけではありません。同じところを触るタスクを並べれば、最後のマージで揉めます。 並べられる本数は、タスクがどれだけ独立しているかで決まる —— つまり、バックログの切り方の問題です。実際、大きめの機能を細かく割ったつもりでも、隣り合ったタスク同士がぶつかったことがあります。

それでも、ファイルとタスクという2つの衝突を先に潰しておくと、残るのは「マージが揉めた」という見える形の衝突だけになります。書きかけのファイルが混ざって、テストの緑が何を意味するのか分からなくなる —— あの手の見えない壊れ方は、無くなります。

devtool のソース

devtool は、Amenbo を自分が使い倒すためだけに書いた、内部用の小さなコマンドです。Go のファイル5つ、全部で 580 行ほど。ランタイムも仮想環境もいらない1バイナリなので、言語を問わずどのプロジェクトにも置けます。

go build するだけで動きます。全文を載せておきます(テストは省きました)。

Amenbo に依存しているのは amenbo.go の予約まわりだけで、残りは素の git 操作です。同じ考え方を別のタスク管理でやるなら、そこだけ差し替えれば足ります。

main.go — コマンドの本体。start は予約 → worktree → cd の出力まで、finish は片付けの拒否条件を持つ
// 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 — 予約まわり。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 とブランチの操作。「メインの作業ツリーを基準にする」ところが肝
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 — GUI タスクの npm ci。失敗しても 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 — 外部コマンドの実行。cd を返すので、診断は 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
}

読んでのとおり、大したことはしていません。git のコマンドを順番に呼んで、予約の結果を確かめて、断るところで断る。それだけです。それでも、この 580 行があるかないかで、セッションを並べられるかどうかが変わりました。

☕ チップを送る