create-pmos 0.68.0 → 0.69.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.68.0",
3
+ "version": "0.69.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@0cc8c83
1
+ kernel-source: pmos@b38c47a
2
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-07T02:38:51Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T02:59:01Z"}
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-07T02:38:51Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T02:59:01Z"}
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-07T02:38:51Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T02:59:01Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -77,7 +77,7 @@ From the top:
77
77
  1. The horizon, the health reason when there is one, and the More button.
78
78
  2. Line 1: the stage and who the initiative waits on. Line 2: the stage steps, as a row of stages.
79
79
  3. One line of text: "Waiting on you: approval" with **Open gate**, or "Waiting on agent: build" with **Hand to agent**. When nothing is owed it says so.
80
- 4. Three tabs: **Breakdown**, **Files** and **History**. The Breakdown tab is the same Breakdown as in its own tab, and it shows here too.
80
+ 4. The tabs: **Breakdown**, **Files**, **History**, **Jira tickets**, **People** and **Notes**. The Breakdown tab is the same Breakdown as in its own tab, and it shows here too. **Notes** lists the meeting notes linked to the initiative, newest first, each with its summary and **Open**. See [Notes](notes.md).
81
81
 
82
82
  An initiative that is finished reads "Accepted". An initiative on hold, closed or cancelled reads that word instead of a stage. After 5 days with no activity, the line adds "idle 5 d".
83
83
 
@@ -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, correct and tag lines, see what changed in your notes, replay the meeting, 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, link a note to its meeting and its initiative, and get a summary."
5
5
  timestamp: 2026-10-06
6
6
  ---
7
7
 
@@ -86,6 +86,17 @@ When a meeting was heard with local transcription, its audio is kept on this mac
86
86
 
87
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
88
 
89
+ ## Link a note to its meeting and its initiative
90
+
91
+ Under the title, a note shows two lines: **Meeting** and **Initiative**.
92
+
93
+ - **Meeting** is the calendar event the note was taken in, or **Not on the calendar** for a hallway chat or a call. Press **Change** to select an event of the note's day or the day before, or **Not on the calendar**.
94
+ - **Initiative** is the initiative the meeting was about. It says how it was set: **from the calendar event**, **suggested from what was said**, or **chosen by you**. Press **Change** to select a suggestion, any open initiative of the selected repository, or **None**. Press the initiative's name to open it.
95
+
96
+ 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.
97
+
98
+ To take notes for a calendar event, open the event's detail on the Agenda and press **Take notes**. The note is linked to the event, and to the event's initiative when it has one. An event that already has a note shows its summary and **Open notes** instead. See [Today](today.md) and [the initiative sheet](initiatives-screen.md).
99
+
89
100
  ## Dictate into your notes
90
101
 
91
102
  1. Put the cursor where the text must go, then press **Dictate** and speak.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  type: Guide
3
3
  title: "Today: what needs you"
4
- description: "The Agenda month and the calendar it remembers, the two-column day (your jobs and the agent's jobs), the eight kinds of card on Today, the one button on each card, the count, the empty state, and the pmos today command."
4
+ description: "The Agenda month and the calendar it remembers, the meeting notes of a meeting, the two-column day (your jobs and the agent's jobs), the eight kinds of card on Today, the one button on each card, the count, the empty state, and the pmos today command."
5
5
  timestamp: 2026-10-06
6
6
  ---
7
7
 
@@ -50,6 +50,8 @@ Each day of the month answers three questions:
50
50
 
51
51
  The switch above the month shows one answer at a time: **Overview** (all three), **Full** (the time in meetings), **Key** (the meeting that matters), **Prep** (how ready you are) or **Free** (the free blocks of 90 minutes or more).
52
52
 
53
+ 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. When the meeting has a note, the detail shows its summary and **Open notes**, and the meeting's row shows **notes**. See [Notes](notes.md).
54
+
53
55
  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.
54
56
 
