breakaway 1.0.2-main.2 → 1.0.2-main.21

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.
package/README.md CHANGED
@@ -171,10 +171,10 @@ pnpm interop # checks sync against real Taskwarrior 3
171
171
 
172
172
  breakaway publishes releases and never deploys an install. Every install, the owner's included, deploys a release from its own repository, and this repository holds no Cloudflare credentials. Its website follows the latest stable release: the release moves the `site` branch, and Cloudflare deploys it ([`site/README.md`](https://github.com/TheAnarchoX/breakaway/blob/main/site/README.md#deploy-it)).
173
173
 
174
- - **Every merge to `main`**, once CI passes, publishes a GitHub pre-release `vX.Y.Z-main.N` on the `main` channel. It carries the bundle (`breakaway-bundle.tar.gz`: the Worker's files and the web app's `dist`), a `manifest.json` (version, channel, commit, `manual`, and the lowest version it updates from), and `SHA256SUMS`. The notes list the merged pull requests by title.
174
+ - **Every merge to `main`**, once CI passes, publishes a GitHub pre-release `vX.Y.Z-main.N` on the `main` channel. It carries the bundle (`breakaway-bundle.tar.gz`: the Worker's files and the web app's `dist`), a `manifest.json` (version, channel, commit, `manual`, and the lowest version it updates from), its signature `manifest.json.sig` (Ed25519, made with a key only the release workflow holds; the public key is `src/release-key.js`), and `SHA256SUMS`. The notes list the merged pull requests by title.
175
175
  - **A stable release** `vX.Y.Z` is the owner's: they run the **Release** workflow with the pre-release to promote. The bundle is that pre-release's, unchanged, and the notes cover everything since the last stable.
176
176
  - **The CLI** is on npm as [`breakaway`](https://www.npmjs.com/package/breakaway), staged on npm by the same workflow, with provenance, and live once the owner approves it there with 2FA. npm's trusted publishing can't yet read the OIDC identity of a repository as new as this one ([npm/cli#9969](https://github.com/npm/cli/issues/9969)), so until it can, a token that can stage but never publish by itself stands in, in an environment only `main` can use. Every pre-release goes out under the `next` dist-tag, and a stable release as `latest`. `npx breakaway <command>` is `node scripts/tasks.mjs <command>`.
177
- - **A major release** is one where an install has to do something by hand: a config or binding change, a Durable Object class or migration, a route or cron. Its notes have a **Manual steps** section and its manifest says `manual: true`, which an install's deploy stops on. A change that needs it sets `manual` and `manualSteps` in `release.json`, and the pull request that ships the steps clears them. Data the Durable Object stores changes forward-only and additively, so an install can always go back one release.
177
+ - **A major release** is one where an install has to do something by hand: a config or binding change, a Durable Object class or migration, a route or cron. Its notes have a **Manual steps** section and its manifest says `manual: true`, which an install's deploy stops on. A change that needs it sets `manual` and `manualSteps` in `release.json`, and the pull request that ships the steps clears them. When the only step is `wrangler deploy` (a new Durable Object class, a cron, a route), it also sets `wranglerDeploy: true`, and an install whose Deploy may run `wrangler deploy` does it itself (the install template's README says when). Data the Durable Object stores changes forward-only and additively, so an install can always go back one release, except across a new Durable Object class, which Cloudflare doesn't roll back.
178
178
  - **The version** is `package.json`'s; the release workflow sets it to the pre-release's before it builds. `GET /api/ping` and `GET /api/health` report it as `release`. An install that deploys a stable passes it as the `BREAKAWAY_VERSION` variable, since the bundle was built as the pre-release.
179
179
 
180
180
  </details>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "breakaway",
3
- "version": "1.0.2-main.2",
3
+ "version": "1.0.2-main.21",
4
4
  "description": "The task board for you and your coding agents: a Cloudflare Worker, its web app, Taskwarrior sync, and the CLI (npx breakaway).",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -54,6 +54,12 @@ The Cloudflare API token is theirs to make. On Cloudflare: My Profile, API Token
54
54
  gh secret set CLOUDFLARE_API_TOKEN --env production --repo <owner>/<name>
55
55
  ```
56
56
 
57
+ That token can run `wrangler deploy`, so Deploy can apply a new address, cron triggers, or a new Durable Object class by itself instead of stopping for them. That's theirs to turn on; after a yes:
58
+
59
+ ```sh
60
+ gh variable set BREAKAWAY_DEPLOY_CHANGES --body true --repo <owner>/<name>
61
+ ```
62
+
57
63
  **Check:** `gh api repos/<owner>/<name>/environments/production/secrets --jq '.secrets[].name'` lists both secrets, and the workflows are on `main`.
58
64
 
59
65
  ## 2. The board's secrets
@@ -11,14 +11,12 @@ import { ask } from '../tasks/ask.js';
11
11
  import { QUESTIONS, configFrom, stateFrom, writeInstall } from './init.js';
12
12
  import {
13
13
  bumpBody,
14
- checkManifest,
15
- configStop,
14
+ deployPlan,
16
15
  deployTarget,
17
16
  isHealthy,
18
17
  latestReleases,
19
18
  parseState,
20
19
  previousVersionId,
21
- shapeChanges,
22
20
  shapeOf,
23
21
  updatePlan,
24
22
  workerMissing,
@@ -72,6 +70,28 @@ const configIn = (path) => {
72
70
  }
73
71
  };
74
72
 
73
+ /** A file's JSON, or null when it's missing or isn't JSON. */
74
+ const jsonIn = (path) => {
75
+ try {
76
+ return path ? JSON.parse(readFileSync(path, 'utf8')) : null;
77
+ } catch {
78
+ return null;
79
+ }
80
+ };
81
+
82
+ /** The running Worker's shape for `check`, or null when nothing says what it is. */
83
+ function runningShape(opts) {
84
+ const running = jsonIn(opts['running-config']);
85
+ if (running && typeof running === 'object') return shapeOf(running);
86
+ const before = jsonIn(opts.before);
87
+ if (!before) return null; // no earlier config (the first deploy, or a dispatch): nothing to compare with
88
+ try {
89
+ return shapeOf(wranglerConfig(parseInstall(before)));
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
75
95
  /** The Worker's wrangler config for a downloaded release: the install's config, with the code and web app where the bundle put them. */
76
96
  export function bundleConfig(config, bundle) {
77
97
  const out = wranglerConfig(config);
@@ -99,20 +119,17 @@ export async function runStep(step, opts, io) {
99
119
  const version = opts.version;
100
120
  if (!version) throw new Stop('check needs --version, the release being deployed.');
101
121
  const manifest = readJson(opts.manifest ?? 'release/manifest.json', 'the release’s manifest');
102
- const verdict = checkManifest(manifest, { version, running: opts.running || null });
103
- if (verdict.ok === false) throw new Stop(verdict.message, 2);
104
- if (opts.before) {
105
- let before = null;
106
- try {
107
- before = parseInstall(JSON.parse(readFileSync(opts.before, 'utf8')));
108
- } catch {
109
- before = null; // no earlier config (the first deploy, or a dispatch): nothing to compare with
110
- }
111
- const now = configIn(join(dir, 'breakaway.config.json'));
112
- const stop = before && configStop(shapeChanges(shapeOf(wranglerConfig(before)), shapeOf(wranglerConfig(now))));
113
- if (stop) throw new Stop(stop, 2);
114
- }
122
+ // The Worker as it runs: the config the running release's own CLI made (--running-config), else this release's code
123
+ // with the config from before this push (--before). Neither (a first deploy, or a dispatch): nothing to compare.
124
+ const before = runningShape(opts);
125
+ const after = before && shapeOf(wranglerConfig(configIn(join(dir, 'breakaway.config.json'))));
126
+ const apply = opts['deploy-changes'] === true || opts['deploy-changes'] === 'true';
127
+ const plan = deployPlan(manifest, { version, running: opts.running || null, before, after, apply });
128
+ if (plan.ok === false) throw new Stop(plan.message, 2);
115
129
  io.out('ok=true');
130
+ io.out(`deploy=${plan.deploy}`);
131
+ io.out(`changes=${plan.changes.join(', ')}`);
132
+ io.out(`address_changed=${plan.addressChanged}`);
116
133
  return 0;
117
134
  }
118
135
  if (step === 'config') {
@@ -78,13 +78,19 @@ export function deployTarget(state, releases) {
78
78
  return { version: latest.version, tag: tagOf(latest.version), channel: 'main' };
79
79
  }
80
80
 
81
+ /** How Deploy is allowed to run wrangler deploy, for the messages that stop it. */
82
+ const LET_DEPLOY =
83
+ "give CLOUDFLARE_API_TOKEN the scopes this repository's README lists for it and set the repository variable BREAKAWAY_DEPLOY_CHANGES to true";
84
+
81
85
  /**
82
86
  * Whether a downloaded release may be deployed by the workflow. A release that needs hands, or one the running version
83
- * can't update from, stops with the message the workflow prints.
84
- * @param {{ version?: string, manual?: boolean, manualSteps?: string[], updatesFrom?: string }} manifest
85
- * @returns {{ ok: true } | { ok: false, message: string }}
87
+ * can't update from, stops with the message the workflow prints. A manual release whose only step is wrangler deploy
88
+ * (`wranglerDeploy`, BRK-62) passes when the install lets Deploy run it (`apply`), and says it needs wrangler deploy.
89
+ * @param {{ version?: string, manual?: boolean, manualSteps?: string[], wranglerDeploy?: boolean, updatesFrom?: string }} manifest
90
+ * @param {{ version: string, running?: string | null, apply?: boolean }} options
91
+ * @returns {{ ok: true, wrangler?: true } | { ok: false, message: string }}
86
92
  */
87
- export function checkManifest(manifest, { version, running = null }) {
93
+ export function checkManifest(manifest, { version, running = null, apply = false }) {
88
94
  if (!manifest || typeof manifest !== 'object')
89
95
  return { ok: false, message: 'the release’s manifest.json isn’t readable, so nothing was deployed.' };
90
96
  if (manifest.version !== version)
@@ -92,13 +98,6 @@ export function checkManifest(manifest, { version, running = null }) {
92
98
  ok: false,
93
99
  message: `the manifest is for ${manifest.version}, not ${version}, so nothing was deployed. Try again; if it persists, the release was published wrongly.`,
94
100
  };
95
- if (manifest.manual === true) {
96
- const steps = (manifest.manualSteps ?? []).map((s) => `\n - ${s}`).join('');
97
- return {
98
- ok: false,
99
- message: `breakaway ${version} needs steps by hand, so the workflow didn't deploy it. Do these, then deploy it yourself with wrangler:${steps}`,
100
- };
101
- }
102
101
  if (
103
102
  running &&
104
103
  manifest.updatesFrom &&
@@ -110,40 +109,131 @@ export function checkManifest(manifest, { version, running = null }) {
110
109
  ok: false,
111
110
  message: `breakaway ${version} updates from ${manifest.updatesFrom} or newer, and this install runs ${running}. Update to ${manifest.updatesFrom} first (set it in breakaway.json), then to ${version}.`,
112
111
  };
112
+ if (manifest.manual === true) {
113
+ const byWrangler = manifest.wranglerDeploy === true;
114
+ if (byWrangler && apply) return { ok: true, wrangler: true };
115
+ const steps = (manifest.manualSteps ?? []).map((s) => `\n - ${s}`).join('');
116
+ const hint = byWrangler
117
+ ? `\nThese steps are what wrangler deploy does, so Deploy can do them itself: ${LET_DEPLOY}, then run Deploy again.`
118
+ : '';
119
+ return {
120
+ ok: false,
121
+ message: `breakaway ${version} needs steps by hand, so the workflow didn't deploy it. Do these, then deploy it yourself with wrangler:${steps}${hint}`,
122
+ };
123
+ }
113
124
  return { ok: true };
114
125
  }
