diff --git a/AGENTS.md b/AGENTS.md index 42554d6..420e96e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,6 +89,7 @@ Never report a step as done before `mise run check` has passed, and say which ga - **No model calls in tests.** Unit and integration tests run a stand-in `copilot` (a script on `PATH`, such as `testdata/copilot`, which plays each role in the walking skeleton), never the real one. - **tmux on its own socket.** Every tmux server that a test, a smoke run or a live check starts gets a config file of its own (`-f /dev/null`, or a file of the test's), so the user's `~/.tmux.conf` and its plugins never load, and runs without `TMUX` and `TMUX_PANE` (`env -u TMUX -u TMUX_PANE`), so a slip can't reach the user's server. Tests run tmux with `-L baton-test-`, in a `TMUX_TMPDIR` and a `HOME` of their own, and kill only their servers. The walking skeleton (`TestSkeleton`) runs baton itself, so it runs tmux as a smoke run does, on the default socket, in a `TMUX_TMPDIR` (`/tmp/baton-test-skeleton-*`) and a `HOME` of its own. Smoke runs and live checks use a socket of their own, such as `-L baton-live`. baton itself uses tmux's default socket, or `TMUX`'s, so a smoke run of the binary gets a `TMUX_TMPDIR` and a `HOME` of its own, and starts that server first, with `-f /dev/null`; it never runs on the user's default server without the user's OK. The tmux tests' `TestMain` ends every server whose socket is in its `TMUX_TMPDIR`, even one that a test started on another socket, before it removes that folder. A test run that fails or stops early skips its cleanup, so after one, look for leftover servers (`pgrep -lf '^tmux .*(baton-test-|/Test)'`: a test's server shows its `-L baton-test-`, or its config file in the test's temporary folder), end each on its own socket, or with `kill ` once its socket is gone, and remove leftover `baton-tmux-` folders in `/tmp` and `baton-test-skeleton-*` folders in `/tmp` and `$TMPDIR`. Never kill the user's tmux server, and never touch a session or window that baton didn't create. - **git without the user's config.** Tests run git, theirs and baton's, with `GIT_CONFIG_GLOBAL=/dev/null`, `GIT_CONFIG_NOSYSTEM=1`, `LC_ALL=C` and a test author, and without the `GIT_*` variables that a hook of git's sets, such as `GIT_DIR`: each package's `TestMain` sets them. A test spells out git's arguments, so it runs no command that it doesn't show. +- **baton without the user's config.** Tests run baton with a `HOME` of their own and without `XDG_CONFIG_HOME` and `XDG_CACHE_HOME`, so it never reads the user's config file or writes in their cache: the cli tests' `TestMain` gives them an empty `HOME`, a `baton-test-home-*` folder in `$TMPDIR`, and a test that writes there, such as a config file, gives itself one of its own first. `TestMain` fails the run if a test left files in that folder. A run that stops early, such as one whose test panics, leaves the folder, so remove it after one. - **Copilot CLI config stays untouched.** Develop with `--plugin-dir`, flags or a throwaway `COPILOT_HOME`, never by changing `~/.copilot/`. - **Live checks are rare and small**: a throwaway repo in the spikes folder, the cheapest model that can do the job, and a `--max-ai-credits` cap (30 at least, the CLI's minimum). The PR records each live check's cost. On a Mac, a live check's `copilot` keeps the user's `HOME`, whose login keychain holds its login, and gets a throwaway `COPILOT_HOME`; its tmux server and the commands sent to it run in a clean environment (`env -i`), since a pane takes the `PATH` of the client that makes it (fact 30 in the plan). diff --git a/README.md b/README.md index 7607bd2..aaf468f 100644 --- a/README.md +++ b/README.md @@ -11,3 +11,22 @@ You talk to one agent, the orchestrator. It reads the ticket, plans the work as - macOS, git 2.36 or newer, and tmux 3.5 or newer. - [GitHub Copilot CLI](https://docs.github.com/copilot/how-tos/copilot-cli). - `gh` for GitHub pull requests; a Bitbucket Cloud API token for Bitbucket pull requests; a read-only Jira API token for Jira tickets. + +## Config + +baton needs no config file. To change its settings, write `config.json` in `~/Library/Application Support/baton/`. Its top holds the settings for every repo, and `repos` a section per repo, keyed by the host and path of the repo's `origin` remote, such as `github.com/codedogapp/baton` for `git@github.com:codedogapp/baton.git`. A repo's section lies over the top, one setting at a time. + +- `roles`: a section per role, `orchestrator`, `lead`, `engineer`, `qa` or `reviewer`, with its `model` (by default `claude-opus-5.5`), its `reasoningEffort` (`none`, `minimal`, `low`, `medium`, `high`, `xhigh` or `max`; by default copilot's own), its `maxAiCredits` (30 or more; by default no cap) and, for the orchestrator alone, its `extraTools`: your own tools that it may use, by default nudge's `nudge-ask` and `nudge-report`. +- `roundCap`: the engineer's passes per brief before the orchestrator asks you (by default 3). +- `branchPrefix`: what goes before a feature's slug in its branch's name (by default `feature/`). + +For example, QA on another model with a cap, for every repo, and two rounds with another branch prefix for one repo: + +```json +{ + "roles": {"qa": {"model": "claude-sonnet-5.5", "maxAiCredits": 60}}, + "repos": {"github.com/codedogapp/baton": {"roundCap": 2, "branchPrefix": "feat/"}} +} +``` + +baton checks the whole file each time it reads it, and refuses a file with a mistake, naming the file and the mistake. diff --git a/docs/plan.md b/docs/plan.md index 537e6e1..256b1b7 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -49,8 +49,8 @@ The first milestone is a walking skeleton (`skeleton`): one feature on a throwaw | `stop-hook` | `baton hook stop` and its `agentStop` hook in the plugin, which runs the pane's baton through `BATON_BIN`: `baton wait` and `baton ask` mark their role as waiting while they wait, and a pane's `copilot` that ends its turn without that mark gets one more turn, which tells it to start `baton wait`, to ask its question again, or to finish its part and hand over; the orchestrator, too, while a role holds a feature or work waits for it (decision 47) | Done (#24) | | `live-run` | A live run on a throwaway repo with a cheap model; the findings in a docs PR (facts 26 to 30) | Done (#25) | | `notes` | `baton handover` refuses a note in baton's folder, and `baton feature start` a brief, and the prompts send the notes and briefs to the session's `files/` folder and the roles' commands to the feature's worktree (decision 48; fact 29, risk 11) | Done (#26) | -| `config` | `internal/config`: baton's config file, with each role's model, reasoning effort and AI credits cap, the orchestrator's extra tools, the round cap and the branch prefix, for every repo and per repo, by the remote's URL, over baton's own; the whole file checked when read (decisions 16 and 49, fact 31) | In review (#29) | -| `config-cli` | `baton pane` and `baton feature start` take their settings from the config, for the repo of the `origin` remote's URL: each role's model, reasoning effort and AI credits cap, and the orchestrator's extra tools, in place of the pane's model and nudge's tools, and the round cap and the branch prefix in place of the start's (decision 49); the README's config section | Todo | +| `config` | `internal/config`: baton's config file, with each role's model, reasoning effort and AI credits cap, the orchestrator's extra tools, the round cap and the branch prefix, for every repo and per repo, by the remote's URL, over baton's own; the whole file checked when read (decisions 16 and 49, fact 31) | Done (#29) | +| `config-cli` | `baton pane` and `baton feature start` take their settings from the config, for the repo of the `origin` remote's URL: each role's model, reasoning effort and AI credits cap, and the orchestrator's extra tools, in place of the pane's model and nudge's tools, and the round cap and the branch prefix in place of the start's (decision 49, fact 32); the README's config section | In review (#30) | | `guard` | `baton hook guard` and its `preToolUse` hook in the plugin: role panes can't make outward actions; the orchestrator's pane asks the user for them and can't edit the code (decisions 17 and 19) | Todo | | `gates` | The engineer's handover runs the repo's gate command and refuses a red one | Todo | | `jira` | `baton jira `: the ticket as text, from Jira's REST API with a read-only token in the Keychain (decision 6) | Todo | @@ -121,6 +121,7 @@ Checked on 2026-10-31 with Copilot CLI 1.0.105 and tmux 3.8, unless a fact says 29. **What the roles did on their own.** In both rounds, the reviewer wrote its note in baton's notes folder (`/baton/features//notes/`), which is outside the worktree, as its prompt asks, and named it as baton names the next note; baton stored its own copy as the note after that, and the reviewer's file stayed beside it. The other roles wrote their notes in their session's folder (`$COPILOT_HOME/session-state//files/`). In the first round, the reviewer ran `git status` and `git diff` in the main worktree, whose path the note's path shows, before it ran them in the feature's. QA named its notes `…-qa-to-lead.md`, and baton stored them, by the flow, as `…-qa-to-reviewer.md`. The lead's commits end with `Co-authored-by: Copilot <…>`, the trailer that Copilot CLI asks for unless told otherwise; the lead's prompt asks only for the repo's commit style. Asked by the brief to try a `git push`, the lead refused, citing its prompt. 30. **Live checks on a Mac.** `copilot` keeps its login in the login keychain, which macOS finds through `HOME`: under another `HOME`, `copilot` isn't logged in. A throwaway `COPILOT_HOME` is enough to keep its config and sessions apart; its package cache (`~/Library/Caches/copilot`) follows `HOME`. A pane that a command-line client makes (`new-session`, `split-window`, `respawn-pane`) gets that client's `PATH` in place of the session's (tmux's `spawn.c`): the role panes that `baton feature start` makes find what its shell finds, and a command typed in another shell carries that shell's `PATH` into a live check's panes. A pane's command runs with its shell's `-c`; a pane without one starts a login shell, whose `path_helper` (from `/etc/profile` or `/etc/zprofile`) puts `/etc/paths` and `/etc/paths.d` (Homebrew's `/opt/homebrew/bin`) before the rest of `PATH`. 31. **A role's flags** (on 2026-11-03, from `copilot --help` and the errors of a `copilot` given a bad flag, which it refuses before it starts a session): `--model `, a model's name or `auto`; `--reasoning-effort `, one of `none`, `minimal`, `low`, `medium`, `high`, `xhigh` and `max`; `--max-ai-credits `, a number, fractions too, whose least value, 30 (fact 10), the CLI checks only after it has read its flags. A value that starts with a dash reads as a flag: `--max-ai-credits -1` fails on an unexpected argument `-1`. +32. **The `origin` remote's URL** (git 2.56.0, on 2026-11-03). `git remote get-url origin` prints the remote's first URL, with the user's `url..insteadOf` rewrites applied, from any of the repo's worktrees. Without an `origin` remote, it prints `error: No such remote 'origin'` and exits with 2, as git-remote's manual says since git 2.30; outside a repo, it exits with 128. A remote that has a section in git's config but no URL prints its own name. ## 3. Design @@ -151,10 +152,11 @@ A round is one pass of the engineer. A rejection starts the next round, with the **The final handover is the user's.** When a feature is ready, the orchestrator tells the user its branch, the lead's report and the open findings. The user decides: deliver it (`baton deliver` pushes the branch and opens the pull request), send it back with changes (a new brief to the lead, and the round count starts again), or close it. -**Panes** (decisions 9 to 11, 29, 32, 35, 36 and 38 to 42). `baton feature start` makes the worktree `-/` next to the repo's main worktree, on a new branch `feature/` from the main worktree's `HEAD`, and a tmux session `baton--` with four tiled panes titled lead, engineer, qa and reviewer (`pane-border-status top`, and `allow-set-title off` so that the titles stay), set on that session's window only. Each pane runs `baton pane`, from the baton that started the feature, with `BATON_ROLE` and `BATON_FEATURE` in its environment. `baton pane` finds `copilot` and writes the plugin, waits without a model until the role's first work arrives and picks it up, as `baton wait` does, then becomes, in the same process: +**Panes** (decisions 9 to 11, 29, 32, 35, 36, 38 to 42 and 49). `baton feature start` makes the worktree `-/` next to the repo's main worktree, on a new branch `` (`feature/` unless the config file gives another prefix) from the main worktree's `HEAD`, and a tmux session `baton--` with four tiled panes titled lead, engineer, qa and reviewer (`pane-border-status top`, and `allow-set-title off` so that the titles stay), set on that session's window only. Each pane runs `baton pane`, from the baton that started the feature, with `BATON_ROLE` and `BATON_FEATURE` in its environment. `baton pane` finds `copilot`, writes the plugin and reads the role's settings from the config file, waits without a model until the role's first work arrives and picks it up, as `baton wait` does, then becomes, in the same process: ```sh copilot --agent baton: --plugin-dir /plugin- --model \ + [--reasoning-effort ] [--max-ai-credits ] \ --session-id --allow-all --no-ask-user \ --available-tools bash read_bash stop_bash list_bash view create edit grep glob web_fetch \ -i "" @@ -162,15 +164,16 @@ copilot --agent baton: --plugin-dir /plugin- --model `copilot` keeps the pane's terminal and environment, so the role's `baton` commands read `BATON_ROLE` and `BATON_FEATURE` there, and gets `COPILOT_ALLOW_ALL=true` too, which trusts the worktree for that session (decision 39), and `BATON_BIN`, the path of the pane's baton, which the plugin's `agentStop` hook runs (decision 47). The plugin comes from the binary: `baton pane` writes it into ``, the `baton` folder in the user's cache folder (`~/Library/Caches/baton` on a Mac), unless it's there already, in a folder named by a hash of its files (decision 36). baton keeps each role's session ID, so it can find the role's events and resume the session. -**The orchestrator's session** (decisions 44 and 45). `baton start`, or plain `baton`, in the user's shell, makes the tmux session `baton-` in the repo's main worktree, unless it runs already, with one pane titled orchestrator, which runs `baton pane` with `BATON_ROLE=orchestrator` and no `BATON_FEATURE`. Then it shows the session: outside tmux, baton becomes `tmux attach-session`, and inside tmux, it switches the user's client to the session. The orchestrator's `baton pane` writes the plugin and becomes its `copilot` at once, with no first prompt, since the user speaks first, and with the user's environment, plus `BATON_BIN` (decision 47): +**The orchestrator's session** (decisions 44, 45 and 49). `baton start`, or plain `baton`, in the user's shell, makes the tmux session `baton-` in the repo's main worktree, unless it runs already, with one pane titled orchestrator, which runs `baton pane` with `BATON_ROLE=orchestrator` and no `BATON_FEATURE`. It reads the config file before it makes the session, so a mistake there shows in the user's terminal, not in a pane that takes its session with it when it fails. Then it shows the session: outside tmux, baton becomes `tmux attach-session`, and inside tmux, it switches the user's client to the session. The orchestrator's `baton pane` writes the plugin, reads its settings, and becomes its `copilot` at once, with no first prompt, since the user speaks first, and with the user's environment, plus `BATON_BIN` (decision 47): ```sh copilot --agent baton:orchestrator --plugin-dir /plugin- --model \ + [--reasoning-effort ] [--max-ai-credits ] \ --available-tools bash read_bash stop_bash list_bash view create edit grep glob web_fetch \ - ask_user nudge-ask nudge-report + ask_user ``` -It keeps copilot's own prompts, since the user watches that pane, `ask_user`, since the user talks to it, and the user's nudge tools for when the user is away. +It keeps copilot's own prompts, since the user watches that pane, `ask_user`, since the user talks to it, and its extra tools from the config file, by default the user's nudge tools, for when the user is away. **Handover and wake-up** (decision 12). `baton handover --note [--verdict …]` checks the move, stores the note as `notes/NN--to-.md`, logs the event and makes the next role the holder. It refuses a note in baton's folder, which only baton writes: the roles write theirs in their session's `files/` folder (decision 48). The role then starts `baton wait` in a background shell and ends its turn. `baton wait` looks for the role's work every second (decision 31); once the role has some, it records the pickup, prints the work (the round, the note's writer and their verdict, and the note's path) and exits; its end wakes the role (fact 5). The orchestrator's pane names no feature (decision 44): its `baton wait` picks up the work of any feature that it holds, ready or escalated, and its `baton handover` names the feature and the next role. The `agentStop` hook is the safety net (decision 47): `baton wait` and `baton ask` mark their role as waiting while they wait, and a `copilot` that ends its turn without that mark gets one more turn that tells it what to do: start `baton wait` when work can come to it, ask its question again when that waits for its answer, and finish its part and hand over otherwise. It gets that turn once per stop (`stop_hook_active`); the orchestrator gets it while a role holds a feature or work waits for it. Typing into the pane stays the fallback (fact 12). @@ -188,7 +191,7 @@ It keeps copilot's own prompts, since the user watches that pane, `ask_user`, si Every change takes an exclusive lock on the feature (`flock` on its `lock` file, which the kernel releases when the process ends), writes a new file and renames it over the old one, so a reader, who takes no lock, sees the old state or the new one. Every change logs its event, and keeps its note if it has one, in the same hold of the lock (decision 26). -**Config** (decisions 16 and 49). `baton` reads `config.json` in its folder under `os.UserConfigDir()` (`~/Library/Application Support/baton/` on a Mac), so nothing goes into a work repo. Its top holds the settings for every repo, and `repos` a section per repo, keyed by the remote's URL without its scheme, user, port and `.git`; the repo's section lies over the top, and the top over baton's own settings, one setting at a time. The settings so far: per role (`roles`), the model, the reasoning effort and an AI credits cap, and the orchestrator's extra tools (nudge's, by default); the round cap (3); the branch prefix (`feature/`). The later steps add theirs: the gate command (`gates`), plan approval, on by default (`guard`), the pull request host, by default from the remote's URL, and the base branch, by default the remote's default branch (`deliver-github`), and Jira's site and email (`jira`). Tokens never go into the file: they live in the login Keychain. For example, QA on another model for every repo, and two rounds with another branch prefix for one repo: +**Config** (decisions 16 and 49). `baton` reads `config.json` in its folder under `os.UserConfigDir()` (`~/Library/Application Support/baton/` on a Mac), so nothing goes into a work repo. Its top holds the settings for every repo, and `repos` a section per repo, keyed by the `origin` remote's URL without its scheme, user, port and `.git` (fact 32); the repo's section lies over the top, and the top over baton's own settings, one setting at a time. A repo without an `origin` remote gets the top's settings. The settings so far: per role (`roles`), the model, the reasoning effort and an AI credits cap, and the orchestrator's extra tools (nudge's, by default); the round cap (3); the branch prefix (`feature/`). `baton pane`, `baton feature start` and `baton start` read the file and check all of it, before they wait or make anything. The later steps add theirs: the gate command (`gates`), plan approval, on by default (`guard`), the pull request host, by default from the remote's URL, and the base branch, by default the remote's default branch (`deliver-github`), and Jira's site and email (`jira`). Tokens never go into the file: they live in the login Keychain. For example, QA on another model for every repo, and two rounds with another branch prefix for one repo: ```json { @@ -244,18 +247,18 @@ Decisions marked **lead's call** are the lead's, for the user's end review. The 35. **`repo.Worktree.Add` makes a feature's branch and worktree, both or neither** (lead's call; fact 23). It refuses a relative path, and a path that's taken, even by an empty folder, which git would take. It makes the branch on its own first, since `worktree add -b` keeps the branch it made when the path is taken, and without an upstream (`--no-track`), so that `baton deliver` sets one when it pushes. If the worktree fails, a hook of the user's included, or Ctrl-C stops it, baton removes the worktree, locked or not, and deletes the branch, once git's child has ended; its error says what it couldn't undo. A stop interrupts git, as Ctrl-C does, rather than killing it, so that git, which a Ctrl-C reaches too, isn't killed halfway through removing its worktree. Ctrl-C during `git branch` itself may leave the branch. A feature's worktree goes next to the repo's main worktree, which `git worktree list` gives first, from any of the repo's worktrees. The caller's names reach git after `--end-of-options`. 36. **The plugin's folder is named by its files, and appears whole** (lead's call; fact 24). baton writes the plugin from the binary into its cache, as `plugin-`, the first 16 hex digits of a SHA-256 of the files' paths and contents. It copies the files into a new temporary folder, which then takes that name, unless another baton has given a folder that name first; then baton uses that one, which holds the same files. So a pane never loads half a plugin, the panes of one baton version share one folder, and Copilot CLI keeps one plugin-data folder per version, not per pane. Two versions, an old pane's and a new one's after an upgrade (fact 17), each keep their own folder, and baton never removes one, since a running pane may still use it; a baton killed while it writes leaves its temporary folder, a few kilobytes, which nothing removes either. A fixed folder would mix two versions' files; a folder per pane would leave a plugin-data folder per pane in `~/.copilot` once the plugin has hooks. baton trusts a folder that has the name: the cache is the user's, and baton makes it readable by the user alone. 37. **The plugin holds only what baton runs** (lead's call): for now, the agents of the orchestrator, the lead, the engineer, QA and the reviewer. Each hook comes with its command (`agentStop` with `stop-hook`, `preToolUse` with `guard`), since a `preToolUse` hook whose command fails denies every tool (fact 4). A prompt names only commands that baton has, so the step that gives a role a command adds it to the role's prompt (`ask` adds `baton ask`). A prompt says how to do the role's job and how to hand over, and leaves the next role to baton (decision 13). The prompts are plain files, each whole, so the text they share is written in each, rather than put together from templates, which would be more code to get wrong; a test checks that each names the commands it needs. -38. **`baton pane` becomes the role's `copilot`** (lead's call). It waits as baton, then replaces itself with `copilot` (`execve`), so `copilot` gets the pane's terminal and signals as they are, and its environment with `COPILOT_ALLOW_ALL=true` (decision 39): Ctrl-C reaches `copilot` alone, and the pane ends when `copilot` does (fact 25). A child process would share the terminal with baton, which would then have to ignore Ctrl-C while `copilot` runs, since baton's second Ctrl-C ends it at once (decision 28). Before it waits, `baton pane` finds `copilot` and writes the plugin, so a pane that can't start `copilot` never picks up work that nobody would then do. It picks up the work as `baton wait` does, under the feature's lock, and gives `copilot` the text that `baton wait` prints as its first prompt. While it waits, it reads the terminal and drops what the user types, so it stops when the terminal's input ends, as on Ctrl-D, or when the terminal is gone, even without a hangup (facts 22 and 25). A `copilot` that fails to start leaves the work picked up, for the `resume` step. The orchestrator's pane doesn't wait: its work comes through its own `baton wait`, so `baton pane` becomes its `copilot` at once (decision 45). `main` passes `syscall.Exec` in, so gosec sees no command to flag, and `copilot`'s start carries no `//nolint:gosec` (decision 34). Every role gets Claude Opus 5.5 until the `config` step (decision 7), and `copilot` gets no `--session-id` until the `cost` step, which keeps it. +38. **`baton pane` becomes the role's `copilot`** (lead's call). It waits as baton, then replaces itself with `copilot` (`execve`), so `copilot` gets the pane's terminal and signals as they are, and its environment with `COPILOT_ALLOW_ALL=true` (decision 39): Ctrl-C reaches `copilot` alone, and the pane ends when `copilot` does (fact 25). A child process would share the terminal with baton, which would then have to ignore Ctrl-C while `copilot` runs, since baton's second Ctrl-C ends it at once (decision 28). Before it waits, `baton pane` finds `copilot`, writes the plugin and reads the config file, so a pane that can't start `copilot`, or not with the user's settings, never picks up work that nobody would then do. It picks up the work as `baton wait` does, under the feature's lock, and gives `copilot` the text that `baton wait` prints as its first prompt. While it waits, it reads the terminal and drops what the user types, so it stops when the terminal's input ends, as on Ctrl-D, or when the terminal is gone, even without a hangup (facts 22 and 25). A `copilot` that fails to start leaves the work picked up, for the `resume` step. The orchestrator's pane doesn't wait: its work comes through its own `baton wait`, so `baton pane` becomes its `copilot` at once (decision 45). `main` passes `syscall.Exec` in, so gosec sees no command to flag, and `copilot`'s start carries no `//nolint:gosec` (decision 34). Each role gets its model, reasoning effort and AI credits cap from the config file (decision 49), and `copilot` gets no `--session-id` until the `cost` step, which keeps it. 39. **A role's `copilot` trusts its worktree** (lead's call, flagged; fact 9). `baton pane` gives `copilot` its environment with `COPILOT_ALLOW_ALL=true`, in place of any value of the user's, which `copilot` would read first. That value also trusts the folder that `copilot` starts in, the feature's worktree, for that session alone, and saves nothing. Without it, a worktree outside the folders that the user trusts would stop the role on copilot's "Confirm folder trust" dialog, in a pane nobody watches (decision 9). The worktree holds the user's own repo, whose skills, plugins, MCP servers and hooks then load, as they do in the main worktree once the user trusts it; the tool allow-list still keeps their tools from the role (decision 10). The variable reaches the role's shells too, so a `copilot` that a role starts trusts its own folder as well; the `guard` step can deny that. Rejected: copilot's dialog; `--add-dir`, which doesn't trust the folder; `--trust-folder`, which needs `-p`; adding each worktree to the user's trusted folders, which would change `~/.copilot/` (decision 11). 40. **A failed start leaves nothing behind** (lead's call). `baton feature start` makes the feature first, which claims its slug, then its branch and worktree, then its tmux session, and when a part fails, it undoes the parts it made. `repo.Worktree.Undo` removes the worktree, with any changes in it, and deletes the branch, as `Add` does when it fails (decision 35). `state.Store.Remove` takes the feature's lock and removes the feature's file, which ends the feature, then its event log and its notes. It keeps the lock file, which baton never replaces, so a process that waits for the lock finds no feature once it gets it, and a new feature can take the slug and start afresh. A crash in between can leave the old event log and notes, which a new feature with that slug would then keep. -41. **A feature's names come from its slug and the repo's main worktree** (lead's call). Its branch is `feature/`, from the main worktree's `HEAD`, until `config-cli` brings the branch prefix (decision 49), and `deliver-github` the base branch, by default the remote's default branch (decision 16). Its worktree is `-`, next to the main worktree, `` being the main worktree's folder (decision 35), and its tmux session is `baton--` (decision 14), with an underscore for each character of `` that a session's name can't hold, such as a dot (decision 32). So two repos whose folders have the same name, or names that differ only in those characters (`my.repo` and `my_repo`), can't run features with the same slug at once: tmux refuses the second session, and baton undoes that start (decision 40). The feature's file keeps the branch and the worktree, for the later steps to read rather than work out again; they sit at the top of the file, so they keep the format (decision 24). +41. **A feature's names come from its slug and the repo's main worktree** (lead's call). Its branch is ``, `feature/` unless the config file gives another prefix (decision 49), from the main worktree's `HEAD`, until `deliver-github` brings the base branch, by default the remote's default branch (decision 16). Its worktree is `-`, next to the main worktree, `` being the main worktree's folder (decision 35), and its tmux session is `baton--` (decision 14), with an underscore for each character of `` that a session's name can't hold, such as a dot (decision 32). So two repos whose folders have the same name, or names that differ only in those characters (`my.repo` and `my_repo`), can't run features with the same slug at once: tmux refuses the second session, and baton undoes that start (decision 40). The feature's file keeps the branch and the worktree, for the later steps to read rather than work out again; they sit at the top of the file, so they keep the format (decision 24). 42. **`baton feature start` runs in the orchestrator's pane or the user's shell, and the panes run the baton that started the feature** (lead's call). It refuses a role's pane, by `BATON_ROLE` (decision 29): roles don't start features. Each pane's command is that baton's own path (`os.Executable`), not `baton` from the tmux server's `PATH`, which the panes get and which may find another baton, or none, so a feature's panes run the baton that started it. On a Mac, that path is the one that started baton, such as Homebrew's link; on Linux, it has its symlinks resolved. baton stops on a hangup (SIGHUP) as on Ctrl-C, so a terminal or pane that closes mid-start undoes the start, the branch included, which git alone would keep (fact 23). Under `nohup`, which starts baton with the hangup ignored, baton leaves it ignored, and so does git, but not for its hooks: a hangup while a hook of the user's runs fails the start, and baton undoes it. The order leaves two races. The lead's pane picks the brief up as soon as the session has that pane, before tmux has made the others, and its `copilot` starts; if tmux then fails, baton ends the session and removes the feature all the same. And Ctrl-C during `new-session` itself may leave the session (decision 32), with the lead's pane, which may have picked the brief up before baton removed the feature, and then works in a worktree that's gone, until the user ends the session. 43. **The walking skeleton runs this test binary as baton, with a stand-in `copilot` in each pane** (lead's call). `TestSkeleton`, in the root package, puts two links first on the `PATH`: `baton`, to the test binary, which runs baton's `main` in place of the tests when `BATON_TEST_AS_BATON` is set, and `copilot`, to `testdata/copilot`, a shell script that plays its pane's role through one round with set notes and verdicts and logs the work it gets. So the test builds nothing and calls no model, and it runs what the user runs: `main`, `baton feature start`, each pane's `baton pane`, and the roles' `baton handover` and `baton wait`. It checks what the orchestrator's commands print, the panes' titles and commands, the feature's journal and notes, the work that each pane got, and the commit. baton uses tmux's default socket, so the test, as a smoke run does, gives tmux a `TMUX_TMPDIR` of its own, under `/tmp`, since a Mac allows a socket's path 104 bytes at most, and starts that server first, with `-f /dev/null`; it also gets a `HOME` and a `TMPDIR` of its own. The server's first pane ends the server if the test binary dies before its cleanup, as when mise stops the tests because another task failed. The `live-run` step tries the real `copilot`. 44. **The orchestrator's pane names no feature** (lead's call). The orchestrator works on all the repo's features, so its pane has `BATON_ROLE` and no `BATON_FEATURE`, and baton refuses an orchestrator's pane that names a feature, and a role's pane that names none (decision 29). The orchestrator's `baton wait` looks at every feature, every second, in the order of their slugs: it picks up the work of the first that it holds, ready or escalated, that it hasn't picked up yet, under that feature's lock, as a role's wait does (decision 31), and prints it with the feature's status; in a feature that a role holds, its work is the role's question (decision 46). It refuses when no work could come, since its wait would never end: when there's no feature, or when it holds them all. So when the user starts a feature from their own shell, the orchestrator learns of it from `baton status`, and its next `baton wait` covers it. Its `baton handover` names the feature and the next role, `baton handover --to --note `: the lead, with a new brief, which starts the rounds again, or, after an escalation, the engineer, for one more round (decision 13). A role's handover takes neither: its pane names the feature, and baton picks the next role. -45. **`baton start` opens the orchestrator in the user's terminal, and the orchestrator keeps copilot's own prompts** (lead's call, flagged). `baton start`, or plain `baton`, runs in the user's own shell, and refuses a pane that baton started, by `BATON_ROLE` (decision 29). From any of the repo's worktrees, it names the session `baton-` after the main worktree's folder, as a feature's session is named (decision 41), and makes it in the main worktree, unless it runs already. A session of that name in another folder belongs to another repo with a folder of the same name, or to the user, so baton refuses it rather than show it. Then baton shows the session in the user's terminal: outside tmux, it becomes `tmux attach-session` (`execve`, as `baton pane` becomes `copilot`, decision 38), so tmux keeps the terminal until the user detaches; inside tmux, it switches the user's client, the one that shows baton's pane, to the session (fact 22). With no client to switch, baton fails with tmux's error, and the session stays for the next `baton start`. Two `baton start`s at once in one repo: one makes the session, and the other fails on tmux's `duplicate session`. The orchestrator's `copilot` starts at once, with no first prompt, since the user speaks first. It keeps copilot's own prompts: no `--allow-all`, `--no-ask-user` or `COPILOT_ALLOW_ALL`, since the user watches that pane and approves each tool, until the `guard` step decides what the orchestrator may do unasked; so copilot's folder-trust question for the main worktree is the user's to answer too, once. It gets the roles' tools (decision 10), plus `ask_user` and the user's nudge tools, `nudge-ask` and `nudge-report`; for a user without nudge, `copilot` only says that those names are unknown (fact 7). Its prompt, like the roles', names only the commands that baton has (decision 37): it plans with the user, starts features, keeps one `baton wait` running, reports each ready or escalated feature, and hands features back on the user's word. +45. **`baton start` opens the orchestrator in the user's terminal, and the orchestrator keeps copilot's own prompts** (lead's call, flagged). `baton start`, or plain `baton`, runs in the user's own shell, and refuses a pane that baton started, by `BATON_ROLE` (decision 29). From any of the repo's worktrees, it names the session `baton-` after the main worktree's folder, as a feature's session is named (decision 41), and makes it in the main worktree, unless it runs already. A session of that name in another folder belongs to another repo with a folder of the same name, or to the user, so baton refuses it rather than show it. Then baton shows the session in the user's terminal: outside tmux, it becomes `tmux attach-session` (`execve`, as `baton pane` becomes `copilot`, decision 38), so tmux keeps the terminal until the user detaches; inside tmux, it switches the user's client, the one that shows baton's pane, to the session (fact 22). With no client to switch, baton fails with tmux's error, and the session stays for the next `baton start`. Two `baton start`s at once in one repo: one makes the session, and the other fails on tmux's `duplicate session`. The orchestrator's `copilot` starts at once, with no first prompt, since the user speaks first. It keeps copilot's own prompts: no `--allow-all`, `--no-ask-user` or `COPILOT_ALLOW_ALL`, since the user watches that pane and approves each tool, until the `guard` step decides what the orchestrator may do unasked; so copilot's folder-trust question for the main worktree is the user's to answer too, once. It gets the roles' tools (decision 10), plus `ask_user` and its extra tools from the config file, by default the user's nudge tools, `nudge-ask` and `nudge-report` (decision 49); for a user without nudge, `copilot` only says that those names are unknown (fact 7), and `"extraTools": []` leaves them out. Its prompt, like the roles', names only the commands that baton has (decision 37): it plans with the user, starts features, keeps one `baton wait` running, reports each ready or escalated feature, and hands features back on the user's word. 46. **A role's question waits in its feature's file, one at a time** (lead's call, flagged). The holder's last question sits at the top of `feature.json`, so it keeps the format (decision 24): the role that asked, the text, whether the orchestrator's `baton wait` picked it up, and the answer once it comes. Not in `questions/.json`, as planned: a question belongs to its feature, so it takes the feature's lock and goes in its journal, with its text, as every change does (decision 26), and the slug names it, so `baton answer ` needs no ID. Only the holder asks, since only the holder works; the orchestrator asks the user. A role asks one question at a time, and baton refuses its handover until the answer comes, since the answer goes to the role that asked, which should still hold the feature when it acts on it. `baton ask` stores the question under the lock, then reads the file every second without it, as any reader may, until the answer comes. An ask with the text of the role's last question doesn't ask it again: it waits for the answer, or prints it if it came, so a role whose ask ended early, in a crash or a restart, gets the answer by asking again, and the orchestrator doesn't get the question twice. The holder's handover clears its question, which the journal keeps, so the same question in a later turn is a new one; the cost is that a role that means to ask the same question twice in one turn gets the first answer. If another question takes the place of an ask's question before the ask reads the answer, as two asks at once could, that ask fails rather than print an answer to another question, and so does an ask whose question a handover cleared before it read the answer. The orchestrator's `baton wait` picks up a question in any feature that a role holds, in the order of the slugs, as it picks up its work (decision 44): it marks the question picked under the lock and logs a pickup, so its next wait doesn't print it again; a question that it picked up but didn't answer, as when its pane stopped, waits for the `resume` step to bring it back. `baton answer` runs only in the orchestrator's pane, while a question waits for its answer. The texts are the commands' arguments, each one whole and not blank, so the prompts tell the roles and the orchestrator to quote them for the shell. 47. **A stop hook sends back a `copilot` that stops while nothing of its role waits** (lead's call, flagged; facts 4 and 5). The plugin's `agentStop` hook runs `"$BATON_BIN" hook stop`, and `baton pane` gives each pane's `copilot` `BATON_BIN`, the path of its own baton, in place of any value of the user's, so the hook runs the baton that started the pane, not one from the `PATH` (decision 42). `baton wait` and `baton ask`, and `baton pane` while it waits, mark their role as waiting: a shared `flock` on `.wait` in the feature's folder, or on `orchestrator.wait` in baton's folder, files that baton never removes, as it never removes a lock (decision 40). The kernel releases the mark when its process ends, so a wait that crashed leaves none, and two waits of one role share it. A wait takes the mark only when its first look finds nothing, so a wait that refuses, or picks its work up at once, writes nothing; that look takes milliseconds, and a model's turn far longer, so the hook sees the mark of a wait that the turn started. `baton hook stop` lets the `copilot` stop while its role's mark is held, since the wait's end wakes it (fact 5). Otherwise it sends it back, with a reason that becomes its next prompt: to start `baton wait` when work can come to it, because another role holds the feature or its work waits there; to ask its question again when that waits for its answer, since baton doesn't ask it twice (decision 46); and otherwise to finish its part and hand over, or just to end its turn when a command of its own runs in the background. The orchestrator gets the first reason while a role holds a feature or its work waits in one, and may stop once it holds every feature with its work picked up, since those wait on the user, or when there is none. The hook changes nothing in baton's folder, and lets the `copilot` stop when its block has sent this turn back already (`stop_hook_active`), so a `copilot` that can't do what the reason says stops one turn later; that turn is also the cost for a role that stops to wait for a command of its own. It finds the repo from the `copilot`'s folder in its input, or from its own folder without one. A hook that fails lets the `copilot` stop (fact 4), so baton reports why on stderr and exits with 1, and copilot logs it. When two hooks look at one mark at the same moment, one can take the other's look for a wait and let its `copilot` stop, as a hook that fails does; only two `copilot`s of one role could do that. Rejected: looking for a `baton wait` in the process table, which needs `ps` and can't tell one pane's wait from another's; a PID file, which a crash leaves behind; and a mark that every wait takes at its start, which would write files for waits that refuse. 48. **Only baton writes in its folder** (lead's call, flagged; fact 29, risk 11). `baton handover` refuses a note, and `baton feature start` a brief, that lies in baton's folder, once a relative path is made absolute from the working folder, so that the check and the read look at the same file, and the symlinks of both paths are resolved; it says to move the file to the session's `files/` folder, and the refusal changes nothing. In the live run the reviewer wrote its notes there, named as baton names the next one: a stray file there misleads whoever reads the notes, and baton numbers a new note after the folder's highest one (decision 26), so a stray shifts the numbers. The prompts send each role's notes, and the orchestrator's briefs and notes, to its session's `files/` folder (`$COPILOT_HOME/session-state//files/`), which Copilot CLI names in each session's context, never to the repo's worktrees, where they could get committed, or to baton's folder. They also tell the roles that the folder of the notes that baton hands them is baton's, and to run git and their other commands in the feature's worktree, where their pane starts, since the notes' paths run through the main worktree. So decision 19's guard lets the orchestrator `edit` and `create` only in its session's folder, no longer in baton's. On a disk that ignores case, as a Mac's does by default, a path that spells baton's folder in other case gets through, and the refusal stops only the note that a handover names: the guard's `preToolUse` hook can keep the file tools out of baton's folder (risk 11). baton doesn't refuse a note in a worktree, which only risks a commit, and doesn't remove a stray that is there already. -49. **One config file, in layers** (lead's call, flagged; decision 16, fact 31). `config.json` in baton's folder under the user's config folder holds the settings for every repo at its top, and each repo's section in `repos`, keyed by the remote's URL as its host and path: `git@github.com:codedogapp/baton.git`, `https://github.com/codedogapp/baton` and `ssh://git@github.com:22/codedogapp/baton.git` all give `github.com/codedogapp/baton`, so a repo's HTTPS and SSH clones share a section; a remote that is a local path keeps its path. baton refuses a key in another form, and says the form; a key keeps the URL's case. The repo's section lies over the top, and the top over baton's own settings, one setting at a time: a setting that a layer leaves out, or gives its zero value, keeps the one below, so a `roundCap` or a `maxAiCredits` of 0 keeps the layer's below, and the branch prefix can't be empty; the orchestrator's `extraTools`, a list, replaces the list below whole, and `[]` gives none. baton's own: Claude Opus 5.5 for every role (decision 7), copilot's own reasoning effort, no cap, nudge's two tools for the orchestrator (decision 45), three rounds (decision 13) and `feature/` (decision 41). baton reads and checks the whole file, the other repos' sections too, so that a mistake shows at once, with the file's path, and not in a `copilot` that it stops: it refuses a member that it doesn't know (names match case: `roundCap`, not `RoundCap`), a member given twice, a role that it doesn't know, a model or a tool that starts with a dash, which would read as one of copilot's flags, a reasoning effort that copilot doesn't take (CLI 1.0.105's, so a new level needs a new baton), a cap below copilot's least, 30 (fact 10), a negative round cap, and extra tools for any role but the orchestrator (decision 10). A missing file gives baton's own settings. The step comes in two: `config` reads the file, and `config-cli` gives its settings to `baton pane` and `baton feature start`, for the `origin` remote's URL; the other settings come with their steps. +49. **One config file, in layers** (lead's call, flagged; decision 16, fact 31). `config.json` in baton's folder under the user's config folder holds the settings for every repo at its top, and each repo's section in `repos`, keyed by the remote's URL as its host and path: `git@github.com:codedogapp/baton.git`, `https://github.com/codedogapp/baton` and `ssh://git@github.com:22/codedogapp/baton.git` all give `github.com/codedogapp/baton`, so a repo's HTTPS and SSH clones share a section; a remote that is a local path keeps its path. baton refuses a key in another form, and says the form; a key keeps the URL's case. The repo's section lies over the top, and the top over baton's own settings, one setting at a time: a setting that a layer leaves out, or gives its zero value, keeps the one below, so a `roundCap` or a `maxAiCredits` of 0 keeps the layer's below, and the branch prefix can't be empty; the orchestrator's `extraTools`, a list, replaces the list below whole, and `[]` gives none. baton's own: Claude Opus 5.5 for every role (decision 7), copilot's own reasoning effort, no cap, nudge's two tools for the orchestrator (decision 45), three rounds (decision 13) and `feature/` (decision 41). baton reads and checks the whole file, the other repos' sections too, so that a mistake shows at once, with the file's path, and not in a `copilot` that it stops: it refuses a member that it doesn't know (names match case: `roundCap`, not `RoundCap`), a member given twice, a role that it doesn't know, a model or a tool that starts with a dash, which would read as one of copilot's flags, a reasoning effort that copilot doesn't take (CLI 1.0.105's, so a new level needs a new baton), a cap below copilot's least, 30 (fact 10), a negative round cap, and extra tools for any role but the orchestrator (decision 10). A missing file gives baton's own settings. The step came in two: `config` reads the file, and `config-cli` gives its settings to `baton pane` and `baton feature start`. They find the repo's section by its `origin` remote's URL, as `git remote get-url origin` gives it, with the user's `insteadOf` rewrites (fact 32): a repo can have many remotes, and `origin` is the one that `git clone` makes. A repo without an `origin` gets the top's settings, even with other remotes, since baton can't tell which of them is the repo's own. `baton pane` reads the file after it writes the plugin and before it waits (decision 38), the orchestrator's pane too, so a mistake in the file stops a pane before its role picks up work; `baton feature start` reads it before it makes the feature; and `baton start` reads it before it makes the orchestrator's session, since a pane that fails takes its session, and its reason, with it, but not to show a session that runs, which has its settings already. A role's settings come after its plugin on copilot's command line: `--model`, then `--reasoning-effort` and `--max-ai-credits` only when the file sets them, the cap in plain digits, as `1000000.5`, never as `1e+06`. The other settings come with their steps. ## 5. Risks diff --git a/internal/cli/helpers_test.go b/internal/cli/helpers_test.go index 8bfe6f2..f267954 100644 --- a/internal/cli/helpers_test.go +++ b/internal/cli/helpers_test.go @@ -1,6 +1,7 @@ package cli_test import ( + "cmp" "context" "errors" "fmt" @@ -21,7 +22,11 @@ const slug = "demo-1" // TestMain runs git without the user's config, in the C locale, with a test author: baton's own // git too, which makes the features' branches and worktrees. It clears the variables that git sets -// for its hooks, such as GIT_DIR, which would point git at the repo that runs the hook. +// for its hooks, such as GIT_DIR, which would point git at the repo that runs the hook. It gives +// the tests an empty home folder, which holds the user's config and cache folders, since it +// clears the XDG ones, so that baton reads none of the user's files, such as its config file. The +// tests share that folder, so a test that writes there, as a config file, moves it into one of its +// own first: one that leaves anything there fails them all. func TestMain(m *testing.M) { var errs []error @@ -40,12 +45,25 @@ func TestMain(m *testing.M) { errs = append(errs, os.Setenv(name, value)) } + home, err := os.MkdirTemp("", "baton-test-home-") + errs = append(errs, err, os.Setenv("HOME", home), os.Unsetenv("XDG_CONFIG_HOME"), + os.Unsetenv("XDG_CACHE_HOME")) + if err := errors.Join(errs...); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - os.Exit(m.Run()) + code := m.Run() + + if err := os.Remove(home); err != nil { + code = cmp.Or(code, 1) + _ = os.RemoveAll(home) + + fmt.Fprintln(os.Stderr, "a test left files in the tests' home folder:", err) + } + + os.Exit(code) } // repos makes a git repo with a commit, and a linked worktree next to it, like a feature's. It diff --git a/internal/cli/locate.go b/internal/cli/locate.go index 9570770..f214715 100644 --- a/internal/cli/locate.go +++ b/internal/cli/locate.go @@ -3,8 +3,10 @@ package cli import ( "context" "fmt" + "os" "path/filepath" + "github.com/codedogapp/baton/internal/config" "github.com/codedogapp/baton/internal/flow" "github.com/codedogapp/baton/internal/repo" "github.com/codedogapp/baton/internal/state" @@ -51,3 +53,19 @@ func openStore(ctx context.Context, dir string) (state.Store, error) { return state.Store{Dir: filepath.Join(common, stateFolder)}, nil } + +// loadSettings returns baton's settings for the repo that holds the folder, from the config file +// in the user's config folder, by the URL of the repo's origin remote (decision 49). +func loadSettings(ctx context.Context, dir string) (config.Settings, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return config.Settings{}, fmt.Errorf("find the config folder: %w", err) + } + + remote, err := repo.Remote(ctx, dir) + if err != nil { + return config.Settings{}, err + } + + return config.Load(configDir, remote) +} diff --git a/internal/cli/open.go b/internal/cli/open.go index fc678e4..eb8cf17 100644 --- a/internal/cli/open.go +++ b/internal/cli/open.go @@ -51,13 +51,19 @@ func open(ctx context.Context, env Env, args []string) error { } // makeOrchestrator makes the orchestrator's session in the main worktree, with one pane, which runs -// this baton's pane command as the orchestrator (decisions 42 and 44). +// this baton's pane command as the orchestrator (decisions 42 and 44). It reads the config file +// first, as the pane does, since a pane that fails takes its session with it, and the reason too: +// a mistake in the file shows here instead (decision 49). func makeOrchestrator(ctx context.Context, env Env, name, mainDir string) error { self, err := env.Executable() if err != nil { return fmt.Errorf("find baton's own program: %w", err) } + if _, err := loadSettings(ctx, mainDir); err != nil { + return err + } + err = env.Tmux.NewSession(ctx, tmux.Session{Name: name, Dir: mainDir, Panes: []tmux.Pane{{ Title: string(flow.RoleOrchestrator), Env: []string{envRole + "=" + string(flow.RoleOrchestrator)}, diff --git a/internal/cli/open_test.go b/internal/cli/open_test.go index 8063685..f3f9d6b 100644 --- a/internal/cli/open_test.go +++ b/internal/cli/open_test.go @@ -33,7 +33,9 @@ type opening struct { // TestStartOpensTheOrchestrator opens the orchestrator from the repo's worktrees, outside tmux and // inside it, with the orchestrator's session running and without it. baton start makes the // session in the main worktree if it isn't running, then shows it: outside tmux, tmux takes -// baton's place and attaches the terminal to it, and inside, the user's client switches to it. +// baton's place and attaches the terminal to it, and inside, the user's client switches to it. It +// reads the config file only to make the session, so a mistake there keeps no one from a session +// that runs. func TestStartOpensTheOrchestrator(t *testing.T) { program := onPath(t, "tmux") mainDir, linked := repos(t) @@ -71,14 +73,24 @@ func TestStartOpensTheOrchestrator(t *testing.T) { name: "inside tmux, with its session running", dir: mainDir, vars: inTmux, server: running, shown: []string{"baton-main"}, }, + { + name: "with its session running, and a mistake in the config file", dir: mainDir, + server: running, line: attached, + setup: func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + writeConfig(t, `{"roundCap": -1}`) + }, + }, } { t.Run(test.name, func(t *testing.T) { wantOpening(t, test) }) } } // TestStartRefuses opens the orchestrator where baton can't, and checks that baton start stops at -// the first part that fails. +// the first part that fails. It has a home folder of its own, for the config file. func TestStartRefuses(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + program := onPath(t, "tmux") mainDir, linked := repos(t) @@ -120,6 +132,11 @@ func TestStartRefuses(t *testing.T) { name: "without its own program", dir: linked, self: errors.New("no such file"), want: refused("find baton's own program: no such file"), }, + { + name: "with a mistake in the config file", dir: linked, + setup: func(t *testing.T) { writeConfig(t, `{"roundCap": -1}`) }, + want: refused("config file " + configPath(t) + ": roundCap -1: want 1 or more"), + }, { name: "when tmux can't make the session", dir: linked, server: fakeTmux{making: func(context.Context) error { diff --git a/internal/cli/pane.go b/internal/cli/pane.go index 3fcb2eb..f7dfc14 100644 --- a/internal/cli/pane.go +++ b/internal/cli/pane.go @@ -9,8 +9,10 @@ import ( "os/exec" "path/filepath" "slices" + "strconv" "strings" + "github.com/codedogapp/baton/internal/config" "github.com/codedogapp/baton/internal/flow" "github.com/codedogapp/baton/internal/plugin" ) @@ -18,9 +20,6 @@ import ( // cacheFolder is baton's folder in the user's cache folder, which holds the plugin (decision 36). const cacheFolder = "baton" -// model is every role's model until the config file names one (decision 7). -const model = "claude-opus-5.5" - // allowAll is copilot's variable that allows every tool and, set to "true" exactly, also trusts // the folder that copilot starts in, the feature's worktree, for that session (decision 39). const allowAll = "COPILOT_ALLOW_ALL" @@ -29,11 +28,11 @@ const allowAll = "COPILOT_ALLOW_ALL" // plugin's agentStop hook, which runs baton hook stop (decision 47). const envBin = "BATON_BIN" -// pane becomes the pane's copilot. The orchestrator's copilot starts at once, since the user talks -// to it. A role's pane waits for the role's first work, then becomes the role's copilot, with that -// work as its first prompt (decision 38). It finds copilot and writes the plugin before it waits, -// so that a pane that can't start copilot never picks up work that nobody would then do. Its wait -// marks the role as waiting, as baton wait does, until copilot replaces it (decision 47). +// pane becomes the pane's copilot, with the role's settings from the config file (decision 49). +// The orchestrator's copilot starts at once, since the user talks to it. A role's pane waits for +// the role's first work, then becomes the role's copilot, with that work as its first prompt +// (decision 38). It finds copilot, writes the plugin and reads the settings before it waits, so +// that a pane that can't start copilot never picks up work that nobody would then do. func pane(ctx context.Context, env Env, args []string) error { if err := parse(newFlags(env, "pane"), args); err != nil { return err @@ -54,18 +53,37 @@ func pane(ctx context.Context, env Env, args []string) error { return err } - bin := envBin + "=" + self + settings, err := loadSettings(ctx, env.Dir) + if err != nil { + return err + } + + own, bin := settings.Roles[role], envBin+"="+self if role == flow.RoleOrchestrator { - return becomeCopilot(env, program, orchestrating(dir), environment(bin)) + return becomeCopilot(env, program, orchestrating(dir, own), environment(bin)) } - store, err := openStore(ctx, env.Dir) + work, err := waitForWork(ctx, env, role, slug) if err != nil { return err } + return becomeCopilot(env, program, command(role, dir, own, work), + environment(bin, allowAll+"=true")) +} + +// waitForWork says that the role waits, waits for the role's work in the feature, then picks it +// up and returns it, as the role's first prompt. While it waits, it marks the role as waiting, as +// baton wait does (decision 47), and drops what the user types in the pane; it stops waiting when +// the pane's terminal ends. +func waitForWork(ctx context.Context, env Env, role flow.Role, slug string) (string, error) { + store, err := openStore(ctx, env.Dir) + if err != nil { + return "", err + } + if _, err := fmt.Fprintf(env.Stdout, "%s in %s: waiting for work.\n", role, slug); err != nil { - return err + return "", err } waiting, stop := context.WithCancelCause(ctx) @@ -75,12 +93,10 @@ func pane(ctx context.Context, env Env, args []string) error { feature, err := pickUp(waiting, store, role, slug) if err != nil { - return err + return "", err } - work := pickupText(store, feature, role) - - return becomeCopilot(env, program, command(role, dir, work), environment(bin, allowAll+"=true")) + return pickupText(store, feature, role), nil } // becomeCopilot replaces baton with copilot, with the command line and the environment. @@ -147,23 +163,40 @@ var tools = []string{ "web_fetch", } -// command returns the role's copilot command line: the role's agent in the plugin, the model, -// every permission and no questions, the tools, and the role's work as the first prompt -// (decisions 9 to 11). -func command(role flow.Role, dir, work string) []string { - return slices.Concat([]string{ - "copilot", "--agent", plugin.Agent(role), "--plugin-dir", dir, "--model", model, - "--allow-all", "--no-ask-user", "--available-tools", - }, tools, []string{"-i", work}) +// command returns the role's copilot command line: the role's agent in the plugin, the role's +// settings, every permission and no questions, the tools, and the role's work as the first prompt +// (decisions 9 to 11 and 49). +func command(role flow.Role, dir string, own config.Role, work string) []string { + return slices.Concat([]string{"copilot", "--agent", plugin.Agent(role), "--plugin-dir", dir}, + settingFlags(own), []string{"--allow-all", "--no-ask-user", "--available-tools"}, tools, + []string{"-i", work}) } -// orchestrating returns the orchestrator's copilot command line: its agent in the plugin, the -// model, and the tools, with ask_user and the user's nudge tools, since the user talks to it. It -// keeps copilot's own prompts for the tools, and has no first prompt, since the user speaks first -// (decision 45). -func orchestrating(dir string) []string { +// orchestrating returns the orchestrator's copilot command line: its agent in the plugin, its +// settings, and the tools, with ask_user and its extra tools, the user's own, such as nudge's, +// since the user talks to it. It keeps copilot's own prompts for the tools, and has no first +// prompt, since the user speaks first (decisions 45 and 49). +func orchestrating(dir string, own config.Role) []string { return slices.Concat([]string{ "copilot", "--agent", plugin.Agent(flow.RoleOrchestrator), "--plugin-dir", dir, - "--model", model, "--available-tools", - }, tools, []string{"ask_user", "nudge-ask", "nudge-report"}) + }, settingFlags(own), []string{"--available-tools"}, tools, []string{"ask_user"}, + own.ExtraTools) +} + +// settingFlags returns copilot's flags for the role's settings: its model, and its reasoning +// effort and its cap on AI credits, unless it leaves them to copilot (fact 31). The cap is in +// digits, never in an exponent's form. +func settingFlags(own config.Role) []string { + flags := []string{"--model", own.Model} + + if own.ReasoningEffort != "" { + flags = append(flags, "--reasoning-effort", own.ReasoningEffort) + } + + if own.MaxAICredits > 0 { + flags = append(flags, "--max-ai-credits", + strconv.FormatFloat(own.MaxAICredits, 'f', -1, 64)) + } + + return flags } diff --git a/internal/cli/pane_refusals_test.go b/internal/cli/pane_refusals_test.go index 271ddf4..9a03430 100644 --- a/internal/cli/pane_refusals_test.go +++ b/internal/cli/pane_refusals_test.go @@ -13,8 +13,8 @@ import ( "github.com/codedogapp/baton/internal/state" ) -// TestPaneRefusesAndWritesNothing refuses panes that could never start their copilot, or that -// have no feature to wait on, or no work to wait for. +// TestPaneRefusesAndWritesNothing refuses panes that could never start their copilot, or not +// with the user's settings, or that have no feature to wait on, or no work to wait for. func TestPaneRefusesAndWritesNothing(t *testing.T) { _, cache := standIn(t) mainDir, linked := repos(t) @@ -89,7 +89,22 @@ func TestPaneRefusesAndWritesNothing(t *testing.T) { }, { name: "outside a git repo", dir: outside, env: paneOf(flow.RoleLead), - want: "find the git repo: fatal: not a git repository", + want: "find the origin remote: fatal: not a git repository", + }, + { + name: "the orchestrator's, outside a git repo", dir: outside, env: orchestratorPane(), + want: "find the origin remote: fatal: not a git repository", + }, + { + name: "with a mistake in the config file", dir: linked, env: paneOf(flow.RoleLead), + setup: func(t *testing.T) { writeConfig(t, `{"roundCap": -1}`) }, + want: "config file " + configPath(t) + ": roundCap -1: want 1 or more\n", + }, + { + name: "the orchestrator's, with a mistake in the config file", dir: mainDir, + env: orchestratorPane(), + setup: func(t *testing.T) { writeConfig(t, `{"roundCap": -1}`) }, + want: "config file " + configPath(t) + ": roundCap -1: want 1 or more\n", }, { name: "of another feature", dir: linked, diff --git a/internal/cli/settings_test.go b/internal/cli/settings_test.go new file mode 100644 index 0000000..c9f0752 --- /dev/null +++ b/internal/cli/settings_test.go @@ -0,0 +1,148 @@ +package cli_test + +import ( + "os" + "os/exec" + "path/filepath" + "reflect" + "testing" + "testing/synctest" + + "github.com/codedogapp/baton/internal/flow" + "github.com/codedogapp/baton/internal/state" +) + +// The settings tests write baton's config file in a home folder of the test's own, and give the +// repo an origin remote, whose URL picks the repo's section in the file (decision 49). + +// TestPaneGivesTheRoleItsSettings has the lead's pane become the lead's copilot with the lead's +// settings: those for every repo, under those of the repo's section, but not another repo's. +func TestPaneGivesTheRoleItsSettings(t *testing.T) { + copilot, cache := standIn(t) + writeConfig(t, `{ + "roles": {"lead": {"model": "gpt-5.6-sol", "reasoningEffort": "high", "maxAiCredits": 40}}, + "repos": { + "github.com/codedogapp/demo": {"roles": {"lead": {"maxAiCredits": 1000000.5}}}, + "github.com/codedogapp/other": {"roles": {"lead": {"model": "claude-haiku-5.5"}}} + } + }`) + + synctest.Test(t, func(t *testing.T) { + mainDir, linked := repos(t) + gitIn(t, mainDir, exec.CommandContext(t.Context(), "git", "remote", "add", "origin", + "https://github.com/codedogapp/demo.git")) + store := started(t, mainDir, fresh()) + + env, _ := inPane(t, linked, paneOf(flow.RoleLead)) + + wantCopilot(t, paneFor(t, env), flow.RoleLead, execed{ + path: copilot, + args: []string{ + "copilot", "--agent", "baton:lead", "--plugin-dir", pluginIn(t, cache), + "--model", "gpt-5.6-sol", "--reasoning-effort", "high", + "--max-ai-credits", "1000000.5", "--allow-all", "--no-ask-user", + "--available-tools", "bash", "read_bash", "stop_bash", "list_bash", "view", + "create", "edit", "grep", "glob", "web_fetch", + "-i", "Picked up demo-1 as lead: round 0 of 3, from orchestrator.\nNote: " + + notePath(store, "01-orchestrator-to-lead.md") + "\n", + }, + env: copilotEnv(), + }) + }) +} + +// TestPaneGivesTheOrchestratorItsSettings has the orchestrator's pane, in a repo without an origin +// remote, become the orchestrator's copilot with the settings for every repo: here no extra tools, +// so not nudge's, and copilot's own reasoning effort. +func TestPaneGivesTheOrchestratorItsSettings(t *testing.T) { + copilot, cache := standIn(t) + writeConfig(t, `{ + "roles": {"orchestrator": {"model": "claude-sonnet-5.5", "maxAiCredits": 30, + "extraTools": []}}, + "repos": {"github.com/codedogapp/demo": {"roles": {"orchestrator": {"model": "auto"}}}} + }`) + + mainDir, _ := repos(t) + env, _ := inPane(t, mainDir, orchestratorPane()) + + got := runExec(t.Context(), env, nil) + if got.result != (result{}) { + t.Errorf("got %+v; want exit code 0 and no output", got.result) + } + + wantExec(t, got.execs, execed{ + path: copilot, + args: []string{ + "copilot", "--agent", "baton:orchestrator", "--plugin-dir", pluginIn(t, cache), + "--model", "claude-sonnet-5.5", "--max-ai-credits", "30", "--available-tools", + "bash", "read_bash", "stop_bash", "list_bash", "view", "create", "edit", "grep", + "glob", "web_fetch", "ask_user", + }, + env: append(withoutVars("BATON_BIN"), "BATON_BIN="+self), + }) +} + +// TestStartFeatureTakesItsSettings starts a feature with the round cap for every repo, and the +// branch prefix of the repo's section. +func TestStartFeatureTakesItsSettings(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + writeConfig(t, `{ + "roundCap": 5, + "repos": {"github.com/codedogapp/demo": {"branchPrefix": "team/"}} + }`) + + mainDir := repoNamed(t, "main") + gitIn(t, mainDir, exec.CommandContext(t.Context(), "git", "remote", "add", "origin", + "git@github.com:codedogapp/demo.git")) + brief := write(t, t.TempDir(), "brief.md", "Build the demo.\n") + + worktree := mainDir + "-demo-1" + want := result{stdout: "Started demo-1: branch team/demo-1, worktree " + worktree + + ", tmux session baton-main-demo-1. The lead has the brief.\n"} + + got := runEnv(t.Context(), startIn(mainDir, &fakeTmux{}, slug, "--brief", brief)) + if got != want { + t.Fatalf("baton feature start = %+v, want %+v", got, want) + } + + feature, err := state.Store{Dir: filepath.Join(mainDir, ".git", "baton")}.Read(slug) + if err != nil { + t.Fatal(err) + } + + now := flow.State{Holder: flow.RoleLead, Status: flow.StatusRunning, Cap: 5} + if feature.Branch != "team/demo-1" || !reflect.DeepEqual(feature.Flow, now) { + t.Errorf("feature = %+v; want branch team/demo-1 and flow %+v", feature, now) + } + + if got := gitIn(t, worktree, exec.CommandContext(t.Context(), "git", "branch", + "--show-current")); got != "team/demo-1\n" { + t.Errorf("the worktree has branch %q checked out; want team/demo-1", got) + } +} + +// configPath returns the path of baton's config file in the user's config folder. +func configPath(t *testing.T) string { + t.Helper() + + dir, err := os.UserConfigDir() + if err != nil { + t.Fatal(err) + } + + return filepath.Join(dir, "baton", "config.json") +} + +// writeConfig writes baton's config file, with the text, until the test ends, in the home folder, +// which the test has moved into one of its own (TestMain). +func writeConfig(t *testing.T, text string) { + t.Helper() + + path := configPath(t) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + + write(t, filepath.Dir(path), filepath.Base(path), text) + t.Cleanup(func() { _ = os.Remove(path) }) +} diff --git a/internal/cli/start.go b/internal/cli/start.go index 24b1602..86a7606 100644 --- a/internal/cli/start.go +++ b/internal/cli/start.go @@ -6,18 +6,13 @@ import ( "fmt" "path/filepath" + "github.com/codedogapp/baton/internal/config" "github.com/codedogapp/baton/internal/flow" "github.com/codedogapp/baton/internal/repo" "github.com/codedogapp/baton/internal/state" "github.com/codedogapp/baton/internal/tmux" ) -// Every feature gets these until the config file sets them (decisions 13, 16 and 41). -const ( - roundCap = 3 // The engineer's passes that a brief gets. - branchPrefix = "feature/" // The feature's branch is its slug after this. -) - // team is the roles that work on a feature, in the order of their panes. var team = []flow.Role{flow.RoleLead, flow.RoleEngineer, flow.RoleQA, flow.RoleReviewer} @@ -59,14 +54,15 @@ func startFeature(ctx context.Context, env Env, args []string) error { // newLaunch returns where the feature's parts go: its state in the repo's baton folder, and its // worktree next to the repo's main worktree, with names after the main worktree's folder -// (decision 41). +// (decision 41). The config file gives its round cap and its branch's prefix (decision 49). func newLaunch(ctx context.Context, env Env, slug string) (launch, error) { self, err := env.Executable() if err != nil { return launch{}, fmt.Errorf("find baton's own program: %w", err) } - // Both look for the repo from the working folder, so they fail together, as outside a repo. + // The first two look for the repo from the working folder, so they fail together, as outside + // a repo. store, err := openStore(ctx, env.Dir) var mainDir string @@ -74,6 +70,11 @@ func newLaunch(ctx context.Context, env Env, slug string) (launch, error) { mainDir, err = repo.MainWorktree(ctx, env.Dir) } + var settings config.Settings + if err == nil { + settings, err = loadSettings(ctx, mainDir) + } + if err != nil { return launch{}, err } @@ -81,12 +82,12 @@ func newLaunch(ctx context.Context, env Env, slug string) (launch, error) { name := filepath.Base(mainDir) + "-" + slug worktree := repo.Worktree{ Path: filepath.Join(filepath.Dir(mainDir), name), - Branch: branchPrefix + slug, + Branch: settings.BranchPrefix + slug, Base: "HEAD", } return launch{ - store: store, slug: slug, mainDir: mainDir, worktree: worktree, + store: store, slug: slug, rounds: settings.RoundCap, mainDir: mainDir, worktree: worktree, session: tmux.Session{ Name: tmux.Name("baton-" + name), Dir: worktree.Path, @@ -119,6 +120,7 @@ type launch struct { store state.Store slug string brief []byte + rounds int // The round cap: the engineer's passes that the brief gets (decision 13). mainDir string worktree repo.Worktree session tmux.Session @@ -128,7 +130,7 @@ type launch struct { // branch and the worktree, then the tmux session. If a part fails, it undoes the parts it made, // even after Ctrl-C (decision 40). func (start launch) run(ctx context.Context, sessions Server) error { - now, err := flow.Start(roundCap) + now, err := flow.Start(start.rounds) if err == nil { err = start.store.Create(state.Feature{ Slug: start.slug, diff --git a/internal/cli/start_refusals_test.go b/internal/cli/start_refusals_test.go index eac7a02..b038347 100644 --- a/internal/cli/start_refusals_test.go +++ b/internal/cli/start_refusals_test.go @@ -10,8 +10,11 @@ import ( ) // TestStartFeatureRefuses tries to start feature demo-2 where demo-1 has started, and checks that -// a refusal changes nothing: baton's folder, the repo's worktrees and branches, and tmux. +// a refusal changes nothing: baton's folder, the repo's worktrees and branches, and tmux. It has a +// home folder of its own, for the config file. func TestStartFeatureRefuses(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + mainDir, linked := repos(t) store := started(t, mainDir, fresh()) brief := write(t, linked, "brief.md", "Build the demo.\n") @@ -22,12 +25,13 @@ func TestStartFeatureRefuses(t *testing.T) { t.Setenv("GIT_CEILING_DIRECTORIES", filepath.Dir(outside)) cases := []struct { - name string - dir string // The working folder, if not the linked worktree. - args []string // After baton feature start. - role string // BATON_ROLE, if any. - self error // Why baton can't find its own program, if it can't. - want string // The start of the reason, after "baton feature start: ". + name string + dir string // The working folder, if not the linked worktree. + args []string // After baton feature start. + role string // BATON_ROLE, if any. + setup func(t *testing.T) // What the test changes first, if anything. + self error // Why baton can't find its own program, if it can't. + want string // The start of the reason, after "baton feature start: ". }{ { name: "without a slug", args: []string{"--brief", "brief.md"}, @@ -68,6 +72,17 @@ func TestStartFeatureRefuses(t *testing.T) { name: "outside a git repo", dir: outside, args: []string{"demo-2", "--brief", brief}, want: "find the git repo: fatal: not a git repository", }, + { + name: "without a config folder", args: []string{"demo-2", "--brief", "brief.md"}, + setup: func(t *testing.T) { t.Setenv("HOME", "") }, + want: "find the config folder: ", + }, + { + name: "with a mistake in the config file", + args: []string{"demo-2", "--brief", "brief.md"}, + setup: func(t *testing.T) { writeConfig(t, `{"roundCap": -1}`) }, + want: "config file " + configPath(t) + ": roundCap -1: want 1 or more\n", + }, { name: "with a bad slug", args: []string{"Demo-2", "--brief", "brief.md"}, want: "slug \"Demo-2\": want words of lowercase letters and digits joined by " + @@ -83,16 +98,17 @@ func TestStartFeatureRefuses(t *testing.T) { for _, test := range cases { t.Run(test.name, func(t *testing.T) { + if test.setup != nil { + test.setup(t) + } + fake := &fakeTmux{} env := startIn(cmp.Or(test.dir, linked), fake, test.args...) + env.Executable = func() (string, error) { return self, test.self } env.Getenv = func(name string) string { return map[string]string{"BATON_ROLE": test.role}[name] } - if test.self != nil { - env.Executable = func() (string, error) { return "", test.self } - } - got := runEnv(t.Context(), env) want := "baton feature start: " + test.want diff --git a/internal/repo/remote_test.go b/internal/repo/remote_test.go new file mode 100644 index 0000000..89bba60 --- /dev/null +++ b/internal/repo/remote_test.go @@ -0,0 +1,77 @@ +package repo_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/codedogapp/baton/internal/repo" +) + +// TestRemoteIsOriginsURL finds the origin remote's URL from every worktree of a repo with two +// remotes, as git gives it: with the repo's insteadOf rules applied. +func TestRemoteIsOriginsURL(t *testing.T) { + cases := []struct { + name string + config []string // git config's arguments, if any, in the main worktree. + url string // origin's URL in the repo's config. + want string + }{ + { + name: "as it is", + url: "git@github.com:codedogapp/baton.git", + want: "git@github.com:codedogapp/baton.git", + }, + { + name: "rewritten", + config: []string{"url.https://github.com/.insteadOf", "gh:"}, + url: "gh:codedogapp/baton", + want: "https://github.com/codedogapp/baton", + }, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + mainDir, linked := repos(t) + run(t, mainDir, "remote", "add", "upstream", "https://example.com/upstream.git") + run(t, mainDir, "remote", "add", "origin", test.url) + + if test.config != nil { + run(t, mainDir, append([]string{"config"}, test.config...)...) + } + + sub := filepath.Join(linked, "docs") + if err := os.Mkdir(sub, 0o700); err != nil { + t.Fatal(err) + } + + for _, dir := range []string{mainDir, linked, sub} { + got, err := repo.Remote(t.Context(), dir) + if err != nil || got != test.want { + t.Errorf("Remote(%s) = %q, %v; want %q", dir, got, err, test.want) + } + } + }) + } +} + +func TestRemoteIsEmptyWithoutOrigin(t *testing.T) { + mainDir, _ := repos(t) + run(t, mainDir, "remote", "add", "upstream", "https://example.com/upstream.git") + + if got, err := repo.Remote(t.Context(), mainDir); got != "" || err != nil { + t.Errorf("Remote = %q, %v; want no URL and no error", got, err) + } +} + +func TestRemoteRefusesAFolderOutsideARepo(t *testing.T) { + dir := t.TempDir() + t.Setenv("GIT_CEILING_DIRECTORIES", filepath.Dir(dir)) + + got, err := repo.Remote(t.Context(), dir) + if want := "find the origin remote: fatal: not a git repository"; got != "" || err == nil || + !strings.HasPrefix(err.Error(), want) { + t.Errorf("Remote = %q, %v; want %q, then the rest of git's reason", got, err, want) + } +} diff --git a/internal/repo/repo.go b/internal/repo/repo.go index b250557..b28ac2e 100644 --- a/internal/repo/repo.go +++ b/internal/repo/repo.go @@ -1,5 +1,5 @@ -// Package repo runs git for baton: it finds the repo that baton works in, and makes a feature's -// branch and worktree. +// Package repo runs git for baton: it finds the repo that baton works in, and the repo's origin +// remote, and makes a feature's branch and worktree. package repo import ( @@ -42,6 +42,24 @@ func MainWorktree(ctx context.Context, dir string) (string, error) { return path, nil } +// noRemote is git remote's exit status for a remote that isn't there (fact 32). +const noRemote = 2 + +// Remote returns the URL of the origin remote of the repo that holds the folder, as git gives it, +// with the user's insteadOf rules applied, or "" if the repo has no origin remote (decision 49). +func Remote(ctx context.Context, dir string) (string, error) { + out, err := git(ctx, dir, "remote", "get-url", "origin") + if exit, ok := errors.AsType[*exec.ExitError](err); ok && exit.ExitCode() == noRemote { + return "", nil + } + + if err != nil { + return "", fmt.Errorf("find the origin remote: %w", err) + } + + return strings.TrimSuffix(out, "\n"), nil +} + // git runs git in the folder and returns what it printed. func git(ctx context.Context, dir string, args ...string) (string, error) { // Sonar's go:S4036 wants git at a fixed path, but baton runs the user's own git, found on the @@ -63,15 +81,25 @@ func git(ctx context.Context, dir string, args ...string) (string, error) { return string(out), nil } -// failure says why running git failed. +// failure says why running git failed: with git's reason, if git gave one, which keeps git's exit +// status. func failure(ctx context.Context, err error) error { if cause := context.Cause(ctx); cause != nil { return cause } if exit, ok := errors.AsType[*exec.ExitError](err); ok && len(exit.Stderr) > 0 { - return errors.New(string(bytes.TrimSpace(exit.Stderr))) + return reason{exit} } return err } + +// reason is a git that failed, and said why. +type reason struct{ exit *exec.ExitError } + +// Error returns what git printed on stderr, trimmed. +func (r reason) Error() string { return string(bytes.TrimSpace(r.exit.Stderr)) } + +// Unwrap returns git's exit status. +func (r reason) Unwrap() error { return r.exit }