breakaway 1.2.1-main.2 → 1.2.1-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.
@@ -19,7 +19,7 @@ breakaway's work is on the board that tracks this repository. The CLI is `npx br
19
19
 
20
20
  A `409` means someone has it or it's blocked. Pick another; never `--force` someone else's claim.
21
21
  4. **Read it:** `tasks show <ID>` (description, done when, comments, spec, what it waits for and holds up), then [`AGENTS.md`](../../../AGENTS.md) if you haven't this session.
22
- 5. **Work** on a branch. Record what you learn as you go: `tasks comment <ID> "<finding>"`. Comments are append-only; the description is the current brief, and you edit it only on a task you made or are refining. New work you find becomes `tasks add "<title>" --project <area> --tag agent|owner --horizon <h> --brief "<what and why>" --done-when "<done when>"`, with `--depends <ID>` when it waits for something. breakaway's areas: `board`, `web`, `docs`, `launch`, `brand`.
22
+ 5. **Work** on a branch. Record what you learn as you go: `tasks comment <ID> "<finding>"`. Comments are append-only; the description is the current brief, and you edit it only on a task you made or are refining. New work you find becomes `tasks add "<title>" --project <area> --tag agent|owner --horizon <h> --brief "<what and why>" --done-when "<done when>"`, with `--depends <ID>` when it waits for something. breakaway's areas: `board`, `web`, `docs`, `launch`, `brand`, `cli`.
23
23
  6. **Hand over:** open the pull request with `Closes <ID>.` in its description, then `tasks modify <ID> --pr <number>` and `tasks comment <ID> "<one-line result>"`. The board moves the task to In review and marks it done when the pull request merges; don't mark it done yourself. If you stop before a pull request: `comment` where you got to, then `release <ID>`.
24
24
 
25
25
  ## Rules
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "breakaway",
3
- "version": "1.2.1-main.2",
3
+ "version": "1.2.1-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",
@@ -9,7 +9,7 @@ The routine on claude.ai holds only the stub, [`prompts/stub.md`](stub.md), whic
9
9
 
10
10
  ## Repository
11
11
 
12
- breakaway: the repository whose `origin` ends with `/breakaway`. Its areas on the board, with their work-ID prefixes: board (`BRK`), web (`WEB`), docs (`DOC`), launch (`LCH`), and brand (`ID`). Its rules are in `AGENTS.md`; read it first, then the `tasks` skill (`.agents/skills/tasks/SKILL.md`).
12
+ breakaway: the repository whose `origin` ends with `/breakaway`. Its areas on the board, with their work-ID prefixes: board (`BRK`), web (`WEB`), docs (`DOC`), launch (`LCH`), brand (`ID`), and cli (`CLI`). Its rules are in `AGENTS.md`; read it first, then the `tasks` skill (`.agents/skills/tasks/SKILL.md`).
13
13
 
14
14
  ## Building
15
15
 
@@ -6,10 +6,11 @@ import { CLI_PACKAGE } from './init.js';
6
6
 
7
7
  /** The subcommands each command knows. Without one, each lists or shows (horizon needs close). */
8
8
  export const SUBCOMMANDS = {
9
- agents: ['next', 'start', 'refine'],
9
+ agents: ['next', 'start', 'refine', 'new'],
10
10
  github: ['fix', 'review'],
11
11
  repos: ['add', 'init', 'modify', 'remove', 'setup'],
12
12
  routines: ['add', 'modify', 'run', 'trigger', 'revoke', 'pause', 'resume'],
13
+ features: ['list', 'add', 'show', 'modify'],
13
14
  horizon: ['close'],
14
15
  hook: ['session', 'wait'],
15
16
  };
@@ -79,14 +80,14 @@ export const FIX_PROBLEMS = ['conflicts', 'failing', 'review'];
79
80
 
80
81
  /**
81
82
  * 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
+ * agent" and "Safe to merge?" or "Review with an agent" buttons (`POST github/pulls/<n>/fix` and `/review`). It names the checkout's repository
83
84
  * like `github` does. Returns an error message instead when the number or `problem` can't be right.
84
85
  * @param {'fix' | 'review'} action
85
86
  * @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>] }}
87
+ * @param {{ repo?: string | null, problem?: string, note?: string, force?: boolean, by?: string }} [options]
88
+ * @returns {{ error?: string, request?: [string, string, Record<string, string | boolean>] }}
88
89
  */
