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 +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/guide/console-calendar.md +1 -1
- package/workspace/guide/notes.md +2 -2
- package/workspace/lib/notes.js +31 -6
- package/workspace/lib/routes-notes.js +4 -4
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-notes.js +35 -16
- package/workspace/public/pm/console3.css +4 -0
- package/workspace/public/pm/dest-notes.js +1 -0
- package/workspace/public/pm/note-dock.js +2 -2
- package/workspace/public/pm/notes-links.js +23 -6
- package/workspace/public/pm/shell-recording.js +7 -7
- package/workspace/public/pm/topbar.js +7 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "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": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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.
|
|
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
|
|
package/workspace/guide/notes.md
CHANGED
|
@@ -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
|
|
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
|
|
package/workspace/lib/notes.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
9
|
+
"version": "0.151.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -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(
|
|
292
|
-
: el('p', { class: 'dim cal3-ov', 'data-cal3-notes': 'no-overview' },
|
|
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
|
-
|
|
295
|
-
|
|
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' },
|
|
298
|
-
|
|
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
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
333
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
65
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
191
|
-
|
|
192
|
-
const inText =
|
|
193
|
-
return { label: e.title, hint: e.time || '', detail: [
|
|
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
|
|
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
|
|
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
|
|
147
|
+
const next = evs.find((x) => !started(x));
|
|
148
|
+
const fmt = (e) => {
|
|
148
149
|
const c = contextOf(e) || {};
|
|
149
|
-
const
|
|
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 = [
|
|
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
|
};
|