create-pmos 0.241.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 (69) 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 +15 -1
  12. package/workspace/guide/notes.md +26 -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-material.js +151 -0
  18. package/workspace/lib/meeting-replay.js +184 -0
  19. package/workspace/lib/notes-screen.js +128 -8
  20. package/workspace/lib/report-structure.js +49 -5
  21. package/workspace/lib/route-modules.js +2 -0
  22. package/workspace/lib/routes-meeting-focus.js +5 -3
  23. package/workspace/lib/routes-meeting-material.js +143 -0
  24. package/workspace/lib/routes-meeting-replay.js +75 -0
  25. package/workspace/lib/routes-notes.js +37 -4
  26. package/workspace/lib/routes-report-setups.js +16 -2
  27. package/workspace/lib/routes-report-structure.js +176 -5
  28. package/workspace/lib/routes-reports.js +10 -0
  29. package/workspace/package-lock.json +2 -2
  30. package/workspace/package.json +1 -1
  31. package/workspace/public/pm/c3-audience-link.js +21 -0
  32. package/workspace/public/pm/c3-audience.js +46 -0
  33. package/workspace/public/pm/c3-boot.js +2 -1
  34. package/workspace/public/pm/c3-calendar.js +15 -3
  35. package/workspace/public/pm/c3-demo-material.js +58 -0
  36. package/workspace/public/pm/c3-demo.js +5 -1
  37. package/workspace/public/pm/c3-focus.js +83 -16
  38. package/workspace/public/pm/c3-listen.css +10 -0
  39. package/workspace/public/pm/c3-material.css +22 -0
  40. package/workspace/public/pm/c3-material.js +321 -0
  41. package/workspace/public/pm/c3-notes.js +12 -4
  42. package/workspace/public/pm/c3-replay.css +133 -0
  43. package/workspace/public/pm/c3-replay.js +429 -0
  44. package/workspace/public/pm/c3-report-deck.css +5 -0
  45. package/workspace/public/pm/c3-report-deck.js +41 -5
  46. package/workspace/public/pm/c3-report-setup.css +5 -0
  47. package/workspace/public/pm/c3-report-setup.js +20 -2
  48. package/workspace/public/pm/c3-report-store.js +24 -2
  49. package/workspace/public/pm/c3-report-structure.css +4 -0
  50. package/workspace/public/pm/c3-report-structure.js +26 -8
  51. package/workspace/public/pm/c3-shell.js +2 -0
  52. package/workspace/public/pm/dest-notes.js +4 -2
  53. package/workspace/public/pm/dest-reports.js +10 -1
  54. package/workspace/public/pm/listen-sheet.js +94 -0
  55. package/workspace/public/pm/listen-sources.js +82 -0
  56. package/workspace/public/pm/notes-aside.js +62 -0
  57. package/workspace/public/pm/notes-screen-capture.js +23 -10
  58. package/workspace/public/pm/notes-screen-view.js +18 -5
  59. package/workspace/public/pm/notes-shots.js +55 -0
  60. package/workspace/public/pm/report-view.js +23 -1
  61. package/workspace/public/pm/reports-copy.js +5 -0
  62. package/workspace/public/pm/shell-recording.js +10 -2
  63. package/workspace/public/pm/view-notes.js +48 -8
  64. package/workspace/server.js +8 -1
  65. package/workspace/skills/report-structure-pull.skill +45 -0
  66. package/workspace/test-lists/meeting-material.json +6 -0
  67. package/workspace/test-lists/meeting-replay.json +6 -0
  68. package/workspace/test-lists/mm-capture.json +15 -0
  69. package/workspace/test-lists/report-structure-gaps.json +5 -0
@@ -12,6 +12,8 @@
12
12
  * GET /api/report-structure/check?ws&id&source → { ok, check }
13
13
  * POST /api/report-structure/dry { ws, id, n? } → { ok, running: true } starts ONE report-structure-dry job
14
14
  * GET /api/report-structure/dry?ws&id&run → { ok, dry }
15
+ * POST /api/report-structure/pull { ws, id, source } → { ok, running: true } Test pull: ONE report-structure-pull job reads ONE source; never a turn, never a version
16
+ * GET /api/report-structure/pull?ws&id&source → { ok, pull, running } the last pull of the source (structure/pull/<source>.json)
15
17
  * GET /api/charts?ws → { ok, charts: [chart + { users }] }
16
18
  * GET /api/charts/one?ws&id → { ok, chart }
17
19
  *
@@ -48,12 +50,15 @@ const headless = require('./headless.js');
48
50
  const text = (v) => (typeof v === 'string' ? v : '');
49
51
  const bad = (status, error) => ({ status, body: { ok: false, error } });
50
52
  const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
51
- const POSTS = ['turn', 'accept', 'revert', 'source', 'remove', 'check', 'dry'];
53
+ const POSTS = ['turn', 'accept', 'revert', 'source', 'remove', 'check', 'dry', 'pull'];
52
54
  const HOLD_MS = 4 * 3600 * 1000;
53
55
  const CHECK_MS = 10 * 60 * 1000;
56
+ /** How long a check session may take. PMOS_REPORT_CHECK_MS shortens it for the suites. */
57
+ const checkMs = () => (Number(process.env.PMOS_REPORT_CHECK_MS) > 0 ? Number(process.env.PMOS_REPORT_CHECK_MS) : CHECK_MS);
54
58
  const STEP_FLAGS_FALLBACK = [{ flag: '--chrome' }, { flag: '--allowedTools', value: 'mcp__claude-in-chrome' }];
55
59
 
56
60
  const jobs = new Map(); // `${root}\0${id}` → { id, kind, since, turn, pending, sessionId, base, n }
61
+ const pullJobs = new Map(); // `${root}\0${id}\0${source}` → { run, since, pending, sessionId, n } (a Test pull never blocks, and is never blocked by, a turn)
57
62
  const checkJobs = new Map(); // `${root}\0${id}\0${source}` → { run, since, pending, sessionId, kind }