89
- export function pullAgentRequest(action, number, { repo = null, problem, note } = {}) {
90
+ export function pullAgentRequest(action, number, { repo = null, problem, note, force = false, by } = {}) {
90
91
  const n = String(number ?? '').replace(/^#/u, '');
91
92
  if (!/^[1-9]\d{0,8}$/u.test(n)) return { error: `say which pull request: npx breakaway github ${action} <number>` };
92
93
  if (problem !== undefined && action !== 'fix') return { error: '--problem is for github fix' };
@@ -96,20 +97,94 @@ export function pullAgentRequest(action, number, { repo = null, problem, note }
96
97
  ...(repo ? { repo } : {}),
97
98
  ...(problem ? { problem } : {}),
98
99
  ...(typeof note === 'string' && note.trim() ? { note } : {}),
100
+ // Review with an agent is the owner's, so a review always says who asks (BRK-111); a fix only when forcing.
101
+ ...(action === 'review' ? { ...(force ? { force: true } : {}), ...(by ? { by } : {}) } : forceFields(force, by)),
99
102
  };
100
103
  return { request: ['POST', `github/pulls/${n}/${action}`, body] };
101
104
  }
102
105
 
106
+ export const REVIEW_VERDICTS = ['ready', 'follow-up', 'changes'];
107
+
108
+ /**
109
+ * `npx breakaway review <ID> --verdict ready|follow-up|changes "<note>"` (BRK-111): an agent's answer on the pull
110
+ * request that closes its task. The board adds it to the task as a comment and keeps it for the pull request page.
111
+ * `pr` picks the pull request when the task has several open.
112
+ * @param {string | undefined} ref
113
+ * @param {string | undefined} verdict
114
+ * @param {string | undefined} note
115
+ * @param {{ by?: string, pr?: string | number }} [options]
116
+ */
117
+ export function reviewRequest(ref, verdict, note, { by, pr } = {}) {
118
+ if (!ref) return { error: 'say which task: npx breakaway review <task> --verdict ready "<note>"' };
119
+ if (!REVIEW_VERDICTS.includes(String(verdict)))
120
+ return { error: `say the verdict: --verdict ${REVIEW_VERDICTS.join('|')}` };
121
+ const text = String(note ?? '').trim();
122
+ if (!text) return { error: 'say what you found: the note is the review (Markdown)' };
123
+ const n = pr === undefined ? null : String(pr).replace(/^#/u, '');
124
+ if (n !== null && !/^[1-9]\d{0,8}$/u.test(n)) return { error: '--pr is a pull request number' };
125
+ return {
126
+ request: [
127
+ 'POST',
128
+ `tasks/${encodeURIComponent(ref)}/review`,
129
+ { verdict, note: text, ...(by ? { by } : {}), ...(n ? { pr: Number(n) } : {}) },
130
+ ],
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Force start on a request that starts an agent (BRK-107): `force`, and who is asking, so the board can refuse an
136
+ * agent's name (only the owner forces a start). Nothing when it isn't forced.
137
+ * @param {unknown} force
138
+ * @param {string | undefined} by
139
+ */
140
+ export function forceFields(force, by) {
141
+ return force ? { force: true, ...(by ? { by } : {}) } : {};
142
+ }
143
+
144
+ /**
145
+ * `agents new`: the request that makes a task from a prompt and starts an agent on it. It's the checkout's repository
146
+ * unless `--repo` names another. It always says who asks, so the board refuses an agent's name: only the owner starts one.
147
+ * With `decision` (`agents new --decision <ID> ["<note>"]`, BRK-110) the board writes the prompt from that answered
148
+ * decision, in the decision's repository, and the text is the owner's note under it.
149
+ * @param {string} prompt
150
+ * @param {{ repo?: string | null, force?: boolean, by?: string, decision?: string | null }} [options]
151
+ */
152
+ export function generalAgentRequest(prompt, { repo = null, force = false, by, decision = null } = {}) {
153
+ const text = String(prompt ?? '').trim();
154
+ if (!text && !decision)
155
+ return { error: 'say what the agent should do: npx breakaway agents new "Tidy the docs" [--image <file>]' };
156
+ const body = {
157
+ ...(decision ? { decision, ...(text ? { note: text } : {}) } : { prompt: text }),
158
+ ...(repo ? { repo } : {}),
159
+ ...(force ? { force: true } : {}),
160
+ ...(by ? { by } : {}),
161
+ };
162
+ return { request: ['POST', 'agents/general', body] };
163
+ }
164
+
165
+ /**
166
+ * What the CLI says about a general agent's answer: the task and that it started, or why it waits (and whether Force
167
+ * start could skip that), or, from a decision, the open one that already has it.
168
+ * @param {{ task: { wid?: string, short?: string }, run?: { url?: string, agent?: string } | null, waiting?: string | null, forceable?: boolean, already?: string | null }} answer
169
+ */
170
+ export function generalAgentSummary({ task, run, waiting, forceable, already }) {
171
+ const id = task.wid ?? task.short;
172
+ if (!run && already) return `${id} already refines from these answers: ${already}.`;
173
+ if (run) return `Started ${run.agent ? `${run.agent} ` : 'an agent '}on ${id}${run.url ? `: ${run.url}` : ''}`;
174
+ 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` : ''}`;
175
+ }
176
+
103
177
  /**
104
178
  * What the CLI says about an answer to those requests: which task and agent took the pull request, or who already has it.
105
179
  * @param {'fix' | 'review'} action
106
180
  * @param {string | number} number
107
- * @param {{ task: { wid?: string, short?: string }, run?: { url?: string, agent?: string } | null, already?: string | null }} answer
181
+ * @param {{ task: { wid?: string, short?: string }, run?: { url?: string, agent?: string, kind?: string } | null, already?: string | null }} answer
108
182
  */
109
183
  export function pullAgentSummary(action, number, { task, run, already }) {
110
184
  const id = task.wid ?? task.short;
111
185
  if (!run) return `${id} already has it: ${already}.`;
112
- const what = action === 'fix' ? `fixing #${number}` : `testing #${number}`;
186
+ const what =
187
+ action === 'fix' ? `fixing #${number}` : run.kind === 'pr-review' ? `reviewing #${number}` : `testing #${number}`;
113
188
  return `Started ${run.agent ? `${run.agent}, ` : 'an agent '}${what} on ${id}${run.url ? `: ${run.url}` : ''}`;
114
189
  }
115
190
 
@@ -132,3 +207,197 @@ export function ideaTask(idea, { horizon = 'auto', auto = false, repo = null } =
132
207
  ...(repo ? { repo } : {}),
133
208
  };
134
209
  }
210
+
211
+ /**
212
+ * The fields `features add` and `features modify` send (BRK-85, IDEA-28 section 1): only the ones given. `--release none`
213
+ * (or an empty one) leaves the feature unplanned, and `v1.2.0` is read as `1.2.0`.
214
+ * @param {{ title?: string, brief?: string, release?: string, state?: string }} options
215
+ */
216
+ export function featureBody({ title, brief, release, state } = {}) {
217
+ const body = {};
218
+ if (title !== undefined) body.title = String(title);
219
+ if (brief !== undefined) body.brief = String(brief);
220
+ if (release !== undefined) {
221
+ const r = String(release).trim();
222
+ body.release = r === 'none' ? '' : r.replace(/^v(?=\d)/u, '');
223
+ }
224
+ if (state !== undefined) body.state = String(state);
225
+ return body;
226
+ }
227
+
228
+ /**
229
+ * The request behind `npx breakaway chase <slug> [stop] [--parallel <n>] [--dry-run]` (BRK-85, IDEA-28 section 3):
230
+ * without `stop` it starts the chase, or keeps a running one going with the new `--parallel`; `--dry-run` shows what
231
+ * would start now and changes nothing. It always says who asks, so the board refuses an agent's name: a chase is the
232
+ * owner's.
233
+ * @param {string | undefined} slug
234
+ * @param {string | undefined} action
235
+ * @param {{ parallel?: string | number, dryRun?: boolean, by?: string }} [options]
236
+ * @returns {{ error?: string, request?: [string, string, Record<string, unknown>] }}
237
+ */
238
+ export function chaseRequest(slug, action, { parallel, dryRun = false, by } = {}) {
239
+ if (!slug) return { error: 'say which feature: npx breakaway chase <slug> [stop] [--parallel <n>] [--dry-run]' };
240
+ if (action !== undefined && action !== 'stop')
241
+ return { error: `chase has no "${String(action).slice(0, 40)}": npx breakaway chase <slug> [stop]` };
242
+ let limit;
243
+ if (parallel !== undefined) {
244
+ limit = Number(parallel);
245
+ if (!Number.isInteger(limit) || limit < 1)
246
+ return { error: '--parallel is how many agents at once in one area: a whole number, 1 or more' };
247
+ }
248
+ if (action === 'stop' && limit !== undefined)
249
+ return { error: '--parallel is for a chase that runs: npx breakaway chase <slug> --parallel <n>' };
250
+ const body = {
251
+ on: action !== 'stop',
252
+ ...(limit !== undefined ? { parallel: limit } : {}),
253
+ ...(dryRun ? { dryRun: true } : {}),
254
+ ...(by ? { by } : {}),
255
+ };
256
+ return { request: ['POST', `features/${encodeURIComponent(slug.toLowerCase())}/chase`, body] };
257
+ }
258
+
259
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
260
+ const idOf = (t) => t.wid ?? t.short ?? String(t.uuid ?? '').slice(0, 8);
261
+
262
+ /**
263
+ * A feature's progress in one line: "4 of 10 done: 2 running, 1 ready, 1 waiting for you".
264
+ * @param {{ total: number, done: number, running?: number, ready?: number, waiting?: number, needsYou?: number, inReview?: number }} p
265
+ */
266
+ export function progressLine(p) {
267
+ if (!p.total) return 'no tasks yet';
268
+ const rest = [
269
+ p.inReview && `${p.inReview} in review`,
270
+ p.running && `${p.running} running`,
271
+ p.ready && `${p.ready} ready`,
272
+ p.waiting && `${p.waiting} waiting on other tasks`,
273
+ p.needsYou && `${p.needsYou} waiting for you`,
274
+ ].filter(Boolean);
275
+ return `${p.done} of ${p.total} done${rest.length ? `: ${rest.join(', ')}` : ''}`;
276
+ }
277
+
278
+ /** The chase in a few words for a feature's line, or null when it's off. */
279
+ function chaseWords(chase) {
280
+ if (!chase || chase.state === 'off') return null;
281
+ if (chase.state === 'on') return `chasing, ${chase.parallel} at once in an area`;
282
+ if (chase.state === 'done') return 'chase ended';
283
+ return 'chase stopped';
284
+ }
285
+
286
+ /**
287
+ * What `npx breakaway features` prints: features by release, then unplanned, then the tags that could be features
288
+ * and the tasks with a release tag and no feature.
289
+ * @param {{ features: any[], suggestions?: any[], releaseTasks?: any[] }} data
290
+ */
291
+ export function featureListLines({ features, suggestions = [], releaseTasks = [] }) {
292
+ const out = [];
293
+ if (!features.length)
294
+ out.push('No features yet. Make one: npx breakaway features add <slug> --title "<title>" [--release 1.2.0]');
295
+ const width = Math.max(12, ...features.map((f) => f.slug.length));
296
+ let group;
297
+ for (const f of features) {
298
+ const release = f.release ?? 'Unplanned';
299
+ if (release !== group) {
300
+ if (group !== undefined) out.push('');
301
+ out.push(release);
302
+ group = release;
303
+ }
304
+ const notes = [
305
+ progressLine(f.progress),
306
+ f.shipped ? 'shipped' : null,
307
+ chaseWords(f.chase),
308
+ f.conflicts?.length ? `${plural(f.conflicts.length, 'task')} in two features` : null,
309
+ ].filter(Boolean);
310
+ out.push(` ${f.slug.padEnd(width)} ${f.title} · ${notes.join(' · ')}`);
311
+ }
312
+ if (suggestions.length) {
313
+ out.push('', 'Tags that could be features (npx breakaway features add <slug>):');
314
+ for (const s of suggestions)
315
+ out.push(` ${s.slug} (${plural(s.open, 'open task')}${s.release ? `, ${s.release}` : ''})`);
316
+ }
317
+ for (const r of releaseTasks)
318
+ out.push('', `Other tasks in ${r.release}: ${r.tasks.map((t) => t.wid ?? t.description).join(', ')}`);
319
+ return out;
320
+ }
321
+
322
+ /**
323
+ * A chase's state, live line, and what holds it, as `features show` and `chase` print them (IDEA-28 section 3.9).
324
+ * @param {any} chase
325
+ * @param {string} [slug] the feature's, for the command that starts it
326
+ */
327
+ export function chaseLines(chase, slug = '<slug>') {
328
+ if (!chase) return [];
329
+ const out = [];
330
+ const head = {
331
+ on: `On since ${String(chase.startedAt ?? '')
332
+ .slice(0, 16)
333
+ .replace('T', ' ')} UTC, ${plural(chase.parallel, 'agent')} at once in an area`,
334
+ stopped: 'Stopped: running agents finish, nothing new starts',
335
+ done: 'Ended: every task is done or in review',
336
+ off: `Off (npx breakaway chase ${slug} starts it, ${plural(chase.parallel, 'agent')} at once in an area)`,
337
+ }[chase.state];
338
+ out.push(` Chase ${head ?? chase.state}`);
339
+ if (chase.summary) out.push(` ${chase.summary}`);
340
+ for (const n of chase.needsYou ?? []) out.push(` Needs you ${idOf(n)} ${n.why}${blocking(n)}`);
341
+ for (const s of chase.stuck ?? [])
342
+ out.push(` Stuck ${idOf(s)} ${s.why}${s.last ? `; last: ${oneLine(s.last)}` : ''}`);
343
+ const queue = chase.queue ?? [];
344
+ if (queue.length) {
345
+ out.push(' Next');
346
+ for (const q of queue) out.push(` ${idOf(q).padEnd(9)} ${q.ready ? 'ready to start' : q.reason}${blocking(q)}`);
347
+ }
348
+ return out;
349
+ }
350
+
351
+ const oneLine = (text) => {
352
+ const flat = String(text).replace(/\s+/gu, ' ').trim();
353
+ return flat.length > 120 ? `${flat.slice(0, 117)}…` : flat;
354
+ };
355
+ /** Why a pulled-in blocker is in the chase (section 3.1). */
356
+ const blocking = (t) => (t.blocks?.length ? ` (in the chase because it blocks ${t.blocks.join(', ')})` : '');
357
+
358
+ /**
359
+ * What `npx breakaway features show <slug>` prints: the record, its progress, its chase, and its tasks in dependency order.
360
+ * @param {any} f
361
+ */
362
+ export function featureLines(f) {
363
+ const out = [`${f.title} (${f.slug})`, ''];
364
+ const row = (k, v) => v && out.push(` ${k.padEnd(11)} ${v}`);
365
+ row('Release', f.release ?? 'unplanned');
366
+ row('State', f.shipped && f.state !== 'shipped' ? 'shipped (every task is done and live)' : f.state);
367
+ row('Progress', progressLine(f.progress));
368
+ out.push(...chaseLines(f.chase, f.slug));
369
+ // The chase's own Needs you says more (merges, connections), so the feature's is shown only without one.
370
+ if (!f.chase?.needsYou) for (const n of f.needsYou ?? []) row('Needs you', `${idOf(n)} ${n.why}`);
371
+ for (const c of f.conflicts ?? []) row('Two features', `${c.wid} is in ${c.features.join(' and ')}: it counts here`);
372
+ if (f.brief) out.push('', ...f.brief.split('\n').map((l) => ` ${l}`));
373
+ if (f.tasks?.length) {
374
+ out.push('', ' Tasks');
375
+ for (const t of f.tasks)
376
+ out.push(` ${idOf(t).padEnd(9)} ${t.state.padEnd(9)} ${t.description}${t.why ? ` (${t.why})` : ''}`);
377
+ } else out.push('', ` No tasks yet: tag them with ${f.slug} (npx breakaway modify <ref> --tag ${f.slug}).`);
378
+ return out;
379
+ }
380
+
381
+ /**
382
+ * What `npx breakaway chase` prints after the board answers: what it started or would start, then the chase. `parallel`
383
+ * is the limit a dry run tried, which the board doesn't keep.
384
+ * @param {string} slug
385
+ * @param {{ dryRun?: boolean, chase: any, started?: string[], wouldStart?: string[] }} answer
386
+ * @param {{ stop?: boolean, parallel?: number }} [options]
387
+ */
388
+ export function chaseSummary(slug, { dryRun, chase, started = [], wouldStart = [] }, { stop = false, parallel } = {}) {
389
+ let first;
390
+ if (dryRun) {
391
+ const limit = parallel ? ` with ${plural(parallel, 'agent')} at once in an area` : '';
392
+ first = `A chase of ${slug}${limit} would start ${wouldStart.length ? `${wouldStart.join(', ')} now` : 'nothing now'}. Nothing was started.`;
393
+ } else if (stop) first = `Stopped the chase of ${slug}. Running agents finish and open their pull requests.`;
394
+ else if (chase.state !== 'on') first = `The chase of ${slug} isn’t on (${chase.state}).`;
395
+ else if (started.length) first = `Chasing ${slug}: started ${started.join(', ')}.`;
396
+ else {
397
+ const next = (chase.queue ?? []).filter((q) => q.ready).map(idOf);
398
+ first = next.length
399
+ ? `Chasing ${slug}: the board starts ${next.join(', ')} on its next check.`
400
+ : `Chasing ${slug}: nothing can start right now; the board starts each task when it’s ready.`;
401
+ }
402
+ return [first, '', ...chaseLines(chase, slug)].join('\n');
403
+ }
package/scripts/tasks.mjs CHANGED
@@ -44,10 +44,20 @@ import { githubFromRemote, inRepo, pickRepo } from './tasks/repo.js';
44
44
  import { NO_TERMINAL, ask as askIn } from './tasks/ask.js';
45
45
  import { CLI_PACKAGE, PROMPT_SECTIONS, initPlan, machineTaskrc, promptSections } from './tasks/init.js';
46
46
  import {
47
+ chaseRequest,
48
+ chaseSummary,
49
+ featureBody,
50
+ featureLines,
51
+ featureListLines,
52
+ progressLine,
53
+ forceFields,
54
+ generalAgentRequest,
55
+ generalAgentSummary,
47
56
  githubRequest,
48
57
  ideaTask,
49
58
  pullAgentRequest,
50
59
  pullAgentSummary,
60
+ reviewRequest,
51
61
  staleCliWarning,
52
62
  unknownSubcommand,
53
63
  } from './tasks/cli.js';
@@ -133,15 +143,26 @@ Reading (list, next, claim, and add work in this checkout's repos
133
143
  --project <p> --horizon <h> --tag <t>
134
144
  activity recent changes, newest first [--limit <n>]
135
145
  agents cloud agents: what's running, what's waiting to start
136
- agents start <ref> start a Claude cloud agent on a task [--note <text>]
137
- agents refine <ref> start an agent that improves a task, not builds it --note <what to look at or change>
146
+ agents new "<prompt>" start an agent from a prompt: it makes its own task [--image <file>]… [--repo <slug>] [--force] (owner)
147
+ agents new --decision <ref> ["<note>"] start an agent that brings the work waiting for an answered decision in line with its answers; the board writes its prompt [--force] (owner)
148
+ agents start <ref> start a Claude cloud agent on a task [--note <text>] [--force]
149
+ agents refine <ref> start an agent that improves a task, not builds it --note <what to look at or change> [--force]
138
150
  agents plan [<plan>] your Claude plan and what it allows; pro, max5, or max20 picks one (owner) and sets the limits to its defaults
139
151
  agents next start the next few ready tasks, one per area [--count <n>] [--dry-run] [--repo <slug>]
140
152
  routines saved prompts the owner runs with a button, and their caps
141
- routines run <slug> run one now: makes a RUN task and starts an agent on it [--note <text>]
153
+ routines run <slug> run one now: makes a RUN task and starts an agent on it [--note <text>] [--force]
154
+ features features by release: each one's progress and chase, and tags that could be features
155
+ features show <slug> one feature: its release, progress, what waits for you, its chase, and its tasks in order
156
+ chase <slug> start a chase (owner): the board starts an agent on every ready task in the feature and on
157
+ what blocks it, within its limits, until all are done or in review
158
+ --parallel <n> the most agents at once in one area (default 3); on a running chase, it changes it
159
+ --dry-run show what would start now, and start nothing
160
+ chase <slug> stop stop it (owner): nothing new starts; running agents finish
142
161
  horizon close close now: finished tasks go to the archive, next becomes now, later becomes next [--dry-run]
143
- 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>]
144
- github review <n> start an agent that tests a Dependabot pull request, as Safe to merge? does (owner) [--note <text>] [--repo <slug>]
162
+ 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>] [--force]
163
+ github review <n> start an agent that reviews a pull request that can merge as it stands, on the task it closes, as
164
+ Review with an agent does; on a Dependabot one it tests the update, as Safe to merge? does
165
+ (owner) [--note <text>] [--repo <slug>] [--force]
145
166
  github the checkout's repository on GitHub: open pull requests, checks, reviews, CI, deploys, alerts [--sync] [--repo <slug>]
146
167
  hook session|wait the Claude Code session hooks a repository's .claude/settings.json runs (npx breakaway hook session)
147
168
  health the server's state
@@ -153,6 +174,8 @@ Working
153
174
  refuses another repository's task unless --repo names it
154
175
  release <ref> give it back [--force]
155
176
  comment <ref> <text> add a comment (signed with your agent name); note is the same command
177
+ review <ref> --verdict ready|follow-up|changes <note> your review of the pull request that closes the task you
178
+ hold: a comment on it, and the review on the pull request's page (the note is Markdown) [--pr <n>]
156
179
  done <ref> finish it [--note <text>] [--pr <url>]
157
180
  add <description> new task; gets the next work ID for its project
158
181
  --project <p> --tag <t>… --priority H|M|L --horizon now|next|later
@@ -205,6 +228,9 @@ Working
205
228
  --agents-max <n|none> and --agents-hourly <n|none> cap its agents under the board's shared limits,
206
229
  --prompt <path|none> says where its agent prompt is in its checkout (default tools/tasks/routine-prompt.md)
207
230
  --pipeline <file.json|none> sets its deploy pipeline ({"workers": {"staging", "production"}, "workflows": {...}, "deployPaths"}) or clears it
231
+ features add <slug> new feature: its tasks join by carrying <slug> as a tag [--title <text>]
232
+ [--brief <text> | --brief-file <path>] [--release <x.y.z>] (agents add one without a release)
233
+ features modify <slug> change one (owner): --title, --brief, --brief-file, --release <x.y.z|none>, --state open|shipped
208
234
  routines add <slug> new routine (owner) --name <text> --prompt <text> | --prompt-file <path> [--done-when <text>] [--horizon now|next|later] [--gap <minutes>] [--daily <n>]
209
235
  [--repo <slug>] the repository it runs in (default: the checkout's)
210
236
  routines modify <slug> change one (owner): the same options (--repo <slug> moves it), and --enabled yes|no; --schedule "0 9 * * 1" runs it on a cron schedule (UTC), --schedule "" clears it; --trigger-start auto|wait sets whether a webhook or GitHub event starts the agent or waits for your Start; --github-events pr_merged,release_published,workflow_failed (or "") starts it on those GitHub events
@@ -752,8 +778,33 @@ const commands = {
752
778
  );
753
779
  return;
754
780
  }
781
+ if (sub === 'new') {
782
+ const decision = typeof opts.decision === 'string' ? opts.decision : null;
783
+ const built = generalAgentRequest(args.slice(1).join(' '), {
784
+ // From a decision, the board runs it in the decision's repository unless --repo says otherwise.
785
+ repo: opts.repo ?? (decision ? null : (await checkoutRepo()).slug),
786
+ decision,
787
+ force: Boolean(opts.force),
788
+ by: opts.as ?? setting('AGENT'),
789
+ });
790
+ if (built.error || !built.request) fail(built.error ?? 'bad request');
791
+ if ((opts.image ?? []).length > 4) fail('an agent takes up to 4 images');
792
+ for (const file of opts.image ?? []) if (!existsSync(file)) fail(`can't read ${file}`);
793
+ const answer = await call(...built.request);
794
+ // The task exists now: attach the images to it, as an idea's are.
795
+ for (const file of opts.image ?? []) {
796
+ const image = await upload(ref(answer.task), file);
797
+ if (!opts.json) console.log(`Attached ${image.name} (${Math.ceil(image.size / 1024)} KB).`);
798
+ }
799
+ print(answer, generalAgentSummary);
800
+ return;
801
+ }
755
802
  if (sub === 'start') {
756
- const { task, run } = await call('POST', 'agents/start', { ref: need(args[1], 'task'), note: opts.note });
803
+ const { task, run } = await call('POST', 'agents/start', {
804
+ ref: need(args[1], 'task'),
805
+ note: opts.note,
806
+ ...forceFields(opts.force, opts.as ?? setting('AGENT')),
807
+ });
757
808
  print({ task, run }, () => `Started an agent on ${task.wid ?? task.short}: ${run.url}`);
758
809
  return;
759
810
  }
@@ -764,6 +815,7 @@ const commands = {
764
815
  ref: need(args[1], 'task'),
765
816
  note: opts.note,
766
817
  mode: 'refine',
818
+ ...forceFields(opts.force, opts.as ?? setting('AGENT')),
767
819
  });
768
820
  print({ task, run }, () => `Started an agent refining ${task.wid ?? task.short}: ${run.url}`);
769
821
  return;
@@ -938,7 +990,10 @@ const commands = {
938
990
  };
939
991
  const clean = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
940
992
  if (sub === 'run') {
941
- const { task, run } = await call('POST', `routines/${enc(need(args[1], 'routine'))}/run`, { note: opts.note });
993
+ const { task, run } = await call('POST', `routines/${enc(need(args[1], 'routine'))}/run`, {
994
+ note: opts.note,
995
+ ...forceFields(opts.force, opts.as ?? setting('AGENT')),
996
+ });
942
997
  print({ task, run }, () => `Started ${task.wid}: ${run.url}`);
943
998
  return;
944
999
  }
@@ -1012,6 +1067,60 @@ const commands = {
1012
1067
  ].join('\n'),
1013
1068
  );
1014
1069
  },
