create-pmos 0.242.0 → 0.243.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 (43) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +2 -2
  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/payload/state/log.md +1 -1
  7. package/workspace/c3-report-design-real-test.js +13 -2
  8. package/workspace/c3-report-design-test.js +25 -3
  9. package/workspace/c3-report-structure-real-test.js +38 -1
  10. package/workspace/c3-report-structure-test.js +21 -6
  11. package/workspace/guide/console-calendar.md +2 -2
  12. package/workspace/guide/notes.md +5 -1
  13. package/workspace/guide/report-structure.md +3 -3
  14. package/workspace/guide/reports-page.md +3 -1
  15. package/workspace/guide/reports-set-up.md +5 -1
  16. package/workspace/lib/headless.js +7 -2
  17. package/workspace/lib/meeting-replay.js +15 -1
  18. package/workspace/lib/report-structure.js +49 -5
  19. package/workspace/lib/routes-report-setups.js +16 -2
  20. package/workspace/lib/routes-report-structure.js +176 -5
  21. package/workspace/lib/routes-reports.js +10 -0
  22. package/workspace/package-lock.json +2 -2
  23. package/workspace/package.json +1 -1
  24. package/workspace/public/pm/c3-audience-link.js +21 -0
  25. package/workspace/public/pm/c3-audience.js +46 -0
  26. package/workspace/public/pm/c3-boot.js +2 -1
  27. package/workspace/public/pm/c3-material.js +11 -1
  28. package/workspace/public/pm/c3-replay.css +6 -0
  29. package/workspace/public/pm/c3-replay.js +19 -3
  30. package/workspace/public/pm/c3-report-deck.css +5 -0
  31. package/workspace/public/pm/c3-report-deck.js +41 -5
  32. package/workspace/public/pm/c3-report-setup.css +5 -0
  33. package/workspace/public/pm/c3-report-setup.js +20 -2
  34. package/workspace/public/pm/c3-report-store.js +24 -2
  35. package/workspace/public/pm/c3-report-structure.css +4 -0
  36. package/workspace/public/pm/c3-report-structure.js +26 -8
  37. package/workspace/public/pm/c3-shell.js +2 -0
  38. package/workspace/public/pm/dest-reports.js +10 -1
  39. package/workspace/public/pm/report-view.js +23 -1
  40. package/workspace/public/pm/reports-copy.js +5 -0
  41. package/workspace/server.js +8 -1
  42. package/workspace/skills/report-structure-pull.skill +45 -0
  43. package/workspace/test-lists/report-structure-gaps.json +5 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.242.0",
3
+ "version": "0.243.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@f07c531
2
- assembled: 2026-10-14
1
+ kernel-source: pmos@ad62a21
2
+ assembled: 2026-10-15
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-14T15:23:59Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-15T01:32:11Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-10-14T15:23:59Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-15T01:32:11Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-10-14T15:23:59Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-15T01:32:11Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  Newest first, ISO dates.
4
4
 
5
- ## 2026-10-14
5
+ ## 2026-10-15
6
6
 
7
7
  - Adopted OKF v0.2 (D73).
@@ -9,7 +9,7 @@
9
9
  * frame shows the render; a point-and-say marker goes with the second turn and lands D2; Compare two; This is it; the Design brief
10
10
  * cycle Draft now on the report: the cycle hook renders the cycle to match the saved design
11
11
  * deck the Read tab shows the deck from /api/report-design/cycle-html in frames with sandbox exactly "allow-same-origin"; a source
12
- * popover; a speaker note and a pin are saved; the pin is in the Edit log; Present answers ArrowRight and Escape
12
+ * popover; a speaker note and a pin are saved; the pin is in the Edit log; Present answers ArrowRight and Escape; Audience view opens a window of its own that follows the presenter and closes with it
13
13
  * No page error and no failed request may be raised. Synthetic repositories only; the stub is the only "model"; nothing reaches the network. */
14
14
  const fs = require('fs');
15
15
  const path = require('path');
@@ -244,7 +244,18 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
244
244
  ok('Present opens the presenter view with the note of the slide', /1 \/ 4/.test(await text('[data-rd-pn]')) && /Open with the release/.test(await text('[data-rd-ovnote]')));
245
245
  await page.keyboard.press('ArrowRight');
246
246
  ok('ArrowRight goes to the next slide', !!(await until(async () => /2 \/ 4/.test(await text('[data-rd-pn]')))));
247
- await page.keyboard.press('Escape');
247
+ /* Audience view: a window of its own, slides only, that follows the presenter and closes with it (initiative meeting-material-replay-gaps) */
248
+ const popening = page.waitForEvent('popup');
249
+ await page.click('[data-rd-ovact="aud"]');
250
+ const pop = await popening;
251
+ await pop.waitForSelector('[data-rd-ov].rd-aud [data-rd-ovslide] iframe', { timeout: 30000 });
252
+ ok('Audience view opens a window of its own with the slide the presenter is on, no notes and no toolbar', /2 \/ 4/.test(await pop.textContent('[data-rd-count]')) && !(await pop.$('[data-rd-ovnote]')) && !(await pop.isVisible('.rp-deck__bar')) && /^PMOS · Audience view · /.test(await pop.title()) && !!(await page.$('[data-rd-ov].rd-pres')));
253
+ await page.bringToFront(); await page.keyboard.press('ArrowRight');
254
+ ok('the audience window follows the presenter to the next slide', !!(await until(async () => /3 \/ 4/.test((await pop.textContent('[data-rd-count]')) || ''))));
255
+ await pop.bringToFront(); await pop.keyboard.press('ArrowLeft');
256
+ ok('a key in the audience window moves the presenter view back', !!(await until(async () => /2 \/ 4/.test(await text('[data-rd-pn]')))));
257
+ await page.bringToFront(); await page.keyboard.press('Escape');
258
+ ok('closing the presenter view closes the audience window', !!(await until(async () => pop.isClosed())));
248
259
  ok('Escape closes the view and leaves the document open', !!(await until(async () => !(await page.$('[data-rd-ov]')))) && !!(await page.$('[data-rd-big]')));
249
260
 
250
261
  await sleep(400);
@@ -12,7 +12,7 @@
12
12
  * Differs from the design with Keep for this cycle, Make it part of the design, Match the design and Show the slide; Matches the design
13
13
  * notes speaker notes save and carry forward (Keep, Clear); pins: yours blue, PMOS dashed purple, Keep the note, a new pin, the Edit log,
14
14
  * Edit this opens the Edit tab at its section
15
- * present the presenter view (slide, note, next, timer, n / N, Jump to) and the audience view: arrows, Page keys, Space, Home, End,
15
+ * present the presenter view (slide, note, next, timer, n / N, Jump to) and the audience view (a window of its own that follows the presenter; the full-screen view in the page when the window is blocked): arrows, Page keys, Space, Home, End,
16
16
  * swipe, Escape
17
17
  * print Print / PDF writes the slides only: no speaker note and no pin; Download .pptx reads the existing export