58
63
  let lastCtx = null;
59
64
  const keyOf = (root, id) => `${path.resolve(String(root))}\u0000${id}`;
@@ -77,7 +82,7 @@ function runningOf(root, id) {
77
82
  const j = jobs.get(keyOf(root, id));
78
83
  return j ? { since: j.since, turn: j.turn, kind: j.kind } : null;
79
84
  }
80
- const answerOf = (root, id) => S.structureAnswer(root, id, { running: runningOf(root, id) });
85
+ const answerOf = (root, id) => S.structureAnswer(root, id, { running: runningOf(root, id), pulling: pullingOf(root, id) });
81
86
  const storeIds = (root) => new Set(CS.stateOf(root).keys());
82
87
 
83
88
  // ── the job ─────────────────────────────────────────────────────────────────────────────────────────────────
@@ -184,6 +189,87 @@ function finish(w, id, job, r) {
184
189
  try { fs.unlinkSync(job.pending); } catch { /* not written */ }
185
190
  }
186
191
 
192
+
193
+ // ── Test pull: one source, read straight into its row ─────────────────────────────────────────────────────
194
+ // A pull is NOT a turn: no line in the conversation, no version, and a running turn does not block it. The only event is `pull`; the result is a file.
195
+ const pullKey = (root, id, source) => `${keyOf(root, id)}\u0000${source}`;
196
+ function pullingOf(root, id) {
197
+ const pre = `${keyOf(root, id)}\u0000`;
198
+ return [...pullJobs.keys()].filter((k) => k.startsWith(pre)).map((k) => k.slice(pre.length));
199
+ }
200
+ function pullPrompt(skill, { root, id, setup, src, n, pending }) {
201
+ const p = S.paths(root, id);
202
+ const lines = [`- Report: ${setup.name} (${id})`, `- Today: ${new Date().toISOString().slice(0, 10)}`, `- Schedule: ${setup.cadence}`, `- Repository: ${p.abs}`,
203
+ `- Source: ${src.id} (${src.kind}): ${String(src.name).replace(/\s+/g, ' ')}`, `- Version: S${n} (${path.join(p.versions, `S${n}.json`)})`, `- Chart store: ${CS.paths(root).file}`, `- Output path: ${pending}`];
204
+ return `${skill}\n\nThis job:\n${lines.join('\n')}\n\nWrite the JSON to the output path, then print the JSON result. Send nothing.\r`;
205
+ }
206
+ /** A source Test pull can read: not a PMOS source, and not a Chrome or computer source (those have Check it works). */
207
+ const pullable = (src) => src.group === 'outside' && src.kind !== 'chrome' && src.kind !== 'computer';
208
+ function startPull(ctx, w, id, sourceId) {
209
+ const root = w.root;
210
+ const st = S.stateOf(root, id);
211
+ const hit = sourceOf(root, id, sourceId);
212
+ if (!hit || st.current == null) return { ok: false, status: 404, error: 'no such source in this version' };
213
+ if (!pullable(hit.src)) return { ok: false, status: 400, error: 'Test pull reads connectors, links and uploads. PMOS sources open in PMOS, and a Chrome or computer source has Check it works.' };
214
+ if (hit.src.state !== 'reached') return { ok: false, status: 409, error: 'Connect this source first, then press Test pull.' };
215
+ const key = pullKey(root, id, sourceId);
216
+ if (pullJobs.has(key)) return { ok: false, status: 409, error: 'PMOS is already reading this source. Wait for the answer.' };
217
+ if (typeof ctx.spawnSession !== 'function') return { ok: false, status: 409, error: 'this workspace cannot start background runs' };
218
+ const origin = `report-structure-pull:${id}:${sourceId}`;
219
+ if (headless.kindFor({ agent: 'claude', origin }) !== 'report-structure-pull') return { ok: false, status: 409, error: 'Background runs are switched off here (PMOS_HEADLESS=off), so PMOS cannot read the source.' };
220
+ const skill = typeof ctx.skillPrompt === 'function' ? ctx.skillPrompt('report-structure-pull', w) : null;
221
+ if (!skill) return { ok: false, status: 500, error: 'the report-structure-pull skill is not installed' };
222
+ const setup = setupOf(root, id) || { id, name: id, cadence: id };
223
+ const p = S.paths(root, id);
224
+ const run = S.newRun();
225
+ const pending = path.join(p.pullPending, `${run}.json`);
226
+ try { fs.mkdirSync(p.pullPending, { recursive: true }); } catch (e) { return { ok: false, status: 500, error: 'could not make the pending folder: ' + String((e && e.code) || e) }; }
227
+ const job = { id: run, since: new Date().toISOString(), pending, sessionId: null, n: st.current, source: sourceId };
228
+ pullJobs.set(key, job);
229
+ let s = null;
230
+ try {
231
+ s = ctx.spawnSession({ name: 'Report structure · test pull', agent: 'claude', ws: w.id, origin, cwd: p.rel,
232
+ initialInput: pullPrompt(skill, { root, id, setup, src: hit.src, n: st.current, pending }), writePaths: [pending], onDone: (r) => finishPull(w, id, job, r) });
233
+ } catch (e) { s = { state: 'exited', note: String((e && e.message) || e) }; }
234
+ if (!s || s.state === 'exited' || s.headless !== 'report-structure-pull') {
235
+ pullJobs.delete(key);
236
+ if (s && s.headless !== 'report-structure-pull' && s.state !== 'exited' && typeof ctx.stopSession === 'function') { try { ctx.stopSession(s.id); } catch { /* best effort */ } }
237
+ return { ok: false, status: 409, error: 'PMOS could not start the read: ' + ((s && s.note) || 'no background run') };
238
+ }
239
+ job.sessionId = s.id;
240
+ return { ok: true, running: true };
241
+ }
242
+ function finishPull(w, id, job, r) {
243
+ const root = w.root;
244
+ const key = pullKey(root, id, job.source);
245
+ if (pullJobs.get(key) === job) pullJobs.delete(key);
246
+ // Whatever happens, the source row gets an answer: a result file, or a "returned nothing" file with the reason. The plan and the conversation stay as they were.
247
+ const land = (pull) => {
248
+ try {
249
+ S.writePull(root, id, { ...pull, at: new Date().toISOString(), run: job.id });
250
+ S.append(root, id, { ev: 'pull', source: job.source, n: job.n, ok: !!pull.ok });
251
+ } catch { /* the folder is gone */ }
252
+ };
253
+ const nothing = (reason) => land({ source: job.source, n: job.n, period: '', reached: false, ok: false, reason, values: [], charts: 0, got: 0 });
254
+ try {
255
+ const res = (r && r.result) || {};
256
+ const ok = !!(r && !r.timedOut && !r.stopped && r.code === 0 && res.ok && !res.isError);
257
+ const out = resultJson(res.text);
258
+ let raw = null; try { raw = fs.readFileSync(job.pending, 'utf8'); } catch { raw = null; }
259
+ let data = null; if (raw) { try { data = JSON.parse(raw); } catch { data = null; } }
260
+ if (!data && out && isObj(out.pull)) data = out.pull;
261
+ if (!ok && !data) nothing(r && r.timedOut ? 'The read took too long and was stopped.' : r && r.stopped ? 'The read was stopped.' : 'The read failed' + (res.text ? ': ' + String(res.text).replace(/\s+/g, ' ').slice(0, 200) : '.'));
262
+ else if (!data) nothing('The read ended without a result.');
263
+ else {
264
+ const plan = S.readVersion(root, id, job.n);
265
+ const v = plan ? S.validatePull(data, plan, job.source) : { ok: false, error: 'the version is gone' };
266
+ if (!v.ok) nothing('The result could not be used: ' + v.error + '. Nothing was saved in the plan.');
267
+ else land(v.pull);
268
+ }
269
+ } catch (e) { nothing('The result could not be read: ' + String((e && e.message) || e).slice(0, 160)); }
270
+ try { fs.unlinkSync(job.pending); } catch { /* not written */ }
271
+ }
272
+
187
273
  /** A turn that has no answer and no job in this server (the workspace restarted mid-run) gets an error line. */