1070
+ async features() {
1071
+ const sub = args[0];
1072
+ const body = () =>
1073
+ featureBody({
1074
+ title: opts.title,
1075
+ brief: opts['brief-file'] ? readFileSync(opts['brief-file'], 'utf8') : opts.brief,
1076
+ release: opts.release,
1077
+ state: opts.state,
1078
+ });
1079
+ // Who asks, so the board can refuse an agent what's the owner's (a release, a change).
1080
+ const by = opts.as ?? setting('AGENT');
1081
+ if (sub === 'add') {
1082
+ const slug = need(args[1], 'slug').toLowerCase();
1083
+ const { feature } = await call('POST', 'features', { slug, ...body(), ...(by ? { by } : {}) });
1084
+ print({ feature }, (d) =>
1085
+ [
1086
+ `Added the feature ${d.feature.slug}${d.feature.release ? `, aimed at ${d.feature.release}` : ''}: ${progressLine(d.feature.progress)}.`,
1087
+ `Tasks join it by the tag: npx breakaway modify <ref> --tag ${d.feature.slug}`,
1088
+ ].join('\n'),
1089
+ );
1090
+ return;
1091
+ }
1092
+ if (sub === 'modify') {
1093
+ const changes = body();
1094
+ if (!Object.keys(changes).length) fail('say what to change: --title, --brief, --release, or --state');
1095
+ const { feature } = await call('PATCH', `features/${enc(need(args[1], 'feature').toLowerCase())}`, {
1096
+ ...changes,
1097
+ ...(by ? { by } : {}),
1098
+ });
1099
+ print({ feature }, (d) => `Saved ${d.feature.slug}.`);
1100
+ return;
1101
+ }
1102
+ if (sub === 'show') {
1103
+ const { feature } = await call('GET', `features/${enc(need(args[1], 'feature').toLowerCase())}`);
1104
+ print({ feature }, (d) => featureLines(d.feature).join('\n'));
1105
+ return;
1106
+ }
1107
+ print(await call('GET', 'features'), (d) => featureListLines(d).join('\n'));
1108
+ },
1109
+ async chase() {
1110
+ const built = chaseRequest(args[0], args[1], {
1111
+ parallel: opts.parallel,
1112
+ dryRun: Boolean(opts['dry-run']),
1113
+ by: opts.as ?? setting('AGENT'),
1114
+ });
1115
+ if (built.error) fail(built.error);
1116
+ const answer = await call(...built.request);
1117
+ print(answer, (d) =>
1118
+ chaseSummary(args[0].toLowerCase(), d, {
1119
+ stop: args[1] === 'stop',
1120
+ parallel: opts.parallel === undefined ? undefined : Number(opts.parallel),
1121
+ }),
1122
+ );
1123
+ },
1015
1124
  async next() {
1016
1125
  const body = { agent: agent(), claim: Boolean(opts.claim), project: opts.project, horizon: opts.horizon };
1017
1126
  const repo = await scopedRepo();
@@ -1046,6 +1155,12 @@ const commands = {
1046
1155
  unmarkSession(task);
1047
1156
  print(task, (t) => `Released ${ref(t)}.`);
1048
1157
  },
1158
+ async review() {
1159
+ const built = reviewRequest(args[0], opts.verdict, args.slice(1).join(' '), { by: agent(), pr: opts.pr });
1160
+ if (built.error || !built.request) fail(built.error ?? 'bad request');
1161
+ const { review, task } = await call(...built.request);
1162
+ print({ review, task }, (r) => `Left your review of #${r.review.pr} on ${ref(r.task)}: ${r.review.label}.`);
1163
+ },
1049
1164
  // The board's `annotate` route is the comments route's alias; using it keeps this working on a board that hasn't deployed /comments yet.
1050
1165
  async comment() {
1051
1166
  const text = args.slice(1).join(' ');
@@ -1206,6 +1321,8 @@ const commands = {
1206
1321
  repo: (await checkoutRepo()).slug,
1207
1322
  problem: opts.problem,
1208
1323
  note: opts.note,
1324
+ force: Boolean(opts.force),
1325
+ by: opts.as ?? setting('AGENT'),
1209
1326
  });
1210
1327
  if (built.error || !built.request) fail(built.error ?? 'bad request');
1211
1328
  const answer = await call(...built.request);
@@ -4,5 +4,5 @@
4
4
  * and how to update it. scripts/tasks/version.test.js fails when the copied files change and this doesn't:
5
5
  * so it lives in the board's package (CLD-135) and the CLI imports it from here.
6
6
  */
7
- export const CLI_VERSION = 50;
8
- export const CLI_FINGERPRINT = '2eb2f4fbe777547e';
7
+ export const CLI_VERSION = 55;
8
+ export const CLI_FINGERPRINT = 'e4ee0503a3e3699a';
package/src/decision.js CHANGED
@@ -239,3 +239,75 @@ export function summarize(questions, answers) {
239
239
  });
