create-pmos 0.254.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.254.0",
3
+ "version": "0.255.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@be12de7
2
- assembled: 2026-10-17
1
+ kernel-source: pmos@c9a1e5a
2
+ assembled: 2026-10-18
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-17T14:34:03Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-18T00:27:07Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-10-17T14:34:03Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-18T00:27:07Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-10-17T14:34:03Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-18T00:27:07Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  Newest first, ISO dates.
4
4
 
5
- ## 2026-10-17
5
+ ## 2026-10-18
6
6
 
7
7
  - Adopted OKF v0.2 (D73).
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /* briefing-premium-sources-test.js — mail, Jira and Confluence on the premium Briefing (initiative briefing-premium-sources; planning/prd/briefing-premium-sources-prd.md S1 to S11).
4
+ * The real server with one product repository and a made-up morning brief written to <state>/briefs/<today>.json. Nothing here touches the PM's real briefs.
5
+ * S1/S8 no brief for today: the header says so S2/S5 the rows, each tagged Across products (the brief names no included initiative)
6
+ * S3 the dialog opens an https link and records nothing S7 the digests in Today S9 the writes refuse a source key; the agent answers
7
+ * S11 the source row and the digest look like the mockup's own row and Today item at 1280 and 390 px, light and dark; no sideways scroll
8
+ * Every wait polls the page; no fixed sleep stands in for a state. */
9
+ const fs = require('fs');
10
+ const os = require('os');
11
+ const path = require('path');
12
+ const { spawn, spawnSync } = require('child_process');
13
+ const { findBrowser } = require('./lib/find-browser');
14
+ const T = require('./test-run').configure({ name: 'briefing-premium-sources' });
15
+
16
+ const BASE = T.BASE;
17
+ const MOCKUP = path.join(__dirname, '..', 'prototype', 'premium-ux-concept', 'briefing-artifact.html');
18
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
19
+ const until = async (fn, ms = 15000, step = 150) => { const t0 = Date.now(); for (;;) { let v = null; try { v = await fn(); } catch { v = null; } if (v) return v; if (Date.now() - t0 > ms) return v; await sleep(step); } };
20
+ const results = [];
21
+ const ok = (name, cond, extra = '') => { results.push({ name, cond: !!cond }); console.log(`${cond ? '✓' : '✗'} ${name}${!cond && extra !== '' ? ' — ' + String(extra).slice(0, 300) : ''}`); };
22
+ let srv = null; let browser = null;
23
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pmos-bps-'));
24
+ const finish = async (code) => {
25
+ if (browser) await browser.close().catch(() => {});
26
+ if (srv) { try { srv.kill('SIGKILL'); } catch { /* gone */ } }
27
+ T.killTmux();
28
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* left for the OS */ }
29
+ const bad = results.filter((x) => !x.cond).length;
30
+ console.log(`\n${results.length - bad}/${results.length} briefing-premium-sources assertions passed`);
31
+ process.exit(code !== undefined ? code : (bad ? 1 : 0));
32
+ };
33
+ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
34
+
35
+ const pad = (n) => String(n).padStart(2, '0');
36
+ const TODAY = (() => { const d = new Date(); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; })();
37
+ const item = (id, o = {}) => ({ id, title: 'Item ' + id, why_now: 'Why ' + id, evidence: '', source: 'mail', initiative_id: null, owner: 'pm', effort: 'minutes', confidence: 0.9, worth_asking: false, kind: 'you', ...o });
38
+ const BRIEF = {
39
+ date: TODAY, generated_at: new Date(Date.now() - 60000).toISOString(), sources: { mail: 'read', jira: 'read', confluence: 'read', slack: 'read' }, calendar: [],
40
+ priorities: [
41
+ item('aaaaaaaaaaa1', { title: 'Confirm the Thursday review', who: 'Anna Lee', verb: 'reply', todo: 'Confirm Thursday', link: 'https://mail.example/t/1', ask: 'Can you confirm?' }),
42
+ item('aaaaaaaaaaa2', { title: 'Sign off the vendor terms', source: 'confluence', who: 'Budi Santoso', verb: 'sign-off', link: 'https://wiki.example/p/2' }),
43
+ item('aaaaaaaaaaa3', { title: 'Look at the failing ticket', source: 'jira', who: 'Chen', verb: 'review', todo: 'Check the repro', link: 'https://jira.example/browse/AB-3' }),
44
+ ],
45
+ worth_asking: [item('bbbbbbbbbbb1', { title: 'A thread that may be for you', who: 'Dewi', confidence: 0.3, worth_asking: true })],
46
+ digests: [{ source: 'slack', name: '#launch', link: 'https://slack.example/c/1', decisions: ['Ship on Friday'], open: ['Who owns QA'], goals: [] }],
47
+ places: [], resolved: [],
48
+ };
49
+ const sh = (cwd, args) => spawnSync('git', args, { cwd, encoding: 'utf8' });
50
+
51
+ (async () => {
52
+ const exe = findBrowser();
53
+ if (!exe) { console.log('✗ no Chrome/Chromium on this machine — the briefing-premium-sources suite cannot run'); return finish(1); }
54
+ let chromium;
55
+ try { ({ chromium } = require('playwright-core')); } catch { console.log('✗ playwright-core is not installed — the briefing-premium-sources suite cannot run'); return finish(1); }
56
+ const fakeBin = path.join(tmp, 'bin'); fs.mkdirSync(fakeBin);
57
+ fs.writeFileSync(path.join(fakeBin, 'claude'), '#!/bin/sh\nsleep 30\n', { mode: 0o755 });
58
+ const root = path.join(tmp, 'idpf-repo');
59
+ fs.mkdirSync(root, { recursive: true }); fs.writeFileSync(path.join(root, 'AGENTS.md'), '# a\n');
60
+ sh(root, ['init', '-q', '-b', 'main']); sh(root, ['config', 'user.email', 't@t']); sh(root, ['config', 'user.name', 't']); sh(root, ['add', '-A']); sh(root, ['commit', '-q', '-m', 'initial']);
61
+ srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore', env: T.env({ PMOS_WS_PORT: String(T.PORT), PATH: fakeBin + path.delimiter + process.env.PATH }) });
62
+ await T.waitForServer(BASE);
63
+ const json = async (m, p, b, h) => { const res = await fetch(BASE + p, { method: m, headers: { 'content-type': 'application/json', ...(h || {}) }, body: b ? JSON.stringify(b) : undefined }); let j = null; try { j = await res.json(); } catch { j = null; } return { status: res.status, body: j }; };
64
+ const added = await json('POST', '/api/workspaces', { root, name: 'IDPF', focus: false });
65
+ ok('S0 the product is registered', !!(added.body && added.body.workspace));
66
+ const model = async () => { const r = await json('GET', '/api/briefing-premium'); return r.status === 200 && r.body.model && !r.body.model.pending ? r.body : null; };
67
+ const m0 = await until(model, 90000);
68
+ ok('S1 with no brief for today there are no source rows and the header says so', m0 && !m0.model.list.rows.some((r) => r.kind === 'source') && /no morning brief for today/.test(m0.model.header.text), m0 && m0.model.header.text);
69
+
70
+ // the brief is written where the workspace reads it: <state>/briefs/<today>.json
71
+ fs.mkdirSync(path.join(T.STATE_DIR, 'briefs'), { recursive: true });
72
+ fs.writeFileSync(path.join(T.STATE_DIR, 'briefs', TODAY + '.json'), JSON.stringify(BRIEF));
73
+ const m1 = (await json('GET', '/api/briefing-premium')).body.model;
74
+ const srcRows = m1.list.rows.filter((r) => r.kind === 'source');
75
+ ok('S2 four rows come from the brief: three priorities and one worth asking, in the brief\'s order', srcRows.length === 4 && srcRows[0].title === 'Confirm the Thursday review' && srcRows[3].title === 'A thread that may be for you', JSON.stringify(srcRows.map((r) => r.title)));
76
+ ok('S5 every row reads Across products, because the brief names no initiative of an included product', srcRows.every((r) => r.product === 'Across products'));
77
+ ok('S8 the header no longer says there is no brief', !/no morning brief/.test(m1.header.text), m1.header.text);
78
+
79
+ browser = await chromium.launch({ executablePath: exe });
80
+ const open = async ({ w = 1280, h = 900, theme = 'light' } = {}) => {
81
+ const ctx = await browser.newContext({ viewport: { width: w, height: h } });
82
+ const page = await ctx.newPage(); const errors = [];
83
+ page.on('pageerror', (e) => errors.push(String(e.message || e)));
84
+ await page.goto(BASE + '/premium.html?theme=' + theme, { waitUntil: 'domcontentloaded' });
85
+ await page.waitForSelector('#rank .rr', { timeout: 60000 });
86
+ return { ctx, page, errors };
87
+ };
88
+
89
+ {
90
+ const { ctx, page, errors } = await open();
91
+ const rows = await page.$$eval('#rank .rr', (els) => els.map((e) => ({ key: e.dataset.key, title: e.querySelector('.rt').textContent.replace('Do this first', ''), subject: e.querySelector('.rs').textContent, sug: e.querySelector('.sug').textContent, skip: e.querySelector('.skip').textContent })));
92
+ ok('S2 the first row shows the source and sender, the verb and the prepared line, and why now', rows[0] && rows[0].subject === 'Across productsMail · Anna Lee' && rows[0].sug === 'Reply: Confirm Thursday' && rows[0].skip === 'Why now: Why aaaaaaaaaaa1', JSON.stringify(rows[0]));
93
+ ok('S2 a row with a verb and no prepared line shows the verb alone', rows[1] && rows[1].sug === 'Sign off' && /Confluence · Budi Santoso/.test(rows[1].subject), JSON.stringify(rows[1]));
94
+ ok('S6 the dock counts the rows', (await page.textContent('#dN')) === '4', await page.textContent('#dN'));
95
+ await page.click('#rank .rr:first-child .rhd');
96
+ await page.waitForSelector('#srcLink');
97
+ const link = await page.$eval('#srcLink', (a) => ({ href: a.href, target: a.target, rel: a.rel, h: Math.round(a.getBoundingClientRect().height) }));
98
+ ok('S3 the dialog opens the item\'s https link in a new tab', link.href === 'https://mail.example/t/1' && link.target === '_blank' && /noopener/.test(link.rel), JSON.stringify(link));
99
+ ok('S3 the dialog offers nothing that records or sends', (await page.$('#recBtn')) === null && (await page.$('[data-until]')) === null && (await page.$('#dBody [data-op]')) === null);
100
+ ok('S11 the link is at least 44 px high', link.h >= 44, String(link.h));
101
+ await page.keyboard.press('Escape');
102
+ const chip = await page.$eval('#srcChips', (e) => e.textContent);
103
+ ok('S7 Today lists Digests with their count', /Digests1/.test(chip), chip);
104
+ await page.click('#tl .ev.src-digest');
105
+ await page.waitForSelector('#dBody');
106
+ const dtxt = await page.textContent('#dlg');
107
+ ok('S7 the digest dialog holds its lines and its link', /Decision: Ship on Friday/.test(dtxt) && /Open: Who owns QA/.test(dtxt) && (await page.getAttribute('#srcLink', 'href')) === 'https://slack.example/c/1', dtxt.slice(0, 200));
108
+ ok('no page error', errors.length === 0, errors.join(' | '));
109
+ await ctx.close();
110
+ }
111
+
112
+ {
113
+ const html = await (await fetch(BASE + '/')).text();
114
+ const nonce = (/<meta name="pmos-nonce" content="([^"]*)"/.exec(html) || [])[1] || '';
115
+ const press = { Origin: BASE, ...(nonce ? { 'x-pmos-nonce': nonce } : {}) };
116
+ const r1 = await json('POST', '/api/briefing-premium/put-off', { key: 'src:aaaaaaaaaaa1', until: 'later' }, press);
117
+ ok('S9 a source row cannot be put off', r1.status === 400, JSON.stringify(r1));
118
+ const r2 = await json('POST', '/api/briefing-premium/decide', { key: 'src:aaaaaaaaaaa1', choice: 'extend', why: 'No.' }, press);
119
+ ok('S9 a source row cannot be decided', r2.status >= 400, JSON.stringify(r2));
120
+ const r3 = await json('POST', '/api/briefing-premium/ask', { key: 'src:aaaaaaaaaaa1', text: 'Why is this on my list?' }, press);
121
+ ok('S9 the read-only agent can be asked about a source row', r3.status === 200 && r3.body.ok && r3.body.state === 'working', JSON.stringify(r3));
122
+ }
123
+
124
+ /* S11 fidelity: the source row against the mockup's second row, the digest against the mockup's Today item */
125
+ if (!fs.existsSync(MOCKUP)) ok('S11 the mockup is in the repository', false, MOCKUP);
126
+ else {
127
+ const PAIRS = [['row', '#rank .rr:nth-child(2)'], ['number', '#rank .rr:nth-child(2) .rn'], ['title', '#rank .rr:nth-child(2) .rt'], ['subject', '#rank .rr:nth-child(2) .rs'], ['prepared', '#rank .rr:nth-child(2) .sug'], ['skip', '#rank .rr:nth-child(2) .skip'], ['event', '#tl .ev:not(:first-of-type)'], ['eventTime', '#tl .ev:not(:first-of-type) time'], ['eventTitle', '#tl .ev:not(:first-of-type) b']];
128
+ const PROPS = ['fontSize', 'fontWeight', 'fontFamily', 'color', 'backgroundColor', 'borderRadius', 'paddingTop', 'paddingLeft', 'letterSpacing', 'lineHeight', 'boxShadow', 'borderTopColor'];
129
+ const grab = (pg, sel) => pg.evaluate(([pairs, props]) => { const o = {}; for (const [n, s] of pairs) { const e = /^#tl/.test(s) && window.__ev ? window.__ev(s) : document.querySelector(s); if (!e) { o[n] = null; continue; } const cs = getComputedStyle(e), r = e.getBoundingClientRect(), v = { left: Math.round(r.left), width: Math.round(r.width) }; props.forEach((k) => { v[k] = cs[k]; }); o[n] = v; } return o; }, [sel, PROPS]);
130
+ for (const [w, h] of [[1280, 900], [390, 844]]) for (const th of ['light', 'dark']) {
131
+ const m = await (await browser.newContext({ viewport: { width: w, height: h } })).newPage();
132
+ await m.goto('file://' + MOCKUP);
133
+ await until(() => m.evaluate(() => document.querySelectorAll('#rank .rr').length > 1 && document.querySelectorAll('#tl .ev').length > 0));
134
+ await m.evaluate((t) => { document.documentElement.dataset.theme = t; }, th);
135
+ const R = await open({ w, h, theme: th });
136
+ await R.page.evaluate(() => { window.__ev = (s) => { const d = document.querySelector('#tl .ev.src-digest'); return !d ? null : / time$/.test(s) ? d.querySelector('time') : / b$/.test(s) ? d.querySelector('b') : d; }; });
137
+ await sleep(500);
138
+ const a = await grab(m, PAIRS), c = await grab(R.page, PAIRS), diffs = [];
139
+ for (const [n] of PAIRS) {
140
+ if (!a[n] || !c[n]) { diffs.push(n + ' missing'); continue; }
141
+ for (const k of Object.keys(a[n])) {
142
+ if (k === 'left' || k === 'width') { if (['row', 'number', 'title', 'subject', 'prepared', 'skip', 'event', 'eventTime', 'eventTitle'].includes(n) && (k === 'left' || n !== 'row' && n !== 'event' && n !== 'eventTime')) continue; }
143
+ if ((k === 'left' || k === 'width') ? Math.abs(a[n][k] - c[n][k]) > 2 : a[n][k] !== c[n][k]) diffs.push(`${n}.${k} ${a[n][k]} | ${c[n][k]}`);
144
+ }
145
+ }
146
+ ok(`S11 the source row and the digest look like the mockup's at ${w} px, ${th}`, diffs.length === 0, diffs.join(' ; '));
147
+ const ov = await R.page.evaluate(() => document.documentElement.scrollWidth - innerWidth);
148
+ ok(`S11 no sideways scroll at ${w} px, ${th}`, ov <= 0, String(ov));
149
+ await m.context().close(); await R.ctx.close();
150
+ }
151
+ }
152
+ return finish();
153
+ })().catch((e) => { console.error(e); finish(1); });
@@ -23,7 +23,20 @@ You work across products, and a conversation in email, the calendar or Confluenc
23
23
  - **The order** is the same across products: blocked work first, then decisions only you can make, then what has waited longest. A failed-review row is made for each product, so a count is never added across products.
24
24
  - **The header** names what is read and what is not, for example "IDPF and microsites · pmos not included". A product that is still being read or could not be read is named there too, and the others still show.
25
25
  - **`/premium.html?ws=<id>`** shows that one workspace as the Briefing did before, whether or not it is ticked, with a button back to the ticked products.
26
- - **Email and Confluence** are not read by this page. They arrive as items on My work.
26
+ - **Mail, Jira and Confluence** come from today's morning brief, which a scheduled skill in an agent session writes to the state folder (the server reaches none of those services). See "Mail, Jira and Confluence" below.
27
+
28
+ ## Mail, Jira and Confluence
29
+
30
+ The page reads today's morning brief (`briefs/<date>.json` in the state folder) and the Jira tickets assigned to you. Nothing is sent, posted or recorded from these rows.
31
+
32
+ - **Rows.** The brief's mail, Jira and Confluence items become rows in "What you need to do" when you own them, when they mention you, or when they are worth asking. Slack, GitHub, Drive, Figma and website items are left out, and so are agent-owned items that do not mention you. A row shows its source and sender, then the verb and the prepared line, then why it is on the list. Only fields the item holds are shown.
33
+ - **Tickets.** An assigned Jira ticket becomes a row when it is overdue, due today, blocked or a decision, and your Work list has not marked it done or put it aside.
34
+ - **The tag.** A row names a product only when the item names an initiative that belongs to exactly one product you included. Every other row reads "Across products". The page never guesses a product.
35
+ - **The order.** These rows follow the repository rows: tickets first, then the brief's items in the brief's own order. The cap of seven rows is shared, and the rest are counted as more.
36
+ - **The dialog** opens the item's link, when it is an https address, in a new tab. It records nothing. The read-only agent can be asked about the row.
37
+ - **Today** lists one item per digest (a watched Slack channel, Jira project or Confluence space) with the decisions, open lines and goals the brief holds, under a Digests filter that appears when the brief has digests.
38
+ - **The header** says "no morning brief for today" when there is none, or that the brief could not be read, or that it did not reach a source (mail, Jira, Confluence or Slack).
39
+ - **`?ws=<id>`** does not show these rows.
27
40
 
28
41
  ## How long it takes to load
29
42
 
@@ -16,7 +16,7 @@ const crypto = require('crypto');
16
16
  const headless = require('./headless.js');
17
17
 
18
18
  const THREADS = 'briefing-premium-threads.jsonl';
19
- const KEY_RE = /^(decision|blocked|waited|ev|work|board|init|doc):[A-Za-z0-9][A-Za-z0-9._:-]{0,100}$/;
19
+ const KEY_RE = /^(decision|blocked|waited|src|ev|work|board|init|doc):[A-Za-z0-9][A-Za-z0-9._:-]{0,100}$/;
20
20
  const MAX_Q = 2000, MAX_A = 6000, MAX_QUESTIONS = 40, PROMPT_TURNS = 8;
21
21
  const fail = (status, error) => ({ status, body: { ok: false, error } });
22
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; };
@@ -71,7 +71,7 @@ function start(p, engine, runtimeDir) {
71
71
  }
72
72
 
73
73
  /** The merged answer for the workspaces that are on. `available` is choose()'s list (all of them, with `on`). */
74
- async function modelOfMany({ available, engine, runtimeDir }) {
74
+ async function modelOfMany({ available, engine, runtimeDir, sources }) {
75
75
  const now = Date.now();
76
76
  const on = (available || []).filter((a) => a.on);
77
77
  const parts = [];
@@ -98,7 +98,7 @@ async function modelOfMany({ available, engine, runtimeDir }) {
98
98
  const settled = parts.filter((p) => p.state === 'ready').length;
99
99
  if (on.length && !settled && parts.every((p) => p.state === 'reading')) return { state: 'reading', elapsed_ms: elapsed };
100
100
  if (on.length && !settled && parts.every((p) => p.state === 'error')) return { state: 'error', error: parts.map((p) => p.product.name + ': ' + p.error).join('; ').slice(0, 300) };
101
- const model = BP.buildMany({ now, asOf: asOf || now, meetings: BP.readMeetings(runtimeDir, now), defers: Object.fromEntries(W.readDefers(runtimeDir, now)), available: (available || []).map((a) => ({ ws: a.ws, name: a.name, home: a.home, on: a.on })), parts });
101
+ const model = BP.buildMany({ now, asOf: asOf || now, meetings: BP.readMeetings(runtimeDir, now), defers: Object.fromEntries(W.readDefers(runtimeDir, now)), available: (available || []).map((a) => ({ ws: a.ws, name: a.name, home: a.home, on: a.on })), parts, sources });
102
102
  last = model;
103
103
  return { state: 'ready', model, as_of: asOf || now, refreshing, pending: model.pending, ...(error ? { error } : {}) };
104
104
  }
@@ -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;
@@ -330,9 +331,10 @@ function todayOf(f) {
330
331
  const runs = (f.runs || []).filter((r) => { const t = Date.parse(r.created_at); return t >= midnight && t <= f.now; });
331
332
  const byId = new Map(f.initiatives.map((i) => [idk(i, i.id), i]));
332
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);
333
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);
334
336
  const count = (k) => items.filter((x) => x.kind === k).length;
335
- 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.' };
336
338
  }
337
339
 
338
340
  /* ── the model ────────────────────────────────────────────────────────────────────────────────────────────── */
@@ -378,7 +380,11 @@ function buildMany(g) {
378
380
  const every = [], putOff = [];
379
381
  for (const p of read) { const a = allRowsOf(p.f); every.push(...a.all); putOff.push(...a.putOff); }
380
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));
381
- const { rows, more } = cut(every, putOff);
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);
382
388
  const tag = (list, p) => list.map((x) => Object.assign({}, x, { ws: p.product.ws, product: p.product.name }));
