create-pmos 0.494.0 → 0.496.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 (80) hide show
  1. package/package.json +75 -27
  2. package/payload/AGENTS.md +2 -0
  3. package/payload/KIT-VERSION +2 -2
  4. package/payload/planning/evals/eval-calibration.md +1 -1
  5. package/payload/planning/okrs/health-budgets.md +1 -1
  6. package/payload/state/discoveries.md +1 -1
  7. package/payload/state/log.md +1 -1
  8. package/workspace/adler-moments-test.js +3 -1
  9. package/workspace/board-programs-test.js +6 -6
  10. package/workspace/board-shared-ui-test.js +2 -2
  11. package/workspace/briefing-premium-adddoc-test.js +3 -49
  12. package/workspace/briefing-premium-board-test.js +23 -23
  13. package/workspace/briefing-premium-initiative-test.js +2 -1
  14. package/workspace/changelog.json +74 -0
  15. package/workspace/guide/adler.md +1 -1
  16. package/workspace/guide/briefing-premium.md +3 -1
  17. package/workspace/guide/claude-proposes.md +6 -2
  18. package/workspace/guide/data-freshness.md +2 -0
  19. package/workspace/guide/home.md +1 -1
  20. package/workspace/guide/initiatives-screen.md +1 -1
  21. package/workspace/guide/published-board.md +1 -1
  22. package/workspace/guide/report-slides.md +6 -6
  23. package/workspace/guide/reports.md +1 -1
  24. package/workspace/guide/workflows.md +18 -5
  25. package/workspace/ini-workflow-test.js +2 -1
  26. package/workspace/lib/board-premium.js +17 -2
  27. package/workspace/lib/engine/workflows.js +19 -6
  28. package/workspace/lib/initiative-chat.js +4 -1
  29. package/workspace/lib/initiative-cli.js +15 -1
  30. package/workspace/lib/open-mission.js +14 -0
  31. package/workspace/lib/proposals.js +18 -4
  32. package/workspace/lib/report-export.js +5 -3
  33. package/workspace/lib/report-pyramid.js +134 -33
  34. package/workspace/lib/report-templates.js +33 -3
  35. package/workspace/lib/routes-board-premium.js +2 -2
  36. package/workspace/lib/routes-boards.js +35 -2
  37. package/workspace/lib/routes-brief-page.js +2 -1
  38. package/workspace/lib/routes-initiatives.js +1 -0
  39. package/workspace/lib/routes-proposals.js +14 -2
  40. package/workspace/lib/routes-report-design.js +1 -1
  41. package/workspace/lib/routes-report-flow.js +11 -4
  42. package/workspace/lib/routes-report-setups.js +3 -2
  43. package/workspace/lib/routes-workflows.js +6 -1
  44. package/workspace/lib/split-merge-suggest.js +37 -3
  45. package/workspace/lib/split-merge.js +10 -4
  46. package/workspace/lib/task-cli.js +5 -1
  47. package/workspace/lib/workflow-cli.js +70 -15
  48. package/workspace/lib/workflows-store.js +15 -1
  49. package/workspace/metric-drafts-ui-test.js +2 -1
  50. package/workspace/package-lock.json +2 -2
  51. package/workspace/package.json +1 -1
  52. package/workspace/premium-board-actions-test.js +9 -10
  53. package/workspace/premium-core-doc-issue-test.js +9 -38
  54. package/workspace/premium-vw-backlog-test.js +3 -2
  55. package/workspace/premium-vw-item-test.js +3 -2
  56. package/workspace/premium-vw-jira-test.js +3 -2
  57. package/workspace/program-panel-test.js +2 -1
  58. package/workspace/public/pm/cozy-board.css +17 -5
  59. package/workspace/public/pm/cozy-home.css +1 -0
  60. package/workspace/public/pm/home-app.js +15 -5
  61. package/workspace/public/pm/home-chat.js +49 -8
  62. package/workspace/public/pm/home-today.js +11 -4
  63. package/workspace/public/pm/ini-view.js +2 -2
  64. package/workspace/public/pm/load-shapes.css +57 -0
  65. package/workspace/public/pm/load-shapes.js +57 -0
  66. package/workspace/public/pm/premium-board.js +82 -57
  67. package/workspace/public/pm/premium-shell.js +57 -6
  68. package/workspace/public/pm/premium.css +1 -0
  69. package/workspace/public/pm/readonly.js +3 -3
  70. package/workspace/public/pm/split-merge.js +3 -2
  71. package/workspace/public/pm/wf-page.css +8 -0
  72. package/workspace/public/pm/wf-page.js +33 -5
  73. package/workspace/public/premium-board.html +7 -10
  74. package/workspace/public/premium.html +1 -0
  75. package/workspace/skills/report-template.skill +4 -2
  76. package/workspace/split-merge-ui-test.js +4 -3
  77. package/workspace/stage-card-test.js +2 -1
  78. package/workspace/task-press-test.js +2 -1
  79. package/workspace/test-lists/page-open-speed.json +1 -1
  80. package/workspace/tk-tasks-test.js +2 -1
@@ -1,22 +1,25 @@
1
1
  'use strict';
