create-pmos 0.516.0 → 0.518.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 +33 -65
- 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/changelog.json +32 -0
- package/workspace/ev-everywhere-test.js +3 -3
- package/workspace/guide/board.md +7 -63
- package/workspace/guide/breakdown.md +8 -108
- package/workspace/guide/briefing-premium.md +5 -526
- package/workspace/guide/console-calendar.md +7 -206
- package/workspace/guide/data-freshness.md +26 -4
- package/workspace/guide/docs.md +10 -125
- package/workspace/guide/home.md +8 -5
- package/workspace/guide/index.md +78 -79
- package/workspace/guide/initiatives-and-next-steps.md +6 -19
- package/workspace/guide/layout.md +23 -230
- package/workspace/guide/live-notes.md +5 -7
- package/workspace/guide/my-work.md +8 -172
- package/workspace/guide/notes.md +14 -21
- package/workspace/guide/people.md +6 -39
- package/workspace/guide/report-design.md +2 -2
- package/workspace/guide/report-structure.md +1 -1
- package/workspace/guide/reports-set-up.md +9 -10
- package/workspace/guide/reports.md +17 -1
- package/workspace/guide/settings.md +16 -69
- package/workspace/guide/today.md +12 -188
- package/workspace/lib/refresh-run.js +39 -2
- package/workspace/lib/report-checks.js +3 -0
- package/workspace/lib/report-cli.js +112 -3
- package/workspace/lib/report-engine.js +779 -0
- package/workspace/lib/report-export.js +36 -1
- package/workspace/lib/report-pyramid.js +20 -0
- package/workspace/lib/routes-ready-reports.js +3 -1
- package/workspace/lib/routes-report-design.js +7 -2
- package/workspace/lib/routes-report-setups.js +17 -2
- package/workspace/lib/routes-sources.js +6 -2
- package/workspace/lib/source-heal.js +140 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/cozy-evmodel.js +18 -1
- package/workspace/public/pm/cozy-evui.js +1 -0
- package/workspace/public/pm/cozy-nav.js +9 -6
- package/workspace/public/pm/cozy-settings.js +15 -9
- package/workspace/public/pm/premium-vw-guide.css +36 -0
- package/workspace/public/pm/premium-vw-guide.js +137 -0
- package/workspace/public/pm/premium-vw-open.js +4 -1
- package/workspace/public/pm/premium-vw-page.js +2 -1
- package/workspace/run-tests.js +6 -0
- package/workspace/skills/report-biweekly.skill +7 -0
- package/workspace/skills/report-daily.skill +7 -0
- package/workspace/skills/report-monthly.skill +7 -0
- package/workspace/skills/report-special.skill +2 -0
- package/workspace/skills/report-template.skill +8 -1
- package/workspace/skills/report-weekly.skill +7 -0
- package/workspace/skills/report-write.skill +44 -0
- package/workspace/test-lists/report-engine.json +5 -0
- package/workspace/guide/ask-the-agent.md +0 -46
- package/workspace/guide/briefing-cards.md +0 -52
- package/workspace/guide/briefing.md +0 -159
- package/workspace/guide/calendar-popup.md +0 -46
- package/workspace/guide/command-palette.md +0 -77
- package/workspace/guide/console3-sessions.md +0 -147
- package/workspace/guide/document-sheet.md +0 -89
- package/workspace/guide/initiative-boards.md +0 -123
- package/workspace/guide/initiatives-screen.md +0 -179
- package/workspace/guide/initiatives-timeline.md +0 -55
- package/workspace/guide/phone-companion.md +0 -89
- package/workspace/guide/reports-dashboards.md +0 -70
- package/workspace/guide/reports-page.md +0 -87
- package/workspace/guide/timeline.md +0 -70
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* report-engine.js — THE REPORT ENGINE (design E, agreed by Wawan 2026-11-03 "Build."; canvas In development EgN1–EgN4, brief
|
|
3
|
+
* /mnt/project-files/intelligence-grill/brief-E-R-build.md). One path for every report, ad hoc from a chat line or recurring from a definition,
|
|
4
|
+
* any domain:
|
|
5
|
+
*
|
|
6
|
+
* brief → read → find → answer → render
|
|
7
|
+
*
|
|
8
|
+
* THERE IS NO FRAME. Nothing in this file holds a section list. The writer (skills/report-write.skill, an agent) chooses the outline of each
|
|
9
|
+
* report from its readers and their question; a recurring report keeps the outline its readers agreed (the last issued issue's sections) and the
|
|
10
|
+
* writer still adds or drops a section when the findings call for it. What this file fixes is only the discipline:
|
|
11
|
+
* - the answer comes first and carries a reading;
|
|
12
|
+
* - every figure in the writer's words is a reading token {{r:<id>}}, so it is sourced and dated by construction (a bare number is refused);
|
|
13
|
+
* - the ask names who and by, or lays out the options;
|
|
14
|
+
* - the gaps are named, never silent;
|
|
15
|
+
* - an addition beyond the ask (why, so what, now what, since last issue, what would change it, what we don't know, what else is here, the next
|
|
16
|
+
* question) is drawn only when it cites a reading.
|
|
17
|
+
*
|
|
18
|
+
* THE FILES, next to the draft, <state>/reports/<id>/<slot>.* (each written atomically; the two rule ledgers are append-only):
|
|
19
|
+
* <slot>.brief.json step 1, briefOf: readers, the question(s), the window, the sources by name, the last issue, the agreed outline, the
|
|
20
|
+
* carried asks, the rules (voice, parts turned off). Derived, never asked. No section list.
|
|
21
|
+
* <slot>.readings.json step 2, readingsOf: { readings: [{ id, value, unit, at, source, how, label, … }], gaps: [{ source, reason, affects }] }
|
|
22
|
+
* <slot>.findings.json step 3, findingsOf: { findings: [{ id, kind, text, readings, weight, question }], none }. none → no draft.
|
|
23
|
+
* <slot>.report.json step 4, written by the report-write skill through `pmos report write` (checkReport refuses one with no answer or a bare figure)
|
|
24
|
+
* <slot>.checks.json step 6, checkReport: the six substance checks, then the form checks of lib/report-checks.js
|
|
25
|
+
* <slot>.md step 5, renderMarkdown: the document the Library, the reader's Document tab and the exports read
|
|
26
|
+
* the deck step 5, deckOf: drawn on demand from report.json in the report's look (lib/report-pyramid.js lookOf = the saved design
|
|
27
|
+
* D<n> as a theme). The reader, the PDF and the PPTX are the same slides.
|
|
28
|
+
* rules.jsonl { ev: 'add'|'remove', n, kind, text, part, from, at, by } — what the readers taught the writer (step 8)
|
|
29
|
+
* asks.jsonl { ev: 'closed', text, at, by } — a carried ask is carried into every next brief until the PM closes it with ✓
|
|
30
|
+
*
|
|
31
|
+
* paths(root, id, slot) → the absolute and repository-relative paths above
|
|
32
|
+
* briefOf(root, id, { slot, ask, readers, sources, now }) → { ok, brief } | { ok: false, status, error }
|
|
33
|
+
* readingsOf(root, brief, { add, gaps, keep, now }) → { ok, readings, gaps } | refusal (add = readings the writer filed)
|
|
34
|
+
* findingsOf(brief, readings, { prior, add }) → { findings, none }
|
|
35
|
+
* resolve(text, readings) → text with every {{r:…}} written out; unresolved ids come back in .missing
|
|
36
|
+
* checkReport(report, { brief, readings, findings, markdown }) → [{ id, pass, reason, hard }]
|
|
37
|
+
* renderMarkdown(report, { brief, readings }) → the document
|
|
38
|
+
* deckOf(report, { brief, readings, look }) → { slides, look, name } (lib/report-pyramid.js deckHtml draws it)
|
|
39
|
+
* make(root, id, opts) → steps 1–3 written: { ok, brief, readings, findings, paths }
|
|
40
|
+
* write(root, id, slot, report, { actor, now }) → steps 4–6 written: { ok, path, checks } | refusal (422 with the failed checks)
|
|
41
|
+
* engineReportOf(absMd) → { report, brief, readings } for a draft the engine wrote, else null
|
|
42
|
+
* addRule / removeRule / rulesOf, closeAsk
|
|
43
|
+
*
|
|
44
|
+
* WHO WRITES. No model runs here. A brief, its readings from local files and its findings are derived by the script; readings from a connector
|
|
45
|
+
* (Jira, BigQuery/GA4, …) or a document are read by the writer in the interactive agent runtime and filed with `pmos report read --add`, each with
|
|
46
|
+
* its source, date and how it was read. A source the brief names that nothing read is a gap. The PM issues (lib/report-cli.js, unchanged).
|
|
47
|
+
*/
|
|
48
|
+
const fs = require('fs');
|
|
49
|
+
const path = require('path');
|
|
50
|
+
const crypto = require('crypto');
|
|
51
|
+
|
|
52
|
+
const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
|
|
53
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
54
|
+
const READING_ID_RE = /^[a-z0-9][a-z0-9._-]{0,80}$/;
|
|
55
|
+
const SOURCE_ID_RE = /^[a-z0-9][a-z0-9._-]{0,40}$/;
|
|
56
|
+
const TOKEN_RE = /\{\{r:([a-z0-9][a-z0-9._-]{0,80})(?:\|(of|n|value|label))?\}\}/g;
|
|
57
|
+
const DAY = 86400000;
|
|
58
|
+
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
59
|
+
const CYCLE_DAYS = { daily: 1, weekly: 7, biweekly: 14, 'bi-weekly': 14, monthly: 30, quarterly: 91 };
|
|
60
|
+
const LIMITS = Object.freeze({ readings: 2000, findings: 60, sections: 30, blocks: 40, rows: 60, cols: 12, text: 2000, title: 240, rules: 60 });
|
|
61
|
+
/** The parts beyond the ask (frame EgN3), in the order the document and the deck draw them. */
|
|
62
|
+
const BEYOND = Object.freeze([
|
|
63
|
+
['why', 'Why'], ['so_what', 'So what'], ['now_what', 'Now what'], ['since', 'Since the last issue'],
|
|
64
|
+
['would_change', 'What would change it'], ['unknown', 'What we don’t know'], ['else', 'What else is here'], ['next_question', 'The next question'],
|
|
65
|
+
]);
|
|
66
|
+
const BEYOND_KEYS = BEYOND.map(([k]) => k);
|
|
67
|
+
/** The default voice of every report, from Wawan's own design turns of 21 October 2026 (frame EgN3). A brief may narrow it, never widen it. */
|
|
68
|
+
const VOICE = Object.freeze([
|
|
69
|
+
'Neutral and straightforward sentences.',
|
|
70
|
+
'The number in the sentence, its source under it.',
|
|
71
|
+
'Coherent, succinct, synthesized: one thought per slide, one claim per paragraph.',
|
|
72
|
+
'No audience confusion: a leadership report does not carry the engineering appendix; it links to it.',
|
|
73
|
+
'Dates in full.',
|
|
74
|
+
'The mood is never happier than the data: 0% is written as 0%, with its why.',
|
|
75
|
+
]);
|
|
76
|
+
const RULE_KINDS = Object.freeze(['voice', 'wording', 'off', 'keep']);
|
|
77
|
+
const BLOCK_KINDS = Object.freeze(['p', 'list', 'table', 'bars', 'options', 'quote']);
|
|
78
|
+
|
|
79
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
80
|
+
const isStr = (v) => typeof v === 'string';
|
|
81
|
+
const str = (v) => (isStr(v) ? v : v == null ? '' : String(v));
|
|
82
|
+
const refuse = (status, error, extra = {}) => ({ ok: false, status, error, ...extra });
|
|
83
|
+
const isDate = (s) => isStr(s) && DATE_RE.test(s) && new Date(s + 'T00:00:00Z').toISOString().slice(0, 10) === s;
|
|
84
|
+
const dayOf = (t) => new Date(t).toISOString().slice(0, 10);
|
|
85
|
+
const addDays = (ymd, n) => dayOf(Date.parse(ymd + 'T00:00:00Z') + n * DAY);
|
|
86
|
+
/** "21 October 2026" from YYYY-MM-DD or an ISO time (the dates-in-full rule). */
|
|
87
|
+
function full(v) {
|
|
88
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(str(v));
|
|
89
|
+
return m ? `${Number(m[3])} ${MONTHS[Number(m[2]) - 1]} ${m[1]}` : str(v);
|
|
90
|
+
}
|
|
91
|
+
const slugOf = (s) => str(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/, '');
|
|
92
|
+
const clip = (s, n) => { const t = str(s).replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1).replace(/\s+\S*$/, '') + '…' : t; };
|
|
93
|
+
const sha = (s) => crypto.createHash('sha1').update(s).digest('hex').slice(0, 12);
|
|
94
|
+
|
|
95
|
+
function readJson(file) { try { const o = JSON.parse(fs.readFileSync(file, 'utf8')); return isObj(o) ? o : null; } catch { return null; } }
|
|
96
|
+
function readJsonl(file) {
|
|
97
|
+
let text; try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
|
|
98
|
+
const out = [];
|
|
99
|
+
for (const raw of text.split('\n')) { if (!raw.trim()) continue; try { const o = JSON.parse(raw); if (isObj(o)) out.push(o); } catch { /* skipped */ } }
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
function writeAtomic(file, data) {
|
|
103
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
104
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
105
|
+
fs.writeFileSync(tmp, data, { flag: 'wx' });
|
|
106
|
+
fs.renameSync(tmp, file);
|
|
107
|
+
}
|
|
108
|
+
const writeJson = (file, o) => writeAtomic(file, JSON.stringify(o, null, 2) + '\n');
|
|
109
|
+
function appendLine(file, o) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.appendFileSync(file, JSON.stringify(o) + '\n'); }
|
|
110
|
+
|
|
111
|
+
// lazy: report-draft.js loads the kit engine, which a pure caller (the renderer in a test) never needs
|
|
112
|
+
const RD = () => require('./report-draft.js');
|
|
113
|
+
const issuesLib = () => require('./issues.js');
|
|
114
|
+
|
|
115
|
+
function stateRel(root) { try { return RD().stateRel(root); } catch { return fs.existsSync(path.join(path.resolve(String(root)), 'pmos', 'state')) ? 'pmos/state' : 'planning'; } }
|
|
116
|
+
|
|
117
|
+
// ── paths ───────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
118
|
+
function paths(root, id, slot) {
|
|
119
|
+
if (!ID_RE.test(str(id))) throw new Error('the report id is lower case letters, digits and dashes, at most 40, starting with a letter');
|
|
120
|
+
if (slot != null && !isDate(slot)) throw new Error('the slot is a date, YYYY-MM-DD');
|
|
121
|
+
const abs = path.resolve(String(root));
|
|
122
|
+
const relDir = `${stateRel(abs)}/reports/${id}`;
|
|
123
|
+
const dir = path.join(abs, relDir);
|
|
124
|
+
const at = (ext) => (slot ? { abs: path.join(dir, `${slot}${ext}`), rel: `${relDir}/${slot}${ext}` } : null);
|
|
125
|
+
return { root: abs, dir, relDir, md: at('.md'), brief: at('.brief.json'), readings: at('.readings.json'), findings: at('.findings.json'),
|
|
126
|
+
report: at('.report.json'), checks: at('.checks.json'), rules: path.join(dir, 'rules.jsonl'), asks: path.join(dir, 'asks.jsonl') };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── the rules the readers taught (step 8) and the carried asks ─────────────────────────────────────────────────────────
|
|
130
|
+
function rulesOf(root, id) {
|
|
131
|
+
const rows = readJsonl(paths(root, id).rules);
|
|
132
|
+
const live = new Map();
|
|
133
|
+
for (const r of rows) {
|
|
134
|
+
if (r.ev === 'add' && Number.isInteger(r.n) && RULE_KINDS.includes(r.kind)) live.set(r.n, { n: r.n, kind: r.kind, text: str(r.text), part: r.part || null, from: str(r.from) || null, at: r.at || null });
|
|
135
|
+
else if (r.ev === 'remove' && Number.isInteger(r.n)) live.delete(r.n);
|
|
136
|
+
}
|
|
137
|
+
return [...live.values()].sort((a, b) => a.n - b.n);
|
|
138
|
+
}
|
|
139
|
+
function addRule(root, id, { kind, text, part, from, by, now } = {}) {
|
|
140
|
+
if (!RULE_KINDS.includes(kind)) return refuse(400, `the kind is one of ${RULE_KINDS.join(', ')}`);
|
|
141
|
+
if (kind === 'off' || kind === 'keep') { if (!BEYOND_KEYS.includes(part)) return refuse(400, `a ${kind} rule names a part beyond the ask: ${BEYOND_KEYS.join(', ')}`); }
|
|
142
|
+
else if (!str(text).trim() || str(text).length > 300 || /[\r\n]/.test(str(text))) return refuse(400, 'the rule is one line of at most 300 characters');
|
|
143
|
+
const all = readJsonl(paths(root, id).rules);
|
|
144
|
+
if (rulesOf(root, id).length >= LIMITS.rules) return refuse(409, `a report keeps at most ${LIMITS.rules} rules; remove one first`);
|
|
145
|
+
const n = all.reduce((m, r) => Math.max(m, Number.isInteger(r.n) ? r.n : 0), 0) + 1;
|
|
146
|
+
const line = { ev: 'add', n, kind, text: str(text).trim() || null, part: part || null, from: str(from).slice(0, 120) || null, at: new Date(now || Date.now()).toISOString(), by: str(by) || null };
|
|
147
|
+
appendLine(paths(root, id).rules, line);
|
|
148
|
+
return { ok: true, rule: line };
|
|
149
|
+
}
|
|
150
|
+
function removeRule(root, id, n, { by, now } = {}) {
|
|
151
|
+
if (!rulesOf(root, id).some((r) => r.n === Number(n))) return refuse(404, `there is no rule ${n}`);
|
|
152
|
+
appendLine(paths(root, id).rules, { ev: 'remove', n: Number(n), at: new Date(now || Date.now()).toISOString(), by: str(by) || null });
|
|
153
|
+
return { ok: true };
|
|
154
|
+
}
|
|
155
|
+
function closedAsks(root, id) { return new Set(readJsonl(paths(root, id).asks).filter((r) => r.ev === 'closed').map((r) => str(r.text).trim().toLowerCase())); }
|
|
156
|
+
function closeAsk(root, id, text, { by, now } = {}) {
|
|
157
|
+
if (!str(text).trim()) return refuse(400, 'name the ask to close');
|
|
158
|
+
appendLine(paths(root, id).asks, { ev: 'closed', text: str(text).trim(), at: new Date(now || Date.now()).toISOString(), by: str(by) || null });
|
|
159
|
+
return { ok: true };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── step 1 · the brief ──────────────────────────────────────────────────────────────────────────────────────────────
|
|
163
|
+
/** A definition's "## Sources" bullets as named sources. A bullet that names the ledgers is the pmos source. */
|
|
164
|
+
function sourcesFromText(lines) {
|
|
165
|
+
const out = [];
|
|
166
|
+
for (const raw of lines) {
|
|
167
|
+
const t = str(raw).replace(/^[-*]\s+/, '').trim();
|
|
168
|
+
if (!t) continue;
|
|
169
|
+
let kind = 'document', id = slugOf(t.split(/[,(—:]/)[0]) || 'source';
|
|
170
|
+
const file = /(?:^|\s|`)((?:pmos\/|planning\/|docs\/)?[\w./-]+\.(?:md|json|jsonl|csv|html))\b/.exec(t);
|
|
171
|
+
if (file) { if (!out.some((s) => s.ref === file[1])) out.push({ id: slugOf(path.basename(file[1]).replace(/\.[a-z]+$/i, '')).slice(0, 40) || 'document', kind: 'document', name: clip(t, 200), ref: file[1] }); continue; }
|
|
172
|
+
if (/\bledgers?\b|\bpmos\b/i.test(t) && !/\bjira\b|\bga4\b|bigquery/i.test(t)) { kind = 'pmos'; id = 'pmos'; }
|
|
173
|
+
else if (/\bga4\b|bigquery|\bevents?\b/i.test(t)) { kind = 'connector'; id = 'ga4'; }
|
|
174
|
+
else if (/\bjira\b/i.test(t)) { kind = 'connector'; id = 'jira'; }
|
|
175
|
+
else if (/\bconfluence\b|\bslack\b|\bmail\b|\bcalendar\b|\bdrive\b|\bnew relic\b|\bservicenow\b/i.test(t)) kind = 'connector';
|
|
176
|
+
if (!out.some((s) => s.id === id)) out.push({ id, kind, name: clip(t, 200) });
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
function sectionLines(text, heading) {
|
|
181
|
+
const m = new RegExp(`\\n## ${heading}\\r?\\n([\\s\\S]*?)(?=\\n## |$)`).exec(str(text));
|
|
182
|
+
return m ? m[1].split(/\r?\n/).filter((l) => /^\s*[-*]\s+/.test(l)) : [];
|
|
183
|
+
}
|
|
184
|
+
/** --source kind:ref → a named source. kinds: pmos · pack (a readings file under the state dir) · connector:<name> · document:<path> */
|
|
185
|
+
function parseSource(spec) {
|
|
186
|
+
const m = /^(pmos|pack|connector|document):?([^=]*)(?:=(.+))?$/.exec(str(spec).trim());
|
|
187
|
+
if (!m) return null;
|
|
188
|
+
const name = m[3] ? clip(m[3], 200) : null;
|
|
189
|
+
if (m[1] === 'pmos') return { id: 'pmos', kind: 'pmos', name: name || 'PMOS ledgers' };
|
|
190
|
+
const ref = m[2].trim();
|
|
191
|
+
if (!ref) return null;
|
|
192
|
+
if (m[1] === 'connector') return { id: slugOf(ref).slice(0, 40) || 'connector', kind: 'connector', name: name || ref };
|
|
193
|
+
return { id: slugOf(path.basename(ref).replace(/\.[a-z]+$/i, '')).slice(0, 40) || m[1], kind: m[1], name: name || path.basename(ref), ref };
|
|
194
|
+
}
|
|
195
|
+
function latestPlan(root, id) {
|
|
196
|
+
const dir = path.join(paths(root, id).dir, 'structure', 'versions');
|
|
197
|
+
let names = []; try { names = fs.readdirSync(dir).filter((f) => /^S\d+\.json$/.test(f)); } catch { return null; }
|
|
198
|
+
names.sort((a, b) => Number(a.slice(1, -5)) - Number(b.slice(1, -5)));
|
|
199
|
+
return names.length ? { plan: readJson(path.join(dir, names[names.length - 1])), file: names[names.length - 1] } : null;
|
|
200
|
+
}
|
|
201
|
+
/** The issued slots of a report, newest last (lib/issues.js). */
|
|
202
|
+
function issuedSlots(root, id) {
|
|
203
|
+
try { return issuesLib().readIssues(root).issues.filter((i) => i.cadence === id).map((i) => i.slot).sort(); } catch { return []; }
|
|
204
|
+
}
|
|
205
|
+
/** The headings of a document the engine did not write: the agreed outline of an older issue. */
|
|
206
|
+
const headingsOf = (md) => str(md).replace(/^---[\s\S]*?\n---\n?/, '').split('\n').filter((l) => /^## /.test(l)).map((l) => l.slice(3).trim()).filter(Boolean);
|
|
207
|
+
|
|
208
|
+
function briefOf(root, id, { slot, ask, readers, sources, now = Date.now() } = {}) {
|
|
209
|
+
if (!ID_RE.test(str(id))) return refuse(400, 'the report id is lower case letters, digits and dashes, at most 40, starting with a letter');
|
|
210
|
+
const today = slot || dayOf(now);
|
|
211
|
+
if (!isDate(today)) return refuse(400, 'the slot is a date, YYYY-MM-DD');
|
|
212
|
+
const abs = path.resolve(String(root));
|
|
213
|
+
const P = paths(abs, id, today);
|
|
214
|
+
const from = [];
|
|
215
|
+
const brief = { v: 1, id, slot: today, title: null, readers: null, question: null, questions: [], decision: null, window: null, sources: [], recurring: false,
|
|
216
|
+
cycle_days: null, agreed_outline: [], earlier_outline: [], last_issue: null, carried_asks: [], rules: [], voice: VOICE.slice(), from };
|
|
217
|
+
|
|
218
|
+
// the definition: readers, the question (its purpose), the sources, the cadence
|
|
219
|
+
let def = null; let defText = '';
|
|
220
|
+
try { def = RD().readDefinition(abs, id); } catch { def = null; }
|
|
221
|
+
if (def && def.exists) {
|
|
222
|
+
try { defText = fs.readFileSync(path.join(abs, def.path), 'utf8'); } catch { defText = ''; }
|
|
223
|
+
from.push(def.path);
|
|
224
|
+
brief.recurring = true;
|
|
225
|
+
brief.title = def.name || null;
|
|
226
|
+
brief.readers = def.audience || null;
|
|
227
|
+
if (def.purpose) brief.question = def.purpose;
|
|
228
|
+
brief.sources.push(...sourcesFromText(sectionLines(defText, 'Sources')));
|
|
229
|
+
const cad = /\ncadence:\s*["']?([a-z-]+)/.exec(defText);
|
|
230
|
+
const sched = /\n## Schedule\r?\n\r?\n([^\n]+)/.exec(defText);
|
|
231
|
+
const word = (sched && /daily|weekly|biweekly|bi-weekly|monthly|quarterly/i.exec(sched[1])) || (cad && /daily|weekly|biweekly|monthly/.exec(cad[1]));
|
|
232
|
+
brief.cycle_days = word ? CYCLE_DAYS[word[0].toLowerCase()] || null : null;
|
|
233
|
+
}
|
|
234
|
+
// the saved structure plan: its purpose block is the brief's intake; its section plan is not read (the writer chooses the outline)
|
|
235
|
+
const pl = latestPlan(abs, id);
|
|
236
|
+
if (pl && pl.plan && isObj(pl.plan.purpose)) {
|
|
237
|
+
const pu = pl.plan.purpose;
|
|
238
|
+
from.push(`${P.relDir}/structure/versions/${pl.file}`);
|
|
239
|
+
if (!brief.readers && pu.readers) brief.readers = str(pu.readers);
|
|
240
|
+
if (pu.decision) brief.decision = str(pu.decision);
|
|
241
|
+
if (Array.isArray(pu.questions)) brief.questions.push(...pu.questions.map(str).filter(Boolean));
|
|
242
|
+
brief.recurring = brief.recurring || !!pu.cadence;
|
|
243
|
+
}
|
|
244
|
+
// the recurring report's setup (lib/report-setups.js): its name and readers
|
|
245
|
+
try {
|
|
246
|
+
const setup = require('./report-setups.js').stateOf(abs).setups.get(id);
|
|
247
|
+
if (setup) { from.push(`setup:${id}`); brief.title = brief.title || setup.name || null; brief.readers = brief.readers || setup.audience || null; brief.recurring = true; }
|
|
248
|
+
} catch { /* no setups ledger */ }
|
|
249
|
+
// the chat line: an ad hoc report's question, or a sharper question for this issue
|
|
250
|
+
if (str(ask).trim()) { from.push('chat'); brief.question = clip(ask, 400); }
|
|
251
|
+
if (str(readers).trim()) { from.push('readers'); brief.readers = clip(readers, 300); }
|
|
252
|
+
for (const s of (Array.isArray(sources) ? sources : [])) { const x = isObj(s) ? s : parseSource(s); if (x && !brief.sources.some((y) => y.id === x.id)) brief.sources.push(x); }
|
|
253
|
+
if (!brief.question && brief.decision) brief.question = brief.decision;
|
|
254
|
+
if (!brief.question && !brief.questions.length) {
|
|
255
|
+
return refuse(404, def && def.exists ? `the definition of ${id} names no purpose, so the report has no question. Give one: --ask "<the question>"` : `there is no report named ${id}. For an ad hoc report give its question: --ask "<the question>"`);
|
|
256
|
+
}
|
|
257
|
+
if (!brief.questions.length || brief.questions[0] !== brief.question) brief.questions = [brief.question, ...brief.questions.filter((q) => q !== brief.question)].filter(Boolean);
|
|
258
|
+
brief.readers = brief.readers || 'You';
|
|
259
|
+
brief.title = brief.title || clip(brief.question.replace(/\?$/, ''), 80);
|
|
260
|
+
// no source named: the PMOS ledgers are the one PMOS always reaches
|
|
261
|
+
if (!brief.sources.length) brief.sources.push({ id: 'pmos', kind: 'pmos', name: 'PMOS ledgers' });
|
|
262
|
+
|
|
263
|
+
// the last issue: the window opens there, its outline is the agreed one, its asks are carried
|
|
264
|
+
const issued = issuedSlots(abs, id).filter((s) => s < today);
|
|
265
|
+
const last = issued.length ? issued[issued.length - 1] : null;
|
|
266
|
+
brief.last_issue = last;
|
|
267
|
+
brief.window = { from: last ? addDays(last, 1) : addDays(today, -((brief.cycle_days || 7) - 1)), to: today };
|
|
268
|
+
if (last) {
|
|
269
|
+
const LP = paths(abs, id, last);
|
|
270
|
+
const prior = readJson(LP.report.abs);
|
|
271
|
+
if (prior && Array.isArray(prior.sections)) brief.agreed_outline = prior.sections.map((s) => str(s.title)).filter(Boolean);
|
|
272
|
+
// an issue the engine did not write shows the shape the readers saw, as an example only: it was the old fixed frame, not an agreed outline
|
|
273
|
+
else { try { brief.earlier_outline = headingsOf(fs.readFileSync(LP.md.abs, 'utf8')); } catch { brief.earlier_outline = []; } }
|
|
274
|
+
const closed = closedAsks(abs, id);
|
|
275
|
+
const asks = prior && isObj(prior.ask) && prior.ask.text ? [prior.ask] : [];
|
|
276
|
+
const carried = prior && Array.isArray(prior.carried) ? prior.carried : [];
|
|
277
|
+
for (const a of carried.concat(asks)) {
|
|
278
|
+
const t = str(a.text).trim();
|
|
279
|
+
if (t && !closed.has(t.toLowerCase()) && !brief.carried_asks.some((x) => x.text === t)) brief.carried_asks.push({ text: t, who: str(a.who) || null, by: isDate(a.by) ? a.by : null, since: last });
|
|
280
|
+
}
|
|
281
|
+
from.push(LP.md.rel);
|
|
282
|
+
}
|
|
283
|
+
brief.rules = rulesOf(abs, id);
|
|
284
|
+
for (const r of brief.rules) if (r.kind === 'voice' || r.kind === 'wording') brief.voice.push(r.text);
|
|
285
|
+
return { ok: true, brief };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── step 2 · the readings ───────────────────────────────────────────────────────────────────────────────────────────
|
|
289
|
+
/** One reading, checked. Every reading has id, value, at and source; a reading from outside PMOS also says how it was read. */
|
|
290
|
+
function checkReading(r, sourceIds) {
|
|
291
|
+
if (!isObj(r)) return 'a reading is an object';
|
|
292
|
+
if (!READING_ID_RE.test(str(r.id))) return `reading id ${JSON.stringify(r.id)} is lower case letters, digits, dot, dash or underscore`;
|
|
293
|
+
if (!(typeof r.value === 'number' && Number.isFinite(r.value)) && !(isStr(r.value) && r.value.trim() && r.value.length <= 80)) return `reading ${r.id}: value is a number or a short text`;
|
|
294
|
+
if (!isStr(r.at) || !/^\d{4}-\d{2}-\d{2}/.test(r.at)) return `reading ${r.id}: at is the date it was read or measured, YYYY-MM-DD`;
|
|
295
|
+
if (!SOURCE_ID_RE.test(str(r.source))) return `reading ${r.id}: source names one of the brief's sources`;
|
|
296
|
+
if (sourceIds && !sourceIds.has(r.source)) return `reading ${r.id}: source ${r.source} is not a source of this brief (${[...sourceIds].join(', ')}); add it with --source`;
|
|
297
|
+
if (r.source !== 'pmos' && !str(r.how).trim()) return `reading ${r.id}: how says how it was read (the query, the filter, the field)`;
|
|
298
|
+
for (const k of ['n', 'of', 'target']) if (r[k] != null && !(typeof r[k] === 'number' && Number.isFinite(r[k]))) return `reading ${r.id}: ${k} is a number`;
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
function cleanReading(r) {
|
|
302
|
+
const o = { id: r.id, value: r.value, unit: str(r.unit).slice(0, 12) || null, at: r.at.slice(0, 10), source: r.source, how: str(r.how).slice(0, 300) || null, label: str(r.label).slice(0, 160) || null };
|
|
303
|
+
for (const k of ['n', 'of', 'target']) if (typeof r[k] === 'number') o[k] = r[k];
|
|
304
|
+
if (r.better === 'lower' || r.better === 'higher') o.better = r.better;
|
|
305
|
+
if (r.flag) o.flag = clip(r.flag, 200);
|
|
306
|
+
if (r.filed) o.filed = true;
|
|
307
|
+
return o;
|
|
308
|
+
}
|
|
309
|
+
/** The PMOS ledgers as readings: what lib/report-draft.js folds (its readers stay; its frame does not). */
|
|
310
|
+
function ledgerReadings(root, brief, now) {
|
|
311
|
+
const RDm = RD();
|
|
312
|
+
const d = RDm.draftOf(root, { cadence: brief.id, slot: brief.slot, now });
|
|
313
|
+
if (!d || d.ok === false) return { readings: [], gaps: [{ source: 'pmos', reason: clip((d && d.error) || 'the ledgers could not be read', 200), affects: [] }] };
|
|
314
|
+
const at = brief.slot; const out = [];
|
|
315
|
+
for (const b of d.blocks || []) {
|
|
316
|
+
const base = `${b.id}`.toLowerCase().replace(/[^a-z0-9._-]/g, '-');
|
|
317
|
+
if (b.progress && b.progress.total) out.push({ id: `${base}.done`, value: b.progress.done, of: b.progress.total, unit: 'items', at, source: 'pmos', how: 'plan items done of total', label: `${b.title}: items done` });
|
|
318
|
+
if (b.health && b.health.level) out.push({ id: `${base}.health`, value: { off: 'off track', 'at-risk': 'at risk', unknown: 'unknown', on: 'on track' }[b.health.level] || b.health.level, at, source: 'pmos', how: 'lib/health.js', label: `${b.title}: health`, ...(b.health.level === 'off' || b.health.level === 'at-risk' ? { flag: (b.health.reasons || []).map((x) => x.text).join('; ') || b.health.level } : {}) });
|
|
319
|
+
if (b.measure && typeof b.measure.value === 'number') out.push({ id: `${base}.${String(b.measure.anchor || 'kr').toLowerCase()}`, value: b.measure.value, unit: b.measure.unit || null, ...(typeof b.measure.target === 'number' ? { target: b.measure.target } : {}), at: b.measure.as_of || at, source: 'pmos', how: `measurement ${b.measure.source}`, label: `${b.title}: ${b.measure.anchor}` });
|
|
320
|
+
const carried = (b.last_cycle && b.last_cycle.carried) || [];
|
|
321
|
+
const overdue = carried.filter((c) => c.due && c.due < brief.slot);
|
|
322
|
+
if (overdue.length) out.push({ id: `${base}.overdue`, value: overdue.length, unit: 'items', at, source: 'pmos', how: 'carried plan items past their due date', label: `${b.title}: items overdue`, flag: overdue.map((c) => `${c.title}, due ${full(c.due)}`).join('; ') });
|
|
323
|
+
const coming = ((b.coming_cycle && b.coming_cycle.items) || []).filter((c) => c.due && c.due >= brief.slot && c.due <= addDays(brief.slot, 14));
|
|
324
|
+
if (coming.length) out.push({ id: `${base}.due-soon`, value: coming.length, unit: 'items', at, source: 'pmos', how: 'plan items due within 14 days', label: `${b.title}: items due within 14 days`, flag: coming.map((c) => `${c.title}, due ${full(c.due)}`).join('; ') });
|
|
325
|
+
}
|
|
326
|
+
return { readings: out, gaps: [] };
|
|
327
|
+
}
|
|
328
|
+
/** A readings pack: a JSON or JSONL file under the state dir whose lines are readings (what a connector run filed). */
|
|
329
|
+
function packReadings(root, src, sourceIds) {
|
|
330
|
+
const abs = path.resolve(String(root));
|
|
331
|
+
const file = path.resolve(abs, str(src.ref));
|
|
332
|
+
if (!file.startsWith(abs + path.sep)) return { readings: [], gaps: [{ source: src.id, reason: 'the pack is outside the repository', affects: [] }] };
|
|
333
|
+
let text; try { text = fs.readFileSync(file, 'utf8'); } catch { return { readings: [], gaps: [{ source: src.id, reason: `${src.ref} could not be read`, affects: [] }] }; }
|
|
334
|
+
let rows = [];
|
|
335
|
+
try { const o = JSON.parse(text); rows = Array.isArray(o) ? o : isObj(o) && Array.isArray(o.readings) ? o.readings : [o]; } catch { rows = text.split('\n').filter((l) => l.trim()).map((l) => { try { return JSON.parse(l); } catch { return null; } }); }
|
|
336
|
+
const readings = []; let skipped = 0;
|
|
337
|
+
for (const r of rows) { const x = isObj(r) ? { source: src.id, how: `pack ${src.ref}`, ...r } : null; if (x && !checkReading(x, sourceIds)) readings.push(cleanReading(x)); else skipped++; }
|
|
338
|
+
return { readings, gaps: skipped ? [{ source: src.id, reason: `${skipped} line(s) of ${src.ref} are not readings (id, value, at)`, affects: [] }] : [] };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function readingsOf(root, brief, { add = [], gaps = [], keep = null, now = Date.now() } = {}) {
|
|
342
|
+
if (!isObj(brief)) return refuse(400, 'no brief');
|
|
343
|
+
const ids = new Set(brief.sources.map((s) => s.id));
|
|
344
|
+
const byId = new Map(); const gapList = [];
|
|
345
|
+
const put = (r) => byId.set(r.id, r);
|
|
346
|
+
// what the writer filed earlier for this slot stays, unless filed again
|
|
347
|
+
for (const r of (keep && Array.isArray(keep.readings) ? keep.readings : [])) if (r.filed && !checkReading(r, ids)) put(cleanReading(r));
|
|
348
|
+
for (const s of brief.sources) {
|
|
349
|
+
if (s.kind === 'pmos') { const x = ledgerReadings(root, brief, now); x.readings.forEach((r) => put(cleanReading(r))); gapList.push(...x.gaps); }
|
|
350
|
+
else if (s.kind === 'pack') { const x = packReadings(root, s, ids); x.readings.forEach(put); gapList.push(...x.gaps); }
|
|
351
|
+
}
|
|
352
|
+
for (const r of (Array.isArray(add) ? add : [])) {
|
|
353
|
+
const why = checkReading(r, ids);
|
|
354
|
+
if (why) return refuse(422, why);
|
|
355
|
+
put(cleanReading({ ...r, filed: true }));
|
|
356
|
+
}
|
|
357
|
+
if (byId.size > LIMITS.readings) return refuse(422, `a report holds at most ${LIMITS.readings} readings`);
|
|
358
|
+
const filedGaps = (keep && Array.isArray(keep.gaps) ? keep.gaps.filter((g) => g.filed) : []).concat((Array.isArray(gaps) ? gaps : []).map((g) => ({ ...g, filed: true })));
|
|
359
|
+
for (const g of filedGaps) {
|
|
360
|
+
if (!isObj(g) || !ids.has(str(g.source)) || !str(g.reason).trim()) return refuse(422, 'a gap is { source, reason, affects } and names a source of the brief');
|
|
361
|
+
gapList.push({ source: g.source, reason: clip(g.reason, 200), affects: Array.isArray(g.affects) ? g.affects.map(str).slice(0, 20) : [], filed: true });
|
|
362
|
+
}
|
|
363
|
+
const readings = [...byId.values()];
|
|
364
|
+
for (let i = gapList.length - 1; i >= 0; i--) if (gapList.findIndex((g) => g.source === gapList[i].source && g.reason === gapList[i].reason) < i) gapList.splice(i, 1);
|
|
365
|
+
// a source the brief names that nothing read is a gap, never silence
|
|
366
|
+
for (const s of brief.sources) {
|
|
367
|
+
if (!readings.some((r) => r.source === s.id) && !gapList.some((g) => g.source === s.id)) {
|
|
368
|
+
gapList.push({ source: s.id, reason: s.kind === 'connector' || s.kind === 'document' ? `not read in this run: the writer reads ${s.name} and files its readings with pmos report read --from <file>` : `${s.name} gave no reading`, affects: [] });
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return { ok: true, at: new Date(now).toISOString(), readings, gaps: gapList };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ── step 3 · the findings ───────────────────────────────────────────────────────────────────────────────────────────
|
|
375
|
+
const fmtNum = (v) => (typeof v === 'number' ? (Number.isInteger(v) ? v.toLocaleString('en-GB') : (Math.round(v * 10) / 10).toLocaleString('en-GB')) : str(v));
|
|
376
|
+
/** A unit the figure carries in a sentence: a symbol or a measure. A count's noun (attempts, items, sessions) is the writer's word, never repeated. */
|
|
377
|
+
const SHOWN_UNIT = /^(?:%|pp|x|[a-zA-Z$€£]{1,3}|seconds?|minutes?|hours?|days?|weeks?|months?|years?)$/;
|
|
378
|
+
function valueText(r, mode) {
|
|
379
|
+
if (!r) return '';
|
|
380
|
+
const u = r.unit && SHOWN_UNIT.test(r.unit) ? (r.unit === '%' ? '%' : ' ' + r.unit) : '';
|
|
381
|
+
if (mode === 'n') return typeof r.n === 'number' ? fmtNum(r.n) : fmtNum(r.value);
|
|
382
|
+
if (mode === 'label') return r.label || r.id;
|
|
383
|
+
if (mode === 'of') return typeof r.of === 'number' ? `${fmtNum(r.n != null ? r.n : r.value)} of ${fmtNum(r.of)}` : fmtNum(r.value) + u;
|
|
384
|
+
if (mode === 'value') return fmtNum(r.value);
|
|
385
|
+
return fmtNum(r.value) + (typeof r.value === 'number' ? u : '');
|
|
386
|
+
}
|
|
387
|
+
function findingsOf(brief, readings, { prior = null, add = [] } = {}) {
|
|
388
|
+
const R = Array.isArray(readings && readings.readings) ? readings.readings : [];
|
|
389
|
+
const byId = new Map(R.map((r) => [r.id, r]));
|
|
390
|
+
const out = [];
|
|
391
|
+
const push = (f) => { if (out.length < LIMITS.findings) out.push({ id: `f${out.length + 1}`, ...f }); };
|
|
392
|
+
const lab = (r) => r.label || r.id;
|
|
393
|
+
// vs the last issue: the same reading moved
|
|
394
|
+
const before = new Map((prior && Array.isArray(prior.readings) ? prior.readings : []).map((r) => [r.id, r]));
|
|
395
|
+
for (const r of R) {
|
|
396
|
+
const p = before.get(r.id);
|
|
397
|
+
if (p && typeof r.value === 'number' && typeof p.value === 'number' && r.value !== p.value) push({ kind: 'vs-last-issue', text: `${lab(r)} moved from ${valueText(p)} to ${valueText(r)} since ${full(p.at)}`, readings: [r.id], weight: 0.7 });
|
|
398
|
+
else if (p && isStr(r.value) && r.value !== p.value) push({ kind: 'vs-last-issue', text: `${lab(r)} is ${r.value}, it was ${p.value} on ${full(p.at)}`, readings: [r.id], weight: 0.7 });
|
|
399
|
+
}
|
|
400
|
+
// vs the target
|
|
401
|
+
for (const r of R) {
|
|
402
|
+
if (typeof r.value !== 'number' || typeof r.target !== 'number') continue;
|
|
403
|
+
const behind = r.better === 'lower' ? r.value > r.target : r.value < r.target;
|
|
404
|
+
if (behind) push({ kind: 'vs-target', text: `${lab(r)} is ${valueText(r)}, against a target of ${valueText({ ...r, value: r.target })}`, readings: [r.id], weight: 0.8 });
|
|
405
|
+
}
|
|
406
|
+
// vs plan, due and anomalies: a reading its reader flagged
|
|
407
|
+
for (const r of R) {
|
|
408
|
+
if (!r.flag) continue;
|
|
409
|
+
const kind = /\.health$/.test(r.id) ? 'vs-plan' : /\.(overdue|due-soon)$/.test(r.id) ? 'due' : 'anomaly';
|
|
410
|
+
push({ kind, text: `${lab(r)}: ${valueText(r)}. ${r.flag}`, readings: [r.id], weight: kind === 'vs-plan' ? 0.85 : kind === 'anomaly' ? 0.8 : 0.6 });
|
|
411
|
+
}
|
|
412
|
+
// the asks carried from the last issue
|
|
413
|
+
for (const a of (brief && brief.carried_asks) || []) push({ kind: 'carried', text: `Carried from ${full(a.since)}: ${a.text}${a.who ? ` (${a.who}${a.by ? `, by ${full(a.by)}` : ''})` : ''}`, readings: [], weight: 0.75 });
|
|
414
|
+
// what the writer found that answers the readers' question (it cites the readings it rests on)
|
|
415
|
+
for (const f of (Array.isArray(add) ? add : [])) {
|
|
416
|
+
if (!isObj(f) || !str(f.text).trim()) return refuse(422, 'a finding is { text, readings: [<reading id>…], kind?, question? }');
|
|
417
|
+
const refs = Array.isArray(f.readings) ? f.readings.map(str) : [];
|
|
418
|
+
if (!refs.length) return refuse(422, `the finding "${clip(f.text, 60)}" cites no reading`);
|
|
419
|
+
const miss = refs.filter((x) => !byId.has(x));
|
|
420
|
+
if (miss.length) return refuse(422, `the finding "${clip(f.text, 60)}" cites ${miss.join(', ')}, which is not a reading`);
|
|
421
|
+
push({ kind: ['vs-target', 'vs-plan', 'vs-last-issue', 'answers-question', 'anomaly', 'due'].includes(f.kind) ? f.kind : 'answers-question', text: clip(f.text, 400), readings: refs,
|
|
422
|
+
weight: typeof f.weight === 'number' && f.weight >= 0 && f.weight <= 1 ? f.weight : 0.9, ...(f.question ? { question: clip(f.question, 300) } : {}), filed: true });
|
|
423
|
+
}
|
|
424
|
+
out.sort((a, b) => b.weight - a.weight);
|
|
425
|
+
out.forEach((f, i) => { f.id = `f${i + 1}`; });
|
|
426
|
+
return { findings: out, none: out.length === 0 };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ── tokens ──────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
430
|
+
function resolve(text, readings) {
|
|
431
|
+
const byId = readings instanceof Map ? readings : new Map((Array.isArray(readings) ? readings : (readings && readings.readings) || []).map((r) => [r.id, r]));
|
|
432
|
+
const missing = []; const used = [];
|
|
433
|
+
const out = str(text).replace(TOKEN_RE, (all, id, mode) => { const r = byId.get(id); if (!r) { missing.push(id); return all; } used.push(id); return valueText(r, mode); });
|
|
434
|
+
const res = new String(out); // eslint-disable-line no-new-wrappers
|
|
435
|
+
return Object.assign(res, { missing, used });
|
|
436
|
+
}
|
|
437
|
+
const tokensIn = (text) => [...str(text).matchAll(TOKEN_RE)].map((m) => m[1]);
|
|
438
|
+
/** A figure in the writer's own words: a digit left once tokens, full dates, ISO dates, ticket keys, ids such as KR-A1, H2, Q4, D64, v5 and S1 are out. */
|
|
439
|
+
function bareFigures(text) {
|
|
440
|
+
const t = str(text).replace(TOKEN_RE, ' ')
|
|
441
|
+
.replace(new RegExp(`\\b\\d{1,2} (?:${MONTHS.join('|')}) \\d{4}\\b`, 'g'), ' ')
|
|
442
|
+
.replace(new RegExp(`\\b(?:${MONTHS.join('|')}) \\d{4}\\b`, 'g'), ' ')
|
|
443
|
+
.replace(/\b\d{4}-\d{2}-\d{2}\b/g, ' ')
|
|
444
|
+
.replace(/\b[A-Z][A-Z0-9]*-[A-Z]?\d+\b/g, ' ')
|
|
445
|
+
.replace(/\b[A-Za-z]+\d+[A-Za-z0-9]*\b/g, ' ')
|
|
446
|
+
.replace(/https?:\/\/\S+/g, ' ');
|
|
447
|
+
return t.match(/\d[\d,.]*/g) || [];
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ── the report model (step 4): its shape ────────────────────────────────────────────────────────────────────────────
|
|
451
|
+
/** Every text the writer wrote in a report, with where it sits. */
|
|
452
|
+
function textsOf(report) {
|
|
453
|
+
const out = [];
|
|
454
|
+
const add = (where, t) => { if (isStr(t) && t) out.push({ where, text: t }); };
|
|
455
|
+
add('title', report.title);
|
|
456
|
+
if (isObj(report.answer)) { add('answer', report.answer.text); (report.answer.supports || []).forEach((s, i) => add(`answer.supports[${i}]`, s)); }
|
|
457
|
+
(report.sections || []).forEach((s, i) => {
|
|
458
|
+
add(`sections[${i}].title`, s.title);
|
|
459
|
+
(s.blocks || []).forEach((b, j) => {
|
|
460
|
+
const w = `sections[${i}].blocks[${j}]`;
|
|
461
|
+
add(w, b.text); (b.items || []).forEach((x) => add(w, x));
|
|
462
|
+
(b.columns || []).forEach((x) => add(w, x));
|
|
463
|
+
(b.rows || []).forEach((row) => (Array.isArray(row) ? row : []).forEach((c) => add(w, str(c))));
|
|
464
|
+
(b.options || []).forEach((o) => { add(w, o && o.name); add(w, o && o.text); });
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
if (isObj(report.ask)) { add('ask', report.ask.text); (report.ask.options || []).forEach((o) => add('ask.options', str(o))); }
|
|
468
|
+
if (isObj(report.beyond)) for (const k of BEYOND_KEYS) { const v = report.beyond[k]; (Array.isArray(v) ? v : [v]).forEach((x) => add(`beyond.${k}`, x)); }
|
|
469
|
+
return out;
|
|
470
|
+
}
|
|
471
|
+
function shapeError(report) {
|
|
472
|
+
if (!isObj(report)) return 'the report is a JSON object';
|
|
473
|
+
if (!isStr(report.title) || !report.title.trim() || report.title.length > LIMITS.title || /[\r\n]/.test(report.title)) return `title is one sentence of at most ${LIMITS.title} characters`;
|
|
474
|
+
if (!isObj(report.answer) || !isStr(report.answer.text) || !report.answer.text.trim()) return 'answer.text is the governing thought, the answer the readers need';
|
|
475
|
+
if (report.answer.supports != null && (!Array.isArray(report.answer.supports) || report.answer.supports.length > 5 || report.answer.supports.some((s) => !isStr(s)))) return 'answer.supports is a list of at most 5 sentences';
|
|
476
|
+
if (!Array.isArray(report.sections) || report.sections.length > LIMITS.sections) return `sections is a list of at most ${LIMITS.sections} (any titles, any order: the writer chooses)`;
|
|
477
|
+
const seen = new Set();
|
|
478
|
+
for (const [i, s] of report.sections.entries()) {
|
|
479
|
+
if (!isObj(s) || !isStr(s.title) || !s.title.trim() || /[\r\n]/.test(s.title)) return `sections[${i}] has a one-line title`;
|
|
480
|
+
const id = str(s.id) || slugOf(s.title);
|
|
481
|
+
if (seen.has(id)) return `two sections share the id ${id}`;
|
|
482
|
+
seen.add(id);
|
|
483
|
+
if (!Array.isArray(s.blocks) || s.blocks.length > LIMITS.blocks) return `sections[${i}].blocks is a list of at most ${LIMITS.blocks}`;
|
|
484
|
+
for (const [j, b] of s.blocks.entries()) {
|
|
485
|
+
if (!isObj(b) || !BLOCK_KINDS.includes(b.kind)) return `sections[${i}].blocks[${j}].kind is one of ${BLOCK_KINDS.join(', ')}`;
|
|
486
|
+
if ((b.kind === 'p' || b.kind === 'quote') && (!isStr(b.text) || b.text.length > LIMITS.text)) return `sections[${i}].blocks[${j}] is { kind: '${b.kind}', text }`;
|
|
487
|
+
if (b.kind === 'list' && (!Array.isArray(b.items) || !b.items.every(isStr))) return `sections[${i}].blocks[${j}] is { kind: 'list', items: [text…] }`;
|
|
488
|
+
if (b.kind === 'table' && (!Array.isArray(b.columns) || !b.columns.length || b.columns.length > LIMITS.cols || !Array.isArray(b.rows) || b.rows.length > LIMITS.rows || b.rows.some((r) => !Array.isArray(r) || r.length !== b.columns.length))) return `sections[${i}].blocks[${j}] is { kind: 'table', columns: [...], rows: [[one cell per column]…] } (at most ${LIMITS.rows} rows)`;
|
|
489
|
+
if (b.kind === 'bars' && (!Array.isArray(b.rows) || !b.rows.length || b.rows.length > 20 || b.rows.some((r) => !Array.isArray(r) || r.length !== 2 || !isStr(r[0]) || tokensIn(r[1]).length !== 1))) return `sections[${i}].blocks[${j}] is { kind: 'bars', rows: [[label, "{{r:<id>}}"]…] }: each bar is one reading`;
|
|
490
|
+
if (b.kind === 'options' && (!Array.isArray(b.options) || b.options.length < 2 || b.options.length > 4 || b.options.some((o) => !isObj(o) || !isStr(o.name)))) return `sections[${i}].blocks[${j}] is { kind: 'options', options: [{ name, text }…] } with 2 to 4 options`;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (report.ask != null) {
|
|
494
|
+
if (!isObj(report.ask) || !isStr(report.ask.text) || !report.ask.text.trim()) return 'ask is null or { text, who, by, options }';
|
|
495
|
+
if (report.ask.by != null && !isDate(report.ask.by)) return 'ask.by is a date, YYYY-MM-DD';
|
|
496
|
+
if (report.ask.options != null && (!Array.isArray(report.ask.options) || report.ask.options.length > 3 || !report.ask.options.every(isStr))) return 'ask.options is a list of at most 3';
|
|
497
|
+
}
|
|
498
|
+
if (report.beyond != null) {
|
|
499
|
+
if (!isObj(report.beyond)) return 'beyond is an object of the parts beyond the ask';
|
|
500
|
+
for (const k of Object.keys(report.beyond)) if (!BEYOND_KEYS.includes(k)) return `beyond.${k} is not a part; the parts are ${BEYOND_KEYS.join(', ')}`;
|
|
501
|
+
}
|
|
502
|
+
if (report.questions != null && (!Array.isArray(report.questions) || report.questions.some((q) => !isObj(q) || !isStr(q.question)))) return 'questions is a list of { question, answered_in, gap }';
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ── step 6 · the checks: substance first ────────────────────────────────────────────────────────────────────────────
|
|
507
|
+
function checkReport(report, { brief = null, readings = null, markdown = null } = {}) {
|
|
508
|
+
const bad = shapeError(report);
|
|
509
|
+
if (bad) return [{ id: 'shape', pass: false, hard: true, reason: bad }];
|
|
510
|
+
const R = Array.isArray(readings && readings.readings) ? readings.readings : [];
|
|
511
|
+
const byId = new Map(R.map((r) => [r.id, r]));
|
|
512
|
+
const gaps = Array.isArray(readings && readings.gaps) ? readings.gaps : [];
|
|
513
|
+
const out = [];
|
|
514
|
+
// 1 · there is an answer: a sentence with a reading in it
|
|
515
|
+
const answerRefs = tokensIn(report.title).concat(tokensIn(report.answer.text)).filter((id) => byId.has(id));
|
|
516
|
+
out.push(answerRefs.length
|
|
517
|
+
? { id: 'answer', pass: true, hard: true, reason: `the title and the answer carry ${answerRefs.length} reading(s)` }
|
|
518
|
+
: { id: 'answer', pass: false, hard: true, reason: 'neither the title nor the answer carries a reading, so there is no answer to show. Put the number that answers the question in the title: {{r:<id>}}' });
|
|
519
|
+
// 2 · each question of the brief is answered or named as a gap
|
|
520
|
+
const qs = brief && Array.isArray(brief.questions) ? brief.questions : [];
|
|
521
|
+
const mapped = new Map((report.questions || []).map((q) => [str(q.question).trim().toLowerCase(), q]));
|
|
522
|
+
const sectionIds = new Set((report.sections || []).map((s) => str(s.id) || slugOf(s.title)));
|
|
523
|
+
const open = qs.slice(1).filter((q) => { const m = mapped.get(q.trim().toLowerCase()); return !m || !((m.answered_in === 'answer' || sectionIds.has(str(m.answered_in))) || str(m.gap).trim()); });
|
|
524
|
+
out.push(open.length ? { id: 'questions', pass: false, hard: false, reason: `${open.length} question(s) of the brief are neither answered nor named as a gap: ${open.slice(0, 2).map((q) => `"${clip(q, 60)}"`).join(', ')}` }
|
|
525
|
+
: { id: 'questions', pass: true, hard: false, reason: qs.length > 1 ? `the ${qs.length} questions of the brief are answered or named as gaps` : 'the answer answers the brief\'s question' });
|
|
526
|
+
// 3 · every figure has a reading; every reading has a source and a date
|
|
527
|
+
const texts = textsOf(report);
|
|
528
|
+
const missing = new Set(); const bare = [];
|
|
529
|
+
for (const t of texts) { for (const id of tokensIn(t.text)) if (!byId.has(id)) missing.add(id); const f = bareFigures(t.text); if (f.length) bare.push({ where: t.where, figs: f }); }
|
|
530
|
+
const undated = R.filter((r) => !r.source || !r.at);
|
|
531
|
+
out.push(missing.size || bare.length || undated.length
|
|
532
|
+
? { id: 'readings', pass: false, hard: true, reason: [missing.size ? `${missing.size} token(s) name no reading: ${[...missing].slice(0, 4).join(', ')}` : '', bare.length ? `${bare.length} place(s) carry a figure that is not a reading (${bare.slice(0, 3).map((b) => `${b.where}: ${b.figs.slice(0, 2).join(', ')}`).join('; ')}); file it as a reading and write {{r:<id>}}` : '', undated.length ? `${undated.length} reading(s) have no source or date` : ''].filter(Boolean).join('. ') }
|
|
533
|
+
: { id: 'readings', pass: true, hard: true, reason: 'every figure is a reading with its source and date' });
|
|
534
|
+
// 4 · since the last issue (from issue 2)
|
|
535
|
+
const since = isObj(report.beyond) ? report.beyond.since : null;
|
|
536
|
+
out.push(!brief || !brief.last_issue ? { id: 'since', pass: true, hard: false, reason: 'the first issue: nothing to compare with yet' }
|
|
537
|
+
: str(Array.isArray(since) ? since.join(' ') : since).trim() ? { id: 'since', pass: true, hard: false, reason: `says what moved since ${full(brief.last_issue)}` }
|
|
538
|
+
: { id: 'since', pass: false, hard: false, reason: `the readers got an issue on ${full(brief.last_issue)}; say what moved since (beyond.since)` });
|
|
539
|
+
// 5 · the ask has who and by, or the options
|
|
540
|
+
const a = report.ask;
|
|
541
|
+
out.push(!a ? { id: 'ask', pass: true, hard: false, reason: 'no ask this time' }
|
|
542
|
+
: (a.who && a.by) || (Array.isArray(a.options) && a.options.length >= 2) ? { id: 'ask', pass: true, hard: false, reason: a.who && a.by ? `${a.who}, by ${full(a.by)}` : `${a.options.length} options laid out` }
|
|
543
|
+
: { id: 'ask', pass: false, hard: false, reason: 'the ask names neither who and by when, nor the options' });
|
|
544
|
+
// 6 · each addition beyond the ask has a reading (what we don't know rests on the gaps)
|
|
545
|
+
const weak = [];
|
|
546
|
+
if (isObj(report.beyond)) for (const k of BEYOND_KEYS) {
|
|
547
|
+
const v = report.beyond[k]; const t = (Array.isArray(v) ? v : [v]).filter((x) => isStr(x) && x.trim());
|
|
548
|
+
if (!t.length) continue;
|
|
549
|
+
if (k === 'unknown') { if (!gaps.length && !t.some((x) => tokensIn(x).length)) weak.push(k); continue; }
|
|
550
|
+
if (!t.every((x) => tokensIn(x).some((id) => byId.has(id)))) weak.push(k);
|
|
551
|
+
}
|
|
552
|
+
out.push(weak.length ? { id: 'beyond', pass: false, hard: false, reason: `${weak.map((k) => BEYOND.find((b) => b[0] === k)[1]).join(', ')} cite${weak.length === 1 ? 's' : ''} no reading; leave a part out when no reading backs it` }
|
|
553
|
+
: { id: 'beyond', pass: true, hard: false, reason: 'each addition beyond the ask cites a reading' });
|
|
554
|
+
// then the form checks of a report document
|
|
555
|
+
if (markdown != null) { try { for (const c of require('./report-checks.js').reportChecks(markdown, { listed: [] })) out.push({ ...c, hard: false, form: true }); } catch { /* the form checks are advisory */ } }
|
|
556
|
+
return out;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// ── step 5 · render: one model → the document and the deck ──────────────────────────────────────────────────────────
|
|
560
|
+
function sourceLine(ids, byId, brief) {
|
|
561
|
+
const names = new Map(((brief && brief.sources) || []).map((s) => [s.id, s.name]));
|
|
562
|
+
const seen = new Map();
|
|
563
|
+
for (const id of ids) { const r = byId.get(id); if (!r) continue; const k = r.source; if (!seen.has(k)) seen.set(k, new Set()); seen.get(k).add(r.at); }
|
|
564
|
+
if (!seen.size) return '';
|
|
565
|
+
return 'Source: ' + [...seen].map(([s, ats]) => `${names.get(s) || s}, ${[...ats].sort().map(full).join(' and ')}`).join('; ') + '.';
|
|
566
|
+
}
|
|
567
|
+
const mdCell = (s) => str(s).replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
|
|
568
|
+
function blockMd(b, rs, byId, brief) {
|
|
569
|
+
const r = (t) => String(resolve(t, byId));
|
|
570
|
+
const used = []; const take = (t) => { used.push(...tokensIn(t)); return r(t); };
|
|
571
|
+
let body = '';
|
|
572
|
+
if (b.kind === 'p') body = take(b.text);
|
|
573
|
+
else if (b.kind === 'quote') body = '> ' + take(b.text);
|
|
574
|
+
else if (b.kind === 'list') body = b.items.map((x) => `- ${take(x)}`).join('\n');
|
|
575
|
+
else if (b.kind === 'table') body = `| ${b.columns.map((c) => mdCell(take(c))).join(' | ')} |\n|${b.columns.map(() => '---').join('|')}|\n` + b.rows.map((row) => `| ${row.map((c) => mdCell(take(str(c)))).join(' | ')} |`).join('\n');
|
|
576
|
+
else if (b.kind === 'bars') body = `| ${mdCell(b.title || 'Item')} | Value |\n|---|---:|\n` + b.rows.map(([l, v]) => `| ${mdCell(take(l))} | ${take(v)} |`).join('\n');
|
|
577
|
+
else if (b.kind === 'options') body = b.options.map((o, i) => `- **Option ${'ABCD'[i]}: ${take(o.name)}.** ${take(str(o.text))}`).join('\n');
|
|
578
|
+
const src = sourceLine(used, byId, brief);
|
|
579
|
+
return body + (src ? `\n\n*${src}*` : '');
|
|
580
|
+
}
|
|
581
|
+
function beyondParts(report) {
|
|
582
|
+
const out = [];
|
|
583
|
+
if (!isObj(report.beyond)) return out;
|
|
584
|
+
for (const [k, label] of BEYOND) {
|
|
585
|
+
const v = report.beyond[k]; const t = (Array.isArray(v) ? v : [v]).filter((x) => isStr(x) && x.trim());
|
|
586
|
+
if (t.length) out.push({ key: k, label, texts: t });
|
|
587
|
+
}
|
|
588
|
+
return out;
|
|
589
|
+
}
|
|
590
|
+
function renderMarkdown(report, { brief = null, readings = null, now = Date.now() } = {}) {
|
|
591
|
+
const R = Array.isArray(readings && readings.readings) ? readings.readings : [];
|
|
592
|
+
const byId = new Map(R.map((r) => [r.id, r]));
|
|
593
|
+
const r = (t) => String(resolve(t, byId));
|
|
594
|
+
const title = r(report.title);
|
|
595
|
+
const q = (s) => JSON.stringify(str(s));
|
|
596
|
+
const lines = ['---', 'type: Reference', `title: ${q(title)}`, `description: ${q(clip(r(report.answer.text), 200))}`, `timestamp: ${new Date(now).toISOString()}`];
|
|
597
|
+
if (brief) {
|
|
598
|
+
lines.push(`report: ${brief.id}`, `slot: ${brief.slot}`, `window: ${q(`${brief.window.from} to ${brief.window.to}`)}`, `audience: ${q(brief.readers)}`);
|
|
599
|
+
if (brief.recurring) lines.push(`cadence: ${brief.id}`);
|
|
600
|
+
}
|
|
601
|
+
lines.push('status: draft', 'engine: e1', '---', '', `# ${title}`, '');
|
|
602
|
+
if (brief) lines.push(`For ${brief.readers}. ${periodOf(brief, R)}.`, '');
|
|
603
|
+
const ansIds = tokensIn(report.answer.text).concat(...(report.answer.supports || []).map(tokensIn), tokensIn(report.title));
|
|
604
|
+
lines.push(`**In one minute.** ${r(report.answer.text)}`, '');
|
|
605
|
+
for (const s of report.answer.supports || []) lines.push(`- ${r(s)}`);
|
|
606
|
+
if ((report.answer.supports || []).length) lines.push('');
|
|
607
|
+
const src = sourceLine(ansIds, byId, brief);
|
|
608
|
+
if (src) lines.push(`*${src}*`, '');
|
|
609
|
+
if (report.ask) {
|
|
610
|
+
lines.push(`**${report.ask.title ? r(report.ask.title) : 'Needs your call'}:** ${stop(r(report.ask.text))}${report.ask.who ? ` Who: ${r(report.ask.who)}.` : ''}${report.ask.by ? ` By ${full(report.ask.by)}.` : ''}`, '');
|
|
611
|
+
(report.ask.options || []).forEach((o, i) => lines.push(`- Option ${'ABC'[i]}: ${r(o)}`));
|
|
612
|
+
if ((report.ask.options || []).length) lines.push('');
|
|
613
|
+
}
|
|
614
|
+
for (const s of report.sections) {
|
|
615
|
+
lines.push(`## ${r(s.title)}`, '');
|
|
616
|
+
for (const b of s.blocks) lines.push(blockMd(b, null, byId, brief), '');
|
|
617
|
+
}
|
|
618
|
+
for (const p of beyondParts(report)) {
|
|
619
|
+
lines.push(`## ${p.label}`, '');
|
|
620
|
+
if (p.texts.length === 1) lines.push(r(p.texts[0])); else for (const t of p.texts) lines.push(`- ${r(t)}`);
|
|
621
|
+
const s2 = sourceLine(p.texts.flatMap(tokensIn), byId, brief);
|
|
622
|
+
lines.push(...(s2 ? ['', `*${s2}*`] : []), '');
|
|
623
|
+
}
|
|
624
|
+
const gaps = Array.isArray(readings && readings.gaps) ? readings.gaps : [];
|
|
625
|
+
const used = new Set(textsOf(report).flatMap((t) => tokensIn(t.text)));
|
|
626
|
+
const names = new Map(((brief && brief.sources) || []).map((x) => [x.id, x.name]));
|
|
627
|
+
const reached = new Map();
|
|
628
|
+
for (const x of R) if (used.has(x.id)) { if (!reached.has(x.source)) reached.set(x.source, { n: 0, ats: new Set(), how: new Set() }); const e = reached.get(x.source); e.n++; e.ats.add(x.at); if (x.how) e.how.add(x.how); }
|
|
629
|
+
lines.push('## Sources', '');
|
|
630
|
+
if (!reached.size) lines.push('- None of the readings were used.');
|
|
631
|
+
for (const [s, e] of reached) lines.push(`- **${names.get(s) || s}** (source: ${s}): ${e.n} reading(s), read ${[...e.ats].sort().map(full).join(', ')}.${e.how.size ? ` How: ${[...e.how].slice(0, 3).join('; ')}.` : ''}`);
|
|
632
|
+
if (gaps.length) {
|
|
633
|
+
lines.push('', '**Not reached**', '');
|
|
634
|
+
for (const g of gaps) lines.push(`- **${names.get(g.source) || g.source}** (source: ${g.source}): ${g.reason}.`);
|
|
635
|
+
}
|
|
636
|
+
return lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const stop = (t) => (/[.?!]$/.test(t) ? t : t + '.');
|
|
640
|
+
/** The period a report covers: a recurring report's window; an ad hoc report's, the dates of its readings. */
|
|
641
|
+
function periodOf(brief, R) {
|
|
642
|
+
const span = (a, b) => (a === b ? full(a) : `${full(a)} to ${full(b)}`);
|
|
643
|
+
if (brief.recurring || !R.length) return span(brief.window.from, brief.window.to);
|
|
644
|
+
const ats = R.map((x) => x.at).sort();
|
|
645
|
+
return 'Data of ' + span(ats[0], ats[ats.length - 1]);
|
|
646
|
+
}
|
|
647
|
+
/** The deck of an engine report: the same model, drawn as slides (lib/report-pyramid.js deckHtml and lib/report-export.js pptx draw it). */
|
|
648
|
+
function deckOf(report, { brief = null, readings = null, look = null } = {}) {
|
|
649
|
+
const R = Array.isArray(readings && readings.readings) ? readings.readings : [];
|
|
650
|
+
const byId = new Map(R.map((r) => [r.id, r]));
|
|
651
|
+
const r = (t) => String(resolve(t, byId));
|
|
652
|
+
const srcOf = (ids) => sourceLine(ids, byId, brief).replace(/\.$/, '');
|
|
653
|
+
const slides = [];
|
|
654
|
+
const titleText = clip(r(report.title), 160);
|
|
655
|
+
slides.push({ kind: 'title', tag: [brief && brief.title, brief && brief.readers ? `for ${brief.readers}` : ''].filter(Boolean).join(' · ') || 'Report', title: titleText, size: titleText.length > 60 ? 48 : 64,
|
|
656
|
+
sub: brief ? periodOf(brief, R) : '', source: srcOf(tokensIn(report.title)) });
|
|
657
|
+
const supports = (report.answer.supports || []).map((s) => { const t = r(s); const m = /^([^:]{3,80}):\s+(.+)$/.exec(t); return m ? { head: m[1], text: m[2] } : { head: t, text: '' }; });
|
|
658
|
+
const ask = report.ask ? { question: r(report.ask.text), who: report.ask.who ? r(report.ask.who) : (brief && brief.readers) || '', by: report.ask.by ? full(report.ask.by) : null, options: (report.ask.options || []).map(r), more: '' } : null;
|
|
659
|
+
slides.push({ kind: 'answer', tag: 'The answer', title: clip(r(report.answer.text), 200), lead: '', supports, ask,
|
|
660
|
+
source: srcOf(tokensIn(report.answer.text).concat(...(report.answer.supports || []).map(tokensIn))) });
|
|
661
|
+
for (const s of report.sections) {
|
|
662
|
+
// a section goes on as many slides as its blocks need: a long table is cut at 8 rows, and a block that would run under the footer starts a new slide
|
|
663
|
+
const chunks = [[]]; let used = 0;
|
|
664
|
+
const put = (b) => { const h = blockHeight(b); if (chunks[chunks.length - 1].length && used + h > SLIDE_ROOM) { chunks.push([]); used = 0; } chunks[chunks.length - 1].push(b); used += h; };
|
|
665
|
+
for (const b of s.blocks) {
|
|
666
|
+
if (b.kind === 'table' && b.rows.length > 8) for (let i = 0; i < b.rows.length; i += 8) put({ ...b, rows: b.rows.slice(i, i + 8) });
|
|
667
|
+
else put(b);
|
|
668
|
+
}
|
|
669
|
+
const real = chunks.filter((c) => c.length);
|
|
670
|
+
real.forEach((blocks, i) => {
|
|
671
|
+
const ids = blocks.flatMap((b) => textsOf({ title: '', answer: { text: '' }, sections: [{ title: '', blocks: [b] }] }).flatMap((t) => tokensIn(t.text)));
|
|
672
|
+
slides.push({ kind: 'section', title: clip(r(s.title), 160) + (i ? ' (continued)' : ''), blocks: blocks.map((b) => drawBlock(b, r, byId)), source: srcOf(ids) });
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
const parts = beyondParts(report);
|
|
676
|
+
for (let i = 0; i < parts.length; i += 4) {
|
|
677
|
+
const chunk = parts.slice(i, i + 4);
|
|
678
|
+
// the title is a finding, as on every slide: the first part's own sentence
|
|
679
|
+
slides.push({ kind: 'beyond', tag: 'Beyond the question', title: clip(r(chunk[0].texts[0]), 160), items: chunk.map((p) => ({ head: p.label, text: clip(p.texts.map(r).join(' '), 260) })),
|
|
680
|
+
source: srcOf(chunk.flatMap((p) => p.texts.flatMap(tokensIn))) });
|
|
681
|
+
}
|
|
682
|
+
if (ask && (ask.options.length || ask.who)) slides.push({ kind: 'ask', tag: 'Needs your call', title: clip(ask.by ? `Decide by ${ask.by}: ${ask.question.replace(/\?$/, '')}` : ask.question, 200), ask, source: 'Source: the readings above' });
|
|
683
|
+
const names = new Map(((brief && brief.sources) || []).map((x) => [x.id, x.name]));
|
|
684
|
+
const used = new Set(textsOf(report).flatMap((t) => tokensIn(t.text)));
|
|
685
|
+
const reached = [...new Set(R.filter((x) => used.has(x.id)).map((x) => x.source))].map((s) => `${names.get(s) || s}, read ${[...new Set(R.filter((x) => x.source === s && used.has(x.id)).map((x) => x.at))].sort().map(full).join(', ')}`);
|
|
686
|
+
const nr = (Array.isArray(readings && readings.gaps) ? readings.gaps : []).map((g) => ({ source: names.get(g.source) || g.source, why: g.reason }));
|
|
687
|
+
slides.push({ kind: 'sources', title: `Every number comes from ${reached.length === 1 ? 'one source' : `${reached.length} sources`}${nr.length ? `; ${nr.length === 1 ? 'one source was' : `${nr.length} sources were`} not reached` : ''}`, reached, notReached: nr, source: 'Source: the readings of this report' });
|
|
688
|
+
slides.forEach((s, i) => { s.n = i + 1; });
|
|
689
|
+
return { slides, look: look || null, name: r(report.title), engine: true };
|
|
690
|
+
}
|
|
691
|
+
/** The room under a slide's title, and what a block takes of it (1280 x 720 frames, lib/report-pyramid.js css). */
|
|
692
|
+
const SLIDE_ROOM = 440;
|
|
693
|
+
function blockHeight(b) {
|
|
694
|
+
if (b.kind === 'p' || b.kind === 'quote') return 30 * Math.ceil(str(b.text).length / 105) + 16;
|
|
695
|
+
if (b.kind === 'list') return b.items.reduce((h, x) => h + 30 * Math.ceil(str(x).length / 105) + 14, 10);
|
|
696
|
+
if (b.kind === 'options') return b.options.length * 48 + 10;
|
|
697
|
+
if (b.kind === 'table') return 56 + b.rows.length * 48;
|
|
698
|
+
if (b.kind === 'bars') return b.rows.length * 33 + 14;
|
|
699
|
+
return 40;
|
|
700
|
+
}
|
|
701
|
+
/** One block as the slide draws it: plain text, resolved. */
|
|
702
|
+
function drawBlock(b, r, byId) {
|
|
703
|
+
if (b.kind === 'p' || b.kind === 'quote') return { kind: 'p', text: r(b.text) };
|
|
704
|
+
if (b.kind === 'list') return { kind: 'list', items: b.items.map(r) };
|
|
705
|
+
if (b.kind === 'table') return { kind: 'table', columns: b.columns.map(r), rows: b.rows.map((row) => row.map((c) => r(str(c)))) };
|
|
706
|
+
if (b.kind === 'options') return { kind: 'list', items: b.options.map((o, i) => `Option ${'ABCD'[i]}: ${r(o.name)}${o.text ? '. ' + r(o.text) : ''}`) };
|
|
707
|
+
// bars: each bar is one reading; the scale is the largest value (100 for percentages that fit)
|
|
708
|
+
const rows = b.rows.map(([l, v]) => { const id = tokensIn(v)[0]; const x = byId.get(id); return { label: r(l), text: r(v), value: x && typeof x.value === 'number' ? x.value : 0, unit: x ? x.unit : null }; });
|
|
709
|
+
const max = rows.every((x) => x.unit === '%' && x.value <= 100) ? 100 : Math.max(1, ...rows.map((x) => x.value));
|
|
710
|
+
return { kind: 'bars', rows: rows.map((x) => ({ ...x, share: Math.max(0, Math.min(1, x.value / max)) })) };
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// ── the steps, written ──────────────────────────────────────────────────────────────────────────────────────────────
|
|
714
|
+
function isIssued(root, id, slot) { return issuedSlots(root, id).includes(slot); }
|
|
715
|
+
/** Steps 1–3: the brief, the readings, the findings, written next to the draft. Filed readings and findings are kept across a re-read. */
|
|
716
|
+
function make(root, id, { slot, ask, readers, sources, add, gaps, addFindings, now = Date.now() } = {}) {
|
|
717
|
+
const abs = path.resolve(String(root));
|
|
718
|
+
const day = slot || dayOf(now);
|
|
719
|
+
let P; try { P = paths(abs, id, day); } catch (e) { return refuse(400, e.message); }
|
|
720
|
+
if (isIssued(abs, id, day)) return refuse(409, `${id} ${day} is issued; it is frozen`);
|
|
721
|
+
const old = readJson(P.brief.abs);
|
|
722
|
+
// a re-read keeps the brief's chat question and named sources unless new ones are given
|
|
723
|
+
const b = briefOf(abs, id, { slot: day, now, ask: ask || (old && old.from && old.from.includes('chat') ? old.question : undefined), readers: readers || (old && old.from && old.from.includes('readers') ? old.readers : undefined),
|
|
724
|
+
sources: [...(old ? old.sources.filter((s) => s.kind === 'pack' || s.kind === 'connector' || s.kind === 'document') : []), ...(sources || [])] });
|
|
725
|
+
if (!b.ok) return b;
|
|
726
|
+
const rd = readingsOf(abs, b.brief, { add, gaps, keep: readJson(P.readings.abs), now });
|
|
727
|
+
if (!rd.ok) return rd;
|
|
728
|
+
const priorSlot = b.brief.last_issue;
|
|
729
|
+
const prior = priorSlot ? readJson(paths(abs, id, priorSlot).readings.abs) : null;
|
|
730
|
+
const oldF = readJson(P.findings.abs);
|
|
731
|
+
const keepFiled = (oldF && Array.isArray(oldF.findings) ? oldF.findings.filter((f) => f.filed && f.readings.every((x) => rd.readings.some((r) => r.id === x))) : []);
|
|
732
|
+
const fd = findingsOf(b.brief, rd, { prior, add: keepFiled.concat(Array.isArray(addFindings) ? addFindings : []) });
|
|
733
|
+
if (fd.ok === false) return fd;
|
|
734
|
+
writeJson(P.brief.abs, b.brief);
|
|
735
|
+
writeJson(P.readings.abs, { at: rd.at, readings: rd.readings, gaps: rd.gaps });
|
|
736
|
+
writeJson(P.findings.abs, fd);
|
|
737
|
+
return { ok: true, brief: b.brief, readings: { readings: rd.readings, gaps: rd.gaps }, findings: fd, paths: { brief: P.brief.rel, readings: P.readings.rel, findings: P.findings.rel, report: P.report.rel, md: P.md.rel } };
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** Steps 4–6: the writer's report.json, checked, then the document rendered. A report with no answer, or a figure that is not a reading, is refused. */
|
|
741
|
+
function write(root, id, slot, report, { now = Date.now() } = {}) {
|
|
742
|
+
const abs = path.resolve(String(root));
|
|
743
|
+
let P; try { P = paths(abs, id, slot); } catch (e) { return refuse(400, e.message); }
|
|
744
|
+
if (isIssued(abs, id, slot)) return refuse(409, `${id} ${slot} is issued; it is frozen`);
|
|
745
|
+
const brief = readJson(P.brief.abs); const readings = readJson(P.readings.abs); const findings = readJson(P.findings.abs);
|
|
746
|
+
if (!brief || !readings) return refuse(409, `read it first: pmos report make ${id}${slot ? ' --slot ' + slot : ''}`);
|
|
747
|
+
if (findings && findings.none) return refuse(422, 'there is no finding, so there is no draft: nothing to report this cycle', { none: true });
|
|
748
|
+
const pre = checkReport(report, { brief, readings });
|
|
749
|
+
const hardFail = pre.filter((c) => c.hard && !c.pass);
|
|
750
|
+
if (hardFail.length) return refuse(422, hardFail.map((c) => `${c.id}: ${c.reason}`).join(' · '), { checks: pre });
|
|
751
|
+
const clean = { ...report, sections: report.sections.map((s) => ({ ...s, id: str(s.id) || slugOf(s.title) })), carried: brief.carried_asks || [], v: 1, at: new Date(now).toISOString() };
|
|
752
|
+
const md = renderMarkdown(clean, { brief, readings, now });
|
|
753
|
+
const checks = checkReport(clean, { brief, readings, markdown: md });
|
|
754
|
+
writeJson(P.report.abs, clean);
|
|
755
|
+
writeAtomic(P.md.abs, md);
|
|
756
|
+
writeJson(P.checks.abs, { at: new Date(now).toISOString(), checks, sha: sha(md) });
|
|
757
|
+
return { ok: true, path: P.md.rel, report: P.report.rel, checks };
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** The engine's model behind a draft, when the engine wrote it: { report, brief, readings, checks } | null. absMd = <state>/reports/<id>/<slot>.md */
|
|
761
|
+
function engineReportOf(absMd) {
|
|
762
|
+
const m = /^(.*)\.md$/.exec(str(absMd));
|
|
763
|
+
if (!m) return null;
|
|
764
|
+
const report = readJson(m[1] + '.report.json');
|
|
765
|
+
if (!report) return null;
|
|
766
|
+
return { report, brief: readJson(m[1] + '.brief.json'), readings: readJson(m[1] + '.readings.json'), checks: readJson(m[1] + '.checks.json') };
|
|
767
|
+
}
|
|
768
|
+
/** The engine deck of a draft in its look, or null when the engine did not write it. */
|
|
769
|
+
function engineDeckOf(absMd, look) {
|
|
770
|
+
const e = engineReportOf(absMd);
|
|
771
|
+
if (!e || shapeError(e.report)) return null;
|
|
772
|
+
return deckOf(e.report, { brief: e.brief, readings: e.readings, look });
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
module.exports = {
|
|
776
|
+
ID_RE, BEYOND, BEYOND_KEYS, VOICE, RULE_KINDS, BLOCK_KINDS, LIMITS,
|
|
777
|
+
paths, briefOf, readingsOf, findingsOf, resolve, tokensIn, bareFigures, checkReport, shapeError, renderMarkdown, deckOf, make, write,
|
|
778
|
+
engineReportOf, engineDeckOf, rulesOf, addRule, removeRule, closeAsk, parseSource, sourcesFromText, full, valueText,
|
|
779
|
+
};
|