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.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/scripts/workflow-state.js +2 -1
  6. package/payload/state/discoveries.md +1 -1
  7. package/workspace/c3-ib-triage-backlog-test.js +20 -13
  8. package/workspace/c3-ini-life-test.js +167 -0
  9. package/workspace/c3-initiatives-test.js +6 -6
  10. package/workspace/c3-phone-test.js +67 -16
  11. package/workspace/c3-quiet-test.js +2 -1
  12. package/workspace/guide/initiative-boards.md +37 -2
  13. package/workspace/guide/phone-companion.md +19 -4
  14. package/workspace/lib/engine/boards.js +73 -18
  15. package/workspace/lib/engine/workflow-state.js +2 -1
  16. package/workspace/lib/initiative-rows.js +2 -2
  17. package/workspace/lib/routes-boards.js +119 -6
  18. package/workspace/lib/routes-meeting-focus.js +19 -7
  19. package/workspace/lib/vendor/scripts/workflow-state.js +2 -1
  20. package/workspace/package-lock.json +2 -2
  21. package/workspace/package.json +1 -1
  22. package/workspace/public/pm/c3-demo-boards.js +82 -17
  23. package/workspace/public/pm/c3-demo-focus.js +39 -1
  24. package/workspace/public/pm/c3-focus.css +53 -0
  25. package/workspace/public/pm/c3-focus.js +188 -44
  26. package/workspace/public/pm/c3-ib-initiatives.js +127 -60
  27. package/workspace/public/pm/c3-ib-items.js +2 -2
  28. package/workspace/public/pm/c3-ini-life.css +32 -0
  29. package/workspace/public/pm/c3-ini-life.js +379 -0
  30. package/workspace/public/pm/c3-ini-ui.js +12 -7
  31. package/workspace/public/pm/c3-initiatives.js +14 -4
  32. package/workspace/public/pm/c3-phone.css +3 -1
  33. package/workspace/public/pm/c3-phone.js +28 -8
  34. package/workspace/server.js +7 -2
  35. 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**, and **Open the meeting companion**, which opens the meeting focus page. |
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 the meeting companion.
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. While it records, the bar shows the name, the time and **Stop**, and the top card shows the notes that are being taken: the last lines that were heard, and the decisions, actions and open questions that PMOS has found so far. The card reads the note again every 5 seconds.
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
- **Open the meeting companion** opens the meeting helper over the companion. To go back, press **Back to your day** at the top or the bottom. The meeting continues to record.
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
- const STATUSES = Object.freeze(['on track', 'at risk', 'late', 'done']);
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
- function viewFilter(view, me) {
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.status !== 'done';
435
- case 'mine': return (r) => !!r.owner && mine.has(lc(r.owner)) && r.status !== 'done';
436
- case 'risk': return (r) => r.status === 'at risk' || r.status === 'late';
437
- case 'noowner': return (r) => !r.owner && r.status !== 'done';
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) }. Facets count the rows after the view and the
452
- * search, before the stage/status/owner/horizon/anchor filters, so each filter shows what choosing it would give.
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 view = VIEWS.includes(query.view) ? query.view : 'now';
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
- let base = (rows || []).filter(viewFilter(view, query.me));
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
- let out = base;
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.status !== 'done');
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.status !== 'done') open++;
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
- latest.set(r.initiative_id, { state: r.state, reason: r.reason || '', pm: r.pm || '', at: r.at || '' });
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 && ['closed', 'cancelled'].includes(rec.stopped.state))) return 'done';
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
- return base.filter((r) => boardIdOf(r, fr.boards, fr.folded, fr.w) === board.id).map((r) => boardRow(board, r, fr.folded));
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
- 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, r.rows),
479
- initiatives: r.rows.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 })) });
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
- if ((!id && !by) || id.length > 120) return fail(400, 'event is required');
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
- try { events = typeof ctx.readEvents === 'function' ? ctx.readEvents() : require('./calendar-events.js').readAll(ctx.runtimeDir, { now: nowMs }); } catch { events = []; }
147
- const ev = findEvent(events, id, date, by);
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
- latest.set(r.initiative_id, { state: r.state, reason: r.reason || '', pm: r.pm || '', at: r.at || '' });
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.186.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.186.0",
9
+ "version": "0.187.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.186.0",
3
+ "version": "0.187.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",