create-pmos 0.117.0 → 0.118.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.117.0",
3
+ "version": "0.118.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@fc4f107
1
+ kernel-source: pmos@5737e5d
2
2
  assembled: 2026-10-08
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-08T06:49:50Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-08T07:37:35Z"}
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-08T06:49:50Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-08T07:37:35Z"}
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-08T06:49:50Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-08T07:37:35Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -7,7 +7,47 @@ timestamp: 2026-10-08
7
7
 
8
8
  # Guide: notes
9
9
 
10
- **Notes** is a page in the top bar, between Docs and People. The list on the left shows every note by initiative, with the time or day and an amber dot when a note conflicts with a file. The note you pick opens on the right: its title, the **Resume** button and the **⋯** menu, then the conflicts, the highlights, the overview, the decisions, the next steps (each with **Make task**), the open questions and a **Discussion** fold with the topics and the transcript. Press the title, the overview or any decision, next step or question to edit it. PMOS keeps the words the agent wrote. At the top right of the bar, **Record** starts a new note and listens at once. While it listens, the bar shows the red dot and the clock, a **Highlight** button and **Pause**. When it is paused, the bar shows **Resume**. Press the clock to open the note as a pop-up. Each note below is shown in the pop-up and on the page in the same way.
10
+ **Notes** is a page in the top bar, between Docs and People. The list on the left shows every note by initiative, with the time or day and an amber dot when a note conflicts with a file. The note you pick opens on the right. That column is the full note: there is no second, pop-up version of it. It has the title with its icons, the meeting context, the conflicts, the highlights, the overview, the decisions, the next steps (each with a **Make task** icon), the open questions and a **Discussion** fold with the topics and the transcript. Press the title, the overview or any decision, next step or question to edit it. PMOS keeps the words the agent wrote.
11
+
12
+ Every button in the top bar and in the note is an icon. Hold the pointer on an icon to read its name.
13
+
14
+ ## The Record control
15
+
16
+ The Record control is at the right of the top bar. It has two groups, always in the same place.
17
+
18
+ - **The meeting group** shows only while a meeting is recording or paused. It starts with the meeting's name and its clock (paused: **paused · 48 min left**). Press the name to open the note. While recording, the group has a highlighter icon, **Pause** and **Stop**. While paused, it has **Resume** and **Stop**. When there is nothing to resume, the group is not there.
19
+ - **The new group** is the red microphone with a plus, and an arrow. The microphone starts an untitled note and listens at once. The arrow lists the meetings of today that have not started and have no note, with the next one first. Each shows who is coming and how many agenda items it has. Press one to start a note for that meeting, with its context. Both are unavailable while a meeting is recording. Pause or stop it first.
20
+
21
+ Starting a note, from the Record control or from **Take notes** on a calendar event, shows the Notes page with that note.
22
+
23
+ ## Pause, resume and stop
24
+
25
+ **Pause** keeps the meeting open. PMOS stops listening and the clock stops. It does not ask, and it does not write the final notes yet. You can resume for one hour. The top bar shows the minutes left. After an hour the meeting ends by itself, and PMOS writes its final notes.
26
+
27
+ **Resume** continues the most recent paused meeting, in the same note, with the clock where it stopped. It is not available for a meeting that was stopped, for one that ran out of its hour, or for an older meeting after a later one was recorded. Its tooltip says why.
28
+
29
+ **Stop** ends the meeting for good. PMOS asks first: **Cancel**, **Pause instead** (while recording) or **Stop for good**. A stopped meeting cannot be resumed. PMOS writes the final notes at once.
30
+
31
+ If you start a note by mistake and stop it within a minute, or before anything was heard, and you typed nothing, PMOS deletes the note. The meeting you paused before it can be resumed again.
32
+
33
+ ## While the meeting is held
34
+
35
+ While a meeting is recording or paused, the note shows a live block under the context: the status, **Your notes** (type a line and press Enter) and the live transcript. The block stays steady while it listens. New lines are added at the end. Your place, the line you are typing and its cursor do not move. If the transcript is scrolled to its end, it follows the new lines. The note shows **Stop** while recording, and **Resume** and **Stop** while paused. **Highlight** and **Pause** are in the top bar only.
36
+
37
+ ## The meeting context
38
+
39
+ A note made for a calendar meeting keeps what PMOS knew of the meeting when the note was made, so the note reads clearly later. The **Meeting context** card shows it. It is open while the meeting is held and folds to one line after. The card shows only what the calendar and the morning brief carry:
40
+
41
+ - **People**: the names in the event's "with" line, only while calendar names are on (Settings, People). Otherwise the card shows the number of attendees.
42
+ - **Agenda**: the steps the brief planned for the meeting. An item shows a tick and a time when a transcript line shares two of its words. That is a word match on this machine, so the card says **heard**, never done.
43
+ - **What it is for**: the brief's purpose and how to be ready, the initiative and the related items.
44
+ - **Details**: the time and date, the series, your role, whether someone from outside the company attends, and the brief's links.
45
+
46
+ The calendar memory holds no invitation text, and the morning brief never copies it. So there is no description or location unless the brief gives one. PMOS never makes up a field. A note with no meeting says so. Press the link icon, or **Link to a meeting** in the **⋯** menu, to pick one. When the note keeps its meeting's people, naming a speaker starts with them. The copy icon puts the note on the clipboard as Markdown, with the context first.
47
+
48
+ ## Exact times and dates
49
+
50
+ The clock icon beside the note's time shows the exact times and dates: the calendar event, each stretch that was recorded with its date, time and time zone, the total recorded, the status, and the time until which the meeting can be resumed. Hold the pointer on a note in the list to see when it started.
11
51
 
