create-pmos 0.153.0 → 0.154.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.153.0",
3
+ "version": "0.154.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@f059e84
1
+ kernel-source: pmos@c6cee1a
2
2
  assembled: 2026-10-12
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-12T04:29:06Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T04:39:11Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-10-12T04:29:06Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T04:39:11Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-10-12T04:29:06Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T04:39:11Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -13,7 +13,7 @@ Every card in a deck has the same height at rest. Text that does not fit is cut
13
13
 
14
14
  The page background is white. A soft, slow glow of colour drifts behind the page. With reduced motion on, it is one still picture.
15
15
 
16
- The tabs show counts: Briefing shows how many things need you, Work shows its inbox, and Initiatives shows new asks to triage. The Sessions button and the dock button show a bolt. In the demo, the Record button and its arrow are visible when nothing is recording.
16
+ The tabs show counts: Briefing shows how many things need you, Work shows its inbox, and Initiatives shows new asks to triage. The Sessions button is a bolt icon with no label, and the dock button shows a bolt too. In the demo, the Record button and its arrow are visible when nothing is recording.
17
17
 
18
18
  ### Kinds of card
19
19
 
@@ -91,11 +91,34 @@ Press a meeting to show its context in the side column. Under the title, a row s
91
91
  - **To prepare**: what is prepared, what is still open, and how to be ready, from today's brief. The links from the brief follow.
92
92
  - A last line names the sources that PMOS used.
93
93
 
94
- The initiative of a coming meeting is the one on the event. If the event has none, it is the one in today's brief. If the brief has none, it is the one on the last note of the meeting.
94
+ ### What a meeting is about
95
+
96
+ A coming meeting is about the **Portfolio**, one **board** or one **initiative**. A switch under the title shows the level, and a line next to it tells you why PMOS chose it. PMOS uses the first of these that applies:
97
+
98
+ 1. The level you set with the switch.
99
+ 2. The initiative in today's brief.
100
+ 3. The level of the last note of the meeting.
101
+ 4. A board that the title names: its name, its id or one of its Jira project keys, as a whole word. For example, "IDPF - DSU" names the IDPF board.
102
+ 5. The word "portfolio" in the title.
103
+
104
+ To set the level, press **Portfolio**, **Board** or **Initiative**. If there is more than one board or initiative, pick one from the list. The level applies to every occurrence of the meeting (the same calendar series, or the same title). To remove a level that you set, press **Unlink**. PMOS keeps the level next to the calendar. It does not change your calendar.
105
+
106
+ The level changes what the detail reads:
107
+
108
+ - **Initiative**: the parts above.
109
+ - **Board**:
110
+ - The active sprint from Jira: its name, the working days left, its end date and a bar of done, in progress and to do.
111
+ - **What matters today**: the blocked tickets of the sprint, the initiatives on the board that are late or at risk, and the new asks in triage. A ticket opens the board's Sprints and an ask opens Triage.
112
+ - For a planning meeting, the list is called **To settle in planning**, and the sprint line also shows the next sprint and how many open tickets can carry over.
113
+ - **Portfolio**:
114
+ - **Boards** lists each board with its health and its counts.
115
+ - **What matters today** shows the late and at-risk initiatives on all boards, a release in the next 14 days and the new asks.
116
+
117
+ At every level, **Since the last one**, **To prepare** and the sources line stay.
95
118
 
96
119
  When the meeting has a note, the detail has **Open the note**. It opens the note in **Notes**.
97
120
 
98
- When PMOS holds nothing for a past meeting, the detail says so. For a coming meeting, **What PMOS knows** tells you what is missing (no initiative, no earlier note) and what to do: link an initiative, or take a note in the meeting. It does not guess.
121
+ When PMOS holds nothing for a past meeting, the detail says so. For a coming meeting, **What PMOS knows** tells you what is missing (no level, no earlier note) and what to do: set the level with the switch, or take a note in the meeting. It does not guess.
99
122
 
100
123
  ## Free time
101
124
 
@@ -7,7 +7,11 @@ timestamp: 2026-10-12
7
7
 
8
8
  # Sessions in console3: open, read and end your sessions
9
9
 
10
- Press **Sessions** in the console3 header to open the panel. It shows your sessions in four groups: **On a card**, **Started by me**, **Past (7 days)** and **Automatic**.
10
+ Press the **Sessions** button in the console3 header to open the panel. It is the bolt icon with no label. Point at it to see its name. When a session needs you, a count shows on it. It shows your sessions in four groups: **On a card**, **Started by me**, **Past (7 days)** and **Automatic**.
11
+
12
+ ## The dock
13
+
14
+ The dock is open on the Briefing. On the other tabs it is folded away, and no rail of buttons is left at the right edge. To open the dock again, press Ctrl J (⌘J on a Mac), or use **Open the dock** in the **More** menu.
11
15
 
12
16
  ## Read a row
13
17
 
@@ -370,7 +370,11 @@ function contextFrom(events, range, src, opts = {}) {
370
370
  }
371
371
  const kept = capKinds(items);
372
372
  context[e.id] = { phase: past ? 'past' : 'upcoming', held: kept.length > 0, items: kept };
373
- if (!past) { const b = briefOf(e, notes, s, { today, inis: opts.inis }); if (b) context[e.id].brief = b; }
373
+ if (!past) {
374
+ const b = briefOf(e, notes, s, { today, inis: opts.inis, links: opts.links, boards: opts.boards });
375
+ if (b && b.level) context[e.id].level = b.level;
376
+ if (b && b.brief) context[e.id].brief = b.brief;
377
+ }
374
378
  }
375
379
  return { context, context_total: inside.length };
376
380
  }