383
389
  const cf = {
384
390
  now, asOf, meetings, defers: g.defers || {},
@@ -386,6 +392,7 @@ function buildMany(g) {
386
392
  runs: [].concat(...read.map((p) => tag(p.f.runs, p))),
387
393
  merges: [].concat(...read.map((p) => tag(p.f.merges, p))),
388
394
  evals: [].concat(...read.map((p) => tag(p.f.evals, p))),
395
+ digests: sx ? sx.digests : [],
389
396
  };
390
397
  const available = g.available || [], inc = available.filter((a) => a.on), out = available.filter((a) => !a.on);
391
398
  const waiting = parts.filter((p) => p.state === 'reading' || !p.f).filter((p) => p.state !== 'error'), failed = parts.filter((p) => p.state === 'error');
@@ -398,10 +405,13 @@ function buildMany(g) {
398
405
  const none = !inc.length;
399
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: [] }]
400
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';
401
411
  return {
402
412
  date: dateLine(now),
403
413
  pending,
404
- 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(', ') : '') + (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 },
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 },
405
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: [] }],
406
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.' },
407
417
  today: todayOf(cf),
@@ -21,6 +21,7 @@ const { correctionEvent } = require(repoScript('card-state.js'));
21
21
  const { appendOutbox } = require(repoScript('outbox.js'));
