breakaway 2.0.2-main.1 → 2.0.2-main.3

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
@@ -42,6 +42,7 @@ import {
42
42
  import { looksLikeSecret } from '../src/ping.js';
43
43
  import { sessionReport, shortHash, stubText } from '../src/session-report.js';
44
44
  import { promptPathOf } from '../src/repos.js';
45
+ import { claimLines, footprintLines } from '../src/footprint-text.js';
45
46
  import { hookFailure, sessionProxy, routeThroughSessionProxy } from './tasks/proxy.js';
46
47
  import { githubFromRemote, inRepo, pickRepo } from './tasks/repo.js';
47
48
  import { appInPlace } from './tasks/github-connect.js';
@@ -87,6 +88,7 @@ import {
87
88
  staleCliWarning,
88
89
  releaseBehind,
89
90
  removedRepoByHand,
91
+ needsOwner,
90
92
  unknownSubcommand,
91
93
  } from './tasks/cli.js';
92
94
  import { InfraReadError, infraRead } from './tasks/infra-read.js';
@@ -111,6 +113,8 @@ import { cwdOf, parentOf, sessionDir } from './tasks/session-dir.js';
111
113
  import { parseInstall, secretName } from '../src/install.js';
112
114
  import {
113
115
  NAMES,
116
+ RUN_KEY,
117
+ authHeaders,
114
118
  boardUrl,
115
119
  configDir,
116
120
  parseEnvFile,
@@ -240,10 +244,11 @@ const HELP = `npx breakaway <command> [options]
240
244
 
241
245
  Reading (list, next, claim, and add work in this checkout's repository; see Repositories below)
242
246
  list open tasks, best first (the default command)
243
- --ready --blocked --active --mine --owner narrow it down
247
+ --ready --blocked --active --mine narrow it down
248
+ --who agent|person|decision --assignee <handle|owner> who does it (--owner: decisions, and people's tasks for the owner or anyone)
244
249
  --project <p> --tag <t> --horizon <h> --status pending|completed|deleted|all
245
250
  show <ref> one task: description, done when, related, comments, dependencies, and what it blocks
246
- next the best ready task for an agent (+agent, not +decide, unclaimed)
251
+ next the best ready task for an agent (who: agent, unclaimed)
247
252
  --claim …and claim it in the same step
248
253
  --project <p> --horizon <h> --tag <t>
249
254
  activity recent changes, newest first [--limit <n>]
@@ -253,7 +258,7 @@ Reading (list, next, claim, and add work in this checkout's repos
253
258
  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)
254
259
  agents new --spec <path> "<what should change>" start an agent that changes a spec as you ask and brings the tasks that
255
260
  link it in line; the board writes its prompt [--repo <slug>] [--force] (owner)
256
- agents start <ref> start a Claude cloud agent on a task [--note <text>] [--force]
261
+ agents start <ref> start a Claude cloud agent on a task; warns when a running agent changes its files [--note <text>] [--force] [--anyway]
257
262
  agents refine <ref> start an agent that improves a task, not builds it --note <what to look at or change> [--force]
258
263
  agents plan [<plan>] your Claude plan and what it allows; pro, max5, or max20 picks one (owner) and sets the limits to its defaults
259
264
  agents next start the next few ready tasks, one per area [--count <n>] [--dry-run] [--repo <slug>]
@@ -288,7 +293,7 @@ Reading (list, next, claim, and add work in this checkout's repos
288
293
  specs the repository's specs, newest first: each one's status and its tasks [--repo <slug>]
289
294
  specs show <path> one spec: its status, last change, Markdown, and the tasks that link it [--repo <slug>]
290
295
  github the checkout's repository on GitHub: open pull requests, checks, reviews, CI, deploys, alerts [--sync] [--repo <slug>]
291
- hook session|wait the Claude Code session hooks a repository's .claude/settings.json runs (npx breakaway hook session)
296
+ hook session|wait|edit the Claude Code session hooks a repository's .claude/settings.json runs (npx breakaway hook session)
292
297
  health the server's state
293
298
  mcp print the claude mcp add line and the .mcp.json entry that connect an MCP client to the board's
294
299
  /mcp from this checkout, with the token as $BREAKAWAY_TOKEN, never its value. Writes nothing
@@ -303,13 +308,17 @@ Reading (list, next, claim, and add work in this checkout's repos
303
308
  Working
304
309
  claim <ref> take a task (atomic: fails if someone else has it) [--force]
305
310
  refuses another repository's task unless --repo names it
311
+ run-key <key> a run on a routine the owner lends: save the Run key from its payload in tasks.env, so this
312
+ CLI, its hooks, and mcp --headers send it in its own header (BRK-324). It's the run's only
313
+ credential, with its person's rights, until its claim ends [--forget] drops it
306
314
  release <ref> give it back [--force]
307
315
  comment <ref> <text> add a comment (signed with your agent name); note is the same command
308
316
  quote <ref> <words> --from <source> put the owner's exact words on a task you hold, quoted and marked as yours:
309
317
  shown first on the task and to every agent that claims it. <source> is where they said it:
310
318
  message, peloton, ping, decision, or comment, with a pointer if you have one ("peloton #12")
311
319
  review <ref> --verdict ready|follow-up|changes <note> your review of the pull request that closes the task you
312
- hold: a comment on it, and the review on the pull request's page (the note is Markdown) [--pr <n>]
320
+ hold, or, as a chase's road captain, a task of your chase: a comment on it, and the review on
321
+ the pull request's page (the note is Markdown) [--pr <n>]
313
322
  risk-review <ref> --file <findings.json> a risky-path reviewer's findings on the pull request the board started it
314
323
  for: { "summary", "findings": [{ "severity": "blocking"|"note", "text", "path", "line" }] }
315
324
  [--pr <n>]; without --file, the task's reviews and their answers
@@ -318,11 +327,13 @@ Working
318
327
  done <ref> finish it [--note <text>] [--pr <url>]
319
328
  add <description> new task; gets the next work ID for its project
320
329
  --project <p> --tag <t>… --priority H|M|L --horizon now|next|later
330
+ --who agent|person|decision who does it: an agent builds it, a person does it, or the owner decides first
331
+ --assignee <handle|owner> on a person's task, who it's for (without one, any member of the repository)
321
332
  --repo <slug> the repository it belongs to (default: the checkout's; its areas decide the prefix)
322
333
  --depends <ref,…> --related <ref,…> --spec <path> --due <date> --wait <date>
323
334
  --brief <text> | --brief-file <path> the description (--note is the same)
324
335
  --done-when <text> what has to be true to call it done
325
- --decision <file.json> questions for the owner to answer on the board (adds +decide); see decision --template
336
+ --decision <file.json> questions for the owner to answer on the board (makes it a decision); see decision --template
326
337
  --force add it even though it resembles open tasks the board named (--related links them instead)
327
338
  decision <ref> --template print an example decision file to edit (nothing here answers a decision: the owner does, on the board)
328
339
  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)
@@ -332,7 +343,15 @@ Working
332
343
  peloton who else is working (in your repository, and your chase's), the chase's plan and open
333
344
  huddle, and what they posted since you last read: new posts are starred [--all] every post
334
345
  peloton checkin <text> say you're here and what you'll change, the files or areas you'll touch, before your first
335
- change (you must hold a task); posts on your repository's peloton and your chase's too
346
+ change (you must hold a task); posts on your repository's peloton and your chase's too,
347
+ and claims the paths it names [--files <pattern,…>] claims these instead
348
+ paths <ref> what a task touches: predicted, claimed (and when each claim runs out), changed, or in its
349
+ pull request, and the shared files left out
350
+ paths <ref> --claim <pattern>… claim a file, a folder/, or a glob (src/**, test/*.test.js) for the task you
351
+ hold before you change it; one another task claims is refused, with who holds it. Claims
352
+ run out 10 minutes after your session goes quiet, and after 4 hours
353
+ paths <ref> --release [<pattern>…] give your claims back (all of them without patterns); the owner, with no
354
+ agent name, releases anyone's
336
355
  peloton step|note|ask|propose|review <text> say what you did, talk, ask, propose a change to the plan or the
337
356
  tasks, or ask for a look at your approach; posts on your chase's peloton if your task is in
338
357
  one, else your repository's [--peloton <name>] picks one
@@ -364,6 +383,7 @@ Working
364
383
  --done-when <text> change what has to be true to call it done
365
384
  --decision <file.json> set the decision's questions (answers to questions that still exist are kept)
366
385
  --related <ref> --unrelated <ref> add or remove a "see also" link (repeatable; it doesn't block)
386
+ --who agent|person|decision --assignee <handle|owner> who does it (--assignee "" removes it)
367
387
  --tag <t> --untag <t> --depends <ref> --undepends <ref> (repeatable)
368
388
  --autostart yes|no start a cloud agent by itself when the task is ready
369
389
 
@@ -530,6 +550,7 @@ const REPEATABLE = new Set([
530
550
  'unrelated',
531
551
  'area',
532
552
  'remove-area',
553
+ 'files',
533
554
  ]);
534
555
  const FLAGS = new Set([
535
556
  'json',
@@ -556,9 +577,13 @@ const FLAGS = new Set([
556
577
  'captain',
557
578
  'no-captain',
558
579
  'handover',
580
+ 'anyway',
581
+ 'forget',
559
582
  ]);
560
583
  /** Flags only in repos init (BRK-91): --pipeline takes a file in repos modify, and is a flag there. */
561
584
  const INIT_FLAGS = new Set(['pipeline', 'copies']);
585
+ /** Flags only in paths (IDEA-55): the patterns follow the task, and --release takes a version elsewhere. */
586
+ const PATHS_FLAGS = new Set(['claim', 'release']);
562
587
 
563
588
  function parse(argv) {
564
589
  const positional = [];
@@ -571,7 +596,11 @@ function parse(argv) {
571
596
  continue;
572
597
  }
573
598
  const [name, inline] = arg.slice(2).split(/=(.*)/su);
574
- if (FLAGS.has(name) || (INIT_FLAGS.has(name) && positional[0] === 'repos' && positional[1] === 'init')) {
599
+ if (
600
+ FLAGS.has(name) ||
601
+ (INIT_FLAGS.has(name) && positional[0] === 'repos' && positional[1] === 'init') ||
602
+ (PATHS_FLAGS.has(name) && positional[0] === 'paths')
603
+ ) {
575
604
  opts[name] = true;
576
605
  continue;
577
606
  }
@@ -597,14 +626,15 @@ function fail(message) {
597
626
  */
598
627
  async function call(method, path, body, { soft = false, raw = false } = {}) {
599
628
  // Without a token the request still goes out: in a Claude cloud environment with an API
600
- // credential for this host, the agent proxy adds the Authorization header on the way.
629
+ // credential for this host, the agent proxy adds the Authorization header on the way. A lent run's key goes in its
630
+ // own header, which the proxy leaves alone (BRK-324).
601
631
  const token = setting('TOKEN');
602
632
  let res;
603
633
  try {
604
634
  res = await fetch(`${BASE}/api/${path}`, {
605
635
  method,
606
636
  headers: {
607
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
637
+ ...authHeaders(token, setting('RUN_KEY')),
608
638
  ...(body ? { 'Content-Type': 'application/json' } : {}),
609
639
  },
610
640
  body: body ? JSON.stringify(body) : undefined,
@@ -616,7 +646,7 @@ async function call(method, path, body, { soft = false, raw = false } = {}) {
616
646
  );
617
647
  }
618
648
  warnIfStale(res.headers.get('X-Tasks-Cli'), res.headers.get('X-Tasks-Release'));
619
- if (res.status === 401 && !token)
649
+ if (res.status === 401 && !token && !setting('RUN_KEY'))
620
650
  fail(
621
651
  `no token. Set BREAKAWAY_TOKEN, put it in ${ENV_FILE}, set it in the breakaway plugin's settings, or add it as an API credential in the cloud environment (see docs/tasks.md#cloud-agents).`,
622
652
  );
@@ -688,7 +718,7 @@ async function upload(taskRef, file, alt = '') {
688
718
  res = await fetch(`${BASE}/api/tasks/${enc(taskRef)}/attachments`, {
689
719
  method: 'POST',
690
720
  headers: {
691
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
721
+ ...authHeaders(token, setting('RUN_KEY')),
692
722
  'Content-Type': 'application/octet-stream',
693
723
  'X-Attachment-Name': enc(basename(file)),
694
724
  'X-Attachment-Alt': enc(alt),
@@ -782,7 +812,7 @@ async function startSessionLog(t) {
782
812
  try {
783
813
  const res = await fetch(`${BASE}/api/tasks/${enc(t.uuid)}/session`, {
784
814
  method: 'POST',
785
- headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
815
+ headers: { 'Content-Type': 'application/json', ...authHeaders(token, setting('RUN_KEY')) },
786
816
  body: JSON.stringify({
787
817
  agent: t.claim,
788
818
  remote: process.env.CLAUDE_CODE_REMOTE === 'true',
@@ -844,14 +874,25 @@ function line(t) {
844
874
  t.description,
845
875
  ];
846
876
  const extra = [];
877
+ if (t.who) extra.push(t.who === 'person' ? `person: ${t.assignee ?? 'anyone'}` : t.who);
847
878
  if (t.tags.length) extra.push(t.tags.map((x) => `+${x}`).join(' '));
848
879
  if (t.claim) extra.push(`claimed by ${t.claim} ${age(t.start)}`);
849
- if (t.blocked) extra.push(`blocked by ${t.blockedBy.length}`);
880
+ // A person's answer leaves out a blocker in a repository they can't read, but keeps the block (BRK-338).
881
+ if (t.blocked) extra.push(`blocked by ${t.blockedBy.length || 'a task you can’t see'}`);
850
882
  if (t.waiting) extra.push(`waiting until ${t.wait.slice(0, 10)}`);
851
883
  if (t.status !== 'pending') extra.push(`${t.status}${t.end ? ` ${t.end.slice(0, 10)}` : ''}`);
852
884
  return `${bits.join(' ')}${extra.length ? ` (${extra.join('; ')})` : ''}`;
853
885
  }
854
886
 
887
+ /** Who does a task (BRK-330), in words: an agent, a person (its assignee, or any member), or a decision. */
888
+ function whoWords(t) {
889
+ if (t.who === 'person')
890
+ return `a person: ${!t.assignee ? 'any member' : t.assignee === 'owner' ? 'the owner' : t.assignee}`;
891
+ if (t.who === 'agent') return 'an agent';
892
+ if (t.who === 'decision') return 'a decision first';
893
+ return 'nobody yet';
894
+ }
895
+
855
896
  function detail(t) {
856
897
  const said = ownerSaidLines(t);
857
898
  const out = [`${ref(t)} · ${t.description}`, '', ...(said.length ? [...said, ''] : [])];
@@ -864,6 +905,7 @@ function detail(t) {
864
905
  row('Project', t.project);
865
906
  row('Horizon', t.horizon);
866
907
  row('Priority', t.priority);
908
+ row('Who', whoWords(t));
867
909
  row('Tags', t.tags.map((x) => `+${x}`).join(' '));
868
910
  row('Claimed by', t.claim && `${t.claim} (${age(t.start)} ago)`);
869
911
  row('Spec', t.spec);
@@ -1021,6 +1063,8 @@ function changesFrom(o) {
1021
1063
  for (const key of ['description', 'project', 'priority', 'horizon', 'spec', 'pr', 'due', 'wait', 'status'])
1022
1064
  if (key in o) c[key] = o[key];
1023
1065
  if (c.priority) c.priority = c.priority.toUpperCase();
1066
+ if (o.who !== undefined) c.who = String(o.who).toLowerCase();
1067
+ if (o.assignee !== undefined) c.assignee = o.assignee === true ? null : String(o.assignee).toLowerCase() || null;
1024
1068
  if (o.tag) c.addTags = o.tag;
1025
1069
  if (o.untag) c.removeTags = o.untag;
1026
1070
  Object.assign(
@@ -1070,7 +1114,8 @@ function describeSources({ url, token, agent: name }) {
1070
1114
  const commands = {
1071
1115
  /** The Claude Code session hooks (BRK-7), so a repository's .claude/settings.json runs them through npx. */
1072
1116
  async hook() {
1073
- await import(args[0] === 'wait' ? './tasks/message-wait.mjs' : './tasks/session-hook.mjs');
1117
+ const file = { wait: 'message-wait', edit: 'edit-hook' }[args[0]] ?? 'session-hook';
1118
+ await import(`./tasks/${file}.mjs`);
1074
1119
  },
1075
1120
  async list() {
1076
1121
  const { tasks } = await call('GET', `tasks?status=${enc(opts.status ?? 'pending')}`);
@@ -1083,7 +1128,9 @@ const commands = {
1083
1128
  (!opts.blocked || t.blocked) &&
1084
1129
  (!opts.active || t.claim || t.active) &&
1085
1130
  (!opts.mine || t.claim === me) &&
1086
- (!opts.owner || t.tags.includes('owner') || t.tags.includes('decide')) &&
1131
+ (!opts.owner || needsOwner(t)) &&
1132
+ (!opts.who || t.who === String(opts.who).toLowerCase()) &&
1133
+ (!opts.assignee || t.assignee === String(opts.assignee).toLowerCase()) &&
1087
1134
  (!opts.project || t.project === opts.project) &&
1088
1135
  // The archive is hidden from open work only: --status completed or all lists it too (CLD-193).
1089
1136
  (opts.horizon
@@ -1103,8 +1150,46 @@ const commands = {
1103
1150
  );
1104
1151
  },
1105
1152
  async show() {
1106
- const { task } = await call('GET', `tasks/${enc(need(args[0], 'task'))}`);
1107
- print(task, detail);
1153
+ const ref = enc(need(args[0], 'task'));
1154
+ const { task } = await call('GET', `tasks/${ref}`);
1155
+ // Its footprint (IDEA-55 section 5), where the board has them; an older board just leaves the section out.
1156
+ const fp = opts.json ? null : await call('GET', `tasks/${ref}/footprint`, undefined, { soft: true });
1157
+ print(task, (t) => [detail(t), ...(fp?.footprint ? ['', ...footprintLines(fp.footprint)] : [])].join('\n'));
1158
+ },
1159
+ /**
1160
+ * A task's footprint and its path claims (IDEA-55 section 1a): with no flag, what it touches; --claim <patterns> for
1161
+ * the task you hold (a refused one exits non-zero, saying who holds it); --release [<patterns>] gives yours back, or
1162
+ * any as the owner (no agent name), and every one without patterns.
1163
+ */
1164
+ async paths() {
1165
+ const ref = enc(need(args[0], 'task'));
1166
+ const patterns = args
1167
+ .slice(1)
1168
+ .flatMap((p) => p.split(','))
1169
+ .map((p) => p.trim())
1170
+ .filter(Boolean);
1171
+ if (opts.claim && opts.release) fail('claim or release, not both at once');
1172
+ if (!opts.claim && !opts.release) {
1173
+ const { footprint } = await call('GET', `tasks/${ref}/footprint`);
1174
+ return print(footprint, (fp) => footprintLines(fp).join('\n'));
1175
+ }
1176
+ if (opts.claim && !patterns.length)
1177
+ fail('say which paths: npx breakaway paths <task> --claim <file, folder/, or glob> …');
1178
+ const name = opts.as ?? setting('AGENT', null);
1179
+ const res = await call(
1180
+ 'POST',
1181
+ `tasks/${ref}/paths`,
1182
+ opts.claim
1183
+ ? { agent: agent(), claim: patterns }
1184
+ : { ...(name ? { agent: name } : {}), release: patterns.length ? patterns : true },
1185
+ { raw: true },
1186
+ );
1187
+ if (!res.ok && res.status !== 409) {
1188
+ if (opts.json) console.log(JSON.stringify({ status: res.status, ...res.data }, null, 2));
1189
+ fail(res.data.error ?? `HTTP ${res.status}`);
1190
+ }
1191
+ print(res.data, (d) => claimLines(d).join('\n'));
1192
+ if (res.status === 409) process.exit(1);
1108
1193
  },
1109
1194
  /**
1110
1195
  * Every task on the board, in every repository, status, and horizon, as the board's JSON, checked against the
@@ -1224,11 +1309,27 @@ const commands = {
1224
1309
  return;
1225
1310
  }
1226
1311
  if (sub === 'start') {
1227
- const { task, run } = await call('POST', 'agents/start', {
1228
- ref: need(args[1], 'task'),
1229
- note: opts.note,
1230
- ...forceFields(opts.force, opts.as ?? setting('AGENT')),
1231
- });
1312
+ const answer = await call(
1313
+ 'POST',
1314
+ 'agents/start',
1315
+ {
1316
+ ref: need(args[1], 'task'),
1317
+ note: opts.note,
1318
+ ...(opts.anyway ? { anyway: true } : {}),
1319
+ ...forceFields(opts.force, opts.as ?? setting('AGENT')),
1320
+ },
1321
+ { raw: true },
1322
+ );
1323
+ if (!answer.ok) {
1324
+ if (opts.json) console.log(JSON.stringify({ status: answer.status, ...answer.data }, null, 2));
1325
+ // A footprint's overlap is a warning (IDEA-55 section 3): the owner decides, knowing.
1326
+ fail(
1327
+ answer.data?.overlap
1328
+ ? `${answer.data.error}. Start it anyway: npx breakaway agents start ${args[1]} --anyway`
1329
+ : (answer.data?.error ?? `HTTP ${answer.status}`),
1330
+ );
1331
+ }
1332
+ const { task, run } = answer.data;
1232
1333
  print({ task, run }, () => `Started an agent on ${task.wid ?? task.short}: ${run.url}`);
1233
1334
  return;
1234
1335
  }
@@ -1722,7 +1823,8 @@ const commands = {
1722
1823
  const body = { agent: agent(), claim: Boolean(opts.claim), project: opts.project, horizon: opts.horizon };
1723
1824
  const repo = await scopedRepo();
1724
1825
  if (repo) body.repo = repo;
1725
- if (opts.tag) body.tags = ['agent', ...opts.tag];
1826
+ body.who = 'agent';
1827
+ if (opts.tag) body.tags = opts.tag;
1726
1828
  const { task } = await call('POST', 'next', body);
1727
1829
  if (task && opts.claim) {
1728
1830
  markSession(task);
@@ -1924,6 +2026,7 @@ const commands = {
1924
2026
  const to = pickPeloton(views, { ...post, chosen: opts.peloton, agent: me });
1925
2027
  if ('error' in to) return fail(to.error);
1926
2028
  const posted = [];
2029
+ let claims = null;
1927
2030
  let after = views;
1928
2031
  for (const peloton of to.pelotons) {
1929
2032
  const out = await call('POST', `peloton/${enc(peloton)}`, {
@@ -1931,13 +2034,20 @@ const commands = {
1931
2034
  kind: post.kind,
1932
2035
  text: post.text,
1933
2036
  ...(post.kind === 'reply' ? { reply_to: post.replyTo } : {}),
2037
+ // A check-in claims --files, or the paths its text names (IDEA-55 section 1a).
2038
+ ...(post.kind === 'checkin' && opts.files?.length ? { files: opts.files } : {}),
1934
2039
  });
1935
2040
  posted.push(out.post);
2041
+ if (out.paths) claims = out.paths;
1936
2042
  after = mergeViews(after, out.peloton);
1937
2043
  }
1938
2044
  const where = posted.map((p) => `#${p.id} on ${p.peloton}`).join(' and ');
1939
- print({ post: posted[0], posts: posted, pelotons: after }, () =>
1940
- [`Posted ${where}.`, pelotonLines(after, { agent: me, all: opts.all })].join('\n\n'),
2045
+ print({ post: posted[0], posts: posted, pelotons: after, ...(claims ? { paths: claims } : {}) }, () =>
2046
+ [
2047
+ `Posted ${where}.`,
2048
+ ...(claims ? [claimLines(claims).join('\n')] : []),
2049
+ pelotonLines(after, { agent: me, all: opts.all }),
2050
+ ].join('\n\n'),
1941
2051
  );
1942
2052
  },
1943
2053
  async idea() {
@@ -1971,7 +2081,7 @@ const commands = {
1971
2081
  const token = setting('TOKEN');
1972
2082
  for (const image of attachments) {
1973
2083
  const res = await fetch(`${BASE}/api/attachments/${image.id}`, {
1974
- headers: token ? { Authorization: `Bearer ${token}` } : {},
2084
+ headers: authHeaders(token, setting('RUN_KEY')),
1975
2085
  });
1976
2086
  if (!res.ok) fail(`couldn't download ${image.name} (HTTP ${res.status})`);
1977
2087
  const ext = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' }[image.type];
@@ -2128,15 +2238,17 @@ const commands = {
2128
2238
  }
2129
2239
  // Claude Code says which server it's connecting: the plugin's board_url, when the CLI's settings don't name one.
2130
2240
  const server = process.env.CLAUDE_CODE_MCP_SERVER_URL?.replace(/\/mcp\/?$/u, '');
2241
+ const runKey = setting('RUN_KEY') ?? null;
2131
2242
  const repo = await headersRepo({
2132
2243
  base: BASE ?? (server?.startsWith('http') ? server : null),
2133
2244
  token,
2245
+ runKey,
2134
2246
  named: opts.repo ?? setting('REPO'),
2135
2247
  remote,
2136
2248
  fetch,
2137
2249
  });
2138
2250
  // Standard output is Claude Code's, and only the headers go there: never --json's wrapping, never a line of text.
2139
- console.log(JSON.stringify(mcpHeaders({ token, named, agent: name, repo })));
2251
+ console.log(JSON.stringify(mcpHeaders({ token, runKey, named, agent: name, repo })));
2140
2252
  return;
2141
2253
  }
2142
2254
  const tokenVar = envName('TOKEN');
@@ -2148,6 +2260,7 @@ const commands = {
2148
2260
  const checked = await checkMcp({
2149
2261
  endpoint: config.endpoint,
2150
2262
  token: setting('TOKEN'),
2263
+ runKey: setting('RUN_KEY') ?? null,
2151
2264
  agent: name,
2152
2265
  repo: slug,
2153
2266
  fetch,
@@ -2155,6 +2268,37 @@ const commands = {
2155
2268
  print(checked, (c) => c.lines.join('\n'));
2156
2269
  if (!checked.ok) process.exitCode = 1;
2157
2270
  },
2271
+ /**
2272
+ * A lent run's key (BRK-324), saved in tasks.env (0600, outside the checkout) because the hooks don't share the
2273
+ * agent's shell: an exported variable never reaches them. --forget drops it.
2274
+ */
2275
+ async 'run-key'() {
2276
+ const name = envName('RUN_KEY');
2277
+ const env = readEnvFile();
2278
+ if (opts.forget) {
2279
+ if (!(name in env)) return print({ forgotten: false }, () => `There's no run key in ${ENV_FILE}.`);
2280
+ const { [name]: _gone, ...rest } = env;
2281
+ writePrivate(`${ENV_FILE}.next`, envFile(rest));
2282
+ renameSync(`${ENV_FILE}.next`, ENV_FILE);
2283
+ return print({ forgotten: true }, () => `Dropped the run key from ${ENV_FILE}.`);
2284
+ }
2285
+ const key = String(need(args[0], 'run key')).trim();
2286
+ if (!RUN_KEY.test(key))
2287
+ fail('that isn’t a run key: copy the whole Run key line’s value from your payload (bkr_…).');
2288
+ writePrivate(`${ENV_FILE}.next`, envFile({ ...env, [name]: key }));
2289
+ renameSync(`${ENV_FILE}.next`, ENV_FILE);
2290
+ process.env[name] = key;
2291
+ const session = await call('GET', 'session', undefined, { raw: true });
2292
+ if (!session.ok) fail(`saved, but the board refused it: ${session.data?.error ?? `HTTP ${session.status}`}`);
2293
+ print({ saved: true, person: session.data.person ?? null }, (d) =>
2294
+ [
2295
+ `Saved the run key in ${ENV_FILE}. This CLI, its hooks, and mcp --headers send it from now on.`,
2296
+ d.person ? `The board knows it: you act for ${d.person.name ?? d.person.handle}, with their rights.` : '',
2297
+ ]
2298
+ .filter(Boolean)
2299
+ .join('\n'),
2300
+ );
2301
+ },
2158
2302
  async health() {
2159
2303
  const settings = settingSources();
2160
2304
  print({ ...(await call('GET', 'health')), settings }, (h) =>
package/src/decision.js CHANGED
@@ -228,8 +228,11 @@ function show(q, answer) {
228
228
  return String(value);
229
229
  }
230
230
 
231
- /** The plain comment the board adds when a decision is submitted. Nothing reads it back. */
232
- export function summarize(questions, answers) {
231
+ /**
232
+ * The plain comment the board adds when a decision is submitted. Nothing reads it back. `by` is who answered
233
+ * (BRK-303): the owner, or the maintainer who did, by handle.
234
+ */
235
+ export function summarize(questions, answers, by = 'owner') {
233
236
  const parts = questions.map((q) => {
234
237
  const answer = answers[q.id];
235
238
  const body = answer
@@ -237,11 +240,11 @@ export function summarize(questions, answers) {
237
240
  : 'no answer';
238
241
  return `${clip(text(q.prompt).replace(/\s+/gu, ' '), 60)} = ${body}`;
239
242
  });
240
- return `Decided by the owner: ${parts.join('; ')}`;
243
+ return `Decided by ${!by || by === 'owner' ? 'the owner' : by}: ${parts.join('; ')}`;
241
244
  }
242
245
 
243
246
  /** Tags that aren't a feature's (IDEA-28 section 1): the board's own, horizons, and release tags. */
244
- const NOT_FEATURES = new Set(['agent', 'owner', 'decide', 'idea', 'general', 'version']);
247
+ const NOT_FEATURES = new Set(['idea', 'general', 'version']);
245
248
  export const featureTags = (tags) =>
246
249
  (tags ?? []).filter((t) => !NOT_FEATURES.has(t) && !t.startsWith('horizon-') && !/^v\d/u.test(t));
247
250
 
@@ -267,18 +270,21 @@ const MAX_BRIEF = 10000;
267
270
  * agent that brings the work waiting for an answered decision in line with its answers. `decision` is the decision's
268
271
  * task (its `ref` is its work ID or short ID), `waiting` the open tasks that depend on it, and `note` the owner's,
269
272
  * which goes under the board's prompt. Returns the task's title and description.
270
- * @param {{ ref: string, description: string, spec?: string | null, questions: any[], answers: Record<string, any> }} decision
273
+ * `by` is who answered (BRK-303): the owner, or a maintainer's handle.
274
+ * @param {{ ref: string, description: string, spec?: string | null, questions: any[], answers: Record<string, any>,
275
+ * by?: string | null }} decision
271
276
  * @param {{ ref: string, description: string, tags?: string[], spec?: string | null }[]} waiting
272
277
  * @param {string | null} [note]
273
278
  */
274
279
  export function refinePrompt(decision, waiting, note = null) {
280
+ const who = !decision.by || decision.by === 'owner' ? 'the owner' : decision.by;
275
281
  const title = clip(`Refine from the answers to ${decision.ref}: ${decision.description}`, 200);
276
282
  const questions = decision.questions.flatMap((q, i) => {
277
283
  const answer = decision.answers[q.id];
278
284
  return [
279
285
  `${i + 1}. ${q.prompt.trim()}`,
280
286
  ` Answer: ${spell(q, answer)}`,
281
- ...(answer?.comment ? [` The owner's note: ${answer.comment.trim()}`] : []),
287
+ ...(answer?.comment ? [` ${who === 'the owner' ? 'The owner' : who}'s note: ${answer.comment.trim()}`] : []),
282
288
  ];
283
289
  });
284
290
  const specs = [...new Set([decision.spec, ...waiting.map((t) => t.spec)].filter(Boolean))];
@@ -296,7 +302,7 @@ export function refinePrompt(decision, waiting, note = null) {
296
302
  '- If nothing in the repository needs to change, comment what you changed on the board, task by task, and release your task.',
297
303
  ...(note && String(note).trim() ? ['', 'Note from the owner:', String(note).trim().slice(0, 4000)] : []),
298
304
  ];
299
- const intro = `The owner answered the decision on ${decision.ref} (${decision.description}). Bring the work waiting for it in line with the answers.`;
305
+ const intro = `${who === 'the owner' ? 'The owner' : who} answered the decision on ${decision.ref} (${decision.description}). Bring the work waiting for it in line with the answers.`;
300
306
  const rest = [
301
307
  '',
302
308
  `Waiting for ${decision.ref}`,
@@ -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
+ }