create-pmos 0.527.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 (58) hide show
  1. package/package.json +57 -29
  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/adler-moments-test.js +4 -3
  7. package/workspace/briefing-premium-calendar-3-test.js +11 -9
  8. package/workspace/briefing-premium-calendar-test.js +1 -1
  9. package/workspace/changelog.json +56 -0
  10. package/workspace/ev-everywhere-test.js +2 -6
  11. package/workspace/fixtures/premium-viewer-fixtures.js +4 -2
  12. package/workspace/guide/home.md +7 -6
  13. package/workspace/guide/idle-lab.md +1 -1
  14. package/workspace/guide/notes.md +19 -7
  15. package/workspace/guide/reports-set-up.md +4 -1
  16. package/workspace/lib/bg-queue.js +10 -3
  17. package/workspace/lib/meeting-replay.js +11 -2
  18. package/workspace/lib/meeting-suggest.js +105 -0
  19. package/workspace/lib/meeting-terms.js +57 -17
  20. package/workspace/lib/okr-goals.js +23 -3
  21. package/workspace/lib/report-chat.js +3 -3
  22. package/workspace/lib/routes-lab.js +44 -2
  23. package/workspace/lib/routes-meeting-points.js +27 -3
  24. package/workspace/lib/routes-morning-goals.js +5 -1
  25. package/workspace/lib/routes-report-flow.js +35 -5
  26. package/workspace/news-viewers-test.js +2 -1
  27. package/workspace/package-lock.json +2 -2
  28. package/workspace/package.json +1 -1
  29. package/workspace/premium-vw-meet-test.js +17 -11
  30. package/workspace/product-lane-test.js +1 -1
  31. package/workspace/program-charter-test.js +2 -2
  32. package/workspace/public/pm/bd-ini-panel.js +1 -1
  33. package/workspace/public/pm/bd-span.js +10 -7
  34. package/workspace/public/pm/cozy-board.css +1 -0
  35. package/workspace/public/pm/cozy-lab-ins.js +13 -4
  36. package/workspace/public/pm/cozy-search.js +10 -17
  37. package/workspace/public/pm/cozy-settings.js +8 -2
  38. package/workspace/public/pm/cozy-today.css +30 -14
  39. package/workspace/public/pm/cozy-vw-mtg.css +122 -24
  40. package/workspace/public/pm/good-morning.js +30 -17
  41. package/workspace/public/pm/home-app.js +11 -1
  42. package/workspace/public/pm/home-data.js +4 -1
  43. package/workspace/public/pm/home-day.js +12 -5
  44. package/workspace/public/pm/premium-board.js +7 -5
  45. package/workspace/public/pm/premium-initiative-head.js +9 -1
  46. package/workspace/public/pm/premium-insights.js +5 -3
  47. package/workspace/public/pm/premium-meeting.js +2 -1
  48. package/workspace/public/pm/premium-mtg-replay.js +149 -73
  49. package/workspace/public/pm/premium-report-read.js +11 -4
  50. package/workspace/public/pm/premium-report-tab.js +33 -4
  51. package/workspace/public/pm/premium-vw-mtg.js +176 -53
  52. package/workspace/public/pm/program-model.js +2 -1
  53. package/workspace/public/pm/report-chat.js +1 -0
  54. package/workspace/public/pm/report-flow.js +7 -3
  55. package/workspace/public/premium-board.html +4 -4
  56. package/workspace/server.js +1 -1
  57. package/workspace/task-milestones-test.js +2 -2
  58. package/workspace/timeline-holds-test.js +3 -3
