breakaway 2.0.2-main.1 → 2.0.2-main.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/pipeline/SKILL.md +1 -1
- package/.agents/skills/tasks/SKILL.md +8 -5
- package/README.md +5 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/prompts/add-repository.md +2 -2
- package/prompts/core.md +30 -17
- package/prompts/install.md +1 -1
- package/scripts/tasks/cli.js +11 -2
- package/scripts/tasks/edit-hook.mjs +54 -0
- package/scripts/tasks/footprint-hook.js +188 -0
- package/scripts/tasks/hook-config.js +3 -3
- package/scripts/tasks/mcp.js +12 -9
- package/scripts/tasks/peloton.js +23 -2
- package/scripts/tasks/plugin-hooks.js +5 -4
- package/scripts/tasks/session-hook.mjs +30 -1
- package/scripts/tasks/session-messages.js +6 -3
- package/scripts/tasks/settings.js +26 -0
- package/scripts/tasks/structure.js +4 -4
- package/scripts/tasks.mjs +172 -28
- package/src/decision.js +13 -7
- package/src/footprint-text.js +93 -0
- package/src/init.js +45 -9
- package/src/model.js +109 -6
- package/src/ping.js +52 -7
- package/src/redact.js +3 -0
- package/taskrc +21 -10
package/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
|
|
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 = [
|
|
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 = [
|
|
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
|
|
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
|
-
/**
|
|
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.
|
|
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.
|
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
|
-
|
|
51
|
-
|
|
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.
|
|
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
|
|
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 (
|
|
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
|