create-pmos 0.63.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.63.1",
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": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@4cf3dc9
1
+ kernel-source: pmos@2196dd0
2
2
  assembled: 2026-10-06
3
3
  mode: vendor
@@ -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-06T22:58:51Z"}
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-06T22:58:51Z"}
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-06T22:58:51Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T23:35:55Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -119,6 +119,8 @@ Every row has a visible ⋯ button. Every control is at least 44 pixels high. No
119
119
 
120
120
  You can have the same session open on your phone and your computer. A session has one size, so the screen you type in or tap last sets it. The other screen keeps showing the session and takes the size back as soon as you type in it.
121
121
 
122
+ On an iPhone or an iPad, the terminal gets smaller when the keyboard opens, so the agent's prompt stays above the keyboard. Tapping the terminal does not zoom the page, and dragging at its top or bottom does not pull or reload the page. A swipe from the left edge of the screen is Safari's Back gesture and leaves the workspace. To avoid that, add the workspace to your Home Screen and open it from there.
123
+
122
124
  ## The older layout
123
125
 
124
126
  The address options `?layout=legacy` and `?fixture=cockpit` keep the older layouts. The rest of this page describes them. In the cockpit, the workspace has four columns: the repository rail, the list, the detail and the tabs. With `?layout=legacy` it has three columns: navigation, detail and tabs.
