create-pmos 0.62.0 → 0.63.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.
@@ -0,0 +1,230 @@
1
+ 'use strict';
2
+ /* routes-notes.js — the Notes view's routes (meeting-notes, Runs 1 and 2; lib/route-modules.js).
3
+ *
4
+ * GET /api/notes → { ok, notes: [row] } newest first; a row never carries the body
5
+ * GET /api/notes/one?id=… → { ok, note } imports an agent summary written since the request
6
+ * POST /api/notes/new { title? } → { ok, note }
7
+ * POST /api/notes/save { id, title?, body? } → { ok, updated_at }
8
+ * POST /api/notes/dictate { text } → { ok, ops } one dictated phrase as insert operations (lib/notes.js dictation)
9
+ * POST /api/notes/record { id, ws? } → { ok, recorded_from } `ws` is the repository the note's lines are checked against
10
+ * POST /api/notes/transcript { id, text, source? } → { ok, added: [{ at, t, text, source, cat, index }] }
11
+ * POST /api/notes/audio { id, source, pcm, final? } → { ok, cut } 16 kHz mono 16-bit samples, base64; local Whisper (D91)
12
+ * POST /api/notes/label { id, index, cat } → { ok, segment } the PM's own label for a line
13
+ * POST /api/notes/speaker { id, source, name } → { ok, speakers }
14
+ * GET /api/notes/engine?ws=… → { ok, whisper, jev } is Whisper set up; which Jev gates hold (booleans only)
15
+ * POST /api/notes/switch { ws, jev?, check? } → { ok, jev } the two D91 switches, default off
16
+ * POST /api/notes/whisper-setup { ws? } → { ok, started, session, command } a terminal with the setup typed in, not run
17
+ * POST /api/notes/private { id, private } → { ok, private }
18
+ * POST /api/notes/summarize { id } → { ok, summary } the local summary
19
+ * POST /api/notes/summarize-agent { id, ws? } → { ok, started, session, handoff, out, withheld, reason }
20
+ * POST /api/notes/delete { id } → { ok }
21
+ *
22
+ * Notes are the PM's own and live in the per-user runtime directory (ctx.runtimeDir), never in a repository, so no route here
23
+ * writes into a registered workspace. Every POST goes through pm-guard `requirePagePress` (a body that is not an object or carries
24
+ * a writer field: 400; no Origin, a foreign Origin or a wrong nonce: 403). No route makes an outbound request of its own: the
25
+ * agent summary starts the PM's own agent session in the repository (ctx.spawnSession), as the skill draft does; Whisper runs
26
+ * on this machine (lib/notes-whisper.js); a label or a check goes to Jev only through lib/jev-notes.js, behind the D91 gates.
27
+ *
28
+ * AFTER A LINE IS ADDED (Run 2). Each new line already has its local label. Then, one note at a time and in order: Jev's label
29
+ * when the label gates hold; for a decision, action or statement, the closest repository passage (local, lib/notes-repo.js),
30
+ * and Jev's comparison when the check gates hold too. The page reads the results with GET /api/notes/one.
31
+ */
32
+ const guard = require('./pm-guard.js');
33
+ const notes = require('./notes.js');
34
+ const whisper = require('./notes-whisper.js');
35
+ const notesRepo = require('./notes-repo.js');
36
+ const jevNotes = require('./jev-notes.js');
37
+ const tsSettings = require('./typesafe-settings.js');
38
+
39
+ const MAX_PCM_BYTES = 16000 * 2 * 15; // 15 s of 16 kHz 16-bit audio in one request
40
+ const CHECKED = new Set(['decision', 'action', 'statement']);
41
+
42
+ const prefixes = ['/api/notes'];
43
+ const POSTS = new Set(['/api/notes/new', '/api/notes/save', '/api/notes/dictate', '/api/notes/record', '/api/notes/transcript',
44
+ '/api/notes/private', '/api/notes/summarize', '/api/notes/summarize-agent', '/api/notes/delete', '/api/notes/audio', '/api/notes/label',
45
+ '/api/notes/speaker', '/api/notes/switch', '/api/notes/whisper-setup']);
46
+
47
+ /* ── labels and checks, one note at a time ───────────────────────────────────────────────────── */
48
+ const chains = new Map(); // note id → the promise of its enrichment so far
49
+ function enrich(ctx, id, added) {
50
+ if (!added || !added.length) return Promise.resolve();
51
+ const next = (chains.get(id) || Promise.resolve()).then(() => enrichNow(ctx, id, added)).catch(() => {});
52
+ chains.set(id, next);
53
+ next.then(() => { if (chains.get(id) === next) chains.delete(id); });
54
+ return next;
55
+ }
56
+ function gatesFor(ctx, root) {
57
+ const dir = ctx.runtimeDir;
58
+ if (!root) return { root: null, exclude: [], ask: null, check: false, key: null, askOpts: {} };
59
+ const repo = tsSettings.getRepo(dir, root);
60
+ const sw = jevNotes.getSwitch(dir, root);
61
+ const key = tsSettings.getKey(dir);
62
+ const snd = jevNotes.senderFor(key, { consent: repo.consent, jev: sw.jev, check: sw.check }, typeof ctx.jevModelOpts === 'function' ? ctx.jevModelOpts(root) : {});
63
+ return { root, exclude: repo.exclude, ask: snd.ask, check: snd.check, key: snd.ask ? key : null, askOpts: snd.askOpts };
64
+ }
65
+ const stillOpen = (dir, id) => { const r = notes.read(dir, id); return r.ok && !r.note.private ? r.note : null; };
66
+ async function enrichNow(ctx, id, added) {
67
+ const dir = ctx.runtimeDir;
68
+ const note = stillOpen(dir, id);
69
+ if (!note) return;
70
+ const w = note.ws ? ctx.getWs(note.ws) : null;
71
+ const g = gatesFor(ctx, w ? w.root : null);
72
+ for (const seg of added) {
73
+ let cat = seg.cat;
74
+ if (g.ask && stillOpen(dir, id)) {
75
+ const l = await jevNotes.label({ ask: g.ask, key: g.key, askOpts: g.askOpts, text: seg.text, root: g.root });
76
+ if (l.cat) { const u = notes.updateSegment(dir, { id, index: seg.index, text: seg.text, patch: { cat: l.cat, cat_by: 'jev' } }); if (u.ok) cat = u.segment.cat; }
77
+ else if (l.reason === 'failed' || l.reason === 'paused') notes.updateSegment(dir, { id, index: seg.index, text: seg.text, patch: { jev: l.reason } });
78
+ }
79
+ if (!g.root || !CHECKED.has(cat)) continue;
80
+ const pass = notesRepo.closest(g.root, seg.text, { exclude: g.exclude });
81
+ if (!pass) continue;
82
+ let check = { doc: pass.path, line: pass.line, verdict: 'related', by: 'local' };
83
+ if (g.ask && g.check && stillOpen(dir, id)) {
84
+ const c = await jevNotes.compare({ ask: g.ask, key: g.key, askOpts: g.askOpts, text: seg.text, passage: pass, root: g.root });
85
+ if (c.verdict) check = { doc: pass.path, line: pass.line, verdict: c.verdict, words: c.words || null, by: 'jev' };
86
+ }
87
+ notes.updateSegment(dir, { id, index: seg.index, text: seg.text, patch: { check } });
88
+ }
89
+ }
90
+
91
+ /* ── local Whisper: one transcriber for the process ───────────────────────────────────────────── */
92
+ let transcriber = null; let transcriberDir = null;
93
+ const audioErrors = new Map(); // note id → the last thing that went wrong, for the page
94
+ function transcriberFor(ctx) {
95
+ if (transcriber && transcriberDir === ctx.runtimeDir) return transcriber;
96
+ transcriberDir = ctx.runtimeDir;
97
+ transcriber = whisper.createTranscriber({
98
+ runtimeDir: ctx.runtimeDir,
99
+ onText: async (c) => {
100
+ const r = notes.appendTranscript(ctx.runtimeDir, { id: c.id, text: c.text, source: c.source, t: c.t });
101
+ if (r.ok) { audioErrors.delete(c.id); await enrich(ctx, c.id, r.added).catch(() => {}); }
102
+ },
103
+ onError: (c) => { audioErrors.set(c.id, { at: new Date().toISOString(), error: c.error }); },
104
+ });
105
+ return transcriber;
106
+ }
107
+ function engineInfo(ctx, w) {
108
+ const d = whisper.detect(ctx.runtimeDir);
109
+ const out = { ready: d.ready, missing: d.missing, setup: d.setup, model_dir: d.model_dir, model_size: d.model_size };
110
+ if (!w) return { whisper: out, jev: null };
111
+ const repo = tsSettings.getRepo(ctx.runtimeDir, w.root);
112
+ const sw = jevNotes.getSwitch(ctx.runtimeDir, w.root);
113
+ const info = jevNotes.infoFor(tsSettings.status(ctx.runtimeDir).present, { consent: repo.consent, jev: sw.jev, check: sw.check });
114
+ return { whisper: out, jev: { ...info, paused: jevNotes.pauseStatus(w.root) } };
115
+ }
116
+
117
+ const send = (ctx, res, r) => ctx.json(res, r, r.ok ? 200 : (r.status || 400));
118
+
119
+ async function handle(req, res, url, ctx) {
120
+ const p = url.pathname;
121
+ const dir = ctx.runtimeDir;
122
+ if (!dir) { ctx.json(res, { ok: false, error: 'the notes store is not available' }, 500); return true; }
123
+ if (p === '/api/notes' || p === '/api/notes/one') {
124
+ if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
125
+ if (p === '/api/notes') { send(ctx, res, notes.list(dir)); return true; }
126
+ const nid = url.searchParams.get('id');
127
+ const r = notes.read(dir, nid);
128
+ if (r.ok) r.audio = { pending: transcriber ? transcriber.pending() : 0, error: audioErrors.get(nid) || null };
129
+ send(ctx, res, r);
130
+ return true;
131
+ }
132
+ if (p === '/api/notes/engine') {
133
+ if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
134
+ const wsId = url.searchParams.get('ws');
135
+ const w = wsId ? ctx.getWs(wsId) : null;
136
+ ctx.json(res, { ok: true, ...engineInfo(ctx, w && w.id === wsId ? w : null) });
137
+ return true;
138
+ }
139
+ if (!POSTS.has(p)) { ctx.json(res, { ok: false, error: 'no such notes route' }, 404); return true; }
140
+ if (req.method !== 'POST') { ctx.json(res, { ok: false, error: 'POST only' }, 405); return true; }
141
+ const parsed = await ctx.readJson(req);
142
+ if (!parsed.ok) { ctx.json(res, { ok: false, error: 'The body is not valid JSON.' }, 400); return true; }
143
+ const body = parsed.body;
144
+ const g = guard.requirePagePress(req, body);
145
+ if (!g.ok) { ctx.json(res, { ok: false, error: g.error }, g.status); return true; }
146
+ const id = body && typeof body.id === 'string' ? body.id : '';
147
+ const done = (r) => { send(ctx, res, r); return true; };
148
+ if (p === '/api/notes/new') return done(notes.create(dir, { title: body.title }));
149
+ if (p === '/api/notes/dictate') return done(notes.dictation(body.text));
150
+ if (p === '/api/notes/save') return done(notes.save(dir, { id, title: body.title, body: body.body }));
151
+ const wsOf = () => {
152
+ if (body.ws == null) return { ok: true, w: null };
153
+ const w = typeof body.ws === 'string' ? ctx.getWs(body.ws) : null;
154
+ return w && w.id === body.ws ? { ok: true, w } : { ok: false };
155
+ };
156
+ if (p === '/api/notes/record') {
157
+ const o = wsOf();
158
+ if (!o.ok) { ctx.json(res, { ok: false, error: 'This repository is not registered.' }, 404); return true; }
159
+ return done(notes.record(dir, { id, ws: body.ws == null ? undefined : o.w.id }));
160
+ }
161
+ if (p === '/api/notes/transcript') {
162
+ const r = notes.appendTranscript(dir, { id, text: body.text, source: 'speech' });
163
+ if (r.ok) enrich(ctx, id, r.added);
164
+ return done(r);
165
+ }
166
+ if (p === '/api/notes/audio') {
167
+ const source = body.source === 'meeting' ? 'meeting' : body.source === 'mic' ? 'mic' : null;
168
+ if (!source) return done({ ok: false, status: 400, error: 'the source must be mic or meeting' });
169
+ const note = notes.read(dir, id);
170
+ if (!note.ok) return done(note);
171
+ if (note.note.private) { transcriberFor(ctx).drop(id); return done({ ok: false, status: 409, error: 'this note is marked private, so nothing more is recorded in it' }); }
172
+ if (!note.note.recorded_from) return done({ ok: false, status: 409, error: 'press Listen first' });
173
+ const b64 = typeof body.pcm === 'string' ? body.pcm : '';
174
+ if (b64.length > Math.ceil(MAX_PCM_BYTES / 3) * 4) return done({ ok: false, status: 413, error: 'that audio chunk is too long' });
175
+ const pcm = Buffer.from(b64, 'base64');
176
+ if (pcm.length % 2) return done({ ok: false, status: 400, error: 'the audio must be 16-bit samples' });
177
+ if (!whisper.detect(dir).ready) return done({ ok: false, status: 409, error: 'local transcription is not set up' });
178
+ const ms = (pcm.length / 2 / whisper.RATE) * 1000;
179
+ const t = Math.max(0, Date.now() - Date.parse(note.note.recorded_from) - ms);
180
+ return done(transcriberFor(ctx).push({ id, source, pcm, t, final: body.final === true }));
181
+ }
182
+ if (p === '/api/notes/label') return done(notes.setLabel(dir, { id, index: Number(body.index), cat: body.cat, drop: body.drop === true }));
183
+ if (p === '/api/notes/speaker') return done(notes.renameSpeaker(dir, { id, source: body.source, name: body.name }));
184
+ if (p === '/api/notes/switch') {
185
+ const o = wsOf();
186
+ if (!o.ok || !o.w) { ctx.json(res, { ok: false, error: 'This repository is not registered.' }, 404); return true; }
187
+ jevNotes.setSwitch(dir, o.w.root, { jev: typeof body.jev === 'boolean' ? body.jev : undefined, check: typeof body.check === 'boolean' ? body.check : undefined });
188
+ ctx.json(res, { ok: true, ...engineInfo(ctx, o.w) });
189
+ return true;
190
+ }
191
+ if (p === '/api/notes/whisper-setup') {
192
+ const o = wsOf();
193
+ if (!o.ok) { ctx.json(res, { ok: false, error: 'This repository is not registered.' }, 404); return true; }
194
+ const command = whisper.setupCommand(dir);
195
+ if (typeof ctx.spawnSession !== 'function') { ctx.json(res, { ok: true, started: false, command, reason: 'this workspace cannot start sessions' }); return true; }
196
+ // typed, not run: the PM reads the commands in their own terminal and presses Enter (D91)
197
+ const s = ctx.spawnSession({ name: 'notes: set up Whisper', agent: 'shell', ws: o.w ? o.w.id : undefined, origin: 'notes-whisper-setup', initialInput: command });
198
+ const started = !!s && s.state !== 'exited';
199
+ ctx.json(res, { ok: true, started, session: s ? s.id : null, command, reason: started ? '' : ((s && s.note) || 'could not start a terminal') });
200
+ return true;
201
+ }
202
+ if (p === '/api/notes/private') {
203
+ const r = notes.setPrivate(dir, { id, private: body.private });
204
+ if (r.ok && r.private && transcriber) transcriber.drop(id);
205
+ return done(r);
206
+ }
207
+ if (p === '/api/notes/summarize') return done(notes.localSummary(dir, { id }));
208
+ if (p === '/api/notes/delete') { if (transcriber) transcriber.drop(id); return done(notes.remove(dir, id)); }
209
+ // summarize-agent: the handoff file first (refused for a private note), then the PM's own agent session
210
+ let w = null;
211
+ if (body.ws != null) {
212
+ w = typeof body.ws === 'string' ? ctx.getWs(body.ws) : null;
213
+ if (!w || w.id !== body.ws) { ctx.json(res, { ok: false, error: 'This repository is not registered.' }, 404); return true; }
214
+ }
215
+ const h = notes.agentHandoff(dir, { id });
216
+ if (!h.ok) return done(h);
217
+ if (typeof ctx.spawnSession !== 'function') {
218
+ notes.agentStarted(dir, { id, started: false, reason: 'this workspace cannot start sessions' });
219
+ ctx.json(res, { ok: true, started: false, reason: 'this workspace cannot start sessions', handoff: h.handoff, out: h.out, withheld: h.withheld });
220
+ return true;
221
+ }
222
+ const s = ctx.spawnSession({ name: 'notes: summary', agent: 'claude', ws: w ? w.id : undefined, origin: 'notes-summary:' + id, initialInput: h.prompt + '\r' });
223
+ const started = !!s && s.state !== 'exited';
224
+ const reason = started ? '' : ((s && s.note) || 'could not start a claude session');
225
+ notes.agentStarted(dir, { id, session: s ? s.id : null, started, reason });
226
+ ctx.json(res, { ok: true, started, session: s ? s.id : null, reason, handoff: h.handoff, out: h.out, withheld: h.withheld });
227
+ return true;
228
+ }
229
+
230
+ module.exports = { prefixes, handle, enrich, __idle: () => Promise.all([transcriber ? transcriber.idle() : null, ...chains.values()]) };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.62.0",
3
+ "version": "0.63.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.62.0",
3
+ "version": "0.63.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -4672,6 +4672,278 @@
4672
4672
  }
