create-pmos 0.61.1 → 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.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/first-paint-audit.js +4 -2
  7. package/workspace/guide/index.md +6 -3
  8. package/workspace/guide/layout.md +5 -4
  9. package/workspace/guide/notes.md +72 -0
  10. package/workspace/guide/people.md +70 -0
  11. package/workspace/lib/calendar-memory.js +2 -1
  12. package/workspace/lib/jev-notes.js +162 -0
  13. package/workspace/lib/notes-repo.js +133 -0
  14. package/workspace/lib/notes-whisper.js +200 -0
  15. package/workspace/lib/notes.js +511 -0
  16. package/workspace/lib/people-cli.js +135 -0
  17. package/workspace/lib/people-store.js +123 -0
  18. package/workspace/lib/people.js +399 -0
  19. package/workspace/lib/pmos-verbs.js +3 -0
  20. package/workspace/lib/route-modules.js +1 -0
  21. package/workspace/lib/routes-notes.js +230 -0
  22. package/workspace/package-lock.json +1 -1
  23. package/workspace/package.json +1 -1
  24. package/workspace/public/pm/app.js +2 -2
  25. package/workspace/public/pm/components.css +441 -0
  26. package/workspace/public/pm/dest-people.js +385 -0
  27. package/workspace/public/pm/dest-settings.js +44 -3
  28. package/workspace/public/pm/frame-copy.js +7 -0
  29. package/workspace/public/pm/initiative-sheet.js +5 -2
  30. package/workspace/public/pm/initiatives-copy.js +1 -0
  31. package/workspace/public/pm/item-sheet.js +18 -1
  32. package/workspace/public/pm/panel-cockpit.js +3 -0
  33. package/workspace/public/pm/people-link.js +106 -0
  34. package/workspace/public/pm/registry.js +2 -0
  35. package/workspace/public/pm/today-agenda.js +3 -1
  36. package/workspace/public/pm/today-day.js +2 -0
  37. package/workspace/public/pm/topbar.js +4 -3
  38. package/workspace/public/pm/view-notes.js +723 -0
  39. package/workspace/replaced-tests.json +14 -0
  40. package/workspace/server.js +30 -1
  41. package/workspace/skills/gather-requirements.skill +5 -3
  42. package/workspace/skills/jira-sync.skill +5 -4
  43. package/workspace/skills/live-notes.skill +3 -1
  44. package/workspace/skills/morning-brief.skill +5 -3
  45. package/workspace/test-lists/meeting-notes.json +15 -0
  46. package/workspace/test-lists/people-directory.json +6 -0
