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
@@ -120,10 +120,16 @@ function documentsOf(ctx, w) {
120
120
  return { docs, reports: r.reports };
121
121
  }
122
122
 
123
+ /** The state of a report's structure draft, else null (report-structure-gaps). */
124
+ function draftStateOf(ctx, w, s) {
125
+ if (!ctx || typeof ctx.structureDraftState !== 'function' || s.source !== 'work') return null;
126
+ try { return ctx.structureDraftState(w, s.id) || null; } catch { return null; }
127
+ }
128
+
123
129
  /** The saved structure of a report (initiative report-structure): { saved, at, charts, sections }, else null. */
124
130
  function structureOf(root, id) { try { return require('./report-structure.js').savedOf(root, id); } catch { return null; } }
125
131
 
126
- function setupOut(s, now, docsOf, idx, root) {
132
+ function setupOut(s, now, docsOf, idx, root, draft) {
127
133
  const mine = (docsOf.reports || []).filter((x) => x.cadence === s.id);
128
134
  const issued = mine.filter((x) => x.issued).map((x) => x.slot).sort().pop() || null;
129
135
  const latest = mine.slice().sort((a, b) => (a.slot < b.slot ? 1 : -1))[0] || null;
@@ -134,6 +140,7 @@ function setupOut(s, now, docsOf, idx, root) {
134
140
  next_draft: next, last_issued: issued, status: s.status, cycles: mine.length,
135
141
  copy: copyOf(latest ? linkFor(idx, latest.path, s.output) : null, docSync.copyKindOf(s.output)),
136
142
  structure: structureOf(root, s.id),
143
+ draft: draft || null, // report-structure-gaps: { state: 'drafting' | 'held' | 'ended', since, … } while a draft that follows the saved structure is being made
137
144
  };
138
145
  }
139
146
 
@@ -162,7 +169,7 @@ function listAnswer(ctx, w, now) {
162
169
  const docsOf = documentsOf(ctx, w);
163
170
  const idx = copyIndex(w.root);
164
171
  return {
165
- setups: [...st.setups.values()].map((s) => setupOut(s, new Date(now), docsOf, idx, w.root)),
172
+ setups: [...st.setups.values()].map((s) => setupOut(s, new Date(now), docsOf, idx, w.root, draftStateOf(ctx, w, s))),
166
173
  findings: RS.openFindings(st).map((f) => ({ id: f.id, name: f.name, why: f.why, evidence: f.evidence, confidence: f.confidence, proposal: f.proposal })),
167
174
  asks: [...st.asks.values()].map((a) => ({ id: a.id, text: a.text, state: a.state, changed: !!a.changed, proposal: a.proposal, error: a.error })),
168
175
  finder: finderOut(ctx, st, now),
@@ -360,6 +367,13 @@ async function handle(req, res, url, ctx) {
360
367
  if (s.source !== 'work') return send(bad(400, 'a dashboard report is drafted from its dashboard on its day'));
361
368
  let next = null; try { next = RS.nextDraftAt(s.schedule, s.last_run, new Date()); } catch { next = null; }
362
369
  const day = next ? `${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, '0')}-${String(next.getDate()).padStart(2, '0')}` : null; // the local day, as setUp names a slot
370
+ // A report with a saved structure is drafted by the session the schedule would start, so the draft follows the structure (report-structure-gaps).
371
+ // One with no saved structure keeps the instant draft below.
372
+ const sd = typeof ctx.structureDraft === 'function' ? ctx.structureDraft(w, s.id) : { structured: false };
373
+ if (sd.structured) {
374
+ if (sd.status) return send(bad(sd.status, sd.error));
375
+ return send({ status: 200, body: { ok: true, drafting: true, held: !!sd.held, state: sd.state || null, id: s.id } });
376
+ }
363
377
  const d = draftCycle(root, s.id, day, g.actor);
364
378
  if (d.error) return send(bad(500, 'the draft was not written: ' + d.error));
365
379
  if (ctx.invalidateNeedsAgg) ctx.invalidateNeedsAgg();
@@ -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.242.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.242.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.242.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');
@@ -18,6 +18,7 @@ import { ensureComponentsCss } from './primitives.js';
18
18
  import { icon, pbtn, sect, put, lk, ib, list, empty } from './c3-ini-ui.js';
19
19
  import { openSheet, closeSheet } from './c3-sheet.js';
20
20
  import { toast } from './c3-toast.js';
21
+ import { openAudienceWindow } from './c3-audience-link.js';
21
22
 
22
23
  /** What each kind of item is called, and which icon it takes. `deck`: PMOS presents it itself. `present`: it can be presented at all. */
23
24
  export const KIND = {
@@ -145,7 +146,16 @@ export function presentItem(item) {
145
146
  if (item.kind === 'pmos-deck') return showDeck(item, 'pres');
146
147
  return openItem(item);
147
148
  }
148
- export function audienceItem(item) { if (item && item.kind === 'pmos-deck') return showDeck(item, 'aud'); return openItem(item); }
149
+ /** Audience view: a PMOS deck opens in a window of its own, slides only (initiative meeting-material-replay-gaps). The window opens at once, on the press, because a browser
150
+ * blocks a window that opens after a wait. When it is blocked a notice says so and offers the full-screen view in this window. */
151
+ export function audienceItem(item) {
152
+ if (item && item.kind === 'pmos-deck') {
153
+ if (openAudienceWindow({ ws: item.ws || wsParam(), path: item.ref, name: item.title || 'Deck' })) return;
154
+ toast({ text: 'The audience window was blocked. Allow pop-ups for this page, or use the full-screen view here.', actionLabel: 'Full-screen view here', timeout: 10000, onAction: () => showDeck(item, 'aud') });
155
+ return;
156
+ }
157
+ return openItem(item);
158
+ }
149
159
 
150
160
  /* ── the Material part of the meeting card ────────────────────────────────────────────────────────────────────────── */
151
161
 
@@ -125,3 +125,9 @@
125
125
  .c3replay .rpl-lb { min-height: 44px; }
126
126
  }
127
127
  @media (prefers-reduced-motion: reduce) { .c3replay .rpl-now { transition: none; } }
128
+
129
+ /* meeting-material-replay-gaps: where a recording was off between two starts */
130
+ .c3replay .rpl-gap { position: absolute; top: 0; bottom: 0; left: var(--fc-left, 0%); width: var(--fc-width, 0%); min-width: 0; min-height: 0; height: auto; padding: 0; margin: 0; box-sizing: border-box; border: 0; border-left: 1px dashed var(--muted-foreground); border-right: 1px dashed var(--muted-foreground); background: repeating-linear-gradient(135deg, transparent 0 4px, var(--border) 4px 6px); cursor: pointer; }
131
+ .c3replay .rpl-gap:focus-visible { outline: 2px solid color-mix(in oklab, var(--ring) 50%, var(--foreground)); outline-offset: 2px; }
132
+ .c3replay .rpl-gapnote { display: flex; gap: 8px; align-items: center; font-size: var(--fs-2); }
133
+ .c3replay .rpl-gapkey { display: inline-block; width: 22px; height: 10px; flex: none; border: 1px dashed var(--muted-foreground); background: repeating-linear-gradient(135deg, transparent 0 3px, var(--border) 3px 5px); }
@@ -27,6 +27,11 @@ const KIC = { decision: 'scale', action: 'todo', question: 'reply' };
27
27
  const KWORD = { decision: 'Decision', action: 'Next step', question: 'Question' };
28
28
  const KPLURAL = { decision: 'Decisions', action: 'Next steps', question: 'Questions' };
29
29
  const pad = (n) => String(n).padStart(2, '0');
30
+ const NO_NOTE = 'That replay link does not match a note on this computer. It may be from another computer, or the note was deleted. Open it from Calendar, Notes.';
31
+ /** A note id, as lib/notes-audio.js names it. The ?replay= link is checked against this before any request is made. Pure. */
32
+ export const NOTE_ID_RE = /^n-[0-9a-z]{4,16}-[0-9a-f]{4}$/;
33
+ /** The gap that holds time t (the recording was off), or null. Pure. */
34
+ export const gapAt = (gaps, t) => (gaps || []).find((g) => t >= g.from_ms && t < g.to_ms) || null;
30
35
 
31
36
  /** 3:07, or 1:03:07 from an hour on. Pure. */
32
37
  export function mmss(ms) {
@@ -89,6 +94,7 @@ export function openReplay({ note, event, date } = {}) {
89
94
  }
90
95
 
91
96
  async function load(s) {
97
+ if (!s.note && !s.event) { fail(s, NO_NOTE); return; } // a ?replay= link that is not a note id: no request is made
92
98
  const p = new URLSearchParams();
93
99
  if (s.note) p.set('note', s.note);
94
100
  if (s.event) p.set('event', s.event);
@@ -96,7 +102,7 @@ async function load(s) {
96
102
  let d = null;
97
103
  try { d = await pmGet('/api/meeting-replay?' + p.toString()); } catch (e) { d = { ok: false, error: String((e && e.message) || e) }; }
98
104
  if (S !== s) return;
99
- if (!d || !d.ok) { fail(s, (d && d.error) || 'The replay could not be read.'); return; }
105
+ if (!d || !d.ok) { fail(s, d && d.error === 'no such note' ? NO_NOTE : (d && d.error) || 'The replay could not be read.'); return; }
100
106
  s.data = d;
101
107
  s.root.dataset.rplState = 'ok';
102
108
  if (d.audio) {
@@ -147,6 +153,8 @@ function tick(s) {
147
153
  if (s.play) {
148
154
  if (s.audio && !s.soundOff && !s.audio.paused) s.t = Math.round(s.audio.currentTime * 1000);
149
155
  else s.t = Math.min(len, s.t + TICK_MS * s.speed); // no sound (none kept, or it cannot play): the page keeps the time
156
+ const gp = gapAt(s.data.gaps, s.t);
157
+ if (gp && s.t < len) { s.t = gp.to_ms; if (s.audio && !s.soundOff) { try { s.audio.currentTime = s.t / 1000; } catch { /* not ready */ } } } // the recording was off: play on from where it began again
150
158
  if (s.t >= len) { s.t = len; s.play = false; if (s.audio) { try { s.audio.pause(); } catch { /* none */ } } paintTop(s); }
151
159
  }
152
160
  paintTime(s, false);
@@ -182,9 +190,13 @@ function chipsOf(s) {
182
190
  el('span', null, [day, d.start_ms != null ? wall(s, 0) + ' to ' + wall(s, lenOf(s)) : '', len + ' min'].filter(Boolean).join(' · ')),
183
191
  d.meeting.attendees.length ? el('span', null, d.meeting.attendees.length + (d.meeting.attendees.length === 1 ? ' person' : ' people')) : null,
184
192
  el('span', { title: 'Audio and screen shots are kept on this machine' }, icon('lock', 'sm'), (d.audio ? 'audio' : 'no audio') + (kept ? ' and ' + kept + (kept === 1 ? ' screen shot' : ' screen shots') : '')),
193
+ (d.recordings || 1) > 1 ? el('span', { class: 'tag warn', 'data-rpl': 'recordings', title: 'The recording was stopped and started again. The gap is marked on the track.' }, icon('alert', 'sm'), d.recordings + ' recordings') : null,
185
194
  d.aside_count ? lk({ 'data-rpl': 'aside', class: 'rpl-warnc', title: 'Shots that looked like another app. They are not in the replay.' }, d.aside_count + ' set aside') : null);
186
195
  }
187
196
 
197
+ /** '7 min', or '40 s' under a minute. Pure. */
198
+ export const gapText = (ms) => (ms < 60000 ? Math.max(1, Math.round(ms / 1000)) + ' s' : Math.round(ms / 60000) + ' min');
199
+
188
200
  function trackOf(s) {
189
201
  const d = s.data;
190
202
  const marks = d.keys.filter((k) => k.t != null).map((k) => {
@@ -198,11 +210,13 @@ function trackOf(s) {
198
210
  const b = btn({ class: 'rpl-run', 'data-rpl-seek': String(rn.from_ms), 'data-rpl-run': String(i), title: (rn.n ? rn.n + '. ' : '') + topicText(rn.topic) + ' · ' + wall(s, rn.from_ms) + ' · ' + Math.max(1, Math.round((rn.to_ms - rn.from_ms) / 60000)) + ' min' }, w >= 5 && rn.n ? String(rn.n) : '');
199
211
  return geo(b, { '--fc-left': pct(s, rn.from_ms) + '%', '--fc-width': Math.max(0.5, w) + '%' });
200
212
  });
213
+ const gaps = (d.gaps || []).map((g, i) => geo(btn({ class: 'rpl-gap', 'data-rpl-seek': String(g.to_ms), 'data-rpl-gap': String(i), title: 'Not recorded ' + wall(s, g.from_ms) + ' to ' + wall(s, g.to_ms) + ' (' + gapText(g.to_ms - g.from_ms) + '). A press plays from where the recording began again.', 'aria-label': 'Not recorded ' + wall(s, g.from_ms) + ' to ' + wall(s, g.to_ms) }),
214
+ { '--fc-left': pct(s, g.from_ms) + '%', '--fc-width': Math.max(0.6, (g.to_ms - g.from_ms) / lenOf(s) * 100) + '%' }));
201
215
  const head = geo(el('span', { class: 'rpl-now', 'data-rpl': 'head' }), { '--fc-left': '0%' });
202
216
  const ticks = [0, 15, 30, 45, 60, 75, 90].map((m) => m * 60000).filter((ms) => ms < lenOf(s) * 0.92)
203
217
  .map((ms) => geo(el('span', { class: 'rpl-tick' }, wall(s, ms)), { '--fc-left': pct(s, ms) + '%' }));
204
218
  return el('div', { class: 'rpl-track', role: 'group', 'aria-label': 'The meeting by topic, with decisions, questions and next steps. Press a stretch or a mark to play from there.' },
205
- el('div', { class: 'rpl-rail', 'data-rpl': 'rail' }, el('div', { class: 'rpl-mks' }, ...marks), el('div', { class: 'rpl-runs', 'data-rpl': 'bar' }, ...runs), el('div', { class: 'rpl-pms' }, ...pms), head),
219
+ el('div', { class: 'rpl-rail', 'data-rpl': 'rail' }, el('div', { class: 'rpl-mks' }, ...marks), el('div', { class: 'rpl-runs', 'data-rpl': 'bar' }, ...runs, ...gaps), el('div', { class: 'rpl-pms' }, ...pms), head),
206
220
  el('div', { class: 'rpl-ticks dim mono' }, ...ticks, el('span', { class: 'end' }, wall(s, lenOf(s)))));
207
221
  }
208
222
 
@@ -218,7 +232,9 @@ function buildTop(s) {
218
232
  el('div', { class: 'rpl-clock' }, el('div', null, el('b', { class: 'mono', 'data-rpl': 't' }, mmss(s.t)),
219
233
  el('span', { class: 'dim mono' }, 'of ' + mmss(lenOf(s)) + ' · ', el('span', { 'data-rpl': 'clk' }, wall(s, s.t))))),
220
234
  el('div', { class: 'rpl-acts', 'data-rpl': 'acts' }),
221
- trackOf(s), legend);
235
+ trackOf(s), legend,
236
+ (d.gaps || []).length ? el('div', { class: 'rpl-gapnote dim', 'data-rpl': 'gapnote' }, el('i', { class: 'rpl-gapkey', 'aria-hidden': 'true' }),
237
+ d.gaps.map((g) => 'Not recorded ' + wall(s, g.from_ms) + ' to ' + wall(s, g.to_ms) + ' (' + gapText(g.to_ms - g.from_ms) + ').').join(' ') + ' You stopped the recording and started it again. Playing across a gap skips it.') : null);
222
238
  paintTop(s);
223
239
  }
224
240
 
@@ -156,3 +156,8 @@
156
156
  .c3 .rd-dks-chat { position: static; max-height: none; }
157
157
  .c3 .rd-dks-log { max-height: 420px; }
158
158
  }
159
+
160
+ /* meeting-material-replay-gaps: the audience window shows the slides only, so the toolbar is hidden; a window with no deck says why in one line */
161
+ body.c3-aud-win { margin: 0; }
162
+ body.c3-aud-win .rd-ov .rp-deck__bar { display: none; }
163
+ .c3.aud-msg { display: grid; place-items: center; min-height: 100vh; padding: var(--sp-3); text-align: center; }