breakaway 1.4.0-main.8 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,30 +2,61 @@
2
2
 
3
3
  You're helping someone set up their own breakaway board: a task board for them and their coding agents, on their own Cloudflare account. It's theirs: they own it, run it, and decide. You do the typing. This file is the whole job. Read it to the end before you start, then work through it one step at a time.
4
4
 
5
- The owner started you with something like: "Set up a breakaway board for me. Read https://leavethepack.dev/install.md and follow it." The full guide behind these steps is at https://leavethepack.dev/docs/quickstart/, if a step needs more detail.
5
+ The owner started you with something like: "Set up a breakaway board for me. Read https://leavethepack.dev/install.md and follow it." The full guide behind these steps is at https://leavethepack.dev/docs/quickstart/, if a step needs more detail. The install is done when one real task is closed by a merged pull request, not when the board is deployed.
6
6
 
7
7
  ## How you work
8
8
 
9
- - **They decide, you do the typing.** Before anything that changes something outside this folder (a GitHub repository or its settings, anything on Cloudflare, a deploy), say in one line what it does and wait for a yes.
9
+ - **They decide, you do the typing.** Before anything that changes something outside this folder (a GitHub repository or its settings, anything on Cloudflare, a deploy, a task on the board), say in one line what it does and wait for a yes.
10
+ - **Look before you make.** Never make anything twice: a second install repository, Worker, `tasks.env`, GitHub App, or routine. When something exists but doesn't match what you expect, stop and ask.
10
11
  - **Never ask for a secret in the chat, and never print one.** A token or key goes straight from where it is to where it's needed: a command reads it from a file without showing it, or the owner types it into their own terminal, where nothing reaches you. If one lands in the conversation anyway, say so, and have them make a new one.
11
- - **Some steps are theirs.** Signing in to Cloudflare, GitHub, and claude.ai, making a token, anything in a browser, and the commands that ask for a secret. Say exactly what to click or type, then wait until they say it's done.
12
- - **Each step ends with a check.** Don't go on until it passes. When it doesn't, say what failed and what to do about it. Once the board is up, `npx breakaway connections` lists everything it leans on, with the fix for each row that needs attention.
12
+ - **Some steps are theirs.** Signing in to Cloudflare, GitHub, and claude.ai, making a token, anything in a browser, and the commands that ask for a secret. For each, name the page (with its link), what to press or type, and what the page shows when it worked. Then wait until they say it's done.
13
+ - **Each step ends with a check, read one of three ways.** **Verified**: a command or the board tested it. **Not verified yet**: it's set up, but nothing has used it; say what will verify it. **Failed**: say what failed and the fix. Never call a step done when it's only Not verified yet. Once the board is up, `npx breakaway connections` lists everything it leans on: a row that reads Working or Verified is Verified, Not verified yet is the same, and Needs attention is Failed, with the row's fix.
14
+ - **POSIX shell only.** Every command here runs in a POSIX shell on macOS, Linux, or Windows through WSL.
13
15
  - **Keep it short.** One step at a time: what you're doing and why, in a sentence, then do it.
14
16
 
15
- ## 0. What's there, and what they want
17
+ ## 0. The system, and what's there
16
18
 
17
- Check, and help install what's missing:
19
+ **The system.** Run `uname -s`. `Darwin` is macOS and `Linux` is Linux, or WSL when `grep -qi microsoft /proc/version` succeeds: carry on. Anything else (`MINGW…`, `MSYS…`, `CYGWIN…`, or no `uname`) is Windows itself, which the board doesn't support. Say so plainly, and that the fix is WSL: they open PowerShell as administrator, run `wsl --install`, restart, and open **Ubuntu** from the Start menu (https://learn.microsoft.com/windows/wsl/install). Inside it they install Node and Claude Code, make a folder, open Claude Code there, and paste the same line. Everything from here happens inside WSL; stop until then.
20
+
21
+ **The tools.** Check, and help install what's missing:
18
22
 
19
23
  - Node 20 or later: `node --version`.
20
- - Git, and the GitHub CLI signed in: `gh auth status`. If it isn't, they run `gh auth login` themselves.
21
- - Wrangler signed in to their Cloudflare account: `npx wrangler whoami`. If it isn't, they run `npx wrangler login` themselves. Note the account ID it shows: it isn't a secret, and step 1 needs it.
24
+ - Git, and the GitHub CLI signed in: `gh auth status`. If it isn't, they run `gh auth login` themselves and pick GitHub.com and the browser; it ends with "Logged in as <login>".
25
+ - Wrangler signed in to their Cloudflare account: `npx wrangler whoami`. If it isn't, they run `npx wrangler login` themselves: a Cloudflare page opens, they press **Allow**, and the terminal says "Successfully logged in". Note the account ID `whoami` shows: it isn't a secret, and step 1 needs it.
26
+
27
+ **What's there.** Before asking anything, look, in this order, and say what you found in one short list:
28
+
29
+ 1. `~/.config/breakaway/tasks.env`: `test -f ~/.config/breakaway/tasks.env`, and which board it's for, `sed -n 's/^BREAKAWAY_URL=//p' ~/.config/breakaway/tasks.env`. Read nothing else from it.
30
+ 2. The install repository: `breakaway.config.json` in this folder, or in the folder they name. If it's there, its `worker` and `url`, and `git remote get-url origin`.
31
+ 3. The Worker: `npx wrangler deployments list --name <worker>`.
32
+ 4. The board: `npx breakaway health`.
33
+ 5. Its repositories: `npx breakaway repos`.
34
+ 6. Its connections: `npx breakaway connections --json`.
35
+
36
+ Then carry on from the first step below that isn't done, and never repeat one that is:
37
+
38
+ | Step | Done when |
39
+ | --- | --- |
40
+ | 1. The install repository | `breakaway.config.json` is on the GitHub repository's `main`, and its `production` environment has both Cloudflare secrets |
41
+ | 2. The board's secrets | `tasks.env` exists |
42
+ | 3. Deploy | `health` says the board is healthy, and `tasks.env` has its address |
43
+ | 4. The repository their agents work on | `repos` lists it |
44
+ | 5. GitHub | the GitHub rows on Connections are Verified, and the board's files are on the repository's default branch |
45
+ | 6. Agents from the board | its **Agent routine** row is connected (or they have no routines) |
46
+ | 7. The first task | a task is closed by a merged pull request |
22
47
 
