create-pmos 0.63.2 → 0.65.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.2",
3
+ "version": "0.65.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@586dc2f
2
- assembled: 2026-10-06
1
+ kernel-source: pmos@bcf4a86
2
+ assembled: 2026-10-07
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-06T23:17:22Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T00:08:29Z"}
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:17:22Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T00:08:29Z"}
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:17:22Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T00:08:29Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  Newest first, ISO dates.
4
4
 
5
- ## 2026-10-06
5
+ ## 2026-10-07
6
6
 
7
7
  - Adopted OKF v0.2 (D73).
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  type: Guide
3
3
  title: "Guide: notes"
4
- description: "How to take notes in a meeting: listen to your microphone and the meeting on this machine, see decisions, actions and questions caught as they are said, check them against the repository, and get a summary."
4
+ description: "How to take notes in a meeting: listen to your microphone and the meeting on this machine, see decisions, actions and questions caught as they are said, check them against the repository, correct and tag lines, see what changed in your notes, replay the meeting, and get a summary."
5
5
  timestamp: 2026-10-06
6
6
  ---
7
7
 
@@ -28,9 +28,9 @@ The meeting can only be heard when local transcription is set up. Until then, **
28
28
 
29
29
  ## What is caught
30
30
 
31
- Each line gets a label: decision, action, question, topic, statement or small talk. Decisions, actions and questions go into their sections as they are said. After **Stop**, statements with a number go under **Facts mentioned**.
31
+ Each line gets a label: decision, action, question, fact, topic, statement or small talk. Decisions, actions and questions go into their sections as they are said. After **Stop**, facts and statements with a number go under **Facts mentioned**.
32
32
 
33
- - To remove an item that does not belong, press the **x** beside it.
33
+ - To remove an item that does not belong, press the **x** beside it. To take a line out of the summary, press the **x** beside it in the summary.
34
34
  - When a caught line is about something in the repository, the closest file shows beside it: **see** the file, a check mark when Jev finds that the file says the same, or **≠** the file when Jev finds that the line says something different. Point at the mark to see the file and the line.
35
35
 
36
36
  ## Set up local transcription
@@ -57,6 +57,35 @@ Without Jev, the workspace labels each line with its own rules and finds the clo
57
57
 
58
58
  With the switches on, each line goes to TypeSafe for its label. For a caught line, that line and one passage of the closest file go too. Audio, speaker names and private notes never go. A line that looks like a secret is not sent.
59
59
 
60
+ ## Correct and tag a line
61
+
62
+ 1. In the transcript, press the **…** button on a line.
63
+ 2. Change what you need:
64
+ - **Words**: correct what was heard. The words that were heard are kept, the line shows **edited**, and **Use what was heard** puts them back.
65
+ - **Label**: decision, action, question, fact, topic, statement or small talk.
66
+ - **Summary**: **As picked**, **Always in the summary** or **Never in the summary**.
67
+ - **Tags**: words such as `#pricing #launch`.
68
+ - **People**: names, separated by commas. Press a name below the field to add a person from this repository.
69
+ 3. Press **Save**. The tags and people show on the line, on its item in your notes and under **Tagged**. **Copy as Markdown** includes them.
70
+
71
+ ## See what changed in your notes
72
+
73
+ Each change to your notes keeps the text from before it. Changes within two minutes count as one. The first text you typed is always kept.
74
+
75
+ 1. When your notes have changed, the header shows **Edited**. Press it.
76
+ 2. **History** shows each version, newest first: the lines that were added and removed, and the original at the end.
77
+ 3. To go back, press **Use this version**. The text before it stays in **History**.
78
+
79
+ ## Replay the meeting
80
+
81
+ When a meeting was heard with local transcription, its audio is kept on this machine.
82
+
83
+ 1. Open the note. **Replay** is above your notes. Press play.
84
+ 2. While it plays, the transcript line, its item and the line of your notes that you wrote at that moment are highlighted. Your notes cannot be edited while it plays: pause to edit them.
85
+ 3. Press a time in the transcript to play from there.
86
+
87
+ To stop keeping audio, clear **Keep the audio for replay** in the settings. To delete one note's audio, press **Delete this note's audio** in the settings; the notes and the transcript stay. The browser's own recognition keeps no audio, so a meeting heard that way cannot be replayed.
88
+
60
89
  ## Dictate into your notes
