create-pmos 0.516.0 → 0.518.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 +33 -65
- 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/changelog.json +32 -0
- package/workspace/ev-everywhere-test.js +3 -3
- package/workspace/guide/board.md +7 -63
- package/workspace/guide/breakdown.md +8 -108
- package/workspace/guide/briefing-premium.md +5 -526
- package/workspace/guide/console-calendar.md +7 -206
- package/workspace/guide/data-freshness.md +26 -4
- package/workspace/guide/docs.md +10 -125
- package/workspace/guide/home.md +8 -5
- package/workspace/guide/index.md +78 -79
- package/workspace/guide/initiatives-and-next-steps.md +6 -19
- package/workspace/guide/layout.md +23 -230
- package/workspace/guide/live-notes.md +5 -7
- package/workspace/guide/my-work.md +8 -172
- package/workspace/guide/notes.md +14 -21
- package/workspace/guide/people.md +6 -39
- package/workspace/guide/report-design.md +2 -2
- package/workspace/guide/report-structure.md +1 -1
- package/workspace/guide/reports-set-up.md +9 -10
- package/workspace/guide/reports.md +17 -1
- package/workspace/guide/settings.md +16 -69
- package/workspace/guide/today.md +12 -188
- package/workspace/lib/refresh-run.js +39 -2
- package/workspace/lib/report-checks.js +3 -0
- package/workspace/lib/report-cli.js +112 -3
- package/workspace/lib/report-engine.js +779 -0
- package/workspace/lib/report-export.js +36 -1
- package/workspace/lib/report-pyramid.js +20 -0
- package/workspace/lib/routes-ready-reports.js +3 -1
- package/workspace/lib/routes-report-design.js +7 -2
- package/workspace/lib/routes-report-setups.js +17 -2
- package/workspace/lib/routes-sources.js +6 -2
- package/workspace/lib/source-heal.js +140 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/cozy-evmodel.js +18 -1
- package/workspace/public/pm/cozy-evui.js +1 -0
- package/workspace/public/pm/cozy-nav.js +9 -6
- package/workspace/public/pm/cozy-settings.js +15 -9
- package/workspace/public/pm/premium-vw-guide.css +36 -0
- package/workspace/public/pm/premium-vw-guide.js +137 -0
- package/workspace/public/pm/premium-vw-open.js +4 -1
- package/workspace/public/pm/premium-vw-page.js +2 -1
- package/workspace/run-tests.js +6 -0
- package/workspace/skills/report-biweekly.skill +7 -0
- package/workspace/skills/report-daily.skill +7 -0
- package/workspace/skills/report-monthly.skill +7 -0
- package/workspace/skills/report-special.skill +2 -0
- package/workspace/skills/report-template.skill +8 -1
- package/workspace/skills/report-weekly.skill +7 -0
- package/workspace/skills/report-write.skill +44 -0
- package/workspace/test-lists/report-engine.json +5 -0
- package/workspace/guide/ask-the-agent.md +0 -46
- package/workspace/guide/briefing-cards.md +0 -52
- package/workspace/guide/briefing.md +0 -159
- package/workspace/guide/calendar-popup.md +0 -46
- package/workspace/guide/command-palette.md +0 -77
- package/workspace/guide/console3-sessions.md +0 -147
- package/workspace/guide/document-sheet.md +0 -89
- package/workspace/guide/initiative-boards.md +0 -123
- package/workspace/guide/initiatives-screen.md +0 -179
- package/workspace/guide/initiatives-timeline.md +0 -55
- package/workspace/guide/phone-companion.md +0 -89
- package/workspace/guide/reports-dashboards.md +0 -70
- package/workspace/guide/reports-page.md +0 -87
- package/workspace/guide/timeline.md +0 -70
|
@@ -332,6 +332,40 @@ function pyramidSlideXml(s, L) {
|
|
|
332
332
|
let yy = y + (n ? 146 : 0);
|
|
333
333
|
if (a.more) { text(67, yy, 1146, 60, a.more, { size: 18, color: L.soft }); yy += 70; }
|
|
334
334
|
text(67, yy, 1146, 26, [[['Who ', { bold: true }], [a.who, {}], ...(a.by ? [[' By ', { bold: true }], [a.by, {}]] : [])]], { size: 18, color: L.soft });
|
|
335
|
+
} else if (s.kind === 'beyond') {
|
|
336
|
+
// the report engine's parts beyond the question: numbered, as the answer's supports
|
|
337
|
+
s.items.forEach((x, i) => {
|
|
338
|
+
const yy = y + i * 104;
|
|
339
|
+
if (i) shape(67, yy - 10, 1146, 1, { fill: L.line });
|
|
340
|
+
shape(67, yy, 35, 35, { geom: 'ellipse', fill: L.accent });
|
|
341
|
+
text(67, yy + 6, 35, 24, String(i + 1), { size: 18, bold: true, color: '#FFFFFF', align: 'ctr' });
|
|
342
|
+
text(121, yy, 1092, 30, x.head, { size: 22, bold: true });
|
|
343
|
+
text(121, yy + 32, 1092, 60, x.text, { size: 17, color: L.soft });
|
|
344
|
+
});
|
|
345
|
+
} else if (s.kind === 'section') {
|
|
346
|
+
// a section of an engine report: paragraphs and lists as text, a table as a grid, bars as shapes. Anything below the footer is cut, never squeezed.
|
|
347
|
+
for (const b of s.blocks || []) {
|
|
348
|
+
if (y > 600) break;
|
|
349
|
+
if (b.kind === 'p') { const h = Math.min(120, 26 * Math.ceil(b.text.length / 110) + 6); text(67, y, 1146, h, b.text, { size: 18, color: L.soft }); y += h + 10; }
|
|
350
|
+
else if (b.kind === 'list') { for (const it of b.items) { if (y > 620) break; text(67, y, 1146, 30, '• ' + it, { size: 18, color: L.soft }); y += 34; } y += 8; }
|
|
351
|
+
else if (b.kind === 'table') {
|
|
352
|
+
const n = b.columns.length; const w = 1146 / n; const rowH = 40;
|
|
353
|
+
b.columns.forEach((c, i) => text(67 + i * w + 6, y, w - 12, 22, c.toUpperCase(), { size: 13, bold: true, color: L.muted }));
|
|
354
|
+
shape(67, y + 26, 1146, 2, { fill: '#D3CCBF' }); y += 34;
|
|
355
|
+
for (const r of b.rows) { if (y > 630) break; r.forEach((c, i) => text(67 + i * w + 6, y + 8, w - 12, 26, c, { size: 15, bold: i === 0 })); shape(67, y + rowH, 1146, 1, { fill: L.line }); y += rowH; }
|
|
356
|
+
y += 12;
|
|
357
|
+
} else if (b.kind === 'bars') {
|
|
358
|
+
for (const r of b.rows) {
|
|
359
|
+
if (y > 630) break;
|
|
360
|
+
text(67, y, 290, 26, r.label, { size: 17 });
|
|
361
|
+
shape(367, y + 4, 700, 18, { geom: 'roundRect', fill: L.track });
|
|
362
|
+
shape(367, y + 4, Math.max(1, 700 * r.share), 18, { geom: 'roundRect', fill: L.accent });
|
|
363
|
+
text(1083, y, 130, 26, r.text, { size: 17, bold: true, align: 'r' });
|
|
364
|
+
y += 36;
|
|
365
|
+
}
|
|
366
|
+
y += 10;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
335
369
|
} else if (s.kind === 'sources') {
|
|
336
370
|
text(67, y, 540, 20, 'REACHED', { size: 15, bold: true, color: L.muted });
|
|
337
371
|
(s.reached.length ? s.reached : ['None']).slice(0, 8).forEach((x, i) => text(67, y + 34 + i * 44, 540, 40, x, { size: 18, color: L.soft }));
|
|
@@ -480,7 +514,8 @@ function exportOf(markdown, fmt, opts = {}) {
|
|
|
480
514
|
if (!Object.prototype.hasOwnProperty.call(FORMATS, fmt)) return { ok: false, error: `fmt must be one of ${Object.keys(FORMATS).join(', ')}` };
|
|
481
515
|
const report = parseReport(markdown);
|
|
482
516
|
let buffer;
|
|
483
|
-
|
|
517
|
+
// opts.deck: the report engine's deck (lib/report-engine.js engineDeckOf), so the PPTX holds the slides the reader shows
|
|
518
|
+
try { buffer = fmt === 'pptx' ? pptx(report, opts.deck || require('./report-pyramid.js').deckOf(markdown, opts.look, { data: opts.data || null })) : BUILD[fmt](report); } catch (e) { return { ok: false, error: 'the file could not be built: ' + String((e && e.message) || e).slice(0, 160) }; }
|
|
484
519
|
const stem = plain(report.title).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'report';
|
|
485
520
|
return { ok: true, fmt, name: `${stem}.${fmt}`, mime: FORMATS[fmt].mime, buffer };
|
|
486
521
|
}
|
|
@@ -460,6 +460,12 @@ function slideHtml(s, L) {
|
|
|
460
460
|
const opts = a.options.map((o, i) => `<div class="ask${i ? ' alt' : ''}" style="flex:1"><div class="tag">Option ${'ABC'[i]}</div><div class="q">${esc(o)}</div></div>`).join('');
|
|
461
461
|
body = (opts ? `<div style="display:flex;gap:26px">${opts}</div>` : '') + (a.more ? `<p class="sm" style="margin:${opts ? '22px' : '0'} 0 0">${esc(a.more)}</p>` : '')
|
|
462
462
|
+ `<div class="sm" style="display:flex;gap:45px;margin-top:29px"><span><b>Who</b> ${esc(a.who)}</span>${a.by ? `<span><b>By</b> ${esc(a.by)}</span>` : ''}</div>`;
|
|
463
|
+
} else if (s.kind === 'beyond') {
|
|
464
|
+
// the report engine's parts beyond the question (lib/report-engine.js deckOf): each part a numbered support
|
|
465
|
+
body = s.items.map((x, i) => `<div class="sup"><span class="n">${i + 1}</span><div><b>${esc(x.head)}</b><span>${esc(x.text)}</span></div></div>`).join('');
|
|
466
|
+
} else if (s.kind === 'section') {
|
|
467
|
+
// a section of an engine report: any title the writer chose, its blocks as they are (paragraphs, lists, a table, bars of readings)
|
|
468
|
+
body = (s.blocks || []).map((b) => sectionBlockHtml(b, L)).join('');
|
|
463
469
|
} else if (s.kind === 'sources') {
|
|
464
470
|
const reached = s.reached.map((x) => `<div class="sup"><span class="sm">${esc(x)}</span></div>`).join('') || '<div class="sup"><span class="sm">None</span></div>';
|
|
465
471
|
const miss = s.notReached.map((x) => `<div class="sup"><div><b>${esc(x.source)}</b><span>${esc(cap(x.why))}${x.why ? '.' : ''}</span></div></div>`).join('');
|
|
@@ -467,6 +473,20 @@ function slideHtml(s, L) {
|
|
|
467
473
|
}
|
|
468
474
|
return open('') + `<div class="in">${head(s.tag)}${body}</div>${footer(s)}</section>`;
|
|
469
475
|
}
|
|
476
|
+
/** One block of an engine section slide. */
|
|
477
|
+
function sectionBlockHtml(b, L) {
|
|
478
|
+
if (b.kind === 'p') return `<p class="sm" style="margin:0 0 14px;max-width:1120px">${esc(b.text)}</p>`;
|
|
479
|
+
if (b.kind === 'list') return `<div style="margin:0 0 10px">${b.items.map((x) => `<div class="sup" style="padding:9px 0"><span class="sm">${esc(x)}</span></div>`).join('')}</div>`;
|
|
480
|
+
if (b.kind === 'table') {
|
|
481
|
+
return `<div class="tb" style="grid-template-columns:repeat(${b.columns.length},minmax(0,1fr));font-size:16px;margin:0 0 14px;align-items:stretch">${b.columns.map((c) => `<div class="h">${esc(c)}</div>`).join('')}`
|
|
482
|
+
+ b.rows.map((r) => r.map((c, i) => `<div${i ? '' : ' style="font-weight:800"'}>${esc(c)}</div>`).join('')).join('') + '</div>';
|
|
483
|
+
}
|
|
484
|
+
if (b.kind === 'bars') {
|
|
485
|
+
return `<div style="display:flex;flex-direction:column;gap:12px;margin:0 0 14px">${b.rows.map((r) => `<div style="display:flex;align-items:center;gap:16px;font-size:18px"><span style="width:300px;flex:none">${esc(r.label)}</span>`
|
|
486
|
+
+ `<span style="flex:1;height:20px;background:${L.track};border-radius:4px;overflow:hidden"><i style="display:block;height:100%;width:${Math.round(r.share * 1000) / 10}%;background:${L.accent}"></i></span><b style="width:120px;flex:none;text-align:right">${esc(r.text)}</b></div>`).join('')}</div>`;
|
|
487
|
+
}
|
|
488
|
+
return '';
|
|
489
|
+
}
|
|
470
490
|
/** The deck as one HTML string (the viewer's frames and the PDF print). */
|
|
471
491
|
function deckHtml(deck) {
|
|
472
492
|
if (!deck) return '';
|
|
@@ -437,7 +437,9 @@ async function tickScope(ctx, w, s, now, cache = null) {
|
|
|
437
437
|
const e = ensureSetup(w, s.scope, m.kind, s.name, { now });
|
|
438
438
|
if (!e.ok) { out.push({ scope: s.scope, kind: m.kind, ok: false, error: e.error }); continue; }
|
|
439
439
|
const r = await SR.draftIntelligence(ctx, w, e.setup, m.slot, { model: X.model, now, ready: extras(X) });
|
|
440
|
-
if (r.ok || r.existing) RR.append(RR.ledgerFile(stateDir(w)), { ev: 'moment', key, slot: m.slot, why: m.why }, { now });
|
|
440
|
+
if (r.ok || r.existing || r.quiet) RR.append(RR.ledgerFile(stateDir(w)), { ev: 'moment', key, slot: m.slot, why: m.why }, { now });
|
|
441
|
+
// nothing to report at this moment: one quiet line (Your day says it), no draft to grade
|
|
442
|
+
if (r.quiet) RR.append(RR.ledgerFile(stateDir(w)), { ev: 'quiet', key, slot: m.slot }, { now });
|
|
441
443
|
out.push({ scope: s.scope, kind: m.kind, slot: m.slot, why: m.why, ...r });
|
|
442
444
|
}
|
|
443
445
|
// a heads-up: a broken health budget or a critical slip, at once and outside the schedule (PbG3)
|
|
@@ -49,6 +49,7 @@ const RS = require('./report-setups.js');
|
|
|
49
49
|
const RC = require('./report-checks.js');
|
|
50
50
|
const RDraft = require('./report-draft.js');
|
|
51
51
|
const RP = require('./report-pyramid.js');
|
|
52
|
+
const RE = require('./report-engine.js');
|
|
52
53
|
const headless = require('./headless.js');
|
|
53
54
|
|
|
54
55
|
const text = (v) => (typeof v === 'string' ? v : '');
|
|
@@ -293,6 +294,8 @@ function startCycle(ctx, w, cadence, rel) {
|
|
|
293
294
|
const st = D.stateOf(w.root, cadence);
|
|
294
295
|
if (!st.saved) return { ok: false, error: 'no saved design' };
|
|
295
296
|
if (D.isIssuedSlot(w.root, cadence, c.slot)) return { ok: false, error: 'issued' };
|
|
297
|
+
// the report engine draws its own deck from report.json; the saved design is its theme, so no per-cycle render rewrites its words
|
|
298
|
+
if (RE.engineReportOf(c.md)) return { ok: false, error: 'drawn by the report engine' };
|
|
296
299
|
const key = keyOf(w.root, cadence);
|
|
297
300
|
if (jobs.has(key)) {
|
|
298
301
|
const j = jobs.get(key);
|
|
@@ -436,7 +439,8 @@ function cycleAnswer(w, c) {
|
|
|
436
439
|
const differences = rec && Array.isArray(rec.differences) ? rec.differences : [];
|
|
437
440
|
// designed: THIS cycle has a render drawn to a saved design (the contract's answer is per document). A cycle drafted before the design,
|
|
438
441
|
// or one whose first render is still running, is not designed yet: the page reads it in its format view and fetches no cycle-html.
|
|
439
|
-
|
|
442
|
+
const engine = !!RE.engineReportOf(c.md);
|
|
443
|
+
return { ok: true, designed: !engine && !!st.saved && !!rec, engine, design_version: rec ? rec.design_version : null,
|
|
440
444
|
format: (rec && rec.format) || (st.saved && (D.versionMeta(w.root, c.cadence, st.saved.version) || {}).format) || st.format || 'deck',
|
|
441
445
|
outline: rec && Array.isArray(rec.outline) ? rec.outline : [], differences, decisions: rec && Array.isArray(rec.decisions) ? rec.decisions : [],
|
|
442
446
|
matches: !!rec && differences.length === 0, rendering: !!(j && j.slotPath === c.rel), previous: prev ? { path: prev.path, slot: prev.slot } : null };
|
|
@@ -482,7 +486,8 @@ async function handle(req, res, url, ctx) {
|
|
|
482
486
|
// The finished slides of a cadence draft (canvas RsA): the Pyramid deck in the report's saved look, else the standard PMOS look.
|
|
483
487
|
const d = reportDoc(w, text(url.searchParams.get('path'))); if (d.error) return send(d.error);
|
|
484
488
|
let md; try { md = fs.readFileSync(d.c.md, 'utf8'); } catch { return send(bad(404, 'no such report document')); }
|
|
485
|
-
|
|
489
|
+
// a draft the report engine wrote is drawn from its own model (report.json), in the report's look: the reader, the PDF and the PPTX are one deck
|
|
490
|
+
const deck = RE.engineDeckOf(d.c.md, RP.lookOf(w.root, d.c.cadence)) || RP.deckOf(md, RP.lookOf(w.root, d.c.cadence), { data: RP.dataOf(d.c.md) });
|
|
486
491
|
if (!deck) return send(bad(404, 'this document is not a cadence draft, so it has no Pyramid deck'));
|
|
487
492
|
return sendHtml(res, D.sanitize(RP.deckHtml(deck)));
|
|
488
493
|
}
|
|
@@ -254,7 +254,10 @@ async function tick(ctx, now = new Date()) {
|
|
|
254
254
|
if (s.drafted.some((d) => d.slot === slot)) continue;
|
|
255
255
|
let out;
|
|
256
256
|
if (IR.isIntelligence(s.source)) {
|
|
257
|
+
const qk = `${w.root}|${s.id}|${slot}`;
|
|
258
|
+
if (quietSlots.has(qk)) continue; // nothing to report at this slot already: not read again every tick
|
|
257
259
|
try { out = await draftIntelligence({ ...ctx, getWs: (id) => (id === w.id || id == null ? w : ctx.getWs(id)) }, w, s, slot, { now }); } catch (e) { out = { ok: false, error: String((e && e.message) || e) }; }
|
|
260
|
+
if (out && out.quiet) quietSlots.add(qk);
|
|
258
261
|
done.push({ id: s.id, slot, ...out });
|
|
259
262
|
continue;
|
|
260
263
|
}
|
|
@@ -293,7 +296,9 @@ function exportAnswer(ctx, res, w, url) {
|
|
|
293
296
|
} catch { return bad(404, 'no such document'); }
|
|
294
297
|
const cyc = /(?:^|\/)reports\/([a-z][a-z0-9-]{0,39})\/\d{4}-\d{2}-\d{2}\.md$/.exec(rel);
|
|
295
298
|
const RPy = require('./report-pyramid.js');
|
|
296
|
-
const
|
|
299
|
+
const look = fmt === 'pptx' && cyc ? RPy.lookOf(abs, cyc[1]) : null;
|
|
300
|
+
const deck = look ? require('./report-engine.js').engineDeckOf(full, look) : null;
|
|
301
|
+
const out = RX.exportOf(md, fmt, fmt === 'pptx' && cyc ? { look, data: RPy.dataOf(full), ...(deck ? { deck } : {}) } : {});
|
|
297
302
|
if (!out.ok) return bad(500, out.error);
|
|
298
303
|
return { status: 200, file: out };
|
|
299
304
|
}
|
|
@@ -563,6 +568,8 @@ async function draftIntelligence(ctx, w, s, slot, { model, now = new Date(), rea
|
|
|
563
568
|
try { if (fs.statSync(path.join(path.resolve(w.root), rel)).isFile()) return { ok: false, existing: true, slot, path: rel, error: 'drafted already' }; } catch { /* not there yet */ }
|
|
564
569
|
const m = model || await intelModel(null, ctx, w.id, null);
|
|
565
570
|
if (!m) return { ok: false, error: 'Intelligence could not be read' };
|
|
571
|
+
// no draft without a finding (design R item 2, report engine E step 3): a scope with no reading, no verdict and no read budget has nothing to report
|
|
572
|
+
if (!hasFinding(m, s)) return { ok: false, quiet: true, status: 422, slot, error: 'nothing to report: no goal here has a reading yet' };
|
|
566
573
|
const mine = (RN.reportsOf(w.root).reports || []).filter((x) => x.cadence === s.id && x.slot < slot).sort((a, b) => (a.slot < b.slot ? -1 : 1));
|
|
567
574
|
const before = mine.filter((x) => x.issued).pop() || null;
|
|
568
575
|
let prior = null;
|
|
@@ -572,6 +579,14 @@ async function draftIntelligence(ctx, w, s, slot, { model, now = new Date(), rea
|
|
|
572
579
|
return r.ok ? { ok: true, slot, path: r.path } : { ok: false, status: r.status, error: r.error };
|
|
573
580
|
}
|
|
574
581
|
|
|
582
|
+
/** The slots of Intelligence reports that had nothing to report, in this process: the tick does not read them again. */
|
|
583
|
+
const quietSlots = new Set();
|
|
584
|
+
/** True when Intelligence holds something to report for the setup's scope: a reading on a goal or budget, or a recorded verdict. */
|
|
585
|
+
function hasFinding(model, setup) {
|
|
586
|
+
const scope = IR.scopeOf(setup && setup.source) || 'view';
|
|
587
|
+
const sl = IR.storylineOf(model, { scope });
|
|
588
|
+
return sl.items.some((it) => it.kind === 'scorecard' || it.kind === 'exhibit' || it.kind === 'verdict');
|
|
589
|
+
}
|
|
575
590
|
/** Draft a report's cycle now through the one writer (report-draft.js writeDraft). → { slot, path } | { error } */
|
|
576
591
|
function draftCycle(root, cadence, slot, actor) {
|
|
577
592
|
const d = RD.draftOf(root, { cadence, slot: slot || undefined });
|
|
@@ -587,4 +602,4 @@ function firstDraft(root, r, actor) {
|
|
|
587
602
|
return draftCycle(root, r.setup.id, r.first_draft && r.first_draft.slot, actor);
|
|
588
603
|
}
|
|
589
604
|
|
|
590
|
-
module.exports = { prefixes: ['/api/report-setups', '/api/report-documents', '/api/report-export'], handle, tick, ensureMigrated, documentsOf, listAnswer, copyOf, draftIntelligence, intelModel };
|
|
605
|
+
module.exports = { prefixes: ['/api/report-setups', '/api/report-documents', '/api/report-export'], handle, tick, ensureMigrated, documentsOf, listAnswer, copyOf, draftIntelligence, intelModel, hasFinding };
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* lib/harness-health.js of(): { level, line, issues } or null; all is the last Refresh all
|
|
6
6
|
* (lib/refresh-run.js allState) or null; derived is the content built from stored reads
|
|
7
7
|
* (refresh-run derived: the brief, core documents, the scan, and the reads each was built from).
|
|
8
|
-
* Each source row has a group: 'machine' (read on this machine) or 'connector'
|
|
8
|
+
* Each source row has a group: 'machine' (read on this machine) or 'connector',
|
|
9
|
+
* and `heal` while Claude repairs it by itself or it needs the PM (lib/source-heal.js view())
|
|
9
10
|
* POST /api/sources/refresh { ws, source } → { ok, source, started: 'rebuild'|'session', session? }
|
|
10
11
|
* POST /api/sources/refresh-all { ws } → { ok, joined, started, already, failed, all } every source that is on, then the
|
|
11
12
|
* Briefing from those reads (briefing-source-flow, slice 3); a press while one runs joins it
|
|
@@ -18,6 +19,7 @@
|
|
|
18
19
|
*/
|
|
19
20
|
const guard = require('./pm-guard.js');
|
|
20
21
|
const freshness = require('./freshness.js');
|
|
22
|
+
const heal_ = require('./source-heal.js');
|
|
21
23
|
|
|
22
24
|
function wsOf(ctx, id) {
|
|
23
25
|
const w = ctx.getWs(id || '');
|
|
@@ -33,7 +35,9 @@ async function handle(req, res, url, ctx) {
|
|
|
33
35
|
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
34
36
|
const w = wsOf(ctx, url.searchParams.get('ws'));
|
|
35
37
|
if (!w) { ctx.json(res, { ok: false, error: 'no such workspace' }, 404); return true; }
|
|
36
|
-
|
|
38
|
+
let heal = {}; try { heal = ctx.runtimeDir ? heal_.viewAll(ctx.runtimeDir) : {}; } catch { heal = {}; }
|
|
39
|
+
// self-heal (lib/source-heal.js): a row Claude is repairing, or that needs the PM, carries its repair
|
|
40
|
+
const sources = F.sources({ id: w.id, root: w.root, repoStateDir: repoStateDir(ctx, w) }).map((r) => (heal[r.id] ? { ...r, heal: heal[r.id] } : r));
|
|
37
41
|
let health = null; try { health = ctx.health ? ctx.health() : null; } catch { health = null; }
|
|
38
42
|
let all = null; try { all = ctx.refreshAllState ? ctx.refreshAllState() : null; } catch { all = null; }
|
|
39
43
|
let derived = []; try { derived = ctx.refreshDerived ? ctx.refreshDerived(w.id) : []; } catch { derived = []; }
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* source-heal.js — Claude repairs a source that is behind by itself, and asks the PM only when the PM must act
|
|
3
|
+
* (feedback batch 26 item 7, Wawan 2026-11-03: "any issue with connection should self diagnose and resolve unless necessary for
|
|
4
|
+
* the user to take action").
|
|
5
|
+
*
|
|
6
|
+
* diagnose(error, { result }) → cause why a read failed: one of CAUSES' keys
|
|
7
|
+
* step(x) → { h, start?, note?, tell? } one tick for one source: what to do now, and its new repair record
|
|
8
|
+
* view(h, name) → { state, cause, why, tried, action, attempts, next_at } | null what /api/sources shows on the row
|
|
9
|
+
* MAX · BACKOFF_MIN · CAUSES
|
|
10
|
+
*
|
|
11
|
+
* THE REPAIR. A source that is on and behind (stale, failed or never read) is read again by Claude, without a press. Attempt 1 starts
|
|
12
|
+
* at once; the next ones wait BACKOFF_MIN minutes (longer after a rate limit). After each attempt the cause is diagnosed from the
|
|
13
|
+
* run's end (the log's error), and a read that ended "done" while the source is still behind counts as failed ("saved nothing new"):
|
|
14
|
+
* a source is fresh only when a read proved it. A cause the PM must fix (a connector that is not connected, a sign-in that still
|
|
15
|
+
* fails after one more try) stops the repair at once; any other cause stops after MAX attempts. Then the record is `needs-you`,
|
|
16
|
+
* with the one action and what Claude tried. The schedule keeps running all the time, so a source the PM fixed turns fresh on its
|
|
17
|
+
* next read and the record becomes `repaired`.
|
|
18
|
+
*
|
|
19
|
+
* A source that is off is left alone (the PM chose it). A source that is read only on a press has no schedule to be past, so its
|
|
20
|
+
* one action is the press.
|
|
21
|
+
*
|
|
22
|
+
* PURE but for viewAll(dir), which reads <state>/source-heal.json for /api/sources (any server, not only the one that runs the
|
|
23
|
+
* schedules). lib/refresh-run.js holds the records, starts the reads, and writes each attempt to the refresh log (trigger 'repair',
|
|
24
|
+
* with the attempt and the last cause in its note).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const MAX = 5;
|
|
28
|
+
const BACKOFF_MIN = Object.freeze([0, 2, 10, 30, 120]); // minutes before attempt n+1, by the attempts made so far
|
|
29
|
+
const RATE_MIN = Object.freeze([0, 15, 60, 120, 240]); // the same after a rate limit
|
|
30
|
+
const KEEP_REPAIRED_MS = 24 * 3600 * 1000; // a repaired record is kept this long, then dropped
|
|
31
|
+
|
|
32
|
+
const CONNECTORS = 'https://claude.ai/settings/connectors';
|
|
33
|
+
/* cause → why (the words after "because"), user: the PM must act, after: attempts before that is sure (1 = the first failure tells) */
|
|
34
|
+
const CAUSES = Object.freeze({
|
|
35
|
+
'claude-signin': { why: 'Claude is signed out on this computer', user: true, after: 2 },
|
|
36
|
+
'connector-missing': { why: 'its connector is not connected', user: true, after: 1 },
|
|
37
|
+
'sign-in': { why: 'its sign-in has expired', user: true, after: 2 },
|
|
38
|
+
'rate-limit': { why: 'it asked Claude to slow down', user: false },
|
|
39
|
+
network: { why: 'the network or the proxy did not answer', user: false },
|
|
40
|
+
'connector-failed': { why: 'its connector did not start', user: false },
|
|
41
|
+
interrupted: { why: 'the read was stopped before it ended', user: false },
|
|
42
|
+
empty: { why: 'the read ended but saved nothing new', user: false },
|
|
43
|
+
skill: { why: 'the read stopped with an error', user: false },
|
|
44
|
+
manual: { why: 'it is read only when you press ↻', user: true, after: 0 },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** Why a read failed, from the error its run ended with. The order matters: Claude's own sign-in first, then a missing connector. */
|
|
48
|
+
function diagnose(error, { result } = {}) {
|
|
49
|
+
const e = String(error || '').toLowerCase();
|
|
50
|
+
if (result === 'done') return 'empty';
|
|
51
|
+
if (/claude is signed out|not logged in|please run \/login|type \/login|invalid api key/.test(e)) return 'claude-signin';
|
|
52
|
+
if (/not connected|connector (is )?(missing|not (found|installed|available|configured|enabled))|no (\w+ )?(connector|mcp server)|no such tool|tools? .{0,40}not (available|found)|mcp server .{0,40}not (found|configured)/.test(e)) return 'connector-missing';
|
|
53
|
+
if (/\b429\b|rate.?limit|too many requests|quota|throttl/.test(e)) return 'rate-limit';
|
|
54
|
+
if (/\b401\b|unauthori[sz]ed|token (has )?expired|expired (token|session|credentials)|re-?authori[sz]|re-?authenticat|sign.?in (has )?expired|reconnect|invalid[_ ]grant|oauth/.test(e)) return 'sign-in';
|
|
55
|
+
if (/econn|etimedout|enotfound|eai_again|network|proxy|socket hang up|tunnel|\b50[234]\b|unreachable|\bdns\b|fetch failed/.test(e)) return 'network';
|
|
56
|
+
if (/failed to connect|mcp.{0,40}(error|failed)|connector.{0,40}(failed|error)/.test(e)) return 'connector-failed';
|
|
57
|
+
if (result === 'interrupted' || /stopped by you|restarted|session was gone/.test(e)) return 'interrupted';
|
|
58
|
+
return 'skill';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const behindOf = (row) => !!row && ['stale', 'failed', 'never'].includes(row.state);
|
|
62
|
+
const waitMin = (cause, n) => { const L = cause === 'rate-limit' ? RATE_MIN : BACKOFF_MIN; return L[Math.min(n, L.length - 1)]; };
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One tick for one source. Every field is plain data:
|
|
66
|
+
* x { now, row (lib/freshness.js row), on, manual, busy (a run is going, or the schedule's own retry or a press waits),
|
|
67
|
+
* last (the source's last run in the log: { start_at, end_at, result, error }), h (its repair record or null), canStart }
|
|
68
|
+
* → { h, start?: true, note?, tell?: { cause, why } } h null drops the record
|
|
69
|
+
*/
|
|
70
|
+
function step(x) {
|
|
71
|
+
const { now, row } = x;
|
|
72
|
+
let h = x.h ? { ...x.h, attempts: (x.h.attempts || []).slice() } : null;
|
|
73
|
+
if (!row || !x.on) return { h: null };
|
|
74
|
+
if (!behindOf(row)) {
|
|
75
|
+
if (h && h.state !== 'repaired' && (h.attempts.length || h.state === 'needs-you')) return { h: { ...h, state: 'repaired', repaired_at: now, open: null, next_at: null } };
|
|
76
|
+
if (h && h.state === 'repaired' && now - (h.repaired_at || 0) < KEEP_REPAIRED_MS) return { h };
|
|
77
|
+
return { h: null };
|
|
78
|
+
}
|
|
79
|
+
if (x.manual) return { h: { state: 'needs-you', cause: 'manual', attempts: [], since: (h && h.since) || now } };
|
|
80
|
+
if (!h || h.state === 'repaired' || h.cause === 'manual') h = { state: 'repairing', since: now, attempts: [], open: null, cause: null, next_at: null };
|
|
81
|
+
|
|
82
|
+
// the attempt that is open ends when its run ends in the log
|
|
83
|
+
if (h.open) {
|
|
84
|
+
const l = x.last;
|
|
85
|
+
const ended = l && l.end_at && Date.parse(l.start_at || l.end_at) >= h.open.at - 1000;
|
|
86
|
+
if (!ended) {
|
|
87
|
+
if (now - h.open.at < 30 * 60000 || x.busy) return { h: { ...h, state: 'repairing' } };
|
|
88
|
+
h.attempts.push({ at: h.open.at, result: 'lost', cause: 'interrupted' }); // no run came of it in 30 minutes
|
|
89
|
+
} else {
|
|
90
|
+
const cause = diagnose(l.error, { result: l.result });
|
|
91
|
+
h.attempts.push({ at: h.open.at, result: l.result, cause, error: l.error ? String(l.error).slice(0, 300) : null });
|
|
92
|
+
}
|
|
93
|
+
h.open = null; h.cause = h.attempts[h.attempts.length - 1].cause;
|
|
94
|
+
}
|
|
95
|
+
if (x.busy) return { h: { ...h, state: h.state === 'needs-you' ? 'needs-you' : 'repairing' } };
|
|
96
|
+
if (h.state === 'needs-you') return { h }; // the schedule keeps reading; a good read makes it repaired
|
|
97
|
+
|
|
98
|
+
const n = h.attempts.length;
|
|
99
|
+
const c = h.cause && CAUSES[h.cause];
|
|
100
|
+
const same = n >= (c && c.after || 1) && h.attempts.slice(-(c && c.after || 1)).every((a) => a.cause === h.cause);
|
|
101
|
+
if ((c && c.user && same) || n >= MAX) {
|
|
102
|
+
return { h: { ...h, state: 'needs-you', next_at: null, told_at: h.told_at || now }, tell: h.told_at ? null : { cause: h.cause, why: c ? c.why : '' } };
|
|
103
|
+
}
|
|
104
|
+
const next = n ? h.attempts[n - 1].at + waitMin(h.cause, n) * 60000 : now;
|
|
105
|
+
if (now < next || !x.canStart) return { h: { ...h, state: 'repairing', next_at: Math.max(next, now) } };
|
|
106
|
+
h.open = { at: now };
|
|
107
|
+
return { h: { ...h, state: 'repairing', next_at: null }, start: true, note: `Repair ${n + 1} of ${MAX}` + (c ? `: the last read failed because ${c.why}` : `: ${row.state === 'never' ? 'never read' : row.state === 'failed' ? 'the last read failed' : 'past its schedule'}`) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const hm = (ms) => { const d = new Date(ms); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); };
|
|
111
|
+
|
|
112
|
+
/** What the Sources row and the banner show. `name` is the source's name ('Jira'). */
|
|
113
|
+
function view(h, name = 'It') {
|
|
114
|
+
if (!h) return null;
|
|
115
|
+
const c = h.cause ? CAUSES[h.cause] : null;
|
|
116
|
+
const n = (h.attempts || []).length;
|
|
117
|
+
const tried = n ? `Claude read it again ${n === 1 ? 'once' : n + ' times'} since ${hm(h.attempts[0].at)}${c ? `, and ${c.why}` : ''}.` : '';
|
|
118
|
+
let action = null;
|
|
119
|
+
if (h.state === 'needs-you') {
|
|
120
|
+
if (h.cause === 'claude-signin') action = { label: 'Sign in to Claude', how: 'Open Terminal, run claude, type /login and sign in.', sec: 'claude' };
|
|
121
|
+
else if (h.cause === 'connector-missing') action = { label: 'Connect ' + name, how: 'Turn on its connector in your Claude connectors.', href: CONNECTORS };
|
|
122
|
+
else if (h.cause === 'sign-in') action = { label: 'Reconnect ' + name, how: 'Sign in to its connector again in your Claude connectors.', href: CONNECTORS };
|
|
123
|
+
else action = { label: 'Read ' + name + ' again', how: h.cause === 'manual' ? 'You chose to read it only when you press ↻.' : 'Claude stopped after ' + n + ' tries.', read: true };
|
|
124
|
+
}
|
|
125
|
+
return { state: h.state, cause: h.cause || null, why: c ? c.why : '', tried, action, attempts: n, next_at: h.next_at ? new Date(h.next_at).toISOString() : null,
|
|
126
|
+
...(h.repaired_at ? { repaired_at: new Date(h.repaired_at).toISOString() } : {}) };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const NAME = Object.freeze({ calendar: 'your calendar', jira: 'Jira', 'my-work': 'Jira', tracker: 'the tracker files', tools: 'your tools' }); // who the PM reconnects
|
|
130
|
+
const FILE = 'source-heal.json';
|
|
131
|
+
/** Every repair record as view() shows it, keyed by its lib/freshness.js id. A missing or torn file is no repair. */
|
|
132
|
+
function viewAll(dir) {
|
|
133
|
+
let x = {};
|
|
134
|
+
try { x = JSON.parse(require('fs').readFileSync(require('path').join(dir, FILE), 'utf8')) || {}; } catch { return {}; }
|
|
135
|
+
const out = {};
|
|
136
|
+
for (const [id, h] of Object.entries(x)) if (h && typeof h === 'object') out[h.fresh || id] = view(h, NAME[h.fresh || id] || id);
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = { viewAll, FILE, NAME, diagnose, step, view, MAX, BACKOFF_MIN, RATE_MIN, CAUSES, CONNECTORS };
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.518.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.518.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@anthropic-ai/sdk": "^1.3.0",
|
package/workspace/package.json
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* ago(iso, now) → '2 min ago' · '1 hour ago' · '5 days ago' · ''
|
|
5
5
|
* sourceName(row) → the name a source shows: 'Jira', 'Calendar', 'Repository', …
|
|
6
6
|
* staleOf(sources, now) → { id, name, text: 'Jira 5 days old', ago, state } | null the source furthest behind (failed, then the oldest)
|
|
7
|
+
* bannerOf(sources, now) → { id, name, title: 'Reconnect Jira.', body, text, action } | null the one source the PM must act on
|
|
8
|
+
* (self-heal, feedback batch 26 item 7): null while Claude repairs a source by itself, or after it did
|
|
7
9
|
* sourceRows(sources, now) → [{ id, name, sub, when, risk, act: 'read' | 'connect' | '' }] the Sources table (EvSources)
|
|
8
10
|
* partsOf(id) → what may be out of date when that source is old ('Board and the Jira-linked initiatives')
|
|
9
11
|
* fabLabel(here, open, stale, news) → 'Open the menu. You are in Home. 3 new for you. Jira 5 days old' (the button says both dots, FmGrill 13)
|
|
@@ -44,11 +46,26 @@ export function staleOf(sources, now = Date.now()) {
|
|
|
44
46
|
return { id: s.id, name, text, ago: ago(s.as_of, now), state: s.state };
|
|
45
47
|
}
|
|
46
48
|
|
|
49
|
+
/** The one source the PM must act on, or null. The server repairs a source that is behind by itself (lib/source-heal.js) and marks a
|
|
50
|
+
* row `heal.state: 'needs-you'` only when the PM must act; until then, and after a repair, there is no banner. The body says what is
|
|
51
|
+
* old and why, what it makes old, and what Claude already tried. It never says a source is fresh: only a read says that. */
|
|
52
|
+
export function bannerOf(sources, now = Date.now()) {
|
|
53
|
+
const L = (sources || []).filter((s) => s && s.heal && s.heal.state === 'needs-you' && s.heal.action);
|
|
54
|
+
if (!L.length) return null;
|
|
55
|
+
const rank = (s) => (s.heal.action.read ? 1 : 0); // a connection to fix leads a read to press
|
|
56
|
+
L.sort((a, b) => rank(a) - rank(b) || (Date.parse(a.as_of || '') || 0) - (Date.parse(b.as_of || '') || 0));
|
|
57
|
+
const s = L[0], h = s.heal, name = sourceName(s);
|
|
58
|
+
const when = s.as_of ? name + ' was last read ' + ago(s.as_of, now) : name + ' was never read';
|
|
59
|
+
const body = when + (h.why ? ' because ' + h.why : '') + '. ' + partsOf(s.id) + ' may be out of date.' + (h.tried ? ' ' + h.tried : '');
|
|
60
|
+
return { id: s.id, name, title: h.action.label + '.', body, text: h.action.label, action: h.action, cause: h.cause };
|
|
61
|
+
}
|
|
62
|
+
|
|
47
63
|
/** The Sources table: one row per source, in the order the server lists them. */
|
|
48
64
|
export function sourceRows(sources, now = Date.now()) {
|
|
49
65
|
return (sources || []).filter(Boolean).map((s) => {
|
|
50
66
|
const behind = s.state === 'stale' || s.state === 'failed' || s.state === 'never';
|
|
51
|
-
const
|
|
67
|
+
const mend = behind && s.heal && s.heal.state === 'repairing';
|
|
68
|
+
const when = s.state === 'off' ? 'Not connected' : s.state === 'building' ? 'Reading now' : mend ? 'Reading again' : s.state === 'never' ? 'Never read'
|
|
52
69
|
: s.state === 'failed' ? 'Could not be read' : s.id === 'repository' && s.state === 'fresh' ? 'Watching' : ago(s.as_of, now) || 'Never read';
|
|
53
70
|
return { id: s.id, name: sourceName(s), sub: SRC_SUB[s.id] || s.label || '', when, risk: behind, act: s.state === 'off' ? 'connect' : behind && s.id !== 'repository' ? 'read' : '' };
|
|
54
71
|
});
|
|
@@ -23,6 +23,7 @@ export const ICON = {
|
|
|
23
23
|
refresh: 'M4 12a8 8 0 0 1 14-5.3M20 4v4h-4M20 12a8 8 0 0 1-14 5.3M4 20v-4h4', jira: 'M4 4h16v16H4zM8.5 12.5l2.5 2.5 4.5-5', cal: 'M4 5h16v15H4zM4 10h16M8 3v4M16 3v4',
|
|
24
24
|
repo: 'M6 3h12v18l-6-4-6 4z', mic: 'M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3zM5 11a7 7 0 0 0 14 0M12 18v3', tools: 'M3 6h18v12H3zM3 7l9 6 9-6',
|
|
25
25
|
analytics: 'M4 20V10M10 20V4M16 20v-8M22 20H2', check: 'M5 12l4.5 4.5L19 7',
|
|
26
|
+
guide: 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6v.6M12 17h.01',
|
|
26
27
|
};
|
|
27
28
|
export function toast(t) { const el = typeof document !== 'undefined' && document.getElementById('toast'); if (!el) return; el.textContent = t; el.classList.add('on'); clearTimeout(toast.t); toast.t = setTimeout(() => el.classList.remove('on'), 4200); }
|
|
28
29
|
export const wsParam = () => (typeof location !== 'undefined' ? new URLSearchParams(location.search).get('ws') || '' : '');
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
* item 5, canvas FmA1Menu and FmA4Desk: fewer menus).
|
|
4
4
|
*
|
|
5
5
|
* One button, bottom left on desktop and bottom right on a phone, opens the menu: the three places, Home, Board and Intelligence; then a
|
|
6
|
-
* tray of
|
|
6
|
+
* tray of three icons, Search (⌘K on a desk), Guide and Settings. It is the one way between places, and it holds nothing else: New for you is a fold
|
|
7
7
|
* in Your day, Sources are Settings › Connectors, Theme is Settings › General, and More is gone (feedback batch 16 item 5).
|
|
8
|
-
* The button keeps its two dots on every page: blue when something is new for you, amber when a source
|
|
8
|
+
* The button keeps its two dots on every page: blue when something is new for you, amber when a source needs you (Claude repairs the rest by itself). Open, the blue dot sits on
|
|
9
9
|
* the Home row (where Your day is) and the amber dot on the Settings icon; the button's own name says both ("Open the menu. 3 new for you.
|
|
10
10
|
* Jira is stale.").
|
|
11
11
|
* Search pm/cozy-search.js: across the repository and every worktree; Ctrl K (Cmd K) or "/" opens it from any page
|
|
12
|
-
*
|
|
12
|
+
* Guide pm/premium-vw-guide.js: the user guide's Start here, in the Viewer of the page you are on (initiative guide-in-app)
|
|
13
|
+
* Settings pm/cozy-settings.js; it opens at Connectors while a source needs you, so that one is the first thing there
|
|
13
14
|
* A page that passes opts.here() runs it when its own place is pressed, in place of a reload (Home: Menu > Home closes the page that is open).
|
|
14
15
|
* The counts come from GET /api/news-for-you and GET /api/sources, read when the page opens and every two minutes while it is shown; each
|
|
15
16
|
* read of New for you is passed on as the event 'cz:news-for-you' (detail: the answer), so Home's Your day and Good morning follow it.
|
|
@@ -19,7 +20,7 @@
|
|
|
19
20
|
* call, the menu open, a desk, Settings > Adler not Full, or after Quiet after: no ears (zero is silence).
|
|
20
21
|
*/
|
|
21
22
|
import { esc, svg, ICON, wsQ } from './cozy-evui.js';
|
|
22
|
-
import { fabLabel,
|
|
23
|
+
import { fabLabel, bannerOf } from './cozy-evmodel.js';
|
|
23
24
|
import { mountToastFaces, loadPrefs, adlerPeek, asleep } from './cozy-adler.js'; // Adler (batch 16 item 2): every toast carries his S face; Settings > Adler is read once
|
|
24
25
|
|
|
25
26
|
export const PLACES = [
|
|
@@ -55,6 +56,7 @@ export function menuHtml(here, o = {}) {
|
|
|
55
56
|
}).join('')
|
|
56
57
|
+ '<div class="ev-tray">'
|
|
57
58
|
+ `<button type="button" class="ev-tb" data-cz-search aria-label="Search (⌘K)" title="Search ⌘K">${svg(ICON.search, 20)}</button>`
|
|
59
|
+
+ `<button type="button" class="ev-tb" data-cz-guide aria-label="Guide" title="Guide">${svg(ICON.guide, 20)}</button>`
|
|
58
60
|
+ `<button type="button" class="ev-tb" data-cz-settings aria-label="Settings${o.stale ? '. ' + esc(o.stale.text) : ''}" title="Settings">${svg(ICON.settings, 20)}${o.stale ? dot('risk') : ''}</button>`
|
|
59
61
|
+ '</div>';
|
|
60
62
|
}
|
|
@@ -116,6 +118,7 @@ export function mountNav(here, doc = document, opts = {}) {
|
|
|
116
118
|
set(false);
|
|
117
119
|
if ('czSearch' in b.dataset) import('./cozy-search.js').then((M) => M.openSearch(opts.search ? { go: opts.search() } : {}));
|
|
118
120
|
// a stale source opens Settings at Connectors, the stale one first (FmA3Settings: open where it matters)
|
|
121
|
+
else if ('czGuide' in b.dataset) import('./premium-vw-open.js').then((M) => M.openGuide('index'));
|
|
119
122
|
else if ('czSettings' in b.dataset) import('./cozy-settings.js').then((M) => M.openSettings(N.stale ? 'connectors' : undefined));
|
|
120
123
|
});
|
|
121
124
|
// Ctrl K (Cmd K) and "/" open Search from any page; a second press focuses it
|
|
@@ -131,7 +134,7 @@ export function mountNav(here, doc = document, opts = {}) {
|
|
|
131
134
|
if (doc.hidden) return;
|
|
132
135
|
const [n, s] = await Promise.all([getj('/api/news-for-you' + wsQ()), getj('/api/sources' + wsQ())]);
|
|
133
136
|
if (n && n.ok) { N.news = n.count || 0; doc.dispatchEvent(new CustomEvent('cz:news-for-you', { detail: n })); }
|
|
134
|
-
if (s && s.ok) { N.stale =
|
|
137
|
+
if (s && s.ok) { N.stale = bannerOf(s.sources); doc.dispatchEvent(new CustomEvent('cz:sources', { detail: s })); } // Your day's failed source (AdP5)
|
|
135
138
|
if (menu.hidden || !menu.contains(doc.activeElement)) draw();
|
|
136
139
|
};
|
|
137
140
|
read();
|
|
@@ -140,6 +143,6 @@ export function mountNav(here, doc = document, opts = {}) {
|
|
|
140
143
|
doc.addEventListener('cz:news', (e) => { if (e.detail && Number.isFinite(e.detail.count)) { N.news = e.detail.count; draw(); } else read(); });
|
|
141
144
|
doc.addEventListener('cz:calls', (e) => { const n = e.detail && Number(e.detail.count); N.calls = Number.isFinite(n) && n > 0 ? n : 0; draw(); });
|
|
142
145
|
doc.addEventListener('adler:prefs', () => { ears.innerHTML = ''; draw(); });
|
|
143
|
-
window.addEventListener('pm:sources', (e) => { const d = e.detail; if (d && d.ok) { N.stale =
|
|
146
|
+
window.addEventListener('pm:sources', (e) => { const d = e.detail; if (d && d.ok) { N.stale = bannerOf(d.sources); draw(); } });
|
|
144
147
|
return w;
|
|
145
148
|
}
|
|
@@ -25,15 +25,15 @@
|
|
|
25
25
|
* Adler's moments (batch 17 item 4-B, AdM4): one face per section, where Claude speaks. Updates: glasses while it checks or installs,
|
|
26
26
|
* holding the card when a version is ready, calm when up to date. General: holding the update card, else the Jira promise (only after
|
|
27
27
|
* Commit). A failure in the status line takes the honest face, and then the section's face steps back (one Adler per screen).
|
|
28
|
-
* The card at the bottom of General says when an update is ready. While a source
|
|
29
|
-
* it with
|
|
28
|
+
* The card at the bottom of General says when an update is ready. While a source needs you (lib/source-heal.js), an amber box at the top of every section names
|
|
29
|
+
* it with its one action and what Claude tried, and the place menu opens Settings at Connectors. Every write is the PM's press (pmPost carries the launch nonce).
|
|
30
30
|
* PMOS reads the sources and never posts to them.
|
|
31
31
|
* openSettings(section?) */
|
|
32
32
|
import { mountClaudeSetup } from './cozy-claude-setup.js';
|
|
33
33
|
import * as JL from './cozy-jl.js';
|
|
34
34
|
import { pmGet, pmPost, pressFetch } from './http.js';
|
|
35
35
|
import { esc, svg, ICON, toast, wsParam, wsQ } from './cozy-evui.js';
|
|
36
|
-
import { sourceRows,
|
|
36
|
+
import { sourceRows, bannerOf } from './cozy-evmodel.js';
|
|
37
37
|
import * as AD from './cozy-adler.js';
|
|
38
38
|
import * as SE from './cozy-seasons.js';
|
|
39
39
|
import { weeksOf, lineOf, storiesOf } from './upd-weeks.js';
|
|
@@ -182,11 +182,16 @@ function readsHtml() {
|
|
|
182
182
|
const L = T.lines ? [['Today', T.lines.readLine(s, ['calendar'])], ['Needs you', T.lines.readLine(s, ['initiatives', 'my-work', 'd:brief'])]].filter((x) => x[1]) : [];
|
|
183
183
|
return `${health}<div class="ev-reads">${rows}</div>${L.map(([k, v]) => `<p class="ev-small ev-mut ev-rl" data-reads="${esc(k)}"><b>${esc(k)}:</b> ${esc(v)}</p>`).join('')}`;
|
|
184
184
|
}
|
|
185
|
-
/** The amber box at the top of Settings
|
|
185
|
+
/** The amber box at the top of Settings, only when the PM must act on a source (self-heal, feedback batch 26 item 7). While Claude
|
|
186
|
+
* reads a source again by itself, or after it did, there is no box. The box names the one action and links to the exact place:
|
|
187
|
+
* the Claude connectors, Settings › Claude, or ↻ when only a press reads it. It says what Claude already tried. */
|
|
186
188
|
function staleHtml() {
|
|
187
|
-
const
|
|
188
|
-
if (!
|
|
189
|
-
|
|
189
|
+
const b = T.fresh && T.fresh.ok ? bannerOf(T.fresh.sources) : null;
|
|
190
|
+
if (!b) return '';
|
|
191
|
+
const a = b.action;
|
|
192
|
+
const act = a.read ? readBtn(b.id, a.label, 'ev-ib ev-ib-card')
|
|
193
|
+
: `<a class="ev-ib ev-ib-card" href="${esc(a.href || '#' + a.sec)}"${a.href ? ' target="_blank" rel="noopener"' : ` data-ev-sec="${esc(a.sec)}"`} aria-label="${esc(a.label)}" title="${esc(a.label)}">${svg(ICON.open, 18)}</a>`;
|
|
194
|
+
return `<div class="ev-warn ev-line ev-st-warn" id="srcWarn" data-cause="${esc(b.cause || '')}"><i class="ev-dot risk" aria-hidden="true"></i><p class="ev-grow"><b>${esc(b.title)}</b> ${esc(b.body)}${a.read ? '' : ' ' + esc(a.how)}</p>${act}</div>`;
|
|
190
195
|
}
|
|
191
196
|
function connectors() {
|
|
192
197
|
if (T.src === null) return readsHtml() + '<p class="ev-small ev-mut">Reading the connectors.</p>';
|
|
@@ -315,8 +320,8 @@ function draw() {
|
|
|
315
320
|
// a redraw replaces the nodes: keep focus on the same control (by its data-ev-* key), else on the open section, never on the body
|
|
316
321
|
const ae = document.activeElement, had = ae && el.contains(ae), key = had ? [...ae.attributes].find((a) => a.name.startsWith('data-ev-')) : null;
|
|
317
322
|
const n = { connectors: T.src === null ? '' : Object.keys(SRC).filter((k) => T.src[k]).length + T.conn.filter((c) => !c.source && T.con[c.name]).length, workspaces: T.ws ? T.ws.filter((x) => !x.linked).length : '', updates: ready() ? '<span class="ev-pill y">1</span>' : '' };
|
|
318
|
-
const stale = T.fresh && T.fresh.ok ?
|
|
319
|
-
el.querySelector('.ev-st-nav').innerHTML = '<h2 class="ev-h2">Settings</h2>' + SECTIONS.map(([k, l]) => PAGE_LINKS[k] ? `<a href="${PAGE_LINKS[k]}" class="ev-mi">${esc(l)}</a>` : `<a href="#${k}" class="ev-mi" data-ev-sec="${k}"${T.sec === k ? ' aria-current="page"' : ''}>${esc(l)}${k === 'connectors' && stale ? '<i class="ev-dot risk" aria-label="A source
|
|
323
|
+
const stale = T.fresh && T.fresh.ok ? bannerOf(T.fresh.sources) : null;
|
|
324
|
+
el.querySelector('.ev-st-nav').innerHTML = '<h2 class="ev-h2">Settings</h2>' + SECTIONS.map(([k, l]) => PAGE_LINKS[k] ? `<a href="${PAGE_LINKS[k]}" class="ev-mi">${esc(l)}</a>` : `<a href="#${k}" class="ev-mi" data-ev-sec="${k}"${T.sec === k ? ' aria-current="page"' : ''}>${esc(l)}${k === 'connectors' && stale ? '<i class="ev-dot risk" aria-label="A source needs you" role="img"></i>' : ''}${n[k] !== undefined && n[k] !== '' ? `<span class="ev-k">${typeof n[k] === 'number' ? n[k] : n[k]}</span>` : ''}</a>`).join('');
|
|
320
325
|
const label = (SECTIONS.find((x) => x[0] === T.sec) || SECTIONS[0])[1];
|
|
321
326
|
const all = T.sec === 'connectors' ? `<button type="button" class="ev-ib" data-ev-readall aria-label="Read every source again" title="Read all again"${T.reading ? ' disabled' : ''}>${svg(RD, 18)}</button>` : '';
|
|
322
327
|
el.querySelector('.ev-st-main').innerHTML = staleHtml() + `<div class="ev-top"><h3 class="ev-h2" id="evStTitle">${esc(label)}</h3>${all}<button type="button" class="ev-ib" data-ev-close aria-label="Close settings" title="Close settings">${svg(ICON.close, 18)}</button></div>`
|
|
@@ -512,6 +517,7 @@ async function load() {
|
|
|
512
517
|
export function openSettings(section) {
|
|
513
518
|
const el = mount();
|
|
514
519
|
if (section === 'dress') { T.sec = 'adler'; T.dress = true; } else if (section && BODY[section]) { T.sec = section; T.dress = false; }
|
|
520
|
+
else if (el.hidden) { T.sec = 'general'; T.dress = false; } // a fresh open with no section opens on General (R2), not on the section of the last visit
|
|
515
521
|
if (el.hidden) { const ae = document.activeElement; T.back = ae && ae !== document.body ? ae : null; T.ws = null; T.src = null; T.msg = ''; T.phase = ''; T.auto = false; T.wkOpen = new Set(); T.wkMore = new Set(); T.arc = false; T.fresh = null; T.note = {}; T.dset = {}; T.cl = null; T.gr = null; T.np = null; JL.reset(); }
|
|
516
522
|
el.hidden = false; document.getElementById('evStScrim').hidden = false; el.setAttribute('data-esc-layer', '');
|
|
517
523
|
draw();
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/* premium-vw-guide.css — the guide in the Viewer (premium-vw-guide.js; canvas Exploring › User guide, option B): a page rail on the left,
|
|
2
|
+
* the page on the right with its section chips. Under 720 px the rail hides and the list icon shows it over the page. Quiet: hairlines, one
|
|
3
|
+
* well for the open page, no colour but the chips' hover. */
|
|
4
|
+
.vwdoc.gd{padding:0}
|
|
5
|
+
.gd-wrap{container-type:inline-size;display:flex;align-items:flex-start;gap:20px;position:relative}
|
|
6
|
+
.gd-rail{width:248px;flex:none;position:sticky;top:0;max-height:calc(100vh - 140px);overflow:auto;padding:4px 4px 16px 0;display:flex;flex-direction:column;gap:2px}
|
|
7
|
+
.gd-read{flex:1;min-width:0}
|
|
8
|
+
.gd-tog{display:none}
|
|
9
|
+
.gd-find{display:flex;align-items:center;gap:8px;height:40px;padding:0 10px;margin:0 0 6px;border-radius:10px;background:var(--well);color:var(--ink-3)}
|
|
10
|
+
.gd-find .i{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;flex:none}
|
|
11
|
+
.gd-find input{flex:1;min-width:0;border:0;background:transparent;color:var(--ink);font:var(--fw-regular) var(--fs-body)/var(--lh-body) var(--sans);outline:none;box-shadow:none}
|
|
12
|
+
.gd-find input:focus,.gd-find input:focus-visible{outline:none}
|
|
13
|
+
.gd-find:focus-within{outline:2px solid var(--ember);outline-offset:1px}
|
|
14
|
+
.gd-grp summary{list-style:none;cursor:pointer;padding:8px 8px 4px;color:var(--ink-3);font:var(--fw-strong) var(--fs-meta)/var(--lh-meta) var(--sans);border-radius:6px}
|
|
15
|
+
.gd-grp summary::-webkit-details-marker{display:none}
|
|
16
|
+
.gd-grp summary::after{content:"";display:inline-block;width:6px;height:6px;margin-left:8px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:translateY(-2px) rotate(45deg)}
|
|
17
|
+
.gd-grp:not([open]) summary::after{transform:translateY(0) rotate(-45deg)}
|
|
18
|
+
.gd-grp summary:focus-visible{outline:2px solid var(--ember);outline-offset:1px}
|
|
19
|
+
.gd-grp ul{list-style:none;margin:0;padding:0}
|
|
20
|
+
.gd-grp a{display:block;padding:5px 8px;border-radius:6px;color:var(--ink-2);text-decoration:none;font:var(--fw-regular) var(--fs-meta)/var(--lh-meta) var(--sans)}
|
|
21
|
+
.gd-grp a:hover{background:var(--well);color:var(--ink)}
|
|
22
|
+
.gd-grp a[aria-current="page"]{background:var(--well);color:var(--ink);font-weight:var(--fw-strong)}
|
|
23
|
+
.gd-grp a:focus-visible{outline:2px solid var(--ember);outline-offset:1px}
|
|
24
|
+
.gd-rail [hidden]{display:none}
|
|
25
|
+
.gd-none{margin:8px;color:var(--ink-3);font:var(--fw-regular) var(--fs-meta)/var(--lh-meta) var(--sans)}
|
|
26
|
+
.gd-secs{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 12px}
|
|
27
|
+
.gd-chip{display:inline-flex;align-items:center;min-height:32px;padding:0 12px;border-radius:999px;background:var(--well);color:var(--ink-2);text-decoration:none;font:var(--fw-regular) var(--fs-meta)/var(--lh-meta) var(--sans)}
|
|
28
|
+
.gd-chip:hover{color:var(--ink)}
|
|
29
|
+
.gd-chip[hidden]{display:none}
|
|
30
|
+
.gd-more{border:0;cursor:pointer;color:var(--ink-3)}
|
|
31
|
+
.gd-chip:focus-visible{outline:2px solid var(--ember);outline-offset:1px}
|
|
32
|
+
@container (max-width: 720px){
|
|
33
|
+
.gd-rail{display:none}
|
|
34
|
+
.gd-tog{display:flex}
|
|
35
|
+
.gd-railon .gd-rail{display:flex;position:absolute;z-index:3;top:64px;left:0;right:0;width:auto;max-height:70vh;padding:12px;border:1px solid var(--hair);border-radius:14px;background:var(--card-solid);box-shadow:var(--card-shadow)}
|
|
36
|
+
}
|