create-pmos 0.116.0 → 0.118.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 (33) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/guide/notes.md +43 -3
  7. package/workspace/guide/today.md +22 -13
  8. package/workspace/lib/brief.js +2 -2
  9. package/workspace/lib/day-marks.js +29 -9
  10. package/workspace/lib/goals.js +68 -10
  11. package/workspace/lib/notes.js +139 -17
  12. package/workspace/lib/routes-notes.js +68 -11
  13. package/workspace/package-lock.json +2 -2
  14. package/workspace/package.json +1 -1
  15. package/workspace/public/pm/components.css +289 -91
  16. package/workspace/public/pm/dest-notes.js +136 -29
  17. package/workspace/public/pm/note-dock.js +204 -164
  18. package/workspace/public/pm/notes-context.js +60 -0
  19. package/workspace/public/pm/notes-links.js +32 -6
  20. package/workspace/public/pm/primitives.js +1 -1
  21. package/workspace/public/pm/today-agenda.js +27 -53
  22. package/workspace/public/pm/today-copy.js +34 -15
  23. package/workspace/public/pm/today-day.js +98 -76
  24. package/workspace/public/pm/today-goals.js +159 -15
  25. package/workspace/public/pm/today-jobs.js +84 -0
  26. package/workspace/public/pm/today-mentions.js +50 -0
  27. package/workspace/public/pm/topbar.js +71 -32
  28. package/workspace/public/pm/view-notes.js +142 -73
  29. package/workspace/server.js +10 -8
  30. package/workspace/test-files.json +4 -0
  31. package/workspace/test-lists/today-goals-jobs-ui.json +5 -0
  32. package/workspace/test-lists/today-goals-jobs.json +5 -0
  33. package/workspace/test-lists/today-mentions-cards.json +5 -0
@@ -28,10 +28,11 @@
28
28
  * calendar) and which initiative it belongs to, and how that was set: from the calendar event, suggested from what was said,
29
29
  * or chosen by the PM. Change opens a sheet for each.
30
30
  *
31
- * MINIMIZED AT THE BOTTOM RIGHT (Run 6). Take notes on a calendar event starts listening at once (listenNow: no chooser) and
32
- * keeps the note as a pill at the bottom right (pm/note-dock.js). The pill opens the note as a pop-up, which renderInline
33
- * draws in one column. While the pop-up shows a note, the Notes tab says so and does not draw it a second time.
34
- * Open in Notes shows it on the full screen.
31
+ * ONE FULL NOTE (Run 13). Take notes, Record and Resume start listening at once (listenNow: no chooser), and the Record
32
+ * control in the top bar (pm/topbar.js, pm/note-dock.js) holds the meeting. The note itself is the right column of the Notes
33
+ * place (pm/dest-notes.js); while the meeting is recording or paused that column draws renderLive: the status, Your notes
34
+ * and the live transcript. There is no pop-up note. PAUSE keeps the meeting open and resumable for an hour (POST
35
+ * /api/notes/pause); STOP ends it for good and writes the final notes (POST /api/notes/stop).
35
36
  *
36
37
  * Reads and writes (lib/routes-notes.js). Notes live in the per-user runtime directory, never in a repository:
37
38
  * GET /api/notes · /api/notes/one?id=… · /api/notes/engine?ws=… · /api/notes/recording?id=… · /api/people?ws=…
@@ -40,12 +41,13 @@
40
41
  * · summarize · summarize-agent · delete · line · recording-delete · link
41
42
  */
42
43
  import { el, kept } from './dom.js';
43
- import { askConfirm, askText } from './dialogs.js';
44
+ import { askConfirm, askText, askChoice } from './dialogs.js';
44
45
  import { pmGet, pmPost } from './http.js';
45
46
  import { mdRender } from './markdown.js';
46
47
  import { icon } from './icons.js';
47
48
  import { button, iconButton, field, row, sheet, empty, menu, toast, ensureComponentsCss } from './primitives.js';
48
49
  import { toInt16, makeResampler, openCapture } from './audio-capture.js';
50
+ import { agendaHeard, peopleOf as contextPeople } from './notes-context.js';
49
51
 
50
52
  const SAVE_MS = 700; // a pause this long in typing saves the note
51
53
  const POLL_MS = 1500; // while listening, and until the last line is labelled, the note is read this often
@@ -68,7 +70,7 @@ let speech = null; // the browser recogniser: { mode: 'dictate'|'reco
68
70
  let audio = null; // local Whisper capture: { id, state, sources, ctx, streams, started, clock, msg }
69
71
  let engine = null; // { ws, whisper, jev, at }
70
72
  let showList = false;
71
- let inlineDraw = false; // the pop-up is drawing: the fields and the player it shows are left in place (pm/note-dock.js)
73
+ let inlineDraw = false; // the live block is drawing: the fields it shows are left in place (renderLive)
72
74
  /** A node this view keeps across redraws (an editor field, the player). The pop-up patches itself (dom.js patchChildren), so
73
75
  * there the node is a placeholder and stays where it is: a field that is typed in is never moved, and keeps its focus. */
74
76
  const own = (node) => (inlineDraw ? kept(node) : node);
@@ -77,9 +79,9 @@ let busy = '';
77
79
  let player = null; // the replay: { id, bytes, el, playing }, kept across redraws so playback goes on
78
80
  let people = null; // { ws, names } — the repository's people, offered in the line editor
79
81
  let pendingOpen = null; // a note another page asked to open (openNote), selected at the next draw