22
22
 
23
23
  const Prod = require('./briefing-premium-products.js');
24
+ const Src = require('./briefing-premium-sources.js');
24
25
  const settings = require('./settings-store.js');
25
26
 
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. */
@@ -65,7 +66,7 @@ async function post(req, res, url, ctx) {
65
66
  }
66
67
  if (act === 'ask') {
67
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; }
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
+ 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 });
69
70
  ctx.json(res, a.body, a.status);
70
71
  return true;
71
72
  }
@@ -100,7 +101,8 @@ module.exports = {
100
101
  // No ws: the Briefing across the products the PM picked. A ws: that one workspace, read as before.
101
102
  if (!url.searchParams.get('ws')) {
102
103
  const sel = products(ctx);
103
- const r = await Prod.modelOfMany({ available: sel.available, engine: enginePath(), runtimeDir: ctx.runtimeDir });
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 });
104
106
  ctx.json(res, r.state === 'error' ? { ok: false, ...r } : { ok: true, ...r }, r.state === 'ready' ? 200 : r.state === 'reading' ? 202 : 500);
105
107
  return true;
106
108
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.254.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.254.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.254.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}
@@ -8,8 +8,9 @@ const ICON = {
8
8
  meeting: '<rect x="4" y="5" width="16" height="15" rx="3"/><path d="M4 10h16M9 3v4M15 3v4"/>',
9
9
  merge: '<circle cx="6" cy="6" r="2"/><circle cx="6" cy="18" r="2"/><circle cx="18" cy="9" r="2"/><path d="M6 8v8M18 11c0 4-6 3-12 5"/>',
10
10
  run: '<path d="M13 3 5 14h6l-1 7 8-11h-6z"/>',
11
+ digest: '<path d="M5 7h14M5 12h14M5 17h9"/>',
11
12
  };
