create-pmos 0.178.0 → 0.179.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.178.0",
3
+ "version": "0.179.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@066089d
1
+ kernel-source: pmos@21b31ef
2
2
  assembled: 2026-10-13
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-13T01:59:58Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-13T02:06:03Z"}
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-13T01:59:58Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-13T02:06:03Z"}
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-13T01:59:58Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-13T02:06:03Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "Guide: the Calendar tab of the console page"
4
4
  description: "The Calendar tab of the console page (/): Calendar and Notes, the five zooms, the highlights, the layer chips, the markers, the context of a meeting, free time, the tidy-up suggestions and the empty states."
5
- timestamp: 2026-10-12
5
+ timestamp: 2026-10-13
6
6
  ---
7
7
 
8
8
  # Guide: the Calendar tab of the console page
@@ -13,7 +13,22 @@ The Calendar tab of the console page (`/`) shows time. It shows what happens and
13
13
 
14
14
  The switch at the start of the bar has two parts. **Calendar** shows the grid. **Notes** shows your notes.
15
15
 
16
- In **Notes**, the list is at the left, under the heading **Notes**. The notes are in groups by what they are about: **Portfolio** first, then one group for each board, in the order of the portfolio. **Not linked** is the last group. A note linked to an initiative from an earlier version, with no board, is in **Initiatives**. In each group, the newest meeting is first. A note with no meeting is placed by when its recording started, or else by when it was made. The number shows how many notes the list shows. Type in **Search notes** to keep only the notes whose title, board, initiative or summary has the words. A row shows when the note was made and its length. In a board group, a row shows its initiative, or **board** when the note is about the whole board. A row also shows **recording** or **paused** while the note is held, **no meeting** when the note has no meeting, and a warning mark when a statement differs from a file.
16
+ In **Notes**, the list is at the left, under the heading **Notes**. The notes are in groups by what they are about: **Portfolio** first, then one group for each board, in the order of the portfolio. **Not linked** is the last group. A note linked to an initiative from an earlier version, with no board, is in **Initiatives**. In each group, the newest meeting is first. A note with no meeting is placed by when its recording started, or else by when it was made. The number shows how many notes the list shows. Type in **Search notes** to keep only the notes that have every word. The search reads the title, the board, the initiative, the summary, the decisions, the next steps and what was said. A private note is found by its title only. A row shows when the note was made and its length. In a board group, a row shows its initiative, or **board** when the note is about the whole board. A row also shows **recording** or **paused** while the note is held, **no meeting** when the note has no meeting, and a warning mark when a statement differs from a file.
17
+
18
+ Notes of one regular meeting are a **series**: the same calendar series, or the same title. A series is one row. The row shows how many meetings it has, and how many next steps are still open. Press the arrow on the row to show its last three meetings. Press the row to open the series at the right. Notes older than 30 days fold into **Older than 30 days** at the end of each group. Press it to show them. A recorder left on after the meeting shows its length up to the last line heard, not up to when it stopped.
19
+
20
+ The series page shows what carries from one meeting to the next:
21
+
22
+ - **Next one** shows the next meeting of the series and what to bring up: the next steps still open after several meetings, the questions asked in several meetings, and your own lines. Type in **Add something to bring up** and press `Enter` to add a line. Press the cross to remove it.
23
+ - **Still open** shows each next step not yet done, its owner, the number of meetings it was raised in, and when it was last heard. Press **Done** when it is done. It stops carrying over. **Open again** puts it back.
24
+ - **Decided so far** shows each decision of the series, newest first, with why.
25
+ - **Keeps coming up** shows the topics heard in several meetings.
26
+ - **Still unanswered** shows the open questions. Press **Answered** when a question has its answer.
27
+ - **Every meeting** shows each note of the series with its gist. Press one to open it.
28
+
29
+ A private note adds its title and date to the series, and nothing else.
30
+
31
+ A note in a series shows the series and its place, for example `IDPF - DSU · 21 of 21`. Press it to open the series. The arrows open the meeting before and the meeting after. **After** shows **Since the last one** first: each next step of the meeting before, as **Done**, **Raised again** or **Not mentioned**, then the new next steps. Press **Not part of this series** to take a note out of its series. Press **Add to a series** to put it in one.
17
32
 
18
33
  Press a row to open the note at the right. The note has three stages. Press **Before**, **During** or **After** to change the stage.
19
34
 
