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/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 the Claude Code session hooks a repository's .claude/settings.json runs (npx breakaway hook session)
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 task you listen on, when you hold more than one
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 (FLAGS.has(name) || (INIT_FLAGS.has(name) && positional[0] === 'repos' && positional[1] === 'init')) {
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 out = [`${ref(t)} · ${t.description}`, ''];
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) => res.status === 404 && /^no route for|no peloton "listen"/u.test(String(res.data?.error ?? ''));
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
- await import(args[0] === 'wait' ? './tasks/message-wait.mjs' : './tasks/session-hook.mjs');
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 { task } = await call('GET', `tasks/${enc(need(args[0], 'task'))}`);
1038
- print(task, detail);
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 { task, run } = await call('POST', 'agents/start', {
1135
- ref: need(args[1], 'task'),
1136
- note: opts.note,
1137
- ...forceFields(opts.force, opts.as ?? setting('AGENT')),
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
- print(task, (t) => `Claimed ${ref(t)} as ${t.claim}: ${t.description}`);
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 { task } = await call('POST', `tasks/${enc(need(args[0], 'task'))}/release`, {
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) => `Released ${ref(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
- const { tasks } = await call('POST', 'tasks', body);
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
- [`Posted ${where}.`, pelotonLines(after, { agent: me, all: opts.all })].join('\n\n'),
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
+ }