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/src/init.js CHANGED
@@ -21,10 +21,15 @@ export { routinePrompt };
21
21
  */
22
22
  export const CLI_PACKAGE = 'breakaway@2';
23
23
  /**
24
- * Where the CLI starts: the command, and the two session hooks. repos init no longer copies them (BRK-7): an old copy
25
- * is replaced by npx, and these still version the CLI and say which files an old copy holds.
24
+ * Where the CLI starts: the command, and the session hooks (the edit hook since IDEA-55). repos init no longer copies
25
+ * them (BRK-7): an old copy is replaced by npx, and these still version the CLI and say which files an old copy holds.
26
26
  */
27
- export const CLI_ENTRIES = ['scripts/tasks.mjs', 'scripts/tasks/session-hook.mjs', 'scripts/tasks/message-wait.mjs'];
27
+ export const CLI_ENTRIES = [
28
+ 'scripts/tasks.mjs',
29
+ 'scripts/tasks/session-hook.mjs',
30
+ 'scripts/tasks/message-wait.mjs',
31
+ 'scripts/tasks/edit-hook.mjs',
32
+ ];
28
33
  /**
29
34
  * While breakaway was private, nothing reached npm (BRK-57), so repos init copied the two hooks and what they import
30
35
  * under HOOKS_DIR and settings.json ran them from there (BRK-64). breakaway is public and on npm now, so the hooks run
@@ -32,7 +37,24 @@ export const CLI_ENTRIES = ['scripts/tasks.mjs', 'scripts/tasks/session-hook.mjs
32
37
  */
33
38
  export const HOOKS_FROM_COPY = false;
34
39
  /** The session hooks' entry files, and where their copy goes: its own folder, so it never touches the repository's src/. */
35
- export const HOOK_ENTRIES = ['scripts/tasks/session-hook.mjs', 'scripts/tasks/message-wait.mjs'];
40
+ export const HOOK_ENTRIES = [
41
+ 'scripts/tasks/session-hook.mjs',
42
+ 'scripts/tasks/message-wait.mjs',
43
+ 'scripts/tasks/edit-hook.mjs',
44
+ ];
45
+ /** Each session hook's name (`npx breakaway hook <name>`) → its entry file. */
46
+ const HOOK_FILES = { session: 'session-hook', wait: 'message-wait', edit: 'edit-hook' };
47
+ /**
48
+ * The edit tools the edit hook runs on (IDEA-55 section 1a), as a PreToolUse matcher: exact tool names split by `|`
49
+ * (source: the matcher table in Claude Code's hooks reference, https://code.claude.com/docs/en/hooks).
50
+ */
51
+ export const EDIT_MATCHER = 'Edit|Write|MultiEdit|NotebookEdit';
52
+ /**
53
+ * The edit hook's `timeout`, in seconds: the field is in seconds and a command hook's defaults to 600, and a PreToolUse
54
+ * command hook that times out doesn't block the tool call (source: https://code.claude.com/docs/en/hooks, the hook
55
+ * handler fields and PreToolUse). Room for npx to start the CLI and for the hook's own wait on the board (3 s).
56
+ */
57
+ export const EDIT_HOOK_TIMEOUT = 15;
36
58
  export const HOOKS_DIR = 'tools/tasks/cli/';
37
59
  /**
38
60
  * The release helpers a repository's Deploy, Promote, Roll back, and Release workflows run (BRK-45), copied with what
@@ -84,7 +106,7 @@ export function pluginSettings() {
84
106
 
85
107
  /** Whether a hook command is one of the session hooks repos init wrote: through npx, any version, or an old copy's. */
86
108
  const BOARD_HOOK =
