create-pmos 0.63.2 → 0.64.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/guide/people.md +19 -10
- package/workspace/lib/people-cli.js +7 -8
- package/workspace/lib/people-store.js +110 -1
- package/workspace/lib/people.js +74 -50
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/dest-people.js +13 -27
- package/workspace/server.js +8 -5
- package/workspace/skills/gather-requirements.skill +2 -2
- package/workspace/skills/jira-sync.skill +1 -1
- package/workspace/skills/live-notes.skill +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.64.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-06T23:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-06T23:35:55Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Health budgets"
|
|
4
4
|
description: "Closed registry of health-budget anchors (D59), edited only by the PM."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-06T23:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-06T23:35:55Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-06T23:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-06T23:35:55Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -12,20 +12,26 @@ People lists the people you work with in the current repository. For each person
|
|
|
12
12
|
|
|
13
13
|
## How people get into People
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
People adds every person it finds. It reads the names the workspace already holds for the repository each time you open People:
|
|
16
16
|
|
|
17
|
-
- **
|
|
18
|
-
- **Live notes
|
|
17
|
+
- **Plan items.** The owner of each item, when the owner is a person.
|
|
18
|
+
- **Live notes.** Each named speaker. A session marked private is skipped.
|
|
19
|
+
- **Meeting notes.** The speakers you renamed and the action owners the summary finds, in your notes for this repository. A note marked private is skipped.
|
|
20
|
+
- **Calendar.** The attendees this machine remembers, but only when **Keep attendee names from the calendar** is on (see Settings below).
|
|
21
|
+
|
|
22
|
+
The workspace does not read your mail, chat or Jira. Agent sessions that already read them with your own connectors record the people they see:
|
|
23
|
+
|
|
24
|
+
- **Gather requirements** records the author of each item it gathers and the people an item names.
|
|
25
|
+
- **Live notes** records each named speaker and each named action owner.
|
|
19
26
|
- **Jira sync** records assignees and reporters by their Jira name.
|
|
20
|
-
- **Morning brief** records meeting attendees, but only when
|
|
27
|
+
- **Morning brief** records meeting attendees, but only when calendar names are on.
|
|
21
28
|
|
|
22
|
-
Each record is a sighting: a name, a handle (a mail address, a chat or Jira name, a
|
|
29
|
+
Each record is a sighting: a name, a handle (a mail address, a chat or Jira name, a speaker, or only a name), and at most one interaction line with a pointer to where it came from. No message body is kept. Private, personal, confidential and HR material is skipped. A numbered speaker such as "Speaker 2" is not a person.
|
|
23
30
|
|
|
24
|
-
A sighting
|
|
31
|
+
A sighting of a new handle adds the person. A sighting with the full name of a person in People joins that person. A first name alone, from a speaker or a name, joins the one person with that first name. When two people have that first name, it joins neither of them.
|
|
25
32
|
|
|
26
33
|
## Waiting for you
|
|
27
34
|
|
|
28
|
-
- **A new person.** Press **Add to People** to add them, or **Ignore** to hide them. Later sightings of an ignored handle stay hidden.
|
|
29
35
|
- **A possible duplicate.** When a session thinks two handles are one person, it says why. Press **Merge** to join the handle to that person, or **Keep separate** to close the question.
|
|
30
36
|
- **Tones to confirm.** The count of people whose tone you have not confirmed. Open the person to confirm it.
|
|
31
37
|
|
|
@@ -39,7 +45,8 @@ Type in **Find a person** to search names, handles and initiatives. **All**, **T
|
|
|
39
45
|
- **Tone.** The mix of tones (warm, neutral, cautious, tense) and a proposed overall tone: the most frequent tone of the latest 10 interactions. When two tones are tied, the latest one wins. Press **Confirm** to keep the proposal, **Set another** to choose a different tone, or **Change** to clear your choice. Tone describes the exchanges, not the person.
|
|
40
46
|
- **Shared work.** The initiatives your interactions name, the plan items the person owns (with **Blocked** when the item is blocked), and the coming meetings that name them when calendar names are on. Nothing here is typed by hand.
|
|
41
47
|
- **Notes.** Type a note and press **Add note**. Press **Remove** to remove one. Only you can add or remove a note. No command writes one.
|
|
42
|
-
- **Identity and sources.** The person's handles, how many interactions each source gave, and the file the person lives in.
|
|
48
|
+
- **Identity and sources.** The person's handles, how many interactions each source gave, where People found them, and the file the person lives in.
|
|
49
|
+
- **Remove from People.** Takes the person out. Later sightings of their handles stay out.
|
|
43
50
|
|
|
44
51
|
## Ways in from other places
|
|
45
52
|
|
|
@@ -65,6 +72,8 @@ People keeps one append-only file, `people.jsonl`, in the repository's state dir
|
|
|
65
72
|
- `pmos people settings` prints `calendar names: on` or `calendar names: off`. The morning brief reads it before it records attendees.
|
|
66
73
|
- `pmos people help` prints the commands.
|
|
67
74
|
|
|
68
|
-
The sightings file holds `{ "sightings": [...], "merges": [...] }`. A sighting is `{ "source": "gather" | "calendar" | "live" | "jira", "handle": { "kind": "mail" | "slack" | "jira" | "confluence" | "speaker" | "name", "value": "…" }, "name": "…", "role"?, "org"?, "external"?: true, "interaction"?: { "date": "YYYY-MM-DD", "kind": "Mail", "text": "one line, at most 200 characters", "pointer": "where it came from, at most 200 characters", "tone"?: "warm", "initiative"?: "<id>" } }`. A merge is `{ "a": <handle>, "b": <handle>, "why": "one line" }`.
|
|
75
|
+
The sightings file holds `{ "sightings": [...], "merges": [...] }`. A sighting is `{ "source": "gather" | "calendar" | "live" | "jira" | "plan" | "notes", "handle": { "kind": "mail" | "slack" | "jira" | "confluence" | "speaker" | "name", "value": "…" }, "name": "…", "role"?, "org"?, "external"?: true, "interaction"?: { "date": "YYYY-MM-DD", "kind": "Mail", "text": "one line, at most 200 characters", "pointer": "where it came from, at most 200 characters", "tone"?: "warm", "initiative"?: "<id>" } }`. A merge is `{ "a": <handle>, "b": <handle>, "why": "one line" }`.
|
|
76
|
+
|
|
77
|
+
`pmos people list` and `pmos people show` first add the plan item owners and Live notes speakers, as People does.
|
|
69
78
|
|
|
70
|
-
No command
|
|
79
|
+
No command removes a person, merges a handle, sets a tone or writes a note. Do those in People.
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* pmos people settings [--json] whether calendar attendee names are kept
|
|
9
9
|
*
|
|
10
10
|
* The same module the People place reads (lib/people-store.js), so a terminal line and the page cannot disagree.
|
|
11
|
-
*
|
|
11
|
+
* `list` and `show` first record the names this repository's records hold (plan item owners, Live notes speakers), as
|
|
12
|
+
* the People place does. No command removes a person, merges, sets a tone or writes a note: those are the PM's presses
|
|
13
|
+
* in People (D90).
|
|
12
14
|
* `record` must name who recorded: the session's PMOS_SESSION token, or `--by <profile>` outside the workspace.
|
|
13
15
|
*
|
|
14
16
|
* Exit codes: 0 done, 1 refused, 2 usage.
|
|
@@ -25,7 +27,7 @@ const USAGE = [
|
|
|
25
27
|
' pmos people show <person> [--json] one person: interactions, tone, shared work, notes and handles',
|
|
26
28
|
' pmos people record --file <sightings.json> (agent session) record the people a session saw',
|
|
27
29
|
' pmos people settings [--json] whether calendar attendee names are kept (calendar names: on|off)',
|
|
28
|
-
'Nothing here
|
|
30
|
+
'Nothing here removes a person, merges, sets a tone or writes a note. Do that in People.',
|
|
29
31
|
];
|
|
30
32
|
const BOOL = new Set(['json', 'help']);
|
|
31
33
|
|
|
@@ -64,20 +66,17 @@ async function run(argv, { say = console.log, cwd = process.cwd(), env = process
|
|
|
64
66
|
const root = flags.root ? path.resolve(cwd, flags.root) : repoRoot(cwd);
|
|
65
67
|
const fail = (error, code = 1) => { if (json) say(JSON.stringify({ ok: false, error })); else say(`✗ ${error}`); return code; };
|
|
66
68
|
|
|
69
|
+
if (sub === 'list' || sub === 'show') store.scan(root, { now });
|
|
67
70
|
if (sub === 'list') {
|
|
68
71
|
const v = store.view(root, { now });
|
|
69
72
|
if (!v.ok) return fail(v.error);
|
|
70
73
|
if (json) { say(JSON.stringify({ ok: true, people: v.people, review: v.review, settings: v.settings, counts: v.counts })); return 0; }
|
|
71
74
|
const r = v.review;
|
|
72
|
-
say(`People · ${v.people.length} ${v.people.length === 1 ? 'person' : 'people'} · ${r.
|
|
73
|
-
if (!v.people.length
|
|
75
|
+
say(`People · ${v.people.length} ${v.people.length === 1 ? 'person' : 'people'} · ${r.merges.length} possible duplicate${r.merges.length === 1 ? '' : 's'} · ${r.tones.length} tone${r.tones.length === 1 ? '' : 's'} to confirm`);
|
|
76
|
+
if (!v.people.length) { say('Nobody yet. People adds the owners of plan items, Live notes speakers, and the people agent sessions record.'); return 0; }
|
|
74
77
|
for (const p of v.people) {
|
|
75
78
|
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
79
|
}
|
|
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
80
|
return 0;
|
|
82
81
|
}
|
|
83
82
|
|
|
@@ -8,8 +8,16 @@
|
|
|
8
8
|
* record(root, { by, sightings, merges, now }) → { ok, recorded, duplicates, refused[], error? }
|
|
9
9
|
* decide(root, { actor, body, now }) → { ok, row } | { ok:false, error } one PM page press
|
|
10
10
|
* settings(root) → { ok, calendar_names, file }
|
|
11
|
+
* scan(root, { now, calendar, notesDir, ws }) → { ok, recorded, found } the names the workspace's own records hold
|
|
11
12
|
*
|
|
12
13
|
* Every write is an append through people.js's validators, so a refused row is never on disk. No network, no process.
|
|
14
|
+
*
|
|
15
|
+
* SCAN (the PM, 2026-10-06: "If you detect a person's name, add it to the people directory"). People no longer waits for
|
|
16
|
+
* an agent session alone. `scan` reads the names the workspace already holds and records each as a sighting by
|
|
17
|
+
* `workspace:scan`, which adds the person (people.js): plan item owners (`person:<name>`), the speakers of this
|
|
18
|
+
* repository's Live notes sessions, the renamed speakers and action owners of the PM's meeting notes for this repository,
|
|
19
|
+
* and, only while calendar names are on, the attendees this machine remembers from the calendar. A private Live notes
|
|
20
|
+
* session or a private note is skipped. A sighting already on disk is not written again, so a scan writes only what is new.
|
|
13
21
|
*/
|
|
14
22
|
const crypto = require('crypto');
|
|
15
23
|
const P = require('./people');
|
|
@@ -17,6 +25,8 @@ const fourPanel = require('./four-panel');
|
|
|
17
25
|
const consoleState = require('./console-state');
|
|
18
26
|
|
|
19
27
|
const MAX_PER_RECORD = 500;
|
|
28
|
+
const fs = require('fs');
|
|
29
|
+
const path = require('path');
|
|
20
30
|
|
|
21
31
|
function stateDir(root) {
|
|
22
32
|
const e = consoleState.loadEngine();
|
|
@@ -120,4 +130,103 @@ function decide(root, { actor, body, now = Date.now() } = {}) {
|
|
|
120
130
|
return { ok: true, row: w.recorded, person };
|
|
121
131
|
}
|
|
122
132
|
|
|
123
|
-
|
|
133
|
+
/* ── scan: the names the workspace's own records hold ─────────────────────────────────────────────────────────────── */
|
|
134
|
+
// Not names: the notes' default speakers, the action owners that are not a person, and numbered speakers.
|
|
135
|
+
const NOT_A_NAME = /^(you|me|meeting|mic|speech|unknown|someone|everyone|the speaker|the team|team|we|speaker\s*\d*|participant\s*\d*|guest\s*\d*)$/i;
|
|
136
|
+
function personName(v) {
|
|
137
|
+
const n = String(v == null ? '' : v).replace(/\s+/g, ' ').trim().replace(/^@/, '');
|
|
138
|
+
if (n.length < 2 || n.length > 80 || !/\p{L}/u.test(n) || NOT_A_NAME.test(n)) return null;
|
|
139
|
+
return n;
|
|
140
|
+
}
|
|
141
|
+
const day = (v) => (typeof v === 'string' && /^\d{4}-\d{2}-\d{2}/.test(v) ? v.slice(0, 10) : null);
|
|
142
|
+
const lines = (file) => { try { return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch { return []; } };
|
|
143
|
+
/** "Ana Lee, Bo Chen and kim@x.io" → ["Ana Lee", "Bo Chen", "kim@x.io"]. */
|
|
144
|
+
const splitNames = (w) => String(w || '').split(/\s*(?:,|;|&|\band\b|\/)\s*/i).map((x) => x.replace(/\(.*?\)/g, '').trim()).filter(Boolean);
|
|
145
|
+
|
|
146
|
+
/** The sightings the workspace's records hold for this repository. Pure apart from reading files. */
|
|
147
|
+
function sightingsFrom(root, { items = [], calendar = [], calendarOn = false, notesDir = null, ws = null } = {}) {
|
|
148
|
+
const out = [];
|
|
149
|
+
const push = (source, kind, value, name, interaction) => {
|
|
150
|
+
const n = personName(name);
|
|
151
|
+
if (!n) return;
|
|
152
|
+
const s = { source, handle: { kind, value: String(value).slice(0, 120) }, name: n };
|
|
153
|
+
if (interaction && interaction.date) s.interaction = { ...interaction, text: interaction.text.slice(0, 200), pointer: interaction.pointer.slice(0, 200) };
|
|
154
|
+
out.push(s);
|
|
155
|
+
};
|
|
156
|
+
// plan item owners: the person's shared work already lists the items, so the sighting carries no interaction
|
|
157
|
+
for (const it of items || []) {
|
|
158
|
+
if (!it || it.state === 'withdrawn' || typeof it.owner !== 'string' || !it.owner.startsWith('person:')) continue;
|
|
159
|
+
const n = personName(it.owner.slice(7));
|
|
160
|
+
if (n) push('plan', 'name', n, n, null);
|
|
161
|
+
}
|
|
162
|
+
// Live notes sessions in this repository: one interaction per speaker per session
|
|
163
|
+
const lnDir = path.join(fs.existsSync(path.join(root, 'pmos', 'state')) ? path.join(root, 'pmos', 'state') : path.join(root, 'planning'), 'live-notes');
|
|
164
|
+
let files = []; try { files = fs.readdirSync(lnDir).filter((f) => f.endsWith('.jsonl')).sort(); } catch { /* no sessions */ }
|
|
165
|
+
for (const f of files) {
|
|
166
|
+
const rows = lines(path.join(lnDir, f));
|
|
167
|
+
if (rows.some((r) => r.row === 'private')) continue;
|
|
168
|
+
const session = f.replace(/\.jsonl$/, '');
|
|
169
|
+
const start = day((rows.find((r) => r.row === 'session') || {}).at) || day((rows[0] || {}).at);
|
|
170
|
+
const said = new Set();
|
|
171
|
+
for (const r of rows) {
|
|
172
|
+
const sp = r && r.pointer && personName(r.pointer.speaker);
|
|
173
|
+
if (!sp || said.has(sp.toLowerCase())) continue;
|
|
174
|
+
said.add(sp.toLowerCase());
|
|
175
|
+
push('live', 'speaker', sp, sp, { date: start || day(r.at), kind: 'Meeting', text: 'Spoke in a Live notes session', pointer: `live notes ${session}` });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// the PM's meeting notes for this repository: renamed speakers and the action owners the local summary reads
|
|
179
|
+
if (notesDir && ws) {
|
|
180
|
+
let nf = []; try { nf = fs.readdirSync(path.join(notesDir, 'notes')).filter((f) => f.endsWith('.json')); } catch { /* no notes */ }
|
|
181
|
+
let notesLib = null; try { notesLib = require('./notes'); } catch { /* notes unavailable */ }
|
|
182
|
+
for (const f of nf) {
|
|
183
|
+
let note = null; try { note = JSON.parse(fs.readFileSync(path.join(notesDir, 'notes', f), 'utf8')); } catch { continue; }
|
|
184
|
+
if (!note || note.private || note.ws !== ws || !note.id) continue;
|
|
185
|
+
const date = day(note.recorded_from) || day(note.created_at);
|
|
186
|
+
if (!date) continue;
|
|
187
|
+
const pointer = `note ${note.id}`;
|
|
188
|
+
for (const sp of Object.values(note.speakers || {})) push('notes', 'speaker', sp, sp, { date, kind: 'Meeting', text: 'Spoke in a meeting note', pointer });
|
|
189
|
+
if (notesLib) {
|
|
190
|
+
let actions = []; try { actions = (notesLib.summarize(note).sections || {}).actions || []; } catch { /* unreadable note */ }
|
|
191
|
+
const owners = new Set();
|
|
192
|
+
for (const a of actions) for (const o of String(a.owner || '').split(/\s+and\s+/)) { const n = personName(o); if (n) owners.add(n); }
|
|
193
|
+
for (const n of owners) push('notes', 'name', n, n, { date, kind: 'Meeting', text: 'Has an action in a meeting note', pointer });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// the calendar this machine remembers, only while calendar names are on (R3)
|
|
198
|
+
if (calendarOn) {
|
|
199
|
+
for (const e of calendar || []) {
|
|
200
|
+
const date = e && day(e.start);
|
|
201
|
+
if (!date || !e.with) continue;
|
|
202
|
+
for (const w of splitNames(e.with)) {
|
|
203
|
+
const mail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(w);
|
|
204
|
+
push('calendar', mail ? 'mail' : 'name', mail ? w.toLowerCase() : w, mail ? w.split('@')[0] : w,
|
|
205
|
+
{ date, kind: 'Meeting', text: e.title ? `Meeting: ${e.title}` : 'Meeting', pointer: 'calendar' });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Record the names the workspace's records hold. Writes only sightings not already on disk. */
|
|
213
|
+
function scan(root, { now = Date.now(), calendar = [], notesDir = null, ws = null } = {}) {
|
|
214
|
+
const r = readRows(root);
|
|
215
|
+
if (!r.ok) return r;
|
|
216
|
+
const calendarOn = P.fold(r.rows).settings.calendar_names;
|
|
217
|
+
const found = sightingsFrom(root, { items: planItems(root), calendar, calendarOn, notesDir, ws });
|
|
218
|
+
const seen = new Set(r.rows.filter((x) => x && x.kind === 'sighting').map(sightKey));
|
|
219
|
+
let recorded = 0;
|
|
220
|
+
for (const s of found) {
|
|
221
|
+
const c = P.checkSighting(s);
|
|
222
|
+
if (!c.ok) continue;
|
|
223
|
+
const k = sightKey(c.row);
|
|
224
|
+
if (seen.has(k)) continue;
|
|
225
|
+
const w = fourPanel.appendDeclaration(r.dir, P.LEDGER, stamp(c.row, 'workspace:scan', now));
|
|
226
|
+
if (!w.ok) return { ok: false, error: w.error, recorded, found: found.length };
|
|
227
|
+
seen.add(k); recorded++;
|
|
228
|
+
}
|
|
229
|
+
return { ok: true, recorded, found: found.length };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = { view, record, decide, settings, scan, sightingsFrom, stateDir, readRows };
|
package/workspace/lib/people.js
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
*
|
|
5
5
|
* checkSighting(s) → { ok, row } | { ok:false, error } one sighting an agent session records
|
|
6
6
|
* checkMergeProposal(m) → { ok, row } | { ok:false, error }
|
|
7
|
-
* checkDecision(body) → { ok, row } | { ok:false, error } one PM press (add, ignore,
|
|
8
|
-
* edit, tone, note, note-remove,
|
|
7
|
+
* checkDecision(body) → { ok, row } | { ok:false, error } one PM press (add, ignore, remove, merge,
|
|
8
|
+
* keep-separate, edit, tone, note, note-remove,
|
|
9
|
+
* setting)
|
|
9
10
|
* fold(rows, { now, items, meetings }) → { people, review, settings, counts }
|
|
10
11
|
* handleKey(h) · slugOf(name) · toneProposal(interactions) · matchPeople(people, text)
|
|
11
12
|
* SOURCES · HANDLE_KINDS · INTERACTION_KINDS · TONES · ACTS · LEDGER
|
|
@@ -13,21 +14,24 @@
|
|
|
13
14
|
* Pure: no file, no clock unless given, no network. lib/people-store.js reads and appends; the route, the CLI and the
|
|
14
15
|
* People place all answer from fold(), so the terminal and the page cannot hold two opinions about a person.
|
|
15
16
|
*
|
|
16
|
-
* WHO DECIDES (D90). A sighting row is anyone's: an agent session records it through
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* WHO DECIDES (D90, as amended 2026-10-06). A sighting row is anyone's: an agent session records it through
|
|
18
|
+
* `pmos people record`, and the workspace records the names its own records hold (people-store.js scan). A sighting of
|
|
19
|
+
* a handle nobody holds ADDS that person: the PM asked for every detected name to be in People, not to wait in a list.
|
|
20
|
+
* A sighting whose name is a person's full name joins that person. Every decision row (a person added or removed, a
|
|
21
|
+
* handle ignored or merged, a tone, a note, the calendar setting) counts only when its `by` is the PM (`pm:<slug>`),
|
|
22
|
+
* which only a page press writes. A decision row with any other `by` is skipped and counted in counts.skipped, so a
|
|
23
|
+
* hand-appended line cannot make an agent's choice the PM's. Remove and Ignore keep a person's handles out for good.
|
|
20
24
|
*
|
|
21
25
|
* CALENDAR NAMES (R3). The setting is off until the PM turns it on. While it is off, the store refuses a calendar
|
|
22
26
|
* sighting, and this fold also drops the calendar sightings recorded while it was on.
|
|
23
27
|
*/
|
|
24
28
|
|
|
25
29
|
const LEDGER = 'people.jsonl';
|
|
26
|
-
const SOURCES = Object.freeze(['gather', 'calendar', 'live', 'jira']);
|
|
30
|
+
const SOURCES = Object.freeze(['gather', 'calendar', 'live', 'jira', 'plan', 'notes']);
|
|
27
31
|
const HANDLE_KINDS = Object.freeze(['mail', 'slack', 'jira', 'confluence', 'speaker', 'name']);
|
|
28
32
|
const INTERACTION_KINDS = Object.freeze(['Mail', 'Chat', 'Meeting', '1:1', 'Jira', 'Doc', 'Call', 'Mention']);
|
|
29
33
|
const TONES = Object.freeze(['warm', 'neutral', 'cautious', 'tense']);
|
|
30
|
-
const ACTS = Object.freeze(['add', 'ignore', 'merge', 'keep-separate', 'edit', 'tone', 'note', 'note-remove', 'setting']);
|
|
34
|
+
const ACTS = Object.freeze(['add', 'ignore', 'remove', 'merge', 'keep-separate', 'edit', 'tone', 'note', 'note-remove', 'setting']);
|
|
31
35
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
32
36
|
const ID_RE = /^[a-z0-9][a-z0-9-]{0,59}$/;
|
|
33
37
|
const NOTE_ID_RE = /^n-[0-9a-z]{4,16}$/;
|
|
@@ -148,6 +152,7 @@ function checkDecision(body) {
|
|
|
148
152
|
return { ok: true, row: { kind: 'keep-separate', a: a.value, b: b.value } };
|
|
149
153
|
}
|
|
150
154
|
if (!person()) return { ok: false, error: 'say which person' };
|
|
155
|
+
if (act === 'remove') return { ok: true, row: { kind: 'remove', person: person() } };
|
|
151
156
|
if (act === 'edit') {
|
|
152
157
|
const row = { kind: 'edit', person: person() };
|
|
153
158
|
for (const [k, max] of [['name', 80], ['role', 120], ['org', 80]]) {
|
|
@@ -217,7 +222,7 @@ function matchPeople(people, hay) {
|
|
|
217
222
|
const sightKey = (r) => [handleKey(r.handle), r.interaction ? r.interaction.date : '', r.interaction ? r.interaction.pointer : '', r.interaction ? r.interaction.text : ''].join('\u0000');
|
|
218
223
|
const pairKey = (a, b) => [handleKey(a), handleKey(b)].sort().join('\u0000');
|
|
219
224
|
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']);
|
|
225
|
+
const DECISION_KINDS = new Set(['add', 'ignore', 'remove', 'merge', 'keep-separate', 'edit', 'tone', 'note', 'note-remove', 'setting']);
|
|
221
226
|
|
|
222
227
|
/**
|
|
223
228
|
* The one fold. `rows` are the ledger's rows in file order (torn lines already dropped). `items` are plan items (the
|
|
@@ -233,7 +238,7 @@ function fold(rows, { now = Date.now(), items = [], meetings = [] } = {}) {
|
|
|
233
238
|
const sightings = [];
|
|
234
239
|
const proposals = [];
|
|
235
240
|
|
|
236
|
-
// 1 ·
|
|
241
|
+
// 1 · the calendar setting first, so every sighting below reads its final value
|
|
237
242
|
for (const r of rows || []) {
|
|
238
243
|
if (!isObj(r)) continue;
|
|
239
244
|
counts.rows++;
|
|
@@ -249,11 +254,44 @@ function fold(rows, { now = Date.now(), items = [], meetings = [] } = {}) {
|
|
|
249
254
|
const p = people.get(id);
|
|
250
255
|
if (!p.handles.some((x) => handleKey(x) === k)) p.handles.push({ kind: h.kind, value: h.value });
|
|
251
256
|
};
|
|
257
|
+
const create = (name, h, at) => {
|
|
258
|
+
let id = slugOf(name);
|
|
259
|
+
for (let n = 2; people.has(id); n++) id = `${slugOf(name).slice(0, 56)}-${n}`;
|
|
260
|
+
people.set(id, { id, name, role: null, org: null, external: null, handles: [], tone: null, notes: new Map(), addedAt: at || null, edited: {} });
|
|
261
|
+
attach(id, h);
|
|
262
|
+
return id;
|
|
263
|
+
};
|
|
264
|
+
// A sighting whose name is a person's full name is that person: "Ana Lee" from Jira and "Ana Lee" from a meeting. A
|
|
265
|
+
// bare first name from a weak handle (a name or a speaker, never a mail or chat handle) joins the one person with that
|
|
266
|
+
// first name; when two people share it, it joins nobody.
|
|
267
|
+
const byName = (name, kind) => {
|
|
268
|
+
const n = str(name).toLowerCase();
|
|
269
|
+
const live = [...people.values()].filter((p) => p.handles.length);
|
|
270
|
+
const full = live.find((p) => str(p.name).toLowerCase() === n);
|
|
271
|
+
if (full) return full.id;
|
|
272
|
+
if ((kind !== 'name' && kind !== 'speaker') || n.includes(' ')) return null;
|
|
273
|
+
const firsts = live.filter((p) => str(p.name).toLowerCase().split(' ')[0] === n);
|
|
274
|
+
return firsts.length === 1 ? firsts[0].id : null;
|
|
275
|
+
};
|
|
276
|
+
// One pass in file order, so a person exists before the PM's later presses about them.
|
|
277
|
+
const seen = new Set();
|
|
252
278
|
for (const r of rows || []) {
|
|
253
279
|
if (!isObj(r)) continue;
|
|
254
280
|
if (r.kind === 'sighting') {
|
|
255
281
|
const c = checkSighting(r);
|
|
256
|
-
if (c.ok)
|
|
282
|
+
if (!c.ok) continue;
|
|
283
|
+
const s = { ...c.row, at: r.at || null };
|
|
284
|
+
const key = sightKey(s);
|
|
285
|
+
if (seen.has(key)) { counts.duplicates++; continue; }
|
|
286
|
+
seen.add(key);
|
|
287
|
+
if (s.source === 'calendar' && !settings.calendar_names) { counts.hiddenCalendar++; continue; }
|
|
288
|
+
const k = handleKey(s.handle);
|
|
289
|
+
if (ignored.has(k)) continue;
|
|
290
|
+
if (!owner.has(k) || !people.has(owner.get(k))) {
|
|
291
|
+
const id = byName(s.name, s.handle.kind);
|
|
292
|
+
if (id) attach(id, s.handle); else create(s.name, s.handle, s.at);
|
|
293
|
+
}
|
|
294
|
+
sightings.push(s);
|
|
257
295
|
continue;
|
|
258
296
|
}
|
|
259
297
|
if (r.kind === 'merge-proposal') {
|
|
@@ -268,20 +306,23 @@ function fold(rows, { now = Date.now(), items = [], meetings = [] } = {}) {
|
|
|
268
306
|
if (d.kind === 'add') {
|
|
269
307
|
const k = handleKey(d.handle);
|
|
270
308
|
if (owner.has(k) && people.has(owner.get(k))) continue;
|
|
271
|
-
|
|
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);
|
|
309
|
+
create(d.name, d.handle, r.at);
|
|
275
310
|
} else if (d.kind === 'ignore') {
|
|
276
311
|
const k = handleKey(d.handle);
|
|
277
|
-
|
|
312
|
+
const o = owner.get(k);
|
|
313
|
+
if (o && people.has(o)) { const pp = people.get(o); pp.handles = pp.handles.filter((x) => handleKey(x) !== k); }
|
|
314
|
+
owner.delete(k);
|
|
315
|
+
ignored.add(k);
|
|
278
316
|
} else if (d.kind === 'merge') {
|
|
279
317
|
if (people.has(d.person)) attach(d.person, d.handle);
|
|
280
318
|
} else if (d.kind === 'keep-separate') {
|
|
281
319
|
closed.add(pairKey(d.a, d.b));
|
|
282
320
|
} else if (people.has(d.person)) {
|
|
283
321
|
const p = people.get(d.person);
|
|
284
|
-
if (d.kind === '
|
|
322
|
+
if (d.kind === 'remove') {
|
|
323
|
+
for (const h of p.handles) { owner.delete(handleKey(h)); ignored.add(handleKey(h)); }
|
|
324
|
+
p.handles = [];
|
|
325
|
+
} else if (d.kind === 'edit') {
|
|
285
326
|
for (const k of ['name', 'role', 'org', 'external']) if (d[k] !== undefined) { p[k] = d[k]; p.edited[k] = true; }
|
|
286
327
|
} else if (d.kind === 'tone') p.tone = d.tone;
|
|
287
328
|
else if (d.kind === 'note') p.notes.set(d.id, { id: d.id, text: d.text, at: r.at || null });
|
|
@@ -289,38 +330,21 @@ function fold(rows, { now = Date.now(), items = [], meetings = [] } = {}) {
|
|
|
289
330
|
}
|
|
290
331
|
}
|
|
291
332
|
|
|
292
|
-
// 2 ·
|
|
293
|
-
const seen = new Set();
|
|
294
|
-
const fresh = new Map(); // handle key -> { handle, name, role, org, external, count, sources, last }
|
|
333
|
+
// 2 · each sighting's details go to the person who holds its handle at the end
|
|
295
334
|
const interactions = new Map(); // person id -> []
|
|
335
|
+
const sourcesOf = new Map(); // person id -> Set of sources
|
|
296
336
|
for (const s of sightings) {
|
|
297
|
-
const
|
|
298
|
-
if (
|
|
299
|
-
|
|
300
|
-
if (
|
|
301
|
-
|
|
302
|
-
const
|
|
303
|
-
if (
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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;
|
|
337
|
+
const id = owner.get(handleKey(s.handle));
|
|
338
|
+
if (!id || !people.has(id)) continue;
|
|
339
|
+
const p = people.get(id);
|
|
340
|
+
if (!sourcesOf.has(id)) sourcesOf.set(id, new Set());
|
|
341
|
+
sourcesOf.get(id).add(s.source);
|
|
342
|
+
for (const f of ['role', 'org']) if (s[f] && !p.edited[f]) p[f] = s[f];
|
|
343
|
+
if (s.external === true && !p.edited.external) p.external = true;
|
|
344
|
+
if (s.interaction) {
|
|
345
|
+
if (!interactions.has(id)) interactions.set(id, []);
|
|
346
|
+
interactions.get(id).push({ ...s.interaction, source: s.source, handle: s.handle, at: s.at });
|
|
312
347
|
}
|
|
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
348
|
}
|
|
325
349
|
|
|
326
350
|
// 3 · each person's page
|
|
@@ -340,6 +364,7 @@ function fold(rows, { now = Date.now(), items = [], meetings = [] } = {}) {
|
|
|
340
364
|
id: p.id, name: p.name, role: p.role || null, org: p.org || null, external: p.external === true,
|
|
341
365
|
group: p.external === true ? 'External' : 'Team',
|
|
342
366
|
handles: p.handles.slice(),
|
|
367
|
+
sources: [...(sourcesOf.get(p.id) || [])],
|
|
343
368
|
interactions: ints,
|
|
344
369
|
tone: { proposed, confirmed: p.tone || null, mix, pending: !!proposed && !p.tone },
|
|
345
370
|
notes: [...p.notes.values()],
|
|
@@ -367,7 +392,7 @@ function fold(rows, { now = Date.now(), items = [], meetings = [] } = {}) {
|
|
|
367
392
|
}
|
|
368
393
|
list.sort((a, b) => String(b.last || '').localeCompare(String(a.last || '')) || a.name.localeCompare(b.name));
|
|
369
394
|
|
|
370
|
-
// 5 · the review list:
|
|
395
|
+
// 5 · the review list: open merge proposals and tones the PM has not confirmed (a new person is added, not listed)
|
|
371
396
|
const merges = [];
|
|
372
397
|
const shown = new Set();
|
|
373
398
|
for (const m of proposals) {
|
|
@@ -375,7 +400,7 @@ function fold(rows, { now = Date.now(), items = [], meetings = [] } = {}) {
|
|
|
375
400
|
if (closed.has(pk) || shown.has(pk)) continue;
|
|
376
401
|
const oa = owner.get(handleKey(m.a)); const ob = owner.get(handleKey(m.b));
|
|
377
402
|
if (oa && ob && oa === ob) continue;
|
|
378
|
-
// Merge needs a person to join: the one holding a (or else b).
|
|
403
|
+
// Merge needs a person to join: the one holding a (or else b).
|
|
379
404
|
const into = oa && people.has(oa) ? oa : ob && people.has(ob) ? ob : null;
|
|
380
405
|
if (!into) continue;
|
|
381
406
|
const other = into === oa ? m.b : m.a;
|
|
@@ -383,11 +408,10 @@ function fold(rows, { now = Date.now(), items = [], meetings = [] } = {}) {
|
|
|
383
408
|
shown.add(pk);
|
|
384
409
|
merges.push({ a: m.a, b: m.b, why: m.why, person: into, personName: people.get(into).name, handle: other });
|
|
385
410
|
}
|
|
386
|
-
const freshList = [...fresh.values()].sort((a, b) => String(b.last || '').localeCompare(String(a.last || '')) || b.count - a.count);
|
|
387
411
|
const tones = list.filter((p) => p.tone.pending).map((p) => p.id);
|
|
388
412
|
return {
|
|
389
413
|
people: list,
|
|
390
|
-
review: {
|
|
414
|
+
review: { merges, tones, count: merges.length + (tones.length ? 1 : 0) },
|
|
391
415
|
settings,
|
|
392
416
|
counts,
|
|
393
417
|
};
|
package/workspace/package.json
CHANGED
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
* only the body is rebuilt when its own data changed.
|
|
7
7
|
*
|
|
8
8
|
* What it shows, for the repository the tab names (or the open one):
|
|
9
|
-
* - Waiting for you:
|
|
10
|
-
*
|
|
9
|
+
* - Waiting for you: possible duplicates (Merge · Keep separate) and how many tones wait for a confirm. A new person
|
|
10
|
+
* is added, not listed here (the PM, 2026-10-06): Remove from People on their page keeps them out;
|
|
11
11
|
* - the list: search over names, handles and initiatives, All · Team · External, sort by latest, most or name;
|
|
12
12
|
* - the person's page: who, interactions with where each came from, tone (Confirm · Set another · Change), shared
|
|
13
13
|
* work (initiatives, items they own with Blocked shown, coming meetings), notes (Add · Remove), and identity and
|
|
14
14
|
* sources (handles, how many interactions each source gave, the file the person lives in).
|
|
15
|
-
* Every press is a page press to POST /api/people/decide. Nothing here reads a source:
|
|
16
|
-
* saw through `pmos people record`. A link from another place opens a person through the tab's slug (people-link.js).
|
|
15
|
+
* Every press is a page press to POST /api/people/decide. Nothing here reads a source: GET /api/people records the names
|
|
16
|
+
* the workspace's own records hold, and agent sessions record what they saw through `pmos people record`. A link from another place opens a person through the tab's slug (people-link.js).
|
|
17
17
|
* In fixture mode it reads nothing.
|
|
18
18
|
*/
|
|
19
19
|
|
|
@@ -26,7 +26,7 @@ import { registerCommand, openDestination } from './registry.js';
|
|
|
26
26
|
import { forgetPeople } from './people-link.js';
|
|
27
27
|
|
|
28
28
|
const TONES = ['warm', 'neutral', 'cautious', 'tense'];
|
|
29
|
-
const SOURCE = { gather: 'Gathered notes', calendar: 'Calendar', live: 'Live notes', jira: 'Jira' };
|
|
29
|
+
const SOURCE = { gather: 'Gathered notes', calendar: 'Calendar', live: 'Live notes', jira: 'Jira', plan: 'Plan items', notes: 'Meeting notes' };
|
|
30
30
|
const HANDLE = { mail: 'Mail', slack: 'Chat', jira: 'Jira', confluence: 'Confluence', speaker: 'Speaker', name: 'Name' };
|
|
31
31
|
|
|
32
32
|
export const COPY = Object.freeze({
|
|
@@ -37,16 +37,13 @@ export const COPY = Object.freeze({
|
|
|
37
37
|
readError: 'People could not be read.',
|
|
38
38
|
find: 'Find a person',
|
|
39
39
|
waiting: 'Waiting for you',
|
|
40
|
-
newPerson: (n, src) => `${n} sighting${n === 1 ? '' : 's'} · ${src}`,
|
|
41
|
-
add: 'Add to People',
|
|
42
|
-
ignore: 'Ignore',
|
|
43
40
|
same: (a, b) => `${a} may be the same person as ${b}`,
|
|
44
41
|
merge: 'Merge',
|
|
45
42
|
keep: 'Keep separate',
|
|
46
43
|
tonesWait: (n) => `${n} tone${n === 1 ? '' : 's'} to confirm. Open the person to confirm it.`,
|
|
47
44
|
all: 'All', team: 'Team', external: 'External',
|
|
48
45
|
latest: 'Latest', most: 'Most', name: 'Name',
|
|
49
|
-
none: 'Nobody yet.
|
|
46
|
+
none: 'Nobody yet. People adds everyone it finds: the owners of plan items, the speakers in Live notes and your meeting notes, the calendar when calendar names are on, and the people agent sessions see when they gather requirements or sync Jira.',
|
|
50
47
|
noMatch: (q) => `Nobody matches “${q}”.`,
|
|
51
48
|
pick: 'Choose a person to see their page.',
|
|
52
49
|
interactions: 'Interactions',
|
|
@@ -70,8 +67,9 @@ export const COPY = Object.freeze({
|
|
|
70
67
|
remove: 'Remove',
|
|
71
68
|
identity: 'Identity and sources',
|
|
72
69
|
lives: (f) => `Lives in ${f}`,
|
|
73
|
-
|
|
74
|
-
|
|
70
|
+
removeFromPeople: 'Remove from People',
|
|
71
|
+
removed: (n) => `${n} is removed. Later sightings stay out.`,
|
|
72
|
+
foundIn: (s) => `Found in ${s}`,
|
|
75
73
|
merged: 'Merged.',
|
|
76
74
|
kept: 'Kept separate.',
|
|
77
75
|
noteAdded: 'Note added.',
|
|
@@ -174,21 +172,6 @@ function review(inst) {
|
|
|
174
172
|
const r = inst.view.review;
|
|
175
173
|
if (!r.count) return null;
|
|
176
174
|
const rows = [];
|
|
177
|
-
for (const f of r.new) {
|
|
178
|
-
const key = f.handle.kind + ':' + f.handle.value;
|
|
179
|
-
rows.push(el('div', { class: 'rp-people-row', role: 'listitem', 'data-people-new': key },
|
|
180
|
-
row({
|
|
181
|
-
title: f.name,
|
|
182
|
-
meta: [[f.role, f.org].filter(Boolean).join(' · ') || handleText(f.handle), COPY.newPerson(f.count, f.sources.map((s) => SOURCE[s] || s).join(', '))],
|
|
183
|
-
trail: [
|
|
184
|
-
button({ label: COPY.add, variant: 'primary', size: 'sm', attrs: { 'data-people-act': 'add', 'data-people-key': key },
|
|
185
|
-
onClick: async () => { const x = await decide(inst, { act: 'add', handle: f.handle, name: f.name }, COPY.added(f.name)); if (x && x.person) { inst.open = x.person; draw(inst); } } }),
|
|
186
|
-
button({ label: COPY.ignore, variant: 'ghost', size: 'sm', attrs: { 'data-people-act': 'ignore', 'data-people-key': key },
|
|
187
|
-
onClick: () => decide(inst, { act: 'ignore', handle: f.handle }, COPY.ignored(f.name)) }),
|
|
188
|
-
],
|
|
189
|
-
attrs: { title: handleText(f.handle) },
|
|
190
|
-
})));
|
|
191
|
-
}
|
|
192
175
|
for (const m of r.merges) {
|
|
193
176
|
const key = m.handle.kind + ':' + m.handle.value;
|
|
194
177
|
rows.push(el('div', { class: 'rp-people-row', role: 'listitem', 'data-people-merge': key },
|
|
@@ -340,6 +323,7 @@ function identitySec(inst, p) {
|
|
|
340
323
|
el('ul', { class: 'rp-people-list-plain' },
|
|
341
324
|
p.handles.map((h) => el('li', { class: 'rp-people-handle', 'data-people-handle': h.kind + ':' + h.value }, handleText(h))),
|
|
342
325
|
Object.entries(by).map(([s, n]) => el('li', { class: 'rp-people-hint' }, `${SOURCE[s] || s}: ${n} interaction${n === 1 ? '' : 's'}`))),
|
|
326
|
+
(p.sources || []).length ? el('p', { class: 'rp-people-hint', 'data-people-sources': p.sources.join(',') }, COPY.foundIn(p.sources.map((s) => SOURCE[s] || s).join(', '))) : null,
|
|
343
327
|
el('p', { class: 'rp-people-hint rp-people-file' }, COPY.lives(file)),
|
|
344
328
|
], { 'data-people-sec': 'identity' });
|
|
345
329
|
}
|
|
@@ -350,7 +334,9 @@ function personPane(inst) {
|
|
|
350
334
|
return el('article', { class: 'rp-people-page', 'data-people-page': p.id },
|
|
351
335
|
el('header', { class: 'rp-people-who' },
|
|
352
336
|
el('h3', { class: 'rp-people-who__name' }, p.name),
|
|
353
|
-
el('p', { class: 'rp-people-hint' }, [who(p), p.external && (p.role || p.org) ? COPY.external : null].filter(Boolean).join(' · '))
|
|
337
|
+
el('p', { class: 'rp-people-hint' }, [who(p), p.external && (p.role || p.org) ? COPY.external : null].filter(Boolean).join(' · ')),
|
|
338
|
+
el('div', { class: 'rp-people-acts' }, button({ label: COPY.removeFromPeople, variant: 'ghost', size: 'sm', attrs: { 'data-people-act': 'remove' },
|
|
339
|
+
onClick: async () => { const r = await decide(inst, { act: 'remove', person: p.id }, COPY.removed(p.name)); if (r) { inst.open = null; draw(inst); } } }))),
|
|
354
340
|
interactionsSec(p), toneSec(inst, p), sharedSec(p), notesSec(inst, p), identitySec(inst, p));
|
|
355
341
|
}
|
|
356
342
|
|
package/workspace/server.js
CHANGED
|
@@ -6623,17 +6623,20 @@ async function handleRequest(req, res) {
|
|
|
6623
6623
|
return json(res, { ok: started, session: s.id, error: started ? undefined : (s.note || 'could not start the session') }, started ? 200 : 500);
|
|
6624
6624
|
}
|
|
6625
6625
|
// ── People: the people the PM works with in a repository (people-directory, PRD R9 to R14, proposed D90) ──────────
|
|
6626
|
-
// GET
|
|
6627
|
-
//
|
|
6628
|
-
//
|
|
6626
|
+
// GET first records the names this machine's own records hold (people-store scan: plan item owners, Live notes
|
|
6627
|
+
// speakers, the PM's meeting notes for this repository, and the remembered calendar while calendar names are on), then
|
|
6628
|
+
// folds people.jsonl with the plan items and the coming meetings. POST is one PM page press: add, remove, ignore,
|
|
6629
|
+
// merge, keep separate, edit, tone, a note, or the calendar setting. Agent sessions record what they read from mail,
|
|
6630
|
+
// chat and Jira through `pmos people record`; no route here reads a connector.
|
|
6629
6631
|
if (p === '/api/people' && req.method === 'GET') {
|
|
6630
6632
|
const w = getWs(url.searchParams.get('ws'));
|
|
6631
6633
|
const root = w ? w.root : SELF_ROOT;
|
|
6632
6634
|
const now = Date.now();
|
|
6633
6635
|
const mem = readCalMemory();
|
|
6634
6636
|
const brief = briefOfDay(briefLib.localDate(now));
|
|
6635
|
-
const
|
|
6636
|
-
|
|
6637
|
+
const calendar = [...((mem && mem.events) || []), ...((brief && brief.calendar) || [])];
|
|
6638
|
+
peopleStore.scan(root, { now, calendar, notesDir: STATE, ws: w ? w.id : null });
|
|
6639
|
+
const meetings = calendar.filter((e) => e && e.start && Date.parse(e.end || e.start) >= now);
|
|
6637
6640
|
const v = peopleStore.view(root, { now, meetings });
|
|
6638
6641
|
if (!v.ok) return json(res, v, 500);
|
|
6639
6642
|
return json(res, { ...v, ws: w ? w.id : null });
|
|
@@ -4,7 +4,7 @@ label: "Gather requirements"
|
|
|
4
4
|
description: "Read what changed in the PM's mail, chat, Jira, Confluence, calendar and meeting notes since the last note, and file one dated note of requirements — each with its source, how sure it is, and which initiative it touches."
|
|
5
5
|
run: agent
|
|
6
6
|
agent: claude
|
|
7
|
-
version: 1.
|
|
7
|
+
version: 1.3.0
|
|
8
8
|
owner: wawan
|
|
9
9
|
risk: low
|
|
10
10
|
scope: read:connectors, read:state, write:state/requirements, write:state/people
|
|
@@ -55,7 +55,7 @@ Procedure (each step ends with a check that shows it is done):
|
|
|
55
55
|
Done when: the file starts with this frontmatter and holds the four sections in order.
|
|
56
56
|
7. Under "Sources reached and not reached", list every source kind you tried, with `reached` or `not reached — <why>`, and the number of private items skipped.
|
|
57
57
|
Done when: every source kind you tried has a line, and the private count is stated.
|
|
58
|
-
8. Record the people in People. For each author of an item you filed (other than the PM
|
|
58
|
+
8. Record the people in People. For each author of an item you filed, and each other person an item names (an owner, a stakeholder, a decision maker), other than the PM, write one sighting (for a person an item only names, the interaction kind is "Mention"): {"source": "gather", "handle": {"kind": "mail" | "slack" | "jira" | "confluence", "value": "<their address or name in that source>"}, "name": "<display name>", "role"?: "<their role, when the source states it>", "org"?: "<their organisation, when it is not the PM's>", "external"?: true (when they are outside the PM's company), "interaction": {"date": "YYYY-MM-DD", "kind": "Mail" | "Chat" | "Jira" | "Doc" | "Meeting", "text": "<what they asked or said, in your words, at most 200 characters>", "pointer": "<the item's pointer>", "tone": "warm|neutral|cautious|tense" (only when the exchange shows one), "initiative": "<the item's initiative id, or omit when unmatched>"}}. Tone describes the exchange, not the person. When two handles look like one person (the same name in mail and in chat), add a merge {"a": <handle>, "b": <handle>, "why": "<one line>"}. Put them in one file {"sightings": [...], "merges": [...]} in the scratch folder and run `pmos people record --file <that file>` once. Contact details beyond the handle are never recorded.
|
|
59
59
|
Done when: the command printed how many sightings it recorded, or you said no item had an author.
|
|
60
60
|
9. Finish by saying in one line how many items you filed and where, and how many people you recorded. Do not commit; the PM's Sync carries the file.
|
|
61
61
|
Done when: the count and the path are said once, and no commit was made.
|
|
@@ -31,7 +31,7 @@ Procedure:
|
|
|
31
31
|
- `url`: the issue's browse link, `https://<site>.atlassian.net/browse/<KEY>`.
|
|
32
32
|
The shape Jira returned (`{"issues": [{"key", "self", "fields": {...}}]}`) is also accepted.
|
|
33
33
|
4. Run `pmos jira sync <initiative> --from <the file> --query "<the JQL>"`. Then delete the file.
|
|
34
|
-
5. Record the people in People. For each assignee and reporter other than the PM, write one sighting per issue: {"source": "jira", "handle": {"kind": "jira", "value": "<their Jira display name>"}, "name": "<display name>", "interaction": {"date": "<the issue's last update, YYYY-MM-DD>", "kind": "Jira", "text": "<Assignee|Reporter> of <KEY>: <summary, at most 150 characters>", "pointer": "jira · <KEY> · <url>", "initiative": "<the initiative id>"}}. Put all of them in one file {"sightings": [...]} in the scratch folder, run `pmos people record --file <that file>` once, and delete the file. An issue with no assignee gives no assignee sighting.
|
|
34
|
+
5. Record the people in People. For each assignee and reporter other than the PM, write one sighting per issue (People adds each new person at once): {"source": "jira", "handle": {"kind": "jira", "value": "<their Jira display name>"}, "name": "<display name>", "interaction": {"date": "<the issue's last update, YYYY-MM-DD>", "kind": "Jira", "text": "<Assignee|Reporter> of <KEY>: <summary, at most 150 characters>", "pointer": "jira · <KEY> · <url>", "initiative": "<the initiative id>"}}. Put all of them in one file {"sightings": [...]} in the scratch folder, run `pmos people record --file <that file>` once, and delete the file. An issue with no assignee gives no assignee sighting.
|
|
35
35
|
6. Finish with the three sections of the output contract, one short line each:
|
|
36
36
|
- Checked: the initiative, the number of tickets read, and each change (status, sprint, blocked). If nothing changed, say "no change". Add how many people you recorded.
|
|
37
37
|
- Not reached: each initiative whose search failed, with the reason.
|
|
@@ -5,7 +5,7 @@ description: "Take the talking points of a conversation, place each against the
|
|
|
5
5
|
run: agent
|
|
6
6
|
agent: claude
|
|
7
7
|
tier: balanced
|
|
8
|
-
version: 1.
|
|
8
|
+
version: 1.2.0
|
|
9
9
|
owner: wawan
|
|
10
10
|
risk: low
|
|
11
11
|
scope: read:connectors, read:state, write:state/live-notes
|
|
@@ -48,7 +48,7 @@ Procedure (each step ends with a check that shows it is done):
|
|
|
48
48
|
Done when: you have shown the list and recorded nothing the PM did not say.
|
|
49
49
|
6. When the PM says the conversation is over, run `node workspace/bin/live-notes.js close --repo . --session <id>`. It appends a section to today's requirements note and deletes the raw transcript.
|
|
50
50
|
Done when: the command printed the note path and "transcript: deleted", or the retention date the PM set.
|
|
51
|
-
7. Record the speakers in People. For each named speaker other than the PM, write one sighting: {"source": "live", "handle": {"kind": "speaker", "value": "<the speaker's name as the transcript gives it>"}, "name": "<display name>", "interaction": {"date": "YYYY-MM-DD", "kind": "Meeting" (or "1:1" when only the PM and this speaker talk), "text": "<what this speaker raised, in your words, at most 200 characters>", "pointer": "<wispr:meeting id, or the segments file> · <date>", "tone": "warm|neutral|cautious|tense" (only when the exchange shows one), "initiative": "<the initiative id their points were placed on, or omit>"}}. Tone describes the exchange, not the person. Never copy what the speaker said. Put the sightings in one file {"sightings": [...]} in the scratch folder and run `pmos people record --file <that file>` once. A speaker with no name ("Speaker 2") is not recorded.
|
|
51
|
+
7. Record the speakers in People. For each named speaker, and each person the meeting names as the owner of an action, other than the PM, write one sighting (for a named owner, the handle kind is "name" and the interaction kind is "Mention"): {"source": "live", "handle": {"kind": "speaker", "value": "<the speaker's name as the transcript gives it>"}, "name": "<display name>", "interaction": {"date": "YYYY-MM-DD", "kind": "Meeting" (or "1:1" when only the PM and this speaker talk), "text": "<what this speaker raised, in your words, at most 200 characters>", "pointer": "<wispr:meeting id, or the segments file> · <date>", "tone": "warm|neutral|cautious|tense" (only when the exchange shows one), "initiative": "<the initiative id their points were placed on, or omit>"}}. Tone describes the exchange, not the person. Never copy what the speaker said. Put the sightings in one file {"sightings": [...]} in the scratch folder and run `pmos people record --file <that file>` once. A speaker with no name ("Speaker 2") is not recorded.
|
|
52
52
|
Done when: the command printed how many sightings it recorded, or you said no speaker was named.
|
|
53
53
|
8. Finish by saying in two lines how many points were placed, how many are still unanswered, which path ran, how many people you recorded, and which sources were not reached. Do not commit; the PM's Sync carries the files.
|
|
54
54
|
Done when: the two lines are said once and no commit was made.
|