2
2
  /* report-pyramid.js — the finished slides of a cadence report: the Pyramid deck (canvas RsA + RsD, agreed by Wawan 2026-11-02, option A).
3
3
  *
4
- * readDraft(markdown) → the report's parts, read back from the draft's markdown (pure) | null when it is not a cadence draft
5
- * deckOf(markdown, look?) → { slides: [{ kind, title, …, source, n }], look } | null (pure)
4
+ * readDraft(markdown, extra?) → the report's parts, read back from the draft's markdown (pure) | null when it is not a cadence draft
5
+ * deckOf(markdown, look?, extra?) → { slides: [{ kind, title, …, source, n }], look } | null (pure; extra.data = the cycle's <slot>.data.json)
6
+ * dataOf(absDraftPath) → the structured cycle's <slot>.data.json next to the draft, or null
6
7
  * deckHtml(deck) → one HTML string: a <style> block and one <section class="pmos-slide"> per slide, 1280 x 720 each (pure)
7
8
  * lookOf(root, cadence) → the look the slides use: the report's saved design D<n> (lib/report-design.js), else the standard PMOS look
8
9
  * STANDARD → the standard PMOS look
9
10
  *
10
11
  * THE SLIDES, in this order. Every title is a finding: reading only the titles tells the story.
11
- * title the answer is the title: the PM's headline note, else the drafted headline as a sentence about who is off plan
12
- * answer the governing thought, up to three supports (one per initiative, off plan first) and the ask beside them
12
+ * title the answer is the title: the first sentence of the PM's headline note (capped, so it fits), else a sentence about who is off plan
13
+ * answer the governing thought, the rest of the headline note's first paragraph, up to three supports (one per initiative, off plan
14
+ * first) and the ask beside them
13
15
  * scorecard the WBR scorecard: stage, status (shape and colour), done of total (+Δ), the KR now vs target with the gap. A comment
14
16
  * only on a row that is off plan (off track or at risk); a row on plan is silent. Seven rows per slide.
15
17
  * timeline the dated work of the Timeline section, a due-date diamond per initiative and what is overdue, with an action title
16
18
  * that compares two due dates. Not drawn when nothing has a date (zero is silence).
17
19
  * ask Needs your call: only when the definition names an audience AND the asks note has words. Who, by when (only when the
18
20
  * note says it) and the options (the note's bullet lines). Nothing is invented.
19
- * sources Sources reached, and "What we don't know yet": each source not reached, with its reason.
21
+ * sources Sources reached, and "What we don't know yet": each source not reached, with its reason. Read from the script's section, the
22
+ * cycle's data file and the agent's "Sources:" paragraph, so the title never says "every source was reached" when one was not.
20
23
  * Not drawn, because the draft does not read them (RsD): KR sparklines, last-quarter bars, forecast ranges.
21
24
  *
22
25
  * WHERE IT IS USED. One model, three outputs: the in-app slide view (GET /api/report-design/pyramid → deckHtml; premium-report-read.js shows
@@ -24,8 +27,9 @@
24
27
  * (lib/report-export.js pptx draws the same slides with shapes). A cycle drawn to its saved design keeps that render: it is the look.
25
28
  *
26
29
  * THE LOOK. lookOf reads the report's saved design version through report-design.js only (the template's T lines version the same D<n>,
27
- * initiative report template A). From its one style block it takes the first font family and the most used dark colour (white text on
28
- * it passes 4.5:1) as the accent. No saved design: the standard PMOS look (Nunito, navy).
30
+ * initiative report template A). From its style it takes the first font family and, as the accent, the design's own title or primary colour
31
+ * (a --primary/--brand variable, else its title slide's or h1's background, then text colour); only when it names none, the most used dark
32
+ * colour. White text on the accent passes 4.5:1. No saved design: the standard PMOS look (Nunito, navy).
29
33
  */
30
34
 