@@ -0,0 +1,357 @@
1
+ 'use strict';
2
+ /* notes-series.js — the notes of one regular meeting, kept together (initiative c3-notes-series, the mockup the PM approved on
3
+ * 2026-10-13: https://claude.ai/artifact/Q4SSHcj6QHhpQAiVAx52x6).
4
+ *
5
+ * assign(rows) the list's rows get their series: a title key joins the calendar series with the same title
6
+ * thread(dir, key, { now, events }) → { ok, series } the series page: next one, still open, decided, keeps coming up, unanswered, every meeting
7
+ * placeOf(dir, id, { now }) → { key, title, pos, count, prev, next, since, carried_in, carried } | null a note in its series
8
+ * search(dir, q) → { ok, ids } the notes whose title, notes or transcript hold every word of q
9
+ * markDone(dir, { key, text, kind, done }) · bring(dir, { key, text }) · unbring(dir, { key, id }) the PM's marks, per series
10
+ *
11
+ * A SERIES is the notes with one key (lib/notes.js seriesOf): the calendar series ('s:<id>'), else the normalized title
12
+ * ('t:<title>'). A note with a title key joins the calendar series whose notes have the same normalized title, so a note taken
13
+ * without the calendar link still lands in its meeting. A key with fewer than two notes is not a series.
14
+ *
15
+ * SAME ITEM. Two actions (or questions) are the same when their words match after lower-casing and dropping punctuation. No model
16
+ * is called and nothing is sent outward. An action is open until the PM marks it done; a question until the PM marks it answered.
17
+ * Those marks, and the PM's lines to bring up, live in <dir>/notes-series.json, keyed by series.
18
+ *
19
+ * A PRIVATE note adds its date and title to a series, never its content (as the calendar's meeting card does). */
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const crypto = require('crypto');
23
+ const notes = require('./notes.js');
24
+ const calMem = require('./calendar-memory.js');
25
+
26
+ const OPEN_WINDOW = 6; // actions and questions come from the last six meetings
27
+ const TOPIC_WINDOW = 10; // topics count over the last ten meetings
28
+ const TOPIC_MIN = 3; // a topic comes up in three or more of them
29
+ const CLOSED_DAYS = 14; // closed actions shown under Still open
30
+ const CARRY_WARN = 3; // an action carried in this many meetings is brought up
31
+ const MAX_BRING = 20;
32
+ const DAY = 86400000;
33
+
34
+ const norm = (s) => String(s || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
35
+ const clip = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
36
+ const err = (status, error) => ({ ok: false, status, error });
37
+ const startOf = (n) => (n && n.meeting && n.meeting.start) || (n && n.recorded_from) || (n && n.created_at) || null;
38
+ const timeOf = (n) => Date.parse(startOf(n)) || 0;
39
+
40
+ /* ── the list ─────────────────────────────────────────────────────────────────────────────────────────────────────── */
41
+
42
+ /** Give each row its series key (rows carry `series` from notes.js seriesOf). A title key moves to the calendar series whose
43
+ * notes share that title. Sets row.series = { key, by } or null; by is 'calendar', 'title' or 'pm'. Changes the rows. */
44
+ function assign(rows) {
45
+ const byTitle = new Map();
46
+ for (const r of rows) {
47
+ if (!r.series || r.series.by !== 'calendar') continue;
48
+ const t = calMem.normTitle((r.meeting && r.meeting.title) || r.title);
49
+ if (t && !byTitle.has(t)) byTitle.set(t, r.series.key);
50
+ }
51
+ for (const r of rows) {
52
+ if (!r.series || r.series.by !== 'title') continue;
53
+ const k = byTitle.get(r.series.key.slice(2));
54
+ if (k) r.series = { key: k, by: 'title' };
55
+ }
56
+ return rows;
57
+ }
58
+
59
+ /** Every note row with its series, the same as GET /api/notes. */
60
+ function rowsOf(dir, now) { return (notes.list(dir, { now }).notes || []); }
61
+
62
+ /* ── a note's parts ───────────────────────────────────────────────────────────────────────────────────────────────── */
63
+
64
+ /** What a note holds: its final notes after Stop, else its saved local summary, else a local summary made now. */
65
+ const partsCache = new WeakMap(); // a cached note (notes.js loadCached) → its parts, made again only when the file changes
66
+ function partsOf(n) {
67
+ if (n && typeof n === 'object' && partsCache.has(n)) return partsCache.get(n);
68
+ const p = partsNow(n);
69
+ if (n && typeof n === 'object') partsCache.set(n, p);
70
+ return p;
71
+ }
72
+ function partsNow(n) {
73
+ const empty = { overview: '', decisions: [], actions: [], questions: [] };
74
+ if (!n || n.private) return empty;
75
+ const f = n.final_notes && typeof n.final_notes === 'object' ? n.final_notes : null;
76
+ let sec = n.summary && n.summary.sections ? n.summary.sections : null;
77
+ if (!f && !sec && ((n.transcript || []).length || (n.bullets || []).length)) { try { sec = notes.summarize(n).sections; } catch { sec = null; } }
78
+ const pick = (a, b) => (Array.isArray(a) && a.length ? a : Array.isArray(b) ? b : []);
79
+ const ov = n.overview_pm || (f && (f.overview || f.purpose))
80
+ || (sec && Array.isArray(sec.overview) ? sec.overview.slice(0, 3).map((x) => String((x && x.text) || '')).filter(Boolean).join(' ') : '');
81
+ const items = (xs, extra) => xs.filter((x) => x && clip(x.text, 400)).map((x) => ({ text: clip(x.text, 400), ...extra(x) }));
82
+ return {
83
+ overview: clip(ov, 600),
84
+ decisions: items(pick(f && f.decisions, sec && sec.decisions), (x) => ({ why: clip(x.why, 300) || null })),
85
+ actions: items(pick(f && f.actions, sec && sec.actions), (x) => ({ owner: clip(x.owner, 80) || null, due: clip(x.due, 60) || null, quote: clip(x.quote, 300) || null })),
86
+ questions: items(pick(f && f.questions, sec && sec.questions), (x) => ({ quote: clip(x.quote, 300) || null })),
87
+ };
88
+ }
89
+
90
+ /** The notes of one series, oldest first, each read in full with its parts. */
91
+ function membersOf(dir, key, rows) {
92
+ return rows.filter((r) => r.series && r.series.key === key)
93
+ .map((r) => ({ row: r, note: notes.loadCached(dir, r.id) }))
94
+ .filter((x) => x.note)
95
+ .sort((a, b) => timeOf(a.note) - timeOf(b.note))
96
+ .map((x) => ({ ...x, at: startOf(x.note), parts: partsOf(x.note) }));
97
+ }
98
+
99
+ /* ── the PM's marks ───────────────────────────────────────────────────────────────────────────────────────────────── */
100
+
101
+ const storeFile = (dir) => path.join(dir, 'notes-series.json');
102
+ function readStore(dir) {
103
+ try { const j = JSON.parse(fs.readFileSync(storeFile(dir), 'utf8')); return j && typeof j === 'object' && !Array.isArray(j) ? j : {}; } catch { return {}; }
104
+ }
105
+ function writeStore(dir, s) {
106
+ const f = storeFile(dir);
107
+ const tmp = f + '.' + process.pid + '.' + crypto.randomBytes(3).toString('hex') + '.tmp';
108
+ fs.mkdirSync(dir, { recursive: true });
109
+ fs.writeFileSync(tmp, JSON.stringify(s, null, 1));
110
+ fs.renameSync(tmp, f);
111
+ }
112
+ const entryOf = (s, key) => { const e = s[key] && typeof s[key] === 'object' ? s[key] : {}; return { done: e.done && typeof e.done === 'object' ? e.done : {}, bring: Array.isArray(e.bring) ? e.bring : [] }; };
113
+ const validKey = (key) => typeof key === 'string' && notes.SERIES_KEY_RE.test(key);
114
+
115
+ /** Mark an action done (kind 'action') or a question answered (kind 'question'), or take the mark back (done: false). */
116
+ function markDone(dir, { key, text, kind = 'action', done = true, now = Date.now() } = {}) {
117
+ if (!validKey(key)) return err(400, 'that is not a series');
118
+ const k = norm(text);
119
+ if (!k) return err(400, 'say which item');
120
+ if (!['action', 'question'].includes(kind)) return err(400, 'the kind must be action or question');
121
+ const s = readStore(dir); const e = entryOf(s, key);
122
+ if (done) e.done[k] = { text: clip(text, 400), kind, at: new Date(now).toISOString() }; else delete e.done[k];
123
+ s[key] = e; writeStore(dir, s);
124
+ return { ok: true };
125
+ }
126
+ /** Add a line to bring up at the next meeting of the series. */
127
+ function bring(dir, { key, text, now = Date.now() } = {}) {
128
+ if (!validKey(key)) return err(400, 'that is not a series');
129
+ const t = clip(text, 300);
130
+ if (!t) return err(400, 'write what to bring up');
131
+ const s = readStore(dir); const e = entryOf(s, key);
132
+ if (e.bring.length >= MAX_BRING) return err(400, 'this series already has ' + MAX_BRING + ' lines to bring up');
133
+ const line = { id: 'b-' + Math.floor(now).toString(36) + crypto.randomBytes(2).toString('hex'), text: t, at: new Date(now).toISOString() };
134
+ e.bring.push(line); s[key] = e; writeStore(dir, s);
135
+ return { ok: true, line };
136
+ }
137
+ function unbring(dir, { key, id } = {}) {
138
+ if (!validKey(key)) return err(400, 'that is not a series');
139
+ const s = readStore(dir); const e = entryOf(s, key);
140
+ const before = e.bring.length;
141
+ e.bring = e.bring.filter((b) => b.id !== id);
142
+ if (e.bring.length === before) return err(404, 'no such line');
143
+ s[key] = e; writeStore(dir, s);
144
+ return { ok: true };
145
+ }
146
+
147
+ /* ── the thread ───────────────────────────────────────────────────────────────────────────────────────────────────── */
148
+
149
+ const STOP = new Set(('the a an and or of to in on for with is are was be it this that we you i at by from as but not if so do does did will can '
150
+ + 'should would could has have had they he she them their our us all any some more most just also into about over than then when what who which '
151
+ + 'how why there here yes no ok okay get got go going need needs make made let lets one two next last week today tomorrow monday tuesday wednesday '
152
+ + 'thursday friday team meeting call update updates').split(' '));
153
+ const wordsIn = (s) => norm(s).split(' ').filter((w) => w.length > 2 && !STOP.has(w) && !/^\d+$/.test(w));
154
+
155
+ /** For each agenda item, whether a transcript line shares two of its words (notes-context.js agendaHeard, on the server). */
156
+ function agendaHeard(agenda, lines) {
157
+ const ls = lines.map((l) => new Set(wordsIn(l.text)));
158
+ return agenda.map((a) => {
159
+ const aw = [...new Set(wordsIn(a))]; const need = Math.min(2, aw.length);
160
+ return need > 0 && ls.some((w) => aw.filter((x) => w.has(x)).length >= need);
161
+ });
162
+ }
163
+
164
+ /** The topics that keep coming up over the last TOPIC_WINDOW meetings: agenda items heard, then two-word phrases from the
165
+ * notes, each in TOPIC_MIN or more meetings. → [{ name, hits: [bool, newest first], count, kind: 'agenda'|'phrase' }] */
166
+ function topicsOf(ms, seriesTitle) {
167
+ const recent = ms.filter((m) => !m.note.private).slice(-TOPIC_WINDOW).reverse();
168
+ if (recent.length < TOPIC_MIN) return [];
169
+ const out = [];
170
+ const agenda = new Map(); // norm → { name, hits }
171
+ recent.forEach((m, i) => {
172
+ const c = m.note.meeting && m.note.meeting.context;
173
+ const items = c && Array.isArray(c.agenda) ? c.agenda.map((a) => clip(a, 120)).filter(Boolean) : [];
174
+ if (!items.length) return;
175
+ const heard = agendaHeard(items, notes.transcriptLines(m.note) || []);
176
+ items.forEach((a, j) => {
177
+ if (!heard[j]) return;
178
+ const k = norm(a); if (!agenda.has(k)) agenda.set(k, { name: a, hits: recent.map(() => false) });
179
+ agenda.get(k).hits[i] = true;
180
+ });
181
+ });
182
+ for (const t of agenda.values()) { const count = t.hits.filter(Boolean).length; if (count >= TOPIC_MIN) out.push({ name: t.name, hits: t.hits, count, kind: 'agenda' }); }
183
+ const skip = new Set(wordsIn(seriesTitle));
184
+ const phrases = new Map(); // "w1 w2" → { name, hits }
185
+ recent.forEach((m, i) => {
186
+ const p = m.parts;
187
+ const texts = [p.overview, ...p.decisions.map((x) => x.text), ...p.actions.map((x) => x.text), ...p.questions.map((x) => x.text)];
188
+ const seen = new Set();
189
+ for (const t of texts) {
190
+ const raw = String(t || '').split(/[^\p{L}\p{N}]+/u).filter(Boolean);
191
+ const ws = raw.map((w) => w.toLowerCase());
192
+ for (let j = 0; j + 1 < ws.length; j++) {
193
+ const a = ws[j]; const b = ws[j + 1];
194
+ // a short word counts only as an acronym ("QA", "UX")
195
+ const short = (w, r) => w.length < 3 && !/^[A-Z]{2}$/.test(r);
196
+ if (short(a, raw[j]) || short(b, raw[j + 1]) || STOP.has(a) || STOP.has(b) || /^\d+$/.test(a) || /^\d+$/.test(b) || (skip.has(a) && skip.has(b))) continue;
197
+ const k = a + ' ' + b;
198
+ if (seen.has(k)) continue;
199
+ seen.add(k);
200
+ if (!phrases.has(k)) phrases.set(k, { name: raw[j] + ' ' + raw[j + 1], hits: recent.map(() => false) });
201
+ phrases.get(k).hits[i] = true;
202
+ }
203
+ }
204
+ });
205
+ const inAgenda = (k) => out.some((t) => k.split(' ').every((w) => norm(t.name).split(' ').includes(w)));
206
+ const ph = [...phrases.entries()].map(([k, t]) => ({ k, ...t, count: t.hits.filter(Boolean).length }))
207
+ .filter((t) => t.count >= TOPIC_MIN && !inAgenda(t.k)).sort((a, b) => b.count - a.count || a.k.localeCompare(b.k));
208
+ for (const t of ph) {
209
+ if (out.length >= 6) break;
210
+ // a phrase that shares a word with a stronger one already kept is the same topic
211
+ if (out.some((o) => o.kind === 'phrase' && o.k.split(' ').some((w) => t.k.split(' ').includes(w)))) continue;
212
+ out.push({ name: t.name.charAt(0).toUpperCase() + t.name.slice(1), hits: t.hits, count: t.count, kind: 'phrase', k: t.k });
213
+ }
214
+ return out.map(({ k, ...t }) => t);
215
+ }
216
+
217
+ /** The items of one kind across the meetings: each distinct item with when it was first raised, in how many meetings, and the
218
+ * last time it was raised. `window` limits which meetings an item must appear in to be listed. */
219
+ function carriedOf(ms, kind, window) {
220
+ const all = new Map(); // norm → { text, owner, due, first, seen, last }
221
+ for (const m of ms) {
222
+ if (m.note.private) continue;
223
+ const seen = new Set();
224
+ for (const x of m.parts[kind]) {
225
+ const k = norm(x.text);
226
+ if (!k || seen.has(k)) continue;
227
+ seen.add(k);
228
+ const e = all.get(k) || { key: k, first: m.at, seen: 0 };
229
+ e.seen++; e.text = x.text; e.owner = x.owner || e.owner || null; e.due = x.due || e.due || null;
230
+ e.last = { at: m.at, note: m.note.id, quote: x.quote || null };
231
+ all.set(k, e);
232
+ }
233
+ }
234
+ const recent = new Set(ms.filter((m) => !m.note.private).slice(-window).map((m) => m.note.id));
235
+ return [...all.values()].filter((e) => recent.has(e.last.note));
236
+ }
237
+
238
+ /** The next meeting of the series on the calendar, after `now`. */
239
+ function nextOf(key, events, now) {
240
+ const t = key.slice(2);
241
+ return (events || []).filter((e) => e && e.title && Date.parse(e.at) > now)
242
+ .filter((e) => (key.startsWith('s:') ? (e.series && 's:' + e.series === key) : false) || calMem.normTitle(e.title) === (key.startsWith('t:') ? t : null))
243
+ .sort((a, b) => Date.parse(a.at) - Date.parse(b.at))
244
+ .map((e) => ({ id: e.id, at: e.at, end: e.end || null, title: e.title }))[0] || null;
245
+ }
246
+
247
+ function thread(dir, key, { now = Date.now(), events = null, rows = null } = {}) {
248
+ if (!validKey(key)) return err(400, 'that is not a series');
249
+ const all = rows || rowsOf(dir, now);
250
+ const ms = membersOf(dir, key, all);
251
+ if (!ms.length) return err(404, 'no notes in this series');
252
+ if (events === null) { try { events = require('./calendar-events.js').readAll(dir, { now }); } catch { events = []; } }
253
+ // a series with a title key whose notes came from the calendar still finds its meetings by the calendar series
254
+ const seriesIds = [...new Set(ms.map((m) => m.note.meeting && m.note.meeting.context && m.note.meeting.context.series).filter(Boolean))];
255
+ const st = entryOf(readStore(dir), key);
256
+ const newest = ms[ms.length - 1];
257
+ const title = clip((newest.note.meeting && newest.note.meeting.title) || newest.note.title, 160) || 'Untitled meeting';
258
+ const hold = (e) => !st.done[e.key];
259
+ const actions = carriedOf(ms, 'actions', OPEN_WINDOW);
260
+ const open = actions.filter(hold).sort((a, b) => b.seen - a.seen || String(b.last.at).localeCompare(String(a.last.at)));
261
+ const closed = Object.entries(st.done).filter(([, v]) => v && v.kind === 'action' && now - Date.parse(v.at) <= CLOSED_DAYS * DAY)
262
+ .map(([k, v]) => ({ key: k, text: v.text, at: v.at })).sort((a, b) => String(b.at).localeCompare(String(a.at)));
263
+ const questions = carriedOf(ms, 'questions', OPEN_WINDOW).filter(hold).sort((a, b) => String(b.last.at).localeCompare(String(a.last.at)));
264
+ const decisions = [];
265
+ for (const m of ms.slice().reverse()) for (const d of m.parts.decisions) decisions.push({ text: d.text, why: d.why, at: m.at, note: m.note.id });
266
+ const meetings = ms.map((m, i) => {
267
+ const prev = i > 0 ? new Set(ms[i - 1].parts.actions.map((x) => norm(x.text))) : new Set();
268
+ return { id: m.note.id, title: clip(m.note.title, 160), at: m.at, ms: m.row.meeting_ms || 0, private: !!m.note.private, by: m.row.series.by,
269
+ overview: m.parts.overview || (m.row.summary_lines || []).join(' ').slice(0, 300), decisions: m.parts.decisions.length,
270
+ new_actions: m.parts.actions.filter((x) => !prev.has(norm(x.text))).length };
271
+ }).reverse();
272
+ let next = nextOf(key, events, now);
273
+ if (!next) for (const s of seriesIds) { next = nextOf('s:' + s, events, now); if (next) break; }
274
+ const derived = [
275
+ ...open.filter((e) => e.seen >= CARRY_WARN).slice(0, 3).map((e) => ({ kind: 'carried', text: e.text, owner: e.owner, seen: e.seen })),
276
+ ...questions.filter((e) => e.seen >= 2).slice(0, 2).map((e) => ({ kind: 'question', text: e.text, seen: e.seen })),
277
+ ];
278
+ const by = new Set(ms.map((m) => m.row.series.by));
279
+ return { ok: true, series: {
280
+ key, title, count: ms.length, since: ms[0].at, joined: { calendar: by.has('calendar'), title: ms.filter((m) => m.row.series.by === 'title').length, pm: ms.filter((m) => m.row.series.by === 'pm').length },
281
+ about: { portfolio: !!newest.note.portfolio, board: newest.note.board || null, initiative: newest.note.initiative || null },
282
+ next: next ? { ...next, bring: st.bring, derived } : { bring: st.bring, derived },
283
+ open, closed, decisions: decisions.slice(0, 60), questions, topics: topicsOf(ms, title), meetings } };
284
+ }
285
+
286
+ /** A note in its series: where it sits, the meeting before and after, and what changed since the meeting before. */
287
+ function placeOf(dir, id, { now = Date.now(), rows = null } = {}) {
288
+ const all = rows || rowsOf(dir, now);
289
+ const r = all.find((x) => x.id === id);
290
+ if (!r || !r.series) return null;
291
+ const ms = membersOf(dir, r.series.key, all);
292
+ if (ms.length < 2) return null;
293
+ const i = ms.findIndex((m) => m.note.id === id);
294
+ if (i < 0) return null;
295
+ const st = entryOf(readStore(dir), r.series.key);
296
+ const me = ms[i]; const prev = i > 0 ? ms[i - 1] : null; const next = i + 1 < ms.length ? ms[i + 1] : null;
297
+ const mine = new Set(me.parts.actions.map((x) => norm(x.text)));
298
+ const before = prev && !prev.note.private ? prev.parts.actions : [];
299
+ const prevKeys = new Set(before.map((x) => norm(x.text)));
300
+ const items = before.map((x) => ({ text: x.text, owner: x.owner, state: st.done[norm(x.text)] ? 'done' : mine.has(norm(x.text)) ? 'again' : 'quiet' }));
301
+ const fresh = me.parts.actions.filter((x) => !prevKeys.has(norm(x.text))).map((x) => ({ text: x.text, owner: x.owner }));
302
+ // how many meetings up to this one raised each of its actions ("carried · meeting N")
303
+ const carried = {};
304
+ for (const x of me.parts.actions) { const k = norm(x.text); const n = ms.slice(0, i + 1).filter((m) => !m.note.private && m.parts.actions.some((y) => norm(y.text) === k)).length; if (n >= 2) carried[k] = n; }
305
+ const carriedIn = items.filter((x) => x.state !== 'done');
306
+ const title = clip((ms[ms.length - 1].note.meeting && ms[ms.length - 1].note.meeting.title) || ms[ms.length - 1].note.title, 160);
307
+ return { key: r.series.key, title, pos: i + 1, count: ms.length, by: r.series.by,
308
+ prev: prev ? { id: prev.note.id, at: prev.at, overview: prev.note.private ? '' : prev.parts.overview } : null,
309
+ next: next ? { id: next.note.id, at: next.at } : null,
310
+ since: prev ? { at: prev.at, items, fresh } : null, carried_in: carriedIn, carried };
311
+ }
312
+
313
+ /** How many actions each series carries open, for the list's "N open": { key: count }, only for keys with two or more notes. */
314
+ function openCounts(dir, rows) {
315
+ const by = new Map();
316
+ for (const r of rows) if (r.series) by.set(r.series.key, (by.get(r.series.key) || 0) + 1);
317
+ const store = readStore(dir);
318
+ const out = {};
319
+ for (const [key, n] of by) {
320
+ if (n < 2) continue;
321
+ const st = entryOf(store, key);
322
+ const open = carriedOf(membersOf(dir, key, rows), 'actions', OPEN_WINDOW).filter((e) => !st.done[e.key]);
323
+ if (open.length) out[key] = { open: open.length, carried: open.filter((e) => e.seen >= CARRY_WARN).length };
324
+ }
325
+ return out;
326
+ }
327
+
328
+ /* ── search ───────────────────────────────────────────────────────────────────────────────────────────────────────── */
329
+
330
+ /** The notes whose text holds every word of `q`: the title always; for a note that is not private, also its overview, final
331
+ * notes, typed notes, summary and transcript. */
332
+ function search(dir, q) {
333
+ const ws = norm(q).split(' ').filter(Boolean);
334
+ if (!ws.length || norm(q).length < 2) return { ok: true, ids: [] };
335
+ let names = [];
336
+ try { names = fs.readdirSync(notes.notesDir(dir)).filter((f) => f.endsWith('.json')); } catch { return { ok: true, ids: [] }; }
337
+ const ids = [];
338
+ for (const f of names) {
339
+ const n = notes.loadCached(dir, f.slice(0, -5));
340
+ if (!n || !n.id) continue;
341
+ let hay = n.title || '';
342
+ if (!n.private) {
343
+ const fin = n.final_notes || {};
344
+ const items = (xs) => (Array.isArray(xs) ? xs.map((x) => (x && x.text) || '').join(' ') : '');
345
+ const sec = (n.summary && n.summary.sections) || {};
346
+ hay += ' ' + [n.overview_pm, fin.overview, fin.purpose, fin.discussion, items(fin.decisions), items(fin.actions), items(fin.questions), n.body,
347
+ items(sec.overview), items(sec.decisions), items(sec.actions), items(sec.questions), items(n.bullets), items(n.transcript),
348
+ n.meeting && n.meeting.title].filter(Boolean).join(' ');
349
+ }
350
+ const h = ' ' + norm(hay) + ' ';
351
+ if (ws.every((w) => h.includes(w))) ids.push(n.id);
352
+ }
353
+ return { ok: true, ids };
354
+ }
355
+
356
+ module.exports = { assign, openCounts, thread, placeOf, search, markDone, bring, unbring, partsOf, topicsOf, carriedOf, nextOf, norm, storeFile,
357
+ OPEN_WINDOW, TOPIC_WINDOW, TOPIC_MIN, CARRY_WARN };
@@ -111,6 +111,19 @@ function load(dir, id) {
111
111
  if (!ID_RE.test(String(id || ''))) return null;
112
112
  try { return JSON.parse(fs.readFileSync(fileOf(dir, id), 'utf8')); } catch { return null; }
113
113
  }
114
+ /* The list's reads (c3-notes-series): a note file is parsed again only when its modified time or size changed, so a list call
115
+ * with many notes reads only the notes that changed. The cached note is shared: a reader must not change it. */
116
+ const cached = new Map(); // file → { mtimeMs, size, note }
117
+ function loadCached(dir, id) {
118
+ if (!ID_RE.test(String(id || ''))) return null;
119
+ const f = fileOf(dir, id);
120
+ let st; try { st = fs.statSync(f); } catch { cached.delete(f); return null; }
121
+ const hit = cached.get(f);
122
+ if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size) return hit.note;
123
+ const note = load(dir, id);
124
+ if (note) cached.set(f, { mtimeMs: st.mtimeMs, size: st.size, note }); else cached.delete(f);
125
+ return note;
126
+ }
114
127
  function store(dir, note) { writeAtomic(fileOf(dir, note.id), JSON.stringify(note, null, 1)); }