4673
4673
  }
4674
4674
 
4675
+ /* -- layouts · .nt- Notes: one meeting on one screen — notes and what was caught beside a quiet transcript (meeting-notes Runs 1 and 2) -- */
4676
+ .nt-wrap {
4677
+ display: flex;
4678
+ flex-direction: column;
4679
+ gap: 12px;
4680
+ }
4681
+ .nt-start {
4682
+ display: flex;
4683
+ flex-direction: column;
4684
+ align-items: flex-start;
4685
+ gap: 12px;
4686
+ max-width: 560px;
4687
+ }
4688
+ .nt-h {
4689
+ margin: 0;
4690
+ font-size: 20px;
4691
+ line-height: 28px;
4692
+ font-weight: 500;
4693
+ }
4694
+ .nt-header {
4695
+ display: flex;
4696
+ flex-wrap: wrap;
4697
+ align-items: center;
4698
+ justify-content: space-between;
4699
+ gap: 12px;
4700
+ padding-bottom: 12px;
4701
+ border-bottom: 1px solid var(--border);
4702
+ }
4703
+ .nt-headl {
4704
+ flex: 1 1 280px;
4705
+ min-width: 0;
4706
+ display: flex;
4707
+ flex-wrap: wrap;
4708
+ align-items: baseline;
4709
+ gap: 4px 12px;
4710
+ }
4711
+ .nt-headl .nt-titlein {
4712
+ font-size: 20px;
4713
+ font-weight: 500;
4714
+ }
4715
+ .nt-when,
4716
+ .nt-saved {
4717
+ font-size: 12px;
4718
+ color: var(--muted-foreground);
4719
+ }
4720
+ .nt-acts {
4721
+ display: flex;
4722
+ flex-wrap: wrap;
4723
+ align-items: center;
4724
+ gap: 8px;
4725
+ }
4726
+ .nt-live {
4727
+ display: inline-flex;
4728
+ align-items: center;
4729
+ gap: 8px;
4730
+ font-family: var(--mono);
4731
+ font-size: 12px;
4732
+ color: var(--foreground);
4733
+ }
4734
+ .nt-live-dot {
4735
+ width: 8px;
4736
+ height: 8px;
4737
+ border-radius: 50%;
4738
+ background: var(--destructive);
4739
+ }
4740
+ @media (prefers-reduced-motion: no-preference) {
4741
+ .nt-live-dot {
4742
+ animation: nt-pulse 1.6s ease-in-out infinite;
4743
+ }
4744
+ }
4745
+ @keyframes nt-pulse {
4746
+ 50% { opacity: .35; }
4747
+ }
4748
+ .nt-sr {
4749
+ position: absolute;
4750
+ width: 1px;
4751
+ height: 1px;
4752
+ overflow: hidden;
4753
+ clip-path: inset(50%);
4754
+ white-space: nowrap;
4755
+ }
4756
+ .nt-body2 {
4757
+ display: grid;
4758
+ grid-template-columns: minmax(0, 1.5fr) minmax(260px, 1fr);
4759
+ align-items: start;
4760
+ gap: 0;
4761
+ }
4762
+ .nt-notecol {
4763
+ min-width: 0;
4764
+ display: flex;
4765
+ flex-direction: column;
4766
+ gap: 16px;
4767
+ padding: 4px 24px 16px 0;
4768
+ }
4769
+ .nt-text {
4770
+ height: var(--nt-h, auto);
4771
+ min-height: 96px;
4772
+ overflow: hidden;
4773
+ resize: none;
4774
+ font-size: 14px;
4775
+ line-height: 22px;
4776
+ }
4777
+ .nt-row {
4778
+ display: flex;
4779
+ flex-wrap: wrap;
4780
+ align-items: center;
4781
+ gap: 8px;
4782
+ }
4783
+ .nt-sec {
4784
+ display: flex;
4785
+ flex-direction: column;
4786
+ gap: 6px;
4787
+ }
4788
+ .nt-sec__h {
4789
+ margin: 0;
4790
+ font-size: 11px;
4791
+ font-weight: 500;
4792
+ letter-spacing: .06em;
4793
+ text-transform: uppercase;
4794
+ color: var(--muted-foreground);
4795
+ }
4796
+ .nt-items {
4797
+ margin: 0;
4798
+ padding: 0;
4799
+ list-style: none;
4800
+ display: flex;
4801
+ flex-direction: column;
4802
+ }
4803
+ .nt-item {
4804
+ display: flex;
4805
+ align-items: baseline;
4806
+ gap: 8px;
4807
+ padding: 4px 0;
4808
+ border-bottom: 1px solid var(--border);
4809
+ font-size: 14px;
4810
+ line-height: 20px;
4811
+ }
4812
+ .nt-item--plain {
4813
+ border-bottom: 0;
4814
+ }
4815
+ .nt-item__text {
4816
+ flex: 1 1 auto;
4817
+ min-width: 0;
4818
+ }
4819
+ .nt-who {
4820
+ color: var(--muted-foreground);
4821
+ }
4822
+ .nt-at {
4823
+ flex: none;
4824
+ font-family: var(--mono);
4825
+ font-size: 11px;
4826
+ color: var(--muted-foreground);
4827
+ }
4828
+ .nt-flag {
4829
+ font-size: 12px;
4830
+ color: var(--warning);
4831
+ }
4832
+ .nt-ok,
4833
+ .nt-rel {
4834
+ font-size: 12px;
4835
+ color: var(--muted-foreground);
4836
+ }
4837
+ .nt-hint {
4838
+ margin: 0;
4839
+ font-size: 12px;
4840
+ line-height: 18px;
4841
+ color: var(--muted-foreground);
4842
+ }
4843
+ .nt-hint--bad {
4844
+ color: var(--destructive);
4845
+ }
4846
+ .nt-consent {
4847
+ margin: 0;
4848
+ font-size: 12px;
4849
+ color: var(--foreground);
4850
+ }
4851
+ .nt-trans {
4852
+ min-width: 0;
4853
+ display: flex;
4854
+ flex-direction: column;
4855
+ gap: 12px;
4856
+ padding: 4px 0 16px 20px;
4857
+ border-left: 1px solid var(--border);
4858
+ }
4859
+ .nt-lines {
4860
+ max-height: 640px;
4861
+ overflow-y: auto;
4862
+ display: flex;
4863
+ flex-direction: column;
4864
+ gap: 12px;
4865
+ }
4866
+ .nt-ln {
4867
+ font-size: 14px;
4868
+ line-height: 20px;
4869
+ color: var(--muted-foreground);
4870
+ }
4871
+ .nt-ln--picked {
4872
+ color: var(--foreground);
4873
+ }
4874
+ .nt-ln--interim {
4875
+ font-style: italic;
4876
+ }
4877
+ .nt-ln__meta {
4878
+ display: flex;
4879
+ align-items: baseline;
4880
+ gap: 8px;
4881
+ font-size: 11px;
4882
+ }
4883
+ .nt-spk {
4884
+ color: var(--foreground);
4885
+ font-weight: 500;
4886
+ }
4887
+ .nt-lab {
4888
+ color: var(--muted-foreground);
4889
+ text-transform: lowercase;
4890
+ }
4891
+ .nt-set {
4892
+ display: flex;
4893
+ flex-direction: column;
4894
+ gap: 8px;
4895
+ padding: 12px 0;
4896
+ border-bottom: 1px solid var(--border);
4897
+ }
4898
+ .nt-set__head {
4899
+ font-size: 14px;
4900
+ font-weight: 500;
4901
+ }
4902
+ .nt-list {
4903
+ display: flex;
4904
+ flex-direction: column;
4905
+ gap: 8px;
4906
+ max-width: 480px;
4907
+ }
4908
+ .nt-rows {
4909
+ display: flex;
4910
+ flex-direction: column;
4911
+ border-top: 1px solid var(--border);
4912
+ }
4913
+ .nt-row--current {
4914
+ background: var(--accent);
4915
+ }
4916
+ .nt-banner {
4917
+ font-size: 14px;
4918
+ line-height: 20px;
4919
+ color: var(--foreground);
4920
+ }
4921
+ .nt-banner--bad {
4922
+ color: var(--destructive);
4923
+ }
4924
+ .nt-md {
4925
+ font-size: 14px;
4926
+ line-height: 22px;
4927
+ }
4928
+ .nt-pre {
4929
+ white-space: pre-wrap;
4930
+ font-size: 14px;
4931
+ }
4932
+ @media (max-width: 760px) {
4933
+ .nt-body2 {
4934
+ grid-template-columns: 1fr;
4935
+ }
4936
+ .nt-notecol {
4937
+ padding-right: 0;
4938
+ }
4939
+ .nt-trans {
4940
+ padding-left: 0;
4941
+ padding-top: 16px;
4942
+ border-left: 0;
4943
+ border-top: 1px solid var(--border);
4944
+ }
4945
+ }
4946
+
4675
4947
  /* -- layouts · .lg- the legacy and fixture shells (Run 3, slice 3D) -- */
