breakaway 2.0.1 → 2.0.2-main.2
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/tasks/SKILL.md +10 -2
- package/README.md +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/prompts/breakaway.md +1 -1
- package/prompts/core.md +51 -8
- package/scripts/tasks/cli.js +158 -5
- package/scripts/tasks/edit-hook.mjs +54 -0
- package/scripts/tasks/footprint-hook.js +188 -0
- package/scripts/tasks/peloton.js +83 -10
- package/scripts/tasks/plugin-hooks.js +5 -4
- package/scripts/tasks/session-hook.mjs +30 -1
- package/scripts/tasks/session-messages.js +3 -2
- package/scripts/tasks/structure.js +21 -0
- package/scripts/tasks.mjs +312 -21
- package/src/footprint-text.js +93 -0
- package/src/infra-cloudflare.js +12 -0
- package/src/infra-desired.js +4 -3
- package/src/infra-provider.js +3 -0
- package/src/init.js +45 -9
- package/src/model.js +57 -2
- package/taskrc +2 -1
package/src/infra-cloudflare.js
CHANGED
|
@@ -120,6 +120,12 @@ const BINDING_WORDS = {
|
|
|
120
120
|
};
|
|
121
121
|
const CRON = '^\\S+( \\S+){4}$';
|
|
122
122
|
const VALUES_ELSEWHERE = 'Set with the Worker’s deploy, never here.';
|
|
123
|
+
/**
|
|
124
|
+
* Where `wrangler deploy` sets a Worker's settings from: its bindings, compatibility date and flags, Workers Logs,
|
|
125
|
+
* placement, and cron triggers are each a key of the wrangler config, and each deploy sets what it says (BRK-313;
|
|
126
|
+
* developers.cloudflare.com/workers/wrangler/configuration/, "Source of truth").
|
|
127
|
+
*/
|
|
128
|
+
const DEPLOY_SETS = 'the wrangler config';
|
|
123
129
|
|
|
124
130
|
/**
|
|
125
131
|
* The settings the console may change, by kind (BRK-262; docs/specs/BRK-258-plan-from-the-board.md): exactly what the
|
|
@@ -132,6 +138,7 @@ export const EDITABLE = {
|
|
|
132
138
|
fields: [
|
|
133
139
|
{
|
|
134
140
|
path: 'compatibilityDate',
|
|
141
|
+
deploy: DEPLOY_SETS,
|
|
135
142
|
label: 'Compatibility date',
|
|
136
143
|
type: 'text',
|
|
137
144
|
pattern: '^\\d{4}-\\d{2}-\\d{2}$',
|
|
@@ -139,6 +146,7 @@ export const EDITABLE = {
|
|
|
139
146
|
},
|
|
140
147
|
{
|
|
141
148
|
path: 'compatibilityFlags',
|
|
149
|
+
deploy: DEPLOY_SETS,
|
|
142
150
|
label: 'Compatibility flags',
|
|
143
151
|
type: 'names',
|
|
144
152
|
pattern: '^[a-z0-9_]+$',
|
|
@@ -158,12 +166,14 @@ export const EDITABLE = {
|
|
|
158
166
|
},
|
|
159
167
|
{
|
|
160
168
|
path: 'observability',
|
|
169
|
+
deploy: DEPLOY_SETS,
|
|
161
170
|
label: 'Workers Logs',
|
|
162
171
|
type: 'yesno',
|
|
163
172
|
help: 'Keep the Worker’s logs on Cloudflare, to read in its dashboard.',
|
|
164
173
|
},
|
|
165
174
|
{
|
|
166
175
|
path: 'placement',
|
|
176
|
+
deploy: DEPLOY_SETS,
|
|
167
177
|
label: 'Placement',
|
|
168
178
|
type: 'choice',
|
|
169
179
|
optional: true,
|
|
@@ -172,6 +182,7 @@ export const EDITABLE = {
|
|
|
172
182
|
},
|
|
173
183
|
{
|
|
174
184
|
path: 'crons',
|
|
185
|
+
deploy: DEPLOY_SETS,
|
|
175
186
|
label: 'Cron triggers',
|
|
176
187
|
type: 'names',
|
|
177
188
|
pattern: CRON,
|
|
@@ -179,6 +190,7 @@ export const EDITABLE = {
|
|
|
179
190
|
},
|
|
180
191
|
{
|
|
181
192
|
path: 'bindings',
|
|
193
|
+
deploy: DEPLOY_SETS,
|
|
182
194
|
label: 'Bindings',
|
|
183
195
|
type: 'bindings',
|
|
184
196
|
targets: BINDABLE,
|
package/src/infra-desired.js
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
* one `{ id, kind, name, attrs? }`. `provider` is optional and, when given, must be the environment's. `health` is
|
|
10
10
|
* optional too: `{ "url": "https://…" }`, the owner's own health address the board GETs on each refresh as an active
|
|
11
11
|
* check of the environment's front door (BRK-266, src/infra-health.js). Nothing else
|
|
12
|
-
* goes at the top level: policy (BRK-181) is `policy.json
|
|
13
|
-
* folder, so
|
|
12
|
+
* goes at the top level: policy (BRK-181) is `policy.json`, scaling rules (BRK-186) are `scaling.json`, and the risky
|
|
13
|
+
* paths (BRK-280) are `risky-paths.json` in the same folder, so none of those names is an environment's, and envelope
|
|
14
|
+
* bounds live on the board, never in the repository.
|
|
14
15
|
*/
|
|
15
16
|
import { checkHealthField } from './infra-health.js';
|
|
16
17
|
import { checkDesired } from './infra-provider.js';
|
|
@@ -24,7 +25,7 @@ export const desiredPath = (environment) => `${DESIRED_DIR}/${environment}.json`
|
|
|
24
25
|
* Files in the folder that aren't an environment's: policy (BRK-181), scaling rules (BRK-186), and the template
|
|
25
26
|
* short-lived environments are made from (BRK-200).
|
|
26
27
|
*/
|
|
27
|
-
export const RESERVED_FILES = ['policy', 'scaling', 'short-lived'];
|
|
28
|
+
export const RESERVED_FILES = ['policy', 'scaling', 'short-lived', 'risky-paths'];
|
|
28
29
|
/** The only version there is. */
|
|
29
30
|
export const DESIRED_VERSION = 1;
|
|
30
31
|
/** Bigger than any one environment needs, and small enough to keep a copy of each in the store. */
|
package/src/infra-provider.js
CHANGED
|
@@ -269,6 +269,8 @@ const PATH = /^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)*$/u;
|
|
|
269
269
|
* from `template`)
|
|
270
270
|
* @property {string} help one line, in plain words
|
|
271
271
|
* @property {boolean} [optional] whether it can be left unset (null: the platform decides)
|
|
272
|
+
* @property {string} [deploy] where an app's deploy sets it too, in words (like `the wrangler config`): on a resource a
|
|
273
|
+
* repository's deploy flow deploys, the console says the next deploy puts back what that says (BRK-313)
|
|
272
274
|
* @property {number} [min] a number's least value
|
|
273
275
|
* @property {number} [max] a number's greatest value
|
|
274
276
|
* @property {boolean} [integer] a number must be whole
|
|
@@ -465,6 +467,7 @@ function checkFields(provider, what, fields, settings) {
|
|
|
465
467
|
if (!text(f.label) || !text(f.help)) fail(at, 'has no label or no help');
|
|
466
468
|
if (!EDITABLE_TYPES.includes(f.type)) fail(at, `has unknown type "${f.type}"`);
|
|
467
469
|
if (f.optional !== undefined && typeof f.optional !== 'boolean') fail(at, 'optional is not true or false');
|
|
470
|
+
if (f.deploy !== undefined && !text(f.deploy)) fail(at, 'deploy is not words');
|
|
468
471
|
if (f.pattern !== undefined) {
|
|
469
472
|
if (f.type !== 'text' && f.type !== 'names') fail(at, 'only text and names take a pattern');
|
|
470
473
|
checkPattern(at, 'pattern', f.pattern);
|
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
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* plus a `tags` list, `dep_<uuid>` plus a `depends` list, `annotation_<epoch>`, and UDAs
|
|
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
|
-
beside `annotation_<epoch>` for a comment's author.
|
|
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
10
|
*/
|
|
10
11
|
|
|
11
12
|
import { keepAnswers, validateQuestions } from './decision.js';
|
|
@@ -108,6 +109,34 @@ export function commentsOf(map) {
|
|
|
108
109
|
});
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
/** The longest one quote of the owner's words may be, and how many a task keeps (BRK-284). */
|
|
113
|
+
export const MAX_SAID = 2000;
|
|
114
|
+
export const MAX_SAID_COUNT = 20;
|
|
115
|
+
/** Where the owner's words came from: the task on the board, a message, a peloton post, a ping, a decision, or a comment. */
|
|
116
|
+
export const SAID_FROM = ['board', 'message', 'peloton', 'ping', 'decision', 'comment'];
|
|
117
|
+
/** A source, with an optional pointer to it: `peloton #2243`, `decision BRK-12`, `message 2026-10-09`. */
|
|
118
|
+
const SAID_SOURCE = new RegExp(`^(${SAID_FROM.join('|')})(?: [#\\w.:/-]{1,48})?$`, 'u');
|
|
119
|
+
const SAID_KEY = /^said_(\d+)$/u;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The owner's words on a task (BRK-284), oldest first: `{ id, text, from, by, at }`. `by` is `owner` when the owner put
|
|
123
|
+
* them there on the board, or the agent that quoted them; `id` is what removing one names.
|
|
124
|
+
*/
|
|
125
|
+
export function ownerSaidOf(map) {
|
|
126
|
+
return Object.keys(map)
|
|
127
|
+
.map((key) => SAID_KEY.exec(key))
|
|
128
|
+
.filter(Boolean)
|
|
129
|
+
.map((m) => Number(m[1]))
|
|
130
|
+
.sort((a, b) => a - b)
|
|
131
|
+
.map((epoch) => ({
|
|
132
|
+
id: epoch,
|
|
133
|
+
text: map[`said_${epoch}`],
|
|
134
|
+
from: map[`said_from_${epoch}`] ?? 'board',
|
|
135
|
+
by: map[`said_by_${epoch}`] ?? DEFAULT_AUTHOR,
|
|
136
|
+
at: iso(epoch),
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
|
|
111
140
|
/** A JSON property Taskwarrior may have mangled → its value, or null. */
|
|
112
141
|
function parseJson(text, shape) {
|
|
113
142
|
try {
|
|
@@ -146,6 +175,7 @@ export function view(uuid, map, all, now = new Date()) {
|
|
|
146
175
|
briefBy: map.brief_by ?? null,
|
|
147
176
|
doneWhen: map.done_when ?? null,
|
|
148
177
|
related: relatedOf(map),
|
|
178
|
+
ownerSaid: ownerSaidOf(map),
|
|
149
179
|
status,
|
|
150
180
|
project: map.project ?? null,
|
|
151
181
|
priority: map.priority ?? '',
|
|
@@ -269,7 +299,8 @@ function setRelated(map, rel) {
|
|
|
269
299
|
* changes: {description, project, priority, horizon, spec, pr, claim, wid (string or null),
|
|
270
300
|
* status, due, wait, scheduled (date text or null), start: true|false, entry, end (epoch),
|
|
271
301
|
* brief, done_when (text or null), addTags, removeTags, addDepends, removeDepends, addRelated,
|
|
272
|
-
* removeRelated (arrays), annotate (text), by (who wrote the annotation or the brief)
|
|
302
|
+
* 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)}
|
|
273
304
|
*/
|
|
274
305
|
export function withChanges(before, changes, now = new Date()) {
|
|
275
306
|
const map = { ...(before ?? {}) };
|
|
@@ -368,6 +399,30 @@ export function withChanges(before, changes, now = new Date()) {
|
|
|
368
399
|
map[`annotation_${key}`] = String(changes.annotate);
|
|
369
400
|
if (changes.by) map[`by_${key}`] = String(changes.by);
|
|
370
401
|
}
|
|
402
|
+
if (changes.said) {
|
|
403
|
+
const text = String(changes.said.text ?? '').trim();
|
|
404
|
+
const from = String(changes.said.from ?? 'board').trim();
|
|
405
|
+
if (!text) throw new InputError("say what the owner said: the quote can't be empty");
|
|
406
|
+
if (text.length > MAX_SAID) throw new InputError(`a quote can be up to ${MAX_SAID} characters`);
|
|
407
|
+
if (!SAID_SOURCE.test(from))
|
|
408
|
+
throw new InputError(
|
|
409
|
+
`say where the owner said it: ${SAID_FROM.join(', ')}, optionally with a pointer like "peloton #12"`,
|
|
410
|
+
);
|
|
411
|
+
const held = ownerSaidOf(map);
|
|
412
|
+
if (held.some((q) => q.text === text)) throw new InputError('the task already quotes those words');
|
|
413
|
+
if (held.length >= MAX_SAID_COUNT)
|
|
414
|
+
throw new InputError(`a task keeps up to ${MAX_SAID_COUNT} quotes; the owner removes one first`);
|
|
415
|
+
let key = Number(nowSec);
|
|
416
|
+
while (map[`said_${key}`] !== undefined) key += 1;
|
|
417
|
+
map[`said_${key}`] = text;
|
|
418
|
+
map[`said_from_${key}`] = from;
|
|
419
|
+
map[`said_by_${key}`] = String(changes.said.by ?? DEFAULT_AUTHOR);
|
|
420
|
+
}
|
|
421
|
+
if (changes.unsay !== undefined && changes.unsay !== null) {
|
|
422
|
+
const key = String(changes.unsay);
|
|
423
|
+
if (!/^\d+$/u.test(key) || map[`said_${key}`] === undefined) throw new InputError(`the task has no quote ${key}`);
|
|
424
|
+
for (const prop of [`said_${key}`, `said_from_${key}`, `said_by_${key}`]) delete map[prop];
|
|
425
|
+
}
|
|
371
426
|
if (!map.description) throw new InputError('a task needs a description');
|
|
372
427
|
map.modified = nowSec;
|
|
373
428
|
return map;
|
package/taskrc
CHANGED
|
@@ -38,7 +38,8 @@ uda.alert.label=Alert
|
|
|
38
38
|
|
|
39
39
|
# The task structure (docs/specs/IDEA-5-task-structure.md): the current description and what done
|
|
40
40
|
# looks like, in Markdown. Comments are annotations; `brief_by` and `by_<epoch>` (who wrote it) are
|
|
41
|
-
# plain properties Taskwarrior keeps without declaring them
|
|
41
|
+
# plain properties Taskwarrior keeps without declaring them, and so are the owner's words, quoted:
|
|
42
|
+
# `said_<epoch>`, `said_from_<epoch>` (where they said it), and `said_by_<epoch>` (who quoted them).
|
|
42
43
|
uda.brief.type=string
|
|
43
44
|
uda.brief.label=Description
|
|
44
45
|
uda.done_when.type=string
|