create-pmos 0.493.0 → 0.495.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 (82) hide show
  1. package/package.json +99 -27
  2. package/payload/KIT-VERSION +1 -1
  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/workspace/briefing-premium-adddoc-test.js +2 -1
  7. package/workspace/briefing-premium-board-test.js +5 -4
  8. package/workspace/briefing-premium-initiative-test.js +4 -2
  9. package/workspace/changelog.json +98 -0
  10. package/workspace/guide/briefing-premium.md +4 -1
  11. package/workspace/guide/claude-proposes.md +2 -2
  12. package/workspace/guide/data-freshness.md +3 -1
  13. package/workspace/guide/initiatives-screen.md +3 -1
  14. package/workspace/guide/report-slides.md +14 -1
  15. package/workspace/guide/reports.md +2 -0
  16. package/workspace/guide/workflows.md +15 -3
  17. package/workspace/guide/your-impact.md +6 -7
  18. package/workspace/ini-workflow-test.js +2 -1
  19. package/workspace/lib/board-trail.js +30 -3
  20. package/workspace/lib/engine/workflows.js +19 -6
  21. package/workspace/lib/git-shape.js +21 -0
  22. package/workspace/lib/grill-runs.js +21 -6
  23. package/workspace/lib/grill.js +1 -1
  24. package/workspace/lib/impact.js +29 -16
  25. package/workspace/lib/initiative-cli.js +15 -1
  26. package/workspace/lib/open-mission.js +14 -0
  27. package/workspace/lib/page-warm.js +68 -0
  28. package/workspace/lib/report-design.js +6 -1
  29. package/workspace/lib/report-export.js +150 -9
  30. package/workspace/lib/report-pyramid.js +535 -0
  31. package/workspace/lib/report-structure.js +5 -1
  32. package/workspace/lib/report-templates.js +111 -20
  33. package/workspace/lib/routes-boards.js +36 -3
  34. package/workspace/lib/routes-brief-page.js +21 -3
  35. package/workspace/lib/routes-grill.js +10 -4
  36. package/workspace/lib/routes-impact.js +12 -4
  37. package/workspace/lib/routes-initiatives.js +1 -0
  38. package/workspace/lib/routes-programs.js +6 -4
  39. package/workspace/lib/routes-report-design.js +15 -0
  40. package/workspace/lib/routes-report-flow.js +115 -19
  41. package/workspace/lib/routes-report-setups.js +4 -2
  42. package/workspace/lib/routes-report-structure.js +3 -0
  43. package/workspace/lib/routes-workflows.js +6 -1
  44. package/workspace/lib/wf-bridge.js +5 -3
  45. package/workspace/lib/workflow-cli.js +2 -2
  46. package/workspace/lib/workflows-store.js +15 -1
  47. package/workspace/metric-drafts-ui-test.js +2 -1
  48. package/workspace/package-lock.json +2 -2
  49. package/workspace/package.json +1 -1
  50. package/workspace/premium-board-actions-test.js +2 -1
  51. package/workspace/premium-chat-report-tab-test.js +5 -1
  52. package/workspace/premium-vw-backlog-test.js +3 -2
  53. package/workspace/premium-vw-item-test.js +3 -2
  54. package/workspace/premium-vw-jira-test.js +3 -2
  55. package/workspace/program-panel-test.js +2 -1
  56. package/workspace/public/pm/cozy-home.css +1 -0
  57. package/workspace/public/pm/cozy-vw-rp.css +12 -0
  58. package/workspace/public/pm/fetch-share.js +38 -0
  59. package/workspace/public/pm/home-app.js +24 -4
  60. package/workspace/public/pm/home-chat.js +53 -11
  61. package/workspace/public/pm/home-impact.css +23 -13
  62. package/workspace/public/pm/home-impact.js +49 -32
  63. package/workspace/public/pm/home-ini.css +2 -0
  64. package/workspace/public/pm/ini-view.css +55 -0
  65. package/workspace/public/pm/ini-view.js +7 -4
  66. package/workspace/public/pm/premium-board.js +1 -0
  67. package/workspace/public/pm/premium-insights.js +1 -0
  68. package/workspace/public/pm/premium-report-read.js +25 -6
  69. package/workspace/public/pm/premium-report-tab.js +69 -14
  70. package/workspace/public/pm/premium-shell.js +57 -6
  71. package/workspace/public/pm/premium.css +1 -0
  72. package/workspace/public/pm/report-flow.js +93 -10
  73. package/workspace/public/pm/wf-page.css +8 -0
  74. package/workspace/public/pm/wf-page.js +33 -5
  75. package/workspace/server.js +24 -3
  76. package/workspace/skills/report-template.skill +10 -0
  77. package/workspace/split-merge-ui-test.js +3 -2
  78. package/workspace/stage-card-test.js +2 -1
  79. package/workspace/task-press-test.js +2 -1
  80. package/workspace/test-lists/page-open-speed.json +5 -0
  81. package/workspace/test-lists/report-pyramid.json +9 -0
  82. package/workspace/tk-tasks-test.js +2 -1
@@ -3,12 +3,23 @@
3
3
  *
4
4
  * GET /api/report-flow?ws&id → { ok, id, name, audience, output, schedule, status, template: { current, versions: [{ n, settings, words,
5
5
  * why, at, current, cycles }] }, cycles: [{ path, slot, state }] (newest first), latest, drafting, session,
6
- * review } the report's current state: what the chat chip opens
6
+ * review, sections, look } the report's current state: what the chat chip opens.
7
+ * sections: { saved: S<n> | null, titles } the sections the next cycle follows
8
+ * look: { saved: D<n> | null, words, follows: [sample], newer: { n, words } | null } the look, and a newer
9
+ * design version not saved yet (made in Design): the template panel offers it as a change
7
10
  * GET /api/report-flow/review?ws&path → { ok, review: { fix: [{ id, title, why }], passed: [label] } } Claude's review of one draft
8
11
  * POST /api/report-flow/start { ws, id, chat? } → { ok, n } T1 of a report just set up, and the conversation it came from
9
- * POST /api/report-flow/template { ws, id, settings } → { ok, n } the PM's ✓ on a changed template: applied, and T<n+1>
10
- * POST /api/report-flow/restore { ws, id, n } → { ok, n } Go back to T<n>: applied; nothing is deleted
11
- * POST /api/report-flow/ask { ws, id, text, path? } → { ok, session, passed | started } a change for the report's own session
12
+ * POST /api/report-flow/template { ws, id, settings?, sections?, design? } → { ok, n } the PM's ✓ on a changed template (canvas TplA): applied,
13
+ * and ONE T<n+1> holding all of it. sections: the titles in order (a new S version, saved); design: a D
14
+ * version made in Design (saved), or null for the standard look. The open draft is not written again.
15
+ * POST /api/report-flow/restore { ws, id, n } → { ok, n, restored } Go back to T<n>: its format, readers and schedule,
16
+ * and its sections and look when it recorded them, are applied; nothing is deleted. Cycles keep
17
+ * the look they were made with; the open draft stays as it is
18
+ * POST /api/report-flow/ask { ws, id, text, path? } → { ok, session, path, passed | started } a change for the report's own session
19
+ * Without a path the change goes to the report's latest draft; an issued latest cycle (or path) is refused
20
+ * 409, as an issued report is frozen. The session always gets the file and its slot, and "do not draft a new
21
+ * slot", so a change edits the draft that exists (report-building brief 1). A change to the words edits only
22
+ * the named notes and keeps every caveat; it redrafts only when the PM asks for fresh numbers (brief 4).
12
23
  *