12
52
  The Notes view shows one meeting, top to bottom. A bar at the top shows whether notes are being taken, with **Stop** or **Listen** and the **⋯** menu. Under it are the title, the day, the meeting, the initiative and the people. Then come the overview, the key points, the decisions, the next steps, the open questions and your own notes, each item with the meeting time it was said or written. The transcript folds away at the end. Notes are kept on this machine, outside every repository.
13
53
 
@@ -150,7 +190,7 @@ To stop keeping audio, clear **Keep the audio for replay** in the settings. To d
150
190
 
151
191
  ## Capture highlights during the meeting
152
192
 
153
- While listening, press the highlighter in the top bar (or in the note) to capture the last 30 seconds of conversation. A pop-up shows the highlight's progress:
193
+ While listening, press the highlighter in the top bar to capture the last 30 seconds of conversation. A pop-up opens just below the Record control and shows the highlight's progress. Its icons keep the highlight, discard it, or close the pop-up (the highlight keeps working):
154
194
 
155
195
  1. **Capturing**: the last 30 seconds are being recorded.
156
196
  2. **Listening**: the highlight listens forward. Choose how long: **30 seconds**, **1 minute** or **2 minutes**. The remaining time counts down.
@@ -190,7 +230,7 @@ Under the title, a note shows two lines: **Meeting** and **Initiative**.
190
230
 
191
231
  When a note has no initiative, the summary suggests one: the open initiative that shares 3 or more words with what was said, when no other initiative shares as many. A suggestion never replaces an initiative you chose.
192
232
 
193
- To take notes for a calendar event, open the event's detail on the Agenda and press **Take notes**. PMOS starts to listen at once, to your microphone and to the meeting when local transcription is set up. Pick the meeting's tab or window when the browser asks. Let people know that you are taking notes. The note is linked to the event, and to the event's initiative when it has one. The top bar shows the red dot and the clock on every page. Press the clock to open the note as a pop-up: add your notes, read the key points, and press **Stop** when the meeting ends. The pop-up stays steady while it listens: new lines are added at the end, and your place in the pop-up, the line you are typing and its cursor do not move. If the transcript is scrolled to its end, it follows the new lines. Press **Minimize** or Esc to make it small again. **Open in Notes** in the **⋯** menu shows it on the full screen. One note is taken at a time. If you press **Take notes** on another event while a note listens, PMOS asks first: **Keep it**, or **Stop it and start this one**. The note stays in the top bar after a reload. Press **Stop** in the note to put it away. An event that already has a note shows its summary and **Open notes**, which opens the note in the pop-up. See [Today](today.md) and [the initiative sheet](initiatives-screen.md).
233
+ To take notes for a calendar event, open the event's detail on the Agenda and press **Take notes**. PMOS starts to listen at once, to your microphone and to the meeting when local transcription is set up. Pick the meeting's tab or window when the browser asks. Let people know that you are taking notes. The note is linked to the event, and to the event's initiative when it has one. It keeps the event's context. The Notes page shows the note, and the top bar shows the meeting's name and clock on every page. One note is taken at a time: pause or stop the one that listens before you start another. An event that already has a note shows its summary and **Open notes**, which shows the note in Notes. See [Today](today.md) and [the initiative sheet](initiatives-screen.md).
194
234
 
195
235
  ## Dictate into your notes
196
236
 
@@ -47,7 +47,7 @@ A day in the month holds three things: the day number, a tint that is darker whe
47
47
 
48
48
  A meeting's detail shows one focus. Under the title is one line: the time, how many people join, and a star when the meeting is key. Under it are the goal of the meeting and, for a meeting with prep, one line of progress (the steps done, a bar, the free minutes before the meeting). Only the first open step is expanded, with what its source says, its link and, for a note step, a box for your note. The one primary button follows it: **I have read it**, **Mark done** or **Done**, then **Mark ready** when every step is done, then **Take notes** once the meeting has started. Press the arrow at the end of another step to open it. **More** holds the rare presses: **Mark ready** while steps are left, **Remind me**, **Skip prep**, the key mark and **Add as a goal for today**. **Details** holds the people, the kind of meeting and how often it met, the initiative, During and After, the mail and the links. When the meeting has a goal, the detail also shows what the goal is done when and the goal's jobs, with the buttons to add a job and to link one.
49
49
 
50
- A meeting's detail ends with **Meeting notes**. Press **Take notes** to start a note linked to the meeting, and to its initiative when the brief names one. PMOS starts to listen at once and keeps the note small at the bottom right. Press it to open the note as a pop-up. When the meeting has a note, the detail shows its summary and **Open notes**, which opens it in the pop-up, and the meeting's row shows **notes**. See [Notes](notes.md).
50
+ A meeting's detail ends with **Meeting notes**. Press **Take notes** to start a note linked to the meeting, and to its initiative when the brief names one. PMOS starts to listen at once and shows the note in Notes. When the meeting has a note, the detail shows its summary and **Open notes**, which shows it in Notes, and the meeting's row shows **notes**. See [Notes](notes.md).
51
51
 
52
52
  The memory of a meeting says what kind of meeting it is (one-to-one, onboarding or group), how many times it was held, how many are to come, and why it matters.
53
53
 