87
- /\bbreakaway(?:@[^\s"]+)? hook (?:session|wait)\b|scripts\/tasks\/(?:session-hook|message-wait)\.mjs/u;
109
+ /\bbreakaway(?:@[^\s"]+)? hook (?:session|wait|edit)\b|scripts\/tasks\/(?:session-hook|message-wait|edit-hook)\.mjs/u;
88
110
 
89
111
  /**
90
112
  * settings.json moved to the plugin (repos init --update): the session hooks repos init wrote are gone, with any event
@@ -229,11 +251,18 @@ export function cliCopySources(read) {
229
251
  /** The command a session hook runs: from the copy under HOOKS_DIR while breakaway is private, else through npx (BRK-7). */
230
252
  export function hookCommand(name, { fromCopy = HOOKS_FROM_COPY, pkg = CLI_PACKAGE } = {}) {
231
253
  if (!fromCopy) return `npx --yes ${pkg} hook ${name}`;
232
- const entry = HOOK_ENTRIES.find((path) => path.includes(name === 'wait' ? 'message-wait' : 'session-hook'));
254
+ const file = HOOK_FILES[/** @type {keyof typeof HOOK_FILES} */ (name)] ?? HOOK_FILES.session;
255
+ const entry = HOOK_ENTRIES.find((path) => path.endsWith(`/${file}.mjs`));
233
256
  return `node "$CLAUDE_PROJECT_DIR/${HOOKS_DIR}${entry}"`;
234
257
  }
235
258
 
236
- /** The session hooks .claude/settings.json runs (see HOOKS_FROM_COPY). */
259
+ /**
260
+ * The session hooks .claude/settings.json runs (see HOOKS_FROM_COPY). All run in the background but the edit hook
261
+ * (IDEA-55 section 1a), which claims the file an edit names before the edit runs: a hook with `async: true` runs in
262
+ * the background without blocking, so it couldn't turn an edit away (source: https://code.claude.com/docs/en/hooks,
263
+ * the `async` field). It has a short `timeout` instead (EDIT_HOOK_TIMEOUT), and a board that doesn't answer lets the
264
+ * edit through.
265
+ */
237
266
  export function sessionHooks(pkg = CLI_PACKAGE) {
238
267
  const hook = (name, extra = {}) => ({
239
268
  type: 'command',
@@ -242,9 +271,11 @@ export function sessionHooks(pkg = CLI_PACKAGE) {
242
271
  ...extra,
243
272
  });
244
273
  const session = [{ hooks: [hook('session')] }];
274
+ const edit = { type: 'command', command: hookCommand('edit', { pkg }), timeout: EDIT_HOOK_TIMEOUT };
245
275
  return {
246
276
  SessionStart: session,
247
277
  UserPromptSubmit: session,
278
+ PreToolUse: [{ matcher: EDIT_MATCHER, hooks: [edit] }],
248
279
  PostToolUse: session,
249
280
  Stop: [{ hooks: [hook('session'), hook('wait', { asyncRewake: true, timeout: 300 })] }],
250
281
  };
@@ -256,7 +287,7 @@ export function sessionHooks(pkg = CLI_PACKAGE) {
256
287
  */
257
288
  export function rewireHooks(text) {
258
289
  let out = text;
259
- for (const name of ['session', 'wait'])
290
+ for (const name of ['session', 'wait', 'edit'])
260
291
  for (const fromCopy of [true, false]) {
261
292
  const was = JSON.stringify(hookCommand(name, { fromCopy })).slice(1, -1);
262
293
  const now = JSON.stringify(hookCommand(name)).slice(1, -1);
@@ -265,7 +296,7 @@ export function rewireHooks(text) {
265
296
  const current = (_, name) => JSON.stringify(hookCommand(name === 'message-wait' ? 'wait' : name)).slice(1, -1);
266
297
  return (
267
298
  out
268
- .replace(/npx --yes breakaway(?:@[^\s"\\]+)? hook (session|wait)\b/gu, current)
299
+ .replace(/npx --yes breakaway(?:@[^\s"\\]+)? hook (session|wait|edit)\b/gu, current)
269
300
  // The old CLI copy's hook scripts, from before BRK-7, quoted or not (BRK-79): its copy is about to go.
270
301
  .replace(
271
302
  /node (\\")?(?:\$CLAUDE_PROJECT_DIR\/)?(?:tools\/tasks\/cli\/)?scripts\/tasks\/(session-hook|message-wait)\.mjs\1/gu,
@@ -592,6 +623,11 @@ export function initPlan({
592
623
  notes.push(
593
624
  `.claude/settings.json is already there: set its session hooks to \`${hookCommand('session')}\` (and \`${hookCommand('wait')}\` on Stop), or a started agent's output won't show on its task.${HOOKS_FROM_COPY ? '' : ' Hooks that run scripts/tasks/session-hook.mjs stop working once the old copy is removed.'}`,
594
625
  );
626
+ // Settings from before the edit hook (IDEA-55): agents there don't claim the files they edit until it's added.
627
+ if (!rewired.includes(hookCommand('edit')))
628
+ notes.push(
629
+ `.claude/settings.json has no edit hook: add ${JSON.stringify({ PreToolUse: sessionHooks().PreToolUse })} to its hooks, so agents claim each file before they edit it.`,
630
+ );
595
631
  }
596
632
  if (readTarget('.claude/skills') === null) files.push({ path: '.claude/skills', link: '../.agents/skills' });
597
633
  if (!usePlugin) copy(SKILL, skillFor(read(SKILL), board, repo));
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.
package/taskrc CHANGED
@@ -47,29 +47,40 @@ uda.done_when.label=Done when
47
47
 
48
48
  # A decision for the owner (docs/specs/IDEA-6-decisions-with-questions.md): the questions, and the
49
49
  # owner's answers once submitted, both JSON. Attach and answer them through the board, not by hand.
50
- uda.decision.type=string
51
- uda.decision.label=Decision
50
+ # The questions were `decision` before BRK-346: Taskwarrior reads a word that names an attribute as
51
+ # that attribute's value, so who:decision stored nothing. The board moves an old `decision` forward.
52
+ uda.decision_questions.type=string
53
+ uda.decision_questions.label=Decision
52
54
  uda.decision_answers.type=string
53
55
  uda.decision_answers.label=Answers
54
56
 
57
+ # Who does it (BRK-330): an agent builds it, a person does it, or the owner decides first. A person's task
58
+ # names who it's for in `assignee` (a person's handle, or owner); without one, any member of its repository
59
+ # does it. Set them with who:agent, who:decision, who:person assignee:owner, and so on.
60
+ uda.who.type=string
61
+ uda.who.label=Who
62
+ uda.who.values=agent,person,decision,
63
+ uda.assignee.type=string
64
+ uda.assignee.label=Assignee
65
+
55
66
  # Order work like the board: horizon first, then priority. Waiting on a decision sinks.
56
67
  urgency.uda.horizon.now.coefficient=8.0
57
68
  urgency.uda.horizon.next.coefficient=2.0
58
69
  urgency.uda.horizon.later.coefficient=-4.0
59
70
  urgency.uda.horizon.archive.coefficient=-8.0
60
- urgency.user.tag.decide.coefficient=-3.0
71
+ urgency.uda.who.decision.coefficient=-3.0
61
72
 
62
73
  # Reports: `task board`, `task agent`, `task claimed`, `task owner`.
63
74
  report.board.description=Open work, best first
64
- report.board.columns=id,wid,priority,horizon,project,tags,claim,depends.indicator,description.count
65
- report.board.labels=ID,Work,P,Horizon,Area,Tags,Claimed by,D,Description
75
+ report.board.columns=id,wid,priority,horizon,project,who,tags,claim,depends.indicator,description.count
76
+ report.board.labels=ID,Work,P,Horizon,Area,Who,Tags,Claimed by,D,Description
66
77
  report.board.filter=status:pending -WAITING
67
78
  report.board.sort=urgency-
68
79
 
69
80
  report.agent.description=Ready for an agent: unblocked, unclaimed, not waiting on a decision
70
81
  report.agent.columns=id,wid,priority,horizon,project,tags,description.count
71
82
  report.agent.labels=ID,Work,P,Horizon,Area,Tags,Description
72
- report.agent.filter=status:pending +READY +agent -decide claim:
83
+ report.agent.filter=status:pending +READY who:agent claim:
73
84
  report.agent.sort=urgency-
74
85
 
75
86
  report.claimed.description=In progress, oldest claim first
@@ -78,10 +89,10 @@ report.claimed.labels=ID,Work,Claimed by,For,Area,Description
78
89
  report.claimed.filter=status:pending claim.any:
79
90
  report.claimed.sort=start+
80
91
 
81
- report.owner.description=Needs the owner: owner work and open decisions
82
- report.owner.columns=id,wid,priority,horizon,project,tags,description.count
83
- report.owner.labels=ID,Work,P,Horizon,Area,Tags,Description
84
- report.owner.filter=status:pending (+owner or +decide)
92
+ report.owner.description=Needs the owner: the owner's work, work for anyone, and open decisions
93
+ report.owner.columns=id,wid,priority,horizon,project,who,assignee,tags,description.count
94
+ report.owner.labels=ID,Work,P,Horizon,Area,Who,For,Tags,Description
95
+ report.owner.filter=status:pending ( who:decision or ( who:person and ( assignee:owner or assignee.none: ) ) )
85
96
  report.owner.sort=urgency-
86
97
 
87
98
  # Repositories (IDEA-14): one report and one context each, for every repository the board tracks. This file