create-pmos 0.61.0 → 0.62.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 (36) 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 +8 -3
  7. package/workspace/guide/index.md +5 -3
  8. package/workspace/guide/layout.md +4 -3
  9. package/workspace/guide/people.md +70 -0
  10. package/workspace/guide/today.md +2 -2
  11. package/workspace/lib/calendar-memory.js +2 -1
  12. package/workspace/lib/people-cli.js +135 -0
  13. package/workspace/lib/people-store.js +123 -0
  14. package/workspace/lib/people.js +399 -0
  15. package/workspace/lib/pmos-verbs.js +3 -0
  16. package/workspace/package-lock.json +1 -1
  17. package/workspace/package.json +1 -1
  18. package/workspace/public/pm/app.js +2 -2
  19. package/workspace/public/pm/components.css +169 -0
  20. package/workspace/public/pm/dest-people.js +385 -0
  21. package/workspace/public/pm/dest-settings.js +44 -3
  22. package/workspace/public/pm/frame-copy.js +7 -0
  23. package/workspace/public/pm/initiative-sheet.js +5 -2
  24. package/workspace/public/pm/initiatives-copy.js +1 -0
  25. package/workspace/public/pm/item-sheet.js +18 -1
  26. package/workspace/public/pm/people-link.js +106 -0
  27. package/workspace/public/pm/registry.js +2 -0
  28. package/workspace/public/pm/today-agenda.js +3 -1
  29. package/workspace/public/pm/today-day.js +6 -3
  30. package/workspace/public/pm/topbar.js +4 -3
  31. package/workspace/server.js +27 -0
  32. package/workspace/skills/gather-requirements.skill +5 -3
  33. package/workspace/skills/jira-sync.skill +5 -4
  34. package/workspace/skills/live-notes.skill +3 -1
  35. package/workspace/skills/morning-brief.skill +5 -3
  36. package/workspace/test-lists/people-directory.json +6 -0
