create-pmos 0.178.0 → 0.180.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/c3-report-setup-test.js +190 -0
  7. package/workspace/c3-reports-test.js +30 -97
  8. package/workspace/guide/console-calendar.md +17 -2
  9. package/workspace/guide/document-sheet.md +14 -0
  10. package/workspace/guide/index.md +3 -2
  11. package/workspace/guide/reports-dashboards.md +17 -28
  12. package/workspace/guide/reports-page.md +10 -5
  13. package/workspace/guide/reports-set-up.md +56 -0
  14. package/workspace/guide/reports.md +13 -1
  15. package/workspace/guide/settings.md +11 -2
  16. package/workspace/lib/doc-sync.js +28 -6
  17. package/workspace/lib/docs-cli.js +7 -11
  18. package/workspace/lib/four-panel.js +1 -1
  19. package/workspace/lib/headless.js +21 -2
  20. package/workspace/lib/notes-series.js +357 -0
  21. package/workspace/lib/notes.js +53 -4
  22. package/workspace/lib/report-checks.js +103 -0
  23. package/workspace/lib/report-cli.js +89 -4
  24. package/workspace/lib/report-export.js +347 -0
  25. package/workspace/lib/report-finder.js +156 -0
  26. package/workspace/lib/report-needs.js +1 -1
  27. package/workspace/lib/report-setups.js +743 -0
  28. package/workspace/lib/route-modules.js +1 -0
  29. package/workspace/lib/routes-c3-reports.js +35 -17
  30. package/workspace/lib/routes-doc-health.js +15 -1
  31. package/workspace/lib/routes-notes.js +27 -2
  32. package/workspace/lib/routes-report-setups.js +368 -0
  33. package/workspace/lib/schedule-logic.js +5 -2
  34. package/workspace/package-lock.json +2 -2
  35. package/workspace/package.json +1 -1
  36. package/workspace/public/pm/c3-demo-rep2.js +15 -0
  37. package/workspace/public/pm/c3-demo-rs.js +281 -0
  38. package/workspace/public/pm/c3-demo.js +1 -1
  39. package/workspace/public/pm/c3-doc-formats.js +152 -0
  40. package/workspace/public/pm/c3-doc-sheet.js +115 -25
  41. package/workspace/public/pm/c3-notes.js +299 -34
  42. package/workspace/public/pm/c3-quiet.js +2 -2
  43. package/workspace/public/pm/c3-report-print.css +20 -0
  44. package/workspace/public/pm/c3-report-setup.css +62 -0
  45. package/workspace/public/pm/c3-report-setup.js +260 -0
  46. package/workspace/public/pm/c3-report-store.js +208 -0
  47. package/workspace/public/pm/c3-reports.css +4 -0
  48. package/workspace/public/pm/c3-reports.js +47 -181
  49. package/workspace/public/pm/c3-settings.js +4 -3
  50. package/workspace/public/pm/c3-shell.js +15 -31
  51. package/workspace/public/pm/console3.css +13 -0
  52. package/workspace/run-tests.js +25 -1
  53. package/workspace/server.js +66 -5
  54. package/workspace/skills/doc-sync.skill +17 -8
  55. package/workspace/skills/report-find.skill +75 -0
  56. package/workspace/skills/report-setup.skill +67 -0
  57. package/workspace/test-files.json +12 -0
  58. package/workspace/test-lists/c3-notes-series.json +5 -0
  59. package/workspace/test-lists/report-workspace-ui.json +5 -0
