@stravica-ai/digital-operator-pa 0.1.66 → 0.1.67
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/src/pa/cli.mjs +8 -0
- package/dist/engineering-pa-cli/src/pa/status-board-cli.mjs +425 -0
- package/dist/engineering-pa-cli/src/pa/status-board.mjs +259 -0
- package/dist/engineering-pa-core/knowledge/delegation-dispatch/delegation-modes.md +2 -0
- package/dist/engineering-pa-core/knowledge/operator-interface/README.md +2 -0
- package/dist/engineering-pa-core/knowledge/operator-interface/session-boot-and-end.md +5 -1
- package/dist/engineering-pa-core/knowledge/work-management/README.md +2 -1
- package/dist/engineering-pa-core/knowledge/work-management/status-board.md +50 -0
- package/dist/engineering-pa-workspace/README.md +9 -0
- package/dist/engineering-pa-workspace/app/nav.json +6 -0
- package/dist/engineering-pa-workspace/app/notes.html +3 -1
- package/dist/engineering-pa-workspace/app/status-board.html +503 -0
- package/dist/engineering-pa-workspace/src/api.mjs +79 -29
- package/package.json +1 -1
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
import { resolveDefaultScope } from '../secrets/vault.mjs';
|
|
25
25
|
import { main as runMigrateInspect } from '../../../engineering-pa-migrate-inspect/src/cli.mjs';
|
|
26
26
|
import { doOnboard } from './onboard.mjs';
|
|
27
|
+
import { SUBVERBS as STATUS_BOARD_SUBVERBS, run as runStatusBoard } from './status-board-cli.mjs';
|
|
27
28
|
|
|
28
29
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
29
30
|
|
|
@@ -32,6 +33,9 @@ const USAGE = `pa - Digital Operator CLI
|
|
|
32
33
|
Usage:
|
|
33
34
|
pa session Start a PA session — chdir to agent dir, exec \`claude\`
|
|
34
35
|
pa status Probe operator content, installed package, and workspace reachability, then print a desk overview
|
|
36
|
+
pa status set [--file <p>] Replace this PA's daily status board from a JSON file or stdin
|
|
37
|
+
pa status patch [flags] Merge one board item or on-me entry, set statuses, append to done
|
|
38
|
+
pa status show [--json] Print the daily status board (never hand-edit its JSON)
|
|
35
39
|
pa workspace <verb> Workspace server lifecycle (start | stop | status | open)
|
|
36
40
|
pa project <verb> Project state writes (create | status | doc add/rm | list)
|
|
37
41
|
pa work <verb> Work-item state writes (create | status | list)
|
|
@@ -102,6 +106,10 @@ export async function main(argv) {
|
|
|
102
106
|
case 'session':
|
|
103
107
|
return await doSession(rest);
|
|
104
108
|
case 'status':
|
|
109
|
+
// Bare `pa status` (and any argv that is not a board subverb) stays
|
|
110
|
+
// the environment probe; set | patch | show write and read the daily
|
|
111
|
+
// status board.
|
|
112
|
+
if (STATUS_BOARD_SUBVERBS.includes(rest[0])) return await runStatusBoard(rest);
|
|
105
113
|
return await doStatus();
|
|
106
114
|
case 'workspace':
|
|
107
115
|
return await doWorkspace(rest);
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
// status-board-cli.mjs - argv router for `pa status set | patch | show`.
|
|
2
|
+
//
|
|
3
|
+
// Bare `pa status` (no subverb) stays the environment probe in cli.mjs;
|
|
4
|
+
// only the three subverbs below route here. Every write goes through
|
|
5
|
+
// `writeBoardAtomic` in status-board.mjs: validated first, then a temp
|
|
6
|
+
// file renamed over the board, so a refused or half-written board never
|
|
7
|
+
// lands. The PA keeps the board current through these verbs and never
|
|
8
|
+
// hand-edits the JSON.
|
|
9
|
+
|
|
10
|
+
import { getRepoRoot } from './repo-root.mjs';
|
|
11
|
+
import {
|
|
12
|
+
BoardError,
|
|
13
|
+
STATUS_VALUES,
|
|
14
|
+
boardPath,
|
|
15
|
+
boardRelPath,
|
|
16
|
+
isValidAgentName,
|
|
17
|
+
parseBoard,
|
|
18
|
+
readBoard,
|
|
19
|
+
resolveAgentName,
|
|
20
|
+
writeBoardAtomic,
|
|
21
|
+
} from './status-board.mjs';
|
|
22
|
+
|
|
23
|
+
export const SUBVERBS = ['set', 'patch', 'show'];
|
|
24
|
+
|
|
25
|
+
const USAGE = `pa status - environment probe, plus the daily status board subverbs
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
pa status <set|patch|show> --help Show this help
|
|
29
|
+
pa status Probe operator content, installed package and workspace (unchanged)
|
|
30
|
+
pa status set [--file <path>] [--agent <name>]
|
|
31
|
+
Replace the whole board from a JSON file, or stdin when --file is absent or "-"
|
|
32
|
+
pa status patch [--agent <name>] [--headline "..."]
|
|
33
|
+
[--id <id> | --match "<title or question>"] [--on-me]
|
|
34
|
+
[--title "..."] [--status <status>] [--where "..."] [--stage "..."] [--note "..."]
|
|
35
|
+
[--ids a,b] [--link <url>] [--group "<label>"]
|
|
36
|
+
[--question "..."] [--options "a|b"] [--workstream "..."] [--remove]
|
|
37
|
+
[--add-done "<title>"]
|
|
38
|
+
Merge one item or one on-me entry, set statuses, append to done
|
|
39
|
+
pa status show [--agent <name>] [--json]
|
|
40
|
+
Print the board
|
|
41
|
+
|
|
42
|
+
Notes:
|
|
43
|
+
- The board is agents/<agent>/workspace/status-board.json. The agent comes
|
|
44
|
+
from --agent, else preferences.json paName, else the single agents/*
|
|
45
|
+
folder holding next-session.md, else "dave".
|
|
46
|
+
- Every write stamps the board's updatedAt from the clock, is validated
|
|
47
|
+
against the schema and lands atomically (temp file, then rename).
|
|
48
|
+
- Status values: ${STATUS_VALUES.join(' | ')}. The legacy key and status
|
|
49
|
+
"onBaz" are read as "onMe" and written as "onMe".
|
|
50
|
+
- patch --id matches items (or with --on-me, on-me entries) whose ids hold
|
|
51
|
+
the id; --match matches the exact title (or question). More than one
|
|
52
|
+
match is refused. No match creates the entry (an item needs --group).
|
|
53
|
+
- --status done also appends the item to the done list.
|
|
54
|
+
|
|
55
|
+
Exit codes:
|
|
56
|
+
0 ok | 1 usage error or schema refusal | 2 operational failure | 3 IO failure
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
class UsageError extends Error {
|
|
60
|
+
constructor(msg) { super(msg); this.exitCode = 1; }
|
|
61
|
+
}
|
|
62
|
+
class OpError extends Error {
|
|
63
|
+
constructor(msg) { super(msg); this.exitCode = 2; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const FLAGS = {
|
|
67
|
+
set: ['file', 'agent'],
|
|
68
|
+
show: ['agent', 'json'],
|
|
69
|
+
patch: [
|
|
70
|
+
'agent', 'headline', 'id', 'match', 'on-me', 'title', 'status', 'where', 'stage', 'note',
|
|
71
|
+
'ids', 'link', 'group', 'question', 'options', 'workstream', 'remove', 'add-done',
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
const BOOLEAN_FLAGS = new Set(['json', 'on-me', 'remove']);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {string[]} argv subverb and its flags (argv after `status`)
|
|
78
|
+
* @param {object} [opts] test seams: repoRoot, now, stdin (string), stdout, stderr
|
|
79
|
+
*/
|
|
80
|
+
export async function run(argv, opts = {}) {
|
|
81
|
+
const out = opts.stdout ?? process.stdout;
|
|
82
|
+
const err = opts.stderr ?? process.stderr;
|
|
83
|
+
const verb = argv[0];
|
|
84
|
+
if (argv.includes('-h') || argv.includes('--help')) { out.write(USAGE); return 0; }
|
|
85
|
+
try {
|
|
86
|
+
if (!SUBVERBS.includes(verb)) throw new UsageError('unknown subverb: ' + verb);
|
|
87
|
+
const flags = parseFlags(argv.slice(1), FLAGS[verb]);
|
|
88
|
+
const repoRoot = opts.repoRoot ?? await getRepoRoot();
|
|
89
|
+
const agent = pickAgent(flags.agent, repoRoot);
|
|
90
|
+
const file = boardPath(repoRoot, agent);
|
|
91
|
+
const ctx = { repoRoot, agent, file, flags, out, err, now: opts.now ?? (() => new Date()), stdin: opts.stdin };
|
|
92
|
+
if (verb === 'set') return await doSet(ctx);
|
|
93
|
+
if (verb === 'patch') return await doPatch(ctx);
|
|
94
|
+
return await doShow(ctx);
|
|
95
|
+
} catch (e) {
|
|
96
|
+
if (e instanceof BoardError) {
|
|
97
|
+
err.write('[pa status] refused, nothing written:\n' + e.problems.map((p) => ' - ' + p + '\n').join(''));
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
err.write('[pa status] ' + (e.message || e) + '\n');
|
|
101
|
+
if (e.exitCode === 1) err.write('Run `pa status set --help` for usage.\n');
|
|
102
|
+
if (e.exitCode) return e.exitCode;
|
|
103
|
+
return e.code ? 3 : 2;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseFlags(args, allowed) {
|
|
108
|
+
const flags = {};
|
|
109
|
+
for (let i = 0; i < args.length; i++) {
|
|
110
|
+
const a = args[i];
|
|
111
|
+
if (!a.startsWith('--')) throw new UsageError('unexpected argument: ' + a);
|
|
112
|
+
const eq = a.indexOf('=');
|
|
113
|
+
const key = eq === -1 ? a.slice(2) : a.slice(2, eq);
|
|
114
|
+
if (!allowed.includes(key)) throw new UsageError('unknown flag: --' + key);
|
|
115
|
+
if (BOOLEAN_FLAGS.has(key)) {
|
|
116
|
+
if (eq !== -1) throw new UsageError('--' + key + ' takes no value');
|
|
117
|
+
flags[key] = true;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (eq !== -1) { flags[key] = a.slice(eq + 1); continue; }
|
|
121
|
+
const v = args[i + 1];
|
|
122
|
+
if (v === undefined) throw new UsageError('--' + key + ' needs a value');
|
|
123
|
+
flags[key] = v;
|
|
124
|
+
i += 1;
|
|
125
|
+
}
|
|
126
|
+
return flags;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function pickAgent(flag, repoRoot) {
|
|
130
|
+
if (flag === undefined) return resolveAgentName(repoRoot);
|
|
131
|
+
if (!isValidAgentName(flag)) throw new UsageError('--agent must be a lowercase agent folder name, got "' + flag + '"');
|
|
132
|
+
return flag;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function stamp(now) {
|
|
136
|
+
return now().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function readStdin(given) {
|
|
140
|
+
if (typeof given === 'string') return given;
|
|
141
|
+
const chunks = [];
|
|
142
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
143
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// -- set ----------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
async function doSet(ctx) {
|
|
149
|
+
const { flags } = ctx;
|
|
150
|
+
let text;
|
|
151
|
+
if (flags.file === undefined || flags.file === '-') {
|
|
152
|
+
text = await readStdin(ctx.stdin);
|
|
153
|
+
} else {
|
|
154
|
+
const { readFile } = await import('node:fs/promises');
|
|
155
|
+
text = await readFile(flags.file, 'utf8');
|
|
156
|
+
}
|
|
157
|
+
let board;
|
|
158
|
+
try {
|
|
159
|
+
board = JSON.parse(text);
|
|
160
|
+
} catch (e) {
|
|
161
|
+
throw new UsageError('input is not valid JSON: ' + e.message);
|
|
162
|
+
}
|
|
163
|
+
if (board === null || typeof board !== 'object' || Array.isArray(board)) {
|
|
164
|
+
throw new BoardError(['board must be a JSON object']);
|
|
165
|
+
}
|
|
166
|
+
board.updatedAt = stamp(ctx.now);
|
|
167
|
+
const written = await writeBoardAtomic(ctx.file, withDefaults(board));
|
|
168
|
+
ctx.out.write('Board set for ' + ctx.agent + ' at ' + boardRelPath(ctx.agent) + ' (' + counts(written) + ').\n');
|
|
169
|
+
return 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Fill the optional arrays so the file on disk always carries every list.
|
|
173
|
+
// `updatedAt` stays first so the file reads top-down like the page.
|
|
174
|
+
function withDefaults(board) {
|
|
175
|
+
const { updatedAt, ...rest } = board;
|
|
176
|
+
const out = { updatedAt, ...rest };
|
|
177
|
+
for (const k of ['onMe', 'groups', 'done']) {
|
|
178
|
+
if (out[k] === undefined && !(k === 'onMe' && 'onBaz' in out)) out[k] = [];
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// -- show ---------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
async function doShow(ctx) {
|
|
186
|
+
const parsed = await loadExisting(ctx, { strictStatus: false });
|
|
187
|
+
if (parsed.problems.length) {
|
|
188
|
+
throw new OpError('board at ' + boardRelPath(ctx.agent) + ' does not match the schema:\n - ' + parsed.problems.join('\n - '));
|
|
189
|
+
}
|
|
190
|
+
if (ctx.flags.json) {
|
|
191
|
+
ctx.out.write(JSON.stringify(parsed.board, null, 2) + '\n');
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
const b = parsed.board;
|
|
195
|
+
const lines = [];
|
|
196
|
+
lines.push('Status board for ' + ctx.agent + ' (' + boardRelPath(ctx.agent) + ')');
|
|
197
|
+
lines.push('Updated ' + b.updatedAt);
|
|
198
|
+
lines.push('Headline: ' + b.headline);
|
|
199
|
+
const onMe = b.onMe || [];
|
|
200
|
+
lines.push('');
|
|
201
|
+
lines.push('On me (' + onMe.length + ')');
|
|
202
|
+
if (!onMe.length) lines.push(' nothing');
|
|
203
|
+
for (const a of onMe) lines.push(' - ' + a.question + idsSuffix(a.ids));
|
|
204
|
+
for (const g of b.groups || []) {
|
|
205
|
+
lines.push('');
|
|
206
|
+
lines.push(g.label || 'Untitled workstream');
|
|
207
|
+
if (!g.items.length) lines.push(' nothing in flight');
|
|
208
|
+
for (const it of g.items) {
|
|
209
|
+
lines.push(' [' + (it.status || 'queued') + '] ' + it.title + idsSuffix(it.ids) + (it.where ? ' @' + it.where : ''));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
lines.push('');
|
|
213
|
+
lines.push('Done today (' + (b.done || []).length + ')');
|
|
214
|
+
for (const it of b.done || []) lines.push(' - ' + it.title + (it.workstream ? ' (' + it.workstream + ')' : ''));
|
|
215
|
+
ctx.out.write(lines.join('\n') + '\n');
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function idsSuffix(ids) {
|
|
220
|
+
return Array.isArray(ids) && ids.length ? ' (' + ids.join(', ') + ')' : '';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function counts(b) {
|
|
224
|
+
const items = (b.groups || []).reduce((n, g) => n + g.items.length, 0);
|
|
225
|
+
return (b.groups || []).length + ' groups, ' + items + ' items, ' + (b.onMe || []).length + ' on me, ' + (b.done || []).length + ' done';
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function loadExisting(ctx, opts) {
|
|
229
|
+
let parsed;
|
|
230
|
+
try {
|
|
231
|
+
parsed = await readBoard(ctx.file, opts);
|
|
232
|
+
} catch (e) {
|
|
233
|
+
if (e instanceof SyntaxError) throw new OpError('board at ' + boardRelPath(ctx.agent) + ' is not valid JSON: ' + e.message);
|
|
234
|
+
throw e;
|
|
235
|
+
}
|
|
236
|
+
if (!parsed) throw new OpError('no status board for ' + ctx.agent + ' yet at ' + boardRelPath(ctx.agent) + '; start one with `pa status set`');
|
|
237
|
+
return parsed;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// -- patch --------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
const ITEM_FIELDS = ['title', 'status', 'where', 'stage', 'note', 'link'];
|
|
243
|
+
const ASK_FIELDS = ['question', 'workstream', 'link'];
|
|
244
|
+
|
|
245
|
+
async function doPatch(ctx) {
|
|
246
|
+
const f = ctx.flags;
|
|
247
|
+
const hasTarget = f.id !== undefined || f.match !== undefined;
|
|
248
|
+
if (f.id !== undefined && f.match !== undefined) throw new UsageError('give --id or --match, not both');
|
|
249
|
+
if (!hasTarget) {
|
|
250
|
+
const stray = Object.keys(f).filter((k) => !['agent', 'headline', 'add-done', 'workstream', 'ids'].includes(k));
|
|
251
|
+
if (stray.length) throw new UsageError('--' + stray[0] + ' needs a target (--id or --match)');
|
|
252
|
+
if (f.headline === undefined && f['add-done'] === undefined) {
|
|
253
|
+
throw new UsageError('patch needs --headline, --add-done, or a target (--id or --match)');
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (f.status !== undefined && !STATUS_VALUES.includes(normaliseStatusValue(f.status))) {
|
|
257
|
+
throw new UsageError('--status must be one of ' + STATUS_VALUES.join(', '));
|
|
258
|
+
}
|
|
259
|
+
if (f['on-me']) {
|
|
260
|
+
const bad = ['title', 'status', 'where', 'stage', 'note', 'group'].find((k) => f[k] !== undefined);
|
|
261
|
+
if (bad) throw new UsageError('--' + bad + ' applies to items, not --on-me entries');
|
|
262
|
+
} else {
|
|
263
|
+
const bad = ['question', 'options'].find((k) => f[k] !== undefined);
|
|
264
|
+
if (bad) throw new UsageError('--' + bad + ' applies to --on-me entries');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const parsed = await loadExisting(ctx, { strictStatus: true });
|
|
268
|
+
if (parsed.problems.length) throw new OpError('board on disk is invalid; fix it with `pa status set`:\n - ' + parsed.problems.join('\n - '));
|
|
269
|
+
const board = structuredClone(parsed.board);
|
|
270
|
+
board.onMe = board.onMe || [];
|
|
271
|
+
board.groups = board.groups || [];
|
|
272
|
+
board.done = board.done || [];
|
|
273
|
+
const now = stamp(ctx.now);
|
|
274
|
+
const notes = [];
|
|
275
|
+
|
|
276
|
+
if (f.headline !== undefined) { board.headline = f.headline; notes.push('headline set'); }
|
|
277
|
+
if (hasTarget) notes.push(f['on-me'] ? patchAsk(board, f, now) : patchItem(board, f, now));
|
|
278
|
+
if (f['add-done'] !== undefined) {
|
|
279
|
+
board.done.push(doneEntry({ title: f['add-done'], ids: splitList(f.ids, ',') || [] }, f.workstream, now));
|
|
280
|
+
notes.push('done entry added');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
board.updatedAt = now;
|
|
284
|
+
await writeBoardAtomic(ctx.file, board);
|
|
285
|
+
ctx.out.write('Board patched for ' + ctx.agent + ': ' + notes.join('; ') + '.\n');
|
|
286
|
+
return 0;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function normaliseStatusValue(s) {
|
|
290
|
+
return s === 'onBaz' ? 'onMe' : s;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function splitList(v, sep) {
|
|
294
|
+
if (v === undefined) return undefined;
|
|
295
|
+
return v.split(sep).map((s) => s.trim()).filter(Boolean);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function refusal(matches, describe) {
|
|
299
|
+
return new UsageError('more than one entry matches, refusing to pick one:\n' + matches.map((m) => ' - ' + describe(m)).join('\n'));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function patchItem(board, f, now) {
|
|
303
|
+
const hits = [];
|
|
304
|
+
board.groups.forEach((g, gi) => g.items.forEach((it, ii) => {
|
|
305
|
+
const ok = f.id !== undefined
|
|
306
|
+
? Array.isArray(it.ids) && it.ids.includes(f.id)
|
|
307
|
+
: String(it.title).trim() === f.match.trim();
|
|
308
|
+
if (ok) hits.push({ gi, ii });
|
|
309
|
+
}));
|
|
310
|
+
if (hits.length > 1) {
|
|
311
|
+
throw refusal(hits, (h) => '"' + board.groups[h.gi].items[h.ii].title + '" in ' + (board.groups[h.gi].label || 'group ' + (h.gi + 1)));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (!hits.length) {
|
|
315
|
+
if (f.remove) throw new OpError('no item matches; nothing to remove');
|
|
316
|
+
if (f.group === undefined) throw new UsageError('no item matches; to add one, give --group');
|
|
317
|
+
const title = f.title ?? f.match;
|
|
318
|
+
if (title === undefined) throw new UsageError('no item matches --id ' + f.id + '; to add one, give --title and --group');
|
|
319
|
+
const ids = mergeIds(splitList(f.ids, ',') || [], f.id);
|
|
320
|
+
const item = {
|
|
321
|
+
title,
|
|
322
|
+
status: normaliseStatusValue(f.status ?? 'queued'),
|
|
323
|
+
where: f.where ?? '',
|
|
324
|
+
stage: f.stage ?? '',
|
|
325
|
+
note: f.note ?? '',
|
|
326
|
+
ids,
|
|
327
|
+
updatedAt: now,
|
|
328
|
+
};
|
|
329
|
+
if (f.link !== undefined) item.link = f.link;
|
|
330
|
+
const g = groupFor(board, f.group);
|
|
331
|
+
g.items.push(item);
|
|
332
|
+
if (item.status === 'done') addDoneOnce(board, item, g.label, now);
|
|
333
|
+
return 'item "' + title + '" added to ' + g.label;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const { gi, ii } = hits[0];
|
|
337
|
+
const fromGroup = board.groups[gi];
|
|
338
|
+
if (f.remove) {
|
|
339
|
+
const [gone] = fromGroup.items.splice(ii, 1);
|
|
340
|
+
return 'item "' + gone.title + '" removed';
|
|
341
|
+
}
|
|
342
|
+
const item = fromGroup.items[ii];
|
|
343
|
+
for (const k of ITEM_FIELDS) {
|
|
344
|
+
if (f[k] !== undefined) item[k] = k === 'status' ? normaliseStatusValue(f[k]) : f[k];
|
|
345
|
+
}
|
|
346
|
+
if (f.ids !== undefined) item.ids = splitList(f.ids, ',');
|
|
347
|
+
item.updatedAt = now;
|
|
348
|
+
let label = fromGroup.label;
|
|
349
|
+
if (f.group !== undefined && f.group !== fromGroup.label) {
|
|
350
|
+
fromGroup.items.splice(ii, 1);
|
|
351
|
+
const to = groupFor(board, f.group);
|
|
352
|
+
to.items.push(item);
|
|
353
|
+
label = to.label;
|
|
354
|
+
}
|
|
355
|
+
if (f.status !== undefined && item.status === 'done') addDoneOnce(board, item, label, now);
|
|
356
|
+
return 'item "' + item.title + '" updated';
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function patchAsk(board, f, now) {
|
|
360
|
+
const hits = [];
|
|
361
|
+
board.onMe.forEach((a, i) => {
|
|
362
|
+
const ok = f.id !== undefined
|
|
363
|
+
? Array.isArray(a.ids) && a.ids.includes(f.id)
|
|
364
|
+
: String(a.question).trim() === f.match.trim();
|
|
365
|
+
if (ok) hits.push(i);
|
|
366
|
+
});
|
|
367
|
+
if (hits.length > 1) throw refusal(hits, (i) => '"' + board.onMe[i].question + '"');
|
|
368
|
+
|
|
369
|
+
if (!hits.length) {
|
|
370
|
+
if (f.remove) throw new OpError('no on-me entry matches; nothing to remove');
|
|
371
|
+
const question = f.question ?? f.match;
|
|
372
|
+
if (question === undefined) throw new UsageError('no on-me entry matches --id ' + f.id + '; to add one, give --question');
|
|
373
|
+
const ask = { since: now };
|
|
374
|
+
if (f.workstream !== undefined) ask.workstream = f.workstream;
|
|
375
|
+
ask.question = question;
|
|
376
|
+
ask.options = splitList(f.options, '|') || [];
|
|
377
|
+
ask.ids = mergeIds(splitList(f.ids, ',') || [], f.id);
|
|
378
|
+
if (f.link !== undefined) ask.link = f.link;
|
|
379
|
+
board.onMe.push(ask);
|
|
380
|
+
return 'on-me entry added';
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const i = hits[0];
|
|
384
|
+
if (f.remove) {
|
|
385
|
+
board.onMe.splice(i, 1);
|
|
386
|
+
return 'on-me entry removed';
|
|
387
|
+
}
|
|
388
|
+
const ask = board.onMe[i];
|
|
389
|
+
for (const k of ASK_FIELDS) if (f[k] !== undefined) ask[k] = f[k];
|
|
390
|
+
if (f.options !== undefined) ask.options = splitList(f.options, '|');
|
|
391
|
+
if (f.ids !== undefined) ask.ids = splitList(f.ids, ',');
|
|
392
|
+
return 'on-me entry updated';
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function mergeIds(ids, id) {
|
|
396
|
+
if (id !== undefined && !ids.includes(id)) ids.push(id);
|
|
397
|
+
return ids;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function groupFor(board, label) {
|
|
401
|
+
let g = board.groups.find((x) => x.label === label);
|
|
402
|
+
if (!g) {
|
|
403
|
+
// New groups land last: group order is priority order, and the PA
|
|
404
|
+
// reorders with `pa status set` when priorities change.
|
|
405
|
+
g = { label, items: [] };
|
|
406
|
+
board.groups.push(g);
|
|
407
|
+
}
|
|
408
|
+
return g;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function doneEntry(item, workstream, now) {
|
|
412
|
+
const e = { title: item.title, status: 'done' };
|
|
413
|
+
if (workstream) e.workstream = workstream;
|
|
414
|
+
e.where = item.where ?? '';
|
|
415
|
+
e.stage = item.stage ?? '';
|
|
416
|
+
e.note = item.note ?? '';
|
|
417
|
+
e.ids = item.ids ? [...item.ids] : [];
|
|
418
|
+
e.updatedAt = now;
|
|
419
|
+
return e;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function addDoneOnce(board, item, workstream, now) {
|
|
423
|
+
if (board.done.some((d) => d.title === item.title)) return;
|
|
424
|
+
board.done.push(doneEntry(item, workstream, now));
|
|
425
|
+
}
|