188
274
  function settleStale(root, id) {
189
275
  if (jobs.has(keyOf(root, id))) return;
@@ -343,7 +429,7 @@ function settleChecks(ctx, w, id) {
343
429
  const steps = v.steps.map((s) => (s.id === 'mac' ? base.find((b) => b.id === 'mac') || s : s)); // the server's own step wins
344
430
  finishCheck(root, id, sourceId, job.kind, steps, { endSession: ctx && ctx.endSession, job });
345
431
  } else finishCheck(root, id, sourceId, job.kind, failSteps(job.kind, 'read', `The check answered something PMOS could not use: ${v.error}`, base), { endSession: ctx && ctx.endSession, job });
346
- } else if (now - job.since > CHECK_MS) {
432
+ } else if (now - job.since > checkMs()) {
347
433
  finishCheck(root, id, sourceId, job.kind, failSteps(job.kind, 'session', 'The check session did not answer in 10 minutes. Check again.', base), { endSession: ctx && ctx.endSession, job });
348
434
  }
349
435
  }
@@ -456,6 +542,79 @@ function sweep(ctx) {
456
542
  return out;
457
543
  }
458
544
 
545
+ // ── Draft it now, for a report with a saved structure ───────────────────────────────────────────────────
546
+ // A press on Draft it now (or Draft now on the report) starts the session the schedule would start, through the same gate, so the draft follows the
547
+ // structure. `drafting.json` marks a draft that was started; it ends when a draft file lands in the report's folder, or when the session is gone.
548
+ const draftingFile = (root, id) => path.join(S.paths(root, id).dir, 'drafting.json');
549
+ function readDrafting(root, id) { try { const o = JSON.parse(fs.readFileSync(draftingFile(root, id), 'utf8')); return isObj(o) ? o : null; } catch { return null; } }
550
+ /** The schedule that drafts a report (skill report-template, args = the id), or null. */
551
+ function draftScheduleOf(ctx, w, id) {
552
+ try { return ctx.scheduleStore.read().find((x) => x && x.skill === 'report-template' && x.args === id && (!x.ws || x.ws === w.id)) || null; } catch { return null; }
553
+ }
554
+ /** server.js calls this when the report-template session of a structured report starts. */
555
+ function markDrafting(root, id, { sched, session, saved } = {}) {
556
+ try {
557
+ fs.mkdirSync(S.paths(root, id).dir, { recursive: true });
558
+ fs.writeFileSync(draftingFile(root, id), JSON.stringify({ since: new Date().toISOString(), sched: sched || null, session: session || null, saved: saved == null ? null : saved }) + '\n');
559
+ } catch { /* the row shows no state; the draft still runs */ }
560
+ }
561
+ /** A draft file (.md) in the report's folder written since `since`: { slot, path } | null. */
562
+ function landedSince(root, id, since) {
563
+ try {
564
+ const p = S.paths(root, id);
565
+ const hit = fs.readdirSync(p.folder).filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f)).map((f) => ({ f, t: fs.statSync(path.join(p.folder, f)).mtimeMs }))
566
+ .filter((x) => x.t >= Date.parse(since) - 1000).sort((a, b) => b.t - a.t)[0];
567
+ return hit ? { slot: hit.f.replace(/\.md$/, ''), path: `${p.rel}/${hit.f}` } : null;
568
+ } catch { return null; }
569
+ }
570
+ /**
571
+ * What a row shows for a report's draft: null, or { state: 'drafting' | 'held' | 'ended', since, saved, sources?, session?, reason? }.
572
+ * A held draft waits for a Chrome or computer check; an ended one lost its session before a draft landed.
573
+ */
574
+ function draftState(ctx, w, id) {
575
+ try {
576
+ const root = w.root;
577
+ const hold = readHold(root, id);
578
+ if (hold) {
579
+ const sp = savedPlan(root, id);
580
+ const names = (Array.isArray(hold.sources) ? hold.sources : []).map((sid) => ((sp && sp.plan.sources.find((x) => x.id === sid)) || { name: sid }).name);
581
+ return { state: 'held', since: hold.since, saved: hold.saved == null ? null : hold.saved, sources: names, expires: new Date(Date.parse(hold.since) + holdMs()).toISOString() };
582
+ }
583
+ const m = readDrafting(root, id);
584
+ if (!m) return null;
585
+ if (m.ended) return { state: 'ended', since: m.since, saved: m.saved, reason: m.reason || 'The drafting session ended before a draft was written.' };
586
+ if (landedSince(root, id, m.since)) { try { fs.unlinkSync(draftingFile(root, id)); } catch { /* gone */ } return null; }
587
+ const alive = m.session && typeof ctx.sessionAlive === 'function' ? ctx.sessionAlive(m.session) : true;
588
+ if (!alive) {
589
+ const o = { ...m, ended: true, reason: 'The drafting session ended before a draft was written. Press Draft it now to try again.' };
590
+ try { fs.writeFileSync(draftingFile(root, id), JSON.stringify(o) + '\n'); } catch { /* shown again next time */ }
591
+ return { state: 'ended', since: m.since, saved: m.saved, reason: o.reason };
592
+ }
593
+ return { state: 'drafting', since: m.since, saved: m.saved, session: m.session || null };
594
+ } catch { return null; }
595
+ }
596
+ /**
597
+ * Draft it now. → { structured: false } for a report with no saved structure (the caller drafts as it always did),
598
+ * { structured: true, status, error } when it cannot start, or { structured: true, drafting: true, held, state }.
599
+ */
600
+ function draftNow(ctx, w, id) {
601
+ const root = w.root;
602
+ const sp = savedPlan(root, id);
603
+ if (!sp) return { structured: false };
604
+ const sched = draftScheduleOf(ctx, w, id);
605
+ if (!sched) return { structured: true, status: 409, error: 'This report has no draft schedule. Set it up again, then press Draft it now.' };
606
+ const cur = draftState(ctx, w, id);
607
+ if (cur && (cur.state === 'drafting' || cur.state === 'held')) {
608
+ return { structured: true, status: 409, error: cur.state === 'held' ? 'A draft is waiting for a check of ' + (cur.sources || []).join(', ') + '.' : 'A draft is being made. Wait for it to land.' };
609
+ }
610
+ try { fs.unlinkSync(draftingFile(root, id)); } catch { /* none */ }
611
+ const gate = beforeDraft(ctx, w, id, sched);
612
+ if (gate.hold) return { structured: true, drafting: true, held: true, state: draftState(ctx, w, id) };
613
+ if (typeof ctx.fireDraft !== 'function') return { structured: true, status: 500, error: 'The server cannot start a draft.' };
614
+ try { ctx.fireDraft(sched.id, { id, ws: w.id }); } catch (e) { return { structured: true, status: 500, error: 'The draft did not start: ' + (e && e.message ? e.message : 'unknown') }; }
615
+ return { structured: true, drafting: true, held: false, state: draftState(ctx, w, id) };
616
+ }
617
+
459
618
  // ── the handler ─────────────────────────────────────────────────────────────────────────────────────────────
