create-pmos 0.528.0 → 0.529.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.
Files changed (31) hide show
  1. package/package.json +26 -14
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/briefing-premium-calendar-3-test.js +11 -9
  7. package/workspace/briefing-premium-calendar-test.js +1 -1
  8. package/workspace/changelog.json +25 -0
  9. package/workspace/fixtures/premium-viewer-fixtures.js +4 -2
  10. package/workspace/guide/idle-lab.md +1 -1
  11. package/workspace/guide/notes.md +19 -7
  12. package/workspace/guide/reports-set-up.md +3 -1
  13. package/workspace/lib/bg-queue.js +5 -1
  14. package/workspace/lib/meeting-replay.js +11 -2
  15. package/workspace/lib/meeting-suggest.js +105 -0
  16. package/workspace/lib/meeting-terms.js +57 -17
  17. package/workspace/lib/report-chat.js +3 -3
  18. package/workspace/lib/routes-lab.js +44 -2
  19. package/workspace/lib/routes-meeting-points.js +27 -3
  20. package/workspace/lib/routes-report-flow.js +35 -5
  21. package/workspace/package-lock.json +2 -2
  22. package/workspace/package.json +1 -1
  23. package/workspace/premium-vw-meet-test.js +17 -11
  24. package/workspace/public/pm/cozy-lab-ins.js +13 -4
  25. package/workspace/public/pm/cozy-vw-mtg.css +122 -24
  26. package/workspace/public/pm/premium-meeting.js +2 -1
  27. package/workspace/public/pm/premium-mtg-replay.js +149 -73
  28. package/workspace/public/pm/premium-report-tab.js +33 -4
  29. package/workspace/public/pm/premium-vw-mtg.js +176 -53
  30. package/workspace/public/pm/report-chat.js +1 -0
  31. package/workspace/public/pm/report-flow.js +7 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.528.0",
3
+ "version": "0.529.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": {
@@ -32,6 +32,31 @@
32
32
  "audit"
33
33
  ],
34
34
  "pmosChanges": [
35
+ {
36
+ "v": "0.529.0",
37
+ "at": "2026-11-04T13:14:44.790Z",
38
+ "f": 4,
39
+ "prs": [
40
+ {
41
+ "n": 1243,
42
+ "t": "A look change from a report's chat becomes its next look, kept with ✓ and redrawn into the open draft; the drafting session's answer shows",
43
+ "s": "A look change from a report's chat becomes its next look, kept with ✓ and redrawn into the open draft; the drafting session's answer shows",
44
+ "k": "fix"
45
+ },
46
+ {
47
+ "n": 1245,
48
+ "t": "The Lab's background record says in plain words what Claude did, what came out and when",
49
+ "s": "The Lab's background record says in plain words what Claude did, what came out and when",
50
+ "k": "fix"
51
+ },
52
+ {
53
+ "n": 1244,
54
+ "t": "Meeting player, Jev crop display and word fix A",
55
+ "s": "Meeting player, Jev crop display and word fix A",
56
+ "k": "new"
57
+ }
58
+ ]
59
+ },
35
60
  {
36
61
  "v": "0.528.0",
37
62
  "at": "2026-11-04T12:00:44.809Z",
@@ -416,19 +441,6 @@
416
441
  "k": "new"
417
442
  }
418
443
  ]
419
- },
420
- {
421
- "v": "0.509.0",
422
- "at": "2026-11-03T14:23:32.696Z",
423
- "f": 4,
424
- "prs": [
425
- {
426
- "n": 1198,
427
- "t": "No initiative without an expected outcome",
428
- "s": "No initiative without an expected outcome",
429
- "k": "new"
430
- }
431
- ]
432
444
  }
433
445
  ]
