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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.253.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@86e280d
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-17T04:34:20Z"}
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-17T04:34:20Z"}
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-17T04:34:20Z"}
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,164 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /* briefing-premium-products-test.js — the premium Briefing across products (initiative briefing-premium-products; planning/prd/briefing-premium-products-prd.md P1 to P13).
4
+ * The real server with two product repositories registered beside the home one (the PMOS checkout the server runs from). Both products hold a
5
+ * kill condition KC9 that has passed its date, so each has a "decide" row with the SAME id: the page must keep them apart.
6
+ * P1 by default the home workspace is not read, and the header says so
7
+ * P2 the picker lists every workspace, the home one included, and says how many are on
8
+ * P3 a tick is saved through /api/settings and survives a reload; Back to the default returns to P1
9
+ * P4 an unticked product's rows leave the list
10
+ * P6 each row carries its product
11
+ * P9 deciding a row writes into the owning product's journal and no other
12
+ * P10 a question about a merged row starts the read-only agent
13
+ * P13 floors: the picker and its menu are 44 px high, reachable by keyboard, no sideways scroll at 390 px, both themes
14
+ * Every wait polls the page; no fixed sleep stands in for a state. */
15
+ const fs = require('fs');
16
+ const os = require('os');
17
+ const path = require('path');
18
+ const { spawn, spawnSync } = require('child_process');
19
+ const { findBrowser } = require('./lib/find-browser');
20
+ const T = require('./test-run').configure({ name: 'briefing-premium-products' });
21
+
22
+ const BASE = T.BASE;
23
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
24
+ 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); } };
25
+ const results = [];
26
+ const ok = (name, cond, extra = '') => { results.push({ name, cond: !!cond }); console.log(`${cond ? '✓' : '✗'} ${name}${!cond && extra !== '' ? ' — ' + String(extra).slice(0, 300) : ''}`); };
27
+ let srv = null; let browser = null;
28
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pmos-bpp-'));
29
+ const finish = async (code) => {
30
+ if (browser) await browser.close().catch(() => {});
31
+ if (srv) { try { srv.kill('SIGKILL'); } catch { /* gone */ } }
32
+ T.killTmux();
33
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* left for the OS */ }
34
+ const bad = results.filter((x) => !x.cond).length;
35
+ console.log(`\n${results.length - bad}/${results.length} briefing-premium-products assertions passed`);
36
+ process.exit(code !== undefined ? code : (bad ? 1 : 0));
37
+ };
38
+ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
39
+
40
+ const sh = (cwd, args) => spawnSync('git', args, { cwd, encoding: 'utf8' });
41
+ const product = (name, cond) => {
42
+ const root = path.join(tmp, name);
43
+ fs.mkdirSync(path.join(root, 'planning', 'okrs'), { recursive: true });
44
+ fs.writeFileSync(path.join(root, 'AGENTS.md'), '# a\n');
45
+ fs.writeFileSync(path.join(root, 'pm-decisions.md'), '# decisions\n');
46
+ fs.writeFileSync(path.join(root, 'planning', 'okrs', 'kill-conditions.md'), `| KC9 | ${cond} | stalls by **2020-01-01** | monthly | pauses merges |\n`);
47
+ 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']);
48
+ return root;
49
+ };
50
+
51
+ (async () => {
52
+ const exe = findBrowser();
53
+ if (!exe) { console.log('✗ no Chrome/Chromium on this machine — the briefing-premium-products 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-products 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 rootA = product('idpf-repo', 'IDPF condition'), rootB = product('micro-repo', 'Microsites condition');
59
+ 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 }) });
60
+ await T.waitForServer(BASE);
61
+ 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 { /* not json */ } return { status: res.status, body: j }; };
62
+ const wA = (await json('POST', '/api/workspaces', { root: rootA, name: 'IDPF', focus: false })).body.workspace;
63
+ const wB = (await json('POST', '/api/workspaces', { root: rootB, name: 'microsites', focus: false })).body.workspace;
64
+ const all = (await json('GET', '/api/workspaces')).body.workspaces;
65
+ const home = all.find((w) => w.home);
66
+ ok('P0 the server has a home workspace and the two products', !!home && !!wA && !!wB, JSON.stringify(all.map((w) => [w.name, w.home])));
67
+ 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; };
68
+ const m0 = await until(model, 90000);
69
+ ok('P1 by default only the two products are read: two decide rows, none from the home workspace', m0 && m0.model.list.rows.length === 2 && m0.model.list.rows.every((r) => r.kind === 'decision' && ['IDPF', 'microsites'].includes(r.product)) && m0.model.products.available.find((a) => a.home).on === false, JSON.stringify(m0 && m0.model.list.rows.map((r) => [r.key, r.product])));
70
+ ok('P1 the two rows share an id and differ in workspace', m0 && new Set(m0.model.list.rows.map((r) => r.key)).size === 2 && m0.model.list.rows.every((r) => /^decision:KC9:/.test(r.key)));
71
+
72
+ browser = await chromium.launch({ executablePath: exe });
73
+ const open = async ({ w = 1280, h = 900, theme = 'light', q = '' } = {}) => {
74
+ const ctx = await browser.newContext({ viewport: { width: w, height: h } });
75
+ await ctx.addInitScript((t) => { try { localStorage.setItem('pmos.premium.theme', t); } catch { /* none */ } }, theme);
76
+ const page = await ctx.newPage(); const errors = [];
77
+ page.on('pageerror', (e) => errors.push(String(e.message || e)));
78
+ await page.goto(BASE + '/premium.html' + q, { waitUntil: 'domcontentloaded' });
79
+ await page.waitForSelector('#rank .rr, #calm:not([hidden]), #pkw:not([hidden])', { timeout: 30000 });
80
+ return { ctx, page, errors };
81
+ };
82
+ const rowsOf = (page) => page.$$eval('#rank .rr', (els) => els.map((e) => ({ key: e.dataset.key, tag: (e.querySelector('.pn') || {}).textContent || '', title: e.querySelector('.rt').textContent })));
83
+
84
+ {
85
+ const { ctx, page, errors } = await open();
86
+ await until(async () => (await rowsOf(page)).length === 2, 20000);
87
+ const rows = await rowsOf(page);
88
+ ok('P6 each row carries its product', rows.length === 2 && rows.map((r) => r.tag).sort().join() === 'IDPF,microsites', JSON.stringify(rows));
89
+ const snap = await page.textContent('#snap');
90
+ ok('P1 the header names what is read and what is not', /IDPF and microsites/.test(snap) && /not included/.test(snap) && /\b[Pp][Mm][Oo][Ss]\b|pmos/i.test(snap), snap);
91
+ ok('P2 the picker says how many of the workspaces are on', (await page.textContent('#pick')).replace(/\s+/g, '') === 'Products2of3', await page.textContent('#pick'));
92
+ await page.click('#pick');
93
+ const items = await page.$$eval('#pickMenu [role="menuitemcheckbox"]', (els) => els.map((e) => ({ name: e.textContent.trim(), on: e.getAttribute('aria-checked'), h: Math.round(e.getBoundingClientRect().height) })));
94
+ ok('P2 the menu lists every workspace, the home one included, unticked', items.length === 3 && items.filter((i) => i.on === 'true').length === 2 && items.some((i) => i.on === 'false' && /home/.test(i.name)), JSON.stringify(items));
95
+ const pickH = await page.$eval('#pick', (e) => Math.round(e.getBoundingClientRect().height));
96
+ ok('P13 the picker and its items are at least 42 px high (the crumb menu\'s own height)', pickH >= 42 && items.every((i) => i.h >= 42), pickH + ' ' + JSON.stringify(items.map((i) => i.h)));
97
+ // untick microsites: its row leaves, the setting is saved
98
+ await page.click(`#pickMenu [data-ws="${wB.id}"]`);
99
+ ok('P4 an unticked product\'s row leaves the list', await until(async () => { const r = await rowsOf(page); return r.length === 1 && r[0].tag === 'IDPF'; }, 20000));
100
+ const saved = (await json('GET', '/api/settings')).body.settings.find((s) => s.key === 'ws.briefing.include');
101
+ ok('P3 the choice is saved as a list of workspace ids', saved && JSON.stringify(saved.value) === JSON.stringify([wA.id]), JSON.stringify(saved));
102
+ ok('P2 the count in the picker follows', (await page.textContent('#pick')).replace(/\s+/g, '') === 'Products1of3');
103
+ ok('P13 keyboard: the menu stays open on the ticked item and Escape closes it', await page.evaluate((id) => document.activeElement && document.activeElement.dataset.ws === id, wB.id) && (await page.keyboard.press('Escape'), await until(async () => await page.$eval('#pickMenu', (e) => e.hidden), 3000)));
104
+ ok('P3 the choice survives a reload', await (async () => { await page.reload({ waitUntil: 'domcontentloaded' }); return until(async () => { const r = await rowsOf(page); return r.length === 1 && r[0].tag === 'IDPF' ? 1 : 0; }, 20000); })());
105
+ // tick the home workspace
106
+ await page.click('#pick'); await page.click(`#pickMenu [data-ws="${home.id}"]`);
107
+ ok('P3 ticking the home workspace reads it', await until(async () => (await page.getAttribute(`#pickMenu [data-ws="${home.id}"]`, 'aria-checked')) === 'true' && new RegExp(home.name + ' and IDPF|IDPF and ' + home.name, 'i').test(await page.textContent('#snap')), 20000), await page.textContent('#snap'));
108
+ // back to the default
109
+ await page.click('#pickMenu [data-reset]');
110
+ ok('P3 Back to the default reads every product but the home one again', await until(async () => (await rowsOf(page)).length === 2, 20000) && (await page.textContent('#pick')).replace(/\s+/g, '') === 'Products2of3');
111
+ ok('P3 and the saved choice is gone', (await json('GET', '/api/settings')).body.settings.find((s) => s.key === 'ws.briefing.include').changed === false);
112
+ ok('no page error', errors.length === 0, errors.join(' | '));
113
+ await ctx.close();
114
+ }
115
+
116
+ {
117
+ // P9 decide a row of IDPF, from the page
118
+ const { ctx, page } = await open();
119
+ await until(async () => (await rowsOf(page)).length === 2, 20000);
120
+ const key = (await rowsOf(page)).find((r) => r.tag === 'IDPF').key;
121
+ await page.click(`#rank .rr[data-key="${key}"] .rhd`);
122
+ await page.waitForSelector('#dBody [data-op]');
123
+ await page.click('#dBody [data-op="extend"]');
124
+ await page.fill('#why', 'IDPF moves the date.');
125
+ await page.click('#recBtn');
126
+ ok('P9 the press is recorded and the IDPF row leaves, the microsites row stays', await until(async () => { const r = await rowsOf(page); return r.length === 1 && r[0].tag === 'microsites' ? 1 : 0; }, 20000), JSON.stringify(await rowsOf(page)));
127
+ const dA = fs.readFileSync(path.join(rootA, 'pm-decisions.md'), 'utf8'), dB = fs.readFileSync(path.join(rootB, 'pm-decisions.md'), 'utf8');
128
+ ok('P9 the decision is in IDPF\'s journal and not in the other product\'s or the home one\'s', /KC9/.test(dA) && /IDPF moves the date/.test(dA) && !/KC9/.test(dB) && !/IDPF moves the date/.test(fs.readFileSync(path.join(home.root, 'pm-decisions.md'), 'utf8')));
129
+ await ctx.close();
130
+ }
131
+
132
+ {
133
+ // P9/P10 refusals and the agent, over the real routes
134
+ const html = await (await fetch(BASE + '/')).text();
135
+ const nonce = (/<meta name="pmos-nonce" content="([^"]*)"/.exec(html) || [])[1] || '';
136
+ const press = { Origin: BASE, ...(nonce ? { 'x-pmos-nonce': nonce } : {}) };
137
+ const homeKey = 'decision:KC9:' + home.id;
138
+ const r1 = await json('POST', '/api/briefing-premium/decide', { key: homeKey, choice: 'extend', why: 'Not included.' }, press);
139
+ ok('P9 a row of a workspace that is not included is refused', r1.status === 400 && /not included/.test(r1.body.error), JSON.stringify(r1));
140
+ const r2 = await json('POST', '/api/briefing-premium/decide', { key: 'decision:KC9:w-nope', choice: 'extend', why: 'Unknown.' }, press);
141
+ ok('P9 a row of a workspace that is not registered is refused', r2.status === 400 && /not registered/.test(r2.body.error), JSON.stringify(r2));
142
+ const r3 = await json('POST', '/api/briefing-premium/ask', { key: 'decision:KC9:' + wB.id, text: 'Why is this on my list?' }, press);
143
+ ok('P10 a question about a merged row starts the read-only agent in that product', r3.status === 200 && r3.body.ok && r3.body.state === 'working', JSON.stringify(r3));
144
+ const r4 = await json('POST', '/api/briefing-premium/ask', { key: 'decision:KC9:' + wA.id, text: 'Why?' }, press);
145
+ ok('P10 a row that is no longer on the page is refused', r4.status === 404, JSON.stringify(r4));
146
+ }
147
+
148
+ for (const [w, th] of [[390, 'light'], [390, 'dark'], [1280, 'dark']]) {
149
+ const { ctx, page } = await open({ w, h: 800, theme: th });
150
+ await until(async () => (await rowsOf(page)).length >= 1, 20000);
151
+ await page.click('#pick');
152
+ const o = await page.evaluate(() => ({ sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth, menu: (() => { const r = document.querySelector('#pickMenu').getBoundingClientRect(); return { l: Math.round(r.left), r: Math.round(r.right) }; })() }));
153
+ ok(`P13 at ${w} px, ${th}: no sideways scroll with the menu open, and the menu stays on screen`, o.sw <= o.cw && o.menu.l >= 0 && o.menu.r <= o.cw, JSON.stringify(o));
154
+ await ctx.close();
155
+ }
156
+
157
+ {
158
+ // the narrowed view (P12): the one-workspace page, with a way back
159
+ const { ctx, page } = await open({ q: '?ws=' + encodeURIComponent(wB.id) });
160
+ ok('P12 ?ws= shows that one workspace and a link back to the picked products', await until(async () => (await page.$('#pickAll')) !== null, 20000) && (await page.getAttribute('#pickAll', 'href')) === '/premium.html');
161
+ await ctx.close();
162
+ }
163
+ return finish();
164
+ })().catch((e) => { console.error(e); finish(1); });
@@ -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); });
@@ -7,10 +7,37 @@ timestamp: 2026-10-16
7
7
 
