@stravica-ai/digital-operator-pa 0.1.69 → 0.1.71
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/dist/engineering-pa-cli/bin/pa +18 -1
- package/dist/engineering-pa-cli/src/pa/board-check.mjs +82 -12
- package/dist/engineering-pa-cli/src/pa/cli.mjs +5 -2
- package/dist/engineering-pa-cli/src/pa/dispatch-cli.mjs +76 -21
- package/dist/engineering-pa-cli/src/pa/status-board-cli.mjs +532 -44
- package/dist/engineering-pa-cli/src/pa/status-board.mjs +587 -17
- package/dist/engineering-pa-core/knowledge/work-management/status-board.md +26 -6
- package/dist/engineering-pa-scaffold/src/generator.mjs +6 -0
- package/dist/engineering-pa-workspace/README.md +8 -6
- package/dist/engineering-pa-workspace/app/banner.js +11 -10
- package/dist/engineering-pa-workspace/app/status-board.html +536 -250
- package/dist/engineering-pa-workspace/src/api.mjs +178 -12
- package/package.json +1 -1
|
@@ -1,31 +1,44 @@
|
|
|
1
|
-
// status-board-cli.mjs - argv router for `pa status set | patch | show
|
|
1
|
+
// status-board-cli.mjs - argv router for `pa status set | patch | show |
|
|
2
|
+
// lane | notice | done`.
|
|
2
3
|
//
|
|
3
4
|
// Bare `pa status` (no subverb) stays the environment probe in cli.mjs;
|
|
4
|
-
// only the
|
|
5
|
+
// only the subverbs below route here. `lane`, `notice` and `done` write the
|
|
6
|
+
// schema v2 sections (lanes, forMe, done); `patch --lane <key>` merges one
|
|
7
|
+
// item into a lane, the seam any outside writer uses to feed a lane. Every write goes through
|
|
5
8
|
// `writeBoardAtomic` in status-board.mjs: validated first, then a temp
|
|
6
9
|
// file renamed over the board, so a refused or half-written board never
|
|
7
|
-
// lands.
|
|
8
|
-
//
|
|
10
|
+
// lands. Every writer verb runs its read, change and write inside the
|
|
11
|
+
// board lock (`withBoardLock`), the one `pa dispatch` and the workspace
|
|
12
|
+
// dismiss route take too, so concurrent writers lose no change. The PA
|
|
13
|
+
// keeps the board current through these verbs and never hand-edits the
|
|
14
|
+
// JSON.
|
|
9
15
|
|
|
10
16
|
import { getRepoRoot } from './repo-root.mjs';
|
|
11
17
|
import {
|
|
18
|
+
BOARD_SCHEMA_VERSION,
|
|
12
19
|
BoardError,
|
|
20
|
+
DONE_WINDOW_HOURS,
|
|
21
|
+
FOR_ME_KINDS,
|
|
22
|
+
MAIN_LANE_KEY,
|
|
23
|
+
MAIN_LANE_TITLE,
|
|
13
24
|
STATUS_VALUES,
|
|
14
|
-
boardPath,
|
|
15
25
|
boardRelPath,
|
|
16
26
|
isValidAgentName,
|
|
17
27
|
parseBoard,
|
|
18
28
|
readBoard,
|
|
19
|
-
|
|
29
|
+
isValidLaneKey,
|
|
30
|
+
resolveAgentBoard,
|
|
31
|
+
toBoardV2,
|
|
32
|
+
withBoardLock,
|
|
20
33
|
writeBoardAtomic,
|
|
21
34
|
} from './status-board.mjs';
|
|
22
35
|
|
|
23
|
-
export const SUBVERBS = ['set', 'patch', 'show'];
|
|
36
|
+
export const SUBVERBS = ['set', 'patch', 'show', 'lane', 'notice', 'done'];
|
|
24
37
|
|
|
25
38
|
const USAGE = `pa status - environment probe, plus the daily status board subverbs
|
|
26
39
|
|
|
27
40
|
Usage:
|
|
28
|
-
pa status <set|patch|show> --help Show this help
|
|
41
|
+
pa status <set|patch|show|lane|notice|done> --help Show this help
|
|
29
42
|
pa status Probe operator content, installed package and workspace (unchanged)
|
|
30
43
|
pa status set [--file <path>] [--agent <name>]
|
|
31
44
|
Replace the whole board from a JSON file, or stdin when --file is absent or "-"
|
|
@@ -36,21 +49,61 @@ Usage:
|
|
|
36
49
|
[--question "..."] [--options "a|b"] [--workstream "..."] [--remove]
|
|
37
50
|
[--add-done "<title>"]
|
|
38
51
|
Merge one item or one on-me entry, set statuses, append to done
|
|
52
|
+
pa status patch --lane <key> (--id <id> | --match "<title>") [--lane-title "..."]
|
|
53
|
+
[--title "..."] [--slot running|queued] [--after "<task>"] [--project "..."]
|
|
54
|
+
[--status <status>] [--where "..."] [--stage "..."] [--note "..."]
|
|
55
|
+
[--ids a,b] [--link <url>] [--started <iso>] [--estimate <iso>] [--remove]
|
|
56
|
+
Merge one item into a lane (the lanes seam); --status done moves it to done
|
|
39
57
|
pa status show [--agent <name>] [--json]
|
|
40
58
|
Print the board
|
|
59
|
+
pa status lane --key <key> [--agent <name>] [--title "..."] [--paused | --resume]
|
|
60
|
+
[--file <path>] [--remove]
|
|
61
|
+
Add or change a lane; --file (or "-" for stdin) replaces its
|
|
62
|
+
running item and queue from { title?, state?, running?, queued? }
|
|
63
|
+
pa status notice [--agent <name>] [--id <id>] [--kind Risk|Money|Security] [--text "..."]
|
|
64
|
+
[--link <url>] [--dismiss | --undismiss | --remove]
|
|
65
|
+
Add or change one For-me notice (risk, money or security only)
|
|
66
|
+
pa status done [--agent <name>] (--id <id> | --title "...") [--ids a,b] [--project "..."]
|
|
67
|
+
[--note "..."] [--ended <iso>] | --prune
|
|
68
|
+
Move the lane item or open group item holding the id to done
|
|
69
|
+
(--id), add a done entry (--title), or drop done entries older
|
|
70
|
+
than ${DONE_WINDOW_HOURS} hours (--prune)
|
|
41
71
|
|
|
42
72
|
Notes:
|
|
43
73
|
- The board is agents/<agent>/workspace/status-board.json. The agent comes
|
|
44
74
|
from --agent, else preferences.json paName, else the single agents/*
|
|
45
|
-
folder holding next-session.md, else "dave".
|
|
75
|
+
folder holding next-session.md, else "dave". Whichever way it comes, it
|
|
76
|
+
must be an agent folder name and the board must lie inside its
|
|
77
|
+
workspace folder (no symlink out); otherwise the verb exits 1 and reads
|
|
78
|
+
and writes nothing.
|
|
46
79
|
- Every write stamps the board's updatedAt from the clock, is validated
|
|
47
80
|
against the schema and lands atomically (temp file, then rename).
|
|
48
|
-
-
|
|
49
|
-
|
|
81
|
+
- Writers take one lock beside the board (status-board.json.lock), the
|
|
82
|
+
same one pa dispatch and the workspace take; a write that cannot get it
|
|
83
|
+
within a few seconds exits 2 and writes nothing.
|
|
84
|
+
- An option's value never starts with "--": "--link --dismiss" and
|
|
85
|
+
"--link=--dismiss" are usage errors. --started, --estimate and --ended
|
|
86
|
+
take an ISO 8601 date-time with a zone on a real calendar date, like
|
|
87
|
+
2030-01-15T09:30:00Z or 2030-01-15T09:30:00+01:00.
|
|
88
|
+
- Status values: ${STATUS_VALUES.join(' | ')}. The legacy on-me key and
|
|
89
|
+
its status value are read as "onMe" and written as "onMe".
|
|
50
90
|
- patch --id matches items (or with --on-me, on-me entries) whose ids hold
|
|
51
91
|
the id; --match matches the exact title (or question). More than one
|
|
52
92
|
match is refused. No match creates the entry (an item needs --group).
|
|
93
|
+
- On a lane item or a group item, --ids with --id X keeps X in the ids:
|
|
94
|
+
the list as given when it holds X, else X first and then the list, so
|
|
95
|
+
a later --id X still finds the item.
|
|
53
96
|
- --status done also appends the item to the done list.
|
|
97
|
+
- Lanes: a lane key is a lowercase slug. The "${MAIN_LANE_KEY}" lane is always shown,
|
|
98
|
+
first; any other lane a writer adds is shown after it, in board order.
|
|
99
|
+
A lane with no running item and an empty queue reads as idle.
|
|
100
|
+
- patch --lane: no match adds the item (queued last, or as the running
|
|
101
|
+
item with --slot running); a lane that does not exist yet is added.
|
|
102
|
+
--slot running is refused while the lane already has a running item.
|
|
103
|
+
- Writing a lane or a notice, done and done --prune make the board schema
|
|
104
|
+
v${BOARD_SCHEMA_VERSION}; v1 groups stay.
|
|
105
|
+
- notice: with no --id a new notice gets one from the clock; a new notice
|
|
106
|
+
needs --kind and --text. Kinds: ${FOR_ME_KINDS.join(' | ')}.
|
|
54
107
|
|
|
55
108
|
Exit codes:
|
|
56
109
|
0 ok | 1 usage error or schema refusal | 2 operational failure | 3 IO failure
|
|
@@ -69,9 +122,13 @@ const FLAGS = {
|
|
|
69
122
|
patch: [
|
|
70
123
|
'agent', 'headline', 'id', 'match', 'on-me', 'title', 'status', 'where', 'stage', 'note',
|
|
71
124
|
'ids', 'link', 'group', 'question', 'options', 'workstream', 'remove', 'add-done',
|
|
125
|
+
'lane', 'lane-title', 'slot', 'after', 'project', 'started', 'estimate',
|
|
72
126
|
],
|
|
127
|
+
lane: ['agent', 'key', 'title', 'paused', 'resume', 'file', 'remove'],
|
|
128
|
+
notice: ['agent', 'id', 'kind', 'text', 'link', 'dismiss', 'undismiss', 'remove'],
|
|
129
|
+
done: ['agent', 'id', 'title', 'ids', 'project', 'note', 'ended', 'prune'],
|
|
73
130
|
};
|
|
74
|
-
const BOOLEAN_FLAGS = new Set(['json', 'on-me', 'remove']);
|
|
131
|
+
const BOOLEAN_FLAGS = new Set(['json', 'on-me', 'remove', 'paused', 'resume', 'dismiss', 'undismiss', 'prune']);
|
|
75
132
|
|
|
76
133
|
/**
|
|
77
134
|
* @param {string[]} argv subverb and its flags (argv after `status`)
|
|
@@ -86,11 +143,13 @@ export async function run(argv, opts = {}) {
|
|
|
86
143
|
if (!SUBVERBS.includes(verb)) throw new UsageError('unknown subverb: ' + verb);
|
|
87
144
|
const flags = parseFlags(argv.slice(1), FLAGS[verb]);
|
|
88
145
|
const repoRoot = opts.repoRoot ?? await getRepoRoot();
|
|
89
|
-
const agent =
|
|
90
|
-
const file = boardPath(repoRoot, agent);
|
|
146
|
+
const { agent, file } = pickBoard(flags.agent, repoRoot);
|
|
91
147
|
const ctx = { repoRoot, agent, file, flags, out, err, now: opts.now ?? (() => new Date()), stdin: opts.stdin };
|
|
92
148
|
if (verb === 'set') return await doSet(ctx);
|
|
93
149
|
if (verb === 'patch') return await doPatch(ctx);
|
|
150
|
+
if (verb === 'lane') return await doLane(ctx);
|
|
151
|
+
if (verb === 'notice') return await doNotice(ctx);
|
|
152
|
+
if (verb === 'done') return await doDone(ctx);
|
|
94
153
|
return await doShow(ctx);
|
|
95
154
|
} catch (e) {
|
|
96
155
|
if (e instanceof BoardError) {
|
|
@@ -104,7 +163,7 @@ export async function run(argv, opts = {}) {
|
|
|
104
163
|
}
|
|
105
164
|
}
|
|
106
165
|
|
|
107
|
-
function parseFlags(args, allowed) {
|
|
166
|
+
export function parseFlags(args, allowed) {
|
|
108
167
|
const flags = {};
|
|
109
168
|
for (let i = 0; i < args.length; i++) {
|
|
110
169
|
const a = args[i];
|
|
@@ -117,25 +176,54 @@ function parseFlags(args, allowed) {
|
|
|
117
176
|
flags[key] = true;
|
|
118
177
|
continue;
|
|
119
178
|
}
|
|
120
|
-
if (eq !== -1) {
|
|
179
|
+
if (eq !== -1) {
|
|
180
|
+
const v = a.slice(eq + 1);
|
|
181
|
+
if (v.startsWith('--')) throw new UsageError('--' + key + ' needs a value, got the option ' + v + '; nothing written');
|
|
182
|
+
flags[key] = v;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
121
185
|
const v = args[i + 1];
|
|
122
186
|
if (v === undefined) throw new UsageError('--' + key + ' needs a value');
|
|
187
|
+
if (v.startsWith('--')) throw new UsageError('--' + key + ' needs a value, got the option ' + v + '; nothing written');
|
|
123
188
|
flags[key] = v;
|
|
124
189
|
i += 1;
|
|
125
190
|
}
|
|
126
191
|
return flags;
|
|
127
192
|
}
|
|
128
193
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
194
|
+
/**
|
|
195
|
+
* The agent and its board file, through the one resolver every board reader
|
|
196
|
+
* and writer uses (resolveAgentBoard): --agent when given, else the
|
|
197
|
+
* configured agent. A bad name or a board path that leads out of
|
|
198
|
+
* agents/<agent>/workspace/ is a usage error (exit 1) before anything is
|
|
199
|
+
* read or written.
|
|
200
|
+
*/
|
|
201
|
+
function pickBoard(flag, repoRoot) {
|
|
202
|
+
if (flag !== undefined && !isValidAgentName(flag)) throw new UsageError('--agent must be a lowercase agent folder name, got "' + flag + '"');
|
|
203
|
+
const r = resolveAgentBoard(repoRoot, flag);
|
|
204
|
+
if (!r.ok) {
|
|
205
|
+
const from = flag === undefined ? ' (the agent from preferences)' : '';
|
|
206
|
+
throw new UsageError(r.message + from + ', got "' + r.agent + '"; nothing was read or written');
|
|
207
|
+
}
|
|
208
|
+
return r;
|
|
133
209
|
}
|
|
134
210
|
|
|
135
211
|
function stamp(now) {
|
|
136
212
|
return now().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
137
213
|
}
|
|
138
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Run a verb's read, change and write inside the board lock. The resolver's
|
|
217
|
+
* fence is checked again once the lock is held, so the path cannot have
|
|
218
|
+
* been swapped for a symlink in between.
|
|
219
|
+
*/
|
|
220
|
+
function locked(ctx, fn) {
|
|
221
|
+
return withBoardLock(ctx.file, () => {
|
|
222
|
+
pickBoard(ctx.flags.agent, ctx.repoRoot);
|
|
223
|
+
return fn();
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
139
227
|
async function readStdin(given) {
|
|
140
228
|
if (typeof given === 'string') return given;
|
|
141
229
|
const chunks = [];
|
|
@@ -163,8 +251,10 @@ async function doSet(ctx) {
|
|
|
163
251
|
if (board === null || typeof board !== 'object' || Array.isArray(board)) {
|
|
164
252
|
throw new BoardError(['board must be a JSON object']);
|
|
165
253
|
}
|
|
166
|
-
|
|
167
|
-
|
|
254
|
+
const written = await locked(ctx, () => {
|
|
255
|
+
board.updatedAt = stamp(ctx.now);
|
|
256
|
+
return writeBoardAtomic(ctx.file, withDefaults(board));
|
|
257
|
+
});
|
|
168
258
|
ctx.out.write('Board set for ' + ctx.agent + ' at ' + boardRelPath(ctx.agent) + ' (' + counts(written) + ').\n');
|
|
169
259
|
return 0;
|
|
170
260
|
}
|
|
@@ -174,7 +264,8 @@ async function doSet(ctx) {
|
|
|
174
264
|
function withDefaults(board) {
|
|
175
265
|
const { updatedAt, ...rest } = board;
|
|
176
266
|
const out = { updatedAt, ...rest };
|
|
177
|
-
|
|
267
|
+
const keys = out.schemaVersion === BOARD_SCHEMA_VERSION ? ['onMe', 'forMe', 'lanes', 'done'] : ['onMe', 'groups', 'done'];
|
|
268
|
+
for (const k of keys) {
|
|
178
269
|
if (out[k] === undefined && !(k === 'onMe' && 'onBaz' in out)) out[k] = [];
|
|
179
270
|
}
|
|
180
271
|
return out;
|
|
@@ -201,6 +292,11 @@ async function doShow(ctx) {
|
|
|
201
292
|
lines.push('On me (' + onMe.length + ')');
|
|
202
293
|
if (!onMe.length) lines.push(' nothing');
|
|
203
294
|
for (const a of onMe) lines.push(' - ' + a.question + idsSuffix(a.ids));
|
|
295
|
+
if (b.schemaVersion === BOARD_SCHEMA_VERSION) {
|
|
296
|
+
showV2(toBoardV2(b), lines);
|
|
297
|
+
ctx.out.write(lines.join('\n') + '\n');
|
|
298
|
+
return 0;
|
|
299
|
+
}
|
|
204
300
|
for (const g of b.groups || []) {
|
|
205
301
|
lines.push('');
|
|
206
302
|
lines.push(g.label || 'Untitled workstream');
|
|
@@ -216,11 +312,37 @@ async function doShow(ctx) {
|
|
|
216
312
|
return 0;
|
|
217
313
|
}
|
|
218
314
|
|
|
315
|
+
function showV2(v2, lines) {
|
|
316
|
+
const notices = v2.forMe.filter((n) => !n.dismissedAt);
|
|
317
|
+
lines.push('');
|
|
318
|
+
lines.push('For me (' + notices.length + ')');
|
|
319
|
+
if (!notices.length) lines.push(' nothing');
|
|
320
|
+
for (const n of notices) lines.push(' - [' + n.kind + '] ' + n.text + ' (' + n.id + ')');
|
|
321
|
+
for (const l of v2.lanes) {
|
|
322
|
+
lines.push('');
|
|
323
|
+
lines.push(l.title + ' [' + l.key + ']' + (l.state === 'paused' ? ' (paused)' : ''));
|
|
324
|
+
if (!l.running && !l.queued.length) lines.push(' ' + (l.state === 'paused' ? 'lane paused' : 'lane idle'));
|
|
325
|
+
if (l.running) lines.push(' [running] ' + l.running.title + idsSuffix(l.running.ids));
|
|
326
|
+
for (const it of l.queued) {
|
|
327
|
+
lines.push(' [' + (it.status || 'queued') + '] ' + it.title + idsSuffix(it.ids) + (it.after ? ' after ' + it.after : ''));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
lines.push('');
|
|
331
|
+
lines.push('Done, last ' + DONE_WINDOW_HOURS + ' hours (' + v2.done.length + ')');
|
|
332
|
+
for (const it of v2.done) lines.push(' - ' + it.title + idsSuffix(it.ids));
|
|
333
|
+
}
|
|
334
|
+
|
|
219
335
|
function idsSuffix(ids) {
|
|
220
336
|
return Array.isArray(ids) && ids.length ? ' (' + ids.join(', ') + ')' : '';
|
|
221
337
|
}
|
|
222
338
|
|
|
223
339
|
function counts(b) {
|
|
340
|
+
if (b.schemaVersion === BOARD_SCHEMA_VERSION) {
|
|
341
|
+
const lanes = b.lanes || [];
|
|
342
|
+
const items = lanes.reduce((n, l) => n + (l.running ? 1 : 0) + (l.queued || []).length, 0);
|
|
343
|
+
return lanes.length + ' lanes, ' + items + ' items, ' + (b.onMe || []).length + ' on me, '
|
|
344
|
+
+ (b.forMe || []).length + ' for me, ' + (b.done || []).length + ' done';
|
|
345
|
+
}
|
|
224
346
|
const items = (b.groups || []).reduce((n, g) => n + g.items.length, 0);
|
|
225
347
|
return (b.groups || []).length + ' groups, ' + items + ' items, ' + (b.onMe || []).length + ' on me, ' + (b.done || []).length + ' done';
|
|
226
348
|
}
|
|
@@ -242,8 +364,13 @@ async function loadExisting(ctx, opts) {
|
|
|
242
364
|
const ITEM_FIELDS = ['title', 'status', 'where', 'stage', 'note', 'link'];
|
|
243
365
|
const ASK_FIELDS = ['question', 'workstream', 'link'];
|
|
244
366
|
|
|
367
|
+
const LANE_ITEM_FLAGS = ['lane-title', 'slot', 'after', 'project', 'started', 'estimate'];
|
|
368
|
+
|
|
245
369
|
async function doPatch(ctx) {
|
|
246
370
|
const f = ctx.flags;
|
|
371
|
+
if (f.lane !== undefined) return doPatchLane(ctx);
|
|
372
|
+
const strayLane = LANE_ITEM_FLAGS.find((k) => f[k] !== undefined);
|
|
373
|
+
if (strayLane) throw new UsageError('--' + strayLane + ' applies to lane items; give --lane <key>');
|
|
247
374
|
const hasTarget = f.id !== undefined || f.match !== undefined;
|
|
248
375
|
if (f.id !== undefined && f.match !== undefined) throw new UsageError('give --id or --match, not both');
|
|
249
376
|
if (!hasTarget) {
|
|
@@ -264,26 +391,28 @@ async function doPatch(ctx) {
|
|
|
264
391
|
if (bad) throw new UsageError('--' + bad + ' applies to --on-me entries');
|
|
265
392
|
}
|
|
266
393
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
394
|
+
return locked(ctx, async () => {
|
|
395
|
+
const parsed = await loadExisting(ctx, { strictStatus: true });
|
|
396
|
+
if (parsed.problems.length) throw new OpError('board on disk is invalid; fix it with `pa status set`:\n - ' + parsed.problems.join('\n - '));
|
|
397
|
+
const board = structuredClone(parsed.board);
|
|
398
|
+
board.onMe = board.onMe || [];
|
|
399
|
+
board.groups = board.groups || [];
|
|
400
|
+
board.done = board.done || [];
|
|
401
|
+
const now = stamp(ctx.now);
|
|
402
|
+
const notes = [];
|
|
403
|
+
|
|
404
|
+
if (f.headline !== undefined) { board.headline = f.headline; notes.push('headline set'); }
|
|
405
|
+
if (hasTarget) notes.push(f['on-me'] ? patchAsk(board, f, now) : patchItem(board, f, now));
|
|
406
|
+
if (f['add-done'] !== undefined) {
|
|
407
|
+
board.done.push(doneEntry({ title: f['add-done'], ids: splitList(f.ids, ',') || [] }, f.workstream, now));
|
|
408
|
+
notes.push('done entry added');
|
|
409
|
+
}
|
|
282
410
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
411
|
+
board.updatedAt = now;
|
|
412
|
+
await writeBoardAtomic(ctx.file, board);
|
|
413
|
+
ctx.out.write('Board patched for ' + ctx.agent + ': ' + notes.join('; ') + '.\n');
|
|
414
|
+
return 0;
|
|
415
|
+
});
|
|
287
416
|
}
|
|
288
417
|
|
|
289
418
|
function normaliseStatusValue(s) {
|
|
@@ -316,7 +445,7 @@ function patchItem(board, f, now) {
|
|
|
316
445
|
if (f.group === undefined) throw new UsageError('no item matches; to add one, give --group');
|
|
317
446
|
const title = f.title ?? f.match;
|
|
318
447
|
if (title === undefined) throw new UsageError('no item matches --id ' + f.id + '; to add one, give --title and --group');
|
|
319
|
-
const ids =
|
|
448
|
+
const ids = keepIdFirst(splitList(f.ids, ',') || [], f.id);
|
|
320
449
|
const item = {
|
|
321
450
|
title,
|
|
322
451
|
status: normaliseStatusValue(f.status ?? 'queued'),
|
|
@@ -343,7 +472,7 @@ function patchItem(board, f, now) {
|
|
|
343
472
|
for (const k of ITEM_FIELDS) {
|
|
344
473
|
if (f[k] !== undefined) item[k] = k === 'status' ? normaliseStatusValue(f[k]) : f[k];
|
|
345
474
|
}
|
|
346
|
-
if (f.ids !== undefined) item.ids = splitList(f.ids, ',');
|
|
475
|
+
if (f.ids !== undefined) item.ids = keepIdFirst(splitList(f.ids, ','), f.id);
|
|
347
476
|
item.updatedAt = now;
|
|
348
477
|
let label = fromGroup.label;
|
|
349
478
|
if (f.group !== undefined && f.group !== fromGroup.label) {
|
|
@@ -397,6 +526,18 @@ function mergeIds(ids, id) {
|
|
|
397
526
|
return ids;
|
|
398
527
|
}
|
|
399
528
|
|
|
529
|
+
/**
|
|
530
|
+
* The ids of a lane item or a group item written with --ids: the list as
|
|
531
|
+
* given when it holds the --id, or the --id first and then the list when it
|
|
532
|
+
* does not, so a later --id still finds the item. With no --id (a --match
|
|
533
|
+
* write), the list as given. On-me and done entries keep mergeIds.
|
|
534
|
+
*/
|
|
535
|
+
export function keepIdFirst(ids, id) {
|
|
536
|
+
const list = Array.isArray(ids) ? ids : [];
|
|
537
|
+
if (id === undefined || list.includes(id)) return list;
|
|
538
|
+
return [id, ...list];
|
|
539
|
+
}
|
|
540
|
+
|
|
400
541
|
function groupFor(board, label) {
|
|
401
542
|
let g = board.groups.find((x) => x.label === label);
|
|
402
543
|
if (!g) {
|
|
@@ -423,3 +564,350 @@ function addDoneOnce(board, item, workstream, now) {
|
|
|
423
564
|
if (board.done.some((d) => d.title === item.title)) return;
|
|
424
565
|
board.done.push(doneEntry(item, workstream, now));
|
|
425
566
|
}
|
|
567
|
+
|
|
568
|
+
// -- schema v2 writes -----------------------------------------------------
|
|
569
|
+
|
|
570
|
+
/** Load the board for a v2 write: valid on disk, cloned, v2 sections present. */
|
|
571
|
+
async function loadForV2Write(ctx) {
|
|
572
|
+
const parsed = await loadExisting(ctx, { strictStatus: true });
|
|
573
|
+
if (parsed.problems.length) throw new OpError('board on disk is invalid; fix it with `pa status set`:\n - ' + parsed.problems.join('\n - '));
|
|
574
|
+
return asV2(structuredClone(parsed.board));
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* The board on disk made schema v2 for a write: schemaVersion first, then
|
|
579
|
+
* the keys as they were, with onMe, forMe, lanes and done present. v1
|
|
580
|
+
* groups are kept as they are.
|
|
581
|
+
*/
|
|
582
|
+
export function asV2(board) {
|
|
583
|
+
const { schemaVersion: _v, ...rest } = board;
|
|
584
|
+
const out = { schemaVersion: BOARD_SCHEMA_VERSION, ...rest };
|
|
585
|
+
for (const k of ['onMe', 'forMe', 'lanes', 'done']) if (!Array.isArray(out[k])) out[k] = [];
|
|
586
|
+
return out;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async function writeV2(ctx, board, note) {
|
|
590
|
+
board.updatedAt = stamp(ctx.now);
|
|
591
|
+
await writeBoardAtomic(ctx.file, board);
|
|
592
|
+
ctx.out.write('Board patched for ' + ctx.agent + ': ' + note + '.\n');
|
|
593
|
+
return 0;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function needKey(key, flag) {
|
|
597
|
+
if (key === undefined) throw new UsageError(flag + ' is required');
|
|
598
|
+
if (!isValidLaneKey(key)) throw new UsageError(flag + ' must be a lowercase slug (letters, digits, hyphens), got "' + key + '"');
|
|
599
|
+
return key;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** The lane with this key, added (last; main first) when the board has none. */
|
|
603
|
+
function laneFor(board, key, title) {
|
|
604
|
+
let lane = board.lanes.find((l) => l.key === key);
|
|
605
|
+
if (!lane) {
|
|
606
|
+
lane = { key, title: title ?? (key === MAIN_LANE_KEY ? MAIN_LANE_TITLE : key), queued: [] };
|
|
607
|
+
if (key === MAIN_LANE_KEY) board.lanes.unshift(lane);
|
|
608
|
+
else board.lanes.push(lane);
|
|
609
|
+
}
|
|
610
|
+
if (!Array.isArray(lane.queued)) lane.queued = [];
|
|
611
|
+
return lane;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// An ISO 8601 date-time with a zone: date, T, hours and minutes (seconds
|
|
615
|
+
// and a fraction optional), then Z or a +hh:mm or -hh:mm offset.
|
|
616
|
+
const ISO_DATE_TIME_RE = /^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):([0-5]\d)(?::([0-5]\d)(\.\d+)?)?(Z|([+-])([01]\d|2[0-3]):([0-5]\d))$/;
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* An ISO 8601 date-time with a zone on a real calendar date. Date.parse
|
|
620
|
+
* alone rolls 2030-02-31 over into March, so the parsed instant is moved
|
|
621
|
+
* back into the input's own offset and its year, month, day, hour, minute
|
|
622
|
+
* and second must read back as the input's.
|
|
623
|
+
*/
|
|
624
|
+
export function isIsoDateTime(v) {
|
|
625
|
+
if (typeof v !== 'string') return false;
|
|
626
|
+
const m = ISO_DATE_TIME_RE.exec(v);
|
|
627
|
+
if (!m) return false;
|
|
628
|
+
const ms = Date.parse(v);
|
|
629
|
+
if (Number.isNaN(ms)) return false;
|
|
630
|
+
const offsetMin = m[8] === 'Z' ? 0 : (m[9] === '-' ? -1 : 1) * (Number(m[10]) * 60 + Number(m[11]));
|
|
631
|
+
const local = new Date(ms + offsetMin * 60_000);
|
|
632
|
+
return local.getUTCFullYear() === Number(m[1])
|
|
633
|
+
&& local.getUTCMonth() + 1 === Number(m[2])
|
|
634
|
+
&& local.getUTCDate() === Number(m[3])
|
|
635
|
+
&& local.getUTCHours() === Number(m[4])
|
|
636
|
+
&& local.getUTCMinutes() === Number(m[5])
|
|
637
|
+
&& local.getUTCSeconds() === Number(m[6] ?? 0);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function checkIsoFlag(v, flag) {
|
|
641
|
+
if (v !== undefined && !isIsoDateTime(v)) {
|
|
642
|
+
throw new UsageError(flag + ' must be an ISO timestamp: an ISO 8601 date-time with a zone on a real calendar date, like 2030-01-15T09:30:00Z; got "' + v + '"');
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** Every item held in a lane (running or queued) that the --id or --match picks. */
|
|
647
|
+
function laneHits(board, f) {
|
|
648
|
+
const hits = [];
|
|
649
|
+
for (const lane of board.lanes) {
|
|
650
|
+
const test = (it) => (f.id !== undefined
|
|
651
|
+
? Array.isArray(it.ids) && it.ids.includes(f.id)
|
|
652
|
+
: String(it.title).trim() === String(f.match).trim());
|
|
653
|
+
if (lane.running && test(lane.running)) hits.push({ lane, slot: 'running', item: lane.running });
|
|
654
|
+
(lane.queued || []).forEach((it, i) => { if (test(it)) hits.push({ lane, slot: 'queued', index: i, item: it }); });
|
|
655
|
+
}
|
|
656
|
+
return hits;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function takeOut(hit) {
|
|
660
|
+
if (hit.slot === 'running') delete hit.lane.running;
|
|
661
|
+
else hit.lane.queued.splice(hit.lane.queued.indexOf(hit.item), 1);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Every open group item (status anything but done) holding the id: the
|
|
666
|
+
* items the read side carries into Main when no lane holds them.
|
|
667
|
+
*/
|
|
668
|
+
function openGroupHits(board, id) {
|
|
669
|
+
const hits = [];
|
|
670
|
+
for (const group of Array.isArray(board.groups) ? board.groups : []) {
|
|
671
|
+
for (const item of Array.isArray(group.items) ? group.items : []) {
|
|
672
|
+
if (item.status !== 'done' && Array.isArray(item.ids) && item.ids.includes(id)) hits.push({ group, item });
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return hits;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async function doPatchLane(ctx) {
|
|
679
|
+
const f = ctx.flags;
|
|
680
|
+
const key = needKey(f.lane, '--lane');
|
|
681
|
+
if (f.id !== undefined && f.match !== undefined) throw new UsageError('give --id or --match, not both');
|
|
682
|
+
if (f.id === undefined && f.match === undefined) throw new UsageError('patch --lane needs a target (--id or --match)');
|
|
683
|
+
const bad = ['on-me', 'group', 'question', 'options', 'add-done', 'headline', 'workstream'].find((k) => f[k] !== undefined);
|
|
684
|
+
if (bad) throw new UsageError('--' + bad + ' does not apply with --lane');
|
|
685
|
+
if (f.slot !== undefined && !['running', 'queued'].includes(f.slot)) throw new UsageError('--slot must be running or queued');
|
|
686
|
+
if (f.status !== undefined && !STATUS_VALUES.includes(normaliseStatusValue(f.status))) {
|
|
687
|
+
throw new UsageError('--status must be one of ' + STATUS_VALUES.join(', '));
|
|
688
|
+
}
|
|
689
|
+
checkIsoFlag(f.started, '--started');
|
|
690
|
+
checkIsoFlag(f.estimate, '--estimate');
|
|
691
|
+
return locked(ctx, () => patchLaneLocked(ctx, key));
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async function patchLaneLocked(ctx, key) {
|
|
695
|
+
const f = ctx.flags;
|
|
696
|
+
const board = await loadForV2Write(ctx);
|
|
697
|
+
const now = stamp(ctx.now);
|
|
698
|
+
const hits = laneHits(board, f);
|
|
699
|
+
if (hits.length > 1) throw refusal(hits, (h) => '"' + h.item.title + '" in lane ' + h.lane.key + ' (' + h.slot + ')');
|
|
700
|
+
let hit = hits[0];
|
|
701
|
+
if (f.remove) {
|
|
702
|
+
if (!hit) throw new OpError('no lane item matches; nothing to remove');
|
|
703
|
+
takeOut(hit);
|
|
704
|
+
return writeV2(ctx, board, 'item "' + hit.item.title + '" removed from lane ' + hit.lane.key);
|
|
705
|
+
}
|
|
706
|
+
const lane = laneFor(board, key, f['lane-title']);
|
|
707
|
+
if (f['lane-title'] !== undefined) lane.title = f['lane-title'];
|
|
708
|
+
|
|
709
|
+
let note;
|
|
710
|
+
if (!hit) {
|
|
711
|
+
const title = f.title ?? f.match;
|
|
712
|
+
if (title === undefined) throw new UsageError('no item in lane ' + key + ' matches --id ' + f.id + '; to add one, give --title');
|
|
713
|
+
const item = { title, ids: keepIdFirst(splitList(f.ids, ',') || [], f.id) };
|
|
714
|
+
hit = { lane, slot: null, item };
|
|
715
|
+
note = 'item "' + title + '" added to lane ' + key;
|
|
716
|
+
} else if (hit.lane !== lane) {
|
|
717
|
+
note = 'item "' + hit.item.title + '" moved from lane ' + hit.lane.key + ' to lane ' + key;
|
|
718
|
+
takeOut(hit);
|
|
719
|
+
hit = { lane, slot: null, item: hit.item };
|
|
720
|
+
} else {
|
|
721
|
+
note = 'item "' + hit.item.title + '" updated in lane ' + key;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const item = hit.item;
|
|
725
|
+
for (const k of ['title', 'where', 'stage', 'note', 'link', 'after', 'project']) if (f[k] !== undefined) item[k] = f[k];
|
|
726
|
+
if (f.status !== undefined) item.status = normaliseStatusValue(f.status);
|
|
727
|
+
if (f.ids !== undefined && hits.length) item.ids = keepIdFirst(splitList(f.ids, ','), f.id);
|
|
728
|
+
if (f.started !== undefined) item.startedAt = f.started;
|
|
729
|
+
if (f.estimate !== undefined) item.estimatedFinishAt = f.estimate;
|
|
730
|
+
item.updatedAt = now;
|
|
731
|
+
|
|
732
|
+
if (item.status === 'done') {
|
|
733
|
+
if (hit.slot !== null) takeOut(hit);
|
|
734
|
+
const entry = { ...item, endedAt: item.endedAt ?? now };
|
|
735
|
+
delete entry.after;
|
|
736
|
+
if (!board.done.some((d) => d.title === entry.title && sameIds(d.ids, entry.ids))) board.done.push(entry);
|
|
737
|
+
return writeV2(ctx, board, note + '; moved to done');
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const slot = f.slot ?? hit.slot ?? 'queued';
|
|
741
|
+
if (slot !== hit.slot) {
|
|
742
|
+
if (slot === 'running' && lane.running && lane.running !== item) {
|
|
743
|
+
throw new OpError('lane ' + key + ' already has a running item ("' + lane.running.title + '"); move it on first');
|
|
744
|
+
}
|
|
745
|
+
if (hit.slot !== null) takeOut(hit);
|
|
746
|
+
if (slot === 'running') {
|
|
747
|
+
delete item.after;
|
|
748
|
+
if (item.startedAt === undefined) item.startedAt = now;
|
|
749
|
+
lane.running = item;
|
|
750
|
+
} else {
|
|
751
|
+
lane.queued.push(item);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
return writeV2(ctx, board, note);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function sameIds(a, b) {
|
|
758
|
+
return JSON.stringify(a || []) === JSON.stringify(b || []);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** The JSON a `pa status lane --file` reads: { title?, state?, running?, queued? }. */
|
|
762
|
+
export function parseLaneFile(text) {
|
|
763
|
+
let body;
|
|
764
|
+
try { body = JSON.parse(text); } catch (e) { throw new UsageError('lane file is not valid JSON: ' + e.message); }
|
|
765
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new UsageError('lane file must be a JSON object');
|
|
766
|
+
const extra = Object.keys(body).filter((k) => !['title', 'state', 'running', 'queued'].includes(k));
|
|
767
|
+
if (extra.length) throw new UsageError('lane file key "' + extra[0] + '" is not one of title, state, running, queued');
|
|
768
|
+
return body;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
async function doLane(ctx) {
|
|
772
|
+
const f = ctx.flags;
|
|
773
|
+
const key = needKey(f.key, '--key');
|
|
774
|
+
if (f.paused && f.resume) throw new UsageError('give --paused or --resume, not both');
|
|
775
|
+
if (f.remove && Object.keys(f).some((k) => !['agent', 'key', 'remove'].includes(k))) {
|
|
776
|
+
throw new UsageError('--remove takes no other lane flag');
|
|
777
|
+
}
|
|
778
|
+
let fromFile;
|
|
779
|
+
if (f.file !== undefined) {
|
|
780
|
+
let text;
|
|
781
|
+
if (f.file === '-') text = await readStdin(ctx.stdin);
|
|
782
|
+
else {
|
|
783
|
+
const { readFile } = await import('node:fs/promises');
|
|
784
|
+
text = await readFile(f.file, 'utf8');
|
|
785
|
+
}
|
|
786
|
+
fromFile = parseLaneFile(text);
|
|
787
|
+
}
|
|
788
|
+
return locked(ctx, () => laneLocked(ctx, key, fromFile));
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
async function laneLocked(ctx, key, fromFile) {
|
|
792
|
+
const f = ctx.flags;
|
|
793
|
+
const board = await loadForV2Write(ctx);
|
|
794
|
+
if (f.remove) {
|
|
795
|
+
const i = board.lanes.findIndex((l) => l.key === key);
|
|
796
|
+
if (i === -1) throw new OpError('no lane ' + key + ' on the board; nothing to remove');
|
|
797
|
+
board.lanes.splice(i, 1);
|
|
798
|
+
return writeV2(ctx, board, 'lane ' + key + ' removed');
|
|
799
|
+
}
|
|
800
|
+
const existed = board.lanes.some((l) => l.key === key);
|
|
801
|
+
const lane = laneFor(board, key, f.title ?? (fromFile && fromFile.title));
|
|
802
|
+
if (fromFile) {
|
|
803
|
+
if (fromFile.title !== undefined) lane.title = fromFile.title;
|
|
804
|
+
if (fromFile.state === undefined || fromFile.state === null) delete lane.state;
|
|
805
|
+
else lane.state = fromFile.state;
|
|
806
|
+
if (fromFile.running === undefined || fromFile.running === null) delete lane.running;
|
|
807
|
+
else lane.running = fromFile.running;
|
|
808
|
+
lane.queued = fromFile.queued === undefined ? [] : fromFile.queued;
|
|
809
|
+
}
|
|
810
|
+
if (f.title !== undefined) lane.title = f.title;
|
|
811
|
+
if (f.paused) lane.state = 'paused';
|
|
812
|
+
if (f.resume) delete lane.state;
|
|
813
|
+
return writeV2(ctx, board, 'lane ' + key + (existed ? ' updated' : ' added'));
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function noticeId(now) {
|
|
817
|
+
return 'n-' + now().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z').replace('T', '-').toLowerCase();
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
async function doNotice(ctx) {
|
|
821
|
+
const f = ctx.flags;
|
|
822
|
+
const actions = ['dismiss', 'undismiss', 'remove'].filter((k) => f[k]);
|
|
823
|
+
if (actions.length > 1) throw new UsageError('give one of --dismiss, --undismiss, --remove');
|
|
824
|
+
if (f.kind !== undefined && !FOR_ME_KINDS.includes(f.kind)) {
|
|
825
|
+
throw new UsageError('--kind must be one of ' + FOR_ME_KINDS.join(', ') + ' (For me is for risk, money or security only)');
|
|
826
|
+
}
|
|
827
|
+
if (actions.length && f.id === undefined) throw new UsageError('--' + actions[0] + ' needs --id');
|
|
828
|
+
return locked(ctx, () => noticeLocked(ctx, actions));
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async function noticeLocked(ctx, actions) {
|
|
832
|
+
const f = ctx.flags;
|
|
833
|
+
const board = await loadForV2Write(ctx);
|
|
834
|
+
const now = stamp(ctx.now);
|
|
835
|
+
const notice = f.id === undefined ? undefined : board.forMe.find((n) => n.id === f.id);
|
|
836
|
+
if (!notice) {
|
|
837
|
+
if (actions.length) throw new OpError('no For-me notice with id ' + f.id);
|
|
838
|
+
if (f.kind === undefined || f.text === undefined) throw new UsageError('a new notice needs --kind and --text');
|
|
839
|
+
const id = f.id ?? noticeId(ctx.now);
|
|
840
|
+
const n = { id, kind: f.kind, text: f.text };
|
|
841
|
+
if (f.link !== undefined) n.link = f.link;
|
|
842
|
+
board.forMe.push(n);
|
|
843
|
+
return writeV2(ctx, board, 'notice ' + id + ' added');
|
|
844
|
+
}
|
|
845
|
+
if (f.remove) {
|
|
846
|
+
board.forMe.splice(board.forMe.indexOf(notice), 1);
|
|
847
|
+
return writeV2(ctx, board, 'notice ' + f.id + ' removed');
|
|
848
|
+
}
|
|
849
|
+
for (const k of ['kind', 'text', 'link']) if (f[k] !== undefined) notice[k] = f[k];
|
|
850
|
+
if (f.dismiss && !notice.dismissedAt) notice.dismissedAt = now;
|
|
851
|
+
if (f.undismiss) delete notice.dismissedAt;
|
|
852
|
+
return writeV2(ctx, board, 'notice ' + f.id + (f.dismiss ? ' dismissed' : f.undismiss ? ' shown again' : ' updated'));
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
async function doDone(ctx) {
|
|
856
|
+
const f = ctx.flags;
|
|
857
|
+
if (f.prune) {
|
|
858
|
+
const other = Object.keys(f).filter((k) => !['agent', 'prune'].includes(k));
|
|
859
|
+
if (other.length) throw new UsageError('--prune takes no other flag');
|
|
860
|
+
} else {
|
|
861
|
+
if (f.id === undefined && f.title === undefined) throw new UsageError('done needs --id, --title or --prune');
|
|
862
|
+
}
|
|
863
|
+
checkIsoFlag(f.ended, '--ended');
|
|
864
|
+
return locked(ctx, () => doneLocked(ctx));
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* done --id moves the item holding the id: the lane item, else the open
|
|
869
|
+
* group item a v1 board (or a group item no lane holds) puts in Main. Every
|
|
870
|
+
* open group item holding the id leaves its group too, so nothing holding
|
|
871
|
+
* it is carried back into Main; the item lands in done once.
|
|
872
|
+
*/
|
|
873
|
+
async function doneLocked(ctx) {
|
|
874
|
+
const f = ctx.flags;
|
|
875
|
+
const board = await loadForV2Write(ctx);
|
|
876
|
+
const now = stamp(ctx.now);
|
|
877
|
+
if (f.prune) {
|
|
878
|
+
const before = board.done.length;
|
|
879
|
+
board.done = toBoardV2({ ...board, updatedAt: now }).done;
|
|
880
|
+
return writeV2(ctx, board, (before - board.done.length) + ' done entries older than ' + DONE_WINDOW_HOURS + ' hours dropped');
|
|
881
|
+
}
|
|
882
|
+
const hits = f.id === undefined ? [] : laneHits(board, { id: f.id });
|
|
883
|
+
if (hits.length > 1) throw refusal(hits, (h) => '"' + h.item.title + '" in lane ' + h.lane.key);
|
|
884
|
+
const groupHits = f.id === undefined ? [] : openGroupHits(board, f.id);
|
|
885
|
+
if (!hits.length && groupHits.length > 1) {
|
|
886
|
+
throw refusal(groupHits, (h) => '"' + h.item.title + '" in group ' + (h.group.label || 'with no label'));
|
|
887
|
+
}
|
|
888
|
+
let entry;
|
|
889
|
+
let from;
|
|
890
|
+
if (hits.length || groupHits.length) {
|
|
891
|
+
if (hits.length) takeOut(hits[0]);
|
|
892
|
+
for (const h of groupHits) h.group.items.splice(h.group.items.indexOf(h.item), 1);
|
|
893
|
+
const base = hits.length ? hits[0].item : groupHits[0].item;
|
|
894
|
+
entry = { ...base };
|
|
895
|
+
delete entry.after;
|
|
896
|
+
entry.status = 'done';
|
|
897
|
+
if (!hits.length && typeof groupHits[0].group.label === 'string' && groupHits[0].group.label && entry.workstream === undefined) {
|
|
898
|
+
entry.workstream = groupHits[0].group.label;
|
|
899
|
+
}
|
|
900
|
+
from = hits.length ? 'lane ' + hits[0].lane.key : 'group ' + (groupHits[0].group.label || 'with no label');
|
|
901
|
+
} else {
|
|
902
|
+
if (f.title === undefined) throw new OpError('no lane item or open group item holds ' + f.id + '; to add a done entry, give --title');
|
|
903
|
+
entry = { title: f.title, status: 'done', ids: mergeIds(splitList(f.ids, ',') || [], f.id) };
|
|
904
|
+
}
|
|
905
|
+
if (f.title !== undefined) entry.title = f.title;
|
|
906
|
+
if (f.project !== undefined) entry.project = f.project;
|
|
907
|
+
if (f.note !== undefined) entry.note = f.note;
|
|
908
|
+
if (f.ids !== undefined && from) entry.ids = splitList(f.ids, ',');
|
|
909
|
+
entry.endedAt = f.ended ?? now;
|
|
910
|
+
entry.updatedAt = now;
|
|
911
|
+
board.done.push(entry);
|
|
912
|
+
return writeV2(ctx, board, '"' + entry.title + '" ' + (from ? 'moved from ' + from + ' to done' : 'added to done'));
|
|
913
|
+
}
|