create-pmos 0.253.0 → 0.254.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/briefing-premium-products-test.js +164 -0
- package/workspace/guide/briefing-premium.md +16 -2
- package/workspace/lib/briefing-premium-products.js +121 -0
- package/workspace/lib/briefing-premium.js +86 -19
- package/workspace/lib/routes-briefing-premium.js +37 -5
- package/workspace/lib/settings-store.js +3 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/premium.css +14 -0
- package/workspace/public/pm/premium.js +49 -5
- package/workspace/public/premium.html +1 -0
- package/workspace/server.js +1 -1
- package/workspace/test-files.json +2 -0
- package/workspace/test-lists/briefing-premium.json +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.254.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": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-17T14:34:03Z"}
|
|
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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-17T14:34:03Z"}
|
|
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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-17T14:34:03Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -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); });
|
|
@@ -7,10 +7,24 @@ 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
|
|
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
|
+
- **Email and Confluence** are not read by this page. They arrive as items on My work.
|
|
27
|
+
|
|
14
28
|
## How long it takes to load
|
|
15
29
|
|
|
16
30
|
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 +32,7 @@ Two reads feed the page. The quick read takes the kill conditions, the failed-re
|
|
|
18
32
|
- **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
33
|
- **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
34
|
- **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
|
|
35
|
+
- **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
36
|
- **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
37
|
- **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
38
|
|
|
@@ -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 }) {
|
|
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 });
|
|
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 };
|
|
@@ -35,6 +35,7 @@ const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July',
|
|
|
35
35
|
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
36
36
|
const dateLine = (ms) => { const d = new Date(ms); return WEEKDAYS[d.getDay()] + ' ' + d.getDate() + ' ' + MONTHS[d.getMonth()]; };
|
|
37
37
|
/* A short name for a sentence: the title up to its first dash, colon or bracket, without a leading article. */
|
|
38
|
+
const joinNames = (a) => (a.length < 2 ? a.join('') : a.slice(0, -1).join(', ') + ' and ' + a[a.length - 1]);
|
|
38
39
|
const shortName = (t) => clip(String(t || '').split(/\s[—–-]\s|:|\(/)[0].replace(/^(the|a|an)\s+/i, ''), 48);
|
|
39
40
|
const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
40
41
|
const shortDate = (ms) => { const d = new Date(ms); return d.getDate() + ' ' + MON[d.getMonth()]; };
|
|
@@ -75,6 +76,8 @@ const START_HOLD = 'Starting an agent from here arrives with the live conversati
|
|
|
75
76
|
const tryD = (fn) => { try { return fn(); } catch { return null; } };
|
|
76
77
|
const skipped = { n: 0 };
|
|
77
78
|
|
|
79
|
+
/** Two products can hold an initiative of the same name, so a lookup by id also names the workspace (none in a one-workspace read). */
|
|
80
|
+
const idk = (x, id) => (x.ws || '') + '|' + id;
|
|
78
81
|
const live = (i) => i && !i.accepted && !i.stopped && !i.legacy;
|
|
79
82
|
|
|
80
83
|
/* ── rows ─────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
@@ -206,15 +209,23 @@ function waitedRows(f) {
|
|
|
206
209
|
}];
|
|
207
210
|
}
|
|
208
211
|
|
|
209
|
-
|
|
210
|
-
|
|
212
|
+
/** A row of one product in a merged list: its key names the workspace (kind:id:ws) so two products never share a key, and it carries the product's name. */
|
|
213
|
+
const tagRow = (r, p) => Object.assign({}, r, { key: r.key + ':' + p.ws, product: p.name, ws: p.ws });
|
|
214
|
+
/** Every row of one set of facts, in the order the rules give, with those the PM put off split out. Not yet cut to the page's cap. */
|
|
215
|
+
function allRowsOf(f) {
|
|
216
|
+
let every = [].concat(blockedRows(f), decisionRows(f), waitedRows(f));
|
|
217
|
+
if (f.product) every = every.map((r) => tagRow(r, f.product));
|
|
211
218
|
const off = (r) => (f.defers && f.defers[r.key] > f.now); // put off by the PM until a time that has not come
|
|
212
219
|
const all = every.filter((r) => !off(r));
|
|
213
|
-
const putOff = every.filter(off).map((r) => ({ key: r.key, title: r.title, until: f.defers[r.key] }));
|
|
220
|
+
const putOff = every.filter(off).map((r) => ({ key: r.key, title: r.title, until: f.defers[r.key], ...(r.product ? { product: r.product } : {}) }));
|
|
221
|
+
return { all, putOff };
|
|
222
|
+
}
|
|
223
|
+
const cut = (all, putOff) => {
|
|
214
224
|
const rows = all.slice(0, MAX_ROWS).map((r, k) => Object.assign({ n: k + 1, first: k === 0 }, r));
|
|
215
225
|
rows.forEach((r) => delete r.order);
|
|
216
226
|
return { rows, more: Math.max(0, all.length - rows.length), putOff };
|
|
217
|
-
}
|
|
227
|
+
};
|
|
228
|
+
function rowsOf(f) { const a = allRowsOf(f); return cut(a.all, a.putOff); }
|
|
218
229
|
|
|
219
230
|
/* ── hero ─────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
220
231
|
function correctionRate(runs, from, to) {
|
|
@@ -244,9 +255,10 @@ function heroOf(f, rows) {
|
|
|
244
255
|
const clauses = [];
|
|
245
256
|
if (blocked.length) {
|
|
246
257
|
const ordering = blocked.every((r) => r.stepKey === 'spec' || r.stepKey === 'prototype');
|
|
247
|
-
|
|
258
|
+
const where = [...new Set(blocked.map((r) => r.product).filter(Boolean))];
|
|
259
|
+
clauses.push({ t: num(blocked.length) + ' ' + plural(blocked.length, 'initiative is', 'initiatives are') + ' blocked' + (ordering ? ' on ordering' : '') + (where.length ? ' in ' + joinNames(where) : ''), from: blocked.map((r) => r.from[0]) });
|
|
248
260
|
}
|
|
249
|
-
rest.forEach((r) => clauses.push({ t: r.hero, from: r.from }));
|
|
261
|
+
rest.forEach((r) => clauses.push({ t: r.hero + (r.product ? ' in ' + r.product : ''), from: r.from }));
|
|
250
262
|
if (clauses.length) {
|
|
251
263
|
const p = [['What is stuck is yours: ', 0]];
|
|
252
264
|
clauses.forEach((c, k) => {
|
|
@@ -286,13 +298,13 @@ function mergeDialog(m, f) {
|
|
|
286
298
|
};
|
|
287
299
|
}
|
|
288
300
|
function runDialog(r, t, byId) {
|
|
289
|
-
const i = byId.get(r.initiative_id), ap = r.pre_run_contract && r.pre_run_contract.PM_approved === true;
|
|
301
|
+
const i = byId.get(idk(r, r.initiative_id)), ap = r.pre_run_contract && r.pre_run_contract.PM_approved === true;
|
|
290
302
|
const facts = [['Initiative', (i && i.title) ? clip(i.title, 70) : r.initiative_id], ['Run', r.run_id], ['Lane', r.lane || (i && i.lane)], ['Corrections', Number.isFinite(Number(r.human_corrections)) ? String(r.human_corrections) : ''], ['Contract approved', r.pre_run_contract ? (ap ? 'Yes' : 'No') : ''], ['Recorded', longDate(t) + ' ' + hhmm(t)]].filter((x) => x[1]);
|
|
291
303
|
return {
|
|
292
304
|
eye: 'Agent run', logTitle: 'Read from the run log', title: i ? clip(i.title, 90) : r.initiative_id, sub: hhmm(t) + ' · agent run',
|
|
293
305
|
log: ['Read planning/runs/agent-runs.jsonl', 'Matched the run to its initiative' + (i ? '' : ' (no record found)'), 'Read its recorded fields'],
|
|
294
306
|
say: 'This run is logged. Nothing needs you unless you disagree with it.',
|
|
295
|
-
head: 'The run', facts, opts: [], sel: null, nodef: '', action: 'Return this run to the agent', write: { kind: 'return', run: r.run_id },
|
|
307
|
+
head: 'The run', facts, opts: [], sel: null, nodef: '', action: 'Return this run to the agent', write: { kind: 'return', run: r.run_id, ...(r.ws ? { ws: r.ws } : {}) },
|
|
296
308
|
pane: { label: 'Run record', file: 'planning/runs/agent-runs.jsonl', h: i ? clip(i.title, 90) : r.initiative_id, intro: '', rows: facts, paras: r.note ? [clip(r.note, 600)] : [], parasTitle: 'Its note' },
|
|
297
309
|
};
|
|
298
310
|
}
|
|
@@ -314,10 +326,10 @@ function todayOf(f) {
|
|
|
314
326
|
for (const m of f.meetings || []) {
|
|
315
327
|
items.push({ at: m.start, t: hhmm(m.start), kind: 'meeting', title: m.title, sub: [Math.round((m.end - m.start) / 60000) + ' min', m.with].filter(Boolean).join(' · '), src: 'Calendar', dialog: tryD(() => meetingDialog(m)) });
|
|
316
328
|
}
|
|
317
|
-
for (const m of f.merges || []) if (m.at >= midnight && m.at <= f.now) items.push({ at: m.at, t: hhmm(m.at), kind: 'merge', title: clip(m.title, 110), sub: 'Merged to main', src: 'Merges', dialog: tryD(() => mergeDialog(m, f)) });
|
|
329
|
+
for (const m of f.merges || []) if (m.at >= midnight && m.at <= f.now) items.push({ at: m.at, t: hhmm(m.at), kind: 'merge', ...(m.product ? { product: m.product } : {}), title: clip(m.title, 110), sub: 'Merged to main', src: 'Merges', dialog: tryD(() => mergeDialog(m, f)) });
|
|
318
330
|
const runs = (f.runs || []).filter((r) => { const t = Date.parse(r.created_at); return t >= midnight && t <= f.now; });
|
|
319
|
-
const byId = new Map(f.initiatives.map((i) => [i.id, i]));
|
|
320
|
-
for (const r of runs) { const t = Date.parse(r.created_at); items.push({ at: t, t: hhmm(t), kind: 'run', title: clip((byId.get(r.initiative_id) || {}).title || r.initiative_id, 110), sub: 'Agent run' + (Number.isFinite(Number(r.human_corrections)) ? ' · ' + r.human_corrections + ' ' + plural(Number(r.human_corrections), 'correction', 'corrections') : ''), src: 'Agent runs', dialog: tryD(() => runDialog(r, t, byId)) }); }
|
|
331
|
+
const byId = new Map(f.initiatives.map((i) => [idk(i, i.id), i]));
|
|
332
|
+
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)) }); }
|
|
321
333
|
const ahead = items.filter((x) => x.at > f.asOf).sort((a, b) => a.at - b.at), earlier = items.filter((x) => x.at <= f.asOf).sort((a, b) => b.at - a.at);
|
|
322
334
|
const count = (k) => items.filter((x) => x.kind === k).length;
|
|
323
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.' };
|
|
@@ -348,6 +360,56 @@ function build(f) {
|
|
|
348
360
|
};
|
|
349
361
|
}
|
|
350
362
|
|
|
363
|
+
|
|
364
|
+
/* ── several products (initiative briefing-premium-products) ──────────────────────────────────────────────── */
|
|
365
|
+
const KIND_RANK = { blocked: 0, decision: 1, waited: 2 };
|
|
366
|
+
const longest = (r) => { const d = (r.detail && r.detail.days) || []; return d.length ? d[d.length - 1] : 0; };
|
|
367
|
+
|
|
368
|
+
/** Several products' facts → ONE model, in the same shape build() makes.
|
|
369
|
+
* g = { now, asOf, meetings (shared, or null), defers { key: until }, available: [{ ws, name, home, on }],
|
|
370
|
+
* parts: [{ product: { ws, name }, f (that product's facts) | null, state: 'ready' | 'reading' | 'error', error? }] }
|
|
371
|
+
* A row belongs to one product (its key ends with the workspace id) and the rows are put in one order: blocked first, then decisions, then reviews.
|
|
372
|
+
* A count is never summed across products: each product gets its own failed-review row. The hero and Today read the merged facts. */
|
|
373
|
+
function buildMany(g) {
|
|
374
|
+
skipped.n = 0;
|
|
375
|
+
const now = g.now, asOf = g.asOf || g.now, meetings = g.meetings === undefined ? null : g.meetings;
|
|
376
|
+
const parts = (g.parts || []).map((p) => ({ product: p.product, state: p.state || 'ready', error: p.error, f: p.f ? Object.assign({ initiatives: [], runs: [], merges: [], evals: [], tripwires: null, missing: [], protoDecided: [] }, p.f, { now, asOf, meetings, defers: g.defers || {}, product: p.product }) : null }));
|
|
377
|
+
const read = parts.filter((p) => p.f && p.state !== 'error');
|
|
378
|
+
const every = [], putOff = [];
|
|
379
|
+
for (const p of read) { const a = allRowsOf(p.f); every.push(...a.all); putOff.push(...a.putOff); }
|
|
380
|
+
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);
|
|
382
|
+
const tag = (list, p) => list.map((x) => Object.assign({}, x, { ws: p.product.ws, product: p.product.name }));
|
|
383
|
+
const cf = {
|
|
384
|
+
now, asOf, meetings, defers: g.defers || {},
|
|
385
|
+
initiatives: [].concat(...read.map((p) => p.f.initiatives.map((i) => Object.assign({}, i, { ws: p.product.ws })))),
|
|
386
|
+
runs: [].concat(...read.map((p) => tag(p.f.runs, p))),
|
|
387
|
+
merges: [].concat(...read.map((p) => tag(p.f.merges, p))),
|
|
388
|
+
evals: [].concat(...read.map((p) => tag(p.f.evals, p))),
|
|
389
|
+
};
|
|
390
|
+
const available = g.available || [], inc = available.filter((a) => a.on), out = available.filter((a) => !a.on);
|
|
391
|
+
const waiting = parts.filter((p) => p.state === 'reading' || !p.f).filter((p) => p.state !== 'error'), failed = parts.filter((p) => p.state === 'error');
|
|
392
|
+
const pending = parts.some((p) => p.state === 'reading' || (p.f && p.f.pending));
|
|
393
|
+
const missing = [];
|
|
394
|
+
if (meetings === null) missing.push('calendar');
|
|
395
|
+
for (const p of read) for (const k of p.f.missing || []) missing.push(k + ' (' + p.product.name + ')');
|
|
396
|
+
for (const p of failed) missing.push(p.product.name);
|
|
397
|
+
const missText = [].concat(meetings === null ? ['the calendar'] : [], ...read.map((p) => (p.f.missing || []).map((k) => (SOURCE_NAME[k] || k) + ' of ' + p.product.name)), failed.map((p) => p.product.name + ' could not be read'));
|
|
398
|
+
const none = !inc.length;
|
|
399
|
+
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
|
+
: heroOf(cf, rows);
|
|
401
|
+
return {
|
|
402
|
+
date: dateLine(now),
|
|
403
|
+
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 },
|
|
405
|
+
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
|
+
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
|
+
today: todayOf(cf),
|
|
408
|
+
dock: { needs: pending ? null : rows.length + more, label: plural(rows.length + more, 'needs you', 'need you'), next: rows[0] ? rows[0].title : null },
|
|
409
|
+
products: { available: available.map((a) => ({ ws: a.ws, name: a.name, home: !!a.home, on: !!a.on })), none },
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
351
413
|
/* ── I/O ──────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
352
414
|
const readJsonl = (file) => { try { return fs.readFileSync(file, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch { return null; } };
|
|
353
415
|
const run = (cmd, args, opts) => new Promise((res) => {
|
|
@@ -406,8 +468,18 @@ async function repoKey(root) {
|
|
|
406
468
|
return head.trim() + ':' + require('crypto').createHash('sha1').update(st || '').digest('hex').slice(0, 12);
|
|
407
469
|
}
|
|
408
470
|
|
|
471
|
+
/** Today's calendar rows, from the calendar PMOS holds (one for the whole computer, not one per workspace). null when it cannot be read. */
|
|
472
|
+
function readMeetings(runtimeDir, now) {
|
|
473
|
+
try {
|
|
474
|
+
const CE = require('./calendar-events.js');
|
|
475
|
+
if (!runtimeDir) throw new Error('no runtime dir');
|
|
476
|
+
const all = CE.readAll(runtimeDir, { now });
|
|
477
|
+
return CE.eventsOn(all, dayKey(now)).map((e) => ({ start: Date.parse(e.start), end: Date.parse(e.end), title: e.title, with: e.with || '', initiative: e.initiative || null })).filter((e) => Number.isFinite(e.start));
|
|
478
|
+
} catch { return null; }
|
|
479
|
+
}
|
|
480
|
+
|
|
409
481
|
/** The quick read: runs, reviews, merges (with their files), the calendar and the kill conditions. No initiative state. */
|
|
410
|
-
async function readFast({ root, now, runtimeDir }) {
|
|
482
|
+
async function readFast({ root, now, runtimeDir, calendar = true }) {
|
|
411
483
|
const f = { now: now || Date.now(), asOf: now || Date.now(), initiatives: [], runs: [], merges: [], evals: [], meetings: null, tripwires: null, missing: [] };
|
|
412
484
|
const runs = readJsonl(path.join(root, 'planning', 'runs', 'agent-runs.jsonl')); if (runs) f.runs = runs; else f.missing.push('runs');
|
|
413
485
|
const evals = readJsonl(path.join(root, 'planning', 'runs', 'evals.jsonl')); if (evals) f.evals = evals; else f.missing.push('evals');
|
|
@@ -423,12 +495,7 @@ async function readFast({ root, now, runtimeDir }) {
|
|
|
423
495
|
m.files = nums.length; m.ins = ins; m.del = del; m.dirs = Object.entries(top).sort((a, b) => b[1] - a[1]).slice(0, 3).map((e) => e[0]);
|
|
424
496
|
m.body = lines.filter((x) => !/^(\d+|-)\t(\d+|-)\t/.test(x)).join('\n').trim();
|
|
425
497
|
}
|
|
426
|
-
|
|
427
|
-
const CE = require('./calendar-events.js');
|
|
428
|
-
if (!runtimeDir) throw new Error('no runtime dir');
|
|
429
|
-
const all = CE.readAll(runtimeDir, { now: f.now });
|
|
430
|
-
f.meetings = CE.eventsOn(all, dayKey(f.now)).map((e) => ({ start: Date.parse(e.start), end: Date.parse(e.end), title: e.title, with: e.with || '', initiative: e.initiative || null })).filter((e) => Number.isFinite(e.start));
|
|
431
|
-
} catch { f.meetings = null; }
|
|
498
|
+
if (calendar) f.meetings = readMeetings(runtimeDir, f.now);
|
|
432
499
|
f.tripwires = readTripwires(root);
|
|
433
500
|
f.protoDecided = [...W.decidedPrototypes(root)];
|
|
434
501
|
f.defers = Object.fromEntries(W.readDefers(runtimeDir, f.now));
|
|
@@ -511,4 +578,4 @@ const peekModel = (root) => (kept && kept.root === root ? kept.model : partial &
|
|
|
511
578
|
const settle = () => inflight || Promise.resolve();
|
|
512
579
|
const forget = () => { kept = null; partial = null; inflight = null; lastError = null; startedAt = 0; };
|
|
513
580
|
|
|
514
|
-
module.exports = { build, hooks, refresh, readSources, readFast, readInitiatives, repoKey, readFm, readTripwires, modelOf, settle, forget, rebuild, peekModel, rowsOf, heroOf, todayOf, failedReviews, num };
|
|
581
|
+
module.exports = { build, buildMany, allRowsOf, readMeetings, hooks, refresh, readSources, readFast, readInitiatives, repoKey, readFm, readTripwires, modelOf, settle, forget, rebuild, peekModel, rowsOf, heroOf, todayOf, failedReviews, num };
|
|
@@ -20,6 +20,23 @@ const { repoScript } = require('./repo-script.js');
|
|
|
20
20
|
const { correctionEvent } = require(repoScript('card-state.js'));
|
|
21
21
|
const { appendOutbox } = require(repoScript('outbox.js'));
|
|
22
22
|
|
|
23
|
+
const Prod = require('./briefing-premium-products.js');
|
|
24
|
+
const settings = require('./settings-store.js');
|
|
25
|
+
|
|
26
|
+
/** The workspaces the Briefing may read right now (initiative briefing-premium-products): choose()'s list, from the server's workspace list and the PM's setting. */
|
|
27
|
+
function products(ctx, only) {
|
|
28
|
+
const home = ctx.homeRoot || ctx.selfRoot || null;
|
|
29
|
+
const list = (typeof ctx.workspaceList === 'function' ? ctx.workspaceList() : []).map((w) => ({ id: w.id, name: w.label || w.name, root: w.root, home: !!home && path.resolve(w.root) === path.resolve(home) }));
|
|
30
|
+
return Prod.choose({ workspaces: list, include: settings.get(ctx.runtimeDir, 'ws.briefing.include', null), only });
|
|
31
|
+
}
|
|
32
|
+
/** The included product a merged row (or a returned run) belongs to: { root, ws } | { error }. null when the key is the one-workspace page's. */
|
|
33
|
+
function ownerOf(ctx, ws) {
|
|
34
|
+
const a = products(ctx).available.find((x) => x.ws === ws);
|
|
35
|
+
if (!a) return { error: 'that product is not registered' };
|
|
36
|
+
if (!a.on) return { error: a.name + ' is not included in the Briefing' };
|
|
37
|
+
return { root: a.root, ws: a.ws };
|
|
38
|
+
}
|
|
39
|
+
|
|
23
40
|
const enginePath = () => ENGINE_PATHS.find((p) => fs.existsSync(p)) || null;
|
|
24
41
|
|
|
25
42
|
const POSTS = ['decide', 'put-off', 'return', 'ask'];
|
|
@@ -37,21 +54,29 @@ async function post(req, res, url, ctx) {
|
|
|
37
54
|
const b = got.body;
|
|
38
55
|
const g = guard.requirePagePress(req, b);
|
|
39
56
|
if (!g.ok) { ctx.json(res, { ok: false, error: g.error }, g.status); return true; }
|
|
40
|
-
const
|
|
57
|
+
const runtimeDir = ctx.runtimeDir;
|
|
58
|
+
// A merged row's key ends with its workspace (kind:id:ws); the one-workspace page's key does not and keeps the old path.
|
|
59
|
+
const sk = Prod.splitKey(b.key);
|
|
60
|
+
let root = rootOf(url, ctx), merged = false;
|
|
61
|
+
if (sk || (act === 'return' && b.ws)) {
|
|
62
|
+
const o = ownerOf(ctx, sk ? sk.ws : String(b.ws));
|
|
63
|
+
if (o.error) { ctx.json(res, { ok: false, error: o.error }, 400); return true; }
|
|
64
|
+
root = o.root; merged = true;
|
|
65
|
+
}
|
|
41
66
|
if (act === 'ask') {
|
|
42
|
-
let wsId; try { const w = ctx.getWs && ctx.getWs(url.searchParams.get('ws') || null); wsId = w && w.id; } catch { wsId = undefined; }
|
|
43
|
-
const a = Ag.ask({ runtimeDir, key: b.key, text: b.text, model: /^work:/.test(String(b.key || '')) ? WP.peekModel() : /^board:/.test(String(b.key || '')) ? BoardP.peekModel() : /^(init|doc):/.test(String(b.key || '')) ? InitP.peekModel() : BP.peekModel(root), spawnSession: ctx.spawnSession, wsId });
|
|
67
|
+
let wsId; try { const w = merged && sk ? { id: sk.ws } : ctx.getWs && ctx.getWs(url.searchParams.get('ws') || null); wsId = w && w.id; } catch { wsId = undefined; }
|
|
68
|
+
const a = Ag.ask({ runtimeDir, key: b.key, text: b.text, model: /^work:/.test(String(b.key || '')) ? WP.peekModel() : /^board:/.test(String(b.key || '')) ? BoardP.peekModel() : /^(init|doc):/.test(String(b.key || '')) ? InitP.peekModel() : sk ? Prod.peekModel() : BP.peekModel(root), spawnSession: ctx.spawnSession, wsId });
|
|
44
69
|
ctx.json(res, a.body, a.status);
|
|
45
70
|
return true;
|
|
46
71
|
}
|
|
47
72
|
let r;
|
|
48
73
|
try {
|
|
49
|
-
if (act === 'decide') r = Wr.decide({ root, key: b.key, choice: b.choice, why: b.why });
|
|
74
|
+
if (act === 'decide') r = Wr.decide({ root, key: sk ? sk.base : b.key, choice: b.choice, why: b.why });
|
|
50
75
|
else if (act === 'put-off') r = Wr.putOff({ runtimeDir, key: b.key, until: b.until });
|
|
51
76
|
else r = Wr.returnRun({ root, runtimeDir, run: b.run, reason: b.reason, correctionEvent, appendOutbox });
|
|
52
77
|
} catch (e) { r = { ok: false, status: 500, error: 'the record could not be written: ' + String((e && e.message) || e).slice(0, 160) }; }
|
|
53
78
|
if (!r.ok) { ctx.json(res, { ok: false, error: r.error }, r.status || 400); return true; }
|
|
54
|
-
try { await BP.rebuild({ root, runtimeDir }); } catch { /* the page re-reads on its next poll */ }
|
|
79
|
+
try { if (merged) await Prod.rebuildOne({ root, runtimeDir }); else await BP.rebuild({ root, runtimeDir }); } catch { /* the page re-reads on its next poll */ }
|
|
55
80
|
ctx.json(res, { ok: true, wrote: act, ...(r.heading ? { heading: r.heading } : {}) });
|
|
56
81
|
return true;
|
|
57
82
|
}
|
|
@@ -72,6 +97,13 @@ module.exports = {
|
|
|
72
97
|
}
|
|
73
98
|
if (url.pathname !== '/api/briefing-premium') return false;
|
|
74
99
|
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
100
|
+
// No ws: the Briefing across the products the PM picked. A ws: that one workspace, read as before.
|
|
101
|
+
if (!url.searchParams.get('ws')) {
|
|
102
|
+
const sel = products(ctx);
|
|
103
|
+
const r = await Prod.modelOfMany({ available: sel.available, engine: enginePath(), runtimeDir: ctx.runtimeDir });
|
|
104
|
+
ctx.json(res, r.state === 'error' ? { ok: false, ...r } : { ok: true, ...r }, r.state === 'ready' ? 200 : r.state === 'reading' ? 202 : 500);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
75
107
|
let root;
|
|
76
108
|
try { const w = ctx.getWs && ctx.getWs(url.searchParams.get('ws') || null); root = w && w.root; } catch { root = null; }
|
|
77
109
|
root = root || path.resolve(__dirname, '..', '..');
|
|
@@ -33,6 +33,8 @@ const bool = (section, scope, def) => ({ section, scope, type: 'bool', def });
|
|
|
33
33
|
const DEFS = Object.freeze({
|
|
34
34
|
'ws.start': one('workspaces', 'computer', ['last', 'ask'], 'last'),
|
|
35
35
|
'ws.briefing': one('workspaces', 'computer', ['one', 'all'], 'one'),
|
|
36
|
+
// the premium Briefing: which workspaces it reads (initiative briefing-premium-products). null = every one except the home workspace.
|
|
37
|
+
'ws.briefing.include': { section: 'workspaces', scope: 'computer', type: 'ids', max: 50, def: null },
|
|
36
38
|
'board.template': one('boards', 'ws', ['product', 'project', 'simple'], 'product'),
|
|
37
39
|
'board.rules': one('boards', 'ws', ['warn', 'off'], 'warn'),
|
|
38
40
|
'board.templates': { section: 'boards', scope: 'computer', type: 'templates', def: TEMPLATES },
|
|
@@ -121,6 +123,7 @@ function validate(key, value) {
|
|
|
121
123
|
if (typeof value !== 'string' || value.length > 64) return `${key} must be a time zone such as Asia/Jakarta, or empty for this computer's`;
|
|
122
124
|
try { new Intl.DateTimeFormat('en', { timeZone: value }); return null; } catch { return `${value} is not a time zone this computer knows`; }
|
|
123
125
|
}
|
|
126
|
+
case 'ids': return value === null || Array.isArray(value) && value.length <= d.max && value.every((x) => typeof x === 'string' && WS_ID.test(x)) && new Set(value).size === value.length ? null : `${key} must list at most ${d.max} workspace ids, each once`;
|
|
124
127
|
case 'templates': {
|
|
125
128
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return `${key} must name the templates`;
|
|
126
129
|
const ids = Object.keys(value);
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.254.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.254.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -1196,3 +1196,17 @@ button.dsn:hover{box-shadow:inset 0 0 0 1.5px rgb(79,109,245)}
|
|
|
1196
1196
|
.crmenu{max-height:min(60vh,420px);overflow:auto;overscroll-behavior:contain}
|
|
1197
1197
|
.crmenu button{flex:none}
|
|
1198
1198
|
.crmenu button span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
1199
|
+
|
|
1200
|
+
/* the Briefing across products (initiative briefing-premium-products): the product name on a row, and the picker built from the mockup's chip and menu */
|
|
1201
|
+
.pn{font:600 12.5px var(--mono);letter-spacing:.04em;color:var(--ink-2);margin-right:8px;padding:2px 8px;border-radius:999px;background:var(--well);white-space:nowrap}
|
|
1202
|
+
.pkw{position:relative;display:inline-flex;align-items:center}
|
|
1203
|
+
.pkw[hidden]{display:none}
|
|
1204
|
+
.pkw .fchip{height:44px}
|
|
1205
|
+
.pkmenu{left:auto;right:0;min-width:240px}
|
|
1206
|
+
.pkmenu .bx{flex:none;width:20px;height:20px;border-radius:6px;border:2px solid var(--ink-3);display:grid;place-items:center}
|
|
1207
|
+
.pkmenu button[aria-checked="true"] .bx{background:var(--solid);border-color:var(--solid)}
|
|
1208
|
+
.pkmenu button[aria-checked="true"] .bx::after{content:"";width:9px;height:5px;border-left:2px solid var(--on-solid);border-bottom:2px solid var(--on-solid);transform:rotate(-45deg) translate(1px,-1px)}
|
|
1209
|
+
.pkmenu .pkreset{margin-top:4px;border-top:1px solid var(--hair);border-radius:0 0 14px 14px;color:var(--ink-2)}
|
|
1210
|
+
.pkempty{padding:10px 14px}
|
|
1211
|
+
.top{position:relative;z-index:6}
|
|
1212
|
+
.pkw{z-index:40}
|
|
@@ -42,7 +42,7 @@ function drawRows() {
|
|
|
42
42
|
const keys = rows.map((r) => r.key), off = (M.list.putOff || []).map((x) => x.key); // a row put off is not done: it leaves both counts and is named below the list
|
|
43
43
|
const base = (M.pending ? keys : baseline(keys)).filter((k) => !off.includes(k)), done = base.filter((k) => !keys.includes(k)).length, total = base.length;
|
|
44
44
|
const order = rows.slice().sort((a, b) => (pinned.has(b.key) - pinned.has(a.key)) || (a.n - b.n));
|
|
45
|
-
L.innerHTML = order.map((r, i) => `<div class="rr${i < 3 ? ' lead' : ''}${i === 0 ? ' first' : ''}" data-key="${esc(r.key)}"${linkOf(r) ? ` data-link="${esc(linkOf(r))}"` : ''}><button class="rhd" aria-haspopup="dialog"><span class="rn">${STAMP(i + 1)}</span><span><span class="rt">${esc(r.title)}${i === 0 ? '<em class="tgf">Do this first</em>' : ''}</span><span class="rs">${esc(r.subject)}</span><span class="sug"><span>${esc(r.prepared)}</span></span><span class="skip">${esc(r.skip)}</span></span></button><button class="rpin" data-pin="${esc(r.key)}" aria-pressed="${pinned.has(r.key)}" aria-label="Pin ${esc(r.title)} to the top" title="Pin to the top"><svg class="i" viewBox="0 0 24 24"><path d="M9 4h6l-1 6 3 3H7l3-3zM12 13v7"/></svg></button></div>`).join('')
|
|
45
|
+
L.innerHTML = order.map((r, i) => `<div class="rr${i < 3 ? ' lead' : ''}${i === 0 ? ' first' : ''}" data-key="${esc(r.key)}"${linkOf(r) ? ` data-link="${esc(linkOf(r))}"` : ''}><button class="rhd" aria-haspopup="dialog"><span class="rn">${STAMP(i + 1)}</span><span><span class="rt">${esc(r.title)}${i === 0 ? '<em class="tgf">Do this first</em>' : ''}</span><span class="rs">${r.product ? `<b class="pn">${esc(r.product)}</b>` : ''}${esc(r.subject)}</span><span class="sug"><span>${esc(r.prepared)}</span></span><span class="skip">${esc(r.skip)}</span></span></button><button class="rpin" data-pin="${esc(r.key)}" aria-pressed="${pinned.has(r.key)}" aria-label="Pin ${esc(r.title)} to the top" title="Pin to the top"><svg class="i" viewBox="0 0 24 24"><path d="M9 4h6l-1 6 3 3H7l3-3zM12 13v7"/></svg></button></div>`).join('')
|
|
46
46
|
+ (M.list.more ? `<p class="note">${M.list.more} more ${M.list.more === 1 ? 'row is' : 'rows are'} not shown here.</p>` : '');
|
|
47
47
|
const n = M.list.count;
|
|
48
48
|
$('#movesCap').innerHTML = M.pending ? `${n} ${n === 1 ? 'row' : 'rows'} so far · still reading` : n ? `<b>${done} of ${total}</b> done · ${n} ${n === 1 ? 'row' : 'rows'} left` : 'All clear';
|
|
@@ -66,7 +66,7 @@ $('#rank').addEventListener('click', (e) => {
|
|
|
66
66
|
const linkOf = (r) => ((r.from || []).find((x) => /^meeting:/.test(x)) || '').replace(/^meeting:/, '');
|
|
67
67
|
function evHtml(e, i) {
|
|
68
68
|
const lnk = e.kind === 'meeting' && (M.list.rows || []).some((r) => linkOf(r) === e.title) ? e.title : '';
|
|
69
|
-
return `<button class="ev src-${e.kind}"${lnk ? ` data-link="${esc(lnk)}"` : ''} data-i="${i}" style="animation:rise .6s var(--ease) both"><time>${esc(e.t)}</time><span class="gl-t"><svg class="i" viewBox="0 0 24 24">${ICON[e.kind]}</svg></span><span><b>${esc(e.title)}</b><span class="d">${esc(e.sub)}</span></span></button>`;
|
|
69
|
+
return `<button class="ev src-${e.kind}"${lnk ? ` data-link="${esc(lnk)}"` : ''} data-i="${i}" style="animation:rise .6s var(--ease) both"><time>${esc(e.t)}</time><span class="gl-t"><svg class="i" viewBox="0 0 24 24">${ICON[e.kind]}</svg></span><span><b>${esc(e.title)}</b><span class="d">${esc(e.sub)}${e.product ? ' · ' + esc(e.product) : ''}</span></span></button>`;
|
|
70
70
|
}
|
|
71
71
|
const ALL = () => M.today.ahead.concat(M.today.earlier);
|
|
72
72
|
function drawChips() {
|
|
@@ -180,7 +180,7 @@ $('#dBody').addEventListener('click', (e) => {
|
|
|
180
180
|
const w = openR.dialog.write, why = ($('#why') || {}).value || '', key = openR.key;
|
|
181
181
|
WHY[key] = why;
|
|
182
182
|
if (w.kind === 'decision') write('decide', { key, choice: SEL[key] || openR.dialog.sel, why }, async (res) => { delete WHY[key]; toast('Recorded in pm-decisions.md.'); closeDlg(); await load(); });
|
|
183
|
-
else write('return', { run: w.run, reason: RET[key] }, async () => { toast('Returned to the agent. It counts as a correction.'); closeDlg(); await load(); });
|
|
183
|
+
else write('return', { run: w.run, reason: RET[key], ...(w.ws ? { ws: w.ws } : {}) }, async () => { toast('Returned to the agent. It counts as a correction.'); closeDlg(); await load(); });
|
|
184
184
|
}
|
|
185
185
|
});
|
|
186
186
|
$('#dBody').addEventListener('input', (e) => { if (e.target.id === 'why' && openR) { WHY[openR.key] = e.target.value; ctaState(openR); } });
|
|
@@ -197,6 +197,50 @@ function openEvent(e) {
|
|
|
197
197
|
['pointerleave', 'focusout'].forEach((ev) => document.addEventListener(ev, (e) => { const t = e.target.closest && e.target.closest('[data-link]'); if (t) lk(t, false); }, true));
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
|
|
201
|
+
/* ── the products the Briefing reads (initiative briefing-premium-products) ──────────────────────────────────────────────────────────── */
|
|
202
|
+
const wsParam = () => new URLSearchParams(location.search).get('ws');
|
|
203
|
+
function drawPick() {
|
|
204
|
+
const w = $('#pkw'), P = M.products, one = wsParam();
|
|
205
|
+
if (!w) return;
|
|
206
|
+
if (one) { w.hidden = false; $('#pick').hidden = true; $('#pickMenu').hidden = true; let a = $('#pickAll'); if (!a) { a = document.createElement('a'); a.id = 'pickAll'; a.className = 'fchip'; a.href = '/premium.html'; a.textContent = 'All picked products'; w.appendChild(a); } return; }
|
|
207
|
+
if ($('#pickAll')) $('#pickAll').remove();
|
|
208
|
+
$('#pick').hidden = false;
|
|
209
|
+
if (!P) { w.hidden = true; return; }
|
|
210
|
+
w.hidden = false;
|
|
211
|
+
const n = P.available.filter((x) => x.on).length;
|
|
212
|
+
$('#pickN').textContent = n + ' of ' + P.available.length;
|
|
213
|
+
$('#pickMenu').innerHTML = P.available.length
|
|
214
|
+
? P.available.map((x) => `<button role="menuitemcheckbox" aria-checked="${x.on}" data-ws="${esc(x.ws)}"><i class="bx"></i><span>${esc(x.name)}</span>${x.home ? '<em>home</em>' : ''}</button>`).join('') + '<button role="menuitem" data-reset="1" class="pkreset"><span>Back to the default</span><em>all but home</em></button>'
|
|
215
|
+
: '<p class="note pkempty">No product workspace is registered.</p>';
|
|
216
|
+
}
|
|
217
|
+
function closePick() { $('#pickMenu').hidden = true; $('#pick').setAttribute('aria-expanded', 'false'); }
|
|
218
|
+
$('#pick').addEventListener('click', (e) => {
|
|
219
|
+
e.stopPropagation();
|
|
220
|
+
const m = $('#pickMenu'), open = m.hidden;
|
|
221
|
+
m.hidden = !open; $('#pick').setAttribute('aria-expanded', open);
|
|
222
|
+
if (open) { const f = $('button', m); if (f) f.focus(); }
|
|
223
|
+
});
|
|
224
|
+
document.addEventListener('click', (e) => { if (!e.target.closest('#pkw')) closePick(); });
|
|
225
|
+
$('#pkw').addEventListener('keydown', (e) => {
|
|
226
|
+
if (e.key === 'Escape') { closePick(); $('#pick').focus(); return; }
|
|
227
|
+
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
|
|
228
|
+
const L = $$('#pickMenu button'), i = L.indexOf(document.activeElement); if (i < 0) return;
|
|
229
|
+
e.preventDefault(); L[(i + (e.key === 'ArrowDown' ? 1 : L.length - 1)) % L.length].focus();
|
|
230
|
+
});
|
|
231
|
+
$('#pickMenu').addEventListener('click', async (e) => {
|
|
232
|
+
const b = e.target.closest('button'); if (!b || !M || !M.products) return;
|
|
233
|
+
const on = M.products.available.filter((x) => x.on).map((x) => x.ws);
|
|
234
|
+
let body;
|
|
235
|
+
if (b.dataset.reset) body = { key: 'ws.briefing.include', reset: true };
|
|
236
|
+
else { const ws = b.dataset.ws, next = on.includes(ws) ? on.filter((x) => x !== ws) : on.concat(ws); body = { key: 'ws.briefing.include', value: next }; }
|
|
237
|
+
const res = await pmPost('/api/settings', body);
|
|
238
|
+
if (!res || res.ok !== true) { toast((res && res.error) || 'The choice was not saved.'); return; }
|
|
239
|
+
toast(b.dataset.reset ? 'Back to the default: every product except the home one.' : 'Saved. The Briefing now reads the products you ticked.');
|
|
240
|
+
await load();
|
|
241
|
+
const again = b.dataset.ws ? $(`#pickMenu [data-ws="${CSS.escape(b.dataset.ws)}"]`) : null; if (again) again.focus();
|
|
242
|
+
});
|
|
243
|
+
|
|
200
244
|
/* ── load ───────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
201
245
|
let readTimer = null;
|
|
202
246
|
const ago = (ms) => { const m = Math.round(ms / 60000); return m < 1 ? 'just now' : m < 90 ? m + ' min ago' : Math.round(m / 60) + ' h ago'; };
|
|
@@ -207,7 +251,7 @@ async function load() {
|
|
|
207
251
|
clearInterval(readTimer);
|
|
208
252
|
if (r && r.state === 'reading') {
|
|
209
253
|
// the first read has no end I can promise: say how long it has run, and ask again
|
|
210
|
-
const t0 = Date.now() - (r.elapsed_ms || 0), tick = () => { $('#brief').textContent = 'Reading the
|
|
254
|
+
const t0 = Date.now() - (r.elapsed_ms || 0), tick = () => { $('#brief').textContent = 'Reading the products for the first time. ' + Math.round((Date.now() - t0) / 1000) + ' s so far. This page shows nothing until it has read, rather than guess.'; };
|
|
211
255
|
tick(); readTimer = setInterval(tick, 1000); setTimeout(load, 3000);
|
|
212
256
|
return;
|
|
213
257
|
}
|
|
@@ -216,7 +260,7 @@ async function load() {
|
|
|
216
260
|
$('#brief').textContent = 'The Briefing could not read the repository, so it shows nothing rather than guess.' + (r && r.error ? ' The reason: ' + r.error : status ? ' (the server answered ' + status + ')' : ' The server did not answer.');
|
|
217
261
|
return;
|
|
218
262
|
}
|
|
219
|
-
M = r.model; drawHero(); drawRows(); drawChips(); drawTl(); drawDock(); onScroll();
|
|
263
|
+
M = r.model; drawHero(); drawRows(); drawChips(); drawTl(); drawDock(); drawPick(); onScroll();
|
|
220
264
|
const age = Date.now() - r.as_of;
|
|
221
265
|
if (age > 5 * 60000) $('#snap').textContent += ' · read ' + ago(age) + (r.refreshing ? ', refreshing' : '');
|
|
222
266
|
if (r.error) $('#snap').textContent += ' · the latest read failed: ' + r.error.slice(0, 120);
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
<header class="top rise" style="--d:0">
|
|
36
36
|
<h1 class="vh">Briefing</h1><span class="glass name" aria-hidden="true">Briefing</span>
|
|
37
37
|
<div class="tools tools-top"><span class="snap" id="snap">Reading the repository</span>
|
|
38
|
+
<span class="pkw" id="pkw" hidden><button class="fchip" id="pick" aria-haspopup="menu" aria-expanded="false" aria-controls="pickMenu">Products<em id="pickN"></em></button><div class="crmenu pkmenu" id="pickMenu" role="menu" hidden></div></span>
|
|
38
39
|
<button class="glass round" id="theme" aria-label="Switch light or dark"><svg class="i" viewBox="0 0 24 24"><path d="M20 14.5A8 8 0 0 1 9.5 4 8 8 0 1 0 20 14.5z"/></svg></button></div>
|
|
39
40
|
</header>
|
|
40
41
|
<section class="hero">
|
package/workspace/server.js
CHANGED
|
@@ -9360,7 +9360,7 @@ async function handleRequest(req, res) {
|
|
|
9360
9360
|
// pm-os-absorb Runs 2–5 — the planning layer's routes, one file per run (lib/route-modules.js). Last, so none shadows a route above.
|
|
9361
9361
|
// meeting-notes Run 1: the Notes routes keep notes in the per-user runtime directory and start the agent-summary session.
|
|
9362
9362
|
if (await routeModules.dispatch(req, res, url, { json, readBody, getWs, invalidateNeedsAgg, boardCache, consoleState, stateDirFor, sessionList, initiativeIndex,
|
|
9363
|
-
runtimeDir: STATE, spawnSession, jevModelOpts, sendToSession, endSupersededRuns, proSettings,
|
|
9363
|
+
runtimeDir: STATE, homeRoot: HOME_ENABLED ? SELF_ROOT : null, spawnSession, jevModelOpts, sendToSession, endSupersededRuns, proSettings,
|
|
9364
9364
|
// report-workspace: the schedule store a recurring report registers its draft-only schedule in, a skill's body, and the stop of a headless run
|
|
9365
9365
|
scheduleStore: { read: readSchedules, write: writeSchedules },
|
|
9366
9366
|
// report-structure-gaps: Draft it now on a report with a saved structure starts the draft session the schedule would, and the row reads its state
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"briefing-premium-work.test.js",
|
|
42
42
|
"briefing-premium-board.test.js",
|
|
43
43
|
"briefing-premium-initiative.test.js",
|
|
44
|
+
"briefing-premium-products.test.js",
|
|
44
45
|
"calendar-events.test.js",
|
|
45
46
|
"calendar-supersede.test.js",
|
|
46
47
|
"tracker-read.test.js",
|
|
@@ -200,6 +201,7 @@
|
|
|
200
201
|
"briefing-premium-work.test.js": 1,
|
|
201
202
|
"briefing-premium-board.test.js": 1,
|
|
202
203
|
"briefing-premium-initiative.test.js": 1,
|
|
204
|
+
"briefing-premium-products.test.js": 1,
|
|
203
205
|
"calendar-events.test.js": 1,
|
|
204
206
|
"calendar-supersede.test.js": 1,
|
|
205
207
|
"tracker-read.test.js": 1,
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
"briefing-premium-work-test.js",
|
|
6
6
|
"briefing-premium-board-test.js",
|
|
7
7
|
"briefing-premium-initiative-test.js",
|
|
8
|
+
"briefing-premium-products-test.js",
|
|
8
9
|
"briefing-premium-agent-server.test.js"
|
|
9
10
|
],
|
|
10
11
|
"seconds": {
|
|
@@ -12,6 +13,7 @@
|
|
|
12
13
|
"briefing-premium-work-test.js": 80,
|
|
13
14
|
"briefing-premium-board-test.js": 120,
|
|
14
15
|
"briefing-premium-initiative-test.js": 120,
|
|
16
|
+
"briefing-premium-products-test.js": 90,
|
|
15
17
|
"briefing-premium-agent-server.test.js": 40
|
|
16
18
|
}
|
|
17
19
|
}
|