12
- const SRC = { meeting: 'Calendar', merge: 'Merges', run: 'Agent runs' };
13
+ const SRC = { meeting: 'Calendar', merge: 'Merges', run: 'Agent runs', digest: 'Digests' };
13
14
  let M = null; // the model
14
15
  const on = new Set(Object.keys(SRC));
15
16
  const pinned = new Set();
@@ -70,7 +71,7 @@ function evHtml(e, i) {
70
71
  }
71
72
  const ALL = () => M.today.ahead.concat(M.today.earlier);
72
73
  function drawChips() {
73
- $('#srcChips').innerHTML = Object.entries(SRC).map(([k, n]) => `<button class="fchip" data-k="${k}" aria-pressed="${on.has(k)}">${n}<em>${M.today.counts[k] || 0}</em></button>`).join('');
74
+ $('#srcChips').innerHTML = Object.entries(SRC).filter(([k]) => k !== 'digest' || M.today.counts.digest).map(([k, n]) => `<button class="fchip" data-k="${k}" aria-pressed="${on.has(k)}">${n}<em>${M.today.counts[k] || 0}</em></button>`).join('');
74
75
  }
75
76
  function drawTl() {
76
77
  const T = M.today, all = ALL(), ahead = T.ahead.filter((e) => on.has(e.kind)), early = T.earlier.filter((e) => on.has(e.kind));
@@ -126,6 +127,7 @@ function cardHtml(r) {
126
127
  return `${head}<p class="ct">Return this run: why</p><div class="seg2" id="retWhy">${RET_REASONS.map((x) => `<button data-ret="${esc(x)}" aria-pressed="${RET[r.key] === x}">${esc(x)}</button>`).join('')}</div>${err}<div class="cact"><button class="cta" id="recBtn" data-rec="return" disabled>${CHECK}<span>${esc(d.action)}</span></button></div></div>`;
127
128
  }
128
129
  if (d.opts.length) return `${head}${opts}${d.nodef ? `<p class="nodef">${esc(d.nodef)}</p>` : ''}<div class="cact" id="cardAct"><button class="cta" disabled title="${esc(d.hold || 'Nothing here can be recorded yet.')}">${o ? esc(d.prim[o.k]) : 'Your call: choose one'}</button></div>${d.hold ? holdNote(d.hold) : ''}</div>`;
130
+ if (d.link) return `${head}<div class="cact"><a class="cta" id="srcLink" href="${esc(d.link)}" target="_blank" rel="noopener noreferrer">${esc(d.linkLabel || 'Open the source')}</a></div></div>`;
129
131
  return `${head}${d.nodef ? `<p class="nodef">${esc(d.nodef)}</p>` : ''}${d.action ? `<div class="cact"><button class="cta" disabled title="${esc(d.hold || 'Nothing here can be recorded.')}">${CHECK}${esc(d.action)}</button></div>${d.hold ? holdNote(d.hold) : ''}` : ''}</div>`;
130
132
  }
