create-pmos 0.48.0 → 0.49.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 (35) 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 +2 -2
  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/server.js +66 -7
@@ -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
+ }
@@ -76,6 +76,7 @@ const jevClaims = require('./lib/jev-claims.js'); // D80 — the bridge betwee
76
76
  const jevCategorize = require('./lib/jev-categorize.js'); // D82 — the bridge between the document categorizer and the one TypeSafe module
77
77
  const validateLib = require('./lib/validate.js');
78
78
  const liveNotes = require('./lib/live-notes.js'); // jev-live-notes — reads and appends a repository's live-notes ledger; no network
79
+ const jevLive = require('./lib/jev-live.js'); // jev-live-notes Run 7 (D85) — the live view's one bridge to Jev, behind three gates
79
80
  const claimsLib = require('./lib/claims.js');
80
81
  const docCategory = require('./lib/doc-category.js'); // jev-doc-categorizer Run 1 — the PM-facing kind of a document, derived, local, no model
81
82
  const docCatCorrections = require('./lib/doc-category-corrections.js'); // jev-doc-categorizer Run 3 — the PM's own category, per user, outside the repository
@@ -291,6 +292,10 @@ function sessionEnv() {
291
292
  // print its environment, so the key is never handed to one, whether it came from settings or the shell.
292
293
  delete env[tsSettings.ENV_NAME];
293
294
  if (SET_GIT_OPTIONAL_LOCKS) delete env.GIT_OPTIONAL_LOCKS;
295
+ // prompt-model-routing Run 3 — where the cockpit mod asks for a prompt's route: this server, on loopback.
296
+ // An address only; the Jev key stays here (D79).
297
+ const routeUrl = jevRoute.routeUrl(PORT);
298
+ if (routeUrl) env.PMOS_ROUTE_URL = routeUrl; else delete env.PMOS_ROUTE_URL;
294
299
  return env;
295
300
  }
296
301
 
@@ -470,6 +475,12 @@ function homeWorkspace() {
470
475
  return HOME_ENABLED ? workspaces.get(wsId(SELF_ROOT)) : [...workspaces.values()][0];
471
476
  }
472
477
  function getWs(id) { return (id && workspaces.get(id)) || homeWorkspace(); }
478
+ /** The registered workspace whose root is this path exactly, or null. Never the home workspace by default. */
479
+ function wsByRoot(root) {
480
+ const want = path.resolve(String(root));
481
+ for (const w of workspaces.values()) if (w && path.resolve(w.root) === want) return w;
482
+ return null;
483
+ }
473
484
  // R2/R3 — what the sidebar tree needs and cannot derive for itself: which REPOSITORY a registered
474
485
  // working tree belongs to, and which branch it is on. `git worktree list` names the main worktree
475
486
  // first from any of a repo's trees (git-info.worktreesAt), so index 0 is the group key: it is what
