diff --git a/README.md b/README.md index 60509ce..4323f80 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Hand a ticket to a team of Copilot CLI agents in tmux: lead, engineer, QA and reviewer pass the baton, and you get the final handover. -You talk to one agent, the orchestrator. It reads the ticket, plans the work as features and, once you approve the plan, starts each feature in its own git worktree and its own tmux session, with one visible, interactive Copilot CLI pane per role. The `baton` binary enforces the flow between the roles: who works next, the rounds of QA and review, and when to stop and ask you. Nothing leaves your Mac until you say so: the last handover is yours, and only your word pushes the branch and opens the pull request. +You talk to one agent, the orchestrator. It reads the ticket, plans the work as features and, once you approve the plan, starts each feature in its own git worktree and its own tmux session, with one visible, interactive Copilot CLI pane per role. The `baton` binary enforces the flow between the roles: who works next, that your repo's checks pass, the rounds of QA and review, and when to stop and ask you. Nothing leaves your Mac until you say so: the last handover is yours, and only your word pushes the branch and opens the pull request. **Status**: in development; nothing to install yet. The design and the steps are in [docs/plan.md](docs/plan.md), and the rules for working on baton in [AGENTS.md](AGENTS.md). @@ -14,17 +14,19 @@ You talk to one agent, the orchestrator. It reads the ticket, plans the work as ## 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. +baton needs a config file, `config.json` in `~/Library/Application Support/baton/`, for one setting: the gate that checks each repo's code. 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/`). - `planApproval`: whether baton asks you before each feature starts, so that you can check the orchestrator's plan first (by default `true`). +- `gate`: the command that checks the repo's code, such as `mise run check`, with no default: baton starts neither the orchestrator nor a feature in a repo without one. The engineer's handover, QA's approval and the lead's final report run it with `/bin/sh -c` in the feature's worktree, and wait until it passes. `"true"` checks nothing. -For example, QA on another model with a cap, for every repo, and two rounds with another branch prefix for one repo: +For example, one gate and QA on another model with a cap, for every repo, and two rounds with another branch prefix for one repo: ```json { + "gate": "mise run check", "roles": {"qa": {"model": "claude-sonnet-5.5", "maxAiCredits": 60}}, "repos": {"github.com/codedogapp/baton": {"roundCap": 2, "branchPrefix": "feat/"}} } diff --git a/docs/plan.md b/docs/plan.md index d75b7c2..6c1c7a1 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -57,8 +57,8 @@ The first milestone is a walking skeleton (`skeleton`): one feature on a throwaw | `guard-hook` | `baton hook guard` with its `preToolUse` hook in the plugin: in a role's pane, it denies, with a reason for the role, a bash call that the guard can't read or that runs a command that the guard watches, but baton's commands for a role (`baton wait`, `handover`, `ask`, `status` and help), and a `create` or `edit` of a file in baton's folder, however its path names it; it says nothing in the orchestrator's pane yet (decisions 9, 17, 48 and 52; fact 36, risk 11) | Done (#34) | | `plugin-check` | `baton pane` checks the files of the plugin folder that it finds in its cache before each `copilot` starts, a role's after its wait too, and writes the plugin under the next name beside a changed folder; `baton hook guard` denies a role's `create` and `edit` in baton's cache, so that a role can't turn the guard off for the panes that start after it (decisions 36, 52 and 53, risk 12) | Done (#35) | | `guard-orchestrator` | In the orchestrator's pane, `baton hook guard` asks the user before each outward action, and before each `baton feature start` while `planApproval`, a new setting, is on; it lets baton's commands for the orchestrator run, denies the other commands that it watches, and denies `edit` and `create` outside the session's folder; the orchestrator runs with `--allow-all` (decisions 8, 19, 45 and 54; fact 37) | Done (#36) | -| `belt` | Risk 2's second belt, in a role's pane: settings in the environment of the role's `copilot`, which each command and script there inherits, so that git's pushes out of the machine fail, sent by `pushInsteadOf` rules to a remote helper that doesn't exist, with a rule for each remote's URL and the remotes' push URLs cleared, and `gh` fails, its config folder under `/dev/null`; the pane goes without the GitHub tokens of the user's environment, and `copilot` gets `GH_TOKEN`'s or `GITHUB_TOKEN`'s as `COPILOT_GITHUB_TOKEN`, for its own login (decision 55, fact 38, risk 2) | In review (#37) | -| `gates` | The engineer's handover runs the repo's gate command and refuses a red one | Todo | +| `belt` | Risk 2's second belt, in a role's pane: settings in the environment of the role's `copilot`, which each command and script there inherits, so that git's pushes out of the machine fail, sent by `pushInsteadOf` rules to a remote helper that doesn't exist, with a rule for each remote's URL and the remotes' push URLs cleared, and `gh` fails, its config folder under `/dev/null`; the pane goes without the GitHub tokens of the user's environment, and `copilot` gets `GH_TOKEN`'s or `GITHUB_TOKEN`'s as `COPILOT_GITHUB_TOKEN`, for its own login (decision 55, fact 38, risk 2) | Done (#37) | +| `gates` | The repo's gate, a new setting: the user's command that checks the repo's code, which the engineer's handover, QA's approval and the lead's final report run in the feature's worktree, and which refuses the handover while it fails; `baton start` and `baton feature start` refuse a repo without one, and the guard denies a role's `create` and `edit` in baton's config folder (decision 56, risk 13) | In review (#38) | | `jira` | `baton jira `: the ticket as text, from Jira's REST API with a read-only token in the Keychain (decision 6) | Todo | | `deliver-github` | `baton deliver` for GitHub: push the branch and open the pull request with `gh`, the lead's final report as its body | Todo | | `close` | `baton feature close `: ends the feature's session; keeps its branch and, unless asked, its worktree | Todo | @@ -187,26 +187,27 @@ copilot --agent baton:orchestrator --plugin-dir /plugin- --model [--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). +**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 engineer's handover, QA's approval and the lead's final report run the repo's gate first, the user's command that checks the code, in the feature's worktree, and refuse while it fails (decision 56). 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). **Questions** (decisions 18 and 46). A role never asks the user. The holder starts `baton ask ""` in a background shell and ends its turn, as with `baton wait`: baton stores the question in the feature's file and logs it, then reads the file every second until the answer comes, prints it and exits, and its end wakes the role (fact 5). The orchestrator's `baton wait` picks up the question, as it picks up its work, and prints it with the command that answers it. The orchestrator answers from the plan with `baton answer ""`, or asks the user first, in its pane or through nudge. A role asks one question at a time, and can't hand over while it waits for the answer. Asking the same question again doesn't ask it twice: it waits for the answer, or prints it if it came, so a role whose `baton ask` ended early still gets its answer. The role's handover clears its question, which the journal keeps. -**Safety** (decisions 9, 17, 19, 39, 54 and 55). The roles run with every permission (`--allow-all`), in a worktree that their `copilot` trusts, and no `ask_user`, so nothing waits on a prompt in a pane nobody watches, and with the tool allow-list, so no hidden agent and none of the user's skills or MCP tools. The guard hook (`preToolUse` → `baton hook guard`) denies outward actions in a role's pane: `git push`, pull request and merge commands, `gh api` writes, HTTP writes with `curl` and `wget`, Jira writes and `ssh`; and `tmux`, `osascript`, another agent's CLI, baton's commands but a role's own (`baton wait`, `handover`, `ask` and `status`), a program that only the run decides, and `edit` and `create` in baton's folder and in its cache, which holds the plugin (decisions 51 to 53). Behind the guard, the belt makes git's pushes out of the machine and every `gh` command fail in a role's pane, however they run, with settings in the environment of the role's `copilot` (decision 55). Both stop a role's mistakes, not a role that sets out to get past them (risks 2, 11 and 12). In the orchestrator's pane, which the user watches, it answers `ask` for outward actions, so the user approves each one, and for `baton feature start` while plan approval is on (decision 8); it denies `tmux`, `osascript`, another agent's CLI and baton's commands that aren't the orchestrator's; and it denies `edit` and `create` outside its session's folder, where it writes its briefs and notes (decision 48), so the orchestrator plans and dispatches but doesn't write the code (decision 54). The guard reads the commands of a bash call with a bash parser (decision 50), not its text, and quickly, because a hook that times out allows (fact 4). The orchestrator, too, runs with `--allow-all`, so the user answers only the guard's questions, but it keeps `ask_user` and copilot's folder-trust question (decision 45). +**Safety** (decisions 9, 17, 19, 39, 54 and 55). The roles run with every permission (`--allow-all`), in a worktree that their `copilot` trusts, and no `ask_user`, so nothing waits on a prompt in a pane nobody watches, and with the tool allow-list, so no hidden agent and none of the user's skills or MCP tools. The guard hook (`preToolUse` → `baton hook guard`) denies outward actions in a role's pane: `git push`, pull request and merge commands, `gh api` writes, HTTP writes with `curl` and `wget`, Jira writes and `ssh`; and `tmux`, `osascript`, another agent's CLI, baton's commands but a role's own (`baton wait`, `handover`, `ask` and `status`), a program that only the run decides, and `edit` and `create` in baton's folder, in its cache, which holds the plugin, and in its config folder, whose file holds the gate (decisions 51 to 53 and 56). Behind the guard, the belt makes git's pushes out of the machine and every `gh` command fail in a role's pane, however they run, with settings in the environment of the role's `copilot` (decision 55). Both stop a role's mistakes, not a role that sets out to get past them (risks 2, 11 and 12). In the orchestrator's pane, which the user watches, it answers `ask` for outward actions, so the user approves each one, and for `baton feature start` while plan approval is on (decision 8); it denies `tmux`, `osascript`, another agent's CLI and baton's commands that aren't the orchestrator's; and it denies `edit` and `create` outside its session's folder, where it writes its briefs and notes (decision 48), so the orchestrator plans and dispatches but doesn't write the code (decision 54). The guard reads the commands of a bash call with a bash parser (decision 50), not its text, and quickly, because a hook that times out allows (fact 4). The orchestrator, too, runs with `--allow-all`, so the user answers only the guard's questions, but it keeps `ask_user` and copilot's folder-trust question (decision 45). **State** (decision 15). Everything lives in `$(git rev-parse --git-common-dir)/baton/`, which every worktree of the repo shares and git never commits: - `features//feature.json`: its format (decision 24), the slug (decision 25), the branch and worktree, the holder, the round and its cap, the status (`running`, `escalated`, `ready`, `delivered`, `closed`), each role's session ID, the holder's work: its note, the note's writer and their verdict, and whether the holder picked it up (decision 31), and the holder's last question, until its handover: the role, the text, whether the orchestrator picked it up, and the answer once it comes (decision 46); -- `features//events.ndjson`: one JSON line per event (start, handover, pickup, question, answer, escalation, push and pull request), with its time, the role that acted, its verdict, its note, the question's or the answer's text, and where the feature stands after it; +- `features//events.ndjson`: one JSON line per event (start, handover, pickup, question, answer, escalation, push and pull request), with its time, the role that acted, its verdict, its note, the gate that it ran, the question's or the answer's text, and where the feature stands after it; - `features//notes/`: the briefs, the handover notes and the final report, each named `NN--to-.md`, from the role that acted to the holder after it; - `features//lock`: the feature's lock, a file that baton never replaces; - `features//.wait`, and `orchestrator.wait` in baton's folder: the roles' wait marks, files that baton never removes, which each `baton wait` or `baton ask` of the role holds a shared `flock` on while it waits (decision 47). 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 `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/`); plan approval (on). `baton pane`, `baton feature start` and `baton start` read the file and check all of it, before they wait or make anything, and so does `baton hook guard` in the orchestrator's pane, for a script that starts a feature (decision 54). The later steps add theirs: the gate command (`gates`), 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, 49 and 56). `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/`); plan approval (on); the gate, the command that checks the repo's code, which has no default: `baton start` and `baton feature start` refuse a repo without one, and `"true"` checks nothing (decision 56). `baton pane`, `baton feature start` and `baton start` read the file and check all of it, before they wait or make anything, and so does `baton hook guard` in the orchestrator's pane, for a script that starts a feature (decision 54), and `baton handover`, for a handover that runs the gate. The later steps add theirs: 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, one gate and QA on another model for every repo, and two rounds with another branch prefix for one repo: ```json { + "gate": "mise run check", "roles": {"qa": {"model": "claude-sonnet-5.5", "maxAiCredits": 60}}, "repos": {"github.com/codedogapp/baton": {"roundCap": 2, "branchPrefix": "feat/"}} } @@ -255,7 +256,7 @@ Decisions marked **lead's call** are the lead's, for the user's end review. The 31. **The feature's file names the holder's work, and `baton wait` takes it under the feature's lock** (lead's call). A note goes from the role that acted to the holder after the change, so it becomes the holder's `work`: the note, its writer, their verdict, and whether the holder picked it up. The store refuses a new feature, or a move to another holder, without a note, so the holder always has work. `baton wait` tries every second to mark the work picked up, under the lock, and logs the pickup before it prints the work, so of two waits of a role only one gets it. It refuses a role that holds the feature and has picked up its work, and a role that baton doesn't know, since nothing could end their wait. `work` sits at the top of the file, so it keeps the format (decision 24). 32. **`internal/tmux` makes a session whole or not at all, and takes the caller's text as it is** (lead's call; fact 22). It refuses a session without panes; a name other than letters, digits, hyphens and underscores, which tmux keeps as it is and baton finds exactly (`=name`); and a folder that isn't an absolute path to a folder, where tmux would quietly start the panes in the home folder. It escapes each `#` and a final `;`, which tmux would expand or take for the end of a command. A pane's environment stays in that pane: the first pane's variables come back out of the session's environment. The options go on the session's window, never the server's (decision 14): the titles in the top borders, and `allow-set-title off`, so a role's program can't retitle its pane; that option needs tmux 3.5. Once tmux has printed the new session's IDs, baton ends the session by its exact name if anything fails after, a hook of the user's included, or if Ctrl-C stops it; Ctrl-C during `new-session` itself may leave the session. A name that tmux has already fails before anything changes. baton reads only the first line that a command prints, since the user's hooks may print after it. 33. **tmux from mise on Linux only** (lead's call): CI's runner has no tmux (fact 11), so mise installs tmux-builds there. On a Mac, mise leaves `tmux` alone, so that in the repo it stays the user's own, the one that runs the user's server (decision 27). -34. **The one place that runs each tool carries `//nolint:gosec` too, with its reason** (the user's calls, 2026-10-31), as does each tool's test helper, the tests' own place that runs it: tmux's and git's now, copilot's and gh's later. gosec's G204 ("subprocess launched with variable") wants constant arguments, but each tool needs the feature's own: a checked name, IDs and text that reach the tool as arguments, which no shell reads (fact 22). A test helper runs the tests' own arguments. +34. **The one place that runs each tool carries `//nolint:gosec` too, with its reason** (the user's calls, 2026-10-31), as does each tool's test helper, the tests' own place that runs it: tmux's and git's now, copilot's and gh's later. gosec's G204 ("subprocess launched with variable") wants constant arguments, but each tool needs the feature's own: a checked name, IDs and text that reach the tool as arguments, which no shell reads (fact 22). A test helper runs the tests' own arguments. The gate's one place to run carries one too, for another reason, the user's call too (decision 56). 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, if it holds the same files (decision 53). 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. The cache is the user's, and baton makes it readable by the user alone; a role runs as the user, though, and can change a folder there (risk 12), so baton checks a folder's files before it uses it (decision 53). 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-hook`), 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. @@ -273,10 +274,11 @@ Decisions marked **lead's call** are the lead's, for the user's end review. The 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; `planApproval`, `true` or `false`, keeps the one below only when the layer leaves it out or gives `null`, so `false` turns plan approval off; 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), `feature/` (decision 41) and plan approval on (decision 8). 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; `baton hook guard`, in the orchestrator's pane, reads it for a script that starts a feature (decision 54). 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. 50. **The guard reads bash with mvdan.cc/sh** (the user's call, 2026-11-03, for the module; lead's call, flagged, for the rest). The guard needs the commands that a bash call runs: a match on the text would miss `git -C x push`, `nohup git push` and `$(git push)`, and catch `echo 'git push'`. The standard library has no bash parser, so `internal/bash`, and only it, imports `mvdan.cc/sh/v3`, the parser of `shfmt`, pinned at exactly v3.14.1 in `go.mod`, which Renovate updates as it does the rest; its license is BSD-3, whose notice the release carries (`homebrew`). `bash.Calls` gives each command that a script runs as its words, as bash passes them: quotes and backslashes taken off, and each part that only the run decides, such as `$HOME` or `$(…)`, as a marker, `bash.Dynamic`. It finds the commands in substitutions, here-documents, functions and conditions too; for a wrapper (`builtin`, `caffeinate`, `command`, `env`, `exec`, `nice`, `nohup`, `stdbuf`, `sudo`, `time`, `timeout`, `xargs` and Homebrew's `g` names, such as `gtimeout`), the command that it runs, past its options, `NAME=value` words and `timeout`'s duration; and for `eval`, and `bash`, `dash`, `ksh`, `sh` and `zsh` with `-c`, the commands of their script. A program's name ignores case, as a Mac's file system does. A script that doesn't parse is an error, which the guard denies, and that is rare (fact 33). It can't see what only the run decides: an alias, a script file or `source`, a variable as the program (`"$X" push`), a script on standard input (`sh -s`, `| sh`, `bash < .git/baton/…` (risk 11), and to the binary that `BATON_BIN` names, which the hooks run. It misses a hard link, outside baton's folder, to a file in it, which the walk up the link's folders can't see; data that `web_fetch` sends in a URL, as it only reads; other repos' baton folders; and what decisions 50 and 51 list. It denies the file tools in the plugin's cache too, and `baton pane` checks the plugin's files there before each `copilot` starts (decision 53), but `bash` can still change them while a `copilot` starts or runs (risk 12). Of all these, the belt (decision 55) stops git's pushes and `gh`; the rest stays with risks 2, 11 and 12. +52. **`baton hook guard` denies a role's tools what the guard watches** (lead's call, flagged; decisions 9, 17, 48, 51 and 56, facts 4 and 36, risks 2, 4, 11, 12 and 13). The plugin's `preToolUse` hook runs `baton hook guard` before each tool that a pane's `copilot` calls. In a role's pane, it denies a `bash` call whose script the guard can't read (decision 50), or that runs a command that the guard watches, but baton's commands for a role: `baton wait`, `baton handover`, `baton ask`, `baton status` and baton's help (`baton help`, `-h`, `-help` and `--help`); the guard names a `baton` command by its first word, or by its first two for `baton feature` and `baton hook`, such as `baton feature start`, so `baton wait -h` passes and `baton answer -h` doesn't. It denies a `create` or an `edit` of a file in baton's folder, which only baton writes (decision 48): it makes the path absolute from the input's `cwd`, resolves the symlinks of the part of the path that is there, and walks up its folders, comparing each with baton's folder as a file (`os.SameFile`), so that neither a symlink, nor a disk that ignores case, nor a new file or folder gets past; in a repo without baton's folder, no path is baton's. Each reason starts with "baton's guard stopped this call, and none of it ran." and says why: the first command that the guard watches, by its name, with its kind's rule (an outward action is the orchestrator's, with the user's approval, so the role keeps its work local and says in its handover note what should go out; a role never drives tmux or the Mac's apps, nor starts another agent; a program must be named in the script itself), the parser's error, with a plea for plain bash, or the file and baton's folder, with the session's `files/` folder for notes. The hook denies the whole call, since a bash call is one script, and the role can try again. It answers only `deny`, and otherwise says nothing, which leaves the tool to `copilot`'s own permissions; the role's other tools only read or look after its shells (fact 36). In the orchestrator's pane, it follows decision 54. When it can't decide, as for input that isn't a JSON object, arguments that don't fit the tool, a pane without `BATON_ROLE` or a folder outside a git repo, it fails, so `copilot` denies the tool (fact 4). It reads only its input and, for a file tool, git's common folder, so it answers well within the hook's timeout, past which `copilot` would allow (risk 4). The guard stops a role's mistakes, not a role that sets out to get past it. It misses `bash`'s own writes, which it doesn't follow: in baton's folder, such as `echo … > .git/baton/…` (risk 11), and to the binary that `BATON_BIN` names, which the hooks run. It misses a hard link, outside baton's folder, to a file in it, which the walk up the link's folders can't see; data that `web_fetch` sends in a URL, as it only reads; other repos' baton folders; and what decisions 50 and 51 list. It denies the file tools in the plugin's cache too, and `baton pane` checks the plugin's files there before each `copilot` starts (decision 53), but `bash` can still change them while a `copilot` starts or runs (risk 12); and in baton's config folder, whose file holds the gate, which `bash` can still change too (decision 56, risk 13). Of all these, the belt (decision 55) stops git's pushes and `gh`; the rest stays with risks 2, 11 and 12. 53. **baton checks the plugin's folder before each `copilot` starts** (lead's call, flagged; decisions 36, 38 and 52, risk 12). `plugin.Write` hashes the files of a folder that has the plugin's name, by their paths in the folder and their contents, as it hashes the binary's, and uses the folder only if the hashes match and it is a folder, not a symlink to one. A folder with other files, more, fewer or changed ones, or with anything but folders and regular files, such as a symlink, and anything else under the name, it leaves as it is, and writes the plugin under the next name, `plugin--1`, then `-2`, up to `-7`; with all eight names taken, the pane fails and says why. baton never replaces a folder (Go's `os.Rename` refuses to), so a starting `copilot` never misses the plugin's folder, as it could while a swap ran, and goes on without the hooks and with the default agent (risk 10). Panes that start at once take the same new name, since a folder appears whole (decision 36). A role's pane checks again after its wait, which can last hours, just before it becomes `copilot`; a failure there leaves the work picked up, as a `copilot` that fails to start does (decision 38). The guard denies a role's `create` and `edit` in baton's cache folder, as in baton's folder, and fails without the user's cache folder (decision 52). `bash` can still change the plugin's files after a pane's check, while its `copilot` starts or runs, or through a hard link (risk 2), and a Finder `.DS_Store` in a folder costs one more folder. Rejected: rewriting a changed folder, which a starting `copilot` could see half written; removing it, which a running pane may still use; and making a checked folder read-only, which a role that runs as the user can undo. 54. **`baton hook guard` asks the user in the orchestrator's pane** (lead's call, flagged; decisions 8, 17, 19, 45, 48, 49 and 52, facts 4, 28 and 37, risks 2 and 3). The orchestrator's `copilot` runs with `--allow-all`, so its tools run unasked, and the guard answers `ask` where the user should decide, which copilot shows in the pane with the reason and the call, for the user to allow, or to decline with a word for the orchestrator (fact 37); the orchestrator's prompt tells it to ask the user what they want instead. For `bash`, the guard reads the script's commands (decision 51). Baton's commands for the orchestrator run unasked: `baton wait`, `baton answer`, `baton handover`, `baton status` and baton's help. `baton feature start` asks while plan approval is on (decision 8), so the user checks the plan first, and runs unasked once `planApproval` is `false` (decision 49); the guard reads the config file only for a script that starts a feature, for the repo of the hook's folder, and a mistake in the file, or a folder outside a repo, fails the hook, so `copilot` denies the script (fact 4). An outward command asks, so the user approves each push and each pull request, until `baton deliver` comes (decision 17). The guard denies, with a reason for the orchestrator, `tmux` and `osascript`, another agent's CLI, baton's other commands, such as `baton ask` or `baton pane`, and, as in a role's pane (decision 52), a program that only the run decides and a script that it can't read. A script runs whole or not at all: a command that the guard denies denies the script, and otherwise the first command that asks names the question, so the user's yes runs the whole script. The guard names baton's `feature` and `hook` commands by two words (decision 52), so that `baton feature start` asks and a later `baton feature close` can differ. For `create` and `edit`, it denies a path outside the session's folder, `session-state/` in copilot's home folder, which `COPILOT_HOME` names in the pane, or else `~/.copilot`, with the walk of decision 52; the input's `sessionId` names the session (fact 37). A session ID that isn't one folder's name fails the hook, and a session's folder that isn't there holds nothing. The orchestrator keeps `ask_user` and copilot's folder-trust question (decision 45). The guard misses what it misses in a role's pane (decision 52): the orchestrator can write any file with `bash`, and run local commands, such as `rm`, unasked (risk 3), and `web_fetch` can send data in a URL. Its extra tools, such as nudge's, run unasked, and send what they send. The guard reads the plan approval of the hook's folder's repo, not of a repo that the script `cd`s into, and `baton feature start -h` asks too. Rejected: denying outward commands in this pane until `baton deliver` comes, which would leave the user no way to push from it; asking about every command, as copilot did before (decision 45), which buries the questions that matter among the rest; and copilot's `--deny-tool` rules, which only deny (fact 28). 55. **A role's pane runs with a second belt** (the user's call, 2026-11-04, for the git and `gh` parts; lead's call, flagged, for the rest; decisions 9, 17, 52 and 54, facts 28 and 38, risk 2). The guard sees only what a command shows (risk 2), so `baton pane` gives a role's `copilot` settings in its environment, which each command and script that the role runs inherits, whatever runs it, and which make git's pushes out of the machine and `gh` fail. For git, after the settings that `GIT_CONFIG_COUNT` counts in baton's own environment, which it keeps, it adds a `pushInsteadOf` rule for each character that starts a URL that leaves the machine, a letter, a digit or `[`, which sends the push to `baton-blocks-push::`, a remote helper that doesn't exist, so that git fails before any network and names baton's belt; a rule that keeps `file://` URLs as they are; and, for the remotes in git's config for the repo, the user's own config included, as the pane finds them when it starts, a rule for each remote's URL that leaves, which beats the user's own rules for a shorter prefix, and an empty `pushurl` for each remote with a push URL that leaves, which clears its push URLs, so that its pushes go to its URL. A git before 2.46, which can't clear them, gets an `insteadOf` rule for each such push URL instead, which also blocks fetches from the URLs that start with it (fact 38). Pushes to a path on the machine, `file://` URLs among them, still work, and fetches, pulls and clones don't change. For `gh`, `GH_CONFIG_DIR` names a folder under `/dev/null`, so that every `gh` command fails before it starts and never reaches the user's login in the Keychain, as it would with an empty folder (fact 38). The pane goes without `GH_TOKEN`, `GITHUB_TOKEN`, `GH_ENTERPRISE_TOKEN` and `GITHUB_ENTERPRISE_TOKEN`, which other tools would use; but `copilot` takes its own login from `GH_TOKEN` or `GITHUB_TOKEN`, or, after its own login, from `gh` (fact 38), so it gets the first of the two that is set as `COPILOT_GITHUB_TOKEN`, unless that is set already, and a user whose `copilot` logs in only through `gh` runs `copilot login` once (README). `baton pane` makes the belt before it waits, and fails when it can't: when git can't read the repo's config or its own version, or when `GIT_CONFIG_COUNT` is a count that git reads but Go doesn't, such as ` 1`, since baton can't tell then where to put its settings. The orchestrator's pane goes without the belt, since the user approves its outward actions there (decision 54). The costs: a push to a relative path that starts with a letter or a digit, such as `git push backup.git`, fails too, as does one to a git server on the machine by its URL, such as `http://localhost/…`; and the roles lose `gh`'s reads, which their prompts don't ask for. Like the guard, the belt stops a role's mistakes, and what the guard can't see, not a role that sets out to get past it (risk 2). Rejected: an empty `GH_CONFIG_DIR` folder, which leaves `gh` the user's login in the Keychain; `BASH_ENV`, which `copilot` reads only with a setting of the user's (fact 38); `copilot`'s deny rules, which match the command that the bash tool runs, as the guard does, not what it runs in turn (fact 28); and `copilot`'s command sandbox, whose policy, such as whether commands reach the network, lives in the user's settings, which each of their `copilot` sessions shares (risk 2). +56. **A repo's gate checks the code before each handover that vouches for it** (lead's call, flagged; the user's call, 2026-11-04, for its `//nolint:gosec`; decisions 13, 34, 49 and 52, fact 16, risk 13). The config file's `gate`, a setting like the others (decision 49), is the user's command that checks the repo's code, such as `mise run check`. The engineer's handover, QA's approval and the lead's final report run it, since each of these roles vouches for the code that it hands on; the rejections, the reviewer's approval, which follows QA's on the same code, the lead's brief and the orchestrator's moves don't. baton reads the gate at each of these handovers, for the repo of the role's folder, and runs it with `/bin/sh -c` in the feature's worktree, wherever in the repo the role runs baton, with baton's environment, the belt's in a role's pane (decision 55), nothing on its input, and its output and its errors on baton's output, in order, after a line that names the gate and the folder, so the role reads what failed. It runs once baton has checked the move and the role's open question on the feature as it stands, so a move that baton refuses runs no gate, and without the feature's lock, so that the other roles' `baton wait` and `baton status` don't wait for it; baton checks the move again under the lock when it writes the handover. A gate that fails refuses the handover, which changes nothing: the role fixes what failed, or, for QA, rejects with the failures. The journal logs the gate that a handover ran, with the handover. The gate has no time limit, since a check can take minutes; Ctrl-C or a hangup stops it, as it stops baton (decision 42): baton kills the gate's shell, and Ctrl-C reaches the commands that the shell started too, but a signal to baton alone leaves them to run to their end. Every repo needs a gate, so that none is forgotten: `baton start`, when it makes the orchestrator's session, and `baton feature start` refuse a repo without one, and say where to set it; `"gate": "true"` checks nothing, for a repo that has no checks. A gated handover refuses too, if the user takes the gate out while a feature runs; the others still go. The config file refuses a gate that is only spaces, and one that starts with `-`, which `sh -c` reads as its own options (bash, dash and zsh all do). The gate is a command line by design, the user's own, from the config file, which only the user writes, so its one place to run carries `//nolint:gosec` for G204, with its reason; decision 34's reason, arguments that no shell reads, doesn't fit it. The guard denies a role's `create` and `edit` in baton's config folder (decision 52), so a role's file tools can't change the gate, and a config folder that isn't there holds nothing, as no feature runs without the file; a role's `bash` still can, and a role can weaken the checks that the gate runs from inside the repo, such as its `mise.toml` (risk 13). Rejected: running the gate in the role's working folder, which may be any folder of the repo; a time limit, which would fail a slow but sound check; a repo without a gate, which would make the gate easy to forget; and gating QA's rejection, which reports a failure rather than vouching for the code. ## 5. Risks @@ -292,6 +294,7 @@ Decisions marked **lead's call** are the lead's, for the user's end review. The 10. **A wrong agent doesn't stop `copilot`** (fact 24): an interactive session that can't find its agent goes on with the default agent, and `-i` hands that agent the role's work. baton's IDs come from the plugin that it writes (decision 37), and `live-run` saw each pane run its role's agent (facts 26 and 27). 11. **A role can write in baton's folder.** Under `--allow-all`, a role writes anywhere: in the live run, the reviewer put its notes there (fact 29). A stray note there misleads whoever reads the notes, and a role that edited the feature's file or its journal would mislead baton. `baton handover` refuses a note there, and the prompts send the notes to the role's session folder (decision 48), and the guard's `preToolUse` hook keeps the file tools out of it (decision 52), but a role's `bash` can still write there, as `echo … > .git/baton/…` does, which the guard doesn't see. 12. **A role can turn the guard off for the panes that start later** (decisions 36, 52 and 53). A role runs as the user, so it can change the files of a plugin folder in baton's cache, such as `hooks.json`, with `bash` or a file tool, and take the guard away from each pane that loads that folder after it. `baton pane` checks the folder's files before each `copilot` starts, and the guard denies the file tools there (decision 53); `bash`'s writes after a check stay with risk 2. +13. **A role can weaken the gate** (decision 56). The gate runs the repo's own checks, which live in the repo, such as its `mise.toml`, its lint config and its tests, and a role can change them as it changes the code: an engineer could skip a failing test or turn a lint rule off, and its handover would pass. QA and the reviewer read the diff, which shows such a change, and the guard keeps the file tools out of baton's config folder, which holds the gate itself (decision 52), but a role's `bash` can still change the config file (risk 2). ## 6. Open questions diff --git a/helpers_test.go b/helpers_test.go index bc24bec..837c043 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -18,9 +18,10 @@ import ( // skeleton makes the skeleton test's world, and returns its folder. The folder holds a bin folder, // first on the PATH, with baton, a link to this test binary, and copilot, a link to the stand-in; -// a home folder and a temp folder of the test's own; the repo, with a commit, which is the test's -// working folder; and the orchestrator's brief. baton's tmux finds the test's own server, as tmux's -// default server, in a TMUX_TMPDIR of the test's own. +// a home folder of the test's own, with baton's config file, which gives the gate; a temp folder of +// the test's own; the repo, with a commit, which is the test's working folder; and the +// orchestrator's brief. baton's tmux finds the test's own server, as tmux's default server, in a +// TMUX_TMPDIR of the test's own. func skeleton(t *testing.T) string { t.Helper() @@ -66,6 +67,8 @@ func skeleton(t *testing.T) string { t.Setenv("TMUX_TMPDIR", folder(t, "/tmp", "baton-test-skeleton-")) t.Chdir(repo) + configure(t) + serve(t) ran(t, exec.CommandContext(t.Context(), "git", "init", "--quiet", "--initial-branch=main")) ran(t, exec.CommandContext(t.Context(), "git", "commit", "--quiet", "--allow-empty", @@ -257,10 +260,12 @@ func wantJournal(t *testing.T, feature string, want []logged) { } } - got = append(got, logged{ - event: fmt.Sprintf("%s %s %s %s", event.Kind, event.Role, event.Verdict, event.Note), - note: string(note), - }) + line := fmt.Sprintf("%s %s %s %s", event.Kind, event.Role, event.Verdict, event.Note) + if event.Gate != "" { + line += " (gate: " + event.Gate + ")" + } + + got = append(got, logged{event: line, note: string(note)}) } if !slices.Equal(got, want) { diff --git a/internal/cli/ask_turn_test.go b/internal/cli/ask_turn_test.go index 8444270..67869cc 100644 --- a/internal/cli/ask_turn_test.go +++ b/internal/cli/ask_turn_test.go @@ -17,6 +17,8 @@ import ( // clears the question, and ask the same one in its next turn: a new question, which waits for its // own answer. func TestAskInTheNextTurnAsksAnew(t *testing.T) { + withGate(t, "true") + synctest.Test(t, func(t *testing.T) { mainDir, _ := repos(t) store := started(t, mainDir, fresh()) diff --git a/internal/cli/gate.go b/internal/cli/gate.go new file mode 100644 index 0000000..f8fa466 --- /dev/null +++ b/internal/cli/gate.go @@ -0,0 +1,87 @@ +package cli + +import ( + "context" + "fmt" + "os" + + "github.com/codedogapp/baton/internal/config" + "github.com/codedogapp/baton/internal/flow" + "github.com/codedogapp/baton/internal/gate" + "github.com/codedogapp/baton/internal/state" +) + +// handing is a role's handover: the feature, in its store, the move in its flow, and the event, +// with the note, that it adds to the feature's journal. +type handing struct { + store state.Store + slug string + move flow.Handover + entry state.Entry +} + +// make makes the move, once the repo's gate passes in the feature's worktree if the move waits for +// it, and returns the feature as moved. It checks the move first, so that a move that the flow +// refuses runs no gate, and the store checks it again under the feature's lock, which the gate +// doesn't hold, since it can take minutes (decision 56). +func (h handing) make(ctx context.Context, env Env) (state.Feature, error) { + feature, err := h.store.Read(h.slug) + if err == nil { + err = moved(h.move)(&feature) + } + + if err == nil && feature.Flow.Gated() { + h.entry.Event.Gate, err = runGate(ctx, env, feature) + } + + if err != nil { + return state.Feature{}, err + } + + return h.store.Update(h.slug, h.entry, moved(h.move)) +} + +// runGate runs the repo's gate in the feature's worktree, with its output on baton's, and returns +// the gate once it passes. +func runGate(ctx context.Context, env Env, feature state.Feature) (string, error) { + settings, err := gatedSettings(ctx, env.Dir) + if err == nil && feature.Worktree == "" { + err = fmt.Errorf("feature %s names no worktree, where its gate runs", feature.Slug) + } + + if err != nil { + return "", err + } + + command := settings.Gate + _, _ = fmt.Fprintf(env.Stdout, "Running the gate in %s: %s\n", feature.Worktree, command) + + if err := gate.Run(ctx, feature.Worktree, command, env.Stdout); err != nil { + return "", fmt.Errorf("gate %q failed: %w: the feature stays with you until it passes", + command, err) + } + + return command, nil +} + +// gatedSettings returns the repo's settings, as loadSettings does, and refuses a repo without a +// gate, which each feature's handovers run (decision 56). +func gatedSettings(ctx context.Context, dir string) (config.Settings, error) { + settings, err := loadSettings(ctx, dir) + if err == nil && settings.Gate == "" { + err = noGate() + } + + return settings, err +} + +// noGate returns the error for a repo without a gate, which names the config file where it goes. +func noGate() error { + file := "baton's config file" + if configDir, err := os.UserConfigDir(); err == nil { + file = config.File(configDir) + } + + return fmt.Errorf(`no gate for this repo: set "gate" in %s to the command that checks `+ + `the repo's code, such as "mise run check", or to "true" for none`, file) +} diff --git a/internal/cli/gate_refusals_test.go b/internal/cli/gate_refusals_test.go new file mode 100644 index 0000000..957763f --- /dev/null +++ b/internal/cli/gate_refusals_test.go @@ -0,0 +1,110 @@ +package cli_test + +import ( + "encoding/json/v2" + "maps" + "os" + "path/filepath" + "testing" + + "github.com/codedogapp/baton/internal/flow" + "github.com/codedogapp/baton/internal/state" +) + +// TestHandoverRefusesWhereTheGateCantRun refuses the engineer's handover where the repo's gate +// can't run, and a handover that the flow refuses before its gate runs: no gate runs, and baton's +// folder stays as it was. +func TestHandoverRefusesWhereTheGateCantRun(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + marker := filepath.Join(t.TempDir(), "ran") + mark := "touch '" + marker + "'" + + marking, err := json.Marshal(map[string]string{"gate": mark}) + if err != nil { + t.Fatal(err) + } + + gone := filepath.Join(t.TempDir(), "gone") + + engineer := flow.State{Holder: flow.RoleEngineer, Status: flow.StatusRunning, Round: 1, Cap: 3} + question := state.Question{From: flow.RoleEngineer, Text: "Which file?"} + + cases := []struct { + name string + config string // The config file's text. + feature state.Feature // The feature, with the main worktree, unless it names another. + stdout string + want string // The reason, after "baton handover: ". + }{ + {"without a gate", `{"roundCap": 2}`, state.Feature{Flow: engineer}, "", noGate(t)}, + { + "with a mistake in the config file", `{"gate": "true", "roundCap": -1}`, + state.Feature{Flow: engineer}, + "", + "config file " + configPath(t) + ": roundCap -1: want 1 or more\n", + }, + { + "without a worktree", string(marking), + state.Feature{Worktree: "-", Flow: engineer}, + "", "feature demo-1 names no worktree, where its gate runs\n", + }, + { + "whose worktree is gone", string(marking), + state.Feature{Worktree: gone, Flow: engineer}, + ranGate(gone, mark, ""), + `gate "` + mark + `" failed: chdir ` + gone + ": no such file or directory: the " + + "feature stays with you until it passes\n", + }, + { + "from a role that doesn't hold it", string(marking), + state.Feature{Flow: fresh()}, + "", "lead holds the feature, not engineer\n", + }, + { + "while the holder's question waits", string(marking), + state.Feature{Flow: engineer, Question: question}, + "", + "the engineer's question \"Which file?\" waits for its answer: wait for it, " + + "then hand over\n", + }, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + writeConfig(t, test.config) + + mainDir, linked := repos(t) + store := state.Store{Dir: filepath.Join(mainDir, ".git", "baton")} + + feature := test.feature + feature.Slug = slug + + switch feature.Worktree { + case "-": + feature.Worktree = "" + case "": + feature.Worktree = mainDir + } + + add(t, store, feature) + before := snapshot(t, store.Dir) + + note := write(t, linked, "note.md", "Done.\n") + got := run(t, linked, paneOf(flow.RoleEngineer), "handover", "--note", note) + + want := result{code: 1, stdout: test.stdout, stderr: "baton handover: " + test.want} + if got != want { + t.Errorf("got %+v; want %+v", got, want) + } + + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Errorf("the gate ran: %v", err) + } + + if after := snapshot(t, store.Dir); !maps.Equal(after, before) { + t.Errorf("baton's folder changed to %v", after) + } + }) + } +} diff --git a/internal/cli/gate_test.go b/internal/cli/gate_test.go new file mode 100644 index 0000000..6e06f97 --- /dev/null +++ b/internal/cli/gate_test.go @@ -0,0 +1,184 @@ +package cli_test + +import ( + "encoding/json/v2" + "maps" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/codedogapp/baton/internal/flow" + "github.com/codedogapp/baton/internal/state" +) + +// checked is a gate that says where it ran: in the folder named main, the main worktree, which +// started gives each feature as its worktree. +const checked = `printf 'checked in %s\n' "${PWD##*/}"` + +// withGate gives the test a home folder of its own, with baton's config file, which gives the +// gate alone. +func withGate(t *testing.T, command string) { + t.Helper() + + text, err := json.Marshal(map[string]string{"gate": command}) + if err != nil { + t.Fatal(err) + } + + t.Setenv("HOME", t.TempDir()) + writeConfig(t, string(text)) +} + +// noGate returns baton's reason to refuse a repo without a gate, which names the config file. +func noGate(t *testing.T) string { + t.Helper() + + return `no gate for this repo: set "gate" in ` + configPath(t) + " to the command that " + + `checks the repo's code, such as "mise run check", or to "true" for none` + "\n" +} + +// ranGate returns what baton prints when the gate runs in the folder: its own line, then the +// gate's output. +func ranGate(dir, command, output string) string { + return "Running the gate in " + dir + ": " + command + "\n" + output +} + +// TestHandoverRunsTheGate hands the feature over at each step of the flow. The engineer's +// handover, QA's approval and the lead's final report run the repo's gate first, in the feature's +// worktree, wherever the role runs baton, and the journal logs the gate with the handover. The +// other handovers run no gate (decision 56). +func TestHandoverRunsTheGate(t *testing.T) { + withGate(t, checked) + + running := func(holder flow.Role, round int) flow.State { + return flow.State{Holder: holder, Status: flow.StatusRunning, Round: round, Cap: 3} + } + escalated := flow.State{ + Holder: flow.RoleOrchestrator, Status: flow.StatusEscalated, Round: 3, Cap: 3, + } + ready := flow.State{Holder: flow.RoleOrchestrator, Status: flow.StatusReady, Round: 1, Cap: 3} + approve, reject := []string{"--verdict", "approve"}, []string{"--verdict", "reject"} + + cases := []struct { + name string + now flow.State + role flow.Role + args []string // After --note. + gated bool + }{ + {"the lead's brief", running(flow.RoleLead, 0), flow.RoleLead, nil, false}, + {"the engineer's handover", running(flow.RoleEngineer, 1), flow.RoleEngineer, nil, true}, + {"QA's approval", running(flow.RoleQA, 1), flow.RoleQA, approve, true}, + {"QA's rejection", running(flow.RoleQA, 1), flow.RoleQA, reject, false}, + { + "the reviewer's approval", + running(flow.RoleReviewer, 1), + flow.RoleReviewer, + approve, + false, + }, + { + "the reviewer's rejection", + running(flow.RoleReviewer, 1), + flow.RoleReviewer, + reject, + false, + }, + {"the lead's final report", running(flow.RoleLead, 1), flow.RoleLead, nil, true}, + { + "the orchestrator's new brief", ready, flow.RoleOrchestrator, + []string{slug, "--to", "lead"}, + false, + }, + { + "the orchestrator's extra round", escalated, flow.RoleOrchestrator, + []string{slug, "--to", "engineer"}, + false, + }, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + mainDir, linked := repos(t) + store := started(t, mainDir, test.now) + note := write(t, t.TempDir(), "note.md", "Done.\n") + + env := paneOf(test.role) + if test.role == flow.RoleOrchestrator { + env = orchestratorPane() + } + + got := run(t, linked, env, + slices.Concat([]string{"handover", "--note", note}, test.args)...) + + ran, gate := ranGate(mainDir, checked, "checked in main\n"), "" + if test.gated { + gate = checked + } + + if got.code != 0 || got.stderr != "" || + strings.HasPrefix(got.stdout, ran) != test.gated || + !strings.Contains(got.stdout, "Handed demo-1 over to ") { + t.Errorf("got %+v; want a handover, after the gate: %t", got, test.gated) + } + + if logged := lastEvent(t, store).Gate; logged != gate { + t.Errorf("the handover's event logs gate %q; want %q", logged, gate) + } + }) + } +} + +// TestHandoverWaitsForTheGateToPass has the engineer hand over while the gate fails: baton shows +// the gate's output, both its streams, and refuses, and the feature stays as it was. +func TestHandoverWaitsForTheGateToPass(t *testing.T) { + const failing = "echo out; echo err >&2; exit 3" + + withGate(t, failing) + + mainDir, linked := repos(t) + store := started(t, mainDir, flow.State{ + Holder: flow.RoleEngineer, Status: flow.StatusRunning, Round: 1, Cap: 3, + }) + before := snapshot(t, store.Dir) + note := write(t, linked, "note.md", "Done.\n") + + got := run(t, linked, paneOf(flow.RoleEngineer), "handover", "--note", note) + + want := result{ + code: 1, + stdout: ranGate(mainDir, failing, "out\nerr\n"), + stderr: `baton handover: gate "` + failing + `" failed: exit status 3: the feature ` + + "stays with you until it passes\n", + } + if got != want { + t.Errorf("got %+v; want %+v", got, want) + } + + if after := snapshot(t, store.Dir); !maps.Equal(after, before) { + t.Errorf("baton's folder changed to %v", after) + } +} + +// lastEvent returns the last event in the feature's journal. +func lastEvent(t *testing.T, store state.Store) state.Event { + t.Helper() + + path := filepath.Join(store.Dir, "features", slug, "events.ndjson") + + data, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + t.Fatal(err) + } + + lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n") + + var event state.Event + if err := json.Unmarshal([]byte(lines[len(lines)-1]), &event); err != nil { + t.Fatal(err) + } + + return event +} diff --git a/internal/cli/guard.go b/internal/cli/guard.go index 9cfa48c..3ff6b6a 100644 --- a/internal/cli/guard.go +++ b/internal/cli/guard.go @@ -10,8 +10,10 @@ import ( "path/filepath" "slices" + "github.com/codedogapp/baton/internal/config" "github.com/codedogapp/baton/internal/flow" "github.com/codedogapp/baton/internal/guard" + "github.com/codedogapp/baton/internal/state" ) // stopped starts each reason that baton hook guard gives for a tool that it denies, which copilot @@ -195,15 +197,22 @@ func roleCommand(command guard.Command) guardOutput { } // pathReason returns the reason to deny a file tool's write of the absolute path in a role's pane, -// or "" to let it run: a file in baton's folder, which only baton writes (decision 48), or in -// baton's cache folder, whose plugin runs this guard (decision 53). +// or "" to let it run: a file in baton's folder, which only baton writes (decision 48), in baton's +// cache folder, whose plugin runs this guard (decision 53), or in baton's config folder, whose file +// holds the repo's gate (decision 56). func pathReason(ctx context.Context, dir, path string) (string, error) { cache, err := batonCache() - if err != nil { - return "", err + + var configDir string + if err == nil { + configDir, err = userConfigDir() + } + + var store state.Store + if err == nil { + store, err = openStore(ctx, dir) } - store, err := openStore(ctx, dir) if err != nil { return "", err } @@ -215,6 +224,10 @@ func pathReason(ctx context.Context, dir, path string) (string, error) { case within(path, cache): return fmt.Sprintf("%s%s is in baton's cache folder %s, which only baton writes: it "+ "holds the plugin whose hook runs this guard.", stopped, path, cache), nil + case within(path, config.Folder(configDir)): + return fmt.Sprintf("%s%s is in baton's config folder %s, which only the user writes: "+ + "its file holds the gate that your handovers run.", stopped, path, + config.Folder(configDir)), nil } return "", nil diff --git a/internal/cli/guard_config_test.go b/internal/cli/guard_config_test.go new file mode 100644 index 0000000..c4387d5 --- /dev/null +++ b/internal/cli/guard_config_test.go @@ -0,0 +1,53 @@ +package cli_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/codedogapp/baton/internal/flow" +) + +// TestGuardHookKeepsTheFileToolsOutOfBatonsConfig denies a role's create and edit of a file in +// baton's config folder, whose file holds the gate that the role's handovers run, however the path +// names it (decision 56). It lets them write beside it. +func TestGuardHookKeepsTheFileToolsOutOfBatonsConfig(t *testing.T) { + withGate(t, "true") + _, linked := repos(t) + + file := configPath(t) + folder := filepath.Dir(file) + + link := filepath.Join(linked, "config") + if err := os.Symlink(folder, link); err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + path string // The path in the tool's arguments. + want string // The path that the reason names, or "" to let the tool run. + }{ + {"the config file", file, file}, + {"baton's config folder", folder, folder}, + {"a file in a new folder", folder + "/old/config.json", folder + "/old/config.json"}, + {"a symlink", link + "/config.json", link + "/config.json"}, + {"a folder beside it", folder + "-old/config.json", ""}, + } + + for _, test := range cases { + for _, tool := range []string{"create", "edit"} { + t.Run(tool+" "+test.name, func(t *testing.T) { + want := "" + if test.want != "" { + want = guardStopped + test.want + " is in baton's config folder " + folder + + ", which only the user writes: its file holds the gate that your " + + "handovers run." + } + + input := guardInput(t, linked, tool, map[string]any{"path": test.path}) + wantGuard(t, guardHook(t, linked, paneOf(flow.RoleEngineer), input), want) + }) + } + } +} diff --git a/internal/cli/handover.go b/internal/cli/handover.go index b74f12b..e348096 100644 --- a/internal/cli/handover.go +++ b/internal/cli/handover.go @@ -15,7 +15,8 @@ import ( // handover hands a feature over from the pane's role to the next one, with a note: the next role's // work. QA and the reviewer give their verdict. The orchestrator, whose pane names no feature, -// names the feature and the next role (decision 44). +// names the feature and the next role (decision 44). The engineer's handover, QA's approval and +// the lead's final report wait for the repo's gate to pass (decision 56). func handover(ctx context.Context, env Env, args []string) error { flags := newFlags(env, "handover") notePath := flags.String("note", "", "the `file` with the note for the next role (required)") @@ -52,7 +53,7 @@ func handover(ctx context.Context, env Env, args []string) error { Note: note, } - feature, err := store.Update(slug, entry, moved(move)) + feature, err := handing{store: store, slug: slug, move: move, entry: entry}.make(ctx, env) if err != nil { return err } diff --git a/internal/cli/handover_test.go b/internal/cli/handover_test.go index 6c755db..a14146e 100644 --- a/internal/cli/handover_test.go +++ b/internal/cli/handover_test.go @@ -15,11 +15,16 @@ import ( ) // TestHandoverPassesTheFeatureAlong walks a round that the reviewer rejects, from a subfolder of -// the main worktree and from the linked worktree, which share baton's folder. +// the main worktree and from the linked worktree, which share baton's folder. The engineer's +// handover and QA's approval run the gate in the feature's worktree first. func TestHandoverPassesTheFeatureAlong(t *testing.T) { + withGate(t, checked) + mainDir, linked := repos(t) store := started(t, mainDir, fresh()) + gated := ranGate(mainDir, checked, "checked in main\n") + sub := filepath.Join(mainDir, "docs") if err := os.Mkdir(sub, 0o700); err != nil { t.Fatal(err) @@ -30,22 +35,21 @@ func TestHandoverPassesTheFeatureAlong(t *testing.T) { role flow.Role relative bool // Whether --note gets the note's path relative to the folder. verdict []string + gate string // What the gate printed, if it ran. want string }{ - {sub, flow.RoleLead, false, nil, "engineer: running, round 1 of 3"}, - {linked, flow.RoleEngineer, true, nil, "qa: running, round 1 of 3"}, + {sub, flow.RoleLead, false, nil, "", "engineer: running, round 1 of 3"}, + {linked, flow.RoleEngineer, true, nil, gated, "qa: running, round 1 of 3"}, { - linked, - flow.RoleQA, - true, + linked, flow.RoleQA, true, []string{"--verdict", "approve"}, + gated, "reviewer: running, round 1 of 3", }, { - linked, - flow.RoleReviewer, - true, + linked, flow.RoleReviewer, true, []string{"--verdict=reject"}, + "", "engineer: running, round 2 of 3", }, } @@ -59,7 +63,7 @@ func TestHandoverPassesTheFeatureAlong(t *testing.T) { got := run(t, step.dir, paneOf(step.role), append([]string{"handover", "--note", note}, step.verdict...)...) - want := result{stdout: "Handed demo-1 over to " + step.want + ".\n"} + want := result{stdout: step.gate + "Handed demo-1 over to " + step.want + ".\n"} if got != want { t.Fatalf("the %s's handover: got %+v; want %+v", step.role, got, want) } @@ -78,8 +82,8 @@ func TestHandoverPassesTheFeatureAlong(t *testing.T) { wantJournal(t, store, []logged{ {"start orchestrator 01-orchestrator-to-lead.md", "Build the demo.\n"}, {"handover lead 02-lead-to-engineer.md", "From the lead.\n"}, - {"handover engineer 03-engineer-to-qa.md", "From the engineer.\n"}, - {"handover qa approve 04-qa-to-reviewer.md", "From the qa.\n"}, + {"handover engineer 03-engineer-to-qa.md (gate: " + checked + ")", "From the engineer.\n"}, + {"handover qa approve 04-qa-to-reviewer.md (gate: " + checked + ")", "From the qa.\n"}, {"handover reviewer reject 05-reviewer-to-engineer.md", "From the reviewer.\n"}, }) } @@ -197,7 +201,8 @@ type logged struct { note string } -// wantJournal checks the feature's events, in order, and their notes, if any. +// wantJournal checks the feature's events, in order, with the gate that each one ran, if any, and +// their notes, if any. func wantJournal(t *testing.T, store state.Store, want []logged) { t.Helper() @@ -224,10 +229,12 @@ func wantJournal(t *testing.T, store state.Store, want []logged) { } } - got = append(got, logged{ - event: fmt.Sprintf("%s %s %s %s", event.Kind, event.Role, event.Verdict, event.Note), - note: string(note) + event.Text, - }) + line := fmt.Sprintf("%s %s %s %s", event.Kind, event.Role, event.Verdict, event.Note) + if event.Gate != "" { + line += " (gate: " + event.Gate + ")" + } + + got = append(got, logged{event: line, note: string(note) + event.Text}) } if !slices.Equal(got, want) { diff --git a/internal/cli/helpers_test.go b/internal/cli/helpers_test.go index a99f720..92fdbe0 100644 --- a/internal/cli/helpers_test.go +++ b/internal/cli/helpers_test.go @@ -142,7 +142,7 @@ func started(t *testing.T, mainDir string, now flow.State) state.Store { t.Helper() store := state.Store{Dir: filepath.Join(mainDir, ".git", "baton")} - add(t, store, state.Feature{Slug: slug, Flow: now}) + add(t, store, state.Feature{Slug: slug, Worktree: mainDir, Flow: now}) return store } diff --git a/internal/cli/hook_test.go b/internal/cli/hook_test.go index 887ef5c..37f19b0 100644 --- a/internal/cli/hook_test.go +++ b/internal/cli/hook_test.go @@ -38,6 +38,8 @@ const ( // waits for it, and may stop once it holds every feature with its work picked up, since those // wait on the user. The hook changes nothing in baton's folder. func TestStopHookSendsBackACopilotThatStopsTooSoon(t *testing.T) { + withGate(t, "true") + ready := flow.State{Holder: flow.RoleLead, Status: flow.StatusRunning, Round: 2, Cap: 3} leadPicks := func(t *testing.T, _ state.Store, linked string) { waitAs(t, linked, flow.RoleLead) } leadAsks := func(t *testing.T, _ state.Store, linked string) { diff --git a/internal/cli/locate.go b/internal/cli/locate.go index f214715..7fe7c6c 100644 --- a/internal/cli/locate.go +++ b/internal/cli/locate.go @@ -57,9 +57,9 @@ func openStore(ctx context.Context, dir string) (state.Store, error) { // 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() + configDir, err := userConfigDir() if err != nil { - return config.Settings{}, fmt.Errorf("find the config folder: %w", err) + return config.Settings{}, err } remote, err := repo.Remote(ctx, dir) @@ -69,3 +69,14 @@ func loadSettings(ctx context.Context, dir string) (config.Settings, error) { return config.Load(configDir, remote) } + +// userConfigDir returns the user's config folder, which holds baton's, with the config file +// (decision 49). +func userConfigDir() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("find the config folder: %w", err) + } + + return dir, nil +} diff --git a/internal/cli/open.go b/internal/cli/open.go index eb8cf17..ad53dde 100644 --- a/internal/cli/open.go +++ b/internal/cli/open.go @@ -53,14 +53,15 @@ 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). 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). +// a mistake in the file shows here instead (decision 49), as does a missing gate, without which no +// feature starts (decision 56). 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 { + if _, err := gatedSettings(ctx, mainDir); err != nil { return err } diff --git a/internal/cli/open_refusals_test.go b/internal/cli/open_refusals_test.go new file mode 100644 index 0000000..2db0d8f --- /dev/null +++ b/internal/cli/open_refusals_test.go @@ -0,0 +1,96 @@ +package cli_test + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" +) + +// TestStartRefuses opens the orchestrator where baton can't, and checks that baton start stops at +// 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) + + outside := t.TempDir() + t.Setenv("GIT_CEILING_DIRECTORIES", filepath.Dir(outside)) + + elsewhere := filepath.Join(filepath.Dir(outside), "main") + inTmux := map[string]string{"TMUX": "/private/tmp/tmux-501/default,1234,0"} + running := fakeTmux{dirs: map[string]string{"baton-main": mainDir}} + failing := errors.New("exec format error") + + for _, test := range []opening{ + { + name: "in a role's pane", dir: linked, vars: paneOf("lead"), + want: refused("run it in your own shell, not in a pane that baton started: " + + "BATON_ROLE names the lead"), + }, + { + name: "in the orchestrator's pane", dir: mainDir, vars: orchestratorPane(), + want: refused("run it in your own shell, not in a pane that baton started: " + + "BATON_ROLE names the orchestrator"), + }, + { + name: "with an argument", args: []string{"start", "now"}, dir: linked, + want: refused(`unexpected argument "now"`), + }, + { + name: "outside a git repo", dir: outside, + want: refused("find the main worktree: fatal: not a git repository " + + "(or any of the parent directories): .git"), + }, + { + name: "with another repo's session", dir: linked, + server: fakeTmux{dirs: map[string]string{"baton-main": elsewhere}}, + want: refused("tmux session baton-main runs in " + elsewhere + ", not in this repo, " + + mainDir + ": baton names a repo's session after its folder"), + }, + { + 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: "without a gate", dir: linked, + setup: func(t *testing.T) { writeConfig(t, `{"roundCap": 2}`) }, + want: refused(strings.TrimSuffix(noGate(t), "\n")), + }, + { + name: "when tmux can't make the session", dir: linked, + server: fakeTmux{making: func(context.Context) error { + return errors.New("tmux new-session: duplicate session: baton-main") + }}, + want: refused("tmux new-session: duplicate session: baton-main"), + made: orchestratorIn("baton-main", mainDir), + }, + { + name: "without tmux", dir: linked, server: running, + setup: func(t *testing.T) { onlyGit(t) }, + want: refused("attach to tmux session baton-main: " + + `exec: "tmux": executable file not found in $PATH`), + }, + { + name: "when tmux can't attach", dir: linked, server: running, exec: failing, + want: refused("attach to tmux session baton-main: exec format error"), + line: attachLine(program, "baton-main"), + }, + { + name: "when tmux can't switch the client", dir: linked, vars: inTmux, + server: fakeTmux{dirs: running.dirs, switching: errors.New( + "tmux switch-client: no current client")}, + want: refused("tmux switch-client: no current client"), + shown: []string{"baton-main"}, + }, + } { + t.Run(test.name, func(t *testing.T) { wantOpening(t, test) }) + } +} diff --git a/internal/cli/open_test.go b/internal/cli/open_test.go index f3f9d6b..ed9e6bc 100644 --- a/internal/cli/open_test.go +++ b/internal/cli/open_test.go @@ -1,8 +1,6 @@ package cli_test import ( - "context" - "errors" "os" "os/exec" "path/filepath" @@ -35,8 +33,10 @@ type opening struct { // 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. It // reads the config file only to make the session, so a mistake there keeps no one from a session -// that runs. +// that runs. It has a home folder of its own, for the config file. func TestStartOpensTheOrchestrator(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + program := onPath(t, "tmux") mainDir, linked := repos(t) dotted := repoNamed(t, "my.repo") @@ -86,88 +86,6 @@ func TestStartOpensTheOrchestrator(t *testing.T) { } } -// TestStartRefuses opens the orchestrator where baton can't, and checks that baton start stops at -// 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) - - outside := t.TempDir() - t.Setenv("GIT_CEILING_DIRECTORIES", filepath.Dir(outside)) - - elsewhere := filepath.Join(filepath.Dir(outside), "main") - inTmux := map[string]string{"TMUX": "/private/tmp/tmux-501/default,1234,0"} - running := fakeTmux{dirs: map[string]string{"baton-main": mainDir}} - failing := errors.New("exec format error") - - for _, test := range []opening{ - { - name: "in a role's pane", dir: linked, vars: paneOf("lead"), - want: refused("run it in your own shell, not in a pane that baton started: " + - "BATON_ROLE names the lead"), - }, - { - name: "in the orchestrator's pane", dir: mainDir, vars: orchestratorPane(), - want: refused("run it in your own shell, not in a pane that baton started: " + - "BATON_ROLE names the orchestrator"), - }, - { - name: "with an argument", args: []string{"start", "now"}, dir: linked, - want: refused(`unexpected argument "now"`), - }, - { - name: "outside a git repo", dir: outside, - want: refused("find the main worktree: fatal: not a git repository " + - "(or any of the parent directories): .git"), - }, - { - name: "with another repo's session", dir: linked, - server: fakeTmux{dirs: map[string]string{"baton-main": elsewhere}}, - want: refused("tmux session baton-main runs in " + elsewhere + ", not in this repo, " + - mainDir + ": baton names a repo's session after its folder"), - }, - { - 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 { - return errors.New("tmux new-session: duplicate session: baton-main") - }}, - want: refused("tmux new-session: duplicate session: baton-main"), - made: orchestratorIn("baton-main", mainDir), - }, - { - name: "without tmux", dir: linked, server: running, - setup: func(t *testing.T) { onlyGit(t) }, - want: refused("attach to tmux session baton-main: " + - `exec: "tmux": executable file not found in $PATH`), - }, - { - name: "when tmux can't attach", dir: linked, server: running, exec: failing, - want: refused("attach to tmux session baton-main: exec format error"), - line: attachLine(program, "baton-main"), - }, - { - name: "when tmux can't switch the client", dir: linked, vars: inTmux, - server: fakeTmux{dirs: running.dirs, switching: errors.New( - "tmux switch-client: no current client")}, - want: refused("tmux switch-client: no current client"), - shown: []string{"baton-main"}, - }, - } { - t.Run(test.name, func(t *testing.T) { wantOpening(t, test) }) - } -} - // onlyGit leaves the user's git alone on the PATH, until the test ends. func onlyGit(t *testing.T) { t.Helper() @@ -211,6 +129,8 @@ func refused(reason string) result { func wantOpening(t *testing.T, test opening) { t.Helper() + writeConfig(t, `{"gate": "true"}`) + if test.setup != nil { test.setup(t) } diff --git a/internal/cli/orchestrator_refusals_test.go b/internal/cli/orchestrator_refusals_test.go index 2d4ce93..6aaf6b6 100644 --- a/internal/cli/orchestrator_refusals_test.go +++ b/internal/cli/orchestrator_refusals_test.go @@ -17,6 +17,8 @@ import ( // end, and writes nothing: without features, and once the orchestrator holds them all. A broken // feature fails the wait too. func TestOrchestratorsWaitRefusesWhenNoFeatureRuns(t *testing.T) { + withGate(t, "true") + synctest.Test(t, func(t *testing.T) { mainDir, _ := repos(t) refused := result{code: 1, stderr: noneRuns} diff --git a/internal/cli/orchestrator_test.go b/internal/cli/orchestrator_test.go index 592d1cc..3313bee 100644 --- a/internal/cli/orchestrator_test.go +++ b/internal/cli/orchestrator_test.go @@ -19,6 +19,8 @@ const noneRuns = "baton wait: no feature is running, so no work can come to the // then pick up, in the order of their slugs, the work of one that the lead reports ready and of // one that the reviewer escalates. The third runs on, so the next wait waits for it. func TestOrchestratorPicksUpItsWorkInAnyFeature(t *testing.T) { + withGate(t, "true") + synctest.Test(t, func(t *testing.T) { mainDir, linked := repos(t) store := started(t, mainDir, flow.State{ @@ -80,7 +82,7 @@ func TestOrchestratorPicksUpItsWorkInAnyFeature(t *testing.T) { wantJournal(t, store, []logged{ {"start orchestrator 01-orchestrator-to-lead.md", "Build the demo.\n"}, - {"handover lead 02-lead-to-orchestrator.md", "From the lead.\n"}, + {"handover lead 02-lead-to-orchestrator.md (gate: true)", "From the lead.\n"}, {"pickup orchestrator ", ""}, }) }) @@ -89,6 +91,8 @@ func TestOrchestratorPicksUpItsWorkInAnyFeature(t *testing.T) { // TestOrchestratorsWorkGoesToOneWait has two waits of the orchestrator race for the work of the // one feature. The other wait then refuses, since no feature runs. func TestOrchestratorsWorkGoesToOneWait(t *testing.T) { + withGate(t, "true") + synctest.Test(t, func(t *testing.T) { mainDir, linked := repos(t) store := started(t, mainDir, flow.State{ @@ -121,10 +125,13 @@ func TestOrchestratorsWorkGoesToOneWait(t *testing.T) { // and a ready one to the lead with a new brief, which starts the rounds again. The next role picks // up the orchestrator's note. func TestOrchestratorHandsFeaturesBack(t *testing.T) { + withGate(t, "true") + cases := []struct { name string now flow.State // Where the feature stands before the handover to the orchestrator. verdict flow.Verdict // The verdict in that handover, if any. + gate string // What the journal logs of the gate that the handover ran, if any. to flow.Role handed string // What the handover prints, after "Handed demo-1 over to ". picked string // What the next role's wait prints, after "Picked up demo-1 as ". @@ -133,6 +140,7 @@ func TestOrchestratorHandsFeaturesBack(t *testing.T) { "an escalated feature to the engineer", flow.State{Holder: flow.RoleReviewer, Status: flow.StatusRunning, Round: 3, Cap: 3}, flow.VerdictReject, + "", flow.RoleEngineer, "engineer: running, round 4 of 4.", "engineer: round 4 of 4, from orchestrator.", @@ -141,6 +149,7 @@ func TestOrchestratorHandsFeaturesBack(t *testing.T) { "a ready feature to the lead", flow.State{Holder: flow.RoleLead, Status: flow.StatusRunning, Round: 2, Cap: 3}, flow.VerdictNone, + " (gate: true)", flow.RoleLead, "lead: running, round 0 of 3.", "lead: round 0 of 3, from orchestrator.", @@ -172,7 +181,7 @@ func TestOrchestratorHandsFeaturesBack(t *testing.T) { }, { "handover " + holder + " " + string(test.verdict) + " 02-" + holder + - "-to-orchestrator.md", + "-to-orchestrator.md" + test.gate, "From the " + holder + ".\n", }, {"handover orchestrator " + note, "The user's word.\n"}, diff --git a/internal/cli/settings_test.go b/internal/cli/settings_test.go index 42fe9f8..58789f4 100644 --- a/internal/cli/settings_test.go +++ b/internal/cli/settings_test.go @@ -88,6 +88,7 @@ func TestPaneGivesTheOrchestratorItsSettings(t *testing.T) { func TestStartFeatureTakesItsSettings(t *testing.T) { t.Setenv("HOME", t.TempDir()) writeConfig(t, `{ + "gate": "true", "roundCap": 5, "repos": {"github.com/codedogapp/demo": {"branchPrefix": "team/"}} }`) diff --git a/internal/cli/start.go b/internal/cli/start.go index 86a7606..34d75b0 100644 --- a/internal/cli/start.go +++ b/internal/cli/start.go @@ -54,7 +54,8 @@ 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). The config file gives its round cap and its branch's prefix (decision 49). +// (decision 41). The config file gives its round cap and its branch's prefix (decision 49), and +// must give the repo's gate, which the feature's handovers run (decision 56). func newLaunch(ctx context.Context, env Env, slug string) (launch, error) { self, err := env.Executable() if err != nil { @@ -72,7 +73,7 @@ func newLaunch(ctx context.Context, env Env, slug string) (launch, error) { var settings config.Settings if err == nil { - settings, err = loadSettings(ctx, mainDir) + settings, err = gatedSettings(ctx, mainDir) } if err != nil { diff --git a/internal/cli/start_refusals_test.go b/internal/cli/start_refusals_test.go index b038347..93d9a68 100644 --- a/internal/cli/start_refusals_test.go +++ b/internal/cli/start_refusals_test.go @@ -83,6 +83,12 @@ func TestStartFeatureRefuses(t *testing.T) { setup: func(t *testing.T) { writeConfig(t, `{"roundCap": -1}`) }, want: "config file " + configPath(t) + ": roundCap -1: want 1 or more\n", }, + { + name: "without a gate", + args: []string{"demo-2", "--brief", "brief.md"}, + setup: func(t *testing.T) { writeConfig(t, `{"roundCap": 2}`) }, + want: noGate(t), + }, { 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 " + @@ -98,6 +104,8 @@ func TestStartFeatureRefuses(t *testing.T) { for _, test := range cases { t.Run(test.name, func(t *testing.T) { + writeConfig(t, `{"gate": "true"}`) + if test.setup != nil { test.setup(t) } diff --git a/internal/cli/start_test.go b/internal/cli/start_test.go index 6592b3e..00a7a88 100644 --- a/internal/cli/start_test.go +++ b/internal/cli/start_test.go @@ -96,6 +96,8 @@ func wantSameRepo(t *testing.T, mainDir, before string) { // TestStartFeature starts a feature in the orchestrator's pane, from a linked worktree whose // HEAD isn't the main worktree's. func TestStartFeature(t *testing.T) { + withGate(t, "true") + mainDir, linked := repos(t) gitIn(t, linked, exec.CommandContext(t.Context(), "git", "commit", "--quiet", "--allow-empty", "--message", "Not the base")) @@ -151,6 +153,8 @@ func TestStartFeature(t *testing.T) { // TestStartFeatureNamesItsPartsAfterTheRepo starts a feature outside any pane, in a repo whose // name holds a dot, which a tmux session's name can't hold. func TestStartFeatureNamesItsPartsAfterTheRepo(t *testing.T) { + withGate(t, "true") + mainDir := repoNamed(t, "my.repo") brief := write(t, t.TempDir(), "brief.md", "Build the demo.\n") diff --git a/internal/cli/start_undo_test.go b/internal/cli/start_undo_test.go index 01a85da..4d66d0e 100644 --- a/internal/cli/start_undo_test.go +++ b/internal/cli/start_undo_test.go @@ -15,6 +15,8 @@ import ( // baton undoes the parts that it made: git lists the worktrees and branches that it listed before, // and baton's folder holds only the feature's lock, which stays (decision 40). func TestStartFeatureUndoesWhatItMade(t *testing.T) { + withGate(t, "true") + cases := []struct { name string branch bool // Whether the feature's branch exists before. @@ -80,6 +82,8 @@ func TestStartFeatureUndoesWhatItMade(t *testing.T) { // TestStartFeatureReportsWhatItCantUndo makes a part of the feature fail, and then its undo, and // checks that baton reports both. func TestStartFeatureReportsWhatItCantUndo(t *testing.T) { + withGate(t, "true") + private, readOnly := os.FileMode(0o700), os.FileMode(0o500) // locks makes the folders read-only until the test ends. diff --git a/internal/config/check.go b/internal/config/check.go index 7487640..2f84057 100644 --- a/internal/config/check.go +++ b/internal/config/check.go @@ -43,8 +43,12 @@ func check(top Settings, repos map[string]Settings) error { // check returns an error for the first setting that baton can't use, with the roles in the order // of their names. func (s Settings) check() error { - if s.RoundCap < 0 { + switch { + case s.RoundCap < 0: return fmt.Errorf("roundCap %d: want 1 or more", s.RoundCap) + case s.Gate != "" && strings.TrimSpace(s.Gate) == "", strings.HasPrefix(s.Gate, "-"): + // sh would read a dash at the start as one of its options. + return fmt.Errorf("gate %q: want the command that checks the repo's code", s.Gate) } for _, role := range slices.Sorted(maps.Keys(s.Roles)) { diff --git a/internal/config/config.go b/internal/config/config.go index d81be43..ac6cf0e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -41,6 +41,10 @@ type Settings struct { // PlanApproval makes the user approve each feature start in the orchestrator's pane (decision // 8). A nil one keeps the one below it, so that false can turn it off. PlanApproval *bool `json:"planApproval"` + // Gate is the command that checks the repo's code: /bin/sh runs it in the feature's worktree + // before each handover that it guards. baton has none of its own, and a feature can't start + // without one (decision 56). + Gate string `json:"gate"` } // PlanApprovalOn reports whether the user approves each feature start in the orchestrator's pane. @@ -79,11 +83,21 @@ func defaults() Settings { } } +// Folder returns baton's folder in the user's config folder, which holds the config file. +func Folder(configDir string) string { + return filepath.Join(configDir, folder) +} + +// File returns the path of the config file in the user's config folder. +func File(configDir string) string { + return filepath.Join(Folder(configDir), file) +} + // Load reads the config file in the user's config folder, and returns the settings for the repo // whose remote has the URL. A missing file gives baton's own. It checks the whole file, the other // repos' sections too, so that a mistake shows at once. func Load(configDir, remote string) (Settings, error) { - path := filepath.Join(configDir, folder, file) + path := File(configDir) data, err := os.ReadFile(filepath.Clean(path)) if errors.Is(err, fs.ErrNotExist) { @@ -129,6 +143,7 @@ func (s Settings) with(layer Settings) Settings { RoundCap: cmp.Or(layer.RoundCap, s.RoundCap), BranchPrefix: cmp.Or(layer.BranchPrefix, s.BranchPrefix), PlanApproval: cmp.Or(layer.PlanApproval, s.PlanApproval), + Gate: cmp.Or(layer.Gate, s.Gate), } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 95395fa..fcfa153 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -22,6 +22,7 @@ func TestLoadGivesBatonsOwnWithoutSettings(t *testing.T) { "without the file": withFolder(t), "with no settings": withFile(t, `{}`), "with empty sections": withFile(t, `{"roles": {}, "repos": {"`+repo+`": {}}}`), + "with an empty gate": withFile(t, `{"gate": ""}`), } { t.Run(name, func(t *testing.T) { got, err := config.Load(dir, remote) @@ -40,6 +41,7 @@ func TestLoadLaysTheFileOverBatonsOwn(t *testing.T) { got := load(t, `{ "roundCap": 2, "branchPrefix": "team/", + "gate": "mise run check", "roles": { "lead": {"model": "", "reasoningEffort": "high"}, "qa": {"model": "claude-sonnet-5.5", "maxAiCredits": 42.5}, @@ -50,6 +52,7 @@ func TestLoadLaysTheFileOverBatonsOwn(t *testing.T) { want := own() want.RoundCap = 2 want.BranchPrefix = "team/" + want.Gate = "mise run check" want.Roles[flow.RoleLead] = config.Role{Model: model, ReasoningEffort: "high"} want.Roles[flow.RoleQA] = config.Role{Model: "claude-sonnet-5.5", MaxAICredits: 42.5} want.Roles[flow.RoleOrchestrator] = config.Role{Model: model, ExtraTools: []string{}} @@ -63,6 +66,7 @@ func TestLoadLaysTheRepoOverTheFile(t *testing.T) { const content = `{ "roundCap": 2, "branchPrefix": "team/", + "gate": "mise run check", "roles": { "lead": {"model": "claude-sonnet-5.5", "reasoningEffort": "high"}, "engineer": {"maxAiCredits": 30}, @@ -73,6 +77,7 @@ func TestLoadLaysTheRepoOverTheFile(t *testing.T) { "` + repo + `": { "roundCap": 4, "branchPrefix": "baton/", + "gate": "make check", "roles": { "lead": {"model": "gpt-6-sol", "reasoningEffort": "low"}, "engineer": {"model": "gpt-6-sol", "maxAiCredits": 0}, @@ -87,6 +92,7 @@ func TestLoadLaysTheRepoOverTheFile(t *testing.T) { file := own() file.RoundCap = 2 file.BranchPrefix = "team/" + file.Gate = "mise run check" file.Roles[flow.RoleLead] = config.Role{Model: "claude-sonnet-5.5", ReasoningEffort: "high"} file.Roles[flow.RoleEngineer] = config.Role{Model: model, MaxAICredits: 30} file.Roles[flow.RoleQA] = config.Role{Model: model, MaxAICredits: 42.5} @@ -95,6 +101,7 @@ func TestLoadLaysTheRepoOverTheFile(t *testing.T) { repoOwn := own() repoOwn.RoundCap = 4 repoOwn.BranchPrefix = "baton/" + repoOwn.Gate = "make check" repoOwn.Roles[flow.RoleLead] = config.Role{Model: "gpt-6-sol", ReasoningEffort: "low"} repoOwn.Roles[flow.RoleEngineer] = config.Role{Model: "gpt-6-sol", MaxAICredits: 30} repoOwn.Roles[flow.RoleQA] = config.Role{Model: model, MaxAICredits: 200} diff --git a/internal/config/refusals_test.go b/internal/config/refusals_test.go index 5349d4e..29b22a4 100644 --- a/internal/config/refusals_test.go +++ b/internal/config/refusals_test.go @@ -10,7 +10,12 @@ import ( func TestLoadRefusesSettingsThatBatonCantUse(t *testing.T) { for content, want := range map[string]string{ - `{"roundCap": -1}`: "roundCap -1: want 1 or more", + `{"roundCap": -1}`: "roundCap -1: want 1 or more", + `{"gate": " "}`: `gate " ": want the command that checks the repo's code`, + `{"gate": "\t\n"}`: `gate "\t\n": want the command that checks the repo's code`, + `{"gate": "-c true"}`: `gate "-c true": want the command that checks the repo's code`, + `{"repos": {"github.com/a/a": {"gate": "--version"}}}`: "repos: github.com/a/a: " + + `gate "--version": want the command that checks the repo's code`, `{"roles": {"boss": {}}}`: "roles: boss: want lead, engineer, qa, reviewer or orchestrator", `{"roles": {"lead": {"model": "--yolo"}}}`: `roles: lead: model "--yolo": want a model's name`, `{"roles": {"qa": {"reasoningEffort": "extreme"}}}`: `roles: qa: reasoningEffort "extreme": ` + diff --git a/internal/flow/flow_test.go b/internal/flow/flow_test.go index a7a7052..aaaf202 100644 --- a/internal/flow/flow_test.go +++ b/internal/flow/flow_test.go @@ -174,3 +174,36 @@ func TestNextFollowsTheFlow(t *testing.T) { func state(holder flow.Role, status flow.Status, round, extra int) flow.State { return flow.State{Holder: holder, Status: status, Round: round, Cap: 3, Extra: extra} } + +// TestGatedNamesTheHandoversThatVouchForTheCode checks the state after each handover: the +// engineer's, QA's approval and the lead's final report wait for the repo's gate, and no other. +func TestGatedNamesTheHandoversThatVouchForTheCode(t *testing.T) { + extra := append(escalated(), "orchestrator to engineer") + + tests := []struct { + name string + names []string + want bool + }{ + {"the start", nil, false}, + {"the lead's brief", approved()[:1], false}, + {"the engineer's handover", approved()[:2], true}, + {"QA's approval", approved()[:3], true}, + {"QA's rejection", escalated()[:3], false}, + {"the reviewer's approval", approved(), false}, + {"the reviewer's rejection", escalated()[:6], false}, + {"the lead's final report", append(approved(), "lead"), true}, + {"an escalation", escalated(), false}, + {"the orchestrator's extra round", extra, false}, + {"the engineer's handover in the extra round", append(extra, "engineer"), true}, + {"the orchestrator's new brief", append(approved(), "lead", "orchestrator to lead"), false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := walk(t, 3, test.names...).Gated(); got != test.want { + t.Errorf("after %q: Gated() = %t, want %t", test.names, got, test.want) + } + }) + } +} diff --git a/internal/flow/state.go b/internal/flow/state.go index 5de0302..b7a5824 100644 --- a/internal/flow/state.go +++ b/internal/flow/state.go @@ -46,6 +46,14 @@ func Start(roundCap int) (State, error) { return state, nil } +// Gated reports whether the handover that makes the state waits for the repo's gate to pass: the +// engineer's, which gives the feature to QA, QA's approval, which gives it to the reviewer, and the +// lead's final report, which makes it ready. The roles that make them vouch for the code (decision +// 56). +func (s State) Gated() bool { + return s.Holder == RoleQA || s.Holder == RoleReviewer || s.Status == StatusReady +} + // Validate returns an error if Start and Next can't make the state, as when it comes from a // damaged file. func (s State) Validate() error { diff --git a/internal/gate/gate.go b/internal/gate/gate.go new file mode 100644 index 0000000..3c2a01e --- /dev/null +++ b/internal/gate/gate.go @@ -0,0 +1,21 @@ +// Package gate runs a repo's gate: the command in baton's config file that checks the repo's code +// before a role hands a feature on (decision 56). +package gate + +import ( + "context" + "io" + "os/exec" +) + +// Run runs the command with sh in the folder, with nothing on its input and both its output and +// its errors on the writer, until it ends or the context does, and returns an error if it fails. +func Run(ctx context.Context, dir, command string, out io.Writer) error { + // gosec's G204 wants constant arguments, but the gate is a command line by design: the user's + // own, from the config file, which a role's file tools can't write (decision 56). + //nolint:gosec // G204: the gate is the user's own command line, from the config file. + cmd := exec.CommandContext(ctx, "/bin/sh", "-c", command) + cmd.Dir, cmd.Stdout, cmd.Stderr = dir, out, out + + return cmd.Run() +} diff --git a/internal/gate/gate_test.go b/internal/gate/gate_test.go new file mode 100644 index 0000000..e9875c2 --- /dev/null +++ b/internal/gate/gate_test.go @@ -0,0 +1,62 @@ +package gate_test + +import ( + "bytes" + "context" + "errors" + "io" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/codedogapp/baton/internal/gate" +) + +// TestRunRunsTheCommandInTheFolder runs a command line with sh in the folder, which shows both its +// output and its errors, in the order that it writes them. +func TestRunRunsTheCommandInTheFolder(t *testing.T) { + dir := t.TempDir() + + var out bytes.Buffer + if err := gate.Run(t.Context(), dir, `echo "in ${PWD##*/}"; echo err >&2; echo out`, + &out); err != nil { + t.Fatal(err) + } + + if got, want := out.String(), "in "+filepath.Base(dir)+"\nerr\nout\n"; got != want { + t.Errorf("the gate wrote %q; want %q", got, want) + } +} + +// TestRunReportsAFailure returns the command's exit status when it fails, after its output. +func TestRunReportsAFailure(t *testing.T) { + var out bytes.Buffer + + err := gate.Run(t.Context(), t.TempDir(), "echo broken >&2; exit 3", &out) + + if exit, ok := errors.AsType[*exec.ExitError](err); !ok || exit.ExitCode() != 3 { + t.Errorf("Run returned %v; want exit status 3", err) + } + + if got := out.String(); got != "broken\n" { + t.Errorf("the gate wrote %q; want %q", got, "broken\n") + } +} + +// TestRunEndsWithTheContext stops a command that runs on once the context ends. +func TestRunEndsWithTheContext(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer cancel() + + start := time.Now() + err := gate.Run(ctx, t.TempDir(), "exec sleep 30", io.Discard) + + if took := time.Since(start); took > 10*time.Second { + t.Errorf("Run took %s", took) + } + + if err == nil || err.Error() != "signal: killed" { + t.Errorf("Run returned %v; want signal: killed", err) + } +} diff --git a/internal/plugin/files/com.github.copilot/agents/engineer.agent.md b/internal/plugin/files/com.github.copilot/agents/engineer.agent.md index 320a40e..11ab0ab 100644 --- a/internal/plugin/files/com.github.copilot/agents/engineer.agent.md +++ b/internal/plugin/files/com.github.copilot/agents/engineer.agent.md @@ -17,7 +17,7 @@ Your note says what you changed and why, the files, the checks you ran and their ## Handing over -When your part is done, write your note for the next role in a new file in your session's `files/` folder: never in the worktree, where it could get committed, or in baton's folder. Then run `baton handover --note `: baton checks the move, keeps a copy of the note and picks who works next. If it refuses, fix what it names and run it again. +When your part is done, write your note for the next role in a new file in your session's `files/` folder: never in the worktree, where it could get committed, or in baton's folder. Then run `baton handover --note `: baton checks the move, runs the repo's gate (the user's command that checks the code) in the feature's worktree, keeps a copy of the note and picks who works next. The gate can take minutes: let the handover end, and read what it printed. If it refuses, or the gate fails, fix what it names and run it again. Then start `baton wait` with the bash tool in async mode, and end your turn. It ends when your next work comes, and its end wakes you: read what it printed, and carry on. Never run it in sync mode, and never end a turn without it running. diff --git a/internal/plugin/files/com.github.copilot/agents/lead.agent.md b/internal/plugin/files/com.github.copilot/agents/lead.agent.md index 0fb330e..88bbb96 100644 --- a/internal/plugin/files/com.github.copilot/agents/lead.agent.md +++ b/internal/plugin/files/com.github.copilot/agents/lead.agent.md @@ -14,7 +14,7 @@ baton hands you each piece of work as a note. Your first prompt names it; after ## Handing over -When your part is done, write your note for the next role in a new file in your session's `files/` folder: never in the worktree, where it could get committed, or in baton's folder. Then run `baton handover --note `: baton checks the move, keeps a copy of the note and picks who works next. If it refuses, fix what it names and run it again. +When your part is done, write your note for the next role in a new file in your session's `files/` folder: never in the worktree, where it could get committed, or in baton's folder. Then run `baton handover --note `: baton checks the move, keeps a copy of the note and picks who works next. Before your final report, it runs the repo's gate (the user's command that checks the code) in the feature's worktree, and refuses while the gate fails. The gate can take minutes: let the handover end, and read what it printed. If the gate fails, fix what broke and commit the fix, or ask the orchestrator when the fix isn't yours to make. If baton refuses for another reason, fix what it names and run it again. Then start `baton wait` with the bash tool in async mode, and end your turn. It ends when your next work comes, and its end wakes you: read what it printed, and carry on. Never run it in sync mode, and never end a turn without it running. diff --git a/internal/plugin/files/com.github.copilot/agents/qa.agent.md b/internal/plugin/files/com.github.copilot/agents/qa.agent.md index 7150c74..b8dc928 100644 --- a/internal/plugin/files/com.github.copilot/agents/qa.agent.md +++ b/internal/plugin/files/com.github.copilot/agents/qa.agent.md @@ -15,7 +15,7 @@ Your note gives the evidence: each criterion and how you checked it, the command ## Handing over -When your part is done, write your note for the next role in a new file in your session's `files/` folder: never in the worktree, where it could get committed, or in baton's folder. Then run `baton handover --note --verdict approve`, or `--verdict reject` if you have a blocking finding: baton checks the move, keeps a copy of the note and picks who works next. If it refuses, fix what it names and run it again. +When your part is done, write your note for the next role in a new file in your session's `files/` folder: never in the worktree, where it could get committed, or in baton's folder. Then run `baton handover --note --verdict approve`, or `--verdict reject` if you have a blocking finding: baton checks the move, keeps a copy of the note and picks who works next. Before an approval, it runs the repo's gate (the user's command that checks the code) in the feature's worktree, and refuses while the gate fails. The gate can take minutes: let the handover end, and read what it printed. If the gate fails, reject, with its failures as a blocking finding. If baton refuses for another reason, fix what it names and run it again. Then start `baton wait` with the bash tool in async mode, and end your turn. It ends when your next work comes, and its end wakes you: read what it printed, and carry on. Never run it in sync mode, and never end a turn without it running. diff --git a/internal/state/journal.go b/internal/state/journal.go index aa42244..1cd7160 100644 --- a/internal/state/journal.go +++ b/internal/state/journal.go @@ -50,6 +50,7 @@ type Event struct { Verdict flow.Verdict `json:"verdict,omitzero"` Note string `json:"note,omitzero"` // The name of the note in the notes folder. Text string `json:"text,omitempty"` // The question, or the answer. + Gate string `json:"gate,omitempty"` // The gate that passed before the handover. Flow flow.State `json:"flow"` // Where the feature stands after the event. } diff --git a/main_test.go b/main_test.go index f257686..5d0f5c4 100644 --- a/main_test.go +++ b/main_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json/v2" "errors" "fmt" "os" @@ -10,6 +11,8 @@ import ( "strings" "testing" "time" + + "github.com/codedogapp/baton/internal/config" ) // asBaton is the variable that has this test binary run baton's main in place of the tests. The @@ -64,8 +67,10 @@ func TestMain(m *testing.M) { // (testdata/copilot). The test, as the orchestrator, starts the feature with its brief, and its // panes run this baton. The lead briefs the engineer, the engineer adds demo.txt, QA and the // reviewer approve it, and the lead commits it and hands the final report to the orchestrator, -// whose baton wait picks it up. Each stand-in runs the plugin's stop hook at the end of its turns: -// the hook sends it back to work before its handover, and lets it stop once its baton wait waits. +// whose baton wait picks it up. The engineer's handover, QA's approval and the lead's final report +// run the gate in the feature's worktree. Each stand-in runs the plugin's stop hook at the end of +// its turns: the hook sends it back to work before its handover, and lets it stop once its baton +// wait waits. func TestSkeleton(t *testing.T) { root := skeleton(t) feature := filepath.Join(root, "repo", ".git", "baton", "features", "demo-1") @@ -93,13 +98,16 @@ func TestSkeleton(t *testing.T) { {"pickup lead ", ""}, {"handover lead 02-lead-to-engineer.md", "Step brief: add demo.txt.\n"}, {"pickup engineer ", ""}, - {"handover engineer 03-engineer-to-qa.md", "Added demo.txt.\n"}, + {"handover engineer 03-engineer-to-qa.md (gate: " + gate + ")", "Added demo.txt.\n"}, {"pickup qa ", ""}, - {"handover qa approve 04-qa-to-reviewer.md", "Checked demo.txt.\n"}, + {"handover qa approve 04-qa-to-reviewer.md (gate: " + gate + ")", "Checked demo.txt.\n"}, {"pickup reviewer ", ""}, {"handover reviewer approve 05-reviewer-to-lead.md", "Reviewed demo.txt.\n"}, {"pickup lead ", ""}, - {"handover lead 06-lead-to-orchestrator.md", "Final report: committed demo.txt.\n"}, + { + "handover lead 06-lead-to-orchestrator.md (gate: " + gate + ")", + "Final report: committed demo.txt.\n", + }, {"pickup orchestrator ", ""}, }) wantFile(t, filepath.Join(root, "copilot.log"), @@ -107,14 +115,17 @@ func TestSkeleton(t *testing.T) { "baton:lead 1 stop: block\n"+ "baton:engineer 1: Picked up demo-1 as engineer: round 1 of 3, from lead.\n"+ "baton:engineer 1 stop: block\n"+ + "gate engineer in repo-demo-1\n"+ "baton:qa 1: Picked up demo-1 as qa: round 1 of 3, from engineer.\n"+ "baton:qa 1 stop: block\n"+ + "gate qa in repo-demo-1\n"+ "baton:reviewer 1: Picked up demo-1 as reviewer: round 1 of 3, from qa, "+ "verdict approve.\n"+ "baton:reviewer 1 stop: block\n"+ "baton:lead 2: Picked up demo-1 as lead: round 1 of 3, from reviewer, "+ "verdict approve.\n"+ - "baton:lead 2 stop: block\n") + "baton:lead 2 stop: block\n"+ + "gate lead in repo-demo-1\n") wantOut(t, exec.CommandContext(t.Context(), "git", "log", "--format=%s", "main..feature/demo-1"), "Add demo.txt\n") @@ -123,3 +134,30 @@ func TestSkeleton(t *testing.T) { wantOut(t, exec.CommandContext(t.Context(), "git", "-C", "../repo-demo-1", "status", "--porcelain"), "") } + +// gate is the gate in the skeleton's config file: it checks that demo.txt is there, and logs the +// role whose handover runs it, and the folder where it runs. +const gate = `test -f demo.txt && printf 'gate %s in %s\n' "$BATON_ROLE" "${PWD##*/}" >>"$SKELETON_LOG"` + +// configure writes baton's config file, with the gate, in the user's config folder. +func configure(t *testing.T) { + t.Helper() + + dir, err := os.UserConfigDir() + if err != nil { + t.Fatal(err) + } + + text, err := json.Marshal(map[string]string{"gate": gate}) + if err != nil { + t.Fatal(err) + } + + if err := os.MkdirAll(config.Folder(dir), 0o700); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(config.File(dir), text, 0o600); err != nil { + t.Fatal(err) + } +}