@@ -0,0 +1,385 @@
1
+ /* pm/dest-people.js — the People destination: the people the PM works with in a repository, with their interactions,
2
+ * tone, notes and shared work (initiative people-directory, PRD R5 to R11, proposed D90).
3
+ *
4
+ * A DESTINATION MODULE (registry.js): mountDestination(host, ctx) on every render with the same host, disposeDestination(host).
5
+ * It keeps ONE persistent root per host; the header (title and search) is built once, so typing never loses focus, and
6
+ * only the body is rebuilt when its own data changed.
7
+ *
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;
11
+ * - the list: search over names, handles and initiatives, All · Team · External, sort by latest, most or name;
12
+ * - the person's page: who, interactions with where each came from, tone (Confirm · Set another · Change), shared
13
+ * work (initiatives, items they own with Blocked shown, coming meetings), notes (Add · Remove), and identity and
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).
17
+ * In fixture mode it reads nothing.
18
+ */
19
+
20
+ import { el } from './dom.js';
21
+ import { button, row, field, toast, empty, tabs, badge, menu, ensureComponentsCss } from './primitives.js';
22
+ import { ensureSprite } from './icons.js';
23
+ import { pmGet, pmPost } from './http.js';
24
+ import { wsOf } from './data.js';
25
+ import { registerCommand, openDestination } from './registry.js';
26
+ import { forgetPeople } from './people-link.js';
27
+
28
+ const TONES = ['warm', 'neutral', 'cautious', 'tense'];
29
+ const SOURCE = { gather: 'Gathered notes', calendar: 'Calendar', live: 'Live notes', jira: 'Jira' };
30
+ const HANDLE = { mail: 'Mail', slack: 'Chat', jira: 'Jira', confluence: 'Confluence', speaker: 'Speaker', name: 'Name' };
31
+
32
+ export const COPY = Object.freeze({
33
+ title: 'People',
34
+ fixture: 'People shows the people of a repository in a real session.',
35
+ noRepo: 'Open a repository to see its people.',
36
+ loading: 'Reading…',
37
+ readError: 'People could not be read.',
38
+ find: 'Find a person',
39
+ waiting: 'Waiting for you',
40
+ newPerson: (n, src) => `${n} sighting${n === 1 ? '' : 's'} · ${src}`,
41
+ add: 'Add to People',
42
+ ignore: 'Ignore',
43
+ same: (a, b) => `${a} may be the same person as ${b}`,
44
+ merge: 'Merge',
45
+ keep: 'Keep separate',
46
+ tonesWait: (n) => `${n} tone${n === 1 ? '' : 's'} to confirm. Open the person to confirm it.`,
47
+ all: 'All', team: 'Team', external: 'External',
48
+ 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.',
50
+ noMatch: (q) => `Nobody matches “${q}”.`,
51
+ pick: 'Choose a person to see their page.',
52
+ interactions: 'Interactions',
53
+ noInteractions: 'No interaction recorded yet.',
54
+ tone: 'Tone',
55
+ noTone: 'No interaction has a tone yet.',
56
+ proposed: (t) => `Proposed: ${t}, from the latest interactions`,
57
+ confirmed: (t) => `Confirmed: ${t}`,
58
+ confirm: 'Confirm',
59
+ setAnother: 'Set another',
60
+ change: 'Change',
61
+ shared: 'Shared work',
62
+ noShared: 'No shared initiative, item or meeting yet.',
63
+ owns: 'Owns',
64
+ blocked: 'Blocked',
65
+ meeting: 'Meeting',
66
+ notes: 'Notes',
67
+ noNotes: 'No note yet. Only you can add one.',
68
+ addNote: 'Add note',
69
+ notePlaceholder: 'Something to remember about working with this person',
70
+ remove: 'Remove',
71
+ identity: 'Identity and sources',
72
+ lives: (f) => `Lives in ${f}`,
73
+ added: (n) => `${n} added to People.`,
74
+ ignored: (n) => `${n} is ignored. Later sightings stay hidden.`,
75
+ merged: 'Merged.',
76
+ kept: 'Kept separate.',
77
+ noteAdded: 'Note added.',
78
+ noteRemoved: 'Note removed.',
79
+ toneSaved: 'Tone saved.',
80
+ });
81
+
82
+ registerCommand({ id: 'people-open', label: 'People', hint: 'The people you work with in this repository', cli: 'pmos people list', run: () => openDestination('people') });
83
+
84
+ /* ── words ───────────────────────────────────────────────────────────────────────────────────────────────────────── */
85
+
86
+ /** "Oct 1" for a YYYY-MM-DD date; "today" and "yesterday" for the two nearest. */
87
+ function day(d) {
88
+ if (!d) return '';
89
+ const t = new Date(d + 'T12:00:00');
90
+ if (Number.isNaN(t.getTime())) return d;
91
+ const today = new Date(); today.setHours(12, 0, 0, 0);
92
+ const n = Math.round((today - t) / 86400000);
93
+ if (n === 0) return 'today';
94
+ if (n === 1) return 'yesterday';
95
+ return t.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
96
+ }
97
+ const toneOf = (p) => p.tone.confirmed || p.tone.proposed || null;
98
+ const handleText = (h) => `${HANDLE[h.kind] || h.kind}: ${h.value}`;
99
+ const who = (p) => [p.role, p.org].filter(Boolean).join(' · ') || p.group;
100
+
101
+ /* ── mount ───────────────────────────────────────────────────────────────────────────────────────────────────────── */
102
+
103
+ const insts = new WeakMap();
104
+ const REREAD_MS = 5000;
105
+
106
+ export function mountDestination(host, ctx) {
107
+ let inst = insts.get(host);
108
+ if (!inst) {
109
+ inst = { host, root: el('div', { class: 'rp-page rp-people', 'data-people': '' }), head: null, body: el('div', { class: 'rp-people-body' }),
110
+ ws: null, loaded: false, error: null, view: null, q: '', group: 'all', sort: 'latest', open: null, slug: null, busy: false,
111
+ seq: 0, last: '', readAt: 0 };
112
+ insts.set(host, inst);
113
+ // A redraw held back while a note was typed runs when the field is left.
114
+ const held = inst;
115
+ held.body.addEventListener('focusout', () => setTimeout(() => { if (!held.last && held.loaded) draw(held); }, 0));
116
+ }
117
+ ensureComponentsCss();
118
+ if (!inst.root.isConnected || inst.root.parentNode !== host) host.replaceChildren(inst.root);
119
+ if (ctx && ctx.FIXTURE) { inst.root.replaceChildren(empty({ text: COPY.fixture })); return; }
120
+ const repo = (ctx && ctx.tab && ctx.tab.repo) || (ctx && ctx.state && ctx.state.product) || null;
121
+ const ws = repo ? wsOf(repo) : null;
122
+ // A link from the Agenda, an initiative or an item names the person in the tab's slug.
123
+ const slug = (ctx && ctx.tab && ctx.tab.slug) || null;
124
+ if (slug !== inst.slug) { inst.slug = slug; if (slug) { inst.open = slug; inst.q = ''; if (inst.head) inst.head.find.control.resetCommitted(''); } }
125
+ if (ws !== inst.ws) {
126
+ Object.assign(inst, { ws, loaded: false, error: null, view: null, last: '' });
127
+ if (!slug) inst.open = null;
128
+ if (ws) { ensureSprite(); refresh(inst); }
129
+ draw(inst);
130
+ return;
131
+ }
132
+ if (Date.now() - inst.readAt > REREAD_MS) refresh(inst);
133
+ draw(inst);
134
+ }
135
+
136
+ export function disposeDestination(host) {
137
+ const inst = insts.get(host);
138
+ if (!inst) return;
139
+ inst.seq++;
140
+ inst.root.remove();
141
+ insts.delete(host);
142
+ }
143
+
144
+ async function refresh(inst) {
145
+ const ws = inst.ws;
146
+ if (!ws) return;
147
+ const seq = ++inst.seq;
148
+ inst.readAt = Date.now();
149
+ const v = await pmGet('/api/people?ws=' + encodeURIComponent(ws));
150
+ if (seq !== inst.seq || ws !== inst.ws) return;
151
+ inst.loaded = true;
152
+ if (!v || v.ok === false) { inst.error = (v && v.error) || COPY.readError; inst.view = null; }
153
+ else { inst.error = null; inst.view = v; }
154
+ draw(inst);
155
+ }
156
+
157
+ /* ── presses ─────────────────────────────────────────────────────────────────────────────────────────────────────── */
158
+
159
+ async function decide(inst, body, done) {
160
+ if (inst.busy) return null;
161
+ inst.busy = true;
162
+ const r = await pmPost('/api/people/decide', { ws: inst.ws, ...body });
163
+ inst.busy = false;
164
+ forgetPeople(inst.ws);
165
+ if (!r || !r.ok) { toast({ text: (r && r.error) || 'Nothing was saved.', tone: 'error' }); await refresh(inst); return null; }
166
+ if (done) toast({ text: done, timeout: 3000 });
167
+ await refresh(inst);
168
+ return r;
169
+ }
170
+
171
+ /* ── the review list ─────────────────────────────────────────────────────────────────────────────────────────────── */
172
+
173
+ function review(inst) {
174
+ const r = inst.view.review;
175
+ if (!r.count) return null;
176
+ 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
+ for (const m of r.merges) {
193
+ const key = m.handle.kind + ':' + m.handle.value;
194
+ rows.push(el('div', { class: 'rp-people-row', role: 'listitem', 'data-people-merge': key },
195
+ row({
196
+ title: COPY.same(m.handle.value, m.personName),
197
+ meta: [m.why],
198
+ trail: [
199
+ button({ label: COPY.merge, variant: 'outline', size: 'sm', attrs: { 'data-people-act': 'merge', 'data-people-key': key },
200
+ onClick: () => decide(inst, { act: 'merge', person: m.person, handle: m.handle }, COPY.merged) }),
201
+ button({ label: COPY.keep, variant: 'ghost', size: 'sm', attrs: { 'data-people-act': 'keep-separate', 'data-people-key': key },
202
+ onClick: () => decide(inst, { act: 'keep-separate', a: m.a, b: m.b }, COPY.kept) }),
203
+ ],
204
+ })));
205
+ }
206
+ if (r.tones.length) rows.push(el('p', { class: 'rp-people-hint', 'data-people-tones': String(r.tones.length) }, COPY.tonesWait(r.tones.length)));
207
+ return el('section', { class: 'rp-people-review', 'data-people-review': '' },
208
+ el('h3', { class: 'rp-people-sec__title' }, `${COPY.waiting} · ${r.count}`),
209
+ el('div', { role: 'list' }, rows));
210
+ }
211
+
212
+ /* ── the list ────────────────────────────────────────────────────────────────────────────────────────────────────── */
213
+
214
+ function matches(p, q) {
215
+ if (!q) return true;
216
+ const hay = [p.name, p.role, p.org, ...p.handles.map((h) => h.value), ...p.shared.initiatives.map((g) => g.id)].join(' ').toLowerCase();
217
+ return hay.includes(q);
218
+ }
219
+ function shownPeople(inst) {
220
+ const q = inst.q.trim().toLowerCase();
221
+ let list = inst.view.people.filter((p) => matches(p, q));
222
+ if (inst.group === 'team') list = list.filter((p) => !p.external);
223
+ if (inst.group === 'external') list = list.filter((p) => p.external);
224
+ if (inst.sort === 'most') list = list.slice().sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
225
+ if (inst.sort === 'name') list = list.slice().sort((a, b) => a.name.localeCompare(b.name));
226
+ return list;
227
+ }
228
+
229
+ function personLine(inst, p) {
230
+ const open = inst.open === p.id;
231
+ const t = toneOf(p);
232
+ return el('div', { class: 'rp-people-row' + (open ? ' rp-people-row--open' : ''), role: 'listitem', 'data-people-person': p.id },
233
+ row({
234
+ title: p.name,
235
+ meta: [who(p), p.last ? `${p.count} · ${day(p.last)}` : String(p.count)],
236
+ trail: t ? [badge({ text: p.tone.confirmed ? t : t + '?', tone: t === 'tense' ? 'destructive' : t === 'cautious' ? 'warning' : 'outline', attrs: { 'data-people-tone': t } })] : [],
237
+ onOpen: () => { inst.open = p.id; draw(inst); },
238
+ openLabel: `${p.name}, ${who(p)}`,
239
+ openAttrs: { 'aria-current': open ? 'true' : null, 'data-people-open': p.id },
240
+ }));
241
+ }
242
+
243
+ function listPane(inst) {
244
+ const v = inst.view;
245
+ const all = v.people.filter((p) => matches(p, inst.q.trim().toLowerCase()));
246
+ const controls = el('div', { class: 'rp-people-views' },
247
+ tabs({ variant: 'line', value: inst.group, ariaLabel: 'Group', attrs: { 'data-people-group': '' },
248
+ items: [{ id: 'all', label: COPY.all, count: all.length }, { id: 'team', label: COPY.team, count: all.filter((p) => !p.external).length },
249
+ { id: 'external', label: COPY.external, count: all.filter((p) => p.external).length }],
250
+ onChange: (val) => { inst.group = val; draw(inst); } }),
251
+ tabs({ variant: 'segmented', value: inst.sort, ariaLabel: 'Sort', attrs: { 'data-people-sort': '' },
252
+ items: [{ id: 'latest', label: COPY.latest }, { id: 'most', label: COPY.most }, { id: 'name', label: COPY.name }],
253
+ onChange: (val) => { inst.sort = val; draw(inst); } }));
254
+ const list = shownPeople(inst);
255
+ let rows;
256
+ if (!v.people.length) rows = [el('p', { class: 'rp-section__empty' }, COPY.none)];
257
+ else if (!list.length) rows = [el('p', { class: 'rp-section__empty' }, COPY.noMatch(inst.q.trim()))];
258
+ else rows = [el('div', { role: 'list', 'data-people-list': '' }, list.map((p) => personLine(inst, p)))];
259
+ return el('div', { class: 'rp-people-list' }, controls, ...rows);
260
+ }
261
+
262
+ /* ── the person's page ───────────────────────────────────────────────────────────────────────────────────────────── */
263
+
264
+ function section(title, kids, attrs) {
265
+ return el('section', { class: 'rp-people-sec', ...(attrs || {}) }, el('h4', { class: 'rp-people-sec__title' }, title), ...kids);
266
+ }
267
+
268
+ function interactionsSec(p) {
269
+ if (!p.interactions.length) return section(COPY.interactions, [el('p', { class: 'rp-people-hint' }, COPY.noInteractions)], { 'data-people-sec': 'interactions' });
270
+ return section(`${COPY.interactions} · ${p.count}`, [el('ol', { class: 'rp-people-ints' }, p.interactions.slice(0, 50).map((i) =>
271
+ el('li', { class: 'rp-people-int', 'data-people-int': i.date },
272
+ el('span', { class: 'rp-people-int__when' }, `${day(i.date)} · ${i.kind}`),
273
+ el('span', { class: 'rp-people-int__text' }, i.text),
274
+ el('span', { class: 'rp-people-int__src' }, [SOURCE[i.source] || i.source, i.pointer, i.tone].filter(Boolean).join(' · ')))))],
275
+ { 'data-people-sec': 'interactions' });
276
+ }
277
+
278
+ function toneSec(inst, p) {
279
+ const t = p.tone;
280
+ const mix = TONES.filter((x) => t.mix[x]).map((x) => `${t.mix[x]} ${x}`).join(' · ');
281
+ const kids = [];
282
+ if (!t.proposed && !t.confirmed) kids.push(el('p', { class: 'rp-people-hint' }, COPY.noTone));
283
+ if (t.confirmed) kids.push(el('p', { class: 'rp-people-tone', 'data-people-confirmed': t.confirmed }, COPY.confirmed(t.confirmed)));
284
+ else if (t.proposed) kids.push(el('p', { class: 'rp-people-tone', 'data-people-proposed': t.proposed }, COPY.proposed(t.proposed)));
285
+ if (mix) kids.push(el('p', { class: 'rp-people-hint', 'data-people-mix': '' }, mix));
286
+ const acts = [];
287
+ if (!t.confirmed && t.proposed) acts.push(button({ label: COPY.confirm, variant: 'primary', size: 'sm', attrs: { 'data-people-act': 'tone-confirm' },
288
+ onClick: () => decide(inst, { act: 'tone', person: p.id, tone: t.proposed }, COPY.toneSaved) }));
289
+ const another = button({ label: COPY.setAnother, variant: 'outline', size: 'sm', iconEnd: 'chevron-down', attrs: { 'data-people-act': 'tone-set', 'aria-haspopup': 'menu' },
290
+ onClick: () => menu({ anchor: another, ariaLabel: COPY.setAnother,
291
+ items: TONES.map((x) => ({ label: x, onSelect: () => decide(inst, { act: 'tone', person: p.id, tone: x }, COPY.toneSaved) })) }) });
292
+ acts.push(another);
293
+ if (t.confirmed) acts.push(button({ label: COPY.change, variant: 'ghost', size: 'sm', attrs: { 'data-people-act': 'tone-clear' },
294
+ onClick: () => decide(inst, { act: 'tone', person: p.id, tone: null }, COPY.toneSaved) }));
295
+ kids.push(el('div', { class: 'rp-people-acts' }, acts));
296
+ return section(COPY.tone, kids, { 'data-people-sec': 'tone' });
297
+ }
298
+
299
+ function sharedSec(p) {
300
+ const s = p.shared;
301
+ const lines = [];
302
+ for (const g of s.initiatives) lines.push(el('li', { class: 'rp-people-shared', 'data-people-shared': 'initiative' }, `${g.id} · ${g.count} interaction${g.count === 1 ? '' : 's'}`));
303
+ for (const it of s.items) {
304
+ lines.push(el('li', { class: 'rp-people-shared', 'data-people-shared': 'item', 'data-people-item': it.id },
305
+ `${COPY.owns} ${it.title || it.id}${it.initiative ? ' · ' + it.initiative : ''} · ${it.state}`,
306
+ it.blocked ? badge({ text: COPY.blocked, tone: 'warning', attrs: { title: it.reason || COPY.blocked } }) : null));
307
+ }
308
+ for (const m of s.meetings) {
309
+ const when = new Date(m.start);
310
+ lines.push(el('li', { class: 'rp-people-shared', 'data-people-shared': 'meeting' },
311
+ `${COPY.meeting} · ${when.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })} ${when.toTimeString().slice(0, 5)} · ${m.title}`));
312
+ }
313
+ return section(COPY.shared, lines.length ? [el('ul', { class: 'rp-people-list-plain' }, lines)] : [el('p', { class: 'rp-people-hint' }, COPY.noShared)], { 'data-people-sec': 'shared' });
314
+ }
315
+
316
+ function notesSec(inst, p) {
317
+ const list = p.notes.map((n) => el('li', { class: 'rp-people-note', 'data-people-note': n.id },
318
+ el('span', { class: 'rp-people-note__text' }, n.text),
319
+ button({ label: COPY.remove, variant: 'ghost', size: 'sm', attrs: { 'data-people-act': 'note-remove', 'data-people-key': n.id },
320
+ onClick: () => decide(inst, { act: 'note-remove', person: p.id, id: n.id }, COPY.noteRemoved) })));
321
+ const f = field({ label: COPY.addNote, kind: 'textarea', hideLabel: true, placeholder: COPY.notePlaceholder, attrs: { 'data-people-field': 'note' } });
322
+ const save = async () => {
323
+ const text = f.control.value.trim();
324
+ if (!text) return;
325
+ f.control.blur();
326
+ const r = await decide(inst, { act: 'note', person: p.id, text }, COPY.noteAdded);
327
+ if (r) f.control.value = '';
328
+ };
329
+ return section(COPY.notes, [
330
+ list.length ? el('ul', { class: 'rp-people-list-plain' }, list) : el('p', { class: 'rp-people-hint' }, COPY.noNotes),
331
+ el('div', { class: 'rp-people-noteadd' }, f, el('div', { class: 'rp-people-acts' }, button({ label: COPY.addNote, variant: 'outline', size: 'sm', attrs: { 'data-people-act': 'note-add' }, onClick: save }))),
332
+ ], { 'data-people-sec': 'notes' });
333
+ }
334
+
335
+ function identitySec(inst, p) {
336
+ const by = {};
337
+ for (const i of p.interactions) by[i.source] = (by[i.source] || 0) + 1;
338
+ const file = inst.view.file ? String(inst.view.file).split('/').slice(-3).join('/') : 'people.jsonl';
339
+ return section(COPY.identity, [
340
+ el('ul', { class: 'rp-people-list-plain' },
341
+ p.handles.map((h) => el('li', { class: 'rp-people-handle', 'data-people-handle': h.kind + ':' + h.value }, handleText(h))),
342
+ Object.entries(by).map(([s, n]) => el('li', { class: 'rp-people-hint' }, `${SOURCE[s] || s}: ${n} interaction${n === 1 ? '' : 's'}`))),
343
+ el('p', { class: 'rp-people-hint rp-people-file' }, COPY.lives(file)),
344
+ ], { 'data-people-sec': 'identity' });
345
+ }
346
+
347
+ function personPane(inst) {
348
+ const p = inst.view.people.find((x) => x.id === inst.open);
349
+ if (!p) return el('div', { class: 'rp-people-page' }, el('p', { class: 'rp-section__empty' }, COPY.pick));
350
+ return el('article', { class: 'rp-people-page', 'data-people-page': p.id },
351
+ el('header', { class: 'rp-people-who' },
352
+ 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(' · '))),
354
+ interactionsSec(p), toneSec(inst, p), sharedSec(p), notesSec(inst, p), identitySec(inst, p));
355
+ }
356
+
357
+ /* ── the page ────────────────────────────────────────────────────────────────────────────────────────────────────── */
358
+
359
+ function header(inst) {
360
+ if (inst.head) return inst.head;
361
+ const find = field({ label: COPY.find, hideLabel: true, placeholder: COPY.find, attrs: { 'data-people-find': '' } });
362
+ const q = find.control;
363
+ q.addEventListener('input', () => { inst.q = q.value; draw(inst); });
364
+ q.addEventListener('keydown', (e) => { if (e.key === 'Escape') { q.resetCommitted(''); inst.q = ''; draw(inst); } });
365
+ inst.head = el('div', { class: 'rp-people-top' }, el('h2', { class: 'rp-head__title' }, COPY.title), el('div', { class: 'rp-people-find' }, find));
366
+ inst.head.find = find;
367
+ return inst.head;
368
+ }
369
+
370
+ function draw(inst) {
371
+ const root = inst.root;
372
+ if (!inst.ws) { root.replaceChildren(empty({ text: COPY.noRepo })); inst.last = 'none'; return; }
373
+ const fp = JSON.stringify([inst.loaded, inst.error, inst.view, inst.q, inst.group, inst.sort, inst.open]);
374
+ if (fp === inst.last && root.childNodes.length && root.contains(inst.body)) return;
375
+ inst.last = fp;
376
+ const head = header(inst);
377
+ if (head.parentNode !== root || inst.body.parentNode !== root) root.replaceChildren(head, inst.body);
378
+ if (!inst.loaded) { inst.body.replaceChildren(el('p', { class: 'rp-section__empty' }, COPY.loading)); return; }
379
+ if (inst.error) { inst.body.replaceChildren(el('p', { class: 'rp-section__empty rp-section__empty--bad', role: 'alert' }, inst.error)); return; }
380
+ if (!inst.open && inst.view.people.length) inst.open = inst.view.people[0].id;
381
+ // Focus stays in a note being typed: a redraw of the page would drop it, so it waits until the field is left.
382
+ const typing = document.activeElement && inst.body.contains(document.activeElement) && document.activeElement.matches('textarea');
383
+ if (typing) { inst.last = ''; return; }
384
+ inst.body.replaceChildren(...[review(inst), el('div', { class: 'rp-people-grid' }, listPane(inst), personPane(inst))].filter(Boolean));
385
+ }
@@ -1,11 +1,13 @@
1
1
  /* pm/dest-settings.js — the Settings destination, the frame's ⚙ (pm-os-absorb Run 3, slice 3A; PRD R9).
2
2
  *
3
- * Settings holds settings only, in seven sections and in this order:
3
+ * Settings holds settings only, in eight sections and in this order:
4
4
  * Repositories every registered repository as a row (open it to make it current; ⋯ for more), and Add repository,
5
5
  * which draws the existing add-repository flow (view-addrepo.js) in place
6
6
  * Profiles one row that opens the Claude profiles in the detail
7
7
  * New sessions the default CLI and the default model the dock's + Session applies (dock.js)
8
8
  * Validate consent the current repository's consent switch (GET and POST /api/validate/repo)
9
+ * People the current repository's switch for calendar attendee names (GET /api/people, POST /api/people/decide;
10
+ * people-directory, D90)
9
11
  * Online the Online switch and its five settings (GET and POST /api/online; workspace-online-mode, D89)
10
12
  * Theme Dark or Light
11
13
  * Updates the workspace app's version and the current repository's kit version; Check for updates asks the
@@ -35,6 +37,7 @@ const roots = new WeakMap(); // host -> { root, sig }
35
37
  const consentByWs = new Map(); // ws -> { on: boolean | null, error, at, pending }
36
38
  const kitByWs = new Map(); // ws -> { st: GET /api/kit/status body | null, at, pending }
37
39
  const upd = { phase: null, latest: null, compare: null, message: '', error: '', output: '' }; // the Updates section
40
+ const peopleByWs = new Map(); // ws -> { on: boolean | null, error, at, pending }
38
41
  const online = { data: null, error: null, note: null, at: 0, pending: false }; // GET /api/online, and the last refusal
39
42
  let addOpen = false;
40
43
  let addMod = null;
@@ -62,6 +65,27 @@ async function setConsent(ws, on) {
62
65
  setState({});
63
66
  }
64
67
 
68
+ function readPeople(ws) {
69
+ if (!ws || FIXTURE) return;
70
+ const have = peopleByWs.get(ws);
71
+ if (have && (have.pending || Date.now() - have.at < 30000)) return;
72
+ const entry = { on: have ? have.on : null, error: null, at: Date.now(), pending: true };
73
+ peopleByWs.set(ws, entry);
74
+ pmGet('/api/people?ws=' + encodeURIComponent(ws)).then((j) => {
75
+ entry.pending = false; entry.at = Date.now();
76
+ if (j && j.ok !== false && j.settings) { entry.on = !!j.settings.calendar_names; entry.error = null; } else entry.error = (j && j.error) || P.peopleUnread;
77
+ setState({});
78
+ });
79
+ }
80
+
81
+ async function setPeopleCalendar(ws, on) {
82
+ const j = await pmPost('/api/people/decide', { ws, act: 'setting', calendar_names: on });
83
+ peopleByWs.delete(ws);
84
+ if (!j || j.ok === false) peopleByWs.set(ws, { on: null, error: (j && j.error) || P.peopleUnread, at: Date.now() });
85
+ else readPeople(ws);
86
+ setState({});
87
+ }
88
+
65
89
  function readOnline(force) {
66
90
  if (FIXTURE || online.pending) return;
67
91
  if (!force && Date.now() - online.at < 10000) return;
@@ -84,7 +108,7 @@ const section = (id, title, kids, action) => el('section', { class: 'fr-set__sec
84
108
  el('div', { class: 'fr-set__head' }, el('h2', { class: 'fr-set__title' }, title), action || null),
85
109
  kids);
86
110
 
87
- /* ── the seven sections ────────────────────────────────────────────────────────────────────────────────────────── */
111
+ /* ── the eight sections ────────────────────────────────────────────────────────────────────────────────────────── */
88
112
 