240
240
  return `Decided by the owner: ${parts.join('; ')}`;
241
241
  }
242
+
243
+ /** Tags that aren't a feature's (IDEA-28 section 1): the board's own, horizons, and release tags. */
244
+ const NOT_FEATURES = new Set(['agent', 'owner', 'decide', 'idea', 'general']);
245
+ export const featureTags = (tags) =>
246
+ (tags ?? []).filter((t) => !NOT_FEATURES.has(t) && !t.startsWith('horizon-') && !/^v\d/u.test(t));
247
+
248
+ /** One answer as words, in full but for a long open answer. */
249
+ function spell(q, answer) {
250
+ if (!answer) return 'no answer';
251
+ if (q.type === 'open') return clip(answer.value.trim(), 1500);
252
+ const label = (id) =>
253
+ id === 'other' ? `something else: ${answer.other}` : (q.options.find((o) => o.id === id)?.label ?? id);
254
+ if (q.type === 'choice') return label(answer.value);
255
+ if (q.type === 'multi') return answer.value.length ? answer.value.map(label).join(', ') : 'none';
256
+ if (q.type === 'rank') return answer.value.map((id, i) => `${i + 1}. ${label(id)}`).join(', ');
257
+ if (q.type === 'scale')
258
+ return `${answer.value} (${q.min}${q.minLabel ? ` ${q.minLabel}` : ''} to ${q.max}${q.maxLabel ? ` ${q.maxLabel}` : ''})`;
259
+ return String(answer.value);
260
+ }
261
+
262
+ /** The longest the prompt may be: a task's description. */
263
+ const MAX_BRIEF = 10000;
264
+
265
+ /**
266
+ * Refine from the answers (docs/specs/IDEA-30-new-agent.md, section 8): the prompt the board writes for a general
267
+ * agent that brings the work waiting for an answered decision in line with its answers. `decision` is the decision's
268
+ * task (its `ref` is its work ID or short ID), `waiting` the open tasks that depend on it, and `note` the owner's,
269
+ * which goes under the board's prompt. Returns the task's title and description.
270
+ * @param {{ ref: string, description: string, spec?: string | null, questions: any[], answers: Record<string, any> }} decision
271
+ * @param {{ ref: string, description: string, tags?: string[], spec?: string | null }[]} waiting
272
+ * @param {string | null} [note]
273
+ */
274
+ export function refinePrompt(decision, waiting, note = null) {
275
+ const title = clip(`Refine from the answers to ${decision.ref}: ${decision.description}`, 200);
276
+ const questions = decision.questions.flatMap((q, i) => {
277
+ const answer = decision.answers[q.id];
278
+ return [
279
+ `${i + 1}. ${q.prompt.trim()}`,
280
+ ` Answer: ${spell(q, answer)}`,
281
+ ...(answer?.comment ? [` The owner's note: ${answer.comment.trim()}`] : []),
282
+ ];
283
+ });
284
+ const specs = [...new Set([decision.spec, ...waiting.map((t) => t.spec)].filter(Boolean))];
285
+ const held = waiting.map((t) => {
286
+ const features = featureTags(t.tags);
287
+ return `- ${t.ref}: ${t.description}${features.length ? ` (feature: ${features.join(', ')})` : ''}`;
288
+ });
289
+ const after = [
290
+ '',
291
+ 'What to do',
292
+ `- Change the tasks waiting for ${decision.ref}, and their dependencies, so they match the answers (the cross-task edits a general agent may make, each change noted).`,
293
+ `- Update ${specs.length ? 'the spec' : 'any spec the tasks link'} to match, in one pull request that closes your own task.`,
294
+ '- Add the tasks the answers need, filled in and depending on what they wait for.',
295
+ '- Ask a new decision for anything the answers leave open. Never change these answers: only the owner does.',
296
+ '- If nothing in the repository needs to change, comment what you changed on the board, task by task, and release your task.',
297
+ ...(note && String(note).trim() ? ['', 'Note from the owner:', String(note).trim().slice(0, 4000)] : []),
298
+ ];
299
+ const intro = `The owner answered the decision on ${decision.ref} (${decision.description}). Bring the work waiting for it in line with the answers.`;
300
+ const rest = [
301
+ '',
302
+ `Waiting for ${decision.ref}`,
303
+ ...(held.length ? held : ['- Nothing open waits for it: look for tasks and specs the answers change.']),
304
+ ...(specs.length ? ['', specs.length === 1 ? 'Spec' : 'Specs', ...specs.map((s) => `- ${s}`)] : []),
305
+ ...after,
306
+ ].join('\n');
307
+ // Too long for a description: the answers give way first, since they stay on the decision for the agent to read.
308
+ const more = `\n… (the rest is on ${decision.ref}: npx breakaway show ${decision.ref})`;
309
+ const answers = ['', 'Questions and answers', ...questions].join('\n');
310
+ const room = MAX_BRIEF - intro.length - rest.length - 1;
311
+ const middle = answers.length <= room ? answers : `${answers.slice(0, Math.max(0, room - more.length))}${more}`;
312
+ return { title, brief: `${intro}${middle}\n${rest}`.slice(0, MAX_BRIEF) };
313
+ }