breakaway 2.0.2-main.2 → 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 +6 -4
- package/README.md +5 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/prompts/add-repository.md +2 -2
- package/prompts/core.md +20 -15
- package/prompts/install.md +1 -1
- package/scripts/tasks/cli.js +10 -1
- package/scripts/tasks/hook-config.js +3 -3
- package/scripts/tasks/mcp.js +12 -9
- package/scripts/tasks/peloton.js +3 -1
- package/scripts/tasks/session-messages.js +3 -1
- package/scripts/tasks/settings.js +26 -0
- package/scripts/tasks/structure.js +4 -4
- package/scripts/tasks.mjs +78 -14
- package/src/decision.js +13 -7
- 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
|
@@ -88,6 +88,7 @@ import {
|
|
|
88
88
|
staleCliWarning,
|
|
89
89
|
releaseBehind,
|
|
90
90
|
removedRepoByHand,
|
|
91
|
+
needsOwner,
|
|
91
92
|
unknownSubcommand,
|
|
92
93
|
} from './tasks/cli.js';
|
|
93
94
|
import { InfraReadError, infraRead } from './tasks/infra-read.js';
|
|
@@ -112,6 +113,8 @@ import { cwdOf, parentOf, sessionDir } from './tasks/session-dir.js';
|
|
|
112
113
|
import { parseInstall, secretName } from '../src/install.js';
|
|
113
114
|
import {
|
|
114
115
|
NAMES,
|
|
116
|
+
RUN_KEY,
|
|
117
|
+
authHeaders,
|
|
115
118
|
boardUrl,
|
|
116
119
|
configDir,
|
|
117
120
|
parseEnvFile,
|
|
@@ -241,10 +244,11 @@ const HELP = `npx breakaway <command> [options]
|
|
|
241
244
|
|
|
242
245
|
Reading (list, next, claim, and add work in this checkout's repository; see Repositories below)
|
|
243
246
|
list open tasks, best first (the default command)
|
|
244
|
-
--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)
|
|
245
249
|
--project <p> --tag <t> --horizon <h> --status pending|completed|deleted|all
|
|
246
250
|
show <ref> one task: description, done when, related, comments, dependencies, and what it blocks
|
|
247
|
-
next the best ready task for an agent (
|
|
251
|
+
next the best ready task for an agent (who: agent, unclaimed)
|
|
248
252
|
--claim …and claim it in the same step
|
|
249
253
|
--project <p> --horizon <h> --tag <t>
|
|
250
254
|
activity recent changes, newest first [--limit <n>]
|
|
@@ -304,13 +308,17 @@ Reading (list, next, claim, and add work in this checkout's repos
|
|
|
304
308
|
Working
|
|
305
309
|
claim <ref> take a task (atomic: fails if someone else has it) [--force]
|
|
306
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
|
|
307
314
|
release <ref> give it back [--force]
|
|
308
315
|
comment <ref> <text> add a comment (signed with your agent name); note is the same command
|
|
309
316
|
quote <ref> <words> --from <source> put the owner's exact words on a task you hold, quoted and marked as yours:
|
|
310
317
|
shown first on the task and to every agent that claims it. <source> is where they said it:
|
|
311
318
|
message, peloton, ping, decision, or comment, with a pointer if you have one ("peloton #12")
|
|
312
319
|
review <ref> --verdict ready|follow-up|changes <note> your review of the pull request that closes the task you
|
|
313
|
-
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>]
|
|
314
322
|
risk-review <ref> --file <findings.json> a risky-path reviewer's findings on the pull request the board started it
|
|
315
323
|
for: { "summary", "findings": [{ "severity": "blocking"|"note", "text", "path", "line" }] }
|
|
316
324
|
[--pr <n>]; without --file, the task's reviews and their answers
|
|
@@ -319,11 +327,13 @@ Working
|
|
|
319
327
|
done <ref> finish it [--note <text>] [--pr <url>]
|
|
320
328
|
add <description> new task; gets the next work ID for its project
|
|
321
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)
|
|
322
332
|
--repo <slug> the repository it belongs to (default: the checkout's; its areas decide the prefix)
|
|
323
333
|
--depends <ref,…> --related <ref,…> --spec <path> --due <date> --wait <date>
|
|
324
334
|
--brief <text> | --brief-file <path> the description (--note is the same)
|
|
325
335
|
--done-when <text> what has to be true to call it done
|
|
326
|
-
--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
|
|
327
337
|
--force add it even though it resembles open tasks the board named (--related links them instead)
|
|
328
338
|
decision <ref> --template print an example decision file to edit (nothing here answers a decision: the owner does, on the board)
|
|
329
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)
|
|
@@ -373,6 +383,7 @@ Working
|
|
|
373
383
|
--done-when <text> change what has to be true to call it done
|
|
374
384
|
--decision <file.json> set the decision's questions (answers to questions that still exist are kept)
|
|
375
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)
|
|
376
387
|
--tag <t> --untag <t> --depends <ref> --undepends <ref> (repeatable)
|
|
377
388
|
--autostart yes|no start a cloud agent by itself when the task is ready
|
|
378
389
|
|
|
@@ -567,6 +578,7 @@ const FLAGS = new Set([
|
|
|
567
578
|
'no-captain',
|
|
568
579
|
'handover',
|
|
569
580
|
'anyway',
|
|
581
|
+
'forget',
|
|
570
582
|
]);
|
|
571
583
|
/** Flags only in repos init (BRK-91): --pipeline takes a file in repos modify, and is a flag there. */
|
|
572
584
|
const INIT_FLAGS = new Set(['pipeline', 'copies']);
|
|
@@ -614,14 +626,15 @@ function fail(message) {
|
|
|
614
626
|
*/
|
|
615
627
|
async function call(method, path, body, { soft = false, raw = false } = {}) {
|
|
616
628
|
// Without a token the request still goes out: in a Claude cloud environment with an API
|
|
617
|
-
// 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).
|
|
618
631
|
const token = setting('TOKEN');
|
|
619
632
|
let res;
|
|
620
633
|
try {
|
|
621
634
|
res = await fetch(`${BASE}/api/${path}`, {
|
|
622
635
|
method,
|
|
623
636
|
headers: {
|
|
624
|
-
...(token
|
|
637
|
+
...authHeaders(token, setting('RUN_KEY')),
|
|
625
638
|
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
626
639
|
},
|
|
627
640
|
body: body ? JSON.stringify(body) : undefined,
|
|
@@ -633,7 +646,7 @@ async function call(method, path, body, { soft = false, raw = false } = {}) {
|
|
|
633
646
|
);
|
|
634
647
|
}
|
|
635
648
|
warnIfStale(res.headers.get('X-Tasks-Cli'), res.headers.get('X-Tasks-Release'));
|
|
636
|
-
if (res.status === 401 && !token)
|
|
649
|
+
if (res.status === 401 && !token && !setting('RUN_KEY'))
|
|
637
650
|
fail(
|
|
638
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).`,
|
|
639
652
|
);
|
|
@@ -705,7 +718,7 @@ async function upload(taskRef, file, alt = '') {
|
|
|
705
718
|
res = await fetch(`${BASE}/api/tasks/${enc(taskRef)}/attachments`, {
|
|
706
719
|
method: 'POST',
|
|
707
720
|
headers: {
|
|
708
|
-
...(token
|
|
721
|
+
...authHeaders(token, setting('RUN_KEY')),
|
|
709
722
|
'Content-Type': 'application/octet-stream',
|
|
710
723
|
'X-Attachment-Name': enc(basename(file)),
|
|
711
724
|
'X-Attachment-Alt': enc(alt),
|
|
@@ -799,7 +812,7 @@ async function startSessionLog(t) {
|
|
|
799
812
|
try {
|
|
800
813
|
const res = await fetch(`${BASE}/api/tasks/${enc(t.uuid)}/session`, {
|
|
801
814
|
method: 'POST',
|
|
802
|
-
headers: { 'Content-Type': 'application/json', ...(token
|
|
815
|
+
headers: { 'Content-Type': 'application/json', ...authHeaders(token, setting('RUN_KEY')) },
|
|
803
816
|
body: JSON.stringify({
|
|
804
817
|
agent: t.claim,
|
|
805
818
|
remote: process.env.CLAUDE_CODE_REMOTE === 'true',
|
|
@@ -861,14 +874,25 @@ function line(t) {
|
|
|
861
874
|
t.description,
|
|
862
875
|
];
|
|
863
876
|
const extra = [];
|
|
877
|
+
if (t.who) extra.push(t.who === 'person' ? `person: ${t.assignee ?? 'anyone'}` : t.who);
|
|
864
878
|
if (t.tags.length) extra.push(t.tags.map((x) => `+${x}`).join(' '));
|
|
865
879
|
if (t.claim) extra.push(`claimed by ${t.claim} ${age(t.start)}`);
|
|
866
|
-
|
|
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'}`);
|
|
867
882
|
if (t.waiting) extra.push(`waiting until ${t.wait.slice(0, 10)}`);
|
|
868
883
|
if (t.status !== 'pending') extra.push(`${t.status}${t.end ? ` ${t.end.slice(0, 10)}` : ''}`);
|
|
869
884
|
return `${bits.join(' ')}${extra.length ? ` (${extra.join('; ')})` : ''}`;
|
|
870
885
|
}
|
|
871
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
|
+
|
|
872
896
|
function detail(t) {
|
|
873
897
|
const said = ownerSaidLines(t);
|
|
874
898
|
const out = [`${ref(t)} · ${t.description}`, '', ...(said.length ? [...said, ''] : [])];
|
|
@@ -881,6 +905,7 @@ function detail(t) {
|
|
|
881
905
|
row('Project', t.project);
|
|
882
906
|
row('Horizon', t.horizon);
|
|
883
907
|
row('Priority', t.priority);
|
|
908
|
+
row('Who', whoWords(t));
|
|
884
909
|
row('Tags', t.tags.map((x) => `+${x}`).join(' '));
|
|
885
910
|
row('Claimed by', t.claim && `${t.claim} (${age(t.start)} ago)`);
|
|
886
911
|
row('Spec', t.spec);
|
|
@@ -1038,6 +1063,8 @@ function changesFrom(o) {
|
|
|
1038
1063
|
for (const key of ['description', 'project', 'priority', 'horizon', 'spec', 'pr', 'due', 'wait', 'status'])
|
|
1039
1064
|
if (key in o) c[key] = o[key];
|
|
1040
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;
|
|
1041
1068
|
if (o.tag) c.addTags = o.tag;
|
|
1042
1069
|
if (o.untag) c.removeTags = o.untag;
|
|
1043
1070
|
Object.assign(
|
|
@@ -1101,7 +1128,9 @@ const commands = {
|
|
|
1101
1128
|
(!opts.blocked || t.blocked) &&
|
|
1102
1129
|
(!opts.active || t.claim || t.active) &&
|
|
1103
1130
|
(!opts.mine || t.claim === me) &&
|
|
1104
|
-
(!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()) &&
|
|
1105
1134
|
(!opts.project || t.project === opts.project) &&
|
|
1106
1135
|
// The archive is hidden from open work only: --status completed or all lists it too (CLD-193).
|
|
1107
1136
|
(opts.horizon
|
|
@@ -1794,7 +1823,8 @@ const commands = {
|
|
|
1794
1823
|
const body = { agent: agent(), claim: Boolean(opts.claim), project: opts.project, horizon: opts.horizon };
|
|
1795
1824
|
const repo = await scopedRepo();
|
|
1796
1825
|
if (repo) body.repo = repo;
|
|
1797
|
-
|
|
1826
|
+
body.who = 'agent';
|
|
1827
|
+
if (opts.tag) body.tags = opts.tag;
|
|
1798
1828
|
const { task } = await call('POST', 'next', body);
|
|
1799
1829
|
if (task && opts.claim) {
|
|
1800
1830
|
markSession(task);
|
|
@@ -2051,7 +2081,7 @@ const commands = {
|
|
|
2051
2081
|
const token = setting('TOKEN');
|
|
2052
2082
|
for (const image of attachments) {
|
|
2053
2083
|
const res = await fetch(`${BASE}/api/attachments/${image.id}`, {
|
|
2054
|
-
headers: token
|
|
2084
|
+
headers: authHeaders(token, setting('RUN_KEY')),
|
|
2055
2085
|
});
|
|
2056
2086
|
if (!res.ok) fail(`couldn't download ${image.name} (HTTP ${res.status})`);
|
|
2057
2087
|
const ext = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' }[image.type];
|
|
@@ -2208,15 +2238,17 @@ const commands = {
|
|
|
2208
2238
|
}
|
|
2209
2239
|
// Claude Code says which server it's connecting: the plugin's board_url, when the CLI's settings don't name one.
|
|
2210
2240
|
const server = process.env.CLAUDE_CODE_MCP_SERVER_URL?.replace(/\/mcp\/?$/u, '');
|
|
2241
|
+
const runKey = setting('RUN_KEY') ?? null;
|
|
2211
2242
|
const repo = await headersRepo({
|
|
2212
2243
|
base: BASE ?? (server?.startsWith('http') ? server : null),
|
|
2213
2244
|
token,
|
|
2245
|
+
runKey,
|
|
2214
2246
|
named: opts.repo ?? setting('REPO'),
|
|
2215
2247
|
remote,
|
|
2216
2248
|
fetch,
|
|
2217
2249
|
});
|
|
2218
2250
|
// Standard output is Claude Code's, and only the headers go there: never --json's wrapping, never a line of text.
|
|
2219
|
-
console.log(JSON.stringify(mcpHeaders({ token, named, agent: name, repo })));
|
|
2251
|
+
console.log(JSON.stringify(mcpHeaders({ token, runKey, named, agent: name, repo })));
|
|
2220
2252
|
return;
|
|
2221
2253
|
}
|
|
2222
2254
|
const tokenVar = envName('TOKEN');
|
|
@@ -2228,6 +2260,7 @@ const commands = {
|
|
|
2228
2260
|
const checked = await checkMcp({
|
|
2229
2261
|
endpoint: config.endpoint,
|
|
2230
2262
|
token: setting('TOKEN'),
|
|
2263
|
+
runKey: setting('RUN_KEY') ?? null,
|
|
2231
2264
|
agent: name,
|
|
2232
2265
|
repo: slug,
|
|
2233
2266
|
fetch,
|
|
@@ -2235,6 +2268,37 @@ const commands = {
|
|
|
2235
2268
|
print(checked, (c) => c.lines.join('\n'));
|
|
2236
2269
|
if (!checked.ok) process.exitCode = 1;
|
|
2237
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
|
+
},
|
|
2238
2302
|
async health() {
|
|
2239
2303
|
const settings = settingSources();
|
|
2240
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}`,
|
package/src/model.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* (`wid`, `horizon`, `spec`, `claim`, `pr`) as plain properties. The task structure (IDEA-5): `brief`
|
|
7
7
|
(the current description), `brief_by`, `done_when`, `rel_<uuid>` plus a `related` list, and `by_<epoch>`
|
|
8
8
|
beside `annotation_<epoch>` for a comment's author. The owner's words (BRK-284): `said_<epoch>` (the quote),
|
|
9
|
-
`said_from_<epoch>` (where it came from), and `said_by_<epoch>` (who put it on the task).
|
|
9
|
+
`said_from_<epoch>` (where it came from), and `said_by_<epoch>` (who put it on the task). Who does it (BRK-330):
|
|
10
|
+
`who` (agent, person, or decision) and, on a person's task, `assignee` (a person's handle, or owner).
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import { keepAnswers, validateQuestions } from './decision.js';
|
|
@@ -42,6 +43,78 @@ export const AREA_NAMES = {
|
|
|
42
43
|
export const HORIZONS = ['now', 'next', 'later', 'archive'];
|
|
43
44
|
export const PRIORITIES = ['H', 'M', 'L'];
|
|
44
45
|
export const STATUSES = ['pending', 'completed', 'deleted'];
|
|
46
|
+
/**
|
|
47
|
+
* Who does a task (BRK-330, docs/specs/BRK-299-people-and-roles.md, "Who does it"): an agent builds it, a person does
|
|
48
|
+
* it (its `assignee`, or any member of its repository when it has none), or the owner decides it first.
|
|
49
|
+
*/
|
|
50
|
+
export const WHO = ['agent', 'person', 'decision'];
|
|
51
|
+
/** A person's handle (src/store-people.js), or `owner`: who a person's task is assigned to. */
|
|
52
|
+
const ASSIGNEE = /^[a-z][a-z0-9-]{0,31}$/u;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The tags `who` replaced (BRK-330), and what each one means now. A task carried them before the migration, and an
|
|
56
|
+
* older CLI copied into another repository, a Taskwarrior replica, or an older prompt still sends them: withChanges
|
|
57
|
+
* and legacyWho map them onto the field, so none is ever stored. Kept by BRK-331 because released CLIs still send
|
|
58
|
+
* them: up to 2.0.2-main.2 (the stable 2.0.1 among them); the first release after it sends --who. BRK-349 (waits until
|
|
59
|
+
* 10 Nov 2026) removes the mapping once every repository's copy is past them.
|
|
60
|
+
*/
|
|
61
|
+
export const LEGACY_WHO_TAGS = {
|
|
62
|
+
decide: { who: 'decision' },
|
|
63
|
+
agent: { who: 'agent' },
|
|
64
|
+
owner: { who: 'person', assignee: 'owner' },
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* When a task carried several of them, the one that wins, as the board read them: a decision held every start, and
|
|
68
|
+
* +agent let agents start a task whatever else it carried, so it beats +owner.
|
|
69
|
+
*/
|
|
70
|
+
const LEGACY_ORDER = ['decide', 'agent', 'owner'];
|
|
71
|
+
export const isLegacyTag = (/** @type {string} */ tag) => Object.hasOwn(LEGACY_WHO_TAGS, tag);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* What the tags `who` replaced among `tags` mean (BRK-330): `{ who, assignee? }`, or null when there are none.
|
|
75
|
+
* @param {string[]} tags
|
|
76
|
+
* @returns {{ who: string, assignee?: string } | null}
|
|
77
|
+
*/
|
|
78
|
+
export function whoFromTags(tags) {
|
|
79
|
+
const tag = LEGACY_ORDER.find((t) => tags.includes(t));
|
|
80
|
+
return tag ? LEGACY_WHO_TAGS[tag] : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A task's map with its questions moved from `decision`, where they were kept before BRK-346, to `decision_questions`.
|
|
85
|
+
* Taskwarrior reads a word that names an attribute as that attribute's value, so while the questions' UDA was called
|
|
86
|
+
* `decision`, `task add … who:decision` stored no who. What the migration and a replica's version go through: a
|
|
87
|
+
* replica with an older shared taskrc may still send `decision`. A map without it comes back as it is (the same object).
|
|
88
|
+
* Kept until every repository's copied taskrc is past BRK-346; BRK-349 removes it.
|
|
89
|
+
* @param {Record<string, string>} map
|
|
90
|
+
*/
|
|
91
|
+
export function legacyQuestions(map) {
|
|
92
|
+
if (!('decision' in map)) return map;
|
|
93
|
+
const { decision, ...after } = map;
|
|
94
|
+
if (!after.decision_questions && decision) after.decision_questions = decision;
|
|
95
|
+
return after;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A task's map with the tags `who` replaced (BRK-330) turned into `who` and `assignee`: what the migration and a
|
|
100
|
+
* Taskwarrior replica's version go through. A map without them comes back as it is (the same object).
|
|
101
|
+
* @param {Record<string, string>} map
|
|
102
|
+
*/
|
|
103
|
+
export function legacyWho(map) {
|
|
104
|
+
const tags = tagsOf(map);
|
|
105
|
+
const mapped = whoFromTags(tags);
|
|
106
|
+
if (!mapped) return map;
|
|
107
|
+
const after = { ...map };
|
|
108
|
+
setTags(
|
|
109
|
+
after,
|
|
110
|
+
tags.filter((tag) => !isLegacyTag(tag)),
|
|
111
|
+
);
|
|
112
|
+
const { who, assignee } = mapped;
|
|
113
|
+
after.who = who;
|
|
114
|
+
if (assignee && !(map.who === 'person' && map.assignee)) after.assignee = assignee;
|
|
115
|
+
if (who !== 'person') delete after.assignee;
|
|
116
|
+
return after;
|
|
117
|
+
}
|
|
45
118
|
/** Properties the API may set directly (strings, or null to remove). */
|
|
46
119
|
export const PLAIN = [
|
|
47
120
|
'description',
|
|
@@ -181,6 +254,8 @@ export function view(uuid, map, all, now = new Date()) {
|
|
|
181
254
|
priority: map.priority ?? '',
|
|
182
255
|
horizon: map.horizon ?? null,
|
|
183
256
|
tags: tagsOf(map),
|
|
257
|
+
who: map.who ?? null,
|
|
258
|
+
assignee: map.assignee ?? null,
|
|
184
259
|
depends,
|
|
185
260
|
blockedBy,
|
|
186
261
|
blocked: blockedBy.length > 0,
|
|
@@ -193,7 +268,7 @@ export function view(uuid, map, all, now = new Date()) {
|
|
|
193
268
|
autostart: map.autostart === 'yes',
|
|
194
269
|
session: map.session ?? null,
|
|
195
270
|
alert: map.alert ?? null,
|
|
196
|
-
decision: map.
|
|
271
|
+
decision: map.decision_questions ? parseJson(map.decision_questions, isList) : null,
|
|
197
272
|
decisionAnswers: map.decision_answers ? parseJson(map.decision_answers, isRecord) : null,
|
|
198
273
|
entry: iso(map.entry),
|
|
199
274
|
modified: iso(map.modified),
|
|
@@ -300,7 +375,9 @@ function setRelated(map, rel) {
|
|
|
300
375
|
* status, due, wait, scheduled (date text or null), start: true|false, entry, end (epoch),
|
|
301
376
|
* brief, done_when (text or null), addTags, removeTags, addDepends, removeDepends, addRelated,
|
|
302
377
|
* removeRelated (arrays), annotate (text), by (who wrote the annotation or the brief),
|
|
303
|
-
* said ({ text, from, by }: the owner's words, quoted), unsay (a quote's id, to remove it)
|
|
378
|
+
* said ({ text, from, by }: the owner's words, quoted), unsay (a quote's id, to remove it),
|
|
379
|
+
* who (agent, person, decision, or null), assignee (a handle, owner, or null: a person's task only)}
|
|
380
|
+
* A tag `who` replaced (LEGACY_WHO_TAGS) in addTags or removeTags changes `who` instead.
|
|
304
381
|
*/
|
|
305
382
|
export function withChanges(before, changes, now = new Date()) {
|
|
306
383
|
const map = { ...(before ?? {}) };
|
|
@@ -337,8 +414,10 @@ export function withChanges(before, changes, now = new Date()) {
|
|
|
337
414
|
if ('entry' in changes && changes.entry) map.entry = String(changes.entry);
|
|
338
415
|
if ('decision' in changes) {
|
|
339
416
|
if (changes.decision === null || changes.decision === '') {
|
|
340
|
-
delete map.
|
|
417
|
+
delete map.decision_questions;
|
|
341
418
|
delete map.decision_answers;
|
|
419
|
+
// Questions an older replica still keeps under `decision` (BRK-346) go too, or legacyQuestions brings them back.
|
|
420
|
+
delete map.decision;
|
|
342
421
|
} else {
|
|
343
422
|
let input = changes.decision;
|
|
344
423
|
if (typeof input === 'string') {
|
|
@@ -349,13 +428,12 @@ export function withChanges(before, changes, now = new Date()) {
|
|
|
349
428
|
}
|
|
350
429
|
}
|
|
351
430
|
const questions = validateQuestions(input);
|
|
352
|
-
map.
|
|
431
|
+
map.decision_questions = JSON.stringify(questions);
|
|
353
432
|
const old = map.decision_answers ? parseJson(map.decision_answers, isRecord) : null;
|
|
354
433
|
if (old) {
|
|
355
434
|
const { answers } = keepAnswers(questions, old.answers);
|
|
356
435
|
map.decision_answers = JSON.stringify({ ...old, answers });
|
|
357
436
|
}
|
|
358
|
-
setTags(map, [...new Set([...tagsOf(map), 'decide'])]);
|
|
359
437
|
}
|
|
360
438
|
}
|
|
361
439
|
if ('decisionAnswers' in changes) {
|
|
@@ -367,7 +445,32 @@ export function withChanges(before, changes, now = new Date()) {
|
|
|
367
445
|
for (const tag of changes.addTags ?? []) tags.add(checkTag(tag));
|
|
368
446
|
for (const tag of changes.removeTags ?? []) tags.delete(tag);
|
|
369
447
|
setTags(map, [...tags]);
|
|
448
|
+
// An older CLI's +agent, +owner, or +decide (BRK-330): removing the one that names who does it clears it.
|
|
449
|
+
for (const tag of changes.removeTags ?? [])
|
|
450
|
+
if (isLegacyTag(tag) && map.who === LEGACY_WHO_TAGS[tag].who) {
|
|
451
|
+
delete map.who;
|
|
452
|
+
delete map.assignee;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const mapped = legacyQuestions(legacyWho(map));
|
|
456
|
+
if (mapped !== map) {
|
|
457
|
+
for (const key of Object.keys(map)) if (!(key in mapped)) delete map[key];
|
|
458
|
+
Object.assign(map, mapped);
|
|
459
|
+
}
|
|
460
|
+
// Questions make it a decision, whatever else the same change says about who does it (an older CLI's --tag owner too).
|
|
461
|
+
const asking = Boolean(changes.decision);
|
|
462
|
+
if (asking) map.who = 'decision';
|
|
463
|
+
for (const key of ['who', 'assignee']) {
|
|
464
|
+
if (asking || !(key in changes) || changes[key] === undefined) continue;
|
|
465
|
+
const value = changes[key];
|
|
466
|
+
if (value === null || value === '') delete map[key];
|
|
467
|
+
else map[key] = String(value).trim().toLowerCase();
|
|
370
468
|
}
|
|
469
|
+
if (map.who && !WHO.includes(map.who)) throw new InputError('who is agent, person, or decision');
|
|
470
|
+
if ((asking || 'who' in changes) && map.who !== 'person' && (asking || !changes.assignee)) delete map.assignee;
|
|
471
|
+
if (map.assignee && !ASSIGNEE.test(map.assignee)) throw new InputError("an assignee is a person's handle, or owner");
|
|
472
|
+
if (map.assignee && map.who !== 'person')
|
|
473
|
+
throw new InputError("only a person's task has an assignee: set who to person too");
|
|
371
474
|
if (changes.addDepends || changes.removeDepends) {
|
|
372
475
|
const deps = new Set(dependsOf(map));
|
|
373
476
|
for (const dep of changes.addDepends ?? []) deps.add(dep);
|
package/src/ping.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* before anything is stored. Pure functions over the board's tasks, so the rules are tested without
|
|
4
4
|
* a Durable Object. The same checks run again when the owner applies a proposal (CLD-112).
|
|
5
5
|
*/
|
|
6
|
-
import { InputError, MAX_TEXT, PROJECTS, dependsOf } from './model.js';
|
|
6
|
+
import { InputError, MAX_TEXT, PROJECTS, WHO, dependsOf, isLegacyTag, whoFromTags } from './model.js';
|
|
7
7
|
|
|
8
8
|
export const PING_KINDS = ['blocked', 'question', 'stale', 'done', 'fyi'];
|
|
9
9
|
/** The kinds that send a push; `fyi` only shows in the inbox. */
|
|
@@ -29,6 +29,7 @@ export const PINGS_PER_AGENT_PER_DAY = 50;
|
|
|
29
29
|
const REF = /^[a-z][a-z0-9_-]{0,19}$/u;
|
|
30
30
|
const WID = /^[A-Z]+-\d+$/u;
|
|
31
31
|
const TAG = /^[a-z][\w-]{0,39}$/u;
|
|
32
|
+
const ASSIGNEE = /^[a-z][a-z0-9-]{0,31}$/u;
|
|
32
33
|
const MAX_TITLE = 200;
|
|
33
34
|
const MAX_NOTE = 1000;
|
|
34
35
|
const NEW_HORIZONS = ['now', 'next', 'later'];
|
|
@@ -111,6 +112,14 @@ const checkTags = (tags, where) => {
|
|
|
111
112
|
}
|
|
112
113
|
return tags;
|
|
113
114
|
};
|
|
115
|
+
/** A proposed assignee (BRK-330): a handle or owner, on a person's task only. The store checks their role on apply. */
|
|
116
|
+
const assigneeOf = (value, who, where) => {
|
|
117
|
+
if (value === undefined || value === null || value === '') return null;
|
|
118
|
+
if (typeof value !== 'string' || !ASSIGNEE.test(value))
|
|
119
|
+
throw new InputError(`${where}: assignee is a person's handle, or owner`);
|
|
120
|
+
if (who !== 'person') throw new InputError(`${where}: only a person's task has an assignee: set who to person too`);
|
|
121
|
+
return value;
|
|
122
|
+
};
|
|
114
123
|
|
|
115
124
|
/**
|
|
116
125
|
* Checks a proposal against the board as it is now. `ctx`:
|
|
@@ -206,7 +215,20 @@ export function validateProposal(raw, ctx) {
|
|
|
206
215
|
if (change.type === 'add') {
|
|
207
216
|
only(
|
|
208
217
|
change,
|
|
209
|
-
[
|
|
218
|
+
[
|
|
219
|
+
'type',
|
|
220
|
+
'ref',
|
|
221
|
+
'title',
|
|
222
|
+
'project',
|
|
223
|
+
'horizon',
|
|
224
|
+
'who',
|
|
225
|
+
'assignee',
|
|
226
|
+
'tags',
|
|
227
|
+
'brief',
|
|
228
|
+
'done_when',
|
|
229
|
+
'depends',
|
|
230
|
+
'priority',
|
|
231
|
+
],
|
|
210
232
|
where,
|
|
211
233
|
);
|
|
212
234
|
const title = text(change.title, 'title', MAX_TITLE, where, true);
|
|
@@ -217,9 +239,18 @@ export function validateProposal(raw, ctx) {
|
|
|
217
239
|
throw new InputError(`${where}: horizon is one of ${NEW_HORIZONS.join(', ')}`);
|
|
218
240
|
if (change.priority !== undefined && !PRIORITIES.includes(change.priority))
|
|
219
241
|
throw new InputError(`${where}: priority is H, M, or L`);
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
242
|
+
const given = checkTags(strings(change.tags, 'tags'), where);
|
|
243
|
+
// An older proposal says who does it with a tag (BRK-330): it still means who.
|
|
244
|
+
const mapped = whoFromTags(given);
|
|
245
|
+
const tags = given.filter((t) => !isLegacyTag(t));
|
|
246
|
+
const who = change.who === undefined ? mapped?.who : change.who;
|
|
247
|
+
if (!WHO.includes(who))
|
|
248
|
+
throw new InputError(`${where}: who is agent, person, or decision, so the board knows who does it`);
|
|
249
|
+
const assignee = assigneeOf(
|
|
250
|
+
change.assignee === undefined && who === 'person' ? mapped?.assignee : change.assignee,
|
|
251
|
+
who,
|
|
252
|
+
where,
|
|
253
|
+
);
|
|
223
254
|
const brief = text(change.brief, 'brief', MAX_TEXT, where, true);
|
|
224
255
|
const doneWhen = text(change.done_when, 'done_when', MAX_TEXT, where, true);
|
|
225
256
|
const depends = [];
|
|
@@ -235,6 +266,8 @@ export function validateProposal(raw, ctx) {
|
|
|
235
266
|
title,
|
|
236
267
|
project: change.project,
|
|
237
268
|
horizon: change.horizon,
|
|
269
|
+
who,
|
|
270
|
+
...(assignee ? { assignee } : {}),
|
|
238
271
|
tags,
|
|
239
272
|
brief,
|
|
240
273
|
done_when: doneWhen,
|
|
@@ -272,7 +305,11 @@ export function validateProposal(raw, ctx) {
|
|
|
272
305
|
}
|
|
273
306
|
changes.push({ type: 'depend', task: from.token, add: added, remove: removed });
|
|
274
307
|
} else if (change.type === 'modify') {
|
|
275
|
-
only(
|
|
308
|
+
only(
|
|
309
|
+
change,
|
|
310
|
+
['type', 'task', 'horizon', 'who', 'assignee', 'addTags', 'removeTags', 'brief', 'done_when'],
|
|
311
|
+
where,
|
|
312
|
+
);
|
|
276
313
|
const uuid = existing(change.task, where, { open: true });
|
|
277
314
|
if (ctx.tasks.get(uuid).claim === ctx.by)
|
|
278
315
|
throw new InputError(
|
|
@@ -284,6 +321,12 @@ export function validateProposal(raw, ctx) {
|
|
|
284
321
|
throw new InputError(`${where}: horizon is one of ${NEW_HORIZONS.join(', ')}`);
|
|
285
322
|
out.horizon = change.horizon;
|
|
286
323
|
}
|
|
324
|
+
if (change.who !== undefined) {
|
|
325
|
+
if (!WHO.includes(change.who)) throw new InputError(`${where}: who is agent, person, or decision`);
|
|
326
|
+
out.who = change.who;
|
|
327
|
+
}
|
|
328
|
+
const assignee = assigneeOf(change.assignee, change.who ?? ctx.tasks.get(uuid).who, where);
|
|
329
|
+
if (assignee) out.assignee = assignee;
|
|
287
330
|
const addTags = checkTags(strings(change.addTags, 'addTags'), where);
|
|
288
331
|
const removeTags = checkTags(strings(change.removeTags, 'removeTags'), where);
|
|
289
332
|
if (addTags.length) out.addTags = addTags;
|
|
@@ -293,7 +336,9 @@ export function validateProposal(raw, ctx) {
|
|
|
293
336
|
if (brief) out.brief = brief;
|
|
294
337
|
if (doneWhen) out.done_when = doneWhen;
|
|
295
338
|
if (Object.keys(out).length === 2)
|
|
296
|
-
throw new InputError(
|
|
339
|
+
throw new InputError(
|
|
340
|
+
`${where}: say what changes (horizon, who, assignee, addTags, removeTags, brief, or done_when)`,
|
|
341
|
+
);
|
|
297
342
|
changes.push(out);
|
|
298
343
|
} else if (change.type === 'done') {
|
|
299
344
|
only(change, ['type', 'task', 'note'], where);
|
package/src/redact.js
CHANGED
|
@@ -20,6 +20,9 @@ const PATTERNS = [
|
|
|
20
20
|
// A runbook run's act key (BRK-252), on its own or on its payload's line.
|
|
21
21
|
[/(\bAct key:\s*)\S+/gu, '$1[redacted]'],
|
|
22
22
|
[/\bact_[0-9a-f]{64}\b/gu, '[redacted]'],
|
|
23
|
+
// A lent run's run key (BRK-324), the same way.
|
|
24
|
+
[/(\bRun key:\s*)\S+/gu, '$1[redacted]'],
|
|
25
|
+
[/bkr_[0-9a-f]{64}/gu, '[redacted]'],
|
|
23
26
|
// Bearer tokens in headers.
|
|
24
27
|
[/(\bBearer\s+)[\w.~+/=-]{16,}/giu, '$1[redacted]'],
|
|
25
28
|
// Long base64 blobs with mixed case and digits (keys, secrets); hex SHAs and UUIDs don't match.
|