@@ -1608,8 +1619,8 @@ function annotateJev(root, node, cache, seen = new Set()) {
1608
1619
  if (node.category === docCategory.UNDETERMINED && typeof node.path === 'string' && /\.md$/i.test(node.path)) {
1609
1620
  const c = categoryInput(path.join(root, node.path));
1610
1621
  const l = c ? jevCategorize.lookup(cache, { fm: c.fm, text: c.text }) : null;
1611
- if (l && l.decided) { node.category = l.category; node.category_basis = 'jev'; node.category_why = l.sentence; if (l.ticket_kind) node.ticket_kind = l.ticket_kind; }
1612
- else if (l) node.category_why = l.sentence;
1622
+ if (l && l.decided) { node.category = l.category; node.category_basis = 'jev'; node.category_why = l.sentence; if (l.ticket_kind) node.category_kind = l.ticket_kind; }
1623
+ else if (l) { node.category_why = l.sentence; node.category_jev = 'unsure'; }
1613
1624
  }
1614
1625
  for (const k of Object.keys(node)) if (node[k] && typeof node[k] === 'object') annotateJev(root, node[k], cache, seen);
1615
1626
  return node;
@@ -4703,6 +4714,18 @@ function jevGateInfo(w) {
4703
4714
  const { cacheDir } = claimsPaths(w);
4704
4715
  return { switch: f.jev, status_read: f.status_read, open: g.open, missing: g.missing, sentence: g.open ? null : jevClaims.gateSentence(g.missing), ...jevClaims.status(cacheDir) };
4705
4716
  }
4717
+ // D85 — the live-notes add route's sender: lib/typesafe.js behind the key, consent and the switch, or none. The key goes to the engine
4718
+ // only when the sender does, and never into a response.
4719
+ function liveSender(w) {
4720
+ const key = tsSettings.getKey(STATE);
4721
+ const flags = { consent: tsSettings.getRepo(STATE, w.root).consent, live: liveNotes.getSwitch(STATE, w.root).live };
4722
+ const snd = jevLive.senderFor(key, flags, jevModelOpts(w.root));
4723
+ return { key: snd.ask ? key : null, flags, ask: snd.ask, askOpts: snd.askOpts };
4724
+ }
4725
+ // D85 — what the live-notes view needs to explain its Jev switch. Booleans and sentences only; the key never leaves the store.
4726
+ function liveJevInfo(w) {
4727
+ return jevLive.infoFor(tsSettings.status(STATE).present, { consent: tsSettings.getRepo(STATE, w.root).consent, live: liveNotes.getSwitch(STATE, w.root).live });
4728
+ }
4706
4729
  // D82 — what the page needs to explain the categorize switch: which gates hold, and what the last pass did.
4707
4730
  const doccatRunning = new Set();
4708
4731
  const doccatAutoAt = new Map(); // root → time of the last automatic pass; at most one a minute
@@ -5555,11 +5578,13 @@ async function handleRequest(req, res) {
5555
5578
  }
5556
5579
  if (p === '/api/model/route' && req.method === 'POST') {
5557
5580
  let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
5558
- const w = getWs(body.ws); if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
5581
+ // A terminal's cockpit mod names its repository by root; the page names a workspace by id.
5582
+ const w = typeof body.root === 'string' ? wsByRoot(body.root) : getWs(body.ws);
5583
+ if (!w) return json(res, { ok: false, error: 'no workspace for that repository' }, 400);
5559
5584
  if (typeof body.prompt !== 'string' || !body.prompt.trim() || body.prompt.length > 60000) return json(res, { ok: false, error: 'prompt must be text of up to 60000 characters' }, 400);
5560
5585
  const f = tsSettings.getRepo(STATE, w.root);
5561
5586
  const out = await jevRoute.routePrompt({ root: w.root, prompt: body.prompt, ctx: { skill: typeof body.skill === 'string' ? body.skill : undefined, source: body.source, running: true },
5562
- key: tsSettings.getKey(STATE), flags: { consent: f.consent, route: f.route, exclude: f.exclude } });
5587
+ key: tsSettings.getKey(STATE), flags: { consent: f.consent, route: f.route, exclude: f.exclude }, askOpts: body.interactive === true ? jevRoute.INTERACTIVE : {} });
5563
5588
  if (!out.ok) return json(res, { ok: false, error: out.error }, 400);
5564
5589
  if (out.shadow) return json(res, { ok: true, shadow: true, id: out.id }); // shadow: logged, nothing shown, nothing applied