434
446
  }
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@b9f6f671
1
+ kernel-source: pmos@96caad55
2
2
  assembled: 2026-11-04
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-11-04T12:00:41Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-04T13:14:41Z"}
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-11-04T12:00:41Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-04T13:14:41Z"}
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-11-04T12:00:41Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-04T13:14:41Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -145,21 +145,23 @@ const json = (r, b, status = 200) => r.fulfill({ status, contentType: 'applicati
145
145
  const r = C.page;
146
146
  // batch 25 item 11 (MvC): no tabs; the kept points sit left, the timeline is docked, the lines are the Transcript part of the one strip
147
147
  const unfold = async (id) => { if (await r.evaluate((k) => document.querySelector(`#czViewer .vzr-fold [data-a="strip"][data-k="${k}"]`).getAttribute('aria-expanded') !== 'true', id)) await tap(r, `.vzr-fold [data-a="strip"][data-k="${id}"]`); };
148
- ok('R7 a kept recording has the replay card under the summary', await until(() => r.evaluate(() => !!document.querySelector('#czViewer .vzp-card .vzp-pl') && !document.querySelector('#czViewer [role="tablist"]')), 15000));
149
- const playing = () => r.evaluate(() => /^Pause/.test((document.querySelector('#czViewer .vzp-dt [data-a="play"]') || { getAttribute: () => '' }).getAttribute('aria-label') || ''));
150
- ok('R7 Replay shows the player, the strip and the kept points (MtReplay)', await until(() => r.evaluate(() => !!document.querySelector('#czViewer .vzp-dt .vzp-strip') && document.querySelectorAll('#czViewer .vzp-kr').length > 0)));
151
- await tap(r, '.vzp-dt [data-a="play"]');
148
+ ok('R7 a kept recording has the replay card under the summary', await until(() => r.evaluate(() => !!document.querySelector('#czViewer .vzp-card .vzr-np') && !document.querySelector('#czViewer [role="tablist"]')), 15000));
149
+ const playing = () => r.evaluate(() => /^Pause/.test((document.querySelector('#czViewer .vzr-np:not(.vzr-ph1) [data-a="play"]') || { getAttribute: () => '' }).getAttribute('aria-label') || ''));
150
+ ok('R7 Replay shows the player, the strip and the kept points (MtReplay)', await until(() => r.evaluate(() => !!document.querySelector('#czViewer .vzr-np:not(.vzr-ph1) .vzp-strip') && document.querySelectorAll('#czViewer .vzp-kr').length > 0)));
151
+ await tap(r, '.vzr-np:not(.vzr-ph1) [data-a="play"]');
152
152
  ok('R7 Play plays', await until(playing));
153
- const pos = () => r.evaluate(() => (document.querySelector('#czViewer .vzp-dt [data-clk="pos"]') || {}).textContent || '');
153
+ const pos = () => r.evaluate(() => (document.querySelector('#czViewer .vzr-np:not(.vzr-ph1) [data-clk="pos"]') || {}).textContent || '');
154
154
  const p0 = await pos();
155
155
  ok('R7 the playhead keeps time on its own clock', await until(async () => (await pos()) !== p0 && /^00:0[1-9]$/.test(await pos()), 5000), await pos());
156
- await tap(r, '.vzp-dt [data-a="play"]');
156
+ await tap(r, '.vzr-np:not(.vzr-ph1) [data-a="play"]');
157
157
  const p1 = await pos();
158
158
  await r.waitForTimeout(600); // longer than the playhead's 250 ms step: a paused playhead does not move
159
159
  ok('R7 Pause stops the playhead', (await pos()) === p1 && !(await playing()), p1 + ' / ' + await pos());
160
- await r.$eval('#czViewer .vzp-dt .vzp-th[data-a="seekto"]', (b) => b.click());
161
- ok('R7 a capture plays from its time, with the topic of that time', await until(async () => /^03:1\d$/.test(await pos()) && await r.evaluate(() => /Viewer beside the chat/.test((document.querySelector('#czViewer .vzp-top[aria-current="true"]') || {}).textContent || ''))), await pos());
162
- await tap(r, '.vzp-dt [data-a="play"]');
160
+ await tap(r, '.vzr-np:not(.vzr-ph1) [data-a="caps"]'); // batch 27 item 12 (MpD4): the captures row is hidden until its icon is pressed
161
+ await until(() => r.evaluate(() => !!document.querySelector('#czViewer .vzr-np:not(.vzr-ph1) .vzp-th[data-a="seekto"]')));
162
+ await r.$eval('#czViewer .vzr-np:not(.vzr-ph1) .vzp-th[data-a="seekto"]', (b) => b.click());
163
+ ok('R7 a capture plays from its time, with the chapter of that time named above the bar (MpD1)', await until(async () => /^03:1\d$/.test(await pos()) && await r.evaluate(() => /Viewer beside the chat/.test((document.querySelector('#czViewer .vzr-np:not(.vzr-ph1) .vzr-nt b') || {}).textContent || ''))), await pos());
164
+ await tap(r, '.vzr-np:not(.vzr-ph1) [data-a="play"]');
163
165
  /* R12 every line: the Transcript fold seeks the replay */
164
166
  await unfold('tr');
165
167
  ok('R12 the Transcript lists every line, each with its time to play from', await until(async () => /Saved views move in their own work-item\./.test(await text(r)) && !!(await r.$('#czViewer [data-a="trseek"][data-t="760000"]'))), (await text(r)).slice(0, 300));
@@ -102,7 +102,7 @@ const json = (r, b, status = 200) => r.fulfill({ status, contentType: 'applicati
102
102
 
103
103
  /* C8 a kept recording offers Replay */
104
104
  const R = await open('/premium-viewer.html?open=meeting&note=n1');
105
- ok('C8 a kept recording offers Replay, and the card reads its replay', await until(() => R.page.evaluate(() => !!document.querySelector('#czViewer .vzp-card .vzp-pl')), 15000) && R.S.gets.some((g) => /meeting-replay\?note=n1/.test(g)));
105
+ ok('C8 a kept recording offers Replay, and the card reads its replay', await until(() => R.page.evaluate(() => !!document.querySelector('#czViewer .vzp-card .vzr-np')), 15000) && R.S.gets.some((g) => /meeting-replay\?note=n1/.test(g)));
106
106
  await R.ctx.close();
107
107
 
108
108
  /* C3 honest states */
@@ -1,4 +1,29 @@
1
1
  [
2
+ {
3
+ "v": "0.529.0",
4
+ "at": "2026-11-04T13:14:44.790Z",
5
+ "f": 4,
6
+ "prs": [
7
+ {
8
+ "n": 1243,
9
+ "t": "A look change from a report's chat becomes its next look, kept with ✓ and redrawn into the open draft; the drafting session's answer shows",
10
+ "s": "A look change from a report's chat becomes its next look, kept with ✓ and redrawn into the open draft; the drafting session's answer shows",
11
+ "k": "fix"
12
+ },
13
+ {
14
+ "n": 1245,
15
+ "t": "The Lab's background record says in plain words what Claude did, what came out and when",
16
+ "s": "The Lab's background record says in plain words what Claude did, what came out and when",
17
+ "k": "fix"
18
+ },
19
+ {
20
+ "n": 1244,
21
+ "t": "Meeting player, Jev crop display and word fix A",
22
+ "s": "Meeting player, Jev crop display and word fix A",
23
+ "k": "new"
24
+ }
25
+ ]
26
+ },
2
27
  {
3
28
  "v": "0.528.0",
4
29
  "at": "2026-11-04T12:00:44.809Z",
@@ -84,8 +84,10 @@ function install(page, o = {}) {
84
84
  page.route('**/api/notes/series?*', (r) => json(r, { ok: true, series: { key: 'viewer-sync', meetings: [{ id: 'n0', at: '2026-10-15T10:00:00Z', decided: 1, steps: 3 }, { id: 'n1', at: W.NOTE.meeting.start, decided: 2, steps: 3 }] } })),
85
85
  page.route('**/api/notes/search*', (r) => json(r, { ok: true, ids: ['n4'], hits: [{ id: 'n4', snip: '…which teams use saved views for teams every week…', open: true }] })),
86
86
  page.route('**/api/meeting-points/*', (r) => { // a correction and its Undo (the answers lib/meeting-learn.js gives)
87
- const b = post(r); if (/forget$/.test(new URL(r.request().url()).pathname)) { S.terms = (S.terms || []).filter((x) => x.id !== b.lesson); return json(r, { ok: true }); }
88
- if (/term$/.test(new URL(r.request().url()).pathname)) { const t = { id: 't0123456789', note: b.note, heard: b.heard, means: b.means }; S.terms = (S.terms || []).concat([t]); return json(r, { ok: true, term: t, terms: S.terms }); }
87
+ if (/suggest$/.test(new URL(r.request().url()).pathname)) return json(r, { ok: true, suggest: S.suggest || null }); // batch 27 item 12: the ✦ spelling (none unless a test sets one)
88
+ const b = post(r); if (/keep$/.test(new URL(r.request().url()).pathname)) { S.terms = (S.terms || []).map((x) => (x.id === b.lesson ? { ...x, keep: b.keep ? 'yes' : 'no' } : x)); return json(r, { ok: true, term: S.terms.find((x) => x.id === b.lesson), terms: S.terms }); }
89
+ if (/forget$/.test(new URL(r.request().url()).pathname)) { S.terms = (S.terms || []).filter((x) => x.id !== b.lesson); return json(r, { ok: true }); }
90
+ if (/term$/.test(new URL(r.request().url()).pathname)) { const t = { id: 't0123456789', note: b.note, heard: b.heard, means: b.means, keep: 'ask' }; S.terms = (S.terms || []).concat([t]); return json(r, { ok: true, term: t, terms: S.terms }); }
89
91
  const phrase = require('../lib/meeting-learn.js').phraseOf(b.orig && b.orig.text, b.text);
90
92
  S.edits = { ...(S.edits || {}), [b.id]: { kind: b.kind, text: b.text, orig: b.orig } };
91
93
  return json(r, { ok: true, edits: S.edits, lesson: b.learn === false ? null : { id: 'l0123456789', phrase, kind: b.kind, meaning: b.text, orig: b.orig }, check: [] });
@@ -39,7 +39,7 @@ The Lab shows only once there is something to show. Top to bottom:
39
39
 
40
40
  - One ✦ line for the week: the idle time Claude used, how many results you kept, and how many findings fit no initiative.
41
41
  - The findings that fit no initiative, each with ✓ and ✕.
42
- - **What Claude did in the background**: every background job this week, not only the idle lab. A result that waits on an initiative is a link there. It is not a second copy.
42
+ - **What Claude did in the background**: every background job this week, not only the idle lab. Each row says in plain words what Claude did, why it ran and what came out, and when. For example: "Checked Inline login for eShop for risks · monthly check · 3 findings waiting · Sat 00:32", or "Redrew TSC Monthly Product Update in its saved look · 19:37". **See what it found** shows only when a result waits on the initiative. It opens the initiative, where the result is. It is not a second copy.
43
43
  - **You kept**: how many prototypes and scans you kept.
44
44
  - **Next time you step away**: what Claude plans to do next. Claude picks these by itself. To skip one, say so in chat.
45
45
 
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "Guide: notes"
4
4
  description: "How to take notes in a meeting: one note top to bottom, typed notes as timed lines, key points written each minute and labelled by Jev, delete a note; final notes written after Stop from the whole recording, with speakers and a quote for each item, decisions with their why and who, next steps with an open question, a discussion paragraph, and Wispr Flow's notes merged in and fetched by hand when the fetch fails; 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 for conflicts, capture highlights during the meeting, create tasks from next steps, correct and tag lines, see what changed in your notes, replay the meeting, see screenshots of the shared screen beside the transcript, link a note to its meeting and its initiative, and get a summary."
5
- timestamp: 2026-11-03
5
+ timestamp: 2026-11-04
6
6
  ---
7
7
 
8
8
  # Guide: notes
@@ -245,19 +245,31 @@ A meeting with a recording opens wide, in two columns. Press **Back to the side*
245
245
 
246
246
  - **Left:** the summary (two lines; press **Read it all** for the rest), then the kept points. Each point is one line. The icon shows its kind: a green dot is a decision, a box is a next step, a question mark is an open question. The header counts each kind. A next step said within two minutes after a decision, in the same topic, shows under that decision, with **Make a task**.
247
247
  - **Confirm:** press **✓** on a row to confirm it (press it again to undo). **Confirm N** in the header confirms every point that is left.
248
- - **Right:** press a row to see its evidence: the screen at that moment, the quote, and the screen captures near it. Press **▶** to play from the point. The pen opens the edit card. **Not right** moves the point to **Left out**.
248
+ - **Right:** one player. The screen capture at the playing time is at the top. Under it is the name of the chapter that plays, with **chapter N of M**. The bar under the name shows each chapter as one part, with a gap between parts, and an icon for each kept point in its chapter. Move the pointer over the bar to see the capture and the chapter at that place. Press the bar to play from there.
249
+ - **Controls:** back 15 seconds, play or pause, and forward 15 seconds, with **‹** and **›** for the previous and next chapter. The speed (1×, 1.5×, 2×) is at the left. At the right, the captures icon shows or hides the row of screen captures, and the chapters icon opens the list of chapters. Press a chapter in the list to play from its start. Escape closes the list.
250
+ - **Caption:** under the controls, the line said at the playing time shows larger, with the speaker. The line before it and the line after it show lighter.
251
+ - **A kept point:** press a row to play from that point. The selected row shows the pen (it opens the edit card) and **Not right** (it moves the point to **Left out**).
249
252
  - **Under the points:** one row of parts. Press one to open it: **Left out**, **Next steps**, **Since the last one**, **Facts mentioned**, **Names and terms**, **Prep and material** and **Transcript**.
250
- - **Bottom:** the timeline stays in view, with the full name of each chapter and a dot for each kept point.
251
253
 
252
- The captures row shows every kept screen capture. It does not yet show only the shared screen: that needs Jev for Images, which PMOS does not have yet.
254
+ The captures row is hidden until you press the captures icon. It shows the five captures near the playing time, and **+N more** shows the rest.
255
+
256
+ When Jev is on for the repository (Settings › **Label lines with Jev**) and Jev gave a box for a capture, the player and the captures row show only the slide or document in that box. The meeting window and the faces are cut off, and a capture that shows only faces is not in the captures row. The icon at the top right of the picture shows the whole frame. When Jev is off, or gave no box, you see the whole frame. Note: PMOS does not yet send the captures to Jev, so for now every capture shows the whole frame.
253
257
 
254
258
  ### Fix a word that was heard wrong
255
259
 
256
- In the summary, a quote, **Left out** or the transcript, select the word (double-click it, or drag over up to four words). The word becomes a field. Type the right spelling and press **✓** or Enter. The word shows fixed in every place this meeting shows it, and the message says how many places. **Undo** puts it back. **Names and terms** lists the people of the invite and the words you fixed.
260
+ Select a word or a few words anywhere in the meeting's notes: a kept point, the summary, **Next steps**, **Facts mentioned**, **Left out**, the caption or the transcript. You can also double-click one word. A small box opens under the words:
261
+
262
+ - **Heard as “…”** says what was heard. The field holds the fix. Type the right spelling and press **✓** or Enter. Escape closes the box and changes nothing.
263
+ - **✦ Claude suggests …** shows only when a real match was found: a fix you made before, a name in the meeting's invite (its title, agenda, material or people), or a word that the workspace's repository writes. The line names where the spelling came from. Press a spelling to put it in the field. When nothing matches, no suggestion shows.
264
+ - **N places in this meeting** counts where the words show, by kind: kept points, next steps, facts, **Left out** and transcript lines.
265
+
266
+ On a phone, the box opens at the bottom of the screen.
267
+
268
+ After the fix, the words show fixed in every place this meeting shows them, and the message says **Fixed in N places**, with **Undo**. The fix shows in **Names and terms**: the word, what was heard, the places, and an Undo icon.
257
269
 
258
- Later notes spell the word your way: the fix goes to your agent with the notes it writes. In other meetings, the same heard words are underlined. Press one to fix it there too, with your fix already typed.
270
+ **Names and terms** also asks **Remember “…” for your next meetings?** Press **✓** to remember it: later notes spell the word your way (the fix goes to your agent with the notes it writes), and in other meetings the same heard words are underlined, with your fix ready. Press **✕** to keep the fix in this meeting only. Until you press **✓**, the fix is for this meeting only. A fix made before this change stays remembered.
259
271
 
260
- The note's own files do not change. The fix shows where PMOS draws the note.
272
+ The note's own files do not change. The fix shows where PMOS draws the note. A one-time hint under the kept points says how to select a word. It goes away after your first fix.
261
273
 
262
274
  ### Fix or delete a key point
263
275
 
@@ -59,7 +59,9 @@ Each cycle is a document: Draft or Issued, with its output, its copy state and w
59
59
  - A download happens only when you press a download item.
60
60
  - You issue a report. An issued report is frozen.
61
61
  - While a draft is written, the report waits until the file is still, then shows it once. The slides do not flicker, and the scroll position stays.
62
- - When a report has an open draft, keep talking in the chat. Each change you ask for, such as a date, a wording or a look, goes to that draft by itself. You do not press anything. The draft updates beside the chat, and Claude says in one line what it passed. An issued report does not change. Change its template for the next cycle.
62
+ - When a report has an open draft, keep talking in the chat. Each change to its words, such as a date, a status or a wording, goes to that draft by itself. You do not press anything. The draft updates beside the chat, and Claude says in one line what it passed. An issued report does not change. Change its template for the next cycle.
63
+ - The report's session writes one line when it is done with your change: what it changed, or why it did not. The line shows in the report beside the chat. The report says "drafting it" only while a draft is written.
64
+ - A change to the look, such as the colours, the tables or the Gantt, or a deck or screenshot to follow, is not a change to the words. Attach the file in the chat if there is one. Claude starts the next look of the report from your words and the file. It shows in the report's template with ✓ and ✕. Press ✓ to keep it: the open draft is drawn again in the new look, and its numbers stay the same. Nothing is issued.
63
65
 
64
66
  ## Send a dashboard as a report
65
67
 
@@ -39,7 +39,7 @@
39
39
  * THE RECORD, <stateDir>/bg-queue.jsonl:
40
40
  * turn { at, kind: 'turn', job, step, go, why } a job's turn changed (it was held and now goes, or the reason changed). A steady
41
41
  * minute writes nothing.
42
- * done { at, kind: 'done', job, title, ws, ini?, session, meta? } a background session ended (ended(), from the server's one
42
+ * done { at, kind: 'done', job, title, ws, ini?, session, meta?, ref? } a background session ended (ended(), from the server's one
43
43
  * session-end hook). A session the PM started, or a chat, is not written.
44
44
  * status() is the last plan, for the Lab ("what runs next"). record({ ws, since }) is the done lines, newest first, for the Lab's
45
45
  * "What Claude did in the background" (lib/routes-lab.js): [{ job, title, meta?, at, ini?, board? }].
@@ -182,6 +182,8 @@ function create(host = {}) {
182
182
  const job = jobOfOrigin(s.origin);
183
183
  if (!job) return;
184
184
  const line = { at: new Date(now).toISOString(), kind: 'done', job, title: String(s.name || job).slice(0, 160), ws: s.ws || null, session: s.id || null };
185
+ const ref = String(s.origin).split(':').slice(1).join(':').slice(0, 120); // what the job was for (a report's id for report-design), for the Lab's words
186
+ if (ref) line.ref = ref;
185
187
  const ini = s.initiative || initiativeOfOrigin(s.origin);
186
188
  if (ini) line.ini = String(ini);
187
189
  if (s.outcome && typeof s.outcome === 'string') line.meta = s.outcome.slice(0, 160);
@@ -202,6 +204,8 @@ function create(host = {}) {
202
204
  if (l.meta) row.meta = l.meta;
203
205
  if (l.ini) row.ini = l.ini;
204
206
  if (l.board) row.board = l.board;
207
+ if (l.ref) row.ref = l.ref;
208
+ if (l.session) row.session = l.session;
205
209
  out.push(row);
206
210
  }
207
211
  return out.reverse();
@@ -23,7 +23,7 @@
23
23
  * topics: [{ id, text, src, state, minutes, n, first_t, stretches, parked, by }] visited ones first, in the order they came up
24
24
  * runs: [{ topic, n, from_ms, to_ms }]
25
25
  * notes: [{ id, t, cat, body, heard, why, src }] PMOS's notes with the time they were made
26
- * shots: [{ t, w, h, why?, text? }] kept shots only
26
+ * shots: [{ t, w, h, why?, text?, jev? }] kept shots only; jev = { kind, box? } when Jev for images read the shot
27
27
  * recordings: how many times the recording was started (1 for most notes)
28
28
  * gaps: [{ from_ms, to_ms }] where the recording was off between two starts (empty when there was one)
29
29
  * aside_count }
@@ -105,13 +105,22 @@ function timeOfKey(item, lines) {
105
105
  return null;
106
106
  }
107
107
 
108
+ /** What Jev for images said of a shot (batch 27 item 12, MpJ), when a shot's index row holds it: the kind ('content', a slide or a
109
+ * document; 'faces', only people) and the box around the useful area, as fractions of the frame [x, y, w, h]. Anything else is dropped. */
110
+ function jevOf(j) {
111
+ if (!j || typeof j !== 'object' || !['content', 'faces'].includes(j.kind)) return null;
112
+ const b = Array.isArray(j.box) && j.box.length === 4 && j.box.every((x) => Number.isFinite(x) && x >= 0 && x <= 1) ? j.box.map((x) => Math.round(x * 1000) / 1000) : null;
113
+ const box = b && b[2] >= 0.05 && b[3] >= 0.05 && b[0] + b[2] <= 1.001 && b[1] + b[3] <= 1.001 ? b : null;
114
+ return { kind: j.kind, ...(box && j.kind === 'content' ? { box } : {}) };
115
+ }
116
+
108
117
  /** Join the three records. */
109
118
  function build({ note, event = null, stateLines = [], frames = [], audio = null, topics = [], now = Date.now() } = {}) {
110
119
  const n = note || {};
111
120
  const lines = linesOf(n);
112
121
  const start = startOf(n, event);
113
122
  const shots = (frames || []).filter((f) => f && finite(f.t) && !f.aside).sort((a, b) => a.t - b.t)
114
- .map((f) => ({ t: f.t, w: f.w || 0, h: f.h || 0, ...(f.why ? { why: f.why } : {}), ...(f.text ? { text: str(f.text, SHOT_TEXT_MAX) } : {}) }));
123
+ .map((f) => ({ t: f.t, w: f.w || 0, h: f.h || 0, ...(f.why ? { why: f.why } : {}), ...(f.text ? { text: str(f.text, SHOT_TEXT_MAX) } : {}), ...(jevOf(f.jev) ? { jev: jevOf(f.jev) } : {}) }));
115
124
  const asideCount = (frames || []).filter((f) => f && f.aside).length;
116
125
 
117
126
  // Focus's state: topics, stretches, PMOS's notes. Stretches are measured from meeting time 0.
@@ -0,0 +1,105 @@
1
+ 'use strict';
2
+ /* meeting-suggest.js — the ✦ spelling the word fix offers (batch 27 item 12, word fix A: MpW1, MpWPhone). Local and read-only.
3
+ *
4
+ * suggest({ heard, fixes, invite, people, repo }) → { means, from: ['fix' | 'invite' | 'repo'], file?, alts: [spelling] } | null pure
5
+ * repoWords(root, { exclude }) → [{ w, file, n }] the names and terms the repository's Markdown writes (capitalised words, CamelCase,
6
+ * and two capitalised words in a row), cached with lib/notes-repo.js passages
7
+ *
8
+ * WHERE A SPELLING COMES FROM. Three places, and nothing else: a word the PM fixed before (the same heard words, remembered), the
9
+ * meeting's invite (its title, its people, its agenda and its material, as the page sends them), and the repository of the note's
10
+ * workspace (lib/notes-repo.js reads its Markdown; D79 exclusions are honoured). A spelling counts only when it is the heard words
11
+ * written another way ("Light box" → "Lightbox") or one or two letters away from them ("Lite box" → "Lightbox"), with the same first
12
+ * letter. When nothing matches, the answer is null and the page shows no ✦ line (zero is silence): a guess is never shown.
13
+ *
14
+ * WHAT IT NEVER DOES. It writes nothing and sends nothing. No model is asked. */
15
+ const notesRepo = require('./notes-repo.js');
16
+
17
+ const squash = (s) => String(s || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
18
+ const clean = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
19
+ function lev(a, b, max) {
20
+ if (Math.abs(a.length - b.length) > max) return max + 1;
21
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
22
+ for (let i = 1; i <= a.length; i++) {
23
+ const cur = [i]; let low = i;
24
+ for (let j = 1; j <= b.length; j++) { cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); low = Math.min(low, cur[j]); }
25
+ if (low > max) return max + 1;
26
+ prev = cur;
27
+ }
28
+ return prev[b.length];
29
+ }
30
+ /** The words as they sound, roughly: "light" and "lite" fold to "lit", "Scarlett" to "scarlet". English spelling only; nothing more clever. */
31
+ const fold = (s) => (String(s || '').toLowerCase().match(/[\p{L}\p{N}]+/gu) || []).map((w) => w.replace(/ght/g, 't').replace(/igh/g, 'i').replace(/ph/g, 'f').replace(/ck/g, 'k')
32
+ .replace(/(\p{L})\1/gu, '$1').replace(/^(.{3,})e$/u, '$1')).join('');
33
+ /** How far a spelling is from the heard words: 0 the same letters written another way, 1–2 letters away, or null (not the same word). */
34
+ function distance(heard, cand) {
35
+ const h = squash(heard), c = squash(cand);
36
+ if (h.length < 3 || c.length < 3 || h[0] !== c[0]) return null;
37
+ if (clean(heard) === clean(cand)) return null;
38
+ if (h === c) return 0;
39
+ const max = h.length >= 5 ? 2 : 0; // a short word must be the same letters: three or four letters one away is another word
40
+ const f = fold(heard), g = fold(cand);
41
+ const d = Math.min(lev(h, c, max), f.length >= 3 && f === g ? 1 : lev(f, g, max) + 1);
42
+ return d <= max ? d : null;
43
+ }
44
+ /** The names and terms a text writes: capitalised words and CamelCase (two letters or more), and two capitalised words in a row. */
45
+ function namesIn(text) {
46
+ const out = [];
47
+ const t = String(text || '');
48
+ const ws = t.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || [];
49
+ for (let i = 0; i < ws.length; i++) {
50
+ const w = ws[i].replace(/['’-]+$/, '');
51
+ const cap = /^\p{Lu}/u.test(w) || /\p{Ll}\p{Lu}/u.test(w);
52
+ if (cap && w.length >= 3) out.push(w);
53
+ const nx = ws[i + 1] ? ws[i + 1].replace(/['’-]+$/, '') : '';
54
+ if (cap && nx && /^\p{Lu}/u.test(nx)) out.push(w + ' ' + nx);
55
+ }
56
+ return out;
57
+ }
58
+ /** A spelling no common word has: CamelCase ("MoEngage", "ePaper"), capitals ("IDPF") or a digit. */
59
+ const distinct = (w) => /\p{Ll}\p{Lu}/u.test(w) || /^\p{Lu}{2,}$/u.test(w.replace(/\s+/g, '')) || /\d/.test(w);
60
+ const CACHE_MS = 60000;
61
+ const cache = new Map();
62
+ function repoWords(root, { exclude = [], now = Date.now() } = {}) {
63
+ if (!root) return [];
64
+ const key = root + '|' + JSON.stringify(exclude);
65
+ const hit = cache.get(key);
66
+ if (hit && now - hit.at < CACHE_MS) return hit.words;
67
+ const count = new Map();
68
+ for (const p of notesRepo.passages(root, { exclude, now })) {
69
+ for (const w of namesIn(p.text)) { const x = count.get(w); if (x) x.n++; else count.set(w, { w, file: p.path, n: 1 }); }
70
+ }
71
+ const words = [...count.values()];
72
+ cache.set(key, { at: now, words });
73
+ return words;
74
+ }
75
+
76
+ /** The best spelling for the heard words, and up to two others. fixes: [{ heard, means }] remembered fixes (phrase lower case);
77
+ * invite: [text] the meeting's title, agenda and material; people: [name] the invite's people; repo: repoWords(). */
78
+ function suggest({ heard, fixes = [], invite = [], people = [], repo = [] } = {}) {
79
+ const h = clean(heard);
80
+ if (!h || h.length > 60) return null;
81
+ const c = new Map(); // spelling → { means, d, from:Set, n, file }
82
+ const words = h.split(' ').length;
83
+ const add = (w, from, n = 1, file = '', person = false) => {
84
+ if (w.toLowerCase() === h.toLowerCase() && !distinct(w)) return; // "users" and "Users" are the same word
85
+ const d = from === 'fix' ? 0 : distance(h, w); if (d == null) return;
86
+ // letters away is a misheard NAME only: CamelCase, capitals or digits, a person of the invite, or several heard words that are one word
87
+ if (d > 0 && from !== 'fix' && !(distinct(w) || person || (words > 1 && !/\s/.test(w)))) return;
88
+ const k = clean(w); const x = c.get(k) || { means: k, d, from: new Set(), n: 0, file: '' };
89
+ x.d = Math.min(x.d, d); x.from.add(from); x.n += n; if (file && !x.file) x.file = file;
90
+ c.set(k, x);
91
+ };
92
+ const hp = squash(h);
93
+ for (const f of fixes) if (f && f.means && squash(f.heard) === hp && clean(f.means) !== h) add(f.means, 'fix');
94
+ for (const t of invite) for (const w of namesIn(t)) add(w, 'invite');
95
+ for (const p of people) for (const w of namesIn(p)) add(w, 'invite', 1, '', true);
96
+ for (const r of repo) if (r && r.w) add(r.w, 'repo', r.n || 1, r.file || '');
97
+ if (!c.size) return null;
98
+ const rank = (x) => (x.from.has('fix') ? 0 : 10) + x.d * 2 - Math.min(3, x.from.size) - Math.min(2, Math.log10(1 + x.n));
99
+ const list = [...c.values()].sort((a, b) => rank(a) - rank(b) || b.n - a.n || a.means.localeCompare(b.means));
100
+ const best = list[0];
101
+ return { means: best.means, from: ['fix', 'invite', 'repo'].filter((k) => best.from.has(k)), ...(best.from.has('repo') && best.file ? { file: best.file } : {}),
102
+ alts: list.slice(1).filter((x) => squash(x.means) !== squash(best.means) || x.means !== best.means).slice(0, 2).map((x) => x.means) };
103
+ }
104
+
105
+ module.exports = { suggest, repoWords, distance, namesIn, squash, fold };
@@ -1,34 +1,57 @@
1
1
  'use strict';
2
- /* meeting-terms.js — a word heard wrong, fixed in place (batch 25 item 11, "Meeting recording view C": MvF, MvN).
2
+ /* meeting-terms.js — a word heard wrong, fixed in place (batch 25 item 11 MvF/MvN; batch 27 item 12 word fix A: MpW1, MpW2, MpWPhone).
3
3
  *
4
- * The PM selects a word in a meeting's summary, a quote or the transcript ("More engaged") and types what it is ("MoEngage"). It is kept
5
- * as one of the lessons of lib/meeting-learn.js, of kind 'term': its phrase is the heard words, its meaning is the fix. So, with no new
6
- * record, the agent's handoff already carries it for later notes (meeting-learn.js handoffLines: "“more engaged”: MoEngage") and its
7
- * Undo is the lesson's forget. The page shows the fix in every place this note shows the words, and underlines the heard words in other
8
- * notes with the fix as the guess. The note's own files are NOT rewritten: rewriting every stored copy of a word is not proven.
4
+ * The PM selects a word or a few words in a meeting's notes ("Lite box") and fixes it ("Lightbox"). The fix shows at once in every place
5
+ * this note shows the words. Remembering it for LATER notes waits for the PM (batch 27 item 12): the ✦ ask "Remember “Lightbox” for your
6
+ * next meetings?" — ✓ makes it one of the lessons of lib/meeting-learn.js, of kind 'term' (its phrase is the heard words, its meaning the
7
+ * fix), so the agent's handoff carries it for later notes (meeting-learn.js handoffLines) and other notes underline the heard words with
8
+ * the fix as the guess. ✕ keeps it in this note only. A fix made before batch 27 was a lesson at once; it stays remembered.
9
+ * The note's own files are NOT rewritten: rewriting every stored copy of a word is not proven.
9
10
  *
10
- * term(dir, { note, heard, means, now }) → { ok, term, terms } | { ok: false, status, error }
11
- * terms(dir) → [{ id, heard, means, note, at }] the word fixes that hold now; the latest fix of the same words in a note wins
12
- * (forget: lib/meeting-learn.js forget({ lesson: <term id> }))
13
- * One line of <runtime>/meeting-lessons.jsonl per fix, append-only, file mode. Nothing here sends anything. */
11
+ * term(dir, { note, heard, means, now }) → { ok, term, terms } | { ok: false, status, error } the fix, in this note only (keep 'ask')
12
+ * keep(dir, { lesson, keep: true | false, now }) → { ok, term, terms } | { ok: false, status, error } the PM's answer to the ✦ ask
13
+ * terms(dir) → [{ id, heard, means, note, at, keep: 'yes' | 'ask' | 'no' }] the fixes that hold now; the latest fix of the same words in a note wins
14
+ * (Undo: lib/meeting-learn.js forget({ lesson: <term id> }), which this module honours too)
15
+ *
16
+ * THE FILE. <runtime>/meeting-lessons.jsonl (meeting-learn.js FILE), append-only, file mode: { t: 'term' } is a fix in its note,
17
+ * { t: 'lesson', kind: 'term' } with the same id is the fix remembered, { t: 'keep', lesson, keep: 'no' } is "this note only", and
18
+ * { t: 'forget', lesson } takes the fix away. Nothing here sends anything. */
14
19
  const fs = require('fs');
15
20
  const path = require('path');
16
21
  const crypto = require('crypto');
17
22
  const ML = require('./meeting-learn.js');
18
23
 
19
24
  const NOTE_ID = /^[A-Za-z0-9_-]{1,80}$/;
25
+ const LESSON_ID = /^l[0-9a-f]{10}$/;
20
26
  const MAX_WORD = 60;
21
27
  const clean = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
22
28
  /** The heard words as a lesson's phrase: lower case, the same words meeting-learn.js mentions() matches. */
23
29
  const phraseOf = (s) => (clean(s).toLowerCase().match(/[\p{L}\p{N}][\p{L}\p{N}.'-]*/gu) || []).map((w) => w.replace(/[.'-]+$/, '')).filter(Boolean).join(' ');
24
30
 
31
+ function readLines(dir) {
32
+ let text = '';
33
+ try { text = fs.readFileSync(path.join(dir, ML.FILE), 'utf8'); } catch { return []; }
34
+ const out = [];
35
+ for (const l of text.split('\n')) { if (!l.trim()) continue; try { const o = JSON.parse(l); if (o && typeof o === 'object') out.push(o); } catch { /* a torn line is skipped */ } }
36
+ return out;
37
+ }
38
+
25
39
  function terms(dir) {
40
+ if (!dir) return [];
41
+ const by = new Map();
42
+ for (const l of readLines(dir)) {
43
+ if (l.t === 'term' && l.id && l.phrase && l.meaning) by.set(l.id, { id: l.id, heard: l.phrase, means: l.meaning, note: l.note, at: l.at, keep: 'ask' });
44
+ else if (l.t === 'lesson' && l.kind === 'term' && l.id && l.phrase && l.meaning) {
45
+ const x = by.get(l.id);
46
+ if (x) x.keep = 'yes'; else by.set(l.id, { id: l.id, heard: l.phrase, means: l.meaning, note: l.note, at: l.at, keep: 'yes' });
47
+ } else if (l.t === 'keep' && by.has(l.lesson)) { const x = by.get(l.lesson); if (x.keep === 'ask') x.keep = 'no'; }
48
+ else if (l.t === 'forget' && l.lesson) by.delete(l.lesson);
49
+ }
26
50
  const out = [];
27
- for (const l of ML.lessons(dir)) {
28
- if (l.kind !== 'term' || !l.phrase || !l.meaning) continue;
29
- const i = out.findIndex((x) => x.note === l.note && x.heard === l.phrase);
51
+ for (const x of by.values()) {
52
+ const i = out.findIndex((y) => y.note === x.note && y.heard === x.heard);
30
53
  if (i >= 0) out.splice(i, 1);
31
- out.push({ id: l.id, heard: l.phrase, means: l.meaning, note: l.note, at: l.at });
54
+ out.push(x);
32
55
  }
33
56
  return out;
34
57
  }
@@ -46,8 +69,25 @@ function term(dir, body = {}) {
46
69
  const at = new Date(body.now || Date.now()).toISOString();
47
70
  const id = 'l' + crypto.createHash('sha1').update('term|' + note + '|' + heard + '|' + means + '|' + at).digest('hex').slice(0, 10);
48
71
  fs.mkdirSync(dir, { recursive: true });
49
- fs.appendFileSync(path.join(dir, ML.FILE), JSON.stringify({ v: 1, t: 'lesson', id, phrase: heard, kind: 'term', meaning: means, orig: null, note, point: '', at }) + '\n');
50
- return { ok: true, term: { id, heard, means, note, at }, terms: terms(dir) };
72
+ fs.appendFileSync(path.join(dir, ML.FILE), JSON.stringify({ v: 1, t: 'term', id, phrase: heard, meaning: means, note, at }) + '\n');
73
+ const all = terms(dir);
74
+ return { ok: true, term: all.find((x) => x.id === id), terms: all };
75
+ }
76
+
77
+ /** The PM's answer to "Remember “…” for your next meetings?": ✓ makes the fix a lesson for later notes, ✕ keeps it in its note. */
78
+ function keep(dir, body = {}) {
79
+ if (!dir) return { ok: false, status: 500, error: 'no runtime folder' };
80
+ const id = String(body.lesson || '');
81
+ if (!LESSON_ID.test(id)) return { ok: false, status: 400, error: 'lesson is the id of a word fix' };
82
+ if (typeof body.keep !== 'boolean') return { ok: false, status: 400, error: 'keep is true (remember it) or false (this meeting only)' };
83
+ const x = terms(dir).find((y) => y.id === id);
84
+ if (!x) return { ok: false, status: 404, error: 'no word fix has that id' };
85
+ const at = new Date(body.now || Date.now()).toISOString();
86
+ const line = body.keep ? { v: 1, t: 'lesson', id, phrase: x.heard, kind: 'term', meaning: x.means, orig: null, note: x.note, point: '', at }
87
+ : { v: 1, t: 'keep', lesson: id, keep: 'no', at };
88
+ if (body.keep || x.keep === 'ask') fs.appendFileSync(path.join(dir, ML.FILE), JSON.stringify(line) + '\n');
89
+ const all = terms(dir);
90
+ return { ok: true, term: all.find((y) => y.id === id), terms: all };
51
91
  }
52
92
 
53
- module.exports = { term, terms, MAX_WORD };
93
+ module.exports = { term, terms, keep, MAX_WORD, phraseOf };
@@ -31,7 +31,7 @@ const RULE = [
31
31
  ' A change to the draft of a set-up report (a date, a slide, a wording): {"kind":"report","id":"<its id>","change":"the change, in the PM\'s words"}. The page passes it to the report\'s own session by itself, with no press, and the draft updates beside this chat; nothing is issued. Do not change the draft yourself.',
32
32
  ' Every refinement the PM gives about a set-up report (the tab shows "Draft · <date>"), in this chat or later in it, is such a change: end with the change block, with the whole change in it, and say in ONE line what you passed, e.g. "Passed to the Nov 9 draft: tender stays open with no award date; Feature implementation not started."',
33
33
  ' Never say "press Draft it", "the proposal is unchanged" or "the report drafts in its own session" for a set-up report. If the draft needs something you do not know (a date), ask it once in the same answer and pass the change with what is known: the draft marks the unknown, it does not wait.',
34
- ' A look change to the open draft (a palette, tables, a Gantt style) is such a change too. Put the paths of any file the PM attached (a sample PDF, a screenshot) in the change text, so the report\'s session reads them. Several changes in a row each pass; say the draft is updating, never "the draft stays as it is".',
34
+ ' A LOOK change (a palette, colours, fonts, table format, a Gantt or chart style, "follow this deck or screenshot") is NOT a change to the draft text: the drafting session may not change the look. Send {"kind":"report","id":"<its id>","design":"the look, in the PM\'s words, complete"} and, when the PM attached a file to follow, "sample":"<its path>" (a .pdf, .pptx, .docx, .png or .jpg; one file, the newest). The page starts the next look version by itself; the PM keeps it with ✓ beside the chat and the open draft is redrawn in it, its numbers unchanged. Say that in one line. A message that asks for both sends the look as "design" and the words as "change" in one block.',
35
35
  ' An issued report (its latest cycle is issued) is frozen: pass nothing, and say the change goes into the template for the next cycle.',
36
36
  ' The set-up reports are the setup lines of report-setups.jsonl (planning/ or pmos/state/); a later change line wins, a remove line deletes one.',
37
37
  '- A chart of one measure: {"kind":"chart","proposal":{"measure":"<KR metric id>","title":"short title","question":"what it answers","form":"line|bars|number|meter","range":null,"target":null,"better":"higher|lower","note":""}}',
@@ -43,7 +43,7 @@ const RULE = [
43
43
  'If the message is not about a report, a chart or a dashboard, answer it and add no block.',
44
44
  ].join('\n');
45
45
 
46
- const REMIND = 'This chat is about a report. If it is set up (report-setups.jsonl, by name) every refinement is a change to its open draft: end with {"kind":"report","id":"<its id>","change":"the whole change, in the PM\'s words"} in a pmos-report block, the page passes it by itself, and say in one line what you passed and that the draft is updating (put the paths of attached files in the change). Never say "the draft stays as it is" or "press Draft it" or "the proposal is unchanged" for a set-up report; ask anything unknown once and pass what is known. An issued report is frozen: pass nothing. Same rules as before.';
46
+ const REMIND = 'This chat is about a report. If it is set up (report-setups.jsonl, by name) every refinement is a change to its open draft: end with {"kind":"report","id":"<its id>","change":"the whole change, in the PM\'s words"} in a pmos-report block, the page passes it by itself, and say in one line what you passed and that the draft is updating. A look change (palette, tables, Gantt, a deck or screenshot to follow) is "design":"the look" with "sample":"<the attached file path>" instead of "change": the page starts the next look and the PM keeps it with ✓. Never say "the draft stays as it is" or "press Draft it" or "the proposal is unchanged" for a set-up report; ask anything unknown once and pass what is known. An issued report is frozen: pass nothing. Same rules as before.';
47
47
 
48
48
  /** A later message in a chat that already carried the rule (the PM refines without saying "report"): the short reminder, then the PM's words. */
49
49
  function remind(text) { return MARK + ' ' + REMIND + '\n\n' + SAYS + String(text == null ? '' : text); }
@@ -57,7 +57,7 @@ function linkedNote(rep) {
57
57
  return [
58
58
  `This chat set up the report "${name}" (report id ${id}). The PM pressed Draft it already: the report is set up and its own session drafts it.${rep.draft ? ' Its latest draft: ' + String(rep.draft).slice(0, 200) + '.' : ''}`,
59
59
  'Read every message here as feedback on that draft unless it is plainly about something else. Never tell the PM to press Draft it again, and never say the proposal is unchanged.',
60
- `When the PM asks for a change to the draft in any words (a date, a status, a row, a slide, a wording, a point to keep open), answer in a few lines and end with {"kind":"report","id":"${id}","change":"the change, in the PM's words, complete"}. Put every point of the PM's message in "change"; it is all the report's session reads.`,
60
+ `A look change (palette, tables, a Gantt or chart style, a deck or screenshot to follow) is {"kind":"report","id":"${id}","design":"the look, complete","sample":"<the attached file's path, if any>"}: the page starts the next look and the PM keeps it with ✓. When the PM asks for a change to the draft's content in any words (a date, a status, a row, a slide, a wording, a point to keep open), answer in a few lines and end with {"kind":"report","id":"${id}","change":"the change, in the PM's words, complete"}. Put every point of the PM's message in "change"; it is all the report's session reads.`,
61
61
  'PMOS passes the block to the report\'s session as your answer lands, and the draft beside this chat changes. Say so in one line. When you need an answer from the PM first, ask it and add no block.',
62
62
  ].join('\n');
63
63
  }