diff --git a/README.md b/README.md index a61b5d7..8fddcdf 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ You talk to one agent, the orchestrator. It reads the ticket, plans the work as - macOS, git 2.36 or newer, and tmux 3.5 or newer. - [GitHub Copilot CLI](https://docs.github.com/copilot/how-tos/copilot-cli), logged in with `copilot login`, or with a token in `COPILOT_GITHUB_TOKEN`, `GH_TOKEN` or `GITHUB_TOKEN`: `gh` doesn't work in the roles' panes, so their `copilot` can't take its login from `gh`. -- `gh` for GitHub pull requests; a Bitbucket Cloud API token for Bitbucket pull requests; a read-only Jira API token for Jira tickets. +- `gh`, logged in with `gh auth login`, for GitHub pull requests; a Bitbucket Cloud API token for Bitbucket pull requests; a read-only Jira API token for Jira tickets. ## Config @@ -35,6 +35,12 @@ For example, one gate and QA on another model with a cap, for every repo, and tw baton checks the whole file each time it reads it, and refuses a file with a mistake, naming the file and the mistake. +## Pull requests + +When a feature is ready, the orchestrator reports it to you, with a title for its pull request. On your word, it delivers the feature with `baton deliver --title ""`, which baton's guard asks you about first; you can run it too. baton pushes the feature's branch to `origin`, with your git, then opens its pull request with your `gh`, into the GitHub repo's default branch, with the lead's final report as its description, as it is: read the report first, at the path that the orchestrator gives, if you want to see what goes out. If you hand the feature back for changes, deliver it again once it's ready, without `--title`: baton pushes the new commits and comments the lead's new report on the same pull request. baton never merges. + +gh picks the GitHub repo from the repo's remotes, as `gh pr create` does, so baton doesn't handle a fork with an `upstream` remote yet. + ## Jira When you name a Jira Cloud ticket, the orchestrator reads it with `baton jira <KEY>`, such as `baton jira DEMO-1`, which prints the ticket and its comments as Markdown; you can run it too. baton only reads Jira, as you, with an API token that can only read: diff --git a/docs/plan.md b/docs/plan.md index 6eaf525..f1c2c02 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -64,15 +64,15 @@ The first milestone is a walking skeleton (`skeleton`): one feature on a throwaw | `jira-text` | `internal/jira`'s ticket: the fields of a Jira ticket that baton shows, as Jira's REST API gives them, and the ticket as Markdown to read: its key and summary, type, status and parent, then its description, subtasks, links and comments (decision 58, fact 40) | Done (#41) | | `jira-client` | `internal/jira`'s client: a ticket and all its comments from Jira Cloud's REST API, through Atlassian's gateway with the user's email and scoped token, and the site's cloud ID from the site, which gets neither (decision 59, facts 13 and 41) | Done (#42) | | `jira` | `baton jira <KEY>`: the ticket as Markdown, for the orchestrator to plan from, with Jira's site and the user's email from the config file and the read-only token from the Keychain; without the text's control characters; the guard runs it unasked in the orchestrator's pane and denies it in a role's (decisions 6 and 60, fact 42, risk 14) | Done (#43) | -| `jira-login` | `baton jira`'s answer to a login that Jira refuses (401): the command that replaces the token in the Keychain, as tokens expire within a year (decision 60, fact 42) | In review (#44) | -| `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 | +| `jira-login` | `baton jira`'s answer to a login that Jira refuses (401): the command that replaces the token in the Keychain, as tokens expire within a year (decision 60, fact 42) | Done (#44) | +| `deliver-github` | `baton deliver <slug> --title <title>`, in the orchestrator's pane or the user's shell, on the user's word: pushes a ready feature's branch to `origin` and opens its pull request with the user's `gh`, into the GitHub repo's default branch, with the lead's final report as its body; the feature becomes `delivered` and keeps the pull request's link, and a later delivery, after the user hands it back, pushes again and comments the new report on that pull request; the guard asks the user before each delivery in the orchestrator's pane, and denies it in a role's (decisions 17, 54 and 61, fact 43, risk 15) | In review (#45) | | `close` | `baton feature close <slug>`: ends the feature's session; keeps its branch and, unless asked, its worktree | Todo | | `deliver-bitbucket` | `baton deliver` for Bitbucket Cloud: push the branch and open the pull request with the REST API, a token in the Keychain | Todo | | `cost` | AI credits per role and per feature in `baton status`, from each session's events (decision 20), found by the session ID that `baton pane` gives `copilot` with `--session-id` and baton keeps | Todo | | `resume` | A pane whose `copilot` ended starts it again with `--resume`, and a pane that picked up its work but couldn't start `copilot` starts it (decision 38); the orchestrator gets again a question that its wait picked up but that has no answer yet (decision 46); the features carry on after a restart of the Mac | Todo | | `homebrew` | A release tag and a formula in `codedogapp/homebrew-tap`; the docs install baton with Homebrew (decision 5); the release carries the notices of the code built into baton, mvdan.cc/sh's BSD-3 license among them (decision 50) | Todo | -Ideas for later, not planned: a read-only page of the runs, like zeroshot's; approvals through nudge when the user is away (open question 1); other agent CLIs; Linux. +Ideas for later, not planned: a read-only page of the runs, like zeroshot's; approvals through nudge when the user is away (open question 1); other agent CLIs; Linux; for `baton deliver`, a base branch per repo, forks, and a delivery that takes over the open pull request of a feature that doesn't keep it (decision 61). ## 1. Goal and scope @@ -143,6 +143,7 @@ Checked on 2026-10-31 with Copilot CLI 1.0.105 and tmux 3.8, unless a fact says 40. **A ticket in Jira's REST API v3** (Jira Cloud's OpenAPI spec and the API's introduction, on 2026-11-04). `GET /rest/api/3/issue/<KEY>` gives the ticket's `key`, and the `fields` that its `fields` parameter names, comma-separated. A ticket's `issuetype` and `status` each hold a `name`, and its `issuelinks` hold each link's `type`, with the text for either side, such as `is blocked by` (`inward`) and `blocks` (`outward`), and the other ticket, as `inwardIssue` or `outwardIssue`, with its `key` and its `fields`, `summary` and `status` among them. The spec gives no shape for the `parent` and `subtasks` fields, which Jira's clients read as such tickets too, with a key and fields (ankitpokhrel/jira-cli's types). `GET /rest/api/3/issue/<KEY>/comment` gives a page of comments, with `startAt`, `maxResults`, 100 by default, and `total`, which can change between pages, so that a page can come empty; `orderBy=created` gives them oldest first. A comment holds its `author`, with a `displayName`, its `body` in ADF, and `created`, an ISO 8601 time in the site's time zone, such as `2021-01-17T12:34:00.000+0000`. `encoding/json/v2` matches names by their case, so baton spells Jira's: `issuetype` and `issuelinks`, not the `issueType` and `issueLinks` of some clients, which `encoding/json` matches whatever the case. 41. **A site's cloud ID** (Atlassian's support article on finding a site's cloud ID, on 2026-11-04). `https://<site>.atlassian.net/_edge/tenant_info` gives the site's cloud ID, which the gateway's addresses hold (fact 13), as one line of JSON, `{"cloudId":"<cloud ID>"}`. The article has it opened in a browser, and doesn't say whether a site answers without a login: baton sends none. The user chose to skip a live check of Jira (2026-11-04), so that stays unchecked: a site that wants one fails `baton jira` with `read the cloud ID of <site>:` and the site's answer. 42. **The Keychain's passwords through `security`** (macOS's `/usr/bin/security`, its usage text and a read of an item that isn't there, on 2026-11-04; Atlassian's article on API tokens). `security find-generic-password -s <service> -a <account> -w` prints the password of the item with that service and account, and a newline; for an item that isn't there, it exits with 44 and `security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.` `security add-generic-password -s <service> -a <account> -w`, with `-w` last, asks for the password, so it stays out of the shell's history; `-U` replaces an item that is there, which the command otherwise refuses. "By default, the application which creates an item is trusted to access its data without warning", and `-T ""` takes that trust away: so an item that `security` made, `security` reads without asking, for any program that runs it as the user. Atlassian's API tokens with scopes, which its gateway takes with the email by basic auth (fact 13), expire after 1 to 365 days, as the user picks when they create one. +43. **gh's `pr create` and `pr comment`** (gh 2.103.2's source: `pkg/cmd/pr/create/create.go`, `pkg/cmd/pr/comment/comment.go`, `pkg/cmd/pr/shared/commentable.go`, `context/context.go`, `context/remote.go` and `pkg/iostreams/iostreams.go`, on 2026-11-04). When its output isn't a terminal, gh can't prompt. `gh pr create` then prints the new pull request's URL alone on stdout, and the rest on stderr; with `--head=<branch>`, the head's repo is the base repo, and gh pushes nothing; with `--title` and `--body-file`, it asks nothing and applies no template; and without `--attach`, it uploads nothing. The base repo is the one that `GH_REPO` names, else the remote that `gh repo set-default` chose, else the first of the repo's GitHub remotes, sorted `upstream`, `github`, `origin`, then the others; the base branch is `--base`, else the head's `branch.<head>.gh-merge-base` setting in git's config, else the base repo's default branch. A branch that has an open pull request into the base branch fails with `a pull request for branch "<head>" into branch "<base>" already exists:` and its URL. `gh pr comment <url> --body-file=<file>` comments without asking when gh can't prompt. ## 3. Design @@ -159,7 +160,7 @@ flowchart LR lead -- final report --> orchestrator ``` -**Commands.** `baton start` (or plain `baton`) opens the orchestrator for the current repo. The orchestrator runs `baton jira <KEY>`, `baton feature start <slug> --brief <file>`, `baton wait`, `baton handover <slug> --to <role>`, `baton answer <slug> "<answer>"`, `baton deliver <slug>` and `baton feature close <slug>`. A role runs `baton handover`, `baton wait` and `baton ask "<question>"`. Copilot CLI runs `baton hook guard` and `baton hook stop`. Each role's pane runs `baton pane`. Anyone can run `baton status`. +**Commands.** `baton start` (or plain `baton`) opens the orchestrator for the current repo. The orchestrator runs `baton jira <KEY>`, `baton feature start <slug> --brief <file>`, `baton wait`, `baton handover <slug> --to <role>`, `baton answer <slug> "<answer>"`, `baton deliver <slug> [--title <title>]` and `baton feature close <slug>`. A role runs `baton handover`, `baton wait` and `baton ask "<question>"`. Copilot CLI runs `baton hook guard` and `baton hook stop`. Each role's pane runs `baton pane`. Anyone can run `baton status`. **The flow** (decision 13). Each feature has one holder, the role whose turn it is. Only the holder hands over, and the binary picks the next role: @@ -171,7 +172,7 @@ flowchart LR A round is one pass of the engineer. A rejection starts the next round, with the rejecting note attached. The third rejection doesn't go to the engineer: the feature is escalated, and the orchestrator gets it and asks the user. On the user's word, the orchestrator hands it back to the engineer for one more round, or to the lead with a new brief (`baton handover <slug> --to engineer|lead`), or closes it. A new brief starts the rounds again. QA and the reviewer must hand over with `--verdict approve|reject`; their note gives the evidence (the commands they ran and what came out, each criterion checked) and, for a rejection, the findings, each marked blocking or non-blocking. -**The final handover is the user's.** When a feature is ready, the orchestrator tells the user its branch, the lead's report and the open findings. The user decides: deliver it (`baton deliver` pushes the branch and opens the pull request), send it back with changes (a new brief to the lead, and the round count starts again), or close it. +**The final handover is the user's.** When a feature is ready, the orchestrator tells the user its branch, the lead's report and the open findings. The user decides: deliver it (`baton deliver` pushes the branch and opens the pull request, or comments the new report on the one that an earlier delivery opened), send it back with changes (a new brief to the lead, and the round count starts again), or close it. **Panes** (decisions 9 to 11, 29, 32, 35, 36, 38 to 42, 49 and 55). `baton feature start` makes the worktree `<repo>-<slug>/` next to the repo's main worktree, on a new branch `<prefix><slug>` (`feature/<slug>` unless the config file gives another prefix) from the main worktree's `HEAD`, and a tmux session `baton-<repo>-<slug>` with four tiled panes titled lead, engineer, qa and reviewer (`pane-border-status top`, and `allow-set-title off` so that the titles stay), set on that session's window only. Each pane runs `baton pane`, from the baton that started the feature, with `BATON_ROLE` and `BATON_FEATURE` in its environment. `baton pane` finds `copilot`, writes the plugin and reads the role's settings from the config file, waits without a model until the role's first work arrives and picks it up, as `baton wait` does, then becomes, in the same process: @@ -204,15 +205,15 @@ It runs with every permission, as the roles do, since baton's guard asks the use **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/<slug>/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/<slug>/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/<slug>/feature.json`: its format (decision 24), the slug (decision 25), the branch and worktree, the pull request's link once a delivery opened it (decision 61), 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/<slug>/events.ndjson`: one JSON line per event (start, handover, pickup, question, answer and delivery), with its time, the role that acted, its verdict, its note, the gate that it ran, the question's or the answer's text or the pull request's link, and where the feature stands after it; - `features/<slug>/notes/`: the briefs, the handover notes and the final report, each named `NN-<from>-to-<to>.md`, from the role that acted to the holder after it; - `features/<slug>/lock`: the feature's lock, a file that baton never replaces; - `features/<slug>/<role>.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, 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); and Jira's site, by its host name, and the user's email there, for `baton jira` (decision 60). `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`). 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: +**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); and Jira's site, by its host name, and the user's email there, for `baton jira` (decision 60). `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 (`deliver-bitbucket`). 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 { @@ -224,7 +225,7 @@ Every change takes an exclusive lock on the feature (`flock` on its `lock` file, **Jira** (decisions 6, 57, 58, 59 and 60). `baton jira DEMO-1` prints the ticket as Markdown: its key, summary, type, status and parent, then its description (the Atlassian Document Format turned into Markdown by `internal/adf`), subtasks, links and comments, without the text's control characters. It takes the site and the user's email from the config file, reads the token from the Keychain with `/usr/bin/security`, as baton's item for that email, and the site's cloud ID from the site, without a login, so that the token goes to Atlassian's gateway alone. The orchestrator runs it unasked, and a role's pane can't (decision 60). -**Delivery.** `baton deliver <slug>` pushes the branch and opens the pull request against the base branch, titled from the brief, with the lead's final report as its body: on GitHub with `gh pr create`, on Bitbucket Cloud with its REST API and a token from the Keychain. It never merges. +**Delivery** (decisions 17, 54 and 61, fact 43). On the user's word, the orchestrator delivers a ready feature with `baton deliver <slug> --title <title>`, which the guard asks the user about first. baton pushes the feature's branch to `origin` and opens its pull request with `gh pr create`, into the GitHub repo's default branch, with the orchestrator's title and the lead's final report as its body, then records the delivery: the feature becomes `delivered`, and keeps the pull request's link. The user can hand it back to the lead, and once it's ready again, `baton deliver <slug>`, without a title, pushes the new commits and comments the new report on that pull request with `gh pr comment`. On Bitbucket Cloud, it will open the pull request with the REST API and a token from the Keychain (`deliver-bitbucket`). It never merges. **Cost** (decision 20). `baton status` sums each role's AI credits from the last `session.usage_checkpoint` in its events (fact 8) and shows them per feature, with the holder, the round and the status. @@ -265,14 +266,14 @@ 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. The gate's one place to run carries one too, for another reason, the user's call too (decision 56). The Keychain's read, which runs `/usr/bin/security` with the item's names as arguments, carries one for this reason, the user's call of 2026-11-04 (decision 60), with a `// NOSONAR` only if Sonar flags it. +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). The Keychain's read, which runs `/usr/bin/security` with the item's names as arguments, carries one for this reason, the user's call of 2026-11-04 (decision 60), with a `// NOSONAR` only if Sonar flags it. gh's, for `baton deliver`, which runs the user's gh with the feature's branch, title and report after an `=` or a `--`, carries one for this reason, and a `// NOSONAR` for go:S4036 (decision 27), the user's call of 2026-11-04 (decision 61). 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-<hash>`, 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. 38. **`baton pane` becomes the role's `copilot`** (lead's call). It waits as baton, then replaces itself with `copilot` (`execve`), so `copilot` gets the pane's terminal and signals as they are, and its environment with `COPILOT_ALLOW_ALL=true` (decision 39): Ctrl-C reaches `copilot` alone, and the pane ends when `copilot` does (fact 25). A child process would share the terminal with baton, which would then have to ignore Ctrl-C while `copilot` runs, since baton's second Ctrl-C ends it at once (decision 28). Before it waits, `baton pane` finds `copilot`, writes the plugin and reads the config file, so a pane that can't start `copilot`, or not with the user's settings, never picks up work that nobody would then do. It picks up the work as `baton wait` does, under the feature's lock, and gives `copilot` the text that `baton wait` prints as its first prompt. While it waits, it reads the terminal and drops what the user types, so it stops when the terminal's input ends, as on Ctrl-D, or when the terminal is gone, even without a hangup (facts 22 and 25). A `copilot` that fails to start leaves the work picked up, for the `resume` step. The orchestrator's pane doesn't wait: its work comes through its own `baton wait`, so `baton pane` becomes its `copilot` at once (decision 45). `main` passes `syscall.Exec` in, so gosec sees no command to flag, and `copilot`'s start carries no `//nolint:gosec` (decision 34). Each role gets its model, reasoning effort and AI credits cap from the config file (decision 49), and `copilot` gets no `--session-id` until the `cost` step, which keeps it. 39. **A role's `copilot` trusts its worktree** (lead's call, flagged; fact 9). `baton pane` gives `copilot` its environment with `COPILOT_ALLOW_ALL=true`, in place of any value of the user's, which `copilot` would read first. That value also trusts the folder that `copilot` starts in, the feature's worktree, for that session alone, and saves nothing. Without it, a worktree outside the folders that the user trusts would stop the role on copilot's "Confirm folder trust" dialog, in a pane nobody watches (decision 9). The worktree holds the user's own repo, whose skills, plugins, MCP servers and hooks then load, as they do in the main worktree once the user trusts it; the tool allow-list still keeps their tools from the role (decision 10). The variable reaches the role's shells too, so a `copilot` that a role starts trusts its own folder as well; the `guard` step can deny that. Rejected: copilot's dialog; `--add-dir`, which doesn't trust the folder; `--trust-folder`, which needs `-p`; adding each worktree to the user's trusted folders, which would change `~/.copilot/` (decision 11). 40. **A failed start leaves nothing behind** (lead's call). `baton feature start` makes the feature first, which claims its slug, then its branch and worktree, then its tmux session, and when a part fails, it undoes the parts it made. `repo.Worktree.Undo` removes the worktree, with any changes in it, and deletes the branch, as `Add` does when it fails (decision 35). `state.Store.Remove` takes the feature's lock and removes the feature's file, which ends the feature, then its event log and its notes. It keeps the lock file, which baton never replaces, so a process that waits for the lock finds no feature once it gets it, and a new feature can take the slug and start afresh. A crash in between can leave the old event log and notes, which a new feature with that slug would then keep. -41. **A feature's names come from its slug and the repo's main worktree** (lead's call). Its branch is `<prefix><slug>`, `feature/<slug>` unless the config file gives another prefix (decision 49), from the main worktree's `HEAD`, until `deliver-github` brings the base branch, by default the remote's default branch (decision 16). Its worktree is `<repo>-<slug>`, next to the main worktree, `<repo>` being the main worktree's folder (decision 35), and its tmux session is `baton-<repo>-<slug>` (decision 14), with an underscore for each character of `<repo>` 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 `<prefix><slug>`, `feature/<slug>` unless the config file gives another prefix (decision 49), from the main worktree's `HEAD`, while its pull request goes into the GitHub repo's default branch (decision 61), so a feature started on another branch brings that branch's commits into its pull request. Its worktree is `<repo>-<slug>`, next to the main worktree, `<repo>` being the main worktree's folder (decision 35), and its tmux session is `baton-<repo>-<slug>` (decision 14), with an underscore for each character of `<repo>` 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 <slug> --to <role> --note <file>`: 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. @@ -285,13 +286,14 @@ Decisions marked **lead's call** are the lead's, for the user's end review. The 51. **The guard watches commands by program** (lead's call, flagged; decisions 9, 17 and 50, facts 34 and 35). `guard.Commands` gives the commands of a bash call that the guard watches, each with its kind and its name, such as `git push`, which a hook's reason can show; each pane's hook then decides what to do with each kind. It watches what the user named (kickoff: `git push`, force pushes and remote branch deletes, which are pushes too, pull requests and their merges, Jira writes) and what could do the same from a role's pane: outward, git's commands that send to another host (`push`, `send-email`, `send-pack`, `http-push`, `imap-send`), past git's options and the values of those that take one (fact 34), and `git-push` and the like, which name git's command themselves; `gh`'s commands but those that only read (`gh`'s `help`, `search`, `status` and `version`, and the `checks`, `checkout`, `clone`, `diff`, `download`, `list`, `status`, `view` and `watch` of any area, such as `gh pr view`), so an alias counts too, and `gh api` when it writes, as `curl` and `wget` count (below); and `ssh`, `scp`, `sftp`, `acli` and `jira`, whatever they do. Control: `tmux`, which can type into any pane, the user's too, and `osascript`, which drives the Mac's apps. Agent: another agent's CLI (`aider`, `claude`, `codex`, `copilot`, `cursor-agent`, `gemini` and `opencode`), which none of the role's limits bind. Baton: `baton`, with its command, as the roles may run some of baton's commands and not others. Unknown: a program that only the run decides, such as `"$GIT" push`, `eval "$X"` or `bash -c "$X"`, since it could be any of these. A git command or option, or one of `gh`'s first two words, that only the run decides counts as outward too, so the guard fails closed on what it can't see. A program's path doesn't count, only its name, in any case (decision 50), but git's and gh's commands match case, as git and gh do (fact 34). `guard-http` reads the options of `curl`, `wget` and `gh api` as they do (fact 35), and watches them only when they send data: by a method other than `GET` and `HEAD`, or, with no method, with data to send, in any of a curl's requests that `--next` chains; and `gh api graphql` always, since GraphQL posts its reads too. Where it doesn't follow a tool, it fails closed: data counts with curl's `-G`, which puts it into the URL; `gh api -X=GET` counts, though gh reads `GET`; and so does a method or a wget setting that only the run decides, and an option whose letters or name only the run decides (`curl -$x POST`). It misses a whole word of options that only the run decides (`curl $OPTS`), which it can't tell from a URL, the options in curl's and wget's own files (`~/.curlrc`, `-K`, `~/.wgetrc`, `WGETRC`), and what curl sends by protocols other than HTTP, such as FTP's commands (`-Q`), or a `telnet://` URL, which sends its standard input. The guard still misses what decision 50 lists, git's aliases and the commands that git runs itself (hooks, `submodule foreach`, `rebase --exec`, `bisect run`), `git lfs push`, and the tools that it doesn't know, such as `npm publish`, `docker push`, `rsync` to a host, `nc` or a cloud's CLI: in a role's pane, the belt (decision 55) makes the git pushes and `gh` commands among these fail, and the rest stays with risk 2. The step came in three, `guard`, `guard-http` and `guard-hook`, so that each stays near 150 lines. 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-<hash>-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`, `baton jira`, which only reads (decision 60), 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/<sessionId>` 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). +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; decision 61 for `baton deliver`). 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`, `baton jira`, which only reads (decision 60), 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, and so does `baton deliver`, which pushes and opens one (decision 61). 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/<sessionId>` 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::<url>`, 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. 57. **A ticket's text as Markdown, through `internal/adf`** (lead's call, flagged; decision 6, fact 39). `internal/adf` renders a ticket's ADF as Markdown for the orchestrator and the user to read, which takes far fewer tokens than the JSON, and which nobody needs to render: so the text stays as Jira has it, with no escapes, but for a `|` in a table's cell. Strong, em, strike, code and links show as Markdown's marks, code innermost and a link outermost, and the other marks, such as underline and colours, leave the text plain; code that holds backticks gets a longer fence. A mention, an emoji, a status, a card and an extension show their text or their address, a date its day in UTC, as Jira shows it, and a file or an image `[attachment]`, with its alternative text or its address, since text can't show it (fact 39). A node without a rule of its own, such as one that a later ADF adds, shows its content, or the text that stands in for it, so that each of a ticket's words reaches the orchestrator; a run of inline nodes in a block makes a paragraph. The blocks keep their shape. A heading keeps its level, from 1 to 6. Lists keep their nesting, and an item's blocks stand on the lines under its marker; a task shows as `- [ ]` or `- [x]`, and a decision as a bullet. A code block keeps its language, in a fence that its code can't end. A quote and a panel show as a quote, without the panel's kind, a rule as `---`, and an expand, which Jira shows folded, as its title in bold above its content. A table becomes a Markdown table: its first row is the header, since a Markdown table needs one; each row is as wide as the widest; and each cell stays on one line, with `<br>` for a line break and `\|` for a `|`. The converter came to about 240 lines, so it came in two steps, `adf`, the walk and the inline text, and `adf-blocks`, the blocks' structure, ahead of `jira`, so that each stays near 150 lines. Rejected: handing the orchestrator the ADF itself, which costs it many more tokens to read. 58. **A ticket as Markdown, with what the orchestrator plans from** (lead's call, flagged; decisions 6 and 57, fact 40). `internal/jira` gives the orchestrator a ticket as Markdown, as `internal/adf` gives its text: the key and the summary as its title; its type, its status and its parent, if it has one, in a list; then its description, its subtasks, its links and its comments, oldest first, each under a heading of its own, and a part that the ticket lacks not at all, heading and all. A subtask, a link's other ticket and the parent show their key, summary and status, and a link its type from the ticket's side, such as `blocks DEMO-4` or `is blocked by DEMO-5`. A comment's heading names its author, or `Someone` when Jira names nobody, and its time as Jira gives it. Left out for now: the people on the ticket, its priority, labels and sprint, which a plan rarely needs, and a site's own fields, such as acceptance criteria, whose IDs differ from site to site, so that a setting would have to name them. Jira's part came in three steps, `jira-text`, `jira-client` and `jira`, so that each stays near 150 lines. 59. **The token goes to Atlassian's gateway alone** (lead's call, flagged; decision 6, facts 13, 40 and 41). `internal/jira`'s client reads the site's cloud ID from the site's `tenant_info` without a login, takes it only as a UUID, in its usual form, and sends the user's email and token to `api.atlassian.com` alone, so that a wrong site in the config gets no token. It takes a ticket's key in any case, but only as a project's key, a dash and a number, which it sends in capitals, as Jira's keys are, so that a key can't change the address. It asks Jira for the fields that baton shows alone, and reads the comments oldest first, a page at a time, until it has as many as Jira counts, or a page comes empty. It reads at most 16 MiB of an answer. When Jira refuses, the error gives the status and the messages that the answer holds, Jira's `errorMessages` or the gateway's `message`, or the status alone for an answer that isn't JSON, such as a proxy's page. Rejected: the cloud ID in the config file, which the user would have to look up; the first page of comments alone, which would leave out the latest of a long discussion. 60. **`baton jira` reads a ticket as the user, for the orchestrator** (lead's call, flagged; the user's call, 2026-11-04, for its `//nolint:gosec`; decisions 6, 34, 49, 52, 54 and 59, facts 40 and 42, risk 14). `baton jira <KEY>` prints the ticket and its comments as Markdown (decision 58), for the orchestrator to plan from; the user can run it too. It checks the key first, as the client does (decision 59), so that a mistake in it reaches neither the Keychain nor Jira. The config file's `jira` names the site, by its host name alone, such as `example.atlassian.net`, and the user's email address at Atlassian, each a setting like the others (decision 49), so that a repo's section can name another site or account; the file refuses a site that isn't a host name, such as a URL or a name with a port, and an email that isn't an address alone, such as one with a name before it. `baton jira` reads the settings of its folder's repo, as `baton feature start` does, and refuses a repo whose settings lack either. The token lives in the login Keychain, as a password whose service is `baton-jira` and whose account is the email, which the user adds with `security add-generic-password` (README); baton reads it with `/usr/bin/security`, by that path, so that the `PATH` can't put another program in its place, and never prints it. An item that isn't there fails with the command that adds it, the email quoted for the shell. A 401 Unauthorized to the email and token, which Jira's API gives for credentials that are "incorrect or missing" (fact 40's spec), such as a token that has expired (fact 42), fails with the command that replaces the token, `security add-generic-password -U`, and a word to check the email; the site's 401, to a request without the login, gets none, since no token caused it. baton prints the ticket without its control characters, but for newlines and tabs, so that a ticket's text, which anyone who can edit the ticket wrote, can't send escape sequences to a terminal, the user's or the orchestrator's pane's. Its HTTP client gives up on a request after a minute, so a site that never answers can't hold the orchestrator's turn, and follows redirects, as Go's client does, which drops the login on a redirect to another domain. The guard runs `baton jira` unasked in the orchestrator's pane, since it only reads, and denies it in a role's pane, as it denies baton's other commands that aren't the role's (decisions 52 and 54): a role gets its part of the ticket through its brief. The orchestrator's prompt tells it to read a ticket that the user names, and that a ticket's text is the work to plan, not the user's word (risk 14). Rejected: the token in the config file, or in an environment variable, which every pane's commands would inherit and `env` would print; Atlassian's `acli` or a Jira CLI, which the user would have to install and log in (decision 6); and a refusal of its own in a role's pane, which the guard already gives. +61. **`baton deliver` pushes a ready feature and opens its pull request with the user's gh** (lead's call, flagged; the user's call, 2026-11-04, for its `//nolint:gosec` and `// NOSONAR`; decisions 17, 24, 27, 34, 35, 41, 44, 52, 54 and 55, fact 43, risk 15). `baton deliver <slug> --title <title>` runs in the orchestrator's pane or the user's shell, on the user's word, and refuses a role's pane, by `BATON_ROLE` (decision 29), as the guard does (decision 52); in the orchestrator's pane, the guard asks the user first (decision 54). It takes a ready feature, whose work is the lead's final report, and refuses any other. It pushes the feature's branch to `origin`, by its full name, `refs/heads/<branch>`, and makes it the branch's upstream (decision 35), with the user's git and credentials, which the orchestrator's pane keeps (decision 55). Then it opens the pull request with `gh pr create --head=<branch> --title=<title> --body-file=<report>`, with the user's gh and its login (decision 27): gh picks the GitHub repo from the remotes, and the base branch, the repo's default, and with these flags pushes nothing, asks nothing and uploads nothing (fact 43), then prints the pull request's URL. The title is the orchestrator's, which the user sees in the guard's question, and the body is the lead's final report, as it is. Last, baton records the delivery in one change: the feature becomes `delivered`, which the orchestrator still holds, keeps the pull request's link, and logs a `deliver` event with it. The user can hand a delivered feature back to the lead (decision 44), and once it's ready again, `baton deliver <slug>`, without a title, pushes the new commits and comments the new report on that pull request with `gh pr comment`; a title there is refused, since the pull request keeps its own, and so is a first delivery without one. In that order, a push or a gh that fails leaves the feature ready and unchanged, for the user to deliver again once they've fixed it, and a second push of the same commits changes nothing. But if baton stops between gh and the record, or the record fails, as for a feature that changed meanwhile, the pull request is open and the feature doesn't keep it, and its next delivery fails with gh's `already exists` and the link: the user closes that pull request and delivers again, or carries on by hand (risk 15). The new status keeps the format (decision 24): no baton without it was released, and an older one would refuse a delivered feature, whose status it doesn't know. gh runs at one place, which carries `//nolint:gosec` with its reason, baton's values reaching gh after an `=` or a `--`, so that gh never takes one for an option (decision 34), and `// NOSONAR` for go:S4036 (decision 27). The orchestrator's prompt tells it to propose a title when it reports a ready feature, to give the report's path, and to push and open pull requests with `baton deliver` alone. Not handled yet: a fork, where gh takes the `upstream` remote for the base repo and looks for the branch there; another base branch than the default, which a feature started from another branch would need (decision 41), and which `branch.<head>.gh-merge-base` in git's config gives gh; and a delivery that takes over an open pull request that the feature doesn't keep. Rejected: a title from the brief, as planned, where the orchestrator writes a better one, which the user sees; gh's `--fill`, which titles the pull request after a commit; and GitHub's REST API with a token of baton's own, which the user would have to make, keep and renew, where gh holds the user's login already. ## 5. Risks @@ -309,6 +311,7 @@ Decisions marked **lead's call** are the lead's, for the user's end review. The 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). 14. **A ticket speaks to the orchestrator** (decision 60). The orchestrator reads a ticket's text, which anyone who can edit the ticket or comment on it wrote, and a model can take text for instructions: a ticket could ask it to push, to send a file somewhere, or to plan more than the user asked. Its prompt tells it that a ticket is the work to plan, not the user's word; the user approves the plan before a feature starts (decision 8), and the guard asks the user about its outward commands (decision 54), but its reads and local commands run unasked (risk 3), and `web_fetch` can send data in a URL. And any program that runs as the user can read the token, a role's `bash` included, with `security find-generic-password` (fact 42): its one scope, `read:jira-work`, keeps it to reading what the user can read in Jira, and its expiry bounds how long; a role that sends it out is risk 2's. +15. **A delivery publishes what the user may not have read** (decision 61). The pull request's body, and each later comment, is the lead's final report as it is, which the orchestrator sums up for the user, and the guard's question shows the command and the title, not the report: a report could hold what shouldn't leave the Mac, such as a path or a value that a check printed, and the pull request is as public as the repo. The orchestrator gives the report's path, for the user to read first. And a pull request that baton opened but couldn't record stays open without the feature knowing it: the next delivery fails with gh's `already exists` and the link, and the user closes it or carries on by hand. ## 6. Open questions diff --git a/internal/cli/deliver.go b/internal/cli/deliver.go new file mode 100644 index 0000000..4f95db6 --- /dev/null +++ b/internal/cli/deliver.go @@ -0,0 +1,132 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/codedogapp/baton/internal/flow" + "github.com/codedogapp/baton/internal/github" + "github.com/codedogapp/baton/internal/repo" + "github.com/codedogapp/baton/internal/state" +) + +// deliver delivers a ready feature, on the user's word: it pushes the feature's branch to origin, +// then opens the branch's pull request on GitHub, with the title and the lead's final report as +// its body, or comments the report on the pull request that an earlier delivery opened, and +// records it (decision 61). Only the orchestrator delivers features, so it refuses a role's pane. +func deliver(ctx context.Context, env Env, args []string) error { + slug, title, err := parseDeliver(env, args) + if err != nil { + return err + } + + if role := flow.Role(env.Getenv(envRole)); role != "" && role != flow.RoleOrchestrator { + return fmt.Errorf("only the orchestrator delivers a feature, and %s names the %s", + envRole, role) + } + + store, err := openStore(ctx, env.Dir) + if err != nil { + return err + } + + feature, err := store.Read(slug) + if err == nil { + err = deliverable(feature, title) + } + + if err == nil { + err = repo.Push(ctx, env.Dir, feature.Branch) + } + + if err != nil { + return err + } + + pr := github.PullRequest{ + Branch: feature.Branch, Title: title, BodyFile: store.NotePath(slug, feature.Work.Note), + } + + url, did, err := publish(ctx, env.Dir, feature.PullRequest, pr) + if err != nil { + return err + } + + entry := state.Entry{Event: state.Event{ + Kind: state.EventDeliver, Role: flow.RoleOrchestrator, Text: url, + }} + if _, err := store.Update(slug, entry, delivered(url)); err != nil { + return fmt.Errorf("record the pull request %s: %w", url, err) + } + + _, err = fmt.Fprintf(env.Stdout, "Delivered %s: pushed %s to origin and %s its pull request, "+ + "%s.\n", slug, feature.Branch, did, url) + + return err +} + +// parseDeliver parses baton deliver's command line: the feature's slug, and the pull request's +// title, trimmed, if given. +func parseDeliver(env Env, args []string) (slug, title string, err error) { + flags := newFlags(env, "deliver") + titled := flags.String("title", "", "the pull request's `title`, for the first delivery") + + slug, err = parseSlug(flags, args) + if err == nil && slug == "" { + err = errors.New("want the feature's slug: baton deliver <slug> [--title <title>]") + } + + return slug, strings.TrimSpace(*titled), err +} + +// deliverable returns an error if the feature isn't ready, or if the title doesn't suit the +// delivery: the first opens the feature's pull request, with the title, and the later ones +// comment on it, which keeps its title. +func deliverable(feature state.Feature, title string) error { + if _, err := feature.Flow.Deliver(); err != nil { + return fmt.Errorf("%s: %w", feature.Slug, err) + } + + switch { + case feature.PullRequest == "" && title == "": + return fmt.Errorf("want the pull request's title for the first delivery of %s: "+ + "baton deliver %[1]s --title <title>", feature.Slug) + case feature.PullRequest != "" && title != "": + return fmt.Errorf("%s has its pull request already, %s, which keeps its title: "+ + "deliver it again without --title", feature.Slug, feature.PullRequest) + } + + return nil +} + +// publish opens the pull request, or comments its body on the one at the URL, if any, in the +// folder's repo. It returns the pull request's URL, and what it did. +func publish(ctx context.Context, dir, url string, request github.PullRequest) ( + link, did string, err error, +) { + if url != "" { + return url, "commented the lead's report on", + github.Comment(ctx, dir, url, request.BodyFile) + } + + link, err = github.Open(ctx, dir, request) + + return link, "opened", err +} + +// delivered returns the change that records the feature's delivery to its pull request at the +// URL. +func delivered(url string) func(*state.Feature) error { + return func(feature *state.Feature) error { + next, err := feature.Flow.Deliver() + if err != nil { + return fmt.Errorf("%s: %w", feature.Slug, err) + } + + feature.Flow, feature.PullRequest = next, url + + return nil + } +} diff --git a/internal/cli/deliver_refusals_test.go b/internal/cli/deliver_refusals_test.go new file mode 100644 index 0000000..1f204f1 --- /dev/null +++ b/internal/cli/deliver_refusals_test.go @@ -0,0 +1,193 @@ +package cli_test + +import ( + "fmt" + "maps" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/codedogapp/baton/internal/flow" + "github.com/codedogapp/baton/internal/state" +) + +// TestDeliverRefusesAndChangesNothing checks that a refusal pushes nothing, runs no gh, and leaves +// baton's folder as it was. +func TestDeliverRefusesAndChangesNothing(t *testing.T) { + ready := readyToDeliver(t) + + cases := []struct { + name string + args []string // After baton deliver. + env map[string]string // The pane's environment. + setup func(t *testing.T) + want string // The reason, after "baton deliver: ". + }{ + { + name: "without a slug", args: []string{"--title", "Add the demo"}, + want: "want the feature's slug: baton deliver <slug> [--title <title>]", + }, + { + name: "with an argument after the slug", args: []string{slug, "now"}, + want: `unexpected argument "now"`, + }, + { + name: "in a role's pane", args: []string{slug, "--title", "Add it"}, + env: paneOf(flow.RoleLead), + want: "only the orchestrator delivers a feature, and BATON_ROLE names the lead", + }, + { + name: "for a feature that isn't there", args: []string{"demo-9", "--title", "Add it"}, + want: "no feature demo-9", + }, + { + name: "for a feature that runs", args: []string{"demo-2", "--title", "Add it"}, + setup: func(t *testing.T) { + add(t, ready.store, state.Feature{Slug: "demo-2", Branch: "linked", Flow: fresh()}) + }, + want: "demo-2: a feature that is running isn't ready to deliver: it is once the " + + "lead's final report comes", + }, + { + name: "without a title", args: []string{slug, "--title", " "}, + want: "want the pull request's title for the first delivery of demo-1: " + + "baton deliver demo-1 --title <title>", + }, + { + name: "with a title for a later delivery", args: []string{slug, "--title", "Add it"}, + setup: func(t *testing.T) { + edit(t, ready.store, func(feature *state.Feature) { + feature.PullRequest = pullRequest + }) + }, + want: "demo-1 has its pull request already, " + pullRequest + ", which keeps its " + + "title: deliver it again without --title", + }, + { + name: "once delivered", args: []string{slug}, + setup: func(t *testing.T) { + edit(t, ready.store, func(feature *state.Feature) { + feature.Flow.Status = flow.StatusDelivered + }) + }, + want: "demo-1: a feature that is delivered isn't ready to deliver: it is once the " + + "lead's final report comes", + }, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + if test.setup != nil { + test.setup(t) + } + + calls, before := standInGh(t, "echo "+pullRequest+"\n"), snapshot(t, ready.store.Dir) + + got := run(t, ready.mainDir, test.env, append([]string{"deliver"}, test.args...)...) + if want := (result{code: 1, stderr: "baton deliver: " + test.want + "\n"}); got != want { + t.Errorf("got %+v; want %+v", got, want) + } + + wantPushed(t, ready.origin, "") + wantGh(t, calls, "") + + if after := snapshot(t, ready.store.Dir); !maps.Equal(after, before) { + t.Errorf("baton's folder changed:\n%v\nwant:\n%v", after, before) + } + }) + } +} + +// TestDeliverRefusesOutsideARepo refuses a delivery that has no git repo, and so no feature. +func TestDeliverRefusesOutsideARepo(t *testing.T) { + outside := t.TempDir() + t.Setenv("GIT_CEILING_DIRECTORIES", filepath.Dir(outside)) + calls := standInGh(t, "") + + got := run(t, outside, orchestratorPane(), "deliver", slug, "--title", "Add it") + + want := "baton deliver: find the git repo: fatal: not a git repository" + if got.code != 1 || got.stdout != "" || !strings.HasPrefix(got.stderr, want) { + t.Errorf("got %+v; want exit code 1 and %q", got, want) + } + + wantGh(t, calls, "") +} + +// TestDeliverReportsWhatFails checks that a delivery that fails leaves the feature ready, without +// a pull request, so that a later delivery tries again, and says what it did. +func TestDeliverReportsWhatFails(t *testing.T) { + cases := []struct { + name string + gh string // The stand-in gh's script. + pushed bool // Whether origin has the branch afterwards. + ran bool // Whether gh ran. + want string // The reason's start, after "baton deliver: ". + // setup changes the repo first, if it isn't nil. + setup func(t *testing.T, ready delivery) + }{ + { + name: "a push that git refuses", gh: "echo " + pullRequest + "\n", + setup: func(t *testing.T, ready delivery) { + gitIn(t, ready.mainDir, exec.CommandContext(t.Context(), "git", "remote", "remove", + "origin")) + }, + want: "push linked to origin: fatal: 'origin' does not appear to be a git repository\n", + }, + { + name: "a gh that fails", gh: "echo 'HTTP 401: Bad credentials' >&2\nexit 1\n", + pushed: true, ran: true, + want: "open the pull request: HTTP 401: Bad credentials (exit status 1)\n", + }, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + ready := readyToDeliver(t) + if test.setup != nil { + test.setup(t, ready) + } + + calls := standInGh(t, test.gh) + + got := run(t, ready.mainDir, orchestratorPane(), "deliver", slug, "--title", "Add it") + if got.code != 1 || got.stdout != "" || + !strings.HasPrefix(got.stderr, "baton deliver: "+test.want) { + t.Errorf("got %+v; want exit code 1 and a reason that starts with %q", got, + test.want) + } + + wantPushed(t, ready.origin, map[bool]string{true: head(t, ready.mainDir)}[test.pushed]) + wantGh(t, calls, map[bool]string{true: "pr\ncreate\n--head=linked\n--title=Add it\n" + + "--body-file=" + ready.report + "\n"}[test.ran]) + + if feature, err := ready.store.Read(slug); err != nil || + feature.Flow.Status != flow.StatusReady || feature.PullRequest != "" { + t.Errorf("Read = %+v, %v; want it ready, without a pull request", feature, err) + } + }) + } +} + +// TestDeliverReportsAPullRequestThatItCannotRecord has the feature change while gh opens its pull +// request, as if the lead held it again: the delivery then names the pull request, which the +// feature doesn't keep. +func TestDeliverReportsAPullRequestThatItCannotRecord(t *testing.T) { + ready := readyToDeliver(t) + file := filepath.Join(ready.store.Dir, "features", slug, "feature.json") + edits := `-e 's/"holder": "orchestrator"/"holder": "lead"/' ` + + `-e 's/"status": "ready"/"status": "running"/'` + standInGh(t, fmt.Sprintf("sed -i.old %s %q\necho %s\n", edits, file, pullRequest)) + + got := run(t, ready.mainDir, orchestratorPane(), "deliver", slug, "--title", "Add it") + if want := (result{code: 1, stderr: "baton deliver: record the pull request " + pullRequest + + ": demo-1: a feature that is running isn't ready to deliver: it is once the lead's " + + "final report comes\n"}); got != want { + t.Errorf("got %+v; want %+v", got, want) + } + + if feature, err := ready.store.Read(slug); err != nil || feature.PullRequest != "" { + t.Errorf("Read = %+v, %v; want it without a pull request", feature, err) + } +} diff --git a/internal/cli/deliver_test.go b/internal/cli/deliver_test.go new file mode 100644 index 0000000..ebc2f39 --- /dev/null +++ b/internal/cli/deliver_test.go @@ -0,0 +1,184 @@ +package cli_test + +import ( + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/codedogapp/baton/internal/cli" + "github.com/codedogapp/baton/internal/flow" + "github.com/codedogapp/baton/internal/state" +) + +// pullRequest is the URL of the pull request that the stand-in gh opens. +const pullRequest = "https://github.com/example/demo/pull/7" + +// delivery is a repo where demo-1 is ready to deliver. +type delivery struct { + mainDir string + origin string // The bare repo that is the repo's origin. + store state.Store + report string // The path of the lead's final report. +} + +// readyToDeliver makes a repo with a bare repo of the test's as its origin, and demo-1 on the +// linked worktree's branch, which the lead hands to the orchestrator with its final report. +func readyToDeliver(t *testing.T) delivery { + t.Helper() + withGate(t, "true") + + mainDir, linked := repos(t) + gitIn(t, mainDir, exec.CommandContext(t.Context(), "git", "init", "--quiet", "--bare", + "../origin.git")) + gitIn(t, mainDir, exec.CommandContext(t.Context(), "git", "remote", "add", "origin", + "../origin.git")) + + store := started(t, mainDir, flow.State{ + Holder: flow.RoleLead, Status: flow.StatusRunning, Round: 1, Cap: 3, + }) + edit(t, store, func(feature *state.Feature) { + feature.Branch, feature.Worktree = "linked", linked + }) + handOver(t, mainDir, flow.RoleLead, flow.VerdictNone) + + origin := filepath.Join(filepath.Dir(mainDir), "origin.git") + + return delivery{mainDir, origin, store, notePath(store, "02-lead-to-orchestrator.md")} +} + +// standInGh puts a stand-in gh first on PATH, which writes its arguments, a line each, to the file +// that it returns, then runs the shell script. +func standInGh(t *testing.T, script string) (calls string) { + t.Helper() + + bin, executable := t.TempDir(), os.FileMode(0o700) + calls = filepath.Join(bin, "calls") + text := fmt.Sprintf("#!/bin/sh\nfor arg; do printf '%%s\\n' \"$arg\"; done > %q\n%s", calls, + script) + + if err := os.WriteFile(filepath.Join(bin, "gh"), []byte(text), executable); err != nil { + t.Fatal(err) + } + + t.Setenv("PATH", bin+string(filepath.ListSeparator)+os.Getenv("PATH")) + + return calls +} + +// wantGh checks the stand-in gh's arguments, a line each, or that gh didn't run if want is "". +func wantGh(t *testing.T, calls, want string) { + t.Helper() + + got, err := os.ReadFile(filepath.Clean(calls)) + if want == "" && !errors.Is(err, fs.ErrNotExist) { + t.Errorf("gh ran: %q, %v", got, err) + } + + if want != "" && string(got) != want { + t.Errorf("gh ran with %q, %v; want %q", got, err, want) + } +} + +// wantPushed checks the commit of the branch linked in the origin, or that origin has no such +// branch if want is "". +func wantPushed(t *testing.T, origin, want string) { + t.Helper() + + out := gitIn(t, origin, exec.CommandContext(t.Context(), "git", "for-each-ref", + "--format=%(objectname)", "refs/heads/linked")) + if got := strings.TrimSpace(out); got != want { + t.Errorf("origin's linked is at %q, want %q", got, want) + } +} + +// head returns the commit of the repo's branch linked. +func head(t *testing.T, mainDir string) string { + t.Helper() + + out := gitIn(t, mainDir, exec.CommandContext(t.Context(), "git", "rev-parse", "linked")) + + return strings.TrimSpace(out) +} + +func TestDeliverPushesTheBranchAndOpensItsPullRequest(t *testing.T) { + ready := readyToDeliver(t) + calls := standInGh(t, "echo "+pullRequest+"\n") + + got := run(t, ready.mainDir, orchestratorPane(), "deliver", slug, "--title", " Add the demo \n") + if want := (result{stdout: "Delivered demo-1: pushed linked to origin and opened its pull " + + "request, " + pullRequest + ".\n"}); got != want { + t.Errorf("got %+v; want %+v", got, want) + } + + wantPushed(t, ready.origin, head(t, ready.mainDir)) + wantGh(t, calls, "pr\ncreate\n--head=linked\n--title=Add the demo\n--body-file="+ + ready.report+"\n") + + feature, err := ready.store.Read(slug) + if want := (flow.State{ + Holder: flow.RoleOrchestrator, Status: flow.StatusDelivered, Round: 1, Cap: 3, + }); err != nil || feature.Flow != want || feature.PullRequest != pullRequest { + t.Errorf("Read = %+v, %v; want %+v, with the pull request", feature, err, want) + } + + wantJournal(t, ready.store, []logged{ + {"start orchestrator 01-orchestrator-to-lead.md", "Build the demo.\n"}, + {"edit orchestrator ", ""}, + {"handover lead 02-lead-to-orchestrator.md (gate: true)", "From the lead.\n"}, + {"deliver orchestrator ", pullRequest}, + }) +} + +// TestDeliverCommentsOnThePullRequestOfAnEarlierDelivery delivers a feature that came back ready, +// after a delivery and changes, from the user's own shell. +func TestDeliverCommentsOnThePullRequestOfAnEarlierDelivery(t *testing.T) { + ready := readyToDeliver(t) + edit(t, ready.store, func(feature *state.Feature) { feature.PullRequest = pullRequest }) + + calls := standInGh(t, "echo "+pullRequest+"#issuecomment-1\n") + + got := run(t, ready.mainDir, nil, "deliver", slug) + if want := (result{stdout: "Delivered demo-1: pushed linked to origin and commented the " + + "lead's report on its pull request, " + pullRequest + ".\n"}); got != want { + t.Errorf("got %+v; want %+v", got, want) + } + + wantPushed(t, ready.origin, head(t, ready.mainDir)) + wantGh(t, calls, "pr\ncomment\n--body-file="+ready.report+"\n--\n"+pullRequest+"\n") + + if feature, err := ready.store.Read(slug); err != nil || + feature.Flow.Status != flow.StatusDelivered || feature.PullRequest != pullRequest { + t.Errorf("Read = %+v, %v; want it delivered, with the pull request", feature, err) + } +} + +// TestDeliverReportsAnOutputItCantWrite fails a delivery that can't print what it did. The +// delivery stays recorded, so the next one comments on its pull request. +func TestDeliverReportsAnOutputItCantWrite(t *testing.T) { + ready := readyToDeliver(t) + standInGh(t, "echo "+pullRequest+"\n") + + var stderr strings.Builder + + code := cli.Run(t.Context(), cli.Env{ + Args: []string{"deliver", slug, "--title", "Add the demo"}, + Getenv: func(name string) string { return orchestratorPane()[name] }, + Dir: ready.mainDir, + Stdout: failing{}, + Stderr: &stderr, + }) + + if got := stderr.String(); code != 1 || got != "baton deliver: output closed\n" { + t.Errorf("exit code %d and %q; want 1 and the output's error", code, got) + } + + if feature, err := ready.store.Read(slug); err != nil || + feature.Flow.Status != flow.StatusDelivered || feature.PullRequest != pullRequest { + t.Errorf("Read = %+v, %v; want it delivered, with the pull request", feature, err) + } +} diff --git a/internal/cli/guard_orchestrator.go b/internal/cli/guard_orchestrator.go index 5ba6890..71fa62f 100644 --- a/internal/cli/guard_orchestrator.go +++ b/internal/cli/guard_orchestrator.go @@ -14,6 +14,10 @@ import ( // approval is on (decision 54). const featureStart = "baton feature start" +// batonDeliver is the guard's name for baton deliver, which the user approves each time +// (decision 61). +const batonDeliver = "baton deliver" + // orchestratorCommands are baton's commands that the orchestrator runs unasked, by the names // that the guard gives them. var orchestratorCommands = slices.Concat([]string{ @@ -28,8 +32,8 @@ var orchestratorWhy = map[guard.Kind]string{ guard.Agent: "starts another agent, which the orchestrator never does: start a feature, " + "and its team does the work.", guard.Baton: "isn't the orchestrator's command: it runs `baton jira`, " + - "`baton feature start`, `baton wait`, `baton answer`, `baton handover` and " + - "`baton status`.", + "`baton feature start`, `baton wait`, `baton answer`, `baton handover`, " + + "`baton status` and `baton deliver`.", guard.Unknown: why[guard.Unknown], } @@ -39,9 +43,9 @@ const guardAsks = "baton's guard asks you: " // orchestratorRules are the guard's rules in the orchestrator's pane, which the user watches // (decision 54). It runs baton's commands for the orchestrator, asks the user before each command -// that acts outside the Mac and, while plan approval is on, before each baton feature start, and -// denies the other commands that it watches. It denies a write outside the copilot's session -// folder, which the input's session names. +// that acts outside the Mac, before each baton deliver and, while plan approval is on, before each +// baton feature start, and denies the other commands that it watches. It denies a write outside +// the copilot's session folder, which the input's session names. func orchestratorRules(ctx context.Context, env Env, dir, session string) paneRules { return paneRules{ script: func(commands []guard.Command) (guardOutput, error) { @@ -93,6 +97,10 @@ func orchestratorCommand(command guard.Command, approval bool) guardOutput { "turns this question off."} case command.Name == featureStart: return guardOutput{} + case command.Name == batonDeliver: + return guardOutput{"ask", guardAsks + "`baton deliver` pushes a feature's branch to " + + "origin, then opens its pull request on GitHub or comments on it. Allow it if you " + + "want the feature delivered."} case command.Kind == guard.Outward: return guardOutput{"ask", fmt.Sprintf("%s`%s` can act outside the Mac. Allow it only "+ "if you want it to.", guardAsks, command.Name)} diff --git a/internal/cli/guard_orchestrator_test.go b/internal/cli/guard_orchestrator_test.go index ff34d6a..0a0b598 100644 --- a/internal/cli/guard_orchestrator_test.go +++ b/internal/cli/guard_orchestrator_test.go @@ -14,8 +14,10 @@ const ( orchestratorAgent = "starts another agent, which the orchestrator never does: start a " + "feature, and its team does the work." orchestratorBaton = "isn't the orchestrator's command: it runs `baton jira`, " + - "`baton feature start`, `baton wait`, `baton answer`, `baton handover` and " + - "`baton status`." + "`baton feature start`, `baton wait`, `baton answer`, `baton handover`, " + + "`baton status` and `baton deliver`." + toDeliver = guardAsks + "`baton deliver` pushes a feature's branch to origin, then opens " + + "its pull request on GitHub or comments on it. Allow it if you want the feature delivered." ) // asks returns the guard's question to the user about the orchestrator's outward command, which @@ -57,6 +59,10 @@ func TestGuardHookAsksTheUserAboutTheOrchestratorsCommands(t *testing.T) { "baton handover demo-1 --to lead": {}, "baton status": {}, "baton jira DEMO-1": {}, + "baton deliver demo-1 --title 'Add it'": {"ask", toDeliver}, + "baton deliver demo-1 && git push": {"ask", toDeliver}, + "git push; baton deliver demo-1": asks("git push"), + "baton deliver demo-1; tmux ls": forbids("tmux", orchestratorControl), "baton help feature": {}, "baton -h": {}, "baton -help": {}, diff --git a/internal/cli/guard_test.go b/internal/cli/guard_test.go index 1f25c4f..8a9ab51 100644 --- a/internal/cli/guard_test.go +++ b/internal/cli/guard_test.go @@ -51,6 +51,7 @@ func TestGuardHookDeniesARoleWhatTheGuardWatches(t *testing.T) { "baton hook stop": denied("baton hook stop", toBaton), "baton pane": denied("baton pane", toBaton), "baton jira DEMO-1": denied("baton jira", toBaton), + "baton deliver demo-1": denied("baton deliver", toBaton), "baton": denied("baton", toBaton), `baton "$verb"`: denied("baton …", toBaton), "baton wait": "", diff --git a/internal/cli/run.go b/internal/cli/run.go index 0ded8d5..d764d68 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -63,6 +63,7 @@ Commands: start open the repo's orchestrator in its tmux session, as plain baton does answer answer the question of a feature's role, in the orchestrator's pane ask ask the orchestrator a question, in a role's pane, then wait for the answer + deliver push a ready feature's branch and open its pull request on GitHub feature start start a feature from a brief: its branch, its worktree and its tmux session handover hand a feature over to the next role, with a note hook guard copilot's tool hook: deny a role what the guard watches, before it runs @@ -83,6 +84,7 @@ var commands = map[string]commandFunc{ "start": open, "answer": answer, "ask": ask, + "deliver": deliver, "handover": handover, "jira": showTicket, "pane": pane, diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 8a02d36..026a7f0 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -9,6 +9,7 @@ Commands: start open the repo's orchestrator in its tmux session, as plain baton does answer answer the question of a feature's role, in the orchestrator's pane ask ask the orchestrator a question, in a role's pane, then wait for the answer + deliver push a ready feature's branch and open its pull request on GitHub feature start start a feature from a brief: its branch, its worktree and its tmux session handover hand a feature over to the next role, with a note hook guard copilot's tool hook: deny a role what the guard watches, before it runs diff --git a/internal/flow/deliver_test.go b/internal/flow/deliver_test.go new file mode 100644 index 0000000..ea3ca41 --- /dev/null +++ b/internal/flow/deliver_test.go @@ -0,0 +1,49 @@ +package flow_test + +import ( + "strings" + "testing" + + "github.com/codedogapp/baton/internal/flow" +) + +// ready returns the state after the lead's final report, in the first round. +func ready(t *testing.T) flow.State { + t.Helper() + + return walk(t, 3, append(approved(), "lead")...) +} + +func TestDeliverKeepsTheFeatureForTheUser(t *testing.T) { + got, err := ready(t).Deliver() + if want := state(flow.RoleOrchestrator, flow.StatusDelivered, 1, 0); err != nil || got != want { + t.Fatalf("Deliver() = %+v, %v; want %+v", got, err, want) + } + + // The user can send it back with changes, which start the rounds again. + back, err := got.Next(moves()["orchestrator to lead"]) + if want := state(flow.RoleLead, flow.StatusRunning, 0, 0); err != nil || back != want { + t.Errorf("the new brief after a delivery: %+v, %v; want %+v", back, err, want) + } +} + +func TestDeliverRefusesAFeatureThatIsNotReady(t *testing.T) { + delivered, err := ready(t).Deliver() + if err != nil { + t.Fatal(err) + } + + for _, now := range []flow.State{ + walk(t, 3, approved()...), + walk(t, 3, escalated()...), + delivered, + } { + got, err := now.Deliver() + + want := "a feature that is " + string(now.Status) + " isn't ready to deliver" + if err == nil || !strings.HasPrefix(err.Error(), want) || got != (flow.State{}) { + t.Errorf("%+v.Deliver() = %+v, %v; want an error that starts with %q", now, got, + err, want) + } + } +} diff --git a/internal/flow/state.go b/internal/flow/state.go index b7a5824..0e62826 100644 --- a/internal/flow/state.go +++ b/internal/flow/state.go @@ -10,12 +10,13 @@ const ( StatusRunning Status = "running" // A role other than the orchestrator holds the feature. StatusEscalated Status = "escalated" // A rejection came after the last round; the user decides. StatusReady Status = "ready" // The lead's final report is in; the user decides. + StatusDelivered Status = "delivered" // baton deliver pushed it, and the user decides again. ) // known reports whether the status is one of the statuses above. func (s Status) known() bool { switch s { - case StatusRunning, StatusEscalated, StatusReady: + case StatusRunning, StatusEscalated, StatusReady, StatusDelivered: return true default: return false @@ -46,6 +47,21 @@ func Start(roundCap int) (State, error) { return state, nil } +// Deliver returns the state of a ready feature that baton deliver has pushed, with the lead's +// final report in its pull request, or an error if the feature isn't ready (decision 61). The +// orchestrator still holds it: the user can hand it back to the lead, for changes that a later +// delivery brings to the same pull request. +func (s State) Deliver() (State, error) { + if s.Status != StatusReady { + return State{}, fmt.Errorf("a feature that is %s isn't ready to deliver: "+ + "it is once the lead's final report comes", s.Status) + } + + s.Status = StatusDelivered + + return s, 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 diff --git a/internal/flow/validate_test.go b/internal/flow/validate_test.go index 0d9743c..c5ea5a9 100644 --- a/internal/flow/validate_test.go +++ b/internal/flow/validate_test.go @@ -18,6 +18,7 @@ func TestValidateAcceptsExactlyTheStatesOfTheFlow(t *testing.T) { flow.StatusRunning, flow.StatusEscalated, flow.StatusReady, + flow.StatusDelivered, } states := []flow.State{{}} @@ -42,8 +43,8 @@ func roles() []flow.Role { } } -// madeStates returns the states that Start and Next make, up to the caps and extra rounds given, -// by trying every handover from every state found. +// madeStates returns the states that Start, Next and Deliver make, up to the caps and extra +// rounds given, by trying every handover and the delivery from every state found. func madeStates(t *testing.T, maxCap, maxExtra int) map[flow.State]bool { t.Helper() @@ -85,6 +86,10 @@ func madeStates(t *testing.T, maxCap, maxExtra int) map[flow.State]bool { found = append(found, next) } } + + if delivered, err := state.Deliver(); err == nil { + found = append(found, delivered) + } } return made diff --git a/internal/github/github.go b/internal/github/github.go new file mode 100644 index 0000000..c6aecc7 --- /dev/null +++ b/internal/github/github.go @@ -0,0 +1,61 @@ +// Package github runs gh, GitHub's command line tool, for baton deliver: it opens a feature's pull +// request and comments on it, as the user's own gh, with the user's login (decision 61). +package github + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "strings" +) + +// PullRequest is a pull request to open: from the branch, which origin has, with the title, and +// with the text of the body file. +type PullRequest struct{ Branch, Title, BodyFile string } + +// Open opens the pull request in the GitHub repo of the repo that holds the folder, as gh finds it, +// into that repo's default branch, and returns its URL. gh pushes nothing, asks nothing and +// uploads nothing that the body links to (fact 43). +func Open(ctx context.Context, dir string, pr PullRequest) (string, error) { + out, err := gh(ctx, dir, "pr", "create", "--head="+pr.Branch, "--title="+pr.Title, + "--body-file="+pr.BodyFile) + if err != nil { + return "", fmt.Errorf("open the pull request: %w", err) + } + + return strings.TrimSpace(out), nil +} + +// Comment comments on the pull request at the URL, with the text of the body file. +func Comment(ctx context.Context, dir, url, bodyFile string) error { + if _, err := gh(ctx, dir, "pr", "comment", "--body-file="+bodyFile, "--", url); err != nil { + return fmt.Errorf("comment on the pull request %s: %w", url, err) + } + + return nil +} + +// gh runs gh in the folder and returns what it printed. Its error holds what gh printed on stderr, +// if gh failed and said why. +func gh(ctx context.Context, dir string, args ...string) (string, error) { + // Sonar's go:S4036 wants gh at a fixed path, but baton runs the user's own gh, found on the + // user's PATH as the user's shell finds it (decision 27). gosec's G204 wants constant + // arguments, but gh needs the feature's own; they reach it as arguments, which no shell reads, + // after an = or a -- (decision 34). + //nolint:gosec // G204: the caller's values come after an = or a -- (decision 61). + cmd := exec.CommandContext(ctx, "gh", args...) // NOSONAR + cmd.Dir = dir + + out, err := cmd.Output() + if exit, ok := errors.AsType[*exec.ExitError](err); ok && len(exit.Stderr) > 0 { + return "", fmt.Errorf("%s (%w)", bytes.TrimSpace(exit.Stderr), err) + } + + if err != nil { + return "", err + } + + return string(out), nil +} diff --git a/internal/github/github_test.go b/internal/github/github_test.go new file mode 100644 index 0000000..48b9ce5 --- /dev/null +++ b/internal/github/github_test.go @@ -0,0 +1,126 @@ +package github_test + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/codedogapp/baton/internal/github" +) + +// pullRequest is the URL of the pull request that the stand-in gh opens. +const pullRequest = "https://github.com/example/demo/pull/7" + +// TestMain keeps the user's own gh out of the tests' reach: each test puts a stand-in on PATH. +func TestMain(m *testing.M) { + if err := os.Setenv("PATH", ""); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + os.Exit(m.Run()) +} + +// standIn puts a stand-in gh on PATH, which runs the shell script after it writes its folder and +// its arguments, a line each, to the file that it returns. +func standIn(t *testing.T, text string) (calls string) { + t.Helper() + + bin, executable := t.TempDir(), os.FileMode(0o700) + calls = filepath.Join(bin, "calls") + record := fmt.Sprintf("{ printf '%%s\\n' \"$PWD\"; for arg; do printf '%%s\\n' \"$arg\"; "+ + "done; } > %q\n", calls) + + err := os.WriteFile(filepath.Join(bin, "gh"), []byte("#!/bin/sh\n"+record+text), executable) + if err != nil { + t.Fatal(err) + } + + t.Setenv("PATH", bin) + + return calls +} + +// called returns the folder and the arguments that the stand-in gh recorded. +func called(t *testing.T, calls string) string { + t.Helper() + + out, err := os.ReadFile(filepath.Clean(calls)) + if err != nil { + t.Fatal(err) + } + + return string(out) +} + +func TestOpenOpensThePullRequestAndGivesItsURL(t *testing.T) { + calls, dir := standIn(t, "echo "+pullRequest+"\n"), t.TempDir() + + got, err := github.Open(t.Context(), dir, github.PullRequest{ + Branch: "baton/demo", Title: "-Add a demo", BodyFile: "/notes/05-lead-to-orchestrator.md", + }) + if err != nil || got != pullRequest { + t.Errorf("Open = %q, %v; want %q", got, err, pullRequest) + } + + want := dir + "\npr\ncreate\n--head=baton/demo\n--title=-Add a demo\n" + + "--body-file=/notes/05-lead-to-orchestrator.md\n" + if got := called(t, calls); got != want { + t.Errorf("gh ran in and with:\n%s\nwant:\n%s", got, want) + } +} + +func TestOpenReportsWhyGhFailed(t *testing.T) { + standIn(t, "echo 'a pull request for branch \"baton/demo\" into branch \"main\" already "+ + "exists:' >&2\necho "+pullRequest+" >&2\nexit 1\n") + + _, err := github.Open(t.Context(), t.TempDir(), github.PullRequest{Branch: "baton/demo"}) + + want := "open the pull request: a pull request for branch \"baton/demo\" into branch \"main\" " + + "already exists:\n" + pullRequest + " (exit status 1)" + if err == nil || err.Error() != want { + t.Errorf("Open = %v; want %q", err, want) + } +} + +func TestOpenReportsAMissingGh(t *testing.T) { + _, err := github.Open(t.Context(), t.TempDir(), github.PullRequest{Branch: "baton/demo"}) + if want := `open the pull request: exec: "gh": executable file not found in $PATH`; err == nil || + err.Error() != want { + t.Errorf("Open = %v; want %q", err, want) + } +} + +func TestCommentCommentsOnThePullRequest(t *testing.T) { + calls, dir := standIn(t, "echo "+pullRequest+"#issuecomment-1\n"), t.TempDir() + + err := github.Comment(t.Context(), dir, pullRequest, "/notes/09.md") + if err != nil { + t.Fatal(err) + } + + want := dir + "\npr\ncomment\n--body-file=/notes/09.md\n--\n" + pullRequest + "\n" + if got := called(t, calls); got != want { + t.Errorf("gh ran in and with:\n%s\nwant:\n%s", got, want) + } +} + +func TestCommentReportsAGhThatFailsWithoutAReason(t *testing.T) { + standIn(t, "exit 4\n") + + err := github.Comment(t.Context(), t.TempDir(), pullRequest, "/x.md") + if want := "comment on the pull request " + pullRequest + ": exit status 4"; err == nil || + err.Error() != want { + t.Errorf("Comment = %v; want %q", err, want) + } +} + +func TestOpenTrimsWhatGhPrints(t *testing.T) { + standIn(t, "printf '\\n https://github.com/example/demo/pull/8 \\n\\n'\n") + + got, err := github.Open(t.Context(), t.TempDir(), github.PullRequest{Branch: "baton/demo"}) + if want := "https://github.com/example/demo/pull/8"; err != nil || got != want { + t.Errorf("Open = %q, %v; want %q", got, err, want) + } +} diff --git a/internal/plugin/agents_test.go b/internal/plugin/agents_test.go index 5a472d4..bdba6fe 100644 --- a/internal/plugin/agents_test.go +++ b/internal/plugin/agents_test.go @@ -136,13 +136,14 @@ func readAgent(t *testing.T, tree fs.FS, path string) (map[string]string, string // commands returns what the role's prompt must say for the role to move the work, to write its // notes in its session's files/ folder (decision 48), to wait, and to ask or answer: the -// orchestrator reads tickets, starts features, answers questions and hands features back, and the -// others hand over and ask. +// orchestrator reads tickets, starts features, answers questions, hands features back and +// delivers them, and the others hand over and ask. func commands(role flow.Role) []string { said := []string{"start `baton wait`", "in async mode", "in your session's `files/` folder"} if role == flow.RoleOrchestrator { return append(said, "`baton jira <KEY>`", "baton feature start <slug> --brief", - "baton handover <slug> --to", "`baton answer <slug> \"<answer>\"`") + "baton handover <slug> --to", "`baton answer <slug> \"<answer>\"`", + "`baton deliver <slug> --title \"<title>\"`", "`baton deliver <slug>`") } said = append(said, "baton handover --note", "start `baton ask \"<question>\"`") diff --git a/internal/plugin/files/com.github.copilot/agents/orchestrator.agent.md b/internal/plugin/files/com.github.copilot/agents/orchestrator.agent.md index 4342965..8b22cc7 100644 --- a/internal/plugin/files/com.github.copilot/agents/orchestrator.agent.md +++ b/internal/plugin/files/com.github.copilot/agents/orchestrator.agent.md @@ -17,7 +17,7 @@ Write the feature's brief, its part of the approved plan, in a new file in your A feature comes back to you when its team is done with it, or stuck, and a role asks you when it needs a call that it can't make. Keep one `baton wait` running while any feature is running: start `baton wait` with the bash tool in async mode after you start a feature or hand one back, unless one is running already (list_bash shows it). Never run it in sync mode. It ends when a feature comes back or a role asks, and its end wakes you: read what it printed, and the note that it names, if any. Then start it again if any feature is still running. -- **Ready**: the reviewer approved the work, and the lead committed it on the feature's branch. The note is the lead's final report. Report the feature to the user: its branch, what changed, the checks and their results, and the findings left open. Then do what the user says. +- **Ready**: the reviewer approved the work, and the lead committed it on the feature's branch. The note is the lead's final report. Report the feature to the user: its branch, what changed, the checks and their results, and the findings left open. Propose a title for its pull request, if it has none yet, and give the report's path: a delivery publishes the report as it is. Then do what the user says. - **Escalated**: the feature used up its rounds, and QA or the reviewer still rejects the work. The note is their last one. Tell the user why it's stuck, and what you would do. - **A question**: the role that asked waits for your answer, and can't hand over until it comes. Answer it from the plan and what the user said, with `baton answer <slug> "<answer>"`, quoted for the shell. If they don't settle it, ask the user first. @@ -25,6 +25,10 @@ A feature comes back to you when its team is done with it, or stuck, and a role On the user's word, write a note in a new file in your session's `files/` folder, as you write a brief, then run `baton handover <slug> --to lead --note <file>` with a new brief, which starts the feature's rounds again, or, after an escalation, `baton handover <slug> --to engineer --note <file>` for one more round. Then make sure that `baton wait` is running. +## Delivering + +On the user's word, deliver a ready feature with `baton deliver <slug> --title "<title>"`, with the title that the user approved, quoted for the shell. baton pushes the feature's branch to origin and opens its pull request on GitHub, into the repo's default branch, with the lead's final report as its description, and keeps the pull request's link. A feature that you hand back after its delivery comes back ready again, with a new report: deliver it again with `baton deliver <slug>`, without a title, and baton pushes the new commits and comments the new report on the same pull request. If baton refuses, or the push or gh fails, tell the user what it said: the feature stays ready, and you can deliver it again once they've fixed it. + ## Talking to the user Ask the user with ask_user. When the user says that they're away, ask with nudge-ask and report with nudge-report instead, if you have them. @@ -33,6 +37,6 @@ Ask the user with ask_user. When the user says that they're away, ask with nudge - Never edit the repo's files or commit: the team does the work, in the feature's worktree. - Never type into the panes of a feature's session, and never start agents: baton runs the team, and the handovers move the work. -- Never push, open a pull request, merge or write to Jira, unless the user tells you to. +- Never push, open a pull request, merge or write to Jira, unless the user tells you to. Push and open pull requests with `baton deliver` alone, which keeps the feature's pull request. - baton's guard asks the user before each command that can act outside the Mac, and before each `baton feature start` while plan approval is on. If the user says no, ask them what they want instead. The guard also stops your file edits outside your session's folder. - `baton status` shows every feature, with its holder, round and status. diff --git a/internal/repo/push_test.go b/internal/repo/push_test.go new file mode 100644 index 0000000..f44200d --- /dev/null +++ b/internal/repo/push_test.go @@ -0,0 +1,40 @@ +package repo_test + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/codedogapp/baton/internal/repo" +) + +func TestPushSendsTheBranchToOriginAndTracksIt(t *testing.T) { + mainDir, linked := repos(t) + origin := filepath.Join(t.TempDir(), "origin.git") + run(t, mainDir, "init", "--quiet", "--bare", origin) + run(t, mainDir, "remote", "add", "origin", origin) + run(t, linked, "commit", "--quiet", "--allow-empty", "--message", "Work") + + if err := repo.Push(t.Context(), mainDir, "linked"); err != nil { + t.Fatal(err) + } + + got, want := run(t, origin, "rev-parse", "linked"), run(t, linked, "rev-parse", "HEAD") + if got != want { + t.Errorf("origin's linked is at %s, want the branch's %s", got, want) + } + + if got := run(t, linked, "rev-parse", "--abbrev-ref", "@{upstream}"); got != "origin/linked" { + t.Errorf("the branch's upstream is %q, want origin/linked", got) + } +} + +func TestPushRefusesARepoWithoutOrigin(t *testing.T) { + mainDir, _ := repos(t) + + err := repo.Push(t.Context(), mainDir, "linked") + if want := "push linked to origin: fatal: 'origin' does not appear to be a git repository"; err == nil || + !strings.HasPrefix(err.Error(), want) { + t.Errorf("Push = %v; want %q, then the rest of git's reason", err, want) + } +} diff --git a/internal/repo/repo.go b/internal/repo/repo.go index dc41116..71aba09 100644 --- a/internal/repo/repo.go +++ b/internal/repo/repo.go @@ -1,5 +1,6 @@ // Package repo runs git for baton: it finds the repo that baton works in, the repo's origin remote -// and its remotes' URLs, and git's version, and makes a feature's branch and worktree. +// and its remotes' URLs, and git's version, makes a feature's branch and worktree, and pushes the +// branch. package repo import ( @@ -60,6 +61,17 @@ func Remote(ctx context.Context, dir string) (string, error) { return strings.TrimSuffix(out, "\n"), nil } +// Push pushes the branch of the repo that holds the folder to the origin remote, to the branch of +// the same name there, which it makes the branch's upstream (decision 61). +func Push(ctx context.Context, dir, branch string) error { + _, err := git(ctx, dir, "push", "--set-upstream", "origin", "refs/heads/"+branch) + if err != nil { + return fmt.Errorf("push %s to origin: %w", branch, err) + } + + return nil +} + // git runs git in the folder and returns what it printed. func git(ctx context.Context, dir string, args ...string) (string, error) { // Sonar's go:S4036 wants git at a fixed path, but baton runs the user's own git, found on the diff --git a/internal/state/feature.go b/internal/state/feature.go index c0e64f5..9f54e23 100644 --- a/internal/state/feature.go +++ b/internal/state/feature.go @@ -20,6 +20,9 @@ type Feature struct { Branch string `json:"branch,omitempty"` // Worktree is the absolute path of the feature's worktree, where its roles work. Worktree string `json:"worktree,omitempty"` + // PullRequest is the URL of the pull request that baton deliver opened for the feature's + // branch, once it has; later deliveries comment on it (decision 61). + PullRequest string `json:"pullRequest,omitempty"` // Flow is where the feature stands in the flow. Flow flow.State `json:"flow"` // Work is the holder's work: the last note, since each note goes to the holder after the diff --git a/internal/state/journal.go b/internal/state/journal.go index 1cd7160..4d9bfb1 100644 --- a/internal/state/journal.go +++ b/internal/state/journal.go @@ -30,6 +30,9 @@ const ( EventPickup = "pickup" EventQuestion = "question" // The holder asked the orchestrator a question. EventAnswer = "answer" // The orchestrator answered it. + // EventDeliver means that baton deliver pushed the feature's branch and opened its pull + // request, or commented the lead's final report on it. + EventDeliver = "deliver" ) // Entry is what a change adds to the feature's journal: its event, and the note that came with @@ -49,7 +52,7 @@ type Event struct { Role flow.Role `json:"role"` // The role that acted. 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. + Text string `json:"text,omitempty"` // The question, the answer, or the pull request. Gate string `json:"gate,omitempty"` // The gate that passed before the handover. Flow flow.State `json:"flow"` // Where the feature stands after the event. }