create-pmos 0.240.0 → 0.242.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 (68) 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-structure-real-test.js +211 -0
  7. package/workspace/c3-report-structure-test.js +382 -0
  8. package/workspace/guide/console-calendar.md +14 -0
  9. package/workspace/guide/index.md +2 -0
  10. package/workspace/guide/notes.md +22 -1
  11. package/workspace/guide/report-design.md +4 -0
  12. package/workspace/guide/report-structure.md +91 -0
  13. package/workspace/guide/reports-dashboards.md +1 -1
  14. package/workspace/guide/reports-set-up.md +2 -0
  15. package/workspace/lib/charts-store.js +124 -0
  16. package/workspace/lib/headless.js +17 -3
  17. package/workspace/lib/meeting-material.js +151 -0
  18. package/workspace/lib/meeting-replay.js +170 -0
  19. package/workspace/lib/needs-providers.js +1 -0
  20. package/workspace/lib/notes-screen.js +128 -8
  21. package/workspace/lib/report-structure-needs.js +64 -0
  22. package/workspace/lib/report-structure.js +515 -0
  23. package/workspace/lib/route-modules.js +3 -0
  24. package/workspace/lib/routes-meeting-focus.js +5 -3
  25. package/workspace/lib/routes-meeting-material.js +143 -0
  26. package/workspace/lib/routes-meeting-replay.js +75 -0
  27. package/workspace/lib/routes-notes.js +37 -4
  28. package/workspace/lib/routes-report-setups.js +11 -6
  29. package/workspace/lib/routes-report-structure.js +619 -0
  30. package/workspace/package-lock.json +2 -2
  31. package/workspace/package.json +1 -1
  32. package/workspace/public/pm/c3-calendar.js +15 -3
  33. package/workspace/public/pm/c3-demo-material.js +58 -0
  34. package/workspace/public/pm/c3-demo-rd.js +4 -0
  35. package/workspace/public/pm/c3-demo.js +5 -1
  36. package/workspace/public/pm/c3-design-studio.js +7 -3
  37. package/workspace/public/pm/c3-focus.js +83 -16
  38. package/workspace/public/pm/c3-listen.css +10 -0
  39. package/workspace/public/pm/c3-material.css +22 -0
  40. package/workspace/public/pm/c3-material.js +311 -0
  41. package/workspace/public/pm/c3-notes.js +12 -4
  42. package/workspace/public/pm/c3-replay.css +127 -0
  43. package/workspace/public/pm/c3-replay.js +413 -0
  44. package/workspace/public/pm/c3-report-setup.js +3 -0
  45. package/workspace/public/pm/c3-report-structure.css +40 -0
  46. package/workspace/public/pm/c3-report-structure.js +455 -0
  47. package/workspace/public/pm/c3-reports.css +2 -0
  48. package/workspace/public/pm/c3-reports.js +22 -3
  49. package/workspace/public/pm/c3-shell.js +2 -0
  50. package/workspace/public/pm/dest-notes.js +4 -2
  51. package/workspace/public/pm/listen-sheet.js +94 -0
  52. package/workspace/public/pm/listen-sources.js +82 -0
  53. package/workspace/public/pm/notes-aside.js +62 -0
  54. package/workspace/public/pm/notes-screen-capture.js +23 -10
  55. package/workspace/public/pm/notes-screen-view.js +18 -5
  56. package/workspace/public/pm/notes-shots.js +55 -0
  57. package/workspace/public/pm/shell-recording.js +10 -2
  58. package/workspace/public/pm/view-notes.js +48 -8
  59. package/workspace/server.js +38 -9
  60. package/workspace/skills/report-source-check.skill +45 -0
  61. package/workspace/skills/report-structure-dry.skill +51 -0
  62. package/workspace/skills/report-structure.skill +61 -0
  63. package/workspace/skills/report-template.skill +10 -0
  64. package/workspace/test-lists/meeting-material.json +6 -0
  65. package/workspace/test-lists/meeting-replay.json +6 -0
  66. package/workspace/test-lists/mm-capture.json +15 -0
  67. package/workspace/test-lists/report-structure-backend.json +5 -0
  68. package/workspace/test-lists/report-structure-ui.json +11 -0
@@ -15,7 +15,7 @@ Each tab has one job. A line under the tabs repeats it: **Live** goes to Dashboa
15
15
 
16
16
  - **Dashboards.** The charts of one scope, drawn from the repository's records. A dashboard is live: it changes when your work changes. The tab shows how many dashboards there are.
17
17
  - **Reports.** The documents you send, with their set-ups. See [Set up a report by asking](reports-set-up.md). The tab shows how many drafts wait for you.
18
- - **Charts.** Every chart the workspace can draw, with the question it answers, the dashboards that use it and the reports that use it. The tab shows how many charts there are.
18
+ - **Charts.** Every chart the workspace can draw, with the question it answers, the dashboards that use it and the reports that use it. The tab shows how many charts there are. A chart that the Structure step of a report saved is listed too, tagged **Data chart**, with its source and the dashboards and reports that use it. See [Shape what a report says](report-structure.md).
19
19
 
20
20
  The old tab names still work. A link to Library opens Charts. A link to Drafts opens Reports.
21
21
 
@@ -43,6 +43,8 @@ The most likely finding also shows on the Briefing as **Report found**. Press **
43
43
  - **Draft it now** shows in place of Open when the report has no draft. PMOS writes the draft of the next cycle and opens it.
44
44
  - **All cycles** lists every cycle of the report.
45
45
  - **Pause** stops the drafts. **Resume** starts them again.
46
+ - **Shape the report** opens the Structure step of the report studio. There you decide what the report answers, where each number comes from and how it is worked out. See [Shape what a report says](report-structure.md). **Shape it with PMOS** on an answer sets the report up and opens the same step.
47
+ - **Shape the report** opens the Structure step of the report studio. There you decide what the report answers, where each number comes from and how it is worked out. See [Shape what a report says](report-structure.md). **Shape it with PMOS** on an answer sets the report up and opens the same step.
46
48
  - **Design how it looks** opens the design studio. There you design how the report looks, as a deck or a document. See [Design how a report looks](report-design.md).
