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 +1 -1
- package/scripts/tasks/cli.js +195 -0
- package/scripts/tasks.mjs +70 -0
- package/src/cli-version.js +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "breakaway",
|
|
3
|
-
"version": "1.2.1-main.
|
|
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",
|
package/scripts/tasks/cli.js
CHANGED
|
@@ -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();
|
package/src/cli-version.js
CHANGED
|
@@ -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 =
|
|
8
|
-
export const CLI_FINGERPRINT = '
|
|
7
|
+
export const CLI_VERSION = 55;
|
|
8
|
+
export const CLI_FINGERPRINT = 'e4ee0503a3e3699a';
|