80
- let transcriptOpen = { inline: false, full: true }; // the transcript is folded in the pop-up and open on the full screen
81
- let inlineSwitch = null; // the note the pop-up is switching to (renderInline), so a redraw does not start it twice
82
- let popped = null; // the note the pop-up shows (pm/note-dock.js), which the Notes tab then does not draw
82
+ let transcriptOpen = { inline: false, full: true }; // the transcript is open in the live block and on the full screen
83
+ let starting = false; // a listen is starting (listenNow): the live block must not switch notes under it
84
+ let inlineSwitch = null; // the note the live block is switching to (renderLive), so a redraw does not start it twice
83
85
  let liveTranscript = false; // after the final pass, the PM can look at the live transcript instead (Run 9)
84
86
  let wisprTimer = null;
85
87
  let highlight = null; // { id, state, listenDurationMs, listenedMs, text, label, conflicts } while highlighting
@@ -180,10 +182,38 @@ export function lineDiff(a, b) {
180
182
  while (j < y.length) out.push({ op: 'add', text: y[j++] });
181
183
  return out;
182
184
  }
185
+ /** The meeting's context as Markdown lines, first in the note (R59): when, where, people, the agenda with what was heard, what it is
186
+ * for and how to be ready, the links and related items. Empty when the note keeps no context. Pure. */
187
+ export function contextMarkdown(n) {
188
+ const m = n.meeting;
189
+ const c = m && m.context;
190
+ if (!m || m.kind !== 'event' || !c) return [];
191
+ const out = [];
192
+ const when = m.start ? dayOf(m.start) + ', ' + nowClock(m.start) + (m.end ? '–' + nowClock(m.end) : '') : '';
193
+ if (when) out.push('**When:** ' + when);
194
+ if (n.initiative) out.push('**Initiative:** ' + (n.initiative.title || n.initiative.id));
195
+ const who = contextPeople(c);
196
+ if (who.length) out.push('**People:** ' + who.join(', '));
197
+ else if (c.attendees !== null && c.attendees !== undefined) out.push('**People:** ' + c.attendees + (c.attendees === 1 ? ' attendee' : ' attendees'));
198
+ if (c.role) out.push('**Your role:** ' + c.role);
199
+ if (c.external) out.push('**Someone from outside the company attends.**');
200
+ if (c.series) out.push('**Series:** ' + c.series);
201
+ if (c.purpose) out.push('', '**What it is for:** ' + c.purpose);
202
+ if (c.prep) out.push('**How to be ready:** ' + c.prep);
203
+ if ((c.agenda || []).length) {
204
+ const heard = agendaHeard(c, (n.transcript || []).filter((x) => !x.dropped));
205
+ out.push('', '## Agenda', '', ...c.agenda.map((a, i) => '- [' + (heard[i] !== null ? 'x' : ' ') + '] ' + a + (heard[i] !== null ? ' (heard at ' + fmtTime(heard[i]) + ')' : '')));
206
+ }
207
+ if ((c.links || []).length) out.push('', ...c.links.map((l) => '- [' + l.label + '](' + l.url + ')'));
208
+ if ((c.refs || []).length) out.push('', '**Related:** ' + c.refs.map((r) => r.label).join('; '));
209
+ out.push('');
210
+ return out;
211
+ }
183
212
  /** The whole note as Markdown, for "Copy as Markdown". Pure, for the suite. */
