create-pmos 0.253.0 → 0.255.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.
@@ -0,0 +1,140 @@
1
+ 'use strict';
2
+ /* lib/briefing-premium-sources.js — mail, Jira and Confluence on the premium Briefing (initiative briefing-premium-sources, S1 to S9).
3
+ *
4
+ * build({ brief, myWork, itemLines, today, products }) → { rows, digests, state, notRead } PURE.
5
+ * readSources(ctx, now) → { brief, myWork, itemLines, today } what the route reads.
6
+ *
7
+ * brief = { state: 'ok' | 'absent' | 'stale' | 'bad', brief? }: today's morning brief (lib/brief.js, <state>/briefs/<date>.json).
8
+ * products = (initiative id) → { ws, name } | null: the included products' initiatives. A row names a product only through it.
9
+ *
10
+ * THE RULE: a sentence, chip, name, count or link is made only from a field the brief or the ticket holds. A field it does not hold is left out.
11
+ * A row has no write: nothing here sends, replies, posts or records. PURE except readSources. */
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const Real = require('./briefing-real.js');
15
+ const { localDate } = require('./brief.js');
16
+
17
+ const SOURCE_WORD = { mail: 'Mail', jira: 'Jira', confluence: 'Confluence', slack: 'Slack' };
18
+ const ROW_SOURCES = ['mail', 'jira', 'confluence'];
19
+ const VERB_WORD = { reply: 'Reply', review: 'Review', decide: 'Decide', 'sign-off': 'Sign off', read: 'Read', do: 'Do' };
20
+ const EFFORT_WORD = { minutes: 'minutes', 'an-hour': 'an hour', 'half-day': 'half a day', day: 'a day' };
21
+ const ACROSS = 'Across products';
22
+ const clip = (s, n) => { s = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s; };
23
+ const text = (v, n) => (typeof v === 'string' && v.trim() ? clip(v, n) : '');
24
+ const https = (v) => { const s = String(v || ''); try { return s.length <= 500 && new URL(s).protocol === 'https:' ? s : null; } catch { return null; } };
25
+ const list = (v) => (Array.isArray(v) ? v : []);
26
+ const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
27
+ const pad = (n) => String(n).padStart(2, '0');
28
+ const hhmm = (ms) => { const d = new Date(ms); return pad(d.getHours()) + ':' + pad(d.getMinutes()); };
29
+
30
+ /** The items that become rows: a mail, Jira or Confluence item the PM owns, or that mentions the PM, or that is worth asking. */
31
+ function rowItems(brief) {
32
+ const keep = (x) => x && typeof x === 'object' && x.id && text(x.title, 140) && ROW_SOURCES.includes(x.source);
33
+ const asks = list(brief.worth_asking).filter((x) => keep(x));
34
+ const mine = list(brief.priorities).filter((x) => keep(x) && (x.owner === 'pm' || x.mention === true));
35
+ return [...mine.map((x) => ({ x, ask: false })), ...asks.map((x) => ({ x, ask: true }))];
36
+ }
37
+
38
+ function itemRow(x, ask, brief, products) {
39
+ const src = SOURCE_WORD[x.source];
40
+ const verb = VERB_WORD[x.verb] || '';
41
+ const todo = text(x.todo, 200);
42
+ const hit = x.initiative_id && products ? products(x.initiative_id) : null;
43
+ const url = https(x.link);
44
+ const facts = [['Source', src], ['From', text(x.who, 120)], ['Where', text(x.where, 120)], ['About', text(x.about, 480)], ['Asked', text(x.ask, 240)], ['Quote', text(x.quote, 280)], ['Evidence', text(x.evidence, 240)],
45
+ ['Confidence', typeof x.confidence === 'number' ? Math.round(x.confidence * 100) + '%' + (ask ? ', below the bar: ask before acting' : '') : ''], ['Effort', EFFORT_WORD[x.effort] || ''],
46
+ ['Reply needed', typeof x.reply_needed === 'boolean' ? (x.reply_needed ? 'Yes' : 'No') : ''], ['Initiative', hit ? hit.name + ' · ' + x.initiative_id : ''], ['Due', text(x.due, 40)]].filter((f) => f[1]);
47
+ const why = text(x.why_now, 240);
48
+ return {
49
+ kind: 'source', key: 'src:' + x.id, title: text(x.title, 140),
50
+ subject: [src, text(x.who, 120), x.mention ? 'mentions you' : '', ask ? 'worth asking' : ''].filter(Boolean).join(' · '),
51
+ prepared: verb && todo ? verb + ': ' + todo : todo || verb || 'No prepared line is on record.',
52
+ skip: why ? 'Why now: ' + why : 'No reason is on record.',
53
+ product: hit ? hit.name : ACROSS, from: ['brief:' + x.id], detail: { source: x.source },
54
+ dialog: {
55
+ eye: src, logTitle: 'Read from the morning brief', title: text(x.title, 140), sub: [src, text(x.who, 120)].filter(Boolean).join(' · '),
56
+ log: ['Read the morning brief of ' + brief.date, 'Found this ' + src + ' item', hit ? 'It names the initiative ' + x.initiative_id + ' of ' + hit.name : 'It names no initiative of an included product, so it is not tied to a product'],
57
+ say: text(x.about, 480) || why || 'The brief holds no more on this item.',
58
+ head: 'The item', facts, opts: [], sel: null, nodef: '', link: url, linkLabel: 'Open in ' + src,
59
+ pane: { label: src, file: 'briefs/' + brief.date + '.json', h: text(x.title, 140), intro: why, rows: facts, paras: [], parasTitle: '' },
60
+ },
61
+ };
62
+ }
63
+
64
+ function ticketRow(n, today) {
65
+ const url = https(n.url);
66
+ const label = { clock: 'Due', alert: 'Blocked', reply: 'Asked', inbox: 'Status' };
67
+ const fact = (f) => { const t = String(f.text || ''); return [label[f.icon] || '', f.icon === 'clock' ? t.replace(/^Due /, '') : f.icon === 'inbox' ? t.split(' · ').slice(1).join(' · ') : t]; };
68
+ const facts = [['Source', 'Jira'], ['Ticket', n.src || ''], ...list(n.facts).map(fact)].filter((f) => f[0] && f[1]);
69
+ return {
70
+ kind: 'source', key: 'src:' + n.itemId, title: text(n.headline, 140),
71
+ subject: ['Jira', n.src, 'assigned to you'].filter(Boolean).join(' · '),
72
+ prepared: text(n.why, 200) || 'No prepared line is on record.',
73
+ skip: n.dueText ? cap(n.dueText) + '.' : 'No due date is on record.',
74
+ product: ACROSS, from: ['jira:' + n.itemId], detail: { source: 'jira' },
75
+ dialog: {
76
+ eye: 'Jira', logTitle: 'Read from your Jira tickets', title: text(n.headline, 140), sub: 'Jira · ' + (n.src || ''),
77
+ log: ['Read the tickets assigned to you', 'Checked your Work list for a done or put-off copy', 'Found none, so it stays'],
78
+ say: text(n.why, 200), head: 'The ticket', facts, opts: [], sel: null, nodef: '', link: url, linkLabel: 'Open ' + (n.src || 'the ticket') + ' in Jira',
79
+ pane: { label: 'Jira', file: 'Jira · assigned to you', h: text(n.headline, 140), intro: text(n.why, 200), rows: facts, paras: [], parasTitle: '' },
80
+ },
81
+ };
82
+ }
83
+
84
+ function digestItem(d, brief) {
85
+ const at = Date.parse(brief.generated_at);
86
+ if (!Number.isFinite(at) || !SOURCE_WORD[d.source] || !text(d.name, 120)) return null;
87
+ const dec = list(d.decisions).filter((s) => text(s, 140)), open = list(d.open).filter((s) => text(s, 140)), goals = list(d.goals).filter((s) => text(s, 140));
88
+ const src = SOURCE_WORD[d.source];
89
+ const sub = [dec.length ? dec.length + (dec.length === 1 ? ' decision' : ' decisions') : '', open.length ? open.length + ' open' : '', goals.length ? goals.length + (goals.length === 1 ? ' goal' : ' goals') : ''].filter(Boolean).join(' · ') || 'No lines on record';
90
+ const facts = [['Source', src], ['Name', text(d.name, 120)], ['Decisions', String(dec.length)], ['Open', String(open.length)], ['Goals', goals.length ? String(goals.length) : '']].filter((f) => f[1]);
91
+ return {
92
+ at, t: hhmm(at), kind: 'digest', title: src + ' · ' + text(d.name, 120), sub, src: 'Digests',
93
+ dialog: {
94
+ eye: 'Digest', logTitle: 'Read from the morning brief', title: src + ' · ' + text(d.name, 120), sub: sub,
95
+ log: ['Read the morning brief of ' + brief.date, 'Found the ' + src + ' digest of ' + text(d.name, 120)],
96
+ say: 'This is what the brief holds for ' + text(d.name, 120) + '. It is a summary, not a copy of the source.',
97
+ head: 'The digest', facts, opts: [], sel: null, nodef: '', link: https(d.link), linkLabel: 'Open in ' + src,
98
+ pane: { label: 'Digest', file: 'briefs/' + brief.date + '.json', h: src + ' · ' + text(d.name, 120), intro: '', rows: facts, parasTitle: 'What the digest holds',
99
+ paras: [...dec.map((s) => 'Decision: ' + text(s, 140)), ...open.map((s) => 'Open: ' + text(s, 140)), ...goals.map((s) => 'Goal: ' + text(s, 140))] },
100
+ },
101
+ };
102
+ }
103
+
104
+ /** The sources the brief says it did not read, named for the header: only the four that rows and digests come from. */
105
+ function notReadOf(brief) {
106
+ const st = brief.sources && typeof brief.sources === 'object' ? brief.sources : {};
107
+ return ['mail', 'jira', 'confluence', 'slack'].filter((k) => st[k] === 'not-reached' || st[k] === 'off')
108
+ .map((k) => SOURCE_WORD[k].toLowerCase() + (st[k] === 'off' ? ' (switched off)' : ' (the brief says it was not reached)'));
109
+ }
110
+
111
+ function build({ brief, myWork, itemLines, today, products } = {}) {
112
+ const out = { rows: [], digests: [], state: 'absent', notRead: [], from: [] };
113
+ const b = brief && brief.state === 'ok' && brief.brief && brief.brief.date === today ? brief.brief : null;
114
+ out.state = b ? 'ok' : brief && brief.state === 'ok' ? 'stale' : (brief && brief.state) || 'absent';
115
+ const tickets = (Real.build({ brief: null, myWork, itemLines, today }).needs || []).filter((n) => n.origin === 'jira' && n.itemId)
116
+ .sort((a, c) => a._rank - c._rank || (c._late || 0) - (a._late || 0));
117
+ out.rows.push(...tickets.map((n) => ticketRow(n, today)));
118
+ if (b) {
119
+ out.rows.push(...rowItems(b).map(({ x, ask }) => itemRow(x, ask, b, products)));
120
+ out.digests = list(b.digests).map((d) => digestItem(d, b)).filter(Boolean);
121
+ out.notRead = notReadOf(b);
122
+ }
123
+ return out;
124
+ }
125
+
126
+ /** What the route reads each request. A source that fails gives nothing. */
127
+ async function readSources(ctx, now) {
128
+ const today = localDate(now);
129
+ const dir = ctx && ctx.runtimeDir;
130
+ const out = { brief: { state: 'absent' }, myWork: [], itemLines: [], today };
131
+ if (!dir) return out;
132
+ let raw = null;
133
+ try { raw = fs.readFileSync(path.join(dir, 'briefs', today + '.json'), 'utf8'); } catch { /* no brief for today */ }
134
+ if (raw !== null) { try { const j = JSON.parse(raw); out.brief = j && typeof j === 'object' && !Array.isArray(j) ? { state: 'ok', brief: j } : { state: 'bad' }; } catch { out.brief = { state: 'bad' }; } }
135
+ const real = await Real.readSources(ctx, new Date(now), today).catch(() => null);
136
+ if (real) { out.myWork = real.myWork; out.itemLines = real.itemLines; }
137
+ return out;
138
+ }
139
+
140
+ module.exports = { build, readSources, ACROSS, SOURCE_WORD };
@@ -21,6 +21,7 @@ const fs = require('fs');
21
21
  const path = require('path');
