create-pmos 0.513.0 → 0.515.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 +27 -127
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/briefing-premium-insights-test.js +12 -12
- package/workspace/briefing-premium-metrics-test.js +1 -1
- package/workspace/changelog.json +26 -0
- package/workspace/guide/index.md +2 -1
- package/workspace/guide/intelligence-okr-goals.md +39 -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/routes-boards.js +18 -6
- package/workspace/lib/routes-insights-premium.js +27 -2
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/cozy-insights.css +26 -1
- package/workspace/public/pm/premium-insights.js +167 -32
- package/workspace/test-files.json +1 -0
|
@@ -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 };
|
|
@@ -477,15 +477,27 @@ const gapFields = (spans) => ({ gaps: holdSpans.planGaps(spans, 14), stageSpans:
|
|
|
477
477
|
const minDay = (a, b) => (!a ? b || null : !b ? a : a < b ? a : b);
|
|
478
478
|
const maxDay = (a, b) => (!a ? b || null : !b ? a : a > b ? a : b);
|
|
479
479
|
|
|
480
|
-
/** batch 26 item 5: whether an initiative stands in its workflow's terminal stage
|
|
481
|
-
*
|
|
480
|
+
/** batch 26 item 5: whether an initiative stands in its workflow's terminal stage. Read from the workflow first: the stage is in the Done
|
|
481
|
+
* category, or it is the last stage (stages after it that only end work, such as Won't do or Cancelled, do not count). A stage the workflow
|
|
482
|
+
* does not hold (a Jira-read status such as "Closed") is terminal when its name says so, as tkStatus reads a ticket (done, closed, resolved,
|
|
483
|
+
* released). Follow-up (CAS rows still late after #1202): the board column branch ignored the Done category and any stage outside the list. */
|
|
484
|
+
const TERMINAL_RE = /\b(done|closed|resolved|released|complete|completed|finished)\b/i;
|
|
485
|
+
const END_RE = /\b(won'?t do|cancel+ed|archived|abandoned|dropped|rejected)\b/i;
|
|
486
|
+
const isDoneCat = (c) => String(c || '').toLowerCase() === 'done';
|
|
482
487
|
function closedOf(row, wx) {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
488
|
+
const cur = (wx && wx.stages || []).find((x) => x.key === wx.stage);
|
|
489
|
+
if (cur && (wx.own || cur.name === row.stage)) {
|
|
490
|
+
if (isDoneCat(cur.cat) || (cur.of > 1 && cur.n === cur.of)) return true;
|
|
491
|
+
if (wx.own) return false;
|
|
486
492
|
}
|
|
493
|
+
if (isDoneCat(row.cat)) return true;
|
|
487
494
|
const wf = Array.isArray(row.workflow) ? row.workflow : [];
|
|
488
|
-
|
|
495
|
+
const name = String(row.stage || '');
|
|
496
|
+
const i = wf.findIndex((x) => x && x.name === name);
|
|
497
|
+
if (i < 0) return TERMINAL_RE.test(name);
|
|
498
|
+
if (isDoneCat(wf[i].cat)) return true;
|
|
499
|
+
// the last stage, not counting the ones after it that only end work
|
|
500
|
+
return wf.length > 1 && wf.slice(i + 1).every((x) => END_RE.test(String(x && x.name || '')));
|
|
489
501
|
}
|
|
490
502
|
function boardRow(board, base, folded, lib) {
|
|
491
503
|
const s = B.stageOf(board, base.id, base.reading, folded);
|
|
@@ -21,6 +21,8 @@ const { recordsDirFor } = require('./records-dir.js');
|
|
|
21
21
|
const reports = require('./routes-reports.js');
|
|
22
22
|
const setups = require('./routes-report-setups.js');
|
|
23
23
|
const PD = require('./premium-dashboards.js');
|
|
24
|
+
/** The PMOS repository's own workspace: the agent measures (merges, corrections) are read only there. */
|
|
25
|
+
const isSelf = (ctx, b) => { try { return !!(ctx.selfRoot && b && require('path').resolve(ctx.getWs(b.ws).root) === require('path').resolve(ctx.selfRoot)); } catch { return false; } };
|
|
24
26
|
|
|
25
27
|
/** GET `pathAndQuery` from a route module's own handler, as the Board page's route does, and return { status, body }. */
|
|
26
28
|
async function via(mod, pathAndQuery, req, ctx) {
|
|
@@ -45,6 +47,22 @@ function leadsOf(ctx, ws, list) {
|
|
|
45
47
|
}
|
|
46
48
|
/** The product's open drafts: the ✦ line's ✓✓ count (lib/metric-drafts.js). */
|
|
47
49
|
const openDrafts = (dir, ws) => { const MD = require('./metric-drafts.js'), S = MD.read(dir).scopes.get(MD.keyOf('product', ws)); return S ? [...S.rows.values()].filter((r) => r.state === 'open').length : 0; };
|
|
50
|
+
/** The health budget registry (D59): planning/okrs/health-budgets.md, or pmos/planning/okrs/ in a product repository (the kit layout). Without
|
|
51
|
+
* the second path every product said "not in the budget registry" although it declares one (Intelligence grill, 3 Nov). null when neither reads. */
|
|
52
|
+
function readBudgets(root) {
|
|
53
|
+
const P = require('path'), IG = require('./insights-goals.js');
|
|
54
|
+
for (const f of [P.join(root, 'planning', 'okrs', 'health-budgets.md'), P.join(root, 'pmos', 'planning', 'okrs', 'health-budgets.md')]) {
|
|
55
|
+
try { return IG.parseBudgets(require('fs').readFileSync(f, 'utf8')); } catch { /* the next place */ }
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
/** Each initiative's D57 promise: { [id]: { metric, horizon, disposition } } from lib/outcomes.js scan. */
|
|
60
|
+
function promisesOf(S) {
|
|
61
|
+
const out = {};
|
|
62
|
+
for (const it of (S && S.subjects) || []) out[it.id] = { metric: it.metric || '', horizon: it.horizon || '', disposition: null };
|
|
63
|
+
for (const it of (S && S.disposed) || []) if (out[it.id]) out[it.id].disposition = it.disposition;
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
48
66
|
const qs = (o) => '?' + Object.entries(o).filter(([, v]) => v != null && v !== '').map(([k, v]) => k + '=' + encodeURIComponent(v)).join('&');
|
|
49
67
|
|
|
50
68
|
module.exports = {
|
|
@@ -91,7 +109,14 @@ module.exports = {
|
|
|
91
109
|
if (pick) {
|
|
92
110
|
const root = require('path').resolve(ctx.getWs(pick.ws).root);
|
|
93
111
|
try { ins = require('./insights-records.js').read(root); } catch { missing.push('insights records'); }
|
|
94
|
-
|
|
112
|
+
budgets = readBudgets(root);
|
|
113
|
+
}
|
|
114
|
+
// Intelligence redesign A: the OKR file is the goal list (lib/okr-goals.js), and each initiative's promise is its D57 outcome (lib/outcomes.js scan)
|
|
115
|
+
let okr = null, promises = null;
|
|
116
|
+
if (pick) {
|
|
117
|
+
const root = require('path').resolve(ctx.getWs(pick.ws).root);
|
|
118
|
+
try { okr = require('./okr-goals.js').read(root); } catch { okr = null; }
|
|
119
|
+
try { promises = promisesOf(require('./outcomes.js').scan(root)); } catch { promises = null; }
|
|
95
120
|
}
|
|
96
121
|
// premium-uncovered-features (IsMenus): the product picker says how many goals each product's workspace records; a workspace that cannot be read says none
|
|
97
122
|
const counts = new Map();
|
|
@@ -110,7 +135,7 @@ module.exports = {
|
|
|
110
135
|
watch,
|
|
111
136
|
now: Date.now(), board: pick ? { ws: pick.ws, id: pick.id, name: pick.name } : { ws, id: null, name: 'Intelligence' }, boards: all.map((x) => Object.assign({ ws: x.ws, id: x.id, name: x.name }, goalsIn(x.ws))),
|
|
112
137
|
outcomes, reports: reportsOk ? rp.body.reports || [] : [], reportsOk, missing, saved, mine,
|
|
113
|
-
setups: setupsOk ? su.body : null, pinned, leads, archived, initiatives, goalMeta, ins, budgets,
|
|
138
|
+
setups: setupsOk ? su.body : null, pinned, leads, archived, initiatives, goalMeta, ins, budgets, okr, promises, self: isSelf(ctx, pick),
|
|
114
139
|
});
|
|
115
140
|
// batch 16 item 1: each ✦ strip finding about a goal that is behind carries one drafted move (lib/insight-moves.js), done ones marked
|
|
116
141
|
if (pick) {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.515.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.515.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@anthropic-ai/sdk": "^1.3.0",
|
package/workspace/package.json
CHANGED
|
@@ -75,7 +75,7 @@ body.cz { padding: 0; }
|
|
|
75
75
|
.ins-tbl { display: flex; flex-direction: column; border: 1px solid var(--cz-hair); border-radius: 18px; overflow: hidden; background: var(--cz-card); }
|
|
76
76
|
.ins-tr { display: grid; align-items: center; column-gap: 16px; padding: 0 16px; }
|
|
77
77
|
.ins-cols { grid-template-columns: minmax(210px, 270px) 190px 230px 170px minmax(0, 1fr); }
|
|
78
|
-
.ins-ocols { grid-template-columns: minmax(200px,
|
|
78
|
+
.ins-ocols { grid-template-columns: minmax(200px, 250px) minmax(0, 1.3fr) minmax(0, .7fr) minmax(0, 1fr) minmax(0, 1.2fr) 96px; } /* Promised · Check · Got · Verdict (IgA3Outcomes) */
|
|
79
79
|
.ins-dcols { grid-template-columns: minmax(200px, 270px) 110px 150px minmax(0, 1fr) 150px 112px; }
|
|
80
80
|
.ins-lcols { grid-template-columns: 40px minmax(0, 1fr) 170px 200px 190px 90px; }
|
|
81
81
|
.ins-th { background: var(--cz-well); font-size: var(--fs-label); font-weight: var(--fw-strong); color: var(--cz-ink2); min-height: 36px; line-height: var(--lh-label); }
|
|
@@ -665,6 +665,7 @@ body.ins-trv #goalTbl { border: 0; background: none; border-radius: 0; overflow:
|
|
|
665
665
|
/* Reports and dashboards (⋯): the old Library in a side panel */
|
|
666
666
|
.ins-lib { width: min(560px, 100%); }
|
|
667
667
|
.ins-lib .ins-sb { display: flex; flex-direction: column; gap: 12px; }
|
|
668
|
+
.ins-lib .ins-sb > * { flex-shrink: 0; } /* the panel scrolls; a table never shrinks under the block after it (Needs you) */
|
|
668
669
|
.ins-lib .ins-dgrid { grid-template-columns: minmax(0, 1fr); }
|
|
669
670
|
/* motion (design principle 12 as amended for Intelligence): a soft rise as each section arrives, once; nothing loops; reduced motion turns it off */
|
|
670
671
|
.ins > .ins-strip, .ins > .ins-ns, .ins > .ins-dr, .ins > .ins-pane { animation: ins-rise .45s cubic-bezier(.2, .8, .2, 1) both; }
|
|
@@ -930,3 +931,27 @@ body.ins-trv #pGoals .ins-trstrip { display: flex; } /* the narrow form hides
|
|
|
930
931
|
.ins-mrow b { margin-left: auto; }
|
|
931
932
|
.ins-mrow .cz-pill { margin-left: auto; }
|
|
932
933
|
.ins-trstrip .t { display: block; -webkit-line-clamp: unset; overflow: visible; }
|
|
934
|
+
|
|
935
|
+
/* ── Intelligence redesign A: the OKR file is the goal list (IgA1Open, IgA2Goal, IgA3Outcomes, IgA7Phone) ── */
|
|
936
|
+
.ins-krt { font-size: var(--fs-badge); font-weight: var(--fw-regular); color: var(--cz-ink3); margin-right: 6px; line-height: var(--lh-badge); }
|
|
937
|
+
.ins-need { display: inline-flex; align-items: center; gap: 6px; }
|
|
938
|
+
.ins-chk { display: inline-flex; align-items: center; gap: 6px; }
|
|
939
|
+
.ins-krcon { display: flex; align-items: center; gap: 10px; background: var(--cz-bluebg); border-radius: 12px; padding: 8px 10px 8px 12px; margin: 10px 0; font-size: var(--fs-meta); line-height: var(--lh-meta); color: var(--cz-ink2); }
|
|
940
|
+
.ins-krcon .sp { color: var(--cz-blueink); display: inline-flex; }
|
|
941
|
+
.ins-krcon .sp svg { width: 14px; height: 14px; }
|
|
942
|
+
.ins-krcon .cz-ib { margin-left: auto; }
|
|
943
|
+
.ins-cfold { display: block; border: 1px solid var(--cz-hair); border-radius: 18px; background: var(--cz-card); padding: 11px 14px; }
|
|
944
|
+
.ins-cfold > summary { cursor: pointer; font-size: var(--fs-row); line-height: var(--lh-row); }
|
|
945
|
+
.ins-cfold[open] > summary { margin-bottom: 8px; }
|
|
946
|
+
.ins-cfold .ins-card { margin-top: 8px; }
|
|
947
|
+
.ins-libfold { display: block; border: 1px solid var(--cz-hair); border-radius: 16px; padding: 10px 14px; margin: 8px 0; }
|
|
948
|
+
.ins-libfold > summary { cursor: pointer; font-size: var(--fs-row); line-height: var(--lh-row); }
|
|
949
|
+
.ins-libfold[open] > summary { margin-bottom: 8px; }
|
|
950
|
+
.ins-pcols { grid-template-columns: minmax(160px, 220px) minmax(0, 1.2fr) 130px 70px 110px minmax(0, 1.6fr); }
|
|
951
|
+
.ins-allp { margin-bottom: 14px; }
|
|
952
|
+
.ins-allp .ins-row { cursor: pointer; }
|
|
953
|
+
.ins-pav { width: 26px; height: 26px; border-radius: 50%; background: var(--cz-well2); color: var(--cz-ink2); font-size: var(--fs-badge); font-weight: var(--fw-strong); display: inline-flex; align-items: center; justify-content: center; flex: none; }
|
|
954
|
+
@media (max-width: 720px) {
|
|
955
|
+
.ins-row.ins-pcols { grid-template-columns: minmax(0, 1fr); row-gap: 6px; border: 1px solid var(--cz-hair); border-radius: 18px; background: var(--cz-card); padding: 12px 14px; }
|
|
956
|
+
.ins-allp .ins-th { display: none; }
|
|
957
|
+
}
|