@@ -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
- The workspace does not read your mail, chat, calendar or Jira. Agent sessions that already read them with your own connectors record the people they see:
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
- - **Gather requirements** records the author of each item it gathers.
18
- - **Live notes** records each named speaker.
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 **Keep attendee names from the calendar** is on (see Settings below).
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 transcript speaker, or only a name), and at most one interaction line in the agent's words with a pointer to where it came from. No message body is kept. Private, personal, confidential and HR material is skipped.
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 does not make a person. New people wait for you in **Waiting for you** at the top of People.
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 adds a person, ignores or merges a handle, sets a tone or writes a note. Do those in People.
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
- * No command adds a person, merges, sets a tone or writes a note: those are the PM's presses in People (D90).
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 adds a person, merges, sets a tone or writes a note. Do that in People.',
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.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; }
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
- module.exports = { view, record, decide, settings, stateDir, readRows };
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 };
@@ -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, merge, keep-separate,
8
- * edit, tone, note, note-remove, setting)
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 `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.
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 · decisions, in file order; the calendar setting first so the sightings below read the final value
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) sightings.push({ ...c.row, at: r.at || null });
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
- 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);
309
+ create(d.name, d.handle, r.at);
275
310
  } else if (d.kind === 'ignore') {
276
311
  const k = handleKey(d.handle);
277
- if (!owner.has(k)) ignored.add(k);
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 === 'edit') {
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 · 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 }
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 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;
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: new handles, open merge proposals, tones the PM has not confirmed
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). Two handles nobody holds wait in "new" instead.
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: { new: freshList, merges, tones, count: freshList.length + merges.length + (tones.length ? 1 : 0) },
414
+ review: { merges, tones, count: merges.length + (tones.length ? 1 : 0) },
391
415
  settings,
392
416
  counts,
393
417
  };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.63.1",
3
+ "version": "0.64.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.63.1",
3
+ "version": "0.64.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -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: new people agent sessions saw (Add to People · Ignore), possible duplicates (Merge · Keep
10
- * separate), and how many tones wait for a confirm;
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: agent sessions record what they
16
- * saw through `pmos people record`. A link from another place opens a person through the tab's slug (people-link.js).
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. Agent sessions that gather requirements, take live notes or sync Jira record the people they see. They wait here for you to add them.',
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
- added: (n) => `${n} added to People.`,
74
- ignored: (n) => `${n} is ignored. Later sightings stay hidden.`,
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
 
@@ -43,6 +43,7 @@
43
43
  border-left: 1px solid var(--border);
44
44
  border-right: 1px solid var(--border);
45
45
  }
46
+ html.pm-vv body { height: var(--fr-vvh, 100vh); }
46
47
 
47
48
  /* Explicit placement, never auto-placement: app.js appends its root LAST, so source order would
48
49
  * put the screen underneath the bottom nav. */
@@ -363,3 +364,19 @@
363
364
  .mob[hidden], .mobmenu[hidden], .mob-back[hidden], .mob-brand[hidden] { display: none !important; }
364
365
  .mob-repo[hidden], .mob-set[hidden], .mob-sync[hidden], .mob-theme[hidden], .mobnav-n[hidden] { display: none !important; }
365
366
  .mob-deck[hidden], .mob-pager[hidden], .mob-deck__views[hidden] { display: none !important; }
367
+
368
+ /* ── a touch screen: the visible height, no page bounce, no zoom on focus ─────────────────────
369
+ * pm/mobile.js sets `pm-vv` and `--fr-vvh` (the visual viewport's height) on a touch screen. The
370
+ * page is that tall, so the soft keyboard sits under the terminal and does not cover it. Above the
371
+ * breakpoint (an iPad) that is app.js's root; at the breakpoint and below it is <body>, in the phone rules above.
372
+ * A drag at the top or bottom of the terminal does not pull the page or reload it. Every rule here
373
+ * needs `pm-vv`, so a desktop with a mouse is untouched. */
374
+ html.pm-vv, html.pm-vv body { overscroll-behavior: none; }
375
+ html.pm-vv { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
376
+ @media (min-width: 761px) {
377
+ html.pm-vv body > [data-pm-theme] { height: var(--fr-vvh, 100vh) !important; }
378
+ }
379
+ /* xterm types through a hidden textarea and gives it the terminal's 12.5px font. iOS zooms the
380
+ * page in on a focused field under 16px, which pushes the terminal off the side of the screen. The
381
+ * textarea is invisible, so its size changes nothing else. */
382
+ html.pm-vv .xterm .xterm-helper-textarea { font-size: 16px !important; }
@@ -475,13 +475,42 @@ function onChromeClick(e) {
475
475
 
476
476
  /* The terminal has to re-measure when the soft keyboard shows or hides and after a rotation, or
477
477
  * xterm keeps the row count it had and the bottom of the session is clipped. The fit itself belongs
478
- * to the terminal panel; this only tells it that the box changed. */
478
+ * to the terminal panel; this only tells it that the box changed. It calls the terminal's refit
479
+ * directly: a synthetic window `resize` also re-lays the card deck and the report slides. */
479
480
  function refit() {
480
481
  const t = window.pmTerm;
481
- if (t && typeof t.fit === 'function') { try { t.fit(); return; } catch { /* fall through */ } }
482
+ if (t && typeof t.refitVisible === 'function') { try { t.refitVisible(); return; } catch { /* fall through */ } }
482
483
  window.dispatchEvent(new Event('resize'));
483
484
  }
484
485
 
486
+ /* ── the visible height, on a touch screen ───────────────────────────────────────────────────
487
+ * iOS does not shrink the page when the soft keyboard opens. `100vh` stays the full screen, the
488
+ * keyboard covers its bottom, and that bottom is where the agent's prompt and the control keys
489
+ * are. Safari then scrolls the page to show the focused field, so the screen jumps. And with
490
+ * Safari's toolbar shown, `100vh` is already taller than what is visible.
491
+ *
492
+ * So on a touch screen the page height is the VISUAL viewport's: `--fr-vvh` on <html>, read by
493
+ * pm/mobile.css, with the page held at the top. A pinch zoom also changes the visual viewport, and
494
+ * that is not the page getting smaller, so the height is in layout pixels (height × scale). */
495
+ const TOUCH = window.matchMedia('(hover: none) and (pointer: coarse)');
496
+ let vvh = 0;
497
+
498
+ function fitViewport() {
499
+ const vv = window.visualViewport;
500
+ const root = document.documentElement;
501
+ if (!vv || !TOUCH.matches) {
502
+ if (root.classList.contains('pm-vv')) { root.classList.remove('pm-vv'); vvh = 0; refit(); }
503
+ return;
504
+ }
505
+ root.classList.add('pm-vv');
506
+ if (window.scrollY || window.scrollX) window.scrollTo(0, 0);
507
+ const h = Math.round(vv.height * (vv.scale || 1));
508
+ if (!h || h === vvh) return;
509
+ vvh = h;
510
+ root.style.setProperty('--fr-vvh', h + 'px');
511
+ refit();
512
+ }
513
+
485
514
  /* ── mount ──────────────────────────────────────────────────────────────────────────────────── */
486
515
 
487
516
  /**
@@ -515,10 +544,12 @@ export function mountMobile(context) {
515
544
  document.addEventListener('click', onChromeClick);
516
545
 
517
546
  if (window.visualViewport) {
518
- window.visualViewport.addEventListener('resize', refit);
519
- window.visualViewport.addEventListener('scroll', refit);
547
+ window.visualViewport.addEventListener('resize', fitViewport);
548
+ window.visualViewport.addEventListener('scroll', fitViewport);
520
549
  }
521
- window.addEventListener('orientationchange', () => setTimeout(refit, 250));
550
+ if (TOUCH.addEventListener) TOUCH.addEventListener('change', fitViewport);
551
+ window.addEventListener('orientationchange', () => setTimeout(() => { fitViewport(); refit(); }, 250));
552
+ fitViewport();
522
553
 
523
554
  // Crossing the breakpoint (rotate a tablet, drag a window narrow) has to re-decide whether the
524
555
  // phone shell exists at all AND repaint the panels — a shell left mounted above 760px is a second
@@ -699,6 +699,18 @@ function syncSize(rec, claim) {
699
699
  if (send({ t: 'resize', id: rec.sessionId, cols, rows })) { rec.sentCols = cols; rec.sentRows = rows; }
700
700
  }
701
701
 
702
+ /**
703
+ * State a grid that a refit changed, once the box stops moving. A phone's soft keyboard and a
704
+ * rotation move the box several times in a few hundred milliseconds, and every grid the PTY hears
705
+ * makes the agent redraw its whole frame. Typing still sends at once: `onData` calls syncSize
706
+ * before the keystroke, which states the latest grid and makes the queued call a no-op.
707
+ */
708
+ const SIZE_SETTLE_MS = 120;
709
+ function queueSize(rec) {
710
+ clearTimeout(rec.sizeTimer);
711
+ rec.sizeTimer = setTimeout(() => { rec.sizeTimer = null; syncSize(rec, true); }, SIZE_SETTLE_MS);
712
+ }
713
+
702
714
  /** Write a status line into the terminal itself — once per distinct message, dimmed. */
703
715
  function note(rec, line, force) {
704
716
  if (!rec.term) return;
@@ -726,8 +738,9 @@ export function refitVisible() {
726
738
  try { rec.fit.fit(); } catch { /* a terminal mid-teardown */ }
727
739
  // …and state the resulting grid to the PTY. `fit()` only fires xterm's `onResize` when the
728
740
  // grid MOVES, so a pane that was measured before it had a session would otherwise never tell
729
- // the shell how wide it is. See the `attached` handler.
730
- syncSize(rec);
741
+ // the shell how wide it is. See the `attached` handler. A grid change the fit just queued
742
+ // (see queueSize) goes out when the box settles, not here.
743
+ if (!rec.sizeTimer) syncSize(rec);
731
744
  }
732
745
  };
733
746
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run);
@@ -891,12 +904,12 @@ function buildReal(tab) {
891
904
  term.attachCustomKeyEventHandler((e) => !((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')));
892
905
 
893
906
  const rec = {
894
- kind: 'real', tab, host, term, fit, sessionId: null, attachedId: null, noted: null, ro: null,
907
+ kind: 'real', tab, host, term, fit, sessionId: null, attachedId: null, noted: null, ro: null, sizeTimer: null,
895
908
  };
896
909
  // Typing, focusing or re-sizing THIS pane takes the session's grid back from another screen, and
897
910
  // the grid goes out before the keystroke so the agent answers it at this pane's size.
898
911
  term.onData((d) => { if (rec.sessionId) { syncSize(rec, true); send({ t: 'input', id: rec.sessionId, data: d }); } });
899
- term.onResize(() => syncSize(rec, true));
912
+ term.onResize(() => queueSize(rec));
900
913
  host.addEventListener('focusin', () => syncSize(rec, true));
901
914
  if (typeof ResizeObserver === 'function') {
902
915
  rec.ro = new ResizeObserver(() => refitVisible());
@@ -951,6 +964,7 @@ export function disposeTerm(tab) {
951
964
  * pane for the id and detaches from the orphan instead. */
952
965
  if (rec.kind !== 'real') return;
953
966
  if (rec.ro) { try { rec.ro.disconnect(); } catch { /* ignore */ } }
967
+ clearTimeout(rec.sizeTimer);
954
968
  if (rec.sessionId) {
955
969
  send({ t: 'detach', id: rec.sessionId });
956
970
  SOCK.attached.delete(rec.sessionId);
@@ -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 folds people.jsonl with the plan items and the coming meetings this machine remembers (the calendar memory and
6627
- // today's brief). POST is one PM page press: add, ignore, merge, keep separate, edit, tone, a note, or the calendar
6628
- // setting. Agent sessions record sightings through `pmos people record`; no route here reads a source.
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 meetings = [...((mem && mem.events) || []), ...((brief && brief.calendar) || [])]
6636
- .filter((e) => e && e.start && Date.parse(e.end || e.start) >= now);
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.2.0
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), write one sighting: {"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.
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.1.0
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.