create-pmos 0.531.0 → 0.533.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.531.0",
3
+ "version": "0.533.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -32,6 +32,32 @@
32
32
  "audit"
33
33
  ],
34
34
  "pmosChanges": [
35
+ {
36
+ "v": "0.533.0",
37
+ "at": "2026-11-04T19:35:12.134Z",
38
+ "f": 4,
39
+ "prs": [
40
+ {
41
+ "n": 1249,
42
+ "t": "Component slots on report slides",
43
+ "s": "Component slots on report slides",
44
+ "k": "new"
45
+ }
46
+ ]
47
+ },
48
+ {
49
+ "v": "0.532.0",
50
+ "at": "2026-11-04T19:01:35.763Z",
51
+ "f": 4,
52
+ "prs": [
53
+ {
54
+ "n": 1248,
55
+ "t": "Report templates T02–T08",
56
+ "s": "Report templates T02–T08",
57
+ "k": "new"
58
+ }
59
+ ]
60
+ },
35
61
  {
36
62
  "v": "0.531.0",
37
63
  "at": "2026-11-04T18:44:50.726Z",
@@ -393,32 +419,6 @@
393
419
  "k": "fix"
394
420
  }
395
421
  ]
396
- },
397
- {
398
- "v": "0.513.0",
399
- "at": "2026-11-03T15:46:14.696Z",
400
- "f": 4,
401
- "prs": [
402
- {
403
- "n": 1203,
404
- "t": "Typography roles with Plus Jakarta Sans, two weights, four sizes",
405
- "s": "Typography roles with Plus Jakarta Sans, two weights, four sizes",
406
- "k": "new"
407
- }
408
- ]
409
- },
410
- {
411
- "v": "0.512.0",
412
- "at": "2026-11-03T15:33:10.629Z",
413
- "f": 4,
414
- "prs": [
415
- {
416
- "n": 1204,
417
- "t": "Suggest the next prompt and sharpen a rough one in the chat",
418
- "s": "Suggest the next prompt and sharpen a rough one in the chat",
419
- "k": "new"
420
- }
421
- ]
422
422
  }
423
423
  ]