460
619
  async function handle(req, res, url, ctx) {
461
620
  lastCtx = ctx;
@@ -497,6 +656,12 @@ async function handle(req, res, url, ctx) {
497
656
  const c = S.readCheck(w.root, id, sid);
498
657
  return send(c ? { status: 200, body: { ok: true, check: c } } : bad(404, 'this source has not been checked'));
499
658
  }
659
+ if (sub === 'pull') {
660
+ const sid = text(url.searchParams.get('source'));
661
+ if (!S.SLUG_RE.test(sid)) return send(bad(400, 'source must be a source id'));
662
+ const pl = S.readPull(w.root, id, sid);
663
+ return send(pl ? { status: 200, body: { ok: true, pull: pl, running: pullJobs.has(pullKey(w.root, id, sid)) } } : bad(404, 'this source has not been pulled'));
664
+ }
500
665
  if (sub === 'dry') {
501
666
  const run = text(url.searchParams.get('run'));
502
667
  if (!S.RUN_RE.test(run)) return send(bad(400, 'run must be a run id'));
@@ -537,6 +702,12 @@ async function handle(req, res, url, ctx) {
537
702
  if (!s.ok) { S.append(root, id, { ev: 'error', text: s.error, mode: 'turn' }); return fail(s); }
538
703
  return send({ status: 200, body: { ok: true, running: true } });
539
704
  }
705
+ if (sub === 'pull') {
706
+ const sid = text(body.source);
707
+ if (!S.SLUG_RE.test(sid)) return send(bad(400, 'source must be a source id'));
708
+ const r = startPull(ctx, w, id, sid);
709
+ return r.ok ? send({ status: 200, body: { ok: true, running: true } }) : fail(r);
710
+ }
540
711
  if (sub === 'dry') {
541
712
  if (busy()) return send(bad(409, BUSY));
542
713
  const st = S.stateOf(root, id);
@@ -615,5 +786,5 @@ async function handle(req, res, url, ctx) {
615
786
  return send(bad(404, 'no such route'));
616
787
  }
617
788
 
618
- module.exports = { prefixes: ['/api/report-structure', '/api/charts'], handle, sweep, beforeDraft, draftBlock, startCheck, settleChecks, readHold, savedPlan, computerRule, resultJson, promptFor,
619
- _jobs: jobs, _checks: checkJobs };
789
+ module.exports = { prefixes: ['/api/report-structure', '/api/charts'], handle, sweep, beforeDraft, draftBlock, draftNow, draftState, markDrafting, startCheck, settleChecks, readHold, savedPlan, computerRule, resultJson, promptFor,
790
+ _jobs: jobs, _checks: checkJobs, _pulls: pullJobs };
@@ -118,6 +118,16 @@ async function handle(req, res, url, ctx) {
118
118
  const failed = (r) => { ctx.json(res, { ok: false, error: r.error || 'refused', index: r.index == null ? null : r.index }, r.status || 400); return true; };
119
119
 
120
120
  if (p === '/api/report/draft') {
121
+ // A first Draft now of a report with a saved structure is made by the session the schedule would start, so it follows the structure
122
+ // (report-structure-gaps). A Redraft, a draft at a named slot and a report with no saved structure keep the instant draft below.
123
+ if (body.redraft !== true && !cs.slot && typeof ctx.structureDraft === 'function') {
124
+ const sd = ctx.structureDraft(w, cs.cadence);
125
+ if (sd.structured) {
126
+ if (sd.status) return refuse(ctx, res, sd.status, sd.error);
127
+ ctx.json(res, { ok: true, ws: w.id, cadence: cs.cadence, drafting: true, held: !!sd.held, state: sd.state || null });
128
+ return true;
129
+ }
130
+ }
121
131
  // Draft now, or Redraft. A Redraft keeps every note, byte for byte, by the initiative id it was written under.
122
132
  const draft = RD.draftOf(root, { cadence: cs.cadence, slot: cs.slot || undefined });
123
133
  if (!draft || draft.ok === false) return failed(draft || { status: 500, error: 'the drafter answered nothing' });
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.241.0",
3
+ "version": "0.243.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.241.0",
9
+ "version": "0.243.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.241.0",
3
+ "version": "0.243.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -0,0 +1,21 @@
1
+ /* pm/c3-audience-link.js — the audience window of a PMOS deck (initiative meeting-material-replay-gaps).
2
+ *
3
+ * audienceChannelName(ws, path) the BroadcastChannel both windows share
4
+ * openAudienceWindow({ ws, path, name, start }) → { win, ch } | null (the browser blocked the window)
5
+ *
6
+ * The window is /c3.html?audience=1&…, which c3-boot.js hands to c3-audience.js. It shows the slides only. The presenter view and the window tell
7
+ * each other the slide through the channel: { t: 'slide', i } both ways, { t: 'hello' } from a window that has just opened, { t: 'close' } from
8
+ * the presenter view when it ends. The channel carries a slide number and nothing else, and it exists only between pages of this origin. */
9
+ export const audienceChannelName = (ws, path) => 'pmos-audience:' + String(ws || '') + ':' + String(path || '');
10
+
11
+ export function openAudienceWindow({ ws, path, name, start } = {}) {
12
+ if (typeof window === 'undefined' || typeof window.open !== 'function') return null;
13
+ const q = new URLSearchParams({ audience: '1', ws: String(ws || ''), path: String(path || ''), name: String(name || 'Deck').slice(0, 120), start: String(Math.max(0, Number(start) || 0)) });
14
+ try { if (new URLSearchParams(location.search).get('demo') === '1') q.set('demo', '1'); } catch { /* no location */ }
15
+ let win = null;
16
+ try { win = window.open('/c3.html?' + q.toString(), 'pmos-audience', 'popup=yes,width=1280,height=720'); } catch { win = null; }
17
+ if (!win || win.closed) return null;
18
+ let ch = null;
19
+ try { ch = typeof BroadcastChannel === 'function' ? new BroadcastChannel(audienceChannelName(ws, path)) : null; } catch { ch = null; }
20
+ return { win, ch };
21
+ }
@@ -0,0 +1,46 @@
1
+ /* pm/c3-audience.js — the audience window of a PMOS deck (initiative meeting-material-replay-gaps). c3-boot.js loads it alone for
2
+ * /c3.html?audience=1&ws=…&path=…&name=…&start=N, opened by c3-audience-link.js.
3
+ *
4
+ * It reads the deck through the routes the presenter view already uses and shows the slides only, full window: no speaker notes, no toolbar. It
5
+ * follows the presenter's slide through a BroadcastChannel and tells the presenter when a key here moves it. Escape, or the presenter ending
6
+ * the session, closes the window. Nothing is written and nothing is sent. */
7
+ import { el } from './dom.js';
8
+ import { createDeck } from './c3-report-deck.js';
9
+ import { audienceChannelName } from './c3-audience-link.js';
10
+ import { ensureC3Css } from './pm-work-view.js';
11
+
12
+ const q = new URLSearchParams(location.search);
13
+ const WS = q.get('ws') || '';
14
+ const PATH = q.get('path') || '';
15
+ const NAME = (q.get('name') || 'Deck').slice(0, 120);
16
+ const START = Math.max(0, parseInt(q.get('start') || '0', 10) || 0);
17
+
18
+ function say(text) {
19
+ document.body.append(el('div', { class: 'c3 aud-msg', 'data-aud': 'message' }, el('p', null, text)));
20
+ }
21
+
22
+ export async function runAudiencePage() {
23
+ document.title = 'PMOS · Audience view · ' + NAME;
24
+ document.body.classList.add('c3-aud-win');
25
+ ensureC3Css();
26
+ // Demo mode (?demo=1, the mockup's sample content) answers the reads of this window as it does for the page that opened it.
27
+ if (q.get('demo') === '1') { try { const m = await import('./c3-demo.js'); m.installDemo(); if (window.__c3demoReady) await window.__c3demoReady; } catch { /* the reads then go to the server */ } }
28
+ if (!PATH) { say('This window needs a deck. Open Audience view from the deck again.'); return; }
29
+ let ch = null;
30
+ try { ch = typeof BroadcastChannel === 'function' ? new BroadcastChannel(audienceChannelName(WS, PATH)) : null; } catch { ch = null; }
31
+ const deck = createDeck({ ws: WS, path: PATH, name: NAME, cycle: '', standalone: true, onSlide: (i) => { if (ch) ch.postMessage({ t: 'slide', i }); } });
32
+ const t0 = Date.now();
33
+ while (!deck.state.loaded && Date.now() - t0 < 10000) await new Promise((r) => setTimeout(r, 60));
34
+ const S = deck.state;
35
+ if (!S.loaded || !S.cyc || !S.cyc.designed || !S.R || S.R.format !== 'deck') { say('This report has no designed deck yet, so there are no slides to show.'); return; }
36
+ deck.present('aud', START);
37
+ if (ch) {
38
+ ch.onmessage = (e) => {
39
+ const m = e && e.data;
40
+ if (!m || typeof m !== 'object') return;
41
+ if (m.t === 'slide' && Number.isInteger(m.i)) deck.goTo(m.i, { quiet: true });
42
+ else if (m.t === 'close') window.close();
43
+ };
44
+ ch.postMessage({ t: 'hello' });
45
+ }
46
+ }
@@ -6,6 +6,7 @@
6
6
  const narrow = typeof matchMedia === 'function' && matchMedia('(max-width:640px)').matches;
7
7
  let desk = false;
8
8
  try { desk = localStorage.getItem('pmos.c3.testDesk') === 'desk'; } catch { /* no storage */ }
9
- if (new URLSearchParams(location.search).get('capture') === '1') import('./c3-capture.js'); // the PMOS Chrome extension's popup page: a small page on its own, at any width
9
+ if (new URLSearchParams(location.search).get('audience') === '1') import('./c3-audience.js').then((m) => m.runAudiencePage()); // the audience window of a PMOS deck: slides only, in a window of its own
10
+ else if (new URLSearchParams(location.search).get('capture') === '1') import('./c3-capture.js'); // the PMOS Chrome extension's popup page: a small page on its own, at any width
10
11
  else if (narrow && !desk) import('./c3-phone-boot.js');
11
12
  else import('./c3-shell.js');
@@ -26,6 +26,7 @@ import { pmPost } from './http.js';
26
26
  import { notesView, openNote, noteOfEvent, primeNotes } from './c3-notes.js'; // Calendar › Notes (initiative c3-notes-place)
27
27
  import { onNotes } from './notes-links.js';
28
28
  import { openSheet } from './c3-sheet.js';
29
+ import { materialSection } from './c3-material.js'; // Material on the meeting card (initiative meeting-material-replay)
29
30
 
30
31
  const LAYERS = [
31
32
  ['meetings', 'Meetings'],
@@ -669,6 +670,15 @@ const focusBtn = (e, date, ui) => button({ label: [c3ic('target', 'sm'), 'Focus'
669
670
  attrs: { 'data-cal3': 'focus', title: 'Open the meeting focus page: the clock, the topics, the material and PMOS beside the conversation' },
670
671
  onClick: () => { if (ui) { ui.st.sel = null; closeMeetingSheet(ui.r); ui.redraw(); } import('./c3-focus.js').then((m) => m.openFocus({ event: e, date })).catch(() => toast({ text: 'The focus page could not load.', tone: 'error' })); } });
671
672
 
673
+ /* "Replay": the full-screen replay of a meeting that has ended and has a recorded note (c3-replay.js); it closes the popup first, as Focus does */
674
+ const replayBtn = (e, ui) => {
675
+ const n = noteOfEvent(e);
676
+ if (!n || n.private || n.state === 'recording' || n.state === 'paused' || !(n.segments > 0 || (n.words || 0) > 0)) return null;
677
+ return button({ label: [c3ic('play', 'sm'), 'Replay'], variant: 'ghost', size: 'sm',
678
+ attrs: { 'data-cal3': 'replay', title: 'Open the meeting replay: the topics, the key points, the screen and the conversation along the audio' },
679
+ onClick: () => { if (ui) { ui.st.sel = null; closeMeetingSheet(ui.r); ui.redraw(); } import('./c3-replay.js').then((m) => m.openReplay({ note: n.id })).catch(() => toast({ text: 'The replay could not load.', tone: 'error' })); } });
680
+ };
681
+
672
682
  function meetingDetail(ui, hit, inSheet) {
673
683
  const { M, h } = ui; const e = hit.e;
674
684
  const s = h.timeOf(e.at); const f = s === null ? null : h.endH(e);
@@ -692,7 +702,7 @@ function meetingDetail(ui, hit, inSheet) {
692
702
  if (!M.ctx) body = [fold('info', 'The calendar sent no meeting context, so PMOS shows nothing for this meeting.', { 'data-cal3': 'no-context' })];
693
703
  else if (past && (!rec || rec.held === false)) body = [fold('moon', 'Nothing held for this meeting: no note, decisions or linked items yet.', { 'data-cal3': 'nothing-held' })];
694
704
  else if (past) body = [list(['decision', 'action'], 'What came out of it', 'out', 'No decision or action is recorded.'), list(['moved'], 'What moved after it', 'moved', 'Nothing moved within 48 hours that refers to it.')];
695
- else body = upcomingBody(ui, rec, items);
705
+ else body = upcomingBody(ui, rec, items, e, hit.date);
696
706
  const b = !past && rec && rec.brief ? rec.brief : null;
697
707
  const lv = !past && rec ? rec.level || null : null;
698
708
  const iniId = lv && lv.kind !== 'initiative' ? null : typeof e.initiative === 'string' && !(lv && lv.by === 'pm') ? e.initiative : b && b.initiative ? b.initiative.id : null;
@@ -707,7 +717,7 @@ function meetingDetail(ui, hit, inSheet) {
707
717
  // the popup: the sheet's header carries the title and Close; at Half and wider the note sits beside what came out of it
708
718
  const side = noteCard(ui, e);
709
719
  return [
710
- el('div', { class: 'cal3-dt' }, el('span', { class: 'tag' + (past ? '' : ' cal3-up') }, past ? 'past' : 'upcoming'), !past && s !== null && hit.date === ui.d.today ? focusBtn(e, hit.date, ui) : null),
720
+ el('div', { class: 'cal3-dt' }, el('span', { class: 'tag' + (past ? '' : ' cal3-up') }, past ? 'past' : 'upcoming'), !past && s !== null && hit.date === ui.d.today ? focusBtn(e, hit.date, ui) : null, past ? replayBtn(e, ui) : null),
711
721
  el('div', { class: 'dim mono cal3-when' }, h.dayLabel(hit.date) + (s === null ? ', all day' : ' · ' + h.hhmm(s) + ' to ' + h.hhmm(f))),
712
722
  chipsRow(),
713
723
  past || !M.ctx ? null : levelRow(ui, e, lv),
@@ -827,7 +837,7 @@ function openBoardTab(ui, board, tab) {
827
837
  ui.h.gotoTab('init');
828
838
  }
829
839
 
830
- function upcomingBody(ui, rec, items) {
840
+ function upcomingBody(ui, rec, items, e, date) {
831
841
  const { h } = ui;
832
842
  const b = rec && rec.brief ? rec.brief : null;
833
843
  const lv = rec && rec.level ? rec.level : null;
@@ -857,6 +867,8 @@ function upcomingBody(ui, rec, items) {
857
867
  el('span', { class: 'tag' + (x.done ? ' cal3-up' : '') }, CTXWORD[x.kind] || x.kind), el('span', { class: 'cal3-ct' }, x.text),
858
868
  x.at ? el('span', { class: 'dim mono cal3-at' }, h.dayLabel(x.at) + (h.timeOf(x.at) !== null ? ' ' + h.hhmm(h.timeOf(x.at)) : '')) : null,
859
869
  x.ref && REF[x.ref.type] ? refLink(ui, x.ref, { title: x.text }) : null))])));
870
+ if (e) out.push(materialSection({ event: e, date, level: lv, brief: b,
871
+ back: () => { if (!ui.st.sel) { ui.st.sel = { type: 'event', id: e.id }; ui.redraw(); } } }));
860
872
  if (b && ((b.links || []).length || (b.refs || []).length)) out.push(el('div', { class: 'chips2', 'data-cal3': 'meeting-links' },
861
873
  ...(b.links || []).map((l) => el('a', { class: 'chip2', href: l.url, target: '_blank', rel: 'noopener noreferrer' }, c3ic('out', 'sm'), l.label)),
862
874
  ...(b.refs || []).map((r) => r.link ? el('a', { class: 'chip2', href: r.link, target: '_blank', rel: 'noopener noreferrer' }, c3ic('out', 'sm'), r.label, r.source ? el('span', { class: 'dim' }, ' ' + r.source) : null)
@@ -0,0 +1,58 @@
1
+ /* pm/c3-demo-material.js — DEMO answers for the material of a meeting (initiative meeting-material-replay, part 1) on /c3.html?demo=1.
2
+ * c3-demo.js passes GET and POST /api/meeting-material and GET /api/meeting-material/suggest here, and adds the material of the meeting to the
3
+ * answer of GET /api/meeting-focus. The data is the approved mockup's: PMOS suggests the Partner API decision deck, the weekly report and a
4
+ * policy document; a search finds three more. The store lives in the page: a reload forgets it and nothing reaches the server. Real mode never loads this file. */
5
+
6
+ const rows = []; // { key, scope, event, item, topic, present, removed? } in the order they were added; the last of an item wins
7
+ const TOPICS = ['Context and goal', 'Options: soft vs hard limits', 'Partner impact: top 20 traffic', 'Partner comms and timing', 'Decide and next steps']
8
+ .map((text) => ({ id: text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''), text }));
9
+ const WS = '881665c933';
10
+ const SUGGESTED = [
11
+ { kind: 'pmos-deck', ref: 'planning/reports/rs1/2026-10-21.md', title: 'Partner API rate limits: decision deck', meta: 'Reports · deck · draft', why: 'Your deck, edited Fri 9 Oct', present: true, ws: WS },
12
+ { kind: 'report', ref: 'planning/reports/rs3/2026-10-14.md', title: 'Partner API weekly report · cycle 41', meta: 'Reports · document · issued', why: 'The report this review reads each week', present: false, ws: WS },
13
+ { kind: 'pmos-doc', ref: 'planning/prd/partner-agreement-prd.md', title: 'Partner agreement v3: fair use (section 4.2)', meta: 'IDPF-07 documents', why: 'Named in the last note: "check the agreement"', present: false, ws: WS },
14
+ ];
15
+ const FIND = [
16
+ { kind: 'pmos-deck', ref: 'planning/reports/rs1/2026-10-07.md', title: 'Payments 1.5 release review deck', meta: 'Reports · deck · issued', present: true, ws: WS },
17
+ { kind: 'report', ref: 'planning/reports/rs3/2026-10-02.md', title: 'Monthly product review · September', meta: 'Reports · document · issued', present: false, ws: WS },
18
+ { kind: 'pmos-doc', ref: 'planning/prd/idpf-07-charter.md', title: 'IDPF-07 charter', meta: 'IDPF-07 documents', present: false, ws: WS },
19
+ ];
20
+ const norm = (t) => String(t || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
21
+ const keyOf = (e) => (e && e.series ? 's:' + e.series : 't:' + norm(e && e.title));
22
+ const idOf = (r) => [r.key, r.scope === 'series' ? '' : r.event || '', r.item.kind, r.item.ref || r.item.url].join('|');
23
+ const classify = (u) => {
24
+ const s = String(u || '').trim(); let x; try { x = new URL(/^[a-z][a-z0-9+.-]*:/i.test(s) ? s : 'https://' + s); } catch { return null; }
25
+ if (x.protocol !== 'https:') return null;
26
+ if (x.hostname === 'docs.google.com' && /^\/presentation\/d\//.test(x.pathname)) return { kind: 'slides', url: x.href };
27
+ if (x.hostname === 'docs.google.com' && /^\/document\/d\//.test(x.pathname)) return { kind: 'gdoc', url: x.href };
28
+ if (/\.atlassian\.net$/.test(x.hostname) && x.pathname.startsWith('/wiki')) return { kind: 'conf', url: x.href };
29
+ return { kind: 'link', url: x.href };
30
+ };
31
+
32
+ /** The items of one meeting, in the shape of GET /api/meeting-material. event: { id?, title, series? } */
33
+ export function materialFor(event) {
34
+ const key = keyOf(event); const held = new Map();
35
+ for (const r of rows) { const id = idOf(r); if (r.removed) held.delete(id); else held.set(id, r); }
36
+ return [...held.values()].filter((r) => r.key === key && (r.scope === 'series' || r.event === String(event.id || ''))).map((r) => ({
37
+ id: Math.abs([...idOf(r)].reduce((h, c) => (h * 31 + c.charCodeAt(0)) | 0, 7)).toString(16), kind: r.item.kind, ref: r.item.ref || null, url: r.item.url || null, title: r.item.title,
38
+ ws: r.item.ws || null, topic: r.topic || null, present: r.present === true, scope: r.scope, from: 'you', by: 'pm:wawan', at: null }));
39
+ }
40
+
41
+ export function materialGet(path, url) {
42
+ const q = new URL(url, location.origin).searchParams;
43
+ if (path === '/api/meeting-material/suggest') {
44
+ const t = (q.get('q') || '').toLowerCase();
45
+ return { ok: true, suggested: SUGGESTED, found: t.length >= 2 ? FIND.filter((x) => x.title.toLowerCase().includes(t)) : [] };
46
+ }
47
+ const event = { id: q.get('event') || '', title: q.get('title') || '', series: q.get('series') || '' };
48
+ return { ok: true, key: keyOf(event), items: materialFor(event), ...(q.get('topics') ? { topics: TOPICS } : {}) };
49
+ }
50
+
51
+ export function materialPost(b) {
52
+ const e = b.event || {}; const it = b.item || {};
53
+ const item = it.ref ? { kind: it.kind, ref: it.ref, title: it.title || it.ref, ws: it.ws } : (() => { const c = classify(it.url); return c ? { ...c, title: it.title || c.url } : null; })();
54
+ if (!item) return { ok: false, error: 'a link must be an https address' };
55
+ const scope = b.scope === 'series' ? 'series' : 'meeting';
56
+ rows.push({ key: keyOf(e), scope, event: scope === 'meeting' ? String(e.id || '') : '', item, topic: b.topic || null, present: b.op !== 'remove' && b.present === true, removed: b.op === 'remove' });
57
+ return { ok: true, items: materialFor(e) };
58
+ }
@@ -12,6 +12,7 @@ import { el } from './dom.js';
12
12
  import * as D from './c3-demo-data.js';
13
13
  import { installInit } from './c3-demo-init.js';
14
14
  import { focusGet, focusPost } from './c3-demo-focus.js'; // Meeting focus: the mockup's Partner API review (GET /api/meeting-focus, its ask poll, and the topic and ask POSTs)
15
+ import { materialGet, materialPost, materialFor } from './c3-demo-material.js'; // Meeting material: GET and POST /api/meeting-material, its suggestions, and the material in the Focus read
15
16
  import { installBoards } from './c3-demo-boards.js'; // Initiatives boards: answers every /api/boards GET and POST (installed outermost, in installDemo)
16
17
  import { installCoreDocs } from './c3-demo-coredocs.js'; // Core documents of each level: GET /api/core-docs and its POSTs (installed outermost, after installBoards)
17
18
  import './c3-demo-cal-ix.js'; // Calendar and the ribbon: sets window.__c3calix (calendar-views.js reads it in demo only)
@@ -53,7 +54,9 @@ function getRoute(path, url) {
53
54
  switch (path) {
54
55
  case '/api/briefing': return D.briefing(url);
55
56
  case '/api/calendar-views': return withCalendarPage(D.calendarViews(url));
56
- case '/api/meeting-focus': case '/api/meeting-focus/ask': case '/api/meeting-focus/now': return focusGet(path, url);
57
+ case '/api/meeting-focus': { const o = focusGet(path, url); return o && o.ok ? { ...o, material: [...materialFor({ id: q.get('event') || '', title: q.get('title') || '' }), ...materialFor(o.event)] } : o; } // what was asked for, and the sample meeting the demo answers with
58
+ case '/api/meeting-focus/ask': case '/api/meeting-focus/now': return focusGet(path, url);
59
+ case '/api/meeting-material': case '/api/meeting-material/suggest': return materialGet(path, url);
57
60
  case '/api/pm-items': return D.pmItems();
58
61
  case '/api/runs': return D.runs();
59
62
  case '/api/releases': return D.releases();
@@ -78,6 +81,7 @@ function postRoute(path, body) {
78
81
  let b = {};
79
82
  try { b = body ? JSON.parse(body) : {}; } catch { b = {}; }
80
83
  if (path.startsWith('/api/meeting-focus/')) return focusPost(path, body);
84
+ if (path === '/api/meeting-material') return materialPost(b);
81
85
  if (path === '/api/pm-items/add') return { ok: true, item: { id: 'item-demo-new' + (++added), title: String(b.line || '').trim() || 'New item', due: null, priority: null, waiting_on: null, job: null, steps: [], created_at: D.NOW_ISO + 'Z' } };
82
86
  if (path === '/api/pm-items/remove') return { ok: true, undo_ms: 8000 };
83
87
  if (path === '/api/calendar-links') return (window.__c3links = (window.__c3links || []).concat([b])), { ok: true, link: b.kind === 'none' ? null : { kind: b.kind, ws: b.ws || null, board: b.board || null, initiative: b.initiative || null, title: b.title || null, by: 'pm:wawan' } }; // nothing is saved