@@ -0,0 +1,399 @@
1
+ 'use strict';
2
+ /* people.js — the people the PM works with in a repository: the row rules and the one fold (initiative people-directory,
3
+ * PRD R1 to R10, proposed D90).
4
+ *
5
+ * checkSighting(s) → { ok, row } | { ok:false, error } one sighting an agent session records
6
+ * checkMergeProposal(m) → { ok, row } | { ok:false, error }
7
+ * checkDecision(body) → { ok, row } | { ok:false, error } one PM press (add, ignore, merge, keep-separate,
8
+ * edit, tone, note, note-remove, setting)
9
+ * fold(rows, { now, items, meetings }) → { people, review, settings, counts }
10
+ * handleKey(h) · slugOf(name) · toneProposal(interactions) · matchPeople(people, text)
11
+ * SOURCES · HANDLE_KINDS · INTERACTION_KINDS · TONES · ACTS · LEDGER
12
+ *
13
+ * Pure: no file, no clock unless given, no network. lib/people-store.js reads and appends; the route, the CLI and the
14
+ * People place all answer from fold(), so the terminal and the page cannot hold two opinions about a person.
15
+ *
16
+ * WHO DECIDES (D90). A sighting row is anyone's: an agent session records it through `pmos people record`. Every
17
+ * decision row (a person added, a handle ignored or merged, a tone, a note, the calendar setting) counts only when its
18
+ * `by` is the PM (`pm:<slug>`), which only a page press writes. A decision row with any other `by` is skipped and
19
+ * counted in counts.skipped, so a hand-appended line cannot make an agent's choice the PM's.
20
+ *
21
+ * CALENDAR NAMES (R3). The setting is off until the PM turns it on. While it is off, the store refuses a calendar
22
+ * sighting, and this fold also drops the calendar sightings recorded while it was on.
23
+ */
24
+
25
+ const LEDGER = 'people.jsonl';
26
+ const SOURCES = Object.freeze(['gather', 'calendar', 'live', 'jira']);
27
+ const HANDLE_KINDS = Object.freeze(['mail', 'slack', 'jira', 'confluence', 'speaker', 'name']);
28
+ const INTERACTION_KINDS = Object.freeze(['Mail', 'Chat', 'Meeting', '1:1', 'Jira', 'Doc', 'Call', 'Mention']);
29
+ const TONES = Object.freeze(['warm', 'neutral', 'cautious', 'tense']);
30
+ const ACTS = Object.freeze(['add', 'ignore', 'merge', 'keep-separate', 'edit', 'tone', 'note', 'note-remove', 'setting']);
31
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
32
+ const ID_RE = /^[a-z0-9][a-z0-9-]{0,59}$/;
33
+ const NOTE_ID_RE = /^n-[0-9a-z]{4,16}$/;
34
+ const PM_BY_RE = /^pm:[a-z0-9_-]{1,40}$/;
35
+ const TONE_WINDOW = 10;
36
+
37
+ const isObj = (x) => !!x && typeof x === 'object' && !Array.isArray(x);
38
+ const str = (v) => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim() : '');
39
+ /** A text field: a string of 1 to `max` characters after its spaces are folded, or null when absent and optional. */
40
+ function text(v, max, what, { required = false } = {}) {
41
+ if (v === undefined || v === null || v === '') return required ? { error: `${what} is required` } : { value: null };
42
+ if (typeof v !== 'string') return { error: `${what} must be text` };
43
+ const s = str(v);
44
+ if (!s) return required ? { error: `${what} is required` } : { value: null };
45
+ if (s.length > max) return { error: `${what} is over ${max} characters` };
46
+ return { value: s };
47
+ }
48
+
49
+ /** The key a handle is known by: its kind and its value, lower-cased. Two sightings of one key are one handle. */
50
+ function handleKey(h) {
51
+ if (!isObj(h)) return '';
52
+ return `${String(h.kind || '')}:${str(h.value).toLowerCase()}`;
53
+ }
54
+ function checkHandle(h, what = 'handle') {
55
+ if (!isObj(h)) return { error: `${what} must be { kind, value }` };
56
+ if (!HANDLE_KINDS.includes(h.kind)) return { error: `${what}.kind must be one of: ${HANDLE_KINDS.join(', ')}` };
57
+ const v = text(h.value, 120, `${what}.value`, { required: true });
58
+ if (v.error) return v;
59
+ return { value: { kind: h.kind, value: v.value } };
60
+ }
61
+
62
+ /** "Mei Lin" → "mei-lin". A name with no letter or digit gives "person". */
63
+ function slugOf(name) {
64
+ const s = str(name).toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50);
65
+ return s || 'person';
66
+ }
67
+
68
+ /** One sighting, as `pmos people record` takes it. Returns the row to append, without v, at or by (the store adds them). */
69
+ function checkSighting(s) {
70
+ if (!isObj(s)) return { ok: false, error: 'a sighting must be an object' };
71
+ if (!SOURCES.includes(s.source)) return { ok: false, error: `source must be one of: ${SOURCES.join(', ')}` };
72
+ const h = checkHandle(s.handle);
73
+ if (h.error) return { ok: false, error: h.error };
74
+ const name = text(s.name, 80, 'name', { required: true });
75
+ if (name.error) return { ok: false, error: name.error };
76
+ const role = text(s.role, 120, 'role');
77
+ if (role.error) return { ok: false, error: role.error };
78
+ const org = text(s.org, 80, 'org');
79
+ if (org.error) return { ok: false, error: org.error };
80
+ if (s.external !== undefined && typeof s.external !== 'boolean') return { ok: false, error: 'external must be true or false' };
81
+ const row = { kind: 'sighting', source: s.source, handle: h.value, name: name.value };
82
+ if (role.value) row.role = role.value;
83
+ if (org.value) row.org = org.value;
84
+ if (s.external === true) row.external = true;
85
+ if (s.interaction !== undefined && s.interaction !== null) {
86
+ const i = s.interaction;
87
+ if (!isObj(i)) return { ok: false, error: 'interaction must be an object' };
88
+ if (typeof i.date !== 'string' || !DATE_RE.test(i.date.slice(0, 10))) return { ok: false, error: 'interaction.date must be YYYY-MM-DD' };
89
+ if (!INTERACTION_KINDS.includes(i.kind)) return { ok: false, error: `interaction.kind must be one of: ${INTERACTION_KINDS.join(', ')}` };
90
+ const t = text(i.text, 200, 'interaction.text', { required: true });
91
+ if (t.error) return { ok: false, error: t.error };
92
+ const p = text(i.pointer, 200, 'interaction.pointer', { required: true });
93
+ if (p.error) return { ok: false, error: p.error };
94
+ if (i.tone !== undefined && i.tone !== null && !TONES.includes(i.tone)) return { ok: false, error: `interaction.tone must be one of: ${TONES.join(', ')}` };
95
+ if (i.initiative !== undefined && i.initiative !== null && (typeof i.initiative !== 'string' || !ID_RE.test(i.initiative))) return { ok: false, error: 'interaction.initiative must be an initiative id' };
96
+ row.interaction = { date: i.date.slice(0, 10), kind: i.kind, text: t.value, pointer: p.value };
97
+ if (i.tone) row.interaction.tone = i.tone;
98
+ if (i.initiative) row.interaction.initiative = i.initiative;
99
+ }
100
+ return { ok: true, row };
101
+ }
102
+
103
+ function checkMergeProposal(m) {
104
+ if (!isObj(m)) return { ok: false, error: 'a merge proposal must be an object' };
105
+ const a = checkHandle(m.a, 'a');
106
+ if (a.error) return { ok: false, error: a.error };
107
+ const b = checkHandle(m.b, 'b');
108
+ if (b.error) return { ok: false, error: b.error };
109
+ if (handleKey(a.value) === handleKey(b.value)) return { ok: false, error: 'a merge proposal needs two different handles' };
110
+ const why = text(m.why, 200, 'why', { required: true });
111
+ if (why.error) return { ok: false, error: why.error };
112
+ return { ok: true, row: { kind: 'merge-proposal', a: a.value, b: b.value, why: why.value } };
113
+ }
114
+
115
+ /** One PM press. The route passes the request body; the store adds v, at and by. */
116
+ function checkDecision(body) {
117
+ if (!isObj(body)) return { ok: false, error: 'the body must be an object' };
118
+ const act = body.act;
119
+ if (!ACTS.includes(act)) return { ok: false, error: `act must be one of: ${ACTS.join(', ')}` };
120
+ const person = () => (typeof body.person === 'string' && ID_RE.test(body.person) ? body.person : null);
121
+ if (act === 'setting') {
122
+ if (typeof body.calendar_names !== 'boolean') return { ok: false, error: 'calendar_names must be true or false' };
123
+ return { ok: true, row: { kind: 'setting', calendar_names: body.calendar_names } };
124
+ }
125
+ if (act === 'add') {
126
+ const h = checkHandle(body.handle);
127
+ if (h.error) return { ok: false, error: h.error };
128
+ const name = text(body.name, 80, 'name', { required: true });
129
+ if (name.error) return { ok: false, error: name.error };
130
+ return { ok: true, row: { kind: 'add', handle: h.value, name: name.value } };
131
+ }
132
+ if (act === 'ignore') {
133
+ const h = checkHandle(body.handle);
134
+ if (h.error) return { ok: false, error: h.error };
135
+ return { ok: true, row: { kind: 'ignore', handle: h.value } };
136
+ }
137
+ if (act === 'merge') {
138
+ const h = checkHandle(body.handle);
139
+ if (h.error) return { ok: false, error: h.error };
140
+ if (!person()) return { ok: false, error: 'say which person the handle joins' };
141
+ return { ok: true, row: { kind: 'merge', person: person(), handle: h.value } };
142
+ }
143
+ if (act === 'keep-separate') {
144
+ const a = checkHandle(body.a, 'a');
145
+ if (a.error) return { ok: false, error: a.error };
146
+ const b = checkHandle(body.b, 'b');
147
+ if (b.error) return { ok: false, error: b.error };
148
+ return { ok: true, row: { kind: 'keep-separate', a: a.value, b: b.value } };
149
+ }
150
+ if (!person()) return { ok: false, error: 'say which person' };
151
+ if (act === 'edit') {
152
+ const row = { kind: 'edit', person: person() };
153
+ for (const [k, max] of [['name', 80], ['role', 120], ['org', 80]]) {
154
+ if (body[k] === undefined) continue;
155
+ if (body[k] === null || body[k] === '') { if (k === 'name') return { ok: false, error: 'name is required' }; row[k] = null; continue; }
156
+ const t = text(body[k], max, k, { required: k === 'name' });
157
+ if (t.error) return { ok: false, error: t.error };
158
+ row[k] = t.value;
159
+ }
160
+ if (body.external !== undefined) {
161
+ if (typeof body.external !== 'boolean') return { ok: false, error: 'external must be true or false' };
162
+ row.external = body.external;
163
+ }
164
+ if (Object.keys(row).length === 2) return { ok: false, error: 'nothing to change' };
165
+ return { ok: true, row };
166
+ }
167
+ if (act === 'tone') {
168
+ if (body.tone !== null && !TONES.includes(body.tone)) return { ok: false, error: `tone must be one of: ${TONES.join(', ')}, or null to clear it` };
169
+ return { ok: true, row: { kind: 'tone', person: person(), tone: body.tone } };
170
+ }
171
+ if (act === 'note') {
172
+ const t = text(body.text, 500, 'note', { required: true });
173
+ if (t.error) return { ok: false, error: t.error };
174
+ if (typeof body.id !== 'string' || !NOTE_ID_RE.test(body.id)) return { ok: false, error: 'the note needs an id (n- and 4 to 16 letters or digits)' };
175
+ return { ok: true, row: { kind: 'note', person: person(), id: body.id, text: t.value } };
176
+ }
177
+ // note-remove
178
+ if (typeof body.id !== 'string' || !NOTE_ID_RE.test(body.id)) return { ok: false, error: 'say which note' };
179
+ return { ok: true, row: { kind: 'note-remove', person: person(), id: body.id } };
180
+ }
181
+
182
+ /** The proposed overall tone: the most frequent tone of the latest TONE_WINDOW toned interactions; the latest of the
183
+ * tied tones wins a tie. `interactions` is newest first. null when no interaction has a tone. */
184
+ function toneProposal(interactions) {
185
+ const toned = (interactions || []).filter((i) => i && TONES.includes(i.tone)).slice(0, TONE_WINDOW);
186
+ if (!toned.length) return null;
187
+ const n = {};
188
+ for (const i of toned) n[i.tone] = (n[i.tone] || 0) + 1;
189
+ const top = Math.max(...Object.values(n));
190
+ return toned.find((i) => n[i.tone] === top).tone;
191
+ }
192
+
193
+ const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
194
+ /** Does `hay` name `needle` as a whole word, ignoring case? */
195
+ function names(hay, needle) {
196
+ const n = str(needle);
197
+ if (n.length < 2) return false;
198
+ return new RegExp(`(^|[^\\p{L}\\p{N}])${escRe(n)}($|[^\\p{L}\\p{N}])`, 'iu').test(String(hay || ''));
199
+ }
200
+ /** The words a person is matched by in free text: the full name, a first name of three letters or more, and the
201
+ * values of their name and speaker handles. */
202
+ function matchWords(p) {
203
+ const w = new Set();
204
+ const full = str(p.name);
205
+ if (full) w.add(full);
206
+ const first = full.split(' ')[0];
207
+ if (first && first.length >= 3 && first !== full) w.add(first);
208
+ for (const h of p.handles || []) if (h.kind === 'name' || h.kind === 'speaker' || h.kind === 'slack' || h.kind === 'jira') w.add(str(h.value).replace(/^@/, ''));
209
+ return [...w].filter((x) => x.length >= 2);
210
+ }
211
+ /** The people a free-text line names (an Agenda `with`, an item owner), each once, in list order. */
212
+ function matchPeople(people, hay) {
213
+ if (!hay) return [];
214
+ return (people || []).filter((p) => matchWords(p).some((w) => names(hay, w)));
215
+ }
216
+
217
+ const sightKey = (r) => [handleKey(r.handle), r.interaction ? r.interaction.date : '', r.interaction ? r.interaction.pointer : '', r.interaction ? r.interaction.text : ''].join('\u0000');
218
+ const pairKey = (a, b) => [handleKey(a), handleKey(b)].sort().join('\u0000');
219
+ const isPm = (r) => typeof r.by === 'string' && PM_BY_RE.test(r.by);
220
+ const DECISION_KINDS = new Set(['add', 'ignore', 'merge', 'keep-separate', 'edit', 'tone', 'note', 'note-remove', 'setting']);
221
+
222
+ /**
223
+ * The one fold. `rows` are the ledger's rows in file order (torn lines already dropped). `items` are plan items (the
224
+ * plan-items fold's values) and `meetings` the coming calendar events ({ start, title, with }); both are optional.
225
+ */
226
+ function fold(rows, { now = Date.now(), items = [], meetings = [] } = {}) {
227
+ const counts = { rows: 0, skipped: 0, duplicates: 0, hiddenCalendar: 0 };
228
+ const settings = { calendar_names: false };
229
+ const people = new Map(); // id -> person
230
+ const owner = new Map(); // handle key -> person id
231
+ const ignored = new Set(); // handle keys
232
+ const closed = new Set(); // merge-proposal pair keys the PM kept separate
233
+ const sightings = [];
234
+ const proposals = [];
235
+
236
+ // 1 · decisions, in file order; the calendar setting first so the sightings below read the final value
237
+ for (const r of rows || []) {
238
+ if (!isObj(r)) continue;
239
+ counts.rows++;
240
+ if (DECISION_KINDS.has(r.kind) && !isPm(r)) { counts.skipped++; continue; }
241
+ if (r.kind === 'setting' && typeof r.calendar_names === 'boolean') settings.calendar_names = r.calendar_names;
242
+ }
243
+ const attach = (id, h) => {
244
+ const k = handleKey(h);
245
+ const prev = owner.get(k);
246
+ if (prev && prev !== id && people.has(prev)) { const pp = people.get(prev); pp.handles = pp.handles.filter((x) => handleKey(x) !== k); }
247
+ owner.set(k, id);
248
+ ignored.delete(k);
249
+ const p = people.get(id);
250
+ if (!p.handles.some((x) => handleKey(x) === k)) p.handles.push({ kind: h.kind, value: h.value });
251
+ };
252
+ for (const r of rows || []) {
253
+ if (!isObj(r)) continue;
254
+ if (r.kind === 'sighting') {
255
+ const c = checkSighting(r);
256
+ if (c.ok) sightings.push({ ...c.row, at: r.at || null });
257
+ continue;
258
+ }
259
+ if (r.kind === 'merge-proposal') {
260
+ const c = checkMergeProposal(r);
261
+ if (c.ok) proposals.push({ ...c.row, at: r.at || null });
262
+ continue;
263
+ }
264
+ if (!DECISION_KINDS.has(r.kind) || !isPm(r) || r.kind === 'setting') continue;
265
+ const c = checkDecision({ ...r, act: r.kind });
266
+ if (!c.ok) { counts.skipped++; continue; }
267
+ const d = c.row;
268
+ if (d.kind === 'add') {
269
+ const k = handleKey(d.handle);
270
+ if (owner.has(k) && people.has(owner.get(k))) continue;
271
+ let id = slugOf(d.name);
272
+ for (let n = 2; people.has(id); n++) id = `${slugOf(d.name).slice(0, 56)}-${n}`;
273
+ people.set(id, { id, name: d.name, role: null, org: null, external: null, handles: [], tone: null, notes: new Map(), addedAt: r.at || null, edited: {} });
274
+ attach(id, d.handle);
275
+ } else if (d.kind === 'ignore') {
276
+ const k = handleKey(d.handle);
277
+ if (!owner.has(k)) ignored.add(k);
278
+ } else if (d.kind === 'merge') {
279
+ if (people.has(d.person)) attach(d.person, d.handle);
280
+ } else if (d.kind === 'keep-separate') {
281
+ closed.add(pairKey(d.a, d.b));
282
+ } else if (people.has(d.person)) {
283
+ const p = people.get(d.person);
284
+ if (d.kind === 'edit') {
285
+ for (const k of ['name', 'role', 'org', 'external']) if (d[k] !== undefined) { p[k] = d[k]; p.edited[k] = true; }
286
+ } else if (d.kind === 'tone') p.tone = d.tone;
287
+ else if (d.kind === 'note') p.notes.set(d.id, { id: d.id, text: d.text, at: r.at || null });
288
+ else if (d.kind === 'note-remove') p.notes.delete(d.id);
289
+ }
290
+ }
291
+
292
+ // 2 · sightings: dedup, drop calendar ones while the setting is off, then hand each to its person or the review list
293
+ const seen = new Set();
294
+ const fresh = new Map(); // handle key -> { handle, name, role, org, external, count, sources, last }
295
+ const interactions = new Map(); // person id -> []
296
+ for (const s of sightings) {
297
+ const key = sightKey(s);
298
+ if (seen.has(key)) { counts.duplicates++; continue; }
299
+ seen.add(key);
300
+ if (s.source === 'calendar' && !settings.calendar_names) { counts.hiddenCalendar++; continue; }
301
+ const k = handleKey(s.handle);
302
+ const id = owner.get(k);
303
+ if (id && people.has(id)) {
304
+ const p = people.get(id);
305
+ for (const f of ['role', 'org']) if (s[f] && !p.edited[f]) p[f] = s[f];
306
+ if (s.external === true && !p.edited.external) p.external = true;
307
+ if (s.interaction) {
308
+ if (!interactions.has(id)) interactions.set(id, []);
309
+ interactions.get(id).push({ ...s.interaction, source: s.source, handle: s.handle, at: s.at });
310
+ }
311
+ continue;
312
+ }
313
+ if (ignored.has(k)) continue;
314
+ const f = fresh.get(k) || { handle: s.handle, name: s.name, role: null, org: null, external: false, count: 0, sources: [], last: null };
315
+ f.name = s.name;
316
+ if (s.role) f.role = s.role;
317
+ if (s.org) f.org = s.org;
318
+ if (s.external) f.external = true;
319
+ f.count++;
320
+ if (!f.sources.includes(s.source)) f.sources.push(s.source);
321
+ const d = s.interaction ? s.interaction.date : (s.at || '').slice(0, 10);
322
+ if (d && (!f.last || d > f.last)) f.last = d;
323
+ fresh.set(k, f);
324
+ }
325
+
326
+ // 3 · each person's page
327
+ const today = new Date(now).toISOString().slice(0, 10);
328
+ const list = [...people.values()].filter((p) => p.handles.length).map((p) => {
329
+ const ints = (interactions.get(p.id) || []).sort((a, b) => (b.date < a.date ? -1 : b.date > a.date ? 1 : String(b.at || '').localeCompare(String(a.at || ''))));
330
+ const mix = {};
331
+ for (const i of ints) if (i.tone) mix[i.tone] = (mix[i.tone] || 0) + 1;
332
+ const proposed = toneProposal(ints);
333
+ const inits = new Map();
334
+ for (const i of ints) if (i.initiative) {
335
+ const g = inits.get(i.initiative) || { id: i.initiative, count: 0, last: null };
336
+ g.count++; if (!g.last || i.date > g.last) g.last = i.date;
337
+ inits.set(i.initiative, g);
338
+ }
339
+ const person = {
340
+ id: p.id, name: p.name, role: p.role || null, org: p.org || null, external: p.external === true,
341
+ group: p.external === true ? 'External' : 'Team',
342
+ handles: p.handles.slice(),
343
+ interactions: ints,
344
+ tone: { proposed, confirmed: p.tone || null, mix, pending: !!proposed && !p.tone },
345
+ notes: [...p.notes.values()],
346
+ last: ints.length ? ints[0].date : (p.addedAt ? String(p.addedAt).slice(0, 10) : null),
347
+ count: ints.length,
348
+ shared: { initiatives: [...inits.values()].sort((a, b) => b.count - a.count || a.id.localeCompare(b.id)), items: [], meetings: [] },
349
+ };
350
+ return person;
351
+ });
352
+
353
+ // 4 · shared work from the repository: plan items owned by the person, and coming meetings that name them
354
+ for (const it of items || []) {
355
+ if (!it || it.state === 'withdrawn' || typeof it.owner !== 'string' || !it.owner.startsWith('person:')) continue;
356
+ const who = it.owner.slice(7);
357
+ for (const p of matchPeople(list, who)) {
358
+ p.shared.items.push({ id: it.id, title: it.title, initiative: it.initiative, state: it.state, blocked: !!it.blocked, reason: (it.blocked && ((typeof it.blocked === 'object' && it.blocked.reason) || it.reason)) || null, due: it.due || null });
359
+ }
360
+ }
361
+ if (settings.calendar_names) {
362
+ for (const m of meetings || []) {
363
+ if (!m || !m.with || !m.start || String(m.start).slice(0, 10) < today) continue;
364
+ for (const p of matchPeople(list, m.with)) if (p.shared.meetings.length < 5) p.shared.meetings.push({ start: m.start, title: m.title || '', with: m.with });
365
+ }
366
+ for (const p of list) p.shared.meetings.sort((a, b) => String(a.start).localeCompare(String(b.start)));
367
+ }
368
+ list.sort((a, b) => String(b.last || '').localeCompare(String(a.last || '')) || a.name.localeCompare(b.name));
369
+
370
+ // 5 · the review list: new handles, open merge proposals, tones the PM has not confirmed
371
+ const merges = [];
372
+ const shown = new Set();
373
+ for (const m of proposals) {
374
+ const pk = pairKey(m.a, m.b);
375
+ if (closed.has(pk) || shown.has(pk)) continue;
376
+ const oa = owner.get(handleKey(m.a)); const ob = owner.get(handleKey(m.b));
377
+ if (oa && ob && oa === ob) continue;
378
+ // Merge needs a person to join: the one holding a (or else b). Two handles nobody holds wait in "new" instead.
379
+ const into = oa && people.has(oa) ? oa : ob && people.has(ob) ? ob : null;
380
+ if (!into) continue;
381
+ const other = into === oa ? m.b : m.a;
382
+ if (ignored.has(handleKey(other))) continue;
383
+ shown.add(pk);
384
+ merges.push({ a: m.a, b: m.b, why: m.why, person: into, personName: people.get(into).name, handle: other });
385
+ }
386
+ const freshList = [...fresh.values()].sort((a, b) => String(b.last || '').localeCompare(String(a.last || '')) || b.count - a.count);
387
+ const tones = list.filter((p) => p.tone.pending).map((p) => p.id);
388
+ return {
389
+ people: list,
390
+ review: { new: freshList, merges, tones, count: freshList.length + merges.length + (tones.length ? 1 : 0) },
391
+ settings,
392
+ counts,
393
+ };
394
+ }
395
+
396
+ module.exports = {
397
+ LEDGER, SOURCES, HANDLE_KINDS, INTERACTION_KINDS, TONES, ACTS, TONE_WINDOW,
398
+ checkSighting, checkMergeProposal, checkDecision, fold, handleKey, slugOf, toneProposal, matchPeople, matchWords,
399
+ };
@@ -41,6 +41,9 @@ const VERBS = Object.freeze({
41
41
  // Not a pm-os-absorb verb (run: null): docs-three-way-sync added it through the same table.
42
42
  docs: Object.freeze({ module: 'docs-cli.js', run: null, guide: 'docs.md',
43
43
  help: 'pmos docs [list|link|unlink|find] … — is each linked document the same as, ahead of or behind its Google Docs and Confluence copy, and record a sync (pmos docs help)' }),
44
+ // Not a pm-os-absorb verb (run: null): people-directory added it through the same table.
45
+ people: Object.freeze({ module: 'people-cli.js', run: null, guide: 'people.md',
46
+ help: 'pmos people list|show|record|settings … — the people you work with in this repository (pmos people help)' }),
44
47
  launcher: Object.freeze({ module: 'launcher-cli.js', run: null, guide: 'launcher.md',
45
48
  help: 'pmos launcher on|off|status — a PMOS app and a login item, so the workspace starts without a terminal' }),
46
49
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.61.0",
3
+ "version": "0.62.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.61.0",
3
+ "version": "0.62.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -406,7 +406,7 @@ function render() {
406
406
 
407
407
  const FRAME = !FIXTURE && !LEGACY_LAYOUT_FLAG;
408
408
  const FRAME_KEY = 'pmos.workspace.pm.frame';
409
- const FRAME_DESTS = Object.freeze(['today', 'initiatives', 'board', 'reports', 'docs', 'settings']);
409
+ const FRAME_DESTS = Object.freeze(['today', 'initiatives', 'board', 'reports', 'docs', 'people', 'settings']);
410
410
 
411
411
  function readFrame() {
412
412
  if (!FRAME) return {};
@@ -637,7 +637,7 @@ function pressIn(r, key) {
637
637
  if (b && !b.disabled) { b.click(); return true; }
638
638
  return false;
639
639
  }
640
- const G_KEYS = { t: 'today', i: 'initiatives', b: 'board', r: 'reports', d: 'docs' };
640
+ const G_KEYS = { t: 'today', i: 'initiatives', b: 'board', r: 'reports', d: 'docs', p: 'people' };
641
641
 
642
642
  function frameKey(e) {
643
643
  const mod = e.metaKey || e.ctrlKey;
@@ -4503,6 +4503,175 @@
4503
4503
  }
4504
4504
  }
4505
4505
 
4506
+ /* People (people-directory): what waits for the PM, the list beside the chosen person's page. */
4507
+ .rp-people {
4508
+ display: flex;
4509
+ flex-direction: column;
4510
+ gap: 16px;
4511
+ }
4512
+ .rp-people-top {
4513
+ display: flex;
4514
+ flex-wrap: wrap;
4515
+ align-items: center;
4516
+ gap: 12px;
4517
+ }
4518
+ .rp-people-top > .rp-head__title {
4519
+ margin-right: auto;
4520
+ }
4521
+ .rp-people-find {
4522
+ flex: 1 1 260px;
4523
+ max-width: 420px;
4524
+ }
4525
+ .rp-people-body {
4526
+ display: flex;
4527
+ flex-direction: column;
4528
+ gap: 16px;
4529
+ }
4530
+ .rp-people-review {
4531
+ padding: 12px;
4532
+ border: 1px solid var(--border);
4533
+ }
4534
+ .rp-people-grid {
4535
+ display: grid;
4536
+ grid-template-columns: minmax(260px, 2fr) 3fr;
4537
+ gap: 24px;
4538
+ align-items: start;
4539
+ }
4540
+ .rp-people-list {
4541
+ display: flex;
4542
+ flex-direction: column;
4543
+ gap: 8px;
4544
+ min-width: 0;
4545
+ }
4546
+ .rp-people-views {
4547
+ display: flex;
4548
+ flex-wrap: wrap;
4549
+ align-items: center;
4550
+ justify-content: space-between;
4551
+ gap: 8px;
4552
+ border-bottom: 1px solid var(--border);
4553
+ }
4554
+ .rp-people-row {
4555
+ border-top: 1px solid var(--border);
4556
+ }
4557
+ .rp-people-row:last-child {
4558
+ border-bottom: 1px solid var(--border);
4559
+ }
4560
+ .rp-people-row--open {
4561
+ box-shadow: inset 2px 0 0 var(--foreground);
4562
+ }
4563
+ .rp-people-page {
4564
+ display: flex;
4565
+ flex-direction: column;
4566
+ gap: 16px;
4567
+ min-width: 0;
4568
+ }
4569
+ .rp-people-who__name {
4570
+ margin: 0;
4571
+ font-size: 20px;
4572
+ font-weight: 500;
4573
+ }
4574
+ .rp-people-sec {
4575
+ display: flex;
4576
+ flex-direction: column;
4577
+ gap: 8px;
4578
+ }
4579
+ .rp-people-sec__title {
4580
+ margin: 0;
4581
+ font-size: 12px;
4582
+ font-weight: 500;
4583
+ color: var(--muted-foreground);
4584
+ }
4585
+ .rp-people-hint {
4586
+ margin: 0;
4587
+ font-size: 12px;
4588
+ color: var(--muted-foreground);
4589
+ }
4590
+ .rp-people-tone {
4591
+ margin: 0;
4592
+ font-size: 14px;
4593
+ }
4594
+ .rp-people-acts {
4595
+ display: flex;
4596
+ flex-wrap: wrap;
4597
+ gap: 8px;
4598
+ }
4599
+ .rp-people-ints,
4600
+ .rp-people-list-plain {
4601
+ display: flex;
4602
+ flex-direction: column;
4603
+ gap: 8px;
4604
+ margin: 0;
4605
+ padding: 0;
4606
+ list-style: none;
4607
+ }
4608
+ .rp-people-int {
4609
+ display: flex;
4610
+ flex-direction: column;
4611
+ gap: 2px;
4612
+ padding-bottom: 8px;
4613
+ border-bottom: 1px solid var(--border);
4614
+ }
4615
+ .rp-people-int__when,
4616
+ .rp-people-int__src {
4617
+ font-size: 12px;
4618
+ color: var(--muted-foreground);
4619
+ }
4620
+ .rp-people-int__src {
4621
+ overflow-wrap: anywhere;
4622
+ }
4623
+ .rp-people-int__text,
4624
+ .rp-people-shared,
4625
+ .rp-people-handle {
4626
+ font-size: 14px;
4627
+ }
4628
+ .rp-people-shared {
4629
+ display: flex;
4630
+ flex-wrap: wrap;
4631
+ align-items: center;
4632
+ gap: 8px;
4633
+ }
4634
+ .rp-people-note {
4635
+ display: flex;
4636
+ align-items: flex-start;
4637
+ justify-content: space-between;
4638
+ gap: 8px;
4639
+ padding: 8px;
4640
+ border: 1px solid var(--border);
4641
+ }
4642
+ .rp-people-note__text {
4643
+ font-size: 14px;
4644
+ white-space: pre-wrap;
4645
+ overflow-wrap: anywhere;
4646
+ }
4647
+ .rp-people-noteadd {
4648
+ display: flex;
4649
+ flex-direction: column;
4650
+ gap: 8px;
4651
+ }
4652
+ .rp-people-links {
4653
+ display: flex;
4654
+ flex-wrap: wrap;
4655
+ gap: 4px;
4656
+ }
4657
+ .rp-people-links:empty {
4658
+ display: none;
4659
+ }
4660
+ .rp-people-rel {
4661
+ display: flex;
4662
+ flex-direction: column;
4663
+ gap: 8px;
4664
+ }
4665
+ .rp-people-file {
4666
+ font-family: var(--mono);
4667
+ overflow-wrap: anywhere;
4668
+ }
4669
+ @media (max-width: 760px) {
4670
+ .rp-people-grid {
4671
+ grid-template-columns: 1fr;
4672
+ }
4673
+ }
4674
+
4506
4675
  /* -- layouts · .lg- the legacy and fixture shells (Run 3, slice 3D) -- */
4507
4676
  /* The legacy and fixture cockpit's two columns: the list head (a stack) and the rail's tile column. */
4508
4677
  .lg-stack {