424
424
  }
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@add0a4c9
1
+ kernel-source: pmos@2120ce89
2
2
  assembled: 2026-11-04
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-11-04T18:44:47Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-04T19:35:08Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-11-04T18:44:47Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-04T19:35:08Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-11-04T18:44:47Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-04T19:35:08Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -1,4 +1,30 @@
1
1
  [
2
+ {
3
+ "v": "0.533.0",
4
+ "at": "2026-11-04T19:35:12.134Z",
5
+ "f": 4,
6
+ "prs": [
7
+ {
8
+ "n": 1249,
9
+ "t": "Component slots on report slides",
10
+ "s": "Component slots on report slides",
11
+ "k": "new"
12
+ }
13
+ ]
14
+ },
15
+ {
16
+ "v": "0.532.0",
17
+ "at": "2026-11-04T19:01:35.763Z",
18
+ "f": 4,
19
+ "prs": [
20
+ {
21
+ "n": 1248,
22
+ "t": "Report templates T02–T08",
23
+ "s": "Report templates T02–T08",
24
+ "k": "new"
25
+ }
26
+ ]
27
+ },
2
28
  {
3
29
  "v": "0.531.0",
4
30
  "at": "2026-11-04T18:44:50.726Z",
@@ -92,6 +92,10 @@ pmos report issue biweekly 2026-10-16
92
92
 
93
93
  `pmos report library` writes one page with every slide layout of the report library, filled with placeholder words. Open it in a browser to see the layouts a report can use. Add `--out <file>` to choose the file.
94
94
 
95
+ The library also has seven report templates: weekly-review, program-kickoff, meeting-recap, portfolio-snapshot, quarterly-review, decision-brief and incident-review. A template is a default order of slides, not a fixed structure. `pmos report library --template <id>` draws one template with placeholder words. `pmos report make <id> --template <id>` starts a report from it: the writer uses its order and adds or drops slides when the findings say so. A recurring report keeps the outline its readers agreed, so the template is used only for a report that has none.
96
+
97
+ A slide can also hold a PMOS component instead of a layout: today, the KR metric tile of the Insights page, as a line, bars, a meter or a number. PMOS draws it from the KR metric record when the report is written and keeps a dated snapshot with the report, so an old issue shows the figures of its day. The slide says when the snapshot was taken, and the PowerPoint file draws the same tile.
98
+
95
99
  Every command accepts `--root <dir>` to name the repository. `draft` and `list` also accept `--json`.
96
100
 
97
101
  ## The window
@@ -40,7 +40,7 @@ const { parseArgs } = require('./schedule-cli');
40
40
  const COMMANDS = { report: ['make', 'read', 'find', 'write', 'check', 'rule', 'draft', 'issue', 'list', 'propose', 'definitions', 'setups', 'finder', 'library'] };
41
41
  const USAGE = [
42
42
  'usage: pmos report make|read|find|write|check|rule — the report engine: brief, read, find, answer, render (any report, any shape)',
43
- ' pmos report make <id> [--ask "<the question>"] [--readers "<who>"] [--source <kind:ref>,…] [--slot <date>]',
43
+ ' pmos report make <id> [--ask "<the question>"] [--readers "<who>"] [--source <kind:ref>,…] [--template <id>] [--slot <date>]',
44
44
  ' Steps 1 to 3: derive the brief (readers, question, window, sources; no section list), read every source PMOS reaches,',
45
45
  ' and find what changed. An ad hoc report needs --ask. Sources: pmos · pack:<state file of readings> · connector:<name> · document:<path>.',
46
46
  ' pmos report read <id> [--slot <date>] --from <file|->',
@@ -54,8 +54,9 @@ const USAGE = [
54
54
  ' pmos report check <id> [--slot <date>] the checks of the last write: substance first, then form',
55
55
  ' pmos report rule <id> list | add <voice|wording|off|keep> "<text or part>" | remove <n>',
56
56
  ' What the readers taught this report: a voice or wording rule, or a part beyond the ask turned off or kept.',
57
- 'usage: pmos report library [--out <file>] — the slide library: every layout in the report frame, with placeholder words',
57
+ 'usage: pmos report library [--template <id>] [--out <file>] [--json] — the slide library: every layout in the report frame, with placeholder words',
58
58
  ' Writes one HTML page (default: report-library.html in the current folder). Open it in a browser to check the look.',
59
+ ' --template draws one report template (its card, then each part); --json lists the templates and the layouts.',
59
60
  'usage: pmos report draft|issue|list — cadence reports (for example the biweekly)',
60
61
  ' pmos report draft <cadence> [<slot>] [--now <time>] [--redraft]',
61
62
  ' Write the draft for a slot (default: today) with every number. Refuses a slot that has a draft;',
@@ -76,9 +77,9 @@ const USAGE = [
76
77
  ' every command: [--json] [--root <dir>]. The cadence is the report skill id without "report-", for example biweekly.',
77
78
  ];
78
79
  const BOOL = new Set(['json', 'redraft', 'help']);
79
- const VALUE = new Set(['now', 'root', 'ask', 'readers', 'source', 'slot', 'from', 'out']);
80
- const ALLOWED = { make: ['json', 'now', 'root', 'ask', 'readers', 'source', 'slot'], read: ['json', 'now', 'root', 'slot', 'from', 'source'], find: ['json', 'now', 'root', 'slot', 'from'],
81
- write: ['json', 'now', 'root', 'slot', 'from'], check: ['json', 'root', 'slot'], rule: ['json', 'root'], draft: ['json', 'redraft', 'now', 'root'], issue: ['root'], list: ['json', 'root'], propose: ['json', 'root'], definitions: ['json', 'root'], setups: ['json', 'root'], finder: ['json', 'root'], library: ['json', 'out'] };
80
+ const VALUE = new Set(['now', 'root', 'ask', 'readers', 'source', 'slot', 'from', 'out', 'template']);
81
+ const ALLOWED = { make: ['json', 'now', 'root', 'ask', 'readers', 'source', 'slot', 'template'], read: ['json', 'now', 'root', 'slot', 'from', 'source'], find: ['json', 'now', 'root', 'slot', 'from'],
82
+ write: ['json', 'now', 'root', 'slot', 'from'], check: ['json', 'root', 'slot'], rule: ['json', 'root'], draft: ['json', 'redraft', 'now', 'root'], issue: ['root'], list: ['json', 'root'], propose: ['json', 'root'], definitions: ['json', 'root'], setups: ['json', 'root'], finder: ['json', 'root'], library: ['json', 'out', 'template'] };
82
83
  const CADENCE_RE = /^[a-z][a-z0-9-]{0,39}$/;
83
84
  const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
84
85
 
@@ -151,9 +152,15 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
151
152
  // the slide library as one page: sanitized as a report render is, with the workspace face inlined so the file shows it anywhere
152
153
  const out = path.resolve(cwd || process.cwd(), typeof flags.out === 'string' && flags.out ? flags.out : 'report-library.html');
153
154
  const D = require('./report-design.js');
155
+ const LB = require('./report-library.js');
156
+ if (flags.template !== undefined && !LB.templateOf(String(flags.template))) return fail(`there is no report template ${flags.template}. The templates: ${LB.TEMPLATES.map((t) => t.id).join(', ')}`, 2);
157
+ if (json && flags.out === undefined && flags.template === undefined) {
158
+ say(JSON.stringify({ ok: true, layouts: require('./report-slides.js').LAYOUTS.filter((x) => x !== 'blocks'), templates: LB.TEMPLATES.map((t) => ({ id: t.id, name: t.name, use: t.use, readers: t.readers, cadence: t.cadence, outline: t.outline })) }));
159
+ return 0;
160
+ }
154
161
  let face = '';
155
162
  try { face = `<style>@font-face{font-family:"Plus Jakarta Sans";font-weight:200 800;src:url(data:font/woff2;base64,${fs.readFileSync(path.join(__dirname, '..', 'public', 'vendor', 'plus-jakarta-sans-var.woff2')).toString('base64')}) format("woff2")}body{margin:0;background:#888}.pmos-slide{margin:0 auto 12px}</style>`; } catch { face = ''; }
156
- const html = D.sanitize(require('./report-library.js').libraryHtml());
163
+ const html = D.sanitize(LB.libraryHtml(flags.template ? { template: String(flags.template) } : {}));
157
164
  try { fs.writeFileSync(out, html.replace('<style>', face + '<style>')); } catch (e) { return fail(`could not write ${out}: ${String((e && e.message) || e).slice(0, 160)}`); }
158
165
  if (json) say(JSON.stringify({ ok: true, path: out })); else say(`Wrote ${out}`);
159
166
  return 0;
@@ -201,7 +208,7 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
201
208
  }
202
209
 
203
210
  /** The report engine's verbs (lib/report-engine.js). An agent session and the PM may run them; issuing stays the PM's (`pmos report issue`). */
204
- function engineVerb(verb, { pos, flags, root, json, say, fail, isTTY, env, stdin, now }) {
211
+ async function engineVerb(verb, { pos, flags, root, json, say, fail, isTTY, env, stdin, now }) {
205
212
  const RE = require('./report-engine.js');
206
213
  const id = pos[0];
207
214
  if (!id || !RE.ID_RE.test(id)) { say(`usage: pmos report ${verb} <id> … (the id is lower case letters, digits and dashes, for example scarlet-assessment)`); return 2; }
@@ -232,7 +239,7 @@ function engineVerb(verb, { pos, flags, root, json, say, fail, isTTY, env, stdin
232
239
  return 0;
233
240
  };
234
241
  if (verb === 'make') {
235
- const m = RE.make(root, id, { slot: day, ask: flags.ask, readers: flags.readers, sources, now: clock });
242
+ const m = RE.make(root, id, { slot: day, ask: flags.ask, readers: flags.readers, sources, template: flags.template, now: clock });
236
243
  if (!m.ok) return fail(m.error);
237
244
  return summary(m);
238
245
  }
@@ -245,7 +252,16 @@ function engineVerb(verb, { pos, flags, root, json, say, fail, isTTY, env, stdin
245
252
  }
246
253
  if (verb === 'write') {
247
254
  const r = readFrom(); if (r.error) return fail(r.error, 2);
248
- const w = RE.write(root, id, day, r.body, { actor: who, now: clock });
255
+ // component slots: PMOS reads each record and draws its component now, and the snapshot is kept with the report (lib/report-slots.js)
256
+ const snapshots = {};
257
+ const secs = r.body && Array.isArray(r.body.sections) ? r.body.sections : [];
258
+ for (const [i, s] of secs.entries()) {
259
+ if (!s || s.layout !== 'component' || !s.data || typeof s.data !== 'object') continue;
260
+ const sn = await require('./report-slots.js').snapshotOf(root, s.data, { now: clock || Date.now() });
261
+ if (!sn.ok) return fail(`sections[${i}] (component): ${sn.error}`);
262
+ snapshots[i] = sn.snapshot;
263
+ }
264
+ const w = RE.write(root, id, day, r.body, { actor: who, now: clock, snapshots });
249
265
  if (!w.ok) {
250
266
  if (json) { say(JSON.stringify({ ok: false, error: w.error, checks: w.checks || null, none: !!w.none })); return 1; }
251
267
  say(`✗ ${w.error}`);
@@ -205,7 +205,7 @@ function issuedSlots(root, id) {
205
205
  /** The headings of a document the engine did not write: the agreed outline of an older issue. */
206
206
  const headingsOf = (md) => str(md).replace(/^---[\s\S]*?\n---\n?/, '').split('\n').filter((l) => /^## /.test(l)).map((l) => l.slice(3).trim()).filter(Boolean);
207
207
 
208
- function briefOf(root, id, { slot, ask, readers, sources, now = Date.now() } = {}) {
208
+ function briefOf(root, id, { slot, ask, readers, sources, template, now = Date.now() } = {}) {
209
209
  if (!ID_RE.test(str(id))) return refuse(400, 'the report id is lower case letters, digits and dashes, at most 40, starting with a letter');
210
210
  const today = slot || dayOf(now);
211
211
  if (!isDate(today)) return refuse(400, 'the slot is a date, YYYY-MM-DD');
@@ -213,7 +213,7 @@ function briefOf(root, id, { slot, ask, readers, sources, now = Date.now() } = {
213
213
  const P = paths(abs, id, today);
214
214
  const from = [];
215
215
  const brief = { v: 1, id, slot: today, title: null, readers: null, question: null, questions: [], decision: null, window: null, sources: [], recurring: false,
216
- cycle_days: null, agreed_outline: [], earlier_outline: [], last_issue: null, carried_asks: [], rules: [], voice: VOICE.slice(), from };
216
+ cycle_days: null, agreed_outline: [], earlier_outline: [], template: null, last_issue: null, carried_asks: [], rules: [], voice: VOICE.slice(), from };
217
217
 
218
218
  // the definition: readers, the question (its purpose), the sources, the cadence
219
219
  let def = null; let defText = '';
@@ -280,6 +280,12 @@ function briefOf(root, id, { slot, ask, readers, sources, now = Date.now() } = {
280
280
  }
281
281
  from.push(LP.md.rel);
282
282
  }
283
+ // a report template (lib/report-library.js) is a default order of slide layouts for a report with no agreed outline; the agreed one wins
284
+ if (template) {
285
+ const t = require('./report-library.js').templateOf(str(template));
286
+ if (!t) return refuse(400, `there is no report template ${template}. The templates: ${require('./report-library.js').TEMPLATES.map((x) => x.id).join(', ')}`);
287
+ if (!brief.agreed_outline.length) { from.push(`template:${t.id}`); brief.template = { id: t.id, name: t.name, outline: t.outline.map((o) => ({ part: o.part, layout: o.layout })) }; }
288
+ }
283
289
  brief.rules = rulesOf(abs, id);
284
290
  for (const r of brief.rules) if (r.kind === 'voice' || r.kind === 'wording') brief.voice.push(r.text);
285
291
  return { ok: true, brief };
@@ -697,7 +703,8 @@ function deckOf(report, { brief = null, readings = null, look = null } = {}) {
697
703
  const data = RS.mapLayout(s.layout, s.data, r);
698
704
  const ins = isObj(s.insight) ? { head: r(str(s.insight.head)), text: r(str(s.insight.text)), points: (Array.isArray(s.insight.points) ? s.insight.points : []).map((x) => ({ tone: x.tone, text: r(str(x.text)) })), note: r(str(s.insight.note)) } : null;
699
705
  const ids = RS.textsOfLayout(s.layout, s.data).concat(textsOfInsight(s.insight)).flatMap(tokensIn);
700
- slides.push({ kind: 'section', layout: s.layout, eyebrow: s.eyebrow ? r(s.eyebrow) : '', title: clip(r(s.title), 160), sub: s.sub ? r(s.sub) : '', data, insight: ins, source: srcOf(ids) });
706
+ const snap = s.layout === 'component' && isObj(data.snapshot) ? data.snapshot : null;
707
+ slides.push({ kind: 'section', layout: s.layout, eyebrow: s.eyebrow ? r(s.eyebrow) : '', title: clip(r(s.title), 160), sub: s.sub ? r(s.sub) : '', data, insight: ins, source: snap ? `Source: PMOS ${snap.kind === 'kr' ? 'KR metric record' : 'record'} ${snap.ref}, last reading ${full(snap.readAt)}` : srcOf(ids) });
701
708
  continue;
702
709
  }
703
710
  // a section goes on as many slides as its blocks need: a long table is cut at 8 rows, and a block that would run under the footer starts a new slide
@@ -763,7 +770,7 @@ function drawBlock(b, r, byId) {
763
770
  // ── the steps, written ──────────────────────────────────────────────────────────────────────────────────────────────
764
771
  function isIssued(root, id, slot) { return issuedSlots(root, id).includes(slot); }
765
772
  /** Steps 1–3: the brief, the readings, the findings, written next to the draft. Filed readings and findings are kept across a re-read. */
766
- function make(root, id, { slot, ask, readers, sources, add, gaps, addFindings, now = Date.now() } = {}) {
773
+ function make(root, id, { slot, ask, readers, sources, template, add, gaps, addFindings, now = Date.now() } = {}) {
767
774
  const abs = path.resolve(String(root));
768
775
  const day = slot || dayOf(now);
769
776
  let P; try { P = paths(abs, id, day); } catch (e) { return refuse(400, e.message); }
@@ -771,6 +778,7 @@ function make(root, id, { slot, ask, readers, sources, add, gaps, addFindings, n
771
778
  const old = readJson(P.brief.abs);
772
779
  // a re-read keeps the brief's chat question and named sources unless new ones are given
773
780
  const b = briefOf(abs, id, { slot: day, now, ask: ask || (old && old.from && old.from.includes('chat') ? old.question : undefined), readers: readers || (old && old.from && old.from.includes('readers') ? old.readers : undefined),
781
+ template: template || (old && old.template ? old.template.id : undefined),
774
782
  sources: [...(old ? old.sources.filter((s) => s.kind === 'pack' || s.kind === 'connector' || s.kind === 'document') : []), ...(sources || [])] });
775
783
  if (!b.ok) return b;
776
784
  const rd = readingsOf(abs, b.brief, { add, gaps, keep: readJson(P.readings.abs), now });
@@ -788,7 +796,7 @@ function make(root, id, { slot, ask, readers, sources, add, gaps, addFindings, n
788
796
  }
789
797
 
790
798
  /** Steps 4–6: the writer's report.json, checked, then the document rendered. A report with no answer, or a figure that is not a reading, is refused. */
791
- function write(root, id, slot, report, { now = Date.now() } = {}) {
799
+ function write(root, id, slot, report, { now = Date.now(), snapshots = {} } = {}) {
792
800
  const abs = path.resolve(String(root));
793
801
  let P; try { P = paths(abs, id, slot); } catch (e) { return refuse(400, e.message); }
794
802
  if (isIssued(abs, id, slot)) return refuse(409, `${id} ${slot} is issued; it is frozen`);
@@ -798,7 +806,10 @@ function write(root, id, slot, report, { now = Date.now() } = {}) {
798
806
  const pre = checkReport(report, { brief, readings });
799
807
  const hardFail = pre.filter((c) => c.hard && !c.pass);
800
808
  if (hardFail.length) return refuse(422, hardFail.map((c) => `${c.id}: ${c.reason}`).join(' · '), { checks: pre });
801
- const clean = { ...report, sections: report.sections.map((s) => ({ ...s, blocks: Array.isArray(s.blocks) ? s.blocks : [], id: str(s.id) || slugOf(s.title) })), carried: brief.carried_asks || [], v: 1, at: new Date(now).toISOString() };
809
+ // a component slot's snapshot comes from PMOS (opts.snapshots, by section index: the CLI reads the record and draws it), never from the writer
810
+ const snaps = isObj(snapshots) ? snapshots : {};
811
+ const slotOf = (s, i) => (s.layout === 'component' && isObj(s.data) ? { ...s, data: { ...Object.fromEntries(Object.entries(s.data).filter(([k]) => k !== 'snapshot')), ...(snaps[i] ? { snapshot: snaps[i] } : {}) } } : s);
812
+ const clean = { ...report, sections: report.sections.map((s, i) => ({ ...slotOf(s, i), blocks: Array.isArray(s.blocks) ? s.blocks : [], id: str(s.id) || slugOf(s.title) })), carried: brief.carried_asks || [], v: 1, at: new Date(now).toISOString() };
802
813
  const md = renderMarkdown(clean, { brief, readings, now });
803
814
  const checks = checkReport(clean, { brief, readings, markdown: md });
804
815
  writeJson(P.report.abs, clean);
@@ -512,6 +512,22 @@ function b2Parts(s, { shape, text }) {
512
512
  for (const [a, b] of d.edges) { const p = pos.get(a); const q = pos.get(b); const x1 = p.x + nw; const y1 = p.y + nh / 2; const x2 = q.x; const y2 = q.y + nh / 2; shape(Math.min(x1, x2), Math.min(y1, y2), Math.abs(x2 - x1), Math.abs(y2 - y1), { geom: 'line', line: '#9FB8E0', lw: 2, flip: y2 < y1 }); }
513
513
  d.nodes.forEach((n) => { const p = pos.get(n.id); const t = tn(n.tone); const [bg, ink] = t === 'bad' ? PILLC.bad : t === 'watch' || t === 'late' ? PILLC.watch : ['#FFFFFF', C.ink]; shape(p.x, p.y, nw, nh, { geom: 'roundRect', adj: round(14, nw, nh), fill: bg, line: bg === '#FFFFFF' ? '#DCDDE0' : null });
514
514
  text(p.x + 8, p.y + 18, nw - 16, 20, n.label, o({ size: 15, bold: true, color: ink, align: 'ctr' })); });
515
+ } else if (L === 'component' && d.snapshot && d.snapshot.kind === 'kr') {
516
+ // PMOS's KR metric tile, from the snapshot kept with the report: the value, its readings as bars from zero, the target line, the date
517
+ const sn = d.snapshot; const bw = Math.min(W, 760); const bh = 300;
518
+ shape(X, y, bw, bh, { geom: 'roundRect', adj: round(18, bw, bh), fill: C.well });
519
+ text(X + 22, y + 18, bw - 200, 20, sn.title, o({ size: 13, bold: true, color: C.ink2 }));
520
+ if (sn.chip) pill(X + bw - 22 - Math.min(160, 18 + sn.chip.length * 7), y + 16, sn.tone, sn.chip);
521
+ text(X + 22, y + 42, bw - 44, 56, sn.valueText || String(sn.value), o({ size: 40, bold: true, color: C.ink }));
522
+ text(X + 22, y + 100, bw - 44, 18, ['on ' + RS.dmy(sn.readAt), sn.targetText].filter(Boolean).join(' · '), o({ size: 13, color: C.ink3 }));
523
+ const ser = (sn.series || []).slice(-24); const hi = Math.max(...ser.map((p) => p.v), sn.target != null ? sn.target : 0) || 1; const px = X + 22; const pw = bw - 44; const py = y + 130; const ph = 130;
524
+ const cw = pw / Math.max(1, ser.length);
525
+ ser.forEach((p, i) => { const h = Math.max(1, (Math.max(0, p.v) / hi) * ph); shape(px + i * cw + cw * 0.2, py + ph - h, cw * 0.6, h, { fill: i === ser.length - 1 ? C.ink : '#C9CBCF' }); });
526
+ shape(px, py + ph, pw, 1, { fill: C.hair });
527
+ if (sn.target != null && sn.target >= 0) shape(px, py + ph - (sn.target / hi) * ph, pw, 1, { line: C.ink3, lw: 1, dash: true, name: 'Target' });
528
+ if (ser.length) { text(px, py + ph + 6, 200, 16, RS.dmy(ser[0].at), o({ size: 11, color: C.ink3 })); text(px + pw - 200, py + ph + 6, 200, 16, RS.dmy(ser[ser.length - 1].at), o({ size: 11, color: C.ink3, align: 'r' })); }
529
+ const when = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2})$/.exec(String(sn.at)); const label = `Snapshot ${when ? RS.dmy(when[1]) + ' ' + when[2] : sn.at} · KR metric record ${sn.ref}`;
530
+ pill(X, y + bh + 12, 'none', label, Math.min(W, 30 + label.length * 6.6));
515
531
  } else plain();
516
532
  if (ins) {
517
533
  shape(902, y, 1, BOT - y, { fill: C.hair, name: 'Insight rule' });
@@ -4,7 +4,9 @@
4
4
  * look can be checked against the boards in a browser, and what the tests draw.
5
5
  *
6
6
  * sampleDeck({ today }) → { name, today, slides } (pure; today = YYYY-MM-DD, default 2026-11-04, the canvas's day)
7
- * libraryHtml(o) → the sample deck as one page (lib/report-slides.js deckHtml)
7
+ * libraryHtml(o) → the sample deck as one page (lib/report-slides.js deckHtml); o.template = one template's deck instead
8
+ * TEMPLATES · templateOf(id) → the 7 report templates (boards T02–T08): a default order of layouts, never a fixed structure
9
+ * templateDeck(id, o) → that template with placeholder words: its card, then each part in its layout
8
10
  *
9
11
  * Placeholder words only: nothing here is a reading, so it never goes into a report.
10
12
  */
@@ -69,8 +71,64 @@ function sampleDeck({ today = '2026-11-04' } = {}) {
69
71
  { date: '2026-11-10', time: '10:00', items: [{ length: '5 min', text: 'Item' }, { length: '15 min', text: 'Item' }, { length: '10 min', text: 'Item' }] }, 'How the agenda is built'),
70
72
  ];
71
73
  slides.forEach((s, i) => { s.n = i + 1; });
74
+ // the footer names the layout and the templates that use it (boards L01–L23)
75
+ for (const sl of slides) {
76
+ const uses = TEMPLATES.filter((t) => t.outline.some((o) => o.layout === sl.layout)).map((t) => t.name);
77
+ sl.source = ['Layout: ' + (NAMES[sl.layout] || sl.layout), uses.length ? uses.join(', ') : 'no template yet', 'placeholder data'].join(' · ');
78
+ }
72
79
  return { name: 'Slide layouts', today, slides };
73
80
  }
74
- const libraryHtml = (o) => RS.deckHtml(sampleDeck(o));
81
+ /* THE TEMPLATES (canvas boards T02–T08). A template is a DEFAULT ORDER of layouts, never a fixed structure: the writer keeps what is true and
82
+ * adds or drops a slide when the findings say so (Wawan, 2026-11-03: no fixed structure). Each part: { part, layout }. A recurring report keeps
83
+ * the outline its readers agreed; a template only starts a report that has none. */
84
+ const T = (id, name, title, sub, use, readers, cadence, parts) => Object.freeze({ id, name, title, sub, use, readers, cadence,
85
+ outline: Object.freeze(parts.map(([part, layout]) => Object.freeze({ part, layout }))) });
86
+ const TEMPLATES = Object.freeze([
87
+ T('weekly-review', 'Weekly Review', 'Weekly Review: what moved, what slipped, what waits on you', 'Eight slides. Read in five minutes.', 'Every Monday for a team lead or sponsor.', 'team lead, sponsor', 'weekly',
88
+ [['Title with agenda', 'overview'], ['This week', 'tiles'], ['Key results', 'ledger'], ['What changed', 'compare'], ['The week', 'schedule'], ['Asks', 'decisions'], ['Next week', 'agenda'], ['Sources', 'sources']]),
89
+ T('program-kickoff', 'Program Kick-off', 'Program Kick-off: why, who, when, risks, first thirty days', 'Ten slides. Presented once, then kept as the charter.', 'At the start of a program or initiative.', 'sponsor, leads, partners', 'once per program',
90
+ [['Title', 'overview'], ['Why', 'statement'], ['Scope', 'lists'], ['People', 'people'], ['Milestones', 'timeline'], ['Risks', 'risks'], ['Ways of working', 'agenda'], ['Success', 'tiles'], ['First 30 days', 'focus'], ['Decisions', 'decisions']]),
91
+ T('meeting-recap', 'Meeting Recap', 'Meeting Recap: decisions, actions, open questions', 'Seven slides. The two-minute read for people who were not there.', 'Within an hour of a meeting.', 'attendees and absentees', 'after each meeting',
92
+ [['Title', 'overview'], ['The short version', 'agenda'], ['Decisions', 'quotes'], ['Action items', 'actions'], ['Discussion', 'minutes'], ['Open and parked', 'lists'], ['Next', 'next']]),
93
+ T('portfolio-snapshot', 'Portfolio Snapshot', 'Portfolio Snapshot: health, money, dependencies, decisions', 'Nine slides. One sitting.', 'For leadership who own several programs.', 'leadership', 'every two weeks',
94
+ [['Title', 'overview'], ['Health', 'donut'], ['Heatmap', 'heatmap'], ['Timeline', 'timeline'], ['Money and outcome', 'bubbles'], ['Top risks', 'risks'], ['Dependencies', 'network'], ['Spotlight', 'compare'], ['Decisions', 'decisions']]),
95
+ T('quarterly-review', 'Quarterly Review', 'Quarterly Review: answer first, then goals, bets, decisions', 'Nine slides. Leads with the answer, ends with sources.', 'At the end of each quarter.', 'leadership, board', 'quarterly',
96
+ [['Title', 'overview'], ['Answer', 'statement'], ['Scorecard', 'tiles'], ['Timeline', 'timeline'], ['Wins and misses', 'lists'], ['Risks', 'risks'], ['Bets', 'ranked'], ['Decisions', 'decisions'], ['Sources', 'sources']]),
97
+ T('decision-brief', 'Decision Brief', 'Decision Brief: the question, the options, the pick', 'Six slides. For a single call that needs a yes.', 'When one person must decide and needs the facts.', 'the decider', 'ad hoc',
98
+ [['The question', 'statement'], ['Options', 'compare'], ['Ranked by impact', 'ranked'], ['Risks of the pick', 'risks'], ['The ask', 'decisions'], ['Sources', 'sources']]),
99
+ T('incident-review', 'Incident Review', 'Incident Review: what happened, why, what we change', 'Six slides. Blameless and short.', 'Within a week of an incident.', 'engineering, support, leadership', 'after incident',
100
+ [['What happened', 'statement'], ['Timeline of the day', 'timeline'], ['Causes ranked', 'ranked'], ['What went well and not', 'lists'], ['Actions', 'actions'], ['Sources', 'sources']]),
101
+ ]);
102
+ const templateOf = (id) => TEMPLATES.find((t) => t.id === id) || null;
103
+ const NAMES = { overview: 'Overview', tiles: 'Tiles', ledger: 'Ledger', timeline: 'Timeline', compare: 'Compare', lists: 'Two lists', risks: 'Risk table', ranked: 'Ranked list',
104
+ decisions: 'Decisions', quotes: 'Quotes', actions: 'Actions', agenda: 'Agenda', heatmap: 'Heatmap', network: 'Network', schedule: 'Schedule', donut: 'Donut', bubbles: 'Bubbles',
105
+ people: 'People', statement: 'Statement', sources: 'Sources', minutes: 'Minutes', focus: 'Focus', next: 'Next' };
106
+ /* One row per part (the agenda numbers them); past eight parts, two parts share a row ("Title · Why"), as the canvas cards pair them. */
107
+ function cardRows(outline) {
108
+ const one = outline.map((o) => ({ title: o.part, text: NAMES[o.layout] || o.layout }));
109
+ if (one.length <= 8) return one;
110
+ const rows = [];
111
+ for (let i = 0; i < one.length; i += 2) rows.push(one[i + 1] ? { title: `${one[i].title} · ${one[i + 1].title}`, text: `${one[i].text} · ${one[i + 1].text}` } : one[i]);
112
+ return rows;
113
+ }
114
+ /** The template's card (boards T02–T08): its default order as numbered rows, and when to use it in the insight column. */
115
+ function templateCard(t) {
116
+ return { layout: 'agenda', eyebrow: `${t.name} · Report template`, title: t.title, sub: t.sub,
117
+ data: { rows: cardRows(t.outline) },
118
+ insight: { head: 'When to use it', text: t.use, points: [{ tone: 'ok', text: `Readers: ${t.readers}` }, { tone: 'ok', text: `Cadence: ${t.cadence}` }, { tone: 'watch', text: 'Add or drop slides when the findings say so' }],
119
+ note: 'Order is the default; the writer may change it per report.' },
120
+ source: 'Template library · sample data' };
121
+ }
122
+ /** A template drawn with placeholder words: its card, then each part in its layout (the sample of that layout, titled by the part). */
123
+ function templateDeck(id, o = {}) {
124
+ const t = templateOf(id);
125
+ if (!t) return null;
126
+ const lib = sampleDeck(o).slides;
127
+ const slides = [templateCard(t)].concat(t.outline.map((p) => { const s = lib.find((x) => x.layout === p.layout); return { ...s, eyebrow: `${t.name} · ${p.part}`, title: p.part, sub: s.title, source: 'Template library · placeholder data' }; }));
128
+ slides.forEach((s, i) => { s.n = i + 1; });
129
+ return { name: t.name, today: o.today || '2026-11-04', slides };
130
+ }
131
+ /** The library page: every layout, or one template (o.template). */
132
+ const libraryHtml = (o = {}) => RS.deckHtml(o.template ? templateDeck(o.template, o) : sampleDeck(o));
75
133
 
76
- module.exports = { sampleDeck, libraryHtml };
134
+ module.exports = { sampleDeck, libraryHtml, TEMPLATES, templateOf, templateCard, templateDeck };
@@ -33,7 +33,7 @@
33
33
  */
34
34
 
35
35
  const LAYOUTS = Object.freeze(['overview', 'tiles', 'ledger', 'timeline', 'compare', 'lists', 'risks', 'ranked', 'decisions', 'quotes', 'actions',
36
- 'agenda', 'heatmap', 'network', 'schedule', 'donut', 'bubbles', 'people', 'statement', 'sources', 'minutes', 'focus', 'next', 'blocks']);
36
+ 'agenda', 'heatmap', 'network', 'schedule', 'donut', 'bubbles', 'people', 'statement', 'sources', 'minutes', 'focus', 'next', 'blocks', 'component']);
37
37
  const TONES = Object.freeze(['ok', 'watch', 'late', 'bad', 'info', 'none']);
38
38
  const MONS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
39
39
  const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
@@ -78,7 +78,7 @@ const SPEC = {
78
78
  decisions: { rows: { max: 3, min: 1, item: { question: 't', pick: 't', who: 't', by: 'date' } } },
79
79
  quotes: { agreed: 't', quotes: { max: 3, min: 1, item: { text: 't', who: 't', at: 't' } } },
80
80
  actions: { rows: { max: 6, min: 1, item: { text: 't', who: 't', due: 'date' } } },
81
- agenda: { rows: { max: 5, min: 1, item: { title: 't', text: 't' } } },
81
+ agenda: { rows: { max: 8, min: 1, item: { title: 't', text: 't' } } },
82
82
  heatmap: { cols: { max: 6, min: 1, text: true }, total: 't', rows: { max: 6, min: 1, item: { name: 't', total: 't', cells: { max: 6, min: 1, item: { text: 't', tone: 'tone' } } } } },
83
83
  network: { nodes: { max: 8, min: 2, item: { id: 'id', label: 't', tone: 'tone' } }, edges: { max: 12, min: 1, pair: true } },
84
84
  schedule: { cols: { max: 7, min: 1, text: true }, rows: { max: 6, min: 1, item: { name: 't', from: 'i', to: 'i', text: 't', tone: 'tone' } } },
@@ -91,12 +91,13 @@ const SPEC = {
91
91
  focus: { gate: { obj: { title: 't', text: 't' } }, steps: { max: 4, min: 1, item: { label: 't', title: 't', text: 't' } } },
92
92
  next: { date: 'date', time: 't', items: { max: 6, min: 1, item: { length: 't', text: 't' } } },
93
93
  blocks: { blocks: { max: 12, min: 1, raw: true } },
94
+ component: {},
94
95
  };
95
96
  const REQUIRED = {
96
97
  overview: ['cols'], tiles: ['tiles'], ledger: ['rows'], timeline: ['rows'], compare: ['left', 'right'], lists: ['left', 'right'], risks: ['rows'],
97
98
  ranked: ['rows'], decisions: ['rows'], quotes: ['quotes'], actions: ['rows'], agenda: ['rows'], heatmap: ['cols', 'rows'], network: ['nodes', 'edges'],
98
99
  schedule: ['cols', 'rows'], donut: ['parts'], bubbles: ['points'], people: ['people'], statement: ['text'], sources: [], minutes: ['rows'],
99
- focus: ['steps'], next: ['items'], blocks: ['blocks'],
100
+ focus: ['steps'], next: ['items'], blocks: ['blocks'], component: [],
100
101
  };
101
102
  const TEXT_MAX = 400;
102
103
 
@@ -133,6 +134,7 @@ function checkSpec(spec, data, at) {
133
134
  function layoutError(name, data) {
134
135
  if (!LAYOUTS.includes(name)) return `layout is one of ${LAYOUTS.join(', ')}`;
135
136
  if (!isObj(data)) return `data is the ${name} layout's object`;
137
+ if (name === 'component') return require('./report-slots.js').slotError(data);
136
138
  for (const k of REQUIRED[name]) if (data[k] == null || (Array.isArray(data[k]) && !data[k].length) || (isStr(data[k]) && !data[k].trim())) return `the ${name} layout needs ${k}`;
137
139
  const e = checkSpec(SPEC[name], data, 'data');
138
140
  if (e) return e;
@@ -154,7 +156,7 @@ function walk(spec, data, fn) {
154
156
  return out;
155
157
  }
156
158
  /** A copy of the data with fn applied to every text the writer wrote (the blocks layout is the engine's own and is left as it is). */
157
- const mapLayout = (name, data, fn) => (SPEC[name] && name !== 'blocks' ? walk(SPEC[name], data, fn) : data);
159
+ const mapLayout = (name, data, fn) => (SPEC[name] && name !== 'blocks' && name !== 'component' ? walk(SPEC[name], data, fn) : data);
158
160
  /** Every text the writer wrote in the data. */
159
161
  function textsOfLayout(name, data) { const out = []; mapLayout(name, data, (t) => { out.push(t); return t; }); return out; }
160
162
 
@@ -188,6 +190,7 @@ function linesOf(name, d) {
188
190
  case 'focus': return [d.gate ? join(d.gate.title, d.gate.text) : ''].concat(L(d.steps, (s) => join(s.label, s.title, s.text))).filter(Boolean);
189
191
  case 'next': return [join(d.date ? longDate(d.date) : '', d.time)].concat(L(d.items, (x) => join(x.length, x.text))).filter(Boolean);
190
192
  case 'blocks': return L(d.blocks, (b) => (b.kind === 'p' ? b.text : b.kind === 'list' ? b.items.join(' · ') : b.kind === 'table' ? b.rows.map((r) => r.join(' | ')).join(' · ') : b.kind === 'bars' ? b.rows.map((r) => `${r.label}: ${r.text}`).join(' · ') : '')).filter(Boolean);
193
+ case 'component': return d.snapshot ? require('./report-slots.js').snapshotLines(d.snapshot) : [`The ${d.kind || ''} component ${d.ref || ''} is drawn when the report is written`];
191
194
  default: return [];
192
195
  }
193
196
  }
@@ -302,6 +305,13 @@ const BODY = {
302
305
  return `<div class="next-row">${when}<div class="fl"><div class="lb pb6">Draft agenda</div>${items}</div></div>`;
303
306
  },
304
307
  blocks(d) { return `<div class="blk">${d.blocks.map(blockHtml).join('')}</div>`; },
308
+ component(d) {
309
+ // PMOS's own component, as the snapshot PMOS took when the report was written (lib/report-slots.js); the writer never supplies this markup
310
+ const s = d.snapshot;
311
+ if (!isObj(s) || !isStr(s.html)) return `<div class="slot"><p class="ink2">This component is drawn from its record when the report is written.</p></div>`;
312
+ const when = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2})$/.exec(str(s.at));
313
+ return `<div class="slot">${s.html}<span class="snap">Snapshot ${esc(when ? `${dmy(when[1])} ${when[2]}` : s.at)} · ${s.kind === 'kr' ? 'KR metric record' : 'record'} ${esc(s.ref)}</span></div>`;
314
+ },
305
315
  };
306
316
  const initials = (name) => str(name).split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0].toUpperCase()).join('');
307
317
  const dueText = (ymd, today) => { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(str(ymd)); return m ? `${DAYS[new Date(ms(ymd)).getUTCDay()].slice(0, 3)} ${+m[3]} ${MONS[+m[2] - 1]}${today && m[1] !== str(today).slice(0, 4) ? ' ' + m[1] : ''}` : str(ymd); };
@@ -574,6 +584,7 @@ width:1280px;height:720px;position:relative;overflow:hidden;box-sizing:border-bo
574
584
  .rs .kr,.rs .lrow,.rs .tb,.rs .btn,.rs .sb{transition:background-color .2s ease,box-shadow .2s ease}
575
585
  .rs .kr:hover,.rs .lrow:hover{background:rgba(244,243,240,.7)}
576
586
  .rs .tb:hover{box-shadow:0 2px 8px -2px rgba(42,46,53,.3)}
587
+ ${require('./report-slots.js').CSS}
577
588
  @media (prefers-reduced-motion:reduce){.rs *{animation:none!important;transition:none!important}}
578
589
  @media print{.rs *{animation:none!important;transition:none!important}.rs .ctl{display:none}}`;
579
590
 
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+ /* report-slots.js — component slots on report slides (batch 27 item 14, PR 3; brief option B, "Component slots"). A slide can hold one of
3
+ * PMOS's own components, drawn by PMOS's own code from a PMOS record, never by the writer. The writer names the component and the record;
4
+ * PMOS reads the record when the report is written and keeps a DATED SNAPSHOT with the report, so an old issue stays true.
5
+ *
6
+ * KINDS → the components a slot can hold today (each proven in the real reader; the rest were dropped)
7
+ * slotError(data) → null | a sentence: what is wrong with a section's { kind, ref, form } (pure)
8
+ * snapshotOf(root, data, { now }) → Promise<{ ok, snapshot } | { ok: false, error }>: reads the record, draws the component
9
+ * snapshotLines(snapshot) → [line]: the component as plain lines, for the document and the PowerPoint file (pure)
10
+ * CSS → the component's own styles, scoped to a slot (the workspace tokens, written out)
11
+ *
12
+ * A snapshot is { kind, ref, form, title, at, html, value, unit, readAt, series: [{ at, v }], target, chip, tone }. `html` is PMOS's markup:
13
+ * the report sanitizer still runs over the whole deck. The writer cannot pass a snapshot: lib/report-engine.js write() drops one it is given.
14
+ *
15
+ * WHICH COMPONENTS (the brief's proof rule: any component that fails the proof in the real reader is dropped, not faked):
16
+ * kr the KR metric tile of the Insights page (public/pm/premium-chart-draw.js tileHtml, from lib/insights-premium.js chartOf), in its four
17
+ * forms: line, bars, meter, number. Pure strings; runs in Node; proven inside the reader's empty sandbox.
18
+ * Dropped for now: the Board Timeline (bd-span.js needs the board's row model, its CSS and a mounted live copy; the library's own timeline
19
+ * layout stands in), Document and Viewer (they mount DOM; they cannot be drawn into a sandboxed slide).
20
+ */
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+
24
+ const KINDS = Object.freeze(['kr']);
25
+ const FORMS = Object.freeze(['line', 'bars', 'meter', 'number']);
26
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
27
+ const str = (v) => (typeof v === 'string' ? v : v == null ? '' : String(v));
28
+
29
+ function slotError(d) {
30
+ if (!isObj(d)) return 'data is the component\'s object: { kind, ref, form }';
31
+ for (const k of Object.keys(d)) if (!['kind', 'ref', 'form', 'snapshot'].includes(k)) return `data.${k} is not part of a component slot; it takes kind, ref and form`;
32
+ if (!KINDS.includes(d.kind)) return `data.kind is one of ${KINDS.join(', ')} (the components PMOS can draw on a slide)`;
33
+ if (!/^[A-Za-z0-9_.:-]{1,100}$/.test(str(d.ref))) return 'data.ref is the id of the record the component shows (a KR metric record id)';
34
+ if (d.form != null && !FORMS.includes(d.form)) return `data.form is one of ${FORMS.join(', ')}`;
35
+ return null;
36
+ }
37
+
38
+ function recordsDir(root) {
39
+ try { const p = require(require('./repo-script.js').repoScript('signals/layout-paths.js')).pathsFor(root).recordsDir; if (p && fs.existsSync(p)) return p; } catch { /* fall through */ }
40
+ return path.join(root, 'planning', 'records');
41
+ }
42
+ const stamp = (now) => { const d = new Date(now); return `${d.toISOString().slice(0, 10)} ${d.toISOString().slice(11, 16)}`; };
43
+
44
+ /** Read the record and draw the component. Async: the drawing code is the browser's own module, imported unchanged. */
45
+ async function snapshotOf(root, d, { now = Date.now() } = {}) {
46
+ const e = slotError(d); if (e) return { ok: false, error: e };
47
+ if (d.kind === 'kr') {
48
+ let krs = [];
49
+ try { krs = require(require('./repo-script.js').repoScript('records/index.js')).list(recordsDir(root), 'kr-metrics'); } catch (x) { return { ok: false, error: `the KR metric records could not be read: ${str(x && x.message).slice(0, 120)}` }; }
50
+ const k = krs.find((x) => x.rid === d.ref);
51
+ if (!k) return { ok: false, error: `there is no KR metric record ${d.ref}. The records: ${krs.map((x) => x.rid).slice(0, 12).join(', ') || 'none'}` };
52
+ const today = new Date(now).toISOString().slice(0, 10);
53
+ const o = require('./routes-c3-strategy.js').outcomeOf(k, today);
54
+ const c = require('./insights-premium.js').chartOf(o);
55
+ if (!c) return { ok: false, error: `the KR metric ${d.ref} has fewer than two readings, so it has no chart yet` };
56
+ // Imported as source, not by path: the file has no imports of its own, and a path import makes newer Node print a module-type warning.
57
+ const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'pm', 'premium-chart-draw.js'));
58
+ const draw = await import('data:text/javascript;base64,' + src.toString('base64'));
59
+ const form = d.form || 'line';
60
+ const html = draw.tileHtml({ ...c, form }).replace(/\stabindex="0"/g, '');
61
+ const last = o.readings[o.readings.length - 1];
62
+ const tone = c.chipKind === 'ok' ? 'ok' : c.chipKind === 'warn' ? 'watch' : c.chipKind === 'bad' ? 'bad' : 'none';
63
+ return { ok: true, snapshot: { kind: 'kr', ref: d.ref, form, title: c.title, at: stamp(now), html, value: last.v, unit: o.unit || '', readAt: last.at,
64
+ series: o.readings.map((r) => ({ at: r.at, v: r.v })), target: o.target, chip: c.chip, tone, valueText: c.v, targetText: c.targetTxt } };
65
+ }
66
+ return { ok: false, error: `no component ${d.kind}` };
67
+ }
68
+
69
+ /** The component as lines: the document and the PowerPoint file (dates in full). */
70
+ function snapshotLines(s) {
71
+ if (!isObj(s)) return [];
72
+ const RS = require('./report-slides.js');
73
+ if (s.kind === 'kr') return [[s.title, s.valueText, s.targetText, s.chip].filter(Boolean).join(' · '), `Snapshot of the KR metric record ${s.ref}, last reading ${RS.dmy(s.readAt)}, taken ${RS.dmy(s.at.slice(0, 10))} ${s.at.slice(11)}`];
74
+ return [];
75
+ }
76
+
77
+ /* The tile's own rules (public/pm/premium.css), scoped to a slot, with the workspace tokens they read written out. No child combinator:
78
+ * the report sanitizer escapes `>` in CSS. */
79
+ const CSS = `.rs .slot{--well:#F4F3F0;--ink:#2A2E35;--ink-2:#4A4F58;--ink-3:#646A74;--hair:rgba(42,46,53,.08);--ok:#3E6E49;--ember:#B8860B;--ember-ink:#8A5A00;--bad:#A8322A;--fs-display:44px;--lh-display:1.1;--fs-meta:13px;--lh-meta:1.4;--fs-badge:12px;--lh-badge:1.2;--fw-strong:600;display:flex;flex-direction:column;gap:10px;flex:1;min-height:0}
80
+ .rs .slot .mt1{background:var(--well);border-radius:18px;padding:18px 22px 16px;display:grid;gap:4px;align-content:start}
81
+ .rs .slot .mt1 span{font-size:var(--fs-meta);color:var(--ink-3);line-height:var(--lh-meta)}
82
+ .rs .slot .mt1 b{font-size:var(--fs-display);font-weight:600;letter-spacing:-.03em;line-height:1.15}
83
+ .rs .slot .mt1 em{font-style:normal;font-size:var(--fs-meta);color:var(--ink-3);line-height:var(--lh-meta)}
84
+ .rs .slot .mt1.warn em{color:var(--ember-ink)}.rs .slot .mt1.bad em{color:var(--bad)}.rs .slot .mt1.ok em{color:var(--ok)}
85
+ .rs .slot .mlab{display:flex;align-items:center;gap:8px;font-weight:600;color:var(--ink-2)}
86
+ .rs .slot .mchip{font-style:normal;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;background:#fff;color:var(--ink-2)}
87
+ .rs .slot .mchip.ok{color:var(--ok)}.rs .slot .mchip.warn{color:var(--ember-ink)}
88
+ .rs .slot .mchg{font-size:var(--fs-meta)}.rs .slot .mchg.good{color:var(--ok)}.rs .slot .mchg.bad{color:var(--bad)}
89
+ .rs .slot .mfoot{display:flex;justify-content:space-between;font-size:12px;color:var(--ink-3)}.rs .slot .mfoot i{font-style:normal}
90
+ .rs .slot svg{width:100%;height:auto;display:block;margin-top:8px;overflow:visible}
91
+ .rs .slot .mch .ar{fill:rgba(42,46,53,.08);stroke:none}
92
+ .rs .slot .mch .ln2{fill:none;stroke:var(--ink);stroke-width:1.8;stroke-linejoin:round;stroke-linecap:round;vector-effect:non-scaling-stroke}
93
+ .rs .slot .mch .tg{stroke:var(--ink-3);stroke-dasharray:3 3;stroke-width:1;vector-effect:non-scaling-stroke}
94
+ .rs .slot .mch .cx{display:none}
95
+ .rs .slot .mch .dt{fill:var(--ink)}.rs .slot .mch.ok .dt{fill:var(--ok)}.rs .slot .mch.warn .dt{fill:var(--ember)}.rs .slot .mch.bad .dt{fill:var(--bad)}
96
+ .rs .slot .mfm .mval{font-size:var(--fs-display);line-height:var(--lh-display)}
97
+ .rs .slot .mfm .nspk{width:100%;height:auto;display:block;margin-top:8px;overflow:visible}
98
+ .rs .slot .mfm .nspk path{fill:none;stroke:var(--ink-2);stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round;vector-effect:non-scaling-stroke}
99
+ .rs .slot .bplot{position:relative;display:flex;align-items:stretch;gap:2px;height:160px;margin-top:10px;border-bottom:1px solid var(--hair)}
100
+ .rs .slot .bw{flex:1 1 0;display:flex;flex-direction:column;justify-content:flex-end;align-items:center;min-width:0;position:relative}
101
+ .rs .slot .bw i{display:block;width:min(24px,64%);border-radius:4px 4px 0 0}
102
+ .rs .slot .bw i.bg{background:rgba(42,46,53,.2)}.rs .slot .bw i.bk{background:var(--ink)}
103
+ .rs .slot .bw .bl{font-size:12px!important;font-weight:600;margin-bottom:3px;white-space:nowrap;letter-spacing:0}
104
+ .rs .slot .btg{position:absolute;left:0;right:0;border-top:1px dashed var(--ink-3);height:0}
105
+ .rs .slot .mtrk{position:relative;height:12px;border-radius:999px;background:rgba(42,46,53,.1);margin:16px 0 8px}
106
+ .rs .slot .mfil{position:absolute;inset:0 auto 0 0;border-radius:999px;background:var(--ink)}.rs .slot .mfil.ok{background:var(--ok)}.rs .slot .mfil.warn{background:var(--ember)}
107
+ .rs .slot .mtgt{position:absolute;top:-5px;bottom:-5px;width:2px;background:var(--ink);border-radius:2px}
108
+ .rs .slot .mnote,.rs .slot .mwhy{font-size:var(--fs-meta);color:var(--ink-3)}
109
+ .rs .snap{align-self:flex-start;display:inline-flex;align-items:center;height:22px;padding:0 9px;border-radius:999px;font-size:12px;font-weight:600;background:var(--well);color:var(--ink-3)}`;
110
+
111
+ module.exports = { KINDS, FORMS, slotError, snapshotOf, snapshotLines, CSS };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.531.0",
3
+ "version": "0.533.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.531.0",
9
+ "version": "0.533.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.531.0",
3
+ "version": "0.533.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",
@@ -26,17 +26,20 @@ Procedure:
26
26
  1. Run `pmos report make <id>`. For an ad hoc report add `--ask "<the question>"`, and `--readers "<who>"` when you know them. Name sources the brief must read with `--source`, comma-separated: `connector:<name>=<what it is>` (Jira, BigQuery for GA4, Confluence, …), `document:<path>`, `pack:<state file of readings>`, `pmos` (the PMOS ledgers). It writes `<slot>.brief.json`, `<slot>.readings.json` and `<slot>.findings.json` next to the draft, and prints the gaps.
27
27
  - If it refuses because the slot is issued, stop: an issued report is frozen.
28
28
  - If it says there is no report with that id, it needs a question: ask the PM once for the question, or take it from the chat line.
29
+ - When the PM names a kind of report that a template fits (a weekly review, a kick-off, a meeting recap, a portfolio snapshot, a quarterly review, a decision brief, an incident review), add `--template <id>`.
29
30
  2. Read `<slot>.brief.json`: the readers, the questions, the window, the sources, the last issue, the agreed outline, the carried asks, the voice and the rules.
30
31
  3. Read each source that the gaps name, through its connector or the file, read-only. Write what you read as readings in one JSON file: `{"readings":[{"id":"web-login-rate","value":81,"unit":"%","n":597,"of":733,"at":"2026-10-19","source":"ga4","how":"events_20261019, sessions with a password start","label":"Web password login, recorded success"}],"gaps":[{"source":"native-app","reason":"read only on the device"}]}`. Give `target` and `better` ("higher" or "lower") when the reading has a target. Give `flag` when the reading is off what the readers expect. Run `pmos report read <id> --from <file>`. A parameter of a method (a 5-second match window) is a reading too.
31
32
  4. Find what answers the readers. Write the findings that answer each question, each citing the readings it rests on, and run `pmos report find <id> --from <file>` with `{"findings":[{"text":"…","readings":["…"],"kind":"answers-question","question":"…"}]}`. The engine adds its own findings: changes since the last issue, misses against a target, initiatives off plan, items due within 14 days, the asks carried over. If `<slot>.findings.json` says `"none": true`, there is nothing to report: stop and say so in one line. Do not write a report about nothing.
32
33
  5. Choose the outline for these readers and this question. Any sections, any titles, any order.
33
34
  - A recurring report keeps `agreed_outline`, the sections of its last issued issue. Add a section only when a finding needs one, drop one only when it would be empty, and say so in `outline_note` (one line).
34
35
  - `earlier_outline` is the shape of an issue written before the engine (the old fixed frame). It is an example, not an agreement: keep what served the readers.
36
+ - `template` is set when the PM started the report from a report template (`pmos report make <id> --template <name>`; `pmos report library --json` lists them). Its `outline` is a default order of parts, each with its slide layout: use it as the starting outline, retitle each part as a finding, and add or drop a part when the findings say so. An agreed outline wins over a template.
35
37
  - Each section title is a finding, not a label: "Sign-up stalls before submit", not "Sign-up". Reading only the titles tells the story.
36
38
  - Shapes other reports took, as examples and never a menu: a decision (the lean · the options and what each costs · what could reverse it · your call); a watch (today against yesterday and the week · the drop that moved most · why); a stakeholder update (are the dates on track · what each team needs and by when · open decisions · what could stop it); a review (the summary for decision · the outcomes by segment · the challenges and answers · method); did-it-work (what was promised · what we got · the verdict).
37
39
  6. Write `<slot>.report.json` (in a scratch file, then `pmos report write <id> --from <file>`):
38
40
  `{"title":"…{{r:…}}…","answer":{"text":"…","supports":["Label: …{{r:…}}…"]},"sections":[{"id":"…","title":"…","blocks":[{"kind":"p","text":"…"},{"kind":"list","items":["…"]},{"kind":"table","columns":["…"],"rows":[["…"]]},{"kind":"bars","title":"…","rows":[["label","{{r:id}}"]]},{"kind":"options","options":[{"name":"…","text":"…"}]},{"kind":"quote","text":"…"}]}],"ask":{"text":"…","who":"…","by":"YYYY-MM-DD","options":["…"]},"beyond":{"why":"…","so_what":"…","now_what":"…","since":"…","would_change":"…","unknown":["…"],"else":["…"],"next_question":"…"},"questions":[{"question":"<a question of the brief>","answered_in":"<section id or answer>","gap":"<why it cannot be answered>"}],"outline_note":"…"}`.
39
41
  - A section can take a slide layout from the report library instead of blocks: `{"title":"…","layout":"<name>","data":{…},"insight":{"head":"…","text":"…","points":[{"tone":"ok|watch|late|bad|info|none","text":"…"}],"note":"…"},"eyebrow":"…","sub":"…"}`. The names: overview, tiles, ledger, timeline, compare, lists, risks, ranked, decisions, quotes, actions, agenda, heatmap, network, schedule, donut, bubbles, people, statement, sources, minutes, focus, next. Run `pmos report library` to see each one with its data; `pmos report write` names a field that does not fit. Pick the layout that fits the finding, never to fill a slide. Every text in `data` and `insight` is checked like any other: a figure is a reading token. A bar's `share`, a `target` mark and a bubble's `x`, `y` and `size` are numbers from 0 to 1: take them from the readings. The insight is optional; leave it out when the slide says it already.
42
+ - A section can also hold a PMOS component, drawn by PMOS from a PMOS record: `{"title":"…","layout":"component","data":{"kind":"kr","ref":"<KR metric record id>","form":"line|bars|meter|number"}}`. Today the one component is `kr`, the KR metric tile of the Insights page; the record needs two readings or more. Do not write the drawing: `pmos report write` reads the record, draws the tile and keeps a dated snapshot with the report, so an old issue keeps the figures of its day. It refuses a record that is not there and names the records that are.
40
43
  - `ask` is null when the readers have nothing to decide. When it is a call with no better option on the evidence, give the options and say "your call".
41
44
  - BEYOND THE ASK. Answer the readers' next three questions before they ask: why (the cause, from the evidence), so what (the effect on the target and its date), now what (the recommendation, who, by), since the last issue (what moved, and each carried ask), what would change it (the one or two readings that would flip the recommendation), what we don't know (the gaps, counted, never padded), what else is here (at most two findings from a named source the readers did not ask about, when their question depends on them) and the next question (the split they will ask for next). Each part cites a reading. Leave a part out when no reading backs it, and leave out a part the brief's rules turn off.
42
45
  - `questions`: map each question of the brief after the first to the section that answers it, or give the gap.