@@ -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 };
@@ -154,12 +154,31 @@ function read(root) {
154
154
  function matchAnchor(anchor, objectives) {
155
155
  const s = String(anchor || '').replace(/\*\*/g, '').trim();
156
156
  if (!s || /^(health|domain):/i.test(s)) return null;
157
+ // by name as well as by id (workspace grill W4): an anchor that names a key result's words is that key result, even when its id is
158
+ // missing, names two key results, or is a number from an older file that now belongs to another key result
159
+ const named = byName(s, objectives);
157
160
  const km = new RegExp('(?:^|[^A-Za-z0-9])' + KR + END).exec(s);
158
- if (!km) return null;
161
+ if (!km) return named;
159
162
  const om = new RegExp('(?:^|[^A-Za-z0-9])' + OBJ + END).exec(s.slice(0, km.index + 1));
160
163
  const kid = krId(km[1]), oid = om ? objId(om[1]) : null;
161
164
  const hits = [];
162
165
  for (const o of objectives || []) { if (oid && o.id !== oid) continue; const k = o.krs.find((x) => x.id === kid); if (k) hits.push({ o, k }); }
166
+ if (named) return named;
167
+ return hits.length === 1 ? hits[0] : null;
168
+ }
169
+ /** Words compared as words: lower case, letters and digits only, one space between. */
170
+ const words = (t) => String(t || '').toLowerCase().replace(/\([^)]*\)/g, ' ').replace(/[^a-z0-9]+/g, ' ').trim();
171
+ /** The one key result whose name the text holds, or whose name holds the text (two words and 12 letters or more, so "KR2" or one word never match); null
172
+ * when none or more than one does. Ids are taken out of the text first, so only the words compare. PURE. */
173
+ function byName(text, objectives) {
174
+ const t = ' ' + words(String(text || '').replace(new RegExp('\\b(?:' + OBJ + '|' + KR + ')' + END, 'g'), ' ')) + ' ';
175
+ if (t.trim().length < 8) return null;
176
+ const part = t.trim().length >= 12 && t.trim().split(' ').length >= 2; // a part of a name: two words or more, so one word never joins
177
+ const hits = [];
178
+ for (const o of objectives || []) for (const k of o.krs || []) {
179
+ const n = words(k.title);
180
+ if (n.length >= 8 && (t.includes(' ' + n + ' ') || (part && (' ' + n + ' ').includes(t)))) hits.push({ o, k });
181
+ }
163
182
  return hits.length === 1 ? hits[0] : null;
164
183
  }
165
184
 