23
- Then ask, in one message:
48
+ Stop and ask, and make nothing, when what's there doesn't fit:
49
+
50
+ - a `tasks.env` for another address, or a `breakaway.config.json` whose `worker` isn't the Worker they mean;
51
+ - a Worker that exists while `tasks.env` doesn't: never run `init-secrets` for it, since new secrets lock them out of that board. Their copy may be in their password manager; otherwise https://leavethepack.dev/docs/operations/ says how to recover each value;
52
+ - `health` answering 401: the token in `tasks.env` isn't the one on the Worker. `npx wrangler secret list --name <worker>` shows only names: if the three secrets are already there, they're another `tasks.env`'s, so never put new ones over them.
53
+
54
+ Then ask what's still open, in one message, and nothing the look already answered:
24
55
 
25
56
  1. What to call the board, and the GitHub `owner/name` for its install repository, the private repository that deploys it (for example `<their login>/my-board`).
26
57
  2. Where the board should answer: an address on a domain in their Cloudflare account (like `tasks.example.com`), or nothing, for a `workers.dev` address.
27
58
  3. The repository their agents will work on (`owner/name`), a short name for it, and its areas, each with a work-ID prefix (like `app:APP` or `docs:DOC`).
28
- 4. Whether they have a Claude plan with routines, to start agents from the board. Without one, the board works the same, and they start agents themselves.
59
+ 4. Whether they have a Claude plan with routines, to start agents from the board. Without one, the board works the same, but they give up starting agents from the board, live output on a task, starting by itself when ready, chase, and scheduled routines; they start agents themselves in Claude Code.
29
60
  5. `stable` (recommended: each new release comes as a pull request they merge) or `main` (the board follows every change to breakaway).
30
61
 
31
62
  ## 1. The install repository
@@ -48,7 +79,7 @@ gh secret set CLOUDFLARE_ACCOUNT_ID --env production --repo <owner>/<name> --bod
48
79
  gh api -X PUT repos/<owner>/<name>/actions/permissions/workflow -f default_workflow_permissions=read -F can_approve_pull_request_reviews=true
49
80
  ```
50
81
 
51
- The Cloudflare API token is theirs to make. On Cloudflare: My Profile, API Tokens, Create Token, the **Edit Cloudflare Workers** template, for their account. For an address on their own domain, also add Zone, Workers Routes, Edit, for that domain's zone. Then they run this in their own terminal, not through you. It asks for the token without showing it:
82
+ **Theirs: the Cloudflare API token.** At https://dash.cloudflare.com/profile/api-tokens, they press **Create Token**, then **Use template** beside **Edit Cloudflare Workers**. Under **Account Resources** they pick their account; under **Zone Resources**, their domain's zone for an address on their own domain, or all zones otherwise. **Continue to summary**, then **Create Token**: the page shows the token once. Then they run this in their own terminal, not through you; it asks for the token without showing it, and says "Set Actions secret CLOUDFLARE_API_TOKEN":
52
83
 
53
84
  ```sh
54
85
  gh secret set CLOUDFLARE_API_TOKEN --env production --repo <owner>/<name>
@@ -60,15 +91,17 @@ That token can run `wrangler deploy`, so Deploy can apply a new address, cron tr
60
91
  gh variable set BREAKAWAY_DEPLOY_CHANGES --body true --repo <owner>/<name>
61
92
  ```
62
93
 
63
- **Check:** `gh api repos/<owner>/<name>/environments/production/secrets --jq '.secrets[].name'` lists both secrets, and the workflows are on `main`.
94
+ **Check:** `gh api repos/<owner>/<name>/environments/production/secrets --jq '.secrets[].name'` lists both secrets, and the workflows are on `main`. That's Verified; whether the token works is Not verified yet, until step 3's dry run.
64
95
 
65
96
  ## 2. The board's secrets
66
97
 
98
+ Only when step 0 found no `tasks.env` and no Worker:
99
+
67
100
  ```sh
68
101
  npx breakaway init-secrets
69
102
  ```
70
103
 
71
- It writes `~/.config/breakaway/tasks.env` (only they can read it) and prints which value goes where, never the values. Tell them to keep a copy of that file in their password manager: it holds the only copy of the sync secret.
104
+ It writes `~/.config/breakaway/tasks.env` (only they can read it), lists what to keep, and prints which value goes where, never the values. Never run it with `--force` on an install that has a board.
72
105
 
73
106
  **Check:** the file exists. The secrets go on the Worker in step 3, once it exists.
74
107
 
@@ -82,9 +115,9 @@ gh run list --repo <owner>/<name> --workflow deploy.yml --limit 1 # it takes a
82
115
  gh run watch <run ID> --repo <owner>/<name> --exit-status
83
116
  ```
84
117
 
85
- When the dry run passes, run it again without `-f dry-run=true` and watch it the same way. It stops with a message when something needs their hands: read the run's log (`gh run view <run ID> --repo <owner>/<name> --log-failed`) and say what it asks for.
118
+ When the dry run passes, run it again without `-f dry-run=true` and watch it the same way. On an address on their own domain, this first deploy can wait up to five minutes for its certificate, and can end with a notice that the board is waiting for its secrets: that's expected, they go on next. It stops with a message when something needs their hands, and it refuses to make a second, empty board when the install already has one: read the run's log (`gh run view <run ID> --repo <owner>/<name> --log-failed`) and say what it asks for.
86
119
 
87
- Then put the three secrets on the Worker. Each command reads its value from `tasks.env` and pipes it to Wrangler, so it never shows:
120
+ Then put the three secrets on the Worker, unless `npx wrangler secret list --name <worker-name>` already lists them (step 0 says what then). Each command reads its value from `tasks.env` and pipes it to Wrangler, so it never shows:
88
121
 