13
24
  * THE REPORT'S OWN SESSION. Each template drafts in a session of its own, never in the PM's chat (the agreed note). A report with a saved
14
25
  * structure drafts in the session its schedule starts (lib/routes-report-structure.js draftNow, origin schedule:<id>). A change the PM asks for
@@ -25,6 +36,8 @@ const guard = require('./pm-guard.js');
25
36
  const RT = require('./report-templates.js');
26
37
  const RS = require('./report-setups.js');
27
38
  const RN = require('./report-needs.js');
39
+ const RSt = require('./report-structure.js');
40
+ const RD = require('./report-design.js');
28
41
  const issues = require('./issues.js');
29
42
 
30
43
  const prefixes = ['/api/report-flow'];
@@ -50,8 +63,14 @@ function draftingOf(ctx, w, id) { try { return typeof ctx.structureDraftState ==
50
63
  function stateOf(ctx, w, id, now = Date.now()) {
51
64
  const root = w.root, s = setupOf(root, id);
52
65
  const cycles = cyclesOf(root, id);
53
- const f = RT.fold(RT.readLines(root, id));
66
+ // a report set up before the template flow, with a saved look or sections, gets its T1 now (brief 4 item 10): its template is what is saved
67
+ const live0 = RT.liveRefs(root, id);
68
+ if (s && s.status !== 'removed' && (live0.design != null || live0.structure != null) && RT.fold(RT.readLines(root, id)).current == null) {
69
+ try { RT.ensureFirst(root, id, s, { refs: live0, why: 'The template as it was set up, with its saved look and sections.' }); } catch { /* shown with no versions */ }
70
+ }
71
+ const f = RT.backfill(root, id, RT.fold(RT.readLines(root, id)));
54
72
  const counts = RT.cyclesOf(f, cycles.filter((c) => c.issued_at).map((c) => ({ at: c.issued_at })));
73
+ const names = RT.namesOf(root, id), live = RT.liveRefs(root, id);
55
74
  const sess = readSession(root, id);
56
75
  const drafting = draftingOf(ctx, w, id);
57
76
  const latest = cycles[0] || null;
@@ -59,12 +78,59 @@ function stateOf(ctx, w, id, now = Date.now()) {
59
78
  if (latest && latest.state === 'draft') { const t = readText(root, latest.path); if (t != null) review = RT.review(t, { slot: latest.slot, now, listed: s && Array.isArray(s.sections) ? s.sections : [] }); }
60
79
  return {
61
80
  ok: true, id, name: s ? s.name : id, audience: s ? s.audience : '', output: s ? s.output : '', schedule: s ? s.schedule : null, status: s ? s.status : 'removed',
62
- template: { current: f.current, versions: f.versions.map((v) => ({ ...v, words: RT.settingsWords(v.settings), cycles: counts.get(v.n) || 0 })) },
81
+ template: { current: f.current, versions: f.versions.map((v) => ({ ...v, words: RT.settingsWords(v.settings, names), cycles: counts.get(v.n) || 0 })) },
63
82
  cycles, latest, drafting,
64
83
  session: sess ? { id: sess.session, alive: alive(ctx, sess.session), since: sess.since || null } : null,
65
84
  review,
85
+ sections: { saved: live.structure, titles: names.sections(live.structure) || (s && Array.isArray(s.sections) ? s.sections.slice() : []) },
86
+ look: lookOf(root, id, live.design, names),
66
87
  };
67
88
  }
89
+ /** The look the next cycle follows, the samples it follows, and the newest design version made after it and not saved yet. */
90
+ function lookOf(root, id, saved, names) {
91
+ let st = null; try { st = RD.stateOf(root, id); } catch { st = null; }
92
+ const follows = st ? [...st.samples.values()].map((x) => x.name).filter(Boolean).slice(0, 3) : [];
93
+ const top = st && st.versions.size ? Math.max(...st.versions.keys()) : null;
94
+ const newer = top != null && top !== saved && (saved == null || top > saved) ? { n: top, words: names.look(top) } : null;
95
+ return { saved, words: names.look(saved), follows, newer };
96
+ }
97
+
98
+ /** Save a template's sections and look in their own stores, as the studios' accept does, when they differ from what is saved now. A ref that is
99
+ * undefined (a T line from before TplA) is left as it is. */
100
+ function applyRefs(root, id, setup, want, actor) {
101
+ const live = RT.liveRefs(root, id), done = {};
102
+ if (want.structure !== undefined && want.structure !== live.structure) {
103
+ if (want.structure == null) { RSt.append(root, id, { ev: 'unsave' }, { actor }); try { fs.unlinkSync(RSt.paths(root, id).brief); } catch { /* none written */ } }
104
+ else {
105
+ if (!RSt.stateOf(root, id).versions.has(want.structure) || !RSt.readVersion(root, id, want.structure)) return { ok: false, status: 409, error: `S${want.structure} is not on disk, so its sections cannot come back` };
106
+ RSt.append(root, id, { ev: 'accept', n: want.structure, charts: [] }, { actor });
107
+ RSt.writeBrief(root, id, setup);
108
+ }
109
+ done.structure = true;
110
+ }
111
+ if (want.design !== undefined && want.design !== live.design) {
112
+ if (want.design == null) { RD.append(root, id, { ev: 'unsave' }, { actor }); try { fs.unlinkSync(RD.paths(root, id).brief); } catch { /* none written */ } }
113
+ else {
114
+ if (!RD.stateOf(root, id).versions.has(want.design)) return { ok: false, status: 409, error: `D${want.design} is not on disk, so its look cannot come back` };
115
+ RD.append(root, id, { ev: 'accept', n: want.design, rules: RD.rulesFor(root, id, want.design) }, { actor });
116
+ RD.writeBrief(root, id, setup);
117
+ }
118
+ done.design = true;
119
+ }
120
+ return { ok: true, done };
121
+ }
122
+ /** The sections the PM named, as a new S version (planFromSections keeps the saved plan's purpose, sources and charts). → { ok, n } | refusal.
123
+ * The same titles as saved write nothing (n is the saved one). */
124
+ function sectionsVersion(root, id, setup, titles, actor) {
125
+ const live = RT.liveRefs(root, id);
126
+ const was = live.structure != null ? RSt.readVersion(root, id, live.structure) : null;
127
+ const shaped = RSt.planFromSections(titles, { readers: (setup && setup.audience) || '', decision: (setup && setup.purpose) || '', base: was });
128
+ if (!shaped.ok) return shaped;
129
+ const same = was && Array.isArray(was.outline) && was.outline.map((o) => o.title).join('\n') === shaped.plan.outline.map((o) => o.title).join('\n');
130
+ if (same) return { ok: true, n: live.structure, same: true };
131
+ const v = RSt.landVersion(root, id, shaped.plan, { turn: 'The sections named in the chat.', answer: `Made the sections you named, for the next template.`, from: 'template', actor });
132
+ return v.ok ? { ok: true, n: v.n } : v;
133
+ }
68
134
 
69
135
  /** Apply a template's settings to the setup line (report-setups change and reschedule): the live values follow the current version. */
70
136
  function apply(root, id, settings, opts) {
@@ -79,10 +145,17 @@ function apply(root, id, settings, opts) {
79
145
  return { ok: true };
80
146
  }
81
147
 
148
+ const slotOfRel = (rel) => { const m = /(\d{4}-\d{2}-\d{2})\.md$/.exec(String(rel || '')); return m ? m[1] : null; };
149
+ /** The line that names the draft a change is for: its file, its slot, and the rule that no new slot is drafted. */
150
+ function draftWords(id, rel) {
151
+ const slot = slotOfRel(rel);
152
+ return rel && slot ? `The draft: ${rel} (slot ${slot}). Change that file in place: edit only the note or notes the change is about, and keep every caveat. `
153
+ + `Do not redraft for a change to the words. Only when the PM asks for fresh numbers, run \`pmos report draft ${id} ${slot} --redraft\`; never \`pmos report draft ${id}\` without the slot, and do not draft a new slot.` : '';
154
+ }
82
155
  /** The words the report's session gets for one change. */
83
156
  function askPrompt(skill, id, rel, change) {
84
157
  return `${skill}\n\nThe PM's request for this run, given when it was scheduled (do not ask again): ${id}\n\n`
85
- + `This is the report's own drafting session (the PMOS report flow), not the PM's chat. ${rel ? `Change the draft ${rel} in place. ` : ''}`
158
+ + `This is the report's own drafting session (the PMOS report flow), not the PM's chat. ${rel ? draftWords(id, rel) + ' ' : ''}`
86
159
  + `The change the PM asked for: ${change}\nChange only the draft. Never issue, send or share it: the PM issues it on the page.`;
87
160
  }
88
161
  function ask(ctx, w, id, change, rel) {
@@ -90,10 +163,10 @@ function ask(ctx, w, id, change, rel) {
90
163
  const d = draftingOf(ctx, w, id);
91
164
  const sess = readSession(root, id);
92
165
  const target = d && d.state === 'drafting' && alive(ctx, d.session) ? d.session : sess && alive(ctx, sess.session) ? sess.session : null;
93
- const msg = `[PMOS report change] ${rel ? `Draft ${rel}: ` : ''}${change}. Change only the draft; never issue, send or share it.`;
166
+ const msg = `[PMOS report change] ${rel ? draftWords(id, rel) + ' ' : ''}The change: ${change}. Change only the draft; never issue, send or share it.`;
94
167
  if (target) {
95
168
  const sent = typeof ctx.sendToSession === 'function' && ctx.sendToSession(target, msg);
96
- if (sent) return { ok: true, session: target, passed: true };
169
+ if (sent) return { ok: true, session: target, path: rel, passed: true };
97
170
  }
98
171
  if (typeof ctx.spawnSession !== 'function') return { ok: false, status: 409, error: 'This workspace cannot start a session for the report.' };
99
172
  const skill = typeof ctx.skillPrompt === 'function' ? ctx.skillPrompt('report-template', w) : null;
@@ -104,7 +177,7 @@ function ask(ctx, w, id, change, rel) {
104
177
  const sp = ctx.spawnSession({ name: 'Report · ' + ((s && s.name) || id), agent: 'claude', ws: w.id, origin, initialInput: askPrompt(skill, id, rel, change) + '\r' });
105
178
  if (!sp || sp.state === 'exited' || !sp.id) return { ok: false, status: 409, error: 'The report\'s session did not start: ' + ((sp && sp.note) || 'no session') };
106
179
  try { fs.mkdirSync(path.dirname(sessionFile(root, id)), { recursive: true }); fs.writeFileSync(sessionFile(root, id), JSON.stringify({ session: sp.id, since: new Date().toISOString() }) + '\n'); } catch { /* shown as no session */ }
107
- return { ok: true, session: sp.id, started: true };
180
+ return { ok: true, session: sp.id, path: rel, started: true };
108
181
  }
109
182
 
110
183
  async function handle(req, res, url, ctx) {
@@ -152,33 +225,56 @@ async function handle(req, res, url, ctx) {
152
225
  return r.ok ? send({ status: 200, body: { ok: true, n: r.n } }) : fail(r);
153
226
  }
154
227
  if (sub === 'template') {
155
- const want = RT.cleanSettings(body.settings);
156
- if (!want) return send(bad(400, 'say what changes: the format, who reads it or when it goes out'));
228
+ const want = { ...(RT.cleanSettings(body.settings) || {}) };
229
+ delete want.structure; delete want.design; // these come only as sections (titles) and design (a D version), checked below
230
+ const titles = Array.isArray(body.sections) ? body.sections : null;
231
+ const hasDesign = body.design === null || Number.isInteger(body.design);
232
+ if (!Object.keys(want).length && !titles && !hasDesign) return send(bad(400, 'say what changes: the format, who reads it, when it goes out, its sections or its look'));
233
+ if (Number.isInteger(body.design) && !RD.stateOf(w.root, id).versions.has(body.design)) return send(bad(404, 'no such design version'));
157
234
  RT.ensureFirst(w.root, id, s);
235
+ const live = RT.liveRefs(w.root, id);
158
236
  const f = RT.fold(RT.readLines(w.root, id)), cur = f.versions.find((v) => v.current);
159
- const next = { ...(cur ? cur.settings : RT.settingsOf(s)), ...want };
160
- if (cur && RT.sameSettings(cur.settings, next)) return send({ status: 200, body: { ok: true, n: cur.n, unchanged: true } });
237
+ // a T line from before TplA did not record its sections or look: it is compared as the ones saved now
238
+ const was = { ...live, ...(cur ? cur.settings : RT.settingsOf(s)) };
239
+ if (titles) {
240
+ const sv = sectionsVersion(w.root, id, s, titles, g.actor);
241
+ if (!sv.ok) return fail(sv);
242
+ want.structure = sv.n;
243
+ }
244
+ if (hasDesign) want.design = body.design;
245
+ const next = { ...was, ...want };
246
+ if (cur && RT.sameSettings(was, next)) return send({ status: 200, body: { ok: true, n: cur.n, unchanged: true } });
161
247
  const a = apply(w.root, id, next, opts);
162
248
  if (!a.ok) return fail(a);
163
- const v = RT.addVersion(w.root, id, next);
249
+ const ar = applyRefs(w.root, id, s, next, g.actor);
250
+ if (!ar.ok) return fail(ar);
251
+ const names = RT.namesOf(w.root, id);
252
+ const v = RT.addVersion(w.root, id, next, { names, why: RT.diffWords(was, next, names).join('; ').slice(0, 200) });
164
253
  return v.ok ? send({ status: 200, body: { ok: true, n: v.n, state: stateOf(ctx, w, id) } }) : fail(v);
165
254
  }
166
255
  if (sub === 'restore') {
167
- const f = RT.fold(RT.readLines(w.root, id)), v = f.versions.find((x) => x.n === +body.n);
256
+ const f = RT.backfill(w.root, id, RT.fold(RT.readLines(w.root, id))), v = f.versions.find((x) => x.n === +body.n);
168
257
  if (!v) return send(bad(404, 'no such template version'));
169
258
  const a = apply(w.root, id, v.settings, opts);
170
259
  if (!a.ok) return fail(a);
260
+ const ar = applyRefs(w.root, id, s, v.settings, g.actor);
261
+ if (!ar.ok) return fail(ar);
171
262
  const r = RT.restore(w.root, id, v.n);
172
- return r.ok ? send({ status: 200, body: { ok: true, n: r.n, state: stateOf(ctx, w, id) } }) : fail(r);
263
+ return r.ok ? send({ status: 200, body: { ok: true, n: r.n, restored: RT.settingsWords(v.settings, RT.namesOf(w.root, id)), state: stateOf(ctx, w, id) } }) : fail(r);
173
264
  }
174
265
  // ask
175
266
  const change = text(body.text).replace(/\s+/g, ' ').trim();
176
267
  if (!change) return send(bad(400, 'say the change'));
177
268
  if (change.length > MAX_ASK) return send(bad(413, 'a change is at most ' + MAX_ASK + ' characters'));
178
- const rel = text(body.path);
269
+ let rel = text(body.path);
179
270
  if (rel && !(new RegExp('(?:^|/)reports/' + id + '/\\d{4}-\\d{2}-\\d{2}\\.md$').test(rel) && !rel.split('/').includes('..'))) return send(bad(400, 'path must be a draft of this report'));
271
+ // The change goes to a draft that exists: the one named, else the latest cycle. An issued cycle is frozen, so its change is refused.
272
+ const cycles = cyclesOf(w.root, id);
273
+ const named = rel ? cycles.find((c) => c.slot === slotOfRel(rel)) : cycles[0];
274
+ if (named && named.state === 'issued') return send(bad(409, `the ${named.slot} report is issued, so it is frozen. Change the template for the next cycle instead`));
275
+ if (!rel && named) rel = named.path;
180
276
  const r = ask(ctx, w, id, change, rel || null);
181
277
  return r.ok ? send({ status: 200, body: r }) : fail(r);
182
278
  }
183
279
 
184
- module.exports = { prefixes, handle, stateOf, askPrompt };
280
+ module.exports = { prefixes, handle, stateOf, askPrompt, draftWords };
@@ -276,7 +276,7 @@ async function tick(ctx, now = new Date()) {
276
276
  function exportAnswer(ctx, res, w, url) {
277
277
  const rel = text(url.searchParams.get('path'));
278
278
  const fmt = text(url.searchParams.get('fmt'));
279
- if (fmt === 'pdf') return bad(400, 'PDF is printed by the browser. Use Print in the document.');
279
+ if (fmt === 'pdf') return bad(400, 'PDF is printed by the browser, so there is no PDF file to download here. Open the report, press Download › PDF, and choose Save as PDF in the print window: you get the same slides, one on each page.');
280
280
  if (!Object.prototype.hasOwnProperty.call(RX.FORMATS, fmt)) return bad(400, `fmt must be one of ${Object.keys(RX.FORMATS).join(', ')}`);
281
281
  if (!RC.isReportPath(rel)) return bad(400, 'path must be a report document: <state>/reports/<cadence>/<slot>.md');
282
282
  const abs = path.resolve(w.root);
@@ -288,7 +288,9 @@ function exportAnswer(ctx, res, w, url) {
288
288
  if (!real.startsWith(fs.realpathSync(abs) + path.sep)) return bad(404, 'outside workspace');
289
289
  md = fs.readFileSync(real, 'utf8');
290
290
  } catch { return bad(404, 'no such document'); }
291
- const out = RX.exportOf(md, fmt);
291
+ const cyc = /(?:^|\/)reports\/([a-z][a-z0-9-]{0,39})\/\d{4}-\d{2}-\d{2}\.md$/.exec(rel);
292
+ const RPy = require('./report-pyramid.js');
293
+ const out = RX.exportOf(md, fmt, fmt === 'pptx' && cyc ? { look: RPy.lookOf(abs, cyc[1]), data: RPy.dataOf(full) } : {});
292
294
  if (!out.ok) return bad(500, out.error);
293
295
  return { status: 200, file: out };
294
296
  }
@@ -42,6 +42,7 @@ const path = require('path');
42
42
  const os = require('os');
43
43
  const guard = require('./pm-guard.js');
44
44
  const S = require('./report-structure.js');
45
+ const RT = require('./report-templates.js');
45
46
  const CS = require('./charts-store.js');
46
47
  const RS = require('./report-setups.js');
47
48
  const RDraft = require('./report-draft.js');
@@ -775,7 +776,9 @@ async function handle(req, res, url, ctx) {
775
776
  c.chart = r.chart.id; written.push(r.chart.id);
776
777
  }
777
778
  if (written.length) S.rewriteVersion(root, id, n, next);
779
+ const tw = RT.followBefore(root, id, setup);
778
780
  S.append(root, id, { ev: 'accept', n, charts: written }, { actor: g.actor });
781
+ RT.followAfter(root, id, { structure: n }, tw); // the template follows the saved sections as T<n+1> (canvas TplA)
779
782
  const b = S.writeBrief(root, id, setup);
780
783
  if (!b.ok) return fail(b);
781
784
  return send({ status: 200, body: { ok: true, structure: answerOf(root, id), brief: b.path, charts: written } });
@@ -6,6 +6,7 @@
6
6
  * POST /api/workflows/save { ws, wf, moveRemoved? } → { ok, workflow, moved } | 409 { ok:false, error, inUse }
7
7
  * POST /api/workflows/copy { ws, id, name? } → { ok, workflow }
8
8
  * POST /api/workflows/archive { ws, id, archived } → { ok } | 409 { inUse }
9
+ * POST /api/workflows/default { ws, id } → { ok, id } the star: the workflow a new initiative starts on (batch 24 item 3, DfA)
9
10
  * POST /api/workflows/draft { ws, draft | null, note? } → { ok, draft } (the page relays the chat's draft; the agent writes through `pmos workflow draft`)
10
11
  * GET /api/workflows/proposals → { ok, items } Claude's ✦: move the initiatives of a replaced workflow (batch 20 item 3, lib/wf-bridge.js)
11
12
  * POST /api/workflows/bridge { ws, id, from, map } → { ok, workflow } the bridge saved with the new workflow (WmB, Settings)
@@ -68,7 +69,7 @@ async function handle(req, res, url, ctx) {
68
69
  if (!read.ok) return send(bad(400, 'the body is not JSON'));
69
70
  const body = read.body;
70
71
  if (!isObj(body)) return send(bad(400, 'the body must be a JSON object'));
71
- if (req.headers && req.headers['x-pmos-session']) return send(bad(403, 'an agent session cannot save, copy or archive a workflow. It drafts one with `pmos workflow draft`, and the PM presses Create'));
72
+ if (req.headers && req.headers['x-pmos-session']) return send(bad(403, 'an agent session cannot save, copy, archive or star a workflow. It drafts one with `pmos workflow draft`, and the PM presses Create'));
72
73
  const g = guard.requirePagePress(req, body);
73
74
  if (!g.ok) return send(bad(g.status, g.error));
74
75
  const w = wsOf(ctx, typeof body.ws === 'string' ? body.ws : null);
@@ -101,6 +102,10 @@ async function handle(req, res, url, ctx) {
101
102
  const r = WS.archive(dir, body.id, body.archived, g.actor, { inUse });
102
103
  return send(r.ok ? { body: { ok: true } } : bad(r.status || 400, r.error, r.inUse ? { inUse: r.inUse } : null));
103
104
  }
105
+ if (p === '/api/workflows/default') {
106
+ const r = WS.setDefault(dir, body.id, g.actor);
107
+ return send(r.ok ? { body: { ok: true, id: r.id } } : bad(r.status || 400, r.error));
108
+ }
104
109
  if (p === '/api/workflows/bridge' || p === '/api/workflows/switch-all' || p === '/api/workflows/switch-all/undo') {
105
110
  if (p !== '/api/workflows/switch-all/undo' && (typeof body.id !== 'string' || !body.id || typeof body.from !== 'string' || !body.from)) return send(bad(400, 'id (the new workflow) and from (the one it replaces) are required'));
106
111
  const r = p === '/api/workflows/bridge' ? Bridge.saveBridge(ctx, w, body, g.actor) : p === '/api/workflows/switch-all' ? Bridge.switchAll(ctx, w, body, g) : Bridge.undoAll(ctx, w, body, g);
@@ -69,12 +69,14 @@ function proposals(ctx) {
69
69
  const dir = home && ctx.stateDirFor ? ctx.stateDirFor(home) : null;
70
70
  if (!dir) return [];
71
71
  const lib = WS.library(dir);
72
+ // Most libraries replace nothing: then no workspace's initiatives are read at all (page-open-speed: New for you read every workspace's
73
+ // board frame on each Home open, ~100 ms on the thread every answer waits for, to find there was no ✦ to show).
74
+ const pairs = [...lib.byId.values()].map((wf) => [wf, replacesOf(lib, wf)]).filter(([, from]) => from);
75
+ if (!pairs.length) return [];
72
76
  const all = [];
73
77
  for (const w of workspaces(ctx)) { const st = standing(ctx, w); if (st) all.push(...st); }
74
78
  const out = [];
75
- for (const wf of lib.byId.values()) {
76
- const from = replacesOf(lib, wf);
77
- if (!from) continue;
79
+ for (const [wf, from] of pairs) {
78
80
  const mine = all.filter((x) => movable(x, from.id));
79
81
  if (!mine.length) continue;
80
82
  const map = WF.mapOf(from, wf);
@@ -105,8 +105,8 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
105
105
 
106
106
  if (verb === 'list') {
107
107
  const all = WF.list(WS.library(stateDir));
108
- if (json) { say(JSON.stringify({ ok: true, workflows: all.map((w) => ({ id: w.id, name: w.name, stages: w.stages.length, archived: !!w.archived })) })); return 0; }
109
- for (const w of all) say(`${w.id} · ${w.name} · ${w.stages.length} stage${w.stages.length === 1 ? '' : 's'}${w.archived ? ' · archived' : ''}`);
108
+ if (json) { say(JSON.stringify({ ok: true, workflows: all.map((w) => ({ id: w.id, name: w.name, stages: w.stages.length, archived: !!w.archived, default: !!w.default })) })); return 0; }
109
+ for (const w of all) say(`${w.id} · ${w.name} · ${w.stages.length} stage${w.stages.length === 1 ? '' : 's'}${w.archived ? ' · archived' : ''}${w.default ? ' · default for new initiatives' : ''}`);
110
110
  return 0;
111
111
  }
112
112
  if (verb === 'show') {
@@ -6,6 +6,7 @@
6
6
  * copy(stateDir, id, name, actor) → { ok, workflow }
7
7
  * adopt(stateDir, wf, actor, fromWs) → { ok, workflow } another workspace's workflow written into this library
8
8
  * archive(stateDir, id, archived, actor, { inUse }) → { ok }
9
+ * setDefault(stateDir, id, actor) → { ok, id } the workflow a new initiative starts on (one wf-default line)
9
10
  * saveDraft(stateDir, { draft, note }, actor) → { ok, draft } (the chat's draft; null clears it)
10
11
  * readDraft(stateDir) → { draft, note, at } | null
11
12
  * judge(stateDir, { board, id, stage, runs, why, cite, undo }, actor) → { ok } one stage-judge line in boards.jsonl
@@ -89,9 +90,22 @@ function archive(stateDir, id, archived, actor, { inUse = [] } = {}) {
89
90
  if (!lib.byId.has(id)) return { ok: false, status: 404, error: 'That workflow could not be found. It may have been removed.' };
90
91
  if (archived && inUse.length) return { ok: false, status: 409, error: `${inUse.length} ${inUse.length === 1 ? 'initiative runs' : 'initiatives run'} this workflow. Move ${inUse.length === 1 ? 'it' : 'them'} to another workflow first.`, inUse };
91
92
  append(fileOf(stateDir), actor, { kind: 'wf-archive', id, archived: !!archived });
93
+ // Archiving the default puts Product feature back as the default, so a later Restore does not bring the old default back silently
94
+ if (archived && lib.defaultId === id) append(fileOf(stateDir), actor, { kind: 'wf-default', id: W.PRODUCT_FEATURE_ID });
92
95
  return { ok: true };
93
96
  }
94
97
 
98
+ /** The default workflow for new initiatives in this workspace (batch 24 item 3, canvas DfA). Product feature can be the default. */
99
+ function setDefault(stateDir, id, actor) {
100
+ if (!stateDir) return { ok: false, status: 409, error: NO_STATE };
101
+ const lib = library(stateDir);
102
+ const wf = typeof id === 'string' && id ? lib.byId.get(id) : null;
103
+ if (!wf) return { ok: false, status: 404, error: 'That workflow could not be found. It may have been removed.' };
104
+ if (wf.archived) return { ok: false, status: 409, error: `${wf.name} is archived. Restore it first.` };
105
+ append(fileOf(stateDir), actor, { kind: 'wf-default', id });
106
+ return { ok: true, id };
107
+ }
108
+
95
109
  /** The chat's draft. It is checked like a save (so the Viewer never shows a draft Create would refuse); null clears it. */
96
110
  function saveDraft(stateDir, { draft, note } = {}, actor) {
97
111
  if (!stateDir) return { ok: false, status: 409, error: NO_STATE };
@@ -116,4 +130,4 @@ function judge(stateDir, { board, id, stage, runs, why, cite, undo } = {}, actor
116
130
  return { ok: true };
117
131
  }
118
132
 
119
- module.exports = { FILE, library, save, copy, adopt, archive, saveDraft, readDraft, judge, fileOf };
133
+ module.exports = { FILE, library, save, copy, adopt, archive, setDefault, saveDraft, readDraft, judge, fileOf };
@@ -12,6 +12,7 @@ const path = require('path');
12
12
  const fs = require('fs');
13
13
  const { spawn } = require('child_process');
14
14
  const { findBrowser } = require('./lib/find-browser');
15
+ const { openMission } = require('./lib/open-mission');
15
16
  const T = require('./test-run').configure({ name: 'metric-drafts-ui' });
16
17
  const BP = require('./lib/board-premium.js');
17
18
  const F = require('./fixtures/board-premium-facts.js');
@@ -68,7 +69,7 @@ const INITIATIVE = () => ({
68
69
  const shot = async (p, name) => { if (SHOTS) { await sleep(450); await p.screenshot({ path: path.join(SHOTS, name + '.png') }); } };
69
70
 
70
71
  const open = async ({ w = 1440, h = 900, drafts = { program: PROGRAM(), initiative: INITIATIVE() }, dark = false } = {}) => {
71
- const c = await browser.newContext({ viewport: { width: w, height: h }, colorScheme: dark ? 'dark' : 'light' });
72
+ const c = await openMission(await browser.newContext({ viewport: { width: w, height: h }, colorScheme: dark ? 'dark' : 'light' }));
72
73
  const page = await c.newPage();
73
74
  const errors = [], posts = [], gets = [];
74
75
  page.on('pageerror', (e) => errors.push(String((e && e.stack) || e)));
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.493.0",
3
+ "version": "0.495.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.493.0",
9
+ "version": "0.495.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@anthropic-ai/sdk": "^1.3.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.493.0",
3
+ "version": "0.495.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",
@@ -10,6 +10,7 @@ const fs = require('fs');
10
10
  const path = require('path');
11
11
  const { spawn } = require('child_process');
12
12
  const { findBrowser } = require('./lib/find-browser');
13
+ const { openMission } = require('./lib/open-mission');
13
14
  const T = require('./test-run').configure({ name: 'premium-board-actions' });
14
15
  const { makeRepos, codeRepo, write, git } = require('./plan-test-repos');
15
16
  const BP = require('./lib/board-premium.js');
@@ -71,7 +72,7 @@ const ALLOWED = /^\/api\/(?:boards\/(?:asks\/(?:add|accept|decline)|move|archive
71
72
 
72
73
  browser = await chromium.launch({ executablePath: exe });
73
74
  const open = async (url, { w = 1280, h = 900 } = {}) => {
74
- const ctx = await browser.newContext({ viewport: { width: w, height: h } });
75
+ const ctx = await openMission(await browser.newContext({ viewport: { width: w, height: h } }));
75
76
  await ctx.addInitScript(() => { try { localStorage.setItem('pmos.premium.openAs', 'popup'); } catch { /* none */ } });
76
77
  const page = await ctx.newPage(), errors = [], posts = [];
77
78
  page.on('pageerror', (e) => errors.push(String((e && e.message) || e)));
@@ -46,7 +46,7 @@ const ANSWER = { role: 'assistant', text: 'Signups from the lightbox rose to 1,2
46
46
  const look = () => {
47
47
  const vw = document.querySelector('#czViewer:not([hidden])'), pane = vw ? vw.querySelector('.vz[data-vw^="report:"]') : null;
48
48
  return { shut: !pane, title: vw ? ((vw.querySelector('.cz-vw-title') || {}).textContent || '') : '', reader: !!(vw && vw.querySelector('.vz-rp0')), review: vw && vw.querySelector('.rf-rv') ? vw.querySelector('.rf-rv').textContent : null,
49
- tpl: !!(vw && vw.querySelector('[data-cz-vw-p="template"]')), layer: false, chip: (document.querySelector('#hmLog [data-report-open]') || {}).textContent || null,
49
+ tpl: !!(vw && vw.querySelector('[data-cz-vw-p="template"]')) && !!(vw && vw.querySelector('[data-cz-vw-p="chat"]')), layer: false, chip: (document.querySelector('#hmLog [data-report-open]') || {}).textContent || null,
50
50
  json: /pmos-report|"kind"/.test((document.querySelector('#hmLog') || {}).textContent || ''), user: [...document.querySelectorAll('#hmLog .hm-u .hm-bub')].map((u) => u.textContent.trim()),
51
51
  tabs: pane ? [(vw.querySelector('.cz-vw-kind') || {}).textContent || ''] : [], pane: pane ? pane.textContent.replace(/\s+/g, ' ').trim() : null,
52
52
  secs: pane ? (pane.querySelector('.vz-dkt') ? pane.querySelectorAll('.vz-dkt button').length - 1 : pane.querySelectorAll('.vz-secs li').length) : 0,
@@ -100,6 +100,10 @@ const look = () => {
100
100
  ok('R4 Draft it records the report, pauses it (Once) and records T1', !!setup && setup.output === 'google-slides' && setup.audience === 'Growth leads' && L.some((l) => l.ev === 'pause' && l.id === setup.id) && T1 && T1.current === 1, JSON.stringify([L.map((l) => l.ev), T1]));
101
101
  ok('R4 the report\'s current state opens: its first draft in the reader, Claude\'s review under it (RfH2), the template icon in the header', s3.reader && /I read it as your readers would/.test(s3.review || '') && s3.tpl, JSON.stringify(s3).slice(0, 600));
102
102
  if (shots) await p.screenshot({ path: `${shots}/draft-1280-light.png` });
103
+ // the chat icon (Wawan, 2026-11-02): the report's own conversation opens in Home's chat, the report stays beside it
104
+ await p.click('#czViewer [data-cz-vw-p="chat"]');
105
+ const ch = await until(() => p.evaluate(() => { const i = document.getElementById('hmIn'); return i && i.placeholder === 'Change the report' ? { ph: i.placeholder, log: (document.getElementById('hmLog') || {}).textContent || '', reader: !!document.querySelector('#czViewer .vz-slide, #czViewer .vz-rp0') } : null; }), 8000);
106
+ ok('R4 the chat icon opens the report\'s own conversation beside it: Claude asks what to change, the report stays open', !!ch && /Tell me what to change in/.test(ch.log) && ch.reader, JSON.stringify(ch));
103
107
  await p.click('#czViewer [data-cz-vw-p="template"]');
104
108
  await until(() => p.evaluate(() => !!document.querySelector('#czViewer .vz[data-panel="template"] [data-rt="fmt|google-doc"]')));
105
109
  await p.click('#czViewer [data-rt="fmt|google-doc"]');
@@ -7,6 +7,7 @@
7
7
  const path = require('path');
8
8
  const { spawn } = require('child_process');
9
9
  const { findBrowser } = require('./lib/find-browser');
10
+ const { openMission } = require('./lib/open-mission');
10
11
  const T = require('./test-run').configure({ name: 'premium-vw-backlog' });
11
12
  const BP = require('./lib/board-premium.js');
12
13
  const F = require('./fixtures/board-premium-facts.js');
@@ -46,7 +47,7 @@ const SPRINTS = { ok: true, source: 'jira', readAt: '2026-10-19T09:00:00Z', velo
46
47
  browser = await chromium.launch({ executablePath: exe });
47
48
 
48
49
  const open = async ({ w = 1280, h = 900, theme = 'light', sprints = SPRINTS } = {}) => {
49
- const ctx = await browser.newContext({ viewport: { width: w, height: h } });
50
+ const ctx = await openMission(await browser.newContext({ viewport: { width: w, height: h } }));
50
51
  const page = await ctx.newPage();
51
52
  const S = { errors: [], reads: [] };
52
53
  page.on('pageerror', (e) => S.errors.push(String((e && e.message) || e)));
@@ -120,7 +121,7 @@ const SPRINTS = { ok: true, source: 'jira', readAt: '2026-10-19T09:00:00Z', velo
120
121
 
121
122
  /* the Board (premium-uncovered-features, BdSprint) carries no Sprint card and no dialog; its sprint chip opens the Sprints and releases popover, whose Backlog row opens the backlog in the side panel */
122
123
  {
123
- const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }), page = await ctx.newPage(), errs = [];
124
+ const ctx = await openMission(await browser.newContext({ viewport: { width: 1280, height: 900 } })), page = await ctx.newPage(), errs = [];
124
125
  page.on('pageerror', (e) => errs.push(String((e && e.message) || e)));
125
126
  const model = BP.build(F.facts({ sprints: { source: 'jira', readAt: SPRINTS.readAt, sprints: [SPRINT] } }));
126
127
  await page.route('**/api/board-premium*', (r) => r.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, state: 'ready', model, as_of: Date.now() }) }));
@@ -8,6 +8,7 @@
8
8
  const path = require('path');
9
9
  const { spawn } = require('child_process');
10
10
  const { findBrowser } = require('./lib/find-browser');
11
+ const { openMission } = require('./lib/open-mission');
11
12
  const T = require('./test-run').configure({ name: 'premium-vw-item' });
12
13
  const IP = require('./lib/initiative-premium.js');
13
14
  const F = require('./fixtures/initiative-premium-facts.js');
@@ -41,7 +42,7 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
41
42
  const MODEL = IP.build(F.facts());
42
43
 
43
44
  const open = async ({ w = 1280, h = 900, theme = 'light', answer = null } = {}) => {
44
- const ctx = await browser.newContext({ viewport: { width: w, height: h } });
45
+ const ctx = await openMission(await browser.newContext({ viewport: { width: w, height: h } }));
45
46
  const page = await ctx.newPage();
46
47
  const S = { errors: [], gets: [], vgets: [] }; // vgets: the Viewer's own reads (no page headers), apart from Home's
47
48
  page.on('pageerror', (e) => S.errors.push(String((e && e.message) || e)));
@@ -106,7 +107,7 @@ const openCard = async (pg, uid, V = '#czViewer') => {
106
107
  /* R9 the Board (premium-agreed-design): the Agreed Board opens an initiative in its side panel and never the dialog; its expand leads to the
107
108
  * initiative (Home's initiative layout), where the record is. R7 floors at 390 in dark: the same record, opened from Home's Overview. */
108
109
  {
109
- const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } });
110
+ const ctx = await openMission(await browser.newContext({ viewport: { width: 390, height: 844 } }));
110
111
  const page = await ctx.newPage(); const errors = [];
111
112
  page.on('pageerror', (e) => errors.push(String((e && e.message) || e)));
112
113
  await page.route('**/api/board-premium*', (r) => r.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, state: 'ready', model: BP.build(BF.facts()), as_of: Date.now() }) }));
@@ -10,6 +10,7 @@
10
10
  const path = require('path');
11
11
  const { spawn } = require('child_process');
12
12
  const { findBrowser } = require('./lib/find-browser');
13
+ const { openMission } = require('./lib/open-mission');
13
14
  const T = require('./test-run').configure({ name: 'premium-vw-jira' });
14
15
  const BP = require('./lib/board-premium.js');
15
16
  const F = require('./fixtures/board-premium-facts.js');
@@ -75,7 +76,7 @@ const TICKETS = {
75
76
  const overview = async (pg) => { await (async (g) => { const P = '#czViewer:not([hidden]) [data-hi-v="page"]', r = '#hmList [data-hm-sel^="i:"][aria-current="true"]'; for (let i = 0; i < 4 && !(await g.$(P)) && (await g.$('#czViewer:not([hidden]) [data-cz-vw="back"]')); i++) { await g.click('#czViewer [data-cz-vw="back"]'); await g.waitForTimeout(150); } if (await g.$(P)) return; if (await g.$(r)) await g.click(r); else await g.reload(); await g.waitForSelector(P + ' .iv', { timeout: 30000 }); })(pg); /* ← back to the initiative's page, else its row, else the address again (an initiative the list does not show) */ /* the page holds the Tasks (batch 11 item 20) */ return until(() => pg.evaluate(() => !!document.querySelector('#czViewer:not([hidden]) [data-hi-v]'))); };
76
77
  const openSprint = async (pg) => { if (!(await pg.$('#czViewer:not([hidden]) [data-hi-v]'))) await overview(pg); /* open() already showed the Overview */ await viaMore(pg, 'Sprint'); };
77
78
  const open = async ({ w = 1280, h = 900, theme = 'light', sprints = SPRINTS, tickets = TICKETS } = {}) => {
78
- const ctx = await browser.newContext({ viewport: { width: w, height: h }, colorScheme: theme });
79
+ const ctx = await openMission(await browser.newContext({ viewport: { width: w, height: h }, colorScheme: theme }));
79
80
  await ctx.addInitScript((t) => { try { localStorage.setItem('pmos.c3.theme', t); } catch { /* none */ } }, theme);
80
81
  const page = await ctx.newPage(), errors = [], posts = [], reads = [];
81
82
  page.on('pageerror', (e) => errors.push(String((e && e.message) || e)));
@@ -193,7 +194,7 @@ const TICKETS = {
193
194
 
194
195
  /* A the Board (premium-uncovered-features, BdSprint): its sprint chip names the sprint and opens the Sprints and releases popover; it opens no dialog */
195
196
  {
196
- const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }), pg = await ctx.newPage(), errs = [];
197
+ const ctx = await openMission(await browser.newContext({ viewport: { width: 1280, height: 900 } })), pg = await ctx.newPage(), errs = [];
197
198
  pg.on('pageerror', (e) => errs.push(String((e && e.message) || e)));
198
199
  await pg.route('**/api/board-premium*', (r) => r.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, state: 'ready', model: model({ source: 'jira', readAt: new Date().toISOString(), sprints: [{ name: 'Sprint 14', state: 'active', start: '2026-10-07', end: '2026-10-21', counts: { todo: 2, doing: 1, done: 1 }, issues: [] }] }), as_of: Date.now() }) }));
199
200
  await pg.goto(BASE + '/premium-board.html');
@@ -15,6 +15,7 @@
15
15
  const path = require('path');
16
16
  const { spawn } = require('child_process');
17
17
  const { findBrowser } = require('./lib/find-browser');
18
+ const { openMission } = require('./lib/open-mission');
18
19
  const T = require('./test-run').configure({ name: 'program-panel' });
19
20
  const BP = require('./lib/board-premium.js');
20
21
  const F = require('./fixtures/board-premium-facts.js');
@@ -46,7 +47,7 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
46
47
  browser = await chromium.launch({ executablePath: exe });
47
48
 
48
49
  const open = async ({ w = 1440, h = 900, qs = '?ws=idpf&program=project-ascent', ctx = null, programs = F.programs() } = {}) => {
49
- const c = ctx || await browser.newContext({ viewport: { width: w, height: h } });
50
+ const c = ctx || await openMission(await browser.newContext({ viewport: { width: w, height: h } }));
50
51
  const page = await c.newPage();
51
52
  const errors = [], posts = [];
52
53
  page.on('pageerror', (e) => errors.push(String((e && e.stack) || e)));
@@ -232,6 +232,7 @@ body.cz-home { min-height: 100vh; overflow: hidden; }
232
232
  .hm-u { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
233
233
  .hm-bub { max-width: 80%; padding: 10px 16px; border-radius: 18px 18px 8px 18px; background: var(--cz-bubble); font-size: var(--fs-3); line-height: 1.5; color: var(--cz-ink); }
234
234
  .hm-you { font-size: var(--fs-1); color: var(--cz-ink3); }
235
+ .hm-pend.sending .hm-bub { opacity: .72; } /* batch 24 item 2: the bubble shows at once, a touch lighter until PMOS takes it */
235
236
  .hm-worked { padding-left: 38px; }
236
237
  .hm-wk { display: flex; align-items: center; gap: 8px; max-width: 100%; min-height: 32px; padding: 0 4px; border: 0; border-radius: 8px; background: none; color: var(--cz-ink3); font-size: var(--fs-2); cursor: pointer; text-align: left; } /* a plain grey line (AgChatAsk) */
237
238
  .hm-wk:hover { color: var(--cz-ink2); }
@@ -298,3 +298,15 @@ button.rp-cc { cursor: pointer; }
298
298
  .rp-rfh .rp-bar { justify-content: flex-end; }
299
299
  .rp-foot { display: flex; justify-content: flex-end; gap: 8px; margin-top: auto; padding-top: 4px; }
300
300
  .rp-foot .rp-pri { width: 40px; height: 40px; margin: 0; }
301
+
302
+ /* a template's sections and look (canvas TplA, brief 3; public/pm/report-flow.js): chips in order, a small title slide and one line */
303
+ .rf-cps { display: flex; flex-wrap: wrap; gap: 6px; }
304
+ .rf-cp { display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 99px; border: 1px solid var(--cz-hair); background: var(--cz-card); color: var(--cz-ink2); font-size: var(--fs-2); }
305
+ .rf-cp[data-k="new"] { border-color: color-mix(in oklab, var(--cz-blue) 55%, transparent); background: var(--cz-bluebg); color: var(--cz-blueink); }
306
+ .rf-cp[data-k="gone"] { border-style: dashed; color: var(--cz-ink3); }
307
+ .rf-lk { display: flex; align-items: center; gap: 12px; }
308
+ .rf-mini { width: 96px; height: 54px; flex: none; border-radius: 7px; display: flex; align-items: flex-end; padding: 6px 7px; box-sizing: border-box; background: linear-gradient(135deg, var(--cz-c-2546b8), var(--cz-c-1b3a9e)); color: var(--cz-c-ffffff); font-size: var(--fs-1); line-height: 1.2; overflow: hidden; }
309
+ .rf-mini[data-k="own"] { background: var(--cz-well2); color: var(--cz-ink); border: 1px solid var(--cz-hair); }
310
+ .rf-lk .rf-fix small { color: var(--cz-ink3); font-size: var(--fs-2); }
311
+ .rf-tplx { padding: 10px 12px; border: 1px solid var(--cz-hair); border-radius: 14px; background: var(--cz-bluebg); }
312
+ .rf-hv em.rf-rest { color: var(--cz-ink2); }
@@ -0,0 +1,38 @@
1
+ /* pm/fetch-share.js — one request for the same answer while it is on its way (page-open-speed).
2
+ *
3
+ * Imported FIRST by the page entries (home-app.js, premium-board.js, premium-insights.js). Parts of a page ask for the same GET on their
4
+ * own, so Home asked for /api/notes three times and /api/settings twice in its opening burst, and the Board asked for /api/board-premium,
5
+ * /api/board-publish, /api/sources and /api/settings twice each (bench 2026-11-02). Every one of them waits on the server's one thread.
6
+ *
7
+ * The rule: a GET to /api/ with the same address as one that is still on its way gets a copy of that one's answer instead of a second
8
+ * request. Nothing is kept once the answer is in, so a later read always asks the server again. Any other method (a press) first drops
9
+ * what is on its way, so a read made after a write never gets an answer that was asked for before it. Only plain same-origin GETs with no
10
+ * body, signal or cache option are shared; everything else goes to fetch as before. */
11
+ const KEY = '__pmosFetchShare';
12
+ if (typeof window !== 'undefined' && typeof window.fetch === 'function' && !window[KEY]) {
13
+ const real = window.fetch.bind(window);
14
+ const flying = new Map(); // url → Promise<Response>
15
+ window[KEY] = { flying };
16
+ window.fetch = function shared(input, init) {
17
+ let url = null, method = 'GET';
18
+ try {
19
+ url = typeof input === 'string' ? input : (typeof URL !== 'undefined' && input instanceof URL ? input.href : null);
20
+ method = String((init && init.method) || (input && typeof input === 'object' && input.method) || 'GET').toUpperCase();
21
+ } catch { url = null; }
22
+ if (method !== 'GET' && method !== 'HEAD') { flying.clear(); return real(input, init); }
23
+ if (!url || method !== 'GET') return real(input, init); // a Request object or a HEAD goes to fetch as before
24
+ if (init && (init.body != null || init.signal || init.cache || init.keepalive)) return real(input, init);
25
+ let abs;
26
+ try { abs = new URL(url, location.href); } catch { return real(input, init); }
27
+ if (abs.origin !== location.origin || !abs.pathname.startsWith('/api/')) return real(input, init);
28
+ const key = abs.href;
29
+ const hit = flying.get(key);
30
+ if (hit) return hit.then((r) => r.clone());
31
+ const p = real(input, init);
32
+ flying.set(key, p);
33
+ const done = () => { if (flying.get(key) === p) flying.delete(key); };
34
+ p.then(done, done);
35
+ return p.then((r) => r.clone());
36
+ };
37
+ }
38
+ export {};