create-pmos 0.63.2 → 0.65.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.
@@ -8,8 +8,16 @@
8
8
  * record(root, { by, sightings, merges, now }) → { ok, recorded, duplicates, refused[], error? }
9
9
  * decide(root, { actor, body, now }) → { ok, row } | { ok:false, error } one PM page press
10
10
  * settings(root) → { ok, calendar_names, file }
11
+ * scan(root, { now, calendar, notesDir, ws }) → { ok, recorded, found } the names the workspace's own records hold
11
12
  *
12
13
  * Every write is an append through people.js's validators, so a refused row is never on disk. No network, no process.
14
+ *
15
+ * SCAN (the PM, 2026-10-06: "If you detect a person's name, add it to the people directory"). People no longer waits for
16
+ * an agent session alone. `scan` reads the names the workspace already holds and records each as a sighting by
17
+ * `workspace:scan`, which adds the person (people.js): plan item owners (`person:<name>`), the speakers of this
18
+ * repository's Live notes sessions, the renamed speakers and action owners of the PM's meeting notes for this repository,
19
+ * and, only while calendar names are on, the attendees this machine remembers from the calendar. A private Live notes
20
+ * session or a private note is skipped. A sighting already on disk is not written again, so a scan writes only what is new.
13
21
  */
14
22
  const crypto = require('crypto');
15
23
  const P = require('./people');
@@ -17,6 +25,8 @@ const fourPanel = require('./four-panel');
17
25
  const consoleState = require('./console-state');
18
26
 
19
27
  const MAX_PER_RECORD = 500;
28
+ const fs = require('fs');
29
+ const path = require('path');
20
30
 