@@ -2,7 +2,7 @@
2
2
  /* notes.js — the PM's notes: typed notes, dictation, meeting transcripts and their summaries (meeting-notes, Runs 1 to 9).
3
3
  *
4
4
  * list(dir) · read(dir, id) · create(dir, { title }) · save(dir, { id, title, body }) · remove(dir, id)
5
- * record(dir, { id, ws }) · appendTranscript(dir, { id, text, source, t, ms }) · setPrivate(dir, { id, private })
5
+ * record(dir, { id, ws }) · pause(dir, { id }) · stop(dir, { id }) · expire(dir, { id, resumeMs }) · appendTranscript(dir, { id, text, source, t, ms }) · setPrivate(dir, { id, private })
6
6
  * categoryOf(text, names) → 'decision'|'action'|'question'|'topic'|'statement'|'small talk' the local label (Run 2)
7
7
  * meetsBar(cat, text) → boolean whether a line is good enough to be a decision, an action or a question (any labeller)
8
8
  * checkable(seg) → boolean whether a line is worth a repository check
@@ -21,6 +21,17 @@
21
21
  * the minute batches and the agent's bullets (Run 8; see "minute batches" below)
22
22
  * `dir` is the per-user runtime directory (server.js STATE). Notes live in `<dir>/notes/`, never in a repository.
23
23
  *
24
+ * PAUSE IS NOT STOP (Run 13, R51, R56). Pause keeps the meeting open for RESUME_MS (one hour). A Listen inside that hour
25
+ * resumes it. After the hour the meeting ends by itself (`expire`, ended_by 'timeout'). Stop ends it for good (`stop`,
26
+ * ended_by 'stop'). A meeting that ended cannot be recorded again: `record` answers 409. Each stretch of recording is
27
+ * `sessions: [{ from, to }]` (ISO times; `to` is null while open); `sessionsOf` derives one for a note from before Run 13.
28
+ * `stateOf(note, now, resumeMs)` is 'recording' | 'paused' | 'ended' | 'idle'. A row and a note carry state, paused_at,
29
+ * resume_until (paused_at + resumeMs while paused), ended_at, ended_by and sessions.
30
+ *
31
+ * MEETING CONTEXT (Run 13, R59). `meeting.context` is a snapshot of the calendar meeting's context, kept when the note is made
32
+ * or linked: with, attendees, role, external, series, purpose, prep, agenda, links, refs, where, organizer, taken_at.
33
+ * `cleanContext` clips and checks every field; nothing the client sends is trusted, and anything else is dropped.
34
+ *
24
35
  * WHERE A NOTE LIVES. One JSON file per note, `<dir>/notes/<id>.json`, written to a temporary file and renamed, so a crash
25
36
  * never leaves half a note. A transcript holds other people's words, which is why it stays in the per-user directory, as
26
37
  * the Live notes transcript does (D85). Deleting a note deletes its file and its agent handoff files.
@@ -65,6 +76,7 @@ const crypto = require('crypto');
65
76
  const { scan } = require('./secret-scan');
66
77
  const { cleanPhrase } = require('./live-notes');
67
78
 
79
+ const RESUME_MS = 60 * 60 * 1000;
68
80
  const ID_RE = /^n-[0-9a-z]{4,16}-[0-9a-f]{4}$/;
69
81
  const MAX_TITLE = 160;
70
82
  const MAX_BODY = 400 * 1024;
@@ -104,15 +116,46 @@ const newId = (now) => 'n-' + Math.floor(now).toString(36) + '-' + crypto.random
104
116
  const cleanTitle = (t) => String(t == null ? '' : t).replace(/\s+/g, ' ').trim().slice(0, MAX_TITLE);
105
117
  const wordsOf = (s) => (String(s || '').match(/[\p{L}\p{N}']+/gu) || []).length;
106
118
 
119
+ /* ── pause, stop and the state of a meeting (Run 13) ─────────────────────────────────────────────── */
120
+ /** The resume window in ms: PMOS_NOTES_RESUME_MS (50 or more), else one hour. Routes pass their own through `resumeMs`. */
121
+ function RESUME_MS_NOW() { const v = Number(process.env.PMOS_NOTES_RESUME_MS); return Number.isFinite(v) && v >= 50 ? v : RESUME_MS; }
122
+ const resumeOf = (o) => (Number.isFinite(Number(o)) && Number(o) >= 50 ? Number(o) : RESUME_MS_NOW());
123
+ /** The recording stretches of a note: [{ from, to }]. A note from before Run 13 has one, derived from its first Listen and its pause. */
124
+ function sessionsOf(note) {
125
+ if (note && Array.isArray(note.sessions)) return note.sessions.map((x) => ({ from: x.from, to: x.to || null }));
126
+ if (note && note.recorded_from) return [{ from: note.recorded_from, to: note.paused_at || null }];
127
+ return [];
128
+ }
129
+ /** 'recording' | 'paused' | 'ended' | 'idle'. Pure. `ended` also covers a pause older than the window that nothing has marked yet. */
130
+ function stateOf(note, now = Date.now(), resumeMs = RESUME_MS) {
131
+ if (!note) return 'idle';
132
+ if (note.ended_at) return 'ended';
133
+ if (!note.recorded_from) return 'idle';
134
+ if (note.paused_at) {
135
+ const p = Date.parse(note.paused_at);
136
+ return Number.isFinite(p) && now - p > resumeMs ? 'ended' : 'paused';
137
+ }
138
+ return note.private ? 'idle' : 'recording';
139
+ }
140
+ function stateFields(n, now, resumeMs) {
141
+ const state = stateOf(n, now, resumeMs);
142
+ const p = Date.parse(n.paused_at);
143
+ let endedAt = n.ended_at || null;
144
+ let endedBy = n.ended_by || null;
145
+ if (state === 'ended' && !endedAt && Number.isFinite(p)) { endedAt = iso(Math.min(now, p + resumeMs)); endedBy = 'timeout'; }
146
+ return { state, paused_at: n.paused_at || null, resume_until: state === 'paused' && Number.isFinite(p) ? iso(p + resumeMs) : null,
147
+ ended_at: endedAt, ended_by: endedBy, sessions: sessionsOf(n) };
148
+ }
149
+
107
150
  /** The row a list shows. Never the body or the transcript. */