@@ -423,23 +427,29 @@ function mattersOf(iniId, src, today) {
423
427
 
424
428
  const plural = (n, one, many) => `${n} ${n === 1 ? one : many || one + 's'}`;
425
429
 
426
- function briefOf(e, current, s, { today, inis } = {}) {
430
+ function briefOf(e, current, s, { today, inis, links: meetingLinks, boards } = {}) {
427
431
  const earlier = seriesNotes(e, s.notes || [], current);
428
432
  const lastNote = earlier[0] || null;
429
- const iniId = iniIdOf(e.initiative) || (earlier.find((n) => n.initiative) || {}).initiative || null;
433
+ const boardList = boards && typeof boards.list === 'function' ? safe(() => boards.list(), []) : [];
434
+ const level = levelOf(e, { link: meetingLinks && typeof meetingLinks.get === 'function' ? meetingLinks.get(memory.seriesKey({ series: e.series || null, title: e.title })) : null, earlier, boards: boardList, inis });
435
+ const wide = level && (level.kind === 'board' || level.kind === 'portfolio');
436
+ const iniId = level && level.kind === 'initiative' ? level.id : null;
430
437
  const row = iniId ? (inis || []).find((g) => g && g.id === iniId) : null;
431
438
  const initiative = iniId ? { id: iniId, title: str(row && row.title) || iniId, status: str(row && row.status) || null } : null;
432
- const matters = mattersOf(iniId, s, today);
439
+ const part = !wide ? null : level.kind === 'board' ? boardPart(boards && typeof boards.detail === 'function' ? safe(() => boards.detail(level.ws, level.id), null) : null, e, level)
440
+ : portfolioPart(boardList, boards, today);
441
+ const matters = part ? part.matters : mattersOf(iniId, s, today);
433
442
  const purpose = str(e.purpose) || null;
434
443
  const why = e.initiative && typeof e.initiative === 'object' ? str(e.initiative.why) : str(e.initiative_why);
435
444
  const gist = [];
436
445
  if (purpose) gist.push(/[.!?]$/.test(purpose) ? purpose : purpose + '.');
437
- else if (initiative) gist.push(`For ${initiative.title}` + (initiative.status && !['no plan', 'on time'].includes(initiative.status) ? `, which is ${initiative.status}.` : '.'));
446
+ if (part) gist.push(...part.gist.slice(purpose ? 1 : 0)); // the purpose replaces the part's own "A meeting of …" sentence
447
+ else if (!purpose && initiative) gist.push(`For ${initiative.title}` + (initiative.status && !['no plan', 'on time'].includes(initiative.status) ? `, which is ${initiative.status}.` : '.'));
438
448
  if (why) gist.push(/[.!?]$/.test(why) ? why : why + '.');
439
449
  const blocked = matters.filter((x) => x.tag === 'Blocked').length; const late = matters.filter((x) => x.tag === 'Late').length;
440
450
  const dueNow = matters.filter((x) => x.tag === 'Due today').length;
441
451
  const parts = [blocked && plural(blocked, 'item') + ' blocked', late && plural(late, 'item') + ' late', dueNow && plural(dueNow, 'item') + ' due today'].filter(Boolean);
442
- if (parts.length) gist.push(parts.join(', ').replace(/^./, (c) => c.toUpperCase()) + '.');
452
+ if (parts.length && !part) gist.push(parts.join(', ').replace(/^./, (c) => c.toUpperCase()) + '.');
443
453
  let last = null;
444
454
  if (lastNote) {
445
455
  last = { id: lastNote.id, title: str(lastNote.title) || str(lastNote.meeting.title), at: lastNote.meeting.start, private: !!lastNote.private };
@@ -456,18 +466,127 @@ function briefOf(e, current, s, { today, inis } = {}) {
456
466
  if (purpose || why || prep || links.length || refs.length) sources.push("today's brief");
457
467
  if (earlier.length) sources.push(plural(earlier.length, 'earlier note') + ' of this meeting');
458
468
  if (initiative) sources.push('the ' + initiative.id + ' plan');
469
+ if (part) sources.push(...part.sources);
459
470
  if (matters.some((x) => x.ref.type === 'item')) sources.push('your items');
460
- if (!gist.length && !matters.length && !last && !links.length && !refs.length && !prep && !initiative) return null;
461
- return { initiative, gist: gist.slice(0, 3), matters, last, earlier: earlier.length, purpose, prep, links, refs, sources };
471
+ if (!gist.length && !matters.length && !last && !links.length && !refs.length && !prep && !initiative && !part) return { level, brief: null };
472
+ const brief = { initiative, gist: gist.slice(0, 4), matters, last, earlier: earlier.length, purpose, prep, links, refs, sources };
473
+ if (part) { brief.planning = !!part.planning; for (const k of ['board', 'sprint', 'next', 'boards']) if (part[k]) brief[k] = part[k]; }
474
+ return { level, brief };
475
+ }
476
+
477
+ /* ── what a meeting is about: the Portfolio, a board or an initiative (initiative c3-meeting-board) ─────────────────────────
478
+ * The first that holds: the link the PM set on the meeting (lib/calendar-links.js), the initiative today's brief names, the
479
+ * latest earlier note's link (note.portfolio, note.initiative, note.board), a board the title names (its name, its id or one of
480
+ * its Jira project keys, as a whole word), and the word "portfolio" in the title. null when none holds.
481
+ * level: { kind: portfolio|board|initiative, ws?, id?, name, why, by: pm|brief|note|title } */
482
+ const safe = (fn, dflt) => { try { const v = fn(); return v == null ? dflt : v; } catch { return dflt; } };
483
+ const PORTFOLIO = { kind: 'portfolio', name: 'Portfolio' };
484
+ const padded = (t) => ' ' + norm(t) + ' ';
485
+
486
+ function levelOf(e, { link, earlier, boards, inis } = {}) {
487
+ const bl = boards || [];
488
+ const boardNamed = (ws, id, fallback) => { const b = bl.find((x) => x.id === id && (!ws || x.ws === ws)) || bl.find((x) => x.id === id); return b ? { ws: b.ws, id: b.id, name: b.name } : { ws: ws || null, id, name: fallback || id }; };
489
+ const iniNamed = (id) => { const r = (inis || []).find((g) => g && g.id === id); return str(r && r.title) || id; };
490
+ if (link && link.kind === 'portfolio') return { ...PORTFOLIO, why: 'you linked it to the Portfolio', by: 'pm' };
491
+ if (link && link.kind === 'board' && link.board) return { kind: 'board', ...boardNamed(link.ws, link.board, link.title), why: 'you linked it to the board', by: 'pm' };
492
+ if (link && link.kind === 'initiative' && link.initiative) return { kind: 'initiative', id: link.initiative, name: iniNamed(link.initiative), why: 'you linked it to the initiative', by: 'pm' };
493
+ const named = iniIdOf(e.initiative);
494
+ if (named) return { kind: 'initiative', id: named, name: iniNamed(named), why: "today's brief names the initiative", by: 'brief' };
495
+ const n = (earlier || []).find((x) => x && (x.portfolio || x.initiative || (x.board && x.board.id)));
496
+ if (n && n.portfolio) return { ...PORTFOLIO, why: 'the last note of this meeting is about the Portfolio', by: 'note' };
497
+ if (n && n.initiative) return { kind: 'initiative', id: n.initiative, name: iniNamed(n.initiative), why: 'the last note of this meeting is about it', by: 'note' };
498
+ if (n && n.board) return { kind: 'board', ...boardNamed(n.board.ws, n.board.id, n.board.title), why: 'the last note of this meeting is about the board', by: 'note' };
499
+ const title = padded(e.title);
500
+ for (const b of bl) {
501
+ const words = [b.name, b.id, ...(b.jiraProjects || [])].map((w) => str(w)).filter((w) => norm(w).length >= 3);
502
+ const hit = words.find((w) => title.includes(padded(w)));
503
+ if (hit) return { kind: 'board', ws: b.ws, id: b.id, name: b.name, why: `the title names the board (${hit})`, by: 'title' };
504
+ }
505
+ if (/\bportfolio\b/i.test(String(e.title || ''))) return { ...PORTFOLIO, why: 'the title says Portfolio', by: 'title' };
506
+ return null;
507
+ }
508
+
509
+ const PLANNING = /\b(sprint\s+)?planning\b/i;
510
+ const BLOCKED_STATUS = /\bblock/i;
511
+
512
+ /** One board, for a meeting about it: the active sprint (and, for a planning meeting, the next one), its blocked tickets, the
513
+ * initiatives that are late or at risk, and the open asks. d is routes-boards.js meetingBoards().detail(ws, id). */
514
+ function boardPart(d, e, level) {
515
+ const name = (d && d.name) || level.name || level.id;
516
+ const gist = [`A meeting of the ${name} board.`];
517
+ if (!d) return { gist: [`A meeting of the ${name} board. PMOS could not read the board.`], matters: [], sources: [] };
518
+ if (d.pending) return { gist: [`A meeting of the ${name} board. The board is still being read.`], matters: [], sources: ['the ' + name + ' board'], board: { ws: d.ws, id: d.id, name } };
519
+ const all = (d.sprints && d.sprints.sprints) || [];
520
+ const active = all.find((x) => x.state === 'active') || null;
521
+ const future = all.find((x) => x.state === 'future') || null;
522
+ const planning = PLANNING.test(String(e.title || '')) || PLANNING.test(String(e.purpose || ''));
523
+ const isBlocked = (i) => i.category !== 'done' && (i.flagged || BLOCKED_STATUS.test(i.status || ''));
524
+ const matters = [];
525
+ let sprint = null;
526
+ if (active) {
527
+ const c = active.counts || { todo: 0, doing: 0, done: 0 };
528
+ const total = c.todo + c.doing + c.done; const open = c.todo + c.doing;
529
+ const blocked = (active.issues || []).filter(isBlocked);
530
+ sprint = { name: active.name, start: active.start ? String(active.start).slice(0, 10) : null, end: active.end ? String(active.end).slice(0, 10) : null, counts: c, total, open, blocked: blocked.length, read_at: d.sprints.readAt || null };
531
+ gist.push(`${active.name} has ${open} of ${plural(total, 'ticket')} still open` + (blocked.length ? `, and ${blocked.length} ${blocked.length === 1 ? 'is' : 'are'} blocked.` : '.'));
532
+ for (const i of blocked) matters.push({ rank: 0, tone: 'bad', tag: 'Blocked', text: i.key + ' ' + (i.summary || '') + (i.reason ? ': ' + i.reason : ''), meta: [i.status, i.flagged && 'flagged in Jira'].filter(Boolean).join(' · '), ref: { type: 'jira', id: i.key, board: d.id, ws: d.ws } });
533
+ }
534
+ const next = planning && future ? { name: future.name, planned: (future.issues || []).length, carry: sprint ? sprint.open : 0 } : null;
535
+ const rows = d.rows || [];
536
+ const late = rows.filter((r) => r.status === 'late'); const risk = rows.filter((r) => r.status === 'at risk');
537
+ for (const r of late) matters.push({ rank: 1, tone: 'bad', tag: 'Late', text: r.title, meta: 'initiative on this board' + (r.end ? ' · due ' + r.end : ''), ref: { type: 'initiative', id: r.id } });
538
+ for (const r of risk) matters.push({ rank: 2, tone: 'warn', tag: 'At risk', text: r.title, meta: 'initiative on this board' + (r.end ? ' · due ' + r.end : ''), ref: { type: 'initiative', id: r.id } });
539
+ for (const a of d.asks || []) matters.push({ rank: 3, tone: 'warn', tag: 'New ask', text: a.line, meta: 'waiting in triage', ref: { type: 'ask', id: a.id, board: d.id, ws: d.ws } });
540
+ const ini = [late.length && plural(late.length, 'initiative') + ' late', risk.length && plural(risk.length, 'initiative') + ' at risk'].filter(Boolean);
541
+ if (ini.length) gist.push('On the board: ' + ini.join(' and ') + '.');
542
+ if ((d.asks || []).length) gist.push(`${plural(d.asks.length, 'new ask')} ${d.asks.length === 1 ? 'waits' : 'wait'} in triage.`);
543
+ const sources = ['the ' + name + ' board'];
544
+ if (sprint) sources.push('its ' + sprint.name + ' tickets from Jira');
545
+ matters.sort((a, b) => a.rank - b.rank);
546
+ return { gist, matters: matters.slice(0, MATTERS_CAP).map(({ rank, ...x }) => x), sources, planning: !!next,
547
+ board: { ws: d.ws, id: d.id, name, health: d.health || null, counts: d.counts || null, next_release: d.nextRelease || null }, sprint, next };
548
+ }
549
+
550
+ const PORTFOLIO_CAP = 12;
551
+ const RELEASE_DAYS = 14;
552
+ /** Every board, for a meeting about the Portfolio: each board's health in one line, then the late and at-risk initiatives, the
553
+ * releases of the next RELEASE_DAYS days and the open asks across the boards. */
554
+ function portfolioPart(list, boards, today) {
555
+ const ds = (list || []).slice(0, PORTFOLIO_CAP).map((b) => (boards && typeof boards.detail === 'function' ? safe(() => boards.detail(b.ws, b.id), null) : null) || { ws: b.ws, id: b.id, name: b.name, error: 'not read' });
556
+ const rows = ds.map((d) => {
557
+ if (d.pending || d.error || !d.counts) return { ws: d.ws, id: d.id, name: d.name, health: null, line: d.pending ? 'still being read' : 'could not be read' };
558
+ const c = d.counts; const asks = (d.asks || []).length;
559
+ const line = [plural(c.open, 'open initiative'), c.late ? c.late + ' late' : null, c.risk ? c.risk + ' at risk' : null, asks ? plural(asks, 'ask') : null].filter(Boolean).join(' · ');
560
+ return { ws: d.ws, id: d.id, name: d.name, health: d.health || 'ok', line };
561
+ });
562
+ const matters = [];
563
+ let late = 0; let risk = 0; let asks = 0; let rel = 0;
564
+ const soon = today ? addDays(today, RELEASE_DAYS) : null;
565
+ for (const d of ds) {
566
+ for (const r of d.rows || []) {
567
+ if (r.status === 'late') late++; else risk++;
568
+ matters.push({ rank: r.status === 'late' ? 0 : 1, tone: r.status === 'late' ? 'bad' : 'warn', tag: r.status === 'late' ? 'Late' : 'At risk', text: r.title, meta: d.name + (r.end ? ' · due ' + r.end : ''), ref: { type: 'initiative', id: r.id } });
569
+ }
570
+ const nr = d.nextRelease;
571
+ if (nr && nr.at && (!soon || String(nr.at).slice(0, 10) <= soon)) { rel++; matters.push({ rank: 2, tone: 'ok', tag: 'Release', text: (nr.type ? nr.type + ' ' : '') + nr.id, meta: d.name + ' · ' + String(nr.at).slice(0, 10), ref: { type: 'release', id: nr.id } }); }
572
+ asks += (d.asks || []).length;
573
+ }
574
+ if (asks) matters.push({ rank: 3, tone: 'warn', tag: 'New asks', text: `${plural(asks, 'ask')} ${asks === 1 ? 'waits' : 'wait'} in triage`, meta: ds.filter((d) => (d.asks || []).length).map((d) => d.name).join(', '), ref: { type: 'portfolio', id: 'asks' } });
575
+ matters.sort((a, b) => a.rank - b.rank);
576
+ const gist = [`A review of ${plural(ds.length, 'board')}.`];
577
+ const parts = [late && plural(late, 'initiative') + ' late', risk && plural(risk, 'initiative') + ' at risk'].filter(Boolean);
578
+ gist.push(parts.length ? 'Across the boards: ' + parts.join(' and ') + '.' : 'No initiative is late or at risk.');
579
+ if (rel) gist.push(`${plural(rel, 'release')} in the next ${RELEASE_DAYS} days.`);
580
+ return { gist, matters: matters.slice(0, MATTERS_CAP + 1).map(({ rank, ...x }) => x), boards: rows, sources: ['every board on the Portfolio'] };
462
581
  }
463
582
 
464
583
  /* ── all three ─────────────────────────────────────────────────────────────────────────────────────────────────── */
465
584
 
466
- function build({ range, today, nowMs, events, dated, src, own, planItemsDue, inis }) {
585
+ function build({ range, today, nowMs, events, dated, src, own, planItemsDue, inis, links, boards }) {
467
586
  const l = layersFrom(dated, range, today);
468
587
  const free = freeFrom(events, range, today, { nowMs, own: own || (dated && dated.own) || [], planItems: planItemsDue || (dated && dated.planItems) || [] });
469
- const c = contextFrom(events, range, src, { today, nowMs, inis });
588
+ const c = contextFrom(events, range, src, { today, nowMs, inis, links, boards });
470
589
  return { layers: l.layers, layers_total: l.layers_total, layers_note: l.layers_note, free, context: c.context, context_total: c.context_total };
471
590
  }
472
591
 
473
- module.exports = { layersFrom, freeFrom, contextFrom, briefOf, seriesNotes, mattersOf, build, toneOf, prepOf, spanOf, prepRisks, minutesOn, localDay, minuteOfDay, LAYER_CAP, CONTEXT_CAP, MIN_BLOCK, WINDOW_MS };
592
+ module.exports = { layersFrom, freeFrom, contextFrom, briefOf, levelOf, boardPart, portfolioPart, seriesNotes, mattersOf, build, toneOf, prepOf, spanOf, prepRisks, minutesOn, localDay, minuteOfDay, LAYER_CAP, CONTEXT_CAP, MIN_BLOCK, WINDOW_MS };
@@ -0,0 +1,64 @@
1
+ 'use strict';
2
+ /* lib/calendar-links.js — what a meeting is about: the Portfolio, one board or one initiative (initiative c3-meeting-board).
3
+ *
4
+ * keyOf(event) → 's:<series>' or 't:<normalized title>' (calendar-memory.js seriesKey): one link holds for every occurrence
5
+ * read(runtimeDir) → Map<key, link> link: { key, kind: portfolio|board|initiative, ws, board, initiative, title, at, by }
6
+ * append(runtimeDir, line, by) → { ok, link } | { ok: false, error } kind 'none' removes the link
7
+ * check(body) → null | the reason the body is refused
8
+ *
9
+ * The calendar is read-only (PMOS never edits it), so the link lives beside it: <runtime>/calendar-links.jsonl, append-only, the last
10
+ * line of a key wins. The shape follows the note's link (lib/notes.js: note.initiative, note.board = { id, ws, title, by },
11
+ * note.portfolio = true), so a meeting and its notes say the same thing in the same words. */
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const calMem = require('./calendar-memory.js');
15
+
16
+ const FILE = 'calendar-links.jsonl';
17
+ const KINDS = ['portfolio', 'board', 'initiative', 'none'];
18
+ const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/;
19
+ const str = (v) => (typeof v === 'string' ? v.trim() : '');
20
+
21
+ const keyOf = (e) => calMem.seriesKey({ series: e && e.series ? String(e.series) : null, title: e && e.title });
22
+ const fileOf = (runtimeDir) => path.join(runtimeDir, FILE);
23
+
24
+ function read(runtimeDir) {
25
+ const out = new Map();
26
+ if (!runtimeDir) return out;
27
+ let text = '';
28
+ try { text = fs.readFileSync(fileOf(runtimeDir), 'utf8'); } catch { return out; }
29
+ for (const line of text.split('\n')) {
30
+ if (!line.trim()) continue;
31
+ let x; try { x = JSON.parse(line); } catch { continue; }
32
+ if (!x || typeof x.key !== 'string' || !KINDS.includes(x.kind)) continue;
33
+ if (x.kind === 'none') { out.delete(x.key); continue; }
34
+ out.set(x.key, { key: x.key, kind: x.kind, ws: x.ws || null, board: x.board || null, initiative: x.initiative || null, title: x.title || null, at: x.at || null, by: x.by || null });
35
+ }
36
+ return out;
37
+ }
38
+
39
+ /** The reason a POST body is refused, or null. body: { event: { title, series? }, kind, ws?, board?, initiative?, title? } */
40
+ function check(body) {
41
+ if (!body || typeof body !== 'object') return 'the body must be a JSON object';
42
+ const e = body.event;
43
+ if (!e || typeof e !== 'object' || !str(e.title) || str(e.title).length > 300) return 'event.title is required (one line, at most 300 characters)';
44
+ if (e.series != null && (typeof e.series !== 'string' || e.series.length > 300)) return 'event.series must be text';
45
+ if (!KINDS.includes(body.kind)) return 'kind is one of ' + KINDS.join(', ');
46
+ if (body.kind === 'board' && !(ID_RE.test(str(body.board)) && ID_RE.test(str(body.ws)))) return 'a board link needs ws and board';
47
+ if (body.kind === 'initiative' && !ID_RE.test(str(body.initiative))) return 'an initiative link needs initiative';
48
+ if (body.title != null && (typeof body.title !== 'string' || body.title.length > 200 || /[\r\n]/.test(body.title))) return 'title is one line of at most 200 characters';
49
+ return null;
50
+ }
51
+
52
+ function append(runtimeDir, body, by) {
53
+ const bad = check(body);
54
+ if (bad) return { ok: false, error: bad };
55
+ const line = { at: new Date().toISOString(), by: by || null, key: keyOf(body.event), kind: body.kind, event_title: str(body.event.title) };
56
+ if (body.kind === 'board') { line.ws = str(body.ws); line.board = str(body.board); }
57
+ if (body.kind === 'initiative') { line.initiative = str(body.initiative); if (ID_RE.test(str(body.ws))) line.ws = str(body.ws); }
58
+ if (str(body.title)) line.title = str(body.title);
59
+ fs.mkdirSync(runtimeDir, { recursive: true });
60
+ fs.appendFileSync(fileOf(runtimeDir), JSON.stringify(line) + '\n');
61
+ return { ok: true, link: body.kind === 'none' ? null : { key: line.key, kind: line.kind, ws: line.ws || null, board: line.board || null, initiative: line.initiative || null, title: line.title || null, at: line.at, by: line.by } };
62
+ }
63
+
64
+ module.exports = { keyOf, read, append, check, KINDS, FILE };
@@ -530,10 +530,12 @@ function sprintsOf(snapshots, onBoard = () => true) {
530
530
  for (const t of tickets) {
531
531
  if (!t || !t.sprint || !t.sprint.name) continue;
532
532
  const name = String(t.sprint.name);
533
- if (!byName.has(name)) byName.set(name, { name, state: t.sprint.state || null, issues: [], counts: { todo: 0, doing: 0, done: 0 } });
533
+ if (!byName.has(name)) byName.set(name, { name, state: t.sprint.state || null, start: null, end: null, issues: [], counts: { todo: 0, doing: 0, done: 0 } });
534
534
  const sp = byName.get(name);
535
+ if (!sp.start && typeof t.sprint.start === 'string' && t.sprint.start) sp.start = t.sprint.start; // the sprint's dates, as Jira holds them
536
+ if (!sp.end && typeof t.sprint.end === 'string' && t.sprint.end) sp.end = t.sprint.end;
535
537
  const category = ['todo', 'doing', 'done'].includes(t.category) ? t.category : 'todo';
536
- sp.issues.push({ key: t.key, summary: t.summary || '', status: t.status || '', category, initiative: s.initiative });
538
+ sp.issues.push({ key: t.key, summary: t.summary || '', status: t.status || '', category, initiative: s.initiative, flagged: !!t.flagged, reason: t.flagged && t.reason ? String(t.reason) : null });
537
539
  sp.counts[category]++;
538
540
  }
539
541
  }
@@ -778,4 +778,54 @@ function boardIndex(ctx, w) {
778
778
  return { boards: fr.boards.map((b) => ({ id: b.id, title: b.name })), of: (id) => byId.get(id) || boardIdOf({ id }, fr.boards, fr.folded, w) };
779
779
  }
780
780
 
781
- module.exports = { prefixes: ['/api/boards'], handle, boardIndex, boardsOf, baseRows, boardRow, portfolio, SELF_REFUSAL, NO_STATE };
781
+ /** What a meeting card reads about boards (initiative c3-meeting-board, lib/calendar-layers.js briefOf): every board's name and
782
+ * Jira project keys (a meeting title that names one), and on demand one board's state: its open asks, the initiatives that are
783
+ * late or at risk, the next release and its sprints from the cached Jira readings. Read-only, like the GETs above; a board whose
784
+ * rows are still being read answers { pending: true }. */
785
+ function meetingBoards(ctx) {
786
+ const frames = new Map();
787
+ const frameOf = (w) => { if (!frames.has(w.id)) { try { frames.set(w.id, frameFor(ctx, w)); } catch { frames.set(w.id, null); } } return frames.get(w.id); };
788
+ const list = () => {
789
+ const out = [];
790
+ for (const w of (ctx.workspaceList ? ctx.workspaceList() : [])) {
791
+ const fr = frameOf(w);
792
+ if (fr) for (const b of fr.boards) out.push({ ws: w.id, id: b.id, name: b.name, jiraProjects: b.jiraProjects || [], build: !!b.build });
793
+ }
794
+ return out;
795
+ };
796
+ let releases = null;
797
+ const detail = (wsId, boardId) => {
798
+ const w = (ctx.workspaceList ? ctx.workspaceList() : []).find((x) => x.id === wsId);
799
+ const fr = w && frameOf(w);
800
+ const board = fr && fr.boards.find((b) => b.id === boardId);
801
+ if (!board) return null;
802
+ const head = { ws: w.id, id: board.id, name: board.name, build: !!board.build, jiraProjects: board.jiraProjects || [] };
803
+ const asks = [...fr.folded.asks.values()].filter((a) => a.board === board.id && a.state === 'open')
804
+ .sort((a, b) => String(b.at || '').localeCompare(String(a.at || ''))).map((a) => ({ id: a.id, line: a.line, at: a.at || null, source: a.source || null }));
805
+ let b;
806
+ try { b = baseRows(w); } catch (e) { return { ...head, asks, error: String((e && e.message) || e).slice(0, 160) }; }
807
+ if (b.pending) return { ...head, asks, pending: true };
808
+ if (!b.ok) return { ...head, asks, error: b.error };
809
+ const rows = rowsOfBoard(fr, board, b.rows);
810
+ const c = B.counts(rows, board.workflow);
811
+ if (!releases) releases = readReleases(ctx);
812
+ const ids = new Set(rows.map((x) => x.id));
813
+ let sprints = { source: 'none', readAt: null, sprints: [] };
814
+ if (board.build) {
815
+ const snaps = [];
816
+ try {
817
+ for (const q of jiraStatus.linkedInitiatives(w.root).filter((x) => x.linked && ids.has(x.initiative))) {
818
+ const s = jiraStatus.readSnapshot(w.root, q.initiative);
819
+ if (s) snaps.push({ initiative: q.initiative, fetchedAt: s.fetchedAt, tickets: s.tickets });
820
+ }
821
+ } catch { /* no snapshot yet */ }
822
+ sprints = B.sprintsOf(snaps, (id) => ids.has(id));
823
+ }
824
+ return { ...head, health: B.health(rows), counts: { total: c.total, open: c.open, risk: c.risk, late: c.late },
825
+ rows: rows.filter((r) => r.status === 'late' || r.status === 'at risk').map((r) => ({ id: r.id, title: r.title, status: r.status, end: r.end || null, owner: r.owner || null })),
826
+ asks, nextRelease: nextReleaseOf(releases, ids), sprints };
827
+ };
828
+ return { list, detail };
829
+ }
830
+
831
+ module.exports = { prefixes: ['/api/boards'], handle, boardIndex, meetingBoards, boardsOf, baseRows, boardRow, portfolio, SELF_REFUSAL, NO_STATE };
@@ -3,6 +3,8 @@
3
3
  *
4
4
  * → { ok, view, range, cells, unit, gantt, strategy, events_source, today,
5
5
  * layers, layers_total, layers_note, free, context, context_total, (lib/calendar-layers.js)
6
+ * link_boards } every board a meeting can be linked to: [{ ws, id, name }]
7
+ * POST /api/calendar-links → { ok, link } what a meeting is about (lib/calendar-links.js, below)
6
8
  * tidy, tidy_total, tidy_note } (lib/calendar-tidy.js: suggestions in words, no write)
7
9
  *
8
10
  * READ ONLY. Initiatives come from the signal engine's repo reader (scripts/signals/facts.js readRepoFacts), required inside
@@ -57,7 +59,15 @@ function handle(query, deps = {}) {
57
59
  const nowMs = Number.isFinite(deps.nowMs) ? deps.nowMs : (deps.today ? undefined : Date.now());
58
60
  // the initiative of a coming meeting: its title from the facts, its status from the Gantt row when the row is in range
59
61
  const iniRows = inis.filter((i) => i && i.id).map((i) => { const g = gantt.find((x) => x.id === i.id); return { id: i.id, title: i.title || i.id, status: g ? g.status : null }; });
60
- const extra = layersLib.build({ range, today: nowDay, nowMs, events, dated, src, own: items.own, planItemsDue: items.planItems, inis: iniRows });
62
+ // what a coming meeting is about (initiative c3-meeting-board): the PM's links and the boards, read once per answer
63
+ let links = null;
64
+ if (typeof deps.readLinks === 'function') { try { links = deps.readLinks(); } catch { links = null; } }
65
+ const boards = deps.boards || null;
66
+ let boardList = [];
67
+ if (boards && typeof boards.list === 'function') { try { boardList = boards.list() || []; } catch { boardList = []; } }
68
+ const boardsOnce = boards ? { list: () => boardList, detail: memo(boards.detail) } : null;
69
+ const extra = layersLib.build({ range, today: nowDay, nowMs, events, dated, src, own: items.own, planItemsDue: items.planItems, inis: iniRows, links, boards: boardsOnce });
70
+ extra.link_boards = boardList.map((x) => ({ ws: x.ws, id: x.id, name: x.name }));
61
71
  const tidy = tidyLib.tidyFrom({ range, today: nowDay, nowMs, events, eventsSource: source, inis, gantt,
62
72
  planItems: dated.planItems || items.planItems || [] });
63
73
  return { status: 200, body: { ok: true, view, range, unit: b.unit, cells: b.cells, gantt, strategy, events_source: source, today: nowDay, ...extra, ...tidy } };
@@ -66,6 +76,13 @@ function handle(query, deps = {}) {
66
76
  }
67
77
  }
68
78
 
79
+ /** fn(ws, id) once per pair: a Portfolio meeting and a board meeting in the same answer read the board once. */
80
+ function memo(fn) {
81
+ if (typeof fn !== 'function') return null;
82
+ const seen = new Map();
83
+ return (ws, id) => { const k = ws + '\u0000' + id; if (!seen.has(k)) seen.set(k, fn(ws, id)); return seen.get(k); };
84
+ }
85
+
69
86
  /* ── the readers behind the layers, free time and context (each source on its own: one that cannot be read is named in
70
87
  * layers_note and gives nothing; it never takes the answer down) ────────────────────────────────────────────────── */
71
88
  const reason = (e) => String((e && e.message) || e).slice(0, 160);
@@ -169,7 +186,9 @@ function linkedNotes(runtimeDir) {
169
186
  const pick = (a, b) => (Array.isArray(a) && a.length ? a : Array.isArray(b) ? b : []);
170
187
  const ov = fin && typeof fin.overview === 'string' && fin.overview.trim() ? fin.overview
171
188
  : sec && Array.isArray(sec.overview) ? sec.overview.slice(0, 3).map((x) => String((x && x.text) || '')).filter(Boolean).join(' ') : '';
172
- out.push({ id: n.id, title: n.title || '', private: !!n.private, initiative: n.initiative && n.initiative.id ? String(n.initiative.id) : null,
189
+ // what the note is about (the Notes page writes it): the Portfolio, a board, or an initiative (which names its board too)
190
+ const nb = n.board && typeof n.board === 'object' && n.board.id ? { id: String(n.board.id), ws: n.board.ws ? String(n.board.ws) : null, title: n.board.title ? String(n.board.title) : null } : null;
191
+ out.push({ id: n.id, title: n.title || '', private: !!n.private, initiative: n.initiative && n.initiative.id ? String(n.initiative.id) : null, board: nb, portfolio: n.portfolio === true,
173
192
  meeting: { event_id: n.meeting.event_id || null, start: n.meeting.start, title: n.meeting.title, series: n.meeting.context && n.meeting.context.series || null },
174
193
  overview: String(ov).replace(/\s+/g, ' ').trim().slice(0, 600),
175
194
  decisions: pick(fin && fin.decisions, sec && sec.decisions).map((x) => ({ text: x && x.text })),
@@ -178,11 +197,29 @@ function linkedNotes(runtimeDir) {
178
197
  return out;
179
198
  }
180
199
 
200
+ /** POST /api/calendar-links { event: { title, series? }, kind: portfolio|board|initiative|none, ws?, board?, initiative?, title? }
201
+ * → { ok, link }. What a meeting is about, for every occurrence of it (lib/calendar-links.js). The page writes it from the meeting card. */
202
+ async function postLink(req, res, ctx) {
203
+ const got = await ctx.readJson(req);
204
+ if (!got.ok || !got.body || typeof got.body !== 'object' || Array.isArray(got.body)) { ctx.json(res, { ok: false, error: 'the body must be a JSON object' }, 400); return true; }
205
+ const g = require('./pm-guard.js').requirePagePress(req, got.body);
206
+ if (!g.ok) { ctx.json(res, { ok: false, error: g.error }, g.status); return true; }
207
+ if (!ctx.runtimeDir) { ctx.json(res, { ok: false, error: 'this server has no runtime folder, so the link cannot be saved' }, 409); return true; }
208
+ const by = g.actor && g.actor.by ? g.actor.by : null;
209
+ const out = require('./calendar-links.js').append(ctx.runtimeDir, got.body, by);
210
+ ctx.json(res, out, out.ok ? 200 : 400);
211
+ return true;
212
+ }
213
+
181
214
  module.exports = {
182
215
  readers,
183
- prefixes: ['/api/calendar-views'],
216
+ prefixes: ['/api/calendar-views', '/api/calendar-links'],
184
217
  handlePure: handle,
185
218
  handle: async (req, res, url, ctx) => {
219
+ if (url.pathname === '/api/calendar-links') {
220
+ if (req.method !== 'POST') { ctx.json(res, { ok: false, error: 'POST only' }, 405); return true; }
221
+ return postLink(req, res, ctx);
222
+ }
186
223
  if (url.pathname !== '/api/calendar-views') return false;
187
224
  if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
188
225
  const w = ctx.getWs(url.searchParams.get('ws'));
@@ -194,6 +231,8 @@ module.exports = {
194
231
  const out = handle({ view: url.searchParams.get('view') || undefined, date: url.searchParams.get('date') || undefined }, {
195
232
  readFacts: () => facts,
196
233
  readEvents: () => require('./calendar-events.js').readAll(ctx.runtimeDir),
234
+ readLinks: () => require('./calendar-links.js').read(ctx.runtimeDir),
235
+ boards: require('./routes-boards.js').meetingBoards(ctx),
197
236
  ...readers(root, ctx.runtimeDir),
198
237
  });
199
238
  ctx.json(res, out.body, out.status);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.153.0",
3
+ "version": "0.154.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.153.0",
9
+ "version": "0.154.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.153.0",
3
+ "version": "0.154.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",
@@ -21,6 +21,7 @@ import { el } from './dom.js';
21
21
  import { c3ic } from './pm-work-view.js';
22
22
  import { button, ensureComponentsCss } from './primitives.js';
23
23
  import { toast } from './c3-toast.js';
24
+ import { pmPost } from './http.js';
24
25
  import { notesView, openNote, noteOfEvent, primeNotes } from './c3-notes.js'; // Calendar › Notes (initiative c3-notes-place)
25
26
  import { onNotes } from './notes-links.js';
26
27
 
@@ -36,7 +37,8 @@ const KIND = {
36
37
  };
37
38
  const RANK = { milestone: 0, release: 1, report: 2, due: 3, own: 4 };
38
39
  const TONE = { bad: 'Late or at risk of missing', warn: 'At risk', ok: 'On track' };
39
- const REF = { initiative: 'Open the initiative', 'plan-item': 'Open the initiative', item: 'Open in Work', release: 'Open the release', report: 'Open in Reports' };
40
+ const REF = { initiative: 'Open the initiative', 'plan-item': 'Open the initiative', item: 'Open in Work', release: 'Open the release', report: 'Open in Reports',
41
+ jira: 'Open the sprint', ask: 'Open in Triage', portfolio: 'Open the Portfolio' };
40
42
  const CTXWORD = { decision: 'Decision', action: 'Action', moved: 'Moved', prepared: 'Prepared', todo: 'To prepare' };
41
43
  const NO_SUGGESTION = 'Free, nothing specific to suggest';
42
44
  /* the tidy-up suggestions (lib/calendar-tidy.js): the severity groups in order, and each rule in words */
@@ -528,6 +530,8 @@ function openRef(ui, ref, extra) {
528
530
  if (ref.type === 'initiative') { if (!h.goInitiative(ref.id)) h.openInit(host, r, ref.id, extra.title); return; }
529
531
  if (ref.type === 'plan-item' || ref.type === 'release') { const ini = extra.initiative || ref.initiative; if (!(ini && h.goInitiative(ini))) h.gotoTab('init'); return; }
530
532
  if (ref.type === 'item') { h.gotoTab('work'); return; }
533
+ if (ref.type === 'jira' || ref.type === 'ask') { openBoardTab(ui, ref.board, ref.type === 'jira' ? 'sprints' : 'triage'); return; }
534
+ if (ref.type === PORTFOLIO) { openBoardTab(ui, null); return; }
531
535
  if (ref.type === 'report') h.gotoTab('rep');
532
536
  }
533
537
 
@@ -585,7 +589,8 @@ function meetingDetail(ui, hit) {
585
589
  else if (past) body = [list(['decision', 'action'], 'What came out of it', 'out', 'No decision or action is recorded.'), list(['moved'], 'What moved after it', 'moved', 'Nothing moved within 48 hours that refers to it.')];
586
590
  else body = upcomingBody(ui, rec, items);
587
591
  const b = !past && rec && rec.brief ? rec.brief : null;
588
- const iniId = typeof e.initiative === 'string' ? e.initiative : b && b.initiative ? b.initiative.id : null;
592
+ const lv = !past && rec ? rec.level || null : null;
593
+ const iniId = lv && lv.kind !== 'initiative' ? null : typeof e.initiative === 'string' && !(lv && lv.by === 'pm') ? e.initiative : b && b.initiative ? b.initiative.id : null;
589
594
  const iniTitle = iniId ? (b && b.initiative && b.initiative.id === iniId && b.initiative.title) || (((ui.d.gantt || []).find((g) => g.id === iniId) || {}).title) || iniId : null;
590
595
  const iniStatus = b && b.initiative && b.initiative.id === iniId ? b.initiative.status : (((ui.d.gantt || []).find((g) => g.id === iniId) || {}).status || null);
591
596
  const sources = b && b.sources && b.sources.length ? 'Put together from ' + listWords(b.sources) + '. Each line opens its source. ' : '';
@@ -596,6 +601,7 @@ function meetingDetail(ui, hit) {
596
601
  el('div', { class: 'chips2', 'data-cal3': 'meeting-chips' }, homeChip('Calendar'), ...peopleOf(e).map((n) => el('span', { class: 'tag', 'data-cal3': 'person' }, n)),
597
602
  iniId ? el('span', { class: 'tag', 'data-cal3': 'meeting-initiative' }, 'Initiative: ' + iniTitle) : null,
598
603
  iniStatus && STATUS_TONE[iniStatus] ? el('span', { class: 'tag cal3-tone-' + STATUS_TONE[iniStatus] }, iniStatus) : null),
604
+ past || !M.ctx ? null : levelRow(ui, e, lv),
599
605
  noteLink(ui, e),
600
606
  ...body,
601
607
  el('p', { class: 'dim cal3-note', 'data-cal3': past ? 'past-note' : 'sources' }, past ? 'Only what PMOS holds a record for: items created or closed within 48 hours of the meeting. Each line opens its source.' : sources + 'PMOS proposes changes. It never edits your calendar.'),
@@ -611,13 +617,106 @@ const ctxBox = (key, title, ...kids) => el('div', { class: 'cal3-ctx', 'data-cal
611
617
  const ul = (rows) => el('ul', { class: 'cal3-cl', role: 'list' }, ...rows);
612
618
  const ci = (attrs, ...kids) => el('li', { class: 'cal3-ci', ...attrs }, ...kids);
613
619
 
620
+ /* ── what a meeting is about: the Portfolio, a board or an initiative (initiative c3-meeting-board) ──
621
+ * The switch says which level the card reads and why PMOS chose it (lib/calendar-layers.js levelOf). Pressing a level links every
622
+ * occurrence of the meeting to it (POST /api/calendar-links); Board and Initiative ask which one when there is more than one. */
623
+ // the meeting's level, not the old initiative list's Portfolio mode (cuts.test.js keeps that mode out)
624
+ const PORTFOLIO = 'portfolio';
625
+ const LEVELS = [[PORTFOLIO, 'Portfolio', 'Read every board'], ['board', 'Board', 'Read one board and its sprint'], ['initiative', 'Initiative', 'Read one initiative']];
626
+ const linkPick = new Map(); // event id → the level whose picker is open
627
+
628
+ function levelRow(ui, e, lv) {
629
+ const open = linkPick.get(e.id) || null;
630
+ const seg = el('div', { class: 'seg cal3-lvseg', role: 'group', 'aria-label': 'This meeting is about', 'data-cal3': 'level' },
631
+ ...LEVELS.map(([k, label, title]) => { const x = button({ variant: 'ghost', attrs: { class: 'cal3-b', 'aria-pressed': String(!!lv && lv.kind === k), 'data-cal3-level': k, title },
632
+ onClick: () => pickLevel(ui, e, lv, k) }); x.replaceChildren(el('span', null, label)); return x; }));
633
+ const what = !lv ? 'Not linked yet. Pick a level and PMOS reads it before the meeting.'
634
+ : lv.kind === PORTFOLIO ? 'Reads every board, linked because ' + lv.why + '.'
635
+ : lv.kind === 'board' ? 'Reads the ' + (lv.name || lv.id) + ' board and its sprint, linked because ' + lv.why + '.'
636
+ : 'Reads ' + (lv.name || lv.id) + ', linked because ' + lv.why + '.';
637
+ const unlink = lv && lv.by === 'pm' ? el('a', { href: '#', class: 'cal3-ref', 'data-cal3': 'unlink', onClick: (ev) => { ev.preventDefault(); saveLink(ui, e, { kind: 'none' }, 'The link is removed.'); } }, 'Unlink') : null;
638
+ return el('div', { class: 'cal3-level', 'data-cal3-ctx': 'level', 'data-cal3-level-by': lv ? lv.by : 'none' }, seg, el('span', { class: 'dim cal3-note' }, what), unlink, open ? pickerOf(ui, e, open) : null);
639
+ }
640
+
641
+ function pickLevel(ui, e, lv, kind) {
642
+ if (lv && lv.kind === kind && lv.by === 'pm' && kind === PORTFOLIO) return;
643
+ if (kind === PORTFOLIO) { saveLink(ui, e, { kind }, 'This meeting now reads every board.'); return; }
644
+ const choices = choicesOf(ui, kind);
645
+ if (choices.length === 1) { saveLink(ui, e, linkBody(kind, choices[0]), 'This meeting now reads ' + choices[0].name + '.'); return; }
646
+ linkPick.set(e.id, linkPick.get(e.id) === kind ? null : kind);
647
+ ui.redraw();
648
+ }
649
+
650
+ const choicesOf = (ui, kind) => kind === 'board' ? (ui.d.link_boards || []).map((b) => ({ ws: b.ws, id: b.id, name: b.name }))
651
+ : (ui.d.gantt || []).filter((g) => g && g.id).map((g) => ({ id: g.id, name: g.title || g.id }));
652
+ const linkBody = (kind, c) => kind === 'board' ? { kind, ws: c.ws, board: c.id, title: c.name } : { kind, initiative: c.id, title: c.name };
653
+
654
+ function pickerOf(ui, e, kind) {
655
+ const choices = choicesOf(ui, kind);
656
+ if (!choices.length) return el('span', { class: 'dim cal3-note', 'data-cal3': 'level-none' }, kind === 'board' ? 'No board to link yet.' : 'No initiative to link in this range.');
657
+ const sel = el('select', { 'aria-label': kind === 'board' ? 'Pick the board' : 'Pick the initiative', 'data-cal3': 'level-pick',
658
+ onChange: () => { const c = choices[Number(sel.value)]; if (c) saveLink(ui, e, linkBody(kind, c), 'This meeting now reads ' + c.name + '.'); } },
659
+ el('option', { value: '' }, kind === 'board' ? 'Pick the board' : 'Pick the initiative'), ...choices.map((c, i) => el('option', { value: String(i) }, c.name)));
660
+ return sel;
661
+ }
662
+
663
+ async function saveLink(ui, e, body, said) {
664
+ linkPick.delete(e.id);
665
+ let r = null;
666
+ try { r = await pmPost('/api/calendar-links', { event: { title: e.title || e.id, series: e.series || null }, ...body }); } catch (err) { r = { ok: false, error: String((err && err.message) || err) }; }
667
+ if (!r || !r.ok) { toast('The link was not saved: ' + ((r && r.error) || 'no answer') + '.'); ui.redraw(); return; }
668
+ toast(said);
669
+ ui.h.go(ui.host, ui.r, {});
670
+ }
671
+
672
+ /* the sprint's days left in words: working days after today up to its end */
673
+ function sprintLeft(h, end, today) {
674
+ if (!end) return '';
675
+ if (end < today) return 'ended ' + h.dayLabel(end);
676
+ let n = 0;
677
+ for (let t = new Date(today + 'T12:00:00Z').getTime() + 86400000; ; t += 86400000) {
678
+ const d = new Date(t).toISOString().slice(0, 10); if (d > end) break;
679
+ const w = new Date(t).getUTCDay(); if (w > 0 && w < 6) n++;
680
+ }
681
+ return (n === 0 ? 'last day' : plural(n, 'working day') + ' left') + ', ends ' + h.dayLabel(end);
682
+ }
683
+
684
+ const HEALTH = { ok: ['', 'On track'], warn: ['warn', 'Needs attention'], bad: ['bad', 'Off track'] };
685
+
686
+ function boardParts(ui, b) {
687
+ const { h } = ui;
688
+ const out = [];
689
+ if (b.boards && b.boards.length) out.push(ctxBox('boards', [c3ic('cols', 'sm'), 'Boards'], ul(b.boards.map((x) => {
690
+ const [tone, word] = HEALTH[x.health] || ['', 'Not read'];
691
+ return ci({ 'data-cal3-board': x.id }, el('span', { class: 'tag' + (tone ? ' cal3-tone-' + tone : '') }, word), el('span', { class: 'cal3-ct' }, x.name), el('span', { class: 'dim mono cal3-at' }, x.line),
692
+ el('a', { href: '#', class: 'cal3-ref', title: 'Open the board: ' + x.name, onClick: (ev) => { ev.preventDefault(); openBoardTab(ui, x.id, 'summary'); } }, c3ic('out', 'sm'), 'Open the board'));
693
+ }))));
694
+ if (b.sprint) {
695
+ const sp = b.sprint; const c = sp.counts || { todo: 0, doing: 0, done: 0 };
696
+ out.push(ctxBox('sprint', [c3ic('cols', 'sm'), sp.name, el('span', { class: 'dim mono cal3-h4r' }, sprintLeft(h, sp.end, ui.d.today || ''))],
697
+ el('div', { class: 'cal3-bar', role: 'img', 'aria-label': c.done + ' done, ' + c.doing + ' in progress, ' + c.todo + ' to do' },
698
+ setVar(el('i', { class: 'd' }), 'n', c.done), setVar(el('i', { class: 'p' }), 'n', c.doing), setVar(el('i', { class: 't' }), 'n', c.todo)),
699
+ el('div', { class: 'chips2 dim mono cal3-counts' }, el('span', null, c.done + ' done'), el('span', null, c.doing + ' in progress'), el('span', null, c.todo + ' to do'),
700
+ b.next ? el('span', { 'data-cal3': 'next-sprint' }, '· ' + b.next.name + ': ' + b.next.planned + ' planned, ' + b.next.carry + ' may carry over') : null)));
701
+ }
702
+ return out;
703
+ }
704
+
705
+ /* a board's home on Initiatives: the page reads ?init= when it opens (c3-initiatives.js parseLoc) */
706
+ function openBoardTab(ui, board, tab) {
707
+ try { const u = new URL(location.href); u.searchParams.set('init', board ? 'board:' + encodeURIComponent(board) + ':' + tab : ''); if (!board) u.searchParams.delete('init'); history.replaceState(history.state, '', u); } catch { /* no history */ }
708
+ ui.h.gotoTab('init');
709
+ }
710
+
614
711
  function upcomingBody(ui, rec, items) {
615
712
  const { h } = ui;
616
713
  const b = rec && rec.brief ? rec.brief : null;
714
+ const lv = rec && rec.level ? rec.level : null;
617
715
  const prep = items.filter((x) => x.kind === 'prepared' || x.kind === 'todo');
618
716
  const out = [];
619
717
  if (b && b.gist && b.gist.length) out.push(el('div', { class: 'cal3-ctx cal3-gist', 'data-cal3-ctx': 'gist' }, el('h4', null, c3ic('spark', 'sm'), 'In short'), el('p', { class: 'cal3-p' }, b.gist.join(' '))));
620
- if (b && b.matters && b.matters.length) out.push(ctxBox('matters', 'What matters today', ul(b.matters.map((x) => ci({ 'data-cal3-matter': x.tag },
718
+ if (b) out.push(...boardParts(ui, b));
719
+ if (b && b.matters && b.matters.length) out.push(ctxBox('matters', b.planning ? 'To settle in planning' : 'What matters today', ul(b.matters.map((x) => ci({ 'data-cal3-matter': x.tag },
621
720
  el('span', { class: 'tag cal3-tone-' + x.tone }, x.tag), el('span', { class: 'cal3-ct' }, x.text), x.meta ? el('span', { class: 'dim mono cal3-at' }, x.meta) : null,
622
721
  refLink(ui, x.ref, { initiative: x.ref.initiative, title: x.text }))))));
623
722
  if (b && b.last) {
@@ -643,11 +742,11 @@ function upcomingBody(ui, rec, items) {
643
742
  ...(b.links || []).map((l) => el('a', { class: 'chip2', href: l.url, target: '_blank', rel: 'noopener noreferrer' }, c3ic('out', 'sm'), l.label)),
644
743
  ...(b.refs || []).map((r) => r.link ? el('a', { class: 'chip2', href: r.link, target: '_blank', rel: 'noopener noreferrer' }, c3ic('out', 'sm'), r.label, r.source ? el('span', { class: 'dim' }, ' ' + r.source) : null)
645
744
  : el('span', { class: 'chip2' }, r.label, r.source ? el('span', { class: 'dim' }, ' ' + r.source) : null))));
646
- const noIni = !(b && b.initiative); const noLast = !(b && b.last);
745
+ const noIni = !lv; const noLast = !(b && b.last);
647
746
  if (noIni || noLast) {
648
- const missing = noIni && noLast ? 'No initiative is linked and there is no earlier note from this meeting.' : noIni ? 'No initiative is linked to this meeting.' : 'There is no earlier note from this meeting.';
747
+ const missing = noIni && noLast ? 'This meeting is not linked and there is no earlier note from it.' : noIni ? 'This meeting is not linked to the Portfolio, a board or an initiative.' : 'There is no earlier note from this meeting.';
649
748
  out.push(ctxBox('knows', 'What PMOS knows', fold('info', missing + (out.length ? '' : ' Nothing is recorded to prepare.'), { 'data-cal3': 'knows' }),
650
- ul([noIni ? ci({}, el('span', { class: 'tag' }, 'Link'), el('span', { class: 'cal3-ct' }, 'Link an initiative, and its blocked, late and due items show here.')) : null,
749
+ ul([noIni ? ci({}, el('span', { class: 'tag' }, 'Link'), el('span', { class: 'cal3-ct' }, 'Pick Portfolio, Board or Initiative above, and what matters there shows here.')) : null,
651
750
  ci({}, el('span', { class: 'tag' }, 'Note'), el('span', { class: 'cal3-ct' }, 'Take a note in this meeting, and the next one opens with its summary and next steps.'))].filter(Boolean))));
652
751
  }
653
752
  return out;
@@ -90,7 +90,23 @@ const CONTEXT = {
90
90
  'm3@2026-10-06': { phase: 'past', held: false, items: [] },
91
91
  'm1@2026-10-08': { phase: 'upcoming', held: true, items: [
92
92
  { kind: 'prepared', text: 'Async summary of the 3 open tickets', ref: init('search') },
93
- ] },
93
+ ],
94
+ // a meeting about a board (initiative c3-meeting-board): the title names no board, so the PM linked it
95
+ level: { kind: 'board', ws: D.WS_ID, id: 'idpf', name: 'IDPF', why: 'you linked it to the board', by: 'pm' },
96
+ brief: {
97
+ initiative: null,
98
+ gist: ['A meeting of the IDPF board.', 'IDPF Sprint 42 has 11 of 20 tickets still open, and 2 are blocked.', 'On the board: 1 initiative at risk.', '1 new ask waits in triage.'],
99
+ board: { ws: D.WS_ID, id: 'idpf', name: 'IDPF', health: 'warn', counts: { total: 3, open: 3, risk: 1, late: 0 }, next_release: null },
100
+ sprint: { name: 'IDPF Sprint 42', start: '2026-10-05', end: '2026-10-16', counts: { todo: 5, doing: 6, done: 9 }, total: 20, open: 11, blocked: 2, read_at: '2026-10-08T08:10:00' },
101
+ matters: [
102
+ { tone: 'bad', tag: 'Blocked', text: 'IDPF-1190 QA test plan for the beta: QA capacity', meta: 'In Progress · flagged in Jira', ref: { type: 'jira', id: 'IDPF-1190', board: 'idpf', ws: D.WS_ID } },
103
+ { tone: 'bad', tag: 'Blocked', text: 'IDPF-1204 Partner sandbox keys', meta: 'Blocked', ref: { type: 'jira', id: 'IDPF-1204', board: 'idpf', ws: D.WS_ID } },
104
+ { tone: 'warn', tag: 'At risk', text: 'Self-serve onboarding v2', meta: 'initiative on this board · due 2026-10-26', ref: init('IDPF-12') },
105
+ { tone: 'warn', tag: 'New ask', text: 'SSO for enterprise trials, from Lina Kusuma (Sales)', meta: 'waiting in triage', ref: { type: 'ask', id: 'ask-demo1', board: 'idpf', ws: D.WS_ID } },
106
+ ],
107
+ last: null, earlier: 0, purpose: null, prep: null, links: [], refs: [], planning: false,
108
+ sources: ['the calendar', 'the IDPF board', 'its IDPF Sprint 42 tickets from Jira'],
109
+ } },
94
110
  'm2@2026-10-08': { phase: 'upcoming', held: true, items: [
95
111
  { kind: 'prepared', text: 'Agenda: scope cut, blockers, next steps', ref: init('checkout') },
96
112
  { kind: 'prepared', text: 'Pre-read: the Checkout v2 PRD with 2 open comments', ref: { type: 'plan-item', id: 'checkout-prd' } },
@@ -104,6 +120,7 @@ const CONTEXT = {
104
120
  { kind: 'prepared', text: 'Three scope options with their dates', ref: init('checkout') },
105
121
  { kind: 'todo', text: 'Cut Checkout v2 to 3 core flows to hold 24 Oct?', ref: item('item-demo010') },
106
122
  ],
123
+ level: { kind: 'initiative', id: 'checkout', name: 'Checkout v2', why: "today's brief names the initiative", by: 'brief' },
107
124
  // the card before a meeting (initiative c3-meeting-card): what the server's briefOf puts together
108
125
  brief: {
109
126
  initiative: { id: 'checkout', title: 'Checkout v2', status: 'at risk' },
@@ -118,12 +135,35 @@ const CONTEXT = {
118
135
  earlier: 3, purpose: null, prep: null, links: [], refs: [],
119
136
  sources: ['the calendar', '3 earlier notes of this meeting', 'the checkout plan', 'your items'],
120
137
  } },
121
- 'm5@2026-10-08': { phase: 'upcoming', held: false, items: [] },
138
+ // a meeting about the Portfolio: the brief names no initiative and the PM linked it
139
+ 'm5@2026-10-08': { phase: 'upcoming', held: false, items: [],
140
+ level: { kind: 'portfolio', name: 'Portfolio', why: 'you linked it to the Portfolio', by: 'pm' },
141
+ brief: {
142
+ initiative: null,
143
+ gist: ['A review of 5 boards.', 'Across the boards: 1 initiative late and 2 initiatives at risk.', '1 release in the next 14 days.'],
144
+ boards: [
145
+ { ws: D.WS_ID, id: 'idpf', name: 'IDPF', health: 'warn', line: '3 open initiatives · 1 at risk · 1 ask' },
146
+ { ws: D.WS_ID, id: 'pay', name: 'Payments', health: 'ok', line: '2 open initiatives' },
147
+ { ws: D.WS_ID, id: 'growth', name: 'Growth', health: 'ok', line: '2 open initiatives' },
148
+ { ws: D.WS_ID, id: 'office', name: 'Office move', health: 'bad', line: '2 open initiatives · 1 late · 1 at risk' },
149
+ { ws: D.WS_ID, id: 'hiring', name: 'Hiring Q4', health: 'ok', line: '1 open initiative' },
150
+ ],
151
+ matters: [
152
+ { tone: 'bad', tag: 'Late', text: 'Fit-out contractor selection', meta: 'Office move · due 2026-10-06', ref: init('OFF-01') },
153
+ { tone: 'warn', tag: 'At risk', text: 'Self-serve onboarding v2', meta: 'IDPF · due 2026-10-26', ref: init('IDPF-12') },
154
+ { tone: 'ok', tag: 'Release', text: 'Tokenisation 1.4', meta: 'Payments · 2026-10-15', ref: { type: 'release', id: 'rel-pay-14' } },
155
+ ],
156
+ last: null, earlier: 0, purpose: null, prep: null, links: [], refs: [], planning: false,
157
+ sources: ['the calendar', 'every board on the Portfolio'],
158
+ } },
122
159
  };
123
160
 
161
+ /* the boards a meeting can be linked to (routes-calendar-views.js link_boards), as c3-demo-boards.js names them */
162
+ const LINK_BOARDS = [['idpf', 'IDPF'], ['pay', 'Payments'], ['growth', 'Growth'], ['office', 'Office move'], ['hiring', 'Hiring Q4']].map(([id, name]) => ({ ws: D.WS_ID, id, name }));
163
+
124
164
  /** The answer of GET /api/calendar-views with the Calendar page's keys added. */
125
165
  export function withCalendarPage(out) {
126
166
  if (!out || !out.range) return out;
127
167
  const { start, end } = out.range;
128
- return withCalendarTidy({ ...out, layers: layersIn(start, end), free: freeIn(start, end), context: CONTEXT });
168
+ return withCalendarTidy({ ...out, layers: layersIn(start, end), free: freeIn(start, end), context: CONTEXT, link_boards: LINK_BOARDS });
129
169
  }
@@ -76,6 +76,7 @@ function postRoute(path, body) {
76
76
  try { b = body ? JSON.parse(body) : {}; } catch { b = {}; }
77
77
  if (path === '/api/pm-items/add') return { ok: true, item: { id: 'item-demo-new' + (++added), title: String(b.line || '').trim() || 'New item', due: null, priority: null, waiting_on: null, job: null, steps: [], created_at: D.NOW_ISO + 'Z' } };
78
78
  if (path === '/api/pm-items/remove') return { ok: true, undo_ms: 8000 };
79
+ if (path === '/api/calendar-links') return (window.__c3links = (window.__c3links || []).concat([b])), { ok: true, link: b.kind === 'none' ? null : { kind: b.kind, ws: b.ws || null, board: b.board || null, initiative: b.initiative || null, title: b.title || null, by: 'pm:wawan' } }; // nothing is saved
79
80
  /* the Work page's routes (initiative console3-work-page): each answers as the real route does; pm-work-view.js lays the press over the fixtures */
80
81
  if (path === '/api/pm-items/notyet') {
81
82
  const when = String(b.when || ''); const n = when === 'tomorrow' ? 1 : when === 'next-week' ? 7 : 0;
@@ -9,8 +9,8 @@
9
9
  * GET /api/runs the needs-you count only (a run that asks, holds a permission or needs the terminal); the cards are on the Terminal page
10
10
  * GET and POST /api/scratchpad the session's pad
11
11
  * The Message box, Send and End are DISABLED (drawn as the mockup's): no page press route sends a prompt to a session, and none is invented here.
12
- * Icon language (c3-terminal-icons.js): the header's AGENT button (spark) opens the Terminal page, the full agent surfaces; the TERMINAL glyph (`>_`) is the rail's
13
- * button (it opens this dock, the terminals of your branches) and every "Open the session" link. The rail draws one glyph button per session below it. */
12
+ * Icon language (c3-terminal-icons.js): the header's AGENT button (spark) opens the Terminal page, the full agent surfaces; the TERMINAL glyph (`>_`) is every "Open the session" link.
13
+ * A folded dock leaves no rail (PM, 2026-10-12): Ctrl J and the More menu open it again. */
14
14
  import { el } from './dom.js';
15
15
  import { pmGet, pmPost } from './http.js';
16
16
  import { c3ic } from './pm-work-view.js';
@@ -53,12 +53,11 @@ export function createDock(opts) {
53
53
  el('a', { class: 'ib sm', href: '/', title: 'Start a branch and session in the main app. A session starts over the terminal connection; the page has no route for it.', 'aria-label': 'Start a branch and session in the main app' }, c3ic('plus', 'sm')),
54
54
  el('button', { type: 'button', class: 'ib sm', title: 'Fold the dock (Ctrl J)', 'aria-label': 'Fold the dock', onclick: () => o.setOpen && o.setOpen(false) }, c3ic('fold'))),
55
55
  body);
56
- const rail = el('div', { class: 'c3dock dock rail', 'data-c3pg': 'dock-rail' });
57
56
 
58
57
  const withEnd = (s) => (S.ended.has(s.id) ? { ...s, shown: 'exited', label: 'ended' } : s);
59
58
  const mine = () => S.sessions.filter((s) => s.ws === S.ws && !s.automatic).map(withEnd);
60
59
  const inTree = (dir) => mine().filter((s) => s.tree === dir).sort((a, b) => (a.shown === 'exited') - (b.shown === 'exited') || String(b.created).localeCompare(String(a.created)));
61
- const pick = (id) => { S.sel = id; S.selTree = null; S.confirm = null; drawTree(); drawDetail(); drawRail(); if (o.setOpen) o.setOpen(true); };
60
+ const pick = (id) => { S.sel = id; S.selTree = null; S.confirm = null; drawTree(); drawDetail(); if (o.setOpen) o.setOpen(true); };
62
61
 
63
62
  function row(s) {
64
63
  const g = smOf(s);
@@ -129,15 +128,9 @@ export function createDock(opts) {
129
128
  const r = await pmPost('/api/session/remove', { id: s.id });
130
129
  if (r && r.ok) { S.ended.add(s.id); toast({ text: r.removed === false ? 'Asked it to end. Press End again to remove it.' : 'Ended. Any pending change stays a proposal.', timeout: 4200 }); }
131
130
  else toast({ text: (r && r.error) ? 'Not ended: ' + r.error : NOEND, tone: 'error' });
132
- drawTree(); drawDetail(); drawRail(); refresh();
131
+ drawTree(); drawDetail(); refresh();
133
132
  }
134
133
 
135
- function drawRail() {
136
- const all = S.sessions.filter((s) => !s.automatic); // the mockup's rail lists the first six sessions of every repository
137
- const dots = all.slice(0, 6).map((s) => el('button', { type: 'button', class: 'ib sm', 'data-c3-rail': s.id, title: s.name + ' \u00b7 ' + (s.label || s.shown), 'aria-label': 'Show the session ' + s.name + ' in the dock', onclick: () => pick(s.id) }, el('span', { class: 'mk ' + smOf(s)[1] }, smOf(s)[0])));
138
- /* the TERMINAL glyph: this button opens the dock (the terminals of your branches). The needs-you badge is on the agent button, not here. */
139
- rail.replaceChildren(el('button', { type: 'button', class: 'ib', title: TIPS.terminalRail, 'aria-label': 'Open the sessions dock', 'data-c3pg': 'rail-open', onclick: () => o.setOpen && o.setOpen(true) }, ic('term')), ...dots);
140
- }
141
134
 
142
135
  async function refresh() {
143
136
  const seq = ++S.seq; const ws = S.ws;
@@ -148,7 +141,7 @@ export function createDock(opts) {
148
141
  if (wt && wt.ok) { S.trees = wt.worktrees || []; S.err = null; } else if (!S.trees) S.trees = [];
149
142
  const m = mine();
150
143
  if (!m.some((x) => x.id === S.sel)) S.sel = (m.find((x) => x.shown !== 'exited') || m[0] || {}).id || null;
151
- drawTree(); drawRail();
144
+ drawTree();
152
145
  const cur = detail.getAttribute('data-sel');
153
146
  if (cur !== String(S.sel)) { detail.setAttribute('data-sel', String(S.sel)); drawDetail(); }
154
147
  if (o.onCount) { const all = S.sessions.filter((x) => !x.automatic); o.onCount(all.filter((x) => x.shown === 'working').length, S.need); }
@@ -157,7 +150,7 @@ export function createDock(opts) {
157
150
  /* the mockup's keys: Ctrl or Cmd J folds and opens the dock (title "Open sessions (Ctrl J)"); the arrows move along the tree */
158
151
  const onKey = (e) => {
159
152
  if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === 'j' || e.key === 'J')) {
160
- if (!side.isConnected && !rail.isConnected) return;
153
+ if (!side.isConnected) return;
161
154
  e.preventDefault(); if (o.setOpen) o.setOpen(side.hidden); return;
162
155
  }
163
156
  if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && e.target.closest && e.target.closest('.dk-tree')) {
@@ -167,13 +160,13 @@ export function createDock(opts) {
167
160
  };
168
161
  let timer = null;
169
162
  const api = {
170
- side, rail,
163
+ side,
171
164
  setWorkspaces(list, ws) {
172
165
  S.ws = ws;
173
166
  sel.replaceChildren(...(list.length ? list : [{ id: ws, name: ws || 'workspace' }]).map((w) => el('option', { value: w.id, selected: w.id === ws }, w.name || w.id)));
174
167
  sel.onchange = (e) => { S.ws = e.target.value; S.trees = null; S.sel = null; detail.removeAttribute('data-sel'); if (o.onWs) o.onWs(S.ws); drawTree(); refresh(); };
175
168
  },
176
- start() { document.addEventListener('keydown', onKey); drawTree(); drawRail(); refresh(); timer = setInterval(refresh, 15000); if (source().subscribe) S.unsub = source().subscribe(refresh); },
169
+ start() { document.addEventListener('keydown', onKey); drawTree(); refresh(); timer = setInterval(refresh, 15000); if (source().subscribe) S.unsub = source().subscribe(refresh); },
177
170
  stop() { if (timer) clearInterval(timer); if (S.unsub) S.unsub(); document.removeEventListener('keydown', onKey); },
178
171
  refresh,
179
172
  };
@@ -102,8 +102,8 @@ function header() {
102
102
  S.ttl = el('h1', { 'data-c3pg': 'title' });
103
103
  S.sub = el('div', { class: 'sub', 'data-c3pg': 'date' }, dateLine());
104
104
  S.termBdg = el('span', { class: 'bdg', hidden: true, 'data-c3pg': 'term-count' }, '0');
105
- /* the AGENT button (the bolt glyph, as the mockup's Sessions pill): the Terminal page, the full agent surfaces, with the needs-you badge. The dock's own button is the TERMINAL glyph (c3-terminal-icons.js); Ctrl J and the More menu fold the dock. */
106
- S.termBtn = button({ variant: 'ghost', label: [ic('bolt', 'sm'), 'Sessions', S.termBdg], onClick: () => { import('./c3-terminal.js').then((m) => m.openTerminalPage()); },
105
+ /* the AGENT button (the bolt glyph, as the mockup's Sessions pill): the Terminal page, the full agent surfaces, with the needs-you badge. It is an icon only (PM, 2026-10-12: no "Sessions" label); its tooltip and aria-label name it. Ctrl J and the More menu fold and open the dock. */
106
+ S.termBtn = button({ variant: 'ghost', label: [ic('bolt', 'sm'), S.termBdg], onClick: () => { import('./c3-terminal.js').then((m) => m.openTerminalPage()); },
107
107
  attrs: { class: 'psess', title: 'Agent surfaces: sessions, results to accept, questions for you', 'aria-label': 'Agent surfaces: sessions, results to accept, questions for you', 'aria-haspopup': 'dialog', 'data-c3pg': 'agent-button' } }); // the mockup's header button (agent-surfaces.html #act)
108
108
  S.tools = el('div', { class: 'tools', 'data-c3pg': 'tools' });
109
109
  if (S.demoMod) S.tools.append(S.demoMod.demoPill());
@@ -423,15 +423,14 @@ function dock() {
423
423
  /* the needs-you badge on the agent button: runs that ask, hold a permission or need the terminal (not the running ones) */
424
424
  onCount: (live, need) => { S.termBdg.textContent = String(need); S.termBdg.hidden = need === 0; import('./c3-terminal-icons.js').then((m) => { const t = m.TIPS.agentHeader(need); S.termBtn.title = t; S.termBtn.setAttribute('aria-label', t); }); }, // the badge counts runs that need you, never running ones
425
425
  });
426
- S.dockEl = S.dock.side; S.rail = S.dock.rail;
426
+ S.dockEl = S.dock.side;
427
427
  // a session opens in its own terminal sheet on this page (c3-session-term.js), not in the main app
428
428
  window.addEventListener('pmos:open-session', (e) => { const id = e.detail && e.detail.id; if (!id) { location.assign('/'); return; } import('./c3-session-term.js').then((m) => m.openSessionTerm({ id, name: e.detail.name })); });
429
- return [S.dock.side, S.dock.rail];
429
+ return [S.dock.side];
430
430
  }
431
- /* The mockup opens the dock on the Briefing and folds it to a rail on the other tabs (console3.html line 2017). */
431
+ /* The dock is open on the Briefing and folded away on the other tabs. A folded dock leaves no rail (PM, 2026-10-12): the header's Sessions button, Ctrl J and the More menu reach it. */
432
432
  function syncDock() {
433
433
  S.dockEl.hidden = !S.dockOpen;
434
- S.rail.hidden = S.dockOpen || !S.wide;
435
434
  }
436
435
 
437
436
  /* ── the other tabs ── */
@@ -14,7 +14,6 @@ export const ICON = { agent: 'bolt', terminal: 'term' }; // the mockup's heade
14
14
  export const TIPS = {
15
15
  agentHeader: (n) => (n ? 'Agent surfaces: ' + n + (n === 1 ? ' session needs you' : ' sessions need you') : 'Agent surfaces: sessions, results to accept, questions for you'),
16
16
  agentDock: 'Agent surfaces: the full page of sessions that need you',
17
- terminalRail: 'Sessions dock (Ctrl J): the terminals of your branches',
18
17
  terminalOpen: 'Open the session: its real terminal, on this page',
19
18
  };
20
19
 
@@ -1139,6 +1139,7 @@
1139
1139
  * `.c3.ph .dock` above hide the mockup's own names on a phone, and this page keeps its tabs there. Append-only. */
1140
1140
  .c3.c3pg{overflow-x:clip;max-width:1412px;margin:0 auto;padding:16px 16px 24px;min-height:100vh}
1141
1141
  .c3 .c3grid{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:20px;align-items:start}
1142
+ .c3 .c3grid:not(:has(> .c3dock:not([hidden]))){grid-template-columns:minmax(0,1fr)} /* a folded dock leaves no column and no gap */
1142
1143
  .c3 .c3main{min-width:0;display:grid;gap:14px;container-type:inline-size;align-content:start}
1143
1144
  .c3 .c3top{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}
1144
1145
  .c3 .c3top h1{margin:0;font-size:var(--fs-5);font-weight:600;display:flex;gap:8px;align-items:center}
@@ -1168,7 +1169,6 @@
1168
1169
  .c3 .c3pg-initbody>[hidden]{display:none}
1169
1170
  .c3 .c3dock{position:sticky;top:12px;width:330px;border:1px solid var(--border);background:var(--card);display:flex;flex-direction:column;max-height:calc(100vh - 24px);border-radius:10px;overflow:hidden}
1170
1171
  .c3 .c3dock[hidden]{display:none}
1171
- .c3 .c3dock.rail{width:46px;align-items:center;padding-block:8px;gap:8px;overflow:visible}
1172
1172
  .c3 .c3dock .dk-h{display:flex;align-items:center;justify-content:space-between;gap:6px;padding:8px 10px;border-bottom:1px solid var(--border)}
1173
1173
  .c3 .c3dock .dk-h select{flex:1;min-width:0;font:500 var(--fs-2) var(--mono);border:0;background:transparent;padding:4px;color:var(--foreground)}
1174
1174
  .c3 .c3dock .dk-h a.ib{text-decoration:none}
@@ -1211,7 +1211,6 @@
1211
1211
  .c3 .c3dock .dk-p .form{display:flex;gap:6px;align-items:center}
1212
1212
  .c3 .c3dock .dk-p .form input[type=text]{flex:1;min-width:0}
1213
1213
  .c3 .c3dock .dk-p .form input:disabled{opacity:.6;cursor:not-allowed}
1214
- .c3 .c3dock.rail .ib .mk{width:auto}
1215
1214
  /* the Briefing's middle column is the mockup's ribbon: the zoom switch, no previous/next row */
1216
1215
  .c3 .c3pg-midcal .calbar>.chips2{display:none}
1217
1216
  @media (prefers-reduced-motion:reduce){.c3 .mk.w{animation:none}}
@@ -2078,3 +2077,15 @@
2078
2077
  .c3 .cal3-ctx .cal3-p{margin:0;font-size:var(--fs-2);line-height:1.5;color:var(--foreground)}
2079
2078
  .c3 .cal3-ctx.cal3-gist{padding:10px 12px;border:1px solid var(--border);border-radius:8px;background:var(--muted)}
2080
2079
  .c3 .cal3-ctx h4{display:flex;align-items:center;gap:6px}
2080
+
2081
+ /* what a meeting is about: the level switch, a board's sprint and the Portfolio's boards (initiative c3-meeting-board) */
2082
+ .c3 .cal3-level{display:flex;flex-wrap:wrap;gap:6px 10px;align-items:center}
2083
+ .c3 .cal3-level .cal3-note{margin:0}
2084
+ .c3 .cal3-level select{font:500 var(--fs-2) var(--mono);padding:4px 8px;border:1px solid var(--border);border-radius:6px;background:var(--background);color:var(--foreground)}
2085
+ .c3 .cal3-ctx h4 .cal3-h4r{font-weight:400;margin-left:auto}
2086
+ .c3 .cal3-bar{display:flex;height:8px;border-radius:6px;overflow:hidden;background:var(--muted)}
2087
+ .c3 .cal3-bar i{display:block;min-width:2px;flex:var(--cal3-n,0)}
2088
+ .c3 .cal3-bar i.d{background:var(--foreground)}
2089
+ .c3 .cal3-bar i.p{background:var(--acc);opacity:.55}
2090
+ .c3 .cal3-bar i.t{background:var(--border)}
2091
+ .c3 .cal3-counts{gap:10px;font-size:var(--fs-1)}
@@ -0,0 +1,5 @@
1
+ {
2
+ "_comment": "Initiative c3-meeting-board: what a meeting is about (the Portfolio, a board or an initiative), the level switch, a board's sprint and the Portfolio's boards on the Calendar's meeting card.",
3
+ "panels": ["pm-c3-meeting-board-test.js"],
4
+ "seconds": { "pm-c3-meeting-board-test.js": 45 }
5
+ }