115
126
 
116
- /** What a deploy by the workflow can't change: routes, crons, the Durable Object classes, and their migrations. */
127
+ /**
128
+ * What a version upload can't change (BRK-62): the Worker and its Durable Object, its routes and crons, and the Durable
129
+ * Object classes and their migrations. Made from the Worker's wrangler config.
130
+ */
117
131
  export function shapeOf(wrangler) {
132
+ const inst = wrangler.vars?.TASKS_INSTALL;
118
133
  return {
134
+ worker: wrangler.name ?? null,
135
+ store: (inst && typeof inst === 'object' ? inst.store : null) ?? null,
136
+ jurisdiction: wrangler.vars?.TASKS_JURISDICTION ?? null,
119
137
  routes: wrangler.routes ?? [],
120
138
  workers_dev: Boolean(wrangler.workers_dev),
121
139
  crons: wrangler.triggers?.crons ?? [],
122
140
  durable_objects: wrangler.durable_objects?.bindings ?? [],
123
141
  migrations: wrangler.migrations ?? [],
124
- jurisdiction: wrangler.vars?.TASKS_JURISDICTION ?? null,
125
142
  };
126
143
  }
127
144
 
128
- /** What differs between two installs' shapes, as phrases for the message; empty when a deploy may go ahead. */
145
+ const json = (value) => JSON.stringify(value);
146
+ /** A migration that only makes classes, which wrangler deploy applies; any other kind deletes, renames, or moves one. */
147
+ const CREATES = new Set(['tag', 'new_sqlite_classes', 'new_classes']);
148
+
149
+ /**
150
+ * The classes `after` adds, when adding is all its Durable Object change does: its migrations are `before`'s with only
151
+ * new classes after them, and it keeps every binding `before` has. Null for anything else.
152
+ */
153
+ function addedClasses(before, after) {
154
+ const kept = before.migrations.every((m, i) => json(m) === json(after.migrations[i]));
155
+ const added = after.migrations.slice(before.migrations.length);
156
+ if (!kept || !added.every((m) => Object.keys(m).every((key) => CREATES.has(key)))) return null;
157
+ if (!before.durable_objects.every((b) => after.durable_objects.some((a) => json(a) === json(b)))) return null;
158
+ return added.flatMap((m) => [...(m.new_sqlite_classes ?? []), ...(m.new_classes ?? [])]);
159
+ }
160
+
161
+ /**
162
+ * What differs between the Worker's shape as it runs and as this deploy makes it, each with whether wrangler deploy may
163
+ * apply it (BRK-62): an address, cron triggers, and new Durable Object classes, yes. Another Worker, another Durable
164
+ * Object, or a class deleted, renamed, or moved, never: the first two open an empty board, and the last is a release's
165
+ * manual step. Empty when a version upload carries the whole deploy.
166
+ * @returns {{ change: string, apply: boolean, why?: string, address?: true }[]}
167
+ */
129
168
  export function shapeChanges(before, after) {
130
- const names = {
131
- routes: 'its address (routes)',
132
- workers_dev: 'its workers.dev address',
133
- crons: 'its cron triggers',
134
- durable_objects: 'its Durable Object classes',
135
- migrations: 'its Durable Object migrations',
136
- jurisdiction: 'its jurisdiction',
137
- };
138
- return Object.keys(names)
139
- .filter((key) => JSON.stringify(before[key]) !== JSON.stringify(after[key]))
140
- .map((key) => names[key]);
169
+ const differs = (key) => json(before[key]) !== json(after[key]);
170
+ const out = [];
171
+ const empty = 'another Durable Object is an empty board';
172
+ if (differs('worker'))
173
+ out.push({
174
+ change: 'its Worker’s name (worker)',
175
+ apply: false,
176
+ why: 'a new name makes a new Worker, with an empty board',
177
+ });
178
+ if (differs('store')) out.push({ change: 'its Durable Object (store)', apply: false, why: empty });
179
+ if (differs('jurisdiction')) out.push({ change: 'its jurisdiction', apply: false, why: empty });
180
+ if (differs('routes')) out.push({ change: 'its address (routes)', apply: true, address: true });
181
+ if (differs('workers_dev')) out.push({ change: 'its workers.dev address', apply: true, address: true });
182
+ if (differs('crons')) out.push({ change: 'its cron triggers', apply: true });
183
+ if (differs('durable_objects') || differs('migrations')) {
184
+ const added = addedClasses(before, after);
185
+ if (added === null)
186
+ out.push({
187
+ change: 'its Durable Object classes: it deletes, renames, or moves one',
188
+ apply: false,
189
+ why: 'that is a release’s manual step, and it can lose a board',
190
+ });
191
+ else
192
+ out.push({
193
+ change: added.length
194
+ ? `its Durable Object classes: it adds ${added.join(', ')}`
195
+ : 'its Durable Object bindings',
196
+ apply: true,
197
+ });
198
+ }
199
+ return out;
141
200
  }
142
201
 
143
- /** The message that stops a deploy when the install's config changed what the workflow can't deploy, or null. */
202
+ /** The message that stops a deploy whose changes need wrangler deploy when the install hasn't allowed it, or null. */
144
203
  export function configStop(changes) {
145
204
  if (!changes.length) return null;
146
- return `breakaway.config.json changed ${changes.join(', ')}, which the workflow doesn't deploy. Apply it yourself with wrangler (npx wrangler deploy, with the config npx breakaway install config makes), then run the workflow again.`;
205
+ return `This deploy changes ${changes.join(', ')}, which a version upload can't carry, so nothing was deployed. To let Deploy run wrangler deploy for it, ${LET_DEPLOY}, then run Deploy again. Or apply it yourself with wrangler (npx wrangler deploy, with the config npx breakaway install config makes), then run Deploy again.`;
206
+ }
207
+
208
+ /** The message that stops a deploy with a change Deploy never makes. */
209
+ function neverStop(changes) {
210
+ const why = [...new Set(changes.map((c) => c.why))].join('; ');
211
+ return `This deploy changes ${changes.map((c) => c.change).join(', ')}, which Deploy never does, so nothing was deployed: ${why}. If breakaway.config.json changed it, put it back as it was. If the release did, its notes say what to do by hand.`;
212
+ }
213
+
214
+ /**
215
+ * What Deploy does with a release (BRK-62): stop with a message, upload a version (`versions`), or run wrangler deploy
216
+ * (`wrangler`) for what a version can't carry. `before` and `after` are the Worker's shapes as it runs and as this deploy
217
+ * makes it; `before` is null when there is nothing to compare with (a first deploy, or a dispatch with no running release
218
+ * to ask). `apply` is whether the install lets Deploy run wrangler deploy: BREAKAWAY_DEPLOY_CHANGES, set for a token that
219
+ * can. `addressChanged` tells the health check to wait for a new custom domain.
220
+ * @returns {{ ok: false, message: string } | { ok: true, deploy: 'versions' | 'wrangler', changes: string[], addressChanged: boolean }}
221
+ */
222
+ export function deployPlan(manifest, { version, running = null, before = null, after = null, apply = false }) {
223
+ const verdict = checkManifest(manifest, { version, running, apply });
224
+ if (verdict.ok === false) return verdict;
225
+ const found = before && after ? shapeChanges(before, after) : [];
226
+ const never = found.filter((c) => !c.apply);
227
+ if (never.length) return { ok: false, message: neverStop(never) };
228
+ const changes = found.map((c) => c.change);
229
+ const byWrangler = changes.length > 0 || verdict.wrangler === true;
230
+ if (byWrangler && !apply) return { ok: false, message: configStop(changes) };
231
+ return {
232
+ ok: true,
233
+ deploy: byWrangler ? 'wrangler' : 'versions',
234
+ changes,
235
+ addressChanged: found.some((c) => c.address === true),
236
+ };
147
237
  }
