create-pmos 0.48.0 → 0.50.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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +2 -2
  3. package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +1 -1
  4. package/payload/mods/pmos-cockpit/README.md +10 -1
  5. package/payload/mods/pmos-cockpit/hooks/lib/route.mjs +95 -0
  6. package/payload/mods/pmos-cockpit/hooks/register.tsx +67 -1
  7. package/payload/mods/pmos-cockpit/types/index.d.ts +1 -1
  8. package/payload/planning/evals/eval-calibration.md +1 -1
  9. package/payload/planning/okrs/health-budgets.md +1 -1
  10. package/payload/scripts/status-facts.js +13 -3
  11. package/payload/scripts/workflow-state.js +7 -2
  12. package/payload/state/discoveries.md +1 -1
  13. package/payload/state/log.md +1 -1
  14. package/workspace/guide/index.md +2 -1
  15. package/workspace/guide/initiatives-and-next-steps.md +3 -1
  16. package/workspace/guide/knowledge-graph.md +12 -1
  17. package/workspace/guide/layout.md +2 -0
  18. package/workspace/guide/live-notes.md +78 -0
  19. package/workspace/guide/validate-with-typesafe.md +15 -1
  20. package/workspace/lib/engine/status-facts.js +13 -3
  21. package/workspace/lib/engine/workflow-state.js +7 -2
  22. package/workspace/lib/jev-categorize.js +47 -13
  23. package/workspace/lib/jev-live.js +37 -0
  24. package/workspace/lib/jev-route.js +11 -1
  25. package/workspace/lib/live-notes.js +171 -26
  26. package/workspace/package-lock.json +1 -1
  27. package/workspace/package.json +8 -6
  28. package/workspace/public/pm/data.js +21 -1
  29. package/workspace/public/pm/modes-live.js +68 -5
  30. package/workspace/public/pm/panel-cockpit.js +3 -1
  31. package/workspace/public/pm/panel-detail.js +3 -1
  32. package/workspace/public/pm/panel-tabs.js +112 -10
  33. package/workspace/public/pm/state.js +3 -0
  34. package/workspace/public/pm/view-livenotes.js +397 -0
  35. package/workspace/run-tests.js +148 -0
  36. package/workspace/server.js +71 -9
  37. package/workspace/test-files.json +180 -0