108
- function rowOf(n) {
151
+ function rowOf(n, now = Date.now(), resumeMs = RESUME_MS) {
109
152
  return { id: n.id, title: n.title || 'Untitled note', created_at: n.created_at, updated_at: n.updated_at, private: !!n.private,
110
153
  words: wordsOf(n.body), segments: (n.transcript || []).length, summary: !!(n.summary || (n.agent && n.agent.markdown)),
111
154
  meeting: n.meeting || null, initiative: n.initiative || null, ws: n.ws || null,
112
155
  // the meeting time so far (a paused note's clock) and how many statements differ from a repository file (the Notes menu)
113
156
  meeting_ms: n.recorded_from ? meetingMs(n) : 0, conflicts: n.private ? 0 : conflictsOf(n).length,
114
157
  // a private note shows its title on the calendar and the initiative, never its summary
115
- summary_lines: n.private ? [] : summaryLines(n) };
158
+ summary_lines: n.private ? [] : summaryLines(n), ...stateFields(n, now, resumeMs) };
116
159
  }
117
160
  /** The statements of a note that a repository file says otherwise: transcript lines, the agent's bullets and the PM's
118
161
  * highlights whose check verdict is 'differs'. → [{ text, doc, line, t, from }] */
@@ -147,6 +190,30 @@ const EVENT_ID_RE = /^[A-Za-z0-9_-]{1,40}$/;
147
190
  const INI_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/;
148
191
  const minuteOf = (s) => { const t = Date.parse(s); return Number.isFinite(t) ? Math.floor(t / 60000) : null; };
149
192
  const normTitle = (t) => String(t || '').toLowerCase().replace(/\s+/g, ' ').trim();
193
+
194
+ const httpUrl = (u) => {
195
+ if (typeof u !== 'string' || u.length > 500) return null;
196
+ try { const x = new URL(u); return x.protocol === 'http:' || x.protocol === 'https:' ? u : null; } catch { return null; }
197
+ };
198
+ const ctxText = (v, n) => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim().slice(0, n) || null : null);
199
+ const ctxList = (v, n, max) => (Array.isArray(v) ? v.map((x) => ctxText(x, n)).filter(Boolean).slice(0, max) : []);
200
+ /** A snapshot of the meeting's context. Every field is clipped and checked; anything else is dropped. null when `c` is not an object. */
201
+ function cleanContext(c, now) {
202
+ if (!c || typeof c !== 'object' || Array.isArray(c)) return null;
203
+ const a = Number(c.attendees);
204
+ const links = (Array.isArray(c.links) ? c.links : []).map((l) => {
205
+ const url = l && httpUrl(l.url); const label = l && ctxText(l.label, 60);
206
+ return url && label ? { label, url } : null;
207
+ }).filter(Boolean).slice(0, 5);
208
+ const refs = (Array.isArray(c.refs) ? c.refs : []).map((r) => {
209
+ const label = r && ctxText(r.label, 120);
210
+ return label ? { label, source: ctxText(r.source, 30), link: httpUrl(r.link) } : null;
211
+ }).filter(Boolean).slice(0, 5);
212
+ return { with: ctxText(c.with, 120), attendees: c.attendees != null && c.attendees !== '' && Number.isInteger(a) && a >= 0 && a <= 1000 ? a : null,
213
+ role: ['present', 'decide', 'attend'].includes(c.role) ? c.role : null, external: c.external === true, series: ctxText(c.series, 200),
214
+ purpose: ctxText(c.purpose, 240), prep: ctxText(c.prep, 240), agenda: ctxList(c.agenda, 140, 6), links, refs,
215
+ where: ctxText(c.where, 120), organizer: ctxText(c.organizer, 120), taken_at: iso(now) };
216
+ }
150
217
  /** A meeting link: a calendar event (its id, where it came from, its start, end and title) or a meeting that is not on the
151
218
  * calendar. null unlinks. Returns { ok, meeting } or an error. */
152
219
  function cleanMeeting(m, now) {
@@ -160,7 +227,8 @@ function cleanMeeting(m, now) {
160
227
  if (m.end != null && minuteOf(m.end) === null) return err(400, 'the end of the event is not a time');
161
228
  if (m.event_id != null && !EVENT_ID_RE.test(String(m.event_id))) return err(400, 'that is not a calendar event id');
162
229
  return { ok: true, meeting: { kind: 'event', event_id: m.event_id != null ? String(m.event_id) : null, src: m.src === 'memory' ? 'memory' : 'brief',
163
- start: new Date(Date.parse(m.start)).toISOString(), end: m.end != null ? new Date(Date.parse(m.end)).toISOString() : null, title, linked_at: iso(now) } };
230
+ start: new Date(Date.parse(m.start)).toISOString(), end: m.end != null ? new Date(Date.parse(m.end)).toISOString() : null, title, linked_at: iso(now),
231
+ ...(cleanContext(m.context, now) ? { context: cleanContext(m.context, now) } : {}) } };
164
232
  }
165
233
  /** An initiative link: { id, ws, repo, title, by }. `repo` is the repository's name in the page, to open the initiative. `by` is pm (chosen), event (from the calendar event) or local (suggested). */