@@ -181,7 +200,8 @@ function build({ okr, goals, initiatives, records, today }) {
181
200
  const t = Date.parse(String(today || '').slice(0, 10)) || Date.now();
182
201
  const P = okr.period ? Object.assign({}, okr.period, { day: t >= okr.period.from && t <= okr.period.to ? Math.round((t - okr.period.from) / DAY) + 1 : 0, days: Math.round((okr.period.to - okr.period.from) / DAY) + 1, fromAt: iso(okr.period.from), toAt: iso(okr.period.to) }) : null;
183
202
  const moversOf = new Map();
184
- for (const r of initiatives || []) { const m = matchAnchor(r.anchor, okr.objectives); if (!m) continue; const key = m.o.id + '::' + m.k.id; if (!moversOf.has(key)) moversOf.set(key, []); moversOf.get(key).push(r); }
203
+ // an initiative with no anchor that resolves, titled with a key result's name, moves that key result (workspace grill W4: the join is by id and by name)
204
+ for (const r of initiatives || []) { const m = matchAnchor(r.anchor, okr.objectives) || (/^(health|domain):/i.test(String(r.anchor || '').trim()) ? null : byName(r.title, okr.objectives)); if (!m) continue; const key = m.o.id + '::' + m.k.id; if (!moversOf.has(key)) moversOf.set(key, []); moversOf.get(key).push(r); }
185
205
  const objectives = okr.objectives.map((o) => ({
186
206
  id: o.id, token: o.token, title: o.title,
187
207
  krs: o.krs.map((k) => {
@@ -197,4 +217,4 @@ function build({ okr, goals, initiatives, records, today }) {
197
217
  return { files: okr.files, period: P, objectives, total: krs.length, read: krs.filter((k) => k.read).length, needs, readers: [...used] };
198
218
  }
199
219
 
200
- module.exports = { read, parse, periodOf, dueOf, sourceOf, needOf, matchAnchor, readerOf, build };
220
+ module.exports = { read, parse, periodOf, dueOf, sourceOf, needOf, matchAnchor, byName, readerOf, build };
@@ -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
  }
@@ -35,19 +35,61 @@ function titles(w) {
35
35
  try { const b = require('./routes-boards.js').baseRows(w); if (b && b.ok) for (const r of b.rows || []) if (r && r.id) m.set(r.id, { title: r.title || r.id, board: r.board || '' }); } catch { /* none */ }
36
36
  return m;
37
37
  }
38
+ // Why a grill run started, in plain words (lib/grill-runs.js why), for "Checked <initiative> for risks · <why> · <what it found>"
39
+ // (Wawan, 2026-11-04: "I don't understand" "Grill · … why: monthly").
40
+ const GRILL_WHY = { monthly: 'monthly check', ask: 'you asked', exit: 'it passed a stage', forecast: 'its forecast moved', move: 'its tasks or end date moved', plan: 'its PRD changed' };
41
+ const plural = (n, one, many) => n + ' ' + (n === 1 ? one : many);
42
+ /** A grill run's findings: filed by the run (proposals.js propose lines carry the run), and the ones still waiting for the PM. */
43
+ function grillFound(P, run) {
44
+ let filed = 0, waiting = 0;
45
+ if (!P) return { filed, waiting };
46
+ const kept = PR.keptIds(P);
47
+ for (const p of P.props.values()) if (p.run === run) { filed++; if (!kept.has(p.id) && !P.drops.has(PR.sigOf(p))) waiting++; }
48
+ return { filed, waiting };
49
+ }
50
+ /** A report job of the shared queue (origin report-design:<id>), named with the report: "Redrew TSC Monthly in its saved look". */
51
+ function reportRow(x, root, runtimeDir) {
52
+ let name = '';
53
+ // a line written before the queue kept `ref`: the session's own record still names its origin
54
+ if (!x.ref && x.session && runtimeDir && /^[A-Za-z0-9-]{1,40}$/.test(x.session)) {
55
+ try { const o = JSON.parse(fs.readFileSync(require('path').join(runtimeDir, 'sessions', x.session + '.json'), 'utf8')).origin; if (typeof o === 'string' && o.startsWith('report-design:')) x = { ...x, ref: o.slice(14) }; } catch { /* the session record is gone */ }
56
+ }
57
+ // or the cycle render it wrote: a <slot>.design.json written within two minutes of the job's end
58
+ if (!x.ref && root) {
59
+ try {
60
+ const path = require('path'), dir = path.join(root, require('./report-draft.js').stateRel(root), 'reports'), end = Date.parse(x.at);
61
+ let best = null;
62
+ for (const id of fs.readdirSync(dir)) {
63
+ let files; try { files = fs.readdirSync(path.join(dir, id)); } catch { continue; }
64
+ for (const f of files) if (f.endsWith('.design.json')) { const d = Math.abs(fs.statSync(path.join(dir, id, f)).mtimeMs - end); if (d <= 120000 && (!best || d < best.d)) best = { id, d }; }
65
+ }
66
+ if (best) x = { ...x, ref: best.id };
67
+ } catch { /* no reports folder */ }
68
+ }
69
+ try { if (x.ref && root) { const s = require('./report-setups.js').stateOf(root).setups.get(x.ref); name = s && s.name ? s.name : ''; } } catch { name = ''; }
70
+ const what = /cycle/.test(x.title || '') ? 'Redrew ' + (name || 'a report') + ' in its saved look' : 'Made the next look of ' + (name || 'a report');
71
+ return { job: x.job, title: what, meta: '', at: x.at };
72
+ }
38
73
  /** What the other background jobs did this week, as record rows. Each source that cannot be read is left out. */
39
74
  function others(ctx, w, dir, T, now) {
40
75
  const out = [], since = now - WEEK, name = (ini) => (T.get(ini) || {}).title || ini;
41
76
  try {
42
77
  const G = require('./grill.js'), F = ctx.runtimeDir ? G.read(ctx.runtimeDir) : null;
43
- for (const r of F ? F.runs.values() : []) if (r.ended && r.ok !== false && r.ws === w.id && (Date.parse(r.endAt || r.at) || 0) >= since) out.push({ job: 'grill', title: 'Grill · ' + name(r.ini), meta: 'why: ' + r.why, at: r.endAt || r.at, ini: r.ini, board: r.board || '' });
78
+ const P = (() => { try { return dir ? PR.read(dir) : null; } catch { return null; } })();
79
+ for (const r of F ? F.runs.values() : []) if (r.ended && r.ok !== false && r.ws === w.id && (Date.parse(r.endAt || r.at) || 0) >= since) {
80
+ const f = grillFound(P, r.run);
81
+ const found = !f.filed ? 'nothing found' : f.waiting === f.filed ? plural(f.filed, 'finding', 'findings') + ' waiting'
82
+ : plural(f.filed, 'finding', 'findings') + (f.waiting ? ', ' + f.waiting + ' waiting' : ', all seen');
83
+ // the initiative chip only when something waits there for the PM
84
+ out.push({ job: 'grill', title: 'Checked ' + name(r.ini) + ' for risks', meta: [GRILL_WHY[r.why] || r.why, found].filter(Boolean).join(' · '), at: r.endAt || r.at, ...(f.waiting ? { ini: r.ini, board: r.board || '' } : {}) });
85
+ }
44
86
  } catch { /* no grill log */ }
45
87
  try {
46
88
  // the AI jobs' audit (lib/ai-guard.js AUDIT), read as a file: this page files nothing through the guard
47
89
  const audit = dir ? fs.readFileSync(require('path').join(dir, 'ai-audit.jsonl'), 'utf8').split('\n').slice(-400).map((s) => { try { return JSON.parse(s); } catch { return null; } }).filter(isObj) : [];
48
90
  for (const l of audit) if (l.event === 'end' && (Date.parse(l.at) || 0) >= since) out.push({ job: l.job, title: (JOB_NAMES[l.job] || l.job), meta: l.filed != null ? l.filed + ' filed' : '', at: l.at });
49
91
  } catch { /* no audit */ }
50
- try { if (ctx.bgQueue && typeof ctx.bgQueue.record === 'function') for (const x of ctx.bgQueue.record({ ws: w.id, since }) || []) if (isObj(x) && x.job !== 'grill') out.push(x); /* grill runs are read above, from their own log */ } catch { /* the queue says nothing */ }
92
+ try { if (ctx.bgQueue && typeof ctx.bgQueue.record === 'function') for (const x of ctx.bgQueue.record({ ws: w.id, since }) || []) if (isObj(x) && x.job !== 'grill') out.push(x.job === 'report-design' ? reportRow(x, w.root, ctx.runtimeDir) : x); /* grill runs are read above, from their own log */ } catch { /* the queue says nothing */ }
51
93
  return out;
52
94
  }
53
95
 
@@ -10,10 +10,14 @@
10
10
  * POST /api/meeting-points/forget { lesson } → { ok } Undo of what PMOS learned, and of a word fix (its term id)
11
11
  * POST /api/meeting-points/term { note, heard, means } → { ok, term, terms } a word fixed in place, in every place the note shows it
12
12
  * The GET answers { terms } too: the word fixes that hold now (lib/meeting-terms.js).
13
+ * POST /api/meeting-points/keep { lesson, keep } → { ok, term, terms } the ✦ ask: remember a word fix for the next meetings (true) or not
14
+ * GET /api/meeting-points/suggest?note=<id>&heard=<words>&invite=<text>…&people=<name>… → { ok, suggest: { means, from, file?, alts } | null }
15
+ * the spelling a word fix offers (lib/meeting-suggest.js): a fix remembered before, the invite the page sends, the workspace's repository
13
16
  * The POSTs are page presses (lib/pm-guard.js requirePagePress). Handlers are pure over (q | body, ctx) so a test needs no server. */
14
17
  const MP = require('./meeting-points.js');
15
18
  const ML = require('./meeting-learn.js');
16
19
  const MT = require('./meeting-terms.js');
20
+ const MS = require('./meeting-suggest.js');
17
21
  const guard = require('./pm-guard.js');
18
22
 
19
23
  const fail = (status, error) => ({ status, body: { ok: false, error } });
@@ -26,7 +30,7 @@ function read(q, ctx) {
26
30
  function learn(req, body, ctx, op) {
27
31
  const g = guard.requirePagePress(req, body);
28
32
  if (!g.ok) return fail(g.status, g.error);
29
- const r = op === 'forget' ? ML.forget(ctx.runtimeDir, body) : op === 'term' ? MT.term(ctx.runtimeDir, body) : ML.edit(ctx.runtimeDir, body);
33
+ const r = op === 'forget' ? ML.forget(ctx.runtimeDir, body) : op === 'term' ? MT.term(ctx.runtimeDir, body) : op === 'keep' ? MT.keep(ctx.runtimeDir, body) : ML.edit(ctx.runtimeDir, body);
30
34
  if (!r.ok) return fail(r.status || 400, r.error);
31
35
  return { status: 200, body: r };
32
36
  }
@@ -37,13 +41,33 @@ function write(req, body, ctx) {
37
41
  return r.ok ? { status: 200, body: { ok: true, points: r.points } } : fail(r.status || 400, r.error);
38
42
  }
39
43
 
44
+ /** The ✦ spelling for heard words: the remembered fixes, the invite's words the page sends, and the note's workspace repository. */
45
+ function suggest(q, ctx, all = {}) {
46
+ const note = String(q.note || ''), heard = String(q.heard || '').replace(/\s+/g, ' ').trim();
47
+ if (!/^[A-Za-z0-9_-]{1,80}$/.test(note)) return fail(400, 'note is the id of a meeting note');
48
+ if (!heard || heard.length > MT.MAX_WORD) return fail(400, 'heard is the words to fix, at most ' + MT.MAX_WORD + ' characters');
49
+ const list = (k) => (Array.isArray(all[k]) ? all[k] : []).map((x) => String(x).slice(0, 300)).slice(0, 60);
50
+ let repo = [];
51
+ try {
52
+ const n = require('./notes.js').load(ctx.runtimeDir, note);
53
+ const w = n && n.ws && typeof ctx.getWs === 'function' ? ctx.getWs(n.ws) : null;
54
+ if (w && w.root) repo = MS.repoWords(w.root, { exclude: require('./typesafe-settings.js').getRepo(ctx.runtimeDir, w.root).exclude || [] });
55
+ } catch { repo = []; } // no repository to read: the other places still answer
56
+ const fixes = MT.terms(ctx.runtimeDir).filter((x) => x.keep === 'yes' || x.note === note);
57
+ return { status: 200, body: { ok: true, suggest: MS.suggest({ heard, fixes, invite: list('invite'), people: list('people'), repo }) } };
58
+ }
59
+
40
60
  const respond = (ctx, res, r) => { ctx.json(res, r.body, r.status); return true; };
41
61
 
42
62
  module.exports = {
43
63
  prefixes: ['/api/meeting-points'],
44
- read, write, learn,
64
+ read, write, learn, suggest,
45
65
  handle: async (req, res, url, ctx) => {
46
- const op = url.pathname === '/api/meeting-points/edit' ? 'edit' : url.pathname === '/api/meeting-points/forget' ? 'forget' : url.pathname === '/api/meeting-points/term' ? 'term' : '';
66
+ if (url.pathname === '/api/meeting-points/suggest') {
67
+ if (req.method !== 'GET') return respond(ctx, res, fail(405, 'GET only'));
68
+ return respond(ctx, res, suggest(Object.fromEntries(url.searchParams), ctx, { invite: url.searchParams.getAll('invite'), people: url.searchParams.getAll('people') }));
69
+ }
70
+ const op = { '/api/meeting-points/edit': 'edit', '/api/meeting-points/forget': 'forget', '/api/meeting-points/term': 'term', '/api/meeting-points/keep': 'keep' }[url.pathname] || '';
47
71
  if (op) {
48
72
  if (req.method !== 'POST') return respond(ctx, res, fail(405, 'POST only'));
49
73
  const got = await ctx.readJson(req);
@@ -44,7 +44,11 @@ async function read(ctx, req, want) {
44
44
  for (const p of await productsOf(ctx, req, want)) {
45
45
  const m = await modelOf(ctx, req, p.ws);
46
46
  if (!m) { missing.push(p.name); continue; }
47
- products.push({ ws: p.ws, name: (m.board && m.board.name && m.board.name !== 'Intelligence' ? m.board.name : p.name), goals: m.goals || [], model: m });
47
+ // the goal an initiative moves: the goal's own moves, and the OKR file's link (an initiative anchored to a key result that this goal reads)
48
+ const via = new Map();
49
+ for (const o of (m.okrFile && m.okrFile.objectives) || []) for (const k of o.krs || []) if (k && k.goal) for (const r of k.moves || []) if (r && r.id) (via.get(k.goal) || via.set(k.goal, []).get(k.goal)).push({ id: r.id });
50
+ const goals = (m.goals || []).map((g) => (g && via.has(g.id) ? { ...g, moves: [...(g.moves || []), ...via.get(g.id)] } : g));
51
+ products.push({ ws: p.ws, name: (m.board && m.board.name && m.board.name !== 'Intelligence' ? m.board.name : p.name), goals, model: m });
48
52
  }
49
53
  const list = MG.collect(products);
50
54
  const dir = ctx.runtimeDir || null;
@@ -55,6 +55,19 @@ function cyclesOf(root, id) {
55
55
  }
56
56
  function readText(root, rel) { try { return fs.readFileSync(path.join(path.resolve(String(root)), rel), 'utf8'); } catch { return null; } }
57
57
  function sessionFile(root, id) { return path.join(path.dirname(RT.ledgerOf(root, id)), 'session.json'); }
58
+ // The drafting session's answer to a change (Wawan, 2026-11-04: it declined a look change in its own terminal and the panel said "drafting it"
59
+ // for good). ask() writes ask.json { at, change } and asks the session to write answer.json { text } when it is done or does nothing;
60
+ // stateOf() shows the answer once it is newer than the ask, and says the change is with the session until then.
61
+ const askFile = (root, id) => path.join(path.dirname(RT.ledgerOf(root, id)), 'ask.json');
62
+ const answerFile = (root, id) => path.join(path.dirname(RT.ledgerOf(root, id)), 'answer.json');
63
+ function readJson(f) { try { const o = JSON.parse(fs.readFileSync(f, 'utf8')); return isObj(o) ? o : null; } catch { return null; } }
64
+ function answerOf(root, id) {
65
+ const a = readJson(askFile(root, id)); if (!a || !a.at) return { asking: null, answer: null };
66
+ let st = null; try { st = fs.statSync(answerFile(root, id)); } catch { st = null; }
67
+ const ans = st && st.mtimeMs >= Date.parse(a.at) ? readJson(answerFile(root, id)) : null;
68
+ const txt = ans && typeof ans.text === 'string' ? ans.text.replace(/\s+/g, ' ').trim().slice(0, 400) : '';
69
+ return txt ? { asking: null, answer: { text: txt, at: new Date(st.mtimeMs).toISOString(), asked: a.at } } : { asking: { at: a.at }, answer: null };
70
+ }
58
71
  function readSession(root, id) { try { const o = JSON.parse(fs.readFileSync(sessionFile(root, id), 'utf8')); return isObj(o) && typeof o.session === 'string' ? o : null; } catch { return null; } }
59
72
  const alive = (ctx, sid) => !!sid && !!(ctx.chatSessions && typeof ctx.chatSessions.alive === 'function' && ctx.chatSessions.alive(sid));
60
73
  function draftingOf(ctx, w, id) { try { return typeof ctx.structureDraftState === 'function' ? ctx.structureDraftState(w, id) || null : null; } catch { return null; } }
@@ -81,6 +94,7 @@ function stateOf(ctx, w, id, now = Date.now()) {
81
94
  template: { current: f.current, versions: f.versions.map((v) => ({ ...v, words: RT.settingsWords(v.settings, names), cycles: counts.get(v.n) || 0 })) },
82
95
  cycles, latest, drafting,
83
96
  session: sess ? { id: sess.session, alive: alive(ctx, sess.session), since: sess.since || null } : null,
97
+ ...answerOf(root, id),
84
98
  review,
85
99
  sections: { saved: live.structure, titles: names.sections(live.structure) || (s && Array.isArray(s.sections) ? s.sections.slice() : []) },
86
100
  look: lookOf(root, id, live.design, names),
@@ -152,18 +166,23 @@ function draftWords(id, rel) {
152
166
  return rel && slot ? `The draft: ${rel} (slot ${slot}). Change that file in place: edit only the note or notes the change is about, and keep every caveat. `
153
167
  + `Do not redraft for a change to the words. Only when the PM asks for fresh numbers, run \`pmos report draft ${id} ${slot} --redraft\`; never \`pmos report draft ${id}\` without the slot, and do not draft a new slot.` : '';
154
168
  }
169
+ /** The last line of every change: where the session says what it did, so the PM reads it beside the chat (never only in this terminal). */
170
+ function answerWords(root, id) {
171
+ return `When you are done, or if you change nothing, write ONE JSON object {"text":"<one or two plain sentences for the PM: what you changed, or why you did not>"} to ${answerFile(root, id)}. A look change (palette, tables, a Gantt style) is not yours: say it goes in the report's look, which the PM keeps with ✓ beside the chat.`;
172
+ }
155
173
  /** The words the report's session gets for one change. */
156
- function askPrompt(skill, id, rel, change) {
174
+ function askPrompt(skill, id, rel, change, root) {
157
175
  return `${skill}\n\nThe PM's request for this run, given when it was scheduled (do not ask again): ${id}\n\n`
158
176
  + `This is the report's own drafting session (the PMOS report flow), not the PM's chat. ${rel ? draftWords(id, rel) + ' ' : ''}`
159
- + `The change the PM asked for: ${change}\nChange only the draft. Never issue, send or share it: the PM issues it on the page.`;
177
+ + `The change the PM asked for: ${change}\nChange only the draft. Never issue, send or share it: the PM issues it on the page. ${answerWords(root, id)}`;
160
178
  }
161
179
  function ask(ctx, w, id, change, rel) {
162
180
  const root = w.root;
163
181
  const d = draftingOf(ctx, w, id);
164
182
  const sess = readSession(root, id);
165
183
  const target = d && d.state === 'drafting' && alive(ctx, d.session) ? d.session : sess && alive(ctx, sess.session) ? sess.session : null;
166
- const msg = `[PMOS report change] ${rel ? draftWords(id, rel) + ' ' : ''}The change: ${change}. Change only the draft; never issue, send or share it.`;
184
+ try { fs.mkdirSync(path.dirname(askFile(root, id)), { recursive: true }); fs.writeFileSync(askFile(root, id), JSON.stringify({ at: new Date().toISOString(), change: String(change).slice(0, 600) }) + '\n'); } catch { /* the panel then shows no answer */ }
185
+ const msg = `[PMOS report change] ${rel ? draftWords(id, rel) + ' ' : ''}The change: ${change}. Change only the draft; never issue, send or share it. ${answerWords(root, id)}`;
167
186
  if (target) {
168
187
  const sent = typeof ctx.sendToSession === 'function' && ctx.sendToSession(target, msg);
169
188
  if (sent) return { ok: true, session: target, path: rel, passed: true };
@@ -174,7 +193,7 @@ function ask(ctx, w, id, change, rel) {
174
193
  const origin = 'report-session:' + id;
175
194
  if (typeof ctx.endSupersededRuns === 'function') { try { ctx.endSupersededRuns(origin); } catch { /* the old one ends by itself */ } }
176
195
  const s = setupOf(root, id);
177
- const sp = ctx.spawnSession({ name: 'Report · ' + ((s && s.name) || id), agent: 'claude', ws: w.id, origin, initialInput: askPrompt(skill, id, rel, change) + '\r' });
196
+ const sp = ctx.spawnSession({ name: 'Report · ' + ((s && s.name) || id), agent: 'claude', ws: w.id, origin, initialInput: askPrompt(skill, id, rel, change, root) + '\r' });
178
197
  if (!sp || sp.state === 'exited' || !sp.id) return { ok: false, status: 409, error: 'The report\'s session did not start: ' + ((sp && sp.note) || 'no session') };
179
198
  try { fs.mkdirSync(path.dirname(sessionFile(root, id)), { recursive: true }); fs.writeFileSync(sessionFile(root, id), JSON.stringify({ session: sp.id, since: new Date().toISOString() }) + '\n'); } catch { /* shown as no session */ }
180
199
  return { ok: true, session: sp.id, path: rel, started: true };
@@ -250,7 +269,18 @@ async function handle(req, res, url, ctx) {
250
269
  if (!ar.ok) return fail(ar);
251
270
  const names = RT.namesOf(w.root, id);
252
271
  const v = RT.addVersion(w.root, id, next, { names, why: RT.diffWords(was, next, names).join('; ').slice(0, 200) });
253
- return v.ok ? send({ status: 200, body: { ok: true, n: v.n, state: stateOf(ctx, w, id) } }) : fail(v);
272
+ if (!v.ok) return fail(v);
273
+ // a new look kept with ✓ redraws the open draft in it at once, as the design studio's "This is it" does (Wawan, 2026-11-04: the look asked
274
+ // for in the chat never reached the draft). Only the look is redrawn: the text and its numbers stay; an issued cycle keeps its look.
275
+ let redraw = null;
276
+ if (ar.done && ar.done.design && next.design != null) {
277
+ const cur = cyclesOf(w.root, id)[0];
278
+ if (cur && cur.state === 'draft') {
279
+ let r; try { r = require('./routes-report-design.js').startCycle(ctx, w, id, cur.path); } catch (e) { r = { ok: false, error: String((e && e.message) || e) }; }
280
+ redraw = { path: cur.path, slot: cur.slot, started: !!(r && r.ok), ...(r && r.ok ? {} : { reason: String((r && r.error) || '') }) };
281
+ } else if (cur) redraw = { path: cur.path, slot: cur.slot, started: false, reason: 'sent' };
282
+ }
283
+ return send({ status: 200, body: { ok: true, n: v.n, redraw, state: stateOf(ctx, w, id) } });
254
284
  }
255
285
  if (sub === 'restore') {
256
286
  const f = RT.backfill(w.root, id, RT.fold(RT.readLines(w.root, id))), v = f.versions.find((x) => x.n === +body.n);
@@ -152,7 +152,8 @@ const ROWS = { 'psle-article-paywall-lock': { title: 'PSLE Article Paywall Lock'
152
152
  await sleep(800);
153
153
  await p.click('[data-hm-day]');
154
154
  await until(() => p.evaluate(() => !!document.querySelector('#czViewer [data-yd="fin"]')));
155
- await p.evaluate(() => { const f = document.querySelector('#czViewer [data-czs-fold="drafts"]'); if (f && f.getAttribute('aria-expanded') !== 'true') f.click(); });
155
+ // Good morning says "N drafted for you" once: its line opens the fold (batch 27 item 10)
156
+ await p.click('#czViewer [data-yd="gm-fold"][data-fold="drafts"]');
156
157
  await p.click('#czViewer [data-yd="fin"]');
157
158
  await until(() => p.evaluate(() => /Project Ascent status summary/.test((document.querySelector('#czVwTitle') || {}).textContent || '')));
158
159
  const t = await vwText(p);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.527.0",
3
+ "version": "0.529.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.527.0",
9
+ "version": "0.529.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@anthropic-ai/sdk": "^1.3.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.527.0",
3
+ "version": "0.529.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",