create-pmos 0.47.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/AGENTS-HUMAN.md +1 -1
- package/payload/AGENTS.md +1 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +1 -1
- package/payload/mods/pmos-cockpit/README.md +14 -3
- package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +34 -6
- package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +22 -3
- package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +16 -3
- package/payload/mods/pmos-cockpit/hooks/lib/route.mjs +95 -0
- package/payload/mods/pmos-cockpit/hooks/register.tsx +104 -19
- package/payload/mods/pmos-cockpit/types/index.d.ts +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/pmos-log.sh +18 -8
- package/payload/scripts/pmos-model.js +233 -2
- package/payload/scripts/status-facts.js +214 -0
- package/payload/scripts/workflow-state.js +59 -2
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/README.md +2 -2
- package/workspace/bin/live-notes.js +133 -0
- package/workspace/design-parity.js +328 -21
- package/workspace/doc-category-bench.js +165 -0
- package/workspace/fixtures/doc-category-labelled.json +167 -0
- package/workspace/guide/index.md +2 -1
- package/workspace/guide/initiatives-and-next-steps.md +17 -2
- package/workspace/guide/knowledge-graph.md +94 -1
- package/workspace/guide/layout.md +31 -2
- package/workspace/guide/live-notes.md +78 -0
- package/workspace/guide/proactive-engine.md +6 -2
- package/workspace/guide/validate-with-typesafe.md +31 -2
- package/workspace/lib/claims.js +51 -2
- package/workspace/lib/doc-category-corrections.js +113 -0
- package/workspace/lib/doc-category-writeback.js +110 -0
- package/workspace/lib/doc-category.js +225 -0
- package/workspace/lib/engine/pmos-model.js +233 -2
- package/workspace/lib/engine/status-facts.js +214 -0
- package/workspace/lib/engine/workflow-state.js +59 -2
- package/workspace/lib/jev-categorize.js +433 -0
- package/workspace/lib/jev-claims.js +59 -15
- package/workspace/lib/jev-live.js +37 -0
- package/workspace/lib/jev-route.js +139 -0
- package/workspace/lib/kg-cli.js +3 -1
- package/workspace/lib/kg.js +6 -1
- package/workspace/lib/learn.js +5 -3
- package/workspace/lib/live-notes.js +1035 -0
- package/workspace/lib/typesafe-settings.js +10 -2
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +5 -4
- package/workspace/public/pm/app.js +148 -79
- package/workspace/public/pm/data.js +78 -0
- package/workspace/public/pm/fixture.js +40 -2
- package/workspace/public/pm/modes-live.js +279 -18
- package/workspace/public/pm/panel-cockpit.js +638 -0
- package/workspace/public/pm/panel-detail.js +212 -8
- package/workspace/public/pm/panel-initiatives.js +9 -9
- package/workspace/public/pm/panel-newsess.js +145 -86
- package/workspace/public/pm/panel-repos.js +34 -17
- package/workspace/public/pm/panel-tabs.js +249 -29
- package/workspace/public/pm/state.js +48 -4
- package/workspace/public/pm/tab-doc.js +244 -16
- package/workspace/public/pm/tab-term.js +11 -1
- package/workspace/public/pm/tokens.css +4 -3
- package/workspace/public/pm/view-addrepo.js +207 -0
- package/workspace/public/pm/view-files.js +197 -0
- package/workspace/public/pm/view-guide.js +141 -0
- package/workspace/public/pm/view-knowledge.js +225 -0
- package/workspace/public/pm/view-livenotes.js +397 -0
- package/workspace/public/pm/view-memory.js +122 -0
- package/workspace/public/pm/view-portfolio.js +153 -0
- package/workspace/public/pm/view-reports.js +190 -0
- package/workspace/public/pm/view-scheduled.js +161 -0
- package/workspace/public/pm/view-skills.js +216 -0
- package/workspace/public/pm/view-today.js +168 -0
- package/workspace/public/pm/view-validate.js +171 -0
- package/workspace/server.js +349 -7
- package/workspace/skills/live-notes.skill +58 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/* pm/view-livenotes.js — the Live notes view tab: confirm each talking point as it arrives (jev-live-notes, Run 6).
|
|
2
|
+
*
|
|
3
|
+
* The PM types or pastes what is being said, one point per line. Each point comes back at once as a card with a guess: the
|
|
4
|
+
* open initiative that shares the most words with it, and an open question when one shares at least two. The guess is a
|
|
5
|
+
* LOCAL WORD MATCH made in the workspace process (lib/live-notes.js, the `local` path): no model, no Jev, no request leaves
|
|
6
|
+
* the machine, and a guess is never shown above "possible". The PM confirms it, picks another relation or initiative, or
|
|
7
|
+
* rejects it, one card at a time. Waiting cards are on top; decided cards drop below. Closing the session files a section in
|
|
8
|
+
* the day's requirements note and deletes the raw transcript.
|
|
9
|
+
*
|
|
10
|
+
* Reads and writes, all on this repository's workspace:
|
|
11
|
+
* GET /api/live-notes?ws=… the sessions GET /api/live-notes?ws=…&session=… one session, ranked, + its shortlist
|
|
12
|
+
* POST /api/live-notes/session start or resume POST /api/live-notes/add typed points
|
|
13
|
+
* POST /api/live-notes/decide accept · change · reject POST /api/live-notes/close file the note
|
|
14
|
+
* Points an agent session adds through the command (Runs 1 to 5) appear here on the next read. In the fixture nothing is read.
|
|
15
|
+
*
|
|
16
|
+
* LISTENING. "Start listening" uses the browser's speech recognition with `processLocally = true`: the audio is recognised on this
|
|
17
|
+
* device (Chrome 139 and later, after a one-time language download) and never sent anywhere. A browser that cannot recognise on
|
|
18
|
+
* the device is told so; there is no fallback to cloud recognition. Interim words show live and are never added. A final phrase
|
|
19
|
+
* is added at a sentence end or after a pause, and the server cleans it (fillers, stumbles, a sentence end) before it becomes a
|
|
20
|
+
* point. "This meeting is private" stops listening and marks the session, so Run 7 never sends a point from it.
|
|
21
|
+
*
|
|
22
|
+
* JEV (Run 7, D85). "Check with Jev" is the per-repository switch, default off. When it, a saved key and consent for the repository
|
|
23
|
+
* all hold, the server hands each cleaned point to Jev with the shortlist, and the card says "from Jev". When a gate is shut the
|
|
24
|
+
* view says which, in words, and the local word match answers. A point Jev could not answer gets the local guess and says so.
|
|
25
|
+
* POST /api/live-notes/switch { ws, live }: the switch; the reply carries the gates again
|
|
26
|
+
*/
|
|
27
|
+
import { el } from './dom.js';
|
|
28
|
+
|
|
29
|
+
const BTN = 'font:500 12.5px var(--sans);background:transparent;color:var(--foreground);border:1px solid var(--input);border-radius:8px;padding:0 12px;height:30px;min-height:30px;display:inline-grid;place-items:center;cursor:pointer;flex:none;white-space:nowrap';
|
|
30
|
+
const BTNP = 'font:500 12.5px var(--sans);background:var(--foreground);color:var(--background);border:0;border-radius:8px;padding:0 12px;height:30px;min-height:30px;display:inline-grid;place-items:center;cursor:pointer;flex:none;white-space:nowrap';
|
|
31
|
+
const CHIP = 'font:500 12px var(--sans);background:transparent;color:var(--foreground);border:1px solid var(--border);border-radius:999px;padding:0 10px;height:26px;cursor:pointer;white-space:nowrap';
|
|
32
|
+
const CHIP_ON = 'font:500 12px var(--sans);background:var(--foreground);color:var(--background);border:1px solid var(--foreground);border-radius:999px;padding:0 10px;height:26px;cursor:pointer;white-space:nowrap';
|
|
33
|
+
const CARD = 'border:1px solid var(--border);background:var(--background);border-radius:10px;padding:12px 14px;display:flex;flex-direction:column;gap:8px';
|
|
34
|
+
const SELECT = 'font:13px var(--sans);background:var(--background);color:var(--foreground);border:1px solid var(--input);border-radius:8px;height:30px;padding:0 8px;max-width:100%';
|
|
35
|
+
const NOTE = 'color:var(--muted-foreground);font-size:13px;line-height:1.5';
|
|
36
|
+
const LBL = 'font:500 10px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground)';
|
|
37
|
+
|
|
38
|
+
/** The relations the PM can pick, in words. `none` is the initiative picker's "none of these", not a chip. */
|
|
39
|
+
export const RELATION_CHIPS = [
|
|
40
|
+
{ key: 'validates', label: 'validates' }, { key: 'clarifies', label: 'clarifies' }, { key: 'extends', label: 'extends' },
|
|
41
|
+
{ key: 'contradicts', label: 'contradicts' }, { key: 'answers', label: 'answers a question' },
|
|
42
|
+
];
|
|
43
|
+
const REFRESH_MS = 5000;
|
|
44
|
+
|
|
45
|
+
/* ── module state: survives the redraw every setState causes ─────────────────────────────────── */
|
|
46
|
+
const reads = new Map(); // key -> { state, data, error, at }
|
|
47
|
+
const drafts = new Map(); // segment -> { initiative, relation, question } the PM's choice before Confirm
|
|
48
|
+
const errors = new Map(); // segment -> the server's refusal, in words
|
|
49
|
+
const sessionOf = new Map(); // ws -> the session shown
|
|
50
|
+
let withheldNow = []; // the last add's withheld points: { id, reason } — the text is never kept
|
|
51
|
+
let banner = null; // { tone, text }
|
|
52
|
+
let anchorAsk = null; // { ws, anchors: [{anchor, open}], error }
|
|
53
|
+
let busy = false;
|
|
54
|
+
let switching = false;
|
|
55
|
+
let input = null; // the textarea, kept across redraws so typing is not lost
|
|
56
|
+
let dataMod = null; let dataTried = false;
|
|
57
|
+
let listen = null; // { ws, session, state: 'checking'|'on'|'needs-install'|'installing'|'unsupported'|'error', rec, interim, buffer, timer, lang, msg }
|
|
58
|
+
const PAUSE_MS = 1500; // a pause this long ends a point
|
|
59
|
+
|
|
60
|
+
function read(ctx, key, url, ttl = REFRESH_MS) {
|
|
61
|
+
const hit = reads.get(key);
|
|
62
|
+
if (hit && (hit.state === 'loading' || Date.now() - hit.at < ttl)) return hit;
|
|
63
|
+
const next = { state: hit ? hit.state : 'loading', data: hit ? hit.data : null, error: hit ? hit.error : null, at: Date.now() };
|
|
64
|
+
reads.set(key, next);
|
|
65
|
+
fetch(url).then((r) => r.json()).then(
|
|
66
|
+
(d) => {
|
|
67
|
+
const same = hit && JSON.stringify(hit.data) === JSON.stringify(d);
|
|
68
|
+
reads.set(key, { state: d && d.ok === false ? 'error' : 'ok', data: d, error: d && d.error, at: Date.now() });
|
|
69
|
+
if (!same) ctx.setState({}); // redraw only when something changed, so a refresh does not disturb typing
|
|
70
|
+
},
|
|
71
|
+
(e) => { reads.set(key, { state: 'error', data: null, error: (e && e.message) || String(e), at: Date.now() }); ctx.setState({}); },
|
|
72
|
+
);
|
|
73
|
+
return next;
|
|
74
|
+
}
|
|
75
|
+
async function post(url, body) {
|
|
76
|
+
try { return await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then((r) => r.json()); }
|
|
77
|
+
catch (e) { return { ok: false, error: (e && e.message) || 'the workspace server did not answer' }; }
|
|
78
|
+
}
|
|
79
|
+
const forget = (ws, session) => { reads.delete('sessions:' + ws); if (session) reads.delete('session:' + ws + ':' + session); };
|
|
80
|
+
|
|
81
|
+
async function setJev(ctx, ws, on) {
|
|
82
|
+
if (switching) return;
|
|
83
|
+
switching = true; ctx.setState({});
|
|
84
|
+
const r = await post('/api/live-notes/switch', { ws, live: on });
|
|
85
|
+
switching = false;
|
|
86
|
+
if (!r || !r.ok) banner = { tone: 'bad', text: (r && r.error) || 'the switch did not change' };
|
|
87
|
+
else banner = null;
|
|
88
|
+
forget(ws, sessionOf.get(ws));
|
|
89
|
+
ctx.setState({});
|
|
90
|
+
}
|
|
91
|
+
const FLAG_WORDS = {
|
|
92
|
+
'jev-not-reached': 'Jev was not reached, so this is the local word match.',
|
|
93
|
+
'jev-unreadable': 'Jev\'s answer could not be read, so this is the local word match.',
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** Every point of a ranked session, flat: waiting first (newest on top), then decided (newest on top). */
|
|
97
|
+
export function feedOf(ranked) {
|
|
98
|
+
const all = [];
|
|
99
|
+
for (const sec of (ranked && ranked.sections) || []) for (const x of sec.items) all.push(x);
|
|
100
|
+
const newest = (a, b) => String(b.at || '').localeCompare(String(a.at || '')) || String(b.segment).localeCompare(String(a.segment));
|
|
101
|
+
return { waiting: all.filter((x) => !x.decision).sort(newest), decided: all.filter((x) => x.decision).sort(newest) };
|
|
102
|
+
}
|
|
103
|
+
/** The counter's numbers. */
|
|
104
|
+
export function countsOf(ranked) {
|
|
105
|
+
const f = feedOf(ranked);
|
|
106
|
+
const by = (a) => f.decided.filter((x) => x.decision.action === a).length;
|
|
107
|
+
return { confirmed: by('accept'), changed: by('change'), rejected: by('reject'), waiting: f.waiting.length, withheld: (ranked && ranked.withheld) || 0 };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* ── actions ──────────────────────────────────────────────────────────────────────────────────── */
|
|
111
|
+
async function startSession(ctx, ws, anchor) {
|
|
112
|
+
busy = true; banner = null; ctx.setState({});
|
|
113
|
+
const r = await post('/api/live-notes/session', { ws, anchor: anchor || null });
|
|
114
|
+
busy = false;
|
|
115
|
+
if (r && r.ok) { sessionOf.set(ws, r.session); anchorAsk = null; withheldNow = []; forget(ws, r.session); }
|
|
116
|
+
else if (r && Array.isArray(r.anchors)) anchorAsk = { ws, anchors: r.anchors, error: r.error };
|
|
117
|
+
else banner = { tone: 'bad', text: (r && r.error) || 'the session did not start' };
|
|
118
|
+
ctx.setState({});
|
|
119
|
+
}
|
|
120
|
+
async function addPoints(ctx, ws, session) {
|
|
121
|
+
const text = input ? input.value : '';
|
|
122
|
+
if (!text.trim() || busy) return;
|
|
123
|
+
busy = true; banner = null; ctx.setState({});
|
|
124
|
+
const r = await post('/api/live-notes/add', { ws, session, text });
|
|
125
|
+
busy = false;
|
|
126
|
+
if (r && r.ok) {
|
|
127
|
+
if (input) input.value = '';
|
|
128
|
+
withheldNow = r.withheld || [];
|
|
129
|
+
reads.set('session:' + ws + ':' + session, { state: 'ok', data: { ok: true, session, ranked: r.ranked, shortlist: (reads.get('session:' + ws + ':' + session) || {}).data?.shortlist || [] }, at: 0 });
|
|
130
|
+
forget(ws, null);
|
|
131
|
+
} else banner = { tone: 'bad', text: (r && r.error) || 'nothing was added' };
|
|
132
|
+
ctx.setState({});
|
|
133
|
+
}
|
|
134
|
+
async function decide(ctx, ws, session, item, action) {
|
|
135
|
+
const d = drafts.get(item.segment) || {};
|
|
136
|
+
const initiative = d.initiative || item.initiative;
|
|
137
|
+
const relation = initiative === 'none' ? null : (d.relation !== undefined ? d.relation : item.relation);
|
|
138
|
+
const question = relation === 'answers' ? (d.question != null ? d.question : (item.answers_question && item.answers_question.initiative === initiative ? item.answers_question.n : null)) : null;
|
|
139
|
+
if (action !== 'reject' && initiative !== 'none' && !relation) { errors.set(item.segment, 'Pick how this point relates to ' + initiative + ' first.'); ctx.setState({}); return; }
|
|
140
|
+
if (action !== 'reject' && relation === 'answers' && question == null) { errors.set(item.segment, '"Answers" needs the open question it answers. Pick one.'); ctx.setState({}); return; }
|
|
141
|
+
const same = initiative === item.initiative && relation === item.relation && (relation !== 'answers' || (item.answers_question && item.answers_question.n === question));
|
|
142
|
+
const body = { ws, session, segment: item.segment, action: action === 'reject' ? 'reject' : same ? 'accept' : 'change' };
|
|
143
|
+
if (body.action === 'change') Object.assign(body, { initiative, relation: relation || null, question });
|
|
144
|
+
const r = await post('/api/live-notes/decide', body);
|
|
145
|
+
if (r && r.ok) { errors.delete(item.segment); drafts.delete(item.segment); forget(ws, session); }
|
|
146
|
+
else errors.set(item.segment, (r && r.error) || 'not recorded');
|
|
147
|
+
ctx.setState({});
|
|
148
|
+
}
|
|
149
|
+
/* ── listening: on-device speech recognition only ─────────────────────────────────────────────── */
|
|
150
|
+
const SRClass = () => (typeof window !== 'undefined' ? window.SpeechRecognition || window.webkitSpeechRecognition : null);
|
|
151
|
+
function stopListening() {
|
|
152
|
+
if (!listen) return;
|
|
153
|
+
const l = listen; listen = null;
|
|
154
|
+
clearTimeout(l.timer);
|
|
155
|
+
if (l.rec) { l.rec.onend = null; try { l.rec.stop(); } catch { /* already stopped */ } }
|
|
156
|
+
}
|
|
157
|
+
async function flush(ctx, l) {
|
|
158
|
+
clearTimeout(l.timer); l.timer = null;
|
|
159
|
+
const text = l.buffer.join(' ').trim(); l.buffer = [];
|
|
160
|
+
if (!text) return;
|
|
161
|
+
// one point per sentence; the server cleans each line before it becomes a point
|
|
162
|
+
const lines = text.split(/(?<=[.?!])\s+/).join('\n');
|
|
163
|
+
const r = await post('/api/live-notes/add', { ws: l.ws, session: l.session, text: lines, source: 'speech' });
|
|
164
|
+
if (r && r.ok) { withheldNow = (r.withheld || []).concat(withheldNow).slice(0, 5); reads.delete('session:' + l.ws + ':' + l.session); forget(l.ws, null); }
|
|
165
|
+
else if (r && r.error && !/type a talking point/.test(r.error)) banner = { tone: 'bad', text: 'A spoken point was not added: ' + r.error };
|
|
166
|
+
ctx.setState({});
|
|
167
|
+
}
|
|
168
|
+
async function startListening(ctx, ws, session, install = false) {
|
|
169
|
+
stopListening();
|
|
170
|
+
const C = SRClass();
|
|
171
|
+
const lang = (typeof navigator !== 'undefined' && navigator.language) || 'en-US';
|
|
172
|
+
listen = { ws, session, state: 'checking', rec: null, interim: '', buffer: [], timer: null, lang, msg: null };
|
|
173
|
+
ctx.setState({});
|
|
174
|
+
const l = listen;
|
|
175
|
+
if (!C || typeof C.available !== 'function') {
|
|
176
|
+
l.state = 'unsupported';
|
|
177
|
+
l.msg = 'This browser cannot recognise speech on this device, and live notes never send audio away. Chrome 139 or later can; until then, type the points.';
|
|
178
|
+
ctx.setState({}); return;
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
if (install) { l.state = 'installing'; ctx.setState({}); await C.install({ langs: [lang], processLocally: true }); }
|
|
182
|
+
const a = await C.available({ langs: [lang], processLocally: true });
|
|
183
|
+
if (listen !== l) return;
|
|
184
|
+
if (a === 'downloadable' || a === 'downloading') { l.state = 'needs-install'; l.msg = 'Recognising ' + lang + ' on this device needs a one-time download by the browser.'; ctx.setState({}); return; }
|
|
185
|
+
if (a !== 'available') { l.state = 'unsupported'; l.msg = 'This browser cannot recognise ' + lang + ' on this device, and live notes never send audio away. Type the points instead.'; ctx.setState({}); return; }
|
|
186
|
+
} catch (e) { l.state = 'error'; l.msg = 'Speech recognition did not start: ' + ((e && e.message) || e); ctx.setState({}); return; }
|
|
187
|
+
const rec = new C();
|
|
188
|
+
rec.lang = lang; rec.continuous = true; rec.interimResults = true; rec.processLocally = true;
|
|
189
|
+
rec.onresult = (e) => {
|
|
190
|
+
if (listen !== l) return; // a result that arrives after Stop or Private is dropped, never added
|
|
191
|
+
let interim = ''; let ended = false;
|
|
192
|
+
for (let i = e.resultIndex; i < e.results.length; i++) {
|
|
193
|
+
const res = e.results[i]; const t = String((res[0] && res[0].transcript) || '').trim();
|
|
194
|
+
if (!t) continue;
|
|
195
|
+
if (res.isFinal) { l.buffer.push(t); if (/[.?!]$/.test(t)) ended = true; } else interim += (interim ? ' ' : '') + t;
|
|
196
|
+
}
|
|
197
|
+
l.interim = interim;
|
|
198
|
+
clearTimeout(l.timer);
|
|
199
|
+
if (ended) flush(ctx, l); else if (l.buffer.length) l.timer = setTimeout(() => flush(ctx, l), PAUSE_MS);
|
|
200
|
+
const live = typeof document !== 'undefined' && document.querySelector('[data-ln-interim]');
|
|
201
|
+
if (live) live.textContent = interim; else ctx.setState({});
|
|
202
|
+
};
|
|
203
|
+
rec.onerror = (e) => {
|
|
204
|
+
if (e && (e.error === 'no-speech' || e.error === 'aborted')) return;
|
|
205
|
+
l.state = 'error'; l.msg = 'Listening stopped: ' + ((e && (e.message || e.error)) || 'the browser gave no reason') + '.'; ctx.setState({});
|
|
206
|
+
};
|
|
207
|
+
// the browser ends a recognition after a silence; while the PM has not pressed Stop, it starts again
|
|
208
|
+
rec.onend = () => { if (listen === l && l.state === 'on') { try { rec.start(); } catch { /* it is starting */ } } };
|
|
209
|
+
l.rec = rec;
|
|
210
|
+
try { rec.start(); l.state = 'on'; } catch (e) { l.state = 'error'; l.msg = 'Listening did not start: ' + ((e && e.message) || e); }
|
|
211
|
+
ctx.setState({});
|
|
212
|
+
}
|
|
213
|
+
async function markPrivate(ctx, ws, session) {
|
|
214
|
+
const l = listen;
|
|
215
|
+
stopListening();
|
|
216
|
+
if (l) l.buffer = [];
|
|
217
|
+
const r = await post('/api/live-notes/private', { ws, session });
|
|
218
|
+
banner = r && r.ok ? { tone: 'ok', text: 'Marked private. Listening stopped, and nothing from this session will be sent to any service.' } : { tone: 'bad', text: (r && r.error) || 'not marked' };
|
|
219
|
+
forget(ws, session);
|
|
220
|
+
ctx.setState({});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function closeSession(ctx, ws, session) {
|
|
224
|
+
if (typeof window !== 'undefined' && window.confirm && !window.confirm('End this session? The points are filed in today\'s requirements note and the raw transcript is deleted.')) return;
|
|
225
|
+
if (listen && listen.session === session) { const l = listen; stopListening(); await flush(ctx, l); }
|
|
226
|
+
const r = await post('/api/live-notes/close', { ws, session });
|
|
227
|
+
banner = r && r.ok ? { tone: 'ok', text: r.already ? 'This session was already closed.' : 'Filed in ' + r.note + '. Raw transcript: ' + r.transcript + '.' } : { tone: 'bad', text: (r && r.error) || 'the session did not close' };
|
|
228
|
+
forget(ws, session);
|
|
229
|
+
ctx.setState({});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* ── drawing ──────────────────────────────────────────────────────────────────────────────────── */
|
|
233
|
+
const titleOf = (list, id) => { const i = (list || []).find((x) => x.id === id); return i ? i.title : id; };
|
|
234
|
+
|
|
235
|
+
function card(ctx, ws, session, item, shortlist, closed) {
|
|
236
|
+
const decided = item.decision;
|
|
237
|
+
const parts = [el('div', { 'data-ln-quote': '', style: 'font-size:14px;line-height:1.5' }, '"' + item.quote + '"')];
|
|
238
|
+
const guess = item.initiative && item.initiative !== 'none'
|
|
239
|
+
? 'Guess: about ' + titleOf(shortlist, item.initiative) + ' (' + (item.initiative_band || 'possible') + ')' + (item.relation ? ', ' + item.relation : '')
|
|
240
|
+
: 'Guess: matches no open initiative';
|
|
241
|
+
const by = item.path === 'local' ? 'local word match' : item.path === 'jev' || item.path === 'jev-agent' ? 'Jev' : 'agent session';
|
|
242
|
+
parts.push(el('div', { 'data-ln-guess': '', style: NOTE }, guess + ' · from ' + by));
|
|
243
|
+
if (item.answers_question) parts.push(el('div', { 'data-ln-question': '', style: NOTE }, 'May answer open question ' + item.answers_question.n + ': ' + item.answers_question.text));
|
|
244
|
+
for (const fl of item.flags || []) if (FLAG_WORDS[fl]) parts.push(el('div', { 'data-ln-flag': fl, style: NOTE }, FLAG_WORDS[fl]));
|
|
245
|
+
if (decided) {
|
|
246
|
+
const d = decided;
|
|
247
|
+
const said = d.action === 'reject' ? 'You rejected this.' : d.action === 'change' && d.now
|
|
248
|
+
? 'You changed it: ' + (d.now.initiative === 'none' ? 'matches no initiative' : d.now.relation + ' ' + titleOf(shortlist, d.now.initiative)) + '.'
|
|
249
|
+
: 'You confirmed it.';
|
|
250
|
+
parts.push(el('div', { 'data-ln-decision': d.action, style: 'font:500 12.5px var(--sans);color:var(--muted-foreground)' }, said));
|
|
251
|
+
return el('div', { 'data-ln-card': item.segment, 'data-ln-state': 'decided', style: CARD + ';opacity:.72' }, parts);
|
|
252
|
+
}
|
|
253
|
+
if (closed) return el('div', { 'data-ln-card': item.segment, 'data-ln-state': 'waiting', style: CARD }, parts);
|
|
254
|
+
|
|
255
|
+
const d = drafts.get(item.segment) || {};
|
|
256
|
+
const ini = d.initiative || item.initiative || 'none';
|
|
257
|
+
const rel = ini === 'none' ? null : (d.relation !== undefined ? d.relation : item.relation);
|
|
258
|
+
const setDraft = (patch) => { drafts.set(item.segment, { ...d, ...patch }); errors.delete(item.segment); ctx.setState({}); };
|
|
259
|
+
const options = [el('option', { value: 'none', selected: ini === 'none' }, 'none of these initiatives')]
|
|
260
|
+
.concat((shortlist || []).map((i) => el('option', { value: i.id, selected: ini === i.id }, i.title + ' (' + i.id + ')')));
|
|
261
|
+
parts.push(el('div', { style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap' },
|
|
262
|
+
el('span', { style: LBL }, 'initiative'),
|
|
263
|
+
el('select', { 'data-ln-ini': '', style: SELECT, onChange: (e) => setDraft({ initiative: e.target.value, question: null }) }, options)));
|
|
264
|
+
if (ini !== 'none') {
|
|
265
|
+
parts.push(el('div', { 'data-ln-rels': '', style: 'display:flex;gap:6px;align-items:center;flex-wrap:wrap' },
|
|
266
|
+
el('span', { style: LBL }, 'relation'),
|
|
267
|
+
RELATION_CHIPS.map((c) => el('button', { 'data-ln-rel': c.key, 'aria-pressed': rel === c.key ? 'true' : 'false', style: rel === c.key ? CHIP_ON : CHIP,
|
|
268
|
+
onClick: () => setDraft({ relation: c.key }) }, c.label))));
|
|
269
|
+
if (rel === 'answers') {
|
|
270
|
+
const qs = ((shortlist || []).find((i) => i.id === ini) || {}).questions || [];
|
|
271
|
+
const cur = d.question != null ? d.question : (item.answers_question && item.answers_question.initiative === ini ? item.answers_question.n : null);
|
|
272
|
+
parts.push(qs.length
|
|
273
|
+
? el('select', { 'data-ln-q': '', style: SELECT, onChange: (e) => setDraft({ question: e.target.value ? Number(e.target.value) : null }) },
|
|
274
|
+
[el('option', { value: '', selected: cur == null }, 'which open question?')].concat(qs.map((q, n) => el('option', { value: String(n + 1), selected: cur === n + 1 }, (n + 1) + '. ' + q.slice(0, 120)))))
|
|
275
|
+
: el('div', { style: NOTE }, titleOf(shortlist, ini) + ' has no open questions, so this point cannot answer one.'));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (errors.get(item.segment)) parts.push(el('div', { 'data-ln-error': '', role: 'alert', style: 'color:var(--destructive);font-size:12.5px' }, errors.get(item.segment)));
|
|
279
|
+
parts.push(el('div', { style: 'display:flex;gap:8px' },
|
|
280
|
+
el('button', { 'data-ln-action': 'confirm', style: BTNP, onClick: () => decide(ctx, ws, session, item, 'confirm') }, 'Confirm'),
|
|
281
|
+
el('button', { 'data-ln-action': 'reject', style: BTN, hover: 'background:var(--accent)', onClick: () => decide(ctx, ws, session, item, 'reject') }, 'Reject')));
|
|
282
|
+
return el('div', { 'data-ln-card': item.segment, 'data-ln-state': 'waiting', style: CARD }, parts);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function renderBoard(inner, ctx) {
|
|
286
|
+
const { FIXTURE } = ctx;
|
|
287
|
+
const repo = (ctx.tab && ctx.tab.repo) || ctx.repo || null;
|
|
288
|
+
let ws = null;
|
|
289
|
+
if (!FIXTURE) {
|
|
290
|
+
if (!dataTried) { dataTried = true; import('./data.js').then((m) => { dataMod = m; ctx.setState({}); }).catch(() => {}); }
|
|
291
|
+
if (dataMod && dataMod.wsOf) ws = dataMod.wsOf(repo);
|
|
292
|
+
if (!ws && ctx.state && ctx.state.product === repo) ws = ctx.state.wsId || null;
|
|
293
|
+
}
|
|
294
|
+
const typing = typeof document !== 'undefined' && input && document.activeElement === input;
|
|
295
|
+
const caret = typing ? [input.selectionStart, input.selectionEnd] : null;
|
|
296
|
+
|
|
297
|
+
const sess = !FIXTURE && ws ? read(ctx, 'sessions:' + ws, '/api/live-notes?ws=' + encodeURIComponent(ws), 15000) : null;
|
|
298
|
+
const jev = (sess && sess.data && sess.data.jev) || null;
|
|
299
|
+
const jevOn = !!(jev && jev.open);
|
|
300
|
+
inner.appendChild(el('div', { 'data-ln-part': 'header' },
|
|
301
|
+
el('div', { style: 'font:600 22px var(--sans);line-height:1.45' }, 'Live notes'),
|
|
302
|
+
jevOn
|
|
303
|
+
? el('div', { 'data-ln-jev-on': '', style: NOTE },
|
|
304
|
+
'Listen or type what is being said. Each point is cleaned up and checked by Jev against your open initiatives; you confirm it, change it or reject it. '
|
|
305
|
+
+ jev.note + ' Nothing changes an initiative until you confirm.')
|
|
306
|
+
: el('div', { 'data-ln-local': '', style: NOTE },
|
|
307
|
+
'Type what is being said, one point per line. Each point gets a guess at once; you confirm it, change it or reject it. '
|
|
308
|
+
+ 'Guesses here are a local word match. Nothing leaves this machine, and nothing changes an initiative.')));
|
|
309
|
+
if (FIXTURE || !ws) { inner.appendChild(el('div', { style: NOTE + ';margin-top:12px' }, FIXTURE ? 'The fixture reads no sessions.' : 'Select a repository to take notes against.')); return; }
|
|
310
|
+
|
|
311
|
+
const sessions = (sess.data && sess.data.sessions) || [];
|
|
312
|
+
let current = sessionOf.get(ws);
|
|
313
|
+
if (!current && sessions.length) { const open = sessions.filter((s) => !s.closed); current = (open[open.length - 1] || sessions[sessions.length - 1]).session; sessionOf.set(ws, current); }
|
|
314
|
+
|
|
315
|
+
const bar = [el('span', { style: LBL }, 'session')];
|
|
316
|
+
if (sessions.length) {
|
|
317
|
+
bar.push(el('select', { 'data-ln-session': '', style: SELECT, onChange: (e) => { stopListening(); sessionOf.set(ws, e.target.value); withheldNow = []; banner = null; ctx.setState({}); } },
|
|
318
|
+
sessions.slice().reverse().map((s) => el('option', { value: s.session, selected: s.session === current }, s.session + (s.closed ? ' · closed' : '') + ' · ' + s.points + ' points'))));
|
|
319
|
+
} else bar.push(el('span', { style: NOTE }, sess.state === 'loading' ? 'reading …' : 'no sessions yet'));
|
|
320
|
+
bar.push(el('button', { 'data-ln-new': '', style: BTN, hover: 'background:var(--accent)', disabled: busy, onClick: () => { stopListening(); startSession(ctx, ws, null); } }, 'New session'));
|
|
321
|
+
inner.appendChild(el('div', { 'data-ln-part': 'sessions', style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:12px' }, bar));
|
|
322
|
+
if (jev) {
|
|
323
|
+
const sw = !!jev.switch;
|
|
324
|
+
const why = jev.open ? 'Jev is checking new points.'
|
|
325
|
+
: sw ? 'Jev is not used yet. ' + (jev.sentence || '')
|
|
326
|
+
: 'Jev is off. Points get the local word match only.';
|
|
327
|
+
inner.appendChild(el('div', { 'data-ln-part': 'jev', style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:10px' },
|
|
328
|
+
el('span', { style: LBL }, 'jev'),
|
|
329
|
+
el('button', { 'data-ln-jev': sw ? 'on' : 'off', 'aria-pressed': sw ? 'true' : 'false', disabled: switching, style: sw ? CHIP_ON : CHIP,
|
|
330
|
+
onClick: () => setJev(ctx, ws, !sw) }, sw ? 'Check with Jev: on' : 'Check with Jev: off'),
|
|
331
|
+
el('span', { 'data-ln-jev-state': jev.open ? 'open' : 'shut', style: NOTE }, why.trim())));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (anchorAsk && anchorAsk.ws === ws) {
|
|
335
|
+
let pick = anchorAsk.anchors[0] ? anchorAsk.anchors[0].anchor : '';
|
|
336
|
+
inner.appendChild(el('div', { 'data-ln-part': 'anchor', style: CARD + ';margin-top:10px' },
|
|
337
|
+
el('div', { style: NOTE }, anchorAsk.error),
|
|
338
|
+
el('div', { style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap' },
|
|
339
|
+
el('select', { 'data-ln-anchor': '', style: SELECT, onChange: (e) => { pick = e.target.value; } },
|
|
340
|
+
anchorAsk.anchors.map((a) => el('option', { value: a.anchor }, a.anchor + ' · ' + a.open + ' open'))),
|
|
341
|
+
el('button', { 'data-ln-start': '', style: BTNP, onClick: () => startSession(ctx, ws, pick) }, 'Start with this anchor'))));
|
|
342
|
+
}
|
|
343
|
+
if (banner) inner.appendChild(el('div', { 'data-ln-banner': banner.tone, role: banner.tone === 'bad' ? 'alert' : 'status', style: 'margin-top:10px;font-size:13px;color:' + (banner.tone === 'bad' ? 'var(--destructive)' : 'var(--foreground)') }, banner.text));
|
|
344
|
+
if (!current) return;
|
|
345
|
+
|
|
346
|
+
const one = read(ctx, 'session:' + ws + ':' + current, '/api/live-notes?ws=' + encodeURIComponent(ws) + '&session=' + encodeURIComponent(current));
|
|
347
|
+
if (!one.data || !one.data.ranked) { inner.appendChild(el('div', { style: NOTE + ';margin-top:12px' }, one.state === 'error' ? 'Could not read this session: ' + one.error : 'reading the session …')); return; }
|
|
348
|
+
const ranked = one.data.ranked; const shortlist = one.data.shortlist || [];
|
|
349
|
+
const closed = !!ranked.closed;
|
|
350
|
+
const c = countsOf(ranked);
|
|
351
|
+
inner.appendChild(el('div', { 'data-ln-counter': '', style: 'display:flex;gap:14px;flex-wrap:wrap;margin-top:12px;font:500 12.5px var(--sans)' },
|
|
352
|
+
[['confirmed', c.confirmed], ['changed', c.changed], ['rejected', c.rejected], ['waiting', c.waiting], ['withheld', c.withheld]]
|
|
353
|
+
.map(([k, n]) => el('span', { 'data-ln-count': k }, n + ' ' + k))));
|
|
354
|
+
if (ranked.paused) inner.appendChild(el('div', { 'data-ln-paused': '', role: 'status', style: NOTE }, ranked.paused.reason));
|
|
355
|
+
if (ranked.private && jevOn) inner.appendChild(el('div', { 'data-ln-private-jev': '', style: NOTE }, 'Nothing from this meeting goes to Jev, because it is marked private.'));
|
|
356
|
+
|
|
357
|
+
if (!closed) {
|
|
358
|
+
// listening: on-device recognition, a visible "notes are being taken", Stop and Private
|
|
359
|
+
const mine = listen && listen.ws === ws && listen.session === current ? listen : null;
|
|
360
|
+
const lp = [];
|
|
361
|
+
if (ranked.private) lp.push(el('span', { 'data-ln-private': '', style: NOTE }, 'This meeting is marked private: listening is off for it.'));
|
|
362
|
+
else if (mine && mine.state === 'on') {
|
|
363
|
+
lp.push(el('span', { 'data-ln-listening': 'on', role: 'status', style: 'font:600 12.5px var(--sans);color:var(--destructive)' }, '● Listening: notes are being taken'));
|
|
364
|
+
lp.push(el('button', { 'data-ln-stop': '', style: BTN, hover: 'background:var(--accent)', onClick: () => { const l = listen; stopListening(); if (l) flush(ctx, l); ctx.setState({}); } }, 'Stop'));
|
|
365
|
+
lp.push(el('button', { 'data-ln-mark-private': '', style: BTN, hover: 'background:var(--accent)', onClick: () => markPrivate(ctx, ws, current) }, 'This meeting is private'));
|
|
366
|
+
} else {
|
|
367
|
+
lp.push(el('button', { 'data-ln-listen': '', style: BTNP, disabled: !!(mine && (mine.state === 'checking' || mine.state === 'installing')), onClick: () => startListening(ctx, ws, current) },
|
|
368
|
+
mine && mine.state === 'checking' ? 'Starting …' : mine && mine.state === 'installing' ? 'Downloading …' : 'Start listening'));
|
|
369
|
+
if (mine && mine.state === 'needs-install') lp.push(el('button', { 'data-ln-install': '', style: BTN, onClick: () => startListening(ctx, ws, current, true) }, 'Download for ' + mine.lang));
|
|
370
|
+
lp.push(el('span', { style: NOTE }, 'Speech is recognised on this device; no audio leaves it.'));
|
|
371
|
+
}
|
|
372
|
+
inner.appendChild(el('div', { 'data-ln-part': 'listen', style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:12px' }, lp));
|
|
373
|
+
if (mine && mine.msg && mine.state !== 'on') inner.appendChild(el('div', { 'data-ln-listen-msg': mine.state, style: NOTE }, mine.msg));
|
|
374
|
+
if (mine && mine.state === 'on') inner.appendChild(el('div', { 'data-ln-interim': '', style: 'min-height:20px;font-size:13px;font-style:italic;color:var(--muted-foreground)' }, mine.interim || ''));
|
|
375
|
+
if (!input) {
|
|
376
|
+
input = el('textarea', { 'data-ln-input': '', rows: '3', placeholder: 'One talking point per line. Enter adds; Shift+Enter starts a new line.',
|
|
377
|
+
style: 'font:14px var(--sans);width:100%;box-sizing:border-box;background:var(--background);color:var(--foreground);border:1px solid var(--input);border-radius:8px;padding:8px 10px;resize:vertical' });
|
|
378
|
+
}
|
|
379
|
+
input.onkeydown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); addPoints(ctx, ws, current); } };
|
|
380
|
+
inner.appendChild(el('div', { 'data-ln-part': 'input', style: 'display:flex;flex-direction:column;gap:8px;margin-top:12px' }, input,
|
|
381
|
+
el('div', { style: 'display:flex;gap:8px;align-items:center' },
|
|
382
|
+
el('button', { 'data-ln-add': '', style: BTNP, disabled: busy, onClick: () => addPoints(ctx, ws, current) }, busy ? 'Adding …' : 'Add'),
|
|
383
|
+
el('span', { style: 'flex:1' }),
|
|
384
|
+
el('button', { 'data-ln-close': '', style: BTN, hover: 'background:var(--accent)', onClick: () => closeSession(ctx, ws, current) }, 'End and file the note'))));
|
|
385
|
+
if (caret) requestAnimationFrame(() => { try { input.focus(); input.setSelectionRange(caret[0], caret[1]); } catch { /* the textarea is gone */ } });
|
|
386
|
+
} else inner.appendChild(el('div', { 'data-ln-closed': '', style: NOTE + ';margin-top:12px' }, 'This session is closed. Its points are filed in the requirements note; start a new session to take more notes.'));
|
|
387
|
+
|
|
388
|
+
for (const w of withheldNow) inner.appendChild(el('div', { 'data-ln-withheld': w.id, style: CARD + ';margin-top:8px;color:var(--muted-foreground)' }, 'A point was withheld and not kept: it ' + w.reason + '.'));
|
|
389
|
+
|
|
390
|
+
const f = feedOf(ranked);
|
|
391
|
+
const list = el('div', { 'data-ln-part': 'feed', style: 'display:flex;flex-direction:column;gap:8px;margin-top:12px' });
|
|
392
|
+
if (!f.waiting.length && !f.decided.length) list.appendChild(el('div', { style: NOTE }, 'No points yet.'));
|
|
393
|
+
for (const x of f.waiting) list.appendChild(card(ctx, ws, current, x, shortlist, closed));
|
|
394
|
+
if (f.decided.length) list.appendChild(el('div', { style: LBL + ';margin-top:8px' }, 'decided'));
|
|
395
|
+
for (const x of f.decided) list.appendChild(card(ctx, ws, current, x, shortlist, closed));
|
|
396
|
+
inner.appendChild(list);
|
|
397
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/* pm/view-memory.js — the Memory view, drawn from planning/design/cockpit-mockup/ViewMemory.dc.html.
|
|
2
|
+
*
|
|
3
|
+
* PRD workspace-cockpit-nav, Run 3 (R4). The board is a header with the actions on the right, a row of badges, the notes as
|
|
4
|
+
* cards and a closing note. The notes and every action are the view's existing list (modes-live.js `memoryLive`): this module
|
|
5
|
+
* draws it into a host, moves its action bar into the header and restyles its rows as cards. No node is removed, so every
|
|
6
|
+
* `data-r9` hook and every live behaviour stays as it was. Only the frame is new.
|
|
7
|
+
*
|
|
8
|
+
* The badges are read off the list once it is drawn, so they come from the same GET /api/memory as the notes and never
|
|
9
|
+
* disagree with them. The board's "used by N sessions" and the note kinds (preference, standard, lesson, fact) have no backing
|
|
10
|
+
* in this app, so they are not drawn. In the fixture the prototype's list is drawn under the header, unchanged, and the badges
|
|
11
|
+
* are not read.
|
|
12
|
+
*/
|
|
13
|
+
import { el } from './dom.js';
|
|
14
|
+
|
|
15
|
+
const BTN = 'font:500 12.5px var(--sans);background:transparent;color:var(--foreground);border:1px solid var(--input);border-radius:8px;padding:0 12px;height:30px;min-height:30px;display:inline-grid;place-items:center;cursor:pointer;flex:none';
|
|
16
|
+
const BTNP = 'font:500 12.5px var(--sans);background:var(--foreground);color:var(--background);border:0;border-radius:8px;padding:0 12px;height:30px;min-height:30px;display:inline-grid;place-items:center;cursor:pointer;flex:none';
|
|
17
|
+
const SMALL = 'font:500 11.5px var(--sans);background:transparent;color:var(--muted-foreground);border:1px solid var(--border);border-radius:6px;padding:0 9px;height:24px;min-height:24px;display:inline-grid;place-items:center;cursor:pointer';
|
|
18
|
+
const BADGE = 'font:500 11px var(--sans);border:1px solid var(--border);border-radius:999px;padding:0 8px;line-height:18px;white-space:nowrap;display:inline-block';
|
|
19
|
+
const TONE = {
|
|
20
|
+
s: ';border-color:transparent;background:color-mix(in oklab, var(--success) 16%, transparent);color:var(--success)',
|
|
21
|
+
w: ';border-color:transparent;background:color-mix(in oklab, var(--warning) 16%, transparent);color:var(--warning)',
|
|
22
|
+
d: ';border-color:transparent;background:color-mix(in oklab, var(--destructive) 18%, transparent);color:var(--destructive)',
|
|
23
|
+
};
|
|
24
|
+
const ENTRY = 'display:flex;flex-direction:column;gap:6px;width:100%;box-sizing:border-box;text-align:left;border:1px solid var(--border);border-radius:10px;padding:12px 14px;background:var(--background);min-height:0';
|
|
25
|
+
const LBL = 'font:500 10px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground);padding:8px 0 0';
|
|
26
|
+
const NOTE = 'color:var(--muted-foreground);font-size:13px;line-height:1.5';
|
|
27
|
+
|
|
28
|
+
const badge = (text, tone, key) => el('span', { style: BADGE + (tone ? TONE[tone] : ''), 'data-board-badge': key }, String(text));
|
|
29
|
+
|
|
30
|
+
/** The badges row. It is read off the list this module just drew, so the badges and the notes always come from the same read
|
|
31
|
+
* of GET /api/memory and agree after every action. Before that read succeeds the list states its own status, so no badge is
|
|
32
|
+
* drawn. Nothing is shown that the read does not hold. */
|
|
33
|
+
function badges(list) {
|
|
34
|
+
if (!list.querySelector('[data-r9="usage-memory"]')) return null;
|
|
35
|
+
const wire = list.querySelector('[data-r9="wire"]');
|
|
36
|
+
const appr = list.querySelector('[data-r9="approval"]');
|
|
37
|
+
const wired = !!wire && /wired \u2713/.test(wire.textContent || '');
|
|
38
|
+
const n = list.querySelectorAll('[data-r9="entry"]').length;
|
|
39
|
+
const pending = list.querySelectorAll('[data-r9="pending"]').length;
|
|
40
|
+
const approval = !!appr && /approval on/.test(appr.textContent || '');
|
|
41
|
+
return el('div', { 'data-board-part': 'badges', style: 'display:flex;gap:6px;flex-wrap:wrap' },
|
|
42
|
+
wired ? badge('wired into CLAUDE.md', 's', 'wired') : badge('not wired into CLAUDE.md', 'w', 'wired'),
|
|
43
|
+
badge(n + (n === 1 ? ' note' : ' notes'), null, 'count'),
|
|
44
|
+
badge('memory.jsonl \u00b7 append-only', null, 'ledger'),
|
|
45
|
+
pending ? badge(pending + ' waiting for you', 'w', 'pending') : null,
|
|
46
|
+
badge('agent notes ' + (approval ? 'wait for your approval' : 'apply at once'), null, 'approval'));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Restyle the existing list in place: its bar goes into the header, its rows become cards. Nothing is removed. */
|
|
50
|
+
function restyle(list, actions) {
|
|
51
|
+
const bar = list.querySelector('[data-r9="memory-bar"]');
|
|
52
|
+
// the list's wrapper, when it drew one (a failed or pending read returns its parts unwrapped)
|
|
53
|
+
const first = list.firstElementChild;
|
|
54
|
+
const wrap = first && bar && first !== bar && first.contains(bar) ? first : null;
|
|
55
|
+
if (bar) {
|
|
56
|
+
bar.setAttribute('style', 'display:flex;gap:8px;flex-wrap:wrap;align-items:center;justify-content:flex-end');
|
|
57
|
+
for (const b of bar.querySelectorAll('button')) {
|
|
58
|
+
const k = b.getAttribute('data-r9');
|
|
59
|
+
b.setAttribute('style', k === 'add' ? BTNP : BTN);
|
|
60
|
+
}
|
|
61
|
+
// the board's order: the secondary actions first, the primary action last
|
|
62
|
+
const add = bar.querySelector('[data-r9="add"]');
|
|
63
|
+
if (add) bar.appendChild(add);
|
|
64
|
+
actions.appendChild(bar);
|
|
65
|
+
}
|
|
66
|
+
if (wrap) {
|
|
67
|
+
wrap.setAttribute('style', 'display:flex;flex-direction:column;gap:10px');
|
|
68
|
+
// the list's closing line has no hook; give it the board's note style
|
|
69
|
+
for (const k of wrap.children) if (!k.hasAttribute('data-r9')) k.setAttribute('style', NOTE);
|
|
70
|
+
}
|
|
71
|
+
for (const row of list.querySelectorAll('[data-r9="entry"],[data-r9="elsewhere"],[data-r9="pending"]')) {
|
|
72
|
+
row.setAttribute('style', ENTRY);
|
|
73
|
+
row.setAttribute('data-board-card', row.getAttribute('data-r9'));
|
|
74
|
+
const [text, meta, btns] = row.children;
|
|
75
|
+
if (text) text.setAttribute('style', 'display:block;font-size:13px;line-height:1.5;color:var(--foreground)');
|
|
76
|
+
if (meta) meta.setAttribute('style', 'display:block;font:11px var(--mono);color:var(--muted-foreground)');
|
|
77
|
+
if (btns) {
|
|
78
|
+
btns.setAttribute('style', 'display:flex;gap:6px;flex-wrap:wrap');
|
|
79
|
+
for (const b of btns.querySelectorAll('button')) b.setAttribute('style', SMALL);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const lab of list.querySelectorAll('[data-r9^="usage-"],[data-r9$="-label"]')) {
|
|
83
|
+
const tone = lab.getAttribute('data-r9') === 'blocked-label' ? ';color:var(--destructive)' : '';
|
|
84
|
+
lab.setAttribute('style', LBL + tone);
|
|
85
|
+
}
|
|
86
|
+
for (const note of list.querySelectorAll('[data-r9="empty"],[data-r9="blocked"]')) note.setAttribute('style', NOTE);
|
|
87
|
+
const memNote = list.querySelector('[data-r9="note"]');
|
|
88
|
+
if (memNote) memNote.setAttribute('style', NOTE + ';color:var(--destructive)');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function renderBoard(inner, ctx) {
|
|
92
|
+
const { FIXTURE, state } = ctx;
|
|
93
|
+
const repo = state.product || null;
|
|
94
|
+
|
|
95
|
+
const actions = el('div', { 'data-board-part': 'toolbar', style: 'display:flex;gap:8px;flex-wrap:wrap;align-items:center' });
|
|
96
|
+
const header = el('div', { 'data-board-part': 'header', style: 'display:flex;justify-content:space-between;align-items:flex-end;gap:12px;flex-wrap:wrap' },
|
|
97
|
+
el('div', null,
|
|
98
|
+
el('div', { 'data-board-title': '', style: 'font:600 22px var(--sans);line-height:1.45' }, 'Memory'),
|
|
99
|
+
el('div', { 'data-board-subtitle': '', style: 'color:var(--muted-foreground)' },
|
|
100
|
+
'Curated notes agents read before they work in ' + (repo || 'this repository'))),
|
|
101
|
+
actions);
|
|
102
|
+
|
|
103
|
+
const list = el('div', { 'data-board-section': 'notes', style: 'display:flex;flex-direction:column;gap:10px;max-width:900px' });
|
|
104
|
+
ctx.renderMode(list);
|
|
105
|
+
|
|
106
|
+
let part = null; let foot = null;
|
|
107
|
+
if (FIXTURE) part = el('div', { 'data-board-part': 'badges', style: NOTE }, 'The repository memory is not read in the fixture.');
|
|
108
|
+
else {
|
|
109
|
+
// read the badges and the approval setting before the bar moves into the header
|
|
110
|
+
part = badges(list);
|
|
111
|
+
const appr = list.querySelector('[data-r9="approval"]');
|
|
112
|
+
if (part) {
|
|
113
|
+
foot = el('div', { 'data-board-part': 'note', style: NOTE }, appr && /approval on/.test(appr.textContent || '')
|
|
114
|
+
? 'You add notes here, and agents propose them. A note an agent proposes waits here until you approve it.'
|
|
115
|
+
: 'You add notes here, and agents add them too. Turn approval on to keep an agent\u2019s note pending until you approve it.');
|
|
116
|
+
}
|
|
117
|
+
restyle(list, actions);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
inner.appendChild(el('div', { 'data-board': 'memory', style: 'display:flex;flex-direction:column;gap:14px;padding:4px 6px;font-size:13px;line-height:1.5;color:var(--foreground)' },
|
|
121
|
+
header, part, list, foot));
|
|
122
|
+
}
|