166
234
  function cleanInitiative(x, by) {
@@ -193,6 +261,9 @@ function link(dir, { id, meeting, initiative, by = 'pm', now = Date.now() } = {}
193
261
  if (meeting !== undefined) {
194
262
  const m = cleanMeeting(meeting, now);
195
263
  if (!m.ok) return m;
264
+ // a link without context keeps the stored context of the same event
265
+ const prev = note.meeting;
266
+ if (m.meeting && m.meeting.kind === 'event' && !m.meeting.context && prev && prev.context && sameEvent(prev, m.meeting)) m.meeting.context = prev.context;
196
267
  note.meeting = m.meeting;
197
268
  if (m.meeting && m.meeting.kind === 'event' && !String(note.title || '').trim()) note.title = m.meeting.title;
198
269
  }
@@ -206,11 +277,11 @@ function link(dir, { id, meeting, initiative, by = 'pm', now = Date.now() } = {}
206
277
  store(dir, note);
207
278
  return { ok: true, note };
208
279
  }
209
- function list(dir) {
280
+ function list(dir, { now = Date.now(), resumeMs } = {}) {
210
281
  let names = [];
211
282
  try { names = fs.readdirSync(notesDir(dir)).filter((f) => f.endsWith('.json')); } catch { return { ok: true, notes: [] }; }
212
283
  const notes = [];
213
- for (const f of names) { const n = load(dir, f.slice(0, -5)); if (n && n.id) notes.push(rowOf(n)); }
284
+ for (const f of names) { const n = load(dir, f.slice(0, -5)); if (n && n.id) notes.push(rowOf(n, now, resumeOf(resumeMs))); }
214
285
  notes.sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)) || (a.id < b.id ? 1 : -1));
215
286
  return { ok: true, notes };
216
287
  }
@@ -227,11 +298,11 @@ function create(dir, { title = '', meeting, initiative, now = Date.now() } = {})
227
298
  return { ok: true, note };
228
299
  }
229
300
  /** Read one note. An agent summary file written since the request is imported here, so the note is the one source. */
