breakaway 1.2.1-main.16 → 1.2.1-main.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "breakaway",
3
- "version": "1.2.1-main.16",
3
+ "version": "1.2.1-main.18",
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",
@@ -10,6 +10,7 @@ export const SUBCOMMANDS = {
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
  };
@@ -206,3 +207,197 @@ export function ideaTask(idea, { horizon = 'auto', auto = false, repo = null } =
206
207
  ...(repo ? { repo } : {}),
207
208
  };
208
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,6 +44,12 @@ 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,
47
53
  forceFields,
48
54
  generalAgentRequest,
49
55
  generalAgentSummary,
@@ -145,6 +151,13 @@ Reading (list, next, claim, and add work in this checkout's repos
145
151
  agents next start the next few ready tasks, one per area [--count <n>] [--dry-run] [--repo <slug>]
146
152
  routines saved prompts the owner runs with a button, and their caps
147
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
148
161
  horizon close close now: finished tasks go to the archive, next becomes now, later becomes next [--dry-run]
149
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]
150
163
  github review <n> start an agent that reviews a pull request that can merge as it stands, on the task it closes, as
@@ -215,6 +228,9 @@ Working
215
228
  --agents-max <n|none> and --agents-hourly <n|none> cap its agents under the board's shared limits,
216
229
  --prompt <path|none> says where its agent prompt is in its checkout (default tools/tasks/routine-prompt.md)
217
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
218
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>]
219
235
  [--repo <slug>] the repository it runs in (default: the checkout's)
220
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
@@ -1051,6 +1067,60 @@ const commands = {
1051
1067
  ].join('\n'),
1052
1068
  );
1053
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
+ },
1054
1124
  async next() {
1055
1125
  const body = { agent: agent(), claim: Boolean(opts.claim), project: opts.project, horizon: opts.horizon };
1056
1126
  const repo = await scopedRepo();
@@ -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 = 54;
8
- export const CLI_FINGERPRINT = 'c5c5e4b72de52bfb';
7
+ export const CLI_VERSION = 55;
8
+ export const CLI_FINGERPRINT = 'e4ee0503a3e3699a';