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.
Files changed (71) hide show
  1. package/package.json +33 -65
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/changelog.json +32 -0
  7. package/workspace/ev-everywhere-test.js +3 -3
  8. package/workspace/guide/board.md +7 -63
  9. package/workspace/guide/breakdown.md +8 -108
  10. package/workspace/guide/briefing-premium.md +5 -526
  11. package/workspace/guide/console-calendar.md +7 -206
  12. package/workspace/guide/data-freshness.md +26 -4
  13. package/workspace/guide/docs.md +10 -125
  14. package/workspace/guide/home.md +8 -5
  15. package/workspace/guide/index.md +78 -79
  16. package/workspace/guide/initiatives-and-next-steps.md +6 -19
  17. package/workspace/guide/layout.md +23 -230
  18. package/workspace/guide/live-notes.md +5 -7
  19. package/workspace/guide/my-work.md +8 -172
  20. package/workspace/guide/notes.md +14 -21
  21. package/workspace/guide/people.md +6 -39
  22. package/workspace/guide/report-design.md +2 -2
  23. package/workspace/guide/report-structure.md +1 -1
  24. package/workspace/guide/reports-set-up.md +9 -10
  25. package/workspace/guide/reports.md +17 -1
  26. package/workspace/guide/settings.md +16 -69
  27. package/workspace/guide/today.md +12 -188
  28. package/workspace/lib/refresh-run.js +39 -2
  29. package/workspace/lib/report-checks.js +3 -0
  30. package/workspace/lib/report-cli.js +112 -3
  31. package/workspace/lib/report-engine.js +779 -0
  32. package/workspace/lib/report-export.js +36 -1
  33. package/workspace/lib/report-pyramid.js +20 -0
  34. package/workspace/lib/routes-ready-reports.js +3 -1
  35. package/workspace/lib/routes-report-design.js +7 -2
  36. package/workspace/lib/routes-report-setups.js +17 -2
  37. package/workspace/lib/routes-sources.js +6 -2
  38. package/workspace/lib/source-heal.js +140 -0
  39. package/workspace/package-lock.json +2 -2
  40. package/workspace/package.json +1 -1
  41. package/workspace/public/pm/cozy-evmodel.js +18 -1
  42. package/workspace/public/pm/cozy-evui.js +1 -0
  43. package/workspace/public/pm/cozy-nav.js +9 -6
  44. package/workspace/public/pm/cozy-settings.js +15 -9
  45. package/workspace/public/pm/premium-vw-guide.css +36 -0
  46. package/workspace/public/pm/premium-vw-guide.js +137 -0
  47. package/workspace/public/pm/premium-vw-open.js +4 -1
  48. package/workspace/public/pm/premium-vw-page.js +2 -1
  49. package/workspace/run-tests.js +6 -0
  50. package/workspace/skills/report-biweekly.skill +7 -0
  51. package/workspace/skills/report-daily.skill +7 -0
  52. package/workspace/skills/report-monthly.skill +7 -0
  53. package/workspace/skills/report-special.skill +2 -0
  54. package/workspace/skills/report-template.skill +8 -1
  55. package/workspace/skills/report-weekly.skill +7 -0
  56. package/workspace/skills/report-write.skill +44 -0
  57. package/workspace/test-lists/report-engine.json +5 -0
  58. package/workspace/guide/ask-the-agent.md +0 -46
  59. package/workspace/guide/briefing-cards.md +0 -52
  60. package/workspace/guide/briefing.md +0 -159
  61. package/workspace/guide/calendar-popup.md +0 -46
  62. package/workspace/guide/command-palette.md +0 -77
  63. package/workspace/guide/console3-sessions.md +0 -147
  64. package/workspace/guide/document-sheet.md +0 -89
  65. package/workspace/guide/initiative-boards.md +0 -123
  66. package/workspace/guide/initiatives-screen.md +0 -179
  67. package/workspace/guide/initiatives-timeline.md +0 -55
  68. package/workspace/guide/phone-companion.md +0 -89
  69. package/workspace/guide/reports-dashboards.md +0 -70
  70. package/workspace/guide/reports-page.md +0 -87
  71. 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
- try { buffer = fmt === 'pptx' ? pptx(report, 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) }; }
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
- return { ok: true, designed: !!st.saved && !!rec, design_version: rec ? rec.design_version : null,
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
- const deck = RP.deckOf(md, RP.lookOf(w.root, d.c.cadence), { data: RP.dataOf(d.c.md) });
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 out = RX.exportOf(md, fmt, fmt === 'pptx' && cyc ? { look: RPy.lookOf(abs, cyc[1]), data: RPy.dataOf(full) } : {});
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
- const sources = F.sources({ id: w.id, root: w.root, repoStateDir: repoStateDir(ctx, w) });
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.516.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.516.0",
9
+ "version": "0.518.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@anthropic-ai/sdk": "^1.3.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.516.0",
3
+ "version": "0.518.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -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 when = s.state === 'off' ? 'Not connected' : s.state === 'building' ? 'Reading now' : s.state === 'never' ? 'Never read'
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 two icons, Search (⌘K on a desk) and Settings. It is the one way between places, and it holds nothing else: New for you is a fold
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 is stale. Open, the blue dot sits on
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
- * Settings pm/cozy-settings.js; it opens at Connectors while a source is stale, so the stale one is the first thing there
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, staleOf } from './cozy-evmodel.js';
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 = staleOf(s.sources); doc.dispatchEvent(new CustomEvent('cz:sources', { detail: s })); } // Your day's failed source (AdP5)
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 = staleOf(d.sources); draw(); } });
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 is stale, an amber box at the top of every section names
29
- * it with ↻ Read again, and the place menu opens Settings at Connectors. Every write is the PM's press (pmPost carries the launch nonce).
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, staleOf, partsOf } from './cozy-evmodel.js';
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 while a source is stale (FmA3Settings): what is old, what it makes old, and ↻ Read again. */
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 st = T.fresh && T.fresh.ok ? staleOf(T.fresh.sources) : null;
188
- if (!st) return '';
189
- return `<div class="ev-warn ev-line ev-st-warn" id="srcWarn"><i class="ev-dot risk" aria-hidden="true"></i><p class="ev-grow"><b>${esc(st.state === 'stale' ? st.name + ' was last read ' + st.ago + '.' : st.text + '.')}</b> ${esc(partsOf(st.id))} may be out of date.</p>${readBtn(st.id, 'Read ' + st.name + ' again', 'ev-ib ev-ib-card')}</div>`;
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 ? staleOf(T.fresh.sources) : null;
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 is stale" role="img"></i>' : ''}${n[k] !== undefined && n[k] !== '' ? `<span class="ev-k">${typeof n[k] === 'number' ? n[k] : n[k]}</span>` : ''}</a>`).join('');
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
+ }