create-pmos 0.514.0 → 0.516.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 +35 -95
- package/payload/KIT-VERSION +2 -2
- 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/payload/state/log.md +1 -1
- package/workspace/briefing-premium-calendar-test.js +6 -3
- package/workspace/briefing-premium-insights-test.js +12 -12
- package/workspace/briefing-premium-metrics-test.js +1 -1
- package/workspace/changelog.json +34 -0
- package/workspace/fixtures/premium-viewer-fixtures.js +4 -2
- package/workspace/guide/agent-capabilities.md +73 -0
- package/workspace/guide/index.md +4 -1
- package/workspace/guide/intelligence-okr-goals.md +39 -0
- package/workspace/guide/reports-needs-you.md +34 -0
- package/workspace/lib/board-metrics-premium.js +4 -1
- package/workspace/lib/insights-goals.js +3 -1
- package/workspace/lib/insights-premium.js +53 -7
- package/workspace/lib/okr-goals.js +200 -0
- package/workspace/lib/report-acts.js +189 -0
- package/workspace/lib/routes-insights-premium.js +44 -5
- package/workspace/lib/routes-ready-reports.js +8 -1
- package/workspace/lib/routes-report-setups.js +21 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/premium-vw-meet-test.js +9 -5
- package/workspace/public/pm/cozy-insights.css +60 -1
- package/workspace/public/pm/home-app.js +3 -1
- package/workspace/public/pm/premium-insights.js +269 -36
- package/workspace/public/premium-intelligence.html +1 -1
- package/workspace/test-files.json +1 -0
- package/workspace/test-lists/reports-needs-you.json +5 -0
|
@@ -95,7 +95,10 @@ function build(f) {
|
|
|
95
95
|
const ch = prev == null ? null : (() => { const d = live - prev; return { d, good: d < 0, txt: (d === 0 ? 'No change' : (d > 0 ? 'Up ' : 'Down ') + Math.abs(d)) + ' vs last week' }; })();
|
|
96
96
|
return { key, label, value: String(live), text: 'of ' + open.length + ' open · ' + since, kind, ser, wk: W.map((w) => w.label), nt: ser.map(() => ''), dec: 0, suf: '', change: ch, since, def, lowBetter: true };
|
|
97
97
|
};
|
|
98
|
-
|
|
98
|
+
// Intelligence redesign A: an initiative with no activity on record is counted idle, and a month of watching is what makes that true. Until
|
|
99
|
+
// the history is a month old, the idle tile says the day it becomes known (Intelligence shows that instead of the count).
|
|
100
|
+
const known = first + IDLE_DAYS * DAY > now ? Rec.dayOf(first + IDLE_DAYS * DAY) : null;
|
|
101
|
+
tiles.push(hTile('blocked', 'Blocked', blocked, blocked ? 'bad' : '', DEFS.blocked), Object.assign(hTile('idle', 'Idle for a month', idle, idle ? 'warn' : '', DEFS.idle), known && open.some((i) => !Number.isFinite(Date.parse(i.last_activity))) ? { knownFrom: known, historyFrom: Rec.dayOf(first) } : {}));
|
|
99
102
|
counted = { blocked, idle, open: open.length };
|
|
100
103
|
}
|
|
101
104
|
|
|
@@ -90,6 +90,8 @@ function goalFields(input, { today } = {}) {
|
|
|
90
90
|
if (SOURCES[i.source]) f.source = i.source;
|
|
91
91
|
const owner = tidy(i.owner, 60); if (owner) f.owner = owner;
|
|
92
92
|
const guard = tidy(i.guardrail, 120); if (guard) f.guardrail = guard;
|
|
93
|
+
// Intelligence redesign A: a goal drafted from the OKR file reads that key result (lib/okr-goals.js readerOf), so it is never a second goal
|
|
94
|
+
const kr = tidy(i.kr, 40); if (kr && /^(?:O[\w-]*\.)?KR[\w.-]+$/i.test(kr)) f.kr = kr;
|
|
93
95
|
return { ok: true, fields: f };
|
|
94
96
|
}
|
|
95
97
|
|
|
@@ -119,7 +121,7 @@ function describe(text, goals) {
|
|
|
119
121
|
function parseBudgets(md) {
|
|
120
122
|
const out = [];
|
|
121
123
|
for (const line of String(md || '').split('\n')) {
|
|
122
|
-
const m = /^\|\s
|
|
124
|
+
const m = /^\|\s*`?health:([a-z0-9-]+)`?\s*\|\s*([^|]*)\|\s*(\d+(?:\.\d+)?)\s*%\s*\|/.exec(line);
|
|
123
125
|
if (m) out.push({ id: m[1], covers: m[2].trim(), cap: Number(m[3]) / 100 });
|
|
124
126
|
}
|
|
125
127
|
return out;
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
const IG = require('./insights-goals.js');
|
|
24
24
|
const GS = require('./goal-status.js'); // batch 16 item 6: the one status of a goal, from its readings
|
|
25
25
|
const MR = require('./metric-reads.js');
|
|
26
|
+
const OG = require('./okr-goals.js'); // Intelligence redesign A: the OKR file is the goal list
|
|
26
27
|
const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
27
28
|
const isoDay = (v) => { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(v || '')); return m ? Date.UTC(+m[1], +m[2] - 1, +m[3]) : null; };
|
|
28
29
|
const shortDate = (v) => { const t = isoDay(v); if (t === null) return ''; const d = new Date(t); return d.getUTCDate() + ' ' + MON[d.getUTCMonth()]; };
|
|
@@ -394,17 +395,55 @@ function okrOf(model, drafts) {
|
|
|
394
395
|
const alert = a ? { rid: a.id, title: a.title, at: a.alert.day, txt: a.reading.txt, line: a.reading.line } : null;
|
|
395
396
|
// the owner the page shows is the goal's owner string: a goal with one has an owner on the ✦ line too
|
|
396
397
|
const named = goals.map((g) => (g.who || !g.owner ? g : Object.assign({}, g, { who: { name: g.owner, initials: GS.initials(g.owner), from: 'pm' } })));
|
|
397
|
-
|
|
398
|
+
let line = GS.lineOf({ goals: named, drafts, alert, moved: GS.movedOf(named) });
|
|
399
|
+
// Intelligence redesign A: with nothing read and nothing drafted, the line was "I have not read a goal yet" although the OKR file was read.
|
|
400
|
+
// It leads with what was read instead, then what waits on a source (grouped by the source the file names).
|
|
401
|
+
if (model.okrFile && line.tone === 'drafts' && !drafts) line = okrLead(model.okrFile, model.goals || []);
|
|
402
|
+
return { line, alert, more: moreOf(model) };
|
|
403
|
+
}
|
|
404
|
+
const noun = (n, one, many) => n + ' ' + (n === 1 ? one : many);
|
|
405
|
+
/** The ✦ line from the OKR file (IgA1Open): what was read this week, then what waits on which source. Never a number without a reading. PURE. */
|
|
406
|
+
function okrLead(F, goals) {
|
|
407
|
+
const krs = F.objectives.flatMap((o) => o.krs), byId = new Map(goals.map((g) => [g.id, g]));
|
|
408
|
+
const fresh = krs.filter((k) => { const g = k.read && byId.get(k.goal); return g && g.reading && g.reading.ageDays !== null && g.reading.ageDays <= 7; });
|
|
409
|
+
const per = F.period ? ' for ' + F.period.label : '';
|
|
410
|
+
const head = fresh.length ? 'Read this week: ' + fresh.slice(0, 2).map((k) => k.token + ' ' + byId.get(k.goal).nowTxt).join(', ') + (fresh.length > 2 ? ' and ' + (fresh.length - 2) + ' more' : '') + '.'
|
|
411
|
+
: F.read ? 'I read your OKR file: ' + noun(F.total, 'key result', 'key results') + per + ', ' + F.read + ' with a reading.'
|
|
412
|
+
: 'I read your OKR file: ' + noun(F.total, 'key result', 'key results') + per + '. None has a reading yet.';
|
|
413
|
+
const wait = Object.entries(F.needs).sort((a, b) => b[1] - a[1]);
|
|
414
|
+
const nw = wait.reduce((a, [, n]) => a + n, 0);
|
|
415
|
+
const sub = nw ? noun(nw, 'key result waits', 'key results wait') + ' on a source: ' + wait.slice(0, 3).map(([k, n]) => n + ' on ' + k).join(', ') + (wait.length > 3 ? ', and ' + (wait.length - 3) + ' more' : '') + '. I will not guess a number.' : '';
|
|
416
|
+
return { head, sub, tone: 'calm', keepAll: 0 };
|
|
417
|
+
}
|
|
418
|
+
/** More ✦ findings after the first line (IgA1Open's pager): outcome checks that are due, and late initiatives. Zero is silence. PURE. */
|
|
419
|
+
function moreOf(model) {
|
|
420
|
+
const out = [];
|
|
421
|
+
const due = (model.outcomes || []).filter((r) => r.group === 'verdict' && r.promise && r.promise.overdue);
|
|
422
|
+
if (due.length) {
|
|
423
|
+
const r = due[0];
|
|
424
|
+
out.push({ text: (due.length === 1 ? 'An outcome check is due: ' : due.length + ' outcome checks are due. The oldest: ') + r.title + ', checked by ' + mdDate(r.promise.horizon) + '.',
|
|
425
|
+
why: r.promise.metric ? 'It promised: ' + clip(r.promise.metric, 140) : 'See Did it work? below.', outcome: r.id });
|
|
426
|
+
}
|
|
427
|
+
const late = model.lateRows || [];
|
|
428
|
+
if (late.length) out.push({ text: noun(late.length, 'initiative is late', 'initiatives are late') + ': ' + late.slice(0, 3).map((r) => r.title + (r.owner ? ' (' + r.owner + ')' : '')).join(', ') + (late.length > 3 ? ', and ' + (late.length - 3) + ' more' : '') + '.', why: 'Late is the Board’s own status for each.' });
|
|
429
|
+
return out;
|
|
398
430
|
}
|
|
399
431
|
/** Shipped work (Deliver, Monitor, Evaluate, or done) against what its anchor goal promised. PURE. */
|
|
400
|
-
|
|
432
|
+
/* Intelligence redesign A (IgA3Outcomes): "Decided" and "kept" come only from a recorded verdict (this page's verdict, or the D57 outcome check in
|
|
433
|
+
* outcome_checks.jsonl). A status word ("done") never makes a row kept: before, "Decided 2 · 2 kept" showed with no verdict recorded anywhere.
|
|
434
|
+
* promises: { [initiative id]: { metric, horizon, disposition } } from lib/outcomes.js scan; a check date that has passed makes the row due. */
|
|
435
|
+
const DISPO = { validated: 'Keep', refuted: 'Stop', inconclusive: 'Iterate' };
|
|
436
|
+
function outcomesOf(inits, goals, outcomes, verdicts, todayIso, promises) {
|
|
401
437
|
const ORDER = ['verdict', 'window', 'decided'];
|
|
402
438
|
const rows = (inits || []).map(initOf).filter((r) => r.idx >= 5 || r.status === 'done');
|
|
403
439
|
return rows.map((r) => {
|
|
404
440
|
const g = goals.find((x) => anchorHits(r.anchor, { id: x.id, name: (outcomes.find((o) => o.id === x.id) || {}).name })) || null;
|
|
405
|
-
const
|
|
406
|
-
const
|
|
407
|
-
|
|
441
|
+
const pr = (promises && promises[r.id]) || null;
|
|
442
|
+
const vd0 = (verdicts && verdicts[r.id]) || null, vd = vd0 || (pr && pr.disposition ? { verdict: DISPO[pr.disposition] || 'Iterate', learned: '', at: null, check: true } : null), o = g ? outcomes.find((x) => x.id === g.id) : null;
|
|
443
|
+
const dueNow = !!(pr && pr.horizon && pr.horizon <= todayIso);
|
|
444
|
+
const group = vd || r.status === 'cancelled' ? 'decided' : r.idx === 7 || r.status === 'done' || dueNow ? 'verdict' : 'window';
|
|
445
|
+
return Object.assign(r, { group, promise: pr ? { metric: pr.metric ? clip(pr.metric, 160) : '', horizon: pr.horizon || null, overdue: dueNow && !vd } : null,
|
|
446
|
+
verdict: vd ? { verdict: vd.verdict, learned: vd.learned || '', at: vd.at ? String(vd.at).slice(0, 10) : null, check: !!vd.check } : null,
|
|
408
447
|
window: IG.windowOf({ shipAt: r.endAt, byAt: g ? g.byAt : null, verdict: vd, today: todayIso }),
|
|
409
448
|
evidence: g && o ? IG.evidenceOf((o.readings || []).map((x) => ({ at: String(x.at), v: x.v })), r.endAt, g.unit) : [], goal: g ? { id: g.id, key: g.key, title: g.title, type: g.type, baseTxt: g.baseTxt, targetTxt: g.targetTxt, nowTxt: g.nowTxt, by: g.by, done: g.done, tone: g.tone, pace: g.pace } : null });
|
|
410
449
|
}).sort((a, b) => ORDER.indexOf(a.group) - ORDER.indexOf(b.group));
|
|
@@ -412,6 +451,8 @@ function outcomesOf(inits, goals, outcomes, verdicts, todayIso) {
|
|
|
412
451
|
|
|
413
452
|
/** The health budgets the board's initiatives anchor to (D59: an anchor health:<id>). A budget's floor or ceiling and its weekly readings are not
|
|
414
453
|
* read on this page, so a budget holds only its id and what moves it. PURE. */
|
|
454
|
+
/** A PMOS kit chore in a product repository: its work is on PMOS itself (adopting the kit, its gates, its paths), not on the product. */
|
|
455
|
+
const isKitChore = (r) => !!r && /\b(pmos|kit|create-pmos)\b/i.test(String(r.title || '') + ' ' + String(r.id || '')) && (r.lane === 'chore' || /^health:/.test(String(r.anchor || '')));
|
|
415
456
|
function budgetsOf(inits, registry) {
|
|
416
457
|
const by = new Map();
|
|
417
458
|
for (const b of registry || []) by.set(b.id, []);
|
|
@@ -476,11 +517,16 @@ function build(facts) {
|
|
|
476
517
|
model.goals = goalsOf(outcomes, charts, f.initiatives || [], today, f.goalMeta || {}, f.watch || null);
|
|
477
518
|
model.initiativesRead = Array.isArray(f.initiatives);
|
|
478
519
|
const ins = f.ins || { charts: {}, verdicts: {}, targets: {} }, bid = board.id || '';
|
|
479
|
-
model.outcomes = outcomesOf(f.initiatives || [], model.goals, outcomes, ins.verdicts[bid] || {}, model.today);
|
|
480
|
-
model.
|
|
520
|
+
model.outcomes = outcomesOf(f.initiatives || [], model.goals, outcomes, ins.verdicts[bid] || {}, model.today, f.promises || null);
|
|
521
|
+
model.self = f.self === true;
|
|
522
|
+
// a product's budgets count its own work: the PMOS kit's chores in a product repository (kit adoption, the gates) are not its health work
|
|
523
|
+
model.budgets = budgetsOf(model.self ? f.initiatives || [] : (f.initiatives || []).filter((r) => !isKitChore(r)), f.budgets || []);
|
|
481
524
|
model.budgetsRead = Array.isArray(f.budgets);
|
|
482
525
|
budgetWatch(model.budgets, model.goals);
|
|
483
526
|
model.metrics = GS.metricsOf(model.goals);
|
|
527
|
+
// Intelligence redesign A: the OKR file is the goal list; a KR metric record is a key result's reader (lib/okr-goals.js)
|
|
528
|
+
model.okrFile = OG.build({ okr: f.okr, goals: model.goals, initiatives: (f.initiatives || []).map(initOf), records: f.goalMeta || {}, today: model.today });
|
|
529
|
+
model.lateRows = Array.isArray(f.initiatives) ? f.initiatives.filter((r) => r && r.status === 'late').map(initOf) : [];
|
|
484
530
|
model.okr = okrOf(model, (f.watch && f.watch.drafts) || 0);
|
|
485
531
|
model.lateNow = Array.isArray(f.initiatives) ? f.initiatives.filter((r) => r && r.status === 'late').length : null;
|
|
486
532
|
model.trends = Object.assign(IG.trendsOf(Array.isArray(f.initiatives) ? f.initiatives : null, model.today), { targets: ins.targets || {}, tiles: ins.tiles || {} });
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* okr-goals.js — the OKR file is the goal list (Intelligence redesign A, frames IgA1Open, IgA2Goal; brief /mnt/project-files/intelligence-grill/brief-A-build.md).
|
|
3
|
+
*
|
|
4
|
+
* read(root) → { ok, files, period, objectives, error } the OKR documents of a workspace, parsed (no other read)
|
|
5
|
+
* parse(text, file) → { title, period, supersedes, objectives } one document. PURE
|
|
6
|
+
* periodOf(text, file) → { label, short, from, to } | null the document's period: a quarter, a half or a quarter range. PURE
|
|
7
|
+
* matchAnchor(anchor, objectives) → { o, k } | null the key result an initiative's parent_kr names. PURE
|
|
8
|
+
* build({ okr, goals, initiatives, records, today }) → the model's okrFile. PURE
|
|
9
|
+
*
|
|
10
|
+
* WHY. Every product keeps an OKR file (IDPF: okrs-h2-2026.md, 10 key results), and the Board anchors initiatives to it (KR-A1, O-C.KR-C1), but
|
|
11
|
+
* Intelligence listed only the KR metric records, so IDPF showed 1 goal and OOH 0. Here the file's key results are the list. A KR metric record is
|
|
12
|
+
* a key result's READER (its readings and recipe), attached by its `kr` field or by its title naming the key result's id; it is never a second
|
|
13
|
+
* goal. A key result with no reader says what it needs (the source the file names), never a number (the proven-only rule).
|
|
14
|
+
*
|
|
15
|
+
* Where the files are: a product repository keeps them at pmos/planning/okrs/ (the kit layout); the PMOS repository at planning/okrs/. A file that
|
|
16
|
+
* says "Supersedes `okrs-…-v3.md`" drops that file. health-budgets.md, domains.md and kill-conditions.md declare no objective, so they are skipped.
|
|
17
|
+
*/
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
|
|
21
|
+
const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
22
|
+
const MONTHS = ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december'];
|
|
23
|
+
const DAY = 864e5;
|
|
24
|
+
const NOT_OKR = new Set(['health-budgets.md', 'domains.md', 'kill-conditions.md', 'index.md', 'log.md']);
|
|
25
|
+
|
|
26
|
+
/* Tokens as scripts/anchor-resolve.js reads them: an objective is O1, O-A or "Objective 1"; a key result is KR1, KR1.2 or KR-A1. */
|
|
27
|
+
const OBJ = '(O\\d+|O-[A-Za-z][A-Za-z0-9-]*|[Oo]bjective\\s*\\d+)';
|
|
28
|
+
const KR = '(KR\\d+(?:\\.\\d+)*|KR-[A-Za-z][A-Za-z0-9-]*)';
|
|
29
|
+
const END = '(?![A-Za-z0-9]|\\.\\d)';
|
|
30
|
+
const RE_OBJ = new RegExp('^#{1,3}\\s+(?:\\*\\*)?' + OBJ + END + '(?:\\*\\*)?\\s*(?:[—–:-]\\s*(.*))?$');
|
|
31
|
+
const RE_KR = new RegExp('^\\s*[-*]\\s*(\\*\\*)?\\s*' + KR + END + '(.*)$');
|
|
32
|
+
const objId = (t) => { const s = String(t || '').trim(), n = /^objective\s*(\d+)$/i.exec(s); if (n) return 'o' + n[1]; const m = /^O-(.+)$/i.exec(s); return (m ? m[1] : s).toLowerCase(); };
|
|
33
|
+
const krId = (t) => String(t || '').trim().toLowerCase();
|
|
34
|
+
const clean = (s) => String(s == null ? '' : s).replace(/\*\*|`|\*/g, '').replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/\s+/g, ' ').trim();
|
|
35
|
+
const clip = (s, n) => { s = clean(s); return s.length > n ? s.slice(0, n - 1).replace(/\s+\S*$/, '') + '…' : s; };
|
|
36
|
+
const qEnd = (y, q) => Date.UTC(y, q * 3, 0);
|
|
37
|
+
const iso = (t) => new Date(t).toISOString().slice(0, 10);
|
|
38
|
+
|
|
39
|
+
/** The document's period. A quarter range in the title ("FY2026 Q3 → FY2027 Q1") wins; then a `period:` line or the title or the file name:
|
|
40
|
+
* "2026-H2", "H2 2026", "2026-Q3", "Q3 2026". → { label, short, from, to } (UTC ms) or null. PURE. */
|
|
41
|
+
function periodOf(text, file) {
|
|
42
|
+
const s = String(text || ''), h1 = (/^#\s+(.+)$/m.exec(s) || [])[1] || '';
|
|
43
|
+
const qs = [...h1.matchAll(/(?:FY)?\s*(\d{4})\s*[-\s]?\s*Q([1-4])|Q([1-4])\s*[-\s]?\s*(?:FY)?\s*(\d{4})/gi)].map((m) => ({ y: +(m[1] || m[4]), q: +(m[2] || m[3]) }));
|
|
44
|
+
if (qs.length >= 2) {
|
|
45
|
+
const a = qs[0], b = qs[qs.length - 1];
|
|
46
|
+
if (b.y * 4 + b.q > a.y * 4 + a.q) return { label: 'Q' + a.q + ' ' + a.y + ' – Q' + b.q + ' ' + b.y, short: 'Q' + a.q + '–Q' + b.q, from: Date.UTC(a.y, (a.q - 1) * 3, 1), to: qEnd(b.y, b.q) };
|
|
47
|
+
}
|
|
48
|
+
const line = (/^\s*[-*]?\s*(?:.*?)\**period\**\s*:\**\s*([^·\n(]+)/im.exec(s) || [])[1] || '';
|
|
49
|
+
for (const src of [line, h1, path.basename(String(file || ''), '.md')]) {
|
|
50
|
+
let m = /(\d{4})\s*[-\s]?\s*H([12])(?![0-9])|(?<![A-Za-z])H([12])\s*[-\s]?\s*(\d{4})/i.exec(src);
|
|
51
|
+
if (m) { const y = +(m[1] || m[4]), h = +(m[2] || m[3]); return { label: 'H' + h + ' ' + y, short: 'H' + h, from: Date.UTC(y, (h - 1) * 6, 1), to: Date.UTC(y, h * 6, 0) }; }
|
|
52
|
+
m = /(\d{4})\s*[-\s]?\s*Q([1-4])(?![0-9])|(?<![A-Za-z])Q([1-4])\s*[-\s]?\s*(\d{4})/i.exec(src);
|
|
53
|
+
if (m) { const y = +(m[1] || m[4]), q = +(m[2] || m[3]); return { label: 'Q' + q + ' ' + y, short: 'Q' + q, from: Date.UTC(y, (q - 1) * 3, 1), to: qEnd(y, q) }; }
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The day a key result is due, from its words: "by Dec 2026", "by end of FY2026 Q4", "by Q1 2027", "by end-Jul 2026". null when it names none. */
|
|
59
|
+
function dueOf(text) {
|
|
60
|
+
const s = String(text || '');
|
|
61
|
+
let m = /\b(?:by|through)\s+(?:the\s+)?(?:end[-\s]+(?:of\s+)?)?(?:FY\s*)?(\d{4})\s*Q([1-4])\b|\bby\s+(?:the\s+)?(?:end[-\s]+(?:of\s+)?)?Q([1-4])\s*(?:FY\s*)?(\d{4})\b/i.exec(s);
|
|
62
|
+
if (m) return iso(qEnd(+(m[1] || m[4]), +(m[2] || m[3])));
|
|
63
|
+
m = /\b(?:by|through)\s+(?:the\s+)?(?:end[-\s]+(?:of\s+)?)?([A-Za-z]{3,9})\.?\s+(\d{4})\b/i.exec(s);
|
|
64
|
+
if (m) { const i = MONTHS.findIndex((x) => x.startsWith(m[1].toLowerCase().slice(0, 3))); if (i >= 0) return iso(Date.UTC(+m[2], i + 1, 0)); }
|
|
65
|
+
m = /\bby\s+(\d{1,2})\s+([A-Za-z]{3,9})\s+(\d{4})\b/i.exec(s);
|
|
66
|
+
if (m) { const i = MONTHS.findIndex((x) => x.startsWith(m[2].toLowerCase().slice(0, 3))); if (i >= 0) return iso(Date.UTC(+m[3], i, +m[1])); }
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
/** Where a key result is read from, in the file's words: "*Source:* x." or "Measured by: x." → the first clause, without links or code marks. */
|
|
70
|
+
function sourceOf(text) {
|
|
71
|
+
const m = /(?:\*Source:\*|\bSource:|\bMeasured by:)\s*([^]*?)(?:\.\s|\.$|\*[A-Z][a-z]+:\*|$)/.exec(String(text || ''));
|
|
72
|
+
return m ? clip(m[1], 90) : '';
|
|
73
|
+
}
|
|
74
|
+
/** What a source needs, said as a connector or a place, never a path: a host name, New Relic, ServiceNow, Jira, or the words the file uses. */
|
|
75
|
+
function needOf(source, host) {
|
|
76
|
+
const s = String(source || '');
|
|
77
|
+
if (!s || /^tbd\b/i.test(s)) return '';
|
|
78
|
+
if (/new relic/i.test(s)) return 'New Relic';
|
|
79
|
+
if (/servicenow/i.test(s)) return 'ServiceNow';
|
|
80
|
+
if (/\bjira\b/i.test(s)) return 'Jira';
|
|
81
|
+
const h = /\b([a-z0-9-]+(?:\.[a-z0-9-]+)+\.[a-z]{2,})(?:\/|\b)/i.exec(s);
|
|
82
|
+
if (h) return h[1];
|
|
83
|
+
if (/^\/[a-z]/i.test(s) && host) return host;
|
|
84
|
+
return clip(s, 60);
|
|
85
|
+
}
|
|
86
|
+
/** The line under a key result's name: "60.2% → ≥ 70% by Dec 2026", from "Baseline: … Target: …" or the bolded numbers. */
|
|
87
|
+
function lineOf(text, due) {
|
|
88
|
+
const t = String(text || '');
|
|
89
|
+
const b = /\bBaseline:\s*([^.]+?)\.\s/i.exec(t), g = /\bTarget:\s*([^.]+?)(?:\.\s|;|$)/i.exec(t);
|
|
90
|
+
const bt = b && !/^tbd\b/i.test(clean(b[1])) ? clean(b[1]) : '';
|
|
91
|
+
if (bt || g) return clip([bt, g ? clean(g[1]) : ''].filter(Boolean).join(' → '), 110);
|
|
92
|
+
const bold = [...t.matchAll(/\*\*([^*]+)\*\*/g)].map((m) => clean(m[1])).filter((x) => /\d|parity|zero|go\/no-go/i.test(x));
|
|
93
|
+
const by = due ? ' by ' + MON[new Date(Date.parse(due)).getUTCMonth()] + ' ' + new Date(Date.parse(due)).getUTCFullYear() : '';
|
|
94
|
+
return bold.length ? clip(bold.slice(0, 2).join(' → ') + by, 110) : '';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** One OKR document → { title, period, supersedes: [file names], objectives: [{ id, token, title, krs: [{ id, token, title, text, line, source, need, due, proposed, decision }] }] }. PURE. */
|
|
98
|
+
function parse(text, file) {
|
|
99
|
+
const s = String(text || ''), lines = s.split('\n');
|
|
100
|
+
const objectives = [];
|
|
101
|
+
let cur = null, kr = null;
|
|
102
|
+
const close = () => { if (!kr) return; const t = kr.raw.join(' '); kr.text = clean(t); kr.due = dueOf(clean(t)); kr.source = sourceOf(t); kr.line = lineOf(t, kr.due); kr.proposed = kr.proposed || /\bPROPOSED\b/.test(t); kr.decision = /go\/no-go|\bdecided by\b|\bdecision\b/i.test(kr.title + ' ' + t.slice(0, 200)); delete kr.raw; kr = null; };
|
|
103
|
+
for (const line of lines) {
|
|
104
|
+
const o = RE_OBJ.exec(line);
|
|
105
|
+
if (o) { close(); cur = { id: objId(o[1]), token: o[1].replace(/\s+/g, ' '), title: clean(o[2] || ''), krs: [] }; objectives.push(cur); continue; }
|
|
106
|
+
if (/^#{1,6}\s/.test(line) || /^---\s*$/.test(line)) { close(); if (/^#{1,6}\s/.test(line)) cur = null; continue; }
|
|
107
|
+
const k = cur ? RE_KR.exec(line) : null;
|
|
108
|
+
if (k && !/^\s*(\*\*)?\s*(,|;|&|(and|or)\b)/i.test(k[3])) {
|
|
109
|
+
close();
|
|
110
|
+
let rest = k[3], title;
|
|
111
|
+
let body = rest; // the words after the name: where the numbers are
|
|
112
|
+
if (k[1] && /^\s*\*\*/.test(rest)) { body = rest.replace(/^\s*\*\*/, '').replace(/^\s*[—–:.-]?\s*/, ''); title = body.split(/\.\s/)[0]; } // **KR1.2** — name
|
|
113
|
+
else if (k[1]) { rest = rest.replace(/^\s*[—–:.-]?\s*/, ''); const e = rest.indexOf('**'); title = e >= 0 ? rest.slice(0, e) : rest.split(/\.\s/)[0]; body = e >= 0 ? rest.slice(e + 2) : rest; }
|
|
114
|
+
else { body = rest.replace(/^\s*[—–:.-]?\s*/, ''); title = body.split(/\.\s/)[0]; }
|
|
115
|
+
title = clean(title).replace(/^PROPOSED:\s*/i, '').replace(/\.$/, '');
|
|
116
|
+
if (!cur.krs.some((x) => x.id === krId(k[2]))) { kr = { id: krId(k[2]), token: k[2], title: clip(title, 110), proposed: /\bPROPOSED\b/.test(line), raw: [body] }; cur.krs.push(kr); }
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (kr) { if (/^\s*$/.test(line) || /^\s*[-*]\s/.test(line) && !/^\s{2,}/.test(line)) close(); else kr.raw.push(line); }
|
|
120
|
+
}
|
|
121
|
+
close();
|
|
122
|
+
// a host named once in the file is where its relative sources ("/native/login") live
|
|
123
|
+
const host = (/\b([a-z0-9-]+(?:\.[a-z0-9-]+)*\.(?:app|com|io|net|org|sg))\b/i.exec(s) || [])[1] || '';
|
|
124
|
+
for (const ob of objectives) for (const k of ob.krs) k.need = needOf(k.source, host);
|
|
125
|
+
const supersedes = [...s.matchAll(/\bSupersedes\s+`?([\w.-]+\.md)`?/gi)].map((m) => m[1]);
|
|
126
|
+
return { title: clean((/^#\s+(.+)$/m.exec(s) || [])[1] || ''), period: periodOf(s, file), supersedes, objectives: objectives.filter((o) => o.krs.length) };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The OKR documents of a workspace: pmos/planning/okrs/ first (a product repository), else planning/okrs/. Never throws. */
|
|
130
|
+
function read(root) {
|
|
131
|
+
const dirs = [path.join(root, 'pmos', 'planning', 'okrs'), path.join(root, 'planning', 'okrs')];
|
|
132
|
+
for (const dir of dirs) {
|
|
133
|
+
let names;
|
|
134
|
+
try { names = fs.readdirSync(dir).filter((f) => f.endsWith('.md') && !NOT_OKR.has(f)).sort(); } catch { continue; }
|
|
135
|
+
const docs = [];
|
|
136
|
+
for (const f of names) {
|
|
137
|
+
try { const d = parse(fs.readFileSync(path.join(dir, f), 'utf8'), f); if (d.objectives.length) docs.push(Object.assign(d, { file: f })); } catch { /* an unreadable file declares nothing */ }
|
|
138
|
+
}
|
|
139
|
+
if (!docs.length) continue;
|
|
140
|
+
const gone = new Set(docs.flatMap((d) => d.supersedes));
|
|
141
|
+
const live = docs.filter((d) => !gone.has(d.file));
|
|
142
|
+
const use = live.length ? live : docs;
|
|
143
|
+
// one period for the page: the one of the newest document that names one (the files sort by name, so the last is the newest)
|
|
144
|
+
const withP = use.filter((d) => d.period);
|
|
145
|
+
const objectives = [], seen = new Set();
|
|
146
|
+
for (const d of use) for (const o of d.objectives) { const k = o.id; if (seen.has(k)) continue; seen.add(k); objectives.push(o); }
|
|
147
|
+
return { ok: true, files: use.map((d) => d.file), period: withP.length ? withP[withP.length - 1].period : null, objectives, error: null };
|
|
148
|
+
}
|
|
149
|
+
return { ok: false, files: [], period: null, objectives: [], error: 'no OKR file' };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The key result an anchor names: "KR-A1", "O-A.KR-A1", "okrs-q3-2026 → O1.KR1", "KR1.2 — … (pmos/planning/okrs/…v3.md)". A key result id
|
|
153
|
+
* with no objective is matched when exactly one objective declares it. → { o, k } | null. PURE. */
|
|
154
|
+
function matchAnchor(anchor, objectives) {
|
|
155
|
+
const s = String(anchor || '').replace(/\*\*/g, '').trim();
|
|
156
|
+
if (!s || /^(health|domain):/i.test(s)) return null;
|
|
157
|
+
const km = new RegExp('(?:^|[^A-Za-z0-9])' + KR + END).exec(s);
|
|
158
|
+
if (!km) return null;
|
|
159
|
+
const om = new RegExp('(?:^|[^A-Za-z0-9])' + OBJ + END).exec(s.slice(0, km.index + 1));
|
|
160
|
+
const kid = krId(km[1]), oid = om ? objId(om[1]) : null;
|
|
161
|
+
const hits = [];
|
|
162
|
+
for (const o of objectives || []) { if (oid && o.id !== oid) continue; const k = o.krs.find((x) => x.id === kid); if (k) hits.push({ o, k }); }
|
|
163
|
+
return hits.length === 1 ? hits[0] : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The record that reads a key result: its `kr` anchor names the key result, or its title starts with the key result's id. PURE. */
|
|
167
|
+
function readerOf(o, k, goals) {
|
|
168
|
+
const G = goals || [];
|
|
169
|
+
return G.find((g) => { const m = g.kr ? matchAnchor(g.kr, [o]) : null; return m && m.k.id === k.id; })
|
|
170
|
+
|| G.find((g) => new RegExp('^\\s*(?:' + o.token.replace(/[-.]/g, '\\$&') + '[.\\s]*)?' + k.token.replace(/[-.]/g, '\\$&') + END, 'i').test(String(g.title || '')))
|
|
171
|
+
|| null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The model's okrFile: the objectives and their key results, each with its reader (a goal of the model, by id), the initiatives that move it,
|
|
175
|
+
* and what it needs when nothing reads it. Counts for the ✦ line and the All products row. PURE.
|
|
176
|
+
* okr: read(); goals: the model's goals (each may carry `kr`, from its record); initiatives: the board rows (initOf'd); records: { [rid]: record } */
|
|
177
|
+
function build({ okr, goals, initiatives, records, today }) {
|
|
178
|
+
if (!okr || !okr.ok || !okr.objectives.length) return null;
|
|
179
|
+
const G = (goals || []).map((g) => Object.assign({}, g, { kr: (records && records[g.id] && records[g.id].kr) || g.kr || null }));
|
|
180
|
+
const used = new Set();
|
|
181
|
+
const t = Date.parse(String(today || '').slice(0, 10)) || Date.now();
|
|
182
|
+
const P = okr.period ? Object.assign({}, okr.period, { day: t >= okr.period.from && t <= okr.period.to ? Math.round((t - okr.period.from) / DAY) + 1 : 0, days: Math.round((okr.period.to - okr.period.from) / DAY) + 1, fromAt: iso(okr.period.from), toAt: iso(okr.period.to) }) : null;
|
|
183
|
+
const moversOf = new Map();
|
|
184
|
+
for (const r of initiatives || []) { const m = matchAnchor(r.anchor, okr.objectives); if (!m) continue; const key = m.o.id + '::' + m.k.id; if (!moversOf.has(key)) moversOf.set(key, []); moversOf.get(key).push(r); }
|
|
185
|
+
const objectives = okr.objectives.map((o) => ({
|
|
186
|
+
id: o.id, token: o.token, title: o.title,
|
|
187
|
+
krs: o.krs.map((k) => {
|
|
188
|
+
const g = readerOf(o, k, G); if (g) used.add(g.id);
|
|
189
|
+
const read = !!(g && g.readings > 0);
|
|
190
|
+
return { key: o.id + '::' + k.id, token: k.token, title: k.title, line: k.line, due: k.due, proposed: k.proposed, decision: k.decision,
|
|
191
|
+
goal: g ? g.id : null, read, need: read || k.decision ? '' : g && g.recipe ? '' : k.need, source: k.source, moves: moversOf.get(o.id + '::' + k.id) || [] };
|
|
192
|
+
}),
|
|
193
|
+
}));
|
|
194
|
+
const krs = objectives.flatMap((o) => o.krs);
|
|
195
|
+
const needs = {};
|
|
196
|
+
for (const k of krs) if (!k.read && k.need) needs[k.need] = (needs[k.need] || 0) + 1;
|
|
197
|
+
return { files: okr.files, period: P, objectives, total: krs.length, read: krs.filter((k) => k.read).length, needs, readers: [...used] };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = { read, parse, periodOf, dueOf, sourceOf, needOf, matchAnchor, readerOf, build };
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* report-acts.js — the one ✓ of a report that waits for the PM, in Reports and dashboards (Reports and dashboards redesign R, frame RgR1Panel;
|
|
3
|
+
* brief /mnt/project-files/intelligence-grill/brief-E-R-build.md step 9).
|
|
4
|
+
*
|
|
5
|
+
* defTitleOf(def) → the definition's title as a report name: "Daily report definition" → "Daily report". PURE
|
|
6
|
+
* reportAboutOf(text, boards, here) → { ws, name } of the one OTHER product a report names, or null. PURE
|
|
7
|
+
* reportActOf({ id, setup, rows, def, about, sha }) → { act, label, … } | null the report's one press. PURE
|
|
8
|
+
* reportActsOf(facts) → { [report id]: act } for every report of the model, and { names, about }. PURE
|
|
9
|
+
* leadOf(text) the opening sentence, past a line that only says the definition is not confirmed yet. PURE
|
|
10
|
+
*
|
|
11
|
+
* WHY. The grill (3 Nov, on 0.514.0) found the five drafts that wait for the PM at the bottom of the panel, under "Yours", named by their id
|
|
12
|
+
* ("daily"), previewed by the warning "this definition is a draft", and with no press that settles them. Each waiting report now has ONE ✓,
|
|
13
|
+
* the act that unblocks it, in this order:
|
|
14
|
+
* move the report is about another product than the one it sits in (Scarlet assessment in the IDPF workspace) → "Move to Scarlet"
|
|
15
|
+
* confirm its draft follows a definition an agent proposed and nobody has confirmed (status: draft, no setup) → "Confirm the definition"
|
|
16
|
+
* issue a draft waits and its definition is confirmed → "Issue" (POST /api/report/issue with the sha of the file as read)
|
|
17
|
+
* resume a set-up report is paused → "Resume"
|
|
18
|
+
* A report with none of these is not waiting for the PM and gets no ✓ (zero is silence).
|
|
19
|
+
*
|
|
20
|
+
* WHAT A NAME MATCHES. A product is named when its board name, as a whole word of four letters or more, is in the report's name or purpose and
|
|
21
|
+
* the product it sits in is not named too. Two other products named → no move: the report does not say which one it is about.
|
|
22
|
+
*/
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
const RS = () => require('./report-setups.js'); // read late: report-setups is wide, and this file only writes through its one writer
|
|
26
|
+
const refuse = (status, error) => ({ ok: false, status, error });
|
|
27
|
+
const clean = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
|
|
28
|
+
const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
29
|
+
|
|
30
|
+
/** "Daily report definition" → "Daily report"; "Scarlet assessment report definition" → "Scarlet assessment report". '' when there is none. PURE. */
|
|
31
|
+
function defTitleOf(def) {
|
|
32
|
+
const t = clean(def && def.title).replace(/^["']|["']$/g, '');
|
|
33
|
+
if (!t || (def && t === def.id)) return '';
|
|
34
|
+
return t.replace(/\s+(definition|template)$/i, '').replace(/\s+template$/i, '').trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A product's name in `text` as a whole word (case-insensitive). Names shorter than four letters never match: "PI" is not a product name in prose. */
|
|
38
|
+
function mentionsProduct(text, name) {
|
|
39
|
+
const n = clean(name);
|
|
40
|
+
if (n.length < 4) return false;
|
|
41
|
+
return new RegExp('(^|[^A-Za-z0-9])' + escRe(n) + '(?![A-Za-z0-9])', 'i').test(String(text || ''));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The one other product a report is about. boards: [{ ws, name }]; here: the ws it sits in. → { ws, name } | null. PURE. */
|
|
45
|
+
function reportAboutOf(text, boards, here) {
|
|
46
|
+
const B = (boards || []).filter((b) => b && b.ws && b.name);
|
|
47
|
+
const mine = B.find((b) => b.ws === here);
|
|
48
|
+
if (mine && mentionsProduct(text, mine.name)) return null;
|
|
49
|
+
const hit = B.filter((b) => b.ws !== here && mentionsProduct(text, b.name));
|
|
50
|
+
return hit.length === 1 ? { ws: hit[0].ws, name: clean(hit[0].name) } : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A report's one press. rows: its files, newest first ({ slot, state, path, archived }); setup: its setup or null; def: its definition or null.
|
|
54
|
+
* → { act: 'move'|'confirm'|'issue'|'resume', label, … } | null. PURE. */
|
|
55
|
+
function reportActOf({ id, setup, rows, def, about, sha }) {
|
|
56
|
+
const live = (rows || []).filter((r) => r && !r.archived);
|
|
57
|
+
const draft = live.find((r) => r.state !== 'issued') || null;
|
|
58
|
+
const issued = live.some((r) => r.state === 'issued');
|
|
59
|
+
if (about && setup && !issued) return { act: 'move', label: 'Move to ' + about.name, to: about.ws, toName: about.name, id };
|
|
60
|
+
if (setup && setup.paused) return { act: 'resume', label: 'Resume', id };
|
|
61
|
+
if (!draft) return null;
|
|
62
|
+
// an agent-written definition (the daily, weekly and monthly skills write status: draft and only the PM confirms it). A report the PM set up
|
|
63
|
+
// wrote its own definition with that press, so its draft is issued
|
|
64
|
+
if (!setup && def && def.status === 'draft') return { act: 'confirm', label: 'Confirm the definition', id, slot: draft.slot };
|
|
65
|
+
return { act: 'issue', label: 'Issue', id, slot: draft.slot, sha: sha || null, path: draft.path || '' };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Every report's press and display name. f: { rows, setups, defs, boards, here, shas }. → { acts, names, about }. PURE. */
|
|
69
|
+
function reportActsOf(f) {
|
|
70
|
+
const rows = (f.rows || []).filter(Boolean), setups = (f.setups || []).filter(Boolean), defs = new Map((f.defs || []).filter(Boolean).map((d) => [d.id, d]));
|
|
71
|
+
const ids = [...new Set(setups.map((s) => s.id).concat(rows.map((r) => r.cadence)))];
|
|
72
|
+
const acts = {}, nameOf = {}, about = {};
|
|
73
|
+
for (const id of ids) {
|
|
74
|
+
const setup = setups.find((s) => s.id === id) || null, def = defs.get(id) || null, mine = rows.filter((r) => r.cadence === id);
|
|
75
|
+
const t = defTitleOf(def);
|
|
76
|
+
if (!setup && t) nameOf[id] = t;
|
|
77
|
+
const a = setup ? reportAboutOf([setup.name, setup.purpose].join(' · '), f.boards, f.here) : null;
|
|
78
|
+
if (a) about[id] = a;
|
|
79
|
+
const draft = mine.find((r) => !r.archived && r.state !== 'issued');
|
|
80
|
+
const act = reportActOf({ id, setup, rows: mine, def, about: a, sha: draft && f.shas ? f.shas[draft.path] : null });
|
|
81
|
+
if (act) acts[id] = act;
|
|
82
|
+
}
|
|
83
|
+
return { acts, names: nameOf, about };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A line that only says the definition is not confirmed: the daily skill puts it first in the Headline (report-daily.skill step 6). */
|
|
87
|
+
const WARN = /\bdefinition\b[^.]*\b(draft|unconfirmed|not (yet )?confirmed|proposed|waits? for your)\b|\b(draft|unconfirmed|proposed)\b[^.]*\bdefinition\b/i;
|
|
88
|
+
/** The opening sentence of a report, past the definition warning: its headline is the preview. `lead` is the caller's leadOf (insights-premium). PURE. */
|
|
89
|
+
function leadPastWarning(text, lead) {
|
|
90
|
+
const first = lead(text);
|
|
91
|
+
if (!first || !WARN.test(first)) return first;
|
|
92
|
+
const t = String(text || ''), at = t.indexOf(first.replace(/…$/, '').slice(0, 40));
|
|
93
|
+
// the same paragraph's next sentence, else the next paragraph
|
|
94
|
+
if (at >= 0) {
|
|
95
|
+
const rest = t.slice(at).replace(/^[^]*?[.!?](\s+|$)/, '');
|
|
96
|
+
const next = lead('\n\n' + rest);
|
|
97
|
+
if (next && !WARN.test(next)) return next;
|
|
98
|
+
}
|
|
99
|
+
return '';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── the writes of the ✓ (their routes: lib/routes-report-setups.js confirm and move) ─────────────────────────────────────────────
|
|
103
|
+
/** Confirm a definition (Reports and dashboards redesign R): the PM's ✓ turns `status: draft` into `status: stable` in its frontmatter. Only
|
|
104
|
+
* that line changes. A definition with no frontmatter status, or one confirmed already, is left as it is. → { ok, path, sha, unchanged? } */
|
|
105
|
+
function confirmDefinition(root, id, opts = {}) {
|
|
106
|
+
if (!RS().ID_RE.test(String(id || ''))) return refuse(400, 'the id is lower-case letters, digits and "-"');
|
|
107
|
+
const file = RS().definitionFile(root, id), rel = require('./report-draft.js').definitionPath(root, id);
|
|
108
|
+
let cur;
|
|
109
|
+
try { cur = fs.readFileSync(file, 'utf8'); } catch { return refuse(404, `there is no definition ${rel}`); }
|
|
110
|
+
const m = /^(---\r?\n)([\s\S]*?)(\r?\n---)/.exec(cur);
|
|
111
|
+
if (!m) return refuse(409, `${rel} has no frontmatter, so there is no status to confirm`);
|
|
112
|
+
if (!/^status:\s*["']?draft["']?\s*$/m.test(m[2])) return { ok: true, path: rel, sha: RS().shaOf(cur), unchanged: true };
|
|
113
|
+
const next = m[1] + m[2].replace(/^status:\s*["']?draft["']?\s*$/m, 'status: stable') + m[3] + cur.slice(m[0].length);
|
|
114
|
+
const tmp = file + '.tmp-' + process.pid;
|
|
115
|
+
try { fs.writeFileSync(tmp, next); fs.renameSync(tmp, file); } catch (e) { try { fs.unlinkSync(tmp); } catch { /* none */ } return refuse(500, 'could not write the definition; nothing changed: ' + String((e && e.message) || e).slice(0, 160)); }
|
|
116
|
+
void opts;
|
|
117
|
+
return { ok: true, path: rel, sha: RS().shaOf(next) };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Copy a folder's files, recursively, refusing to overwrite. → the repository-relative paths written under `to`. */
|
|
121
|
+
function copyReportTree(from, to) {
|
|
122
|
+
const out = [];
|
|
123
|
+
fs.mkdirSync(to, { recursive: true });
|
|
124
|
+
for (const e of fs.readdirSync(from, { withFileTypes: true })) {
|
|
125
|
+
const a = path.join(from, e.name), b = path.join(to, e.name);
|
|
126
|
+
if (e.isDirectory()) out.push(...copyReportTree(a, b));
|
|
127
|
+
else if (e.isFile()) { fs.copyFileSync(a, b, fs.constants.COPYFILE_EXCL); out.push(b); }
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Move a set-up report to the product it is about (Reports and dashboards redesign R, "✦ Move to Scarlet"): its definition, its drafts and its
|
|
134
|
+
* setup line go to `toRoot`, and it is removed here. A report with an ISSUED issue is refused (409): an issued report is never moved or deleted,
|
|
135
|
+
* it stays where it was issued. The id must be free in the target. Everything is copied before anything is removed here, so a failed copy
|
|
136
|
+
* leaves this workspace as it was (and removes what it copied). → { ok, id, moved: [rel] } | { ok: false, status, error }
|
|
137
|
+
*/
|
|
138
|
+
function moveReportTo(root, id, toRoot, opts = {}) {
|
|
139
|
+
const abs = path.resolve(String(root)), to = path.resolve(String(toRoot || ''));
|
|
140
|
+
if (!toRoot || to === abs) return refuse(400, 'say which product to move it to');
|
|
141
|
+
const file = RS().ledgerFile(abs), st = RS().fold(RS().readLines(file)), cur = st.setups.get(id);
|
|
142
|
+
if (!cur) return refuse(404, `no recurring report ${String(id).slice(0, 40)}`);
|
|
143
|
+
const docs = (require('./report-needs.js').reportsOf(abs).reports || []).filter((r) => r.cadence === id);
|
|
144
|
+
if (docs.some((r) => r.issued)) return refuse(409, `${cur.name} has issued reports. An issued report stays where it was issued, so it was not moved`);
|
|
145
|
+
const tst = RS().fold(RS().readLines(RS().ledgerFile(to)));
|
|
146
|
+
const srcDef = RS().definitionFile(abs, id), dstDef = RS().definitionFile(to, id);
|
|
147
|
+
const srcDir = path.join(RS().stateDir(abs), 'reports', id), dstDir = path.join(RS().stateDir(to), 'reports', id);
|
|
148
|
+
if (tst.setups.has(id) || fs.existsSync(dstDef) || fs.existsSync(dstDir)) return refuse(409, `${id} is taken in that product already. Nothing was moved`);
|
|
149
|
+
const now = opts.now == null ? new Date() : new Date(opts.now);
|
|
150
|
+
const written = [];
|
|
151
|
+
try {
|
|
152
|
+
if (fs.existsSync(srcDef)) {
|
|
153
|
+
const t = fs.readFileSync(srcDef, 'utf8').replace(/^(product:\s*).*$/m, '$1' + JSON.stringify(path.basename(to)));
|
|
154
|
+
fs.mkdirSync(path.dirname(dstDef), { recursive: true }); fs.writeFileSync(dstDef, t, { flag: 'wx' }); written.push(dstDef);
|
|
155
|
+
}
|
|
156
|
+
if (fs.existsSync(srcDir)) written.push(...copyReportTree(srcDir, dstDir));
|
|
157
|
+
} catch (e) {
|
|
158
|
+
for (const w of written.reverse()) { try { fs.unlinkSync(w); } catch { /* gone */ } }
|
|
159
|
+
try { fs.rmSync(dstDir, { recursive: true, force: true }); } catch { /* gone */ }
|
|
160
|
+
return refuse(500, 'the copy failed, so nothing was moved: ' + String((e && e.message) || e).slice(0, 160));
|
|
161
|
+
}
|
|
162
|
+
// the setup line in the target, as it is here, with its new home; then the schedule moves and it is removed here
|
|
163
|
+
const line = { ev: 'setup' };
|
|
164
|
+
for (const k of ['id', 'name', 'purpose', 'audience', 'source', 'output', 'schedule', 'sources', 'sections', 'learned_from', 'fixes', 'last_run', 'def_sha', 'from', 'storyline', 'ready']) if (cur[k] !== undefined) line[k] = cur[k];
|
|
165
|
+
line.product = path.basename(to); line.ws = opts.toWsId || null; line.moved_from = path.basename(abs);
|
|
166
|
+
RS().append(RS().ledgerFile(to), line, { now, by: opts.by });
|
|
167
|
+
if (cur.status === 'paused') RS().append(RS().ledgerFile(to), { ev: 'pause', id }, { now, by: opts.by });
|
|
168
|
+
if (cur.source === 'work') {
|
|
169
|
+
RS().unregisterSchedule(opts.scheduleStore, cur, opts.wsId);
|
|
170
|
+
if (cur.status !== 'paused') RS().registerSchedule(opts.scheduleStore, Object.assign({}, cur, { product: line.product }), { wsId: opts.toWsId, lastRun: cur.last_run, now });
|
|
171
|
+
}
|
|
172
|
+
RS().append(file, { ev: 'remove', id }, { now, by: opts.by });
|
|
173
|
+
try { if (fs.existsSync(srcDef)) fs.unlinkSync(srcDef); } catch { /* the copy is in place; a stale definition here is harmless */ }
|
|
174
|
+
try { fs.rmSync(srcDir, { recursive: true, force: true }); } catch { /* the drafts stay here too */ }
|
|
175
|
+
return { ok: true, id, moved: written.map((w) => path.relative(to, w)) };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The product page's ✦ line is Intelligence's (RgR4Make, "one brain"). The grill found the product page saying "2 things are behind: KR-C4,
|
|
179
|
+
* KR-A1 · weekly update ready" while Intelligence said "I have not read a goal yet". Both now read the same model's line (lib/insights-premium.js
|
|
180
|
+
* okrOf → model.okr.line); "Your weekly update is ready to send" follows it only in a week with news. Without that line, the dashboard's own
|
|
181
|
+
* line stays. PURE. */
|
|
182
|
+
function productLineOfIntel(intel, { deskReady, news, fallback }) {
|
|
183
|
+
const L = intel && intel.okr && intel.okr.line;
|
|
184
|
+
if (!L || !L.head) return fallback;
|
|
185
|
+
const rest = [L.sub || '', deskReady && news ? 'Your weekly update is ready to send.' : ''].filter(Boolean).join(' ');
|
|
186
|
+
return { lead: L.head, rest, tone: L.tone === 'crit' || L.tone === 'behind' ? 'warn' : 'calm' };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
module.exports = { defTitleOf, reportAboutOf, reportActOf, reportActsOf, leadPastWarning, WARN, confirmDefinition, moveReportTo, productLineOfIntel };
|