18
18
  * document a report designed as a document reads as an interactive document (pmos-sec) with its notes keyed by section
@@ -331,7 +331,24 @@ const DOC = 'planning/reports/rs1/2026-10-21.md';
331
331
  await page.keyboard.press('End'); ok('End goes to the last slide', !!(await until(async () => /11 \/ 11/.test(await pn()))));
332
332
  await page.keyboard.press('Home'); ok('Home goes to the first slide', !!(await until(async () => /1 \/ 11/.test(await pn()))));
333
333
  await page.click('[data-rd-jump="4"]'); ok('Jump to goes to that slide', !!(await until(async () => /5 \/ 11/.test(await pn()))));
334
+ /* Audience view opens a window of its own (initiative meeting-material-replay-gaps). This page is in demo mode, whose sample state lives in this page's memory, so the new window
335
+ * cannot read the deck here: it says so. What the window shows, follows and closes with is tested against a real deck in pm-c3-meeting-material-test.js. */
336
+ const popening = page.waitForEvent('popup');
334
337
  await page.click('[data-rd-ovact="aud"]');
338
+ const pop = await popening;
339
+ await pop.waitForSelector('[data-aud="message"]', { timeout: 30000 });
340
+ const pu = new URL(pop.url());
341
+ ok('Audience view opens a window of its own: the audience page, the report path, the slide the presenter is on, and a title that names the deck',
342
+ pu.pathname === '/c3.html' && pu.searchParams.get('audience') === '1' && /rs1\/[\d-]+\.md$/.test(pu.searchParams.get('path') || '') && pu.searchParams.get('start') === '4' && /^PMOS · Audience view · /.test(await pop.title()) && !!(await page.$('[data-rd-ov]')), pop.url());
343
+ ok('the presenter view stays on this page while the window is open', !!(await page.$('[data-rd-ov].rd-pres')));
344
+ await page.bringToFront(); await page.keyboard.press('Escape');
345
+ ok('closing the presenter view closes the audience window', !!(await until(async () => pop.isClosed())) && !(await page.$('[data-rd-ov]')));
346
+ /* a browser that blocks the window: a notice that says so, and the full-screen view in this window */
347
+ await page.evaluate(() => { window.__open = window.open; window.open = () => null; });
348
+ await page.click('[data-rd-act="present"]'); await page.waitForSelector('[data-rd-ov]');
349
+ await page.click('[data-rd-ovact="aud"]');
350
+ ok('a blocked window says so and offers the full-screen view here', !!(await until(async () => /The audience window was blocked/.test(await text('[data-c3-toast]') || ''))) && /Full-screen view here/.test(await text('[data-c3-toast]')));
351
+ await page.click('[data-c3-toast-action]');
335
352
  ok('Audience view shows only the slide and n / N', !!(await until(() => page.$('[data-rd-ov].rd-aud, [data-rd-ov] [data-rd-count]'))) && !(await page.$('[data-rd-ovnote]')) && /5 \/ 11/.test(await text('[data-rd-count]')));
336
353
  await shot(page, 'deck-audience-1440');
337
354
  await page.keyboard.press(' '); ok('Space goes on', !!(await until(async () => /6 \/ 11/.test(await text('[data-rd-count]')))));
@@ -339,9 +356,14 @@ const DOC = 'planning/reports/rs1/2026-10-21.md';
339
356
  ok('a swipe to the left goes on', !!(await until(async () => /7 \/ 11/.test(await text('[data-rd-count]')))));
340
357
  await page.keyboard.press('Escape');
341
358
  ok('Escape closes the view and leaves the document open', !!(await until(async () => !(await page.$('[data-rd-ov]')))) && !!(await page.$('[data-rd-big]')));
359
+ await page.evaluate(() => { window.open = window.__open; });
360
+ const popening2 = page.waitForEvent('popup');
342
361
  await page.click('[data-rd-act="audience"]');
343
- ok('Audience view opens straight from the bar', !!(await until(() => page.$('[data-rd-ov] [data-rd-count]'))));
344
- await page.keyboard.press('Escape'); await until(async () => !(await page.$('[data-rd-ov]')));
362
+ const pop2 = await popening2;
363
+ await pop2.waitForSelector('[data-aud="message"]', { timeout: 30000 });
364
+ ok('Audience view opens straight from the bar, in a window of its own, and not as an overlay on this page', /audience=1/.test(pop2.url()) && !(await page.$('[data-rd-ov]')));
365
+ await pop2.close();
366
+ ok('the audience window can be closed', !!(await until(async () => pop2.isClosed())));
345
367
 
346
368
  /* print and download */
347
369
  await page.evaluate(() => { window.__printed = []; window.__printCalls = 0; window.open = () => ({ document: { open() {}, write(h) { window.__printed.push(h); }, close() {} }, focus() {}, print() { window.__printCalls++; }, close() {}, addEventListener() {}, onload: null }); });
@@ -12,6 +12,8 @@
12
12
  * source a Chrome source is added with the check stubbed to pass: the check view shows its steps
13
13
  * accept Use this structure: the setup sheet shows the saved structure, brief.md is on disk, charts.jsonl holds the new charts
14
14
  * charts the Charts tab lists a data chart with its source and Used in naming the report
15
+ * pull Test pull reads a source into its row (Reading…, then Pulled), adds nothing to the conversation and fills the chart's TEST PULL
16
+ * drafting a report row while a session drafts (Drafting…), when the file lands (Draft ready), and when it waits for a check
15
17
  * No page error and no failed request may be raised. Synthetic repositories only; the stub is the only "model"; nothing reaches the network. */
16
18
  const fs = require('fs');
17
19
  const path = require('path');
@@ -47,6 +49,14 @@ let input = ''; process.stdin.on('data', (d) => { input += d; }); process.stdin.
47
49
  const line = (k) => { const m = new RegExp('^- ' + k + ': (.*)$', 'm').exec(input); return m ? m[1].trim() : null; };
48
50
  const mode = line('Mode'); const out = line('Output path'); const turn = line('Turn') || '';