230
- function read(dir, id) {
301
+ function read(dir, id, { now = Date.now(), resumeMs } = {}) {
231
302
  const note = load(dir, id);
232
303
  if (!note) return err(404, 'no such note');
233
304
  importAgent(dir, note);
234
- return { ok: true, note };
305
+ return { ok: true, note: { ...note, ...stateFields(note, now, resumeOf(resumeMs)) } };
235
306
  }
236
307
  function save(dir, { id, title, body, now = Date.now() } = {}) {
237
308
  const note = load(dir, id);
@@ -349,28 +420,79 @@ function meetingMs(note, now = Date.now()) {
349
420
  }
350
421
  /** When the meeting clock started, as the page counts it: the first Listen moved on by the time it was stopped. */
351
422
  const clockFrom = (note, now = Date.now()) => iso(now - meetingMs(note, now));
352
- /** Recording starts: the first start fixes the time every transcript offset is counted from. A start after a stop adds
353
- * the stopped time to `paused_ms`, so the meeting time carries on from where it stopped. */
354
- function record(dir, { id, ws = undefined, now = Date.now() } = {}) {
355
- const note = load(dir, id);
423
+ const REFUSE = Object.freeze({
424
+ stop: 'this meeting was stopped, so it cannot be resumed',
425
+ timeout: 'this meeting was paused for over an hour, so it ended and cannot be resumed' });
426
+ /** Close the open recording stretch, if any. */
427
+ function closeSession(note, at) {
428
+ const ss = sessionsOf(note);
429
+ const open = ss.find((x) => !x.to);
430
+ if (open) open.to = at;
431
+ note.sessions = ss;
432
+ return !!open;
433
+ }
434
+ /** Recording starts: the first start fixes the time every transcript offset is counted from. A start after a pause adds
435
+ * the paused time to `paused_ms`, so the meeting time carries on from where it stopped. A meeting that ended (Stop, or a
436
+ * pause longer than the resume window) is refused with 409. */
437
+ function record(dir, { id, ws = undefined, now = Date.now(), resumeMs } = {}) {
438
+ let note = load(dir, id);
356
439
  if (!note) return err(404, 'no such note');
357
440
  if (note.private) return err(409, 'this note is marked private, so it is not recorded. Mark it not private first.');
441
+ if (stateOf(note, now, resumeOf(resumeMs)) === 'ended') {
442
+ if (!note.ended_at) expire(dir, { id, now, resumeMs });
443
+ note = load(dir, id);
444
+ return err(409, REFUSE[note.ended_by] || REFUSE.stop);
445
+ }
358
446
  let changed = false;
359
447
  if (!note.recorded_from) { note.recorded_from = iso(now); changed = true; }
360
448
  const stopped = Date.parse(note.paused_at);
361
449
  if (Number.isFinite(stopped)) { note.paused_ms = (Number(note.paused_ms) || 0) + Math.max(0, now - stopped); note.paused_at = null; changed = true; }
450
+ const ss = sessionsOf(note);
451
+ if (!ss.some((x) => !x.to)) { ss.push({ from: iso(now), to: null }); changed = true; }
452
+ note.sessions = ss;
362
453
  if (ws !== undefined && note.ws !== (ws || null)) { note.ws = ws || null; changed = true; }
363
454
  if (changed) { note.updated_at = iso(now); store(dir, note); }
364
455
  return { ok: true, recorded_from: note.recorded_from, clock_from: clockFrom(note, now), meeting_ms: meetingMs(note, now) };
365
456
  }
366
- /** Listening stopped: the meeting clock stops too, until the next Listen. */
367
- function pause(dir, { id, now = Date.now() } = {}) {
457
+ const endedShape = (note, now, resumeMs) => ({ meeting_ms: meetingMs(note, now), ...stateFields(note, now, resumeMs) });
458
+ /** Listening stopped but the meeting stays open: the meeting clock stops until the next Listen, for the resume window. */
459
+ function pause(dir, { id, now = Date.now(), resumeMs } = {}) {
368
460
  const note = load(dir, id);
369
461
  if (!note) return err(404, 'no such note');
370
462
  const closed = closeOpenLines(note);
371
463
  if (note.recorded_from && !note.paused_at) note.paused_at = iso(now);
372
- if (closed.length || note.paused_at) store(dir, note);
373
- return { ok: true, meeting_ms: meetingMs(note, now), closed: closed.length };
464
+ const s = note.paused_at ? closeSession(note, note.paused_at) : false;
465
+ if (closed.length || note.paused_at || s) store(dir, note);
466
+ return { ok: true, ...endedShape(note, now, resumeOf(resumeMs)), closed: closed.length };
467
+ }
468
+ /** The meeting ends for good: it is paused if it was listening, then marked ended by 'stop'. Calling it again changes nothing. */
469
+ function stop(dir, { id, now = Date.now(), resumeMs } = {}) {
470
+ const note = load(dir, id);
471
+ if (!note) return err(404, 'no such note');
472
+ const closed = closeOpenLines(note);
473
+ if (note.recorded_from && !note.paused_at) note.paused_at = iso(now);
474
+ if (note.paused_at) closeSession(note, note.paused_at);
475
+ if (!note.ended_at) { note.ended_at = iso(now); note.ended_by = 'stop'; }
476
+ if (!note.sessions && note.recorded_from) note.sessions = sessionsOf(note);
477
+ note.updated_at = iso(now);
478
+ store(dir, note);
479
+ return { ok: true, ...endedShape(note, now, resumeOf(resumeMs)), closed: closed.length };
480
+ }
481
+ /** A pause older than the resume window ends the meeting by 'timeout', at the moment the window closed.
482
+ * → { ok, expired } plus the same fields as pause. */
483
+ function expire(dir, { id, now = Date.now(), resumeMs } = {}) {
484
+ const note = load(dir, id);
485
+ if (!note) return err(404, 'no such note');
486
+ const win = resumeOf(resumeMs);
487
+ const p = Date.parse(note.paused_at);
488
+ let expired = false;
489
+ if (!note.ended_at && Number.isFinite(p) && now - p > win) {
490
+ note.ended_at = iso(Math.min(now, p + win)); note.ended_by = 'timeout'; note.updated_at = iso(now);
491
+ if (!note.sessions) note.sessions = sessionsOf(note);
492
+ store(dir, note);
493
+ expired = true;
494
+ }
495
+ return { ok: true, expired, ...endedShape(note, now, win) };
374
496
  }
375
497
  /** Listening stopped, so no cut will finish a line still open: each one is closed and gets its local label. A label the PM
376
498
  * chose is kept. → the closed lines' indexes */
@@ -1545,7 +1667,7 @@ function editFinal(dir, { id, sec, i, text, now = Date.now() } = {}) {
1545
1667
  }
1546
1668
 
1547
1669
  module.exports = { addHighlight, updateHighlight, removeHighlight, editFinal, conflictsOf, setFinal, setFinalState, renameFinalSpeaker, finalSpeaker, transcriptLines, cleanNotes, notesMarkdown, groundQuote, parseTime, NOTES_SHAPE,
1548
- wisprHandoff, wisprPrompt, setWispr, importWispr, notesOutOf, wisprOutOf, list, read, create, link, findByEvent, sameEvent, cleanMeeting, summaryLines, save, remove, record, pause, meetingMs, appendTranscript, updateSegment, setLabel, editLine, versionsOf, renameSpeaker, speakerOf, categoryOf, isFact, CATS, PM_CATS, VERSION_FOLD_MS, setPrivate, dictation, cleanSentences, spokenPunctuation,
1670
+ wisprHandoff, wisprPrompt, setWispr, importWispr, notesOutOf, wisprOutOf, list, read, create, link, findByEvent, sameEvent, cleanMeeting, summaryLines, save, remove, record, pause, stop, expire, stateOf, sessionsOf, cleanContext, RESUME_MS, meetingMs, appendTranscript, updateSegment, setLabel, editLine, versionsOf, renameSpeaker, speakerOf, categoryOf, isFact, CATS, PM_CATS, VERSION_FOLD_MS, setPrivate, dictation, cleanSentences, spokenPunctuation,
1549
1671
  summarize, localSummary, agentHandoff, agentStarted, toMarkdown, promptFor, fmtTime, ownerOf, dueOf, ID_RE, notesDir, handoffOf, agentOutOf,
1550
1672
  meetsBar, checkable, vocabOf, termsIn, namesIn, SECTION_CAP, JOIN_GAP_MS,
1551
1673
  takeBatch, setMinutesAgent, minuteHandoff, bulletsPrompt, bulletsFollowUp, importBullets, closeOpenBullets, minuteHanded, lateMinutes, markFallback, updateBullet, editBullet, minutesDirOf, minuteMdOf, minuteJsonOf, MAX_BULLET };
@@ -17,7 +17,16 @@
17
17
  * POST /api/notes/dictate { text } → { ok, ops } one dictated phrase as insert operations (lib/notes.js dictation)
18
18
  * POST /api/notes/record { id, ws? } → { ok, recorded_from, clock_from, meeting_ms } `ws` is the repository the note's lines are checked against;
19
19
  * clock_from is the first Listen moved on by the time listening was stopped
20
- * POST /api/notes/pause { id } → { ok, meeting_ms } listening stopped: the meeting clock stops until the next Listen
20
+ * POST /api/notes/pause { id } → { ok, meeting_ms, state, paused_at, resume_until, ended_at, ended_by, sessions }
21
+ * a resumable pause (Run 13, R51): the clock stops, the meeting stays open for one hour
22
+ * (PMOS_NOTES_RESUME_MS or ctx.notesResumeMs, 50 or more), and a timer ends it after that.
23
+ * It writes the last minute batch. It does not ask for Wispr Flow's notes.
24
+ * POST /api/notes/stop { id } → { ok, meeting_ms, state, … } the meeting ends for good: the last minute batch, and the
25
+ * Wispr Flow fetch is scheduled. A stopped meeting cannot be recorded again.
26
+ * When a pause runs out (the timer, or the first list or read after a restart) the meeting ends with ended_by 'timeout':
27
+ * the Wispr Flow fetch is scheduled and the final pass is queued once. POST /api/notes/record answers 409 for a meeting that
28
+ * ended. A row and a note carry state ('idle'|'recording'|'paused'|'ended'), paused_at, resume_until, ended_at, ended_by, sessions
29
+ * and meeting.context (the snapshot of the meeting, lib/notes.js cleanContext; R59).
21
30
  * POST /api/notes/transcript { id, text, source? } → { ok, added: [{ at, t, text, source, cat, index }] }
22
31
  * POST /api/notes/audio { id, source, pcm, final? } → { ok, cut } 16 kHz mono 16-bit samples, base64; local Whisper (D91)
23
32
  * POST /api/notes/label { id, index, cat } → { ok, segment } the PM's own label for a line
@@ -83,7 +92,7 @@ const VOCAB_TTL_MS = 60 * 1000;
83
92
 
84
93
  const prefixes = ['/api/notes'];
85
94
  const POSTS = new Set(['/api/notes/new', '/api/notes/save', '/api/notes/dictate', '/api/notes/record', '/api/notes/transcript',
86
- '/api/notes/private', '/api/notes/pause', '/api/notes/summarize', '/api/notes/summarize-agent', '/api/notes/delete', '/api/notes/audio', '/api/notes/label',
95
+ '/api/notes/private', '/api/notes/pause', '/api/notes/stop', '/api/notes/summarize', '/api/notes/summarize-agent', '/api/notes/delete', '/api/notes/audio', '/api/notes/label',
87
96
  '/api/notes/speaker', '/api/notes/switch', '/api/notes/whisper-setup', '/api/notes/final', '/api/notes/final-setup', '/api/notes/line', '/api/notes/recording-delete', '/api/notes/clean-audio', '/api/notes/link', '/api/notes/bullet', '/api/notes/highlight', '/api/notes/highlight-remove', '/api/notes/final-edit', '/api/notes/wispr-fetch']);
88
97
 
89
98
  /* ── labels and checks, one note at a time ───────────────────────────────────────────────────── */
@@ -170,6 +179,43 @@ function arm(ctx, id) {
170
179
  if (t.timer.unref) t.timer.unref();
171
180
  timers.set(id, t);
172
181
  }
182
+ /* ── the resume window (Run 13): a paused meeting ends by itself ─────────────────────────────────── */
183
+ const resumeMsOf = (ctx) => { const v = Number(ctx.notesResumeMs || process.env.PMOS_NOTES_RESUME_MS); return Number.isFinite(v) && v >= 50 ? v : notes.RESUME_MS; };
184
+ const expiryTimers = new Map(); // note id → the timer that ends a paused meeting
185
+ function clearExpiry(id) { const t = expiryTimers.get(id); if (t) { clearTimeout(t); expiryTimers.delete(id); } }
186
+ /** The meeting ended by itself: write nothing new, ask for Wispr Flow's notes and queue the final pass (not twice). */
187
+ function endMeeting(ctx, id, by = 'timeout') {
188
+ if (by !== 'timeout') return { ok: true, expired: false };
189
+ const dir = ctx.runtimeDir;
190
+ const r = notes.expire(dir, { id, resumeMs: resumeMsOf(ctx) });
191
+ if (!r.ok || !r.expired) return r;
192
+ clearExpiry(id);
193
+ const cur = notes.read(dir, id, { resumeMs: resumeMsOf(ctx) });
194
+ if (cur.ok && !cur.note.private) {
195
+ disarm(id);
196
+ runMinute(ctx, id, { final: true });
197
+ scheduleWispr(ctx, id);
198
+ const fs0 = cur.note.final_state && cur.note.final_state.state;
199
+ if (!fs0) queueFinal(ctx, id);
200
+ }
201
+ return r;
202
+ }
203
+ function armExpiry(ctx, id, resumeUntil) {
204
+ clearExpiry(id);
205
+ const left = Number.isFinite(Date.parse(resumeUntil)) ? Date.parse(resumeUntil) - Date.now() : resumeMsOf(ctx);
206
+ const t = setTimeout(() => { expiryTimers.delete(id); endMeeting(ctx, id, 'timeout'); }, Math.max(1, left) + 5);
207
+ if (t.unref) t.unref();
208
+ expiryTimers.set(id, t);
209
+ }
210
+ const swept = new Set(); // note ids already ended by a sweep
211
+ /** After a restart no timer is left: a list or a read ends a meeting whose pause ran out. Idempotent and cheap. */
212
+ function sweepExpired(ctx, rows) {
213
+ for (const n of rows) {
214
+ if (n.state !== 'ended' || n.ended_by !== 'timeout' || !n.paused_at || swept.has(n.id)) continue;
215
+ swept.add(n.id);
216
+ endMeeting(ctx, n.id, 'timeout');
217
+ }
218
+ }
173
219
  function disarm(id) { const t = timers.get(id); if (t) { clearTimeout(t.timer); timers.delete(id); } }
174
220
  /** One minute's work for a note, after its earlier work: import what the agent wrote, then the next batch. */
175
221
  function runMinute(ctx, id, { final = false } = {}) { return chain(id, () => minuteNow(ctx, id, { final })); }
@@ -538,15 +584,16 @@ async function handle(req, res, url, ctx) {
538
584
  if (!dir) { ctx.json(res, { ok: false, error: 'the notes store is not available' }, 500); return true; }
539
585
  if (p === '/api/notes' || p === '/api/notes/one') {
540
586
  if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
541
- if (p === '/api/notes') { send(ctx, res, notes.list(dir)); return true; }
587
+ if (p === '/api/notes') { const l = notes.list(dir, { resumeMs: resumeMsOf(ctx) }); if (l.ok) sweepExpired(ctx, l.notes); send(ctx, res, l); return true; }
542
588
  const nid = url.searchParams.get('id');
543
589
  // the bullets the agent wrote since the last read; Jev labels and checks them after the note's earlier work
544
590
  const imp = notes.importBullets(dir, { id: nid });
545
591
  if (imp.added.length) enrich(ctx, nid, imp.added, 'bullets');
546
592
  notes.importWispr(dir, { id: nid });
547
- let r = notes.read(dir, nid);
593
+ let r = notes.read(dir, nid, { resumeMs: resumeMsOf(ctx) });
594
+ if (r.ok) { sweepExpired(ctx, [r.note]); r = notes.read(dir, nid, { resumeMs: resumeMsOf(ctx) }); }
548
595
  // the page is open: the agent that wrote the notes before Wispr Flow's arrived writes them again, merged
549
- if (r.ok && mergeWispr(ctx, nid)) r = notes.read(dir, nid);
596
+ if (r.ok && mergeWispr(ctx, nid)) r = notes.read(dir, nid, { resumeMs: resumeMsOf(ctx) });
550
597
  if (r.ok) {
551
598
  r.audio = { pending: transcriber ? transcriber.pending() : 0, error: audioErrors.get(nid) || null }; r.recording = notesAudio.info(dir, nid); if (r.recording) r.recording = { ...r.recording, cleaned: notesAudio.hasClean(dir, nid) };
552
599
  if (!Array.isArray(r.note.bullets)) r.note.bullets = [];
@@ -619,8 +666,12 @@ async function handle(req, res, url, ctx) {
619
666
  if (p === '/api/notes/record') {
620
667
  const o = wsOf();
621
668
  if (!o.ok) { ctx.json(res, { ok: false, error: 'This repository is not registered.' }, 404); return true; }
622
- const r = notes.record(dir, { id, ws: body.ws == null ? undefined : o.w.id });
669
+ const rm = resumeMsOf(ctx);
670
+ // a pause that ran out ends the meeting first (final notes and all), then the record is refused
671
+ endMeeting(ctx, id, 'timeout');
672
+ const r = notes.record(dir, { id, ws: body.ws == null ? undefined : o.w.id, resumeMs: rm });
623
673
  if (r.ok) {
674
+ clearExpiry(id); swept.delete(id);
624
675
  // a new Listen tries the agent again when it could not start last time
625
676
  const cur = notes.read(dir, id);
626
677
  if (cur.ok && cur.note.minutes && cur.note.minutes.agent === 'failed') notes.setMinutesAgent(dir, { id, agent: null, reason: '' });
@@ -633,9 +684,15 @@ async function handle(req, res, url, ctx) {
633
684
  return done(r);
634
685
  }
635
686
  if (p === '/api/notes/pause') {
636
- const r = notes.pause(dir, { id });
637
- // listening stopped: the minute timer stops, and what was said since the last minute is one more batch
638
- if (r.ok) { disarm(id); runMinute(ctx, id, { final: true }); scheduleWispr(ctx, id); }
687
+ const r = notes.pause(dir, { id, resumeMs: resumeMsOf(ctx) });
688
+ // listening stopped: the minute timer stops, and what was said since the last minute is one more batch.
689
+ // The meeting stays open: Wispr Flow and the final pass wait for Stop or for the hour to run out.
690
+ if (r.ok) { disarm(id); runMinute(ctx, id, { final: true }); if (r.state === 'paused') armExpiry(ctx, id, r.resume_until); }
691
+ return done(r);
692
+ }
693
+ if (p === '/api/notes/stop') {
694
+ const r = notes.stop(dir, { id, resumeMs: resumeMsOf(ctx) });
695
+ if (r.ok) { clearExpiry(id); swept.add(id); disarm(id); runMinute(ctx, id, { final: true }); scheduleWispr(ctx, id); }
639
696
  return done(r);
640
697
  }
641
698
  if (p === '/api/notes/transcript') {
@@ -751,7 +808,7 @@ async function handle(req, res, url, ctx) {
751
808
  if (p === '/api/notes/private') {
752
809
  vocabCache.delete(id);
753
810
  const r = notes.setPrivate(dir, { id, private: body.private });
754
- if (r.ok && r.private) { disarm(id); unwatchLate(id); cancelFinal(ctx, id); unwatchWispr(id); if (transcriber) transcriber.drop(id); notesAudio.remove(dir, id); }
811
+ if (r.ok && r.private) { clearExpiry(id); disarm(id); unwatchLate(id); cancelFinal(ctx, id); unwatchWispr(id); if (transcriber) transcriber.drop(id); notesAudio.remove(dir, id); }
755
812
  return done(r);
756
813
  }
757
814
  if (p === '/api/notes/summarize') {
@@ -780,7 +837,7 @@ async function handle(req, res, url, ctx) {
780
837
  }
781
838
  return done(r);
782
839
  }
783
- if (p === '/api/notes/delete') { disarm(id); unwatchLate(id); cancelFinal(ctx, id); unwatchWispr(id); if (transcriber) transcriber.drop(id); const r = notes.remove(dir, id); if (r.ok) notesAudio.remove(dir, id); return done(r); }
840
+ if (p === '/api/notes/delete') { clearExpiry(id); swept.delete(id); disarm(id); unwatchLate(id); cancelFinal(ctx, id); unwatchWispr(id); if (transcriber) transcriber.drop(id); const r = notes.remove(dir, id); if (r.ok) notesAudio.remove(dir, id); return done(r); }
784
841
  if (p === '/api/notes/highlight') {
785
842
  // the PM's highlight: the transcript of the window, then Jev's label and the repository check of its text
786
843
  const r = notes.addHighlight(dir, { id, from: body.from, to: body.to });
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.117.0",
3
+ "version": "0.118.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.117.0",
9
+ "version": "0.118.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.117.0",
3
+ "version": "0.118.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",