89
122
  ```sh
90
123
  v() { sed -n "s/^$1=//p" ~/.config/breakaway/tasks.env; }
@@ -93,9 +126,9 @@ v BREAKAWAY_CLIENT_ID | npx wrangler secret put TASKS_CLIENT_ID --name <worker-n
93
126
  v BREAKAWAY_SYNC_KEY | npx wrangler secret put TASKS_SYNC_KEY --name <worker-name>
94
127
  ```
95
128
 
96
- The board's address is the one they chose, or the `workers.dev` one the run's log shows. Add it to `tasks.env` as `BREAKAWAY_URL=<address>`, and to the install repository as the variable `BREAKAWAY_URL` (`gh variable set BREAKAWAY_URL --repo <owner>/<name> --body <address>`), so later deploys check it.
129
+ The board's address is the one they chose, or the `workers.dev` one the run's log shows. Add it to `tasks.env` as `BREAKAWAY_URL=<address>` if it isn't there, and to the install repository as the variable `BREAKAWAY_URL` (`gh variable set BREAKAWAY_URL --repo <owner>/<name> --body <address>`), so later deploys check it.
97
130
 
98
- **Check:** `npx breakaway health` says the board is healthy. Then they open the address in their browser and sign in with `BREAKAWAY_TOKEN` from `tasks.env`, copying it from the file themselves.
131
+ **Check:** `npx breakaway health` says the board is healthy. Then they open the address in their browser and sign in with `BREAKAWAY_TOKEN` from `tasks.env`, copying it from the file themselves. The board says it has no repository yet and points to **Set up the board** on Connections, which ticks the steps below as they work.
99
132
 
100
133
  ## 4. The repository their agents work on
101
134
 
@@ -111,24 +144,54 @@ The first repository is the board's default. A prefix belongs to one repository
111
144
 
112
145
  The board reads GitHub through a private GitHub App made for it.
113
146
 
114
- 1. **Theirs:** on the board's GitHub view, press **Connect GitHub**, make the App, and copy the code it shows. Then they run `npx breakaway github-connect <code>` in their own terminal. It stores the App's keys and deploys a new version of the Worker.
115
- 2. **Theirs:** install the App on the repository from step 4, and on the install repository if they chose `main` (the board starts its Deploy workflow).
147
+ 1. **Theirs: make the App.** On the board, they open **GitHub** in the sidebar and press **Create the App on GitHub**. GitHub shows a **Create GitHub App** page with what the App may read; they keep the name and press **Create GitHub App**, and land back on the board, which shows a `npx breakaway github-connect <code>` command. They run it in their own terminal, in this folder, within the hour. It stores the App's keys and deploys a new version of the Worker. If it refuses because the board already has a working App, stop: that's the App to keep, and `--replace` is only for one Connections says has failed.
148
+ 2. **Theirs: install it.** The command prints the install link. On GitHub they pick **Only select repositories**, choose the repository from step 4 (and the install repository too if they chose `main`, so the board can start its Deploy workflow), and press **Install**. Within a few seconds the board's GitHub view fills in.
116
149
  3. **Yours, after a yes:** turn on auto-merge for the repository: `gh api -X PATCH repos/<owner/name> -F allow_auto_merge=true`.
117
- 4. **Yours:** add the board's files to the repository with `npx breakaway repos init <slug>`. It writes the agent prompt from sections you can fill in well: read the repository's README, `AGENTS.md`, and package scripts first, then pass `--building`, `--checks`, and `--pull-requests` from what you found. Ask them for `--direction` (what matters most right now) and `--never-share` (what must never leave the repository). It opens a pull request on the repository. They review and merge it.
150
+ 4. **Yours:** add the board's files to the repository with `npx breakaway repos init <slug>`. It writes the agent prompt from sections you can fill in well: read the repository's README, `AGENTS.md`, and package scripts first, then pass `--building`, `--checks`, and `--pull-requests` from what you found. Ask them for `--direction` (what matters most right now) and `--never-share` (what must never leave the repository). It opens a pull request on the repository. They review and merge it on GitHub.
118
151
 
119
- **Check:** `npx breakaway connections` shows the GitHub rows as Working: the App, the webhook (once GitHub has sent one), installed, permissions, auto-merge, and sync.
152
+ **Check:** `npx breakaway connections` shows the GitHub rows Verified: the App, installed, permissions, auto-merge, and sync. The webhook stays Not verified yet until GitHub sends one, which the merge in item 4 does.
120
153
 
121
154
  ## 6. Agents from the board
122
155
 