22
22
  const { execFile } = require('child_process');
23
23
  const W = require('./briefing-premium-writes.js');
24
+ const Src = require('./briefing-premium-sources.js');
24
25
 
25
26
  const DAY = 86400000;
26
27
  const MAX_ROWS = 7;
@@ -35,6 +36,7 @@ const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July',
35
36
  const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
36
37
  const dateLine = (ms) => { const d = new Date(ms); return WEEKDAYS[d.getDay()] + ' ' + d.getDate() + ' ' + MONTHS[d.getMonth()]; };
37
38
  /* A short name for a sentence: the title up to its first dash, colon or bracket, without a leading article. */
39
+ const joinNames = (a) => (a.length < 2 ? a.join('') : a.slice(0, -1).join(', ') + ' and ' + a[a.length - 1]);
38
40
  const shortName = (t) => clip(String(t || '').split(/\s[—–-]\s|:|\(/)[0].replace(/^(the|a|an)\s+/i, ''), 48);
39
41
  const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
40
42
  const shortDate = (ms) => { const d = new Date(ms); return d.getDate() + ' ' + MON[d.getMonth()]; };
@@ -75,6 +77,8 @@ const START_HOLD = 'Starting an agent from here arrives with the live conversati
75
77
  const tryD = (fn) => { try { return fn(); } catch { return null; } };
76
78
  const skipped = { n: 0 };
77
79
 
80
+ /** Two products can hold an initiative of the same name, so a lookup by id also names the workspace (none in a one-workspace read). */
81
+ const idk = (x, id) => (x.ws || '') + '|' + id;
78
82
  const live = (i) => i && !i.accepted && !i.stopped && !i.legacy;
79
83
 
80
84
  /* ── rows ─────────────────────────────────────────────────────────────────────────────────────────────────── */
@@ -206,15 +210,23 @@ function waitedRows(f) {
206
210
  }];
