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.
@@ -6,14 +6,14 @@
6
6
  * only the body is rebuilt when its own data changed.
7
7
  *
8
8
  * What it shows, for the repository the tab names (or the open one):
9
- * - Waiting for you: new people agent sessions saw (Add to People · Ignore), possible duplicates (Merge · Keep
10
- * separate), and how many tones wait for a confirm;
9
+ * - Waiting for you: possible duplicates (Merge · Keep separate) and how many tones wait for a confirm. A new person
10
+ * is added, not listed here (the PM, 2026-10-06): Remove from People on their page keeps them out;
11
11
  * - the list: search over names, handles and initiatives, All · Team · External, sort by latest, most or name;
12
12
  * - the person's page: who, interactions with where each came from, tone (Confirm · Set another · Change), shared
13
13
  * work (initiatives, items they own with Blocked shown, coming meetings), notes (Add · Remove), and identity and
14
14
  * sources (handles, how many interactions each source gave, the file the person lives in).
15
- * Every press is a page press to POST /api/people/decide. Nothing here reads a source: agent sessions record what they
16
- * saw through `pmos people record`. A link from another place opens a person through the tab's slug (people-link.js).
15
+ * Every press is a page press to POST /api/people/decide. Nothing here reads a source: GET /api/people records the names
16
+ * the workspace's own records hold, and agent sessions record what they saw through `pmos people record`. A link from another place opens a person through the tab's slug (people-link.js).
17
17
  * In fixture mode it reads nothing.
18
18
  */
19
19
 
@@ -26,7 +26,7 @@ import { registerCommand, openDestination } from './registry.js';
26
26
  import { forgetPeople } from './people-link.js';
27
27
 
28
28
  const TONES = ['warm', 'neutral', 'cautious', 'tense'];
29
- const SOURCE = { gather: 'Gathered notes', calendar: 'Calendar', live: 'Live notes', jira: 'Jira' };
29
+ const SOURCE = { gather: 'Gathered notes', calendar: 'Calendar', live: 'Live notes', jira: 'Jira', plan: 'Plan items', notes: 'Meeting notes' };
30
30
  const HANDLE = { mail: 'Mail', slack: 'Chat', jira: 'Jira', confluence: 'Confluence', speaker: 'Speaker', name: 'Name' };
31
31
 