4676
4948
  /* The legacy and fixture cockpit's two columns: the list head (a stack) and the rail's tile column. */
4677
4949
  .lg-stack {
@@ -44,6 +44,8 @@ export const VIEWS = {
44
44
  addrepo: { label: 'add repository', mode: null, scoped: false },
45
45
  // jev-live-notes Run 6: confirm each talking point as it arrives
46
46
  livenotes: { label: 'live notes', mode: null, scoped: true, palette: true, hint: 'confirm talking points' },
47
+ // meeting-notes Run 1: typed notes, dictation, meeting transcripts and summaries; the PM's own, the same in every repository
48
+ notes: { label: 'notes', mode: null, scoped: false, palette: true, hint: 'type, dictate or record a meeting' },
47
49
  };
48
50
 
49
51
  /** The view a list mode opens as, or null when the mode is not a view. `product` is the list itself. */
@@ -93,6 +95,7 @@ function modes() {
93
95
  * every `data-*` hook the suites use, and only adds the board's frame around it. A view with no board module draws as before. */
94
96
  export const BOARD_MODULES = {
95
97
  today: './view-today.js', validate: './view-validate.js', addrepo: './view-addrepo.js', livenotes: './view-livenotes.js',
98
+ notes: './view-notes.js',
96
99
  };
97
100
  const boardMods = {}; // view -> undefined (not tried) · null (loading or absent) · module
98
101
  function boardOf(view) {