61
90
 
62
91
  1. Put the cursor where the text must go, then press **Dictate** and speak.
@@ -66,13 +95,13 @@ With the switches on, each line goes to TypeSafe for its label. For a caught lin
66
95
  ## Copy, private and delete
67
96
 
68
97
  - **Copy as Markdown** puts the title, the summary, your notes, the caught items and the transcript on the clipboard.
69
- - In the settings, **Mark private** stops listening at once. A private note is not recorded again, and nothing from it goes to Jev or to an agent. **Mark not private** undoes it.
98
+ - In the settings, **Mark private** stops listening at once and deletes the note's audio. A private note is not recorded again, and nothing from it goes to Jev or to an agent. **Mark not private** undoes it.
70
99
  - **Summary by agent** starts an agent session in the selected repository that writes a summary into the note. Lines that look like a secret are removed first.
71
- - **Delete** removes the note, its transcript and its summaries from this machine after you confirm. You cannot undo it.
100
+ - **Delete** removes the note, its transcript, its summaries and its audio from this machine after you confirm. You cannot undo it.
72
101
 
73
102
  ## What stays on your machine
74
103
 
75
- - Audio never leaves this machine. The page sends it to the workspace on this machine, which gives it to whisper.cpp in a temporary file and deletes the file at once. The browser's own recognition is used only on the device, never in the cloud.
104
+ - Audio never leaves this machine. The page sends it to the workspace on this machine, which gives it to whisper.cpp in a temporary file and deletes that file at once. For replay, the workspace keeps one recording of each note in `notes/recordings/` in the state folder, until you delete the audio, the note, or mark the note private. The browser's own recognition is used only on the device, never in the cloud.
76
105
  - Notes are files in the workspace's own folder on this machine (`notes/` in the state folder). They are never written into a repository.
77
106
  - Only Jev, with the switches on, and **Summary by agent**, when you press it, receive any text from a note.
78
107
 