32
32
  export const COPY = Object.freeze({
@@ -37,16 +37,13 @@ export const COPY = Object.freeze({
37
37
  readError: 'People could not be read.',
38
38
  find: 'Find a person',
39
39
  waiting: 'Waiting for you',
40
- newPerson: (n, src) => `${n} sighting${n === 1 ? '' : 's'} · ${src}`,
41
- add: 'Add to People',
42
- ignore: 'Ignore',
43
40
  same: (a, b) => `${a} may be the same person as ${b}`,
44
41
  merge: 'Merge',
45
42
  keep: 'Keep separate',
46
43
  tonesWait: (n) => `${n} tone${n === 1 ? '' : 's'} to confirm. Open the person to confirm it.`,
47
44
  all: 'All', team: 'Team', external: 'External',
48
45
  latest: 'Latest', most: 'Most', name: 'Name',
49
- none: 'Nobody yet. Agent sessions that gather requirements, take live notes or sync Jira record the people they see. They wait here for you to add them.',
46
+ none: 'Nobody yet. People adds everyone it finds: the owners of plan items, the speakers in Live notes and your meeting notes, the calendar when calendar names are on, and the people agent sessions see when they gather requirements or sync Jira.',
50
47
  noMatch: (q) => `Nobody matches “${q}”.`,
51
48
  pick: 'Choose a person to see their page.',
52
49
  interactions: 'Interactions',
@@ -70,8 +67,9 @@ export const COPY = Object.freeze({
70
67
  remove: 'Remove',
71
68
  identity: 'Identity and sources',
72
69
  lives: (f) => `Lives in ${f}`,
73
- added: (n) => `${n} added to People.`,
74
- ignored: (n) => `${n} is ignored. Later sightings stay hidden.`,
70
+ removeFromPeople: 'Remove from People',
71
+ removed: (n) => `${n} is removed. Later sightings stay out.`,
72
+ foundIn: (s) => `Found in ${s}`,
75
73
  merged: 'Merged.',
76
74
  kept: 'Kept separate.',
77
75
  noteAdded: 'Note added.',
@@ -174,21 +172,6 @@ function review(inst) {
174
172
  const r = inst.view.review;
175
173
  if (!r.count) return null;
176
174
  const rows = [];
177
- for (const f of r.new) {
178
- const key = f.handle.kind + ':' + f.handle.value;
179
- rows.push(el('div', { class: 'rp-people-row', role: 'listitem', 'data-people-new': key },
180
- row({
181
- title: f.name,
182
- meta: [[f.role, f.org].filter(Boolean).join(' · ') || handleText(f.handle), COPY.newPerson(f.count, f.sources.map((s) => SOURCE[s] || s).join(', '))],
183
- trail: [
184
- button({ label: COPY.add, variant: 'primary', size: 'sm', attrs: { 'data-people-act': 'add', 'data-people-key': key },
185
- onClick: async () => { const x = await decide(inst, { act: 'add', handle: f.handle, name: f.name }, COPY.added(f.name)); if (x && x.person) { inst.open = x.person; draw(inst); } } }),
186
- button({ label: COPY.ignore, variant: 'ghost', size: 'sm', attrs: { 'data-people-act': 'ignore', 'data-people-key': key },
187
- onClick: () => decide(inst, { act: 'ignore', handle: f.handle }, COPY.ignored(f.name)) }),
188
- ],
189
- attrs: { title: handleText(f.handle) },
190
- })));
191
- }
192
175
  for (const m of r.merges) {
193
176
  const key = m.handle.kind + ':' + m.handle.value;
194
177
  rows.push(el('div', { class: 'rp-people-row', role: 'listitem', 'data-people-merge': key },
@@ -340,6 +323,7 @@ function identitySec(inst, p) {
340
323
  el('ul', { class: 'rp-people-list-plain' },
341
324
  p.handles.map((h) => el('li', { class: 'rp-people-handle', 'data-people-handle': h.kind + ':' + h.value }, handleText(h))),
342
325
  Object.entries(by).map(([s, n]) => el('li', { class: 'rp-people-hint' }, `${SOURCE[s] || s}: ${n} interaction${n === 1 ? '' : 's'}`))),
326
+ (p.sources || []).length ? el('p', { class: 'rp-people-hint', 'data-people-sources': p.sources.join(',') }, COPY.foundIn(p.sources.map((s) => SOURCE[s] || s).join(', '))) : null,
343
327
  el('p', { class: 'rp-people-hint rp-people-file' }, COPY.lives(file)),
344
328
  ], { 'data-people-sec': 'identity' });
345
329
  }
@@ -350,7 +334,9 @@ function personPane(inst) {
350
334
  return el('article', { class: 'rp-people-page', 'data-people-page': p.id },
351
335
  el('header', { class: 'rp-people-who' },
352
336
  el('h3', { class: 'rp-people-who__name' }, p.name),
353
- el('p', { class: 'rp-people-hint' }, [who(p), p.external && (p.role || p.org) ? COPY.external : null].filter(Boolean).join(' · '))),
337
+ el('p', { class: 'rp-people-hint' }, [who(p), p.external && (p.role || p.org) ? COPY.external : null].filter(Boolean).join(' · ')),
338
+ el('div', { class: 'rp-people-acts' }, button({ label: COPY.removeFromPeople, variant: 'ghost', size: 'sm', attrs: { 'data-people-act': 'remove' },
339
+ onClick: async () => { const r = await decide(inst, { act: 'remove', person: p.id }, COPY.removed(p.name)); if (r) { inst.open = null; draw(inst); } } }))),
354
340
  interactionsSec(p), toneSec(inst, p), sharedSec(p), notesSec(inst, p), identitySec(inst, p));
355
341
  }
356
342
 
@@ -1,4 +1,4 @@
1
- /* pm/view-notes.js — the Notes view tab: one meeting on one screen (meeting-notes, Runs 1 and 2).
1
+ /* pm/view-notes.js — the Notes view tab: one meeting on one screen (meeting-notes, Runs 1 to 3).
2
2
  *
3
3
  * The screen follows the simplified mockup the PM reviewed (prototype/meeting-notes):
4
4
  * header the title and the date, and one action: Listen; while listening a red dot, the time, the sources and Stop;
@@ -17,10 +17,17 @@
17
17
  * LABELS AND CHECKS. The server labels each line locally; behind the D91 gates Jev labels it and compares a captured line
18
18
  * with the closest repository passage. The page shows the result in words and reads it by polling the note.
19
19
  *
20
+ * CORRECT, TRACK AND REPLAY (Run 3, D92). The ellipsis on a transcript line opens its editor: the words (the words that
21
+ * were heard stay kept and the line shows "edited"), the label, whether it is in the summary, tags and people. Tags and
22
+ * people show on the line, on its item and under Tagged. Each save of the notes keeps the text before it, and History
23
+ * shows every version as a line diff, the original first. When the note kept its audio, Replay plays it; while it plays,
24
+ * the transcript line, its item and the note line written at that moment are highlighted, and a time in the transcript
25
+ * jumps there. Your notes are shown read-only while the meeting plays, so their lines can be highlighted.
26
+ *
20
27
  * Reads and writes (lib/routes-notes.js). Notes live in the per-user runtime directory, never in a repository:
21
- * GET /api/notes · /api/notes/one?id=… · /api/notes/engine?ws=…
28
+ * GET /api/notes · /api/notes/one?id=… · /api/notes/engine?ws=… · /api/notes/recording?id=… · /api/people?ws=…
22
29
  * POST /api/notes/new · save · dictate · record · transcript · audio · label · speaker · switch · whisper-setup · private
23
- * · summarize · summarize-agent · delete
30
+ * · summarize · summarize-agent · delete · line · recording-delete
24
31
  */
25
32
  import { el } from './dom.js';
26
33
  import { askConfirm, askText } from './dialogs.js';
@@ -49,6 +56,9 @@ let engine = null; // { ws, whisper, jev, at }
49
56
  let showList = false;
50
57
  let pollTimer = null; let agentTimer = null;
51
58
  let busy = '';
59
+ let player = null; // the replay: { id, bytes, el, playing }, kept across redraws so playback goes on
60
+ let people = null; // { ws, names } — the repository's people, offered in the line editor
61
+ const LABELS = [['decision', 'Decision'], ['action', 'Action'], ['question', 'Question'], ['fact', 'Fact'], ['topic', 'Topic'], ['statement', 'Statement'], ['small talk', 'Small talk']];
52
62
 
53
63
  const nowClock = (iso) => { try { return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } catch { return ''; } };
54
64
  const dayOf = (iso) => { try { return new Date(iso).toLocaleDateString([], { weekday: 'short', day: 'numeric', month: 'short' }); } catch { return ''; } };
@@ -107,6 +117,42 @@ export function caught(n) {
107
117
  return out;
108
118
  }
109
119
  const checkWords = (c) => (!c ? '' : c.verdict === 'differs' ? 'differs from ' + c.doc : c.verdict === 'agrees' ? 'consistent with ' + c.doc : 'see ' + c.doc);
120
+ /** " #tag @Person" for a line, or ''. Pure. */
121
+ export const tagWords = (x) => [...((x && x.tags) || []).map((t) => ' #' + t), ...((x && x.people) || []).map((p) => ' @' + p)].join('');
122
+ /** The lines with tags or people that are not already a caught item, in the order they were said. Pure. */
123
+ export function tagged(n) {
124
+ const c = caught(n);
125
+ const inItems = new Set(Object.values(c).flat().map((x) => x.index));
126
+ return (n.transcript || []).map((x, index) => ({ ...x, index }))
127
+ .filter((x) => x && x.text && !x.dropped && !inItems.has(x.index) && ((x.tags && x.tags.length) || (x.people && x.people.length)));
128
+ }
129
+ /** The index of the last item whose time is at or before `ms`, or -1. `times` is in meeting order. Pure. */
130
+ export function nowIndex(times, ms) {
131
+ let best = -1;
132
+ for (let i = 0; i < times.length; i++) if (typeof times[i] === 'number' && times[i] <= ms && (best < 0 || times[i] >= times[best])) best = i;
133
+ return best;
134
+ }
135
+ /** The note line written at meeting time `ms`: of the lines that have a time at or before it, the latest. Pure. */
136
+ export function noteLineAt(body, lineTimes, ms) {
137
+ const lines = String(body || '').split(/\r?\n/);
138
+ return nowIndex(lines.map((l) => (l.trim() && lineTimes ? lineTimes[l.trim()] : undefined)), ms);
139
+ }
140
+ /** A line diff from `a` to `b`: [{ op: 'same'|'del'|'add', text }]. Longest common subsequence of lines. Pure. */
141
+ export function lineDiff(a, b) {
142
+ const x = String(a || '').split(/\r?\n/); const y = String(b || '').split(/\r?\n/);
143
+ if (x.length * y.length > 250000) return [...x.map((t) => ({ op: 'del', text: t })), ...y.map((t) => ({ op: 'add', text: t }))];
144
+ const L = Array.from({ length: x.length + 1 }, () => new Array(y.length + 1).fill(0));
145
+ for (let i = x.length - 1; i >= 0; i--) for (let j = y.length - 1; j >= 0; j--) L[i][j] = x[i] === y[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1]);
146
+ const out = []; let i = 0; let j = 0;
147
+ while (i < x.length && j < y.length) {
148
+ if (x[i] === y[j]) { out.push({ op: 'same', text: x[i] }); i++; j++; }
149
+ else if (L[i + 1][j] >= L[i][j + 1]) out.push({ op: 'del', text: x[i++] });
150
+ else out.push({ op: 'add', text: y[j++] });
151
+ }
152
+ while (i < x.length) out.push({ op: 'del', text: x[i++] });
153
+ while (j < y.length) out.push({ op: 'add', text: y[j++] });
154
+ return out;
155
+ }
110
156
  /** The whole note as Markdown, for "Copy as Markdown". Pure, for the suite. */
111
157
  export function noteMarkdown(n) {
112
158
  const out = ['# ' + (n.title || 'Untitled note'), ''];
@@ -118,9 +164,11 @@ export function noteMarkdown(n) {
118
164
  for (const [k, title] of [...SECTIONS, ['fact', 'Facts mentioned']]) {
119
165
  if (!c[k].length) continue;
120
166
  out.push('## ' + title, '', ...c[k].map((x) => '- ' + (k === 'action' && x.source && x.source !== 'speech' ? speakerOf(n, x.source) + ': ' : '') + x.text
121
- + (x.check && x.check.verdict !== 'related' ? ' (' + checkWords(x.check) + ')' : '') + ' [' + fmtTime(x.t) + ']'), '');
167
+ + tagWords(x) + (x.check && x.check.verdict !== 'related' ? ' (' + checkWords(x.check) + ')' : '') + ' [' + fmtTime(x.t) + ']'), '');
122
168
  }
123
- if ((n.transcript || []).length) out.push('## Transcript', '', ...n.transcript.map((x) => '[' + fmtTime(x.t) + '] ' + speakerOf(n, x.source) + ': ' + x.text), '');
169
+ const tg = tagged(n);
170
+ if (tg.length) out.push('## Tagged', '', ...tg.map((x) => '- ' + x.text + tagWords(x) + ' [' + fmtTime(x.t) + ']'), '');
171
+ if ((n.transcript || []).length) out.push('## Transcript', '', ...n.transcript.map((x) => '[' + fmtTime(x.t) + '] ' + speakerOf(n, x.source) + ': ' + x.text + tagWords(x)), '');
124
172
  return out.join('\n');
125
173
  }
126
174
  /** Float samples at `inRate` as 16 kHz 16-bit samples. Each output sample averages the input samples it covers. Pure. */
@@ -151,7 +199,7 @@ async function loadList(ctx) {
151
199
  if (!currentId && list.notes.length) currentId = list.notes[0].id;
152
200
  ctx.setState({});
153
201
  }
154
- const sig = (n) => JSON.stringify([n.transcript, n.summary && n.summary.at, n.agent && n.agent.markdown, n.private, n.speakers, n.title]);
202
+ const sig = (n) => JSON.stringify([n.transcript, n.summary && n.summary.at, n.agent && n.agent.markdown, n.private, n.speakers, n.title, (n.versions || []).length, n.__recording && n.__recording.bytes]);
155
203
  async function loadNote(ctx, id) {
156
204
  const r = await pmGet('/api/notes/one?id=' + encodeURIComponent(id));
157
205
  if (r && r.ok) {
@@ -159,6 +207,7 @@ async function loadNote(ctx, id) {
159
207
  // what the PM is typing wins over what the server last saved
160
208
  if (editor && editor.id === id && dirty) { r.note.title = editor.title.value; r.note.body = editor.body.value; }
161
209
  r.note.__audio = r.audio || null;
210
+ r.note.__recording = r.recording || null;
162
211
  cache.set(id, r.note);
163
212
  if (!had || sig(had) !== sig(r.note)) ctx.setState({});
164
213
  } else if (r && r.status === 404) { cache.delete(id); if (currentId === id) currentId = null; loadList(ctx); }
@@ -210,6 +259,8 @@ async function flushSave(ctx) {
210
259
  if (r && r.ok) {
211
260
  savedAt = r.updated_at; saveError = null;
212
261
  const n = cache.get(id); if (n) { n.title = title; n.body = body; n.updated_at = r.updated_at; touchRow(n); }
262
+ // a new version was kept: read it, so History shows it
263
+ if (n && typeof r.versions === 'number' && r.versions !== (n.versions || []).length) loadNote(ctx, id);
213
264
  } else { dirty = true; saveError = (r && r.error) || 'not saved'; }
214
265
  if (dirty && !saveError) scheduleSave(ctx); // typed while the save was in flight
215
266
  paintSaveState();
@@ -237,14 +288,14 @@ async function selectNote(ctx, id) {
237
288
  showList = false;
238
289
  if (id === currentId) { ctx.setState({}); return; }
239
290
  await stopAll(ctx); await flushSave(ctx);
240
- currentId = id; editor = null; savedAt = null; saveError = null; banner = null;
291
+ currentId = id; editor = null; savedAt = null; saveError = null; banner = null; dropPlayer();
241
292
  ctx.setState({});
242
293
  loadNote(ctx, id);
243
294
  }
244
295
  async function deleteNote(ctx, n) {
245
296
  const sure = await askConfirm({ title: 'Delete this note?', body: '"' + (n.title || 'Untitled note') + '" and its transcript and summaries are deleted from this machine. This cannot be undone.', confirmLabel: 'Delete note', danger: true });
246
297
  if (!sure) return;
247
- await stopAll(ctx, { discard: true }); clearTimeout(saveTimer); dirty = false;
298
+ await stopAll(ctx, { discard: true }); clearTimeout(saveTimer); dirty = false; dropPlayer();
248
299
  const r = await pmPost('/api/notes/delete', { id: n.id });
249
300
  if (r && r.ok) { cache.delete(n.id); currentId = null; editor = null; if (list) list.notes = (list.notes || []).filter((x) => x.id !== n.id); if (list && list.notes.length) currentId = list.notes[0].id; banner = { tone: 'ok', text: 'The note was deleted.' }; }
250
301
  else banner = { tone: 'bad', text: (r && r.error) || 'the note was not deleted' };
@@ -254,7 +305,7 @@ async function setPrivate(ctx, n, priv) {
254
305
  // a private press drops what is still buffered: nothing more is sent, transcribed or summarized
255
306
  if (priv) await stopAll(ctx, { discard: true });
256
307
  const r = await pmPost('/api/notes/private', { id: n.id, private: priv });
257
- if (r && r.ok) { n.private = r.private; touchRow(n); banner = priv ? { tone: 'ok', text: 'Marked private. Listening stopped, and nothing from this note goes to Jev or to any agent.' } : null; }
308
+ if (r && r.ok) { n.private = r.private; if (priv) { n.__recording = null; dropPlayer(); } touchRow(n); banner = priv ? { tone: 'ok', text: 'Marked private. Listening stopped, its audio was deleted, and nothing from this note goes to Jev or to any agent.' } : null; }
258
309
  else banner = { tone: 'bad', text: (r && r.error) || 'not changed' };
259
310
  ctx.setState({});
260
311
  }
@@ -310,6 +361,133 @@ async function renameSpeaker(ctx, n, source) {
310
361
  ctx.setState({});
311
362
  }
312
363
 
364
+ /* ── corrections, history and replay (Run 3, D92) ─────────────────────────────────────────────────── */
365
+ async function peopleNames(ctx) {
366
+ const ws = wsOf(ctx);
367
+ if (people && people.ws === ws) return people.names;
368
+ const r = await pmGet('/api/people' + (ws ? '?ws=' + encodeURIComponent(ws) : ''));
369
+ const names = r && r.ok && Array.isArray(r.people) ? [...new Set(r.people.map((x) => x && x.name).filter(Boolean))].slice(0, 24) : [];
370
+ people = { ws, names };
371
+ return names;
372
+ }
373
+ const splitTags = (v) => String(v || '').split(/[\s,]+/).map((t) => t.replace(/^#+/, '')).filter(Boolean);
374
+ const splitPeople = (v) => String(v || '').split(',').map((t) => t.replace(/^\s*@/, '').trim()).filter(Boolean);
375
+ /** The editor for one transcript line: its words, label, place in the summary, tags and people. */
376
+ async function editLine(ctx, n, index) {
377
+ const x = (n.transcript || [])[index];
378
+ if (!x) return;
379
+ const text = field({ label: 'Words', kind: 'textarea', value: x.text, attrs: { 'data-nt-ed-text': '' } });
380
+ const cat = field({ label: 'Label', kind: 'select', value: LABELS.some(([v]) => v === x.cat) ? x.cat : 'statement', options: LABELS.map(([value, label]) => ({ value, label })), attrs: { 'data-nt-ed-cat': '' } });
381
+ const sumv = x.in_summary === true ? 'in' : x.in_summary === false ? 'out' : 'auto';
382
+ const sum = field({ label: 'Summary', kind: 'select', value: sumv, options: [{ value: 'auto', label: 'As picked' }, { value: 'in', label: 'Always in the summary' }, { value: 'out', label: 'Never in the summary' }], attrs: { 'data-nt-ed-summary': '' } });
383
+ const tags = field({ label: 'Tags', value: (x.tags || []).map((t) => '#' + t).join(' '), placeholder: '#pricing #trial', attrs: { 'data-nt-ed-tags': '' } });
384
+ const ppl = field({ label: 'People', value: (x.people || []).join(', '), placeholder: 'Names, separated by commas', attrs: { 'data-nt-ed-people': '' } });
385
+ const sugg = el('div', { class: 'nt-row', 'data-nt-ed-suggest': '' });
386
+ const addPerson = (name) => {
387
+ const have = splitPeople(ppl.control.value);
388
+ if (!have.some((h) => h.toLowerCase() === name.toLowerCase())) ppl.control.value = [...have, name].join(', ');
389
+ };
390
+ const heard = x.orig_text !== undefined
391
+ ? el('div', { class: 'nt-heard', 'data-nt-ed-orig': '' }, el('p', { class: 'nt-hint' }, 'Heard as: ' + x.orig_text),
392
+ button({ label: 'Use what was heard', variant: 'ghost', size: 'sm', icon: 'undo-2', onClick: () => { text.control.value = x.orig_text; }, attrs: { 'data-nt-ed-restore': '' } }))
393
+ : null;
394
+ let h = null;
395
+ const save = async () => {
396
+ const patch = { id: n.id, index, tags: splitTags(tags.control.value), people: splitPeople(ppl.control.value) };
397
+ const t = text.control.value.replace(/\s+/g, ' ').trim();
398
+ if (t !== x.text) patch.text = t;
399
+ if (cat.control.value !== x.cat) patch.cat = cat.control.value;
400
+ if (sum.control.value !== sumv) patch.summary = sum.control.value === 'in' ? true : sum.control.value === 'out' ? false : null;
401
+ const r = await pmPost('/api/notes/line', patch);
402
+ if (r && r.ok) { n.transcript[index] = r.segment; if (r.summary) n.summary = r.summary; banner = null; h.close(); }
403
+ else banner = { tone: 'bad', text: (r && r.error) || 'the line was not changed' };
404
+ ctx.setState({});
405
+ };
406
+ h = sheet({ title: 'Correct this line', sub: speakerOf(n, x.source) + ', ' + fmtTime(x.t), body: [text, heard, cat, sum, tags, ppl, sugg], attrs: { 'data-nt-line-sheet': '' },
407
+ actions: [button({ label: 'Cancel', variant: 'ghost', onClick: () => h.close() }), button({ label: 'Save', variant: 'primary', onClick: save, attrs: { 'data-nt-ed-save': '' } })] });
408
+ const names = await peopleNames(ctx);
409
+ if (h.open && names.length) sugg.replaceChildren(...names.map((name) => button({ label: name, icon: 'plus', variant: 'ghost', size: 'sm', onClick: () => addPerson(name), attrs: { 'data-nt-ed-person': name } })));
410
+ }
411
+ async function unsummarize(ctx, n, index) {
412
+ const r = await pmPost('/api/notes/line', { id: n.id, index, summary: false });
413
+ if (r && r.ok) { n.transcript[index] = r.segment; if (r.summary) n.summary = r.summary; } else banner = { tone: 'bad', text: (r && r.error) || 'not changed' };
414
+ ctx.setState({});
415
+ }
416
+ /** Every kept version of the notes, newest first, each as the lines it changed from the one before. */
417
+ function openHistory(ctx, n) {
418
+ const vs = n.versions || [];
419
+ const now = { at: n.updated_at, title: editor && editor.id === n.id ? editor.title.value : n.title, body: editor && editor.id === n.id ? editor.body.value : n.body, current: true };
420
+ const all = [...vs, now];
421
+ const when = (iso) => dayOf(iso) + ', ' + nowClock(iso);
422
+ let h = null;
423
+ const restore = (v) => {
424
+ h.close();
425
+ if (!editor || editor.id !== n.id) return;
426
+ editor.title.value = v.title || ''; editor.body.value = v.body || '';
427
+ banner = { tone: 'ok', text: 'The version from ' + when(v.at) + ' is back in your notes. The text before it is kept in History.' };
428
+ scheduleSave(ctx); ctx.setState({});
429
+ };
430
+ const items = all.map((v, i) => {
431
+ const diff = i === 0 ? String(v.body || '').split(/\r?\n/).map((t) => ({ op: 'same', text: t })) : lineDiff(all[i - 1].body, v.body);
432
+ const head = v.current ? 'Now' : i === 0 ? 'Original, ' + when(v.at) : 'Version ' + (i + 1) + ', ' + when(v.at);
433
+ return el('div', { class: 'nt-ver', 'data-nt-version': v.current ? 'now' : String(i) },
434
+ el('div', { class: 'nt-set__head' }, head, i > 0 && all[i - 1].title !== v.title ? el('span', { class: 'nt-hint' }, ' · title: ' + (v.title || 'Untitled')) : null),
435
+ el('div', { class: 'nt-diff' }, diff.map((d) => el('div', { class: 'nt-diff__ln nt-diff__ln--' + d.op, 'data-nt-diff': d.op },
436
+ el('span', { class: 'nt-sr' }, d.op === 'add' ? 'added: ' : d.op === 'del' ? 'removed: ' : ''), d.text || ' '))),
437
+ v.current ? null : button({ label: 'Use this version', variant: 'ghost', size: 'sm', icon: 'undo-2', onClick: () => restore(v), attrs: { 'data-nt-restore': String(i) } }));
438
+ }).reverse();
439
+ h = sheet({ title: 'History of your notes', sub: vs.length + ' earlier version' + (vs.length === 1 ? '' : 's') + '. The original is kept.', body: items, attrs: { 'data-nt-history-sheet': '' } });
440
+ }
441
+ function dropPlayer() { if (player) { try { player.el.pause(); } catch { /* gone */ } player = null; } }
442
+ /** The replay of the note's kept audio. One <audio> per note, kept across redraws so playback goes on. */
443
+ function playerFor(ctx, n) {
444
+ const rec = n.__recording;
445
+ if (!rec || n.private) { if (player && player.id === n.id) dropPlayer(); return null; }
446
+ if (player && (player.id !== n.id || (player.bytes !== rec.bytes && !player.playing))) dropPlayer();
447
+ if (player) return player;
448
+ const a = el('audio', { controls: true, preload: 'metadata', class: 'nt-audio', 'data-nt-audio': '', src: '/api/notes/recording?id=' + encodeURIComponent(n.id) + '&b=' + rec.bytes });
449
+ const p = { id: n.id, bytes: rec.bytes, el: a, playing: false };
450
+ a.addEventListener('play', () => { p.playing = true; ctx.setState({}); });
451
+ a.addEventListener('pause', () => { p.playing = false; ctx.setState({}); });
452
+ a.addEventListener('ended', () => { p.playing = false; ctx.setState({}); });
453
+ a.addEventListener('timeupdate', paintNow);
454
+ a.addEventListener('seeked', paintNow);
455
+ player = p;
456
+ return p;
457
+ }
458
+ function seek(ctx, n, ms) {
459
+ const p = playerFor(ctx, n);
460
+ if (!p) return;
461
+ try { p.el.currentTime = Math.max(0, ms / 1000); p.el.play().catch(() => {}); } catch { /* not loaded yet */ }
462
+ paintNow();
463
+ }
464
+ /** Highlight the transcript line, its item and the note line at the replay's time. Classes only: no redraw. */
465
+ function paintNow() {
466
+ if (typeof document === 'undefined') return;
467
+ for (const x of document.querySelectorAll('.nt-now')) x.classList.remove('nt-now');
468
+ const n = currentId ? cache.get(currentId) : null;
469
+ if (!player || !n || player.id !== n.id) return;
470
+ const ms = player.el.currentTime * 1000;
471
+ if (!player.playing && !(ms > 0)) return;
472
+ const i = nowIndex((n.transcript || []).map((x) => x.t), ms);
473
+ if (i >= 0) {
474
+ for (const x of document.querySelectorAll('[data-nt-seg="' + i + '"], [data-nt-item="' + i + '"], [data-nt-ov="' + i + '"]')) x.classList.add('nt-now');
475
+ const ln = document.querySelector('[data-nt-seg="' + i + '"]');
476
+ if (ln && player.playing && ln.scrollIntoView) ln.scrollIntoView({ block: 'nearest' });
477
+ }
478
+ const k = noteLineAt(editor && editor.id === n.id ? editor.body.value : n.body, n.line_times, ms);
479
+ if (k >= 0) { const nl = document.querySelector('[data-nt-noteline="' + k + '"]'); if (nl) nl.classList.add('nt-now'); }
480
+ }
481
+ async function deleteAudio(ctx, n) {
482
+ const sure = await askConfirm({ title: 'Delete this note\'s audio?', body: 'The recording of "' + (n.title || 'Untitled note') + '" is deleted from this machine. The notes and the transcript stay. This cannot be undone.', confirmLabel: 'Delete audio', danger: true });
483
+ if (!sure) return;
484
+ if (player && player.id === n.id) dropPlayer();
485
+ const r = await pmPost('/api/notes/recording-delete', { id: n.id });
486
+ if (r && r.ok) { n.__recording = null; banner = { tone: 'ok', text: 'The audio was deleted. The notes and the transcript are kept.' }; }
487
+ else banner = { tone: 'bad', text: (r && r.error) || 'the audio was not deleted' };
488
+ ctx.setState({});
489
+ }
490
+
313
491
  /* ── listening with local Whisper ─────────────────────────────────────────────────────────────── */
314
492
  async function openSource(kind) {
315
493
  const md = navigator.mediaDevices;
@@ -491,7 +669,9 @@ async function chooseSources(ctx, n) {
491
669
  const meet = field({ label: 'The meeting', kind: 'check', value: local, attrs: { 'data-nt-src-meeting': '', disabled: !local } });
492
670
  const body = [mic, meet,
493
671
  el('p', { class: 'nt-hint' }, local ? 'Pick the meeting\'s tab or window next, and tick "Share tab audio".' : 'The meeting can be heard once local transcription is set up in Settings. Until then the microphone is recognised by the browser on this device.'),
494
- el('p', { class: 'nt-consent', 'data-nt-consent': '' }, 'Speech is transcribed on this ' + (local ? 'machine' : 'device') + '. Let people know you\'re taking notes.')];
672
+ el('p', { class: 'nt-consent', 'data-nt-consent': '' }, local
673
+ ? 'Speech is transcribed on this machine' + (eng.whisper.keep_audio === false ? ', and no audio is kept' : ', and the audio is kept on this machine so you can replay the meeting') + '. Let people know you\'re taking notes.'
674
+ : 'Speech is recognised by the browser on this device, and no audio is kept. Let people know you\'re taking notes.')];
495
675
  let h = null;
496
676
  const go = () => {
497
677
  const want = [mic.control.checked && 'mic', local && meet.control.checked && 'meeting'].filter(Boolean);
@@ -504,7 +684,7 @@ async function chooseSources(ctx, n) {
504
684
  }
505
685
  async function setSwitch(ctx, patch) {
506
686
  const ws = wsOf(ctx);
507
- if (!ws && patch.language === undefined && patch.model === undefined) return;
687
+ if (!ws && patch.language === undefined && patch.model === undefined && patch.keep_audio === undefined) return;
508
688
  const r = await pmPost('/api/notes/switch', ws ? { ws, ...patch } : patch);
509
689
  if (r && r.ok) engine = { ws, whisper: r.whisper, jev: r.jev, at: Date.now() };
510
690
  ctx.setState({});
@@ -531,7 +711,12 @@ function openSettings(ctx, n) {
531
711
  onCommit: (v) => setSwitch(ctx, { model: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-model': '' } }) : null,
532
712
  w && !w.ready ? button({ label: 'Set up in a terminal', onClick: () => { h.close(); whisperSetup(ctx); }, attrs: { 'data-nt-whisper-setup': '' } }) : null,
533
713
  w && w.ready && w.languages ? field({ label: 'Language spoken in the meeting', kind: 'select', value: w.language, options: w.languages,
534
- onCommit: (v) => setSwitch(ctx, { language: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-language': '' } }) : null));
714
+ onCommit: (v) => setSwitch(ctx, { language: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-language': '' } }) : null,
715
+ w ? field({ label: 'Keep the audio for replay', kind: 'check', value: w.keep_audio !== false,
716
+ onCommit: (v) => setSwitch(ctx, { keep_audio: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-keep-audio': '' } }) : null,
717
+ w ? el('p', { class: 'nt-hint', 'data-nt-keep-hint': '' }, w.keep_audio !== false
718
+ ? 'The meeting audio is kept on this machine, outside every repository, until you delete it. It is never sent anywhere. Browser recognition keeps no audio.'
719
+ : 'No audio is kept: each clip is deleted once it is transcribed, and a meeting cannot be replayed.') : null));
535
720
  if (!wsOf(ctx)) parts.push(el('p', { class: 'nt-hint' }, 'Choose a repository to check notes against it.'));
536
721
  else {
537
722
  const jsw = field({ label: 'Label lines with Jev', kind: 'check', value: !!(j && j.jev), onCommit: (v) => setSwitch(ctx, { jev: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-sw-jev': '' } });
@@ -546,6 +731,7 @@ function openSettings(ctx, n) {
546
731
  el('div', { class: 'nt-row' },
547
732
  button({ label: n.private ? 'Mark not private' : 'Mark private', onClick: () => { h.close(); setPrivate(ctx, n, !n.private); }, attrs: { [n.private ? 'data-nt-unprivate' : 'data-nt-mark-private']: '' } }),
548
733
  button({ label: busy === 'agent' ? 'Starting …' : 'Summary by agent', disabled: n.private || busy === 'agent', onClick: () => { h.close(); summarizeAgent(ctx, n); }, attrs: { 'data-nt-summarize-agent': '' } }),
734
+ n.__recording ? button({ label: 'Delete this note\'s audio', variant: 'ghost', onClick: () => { h.close(); deleteAudio(ctx, n); }, attrs: { 'data-nt-delete-audio': '' } }) : null,
549
735
  button({ label: 'Delete', variant: 'ghost', onClick: () => { h.close(); deleteNote(ctx, n); }, attrs: { 'data-nt-delete': '' } }))));
550
736
  return parts;
551
737
  };
@@ -584,7 +770,9 @@ function listening(n) {
584
770
  function header(ctx, n, ed) {
585
771
  const live = listening(n);
586
772
  const when = el('span', { class: 'nt-when' }, dayOf(n.created_at) + ', ' + nowClock(n.created_at));
587
- const left = el('div', { class: 'nt-headl' }, ed.titleWrap, when, el('span', { class: 'nt-saved', 'data-nt-saved': '' }, saveWords()));
773
+ const nv = (n.versions || []).length;
774
+ const left = el('div', { class: 'nt-headl' }, ed.titleWrap, when, el('span', { class: 'nt-saved', 'data-nt-saved': '' }, saveWords()),
775
+ nv ? button({ label: 'Edited', icon: 'history', variant: 'ghost', size: 'sm', onClick: () => openHistory(ctx, n), attrs: { 'data-nt-history': String(nv), title: nv + ' earlier version' + (nv === 1 ? '' : 's') + ' kept' } }) : null);
588
776
  const acts = [];
589
777
  if (live) {
590
778
  acts.push(el('span', { class: 'nt-live', 'data-nt-listening': live.kind, role: 'status' },
@@ -622,6 +810,11 @@ function checkMark(x) {
622
810
  if (c.verdict === 'agrees') return el('span', { class: 'nt-ok', 'data-nt-check': 'agrees', title: 'Jev rates it ' + (c.words || 'likely') + ' that ' + where + ' already says this', role: 'img', 'aria-label': 'consistent with ' + baseName(c.doc) }, icon('check'));
623
811
  return el('span', { class: 'nt-rel', 'data-nt-check': 'related', title: 'The closest passage in this repository: ' + where }, 'see ' + baseName(c.doc));
624
812
  }
813
+ function chips(x) {
814
+ return [...(x.tags || []).map((t) => el('span', { class: 'nt-tag', 'data-nt-tag': t }, '#' + t)),
815
+ ...(x.people || []).map((p) => el('span', { class: 'nt-tag nt-tag--person', 'data-nt-person': p }, '@' + p))];
816
+ }
817
+ const editedMark = (x) => (x.orig_text !== undefined ? el('span', { class: 'nt-edited', 'data-nt-edited': '', title: 'Heard as: ' + x.orig_text }, 'edited') : null);
625
818
  function sectionOf(ctx, n, key, title, items) {
626
819
  if (!items.length) return null;
627
820
  return el('section', { class: 'nt-sec', 'data-nt-sec': key },
@@ -629,9 +822,9 @@ function sectionOf(ctx, n, key, title, items) {
629
822
  el('ul', { class: 'nt-items' }, items.map((x) => el('li', { class: 'nt-item', 'data-nt-item': String(x.index) },
630
823
  el('span', { class: 'nt-item__text' },
631
824
  key === 'action' && x.source && x.source !== 'speech' ? el('span', { class: 'nt-who' }, speakerOf(n, x.source) + ': ') : null,
632
- x.text, ' ', checkMark(x)),
825
+ x.text, ' ', checkMark(x), ' ', editedMark(x), chips(x)),
633
826
  el('span', { class: 'nt-at' }, fmtTime(x.t)),
634
- key === 'fact' ? null : iconButton({ icon: 'x', ariaLabel: 'Remove from ' + title, onClick: () => unpick(ctx, n, x), size: 'sm', attrs: { 'data-nt-unpick': String(x.index) } })))));
827
+ key === 'fact' || key === 'tagged' ? null : iconButton({ icon: 'x', ariaLabel: 'Remove from ' + title, onClick: () => unpick(ctx, n, x), size: 'sm', attrs: { 'data-nt-unpick': String(x.index) } })))));
635
828
  }
636
829
  function summarySection(ctx, n) {
637
830
  const a = n.agent;
@@ -646,13 +839,19 @@ function summarySection(ctx, n) {
646
839
  else if (a && a.started === false && a.reason) out.push(el('p', { class: 'nt-hint', 'data-nt-agent-failed': '' }, 'No agent session started: ' + a.reason + '.'));
647
840
  if (!ov || !ov.length) return out.length ? el('section', { class: 'nt-sec' }, out) : null;
648
841
  return el('section', { class: 'nt-sec', 'data-nt-summary': 'local' }, el('h3', { class: 'nt-sec__h' }, 'Summary'),
649
- el('ul', { class: 'nt-items' }, ov.map((x) => el('li', { class: 'nt-item nt-item--plain' }, el('span', { class: 'nt-item__text' }, x.text)))),
842
+ el('ul', { class: 'nt-items' }, ov.map((x) => el('li', { class: 'nt-item nt-item--plain', 'data-nt-ov': x.index === undefined ? null : String(x.index) }, el('span', { class: 'nt-item__text' }, x.text),
843
+ x.index === undefined ? null : iconButton({ icon: 'x', ariaLabel: 'Take out of the summary', onClick: () => unsummarize(ctx, n, x.index), size: 'sm', attrs: { 'data-nt-unsum': String(x.index) } })))),
650
844
  el('p', { class: 'nt-hint' }, 'Picked from what was said, on this machine.'), ...out);
651
845
  }
652
846
  function noteColumn(ctx, n, ed) {
653
847
  const live = listening(n);
654
848
  const c = caught(n);
655
- const parts = [summarySection(ctx, n), ed.bodyWrap];
849
+ const replaying = player && player.id === n.id && player.playing;
850
+ const notes = replaying
851
+ ? el('div', { class: 'nt-notelines', 'data-nt-notelines': '' }, String(ed.body.value || '').split(/\r?\n/).map((l, k) => el('div', { class: 'nt-noteline', 'data-nt-noteline': String(k) }, l || ' ')),
852
+ el('p', { class: 'nt-hint' }, 'Your notes, as the meeting plays. Pause to edit them.'))
853
+ : ed.bodyWrap;
854
+ const parts = [summarySection(ctx, n), notes];
656
855
  const sp = speech && speech.id === n.id ? speech : null;
657
856
  const bar = [];
658
857
  if (sp && sp.mode === 'dictate' && sp.state === 'on') bar.push(el('span', { class: 'nt-hint', 'data-nt-listening': 'dictate', role: 'status' }, 'Dictating into your notes.'), button({ label: 'Stop dictating', variant: 'ghost', onClick: () => { stopSpeech(); ctx.setState({}); }, attrs: { 'data-nt-stop-dictate': '' } }));
@@ -663,6 +862,7 @@ function noteColumn(ctx, n, ed) {
663
862
  if (n.__audio && n.__audio.error && live) parts.push(el('p', { class: 'nt-hint nt-hint--bad', 'data-nt-audio-error': '' }, n.__audio.error));
664
863
  const secs = SECTIONS.map(([k, t]) => sectionOf(ctx, n, k, t, c[k])).filter(Boolean);
665
864
  if (!live && (n.transcript || []).length) { const f = sectionOf(ctx, n, 'fact', 'Facts mentioned', c.fact); if (f) secs.push(f); }
865
+ const tg = sectionOf(ctx, n, 'tagged', 'Tagged', tagged(n)); if (tg) secs.push(tg);
666
866
  if (secs.length) parts.push(...secs);
667
867
  else parts.push(el('p', { class: 'nt-hint', 'data-nt-caught-hint': '' }, live ? 'Listening …' : (n.transcript || []).length ? 'Nothing was caught as a decision, action or question.'
668
868
  : 'Decisions, actions and questions appear here as they are said, each checked against this repository.'));
@@ -677,8 +877,13 @@ function transcriptColumn(ctx, n) {
677
877
  const spk = x.source === 'meeting'
678
878
  ? button({ label: who, variant: 'ghost', size: 'sm', onClick: () => renameSpeaker(ctx, n, 'meeting'), attrs: { 'data-nt-spk': 'meeting', title: 'Name this speaker' } })
679
879
  : el('span', { class: 'nt-spk', 'data-nt-spk': x.source || 'speech' }, who);
680
- return el('div', { class: 'nt-ln' + (['decision', 'action', 'question'].includes(x.cat) ? ' nt-ln--picked' : ''), 'data-nt-seg': String(i) },
681
- el('div', { class: 'nt-ln__meta' }, spk, el('span', { class: 'nt-at' }, fmtTime(x.t)), lab), el('div', null, x.text));
880
+ const at = n.__recording && !live && typeof x.t === 'number'
881
+ ? button({ label: fmtTime(x.t), variant: 'ghost', size: 'sm', onClick: () => seek(ctx, n, x.t), attrs: { 'data-nt-seek': String(i), title: 'Play from here' } })
882
+ : el('span', { class: 'nt-at' }, fmtTime(x.t));
883
+ return el('div', { class: 'nt-ln' + (['decision', 'action', 'question', 'fact'].includes(x.cat) ? ' nt-ln--picked' : ''), 'data-nt-seg': String(i) },
884
+ el('div', { class: 'nt-ln__meta' }, spk, at, lab, editedMark(x),
885
+ iconButton({ icon: 'ellipsis', ariaLabel: 'Correct this line', onClick: () => editLine(ctx, n, i), size: 'sm', attrs: { 'data-nt-edit': String(i) } })),
886
+ el('div', null, x.text), x.tags || x.people ? el('div', { class: 'nt-tags' }, chips(x)) : null);
682
887
  });
683
888
  const sp = speech && speech.id === n.id && speech.mode === 'record' && speech.state === 'on' ? speech : null;
684
889
  if (sp) lines.push(el('div', { class: 'nt-ln nt-ln--interim', 'data-nt-interim': '', 'aria-live': 'polite' }, sp.interim || ''));
@@ -716,7 +921,11 @@ export function renderBoard(inner, ctx) {
716
921
  wrap.appendChild(header(ctx, n, ed));
717
922
  if (showList) wrap.appendChild(listPane(ctx));
718
923
  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.'));
924
+ const pl = !listening(n) ? playerFor(ctx, n) : null;
925
+ if (pl) wrap.appendChild(el('div', { class: 'nt-replay', 'data-nt-part': 'replay' }, el('span', { class: 'nt-sec__h' }, 'Replay'), pl.el,
926
+ el('span', { class: 'nt-hint' }, 'Kept on this machine. Press a time in the transcript to play from there.')));
719
927
  wrap.appendChild(el('div', { class: 'nt-body2' }, noteColumn(ctx, n, ed), transcriptColumn(ctx, n)));
928
+ if (pl) queueMicrotask(paintNow);
720
929
  if (n.agent && n.agent.started && !n.agent.markdown && !agentTimer) pollAgent(ctx, n.id);
721
930
  requestAnimationFrame(fitBody);
722
931
  }
@@ -6623,17 +6623,20 @@ async function handleRequest(req, res) {
6623
6623
  return json(res, { ok: started, session: s.id, error: started ? undefined : (s.note || 'could not start the session') }, started ? 200 : 500);
6624
6624
  }
6625
6625
  // ── People: the people the PM works with in a repository (people-directory, PRD R9 to R14, proposed D90) ──────────
6626
- // GET folds people.jsonl with the plan items and the coming meetings this machine remembers (the calendar memory and
6627
- // today's brief). POST is one PM page press: add, ignore, merge, keep separate, edit, tone, a note, or the calendar
6628
- // setting. Agent sessions record sightings through `pmos people record`; no route here reads a source.
6626
+ // GET first records the names this machine's own records hold (people-store scan: plan item owners, Live notes
6627
+ // speakers, the PM's meeting notes for this repository, and the remembered calendar while calendar names are on), then
6628
+ // folds people.jsonl with the plan items and the coming meetings. POST is one PM page press: add, remove, ignore,
6629
+ // merge, keep separate, edit, tone, a note, or the calendar setting. Agent sessions record what they read from mail,
6630
+ // chat and Jira through `pmos people record`; no route here reads a connector.
6629
6631
  if (p === '/api/people' && req.method === 'GET') {
6630
6632
  const w = getWs(url.searchParams.get('ws'));
6631
6633
  const root = w ? w.root : SELF_ROOT;
6632
6634
  const now = Date.now();
6633
6635
  const mem = readCalMemory();
6634
6636
  const brief = briefOfDay(briefLib.localDate(now));
6635
- const meetings = [...((mem && mem.events) || []), ...((brief && brief.calendar) || [])]
6636
- .filter((e) => e && e.start && Date.parse(e.end || e.start) >= now);
6637
+ const calendar = [...((mem && mem.events) || []), ...((brief && brief.calendar) || [])];
6638
+ peopleStore.scan(root, { now, calendar, notesDir: STATE, ws: w ? w.id : null });
6639
+ const meetings = calendar.filter((e) => e && e.start && Date.parse(e.end || e.start) >= now);
6637
6640
  const v = peopleStore.view(root, { now, meetings });
6638
6641
  if (!v.ok) return json(res, v, 500);
6639
6642
  return json(res, { ...v, ws: w ? w.id : null });
@@ -4,7 +4,7 @@ label: "Gather requirements"
4
4
  description: "Read what changed in the PM's mail, chat, Jira, Confluence, calendar and meeting notes since the last note, and file one dated note of requirements — each with its source, how sure it is, and which initiative it touches."
5
5
  run: agent
6
6
  agent: claude
7
- version: 1.2.0
7
+ version: 1.3.0
8
8
  owner: wawan
9
9
  risk: low
10
10
  scope: read:connectors, read:state, write:state/requirements, write:state/people
@@ -55,7 +55,7 @@ Procedure (each step ends with a check that shows it is done):
55
55
  Done when: the file starts with this frontmatter and holds the four sections in order.
56
56
  7. Under "Sources reached and not reached", list every source kind you tried, with `reached` or `not reached — <why>`, and the number of private items skipped.
57
57
  Done when: every source kind you tried has a line, and the private count is stated.
58
- 8. Record the people in People. For each author of an item you filed (other than the PM), write one sighting: {"source": "gather", "handle": {"kind": "mail" | "slack" | "jira" | "confluence", "value": "<their address or name in that source>"}, "name": "<display name>", "role"?: "<their role, when the source states it>", "org"?: "<their organisation, when it is not the PM's>", "external"?: true (when they are outside the PM's company), "interaction": {"date": "YYYY-MM-DD", "kind": "Mail" | "Chat" | "Jira" | "Doc" | "Meeting", "text": "<what they asked or said, in your words, at most 200 characters>", "pointer": "<the item's pointer>", "tone": "warm|neutral|cautious|tense" (only when the exchange shows one), "initiative": "<the item's initiative id, or omit when unmatched>"}}. Tone describes the exchange, not the person. When two handles look like one person (the same name in mail and in chat), add a merge {"a": <handle>, "b": <handle>, "why": "<one line>"}. Put them in one file {"sightings": [...], "merges": [...]} in the scratch folder and run `pmos people record --file <that file>` once. Contact details beyond the handle are never recorded.
58
+ 8. Record the people in People. For each author of an item you filed, and each other person an item names (an owner, a stakeholder, a decision maker), other than the PM, write one sighting (for a person an item only names, the interaction kind is "Mention"): {"source": "gather", "handle": {"kind": "mail" | "slack" | "jira" | "confluence", "value": "<their address or name in that source>"}, "name": "<display name>", "role"?: "<their role, when the source states it>", "org"?: "<their organisation, when it is not the PM's>", "external"?: true (when they are outside the PM's company), "interaction": {"date": "YYYY-MM-DD", "kind": "Mail" | "Chat" | "Jira" | "Doc" | "Meeting", "text": "<what they asked or said, in your words, at most 200 characters>", "pointer": "<the item's pointer>", "tone": "warm|neutral|cautious|tense" (only when the exchange shows one), "initiative": "<the item's initiative id, or omit when unmatched>"}}. Tone describes the exchange, not the person. When two handles look like one person (the same name in mail and in chat), add a merge {"a": <handle>, "b": <handle>, "why": "<one line>"}. Put them in one file {"sightings": [...], "merges": [...]} in the scratch folder and run `pmos people record --file <that file>` once. Contact details beyond the handle are never recorded.
59
59
  Done when: the command printed how many sightings it recorded, or you said no item had an author.
60
60
  9. Finish by saying in one line how many items you filed and where, and how many people you recorded. Do not commit; the PM's Sync carries the file.
61
61
  Done when: the count and the path are said once, and no commit was made.
@@ -31,7 +31,7 @@ Procedure:
31
31
  - `url`: the issue's browse link, `https://<site>.atlassian.net/browse/<KEY>`.
32
32
  The shape Jira returned (`{"issues": [{"key", "self", "fields": {...}}]}`) is also accepted.
33
33
  4. Run `pmos jira sync <initiative> --from <the file> --query "<the JQL>"`. Then delete the file.
34
- 5. Record the people in People. For each assignee and reporter other than the PM, write one sighting per issue: {"source": "jira", "handle": {"kind": "jira", "value": "<their Jira display name>"}, "name": "<display name>", "interaction": {"date": "<the issue's last update, YYYY-MM-DD>", "kind": "Jira", "text": "<Assignee|Reporter> of <KEY>: <summary, at most 150 characters>", "pointer": "jira · <KEY> · <url>", "initiative": "<the initiative id>"}}. Put all of them in one file {"sightings": [...]} in the scratch folder, run `pmos people record --file <that file>` once, and delete the file. An issue with no assignee gives no assignee sighting.
34
+ 5. Record the people in People. For each assignee and reporter other than the PM, write one sighting per issue (People adds each new person at once): {"source": "jira", "handle": {"kind": "jira", "value": "<their Jira display name>"}, "name": "<display name>", "interaction": {"date": "<the issue's last update, YYYY-MM-DD>", "kind": "Jira", "text": "<Assignee|Reporter> of <KEY>: <summary, at most 150 characters>", "pointer": "jira · <KEY> · <url>", "initiative": "<the initiative id>"}}. Put all of them in one file {"sightings": [...]} in the scratch folder, run `pmos people record --file <that file>` once, and delete the file. An issue with no assignee gives no assignee sighting.
35
35
  6. Finish with the three sections of the output contract, one short line each:
36
36
  - Checked: the initiative, the number of tickets read, and each change (status, sprint, blocked). If nothing changed, say "no change". Add how many people you recorded.
37
37
  - Not reached: each initiative whose search failed, with the reason.
@@ -5,7 +5,7 @@ description: "Take the talking points of a conversation, place each against the
5
5
  run: agent
6
6
  agent: claude
7
7
  tier: balanced
8
- version: 1.1.0
8
+ version: 1.2.0
9
9
  owner: wawan
10
10
  risk: low
11
11
  scope: read:connectors, read:state, write:state/live-notes
@@ -48,7 +48,7 @@ Procedure (each step ends with a check that shows it is done):
48
48
  Done when: you have shown the list and recorded nothing the PM did not say.
49
49
  6. When the PM says the conversation is over, run `node workspace/bin/live-notes.js close --repo . --session <id>`. It appends a section to today's requirements note and deletes the raw transcript.
50
50
  Done when: the command printed the note path and "transcript: deleted", or the retention date the PM set.
51
- 7. Record the speakers in People. For each named speaker other than the PM, write one sighting: {"source": "live", "handle": {"kind": "speaker", "value": "<the speaker's name as the transcript gives it>"}, "name": "<display name>", "interaction": {"date": "YYYY-MM-DD", "kind": "Meeting" (or "1:1" when only the PM and this speaker talk), "text": "<what this speaker raised, in your words, at most 200 characters>", "pointer": "<wispr:meeting id, or the segments file> · <date>", "tone": "warm|neutral|cautious|tense" (only when the exchange shows one), "initiative": "<the initiative id their points were placed on, or omit>"}}. Tone describes the exchange, not the person. Never copy what the speaker said. Put the sightings in one file {"sightings": [...]} in the scratch folder and run `pmos people record --file <that file>` once. A speaker with no name ("Speaker 2") is not recorded.
51
+ 7. Record the speakers in People. For each named speaker, and each person the meeting names as the owner of an action, other than the PM, write one sighting (for a named owner, the handle kind is "name" and the interaction kind is "Mention"): {"source": "live", "handle": {"kind": "speaker", "value": "<the speaker's name as the transcript gives it>"}, "name": "<display name>", "interaction": {"date": "YYYY-MM-DD", "kind": "Meeting" (or "1:1" when only the PM and this speaker talk), "text": "<what this speaker raised, in your words, at most 200 characters>", "pointer": "<wispr:meeting id, or the segments file> · <date>", "tone": "warm|neutral|cautious|tense" (only when the exchange shows one), "initiative": "<the initiative id their points were placed on, or omit>"}}. Tone describes the exchange, not the person. Never copy what the speaker said. Put the sightings in one file {"sightings": [...]} in the scratch folder and run `pmos people record --file <that file>` once. A speaker with no name ("Speaker 2") is not recorded.
52
52
  Done when: the command printed how many sightings it recorded, or you said no speaker was named.
53
53
  8. Finish by saying in two lines how many points were placed, how many are still unanswered, which path ran, how many people you recorded, and which sources were not reached. Do not commit; the PM's Sync carries the files.
54
54
  Done when: the two lines are said once and no commit was made.