create-pmos 0.252.0 → 0.254.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 +1 -1
- 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/scripts/workflow-state.js +6 -1
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/briefing-premium-board-test.js +275 -0
- package/workspace/briefing-premium-initiative-test.js +304 -0
- package/workspace/briefing-premium-products-test.js +164 -0
- package/workspace/briefing-premium-test.js +438 -0
- package/workspace/briefing-premium-work-test.js +281 -0
- package/workspace/fixtures/board-premium-facts.js +38 -0
- package/workspace/fixtures/briefing-premium-facts.js +32 -0
- package/workspace/fixtures/initiative-premium-facts.js +42 -0
- package/workspace/fixtures/my-work-premium-facts.js +31 -0
- package/workspace/guide/briefing-premium.md +151 -0
- package/workspace/guide/index.md +1 -0
- package/workspace/lib/board-premium.js +133 -0
- package/workspace/lib/briefing-premium-agent.js +134 -0
- package/workspace/lib/briefing-premium-products.js +121 -0
- package/workspace/lib/briefing-premium-writes.js +138 -0
- package/workspace/lib/briefing-premium.js +581 -0
- package/workspace/lib/engine/workflow-state.js +6 -1
- package/workspace/lib/headless.js +1 -1
- package/workspace/lib/initiative-premium.js +147 -0
- package/workspace/lib/my-work-premium.js +151 -0
- package/workspace/lib/route-modules.js +4 -0
- package/workspace/lib/routes-board-premium.js +61 -0
- package/workspace/lib/routes-briefing-premium.js +115 -0
- package/workspace/lib/routes-initiative-premium.js +68 -0
- package/workspace/lib/routes-my-work-premium.js +31 -0
- package/workspace/lib/settings-store.js +3 -0
- package/workspace/lib/vendor/scripts/workflow-state.js +6 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/premium-board.js +180 -0
- package/workspace/public/pm/premium-initiative.js +336 -0
- package/workspace/public/pm/premium-shell.js +184 -0
- package/workspace/public/pm/premium-work.js +198 -0
- package/workspace/public/pm/premium.css +1212 -0
- package/workspace/public/pm/premium.js +272 -0
- package/workspace/public/premium-board.html +73 -0
- package/workspace/public/premium-initiative.html +46 -0
- package/workspace/public/premium-work.html +84 -0
- package/workspace/public/premium.html +69 -0
- package/workspace/public/vendor/geist-mono-var.woff2 +0 -0
- package/workspace/public/vendor/geist-var.woff2 +0 -0
- package/workspace/server.js +5 -3
- package/workspace/test-files.json +14 -0
- package/workspace/test-lists/briefing-premium.json +19 -0
- package/workspace/third-party/README.md +12 -0
- package/workspace/third-party/geist-OFL.LICENSE +93 -0
- package/workspace/third-party/geist-mono-OFL.LICENSE +93 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* lib/initiative-premium.js — the premium initiative page's model, built from what the production Initiatives sheet reads (initiative briefing-premium-initiative).
|
|
3
|
+
*
|
|
4
|
+
* build(facts) → model PURE. facts = { now, board, boards, row, sheet, docs, items, missing[], pending }
|
|
5
|
+
* peekModel() → the last model built, for the agent to find the initiative in (null before the first)
|
|
6
|
+
*
|
|
7
|
+
* facts come from the routes of lib/routes-boards.js, lib/routes-initiatives.js and lib/routes-core-docs.js:
|
|
8
|
+
* row GET /api/boards/initiative → .row { id, title, lane, stage, cat, owner, anchor, horizon, status, start, end, progress, warnings, updated, stageSource, workflow }
|
|
9
|
+
* sheet GET /api/initiatives/<id> → { initiative: { health, progress, display }, stepper: [{ id, label, state, owed }], waiting, files, derived }
|
|
10
|
+
* docs GET /api/core-docs?level=initiative → { summary, docs: [{ key, title, for, by, kind, st, when, open: { path }, act, running }] }
|
|
11
|
+
* ladder the workflow engine's ladder for this initiative (console-state.ladder → row): { steps: [{ key, status, evidence, rule }], next: { step, why, action } }
|
|
12
|
+
* boards [{ ws, id, name }] and siblings [{ id, title }]: the switchers of the crumb
|
|
13
|
+
* items GET /api/boards/items → .items [{ key, kind, src, title, status, blocked, owner, ini, start, end }] for the board; only this initiative's are used
|
|
14
|
+
*
|
|
15
|
+
* THE RULE THE FILE FOLLOWS (as the other premium models): a figure, chip, bar or date is made only from a field of a record. An owner or a finish date no
|
|
16
|
+
* record holds is not drawn. The repository records no result for each PRD requirement, so a requirement is drawn with no state of its own and the
|
|
17
|
+
* initiative's progress is said once. The mockup's description line, its "PMOS can help" suggestions and its demo tasks have no record and are not built. */
|
|
18
|
+
|
|
19
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
20
|
+
const hhmm = (ms) => { const d = new Date(ms); return pad(d.getHours()) + ':' + pad(d.getMinutes()); };
|
|
21
|
+
const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
22
|
+
const isoDay = (v) => { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(v || '')); return m ? new Date(+m[1], +m[2] - 1, +m[3]) : null; };
|
|
23
|
+
const shortDate = (v) => { const d = isoDay(v); return d ? d.getDate() + ' ' + MON[d.getMonth()] : ''; };
|
|
24
|
+
const plural = (n, one, many) => (n === 1 ? one : many);
|
|
25
|
+
const clip = (s, n) => { s = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s; };
|
|
26
|
+
const cap1 = (s) => (s ? String(s).charAt(0).toUpperCase() + String(s).slice(1) : '');
|
|
27
|
+
const STATUS_LABEL = { 'on track': 'On track', 'at risk': 'At risk', late: 'Late', 'on hold': 'On hold', done: 'Done', cancelled: 'Cancelled' };
|
|
28
|
+
const DOC_STATE = { missing: 'Not started', draft: 'Draft', ok: 'Up to date', stale: 'Stale', due: 'Due' };
|
|
29
|
+
const BY = { drafts: 'PMOS drafts it', proposes: 'PMOS proposes', reviews: 'PMOS reviews', you: 'You write it', live: 'Live view' };
|
|
30
|
+
const STEP_STATE = { done: 'Done', current: 'In progress', todo: 'To do', skipped: 'Skipped', blocked: 'Blocked' };
|
|
31
|
+
const LAD_STATE = { done: 'Done', blocked: 'Blocked', pending: 'Waiting', unknown: 'Unknown', na: 'Not needed', 'no-signal': 'No signal', skipped: 'Skipped' };
|
|
32
|
+
const CHECK_KEYS = /^(spec|contract|build|quality|review|accept)$/;
|
|
33
|
+
const PHASE_OF_CAT = { Discover: 0, Plan: 1, Do: 2, Learn: 3 };
|
|
34
|
+
const DOC_CAT_ORDER = ['Discover', 'Plan', 'Build', 'Evaluate'];
|
|
35
|
+
const TECH = new Set(['record', 'rubric', 'stories', 'review', 'explainer']); // the engineering side, as the mockup groups it
|
|
36
|
+
const MAX_TASKS = 60;
|
|
37
|
+
|
|
38
|
+
let last = null;
|
|
39
|
+
const peekModel = () => last;
|
|
40
|
+
|
|
41
|
+
function build(f) {
|
|
42
|
+
const now = f.now || Date.now();
|
|
43
|
+
const row = f.row || {}, sheet = f.sheet || null, ini = (sheet && sheet.initiative) || {}, board = f.board || {};
|
|
44
|
+
const missing = (f.missing || []).slice();
|
|
45
|
+
if (!f.ladder && !missing.includes('workflow steps')) missing.push('workflow steps');
|
|
46
|
+
const wf = (row.workflow && row.workflow.length ? row.workflow : board.workflow) || [];
|
|
47
|
+
const names = wf.map((s) => s.name), cur = names.indexOf(row.stage);
|
|
48
|
+
const id = row.id || '';
|
|
49
|
+
const key = 'init:' + id;
|
|
50
|
+
|
|
51
|
+
// ── header ──
|
|
52
|
+
const status = row.status || null, bad = status === 'late' || status === 'at risk';
|
|
53
|
+
const head = {
|
|
54
|
+
statusLabel: status ? (STATUS_LABEL[status] || cap1(status)) : null, tone: status === 'on track' || status === 'done' ? 'ok' : bad ? (status === 'late' ? 'bad' : 'warn') : 'mut',
|
|
55
|
+
meta: [board.name || '', row.owner || '', row.end ? 'planned finish ' + shortDate(row.end) : ''].filter(Boolean),
|
|
56
|
+
stage: row.stage || null, stageNote: row.stageSource === 'derived' ? 'derived by the workflow engine' : null,
|
|
57
|
+
strip: { names, seg: names.map((n, j) => (j < cur ? 'p' : j === cur ? 'n' : '')), cur: cur >= 0 ? cur : null },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// ── overview: the workflow engine's next step when it has one, else the sheet's waiting record ──
|
|
61
|
+
const w = sheet && sheet.waiting, disp = ini.display || {};
|
|
62
|
+
const lad = f.ladder || null, nx = lad && lad.next && lad.next.step ? lad.next : null;
|
|
63
|
+
const WHO = { agent: 'Agents can do it', pm: 'Waits on you', you: 'Waits on you', human: 'Waits on you' };
|
|
64
|
+
let overview;
|
|
65
|
+
if (nx) overview = { who: /^(review|accept|contract)$/.test(nx.step) ? 'Waits on you' : 'Agents can do it', h: 'Next: ' + nx.step, p: cap1(String(nx.why || '')).replace(/\.?$/, '.'), more: nx.action ? cap1(String(nx.action)).replace(/\.?$/, '.') : '', skip: 'it stays at ' + nx.step + '.', tone: bad ? 'warn' : '' };
|
|
66
|
+
else if (!sheet) overview = { missing: true, why: 'The initiative sheet could not be read, so the next step is not shown.' };
|
|
67
|
+
else if (w) overview = { who: WHO[w.on] || (w.on ? 'Waits on ' + w.on : ''), h: (w.act ? cap1(w.act) : 'Next step'), p: [w.action && w.action.label ? w.action.label : '', disp.line2 || ''].filter(Boolean).join('. '), tone: bad ? 'warn' : '' };
|
|
68
|
+
else overview = { none: true, why: 'Nothing is waiting on a person or an agent for this initiative.' };
|
|
69
|
+
const health = ini.health && Array.isArray(ini.health.reasons) ? ini.health.reasons.filter(Boolean).map((r) => clip(typeof r === 'string' ? r : (r && (r.text || r.reason || r.id)) || '', 200)).filter(Boolean) : [];
|
|
70
|
+
const warnings = (row.warnings || []).map((x) => clip(typeof x === 'string' ? x : x && (x.text || x.message || x.id) || '', 200)).filter(Boolean);
|
|
71
|
+
// the title is "short name — what it is for": the crumb shows the first part, the description the rest (the mockup's own rule); a title with no dash is shown whole and has no description
|
|
72
|
+
const full = String(row.title || id), dash = full.indexOf(' — ');
|
|
73
|
+
const crumbTitle = clip(dash > 0 ? full.slice(0, dash) : full, 70);
|
|
74
|
+
const desc = dash > 0 && full.slice(dash + 3).trim() ? clip(cap1(full.slice(dash + 3).trim()), 240) : '';
|
|
75
|
+
|
|
76
|
+
// ── checks: the engine's ladder with its evidence; the sheet's stepper when the engine did not answer ──
|
|
77
|
+
const steps = lad && Array.isArray(lad.steps) ? lad.steps : null;
|
|
78
|
+
const checks = steps
|
|
79
|
+
? steps.filter((s) => CHECK_KEYS.test(s.key)).map((s) => ({ k: s.key, label: s.label || s.key, state: s.status, stateLabel: LAD_STATE[s.status] || cap1(s.status), owed: clip(s.evidence || s.rule || '', 220), cur: !!nx && nx.step === s.key }))
|
|
80
|
+
: ((sheet && sheet.stepper) || []).map((s) => ({ k: s.id, label: s.label || s.id, state: s.state === 'todo' ? 'pending' : s.state, stateLabel: STEP_STATE[s.state] || cap1(s.state), owed: s.owed ? clip(typeof s.owed === 'string' ? s.owed : s.owed.text || s.owed.what || '', 160) : '', cur: s.state === 'current' }));
|
|
81
|
+
const allSteps = steps ? steps.map((s) => ({ label: s.label || s.key, stateLabel: LAD_STATE[s.status] || cap1(s.status), note: clip(s.evidence || (s.basis && s.basis.rule) || s.rule || '', 220) })) : [];
|
|
82
|
+
|
|
83
|
+
// ── tasks: PRD requirements (no result per requirement) and the board's tracker items ──
|
|
84
|
+
const items = (f.items || []).filter((t) => t && t.ini === id).slice(0, MAX_TASKS);
|
|
85
|
+
const tracker = items.map((t, n) => ({ key: 'task:' + (t.uid || t.key || n), uid: t.uid || '', pmos: t.src === 'pmos', ref: t.key || t.ref || '', title: clip(t.title, 160), state: t.status || 'To do', src: t.src === 'jira' ? 'Jira (read-only)' : 'PMOS task', owner: t.owner || '', start: t.start || '', end: t.end || '', blocked: !!t.blocked, kind: t.kind || 'Task' }));
|
|
86
|
+
const dv = sheet && sheet.derived;
|
|
87
|
+
const reqs = dv && Array.isArray(dv.items) ? dv.items.slice(0, MAX_TASKS).map((x) => ({ key: 'req:' + x.id, ref: x.ref || x.id, title: clip(x.title, 160), state: null, src: 'PRD requirement' })) : [];
|
|
88
|
+
const prog = ini.progress || null;
|
|
89
|
+
const trackerDone = tracker.filter((t) => t.state === 'Done').length, trackerWip = tracker.filter((t) => t.state === 'In progress' || t.state === 'In review').length;
|
|
90
|
+
const dated = tracker.filter((t) => t.start && t.end);
|
|
91
|
+
const tasks = {
|
|
92
|
+
rows: tracker.concat(reqs), tracker: tracker.length, reqs: reqs.length,
|
|
93
|
+
cap: [reqs.length ? reqs.length + ' PRD ' + plural(reqs.length, 'requirement', 'requirements') : '', tracker.length ? tracker.length + ' from the board' : '', 'no tracker link'].filter(Boolean).join(' · '),
|
|
94
|
+
bar: tracker.length ? { done: trackerDone, wip: trackerWip, todo: tracker.length - trackerDone - trackerWip } : null,
|
|
95
|
+
basis: dv ? { source: dv.source || '', text: dv.basis || '', state: dv.state || '' } : null,
|
|
96
|
+
progress: prog && prog.total ? { done: prog.done, total: prog.total, label: prog.label || '' } : null,
|
|
97
|
+
byStatus: tracker.length > 0, timeline: dated.length > 0,
|
|
98
|
+
note: reqs.length ? 'The PRD lists these requirements. The repository records no result for each one, so none is drawn as done or open.' : tracker.length ? '' : 'This initiative has no PRD requirement list and no tracker item on its board.',
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// ── documents ──
|
|
102
|
+
const dd = f.docs || null;
|
|
103
|
+
const all = dd ? (dd.docs || []).map((d) => ({
|
|
104
|
+
key: d.key, title: d.title, what: d.for || '', st: d.st, state: d.kind === 'live' ? 'Live' : DOC_STATE[d.st] || d.st || '', by: BY[d.by] || d.by || '', byKey: d.by || '',
|
|
105
|
+
when: d.when ? shortDate(d.when) : '', cat: d.cat === 'Do' ? 'Build' : d.cat === 'Learn' ? 'Evaluate' : d.cat || 'Board', path: d.open && d.open.path ? d.open.path : null, running: !!d.running, tech: TECH.has(d.key),
|
|
106
|
+
act: d.act && d.act.do === 'draft' ? { kind: 'draft', label: d.running ? 'Drafting' : d.act.label || 'Draft it' } : null,
|
|
107
|
+
})) : [];
|
|
108
|
+
const core = all.filter((d) => !d.tech);
|
|
109
|
+
const cnt = { all: core.length, none: core.filter((d) => d.st === 'missing').length, draft: core.filter((d) => d.st === 'draft').length, ok: core.filter((d) => d.st === 'ok' || d.st === 'live').length };
|
|
110
|
+
const docs = dd ? { rows: all, counts: cnt, techCount: all.length - core.length,
|
|
111
|
+
cap: core.length ? [cnt.draft ? cnt.draft + ' ' + plural(cnt.draft, 'draft', 'drafts') + ' to review' : '', cnt.none ? cnt.none + ' not started' : '', cnt.ok ? cnt.ok + ' up to date' : ''].filter(Boolean).join(' · ') : '',
|
|
112
|
+
scope: dd.scope || 'initiative:' + id } : { rows: [], counts: { all: 0, none: 0, draft: 0, ok: 0 }, techCount: 0, cap: '', missing: true };
|
|
113
|
+
if (!dd && !missing.includes('core documents')) missing.push('core documents');
|
|
114
|
+
|
|
115
|
+
// ── PMOS can help: only things a press can really do. A draft of a core document the initiative has not started (the existing read-only draft run), and a question for the read-only agent ──
|
|
116
|
+
const phase = Math.max(0, PHASE_OF_CAT[row.cat] != null ? PHASE_OF_CAT[row.cat] : Math.max(0, cur >= 0 && names.length ? Math.floor(cur / Math.max(1, names.length) * 4) : 0));
|
|
117
|
+
const help = [];
|
|
118
|
+
const nd = docs.rows.find((d) => d.st === 'missing' && !d.tech && d.act && d.act.kind === 'draft' && DOC_CAT_ORDER.indexOf(d.cat) <= phase);
|
|
119
|
+
if (nd) help.push({ label: 'Draft the ' + nd.title.toLowerCase(), kind: 'draft', key: nd.key });
|
|
120
|
+
const wantsOptions = bad || (lad && lad.next && /^(review|accept)$/.test(lad.next.step));
|
|
121
|
+
if (wantsOptions) help.push({ label: lad ? 'Summarise the review findings' : 'Lay out the options', kind: 'ask', q: lad ? 'Summarise the review findings for this initiative, from the evidence you can read in this repository. Say what you could not find.' : 'Lay out the options for this initiative, from what you can read in this repository, and say what you could not find. Do not recommend one unless the records do.' });
|
|
122
|
+
const upd = docs.rows.find((d) => d.key === 'status' && d.st === 'missing' && d.act && d.act.kind === 'draft');
|
|
123
|
+
if (phase === 2 && upd && !help.some((h) => h.key === 'status')) help.push({ label: 'Write the status update', kind: 'draft', key: upd.key });
|
|
124
|
+
const out = docs.rows.find((d) => d.key === 'outcome' && d.st === 'missing' && d.act && d.act.kind === 'draft');
|
|
125
|
+
if (phase === 3 && out && !help.some((h) => h.key === 'outcome')) help.push({ label: 'Draft the outcome review', kind: 'draft', key: out.key });
|
|
126
|
+
|
|
127
|
+
const files = ((sheet && sheet.files) || []).filter((x) => x && x.path).slice(0, 12).map((x) => ({ label: x.label || x.kind, path: x.path }));
|
|
128
|
+
const say = (row.title || id) + ' is ' + (status ? STATUS_LABEL[status] ? STATUS_LABEL[status].toLowerCase() : status : 'without a status') + (row.stage ? ', in the ' + row.stage + ' stage' : '') + '.'
|
|
129
|
+
+ (row.stageSource === 'derived' ? ' The stage comes from the workflow engine, not from a person.' : '');
|
|
130
|
+
const facts = [['Lane', row.lane], ['Stage', row.stage], ['Anchor', row.anchor], ['Horizon', row.horizon], ['Status', head.statusLabel], ['Owner', row.owner], ['Planned finish', row.end ? shortDate(row.end) : ''], ['Progress', prog && prog.total ? prog.done + ' of ' + prog.total : '']].filter((x) => x[1]);
|
|
131
|
+
|
|
132
|
+
const model = {
|
|
133
|
+
pending: !!f.pending,
|
|
134
|
+
header: { text: 'Real data · as of ' + hhmm(now) + (missing.length ? ' · not read: ' + missing.join(', ') : ''), missing },
|
|
135
|
+
board: { name: board.name || '', id: board.id || null, ws: board.ws || null },
|
|
136
|
+
id, key, title: clip(row.title || id, 160), lane: row.lane || '', anchor: row.anchor || '',
|
|
137
|
+
head, overview, health, warnings, checks, allSteps, tasks, docs, files, desc, crumbTitle, help,
|
|
138
|
+
boards: f.boards || [], siblings: (f.siblings || []).slice(0, 100),
|
|
139
|
+
dock: { label: head.statusLabel || 'No status', stage: row.stage || '', next: String(overview.h || '').replace(/^Next: /, '') },
|
|
140
|
+
rows: [{ key, id, title: clip(row.title || id, 160), sub: [board.name, row.lane].filter(Boolean).join(' · '), dialog: { say, facts } }]
|
|
141
|
+
.concat(docs.rows.filter((d) => d.path).map((d) => ({ key: 'doc:' + id + ':' + d.key, id, title: d.title, sub: d.path, dialog: { say: d.title + ' is ' + (d.state || 'not started').toLowerCase() + '.', facts: [['File', d.path], ['State', d.state], ['Who writes it', d.by]].filter((x) => x[1]), files: [d.path] } }))),
|
|
142
|
+
};
|
|
143
|
+
last = model;
|
|
144
|
+
return model;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = { build, peekModel };
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* lib/my-work-premium.js — the premium My work page's model, built from the list the Work page reads (initiative briefing-premium-work).
|
|
3
|
+
*
|
|
4
|
+
* build({ v, now }) → model PURE. v is GET /api/pm-items' answer: { today, work: { inbox, today, upcoming, waiting, someday }, review, review_days }.
|
|
5
|
+
* peekModel() → the last model built, for the agent to find a row in (null before the first)
|
|
6
|
+
*
|
|
7
|
+
* THE RULE THE FILE FOLLOWS (as lib/briefing-premium.js): a sentence, a count or a chip is made only from a field of an item. A clause with no
|
|
8
|
+
* record is left out. The hero, the card headers and the dock read ONE definition of "to clear": inbox + today + agent drafts to review (M9).
|
|
9
|
+
* The tray is the PM's own unsorted captures: an item the PM added (source local) that no date or flag has placed yet, so it waits in the inbox
|
|
10
|
+
* placement. It is not counted as work to clear until the PM places it. The shelf, the capacity figure and the week bars of the mockup have no record
|
|
11
|
+
* behind them and are not built (PRD D15, amended by the PM 2026-10-17 to add the tray).
|
|
12
|
+
* An agent draft is the agent's text: it is shown labelled and is never counted as done. */
|
|
13
|
+
|
|
14
|
+
const WORDS = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve'];
|
|
15
|
+
const num = (n) => (n >= 0 && n <= 12 ? WORDS[n] : String(n));
|
|
16
|
+
const plural = (n, one, many) => (n === 1 ? one : many);
|
|
17
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
18
|
+
const hhmm = (ms) => { const d = new Date(ms); return pad(d.getHours()) + ':' + pad(d.getMinutes()); };
|
|
19
|
+
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
20
|
+
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
21
|
+
const MON = MONTHS.map((m) => m.slice(0, 3));
|
|
22
|
+
const dateLine = (ms) => { const d = new Date(ms); return WEEKDAYS[d.getDay()] + ' ' + d.getDate() + ' ' + MONTHS[d.getMonth()]; };
|
|
23
|
+
const isoDay = (iso) => { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(iso || '')); return m ? new Date(+m[1], +m[2] - 1, +m[3]) : null; };
|
|
24
|
+
const shortDate = (iso) => { const d = isoDay(iso); return d ? d.getDate() + ' ' + MON[d.getMonth()] : ''; };
|
|
25
|
+
const daysBetween = (fromIso, toIso) => { const a = isoDay(fromIso), b = isoDay(toIso); return a && b ? Math.round((b - a) / 86400000) : null; };
|
|
26
|
+
const clip = (s, n) => { s = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s; };
|
|
27
|
+
const MAX_PER = 8;
|
|
28
|
+
const SECTIONS = [['inbox', 'Inbox'], ['today', 'Today'], ['upcoming', 'Upcoming'], ['someday', 'Someday']];
|
|
29
|
+
const RUNNING = ['queued', 'working', 'sending'];
|
|
30
|
+
const HOLD = { agent: 'Starting, approving or revising an agent draft from here is not built yet. It can write or post, so it needs its own decision.' };
|
|
31
|
+
|
|
32
|
+
let last = null;
|
|
33
|
+
const peekModel = () => last;
|
|
34
|
+
|
|
35
|
+
function srcLabel(it) {
|
|
36
|
+
const s = String(it.source || 'local');
|
|
37
|
+
if (s === 'local') return 'Yours';
|
|
38
|
+
if (s === 'jira') return 'Jira';
|
|
39
|
+
if (s === 'wbs') return 'Board';
|
|
40
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
41
|
+
}
|
|
42
|
+
const isLocal = (it) => !it.source || it.source === 'local';
|
|
43
|
+
const agentOf = (it) => (it && it.agent && it.agent.state) || null;
|
|
44
|
+
const isReady = (it) => agentOf(it) === 'ready';
|
|
45
|
+
const isRunning = (it) => RUNNING.includes(agentOf(it));
|
|
46
|
+
|
|
47
|
+
function dueChip(it, today) {
|
|
48
|
+
if (!it.due) return { chip: null, overdue: false };
|
|
49
|
+
const d = daysBetween(today, it.due);
|
|
50
|
+
if (d == null) return { chip: null, overdue: false };
|
|
51
|
+
if (d < 0) return { chip: 'Overdue · ' + shortDate(it.due), overdue: true };
|
|
52
|
+
if (d === 0) return { chip: 'Today', overdue: false };
|
|
53
|
+
return { chip: shortDate(it.due), overdue: false };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function rowOf(it, list, v, inReview) {
|
|
57
|
+
const today = v.today, { chip, overdue } = dueChip(it, today);
|
|
58
|
+
const sub = [srcLabel(it), it.priority, it.waiting_on ? 'waiting on ' + it.waiting_on : null].filter(Boolean).join(' · ');
|
|
59
|
+
const days = it.waiting_since ? daysBetween(String(it.waiting_since).slice(0, 10), today) : null;
|
|
60
|
+
const facts = [
|
|
61
|
+
['From', srcLabel(it)], ['Place', list === 'upcoming' ? 'Upcoming' : list.charAt(0).toUpperCase() + list.slice(1)], ['Due', it.due ? shortDate(it.due) + (overdue ? ' (overdue)' : '') : ''],
|
|
62
|
+
['Priority', it.priority], ['Waiting on', it.waiting_on], ['Waiting since', it.waiting_since ? shortDate(String(it.waiting_since).slice(0, 10)) + (days != null ? ' (' + days + ' ' + plural(days, 'day', 'days') + ')' : '') : ''],
|
|
63
|
+
['Not before', it.defer && it.defer > today ? shortDate(it.defer) : ''], ['Added', it.created_at ? shortDate(String(it.created_at).slice(0, 10)) : ''], ['Last touched', it.touched_at ? shortDate(String(it.touched_at).slice(0, 10)) : ''],
|
|
64
|
+
].filter((x) => x[1]);
|
|
65
|
+
const draft = it.agent && it.agent.draft && (it.agent.draft.body || it.agent.draft.summary);
|
|
66
|
+
const actions = ['done', 'notyet'];
|
|
67
|
+
if (list !== 'today') actions.push('flag');
|
|
68
|
+
if (list !== 'someday') actions.push('park');
|
|
69
|
+
if (list !== 'waiting') actions.push('waiting');
|
|
70
|
+
actions.push('remove');
|
|
71
|
+
if (inReview) actions.push('keep');
|
|
72
|
+
const say = overdue ? 'This was due ' + shortDate(it.due) + ' and is still open.' : list === 'tray' ? 'You threw this in. It has no date or place yet. Place it, or drop it.' : list === 'inbox' ? 'This is new and has no date or place yet.' : list === 'waiting' ? 'You are waiting on ' + (it.waiting_on || 'someone') + (days != null ? ' for ' + days + ' ' + plural(days, 'day', 'days') : '') + '.' : 'This is on your list.';
|
|
73
|
+
return {
|
|
74
|
+
key: 'work:' + it.id, id: it.id, list, title: clip(it.title, 140), sub, chip, overdue, src: srcLabel(it),
|
|
75
|
+
agent: agentOf(it) && agentOf(it) !== 'suggest' ? agentOf(it) : null,
|
|
76
|
+
dialog: {
|
|
77
|
+
eye: 'Your item', logTitle: 'Read from your list',
|
|
78
|
+
log: ['Read the item (' + srcLabel(it) + ')', 'Placed it from its date, its flag and who it waits on: ' + list, inReview ? 'Found it untouched for ' + v.review_days + ' days' : 'Checked it against the seven-day review'],
|
|
79
|
+
say, head: 'Where it stands', facts,
|
|
80
|
+
write: { kind: 'work', id: it.id, actions },
|
|
81
|
+
hold: HOLD.agent,
|
|
82
|
+
pane: { label: 'Item record', file: 'pm-items · ' + it.id, h: clip(it.title, 120), intro: sub, rows: facts, paras: draft ? [clip(draft, 1500)] : [], parasTitle: draft ? 'Agent draft, not checked' : '' },
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function build({ v, now }) {
|
|
88
|
+
now = now || Date.now();
|
|
89
|
+
v = v || {};
|
|
90
|
+
const today = v.today || new Date(now).toISOString().slice(0, 10);
|
|
91
|
+
v = Object.assign({ work: {}, review: [], review_days: 7 }, v, { today });
|
|
92
|
+
const work = Object.assign({ inbox: [], today: [], upcoming: [], waiting: [], someday: [] }, v.work);
|
|
93
|
+
const reviewIds = new Set((v.review || []).map((x) => x.id));
|
|
94
|
+
const tray = (work.inbox || []).filter(isLocal), arrivals = (work.inbox || []).filter((i) => !isLocal(i));
|
|
95
|
+
work.inbox = arrivals;
|
|
96
|
+
const all = [];
|
|
97
|
+
for (const [k] of SECTIONS.concat([['waiting', 'Waiting']])) for (const it of work[k] || []) all.push({ it, list: k });
|
|
98
|
+
for (const it of tray) all.push({ it, list: 'tray' });
|
|
99
|
+
const ready = all.filter((x) => isReady(x.it)), running = all.filter((x) => isRunning(x.it)), failed = all.filter((x) => agentOf(x.it) === 'failed');
|
|
100
|
+
const overdue = all.filter((x) => x.list !== 'waiting' && x.list !== 'someday' && x.it.due && x.it.due < today);
|
|
101
|
+
const dueToday = work.today.filter((it) => it.due === today);
|
|
102
|
+
const waitDays = (work.waiting || []).map((it) => (it.waiting_since ? daysBetween(String(it.waiting_since).slice(0, 10), today) : null)).filter((d) => d != null && d >= 0);
|
|
103
|
+
const longest = waitDays.length ? Math.max(...waitDays) : null;
|
|
104
|
+
const clear = work.inbox.length + work.today.length + ready.length;
|
|
105
|
+
|
|
106
|
+
const hero = [];
|
|
107
|
+
const cap1 = (parts) => [Object.assign({}, parts[0], { t: parts[0].t.charAt(0).toUpperCase() + parts[0].t.slice(1) })].concat(parts.slice(1));
|
|
108
|
+
const add = (parts, from) => hero.push({ parts: cap1(parts), from });
|
|
109
|
+
const first = [];
|
|
110
|
+
if (work.inbox.length) first.push({ t: num(work.inbox.length), s: true }, { t: ' new in your inbox' });
|
|
111
|
+
const bits = [];
|
|
112
|
+
if (overdue.length) bits.push([{ t: num(overdue.length), s: true }, { t: ' overdue' }]);
|
|
113
|
+
if (dueToday.length) bits.push([{ t: num(dueToday.length), s: true }, { t: ' due today' }]);
|
|
114
|
+
const line1 = [];
|
|
115
|
+
if (first.length) line1.push(...first);
|
|
116
|
+
for (const b of bits) { if (line1.length) line1.push({ t: ', ' }); line1.push(...b); }
|
|
117
|
+
if (line1.length) { line1.push({ t: '.' }); add(line1, [].concat(work.inbox.map((i) => i.id), overdue.map((x) => x.it.id), dueToday.map((i) => i.id))); }
|
|
118
|
+
if (tray.length) add([{ t: num(tray.length), s: true }, { t: ' ' + plural(tray.length, 'thing you threw in waits', 'things you threw in wait') + ' in your tray.' }], tray.map((i) => i.id));
|
|
119
|
+
if ((work.waiting || []).length) add([{ t: num(work.waiting.length), s: true }, { t: ' ' + plural(work.waiting.length, 'person owes', 'people owe') + ' you something' + (longest != null ? ', the longest for ' : '.') }].concat(longest != null ? [{ t: longest + ' ' + plural(longest, 'day', 'days'), s: true }, { t: '.' }] : []), work.waiting.map((i) => i.id));
|
|
120
|
+
if (ready.length) add([{ t: num(ready.length), s: true }, { t: ' agent ' + plural(ready.length, 'draft needs', 'drafts need') + ' your review.' }], ready.map((x) => x.it.id));
|
|
121
|
+
if ((v.review || []).length) add([{ t: num(v.review.length), s: true }, { t: ' ' + plural(v.review.length, 'item has', 'items have') + ' sat untouched for ' + v.review_days + ' days.' }], v.review.map((i) => i.id));
|
|
122
|
+
const empty = !hero.length;
|
|
123
|
+
if (empty) add([{ t: all.length ? 'Nothing is late and nothing is new. ' + num(all.length).replace(/^./, (c) => c.toUpperCase()) + ' ' + plural(all.length, 'item is', 'items are') + ' on your list.' : 'Your list is empty.', s: false }], []);
|
|
124
|
+
|
|
125
|
+
const sections = SECTIONS.map(([k, label]) => {
|
|
126
|
+
const items = work[k] || [];
|
|
127
|
+
return { key: k, label, n: items.length, more: Math.max(0, items.length - MAX_PER), rows: items.slice(0, MAX_PER).map((it) => rowOf(it, k, v, reviewIds.has(it.id))) };
|
|
128
|
+
});
|
|
129
|
+
const waitingRows = (work.waiting || []).slice(0, MAX_PER).map((it) => rowOf(it, 'waiting', v, reviewIds.has(it.id)));
|
|
130
|
+
const agentRows = ready.concat(running, failed.filter((x) => !ready.includes(x))).slice(0, MAX_PER).map((x) => rowOf(x.it, x.list, v, reviewIds.has(x.it.id)));
|
|
131
|
+
const trayRows = tray.slice(0, MAX_PER).map((it) => rowOf(it, 'tray', v, reviewIds.has(it.id)));
|
|
132
|
+
const rowsAll = [].concat(...sections.map((s) => s.rows), waitingRows, agentRows, trayRows);
|
|
133
|
+
const firstToday = sections[1].rows[0];
|
|
134
|
+
const model = {
|
|
135
|
+
date: dateLine(now), pending: false,
|
|
136
|
+
header: { text: 'Real data · as of ' + hhmm(now) + ' · Jira and Slack appear only when an agent session has read them', missing: [] },
|
|
137
|
+
hero, empty,
|
|
138
|
+
capture: { placeholder: 'Add anything: "Reply to Rina about SSO by Friday"', hint: 'One line. The date and priority are read from it.' },
|
|
139
|
+
plate: { title: 'On your plate', cap: clear + ' to clear', n: clear, sections, note: 'Each item comes from your list. A date shows only when one was set. Nothing changes in Jira or Slack from here.' },
|
|
140
|
+
tray: { title: 'Your tray', cap: tray.length ? tray.length + ' to place' : 'empty', n: tray.length, more: Math.max(0, tray.length - MAX_PER), rows: trayRows, note: 'Anything you add lands here first. Open one to place it: Today, Later, waiting on someone, or drop it. Nothing here counts as work to clear until you place it.' },
|
|
141
|
+
agents: { title: 'With agents', cap: ready.length ? num(ready.length) + ' to review' : running.length ? num(running.length) + ' running' : 'nothing running', counts: { running: running.length, ready: ready.length, failed: failed.length }, rows: agentRows, hold: HOLD.agent },
|
|
142
|
+
waiting: { title: 'Waiting on others', cap: longest != null ? 'longest ' + longest + ' ' + plural(longest, 'day', 'days') : (work.waiting || []).length ? num(work.waiting.length) + ' waiting' : '', n: (work.waiting || []).length, more: Math.max(0, (work.waiting || []).length - MAX_PER), rows: waitingRows },
|
|
143
|
+
review: { n: (v.review || []).length, days: v.review_days, ids: (v.review || []).map((i) => i.id) },
|
|
144
|
+
dock: { needs: clear, label: 'to clear', next: firstToday ? firstToday.title : (sections[0].rows[0] ? sections[0].rows[0].title : null) },
|
|
145
|
+
rows: rowsAll,
|
|
146
|
+
};
|
|
147
|
+
last = model;
|
|
148
|
+
return model;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = { build, peekModel, srcLabel, num };
|
|
@@ -49,6 +49,10 @@ const MODULES = Object.freeze([
|
|
|
49
49
|
'routes-forecast', // console3 B5: forecast, critical path, time machine and capacity (read-only)
|
|
50
50
|
'routes-calendar-views', // console3 C2: Day/Week/Month/Quarter/Year cells, Gantt and the strategy lens (read-only)
|
|
51
51
|
'routes-briefing', // console3 C1: the day-part Briefing cards from the signal engine (read-only)
|
|
52
|
+
'routes-briefing-premium', // briefing-premium: the premium Briefing's page model from repository state (read-only)
|
|
53
|
+
'routes-my-work-premium', // briefing-premium-work: the premium My work page's model from the Work page's own list (read-only)
|
|
54
|
+
'routes-board-premium', // briefing-premium-board: the premium Board page's model from the Initiatives page's own routes (read-only)
|
|
55
|
+
'routes-initiative-premium', // briefing-premium-initiative: the premium initiative page's model from the Initiatives sheet's own routes (read-only)
|
|
52
56
|
'routes-brief-page', // console3 Briefing as the brief: Today agenda, Needs you and Mentions (read-only; writes go through routes-pm-items)
|
|
53
57
|
'routes-mentions', // console3 Briefing Mentions: the PM's read marks (page press only)
|
|
54
58
|
'routes-phone-day', // phone companion: the agent's finished drafts, the evening recap and tomorrow at a glance (read-only)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-board-premium.js — GET /api/board-premium[?ws=<id>&board=<id>] (initiative briefing-premium-board).
|
|
3
|
+
*
|
|
4
|
+
* 200 { ok, state: 'ready', model, as_of } | 202 { ok, state: 'reading' } | 500 { ok: false, state: 'error', error }
|
|
5
|
+
* model is lib/board-premium.js build(), made from the answers of the routes the production Initiatives page reads: /api/boards, /api/boards/board,
|
|
6
|
+
* /backlog, /columns, /sprints and /api/core-docs?level=board. Those routes derive the initiatives in the background and answer 202 while they do, so
|
|
7
|
+
* this route answers 202 too and the page asks again: no request waits for the read. Read-only: the one press of the page (Draft it) goes to
|
|
8
|
+
* POST /api/core-docs/draft from the page. */
|
|
9
|
+
const BoardPremium = require('./board-premium.js');
|
|
10
|
+
const boards = require('./routes-boards.js');
|
|
11
|
+
const coreDocs = require('./routes-core-docs.js');
|
|
12
|
+
|
|
13
|
+
/** GET `pathAndQuery` from a route module's own handler, as the Work page's route does, and return { status, body }. */
|
|
14
|
+
async function via(mod, pathAndQuery, req, ctx) {
|
|
15
|
+
let out = { status: 500, body: null };
|
|
16
|
+
const c = Object.assign({}, ctx, { json: (res, obj, code) => { out = { status: code || 200, body: obj }; } });
|
|
17
|
+
const u = new URL('http://local' + pathAndQuery);
|
|
18
|
+
const handled = await mod.handle({ method: 'GET', headers: (req && req.headers) || {} }, {}, u, c);
|
|
19
|
+
return handled === false ? { status: 404, body: null } : out;
|
|
20
|
+
}
|
|
21
|
+
const qs = (o) => '?' + Object.entries(o).filter(([, v]) => v != null && v !== '').map(([k, v]) => k + '=' + encodeURIComponent(v)).join('&');
|
|
22
|
+
|
|
23
|
+
module.exports = {
|
|
24
|
+
prefixes: ['/api/board-premium'],
|
|
25
|
+
handle: async (req, res, url, ctx) => {
|
|
26
|
+
if (url.pathname !== '/api/board-premium') return false;
|
|
27
|
+
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
28
|
+
const fail = (error) => { ctx.json(res, { ok: false, state: 'error', error: String(error).slice(0, 240) }, 500); return true; };
|
|
29
|
+
try {
|
|
30
|
+
const list = await via(boards, '/api/boards', req, ctx);
|
|
31
|
+
if (!list.body || list.body.ok === false) return fail((list.body && list.body.error) || 'the boards could not be listed');
|
|
32
|
+
const all = list.body.boards || [];
|
|
33
|
+
const wantWs = url.searchParams.get('ws'), wantId = url.searchParams.get('board');
|
|
34
|
+
const pick = all.find((b) => (!wantWs || b.ws === wantWs) && (!wantId || b.id === wantId)) || all[0];
|
|
35
|
+
if (!pick) return fail('no board is registered');
|
|
36
|
+
const q = qs({ ws: pick.ws, board: pick.id });
|
|
37
|
+
const head = await via(boards, '/api/boards/board' + q, req, ctx);
|
|
38
|
+
if (head.status === 202 || (head.body && head.body.pending)) { ctx.json(res, { ok: true, state: 'reading' }, 202); return true; }
|
|
39
|
+
if (!head.body || head.body.ok === false) return fail((head.body && head.body.error) || 'the board could not be read');
|
|
40
|
+
const missing = [];
|
|
41
|
+
// the late and at-risk initiatives first (the board's own `risk` view, late before at risk), then its `now` view, each initiative once
|
|
42
|
+
const listOf = (a, n) => { const seen = new Set(), out = []; const rank = (r) => (r.status === 'late' ? 0 : 1); for (const r of [...((a && a.rows) || [])].sort((x, y) => rank(x) - rank(y))) { if (!seen.has(r.id)) { seen.add(r.id); out.push(r); } } for (const r of (n && n.rows) || []) if (!seen.has(r.id)) { seen.add(r.id); out.push(r); } return out.slice(0, 100); };
|
|
43
|
+
const [risk, now, cols, spr, docs] = await Promise.all([
|
|
44
|
+
via(boards, '/api/boards/backlog' + q + '&view=risk&limit=100', req, ctx),
|
|
45
|
+
via(boards, '/api/boards/backlog' + q + '&view=now&limit=100', req, ctx),
|
|
46
|
+
via(boards, '/api/boards/columns' + q, req, ctx),
|
|
47
|
+
via(boards, '/api/boards/sprints' + q, req, ctx),
|
|
48
|
+
via(coreDocs, '/api/core-docs' + qs({ level: 'board', ws: pick.ws, board: pick.id }), req, ctx),
|
|
49
|
+
]);
|
|
50
|
+
const ok = (r, name) => { if (r.status === 202 || !r.body || r.body.ok === false || r.body.pending) { missing.push(name); return null; } return r.body; };
|
|
51
|
+
const rk = ok(risk, 'initiatives'), nw = ok(now, 'initiatives'), c = ok(cols, 'stages'), s = ok(spr, 'sprints'), d = ok(docs, 'core documents');
|
|
52
|
+
const model = BoardPremium.build({
|
|
53
|
+
now: Date.now(), pending: false, missing: missing.filter((m) => m !== 'core documents'), boards: all.map((x) => ({ ws: x.ws, id: x.id, name: x.name })),
|
|
54
|
+
board: head.body.board, counts: head.body.counts, nextRelease: head.body.nextRelease || null,
|
|
55
|
+
rows: listOf(rk, nw), columns: c ? c.columns : [], sprints: s || { source: 'none', sprints: [] }, docs: d,
|
|
56
|
+
});
|
|
57
|
+
ctx.json(res, { ok: true, state: 'ready', model, as_of: Date.now() });
|
|
58
|
+
return true;
|
|
59
|
+
} catch (e) { return fail((e && e.message) || e); }
|
|
60
|
+
},
|
|
61
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-briefing-premium.js — GET /api/briefing-premium[?ws=<id>] (initiative briefing-premium).
|
|
3
|
+
*
|
|
4
|
+
* { ok, state: 'ready'|'reading'|'error', model, as_of, refreshing, elapsed_ms, error }
|
|
5
|
+
* model is lib/briefing-premium.js build(): { date, header, hero, list, today, dock }. A read derives every initiative and takes from half a
|
|
6
|
+
* minute upward, so no request waits for it: a kept answer is served at once (and refreshed in the background after 5 minutes, and kept on
|
|
7
|
+
* disk across restarts), and with none the answer is 202 { state: 'reading', elapsed_ms } until the first read ends; `as_of` says when it was read. The POST routes decide, put-off and return are the PM's own calls and write through lib/briefing-premium-writes.js; ask starts the read-only agent (lib/briefing-premium-agent.js) and GET /thread?key= reads a row's conversation. A source that cannot be read is named in model.header.missing and never guessed.
|
|
8
|
+
*/
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const BP = require('./briefing-premium.js');
|
|
11
|
+
const { ENGINE_PATHS } = require('./console-state.js');
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const guard = require('./pm-guard.js');
|
|
14
|
+
const Wr = require('./briefing-premium-writes.js');
|
|
15
|
+
const Ag = require('./briefing-premium-agent.js');
|
|
16
|
+
const WP = require('./my-work-premium.js');
|
|
17
|
+
const BoardP = require('./board-premium.js');
|
|
18
|
+
const InitP = require('./initiative-premium.js');
|
|
19
|
+
const { repoScript } = require('./repo-script.js');
|
|
20
|
+
const { correctionEvent } = require(repoScript('card-state.js'));
|
|
21
|
+
const { appendOutbox } = require(repoScript('outbox.js'));
|
|
22
|
+
|
|
23
|
+
const Prod = require('./briefing-premium-products.js');
|
|
24
|
+
const settings = require('./settings-store.js');
|
|
25
|
+
|
|
26
|
+
/** The workspaces the Briefing may read right now (initiative briefing-premium-products): choose()'s list, from the server's workspace list and the PM's setting. */
|
|
27
|
+
function products(ctx, only) {
|
|
28
|
+
const home = ctx.homeRoot || ctx.selfRoot || null;
|
|
29
|
+
const list = (typeof ctx.workspaceList === 'function' ? ctx.workspaceList() : []).map((w) => ({ id: w.id, name: w.label || w.name, root: w.root, home: !!home && path.resolve(w.root) === path.resolve(home) }));
|
|
30
|
+
return Prod.choose({ workspaces: list, include: settings.get(ctx.runtimeDir, 'ws.briefing.include', null), only });
|
|
31
|
+
}
|
|
32
|
+
/** The included product a merged row (or a returned run) belongs to: { root, ws } | { error }. null when the key is the one-workspace page's. */
|
|
33
|
+
function ownerOf(ctx, ws) {
|
|
34
|
+
const a = products(ctx).available.find((x) => x.ws === ws);
|
|
35
|
+
if (!a) return { error: 'that product is not registered' };
|
|
36
|
+
if (!a.on) return { error: a.name + ' is not included in the Briefing' };
|
|
37
|
+
return { root: a.root, ws: a.ws };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const enginePath = () => ENGINE_PATHS.find((p) => fs.existsSync(p)) || null;
|
|
41
|
+
|
|
42
|
+
const POSTS = ['decide', 'put-off', 'return', 'ask'];
|
|
43
|
+
const rootOf = (url, ctx) => {
|
|
44
|
+
let root; try { const w = ctx.getWs && ctx.getWs(url.searchParams.get('ws') || null); root = w && w.root; } catch { root = null; }
|
|
45
|
+
return root || path.resolve(__dirname, '..', '..');
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** The PM's own calls (initiative briefing-premium-writes). Each appends one record and answers with the rebuilt model. */
|
|
49
|
+
async function post(req, res, url, ctx) {
|
|
50
|
+
const act = url.pathname.slice('/api/briefing-premium/'.length);
|
|
51
|
+
if (req.headers && req.headers['x-pmos-session']) { ctx.json(res, { ok: false, error: 'an agent session cannot record the PM\'s decision' }, 403); return true; }
|
|
52
|
+
const got = await ctx.readJson(req);
|
|
53
|
+
if (!got.ok) { ctx.json(res, { ok: false, error: 'body must be JSON' }, 400); return true; }
|
|
54
|
+
const b = got.body;
|
|
55
|
+
const g = guard.requirePagePress(req, b);
|
|
56
|
+
if (!g.ok) { ctx.json(res, { ok: false, error: g.error }, g.status); return true; }
|
|
57
|
+
const runtimeDir = ctx.runtimeDir;
|
|
58
|
+
// A merged row's key ends with its workspace (kind:id:ws); the one-workspace page's key does not and keeps the old path.
|
|
59
|
+
const sk = Prod.splitKey(b.key);
|
|
60
|
+
let root = rootOf(url, ctx), merged = false;
|
|
61
|
+
if (sk || (act === 'return' && b.ws)) {
|
|
62
|
+
const o = ownerOf(ctx, sk ? sk.ws : String(b.ws));
|
|
63
|
+
if (o.error) { ctx.json(res, { ok: false, error: o.error }, 400); return true; }
|
|
64
|
+
root = o.root; merged = true;
|
|
65
|
+
}
|
|
66
|
+
if (act === 'ask') {
|
|
67
|
+
let wsId; try { const w = merged && sk ? { id: sk.ws } : ctx.getWs && ctx.getWs(url.searchParams.get('ws') || null); wsId = w && w.id; } catch { wsId = undefined; }
|
|
68
|
+
const a = Ag.ask({ runtimeDir, key: b.key, text: b.text, model: /^work:/.test(String(b.key || '')) ? WP.peekModel() : /^board:/.test(String(b.key || '')) ? BoardP.peekModel() : /^(init|doc):/.test(String(b.key || '')) ? InitP.peekModel() : sk ? Prod.peekModel() : BP.peekModel(root), spawnSession: ctx.spawnSession, wsId });
|
|
69
|
+
ctx.json(res, a.body, a.status);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
let r;
|
|
73
|
+
try {
|
|
74
|
+
if (act === 'decide') r = Wr.decide({ root, key: sk ? sk.base : b.key, choice: b.choice, why: b.why });
|
|
75
|
+
else if (act === 'put-off') r = Wr.putOff({ runtimeDir, key: b.key, until: b.until });
|
|
76
|
+
else r = Wr.returnRun({ root, runtimeDir, run: b.run, reason: b.reason, correctionEvent, appendOutbox });
|
|
77
|
+
} catch (e) { r = { ok: false, status: 500, error: 'the record could not be written: ' + String((e && e.message) || e).slice(0, 160) }; }
|
|
78
|
+
if (!r.ok) { ctx.json(res, { ok: false, error: r.error }, r.status || 400); return true; }
|
|
79
|
+
try { if (merged) await Prod.rebuildOne({ root, runtimeDir }); else await BP.rebuild({ root, runtimeDir }); } catch { /* the page re-reads on its next poll */ }
|
|
80
|
+
ctx.json(res, { ok: true, wrote: act, ...(r.heading ? { heading: r.heading } : {}) });
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = {
|
|
85
|
+
prefixes: ['/api/briefing-premium'],
|
|
86
|
+
handle: async (req, res, url, ctx) => {
|
|
87
|
+
if (url.pathname.startsWith('/api/briefing-premium/') && POSTS.includes(url.pathname.slice('/api/briefing-premium/'.length))) {
|
|
88
|
+
if (req.method !== 'POST') { ctx.json(res, { ok: false, error: 'POST only' }, 405); return true; }
|
|
89
|
+
return post(req, res, url, ctx);
|
|
90
|
+
}
|
|
91
|
+
if (url.pathname === '/api/briefing-premium/thread') {
|
|
92
|
+
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
93
|
+
const key = url.searchParams.get('key') || '';
|
|
94
|
+
if (!Ag.KEY_RE.test(key)) { ctx.json(res, { ok: false, error: 'key must name a row' }, 400); return true; }
|
|
95
|
+
ctx.json(res, { ok: true, ...Ag.threadOf(ctx.runtimeDir, key) });
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
if (url.pathname !== '/api/briefing-premium') return false;
|
|
99
|
+
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
100
|
+
// No ws: the Briefing across the products the PM picked. A ws: that one workspace, read as before.
|
|
101
|
+
if (!url.searchParams.get('ws')) {
|
|
102
|
+
const sel = products(ctx);
|
|
103
|
+
const r = await Prod.modelOfMany({ available: sel.available, engine: enginePath(), runtimeDir: ctx.runtimeDir });
|
|
104
|
+
ctx.json(res, r.state === 'error' ? { ok: false, ...r } : { ok: true, ...r }, r.state === 'ready' ? 200 : r.state === 'reading' ? 202 : 500);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
let root;
|
|
108
|
+
try { const w = ctx.getWs && ctx.getWs(url.searchParams.get('ws') || null); root = w && w.root; } catch { root = null; }
|
|
109
|
+
root = root || path.resolve(__dirname, '..', '..');
|
|
110
|
+
const r = await BP.modelOf({ root, engine: enginePath(), runtimeDir: ctx.runtimeDir });
|
|
111
|
+
// 200 with the model, 202 while the first read goes on, 500 with the reason when a read failed
|
|
112
|
+
ctx.json(res, r.state === 'error' ? { ok: false, ...r } : { ok: true, ...r }, r.state === 'ready' ? 200 : r.state === 'reading' ? 202 : 500);
|
|
113
|
+
return true;
|
|
114
|
+
},
|
|
115
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-initiative-premium.js — GET /api/initiative-premium?id=<id>[&ws=<id>&board=<id>] (initiative briefing-premium-initiative).
|
|
3
|
+
*
|
|
4
|
+
* 200 { ok, state: 'ready', model, as_of } | 202 { ok, state: 'reading' } | 404 { ok: false, state: 'error', error } | 500 { ok: false, state: 'error', error }
|
|
5
|
+
* model is lib/initiative-premium.js build(), made from the answers of the routes the production Initiatives sheet reads: /api/boards/initiative,
|
|
6
|
+
* /api/initiatives/<id>, /api/boards/items and /api/core-docs?level=initiative. Those routes derive the initiatives in the background and answer 202
|
|
7
|
+
* while they do, so this route answers 202 too and the page asks again: no request waits for the read. Read-only: the one press of the page (Draft it)
|
|
8
|
+
* goes to POST /api/core-docs/draft from the page. */
|
|
9
|
+
const InitPremium = require('./initiative-premium.js');
|
|
10
|
+
const boards = require('./routes-boards.js');
|
|
11
|
+
const initiatives = require('./routes-initiatives.js');
|
|
12
|
+
const coreDocs = require('./routes-core-docs.js');
|
|
13
|
+
|
|
14
|
+
/** GET `pathAndQuery` from a route module's own handler, as the Work page's route does, and return { status, body }. */
|
|
15
|
+
async function via(mod, pathAndQuery, req, ctx) {
|
|
16
|
+
let out = { status: 500, body: null };
|
|
17
|
+
const c = Object.assign({}, ctx, { json: (res, obj, code) => { out = { status: code || 200, body: obj }; } });
|
|
18
|
+
const u = new URL('http://local' + pathAndQuery);
|
|
19
|
+
const handled = await mod.handle({ method: 'GET', headers: (req && req.headers) || {} }, {}, u, c);
|
|
20
|
+
return handled === false ? { status: 404, body: null } : out;
|
|
21
|
+
}
|
|
22
|
+
const qs = (o) => '?' + Object.entries(o).filter(([, v]) => v != null && v !== '').map(([k, v]) => k + '=' + encodeURIComponent(v)).join('&');
|
|
23
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/;
|
|
24
|
+
|
|
25
|
+
module.exports = {
|
|
26
|
+
prefixes: ['/api/initiative-premium'],
|
|
27
|
+
handle: async (req, res, url, ctx) => {
|
|
28
|
+
if (url.pathname !== '/api/initiative-premium') return false;
|
|
29
|
+
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
30
|
+
const fail = (error, code) => { ctx.json(res, { ok: false, state: 'error', error: String(error).slice(0, 240) }, code || 500); return true; };
|
|
31
|
+
try {
|
|
32
|
+
const id = url.searchParams.get('id') || '';
|
|
33
|
+
if (!ID_RE.test(id)) return fail('id must name an initiative', 400);
|
|
34
|
+
const list = await via(boards, '/api/boards', req, ctx);
|
|
35
|
+
if (!list.body || list.body.ok === false) return fail((list.body && list.body.error) || 'the boards could not be listed');
|
|
36
|
+
const all = list.body.boards || [];
|
|
37
|
+
const wantWs = url.searchParams.get('ws'), wantId = url.searchParams.get('board');
|
|
38
|
+
const pick = all.find((b) => (!wantWs || b.ws === wantWs) && (!wantId || b.id === wantId)) || all[0];
|
|
39
|
+
if (!pick) return fail('no board is registered');
|
|
40
|
+
const q = qs({ ws: pick.ws, board: pick.id });
|
|
41
|
+
const head = await via(boards, '/api/boards/initiative' + q + '&id=' + encodeURIComponent(id), req, ctx);
|
|
42
|
+
if (head.status === 202 || (head.body && head.body.pending)) { ctx.json(res, { ok: true, state: 'reading' }, 202); return true; }
|
|
43
|
+
if (head.status === 404) return fail((head.body && head.body.error) || id + ' is not an initiative on a board', 404);
|
|
44
|
+
if (!head.body || head.body.ok === false) return fail((head.body && head.body.error) || 'the initiative could not be read');
|
|
45
|
+
const own = head.body.board || pick;
|
|
46
|
+
const missing = [];
|
|
47
|
+
const [sheet, items, docs] = await Promise.all([
|
|
48
|
+
via(initiatives, '/api/initiatives/' + encodeURIComponent(id) + qs({ ws: pick.ws }), req, ctx),
|
|
49
|
+
via(boards, '/api/boards/items' + qs({ ws: pick.ws, board: own.id }), req, ctx),
|
|
50
|
+
via(coreDocs, '/api/core-docs' + qs({ level: 'initiative', ws: pick.ws, board: own.id, id }), req, ctx),
|
|
51
|
+
]);
|
|
52
|
+
const ok = (r, name) => { if (r.status === 202 || !r.body || r.body.ok === false || r.body.pending) { missing.push(name); return null; } return r.body; };
|
|
53
|
+
const sh = ok(sheet, 'the initiative sheet'), it = ok(items, 'tracker items'), dd = ok(docs, 'core documents');
|
|
54
|
+
// the workflow engine's own ladder for this one initiative: each step with the evidence it states, and the next step (the sheet's stepper holds neither)
|
|
55
|
+
let lad = null;
|
|
56
|
+
try { const w = ctx.getWs ? ctx.getWs(pick.ws) : null; const r = w && ctx.consoleState && ctx.consoleState.ladder ? ctx.consoleState.ladder({ root: w.root, id }) : null; lad = r && r.ok && r.row ? r.row : null; } catch { lad = null; }
|
|
57
|
+
if (!lad) missing.push('workflow steps');
|
|
58
|
+
const model = InitPremium.build({
|
|
59
|
+
now: Date.now(), pending: false, missing: missing.filter((m) => m !== 'core documents'),
|
|
60
|
+
board: { ws: own.ws || pick.ws, id: own.id, name: own.name, workflow: own.workflow },
|
|
61
|
+
row: head.body.row, sheet: sh, items: it ? it.items : [], docs: dd, ladder: lad,
|
|
62
|
+
boards: all.map((x) => ({ ws: x.ws, id: x.id, name: x.name })), siblings: it ? (it.initiatives || []).map((x) => ({ id: x.id, title: x.title })) : [],
|
|
63
|
+
});
|
|
64
|
+
ctx.json(res, { ok: true, state: 'ready', model, as_of: Date.now() });
|
|
65
|
+
return true;
|
|
66
|
+
} catch (e) { return fail((e && e.message) || e); }
|
|
67
|
+
},
|
|
68
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-my-work-premium.js — GET /api/my-work-premium (initiative briefing-premium-work).
|
|
3
|
+
*
|
|
4
|
+
* { ok, state: 'ready', model, as_of } | { ok: false, error }
|
|
5
|
+
* model is lib/my-work-premium.js build(): the hero, the plate, the agents, the waiting list, the dock. It is built from the answer of GET /api/pm-items,
|
|
6
|
+
* which the Work page reads, so the two pages show one list. Read-only: every press goes to the existing /api/pm-items routes from the page. */
|
|
7
|
+
const WP = require('./my-work-premium.js');
|
|
8
|
+
const pmItems = require('./routes-pm-items.js');
|
|
9
|
+
|
|
10
|
+
/** GET /api/pm-items, answered by the route's own handler, so this page reads exactly what the Work page reads. */
|
|
11
|
+
async function listView(req, ctx) {
|
|
12
|
+
let body = null;
|
|
13
|
+
const c = Object.assign({}, ctx, { json: (res, obj) => { body = obj; } });
|
|
14
|
+
await pmItems.handle({ method: 'GET', headers: (req && req.headers) || {} }, {}, new URL('http://local/api/pm-items'), c);
|
|
15
|
+
return body;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
module.exports = {
|
|
19
|
+
prefixes: ['/api/my-work-premium'],
|
|
20
|
+
handle: async (req, res, url, ctx) => {
|
|
21
|
+
if (url.pathname !== '/api/my-work-premium') return false;
|
|
22
|
+
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
23
|
+
let v = null, err = null;
|
|
24
|
+
try { v = await listView(req, ctx); } catch (e) { err = String((e && e.message) || e).slice(0, 200); }
|
|
25
|
+
if (!v || v.ok === false) { ctx.json(res, { ok: false, state: 'error', error: err || (v && v.error) || 'the list could not be read' }, 500); return true; }
|
|
26
|
+
let model;
|
|
27
|
+
try { model = WP.build({ v, now: Date.now() }); } catch (e) { ctx.json(res, { ok: false, state: 'error', error: 'the page could not be built: ' + String((e && e.message) || e).slice(0, 160) }, 500); return true; }
|
|
28
|
+
ctx.json(res, { ok: true, state: 'ready', model, as_of: Date.now() });
|
|
29
|
+
return true;
|
|
30
|
+
},
|
|
31
|
+
};
|