8
8
  # The premium Briefing
9
9
 
10
- The premium Briefing is a page of its own at `/premium.html`. The console at `/` stays the default page. This page reads the repository. It writes only when you press a button that says it records (see "What the buttons record"), plus one derived copy of its last answer, described below.
10
+ The premium Briefing is a page of its own at `/premium.html`. The console at `/` stays the default page. This page reads the repositories of the products you pick (see "Products"). It writes only when you press a button that says it records (see "What the buttons record"), plus one derived copy of its last answer, described below.
11
11
 
12
12
  A rail on the left names four places: Briefing, My work, Board and Insights. Briefing is on this page. The other three open the matching tab of the console.
13
13
 
14
+ ## Products
15
+
16
+ You work across products, and a conversation in email, the calendar or Confluence cannot be tied to one product. So the Briefing reads more than one workspace and merges what needs you into one list.
17
+
18
+ - **By default** it reads every registered workspace except the home one (PMOS itself). PMOS is the tool, not a product you manage, so it is left out until you ask.
19
+ - **The Products button** in the header lists every registered workspace, the home one included, each with a box. Tick a box to read that workspace, clear it to stop. The button says how many are on, for example "2 of 3". "Back to the default" returns to every workspace except the home one.
20
+ - **Your choice is saved** as the setting `ws.briefing.include` (a list of workspace ids) in this computer's state folder, so it stays when you reload or restart.
21
+ - **A workspace that is not ticked is not read at all.** Its decisions, blocked work, merges and runs do not count in the hero, the list or the dock.
22
+ - **Each row says which product it belongs to**, in a small tag before its line. Two products can hold an initiative or a kill condition of the same name. They stay separate rows, and a press is written into the journal of the product the row names.
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
+ - **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
+ - **`/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
+ - **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.
40
+
14
41
  ## How long it takes to load
15
42
 
16
43
  Two reads feed the page. The quick read takes the kill conditions, the failed-review verdicts, today's merges and agent runs, and the calendar. It finishes in seconds. The slow read derives the state of every initiative from git history. It takes about half a minute on a fast machine, and much longer on a machine that is busy. The page never waits on the slow read:
@@ -18,7 +45,7 @@ Two reads feed the page. The quick read takes the kill conditions, the failed-re
18
45
  - **The first time**, the page says "Reading the repository for the first time" and counts the seconds. As soon as the quick read ends it draws what that read holds, and says in the header, the list and the dock that it is "still reading initiative state". Until then it does not say that nothing needs you. Blocked work, prototypes that wait for you and failed reviews appear when the slow read ends.
19
46
  - **After that**, a reload answers at once with the last read. A read older than 5 minutes is refreshed in the background, and the header says when it was read.
20
47
  - **The slow read runs only when the repository changed.** If you are on the same commit and nothing under `planning/` or `scripts/` changed, the page reuses the last initiative state. It runs at low priority, so it does not starve the machine.
21
- - **After a restart**, the last read comes back from a file in the workspace's state folder, `briefing-premium-model.json`. The file is a copy of derived data. You can delete it.
48
+ - **After a restart**, the last read comes back from a file in the workspace's state folder, one `briefing-premium-products-<id>.json` for each product (`briefing-premium-model.json` for the one-workspace view). The file is a copy of derived data. You can delete it.
22
49
  - **If a read fails**, the header says "the latest read failed" with the reason, and the page stops saying it is still reading. The server waits two minutes before it tries again, so a failing read does not run in a loop. A slow read that failed is always tried again on the next refresh, even when the repository has not changed.
23
50
  - **If one record cannot be read**, only that record is left out. The header says how many were left out. A row or a Today item whose detail cannot be built still opens, with what the row itself says.
24
51
 
@@ -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; };
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+ /* lib/briefing-premium-products.js — the premium Briefing across products (initiative briefing-premium-products).
3
+ *
4
+ * choose({ workspaces, include }) → { available[{ ws, name, root, home, on }], ignored[] } PURE. Which workspaces the Briefing reads.
5
+ * splitKey(key) → { base, ws } | null PURE. A merged row's key is kind:id:ws.
6
+ * modelOfMany({ available, engine, runtimeDir }) → { state, model, as_of, ... } the route's answer, one workspace read and kept at a time.
7
+ * rebuildOne({ root, runtimeDir }) after a write: that workspace's quick facts again, without the slow read.
8
+ * peekModel() the last merged model, for the read-only agent to find a row in.
9
+ *
10
+ * THE SELECTION RULE. With nothing chosen, every registered workspace except the home one (PMOS itself) is read. With a choice (the setting
11
+ * ws.briefing.include, a list of workspace ids), exactly those are read, the home workspace included when it is listed. A workspace that is not
12
+ * selected is not read at all: no file of it is opened and no git command runs in it. An id in the choice that is no longer registered is ignored and returned.
13
+ *
14
+ * Each workspace keeps its own answer (memory and a file under the state folder), so one slow or broken repository costs only its own part:
15
+ * the others show, and the header names the one that is still being read or could not be. */
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const crypto = require('crypto');
19
+ const BP = require('./briefing-premium.js');
20
+ const W = require('./briefing-premium-writes.js');
21
+
22
+ const STALE_MS = 5 * 60 * 1000;
23
+ const RETRY_AFTER_ERROR_MS = 2 * 60 * 1000;
24
+
25
+ /** workspaces: [{ id, name, root, home }]. include: an array of ids, or null for the default. */
26
+ function choose({ workspaces, include, only }) {
27
+ const list = (workspaces || []).filter((w) => w && w.id && w.root);
28
+ const ids = new Set(list.map((w) => w.id));
29
+ const pick = Array.isArray(include) ? new Set(include) : null;
30
+ const available = list.map((w) => ({ ws: w.id, name: w.name || w.id, root: w.root, home: !!w.home, on: only ? w.id === only : pick ? pick.has(w.id) : !w.home }));
31
+ return { available, ignored: Array.isArray(include) ? include.filter((x) => !ids.has(x)) : [] };
32
+ }
33
+
34
+ /** A merged row's key is kind:id:ws. A key of the one-workspace page (kind:id) has no workspace and returns null. */
35
+ function splitKey(key) {
36
+ const m = /^(blocked|decision|waited):([A-Za-z0-9][A-Za-z0-9._-]*):([A-Za-z0-9_-]{1,80})$/.exec(String(key || ''));
37
+ return m ? { base: m[1] + ':' + m[2], kind: m[1], id: m[2], ws: m[3] } : null;
38
+ }
39
+
40
+ /* ── one state per workspace ───────────────────────────────────────────────────────────────────────────────── */
41
+ const states = new Map();
42
+ const stateOf = (root) => { let s = states.get(root); if (!s) states.set(root, s = { kept: null, partial: null, inflight: null, startedAt: 0, lastError: null, lastErrorAt: 0 }); return s; };
43
+ let last = null;
44
+
45
+ const cacheFile = (dir, root) => path.join(dir, 'briefing-premium-products-' + crypto.createHash('sha1').update(root).digest('hex').slice(0, 10) + '.json');
46
+ const loadCache = (dir, root) => { try { const j = JSON.parse(fs.readFileSync(cacheFile(dir, root), 'utf8')); return j && j.root === root && j.at && Array.isArray(j.initiatives) ? j : null; } catch { return null; } };
47
+ const saveCache = (dir, root, k) => { try { fs.mkdirSync(dir, { recursive: true }); const f = cacheFile(dir, root), t = f + '.' + process.pid + '.tmp'; fs.writeFileSync(t, JSON.stringify({ at: k.at, root, key: k.key, ok: k.ok, initiatives: k.initiatives })); fs.renameSync(t, f); } catch { /* a cache that cannot be written is only a slower next start */ } };
48
+
49
+ /** One workspace's read: the quick facts always, the slow initiative state only when its repository changed since the last answer. */
50
+ async function refreshOne({ root, engine, runtimeDir }) {
51
+ const s = stateOf(root);
52
+ const key = await BP.repoKey(root);
53
+ const f = await BP.readFast({ root, now: Date.now(), runtimeDir, calendar: false });
54
+ let inits = s.kept && key && s.kept.key === key && s.kept.ok && s.kept.initiatives ? s.kept.initiatives : null;
55
+ if (!inits) {
56
+ if (!s.kept) s.partial = { at: Date.now(), f: Object.assign({}, f, { initiatives: [], pending: true }) };
57
+ inits = await BP.readInitiatives({ root, engine });
58
+ }
59
+ if (!inits) f.missing.push('workflow');
60
+ s.kept = { at: Date.now(), key, ok: !!inits, initiatives: inits || [], f: Object.assign({}, f, { initiatives: inits || [] }) };
61
+ s.partial = null; s.lastError = null; s.lastErrorAt = 0;
62
+ if (runtimeDir) saveCache(runtimeDir, root, s.kept);
63
+ }
64
+ function start(p, engine, runtimeDir) {
65
+ const s = stateOf(p.root);
66
+ if (!s.inflight && !(s.lastError && Date.now() - s.lastErrorAt < RETRY_AFTER_ERROR_MS)) {
67
+ s.startedAt = Date.now();
68
+ s.inflight = refreshOne({ root: p.root, engine, runtimeDir }).catch((e) => { s.lastError = String((e && e.stack) || e).split('\n').slice(0, 3).join(' | ').slice(0, 300); s.lastErrorAt = Date.now(); }).finally(() => { s.inflight = null; });
69
+ }
70
+ return s.inflight;
71
+ }
72
+
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, sources }) {
75
+ const now = Date.now();
76
+ const on = (available || []).filter((a) => a.on);
77
+ const parts = [];
78
+ let asOf = null, refreshing = false, error = null, elapsed = 0;
79
+ for (const p of on) {
80
+ const s = stateOf(p.root);
81
+ if (!s.kept && runtimeDir) {
82
+ const c = loadCache(runtimeDir, p.root);
83
+ if (c) { const f = await BP.readFast({ root: p.root, now, runtimeDir, calendar: false }); s.kept = { at: c.at, key: c.key, ok: c.ok, initiatives: c.initiatives, f: Object.assign({}, f, { initiatives: c.initiatives }) }; }
84
+ }
85
+ if (!s.kept || Date.now() - s.kept.at > STALE_MS) start(p, engine, runtimeDir);
86
+ const product = { ws: p.ws, name: p.name };
87
+ if (s.kept) {
88
+ parts.push({ product, state: 'ready', f: s.kept.f });
89
+ asOf = asOf == null ? s.kept.at : Math.min(asOf, s.kept.at);
90
+ if (s.inflight) refreshing = true;
91
+ if (s.lastError) error = (error ? error + '; ' : '') + p.name + ': ' + s.lastError;
92
+ } else if (s.partial) {
93
+ parts.push({ product, state: 'ready', f: s.partial.f });
94
+ asOf = asOf == null ? s.partial.at : Math.min(asOf, s.partial.at);
95
+ } else if (s.lastError && !s.inflight) parts.push({ product, state: 'error', f: null, error: s.lastError });
96
+ else { parts.push({ product, state: 'reading', f: null }); elapsed = Math.max(elapsed, now - s.startedAt); }
97
+ }
98
+ const settled = parts.filter((p) => p.state === 'ready').length;
99
+ if (on.length && !settled && parts.every((p) => p.state === 'reading')) return { state: 'reading', elapsed_ms: elapsed };
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, sources });
102
+ last = model;
103
+ return { state: 'ready', model, as_of: asOf || now, refreshing, pending: model.pending, ...(error ? { error } : {}) };
104
+ }
105
+
106
+ /** After a write: the quick facts of one workspace again (the PM's decision or a returned run changed its records). */
107
+ async function rebuildOne({ root, runtimeDir }) {
108
+ const s = stateOf(root);
109
+ if (s.inflight) await s.inflight;
110
+ if (!s.kept) return false;
111
+ const f = await BP.readFast({ root, now: Date.now(), runtimeDir, calendar: false });
112
+ if (!s.kept.ok) f.missing.push('workflow');
113
+ s.kept = Object.assign({}, s.kept, { f: Object.assign({}, f, { initiatives: s.kept.initiatives }) });
114
+ return true;
115
+ }
116
+
117
+ const peekModel = () => last;
118
+ const settle = () => Promise.all([...states.values()].map((s) => s.inflight).filter(Boolean));
119
+ const forget = () => { states.clear(); last = null; };
120
+
121
+ module.exports = { choose, splitKey, modelOfMany, rebuildOne, peekModel, settle, forget, refreshOne, STALE_MS };