123
- Only with a Claude plan that has routines. Otherwise skip to step 7.
156
+ Only with a Claude plan that has routines. Without one, say again what they give up (step 0, question 4) and go to step 7.
157
+
158
+ **Theirs: the routine.** At https://claude.ai/code/routines, they press **New routine**, and go down this list, one line at a time, saying each one is done:
159
+
160
+ - [ ] **Repository:** the one from step 4.
161
+ - [ ] **Instructions:** the stub, from the board's **Agents** view (**Copy stub**), pasted as it is.
162
+ - [ ] **Cloud environment, network access:** **Custom**, with the board's host under **Allowed domains**, and **Also include default list of common package managers** ticked.
163
+ - [ ] **Cloud environment, API credential:** **Add credential**, type **Bearer**, the board's host as the allowed website, and `BREAKAWAY_TOKEN` from `tasks.env` as the value, which they copy from the file themselves. Not as an environment variable: the credential keeps the token out of the session.
164
+ - [ ] **Cloud environment, environment variable:** `BREAKAWAY_AGENT=claude-cloud`.
165
+ - [ ] **Trigger:** **Add trigger**, **API**. It shows a URL and a token, the token once.
166
+
167
+ **Theirs: connect it.** On the board's Connections, the **Agent routine** row's form takes the trigger's URL and token; or they run `npx breakaway agents-connect` in their own terminal and paste them when it asks.
168
+
169
+ **Check:** `npx breakaway connections` shows **Agent routine** connected and **Not verified yet**: the board can't read claude.ai, so nothing proves the routine works until an agent it starts claims a task. Step 7 does that. Don't start an agent just to test it.
170
+
171
+ ## 7. The first task
172
+
173
+ The install is done when one real task is closed by its merged pull request.
174
+
175
+ 1. **Propose one.** Read the repository's README and its open issues (`gh issue list --repo <owner/name> --limit 20`), and propose one small, useful task: a title, a sentence on why, and a done when they can check in a few minutes. After a yes, add it from a checkout of the repository (`gh repo clone <owner/name>` next to this folder, if there's none):
176
+
177
+ ```sh
178
+ npx breakaway add "<title>" --project <area> --tag agent --horizon now --brief "<what and why>" --done-when "<what they can check>"
179
+ ```
180
+
181
+ 2. **With routines:** they open **Set up the board** on Connections; its last step opens the **Add a repository** wizard's agent step, where they press **Start an agent on <ID>** (if it names another task, they press **Start an agent** on <ID>'s own page instead). The step ticks as it goes: started, live output, pull request. If the start fails, the step says why and the fix, and **Try again**. The **Agent routine** row reads **Verified by <ID>** once the agent claims the task.
182
+ 3. **Without routines:** they open Claude Code in the checkout and say "Work on <ID> from the board". The board's files tell it how to claim, report, and open the pull request.
183
+ 4. **Theirs: review and merge.** The pull request's title starts with the work ID and its description says `Closes <ID>.` They check it against the done when, and merge it on GitHub or on the board's pull request page. Merging stays theirs; the agent never merges.
184
+
185
+ **Check:** `npx breakaway show <ID>` says it's completed, merged in the pull request. With routines, the wizard's last check, merged, ticks too.
124
186
 
125
- 1. **Theirs:** at https://claude.ai/code/routines, make a routine for the repository, with a cloud environment whose network access allows the board's host (Custom, plus the default package managers). Add the board's token as an API credential for that host, and `BREAKAWAY_AGENT=claude-cloud` as an environment variable. Paste the stub `repos init` wrote (`tools/tasks/prompts/stub.md` in the repository) as its instructions, and add an API trigger.
126
- 2. **Theirs:** run `npx breakaway agents-connect` in their own terminal and paste the trigger's URL and token when it asks.
187
+ ## Before you stop
127
188
 
128
- **Check:** `npx breakaway connections` shows **Agent routine** as Working. Once they start an agent on a task, **Live output from sessions** reads Working when its session sends something back.
189
+ Run `npx breakaway connections` and go through anything that's Failed, with the fix each row gives. Taskwarrior is optional: if they use it, `npx breakaway setup` connects it.
129
190
 
130
- ## 7. Done
191
+ Ask them to confirm they've put these in their password manager, whole, since the board can't give the values back:
131
192
 
132
- Run `npx breakaway connections` and go through anything that still needs attention, with the fix each row gives. Taskwarrior is optional: if they use it, `npx breakaway setup` connects it.
193
+ - `~/.config/breakaway/tasks.env`: the token, and the only copy of the sync secret.
194
+ - `~/.config/breakaway/tasks-routines.json`, when there is one: other repositories' routines.
195
+ - `~/.config/breakaway/github-app.json`, only if `github-connect` wrote it: the App's keys, until they're stored.
133
196
 
134
- Then add a first task together, in a checkout of the repository: `npx breakaway add "<something small that needs doing>" --project <area> --tag agent --horizon now`. Show them the board. Tell them where things are: the board in their browser, `npx breakaway help` for the CLI, and the docs at https://leavethepack.dev/docs/. Then stop. From here on, the board is theirs.
197
+ Tell them how to get back in: open Claude Code in this same folder and paste the same line; it looks first and carries on where it stopped. Tell them where things are: the board in their browser, `npx breakaway help` for the CLI, and the docs at https://leavethepack.dev/docs/. Then stop. From here on, the board is theirs.
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Writes src/board-files.json (BRK-132): the board's files repos init reads (boardSources in src/init.js), by path,
4
+ * so the Worker renders an empty repository's first commit from the same files as the CLI. It's generated, never
5
+ * committed (BRK-148): `pnpm install`, `build`, `typecheck`, `test`, and `deploy` run this first, and the release
6
+ * workflow's build puts it in the bundle.
7
+ */
8
+ import { readFileSync, writeFileSync } from 'node:fs';
9
+ import { boardSources } from '../src/init.js';
10
+
11
+ const ROOT = new URL('../', import.meta.url);
12
+ const read = (path) => readFileSync(new URL(path, ROOT), 'utf8');
13
+
14
+ export const boardFiles = () => Object.fromEntries(boardSources(read).map((path) => [path, read(path)]));
15
+
16
+ if (import.meta.url === `file://${process.argv[1]}`) {
17
+ writeFileSync(new URL('src/board-files.json', ROOT), `${JSON.stringify(boardFiles(), null, 2)}\n`);
18
+ // On stderr: npm pack and npm publish run it (prepare), and their --json goes to stdout.
19
+ console.error('Wrote src/board-files.json.');
20
+ }
@@ -13,9 +13,10 @@ import {
13
13
  bumpBody,
14
14
  deployPlan,
15
15
  deployTarget,
16
- isHealthy,
17
16
  latestReleases,
17
+ newWorkerStop,
18
18
  parseState,
19
+ pingHealth,
19
20
  previousVersionId,
20
21
  shapeOf,
21
22
  updatePlan,
@@ -152,6 +153,15 @@ export async function runStep(step, opts, io) {
152
153
  `Couldn't list the Worker's deployments, so the deploy stopped before changing anything. Check that CLOUDFLARE_ACCOUNT_ID is your account's ID and that CLOUDFLARE_API_TOKEN can read Workers on it. Wrangler said:\n${output.trim()}`,
153
154
  );
154
155
  }
156
+ // A Worker that doesn't exist on an install that already has a board is a changed or mistyped name, not a first
157
+ // deploy (BRK-141): a dispatch has no earlier config for `check` to compare with.
158
+ const stop = newWorkerStop({
159
+ worker: configIn(join(dir, 'breakaway.config.json')).worker,
160
+ variable: opts.variable || null,
161
+ running: opts.running || null,
162
+ at: opts.at || null,
163
+ });
164
+ if (stop) throw new Stop(stop, 2);
155
165
  io.out('first=true');
