create-pmos 0.149.0 → 0.151.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.149.0",
3
+ "version": "0.151.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@b71e523
1
+ kernel-source: pmos@ccceced
2
2
  assembled: 2026-10-12
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-12T03:20:05Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T03:34:16Z"}
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-12T03:20:05Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T03:34:16Z"}
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-12T03:20:05Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T03:34:16Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -21,7 +21,7 @@ Under the title, the note shows its meeting and its initiative. Press **Link to
21
21
 
22
22
  - **Before** shows what the note kept about its meeting: the agenda, what the meeting is for, what to do to be ready, what to read first, the people, and the meeting: its time, its place and who organized it. A tick shows an agenda item that was heard in the transcript. A note with no meeting says so, and has **Link to a meeting** and **Link to an initiative**.
23
23
  - **During** shows the live notes while the note is recorded or paused. At other times it shows the transcript.
24
- - **After** shows **Replay** first when the sound of the note is kept on this machine. Then it shows the overview, the decisions with why, the next steps with owner and due date, the open questions, the statements that differ from a file, the highlights and the screenshots of the shared screen (**Screen**; press one to see it large). The transcript is folded at the bottom. When the final notes are written, click a text to correct it. Leave the text to save it. Press `Esc` to cancel. **Make task** adds a next step to the note's initiative.
24
+ - **After** shows **Replay** first when the sound of the note is kept on this machine. Then it shows the overview, the decisions with why, the next steps with owner and due date, the open questions, the statements that differ from a file, the highlights and the screenshots of the shared screen (**Screen**; press one to see it large). The transcript is folded at the bottom. Click a text to correct it: the summary, a decision and its why, or a next step, its owner and its due day. Press `Enter` or leave the text to save it. Press `Esc` to cancel. Empty an owner or a due day to clear it. A decision caught while the meeting was recorded has no why. Before the final notes are written, press **Write a summary of the meeting** to write your own summary; empty it to go back to the caught one. **Make task** adds a next step to the note's initiative.
25
25
 
26
26
  A note that is recorded now opens on **During**. Other notes open on **After**. **Show in the calendar** goes back to the grid, on the day of the meeting, with the meeting selected. The copy button puts the note on the clipboard as Markdown.
27
27
 
@@ -16,13 +16,13 @@ Every button in the top bar and in the note is an icon. Hold the pointer on an i
16
16
  The Record control is at the right of the top bar. It has two groups, always in the same place.
17
17
 
18
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.
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 ended and have no note, soonest first. A meeting that has started stays in the list until 10 minutes after it ends, so you can still record it. It shows "Now · started 5 min ago", or "Ended 3 min ago" in those last 10 minutes. **Next** marks the first meeting that has not started. 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
20
 
21
21
  Starting a note, from the Record control or from **Take notes** on a calendar event, shows the Notes page with that note.
22
22
 
23
23
  ### The Record control in the console
24
24
 
25
- The console (the page at `/c3.html`) has the same Record control in its top bar, beside the Sessions button. It is always there, also when nothing is recording. **Record** (the microphone) starts an untitled note and listens at once. The arrow lists the meetings of today that have not started. Press one to start a note linked to that calendar event. While a meeting records, the red group shows its name and clock, **Highlight**, **Pause** and **Stop**. While it is paused, the group shows **Resume** and **Stop**. **Stop** asks first and offers **Pause instead**. A red dot shows on the Calendar tab while a meeting is held. Press the name in the red group to open the note on the console's Calendar tab, in **Notes**, on the **During** stage. See [the Calendar tab of the console page](console-calendar.md).
25
+ The console (the page at `/c3.html`) has the same Record control in its top bar, beside the Sessions button. It is always there, also when nothing is recording. **Record** (the microphone) starts an untitled note and listens at once. The arrow lists the meetings of today that have not ended, including one that has started, until 10 minutes after its end. Press one to start a note linked to that calendar event. While a meeting records, the red group shows its name and clock, **Highlight**, **Pause** and **Stop**. While it is paused, the group shows **Resume** and **Stop**. **Stop** asks first and offers **Pause instead**. A red dot shows on the Calendar tab while a meeting is held. Press the name in the red group to open the note on the console's Calendar tab, in **Notes**, on the **During** stage. See [the Calendar tab of the console page](console-calendar.md).
26
26
 
27
27
  When Whisper or Qwen3-ASR transcribes the meeting, the red group also names the engine and the language, for example **Whisper · English**. Check it when lines come out in the wrong language: the language is set in the Notes settings.
28
28
 
@@ -903,11 +903,11 @@ function summarize(note) {
903
903
  // PM labelled it; without bullets, from the lines as before
904
904
  const bl = ((note && note.bullets) || []).filter((b) => b && b.text);
905
905
  const bitems = bl.map((b, k) => ({ i: n + k, bid: b.id, text: b.text, t: Number.isFinite(b.t) ? b.t : null, src: 'bullet', cat: b.cat || null, by_pm: b.cat_by === 'pm',
906
- dropped: !!b.dropped, open: !!b.open, owner: typeof b.owner === 'string' && b.owner ? b.owner : null }));
906
+ dropped: !!b.dropped, open: !!b.open, owner: typeof b.owner === 'string' && b.owner ? b.owner : null, due: typeof b.due === 'string' && b.due ? b.due : null }));
907
907
  const caught = bl.length ? bitems.concat(items.filter((x) => x.src === 'transcript' && x.by_pm)) : items;
