create-pmos 0.494.0 → 0.495.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +38 -14
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/briefing-premium-adddoc-test.js +2 -1
- package/workspace/briefing-premium-board-test.js +5 -4
- package/workspace/briefing-premium-initiative-test.js +2 -1
- package/workspace/changelog.json +37 -0
- package/workspace/guide/briefing-premium.md +2 -1
- package/workspace/guide/initiatives-screen.md +1 -1
- package/workspace/guide/report-slides.md +6 -6
- package/workspace/guide/reports.md +1 -1
- package/workspace/guide/workflows.md +15 -3
- package/workspace/ini-workflow-test.js +2 -1
- package/workspace/lib/engine/workflows.js +19 -6
- package/workspace/lib/initiative-cli.js +15 -1
- package/workspace/lib/open-mission.js +14 -0
- package/workspace/lib/report-export.js +5 -3
- package/workspace/lib/report-pyramid.js +134 -33
- package/workspace/lib/report-templates.js +33 -3
- package/workspace/lib/routes-boards.js +35 -2
- package/workspace/lib/routes-brief-page.js +2 -1
- package/workspace/lib/routes-initiatives.js +1 -0
- package/workspace/lib/routes-report-design.js +1 -1
- package/workspace/lib/routes-report-flow.js +11 -4
- package/workspace/lib/routes-report-setups.js +3 -2
- package/workspace/lib/routes-workflows.js +6 -1
- package/workspace/lib/workflow-cli.js +2 -2
- package/workspace/lib/workflows-store.js +15 -1
- package/workspace/metric-drafts-ui-test.js +2 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/premium-board-actions-test.js +2 -1
- package/workspace/premium-vw-backlog-test.js +3 -2
- package/workspace/premium-vw-item-test.js +3 -2
- package/workspace/premium-vw-jira-test.js +3 -2
- package/workspace/program-panel-test.js +2 -1
- package/workspace/public/pm/cozy-home.css +1 -0
- package/workspace/public/pm/home-app.js +3 -1
- package/workspace/public/pm/home-chat.js +49 -8
- package/workspace/public/pm/ini-view.js +2 -2
- package/workspace/public/pm/premium-shell.js +57 -6
- package/workspace/public/pm/premium.css +1 -0
- package/workspace/public/pm/wf-page.css +8 -0
- package/workspace/public/pm/wf-page.js +33 -5
- package/workspace/skills/report-template.skill +4 -2
- package/workspace/split-merge-ui-test.js +3 -2
- package/workspace/stage-card-test.js +2 -1
- package/workspace/task-press-test.js +2 -1
- 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)
|
|
5
|
-
* deckOf(markdown, look?)
|
|
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,
|
|
12
|
-
* answer the governing thought, up to three supports (one per initiative, off plan
|
|
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
|
|
28
|
-
*
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
|
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:
|
|
236
|
-
sub: [d.slot ? short(d.slot) + ' ' + d.slot.slice(0, 4) : '', d.window ? `window ${short(d.window.from)} to ${short(d.window.to)}${
|
|
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 =
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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(
|
|
267
|
-
rows:
|
|
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
|
|
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]) =>
|
|
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
|
-
|
|
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])
|
|
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
|
-
|
|
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 };
|
|
@@ -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')
|
|
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) });
|
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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);
|
|
@@ -105,8 +105,8 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
|
|
|
105
105
|
|
|
106
106
|
if (verb === 'list') {
|
|
107
107
|
const all = WF.list(WS.library(stateDir));
|
|
108
|
-
if (json) { say(JSON.stringify({ ok: true, workflows: all.map((w) => ({ id: w.id, name: w.name, stages: w.stages.length, archived: !!w.archived })) })); return 0; }
|
|
109
|
-
for (const w of all) say(`${w.id} · ${w.name} · ${w.stages.length} stage${w.stages.length === 1 ? '' : 's'}${w.archived ? ' · archived' : ''}`);
|
|
108
|
+
if (json) { say(JSON.stringify({ ok: true, workflows: all.map((w) => ({ id: w.id, name: w.name, stages: w.stages.length, archived: !!w.archived, default: !!w.default })) })); return 0; }
|
|
109
|
+
for (const w of all) say(`${w.id} · ${w.name} · ${w.stages.length} stage${w.stages.length === 1 ? '' : 's'}${w.archived ? ' · archived' : ''}${w.default ? ' · default for new initiatives' : ''}`);
|
|
110
110
|
return 0;
|
|
111
111
|
}
|
|
112
112
|
if (verb === 'show') {
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* copy(stateDir, id, name, actor) → { ok, workflow }
|
|
7
7
|
* adopt(stateDir, wf, actor, fromWs) → { ok, workflow } another workspace's workflow written into this library
|
|
8
8
|
* archive(stateDir, id, archived, actor, { inUse }) → { ok }
|
|
9
|
+
* setDefault(stateDir, id, actor) → { ok, id } the workflow a new initiative starts on (one wf-default line)
|
|
9
10
|
* saveDraft(stateDir, { draft, note }, actor) → { ok, draft } (the chat's draft; null clears it)
|
|
10
11
|
* readDraft(stateDir) → { draft, note, at } | null
|
|
11
12
|
* judge(stateDir, { board, id, stage, runs, why, cite, undo }, actor) → { ok } one stage-judge line in boards.jsonl
|
|
@@ -89,9 +90,22 @@ function archive(stateDir, id, archived, actor, { inUse = [] } = {}) {
|
|
|
89
90
|
if (!lib.byId.has(id)) return { ok: false, status: 404, error: 'That workflow could not be found. It may have been removed.' };
|
|
90
91
|
if (archived && inUse.length) return { ok: false, status: 409, error: `${inUse.length} ${inUse.length === 1 ? 'initiative runs' : 'initiatives run'} this workflow. Move ${inUse.length === 1 ? 'it' : 'them'} to another workflow first.`, inUse };
|
|
91
92
|
append(fileOf(stateDir), actor, { kind: 'wf-archive', id, archived: !!archived });
|
|
93
|
+
// Archiving the default puts Product feature back as the default, so a later Restore does not bring the old default back silently
|
|
94
|
+
if (archived && lib.defaultId === id) append(fileOf(stateDir), actor, { kind: 'wf-default', id: W.PRODUCT_FEATURE_ID });
|
|
92
95
|
return { ok: true };
|
|
93
96
|
}
|
|
94
97
|
|
|
98
|
+
/** The default workflow for new initiatives in this workspace (batch 24 item 3, canvas DfA). Product feature can be the default. */
|
|
99
|
+
function setDefault(stateDir, id, actor) {
|
|
100
|
+
if (!stateDir) return { ok: false, status: 409, error: NO_STATE };
|
|
101
|
+
const lib = library(stateDir);
|
|
102
|
+
const wf = typeof id === 'string' && id ? lib.byId.get(id) : null;
|
|
103
|
+
if (!wf) return { ok: false, status: 404, error: 'That workflow could not be found. It may have been removed.' };
|
|
104
|
+
if (wf.archived) return { ok: false, status: 409, error: `${wf.name} is archived. Restore it first.` };
|
|
105
|
+
append(fileOf(stateDir), actor, { kind: 'wf-default', id });
|
|
106
|
+
return { ok: true, id };
|
|
107
|
+
}
|
|
108
|
+
|
|
95
109
|
/** The chat's draft. It is checked like a save (so the Viewer never shows a draft Create would refuse); null clears it. */
|
|
96
110
|
function saveDraft(stateDir, { draft, note } = {}, actor) {
|
|
97
111
|
if (!stateDir) return { ok: false, status: 409, error: NO_STATE };
|
|
@@ -116,4 +130,4 @@ function judge(stateDir, { board, id, stage, runs, why, cite, undo } = {}, actor
|
|
|
116
130
|
return { ok: true };
|
|
117
131
|
}
|
|
118
132
|
|
|
119
|
-
module.exports = { FILE, library, save, copy, adopt, archive, saveDraft, readDraft, judge, fileOf };
|
|
133
|
+
module.exports = { FILE, library, save, copy, adopt, archive, setDefault, saveDraft, readDraft, judge, fileOf };
|