207
211
  }
208
212
 
209
- function rowsOf(f) {
210
- const every = [].concat(blockedRows(f), decisionRows(f), waitedRows(f));
213
+ /** A row of one product in a merged list: its key names the workspace (kind:id:ws) so two products never share a key, and it carries the product's name. */
214
+ const tagRow = (r, p) => Object.assign({}, r, { key: r.key + ':' + p.ws, product: p.name, ws: p.ws });
215
+ /** Every row of one set of facts, in the order the rules give, with those the PM put off split out. Not yet cut to the page's cap. */
216
+ function allRowsOf(f) {
217
+ let every = [].concat(blockedRows(f), decisionRows(f), waitedRows(f));
218
+ if (f.product) every = every.map((r) => tagRow(r, f.product));
211
219
  const off = (r) => (f.defers && f.defers[r.key] > f.now); // put off by the PM until a time that has not come
212
220
  const all = every.filter((r) => !off(r));
213
- const putOff = every.filter(off).map((r) => ({ key: r.key, title: r.title, until: f.defers[r.key] }));
221
+ const putOff = every.filter(off).map((r) => ({ key: r.key, title: r.title, until: f.defers[r.key], ...(r.product ? { product: r.product } : {}) }));
222
+ return { all, putOff };
223
+ }
224
+ const cut = (all, putOff) => {
214
225
  const rows = all.slice(0, MAX_ROWS).map((r, k) => Object.assign({ n: k + 1, first: k === 0 }, r));
215
226
  rows.forEach((r) => delete r.order);
216
227
  return { rows, more: Math.max(0, all.length - rows.length), putOff };
217
- }
228
+ };
229
+ function rowsOf(f) { const a = allRowsOf(f); return cut(a.all, a.putOff); }
218
230
 
219
231
  /* ── hero ─────────────────────────────────────────────────────────────────────────────────────────────────── */