@@ -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.
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+ /* notes-audio.js — the meeting audio a note keeps for replay (meeting-notes Run 3, D92). Local only.
3
+ *
4
+ * write(runtimeDir, { id, source, pcm, t }) add one chunk of 16 kHz mono 16-bit samples at meeting time t (ms)
5
+ * info(runtimeDir, id) → { bytes, ms } | null
6
+ * remove(runtimeDir, id) delete the note's audio
7
+ * serve(req, res, runtimeDir, id) the audio as a WAV file, with byte ranges so the page can seek
8
+ * getKeep(runtimeDir) · setKeep(runtimeDir, bool) "Keep the audio for replay", on by default, for this machine
9
+ *
10
+ * WHAT IT KEEPS. One file per note, `<runtime>/notes/recordings/<id>.pcm`: raw samples with no header, sample k at
11
+ * k / 16000 seconds after the note's first Listen. The microphone and the meeting are added into the same samples, so the
12
+ * file is the meeting as the PM heard it. A chunk is placed straight after the previous chunk of its source when it
13
+ * arrives on time or early, so the small jitter of the page's timer leaves no clicks; a chunk that arrives much later (Listen again)
14
+ * is placed at its own time, and the gap stays silent.
15
+ *
16
+ * WHAT IT NEVER DOES. It sends nothing and opens no network module. The routes (lib/routes-notes.js) write nothing for a
17
+ * private note, delete the file when a note is made private or deleted, and write nothing when the PM switches keeping off.
18
+ */
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+
22
+ const RATE = 16000;
23
+ const BYTES_PER_MS = (RATE * 2) / 1000;
24
+ const ON_TIME_MS = 1500; // a chunk this close to where its source left off is placed straight after it
25
+ const MAX_BYTES = RATE * 2 * 60 * 60 * 6; // six hours; a longer meeting keeps its first six hours
26
+ const ID_RE = /^n-[0-9a-z]{4,16}-[0-9a-f]{4}$/;
27
+
28
+ const dirOf = (runtimeDir) => path.join(runtimeDir, 'notes', 'recordings');
29
+ const fileOf = (runtimeDir, id) => path.join(dirOf(runtimeDir), id + '.pcm');
30
+ const settingsFile = (runtimeDir) => path.join(runtimeDir, 'notes', 'audio-settings.json');
31
+ const next = new Map(); // `${file}|${source}` → the byte offset where that source's last chunk ended
32
+
33
+ function getKeep(runtimeDir) {
34
+ try { const s = JSON.parse(fs.readFileSync(settingsFile(runtimeDir), 'utf8')); return s.keep !== false; } catch { return true; }
35
+ }
36
+ function setKeep(runtimeDir, keep) {
37
+ if (typeof keep !== 'boolean') return { ok: false, error: 'say keep: true or keep: false' };
38
+ const f = settingsFile(runtimeDir);
39
+ fs.mkdirSync(path.dirname(f), { recursive: true });
40
+ const tmp = f + '.' + process.pid + '.tmp';
41
+ fs.writeFileSync(tmp, JSON.stringify({ keep }), { mode: 0o600 });
42
+ fs.renameSync(tmp, f);
43
+ return { ok: true, keep };
44
+ }
45
+
46
+ /** Add one chunk at meeting time `t`. Samples already there (the other source) are added to, and clipped. */
47
+ function write(runtimeDir, { id, source, pcm, t }) {
48
+ if (!ID_RE.test(String(id || '')) || !pcm || !pcm.length || pcm.length % 2) return { ok: false, error: 'nothing to keep' };
49
+ const f = fileOf(runtimeDir, id);
50
+ const key = f + '|' + source;
51
+ let at = Math.max(0, Math.round((Number(t) || 0) * BYTES_PER_MS / 2) * 2);
52
+ const prev = next.get(key);
53
+ // a source never overlaps itself: a chunk that would start before its source left off, or just after, goes straight after it
54
+ if (prev !== undefined && at <= prev + ON_TIME_MS * BYTES_PER_MS) at = prev;
55
+ if (at >= MAX_BYTES) return { ok: true, kept: false };
56
+ const len = Math.min(pcm.length, MAX_BYTES - at);
57
+ fs.mkdirSync(dirOf(runtimeDir), { recursive: true });
58
+ const fd = fs.openSync(f, fs.existsSync(f) ? 'r+' : 'w+', 0o600);
59
+ try {
60
+ const old = Buffer.alloc(len);
61
+ fs.readSync(fd, old, 0, len, at);
62
+ const out = Buffer.alloc(len);
63
+ for (let i = 0; i < len; i += 2) out.writeInt16LE(Math.max(-32768, Math.min(32767, old.readInt16LE(i) + pcm.readInt16LE(i))), i);
64
+ fs.writeSync(fd, out, 0, len, at);
65
+ } finally { fs.closeSync(fd); }
66
+ next.set(key, at + len);
67
+ return { ok: true, kept: true, at_ms: at / BYTES_PER_MS };
68
+ }
69
+
70
+ function info(runtimeDir, id) {
71
+ if (!ID_RE.test(String(id || ''))) return null;
72
+ try { const st = fs.statSync(fileOf(runtimeDir, id)); return st.size ? { bytes: st.size, ms: Math.round(st.size / BYTES_PER_MS) } : null; } catch { return null; }
73
+ }
74
+ function remove(runtimeDir, id) {
75
+ if (!ID_RE.test(String(id || ''))) return false;
76
+ for (const k of [...next.keys()]) if (k.startsWith(fileOf(runtimeDir, id) + '|')) next.delete(k);
77
+ try { fs.unlinkSync(fileOf(runtimeDir, id)); return true; } catch { return false; }
78
+ }
79
+
80
+ function wavHeader(dataBytes) {
81
+ const h = Buffer.alloc(44);
82
+ h.write('RIFF', 0); h.writeUInt32LE(36 + dataBytes, 4); h.write('WAVE', 8);
83
+ h.write('fmt ', 12); h.writeUInt32LE(16, 16); h.writeUInt16LE(1, 20); h.writeUInt16LE(1, 22);
84
+ h.writeUInt32LE(RATE, 24); h.writeUInt32LE(RATE * 2, 28); h.writeUInt16LE(2, 32); h.writeUInt16LE(16, 34);
85
+ h.write('data', 36); h.writeUInt32LE(dataBytes, 40);
86
+ return h;
87
+ }
88
+ /** The audio as audio/wav. A `Range: bytes=a-b` request gets 206 with that part, which is how the page seeks. */
89
+ function serve(req, res, runtimeDir, id) {
90
+ const inf = info(runtimeDir, id);
91
+ if (!inf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'this note has no audio' })); return; }
92
+ const header = wavHeader(inf.bytes);
93
+ const total = header.length + inf.bytes;
94
+ let start = 0; let end = total - 1; let partial = false;
95
+ const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || '').trim());
96
+ if (m && (m[1] || m[2])) {
97
+ if (m[1]) { start = Number(m[1]); if (m[2]) end = Math.min(total - 1, Number(m[2])); }
98
+ else { start = Math.max(0, total - Number(m[2])); }
99
+ if (start > end || start >= total) { res.writeHead(416, { 'Content-Range': 'bytes */' + total }); res.end(); return; }
100
+ partial = true;
101
+ }
102
+ res.writeHead(partial ? 206 : 200, { 'Content-Type': 'audio/wav', 'Accept-Ranges': 'bytes', 'Content-Length': end - start + 1, 'Cache-Control': 'no-store',
103
+ ...(partial ? { 'Content-Range': 'bytes ' + start + '-' + end + '/' + total } : {}) });
104
+ if (req.method === 'HEAD') { res.end(); return; }
105
+ if (start < header.length) res.write(header.subarray(start, Math.min(header.length, end + 1)));
106
+ const from = Math.max(0, start - header.length); const to = end - header.length;
107
+ if (to < from) { res.end(); return; }
108
+ fs.createReadStream(fileOf(runtimeDir, id), { start: from, end: to }).on('error', () => res.end()).pipe(res);
109
+ }
110
+
111
+ module.exports = { write, info, remove, serve, getKeep, setKeep, wavHeader, RATE, BYTES_PER_MS, MAX_BYTES };
@@ -1,10 +1,12 @@
1
1
  'use strict';