47
49
 
48
50
  **Documents** has one row for each cycle: Draft or Issued, the output, the copy state and who made it. Filter by state or by source. Press **Open** to read a document in the [document sheet](document-sheet.md).
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+ /* charts-store.js — the chart store: the charts a report structure defines once (initiative report-structure, contract section 6).
3
+ *
4
+ * paths(root) → { file, rel } <state>/charts.jsonl
5
+ * append(root, line, { actor, now }) → the line written. THE SINGLE WRITER of charts.jsonl. `at` and `actor` are set here.
6
+ * readEvents(root) · fold(events) → Map id → chart a torn line is skipped
7
+ * get(root, id) → chart | null
8
+ * addChart(root, chart, { actor }) → { ok, chart } | refusal slugs the id, makes it unique, writes one 'add' line
9
+ * list(root, { runtimeDir }) → [chart + { users: [{ kind: 'report' | 'dashboard', id, name }] }]
10
+ * slugOf(text) → a chart id slug
11
+ *
12
+ * THE LINES. Every line is { ev, id, chart?, at, actor }: 'add' (a whole chart), 'change' (the fields that changed), 'remove'.
13
+ * A CHART is { id, name, question, source: { kind, name, via?, ref? }, from, how, compare, flag, query, why, created_by }.
14
+ * USERS are folded from the ACCEPTED plans (a report whose saved structure names the chart in charts[].chart) and from the Reports dashboards'
15
+ * layouts (<runtime>/report-dashboards/layouts.json: a key whose list holds the chart id). A chart from the store is REFERENCED by a plan, never copied.
16
+ *
17
+ * NOTHING HERE STARTS A JOB OR SENDS ANYTHING.
18
+ */
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const RD = require('./report-draft.js');
22
+
23
+ const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
24
+ const EVENTS = Object.freeze(['add', 'change', 'remove']);
25
+ const FIELDS = Object.freeze(['name', 'question', 'from', 'how', 'compare', 'flag', 'query', 'why']);
26
+ const PMOS_ACTOR = Object.freeze({ by: 'pmos', via: 'job' });
27
+
28
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
29
+ const str = (v) => (typeof v === 'string' ? v : '');
30
+ const refuse = (status, error) => ({ ok: false, status, error });
31
+
32
+ function stateRel(root) { try { return RD.stateRel(root); } catch { return 'planning'; } }
33
+ function paths(root) {
34
+ const abs = path.resolve(String(root));
35
+ const rel = `${stateRel(abs)}/charts.jsonl`;
36
+ return { abs, rel, file: path.join(abs, rel) };
37
+ }
38
+ function slugOf(text) {
39
+ const s = String(text == null ? '' : text).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').replace(/^[^a-z]+/, '').slice(0, 36).replace(/-+$/, '');
40
+ return s || 'chart';
41
+ }
42
+
43
+ function append(root, line, { actor, now } = {}) {
44
+ if (!isObj(line) || !EVENTS.includes(line.ev) || !ID_RE.test(str(line.id))) throw new Error(`charts-store: bad line ${JSON.stringify(line && line.ev)}`);
45
+ const p = paths(root);
46
+ const out = { ...line, at: new Date(now == null ? Date.now() : now).toISOString(), actor: isObj(actor) ? { by: str(actor.by), via: str(actor.via) } : { ...PMOS_ACTOR } };
47
+ fs.mkdirSync(path.dirname(p.file), { recursive: true });
48
+ fs.appendFileSync(p.file, JSON.stringify(out) + '\n');
49
+ return out;
50
+ }
51
+ function readEvents(root) {
52
+ let text = '';
53
+ try { text = fs.readFileSync(paths(root).file, 'utf8'); } catch { return []; }
54
+ const out = [];
55
+ for (const raw of text.split('\n')) {
56
+ if (!raw.trim()) continue;
57
+ try { const l = JSON.parse(raw); if (isObj(l) && EVENTS.includes(l.ev) && ID_RE.test(str(l.id))) out.push(l); } catch { /* a torn line is skipped */ }
58
+ }
59
+ return out;
60
+ }
61
+ function fold(events) {
62
+ const m = new Map();
63
+ for (const l of events) {
64
+ if (l.ev === 'add' && isObj(l.chart)) m.set(l.id, { ...l.chart, id: l.id, added_at: l.at });
65
+ else if (l.ev === 'change' && m.has(l.id) && isObj(l.chart)) m.set(l.id, { ...m.get(l.id), ...l.chart, id: l.id, changed_at: l.at });
66
+ else if (l.ev === 'remove') m.delete(l.id);
67
+ }
68
+ return m;
69
+ }
70
+ const stateOf = (root) => fold(readEvents(root));
71
+ function get(root, id) { return stateOf(root).get(String(id)) || null; }
72
+
73
+ /** Write one new chart. The id is slugged from chart.id (else the name) and made unique with -2, -3, …. → { ok, chart } */
74
+ function addChart(root, chart, { actor, now } = {}) {
75
+ if (!isObj(chart)) return refuse(400, 'chart must be an object');
76
+ const have = stateOf(root);
77
+ const base = slugOf(chart.id || chart.name);
78
+ let id = base; let k = 2;
79
+ while (have.has(id)) { id = `${base.slice(0, 36)}-${k++}`; }
80
+ const clean = { id, name: str(chart.name), question: str(chart.question), source: isObj(chart.source) ? chart.source : { kind: 'pmos-level', name: '' },
81
+ from: str(chart.from), how: str(chart.how), compare: str(chart.compare), flag: str(chart.flag), query: str(chart.query), why: str(chart.why), created_by: str(chart.created_by) };
82
+ append(root, { ev: 'add', id, chart: clean }, { actor, now });
83
+ return { ok: true, chart: clean };
84
+ }
85
+
86
+ /** The reports whose ACCEPTED structure uses each chart id: Map chart id → [{ kind: 'report', id, name }]. */
87
+ function reportUsers(root, nameOf) {
88
+ const users = new Map();
89
+ const base = path.join(paths(root).abs, stateRel(root), 'reports');
90
+ let cadences = [];
91
+ try { cadences = fs.readdirSync(base); } catch { return users; }
92
+ const RS = require('./report-structure.js');
93
+ for (const cadence of cadences) {
94
+ if (!/^[a-z][a-z0-9-]{0,39}$/.test(cadence) || cadence === 'definitions') continue;
95
+ let st; try { st = RS.stateOf(root, cadence); } catch { continue; }
96
+ if (!st.saved) continue;
97
+ const plan = RS.readVersion(root, cadence, st.saved.n);
98
+ if (!plan) continue;
99
+ for (const c of plan.charts || []) {
100
+ if (!c.chart) continue;
101
+ if (!users.has(c.chart)) users.set(c.chart, []);
102
+ const list = users.get(c.chart);
103
+ if (!list.some((u) => u.id === cadence)) list.push({ kind: 'report', id: cadence, name: (nameOf && nameOf(cadence)) || cadence });
104
+ }
105
+ }
106
+ return users;
107
+ }
108
+ /** The dashboard layout keys whose list names the chart id. The layout reader keeps only the built-in ids, so the file is read as it is. */
109
+ function dashboardUsers(runtimeDir, id) {
110
+ if (!runtimeDir) return [];
111
+ let raw; try { raw = JSON.parse(fs.readFileSync(path.join(runtimeDir, 'report-dashboards', 'layouts.json'), 'utf8')); } catch { return []; }
112
+ const src = isObj(raw) && isObj(raw.layouts) ? raw.layouts : raw;
113
+ if (!isObj(src)) return [];
114
+ return Object.entries(src).filter(([, v]) => Array.isArray(v) && v.includes(id)).map(([k]) => ({ kind: 'dashboard', id: k, name: k }));
115
+ }
116
+
117
+ function list(root, { runtimeDir, nameOf } = {}) {
118
+ const users = reportUsers(root, nameOf);
119
+ return [...stateOf(root).values()].sort((a, b) => (a.added_at < b.added_at ? -1 : a.added_at > b.added_at ? 1 : 0))
120
+ .map((c) => ({ ...c, users: [...(users.get(c.id) || []), ...dashboardUsers(runtimeDir, c.id)] }));
121
+ }
122
+ function one(root, id, opts) { return list(root, opts).find((c) => c.id === id) || null; }
123
+
124
+ module.exports = { ID_RE, EVENTS, FIELDS, paths, append, readEvents, fold, stateOf, get, addChart, list, one, slugOf };
@@ -3,7 +3,7 @@
3
3
  * (initiative workspace-headless-jobs; PM decision 2026-10-11: keep Jev, no direct Claude API, background
4
4
  * jobs that need no human run headless).