156
166
  return 0;
157
167
  }
@@ -163,7 +173,12 @@ export async function runStep(step, opts, io) {
163
173
  } catch {
164
174
  ping = null;
165
175
  }
166
- return isHealthy(ping, opts.version) ? 0 : 1;
176
+ // A first deploy answers before its secrets are on (the install puts them on the Worker it made), so there it
177
+ // passes and says so; any later deploy needs them (BRK-141).
178
+ const health = pingHealth(ping, opts.version);
179
+ const first = opts.first === true || opts.first === 'true';
180
+ io.out(`health=${health}`);
181
+ return health === 'healthy' || (first && health === 'secrets') ? 0 : 1;
167
182
  }
168
183
  if (step === 'update') {
169
184
  const state = stateIn(dir);
@@ -253,9 +253,34 @@ export function workerMissing(output) {
253
253
  return /\[code: 10007\]|worker does not exist on your account/iu.test(String(output ?? ''));
254
254
  }
255
255
 
256
+ /**
257
+ * The message that stops a deploy about to make a new Worker on an install that already has a board (BRK-141), or null
258
+ * when making one is a first deploy. A board is there when the repository variable BREAKAWAY_URL is set (`variable`: the
259
+ * install sets it once the board answers) or the address the deploy checked (`at`) answered /api/ping with a release
260
+ * (`running`). A new Worker then means the name changed or is mistyped, and deploying it would open a second, empty board.
261
+ * @param {{ worker: string, variable?: string | null, running?: string | null, at?: string | null }} options
262
+ */
263
+ export function newWorkerStop({ worker, variable = null, running = null, at = null }) {
264
+ if (!variable && !running) return null;
265
+ const seen = running
266
+ ? `${at || variable || 'its address'} answers as breakaway ${running}`
267
+ : `the repository variable BREAKAWAY_URL is set (${variable})`;
268
+ return `There is no Worker named ${worker} on this Cloudflare account, but this install already has a board: ${seen}. Deploying would make a new Worker with an empty board, so nothing was deployed. Put "worker" in breakaway.config.json back to the name the board runs as (Workers & Pages on Cloudflare lists it), then run Deploy again. If that board is gone and you mean to start an empty one, delete the repository variable BREAKAWAY_URL, then run Deploy again.`;
269
+ }
270
+
271
+ /**
272
+ * What `/api/ping`'s answer says about the new release: `healthy` when it runs and its secrets load (BRK-96), `secrets`
273
+ * when it runs but a bound secret can't be read yet, and `down` for anything else.
274
+ * @returns {'healthy' | 'secrets' | 'down'}
275
+ */
276
+ export function pingHealth(ping, version) {
277
+ if (ping?.ok !== true || ping.release !== version) return 'down';
278
+ return ping.secrets?.ok === true ? 'healthy' : 'secrets';
279
+ }
280
+
256
281
  /** Whether `/api/ping`'s answer says the new release is running and its secrets load (BRK-96). */
257
282
  export function isHealthy(ping, version) {
258
- return Boolean(ping) && ping.ok === true && ping.release === version && ping.secrets?.ok === true;
283
+ return pingHealth(ping, version) === 'healthy';
259
284
  }
260
285
 
261
286
  /**
@@ -56,6 +56,25 @@ export function nextPrerelease(current, tags, prefix = 'v') {
56
56
  return { version, base, tag: `${prefix}${version}` };
57
57
  }
58
58
 
59
+ /**
60
+ * The version a stable release's pull request sets package.json to (BRK-118, WEB-39): the next minor or major after
61
+ * the stable, or null when there is nothing to set. A patch is null, since the pre-releases count patches by
62
+ * themselves, and so is a package.json already at or past the choice (main moved on before an older pre-release was
63
+ * released).
64
+ * @param {string} stable the version just released, like 1.3.0
65
+ * @param {string} next patch, minor, or major
66
+ * @param {string} current package.json's version on the default branch
67
+ * @returns {string | null}
68
+ */
69
+ export function nextVersion(stable, next, current) {
70
+ const [maj, min] = parts(stable);
71
+ parts(current);
72
+ if (next === 'patch') return null;
73
+ if (next !== 'minor' && next !== 'major') throw new Error(`next is patch, minor, or major, not "${next}".`);
74
+ const version = next === 'major' ? `${maj + 1}.0.0` : `${maj}.${min + 1}.0`;
75
+ return compareVersions(current, version) >= 0 ? null : version;
76
+ }
77
+
59
78
  /** The pre-release among `tags` (the tags on one commit), if one was already staged from it. */
60
79
  export function prereleaseAmong(tags, prefix = 'v') {
61
80
  const prerelease = prereleasePattern(prefix);
@@ -7,13 +7,15 @@
7
7
  * exists=true when that commit already has a pre-release (a second run for one merge stages nothing)
8
8
  * node scripts/package-release.mjs stable <pre-release tag> --prefix <tag prefix>
9
9
  * the stable version and tag it becomes, and the commit it was staged from; stops if that stable exists
10
+ * node scripts/package-release.mjs next <stable> <patch|minor|major> --dir <path>
11
+ * the version to set <path>/package.json to after the stable, empty for none (WEB-39)
10
12
  * Reads the tags with git, so run it in a checkout with its tags (fetch-depth: 0). Copied by `repos init`.
11
13
  */
12
14
  import { execFileSync } from 'node:child_process';
13
15
  import { readFileSync } from 'node:fs';
14
16
  import { join } from 'node:path';
15
17
  import { parseArgs } from 'node:util';
16
- import { nextPrerelease, prereleaseAmong, stableOf } from './lib/package-release.js';
18
+ import { nextPrerelease, nextVersion, prereleaseAmong, stableOf } from './lib/package-release.js';
17
19
 
18
20
  const { positionals, values: o } = parseArgs({
19
21
  allowPositionals: true,
@@ -41,9 +43,15 @@ try {
41
43
  throw new Error(`${o.prefix}${version} is already released. Pick a newer pre-release.`);
42
44
  const [commit] = git('rev-list', '-n', '1', `refs/tags/${tag}`);
43
45
  console.log(`version=${version}\ntag=${o.prefix}${version}\ncommit=${commit}`);
46
+ } else if (command === 'next' && tag) {
47
+ const current = JSON.parse(readFileSync(join(o.dir, 'package.json'), 'utf8')).version;
48
+ const version = nextVersion(tag, positionals[2] ?? 'patch', current);
49
+ if (!version && positionals[2] && positionals[2] !== 'patch')
50
+ console.error(`package.json already says ${current}, at or past the next ${positionals[2]} after ${tag}.`);
51
+ console.log(`version=${version ?? ''}`);
44
52
  } else {
45
53
  throw new Error(
46
- 'Usage: package-release.mjs prerelease --dir <path> --prefix <p> [--sha <commit>] | stable <tag> --prefix <p>',
54
+ 'Usage: package-release.mjs prerelease --dir <path> --prefix <p> [--sha <commit>] | stable <tag> --prefix <p> | next <stable> <patch|minor|major> --dir <path>',
47
55
  );
48
56
  }
49
57
  } catch (error) {
@@ -14,6 +14,7 @@ export const SUBCOMMANDS = {
14
14
  horizon: ['close'],
15
15
  hook: ['session', 'wait'],
16
16
  peloton: ['checkin', 'step', 'reply'],
17
+ specs: ['list', 'show'],
17
18
  };
18
19
 
19
20
  /** Commands that take nothing after their name, so a word there is a mistake (an old copy's missing subcommand, say). */
@@ -48,17 +49,18 @@ export function unknownSubcommand(command, first) {
48
49
 
49
50
  /**
50
51
  * What a CLI says about where it runs from, or null to say nothing (BRK-7). The CLI ships on npm, so `packaged` (run
51
- * through npx) has nothing to say. In a checkout of the board's own repository, `own` older than the board's (`board`,
52
- * its X-Tasks-Cli header) means pull. Anywhere else the CLI is an old copy that `repos init` used to commit: it
53
- * works while the API stays compatible, and on each run it says how to switch.
52
+ * through npx) has nothing to say. In a checkout of the board's own repository, `behind` (the board's `release`, its
53
+ * X-Tasks-Release header, isn't in this checkout's history: releaseBehind) means pull (BRK-148). Anywhere else the
54
+ * CLI is an old copy that `repos init` used to commit: it works while the API stays compatible, and on each run it says
55
+ * how to switch. `own` and `board` are the frozen CLI number (src/cli-version.js) such a copy carries and the board sends.
54
56
  */
55
- export function staleCliWarning({ own, board, boardCheckout, slug, packaged = false }) {
57
+ export function staleCliWarning({ own, board, boardCheckout, slug, packaged = false, release = null, behind = false }) {
56
58
  if (packaged) return null;
57
59
  const theirs = Number(board);
58
60
  const older = Number.isInteger(theirs) && theirs > own;
59
61
  if (boardCheckout) {
60
- if (!older) return null;
61
- return `this checkout's board CLI (version ${own}) is older than the board's (${theirs}), so a command may be missing or behave differently: pull the default branch to update it.`;
62
+ if (!release || !behind) return null;
63
+ return `this checkout is behind the board's release (v${release}), so a command may be missing or behave differently: pull the default branch to update it.`;
62
64
  }
63
65
  const newer = older ? `, older than the board's (${theirs}), so a command may be missing or behave differently` : '';
64
66
  return `this checkout carries a copy of the board's CLI (version ${own}${newer}). The CLI is on npm now: run it as npx ${CLI_PACKAGE} <command> instead of node scripts/tasks.mjs, and remove the copy with npx ${CLI_PACKAGE} repos init ${slug || '<slug>'} --update, which opens a pull request here.`;
@@ -76,6 +78,22 @@ export function githubRequest(repo, { sync = false } = {}) {
76
78
  return ['GET', repo ? `github?repo=${encodeURIComponent(repo)}` : 'github', undefined];
77
79
  }
78
80
 
81
+ /**
82
+ * Whether the checkout `git` runs in is behind the board's release `release` (BRK-148): it has the release's tag
83
+ * (`v1.4.0-main.9`, which the release workflow pushes) and that commit isn't in HEAD's history. Without the tag (tags
84
+ * not fetched yet), or in a `shallow` clone, whose cut history can hide an ancestor, it can't tell, and says no.
85
+ * `git(args)` runs git and returns its exit code.
86
+ * @param {string | null} release
87
+ * @param {(args: string[]) => number | null} git
88
+ * @param {{ shallow?: boolean }} [options]
89
+ */
90
+ export function releaseBehind(release, git, { shallow = false } = {}) {
91
+ if (shallow || !release || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(release)) return false;
92
+ const tag = `refs/tags/v${release}`;
93
+ if (git(['rev-parse', '-q', '--verify', `${tag}^{commit}`]) !== 0) return false;
94
+ return git(['merge-base', '--is-ancestor', tag, 'HEAD']) === 1;
95
+ }
96
+
79
97
  /** What `github fix` accepts for --problem: the same three the pull request page offers. */
80
98
  export const FIX_PROBLEMS = ['conflicts', 'failing', 'review'];
81
99
 
@@ -104,6 +122,30 @@ export function pullAgentRequest(action, number, { repo = null, problem, note, f
104
122
  return { request: ['POST', `github/pulls/${n}/${action}`, body] };
105
123
  }
106
124
 
125
+ /**
126
+ * `npx breakaway github release <pre-release> [--next patch|minor|major]` (BRK-103, WEB-39): the owner releases a
127
+ * package's pre-release as its stable, as Release on the GitHub page does, with what the default branch works toward
128
+ * next. The board starts the repository's release.yml stable job, and npm waits for the owner's 2FA; it refuses an
129
+ * agent, so the request always says who asks, and refuses a pre-release whose stable is already out (409).
130
+ * @param {string | undefined} version the pre-release, like 1.4.0-main.5 (or its tag)
131
+ * @param {{ repo?: string | null, by?: string, next?: string | null }} [options]
132
+ * @returns {{ error?: string, request?: [string, string, Record<string, string>] }}
133
+ */
134
+ export function packageReleaseRequest(version, { repo = null, by, next = null } = {}) {
135
+ const v = String(version ?? '').trim();
136
+ if (!/^(?:\S+@|v)?\d+\.\d+\.\d+-main\.\d+$/u.test(v))
137
+ return { error: 'say which pre-release: npx breakaway github release <version>, like 1.4.0-main.5' };
138
+ if (next !== null && next !== undefined && !['patch', 'minor', 'major'].includes(String(next)))
139
+ return { error: '--next is patch, minor, or major' };
140
+ return {
141
+ request: [
142
+ 'POST',
143
+ 'github/release',
144
+ { version: v, ...(next ? { next: String(next) } : {}), ...(repo ? { repo } : {}), ...(by ? { by } : {}) },
145
+ ],
146
+ };
147
+ }
148
+
107
149
  export const REVIEW_VERDICTS = ['ready', 'follow-up', 'changes'];
108
150
 
109
151
  /**
@@ -148,17 +190,27 @@ export function forceFields(force, by) {
148
190
  * With `decision` (`agents new --decision <ID> ["<note>"]`, BRK-110) the board writes the prompt from that answered
149
191
  * decision, in the decision's repository, and the text is the owner's note under it. With `next`
150
192
  * (`agents new --next minor|major ["<note>"]`, BRK-100) it writes the prompt that sets the repository's next version.
193
+ * With `spec` (`agents new --spec <path> "<what should change>"`, BRK-121) it writes the prompt that refines that spec
194
+ * and the tasks that link it, and the text, required, is what should change.
151
195
  * @param {string} prompt
152
- * @param {{ repo?: string | null, force?: boolean, by?: string, decision?: string | null, next?: string | null }} [options]
196
+ * @param {{ repo?: string | null, force?: boolean, by?: string, decision?: string | null, next?: string | null, spec?: string | null }} [options]
153
197
  */
154
- export function generalAgentRequest(prompt, { repo = null, force = false, by, decision = null, next = null } = {}) {
198
+ export function generalAgentRequest(
199
+ prompt,
200
+ { repo = null, force = false, by, decision = null, next = null, spec = null } = {},
201
+ ) {
155
202
  const text = String(prompt ?? '').trim();
156
- if (decision && next) return { error: 'start one from --decision or --next, not both' };
203
+ if ([decision, next, spec].filter(Boolean).length > 1)
204
+ return { error: 'start one from --decision, --spec, or --next: only one of them' };
157
205
  if (next && !['minor', 'major'].includes(next))
158
206
  return { error: 'patches count by themselves: --next minor or --next major' };
207
+ if (spec && !text)
208
+ return {
209
+ error: 'say what should change in the spec: npx breakaway agents new --spec <path> "<what should change>"',
210
+ };
159
211
  if (!text && !decision && !next)
160
212
  return { error: 'say what the agent should do: npx breakaway agents new "Tidy the docs" [--image <file>]' };
161
- const board = decision ? { decision } : next ? { next } : null;
213
+ const board = decision ? { decision } : next ? { next } : spec ? { spec: specPath(spec) } : null;
162
214
  const body = {
163
215
  ...(board ? { ...board, ...(text ? { note: text } : {}) } : { prompt: text }),
164
216
  ...(repo ? { repo } : {}),
@@ -170,18 +222,54 @@ export function generalAgentRequest(prompt, { repo = null, force = false, by, de
170
222
 
171
223
  /**
172
224
  * What the CLI says about a general agent's answer: the task and that it started, or why it waits (and whether Force
173
- * start could skip that), or, from a decision or for the next version (`next`), the open one that already has it.
225
+ * start could skip that), or, from a decision, for the next version (`next`), or on a spec (`spec`), the open one
226
+ * that already has it.
174
227
  * @param {{ task: { wid?: string, short?: string }, run?: { url?: string, agent?: string } | null, waiting?: string | null, forceable?: boolean, already?: string | null }} answer
175
- * @param {{ next?: string | null }} [options]
228
+ * @param {{ next?: string | null, spec?: unknown }} [options]
176
229
  */
177
- export function generalAgentSummary({ task, run, waiting, forceable, already }, { next = null } = {}) {
230
+ export function generalAgentSummary({ task, run, waiting, forceable, already }, { next = null, spec = null } = {}) {
178
231
  const id = task.wid ?? task.short;
179
- if (!run && already)
180
- return `${id} already ${next ? 'prepares the next version' : 'refines from these answers'}: ${already}.`;
232
+ if (!run && already) {
233
+ const what = next ? 'prepares the next version' : spec ? 'refines this spec' : 'refines from these answers';
234
+ return `${id} already ${what}: ${already}.`;
235
+ }
181
236
  if (run) return `Started ${run.agent ? `${run.agent} ` : 'an agent '}on ${id}${run.url ? `: ${run.url}` : ''}`;
182
237
  return `Saved ${id}, waiting to start: ${waiting ?? 'no room yet'}.${forceable ? ` Start it now past the board's limits: npx breakaway agents start ${id} --force` : ''}`;
183
238
  }
184
239
 
240
+ /** A spec's path as the board reads it: no leading `./`, no doubled or trailing slashes. */
241
+ const specPath = (path) =>
242
+ String(path ?? '')
243
+ .trim()
244
+ .replace(/^(\.\/)+/u, '')
245
+ .split('/')
246
+ .filter((part) => part && part !== '.')
247
+ .join('/');
248
+
249
+ /**
250
+ * The request behind `npx breakaway specs [list]` (BRK-121): the specs of the checkout's repository, or the one `--repo`
251
+ * names; without either, the board answers with its default repository's.
252
+ * @param {string | null} repo
253
+ * @returns {[string, string, undefined]}
254
+ */
255
+ export function specsRequest(repo) {
256
+ return ['GET', repo ? `specs?repo=${encodeURIComponent(repo)}` : 'specs', undefined];
257
+ }
258
+
259
+ /**
260
+ * The request behind `npx breakaway specs show <path>` (BRK-121): one spec, by its path in the repository. The board
261
+ * refuses a path outside the specs directory; one that climbs out with `..` is refused here first.
262
+ * @param {string | undefined} path
263
+ * @param {string | null} repo
264
+ */
265
+ export function specRequest(path, repo) {
266
+ const clean = specPath(path);
267
+ if (!clean) return { error: 'say which spec: npx breakaway specs show <path>, like docs/specs/BRK-1-thing.md' };
268
+ if (clean.split('/').includes('..')) return { error: `${clean.slice(0, 200)} climbs out of the repository` };
269
+ const query = repo ? `?repo=${encodeURIComponent(repo)}` : '';
270
+ return { request: ['GET', `specs/${clean.split('/').map(encodeURIComponent).join('/')}${query}`, undefined] };
271
+ }
272
+
185
273
  /**
186
274
  * What the CLI says about an answer to those requests: which task and agent took the pull request, or who already has it.
187
275
  * @param {'fix' | 'review'} action
@@ -409,3 +497,78 @@ export function chaseSummary(slug, { dryRun, chase, started = [], wouldStart = [
409
497
  }
410
498
  return [first, '', ...chaseLines(chase, slug)].join('\n');
411
499
  }
500
+
501
+ /** A spec's tasks in a few words: "3 tasks, 2 open", or "no tasks". */
502
+ const specTaskCount = (tasks = []) => {
503
+ if (!tasks.length) return 'no tasks';
504
+ const open = tasks.filter((t) => t.status === 'pending').length;
505
+ return `${plural(tasks.length, 'task')}, ${open} open`;
506
+ };
507
+
508
+ /**
509
+ * What `npx breakaway specs` prints: the repository's specs newest first, each with its work ID, status, title, and its
510
+ * tasks' count; with none, where specs go and how to point the board at another directory.
511
+ * @param {{ slug: string, dir: string, missing?: boolean, readme?: { path: string } | null, specs: any[] }} answer
512
+ */
513
+ export function specListLines({ slug, dir, missing, readme, specs }) {
514
+ if (!specs.length)
515
+ return [
516
+ `No specs in ${dir} yet${missing ? `: ${slug} has no ${dir} on its default branch` : ''}.`,
517
+ 'A spec is a Markdown file in that directory, merged like any change.',
518
+ `If ${slug} keeps its specs somewhere else, the owner sets it with npx breakaway repos modify ${slug} --specs <dir>.`,
519
+ ];
520
+ const intro = readme ? ` (its introduction is ${readme.path})` : '';
521
+ const out = [`${slug}: ${plural(specs.length, 'spec')} in ${dir}${intro}`, ''];
522
+ for (const s of specs) {
523
+ const extra = s.tooLarge ? ', over 1 MB: read it on GitHub' : '';
524
+ out.push(
525
+ ` ${(s.wid ?? '').padEnd(9)} ${(s.status ?? '-').padEnd(10)} ${s.title} (${specTaskCount(s.tasks)}${extra})`,
526
+ );
527
+ }
528
+ out.push('', `Read one: npx breakaway specs show <path>, like ${specs[0].path}`);
529
+ return out;
530
+ }
531
+
532
+ /**
533
+ * What `npx breakaway specs show <path>` prints: the spec's title and path, status, the commit that last changed it,
534
+ * its GitHub link, its Markdown (or, over 1 MB, a pointer to GitHub), and the tasks that link it.
535
+ * @param {any} spec
536
+ */
537
+ export function specLines(spec) {
538
+ const out = [`${spec.title} (${spec.path})`, ''];
539
+ const row = (k, v) => v && out.push(` ${k.padEnd(11)} ${v}`);
540
+ row('Status', spec.status);
541
+ const c = spec.commit;
542
+ if (c)
543
+ row(
544
+ 'Changed',
545
+ `${c.date ? `${String(c.date).slice(0, 16).replace('T', ' ')} ` : ''}in ${String(c.sha).slice(0, 7)}${c.message ? `: ${c.message}` : ''}`,
546
+ );
547
+ row('GitHub', spec.url);
548
+ out.push('');
549
+ if (spec.tooLarge || spec.text === null || spec.text === undefined)
550
+ out.push('Over 1 MB, too large to show here: read it on GitHub.');
551
+ else out.push(String(spec.text).replace(/\s+$/u, ''));
552
+ const tasks = spec.tasks ?? [];
553
+ out.push('');
554
+ if (tasks.length) {
555
+ out.push(` Tasks (${tasks.length}, ${tasks.filter((t) => t.status === 'pending').length} open)`);
556
+ for (const t of tasks) out.push(` ${idOf(t).padEnd(9)} ${t.status.padEnd(9)} ${t.description}`);
557
+ } else out.push(` No task links it yet: npx breakaway modify <ref> --spec ${spec.path}`);
558
+ out.push('', `Refine it: npx breakaway agents new --spec ${spec.path} "<what should change>"`);
559
+ return out;
560
+ }
561
+
562
+ /**
563
+ * What's left by hand once `repos remove` took a repository off the board (CLI-4): the board can't delete its routine
564
+ * on claude.ai, connected or not, nor the repository on GitHub. `repos remove` ends with these, and `--json` carries
565
+ * them as `byHand`.
566
+ * @param {string} github the repository, as owner/name
567
+ * @returns {string[]}
568
+ */
569
+ export function removedRepoByHand(github) {
570
+ return [
571
+ `Delete its routine on claude.ai, with its API trigger: open claude.ai/code/routines, then the routine for ${github}. The board can't delete it, whether or not it was connected.`,
572
+ `Made ${github} only for a rehearsal? Delete it on GitHub too (Settings, then Danger zone; or gh auth refresh -h github.com -s delete_repo once, then gh repo delete ${github}), and your local clone.`,
573
+ ];
574
+ }