55
57
  To mark a meeting yourself, open it. Press **Mark as key** to make this time a key meeting, or **Key every time** for every time it happens. On a meeting that is key already, press **Not key** or **Never key**. Your mark is used before what the calendar says. Press **Remove my mark** to go back to what the calendar says. The marks are kept in `calendar-marks.json` in the state folder. **Before this meeting** lists the prep. **Last time** is folded: open it to read the notes and what stayed open.
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+ /* notes-links.js — which initiative a note belongs to, suggested locally (meeting-notes Run 4). Local only.
3
+ *
4
+ * initiativesOf(root) → [{ id, title, anchor, intent }] the open initiatives of one repository
5
+ * suggest(note, open) → { pick: { id, title, shared } | null, ranked: [{ id, title, shared }] }
6
+ *
7
+ * HOW IT SUGGESTS. It compares the words of the note (title, body, transcript lines, and the calendar event's title) with the
8
+ * words of each open initiative (id, title and the first paragraph of its record). Short and common words do not count. The
9
+ * initiative that shares the most distinct words is the suggestion when it shares 3 or more and no other initiative shares
10
+ * as many. Otherwise there is no suggestion, and the PM chooses. A suggestion never replaces the PM's own choice
11
+ * (lib/notes.js link refuses `by: 'local'` over `by: 'pm'`).
12
+ *
13
+ * WHAT IT NEVER DOES. It sends nothing and opens no network module. It reads the repository's planning/initiatives records
14
+ * through lib/live-notes.js loadInitiatives and writes nothing.
15
+ */
16
+ const liveNotes = require('./live-notes.js');
17
+
18
+ const MIN_SHARED = 3;
19
+ const MAX_RANKED = 50;
20
+ const STOP = new Set(('the and for are but not you all any can had her was one our out has his how its may new now old see two way who '
21
+ + 'did get got let put say she too use with this that from they will would there their what when which while about into than then them '
22
+ + 'these those have been were being also just like some more most much very only over such each other could should after before where '
23
+ + 'because does doing done make made need needs want wants yes okay meeting notes note today tomorrow yang dan untuk dengan ini itu '
24
+ + 'dari ada tidak akan bisa kita kami saya juga sudah belum').split(' '));
25
+
26
+ /** Distinct content words, lower case, stemmed only by a trailing s. */
27
+ function terms(text) {
28
+ const out = new Set();
29
+ for (const w of String(text || '').toLowerCase().match(/[\p{L}\p{N}]+/gu) || []) {
30
+ if (w.length < 3 || STOP.has(w) || /^\d+$/.test(w)) continue;
31
+ out.add(w.length > 4 && w.endsWith('s') && !w.endsWith('ss') ? w.slice(0, -1) : w);
32
+ }
33
+ return out;
34
+ }
35
+ function noteText(note) {
36
+ const n = note || {};
37
+ return [n.title, n.body, n.meeting && n.meeting.title, ...(n.transcript || []).map((s) => s && s.text)].join('\n');
38
+ }
39
+ function initiativesOf(root) {
40
+ try { return liveNotes.loadInitiatives(root).open.map((i) => ({ id: i.id, title: i.title, anchor: i.anchor, intent: i.intent })); } catch { return []; }
41
+ }
42
+ /** Pure. `open` is initiativesOf's list. */
43
+ function suggest(note, open) {
44
+ const mine = terms(noteText(note));
45
+ const ranked = (open || []).map((i) => {
46
+ const theirs = terms([String(i.id).replace(/[-_.]/g, ' '), i.title, i.intent].join(' '));
47
+ let shared = 0;
48
+ for (const t of theirs) if (mine.has(t)) shared += 1;
49
+ return { id: i.id, title: i.title, shared };
50
+ }).sort((a, b) => b.shared - a.shared || String(a.title).localeCompare(String(b.title)));
51
+ const top = ranked[0];
52
+ const pick = top && top.shared >= MIN_SHARED && !(ranked[1] && ranked[1].shared === top.shared) ? top : null;
53
+ return { pick, ranked: ranked.slice(0, MAX_RANKED) };
54
+ }
55
+
56
+ module.exports = { terms, noteText, initiativesOf, suggest, MIN_SHARED };
@@ -83,7 +83,84 @@ const wordsOf = (s) => (String(s || '').match(/[\p{L}\p{N}']+/gu) || []).length;
83
83
  /** The row a list shows. Never the body or the transcript. */
84
84
  function rowOf(n) {
85
85
  return { id: n.id, title: n.title || 'Untitled note', created_at: n.created_at, updated_at: n.updated_at, private: !!n.private,
86
- words: wordsOf(n.body), segments: (n.transcript || []).length, summary: !!(n.summary || (n.agent && n.agent.markdown)) };
86
+ words: wordsOf(n.body), segments: (n.transcript || []).length, summary: !!(n.summary || (n.agent && n.agent.markdown)),
87
+ meeting: n.meeting || null, initiative: n.initiative || null, ws: n.ws || null,
88
+ // a private note shows its title on the calendar and the initiative, never its summary
89
+ summary_lines: n.private ? [] : summaryLines(n) };
90
+ }
91
+ /** Up to three lines of the note's summary: the agent's, else the local overview. Plain text. */
92
+ function summaryLines(n) {
93
+ if (n.agent && n.agent.markdown) {
94
+ return String(n.agent.markdown).split(/\r?\n/).map((l) => l.replace(/^\s*(?:#+|[-*]|\d+[.)])\s*/, '').replace(/\*\*|__|`/g, '').trim())
95
+ .filter((l) => l && !/^summary$/i.test(l)).slice(0, 3).map((l) => l.slice(0, 200));
96
+ }
97
+ const ov = n.summary && n.summary.sections && n.summary.sections.overview;
98
+ return Array.isArray(ov) ? ov.slice(0, 3).map((x) => String((x && x.text) || '').slice(0, 200)).filter(Boolean) : [];
99
+ }
100
+
101
+ /* ── the meeting and the initiative a note belongs to (meeting-notes Run 4) ──────────────────────── */
102
+ const EVENT_ID_RE = /^[A-Za-z0-9_-]{1,40}$/;
103
+ const INI_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/;
104
+ const minuteOf = (s) => { const t = Date.parse(s); return Number.isFinite(t) ? Math.floor(t / 60000) : null; };
105
+ const normTitle = (t) => String(t || '').toLowerCase().replace(/\s+/g, ' ').trim();
106
+ /** 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
107
+ * calendar. null unlinks. Returns { ok, meeting } or an error. */
108
+ function cleanMeeting(m, now) {
109
+ if (m === null) return { ok: true, meeting: null };
110
+ if (!m || typeof m !== 'object') return err(400, 'say which meeting: { kind: "event", … } or { kind: "adhoc" }');
111
+ if (m.kind === 'adhoc') return { ok: true, meeting: { kind: 'adhoc', linked_at: iso(now) } };
112
+ if (m.kind !== 'event') return err(400, 'the meeting kind must be event or adhoc');
113
+ const title = cleanTitle(m.title);
114
+ if (!title) return err(400, 'a calendar event needs its title');
115
+ if (minuteOf(m.start) === null) return err(400, 'a calendar event needs its start time');
116
+ if (m.end != null && minuteOf(m.end) === null) return err(400, 'the end of the event is not a time');
117
+ if (m.event_id != null && !EVENT_ID_RE.test(String(m.event_id))) return err(400, 'that is not a calendar event id');
118
+ return { ok: true, meeting: { kind: 'event', event_id: m.event_id != null ? String(m.event_id) : null, src: m.src === 'memory' ? 'memory' : 'brief',
119
+ 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) } };
120
+ }
121
+ /** 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). */
122
+ function cleanInitiative(x, by) {
123
+ if (x === null) return { ok: true, initiative: null };
124
+ if (!x || typeof x !== 'object' || !INI_ID_RE.test(String(x.id || ''))) return err(400, 'that is not an initiative id');
125
+ return { ok: true, initiative: { id: String(x.id), ws: typeof x.ws === 'string' && x.ws.length <= 40 ? x.ws : null,
126
+ repo: typeof x.repo === 'string' && x.repo.length <= 120 ? x.repo : null, title: x.title ? cleanTitle(x.title) : null, by: ['pm', 'event', 'local'].includes(by) ? by : 'pm', ...(by === 'local' && Number.isFinite(x.shared) ? { shared: x.shared } : {}) } };
127
+ }
128
+ /** Whether a note's meeting is this calendar event: the same id, or the same start minute and title (the brief and the
129
+ * calendar memory give one meeting different ids). Pure. */
130
+ function sameEvent(meeting, e) {
131
+ if (!meeting || meeting.kind !== 'event' || !e) return false;
132
+ if (meeting.event_id && e.event_id && meeting.event_id === e.event_id) return true;
133
+ const a = minuteOf(meeting.start); const b = minuteOf(e.start);
134
+ return a !== null && a === b && normTitle(meeting.title) === normTitle(e.title);
135
+ }
136
+ /** The note already linked to this calendar event, or null. */
137
+ function findByEvent(dir, e) {
138
+ let names = [];
139
+ try { names = fs.readdirSync(notesDir(dir)).filter((f) => f.endsWith('.json')); } catch { return null; }
140
+ const hits = names.map((f) => load(dir, f.slice(0, -5))).filter((n) => n && sameEvent(n.meeting, e));
141
+ hits.sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)));
142
+ return hits[0] || null;
143
+ }
144
+ /** Link a note to its meeting and to its initiative. A field left out is kept. The PM's initiative is never replaced by a
145
+ * suggestion: `by: 'local'` is refused when the PM chose one. */
146
+ function link(dir, { id, meeting, initiative, by = 'pm', now = Date.now() } = {}) {
147
+ const note = load(dir, id);
148
+ if (!note) return err(404, 'no such note');
149
+ if (meeting !== undefined) {
150
+ const m = cleanMeeting(meeting, now);
151
+ if (!m.ok) return m;
152
+ note.meeting = m.meeting;
153
+ if (m.meeting && m.meeting.kind === 'event' && !String(note.title || '').trim()) note.title = m.meeting.title;
154
+ }
155
+ if (initiative !== undefined) {
156
+ if (by !== 'pm' && note.initiative && note.initiative.by === 'pm') return { ok: true, note, kept: true };
157
+ const c = cleanInitiative(initiative, by);
158
+ if (!c.ok) return c;
159
+ note.initiative = c.initiative;
160
+ }
161
+ note.updated_at = iso(now);
162
+ store(dir, note);
163
+ return { ok: true, note };
87
164
  }
88
165
  function list(dir) {
89
166
  let names = [];
@@ -93,10 +170,15 @@ function list(dir) {
93
170
  notes.sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)) || (a.id < b.id ? 1 : -1));
94
171
  return { ok: true, notes };
95
172
  }
