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/.agents/skills/pipeline/SKILL.md +1 -1
- package/.agents/skills/tasks/SKILL.md +8 -5
- package/README.md +5 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/prompts/add-repository.md +2 -2
- package/prompts/core.md +30 -17
- package/prompts/install.md +1 -1
- package/scripts/tasks/cli.js +11 -2
- package/scripts/tasks/edit-hook.mjs +54 -0
- package/scripts/tasks/footprint-hook.js +188 -0
- package/scripts/tasks/hook-config.js +3 -3
- package/scripts/tasks/mcp.js +12 -9
- package/scripts/tasks/peloton.js +23 -2
- package/scripts/tasks/plugin-hooks.js +5 -4
- package/scripts/tasks/session-hook.mjs +30 -1
- package/scripts/tasks/session-messages.js +6 -3
- package/scripts/tasks/settings.js +26 -0
- package/scripts/tasks/structure.js +4 -4
- package/scripts/tasks.mjs +172 -28
- package/src/decision.js +13 -7
- package/src/footprint-text.js +93 -0
- package/src/init.js +45 -9
- package/src/model.js +109 -6
- package/src/ping.js +52 -7
- package/src/redact.js +3 -0
- package/taskrc +21 -10
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
|
|
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 (
|
|
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
|
|
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
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
|
1107
|
-
|
|
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
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
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
|
-
|
|
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
|
-
[
|
|
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
|
|
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
|
-
/**
|
|
232
|
-
|
|
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(['
|
|
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
|
-
*
|
|
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 =
|
|
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
|
+
}
|