220
232
  function correctionRate(runs, from, to) {
@@ -244,9 +256,10 @@ function heroOf(f, rows) {
244
256
  const clauses = [];
245
257
  if (blocked.length) {
246
258
  const ordering = blocked.every((r) => r.stepKey === 'spec' || r.stepKey === 'prototype');
247
- clauses.push({ t: num(blocked.length) + ' ' + plural(blocked.length, 'initiative is', 'initiatives are') + ' blocked' + (ordering ? ' on ordering' : ''), from: blocked.map((r) => r.from[0]) });
259
+ const where = [...new Set(blocked.map((r) => r.product).filter(Boolean))];
260
+ clauses.push({ t: num(blocked.length) + ' ' + plural(blocked.length, 'initiative is', 'initiatives are') + ' blocked' + (ordering ? ' on ordering' : '') + (where.length ? ' in ' + joinNames(where) : ''), from: blocked.map((r) => r.from[0]) });
248
261
  }
249
- rest.forEach((r) => clauses.push({ t: r.hero, from: r.from }));
262
+ rest.forEach((r) => clauses.push({ t: r.hero + (r.product ? ' in ' + r.product : ''), from: r.from }));
250
263
  if (clauses.length) {
251
264
  const p = [['What is stuck is yours: ', 0]];
252
265
  clauses.forEach((c, k) => {
@@ -286,13 +299,13 @@ function mergeDialog(m, f) {
286
299
  };
287
300
  }
288
301
  function runDialog(r, t, byId) {
289
- const i = byId.get(r.initiative_id), ap = r.pre_run_contract && r.pre_run_contract.PM_approved === true;
302
+ const i = byId.get(idk(r, r.initiative_id)), ap = r.pre_run_contract && r.pre_run_contract.PM_approved === true;
290
303
  const facts = [['Initiative', (i && i.title) ? clip(i.title, 70) : r.initiative_id], ['Run', r.run_id], ['Lane', r.lane || (i && i.lane)], ['Corrections', Number.isFinite(Number(r.human_corrections)) ? String(r.human_corrections) : ''], ['Contract approved', r.pre_run_contract ? (ap ? 'Yes' : 'No') : ''], ['Recorded', longDate(t) + ' ' + hhmm(t)]].filter((x) => x[1]);
291
304
  return {
292
305
  eye: 'Agent run', logTitle: 'Read from the run log', title: i ? clip(i.title, 90) : r.initiative_id, sub: hhmm(t) + ' · agent run',
293
306
  log: ['Read planning/runs/agent-runs.jsonl', 'Matched the run to its initiative' + (i ? '' : ' (no record found)'), 'Read its recorded fields'],
294
307
  say: 'This run is logged. Nothing needs you unless you disagree with it.',
295
- head: 'The run', facts, opts: [], sel: null, nodef: '', action: 'Return this run to the agent', write: { kind: 'return', run: r.run_id },
308
+ head: 'The run', facts, opts: [], sel: null, nodef: '', action: 'Return this run to the agent', write: { kind: 'return', run: r.run_id, ...(r.ws ? { ws: r.ws } : {}) },
296
309
  pane: { label: 'Run record', file: 'planning/runs/agent-runs.jsonl', h: i ? clip(i.title, 90) : r.initiative_id, intro: '', rows: facts, paras: r.note ? [clip(r.note, 600)] : [], parasTitle: 'Its note' },
297
310
  };
298
311
  }
@@ -314,13 +327,14 @@ function todayOf(f) {
314
327
  for (const m of f.meetings || []) {
315
328
  items.push({ at: m.start, t: hhmm(m.start), kind: 'meeting', title: m.title, sub: [Math.round((m.end - m.start) / 60000) + ' min', m.with].filter(Boolean).join(' · '), src: 'Calendar', dialog: tryD(() => meetingDialog(m)) });
316
329
  }
317
- for (const m of f.merges || []) if (m.at >= midnight && m.at <= f.now) items.push({ at: m.at, t: hhmm(m.at), kind: 'merge', title: clip(m.title, 110), sub: 'Merged to main', src: 'Merges', dialog: tryD(() => mergeDialog(m, f)) });
330
+ for (const m of f.merges || []) if (m.at >= midnight && m.at <= f.now) items.push({ at: m.at, t: hhmm(m.at), kind: 'merge', ...(m.product ? { product: m.product } : {}), title: clip(m.title, 110), sub: 'Merged to main', src: 'Merges', dialog: tryD(() => mergeDialog(m, f)) });
318
331
  const runs = (f.runs || []).filter((r) => { const t = Date.parse(r.created_at); return t >= midnight && t <= f.now; });
319
- const byId = new Map(f.initiatives.map((i) => [i.id, i]));
320
- for (const r of runs) { const t = Date.parse(r.created_at); items.push({ at: t, t: hhmm(t), kind: 'run', title: clip((byId.get(r.initiative_id) || {}).title || r.initiative_id, 110), sub: 'Agent run' + (Number.isFinite(Number(r.human_corrections)) ? ' · ' + r.human_corrections + ' ' + plural(Number(r.human_corrections), 'correction', 'corrections') : ''), src: 'Agent runs', dialog: tryD(() => runDialog(r, t, byId)) }); }
332
+ const byId = new Map(f.initiatives.map((i) => [idk(i, i.id), i]));
333
+ for (const r of runs) { const t = Date.parse(r.created_at); items.push({ at: t, t: hhmm(t), kind: 'run', ...(r.product ? { product: r.product } : {}), title: clip((byId.get(idk(r, r.initiative_id)) || {}).title || r.initiative_id, 110), sub: 'Agent run' + (Number.isFinite(Number(r.human_corrections)) ? ' · ' + r.human_corrections + ' ' + plural(Number(r.human_corrections), 'correction', 'corrections') : ''), src: 'Agent runs', dialog: tryD(() => runDialog(r, t, byId)) }); }
334
+ for (const d of f.digests || []) items.push(d);
321
335
  const ahead = items.filter((x) => x.at > f.asOf).sort((a, b) => a.at - b.at), earlier = items.filter((x) => x.at <= f.asOf).sort((a, b) => b.at - a.at);
322
336
  const count = (k) => items.filter((x) => x.kind === k).length;
323
- return { ahead, earlier, now: { t: hhmm(f.asOf), label: 'snapshot' }, counts: { meeting: count('meeting'), merge: count('merge'), run: count('run') }, note: ahead.length + earlier.length ? null : 'Nothing is recorded for today yet.' };
337
+ return { ahead, earlier, now: { t: hhmm(f.asOf), label: 'snapshot' }, counts: { meeting: count('meeting'), merge: count('merge'), run: count('run'), digest: count('digest') }, note: ahead.length + earlier.length ? null : 'Nothing is recorded for today yet.' };
324
338
  }
325
339
 
326
340
  /* ── the model ────────────────────────────────────────────────────────────────────────────────────────────── */
@@ -348,6 +362,64 @@ function build(f) {
348
362
  };
349
363
  }
350
364
 
365
+
366
+ /* ── several products (initiative briefing-premium-products) ──────────────────────────────────────────────── */
367
+ const KIND_RANK = { blocked: 0, decision: 1, waited: 2 };
368
+ const longest = (r) => { const d = (r.detail && r.detail.days) || []; return d.length ? d[d.length - 1] : 0; };
369
+
370
+ /** Several products' facts → ONE model, in the same shape build() makes.
371
+ * g = { now, asOf, meetings (shared, or null), defers { key: until }, available: [{ ws, name, home, on }],
372
+ * parts: [{ product: { ws, name }, f (that product's facts) | null, state: 'ready' | 'reading' | 'error', error? }] }
373
+ * A row belongs to one product (its key ends with the workspace id) and the rows are put in one order: blocked first, then decisions, then reviews.
374
+ * A count is never summed across products: each product gets its own failed-review row. The hero and Today read the merged facts. */
375
+ function buildMany(g) {
376
+ skipped.n = 0;
377
+ const now = g.now, asOf = g.asOf || g.now, meetings = g.meetings === undefined ? null : g.meetings;
378
+ const parts = (g.parts || []).map((p) => ({ product: p.product, state: p.state || 'ready', error: p.error, f: p.f ? Object.assign({ initiatives: [], runs: [], merges: [], evals: [], tripwires: null, missing: [], protoDecided: [] }, p.f, { now, asOf, meetings, defers: g.defers || {}, product: p.product }) : null }));
379
+ const read = parts.filter((p) => p.f && p.state !== 'error');
380
+ const every = [], putOff = [];
381
+ for (const p of read) { const a = allRowsOf(p.f); every.push(...a.all); putOff.push(...a.putOff); }
382
+ every.sort((a, b) => (KIND_RANK[a.kind] - KIND_RANK[b.kind]) || (a.kind === 'blocked' ? (a.order[0] - b.order[0]) || (a.order[1] < b.order[1] ? -1 : a.order[1] > b.order[1] ? 1 : 0) : a.kind === 'waited' ? longest(b) - longest(a) : 0));
383
+ // mail, Jira and Confluence (initiative briefing-premium-sources): their rows follow the repositories' rows, and a row names a product only when exactly one included product holds its initiative
384
+ const owner = new Map();
385
+ for (const p of read) for (const i of p.f.initiatives) owner.set(i.id, owner.has(i.id) ? null : { ws: p.product.ws, name: p.product.name });
386
+ const sx = g.sources ? Src.build(Object.assign({}, g.sources, { products: (id) => owner.get(id) || null })) : null;
387
+ const { rows, more } = cut(every.concat(sx ? sx.rows : []), putOff);
388
+ const tag = (list, p) => list.map((x) => Object.assign({}, x, { ws: p.product.ws, product: p.product.name }));
389
+ const cf = {
390
+ now, asOf, meetings, defers: g.defers || {},
391
+ initiatives: [].concat(...read.map((p) => p.f.initiatives.map((i) => Object.assign({}, i, { ws: p.product.ws })))),
392
+ runs: [].concat(...read.map((p) => tag(p.f.runs, p))),
393
+ merges: [].concat(...read.map((p) => tag(p.f.merges, p))),
394
+ evals: [].concat(...read.map((p) => tag(p.f.evals, p))),
395
+ digests: sx ? sx.digests : [],
396
+ };
397
+ const available = g.available || [], inc = available.filter((a) => a.on), out = available.filter((a) => !a.on);
398
+ const waiting = parts.filter((p) => p.state === 'reading' || !p.f).filter((p) => p.state !== 'error'), failed = parts.filter((p) => p.state === 'error');
399
+ const pending = parts.some((p) => p.state === 'reading' || (p.f && p.f.pending));
400
+ const missing = [];
401
+ if (meetings === null) missing.push('calendar');
402
+ for (const p of read) for (const k of p.f.missing || []) missing.push(k + ' (' + p.product.name + ')');
403
+ for (const p of failed) missing.push(p.product.name);
404
+ const missText = [].concat(meetings === null ? ['the calendar'] : [], ...read.map((p) => (p.f.missing || []).map((k) => (SOURCE_NAME[k] || k) + ' of ' + p.product.name)), failed.map((p) => p.product.name + ' could not be read'));
405
+ const none = !inc.length;
406
+ const hero = none ? [{ parts: [{ t: available.length ? 'No product is included, so there is nothing to read. Pick one under Products.' : 'No product workspace is registered, so there is nothing to read.', s: false }], from: [] }]
407
+ : heroOf(cf, rows);
408
+ const srcRows = rows.filter((r) => r.kind === 'source'), srcSeen = [...new Set(srcRows.map((r) => r.detail.source))];
409
+ if (srcRows.length) hero.push({ parts: [{ t: num(srcRows.length) + ' ' + plural(srcRows.length, 'item', 'items') + ' from ' + joinNames(srcSeen.map((k) => Src.SOURCE_WORD[k])), s: true }, { t: ' ' + plural(srcRows.length, 'needs', 'need') + ' you.', s: false }], from: srcRows.map((r) => r.from[0]) });
410
+ const sxNote = !sx ? '' : sx.state === 'ok' ? (sx.notRead.length ? ' · not read by the morning brief: ' + sx.notRead.join(', ') : '') : sx.state === 'bad' ? ' · the morning brief could not be read' : ' · no morning brief for today';
411
+ return {
412
+ date: dateLine(now),
413
+ pending,
414
+ header: { text: 'Real data · as of ' + hhmm(asOf) + ' · ' + (none ? 'no product included' : joinNames(inc.map((a) => a.name))) + (out.length ? ' · ' + joinNames(out.map((a) => a.name)) + ' not included' : '') + (missText.length ? ' · not read: ' + missText.join(', ') : '') + sxNote + (waiting.length ? ' · reading ' + joinNames(waiting.map((p) => p.product.name)) : '') + (skipped.n ? ' · ' + skipped.n + (skipped.n === 1 ? ' record' : ' records') + ' could not be read and ' + (skipped.n === 1 ? 'was' : 'were') + ' left out' : ''), missing },
415
+ hero: hero.length ? hero : [{ parts: [{ t: pending ? 'Reading the initiative state. What needs you is not known yet.' : 'Nothing needs you and nothing moved today that the records show.', s: false }], from: [] }],
416
+ list: { title: 'What you need to do', rows, more, count: rows.length + more, pendingNote: pending ? 'Blocked work, prototypes waiting for you and failed reviews appear when the initiative state has been read.' : null, putOff: putOff.map((x) => ({ key: x.key, title: x.title, ...(x.product ? { product: x.product } : {}), until: x.until, text: (x.product ? x.product + ': ' : '') + x.title + ' · back ' + backAt(x.until, now) })), order: 'In this order: blocked work first, then decisions only you can make, then what has waited longest.' },
417
+ today: todayOf(cf),
418
+ dock: { needs: pending ? null : rows.length + more, label: plural(rows.length + more, 'needs you', 'need you'), next: rows[0] ? rows[0].title : null },
419
+ products: { available: available.map((a) => ({ ws: a.ws, name: a.name, home: !!a.home, on: !!a.on })), none },
420
+ };
421
+ }
422
+
351
423
  /* ── I/O ──────────────────────────────────────────────────────────────────────────────────────────────────── */
352
424
  const readJsonl = (file) => { try { return fs.readFileSync(file, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch { return null; } };
353
425
  const run = (cmd, args, opts) => new Promise((res) => {
@@ -406,8 +478,18 @@ async function repoKey(root) {
406
478
  return head.trim() + ':' + require('crypto').createHash('sha1').update(st || '').digest('hex').slice(0, 12);
407
479
  }
408
480
 
481
+ /** Today's calendar rows, from the calendar PMOS holds (one for the whole computer, not one per workspace). null when it cannot be read. */
482
+ function readMeetings(runtimeDir, now) {
483
+ try {
484
+ const CE = require('./calendar-events.js');
485
+ if (!runtimeDir) throw new Error('no runtime dir');
486
+ const all = CE.readAll(runtimeDir, { now });
487
+ return CE.eventsOn(all, dayKey(now)).map((e) => ({ start: Date.parse(e.start), end: Date.parse(e.end), title: e.title, with: e.with || '', initiative: e.initiative || null })).filter((e) => Number.isFinite(e.start));
488
+ } catch { return null; }
489
+ }
490
+
409
491
  /** The quick read: runs, reviews, merges (with their files), the calendar and the kill conditions. No initiative state. */
410
- async function readFast({ root, now, runtimeDir }) {
492
+ async function readFast({ root, now, runtimeDir, calendar = true }) {
411
493
  const f = { now: now || Date.now(), asOf: now || Date.now(), initiatives: [], runs: [], merges: [], evals: [], meetings: null, tripwires: null, missing: [] };
412
494
  const runs = readJsonl(path.join(root, 'planning', 'runs', 'agent-runs.jsonl')); if (runs) f.runs = runs; else f.missing.push('runs');
413
495
  const evals = readJsonl(path.join(root, 'planning', 'runs', 'evals.jsonl')); if (evals) f.evals = evals; else f.missing.push('evals');
@@ -423,12 +505,7 @@ async function readFast({ root, now, runtimeDir }) {
423
505
  m.files = nums.length; m.ins = ins; m.del = del; m.dirs = Object.entries(top).sort((a, b) => b[1] - a[1]).slice(0, 3).map((e) => e[0]);
424
506
  m.body = lines.filter((x) => !/^(\d+|-)\t(\d+|-)\t/.test(x)).join('\n').trim();
425
507
  }
426
- try {
427
- const CE = require('./calendar-events.js');
428
- if (!runtimeDir) throw new Error('no runtime dir');
429
- const all = CE.readAll(runtimeDir, { now: f.now });
430
- f.meetings = CE.eventsOn(all, dayKey(f.now)).map((e) => ({ start: Date.parse(e.start), end: Date.parse(e.end), title: e.title, with: e.with || '', initiative: e.initiative || null })).filter((e) => Number.isFinite(e.start));
431
- } catch { f.meetings = null; }
508
+ if (calendar) f.meetings = readMeetings(runtimeDir, f.now);
432
509
  f.tripwires = readTripwires(root);
433
510
  f.protoDecided = [...W.decidedPrototypes(root)];
434
511
  f.defers = Object.fromEntries(W.readDefers(runtimeDir, f.now));
@@ -511,4 +588,4 @@ const peekModel = (root) => (kept && kept.root === root ? kept.model : partial &
511
588
  const settle = () => inflight || Promise.resolve();
512
589
  const forget = () => { kept = null; partial = null; inflight = null; lastError = null; startedAt = 0; };
513
590
 
514
- module.exports = { build, hooks, refresh, readSources, readFast, readInitiatives, repoKey, readFm, readTripwires, modelOf, settle, forget, rebuild, peekModel, rowsOf, heroOf, todayOf, failedReviews, num };
591
+ module.exports = { build, buildMany, allRowsOf, readMeetings, hooks, refresh, readSources, readFast, readInitiatives, repoKey, readFm, readTripwires, modelOf, settle, forget, rebuild, peekModel, rowsOf, heroOf, todayOf, failedReviews, num };
@@ -20,6 +20,24 @@ const { repoScript } = require('./repo-script.js');
20
20
  const { correctionEvent } = require(repoScript('card-state.js'));
21
21
  const { appendOutbox } = require(repoScript('outbox.js'));
22
22
 
23
+ const Prod = require('./briefing-premium-products.js');
24
+ const Src = require('./briefing-premium-sources.js');
25
+ const settings = require('./settings-store.js');
26
+
27
+ /** 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. */
28
+ function products(ctx, only) {
29
+ const home = ctx.homeRoot || ctx.selfRoot || null;
30
+ 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) }));
31
+ return Prod.choose({ workspaces: list, include: settings.get(ctx.runtimeDir, 'ws.briefing.include', null), only });
32
+ }
33
+ /** 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. */
34
+ function ownerOf(ctx, ws) {
35
+ const a = products(ctx).available.find((x) => x.ws === ws);
36
+ if (!a) return { error: 'that product is not registered' };
37
+ if (!a.on) return { error: a.name + ' is not included in the Briefing' };
38
+ return { root: a.root, ws: a.ws };
39
+ }
40
+
23
41
  const enginePath = () => ENGINE_PATHS.find((p) => fs.existsSync(p)) || null;
24
42
 
25
43
  const POSTS = ['decide', 'put-off', 'return', 'ask'];
@@ -37,21 +55,29 @@ async function post(req, res, url, ctx) {
37
55
  const b = got.body;
38
56
  const g = guard.requirePagePress(req, b);
39
57
  if (!g.ok) { ctx.json(res, { ok: false, error: g.error }, g.status); return true; }
40
- const root = rootOf(url, ctx), runtimeDir = ctx.runtimeDir;
58
+ const runtimeDir = ctx.runtimeDir;
59
+ // 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.
60
+ const sk = Prod.splitKey(b.key);
61
+ let root = rootOf(url, ctx), merged = false;
62
+ if (sk || (act === 'return' && b.ws)) {
63
+ const o = ownerOf(ctx, sk ? sk.ws : String(b.ws));
64
+ if (o.error) { ctx.json(res, { ok: false, error: o.error }, 400); return true; }
65
+ root = o.root; merged = true;
66
+ }
41
67
  if (act === 'ask') {
42
- let wsId; try { const w = ctx.getWs && ctx.getWs(url.searchParams.get('ws') || null); wsId = w && w.id; } catch { wsId = undefined; }
43
- 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() : BP.peekModel(root), spawnSession: ctx.spawnSession, wsId });
68
+ 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; }
69
+ 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 || /^src:/.test(String(b.key || '')) ? Prod.peekModel() : BP.peekModel(root), spawnSession: ctx.spawnSession, wsId });
44
70
  ctx.json(res, a.body, a.status);
45
71
  return true;
46
72
  }
47
73
  let r;
48
74
  try {
49
- if (act === 'decide') r = Wr.decide({ root, key: b.key, choice: b.choice, why: b.why });
75
+ if (act === 'decide') r = Wr.decide({ root, key: sk ? sk.base : b.key, choice: b.choice, why: b.why });
50
76
  else if (act === 'put-off') r = Wr.putOff({ runtimeDir, key: b.key, until: b.until });
51
77
  else r = Wr.returnRun({ root, runtimeDir, run: b.run, reason: b.reason, correctionEvent, appendOutbox });
52
78
  } catch (e) { r = { ok: false, status: 500, error: 'the record could not be written: ' + String((e && e.message) || e).slice(0, 160) }; }
53
79
  if (!r.ok) { ctx.json(res, { ok: false, error: r.error }, r.status || 400); return true; }
54
- try { await BP.rebuild({ root, runtimeDir }); } catch { /* the page re-reads on its next poll */ }
80
+ try { if (merged) await Prod.rebuildOne({ root, runtimeDir }); else await BP.rebuild({ root, runtimeDir }); } catch { /* the page re-reads on its next poll */ }
55
81
  ctx.json(res, { ok: true, wrote: act, ...(r.heading ? { heading: r.heading } : {}) });
56
82
  return true;
57
83
  }
@@ -72,6 +98,14 @@ module.exports = {
72
98
  }
73
99
  if (url.pathname !== '/api/briefing-premium') return false;
74
100
  if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
101
+ // No ws: the Briefing across the products the PM picked. A ws: that one workspace, read as before.
102
+ if (!url.searchParams.get('ws')) {
103
+ const sel = products(ctx);
104
+ const sources = await Src.readSources(ctx, Date.now()).catch(() => null);
105
+ const r = await Prod.modelOfMany({ available: sel.available, engine: enginePath(), runtimeDir: ctx.runtimeDir, sources });
106
+ ctx.json(res, r.state === 'error' ? { ok: false, ...r } : { ok: true, ...r }, r.state === 'ready' ? 200 : r.state === 'reading' ? 202 : 500);
107
+ return true;
108
+ }
75
109
  let root;
76
110
  try { const w = ctx.getWs && ctx.getWs(url.searchParams.get('ws') || null); root = w && w.root; } catch { root = null; }
77
111
  root = root || path.resolve(__dirname, '..', '..');
@@ -33,6 +33,8 @@ const bool = (section, scope, def) => ({ section, scope, type: 'bool', def });
33
33
  const DEFS = Object.freeze({
34
34
  'ws.start': one('workspaces', 'computer', ['last', 'ask'], 'last'),
35
35
  'ws.briefing': one('workspaces', 'computer', ['one', 'all'], 'one'),
36
+ // the premium Briefing: which workspaces it reads (initiative briefing-premium-products). null = every one except the home workspace.
37
+ 'ws.briefing.include': { section: 'workspaces', scope: 'computer', type: 'ids', max: 50, def: null },
36
38
  'board.template': one('boards', 'ws', ['product', 'project', 'simple'], 'product'),
37
39
  'board.rules': one('boards', 'ws', ['warn', 'off'], 'warn'),
38
40
  'board.templates': { section: 'boards', scope: 'computer', type: 'templates', def: TEMPLATES },
@@ -121,6 +123,7 @@ function validate(key, value) {
121
123
  if (typeof value !== 'string' || value.length > 64) return `${key} must be a time zone such as Asia/Jakarta, or empty for this computer's`;
122
124
  try { new Intl.DateTimeFormat('en', { timeZone: value }); return null; } catch { return `${value} is not a time zone this computer knows`; }
123
125
  }
126
+ case 'ids': return value === null || Array.isArray(value) && value.length <= d.max && value.every((x) => typeof x === 'string' && WS_ID.test(x)) && new Set(value).size === value.length ? null : `${key} must list at most ${d.max} workspace ids, each once`;
124
127
  case 'templates': {
125
128
  if (!value || typeof value !== 'object' || Array.isArray(value)) return `${key} must name the templates`;
126
129
  const ids = Object.keys(value);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.253.0",
3
+ "version": "0.255.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.253.0",
9
+ "version": "0.255.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.253.0",
3
+ "version": "0.255.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -151,6 +151,7 @@ input[type=range]:active::-webkit-slider-thumb{transform:scale(1.15)}
151
151
  .dbody .kv{display:grid;grid-template-columns:110px minmax(0,1fr);gap:6px 14px;font-size:15px}
152
152
  .dbody .kv span:nth-child(odd){color:var(--ink-3)}
153
153
  .cta{height:48px;padding:0 24px;border-radius:var(--r-chip);background:var(--solid);color:var(--on-solid);font-weight:650;display:inline-flex;align-items:center;gap:8px;transition:transform .35s var(--spring)}
154
+ a.cta{text-decoration:none}
154
155
  .cta:hover{transform:translateY(-2px)}
155
156
  .cta:active{transform:scale(.97)}
156
157
  .cta svg{width:18px;height:18px}
@@ -1196,3 +1197,17 @@ button.dsn:hover{box-shadow:inset 0 0 0 1.5px rgb(79,109,245)}
1196
1197
  .crmenu{max-height:min(60vh,420px);overflow:auto;overscroll-behavior:contain}
1197
1198
  .crmenu button{flex:none}
1198
1199
  .crmenu button span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1200
+
1201
+ /* the Briefing across products (initiative briefing-premium-products): the product name on a row, and the picker built from the mockup's chip and menu */
1202
+ .pn{font:600 12.5px var(--mono);letter-spacing:.04em;color:var(--ink-2);margin-right:8px;padding:2px 8px;border-radius:999px;background:var(--well);white-space:nowrap}
1203
+ .pkw{position:relative;display:inline-flex;align-items:center}
1204
+ .pkw[hidden]{display:none}
1205
+ .pkw .fchip{height:44px}
1206
+ .pkmenu{left:auto;right:0;min-width:240px}
1207
+ .pkmenu .bx{flex:none;width:20px;height:20px;border-radius:6px;border:2px solid var(--ink-3);display:grid;place-items:center}
1208
+ .pkmenu button[aria-checked="true"] .bx{background:var(--solid);border-color:var(--solid)}
1209
+ .pkmenu button[aria-checked="true"] .bx::after{content:"";width:9px;height:5px;border-left:2px solid var(--on-solid);border-bottom:2px solid var(--on-solid);transform:rotate(-45deg) translate(1px,-1px)}
1210
+ .pkmenu .pkreset{margin-top:4px;border-top:1px solid var(--hair);border-radius:0 0 14px 14px;color:var(--ink-2)}
1211
+ .pkempty{padding:10px 14px}
1212
+ .top{position:relative;z-index:6}
1213
+ .pkw{z-index:40}