@@ -0,0 +1,397 @@
1
+ /* pm/view-livenotes.js — the Live notes view tab: confirm each talking point as it arrives (jev-live-notes, Run 6).
2
+ *
3
+ * The PM types or pastes what is being said, one point per line. Each point comes back at once as a card with a guess: the
4
+ * open initiative that shares the most words with it, and an open question when one shares at least two. The guess is a
5
+ * LOCAL WORD MATCH made in the workspace process (lib/live-notes.js, the `local` path): no model, no Jev, no request leaves
6
+ * the machine, and a guess is never shown above "possible". The PM confirms it, picks another relation or initiative, or
7
+ * rejects it, one card at a time. Waiting cards are on top; decided cards drop below. Closing the session files a section in
8
+ * the day's requirements note and deletes the raw transcript.
9
+ *
10
+ * Reads and writes, all on this repository's workspace:
11
+ * GET /api/live-notes?ws=… the sessions GET /api/live-notes?ws=…&session=… one session, ranked, + its shortlist
12
+ * POST /api/live-notes/session start or resume POST /api/live-notes/add typed points
13
+ * POST /api/live-notes/decide accept · change · reject POST /api/live-notes/close file the note
14
+ * Points an agent session adds through the command (Runs 1 to 5) appear here on the next read. In the fixture nothing is read.
15
+ *
16
+ * LISTENING. "Start listening" uses the browser's speech recognition with `processLocally = true`: the audio is recognised on this
17
+ * device (Chrome 139 and later, after a one-time language download) and never sent anywhere. A browser that cannot recognise on
18
+ * the device is told so; there is no fallback to cloud recognition. Interim words show live and are never added. A final phrase
19
+ * is added at a sentence end or after a pause, and the server cleans it (fillers, stumbles, a sentence end) before it becomes a
20
+ * point. "This meeting is private" stops listening and marks the session, so Run 7 never sends a point from it.
21
+ *
22
+ * JEV (Run 7, D85). "Check with Jev" is the per-repository switch, default off. When it, a saved key and consent for the repository
23
+ * all hold, the server hands each cleaned point to Jev with the shortlist, and the card says "from Jev". When a gate is shut the
24
+ * view says which, in words, and the local word match answers. A point Jev could not answer gets the local guess and says so.
25
+ * POST /api/live-notes/switch { ws, live }: the switch; the reply carries the gates again
26
+ */
27
+ import { el } from './dom.js';
28
+
29
+ const BTN = 'font:500 12.5px var(--sans);background:transparent;color:var(--foreground);border:1px solid var(--input);border-radius:8px;padding:0 12px;height:30px;min-height:30px;display:inline-grid;place-items:center;cursor:pointer;flex:none;white-space:nowrap';
30
+ const BTNP = 'font:500 12.5px var(--sans);background:var(--foreground);color:var(--background);border:0;border-radius:8px;padding:0 12px;height:30px;min-height:30px;display:inline-grid;place-items:center;cursor:pointer;flex:none;white-space:nowrap';
31
+ const CHIP = 'font:500 12px var(--sans);background:transparent;color:var(--foreground);border:1px solid var(--border);border-radius:999px;padding:0 10px;height:26px;cursor:pointer;white-space:nowrap';
32
+ const CHIP_ON = 'font:500 12px var(--sans);background:var(--foreground);color:var(--background);border:1px solid var(--foreground);border-radius:999px;padding:0 10px;height:26px;cursor:pointer;white-space:nowrap';
33
+ const CARD = 'border:1px solid var(--border);background:var(--background);border-radius:10px;padding:12px 14px;display:flex;flex-direction:column;gap:8px';
34
+ const SELECT = 'font:13px var(--sans);background:var(--background);color:var(--foreground);border:1px solid var(--input);border-radius:8px;height:30px;padding:0 8px;max-width:100%';
35
+ const NOTE = 'color:var(--muted-foreground);font-size:13px;line-height:1.5';
36
+ const LBL = 'font:500 10px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground)';
37
+
38
+ /** The relations the PM can pick, in words. `none` is the initiative picker's "none of these", not a chip. */
39
+ export const RELATION_CHIPS = [
40
+ { key: 'validates', label: 'validates' }, { key: 'clarifies', label: 'clarifies' }, { key: 'extends', label: 'extends' },
41
+ { key: 'contradicts', label: 'contradicts' }, { key: 'answers', label: 'answers a question' },
42
+ ];
43
+ const REFRESH_MS = 5000;
44
+
45
+ /* ── module state: survives the redraw every setState causes ─────────────────────────────────── */
46
+ const reads = new Map(); // key -> { state, data, error, at }
47
+ const drafts = new Map(); // segment -> { initiative, relation, question } the PM's choice before Confirm
48
+ const errors = new Map(); // segment -> the server's refusal, in words
49
+ const sessionOf = new Map(); // ws -> the session shown
50
+ let withheldNow = []; // the last add's withheld points: { id, reason } — the text is never kept
51
+ let banner = null; // { tone, text }
52
+ let anchorAsk = null; // { ws, anchors: [{anchor, open}], error }
53
+ let busy = false;
54
+ let switching = false;
55
+ let input = null; // the textarea, kept across redraws so typing is not lost
56
+ let dataMod = null; let dataTried = false;
57
+ let listen = null; // { ws, session, state: 'checking'|'on'|'needs-install'|'installing'|'unsupported'|'error', rec, interim, buffer, timer, lang, msg }
58
+ const PAUSE_MS = 1500; // a pause this long ends a point
59
+
60
+ function read(ctx, key, url, ttl = REFRESH_MS) {
61
+ const hit = reads.get(key);
62
+ if (hit && (hit.state === 'loading' || Date.now() - hit.at < ttl)) return hit;
63
+ const next = { state: hit ? hit.state : 'loading', data: hit ? hit.data : null, error: hit ? hit.error : null, at: Date.now() };
64
+ reads.set(key, next);
65
+ fetch(url).then((r) => r.json()).then(
66
+ (d) => {
67
+ const same = hit && JSON.stringify(hit.data) === JSON.stringify(d);
68
+ reads.set(key, { state: d && d.ok === false ? 'error' : 'ok', data: d, error: d && d.error, at: Date.now() });
69
+ if (!same) ctx.setState({}); // redraw only when something changed, so a refresh does not disturb typing
70
+ },
71
+ (e) => { reads.set(key, { state: 'error', data: null, error: (e && e.message) || String(e), at: Date.now() }); ctx.setState({}); },
72
+ );
73
+ return next;
74
+ }
75
+ async function post(url, body) {
76
+ try { return await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then((r) => r.json()); }
77
+ catch (e) { return { ok: false, error: (e && e.message) || 'the workspace server did not answer' }; }
78
+ }
79
+ const forget = (ws, session) => { reads.delete('sessions:' + ws); if (session) reads.delete('session:' + ws + ':' + session); };
80
+
81
+ async function setJev(ctx, ws, on) {
82
+ if (switching) return;
83
+ switching = true; ctx.setState({});
84
+ const r = await post('/api/live-notes/switch', { ws, live: on });
85
+ switching = false;
86
+ if (!r || !r.ok) banner = { tone: 'bad', text: (r && r.error) || 'the switch did not change' };
87
+ else banner = null;
88
+ forget(ws, sessionOf.get(ws));
89
+ ctx.setState({});
90
+ }
91
+ const FLAG_WORDS = {
92
+ 'jev-not-reached': 'Jev was not reached, so this is the local word match.',
93
+ 'jev-unreadable': 'Jev\'s answer could not be read, so this is the local word match.',
94
+ };
95
+
96
+ /** Every point of a ranked session, flat: waiting first (newest on top), then decided (newest on top). */
97
+ export function feedOf(ranked) {
98
+ const all = [];
99
+ for (const sec of (ranked && ranked.sections) || []) for (const x of sec.items) all.push(x);
100
+ const newest = (a, b) => String(b.at || '').localeCompare(String(a.at || '')) || String(b.segment).localeCompare(String(a.segment));
101
+ return { waiting: all.filter((x) => !x.decision).sort(newest), decided: all.filter((x) => x.decision).sort(newest) };
102
+ }
103
+ /** The counter's numbers. */
104
+ export function countsOf(ranked) {
105
+ const f = feedOf(ranked);
106
+ const by = (a) => f.decided.filter((x) => x.decision.action === a).length;
107
+ return { confirmed: by('accept'), changed: by('change'), rejected: by('reject'), waiting: f.waiting.length, withheld: (ranked && ranked.withheld) || 0 };
108
+ }
109
+
110
+ /* ── actions ──────────────────────────────────────────────────────────────────────────────────── */
111
+ async function startSession(ctx, ws, anchor) {
112
+ busy = true; banner = null; ctx.setState({});
113
+ const r = await post('/api/live-notes/session', { ws, anchor: anchor || null });
114
+ busy = false;
115
+ if (r && r.ok) { sessionOf.set(ws, r.session); anchorAsk = null; withheldNow = []; forget(ws, r.session); }
116
+ else if (r && Array.isArray(r.anchors)) anchorAsk = { ws, anchors: r.anchors, error: r.error };
117
+ else banner = { tone: 'bad', text: (r && r.error) || 'the session did not start' };
118
+ ctx.setState({});
119
+ }
120
+ async function addPoints(ctx, ws, session) {
121
+ const text = input ? input.value : '';
122
+ if (!text.trim() || busy) return;
123
+ busy = true; banner = null; ctx.setState({});
124
+ const r = await post('/api/live-notes/add', { ws, session, text });
125
+ busy = false;
126
+ if (r && r.ok) {
127
+ if (input) input.value = '';
128
+ withheldNow = r.withheld || [];
129
+ reads.set('session:' + ws + ':' + session, { state: 'ok', data: { ok: true, session, ranked: r.ranked, shortlist: (reads.get('session:' + ws + ':' + session) || {}).data?.shortlist || [] }, at: 0 });
130
+ forget(ws, null);
131
+ } else banner = { tone: 'bad', text: (r && r.error) || 'nothing was added' };
132
+ ctx.setState({});
133
+ }
134
+ async function decide(ctx, ws, session, item, action) {
135
+ const d = drafts.get(item.segment) || {};
136
+ const initiative = d.initiative || item.initiative;
137
+ const relation = initiative === 'none' ? null : (d.relation !== undefined ? d.relation : item.relation);
138
+ const question = relation === 'answers' ? (d.question != null ? d.question : (item.answers_question && item.answers_question.initiative === initiative ? item.answers_question.n : null)) : null;
139
+ if (action !== 'reject' && initiative !== 'none' && !relation) { errors.set(item.segment, 'Pick how this point relates to ' + initiative + ' first.'); ctx.setState({}); return; }
140
+ if (action !== 'reject' && relation === 'answers' && question == null) { errors.set(item.segment, '"Answers" needs the open question it answers. Pick one.'); ctx.setState({}); return; }
141
+ const same = initiative === item.initiative && relation === item.relation && (relation !== 'answers' || (item.answers_question && item.answers_question.n === question));
142
+ const body = { ws, session, segment: item.segment, action: action === 'reject' ? 'reject' : same ? 'accept' : 'change' };
143
+ if (body.action === 'change') Object.assign(body, { initiative, relation: relation || null, question });
144
+ const r = await post('/api/live-notes/decide', body);
145
+ if (r && r.ok) { errors.delete(item.segment); drafts.delete(item.segment); forget(ws, session); }
146
+ else errors.set(item.segment, (r && r.error) || 'not recorded');
147
+ ctx.setState({});
148
+ }
149
+ /* ── listening: on-device speech recognition only ─────────────────────────────────────────────── */
150
+ const SRClass = () => (typeof window !== 'undefined' ? window.SpeechRecognition || window.webkitSpeechRecognition : null);
151
+ function stopListening() {
152
+ if (!listen) return;
153
+ const l = listen; listen = null;
154
+ clearTimeout(l.timer);
155
+ if (l.rec) { l.rec.onend = null; try { l.rec.stop(); } catch { /* already stopped */ } }
156
+ }
157
+ async function flush(ctx, l) {
158
+ clearTimeout(l.timer); l.timer = null;
159
+ const text = l.buffer.join(' ').trim(); l.buffer = [];
160
+ if (!text) return;
161
+ // one point per sentence; the server cleans each line before it becomes a point
162
+ const lines = text.split(/(?<=[.?!])\s+/).join('\n');
163
+ const r = await post('/api/live-notes/add', { ws: l.ws, session: l.session, text: lines, source: 'speech' });
164
+ if (r && r.ok) { withheldNow = (r.withheld || []).concat(withheldNow).slice(0, 5); reads.delete('session:' + l.ws + ':' + l.session); forget(l.ws, null); }
165
+ else if (r && r.error && !/type a talking point/.test(r.error)) banner = { tone: 'bad', text: 'A spoken point was not added: ' + r.error };
166
+ ctx.setState({});
167
+ }
168
+ async function startListening(ctx, ws, session, install = false) {
169
+ stopListening();
170
+ const C = SRClass();
171
+ const lang = (typeof navigator !== 'undefined' && navigator.language) || 'en-US';
172
+ listen = { ws, session, state: 'checking', rec: null, interim: '', buffer: [], timer: null, lang, msg: null };
173
+ ctx.setState({});
174
+ const l = listen;
175
+ if (!C || typeof C.available !== 'function') {
176
+ l.state = 'unsupported';
177
+ l.msg = 'This browser cannot recognise speech on this device, and live notes never send audio away. Chrome 139 or later can; until then, type the points.';
178
+ ctx.setState({}); return;
179
+ }
180
+ try {
181
+ if (install) { l.state = 'installing'; ctx.setState({}); await C.install({ langs: [lang], processLocally: true }); }
182
+ const a = await C.available({ langs: [lang], processLocally: true });
183
+ if (listen !== l) return;
184
+ if (a === 'downloadable' || a === 'downloading') { l.state = 'needs-install'; l.msg = 'Recognising ' + lang + ' on this device needs a one-time download by the browser.'; ctx.setState({}); return; }
185
+ if (a !== 'available') { l.state = 'unsupported'; l.msg = 'This browser cannot recognise ' + lang + ' on this device, and live notes never send audio away. Type the points instead.'; ctx.setState({}); return; }
186
+ } catch (e) { l.state = 'error'; l.msg = 'Speech recognition did not start: ' + ((e && e.message) || e); ctx.setState({}); return; }
187
+ const rec = new C();
188
+ rec.lang = lang; rec.continuous = true; rec.interimResults = true; rec.processLocally = true;
189
+ rec.onresult = (e) => {
190
+ if (listen !== l) return; // a result that arrives after Stop or Private is dropped, never added
191
+ let interim = ''; let ended = false;
192
+ for (let i = e.resultIndex; i < e.results.length; i++) {
193
+ const res = e.results[i]; const t = String((res[0] && res[0].transcript) || '').trim();
194
+ if (!t) continue;
195
+ if (res.isFinal) { l.buffer.push(t); if (/[.?!]$/.test(t)) ended = true; } else interim += (interim ? ' ' : '') + t;
196
+ }
197
+ l.interim = interim;
198
+ clearTimeout(l.timer);
199
+ if (ended) flush(ctx, l); else if (l.buffer.length) l.timer = setTimeout(() => flush(ctx, l), PAUSE_MS);
200
+ const live = typeof document !== 'undefined' && document.querySelector('[data-ln-interim]');
201
+ if (live) live.textContent = interim; else ctx.setState({});
202
+ };
203
+ rec.onerror = (e) => {
204
+ if (e && (e.error === 'no-speech' || e.error === 'aborted')) return;
205
+ l.state = 'error'; l.msg = 'Listening stopped: ' + ((e && (e.message || e.error)) || 'the browser gave no reason') + '.'; ctx.setState({});
206
+ };
207
+ // the browser ends a recognition after a silence; while the PM has not pressed Stop, it starts again
208
+ rec.onend = () => { if (listen === l && l.state === 'on') { try { rec.start(); } catch { /* it is starting */ } } };
209
+ l.rec = rec;
210
+ try { rec.start(); l.state = 'on'; } catch (e) { l.state = 'error'; l.msg = 'Listening did not start: ' + ((e && e.message) || e); }
211
+ ctx.setState({});
212
+ }
213
+ async function markPrivate(ctx, ws, session) {
214
+ const l = listen;
215
+ stopListening();
216
+ if (l) l.buffer = [];
217
+ const r = await post('/api/live-notes/private', { ws, session });
218
+ banner = r && r.ok ? { tone: 'ok', text: 'Marked private. Listening stopped, and nothing from this session will be sent to any service.' } : { tone: 'bad', text: (r && r.error) || 'not marked' };
219
+ forget(ws, session);
220
+ ctx.setState({});
221
+ }
222
+
223
+ async function closeSession(ctx, ws, session) {
224
+ if (typeof window !== 'undefined' && window.confirm && !window.confirm('End this session? The points are filed in today\'s requirements note and the raw transcript is deleted.')) return;
225
+ if (listen && listen.session === session) { const l = listen; stopListening(); await flush(ctx, l); }
226
+ const r = await post('/api/live-notes/close', { ws, session });
227
+ banner = r && r.ok ? { tone: 'ok', text: r.already ? 'This session was already closed.' : 'Filed in ' + r.note + '. Raw transcript: ' + r.transcript + '.' } : { tone: 'bad', text: (r && r.error) || 'the session did not close' };
228
+ forget(ws, session);
229
+ ctx.setState({});
230
+ }
231
+
232
+ /* ── drawing ──────────────────────────────────────────────────────────────────────────────────── */
233
+ const titleOf = (list, id) => { const i = (list || []).find((x) => x.id === id); return i ? i.title : id; };
234
+
235
+ function card(ctx, ws, session, item, shortlist, closed) {
236
+ const decided = item.decision;
237
+ const parts = [el('div', { 'data-ln-quote': '', style: 'font-size:14px;line-height:1.5' }, '"' + item.quote + '"')];
238
+ const guess = item.initiative && item.initiative !== 'none'
239
+ ? 'Guess: about ' + titleOf(shortlist, item.initiative) + ' (' + (item.initiative_band || 'possible') + ')' + (item.relation ? ', ' + item.relation : '')
240
+ : 'Guess: matches no open initiative';
241
+ const by = item.path === 'local' ? 'local word match' : item.path === 'jev' || item.path === 'jev-agent' ? 'Jev' : 'agent session';
242
+ parts.push(el('div', { 'data-ln-guess': '', style: NOTE }, guess + ' · from ' + by));
243
+ if (item.answers_question) parts.push(el('div', { 'data-ln-question': '', style: NOTE }, 'May answer open question ' + item.answers_question.n + ': ' + item.answers_question.text));
244
+ for (const fl of item.flags || []) if (FLAG_WORDS[fl]) parts.push(el('div', { 'data-ln-flag': fl, style: NOTE }, FLAG_WORDS[fl]));
245
+ if (decided) {
246
+ const d = decided;
247
+ const said = d.action === 'reject' ? 'You rejected this.' : d.action === 'change' && d.now
248
+ ? 'You changed it: ' + (d.now.initiative === 'none' ? 'matches no initiative' : d.now.relation + ' ' + titleOf(shortlist, d.now.initiative)) + '.'
249
+ : 'You confirmed it.';
250
+ parts.push(el('div', { 'data-ln-decision': d.action, style: 'font:500 12.5px var(--sans);color:var(--muted-foreground)' }, said));
251
+ return el('div', { 'data-ln-card': item.segment, 'data-ln-state': 'decided', style: CARD + ';opacity:.72' }, parts);
252
+ }
253
+ if (closed) return el('div', { 'data-ln-card': item.segment, 'data-ln-state': 'waiting', style: CARD }, parts);
254
+
255
+ const d = drafts.get(item.segment) || {};
256
+ const ini = d.initiative || item.initiative || 'none';
257
+ const rel = ini === 'none' ? null : (d.relation !== undefined ? d.relation : item.relation);
258
+ const setDraft = (patch) => { drafts.set(item.segment, { ...d, ...patch }); errors.delete(item.segment); ctx.setState({}); };
259
+ const options = [el('option', { value: 'none', selected: ini === 'none' }, 'none of these initiatives')]
260
+ .concat((shortlist || []).map((i) => el('option', { value: i.id, selected: ini === i.id }, i.title + ' (' + i.id + ')')));
261
+ parts.push(el('div', { style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap' },
262
+ el('span', { style: LBL }, 'initiative'),
263
+ el('select', { 'data-ln-ini': '', style: SELECT, onChange: (e) => setDraft({ initiative: e.target.value, question: null }) }, options)));
264
+ if (ini !== 'none') {
265
+ parts.push(el('div', { 'data-ln-rels': '', style: 'display:flex;gap:6px;align-items:center;flex-wrap:wrap' },
266
+ el('span', { style: LBL }, 'relation'),
267
+ RELATION_CHIPS.map((c) => el('button', { 'data-ln-rel': c.key, 'aria-pressed': rel === c.key ? 'true' : 'false', style: rel === c.key ? CHIP_ON : CHIP,
268
+ onClick: () => setDraft({ relation: c.key }) }, c.label))));
269
+ if (rel === 'answers') {
270
+ const qs = ((shortlist || []).find((i) => i.id === ini) || {}).questions || [];
271
+ const cur = d.question != null ? d.question : (item.answers_question && item.answers_question.initiative === ini ? item.answers_question.n : null);
272
+ parts.push(qs.length
273
+ ? el('select', { 'data-ln-q': '', style: SELECT, onChange: (e) => setDraft({ question: e.target.value ? Number(e.target.value) : null }) },
274
+ [el('option', { value: '', selected: cur == null }, 'which open question?')].concat(qs.map((q, n) => el('option', { value: String(n + 1), selected: cur === n + 1 }, (n + 1) + '. ' + q.slice(0, 120)))))
275
+ : el('div', { style: NOTE }, titleOf(shortlist, ini) + ' has no open questions, so this point cannot answer one.'));
276
+ }
277
+ }
278
+ if (errors.get(item.segment)) parts.push(el('div', { 'data-ln-error': '', role: 'alert', style: 'color:var(--destructive);font-size:12.5px' }, errors.get(item.segment)));
279
+ parts.push(el('div', { style: 'display:flex;gap:8px' },
280
+ el('button', { 'data-ln-action': 'confirm', style: BTNP, onClick: () => decide(ctx, ws, session, item, 'confirm') }, 'Confirm'),
281
+ el('button', { 'data-ln-action': 'reject', style: BTN, hover: 'background:var(--accent)', onClick: () => decide(ctx, ws, session, item, 'reject') }, 'Reject')));
282
+ return el('div', { 'data-ln-card': item.segment, 'data-ln-state': 'waiting', style: CARD }, parts);
283
+ }
284
+
285
+ export function renderBoard(inner, ctx) {
286
+ const { FIXTURE } = ctx;
287
+ const repo = (ctx.tab && ctx.tab.repo) || ctx.repo || null;
288
+ let ws = null;
289
+ if (!FIXTURE) {
290
+ if (!dataTried) { dataTried = true; import('./data.js').then((m) => { dataMod = m; ctx.setState({}); }).catch(() => {}); }
291
+ if (dataMod && dataMod.wsOf) ws = dataMod.wsOf(repo);
292
+ if (!ws && ctx.state && ctx.state.product === repo) ws = ctx.state.wsId || null;
293
+ }
294
+ const typing = typeof document !== 'undefined' && input && document.activeElement === input;
295
+ const caret = typing ? [input.selectionStart, input.selectionEnd] : null;
296
+
297
+ const sess = !FIXTURE && ws ? read(ctx, 'sessions:' + ws, '/api/live-notes?ws=' + encodeURIComponent(ws), 15000) : null;
298
+ const jev = (sess && sess.data && sess.data.jev) || null;
299
+ const jevOn = !!(jev && jev.open);
300
+ inner.appendChild(el('div', { 'data-ln-part': 'header' },
301
+ el('div', { style: 'font:600 22px var(--sans);line-height:1.45' }, 'Live notes'),
302
+ jevOn
303
+ ? el('div', { 'data-ln-jev-on': '', style: NOTE },
304
+ 'Listen or type what is being said. Each point is cleaned up and checked by Jev against your open initiatives; you confirm it, change it or reject it. '
305
+ + jev.note + ' Nothing changes an initiative until you confirm.')
306
+ : el('div', { 'data-ln-local': '', style: NOTE },
307
+ 'Type what is being said, one point per line. Each point gets a guess at once; you confirm it, change it or reject it. '
308
+ + 'Guesses here are a local word match. Nothing leaves this machine, and nothing changes an initiative.')));
309
+ if (FIXTURE || !ws) { inner.appendChild(el('div', { style: NOTE + ';margin-top:12px' }, FIXTURE ? 'The fixture reads no sessions.' : 'Select a repository to take notes against.')); return; }
310
+
311
+ const sessions = (sess.data && sess.data.sessions) || [];
312
+ let current = sessionOf.get(ws);
313
+ if (!current && sessions.length) { const open = sessions.filter((s) => !s.closed); current = (open[open.length - 1] || sessions[sessions.length - 1]).session; sessionOf.set(ws, current); }
314
+
315
+ const bar = [el('span', { style: LBL }, 'session')];
316
+ if (sessions.length) {
317
+ bar.push(el('select', { 'data-ln-session': '', style: SELECT, onChange: (e) => { stopListening(); sessionOf.set(ws, e.target.value); withheldNow = []; banner = null; ctx.setState({}); } },
318
+ sessions.slice().reverse().map((s) => el('option', { value: s.session, selected: s.session === current }, s.session + (s.closed ? ' · closed' : '') + ' · ' + s.points + ' points'))));
319
+ } else bar.push(el('span', { style: NOTE }, sess.state === 'loading' ? 'reading …' : 'no sessions yet'));
320
+ bar.push(el('button', { 'data-ln-new': '', style: BTN, hover: 'background:var(--accent)', disabled: busy, onClick: () => { stopListening(); startSession(ctx, ws, null); } }, 'New session'));
321
+ inner.appendChild(el('div', { 'data-ln-part': 'sessions', style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:12px' }, bar));
322
+ if (jev) {
323
+ const sw = !!jev.switch;
324
+ const why = jev.open ? 'Jev is checking new points.'
325
+ : sw ? 'Jev is not used yet. ' + (jev.sentence || '')
326
+ : 'Jev is off. Points get the local word match only.';
327
+ inner.appendChild(el('div', { 'data-ln-part': 'jev', style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:10px' },
328
+ el('span', { style: LBL }, 'jev'),
329
+ el('button', { 'data-ln-jev': sw ? 'on' : 'off', 'aria-pressed': sw ? 'true' : 'false', disabled: switching, style: sw ? CHIP_ON : CHIP,
330
+ onClick: () => setJev(ctx, ws, !sw) }, sw ? 'Check with Jev: on' : 'Check with Jev: off'),
331
+ el('span', { 'data-ln-jev-state': jev.open ? 'open' : 'shut', style: NOTE }, why.trim())));
332
+ }
333
+
334
+ if (anchorAsk && anchorAsk.ws === ws) {
335
+ let pick = anchorAsk.anchors[0] ? anchorAsk.anchors[0].anchor : '';
336
+ inner.appendChild(el('div', { 'data-ln-part': 'anchor', style: CARD + ';margin-top:10px' },
337
+ el('div', { style: NOTE }, anchorAsk.error),
338
+ el('div', { style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap' },
339
+ el('select', { 'data-ln-anchor': '', style: SELECT, onChange: (e) => { pick = e.target.value; } },
340
+ anchorAsk.anchors.map((a) => el('option', { value: a.anchor }, a.anchor + ' · ' + a.open + ' open'))),
341
+ el('button', { 'data-ln-start': '', style: BTNP, onClick: () => startSession(ctx, ws, pick) }, 'Start with this anchor'))));
342
+ }
343
+ if (banner) inner.appendChild(el('div', { 'data-ln-banner': banner.tone, role: banner.tone === 'bad' ? 'alert' : 'status', style: 'margin-top:10px;font-size:13px;color:' + (banner.tone === 'bad' ? 'var(--destructive)' : 'var(--foreground)') }, banner.text));
344
+ if (!current) return;
345
+
346
+ const one = read(ctx, 'session:' + ws + ':' + current, '/api/live-notes?ws=' + encodeURIComponent(ws) + '&session=' + encodeURIComponent(current));
347
+ if (!one.data || !one.data.ranked) { inner.appendChild(el('div', { style: NOTE + ';margin-top:12px' }, one.state === 'error' ? 'Could not read this session: ' + one.error : 'reading the session …')); return; }
348
+ const ranked = one.data.ranked; const shortlist = one.data.shortlist || [];
349
+ const closed = !!ranked.closed;
350
+ const c = countsOf(ranked);
351
+ inner.appendChild(el('div', { 'data-ln-counter': '', style: 'display:flex;gap:14px;flex-wrap:wrap;margin-top:12px;font:500 12.5px var(--sans)' },
352
+ [['confirmed', c.confirmed], ['changed', c.changed], ['rejected', c.rejected], ['waiting', c.waiting], ['withheld', c.withheld]]
353
+ .map(([k, n]) => el('span', { 'data-ln-count': k }, n + ' ' + k))));
354
+ if (ranked.paused) inner.appendChild(el('div', { 'data-ln-paused': '', role: 'status', style: NOTE }, ranked.paused.reason));
355
+ if (ranked.private && jevOn) inner.appendChild(el('div', { 'data-ln-private-jev': '', style: NOTE }, 'Nothing from this meeting goes to Jev, because it is marked private.'));
356
+
357
+ if (!closed) {
358
+ // listening: on-device recognition, a visible "notes are being taken", Stop and Private
359
+ const mine = listen && listen.ws === ws && listen.session === current ? listen : null;
360
+ const lp = [];
361
+ if (ranked.private) lp.push(el('span', { 'data-ln-private': '', style: NOTE }, 'This meeting is marked private: listening is off for it.'));
362
+ else if (mine && mine.state === 'on') {
363
+ lp.push(el('span', { 'data-ln-listening': 'on', role: 'status', style: 'font:600 12.5px var(--sans);color:var(--destructive)' }, '● Listening: notes are being taken'));
364
+ lp.push(el('button', { 'data-ln-stop': '', style: BTN, hover: 'background:var(--accent)', onClick: () => { const l = listen; stopListening(); if (l) flush(ctx, l); ctx.setState({}); } }, 'Stop'));
365
+ lp.push(el('button', { 'data-ln-mark-private': '', style: BTN, hover: 'background:var(--accent)', onClick: () => markPrivate(ctx, ws, current) }, 'This meeting is private'));
366
+ } else {
367
+ lp.push(el('button', { 'data-ln-listen': '', style: BTNP, disabled: !!(mine && (mine.state === 'checking' || mine.state === 'installing')), onClick: () => startListening(ctx, ws, current) },
368
+ mine && mine.state === 'checking' ? 'Starting …' : mine && mine.state === 'installing' ? 'Downloading …' : 'Start listening'));
369
+ if (mine && mine.state === 'needs-install') lp.push(el('button', { 'data-ln-install': '', style: BTN, onClick: () => startListening(ctx, ws, current, true) }, 'Download for ' + mine.lang));
370
+ lp.push(el('span', { style: NOTE }, 'Speech is recognised on this device; no audio leaves it.'));
371
+ }
372
+ inner.appendChild(el('div', { 'data-ln-part': 'listen', style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:12px' }, lp));
373
+ if (mine && mine.msg && mine.state !== 'on') inner.appendChild(el('div', { 'data-ln-listen-msg': mine.state, style: NOTE }, mine.msg));
374
+ if (mine && mine.state === 'on') inner.appendChild(el('div', { 'data-ln-interim': '', style: 'min-height:20px;font-size:13px;font-style:italic;color:var(--muted-foreground)' }, mine.interim || ''));
375
+ if (!input) {
376
+ input = el('textarea', { 'data-ln-input': '', rows: '3', placeholder: 'One talking point per line. Enter adds; Shift+Enter starts a new line.',
377
+ style: 'font:14px var(--sans);width:100%;box-sizing:border-box;background:var(--background);color:var(--foreground);border:1px solid var(--input);border-radius:8px;padding:8px 10px;resize:vertical' });
378
+ }
379
+ input.onkeydown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); addPoints(ctx, ws, current); } };
380
+ inner.appendChild(el('div', { 'data-ln-part': 'input', style: 'display:flex;flex-direction:column;gap:8px;margin-top:12px' }, input,
381
+ el('div', { style: 'display:flex;gap:8px;align-items:center' },
382
+ el('button', { 'data-ln-add': '', style: BTNP, disabled: busy, onClick: () => addPoints(ctx, ws, current) }, busy ? 'Adding …' : 'Add'),
383
+ el('span', { style: 'flex:1' }),
384
+ el('button', { 'data-ln-close': '', style: BTN, hover: 'background:var(--accent)', onClick: () => closeSession(ctx, ws, current) }, 'End and file the note'))));
385
+ if (caret) requestAnimationFrame(() => { try { input.focus(); input.setSelectionRange(caret[0], caret[1]); } catch { /* the textarea is gone */ } });
386
+ } else inner.appendChild(el('div', { 'data-ln-closed': '', style: NOTE + ';margin-top:12px' }, 'This session is closed. Its points are filed in the requirements note; start a new session to take more notes.'));
387
+
388
+ for (const w of withheldNow) inner.appendChild(el('div', { 'data-ln-withheld': w.id, style: CARD + ';margin-top:8px;color:var(--muted-foreground)' }, 'A point was withheld and not kept: it ' + w.reason + '.'));
389
+
390
+ const f = feedOf(ranked);
391
+ const list = el('div', { 'data-ln-part': 'feed', style: 'display:flex;flex-direction:column;gap:8px;margin-top:12px' });
392
+ if (!f.waiting.length && !f.decided.length) list.appendChild(el('div', { style: NOTE }, 'No points yet.'));
393
+ for (const x of f.waiting) list.appendChild(card(ctx, ws, current, x, shortlist, closed));
394
+ if (f.decided.length) list.appendChild(el('div', { style: LBL + ';margin-top:8px' }, 'decided'));
395
+ for (const x of f.decided) list.appendChild(card(ctx, ws, current, x, shortlist, closed));
396
+ inner.appendChild(list);
397
+ }
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /* run-tests.js — run the workspace suites side by side.
4
+ *
5
+ * node run-tests.js <group> [--jobs=N] [--shard=I/N] [--list] [--verbose]
6
+ * node run-tests.js --check
7
+ *
8
+ * Groups come from test-files.json: unit (test.js + modules), modules, panels, ui, browser
9
+ * (panels + ui), all. The npm scripts call this file, so `npm test` and CI run the same lists.
10
+ *
11
+ * Running in parallel is safe because every suite claims its own ephemeral port, scratch state
12
+ * directory and private tmux socket (test-run.js, or the same three lines written out in the
13
+ * suite). It was checked rather than assumed: on 2026-10-05 all 48 browser suites ran four at a
14
+ * time with no failure that the serial run did not also show.
15
+ *
16
+ * Nothing is skipped. --shard splits a group across CI jobs; the shards partition the list (every
17
+ * file in exactly one shard), and --check fails when a suite on disk is in no list, so a new suite
18
+ * that nobody wired in is a red build rather than a suite that runs nowhere.
19
+ *
20
+ * Output: a suite's own output is buffered and printed whole when it finishes, so lines from two
21
+ * suites never interleave. A passing suite prints its last lines; --verbose prints all of it.
22
+ */
23
+ const fs = require('fs');
24
+ const os = require('os');
25
+ const path = require('path');
26
+ const { spawn } = require('child_process');
27
+
28
+ const DIR = __dirname;
29
+ const FILES = JSON.parse(fs.readFileSync(path.join(DIR, 'test-files.json'), 'utf8'));
30
+ const GROUPS = {
31
+ unit: [...FILES.unit, ...FILES.modules],
32
+ modules: FILES.modules,
33
+ panels: FILES.panels,
34
+ ui: FILES.ui,
35
+ browser: [...FILES.panels, ...FILES.ui],
36
+ all: [...FILES.unit, ...FILES.modules, ...FILES.panels, ...FILES.ui],
37
+ };
38
+ // Suites that run elsewhere on purpose, so --check does not count them as unwired.
39
+ const ELSEWHERE = {
40
+ 'offline-tarball.test.js': 'npm run test:offline (needs a packed tarball)',
41
+ };
42
+ const PER_FILE_TIMEOUT_MS = 10 * 60 * 1000;
43
+
44
+ const args = process.argv.slice(2);
45
+ const flag = (name) => { const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`)); return a === undefined ? undefined : (a.includes('=') ? a.slice(a.indexOf('=') + 1) : true); };
46
+ const group = args.find((a) => !a.startsWith('--'));
47
+
48
+ const secs = (f) => Number((FILES.seconds || {})[f]) || 10;
49
+ // Slowest first, so a long suite never starts last and holds the run open on its own.
50
+ const bySlowest = (list) => list.map((f, k) => ({ f, k })).sort((a, b) => secs(b.f) - secs(a.f) || a.k - b.k).map((x) => x.f);
51
+
52
+ // Deal the suites, slowest first, each to the shard with the least time so far (ties to the lower
53
+ // shard). Deterministic, so every CI job computes the same split, and a partition by construction:
54
+ // each suite goes to exactly one shard. --check proves the partition for every shard count it tries.
55
+ function shardOf(list, spec) {
56
+ const m = /^(\d+)\/(\d+)$/.exec(String(spec));
57
+ if (!m) throw new Error(`--shard must look like 1/4, got "${spec}"`);
58
+ const i = Number(m[1]); const n = Number(m[2]);
59
+ if (n < 1 || i < 1 || i > n) throw new Error(`--shard ${spec}: the index must be between 1 and ${n}`);
60
+ const load = new Array(n).fill(0); const out = Array.from({ length: n }, () => []);
61
+ for (const f of bySlowest(list)) {
62
+ let best = 0; for (let k = 1; k < n; k++) if (load[k] < load[best]) best = k;
63
+ out[best].push(f); load[best] += secs(f);
64
+ }
65
+ return out[i - 1];
66
+ }
67
+
68
+ function check() {
69
+ const listed = new Set(GROUPS.all);
70
+ const problems = [];
71
+ const dupes = GROUPS.all.filter((f, k) => GROUPS.all.indexOf(f) !== k);
72
+ for (const f of new Set(dupes)) problems.push(`${f} is listed more than once in test-files.json`);
73
+ for (const f of listed) if (!fs.existsSync(path.join(DIR, f))) problems.push(`${f} is listed in test-files.json but does not exist`);
74
+ for (const f of FILES.exclusive || []) if (!listed.has(f)) problems.push(`${f} is marked exclusive but is in no test list`);
75
+ const onDisk = fs.readdirSync(DIR).filter((f) => f === 'test.js' || /(\.test\.(js|mjs)|-test\.js)$/.test(f));
76
+ for (const f of onDisk) if (!listed.has(f) && !ELSEWHERE[f]) problems.push(`${f} is a suite on disk that no test list runs. Add it to test-files.json.`);
77
+ for (const n of [2, 3, 4, 6, 8]) for (const g of ['unit', 'browser']) {
78
+ const parts = Array.from({ length: n }, (_, k) => shardOf(GROUPS[g], `${k + 1}/${n}`)).flat();
79
+ if (parts.length !== GROUPS[g].length || new Set(parts).size !== GROUPS[g].length) problems.push(`--shard does not partition "${g}" into ${n}`);
80
+ }
81
+ if (problems.length) { for (const p of problems) console.error(`✗ ${p}`); process.exitCode = 1; return; }
82
+ console.log(`✓ ${listed.size} suites listed, each once, each on disk; ${onDisk.length - Object.keys(ELSEWHERE).length} suites on disk, all listed; shards partition every group.`);
83
+ }
84
+
85
+ function runOne(file, verbose) {
86
+ return new Promise((resolve) => {
87
+ const t0 = Date.now();
88
+ const chunks = [];
89
+ const child = spawn(process.execPath, [file], { cwd: DIR, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
90
+ child.stdout.on('data', (d) => chunks.push(d));
91
+ child.stderr.on('data', (d) => chunks.push(d));
92
+ let timedOut = false;
93
+ const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, PER_FILE_TIMEOUT_MS);
94
+ child.on('close', (code, signal) => {
95
+ clearTimeout(timer);
96
+ const secs = ((Date.now() - t0) / 1000).toFixed(1);
97
+ const out = Buffer.concat(chunks).toString('utf8').replace(/\s+$/, '');
98
+ const ok = code === 0 && !timedOut;
99
+ const why = timedOut ? `timed out after ${PER_FILE_TIMEOUT_MS / 60000} min` : (signal ? `killed by ${signal}` : `exit ${code}`);
100
+ const lines = out.split('\n');
101
+ const body = ok && !verbose ? lines.slice(-3).join('\n') : out;
102
+ process.stdout.write(`\n── ${ok ? 'PASS' : 'FAIL'} ${file} (${secs}s${ok ? '' : ', ' + why}) ──\n${body}\n`);
103
+ resolve({ file, ok, secs: Number(secs), why });
104
+ });
105
+ });
106
+ }
107
+
108
+ async function main() {
109
+ if (flag('check')) return check();
110
+ if (!group || !GROUPS[group]) {
111
+ console.error(`usage: node run-tests.js <${Object.keys(GROUPS).join('|')}> [--jobs=N] [--shard=I/N] [--list] [--verbose]\n node run-tests.js --check`);
112
+ process.exitCode = 2; return;
113
+ }
114
+ let list = GROUPS[group];
115
+ const shard = flag('shard');
116
+ list = shard ? shardOf(list, shard) : bySlowest(list);
117
+ if (flag('list')) { console.log(list.join('\n')); return; }
118
+ const cpus = typeof os.availableParallelism === 'function' ? os.availableParallelism() : os.cpus().length;
119
+ // A browser suite runs a server, a tmux daemon and a Chromium of its own, and several of them assert
120
+ // on timing. At one per CPU they starved each other: 2 of 48 failed per run on a 4-CPU machine, a
121
+ // different 2 each time, all green alone. Half the CPUs for any group with browser suites in it
122
+ // kept every run green. --jobs or PMOS_TEST_JOBS still overrides.
123
+ const hasBrowser = list.some((f) => FILES.panels.includes(f) || FILES.ui.includes(f));
124
+ const auto = hasBrowser ? Math.max(1, Math.floor(cpus / 2)) : cpus;
125
+ const jobs = Math.max(1, Number(flag('jobs')) || Number(process.env.PMOS_TEST_JOBS) || auto);
126
+ const verbose = !!flag('verbose');
127
+ console.log(`run-tests: ${group}${shard ? ` shard ${shard}` : ''} — ${list.length} suites, ${jobs} at a time`);
128
+
129
+ const t0 = Date.now();
130
+ const results = [];
131
+ // An exclusive suite writes the checkout's own state, so it runs alone: first the shared suites side
132
+ // by side, then each exclusive one with nothing beside it.
133
+ const exclusive = new Set(FILES.exclusive || []);
134
+ const shared = list.filter((f) => !exclusive.has(f));
135
+ let next = 0;
136
+ const worker = async () => { while (next < shared.length) { const f = shared[next++]; results.push(await runOne(f, verbose)); } };
137
+ await Promise.all(Array.from({ length: Math.min(jobs, shared.length) }, worker));
138
+ for (const f of list.filter((x) => exclusive.has(x))) results.push(await runOne(f, verbose));
139
+
140
+ const failed = results.filter((r) => !r.ok);
141
+ const slowest = [...results].sort((a, b) => b.secs - a.secs).slice(0, 5).map((r) => `${r.file} ${r.secs}s`).join(', ');
142
+ console.log(`\nrun-tests: ${results.length - failed.length}/${results.length} suites passed in ${((Date.now() - t0) / 1000).toFixed(1)}s wall. Slowest: ${slowest}.`);
143
+ // exitCode, never process.exit(): stdout to a pipe is asynchronous, and exiting here cut the CI log
144
+ // off mid-suite, before the summary that names what failed.
145
+ if (failed.length) { console.log(`FAILED: ${failed.map((r) => `${r.file} (${r.why})`).join(', ')}`); process.exitCode = 1; }
146
+ }
147
+
148
+ main().catch((e) => { console.error(e); process.exitCode = 1; });