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.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/first-paint-audit.js +8 -3
- package/workspace/guide/index.md +5 -3
- package/workspace/guide/layout.md +4 -3
- package/workspace/guide/people.md +70 -0
- package/workspace/guide/today.md +2 -2
- package/workspace/lib/calendar-memory.js +2 -1
- package/workspace/lib/people-cli.js +135 -0
- package/workspace/lib/people-store.js +123 -0
- package/workspace/lib/people.js +399 -0
- package/workspace/lib/pmos-verbs.js +3 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/app.js +2 -2
- package/workspace/public/pm/components.css +169 -0
- package/workspace/public/pm/dest-people.js +385 -0
- package/workspace/public/pm/dest-settings.js +44 -3
- package/workspace/public/pm/frame-copy.js +7 -0
- package/workspace/public/pm/initiative-sheet.js +5 -2
- package/workspace/public/pm/initiatives-copy.js +1 -0
- package/workspace/public/pm/item-sheet.js +18 -1
- package/workspace/public/pm/people-link.js +106 -0
- package/workspace/public/pm/registry.js +2 -0
- package/workspace/public/pm/today-agenda.js +3 -1
- package/workspace/public/pm/today-day.js +6 -3
- package/workspace/public/pm/topbar.js +4 -3
- package/workspace/server.js +27 -0
- package/workspace/skills/gather-requirements.skill +5 -3
- package/workspace/skills/jira-sync.skill +5 -4
- package/workspace/skills/live-notes.skill +3 -1
- package/workspace/skills/morning-brief.skill +5 -3
- 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
|
});
|
package/workspace/package.json
CHANGED
|
@@ -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 {
|