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.
- package/package.json +57 -29
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/adler-moments-test.js +4 -3
- package/workspace/briefing-premium-calendar-3-test.js +11 -9
- package/workspace/briefing-premium-calendar-test.js +1 -1
- package/workspace/changelog.json +56 -0
- package/workspace/ev-everywhere-test.js +2 -6
- package/workspace/fixtures/premium-viewer-fixtures.js +4 -2
- package/workspace/guide/home.md +7 -6
- package/workspace/guide/idle-lab.md +1 -1
- package/workspace/guide/notes.md +19 -7
- package/workspace/guide/reports-set-up.md +4 -1
- package/workspace/lib/bg-queue.js +10 -3
- package/workspace/lib/meeting-replay.js +11 -2
- package/workspace/lib/meeting-suggest.js +105 -0
- package/workspace/lib/meeting-terms.js +57 -17
- package/workspace/lib/okr-goals.js +23 -3
- package/workspace/lib/report-chat.js +3 -3
- package/workspace/lib/routes-lab.js +44 -2
- package/workspace/lib/routes-meeting-points.js +27 -3
- package/workspace/lib/routes-morning-goals.js +5 -1
- package/workspace/lib/routes-report-flow.js +35 -5
- package/workspace/news-viewers-test.js +2 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/premium-vw-meet-test.js +17 -11
- package/workspace/product-lane-test.js +1 -1
- package/workspace/program-charter-test.js +2 -2
- package/workspace/public/pm/bd-ini-panel.js +1 -1
- package/workspace/public/pm/bd-span.js +10 -7
- package/workspace/public/pm/cozy-board.css +1 -0
- package/workspace/public/pm/cozy-lab-ins.js +13 -4
- package/workspace/public/pm/cozy-search.js +10 -17
- package/workspace/public/pm/cozy-settings.js +8 -2
- package/workspace/public/pm/cozy-today.css +30 -14
- package/workspace/public/pm/cozy-vw-mtg.css +122 -24
- package/workspace/public/pm/good-morning.js +30 -17
- package/workspace/public/pm/home-app.js +11 -1
- package/workspace/public/pm/home-data.js +4 -1
- package/workspace/public/pm/home-day.js +12 -5
- package/workspace/public/pm/premium-board.js +7 -5
- package/workspace/public/pm/premium-initiative-head.js +9 -1
- package/workspace/public/pm/premium-insights.js +5 -3
- package/workspace/public/pm/premium-meeting.js +2 -1
- package/workspace/public/pm/premium-mtg-replay.js +149 -73
- package/workspace/public/pm/premium-report-read.js +11 -4
- package/workspace/public/pm/premium-report-tab.js +33 -4
- package/workspace/public/pm/premium-vw-mtg.js +176 -53
- package/workspace/public/pm/program-model.js +2 -1
- package/workspace/public/pm/report-chat.js +1 -0
- package/workspace/public/pm/report-flow.js +7 -3
- package/workspace/public/premium-board.html +4 -4
- package/workspace/server.js +1 -1
- package/workspace/task-milestones-test.js +2 -2
- 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
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
12
|
-
* (
|
|
13
|
-
*
|
|
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
|
|
28
|
-
|
|
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(
|
|
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: '
|
|
50
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
9
|
+
"version": "0.529.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@anthropic-ai/sdk": "^1.3.0",
|
package/workspace/package.json
CHANGED