create-pmos 0.242.0 → 0.244.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 (45) 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 +16 -4
  17. package/workspace/lib/meeting-focus.js +6 -4
  18. package/workspace/lib/meeting-replay.js +15 -1
  19. package/workspace/lib/report-structure.js +49 -5
  20. package/workspace/lib/routes-meeting-focus.js +1 -0
  21. package/workspace/lib/routes-report-setups.js +16 -2
  22. package/workspace/lib/routes-report-structure.js +176 -5
  23. package/workspace/lib/routes-reports.js +10 -0
  24. package/workspace/package-lock.json +2 -2
  25. package/workspace/package.json +1 -1
  26. package/workspace/public/pm/c3-audience-link.js +21 -0
  27. package/workspace/public/pm/c3-audience.js +46 -0
  28. package/workspace/public/pm/c3-boot.js +2 -1
  29. package/workspace/public/pm/c3-material.js +11 -1
  30. package/workspace/public/pm/c3-replay.css +6 -0
  31. package/workspace/public/pm/c3-replay.js +19 -3
  32. package/workspace/public/pm/c3-report-deck.css +5 -0
  33. package/workspace/public/pm/c3-report-deck.js +41 -5
  34. package/workspace/public/pm/c3-report-setup.css +5 -0
  35. package/workspace/public/pm/c3-report-setup.js +20 -2
  36. package/workspace/public/pm/c3-report-store.js +24 -2
  37. package/workspace/public/pm/c3-report-structure.css +4 -0
  38. package/workspace/public/pm/c3-report-structure.js +26 -8
  39. package/workspace/public/pm/c3-shell.js +2 -0
  40. package/workspace/public/pm/dest-reports.js +10 -1
  41. package/workspace/public/pm/report-view.js +23 -1
  42. package/workspace/public/pm/reports-copy.js +5 -0
  43. package/workspace/server.js +8 -1
  44. package/workspace/skills/report-structure-pull.skill +45 -0
  45. package/workspace/test-lists/report-structure-gaps.json +5 -0
@@ -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 };
@@ -129,6 +129,7 @@ function settleRun(ctx, key, done, now) {
129
129
  const res = done && done.result;
130
130
  if (done && done.stopped) return append(ctx.runtimeDir, key + '.jsonl', { k: 'fail', at, error: 'The run was stopped before it answered.' });
131
131
  if (done && done.timedOut) return append(ctx.runtimeDir, key + '.jsonl', { k: 'fail', at, error: 'The companion took too long and was stopped.' });
132
+ if (res && headless.signedOut(res.text)) return append(ctx.runtimeDir, key + '.jsonl', { k: 'fail', at, auth: true, error: headless.SIGNED_OUT_WHY });
132
133
  if (!res || !res.ok || res.isError) return append(ctx.runtimeDir, key + '.jsonl', { k: 'fail', at, error: 'The companion did not answer (' + ((done && done.note) || 'no result') + ').' });
133
134
  const parsed = F.parseCompanion(res.text);
134
135
  // a malformed answer is dropped, not shown
@@ -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.244.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.244.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.244.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); }