2
- /* notes.js — the PM's notes: typed notes, dictation, meeting transcripts and their summaries (meeting-notes, Runs 1 and 2).
2
+ /* notes.js — the PM's notes: typed notes, dictation, meeting transcripts and their summaries (meeting-notes, Runs 1 to 3).
3
3
  *
4
4
  * list(dir) · read(dir, id) · create(dir, { title }) · save(dir, { id, title, body }) · remove(dir, id)
5
5
  * record(dir, { id, ws }) · appendTranscript(dir, { id, text, source, t }) · setPrivate(dir, { id, private })
6
6
  * categoryOf(text, names) → 'decision'|'action'|'question'|'topic'|'statement'|'small talk' the local label (Run 2)
7
7
  * updateSegment(dir, { id, index, text, patch }) · setLabel(dir, { id, index, cat }) · renameSpeaker(dir, { id, source, name })
8
+ * editLine(dir, { id, index, cat?, summary?, tags?, people?, text? }) the PM's corrections to one line (Run 3, D92)
9
+ * versionsOf(note) → [{ at, title, body, original }] every kept version of the notes, oldest first (Run 3, D92)
8
10
  * dictation(text) → { ops } the cleaned phrase as insert operations, spoken commands resolved
9
11
  * cleanSentences(text) → [sentence] a recognised phrase, cleaned and split into sentences
10
12
  * summarize(note) → { markdown, sections, counts } the local summary: no model, the same for the same input
@@ -15,6 +17,13 @@
15
17
  * never leaves half a note. A transcript holds other people's words, which is why it stays in the per-user directory, as
16
18
  * the Live notes transcript does (D85). Deleting a note deletes its file and its agent handoff files.
17
19
  *
20
+ * CORRECTIONS AND VERSIONS (Run 3, D92). The PM can correct a line: its label (the six, or fact), whether it is in the
21
+ * summary, its tags, the people it names and its text. A corrected text keeps the first text the transcript recorded as
22
+ * `orig_text`, and its repository check is dropped because it was about the old words. Every save of the notes keeps the
23
+ * state before it in `versions`; a save within VERSION_FOLD_MS of the last kept version keeps nothing new, so a burst of
24
+ * typing is one version, and the first version is never replaced. While a note has been recorded, each new line of the
25
+ * notes gets the meeting time it was first saved at (`line_times`), so a replay can show what was typed when.
26
+ *
18
27
  * SPEECH. This file never sees audio. Words reach it from local Whisper (lib/notes-whisper.js, D91) or from the browser's
19
28
  * on-device recognition (processLocally, no cloud fallback, D85). A transcript line carries its source: `mic` and
20
29
  * `meeting` from Whisper, `speech` from the browser. The note names each source ("You", "Meeting"); the PM can rename it.
@@ -43,6 +52,10 @@ const MAX_BODY = 400 * 1024;
43
52
  const MAX_PHRASE = 4000;
44
53
  const MAX_SEGMENTS = 20000;
45
54
  const MAX_AGENT_SUMMARY = 64 * 1024;
55
+ const VERSION_FOLD_MS = 2 * 60 * 1000;
56
+ const MAX_VERSIONS = 200;
57
+ const MAX_TAGS = 12;
58
+ const MAX_LINE_TIMES = 2000;
46
59
  const notesDir = (dir) => path.join(dir, 'notes');
47
60
  const handoffDir = (dir) => path.join(notesDir(dir), 'handoff');
48
61
  const fileOf = (dir, id) => path.join(notesDir(dir), id + '.json');
@@ -97,15 +110,51 @@ function read(dir, id) {
97
110
  function save(dir, { id, title, body, now = Date.now() } = {}) {
98
111
  const note = load(dir, id);
99
112
  if (!note) return err(404, 'no such note');
100
- if (title !== undefined) note.title = cleanTitle(title);
113
+ const nextTitle = title !== undefined ? cleanTitle(title) : note.title;
114
+ let nextBody = note.body;
101
115
  if (body !== undefined) {
102
- const b = String(body == null ? '' : body);
103
- if (Buffer.byteLength(b) > MAX_BODY) return err(413, 'the note is too long to save: keep it under 400 KB');
104
- note.body = b;
116
+ nextBody = String(body == null ? '' : body);
117
+ if (Buffer.byteLength(nextBody) > MAX_BODY) return err(413, 'the note is too long to save: keep it under 400 KB');
118
+ }
119
+ if (nextTitle !== note.title || nextBody !== (note.body || '')) {
120
+ keepVersion(note, now);
121
+ if (nextBody !== (note.body || '')) stampLines(note, nextBody, now);
105
122
  }
123
+ note.title = nextTitle; note.body = nextBody;
106
124
  note.updated_at = iso(now);
107
125
  store(dir, note);
108
- return { ok: true, updated_at: note.updated_at };
126
+ return { ok: true, updated_at: note.updated_at, versions: (note.versions || []).length };
127
+ }
128
+ /** Keep the note's text before this save, unless the last kept version is younger than VERSION_FOLD_MS. Notes with no
129
+ * text yet are not a version. The first version is never dropped, so the original stays. */
130
+ function keepVersion(note, now) {
131
+ if (!String(note.body || '').trim()) return;
132
+ const vs = note.versions || (note.versions = []);
133
+ const last = vs[vs.length - 1];
134
+ if (last && now - Date.parse(last.kept_at) < VERSION_FOLD_MS) return;
135
+ vs.push({ at: note.body_at || note.updated_at, kept_at: iso(now), title: note.title || '', body: note.body || '' });
136
+ if (vs.length > MAX_VERSIONS) vs.splice(1, 1);
137
+ note.body_at = iso(now);
138
+ }
139
+ /** Each line of the notes that is new in this save gets the meeting time it appeared at. */
140
+ function stampLines(note, body, now) {
141
+ if (!note.recorded_from) return;
142
+ const t = now - Date.parse(note.recorded_from);
143
+ if (!(t >= 0)) return;
144
+ const prev = note.line_times || {};
145
+ const next = {};
146
+ for (const raw of String(body).split(/\r?\n/)) {
147
+ const l = raw.trim();
148
+ if (!l || next[l] !== undefined) continue;
149
+ next[l] = prev[l] !== undefined ? prev[l] : Math.round(t);
150
+ if (Object.keys(next).length >= MAX_LINE_TIMES) break;
151
+ }
152
+ note.line_times = next;
153
+ }
154
+ /** Every kept version, oldest first, then the current text. The first is the original. */
155
+ function versionsOf(note) {
156
+ const vs = (note.versions || []).map((v, i) => ({ at: v.at, title: v.title, body: v.body, original: i === 0 }));
157
+ return vs;
109
158
  }