31
35
  const STANDARD = Object.freeze({
@@ -44,6 +48,7 @@ const ROWS_PER_SLIDE = 7;
44
48
  const DAY = 86400000;
45
49
 
46
50
  const str = (v) => (typeof v === 'string' ? v : '');
51
+ const isObjP = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
47
52
  const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
48
53
  const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
49
54
  const num = (n) => (n >= 0 && n < NUM.length ? NUM[n] : String(n));
@@ -63,6 +68,40 @@ function fromShort(s, today) {
63
68
  }
64
69
  const join = (xs) => (xs.length < 2 ? xs.join('') : xs.slice(0, -1).join(', ') + ' and ' + xs[xs.length - 1]);
65
70
  const firstLine = (s) => plain(str(s).split('\n').map((l) => l.trim()).find((l) => l && !/^<!--/.test(l)) || '');
71
+ /** The sentences of one paragraph. A full stop ends a sentence only before a space and a capital, a digit or a quote. */
72
+ const sentences = (p) => str(p).split(/(?<=[.!?])\s+(?=["“'(A-Z0-9])/).map((x) => x.trim()).filter(Boolean);
73
+ /** Cut a text at a word boundary to at most n characters, with an ellipsis when cut. */
74
+ const clip = (t, n) => { const x = str(t).trim(); if (x.length <= n) return x; const c = x.slice(0, n - 1); const i = c.lastIndexOf(' '); return (i > n * 0.6 ? c.slice(0, i) : c).replace(/[\s,;:–—-]+$/, '') + '…'; };
75
+ const TITLE_MAX = 120; const TITLE_FIT = 90; const LEAD_MAX = 320; const ACTION_MAX = 110;
76
+ // the scorecard's rows fit a height: a row with a KR bullet is taller (brief 4 item 4: twelve rows ran into the key and the footer)
77
+ const ROWS_HEIGHT = 340; const rowHeight = (r) => (r.measure && r.measure.target != null ? 80 : 48);
78
+ /** "2 Nov 2026, 22:00 UTC" from an ISO time (or the text as it is). */
79
+ const human = (iso) => { const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(str(iso)); return m ? `${+m[3]} ${MONS[+m[2] - 1]} ${m[1]}, ${m[4]}:${m[5]} UTC` : str(iso); };
80
+ /** The window's reason in words: the drafter writes "since the last issue" or "report_cycle_days back" (its rule's name). */
81
+ function sinceWords(w) {
82
+ if (!w || !w.since) return '';
83
+ if (/report_cycle_days/.test(w.since)) { const d = Math.round((Date.parse(w.to) - Date.parse(w.fromAt || w.from)) / DAY); return Number.isFinite(d) && d > 0 ? `the last ${d} day${d === 1 ? '' : 's'}` : ''; }
84
+ return w.since.replace(/_/g, ' ');
85
+ }
86
+ /** The sources not reached that the agent named in a note's "Sources:" paragraph: "Not reached: Jira CLDS board (no connector), events list…". */
87
+ function notReachedInNotes(notes) {
88
+ const out = [];
89
+ for (const v of Object.values(notes || {})) {
90
+ for (const para of str(v).split(/\n\s*\n/)) {
91
+ const t = plain(para.replace(/\s+/g, ' '));
92
+ if (!/^Sources\b/i.test(t)) continue;
93
+ const m = /\b(?:not reached|could not (?:be )?(?:read|reach)(?: these)?|unreachable)\b\s*(?:were|was)?\s*[:,-]?\s*([^.]*(?:\([^)]*\)[^.]*)*)/i.exec(t);
94
+ if (!m) continue;
95
+ for (const raw of m[1].split(/;\s*|,\s*(?![^()]*\))|\s+and\s+(?![^()]*\))/)) {
96
+ const x = raw.trim().replace(/^(?:and|the)\s+/i, '');
97
+ if (!x || /^none\b/i.test(x)) continue;
98
+ const p = /^(.*?)\s*\(([^)]*)\)\s*$/.exec(x);
99
+ out.push(p ? { source: p[1].trim(), why: p[2].trim() } : { source: x, why: '' });
100
+ }
101
+ }
102
+ }
103
+ return out.filter((x) => x.source);
104
+ }
66
105
 
67
106
  // ── reading the draft back ──────────────────────────────────────────────────────────────────────────
68
107
  function frontmatter(md) {
@@ -133,8 +172,9 @@ function timelineOf(lines) {
133
172
  }
134
173
 
135
174
  /** The parts of a cadence draft (lib/report-draft.js render), or null when the markdown is not one. */
136
- function readDraft(markdown) {
175
+ function readDraft(markdown, extra) {
137
176
  const md = str(markdown);
177
+ const cycleData = extra && isObjP(extra.data) ? extra.data : null;
138
178
  const S = sectionsOf(md);
139
179
  if (!S.has('Headline') || !S.has('Initiatives') || !S.has('Sources reached and not reached')) return null; // the drafter's frame (report-draft.js render)
140
180
  const fm = frontmatter(md);
@@ -153,11 +193,20 @@ function readDraft(markdown) {
153
193
  const at = src.findIndex((l) => l.trim() === '**Not reached**');
154
194
  const reached = bullets(at < 0 ? src : src.slice(0, at)).filter((x) => x !== 'none');
155
195
  const notReached = (at < 0 ? [] : bullets(src.slice(at))).filter((x) => x !== 'none').map((x) => { const i = x.indexOf(': '); return i < 0 ? { source: x, why: '' } : { source: x.slice(0, i), why: x.slice(i + 2) }; });
196
+ // a structured report's sources live in its <slot>.data.json and in the agent's "Sources:" paragraph, not in the script's section (brief 4 item 6)
197
+ const seen = new Set(notReached.map((x) => x.source.toLowerCase()));
198
+ const more = (cycleData && Array.isArray(cycleData.sources) ? cycleData.sources : []).filter((x) => x && x.reached === false && str(x.id || x.name)).map((x) => ({ source: str(x.name || x.id), why: str(x.reason) }))
199
+ .concat(notReachedInNotes(notes));
200
+ for (const x of more) { const k = x.source.toLowerCase(); if (!seen.has(k)) { seen.add(k); notReached.push(x); } }
201
+ for (const x of cycleData && Array.isArray(cycleData.sources) ? cycleData.sources : []) {
202
+ const k = str(x && (x.name || x.id));
203
+ if (x && x.reached === true && k && !reached.some((r) => r.toLowerCase() === k.toLowerCase())) reached.push(k);
204
+ }
156
205
  const product = data && data.meta ? str(data.meta.product) : '';
157
206
  return {
158
207
  title: (h1 ? h1[1] : str(fm.title)).trim(), name: (h1 ? h1[1] : str(fm.title)).replace(/\s+\d{4}-\d{2}-\d{2}$/, '').trim(),
159
208
  cadence: str(fm.cadence), slot, product, today,
160
- window: win ? { from: win[1].slice(0, 10), to: win[2], since: win[3] || '' } : null,
209
+ window: win ? { from: win[1].slice(0, 10), fromAt: win[1], to: win[2], since: win[3] || '' } : null,
161
210
  headline: head, headlineNote: firstLine(notes['report.headline']),
162
211
  blocks: blocksOf(S.get('Initiatives'), notes, today),
163
212
  timeline: timelineOf(S.get('Timeline') || []),
@@ -219,52 +268,80 @@ function timelineTitle(rows, overdue) {
219
268
  return cap(t);
220
269
  }
221
270
 
271
+ /** The scorecard's action title: the verdict in one line (brief 4 item 4). The long form names who is on track too; when it would not fit on one
272
+ * line it is "3 of 12 off plan: X, Y and Z", with at most three names ("… and 2 more"). */
273
+ function scorecardTitle(blocks, off, on) {
274
+ const n = blocks.length;
275
+ if (!n) return 'No initiative was active in this window';
276
+ if (!off.length) return n === 1 ? `${blocks[0].title} is on plan and needs no comment` : `All ${num(n)} initiatives are on plan; no row needs a comment`;
277
+ const long = `${off.length === 1 ? `Only ${off[0].title} is` : `${join(off.map((b) => b.title))} are`} off plan${on.length ? `; ${join(on.map((b) => b.title))} ${on.length === 1 ? 'is' : 'are'} on track and need${on.length === 1 ? 's' : ''} no comment` : ''}`;
278
+ if (long.length <= ACTION_MAX) return long;
279
+ const names = off.slice(0, 3).map((b) => b.title).concat(off.length > 3 ? [`${off.length - 3} more`] : []);
280
+ const short1 = `${off.length} of ${n} off plan: ${join(names)}`;
281
+ return short1.length <= ACTION_MAX ? short1 : `${off.length} of ${n} initiatives are off plan`;
282
+ }
222
283
  /** The Pyramid deck of a cadence draft, or null when the markdown is not one. */
223
- function deckOf(markdown, look) {
224
- const d = readDraft(markdown);
284
+ function deckOf(markdown, look, extra) {
285
+ const d = readDraft(markdown, extra);
225
286
  if (!d) return null;
226
287
  const L = { ...STANDARD, ...(look || {}) };
227
288
  const slides = [];
228
- const frozen = d.window ? `Frozen at ${d.window.to.replace('T', ' ').replace(/:00Z$/, ' UTC').replace(/Z$/, ' UTC')}` : '';
289
+ const frozen = d.window ? `Frozen ${human(d.window.to)}` : '';
290
+ // the title is the answer: the first sentence of the PM's headline note; the rest of that paragraph opens the Answer slide (brief 4 item 3)
291
+ const said = sentences(d.headlineNote);
292
+ // a long first sentence keeps its first clause as the title; the clause after it opens the lead, so nothing the PM wrote is lost
293
+ const cut = said[0] && said[0].length > TITLE_FIT ? /^(.{30,}?)(?:,| —| –|;)\s+(?:because |as |so |which |while |but )?(.+)$/.exec(said[0]) : null;
294
+ const head = cut && cut[1].length <= TITLE_FIT ? cut[1].replace(/[,;]$/, '') + '.' : said[0];
295
+ const rest = (cut && cut[1].length <= TITLE_FIT ? [cap(cut[2])] : []).concat(said.slice(1));
296
+ const titleText = clip(head || shortAnswer(d), TITLE_MAX);
297
+ const lead = rest.length ? clip(rest.join(' '), LEAD_MAX) : '';
298
+ const since = sinceWords(d.window);
229
299
  const measures = d.blocks.filter((b) => b.measure);
230
- const krSrc = measures.map((b) => `${b.measure.anchor} as of ${short(b.measure.as_of)} (${b.measure.source})`);
300
+ const krSrc = [...new Set(measures.map((b) => `${b.measure.anchor} as of ${short(b.measure.as_of)} (${b.measure.source})`))];
231
301
  const ask = askOf(d);
232
302
 
233
303
  slides.push({
234
304
  kind: 'title', tag: [d.name ? (/\breport$/i.test(d.name) ? d.name : `${d.name} report`) : 'Report', d.product].filter(Boolean).join(' · '),
235
- title: d.headlineNote || shortAnswer(d),
236
- sub: [d.slot ? short(d.slot) + ' ' + d.slot.slice(0, 4) : '', d.window ? `window ${short(d.window.from)} to ${short(d.window.to)}${d.window.since ? ', ' + d.window.since : ''}` : ''].filter(Boolean).join(' · '),
305
+ title: titleText, size: titleText.length > 60 ? 48 : 64,
306
+ sub: [d.slot ? short(d.slot) + ' ' + d.slot.slice(0, 4) : '', d.window ? `window ${short(d.window.from)} to ${short(d.window.to)}${since ? ', ' + since : ''}` : ''].filter(Boolean).join(' · '),
237
307
  source: frozen,
238
308
  });
239
309
  const order = d.blocks.filter(offPlan).concat(d.blocks.filter((b) => !offPlan(b)));
240
310
  slides.push({
241
- kind: 'answer', tag: 'The answer', title: longAnswer(d), supports: order.slice(0, 3).map(supportOf), ask,
311
+ kind: 'answer', tag: 'The answer', title: clip(longAnswer(d), TITLE_MAX), lead, supports: order.slice(0, 3).map(supportOf), ask,
242
312
  source: ['Source: Headline, initiative blocks', ...krSrc, ask ? 'Needs your call note' : ''].filter(Boolean).join(' · '),
243
313
  });
244
314
 
245
315
  const off = d.blocks.filter(offPlan); const on = d.blocks.filter((b) => b.level === 'on');
246
- const scTitle = !d.blocks.length ? 'No initiative was active in this window'
247
- : !off.length ? (d.blocks.length === 1 ? `${d.blocks[0].title} is on plan and needs no comment` : `All ${num(d.blocks.length)} initiatives are on plan; no row needs a comment`)
248
- : `${off.length === 1 ? `Only ${off[0].title} is` : `${join(off.map((b) => b.title))} are`} off plan${on.length ? `; ${join(on.map((b) => b.title))} ${on.length === 1 ? 'is' : 'are'} on track and need${on.length === 1 ? 's' : ''} no comment` : ''}`;
316
+ const scTitle = scorecardTitle(d.blocks, off, on);
249
317
  const anchor = measures.length && measures.every((b) => b.measure.anchor === measures[0].measure.anchor) ? measures[0].measure.anchor : 'KR';
250
318
  const rows = order.map((b) => ({
251
319
  title: b.title, stage: b.stage, level: b.level, progress: b.progress, measure: b.measure, gap: gapText(b.measure),
252
320
  comment: offPlan(b) ? [b.reason ? cap(b.reason) + '.' : '', firstLine(b.note)].filter(Boolean).join(' ') : '',
253
321
  }));
254
- for (let i = 0; i < Math.max(rows.length, 1); i += ROWS_PER_SLIDE) {
255
- slides.push({ kind: 'scorecard', title: i ? scTitle + ' (continued)' : scTitle, rows: rows.slice(i, i + ROWS_PER_SLIDE), kr: measures.length ? anchor : null,
256
- source: ['Source: initiative blocks', ...krSrc].join(' · ') });
322
+ const pages = [[]]; let used = 0;
323
+ for (const r of rows) {
324
+ const h = rowHeight(r);
325
+ if (pages[pages.length - 1].length && (used + h > ROWS_HEIGHT || pages[pages.length - 1].length >= ROWS_PER_SLIDE)) { pages.push([]); used = 0; }
326
+ pages[pages.length - 1].push(r); used += h;
257
327
  }
328
+ pages.forEach((pg, i) => {
329
+ slides.push({ kind: 'scorecard', title: i ? clip(scTitle, ACTION_MAX - 12) + ' (continued)' : scTitle, rows: pg, kr: measures.length ? anchor : null,
330
+ source: ['Source: initiative blocks', ...krSrc].join(' · ') });
331
+ });
258
332
 
259
333
  const overdue = d.blocks.flatMap((b) => b.carried.filter((c) => c.due).map((c) => ({ ...c, initiative: b.title })));
260
334
  // an initiative with an overdue item but no dated span still gets its row: the overdue mark is the finding
261
335
  const tl = d.timeline.concat(d.blocks.filter((b) => !d.timeline.some((r) => r.title === b.title) && b.carried.some((c) => c.due)).map((b) => ({ title: b.title, spans: [], holds: [] })));
262
336
  if (tl.length) {
263
337
  const ids = new Map(d.blocks.map((b) => [b.title, b.id]));
264
- const days = tl.flatMap((r) => r.spans.flatMap((s) => [s.start, s.due])).concat(overdue.map((o) => o.due), d.today ? [d.today] : []).filter(Boolean).sort();
338
+ // the title speaks only of the rows the chart draws (brief 4 item 5)
339
+ const shown = tl.slice(0, 6);
340
+ const shownOverdue = overdue.filter((o) => shown.some((r) => r.title === o.initiative));
341
+ const days = shown.flatMap((r) => r.spans.flatMap((s) => [s.start, s.due])).concat(shownOverdue.map((o) => o.due), d.today ? [d.today] : []).filter(Boolean).sort();
265
342
  slides.push({
266
- kind: 'timeline', title: timelineTitle(tl, overdue), from: days[0], to: days[days.length - 1], today: d.today,
267
- rows: tl.slice(0, 6).map((r) => ({ title: r.title, spans: r.spans, holds: r.holds, milestone: d.milestones.find((m) => m.id === ids.get(r.title) || m.title === r.title) || null,
343
+ kind: 'timeline', title: clip(timelineTitle(shown, shownOverdue), ACTION_MAX + 20), from: days[0], to: days[days.length - 1], today: d.today,
344
+ rows: shown.map((r) => ({ title: r.title, spans: r.spans, holds: r.holds, milestone: d.milestones.find((m) => m.id === ids.get(r.title) || m.title === r.title) || null,
268
345
  overdue: overdue.filter((o) => o.initiative === r.title) })),
269
346
  source: `Source: dated work items (Timeline section)${d.window ? ', frozen ' + short(d.window.to) : ''}`,
270
347
  });
@@ -276,7 +353,7 @@ function deckOf(markdown, look) {
276
353
  const nr = d.notReached;
277
354
  slides.push({
278
355
  kind: 'sources',
279
- title: `Every number comes from ${num(d.reached.length)} source${d.reached.length === 1 ? '' : 's'}${nr.length ? `; ${join(nr.map((x) => x.source))} could not be read, so what ${nr.length === 1 ? 'it holds is' : 'they hold is'} not in this report` : '; every source was reached'}`,
356
+ title: clip(`Every number comes from ${num(d.reached.length)} source${d.reached.length === 1 ? '' : 's'}${nr.length ? `; ${nr.length > 3 ? `${num(nr.length)} sources` : join(nr.map((x) => x.source))} could not be read, so what ${nr.length === 1 ? 'it holds is' : 'they hold is'} not in this report` : '; every source was reached'}`, TITLE_MAX + 40),
280
357
  reached: d.reached, notReached: nr, source: 'Source: Sources reached and not reached',
281
358
  });
282
359
  slides.forEach((s, i) => { s.n = i + 1; });
@@ -360,14 +437,14 @@ function slideHtml(s, L) {
360
437
  const open = (cls) => `<section class="pmos-slide py${cls ? ' ' + cls : ''}" data-title="${esc(s.title)}" data-kind="${s.kind}">`;
361
438
  if (s.kind === 'title') {
362
439
  return open('py-navy') + `<div class="in" style="justify-content:flex-end;padding-bottom:54px"><div class="tag" style="color:#c9d3ea">${esc(s.tag)}</div>`
363
- + `<div style="font-size:64px;font-weight:800;line-height:1.1;margin:16px 0;max-width:1000px">${esc(s.title)}</div><div style="font-size:22px;color:#dde4f3;max-width:900px;line-height:1.5">${esc(s.sub)}</div></div>${footer(s)}</section>`;
440
+ + `<div style="font-size:${s.size || 64}px;font-weight:800;line-height:1.1;margin:16px 0;max-width:1100px">${esc(s.title)}</div><div style="font-size:22px;color:#dde4f3;max-width:900px;line-height:1.5">${esc(s.sub)}</div></div>${footer(s)}</section>`;
364
441
  }
365
442
  const head = (tag) => `${tag ? `<div class="tag">${esc(tag)}</div>` : ''}<h3 class="ti">${esc(s.title)}</h3><div class="rule"></div>`;
366
443
  let body = '';
367
444
  if (s.kind === 'answer') {
368
445
  const sup = s.supports.map((x, i) => `<div class="sup"><span class="n">${i + 1}</span><div><b>${esc(x.head)}</b><span>${esc(x.text)}</span></div></div>`).join('');
369
446
  const ask = s.ask && s.ask.question ? `<div style="flex:1"><div class="ask"><div class="tag" style="color:${L.accent}">Your call</div><div class="q">${esc(s.ask.question)}</div><div class="sm">For ${esc(s.ask.who)}${s.ask.by ? ` · by ${esc(s.ask.by)}` : ''}</div></div></div>` : '';
370
- body = `<div style="display:flex;gap:42px;flex:1;min-height:0"><div style="flex:1.45">${sup}</div>${ask}</div>`;
447
+ body = (s.lead ? `<p class="sm" style="margin:-8px 0 14px;max-width:1120px">${esc(s.lead)}</p>` : '') + `<div style="display:flex;gap:42px;flex:1;min-height:0"><div style="flex:1.45">${sup}</div>${ask}</div>`;
371
448
  } else if (s.kind === 'scorecard') {
372
449
  const cols = s.kr ? '220px 120px 80px 230px 270px 1fr' : '260px 140px 90px 260px 1fr';
373
450
  const hd = ['Initiative', 'Stage', 'Status', 'Done of total'].concat(s.kr ? [`${s.kr} · now vs target`] : [], ['Why, what we do']);
@@ -403,6 +480,21 @@ function lum(hex) {
403
480
  const ch = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255).map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
404
481
  return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2];
405
482
  }
483
+ const hex6 = (k) => (k.length === 4 ? '#' + k.slice(1).split('').map((c) => c + c).join('') : k).toLowerCase();
484
+ /** A colour white text can sit on (4.5:1) that is a colour, not a grey. */
485
+ function isAccent(k) { const h = hex6(k).slice(1); const v = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)); return lum(hex6(k)) <= 0.18 && Math.max(...v) - Math.min(...v) >= 16; }
486
+ const TITLE_SEL = /data-(?:kind|slide-id)\s*=\s*["']?title\b|\.(?:title|cover|hero)(?:-slide)?\b|(?:^|[\s,>])h1\b/i;
487
+ const PRIMARY_VAR = /--(?:primary|brand|accent|title|main|navy|heading)[\w-]*\s*:\s*(#[0-9a-f]{6}|#[0-9a-f]{3})\b/gi;
488
+ /** The colour a design names as its own: a --primary/--brand/--accent/--title variable, else the background (then the text colour) of its title
489
+ * slide or main heading. Only a colour that passes isAccent. */
490
+ function primaryOf(t) {
491
+ for (const m of t.matchAll(PRIMARY_VAR)) if (isAccent(m[1])) return hex6(m[1]);
492
+ const rules = [...t.matchAll(/([^{}]+)\{([^{}]*)\}/g)].filter((m) => TITLE_SEL.test(m[1]));
493
+ for (const prop of [/(?:^|;)\s*background(?:-color)?\s*:[^;]*?(#[0-9a-f]{6}|#[0-9a-f]{3})\b/i, /(?:^|;)\s*color\s*:\s*(#[0-9a-f]{6}|#[0-9a-f]{3})\b/i]) {
494
+ for (const r of rules) { const c = prop.exec(r[2]); if (c && isAccent(c[1])) return hex6(c[1]); }
495
+ }
496
+ return null;
497
+ }
406
498
  /** The font and accent a saved design's style uses, or null. Pure: css = the render's style text. */
407
499
  function lookFromCss(cssText) {
408
500
  const t = str(cssText);
@@ -411,11 +503,13 @@ function lookFromCss(cssText) {
411
503
  const font = fm ? fm[1].split(',')[0].trim().replace(/^['"]|['"]$/g, '').replace(/[^A-Za-z0-9 -]/g, '').slice(0, 60) : '';
412
504
  const count = new Map();
413
505
  for (const m of t.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) { const k = '#' + m[1].toLowerCase(); count.set(k, (count.get(k) || 0) + 1); }
414
- const dark = [...count.entries()].filter(([k]) => lum(k) <= 0.18 && (() => { const h = k.length === 4 ? k.slice(1).split('').map((c) => c + c).join('') : k.slice(1); const v = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)); return Math.max(...v) - Math.min(...v) >= 16; })())
415
- .sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
506
+ const dark = [...count.entries()].filter(([k]) => isAccent(k)).sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
416
507
  const out = {};
417
508
  if (font && !/^(inherit|initial|var)/i.test(font)) out.font = font;
418
- if (dark.length) out.accent = dark[0][0];
509
+ // the look's own title or primary colour first (live check of D3: navy #1f4e79, not the most used slate); the most used dark colour after it
510
+ const named = primaryOf(t);
511
+ if (named) out.accent = named;
512
+ else if (dark.length) out.accent = dark[0][0];
419
513
  return Object.keys(out).length ? out : null;
420
514
  }
421
515
  /** The look of a report: its saved design D<n> (read through report-design.js), else the standard PMOS look. Never throws. */
@@ -426,9 +520,16 @@ function lookOf(root, cadence) {
426
520
  if (!st.saved) return { ...STANDARD };
427
521
  const n = st.saved.version;
428
522
  const html = D.versionHtml(root, cadence, n);
429
- const styles = [...str(html).matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1]).join('\n');
523
+ const styles = [...str(html).matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1])
524
+ // the title slide's own inline style counts as a title rule
525
+ .concat([...str(html).matchAll(/<section\b[^>]*data-(?:kind|slide-id)="title"[^>]*\bstyle="([^"]*)"/gi)].map((m) => `[data-kind=title]{${m[1]}}`)).join('\n');
430
526
  return { ...STANDARD, ...(lookFromCss(styles) || {}), name: `Look D${n}`, version: n };
431
527
  } catch { return { ...STANDARD }; }
432
528
  }
433
529
 
434
- module.exports = { STANDARD, readDraft, deckOf, deckHtml, lookOf, lookFromCss, short, fromShort };
530
+ /** The cycle's numbers next to a structured draft (<slot>.data.json), or null. abs = the draft's absolute path. */
531
+ function dataOf(abs) {
532
+ try { const o = JSON.parse(require('fs').readFileSync(String(abs).replace(/\.md$/, '.data.json'), 'utf8')); return isObjP(o) ? o : null; } catch { return null; }
533
+ }
534
+
535
+ module.exports = { STANDARD, readDraft, deckOf, deckHtml, lookOf, lookFromCss, dataOf, short, fromShort, sentences, clip };
@@ -157,11 +157,11 @@ function fold(lines) {
157
157
  }
158
158
 
159
159
  /** T1, from the setup as it is now. Once: a report with a version already keeps it. `chat` names the conversation it came from. */
160
- function ensureFirst(root, id, setup, { chat, refs, now } = {}) {
160
+ function ensureFirst(root, id, setup, { chat, refs, why, now } = {}) {
161
161
  if (!ID_RE.test(String(id || ''))) return refuse(400, 'id must be a report id');
162
162
  const f = fold(readLines(root, id));
163
163
  if (f.current != null) return { ok: true, n: f.current, existing: true };
164
- const line = { ev: 'version', n: 1, settings: settingsOf(setup, refs || liveRefs(root, id)), why: 'First shape, from your chat.' };
164
+ const line = { ev: 'version', n: 1, settings: settingsOf(setup, refs || liveRefs(root, id)), why: typeof why === 'string' && why ? why.slice(0, 200) : 'First shape, from your chat.' };
165
165
  if (typeof chat === 'string' && CHAT_RE.test(chat)) line.chat = chat;
166
166
  append(root, id, line, now);
167
167
  return { ok: true, n: 1 };
@@ -179,6 +179,36 @@ function addVersion(root, id, settings, { why, names, fill, now } = {}) {
179
179
  append(root, id, { ev: 'version', n, settings: next, why: typeof why === 'string' && why.trim() ? why.trim().slice(0, 200) : diffWords(was, next, names).join('; ').slice(0, 200) }, now);
180
180
  return { ok: true, n, settings: next };
181
181
  }
182
+ /** The saved version (accept n, unsave null) of one store's events at a time; undefined when the store has no event at or before it. */
183
+ function savedAt(events, t) {
184
+ let out;
185
+ for (const e of Array.isArray(events) ? events : []) {
186
+ const at = Date.parse((e && e.at) || '');
187
+ if (!Number.isFinite(at) || at > t) continue;
188
+ if (e.ev === 'accept') out = refN(e.n); else if (e.ev === 'unsave') out = null;
189
+ }
190
+ return out;
191
+ }
192
+ /** Backfill on read (live check of 0.494.0, brief 4 item 10): a T line written before TplA has no sections or look. Each such version takes the
193
+ * S<n> and D<n> that were saved at its time (no saved one then = the standard ones); the current version takes the ones saved now. Nothing is
194
+ * written. `filled` names the keys that were backfilled. Pure apart from reading the two stores' events. */
195
+ function backfill(root, id, folded) {
196
+ if (!folded || !Array.isArray(folded.versions) || !folded.versions.some((v) => v.settings.structure === undefined || v.settings.design === undefined)) return folded;
197
+ let sEv = [], dEv = [];
198
+ try { sEv = require('./report-structure.js').readEvents(root, id); } catch { sEv = []; }
199
+ try { dEv = require('./report-design.js').readEvents(root, id); } catch { dEv = []; }
200
+ const live = liveRefs(root, id);
201
+ const versions = folded.versions.map((v) => {
202
+ const fill = {}; const t = Date.parse(v.at || '');
203
+ for (const [k, ev] of [['structure', sEv], ['design', dEv]]) {
204
+ if (v.settings[k] !== undefined) continue;
205
+ const then = Number.isFinite(t) ? savedAt(ev, t) : undefined;
206
+ fill[k] = v.current || !Number.isFinite(t) ? live[k] : then === undefined ? null : then;
207
+ }
208
+ return Object.keys(fill).length ? { ...v, settings: { ...v.settings, ...fill }, filled: Object.keys(fill) } : v;
209
+ });
210
+ return { ...folded, versions };
211
+ }
182
212
  /** Go back to T<n>: that version is the current one again. Nothing is deleted. */
183
213
  function restore(root, id, n, { now } = {}) {
184
214
  const f = fold(readLines(root, id));
@@ -230,4 +260,4 @@ function followBefore(root, id, setup) { try { const was = liveRefs(root, id); e
230
260
  function followAfter(root, id, patch, was) { try { return addVersion(root, id, patch, { names: namesOf(root, id), fill: was }); } catch { return null; } }
231
261
 
232
262
  module.exports = { ID_RE, CHAT_RE, FORMAT, STANDARD_LOOK, ledgerOf, readLines, fold, settingsOf, cleanSettings, sameSettings, sectionsDiff, diffWords, settingsWords, goesWords,
233
- liveRefs, namesOf, refN, ensureFirst, addVersion, restore, cyclesOf, chatOf, review, followBefore, followAfter };
263
+ liveRefs, namesOf, refN, ensureFirst, backfill, savedAt, addVersion, restore, cyclesOf, chatOf, review, followBefore, followAfter };
@@ -155,11 +155,11 @@ module.exports = {
155
155
  }
156
156
  } catch { missing.push('Claude\'s proposals'); }
157
157
  // Claude's suggestion to split an initiative or merge two (batch 11 item 14): made from the tasks and tickets on every read, never a timer;
158
- // the PM's ✕ Not now hides one until its tasks change. A failure here only leaves the suggestions out.
158
+ // the PM's ✕ hides one while its facts hold (the same group, the same partner). A failure here only leaves the suggestions out.
159
159
  try {
160
160
  const w = ctx.getWs ? ctx.getWs(pick.ws) : null;
161
161
  let dismissed = null;
162
- try { const f = w ? boards.foldOf(ctx, w) : null; dismissed = f ? f.suggestNo : null; } catch { dismissed = null; }
162
+ try { const f = w ? boards.foldOf(ctx, w) : null; dismissed = f ? Suggest.dismissedOf(f.events) : null; } catch { dismissed = null; }
163
163
  const items = its && its.body && Array.isArray(its.body.items) ? its.body.items : [];
164
164
  const open = ((model.initiatives && model.initiatives.list && model.initiatives.list.rows) || []).filter((r) => r && r.id && r.status !== 'done' && r.status !== 'on hold');
165
165
  const got = Suggest.suggest(open, items, { dismissed });
@@ -207,10 +207,43 @@ function createAndPlace(ctx, w, boardId, input, actor, tie) {
207
207
  const stage = (ownWf || board.workflow)[0].name;
208
208
  tie({ file, board, initiative: r.id, stage });
209
209
  if (ownWf) append(file, actor, { kind: 'ini-workflow', board: board.id, id: r.id, template: tpl, stages: ownWf, moved: null });
210
+ const wfStart = startOnDefault(ctx, w, r.id, actor, { boardId: board.id });
210
211
  rowsLib.invalidate(w.root);
211
212
  rowCache.delete(w.root);
212
213
  if (ctx.invalidateNeedsAgg) ctx.invalidateNeedsAgg(w.id);
213
- return { ok: true, initiative: { id: r.id, path: r.path }, stage, board: board.id };
214
+ return { ok: true, initiative: { id: r.id, path: r.path }, stage, board: board.id, ...(wfStart ? { wf: wfStart } : {}) };
215
+ }
216
+
217
+ /** Batch 24 item 3 (canvas DfA): a new initiative starts on the default workflow (Settings › Workflows, the star). It writes one ini-wf
218
+ * line on the initiative's board, the line a workflow switch writes, so every surface reads the workflow from it; with no `moved` the
219
+ * initiative stands in the first stage (Triage) and no move shows in its history. Product feature (the default when none is set, or when
220
+ * the default is archived) writes nothing. The workspace's own default first; a workspace that never set one reads the home workspace's
221
+ * (Settings opens on home), and that workflow is adopted into this workspace's library first, as a switch does. PMOS still reads the BRD
222
+ * and proposes a switch. Never throws: a new initiative is made even when this line cannot be written.
223
+ * boardId: the board the initiative is on; else its domain board (products/<domain>/) or the workspace board. → the workflow id | null */
224
+ function startOnDefault(ctx, w, iniId, actor, { boardId = null, recordPath = null } = {}) {
225
+ try {
226
+ const file = ledgerFile(ctx, w);
227
+ const dir = ctx.stateDirFor ? ctx.stateDirFor(w) : null;
228
+ if (!file || !dir || isSelf(ctx, w)) return null;
229
+ let lib = WS.library(dir), from = null;
230
+ if (!lib.defaultId) {
231
+ const home = ctx.getWs ? ctx.getWs(null) : null;
232
+ const hd = home && home.id !== w.id && ctx.stateDirFor ? ctx.stateDirFor(home) : null;
233
+ const hl = hd ? WS.library(hd) : null;
234
+ if (hl && hl.defaultId) { lib = hl; from = home; }
235
+ }
236
+ const def = WF.defaultOf(lib);
237
+ if (def.id === WF.PRODUCT_FEATURE_ID) return null;
238
+ const fr = frameFor(ctx, w);
239
+ const dm = recordPath ? /^products\/([^/]+)\//.exec(recordPath) : null;
240
+ const bid = boardId || (dm && fr.boards.some((b) => b.id === dm[1] && b.kind === 'domain') ? dm[1] : w.id);
241
+ const board = fr.boards.find((b) => b.id === bid);
242
+ if (!board || board.self) return null;
243
+ if (from && !WS.library(dir).byId.has(def.id)) { const a = WS.adopt(dir, def, actor, from.id); if (!a.ok) return null; }
244
+ append(file, actor, { kind: 'ini-wf', board: board.id, id: iniId, wf: def.id, why: 'the default workflow for new initiatives', start: true });
245
+ return def.id;
246
+ } catch { return null; }
214
247
  }
215
248
 
216
249
  // ── boards of a workspace ─────────────────────────────────────────────────────────────────────────
@@ -1784,4 +1817,4 @@ function meetingBoards(ctx) {
1784
1817
  return { list, detail };
1785
1818
  }
1786
1819
 
1787
- module.exports = { prefixes: ['/api/boards'], handle, tasksOfIni, postFrame, needRows, itemsOf, readLedger, foldOf, epicsOf, rowsOfBoard, invalidateRows: (w) => { rowsLib.invalidate(w.root); rowCache.delete(w.root); }, move, switchWorkflow, taskState, taskAdd, taskEdit, writeAs, taskDelete, taskRestore, initiativeWorkflow, seedInitiative, stageTasks, boardIndex, appendScanAsk, scanDeclineSignal, createAndPlace, ledgerFile, append, frameFor, boardIdOf, meetingBoards, boardsOf, baseRows, boardRow, taskSpanOf, workflowRowOf, foldOf, portfolio, SELF_REFUSAL, NO_STATE };
1820
+ module.exports = { prefixes: ['/api/boards'], handle, tasksOfIni, postFrame, needRows, itemsOf, readLedger, foldOf, epicsOf, rowsOfBoard, invalidateRows: (w) => { rowsLib.invalidate(w.root); rowCache.delete(w.root); }, move, switchWorkflow, taskState, taskAdd, taskEdit, writeAs, taskDelete, taskRestore, initiativeWorkflow, seedInitiative, stageTasks, boardIndex, appendScanAsk, scanDeclineSignal, createAndPlace, startOnDefault, ledgerFile, append, frameFor, boardIdOf, meetingBoards, boardsOf, baseRows, boardRow, taskSpanOf, workflowRowOf, foldOf, portfolio, SELF_REFUSAL, NO_STATE };
@@ -241,7 +241,8 @@ const nameCache = new Map(); // cwd -> { key, name }
241
241
  function gitConfigKey(cwd) {
242
242
  const os = require('os');
243
243
  const home = os.homedir();
244
- const files = [path.join(home, '.gitconfig'), path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'git', 'config'), path.join(cwd, '.git', 'config')];
244
+ const files = [path.join(home, '.gitconfig'), path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'git', 'config')];
245
+ if (cwd) files.push(path.join(cwd, '.git', 'config')); // a caller with no folder (routes-mentions answer in a test) reads the global config only
245
246
  if (process.env.GIT_CONFIG_GLOBAL) files.push(process.env.GIT_CONFIG_GLOBAL);
246
247
  return files.map((f) => { try { const st = fs.statSync(f); return st.size + ':' + st.mtimeMs; } catch { return '-'; } }).join('|');
247
248
  }
@@ -666,6 +666,7 @@ async function handle(req, res, url, ctx) {
666
666
  const { ws: _ws, ...input } = body;
667
667
  const r = writer.createInitiative(w.root, input, { actor: g.actor });
668
668
  if (!r.ok) { ctx.json(res, { ok: false, status: r.status, error: r.error, field: r.field }, r.status); return true; }
669
+ require('./routes-boards.js').startOnDefault(ctx, w, r.id, g.actor, { recordPath: r.path }); // batch 24 item 3: the default workflow
669
670
  rowsLib.invalidate(w.root);
670
671
  if (ctx.invalidateNeedsAgg) ctx.invalidateNeedsAgg(w.id);
671
672
  ctx.json(res, { ok: true, id: r.id, path: r.path, written: r.written, sig: sigOf(w.root) });
@@ -8,7 +8,8 @@
8
8
  * POST /api/proposals/keep { ws, board, initiative, ids?: [id] | all: true, choices?: { <from>><to>: 'keep' | 'ignore' } }
9
9
  * → { ok, batch, said, counts, props } ✓ one proposal, or ✓ in the header (all: every one Claude is sure of;
10
10
  * Check these and a Jira ticket's comment never ride on it). One batch, one toast, one Undo.
11
- * POST /api/proposals/drop { ws, board, initiative, sig } → { ok } ✕ on a proposal or on one item of it, for good (rule 7)
11
+ * POST /api/proposals/drop { ws, board, initiative, sig } → { ok } ✕ on a proposal or on one item of it, for good (rule 7); the line
12
+ * also keeps its facts (PR.factsOf), so a re-sectioned task or a new day does not bring it back
12
13
  * POST /api/proposals/undo { ws, board, batch } → { ok, back, kept } the whole batch; a task changed since keeps its change
13
14
  * POST /api/proposals/stop { ws, board, initiative } → { ok } ■ Stop: the look ends; what was found so far stays proposed
14
15
  * POST /api/proposals/look { ws, board, initiative, why?: 'populate' | 'check' } → { ok, started } Claude looks now (the New initiative
@@ -258,7 +259,18 @@ const WRITES = {
258
259
  '/api/proposals/drop': (ctx, res, body, g, p) => {
259
260
  const sig = String(body.sig || ''), ini = String(body.initiative || '');
260
261
  if (!/^[sdi]:[0-9a-f]{10}$/.test(sig) || !ini) return fail(ctx, res, 400, 'sig names a proposal or one item of it; initiative is required');
261
- PR.append(dirOf(ctx, p.w), g.actor, { kind: 'drop', ini, sig });
262
+ // what the ✕ rests on (batch 24 item 6): a date proposal's tasks and the dates they missed, a grill finding's angle and sources, so a
263
+ // workflow switch, a reworded reason or a new day does not bring it back. A failure here keeps the plain signature.
264
+ let facts = [];
265
+ try {
266
+ const v = view(ctx, p, ini, null, { grillAll: true });
267
+ for (const x of v ? v.groups.flatMap((gr) => gr.props) : []) {
268
+ if (x.sig === sig) { facts = PR.factsOf(x); break; }
269
+ const it = (x.items || []).find((y) => y && y.sig === sig);
270
+ if (it) { facts = PR.factsOf(x, it); break; }
271
+ }
272
+ } catch { facts = []; }
273
+ PR.append(dirOf(ctx, p.w), g.actor, { kind: 'drop', ini, sig, ...(facts.length ? { facts } : {}) });
262
274
  ctx.json(res, { ok: true });
263
275
  },
264
276
  '/api/proposals/undo': (ctx, res, body, g, p) => {
@@ -482,7 +482,7 @@ async function handle(req, res, url, ctx) {
482
482
  // The finished slides of a cadence draft (canvas RsA): the Pyramid deck in the report's saved look, else the standard PMOS look.
483
483
  const d = reportDoc(w, text(url.searchParams.get('path'))); if (d.error) return send(d.error);
484
484
  let md; try { md = fs.readFileSync(d.c.md, 'utf8'); } catch { return send(bad(404, 'no such report document')); }
485
- const deck = RP.deckOf(md, RP.lookOf(w.root, d.c.cadence));
485
+ const deck = RP.deckOf(md, RP.lookOf(w.root, d.c.cadence), { data: RP.dataOf(d.c.md) });
486
486
  if (!deck) return send(bad(404, 'this document is not a cadence draft, so it has no Pyramid deck'));
487
487
  return sendHtml(res, D.sanitize(RP.deckHtml(deck)));
488
488
  }
@@ -18,7 +18,8 @@
18
18
  * POST /api/report-flow/ask { ws, id, text, path? } → { ok, session, path, passed | started } a change for the report's own session
19
19
  * Without a path the change goes to the report's latest draft; an issued latest cycle (or path) is refused
20
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).
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).
22
23
  *
23
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
24
25
  * structure drafts in the session its schedule starts (lib/routes-report-structure.js draftNow, origin schedule:<id>). A change the PM asks for
@@ -62,7 +63,12 @@ function draftingOf(ctx, w, id) { try { return typeof ctx.structureDraftState ==
62
63
  function stateOf(ctx, w, id, now = Date.now()) {
63
64
  const root = w.root, s = setupOf(root, id);
64
65
  const cycles = cyclesOf(root, id);
65
- 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)));
66
72
  const counts = RT.cyclesOf(f, cycles.filter((c) => c.issued_at).map((c) => ({ at: c.issued_at })));
67
73
  const names = RT.namesOf(root, id), live = RT.liveRefs(root, id);
68
74
  const sess = readSession(root, id);
@@ -143,7 +149,8 @@ const slotOfRel = (rel) => { const m = /(\d{4}-\d{2}-\d{2})\.md$/.exec(String(re
143
149
  /** The line that names the draft a change is for: its file, its slot, and the rule that no new slot is drafted. */
144
150
  function draftWords(id, rel) {
145
151
  const slot = slotOfRel(rel);
146
- return rel && slot ? `The draft: ${rel} (slot ${slot}). Change that file in place. Do not draft a new slot: run \`pmos report draft ${id} ${slot} --redraft\` only, never \`pmos report draft ${id}\` without the slot.` : '';
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.` : '';
147
154
  }
148
155
  /** The words the report's session gets for one change. */
149
156
  function askPrompt(skill, id, rel, change) {
@@ -246,7 +253,7 @@ async function handle(req, res, url, ctx) {
246
253
  return v.ok ? send({ status: 200, body: { ok: true, n: v.n, state: stateOf(ctx, w, id) } }) : fail(v);
247
254
  }
248
255
  if (sub === 'restore') {
249
- 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);
250
257
  if (!v) return send(bad(404, 'no such template version'));
251
258
  const a = apply(w.root, id, v.settings, opts);
252
259
  if (!a.ok) return fail(a);
@@ -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);
@@ -289,7 +289,8 @@ function exportAnswer(ctx, res, w, url) {
289
289
  md = fs.readFileSync(real, 'utf8');
290
290
  } catch { return bad(404, 'no such document'); }
291
291
  const cyc = /(?:^|\/)reports\/([a-z][a-z0-9-]{0,39})\/\d{4}-\d{2}-\d{2}\.md$/.exec(rel);
292
- const out = RX.exportOf(md, fmt, { look: fmt === 'pptx' && cyc ? require('./report-pyramid.js').lookOf(abs, cyc[1]) : null });
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) } : {});
293
294
  if (!out.ok) return bad(500, out.error);
294
295
  return { status: 200, file: out };
295
296
  }
@@ -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);