908
908
  const decisions = cap(caught.filter((x) => kindOf(x, 'decision')).filter(once), 'decisions');
909
909
  const actions = cap(caught.filter((x) => kindOf(x, 'action')).filter(once), 'actions')
910
- .map((x) => ({ ...x, owner: x.owner || ownerOf(x.text, names), due: dueOf(x.text) }));
910
+ .map((x) => ({ ...x, owner: x.owner || ownerOf(x.text, names), due: x.due || dueOf(x.text) }));
911
911
  const questions = cap(caught.filter((x) => kindOf(x, 'question')).filter(once), 'questions');
912
912
  const points = cap(bitems.filter((x) => !x.dropped && !x.open && (x.cat === 'topic' || x.cat === 'statement')).filter(once), 'points');
913
913
  const facts = items.filter((x) => x.src === 'transcript' && isFact(x)).filter(once);
@@ -1569,7 +1569,7 @@ function updateBullet(dir, { id, bid, text, patch = {} } = {}) {
1569
1569
  return { ok: true, bullet: b };
1570
1570
  }
1571
1571
  /** The PM's correction to one bullet: its label (the seven), `drop` (out of its section and the summary), or its words. */
1572
- function editBullet(dir, { id, bid, cat, drop, text, now = Date.now() } = {}) {
1572
+ function editBullet(dir, { id, bid, cat, drop, text, owner, due, now = Date.now() } = {}) {
1573
1573
  const note = load(dir, id);
1574
1574
  if (!note) return err(404, 'no such note');
1575
1575
  if (!BULLET_ID_RE.test(String(bid || ''))) return err(400, 'that is not a bullet id');
@@ -1583,6 +1583,9 @@ function editBullet(dir, { id, bid, cat, drop, text, now = Date.now() } = {}) {
1583
1583
  if (t.length > MAX_BULLET) return err(413, 'that bullet is too long');
1584
1584
  if (t !== b.text) { if (b.orig_text === undefined) b.orig_text = b.text; b.text = t; delete b.check; }
1585
1585
  }
1586
+ // the PM's owner or due day for a next step; an empty one clears it
1587
+ if (owner !== undefined) { const o = String(owner == null ? '' : owner).replace(/\s+/g, ' ').trim().slice(0, 80); if (o) b.owner = o; else delete b.owner; }
1588
+ if (due !== undefined) { const d = String(due == null ? '' : due).replace(/\s+/g, ' ').trim().slice(0, 60); if (d) b.due = d; else delete b.due; }
1586
1589
  if (cat !== undefined) { b.cat = cat; b.cat_by = 'pm'; delete b.open; }
1587
1590
  if (drop === true) b.dropped = true; else if (drop === false) delete b.dropped;
1588
1591
  refreshLocal(note, now);
@@ -1642,14 +1645,36 @@ function removeHighlight(dir, { id, hid, now = Date.now() } = {}) {
1642
1645
  store(dir, note);
1643
1646
  return { ok: true };
1644
1647
  }
1645
- /** The PM's correction to the final notes: the overview, or the text of one decision, next step or open question. The
1646
- * first words the agent wrote are kept as `orig_text`. */
1648
+ /** The PM's correction to the final notes: the overview (before the final notes, the PM's summary of the note), or the text of one decision, next step or open question. The
1649
+ * first words the agent wrote are kept as `orig_text`. `field` corrects another part of one item instead: a next step's
1650
+ * `owner` or `due`, or a decision's `why`; an empty one clears it. */
1647
1651
  const FINAL_SECS = { decision: 'decisions', action: 'actions', question: 'questions' };
1648
- function editFinal(dir, { id, sec, i, text, now = Date.now() } = {}) {
1652
+ const FINAL_FIELDS = { action: { owner: 80, due: 60 }, decision: { why: 400 } };
1653
+ function editFinal(dir, { id, sec, i, text, field, now = Date.now() } = {}) {
1649
1654
  const note = load(dir, id);
1650
1655
  if (!note) return err(404, 'no such note');
1651
1656
  const f = note.final_notes;
1657
+ // before the final notes exist, the PM's summary is kept beside what was caught live; an empty one goes back to that
1658
+ if (!f && sec === 'overview') {
1659
+ const t = clip(text, 2000);
1660
+ if (t) note.overview_pm = t; else delete note.overview_pm;
1661
+ note.updated_at = iso(now);
1662
+ store(dir, note);
1663
+ return { ok: true, final_notes: null, overview_pm: note.overview_pm || null };
1664
+ }
1652
1665
  if (!f) return err(409, 'this note has no final notes yet');
1666
+ if (field !== undefined && field !== 'text') {
1667
+ const max = (FINAL_FIELDS[sec] || {})[field];
1668
+ if (!max) return err(400, 'a ' + (sec || 'note') + ' has no ' + String(field).slice(0, 20) + ' to correct');
1669
+ const list = f[FINAL_SECS[sec]];
1670
+ const x = Array.isArray(list) ? list[Number(i)] : null;
1671
+ if (!x) return err(404, 'no such item');
1672
+ x[field] = clip(text, max) || null;
1673
+ keepVersion(note, now);
1674
+ note.updated_at = iso(now);
1675
+ store(dir, note);
1676
+ return { ok: true, final_notes: f };
1677
+ }
1653
1678
  const t = clip(text, sec === 'overview' ? 2000 : 400);
1654
1679
  if (!t) return err(400, 'the text is empty');
1655
1680
  if (sec === 'overview') {
@@ -7,7 +7,7 @@
7
7
  * [{ id, t, minute, text, owner, cat, cat_by, by: 'agent', open?, check?, dropped? }];
8
8
  * an open bullet has cat null. bullets_state: { agent: 'on'|'off'|'failed',
9
9
  * reason, session, next_at_ms, minute_ms, minutes }
10
- * POST /api/notes/bullet { id, bullet, cat?, drop?, text? } → { ok, bullet, summary } the PM's correction to one bullet (Run 8)
10
+ * POST /api/notes/bullet { id, bullet, cat?, drop?, text?, owner?, due? } → { ok, bullet, summary } the PM's correction to one bullet (Run 8)
11
11
  * POST /api/notes/new { title?, meeting?, initiative? } → { ok, note, reused } a calendar event that already has a note
12
12
  * gets that note back (reused: true), so one meeting keeps one note (Run 4)
13
13
  * POST /api/notes/link { id, meeting?, initiative?, from_event? } → { ok, note, kept? } the PM's link; null unlinks (Run 4)
@@ -62,7 +62,7 @@
62
62
  * POST /api/notes/highlight-remove { id, hid } → { ok }
63
63
  * POST /api/notes/wispr-fetch { id } → { ok, wispr } the PM asks for Wispr Flow's notes now, after the automatic fetch failed or
64
64
  * found nothing; the agent session is asked at once and again every two minutes for up to 20 minutes
65
- * POST /api/notes/final-edit { id, sec: 'overview'|'decision'|'action'|'question', i?, text } → { ok, final_notes } the PM's correction
65
+ * POST /api/notes/final-edit { id, sec: 'overview'|'decision'|'action'|'question', i?, text, field?: 'owner'|'due'|'why' } → { ok, final_notes } the PM's correction
66
66
  *
67
67
  * Notes are the PM's own and live in the per-user runtime directory (ctx.runtimeDir), never in a repository, so no route here
68
68
  * writes into a registered workspace. Every POST goes through pm-guard `requirePagePress` (a body that is not an object or carries
@@ -812,7 +812,7 @@ async function handle(req, res, url, ctx) {
812
812
  const q = queueFinal(ctx, id);
813
813
  return done(q.queued ? { ok: true, queued: true } : { ok: false, status: 409, error: q.reason });
814
814
  }
815
- if (p === '/api/notes/bullet') return done(notes.editBullet(dir, { id, bid: body.bullet, cat: body.cat, drop: body.drop, text: body.text }));
815
+ if (p === '/api/notes/bullet') return done(notes.editBullet(dir, { id, bid: body.bullet, cat: body.cat, drop: body.drop, text: body.text, owner: body.owner, due: body.due }));
816
816
  if (p === '/api/notes/line') {
817
817
  const pick = (k) => (body[k] === undefined ? undefined : body[k]);
818
818
  return done(notes.editLine(dir, { id, index: Number(body.index), cat: pick('cat'), summary: pick('summary'), tags: pick('tags'), people: pick('people'), text: pick('text') }));
@@ -963,7 +963,7 @@ async function handle(req, res, url, ctx) {
963
963
  }
964
964
  if (p === '/api/notes/highlight-remove') return done(notes.removeHighlight(dir, { id, hid: body.hid }));
965
965
  if (p === '/api/notes/wispr-fetch') return done(fetchWisprNow(ctx, id));
966
- if (p === '/api/notes/final-edit') return done(notes.editFinal(dir, { id, sec: body.sec, i: body.i, text: body.text }));
966
+ if (p === '/api/notes/final-edit') return done(notes.editFinal(dir, { id, sec: body.sec, i: body.i, text: body.text, field: body.field }));
967
967
  // summarize-agent: the handoff file first (refused for a private note), then the PM's own agent session
968
968
  let w = null;
969
969
  if (body.ws != null) {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.149.0",
3
+ "version": "0.151.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.149.0",
9
+ "version": "0.151.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.149.0",
3
+ "version": "0.151.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",
@@ -282,20 +282,26 @@ function after(s, n, mode) {
282
282
  const p = partsOf(n);
283
283
  const names = n.meeting && n.meeting.context ? peopleOf(n.meeting.context) : [];
284
284
  const cf = conflictsOf(n);
285
- const td = (text) => el('td', null, text);
286
285
  const kids = [
287
286
  el('div', { class: 'c3cv-head' }, el('h3', null, c3ic('notes'), 'Notes'), recordButton(s, n, mode)),
288
287
  el('div', { class: 'chips2' }, el('b', null, n.title || 'Untitled note'), el('span', { class: 'dim mono cal3-sm' }, [dayOf(startOf(n)), mins(lenOf(n))].filter(Boolean).join(' · ')), ...names.map(avatar)),
289
288
  ...(() => { const rp = mode ? null : replayOf(s, n); return rp ? [el('div', { class: 'cal3-replay', 'data-cal3-notes': 'replay' }, el('h4', null, 'Replay'), rp, el('p', { class: 'dim cal3-sm' }, 'Kept on this machine.'))] : []; })(),
290
289
  ...cf.map((c) => el('div', { class: 'fold w', 'data-cal3-notes': 'conflict' }, c3ic('alert', 'sm'), '“' + c.text + '” differs from ', el('span', { class: 'mono' }, c.doc + (c.line ? ':' + c.line : '')))),
291
- p.overview ? editable(p.from === 'final', 'p', p.overview, 'Overview', (t) => saveFinal(s, n, 'overview', 0, t), { class: 'cal3-ov', 'data-cal3-notes': 'overview' })
292
- : el('p', { class: 'dim cal3-ov', 'data-cal3-notes': 'no-overview' }, mode ? 'The overview is written when you stop recording.' : 'No overview yet.'),
290
+ p.overview || !mode ? editable(true, 'p', p.overview, 'Summary', (t) => saveFinal(s, n, 'overview', 0, t), { class: 'cal3-ov', 'data-cal3-notes': p.overview ? 'overview' : 'no-overview' }, p.from === 'final' ? null : 'Write a summary of the meeting')
291
+ : el('p', { class: 'dim cal3-ov', 'data-cal3-notes': 'no-overview' }, 'The overview is written when you stop recording.'),
293
292
  ];
294
- if (p.decisions.length) kids.push(el('table', { class: 'tbl', 'data-cv': 'decisions' }, el('thead', null, el('tr', null, el('th', null, 'Decision'), el('th', null, 'Why'))),
295
- el('tbody', null, p.decisions.map((d) => el('tr', { 'data-cal3-notes': 'decision' }, el('td', null, editable(p.from === 'final', 'span', d.text, 'Decision', (t) => saveFinal(s, n, d.sec, d.i, t))), el('td', { class: 'dim' }, d.why || d.by || ''))))));
293
+ // each part of a decision or a next step is corrected in place: in the final notes (/api/notes/final-edit), or in the bullet
294
+ // it was caught from (/api/notes/bullet). A bullet has no why, so a caught decision's why is not shown.
295
+ const fin = p.from === 'final';
296
+ const cell = (x, field, label, ph, cls) => {
297
+ const on = fin || (x.bullet !== undefined && field !== 'why');
298
+ return el('td', cls ? { class: cls } : null, editable(on, 'span', x[field] || '', label, (t) => saveItem(s, n, x, field, t), { 'data-cal3-notes-field': field }, ph));
299
+ };
300
+ if (p.decisions.length) kids.push(el('table', { class: 'tbl', 'data-cv': 'decisions' }, el('thead', null, el('tr', null, el('th', null, 'Decision'), fin ? el('th', null, 'Why') : null)),
301
+ el('tbody', null, p.decisions.map((d) => el('tr', { 'data-cal3-notes': 'decision' }, cell(d, 'text', 'Decision'), fin ? cell(d, 'why', 'Why', 'Add why', 'dim') : null)))));
296
302
  if (p.actions.length) kids.push(el('table', { class: 'tbl', 'data-cv': 'actions' }, el('thead', null, el('tr', null, el('th', null, 'Next step'), el('th', null, 'Owner'), el('th', null, 'Due'), el('th'))),
297
- el('tbody', null, p.actions.map((a) => el('tr', { 'data-cal3-notes': 'action' }, el('td', null, editable(p.from === 'final', 'span', a.text, 'Next step', (t) => saveFinal(s, n, a.sec, a.i, t))),
298
- td(a.owner || el('span', { class: 'dim' }, 'No owner')), td(a.due || el('span', { class: 'dim' }, '—')),
303
+ el('tbody', null, p.actions.map((a) => el('tr', { 'data-cal3-notes': 'action' }, cell(a, 'text', 'Next step'),
304
+ cell(a, 'owner', 'Owner', 'No owner'), cell(a, 'due', 'Due', 'No date'),
299
305
  el('td', { class: 'cal3-tdr' }, btn('chip2 lk', { 'data-cal3-notes': 'make-task', title: n.initiative ? 'Add this step to ' + iniName(n.initiative) + ' as a plan item' : 'Link the note to an initiative first' },
300
306
  [c3ic('plus', 'sm'), 'Make task'], (e) => makeTask(n, a, { btn: e.currentTarget }))))))));
301
307
  if (p.questions.length) kids.push(el('div', { class: 'cal3-ctx', 'data-cal3-notes': 'questions' }, el('h4', null, 'Open questions'),
@@ -313,10 +319,11 @@ function after(s, n, mode) {
313
319
  return el('section', { class: 'card cal3-ncard', 'data-cv': 'notes' }, kids);
314
320
  }
315
321
 
316
- /** A text the PM corrects in place once the notes are final: a click puts the cursor in it, leaving it saves, Esc puts it back. */
317
- function editable(on, tag, text, label, save, attrs) {
318
- if (!on) return el(tag, attrs || null, text);
319
- const node = el(tag, { ...(attrs || {}), contenteditable: 'true', spellcheck: 'true', role: 'textbox', 'aria-label': 'Edit ' + label, 'data-cal3-notes-edit': '' }, text);
322
+ /** A text the PM corrects in place: a click puts the cursor in it, leaving it saves, Esc puts it back. With a placeholder
323
+ * (`ph`), the text may be empty: the placeholder shows, and emptying the text clears it; without one, an empty text is refused. */
324
+ function editable(on, tag, text, label, save, attrs, ph) {
325
+ if (!on) return el(tag, attrs || null, text || (ph ? el('span', { class: 'dim' }, ph) : ''));
326
+ const node = el(tag, { ...(attrs || {}), class: [attrs && attrs.class, ph ? 'cal3-ed' : null].filter(Boolean).join(' ') || null, contenteditable: 'true', spellcheck: 'true', role: 'textbox', 'aria-label': 'Edit ' + label, 'data-cal3-notes-edit': '', 'data-ph': ph || null }, text);
320
327
  let last = text;
321
328
  node.addEventListener('keydown', (e) => {
322
329
  if (e.key === 'Enter' && tag !== 'p') { e.preventDefault(); node.blur(); }
@@ -324,15 +331,27 @@ function editable(on, tag, text, label, save, attrs) {
324
331
  });
325
332
  node.addEventListener('blur', async () => {
326
333
  const v = node.textContent.replace(/\s+/g, ' ').trim();
327
- if (!v || v === last) { node.textContent = last; return; }
328
- if (await save(v)) last = v; else node.textContent = last;
334
+ if ((!v && !ph) || v === last) { node.textContent = last; return; }
335
+ if (await save(v)) { last = v; node.textContent = v; } else node.textContent = last;
329
336
  });
330
337
  return node;
331
338
  }
332
- async function saveFinal(s, n, sec, i, text) {
333
- const r = await pmPost('/api/notes/final-edit', { id: n.id, sec, i, text });
339
+ /* one part of a decision or a next step: in the final notes, or in the bullet it was caught from */
340
+ async function saveItem(s, n, x, field, text) {
341
+ if (x.sec) return saveFinal(s, n, x.sec, x.i, text, field === 'text' ? undefined : field);
342
+ const r = await pmPost('/api/notes/bullet', { id: n.id, bullet: x.bullet, [field]: text });
334
343
  if (!r || !r.ok) { toast('Not saved' + ((r && r.error) ? ': ' + r.error : '')); return false; }
335
- n.final_notes = r.final_notes;
344
+ const b = (n.bullets || []).find((y) => y && y.id === x.bullet);
345
+ if (b) Object.assign(b, r.bullet);
346
+ x[field] = text;
347
+ toast('Saved');
348
+ return true;
349
+ }
350
+ async function saveFinal(s, n, sec, i, text, field) {
351
+ const r = await pmPost('/api/notes/final-edit', field ? { id: n.id, sec, i, text, field } : { id: n.id, sec, i, text });
352
+ if (!r || !r.ok) { toast('Not saved' + ((r && r.error) ? ': ' + r.error : '')); return false; }
353
+ if (r.final_notes) n.final_notes = r.final_notes;
354
+ if ('overview_pm' in r) { if (r.overview_pm) n.overview_pm = r.overview_pm; else delete n.overview_pm; }
336
355
  toast('Saved');
337
356
  return true;
338
357
  }
@@ -529,6 +529,10 @@
529
529
  .c3 .cal3-ov{margin:0}
530
530
  .c3 .cal3-tdr{text-align:right}
531
531
  .c3 .cal3-ncard{display:grid;gap:var(--sp-2)}
532
+ .c3 [data-cal3-notes-edit]{cursor:text;border-radius:6px;outline-offset:2px}
533
+ @media (hover:hover){.c3 [data-cal3-notes-edit]:hover{background:var(--muted)}}
534
+ .c3 .cal3-ed{display:inline-block;min-width:6ch}
535
+ .c3 .cal3-ed:empty::before{content:attr(data-ph);color:var(--muted-foreground)}
532
536
  .c3 .cal3-nlinks{display:grid;gap:var(--sp-1);margin-top:var(--sp-1)}
533
537
  .c3 .cal3-replay{display:grid;gap:var(--sp-1)}
534
538
  .c3 .cal3-replay audio{width:100%;max-width:100%}
@@ -144,6 +144,7 @@ export function partsOf(n) {
144
144
  const ov = n.summary && n.summary.sections && n.summary.sections.overview;
145
145
  if (Array.isArray(ov)) overview = ov.map((x) => String((x && x.text) || '')).filter(Boolean).join(' ');
146
146
  }
147
+ if (n.overview_pm) overview = n.overview_pm; // the PM's own summary, written before the final notes
147
148
  return { from: 'live', overview, decisions: c.decision, actions: c.action, questions: c.question, topics: [] };
148
149
  }
149
150
 
@@ -2,7 +2,7 @@
2
2
  *
3
3
  * The Record control lives in the top bar (pm/topbar.js). It has two groups. The left one exists while a meeting is recording
4
4
  * or paused: a chip that names the meeting, then Highlight, Pause and Stop (recording) or Resume and Stop (paused). The right
5
- * one is always there: New, which starts an untitled note, and an arrow that lists today's meetings that have not started.
5
+ * one is always there: New, which starts an untitled note, and an arrow that lists today's meetings that have not ended, including one under way.
6
6
  * There is no pop-up note any more: the chip, and every start of a recording, show the Notes place with that note selected,
7
7
  * and the note there is the full note (pm/dest-notes.js).
8
8
  *
@@ -117,7 +117,7 @@ async function begin(id) {
117
117
  }
118
118
  /** New: an untitled note, linked to nothing, that listens at once. */
119
119
  export function recordNew(btn) { return startNote({}, btn); }
120
- /** A meeting that has not started: a note made for its event, with its context kept. */
120
+ /** A meeting from the Record menu (coming up, or under way): a note made for its event, with its context kept. */
121
121
  export function recordEvent(e, btn) { return startNote({ meeting: meetingOf(e) }, btn); }
122
122
  /** Take notes on a calendar event (the calendar's own button): the note is linked to the event, and to its initiative when it has one. */
123
123
  export function takeNotes(e, { ws, repo, initiative } = {}, btn) {
@@ -61,13 +61,29 @@ export function meetingOf(e) {
61
61
  }
62
62
  export const notesRows = () => rows || [];
63
63
 
64
- /** Today's meetings that have not started and have no note yet, soonest first (the Record control's "upcoming" menu).
65
- * Read from the Agenda: GET /api/proactive/agenda?month=… The answer is kept for UPCOMING_MS. */
64
+ /** Today's meetings that have not ended and have no note yet, soonest first (the Record control's "upcoming" menu). A meeting that has
65
+ * started stays in the list until its end plus RECORD_GRACE_MS, so it can still be recorded once it is under way.
66
+ * Read from the Agenda: GET /api/proactive/agenda?month=… The answer is kept for UPCOMING_MS; the time filter runs on every call. */
66
67
  const UPCOMING_MS = 60000;
68
+ export const RECORD_GRACE_MS = 10 * 60000;
69
+ const endOf = (e) => { const f = Date.parse(e.end); if (Number.isFinite(f)) return f; const s = Date.parse(e.start); return Number.isFinite(s) ? s + (Number(e.minutes) || 30) * 60000 : NaN; };
70
+ /** Still recordable at `now`: not yet past its end plus the grace period. */
71
+ export const recordable = (e, now = Date.now()) => endOf(e) + RECORD_GRACE_MS > now;
72
+ /** Has the meeting started at `now`? */
73
+ export const started = (e, now = Date.now()) => Date.parse(e.start) <= now;
74
+ /** When the meeting is, for the Record menu: 'in 25 min', 'in 1 h 10 min', 'Now · started 5 min ago', or 'Ended 3 min ago'. */
75
+ export function whenText(e, now = Date.now()) {
76
+ const span = (m) => (m < 60 ? m + ' min' : Math.floor(m / 60) + ' h' + (m % 60 ? ' ' + (m % 60) + ' min' : ''));
77
+ const end = endOf(e);
78
+ if (Number.isFinite(end) && end <= now) return 'Ended ' + span(Math.max(1, Math.round((now - end) / 60000))) + ' ago';
79
+ if (started(e, now)) { const m = Math.round((now - Date.parse(e.start)) / 60000); return m < 1 ? 'Now · just started' : 'Now · started ' + span(m) + ' ago'; }
80
+ return 'in ' + span(Math.max(1, Math.round((Date.parse(e.start) - now) / 60000)));
81
+ }
82
+ const live = (evs) => (evs || []).filter((e) => recordable(e));
67
83
  let upcoming = null; let upcomingAt = 0; let upcomingReading = null;
68
84
  export function upcomingToday({ force = false } = {}) {
69
- if (!force && upcoming && Date.now() - upcomingAt < UPCOMING_MS) return Promise.resolve(upcoming);
70
- if (upcomingReading) return upcomingReading;
85
+ if (!force && upcoming && Date.now() - upcomingAt < UPCOMING_MS) return Promise.resolve(live(upcoming));
86
+ if (upcomingReading) return upcomingReading.then(live);
71
87
  const d = new Date(); const pad = (n) => String(n).padStart(2, '0');
72
88
  const day = d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
73
89
  // the Agenda gives the event; today's brief gives the plan for it (purpose, how to be ready, steps, links, related items)
@@ -77,10 +93,11 @@ export function upcomingToday({ force = false } = {}) {
77
93
  const cal = t && t.ok && t.brief && Array.isArray(t.brief.calendar) ? t.brief.calendar : [];
78
94
  const planOf = (e) => cal.find((c) => minuteOf(c.start) === minuteOf(e.start) && normTitle(c.title) === normTitle(e.title));
79
95
  const merged = evs.map((e) => { const p = planOf(e); return p ? { ...e, with: e.with || p.with, purpose: p.purpose, prep: typeof p.prep === 'string' ? p.prep : e.prep, steps: p.steps, links: p.links, refs: p.refs, initiative: p.initiative } : e; });
80
- upcoming = merged.filter((e) => !e.ended && Date.parse(e.start) > Date.now()).sort((a, b) => String(a.start).localeCompare(String(b.start)));
96
+ // the server's `ended` flag is not read here: it is true from the end itself, and the list keeps a meeting for the grace period after it
97
+ upcoming = merged.filter((e) => recordable(e)).sort((a, b) => String(a.start).localeCompare(String(b.start)));
81
98
  return upcoming;
82
99
  }).catch(() => { upcomingReading = null; return upcoming || []; });
83
- return upcomingReading;
100
+ return upcomingReading.then(live);
84
101
  }
85
102
  export const upcomingWithoutNote = (evs) => (evs || []).filter((e) => notesFor(e).length === 0);
86
103
 
@@ -3,7 +3,7 @@
3
3
  * mountRecording(host) / disposeRecording(host)
4
4
  *
5
5
  * The control is always there, idle included, with the same capabilities as the main app's Record control (pm/topbar.js, pm/note-dock.js):
6
- * New (an untitled note that records at once), an arrow that lists today's meetings that have not started (a pick makes a note linked to that
6
+ * New (an untitled note that records at once), an arrow that lists today's meetings that have not ended, including one under way (a pick makes a note linked to that
7
7
  * calendar event and records it), and, while a meeting records or is paused, a chip that names it with Highlight, Pause or Resume, and Stop
8
8
  * (Stop asks first and offers "Pause instead"). All of it goes through pm/note-dock.js, which owns the real recorder, so the audio capture of
9
9
  * pm/view-notes.js runs here without the main app's Notes place on screen. The chip opens the note in the main app, because console3 has no
@@ -51,7 +51,7 @@ export const simDock = {
51
51
  const live = new WeakMap();
52
52
 
53
53
  /* Demo mode, idle: the mockup's Record button and the arrow beside it (the same two buttons the real control draws). Record starts the simulated recorder with an
54
- * untitled note; the arrow lists the same choice and today's meetings that have not started. Nothing is opened or saved. */
54
+ * untitled note; the arrow lists the same choice and today's meetings that have not ended. Nothing is opened or saved. */
55
55
  function simIdle(d) {
56
56
  const glyph = (dd) => { const sv = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); sv.setAttribute('class', 'ic sm'); sv.setAttribute('viewBox', '0 0 24 24'); sv.setAttribute('aria-hidden', 'true'); const pth = document.createElementNS('http://www.w3.org/2000/svg', 'path'); pth.setAttribute('d', dd); sv.appendChild(pth); return sv; };
57
57
  const start = (title) => { if (d === simDock) simStart(title); };
@@ -182,15 +182,15 @@ function mountReal(host) {
182
182
  const off = (b, why) => { b.setAttribute('aria-disabled', 'true'); b.classList.add('c3rec__off'); b.title = why; b.setAttribute('aria-label', why); return b; };
183
183
  const chipText = (st) => (st.mode === 'recording' ? (st.started ? clockOf(Date.now() - st.started) : '') : st.until ? 'paused · ' + Math.max(1, Math.ceil((st.until - Date.now()) / 60000)) + ' min left' : 'paused');
184
184
 
185
- /** The arrow beside New: Untitled note, then today's meetings that have not started and have no note, the next one marked. */
185
+ /** The arrow beside New: Untitled note, then today's meetings that have not ended (or ended within the grace period) and have no note, the next one marked. */
186
186
  async function openUpcoming(anchor) {
187
187
  const { menu } = await import('./primitives.js');
188
188
  const links = await import('./notes-links.js');
189
189
  const evs = links.upcomingWithoutNote(await links.upcomingToday());
190
- const fmt = (e, i) => {
191
- const mins = Math.max(1, Math.round((Date.parse(e.start) - Date.now()) / 60000));
192
- const inText = mins < 60 ? 'in ' + mins + ' min' : 'in ' + Math.floor(mins / 60) + ' h' + (mins % 60 ? ' ' + (mins % 60) + ' min' : '');
193
- return { label: e.title, hint: e.time || '', detail: [i === 0 ? 'Next' : null, inText, e.with || null].filter(Boolean).join(' · '), attrs: { 'data-fr-rec-event': e.id || '' }, onSelect: () => dock.recordEvent(e) };
190
+ const next = evs.find((x) => !links.started(x));
191
+ const fmt = (e) => {
192
+ const inText = links.whenText(e);
193
+ return { label: e.title, hint: e.time || '', detail: [e === next ? 'Next' : null, inText, e.with || null].filter(Boolean).join(' · '), attrs: { 'data-fr-rec-event': e.id || '' }, onSelect: () => dock.recordEvent(e) };
194
194
  };
195
195
  menu({ anchor, ariaLabel: 'Record a new meeting', items: [{ label: 'Untitled note', icon: 'plus', detail: 'not linked to a meeting', attrs: { 'data-fr-rec-untitled': '' }, onSelect: () => dock.recordNew() }, { sep: true },
196
196
  ...(evs.length ? evs.map(fmt) : [{ label: 'No more meetings today', attrs: { 'data-fr-rec-none': '' }, onSelect: () => {} }])] });
@@ -23,7 +23,7 @@ import { button, iconButton, menu, needsDot, needsCount } from './primitives.js'
23
23
  import { COPY } from './frame-copy.js';
24
24
  import * as dock from './note-dock.js';
25
25
  import { el as h } from './dom.js';
26
- import { upcomingToday, upcomingWithoutNote } from './notes-links.js';
26
+ import { upcomingToday, upcomingWithoutNote, whenText, started } from './notes-links.js';
27
27
  import { contextOf, peopleOf } from './notes-context.js';
28
28
 
29
29
  /** The destinations the bar names, in order. Settings sits at the end of the bar, after Sync. Docs joined the bar at the
@@ -91,7 +91,7 @@ export function createTopbar({ go, openPalette, openView, repoMenu, sync } = {})
91
91
  /* The Record control (meeting-notes Run 13, prototype/meeting-notes/record-control-mockup.html). Two groups, always in the same
92
92
  * place. Left, only while a meeting is recording or paused: the chip that names it, then Highlight, Pause and Stop (recording)
93
93
  * or Resume and Stop (paused). Nothing to resume: no left group. Right, always: New (an untitled note) and an arrow that lists
94
- * today's meetings that have not started; both are unavailable while a meeting is recording. The highlight's pop-up hangs
94
+ * today's meetings that have not ended (one under way included); both are unavailable while a meeting is recording. The highlight's pop-up hangs
95
95
  * under the control. */
96
96
  const recSlot = el('div', { class: 'fr-rec', 'data-fr': 'record' });
97
97
  let recKey = '';
@@ -141,15 +141,15 @@ export function createTopbar({ go, openPalette, openView, repoMenu, sync } = {})
141
141
  if (panel) kids.push(panel);
142
142
  setChildren(recSlot, kids);
143
143
  }
144
- /** The arrow beside New: Untitled note, then today's meetings that have not started and have no note, the next one marked. */
144
+ /** The arrow beside New: Untitled note, then today's meetings that have not ended (or ended within the grace period) and have no note, the next one marked. */
145
145
  async function openUpcoming(anchor) {
146
146
  const evs = upcomingWithoutNote(await upcomingToday());
147
- const fmt = (e, i) => {
147
+ const next = evs.find((x) => !started(x));
148
+ const fmt = (e) => {
148
149
  const c = contextOf(e) || {};
149
- const mins = Math.max(1, Math.round((Date.parse(e.start) - Date.now()) / 60000));
150
- const inText = mins < 60 ? 'in ' + mins + ' min' : 'in ' + Math.floor(mins / 60) + ' h' + (mins % 60 ? ' ' + (mins % 60) + ' min' : '');
150
+ const inText = whenText(e); const first = e === next;
151
151
  const who = peopleOf(c); const extra = c.attendees ? ' (' + c.attendees + ' attendees)' : '';
152
- const bits = [i === 0 ? 'Next' : null, inText, who.length ? who.slice(0, 2).join(', ') + (who.length > 2 ? ' +' + (who.length - 2) : '') : c.attendees ? c.attendees + ' attendees' : null, c.agenda && c.agenda.length ? c.agenda.length + ' agenda item' + (c.agenda.length === 1 ? '' : 's') : null].filter(Boolean);
152
+ const bits = [first ? 'Next' : null, inText, who.length ? who.slice(0, 2).join(', ') + (who.length > 2 ? ' +' + (who.length - 2) : '') : c.attendees ? c.attendees + ' attendees' : null, c.agenda && c.agenda.length ? c.agenda.length + ' agenda item' + (c.agenda.length === 1 ? '' : 's') : null].filter(Boolean);
153
153
  void extra;
154
154
  return { label: e.title, hint: e.time || '', detail: bits.join(' · '), attrs: { 'data-fr-rec-event': e.id || '' }, onSelect: () => dock.recordEvent(e) };
155
155
  };