115
128
  const newId = (now) => 'n-' + Math.floor(now).toString(36) + '-' + crypto.randomBytes(2).toString('hex');
116
129
  const cleanTitle = (t) => String(t == null ? '' : t).replace(/\s+/g, ' ').trim().slice(0, MAX_TITLE);
@@ -147,13 +160,48 @@ function stateFields(n, now, resumeMs) {
147
160
  ended_at: endedAt, ended_by: endedBy, sessions: sessionsOf(n) };
148
161
  }
149
162
 
163
+ /** The meeting time a row shows: the meeting clock, except that an ended meeting whose recorder ran on for more than
164
+ * LEFT_ON_MS after the last line heard stops at that line ("1735 min" for a stand-up left recording). */
165
+ const LEFT_ON_MS = 10 * 60 * 1000;
166
+ function heardMs(n, now = Date.now()) {
167
+ const ms = meetingMs(n, now);
168
+ if (!n.ended_at && !n.paused_at) return ms;
169
+ const last = Math.max(0, ...(n.transcript || []).map((x) => (x && Number.isFinite(x.t_end) ? x.t_end : x && Number.isFinite(x.t) ? x.t : 0)));
170
+ return last > 0 && ms - last > LEFT_ON_MS ? last : ms;
171
+ }
172
+ /** The series a note belongs to (c3-notes-series): the PM's choice (`series_pm`: a key, or 'off' for none), else the calendar
173
+ * series of its meeting, else the meeting's title, else the note's own title (calendar-memory.js seriesKey and normTitle).
174
+ * "Untitled note" and a title with no letters belong to none. → { key, by: 'pm'|'calendar'|'title' } or null. Pure. */
175
+ function seriesOf(n) {
176
+ if (!n) return null;
177
+ if (n.series_pm === 'off') return null;
178
+ if (typeof n.series_pm === 'string' && SERIES_KEY_RE.test(n.series_pm)) return { key: n.series_pm, by: 'pm' };
179
+ const calMem = require('./calendar-memory.js');
180
+ const m = n.meeting && n.meeting.kind === 'event' ? n.meeting : null;
181
+ if (m && m.context && m.context.series) return { key: 's:' + m.context.series, by: 'calendar' };
182
+ const t = calMem.normTitle((m && m.title) || n.title);
183
+ if (!t || t === 'untitled note' || t === 'untitled') return null;
184
+ return { key: 't:' + t, by: 'title' };
185
+ }
186
+ const SERIES_KEY_RE = /^[st]:[^\n]{1,200}$/;
187
+ /** Take a note out of its series (key 'off'), put it in one (a key), or go back to the automatic series (null). */
188
+ function setSeries(dir, { id, key } = {}) {
189
+ const note = load(dir, id);
190
+ if (!note) return err(404, 'no such note');
191
+ if (key === null || key === undefined || key === '') delete note.series_pm;
192
+ else if (key === 'off' || SERIES_KEY_RE.test(String(key))) note.series_pm = String(key);
193
+ else return err(400, 'that is not a series');
194
+ store(dir, note);
195
+ return { ok: true, id: note.id, series: seriesOf(note) };
196
+ }
197
+
150
198
  /** The row a list shows. Never the body or the transcript. */
151
199
  function rowOf(n, now = Date.now(), resumeMs = RESUME_MS) {
152
200
  return { id: n.id, title: n.title || 'Untitled note', created_at: n.created_at, updated_at: n.updated_at, private: !!n.private,
153
201
  words: wordsOf(n.body), segments: (n.transcript || []).length, summary: !!(n.summary || (n.agent && n.agent.markdown)),
154
- meeting: n.meeting || null, initiative: n.initiative || null, board: n.board || null, portfolio: !!n.portfolio, ws: n.ws || null,
202
+ meeting: n.meeting || null, initiative: n.initiative || null, board: n.board || null, portfolio: !!n.portfolio, ws: n.ws || null, series: seriesOf(n),
155
203
  // the meeting time so far (a paused note's clock) and how many statements differ from a repository file (the Notes menu)
156
- meeting_ms: n.recorded_from ? meetingMs(n) : 0, conflicts: n.private ? 0 : conflictsOf(n).length,
204
+ meeting_ms: n.recorded_from ? heardMs(n, now) : 0, conflicts: n.private ? 0 : conflictsOf(n).length,
157
205
  // a private note shows its title on the calendar and the initiative, never its summary
158
206
  summary_lines: n.private ? [] : summaryLines(n), ...stateFields(n, now, resumeMs) };
159
207
  }
@@ -299,7 +347,8 @@ function list(dir, { now = Date.now(), resumeMs } = {}) {
299
347
  let names = [];
300
348
  try { names = fs.readdirSync(notesDir(dir)).filter((f) => f.endsWith('.json')); } catch { return { ok: true, notes: [] }; }
301
349
  const notes = [];
302
- for (const f of names) { const n = load(dir, f.slice(0, -5)); if (n && n.id) notes.push(rowOf(n, now, resumeOf(resumeMs))); }
350
+ for (const f of names) { const n = loadCached(dir, f.slice(0, -5)); if (n && n.id) notes.push(rowOf(n, now, resumeOf(resumeMs))); }
351
+ require('./notes-series.js').assign(notes);
303
352
  notes.sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)) || (a.id < b.id ? 1 : -1));
304
353
  return { ok: true, notes };
305
354
  }
@@ -1711,7 +1760,7 @@ function editFinal(dir, { id, sec, i, text, field, now = Date.now() } = {}) {
1711
1760
  return { ok: true, final_notes: f };
1712
1761
  }
1713
1762
 