49
51
  const done = (o) => { process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', is_error: false, result: typeof o === 'string' ? o : JSON.stringify(o), num_turns: 2, duration_ms: 500, permission_denials: [] })); process.exit(0); };
52
+ const pullSrc = /^- Source: ([a-z0-9-]+) /m.exec(input);
53
+ if (pullSrc && out) { // a Test pull: one source, one result file (initiative report-structure-gaps)
54
+ const vm = /^- Version: S(\\d+) \\((.*)\\)$/m.exec(input);
55
+ const plan = JSON.parse(fs.readFileSync(vm[2], 'utf8'));
56
+ const mine = plan.charts.filter((c) => c.source === pullSrc[1]);
57
+ fs.writeFileSync(out, JSON.stringify({ source: pullSrc[1], n: Number(vm[1]), period: '2026-09', reached: true, reason: '', values: mine.map((c, i) => ({ chart: c.id, value: i ? '3' : '48,210', was: '52,930', delta: '-8.9%', flag: !i, note: '' })) }));
58
+ return setTimeout(() => done({ ok: true, note: mine.length + ' charts' }), 1200);
59
+ }
50
60
  if (!mode || !out) return done('Drafted.');
51
61
  const base = () => ({
52
62
  purpose: { readers: 'Leadership', decision: 'Where to invest next quarter', scope: 'The app', cadence: 'monthly', questions: ['Are we growing?'] },
@@ -130,7 +140,7 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
130
140
  ok('the set-up row has Shape the report', !!(await page.$('[data-rs-reg="' + id + '"] [data-rs-act="shape"]')));
131
141
  await page.click('[data-rs-reg="' + id + '"] [data-rs-act="shape"]');
132
142
  await page.waitForSelector('[data-st-studio]');
133
- ok('the Structure step opens with nothing saved yet and the Use button waiting', /Not saved yet/.test(await text('[data-st-studio]')) && await page.$eval('[data-st-act="save"]', (b) => b.disabled));
143
+ ok('the Structure step opens with nothing saved yet and the Use button waiting', !!(await until(async () => /Not saved yet/.test(await text('[data-st-studio]')))) && await page.$eval('[data-st-act="save"]', (b) => b.disabled));
134
144
 
135
145
  /* ── a purpose and the first turn: S1 ── */
136
146
  await page.fill('[data-st-in]', 'Monthly product performance for leadership, numbers from Google Analytics.');
@@ -184,6 +194,18 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
184
194
  ok('what it could not get names the Chrome source', /Looker Studio/.test(await text('[data-st-dry-missing]')) && /Needs Chrome/.test(await text('[data-st-dry-missing]')));
185
195
  await page.click('[data-st-view="plan"]');
186
196
 
197
+ /* ── Test pull: one source read into its row, not a turn (report-structure-gaps) ── */
198
+ const logLen = async () => (await call('GET', `/api/report-structure?ws=${ws}&id=${id}`)).body.structure.log.length;
199
+ const logBefore = await logLen();
200
+ await page.waitForSelector('[data-st-src="ga"] [data-st-act="test"]');
201
+ await page.click('[data-st-src="ga"] [data-st-act="test"]');
202
+ ok('Test pull shows Reading… on the source row while the read runs', !!(await until(() => page.$('[data-st-src="ga"] [data-st-pulling]'), 8000, 50)));
203
+ ok('the row then says Pulled with the period and the charts that returned a number', !!(await until(() => page.$('[data-st-src="ga"] [data-st-pulled="ok"]'), 30000, 100)) && /2026-09/.test(await text('[data-st-src="ga"]')) && /1 of 1 chart returned a number/.test(await text('[data-st-src="ga"]')), await text('[data-st-src="ga"]'));
204
+ ok('the pull added no line to the conversation', (await logLen()) === logBefore);
205
+ await page.click('[data-st-chart="users"] [data-st-act="chart"]');
206
+ ok('the chart row shows the pulled value, its source and when', !!(await until(async () => /48,210/.test(await text('[data-st-testpull]')))) && /Pulled from Google Analytics at \d\d:\d\d, 2026-09/.test(await text('[data-st-pulled-from]')), await text('[data-st-testpull]'));
207
+ ok('the pull is on disk as structure/pull/ga.json', fs.existsSync(path.join(P.pull, 'ga.json')));
208
+
187
209
  /* ── Use this structure ── */
188
210
  await idle();
189
211
  await page.click('[data-st-act="save"]');
@@ -205,6 +227,21 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
205
227
  ok('the Charts tab lists a data chart beside the built-in ones', !!(await page.$('[data-rp3-lib] [data-home="north"]')) && /Data chart/.test(await text('[data-rp3-data-chart="' + newUsersId + '"]')));
206
228
  ok('the data chart shows its source and Used in naming the report', /Google Analytics/.test(await text('[data-rp3-source="' + newUsersId + '"]')) && /Used in: .*MySPH app performance/.test(await text('[data-rp3-data-chart="' + newUsersId + '"] [data-rp3-used]')), await text('[data-rp3-data-chart="' + newUsersId + '"]'));
207
229
 
230
+ /* ── a report whose draft a session is making (report-structure-gaps) ── */
231
+ const dFile = path.join(P.dir, 'drafting.json');
232
+ fs.writeFileSync(dFile, JSON.stringify({ since: new Date().toISOString(), sched: null, session: null, saved: 3 }) + '\n');
233
+ await goReports();
234
+ const row = '[data-rs-reg="' + id + '"]';
235
+ ok('the report row says Drafting… with the saved structure it follows, and its button is disabled', (await page.getAttribute(row, 'data-rs-draft')) === 'drafting' && /Following saved structure S3/.test(await text(row + ' [data-rs-drafting]')) && /Drafting…/.test(await text(row + ' .acts'))
236
+ && await page.isDisabled(row + ' [data-rs-act="draft-now"]'), await text(row));
237
+ { const acts = await text(row + ' .acts'); ok('Draft it now and Open are not offered while it drafts', !/Draft it now/.test(acts) && !/\bOpen\b/.test(acts), acts); }
238
+ fs.writeFileSync(path.join(P.folder, '2026-11-03.md'), '---\ntype: Report\n---\n# draft\n');
239
+ ok('when the file lands the row returns to normal and a notice says the draft is ready, with Open', !!(await until(async () => !(await page.getAttribute(row, 'data-rs-draft')), 15000)) && !!(await until(async () => /Draft ready/.test(await text('[data-c3-toast]')), 8000)), await text('[data-c3-toast]'));
240
+ fs.writeFileSync(path.join(P.dir, 'hold.json'), JSON.stringify({ since: new Date().toISOString(), sched: null, sources: ['looker-studio-paid-acquisition'], saved: 3 }) + '\n');
241
+ await goReports();
242
+ ok('a draft held for a check says Waiting for a check and names the source', (await page.getAttribute(row, 'data-rs-draft')) === 'held' && /Waiting for a check/.test(await text(row)) && /Looker Studio/.test(await text(row + ' [data-rs-drafting]')), await text(row));
243
+ fs.unlinkSync(path.join(P.dir, 'hold.json'));
244
+
208
245
  await sleep(400);
209
246
  ok('no page error and no failed request', errors.length === 0, errors.slice(0, 6).join(' | '));
210
247
  return finish();
@@ -68,12 +68,14 @@ const plan1 = () => ({ purpose: { readers: 'Leadership: Gita Rahma and the produ
68
68
  const Q1 = [{ q: 'Who reads it, and what do they decide with it?', chips: ['Leadership: where to invest next quarter', 'Product team: what to fix next', 'Both'] }, { q: 'Anything that must be in it?', chips: ['Revenue', 'Releases this month', 'Nothing more'] }];
69
69
 
70
70
  function makeStub() {
71
- const S = { versions: [], plans: {}, log: [], current: null, saved: null, running: null, ticks: 0, dry: null, dryResult: null, checks: {}, posts: [], gets: [], chartsRead: 0, polls: 0, setupMade: 0, designStep: 0 };
71
+ const S = { versions: [], plans: {}, log: [], current: null, saved: null, running: null, ticks: 0, dry: null, dryResult: null, checks: {}, pulls: {}, pulling: [], pullTicks: 0, posts: [], gets: [], chartsRead: 0, polls: 0, setupMade: 0, designStep: 0 };
72
72
  const now = () => '2026-10-12T22:' + String(10 + S.versions.length * 3 + S.log.length).padStart(2, '0') + ':00.000Z';
73
73
  const land = (turn, plan, answer, extra) => { const n = S.versions.length + 1; S.versions.push({ n, at: now(), turn }); S.plans[n] = plan; S.current = n; S.log.push({ who: 'pmos', text: answer, at: now(), n, questions: [], options: [], ...(extra || {}) }); };
74
74
  const view = () => {
75
75
  if (S.running) { S.ticks++; if (S.ticks >= 2) { const r = S.running; S.running = null; r.done(); } }
76
- return { ok: true, setup: { id: SETUP.id, name: SETUP.name, cadence: 'monthly' }, structure: { running: !!S.running, job: S.running ? { kind: S.running.kind } : null, current: S.current, saved: S.saved, versions: S.versions.slice(), log: S.log.slice(),
76
+ if (S.pulling.length) { S.pullTicks++; if (S.pullTicks >= 2) { const src = S.pulling.shift(); S.pulls[src] = { source: src, n: S.current, period: '2026-09', reached: true, ok: true, reason: '', charts: 2, got: 1, at: now(),
77
+ values: [{ chart: 'new-users', value: '48,210', was: '52,930', delta: '-8.9%', flag: true, note: '' }] }; } }
78
+ return { ok: true, setup: { id: SETUP.id, name: SETUP.name, cadence: 'monthly' }, structure: { pulls: S.pulls, pulling: S.pulling.slice(), running: !!S.running, job: S.running ? { kind: S.running.kind } : null, current: S.current, saved: S.saved, versions: S.versions.slice(), log: S.log.slice(),
77
79
  plan: S.current ? S.plans[S.current] : null, dry: S.dry, checks: S.checks, errors: [] } };
78
80
  };
79
81
  const chromeChecks = (pass) => ({ source: 'looker', kind: 'chrome', at: now(), running: false, ok: pass, steps: [
@@ -102,6 +104,7 @@ function makeStub() {
102
104
  S.running = { kind: 'report-structure', done };
103
105
  return json({ ok: true, running: true });
104
106
  }
107
+ if (m === 'POST' && p === '/api/report-structure/pull') { if (S.pulling.includes(body.source)) return json({ ok: false, error: 'PMOS is already reading this source. Wait for the answer.' }, 409); S.pullTicks = 0; S.pulling.push(body.source); return json({ ok: true, running: true }); }
105
108
  if (m === 'POST' && p === '/api/report-structure/revert') { S.current = body.n; return json({ ok: true, structure: view().structure }); }
106
109
  if (m === 'POST' && p === '/api/report-structure/remove') { const pl = fixedPlan(S.current); if (body.kind === 'chart') { pl.charts = pl.charts.filter((c) => c.id !== body.ref); pl.outline.forEach((o) => { o.charts = o.charts.filter((c) => c !== body.ref); }); } else pl.sources = pl.sources.filter((s) => s.id !== body.ref); land('Remove ' + body.ref, pl, 'Removed ' + body.ref + '.'); return json({ ok: true, structure: view().structure }); }
107
110
  if (m === 'POST' && p === '/api/report-structure/source') {
@@ -169,14 +172,14 @@ function makeStub() {
169
172
  await goReports();
170
173
  await page.click('[data-rs-reg="' + SETUP.id + '"] [data-rs-act="shape"]');
171
174
  await page.waitForSelector('[data-st-studio]');
172
- ok('Shape the report on the row opens the same studio', /Not saved yet/.test(await text('[data-st-studio]')));
175
+ ok('Shape the report on the row opens the same studio', !!(await until(async () => /Not saved yet/.test(await text('[data-st-studio]')))));
173
176
  await closeAll();
174
177
  await page.click('[data-rs-reg="' + SETUP.id + '"] [data-rs-act="design"]');
175
178
  await page.waitForSelector('[data-rd-studio]');
176
179
  ok('the Design studio shows the step bar with 2 Design pressed', await page.getAttribute('[data-rd-step="design"]', 'aria-pressed') === 'true' && await page.getAttribute('[data-rd-step="structure"]', 'aria-pressed') === 'false');
177
180
  await page.click('[data-rd-step="structure"]');
178
181
  await page.waitForSelector('[data-st-studio]');
179
- ok('1 Structure in the Design studio opens the Structure step', !!(await page.$('[data-st-steps]')));
182
+ ok('1 Structure in the Design studio opens the Structure step', !!(await until(() => page.$('[data-st-steps]'))));
180
183
  await page.click('[data-st-step-btn="design"]');
181
184
  await page.waitForSelector('[data-rd-studio]');
182
185
  ok('2 Design in the Structure step opens the Design studio', true);
@@ -207,7 +210,15 @@ function makeStub() {
207
210
  ok('the actions fit the source: Open, Test pull, How to connect', /Open/.test(await text('[data-st-src="board"] .acts')) && /Test pull/.test(await text('[data-st-src="ga"] .acts')) && /How to connect/.test(await text('[data-st-src="bq"] .acts')));
208
211
  ok('a source says how many charts use it', /2 charts/.test(await text('[data-st-src="ga"]')) && !/ chart/.test(await text('[data-st-src="board"] .chips')));
209
212
  await page.click('[data-st-src="ga"] [data-st-act="test"]');
210
- ok('Test pull asks PMOS to read that source (one turn)', !!(await until(() => sent('/api/report-structure/turn').length === 3)) && /Test pull from Google Analytics 4/.test(sent('/api/report-structure/turn')[2].body.text));
213
+ ok('Test pull starts a read of that source, not a turn', !!(await until(() => sent('/api/report-structure/pull').length === 1)) && sent('/api/report-structure/pull')[0].body.source === 'ga' && sent('/api/report-structure/turn').length === 2, JSON.stringify(sent('/api/report-structure/pull')));
214
+ const reading = await until(() => page.$('[data-st-src="ga"] [data-st-pulling]'), 5000, 50);
215
+ const readingOff = reading ? await page.isDisabled('[data-st-src="ga"] [data-st-act="test"]') : null;
216
+ const readingNote = reading ? await text('[data-st-src="ga"] [data-st-pullnote]') : '';
217
+ ok('the source row says Reading… with a disabled button while it reads', !!reading && readingOff === true && /Read only/.test(readingNote), JSON.stringify({ reading: !!reading, readingOff, readingNote }));
218
+ const pulledEl = await until(() => page.$('[data-st-src="ga"] [data-st-pulled="ok"]'), 8000, 100);
219
+ const pulledRow = await text('[data-st-src="ga"]'); const pulledActs = await text('[data-st-src="ga"] .acts');
220
+ ok('the row then says Pulled with the period and how many charts returned a number, and offers Test pull again', !!pulledEl && /2026-09/.test(pulledRow) && /1 of 2 charts returned a number/.test(pulledRow) && /Test pull again/.test(pulledActs), JSON.stringify({ pulledEl: !!pulledEl, pulledRow, pulledActs, tags: await page.$$eval('[data-st-src="ga"] .tag', (a) => a.map((x) => x.className + '|' + x.getAttributeNames().join(',') + '|' + x.getAttribute('data-st-pulled') + '|' + x.textContent)), q: !!(await page.$('[data-st-pulled]')) }));
221
+ ok('the conversation got no line from the pull', !/Test pull from/.test(await text('.rd-dks-log')));
211
222
  await idle();
212
223
  await page.click('[data-st-src="bq"] [data-st-act="connect"]');
213
224
  ok('How to connect says where to connect it', /Settings, Connectors/.test(await text('[data-c3-toast]')));
@@ -221,7 +232,11 @@ function makeStub() {
221
232
  ok('a chart from Charts has Open in Charts, a new chart does not', !!(await page.$('[data-st-detail="new-users"] [data-st-act="opencharts"]')));
222
233
  await page.click('[data-st-chart="cpa"] [data-st-act="chart"]');
223
234
  ok('only one row is open and a new chart says it is saved in Charts when you use the structure', !!(await page.$('[data-st-detail="cpa"]')) && !(await page.$('[data-st-detail="new-users"]')) && /Saved in Charts when you use this structure/.test(await text('[data-st-used]')) && !(await page.$('[data-st-detail="cpa"] [data-st-act="opencharts"]')));
224
- ok('the test pull says it is not tried yet', /Not tried on this version/.test(await text('[data-st-testpull]')));
235
+ ok('a chart the pull did not return says it is not tried yet', /Not tried on this version/.test(await text('[data-st-testpull]')));
236
+ await page.click('[data-st-chart="new-users"] [data-st-act="chart"]');
237
+ ok('a chart the pull returned shows its value, comparison, flag, source and time', !!(await until(async () => /48,210/.test(await text('[data-st-testpull]')))) && /was 52,930/.test(await text('[data-st-testpull]')) && /-8\.9%/.test(await text('[data-st-testpull]')) && /Flagged/.test(await text('[data-st-testpull]'))
238
+ && /Pulled from Google Analytics 4: MySPH web \+ app at \d\d:\d\d, 2026-09\. Not saved in the report\./.test(await text('[data-st-pulled-from]')), await text('[data-st-testpull]'));
239
+ await page.click('[data-st-chart="cpa"] [data-st-act="chart"]');
225
240
  await shot(page, 'structure-logic-1440');
226
241
  await page.click('[data-st-detail="cpa"] [data-st-act="discuss"]');
227
242
  ok('Discuss this chart fills the box with the chart name and focuses it', (await page.inputValue('[data-st-in]')) === 'About Cost per new user: ' && await page.evaluate(() => document.activeElement && document.activeElement.hasAttribute('data-st-in')));
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "Guide: the Calendar tab of the console page"
4
4
  description: "The Calendar tab of the console page (/): Calendar and Notes, the five zooms, the highlights, the layer chips, the markers, the context of a meeting, free time, the tidy-up suggestions and the empty states."
5
- timestamp: 2026-10-14
5
+ timestamp: 2026-10-15
6
6
  ---
7
7
 
8
8
  # Guide: the Calendar tab of the console page
@@ -168,7 +168,7 @@ Press **Add material** to open the sheet:
168
168
 
169
169
  After you pick an item, choose **For** (this meeting, or every meeting in the series), **Topic** (one of the topics of the meeting, or the whole meeting) and **You** (**Present it** or **Only read it**, for a PMOS deck or a Google Slides link).
170
170
 
171
- In **Focus**, the material shows next to the links of the invite. An item for a topic comes up on the stage when the room is on that topic. When the room moves to another topic, the stage goes back to the first link. Press an item in the **Material** switch to keep it on the stage. A PMOS deck or report opens in PMOS. A PMOS deck has **Present**, which opens the presenter view with your speaker notes, and **Audience view**, which shows the slides only, full screen. Press it before you share your screen in the call. When nothing is linked, the Material part of Focus has **Add material**.
171
+ In **Focus**, the material shows next to the links of the invite. An item for a topic comes up on the stage when the room is on that topic. When the room moves to another topic, the stage goes back to the first link. Press an item in the **Material** switch to keep it on the stage. A PMOS deck or report opens in PMOS. A PMOS deck has **Present**, which opens the presenter view with your speaker notes, and **Audience view**, which opens a window of its own with the slides only. Drag that window to the screen or the projector that you share. In the presenter view, the window follows your slide, and a key in the window moves your slide. Closing the presenter view closes the window. If the browser blocks the window, a notice says so and **Full-screen view here** shows the slides full screen in this window. A Google Slides deck opens in its own tab, as before. When nothing is linked, the Material part of Focus has **Add material**.
172
172
 
173
173
  ### Meeting focus over Google Meet
174
174
 
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "Guide: notes"
4
4
  description: "How to take notes in a meeting: one note top to bottom, typed notes as timed lines, key points written each minute and labelled by Jev, delete a note; final notes written after Stop from the whole recording, with speakers and a quote for each item, decisions with their why and who, next steps with an open question, a discussion paragraph, and Wispr Flow's notes merged in and fetched by hand when the fetch fails; listen to your microphone and the meeting on this machine, see decisions, actions and questions caught as they are said, check them against the repository for conflicts, capture highlights during the meeting, create tasks from next steps, correct and tag lines, see what changed in your notes, replay the meeting, see screenshots of the shared screen beside the transcript, link a note to its meeting and its initiative, and get a summary."
5
- timestamp: 2026-10-14
5
+ timestamp: 2026-10-15
6
6
  ---
7
7
 
8
8
  # Guide: notes
@@ -211,8 +211,12 @@ Press **Replay** beside the player (in Calendar › Notes, or on the meeting's c
211
211
  - **Key points** lists the decisions, next steps and questions with the time each was said. A key point whose line is not found has no time and no play button. **Confirm all** makes a task of each next step on the note's initiative.
212
212
  - **Topics** shows the minutes each topic took and the first time it came up. **Conversation** shows the lines and PMOS's notes in time order. The line being played is marked and kept in view. Choose **All**, **Key points** or **PMOS**, or search for a word.
213
213
 
214
+ - A meeting that you stopped and started again has **2 recordings** (or more) beside its title. A hatched part of the track is a time when nothing was recorded, and a line under the track gives its times and length. A press on the hatched part, or playing across it, goes on from where the recording began again. The sound keeps one clock, so every line, mark and screen shot stays at its own time.
215
+
214
216
  The page reads only records on this machine: the note, the file Focus kept for the meeting, and the screen shots.
215
217
 
218
+ You can open the replay from a link. The link is the address of the console with `?replay=` and the id of the note, for example `/c3.html?replay=n-abcd-1234`. It works on the computer that holds the note. On another computer, or for a note that you deleted, the page says that the link does not match a note on this computer.
219
+
216
220
  ### Clean up the replay sound
217
221
 
218
222
  The replay can play a cleaned copy of the sound. The copy has less rumble and hiss, and an even loudness. It needs **ffmpeg** on your machine. To install it, run `brew install ffmpeg` in a terminal.
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "Shape what a report says: the Structure step"
4
4
  description: "How to decide with PMOS what a report answers, where each number comes from and how it is worked out, in the Structure step of the report studio, and how to try the plan on real data before you design it."
5
- timestamp: 2026-10-14
5
+ timestamp: 2026-10-15
6
6
  ---
7
7
 
8
8
  # Shape what a report says
@@ -40,8 +40,8 @@ The plan has these parts:
40
40
  - **Sources.** Where the numbers come from. They are in two groups.
41
41
  - **From PMOS.** A portfolio, board or initiative, a dashboard, a chart, a document, or a past report. A document gives PMOS context for its notes, never numbers. A past report gives last cycle's numbers. In the first cycle there is nothing to read.
42
42
  - **Outside PMOS.** A connector you have (for example Google Analytics through Supermetrics), a link, a file you upload each cycle, a person who gives a number each cycle, a page read in Chrome, or an app on your Mac.
43
- - Each source shows its state: **Reached**, **Not connected**, **First cycle: nothing to read** or **Needs you**. It also shows how it is read and how many charts use it. Press **Test pull** to ask PMOS to read it now. Press **Check it works** on a Chrome page or an app on your Mac. Press **How to connect** for a connector that is not connected. Press **Open** for a source in PMOS.
44
- - **Charts.** Every number is a chart: one question, its source, how it is worked out, what it is compared with, and when it is flagged. A chart that PMOS proposes is tagged **New chart**. A chart that already exists in Reports is tagged **From Charts**. Press a chart to see the question, where it is used, exactly what PMOS reads, why PMOS proposed it and its test value. Press **Discuss this chart** to put its name in the box. Press **Open in Charts** to see a chart from Charts. Press **Remove it** to make the next version without the chart.
43
+ - Each source shows its state: **Reached**, **Not connected**, **First cycle: nothing to read** or **Needs you**. It also shows how it is read and how many charts use it. Press **Test pull** to have PMOS read that one source now, for the last complete period. The row shows **Reading…**, then **Pulled** with the period and how many charts returned a number. If the source returns nothing, the row says **Returned nothing** and why. A test pull adds nothing to the conversation and does not make a new version. It works while PMOS makes a version. Press **Test pull again** to read it again. Press **Check it works** on a Chrome page or an app on your Mac. Press **How to connect** for a connector that is not connected. Press **Open** for a source in PMOS.
44
+ - **Charts.** Every number is a chart: one question, its source, how it is worked out, what it is compared with, and when it is flagged. A chart that PMOS proposes is tagged **New chart**. A chart that already exists in Reports is tagged **From Charts**. Press a chart to see the question, where it is used, exactly what PMOS reads, why PMOS proposed it and its test pull. After a test pull of its source, the test pull shows the value, what it was before, the change, a **Flagged** tag, where PMOS pulled it and when. A test pull is not saved in the report. Press **Discuss this chart** to put its name in the box. Press **Open in Charts** to see a chart from Charts. Press **Remove it** to make the next version without the chart.
45
45
  - **Outline.** The sections in order. Each section lists its charts and says if PMOS writes notes on them. **Headline**, **Needs your call** and **Sources** are in every report. You decide everything between them.
46
46
 
47
47
  Press **Other structures** to read the structures PMOS proposed side by side. The one PMOS recommends is first. Press **Use this instead** to put the choice in the box, then press **Make the next version**.
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "Reports: draft, read and issue"
4
4
  description: "How to draft a report, add your notes, issue it, and print or copy it. How the report schedules and the Today cards for reports work."
5
- timestamp: 2026-10-12
5
+ timestamp: 2026-10-15
6
6
  ---
7
7
 
8
8
  # Reports: draft, read and issue
@@ -31,6 +31,8 @@ The parts that this guide describes below, such as Draft now, Edit notes and the
31
31
  1. Press **Draft now**. The workspace writes the draft of the cadence you have selected, for example biweekly. A cadence is one kind of report: daily, weekly, biweekly or monthly.
32
32
  2. The new draft is selected. Every number in it comes from the repository's ledgers. The page never changes a number.
33
33
 
34
+ For a report with a saved structure, **Draft now** starts a session that follows the structure. The button shows **Drafting…** until the draft is ready, and a notice says when it is. A **Redraft**, and a draft at a slot you name, stay instant. A report with no saved structure is drafted at once.
35
+
34
36
  A second **Draft now** for the same slot is refused. Press **Redraft** to write the draft again. A Redraft keeps every note. It moves a note with its initiative, even when the order of the initiatives changed. A note of an initiative that left the window stays at the end, under "Notes kept from an earlier draft".
35
37
 
36
38
  To draft another cadence, open the menu with the three dots and choose **Draft now** with the name of the cadence.
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "Set up a report by asking, and the report finder"
4
4
  description: "How to ask PMOS to draft a report you send, how the report finder finds reports you already send, how to set one up, and how a cycle becomes a document you issue."
5
- timestamp: 2026-10-13
5
+ timestamp: 2026-10-15
6
6
  ---
7
7
 
8
8
  # Set up a report by asking, and the report finder
@@ -41,6 +41,10 @@ The most likely finding also shows on the Briefing as **Report found**. Press **
41
41
 
42
42
  - **Open** opens the current draft.
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
+ - A report with a saved structure is drafted by a session, as the schedule drafts it, so the draft follows the structure. The row shows **Drafting…** with the saved structure it follows. You can leave the page. A notice with **Open** appears when the draft is ready. Press **Watch the session** to see the session work. A second press while it drafts does nothing.
45
+ - If a Chrome page or an app on your Mac needs a check, the row shows **Waiting for a check** and names the source. The draft starts when the check passes, or after 4 hours without that source.
46
+ - If the drafting session ends before it writes a draft, the row says **Draft did not finish**. Press **Draft it now** to try again.
47
+ - A report with no saved structure is drafted at once, as before.
44
48
  - **All cycles** lists every cycle of the report.
45
49
  - **Pause** stops the drafts. **Resume** starts them again.
46
50
  - **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.
@@ -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' | 'report-structure' | 'report-structure-dry' | 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-structure-pull' | '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
@@ -90,7 +90,9 @@ const REPORT_SAMPLE_FETCH_ORIGINS = Object.freeze(['report-sample-fetch:']);
90
90
  // report-structure: a structure turn and a dry run on real data (origins `report-structure:<id>`, `report-structure-dry:<id>`).
91
91
  const REPORT_STRUCTURE_ORIGINS = Object.freeze(['report-structure:']);
92
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)
93
+ // report-structure-gaps: Test pull reads ONE source for the last complete period (origin `report-structure-pull:<id>:<source>`).
94
+ const REPORT_STRUCTURE_PULL_ORIGINS = Object.freeze(['report-structure-pull:']);
95
+ const WRITE_PATH_KINDS = Object.freeze(['report-design', 'report-sample-fetch', 'report-structure', 'report-structure-dry', 'report-structure-pull']); // kinds whose one writable file the caller names (argsFor writePaths)
94
96
 
95
97
  // What each kind may use. Read-only search is always allowed. The notes passes write their JSON (and rename the .tmp
96
98
  // file) in their notes folder, which --add-dir opens to them; the skills also run `pmos` verbs and repository scripts.
@@ -125,6 +127,8 @@ const TOOLS = Object.freeze({
125
127
  // 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
128
  'report-structure': Object.freeze(['Read', 'Glob', 'Grep']),
127
129
  'report-structure-dry': Object.freeze(['Read', 'Glob', 'Grep']),
130
+ // The pull run: the same read tools as the dry run. One source, one JSON file, no Bash, no web fetch, nothing that sends.
131
+ 'report-structure-pull': Object.freeze(['Read', 'Glob', 'Grep']),
128
132
  skill: Object.freeze(['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash(pmos *)', 'Bash(node *)',
129
133
  'Bash(git log *)', 'Bash(git status *)', 'Bash(git diff *)', 'Bash(ls *)', 'Bash(date *)']),
130
134
  });
@@ -152,6 +156,7 @@ function kindFor({ agent, origin, skill, env } = {}) {
152
156
  if (REPORT_SAMPLE_FETCH_ORIGINS.some((p) => o.startsWith(p))) return 'report-sample-fetch';
153
157
  // 'report-structure-dry:' is not a prefix of 'report-structure:' (the colon differs), so the two are told apart by their prefix alone
154
158
  if (REPORT_STRUCTURE_DRY_ORIGINS.some((p) => o.startsWith(p))) return 'report-structure-dry';
159
+ if (REPORT_STRUCTURE_PULL_ORIGINS.some((p) => o.startsWith(p))) return 'report-structure-pull';
155
160
  if (REPORT_STRUCTURE_ORIGINS.some((p) => o.startsWith(p))) return 'report-structure';
156
161
  if (skill && HEADLESS_SKILLS.includes(String(skill))) return 'skill';
157
162
  return null;
@@ -24,6 +24,8 @@
24
24
  * runs: [{ topic, n, from_ms, to_ms }]
25
25
  * notes: [{ id, t, cat, body, heard, why, src }] PMOS's notes with the time they were made
26
26
  * shots: [{ t, w, h, why?, text? }] kept shots only
27
+ * recordings: how many times the recording was started (1 for most notes)
28
+ * gaps: [{ from_ms, to_ms }] where the recording was off between two starts (empty when there was one)
27
29
  * aside_count }
28
30
  */
29
31
  const N = require('./notes.js');
@@ -154,6 +156,18 @@ function build({ note, event = null, stateLines = [], frames = [], audio = null,
154
156
  const topicRows = [...visited, ...fold.topics.filter((t) => !firstOf.has(t.id))].map((t) => ({
155
157
  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
158
  stretches: stretches(t.id), parked: t.state === 'parked', by: t.by || null }));
159
+ // A recording that was stopped and started again (a Pause then Listen, or a new Listen on the same meeting) has one stretch per start. The audio keeps one clock
160
+ // (lib/notes-audio.js places a late chunk at its own time and leaves the gap silent), so the replay needs only to show where the recording was off.
161
+ const sessions = (Array.isArray(n.sessions) ? n.sessions : []).filter((x) => x && Number.isFinite(Date.parse(x.from))).sort((a, b) => Date.parse(a.from) - Date.parse(b.from));
162
+ const gaps = [];
163
+ if (start != null) {
164
+ for (let i = 1; i < sessions.length; i++) {
165
+ const off = Date.parse(sessions[i - 1].to); const on = Date.parse(sessions[i].from);
166
+ if (!Number.isFinite(off) || !Number.isFinite(on) || on <= off) continue;
167
+ const from_ms = Math.max(0, Math.round(off - start)); const to_ms = Math.min(length, Math.round(on - start));
168
+ if (to_ms > from_ms) gaps.push({ from_ms, to_ms });
169
+ }
170
+ }
157
171
  const ev = event || {};
158
172
  const m = n.meeting || {};
159
173
  return {
@@ -162,7 +176,7 @@ function build({ note, event = null, stateLines = [], frames = [], audio = null,
162
176
  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
177
  attendees: [].concat(ev.attendees || ev.people || []).map((p) => (p && typeof p === 'object' ? p.name || p.id : p)).filter(Boolean).map(String) },
164
178
  audio: audio && audio.url ? { url: audio.url, ms: finite(audio.ms) ? audio.ms : null } : null,
165
- start_ms: start, length_ms: length,
179
+ start_ms: start, length_ms: length, recordings: Math.max(1, sessions.length), gaps,
166
180
  lines, keys, topics: topicRows, runs: runs.map((r) => ({ ...r, n: numOf.get(r.topic) || null })), notes: pmos, shots, aside_count: asideCount,
167
181
  };
168
182
  }
@@ -36,7 +36,7 @@ const crypto = require('crypto');
36
36
  const RD = require('./report-draft.js');
37
37
  const CS = require('./charts-store.js');
38
38
 
39
- const EVENTS = Object.freeze(['describe', 'turn', 'version', 'accept', 'revert', 'source', 'remove', 'dry', 'check', 'error']);
39
+ const EVENTS = Object.freeze(['describe', 'turn', 'version', 'accept', 'revert', 'source', 'remove', 'dry', 'pull', 'check', 'error']);
40
40
  const PMOS_KINDS = Object.freeze(['pmos-level', 'dashboard', 'chart', 'document', 'past-report']);
41
41
  const OUTSIDE_KINDS = Object.freeze(['connector', 'link', 'upload', 'person', 'chrome', 'computer']);
42
42
  const KINDS = Object.freeze([...PMOS_KINDS, ...OUTSIDE_KINDS]);
@@ -79,7 +79,7 @@ function paths(root, id) {
79
79
  const folder = path.join(abs, rel);
80
80
  const dir = path.join(folder, 'structure');
81
81
  return { abs, rel, folder, dir, relDir: `${rel}/structure`, events: path.join(dir, 'structure.jsonl'), versions: path.join(dir, 'versions'), pending: path.join(dir, 'pending'),
82
- dry: path.join(dir, 'dry'), checks: path.join(dir, 'checks'), checksPending: path.join(dir, 'checks', 'pending'), brief: path.join(dir, 'brief.md'), briefRel: `${rel}/structure/brief.md` };
82
+ dry: path.join(dir, 'dry'), pull: path.join(dir, 'pull'), pullPending: path.join(dir, 'pull', 'pending'), checks: path.join(dir, 'checks'), checksPending: path.join(dir, 'checks', 'pending'), brief: path.join(dir, 'brief.md'), briefRel: `${rel}/structure/brief.md` };
83
83
  }
84
84
 
85
85
  // ── the one writer and the fold ───────────────────────────────────────────────────────────────────────────
@@ -440,6 +440,50 @@ function landVersion(root, id, plan, { turn, answer, questions, options, from, b
440
440
  return { ok: true, n, plan: v.plan };
441
441
  }
442
442
 
443
+ // ── test pull (report-structure-gaps): ONE source read for the last complete period ──────────────────────────
444
+ /** What a pull job wrote for one source of a plan. → { ok, pull } | refusal. `reached` false is a valid answer: the source returned nothing. */
445
+ function validatePull(data, plan, sourceId) {
446
+ if (!isObj(data)) return refuse(422, 'the pull result must be a JSON object');
447
+ const src = listOf(plan && plan.sources).find((s) => s.id === sourceId);
448
+ if (!src) return refuse(422, 'the source is not in the plan');
449
+ const chartIds = new Set(listOf(plan.charts).filter((c) => c.source === sourceId).map((c) => c.id));
450
+ if (str(data.source) !== sourceId) return refuse(422, 'source must be the source that was pulled');
451
+ if (!Number.isInteger(data.n) || data.n < 1) return refuse(422, 'n must be the version number');
452
+ const per = badText(data.period, 'period', { max: 40 });
453
+ if (per || !data.period.trim()) return refuse(422, per || 'period is required');
454
+ if (typeof data.reached !== 'boolean') return refuse(422, 'reached must be true or false');
455
+ const rs = badText(data.reason === undefined ? '' : data.reason, 'reason', { max: 300, allowEmpty: true });
456
+ if (rs) return refuse(422, rs);
457
+ const values = data.values === undefined ? [] : data.values;
458
+ if (!Array.isArray(values) || values.length > LIMITS.charts) return refuse(422, `values is a list of at most ${LIMITS.charts}`);
459
+ const out = [];
460
+ for (const [i, v] of values.entries()) {
461
+ const w = `values[${i}]`;
462
+ if (!isObj(v)) return refuse(422, `${w} must be an object`);
463
+ if (!chartIds.has(str(v.chart))) return refuse(422, `${w}.chart ${JSON.stringify(str(v.chart)).slice(0, 40)} is not a chart of the plan that uses this source`);
464
+ const c = {};
465
+ for (const k of ['value', 'was', 'delta', 'note']) { const r = textField(v, k, w); if (r.error) return refuse(422, r.error); c[k] = r.v; }
466
+ if (v.flag !== undefined && typeof v.flag !== 'boolean') return refuse(422, `${w}.flag must be true or false`);
467
+ out.push({ chart: v.chart, value: c.value, was: c.was, delta: c.delta, flag: v.flag === true, note: c.note });
468
+ }
469
+ const reached = data.reached === true;
470
+ if (!reached && out.length) return refuse(422, 'a source that was not reached has no values');
471
+ const got = out.filter((v) => v.value).length;
472
+ const reason = str(data.reason).trim() || (reached && !got ? 'The source answered, but no chart returned a number.' : '');
473
+ return { ok: true, pull: { source: sourceId, n: data.n, period: data.period.trim(), reached, ok: reached && got > 0, reason, values: out, charts: chartIds.size, got } };
474
+ }
475
+ function writePull(root, id, pull) {
476
+ if (!SLUG_RE.test(String(pull.source))) throw new Error('report-structure: bad source id');
477
+ writeAtomic(path.join(paths(root, id).pull, `${pull.source}.json`), JSON.stringify(pull, null, 2) + '\n');
478
+ }
479
+ function readPull(root, id, source) { return SLUG_RE.test(String(source)) ? readJson(path.join(paths(root, id).pull, `${source}.json`)) : null; }
480
+ function readPulls(root, id) {
481
+ const out = {};
482
+ let names = []; try { names = fs.readdirSync(paths(root, id).pull); } catch { return out; }
483
+ for (const f of names) { const m = /^([a-z0-9][a-z0-9-]*)\.json$/.exec(f); if (m && SLUG_RE.test(m[1])) { const o = readPull(root, id, m[1]); if (o) out[m[1]] = o; } }
484
+ return out;
485
+ }
486
+
443
487
  // ── the GET answer ────────────────────────────────────────────────────────────────────────────────────────
444
488
  /** Chrome and computer sources show what their last check found: a passing check is Reached, a failing one Needs you. */
445
489
  function overlay(plan, checks) {
@@ -450,7 +494,7 @@ function overlay(plan, checks) {
450
494
  return { ...s, state: c.ok ? 'reached' : 'needs-you' };
451
495
  }) };
452
496
  }
453
- function structureAnswer(root, id, { running } = {}) {
497
+ function structureAnswer(root, id, { running, pulling } = {}) {
454
498
  const st = stateOf(root, id);
455
499
  const checks = readChecks(root, id);
456
500
  const versions = [...st.versions.values()].sort((a, b) => a.n - b.n).map((v) => ({ n: v.n, at: v.at, turn: v.turn }));
@@ -458,7 +502,7 @@ function structureAnswer(root, id, { running } = {}) {
458
502
  running: !!running, job: running ? { since: running.since, turn: running.turn, kind: running.kind } : null,
459
503
  current: st.current, saved: st.saved ? st.saved.n : null, versions, log: st.log,
460
504
  plan: st.current != null ? overlay(readVersion(root, id, st.current), checks) : null,
461
- dry: st.dry, checks, errors: st.errors.map((e) => ({ text: e.text, at: e.at })),
505
+ dry: st.dry, checks, pulls: readPulls(root, id), pulling: Array.isArray(pulling) ? pulling : [], errors: st.errors.map((e) => ({ text: e.text, at: e.at })),
462
506
  };
463
507
  }
464
508
  /** What the setups GET carries for one report: { saved, at, charts, sections } of the accepted version, else null. */
@@ -512,4 +556,4 @@ function writeBrief(root, id, setup, { now } = {}) {
512
556
 
513
557
  module.exports = { EVENTS, KINDS, PMOS_KINDS, OUTSIDE_KINDS, STATES, FIXED, LIMITS, CHECK_STEPS, CHECK_STATES, CADENCE_RE, SLUG_RE, RUN_RE,
514
558
  paths, append, readEvents, fold, stateOf, nextN, badText, validatePlan, validateSource, validateDry, validateCheck, cleanQuestions, cleanOptions, addSourceTo, removeFrom,
515
- landVersion, readVersion, rewriteVersion, writeDry, readDry, writeCheck, readCheck, readChecks, writeBrief, structureAnswer, savedOf, overlay, defaultState, newRun };
559
+ landVersion, readVersion, rewriteVersion, writeDry, readDry, validatePull, writePull, readPull, readPulls, writeCheck, readCheck, readChecks, writeBrief, structureAnswer, savedOf, overlay, defaultState, newRun };