110
159
  function remove(dir, id) {
111
160
  if (!load(dir, id)) return err(404, 'no such note');
@@ -176,11 +225,47 @@ function setLabel(dir, { id, index, cat, drop = false, now = Date.now() } = {})
176
225
  if (!note) return err(404, 'no such note');
177
226
  const seg = (note.transcript || [])[index];
178
227
  if (!seg) return err(404, 'no such line');
179
- if (!CATS.includes(cat)) return err(400, 'the label must be one of: ' + CATS.join(', '));
228
+ if (!PM_CATS.includes(cat)) return err(400, 'the label must be one of: ' + PM_CATS.join(', '));
180
229
  seg.cat = cat; seg.cat_by = 'pm'; if (drop) seg.dropped = true; else delete seg.dropped; note.updated_at = iso(now);
181
230
  store(dir, note);
182
231
  return { ok: true, segment: seg };
183
232
  }
233
+ const cleanTag = (t) => String(t == null ? '' : t).replace(/^[#\s]+/, '').replace(/\s+/g, '-').replace(/[^\p{L}\p{N}_-]/gu, '').slice(0, 40);
234
+ const cleanPerson = (p) => String(p == null ? '' : p).replace(/^[@\s]+/, '').replace(/\s+/g, ' ').trim().slice(0, 60);
235
+ const uniq = (xs) => { const seen = new Set(); return xs.filter((x) => { const k = x.toLowerCase(); if (!x || seen.has(k)) return false; seen.add(k); return true; }); };
236
+ /** The PM's corrections to one line. Each field is optional; what is not given is kept. */
237
+ function editLine(dir, { id, index, cat, summary, tags, people, text, now = Date.now() } = {}) {
238
+ const note = load(dir, id);
239
+ if (!note) return err(404, 'no such note');
240
+ const seg = (note.transcript || [])[index];
241
+ if (!seg) return err(404, 'no such line');
242
+ if (cat !== undefined && !PM_CATS.includes(cat)) return err(400, 'the label must be one of: ' + PM_CATS.join(', '));
243
+ if (summary !== undefined && summary !== null && typeof summary !== 'boolean') return err(400, 'summary must be true, false or null');
244
+ if (tags !== undefined && !Array.isArray(tags)) return err(400, 'tags must be a list');
245
+ if (people !== undefined && !Array.isArray(people)) return err(400, 'people must be a list');
246
+ let resummarize = false;
247
+ if (text !== undefined) {
248
+ const t = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
249
+ if (!t) return err(400, 'a line cannot be empty: remove it from its section instead');
250
+ if (t.length > MAX_PHRASE) return err(413, 'that line is too long');
251
+ if (t !== seg.text) {
252
+ if (seg.orig_text === undefined) seg.orig_text = seg.text;
253
+ if (t === seg.orig_text) { delete seg.orig_text; delete seg.edited_at; } else seg.edited_at = iso(now);
254
+ seg.text = t; delete seg.check; resummarize = true;
255
+ }
256
+ }
257
+ if (cat !== undefined) { seg.cat = cat; seg.cat_by = 'pm'; delete seg.dropped; resummarize = true; }
258
+ if (summary !== undefined) { if (summary === null) delete seg.in_summary; else seg.in_summary = summary; resummarize = true; }
259
+ if (tags !== undefined) { const v = uniq(tags.map(cleanTag)).slice(0, MAX_TAGS); if (v.length) seg.tags = v; else delete seg.tags; }
260
+ if (people !== undefined) { const v = uniq(people.map(cleanPerson)).slice(0, MAX_TAGS); if (v.length) seg.people = v; else delete seg.people; }
261
+ if (resummarize && note.summary && note.summary.source === 'local') {
262
+ const sm = summarize(note);
263
+ note.summary = { source: 'local', at: iso(now), markdown: sm.markdown, sections: sm.sections };
264
+ }
265
+ note.updated_at = iso(now);
266
+ store(dir, note);
267
+ return { ok: true, segment: seg, summary: note.summary || null };
268
+ }
184
269
  function renameSpeaker(dir, { id, source, name, now = Date.now() } = {}) {
185
270
  const note = load(dir, id);
186
271
  if (!note) return err(404, 'no such note');
@@ -325,6 +410,8 @@ function isQuestion(s) {
325
410
  }
326
411
  /* ── the local label (Run 2) ───────────────────────────────────────────────────────────────────── */
327
412
  const CATS = ['decision', 'action', 'question', 'topic', 'statement', 'small talk'];
413
+ // the labels the PM may set: the six a rule or Jev may give, and fact, which only the PM gives
414
+ const PM_CATS = ['decision', 'action', 'question', 'fact', 'topic', 'statement', 'small talk'];
328
415
  const SMALL_TALK = /^(?:(?:ok(?:ay)?|so|and|well|um|uh)[, ]+)*(?:hi|hello|hey|morning|good (?:morning|afternoon|evening)|thanks?(?: you)?|thank you|bye|see you|cheers|can you (?:hear|see) me|you(?:'re| are) (?:on )?mute|one sec(?:ond)?|sorry|ok(?:ay)?|yeah|yes|no|right|cool|great|perfect|sounds good|let me share my screen|is everyone here|let's (?:start|begin|get started))\b/i;
329
416
  const TOPIC = /\b(?:let's (?:talk about|move on|discuss|look at|go through|turn to)|(?:the )?main (?:thing|topic|item|point)|agenda|next (?:topic|item)|moving on|today we(?:'re| are| will)|the (?:goal|point|idea|context|background|reason) (?:is|here)|what (?:this|that) means is|to give (?:you )?some context)\b/i;
330
417
  /** The local label of one line. Decisions, actions and questions use the summary's own rules, so both agree. */
@@ -340,7 +427,7 @@ function categoryOf(text, names) {
340
427
  return 'statement';
341
428
  }
342
429
  /** A statement worth listing under "Facts mentioned": it has a number, or the repository check found its passage. */
343
- const isFact = (x) => !x.dropped && x.cat === 'statement' && (/\d|\b(?:percent|half|double|twice)\b/i.test(x.text) || !!(x.check && x.check.doc));
430
+ const isFact = (x) => !x.dropped && (x.cat === 'fact' || (x.cat === 'statement' && (/\d|\b(?:percent|half|double|twice)\b/i.test(x.text) || !!(x.check && x.check.doc))));
344
431
  const fmtTime = (ms) => {
345
432
  if (ms == null || !Number.isFinite(ms)) return null;
346
433
  const s = Math.floor(ms / 1000); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const r = s % 60;
@@ -362,7 +449,8 @@ function bodySentences(body) {
362
449
  function summarize(note) {
363
450
  const tr = ((note && note.transcript) || []).filter((x) => x && x.text);
364
451
  const typed = bodySentences(note && note.body);
365
- const items = tr.map((x, i) => ({ i, text: x.text, t: Number.isFinite(x.t) ? x.t : null, src: 'transcript', cat: x.cat || null, check: x.check || null, dropped: !!x.dropped }));
452
+ const items = tr.map((x, i) => ({ i, index: (note.transcript || []).indexOf(x), text: x.text, t: Number.isFinite(x.t) ? x.t : null, src: 'transcript', cat: x.cat || null, check: x.check || null, dropped: !!x.dropped,
453
+ in_summary: x.in_summary === true ? true : x.in_summary === false ? false : null }));
366
454
  for (const s of typed) items.push({ i: items.length, text: s, t: null, src: 'notes' });
367
455
  const terms = items.map((x) => [...new Set(termsOf(x.text))]);
368
456
  const names = namesIn(items.map((x) => x.text));
@@ -396,8 +484,11 @@ function summarize(note) {
396
484
  const k = Math.max(3, Math.min(7, Math.round(pool.length / 12)));
397
485
  const chosen = [];
398
486
  const jac = (a, b) => { const A = new Set(a); const B = new Set(b); let i = 0; for (const w of A) if (B.has(w)) i++; return i / ((A.size + B.size - i) || 1); };
487
+ // the PM's choice wins: a line put in the summary is in it, a line taken out never is
488
+ for (const x of pool) if (x.in_summary === true) chosen.push(x);
399
489
  for (const x of pool.slice().sort((a, b) => score[b.i] - score[a.i] || a.i - b.i)) {
400
490
  if (chosen.length >= k || score[x.i] <= 0) break;
491
+ if (x.in_summary === false || chosen.includes(x)) continue;
401
492
  if (chosen.some((c) => jac(terms[c.i], terms[x.i]) > 0.5)) continue;
402
493
  chosen.push(x);
403
494
  }
@@ -410,7 +501,7 @@ function summarize(note) {
410
501
  const questions = items.filter((x) => kindOf(x, 'question')).filter(once);
411
502
  const facts = items.filter((x) => x.src === 'transcript' && isFact(x)).filter(once);
412
503
  const sections = {
413
- overview: chosen.map((x) => ({ text: x.text, at: cite(x) })),
504
+ overview: chosen.map((x) => (x.src === 'transcript' ? { text: x.text, at: cite(x), index: x.index } : { text: x.text, at: cite(x) })),
414
505
  decisions: decisions.map((x) => ({ text: x.text, at: cite(x) })),
415
506
  actions: actions.map((x) => ({ text: x.text, at: cite(x), owner: x.owner, due: x.due })),
416
507
  questions: questions.map((x) => ({ text: x.text, at: cite(x) })),
@@ -459,9 +550,11 @@ function toMarkdown(note, { redact = false, summary = true } = {}) {
459
550
  if (String(note.body || '').trim()) out.push('## Notes', '', ...String(note.body).split(/\r?\n/).map(line), '');
460
551
  const sum = note.agent && note.agent.markdown ? note.agent.markdown : note.summary && note.summary.markdown;
461
552
  if (summary && sum) out.push('## Summary', '', ...sum.split(/\r?\n/).map(line), '');
462
- if ((note.transcript || []).length) out.push('## Transcript', '', ...note.transcript.map((x) => line('[' + fmtTime(x.t) + '] ' + (x.source && x.source !== 'speech' ? speakerOf(note, x.source) + ': ' : '') + x.text)), '');
553
+ if ((note.transcript || []).length) out.push('## Transcript', '', ...note.transcript.map((x) => line('[' + fmtTime(x.t) + '] ' + (x.source && x.source !== 'speech' ? speakerOf(note, x.source) + ': ' : '') + x.text + tagWords(x))), '');
463
554
  return { markdown: out.join('\n'), withheld };
464
555
  }
556
+ /** " #tag @Person" for a line, or "". */
557
+ const tagWords = (x) => [...(x.tags || []).map((t) => '#' + t), ...(x.people || []).map((p) => '@' + p)].map((w) => ' ' + w).join('');
465
558
  function promptFor(handoff, out) {
466
559
  return 'Summarize a meeting note for me. Read the file ' + handoff + ' (a PMOS meeting note: my typed notes and a transcript recognised on my machine). '
467
560
  + 'Write the summary in Markdown to the file ' + out + ' with these sections: ### Overview (three to six bullets on what the meeting was about and concluded), '
@@ -507,5 +600,5 @@ function importAgent(dir, note) {
507
600
  return true;
508
601
  }
509
602
 
510
- module.exports = { list, read, create, save, remove, record, appendTranscript, updateSegment, setLabel, renameSpeaker, speakerOf, categoryOf, isFact, CATS, setPrivate, dictation, cleanSentences, spokenPunctuation,
603
+ module.exports = { list, read, create, save, remove, record, appendTranscript, updateSegment, setLabel, editLine, versionsOf, renameSpeaker, speakerOf, categoryOf, isFact, CATS, PM_CATS, VERSION_FOLD_MS, setPrivate, dictation, cleanSentences, spokenPunctuation,
511
604
  summarize, localSummary, agentHandoff, agentStarted, toMarkdown, promptFor, fmtTime, ownerOf, dueOf, ID_RE, notesDir, handoffOf, agentOutOf };
@@ -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