diff --git a/AGENTS.md b/AGENTS.md index d4e7c88..42554d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,7 @@ Never report a step as done before `mise run check` has passed, and say which ga - Repo: [`codedogapp/baton`](https://github.com/codedogapp/baton), public. Default branch `main`. - One step, one branch (`step/`, ids from plan §0) and one PR. The lead squash-merges once CI is green, with an explicit subject and body: `gh pr merge --squash --subject "…" --body "…"`. +- Renovate's pull requests: the lead squash-merges a green one once its release notes show no breaking change and its pins match their tags, and asks the user about a red one or a major update (the user's OK of 2026-11-03). - Commit messages and PR titles follow Conventional Commits (`feat(flow): …`, `fix: …`, `build: …`, `ci: …`, `docs: …`): an imperative subject of 72 characters or fewer, and a body that says why when it isn't obvious. - **No AI attribution.** Never add `Co-authored-by` trailers for Copilot or any other AI agent. - Never write close, fix or resolve next to an issue or PR number unless the PR is meant to close it. diff --git a/docs/plan.md b/docs/plan.md index dbb47ea..537e6e1 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -48,8 +48,9 @@ The first milestone is a walking skeleton (`skeleton`): one feature on a throwaw | `ask` | `baton ask` and `baton answer `: the holder's question goes to the orchestrator, whose `baton wait` picks it up, and the answer comes back to the role's `baton ask`; one question at a time, in the feature's file, and no handover while it waits; the roles' prompts say how to ask, and the orchestrator's how to answer (decisions 18, 37 and 46) | Done (#23) | | `stop-hook` | `baton hook stop` and its `agentStop` hook in the plugin, which runs the pane's baton through `BATON_BIN`: `baton wait` and `baton ask` mark their role as waiting while they wait, and a pane's `copilot` that ends its turn without that mark gets one more turn, which tells it to start `baton wait`, to ask its question again, or to finish its part and hand over; the orchestrator, too, while a role holds a feature or work waits for it (decision 47) | Done (#24) | | `live-run` | A live run on a throwaway repo with a cheap model; the findings in a docs PR (facts 26 to 30) | Done (#25) | -| `notes` | `baton handover` refuses a note in baton's folder, and `baton feature start` a brief, and the prompts send the notes and briefs to the session's `files/` folder and the roles' commands to the feature's worktree (decision 48; fact 29, risk 11) | In review (#26) | -| `config` | The config file: the model per role, the round cap, the gate command, plan approval, the pull request host and base branch, per repo (decision 16) | Todo | +| `notes` | `baton handover` refuses a note in baton's folder, and `baton feature start` a brief, and the prompts send the notes and briefs to the session's `files/` folder and the roles' commands to the feature's worktree (decision 48; fact 29, risk 11) | Done (#26) | +| `config` | `internal/config`: baton's config file, with each role's model, reasoning effort and AI credits cap, the orchestrator's extra tools, the round cap and the branch prefix, for every repo and per repo, by the remote's URL, over baton's own; the whole file checked when read (decisions 16 and 49, fact 31) | In review (#29) | +| `config-cli` | `baton pane` and `baton feature start` take their settings from the config, for the repo of the `origin` remote's URL: each role's model, reasoning effort and AI credits cap, and the orchestrator's extra tools, in place of the pane's model and nudge's tools, and the round cap and the branch prefix in place of the start's (decision 49); the README's config section | Todo | | `guard` | `baton hook guard` and its `preToolUse` hook in the plugin: role panes can't make outward actions; the orchestrator's pane asks the user for them and can't edit the code (decisions 17 and 19) | Todo | | `gates` | The engineer's handover runs the repo's gate command and refuses a red one | Todo | | `jira` | `baton jira `: the ticket as text, from Jira's REST API with a read-only token in the Keychain (decision 6) | Todo | @@ -119,6 +120,7 @@ Checked on 2026-10-31 with Copilot CLI 1.0.105 and tmux 3.8, unless a fact says 28. **Deny rules beat `--allow-all`.** Under `--allow-all`, `--deny-tool='shell(git push)'` and `--deny-tool='shell(gh:*)'` refused `git push origin HEAD` and `gh --version` with "Permission to run this tool was denied due to the following rules: `shell(git push)`" (or `shell(gh:*)`), which the model saw. For git and gh, a rule goes by the subcommand (`git push`, `gh pr create`), so `shell(git push)` takes any arguments, and `shell(:*)` matches the command's stem; `write()` matches the tools that write files, but not the shell's redirections; `url(…)` applies to the shell and to `web_fetch` (`copilot help permissions`). 29. **What the roles did on their own.** In both rounds, the reviewer wrote its note in baton's notes folder (`/baton/features//notes/`), which is outside the worktree, as its prompt asks, and named it as baton names the next note; baton stored its own copy as the note after that, and the reviewer's file stayed beside it. The other roles wrote their notes in their session's folder (`$COPILOT_HOME/session-state//files/`). In the first round, the reviewer ran `git status` and `git diff` in the main worktree, whose path the note's path shows, before it ran them in the feature's. QA named its notes `…-qa-to-lead.md`, and baton stored them, by the flow, as `…-qa-to-reviewer.md`. The lead's commits end with `Co-authored-by: Copilot <…>`, the trailer that Copilot CLI asks for unless told otherwise; the lead's prompt asks only for the repo's commit style. Asked by the brief to try a `git push`, the lead refused, citing its prompt. 30. **Live checks on a Mac.** `copilot` keeps its login in the login keychain, which macOS finds through `HOME`: under another `HOME`, `copilot` isn't logged in. A throwaway `COPILOT_HOME` is enough to keep its config and sessions apart; its package cache (`~/Library/Caches/copilot`) follows `HOME`. A pane that a command-line client makes (`new-session`, `split-window`, `respawn-pane`) gets that client's `PATH` in place of the session's (tmux's `spawn.c`): the role panes that `baton feature start` makes find what its shell finds, and a command typed in another shell carries that shell's `PATH` into a live check's panes. A pane's command runs with its shell's `-c`; a pane without one starts a login shell, whose `path_helper` (from `/etc/profile` or `/etc/zprofile`) puts `/etc/paths` and `/etc/paths.d` (Homebrew's `/opt/homebrew/bin`) before the rest of `PATH`. +31. **A role's flags** (on 2026-11-03, from `copilot --help` and the errors of a `copilot` given a bad flag, which it refuses before it starts a session): `--model `, a model's name or `auto`; `--reasoning-effort `, one of `none`, `minimal`, `low`, `medium`, `high`, `xhigh` and `max`; `--max-ai-credits `, a number, fractions too, whose least value, 30 (fact 10), the CLI checks only after it has read its flags. A value that starts with a dash reads as a flag: `--max-ai-credits -1` fails on an unexpected argument `-1`. ## 3. Design @@ -186,7 +188,14 @@ It keeps copilot's own prompts, since the user watches that pane, `ask_user`, si Every change takes an exclusive lock on the feature (`flock` on its `lock` file, which the kernel releases when the process ends), writes a new file and renames it over the old one, so a reader, who takes no lock, sees the old state or the new one. Every change logs its event, and keeps its note if it has one, in the same hold of the lock (decision 26). -**Config** (decision 16). `baton` reads `config.json` in its folder under `os.UserConfigDir()` (`~/Library/Application Support/baton/` on a Mac): defaults, plus a section per repo, keyed by the remote's URL, so nothing goes into a work repo. The settings: the model and reasoning effort per role, an AI credits cap per role, the round cap (3), plan approval (on), the gate command, the branch prefix, the base branch (by default the remote's default branch), the pull request host (by default from the remote's URL), and Jira's site and email. Tokens never go into the file: they live in the login Keychain. +**Config** (decisions 16 and 49). `baton` reads `config.json` in its folder under `os.UserConfigDir()` (`~/Library/Application Support/baton/` on a Mac), so nothing goes into a work repo. Its top holds the settings for every repo, and `repos` a section per repo, keyed by the remote's URL without its scheme, user, port and `.git`; the repo's section lies over the top, and the top over baton's own settings, one setting at a time. The settings so far: per role (`roles`), the model, the reasoning effort and an AI credits cap, and the orchestrator's extra tools (nudge's, by default); the round cap (3); the branch prefix (`feature/`). The later steps add theirs: the gate command (`gates`), plan approval, on by default (`guard`), the pull request host, by default from the remote's URL, and the base branch, by default the remote's default branch (`deliver-github`), and Jira's site and email (`jira`). Tokens never go into the file: they live in the login Keychain. For example, QA on another model for every repo, and two rounds with another branch prefix for one repo: + +```json +{ + "roles": {"qa": {"model": "claude-sonnet-5.5", "maxAiCredits": 60}}, + "repos": {"github.com/codedogapp/baton": {"roundCap": 2, "branchPrefix": "feat/"}} +} +``` **Jira** (decision 6). `baton jira DEMO-1` prints the ticket's summary, type, status, description (Atlassian Document Format turned into Markdown), subtasks, links and comments. It reads the token from the Keychain with `/usr/bin/security`, and the site's `cloudId` from the site. @@ -238,7 +247,7 @@ Decisions marked **lead's call** are the lead's, for the user's end review. The 38. **`baton pane` becomes the role's `copilot`** (lead's call). It waits as baton, then replaces itself with `copilot` (`execve`), so `copilot` gets the pane's terminal and signals as they are, and its environment with `COPILOT_ALLOW_ALL=true` (decision 39): Ctrl-C reaches `copilot` alone, and the pane ends when `copilot` does (fact 25). A child process would share the terminal with baton, which would then have to ignore Ctrl-C while `copilot` runs, since baton's second Ctrl-C ends it at once (decision 28). Before it waits, `baton pane` finds `copilot` and writes the plugin, so a pane that can't start `copilot` never picks up work that nobody would then do. It picks up the work as `baton wait` does, under the feature's lock, and gives `copilot` the text that `baton wait` prints as its first prompt. While it waits, it reads the terminal and drops what the user types, so it stops when the terminal's input ends, as on Ctrl-D, or when the terminal is gone, even without a hangup (facts 22 and 25). A `copilot` that fails to start leaves the work picked up, for the `resume` step. The orchestrator's pane doesn't wait: its work comes through its own `baton wait`, so `baton pane` becomes its `copilot` at once (decision 45). `main` passes `syscall.Exec` in, so gosec sees no command to flag, and `copilot`'s start carries no `//nolint:gosec` (decision 34). Every role gets Claude Opus 5.5 until the `config` step (decision 7), and `copilot` gets no `--session-id` until the `cost` step, which keeps it. 39. **A role's `copilot` trusts its worktree** (lead's call, flagged; fact 9). `baton pane` gives `copilot` its environment with `COPILOT_ALLOW_ALL=true`, in place of any value of the user's, which `copilot` would read first. That value also trusts the folder that `copilot` starts in, the feature's worktree, for that session alone, and saves nothing. Without it, a worktree outside the folders that the user trusts would stop the role on copilot's "Confirm folder trust" dialog, in a pane nobody watches (decision 9). The worktree holds the user's own repo, whose skills, plugins, MCP servers and hooks then load, as they do in the main worktree once the user trusts it; the tool allow-list still keeps their tools from the role (decision 10). The variable reaches the role's shells too, so a `copilot` that a role starts trusts its own folder as well; the `guard` step can deny that. Rejected: copilot's dialog; `--add-dir`, which doesn't trust the folder; `--trust-folder`, which needs `-p`; adding each worktree to the user's trusted folders, which would change `~/.copilot/` (decision 11). 40. **A failed start leaves nothing behind** (lead's call). `baton feature start` makes the feature first, which claims its slug, then its branch and worktree, then its tmux session, and when a part fails, it undoes the parts it made. `repo.Worktree.Undo` removes the worktree, with any changes in it, and deletes the branch, as `Add` does when it fails (decision 35). `state.Store.Remove` takes the feature's lock and removes the feature's file, which ends the feature, then its event log and its notes. It keeps the lock file, which baton never replaces, so a process that waits for the lock finds no feature once it gets it, and a new feature can take the slug and start afresh. A crash in between can leave the old event log and notes, which a new feature with that slug would then keep. -41. **A feature's names come from its slug and the repo's main worktree** (lead's call). Its branch is `feature/`, from the main worktree's `HEAD`, until the `config` step brings the branch prefix and the base branch, by default the remote's default branch (decision 16). Its worktree is `-`, next to the main worktree, `` being the main worktree's folder (decision 35), and its tmux session is `baton--` (decision 14), with an underscore for each character of `` that a session's name can't hold, such as a dot (decision 32). So two repos whose folders have the same name, or names that differ only in those characters (`my.repo` and `my_repo`), can't run features with the same slug at once: tmux refuses the second session, and baton undoes that start (decision 40). The feature's file keeps the branch and the worktree, for the later steps to read rather than work out again; they sit at the top of the file, so they keep the format (decision 24). +41. **A feature's names come from its slug and the repo's main worktree** (lead's call). Its branch is `feature/`, from the main worktree's `HEAD`, until `config-cli` brings the branch prefix (decision 49), and `deliver-github` the base branch, by default the remote's default branch (decision 16). Its worktree is `-`, next to the main worktree, `` being the main worktree's folder (decision 35), and its tmux session is `baton--` (decision 14), with an underscore for each character of `` that a session's name can't hold, such as a dot (decision 32). So two repos whose folders have the same name, or names that differ only in those characters (`my.repo` and `my_repo`), can't run features with the same slug at once: tmux refuses the second session, and baton undoes that start (decision 40). The feature's file keeps the branch and the worktree, for the later steps to read rather than work out again; they sit at the top of the file, so they keep the format (decision 24). 42. **`baton feature start` runs in the orchestrator's pane or the user's shell, and the panes run the baton that started the feature** (lead's call). It refuses a role's pane, by `BATON_ROLE` (decision 29): roles don't start features. Each pane's command is that baton's own path (`os.Executable`), not `baton` from the tmux server's `PATH`, which the panes get and which may find another baton, or none, so a feature's panes run the baton that started it. On a Mac, that path is the one that started baton, such as Homebrew's link; on Linux, it has its symlinks resolved. baton stops on a hangup (SIGHUP) as on Ctrl-C, so a terminal or pane that closes mid-start undoes the start, the branch included, which git alone would keep (fact 23). Under `nohup`, which starts baton with the hangup ignored, baton leaves it ignored, and so does git, but not for its hooks: a hangup while a hook of the user's runs fails the start, and baton undoes it. The order leaves two races. The lead's pane picks the brief up as soon as the session has that pane, before tmux has made the others, and its `copilot` starts; if tmux then fails, baton ends the session and removes the feature all the same. And Ctrl-C during `new-session` itself may leave the session (decision 32), with the lead's pane, which may have picked the brief up before baton removed the feature, and then works in a worktree that's gone, until the user ends the session. 43. **The walking skeleton runs this test binary as baton, with a stand-in `copilot` in each pane** (lead's call). `TestSkeleton`, in the root package, puts two links first on the `PATH`: `baton`, to the test binary, which runs baton's `main` in place of the tests when `BATON_TEST_AS_BATON` is set, and `copilot`, to `testdata/copilot`, a shell script that plays its pane's role through one round with set notes and verdicts and logs the work it gets. So the test builds nothing and calls no model, and it runs what the user runs: `main`, `baton feature start`, each pane's `baton pane`, and the roles' `baton handover` and `baton wait`. It checks what the orchestrator's commands print, the panes' titles and commands, the feature's journal and notes, the work that each pane got, and the commit. baton uses tmux's default socket, so the test, as a smoke run does, gives tmux a `TMUX_TMPDIR` of its own, under `/tmp`, since a Mac allows a socket's path 104 bytes at most, and starts that server first, with `-f /dev/null`; it also gets a `HOME` and a `TMPDIR` of its own. The server's first pane ends the server if the test binary dies before its cleanup, as when mise stops the tests because another task failed. The `live-run` step tries the real `copilot`. 44. **The orchestrator's pane names no feature** (lead's call). The orchestrator works on all the repo's features, so its pane has `BATON_ROLE` and no `BATON_FEATURE`, and baton refuses an orchestrator's pane that names a feature, and a role's pane that names none (decision 29). The orchestrator's `baton wait` looks at every feature, every second, in the order of their slugs: it picks up the work of the first that it holds, ready or escalated, that it hasn't picked up yet, under that feature's lock, as a role's wait does (decision 31), and prints it with the feature's status; in a feature that a role holds, its work is the role's question (decision 46). It refuses when no work could come, since its wait would never end: when there's no feature, or when it holds them all. So when the user starts a feature from their own shell, the orchestrator learns of it from `baton status`, and its next `baton wait` covers it. Its `baton handover` names the feature and the next role, `baton handover --to --note `: the lead, with a new brief, which starts the rounds again, or, after an escalation, the engineer, for one more round (decision 13). A role's handover takes neither: its pane names the feature, and baton picks the next role. @@ -246,6 +255,7 @@ Decisions marked **lead's call** are the lead's, for the user's end review. The 46. **A role's question waits in its feature's file, one at a time** (lead's call, flagged). The holder's last question sits at the top of `feature.json`, so it keeps the format (decision 24): the role that asked, the text, whether the orchestrator's `baton wait` picked it up, and the answer once it comes. Not in `questions/.json`, as planned: a question belongs to its feature, so it takes the feature's lock and goes in its journal, with its text, as every change does (decision 26), and the slug names it, so `baton answer ` needs no ID. Only the holder asks, since only the holder works; the orchestrator asks the user. A role asks one question at a time, and baton refuses its handover until the answer comes, since the answer goes to the role that asked, which should still hold the feature when it acts on it. `baton ask` stores the question under the lock, then reads the file every second without it, as any reader may, until the answer comes. An ask with the text of the role's last question doesn't ask it again: it waits for the answer, or prints it if it came, so a role whose ask ended early, in a crash or a restart, gets the answer by asking again, and the orchestrator doesn't get the question twice. The holder's handover clears its question, which the journal keeps, so the same question in a later turn is a new one; the cost is that a role that means to ask the same question twice in one turn gets the first answer. If another question takes the place of an ask's question before the ask reads the answer, as two asks at once could, that ask fails rather than print an answer to another question, and so does an ask whose question a handover cleared before it read the answer. The orchestrator's `baton wait` picks up a question in any feature that a role holds, in the order of the slugs, as it picks up its work (decision 44): it marks the question picked under the lock and logs a pickup, so its next wait doesn't print it again; a question that it picked up but didn't answer, as when its pane stopped, waits for the `resume` step to bring it back. `baton answer` runs only in the orchestrator's pane, while a question waits for its answer. The texts are the commands' arguments, each one whole and not blank, so the prompts tell the roles and the orchestrator to quote them for the shell. 47. **A stop hook sends back a `copilot` that stops while nothing of its role waits** (lead's call, flagged; facts 4 and 5). The plugin's `agentStop` hook runs `"$BATON_BIN" hook stop`, and `baton pane` gives each pane's `copilot` `BATON_BIN`, the path of its own baton, in place of any value of the user's, so the hook runs the baton that started the pane, not one from the `PATH` (decision 42). `baton wait` and `baton ask`, and `baton pane` while it waits, mark their role as waiting: a shared `flock` on `.wait` in the feature's folder, or on `orchestrator.wait` in baton's folder, files that baton never removes, as it never removes a lock (decision 40). The kernel releases the mark when its process ends, so a wait that crashed leaves none, and two waits of one role share it. A wait takes the mark only when its first look finds nothing, so a wait that refuses, or picks its work up at once, writes nothing; that look takes milliseconds, and a model's turn far longer, so the hook sees the mark of a wait that the turn started. `baton hook stop` lets the `copilot` stop while its role's mark is held, since the wait's end wakes it (fact 5). Otherwise it sends it back, with a reason that becomes its next prompt: to start `baton wait` when work can come to it, because another role holds the feature or its work waits there; to ask its question again when that waits for its answer, since baton doesn't ask it twice (decision 46); and otherwise to finish its part and hand over, or just to end its turn when a command of its own runs in the background. The orchestrator gets the first reason while a role holds a feature or its work waits in one, and may stop once it holds every feature with its work picked up, since those wait on the user, or when there is none. The hook changes nothing in baton's folder, and lets the `copilot` stop when its block has sent this turn back already (`stop_hook_active`), so a `copilot` that can't do what the reason says stops one turn later; that turn is also the cost for a role that stops to wait for a command of its own. It finds the repo from the `copilot`'s folder in its input, or from its own folder without one. A hook that fails lets the `copilot` stop (fact 4), so baton reports why on stderr and exits with 1, and copilot logs it. When two hooks look at one mark at the same moment, one can take the other's look for a wait and let its `copilot` stop, as a hook that fails does; only two `copilot`s of one role could do that. Rejected: looking for a `baton wait` in the process table, which needs `ps` and can't tell one pane's wait from another's; a PID file, which a crash leaves behind; and a mark that every wait takes at its start, which would write files for waits that refuse. 48. **Only baton writes in its folder** (lead's call, flagged; fact 29, risk 11). `baton handover` refuses a note, and `baton feature start` a brief, that lies in baton's folder, once a relative path is made absolute from the working folder, so that the check and the read look at the same file, and the symlinks of both paths are resolved; it says to move the file to the session's `files/` folder, and the refusal changes nothing. In the live run the reviewer wrote its notes there, named as baton names the next one: a stray file there misleads whoever reads the notes, and baton numbers a new note after the folder's highest one (decision 26), so a stray shifts the numbers. The prompts send each role's notes, and the orchestrator's briefs and notes, to its session's `files/` folder (`$COPILOT_HOME/session-state//files/`), which Copilot CLI names in each session's context, never to the repo's worktrees, where they could get committed, or to baton's folder. They also tell the roles that the folder of the notes that baton hands them is baton's, and to run git and their other commands in the feature's worktree, where their pane starts, since the notes' paths run through the main worktree. So decision 19's guard lets the orchestrator `edit` and `create` only in its session's folder, no longer in baton's. On a disk that ignores case, as a Mac's does by default, a path that spells baton's folder in other case gets through, and the refusal stops only the note that a handover names: the guard's `preToolUse` hook can keep the file tools out of baton's folder (risk 11). baton doesn't refuse a note in a worktree, which only risks a commit, and doesn't remove a stray that is there already. +49. **One config file, in layers** (lead's call, flagged; decision 16, fact 31). `config.json` in baton's folder under the user's config folder holds the settings for every repo at its top, and each repo's section in `repos`, keyed by the remote's URL as its host and path: `git@github.com:codedogapp/baton.git`, `https://github.com/codedogapp/baton` and `ssh://git@github.com:22/codedogapp/baton.git` all give `github.com/codedogapp/baton`, so a repo's HTTPS and SSH clones share a section; a remote that is a local path keeps its path. baton refuses a key in another form, and says the form; a key keeps the URL's case. The repo's section lies over the top, and the top over baton's own settings, one setting at a time: a setting that a layer leaves out, or gives its zero value, keeps the one below, so a `roundCap` or a `maxAiCredits` of 0 keeps the layer's below, and the branch prefix can't be empty; the orchestrator's `extraTools`, a list, replaces the list below whole, and `[]` gives none. baton's own: Claude Opus 5.5 for every role (decision 7), copilot's own reasoning effort, no cap, nudge's two tools for the orchestrator (decision 45), three rounds (decision 13) and `feature/` (decision 41). baton reads and checks the whole file, the other repos' sections too, so that a mistake shows at once, with the file's path, and not in a `copilot` that it stops: it refuses a member that it doesn't know (names match case: `roundCap`, not `RoundCap`), a member given twice, a role that it doesn't know, a model or a tool that starts with a dash, which would read as one of copilot's flags, a reasoning effort that copilot doesn't take (CLI 1.0.105's, so a new level needs a new baton), a cap below copilot's least, 30 (fact 10), a negative round cap, and extra tools for any role but the orchestrator (decision 10). A missing file gives baton's own settings. The step comes in two: `config` reads the file, and `config-cli` gives its settings to `baton pane` and `baton feature start`, for the `origin` remote's URL; the other settings come with their steps. ## 5. Risks @@ -253,7 +263,7 @@ Decisions marked **lead's call** are the lead's, for the user's end review. The 2. **The guard matches text** (decision 9): a role can reach the network in ways no pattern catches (a script, another tool). It is a seatbelt, not a sandbox. Taking the role panes' credentials away (an empty `GH_CONFIG_DIR`, a git `pushInsteadOf` that fails) would add a second belt; `guard` checks whether that works. 3. **`--allow-all` lets a role read and write any path** on the Mac. Roles work in their worktree by their instructions, and the guard watches outward actions only. 4. **A hook that times out allows** (fact 4): the guard must answer within its timeout and never wait on anything. -5. **AI credits**: four Opus sessions per feature, three of them in every round, plus the orchestrator. The per-role cap (`--max-ai-credits`, decision 16) and `baton status` keep it visible; the round cap bounds it. +5. **AI credits**: four Opus sessions per feature, three of them in every round, plus the orchestrator. The per-role cap (`maxAiCredits` in the config, for `--max-ai-credits`, decision 49) and `baton status` keep it visible; the round cap bounds it. 6. **The user's own instructions apply in every pane**: Copilot CLI loads the user's and the repo's custom instructions, which may disagree with a role's prompt. 7. **The CLI changes fast**: flags, tool names and event fields can change. The facts carry the version they were checked with; `live-run` saw the ones that the flow uses hold with the real flow (facts 26 and 27). 8. **Processes outlive an upgrade** (fact 17): a running `baton wait` or `baton pane` keeps the old binary, so the feature's file carries its format, and an older baton refuses a newer format and keeps the fields it doesn't know (decision 24). diff --git a/internal/config/check.go b/internal/config/check.go new file mode 100644 index 0000000..7487640 --- /dev/null +++ b/internal/config/check.go @@ -0,0 +1,112 @@ +package config + +import ( + "errors" + "fmt" + "maps" + "net/url" + "slices" + "strings" + + "github.com/codedogapp/baton/internal/flow" +) + +// efforts are the reasoning efforts that copilot takes (fact 31). +var efforts = []string{"none", "minimal", "low", "medium", "high", "xhigh", "max"} + +// minCredits is the least AI credits cap that copilot takes (fact 10). +const minCredits = 30 + +// check returns an error for the first setting in the file that baton can't use, with the repos' +// sections in the order of their keys. +func check(top Settings, repos map[string]Settings) error { + if err := top.check(); err != nil { + return err + } + + for _, name := range slices.Sorted(maps.Keys(repos)) { + switch { + case name == "": + return errors.New("repos: an empty key: want a remote's URL") + case key(name) != name: + return fmt.Errorf("repos: %q: want %q, the URL's host and path", name, key(name)) + } + + if err := repos[name].check(); err != nil { + return fmt.Errorf("repos: %s: %w", name, err) + } + } + + return nil +} + +// 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 { + return fmt.Errorf("roundCap %d: want 1 or more", s.RoundCap) + } + + for _, role := range slices.Sorted(maps.Keys(s.Roles)) { + if err := s.Roles[role].check(role); err != nil { + return fmt.Errorf("roles: %s: %w", role, err) + } + } + + return nil +} + +// check returns an error for the first of the role's settings that baton can't use. A value that +// starts with a dash would read as one of copilot's flags. +func (r Role) check(role flow.Role) error { + switch { + case !role.Known(): + return errors.New("want lead, engineer, qa, reviewer or orchestrator") + case strings.HasPrefix(r.Model, "-"): + return fmt.Errorf("model %q: want a model's name", r.Model) + case r.ReasoningEffort != "" && !slices.Contains(efforts, r.ReasoningEffort): + return fmt.Errorf("reasoningEffort %q: want one of %s", r.ReasoningEffort, + strings.Join(efforts, ", ")) + case r.MaxAICredits != 0 && r.MaxAICredits < minCredits: + return fmt.Errorf("maxAiCredits %g: want %d or more, or 0 for no cap", r.MaxAICredits, + minCredits) + } + + return checkTools(role, r.ExtraTools) +} + +// checkTools returns an error for extra tools that the role can't have: only the orchestrator gets +// tools of the user's (decision 10). +func checkTools(role flow.Role, tools []string) error { + if tools != nil && role != flow.RoleOrchestrator { + return errors.New("extraTools: only the orchestrator gets tools of the user's") + } + + for _, tool := range tools { + if tool == "" || strings.HasPrefix(tool, "-") { + return fmt.Errorf("extraTools: %q: want a tool's name", tool) + } + } + + return nil +} + +// key returns a repo's key in the config file, from its remote's URL: the URL's host and path, +// without its scheme, user and port, or a last slash or .git, so that a repo's HTTPS and SSH URLs +// give the same key, such as github.com/codedogapp/baton. A local path stays a path. +func key(remote string) string { + name := remote + + if parsed, err := url.Parse(remote); err == nil && strings.Contains(remote, "://") { + name = parsed.Hostname() + parsed.Path + } else if host, path, ok := strings.Cut(remote, ":"); ok && !strings.Contains(host, "/") { + // git reads [user@]host:path as an SSH URL, when no slash comes before the colon. + if _, after, found := strings.CutLast(host, "@"); found { + host = after + } + + name = host + "/" + strings.TrimLeft(path, "/") + } + + return strings.TrimSuffix(strings.TrimSuffix(name, "/"), ".git") +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..5f2fc80 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,135 @@ +// Package config reads baton's config file, config.json in baton's folder in the user's config +// folder: the settings for every repo, and a section for each repo, keyed by its remote's URL, so +// that nothing goes into a work repo (decision 16). +package config + +import ( + "cmp" + "encoding/json/v2" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + + "github.com/codedogapp/baton/internal/flow" +) + +// The config file's folder in the user's config folder, and its name there. +const ( + folder = "baton" + file = "config.json" +) + +// baton's own settings, where the config file gives none (decisions 7, 13 and 41). +const ( + model = "claude-opus-5.5" + roundCap = 3 + branchPrefix = "feature/" +) + +// Settings are baton's settings for a repo. A setting that a layer leaves out, or gives its zero +// value, keeps the one below it: the repo's section lies over the file's settings for every repo, +// and those over baton's own (decision 49). +type Settings struct { + // Roles holds each role's settings for its copilot. + Roles map[flow.Role]Role `json:"roles"` + // RoundCap is the engineer's passes that a brief gets (decision 13). + RoundCap int `json:"roundCap"` + // BranchPrefix comes before the slug in the name of a feature's branch (decision 41). + BranchPrefix string `json:"branchPrefix"` +} + +// Role is a role's settings for its copilot, each one a flag of copilot's (fact 31). +type Role struct { + Model string `json:"model"` + // ReasoningEffort is empty for copilot's own. + ReasoningEffort string `json:"reasoningEffort"` + // MaxAICredits caps the AI credits that the role's session spends: 30 or more, or 0 for no + // cap (fact 10). + MaxAICredits float64 `json:"maxAiCredits"` + // ExtraTools are the orchestrator's tools besides baton's, the user's own, such as nudge's + // for when the user is away. A nil list keeps the one below it, and an empty one gives none. + ExtraTools []string `json:"extraTools"` +} + +// defaults returns baton's own settings: every role on the same model, and the orchestrator with +// the user's nudge tools (decision 45). +func defaults() Settings { + roles := map[flow.Role]Role{flow.RoleOrchestrator: { + Model: model, ExtraTools: []string{"nudge-ask", "nudge-report"}, + }} + + for _, role := range []flow.Role{ + flow.RoleLead, flow.RoleEngineer, flow.RoleQA, flow.RoleReviewer, + } { + roles[role] = Role{Model: model} + } + + return Settings{Roles: roles, RoundCap: roundCap, BranchPrefix: branchPrefix} +} + +// 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) + + data, err := os.ReadFile(filepath.Clean(path)) + if errors.Is(err, fs.ErrNotExist) { + return defaults(), nil + } + + if err != nil { + return Settings{}, fmt.Errorf("read the config file: %w", err) + } + + var content layers + + err = json.Unmarshal(data, &content, json.RejectUnknownMembers(true)) + if err == nil { + err = check(content.Settings, content.Repos) + } + + if err != nil { + return Settings{}, fmt.Errorf("config file %s: %w", path, err) + } + + return defaults().with(content.Settings).with(content.Repos[key(remote)]), nil +} + +// layers are the config file's content: its settings for every repo, and each repo's section, by +// the repo's key. +type layers struct { + Settings + + Repos map[string]Settings `json:"repos"` +} + +// with returns the settings with the layer over them. The settings below hold every role, and the +// layer, once checked, no other. +func (s Settings) with(layer Settings) Settings { + roles := make(map[flow.Role]Role, len(s.Roles)) + for role, below := range s.Roles { + roles[role] = below.with(layer.Roles[role]) + } + + return Settings{ + Roles: roles, + RoundCap: cmp.Or(layer.RoundCap, s.RoundCap), + BranchPrefix: cmp.Or(layer.BranchPrefix, s.BranchPrefix), + } +} + +// with returns the role's settings with the layer over them. +func (r Role) with(layer Role) Role { + if layer.ExtraTools != nil { + r.ExtraTools = layer.ExtraTools + } + + r.Model = cmp.Or(layer.Model, r.Model) + r.ReasoningEffort = cmp.Or(layer.ReasoningEffort, r.ReasoningEffort) + r.MaxAICredits = cmp.Or(layer.MaxAICredits, r.MaxAICredits) + + return r +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..1caac10 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,219 @@ +package config_test + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/codedogapp/baton/internal/config" + "github.com/codedogapp/baton/internal/flow" +) + +// The remote of the repo that the tests load the settings for, and its key in the config file. +const ( + remote = "git@github.com:codedogapp/baton.git" + repo = "github.com/codedogapp/baton" +) + +func TestLoadGivesBatonsOwnWithoutSettings(t *testing.T) { + for name, dir := range map[string]string{ + "without baton's folder": t.TempDir(), + "without the file": withFolder(t), + "with no settings": withFile(t, `{}`), + "with empty sections": withFile(t, `{"roles": {}, "repos": {"`+repo+`": {}}}`), + } { + t.Run(name, func(t *testing.T) { + got, err := config.Load(dir, remote) + if err != nil { + t.Fatal(err) + } + + if want := own(); !reflect.DeepEqual(got, want) { + t.Errorf("Load gives\n%+v\nwant baton's own\n%+v", got, want) + } + }) + } +} + +func TestLoadLaysTheFileOverBatonsOwn(t *testing.T) { + got := load(t, `{ + "roundCap": 2, + "branchPrefix": "team/", + "roles": { + "lead": {"model": "", "reasoningEffort": "high"}, + "qa": {"model": "claude-sonnet-5.5", "maxAiCredits": 42.5}, + "orchestrator": {"extraTools": []} + } + }`, remote) + + want := own() + want.RoundCap = 2 + want.BranchPrefix = "team/" + 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{}} + + if !reflect.DeepEqual(got, want) { + t.Errorf("Load gives\n%+v\nwant\n%+v", got, want) + } +} + +func TestLoadLaysTheRepoOverTheFile(t *testing.T) { + const content = `{ + "roundCap": 2, + "branchPrefix": "team/", + "roles": { + "lead": {"model": "claude-sonnet-5.5", "reasoningEffort": "high"}, + "engineer": {"maxAiCredits": 30}, + "qa": {"maxAiCredits": 42.5}, + "orchestrator": {"extraTools": ["nudge-ask"]} + }, + "repos": { + "` + repo + `": { + "roundCap": 4, + "branchPrefix": "baton/", + "roles": { + "lead": {"model": "gpt-6-sol", "reasoningEffort": "low"}, + "engineer": {"model": "gpt-6-sol", "maxAiCredits": 0}, + "qa": {"maxAiCredits": 200}, + "orchestrator": {"extraTools": []} + } + }, + "github.com/codedogapp/nudge": {} + } + }` + + file := own() + file.RoundCap = 2 + file.BranchPrefix = "team/" + 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} + file.Roles[flow.RoleOrchestrator] = config.Role{Model: model, ExtraTools: []string{"nudge-ask"}} + + repoOwn := own() + repoOwn.RoundCap = 4 + repoOwn.BranchPrefix = "baton/" + 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} + repoOwn.Roles[flow.RoleOrchestrator] = config.Role{Model: model, ExtraTools: []string{}} + + for name, test := range map[string]struct { + remote string + want config.Settings + }{ + "with its section": {remote: remote, want: repoOwn}, + "with an empty section": {remote: "https://github.com/codedogapp/nudge", want: file}, + "without a section": {remote: "https://github.com/codedogapp/tap", want: file}, + "without a remote at all": {remote: "", want: file}, + } { + t.Run(name, func(t *testing.T) { + if got := load(t, content, test.remote); !reflect.DeepEqual(got, test.want) { + t.Errorf("Load gives\n%+v\nwant\n%+v", got, test.want) + } + }) + } +} + +func TestLoadFindsTheRepoByItsRemotesURL(t *testing.T) { + const content = `{"repos": { + "` + repo + `": {"roundCap": 5}, + "/srv/git/baton": {"roundCap": 6}, + "/srv/git:baton": {"roundCap": 7} + }}` + + for given, want := range map[string]int{ + "https://github.com/codedogapp/baton": 5, + "https://github.com/codedogapp/baton.git": 5, + "https://user@github.com/codedogapp/baton/": 5, + "https://github.com:443/codedogapp/baton.git": 5, + "ssh://git@github.com/codedogapp/baton.git": 5, + "ssh://git@github.com:22/codedogapp/baton": 5, + "git://github.com/codedogapp/baton.git": 5, + "git@github.com:codedogapp/baton.git": 5, + "git@github.com:/codedogapp/baton.git/": 5, + "me@example.com@github.com:codedogapp/baton.git": 5, + "github.com:codedogapp/baton": 5, + "/srv/git/baton.git": 6, + "file:///srv/git/baton/": 6, + "/srv/git:baton": 7, + "https://github.com/codedogapp/baton-x": 3, + "https://github.com/codedogapp/baton/x": 3, + "https://gitlab.com/codedogapp/baton": 3, + "git@github.com:codedogapp/baton.git.git/x": 3, + "https://github.com/codedogapp/baton.github": 3, + } { + t.Run(given, func(t *testing.T) { + if got := load(t, content, given).RoundCap; got != want { + t.Errorf("the round cap is %d, want %d", got, want) + } + }) + } +} + +// model is baton's own model for every role. +const model = "claude-opus-5.5" + +// own returns baton's own settings. +func own() config.Settings { + return config.Settings{ + Roles: map[flow.Role]config.Role{ + flow.RoleOrchestrator: { + Model: model, + ExtraTools: []string{"nudge-ask", "nudge-report"}, + }, + flow.RoleLead: {Model: model}, + flow.RoleEngineer: {Model: model}, + flow.RoleQA: {Model: model}, + flow.RoleReviewer: {Model: model}, + }, + RoundCap: 3, + BranchPrefix: "feature/", + } +} + +// load returns the settings for the remote from a config file with the content. +func load(t *testing.T, content, remote string) config.Settings { + t.Helper() + + got, err := config.Load(withFile(t, content), remote) + if err != nil { + t.Fatal(err) + } + + return got +} + +// withFile returns a config folder of the test's own, with baton's config file holding the +// content. +func withFile(t *testing.T, content string) string { + t.Helper() + + dir := withFolder(t) + + if err := os.WriteFile(filePath(dir), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + return dir +} + +// withFolder returns a config folder of the test's own, with baton's folder in it. +func withFolder(t *testing.T) string { + t.Helper() + + dir := t.TempDir() + + if err := os.Mkdir(filepath.Dir(filePath(dir)), 0o700); err != nil { + t.Fatal(err) + } + + return dir +} + +// filePath returns the path of baton's config file in the config folder. +func filePath(dir string) string { + return filepath.Join(dir, "baton", "config.json") +} diff --git a/internal/config/refusals_test.go b/internal/config/refusals_test.go new file mode 100644 index 0000000..18f8ad9 --- /dev/null +++ b/internal/config/refusals_test.go @@ -0,0 +1,98 @@ +package config_test + +import ( + "os" + "strings" + "testing" + + "github.com/codedogapp/baton/internal/config" +) + +func TestLoadRefusesSettingsThatBatonCantUse(t *testing.T) { + for content, want := range map[string]string{ + `{"roundCap": -1}`: "roundCap -1: want 1 or more", + `{"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": ` + + "want one of none, minimal, low, medium, high, xhigh, max", + `{"roles": {"engineer": {"maxAiCredits": 29.5}}}`: "roles: engineer: maxAiCredits 29.5: " + + "want 30 or more, or 0 for no cap", + `{"roles": {"engineer": {"maxAiCredits": -30}}}`: "roles: engineer: maxAiCredits -30: " + + "want 30 or more, or 0 for no cap", + `{"roles": {"reviewer": {"extraTools": []}}}`: "roles: reviewer: extraTools: " + + "only the orchestrator gets tools of the user's", + `{"roles": {"orchestrator": {"extraTools": ["nudge-ask", ""]}}}`: `roles: orchestrator: ` + + `extraTools: "": want a tool's name`, + `{"roles": {"orchestrator": {"extraTools": ["--yolo"]}}}`: `roles: orchestrator: ` + + `extraTools: "--yolo": want a tool's name`, + `{"roles": {"qa": {"model": "-q"}, "lead": {"model": "-l"}}}`: `roles: lead: model "-l": ` + + "want a model's name", + `{"repos": {"": {}}}`: "repos: an empty key: want a remote's URL", + `{"repos": {"` + remote + `": {}}}`: `repos: "` + remote + `": want "` + repo + + `", the URL's host and path`, + `{"repos": {"github.com/codedogapp/tap": {"roundCap": -2}}}`: "repos: " + + "github.com/codedogapp/tap: roundCap -2: want 1 or more", + `{"repos": {"github.com/codedogapp/tap": {"roles": {"qa": {"model": "-q"}}}}}`: "repos: " + + `github.com/codedogapp/tap: roles: qa: model "-q": want a model's name`, + `{"repos": {"github.com/b/b": {"roundCap": -2}, "github.com/a/a": {"roundCap": -1}}}`: "repos: " + + "github.com/a/a: roundCap -1: want 1 or more", + `{"roundCap": -3, "repos": {"github.com/a/a": {"roundCap": -1}}}`: "roundCap -3: " + + "want 1 or more", + } { + t.Run(want, func(t *testing.T) { + dir := withFile(t, content) + + _, err := config.Load(dir, remote) + if err == nil { + t.Fatalf("Load took %s", content) + } + + if want := "config file " + filePath(dir) + ": " + want; err.Error() != want { + t.Errorf("Load says %q, want %q", err, want) + } + }) + } +} + +func TestLoadRefusesAFileThatIsntSettings(t *testing.T) { + for content, want := range map[string]string{ + ``: "unexpected EOF", + `{`: "unexpected EOF", + `[]`: "unmarshal JSON array", + `{"roundCap": "3"}`: `within "/roundCap"`, + `{"rounds": 2}`: `unknown object member name "rounds"`, + `{"RoundCap": 2}`: `unknown object member name "RoundCap"`, + `{"roles": {"lead": {"effort": "high"}}}`: `unknown object member name "effort" within "/roles/lead"`, + `{"repos": {"github.com/a/a": {"repos": {}}}}`: `unknown object member name "repos" ` + + `within "/repos/github.com~1a~1a"`, + `{"roundCap": 2, "roundCap": 3}`: `duplicate object member name "roundCap"`, + `{"roles": {"lead": {}, "lead": {}}}`: `duplicate object member name "lead" within "/roles"`, + } { + t.Run(content, func(t *testing.T) { + dir := withFile(t, content) + + _, err := config.Load(dir, remote) + if err == nil { + t.Fatalf("Load took %q", content) + } + + if prefix := "config file " + filePath(dir) + ": "; !strings.HasPrefix(err.Error(), + prefix) || !strings.Contains(err.Error(), want) { + t.Errorf("Load says %q, want %q after %q", err, want, prefix) + } + }) + } +} + +func TestLoadFailsOnAFileThatItCantRead(t *testing.T) { + dir := withFolder(t) + + if err := os.Mkdir(filePath(dir), 0o700); err != nil { + t.Fatal(err) + } + + _, err := config.Load(dir, remote) + if want := "read the config file: "; err == nil || !strings.HasPrefix(err.Error(), want) { + t.Errorf("Load says %v, want an error after %q", err, want) + } +}