21
31
  function stateDir(root) {
22
32
  const e = consoleState.loadEngine();
@@ -120,4 +130,103 @@ function decide(root, { actor, body, now = Date.now() } = {}) {
120
130
  return { ok: true, row: w.recorded, person };
121
131
  }
122
132
 
123
- module.exports = { view, record, decide, settings, stateDir, readRows };
133
+ /* ── scan: the names the workspace's own records hold ─────────────────────────────────────────────────────────────── */
134
+ // Not names: the notes' default speakers, the action owners that are not a person, and numbered speakers.
135
+ const NOT_A_NAME = /^(you|me|meeting|mic|speech|unknown|someone|everyone|the speaker|the team|team|we|speaker\s*\d*|participant\s*\d*|guest\s*\d*)$/i;
136
+ function personName(v) {
137
+ const n = String(v == null ? '' : v).replace(/\s+/g, ' ').trim().replace(/^@/, '');
138
+ if (n.length < 2 || n.length > 80 || !/\p{L}/u.test(n) || NOT_A_NAME.test(n)) return null;
139
+ return n;
140
+ }
141
+ const day = (v) => (typeof v === 'string' && /^\d{4}-\d{2}-\d{2}/.test(v) ? v.slice(0, 10) : null);
142
+ const lines = (file) => { try { return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch { return []; } };
143
+ /** "Ana Lee, Bo Chen and kim@x.io" → ["Ana Lee", "Bo Chen", "kim@x.io"]. */
144
+ const splitNames = (w) => String(w || '').split(/\s*(?:,|;|&|\band\b|\/)\s*/i).map((x) => x.replace(/\(.*?\)/g, '').trim()).filter(Boolean);
145
+
146
+ /** The sightings the workspace's records hold for this repository. Pure apart from reading files. */
147
+ function sightingsFrom(root, { items = [], calendar = [], calendarOn = false, notesDir = null, ws = null } = {}) {
148
+ const out = [];
149
+ const push = (source, kind, value, name, interaction) => {
150
+ const n = personName(name);
151
+ if (!n) return;
152
+ const s = { source, handle: { kind, value: String(value).slice(0, 120) }, name: n };
153
+ if (interaction && interaction.date) s.interaction = { ...interaction, text: interaction.text.slice(0, 200), pointer: interaction.pointer.slice(0, 200) };
154
+ out.push(s);
155
+ };
156
+ // plan item owners: the person's shared work already lists the items, so the sighting carries no interaction
157
+ for (const it of items || []) {
158
+ if (!it || it.state === 'withdrawn' || typeof it.owner !== 'string' || !it.owner.startsWith('person:')) continue;
159
+ const n = personName(it.owner.slice(7));
160
+ if (n) push('plan', 'name', n, n, null);
161
+ }
162
+ // Live notes sessions in this repository: one interaction per speaker per session
163
+ const lnDir = path.join(fs.existsSync(path.join(root, 'pmos', 'state')) ? path.join(root, 'pmos', 'state') : path.join(root, 'planning'), 'live-notes');
164
+ let files = []; try { files = fs.readdirSync(lnDir).filter((f) => f.endsWith('.jsonl')).sort(); } catch { /* no sessions */ }
165
+ for (const f of files) {
166
+ const rows = lines(path.join(lnDir, f));
167
+ if (rows.some((r) => r.row === 'private')) continue;
168
+ const session = f.replace(/\.jsonl$/, '');
169
+ const start = day((rows.find((r) => r.row === 'session') || {}).at) || day((rows[0] || {}).at);
170
+ const said = new Set();
171
+ for (const r of rows) {
172
+ const sp = r && r.pointer && personName(r.pointer.speaker);
173
+ if (!sp || said.has(sp.toLowerCase())) continue;
174
+ said.add(sp.toLowerCase());
175
+ push('live', 'speaker', sp, sp, { date: start || day(r.at), kind: 'Meeting', text: 'Spoke in a Live notes session', pointer: `live notes ${session}` });
176
+ }
177
+ }
178
+ // the PM's meeting notes for this repository: renamed speakers and the action owners the local summary reads
179
+ if (notesDir && ws) {
180
+ let nf = []; try { nf = fs.readdirSync(path.join(notesDir, 'notes')).filter((f) => f.endsWith('.json')); } catch { /* no notes */ }
181
+ let notesLib = null; try { notesLib = require('./notes'); } catch { /* notes unavailable */ }
182
+ for (const f of nf) {
183
+ let note = null; try { note = JSON.parse(fs.readFileSync(path.join(notesDir, 'notes', f), 'utf8')); } catch { continue; }
184
+ if (!note || note.private || note.ws !== ws || !note.id) continue;
185
+ const date = day(note.recorded_from) || day(note.created_at);
186
+ if (!date) continue;
187
+ const pointer = `note ${note.id}`;
188
+ for (const sp of Object.values(note.speakers || {})) push('notes', 'speaker', sp, sp, { date, kind: 'Meeting', text: 'Spoke in a meeting note', pointer });
189
+ if (notesLib) {
190
+ let actions = []; try { actions = (notesLib.summarize(note).sections || {}).actions || []; } catch { /* unreadable note */ }
191
+ const owners = new Set();
192
+ for (const a of actions) for (const o of String(a.owner || '').split(/\s+and\s+/)) { const n = personName(o); if (n) owners.add(n); }
193
+ for (const n of owners) push('notes', 'name', n, n, { date, kind: 'Meeting', text: 'Has an action in a meeting note', pointer });
194
+ }
195
+ }
196
+ }
197
+ // the calendar this machine remembers, only while calendar names are on (R3)
198
+ if (calendarOn) {
199
+ for (const e of calendar || []) {
200
+ const date = e && day(e.start);
201
+ if (!date || !e.with) continue;
202
+ for (const w of splitNames(e.with)) {
203
+ const mail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(w);
204
+ push('calendar', mail ? 'mail' : 'name', mail ? w.toLowerCase() : w, mail ? w.split('@')[0] : w,
205
+ { date, kind: 'Meeting', text: e.title ? `Meeting: ${e.title}` : 'Meeting', pointer: 'calendar' });
206
+ }
207
+ }
208
+ }
209
+ return out;
210
+ }
211
+
212
+ /** Record the names the workspace's records hold. Writes only sightings not already on disk. */
213
+ function scan(root, { now = Date.now(), calendar = [], notesDir = null, ws = null } = {}) {
214
+ const r = readRows(root);
215
+ if (!r.ok) return r;
216
+ const calendarOn = P.fold(r.rows).settings.calendar_names;
217
+ const found = sightingsFrom(root, { items: planItems(root), calendar, calendarOn, notesDir, ws });
218
+ const seen = new Set(r.rows.filter((x) => x && x.kind === 'sighting').map(sightKey));
219
+ let recorded = 0;
220
+ for (const s of found) {
221
+ const c = P.checkSighting(s);
222
+ if (!c.ok) continue;
223
+ const k = sightKey(c.row);
224
+ if (seen.has(k)) continue;
225
+ const w = fourPanel.appendDeclaration(r.dir, P.LEDGER, stamp(c.row, 'workspace:scan', now));
226
+ if (!w.ok) return { ok: false, error: w.error, recorded, found: found.length };
227
+ seen.add(k); recorded++;
228
+ }
229
+ return { ok: true, recorded, found: found.length };
230
+ }
231
+
232
+ module.exports = { view, record, decide, settings, scan, sightingsFrom, stateDir, readRows };
@@ -4,8 +4,9 @@
4
4
  *
5
5
  * checkSighting(s) → { ok, row } | { ok:false, error } one sighting an agent session records
6
6
  * checkMergeProposal(m) → { ok, row } | { ok:false, error }
7
- * checkDecision(body) → { ok, row } | { ok:false, error } one PM press (add, ignore, merge, keep-separate,
8
- * edit, tone, note, note-remove, setting)
7
+ * checkDecision(body) → { ok, row } | { ok:false, error } one PM press (add, ignore, remove, merge,
8
+ * keep-separate, edit, tone, note, note-remove,
9
+ * setting)
9
10
  * fold(rows, { now, items, meetings }) → { people, review, settings, counts }
10
11
  * handleKey(h) · slugOf(name) · toneProposal(interactions) · matchPeople(people, text)
11
12
  * SOURCES · HANDLE_KINDS · INTERACTION_KINDS · TONES · ACTS · LEDGER
@@ -13,21 +14,24 @@
13
14
  * Pure: no file, no clock unless given, no network. lib/people-store.js reads and appends; the route, the CLI and the
14
15
  * People place all answer from fold(), so the terminal and the page cannot hold two opinions about a person.
15
16
  *
16
- * WHO DECIDES (D90). A sighting row is anyone's: an agent session records it through `pmos people record`. Every
17
- * decision row (a person added, a handle ignored or merged, a tone, a note, the calendar setting) counts only when its
18
- * `by` is the PM (`pm:<slug>`), which only a page press writes. A decision row with any other `by` is skipped and
19
- * counted in counts.skipped, so a hand-appended line cannot make an agent's choice the PM's.
17
+ * WHO DECIDES (D90, as amended 2026-10-06). A sighting row is anyone's: an agent session records it through
18
+ * `pmos people record`, and the workspace records the names its own records hold (people-store.js scan). A sighting of
19
+ * a handle nobody holds ADDS that person: the PM asked for every detected name to be in People, not to wait in a list.
20
+ * A sighting whose name is a person's full name joins that person. Every decision row (a person added or removed, a
21
+ * handle ignored or merged, a tone, a note, the calendar setting) counts only when its `by` is the PM (`pm:<slug>`),
22
+ * which only a page press writes. A decision row with any other `by` is skipped and counted in counts.skipped, so a
23
+ * hand-appended line cannot make an agent's choice the PM's. Remove and Ignore keep a person's handles out for good.
20
24
  *
21
25
  * CALENDAR NAMES (R3). The setting is off until the PM turns it on. While it is off, the store refuses a calendar
22
26
  * sighting, and this fold also drops the calendar sightings recorded while it was on.
23
27
  */
24
28
 
25
29
  const LEDGER = 'people.jsonl';
26
- const SOURCES = Object.freeze(['gather', 'calendar', 'live', 'jira']);
30
+ const SOURCES = Object.freeze(['gather', 'calendar', 'live', 'jira', 'plan', 'notes']);
27
31
  const HANDLE_KINDS = Object.freeze(['mail', 'slack', 'jira', 'confluence', 'speaker', 'name']);
28
32
  const INTERACTION_KINDS = Object.freeze(['Mail', 'Chat', 'Meeting', '1:1', 'Jira', 'Doc', 'Call', 'Mention']);
29
33
  const TONES = Object.freeze(['warm', 'neutral', 'cautious', 'tense']);
30
- const ACTS = Object.freeze(['add', 'ignore', 'merge', 'keep-separate', 'edit', 'tone', 'note', 'note-remove', 'setting']);
34
+ const ACTS = Object.freeze(['add', 'ignore', 'remove', 'merge', 'keep-separate', 'edit', 'tone', 'note', 'note-remove', 'setting']);
31
35
  const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
32
36
  const ID_RE = /^[a-z0-9][a-z0-9-]{0,59}$/;
33
37
  const NOTE_ID_RE = /^n-[0-9a-z]{4,16}$/;
@@ -148,6 +152,7 @@ function checkDecision(body) {
148
152
  return { ok: true, row: { kind: 'keep-separate', a: a.value, b: b.value } };
149
153
  }
150
154
  if (!person()) return { ok: false, error: 'say which person' };
155
+ if (act === 'remove') return { ok: true, row: { kind: 'remove', person: person() } };
151
156
  if (act === 'edit') {
152
157
  const row = { kind: 'edit', person: person() };
153
158
  for (const [k, max] of [['name', 80], ['role', 120], ['org', 80]]) {
@@ -217,7 +222,7 @@ function matchPeople(people, hay) {
217
222
  const sightKey = (r) => [handleKey(r.handle), r.interaction ? r.interaction.date : '', r.interaction ? r.interaction.pointer : '', r.interaction ? r.interaction.text : ''].join('\u0000');
218
223
  const pairKey = (a, b) => [handleKey(a), handleKey(b)].sort().join('\u0000');
219
224
  const isPm = (r) => typeof r.by === 'string' && PM_BY_RE.test(r.by);
220
- const DECISION_KINDS = new Set(['add', 'ignore', 'merge', 'keep-separate', 'edit', 'tone', 'note', 'note-remove', 'setting']);
225
+ const DECISION_KINDS = new Set(['add', 'ignore', 'remove', 'merge', 'keep-separate', 'edit', 'tone', 'note', 'note-remove', 'setting']);
221
226
 
222
227
  /**
223
228
  * The one fold. `rows` are the ledger's rows in file order (torn lines already dropped). `items` are plan items (the
@@ -233,7 +238,7 @@ function fold(rows, { now = Date.now(), items = [], meetings = [] } = {}) {
233
238
  const sightings = [];
234
239
  const proposals = [];
235
240
 
236
- // 1 · decisions, in file order; the calendar setting first so the sightings below read the final value
241
+ // 1 · the calendar setting first, so every sighting below reads its final value
237
242
  for (const r of rows || []) {
238
243
  if (!isObj(r)) continue;
239
244
  counts.rows++;
@@ -249,11 +254,44 @@ function fold(rows, { now = Date.now(), items = [], meetings = [] } = {}) {
249
254
  const p = people.get(id);
250
255
  if (!p.handles.some((x) => handleKey(x) === k)) p.handles.push({ kind: h.kind, value: h.value });
251
256
  };
257
+ const create = (name, h, at) => {
258
+ let id = slugOf(name);
259
+ for (let n = 2; people.has(id); n++) id = `${slugOf(name).slice(0, 56)}-${n}`;
260
+ people.set(id, { id, name, role: null, org: null, external: null, handles: [], tone: null, notes: new Map(), addedAt: at || null, edited: {} });
261
+ attach(id, h);
262
+ return id;
263
+ };
264
+ // A sighting whose name is a person's full name is that person: "Ana Lee" from Jira and "Ana Lee" from a meeting. A
265
+ // bare first name from a weak handle (a name or a speaker, never a mail or chat handle) joins the one person with that
266
+ // first name; when two people share it, it joins nobody.
267
+ const byName = (name, kind) => {
268
+ const n = str(name).toLowerCase();
269
+ const live = [...people.values()].filter((p) => p.handles.length);
270
+ const full = live.find((p) => str(p.name).toLowerCase() === n);
271
+ if (full) return full.id;
272
+ if ((kind !== 'name' && kind !== 'speaker') || n.includes(' ')) return null;
273
+ const firsts = live.filter((p) => str(p.name).toLowerCase().split(' ')[0] === n);
274
+ return firsts.length === 1 ? firsts[0].id : null;
275
+ };
276
+ // One pass in file order, so a person exists before the PM's later presses about them.
277
+ const seen = new Set();
252
278
  for (const r of rows || []) {
253
279
  if (!isObj(r)) continue;
254
280
  if (r.kind === 'sighting') {
255
281
  const c = checkSighting(r);
256
- if (c.ok) sightings.push({ ...c.row, at: r.at || null });
282
+ if (!c.ok) continue;
283
+ const s = { ...c.row, at: r.at || null };
284
+ const key = sightKey(s);
285
+ if (seen.has(key)) { counts.duplicates++; continue; }
286
+ seen.add(key);
287
+ if (s.source === 'calendar' && !settings.calendar_names) { counts.hiddenCalendar++; continue; }
288
+ const k = handleKey(s.handle);
289
+ if (ignored.has(k)) continue;
290
+ if (!owner.has(k) || !people.has(owner.get(k))) {
291
+ const id = byName(s.name, s.handle.kind);
292
+ if (id) attach(id, s.handle); else create(s.name, s.handle, s.at);
293
+ }
294
+ sightings.push(s);
257
295
  continue;
258
296
  }
259
297
  if (r.kind === 'merge-proposal') {
@@ -268,20 +306,23 @@ function fold(rows, { now = Date.now(), items = [], meetings = [] } = {}) {
268
306
  if (d.kind === 'add') {
269
307
  const k = handleKey(d.handle);
270
308
  if (owner.has(k) && people.has(owner.get(k))) continue;
271
- let id = slugOf(d.name);
272
- for (let n = 2; people.has(id); n++) id = `${slugOf(d.name).slice(0, 56)}-${n}`;
273
- people.set(id, { id, name: d.name, role: null, org: null, external: null, handles: [], tone: null, notes: new Map(), addedAt: r.at || null, edited: {} });
274
- attach(id, d.handle);
309
+ create(d.name, d.handle, r.at);
275
310
  } else if (d.kind === 'ignore') {
276
311
  const k = handleKey(d.handle);
277
- if (!owner.has(k)) ignored.add(k);
312
+ const o = owner.get(k);
313
+ if (o && people.has(o)) { const pp = people.get(o); pp.handles = pp.handles.filter((x) => handleKey(x) !== k); }
314
+ owner.delete(k);
315
+ ignored.add(k);
278
316
  } else if (d.kind === 'merge') {
279
317
  if (people.has(d.person)) attach(d.person, d.handle);
280
318
  } else if (d.kind === 'keep-separate') {
281
319
  closed.add(pairKey(d.a, d.b));
282
320
  } else if (people.has(d.person)) {
283
321
  const p = people.get(d.person);
284
- if (d.kind === 'edit') {
322
+ if (d.kind === 'remove') {
323
+ for (const h of p.handles) { owner.delete(handleKey(h)); ignored.add(handleKey(h)); }
324
+ p.handles = [];
325
+ } else if (d.kind === 'edit') {
285
326
  for (const k of ['name', 'role', 'org', 'external']) if (d[k] !== undefined) { p[k] = d[k]; p.edited[k] = true; }
286
327
  } else if (d.kind === 'tone') p.tone = d.tone;
287
328
  else if (d.kind === 'note') p.notes.set(d.id, { id: d.id, text: d.text, at: r.at || null });
@@ -289,38 +330,21 @@ function fold(rows, { now = Date.now(), items = [], meetings = [] } = {}) {
289
330
  }
290
331
  }
291
332
 
292
- // 2 · sightings: dedup, drop calendar ones while the setting is off, then hand each to its person or the review list
293
- const seen = new Set();
294
- const fresh = new Map(); // handle key -> { handle, name, role, org, external, count, sources, last }
333
+ // 2 · each sighting's details go to the person who holds its handle at the end
295
334
  const interactions = new Map(); // person id -> []
335
+ const sourcesOf = new Map(); // person id -> Set of sources
296
336
  for (const s of sightings) {
297
- const key = sightKey(s);
298
- if (seen.has(key)) { counts.duplicates++; continue; }
299
- seen.add(key);
300
- if (s.source === 'calendar' && !settings.calendar_names) { counts.hiddenCalendar++; continue; }
301
- const k = handleKey(s.handle);
302
- const id = owner.get(k);
303
- if (id && people.has(id)) {
304
- const p = people.get(id);
305
- for (const f of ['role', 'org']) if (s[f] && !p.edited[f]) p[f] = s[f];
306
- if (s.external === true && !p.edited.external) p.external = true;
307
- if (s.interaction) {
308
- if (!interactions.has(id)) interactions.set(id, []);
309
- interactions.get(id).push({ ...s.interaction, source: s.source, handle: s.handle, at: s.at });
310
- }
311
- continue;
337
+ const id = owner.get(handleKey(s.handle));
338
+ if (!id || !people.has(id)) continue;
339
+ const p = people.get(id);
340
+ if (!sourcesOf.has(id)) sourcesOf.set(id, new Set());
341
+ sourcesOf.get(id).add(s.source);
342
+ for (const f of ['role', 'org']) if (s[f] && !p.edited[f]) p[f] = s[f];
343
+ if (s.external === true && !p.edited.external) p.external = true;
344
+ if (s.interaction) {
345
+ if (!interactions.has(id)) interactions.set(id, []);
346
+ interactions.get(id).push({ ...s.interaction, source: s.source, handle: s.handle, at: s.at });
312
347
  }
313
- if (ignored.has(k)) continue;
314
- const f = fresh.get(k) || { handle: s.handle, name: s.name, role: null, org: null, external: false, count: 0, sources: [], last: null };
315
- f.name = s.name;
316
- if (s.role) f.role = s.role;
317
- if (s.org) f.org = s.org;
318
- if (s.external) f.external = true;
319
- f.count++;
320
- if (!f.sources.includes(s.source)) f.sources.push(s.source);
321
- const d = s.interaction ? s.interaction.date : (s.at || '').slice(0, 10);
322
- if (d && (!f.last || d > f.last)) f.last = d;
323
- fresh.set(k, f);
324
348
  }
325
349
 
326
350
  // 3 · each person's page
@@ -340,6 +364,7 @@ function fold(rows, { now = Date.now(), items = [], meetings = [] } = {}) {
340
364
  id: p.id, name: p.name, role: p.role || null, org: p.org || null, external: p.external === true,
341
365
  group: p.external === true ? 'External' : 'Team',
342
366
  handles: p.handles.slice(),
367
+ sources: [...(sourcesOf.get(p.id) || [])],
343
368
  interactions: ints,
344
369
  tone: { proposed, confirmed: p.tone || null, mix, pending: !!proposed && !p.tone },
345
370
  notes: [...p.notes.values()],
@@ -367,7 +392,7 @@ function fold(rows, { now = Date.now(), items = [], meetings = [] } = {}) {
367
392
  }
368
393
  list.sort((a, b) => String(b.last || '').localeCompare(String(a.last || '')) || a.name.localeCompare(b.name));
369
394
 
370
- // 5 · the review list: new handles, open merge proposals, tones the PM has not confirmed
395
+ // 5 · the review list: open merge proposals and tones the PM has not confirmed (a new person is added, not listed)
371
396
  const merges = [];
372
397
  const shown = new Set();
373
398
  for (const m of proposals) {
@@ -375,7 +400,7 @@ function fold(rows, { now = Date.now(), items = [], meetings = [] } = {}) {
375
400
  if (closed.has(pk) || shown.has(pk)) continue;
376
401
  const oa = owner.get(handleKey(m.a)); const ob = owner.get(handleKey(m.b));
377
402
  if (oa && ob && oa === ob) continue;
378
- // Merge needs a person to join: the one holding a (or else b). Two handles nobody holds wait in "new" instead.
403
+ // Merge needs a person to join: the one holding a (or else b).
379
404
  const into = oa && people.has(oa) ? oa : ob && people.has(ob) ? ob : null;
380
405
  if (!into) continue;
381
406
  const other = into === oa ? m.b : m.a;
@@ -383,11 +408,10 @@ function fold(rows, { now = Date.now(), items = [], meetings = [] } = {}) {
383
408
  shown.add(pk);
384
409
  merges.push({ a: m.a, b: m.b, why: m.why, person: into, personName: people.get(into).name, handle: other });
385
410
  }
386
- const freshList = [...fresh.values()].sort((a, b) => String(b.last || '').localeCompare(String(a.last || '')) || b.count - a.count);
387
411
  const tones = list.filter((p) => p.tone.pending).map((p) => p.id);
388
412
  return {
389
413
  people: list,
390
- review: { new: freshList, merges, tones, count: freshList.length + merges.length + (tones.length ? 1 : 0) },
414
+ review: { merges, tones, count: merges.length + (tones.length ? 1 : 0) },
391
415
  settings,
392
416
  counts,
393
417
  };
@@ -1,5 +1,5 @@
1
1
  'use strict';
2
- /* routes-notes.js — the Notes view's routes (meeting-notes, Runs 1 and 2; lib/route-modules.js).
2
+ /* routes-notes.js — the Notes view's routes (meeting-notes, Runs 1 to 3; lib/route-modules.js).
3
3
  *
4
4
  * GET /api/notes → { ok, notes: [row] } newest first; a row never carries the body
5
5
  * GET /api/notes/one?id=… → { ok, note } imports an agent summary written since the request
@@ -11,9 +11,12 @@
11
11
  * POST /api/notes/audio { id, source, pcm, final? } → { ok, cut } 16 kHz mono 16-bit samples, base64; local Whisper (D91)
12
12
  * POST /api/notes/label { id, index, cat } → { ok, segment } the PM's own label for a line
13
13
  * POST /api/notes/speaker { id, source, name } → { ok, speakers }
14
+ * POST /api/notes/line { id, index, cat?, summary?, tags?, people?, text? } → { ok, segment, summary } the PM's correction (Run 3)
15
+ * GET /api/notes/recording?id=… → audio/wav, with byte ranges the note's kept audio (Run 3, D92)
16
+ * POST /api/notes/recording-delete { id } → { ok } deletes the note's audio and keeps the rest
14
17
  * GET /api/notes/engine?ws=… → { ok, whisper, jev } is Whisper set up; which Jev gates hold (booleans only)
15
18
  * POST /api/notes/switch { ws, jev?, check? } → { ok, jev } the two D91 switches, default off
16
- * { language?, model? } → { ok, whisper } the spoken language and the model, for this machine
19
+ * { language?, model?, keep_audio? } → { ok, whisper } the language, the model and keeping audio, for this machine
17
20
  * POST /api/notes/whisper-setup { ws? } → { ok, started, session, command } a terminal with the setup typed in, not run
18
21
  * POST /api/notes/private { id, private } → { ok, private }
19
22
  * POST /api/notes/summarize { id } → { ok, summary } the local summary
@@ -34,6 +37,7 @@ const guard = require('./pm-guard.js');
34
37
  const notes = require('./notes.js');
35
38
  const whisper = require('./notes-whisper.js');
36
39
  const notesRepo = require('./notes-repo.js');
40
+ const notesAudio = require('./notes-audio.js');
37
41
  const jevNotes = require('./jev-notes.js');
38
42
  const tsSettings = require('./typesafe-settings.js');
39
43
 
@@ -43,7 +47,7 @@ const CHECKED = new Set(['decision', 'action', 'statement']);
43
47
  const prefixes = ['/api/notes'];
44
48
  const POSTS = new Set(['/api/notes/new', '/api/notes/save', '/api/notes/dictate', '/api/notes/record', '/api/notes/transcript',
45
49
  '/api/notes/private', '/api/notes/summarize', '/api/notes/summarize-agent', '/api/notes/delete', '/api/notes/audio', '/api/notes/label',
46
- '/api/notes/speaker', '/api/notes/switch', '/api/notes/whisper-setup']);
50
+ '/api/notes/speaker', '/api/notes/switch', '/api/notes/whisper-setup', '/api/notes/line', '/api/notes/recording-delete']);
47
51
 
48
52
  /* ── labels and checks, one note at a time ───────────────────────────────────────────────────── */
49
53
  const chains = new Map(); // note id → the promise of its enrichment so far
@@ -109,7 +113,7 @@ function engineInfo(ctx, w) {
109
113
  const d = whisper.detect(ctx.runtimeDir);
110
114
  const out = { ready: d.ready, missing: d.missing, setup: d.setup, model_dir: d.model_dir, model_size: d.model_size,
111
115
  language: whisper.getLanguage(ctx.runtimeDir), languages: whisper.LANGUAGES.map(([value, label]) => ({ value, label })),
112
- model: whisper.getModel(ctx.runtimeDir), models: whisper.MODELS.map((m) => ({ value: m.id, label: m.label })) };
116
+ model: whisper.getModel(ctx.runtimeDir), models: whisper.MODELS.map((m) => ({ value: m.id, label: m.label })), keep_audio: notesAudio.getKeep(ctx.runtimeDir) };
113
117
  if (!w) return { whisper: out, jev: null };
114
118
  const repo = tsSettings.getRepo(ctx.runtimeDir, w.root);
115
119
  const sw = jevNotes.getSwitch(ctx.runtimeDir, w.root);
@@ -128,10 +132,18 @@ async function handle(req, res, url, ctx) {
128
132
  if (p === '/api/notes') { send(ctx, res, notes.list(dir)); return true; }
129
133
  const nid = url.searchParams.get('id');
130
134
  const r = notes.read(dir, nid);
131
- if (r.ok) r.audio = { pending: transcriber ? transcriber.pending() : 0, error: audioErrors.get(nid) || null };
135
+ if (r.ok) { r.audio = { pending: transcriber ? transcriber.pending() : 0, error: audioErrors.get(nid) || null }; r.recording = notesAudio.info(dir, nid); }
132
136
  send(ctx, res, r);
133
137
  return true;
134
138
  }
139
+ if (p === '/api/notes/recording' && (req.method === 'GET' || req.method === 'HEAD')) {
140
+ const nid = url.searchParams.get('id');
141
+ const r = notes.read(dir, nid);
142
+ if (!r.ok) { send(ctx, res, r); return true; }
143
+ if (r.note.private) { ctx.json(res, { ok: false, error: 'this note is private and keeps no audio' }, 404); return true; }
144
+ notesAudio.serve(req, res, dir, nid);
145
+ return true;
146
+ }
135
147
  if (p === '/api/notes/engine') {
136
148
  if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
137
149
  const wsId = url.searchParams.get('ws');
@@ -180,17 +192,29 @@ async function handle(req, res, url, ctx) {
180
192
  if (!whisper.detect(dir).ready) return done({ ok: false, status: 409, error: 'local transcription is not set up' });
181
193
  const ms = (pcm.length / 2 / whisper.RATE) * 1000;
182
194
  const t = Math.max(0, Date.now() - Date.parse(note.note.recorded_from) - ms);
195
+ // the audio is kept for replay unless the PM switched that off (D92); a private note never reaches this line
196
+ if (notesAudio.getKeep(dir)) { try { notesAudio.write(dir, { id, source, pcm, t }); } catch (e) { audioErrors.set(id, { at: new Date().toISOString(), error: 'the audio was not kept: ' + ((e && e.message) || e) }); } }
183
197
  return done(transcriberFor(ctx).push({ id, source, pcm, t, final: body.final === true }));
184
198
  }
185
199
  if (p === '/api/notes/label') return done(notes.setLabel(dir, { id, index: Number(body.index), cat: body.cat, drop: body.drop === true }));
186
200
  if (p === '/api/notes/speaker') return done(notes.renameSpeaker(dir, { id, source: body.source, name: body.name }));
201
+ if (p === '/api/notes/line') {
202
+ const pick = (k) => (body[k] === undefined ? undefined : body[k]);
203
+ return done(notes.editLine(dir, { id, index: Number(body.index), cat: pick('cat'), summary: pick('summary'), tags: pick('tags'), people: pick('people'), text: pick('text') }));
204
+ }
205
+ if (p === '/api/notes/recording-delete') {
206
+ const r = notes.read(dir, id);
207
+ if (!r.ok) return done(r);
208
+ return done({ ok: true, deleted: notesAudio.remove(dir, id) });
209
+ }
187
210
  if (p === '/api/notes/switch') {
188
211
  const o = wsOf();
189
212
  // the spoken language belongs to this machine, not to a repository
190
- if (body.language !== undefined || body.model !== undefined) {
213
+ if (body.language !== undefined || body.model !== undefined || body.keep_audio !== undefined) {
191
214
  const r = body.language !== undefined ? whisper.setLanguage(dir, String(body.language)) : { ok: true };
192
215
  const m = r.ok && body.model !== undefined ? whisper.setModel(dir, String(body.model)) : { ok: true };
193
- if (!r.ok || !m.ok) { ctx.json(res, { ok: false, error: r.error || m.error }, 400); return true; }
216
+ const k = r.ok && m.ok && body.keep_audio !== undefined ? notesAudio.setKeep(dir, body.keep_audio) : { ok: true };
217
+ if (!r.ok || !m.ok || !k.ok) { ctx.json(res, { ok: false, error: r.error || m.error || k.error }, 400); return true; }
194
218
  if (body.jev === undefined && body.check === undefined) { ctx.json(res, { ok: true, ...engineInfo(ctx, o.ok ? o.w : null) }); return true; }
195
219
  }
196
220
  if (!o.ok || !o.w) { ctx.json(res, { ok: false, error: 'This repository is not registered.' }, 404); return true; }
@@ -211,11 +235,11 @@ async function handle(req, res, url, ctx) {
211
235
  }
212
236
  if (p === '/api/notes/private') {
213
237
  const r = notes.setPrivate(dir, { id, private: body.private });
214
- if (r.ok && r.private && transcriber) transcriber.drop(id);
238
+ if (r.ok && r.private) { if (transcriber) transcriber.drop(id); notesAudio.remove(dir, id); }
215
239
  return done(r);
216
240
  }
217
241
  if (p === '/api/notes/summarize') return done(notes.localSummary(dir, { id }));
218
- if (p === '/api/notes/delete') { if (transcriber) transcriber.drop(id); return done(notes.remove(dir, id)); }
242
+ if (p === '/api/notes/delete') { if (transcriber) transcriber.drop(id); const r = notes.remove(dir, id); if (r.ok) notesAudio.remove(dir, id); return done(r); }
219
243
  // summarize-agent: the handoff file first (refused for a private note), then the PM's own agent session
220
244
  let w = null;
221
245
  if (body.ws != null) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.63.2",
3
+ "version": "0.65.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.63.2",
3
+ "version": "0.65.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",
@@ -4929,6 +4929,88 @@
4929
4929
  white-space: pre-wrap;
4930
4930
  font-size: 14px;
4931
4931
  }
4932
+ .nt-now {
4933
+ background: var(--accent);
4934
+ }
4935
+ .nt-ln.nt-now {
4936
+ color: var(--foreground);
4937
+ }
4938
+ .nt-tags {
4939
+ display: flex;
4940
+ flex-wrap: wrap;
4941
+ gap: 4px;
4942
+ margin-top: 4px;
4943
+ }
4944
+ .nt-tag {
4945
+ margin-left: 4px;
4946
+ padding: 0 4px;
4947
+ border: 1px solid var(--border);
4948
+ font-size: 11px;
4949
+ color: var(--muted-foreground);
4950
+ }
4951
+ .nt-tags .nt-tag {
4952
+ margin-left: 0;
4953
+ }
4954
+ .nt-edited {
4955
+ font-size: 11px;
4956
+ font-style: italic;
4957
+ color: var(--muted-foreground);
4958
+ }
4959
+ .nt-heard {
4960
+ display: flex;
4961
+ flex-direction: column;
4962
+ align-items: flex-start;
4963
+ gap: 4px;
4964
+ }
4965
+ .nt-ver {
4966
+ display: flex;
4967
+ flex-direction: column;
4968
+ align-items: flex-start;
4969
+ gap: 8px;
4970
+ padding: 12px 0;
4971
+ border-bottom: 1px solid var(--border);
4972
+ }
4973
+ .nt-diff {
4974
+ align-self: stretch;
4975
+ font-family: var(--mono);
4976
+ font-size: 12px;
4977
+ line-height: 18px;
4978
+ white-space: pre-wrap;
4979
+ }
4980
+ .nt-diff__ln--same {
4981
+ color: var(--muted-foreground);
4982
+ }
4983
+ .nt-diff__ln--add {
4984
+ color: var(--foreground);
4985
+ background: var(--accent);
4986
+ }
4987
+ .nt-diff__ln--del {
4988
+ color: var(--muted-foreground);
4989
+ text-decoration: line-through;
4990
+ }
4991
+ .nt-replay {
4992
+ display: flex;
4993
+ align-items: center;
4994
+ flex-wrap: wrap;
4995
+ gap: 12px;
4996
+ padding-bottom: 12px;
4997
+ border-bottom: 1px solid var(--border);
4998
+ }
4999
+ .nt-audio {
5000
+ flex: 1 1 320px;
5001
+ min-width: 0;
5002
+ height: 32px;
5003
+ }
5004
+ .nt-notelines {
5005
+ display: flex;
5006
+ flex-direction: column;
5007
+ min-height: 96px;
5008
+ font-size: 14px;
5009
+ line-height: 22px;
5010
+ }
5011
+ .nt-noteline {
5012
+ white-space: pre-wrap;
5013
+ }
4932
5014
  @media (max-width: 760px) {
4933
5015
  .nt-body2 {
4934
5016
  grid-template-columns: 1fr;