breakaway 2.0.1 → 2.0.2-main.2
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/.agents/skills/tasks/SKILL.md +10 -2
- package/README.md +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/prompts/breakaway.md +1 -1
- package/prompts/core.md +51 -8
- package/scripts/tasks/cli.js +158 -5
- package/scripts/tasks/edit-hook.mjs +54 -0
- package/scripts/tasks/footprint-hook.js +188 -0
- package/scripts/tasks/peloton.js +83 -10
- package/scripts/tasks/plugin-hooks.js +5 -4
- package/scripts/tasks/session-hook.mjs +30 -1
- package/scripts/tasks/session-messages.js +3 -2
- package/scripts/tasks/structure.js +21 -0
- package/scripts/tasks.mjs +312 -21
- package/src/footprint-text.js +93 -0
- package/src/infra-cloudflare.js +12 -0
- package/src/infra-desired.js +4 -3
- package/src/infra-provider.js +3 -0
- package/src/init.js +45 -9
- package/src/model.js +57 -2
- package/taskrc +2 -1
package/scripts/tasks.mjs
CHANGED
|
@@ -36,11 +36,13 @@ import {
|
|
|
36
36
|
decisionField,
|
|
37
37
|
proposalField,
|
|
38
38
|
structureLines,
|
|
39
|
+
ownerSaidLines,
|
|
39
40
|
textFields,
|
|
40
41
|
} from './tasks/structure.js';
|
|
41
42
|
import { looksLikeSecret } from '../src/ping.js';
|
|
42
43
|
import { sessionReport, shortHash, stubText } from '../src/session-report.js';
|
|
43
44
|
import { promptPathOf } from '../src/repos.js';
|
|
45
|
+
import { claimLines, footprintLines } from '../src/footprint-text.js';
|
|
44
46
|
import { hookFailure, sessionProxy, routeThroughSessionProxy } from './tasks/proxy.js';
|
|
45
47
|
import { githubFromRemote, inRepo, pickRepo } from './tasks/repo.js';
|
|
46
48
|
import { appInPlace } from './tasks/github-connect.js';
|
|
@@ -58,6 +60,8 @@ import {
|
|
|
58
60
|
promptSections,
|
|
59
61
|
} from './tasks/init.js';
|
|
60
62
|
import {
|
|
63
|
+
captainLines,
|
|
64
|
+
captainRequest,
|
|
61
65
|
chaseRequest,
|
|
62
66
|
chaseSummary,
|
|
63
67
|
featureBody,
|
|
@@ -79,6 +83,8 @@ import {
|
|
|
79
83
|
pullAgentRequest,
|
|
80
84
|
pullAgentSummary,
|
|
81
85
|
reviewRequest,
|
|
86
|
+
riskAnswerRequest,
|
|
87
|
+
riskReviewRequest,
|
|
82
88
|
staleCliWarning,
|
|
83
89
|
releaseBehind,
|
|
84
90
|
removedRepoByHand,
|
|
@@ -94,6 +100,8 @@ import {
|
|
|
94
100
|
mergeViews,
|
|
95
101
|
pelotonLines,
|
|
96
102
|
pelotonPost,
|
|
103
|
+
openLines,
|
|
104
|
+
handoverComment,
|
|
97
105
|
pickPeloton,
|
|
98
106
|
pickPlanPeloton,
|
|
99
107
|
planRevision,
|
|
@@ -246,7 +254,7 @@ Reading (list, next, claim, and add work in this checkout's repos
|
|
|
246
254
|
agents new --next minor|major ["<note>"] start an agent that sets package.json to the next minor or major release; the board writes its prompt [--repo <slug>] [--force] (owner)
|
|
247
255
|
agents new --spec <path> "<what should change>" start an agent that changes a spec as you ask and brings the tasks that
|
|
248
256
|
link it in line; the board writes its prompt [--repo <slug>] [--force] (owner)
|
|
249
|
-
agents start <ref> start a Claude cloud agent on a task [--note <text>] [--force]
|
|
257
|
+
agents start <ref> start a Claude cloud agent on a task; warns when a running agent changes its files [--note <text>] [--force] [--anyway]
|
|
250
258
|
agents refine <ref> start an agent that improves a task, not builds it --note <what to look at or change> [--force]
|
|
251
259
|
agents plan [<plan>] your Claude plan and what it allows; pro, max5, or max20 picks one (owner) and sets the limits to its defaults
|
|
252
260
|
agents next start the next few ready tasks, one per area [--count <n>] [--dry-run] [--repo <slug>]
|
|
@@ -259,8 +267,16 @@ Reading (list, next, claim, and add work in this checkout's repos
|
|
|
259
267
|
chase <slug> start a chase (owner): the board starts an agent on every ready task in the feature and on
|
|
260
268
|
what blocks it, within its limits, until all are done or in review
|
|
261
269
|
--parallel <n> the most agents at once in one area (default 3); on a running chase, it changes it
|
|
270
|
+
--review-cap <n> the most of its pull requests that wait for you before it starts nothing new but fixes
|
|
271
|
+
(default 5, from 1 to 50); on a running chase, it changes it
|
|
262
272
|
--dry-run show what would start now, and start nothing
|
|
273
|
+
--captain, --no-captain start a road captain with it, or not (by default, one for more than 10 tasks); on a
|
|
274
|
+
running chase, start or stand down its captain
|
|
275
|
+
--watch <hours> a road captain's watch before the board starts a fresh one (default 12)
|
|
263
276
|
chase <slug> stop stop it (owner): nothing new starts; running agents finish
|
|
277
|
+
captain <slug> the chase's road captain: who it is, when its watch ends, and its log
|
|
278
|
+
captain <slug> log --file <path> the road captain writes its log [--handover] then the board starts the next one
|
|
279
|
+
captain <slug> digest --file <path> the road captain's lines for the owner's next hourly digest of the chase
|
|
264
280
|
horizon close close now: finished tasks go to the archive, next becomes now, later becomes next [--dry-run]
|
|
265
281
|
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]
|
|
266
282
|
github review <n> start an agent that reviews a pull request that can merge as it stands, on the task it closes, as
|
|
@@ -273,7 +289,7 @@ Reading (list, next, claim, and add work in this checkout's repos
|
|
|
273
289
|
specs the repository's specs, newest first: each one's status and its tasks [--repo <slug>]
|
|
274
290
|
specs show <path> one spec: its status, last change, Markdown, and the tasks that link it [--repo <slug>]
|
|
275
291
|
github the checkout's repository on GitHub: open pull requests, checks, reviews, CI, deploys, alerts [--sync] [--repo <slug>]
|
|
276
|
-
hook session|wait
|
|
292
|
+
hook session|wait|edit the Claude Code session hooks a repository's .claude/settings.json runs (npx breakaway hook session)
|
|
277
293
|
health the server's state
|
|
278
294
|
mcp print the claude mcp add line and the .mcp.json entry that connect an MCP client to the board's
|
|
279
295
|
/mcp from this checkout, with the token as $BREAKAWAY_TOKEN, never its value. Writes nothing
|
|
@@ -281,6 +297,8 @@ Reading (list, next, claim, and add work in this checkout's repos
|
|
|
281
297
|
--headers …or print the headers Claude Code sends to /mcp as JSON, the token included: the plugin's
|
|
282
298
|
headersHelper. Only Authorization outside a repository the board tracks
|
|
283
299
|
export every task (all repositories, statuses, and horizons) as JSON, checked against health's count [--out <file>]
|
|
300
|
+
import <file> restore an export into an empty board: every task with its work ID and comments, claims cleared
|
|
301
|
+
(owner). Refused on a board that has tasks; register the export's repositories first
|
|
284
302
|
connections is everything the board leans on wired up: GitHub, Cloudflare, Claude, sync, push; the fix for each that isn't
|
|
285
303
|
|
|
286
304
|
Working
|
|
@@ -288,8 +306,16 @@ Working
|
|
|
288
306
|
refuses another repository's task unless --repo names it
|
|
289
307
|
release <ref> give it back [--force]
|
|
290
308
|
comment <ref> <text> add a comment (signed with your agent name); note is the same command
|
|
309
|
+
quote <ref> <words> --from <source> put the owner's exact words on a task you hold, quoted and marked as yours:
|
|
310
|
+
shown first on the task and to every agent that claims it. <source> is where they said it:
|
|
311
|
+
message, peloton, ping, decision, or comment, with a pointer if you have one ("peloton #12")
|
|
291
312
|
review <ref> --verdict ready|follow-up|changes <note> your review of the pull request that closes the task you
|
|
292
313
|
hold: a comment on it, and the review on the pull request's page (the note is Markdown) [--pr <n>]
|
|
314
|
+
risk-review <ref> --file <findings.json> a risky-path reviewer's findings on the pull request the board started it
|
|
315
|
+
for: { "summary", "findings": [{ "severity": "blocking"|"note", "text", "path", "line" }] }
|
|
316
|
+
[--pr <n>]; without --file, the task's reviews and their answers
|
|
317
|
+
risk-answer <ref> <finding> <text> answer a finding of the review on your pull request: what you changed, or
|
|
318
|
+
why it's safe; a blocking one holds Merge when green until you do [--pr <n>]
|
|
293
319
|
done <ref> finish it [--note <text>] [--pr <url>]
|
|
294
320
|
add <description> new task; gets the next work ID for its project
|
|
295
321
|
--project <p> --tag <t>… --priority H|M|L --horizon now|next|later
|
|
@@ -298,6 +324,7 @@ Working
|
|
|
298
324
|
--brief <text> | --brief-file <path> the description (--note is the same)
|
|
299
325
|
--done-when <text> what has to be true to call it done
|
|
300
326
|
--decision <file.json> questions for the owner to answer on the board (adds +decide); see decision --template
|
|
327
|
+
--force add it even though it resembles open tasks the board named (--related links them instead)
|
|
301
328
|
decision <ref> --template print an example decision file to edit (nothing here answers a decision: the owner does, on the board)
|
|
302
329
|
ping <ref> <message> tell the owner you need them, in the inbox and as a push (you must hold the task; only when they must act)
|
|
303
330
|
--kind blocked|question|stale|done|fyi blocked: needs the owner; question: a small question; stale: can't reproduce or already fine; done: looks finished; fyi: inbox only
|
|
@@ -306,11 +333,23 @@ Working
|
|
|
306
333
|
peloton who else is working (in your repository, and your chase's), the chase's plan and open
|
|
307
334
|
huddle, and what they posted since you last read: new posts are starred [--all] every post
|
|
308
335
|
peloton checkin <text> say you're here and what you'll change, the files or areas you'll touch, before your first
|
|
309
|
-
change (you must hold a task); posts on your repository's peloton and your chase's too
|
|
336
|
+
change (you must hold a task); posts on your repository's peloton and your chase's too,
|
|
337
|
+
and claims the paths it names [--files <pattern,…>] claims these instead
|
|
338
|
+
paths <ref> what a task touches: predicted, claimed (and when each claim runs out), changed, or in its
|
|
339
|
+
pull request, and the shared files left out
|
|
340
|
+
paths <ref> --claim <pattern>… claim a file, a folder/, or a glob (src/**, test/*.test.js) for the task you
|
|
341
|
+
hold before you change it; one another task claims is refused, with who holds it. Claims
|
|
342
|
+
run out 10 minutes after your session goes quiet, and after 4 hours
|
|
343
|
+
paths <ref> --release [<pattern>…] give your claims back (all of them without patterns); the owner, with no
|
|
344
|
+
agent name, releases anyone's
|
|
310
345
|
peloton step|note|ask|propose|review <text> say what you did, talk, ask, propose a change to the plan or the
|
|
311
346
|
tasks, or ask for a look at your approach; posts on your chase's peloton if your task is in
|
|
312
347
|
one, else your repository's [--peloton <name>] picks one
|
|
313
348
|
peloton reply <post> <text> answer a post, on the peloton it's on
|
|
349
|
+
peloton open the posts to you that you haven't answered or handed over: answer or hand over each before
|
|
350
|
+
you release your task or stop after its pull request merges [--task <ref>]
|
|
351
|
+
peloton handover <post> <text> hand a post over: a comment on your task naming who or which task follows it up
|
|
352
|
+
(works after you've left, too) [--task <ref>]
|
|
314
353
|
peloton huddle <question> call a huddle on your chase's peloton: every agent stops to talk one thing through
|
|
315
354
|
peloton in <huddle> [<text>] join the open huddle (or say why not now)
|
|
316
355
|
peloton outcome <huddle> <text> close the huddle you called with what was agreed, and who does what
|
|
@@ -319,7 +358,8 @@ Working
|
|
|
319
358
|
peloton listen wait for what's for you, in the foreground, instead of stopping: returns at once on an urgent
|
|
320
359
|
post, a message, or a change to your pull request, gathers other posts for 30 seconds, and
|
|
321
360
|
returns after 9 minutes with nothing; then run it again, unless it says to stop
|
|
322
|
-
[--for <minutes>] up to 9 [--task <ref>] the
|
|
361
|
+
[--for <minutes>|<seconds>s] from 10 seconds up to 9 minutes (2, 30s) [--task <ref>] the
|
|
362
|
+
task you listen on, when you hold more than one
|
|
323
363
|
idea <text> write down an idea for an agent to shape into tasks and a spec (area Ideas, IDEA-n)
|
|
324
364
|
--horizon now|next|later|auto the horizon for the tasks it makes (default auto: the agent chooses)
|
|
325
365
|
--auto start its agent by itself when there's room (your choice; off by default here)
|
|
@@ -499,6 +539,7 @@ const REPEATABLE = new Set([
|
|
|
499
539
|
'unrelated',
|
|
500
540
|
'area',
|
|
501
541
|
'remove-area',
|
|
542
|
+
'files',
|
|
502
543
|
]);
|
|
503
544
|
const FLAGS = new Set([
|
|
504
545
|
'json',
|
|
@@ -522,9 +563,15 @@ const FLAGS = new Set([
|
|
|
522
563
|
'check',
|
|
523
564
|
'headers',
|
|
524
565
|
'days',
|
|
566
|
+
'captain',
|
|
567
|
+
'no-captain',
|
|
568
|
+
'handover',
|
|
569
|
+
'anyway',
|
|
525
570
|
]);
|
|
526
571
|
/** Flags only in repos init (BRK-91): --pipeline takes a file in repos modify, and is a flag there. */
|
|
527
572
|
const INIT_FLAGS = new Set(['pipeline', 'copies']);
|
|
573
|
+
/** Flags only in paths (IDEA-55): the patterns follow the task, and --release takes a version elsewhere. */
|
|
574
|
+
const PATHS_FLAGS = new Set(['claim', 'release']);
|
|
528
575
|
|
|
529
576
|
function parse(argv) {
|
|
530
577
|
const positional = [];
|
|
@@ -537,7 +584,11 @@ function parse(argv) {
|
|
|
537
584
|
continue;
|
|
538
585
|
}
|
|
539
586
|
const [name, inline] = arg.slice(2).split(/=(.*)/su);
|
|
540
|
-
if (
|
|
587
|
+
if (
|
|
588
|
+
FLAGS.has(name) ||
|
|
589
|
+
(INIT_FLAGS.has(name) && positional[0] === 'repos' && positional[1] === 'init') ||
|
|
590
|
+
(PATHS_FLAGS.has(name) && positional[0] === 'paths')
|
|
591
|
+
) {
|
|
541
592
|
opts[name] = true;
|
|
542
593
|
continue;
|
|
543
594
|
}
|
|
@@ -819,7 +870,8 @@ function line(t) {
|
|
|
819
870
|
}
|
|
820
871
|
|
|
821
872
|
function detail(t) {
|
|
822
|
-
const
|
|
873
|
+
const said = ownerSaidLines(t);
|
|
874
|
+
const out = [`${ref(t)} · ${t.description}`, '', ...(said.length ? [...said, ''] : [])];
|
|
823
875
|
const row = (k, v) => v && out.push(` ${k.padEnd(11)} ${v}`);
|
|
824
876
|
const inReview = t.status === 'pending' && (t.github ?? []).some((p) => p.closes && p.state === 'open');
|
|
825
877
|
row(
|
|
@@ -878,7 +930,8 @@ function print(data, human) {
|
|
|
878
930
|
// ---- commands ----------------------------------------------------------------------------
|
|
879
931
|
|
|
880
932
|
/** A board from before a route answers 404 with "no route for …", or reads `listen` as a peloton's name. */
|
|
881
|
-
const noRoute = (res) =>
|
|
933
|
+
const noRoute = (res) =>
|
|
934
|
+
res.status === 404 && /^no route for|no peloton "(?:listen|open)"/u.test(String(res.data?.error ?? ''));
|
|
882
935
|
|
|
883
936
|
/**
|
|
884
937
|
* `peloton listen`: waits for what's for the agent (IDEA-36 section 3), asking the board every 5 seconds, and prints
|
|
@@ -904,6 +957,39 @@ async function pelotonListen(me) {
|
|
|
904
957
|
print(heard, listenText);
|
|
905
958
|
}
|
|
906
959
|
|
|
960
|
+
/** The posts to the agent it hasn't answered or handed over (BRK-281), from the board; null on a board without them. */
|
|
961
|
+
async function openPosts(me) {
|
|
962
|
+
const path = `peloton/open?agent=${enc(me)}${opts.task ? `&task=${enc(opts.task)}` : ''}`;
|
|
963
|
+
const res = await call('GET', path, undefined, { raw: true });
|
|
964
|
+
if (noRoute(res)) return null;
|
|
965
|
+
if (!res.ok) fail(res.data?.error ?? `HTTP ${res.status}`);
|
|
966
|
+
return res.data;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/** `peloton open`: what's still to answer or hand over before the agent leaves its task (BRK-281). */
|
|
970
|
+
async function pelotonOpen(me) {
|
|
971
|
+
const open = await openPosts(me);
|
|
972
|
+
if (!open) fail('this board doesn’t list open posts yet: it runs an older release');
|
|
973
|
+
print(open, (d) =>
|
|
974
|
+
d.posts.length
|
|
975
|
+
? openLines(d.posts, d.task)
|
|
976
|
+
: `Nothing to you is unanswered on the peloton${d.task ? ` for ${d.task}` : ''}.`,
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/** `peloton handover <post> <text>`: a comment on the agent's task naming who follows the post up (BRK-281). */
|
|
981
|
+
async function pelotonHandover(me) {
|
|
982
|
+
const open = await openPosts(me);
|
|
983
|
+
const id = /^#?(\d+)$/u.exec(String(args[1] ?? ''))?.[1];
|
|
984
|
+
const post = open?.posts.find((p) => String(p.id) === id) ?? null;
|
|
985
|
+
const built = handoverComment(args.slice(1), post);
|
|
986
|
+
if ('error' in built) return fail(built.error);
|
|
987
|
+
const task = opts.task ?? open?.task;
|
|
988
|
+
if (!task) fail('say which task it’s on: --task <ref>');
|
|
989
|
+
const out = await call('POST', `tasks/${enc(task)}/annotate`, { text: built.text, by: me });
|
|
990
|
+
print(out.task, (t) => `Handed over #${built.post} on ${ref(t)}.`);
|
|
991
|
+
}
|
|
992
|
+
|
|
907
993
|
/** `peloton plan` prints the chase's plan; with `--file <path> --why <text>`, it revises it (IDEA-36 section 5). */
|
|
908
994
|
async function pelotonPlan(me) {
|
|
909
995
|
if (args.length > 1)
|
|
@@ -1001,7 +1087,8 @@ function describeSources({ url, token, agent: name }) {
|
|
|
1001
1087
|
const commands = {
|
|
1002
1088
|
/** The Claude Code session hooks (BRK-7), so a repository's .claude/settings.json runs them through npx. */
|
|
1003
1089
|
async hook() {
|
|
1004
|
-
|
|
1090
|
+
const file = { wait: 'message-wait', edit: 'edit-hook' }[args[0]] ?? 'session-hook';
|
|
1091
|
+
await import(`./tasks/${file}.mjs`);
|
|
1005
1092
|
},
|
|
1006
1093
|
async list() {
|
|
1007
1094
|
const { tasks } = await call('GET', `tasks?status=${enc(opts.status ?? 'pending')}`);
|
|
@@ -1034,8 +1121,46 @@ const commands = {
|
|
|
1034
1121
|
);
|
|
1035
1122
|
},
|
|
1036
1123
|
async show() {
|
|
1037
|
-
const
|
|
1038
|
-
|
|
1124
|
+
const ref = enc(need(args[0], 'task'));
|
|
1125
|
+
const { task } = await call('GET', `tasks/${ref}`);
|
|
1126
|
+
// Its footprint (IDEA-55 section 5), where the board has them; an older board just leaves the section out.
|
|
1127
|
+
const fp = opts.json ? null : await call('GET', `tasks/${ref}/footprint`, undefined, { soft: true });
|
|
1128
|
+
print(task, (t) => [detail(t), ...(fp?.footprint ? ['', ...footprintLines(fp.footprint)] : [])].join('\n'));
|
|
1129
|
+
},
|
|
1130
|
+
/**
|
|
1131
|
+
* A task's footprint and its path claims (IDEA-55 section 1a): with no flag, what it touches; --claim <patterns> for
|
|
1132
|
+
* the task you hold (a refused one exits non-zero, saying who holds it); --release [<patterns>] gives yours back, or
|
|
1133
|
+
* any as the owner (no agent name), and every one without patterns.
|
|
1134
|
+
*/
|
|
1135
|
+
async paths() {
|
|
1136
|
+
const ref = enc(need(args[0], 'task'));
|
|
1137
|
+
const patterns = args
|
|
1138
|
+
.slice(1)
|
|
1139
|
+
.flatMap((p) => p.split(','))
|
|
1140
|
+
.map((p) => p.trim())
|
|
1141
|
+
.filter(Boolean);
|
|
1142
|
+
if (opts.claim && opts.release) fail('claim or release, not both at once');
|
|
1143
|
+
if (!opts.claim && !opts.release) {
|
|
1144
|
+
const { footprint } = await call('GET', `tasks/${ref}/footprint`);
|
|
1145
|
+
return print(footprint, (fp) => footprintLines(fp).join('\n'));
|
|
1146
|
+
}
|
|
1147
|
+
if (opts.claim && !patterns.length)
|
|
1148
|
+
fail('say which paths: npx breakaway paths <task> --claim <file, folder/, or glob> …');
|
|
1149
|
+
const name = opts.as ?? setting('AGENT', null);
|
|
1150
|
+
const res = await call(
|
|
1151
|
+
'POST',
|
|
1152
|
+
`tasks/${ref}/paths`,
|
|
1153
|
+
opts.claim
|
|
1154
|
+
? { agent: agent(), claim: patterns }
|
|
1155
|
+
: { ...(name ? { agent: name } : {}), release: patterns.length ? patterns : true },
|
|
1156
|
+
{ raw: true },
|
|
1157
|
+
);
|
|
1158
|
+
if (!res.ok && res.status !== 409) {
|
|
1159
|
+
if (opts.json) console.log(JSON.stringify({ status: res.status, ...res.data }, null, 2));
|
|
1160
|
+
fail(res.data.error ?? `HTTP ${res.status}`);
|
|
1161
|
+
}
|
|
1162
|
+
print(res.data, (d) => claimLines(d).join('\n'));
|
|
1163
|
+
if (res.status === 409) process.exit(1);
|
|
1039
1164
|
},
|
|
1040
1165
|
/**
|
|
1041
1166
|
* Every task on the board, in every repository, status, and horizon, as the board's JSON, checked against the
|
|
@@ -1053,6 +1178,30 @@ const commands = {
|
|
|
1053
1178
|
);
|
|
1054
1179
|
console.error(`Exported all ${tasks.length} tasks${opts.out ? ` to ${opts.out}` : ''}; the board has ${total}.`);
|
|
1055
1180
|
},
|
|
1181
|
+
/**
|
|
1182
|
+
* Restores what export wrote into an empty board (BRK-234): the owner's, so an agent's name is sent and refused.
|
|
1183
|
+
* The board keeps each task's UUID, work ID, and comments, and clears claims and autostart.
|
|
1184
|
+
*/
|
|
1185
|
+
async import() {
|
|
1186
|
+
const file = need(args[0], 'file');
|
|
1187
|
+
let data;
|
|
1188
|
+
try {
|
|
1189
|
+
data = JSON.parse(readFileSync(file, 'utf8'));
|
|
1190
|
+
} catch (error) {
|
|
1191
|
+
fail(`couldn't read ${file} as an export (${error.message}): it's the file npx breakaway export --out wrote`);
|
|
1192
|
+
}
|
|
1193
|
+
const signer = opts.as ?? setting('AGENT');
|
|
1194
|
+
const r = await call('POST', 'import', {
|
|
1195
|
+
tasks: data?.tasks,
|
|
1196
|
+
count: data?.count,
|
|
1197
|
+
...(signer ? { by: signer } : {}),
|
|
1198
|
+
});
|
|
1199
|
+
print(
|
|
1200
|
+
r,
|
|
1201
|
+
(d) =>
|
|
1202
|
+
`Restored ${d.imported} tasks with ${d.comments} comments; the board has ${d.total}.${d.cleared ? ` Cleared the claim or autostart on ${d.cleared}: start agents again from the board.` : ''}`,
|
|
1203
|
+
);
|
|
1204
|
+
},
|
|
1056
1205
|
async agents() {
|
|
1057
1206
|
const sub = args[0];
|
|
1058
1207
|
if (sub === 'next') {
|
|
@@ -1131,11 +1280,27 @@ const commands = {
|
|
|
1131
1280
|
return;
|
|
1132
1281
|
}
|
|
1133
1282
|
if (sub === 'start') {
|
|
1134
|
-
const
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1283
|
+
const answer = await call(
|
|
1284
|
+
'POST',
|
|
1285
|
+
'agents/start',
|
|
1286
|
+
{
|
|
1287
|
+
ref: need(args[1], 'task'),
|
|
1288
|
+
note: opts.note,
|
|
1289
|
+
...(opts.anyway ? { anyway: true } : {}),
|
|
1290
|
+
...forceFields(opts.force, opts.as ?? setting('AGENT')),
|
|
1291
|
+
},
|
|
1292
|
+
{ raw: true },
|
|
1293
|
+
);
|
|
1294
|
+
if (!answer.ok) {
|
|
1295
|
+
if (opts.json) console.log(JSON.stringify({ status: answer.status, ...answer.data }, null, 2));
|
|
1296
|
+
// A footprint's overlap is a warning (IDEA-55 section 3): the owner decides, knowing.
|
|
1297
|
+
fail(
|
|
1298
|
+
answer.data?.overlap
|
|
1299
|
+
? `${answer.data.error}. Start it anyway: npx breakaway agents start ${args[1]} --anyway`
|
|
1300
|
+
: (answer.data?.error ?? `HTTP ${answer.status}`),
|
|
1301
|
+
);
|
|
1302
|
+
}
|
|
1303
|
+
const { task, run } = answer.data;
|
|
1139
1304
|
print({ task, run }, () => `Started an agent on ${task.wid ?? task.short}: ${run.url}`);
|
|
1140
1305
|
return;
|
|
1141
1306
|
}
|
|
@@ -1579,8 +1744,11 @@ const commands = {
|
|
|
1579
1744
|
async chase() {
|
|
1580
1745
|
const built = chaseRequest(args[0], args[1], {
|
|
1581
1746
|
parallel: opts.parallel,
|
|
1747
|
+
reviewCap: opts['review-cap'],
|
|
1582
1748
|
dryRun: Boolean(opts['dry-run']),
|
|
1583
1749
|
by: opts.as ?? setting('AGENT'),
|
|
1750
|
+
captain: opts['no-captain'] ? false : opts.captain ? true : undefined,
|
|
1751
|
+
watch: opts.watch,
|
|
1584
1752
|
});
|
|
1585
1753
|
if (built.error) fail(built.error);
|
|
1586
1754
|
const answer = await call(...built.request);
|
|
@@ -1591,6 +1759,37 @@ const commands = {
|
|
|
1591
1759
|
}),
|
|
1592
1760
|
);
|
|
1593
1761
|
},
|
|
1762
|
+
/** The chase's road captain and its log; the captain writes its log, and hands over, with `log` (BRK-275). */
|
|
1763
|
+
async captain() {
|
|
1764
|
+
let text = null;
|
|
1765
|
+
if (opts.file !== undefined) {
|
|
1766
|
+
try {
|
|
1767
|
+
text = readFileSync(opts.file, 'utf8');
|
|
1768
|
+
} catch (error) {
|
|
1769
|
+
fail(`can't read ${opts.file}: ${reasonOf(error)}`);
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
const built = captainRequest(args[0], args[1], {
|
|
1773
|
+
text,
|
|
1774
|
+
handover: Boolean(opts.handover),
|
|
1775
|
+
by: opts.as ?? setting('AGENT'),
|
|
1776
|
+
});
|
|
1777
|
+
if (built.error) fail(built.error);
|
|
1778
|
+
const answer = await call(...built.request);
|
|
1779
|
+
if (args[1] === undefined)
|
|
1780
|
+
return print(answer.feature?.chase?.captain ?? null, (c) =>
|
|
1781
|
+
[`${answer.feature.title} (${answer.feature.slug})`, ...captainLines(c, { all: true })].join('\n'),
|
|
1782
|
+
);
|
|
1783
|
+
if (args[1] === 'digest')
|
|
1784
|
+
return print(answer, (d) => `Kept for the owner’s next digest, due ${d.digest.nextAt.slice(11, 16)} UTC.`);
|
|
1785
|
+
print(answer, (d) =>
|
|
1786
|
+
d.successor
|
|
1787
|
+
? `Logged and handed over: ${d.successor.agent} starts as the road captain and reads your log first. Stop here.`
|
|
1788
|
+
: d.waiting
|
|
1789
|
+
? `Logged and handed over. The next captain waits to start: ${d.waiting}. Stop here.`
|
|
1790
|
+
: 'Logged.',
|
|
1791
|
+
);
|
|
1792
|
+
},
|
|
1594
1793
|
async next() {
|
|
1595
1794
|
const body = { agent: agent(), claim: Boolean(opts.claim), project: opts.project, horizon: opts.horizon };
|
|
1596
1795
|
const repo = await scopedRepo();
|
|
@@ -1617,15 +1816,24 @@ const commands = {
|
|
|
1617
1816
|
});
|
|
1618
1817
|
markSession(task);
|
|
1619
1818
|
await startSessionLog(task);
|
|
1620
|
-
|
|
1819
|
+
// The owner's words go to every agent that claims the task, before the description (BRK-284).
|
|
1820
|
+
print(task, (t) =>
|
|
1821
|
+
[
|
|
1822
|
+
`Claimed ${ref(t)} as ${t.claim}: ${t.description}`,
|
|
1823
|
+
...(t.ownerSaid?.length ? ['', ...ownerSaidLines(t)] : []),
|
|
1824
|
+
].join('\n'),
|
|
1825
|
+
);
|
|
1621
1826
|
},
|
|
1622
1827
|
async release() {
|
|
1623
|
-
const
|
|
1828
|
+
const released = await call('POST', `tasks/${enc(need(args[0], 'task'))}/release`, {
|
|
1624
1829
|
agent: agent(),
|
|
1625
1830
|
force: Boolean(opts.force),
|
|
1626
1831
|
});
|
|
1832
|
+
const { task, open = [] } = released;
|
|
1627
1833
|
unmarkSession(task);
|
|
1628
|
-
print(task, (t) =>
|
|
1834
|
+
print({ ...task, open }, (t) =>
|
|
1835
|
+
[`Released ${ref(t)}.`, openLines(open, ref(t), { released: true })].filter(Boolean).join('\n\n'),
|
|
1836
|
+
);
|
|
1629
1837
|
},
|
|
1630
1838
|
async review() {
|
|
1631
1839
|
const built = reviewRequest(args[0], opts.verdict, args.slice(1).join(' '), { by: agent(), pr: opts.pr });
|
|
@@ -1633,6 +1841,48 @@ const commands = {
|
|
|
1633
1841
|
const { review, task } = await call(...built.request);
|
|
1634
1842
|
print({ review, task }, (r) => `Left your review of #${r.review.pr} on ${ref(r.task)}: ${r.review.label}.`);
|
|
1635
1843
|
},
|
|
1844
|
+
async 'risk-review'() {
|
|
1845
|
+
let text = null;
|
|
1846
|
+
if (typeof opts.file === 'string') {
|
|
1847
|
+
try {
|
|
1848
|
+
text = readFileSync(opts.file, 'utf8');
|
|
1849
|
+
} catch (error) {
|
|
1850
|
+
fail(`couldn’t read ${opts.file}: ${error.message}`);
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
const built = riskReviewRequest(args[0], text, { by: agent(), pr: opts.pr });
|
|
1854
|
+
if (built.error || !built.request) fail(built.error ?? 'bad request');
|
|
1855
|
+
const out = await call(...built.request);
|
|
1856
|
+
if (text === null)
|
|
1857
|
+
return print(out, (r) =>
|
|
1858
|
+
r.reviews.length
|
|
1859
|
+
? r.reviews
|
|
1860
|
+
.map(
|
|
1861
|
+
(v) =>
|
|
1862
|
+
`#${v.pr} ${v.state}${v.hold ? ` (holds Merge when green: ${v.hold})` : ''}${v.summary ? `\n ${v.summary}` : ''}${v.findings
|
|
1863
|
+
.map((f) => {
|
|
1864
|
+
const a = v.answers.find((x) => x.finding === f.n);
|
|
1865
|
+
return `\n ${f.n}. ${f.severity}${f.path ? ` ${f.path}${f.line ? `:${f.line}` : ''}` : ''}: ${f.text}${a ? `\n answered by ${a.by}: ${a.text}` : ''}`;
|
|
1866
|
+
})
|
|
1867
|
+
.join('')}`,
|
|
1868
|
+
)
|
|
1869
|
+
.join('\n')
|
|
1870
|
+
: 'No risky-path review on its pull requests.',
|
|
1871
|
+
);
|
|
1872
|
+
print(out, (r) =>
|
|
1873
|
+
r.review.hold
|
|
1874
|
+
? `Posted your review of ${r.review.task}'s pull request: ${r.review.hold}.`
|
|
1875
|
+
: `Posted your review of ${r.review.task}'s pull request: nothing holds it.`,
|
|
1876
|
+
);
|
|
1877
|
+
},
|
|
1878
|
+
async 'risk-answer'() {
|
|
1879
|
+
const built = riskAnswerRequest(args[0], args[1], args.slice(2).join(' '), { by: agent(), pr: opts.pr });
|
|
1880
|
+
if (built.error || !built.request) fail(built.error ?? 'bad request');
|
|
1881
|
+
const out = await call(...built.request);
|
|
1882
|
+
print(out, (r) =>
|
|
1883
|
+
r.review.hold ? `Answered. Still held: ${r.review.hold}.` : 'Answered. Nothing holds Merge when green now.',
|
|
1884
|
+
);
|
|
1885
|
+
},
|
|
1636
1886
|
// 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.
|
|
1637
1887
|
async comment() {
|
|
1638
1888
|
const text = args.slice(1).join(' ');
|
|
@@ -1640,6 +1890,20 @@ const commands = {
|
|
|
1640
1890
|
const { task } = await call('POST', `tasks/${enc(need(args[0], 'task'))}/annotate`, { text, by: agent() });
|
|
1641
1891
|
print(task, (t) => `Commented on ${ref(t)}.`);
|
|
1642
1892
|
},
|
|
1893
|
+
/** Quotes the owner's words on a task you hold (BRK-284), with where they came from. */
|
|
1894
|
+
async quote() {
|
|
1895
|
+
const text = args.slice(1).join(' ').trim();
|
|
1896
|
+
if (!text || !opts.from)
|
|
1897
|
+
fail(
|
|
1898
|
+
`say what the owner said and where: npx breakaway quote <task> "<their words>" --from message|peloton|ping|decision|comment[ <pointer>]`,
|
|
1899
|
+
);
|
|
1900
|
+
const { task } = await call('POST', `tasks/${enc(need(args[0], 'task'))}/said`, {
|
|
1901
|
+
text,
|
|
1902
|
+
from: opts.from,
|
|
1903
|
+
by: agent(),
|
|
1904
|
+
});
|
|
1905
|
+
print(task, (t) => `Quoted the owner on ${ref(t)}: every agent that picks it up reads it first.`);
|
|
1906
|
+
},
|
|
1643
1907
|
async done() {
|
|
1644
1908
|
const id = need(args[0], 'task');
|
|
1645
1909
|
if (opts.pr) await call('PATCH', `tasks/${enc(id)}`, { pr: opts.pr });
|
|
@@ -1660,7 +1924,24 @@ const commands = {
|
|
|
1660
1924
|
delete body.addDepends;
|
|
1661
1925
|
delete body.addRelated;
|
|
1662
1926
|
delete body.removeRelated;
|
|
1663
|
-
|
|
1927
|
+
if (opts.force) body.force = true;
|
|
1928
|
+
const res = await call('POST', 'tasks', body, { raw: true });
|
|
1929
|
+
// An open task it resembles comes back as a list (BRK-283): say which, and the two ways on.
|
|
1930
|
+
if (res.status === 409 && Array.isArray(res.data.similar)) {
|
|
1931
|
+
if (opts.json) console.log(JSON.stringify({ status: 409, ...res.data }, null, 2));
|
|
1932
|
+
const lines = res.data.similar.map(
|
|
1933
|
+
(t) => ` ${t.wid ?? t.short} ${t.description}${t.claim ? ` (claimed by ${t.claim})` : ''}`,
|
|
1934
|
+
);
|
|
1935
|
+
const ids = res.data.similar.map((t) => t.wid ?? t.short).join(',');
|
|
1936
|
+
fail(
|
|
1937
|
+
`not added: it resembles open tasks\n${lines.join('\n')}\nIf it's one of them, comment there instead. Otherwise add it again with --related ${ids} to link them, or --force if they're unrelated.`,
|
|
1938
|
+
);
|
|
1939
|
+
}
|
|
1940
|
+
if (!res.ok) {
|
|
1941
|
+
if (opts.json) console.log(JSON.stringify({ status: res.status, ...res.data }, null, 2));
|
|
1942
|
+
fail(res.data.error ?? `HTTP ${res.status}`);
|
|
1943
|
+
}
|
|
1944
|
+
const { tasks } = res.data;
|
|
1664
1945
|
print(tasks[0], (t) => `Added ${ref(t)}: ${t.description}`);
|
|
1665
1946
|
},
|
|
1666
1947
|
async decision() {
|
|
@@ -1706,6 +1987,8 @@ const commands = {
|
|
|
1706
1987
|
}
|
|
1707
1988
|
if (args[0] === 'listen') return pelotonListen(me);
|
|
1708
1989
|
if (args[0] === 'plan') return pelotonPlan(me);
|
|
1990
|
+
if (args[0] === 'open') return pelotonOpen(me);
|
|
1991
|
+
if (args[0] === 'handover') return pelotonHandover(me);
|
|
1709
1992
|
const post = pelotonPost(args[0], args.slice(1));
|
|
1710
1993
|
if ('error' in post) return fail(post.error);
|
|
1711
1994
|
// Read first: it says which peloton the post goes to, and which posts were new before it.
|
|
@@ -1713,6 +1996,7 @@ const commands = {
|
|
|
1713
1996
|
const to = pickPeloton(views, { ...post, chosen: opts.peloton, agent: me });
|
|
1714
1997
|
if ('error' in to) return fail(to.error);
|
|
1715
1998
|
const posted = [];
|
|
1999
|
+
let claims = null;
|
|
1716
2000
|
let after = views;
|
|
1717
2001
|
for (const peloton of to.pelotons) {
|
|
1718
2002
|
const out = await call('POST', `peloton/${enc(peloton)}`, {
|
|
@@ -1720,13 +2004,20 @@ const commands = {
|
|
|
1720
2004
|
kind: post.kind,
|
|
1721
2005
|
text: post.text,
|
|
1722
2006
|
...(post.kind === 'reply' ? { reply_to: post.replyTo } : {}),
|
|
2007
|
+
// A check-in claims --files, or the paths its text names (IDEA-55 section 1a).
|
|
2008
|
+
...(post.kind === 'checkin' && opts.files?.length ? { files: opts.files } : {}),
|
|
1723
2009
|
});
|
|
1724
2010
|
posted.push(out.post);
|
|
2011
|
+
if (out.paths) claims = out.paths;
|
|
1725
2012
|
after = mergeViews(after, out.peloton);
|
|
1726
2013
|
}
|
|
1727
2014
|
const where = posted.map((p) => `#${p.id} on ${p.peloton}`).join(' and ');
|
|
1728
|
-
print({ post: posted[0], posts: posted, pelotons: after }, () =>
|
|
1729
|
-
[
|
|
2015
|
+
print({ post: posted[0], posts: posted, pelotons: after, ...(claims ? { paths: claims } : {}) }, () =>
|
|
2016
|
+
[
|
|
2017
|
+
`Posted ${where}.`,
|
|
2018
|
+
...(claims ? [claimLines(claims).join('\n')] : []),
|
|
2019
|
+
pelotonLines(after, { agent: me, all: opts.all }),
|
|
2020
|
+
].join('\n\n'),
|
|
1730
2021
|
);
|
|
1731
2022
|
},
|
|
1732
2023
|
async idea() {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A task's footprint in words (docs/specs/IDEA-55-footprints.md, section 5), the same for `tasks show`, `tasks paths`,
|
|
3
|
+
* and the MCP server's footprint read: each pattern marked predicted, claimed (with when it runs out), changed, or from
|
|
4
|
+
* the pull request, and the shared files left out. Pure, so the CLI ships it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** @param {string} iso @param {number} now */
|
|
8
|
+
function runsOut(iso, now) {
|
|
9
|
+
const minutes = Math.max(0, Math.round((Date.parse(iso) - now) / 60_000));
|
|
10
|
+
return minutes < 1 ? 'runs out now' : `runs out in ${minutes} min`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** @param {any} p one path of a footprint @param {number} now */
|
|
14
|
+
function pathNote(p, now) {
|
|
15
|
+
if (p.state === 'claimed')
|
|
16
|
+
return `claimed by ${p.agent}${p.source === 'claim' ? '' : ` (${p.source})`}, ${runsOut(p.until, now)}`;
|
|
17
|
+
if (p.state === 'dirty') return 'changed, not claimed';
|
|
18
|
+
if (p.state === 'pull') return p.holds === false ? 'pull request, quiet over a day: holds nothing' : 'pull request';
|
|
19
|
+
return `predicted (${p.source})`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const HEADS = {
|
|
23
|
+
unknown: 'unknown: nothing names a path yet, so it’s scheduled by its area',
|
|
24
|
+
predicted: 'predicted',
|
|
25
|
+
claimed: 'claimed',
|
|
26
|
+
actual: 'actual',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The Footprint section's lines, indented like `tasks show`'s other sections.
|
|
31
|
+
* @param {any} fp the footprint from GET /api/tasks/:id/footprint
|
|
32
|
+
* @param {number} [now]
|
|
33
|
+
* @returns {string[]}
|
|
34
|
+
*/
|
|
35
|
+
export function footprintLines(fp, now = Date.now()) {
|
|
36
|
+
const kind = /** @type {keyof typeof HEADS} */ (fp?.kind ?? 'unknown');
|
|
37
|
+
const out = [
|
|
38
|
+
` Footprint (${HEADS[kind] ?? kind}${kind === 'predicted' && fp.trusted === false ? ', not trusted in this repository' : ''})`,
|
|
39
|
+
];
|
|
40
|
+
const paths = fp?.paths ?? [];
|
|
41
|
+
const width = Math.min(48, Math.max(0, ...paths.map((/** @type {any} */ p) => p.pattern.length)));
|
|
42
|
+
for (const p of paths) out.push(` ${p.pattern.padEnd(width)} ${pathNote(p, now)}`);
|
|
43
|
+
if (fp?.pull) out.push(` from #${fp.pull.number}${fp.pull.partial ? ' (more files than GitHub lists)' : ''}`);
|
|
44
|
+
for (const c of fp?.conflicts ?? [])
|
|
45
|
+
out.push(` conflict: changed ${c.path}, which ${c.agent} claims on ${c.task} (${c.pattern})`);
|
|
46
|
+
if (fp?.shared?.length) out.push(` left out, shared: ${fp.shared.join(', ')}`);
|
|
47
|
+
const rate = fp?.hitRate;
|
|
48
|
+
if (rate?.count)
|
|
49
|
+
out.push(
|
|
50
|
+
` predictions here covered ${Math.round(rate.rate * 100)}% of the files the last ${rate.count} merged task${rate.count === 1 ? '' : 's'} changed`,
|
|
51
|
+
);
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What a claim answered, in words: what was granted, already held, and refused, with who holds it.
|
|
57
|
+
* @param {{ granted?: { pattern: string, until: string }[], held?: string[], refused?: { pattern: string, holder: any }[],
|
|
58
|
+
* released?: string[] }} result
|
|
59
|
+
* @param {number} [now]
|
|
60
|
+
*/
|
|
61
|
+
export function claimLines(result, now = Date.now()) {
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const g of result.granted ?? [])
|
|
64
|
+
out.push(`Claimed ${g.pattern} (${runsOut(g.until, now)} without a heartbeat).`);
|
|
65
|
+
for (const pattern of result.held ?? []) out.push(`Already yours: ${pattern}.`);
|
|
66
|
+
for (const r of result.refused ?? [])
|
|
67
|
+
out.push(
|
|
68
|
+
`Refused ${r.pattern}: ${r.holder.agent} claims ${r.holder.pattern} on ${r.holder.task}, active ${new Date(r.holder.active).toISOString().slice(11, 16)} UTC. Change other files, ask @${r.holder.agent} on the peloton, or comment why and release your task.`,
|
|
69
|
+
);
|
|
70
|
+
if (result.released)
|
|
71
|
+
out.push(result.released.length ? `Released ${result.released.join(', ')}.` : 'Nothing to release.');
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Riders and patterns a start payload names before "and n more" (section 4). */
|
|
76
|
+
const BESIDE_RIDERS = 8;
|
|
77
|
+
const BESIDE_PATTERNS = 6;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The start payload's "Riding beside you" lines (section 4): one per agent already running in the repository, with
|
|
81
|
+
* what it's changing. None when nobody with a known footprint is.
|
|
82
|
+
* @param {{ task: string, agent: string, patterns: string[] }[] | null | undefined} riders
|
|
83
|
+
* @returns {string[]}
|
|
84
|
+
*/
|
|
85
|
+
export function besideLines(riders) {
|
|
86
|
+
const list = riders ?? [];
|
|
87
|
+
const lines = list.slice(0, BESIDE_RIDERS).map((r) => {
|
|
88
|
+
const more = r.patterns.length - BESIDE_PATTERNS;
|
|
89
|
+
return `Riding beside you: ${r.task} (${r.agent}) is changing ${r.patterns.slice(0, BESIDE_PATTERNS).join(', ')}${more > 0 ? ` and ${more} more` : ''}`;
|
|
90
|
+
});
|
|
91
|
+
if (list.length > BESIDE_RIDERS) lines.push(`Riding beside you: ${list.length - BESIDE_RIDERS} more agents`);
|
|
92
|
+
return lines;
|
|
93
|
+
}
|