184
213
  export function noteMarkdown(n) {
185
214
  const out = ['# ' + (n.title || 'Untitled note'), ''];
186
215
  if (n.created_at) out.push('_' + dayOf(n.created_at) + ', ' + nowClock(n.created_at) + '_', '');
216
+ out.push(...contextMarkdown(n));
187
217
  if (n.agent && n.agent.markdown) out.push('## Summary', '', n.agent.markdown.trim(), '');
188
218
  else if (n.summary && n.summary.sections && n.summary.sections.overview && n.summary.sections.overview.length) out.push('## Summary', '', ...n.summary.sections.overview.map((x) => '- ' + x.text), '');
189
219
  if (String(n.body || '').trim()) out.push('## Notes', '', n.body.trim(), '');
@@ -320,7 +350,7 @@ async function deleteNote(ctx, n) {
320
350
  if (r && r.ok) {
321
351
  cache.delete(n.id);
322
352
  if (list) list.notes = (list.notes || []).filter((x) => x.id !== n.id);
323
- if (here) { currentId = list && list.notes && list.notes.length ? list.notes[0].id : null; editor = null; if (popped === n.id) popped = null; }
353
+ if (here) { currentId = list && list.notes && list.notes.length ? list.notes[0].id : null; editor = null; }
324
354
  import('./note-dock.js').then((d) => d.forget(n.id)).catch(() => {});
325
355
  import('./notes-links.js').then((l) => l.refreshNotes && l.refreshNotes()).catch(() => {});
326
356
  toast({ text: '"' + (n.title || 'Untitled note') + '" was deleted.' });
@@ -401,8 +431,18 @@ async function finalSetup(ctx) {
401
431
  : { tone: 'bad', text: 'No terminal opened' + (r && r.reason ? ': ' + r.reason : '') + '. Run this in a terminal: ' + ((r && r.command) || '') };
402
432
  ctx.setState({});
403
433
  }
434
+ /** Name a speaker. When the note keeps its meeting's people (R59), the choice starts with them; "Someone else" asks for a name. */
435
+ async function askSpeaker(n, current, placeholder) {
436
+ const names = contextPeople(n.meeting && n.meeting.context);
437
+ const ask = () => askText({ title: 'Name this speaker', label: 'Name', value: current, placeholder, confirmLabel: 'Save' });
438
+ if (!names.length) return ask();
439
+ const other = '__other';
440
+ const pick = await askChoice({ title: 'Who is speaking?', label: 'Speaker', options: [...names.map((x) => ({ value: x, label: x })), { value: other, label: 'Someone else …' }], value: names.includes(current) ? current : names[0], confirmLabel: 'Save' });
441
+ if (pick == null) return null;
442
+ return pick === other ? ask() : pick;
443
+ }
404
444
  async function renameFinalSpeaker(ctx, n, key, now) {
405
- const name = await askText({ title: 'Name this speaker', label: 'Name', value: now, placeholder: now, confirmLabel: 'Save' });
445
+ const name = await askSpeaker(n, now, now);
406
446
  if (name == null) return;
407
447
  const r = await pmPost('/api/notes/speaker', { id: n.id, key, name });
408
448
  // the note may have been read again since this button was drawn, so read it once more rather than change a stale copy
@@ -428,7 +468,7 @@ async function unpick(ctx, n, x) {
428
468
  ctx.setState({});
429
469
  }
430
470
  async function renameSpeaker(ctx, n, source) {
431
- const name = await askText({ title: 'Name this speaker', label: 'Name', value: n.speakers && n.speakers[source] ? n.speakers[source] : '', placeholder: SPEAKER[source], confirmLabel: 'Save' });
471
+ const name = await askSpeaker(n, n.speakers && n.speakers[source] ? n.speakers[source] : '', SPEAKER[source]);
432
472
  if (name == null) return;
433
473
  const r = await pmPost('/api/notes/speaker', { id: n.id, source, name });
434
474
  if (r && r.ok) n.speakers = r.speakers; else banner = { tone: 'bad', text: (r && r.error) || 'not renamed' };
@@ -629,6 +669,7 @@ async function startAudio(ctx, n, want) {
629
669
  a.clock = setInterval(() => tickClocks(a.started), 1000);
630
670
  ctx.setState({});
631
671
  poll(ctx, n.id);
672
+ import('./notes-links.js').then((m) => m.refreshNotes()).catch(() => {});
632
673
  }
633
674
  function sendChunk(ctx, a, kind, final) {
634
675
  const b = a.buf[kind] || []; a.buf[kind] = [];
@@ -655,7 +696,7 @@ function sendChunk(ctx, a, kind, final) {
655
696
  }).catch(() => {});
656
697
  return a.queue[kind];
657
698
  }
658
- async function stopAudio(ctx, { discard = false } = {}) {
699
+ async function stopAudio(ctx, { discard = false, resumable = true } = {}) {
659
700
  const a = audio;
660
701
  if (!a) return;
661
702
  audio = null;
@@ -669,8 +710,10 @@ async function stopAudio(ctx, { discard = false } = {}) {
669
710
  const tail24 = a.rs24[k] ? a.rs24[k].flush() : []; const b24 = a.buf24[k]; for (let i = 0; i < tail24.length; i++) b24.push(tail24[i]);
670
711
  }
671
712
  await Promise.all(a.sources.map((k) => sendChunk(ctx, a, k, true)));
672
- await pmPost('/api/notes/pause', { id: a.id });
713
+ await pmPost(resumable ? '/api/notes/pause' : '/api/notes/stop', { id: a.id });
673
714
  ctx.setState({});
715
+ // a pause keeps the meeting open: the final notes wait for Stop, or for the hour to end
716
+ if (resumable) { poll(ctx, a.id); return; }
674
717
  const n = cache.get(a.id);
675
718
  if (!n || n.private) return;
676
719
  const t0 = Date.now();
@@ -763,7 +806,7 @@ async function startSpeech(ctx, n, mode, install = false) {
763
806
  rec.onend = () => { if (speech === s && s.state === 'on') { try { rec.start(); } catch { /* it is starting */ } } };
764
807
  s.rec = rec;
765
808
  try { rec.start(); s.state = 'on'; } catch (e) { s.state = 'error'; s.msg = 'Listening did not start: ' + ((e && e.message) || e); }
766
- if (mode === 'record') { s.clock = setInterval(() => tickClocks(s.started), 1000); poll(ctx, n.id); }
809
+ if (mode === 'record') { s.clock = setInterval(() => tickClocks(s.started), 1000); poll(ctx, n.id); import('./notes-links.js').then((m) => m.refreshNotes()).catch(() => {}); }
767
810
  ctx.setState({});
768
811
  }
769
812
 
@@ -841,7 +884,7 @@ async function removeHighlight(ctx, h) {
841
884
  export const highlightBusy = () => (highlight ? { state: highlight.state, open: hlOpen } : null);
842
885
  export function openHighlight(ctx) { if (highlight) { hlOpen = true; ctx.setState({}); } }
843
886
 
844
- /** The highlight's pop-up (pm/note-dock.js draws it on every place), or null when there is none or it is closed. */
887
+ /** The highlight's pop-up (pm/topbar.js draws it under the Record control), or null when there is none or it is closed. */
845
888
  export function highlightPanel(ctx) {
846
889
  const h = highlight;
847
890
  if (!h || !hlOpen) return null;
@@ -877,36 +920,57 @@ export function highlightPanel(ctx) {
877
920
  if (h.state === 'failed') body.push(el('p', { class: 'nt-hint nt-hint--bad', 'data-nt-hl-failed': '' }, 'No highlight: ' + h.error + '.'));
878
921
  const acts = [];
879
922
  if (h.state === 'listening') acts.push(button({ label: 'Stop listening now', variant: 'outline', size: 'sm', onClick: () => { h.pressed = Date.now() - hlAfterMs(); hlTick(ctx); }, attrs: { 'data-nt-hl-stop': '' } }));
880
- if (h.state === 'done') acts.push(button({ label: 'Done', variant: 'primary', size: 'sm', onClick: () => closeHighlight(ctx), attrs: { 'data-nt-hl-done': '' } }),
881
- button({ label: 'Remove', variant: 'ghost', size: 'sm', onClick: () => removeHighlight(ctx, h), attrs: { 'data-nt-hl-remove': '' } }));
882
- else acts.push(button({ label: 'Close', variant: 'ghost', size: 'sm', onClick: () => closeHighlight(ctx), attrs: { 'data-nt-hl-close': '' } }));
923
+ // icons only (Run 13): keep, discard, close; each carries its name as a tooltip and an aria-label
924
+ if (h.state === 'done') acts.push(iconButton({ icon: 'check', ariaLabel: 'Keep the highlight in the note', onClick: () => closeHighlight(ctx), attrs: { 'data-nt-hl-done': '' } }),
925
+ iconButton({ icon: 'ban', ariaLabel: 'Discard the highlight', onClick: () => removeHighlight(ctx, h), attrs: { 'data-nt-hl-remove': '' } }));
926
+ else acts.push(iconButton({ icon: 'x', ariaLabel: 'Close (the highlight keeps working)', onClick: () => closeHighlight(ctx), attrs: { 'data-nt-hl-close': '' } }));
883
927
  body.push(el('div', { class: 'nt-hl-acts' }, acts));
884
928
  return el('section', { class: 'nt-hl', role: 'dialog', 'aria-label': 'Highlight', 'data-nt-part': 'highlight-popup', 'data-nt-hl': h.state }, body);
885
929
  }
886
930
 
887
- async function stopRecording(ctx) {
931
+ async function stopRecording(ctx, { resumable = true } = {}) {
888
932
  const s = speech;
889
933
  if (s && s.mode === 'record') {
890
934
  clearInterval(s.clock); stopSpeech();
891
935
  await s.queue;
892
- await pmPost('/api/notes/pause', { id: s.id });
936
+ await pmPost(resumable ? '/api/notes/pause' : '/api/notes/stop', { id: s.id });
893
937
  const n = cache.get(s.id);
894
- if (n && !n.private && (n.transcript || []).length) await summarize(ctx, n, true);
938
+ if (!resumable && n && !n.private && (n.transcript || []).length) await summarize(ctx, n, true);
895
939
  }
896
- await stopAudio(ctx);
940
+ await stopAudio(ctx, { resumable });
897
941
  ctx.setState({});
898
942
  }
899
-
900
- /** Stop in the note: listening stops and the note leaves the Record control at the top right (pm/note-dock.js). */
943
+ /** Stop in the note: ask the Record control to stop the meeting for good (it asks first). */
901
944
  async function stopAndFinish(ctx) {
902
945
  const d = await import('./note-dock.js');
903
- if (d.recordState().mode !== 'idle') await d.finish(); else await stopRecording(ctx);
946
+ await d.stopNow();
904
947
  }
905
948
  /** Resume in the note: the Record control holds it again and it listens. */
906
949
  async function resumeOn(n) {
907
950
  const d = await import('./note-dock.js');
908
951
  await d.recordNote(n.id, n.title);
909
952
  }
953
+ /** PAUSE: listening stops and the meeting stays open for an hour; it can be resumed. No final notes yet. */
954
+ export const pauseListening = (ctx) => stopRecording(ctx, { resumable: true });
955
+ /** STOP: the meeting ends for good. Listening stops, the server ends the meeting, and the final notes are written. */
956
+ export async function stopMeeting(ctx, id) {
957
+ if (listeningId() === id) { await stopRecording(ctx, { resumable: false }); return; }
958
+ const r = await pmPost('/api/notes/stop', { id });
959
+ if (!(r && r.ok)) { banner = { tone: 'bad', text: (r && r.error) || 'the meeting was not stopped' }; ctx.setState({}); return; }
960
+ await loadNote(ctx, id);
961
+ const n = cache.get(id);
962
+ if (n && !n.private && (n.transcript || []).length) await summarize(ctx, n, true);
963
+ poll(ctx, id);
964
+ ctx.setState({});
965
+ }
966
+ /** Delete a recording that was started by mistake: stop listening without keeping the audio, then delete the note. */
967
+ export async function discardRecording(ctx, id) {
968
+ if (listeningId() === id) await stopAll(ctx, { discard: true });
969
+ const r = await pmPost('/api/notes/delete', { id });
970
+ if (r && r.ok) { cache.delete(id); if (currentId === id) { currentId = null; editor = null; dirty = false; clearTimeout(saveTimer); } }
971
+ ctx.setState({});
972
+ return !!(r && r.ok);
973
+ }
910
974
 
911
975
  /* ── the two sheets: what to listen to, and settings ─────────────────────────────────────────── */
912
976
  async function chooseSources(ctx, n) {
@@ -1215,8 +1279,7 @@ function moreMenu(ctx, n, opts, anchor) {
1215
1279
  const has = (n.transcript || []).length > 0;
1216
1280
  const items = [
1217
1281
  has ? { label: 'Copy as Markdown', onSelect: () => copyMarkdown(ctx, n), attrs: { 'data-nt-copy': '' } } : null,
1218
- opts.inline ? { label: 'Open in Notes', onSelect: () => openFull(ctx, n.id), attrs: { 'data-nt-full': '' } }
1219
- : { label: 'New meeting', onSelect: () => newNote(ctx), attrs: { 'data-nt-new': '' } },
1282
+ { label: 'New meeting', onSelect: () => newNote(ctx), attrs: { 'data-nt-new': '' } },
1220
1283
  { sep: true },
1221
1284
  { label: 'Change meeting', onSelect: () => meetingSheet(ctx, n), attrs: { 'data-nt-meeting': '' } },
1222
1285
  { label: n.initiative ? 'Change initiative' : 'Link an initiative', onSelect: () => initiativeSheet(ctx, n), attrs: { 'data-nt-ini': '' } },
@@ -1249,8 +1312,7 @@ function header(ctx, n, ed, opts = {}) {
1249
1312
  ? button({ label: 'Resume', variant: 'primary', size: 'sm', onClick: () => resumeOn(n), attrs: { 'data-nt-listen': 'again' } })
1250
1313
  : button({ label: 'Listen', variant: 'primary', size: 'sm', onClick: () => chooseSources(ctx, n), attrs: { 'data-nt-listen': '' } }));
1251
1314
  }
1252
- if (opts.onMinimize) acts.push(button({ label: 'Minimize', variant: 'ghost', size: 'sm', onClick: opts.onMinimize, attrs: { 'data-nt-minimize': '' } }));
1253
- if (!opts.inline) acts.push(button({ label: 'All notes', variant: 'ghost', size: 'sm', onClick: () => { showList = !showList; ctx.setState({}); if (showList) loadList(ctx); }, attrs: { 'data-nt-all': '', 'aria-expanded': showList ? 'true' : 'false' } }));
1315
+ acts.push(button({ label: 'All notes', variant: 'ghost', size: 'sm', onClick: () => { showList = !showList; ctx.setState({}); if (showList) loadList(ctx); }, attrs: { 'data-nt-all': '', 'aria-expanded': showList ? 'true' : 'false' } }));
1254
1316
  const more = iconButton({ icon: 'ellipsis', ariaLabel: 'More', onClick: (e) => moreMenu(ctx, n, opts, (e && e.currentTarget) || more), attrs: { 'data-nt-more': '' } });
1255
1317
  acts.push(more);
1256
1318
  return el('header', { class: 'nt-header', 'data-nt-part': 'header' },
@@ -1601,12 +1663,7 @@ export function renderBoard(inner, ctx) {
1601
1663
  if (banner) wrap.appendChild(el('div', { class: 'nt-banner' + (banner.tone === 'bad' ? ' nt-banner--bad' : ''), 'data-nt-banner': banner.tone, role: banner.tone === 'bad' ? 'alert' : 'status' }, banner.text));
1602
1664
  const n = currentId ? cache.get(currentId) : null;
1603
1665
  if (currentId && !n) { loadNote(ctx, currentId); wrap.appendChild(el('p', { class: 'nt-hint' }, 'reading the note …')); }
1604
- else if (n && popped === n.id) {
1605
- wrap.appendChild(el('div', { class: 'nt-start', 'data-nt-popped': '' },
1606
- el('h2', { class: 'nt-h' }, n.title || 'Untitled note'),
1607
- el('p', { class: 'nt-hint' }, 'This note is open in the pop-up at the bottom right.'),
1608
- button({ label: 'Show it here', variant: 'primary', onClick: () => openFull(ctx, n.id), attrs: { 'data-nt-unpop': '' } })));
1609
- } else if (!n) {
1666
+ else if (!n) {
1610
1667
  wrap.appendChild(el('div', { class: 'nt-start' },
1611
1668
  el('h2', { class: 'nt-h' }, 'Notes'),
1612
1669
  el('p', { class: 'nt-hint', 'data-nt-about': '' }, 'Take notes in a meeting. PMOS listens on this machine, catches the decisions, actions and questions, and checks them against your repository. No audio leaves this machine, and notes are kept outside every repository.'),
@@ -1637,43 +1694,38 @@ function keepFocus() {
1637
1694
  const caret = [editor[focus].selectionStart, editor[focus].selectionEnd];
1638
1695
  return () => queueMicrotask(() => { try { const t = editor[focus]; if (t.isConnected) { t.focus(); t.setSelectionRange(caret[0], caret[1]); } } catch { /* the field is gone */ } });
1639
1696
  }
1640
- async function openFull(ctx, id) {
1641
- await flushSave(ctx);
1642
- popped = null;
1643
- const [c, d] = await Promise.all([import('./panel-cockpit.js'), import('./note-dock.js')]);
1644
- d.minimize();
1645
- pendingOpen = id;
1646
- c.openView('notes');
1647
- }
1648
-
1649
- /** The note the pop-up shows (pm/note-dock.js), or null. */
1650
- export function setPopped(id) { popped = id || null; }
1651
1697
  /** The note's title as last read or typed, or ''. */
1652
1698
  export const titleOf = (id) => (editor && editor.id === id && editor.title.value.trim()) || ((cache.get(id) || {}).title) || '';
1653
1699
  /** When the note that is listening started, or null. */
1654
1700
  export const liveStarted = () => (audio && audio.state === 'on' ? audio.started : speech && speech.mode === 'record' && speech.state === 'on' ? speech.started : null);
1655
1701
  /** Stop the note that is listening, as its Stop does. */
1656
- export const stopListening = (ctx) => stopRecording(ctx);
1702
+ export const stopListening = (ctx) => stopRecording(ctx, { resumable: true });
1657
1703
  /** Start listening on this note at once, with no chooser (Run 6, Take notes): the microphone and the meeting when Whisper is set
1658
1704
  * up, else the microphone through the browser's on-device recognition. False when the note cannot listen (private, gone). */
1659
1705
  export async function listenNow(ctx, id) {
1660
- if (id !== currentId) await selectNote(ctx, id);
1661
- let n = cache.get(id);
1662
- if (!n) { await loadNote(ctx, id); n = cache.get(id); }
1663
- if (!n || n.private) return false;
1664
- const eng = engine && engine.ws === wsOf(ctx) && engine.whisper ? engine : await loadEngine(ctx);
1665
- if (eng.whisper && eng.whisper.ready) await startAudio(ctx, n, ['mic', 'meeting']); else await startSpeech(ctx, n, 'record');
1666
- return listeningId() === id;
1706
+ starting = true;
1707
+ try {
1708
+ // a highlight belongs to the meeting it was pressed in: it does not carry over to the next one
1709
+ if (highlight && highlight.id !== id) { highlight = null; hlOpen = false; clearInterval(highlightTimer); highlightTimer = null; }
1710
+ if (id !== currentId) await selectNote(ctx, id);
1711
+ let n = cache.get(id);
1712
+ if (!n) { await loadNote(ctx, id); n = cache.get(id); }
1713
+ if (!n || n.private) return false;
1714
+ const eng = engine && engine.ws === wsOf(ctx) && engine.whisper ? engine : await loadEngine(ctx);
1715
+ if (eng.whisper && eng.whisper.ready) await startAudio(ctx, n, ['mic', 'meeting']); else await startSpeech(ctx, n, 'record');
1716
+ return listeningId() === id;
1717
+ } finally { starting = false; ctx.setState({}); }
1667
1718
  }
1668
1719
 
1669
1720
  /** The note that is listening now, or null. */
1670
1721
  export const listeningId = () => (audio && audio.id) || (speech && speech.mode === 'record' && (speech.state === 'on' || speech.state === 'checking') ? speech.id : null);
1671
1722
 
1672
- /** One note drawn in the pop-up (meeting-notes Run 6, pm/note-dock.js): the same note, editor and listening as the Notes tab,
1673
- * in one column, with Open in Notes for the full screen. It never stops a meeting that is being heard: when another note is
1674
- * listening it draws nothing and returns false. */
1675
- export function renderInline(host, ctx, id, opts = {}) {
1676
- if (ctx.FIXTURE) return false;
1723
+ /** The live part of the note, drawn in the Notes place's right column while the meeting is recording or paused (meeting-notes
1724
+ * Run 13): the status, Your notes (the field to type in) and the live transcript. It is the same editor and listening as the
1725
+ * rest of this view. It never stops a meeting that is being heard: when another note is listening it draws nothing and returns
1726
+ * false. The fields it shows are kept across redraws (kept() through own()), so typing and scrolling survive new lines. */
1727
+ export function renderLive(host, ctx, id) {
1728
+ if (ctx.FIXTURE || starting) return false;
1677
1729
  const other = listeningId();
1678
1730
  if (other && other !== id) return false;
1679
1731
  ensureComponentsCss();
@@ -1683,20 +1735,37 @@ export function renderInline(host, ctx, id, opts = {}) {
1683
1735
  if (!n) { loadNote(ctx, id); host.appendChild(el('p', { class: 'nt-hint' }, 'reading the note …')); return true; }
1684
1736
  const keep = keepFocus();
1685
1737
  const ed = editorFor(ctx, n);
1738
+ // the title is typed in the Notes place, not here: this editor takes it from the note, so a save here never puts back an old one
1739
+ if (!dirty && ed.title.value !== (n.title || '')) ed.title.value = n.title || '';
1686
1740
  inlineDraw = true;
1687
- try { drawInline(ctx, n, ed, host, opts); } finally { inlineDraw = false; }
1741
+ try { host.appendChild(liveBlock(ctx, n, ed)); } finally { inlineDraw = false; }
1688
1742
  keep();
1689
1743
  return true;
1690
1744
  }
1691
- function drawInline(ctx, n, ed, host, opts) {
1692
- const wrap = el('div', { class: 'nt-wrap nt-wrap--inline', 'data-nt-part': 'inline', 'data-nt-id': n.id });
1693
- if (banner) wrap.appendChild(el('div', { class: 'nt-banner' + (banner.tone === 'bad' ? ' nt-banner--bad' : ''), 'data-nt-banner': banner.tone, role: banner.tone === 'bad' ? 'alert' : 'status' }, banner.text));
1694
- wrap.appendChild(header(ctx, n, ed, { inline: true, onMinimize: opts.onMinimize }));
1695
- if (n.private) wrap.appendChild(el('p', { class: 'nt-hint', 'data-nt-private': '' }, 'This note is private: it is not recorded, and nothing from it goes to Jev or to any agent.'));
1696
- const pl = !listening(n) ? playerFor(ctx, n) : null;
1697
- if (pl) wrap.appendChild(el('div', { class: 'nt-replay', 'data-nt-part': 'replay' }, el('span', { class: 'nt-sec__h' }, 'Replay'), own(pl.el)));
1698
- wrap.appendChild(el('div', { class: 'nt-body2' }, noteColumn(ctx, n, ed), transcriptColumn(ctx, n, { inline: true })));
1699
- if (pl) queueMicrotask(paintNow);
1700
- if (n.agent && n.agent.started && !n.agent.markdown && !agentTimer) pollAgent(ctx, n.id);
1701
- host.appendChild(wrap);
1702
- }
1745
+ function liveBlock(ctx, n, ed) {
1746
+ const live = listening(n);
1747
+ const parts = [];
1748
+ if (banner) parts.push(el('div', { class: 'nt-banner' + (banner.tone === 'bad' ? ' nt-banner--bad' : ''), 'data-nt-banner': banner.tone, role: banner.tone === 'bad' ? 'alert' : 'status' }, banner.text));
1749
+ if (live) {
1750
+ parts.push(el('div', { class: 'nt-live', 'data-nt-listening': live.kind, role: 'status' },
1751
+ el('span', { class: 'nt-live-dot', 'aria-hidden': 'true' }), el('b', null, 'Taking notes'),
1752
+ el('span', { 'data-nt-clock': '' }, fmtTime(Date.now() - live.started)),
1753
+ el('span', { 'data-nt-sources': '' }, ' · ' + (live.state === 'starting' || live.state === 'checking' ? 'starting' : ['mic', 'meeting'].filter((s) => live.sources.includes(s)).join(' + '))),
1754
+ el('span', { class: 'nt-hint' }, 'Highlight and Pause are in the top bar.'),
1755
+ el('span', { class: 'nt-sr' }, ' Notes are being taken.')));
1756
+ } else {
1757
+ const left = n.resume_until ? Math.max(1, Math.ceil((Date.parse(n.resume_until) - Date.now()) / 60000)) : null;
1758
+ parts.push(el('div', { class: 'nt-live nt-live--paused', 'data-nt-paused': '', role: 'status' }, el('b', null, 'Paused'),
1759
+ el('span', { class: 'nt-hint' }, (left ? left + (left === 1 ? ' minute' : ' minutes') + ' left to resume. ' : '') + 'Stop ends the meeting for good.')));
1760
+ }
1761
+ if (n.private) parts.push(el('p', { class: 'nt-hint', 'data-nt-private': '' }, 'This note is private: it is not recorded, and nothing from it goes to Jev or to any agent.'));
1762
+ parts.push(myNotes(ctx, n, ed), transcriptColumn(ctx, n, {}));
1763
+ return el('section', { class: 'nt-liveblock', 'data-nt-part': 'live', 'data-nt-id': n.id }, parts);
1764
+ }
1765
+ /** Link the note to a calendar meeting, or to an initiative, from the Notes place's ⋯ menu: the same sheets as the Notes tab. */
1766
+ export async function linkMeeting(ctx, id) { await loadNote(ctx, id); const n = cache.get(id); if (n) await meetingSheet(ctx, n); }
1767
+ export async function linkInitiative(ctx, id) { await loadNote(ctx, id); const n = cache.get(id); if (n) await initiativeSheet(ctx, n); }
1768
+ /** The text of the last failure shown on this view (for example "this meeting was stopped"), and forget it. */
1769
+ export function takeBanner() { const t = banner && banner.tone === 'bad' ? banner.text : null; if (t) banner = null; return t; }
1770
+ /** The title was changed elsewhere (the Notes place): keep this view's copy in step. */
1771
+ export function noteTitled(id, title) { const c = cache.get(id); if (c) c.title = title; if (editor && editor.id === id && !dirty) editor.title.value = title || ''; }
@@ -3889,7 +3889,7 @@ function taskWsId(run, t) {
3889
3889
  const brief = briefOfDay(run.date);
3890
3890
  if (!brief || !t.item_id) return null;
3891
3891
  const st = proSettings();
3892
- const r = goalsLib.itemRepos({ day: readGoalsDay(run.date), brief, placeRepos: st.placeRepos || {}, iniRepos: iniRepos() })[t.item_id];
3892
+ const r = goalsLib.itemRepos({ day: readGoalsDay(run.date), brief, placeRepos: st.placeRepos || {}, iniRepos: iniRepos(), links: dayMarksLib.links(readDayMarks(run.date)) })[t.item_id];
3893
3893
  const w = r && r !== run.ws ? getWs(r) : null;
3894
3894
  return w && !daySkillPrompt(run, 'day-task', w.id).error ? w.id : null;
3895
3895
  } catch { return null; }
@@ -4035,9 +4035,10 @@ function writeDayMarks(rec) {
4035
4035
  fs.writeFileSync(tmp, JSON.stringify(rec) + '\n'); fs.renameSync(tmp, f); return true;
4036
4036
  } catch (e) { warnWriteFail('day marks', e); return false; }
4037
4037
  }
4038
- /** The ids a mark may name: today's brief items ('item') and calendar events ('event'). */
4039
- function dayMarkIds(brief) {
4038
+ /** The ids a mark may name: today's brief items ('item'), calendar events ('event') and the run's tasks ('task', for a link). */
4039
+ function dayMarkIds(brief, run) {
4040
4040
  const known = new Map();
4041
+ for (const t of (run && run.tasks) || []) if (t && /^t-[0-9a-f]{12}$/.test(String(t.id))) known.set(t.id, 'task');
4041
4042
  if (!brief) return known;
4042
4043
  for (const x of [...(brief.priorities || []), ...(brief.worth_asking || [])]) known.set(x.id, 'item');
4043
4044
  for (const e of brief.calendar || []) known.set(e.id, 'event');
@@ -7850,7 +7851,7 @@ async function handleRequest(req, res) {
7850
7851
  goalsCardsMemo.set(w.id, { at: now, cards, error: cardsError });
7851
7852
  }
7852
7853
  }
7853
- const plan = goalsLib.planDay({ day, brief, cards, now });
7854
+ const plan = goalsLib.planDay({ day, brief, cards, now, links: dayMarksLib.links(readDayMarks(date)) });
7854
7855
  const sched = readSchedules().find((x) => x.skill === 'morning-brief' && (!w || x.ws === w.id)) || null;
7855
7856
  return json(res, { ok: true, ws: w ? w.id : null, today: date, plan, cards_error: cardsError, max_goals: goalsLib.MAX_GOALS,
7856
7857
  schedule: sched ? { id: sched.id, time: sched.time, lastRun: sched.lastRun, lastStatus: sched.lastStatus } : null,
@@ -7861,7 +7862,7 @@ async function handleRequest(req, res) {
7861
7862
  const now = Date.now();
7862
7863
  const date = briefLib.localDate(now);
7863
7864
  const r = goalsLib.edit(readGoalsDay(date), body, { now });
7864
- if (!r.ok) return json(res, { ok: false, error: r.error }, /no goal with that id/.test(r.error) ? 404 : 400);
7865
+ if (!r.ok) return json(res, { ok: false, error: r.error }, /no goal with that id|no job with that id/.test(r.error) ? 404 : 400);
7865
7866
  try { fs.mkdirSync(GOALS_DIR(), { recursive: true }); fs.writeFileSync(path.join(GOALS_DIR(), date + '.json'), JSON.stringify(r.day) + '\n'); }
7866
7867
  catch (e) { warnWriteFail('goals', e); return json(res, { ok: false, error: 'could not save — nothing changed' }, 500); }
7867
7868
  return json(res, { ok: true, day: r.day, goal: r.goal });
@@ -7943,7 +7944,7 @@ async function handleRequest(req, res) {
7943
7944
  goalsCardsMemo.set(w.id, { at: now, cards, error: cardsError });
7944
7945
  }
7945
7946
  }
7946
- const plan = goalsLib.planDay({ day: readGoalsDay(date), brief, cards, now });
7947
+ const plan = goalsLib.planDay({ day: readGoalsDay(date), brief, cards, now, links: dayMarksLib.links(readDayMarks(date)) });
7947
7948
  const due = dayRunLib.checkDue(run, { settings: st, now, events: brief ? brief.calendar || [] : [] });
7948
7949
  const items = brief ? [...(brief.priorities || []), ...(brief.worth_asking || [])] : [];
7949
7950
  const sources = proactiveLib.BRIEF_SOURCES.map((k) => ({ k, on: st.briefSources[k] === true, state: brief ? brief.sources[k] || null : null, got: items.filter((x) => x.source === k).length }));
@@ -7959,7 +7960,7 @@ async function handleRequest(req, res) {
7959
7960
  settings: { briefSources: st.briefSources, scopes: st.scopes, placeRepos: st.placeRepos, dayCheck: st.dayCheck, websites: st.websites }, sources,
7960
7961
  // one day for every repository: the repositories it can be narrowed to, and the repository of each tagged item
7961
7962
  repos: [...workspaces.values()].map((x) => ({ id: x.id, name: x.name || path.basename(x.root) })),
7962
- item_repos: goalsLib.itemRepos({ day: readGoalsDay(date), brief, placeRepos: st.placeRepos || {}, iniRepos: iniRepos(now) }),
7963
+ item_repos: goalsLib.itemRepos({ day: readGoalsDay(date), brief, placeRepos: st.placeRepos || {}, iniRepos: iniRepos(now), links: dayMarksLib.links(readDayMarks(date)) }),
7963
7964
  goal_repos: goalsLib.goalRepos({ day: readGoalsDay(date), iniRepos: iniRepos(now) }),
7964
7965
  check: { every: st.dayCheck.every, last: run ? run.checks.last : null, next: due.next, running: !!(run && run.checks.session_id), log: run ? run.checks.log : [] } });
7965
7966
  }
@@ -7987,7 +7988,8 @@ async function handleRequest(req, res) {
7987
7988
  const stored = briefOfDay(date);
7988
7989
  const brief = stored && stored.date === date ? stored : null;
7989
7990
  if (!brief) return json(res, { ok: false, error: 'no brief today — press Prepare my day first' }, 409);
7990
- const r = dayMarksLib.edit(readDayMarks(date), body, { now, known: dayMarkIds(brief) });
7991
+ const openGoals = new Set(readGoalsDay(date).goals.filter((g) => g.status === 'open').map((g) => g.id));
7992
+ const r = dayMarksLib.edit(readDayMarks(date), body, { now, known: dayMarkIds(brief, readDayRun(date)), goals: openGoals });
7991
7993
  if (!r.ok) return json(res, { ok: false, error: r.error }, /no item or meeting with that id/.test(r.error) ? 404 : 400);
7992
7994
  if (!writeDayMarks(r.rec)) return json(res, { ok: false, error: 'could not save — nothing changed' }, 500);
7993
7995
  return json(res, { ok: true, mark: r.mark, prev: r.prev, marks: dayMarksLib.effective(r.rec, now) });
@@ -44,6 +44,7 @@
44
44
  "notes-highlight.test.js",
45
45
  "notes-page.test.js",
46
46
  "notes-conflicts.test.js",
47
+ "notes-resume.test.js",
47
48
  "proactive-prepare.test.js",
48
49
  "learn.test.js",
49
50
  "session-flag.test.js",
@@ -107,6 +108,7 @@
107
108
  "pm-r29-newsession-alignment-test.js",
108
109
  "pm-live-notes-test.js",
109
110
  "pm-notes-menu-test.js",
111
+ "pm-record-control-test.js",
110
112
  "pm-r30-test.js",
111
113
  "pm-breakdown-test.js",
112
114
  "pm-breakdown-phone-test.js",
@@ -152,6 +154,7 @@
152
154
  "notes-highlight.test.js": 1,
153
155
  "notes-page.test.js": 1,
154
156
  "notes-conflicts.test.js": 1,
157
+ "notes-resume.test.js": 8,
155
158
  "proactive-prepare.test.js": 9,
156
159
  "learn.test.js": 3,
157
160
  "session-flag.test.js": 1,
@@ -209,6 +212,7 @@
209
212
  "pm-r29-newsession-alignment-test.js": 8,
210
213
  "pm-live-notes-test.js": 24,
211
214
  "pm-notes-menu-test.js": 10,
215
+ "pm-record-control-test.js": 12,
212
216
  "pm-r30-test.js": 4,
213
217
  "ui-test.js": 30,
214
218
  "plan-items.test.js": 1,
@@ -0,0 +1,5 @@
1
+ {
2
+ "_comment": "Initiative today-goals-jobs, the client half: goals own jobs, who acts on each job, the link control and the meeting's goal block, in a browser against a real server.",
3
+ "panels": ["pm-today-goals-jobs-test.js"],
4
+ "seconds": { "pm-today-goals-jobs-test.js": 40 }
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "_comment": "Initiative today-goals-jobs: the pure core of goals that own jobs.",
3
+ "modules": ["today-goals-jobs.test.js"],
4
+ "seconds": { "today-goals-jobs.test.js": 2 }
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "_comment": "Initiative today-goals-jobs, slice C: a Slack, Confluence or Figma mention draws as the card of its service, from the brief item's own fields, with no way to post to, resolve in or react in the service.",
3
+ "panels": ["pm-today-mentions-cards-test.js"],
4
+ "seconds": { "pm-today-mentions-cards-test.js": 25 }
5
+ }