148
238
 
149
239
  /**
@@ -7,6 +7,7 @@ import { CLI_PACKAGE } from './init.js';
7
7
  /** The subcommands each command knows. Without one, each lists or shows (horizon needs close). */
8
8
  export const SUBCOMMANDS = {
9
9
  agents: ['next', 'start', 'refine'],
10
+ github: ['fix', 'review'],
10
11
  repos: ['add', 'init', 'modify', 'remove', 'setup'],
11
12
  routines: ['add', 'modify', 'run', 'trigger', 'revoke', 'pause', 'resume'],
12
13
  horizon: ['close'],
@@ -18,7 +19,6 @@ export const NO_ARGUMENTS = new Set([
18
19
  'list',
19
20
  'next',
20
21
  'activity',
21
- 'github',
22
22
  'health',
23
23
  'connections',
24
24
  'export',
@@ -74,6 +74,45 @@ export function githubRequest(repo, { sync = false } = {}) {
74
74
  return ['GET', repo ? `github?repo=${encodeURIComponent(repo)}` : 'github', undefined];
75
75
  }
76
76
 
77
+ /** What `github fix` accepts for --problem: the same three the pull request page offers. */
78
+ export const FIX_PROBLEMS = ['conflicts', 'failing', 'review'];
79
+
80
+ /**
81
+ * The request behind `npx breakaway github fix <n>` and `github review <n>` (BRK-81): the pull request page's "Fix with an
82
+ * agent" and "Safe to merge?" buttons (`POST github/pulls/<n>/fix` and `/review`). It names the checkout's repository
83
+ * like `github` does. Returns an error message instead when the number or `problem` can't be right.
84
+ * @param {'fix' | 'review'} action
85
+ * @param {string | number | undefined} number
86
+ * @param {{ repo?: string | null, problem?: string, note?: string }} [options]
87
+ * @returns {{ error?: string, request?: [string, string, Record<string, string>] }}
88
+ */
89
+ export function pullAgentRequest(action, number, { repo = null, problem, note } = {}) {
90
+ const n = String(number ?? '').replace(/^#/u, '');
91
+ if (!/^[1-9]\d{0,8}$/u.test(n)) return { error: `say which pull request: npx breakaway github ${action} <number>` };
92
+ if (problem !== undefined && action !== 'fix') return { error: '--problem is for github fix' };
93
+ if (problem !== undefined && !FIX_PROBLEMS.includes(problem))
94
+ return { error: `--problem is ${FIX_PROBLEMS.join(', ')}` };
95
+ const body = {
96
+ ...(repo ? { repo } : {}),
97
+ ...(problem ? { problem } : {}),
98
+ ...(typeof note === 'string' && note.trim() ? { note } : {}),
99
+ };
100
+ return { request: ['POST', `github/pulls/${n}/${action}`, body] };
101
+ }
102
+
103
+ /**
104
+ * What the CLI says about an answer to those requests: which task and agent took the pull request, or who already has it.
105
+ * @param {'fix' | 'review'} action
106
+ * @param {string | number} number
107
+ * @param {{ task: { wid?: string, short?: string }, run?: { url?: string, agent?: string } | null, already?: string | null }} answer
108
+ */
109
+ export function pullAgentSummary(action, number, { task, run, already }) {
110
+ const id = task.wid ?? task.short;
111
+ if (!run) return `${id} already has it: ${already}.`;
112
+ const what = action === 'fix' ? `fixing #${number}` : `testing #${number}`;
113
+ return `Started ${run.agent ? `${run.agent}, ` : 'an agent '}${what} on ${id}${run.url ? `: ${run.url}` : ''}`;
114
+ }
115
+
77
116
  /**
78
117
  * The task an idea becomes (`npx breakaway idea`): its first line, kept short, as the title, and the whole text as its
79
118
  * description. Like `add`, it lands in the repository of the checkout it was written in (BRK-71): without one, the
@@ -3,14 +3,32 @@
3
3
  * `tasks claim`) and where the board is, found the same way as the CLI (scripts/tasks/settings.js).
4
4
  * Each returns null/undefined rather than throwing when something's missing, because the hooks stay quiet.
5
5
  */
6
- import { existsSync, readFileSync } from 'node:fs';
6
+ import { execFileSync } from 'node:child_process';
7
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
7
8
  import { homedir } from 'node:os';
8
9
  import { join } from 'node:path';
9
10
  import { boardUrl, configDir, parseEnvFile, readSetting } from './settings.js';
10
11
 
11
- /** The project's root, as Claude Code gives it to hooks. */
12
- export function projectRoot() {
13
- return process.env.CLAUDE_PROJECT_DIR || process.cwd();
12
+ /** The git root of the current folder, or null outside a repository. */
13
+ function gitRoot() {
14
+ try {
15
+ return (
16
+ execFileSync('git', ['rev-parse', '--show-toplevel'], {
17
+ encoding: 'utf8',
18
+ stdio: ['ignore', 'pipe', 'ignore'],
19
+ }).trim() || null
20
+ );
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * The project's root: as Claude Code gives it to hooks, and where it doesn't (a cloud session can leave
28
+ * CLAUDE_PROJECT_DIR empty, BRK-88), the git root of the folder the hook runs in, where `tasks claim` marks the task.
29
+ */
30
+ export function projectRoot(env = process.env, root = gitRoot, cwd = process.cwd()) {
31
+ return env.CLAUDE_PROJECT_DIR || root() || cwd;
14
32
  }
15
33
 
16
34
  /** A file's text, or null when it can't be read. */
@@ -55,3 +73,13 @@ export function boardConfig(root = projectRoot()) {
55
73
  const token = readSetting('TOKEN', { env: process.env, file });
56
74
  return { base: url, headers: token ? { Authorization: `Bearer ${token}` } : {} };
57
75
  }
76
+
77
+ /** The board says this checkout's task isn't claimed by its agent any more: drop the marker so later calls post nothing (BRK-87). */
78
+ export function dropClaim(claim, root = projectRoot()) {
79
+ try {
80
+ const marker = join(root, '.task-session');
81
+ if (JSON.parse(readFileSync(marker, 'utf8'))?.uuid === claim.uuid) rmSync(marker);
82
+ } catch {
83
+ /* already gone */
84
+ }
85
+ }
@@ -14,10 +14,10 @@ const DEFAULT_DIR = '~/.config/breakaway';
14
14
  export { routinePrompt };
15
15
 
16
16
  /**
17
- * The CLI on npm (BRK-7), as a repository runs it: `npx breakaway`. Pinned to the `next` channel until the first stable
18
- * release exists; then this becomes the major (`breakaway@1`), so a breaking change never reaches a repository by itself.
17
+ * The CLI on npm (BRK-7), as a repository runs it: `npx breakaway`, pinned to the major (BRK-47), so a breaking change
18
+ * never reaches a repository by itself. It was the `next` channel until the first stable release.
19
19
  */
20
- export const CLI_PACKAGE = 'breakaway@next';
20
+ export const CLI_PACKAGE = 'breakaway@1';
21
21
  /**
22
22
  * Where the CLI starts: the command, and the two session hooks. repos init no longer copies them (BRK-7): an old copy
23
23
  * is replaced by npx, and these still version the CLI and say which files an old copy holds.
@@ -49,6 +49,11 @@ const ADAPTED = ['taskrc', 'scripts/task', SKILL];
49
49
  * sit at the root, so the paths `read` takes are the board's; what is written keeps this folder.
50
50
  */
51
51
  const TARGET_DIR = 'tools/tasks/';
52
+ /**
53
+ * The record of what repos init wrote into a repository (BRK-79): `--update` replaces a copied file only when it's
54
+ * listed here, or sits in TARGET_DIR, breakaway's own folder. Anything else at a path it copies to is the repository's.
55
+ */
56
+ export const MANIFEST = `${TARGET_DIR}copied.json`;
52
57
  const GITIGNORE = ['.task/', '.task-session', '.env'];
53
58
  /** Pinned to LF so the shell scripts run on a checkout with core.autocrlf=true (BRK-41). */
54
59
  const GITATTRIBUTES = ['scripts/task text eol=lf', '.envrc text eol=lf'];
@@ -134,7 +139,10 @@ export function sessionHooks(pkg = CLI_PACKAGE) {
134
139
  };
135
140
  }
136
141
 
137
- /** settings.json with the session hooks' commands (either form) set to the current one; the rest is untouched. */
142
+ /**
143
+ * settings.json with the session hooks' commands set to the current one, whichever form they had: the copy's, or npx
144
+ * with an earlier channel or version of the package (`breakaway@next` before BRK-47). The rest is untouched.
145
+ */
138
146
  export function rewireHooks(text) {
139
147
  let out = text;
140
148
  for (const name of ['session', 'wait'])
@@ -143,7 +151,28 @@ export function rewireHooks(text) {
143
151
  const now = JSON.stringify(hookCommand(name)).slice(1, -1);
144
152
  out = out.split(was).join(now);
145
153
  }
146
- return out;
154
+ const current = (_, name) => JSON.stringify(hookCommand(name === 'message-wait' ? 'wait' : name)).slice(1, -1);
155
+ return (
156
+ out
157
+ .replace(/npx --yes breakaway(?:@[^\s"\\]+)? hook (session|wait)\b/gu, current)
158
+ // The old CLI copy's hook scripts, from before BRK-7, quoted or not (BRK-79): its copy is about to go.
159
+ .replace(
160
+ /node (\\")?(?:\$CLAUDE_PROJECT_DIR\/)?(?:tools\/tasks\/cli\/)?scripts\/tasks\/(session-hook|message-wait)\.mjs\1/gu,
161
+ (_, _q, name) => current(_, name === 'session-hook' ? 'session' : name),
162
+ )
163
+ );
164
+ }
165
+
166
+ /**
167
+ * The paths a repository's AGENTS.md says came from the board, in its "Copied files." bullet as any version of repos
168
+ * init wrote it; a folder ends in `/`. None when AGENTS.md is the repository's own (BRK-79).
169
+ */
170
+ export function declaredCopies(agents) {
171
+ const line = String(agents ?? '')
172
+ .split('\n')
173
+ .find((l) => /^- \*\*Copied files\.\*\*/u.test(l));
174
+ if (!line) return [];
175
+ return [...line.split(/ come from \[/u)[0].matchAll(/`([^`]+)`/gu)].map((m) => m[1]);
147
176
  }
148
177
 
149
178
  /** A short SHA-256 of `paths` and their text: changes whenever one of them does. */
@@ -261,10 +290,20 @@ export function promptSections(given = {}) {
261
290
  }
262
291
 
263
292
  /** The `tasks` skill for another repository: links into the board's docs point at them on GitHub. */
264
- export function skillFor(text, board) {
265
- return String(text)
293
+ export function skillFor(text, board, repo = null) {
294
+ const linked = String(text)
266
295
  .replace(/\]\((?:\.\.\/)+((?:docs|tools)\/[^)]+)\)/gu, `](https://github.com/${board}/blob/main/$1)`)
267
296
  .replace(/\]\(((?:\.\.\/)+)prompts\//gu, ']($1tools/tasks/prompts/');
297
+ if (!repo) return linked;
298
+ // The skill is breakaway's own: in another repository it names that repository's work, areas, and prompt (BRK-79).
299
+ const prompt = promptPathOf(repo);
300
+ return linked
301
+ .replace(/breakaway's work is on the board/gu, "This repository's work is on the board")
302
+ .replace(/breakaway's areas: [^.\n]+\./gu, `This repository's areas: ${areaList(repo)}.`)
303
+ .replace(
304
+ /\[`prompts\/breakaway\.md`\]\(((?:\.\.\/)+)tools\/tasks\/prompts\/breakaway\.md\)/gu,
305
+ (_, up) => `[\`${prompt}\`](${up}${prompt})`,
306
+ );
268
307
  }
269
308
 
270
309
  /** A starter AGENTS.md: how this repository works with the board. The owner adds how to build here. */
@@ -273,9 +312,9 @@ export function agentsMd(repo, board, dir = DEFAULT_DIR) {
273
312
 
274
313
  <!-- Started by \`npx breakaway repos init\` (breakaway's task board). Add how to build here: setup, tests, style, and anything agents must never do. -->
275
314
 
276
- - **Work lives on the task board.** This repository's tasks are in the areas ${areaList(repo)}. Use the \`tasks\` skill (\`${SKILL}\`) and the CLI, \`npx breakaway\` (the \`breakaway\` package on npm), to claim, comment, and hand over. It works in this checkout's repository, so \`list\` and \`next\` show only this repository's tasks. The skill is breakaway's copy: where it names breakaway's own files or rules, the board's part applies and the rest doesn't.
315
+ - **Work lives on the task board.** This repository's tasks are in the areas ${areaList(repo)}. Use the \`tasks\` skill (\`${SKILL}\`) and the CLI, \`npx breakaway\` (the \`breakaway\` package on npm), to claim, comment, and hand over. It works in this checkout's repository, so \`list\` and \`next\` show only this repository's tasks. The skill is breakaway's, written for this repository's areas and prompt: where it names breakaway's own files or rules, the board's part applies and the rest doesn't.
277
316
  - **Agents started by the board** follow [\`${promptPathOf(repo)}\`](${promptPathOf(repo)}), which starts with the board's core, \`tools/tasks/prompts/core.md\`.
278
- - **Copied files.** \`tools/tasks/\`, the release helpers in \`scripts/\`, and \`${SKILL}\` come from [${board}](https://github.com/${board}). Don't edit them here: change them there. \`.claude/settings.json\` holds the session hooks that show a cloud agent's output on its task, and they run through \`npx\`, so this repository carries no copy of the CLI.
317
+ - **Copied files.** \`tools/tasks/\`, the release helpers in \`scripts/\`, and \`${SKILL}\` come from [${board}](https://github.com/${board}). Don't edit them here: change them there. \`${MANIFEST}\` lists every file it copied, and \`repos init --update\` replaces only those: a file it doesn't list is this repository's own, even at a path breakaway copies to. \`.claude/settings.json\` holds the session hooks that show a cloud agent's output on its task, and they run through \`npx\`, so this repository carries no copy of the CLI.
279
318
  - **Taskwarrior** (optional): \`scripts/task\`, or plain \`task\` with direnv after \`direnv allow\`, uses the board with this checkout's own \`.task/\` database, in the \`${repo.slug}\` context. \`npx breakaway setup\` connects the machine once.
280
319
  - **Changes reach \`${repo.defaultBranch || 'main'}\` through pull requests**, which the owner merges. Never merge, force-push, or rewrite \`${repo.defaultBranch || 'main'}\`.
281
320
  - **Never put a secret or token** in a file, task, comment, or pull request. The board's token lives in \`${dir}/tasks.env\` (or \`$BREAKAWAY_HOME/tasks.env\`) or the cloud environment's credentials, never in this repository.
@@ -338,13 +377,46 @@ export function initPlan({
338
377
  if (readTarget(path) !== null) skipped.push(path);
339
378
  else files.push({ path, content, ...extra });
340
379
  };
341
- /** A file copied from the board: added when it's missing, and with `update`, replaced when it differs. */
380
+ // What an earlier run recorded writing (BRK-79): null for a repository set up before the record existed.
381
+ const recorded = (() => {
382
+ try {
383
+ const files = JSON.parse(readTarget(MANIFEST) ?? 'null')?.files;
384
+ return Array.isArray(files) ? new Set(files) : null;
385
+ } catch {
386
+ return null;
387
+ }
388
+ })();
389
+ // Set up before the record: the AGENTS.md repos init wrote says the copied files came from the board, and a
390
+ // repository's own AGENTS.md doesn't, so only then are files at the copied paths breakaway's.
391
+ const declared = recorded === null ? declaredCopies(readTarget('AGENTS.md')) : [];
392
+ /** Whether repos init wrote `path` here: in breakaway's own folder, on the record, or in AGENTS.md's copied files. */
393
+ const owns = (path) =>
394
+ path.startsWith(TARGET_DIR) ||
395
+ Boolean(recorded?.has(path)) ||
396
+ declared.some((d) => (d.endsWith('/') ? path.startsWith(d) : path === d));
397
+ const ours = new Set();
398
+ const theirs = [];
399
+ /**
400
+ * A file copied from the board: added when it's missing, and with `update`, replaced when it differs, but only when
401
+ * repos init wrote it (the record lists it, or it's in breakaway's own folder). A repository's own file at a path
402
+ * breakaway copies to is left alone (BRK-79).
403
+ */
342
404
  const copy = (path, content, extra = {}) => {
343
405
  const there = readTarget(path);
344
- if (there === null) files.push({ path, content, ...extra });
345
- else if (!update) skipped.push(path);
346
- else if (there === content) current.push(path);
347
- else files.push({ path, content, ...extra, changed: true });
406
+ if (there === null) {
407
+ files.push({ path, content, ...extra });
408
+ ours.add(path);
409
+ } else if (there === content) {
410
+ (update ? current : skipped).push(path);
411
+ ours.add(path);
412
+ } else if (!update) skipped.push(path);
413
+ else if (owns(path)) {
414
+ files.push({ path, content, ...extra, changed: true });
415
+ ours.add(path);
416
+ } else {
417
+ skipped.push(path);
418
+ theirs.push(path);
419
+ }
348
420
  };
349
421
 
350
422
  const prompt = promptPathOf(repo);
@@ -357,7 +429,14 @@ export function initPlan({
357
429
  }
358
430
  for (const path of COPIED) copy(TARGET_DIR + path, read(path));
359
431
  copy(`${TARGET_DIR}taskrc`, withRepoInTaskrc(read('taskrc'), repo.slug, Boolean(repo.isDefault)));
360
- for (const path of importClosure(RELEASE_ENTRIES, read)) copy(path, read(path));
432
+ // A release helper the repository keeps as its own doesn't bring the files breakaway's version imports (BRK-79).
433
+ const kept = (entry) => {
434
+ const there = readTarget(entry);
435
+ return there === null || there === read(entry) || (update && owns(entry));
436
+ };
437
+ const needed = new Set(importClosure(RELEASE_ENTRIES.filter(kept), read));
438
+ for (const path of importClosure(RELEASE_ENTRIES, read))
439
+ if (needed.has(path) || readTarget(path) !== null) copy(path, read(path));
361
440
  if (HOOKS_FROM_COPY) for (const path of importClosure(HOOK_ENTRIES, read)) copy(HOOKS_DIR + path, read(path));
362
441
  // An old copy of the CLI is replaced by npx: with update, its files go. The src/ files it imported may be the
363
442
  // repository's own by now, so those are only named.
@@ -389,7 +468,7 @@ export function initPlan({
389
468
  );
390
469
  }
391
470
  if (readTarget('.claude/skills') === null) files.push({ path: '.claude/skills', link: '../.agents/skills' });
392
- copy(SKILL, skillFor(read(SKILL), board));
471
+ copy(SKILL, skillFor(read(SKILL), board, repo));
393
472
  add('AGENTS.md', agentsMd(repo, board, configDir));
394
473
  if (!skipped.includes('AGENTS.md')) todo.push('AGENTS.md: add how to build in this repository');
395
474
 
@@ -443,5 +522,43 @@ export function initPlan({
443
522
  append: attributes !== null,
444
523
  });
445
524
  }
525
+ // A file the repository still runs stays, with the old copy it belongs to (BRK-79): package.json, or settings the
526
+ // rewiring above couldn't move to npx.
527
+ const settingsNow =
528
+ files.find((f) => f.path === '.claude/settings.json')?.content ?? readTarget('.claude/settings.json');
529
+ const uses = [
530
+ ['package.json', readTarget('package.json')],
531
+ ['.claude/settings.json', settingsNow],
532
+ ];
533
+ for (const group of [(p) => p.startsWith('scripts/'), (p) => p.startsWith(HOOKS_DIR)]) {
534
+ const inGroup = removals.filter(group);
535
+ const used = uses.flatMap(([file, text]) =>
536
+ inGroup.filter((p) => String(text ?? '').includes(p)).map((p) => `${file} still runs ${p}`),
537
+ );
538
+ if (!used.length) continue;
539
+ for (const p of inGroup) removals.splice(removals.indexOf(p), 1);
540
+ notes.push(
541
+ `${used.join('; ')}, so its copy stays: switch it to npx breakaway (\`${hookCommand('session')}\` for the hooks), then run --update again to remove the copy.`,
542
+ );
543
+ }
544
+ if (theirs.length)
545
+ notes.push(
546
+ `${theirs.join(', ')} ${theirs.length > 1 ? 'are' : 'is'} at a path breakaway copies to, but repos init has no record of writing ${theirs.length > 1 ? 'them' : 'it'}, so ${theirs.length > 1 ? 'they are left as they are' : 'it is left as it is'}. If one is breakaway's older copy, delete it and run --update again.`,
547
+ );
548
+ // The record of what is breakaway's here; a run without --update leaves an existing one alone.
549
+ const record = `${JSON.stringify(
550
+ {
551
+ about: `Files repos init copied from ${board} and keeps in step with it (npx breakaway repos init <slug> --update). A file not listed is this repository's own, even at a path breakaway copies to.`,
552
+ from: board,
553
+ files: [...ours].sort(),
554
+ },
555
+ null,
556
+ 2,
557
+ )}\n`;
558
+ const recordThere = readTarget(MANIFEST);
559
+ if (recordThere === null) files.push({ path: MANIFEST, content: record });
560
+ else if (recordThere === record) current.push(MANIFEST);
561
+ else if (update) files.push({ path: MANIFEST, content: record, changed: true });
562
+ else skipped.push(MANIFEST);
446
563
  return { files, removals, skipped, current, notes, todo };
447
564
  }
@@ -17,7 +17,7 @@ import { tmpdir } from 'node:os';
17
17
  import { join } from 'node:path';
18
18
  import { setTimeout as sleep } from 'node:timers/promises';
19
19
  import { boardConfig, claimedTask, projectRoot } from './hook-config.js';
20
- import { routeThroughSessionProxy } from './proxy.js';
20
+ import { sessionRequest } from './proxy.js';
21
21
  import { waitForMessages } from './session-messages.js';
22
22
 
23
23
  async function main() {
@@ -43,9 +43,9 @@ async function main() {
43
43
  const { base, headers } = boardConfig(root);
44
44
  if (!base) return;
45
45
  const url = `${base}/api/tasks/${encodeURIComponent(claim.uuid)}/messages/waiting?agent=${encodeURIComponent(claim.agent)}`;
46
- routeThroughSessionProxy();
46
+ // Through curl in a cloud session, so it works on whichever Node runs hooks there (BRK-86).
47
47
  const ask = async () => {
48
- const res = await fetch(url, { headers, signal: AbortSignal.timeout(10_000) });
48
+ const res = await sessionRequest(url, { headers, timeoutMs: 10_000 });
49
49
  return res.ok ? res.json() : null;
50
50
  };
51
51
 
@@ -7,7 +7,11 @@
7
7
  * routeThroughSessionProxy() sends fetch through the proxy and trusts the system's CA store too, which
8
8
  * holds the proxy's certificate. Without a proxy in the environment it does nothing.
9
9
  */
10
+ import { spawn } from 'node:child_process';
11
+ import { readFileSync, rmSync, writeFileSync } from 'node:fs';
10
12
  import http from 'node:http';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
11
15
  import tls from 'node:tls';
12
16
 
13
17
  export function sessionProxy(env = process.env) {
@@ -25,3 +29,89 @@ export function routeThroughSessionProxy(env = process.env) {
25
29
  }
26
30
  return true;
27
31
  }
32
+
33
+ /** Runs `command` with `args`, `input` on stdin, and resolves with stdout; rejects with stderr when it fails. */
34
+ function runWithInput(command, args, input) {
35
+ return new Promise((resolve, reject) => {
36
+ const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
37
+ let out = '';
38
+ let err = '';
39
+ child.stdout.on('data', (d) => {
40
+ out += d;
41
+ });
42
+ child.stderr.on('data', (d) => {
43
+ err += d;
44
+ });
45
+ child.on('error', reject);
46
+ child.on('close', (code) =>
47
+ code === 0 ? resolve(out) : reject(Object.assign(new Error(err.trim() || `${command} exited ${code}`), { code })),
48
+ );
49
+ child.stdin.end(input ?? '');
50
+ });
51
+ }
52
+
53
+ /**
54
+ * A hook's request to the board (BRK-86). Claude Code runs hooks with the image's default Node, which in a cloud session
55
+ * can be too old to send fetch through the session proxy (Node 20 has neither http.setGlobalProxyFromEnv nor the CA
56
+ * calls above), and the sandbox refuses a request that goes around it. So with a proxy the request goes through curl,
57
+ * which reads HTTPS_PROXY and the system's certificates on any Node; without curl, or without a proxy, through fetch.
58
+ * Answers like fetch's response: { ok, status, json() }.
59
+ * @param {string} url
60
+ * @param {{ method?: string, headers?: Record<string, string>, body?: string, timeoutMs?: number }} [request]
61
+ */
62
+ export async function sessionRequest(
63
+ url,
64
+ { method = 'GET', headers = {}, body, timeoutMs = 4000 } = {},
65
+ { env = process.env, run = runWithInput, fetch = globalThis.fetch } = {},
66
+ ) {
67
+ if (sessionProxy(env)) {
68
+ const args = ['-sS', '--max-time', String(Math.max(1, Math.ceil(timeoutMs / 1000))), '-X', method];
69
+ for (const [name, value] of Object.entries(headers)) args.push('-H', `${name}: ${value}`);
70
+ if (body !== undefined) args.push('--data-binary', '@-');
71
+ args.push('-w', '\n%{http_code}', url);
72
+ try {
73
+ const out = await run('curl', args, body);
74
+ const at = out.lastIndexOf('\n');
75
+ const status = Number(out.slice(at + 1));
76
+ const text = out.slice(0, Math.max(0, at));
77
+ return { ok: status >= 200 && status < 300, status, json: async () => (text.trim() ? JSON.parse(text) : null) };
78
+ } catch (error) {
79
+ if (/** @type {any} */ (error)?.code !== 'ENOENT') throw error;
80
+ // No curl here: fetch, through the proxy if this Node can.
81
+ }
82
+ }
83
+ routeThroughSessionProxy(env);
84
+ return fetch(url, { method, headers, body, signal: AbortSignal.timeout(timeoutMs) });
85
+ }
86
+
87
+ /**
88
+ * Where a hook notes why it couldn't post for a task (BRK-86): the system's temp folder, never the repository, where a
89
+ * cloud session's Stop check would take a new file as work to commit. The CLI shows it on its next command here.
90
+ */
91
+ const failurePath = (uuid, dir) => join(dir, `breakaway-hook-${String(uuid).replace(/[^\w-]/gu, '')}.json`);
92
+
93
+ /** Records that the hook couldn't post for task `uuid`, and why. */
94
+ export function noteHookFailure(uuid, reason, dir = tmpdir(), at = new Date()) {
95
+ try {
96
+ writeFileSync(
97
+ failurePath(uuid, dir),
98
+ `${JSON.stringify({ at: at.toISOString(), reason: String(reason).slice(0, 300) })}\n`,
99
+ );
100
+ } catch {
101
+ /* nowhere to note it */
102
+ }
103
+ }
104
+
105
+ /** Clears that note, after a post that went through. */
106
+ export function clearHookFailure(uuid, dir = tmpdir()) {
107
+ rmSync(failurePath(uuid, dir), { force: true });
108
+ }
109
+
110
+ /** The hook's last failure for task `uuid`, or null. */
111
+ export function hookFailure(uuid, dir = tmpdir()) {
112
+ try {
113
+ return JSON.parse(readFileSync(failurePath(uuid, dir), 'utf8'));
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
@@ -7,12 +7,13 @@
7
7
  * additionalContext so Claude gets them on its next turn (docs/specs/IDEA-15-message-a-running-agent.md).
8
8
  *
9
9
  * Quiet by design: without a claimed task (.task-session, written by `tasks claim`), with
10
- * BREAKAWAY_SESSION_LOG=off (or SAMEWAVE_TASKS_SESSION_LOG=off), or on any error, it does nothing and exits 0.
10
+ * BREAKAWAY_SESSION_LOG=off (or SAMEWAVE_TASKS_SESSION_LOG=off), or on any error, it does nothing and exits 0. A post
11
+ * that fails leaves its reason in the temp folder, which the CLI's next command here shows (BRK-86).
11
12
  */
12
- import { boardConfig, claimedTask, projectRoot } from './hook-config.js';
13
- import { routeThroughSessionProxy } from './proxy.js';
13
+ import { boardConfig, claimedTask, dropClaim, projectRoot } from './hook-config.js';
14
+ import { clearHookFailure, noteHookFailure, sessionRequest } from './proxy.js';
14
15
  import { entryFor } from './session-log.js';
15
- import { CONTEXT_EVENTS, messageOutput } from './session-messages.js';
16
+ import { CONTEXT_EVENTS, messageOutput, releasedOutput } from './session-messages.js';
16
17
 
17
18
  async function main() {
18
19
  const root = projectRoot();
@@ -26,23 +27,36 @@ async function main() {
26
27
  if (!entry) return;
27
28
 
28
29
  const { base, headers } = boardConfig(root);
29
- if (!base) return;
30
- routeThroughSessionProxy();
31
- const res = await fetch(`${base}/api/tasks/${encodeURIComponent(claim.uuid)}/session`, {
32
- method: 'POST',
33
- headers: { 'Content-Type': 'application/json', ...headers },
34
- body: JSON.stringify({
35
- agent: claim.agent,
36
- session: hook.session_id ?? null,
37
- remote: process.env.CLAUDE_CODE_REMOTE === 'true',
38
- entries: [entry],
39
- // A Stop hook's output can't reach Claude, so it leaves the messages for the next event.
40
- messages: CONTEXT_EVENTS.has(hook.hook_event_name),
41
- }),
42
- signal: AbortSignal.timeout(4000),
43
- });
44
- if (!res.ok) return;
45
- const output = messageOutput(await res.json(), hook.hook_event_name);
30
+ if (!base) return noteHookFailure(claim.uuid, 'no board address: set BREAKAWAY_URL');
31
+ let res;
32
+ try {
33
+ // Through curl in a cloud session, so it works on whichever Node runs hooks there (BRK-86).
34
+ res = await sessionRequest(`${base}/api/tasks/${encodeURIComponent(claim.uuid)}/session`, {
35
+ method: 'POST',
36
+ headers: { 'Content-Type': 'application/json', ...headers },
37
+ body: JSON.stringify({
38
+ agent: claim.agent,
39
+ session: hook.session_id ?? null,
40
+ remote: process.env.CLAUDE_CODE_REMOTE === 'true',
41
+ entries: [entry],
42
+ // A Stop hook's output can't reach Claude, so it leaves the messages for the next event.
43
+ messages: CONTEXT_EVENTS.has(hook.hook_event_name),
44
+ }),
45
+ timeoutMs: 4000,
46
+ });
47
+ } catch (error) {
48
+ return noteHookFailure(claim.uuid, /** @type {Error} */ (error)?.message ?? String(error));
49
+ }
50
+ if (!res.ok) return noteHookFailure(claim.uuid, `HTTP ${res.status}`);
51
+ clearHookFailure(claim.uuid);
52
+ const answer = await res.json();
53
+ if (answer?.released) {
54
+ dropClaim(claim, root);
55
+ const output = releasedOutput(claim, hook.hook_event_name);
56
+ if (output) process.stdout.write(`${JSON.stringify(output)}\n`);
57
+ return;
58
+ }
59
+ const output = messageOutput(answer, hook.hook_event_name);
46
60
  if (output) process.stdout.write(`${JSON.stringify(output)}\n`);
47
61
  }
48
62
 
@@ -27,6 +27,13 @@ export function messageOutput(answer, event) {
27
27
  return { hookSpecificOutput: { hookEventName: event, additionalContext: text } };
28
28
  }
29
29
 
30
+ /** The one line the hook says when the board no longer has this checkout's task claimed by its agent (BRK-87), or null on an event that can't carry it. */
31
+ export function releasedOutput(claim, event) {
32
+ if (!CONTEXT_EVENTS.has(event)) return null;
33
+ const text = `${claim.wid ?? 'The task'} is no longer claimed by ${claim.agent ?? 'this agent'} (finished or released), so this checkout stops sending its live output. \`tasks claim\` starts it again on another task.`;
34
+ return { hookSpecificOutput: { hookEventName: event, additionalContext: text } };
35
+ }
36
+
30
37
  /** The board's answer → the messages as Claude reads them, one paragraph each, or '' when there are none. */
31
38
  export function messageText(answer) {
32
39
  const messages = Array.isArray(answer?.messages) ? answer.messages : [];
package/scripts/tasks.mjs CHANGED
@@ -40,11 +40,18 @@ import {
40
40
  } from './tasks/structure.js';
41
41
  import { looksLikeSecret } from '../src/ping.js';
42
42
  import { promptPathOf } from '../src/repos.js';
43
- import { sessionProxy, routeThroughSessionProxy } from './tasks/proxy.js';
43
+ import { hookFailure, sessionProxy, routeThroughSessionProxy } from './tasks/proxy.js';
44
44
  import { githubFromRemote, inRepo, pickRepo } from './tasks/repo.js';
45
45
  import { NO_TERMINAL, ask as askIn } from './tasks/ask.js';
46
46
  import { CLI_PACKAGE, PROMPT_SECTIONS, initPlan, machineTaskrc, promptSections } from './tasks/init.js';
47
- import { githubRequest, ideaTask, staleCliWarning, unknownSubcommand } from './tasks/cli.js';
47
+ import {
48
+ githubRequest,
49
+ ideaTask,
50
+ pullAgentRequest,
51
+ pullAgentSummary,
52
+ staleCliWarning,
53
+ unknownSubcommand,
54
+ } from './tasks/cli.js';
48
55
  import { CLI_VERSION } from '../src/cli-version.js';
49
56
  import { LEGACY, parseInstall, secretName } from '../src/install.js';
50
57
  import {
@@ -143,6 +150,8 @@ Reading (list, next, claim, and add work in this checkout's repos
143
150
  routines saved prompts the owner runs with a button, and their caps
144
151
  routines run <slug> run one now: makes a RUN task and starts an agent on it [--note <text>]
145
152
  horizon close close now: finished tasks go to the archive, next becomes now, later becomes next [--dry-run]
153
+ github fix <n> start an agent on a pull request's conflicts, failing checks, or review comments (owner) [--problem conflicts|failing|review] [--note <text>] [--repo <slug>]
154
+ github review <n> start an agent that tests a Dependabot pull request, as Safe to merge? does (owner) [--note <text>] [--repo <slug>]
146
155
  github the checkout's repository on GitHub: open pull requests, checks, reviews, CI, deploys, alerts [--sync] [--repo <slug>]
147
156
  hook session|wait the Claude Code session hooks a repository's .claude/settings.json runs (npx breakaway hook session)
148
157
  health the server's state
@@ -517,6 +526,20 @@ async function startSessionLog(t) {
517
526
  );
518
527
  }
519
528
 
529
+ /** The session hook couldn't post this checkout's live output: say so where the agent and the owner see it (BRK-86). */
530
+ function warnHookFailure() {
531
+ try {
532
+ const claim = JSON.parse(readFileSync(join(REPO, '.task-session'), 'utf8'));
533
+ const failed = claim?.uuid && hookFailure(claim.uuid);
534
+ if (failed)
535
+ console.error(
536
+ `tasks: the session hook couldn't post ${claim.wid ?? 'this task'}'s live output (${failed.reason}, ${failed.at.slice(0, 16).replace('T', ' ')} UTC), so the board shows none. docs/tasks.md#cloud-agents says what to check.`,
537
+ );
538
+ } catch {
539
+ /* no claim here */
540
+ }
541
+ }
542
+
520
543
  function unmarkSession(t) {
521
544
  try {
522
545
  const path = join(REPO, '.task-session');
@@ -1188,6 +1211,18 @@ const commands = {
1188
1211
  );
1189
1212
  },
1190
1213
  async github() {
1214
+ const action = args[0];
1215
+ if (action === 'fix' || action === 'review') {
1216
+ const built = pullAgentRequest(action, args[1], {
1217
+ repo: (await checkoutRepo()).slug,
1218
+ problem: opts.problem,
1219
+ note: opts.note,
1220
+ });
1221
+ if (built.error || !built.request) fail(built.error ?? 'bad request');
1222
+ const answer = await call(...built.request);
1223
+ print(answer, (a) => pullAgentSummary(action, args[1].replace(/^#/u, ''), a));
1224
+ return;
1225
+ }
1191
1226
  const g = await call(...githubRequest((await checkoutRepo()).slug, { sync: Boolean(opts.sync) }));
1192
1227
  print(g, (d) => {
1193
1228
  if (!d.connected) return "GitHub isn't connected yet: open the GitHub view on the board (docs/tasks.md#github).";
@@ -1938,5 +1973,6 @@ if (opts.help || command === 'help') {
1938
1973
  } else if (unknownSubcommand(command, args[0])) {
1939
1974
  fail(unknownSubcommand(command, args[0]));
1940
1975
  } else {
1976
+ if (command !== 'hook') warnHookFailure();
1941
1977
  await commands[command]();
1942
1978
  }
@@ -2,8 +2,7 @@
2
2
  * The version of the board's CLI and the files repos init copies with it (CLD-193). The board reports it
3
3
  * (every API answer's X-Tasks-Cli header, and health), so a copy in another repository can say it's older
4
4
  * and how to update it. scripts/tasks/version.test.js fails when the copied files change and this doesn't:
5
- * bump CLI_VERSION and set CLI_FINGERPRINT to the value it prints. Constants only: the board's Worker imports it,
6
5
  * so it lives in the board's package (CLD-135) and the CLI imports it from here.
7
6
  */
8
- export const CLI_VERSION = 36;
9
- export const CLI_FINGERPRINT = '252bee7de1e280f7';
7
+ export const CLI_VERSION = 45;
8
+ export const CLI_FINGERPRINT = '947c6296a0dd677e';
package/src/install.js CHANGED
@@ -147,7 +147,7 @@ export const secretName = (inst, key) => `${inst.secretsPrefix}${key}`;
147
147
  export const docsLink = (inst, anchor) => (inst.docs ? `${inst.docs}${anchor ? `#${anchor}` : ''}` : null);
148
148
 
149
149
  /** The routes `run_worker_first` sends to the Worker; everything else is the web app. */
150
- const WORKER_FIRST = ['/api/*', '/v1/*', '/github/*', '/login', '/logout'];
150
+ export const WORKER_FIRST = ['/api/*', '/v1/*', '/github/*', '/login', '/logout'];
151
151
 
152
152
  /**
153
153
  * The Worker's wrangler config for an install. `local` is for `wrangler dev` (interop): no custom
@@ -1,7 +1,9 @@
1
1
  # Deploys a breakaway release to this install's Worker: the release breakaway.json pins (stable), or the latest
2
2
  # pre-release (main). Runs on a push to main, which is how a merged update pull request deploys, and by hand.
3
- # It stops with a plain message when the release needs steps by hand, or when breakaway.config.json changed
4
- # something it can't deploy (the address, cron triggers, or Durable Object classes): those are yours to deploy.
3
+ # A deploy uploads a version of the Worker. A change a version can't carry (the address, cron triggers, or a new
4
+ # Durable Object class) needs wrangler deploy: Deploy runs it when the repository variable BREAKAWAY_DEPLOY_CHANGES is
5
+ # true, for a token that can (the README says which), and otherwise stops with a plain message, as it does for a release
6
+ # that needs steps by hand. It never changes the Worker's name or its Durable Object, which would open an empty board.
5
7
  name: Deploy
6
8
 
7
9
  on:
@@ -40,6 +42,8 @@ jobs:
40
42
  CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
41
43
  CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
42
44
  DRY_RUN: ${{ inputs.dry-run }}
45
+ # true lets Deploy run wrangler deploy for an address, cron triggers, or a new Durable Object class (BRK-62).
46
+ DEPLOY_CHANGES: ${{ vars.BREAKAWAY_DEPLOY_CHANGES }}
43
47
  steps:
44
48
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
45
49
  with:
@@ -91,6 +95,7 @@ jobs:
91
95
  (cd release && sha256sum --check SHA256SUMS)
92
96
 
93
97
  - name: Check it can deploy by itself
98
+ id: check
94
99
  env:
95
100
  CLI: ${{ steps.release.outputs.cli }}
96
101
  VERSION: ${{ steps.release.outputs.version }}
@@ -98,15 +103,34 @@ jobs:
98
103
  ADDRESS: ${{ vars.BREAKAWAY_URL }}
99
104
  run: |
100
105
  set -euo pipefail
101
- # Nothing is deployed yet on the first run, so there is no running release to compare with.
102
- address=${ADDRESS:-$(node -p "require('./breakaway.config.json').url || ''")}
106
+ # The config as it was before this push, to see whether it changed what a version upload can't carry.
107
+ rm -f before.json running.wrangler.json
108
+ case "$BEFORE" in ''|0000000000000000000000000000000000000000) ;; *) git show "$BEFORE:breakaway.config.json" > before.json 2>/dev/null || rm -f before.json ;; esac
109
+ was=breakaway.config.json
110
+ if [ -f before.json ]; then was=before.json; fi
111
+ # Where the running board answers: the address from before this push, if the push changes it.
112
+ address=${ADDRESS:-$(node -p "require('./$was').url || ''")}
103
113
  running=""
104
- # A board that doesn't say its release (one from before BRK-10) runs none this check knows.
114
+ # A board that doesn't say its release (one from before BRK-10) runs none this check knows. Nothing is deployed
115
+ # yet on the first run, so there is no running release to compare with.
105
116
  if [ -n "$address" ]; then running=$(curl -fsS --max-time 20 "$address/api/ping" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).release ?? ''" 2>/dev/null || true); fi
106
- # The config as it was before this push, to see whether it changed what a deploy can't.
107
- rm -f before.json
108
- case "$BEFORE" in ''|0000000000000000000000000000000000000000) ;; *) git show "$BEFORE:breakaway.config.json" > before.json 2>/dev/null || rm -f before.json ;; esac
109
- node "$CLI" install check --version "$VERSION" --manifest release/manifest.json --running "$running" --before before.json
117
+ # The Worker as it runs: the config the running release's own CLI makes from the config it was deployed with, so
118
+ # a release that changes the cron triggers or the Durable Object classes shows as a change too. Without it (the
119
+ # same release, or one that can't be read), the check compares the configs with this release's code.
120
+ if [ -n "$running" ] && [ "$running" != "$VERSION" ]; then
121
+ dir="$RUNNER_TEMP/running"
122
+ mkdir -p "$dir/src"
123
+ if gh release download "v$running" --repo "$BREAKAWAY_REPO" --archive tar.gz -O - 2>/dev/null | tar -xz -C "$dir/src" --strip-components 1 2>/dev/null; then
124
+ cp "$was" "$dir/breakaway.config.json"
125
+ (cd "$dir" && node src/scripts/tasks.mjs install config --out "$GITHUB_WORKSPACE/running.wrangler.json" > /dev/null 2>&1) || rm -f running.wrangler.json
126
+ fi
127
+ fi
128
+ status=0
129
+ node "$CLI" install check --version "$VERSION" --manifest release/manifest.json --running "$running" \
130
+ --before before.json --running-config running.wrangler.json --deploy-changes "${DEPLOY_CHANGES:-false}" > check.out || status=$?
131
+ cat check.out
132
+ if [ "$status" -ne 0 ]; then exit "$status"; fi
133
+ cat check.out >> "$GITHUB_OUTPUT"
110
134
 
111
135
  - name: Make the Worker config
112
136
  env:
@@ -127,7 +151,15 @@ jobs:
127
151
 
128
152
  - name: Dry run
129
153
  if: ${{ inputs.dry-run }}
130
- run: npx --yes wrangler@4 deploy --dry-run --outdir "$RUNNER_TEMP/dry" -c wrangler.generated.json
154
+ env:
155
+ DEPLOY: ${{ steps.check.outputs.deploy }}
156
+ CHANGES: ${{ steps.check.outputs.changes }}
157
+ run: |
158
+ set -euo pipefail
159
+ npx --yes wrangler@4 deploy --dry-run --outdir "$RUNNER_TEMP/dry" -c wrangler.generated.json
160
+ if [ "$DEPLOY" != wrangler ]; then echo "A deploy would upload a version."
161
+ elif [ -n "$CHANGES" ]; then echo "A deploy would run wrangler deploy for $CHANGES."
162
+ else echo "A deploy would run wrangler deploy, the release's manual step."; fi
131
163
 
132
164
  - name: Upload and deploy
133
165
  if: ${{ !inputs.dry-run }}
@@ -135,6 +167,9 @@ jobs:
135
167
  CLI: ${{ steps.release.outputs.cli }}
136
168
  VERSION: ${{ steps.release.outputs.version }}
137
169
  ADDRESS: ${{ vars.BREAKAWAY_URL }}
170
+ DEPLOY: ${{ steps.check.outputs.deploy }}
171
+ CHANGES: ${{ steps.check.outputs.changes }}
172
+ ADDRESS_CHANGED: ${{ steps.check.outputs.address_changed }}
138
173
  run: |
139
174
  set -euo pipefail
140
175
  wrangler() { npx --yes wrangler@4 "$@" -c wrangler.generated.json; }
@@ -150,6 +185,11 @@ jobs:
150
185
  fi
151
186
  if [ -z "$previous" ]; then
152
187
  wrangler deploy --var "BREAKAWAY_VERSION:$VERSION"
188
+ elif [ "$DEPLOY" = wrangler ]; then
189
+ # What a version can't carry (BRK-62): wrangler deploy applies the address, cron triggers, and new Durable
190
+ # Object classes with the code, and the Worker can still go back to $previous unless a class was added.
191
+ if [ -n "$CHANGES" ]; then echo "This deploy changes $CHANGES, so it runs wrangler deploy."; else echo "breakaway $VERSION's manual step is wrangler deploy, so this deploy runs it."; fi
192
+ wrangler deploy --var "BREAKAWAY_VERSION:$VERSION" --message "breakaway $VERSION"
153
193
  else
154
194
  export WRANGLER_OUTPUT_FILE_PATH="$RUNNER_TEMP/wrangler-output.ndjson"
155
195
  wrangler versions upload --var "BREAKAWAY_VERSION:$VERSION" --message "breakaway $VERSION"
@@ -157,22 +197,33 @@ jobs:
157
197
  wrangler versions deploy "$uploaded@100%" --yes
158
198
  fi
159
199
  # Health check: the new release answers /api/ping. Without it, go back to the previous version.
160
- address=${ADDRESS:-$(node -p "require('./breakaway.config.json').url || ''")}
200
+ url=$(node -p "require('./breakaway.config.json').url || ''")
201
+ address=${ADDRESS:-$url}
202
+ tries=12
203
+ if [ "$ADDRESS_CHANGED" = true ]; then
204
+ # A new address: check it, not the old one, and give its custom domain five minutes for its certificate.
205
+ address=${url:-$ADDRESS}
206
+ tries=60
207
+ fi
161
208
  if [ -z "$address" ]; then
162
209
  echo "::warning title=Not checked::There is no address to check. Set the repository variable BREAKAWAY_URL to where the board answers (a workers.dev address, say) and the next deploy checks it."
163
210
  exit 0
164
211
  fi
165
- for attempt in $(seq 1 12); do
212
+ for attempt in $(seq 1 "$tries"); do
166
213
  if curl -fsS --max-time 15 "$address/api/ping" | node "$CLI" install healthy --version "$VERSION"; then
167
214
  echo "breakaway $VERSION is running at $address."
168
215
  exit 0
169
216
  fi
170
217
  sleep 5
171
218
  done
172
- if [ -n "$previous" ]; then
173
- wrangler rollback "$previous" --message "breakaway $VERSION failed its check" --yes
174
- echo "::error title=Rolled back::breakaway $VERSION didn't answer $address/api/ping within a minute, so the Worker went back to its previous version. Nothing is lost; read the Worker's logs on Cloudflare."
219
+ waited=$([ "$tries" -gt 12 ] && echo "five minutes" || echo "a minute")
220
+ if [ -z "$previous" ]; then
221
+ echo "::error title=Failed its check::breakaway $VERSION didn't answer $address/api/ping within $waited, and this was the first deploy, so there is no version to go back to. Read the Worker's logs on Cloudflare."
222
+ elif wrangler rollback "$previous" --message "breakaway $VERSION failed its check" --yes; then
223
+ kept=""
224
+ if [ "$DEPLOY" = wrangler ]; then kept=" Rolling back brings back the code only: what wrangler deploy changed${CHANGES:+ ($CHANGES)} stays as it left it."; fi
225
+ echo "::error title=Rolled back::breakaway $VERSION didn't answer $address/api/ping within $waited, so the Worker went back to its previous version.$kept Nothing is lost; read the Worker's logs on Cloudflare."
175
226
  else
176
- echo "::error title=Failed its check::breakaway $VERSION didn't answer $address/api/ping within a minute, and this was the first deploy, so there is no version to go back to. Read the Worker's logs on Cloudflare."
227
+ echo "::error title=Couldn't roll back::breakaway $VERSION didn't answer $address/api/ping within $waited, and the Worker couldn't go back to its previous version: Cloudflare doesn't roll back across a new Durable Object class. The board's data is still in its Durable Object. Read the Worker's logs on Cloudflare, then deploy a fix."
177
228
  fi
178
229
  exit 1
@@ -17,7 +17,7 @@ The board answers at {{address}}.
17
17
  ## Set it up once
18
18
 
19
19
  1. **Push this to a private GitHub repository.**
20
- 2. **Make a GitHub environment named `production`** (Settings, Environments) with two secrets: `CLOUDFLARE_API_TOKEN`, a token that can edit Workers on your account, and `CLOUDFLARE_ACCOUNT_ID`.
20
+ 2. **Make a GitHub environment named `production`** (Settings, Environments) with two secrets: `CLOUDFLARE_API_TOKEN`, a Cloudflare API token ([which token does what](#which-token-does-what)), and `CLOUDFLARE_ACCOUNT_ID`.
21
21
  3. **Allow the update workflow to open pull requests:** Settings, Actions, General, "Allow GitHub Actions to create and approve pull requests".
22
22
  4. **Set the board's secrets** (`npx breakaway init-secrets`, then the Worker's secrets or your Secrets Store: see `.dev.vars.example`).
23
23
  5. **Run the Deploy workflow** from the Actions tab. Choose "dry-run" first to check everything up to the Worker without changing it.
@@ -32,7 +32,25 @@ Both workflows run breakaway's CLI from the release's own source on GitHub, with
32
32
 
33
33
  If Deploy stops with "Couldn't list the Worker's deployments", it changed nothing: only a Worker that doesn't exist yet counts as a first deploy. Check that `CLOUDFLARE_ACCOUNT_ID` is your account's ID (32 hex characters) and that `CLOUDFLARE_API_TOKEN` can read and edit Workers on it, then run Deploy again.
34
34
 
35
- A release can need steps by hand (a change to the Durable Object classes, say). The workflow stops with those steps in its message and deploys nothing; do them, then deploy with `wrangler`. The same stop happens when you change something in `breakaway.config.json` the workflow can't deploy: the address, cron triggers, or Durable Object classes. Apply that change yourself (`npx breakaway install config` makes the Worker config), then run Deploy again.
35
+ A release can need steps by hand (a Durable Object class deleted or renamed, say). The workflow stops with those steps in its message and deploys nothing; do them, then deploy with `wrangler`. A release whose only step is `wrangler deploy` says so in its notes, and Deploy runs it itself when it may ([below](#which-token-does-what)). The same goes for a new address in `breakaway.config.json`, which a version upload can't carry: without that, apply it yourself (`npx breakaway install config` makes the Worker config), then run Deploy again.
36
+
37
+ ## Which token does what
38
+
39
+ `CLOUDFLARE_API_TOKEN` decides what Deploy can do by itself. Most deploys only upload a new version of the Worker. A deploy that changes the address, the cron triggers, or the Durable Object classes needs `wrangler deploy`, and so a token that can run it:
40
+
41
+ | Token | What Deploy does |
42
+ | --- | --- |
43
+ | **Workers Editor on this Worker** | Uploads each release, checks it, and goes back if it fails. It stops on an address, cron, or Durable Object class change, and you apply that yourself. |
44
+ | **Workers Editor on every Worker**, and **Zone, Workers Routes, Write** on the board's zone | The same, and with the repository variable `BREAKAWAY_DEPLOY_CHANGES` set to `true`, it runs `wrangler deploy` for those changes too. Custom domains don't support per-Worker roles yet, so Editor has to cover every Worker. |
45
+
46
+ Either token also needs **Account, Secrets Store, Edit** when the board has a Secrets Store: every deploy binds its secrets. The very first deploy makes the Worker, which takes **Workers Admin**; after that, Editor is enough.
47
+
48
+ Whatever the token, Deploy never changes the Worker's name, its Durable Object (`store`), or its jurisdiction: each opens an empty board, so it stops instead.
49
+
50
+ With `wrangler deploy`:
51
+
52
+ - **A new address replaces the old one.** The Worker answers only on the addresses in its config, so the old one stops answering, and in a workflow `wrangler deploy` takes over the new hostname's DNS record, or another Worker's domain on it, without asking. Moving to another zone needs Workers Routes, Write on both zones. The check after the deploy asks the new address, for up to five minutes while its certificate is issued. If you set `BREAKAWAY_URL`, change it with the address.
53
+ - **Going back brings back the code only.** The address and cron triggers stay as `wrangler deploy` left them, and Cloudflare doesn't roll back across a new Durable Object class, so after one the only way is forward.
36
54
 
37
55
  ## Change the install
38
56