create-pmos 0.186.0 → 0.187.0
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/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/workflow-state.js +2 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/c3-ib-triage-backlog-test.js +20 -13
- package/workspace/c3-ini-life-test.js +167 -0
- package/workspace/c3-initiatives-test.js +6 -6
- package/workspace/c3-phone-test.js +67 -16
- package/workspace/c3-quiet-test.js +2 -1
- package/workspace/guide/initiative-boards.md +37 -2
- package/workspace/guide/phone-companion.md +19 -4
- package/workspace/lib/engine/boards.js +73 -18
- package/workspace/lib/engine/workflow-state.js +2 -1
- package/workspace/lib/initiative-rows.js +2 -2
- package/workspace/lib/routes-boards.js +119 -6
- package/workspace/lib/routes-meeting-focus.js +19 -7
- package/workspace/lib/vendor/scripts/workflow-state.js +2 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-demo-boards.js +82 -17
- package/workspace/public/pm/c3-demo-focus.js +39 -1
- package/workspace/public/pm/c3-focus.css +53 -0
- package/workspace/public/pm/c3-focus.js +188 -44
- package/workspace/public/pm/c3-ib-initiatives.js +127 -60
- package/workspace/public/pm/c3-ib-items.js +2 -2
- package/workspace/public/pm/c3-ini-life.css +32 -0
- package/workspace/public/pm/c3-ini-life.js +379 -0
- package/workspace/public/pm/c3-ini-ui.js +12 -7
- package/workspace/public/pm/c3-initiatives.js +14 -4
- package/workspace/public/pm/c3-phone.css +3 -1
- package/workspace/public/pm/c3-phone.js +28 -8
- package/workspace/server.js +7 -2
- package/workspace/test-lists/c3-ini-life.json +9 -0
|
@@ -17,7 +17,7 @@ The top card shows where you are in your day and why it is useful now. PMOS choo
|
|
|
17
17
|
|---|---|---|
|
|
18
18
|
| Your day | Before noon, with no meeting in the next 30 minutes | The next meeting, the number of things that wait on you and how many are overdue, and **The brief of this meeting** |
|
|
19
19
|
| Before you walk in | The next meeting starts in 30 minutes or less | What is prepared for it, the items to close first, each with **Done**, and **The brief of this meeting** |
|
|
20
|
-
| In the meeting | A meeting is in progress | **Record this meeting?** with **Record** and **Not this one
|
|
20
|
+
| In the meeting | A meeting is in progress | **Record this meeting?** with **Record** and **Not this one**. Recording opens focus (see below). |
|
|
21
21
|
| Just ended | A meeting ended less than 20 minutes ago | **What came out of it?** If you recorded the meeting, the card shows the summary, the decisions, the actions and the open questions from its notes, the same notes as on the Notes page. While PMOS writes the notes, the card says so. If you did not record it, **Tell PMOS** puts the meeting name in the bar so you can type the result. |
|
|
22
22
|
| Free time | 15 minutes or more before the next meeting | The items that fit in the time: overdue items first, then items due today, at most two. Each has **Done** and **Later today**. |
|
|
23
23
|
| Wrapping up | From 17:00, or after noon with no meeting left | What is still open and what waits for your computer |
|
|
@@ -34,7 +34,7 @@ Under the moment card, the things that wait on you show one at a time ("1 of 9")
|
|
|
34
34
|
|
|
35
35
|
## Meetings
|
|
36
36
|
|
|
37
|
-
Under the card, **Later today** lists the rest of today's meetings. Tap a meeting to see its brief: the time, the people, the initiative, then the same brief as the meeting card of the desktop Calendar (in short, what matters today, what came out of the last one, what to be ready with, and the links). From the brief you can record the meeting or open
|
|
37
|
+
Under the card, **Later today** lists the rest of today's meetings. Tap a meeting to see its brief: the time, the people, the initiative, then the same brief as the meeting card of the desktop Calendar (in short, what matters today, what came out of the last one, what to be ready with, and the links). From the brief you can record the meeting or open focus.
|
|
38
38
|
|
|
39
39
|
## Tell PMOS
|
|
40
40
|
|
|
@@ -42,13 +42,28 @@ Type a note, a to-do or an idea in the bar at the bottom and press Enter. PMOS p
|
|
|
42
42
|
|
|
43
43
|
## Record
|
|
44
44
|
|
|
45
|
-
The red button records a conversation or a meeting with the same recorder as **Record** on the desktop.
|
|
45
|
+
The red button records a conversation or a meeting with the same recorder as **Record** on the desktop. When the recording starts, the phone opens focus.
|
|
46
|
+
|
|
47
|
+
## Focus
|
|
48
|
+
|
|
49
|
+
Focus is the desktop Meeting focus in one column. From the top:
|
|
50
|
+
|
|
51
|
+
- **Back to your day**, the title, and the clock: the time since the start and the time left. Under them, the bar of what the room talked about.
|
|
52
|
+
- The topic that the room is on now, and how long it has been on it.
|
|
53
|
+
- **Topics**, **Captured**, **Note** and **Your slides** (or **Material**). Each one opens a sheet over focus. **Note** shows what the live note has found so far, or the last lines of the transcript.
|
|
54
|
+
- **What's being said**, with context. Tap a context to see its detail.
|
|
55
|
+
- The room's last lines, and the notes PMOS writes for you. The newest note is open. Tap an older note to open it. PMOS holds some notes for the right moment; tap one to see it now.
|
|
56
|
+
- At the bottom, **Ask PMOS quietly** and **Stop**. Only you see your question and the answer. **Stop** asks before it stops the recording.
|
|
57
|
+
|
|
58
|
+
A recording that is not on the calendar, for example a talk in the corridor, opens the same focus without topics and slides.
|
|
59
|
+
|
|
60
|
+
**Back to your day** closes focus. The recording continues. While it records, the bar at the bottom shows the name, the time, **Focus** and **Stop**, and the top card shows the notes that are being taken. **Focus** opens focus again.
|
|
46
61
|
|
|
47
62
|
## The full console
|
|
48
63
|
|
|
49
64
|
A phone always shows the companion. There is no way to the desktop pages on a phone: use your computer for them. In a window wider than 640 pixels the console always shows the desktop pages.
|
|
50
65
|
|
|
51
|
-
|
|
66
|
+
The number of things that wait on you, at the top right, opens the list of all of them.
|
|
52
67
|
|
|
53
68
|
## Limits
|
|
54
69
|
|
|
@@ -144,7 +144,11 @@ const TOOLS = Object.freeze({
|
|
|
144
144
|
},
|
|
145
145
|
});
|
|
146
146
|
|
|
147
|
-
|
|
147
|
+
// on hold and cancelled come from a D70 lifecycle line (the PM stopped it); done is the end of the workflow. Only the first
|
|
148
|
+
// three are ACTIVE: an initiative on hold, done or cancelled has no health and is not counted as open (c3-initiative-lifecycle).
|
|
149
|
+
const STATUSES = Object.freeze(['on track', 'at risk', 'late', 'on hold', 'done', 'cancelled']);
|
|
150
|
+
const ACTIVE = Object.freeze(['on track', 'at risk', 'late']);
|
|
151
|
+
const isActive = (r) => !!r && ACTIVE.includes(r.status);
|
|
148
152
|
const VIEWS = Object.freeze(['now', 'mine', 'risk', 'noowner', 'all']);
|
|
149
153
|
/** What a Horizon scan filed with an ask (initiative horizon-scan), cut to its shape; null for any other ask. */
|
|
150
154
|
function foundOf(f) {
|
|
@@ -152,7 +156,7 @@ function foundOf(f) {
|
|
|
152
156
|
const sources = Array.isArray(f.sources) ? f.sources.filter((x) => x && typeof x.ref === 'string').slice(0, 8).map((x) => ({ label: String(x.label || ''), ref: x.ref })) : [];
|
|
153
157
|
return { why: typeof f.why === 'string' ? f.why : '', sources, match: typeof f.match === 'string' && f.match ? f.match : null, scan: typeof f.scan === 'string' ? f.scan : null, run: typeof f.run === 'string' ? f.run : null };
|
|
154
158
|
}
|
|
155
|
-
const KINDS = new Set(['board', 'workflow', 'move', 'assign', 'ask', 'ask-accept', 'ask-decline', 'ask-more', 'ini-workflow', 'task', 'task-update', 'ini-epics']);
|
|
159
|
+
const KINDS = new Set(['board', 'workflow', 'move', 'assign', 'ask', 'ask-accept', 'ask-decline', 'ask-more', 'ini-workflow', 'task', 'task-update', 'ini-epics', 'ini-archive', 'ini-notdup']);
|
|
156
160
|
const TASK_STATES = Object.freeze(['To do', 'In progress', 'In review', 'Done']);
|
|
157
161
|
|
|
158
162
|
const copyStages = (stages) => stages.map((s) => ({ name: s.name, cat: s.cat, hint: s.hint || '' }));
|
|
@@ -180,6 +184,8 @@ function fold(events) {
|
|
|
180
184
|
const iniWorkflows = new Map(); // key(board, id) → { template, stages } (the initiative's own workflow)
|
|
181
185
|
const tasks = new Map(); // task id → { id, board, initiative, title, epic, owner, state, start, due, at, by, history }
|
|
182
186
|
const epics = new Map(); // initiative id → the Jira epic keys the PM linked to it (the last ini-epics line wins)
|
|
187
|
+
const archives = new Map(); // initiative id → { at, by, reason, into, moved } while archived (an `archived: false` line restores it)
|
|
188
|
+
const notDup = new Map(); // initiative id → Set of initiative ids the PM said it is not a duplicate of
|
|
183
189
|
const list = [];
|
|
184
190
|
for (let e of Array.isArray(events) ? events : []) {
|
|
185
191
|
if (typeof e === 'string') { try { e = JSON.parse(e); } catch { continue; } }
|
|
@@ -243,18 +249,26 @@ function fold(events) {
|
|
|
243
249
|
} else if (e.kind === 'task-update' && typeof e.task === 'string' && tasks.has(e.task)) {
|
|
244
250
|
const t = tasks.get(e.task);
|
|
245
251
|
if (TASK_STATES.includes(e.state)) t.state = e.state;
|
|
252
|
+
if (typeof e.initiative === 'string' && e.initiative) t.initiative = e.initiative; // a merge moves the task (and its undo moves it back)
|
|
246
253
|
if (typeof e.start === 'string') t.start = e.start;
|
|
247
254
|
if (typeof e.due === 'string') t.due = e.due;
|
|
248
255
|
t.history.push({ at: e.at || null, by: e.by || null, state: e.state || null, start: e.start || null, due: e.due || null, why: e.why || '' });
|
|
249
256
|
} else if (e.kind === 'ini-epics' && typeof e.id === 'string' && Array.isArray(e.epics)) {
|
|
250
257
|
epics.set(e.id, e.epics.filter((k) => typeof k === 'string' && JIRA_KEY_RE.test(k)).map((k) => k.toUpperCase()));
|
|
258
|
+
} else if (e.kind === 'ini-archive' && typeof e.id === 'string') {
|
|
259
|
+
if (e.archived === false) archives.delete(e.id);
|
|
260
|
+
else archives.set(e.id, { at: e.at || null, by: e.by || null, board: b, reason: typeof e.reason === 'string' ? e.reason : '', into: typeof e.into === 'string' && e.into ? e.into : null,
|
|
261
|
+
moved: isObj(e.moved) ? { epics: Array.isArray(e.moved.epics) ? e.moved.epics.filter((k) => typeof k === 'string') : [], tasks: Array.isArray(e.moved.tasks) ? e.moved.tasks.filter((k) => typeof k === 'string') : [] } : { epics: [], tasks: [] } });
|
|
262
|
+
} else if (e.kind === 'ini-notdup' && typeof e.id === 'string' && typeof e.of === 'string') {
|
|
263
|
+
if (!notDup.has(e.id)) notDup.set(e.id, new Set());
|
|
264
|
+
notDup.get(e.id).add(e.of);
|
|
251
265
|
} else if (e.kind === 'ask-decline' && typeof e.ask === 'string' && asks.has(e.ask)) {
|
|
252
266
|
const a = asks.get(e.ask);
|
|
253
267
|
if (a.state !== 'open') continue;
|
|
254
268
|
Object.assign(a, { state: 'declined', why: e.why || '', closedAt: e.at || null });
|
|
255
269
|
}
|
|
256
270
|
}
|
|
257
|
-
return { boards, moves, assigns, asks, stages, iniWorkflows, tasks, epics, events: list };
|
|
271
|
+
return { boards, moves, assigns, asks, stages, iniWorkflows, tasks, epics, archives, notDup, events: list };
|
|
258
272
|
}
|
|
259
273
|
|
|
260
274
|
/** The stages an initiative runs on a board: its own workflow when one is recorded, else the board's. */
|
|
@@ -428,13 +442,16 @@ const SORTS = ['rank', 'id', 'title', 'stage', 'status', 'owner', 'horizon', 'en
|
|
|
428
442
|
const listParam = (v) => (v == null || v === '' ? null : String(v).split(',').map((x) => x.trim()).filter(Boolean));
|
|
429
443
|
const lc = (v) => String(v == null ? '' : v).toLowerCase();
|
|
430
444
|
|
|
431
|
-
|
|
445
|
+
/* A view's own status rule (active only; at risk or late) gives way to a status filter the PM picks, so "Now and next" with
|
|
446
|
+
* Status: On hold lists the paused initiatives on the horizon (c3-initiative-lifecycle). */
|
|
447
|
+
function viewFilter(view, me, statusGiven) {
|
|
432
448
|
const mine = new Set([lc(me), lc(me ? `pm:${me}` : '')].filter(Boolean));
|
|
449
|
+
const act = (r) => statusGiven || isActive(r);
|
|
433
450
|
switch (view) {
|
|
434
|
-
case 'now': return (r) => (r.horizon === 'now' || r.horizon === 'next') && r
|
|
435
|
-
case 'mine': return (r) => !!r.owner && mine.has(lc(r.owner)) && r
|
|
436
|
-
case 'risk': return (r) => r.status === 'at risk' || r.status === 'late';
|
|
437
|
-
case 'noowner': return (r) => !r.owner && r
|
|
451
|
+
case 'now': return (r) => (r.horizon === 'now' || r.horizon === 'next') && act(r);
|
|
452
|
+
case 'mine': return (r) => !!r.owner && mine.has(lc(r.owner)) && act(r);
|
|
453
|
+
case 'risk': return (r) => statusGiven || r.status === 'at risk' || r.status === 'late';
|
|
454
|
+
case 'noowner': return (r) => !r.owner && act(r);
|
|
438
455
|
default: return () => true;
|
|
439
456
|
}
|
|
440
457
|
}
|
|
@@ -448,16 +465,24 @@ function facetOf(rows, field) {
|
|
|
448
465
|
|
|
449
466
|
/**
|
|
450
467
|
* query = { q, stage, status, owner, horizon, anchor (each a comma list), view, sort ('-' prefix = descending), offset,
|
|
451
|
-
* limit, me, stages (the workflow's stage names, for the stage sort)
|
|
452
|
-
* search, before the stage/status/owner/horizon/anchor filters, so each filter shows what choosing it
|
|
468
|
+
* limit, me, stages (the workflow's stage names, for the stage sort), archived ('1' = the archive only) }. Facets count the rows
|
|
469
|
+
* after the view and the search, before the stage/status/owner/horizon/anchor filters, so each filter shows what choosing it
|
|
470
|
+
* would give. An archived row (`r.archived`) is in no list but the archive. A cancelled row is in no view unless the status
|
|
471
|
+
* filter names `cancelled`; `cancelledHidden` counts the board's cancelled rows while they are hidden (0 once asked for).
|
|
453
472
|
*/
|
|
454
473
|
function filterRows(rows, query = {}) {
|
|
455
|
-
const
|
|
474
|
+
const archive = query.archived === '1' || query.archived === true;
|
|
475
|
+
const view = archive ? 'all' : (VIEWS.includes(query.view) ? query.view : 'now');
|
|
456
476
|
const q = lc(query.q).trim();
|
|
457
|
-
|
|
477
|
+
const wantStatus = listParam(query.status);
|
|
478
|
+
let base = (rows || []).filter((r) => !!r.archived === archive).filter(viewFilter(view, query.me, !!wantStatus));
|
|
458
479
|
if (q) base = base.filter((r) => lc(r.title).includes(q) || lc(r.id).includes(q) || lc(r.anchor).includes(q) || lc(r.owner).includes(q));
|
|
459
480
|
const facets = { stage: facetOf(base, 'stage'), status: facetOf(base, 'status'), owner: facetOf(base, 'owner'), horizon: facetOf(base, 'horizon'), anchor: facetOf(base, 'anchor'), cat: facetOf(base, 'cat') };
|
|
460
|
-
|
|
481
|
+
const askCancelled = archive || !!(wantStatus && wantStatus.map(lc).includes('cancelled'));
|
|
482
|
+
let out = askCancelled ? base : base.filter((r) => r.status !== 'cancelled');
|
|
483
|
+
// How many cancelled initiatives the list leaves out: every one on the board, whatever the view, since the link that shows them
|
|
484
|
+
// opens All with Status: Cancelled.
|
|
485
|
+
const cancelledHidden = askCancelled ? 0 : (rows || []).filter((r) => !r.archived && r.status === 'cancelled').length;
|
|
461
486
|
for (const f of ['stage', 'status', 'owner', 'horizon', 'anchor', 'cat']) {
|
|
462
487
|
const want = listParam(query[f]);
|
|
463
488
|
if (!want) continue;
|
|
@@ -491,13 +516,42 @@ function filterRows(rows, query = {}) {
|
|
|
491
516
|
if (!query.all) limit = Math.min(limit, 100); // `all` is for the server's own callers (the kanban columns), never a URL parameter
|
|
492
517
|
let offset = Number.parseInt(query.offset, 10);
|
|
493
518
|
if (!Number.isFinite(offset) || offset < 0) offset = 0;
|
|
494
|
-
return { view, sort: (desc ? '-' : '') + sort, total: out.length, offset, limit, rows: out.slice(offset, offset + limit), facets
|
|
519
|
+
return { view, sort: (desc ? '-' : '') + sort, total: out.length, offset, limit, rows: out.slice(offset, offset + limit), facets, cancelledHidden,
|
|
520
|
+
archived: archive ? out.length : (rows || []).filter((r) => r.archived).length };
|
|
495
521
|
}
|
|
496
522
|
const pct = (r) => (typeof r.progress === 'number' ? r.progress : -1);
|
|
497
523
|
|
|
524
|
+
/* Likely duplicates (c3-initiative-lifecycle): an active row whose title shares at least three significant words, and at
|
|
525
|
+
* least three quarters of the shorter title's words, with another active row on the same anchor. The less advanced row of
|
|
526
|
+
* the pair (lower progress, then the later id) is the one marked, and a pair the PM called "not a duplicate" is skipped.
|
|
527
|
+
* Deliberately strict: a wrong hint costs the PM a look, a missed one costs nothing that was there before. */
|
|
528
|
+
const DUP_STOP = new Set(['the', 'and', 'for', 'with', 'from', 'into', 'onto', 'that', 'this', 'when', 'what', 'over', 'make', 'add', 'new', 'more', 'less', 'one', 'all', 'via', 'per', 'out', 'not', 'its', 'our', 'your', 'their', 'page', 'pages', 'initiative', 'initiatives']);
|
|
529
|
+
function sigWords(title) {
|
|
530
|
+
return new Set(lc(title).split(/[^a-z0-9]+/).filter((x) => x.length > 2 && !DUP_STOP.has(x)));
|
|
531
|
+
}
|
|
532
|
+
function duplicatesOf(rows, notDup) {
|
|
533
|
+
const live = (rows || []).filter((r) => isActive(r) && !r.archived);
|
|
534
|
+
const words = new Map(live.map((r) => [r.id, sigWords(r.title)]));
|
|
535
|
+
const out = new Map();
|
|
536
|
+
const skip = (a, b) => !!((notDup && notDup.get(a) && notDup.get(a).has(b)) || (notDup && notDup.get(b) && notDup.get(b).has(a)));
|
|
537
|
+
for (let i = 0; i < live.length; i++) {
|
|
538
|
+
for (let j = i + 1; j < live.length; j++) {
|
|
539
|
+
const a = live[i], b = live[j];
|
|
540
|
+
if (!a.anchor || a.anchor !== b.anchor || skip(a.id, b.id)) continue;
|
|
541
|
+
const A = words.get(a.id), Bw = words.get(b.id);
|
|
542
|
+
let n = 0; for (const x of A) if (Bw.has(x)) n++;
|
|
543
|
+
if (n < 3 || n / Math.max(1, Math.min(A.size, Bw.size)) < 0.75) continue;
|
|
544
|
+
const lesser = (pct(a) - pct(b)) || -a.id.localeCompare(b.id, undefined, { numeric: true });
|
|
545
|
+
const [dup, of] = lesser < 0 ? [a, b] : [b, a];
|
|
546
|
+
if (!out.has(dup.id)) out.set(dup.id, of.id);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return out;
|
|
550
|
+
}
|
|
551
|
+
|
|
498
552
|
/** Board health from its rows: bad when a quarter or more of the open rows are late; warn when any is late or at risk. */
|
|
499
553
|
function health(rows) {
|
|
500
|
-
const open = (rows || []).filter((r) => r
|
|
554
|
+
const open = (rows || []).filter((r) => isActive(r) && !r.archived);
|
|
501
555
|
const late = open.filter((r) => r.status === 'late').length;
|
|
502
556
|
const risk = open.filter((r) => r.status === 'at risk').length;
|
|
503
557
|
if (open.length && late / open.length >= 0.25) return 'bad';
|
|
@@ -511,12 +565,13 @@ function counts(rows, workflow) {
|
|
|
511
565
|
const byStatus = Object.fromEntries(STATUSES.map((s) => [s, 0]));
|
|
512
566
|
let open = 0;
|
|
513
567
|
for (const r of rows || []) {
|
|
568
|
+
if (r.archived) continue;
|
|
514
569
|
if (r.cat && r.cat in byCat) byCat[r.cat]++;
|
|
515
570
|
if (r.stage != null) byStage[r.stage] = (byStage[r.stage] || 0) + 1;
|
|
516
571
|
if (r.status in byStatus) byStatus[r.status]++;
|
|
517
|
-
if (r
|
|
572
|
+
if (isActive(r)) open++;
|
|
518
573
|
}
|
|
519
|
-
return { total: (rows || []).length, open, byCat, byStage, byStatus, risk: byStatus['at risk'], late: byStatus.late };
|
|
574
|
+
return { total: (rows || []).filter((r) => !r.archived).length, open, byCat, byStage, byStatus, risk: byStatus['at risk'], late: byStatus.late };
|
|
520
575
|
}
|
|
521
576
|
|
|
522
577
|
/**
|
|
@@ -577,7 +632,7 @@ function sprintsOf(snapshots, onBoard = () => true, whole = null) {
|
|
|
577
632
|
}
|
|
578
633
|
|
|
579
634
|
module.exports = {
|
|
580
|
-
CATEGORIES, WORKFLOW_TEMPLATES, TOOLS, SELF_LADDER, READING_CAT, STATUSES, VIEWS, SORTS,
|
|
635
|
+
CATEGORIES, WORKFLOW_TEMPLATES, TOOLS, SELF_LADDER, READING_CAT, STATUSES, ACTIVE, isActive, duplicatesOf, VIEWS, SORTS,
|
|
581
636
|
templateWorkflow, isTemplateEdited, fold, readingOf, deriveStage, stageOf, catOf, moveWarnings, cardWarnings,
|
|
582
637
|
applyWorkflowEdit, filterRows, health, counts, sprintsOf, key,
|
|
583
638
|
INI_TEMPLATES, TASK_STATES, isBuildStage, templateBuilds, boardSettings, jiraProjectsOf, JIRA_KEY_RE, JIRA_PROJECT_RE, workflowOf, templateOf, applyIniWorkflow, taskOf, shiftDay,
|
|
@@ -1603,7 +1603,8 @@ function readLifecycle(root) {
|
|
|
1603
1603
|
for (const r of cachedJsonl(path.join(L.runsDir, 'lifecycle.jsonl'))) {
|
|
1604
1604
|
if (!r || typeof r.initiative_id !== 'string') continue;
|
|
1605
1605
|
if (r.state !== 'active' && !STOPPED_STATES.includes(r.state)) continue;
|
|
1606
|
-
|
|
1606
|
+
// `back` (c3-initiative-lifecycle): the day an on-hold initiative comes back to the PM, when the hold named one.
|
|
1607
|
+
latest.set(r.initiative_id, { state: r.state, reason: r.reason || '', pm: r.pm || '', at: r.at || '', ...(r.state === 'on-hold' && /^\d{4}-\d{2}-\d{2}$/.test(r.back || '') ? { back: r.back } : {}) });
|
|
1607
1608
|
}
|
|
1608
1609
|
return latest;
|
|
1609
1610
|
}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*
|
|
17
17
|
* A ROW is one line of `node scripts/workflow-state.js --json` (every record, uncapped), narrowed to what the planning
|
|
18
18
|
* layer reads: { ok, id, title, lane, group, anchor, stage, accepted, stopped, last_activity (ISO), profile, record,
|
|
19
|
-
* next, steps: [{ key, status }] }. `stopped` is the lifecycle fold ({ state, reason, at }) or null.
|
|
19
|
+
* next, steps: [{ key, status }] }. `stopped` is the lifecycle fold ({ state, reason, at, back }) or null.
|
|
20
20
|
*
|
|
21
21
|
* WHY A CHILD PROCESS. The engine resolves each artifact's first appearance by running git: about 260 ms per record,
|
|
22
22
|
* so a repository of 100 records takes half a minute (lib/board-cache.js measured it). The board cache is capped at
|
|
@@ -69,7 +69,7 @@ function narrow(root, raw) {
|
|
|
69
69
|
return (Array.isArray(raw) ? raw : []).map((r) => (r && r.ok !== false ? {
|
|
70
70
|
ok: true, id: r.id, title: r.title || r.id, lane: r.lane, group: groupOf(r), anchor: r.anchor || null,
|
|
71
71
|
stage: r.stage || null, accepted: !!r.accepted,
|
|
72
|
-
stopped: r.stopped ? { state: r.stopped.state, reason: r.stopped.reason || '', at: r.stopped.at || null } : null,
|
|
72
|
+
stopped: r.stopped ? { state: r.stopped.state, reason: r.stopped.reason || '', at: r.stopped.at || null, back: r.stopped.back || null } : null,
|
|
73
73
|
last_activity: iso(r.last_activity), profile: r.profile || null, record: r.record || null, next: r.next || null,
|
|
74
74
|
// key and status of each ladder step, so a row's stage is read by the same rule as the sheet's stepper
|
|
75
75
|
steps: Array.isArray(r.steps) ? r.steps.filter(Boolean).map((x) => ({ key: x.key, status: x.status })) : null,
|
|
@@ -27,6 +27,13 @@
|
|
|
27
27
|
* POST /api/boards/tasks/add { ws, board, initiative, title, type?, epic?, start?, due?, owner? } → { ok, item }
|
|
28
28
|
* POST /api/boards/tasks/state { ws, board, task, state } → { ok, item }
|
|
29
29
|
* POST /api/boards/tasks/reschedule { ws, board, task, days } → { ok, item }
|
|
30
|
+
* POST /api/boards/archive { ws, board, id, archived, reason? } → { ok, row } archive (hidden everywhere, kept whole) or restore;
|
|
31
|
+
* restoring a merged initiative moves its epics and tasks back
|
|
32
|
+
* POST /api/boards/merge { ws, board, id, into, epics?, tasks? } → { ok, row, into } moves the picked Jira epic links and PMOS
|
|
33
|
+
* tasks of `id` to `into`, then archives `id` as "Merged into <into>"
|
|
34
|
+
* POST /api/boards/notdup { ws, board, id, of } → { ok } the PM says `id` is not a duplicate of `of`
|
|
35
|
+
* (c3-initiative-lifecycle, planning/prd/c3-initiative-lifecycle-prd.md: on hold and cancelled are D70 lifecycle lines written by
|
|
36
|
+
* POST /api/lifecycle; archive and merge are board-ledger lines, so nothing here rewrites a record.)
|
|
30
37
|
* (console3-initiatives-parity, planning/prd/console3-initiatives-parity-prd.md: the workflow belongs to each initiative, a board
|
|
31
38
|
* says whether it builds and delivers, and PMOS tasks live on the board. All of it is append-only in the same ledger.)
|
|
32
39
|
*
|
|
@@ -177,7 +184,9 @@ function frontmatter(root, rel) {
|
|
|
177
184
|
}
|
|
178
185
|
|
|
179
186
|
function statusOf(rec, healthLevel, end, day) {
|
|
180
|
-
if (rec.accepted || (rec.stopped &&
|
|
187
|
+
if (rec.accepted || (rec.stopped && rec.stopped.state === 'closed')) return 'done';
|
|
188
|
+
if (rec.stopped && rec.stopped.state === 'cancelled') return 'cancelled';
|
|
189
|
+
if (rec.stopped && rec.stopped.state === 'on-hold') return 'on hold'; // no health while paused: it never turns late
|
|
181
190
|
if (healthLevel === 'off' || (end && end < day)) return 'late';
|
|
182
191
|
if (healthLevel === 'at-risk') return 'at risk';
|
|
183
192
|
return 'on track';
|
|
@@ -230,6 +239,7 @@ function baseRows(w) {
|
|
|
230
239
|
updated: r.last_activity || null,
|
|
231
240
|
reading: B.readingOf(r.nine_stages, { accepted: !!rec.accepted }),
|
|
232
241
|
lifecycle: rec.stopped ? rec.stopped.state : null,
|
|
242
|
+
stopped: rec.stopped ? { reason: rec.stopped.reason || '', at: rec.stopped.at || null, back: rec.stopped.back || null } : null,
|
|
233
243
|
hasSpec, record: relRec || null, domainFolder: dm ? dm[1] : null,
|
|
234
244
|
recordEpic: jiraTickets.anchorOf(fm),
|
|
235
245
|
});
|
|
@@ -252,7 +262,8 @@ function boardRow(board, base, folded) {
|
|
|
252
262
|
const row = {
|
|
253
263
|
id: base.id, title: base.title, lane: base.lane, stage: s.stage, cat, owner: base.owner, anchor: base.anchor,
|
|
254
264
|
horizon: base.horizon, status: base.status, start: base.start, end: base.end, progress: base.progress, warnings: [],
|
|
255
|
-
rank: base.rank, health: base.health, updated: base.updated, reading: base.reading, stageSource: s.source, lifecycle: base.lifecycle,
|
|
265
|
+
rank: base.rank, health: B.isActive(base) ? base.health : null, updated: base.updated, reading: base.reading, stageSource: s.source, lifecycle: base.lifecycle,
|
|
266
|
+
stopped: base.stopped || null, archived: archivedOf(folded, base.id),
|
|
256
267
|
template: B.templateOf(board, base.id, folded), workflow: wf.map((x) => ({ name: x.name, cat: x.cat })),
|
|
257
268
|
...epicsOf(base, folded),
|
|
258
269
|
};
|
|
@@ -273,7 +284,22 @@ function frameFor(ctx, w) {
|
|
|
273
284
|
return { w, folded, boards };
|
|
274
285
|
}
|
|
275
286
|
function rowsOfBoard(fr, board, base) {
|
|
276
|
-
|
|
287
|
+
const rows = base.filter((r) => boardIdOf(r, fr.boards, fr.folded, fr.w) === board.id).map((r) => boardRow(board, r, fr.folded));
|
|
288
|
+
// c3-initiative-lifecycle: what was merged into each row, and the likely duplicates among the active rows of this board.
|
|
289
|
+
const from = new Map();
|
|
290
|
+
for (const [id, a] of fr.folded.archives || []) if (a.into) { if (!from.has(a.into)) from.set(a.into, []); from.get(a.into).push(id); }
|
|
291
|
+
const dups = B.duplicatesOf(rows, fr.folded.notDup);
|
|
292
|
+
const titles = new Map(base.map((x) => [x.id, x.title]));
|
|
293
|
+
for (const r of rows) {
|
|
294
|
+
r.mergedFrom = from.get(r.id) || []; r.dupOf = dups.get(r.id) || null;
|
|
295
|
+
if (r.archived && r.archived.into) r.archived.intoTitle = titles.get(r.archived.into) || r.archived.into;
|
|
296
|
+
}
|
|
297
|
+
return rows;
|
|
298
|
+
}
|
|
299
|
+
/** The archive entry of an initiative, as a page row carries it, or null while it is not archived. */
|
|
300
|
+
function archivedOf(folded, id) {
|
|
301
|
+
const a = folded && folded.archives && folded.archives.get(id);
|
|
302
|
+
return a ? { at: a.at, by: actorLib.pageName(a.by || null), reason: a.reason, into: a.into, moved: a.moved } : null;
|
|
277
303
|
}
|
|
278
304
|
|
|
279
305
|
function readReleases(ctx) {
|
|
@@ -414,7 +440,7 @@ const READS = {
|
|
|
414
440
|
'/api/boards/backlog': (ctx, res, url) => {
|
|
415
441
|
const r = boardRead(ctx, res, url);
|
|
416
442
|
if (!r) return;
|
|
417
|
-
const q = Object.fromEntries(['q', 'stage', 'status', 'owner', 'horizon', 'anchor', 'cat', 'view', 'sort', 'offset', 'limit', 'me'].map((k) => [k, url.searchParams.get(k)]));
|
|
443
|
+
const q = Object.fromEntries(['q', 'stage', 'status', 'owner', 'horizon', 'anchor', 'cat', 'view', 'sort', 'offset', 'limit', 'me', 'archived'].map((k) => [k, url.searchParams.get(k)]));
|
|
418
444
|
if (!q.me) q.me = actorLib.PM_SLUG;
|
|
419
445
|
const out = B.filterRows(r.rows, { ...q, stages: r.board.workflow.map((s) => s.name) });
|
|
420
446
|
ctx.json(res, { ok: true, ws: r.w.id, board: r.board.id, ...out });
|
|
@@ -446,10 +472,16 @@ const READS = {
|
|
|
446
472
|
const own = r.fr.boards.find((b) => b.id === boardIdOf(base, r.fr.boards, r.fr.folded, r.w)) || r.board;
|
|
447
473
|
const row = boardRow(own, base, r.fr.folded);
|
|
448
474
|
const F = r.fr.folded;
|
|
475
|
+
const ownRows = own.id === r.board.id ? r.rows : rowsOfBoard(r.fr, own, r.base);
|
|
476
|
+
const mine = ownRows.find((x) => x.id === id);
|
|
477
|
+
row.mergedFrom = mine ? mine.mergedFrom : [];
|
|
478
|
+
row.dupOf = mine ? mine.dupOf : null;
|
|
479
|
+
if (row.archived && row.archived.into) row.archived.intoTitle = mine && mine.archived ? mine.archived.intoTitle : row.archived.into;
|
|
449
480
|
const history = [];
|
|
450
481
|
for (const m of F.moves) if (m.id === id) history.push({ kind: m.via === 'move' ? 'move' : m.via, ...pageMove(m) });
|
|
451
482
|
for (const e of F.events) {
|
|
452
483
|
if (e.kind === 'assign' && e.id === id) history.push({ kind: 'assign', at: e.at || null, by: actorLib.pageName(e.by || null), board: e.board || null, to: e.to });
|
|
484
|
+
if (e.kind === 'ini-archive' && (e.id === id || e.into === id)) history.push({ kind: e.archived === false ? 'restore' : (e.into ? 'merge' : 'archive'), at: e.at || null, by: actorLib.pageName(e.by || null), board: e.board || null, id: e.id, into: e.into || null, reason: e.reason || '' });
|
|
453
485
|
if (e.kind === 'ini-workflow' && e.id === id && Array.isArray(e.stages)) history.push({ kind: 'workflow', at: e.at || null, by: actorLib.pageName(e.by || null), board: e.board || null, template: e.template || null, stages: e.stages.map((x) => x && x.name).filter(Boolean) });
|
|
454
486
|
}
|
|
455
487
|
history.sort((a, b) => String(b.at || '').localeCompare(String(a.at || '')));
|
|
@@ -475,8 +507,9 @@ const READS = {
|
|
|
475
507
|
'/api/boards/items': (ctx, res, url) => {
|
|
476
508
|
const r = boardRead(ctx, res, url);
|
|
477
509
|
if (!r) return;
|
|
478
|
-
|
|
479
|
-
|
|
510
|
+
const live = r.rows.filter((x) => !x.archived); // an archived initiative leaves every list and timeline (c3-initiative-lifecycle)
|
|
511
|
+
ctx.json(res, { ok: true, ws: r.w.id, board: r.board.id, build: r.board.build, jira: r.board.jira, jiraProjects: r.board.jiraProjects, items: itemsOf(r.w, r.fr, r.board, live),
|
|
512
|
+
initiatives: live.map((x) => ({ id: x.id, title: x.title, stage: x.stage, cat: x.cat, status: x.status, owner: x.owner, horizon: x.horizon, start: x.start, end: x.end, progress: x.progress, template: x.template, epics: x.epics })) });
|
|
480
513
|
},
|
|
481
514
|
'/api/boards/sprints': (ctx, res, url) => {
|
|
482
515
|
const r = boardRead(ctx, res, url);
|
|
@@ -770,6 +803,86 @@ const WRITES = {
|
|
|
770
803
|
const t2 = B.taskOf(B.fold(readLedger(ctx, p.w)), t.id);
|
|
771
804
|
ctx.json(res, { ok: true, item: B.rollupItems([B.taskItem(t2)])[0] });
|
|
772
805
|
},
|
|
806
|
+
'/api/boards/archive': (ctx, res, body, g) => {
|
|
807
|
+
const p = postFrame(ctx, res, body);
|
|
808
|
+
if (!p) return;
|
|
809
|
+
if (typeof body.id !== 'string' || !body.id) return fail(ctx, res, 400, 'id is required');
|
|
810
|
+
if (typeof body.archived !== 'boolean') return fail(ctx, res, 400, 'archived is true (archive) or false (restore)');
|
|
811
|
+
if (body.reason != null && body.reason !== '' && !ONE_LINE(body.reason, 300)) return fail(ctx, res, 400, 'reason is one line of at most 300 characters');
|
|
812
|
+
if (!p.file) return fail(ctx, res, 409, NO_STATE);
|
|
813
|
+
const base = needRows(ctx, res, p.w);
|
|
814
|
+
if (!base) return;
|
|
815
|
+
const b0 = base.find((r) => r.id === body.id);
|
|
816
|
+
if (!b0 || boardIdOf(b0, p.fr.boards, p.fr.folded, p.w) !== p.board.id) return fail(ctx, res, 404, `no initiative ${body.id} on this board`);
|
|
817
|
+
const was = p.fr.folded.archives.get(body.id) || null;
|
|
818
|
+
if (body.archived && was) return fail(ctx, res, 409, `${body.id} is archived already`);
|
|
819
|
+
if (!body.archived && !was) return fail(ctx, res, 409, `${body.id} is not archived`);
|
|
820
|
+
if (!body.archived && was.into) {
|
|
821
|
+
// Undo a merge: the epic links and tasks it moved go back, unless the PM has moved them on since.
|
|
822
|
+
const F = p.fr.folded;
|
|
823
|
+
const intoBase = base.find((r) => r.id === was.into);
|
|
824
|
+
if (was.moved.epics.length && intoBase) {
|
|
825
|
+
const there = epicsOf(intoBase, F).epics;
|
|
826
|
+
append(p.file, g.actor, { kind: 'ini-epics', board: p.board.id, id: was.into, epics: there.filter((k) => !was.moved.epics.includes(k)), why: `restore ${body.id}` });
|
|
827
|
+
const here = epicsOf(b0, F).epics;
|
|
828
|
+
append(p.file, g.actor, { kind: 'ini-epics', board: p.board.id, id: body.id, epics: [...new Set([...here, ...was.moved.epics])], why: `restore ${body.id}` });
|
|
829
|
+
}
|
|
830
|
+
for (const tid of was.moved.tasks) {
|
|
831
|
+
const t = B.taskOf(F, tid);
|
|
832
|
+
if (t && t.initiative === was.into) append(p.file, g.actor, { kind: 'task-update', board: t.board, task: tid, initiative: body.id, why: `restore ${body.id}` });
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
append(p.file, g.actor, body.archived
|
|
836
|
+
? { kind: 'ini-archive', board: p.board.id, id: body.id, archived: true, reason: body.reason ? body.reason.trim() : '' }
|
|
837
|
+
: { kind: 'ini-archive', board: p.board.id, id: body.id, archived: false });
|
|
838
|
+
const row = boardRow(p.board, b0, B.fold(readLedger(ctx, p.w)));
|
|
839
|
+
ctx.json(res, { ok: true, row });
|
|
840
|
+
},
|
|
841
|
+
'/api/boards/merge': (ctx, res, body, g) => {
|
|
842
|
+
const p = postFrame(ctx, res, body);
|
|
843
|
+
if (!p) return;
|
|
844
|
+
for (const k of ['id', 'into']) if (typeof body[k] !== 'string' || !body[k]) return fail(ctx, res, 400, `${k} is required`);
|
|
845
|
+
if (body.id === body.into) return fail(ctx, res, 400, 'an initiative cannot be merged into itself');
|
|
846
|
+
for (const k of ['epics', 'tasks']) if (body[k] != null && (!Array.isArray(body[k]) || body[k].length > 200 || body[k].some((x) => typeof x !== 'string'))) return fail(ctx, res, 400, `${k} is a list of ids`);
|
|
847
|
+
if (!p.file) return fail(ctx, res, 409, NO_STATE);
|
|
848
|
+
const base = needRows(ctx, res, p.w);
|
|
849
|
+
if (!base) return;
|
|
850
|
+
const F = p.fr.folded;
|
|
851
|
+
const on = (id) => { const b = base.find((r) => r.id === id); return b && boardIdOf(b, p.fr.boards, F, p.w) === p.board.id ? b : null; };
|
|
852
|
+
const src = on(body.id);
|
|
853
|
+
const dst = on(body.into);
|
|
854
|
+
if (!src) return fail(ctx, res, 404, `no initiative ${body.id} on this board`);
|
|
855
|
+
if (!dst) return fail(ctx, res, 404, `no initiative ${body.into} on this board (an initiative merges into one on the same board)`);
|
|
856
|
+
if (F.archives.has(src.id)) return fail(ctx, res, 409, `${src.id} is archived already`);
|
|
857
|
+
if (F.archives.has(dst.id)) return fail(ctx, res, 409, `${dst.id} is archived, so nothing can be merged into it`);
|
|
858
|
+
const srcEpics = epicsOf(src, F).epics;
|
|
859
|
+
const epics = [...new Set((body.epics || []).map((k) => k.trim().toUpperCase()))];
|
|
860
|
+
const offE = epics.filter((k) => !srcEpics.includes(k));
|
|
861
|
+
if (offE.length) return fail(ctx, res, 400, `${offE.join(', ')} ${offE.length === 1 ? 'is' : 'are'} not linked to ${src.id}`);
|
|
862
|
+
if (epics.length && !p.board.build) return fail(ctx, res, 409, `${p.board.name} has Build and deliver off, so it links no Jira epics`);
|
|
863
|
+
const tasks = [...new Set(body.tasks || [])];
|
|
864
|
+
const offT = tasks.filter((t) => { const x = B.taskOf(F, t); return !x || x.initiative !== src.id; });
|
|
865
|
+
if (offT.length) return fail(ctx, res, 400, `${offT.join(', ')} ${offT.length === 1 ? 'is' : 'are'} not a PMOS task of ${src.id}`);
|
|
866
|
+
if (epics.length) {
|
|
867
|
+
append(p.file, g.actor, { kind: 'ini-epics', board: p.board.id, id: dst.id, epics: [...new Set([...epicsOf(dst, F).epics, ...epics])], why: `merge ${src.id}` });
|
|
868
|
+
append(p.file, g.actor, { kind: 'ini-epics', board: p.board.id, id: src.id, epics: srcEpics.filter((k) => !epics.includes(k)), why: `merge into ${dst.id}` });
|
|
869
|
+
}
|
|
870
|
+
for (const t of tasks) append(p.file, g.actor, { kind: 'task-update', board: B.taskOf(F, t).board, task: t, initiative: dst.id, why: `merge into ${dst.id}` });
|
|
871
|
+
append(p.file, g.actor, { kind: 'ini-archive', board: p.board.id, id: src.id, archived: true, into: dst.id, reason: `Merged into ${dst.title}`, moved: { epics, tasks } });
|
|
872
|
+
const after = B.fold(readLedger(ctx, p.w));
|
|
873
|
+
const fr2 = { ...p.fr, folded: after };
|
|
874
|
+
const rows = rowsOfBoard(fr2, p.board, base);
|
|
875
|
+
ctx.json(res, { ok: true, row: rows.find((r) => r.id === src.id), into: rows.find((r) => r.id === dst.id) });
|
|
876
|
+
},
|
|
877
|
+
'/api/boards/notdup': (ctx, res, body, g) => {
|
|
878
|
+
const p = postFrame(ctx, res, body);
|
|
879
|
+
if (!p) return;
|
|
880
|
+
for (const k of ['id', 'of']) if (typeof body[k] !== 'string' || !body[k]) return fail(ctx, res, 400, `${k} is required`);
|
|
881
|
+
if (body.id === body.of) return fail(ctx, res, 400, 'id and of are the same initiative');
|
|
882
|
+
if (!p.file) return fail(ctx, res, 409, NO_STATE);
|
|
883
|
+
append(p.file, g.actor, { kind: 'ini-notdup', board: p.board.id, id: body.id, of: body.of });
|
|
884
|
+
ctx.json(res, { ok: true });
|
|
885
|
+
},
|
|
773
886
|
'/api/boards/asks/decline': (ctx, res, body, g) => {
|
|
774
887
|
const p = postFrame(ctx, res, body);
|
|
775
888
|
if (!p) return;
|
|
@@ -64,6 +64,11 @@ function findEvent(events, id, date, by) {
|
|
|
64
64
|
const onDay = rows.filter((e) => !date || String(e.at || '').slice(0, 10) === date);
|
|
65
65
|
return onDay.find((e) => String(e.id) === id) || onDay[0] || null;
|
|
66
66
|
}
|
|
67
|
+
/** A recording that is not on the calendar (a talk in the corridor): the note stands in for the meeting. It has no end, no people
|
|
68
|
+
* and no brief, so focus shows what is said, PMOS's notes and Captured, and no topics from an invite. */
|
|
69
|
+
function noteEvent(n) {
|
|
70
|
+
return { id: 'note:' + n.id, title: n.title || (n.meeting && n.meeting.title) || 'Conversation', at: n.recorded_from || n.created_at, end: null, attendees: [], links: [] };
|
|
71
|
+
}
|
|
67
72
|
function noteFor(runtimeDir, ev, noteId) {
|
|
68
73
|
if (noteId) { const r = notes.read(runtimeDir, String(noteId)); return r.ok ? r.note : null; }
|
|
69
74
|
const hit = notes.findByEvent(runtimeDir, { event_id: String(ev.id), start: ev.at, title: ev.title }) || notes.findByEvent(runtimeDir, { event_id: baseId(ev.id), start: ev.at, title: ev.title });
|
|
@@ -138,17 +143,22 @@ function startRun(ctx, key, ev, brief, note, state, topics, now) {
|
|
|
138
143
|
async function read(q, ctx) {
|
|
139
144
|
const id = String((q && q.event) || '');
|
|
140
145
|
const by = q && q.at ? { at: String(q.at).slice(0, 40), title: String(q.title || '').slice(0, 200) } : null;
|
|
141
|
-
|
|
146
|
+
const only = !id && !by && q && q.note ? notes.read(ctx.runtimeDir, String(q.note).slice(0, 120)) : null; // a recording with no meeting
|
|
147
|
+
if (only && !only.ok) return fail(404, 'no such note');
|
|
148
|
+
if ((!id && !by && !only) || id.length > 120) return fail(400, 'event or note is required');
|
|
142
149
|
const date = q && q.date ? String(q.date) : '';
|
|
143
150
|
if (date && !DAY.test(date)) return fail(400, 'date must be YYYY-MM-DD');
|
|
144
151
|
const nowMs = typeof ctx.now === 'function' ? ctx.now() : Date.now();
|
|
145
152
|
let events = [];
|
|
146
|
-
|
|
147
|
-
|
|
153
|
+
const om = only && only.note.meeting && only.note.meeting.kind === 'event' && only.note.meeting.event_id ? only.note.meeting : null; // a note that knows its meeting finds it
|
|
154
|
+
if (!only || om) { try { events = typeof ctx.readEvents === 'function' ? ctx.readEvents() : require('./calendar-events.js').readAll(ctx.runtimeDir, { now: nowMs }); } catch { events = []; } }
|
|
155
|
+
const met = om ? findEvent(events, String(om.event_id), date || String(om.start || '').slice(0, 10)) : null;
|
|
156
|
+
const talk = !!only && !met;
|
|
157
|
+
const ev = talk ? noteEvent(only.note) : met || findEvent(events, id, date, by);
|
|
148
158
|
if (!ev) return fail(404, 'no such meeting on the calendar');
|
|
149
159
|
const day = date || String(ev.at || '').slice(0, 10);
|
|
150
|
-
const brief = typeof ctx.briefFor === 'function' ? await ctx.briefFor(ev, day) : await realBrief(ctx, ev, day);
|
|
151
|
-
const note = noteFor(ctx.runtimeDir, ev, q && q.note);
|
|
160
|
+
const brief = talk ? null : typeof ctx.briefFor === 'function' ? await ctx.briefFor(ev, day) : await realBrief(ctx, ev, day);
|
|
161
|
+
const note = only ? only.note : noteFor(ctx.runtimeDir, ev, q && q.note);
|
|
152
162
|
const startMs = Date.parse(ev.at);
|
|
153
163
|
const topics = F.topicsFrom(ev, brief);
|
|
154
164
|
const keys = [keyOf(ev.id), note ? keyOf(note.id) : null];
|
|
@@ -168,6 +178,7 @@ async function read(q, ctx) {
|
|
|
168
178
|
const sessions = note && Array.isArray(note.sessions) && note.sessions.length ? note.sessions : [];
|
|
169
179
|
return { status: 200, body: {
|
|
170
180
|
ok: true, now_ms: nowMs, date: day,
|
|
181
|
+
talk,
|
|
171
182
|
event: { id: ev.id, title: ev.title, at: ev.at, end: ev.end, attendees: [].concat(ev.attendees || ev.people || []).map((p) => (p && typeof p === 'object' ? p.name || p.id : p)).filter(Boolean).map(String),
|
|
172
183
|
initiative: ev.initiative || null, purpose: ev.purpose || (brief && brief.purpose) || null, links },
|
|
173
184
|
brief: brief || null,
|
|
@@ -255,11 +266,12 @@ async function ask(req, body, ctx) {
|
|
|
255
266
|
let events = [];
|
|
256
267
|
try { events = typeof ctx.readEvents === 'function' ? ctx.readEvents() : require('./calendar-events.js').readAll(ctx.runtimeDir); } catch { events = []; }
|
|
257
268
|
ev = body.event ? findEvent(events, String(body.event), '') : null;
|
|
258
|
-
if (!ev && k.note && k.note.meeting) ev = { id: k.note.meeting.event_id || k.note.id, title: k.note.meeting.title || k.note.title, at: k.note.meeting.start, end: k.note.meeting.end };
|
|
269
|
+
if (!ev && k.note && k.note.meeting && k.note.meeting.kind === 'event') ev = { id: k.note.meeting.event_id || k.note.id, title: k.note.meeting.title || k.note.title, at: k.note.meeting.start, end: k.note.meeting.end };
|
|
270
|
+
else if (!ev && k.note) { ev = noteEvent(k.note); ev.talk = true; }
|
|
259
271
|
}
|
|
260
272
|
if (!ev) return fail(404, 'no such meeting on the calendar');
|
|
261
273
|
const day = String(ev.at || '').slice(0, 10);
|
|
262
|
-
const brief = typeof ctx.briefFor === 'function' ? await ctx.briefFor(ev, day) : await realBrief(ctx, ev, day);
|
|
274
|
+
const brief = ev.talk ? null : typeof ctx.briefFor === 'function' ? await ctx.briefFor(ev, day) : await realBrief(ctx, ev, day);
|
|
263
275
|
const topics = F.topicsFrom(ev, brief);
|
|
264
276
|
const keys = [keyOf(ev.id), k.note ? keyOf(k.note.id) : null];
|
|
265
277
|
const startMs = Date.parse(ev.at);
|
|
@@ -1603,7 +1603,8 @@ function readLifecycle(root) {
|
|
|
1603
1603
|
for (const r of cachedJsonl(path.join(L.runsDir, 'lifecycle.jsonl'))) {
|
|
1604
1604
|
if (!r || typeof r.initiative_id !== 'string') continue;
|
|
1605
1605
|
if (r.state !== 'active' && !STOPPED_STATES.includes(r.state)) continue;
|
|
1606
|
-
|
|
1606
|
+
// `back` (c3-initiative-lifecycle): the day an on-hold initiative comes back to the PM, when the hold named one.
|
|
1607
|
+
latest.set(r.initiative_id, { state: r.state, reason: r.reason || '', pm: r.pm || '', at: r.at || '', ...(r.state === 'on-hold' && /^\d{4}-\d{2}-\d{2}$/.test(r.back || '') ? { back: r.back } : {}) });
|
|
1607
1608
|
}
|
|
1608
1609
|
return latest;
|
|
1609
1610
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.187.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.187.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED