create-pmos 0.203.0 → 0.205.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.203.0",
3
+ "version": "0.205.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@ab34930
1
+ kernel-source: pmos@3947135
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-13T12:24:07Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-13T13:16:48Z"}
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-13T12:24:07Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-13T13:16:48Z"}
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-13T12:24:07Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-13T13:16:48Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /* c3-capture-test.js — the PMOS Chrome extension's popup page, end to end against the REAL backend (initiative report-design-studio; mockup 42-reportdeck.js
4
+ * hashes #design-chrome and #design-chrome-page). A real server on a scratch repository, a real Chromium, and a small parent page that PLAYS THE EXTENSION
5
+ * POPUP: it frames ${BASE}/c3.html?capture=1&pop=1&… exactly as extension/popup.js does and answers the page's postMessage { pmosCapture: 'request' } with a
6
+ * small PDF or two pictures. (The parent is served from the same origin by the test, so the page's origin check, which takes chrome-extension:// or its own
7
+ * origin, lets it in. Chrome itself, the real extension and Google are not part of this suite.)
8
+ *
9
+ * list the page lists the report setups; the report whose studio was opened last is chosen, with the line that says so
10
+ * pictures Add as a sample asks the parent, shows the progress, uploads the two pictures with its own press and says "Added to <report>"; the sample is
11
+ * in the design (API) as kind pictures, from Chrome, "From Chrome, 2 page pictures"
12
+ * studio a studio open in another tab (opened with ?studio=<id>) picks the sample up by itself, with its kind line and thumbnail
13
+ * pdf a deck as a PDF: "From Chrome, PDF, 2 slides"; Open the studio asks the parent to open it
14
+ * guards a 'data' message nobody asked for adds nothing; a parent on another origin is not answered; a sample over 20 MB says so plainly (413)
15
+ * option the studio's Add a sample has "From Chrome" with its sub-line and a how-to that names `pmos extension`
16
+ * No page error. Synthetic repositories only; nothing reaches the network. */
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const { spawn } = require('child_process');
20
+ const { findBrowser } = require('./lib/find-browser');
21
+ const T = require('./test-run').configure({ name: 'c3-capture' });
22
+ const { makeRepos, codeRepo, write, git } = require('./plan-test-repos');
23
+ const RS = require('./lib/report-setups.js');
24
+
25
+ const BASE = T.BASE;
26
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
27
+ const until = async (fn, ms = 15000, step = 150) => { const t0 = Date.now(); for (;;) { let v; try { v = await fn(); } catch { v = null; } if (v) return v; if (Date.now() - t0 > ms) return v; await sleep(step); } };
28
+ const results = [];
29
+ const ok = (name, cond, extra = '') => { results.push({ name, cond: !!cond }); console.log(`${cond ? '✓' : '✗'} ${name}${!cond && extra !== '' ? ' — ' + String(extra).slice(0, 600) : ''}`); };
30
+
31
+ const R = makeRepos('c3-capture');
32
+ const team = codeRepo(path.join(R.base, 'team'));
33
+ fs.mkdirSync(path.join(team, 'pmos', 'state'), { recursive: true });
34
+ write(team, 'pmos/state/.keep', '');
35
+ git(team, ['add', '-A']); git(team, ['commit', '-q', '-m', 'state']);
36
+
37
+ const JPG1 = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.from('first screen of the page')]).toString('base64');
38
+ const JPG2 = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.from('second screen of the page')]).toString('base64');
39
+ const PDF = Buffer.from('%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Count 2 >> endobj\n3 0 obj << /Type /Page >> endobj\n4 0 obj << /Type /Page >> endobj\n%%EOF').toString('base64');
40
+ const SLIDES = 'https://docs.google.com/presentation/d/1qX7mR2vKp9/edit';
41
+ const WIKI = 'https://acme.atlassian.net/wiki/spaces/IDPF/pages/48213/IDPF+roadmap';
42
+
43
+ /* The page that plays the extension popup. ?mode=pictures|pdf|big|silent, &ph=<PMOS origin>, &kind=page|deck. It logs what the frame says to it (window.log). */
44
+ const hostHtml = (q) => `<!doctype html><meta charset="utf-8"><title>fake extension popup</title><body style="margin:0">
45
+ <iframe id="f" style="width:360px;height:440px;border:0"></iframe>
46
+ <script>
47
+ const q = new URLSearchParams(location.search); const mode = q.get('mode') || 'pictures'; const ph = q.get('ph') || location.origin; const kind = q.get('kind') || 'page';
48
+ window.log = [];
49
+ const f = document.getElementById('f');
50
+ f.src = ph + '/c3.html?capture=1&pop=1&pkind=' + kind + '&ptitle=' + encodeURIComponent(kind === 'deck' ? 'Biweekly update, 26 Mar' : 'IDPF roadmap') + '&purl=' + encodeURIComponent(kind === 'deck' ? ${JSON.stringify(SLIDES)} : ${JSON.stringify(WIKI)}) + (kind === 'page' ? '&pscreens=3' : '');
51
+ window.sendData = (m) => f.contentWindow.postMessage(m, ph);
52
+ addEventListener('message', (e) => {
53
+ if (e.source !== f.contentWindow || e.origin !== ph) return;
54
+ log.push(e.data);
55
+ if (!e.data || e.data.pmosCapture !== 'request' || mode === 'silent') return;
56
+ sendData({ pmosCapture: 'progress', text: 'Picture 1 of 2', step: 1, of: 3 });
57
+ setTimeout(() => {
58
+ if (mode === 'pdf') sendData({ pmosCapture: 'data', kind: 'pdf', name: 'Biweekly update, 26 Mar', url: ${JSON.stringify(SLIDES)}, pages: 2, data: ${JSON.stringify(PDF)} });
59
+ else if (mode === 'big') { const big = '/9j/' + 'A'.repeat(14700000); sendData({ pmosCapture: 'data', kind: 'pictures', name: 'Very long page', url: ${JSON.stringify(WIKI)}, pictures: [big, big] }); }
60
+ else sendData({ pmosCapture: 'data', kind: 'pictures', name: 'IDPF roadmap', url: ${JSON.stringify(WIKI)}, pictures: [${JSON.stringify(JPG1)}, ${JSON.stringify(JPG2)}] });
61
+ }, 250);
62
+ });
63
+ </script>`;
64
+
65
+ let srv = null; let browser = null;
66
+ const finish = async (code) => {
67
+ if (browser) await browser.close().catch(() => {});
68
+ if (srv) { try { srv.kill('SIGKILL'); } catch { /* gone */ } }
69
+ T.killTmux();
70
+ R.cleanup();
71
+ const bad = results.filter((x) => !x.cond).length;
72
+ console.log(`\n${results.length - bad}/${results.length} c3-capture assertions passed`);
73
+ process.exit(code !== undefined ? code : (bad ? 1 : 0));
74
+ };
75
+ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
76
+
77
+ (async () => {
78
+ const exe = findBrowser();
79
+ if (!exe) { console.log('✗ no Chrome/Chromium on this machine, c3-capture cannot run'); return finish(1); }
80
+ let chromium;
81
+ try { ({ chromium } = require('playwright-core')); } catch { console.log('✗ playwright-core is not installed, c3-capture cannot run'); return finish(1); }
82
+
83
+ srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore',
84
+ env: T.env({ PMOS_WS_PORT: String(T.PORT), PMOS_SELF_ROOT: team, PMOS_NO_OPEN: '1', PMOS_TICK_MS: '300', PMOS_REPORT_FIND: 'off', PMOS_CORE_DOCS: 'off' }) });
85
+ await T.waitForServer(BASE);
86
+ const press = await T.pressHeaders(BASE);
87
+ const call = async (method, p, body) => { const r = await fetch(BASE + p, { method, headers: method === 'POST' ? press : {}, body: body === undefined ? undefined : JSON.stringify(body) }); const t = await r.text(); let j = null; try { j = JSON.parse(t); } catch { /* not JSON */ } return { status: r.status, body: j, raw: t }; };
88
+
89
+ const ws = (await call('POST', '/api/workspaces', { root: team, focus: true })).body.workspace.id;
90
+ const mkSetup = async (name) => {
91
+ const proposal = { name, purpose: 'Tell the attendees what shipped.', audience: 'eShop attendees', source: 'work', output: 'google-slides',
92
+ schedule: { cadence: 'weekly', day: 'Wed', time: '09:00' }, sources: ['The local ledgers'], sections: RS.FRAME.slice(), learned_from: 'The June copy.', fixes: [] };
93
+ const m = await call('POST', '/api/report-setups/setup', { ws, proposal });
94
+ return m.body && m.body.setup && m.body.setup.id;
95
+ };
96
+ const idA = await mkSetup('IDPF biweekly');
97
+ const idB = await mkSetup('TSC digital input');
98
+ ok('setup: two report setups', !!idA && !!idB && idA !== idB, idA + ' ' + idB);
99
+ if (!idA || !idB) return finish(1);
100
+ const samples = async (id) => ((await call('GET', `/api/report-design?ws=${ws}&id=${id}`)).body.design || {}).samples || [];
101
+ const last0 = await call('GET', `/api/report-design/last?ws=${ws}`);
102
+ ok('before any studio is opened no report is the last one', last0.status === 200 && last0.body.id === null, last0.raw);
103
+ await call('GET', `/api/report-design?ws=${ws}&id=${idA}`);
104
+ await sleep(30);
105
+ await call('GET', `/api/report-design?ws=${ws}&id=${idB}`); // the studio of B is the one opened last
106
+
107
+ browser = await chromium.launch({ executablePath: exe, args: ['--disable-features=PrivateNetworkAccessForNavigations,PrivateNetworkAccessForWorkers,PrivateNetworkAccessSendPreflights,PrivateNetworkAccessRespectPreflightResults,LocalNetworkAccessChecks,BlockInsecurePrivateNetworkRequests'] }); // the test's own parent page is answered by the test, so Chrome sees it as a public page framing a loopback one
108
+ const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
109
+ const errors = []; const expectHttp = new Set();
110
+ const watch = (pg) => {
111
+ pg.on('pageerror', (e) => errors.push('pageerror: ' + String((e && e.message) || e)));
112
+ pg.on('requestfailed', (r) => { const f = r.failure(); if (!/ERR_ABORTED/.test((f && f.errorText) || '')) errors.push('failed ' + r.url() + ' ' + ((f && f.errorText) || '')); });
113
+ pg.on('response', (r) => { if (r.status() >= 400 && !expectHttp.has(r.status())) errors.push('http ' + r.status() + ' ' + r.request().method() + ' ' + r.url()); });
114
+ pg.on('dialog', (d) => d.dismiss().catch(() => {}));
115
+ };
116
+ /* the parent page is served by the test: any address of the form <host>/__popup.html answers with the fake popup */
117
+ await ctx.route(/\/__popup\.html/, (route) => route.fulfill({ status: 200, contentType: 'text/html', body: hostHtml() }));
118
+
119
+ const popup = async (q) => {
120
+ const pg = await ctx.newPage(); watch(pg);
121
+ await pg.setViewport?.({ width: 400, height: 500 });
122
+ await pg.goto(BASE + '/__popup.html?' + q);
123
+ const frame = () => pg.frames().find((f) => /capture=1/.test(f.url()) && f !== pg.mainFrame());
124
+ await until(() => { const f = frame(); return f && f.$('[data-cap]'); }, 15000);
125
+ return { pg, frame, log: () => pg.evaluate(() => window.log.map((m) => JSON.stringify(m))) };
126
+ };
127
+ const ftext = async (p, sel = '.cap-pop') => { const f = p.frame(); return f ? (await f.textContent(sel).catch(() => '')) || '' : ''; };
128
+
129
+ /* ── list ── */
130
+ const A = await popup('mode=pictures&kind=page');
131
+ const f1 = A.frame();
132
+ ok('the page is drawn in console3 markup: the head, the page name and where it is from, how many screens', /PMOS/.test(await ftext(A)) && /Add this page to a report design/.test(await ftext(A)) && /IDPF roadmap/.test(await ftext(A, '[data-cap="page"]')) && /acme\.atlassian\.net, about 3 screens long/.test(await ftext(A, '[data-cap="page"]')), await ftext(A));
133
+ const opts = await f1.$$eval('[data-cap-rep] option', (os) => os.map((o) => [o.textContent, o.selected]));
134
+ ok('the Report list holds both setups, and the report whose studio was opened last is chosen', opts.length === 2 && opts.some(([t, s]) => t === 'TSC digital input' && s) && opts.some(([t, s]) => t === 'IDPF biweekly' && !s), JSON.stringify(opts));
135
+ ok('the page says it is the report whose studio was opened last, and how the page is saved', /The report whose studio you opened last\./.test(await ftext(A)) && /saves the page as pictures, scrolled top to bottom/.test(await ftext(A)));
136
+ ok('the page tells the popup it is ready, and its height', (await A.log()).some((m) => /"pmosCapture":"ready"/.test(m)) && (await until(async () => (await A.log()).some((m) => /"pmosCapture":"resize"/.test(m)))));
137
+ await f1.selectOption('[data-cap-rep]', await f1.$eval('[data-cap-rep] option:nth-child(1)', (o) => o.value));
138
+ const picked = await f1.$eval('[data-cap-rep]', (s) => s.selectedOptions[0].textContent);
139
+ ok('choosing another report changes the choice and drops the "opened last" line when it is not that one', picked !== 'TSC digital input' ? !/opened last/.test(await ftext(A)) : true);
140
+ await f1.selectOption('[data-cap-rep]', { label: 'TSC digital input' });
141
+
142
+ /* a studio of B opened in another tab by the link the popup's Open the studio uses */
143
+ const S = await ctx.newPage(); watch(S);
144
+ await S.goto(BASE + '/c3.html?studio=' + idB);
145
+ ok('?studio=<id> opens that report\'s design studio', !!(await until(() => S.$('[data-rd-studio="' + idB + '"]'), 20000)));
146
+
147
+ /* ── pictures ── */
148
+ await f1.click('[data-cap="send"]');
149
+ ok('Add as a sample asks the popup to take the page, and shows the saving state with the progress', !!(await until(async () => (await A.log()).some((m) => /"pmosCapture":"request"/.test(m)))) && !!(await until(async () => /Saving the page as pictures/.test(await ftext(A)))));
150
+ ok('then the page says Added to the report and offers Open the studio', !!(await until(async () => /Added to TSC digital input/.test(await ftext(A)), 15000)) && /2 page pictures/.test(await ftext(A)) && !!(await A.frame().$('[data-cap="studio"]')), await ftext(A));
151
+ const got = await until(async () => (await samples(idB)).find((s) => s.kind === 'pictures'));
152
+ ok('the sample is in the design of that report: kind pictures, from Chrome, "From Chrome, 2 page pictures", a thumb, read', !!got && got.via === 'chrome' && got.line === 'From Chrome, 2 page pictures' && !!got.thumb && got.name === 'IDPF roadmap' && got.source === WIKI, JSON.stringify(got));
153
+ ok('and not in the other report', (await samples(idA)).length === 0);
154
+ const files = got ? fs.readdirSync(path.join(team, 'planning', 'reports', idB, 'design', 'samples')).filter((f) => f.startsWith(got.id + '-')).sort() : [];
155
+ ok('two picture files are on disk', files.join() === got.id + '-1.jpg,' + got.id + '-2.jpg', files.join());
156
+ ok('a studio that was already open in another tab picks the new sample up by itself, with its kind line and thumbnail', !!(await until(async () => /From Chrome, 2 page pictures/.test((await S.textContent('[data-rd-studio]').catch(() => '')) || '') && !!(await S.$('[data-rd-sample="' + (got && got.id) + '"] img.rd-dks-thumb')), 15000)));
157
+
158
+ /* ── guards ── */
159
+ await A.pg.evaluate((d) => window.sendData(d), { pmosCapture: 'data', kind: 'pictures', name: 'Nobody asked', url: WIKI, pictures: [JPG1] });
160
+ await sleep(1200);
161
+ ok('a data message nobody asked for adds nothing', (await samples(idB)).filter((s) => s.kind === 'pictures').length === 1);
162
+ const alt = new URL(BASE); alt.port = String(Number(alt.port) + 1); // another origin on this machine; the test answers for it, nothing listens there
163
+ const X = await ctx.newPage(); watch(X);
164
+ await X.route(/__popup\.html/, (route) => route.fulfill({ status: 200, contentType: 'text/html', body: hostHtml() }));
165
+ await X.goto(alt.origin + '/__popup.html?mode=pictures&kind=page&ph=' + encodeURIComponent(BASE));
166
+ const fx = () => X.frames().find((f) => /capture=1/.test(f.url()) && f !== X.mainFrame());
167
+ await until(() => { const f = fx(); return f && f.$('[data-cap="send"]'); }, 15000);
168
+ const beforeX = (await samples(idB)).length;
169
+ const fxf = fx(); if (fxf) await fxf.click('[data-cap="send"]');
170
+ await sleep(2200);
171
+ ok('a popup page on another origin is not answered: the frame loaded, asked, and got nothing; no sample is added', !!fxf && (await fxf.$$('[data-cap]')).length > 0 && (await samples(idB)).length === beforeX && /Saving the page as pictures/.test((await fxf.textContent('.cap-pop').catch(() => '')) || '') && !/Added to/.test((fxf ? await fxf.textContent('.cap-pop').catch(() => '') : '') || ''), await (fxf ? fxf.textContent('.cap-pop').catch(() => '') : ''));
172
+ await X.close();
173
+
174
+ /* ── pdf ── */
175
+ const B = await popup('mode=pdf&kind=deck');
176
+ ok('a deck is a deck: Google Slides, saved as a PDF', /Google Slides/.test(await ftext(B, '[data-cap="page"]')) && /saves the deck as a PDF/.test(await ftext(B)), await ftext(B));
177
+ await B.frame().click('[data-cap="send"]');
178
+ ok('a PDF is added: Added to TSC digital input, 2 slides as a PDF', !!(await until(async () => /Added to TSC digital input/.test(await ftext(B)) && /2 slides as a PDF/.test(await ftext(B)), 15000)), await ftext(B));
179
+ const gp = await until(async () => (await samples(idB)).find((s) => s.kind === 'pdf'));
180
+ ok('the sample reads "From Chrome, PDF, 2 slides"', !!gp && gp.line === 'From Chrome, PDF, 2 slides' && gp.via === 'chrome', JSON.stringify(gp));
181
+ ok('the open studio shows it too', !!(await until(async () => /From Chrome, PDF, 2 slides/.test((await S.textContent('[data-rd-studio]').catch(() => '')) || ''), 15000)));
182
+ await B.frame().click('[data-cap="studio"]');
183
+ ok('Open the studio asks the popup to open this report\'s studio', !!(await until(async () => (await B.log()).some((m) => m === JSON.stringify({ pmosCapture: 'open', report: idB })))), (await B.log()).join(' '));
184
+ await B.pg.close();
185
+
186
+ /* ── over the limit ── */
187
+ expectHttp.add(413);
188
+ const C = await popup('mode=big&kind=page');
189
+ await C.frame().click('[data-cap="send"]');
190
+ ok('a sample over 20 MB says so plainly, with Try again, and adds nothing', !!(await until(async () => /over 20 MB/.test(await ftext(C)) && !!(await C.frame().$('[data-cap="retry"]')), 30000)) && (await samples(idB)).length === 2, await ftext(C));
191
+ expectHttp.delete(413);
192
+ await C.pg.close();
193
+
194
+ /* ── the studio option ── */
195
+ await S.click('[data-rd-act="panel"]').catch(() => {});
196
+ await until(() => S.$('[data-rd-panel]'));
197
+ const optTxt = (await S.textContent('[data-rd-smp="chrome"]').catch(() => '')) || '';
198
+ ok('the studio\'s Add a sample has From Chrome with its sub-line', /From Chrome/.test(optTxt) && /Open any page in Chrome and press the PMOS button\. Decks come as a PDF, other pages as pictures\./.test(optTxt), optTxt);
199
+ await S.click('[data-rd-smp="chrome"]');
200
+ const how = (await S.textContent('[data-rd-chrome]').catch(() => '')) || '';
201
+ ok('pressing it shows a short how-to that names `pmos extension` for the load steps', /press the PMOS button in the toolbar/.test(how) && /pmos extension/.test(how) && /Add as a sample/.test(how), how);
202
+
203
+ await sleep(300);
204
+ ok('no page error and no failed request', errors.length === 0, errors.slice(0, 6).join(' | '));
205
+ return finish();
206
+ })().catch((e) => { console.error(e); finish(1); });
@@ -41,6 +41,10 @@ const fs = require('fs');
41
41
  let input = ''; process.stdin.on('data', (d) => { input += d; }); process.stdin.on('end', () => {
42
42
  const line = (k) => { const m = new RegExp('^- ' + k + ': (.*)$', 'm').exec(input); return m ? m[1].trim() : null; };
43
43
  const mode = line('Mode'); const out = line('Output path');
44
+ if (line('Link')) { // the report-sample-fetch run: it writes the words of the linked file and prints ok
45
+ fs.writeFileSync(out, '## Slide 1: Agenda\\nWelcome\\n');
46
+ process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', is_error: false, result: JSON.stringify({ ok: true, title: 'Quarterly review', slides: 1 }), num_turns: 2, duration_ms: 300, permission_denials: [] })); process.exit(0);
47
+ }
44
48
  const done = (o) => { process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', is_error: false, result: JSON.stringify(o), num_turns: 2, duration_ms: 500, permission_denials: [] })); process.exit(0); };
45
49
  const sec = (id, kind, title, body) => '<section class="pmos-slide" data-slide-id="' + id + '" data-kind="' + kind + '" data-title="' + title + '">' + (body || '<h2>' + title + '</h2>') + '</section>';
46
50
  if (mode === 'turn') {
@@ -127,9 +131,10 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
127
131
  if (!(await page.$('[data-rd-link]'))) { await page.click('[data-rd-act="panel"]'); await page.waitForSelector('[data-rd-link]'); }
128
132
  await page.fill('[data-rd-link]', 'https://docs.google.com/presentation/d/1FqiafPx1SjmntCVT8SOOIfFbxHrkemAwEc_MpAnGGh0/edit');
129
133
  await page.click('[data-rd-smp="link"]');
130
- ok('a Google Slides link says PMOS cannot open it and to add the PDF download', !!(await until(async () => /Google Slides deck/.test(await text('[data-rd-chat]')) && /File, Download, PDF document \(\.pdf\)/.test(await text('[data-rd-chat] [data-rd-hint]')))));
134
+ ok('a Google Slides link is read through the connectors: the hint says words and structure, and still gives the PDF step for the look', !!(await until(async () => /Google Slides deck/.test(await text('[data-rd-chat]'))
135
+ && /PMOS read the words and structure of this link\. To match its look, also add it as a PDF or a screenshot\./.test(await text('[data-rd-chat] [data-rd-hint]')) && /File, Download, PDF document \(\.pdf\)/.test(await text('[data-rd-chat] [data-rd-hint]')))));
131
136
  if (!(await page.$('[data-rd-link]'))) { await page.click('[data-rd-act="panel"]'); await page.waitForSelector('[data-rd-link]'); }
132
- await page.fill('[data-rd-link]', 'https://example.atlassian.net/wiki/d/1FqiafPx1SjmntCVT8SOOIfFbxHrkemAwEc_MpAnGGh0xYzW1FqiafPx1SjmntCVT8SOOIfFbx/edit');
137
+ await page.fill('[data-rd-link]', 'https://intranet.example.com/d/1FqiafPx1SjmntCVT8SOOIfFbxHrkemAwEc_MpAnGGh0xYzW1FqiafPx1SjmntCVT8SOOIfFbx/edit');
133
138
  await page.click('[data-rd-smp="link"]');
134
139
  ok('any other link says PMOS cannot open web pages and to add a PDF or a screenshot', !!(await until(async () => /PMOS cannot open web pages/.test(await text('[data-rd-chat]')))));
135
140
  const box = async (sel) => page.evaluate((q) => { const r = document.querySelector(q).getBoundingClientRect(); return { l: r.left, r: r.right, t: r.top, b: r.bottom, w: r.width }; }, sel);
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  type: Guide
3
- title: "PMOS for Google Meet: the Chrome extension"
4
- timestamp: 2026-10-12
3
+ title: "PMOS in Chrome: the Chrome extension"
4
+ timestamp: 2026-10-13
5
5
  ---
6
6
 
7
- # PMOS for Google Meet
7
+ # PMOS in Chrome
8
8
 
9
- A small panel over a Google Meet call. It shows the PMOS meeting companion: the topic the room is on, the time left, what is being said, the notes from PMOS and a line to ask PMOS quietly. Only you see it. The extension has no build step and no remote code. It runs only on `https://meet.google.com/*`, reads the address and the title of the Meet tab, and talks only to the PMOS on your own computer.
9
+ The PMOS extension does two things. It shows the PMOS meeting companion in a small panel over a Google Meet call, and it adds a toolbar button that puts the page you are on into a report design. The extension has no build step and no remote code. It talks only to the PMOS on your own computer.
10
10
 
11
11
  ## Load it in Chrome
12
12
 
@@ -16,17 +16,32 @@ A small panel over a Google Meet call. It shows the PMOS meeting companion: the
16
16
 
17
17
  If PMOS does not run on `http://localhost:7433`, open the extension's **Options** and set the address that `pmos` printed. A Meet tab that is already open picks up the new address when you reload it.
18
18
 
19
- ## Use it
19
+ After you update PMOS, press the reload arrow on the extension's card in `chrome://extensions`. Version 0.2.0 adds the toolbar button and asks Chrome for three more permissions (see below), so Chrome may ask you to allow them.
20
20
 
21
- Open a Meet call, for example `meet.google.com/abc-defg-hij`. The panel shows at the bottom right of the call. It finds the meeting on your PMOS calendar by the Meet link in the invite, then by the title of the tab, then by the meeting that is under way now. If none fits, the panel says so and lists today's meetings to pick from. **Fold** shrinks the panel to a pill, and the same button opens it again. **Open focus in PMOS** opens the full Meeting focus page in a new tab. When PMOS does not answer, the panel says "PMOS is not running at <address>" and links to the options.
21
+ ## The Meet panel
22
+
23
+ Open a Meet call, for example `meet.google.com/abc-defg-hij`. The panel shows at the bottom right of the call. It shows the topic the room is on, the time left, what is being said, the notes from PMOS and a line to ask PMOS quietly. Only you see it. It finds the meeting on your PMOS calendar by the Meet link in the invite, then by the title of the tab, then by the meeting that is under way now. If none fits, the panel says so and lists today's meetings to pick from. **Fold** shrinks the panel to a pill, and the same button opens it again. **Open focus in PMOS** opens the full Meeting focus page in a new tab. When PMOS does not answer, the panel says "PMOS is not running at <address>" and links to the options.
24
+
25
+ ## Add a page to a report design
26
+
27
+ Open any page in Chrome and press the PMOS button in the toolbar. The popup is "Add this page to a report design". It shows the page, a **Report** list and one line on how the page is saved. The list starts on the report whose design studio you opened last. Press **Add as a sample**. The popup shows the progress, then "Added to <report>" and **Open the studio**. Keep the popup open while it saves.
28
+
29
+ - **Google Slides and Google Docs** are saved as a PDF. The extension asks Google for the PDF with your own Google sign-in, as Chrome does when you press Download. The sample reads "From Chrome, PDF, 12 slides" (or "pages" for a document).
30
+ - **Any other page** is saved as pictures of the whole page, scrolled top to bottom: at most 8 pictures, one screen each, with a pause between them because Chrome allows about two captures a second. The page is scrolled back to where you were. The sample reads "From Chrome, 3 page pictures". Only the page itself is pictured: a part of the page that scrolls inside the page (a code editor, a side panel) is not scrolled.
31
+ - A sample is at most 20 MB. When it is over, the popup says so. Open a shorter page, or add a smaller copy.
32
+ - When PMOS does not answer, the popup says "PMOS is not answering at <address>. Start PMOS, then try again." and has **Try again**.
33
+
34
+ How the data reaches PMOS: the extension never writes to PMOS itself. The popup shows a page from your PMOS (`/c3.html?capture=1&pop=1`) in a frame. That page has the report list and the Add button. When you press Add, it asks the popup to take the page. The popup sends the PDF or the pictures back to that page, and that page saves them with its own press, as every other change in PMOS is saved. Each message is checked for its sender and its origin on both sides.
22
35
 
23
36
  ## Files
24
37
 
25
38
  | File | What it is |
26
39
  |---|---|
27
- | `manifest.json` | Manifest V3. Permission: `storage` only. Content script on `https://meet.google.com/*`. |
40
+ | `manifest.json` | Manifest V3. Permissions: `storage`, `activeTab` (the page you press the button on) and `scripting` (read its height, scroll it). Host permissions: `https://docs.google.com/*` (the PDF of a deck or a document) and `https://*.googleusercontent.com/*` (where Google sends that PDF from). Content script on `https://meet.google.com/*`. |
28
41
  | `content.js` | Adds the panel on a call page (`/abc-defg-hij`) and removes it when you leave. |
29
42
  | `panel.html`, `panel.js`, `panel.css` | The page inside the panel. It checks that PMOS answers, then frames PMOS's compact Meeting focus page. |
43
+ | `popup.html`, `popup.js`, `popup.css` | The toolbar popup. It checks that PMOS answers, frames the PMOS capture page and takes the page when asked. |
44
+ | `capture.js` | Pure helpers: what kind of page it is, the PDF address, the page count of a PDF, the scroll plan, the message checks. |
30
45
  | `options.html`, `options.js`, `options.css`, `address.js` | The one setting: where PMOS is. |
31
46
  | `icons/` | The PMOS icon at four sizes. |
32
47
 
@@ -0,0 +1,114 @@
1
+ /* capture.js — the pure helpers of the PMOS button (classic script; no build step; also required by extension.test.js).
2
+ * What kind of page is open, where Google keeps its PDF, how many pages a PDF has, how far to scroll for pictures of a whole page, and
3
+ * which messages between the popup and the framed PMOS page are accepted. Nothing here touches the browser or the network. */
4
+ (function () {
5
+ 'use strict';
6
+ var MAX_BYTES = 20 * 1024 * 1024; // PMOS keeps a sample up to 20 MB (lib/report-design.js MAX_SAMPLE_BYTES)
7
+ var MAX_PICTURES = 8; // pictures of one page
8
+ var MIN_GAP_MS = 650; // Chrome allows about two captures a second; 600 ms is the floor
9
+ var SLIDES = /^\/presentation\/d\/([A-Za-z0-9_-]+)(?:\/|$)/;
10
+ var DOC = /^\/document\/d\/([A-Za-z0-9_-]+)(?:\/|$)/;
11
+
12
+ function parse(url) { try { return new URL(String(url || '')); } catch (e) { return null; } }
13
+
14
+ /** 'google-slides' | 'google-doc' | 'other' (any web page) | 'blocked' (Chrome pages, files, anything that is not http or https). */
15
+ function classifyUrl(url) {
16
+ var u = parse(url);
17
+ if (!u || (u.protocol !== 'https:' && u.protocol !== 'http:')) return 'blocked';
18
+ if (u.protocol === 'https:' && u.hostname === 'docs.google.com') {
19
+ if (SLIDES.test(u.pathname)) return 'google-slides';
20
+ if (DOC.test(u.pathname)) return 'google-doc';
21
+ }
22
+ if (u.protocol === 'https:' && (u.hostname === 'chromewebstore.google.com' || u.hostname === 'chrome.google.com')) return 'blocked'; // Chrome does not let an extension read these
23
+ return 'other';
24
+ }
25
+
26
+ /** The address that gives the file as a PDF: Slides /export/pdf, Docs /export?format=pdf. '' for any other page. */
27
+ function exportUrl(url) {
28
+ var u = parse(url);
29
+ var kind = classifyUrl(url);
30
+ if (kind === 'google-slides') return 'https://docs.google.com/presentation/d/' + SLIDES.exec(u.pathname)[1] + '/export/pdf';
31
+ if (kind === 'google-doc') return 'https://docs.google.com/document/d/' + DOC.exec(u.pathname)[1] + '/export?format=pdf';
32
+ return '';
33
+ }
34
+
35
+ /** The kind the PMOS popup page shows: 'deck' (Google Slides), 'doc' (Google Docs) or 'page'. */
36
+ function pageKind(url) { var k = classifyUrl(url); return k === 'google-slides' ? 'deck' : k === 'google-doc' ? 'doc' : 'page'; }
37
+
38
+ /** The number of pages of a PDF (bytes: Uint8Array or Buffer): the /Type /Page objects, not /Pages. When none can be seen
39
+ * (page objects packed in a compressed stream) the largest /Count is used; 0 when neither is found. */
40
+ function pdfPageCount(bytes) {
41
+ var s = '';
42
+ var n = bytes ? bytes.length : 0;
43
+ for (var i = 0; i < n; i += 0x8000) s += String.fromCharCode.apply(null, Array.prototype.slice.call(bytes, i, Math.min(n, i + 0x8000)));
44
+ var pages = s.match(/\/Type\s*\/Page(?![A-Za-z])/g);
45
+ if (pages && pages.length) return pages.length;
46
+ var best = 0;
47
+ var re = /\/Count\s+(\d+)/g;
48
+ var m;
49
+ while ((m = re.exec(s))) { var c = parseInt(m[1], 10); if (c > best && c < 5000) best = c; }
50
+ return best;
51
+ }
52
+
53
+ /** The scroll positions for pictures of a page: one for each screen from the top, the last one ending at the page bottom, at most `max`.
54
+ * → { offsets: [y, …], total: screens the page needs, cut: true when the page is longer than `max` screens } */
55
+ function scrollPlan(pageHeight, viewHeight, max) {
56
+ var cap = max > 0 ? Math.floor(max) : MAX_PICTURES;
57
+ var vh = Math.max(1, Math.floor(viewHeight) || 1);
58
+ var h = Math.max(vh, Math.floor(pageHeight) || vh);
59
+ var total = Math.ceil(h / vh);
60
+ var count = Math.min(total, cap);
61
+ var last = h - vh;
62
+ var offsets = [];
63
+ for (var i = 0; i < count; i++) offsets.push(Math.min(i * vh, last));
64
+ return { offsets: offsets, total: total, cut: total > count };
65
+ }
66
+
67
+ /** The kind line the popup shows under the page name. */
68
+ function whatLine(url, title) {
69
+ var k = classifyUrl(url);
70
+ return k === 'google-slides' ? 'Google Slides' : k === 'google-doc' ? 'Google Docs' : (parse(url) ? parse(url).hostname : '') || String(title || '');
71
+ }
72
+
73
+ /** A short file name for a sample from the tab title: "Biweekly update - Google Slides" → "Biweekly update". */
74
+ function sampleName(title, url) {
75
+ var t = String(title || '').replace(/\s+-\s+Google (Slides|Docs)\s*$/i, '').replace(/\s+/g, ' ').trim();
76
+ if (!t) { var u = parse(url); t = u ? u.hostname : 'Page'; }
77
+ return t.length > 120 ? t.slice(0, 119) + '…' : t;
78
+ }
79
+
80
+ var ALLOWED_FROM_FRAME = { ready: 1, request: 1, open: 1, close: 1, resize: 1 };
81
+ var REPORT_ID = /^[a-z][a-z0-9-]{0,39}$/;
82
+ /** A message from the framed PMOS page to the popup. ok only from the frame's own window and the PMOS origin, and only these words:
83
+ * { pmosCapture: 'ready' } (the page is drawn), { pmosCapture: 'request' } (capture now), { pmosCapture: 'open', report } (open the studio),
84
+ * { pmosCapture: 'resize', height } (the popup's height, 120 to 600) and { pmosCapture: 'close' }. → the message or null. */
85
+ function fromFrame(event, frameWindow, pmosOrigin) {
86
+ if (!event || !frameWindow || event.source !== frameWindow || !pmosOrigin || event.origin !== pmosOrigin) return null;
87
+ var d = event.data;
88
+ if (!d || typeof d !== 'object' || typeof d.pmosCapture !== 'string' || !ALLOWED_FROM_FRAME[d.pmosCapture]) return null;
89
+ if (d.pmosCapture === 'open') return REPORT_ID.test(String(d.report || '')) ? { pmosCapture: 'open', report: d.report } : null;
90
+ if (d.pmosCapture === 'resize') return Number.isInteger(d.height) && d.height >= 120 && d.height <= 600 ? { pmosCapture: 'resize', height: d.height } : null;
91
+ return { pmosCapture: d.pmosCapture };
92
+ }
93
+
94
+ /** The popup's answer to a request, checked before it is posted: the shape the framed page uploads.
95
+ * { pmosCapture: 'data', kind: 'pdf', name, url, pages, data } | { pmosCapture: 'data', kind: 'pictures', name, url, pictures: [base64, …] }
96
+ * | { pmosCapture: 'progress', text, step, of } | { pmosCapture: 'error', error }. → true when well formed. */
97
+ function validOutgoing(m) {
98
+ if (!m || typeof m !== 'object') return false;
99
+ if (m.pmosCapture === 'progress') return typeof m.text === 'string' && Number.isInteger(m.step) && Number.isInteger(m.of) && m.of > 0;
100
+ if (m.pmosCapture === 'error') return typeof m.error === 'string' && !!m.error;
101
+ if (m.pmosCapture !== 'data' || typeof m.name !== 'string' || typeof m.url !== 'string') return false;
102
+ if (m.kind === 'pdf') return typeof m.data === 'string' && m.data.length > 0 && (m.pages === undefined || Number.isInteger(m.pages));
103
+ if (m.kind === 'pictures') return Array.isArray(m.pictures) && m.pictures.length > 0 && m.pictures.length <= MAX_PICTURES && m.pictures.every(function (p) { return typeof p === 'string' && p.length > 0; });
104
+ return false;
105
+ }
106
+
107
+ /** The base64 size of a number of bytes, and the bytes of a base64 text (no decoding). */
108
+ function base64Bytes(b64) { return Math.floor(String(b64 || '').length * 3 / 4); }
109
+
110
+ var api = { MAX_BYTES: MAX_BYTES, MAX_PICTURES: MAX_PICTURES, MIN_GAP_MS: MIN_GAP_MS, classifyUrl: classifyUrl, exportUrl: exportUrl, pageKind: pageKind, pdfPageCount: pdfPageCount,
111
+ scrollPlan: scrollPlan, whatLine: whatLine, sampleName: sampleName, fromFrame: fromFrame, validOutgoing: validOutgoing, base64Bytes: base64Bytes };
112
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
113
+ else window.PMOS_CAPTURE = api;
114
+ })();
@@ -1,15 +1,56 @@
1
1
  {
2
2
  "manifest_version": 3,
3
- "name": "PMOS for Google Meet",
4
- "version": "0.1.0",
5
- "description": "Shows your PMOS meeting companion in a small panel over a Google Meet call. Only you see it. It talks only to the PMOS on your own computer.",
6
- "icons": { "16": "icons/icon-16.png", "32": "icons/icon-32.png", "48": "icons/icon-48.png", "128": "icons/icon-128.png" },
7
- "permissions": ["storage"],
3
+ "name": "PMOS",
4
+ "version": "0.2.0",
5
+ "description": "The PMOS panel over a Google Meet call, and a button that adds the page you are on to a report design. It talks only to the PMOS on your own computer.",
6
+ "icons": {
7
+ "16": "icons/icon-16.png",
8
+ "32": "icons/icon-32.png",
9
+ "48": "icons/icon-48.png",
10
+ "128": "icons/icon-128.png"
11
+ },
12
+ "action": {
13
+ "default_title": "Add this page to a report design",
14
+ "default_popup": "popup.html",
15
+ "default_icon": {
16
+ "16": "icons/icon-16.png",
17
+ "32": "icons/icon-32.png",
18
+ "48": "icons/icon-48.png",
19
+ "128": "icons/icon-128.png"
20
+ }
21
+ },
22
+ "permissions": [
23
+ "storage",
24
+ "activeTab",
25
+ "scripting"
26
+ ],
27
+ "host_permissions": [
28
+ "https://docs.google.com/*",
29
+ "https://*.googleusercontent.com/*"
30
+ ],
8
31
  "content_scripts": [
9
- { "matches": ["https://meet.google.com/*"], "js": ["content.js"], "run_at": "document_idle" }
32
+ {
33
+ "matches": [
34
+ "https://meet.google.com/*"
35
+ ],
36
+ "js": [
37
+ "content.js"
38
+ ],
39
+ "run_at": "document_idle"
40
+ }
10
41
  ],
11
42
  "web_accessible_resources": [
12
- { "resources": ["panel.html"], "matches": ["https://meet.google.com/*"] }
43
+ {
44
+ "resources": [
45
+ "panel.html"
46
+ ],
47
+ "matches": [
48
+ "https://meet.google.com/*"
49
+ ]
50
+ }
13
51
  ],
14
- "options_ui": { "page": "options.html", "open_in_tab": true }
52
+ "options_ui": {
53
+ "page": "options.html",
54
+ "open_in_tab": true
55
+ }
15
56
  }
@@ -0,0 +1,28 @@
1
+ /* popup.css — the popup's own page, shown only while PMOS is being looked for or when the page cannot be read. PMOS draws the rest. PMOS does not serve this file, so the colours are PMOS's tokens by value. */
2
+ :root {
3
+ color-scheme: light dark;
4
+ --background: oklch(1 0 0);
5
+ --foreground: oklch(0.148 0.004 228.8);
6
+ --muted-foreground: oklch(0.48 0.021 213.5);
7
+ --accent: oklch(0.488 0.243 264.376);
8
+ --border: oklch(0.9 0.006 214);
9
+ }
10
+ @media (prefers-color-scheme: dark) {
11
+ :root {
12
+ --background: oklch(0.148 0.004 228.8);
13
+ --foreground: oklch(0.987 0.002 197.1);
14
+ --muted-foreground: oklch(0.723 0.014 214.4);
15
+ --accent: oklch(0.623 0.214 259.815);
16
+ --border: oklch(0.3 0.01 214);
17
+ }
18
+ }
19
+ html, body { margin: 0; }
20
+ body { width: 360px; background: var(--background); color: var(--foreground); font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
21
+ .msg { display: grid; gap: 12px; margin: 0; padding: 14px; }
22
+ .msg .head { display: flex; gap: 8px; align-items: center; font-weight: 600; }
23
+ .msg .head b { color: var(--accent); font: 600 12px ui-monospace, "IBM Plex Mono", monospace; }
24
+ .msg p { margin: 0; }
25
+ .msg .dim { color: var(--muted-foreground); }
26
+ .msg button { justify-self: start; padding: 6px 12px; border: 1px solid var(--border); border-radius: 6px; background: transparent; color: var(--foreground); font: inherit; cursor: pointer; }
27
+ .msg a { color: var(--accent); text-underline-offset: 2px; }
28
+ iframe { display: block; width: 360px; height: 440px; border: 0; background: var(--background); }
@@ -0,0 +1,15 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>PMOS</title>
7
+ <link rel="stylesheet" href="popup.css">
8
+ </head>
9
+ <body>
10
+ <main id="msg" class="msg" role="status" aria-live="polite">Looking for PMOS.</main>
11
+ <script src="address.js"></script>
12
+ <script src="capture.js"></script>
13
+ <script src="popup.js"></script>
14
+ </body>
15
+ </html>