@@ -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,
@@ -0,0 +1,103 @@
1
+ 'use strict';
2
+ /* report-checks.js — the three extra health checks of a report document (initiative report-workspace, PRD section 6).
3
+ *
4
+ * reportChecks(text, { listed }) → [{ id, pass, reason }] pure: the text and the section names the definition lists
5
+ * listedOf(definitionText) → the section names a definition's frontmatter lists
6
+ * isReportPath(rel) → true for <state>/reports/<cadence>/<slot>.md (not a definition)
7
+ *
8
+ * These ride beside the six checks of scripts/doc-health.js (routes-doc-health.js adds them for a document under reports/). Each answers
9
+ * { id, pass, reason } like those. The text is read; nothing is changed.
10
+ * numbers-sourced Every line with a figure carries its source: a ticket key, a link, a "[reported]"-style label, or "Source:". A date is not a figure.
11
+ * dates-in-full No "next week", "tomorrow", "yesterday" or a short date such as 22/10 or 22 Oct. A date is written as 22 October 2026.
12
+ * decisions-risk "Decisions" and "Risk watch" are present as headings when the definition lists them. Not listed: pass, and the reason says so.
13
+ */
14
+ // The labels of scripts/releases.js (fact, estimate, assumption, reported), plus the ones a hand-written report uses.
15
+ const LABELS = ['fact', 'estimate', 'assumption', 'reported', 'inferred', 'verified', 'measured', 'forecast', 'source'];
16
+ const MONTHS = 'January|February|March|April|May|June|July|August|September|October|November|December';
17
+ const MON3 = 'Jan|Feb|Mar|Apr|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec';
18
+
19
+ const body = (text) => String(text || '').replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
20
+ const lineOf = (s) => s.trim().replace(/\s+/g, ' ');
21
+ const short = (s) => (s.length > 60 ? s.slice(0, 57) + '...' : s);
22
+
23
+ /** A line with a figure once every full date, ticket key and link address is taken out. */
24
+ function hasFigure(line) {
25
+ const t = line
26
+ .replace(new RegExp(`\\b\\d{1,2} (?:${MONTHS}) \\d{4}\\b`, 'gi'), ' ')
27
+ .replace(/\b\d{4}-\d{2}-\d{2}\b/g, ' ')
28
+ .replace(/\b[A-Z][A-Z0-9]+-\d+\b/g, ' ')
29
+ .replace(/\]\([^)]*\)/g, ']')
30
+ .replace(/\bD\d{1,3}\b/g, ' ');
31
+ return /\d/.test(t);
32
+ }
33
+ const hasSource = (line) => /\b[A-Z][A-Z0-9]+-\d+\b/.test(line) || /\]\(https?:\/\/[^)]+\)/.test(line) || new RegExp(`[\\[(](?:${LABELS.join('|')})[\\])]`, 'i').test(line) || /\bsource:/i.test(line);
34
+
35
+ function numbersSourced(text) {
36
+ const claims = body(text).split('\n').map(lineOf).filter((l) => l && !/^#/.test(l) && !/^\|?[\s:|-]+\|?$/.test(l) && !/^```/.test(l) && hasFigure(l));
37
+ if (!claims.length) return { id: 'numbers-sourced', pass: true, reason: 'no line carries a figure' };
38
+ const bare = claims.filter((l) => !hasSource(l));
39
+ return bare.length
40
+ ? { id: 'numbers-sourced', pass: false, reason: `${bare.length} of ${claims.length} line(s) with a figure name no source: ${bare.slice(0, 3).map((l) => `"${short(l)}"`).join(', ')}` }
41
+ : { id: 'numbers-sourced', pass: true, reason: `${claims.length} line(s) with a figure, all with a source` };
42
+ }
43
+
44
+ const RELATIVE = /\b(?:tomorrow|yesterday|(?:next|last|this|coming|past) (?:week|month|quarter|friday|monday|tuesday|wednesday|thursday|saturday|sunday)|in (?:a|\d+) (?:day|days|week|weeks)|end of (?:the )?(?:week|month))\b/i;
45
+ const SHORT_DATES = [
46
+ /\b\d{1,2}\/\d{1,2}(?:\/\d{2})?\b/, // 22/10, 10/22/26
47
+ new RegExp(`\\b\\d{1,2} (?:${MON3})\\b(?! \\d{4})`), // 22 Oct
48
+ new RegExp(`\\b(?:${MON3}) \\d{1,2}\\b(?!,? \\d{4})`), // Oct 22
49
+ new RegExp(`\\b\\d{1,2} (?:${MONTHS})\\b(?! \\d{4})`), // 22 October
50
+ ];
51
+ function datesInFull(text) {
52
+ const bad = [];
53
+ for (const raw of body(text).split('\n')) {
54
+ const l = lineOf(raw);
55
+ if (!l || /^#/.test(l)) continue;
56
+ const rel = new RegExp(RELATIVE.source, 'gi');
57
+ for (let m = rel.exec(l); m; m = rel.exec(l)) bad.push(m[0].toLowerCase());
58
+ for (const re of SHORT_DATES) { const m = re.exec(l); if (m) bad.push(m[0]); }
59
+ }
60
+ return bad.length
61
+ ? { id: 'dates-in-full', pass: false, reason: `${bad.length} date(s) not written in full: ${[...new Set(bad)].slice(0, 4).map((x) => `"${x}"`).join(', ')}` }
62
+ : { id: 'dates-in-full', pass: true, reason: 'every date is written in full' };
63
+ }
64
+
65
+ /** The section names a definition lists, lower-case. Reads the frontmatter list only. */
66
+ function listedOf(definitionText) {
67
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(String(definitionText || ''));
68
+ if (!fm) return [];
69
+ const out = []; let inList = false;
70
+ for (const raw of fm[1].split(/\r?\n/)) {
71
+ const inline = /^sections:\s*\[(.*)\]\s*$/.exec(raw);
72
+ if (inline) return inline[1].split(',').map((x) => x.replace(/["']/g, '').trim().toLowerCase()).filter(Boolean);
73
+ if (/^sections:\s*$/.test(raw)) { inList = true; continue; }
74
+ const item = /^\s*-\s+(.*)$/.exec(raw);
75
+ if (inList && item) { out.push(item[1].replace(/^["']|["']$/g, '').trim().toLowerCase()); continue; }
76
+ if (inList && /^[A-Za-z_]/.test(raw)) inList = false;
77
+ }
78
+ return out;
79
+ }
80
+
81
+ function decisionsRisk(text, listed) {
82
+ const list = Array.isArray(listed) ? listed : [];
83
+ const wantDecisions = list.some((s) => /decid|decision/.test(s));
84
+ const wantRisk = list.some((s) => /risk/.test(s));
85
+ if (!wantDecisions && !wantRisk) return { id: 'decisions-risk', pass: true, reason: 'the definition lists neither Decisions nor Risk watch' };
86
+ const heads = body(text).split('\n').filter((l) => /^#{1,6}\s/.test(l)).map((l) => l.toLowerCase());
87
+ const missing = [];
88
+ if (wantDecisions && !heads.some((h) => /decid|decision/.test(h))) missing.push('Decisions');
89
+ if (wantRisk && !heads.some((h) => /risk/.test(h))) missing.push('Risk watch');
90
+ return missing.length
91
+ ? { id: 'decisions-risk', pass: false, reason: `the definition lists ${missing.join(' and ')}, and the document has no such heading` }
92
+ : { id: 'decisions-risk', pass: true, reason: 'the listed Decisions and Risk watch sections are present' };
93
+ }
94
+
95
+ function reportChecks(text, { listed } = {}) {
96
+ return [numbersSourced(text), datesInFull(text), decisionsRisk(text, listed)];
97
+ }
98
+
99
+ /** <state>/reports/<cadence>/<slot>.md under pmos/state or planning; the definitions folder is not a report. */
100
+ const isReportPath = (rel) => /(?:^|\/)reports\/(?!definitions\/)[a-z][a-z0-9-]{0,39}\/\d{4}-\d{2}-\d{2}\.md$/.test(String(rel || '').replace(/\\/g, '/'));
101
+ const cadenceOfPath = (rel) => { const m = /reports\/([a-z][a-z0-9-]{0,39})\/\d{4}-\d{2}-\d{2}\.md$/.exec(String(rel || '').replace(/\\/g, '/')); return m ? m[1] : null; };
102
+
103
+ module.exports = { reportChecks, numbersSourced, datesInFull, decisionsRisk, listedOf, isReportPath, cadenceOfPath };