1714
- module.exports = { addHighlight, updateHighlight, removeHighlight, editFinal, conflictsOf, setFinal, setFinalState, renameFinalSpeaker, finalSpeaker, transcriptLines, cleanNotes, notesMarkdown, groundQuote, parseTime, NOTES_SHAPE,
1763
+ module.exports = { load, loadCached, heardMs, seriesOf, setSeries, SERIES_KEY_RE, addHighlight, updateHighlight, removeHighlight, editFinal, conflictsOf, setFinal, setFinalState, renameFinalSpeaker, finalSpeaker, transcriptLines, cleanNotes, notesMarkdown, groundQuote, parseTime, NOTES_SHAPE,
1715
1764
  wisprHandoff, wisprPrompt, setWispr, importWispr, notesOutOf, wisprOutOf, list, read, create, link, findByEvent, sameEvent, cleanMeeting, summaryLines, save, remove, record, pause, stop, expire, stateOf, sessionsOf, cleanContext, RESUME_MS, meetingMs, appendTranscript, updateSegment, setLabel, editLine, versionsOf, renameSpeaker, speakerOf, categoryOf, isFact, CATS, PM_CATS, VERSION_FOLD_MS, setPrivate, dictation, cleanSentences, spokenPunctuation,
1716
1765
  summarize, localSummary, agentHandoff, agentStarted, toMarkdown, promptFor, fmtTime, ownerOf, dueOf, ID_RE, notesDir, handoffOf, agentOutOf,
1717
1766
  meetsBar, checkable, vocabOf, termsIn, namesIn, SECTION_CAP, JOIN_GAP_MS,
@@ -63,6 +63,13 @@
63
63
  * POST /api/notes/wispr-fetch { id } → { ok, wispr } the PM asks for Wispr Flow's notes now, after the automatic fetch failed or
64
64
  * found nothing; the agent session is asked at once and again every two minutes for up to 20 minutes
65
65
  * POST /api/notes/final-edit { id, sec: 'overview'|'decision'|'action'|'question', i?, text, field?: 'owner'|'due'|'why' } → { ok, final_notes } the PM's correction
66
+ * GET /api/notes also answers `series_open`: { <series key>: { open, carried } }, the actions each meeting series carries open.
67
+ * GET /api/notes/series?key= → { ok, series } one regular meeting's notes together (lib/notes-series.js thread)
68
+ * GET /api/notes/search?q= → { ok, ids } the notes whose title, notes or transcript hold every word (a private note: its title)
69
+ * POST /api/notes/series-done { key, text, kind: 'action'|'question', done? } → { ok } the PM marks a carried action done, a question answered
70
+ * POST /api/notes/series-bring { key, text } → { ok, line } · POST /api/notes/series-unbring { key, id } → { ok } lines to bring up next time
71
+ * POST /api/notes/series-member { id, key: '<series key>'|'off'|null } → { ok, series } put a note in a series, take it out, or back to automatic
72
+ * GET /api/notes/one also answers `place`: the note's series, its position, the meeting before and after, and Since the last one.
66
73
  *
67
74
  * Notes are the PM's own and live in the per-user runtime directory (ctx.runtimeDir), never in a repository, so no route here
68
75
  * writes into a registered workspace. Every POST goes through pm-guard `requirePagePress` (a body that is not an object or carries
@@ -81,6 +88,7 @@
81
88
  */
82
89
  const guard = require('./pm-guard.js');
83
90
  const notes = require('./notes.js');
91
+ const notesSeries = require('./notes-series.js');
84
92
  const whisper = require('./notes-whisper.js');
85
93
  const notesCapture = require('./notes-capture.js');
86
94
  const notesRepo = require('./notes-repo.js');
@@ -103,7 +111,7 @@ const VOCAB_TTL_MS = 60 * 1000;
103
111
  const prefixes = ['/api/notes'];
104
112
  const POSTS = new Set(['/api/notes/new', '/api/notes/save', '/api/notes/dictate', '/api/notes/record', '/api/notes/transcript',
105
113
  '/api/notes/private', '/api/notes/pause', '/api/notes/stop', '/api/notes/summarize', '/api/notes/summarize-agent', '/api/notes/delete', '/api/notes/audio', '/api/notes/label',
106
- '/api/notes/speaker', '/api/notes/switch', '/api/notes/whisper-setup', '/api/notes/final', '/api/notes/final-setup', '/api/notes/line', '/api/notes/recording-delete', '/api/notes/screen', '/api/notes/screen-delete', '/api/notes/clean-audio', '/api/notes/link', '/api/notes/bullet', '/api/notes/highlight', '/api/notes/highlight-remove', '/api/notes/final-edit', '/api/notes/wispr-fetch']);
114
+ '/api/notes/speaker', '/api/notes/switch', '/api/notes/whisper-setup', '/api/notes/final', '/api/notes/final-setup', '/api/notes/line', '/api/notes/recording-delete', '/api/notes/screen', '/api/notes/screen-delete', '/api/notes/clean-audio', '/api/notes/link', '/api/notes/bullet', '/api/notes/highlight', '/api/notes/highlight-remove', '/api/notes/final-edit', '/api/notes/wispr-fetch', '/api/notes/series-done', '/api/notes/series-bring', '/api/notes/series-unbring', '/api/notes/series-member']);
107
115
 
108
116
  /* ── labels and checks, one note at a time ───────────────────────────────────────────────────── */
109
117
  const chains = new Map(); // note id → the promise of its work so far (minute batches, imports, labels, checks)
@@ -673,7 +681,11 @@ async function handle(req, res, url, ctx) {
673
681
  if (resumedFor !== dir) { try { resumeStuck(ctx); } catch { /* a note that cannot be picked up stays as it was */ } }
674
682
  if (p === '/api/notes' || p === '/api/notes/one') {
675
683
  if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
676
- if (p === '/api/notes') { const l = notes.list(dir, { resumeMs: resumeMsOf(ctx) }); if (l.ok) sweepExpired(ctx, l.notes); send(ctx, res, l); return true; }
684
+ if (p === '/api/notes') {
685
+ const l = notes.list(dir, { resumeMs: resumeMsOf(ctx) });
686
+ if (l.ok) { sweepExpired(ctx, l.notes); try { l.series_open = notesSeries.openCounts(dir, l.notes); } catch { l.series_open = {}; } }
687
+ send(ctx, res, l); return true;
688
+ }
677
689
  const nid = url.searchParams.get('id');
678
690
  // the bullets the agent wrote since the last read; Jev labels and checks them after the note's earlier work
679
691
  const imp = notes.importBullets(dir, { id: nid });
@@ -688,10 +700,18 @@ async function handle(req, res, url, ctx) {
688
700
  r.screen = { frames: r.note.private ? [] : notesScreen.list(dir, nid) };
689
701
  if (!Array.isArray(r.note.bullets)) r.note.bullets = [];
690
702
  r.bullets_state = bulletsState(ctx, r.note);
703
+ // where the note sits in its meeting series, and what changed since the meeting before (c3-notes-series)
704
+ try { r.place = notesSeries.placeOf(dir, nid); } catch { r.place = null; }
691
705
  }
692
706
  send(ctx, res, r);
693
707
  return true;
694
708
  }
709
+ if (p === '/api/notes/series' || p === '/api/notes/search') {
710
+ if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
711
+ if (p === '/api/notes/search') { send(ctx, res, notesSeries.search(dir, url.searchParams.get('q') || '')); return true; }
712
+ send(ctx, res, notesSeries.thread(dir, url.searchParams.get('key') || ''));
713
+ return true;
714
+ }
695
715
  if (p === '/api/notes/screen' && (req.method === 'GET' || req.method === 'HEAD')) {
696
716
  const nid = url.searchParams.get('id');
697
717
  const r = notes.read(dir, nid);
@@ -748,6 +768,11 @@ async function handle(req, res, url, ctx) {
748
768
  if (r.ok) r.reused = false;
749
769
  return done(r);
750
770
  }
771
+ // the meeting series (c3-notes-series): the PM's marks and lines per series, and which series a note is in
772
+ if (p === '/api/notes/series-done') return done(notesSeries.markDone(dir, { key: body.key, text: body.text, kind: body.kind, done: body.done !== false }));
773
+ if (p === '/api/notes/series-bring') return done(notesSeries.bring(dir, { key: body.key, text: body.text }));
774
+ if (p === '/api/notes/series-unbring') return done(notesSeries.unbring(dir, { key: body.key, id: body.id }));
775
+ if (p === '/api/notes/series-member') return done(notes.setSeries(dir, { id, key: body.key }));
751
776
  // from_event: the initiative is the calendar event's own, and never replaces the PM's choice
752
777
  if (p === '/api/notes/link') return done(notes.link(dir, { id, meeting: body.meeting, initiative: body.initiative, board: body.board, portfolio: typeof body.portfolio === 'boolean' ? body.portfolio : undefined, by: body.from_event === true ? 'event' : 'pm' }));
753
778
  if (p === '/api/notes/dictate') return done(notes.dictation(body.text));
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.178.0",
3
+ "version": "0.179.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.178.0",
9
+ "version": "0.179.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.178.0",
3
+ "version": "0.179.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",
@@ -64,14 +64,15 @@ const transcriptOf = (n) => (n.final && n.final.segments && n.final.segments.len
64
64
 
65
65
  /** The notes state on the calendar instance r. */
66
66
  function stateOf(r) {
67
- if (!r.notes) r.notes = { box: el('div', { class: 'cal3-notes', 'data-cal3': 'notes' }), rows: null, err: null, open: null, note: null, phase: null, q: '', seq: 0, live: null, unsub: null };
67
+ if (!r.notes) r.notes = { box: el('div', { class: 'cal3-notes', 'data-cal3': 'notes' }), rows: null, err: null, open: null, note: null, phase: null, q: '', seq: 0, live: null, unsub: null,
68
+ ser: null, thread: null, place: null, expand: new Set(), older: new Set(), found: null, every: false, open_by: {} };
68
69
  return r.notes;
69
70
  }
70
71
 
71
72
  /** The note the Calendar shows next: from the Record chip (phase During) or a meeting's detail (its default stage). */
72
73
  export function openNote(r, id, phase) {
73
74
  const s = stateOf(r);
74
- s.open = id; s.note = null; s.phase = phase || null;
75
+ s.open = id; s.note = null; s.phase = phase || null; s.ser = null; s.thread = null;
75
76
  r.page = r.page || null;
76
77
  if (r.page) r.page.mode = 'notes';
77
78
  else r.pendingMode = 'notes';
@@ -92,18 +93,25 @@ export function notesView(ui) {
92
93
  s.ui = ui;
93
94
  if (!s.unsub) s.unsub = onNotes(() => { if (s.box.isConnected) read(s); });
94
95
  if (!s.boards) loadBoards(s);
95
- if (!s.rows || (s.open && (!s.note || s.note.id !== s.open))) read(s);
96
+ if (!s.rows || (s.open && (!s.note || s.note.id !== s.open)) || (s.ser && (!s.thread || s.thread.key !== s.ser))) read(s);
96
97
  draw(s);
97
98
  return s.box;
98
99
  }
99
100
 
100
101
  async function read(s) {
101
102
  const seq = ++s.seq;
102
- const [l, one] = await Promise.all([pmGet('/api/notes'), s.open ? pmGet('/api/notes/one?id=' + encodeURIComponent(s.open)) : null]);
103
+ const ser = s.ser;
104
+ const [l, one, th] = await Promise.all([pmGet('/api/notes'), !ser && s.open ? pmGet('/api/notes/one?id=' + encodeURIComponent(s.open)) : null,
105
+ ser ? pmGet('/api/notes/series?key=' + encodeURIComponent(ser)) : null]);
103
106
  if (seq !== s.seq) return;
104
- if (l && l.ok) { s.rows = l.notes || []; s.err = null; } else { s.rows = s.rows || []; s.err = (l && l.error) || 'The notes could not be read.'; }
107
+ if (l && l.ok) { s.rows = l.notes || []; s.open_by = l.series_open || {}; s.err = null; } else { s.rows = s.rows || []; s.err = (l && l.error) || 'The notes could not be read.'; }
108
+ if (ser) {
109
+ if (th && th.ok && th.series) s.thread = th.series;
110
+ else { s.ser = null; s.thread = null; toast('That meeting series could not be read' + (th && th.error ? ': ' + th.error : '.')); }
111
+ draw(s); return;
112
+ }
105
113
  if (!s.open && s.rows.length) { s.open = s.rows[0].id; return read(s); }
106
- if (one && one.ok && one.note && one.note.id === s.open) { s.note = one.note; s.shots = (one.screen && one.screen.frames) || []; s.rec = one.recording || null; }
114
+ if (one && one.ok && one.note && one.note.id === s.open) { s.note = one.note; s.shots = (one.screen && one.screen.frames) || []; s.rec = one.recording || null; s.place = one.place || null; }
107
115
  draw(s);
108
116
  }
109
117
 
@@ -114,9 +122,15 @@ async function loadBoards(s) {
114
122
  if (r && r.ok && Array.isArray(r.boards)) { s.boards = r.boards.filter((b) => b && b.id && !b.error); if (s.box.isConnected) draw(s); }
115
123
  }
116
124
 
117
- function choose(s, id) {
118
- if (s.open === id) return;
119
- s.open = id; s.note = null; s.phase = null; s.shots = []; s.rec = null;
125
+ function choose(s, id, phase) {
126
+ if (s.open === id && !s.ser) return;
127
+ s.open = id; s.note = null; s.phase = phase || null; s.shots = []; s.rec = null; s.place = null; s.ser = null; s.thread = null;
128
+ draw(s); read(s);
129
+ }
130
+ /* open a meeting series on the right (the series page) */
131
+ function chooseSeries(s, key) {
132
+ if (s.ser === key) return;
133
+ s.ser = key; s.thread = null; s.every = false; s.note = null; s.open = null; s.place = null;
120
134
  draw(s); read(s);
121
135
  }
122
136
 
@@ -127,61 +141,135 @@ function draw(s) {
127
141
  if (active && s.box.contains(active) && active.matches('textarea, [contenteditable="true"]') && !active.closest('[data-nt-part="live"]')) return;
128
142
  const q = s.box.querySelector('[data-cal3-notes="search"]');
129
143
  const typing = !!q && active === q; const caret = typing ? q.selectionStart : null;
130
- s.box.replaceChildren(el('div', { class: 'cal3-nwrap' }, listCard(s), noteCol(s)));
144
+ s.box.replaceChildren(el('div', { class: 'cal3-nwrap' }, listCard(s), s.ser ? seriesCol(s) : noteCol(s)));
131
145
  s.drawn = true;
132
146
  if (typing) { const nq = s.box.querySelector('[data-cal3-notes="search"]'); if (nq) { nq.focus(); nq.setSelectionRange(caret, caret); } }
133
147
  }
134
148
 
135
149
  /* ── the list ─────────────────────────────────────────────────────────────────────────────────────────────────────── */
136
150
 
151
+ const OLD_DAYS = 30;
152
+ const SEARCH_MIN = 2;
153
+ const seriesKeyOf = (r) => (r && r.series ? r.series.key : null);
154
+
137
155
  function listCard(s) {
138
156
  const qtext = s.q.trim().toLowerCase();
157
+ const found = s.found && s.found.q === qtext ? s.found.ids : null;
139
158
  const shown = (s.rows || []).filter((r) => !qtext || String(r.title || '').toLowerCase().includes(qtext) || pathOf(r).some((x) => String(x).toLowerCase().includes(qtext))
140
- || (r.summary_lines || []).some((x) => String(x).toLowerCase().includes(qtext)));
141
- const search = el('input', { type: 'text', class: 'cal3-nsearch', placeholder: 'Search notes', 'aria-label': 'Search notes', value: s.q, 'data-cal3-notes': 'search' });
142
- search.addEventListener('input', () => { s.q = search.value; draw(s); });
159
+ || (r.summary_lines || []).some((x) => String(x).toLowerCase().includes(qtext)) || (found && found.has(r.id)));
160
+ const search = el('input', { type: 'text', class: 'cal3-nsearch', placeholder: 'Search notes, decisions, what was said', 'aria-label': 'Search notes', value: s.q, 'data-cal3-notes': 'search' });
161
+ search.addEventListener('input', () => { s.q = search.value; draw(s); findLater(s); });
143
162
  let body;
144
163
  if (!s.rows) body = fold('clock', 'Reading the notes', { role: 'status' });
145
164
  else if (!s.rows.length) body = fold('mic', s.err || 'No notes yet. Press Record at the top right to start one.', { 'data-cal3-notes': 'none' });
146
- else if (!shown.length) body = fold('search', 'No note matches “' + s.q.trim() + '”.', { 'data-cal3-notes': 'no-match' });
147
- else body = scopeGroups(s, shown).map((g) => el('div', { class: 'cal3-ctx', 'data-cal3-notes-group': g.label }, el('h4', null, g.label),
148
- el('ul', { class: 'list', role: 'list' }, g.rows.map((r) => noteRow(s, r)))));
165
+ else if (!shown.length) body = fold('search', qtext.length >= SEARCH_MIN && !found ? 'Searching the notes' : 'No note matches “' + s.q.trim() + '”.', { 'data-cal3-notes': 'no-match' });
166
+ else body = scopeGroups(s, shown).map((g) => {
167
+ const old = qtext ? [] : g.entries.filter((e) => isOld(e));
168
+ const recent = qtext ? g.entries : g.entries.filter((e) => !isOld(e));
169
+ const open = s.older.has(g.key);
170
+ const olderN = old.reduce((t, e) => t + e.rows.length, 0);
171
+ const rows = [...recent.flatMap((e) => entryRows(s, e, !!qtext)), ...(open ? old.flatMap((e) => entryRows(s, e, false)) : [])];
172
+ if (old.length) rows.push(el('li', { class: 'nrow cal3-nmore' }, btn('chip2 lk', { 'data-cal3-notes': 'older', 'aria-expanded': String(open) },
173
+ open ? 'Hide the older notes' : [c3ic('clock', 'sm'), 'Older than ' + OLD_DAYS + ' days · ' + olderN + (olderN === 1 ? ' note' : ' notes')],
174
+ () => { if (open) s.older.delete(g.key); else s.older.add(g.key); draw(s); })));
175
+ return el('div', { class: 'cal3-ctx', 'data-cal3-notes-group': g.label }, el('h4', null, g.label), el('ul', { class: 'list', role: 'list' }, rows));
176
+ });
149
177
  return el('section', { class: 'card sect', 'data-cal3-notes': 'list', 'aria-label': 'Notes' },
150
178
  el('div', { class: 'sh2' }, el('h2', null, c3ic('notes', 'sm'), 'Notes ', el('span', { class: 'bdg g', 'data-cal3-notes': 'count' }, String(shown.length)))),
151
179
  el('div', { class: 'cal3-nsearchw' }, search), body);
152
180
  }
181
+ const isOld = (e) => { const t = Date.parse(startOf(e.rows[0])); return Number.isFinite(t) && Date.now() - t > OLD_DAYS * 86400000 && !e.rows.some((r) => held(r.id) || r.state === 'recording' || r.state === 'paused'); };
182
+
183
+ /* search the notes' text on the server (GET /api/notes/search), a moment after the last key */
184
+ function findLater(s) {
185
+ clearTimeout(s.findT);
186
+ const q = s.q.trim().toLowerCase();
187
+ if (q.length < SEARCH_MIN) { s.found = null; return; }
188
+ s.findT = setTimeout(async () => {
189
+ const r = await pmGet('/api/notes/search?q=' + encodeURIComponent(q));
190
+ if (s.q.trim().toLowerCase() !== q) return;
191
+ s.found = { q, ids: new Set(r && r.ok && Array.isArray(r.ids) ? r.ids : []) };
192
+ if (s.box.isConnected) draw(s);
193
+ }, 220);
194
+ }
153
195
 
154
196
  /* the list's groups: Portfolio, each board in the portfolio's order (a board it does not list goes after them by name), an
155
- * initiative linked before boards were (no board on the note) under "Initiatives", then "Not linked"; in a group, the newest
156
- * meeting (or recording) first */
197
+ * initiative linked before boards were (no board on the note) under "Initiatives", then "Not linked". The notes of one meeting
198
+ * series (lib/notes-series.js, two or more notes with one key) are one entry, in the group of their newest note. In a group,
199
+ * the newest meeting (or recording) first. → [{ key, label, entries: [{ key, rows (newest first) }] }] */
157
200
  function scopeGroups(s, rows) {
158
201
  const order = new Map((s.boards || []).map((b, i) => [boardKey(b), i]));
159
202
  const known = new Map((s.boards || []).map((b) => [boardKey(b), b.name || b.id]));
160
- const g = new Map();
203
+ const t = (r) => Date.parse(startOf(r)) || 0;
204
+ const all = (s.rows || []);
205
+ const size = new Map();
206
+ for (const r of all) { const k = seriesKeyOf(r); if (k) size.set(k, (size.get(k) || 0) + 1); }
207
+ const entries = new Map();
161
208
  for (const r of rows) {
209
+ const k = seriesKeyOf(r);
210
+ const ek = k && size.get(k) >= 2 ? 'S' + k : 'N' + r.id;
211
+ if (!entries.has(ek)) entries.set(ek, { key: ek, series: ek[0] === 'S' ? k : null, rows: [], size: k ? size.get(k) : 1 });
212
+ entries.get(ek).rows.push(r);
213
+ }
214
+ const g = new Map();
215
+ for (const e of entries.values()) {
216
+ e.rows.sort((a, b) => t(b) - t(a));
217
+ const r = e.rows[0];
162
218
  const k = r.portfolio ? '@portfolio' : r.board ? boardKey(r.board) : r.initiative ? '@initiative' : '';
163
- if (!g.has(k)) g.set(k, { key: k, label: k === '@portfolio' ? 'Portfolio' : k === '@initiative' ? 'Initiatives' : !k ? 'Not linked' : known.get(k) || boardName(r.board), rows: [] });
164
- g.get(k).rows.push(r);
219
+ if (!g.has(k)) g.set(k, { key: k, label: k === '@portfolio' ? 'Portfolio' : k === '@initiative' ? 'Initiatives' : !k ? 'Not linked' : known.get(k) || boardName(r.board), entries: [] });
220
+ g.get(k).entries.push(e);
165
221
  }
166
222
  // the server lists notes by when they were last edited; a group reads newest meeting first
167
- for (const x of g.values()) x.rows.sort((a, b) => (Date.parse(startOf(b)) || 0) - (Date.parse(startOf(a)) || 0));
223
+ for (const x of g.values()) x.entries.sort((a, b) => t(b.rows[0]) - t(a.rows[0]));
168
224
  const rank = (x) => (x.key === '@portfolio' ? [0, 0] : x.key === '@initiative' ? [3, 0] : !x.key ? [4, 0] : order.has(x.key) ? [1, order.get(x.key)] : [2, 0]);
169
225
  return [...g.values()].sort((a, b) => { const p = rank(a); const q = rank(b); return p[0] - q[0] || p[1] - q[1] || a.label.localeCompare(b.label); });
170
226
  }
171
227
 
172
- function noteRow(s, r) {
228
+ /* the rows an entry draws: a note's row, or a series row with its newest meetings under it when open (or matching a search) */
229
+ function entryRows(s, e, searching) {
230
+ if (!e.series) return [noteRow(s, e.rows[0])];
231
+ const open = s.expand.has(e.series) || searching;
232
+ const out = [seriesRow(s, e, open)];
233
+ if (!open) return out;
234
+ const shown = e.rows.slice(0, 3);
235
+ out.push(...shown.map((r) => noteRow(s, r, true)));
236
+ const more = (searching ? e.rows.length : e.size) - shown.length;
237
+ if (more > 0) out.push(el('li', { class: 'nrow cal3-nsub cal3-nmore' }, btn('chip2 lk', { 'data-cal3-notes': 'series-all' },
238
+ searching ? more + ' more matching in the series' : 'All ' + e.size + ' meetings in the series', () => chooseSeries(s, e.series))));
239
+ return out;
240
+ }
241
+
242
+ function seriesRow(s, e, open) {
243
+ const r = e.rows[0];
244
+ const on = s.ser === e.series;
245
+ const o = s.open_by[e.series];
246
+ const title = (r.meeting && r.meeting.kind === 'event' && r.meeting.title) || r.title || 'Untitled meeting';
247
+ return el('li', { class: 'nrow cal3-nrow cal3-nser' + (on ? ' on' : ''), 'data-cal3-series': e.series },
248
+ el('span', { class: 'ki' }, c3ic('notes')),
249
+ btn('cal3-nopen', { title: 'Open the series ' + title, 'aria-current': on ? 'true' : 'false' }, [
250
+ el('span', { class: 'tt' }, title),
251
+ el('span', { class: 'chips' }, el('span', { class: 'dim mono cal3-xs' }, whenShort(startOf(r)) + ' · ' + e.size + ' meetings'),
252
+ el('span', { class: 'tag', title: 'A regular meeting: its notes are kept together' }, 'series'),
253
+ o ? el('span', { class: 'st ' + (o.carried ? 'warn' : 'unt'), title: o.open + (o.open === 1 ? ' action is' : ' actions are') + ' carried from meeting to meeting', 'data-cal3-notes': 'series-open' }, c3ic(o.carried ? 'alert' : 'todo', 'sm'), o.open + ' open') : null)],
254
+ () => chooseSeries(s, e.series)),
255
+ btn('ib sm', { 'data-cal3-notes': 'series-x', 'aria-expanded': String(open), title: open ? 'Hide its recent meetings' : 'Show its recent meetings', 'aria-label': open ? 'Hide recent meetings' : 'Show recent meetings' },
256
+ c3ic(open ? 'fold' : 'next', 'sm'), () => { if (s.expand.has(e.series)) s.expand.delete(e.series); else s.expand.add(e.series); draw(s); }));
257
+ }
258
+
259
+ function noteRow(s, r, sub) {
173
260
  const mode = held(r.id) || (r.state === 'recording' || r.state === 'paused' ? r.state : null);
174
- const on = s.open === r.id;
261
+ const on = s.open === r.id && !s.ser;
175
262
  const len = mins(r.meeting_ms || 0);
176
- return el('li', { class: 'nrow cal3-nrow' + (on ? ' on' : ''), 'data-cal3-note-row': r.id },
263
+ const gist = sub ? (r.summary_lines || [])[0] : null;
264
+ return el('li', { class: 'nrow cal3-nrow' + (on ? ' on' : '') + (sub ? ' cal3-nsub' : ''), 'data-cal3-note-row': r.id },
177
265
  el('span', { class: 'ki' + (mode ? ' hot' : '') }, c3ic(mode ? 'mic' : 'doc')),
178
266
  btn('cal3-nopen', { title: 'Open ' + (r.title || 'Untitled note'), 'aria-current': on ? 'true' : 'false' }, [
179
- el('span', { class: 'tt' }, r.title || 'Untitled note'),
180
- el('span', { class: 'chips' }, el('span', { class: 'dim mono cal3-xs' }, [mode ? 'now' : whenShort(startOf(r)), len].filter(Boolean).join(' · ')),
267
+ el('span', { class: 'tt' }, gist || r.title || 'Untitled note'),
268
+ el('span', { class: 'chips' }, el('span', { class: 'dim mono cal3-xs' }, [mode ? 'now' : sub ? dayOf(startOf(r)) : whenShort(startOf(r)), len].filter(Boolean).join(' · ')),
181
269
  mode ? el('span', { class: 'tag', 'data-cal3-notes': 'live' }, mode === 'paused' ? 'paused' : 'recording')
182
- : r.initiative ? el('span', { class: 'chip2', title: 'Initiative', 'data-cal3-notes': 'row-initiative' }, c3ic('init', 'sm'), iniName(r.initiative))
270
+ : sub ? null : r.initiative ? el('span', { class: 'chip2', title: 'Initiative', 'data-cal3-notes': 'row-initiative' }, c3ic('init', 'sm'), iniName(r.initiative))
183
271
  : r.board ? el('span', { class: 'tag', title: 'Linked to the board, not to one initiative', 'data-cal3-notes': 'row-board' }, 'board') : null,
184
- !linked(r) && !mode ? el('span', { class: 'tag', title: 'Not linked to a meeting', 'data-cal3-notes': 'no-meeting' }, 'no meeting') : null,
272
+ !linked(r) && !mode && !sub ? el('span', { class: 'tag', title: 'Not linked to a meeting', 'data-cal3-notes': 'no-meeting' }, 'no meeting') : null,
185
273
  r.conflicts ? el('span', { class: 'st warn', title: r.conflicts + (r.conflicts === 1 ? ' statement differs' : ' statements differ') + ' from a file' }, c3ic('alert', 'sm'), String(r.conflicts)) : null)],
186
274
  () => choose(s, r.id)),
187
275
  el('span'));
@@ -206,8 +294,8 @@ function noteCol(s) {
206
294
  [dayOf(startOf(n)), len].filter(Boolean).join(' · '),
207
295
  btn('ib sm', { title: 'Copy as Markdown (with the meeting context)', 'aria-label': 'Copy as Markdown', 'data-cal3-notes': 'copy' }, c3ic('clip'), () => copy(n)),
208
296
  btn('ib sm', { title: 'Delete this note', 'aria-label': 'Delete note', 'data-cal3-notes': 'delete' }, c3ic('trash'), () => remove(s, n))),
209
- links(s, n, ev));
210
- const body = ph === 'before' ? before(s, n) : ph === 'during' ? during(s, n, mode) : after(s, n, mode);
297
+ links(s, n, ev), seriesLine(s, n));
298
+ const body = ph === 'before' ? before(s, n) : ph === 'during' ? during(s, n, mode) : [sinceCard(s, n), after(s, n, mode)].filter(Boolean);
211
299
  return el('div', { class: 'cal3-ncol', 'data-cal3-notes': 'note', 'data-cal3-note': n.id, 'data-cal3-stage': ph }, head, ...[].concat(body));
212
300
  }
213
301
 
@@ -309,12 +397,13 @@ function before(s, n) {
309
397
  const ev = n.meeting && n.meeting.kind === 'event' ? n.meeting : null;
310
398
  const h3 = el('h3', null, c3ic('spark'), 'Before the meeting');
311
399
  if (!ev) {
312
- return el('section', { class: 'card', 'data-cv': 'stage-before' }, h3,
400
+ return el('section', { class: 'card', 'data-cv': 'stage-before' }, h3, ...carriedIn(s.place),
313
401
  fold('info', n.meeting && n.meeting.kind === 'adhoc' ? 'A meeting that is not on the calendar, so nothing was prepared for it.' : 'Not linked to a meeting, so nothing was prepared. Link it to a meeting to bring in its people and agenda.', { 'data-cal3-notes': 'no-meeting-before' }),
314
402
  linkButtons(s, n));
315
403
  }
316
404
  const c = ev.context || null;
317
405
  const lines = transcriptOf(n).map((x) => ({ t: x.t, text: x.text }));
406
+ const pl = s.place && s.place.key ? s.place : null;
318
407
  const heard = c ? agendaHeard(c, lines) : [];
319
408
  const rows = [];
320
409
  const isHeard = (i) => heard[i] !== null && heard[i] !== undefined;
@@ -331,7 +420,7 @@ function before(s, n) {
331
420
  rows.push(prepRow('cal', 'Meeting', [ev.title, when, c && c.where ? 'where: ' + c.where : null, c && c.organizer ? 'organizer: ' + c.organizer : null,
332
421
  c && c.series ? 'series: ' + c.series : null, c && c.role ? 'your role: ' + c.role : null, c && c.external ? 'with people outside the company' : null,
333
422
  c && c.attendees && names.length ? c.attendees + ' attendees' : null].filter(Boolean).join(' · ')));
334
- return el('section', { class: 'card', 'data-cv': 'stage-before' }, h3, ...rows,
423
+ return el('section', { class: 'card', 'data-cv': 'stage-before' }, h3, ...carriedIn(pl), ...rows,
335
424
  c ? null : el('p', { class: 'dim cal3-sm' }, 'The calendar gave no more about this meeting.'));
336
425
  }
337
426
 
@@ -392,7 +481,7 @@ function after(s, n, mode) {
392
481
  if (p.actions.length) kids.push(el('table', { class: 'tbl', 'data-cv': 'actions' }, el('thead', null, el('tr', null, el('th', null, 'Next step'), el('th', null, 'Owner'), el('th', null, 'Due'), el('th'))),
393
482
  el('tbody', null, p.actions.map((a) => el('tr', { 'data-cal3-notes': 'action' }, cell(a, 'text', 'Next step'),
394
483
  cell(a, 'owner', 'Owner', 'No owner'), cell(a, 'due', 'Due', 'No date'),
395
- el('td', { class: 'cal3-tdr' }, btn('chip2 lk', { 'data-cal3-notes': 'make-task', title: n.initiative ? 'Add this step to ' + iniName(n.initiative) + ' as a plan item' : 'Link the note to an initiative first' },
484
+ el('td', { class: 'cal3-tdr' }, carriedTag(s, a), btn('chip2 lk', { 'data-cal3-notes': 'make-task', title: n.initiative ? 'Add this step to ' + iniName(n.initiative) + ' as a plan item' : 'Link the note to an initiative first' },
396
485
  [c3ic('plus', 'sm'), 'Make task'], (e) => makeTask(n, a, { btn: e.currentTarget }))))))));
397
486
  if (p.questions.length) kids.push(el('div', { class: 'cal3-ctx', 'data-cal3-notes': 'questions' }, el('h4', null, 'Open questions'),
398
487
  el('ul', { class: 'cal3-cl', role: 'list' }, p.questions.map((x) => el('li', { class: 'cal3-ci' }, el('span', { class: 'tag' }, 'Question'), el('span', { class: 'cal3-ct' }, x.text))))));
@@ -465,6 +554,182 @@ async function remove(s, n) {
465
554
  read(s);
466
555
  }
467
556
 
557
+ /* ── a meeting series (initiative c3-notes-series) ────────────────────────────────────────────────────────────────── */
558
+
559
+ /* the words of an item, as lib/notes-series.js norm() matches them */
560
+ const normItem = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
561
+ const plural = (n, one, many) => n + ' ' + (n === 1 ? one : many || one + 's');
562
+ const sect = (name, ic, title, count, kids) => el('section', { class: 'card sect', 'data-cal3-series-card': name },
563
+ el('div', { class: 'sh2' }, el('h2', null, c3ic(ic, 'sm'), title, count !== null && count !== undefined ? el('span', { class: 'bdg g' }, String(count)) : null)), kids);
564
+
565
+ /* "carried · meeting N": the step was raised in N meetings of the series up to this one */
566
+ function carriedTag(s, a) {
567
+ const k = s.place && s.place.carried ? s.place.carried[normItem(a.text)] : 0;
568
+ return k ? el('span', { class: 'st ' + (k >= 3 ? 'warn' : 'unt'), title: 'Raised in ' + k + ' meetings of this series', 'data-cal3-notes': 'carried' }, 'carried · meeting ' + k) : null;
569
+ }
570
+
571
+ /* Before: what was still open coming in from the meeting before */
572
+ function carriedIn(pl) {
573
+ if (!pl || !pl.prev) return [];
574
+ return [prepRow('notes', 'Carried in from ' + dayOf(pl.prev.at), pl.prev.overview || 'The meeting before in this series.'),
575
+ ...(pl.carried_in || []).map((x) => prepRow('todo', x.text, x.owner || 'No owner'))];
576
+ }
577
+
578
+ /* the head's series line: the meeting before, "IDPF - DSU · 4 of 21" (opens the series), the meeting after, and Take out;
579
+ * a note in no series offers Add to a series, and one taken out offers to go back */
580
+ function seriesLine(s, n) {
581
+ const pl = s.place;
582
+ if (pl && pl.key) {
583
+ const go = (x, ic, label) => { const b = btn('ib sm', { 'data-cal3-notes': 'series-' + label, title: x ? 'The meeting ' + (label === 'prev' ? 'before' : 'after') + ', ' + dayOf(x.at) : 'None ' + (label === 'prev' ? 'before' : 'after'), 'aria-label': label === 'prev' ? 'The meeting before' : 'The meeting after' }, c3ic(ic, 'sm'), () => { if (x) choose(s, x.id, s.phase); }); if (!x) b.disabled = true; return b; };
584
+ return el('div', { class: 'chips2 cal3-sm cal3-nserl', 'data-cal3-notes': 'series-line' }, go(pl.prev, 'prev', 'prev'),
585
+ btn('chip2 lk', { 'data-cal3-notes': 'series-open-page', title: 'Open the series: what is still open, decided and coming up' }, [c3ic('notes', 'sm'), pl.title + ' · ' + pl.pos + ' of ' + pl.count], () => chooseSeries(s, pl.key)),
586
+ go(pl.next, 'next', 'next'),
587
+ btn('chip2 lk', { 'data-cal3-notes': 'series-out', title: 'This note is not a meeting of this series' }, 'Not part of this series', () => member(s, n, 'off')));
588
+ }
589
+ if (n.series_pm === 'off') return el('div', { class: 'chips2 cal3-sm cal3-nserl', 'data-cal3-notes': 'series-line' }, c3ic('notes', 'sm'), el('span', { class: 'dim' }, 'Taken out of its series'),
590
+ btn('chip2 lk', { 'data-cal3-notes': 'series-back' }, 'Put it back', () => member(s, n, null)));
591
+ const size = new Map();
592
+ for (const r of s.rows || []) { const k = seriesKeyOf(r); if (k) size.set(k, (size.get(k) || [])); if (k) size.get(k).push(r); }
593
+ const series = [...size.entries()].filter(([, rs]) => rs.length >= 2 && !rs.some((r) => r.id === n.id));
594
+ if (!series.length || n.private) return null;
595
+ const b = btn('chip2 lk', { 'data-cal3-notes': 'series-add', title: 'Keep this note with the notes of a regular meeting' }, [c3ic('notes', 'sm'), 'Add to a series'], () => {
596
+ menu({ anchor: b, ariaLabel: 'Add to a series', items: series.map(([k, rs]) => {
597
+ const r = rs.slice().sort((x, y) => (Date.parse(startOf(y)) || 0) - (Date.parse(startOf(x)) || 0))[0];
598
+ const title = (r.meeting && r.meeting.kind === 'event' && r.meeting.title) || r.title || 'Untitled meeting';
599
+ return { label: title, detail: plural(rs.length, 'meeting'), iconNode: icon('notes'), attrs: { 'data-cal3-series-pick': k }, onSelect: () => member(s, n, k) };
600
+ }) });
601
+ });
602
+ return el('div', { class: 'chips2 cal3-sm cal3-nserl', 'data-cal3-notes': 'series-line' }, b);
603
+ }
604
+ async function member(s, n, key) {
605
+ const r = await pmPost('/api/notes/series-member', { id: n.id, key });
606
+ if (!r || !r.ok) { toast('Not saved' + ((r && r.error) ? ': ' + r.error : '')); return; }
607
+ toast(key === 'off' ? 'Taken out of the series.' : key ? 'Added to the series.' : 'Back in its series.');
608
+ n.series_pm = key || undefined;
609
+ refreshNotes();
610
+ read(s);
611
+ }
612
+
613
+ /* After: Since the last one. Each next step of the meeting before: Done (you marked it), Raised again (this note has it) or
614
+ * Not mentioned; then this note's new steps. */
615
+ function sinceCard(s, n) {
616
+ const pl = s.place;
617
+ if (!pl || !pl.since || n.private) return null;
618
+ const st = { done: ['ok', 'Done', 'check'], again: ['warn', 'Raised again', 'todo'], quiet: ['unt', 'Not mentioned', 'todo'] };
619
+ const rows = [
620
+ ...pl.since.items.map((x) => el('li', { class: 'nrow', 'data-cal3-since': x.state }, el('span', { class: 'ki' }, c3ic(st[x.state][2])),
621
+ el('div', { class: 'nb' }, el('span', { class: 'tt' }, x.text), el('div', { class: 'chips' }, el('span', { class: 'st ' + st[x.state][0] }, st[x.state][1]), x.owner ? el('span', { class: 'dim cal3-xs' }, x.owner) : null)), el('span'))),
622
+ ...pl.since.fresh.map((x) => el('li', { class: 'nrow', 'data-cal3-since': 'new' }, el('span', { class: 'ki' }, c3ic('plus')),
623
+ el('div', { class: 'nb' }, el('span', { class: 'tt' }, x.text), el('div', { class: 'chips' }, el('span', { class: 'tag' }, 'New'), x.owner ? el('span', { class: 'dim cal3-xs' }, x.owner) : null)), el('span'))),
624
+ ];
625
+ return el('section', { class: 'card sect', 'data-cal3-notes': 'since' },
626
+ el('div', { class: 'sh2' }, el('h2', null, c3ic('notes', 'sm'), 'Since the last one, ' + dayOf(pl.since.at))),
627
+ rows.length ? el('ul', { class: 'list', role: 'list' }, rows) : fold('info', 'The meeting before had no next steps, and this one has none.'));
628
+ }
629
+
630
+ /* The series page: the head, Next one, Still open and Decided so far, Keeps coming up and Still unanswered, Every meeting.
631
+ * Every line comes from the series' notes (GET /api/notes/series) or from the PM's own marks and lines. */
632
+ function seriesCol(s) {
633
+ const T = s.thread;
634
+ if (!T) return el('div', { 'data-cal3-notes': 'series' }, el('section', { class: 'card' }, fold('clock', 'Reading the series', { role: 'status' })));
635
+ const a = T.about || {};
636
+ const about = a.initiative ? [boardName(a.board), iniName(a.initiative)].filter(Boolean).join(' › ') : a.board ? boardName(a.board) : a.portfolio ? 'Portfolio' : null;
637
+ const joined = (T.joined.calendar ? 'From the calendar series' : 'Notes with the same title')
638
+ + (T.joined.calendar && T.joined.title ? ', plus ' + plural(T.joined.title, 'note') + ' with the same title' : '') + (T.joined.pm ? ', ' + T.joined.pm + ' added by you' : '') + '.';
639
+ const head = el('section', { class: 'card', 'data-cv': 'stage-head', 'data-cal3-notes': 'series-head' },
640
+ el('div', { class: 'cal3-nhead' }, el('span', { class: 'chips2' }, el('b', { 'data-cal3-notes': 'title' }, T.title), el('span', { class: 'tag' }, 'series')),
641
+ btn('ib sm', { title: 'Copy the series as Markdown', 'aria-label': 'Copy as Markdown', 'data-cal3-notes': 'copy' }, c3ic('clip'), () => copySeries(T))),
642
+ el('div', { class: 'chips2 dim cal3-sm' }, plural(T.count, 'meeting') + ' since ' + dayOf(T.since)),
643
+ el('div', { class: 'chips2 cal3-sm' }, about ? el('span', { class: 'chip2' }, c3ic(a.portfolio && !a.board ? 'org' : 'init', 'sm'), about) : null, el('span', { class: 'dim', 'data-cal3-notes': 'series-joined' }, joined)));
644
+ return el('div', { class: 'cal3-ncol', 'data-cal3-notes': 'series', 'data-cal3-series': T.key }, head, nextCard(s, T),
645
+ el('div', { class: 'cal3-two' }, openCard(s, T), decidedCard(s, T)),
646
+ el('div', { class: 'cal3-two' }, topicsCard(T), questionsCard(s, T)), everyCard(s, T));
647
+ }
648
+
649
+ function nextCard(s, T) {
650
+ const x = T.next || {};
651
+ const rows = [
652
+ ...(x.bring || []).map((b) => el('div', { class: 'chips2 cal3-prep', 'data-cal3-bring': b.id }, el('span', { class: 'ki' }, c3ic('spark')),
653
+ el('div', { class: 'cal3-prepb' }, el('b', null, b.text), el('div', { class: 'dim' }, 'Added by you, ' + dayOf(b.at))),
654
+ btn('ib sm', { title: 'Remove this line', 'aria-label': 'Remove', 'data-cal3-notes': 'bring-remove' }, c3ic('x', 'sm'), () => seriesPost(s, '/api/notes/series-unbring', { key: T.key, id: b.id }, 'Removed.')))),
655
+ ...(x.derived || []).map((d) => (d.kind === 'carried' ? prepRow('alert', d.text, 'Still open after ' + d.seen + ' meetings' + (d.owner ? ' · ' + d.owner : ''))
656
+ : prepRow('info', d.text, 'Unanswered in ' + d.seen + ' meetings'))),
657
+ ];
658
+ const add = el('input', { type: 'text', class: 'cal3-nsearch', placeholder: 'Add something to bring up', 'aria-label': 'Add something to bring up', 'data-cal3-notes': 'bring-input', maxlength: '300' });
659
+ add.addEventListener('keydown', (e) => { if (e.key === 'Enter' && add.value.trim()) { e.preventDefault(); seriesPost(s, '/api/notes/series-bring', { key: T.key, text: add.value.trim() }, 'Added. It shows here until you remove it.'); } });
660
+ const when = x.at ? dayOf(x.at) + ' ' + hm(x.at) : null;
661
+ return el('section', { class: 'card sect', 'data-cal3-series-card': 'next' },
662
+ el('div', { class: 'sh2' }, el('h2', null, c3ic('spark', 'sm'), when ? 'Next one, ' + when : 'Next one'), when ? null : el('span', { class: 'dim cal3-sm' }, 'not on the calendar yet')),
663
+ rows.length ? rows : fold('info', 'Nothing to bring up yet.', { 'data-cal3-notes': 'bring-none' }),
664
+ el('div', { class: 'chips2 cal3-nadd' }, add,
665
+ x.id ? btn('chip2 lk', { 'data-cal3-notes': 'series-meeting', title: 'Show the next meeting in the calendar' }, [c3ic('cal', 'sm'), 'Open the meeting'], () => showMeeting(s, { start: x.at, event_id: x.id })) : null));
666
+ }
667
+
668
+ function openCard(s, T) {
669
+ const rows = T.open.map((o) => el('li', { class: 'nrow', 'data-cal3-open': o.key }, el('span', { class: 'ki' }, c3ic('todo')),
670
+ el('div', { class: 'nb' }, el('span', { class: 'tt' }, o.text),
671
+ el('div', { class: 'chips' }, o.owner ? el('span', { class: 'chip2' }, c3ic('user', 'sm'), o.owner) : null, el('span', { class: 'dim mono cal3-xs' }, 'since ' + dayOf(o.first)),
672
+ el('span', { class: 'st ' + (o.seen >= 3 ? 'warn' : 'unt'), title: 'Raised in each of these meetings', 'data-cal3-notes': 'seen' }, o.seen >= 3 ? c3ic('alert', 'sm') : null, 'in ' + plural(o.seen, 'meeting'))),
673
+ el('div', { class: 'dim cal3-sm' }, o.last.quote ? 'Last said: “' + o.last.quote + '” ' + dayOf(o.last.at) : 'Last raised ' + dayOf(o.last.at))),
674
+ btn('chip2 lk', { 'data-cal3-notes': 'series-done', title: 'Mark it done: it stops carrying over' }, [c3ic('check', 'sm'), 'Done'], () => seriesPost(s, '/api/notes/series-done', { key: T.key, text: o.text, kind: 'action' }, 'Done. It stops carrying over.'))));
675
+ const closed = T.closed.length ? el('details', { class: 'cal3-ctx', 'data-cal3-notes': 'closed' }, el('summary', null, el('b', null, 'Closed in the last two weeks'), el('span', { class: 'dim' }, ' · ' + T.closed.length)),
676
+ el('ul', { class: 'list', role: 'list' }, T.closed.map((c) => el('li', { class: 'nrow' }, el('span', { class: 'ki' }, c3ic('check')),
677
+ el('div', { class: 'nb' }, el('span', { class: 'tt' }, c.text), el('div', { class: 'chips' }, el('span', { class: 'dim cal3-xs' }, 'marked done ' + dayOf(c.at)))),
678
+ btn('chip2 lk', { 'data-cal3-notes': 'series-undone', title: 'Open it again' }, 'Open again', () => seriesPost(s, '/api/notes/series-done', { key: T.key, text: c.text, kind: 'action', done: false }, 'Open again.')))))) : null;
679
+ return sect('open', 'todo', 'Still open', T.open.length, [rows.length ? el('ul', { class: 'list', role: 'list' }, rows) : fold('info', 'Nothing is carried over.'), closed]);
680
+ }
681
+
682
+ function decidedCard(s, T) {
683
+ return sect('decisions', 'scale', 'Decided so far', T.decisions.length, T.decisions.length ? el('ul', { class: 'list', role: 'list' }, T.decisions.map((d) => el('li', { class: 'nrow cal3-nrow' },
684
+ el('span', { class: 'ki' }, c3ic('scale')),
685
+ btn('cal3-nopen', { title: 'Open the note of ' + dayOf(d.at) }, [el('span', { class: 'tt' }, d.text), el('span', { class: 'chips' }, el('span', { class: 'dim mono cal3-xs' }, dayOf(d.at)), d.why ? el('span', { class: 'dim cal3-xs' }, d.why) : null)], () => choose(s, d.note)),
686
+ el('span')))) : fold('info', 'No decisions yet.'));
687
+ }
688
+
689
+ function topicsCard(T) {
690
+ if (!T.topics.length) return null;
691
+ return sect('topics', 'spark', 'Keeps coming up', null, el('ul', { class: 'list', role: 'list' }, T.topics.map((t) => el('li', { class: 'nrow' }, el('span', { class: 'ki' }, c3ic('spark')),
692
+ el('div', { class: 'nb' }, el('span', { class: 'tt' }, t.name), el('div', { class: 'chips' },
693
+ el('span', { class: 'cal3-dots', title: 'The last ' + t.hits.length + ' meetings, newest on the left' }, t.hits.map((h) => el('i', { class: h ? 'cal3-dot on' : 'cal3-dot' }))),
694
+ el('span', { class: 'dim mono cal3-xs' }, t.count + ' of last ' + t.hits.length), t.kind === 'agenda' ? el('span', { class: 'tag' }, 'agenda') : null)), el('span')))));
695
+ }
696
+
697
+ function questionsCard(s, T) {
698
+ if (!T.questions.length) return null;
699
+ return sect('questions', 'info', 'Still unanswered', T.questions.length, el('ul', { class: 'list', role: 'list' }, T.questions.map((q) => el('li', { class: 'nrow' }, el('span', { class: 'ki' }, c3ic('info')),
700
+ el('div', { class: 'nb' }, el('span', { class: 'tt' }, q.text), el('div', { class: 'chips' }, el('span', { class: 'dim mono cal3-xs' }, 'asked ' + dayOf(q.first)),
701
+ el('span', { class: 'st unt' }, q.seen > 1 ? 'asked in ' + q.seen + ' meetings' : 'not answered yet'))),
702
+ btn('chip2 lk', { 'data-cal3-notes': 'series-answered', title: 'Mark it answered' }, [c3ic('check', 'sm'), 'Answered'], () => seriesPost(s, '/api/notes/series-done', { key: T.key, text: q.text, kind: 'question' }, 'Marked answered.'))))));
703
+ }
704
+
705
+ function everyCard(s, T) {
706
+ const show = s.every ? T.meetings : T.meetings.slice(0, 6);
707
+ const rows = show.map((m) => el('li', { class: 'nrow cal3-nrow', 'data-cal3-meeting': m.id }, el('span', { class: 'ki' }, c3ic(m.private ? 'lock' : 'doc')),
708
+ btn('cal3-nopen', { title: 'Open the note of ' + dayOf(m.at) }, [el('span', { class: 'tt' }, m.private ? 'Private note' : m.overview || m.title),
709
+ el('span', { class: 'chips' }, el('span', { class: 'dim mono cal3-xs' }, [dayOf(m.at), mins(m.ms)].filter(Boolean).join(' · ')),
710
+ m.decisions ? el('span', { class: 'tag' }, '+' + m.decisions + ' decided') : null, m.new_actions ? el('span', { class: 'tag' }, '+' + plural(m.new_actions, 'new step')) : null,
711
+ m.by === 'title' ? el('span', { class: 'tag', title: 'In the series because the title matches' }, 'by title') : m.by === 'pm' ? el('span', { class: 'tag' }, 'added by you') : null)],
712
+ () => choose(s, m.id)), el('span')));
713
+ const more = T.meetings.length - show.length;
714
+ return sect('every', 'notes', 'Every meeting', T.meetings.length, [el('ul', { class: 'list', role: 'list' }, rows),
715
+ more > 0 ? el('div', { class: 'cal3-nadd' }, btn('chip2 lk', { 'data-cal3-notes': 'series-every' }, 'Show ' + plural(more, 'earlier meeting'), () => { s.every = true; draw(s); })) : null]);
716
+ }
717
+
718
+ async function seriesPost(s, url, body, said) {
719
+ const r = await pmPost(url, body);
720
+ if (!r || !r.ok) { toast('Not saved' + ((r && r.error) ? ': ' + r.error : '')); return; }
721
+ toast(said);
722
+ read(s);
723
+ }
724
+ async function copySeries(T) {
725
+ const md = ['# ' + T.title, '', plural(T.count, 'meeting') + ' since ' + dayOf(T.since) + '.', ''];
726
+ if (T.open.length) md.push('## Still open', ...T.open.map((o) => '- ' + o.text + (o.owner ? ' (' + o.owner + ')' : '') + ', in ' + plural(o.seen, 'meeting')), '');
727
+ if (T.decisions.length) md.push('## Decided so far', ...T.decisions.map((d) => '- ' + dayOf(d.at) + ': ' + d.text), '');
728
+ if (T.questions.length) md.push('## Still unanswered', ...T.questions.map((q) => '- ' + q.text), '');
729
+ md.push('## Every meeting', ...T.meetings.map((m) => '- ' + dayOf(m.at) + ': ' + (m.private ? 'Private note' : m.overview || m.title)));
730
+ try { await navigator.clipboard.writeText(md.join('\n')); toast('The series is on the clipboard as Markdown.'); } catch { toast('The clipboard was not available.'); }
731
+ }
732
+
468
733
  /* "Show in the calendar": the Calendar switch back to the grid, on the meeting's day, with the meeting selected */
469
734
  function showMeeting(s, ev) {
470
735
  const ui = s.ui; if (!ui) return;
@@ -521,6 +521,19 @@
521
521
  .c3 .cal3-xs{font-size:var(--fs-1)}
522
522
  .c3 .cal3-prep{align-items:flex-start;padding:6px 0;border-bottom:1px solid var(--border)}
523
523
  .c3 .cal3-prep .ki{flex:none}
524
+ /* Calendar › Notes meeting series (c3-notes-series): a series' recent meetings indented under it, the quiet "more" lines,
525
+ the head's series line, the topic dots (last 10 meetings) and two cards side by side when there is room */
526
+ .c3 .cal3-nrow.cal3-nsub{padding-left:var(--sp-5)}
527
+ .c3 .cal3-nrow.cal3-nsub .tt{font-weight:400;font-size:var(--fs-2)}
528
+ .c3 .nrow.cal3-nmore{display:flex;padding-top:2px;padding-bottom:2px}
529
+ .c3 .cal3-nserl{gap:4px;flex-wrap:wrap}
530
+ .c3 .cal3-nadd{padding-top:var(--sp-2);flex-wrap:wrap}
531
+ .c3 .cal3-nadd .cal3-nsearch{flex:1 1 220px;width:auto}
532
+ .c3 .cal3-dots{display:inline-flex;gap:3px;align-items:center}
533
+ .c3 .cal3-dots .cal3-dot{width:7px;height:7px;border-radius:50%;background:var(--border)}
534
+ .c3 .cal3-dots .cal3-dot.on{background:var(--acc)}
535
+ .c3 .cal3-two{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(300px,100%),1fr));gap:var(--sp-3);align-items:start}
536
+ .c3 .cal3-two:empty{display:none}
524
537
  .c3 .cal3-prepb{flex:1;min-width:0;font-size:var(--fs-3)}
525
538
  .c3 .cal3-agi{display:inline-flex;align-items:center;gap:2px}
526
539
  .c3 .cal3-agi[data-cal3-heard="true"] svg{color:var(--acc)}
@@ -2,7 +2,7 @@
2
2
  'use strict';
3
3
  /* run-tests.js — run the workspace suites side by side.
4
4
  *
5
- * node run-tests.js <group> [--jobs=N] [--shard=I/N] [--changed[=REF]] [--timings-out=FILE] [--list] [--verbose]
5
+ * node run-tests.js <group> [--jobs=N] [--shard=I/N] [--changed[=REF]] [--retry-browser] [--timings-out=FILE] [--list] [--verbose]
6
6
  * node run-tests.js --check
7
7
  * node run-tests.js --merge-timings=DIR
8
8
  *
@@ -28,6 +28,13 @@
28
28
  * the suites that failed and the suites its new commits reach, not the whole group again. A suite it skips is written
29
29
  * to --passed-out too, so a pass carries forward to the run after. An empty or unknown SHA skips nothing.
30
30
  *
31
+ * --retry-browser runs a failed browser suite once more, alone, after the others finish (ci-flaky-retry, PM
32
+ * 2026-10-13). On 2026-10-12 most red Workspace CI runs went green on "Re-run failed jobs" with no change: 27 of 38
33
+ * re-runs. A suite that passes on the retry counts as passed and is printed as FLAKY, with a ::warning:: on CI and a
34
+ * line in the job summary, so the flaky suites stay visible and get fixed. A suite that fails twice fails the run. A
35
+ * suite that timed out is not retried: a second 10-minute wait costs more than the re-run it saves. Unit and module
36
+ * suites are never retried. Workspace CI passes it; a local run does not.
37
+ *
31
38
  * Output: a suite's own output is buffered and printed whole when it finishes, so lines from two
32
39
  * suites never interleave. A passing suite prints its last lines; --verbose prints all of it.
33
40
  *
@@ -307,12 +314,29 @@ async function main() {
307
314
  await Promise.all(Array.from({ length: Math.min(jobs, shared.length) }, worker));
308
315
  for (const f of list.filter((x) => exclusive.has(x))) results.push(await runOne(f, verbose));
309
316
 
317
+ // --retry-browser: one more run of each failed browser suite, alone, so the load of the first pass is gone (see the header)
318
+ const flaky = [];
319
+ if (flag('retry-browser')) {
320
+ const isBrowser = (f) => FILES.panels.includes(f) || FILES.ui.includes(f);
321
+ for (const r of results.filter((x) => !x.ok && isBrowser(x.file) && !/timed out/.test(x.why))) {
322
+ console.log(`\nrun-tests: ${r.file} failed (${r.why}). Running it once more, alone.`);
323
+ const again = await runOne(r.file, verbose);
324
+ if (again.ok) { flaky.push(r); r.ok = true; r.flaky = true; }
325
+ }
326
+ }
310
327
  const failed = results.filter((r) => !r.ok);
311
328
  const slowest = [...results].sort((a, b) => b.secs - a.secs).slice(0, 5).map((r) => `${r.file} ${r.secs}s`).join(', ');
312
329
  const timingsOut = flag('timings-out');
313
330
  writePassed(results);
314
331
  if (timingsOut && timingsOut !== true) fs.writeFileSync(timingsOut, JSON.stringify(Object.fromEntries(results.map((r) => [r.file, r.secs])), null, 2));
315
332
  console.log(`\nrun-tests: ${results.length - failed.length}/${results.length} suites passed in ${((Date.now() - t0) / 1000).toFixed(1)}s wall. Slowest: ${slowest}.`);
333
+ if (flaky.length) {
334
+ console.log(`FLAKY: ${flaky.map((r) => `${r.file} (first run: ${r.why})`).join(', ')}. Each passed when run again alone.`);
335
+ if (process.env.GITHUB_ACTIONS) for (const r of flaky) console.log(`::warning title=Flaky suite::${r.file} failed (${r.why}) and passed when run again alone. Fix it: it cost this run a retry.`);
336
+ if (process.env.GITHUB_STEP_SUMMARY) {
337
+ try { fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `### Flaky suites (passed on the retry)\n\n${flaky.map((r) => `- \`${r.file}\` (first run: ${r.why})`).join('\n')}\n`); } catch { /* the summary is a convenience */ }
338
+ }
339
+ }
316
340
  // exitCode, never process.exit(): stdout to a pipe is asynchronous, and exiting here cut the CI log
317
341
  // off mid-suite, before the summary that names what failed.
318
342
  if (failed.length) { console.log(`FAILED: ${failed.map((r) => `${r.file} (${r.why})`).join(', ')}`); process.exitCode = 1; }
@@ -61,6 +61,7 @@
61
61
  "notes-highlight.test.js",
62
62
  "notes-page.test.js",
63
63
  "notes-conflicts.test.js",
64
+ "notes-series.test.js",
64
65
  "notes-resume.test.js",
65
66
  "proactive-prepare.test.js",
66
67
  "learn.test.js",
@@ -208,6 +209,7 @@
208
209
  "notes-highlight.test.js": 1,
209
210
  "notes-page.test.js": 1,
210
211
  "notes-conflicts.test.js": 1,
212
+ "notes-series.test.js": 1,
211
213
  "notes-resume.test.js": 8,
212
214
  "proactive-prepare.test.js": 9,
213
215
  "learn.test.js": 3,
@@ -0,0 +1,5 @@
1
+ {
2
+ "_comment": "Initiative c3-notes-series: meeting series in Calendar › Notes (one row per series, the series page, a note's place in its series, older notes folded, search in the transcript).",
3
+ "panels": ["pm-c3-notes-series-test.js"],
4
+ "seconds": { "pm-c3-notes-series-test.js": 40 }
5
+ }