5565
5590
  return json(res, { ok: true, ws: w.id, id: out.id, tier: out.tier, model: out.model, effort: out.effort, stage: out.stage, confidence: out.confidence, why: out.why,
@@ -6566,12 +6591,14 @@ async function handleRequest(req, res) {
6566
6591
  return json(res, { ok: true, ws: w.id, name: w.name, ...tsSettings.getRepo(STATE, w.root), key: tsSettings.status(STATE) });
6567
6592
  }
6568
6593
  /* ── live notes (jev-live-notes, Run 2): the ranked proposals of a conversation, and the PM's accept, change or reject.
6569
- * Reads the repository's ledger; the one write is a decision row. No request leaves this process, and no page is built yet. ── */
6594
+ * Reads the repository's ledger; the writes are ledger rows and, on close, the day's note. No request leaves this process.
6595
+ * The page is the cockpit's live-notes view (public/pm/view-livenotes.js, Run 6). ── */
6570
6596
  if (p === '/api/live-notes' && req.method === 'GET') {
6571
6597
  const wsq = url.searchParams.get('ws');
6572
6598
  const w = wsq ? workspaces.get(wsq) : homeWorkspace(); // a named workspace the server does not know is refused, never replaced by the home one
6573
6599
  if (!w) return json(res, { ok: false, error: 'no such workspace' }, 400);
6574
- const r = liveNotes.apiList({ root: w.root, session: url.searchParams.get('session') || null });
6600
+ const r = liveNotes.apiList({ root: w.root, session: url.searchParams.get('session') || null, exclude: tsSettings.getRepo(STATE, w.root).exclude });
6601
+ if (r.ok) r.jev = liveJevInfo(w);
6575
6602
  return json(res, r, r.ok ? 200 : (r.status || 400));
6576
6603
  }
6577
6604
  if (p === '/api/live-notes/decide' && req.method === 'POST') {
@@ -6580,7 +6607,39 @@ async function handleRequest(req, res) {
6580
6607
  catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6581
6608
  const w = body.ws ? workspaces.get(String(body.ws)) : homeWorkspace();
6582
6609
  if (!w) return json(res, { ok: false, error: 'no such workspace' }, 400);
6583
- const r = liveNotes.apiDecide({ root: w.root, runtimeDir: STATE, session: body.session, segment: body.segment, action: body.action, initiative: body.initiative || null, relation: body.relation || null, exclude: tsSettings.getRepo(STATE, w.root).exclude });
6610
+ const r = liveNotes.apiDecide({ root: w.root, runtimeDir: STATE, session: body.session, segment: body.segment, action: body.action, initiative: body.initiative || null, relation: body.relation || null, question: body.question == null ? null : body.question, exclude: tsSettings.getRepo(STATE, w.root).exclude });
6611
+ return json(res, r, r.ok ? 200 : (r.status || 400));
6612
+ }
6613
+ /* Run 7 (D85): the per-repository switch "live notes may use Jev". Default off; it is one of three gates, with the key and consent. */
6614
+ if (p === '/api/live-notes/switch' && req.method === 'POST') {
6615
+ let body = {};
6616
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
6617
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6618
+ const w = body.ws ? workspaces.get(String(body.ws)) : homeWorkspace();
6619
+ if (!w) return json(res, { ok: false, error: 'no such workspace' }, 400);
6620
+ if (typeof body.live !== 'boolean') return json(res, { ok: false, error: 'say live: true or live: false' }, 400);
6621
+ liveNotes.setSwitch(STATE, w.root, { live: body.live });
6622
+ return json(res, { ok: true, ws: w.id, jev: liveJevInfo(w) });
6623
+ }
6624
+ /* Run 6: the live-notes view's writes — start or resume a session, add points, close. A proposal is a local word match made in
6625
+ * this process, unless all three D85 gates hold: then `add` hands each cleaned point to Jev through lib/jev-live.js. */
6626
+ if ((p === '/api/live-notes/session' || p === '/api/live-notes/add' || p === '/api/live-notes/close' || p === '/api/live-notes/private') && req.method === 'POST') {
6627
+ let body = {};
6628
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
6629
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6630
+ const w = body.ws ? workspaces.get(String(body.ws)) : homeWorkspace();
6631
+ if (!w) return json(res, { ok: false, error: 'no such workspace' }, 400);
6632
+ const exclude = tsSettings.getRepo(STATE, w.root).exclude;
6633
+ let r;
6634
+ if (p === '/api/live-notes/session') r = liveNotes.apiOpen({ root: w.root, runtimeDir: STATE, session: body.session || null, anchor: body.anchor || null, exclude });
6635
+ else if (p === '/api/live-notes/add') {
6636
+ const snd = liveSender(w);
6637
+ r = await liveNotes.apiAdd({ root: w.root, runtimeDir: STATE, session: body.session, text: body.text, source: body.source === 'speech' ? 'speech' : 'typed', exclude,
6638
+ key: snd.key, flags: snd.flags, ask: snd.ask, askOpts: snd.askOpts });
6639
+ }
6640
+ else if (p === '/api/live-notes/private') r = liveNotes.apiMarkPrivate({ root: w.root, session: body.session });
6641
+ else r = liveNotes.apiClose({ root: w.root, runtimeDir: STATE, session: body.session, retentionDays: liveNotes.getSwitch(STATE, w.root).retention_days, exclude });
6642
+ if (r.ok && p !== '/api/live-notes/close') { r.jev = liveJevInfo(w); if (r.note) r.note = r.jev.note; }
6584
6643
  return json(res, r, r.ok ? 200 : (r.status || 400));
6585
6644
  }
6586
6645
  if (p === '/api/validate/plan' && req.method === 'POST') {