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/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 --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)
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 (+agent, not +decide, unclaimed)
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: 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>]
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 (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
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 ? { Authorization: `Bearer ${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 ? { Authorization: `Bearer ${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 ? { Authorization: `Bearer ${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
- 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'}`);
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 || 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()) &&
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
- if (opts.tag) body.tags = ['agent', ...opts.tag];
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 ? { Authorization: `Bearer ${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
- /** 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}`,
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.decision ? parseJson(map.decision, isList) : null,
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.decision;
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.decision = JSON.stringify(questions);
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
- ['type', 'ref', 'title', 'project', 'horizon', 'tags', 'brief', 'done_when', 'depends', 'priority'],
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 tags = checkTags(strings(change.tags, 'tags'), where);
221
- if (!tags.some((t) => ['agent', 'owner', 'decide'].includes(t)))
222
- throw new InputError(`${where}: tags need agent, owner, or decide, so the board knows who does it`);
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(change, ['type', 'task', 'horizon', 'addTags', 'removeTags', 'brief', 'done_when'], where);
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(`${where}: say what changes (horizon, addTags, removeTags, brief, or done_when)`);
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.