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.
- 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 +4 -2
- package/workspace/guide/index.md +6 -3
- package/workspace/guide/layout.md +5 -4
- package/workspace/guide/notes.md +72 -0
- package/workspace/guide/people.md +70 -0
- package/workspace/lib/calendar-memory.js +2 -1
- package/workspace/lib/jev-notes.js +162 -0
- package/workspace/lib/notes-repo.js +133 -0
- package/workspace/lib/notes-whisper.js +200 -0
- package/workspace/lib/notes.js +511 -0
- 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/lib/route-modules.js +1 -0
- package/workspace/lib/routes-notes.js +230 -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 +441 -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/panel-cockpit.js +3 -0
- 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 +2 -0
- package/workspace/public/pm/topbar.js +4 -3
- package/workspace/public/pm/view-notes.js +723 -0
- package/workspace/replaced-tests.json +14 -0
- package/workspace/server.js +30 -1
- 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/meeting-notes.json +15 -0
- package/workspace/test-lists/people-directory.json +6 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* people-cli.js — `pmos people`: the people the PM works with in this repository, from a terminal
|
|
3
|
+
* (initiative people-directory, PRD R2, R9, R14, proposed D90).
|
|
4
|
+
*
|
|
5
|
+
* pmos people [list] [--json] each person: name, role, last interaction, tone; and the review list
|
|
6
|
+
* pmos people show <person> [--json] one person's page: interactions, tone, shared work, notes, handles
|
|
7
|
+
* pmos people record --file <sightings.json> (agent session) append what the session saw
|
|
8
|
+
* pmos people settings [--json] whether calendar attendee names are kept
|
|
9
|
+
*
|
|
10
|
+
* The same module the People place reads (lib/people-store.js), so a terminal line and the page cannot disagree.
|
|
11
|
+
* No command adds a person, merges, sets a tone or writes a note: those are the PM's presses in People (D90).
|
|
12
|
+
* `record` must name who recorded: the session's PMOS_SESSION token, or `--by <profile>` outside the workspace.
|
|
13
|
+
*
|
|
14
|
+
* Exit codes: 0 done, 1 refused, 2 usage.
|
|
15
|
+
*/
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const store = require('./people-store');
|
|
19
|
+
const { parseArgs } = require('./schedule-cli');
|
|
20
|
+
|
|
21
|
+
const COMMANDS = { people: ['list', 'show', 'record', 'settings'] };
|
|
22
|
+
const USAGE = [
|
|
23
|
+
'usage: pmos people [list|show|record|settings] — the people you work with in this repository',
|
|
24
|
+
' pmos people [list] [--json] each person, and what waits for you in the review list',
|
|
25
|
+
' pmos people show <person> [--json] one person: interactions, tone, shared work, notes and handles',
|
|
26
|
+
' pmos people record --file <sightings.json> (agent session) record the people a session saw',
|
|
27
|
+
' pmos people settings [--json] whether calendar attendee names are kept (calendar names: on|off)',
|
|
28
|
+
'Nothing here adds a person, merges, sets a tone or writes a note. Do that in People.',
|
|
29
|
+
];
|
|
30
|
+
const BOOL = new Set(['json', 'help']);
|
|
31
|
+
|
|
32
|
+
function parse(argv) {
|
|
33
|
+
const rest = []; const bools = {};
|
|
34
|
+
for (const a of argv) {
|
|
35
|
+
if (a.startsWith('--') && BOOL.has(a.slice(2))) bools[a.slice(2)] = true;
|
|
36
|
+
else if (a === '-h') bools.help = true;
|
|
37
|
+
else rest.push(a);
|
|
38
|
+
}
|
|
39
|
+
const { pos, flags } = parseArgs(rest);
|
|
40
|
+
return { pos, flags: { ...flags, ...bools } };
|
|
41
|
+
}
|
|
42
|
+
/** The repository holding `cwd`: the nearest folder with a .git entry. No process is started (R16). */
|
|
43
|
+
function repoRoot(cwd) {
|
|
44
|
+
for (let d = path.resolve(cwd); ; d = path.dirname(d)) {
|
|
45
|
+
if (fs.existsSync(path.join(d, '.git'))) return d;
|
|
46
|
+
if (path.dirname(d) === d) return cwd;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Who is recording: the workspace session's token names it; outside the workspace, the profile the PM names. */
|
|
50
|
+
function whoOf(flags, env) {
|
|
51
|
+
const tok = String(env.PMOS_SESSION || '').trim();
|
|
52
|
+
if (tok) return 'session:' + tok.split('.')[0].slice(0, 60);
|
|
53
|
+
if (flags.by) return 'profile:' + String(flags.by).slice(0, 60);
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const toneWord = (t) => (t.confirmed ? t.confirmed : t.proposed ? `${t.proposed} (proposed)` : 'no tone yet');
|
|
57
|
+
const handleText = (h) => `${h.kind} ${h.value}`;
|
|
58
|
+
|
|
59
|
+
async function run(argv, { say = console.log, cwd = process.cwd(), env = process.env, now = Date.now() } = {}) {
|
|
60
|
+
const { pos, flags } = parse(argv.slice(1));
|
|
61
|
+
const sub = pos[0] || 'list';
|
|
62
|
+
if (flags.help || sub === 'help') { USAGE.forEach((l) => say(l)); return 0; }
|
|
63
|
+
const json = !!flags.json;
|
|
64
|
+
const root = flags.root ? path.resolve(cwd, flags.root) : repoRoot(cwd);
|
|
65
|
+
const fail = (error, code = 1) => { if (json) say(JSON.stringify({ ok: false, error })); else say(`✗ ${error}`); return code; };
|
|
66
|
+
|
|
67
|
+
if (sub === 'list') {
|
|
68
|
+
const v = store.view(root, { now });
|
|
69
|
+
if (!v.ok) return fail(v.error);
|
|
70
|
+
if (json) { say(JSON.stringify({ ok: true, people: v.people, review: v.review, settings: v.settings, counts: v.counts })); return 0; }
|
|
71
|
+
const r = v.review;
|
|
72
|
+
say(`People · ${v.people.length} ${v.people.length === 1 ? 'person' : 'people'} · ${r.new.length} new · ${r.merges.length} possible duplicate${r.merges.length === 1 ? '' : 's'} · ${r.tones.length} tone${r.tones.length === 1 ? '' : 's'} to confirm`);
|
|
73
|
+
if (!v.people.length && !r.new.length) { say('Nobody yet. Agent sessions record the people they see; you add them in People.'); return 0; }
|
|
74
|
+
for (const p of v.people) {
|
|
75
|
+
say(`${p.name} (${p.id}) ${[p.role, p.org].filter(Boolean).join(' · ') || p.group} · ${p.count} interaction${p.count === 1 ? '' : 's'}${p.last ? ' · last ' + p.last : ''} · ${toneWord(p.tone)}`);
|
|
76
|
+
}
|
|
77
|
+
if (r.new.length) {
|
|
78
|
+
say('New, waiting for you in People:');
|
|
79
|
+
for (const f of r.new) say(` ${f.name} ${handleText(f.handle)} · ${f.count} sighting${f.count === 1 ? '' : 's'} from ${f.sources.join(', ')}`);
|
|
80
|
+
}
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (sub === 'show') {
|
|
85
|
+
const id = pos[1];
|
|
86
|
+
if (!id) return fail('usage: pmos people show <person>', 2);
|
|
87
|
+
const v = store.view(root, { now });
|
|
88
|
+
if (!v.ok) return fail(v.error);
|
|
89
|
+
const q = String(id).toLowerCase();
|
|
90
|
+
const p = v.people.find((x) => x.id === q) || v.people.find((x) => x.name.toLowerCase() === q);
|
|
91
|
+
if (!p) return fail(`no person ${id} — pmos people list names everyone`);
|
|
92
|
+
if (json) { say(JSON.stringify({ ok: true, person: p })); return 0; }
|
|
93
|
+
say(`${p.name} (${p.id}) ${[p.role, p.org].filter(Boolean).join(' · ')} ${p.group}`);
|
|
94
|
+
const mix = Object.entries(p.tone.mix).map(([t, n]) => `${n} ${t}`).join(', ');
|
|
95
|
+
say(`Tone: ${toneWord(p.tone)}${mix ? ' · ' + mix : ''}`);
|
|
96
|
+
say(`Interactions (${p.count}):`);
|
|
97
|
+
for (const i of p.interactions.slice(0, 20)) say(` ${i.date} ${i.kind.padEnd(7)} ${i.text}${i.tone ? ' [' + i.tone + ']' : ''} — ${i.source}: ${i.pointer}`);
|
|
98
|
+
const s = p.shared;
|
|
99
|
+
if (s.initiatives.length) say(`Initiatives: ${s.initiatives.map((g) => `${g.id} (${g.count})`).join(', ')}`);
|
|
100
|
+
for (const it of s.items) say(` Owns ${it.id} ${it.title || ''} · ${it.state}${it.blocked ? ' · Blocked' + (it.reason ? ': ' + it.reason : '') : ''}`);
|
|
101
|
+
for (const m of s.meetings) say(` Meeting ${String(m.start).slice(0, 16).replace('T', ' ')} ${m.title}`);
|
|
102
|
+
if (p.notes.length) { say('Notes:'); for (const n of p.notes) say(` ${n.text}`); }
|
|
103
|
+
say(`Handles: ${p.handles.map(handleText).join(' · ')}`);
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (sub === 'settings') {
|
|
108
|
+
const s = store.settings(root);
|
|
109
|
+
if (!s.ok) return fail(s.error);
|
|
110
|
+
if (json) say(JSON.stringify({ ok: true, calendar_names: s.calendar_names }));
|
|
111
|
+
else say(`calendar names: ${s.calendar_names ? 'on' : 'off'}`);
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (sub === 'record') {
|
|
116
|
+
if (!flags.file) return fail('usage: pmos people record --file <sightings.json>', 2);
|
|
117
|
+
const by = whoOf(flags, env);
|
|
118
|
+
if (!by) return fail('a sighting must name who recorded it: run it in a workspace session, or add --by <profile>');
|
|
119
|
+
let parsed;
|
|
120
|
+
try { parsed = JSON.parse(fs.readFileSync(path.resolve(cwd, flags.file), 'utf8')); }
|
|
121
|
+
catch (e) { return fail(`could not read ${flags.file}: ${String((e && e.message) || e).slice(0, 160)}`); }
|
|
122
|
+
const sightings = Array.isArray(parsed) ? parsed : (parsed && parsed.sightings) || [];
|
|
123
|
+
const merges = (!Array.isArray(parsed) && parsed && parsed.merges) || [];
|
|
124
|
+
const r = store.record(root, { by, sightings, merges, now });
|
|
125
|
+
if (json) { say(JSON.stringify(r)); return r.ok ? 0 : 1; }
|
|
126
|
+
if (!r.ok && r.recorded === undefined) return fail(r.error);
|
|
127
|
+
say(`${r.recorded || 0} sighting(s) recorded · ${r.duplicates || 0} already recorded · ${r.proposals || 0} merge proposal(s) · ${(r.refused || []).length} refused`);
|
|
128
|
+
for (const x of r.refused || []) say(`✗ ${x.index !== undefined ? 'sighting ' + x.index : 'merge ' + x.merge}: ${x.error}`);
|
|
129
|
+
if (!r.ok) say(`✗ ${r.error}`);
|
|
130
|
+
return r.ok ? 0 : 1;
|
|
131
|
+
}
|
|
132
|
+
return fail(`pmos people ${sub} is not a command. ${USAGE[0]}`, 2);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = { run, COMMANDS, USAGE };
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* people-store.js — the file side of People (initiative people-directory, PRD R1 to R10, proposed D90).
|
|
3
|
+
*
|
|
4
|
+
* lib/people.js decides; this module reads and appends. The `pmos people` verb and the /api/people routes both call
|
|
5
|
+
* it, so the terminal and the People place cannot hold two opinions about a person.
|
|
6
|
+
*
|
|
7
|
+
* view(root, { now, meetings }) → { ok, people, review, settings, counts, file }
|
|
8
|
+
* record(root, { by, sightings, merges, now }) → { ok, recorded, duplicates, refused[], error? }
|
|
9
|
+
* decide(root, { actor, body, now }) → { ok, row } | { ok:false, error } one PM page press
|
|
10
|
+
* settings(root) → { ok, calendar_names, file }
|
|
11
|
+
*
|
|
12
|
+
* Every write is an append through people.js's validators, so a refused row is never on disk. No network, no process.
|
|
13
|
+
*/
|
|
14
|
+
const crypto = require('crypto');
|
|
15
|
+
const P = require('./people');
|
|
16
|
+
const fourPanel = require('./four-panel');
|
|
17
|
+
const consoleState = require('./console-state');
|
|
18
|
+
|
|
19
|
+
const MAX_PER_RECORD = 500;
|
|
20
|
+
|
|
21
|
+
function stateDir(root) {
|
|
22
|
+
const e = consoleState.loadEngine();
|
|
23
|
+
if (!e.ok) return { ok: false, error: e.error };
|
|
24
|
+
const dir = fourPanel.storeDir(e.engine, root);
|
|
25
|
+
return dir ? { ok: true, dir } : { ok: false, error: 'this repository has no file-mode state directory' };
|
|
26
|
+
}
|
|
27
|
+
function readRows(root) {
|
|
28
|
+
const s = stateDir(root);
|
|
29
|
+
if (!s.ok) return s;
|
|
30
|
+
const r = fourPanel.readDeclarations(s.dir, P.LEDGER);
|
|
31
|
+
if (!r.ok) return { ok: false, error: r.error };
|
|
32
|
+
return { ok: true, dir: s.dir, file: r.file, rows: r.rows };
|
|
33
|
+
}
|
|
34
|
+
/** The plan items, for shared work (R10). A repository without a plan gives none. */
|
|
35
|
+
function planItems(root) {
|
|
36
|
+
try { return Object.values(require('./plan-items').readFold(root).items || {}); } catch { return []; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function view(root, { now = Date.now(), meetings = [] } = {}) {
|
|
40
|
+
const r = readRows(root);
|
|
41
|
+
if (!r.ok) return r;
|
|
42
|
+
const v = P.fold(r.rows, { now, items: planItems(root), meetings });
|
|
43
|
+
return { ok: true, ...v, file: r.file };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function settings(root) {
|
|
47
|
+
const r = readRows(root);
|
|
48
|
+
if (!r.ok) return r;
|
|
49
|
+
return { ok: true, calendar_names: P.fold(r.rows).settings.calendar_names, file: r.file };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const stamp = (row, by, now) => ({ v: 1, ...row, by, at: new Date(now).toISOString() });
|
|
53
|
+
const sightKey = (r) => [P.handleKey(r.handle), r.interaction ? r.interaction.date : '', r.interaction ? r.interaction.pointer : '', r.interaction ? r.interaction.text : ''].join('\u0000');
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Append what an agent session saw. `by` names who recorded it (a session token or a profile); it is required.
|
|
57
|
+
* A calendar sighting is refused while the PM's calendar setting is off (R3). A sighting already on disk is skipped.
|
|
58
|
+
*/
|
|
59
|
+
function record(root, { by, sightings = [], merges = [], now = Date.now() } = {}) {
|
|
60
|
+
if (typeof by !== 'string' || !/^(session|profile):\S{1,60}$/.test(by)) return { ok: false, error: 'a sighting must name who recorded it' };
|
|
61
|
+
if (!Array.isArray(sightings) || !Array.isArray(merges)) return { ok: false, error: 'sightings and merges must be lists' };
|
|
62
|
+
if (sightings.length + merges.length > MAX_PER_RECORD) return { ok: false, error: `at most ${MAX_PER_RECORD} rows in one record` };
|
|
63
|
+
const r = readRows(root);
|
|
64
|
+
if (!r.ok) return r;
|
|
65
|
+
const calendarOn = P.fold(r.rows).settings.calendar_names;
|
|
66
|
+
const seen = new Set(r.rows.filter((x) => x && x.kind === 'sighting').map(sightKey));
|
|
67
|
+
const refused = [];
|
|
68
|
+
let recorded = 0; let duplicates = 0;
|
|
69
|
+
for (let i = 0; i < sightings.length; i++) {
|
|
70
|
+
const c = P.checkSighting(sightings[i]);
|
|
71
|
+
if (!c.ok) { refused.push({ index: i, error: c.error }); continue; }
|
|
72
|
+
if (c.row.source === 'calendar' && !calendarOn) { refused.push({ index: i, error: 'calendar names are off for this repository (Settings → People)' }); continue; }
|
|
73
|
+
const k = sightKey(c.row);
|
|
74
|
+
if (seen.has(k)) { duplicates++; continue; }
|
|
75
|
+
const w = fourPanel.appendDeclaration(r.dir, P.LEDGER, stamp(c.row, by, now));
|
|
76
|
+
if (!w.ok) return { ok: false, error: w.error, recorded, duplicates, refused };
|
|
77
|
+
seen.add(k); recorded++;
|
|
78
|
+
}
|
|
79
|
+
let proposals = 0;
|
|
80
|
+
for (let i = 0; i < merges.length; i++) {
|
|
81
|
+
const c = P.checkMergeProposal(merges[i]);
|
|
82
|
+
if (!c.ok) { refused.push({ merge: i, error: c.error }); continue; }
|
|
83
|
+
const w = fourPanel.appendDeclaration(r.dir, P.LEDGER, stamp(c.row, by, now));
|
|
84
|
+
if (!w.ok) return { ok: false, error: w.error, recorded, duplicates, refused };
|
|
85
|
+
proposals++;
|
|
86
|
+
}
|
|
87
|
+
return { ok: true, recorded, proposals, duplicates, refused };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A new note id: n- and eight letters or digits. */
|
|
91
|
+
const noteId = () => 'n-' + crypto.randomBytes(6).toString('base64').replace(/[^0-9a-z]/gi, '').toLowerCase().slice(0, 8).padEnd(8, '0');
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* One PM press. `actor` is requirePagePress's actor ({ by: 'pm:<slug>' }); the route has already checked the press.
|
|
95
|
+
* Add refuses a handle that a person already holds; merge, tone and notes refuse a person who does not exist.
|
|
96
|
+
*/
|
|
97
|
+
function decide(root, { actor, body, now = Date.now() } = {}) {
|
|
98
|
+
const by = actor && typeof actor.by === 'string' ? actor.by : null;
|
|
99
|
+
if (!by || !/^pm:/.test(by)) return { ok: false, error: 'only the PM decides who is a person' };
|
|
100
|
+
const b = body && typeof body === 'object' ? { ...body } : {};
|
|
101
|
+
if (b.act === 'note' && !b.id) b.id = noteId();
|
|
102
|
+
const c = P.checkDecision(b);
|
|
103
|
+
if (!c.ok) return c;
|
|
104
|
+
const r = readRows(root);
|
|
105
|
+
if (!r.ok) return r;
|
|
106
|
+
const v = P.fold(r.rows);
|
|
107
|
+
const row = c.row;
|
|
108
|
+
if (row.person && !v.people.some((p) => p.id === row.person)) return { ok: false, error: `no person ${row.person}` };
|
|
109
|
+
if (row.kind === 'add' && v.people.some((p) => p.handles.some((h) => P.handleKey(h) === P.handleKey(row.handle)))) {
|
|
110
|
+
return { ok: false, error: 'a person already holds that handle' };
|
|
111
|
+
}
|
|
112
|
+
const w = fourPanel.appendDeclaration(r.dir, P.LEDGER, stamp(row, by, now));
|
|
113
|
+
if (!w.ok) return { ok: false, error: w.error };
|
|
114
|
+
let person = row.person || null;
|
|
115
|
+
if (row.kind === 'add') {
|
|
116
|
+
const after = P.fold([...r.rows, stamp(row, by, now)]);
|
|
117
|
+
const p = after.people.find((x) => x.handles.some((h) => P.handleKey(h) === P.handleKey(row.handle)));
|
|
118
|
+
person = p ? p.id : null;
|
|
119
|
+
}
|
|
120
|
+
return { ok: true, row: w.recorded, person };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { view, record, decide, settings, stateDir, readRows };
|
|
@@ -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
|
+
};
|