131
133
  function paneHtml(r) {
@@ -42,6 +42,7 @@
42
42
  "briefing-premium-board.test.js",
43
43
  "briefing-premium-initiative.test.js",
44
44
  "briefing-premium-products.test.js",
45
+ "briefing-premium-sources.test.js",
45
46
  "calendar-events.test.js",
46
47
  "calendar-supersede.test.js",
47
48
  "tracker-read.test.js",
@@ -202,6 +203,7 @@
202
203
  "briefing-premium-board.test.js": 1,
203
204
  "briefing-premium-initiative.test.js": 1,
204
205
  "briefing-premium-products.test.js": 1,
206
+ "briefing-premium-sources.test.js": 1,
205
207
  "calendar-events.test.js": 1,
206
208
  "calendar-supersede.test.js": 1,
207
209
  "tracker-read.test.js": 1,
@@ -6,6 +6,7 @@
6
6
  "briefing-premium-board-test.js",
7
7
  "briefing-premium-initiative-test.js",
8
8
  "briefing-premium-products-test.js",
9
+ "briefing-premium-sources-test.js",
9
10
  "briefing-premium-agent-server.test.js"
10
11
  ],
11
12
  "seconds": {
@@ -14,6 +15,7 @@
14
15
  "briefing-premium-board-test.js": 120,
15
16
  "briefing-premium-initiative-test.js": 120,
16
17
  "briefing-premium-products-test.js": 90,
18
+ "briefing-premium-sources-test.js": 90,
17
19
  "briefing-premium-agent-server.test.js": 40
18
20
  }
19
21
  }