5
5
  *
6
- * kindFor({ agent, origin, skill, env }) → 'notes' | 'skill' | 'ask' | 'drive' | 'scan-web' | 'scan-repo' | 'scan-research-web' | 'scan-research-repo' | 'report-setup' | 'report-find' | 'core-doc' | 'report-design' | 'report-sample-fetch' | null (null: open a pane, as before)
6
+ * kindFor({ agent, origin, skill, env }) → 'notes' | 'skill' | 'ask' | 'drive' | 'scan-web' | 'scan-repo' | 'scan-research-web' | 'scan-research-repo' | 'report-setup' | 'report-find' | 'core-doc' | 'report-design' | 'report-sample-fetch' | 'report-structure' | 'report-structure-dry' | null (null: open a pane, as before)
7
7
  * argsFor(kind, { modelArgs, addDirs, writePaths }) → the argv after `claude` (writePaths: the one render file a report-design job may write)
8
8
  * parseResult(stdout) → { ok, text, isError, turns, durationMs, costUsd, denied[] }
9
9
  * streamLines(line) → { log: [lines], result: object|null } one stream-json event, for the live log
@@ -48,6 +48,11 @@
48
48
  * (the cycle document, the samples, the earlier versions and the brief) and writes ONE file: the render at the pending path the server gives it, named in
49
49
  * its tool list as `Write(//<path>)` and `Edit(//<path>)`. It has no web tool, no Bash and no connector tool, and nothing that sends. The server sanitizes
50
50
  * the render before anything shows it. It never runs in a pane.
51
+ * The `report-structure` and `report-structure-dry` kinds are the report structure step (initiative report-structure): origins `report-structure:<id>` and
52
+ * `report-structure-dry:<id>`. A turn proposes or refines the plan of a report (sources, charts, outline); a dry run tries a saved version on the last complete
53
+ * period. Each reads the repository and, where the PM's own Claude Code settings allow it, reads through connectors (none is named in the list, as for
54
+ * `report-sample-fetch`), and each writes ONE file: structure/pending/<job>.json, named in its tool list like the design render. No Bash, no web fetch, no
55
+ * connector write tool, nothing that sends. The server validates the file before it lands. Neither kind ever runs in a pane.
51
56
  */
52
57
 
53
58
  // The skills whose every start runs headless: they read, file a result for PMOS, and send nothing out.
@@ -82,7 +87,10 @@ const CORE_DOC_ORIGINS = Object.freeze(['core-doc:']);
82
87
  const REPORT_DESIGN_ORIGINS = Object.freeze(['report-design:']);
83
88
  // report-design-studio: reading ONE linked Google or Confluence file through the PM's own connectors (origin `report-sample-fetch:<cadence>:<sampleId>`).
84
89
  const REPORT_SAMPLE_FETCH_ORIGINS = Object.freeze(['report-sample-fetch:']);
85
- const WRITE_PATH_KINDS = Object.freeze(['report-design', 'report-sample-fetch']); // kinds whose one writable file the caller names (argsFor writePaths)
90
+ // report-structure: a structure turn and a dry run on real data (origins `report-structure:<id>`, `report-structure-dry:<id>`).
91
+ const REPORT_STRUCTURE_ORIGINS = Object.freeze(['report-structure:']);
92
+ const REPORT_STRUCTURE_DRY_ORIGINS = Object.freeze(['report-structure-dry:']);
93
+ const WRITE_PATH_KINDS = Object.freeze(['report-design', 'report-sample-fetch', 'report-structure', 'report-structure-dry']); // kinds whose one writable file the caller names (argsFor writePaths)
86
94
 
87
95
  // What each kind may use. Read-only search is always allowed. The notes passes write their JSON (and rename the .tmp
88
96
  // file) in their notes folder, which --add-dir opens to them; the skills also run `pmos` verbs and repository scripts.
@@ -114,6 +122,9 @@ const TOOLS = Object.freeze({
114
122
  'report-design': Object.freeze(['Read', 'Glob', 'Grep']),
115
123
  // The link fetch run: Read, and its one write (the text copy of the linked file) is added per run by argsFor. No Bash, no web, no connector tool named.
116
124
  'report-sample-fetch': Object.freeze(['Read']),
125
+ // The structure runs: read and search; a connector read runs only where the PM's own settings allow it. Their one write, the JSON file, is added per run by argsFor.
126
+ 'report-structure': Object.freeze(['Read', 'Glob', 'Grep']),
127
+ 'report-structure-dry': Object.freeze(['Read', 'Glob', 'Grep']),
117
128
  skill: Object.freeze(['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash(pmos *)', 'Bash(node *)',
118
129
  'Bash(git log *)', 'Bash(git status *)', 'Bash(git diff *)', 'Bash(ls *)', 'Bash(date *)']),
119
130
  });
@@ -139,6 +150,9 @@ function kindFor({ agent, origin, skill, env } = {}) {
139
150
  if (CORE_DOC_ORIGINS.some((p) => o.startsWith(p))) return 'core-doc';
140
151
  if (REPORT_DESIGN_ORIGINS.some((p) => o.startsWith(p))) return 'report-design';
141
152
  if (REPORT_SAMPLE_FETCH_ORIGINS.some((p) => o.startsWith(p))) return 'report-sample-fetch';
153
+ // 'report-structure-dry:' is not a prefix of 'report-structure:' (the colon differs), so the two are told apart by their prefix alone
154
+ if (REPORT_STRUCTURE_DRY_ORIGINS.some((p) => o.startsWith(p))) return 'report-structure-dry';
155
+ if (REPORT_STRUCTURE_ORIGINS.some((p) => o.startsWith(p))) return 'report-structure';
142
156
  if (skill && HEADLESS_SKILLS.includes(String(skill))) return 'skill';
143
157
  return null;
144
158
  }
@@ -265,4 +279,4 @@ function logLines(r, exitCode) {
265
279
  return out;
266
280
  }
267
281
 
268
- module.exports = { HEADLESS_SKILLS, HEADLESS_ORIGINS, ASK_ORIGINS, DRIVE_ORIGINS, SCAN_WEB_ORIGINS, SCAN_REPO_ORIGINS, SCAN_RESEARCH_WEB_ORIGINS, SCAN_RESEARCH_REPO_ORIGINS, WEB_KINDS, REPORT_SETUP_ORIGINS, REPORT_FIND_ORIGINS, CORE_DOC_ORIGINS, REPORT_DESIGN_ORIGINS, REPORT_SAMPLE_FETCH_ORIGINS, TOOLS, DEFAULT_TIMEOUT_MS, kindFor, argsFor, timeoutMs, parseResult, streamLines, outcomeOf, logLines, switchedOff };
282
+ module.exports = { HEADLESS_SKILLS, HEADLESS_ORIGINS, ASK_ORIGINS, DRIVE_ORIGINS, SCAN_WEB_ORIGINS, SCAN_REPO_ORIGINS, SCAN_RESEARCH_WEB_ORIGINS, SCAN_RESEARCH_REPO_ORIGINS, WEB_KINDS, REPORT_SETUP_ORIGINS, REPORT_FIND_ORIGINS, CORE_DOC_ORIGINS, REPORT_DESIGN_ORIGINS, REPORT_SAMPLE_FETCH_ORIGINS, REPORT_STRUCTURE_ORIGINS, REPORT_STRUCTURE_DRY_ORIGINS, TOOLS, DEFAULT_TIMEOUT_MS, kindFor, argsFor, timeoutMs, parseResult, streamLines, outcomeOf, logLines, switchedOff };
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+ /* lib/meeting-material.js — the material the PM linked to a meeting (initiative meeting-material-replay, part 1).
3
+ *
4
+ * keyOf(event) → 's:<series>' or 't:<normalized title>' (calendar-memory.js seriesKey), as lib/calendar-links.js
5
+ * append(runtimeDir, body, by) → { ok, items } | { ok: false, error } op 'add' (the default) or 'remove'
6
+ * forMeeting(runtimeDir, event) → [item] the series items of the meeting and the items for this one meeting, in the order they were added
7
+ * read(runtimeDir) → Map<identity, row> the rows that hold now (a removed item is gone)
8
+ * check(body) → null | the reason the body is refused
9
+ * classifyUrl(url) → { kind: slides|gdoc|conf|link, url } | null
10
+ *
11
+ * The calendar is read-only (PMOS never edits an invite), so the material lives beside it: <runtime>/meeting-material.jsonl, append-only.
12
+ * A row: { at, by, key, series, event?, scope: 'meeting'|'series', item: { kind, ref?, title, url?, ws? }, topic?, present, removed? }.
13
+ * An item is named by its key, its event ("this meeting"; empty for the series) and its kind and ref or url. The last row of a name wins,
14
+ * and a row with removed: true takes the item off. Nothing is ever rewritten.
15
+ *
16
+ * Kinds. PMOS items name a file of the repository by ref: pmos-doc (a document), pmos-deck (a report cycle that is a slide deck), report
17
+ * (any other report cycle). Links name an address: slides, gdoc, conf, link. The server decides a link's kind from its address. */
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const calMem = require('./calendar-memory.js');
21
+
22
+ const FILE = 'meeting-material.jsonl';
23
+ const PMOS_KINDS = ['pmos-deck', 'pmos-doc', 'report'];
24
+ const LINK_KINDS = ['slides', 'gdoc', 'conf', 'link'];
25
+ const KINDS = [...PMOS_KINDS, ...LINK_KINDS];
26
+ const SCOPES = ['meeting', 'series'];
27
+ const OPS = ['add', 'remove'];
28
+ const MAX_ITEMS = 60;
29
+ const str = (v) => (typeof v === 'string' ? v.trim() : '');
30
+
31
+ const keyOf = (e) => calMem.seriesKey({ series: e && e.series ? String(e.series) : null, title: e && e.title });
32
+ const fileOf = (runtimeDir) => path.join(runtimeDir, FILE);
33
+
34
+ /** What an address is. Only https addresses are kept; the server and the page agree on the kind. */
35
+ function classifyUrl(raw) {
36
+ const s = str(raw);
37
+ if (!s || s.length > 1000 || /\s/.test(s)) return null;
38
+ let u; try { u = new URL(/^[a-z][a-z0-9+.-]*:/i.test(s) ? s : 'https://' + s); } catch { return null; }
39
+ if (u.protocol !== 'https:' || !u.hostname.includes('.')) return null;
40
+ if (u.hostname === 'docs.google.com' && /^\/presentation\/d\//.test(u.pathname)) return { kind: 'slides', url: u.href };
41
+ if (u.hostname === 'docs.google.com' && /^\/document\/d\//.test(u.pathname)) return { kind: 'gdoc', url: u.href };
42
+ if (/\.atlassian\.net$/.test(u.hostname) && u.pathname.startsWith('/wiki')) return { kind: 'conf', url: u.href };
43
+ return { kind: 'link', url: u.href };
44
+ }
45
+
46
+ /** A repository-relative file path: no leading slash, no "..", no backslash, no control character. */
47
+ const refOk = (r) => typeof r === 'string' && r.length > 0 && r.length <= 300 && !r.startsWith('/') && !r.includes('\\') && !/(^|\/)\.\.(\/|$)/.test(r) && !/[\u0000-\u001f]/.test(r);
48
+
49
+ /** The name of an item inside one meeting or series. */
50
+ const idOf = (item) => item.kind + '|' + (PMOS_KINDS.includes(item.kind) ? item.ref : item.url);
51
+ const identity = (row) => [row.key, row.scope === 'series' ? '' : row.event || '', idOf(row.item)].join('\u0000');
52
+
53
+ /** The reason a POST body is refused, or null.
54
+ * body: { op?: 'add'|'remove', event: { id?, title, series? }, scope: 'meeting'|'series', item: { kind, ref | url, title?, ws? }, topic?, present? } */
55
+ function check(body) {
56
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return 'the body must be a JSON object';
57
+ if (body.op != null && !OPS.includes(body.op)) return 'op is one of ' + OPS.join(', ');
58
+ const e = body.event;
59
+ if (!e || typeof e !== 'object' || !str(e.title) || str(e.title).length > 300) return 'event.title is required (one line, at most 300 characters)';
60
+ if (e.series != null && (typeof e.series !== 'string' || e.series.length > 300)) return 'event.series must be text';
61
+ if (e.id != null && (typeof e.id !== 'string' || e.id.length > 120)) return 'event.id must be text';
62
+ const scope = body.scope == null ? 'meeting' : body.scope;
63
+ if (!SCOPES.includes(scope)) return 'scope is one of ' + SCOPES.join(', ');
64
+ if (scope === 'meeting' && !str(e.id)) return 'a link for one meeting needs event.id';
65
+ const it = body.item;
66
+ if (!it || typeof it !== 'object') return 'item is required';
67
+ if (PMOS_KINDS.includes(it.kind)) { if (!refOk(it.ref)) return 'a PMOS item needs ref, the path of the file in the repository'; }
68
+ else if (it.kind == null || LINK_KINDS.includes(it.kind)) { if (!classifyUrl(it.url)) return 'a link must be an https address'; }
69
+ else return 'item.kind is one of ' + KINDS.join(', ');
70
+ if (it.title != null && (typeof it.title !== 'string' || it.title.length > 300 || /[\r\n]/.test(it.title))) return 'item.title is one line of at most 300 characters';
71
+ if (it.ws != null && (typeof it.ws !== 'string' || it.ws.length > 120)) return 'item.ws must be text';
72
+ if (body.topic != null && (typeof body.topic !== 'string' || body.topic.length > 160 || /[\r\n]/.test(body.topic))) return 'topic is one line of at most 160 characters';
73
+ if (body.present != null && typeof body.present !== 'boolean') return 'present must be true or false';
74
+ return null;
75
+ }
76
+
77
+ /** The item as it is kept: the kind a link has, its address in full, a title. */
78
+ function cleanItem(it) {
79
+ if (PMOS_KINDS.includes(it.kind)) return { kind: it.kind, ref: it.ref, title: str(it.title) || it.ref, ...(str(it.ws) ? { ws: str(it.ws) } : {}) };
80
+ const c = classifyUrl(it.url);
81
+ return { kind: c.kind, url: c.url, title: str(it.title) || c.url };
82
+ }
83
+
84
+ function lines(runtimeDir) {
85
+ if (!runtimeDir) return [];
86
+ let text = '';
87
+ try { text = fs.readFileSync(fileOf(runtimeDir), 'utf8'); } catch { return []; }
88
+ const out = [];
89
+ for (const l of text.split('\n')) {
90
+ if (!l.trim()) continue;
91
+ let x; try { x = JSON.parse(l); } catch { continue; }
92
+ if (!x || typeof x.key !== 'string' || !SCOPES.includes(x.scope) || !x.item || !KINDS.includes(x.item.kind)) continue;
93
+ if (PMOS_KINDS.includes(x.item.kind) ? !refOk(x.item.ref) : !x.item.url) continue;
94
+ out.push(x);
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /** The rows that hold now, in the order their items were first added (a changed item keeps its place). */
100
+ function read(runtimeDir) {
101
+ const out = new Map();
102
+ for (const row of lines(runtimeDir)) {
103
+ const id = identity(row);
104
+ if (row.removed) { out.delete(id); continue; }
105
+ out.set(id, out.has(id) ? { ...row, firstAt: out.get(id).firstAt } : { ...row, firstAt: row.at || null });
106
+ }
107
+ return out;
108
+ }
109
+
110
+ const view = (row) => ({
111
+ id: crypto16(identity(row)), kind: row.item.kind, ref: row.item.ref || null, url: row.item.url || null, title: row.item.title || row.item.ref || row.item.url,
112
+ ws: row.item.ws || null, topic: row.topic || null, present: row.present === true, scope: row.scope, from: 'you', by: row.by || null, at: row.at || null,
113
+ });
114
+ /** A short stable id for a row, so the page can name an item without sending its path back and forth. */
115
+ function crypto16(s) { return require('crypto').createHash('sha1').update(s).digest('hex').slice(0, 12); }
116
+
117
+ /** The material of one meeting: its series items and its own, in the order they were added. event: { id?, title, series? } */
118
+ function forMeeting(runtimeDir, event) {
119
+ if (!event || !str(event.title)) return [];
120
+ const key = keyOf(event);
121
+ const id = str(event.id);
122
+ const mine = [];
123
+ for (const row of read(runtimeDir).values()) {
124
+ if (row.key !== key) continue;
125
+ if (row.scope === 'meeting' && !(id && row.event === id)) continue;
126
+ mine.push(row);
127
+ }
128
+ return mine.sort((a, b) => String(a.firstAt).localeCompare(String(b.firstAt))).map(view);
129
+ }
130
+
131
+ function append(runtimeDir, body, by) {
132
+ const bad = check(body);
133
+ if (bad) return { ok: false, error: bad };
134
+ if (!runtimeDir) return { ok: false, error: 'this server has no runtime folder, so the material cannot be saved' };
135
+ const op = body.op || 'add';
136
+ const scope = body.scope || 'meeting';
137
+ const item = cleanItem(body.item);
138
+ const line = { at: new Date().toISOString(), by: by || null, key: keyOf(body.event), series: body.event.series ? String(body.event.series) : null, event_title: str(body.event.title), scope, item };
139
+ if (scope === 'meeting') line.event = str(body.event.id);
140
+ if (op === 'remove') line.removed = true;
141
+ else {
142
+ if (str(body.topic)) line.topic = str(body.topic);
143
+ line.present = body.present === true && ['pmos-deck', 'slides', 'report'].includes(item.kind);
144
+ if (forMeeting(runtimeDir, body.event).length >= MAX_ITEMS && !read(runtimeDir).has(identity(line))) return { ok: false, error: 'a meeting holds at most ' + MAX_ITEMS + ' items' };
145
+ }
146
+ fs.mkdirSync(runtimeDir, { recursive: true });
147
+ fs.appendFileSync(fileOf(runtimeDir), JSON.stringify(line) + '\n');
148
+ return { ok: true, items: forMeeting(runtimeDir, body.event) };
149
+ }
150
+
151
+ module.exports = { keyOf, read, forMeeting, append, check, classifyUrl, KINDS, PMOS_KINDS, LINK_KINDS, FILE };
@@ -0,0 +1,170 @@
1
+ 'use strict';
2
+ /* meeting-replay.js — the pure join behind the meeting replay page (initiative meeting-material-replay, part 2).
3
+ *
4
+ * build({ note, event?, stateLines?, frames?, audio?, topics?, now? }) → the replay
5
+ *
6
+ * The replay is Focus played back, so it joins three records PMOS already keeps and invents nothing:
7
+ * the note transcript lines (with who said them), decisions, next steps and questions, the audio's length
8
+ * the Focus file <runtime>/focus/<note or event id>.jsonl, folded by lib/meeting-focus.js: topics, the stretches of talk, PMOS's notes
9
+ * the shots lib/notes-screen.js frames; a frame with `aside` truthy was set aside (it looked like another app) and is NEVER in the replay
10
+ *
11
+ * Every time is MEETING time in ms: 0 is where the recording starts, the same clock the audio, the transcript and the shots use
12
+ * (the audio's currentTime * 1000). `start_ms` is the wall clock (epoch ms) of meeting time 0, so the page can show 11:18.
13
+ *
14
+ * A KEY POINT'S TIME is the time of the transcript line it was caught from: the item's own `t` (the final notes and the minute
15
+ * bullets keep it), else the line its quote (or, failing that, its text) is found in. When none is known `t` is null and the page
16
+ * shows no play button. Nothing here reads a file or starts a process; lib/routes-meeting-replay.js reads and calls build().
17
+ *
18
+ * Output:
19
+ * { ok, note: { id, title, state, initiative, ws }, meeting: { title, at, end, attendees },
20
+ * audio: { url, ms } | null, start_ms, length_ms,
21
+ * lines: [{ i, t, who, text, source, key: { k, id } | null }] by t
22
+ * keys: [{ id, k: 'decision'|'action'|'question', text, t, owner, due, by, state: 'draft'|'owner'|'open'|'answered', warn }]
23
+ * topics: [{ id, text, src, state, minutes, n, first_t, stretches, parked, by }] visited ones first, in the order they came up
24
+ * runs: [{ topic, n, from_ms, to_ms }]
25
+ * notes: [{ id, t, cat, body, heard, why, src }] PMOS's notes with the time they were made
26
+ * shots: [{ t, w, h, why?, text? }] kept shots only
27
+ * aside_count }
28
+ */
29
+ const N = require('./notes.js');
30
+ const F = require('./meeting-focus.js');
31
+
32
+ const SHOT_TEXT_MAX = 400;
33
+ const NEAR_LINE_MS = 3000; // a key point whose time is not exactly a line's belongs to the line this close
34
+ const ANSWER_OVERLAP = 0.6;
35
+
36
+ const str = (v, n) => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim().slice(0, n) : '');
37
+ const finite = (v) => typeof v === 'number' && Number.isFinite(v);
38
+ const words = (t) => String(t == null ? '' : t).toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '').match(/[\p{L}\p{N}]+/gu) || [];
39
+ const share = (a, b) => {
40
+ const wa = words(a); const hb = new Set(words(b));
41
+ return wa.length ? wa.filter((w) => hb.has(w)).length / wa.length : 0;
42
+ };
43
+
44
+ /** The wall clock (epoch ms) of meeting time 0: a transcript line's `at` minus its `t`, else when recording began, else the meeting's start. */
45
+ function startOf(note, event) {
46
+ for (const x of (note && note.transcript) || []) {
47
+ const at = Date.parse(x && x.at);
48
+ if (x && finite(x.t) && Number.isFinite(at)) return at - x.t;
49
+ }
50
+ const rec = Date.parse(note && note.recorded_from);
51
+ if (Number.isFinite(rec)) return rec;
52
+ const ev = Date.parse(event && event.at);
53
+ return Number.isFinite(ev) ? ev : null;
54
+ }
55
+
56
+ /** The lines of the talk with a time: the final pass's segments when there are some, else the live lines; by t. */
57
+ function linesOf(note) {
58
+ let raw = [];
59
+ try { raw = N.transcriptLines(note); } catch { raw = []; }
60
+ return raw.filter((x) => x && finite(x.t) && typeof x.text === 'string' && x.text.trim())
61
+ .map((x) => ({ t: Math.round(x.t), who: str(x.who, 60), text: str(x.text, 600), source: x.source || 'speech' }))
62
+ .sort((a, b) => a.t - b.t)
63
+ .map((x, i) => ({ i, ...x, key: null }));
64
+ }
65
+
66
+ /** The decisions, next steps and questions of the note, as the Notes page shows them: the final notes when written, else the minute
67
+ * bullets, else the local summary. → { decision: [item], action: [item], question: [item] }, item = { text, quote?, t?, owner?, due?, by? } */
68
+ function caughtOf(note) {
69
+ const out = { decision: [], action: [], question: [] };
70
+ const f = note && note.final_notes;
71
+ const fromList = (kind, list) => {
72
+ for (const x of Array.isArray(list) ? list : []) {
73
+ const text = str(x && x.text, 300);
74
+ if (text) out[kind].push({ text, quote: str(x.quote, 400), t: finite(x.t) ? x.t : null, owner: str(x.owner, 60) || null, due: str(x.due, 40) || null, by: str(x.by, 120) || null });
75
+ }
76
+ };
77
+ if (f && ((f.decisions || []).length || (f.actions || []).length || (f.questions || []).length)) {
78
+ fromList('decision', f.decisions); fromList('action', f.actions); fromList('question', f.questions);
79
+ return out;
80
+ }
81
+ const bl = ((note && note.bullets) || []).filter((b) => b && b.text && !b.dropped && !b.open);
82
+ if (bl.length) {
83
+ for (const b of bl) if (out[b.cat]) out[b.cat].push({ text: str(b.text, 300), quote: '', t: finite(b.t) ? b.t : null, owner: str(b.owner, 60) || null, due: str(b.due, 40) || null, by: null });
84
+ if (out.decision.length || out.action.length || out.question.length) return out;
85
+ }
86
+ const sec = (note && note.summary && note.summary.sections) || {};
87
+ const at = (x) => { const m = /^\s*\[?(\d+:\d{2}(?::\d{2})?)\]?\s*$/.exec(String((x && x.at) || '')); return m ? N.parseTime(m[1]) : null; };
88
+ const fromSec = (kind, list) => { for (const x of Array.isArray(list) ? list : []) { const text = str(x && x.text, 300); if (text) out[kind].push({ text, quote: '', t: at(x), owner: str(x.owner, 60) || null, due: str(x.due, 40) || null, by: null }); } };
89
+ fromSec('decision', sec.decisions); fromSec('action', sec.actions); fromSec('question', sec.questions);
90
+ return out;
91
+ }
92
+
93
+ /** The time of the line a key point was caught from: its own t, else the line its quote (or its text) is in; null when unknown. */
94
+ function timeOfKey(item, lines) {
95
+ if (finite(item.t)) return Math.max(0, Math.round(item.t));
96
+ const timed = lines.map((l) => ({ t: l.t, text: l.text }));
97
+ for (const probe of [item.quote, item.text]) {
98
+ if (!probe) continue;
99
+ let at = null;
100
+ try { at = N.groundQuote(timed, probe, null); } catch { at = null; }
101
+ if (finite(at)) return Math.max(0, Math.round(at));
102
+ }
103
+ return null;
104
+ }
105
+
106
+ /** Join the three records. */
107
+ function build({ note, event = null, stateLines = [], frames = [], audio = null, topics = [], now = Date.now() } = {}) {
108
+ const n = note || {};
109
+ const lines = linesOf(n);
110
+ const start = startOf(n, event);
111
+ const shots = (frames || []).filter((f) => f && finite(f.t) && !f.aside).sort((a, b) => a.t - b.t)
112
+ .map((f) => ({ t: f.t, w: f.w || 0, h: f.h || 0, ...(f.why ? { why: f.why } : {}), ...(f.text ? { text: str(f.text, SHOT_TEXT_MAX) } : {}) }));
113
+ const asideCount = (frames || []).filter((f) => f && f.aside).length;
114
+
115
+ // Focus's state: topics, stretches, PMOS's notes. Stretches are measured from meeting time 0.
116
+ const fold = F.foldState(stateLines || [], { topics, startMs: start, nowMs: start == null ? now : start + 0 });
117
+ const pmos = start == null ? [] : fold.notes.filter((x) => x && x.body && finite(x.at)).map((x) => ({ id: x.id, t: Math.max(0, Math.round(x.at - start)), cat: str(x.cat, 30), body: str(x.body, 700), heard: str(x.heard, 300), why: str(x.why, 300), src: str(x.src, 80) }))
118
+ .sort((a, b) => a.t - b.t);
119
+
120
+ // length: the audio, the clock of the note, else the last thing that has a time
121
+ let heard = 0;
122
+ if (n.ended_at || n.paused_at) { try { heard = N.heardMs(n, now) || 0; } catch { heard = 0; } } // a note still recording has no end to trust
123
+ const caught = caughtOf(n);
124
+ const keys = [];
125
+ for (const [kind, list] of [['decision', caught.decision], ['action', caught.action], ['question', caught.question]]) {
126
+ list.forEach((it, i) => {
127
+ const t = timeOfKey(it, lines);
128
+ const answered = kind === 'question' && t != null && pmos.some((p) => p.cat === 'Answer' && p.t >= t && share(it.text, p.heard || p.body) >= ANSWER_OVERLAP);
129
+ const state = kind === 'action' ? (it.owner ? 'draft' : 'owner') : kind === 'question' ? (answered ? 'answered' : 'open') : 'draft';
130
+ keys.push({ id: kind[0] + (i + 1), k: kind, text: it.text, t, owner: it.owner, due: it.due, by: it.by, state, warn: state === 'owner' || state === 'open' });
131
+ });
132
+ }
133
+ keys.sort((a, b) => (a.t == null) - (b.t == null) || (a.t || 0) - (b.t || 0));
134
+ // the line a key point was caught from carries its mark
135
+ for (const k of keys) {
136
+ if (k.t == null || !lines.length) continue;
137
+ let best = null;
138
+ for (const l of lines) if (!l.key && (best === null || Math.abs(l.t - k.t) < Math.abs(best.t - k.t))) best = l;
139
+ if (best && Math.abs(best.t - k.t) <= NEAR_LINE_MS) best.key = { k: k.k, id: k.id };
140
+ }
141
+
142
+ const lastT = (xs) => xs.reduce((m, x) => (finite(x.t) && x.t > m ? x.t : m), 0);
143
+ const length = Math.max(finite(audio && audio.ms) ? audio.ms : 0, heard, lastT(lines), lastT(shots), lastT(keys), lastT(pmos));
144
+
145
+ // stretches, in ms; a stretch still open runs to the end
146
+ const runs = fold.runs.map((r) => ({ topic: r.topic, from_ms: Math.round(r.from_min * 60000), to_ms: r.to_min == null ? length : Math.round(r.to_min * 60000) }))
147
+ .filter((r) => r.to_ms > r.from_ms || r.to_ms === length);
148
+ const firstOf = new Map();
149
+ for (const r of runs) if (!firstOf.has(r.topic)) firstOf.set(r.topic, r.from_ms);
150
+ const visited = fold.topics.filter((t) => firstOf.has(t.id)).sort((a, b) => firstOf.get(a.id) - firstOf.get(b.id));
151
+ const numOf = new Map(visited.map((t, i) => [t.id, i + 1]));
152
+ const stretches = (id) => runs.filter((r) => r.topic === id).length;
153
+ const minutesOf = (id) => Math.round(runs.filter((r) => r.topic === id).reduce((s, r) => s + (r.to_ms - r.from_ms), 0) / 60000);
154
+ const topicRows = [...visited, ...fold.topics.filter((t) => !firstOf.has(t.id))].map((t) => ({
155
+ id: t.id, text: t.text, src: t.src, state: t.state, minutes: minutesOf(t.id), n: numOf.get(t.id) || null, first_t: firstOf.has(t.id) ? firstOf.get(t.id) : null,
156
+ stretches: stretches(t.id), parked: t.state === 'parked', by: t.by || null }));
157
+ const ev = event || {};
158
+ const m = n.meeting || {};
159
+ return {
160
+ ok: true,
161
+ note: { id: n.id, title: n.title || m.title || ev.title || 'Untitled note', state: n.state || null, initiative: n.initiative || null, ws: n.ws || (n.initiative && n.initiative.ws) || null },
162
+ meeting: { title: ev.title || m.title || n.title || 'Untitled note', at: ev.at || m.start || n.recorded_from || n.created_at || null, end: ev.end || m.end || null,
163
+ attendees: [].concat(ev.attendees || ev.people || []).map((p) => (p && typeof p === 'object' ? p.name || p.id : p)).filter(Boolean).map(String) },
164
+ audio: audio && audio.url ? { url: audio.url, ms: finite(audio.ms) ? audio.ms : null } : null,
165
+ start_ms: start, length_ms: length,
166
+ lines, keys, topics: topicRows, runs: runs.map((r) => ({ ...r, n: numOf.get(r.topic) || null })), notes: pmos, shots, aside_count: asideCount,
167
+ };
168
+ }
169
+
170
+ module.exports = { build, caughtOf, timeOfKey, linesOf, startOf };
@@ -35,6 +35,7 @@ const PROVIDERS = Object.freeze([
35
35
  'report-needs', // Run 5 (5C): a report to issue (kind 1), cycle rows (kind 7), a schedule left no output (kind 8)
36
36
  'driver-needs', // initiative-driver: drafts the agent filed (kind 7), a target the PM is blocking (kind 7)
37
37
  'core-docs-needs', // core-documents: one row per scope with core document drafts waiting for the PM (kind 1)
38
+ 'report-structure-needs', // report-structure: a Chrome or computer source that failed its check while a draft waits for it (kind 8)
38
39
  ]);
39
40
  /** Kinds that already mean something in the needs queue. A provider may not reuse one. */
40
41
  const RESERVED_KINDS = Object.freeze(['question', 'accept', 'outcome', 'keep', 'evidence', 'plan-flag', 'provider-error']);