96
- function create(dir, { title = '', now = Date.now() } = {}) {
173
+ function create(dir, { title = '', meeting, initiative, now = Date.now() } = {}) {
97
174
  const at = iso(now);
98
- const note = { v: 1, id: newId(now), title: cleanTitle(title), created_at: at, updated_at: at, body: '', private: false,
99
- recorded_from: null, transcript: [], summary: null, agent: null };
175
+ const m = meeting === undefined ? { ok: true, meeting: null } : cleanMeeting(meeting, now);
176
+ if (!m.ok) return m;
177
+ // a note taken from a calendar event inherits the event's initiative, marked as coming from the event
178
+ const c = initiative === undefined || initiative === null ? { ok: true, initiative: null } : cleanInitiative(initiative, m.meeting && m.meeting.kind === 'event' ? 'event' : 'pm');
179
+ if (!c.ok) return c;
180
+ const note = { v: 1, id: newId(now), title: cleanTitle(title) || (m.meeting && m.meeting.title) || '', created_at: at, updated_at: at, body: '', private: false,
181
+ recorded_from: null, transcript: [], summary: null, agent: null, meeting: m.meeting, initiative: c.initiative };
100
182
  store(dir, note);
101
183
  return { ok: true, note };
102
184
  }
@@ -600,5 +682,5 @@ function importAgent(dir, note) {
600
682
  return true;
601
683
  }
602
684
 
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,
685
+ module.exports = { list, read, create, link, findByEvent, sameEvent, cleanMeeting, summaryLines, save, remove, record, appendTranscript, updateSegment, setLabel, editLine, versionsOf, renameSpeaker, speakerOf, categoryOf, isFact, CATS, PM_CATS, VERSION_FOLD_MS, setPrivate, dictation, cleanSentences, spokenPunctuation,
604
686
  summarize, localSummary, agentHandoff, agentStarted, toMarkdown, promptFor, fmtTime, ownerOf, dueOf, ID_RE, notesDir, handoffOf, agentOutOf };
@@ -1,9 +1,12 @@
1
1
  'use strict';
2
- /* routes-notes.js — the Notes view's routes (meeting-notes, Runs 1 to 3; lib/route-modules.js).
2
+ /* routes-notes.js — the Notes view's routes (meeting-notes, Runs 1 to 4; lib/route-modules.js).
3
3
  *
4
4
  * GET /api/notes → { ok, notes: [row] } newest first; a row never carries the body
5
5
  * GET /api/notes/one?id=… → { ok, note } imports an agent summary written since the request
6
- * POST /api/notes/new { title? } → { ok, note }
6
+ * POST /api/notes/new { title?, meeting?, initiative? } → { ok, note, reused } a calendar event that already has a note
7
+ * gets that note back (reused: true), so one meeting keeps one note (Run 4)
8
+ * POST /api/notes/link { id, meeting?, initiative?, from_event? } → { ok, note, kept? } the PM's link; null unlinks (Run 4)
9
+ * GET /api/notes/initiatives?id=…&ws=… → { ok, open: [{ id, title }], pick } the repository's open initiatives, and the local suggestion
7
10
  * POST /api/notes/save { id, title?, body? } → { ok, updated_at }
8
11
  * POST /api/notes/dictate { text } → { ok, ops } one dictated phrase as insert operations (lib/notes.js dictation)
9
12
  * POST /api/notes/record { id, ws? } → { ok, recorded_from } `ws` is the repository the note's lines are checked against
@@ -19,7 +22,8 @@
19
22
  * { language?, model?, keep_audio? } → { ok, whisper } the language, the model and keeping audio, for this machine
20
23
  * POST /api/notes/whisper-setup { ws? } → { ok, started, session, command } a terminal with the setup typed in, not run
21
24
  * POST /api/notes/private { id, private } → { ok, private }
22
- * POST /api/notes/summarize { id } → { ok, summary } the local summary
25
+ * POST /api/notes/summarize { id, ws? } → { ok, summary, initiative } the local summary; with ws and no initiative yet,
26
+ * the local suggestion is linked (by: 'local')
23
27
  * POST /api/notes/summarize-agent { id, ws? } → { ok, started, session, handoff, out, withheld, reason }
24
28
  * POST /api/notes/delete { id } → { ok }
25
29
  *
@@ -38,6 +42,7 @@ const notes = require('./notes.js');
38
42
  const whisper = require('./notes-whisper.js');
39
43
  const notesRepo = require('./notes-repo.js');
40
44
  const notesAudio = require('./notes-audio.js');
45
+ const notesLinks = require('./notes-links.js');
41
46
  const jevNotes = require('./jev-notes.js');
42
47
  const tsSettings = require('./typesafe-settings.js');
43
48
 
@@ -47,7 +52,7 @@ const CHECKED = new Set(['decision', 'action', 'statement']);
47
52
  const prefixes = ['/api/notes'];
48
53
  const POSTS = new Set(['/api/notes/new', '/api/notes/save', '/api/notes/dictate', '/api/notes/record', '/api/notes/transcript',
49
54
  '/api/notes/private', '/api/notes/summarize', '/api/notes/summarize-agent', '/api/notes/delete', '/api/notes/audio', '/api/notes/label',
50
- '/api/notes/speaker', '/api/notes/switch', '/api/notes/whisper-setup', '/api/notes/line', '/api/notes/recording-delete']);
55
+ '/api/notes/speaker', '/api/notes/switch', '/api/notes/whisper-setup', '/api/notes/line', '/api/notes/recording-delete', '/api/notes/link']);
51
56
 
52
57
  /* ── labels and checks, one note at a time ───────────────────────────────────────────────────── */
53
58
  const chains = new Map(); // note id → the promise of its enrichment so far
@@ -144,6 +149,19 @@ async function handle(req, res, url, ctx) {
144
149
  notesAudio.serve(req, res, dir, nid);
145
150
  return true;
146
151
  }
152
+ if (p === '/api/notes/initiatives') {
153
+ if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
154
+ const wsId = url.searchParams.get('ws');
155
+ const w = wsId ? ctx.getWs(wsId) : null;
156
+ if (!w || w.id !== wsId) { ctx.json(res, { ok: false, error: 'This repository is not registered.' }, 404); return true; }
157
+ const open = notesLinks.initiativesOf(w.root);
158
+ const nid = url.searchParams.get('id');
159
+ const r = nid ? notes.read(dir, nid) : null;
160
+ if (r && !r.ok) { send(ctx, res, r); return true; }
161
+ const s = r ? notesLinks.suggest(r.note, open) : { pick: null, ranked: [] };
162
+ ctx.json(res, { ok: true, open: open.map((i) => ({ id: i.id, title: i.title })), pick: s.pick });
163
+ return true;
164
+ }
147
165
  if (p === '/api/notes/engine') {
148
166
  if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
149
167
  const wsId = url.searchParams.get('ws');
@@ -160,7 +178,19 @@ async function handle(req, res, url, ctx) {
160
178
  if (!g.ok) { ctx.json(res, { ok: false, error: g.error }, g.status); return true; }
161
179
  const id = body && typeof body.id === 'string' ? body.id : '';
162
180
  const done = (r) => { send(ctx, res, r); return true; };
163
- if (p === '/api/notes/new') return done(notes.create(dir, { title: body.title }));
181
+ if (p === '/api/notes/new') {
182
+ // one calendar event keeps one note: Take notes on an event that already has one opens it
183
+ const m = body.meeting;
184
+ if (m && m.kind === 'event') {
185
+ const hit = notes.findByEvent(dir, m);
186
+ if (hit) return done({ ok: true, note: hit, reused: true });
187
+ }
188
+ const r = notes.create(dir, { title: body.title, meeting: m, initiative: body.initiative });
189
+ if (r.ok) r.reused = false;
190
+ return done(r);
191
+ }
192
+ // from_event: the initiative is the calendar event's own, and never replaces the PM's choice
193
+ if (p === '/api/notes/link') return done(notes.link(dir, { id, meeting: body.meeting, initiative: body.initiative, by: body.from_event === true ? 'event' : 'pm' }));
164
194
  if (p === '/api/notes/dictate') return done(notes.dictation(body.text));
165
195
  if (p === '/api/notes/save') return done(notes.save(dir, { id, title: body.title, body: body.body }));
166
196
  const wsOf = () => {
@@ -238,7 +268,18 @@ async function handle(req, res, url, ctx) {
238
268
  if (r.ok && r.private) { if (transcriber) transcriber.drop(id); notesAudio.remove(dir, id); }
239
269
  return done(r);
240
270
  }
241
- if (p === '/api/notes/summarize') return done(notes.localSummary(dir, { id }));
271
+ if (p === '/api/notes/summarize') {
272
+ const r = notes.localSummary(dir, { id });
273
+ if (!r.ok) return done(r);
274
+ const ws = wsOf();
275
+ const cur = notes.read(dir, id);
276
+ if (ws.ok && ws.w && cur.ok && !cur.note.initiative) {
277
+ const s = notesLinks.suggest(cur.note, notesLinks.initiativesOf(ws.w.root));
278
+ if (s.pick) { const l = notes.link(dir, { id, initiative: { id: s.pick.id, ws: ws.w.id, title: s.pick.title, shared: s.pick.shared }, by: 'local' }); if (l.ok) r.initiative = l.note.initiative; }
279
+ }
280
+ if (r.initiative === undefined) r.initiative = cur.ok ? cur.note.initiative || null : null;
281
+ return done(r);
282
+ }
242
283
  if (p === '/api/notes/delete') { if (transcriber) transcriber.drop(id); const r = notes.remove(dir, id); if (r.ok) notesAudio.remove(dir, id); return done(r); }
243
284
  // summarize-agent: the handoff file first (refused for a private note), then the PM's own agent session
244
285
  let w = null;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.68.0",
3
+ "version": "0.69.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.68.0",
3
+ "version": "0.69.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",
@@ -5940,6 +5940,93 @@
5940
5940
  }
5941
5941
  }
5942
5942
 
5943
+ /* meeting-notes Run 4: the note's meeting and initiative, and its notes on the calendar and the initiative */
5944
+ .nt-headwrap {
5945
+ flex: 1 1 280px;
5946
+ min-width: 0;
5947
+ display: flex;
5948
+ flex-direction: column;
5949
+ gap: 8px;
5950
+ }
5951
+ .nt-links {
5952
+ display: flex;
5953
+ flex-wrap: wrap;
5954
+ gap: 4px 24px;
5955
+ font-size: 14px;
5956
+ }
5957
+ .nt-link {
5958
+ display: flex;
5959
+ flex-wrap: wrap;
5960
+ align-items: center;
5961
+ gap: 4px 8px;
5962
+ min-width: 0;
5963
+ }
5964
+ .nt-link__k {
5965
+ font-size: 12px;
5966
+ color: var(--muted-foreground);
5967
+ }
5968
+ .nt-picks,
5969
+ .nt-picks [role="radiogroup"] {
5970
+ display: flex;
5971
+ flex-direction: column;
5972
+ gap: 8px;
5973
+ }
5974
+ .nt-pick {
5975
+ display: flex;
5976
+ flex-direction: column;
5977
+ align-items: flex-start;
5978
+ gap: 2px;
5979
+ padding: 8px 12px;
5980
+ border: 1px solid var(--border);
5981
+ border-radius: 0;
5982
+ background: var(--background);
5983
+ color: var(--foreground);
5984
+ font: inherit;
5985
+ text-align: left;
5986
+ cursor: pointer;
5987
+ }
5988
+ .nt-pick {
5989
+ height: auto;
5990
+ justify-content: flex-start;
5991
+ }
5992
+ .nt-pick__txt {
5993
+ display: flex;
5994
+ flex-direction: column;
5995
+ align-items: flex-start;
5996
+ gap: 2px;
5997
+ white-space: normal;
5998
+ }
5999
+ .nt-pick[aria-checked="true"] {
6000
+ border-color: var(--foreground);
6001
+ background: var(--accent);
6002
+ }
6003
+ .nt-pick:focus-visible {
6004
+ outline: 2px solid color-mix(in oklab, var(--ring) 50%, var(--foreground));
6005
+ outline-offset: 2px;
6006
+ }
6007
+ .nt-lsum {
6008
+ margin: 4px 0;
6009
+ padding-left: 18px;
6010
+ }
6011
+ .nt-lrows {
6012
+ display: flex;
6013
+ flex-direction: column;
6014
+ }
6015
+ .nt-lrow {
6016
+ display: flex;
6017
+ align-items: flex-start;
6018
+ justify-content: space-between;
6019
+ gap: 12px;
6020
+ padding: 12px 0;
6021
+ border-bottom: 1px solid var(--border);
6022
+ }
6023
+ .nt-lrow__main {
6024
+ min-width: 0;
6025
+ }
6026
+ .nt-lrow__meta {
6027
+ font-size: 12px;
6028
+ color: var(--muted-foreground);
6029
+ }
5943
6030
  /* -- layouts · .lg- the legacy and fixture shells (Run 3, slice 3D) -- */
5944
6031
  /* The legacy and fixture cockpit's two columns: the list head (a stack) and the rail's tile column. */
5945
6032
  .lg-stack {
@@ -7,6 +7,7 @@
7
7
  * the graph is doc-graph.js, initiative docs-library-graph), History,
8
8
  * Jira tickets (sheet-jira.js, initiative jira-wbs: the tickets drafted from the PRD and their status in Jira),
9
9
  * People (people-link.js, initiative people-directory: who owns its items or is named in interactions about it)
10
+ * Notes (notes-links.js, initiative meeting-notes Run 4: the meeting notes linked to it, each with its summary)
10
11
  * ⋯ horizon, check-by, lifecycle and the anchor (both PM-only: the server's guard answers), Copy CLI, Export breakdown
11
12
  * Nothing on this sheet says "plan". Everything it shows is read from GET /api/initiatives/<id>; every write goes through
12
13
  * initiatives-api.js. The Breakdown tab mounts with a tab id of the form `initiative:<slug>`, so it never meets a panel-4
@@ -22,6 +23,7 @@ import { icon, ensureSprite } from './icons.js';
22
23
  import { mountBreakdown, unmountBreakdown } from './panel-breakdown.js';
23
24
  import { mountJira, unmountJira } from './sheet-jira.js';
24
25
  import { initiativePeople } from './people-link.js';
26
+ import { notesOfInitiative, noteRows, onNotes, refreshNotes } from './notes-links.js';
25
27
  import { openDoc } from './panel-initiatives.js';
26
28
  import { getLibrary, initiativeGraph } from './doc-graph.js';
27
29
  import { openNewSession } from './panel-newsess.js';
@@ -83,7 +85,7 @@ export function openSheet(repo, slug) {
83
85
  const s = sheet({
84
86
  mode: 'auto', title: slug, sub: '', body: [crumb, stepperHost, waitingHost, tabsHost, panel],
85
87
  attrs: { class: 'in-sheet', 'data-in-sheet': 'initiative', 'data-in-id': slug },
86
- onClose: () => { closed = true; unmountBreakdown(bdHost); unmountJira(panel); if (current === s) current = null; },
88
+ onClose: () => { closed = true; unmountBreakdown(bdHost); unmountJira(panel); offNotes(); if (current === s) current = null; },
87
89
  });
88
90
  current = s;
89
91
 
@@ -244,10 +246,14 @@ export function openSheet(repo, slug) {
244
246
  );
245
247
  }
246
248
 
249
+ const notesHere = () => notesOfInitiative(slug).filter((n) => !n.initiative.ws || !ws || n.initiative.ws === ws);
250
+ const offNotes = onNotes(() => { if (!closed && tab === 'notes') drawPanel(); });
251
+ refreshNotes();
252
+
247
253
  function drawTabs() {
248
254
  tabsHost.replaceChildren(tabs({
249
255
  variant: 'segmented', full: true, ariaLabel: COPY.sheet.tabs, value: tab,
250
- items: [{ id: 'breakdown', label: COPY.sheet.breakdown }, { id: 'files', label: COPY.sheet.files }, { id: 'history', label: COPY.sheet.history }, { id: 'jira', label: COPY.sheet.jira }, { id: 'people', label: COPY.sheet.people }],
256
+ items: [{ id: 'breakdown', label: COPY.sheet.breakdown }, { id: 'files', label: COPY.sheet.files }, { id: 'history', label: COPY.sheet.history }, { id: 'jira', label: COPY.sheet.jira }, { id: 'people', label: COPY.sheet.people }, { id: 'notes', label: COPY.sheet.notes }],
251
257
  onChange: (id) => {
252
258
  if (id === tab) return;
253
259
  if (tab === 'breakdown') unmountBreakdown(bdHost);
@@ -270,6 +276,8 @@ export function openSheet(repo, slug) {
270
276
  }
271
277
  if (tab === 'jira') { mountJira(panel, { ws, slug }); return; }
272
278
  if (tab === 'people') { panel.replaceChildren(initiativePeople(ws, slug, { onOpen: () => s.close() })); return; }
279
+ // meeting-notes Run 4: the meeting notes linked to this initiative, newest first, each with its summary
280
+ if (tab === 'notes') { panel.replaceChildren(el('div', { 'data-in-notes': '' }, noteRows(notesHere(), { onOpen: () => s.close() }))); return; }
273
281
  if (tab === 'files') {
274
282
  const files = data.files || [];
275
283
  // Grouped by where the tie came from (scan-consistency R3, scan-documents S3): the record's own paths, the documents
@@ -135,6 +135,7 @@ export const COPY = {
135
135
  history: 'History',
136
136
  jira: 'Jira tickets',
137
137
  people: 'People',
138
+ notes: 'Notes',
138
139
  waitingYou: (act) => `Waiting on you: ${act}`,
139
140
  waitingAgent: (act) => `Waiting on agent: ${act}`,
140
141
  nothingOwed: 'Nothing is owed.',
@@ -0,0 +1,110 @@
1
+ /* pm/notes-links.js — a meeting note where its meeting and its initiative are shown (meeting-notes Run 4).
2
+ *
3
+ * notesFor(e) → the note rows linked to a calendar event (same id, or same start minute and title)
4
+ * notesOfInitiative(id) → the note rows linked to an initiative, newest first
5
+ * notesBlock(e, { ws, repo, initiative }) → the "Meeting notes" block of an event's detail: the summary and Open notes,
6
+ * or Take notes; Take notes on an event that already has a note opens that note
7
+ * noteRows(rows, { onOpen }) → the rows of an initiative's Notes tab; onOpen runs before the note opens
8
+ * openNote(id) → opens the Notes tab on that note
9
+ * onNotes(fn) · refreshNotes() → redraw when the list is read
10
+ *
11
+ * One read feeds it: GET /api/notes (a row carries the meeting, the initiative and up to three summary lines, never the body;
12
+ * a private note carries its title only). Take notes writes POST /api/notes/new { meeting, initiative }.
13
+ */
14
+ import { el } from './dom.js';
15
+ import { button } from './primitives.js';
16
+ import { pmGet, pmPost } from './http.js';
17
+
18
+ const READ_MS = 15000; // the list is read again at most this often while a calendar or an initiative is drawn
19
+ let rows = null; let readAt = 0; let reading = null; let failed = false;
20
+ const subs = new Set();
21
+
22
+ export function onNotes(fn) { subs.add(fn); return () => subs.delete(fn); }
23
+ export function refreshNotes() {
24
+ if (reading) return reading;
25
+ reading = pmGet('/api/notes').then((r) => {
26
+ reading = null; readAt = Date.now();
27
+ if (r && r.ok) { const sig = JSON.stringify(r.notes); const was = JSON.stringify(rows); rows = r.notes; failed = false; if (sig !== was) for (const f of subs) f(); }
28
+ else failed = true;
29
+ });
30
+ return reading;
31
+ }
32
+ function list() {
33
+ if (!reading && Date.now() - readAt > READ_MS) refreshNotes();
34
+ return rows || [];
35
+ }
36
+
37
+ const minuteOf = (s) => { const t = Date.parse(s); return Number.isFinite(t) ? Math.floor(t / 60000) : null; };
38
+ const normTitle = (t) => String(t || '').toLowerCase().replace(/\s+/g, ' ').trim();
39
+ /** Whether a note's meeting is this event. The same rule as lib/notes.js sameEvent. Pure, for the suite. */
40
+ export function sameEvent(meeting, e) {
41
+ if (!meeting || meeting.kind !== 'event' || !e) return false;
42
+ if (meeting.event_id && e.id && meeting.event_id === e.id) return true;
43
+ const a = minuteOf(meeting.start); const b = minuteOf(e.start);
44
+ return a !== null && a === b && normTitle(meeting.title) === normTitle(e.title);
45
+ }
46
+ export const notesFor = (e) => list().filter((n) => sameEvent(n.meeting, e));
47
+ export const notesOfInitiative = (id) => list().filter((n) => n.initiative && n.initiative.id === id);
48
+ export const notesKnown = () => rows !== null || failed;
49
+
50
+ /** The meeting link a calendar event gives a note. */
51
+ export function meetingOf(e) {
52
+ return { kind: 'event', event_id: /^[A-Za-z0-9_-]{1,40}$/.test(String(e.id || '')) ? String(e.id) : null, src: e.src === 'memory' ? 'memory' : 'brief',
53
+ start: e.start, end: e.end || null, title: e.title };
54
+ }
55
+
56
+ export async function openNote(id) {
57
+ const [v, c] = await Promise.all([import('./view-notes.js'), import('./panel-cockpit.js')]);
58
+ v.openNote(id);
59
+ c.openView('notes');
60
+ }
61
+ async function takeNotes(e, { ws, repo, initiative } = {}, btn) {
62
+ if (btn) btn.disabled = true;
63
+ const ini = initiative && initiative.id ? { id: initiative.id, ws: ws || null, repo: repo || null, title: initiative.title || null } : undefined;
64
+ const r = await pmPost('/api/notes/new', { meeting: meetingOf(e), ...(ini ? { initiative: ini } : {}) });
65
+ if (btn) btn.disabled = false;
66
+ if (!r || !r.ok) { const { toast } = await import('./primitives.js'); toast({ text: (r && r.error) || 'The note was not made.', tone: 'destructive' }); return; }
67
+ refreshNotes();
68
+ openNote(r.note.id);
69
+ }
70
+
71
+ const dayOf = (iso) => { try { return new Date(iso).toLocaleDateString([], { weekday: 'short', day: 'numeric', month: 'short' }); } catch { return ''; } };
72
+ const clock = (iso) => { try { return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } catch { return ''; } };
73
+
74
+ /** The Meeting notes block of an event's detail. */
75
+ export function notesBlock(e, opts = {}) {
76
+ const mine = notesFor(e);
77
+ const ended = !!e.ended;
78
+ if (mine.length) {
79
+ const n = mine[0];
80
+ const lines = n.summary_lines || [];
81
+ return el('div', { class: 'td-day__notes', 'data-notes-block': 'linked', 'data-notes-id': n.id },
82
+ el('h4', null, 'Meeting notes'),
83
+ n.private ? el('p', { class: 'td-day__note' }, 'This note is private.')
84
+ : lines.length ? el('ul', { class: 'nt-lsum' }, ...lines.map((l) => el('li', null, l)))
85
+ : el('p', { class: 'td-day__note' }, n.segments ? 'No summary yet. Open the note and press Summarize.' : 'No summary yet.'),
86
+ el('div', { class: 'td-day__acts' }, button({ label: 'Open notes', size: 'sm', attrs: { 'data-notes-open': n.id }, onClick: () => openNote(n.id) })));
87
+ }
88
+ if (!notesKnown()) return null;
89
+ const take = button({ label: 'Take notes', variant: ended ? undefined : 'primary', size: 'sm', attrs: { 'data-notes-take': e.id || '' }, onClick: () => takeNotes(e, opts, take) });
90
+ return el('div', { class: 'td-day__notes', 'data-notes-block': 'none' },
91
+ el('h4', null, 'Meeting notes'),
92
+ el('p', { class: 'td-day__note' }, ended ? 'No notes were taken in PMOS for this meeting.' : 'Take notes here when the meeting starts. The note is linked to this event' + (opts.initiative ? ' and its initiative.' : '.')),
93
+ el('div', { class: 'td-day__acts' }, take));
94
+ }
95
+
96
+ /** The rows of an initiative's Notes tab. */
97
+ export function noteRows(found, { onOpen } = {}) {
98
+ if (!found.length) return el('p', { class: 'td-day__note', 'data-notes-empty': '' }, 'No meeting notes are linked to this initiative yet. Link one from the note\'s Initiative line.');
99
+ const sorted = found.slice().sort((a, b) => String((b.meeting && b.meeting.start) || b.created_at).localeCompare(String((a.meeting && a.meeting.start) || a.created_at)));
100
+ return el('div', { class: 'nt-lrows' }, ...sorted.map((n) => {
101
+ const at = n.meeting && n.meeting.kind === 'event' ? n.meeting.start : n.created_at;
102
+ const where = n.meeting && n.meeting.kind === 'event' ? 'calendar' : 'not on the calendar';
103
+ const lines = n.summary_lines || [];
104
+ return el('div', { class: 'nt-lrow', 'data-notes-row': n.id },
105
+ el('div', { class: 'nt-lrow__main' }, el('b', null, n.title || 'Untitled note'),
106
+ el('div', { class: 'nt-lrow__meta' }, dayOf(at) + ', ' + clock(at) + ' · ' + where + (n.private ? ' · private' : '')),
107
+ lines.length ? el('ul', { class: 'nt-lsum' }, ...lines.map((l) => el('li', null, l))) : el('p', { class: 'td-day__note' }, 'No summary yet.')),
108
+ button({ label: 'Open', size: 'sm', attrs: { 'data-notes-open': n.id }, onClick: () => { if (onOpen) onOpen(); openNote(n.id); } }));
109
+ }));
110
+ }
@@ -12,6 +12,8 @@
12
12
  * picks the week. The lens switch says which answer the cells show; the agenda under the month always shows them all.
13
13
  * The calendar memory changes only through the morning-brief session's brief. The one write here is the PM's key-meeting
14
14
  * mark (POST /api/proactive/agenda/mark): this time or every time, key or not key, over what the calendar says.
15
+ * A meeting's detail shows its meeting notes, and an event row a notes chip, from pm/notes-links.js (meeting-notes Run 4);
16
+ * Take notes writes there.
15
17
  */
16
18
 
17
19
  import { peopleLinks } from './people-link.js';
@@ -19,6 +21,7 @@ import { el } from './dom.js';
19
21
  import { button, iconButton, badge, tabs, toast } from './primitives.js';
20
22
  import { pmGet, pmPost } from './http.js';
21
23
  import { COPY } from './today-copy.js';
24
+ import { notesBlock, notesFor } from './notes-links.js';
22
25
 
23
26
  const A = COPY.day.ag;
24
27
  const DAY0 = 9 * 60;
@@ -203,7 +206,8 @@ function eventRow(h, e) {
203
206
  const chips = el('span', { class: 'td-day__chips' },
204
207
  e.imp ? badge({ text: A.star, tone: 'outline', attrs: { title: A.means } }) : null,
205
208
  e.prep ? el('span', { class: 'td-ag__ready', 'data-ag-ready': e.ready || 'ok' }, A.readyBadge(e.prep_done || 0, e.prep.length)) : null,
206
- e.ended && !e.notes && (e.imp || e.kind === 'one') ? el('span', { class: 'td-ag__nonotes' }, A.noNotes(1)) : null);
209
+ notesFor(e).length ? badge({ text: 'notes', tone: 'outline', attrs: { 'data-ag-notes': e.id, title: 'Meeting notes are linked to this event' } })
210
+ : e.ended && !e.notes && (e.imp || e.kind === 'one') ? el('span', { class: 'td-ag__nonotes' }, A.noNotes(1)) : null);
207
211
  return h.rowBtn(type, e.id, { lead: e.time, title: e.title, meta: A.kindWord[e.kind] + (e.attendees ? ' · ' + A.people(e.attendees) : ''), trail: chips },
208
212
  { 'data-past': e.ended ? 'true' : null, 'data-ag-event': e.id });
209
213
  }
@@ -327,5 +331,6 @@ export function meetingDetail(ctx, id, h) {
327
331
  e.ended ? el('div', { class: 'td-day__phase', 'data-ag': 'notes' }, el('h4', null, A.afterNotes),
328
332
  e.notes ? el('p', null, e.notes) : el('p', { class: 'td-day__note' }, A.noNotesYet),
329
333
  e.open && e.open.length ? [el('h4', null, A.openLines), el('ul', { class: 'td-day__chk' }, ...e.open.map((x) => el('li', null, x)))] : null) : null,
334
+ notesBlock(e, { ws: ctx && ctx.ws }),
330
335
  ];
331
336
  }
@@ -10,6 +10,8 @@
10
10
  * - on the left, the calendar (with a line at the current time), the jobs (needs you, agent working, done), the goals
11
11
  * and the mentions with the watched channels, projects and spaces;
12
12
  * - on the right, the detail of what is selected: a meeting, a job, a goal, a mention, a step, the sources or a place.
13
+ * A meeting's detail ends with its meeting notes: the summary and Open notes, or Take notes (pm/notes-links.js,
14
+ * meeting-notes Run 4).
13
15
  * The presses go to the routes that own them: the goals route (add, done, drop, reopen, remove), /goals/plan (Prepare my
14
16
  * day), /day/decide (approve, revise, run again), /day/check (Check now) and the settings route (sources, levels, the
15
17
  * daytime check, websites). Nothing here sends anything outward: the agent sessions do that, after the PM approves.
@@ -30,6 +32,7 @@ import { COPY } from './today-copy.js';
30
32
  import { goalRows, goalAdd, goalDetail, addGoal, goalsSig } from './today-goals.js';
31
33
  import { peopleLinks } from './people-link.js';
32
34
  import { agendaPanel, meetingDetail, memoryBlock, refreshAgenda, agendaSig, onAgenda } from './today-agenda.js';
35
+ import { notesBlock, onNotes } from './notes-links.js';
33
36
 
34
37
  const D = COPY.day;
35
38
  const SLOW_MS = 30000;
@@ -57,6 +60,7 @@ const ui = {
57
60
  revising: new Set(), notes: new Map(), siteDraft: '', placeDraft: {}, busy: new Set(), scroll: false,
58
61
  };
59
62
  let rerender = () => {};
63
+ let notesSub = null; // the redraw when the meeting notes list is read (pm/notes-links.js)
60
64
  let timer = null;
61
65
 
62
66
  /* ── the read ─────────────────────────────────────────────────────────────────────────────────────────────────── */
@@ -566,6 +570,7 @@ function eventDetail(ctx, e) {
566
570
  memoryBlock(e.id),
567
571
  !e.ended ? el('div', { class: 'td-day__acts' }, e.prep ? pill(D.needPrep, 'warn') : steps.before && steps.before.length ? pill(D.nothingToPrep, 'ok') : null) : null,
568
572
  after,
573
+ notesBlock(e, { ws: ctx.ws, initiative: ini }),
569
574
  !e.ended ? [list(D.phases.before, (steps.before || []).map(stepItem)), list(D.phases.during, (steps.during || []).map(stepItem)), list(D.phases.after, (steps.after || []).map(stepItem)), mailBlock] : null,
570
575
  Array.isArray(e.links) && e.links.length ? el('div', { class: 'td-day__acts', 'data-day': 'links' }, ...e.links.map((l) => extLink((l.label || l.url) + ' ↗', l.url))) : null,
571
576
  ];
@@ -856,6 +861,7 @@ export function daySection({ ws, setState, openIni, owed, here, all } = {}) {
856
861
  // cause a whole-page render under whatever the PM is doing elsewhere
857
862
  if (setState) rerender = () => { if (document.querySelector('[data-today="day"]')) setState({}); };
858
863
  onAgenda(() => rerender());
864
+ if (!notesSub) notesSub = onNotes(() => rerender());
859
865
  ensure(ws);
860
866
  const c = cache.get(ws) || {};
861
867
  const m = model(c.data, { here: here || null, all: !!all });
@@ -1,4 +1,4 @@
1
- /* pm/view-notes.js — the Notes view tab: one meeting on one screen (meeting-notes, Runs 1 to 3).
1
+ /* pm/view-notes.js — the Notes view tab: one meeting on one screen (meeting-notes, Runs 1 to 4).
2
2
  *
3
3
  * The screen follows the simplified mockup the PM reviewed (prototype/meeting-notes):
4
4
  * header the title and the date, and one action: Listen; while listening a red dot, the time, the sources and Stop;
@@ -24,10 +24,15 @@
24
24
  * the transcript line, its item and the note line written at that moment are highlighted, and a time in the transcript
25
25
  * jumps there. Your notes are shown read-only while the meeting plays, so their lines can be highlighted.
26
26
  *
27
+ * MEETING AND INITIATIVE (Run 4). Under the title, the note says which meeting it is (a calendar event, or not on the
28
+ * calendar) and which initiative it belongs to, and how that was set: from the calendar event, suggested from what was said,
29
+ * or chosen by the PM. Change opens a sheet for each. A note taken from an event's detail (pm/notes-links.js) opens here.
30
+ *
27
31
  * Reads and writes (lib/routes-notes.js). Notes live in the per-user runtime directory, never in a repository:
28
32
  * GET /api/notes · /api/notes/one?id=… · /api/notes/engine?ws=… · /api/notes/recording?id=… · /api/people?ws=…
33
+ * · /api/notes/initiatives?ws=…&id=… · /api/proactive/agenda?month=…
29
34
  * POST /api/notes/new · save · dictate · record · transcript · audio · label · speaker · switch · whisper-setup · private
30
- * · summarize · summarize-agent · delete · line · recording-delete
35
+ * · summarize · summarize-agent · delete · line · recording-delete · link
31
36
  */
32
37
  import { el } from './dom.js';
33
38
  import { askConfirm, askText } from './dialogs.js';
@@ -58,6 +63,7 @@ let pollTimer = null; let agentTimer = null;
58
63
  let busy = '';
59
64
  let player = null; // the replay: { id, bytes, el, playing }, kept across redraws so playback goes on
60
65
  let people = null; // { ws, names } — the repository's people, offered in the line editor
66
+ let pendingOpen = null; // a note another page asked to open (openNote), selected at the next draw
61
67
  const LABELS = [['decision', 'Decision'], ['action', 'Action'], ['question', 'Question'], ['fact', 'Fact'], ['topic', 'Topic'], ['statement', 'Statement'], ['small talk', 'Small talk']];
62
68
 
63
69
  const nowClock = (iso) => { try { return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } catch { return ''; } };
@@ -199,7 +205,7 @@ async function loadList(ctx) {
199
205
  if (!currentId && list.notes.length) currentId = list.notes[0].id;
200
206
  ctx.setState({});
201
207
  }
202
- const sig = (n) => JSON.stringify([n.transcript, n.summary && n.summary.at, n.agent && n.agent.markdown, n.private, n.speakers, n.title, (n.versions || []).length, n.__recording && n.__recording.bytes]);
208
+ const sig = (n) => JSON.stringify([n.transcript, n.summary && n.summary.at, n.agent && n.agent.markdown, n.private, n.speakers, n.title, (n.versions || []).length, n.__recording && n.__recording.bytes, n.meeting, n.initiative]);
203
209
  async function loadNote(ctx, id) {
204
210
  const r = await pmGet('/api/notes/one?id=' + encodeURIComponent(id));
205
211
  if (r && r.ok) {
@@ -312,9 +318,10 @@ async function setPrivate(ctx, n, priv) {
312
318
  async function summarize(ctx, n, quiet = false) {
313
319
  await flushSave(ctx);
314
320
  busy = 'summary'; ctx.setState({});
315
- const r = await pmPost('/api/notes/summarize', { id: n.id });
321
+ const ws = n.ws || wsOf(ctx);
322
+ const r = await pmPost('/api/notes/summarize', { id: n.id, ...(ws ? { ws } : {}) });
316
323
  busy = '';
317
- if (r && r.ok) { n.summary = r.summary; banner = null; } else if (!quiet) banner = { tone: 'bad', text: (r && r.error) || 'no summary was made' };
324
+ if (r && r.ok) { n.summary = r.summary; if (r.initiative !== undefined) { n.initiative = r.initiative; const c = cache.get(n.id); if (c) c.initiative = r.initiative; } banner = null; } else if (!quiet) banner = { tone: 'bad', text: (r && r.error) || 'no summary was made' };
318
325
  ctx.setState({});
319
326
  }
320
327
  async function summarizeAgent(ctx, n) {
@@ -762,6 +769,105 @@ function editorFor(ctx, n) {
762
769
  title.addEventListener('blur', () => flushSave(ctx));
763
770
  return editor;
764
771
  }
772
+ /* ── the meeting and the initiative of a note (Run 4) ───────────────────────────────────────────── */
773
+ const BY_WORDS = { event: 'from the calendar event', local: 'suggested from what was said', pm: 'chosen by you' };
774
+ const ymOfIso = (iso) => { const d = new Date(iso); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); };
775
+ const dateOfIso = (iso) => { const d = new Date(iso); return ymOfIso(iso) + '-' + String(d.getDate()).padStart(2, '0'); };
776
+ /** Open the Notes tab on this note at its next draw (pm/notes-links.js openNote). */
777
+ export function openNote(id) { pendingOpen = id; }
778
+ async function link(ctx, n, patch, said) {
779
+ const r = await pmPost('/api/notes/link', { id: n.id, ...patch });
780
+ if (r && r.ok) {
781
+ for (const x of new Set([n, cache.get(n.id)])) if (x) { x.meeting = r.note.meeting; x.initiative = r.note.initiative; x.title = r.note.title; }
782
+ if (editor && editor.id === n.id && !editor.title.value.trim()) editor.title.value = r.note.title || '';
783
+ touchRow(n); banner = { tone: 'ok', text: said(r.note) };
784
+ import('./notes-links.js').then((m) => m.refreshNotes()).catch(() => {});
785
+ } else banner = { tone: 'bad', text: (r && r.error) || 'the link was not changed' };
786
+ ctx.setState({});
787
+ }
788
+ /** The events of the note's day and the day before, from the Agenda's read. */
789
+ async function eventsNear(n) {
790
+ const at = n.created_at || new Date().toISOString();
791
+ const day = dateOfIso(at); const before = dateOfIso(new Date(Date.parse(at) - 86400000).toISOString());
792
+ const months = [...new Set([ymOfIso(at), before.slice(0, 7)])];
793
+ const reads = await Promise.all(months.map((m) => pmGet('/api/proactive/agenda?month=' + m)));
794
+ const out = []; const seen = new Set();
795
+ for (const r of reads) {
796
+ if (!r || !r.days) continue;
797
+ for (const d of [before, day]) for (const e of ((r.days[d] && r.days[d].events) || [])) { const k = e.start + '|' + e.title; if (!seen.has(k)) { seen.add(k); out.push({ ...e, date: d }); } }
798
+ }
799
+ return out.sort((a, b) => String(a.start).localeCompare(String(b.start)));
800
+ }
801
+ async function meetingSheet(ctx, n) {
802
+ const box = el('div', { class: 'nt-picks', role: 'radiogroup', 'aria-label': 'Meeting' }, el('p', { class: 'nt-hint' }, 'reading your calendar …'));
803
+ let pick = n.meeting && n.meeting.kind === 'adhoc' ? 'adhoc' : null;
804
+ let evs = [];
805
+ const opt = (key, title, sub, on) => button({ label: el('span', { class: 'nt-pick__txt' }, el('b', null, title), el('span', { class: 'nt-hint' }, sub)), variant: 'ghost', attrs: { class: 'nt-pick', role: 'radio', 'aria-checked': on ? 'true' : 'false', 'data-nt-meeting-pick': key },
806
+ onClick: (ev) => { pick = key; for (const b of box.querySelectorAll('[data-nt-meeting-pick]')) b.setAttribute('aria-checked', b === ev.currentTarget ? 'true' : 'false'); } });
807
+ const h = sheet({ title: 'Which meeting is this?', sub: 'The day of the note and the day before, from your calendar.', body: [box], attrs: { 'data-nt-meeting-sheet': '' },
808
+ actions: [button({ label: 'Cancel', variant: 'ghost', onClick: () => h.close() }), button({ label: 'Link', variant: 'primary', attrs: { 'data-nt-meeting-save': '' }, onClick: async () => {
809
+ if (!pick) { h.close(); return; }
810
+ h.close();
811
+ if (pick === 'adhoc') return link(ctx, n, { meeting: { kind: 'adhoc' } }, () => 'Marked as a meeting that is not on the calendar.');
812
+ const e = evs[Number(pick)];
813
+ const { meetingOf } = await import('./notes-links.js');
814
+ return link(ctx, n, { meeting: meetingOf(e) }, () => 'Linked to ' + e.title + '. The note shows on the calendar.');
815
+ } })] });
816
+ evs = await eventsNear(n);
817
+ if (!h.open) return;
818
+ const { notesFor } = await import('./notes-links.js');
819
+ box.replaceChildren(...evs.map((e, i) => {
820
+ const on = !!(n.meeting && n.meeting.kind === 'event' && (n.meeting.event_id === e.id || (Date.parse(n.meeting.start) === Date.parse(e.start) && n.meeting.title === e.title)));
821
+ if (on) pick = String(i);
822
+ const other = notesFor(e).some((x) => x.id !== n.id);
823
+ return opt(String(i), e.title, dayOf(e.start) + ', ' + e.time + (other ? ' · has a note' : ''), on);
824
+ }), evs.length ? null : el('p', { class: 'nt-hint' }, 'No calendar events on these days.'),
825
+ opt('adhoc', 'Not on the calendar', 'a hallway chat, a call', pick === 'adhoc'));
826
+ }
827
+ async function initiativeSheet(ctx, n) {
828
+ const ws = n.ws || wsOf(ctx);
829
+ const box = el('div', { class: 'nt-picks' }, el('p', { class: 'nt-hint' }, 'reading the initiatives …'));
830
+ let pick = n.initiative ? n.initiative.id : '';
831
+ let open = [];
832
+ const h = sheet({ title: 'Which initiative is this note about?', sub: 'The note shows on the initiative\'s page.', body: [box], attrs: { 'data-nt-ini-sheet': '' },
833
+ actions: [button({ label: 'Cancel', variant: 'ghost', onClick: () => h.close() }), button({ label: 'Save', variant: 'primary', attrs: { 'data-nt-ini-save': '' }, onClick: () => {
834
+ h.close();
835
+ if (pick === (n.initiative ? n.initiative.id : '') && (!n.initiative || n.initiative.by === 'pm')) return;
836
+ if (!pick) return link(ctx, n, { initiative: null }, () => 'Not linked to an initiative.');
837
+ const i = open.find((x) => x.id === pick) || { id: pick, title: pick };
838
+ return link(ctx, n, { initiative: { id: i.id, ws, repo: (ctx.state && ctx.state.product) || ctx.repo || null, title: i.title } }, () => 'Now on ' + i.title + '. The note shows on its page.');
839
+ } })] });
840
+ if (!ws) { box.replaceChildren(el('p', { class: 'nt-hint' }, 'Open a repository first: its initiatives are the ones a note can belong to.')); return; }
841
+ const r = await pmGet('/api/notes/initiatives?ws=' + encodeURIComponent(ws) + '&id=' + encodeURIComponent(n.id));
842
+ if (!h.open) return;
843
+ if (!r || !r.ok) { box.replaceChildren(el('p', { class: 'nt-hint', role: 'alert' }, (r && r.error) || 'The initiatives could not be read.')); return; }
844
+ open = r.open || [];
845
+ const radios = [];
846
+ const choose = (v) => { pick = v; for (const b of radios) b.setAttribute('aria-checked', b.dataset.ntIniPick === v ? 'true' : 'false'); if (sel.control.value !== v) sel.control.value = open.some((x) => x.id === v) ? v : ''; };
847
+ const opt = (v, title, sub) => { const b = button({ label: el('span', { class: 'nt-pick__txt' }, el('b', null, title), el('span', { class: 'nt-hint' }, sub)), variant: 'ghost', attrs: { class: 'nt-pick', role: 'radio', 'aria-checked': pick === v ? 'true' : 'false', 'data-nt-ini-pick': v }, onClick: () => choose(v) }); radios.push(b); return b; };
848
+ const sel = field({ label: 'Or choose any open initiative', kind: 'select', value: open.some((x) => x.id === pick) ? pick : '', options: [{ value: '', label: 'Choose …' }, ...open.map((x) => ({ value: x.id, label: x.title }))], attrs: { 'data-nt-ini-all': '' } });
849
+ sel.control.addEventListener('change', () => choose(sel.control.value));
850
+ const sugg = r.pick ? [el('h3', { class: 'nt-sec__h' }, 'Suggested from what was said'), opt(r.pick.id, r.pick.title, r.pick.shared + ' words in common')] : [];
851
+ const current = n.initiative && (!r.pick || r.pick.id !== n.initiative.id) ? [opt(n.initiative.id, n.initiative.title || n.initiative.id, BY_WORDS[n.initiative.by] || '')] : [];
852
+ box.replaceChildren(el('div', { role: 'radiogroup', 'aria-label': 'Initiative' }, ...sugg, ...current, opt('', 'None', 'not about an initiative')), open.length ? sel : el('p', { class: 'nt-hint' }, 'This repository has no open initiatives.'));
853
+ }
854
+ async function openInitiativeOf(ctx, ini) {
855
+ const reg = await import('./registry.js');
856
+ reg.openInitiative(ini.repo || (ctx.state && ctx.state.product) || ctx.repo || null, ini.id);
857
+ }
858
+ function linksLine(ctx, n) {
859
+ const m = n.meeting; const ini = n.initiative;
860
+ const meet = m && m.kind === 'event'
861
+ ? [el('b', null, m.title), el('span', { class: 'nt-hint' }, dayOf(m.start) + ', ' + nowClock(m.start) + (m.end ? '–' + nowClock(m.end) : ''))]
862
+ : m && m.kind === 'adhoc' ? [el('b', null, 'Not on the calendar'), el('span', { class: 'nt-hint' }, dayOf(n.created_at) + ', ' + nowClock(n.created_at))]
863
+ : [el('b', null, 'None')];
864
+ return el('div', { class: 'nt-links', 'data-nt-part': 'links' },
865
+ el('div', { class: 'nt-link', 'data-nt-link': 'meeting' }, el('span', { class: 'nt-link__k' }, 'Meeting'), ...meet,
866
+ button({ label: 'Change', variant: 'ghost', size: 'sm', onClick: () => meetingSheet(ctx, n), attrs: { 'data-nt-meeting': '' } })),
867
+ el('div', { class: 'nt-link', 'data-nt-link': 'initiative', 'data-nt-ini-by': ini ? ini.by : '' }, el('span', { class: 'nt-link__k' }, 'Initiative'),
868
+ ini ? [button({ label: ini.title || ini.id, variant: 'ghost', size: 'sm', onClick: () => openInitiativeOf(ctx, ini), attrs: { 'data-nt-ini-open': ini.id } }), el('span', { class: 'nt-hint' }, BY_WORDS[ini.by] || '')] : el('b', null, 'None'),
869
+ button({ label: 'Change', variant: 'ghost', size: 'sm', onClick: () => initiativeSheet(ctx, n), attrs: { 'data-nt-ini': '' } })));
870
+ }
765
871
  function listening(n) {
766
872
  if (audio && audio.id === n.id) return { kind: 'local', state: audio.state, sources: audio.sources, started: audio.started };
767
873
  if (speech && speech.id === n.id && speech.mode === 'record' && (speech.state === 'on' || speech.state === 'checking')) return { kind: 'browser', state: speech.state, sources: ['mic'], started: speech.started };
@@ -790,7 +896,7 @@ function header(ctx, n, ed) {
790
896
  }
791
897
  acts.push(button({ label: 'All notes', variant: 'ghost', onClick: () => { showList = !showList; ctx.setState({}); if (showList) loadList(ctx); }, attrs: { 'data-nt-all': '', 'aria-expanded': showList ? 'true' : 'false' } }));
792
898
  acts.push(iconButton({ icon: 'settings', ariaLabel: 'Settings', onClick: () => openSettings(ctx, n), attrs: { 'data-nt-settings': '' } }));
793
- return el('header', { class: 'nt-header', 'data-nt-part': 'header' }, left, el('div', { class: 'nt-acts' }, acts));
899
+ return el('header', { class: 'nt-header', 'data-nt-part': 'header' }, el('div', { class: 'nt-headwrap' }, left, linksLine(ctx, n)), el('div', { class: 'nt-acts' }, acts));
794
900
  }
795
901
  function listPane(ctx) {
796
902
  const rows = (list && list.notes) || [];
@@ -900,6 +1006,7 @@ export function renderBoard(inner, ctx) {
900
1006
  ensureComponentsCss();
901
1007
  if (FIXTURE) { inner.appendChild(el('p', { class: 'nt-hint' }, 'The fixture keeps no notes.')); return; }
902
1008
  if (!list) loadList(ctx);
1009
+ if (pendingOpen) { const id = pendingOpen; pendingOpen = null; if (id !== currentId) { selectNote(ctx, id); loadList(ctx); } }
903
1010
  if (!engine || engine.ws !== wsOf(ctx)) { if (!engine || !engine.loading || engine.ws !== wsOf(ctx)) loadEngine(ctx); }
904
1011
 
905
1012
  const focus = typeof document !== 'undefined' && editor && (document.activeElement === editor.body ? 'body' : document.activeElement === editor.title ? 'title' : null);