89
113
  function reposSection(scope) {
90
114
  const tuples = repoTuples();
@@ -194,6 +218,22 @@ function validateSection(scope) {
194
218
  el('span', { 'data-fr': 'consent-state', 'data-on': known ? String(c.on) : 'unread' }, line), toggle, open)]);
195
219
  }
196
220
 
221
+ function peopleSection(scope) {
222
+ const repo = scope === 'all' ? null : activeProduct();
223
+ const ws = wsOfRepo(repo);
224
+ if (!ws) return section('people', P.people, [empty({ text: P.peopleNoRepo })]);
225
+ readPeople(ws);
226
+ const c = peopleByWs.get(ws) || {};
227
+ const known = c.on === true || c.on === false;
228
+ const line = c.error ? c.error : !known ? '' : c.on ? P.peopleOn : P.peopleOff;
229
+ const toggle = known ? button({
230
+ label: c.on ? P.turnOff : P.turnOn, variant: 'outline', size: 'sm',
231
+ attrs: { 'data-fr': 'people-calendar' }, onClick: () => setPeopleCalendar(ws, !c.on),
232
+ }) : null;
233
+ return section('people', P.people, [el('p', { class: 'fr-set__note' }, P.peopleNote),
234
+ el('div', { class: 'fr-set__line' }, el('span', { 'data-fr': 'people-state', 'data-on': known ? String(c.on) : 'unread' }, line), toggle)]);
235
+ }
236
+
197
237
  function onlineSection() {
198
238
  readOnline(false);
199
239
  const d = online.data;
@@ -353,7 +393,7 @@ function signature(scope) {
353
393
  let add = '';
354
394
  try { add = JSON.stringify(addKeys); } catch { add = String(Date.now()); }
355
395
  const kit = ws ? kitByWs.get(ws) : null;
356
- return JSON.stringify([repoTuples(), activeProduct(), scope, state.theme, sessionDefaults(), ws, consentByWs.get(ws) || null, addOpen, !!addMod, online.data, online.error, online.note,
396
+ return JSON.stringify([repoTuples(), activeProduct(), scope, state.theme, sessionDefaults(), ws, consentByWs.get(ws) || null, peopleByWs.get(ws) || null, addOpen, !!addMod, online.data, online.error, online.note,
357
397
  kit ? kit.st : null, upd]) + add;
358
398
  }
359
399
 
@@ -380,6 +420,7 @@ export function mountDestination(host, ctx) {
380
420
  profilesSection(),
381
421
  sessionsSection(),
382
422
  validateSection(scope),
423
+ peopleSection(scope),
383
424
  onlineSection(),
384
425
  themeSection(),
385
426
  updatesSection(scope),
@@ -27,6 +27,7 @@ export const COPY = {
27
27
  board: 'Board',
28
28
  reports: 'Reports',
29
29
  docs: 'Docs',
30
+ people: 'People',
30
31
  agent: 'Agent',
31
32
  settings: 'Settings',
32
33
  },
@@ -145,6 +146,12 @@ export const COPY = {
145
146
  validateUnread: 'The consent of this repository did not load.',
146
147
  validateNoRepo: 'Choose a repository to set its consent.',
147
148
  validateOpen: 'Open Validate',
149
+ people: 'People',
150
+ peopleOn: 'Calendar attendee names are kept. Agent sessions record who attends each meeting.',
151
+ peopleOff: 'Calendar attendee names are not kept. Agent sessions record no one from the calendar.',
152
+ peopleNote: 'Keep attendee names from the calendar, for this repository only. Turning it off also hides the names recorded before.',
153
+ peopleUnread: 'The People setting of this repository did not load.',
154
+ peopleNoRepo: 'Choose a repository to set what People keeps.',
148
155
  turnOn: 'Turn on',
149
156
  turnOff: 'Turn off',
150
157
  theme: 'Theme',
@@ -4,7 +4,8 @@
4
4
  * line 1 the title, the horizon, the health reason (only when not on track), and a meta line: lane · anchor · id
5
5
  * line 2 the stage stepper, then "Waiting on you: approval [Open gate ▸]" or "Waiting on agent: build [Hand to agent ▸]"
6
6
  * tabs Breakdown (the Run 1 pane, embedded: Tree · Board, and Timeline once Run 4 registers it), Files, History,
7
- * Jira tickets (sheet-jira.js, initiative jira-wbs: the tickets drafted from the PRD and their status in Jira)
7
+ * Jira tickets (sheet-jira.js, initiative jira-wbs: the tickets drafted from the PRD and their status in Jira),
8
+ * People (people-link.js, initiative people-directory: who owns its items or is named in interactions about it)
8
9
  * ⋯ horizon, check-by, lifecycle and the anchor (both PM-only: the server's guard answers), Copy CLI, Export breakdown
9
10
  * Nothing on this sheet says "plan". Everything it shows is read from GET /api/initiatives/<id>; every write goes through
10
11
  * initiatives-api.js. The Breakdown tab mounts with a tab id of the form `initiative:<slug>`, so it never meets a panel-4
@@ -19,6 +20,7 @@ import { sheet, button, iconButton, tabs, row, badge, menu, toast, empty, field,
19
20
  import { icon, ensureSprite } from './icons.js';
20
21
  import { mountBreakdown, unmountBreakdown } from './panel-breakdown.js';
21
22
  import { mountJira, unmountJira } from './sheet-jira.js';
23
+ import { initiativePeople } from './people-link.js';
22
24
  import { openDoc } from './panel-initiatives.js';
23
25
  import { openNewSession } from './panel-newsess.js';
24
26
  import { wsOf, readDoc, saveDoc } from './data.js';
@@ -241,7 +243,7 @@ export function openSheet(repo, slug) {
241
243
  function drawTabs() {
242
244
  tabsHost.replaceChildren(tabs({
243
245
  variant: 'segmented', full: true, ariaLabel: COPY.sheet.tabs, value: tab,
244
- items: [{ id: 'breakdown', label: COPY.sheet.breakdown }, { id: 'files', label: COPY.sheet.files }, { id: 'history', label: COPY.sheet.history }, { id: 'jira', label: COPY.sheet.jira }],
246
+ items: [{ id: 'breakdown', label: COPY.sheet.breakdown }, { id: 'files', label: COPY.sheet.files }, { id: 'history', label: COPY.sheet.history }, { id: 'jira', label: COPY.sheet.jira }, { id: 'people', label: COPY.sheet.people }],
245
247
  onChange: (id) => {
246
248
  if (id === tab) return;
247
249
  if (tab === 'breakdown') unmountBreakdown(bdHost);
@@ -263,6 +265,7 @@ export function openSheet(repo, slug) {
263
265
  return;
264
266
  }
265
267
  if (tab === 'jira') { mountJira(panel, { ws, slug }); return; }
268
+ if (tab === 'people') { panel.replaceChildren(initiativePeople(ws, slug, { onOpen: () => s.close() })); return; }
266
269
  if (tab === 'files') {
267
270
  const files = data.files || [];
268
271
  // Grouped by where the tie came from (scan-consistency R3): the record's own paths, the documents the library's
@@ -127,6 +127,7 @@ export const COPY = {
127
127
  files: 'Files',
128
128
  history: 'History',
129
129
  jira: 'Jira tickets',
130
+ people: 'People',
130
131
  waitingYou: (act) => `Waiting on you: ${act}`,
131
132
  waitingAgent: (act) => `Waiting on agent: ${act}`,
132
133
  nothingOwed: 'Nothing is owed.',
@@ -17,6 +17,7 @@ import { icon } from './icons.js';
17
17
  import { COPY, STATES, ago, dayOf, ownerLabel } from './breakdown-copy.js';
18
18
  import { stageLabel } from './bd-model.js';
19
19
  import { handoffBox } from './handoff-box.js';
20
+ import { peopleOf, namedIn, openPerson } from './people-link.js';
20
21
 
21
22
  const ownerKind = (o) => (o === 'you' || o === 'agent' ? o : String(o || '').split(':')[0] || 'you');
22
23
  const ownerName = (o) => { const s = String(o || ''); const i = s.indexOf(':'); return i > 0 ? s.slice(i + 1) : ''; };
@@ -113,6 +114,20 @@ export function openItemSheet(ctx, id, { handoff = false } = {}) {
113
114
  });
114
115
  owner.setAttribute('data-bd-field', 'owner');
115
116
  const showOwnerName = (k) => { ownerNameField.hidden = !(k === 'person' || k === 'tracker'); };
117
+ // A person owner who is on People links to their page (people-directory R13). Nothing shows for anyone else.
118
+ const ownerPeople = el('span', { class: 'rp-people-links', 'data-bd-owner-people': '' });
119
+ let ownerShown = null;
120
+ const drawOwnerPeople = (o) => {
121
+ if (o === ownerShown) return;
122
+ ownerShown = o;
123
+ ownerPeople.replaceChildren();
124
+ if (ownerKind(o) !== 'person' || !ownerName(o)) return;
125
+ peopleOf(ctx.ws).then((v) => {
126
+ if (ownerShown !== o) return;
127
+ ownerPeople.replaceChildren(...namedIn(v, ownerName(o)).map((p) => button({ label: p.name, variant: 'ghost', size: 'sm', iconEnd: 'chevron-right',
128
+ attrs: { 'data-person-link': p.id, 'aria-label': `Open ${p.name} in People` }, onClick: () => { sh.close(); openPerson(p.id); } })));
129
+ });
130
+ };
116
131
  const stage = field({
117
132
  label: COPY.labels.stage, kind: 'select', value: it0.stage, attrs: { 'data-bd-input': 'stage' },
118
133
  options: (L0.stages || []).map((s) => ({ value: s.id, label: s.label })),
@@ -211,12 +226,13 @@ export function openItemSheet(ctx, id, { handoff = false } = {}) {
211
226
  const sh = sheet({
212
227
  mode: 'auto', title: it0.title, sub: ctx.slug + ' · ' + stageLabel(L0, it0.stage),
213
228
  attrs: { 'data-bd-sheet': id },
214
- body: [stateSlot, noteSlot, title, el('div', { class: 'bd-grid2' }, el('div', { class: 'bd-group' }, owner, ownerNameField), stage),
229
+ body: [stateSlot, noteSlot, title, el('div', { class: 'bd-grid2' }, el('div', { class: 'bd-group' }, owner, ownerNameField, ownerPeople), stage),
215
230
  afterSlot, el('div', { class: 'bd-grid2' }, start, due), el('div', { class: 'bd-checks' }, milestone, blocked), reason, note, handSlot, history],
216
231
  actions: [withdraw, closeBtn],
217
232
  onClose: () => { if (ctx.onClose) ctx.onClose(id); },
218
233
  });
219
234
  showOwnerName(ownerKind(it0.owner));
235
+ drawOwnerPeople(it0.owner);
220
236
 
221
237
  const refresh = () => {
222
238
  const it = item();
@@ -230,6 +246,7 @@ export function openItemSheet(ctx, id, { handoff = false } = {}) {
230
246
  if (!focused(title.control)) title.control.resetCommitted(it.title);
231
247
  if (!focused(owner.control)) { owner.control.value = ownerKind(it.owner); showOwnerName(ownerKind(it.owner)); }
232
248
  if (!focused(ownerNameField.control) && ownerName(it.owner)) ownerNameField.control.resetCommitted(ownerName(it.owner));
249
+ drawOwnerPeople(it.owner);
233
250
  if (!focused(stage.control)) stage.control.value = it.stage;
234
251
  if (!afterSlot.contains(document.activeElement)) drawAfter(it);
235
252
  if (!focused(start.control)) start.control.value = it.start || '';
@@ -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) {