create-pmos 0.525.0 → 0.526.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 +38 -41
  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/briefing-premium-board-test.js +1 -1
  7. package/workspace/changelog.json +37 -0
  8. package/workspace/ev-everywhere-test.js +2 -2
  9. package/workspace/guide/agent-capabilities.md +28 -2
  10. package/workspace/guide/board.md +14 -1
  11. package/workspace/guide/claude-proposes.md +3 -1
  12. package/workspace/guide/home.md +11 -5
  13. package/workspace/guide/idle-lab.md +2 -2
  14. package/workspace/guide/lifecycle.md +16 -3
  15. package/workspace/guide/today.md +11 -3
  16. package/workspace/lib/bg-queue.js +7 -3
  17. package/workspace/lib/board-premium.js +4 -1
  18. package/workspace/lib/engine/boards.js +3 -2
  19. package/workspace/lib/headless.js +19 -4
  20. package/workspace/lib/idle-lab-engine.js +531 -0
  21. package/workspace/lib/initiative-chat.js +12 -2
  22. package/workspace/lib/initiative-cli.js +1 -1
  23. package/workspace/lib/initiative-premium.js +11 -3
  24. package/workspace/lib/lab-cli.js +220 -0
  25. package/workspace/lib/lifecycle-stage.js +17 -3
  26. package/workspace/lib/one-stage.js +131 -0
  27. package/workspace/lib/pmos-verbs.js +3 -0
  28. package/workspace/lib/proposals.js +5 -2
  29. package/workspace/lib/report-draft.js +1 -1
  30. package/workspace/lib/route-modules.js +1 -0
  31. package/workspace/lib/routes-board-premium.js +22 -0
  32. package/workspace/lib/routes-boards.js +61 -12
  33. package/workspace/lib/routes-notes.js +1 -1
  34. package/workspace/lib/routes-your-move.js +64 -0
  35. package/workspace/lib/status-signal.js +123 -0
  36. package/workspace/lib/workflow-cli.js +3 -1
  37. package/workspace/lib/your-move.js +61 -0
  38. package/workspace/package-lock.json +2 -2
  39. package/workspace/package.json +1 -1
  40. package/workspace/public/pm/bd-acts.js +2 -1
  41. package/workspace/public/pm/bd-cols.js +8 -5
  42. package/workspace/public/pm/bd-prog-panel.js +3 -2
  43. package/workspace/public/pm/bd-span.js +2 -1
  44. package/workspace/public/pm/bd-views.js +8 -6
  45. package/workspace/public/pm/cozy-home.css +3 -0
  46. package/workspace/public/pm/cozy-today.css +151 -0
  47. package/workspace/public/pm/cozy.css +9 -0
  48. package/workspace/public/pm/good-morning.js +327 -0
  49. package/workspace/public/pm/grill-words.js +16 -1
  50. package/workspace/public/pm/home-app.js +187 -18
  51. package/workspace/public/pm/home-data.js +45 -13
  52. package/workspace/public/pm/home-day.js +66 -14
  53. package/workspace/public/pm/home-ini-model.js +1 -1
  54. package/workspace/public/pm/home-stage-card.js +7 -0
  55. package/workspace/public/pm/ini-tasks.js +2 -2
  56. package/workspace/public/pm/ini-view-model.js +10 -3
  57. package/workspace/public/pm/ini-view.css +9 -0
  58. package/workspace/public/pm/ini-view.js +19 -5
  59. package/workspace/public/pm/move-rank.js +155 -0
  60. package/workspace/public/pm/premium-board.js +7 -4
  61. package/workspace/public/pm/premium-initiative-head.js +39 -4
  62. package/workspace/public/pm/stage-chip.js +44 -0
  63. package/workspace/run-tests.js +1 -0
  64. package/workspace/server.js +43 -1
  65. package/workspace/skills/idle-lab-prototype.skill +36 -0
  66. package/workspace/skills/idle-lab-scan.skill +32 -0
  67. package/workspace/test-lists/good-morning.json +9 -0
  68. package/workspace/test-lists/idle-lab-engine.json +9 -0
  69. package/workspace/test-lists/one-stage.json +5 -0
  70. package/workspace/test-lists/status-signal.json +5 -0
  71. package/workspace/test-lists/your-move.json +9 -0
@@ -8,7 +8,9 @@
8
8
  * costWord(usd) '38¢' · '$1.20' · '' (unknown)
9
9
  * whyWord(run) 'before it leaves its stage' · 'when its forecast turned' · 'after its scope or dates moved' · 'in the monthly look'
10
10
  * runLine(run, now) "Claude grilled it overnight, when its forecast turned" (no cost; the page adds it quietly)
11
- * turnWord(to) 'will miss' · 'is at risk' · '' */
11
+ * turnWord(to) 'will miss' · 'is at risk' · ''
12
+ * headerFlags(props) the initiative header's grill findings (workspace grill W3, WgW3Move): { n, rows: [{ title, label, day, hot }] } | null.
13
+ * props is a Board row's r.props (lib/proposals.js viewOf); the two best by rank, the folded ones counted too */
12
14
  export const ANGLE = Object.freeze({
13
15
  premortem: { label: 'Premortem', icon: 'M12 3l9 16H3zM12 10v4M12 17v.5' },
14
16
  redteam: { label: 'Red team', icon: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8' },
@@ -53,3 +55,16 @@ export function runLine(r, now = Date.now()) {
53
55
  return ['Claude grilled it', whenWord(r.at, now)].filter(Boolean).join(' ') + (whyWord(r) ? ', ' + whyWord(r) : '');
54
56
  }
55
57
  export const turnWord = (to) => (to === 'will-miss' ? 'will miss' : to === 'at-risk' ? 'is at risk' : '');
58
+
59
+ const MONS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
60
+ export function headerFlags(props) {
61
+ const g = ((props && props.groups) || []).find((x) => x.group === 'grill');
62
+ const all = g ? (g.props || []).concat(g.hidden || []) : [];
63
+ if (!all.length) return null;
64
+ const rank = (p) => (p && p.data && p.data.rank) || 5;
65
+ const rows = all.slice().sort((a, b) => rank(a) - rank(b)).slice(0, 2).map((p) => {
66
+ const f = findingOf(p), t = Date.parse(p.at || ''), d = Number.isFinite(t) ? new Date(t) : null;
67
+ return { title: f.title, label: f.label, day: d ? d.getDate() + ' ' + MONS[d.getMonth()] : '', hot: rank(p) <= 2 };
68
+ });
69
+ return { n: all.length, rows };
70
+ }
@@ -29,7 +29,7 @@ import * as H from './home-data.js';
29
29
  import { mountChat, avHtml } from './home-chat.js';
30
30
  import * as MORE from './home-more.js';
31
31
  import * as PP from './cozy-pick.js'; // the one product picker Home, Board and Insights share (batch 12 item 5)
32
- import { headModel, STEPS } from './premium-initiative-head.js';
32
+ import { headModel, STEPS, gateOf } from './premium-initiative-head.js';
33
33
  import * as SC from './home-stage-card.js'; // the initiative's stage card D
34
34
  import * as WFM from './ini-wf-model.js'; // D99: the workflow an initiative runs (kind line, usual days)
35
35
  import { wfIcon, wfOf } from './wf-model.js';
@@ -45,6 +45,8 @@ import * as L from './home-list.js';
45
45
  import * as TD from './home-today.js';
46
46
  import * as GW from './grill-words.js'; // Home's one line when Claude grilled something by itself (canvas FcPhone, Home)
47
47
  import * as YD from './home-day.js';
48
+ import * as GM from './good-morning.js'; // Good morning is one page: the quarter, the reading column and the time rail (nyt-ui GM2)
49
+ import * as MSG from './ms-suggest.js'; // Your move's ✓ on a program's milestone changes confirms them all (workspace grill W3)
48
50
  import * as AD from './cozy-adler.js'; // Adler, Claude's face (batch 16 item 2)
49
51
  import * as OKL from './okr-list.js'; // the product page's Goals line (batch 16 item 6, GsProduct)
50
52
  import { canDictate, dictate } from './home-dictate.js';
@@ -363,13 +365,155 @@ function homeListHtml() {
363
365
  * minutes (the event 'cz:news-for-you') and again when Your day opens; a row opens the item in the Viewer, "N more" the whole list, ✓✓
364
366
  * marks all seen. An item leaves once it is opened or marked seen. */
365
367
  function dayHtml() { return ''; }
368
+ /** Your move's ranked list (workspace grill W3, pm/move-rank.js): the products Home reads with their Board rows, the programs, and each
369
+ * product's GET /api/your-move answer (overdue verdicts, the first key result with no reading). */
370
+ function rankIn() {
371
+ const products = ((S.M && S.M.products) || []).filter((p) => p.model).map((p) => {
372
+ const I = p.model.initiatives || {};
373
+ return { ws: p.ws || boardWs() || '', name: p.name, board: (p.model.board && p.model.board.id) || '', rows: (I.list && I.list.rows) || p.model.rows || [] };
374
+ });
375
+ return { products, programs: S.D.programs || [], move: S.move || {} };
376
+ }
377
+ /** Each product's GET /api/your-move, read when Your day opens (never on Home's first draw: page speed). */
378
+ async function readMove() {
379
+ const L = ((S.M && S.M.products) || []).filter((p) => p.model).map((p) => p.ws || boardWs() || '');
380
+ const got = await Promise.all(L.map((ws) => pmGet('/api/your-move' + (ws ? '?ws=' + encodeURIComponent(ws) : '')).catch(() => null)));
381
+ const next = {};
382
+ L.forEach((ws, i) => { const r = got[i]; if (r && r.ok) next[ws] = { verdicts: r.verdicts || [], kr: r.kr || null }; });
383
+ const same = JSON.stringify(next) === JSON.stringify(S.move || null);
384
+ S.move = next;
385
+ if (!same) redrawLater();
386
+ }
387
+ /** Redraw Your day after a late read, but never under something passing on it (Adler's dress tip, the pull's "Reading your day again"):
388
+ * it waits until that is gone. */
389
+ const PASSING = '.ad-tip, .cz-pull';
390
+ function redrawLater() {
391
+ const body = VW.body();
392
+ if (!body || !body.querySelector(PASSING)) { redrawDay(); return; }
393
+ const mo = new MutationObserver(() => { if (!body.querySelector(PASSING)) { mo.disconnect(); redrawDay(); } });
394
+ mo.observe(body, { childList: true, subtree: true });
395
+ }
396
+ const moveRow = (key) => { const Y = S.Y && S.Y.rank; return Y ? Y.rows.concat(Y.older).find((r) => r.key === key) || null : null; };
397
+ const insightsHref = (ws) => '/premium-intelligence.html' + (ws ? '?ws=' + encodeURIComponent(ws) : '');
398
+ /** The ✦ of one initiative of any product Home reads, over its page (pm/ini-props.js). */
399
+ async function openProps(ws, board, id) {
400
+ await goIni(id);
401
+ const r = await pmGet('/api/proposals?ws=' + encodeURIComponent(ws || '') + '&board=' + encodeURIComponent(board || '') + '&initiative=' + encodeURIComponent(id) + '&grill=all').catch(() => null);
402
+ if (!r || r.ok === false) { toast((r && r.error) || 'What Claude proposes could not be read.'); return; }
403
+ const row = iniRowOf(id) || {};
404
+ const IP = await import('./ini-props.js');
405
+ IP.open({ id, title: row.title || id, props: r.props }, { where: () => ({ ws, board }), reload: () => load(), toast: (m, undo) => (undo ? toastUndo(m, undo) : toast(m)) });
406
+ }
407
+ /** A press on a ranked row of Your move: mv-open opens what it is about, mv-yes does what its ✓ says, mv-alt picks, decides or drops. */
408
+ async function movePress(b) {
409
+ const r = moveRow(b.dataset.key); if (!r || b.disabled) return;
410
+ const act = b.dataset.yd;
411
+ const prog = r.program ? (S.D.programs || []).find((p) => p.id === r.program) : null;
412
+ if (act === 'mv-open' || act === 'mv-alt' && (r.kind === 'other' || r.kind === 'kr' || r.kind === 'verdict' || r.kind === 'meeting')) {
413
+ if (r.kind === 'verdict' || r.kind === 'kr') { location.href = insightsHref(r.ws); return; }
414
+ if (r.kind === 'meeting') { if (prog) { VW.drill(); goProgram(prog.id); } else if (r.props[0]) { VW.drill(); openProps(r.props[0].ws, r.props[0].board, r.props[0].ini); } return; }
415
+ if (r.kind === 'call') { select(r.call.id); return; }
416
+ VW.drill(); openProps(r.ws, r.board, r.ini); return;
417
+ }
418
+ b.disabled = true;
419
+ const done = (msg, undo) => { if (undo) toastUndo(msg, undo); else toast(msg); load().then(() => readMove()); };
420
+ if (act === 'mv-alt' && r.kind === 'grill') {
421
+ // ✕ Not this: each part is dropped for good (Claude does not propose it again on the same evidence)
422
+ let ok = true;
423
+ for (const x of r.props) for (const sig of x.sigs) { const res = await pmPost('/api/proposals/drop', { ws: x.ws, board: x.board, initiative: x.ini, sig }).catch(() => null); if (!res || !res.ok) ok = false; }
424
+ if (!ok) { b.disabled = false; toast('It could not be dropped. Nothing changed.'); return; }
425
+ done('Dropped. Claude will not raise it again on the same evidence.'); return;
426
+ }
427
+ if (act !== 'mv-yes') { b.disabled = false; return; }
428
+ if (r.kind === 'verdict' && r.verdict) {
429
+ const v = r.verdict;
430
+ const res = await pmPost('/api/insights-records/verdict', { ws: r.ws, board: v.board, ini: v.ini, verdict: v.verdict, learned: v.text, goal: null, drafted: true }).catch(() => null);
431
+ if (!res || res.ok !== true) { b.disabled = false; toast((res && res.error) || 'The verdict could not be saved. Nothing changed.'); return; }
432
+ done('Recorded: ' + v.verdict + '.', async () => { const u = await pmPost('/api/insights-records/verdict-undo', { ws: r.ws, board: v.board, ini: v.ini }).catch(() => null); load().then(() => readMove()); return u || { ok: false }; });
433
+ return;
434
+ }
435
+ // ✓ keeps every part: the program's milestone changes, then the proposals per initiative (one batch each, one Undo for all)
436
+ const batches = []; let failed = 0;
437
+ if (r.ms && prog) for (const key of r.ms.keys) { const a = await MSG.answer(pmPost, prog, key, 'keep'); if (!a.ok) failed++; }
438
+ for (const x of r.props) {
439
+ const res = await pmPost('/api/proposals/keep', { ws: x.ws, board: x.board, initiative: x.ini, ids: x.ids }).catch(() => null);
440
+ if (res && res.ok) { if (res.batch) batches.push({ ws: x.ws, board: x.board, batch: res.batch }); } else failed++;
441
+ }
442
+ if (failed && !batches.length && !(r.ms && r.ms.keys.length > failed)) { b.disabled = false; toast('It could not be kept. Nothing changed.'); return; }
443
+ const undo = batches.length ? async () => { let ok = true; for (const x of batches) { const u = await pmPost('/api/proposals/undo', x).catch(() => null); if (!u || !u.ok) ok = false; } load().then(() => readMove()); return { ok }; } : null;
444
+ done(failed ? 'Kept, but ' + failed + (failed === 1 ? ' part was' : ' parts were') + ' not saved.' : 'Kept.', undo);
445
+ }
446
+ /** The notes Home's recorder holds (GET /api/notes rows), for Good morning's rail. */
447
+ const noteRows = () => (REC && REC.notes ? REC.notes() || [] : []);
366
448
  function dayParts() {
367
- const Y = YD.dayModel(S.M, S.D, L.reviewModel(S.D, NOW()), NOW(), { delightOk: !!S.joyOk, news: S.news, sources: S.src && S.src.sources, ask: MEN.dayAskOf(S.D), dress: AD.dress() });
368
- return { Y, P: YD.dayParts(Y, { moment: !!(isPhone() && PHC), now: NOW(), first: !!S.first }) };
449
+ const Y = YD.dayModel(S.M, S.D, L.reviewModel(S.D, NOW()), NOW(), { delightOk: !!S.joyOk, news: S.news, sources: S.src && S.src.sources, ask: MEN.dayAskOf(S.D), dress: AD.dress(), rank: rankIn() });
450
+ // Good morning (GM2): the goal data (the quarter, the PM's line, the morning snapshot, a goal tag per row) is GM1's, in S.gmGoals once it reads
451
+ const G = GM.gmModel(Y, S.D, NOW(), { quarter: S.gmGoals && S.gmGoals.quarter, tags: S.gmGoals && S.gmGoals.tags, notes: noteRows(), picks: S.notePicks || {}, today: H.todayOf(S.D) });
452
+ S.G = G;
453
+ return { Y, P: YD.gmParts(Y, G, { moment: !!(isPhone() && PHC), now: NOW(), first: !!S.first, html: GM.gmHtml }) };
454
+ }
455
+ /** The quarter under Good morning (GET /api/morning-goals, GM1): goal states, the PM's line or Claude's ✦ line, the move since the
456
+ * morning, and a goal tag per initiative. Read when Your day opens (never on Home's first draw: page speed). No answer, no strip. */
457
+ async function readGoals() {
458
+ const r = await pmGet('/api/morning-goals').catch(() => null);
459
+ const next = GM.fromMorningGoals(r), was = JSON.stringify(S.gmGoals || null);
460
+ S.gmGoals = next;
461
+ // redraw only when the page would change: a redraw closes what is open on the page (a tip, a fold the PM is reading)
462
+ const shows = !!next && (!!GM.quarterOf(next.quarter, 'morning', NOW()) || Object.keys(next.tags).length > 0);
463
+ if (shows && JSON.stringify(next) !== was) redrawLater();
464
+ }
465
+ /** The ✦ link of each loose recording in Good morning's rail: GET /api/notes/initiatives finds the initiative it shares the most words with.
466
+ * A note with no such initiative gets no ✦ (proven only). */
467
+ async function readNotePicks() {
468
+ const L = GM.notesOf(noteRows(), NOW(), {}).filter((n) => n.loose);
469
+ const ws = deskWs(); if (!L.length || !ws) return;
470
+ const got = await Promise.all(L.map((n) => pmGet('/api/notes/initiatives?ws=' + encodeURIComponent(ws) + '&id=' + encodeURIComponent(n.id)).catch(() => null)));
471
+ const next = {};
472
+ L.forEach((n, i) => { const r = got[i]; if (r && r.ok && r.pick && r.pick.id) next[n.id] = { id: r.pick.id, title: r.pick.title || r.pick.id }; });
473
+ S.notePicks = next;
474
+ if (Object.keys(next).length) redrawLater();
475
+ }
476
+ /** A press on Good morning's own parts: By product, a quiet "N drafted for you" line, a meeting, Record, a note, All notes, ✦ link. */
477
+ async function gmPress(b) {
478
+ const act = b.dataset.yd, body = b.closest('.yd');
479
+ if (act === 'gm-intel') { location.href = insightsHref(deskWs()); return; }
480
+ if (act === 'gm-fold') {
481
+ const sec = body && body.querySelector('.czs-sec[data-czs-id="' + b.dataset.fold + '"]'); if (!sec) return;
482
+ const f = sec.querySelector('[data-czs-fold]'); if (f && f.getAttribute('aria-expanded') !== 'true') f.click();
483
+ sec.scrollIntoView({ behavior: 'smooth', block: 'start' }); return;
484
+ }
485
+ const meetOf = (id) => H.todayOf(S.D).concat((S.D.phoneDay && S.D.phoneDay.tomorrow && S.D.phoneDay.tomorrow.meetings) || []).find((m) => m && m.id === id) || null;
486
+ if (act === 'gm-meet' || act === 'gm-rec') { const m = meetOf(b.dataset.id); if (m) { VW.drill(); openMeeting(m, act === 'gm-rec' ? { record: true } : {}); } return; }
487
+ if (act === 'gm-note') { VW.drill(); openMeeting({ note: b.dataset.id }); return; }
488
+ if (act === 'gm-notes') { const v = await V(); if (typeof v.openNotes === 'function') { VW.drill(); v.openNotes({ ws: deskWs() }); } return; }
489
+ if (act === 'gm-line' || act === 'gm-line-off') {
490
+ // ✓ keeps Claude's line for the quarter; ✕ puts the ✦ off for the quarter. Both are the PM's own press (page press)
491
+ if (b.disabled) return; b.disabled = true;
492
+ const r = await pmPost('/api/morning-goals/' + (act === 'gm-line' ? 'line' : 'line-off'), act === 'gm-line' ? { line: +b.dataset.n } : {}).catch(() => null);
493
+ if (!r || !r.ok) { b.disabled = false; toast((r && r.error) || 'The line was not kept. Nothing changed.'); return; }
494
+ toast(act === 'gm-line' ? 'Your line for the quarter is ' + b.dataset.n + '.' : 'No line this quarter. I won’t ask again.');
495
+ await readGoals(); redrawDay(); return;
496
+ }
497
+ if (act === 'gm-link') {
498
+ const pk = (S.notePicks || {})[b.dataset.id]; if (!pk || b.disabled) return;
499
+ b.disabled = true;
500
+ const r = await pmPost('/api/notes/link', { id: b.dataset.id, initiative: { id: pk.id, ws: deskWs() || null, title: pk.title } }).catch(() => null);
501
+ if (!r || !r.ok) { b.disabled = false; toast((r && r.error) || 'The note could not be linked. Nothing changed.'); return; }
502
+ toast('Linked to ' + pk.title + '.');
503
+ delete S.notePicks[b.dataset.id];
504
+ if (REC) await REC.refresh();
505
+ redrawDay();
506
+ }
369
507
  }
370
508
  function drawDayBody(body, P) {
509
+ S.dayP = JSON.stringify([P, S.chap || null]);
371
510
  const ph = isPhone() && PHC;
511
+ // the reads that land after the first draw (New for you, Your move, the quarter) redraw the page: an open dress tip stays open
512
+ // (and so does Adler peeking up while a pull reads the day again)
513
+ const tip = body.querySelector('.ad-tip'), pull = body.querySelector(':scope > .cz-pull');
372
514
  YD.drawDay(body, P, VW, ph ? (el) => { el.append(PHC.el); PHC.read(true); } : null);
515
+ const at = tip && body.querySelector('[data-ad-dress]'); if (at) at.insertAdjacentElement('afterend', tip);
516
+ if (pull) body.prepend(pull);
373
517
  // Your impact's chapter (batch 16 item 7, GmP4Chapter): on a Friday when a stage of one of these initiatives really closed this week
374
518
  if (S.chap) body.insertAdjacentHTML('afterbegin', IM.chapterHtml(S.chap, { href: '/premium.html?ini=' + encodeURIComponent(S.chap.c.id) + (S.chap.c.ws ? '&ws=' + encodeURIComponent(S.chap.c.ws) : '') }));
375
519
  }
@@ -414,6 +558,8 @@ function day(on) {
414
558
  if (b.dataset.hmSel) { if (b.dataset.hmSel === 'n:update' && !rowById('n:update')) openUpdateViewer(); else select(b.dataset.hmSel); return; }
415
559
  if (b.dataset.yd === 'fin') { const d = (dayParts().Y.drafts || [])[+b.dataset.i]; if (d && d.fin) { VW.drill(); openFinished(d.fin); } return; }
416
560
  if (b.dataset.yd === 'sel') { const c = b.closest('.yd-call'); if (c) select(c.dataset.id); return; }
561
+ if (/^mv-/.test(b.dataset.yd || '')) { movePress(b); return; }
562
+ if (/^gm-/.test(b.dataset.yd || '')) { gmPress(b); return; }
417
563
  if (b.dataset.yd === 'retry' || b.dataset.yd === 'prep') { dayAct(b); return; }
418
564
  if (b.dataset.yd === 'sync') { syncAgain(b); return; }
419
565
  if (b.dataset.yd === 'ask') { S.day = false; S.sel = 'mentions'; drawList(); MEN.openMentions(menCtx(), { sel: b.dataset.id }); return; }
@@ -422,13 +568,20 @@ function day(on) {
422
568
  if ('hmReview' in b.dataset) { openReviewVw(); return; }
423
569
  };
424
570
  } });
571
+ // Good morning is a page: on a wide desk it opens wide (the reading column and the time rail side by side)
572
+ if (!isPhone() && typeof innerWidth === 'number' && innerWidth >= 1280) VW.wide(true);
425
573
  readNews();
574
+ readMove();
575
+ readNotePicks();
576
+ readGoals();
426
577
  }
427
578
  /** Pull to refresh on Your day (AdP2): Home reads again, the place menu reads New for you and the sources again, then the day redraws. */
428
579
  async function rereadDay() {
429
580
  await load();
430
581
  document.dispatchEvent(new CustomEvent('cz:news'));
431
582
  await readNews();
583
+ await readMove();
584
+ await readGoals();
432
585
  redrawDay();
433
586
  }
434
587
  /** The sources' last reads (GET /api/sources), passed on by the place menu and by Settings: a failed one shows on Your day (AdP5). */
@@ -452,7 +605,10 @@ async function syncAgain(b) {
452
605
  /** Your day drawn again in place (a new read of New for you, a seen item), when it is the item shown. */
453
606
  function redrawDay() {
454
607
  const c = VW.current(), body = VW.body(); if (!S.day || !c || c.id !== 'day' || !body || !S.M) return;
455
- const { Y, P } = dayParts(); S.Y = Y; drawDayBody(body, P);
608
+ const { Y, P } = dayParts(); S.Y = Y;
609
+ // a read that changed nothing draws nothing: a redraw closes what is open on the page (the dress tip, a fold being read)
610
+ if (S.dayP === JSON.stringify([P, S.chap || null]) && body.querySelector(".yd")) return;
611
+ drawDayBody(body, P);
456
612
  }
457
613
  async function readNews() {
458
614
  const r = await pmGet('/api/news-for-you' + wsQ()).catch(() => null);
@@ -513,7 +669,9 @@ function iniViewOf(id) {
513
669
  const B = S.D.board || {}, t = S.D.trail && S.D.trail.items && S.D.trail.items[id];
514
670
  const need = H.needsOf(S.D).find((r) => r.ini === id);
515
671
  const p = programOf(S.D.programs || [], row.ws || (B.board || {}).ws || '', id);
672
+ const IM = S.IM && S.IMid === id ? S.IM : null; // W1: the page model's gate line (the spec ladder), when the page model is read
516
673
  return iniView(rowFor({ ...row, trail: t || row.trail || null }), { steps: B.steps || STEPS, today: B.today, usual: WFM.usualOf(S.D.trail, row),
674
+ gate: IM && !(row.wf && row.wf.id && row.wf.id !== 'product-feature') ? gateOf(IM.gateSteps || IM.checks) : null,
517
675
  ask: need ? { title: need.name, n: H.needsOf(S.D).filter((r) => r.ini === id).length, propose: (need.row && need.row.propose) || null } : null, program: p ? { id: p.id, title: p.title } : null, jiraReadAt: ((B.jira || {}).readAt) || '',
518
676
  // the card (batch 18 item 1): Claude's line while a session works on it, the stakeholders
519
677
  claude: (() => { const w = S.D.sessions.find((s) => H.iniOfSession(s) === id && s.shown === 'working'); return w ? 'I’m working on “' + H.nameOf(w) + '”. I’ll say here when it is ready.' : ''; })(),
@@ -548,7 +706,7 @@ function otherIni(id) {
548
706
  function headOf(id) {
549
707
  const row = iniRowOf(id) || {};
550
708
  const M = S.IM && S.IMid === id ? { ...S.IM } : { head: { stage: row.stage }, acts: row.acts || {} };
551
- Object.assign(M, { wf: row.wf || null, wfStage: row.wfStage || null, wfStages: row.wfStages || null }); // D99: the initiative's own workflow
709
+ Object.assign(M, { wf: row.wf || null, wfStage: row.wfStage || null, wfStages: row.wfStages || null, oneStage: row.oneStage || M.oneStage || null }); // W1: the one stage // D99: the initiative's own workflow
552
710
  const t = S.D.trail && S.D.trail.items && S.D.trail.items[id];
553
711
  const u = WFM.usualOf(S.D.trail, { ...row, stage: (M.head && M.head.stage) || row.stage }); // "usually N days" within its own workflow
554
712
  if (t && t.since) M.stageSince = t.since;
@@ -564,7 +722,7 @@ function headOf(id) {
564
722
  * next, ! for a risk, J for Jira. Its tag: You; Working (a plain word), or Stuck (an outlined pill) when the initiative is stalled; Next. */
565
723
  const markHtml = (k, role) => (k === 'done' ? `<span class="hm-mk done" aria-hidden="true">${SVG(I.check, 12, 3)}</span>` : k === 'risk' ? '<span class="hm-mk risk" aria-hidden="true">!</span>' : k === 'jira' ? '<span class="hm-mk jira" aria-hidden="true">J</span>'
566
724
  : k === 'agent' && role ? avHtml(role, 20, 'hm-mk-av') : `<span class="hm-mk ${esc(k)}" aria-hidden="true"></span>`);
567
- const TAG = { you: 'You', agent: 'Working', wait: 'Next', jira: 'Jira', risk: 'At risk' };
725
+ const TAG = { you: 'You', agent: 'Working', wait: 'Next', jira: 'Jira', risk: 'At risk', none: 'No signal' };
568
726
  const itemHtml = (x, o = {}) => {
569
727
  const k = x.kind === 'agent' && !o.role && !o.stalled ? 'wait' : x.kind; // an agent step with no agent at work on it waits: it is next
570
728
  const tag = k === 'agent' && o.stalled ? 'Stuck' : TAG[k];
@@ -595,7 +753,7 @@ function cardHtml(id, o = {}) {
595
753
  const C = H.productCard(S.D, S.D.now);
596
754
  kind = C.kind; title = C.name; turn = C.turn === 'you' ? 'you' : 'agents'; turnLabel = C.turn === 'you' ? 'Your turn' : 'Sent this week';
597
755
  stage = C.stage; ckh = [C.title, C.of];
598
- items = C.items.map((x) => itemHtml({ kind: x.kind === 'ok' ? 'done' : 'risk', label: x.label })).join('') || '<p class="hm-none">No KR or budget is anchored on this board.</p>';
756
+ items = C.items.map((x) => itemHtml({ kind: x.kind === 'ok' ? 'done' : x.kind === 'none' ? 'none' : 'risk', label: x.label })).join('') || '<p class="hm-none">No KR or budget is anchored on this board.</p>';
599
757
  then = C.payoff; time = C.time; cta = C.cta; primary = !C.sent;
600
758
  const risk = C.items.filter((x) => x.kind === 'risk').length;
601
759
  foldSum = C.of + (risk ? ' · ' + risk + ' at risk' : '');
@@ -916,13 +1074,14 @@ function needTarget(r) {
916
1074
  function updateTarget() {
917
1075
  const U = H.weeklyUpdate(S.D, S.D.now);
918
1076
  const flags = U.sections.flatMap(([, , L]) => L).filter((x) => x.flag);
919
- const text = 'The weekly update is drafted from the ladders and run logs, not from memory.' + (flags.length ? ' One thing to check before it goes out: ' + flags[0].title + ' looks green, but ' + flags[0].flag.replace(/^Looks green, but /, '').toLowerCase() + ', so I marked it.' : '');
1077
+ const text = (U.draft ? 'The weekly update is drafted from what PMOS read (Jira, the plan\'s dates, the risks you confirmed), not from memory.' : 'Nothing moved and no risk is open this week, so there is no update to send.') + (flags.length ? ' One thing to check before it goes out: ' + flags[0].title + ' looks green, but ' + flags[0].flag.replace(/^Looks green, but /, '').toLowerCase() + ', so I marked it.' : '');
920
1078
  const sent = (U.sent || []).find((x) => x.week === U.week.iso);
921
1079
  // the product's conversation shows its In: scope chip (AgProduct); To: is for a running role session only (AgChatRunning)
922
1080
  return { key: H.UPDATE_KEY, ini: 'product', ws: WS || undefined, title: 'Weekly update', sub: 'Communicator · ' + (sent ? 'sent this week' : 'ready to send'), scope: scopeOf('product'), rich: rich(), role: COMMS, to: null,
923
1081
  intro: { role: COMMS, text: sent ? 'This week\'s update went out from your mail on ' + new Date(sent.at).toDateString() + '.' : text,
924
1082
  pills: sent ? [{ label: 'Read it', primary: true, go: () => openUpdateViewer() }] : [{ label: 'Send now', primary: true, go: () => sendUpdate() }, { label: 'Edit', go: () => { openUpdateViewer(); chat.focus(); } }] },
925
- context: 'The weekly update draft for ' + H.productName(S.D) + ':\n' + H.updateText(U, H.productName(S.D)) + '\nRecipients: ' + (U.to.join(', ') || 'none yet') + '. You can change the draft; the PM sends it from their own mail.',
1083
+ context: (U.unconfirmed.length ? 'Read these first: Claude proposed ' + U.unconfirmed.length + ' risks the PM has not confirmed (they are not in the mail): ' + U.unconfirmed.map((x) => x.title + ', ' + x.why).join('; ') + '.\n' : '')
1084
+ + 'The weekly update draft for ' + H.productName(S.D) + ':\n' + H.updateText(U, H.productName(S.D)) + '\nRecipients: ' + (U.to.join(', ') || 'none yet') + '. You can change the draft; the PM sends it from their own mail.',
926
1085
  placeholder: 'Tell Communicator what to change', also: null };
927
1086
  }
928
1087
  /** The product's own conversation (its heading's name, HbCProduct): Claude with the product's goals as its context. */
@@ -1013,7 +1172,7 @@ function productPageHtml(x, ws, vid, name, g) {
1013
1172
  const U = own ? H.weeklyUpdate(S.D, S.D.now) : null, sent = U && (U.sent || []).some((y) => y.week === U.week.iso);
1014
1173
  const R = d ? DB.reportsHtml(d, { open: x.open }) : '';
1015
1174
  // redesign R (RgR4Make): "Ready to send" only in a week with news (something at risk or waiting for a decision); a quiet week says so
1016
- const news = U && U.sections.some(([k, , L]) => k !== 'ok' && L.length);
1175
+ const news = !!(U && U.draft); // workspace grill W2: something moved or a risk is open
1017
1176
  const extra = own ? (R ? '' : row('data-hp="update"', '', 'Weekly update', sent ? 'Sent this week' : news ? 'Ready to send' : 'Nothing new this week · skipped'))
1018
1177
  : `<a class="pv-row" href="${esc(ownHome)}" target="_blank" rel="noopener"><span class="pv-rt"><b>Weekly update</b><small>In ${esc(name)}'s own Home, in a new tab</small></span></a>`;
1019
1178
  const reps = ((d && d.reports) || []).filter((r) => r.kind !== 'heads-up');
@@ -1489,15 +1648,16 @@ function openUpdateViewer(o = {}) {
1489
1648
  const draw = (b) => {
1490
1649
  const U = H.weeklyUpdate(S.D, S.D.now);
1491
1650
  const sent = (U.sent || []).find((x) => x.week === U.week.iso);
1492
- const [ok, risk, dec] = U.sections.map((x) => x[2].length);
1493
- const words = [ok ? ok + ' on track' : '', risk ? risk + ' at risk' : '', dec ? dec + (dec === 1 ? ' needs' : ' need') + ' a decision' : ''].filter(Boolean);
1651
+ // workspace grill W2: the words come from the sections (H.updateWords); a week with nothing moved and no open risk has no update to send
1494
1652
  const flag = U.sections.flatMap((x) => x[2]).find((x) => x.flag);
1495
- const text = (words.length ? words.join(', ').replace(/, ([^,]*)$/, ' and $1') + '.' : 'No initiative is open this week.') + (flag ? ' ' + flag.title + ': ' + flag.flag.toLowerCase() + '.' : '');
1496
- const sum = VW.czSummary({ line: sent ? 'Sent ' + new Date(sent.at).toDateString().slice(0, 10) : 'Ready to send', tone: sent ? '' : 'you', text,
1497
- meta: 'Communicator · ' + (U.to.length ? 'to ' + U.to.join(', ') : 'say who gets it under More'), primary: { label: sent ? 'Send again' : 'Send now', act: 'send' } });
1498
- const it = (x) => `<button type="button" class="czs-it" data-hv-ini="${esc(x.id)}"><span class="w">${esc(x.title)}<span>${esc([x.meta, x.text].filter(Boolean).join(' · '))}</span>${x.flag ? `<span class="flag">${esc(x.flag)}</span>` : ''}</span></button>`;
1499
- const secs = VW.czSections(U.sections.map(([k, h, L]) => ({ id: k, title: h, count: L.length, open: k !== 'ok' && L.length > 0, html: L.map(it).join('') }))
1500
- .concat([{ id: 'src', title: 'Sources', count: 'ladders and run logs · ' + H.hm(new Date(S.D.now).toISOString()), html: '<p>Drafted by Communicator from the ladders and run logs. Every line links to its source.</p>' }]), { key: 'weekly' });
1653
+ const text = (U.draft || sent ? H.updateWords(U) : 'Nothing moved and no risk is open this week, so there is no update to send.') + (flag ? ' ' + flag.title + ': ' + flag.flag.toLowerCase() + '.' : '');
1654
+ const sum = VW.czSummary({ line: sent ? 'Sent ' + new Date(sent.at).toDateString().slice(0, 10) : U.draft ? 'Ready to send' : 'Nothing new this week', tone: sent || !U.draft ? '' : 'you', text,
1655
+ meta: 'Communicator · ' + (U.to.length ? 'to ' + U.to.join(', ') : 'say who gets it under More'), primary: U.draft || sent ? { label: sent ? 'Send again' : 'Send now', act: 'send' } : null });
1656
+ const it = (x) => x.key ? `<div class="czs-it"><span class="w">${esc(x.title)}<span>${esc([x.meta, x.text].filter(Boolean).join(' · '))}</span></span></div>` : `<button type="button" class="czs-it" data-hv-ini="${esc(x.id)}"><span class="w">${esc(x.title)}<span>${esc([x.meta, x.text].filter(Boolean).join(' · '))}</span>${x.flag ? `<span class="flag">${esc(x.flag)}</span>` : ''}</span></button>`;
1657
+ // "Before you send": Claude's risks the PM has not confirmed, with one press that confirms them all (each a ✓ Keep on its program's charter)
1658
+ const confirmAll = (L) => { const say = L.length === 1 ? 'Confirm this risk' : 'Confirm all ' + L.length + ' risks'; return `<p><button type="button" class="hv-chip" data-hv-confirm aria-label="${say}" title="${say}">${SVG(I.check, 15, 2.4)}</button></p>`; };
1659
+ const secs = VW.czSections(U.sections.filter(([k, , L]) => L.length || k !== 'none').map(([k, h, L]) => ({ id: k, title: h, count: L.length, open: k !== 'ok' && L.length > 0, html: L.map(it).join('') + (k === 'confirm' && L.length ? confirmAll(L) : '') }))
1660
+ .concat([{ id: 'src', title: 'Sources', count: 'Jira, plan dates, confirmed risks · ' + H.hm(new Date(S.D.now).toISOString()), html: '<p>Drafted by Communicator from what PMOS read: the Jira tickets, the dates of the plan items and the risks you confirmed. An initiative nothing was read for is under Not read, never under On track.</p>' }]), { key: 'weekly' });
1501
1661
  const to = edit ? `<div class="hv-to"><span>To</span>${U.to.map((x, i) => `<button type="button" class="hv-chip" data-hv-rm="${i}" aria-label="Remove ${esc(x)}" title="Remove">${esc(x)}</button>`).join('')}<input id="hvTo" placeholder="${U.to.length ? 'Add' : 'Who gets it: a name, a mail or #channel'}" aria-label="Add a recipient"></div>` : '';
1502
1662
  const old = past ? `<div class="czs-secs"><div class="czs-body" style="padding-top:12px">${(U.sent || []).length ? U.sent.slice().reverse().map((x) => `<div class="czs-it"><span class="w">Week ${esc(x.week)}<span>${esc(new Date(x.at).toDateString())} · to ${esc((x.to || []).join(', '))}</span></span></div>`).join('') : '<p>No update was sent yet.</p>'}</div></div>` : '';
1503
1663
  b.innerHTML = to + sum + old + secs + '<p class="hx-n">Send opens a draft in your own mail app. PMOS sends nothing by itself; it records that you opened the draft.</p><p class="hx-n" role="status" id="hvMsg"></p>';
@@ -1517,9 +1677,18 @@ function openUpdateViewer(o = {}) {
1517
1677
  const t = e.target.closest('button'); if (!t) return;
1518
1678
  if (t.dataset.hvRm) { const to = H.weeklyUpdate(S.D).to.slice(); to.splice(+t.dataset.hvRm, 1); saveTo(to); return; }
1519
1679
  if (t.dataset.hvIni) { goIni(t.dataset.hvIni); return; }
1680
+ if ('hvConfirm' in t.dataset) { confirmRisks(H.weeklyUpdate(S.D, S.D.now).unconfirmed, () => draw(body)); return; }
1520
1681
  if (t.dataset.czsAct === 'send' || 'hvSend' in t.dataset) sendUpdate();
1521
1682
  };
1522
1683
  }
1684
+ /** ✓ on every risk Claude proposed and the PM has not confirmed (the "Mark … at risk" ✦ of the programs): each is a Keep on its program's charter
1685
+ * (POST /api/programs/suggestion), so its initiatives read At risk on every surface. */
1686
+ async function confirmRisks(L, after) {
1687
+ let ok = 0; const bad = [];
1688
+ for (const x of L || []) { const r = await pmPost('/api/programs/suggestion', { program: x.program, key: x.key, answer: 'keep' }).catch(() => null); if (r && r.ok) ok++; else bad.push((r && r.error) || 'not saved'); }
1689
+ toast(ok && !bad.length ? (ok === 1 ? 'The risk is confirmed.' : 'All ' + ok + ' risks are confirmed.') : ok ? ok + ' of ' + L.length + ' risks are confirmed. ' + bad[0] + '.' : 'No risk was confirmed: ' + (bad[0] || 'nothing to confirm') + '.');
1690
+ if (ok) { await load(); if (after) after(); }
1691
+ }
1523
1692
  /** This page's address for one view (the Viewer's Open in a new tab): the workspace and board of this page, and the view's own keys. */
1524
1693
  const selfUrl = (o) => { const q = new URLSearchParams(); if (WS) q.set('ws', WS); if (BOARD) q.set('board', BOARD); for (const [k, v] of Object.entries(o)) q.set(k, v); return location.pathname + '?' + q.toString(); };
1525
1694
  const deskWs = () => (S.D.desk && S.D.desk.ws) || ((S.D.board && S.D.board.board) || {}).ws || WS || undefined;
@@ -6,7 +6,8 @@
6
6
  * iniLayout(D, id) one initiative as a layout of Home: its card's groups (Needs you, Sessions, Stakeholders, Meetings, Routines, Files,
7
7
  * Finished sessions) and the Elsewhere line
8
8
  * productCard(D) the product's card (every anchor from D.board.anchorRows when sent): "This week", the KRs and the budget on track or at risk, Then, the next routine, the CTA
9
- * weeklyUpdate(D) the weekly update the Viewer shows and Send turns into a mail draft: On track, At risk, Needs a decision
9
+ * weeklyUpdate(D) the weekly update the Viewer shows and Send turns into a mail draft: risks to confirm, At risk, Needs a decision, On track,
10
+ * Not read (workspace grill W2); updateNews(D) says whether there is one to draft this week
10
11
  * streamOf(conv, o) the chat's stream: day dividers, "New since you left", "N earlier messages", messages, Worked-for folds
11
12
  * meetLineOf(P) the meeting line: the current or next meeting, +N for the rest of the day
12
13
  * roleOf(s, roles) an agent's role name and initial for a session ("Claude" when no skill names one)
@@ -107,7 +108,7 @@ export function needsOf(D) {
107
108
  // the product's weekly update: drafted from the board each week, it waits on the PM until it is sent (a sent record for this week)
108
109
  if (boardRows(D).length) {
109
110
  const P = (D.desk && D.desk.product) || {}, wk = weekOf(D.now || Date.now());
110
- if (!(P.sent || []).some((x) => x.week === wk.iso)) {
111
+ if (!(P.sent || []).some((x) => x.week === wk.iso) && updateNews(D, D.now || Date.now()).draft) {
111
112
  const rt = nextRoutine(D, D.now || Date.now(), /comm|update|weekly|status|stakeholder/i);
112
113
  rows.push({ id: 'n:update', kind: 'update', key: UPDATE_KEY, ini: 'product', name: 'Weekly update is ready to send', hint: 'Communicator · ' + (rt ? 'goes out ' + rt.when : 'send it when it reads right'), state: 'needs', icon: 'doc', initialRole: 'Co' });
113
114
  }
@@ -144,6 +145,9 @@ export const OPEN = (r) => !(r.acts && (r.acts.archived || r.acts.life === 'done
144
145
  /* ── the product ── */
145
146
  const statusOf = (r) => String(r.status || '').toLowerCase();
146
147
  const risky = (r) => r.late || /risk|late|stalled|blocked/.test(statusOf(r));
148
+ /* workspace grill W2: on track only from a read; a row with nothing read (status 'no signal', or no status at all) is never counted as on track */
149
+ const onTrack = (r) => !risky(r) && (statusOf(r) === 'on track' || statusOf(r) === 'done');
150
+ const noSignal = (r) => !risky(r) && !onTrack(r) && !/hold/.test(statusOf(r));
147
151
  const anchorOf = (r) => (r.kr ? r.kr : /^health:/.test(String(r.anchor || '')) ? String(r.anchor) : r.anchor ? String(r.anchor) : 'No anchor');
148
152
  export function productName(D) {
149
153
  const b = D.board && D.board.board;
@@ -159,10 +163,12 @@ export function productCard(D, now = Date.now()) {
159
163
  for (const r of rows) { const a = anchorOf(r); if (!by.has(a)) by.set(a, []); by.get(a).push(r); }
160
164
  const items = [...by.entries()].filter(([a]) => a !== 'No anchor').map(([a, L]) => {
161
165
  const bad = L.filter(risky);
162
- const lead = (bad[0] || L[0]);
166
+ // an anchor is on track only when a read says so for one of its initiatives and none is late or at risk; else it has no signal
167
+ const read = L.some(onTrack);
168
+ const lead = (bad[0] || L.find(onTrack) || L[0]);
163
169
  const health = /^health:/.test(a);
164
- const words = bad.length ? (bad[0].late ? 'running late' : /stalled/.test(statusOf(bad[0])) ? 'stalled' : statusOf(bad[0]) || 'at risk') : health ? 'within budget' : 'on track';
165
- return { kind: bad.length ? 'risk' : 'ok', label: (health ? a : a + ' · ' + lead.title) + ' ' + (health && !bad.length ? '· within budget' : words), anchor: a, ids: L.map((x) => x.id) };
170
+ const words = bad.length ? (bad[0].late ? 'running late' : /stalled/.test(statusOf(bad[0])) ? 'stalled' : statusOf(bad[0]) || 'at risk') : !read ? 'no signal' : health ? 'within budget' : 'on track';
171
+ return { kind: bad.length ? 'risk' : read ? 'ok' : 'none', label: (health ? a : a + ' · ' + lead.title) + ' ' + (health && !bad.length && read ? '· within budget' : words), anchor: a, ids: L.map((x) => x.id) };
166
172
  });
167
173
  const krs = items.filter((x) => !/^health:/.test(x.anchor)).length, budgets = items.length - krs;
168
174
  const ok = items.filter((x) => x.kind === 'ok').length;
@@ -186,9 +192,22 @@ export function weekOf(now = Date.now()) {
186
192
  const d = new Date(now); const day = (d.getDay() + 6) % 7; d.setDate(d.getDate() - day);
187
193
  return { iso: d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()), label: MON[d.getMonth()] + ' ' + d.getDate() };
188
194
  }
189
- /** The weekly update's three sections from the board: each initiative once, with its stage and anchor and one line of why. */
195
+ /** What the week holds (workspace grill W2): the rows that moved (a stage entered this week), the rows at risk or late, Claude's risks the PM
196
+ * has not confirmed, and the open rows nothing was read for. The update is drafted only when something moved or a risk is open. */
197
+ export function updateNews(D, now = Date.now()) {
198
+ const rows = boardRows(D).filter(OPEN).filter((r) => !/hold/.test(statusOf(r)));
199
+ const trail = (D.trail && D.trail.items) || {};
200
+ const moved = rows.filter((r) => trail[r.id] && trail[r.id].days != null && trail[r.id].days < 7);
201
+ const risk = rows.filter(risky);
202
+ const unconfirmed = (D.board && Array.isArray(D.board.openRisks) ? D.board.openRisks : []).filter((x) => x && x.key);
203
+ return { moved, risk, unconfirmed, unread: rows.filter(noSignal), draft: moved.length > 0 || risk.length > 0 || unconfirmed.length > 0 };
204
+ }
205
+ /** The weekly update from the board: first the risks Claude found that the PM has not confirmed, then At risk, Needs a decision, On track (only
206
+ * what a read says) and Not read (the initiatives nothing was read for). `draft` is false in a week where nothing moved and no risk is open:
207
+ * then there is no update to send. */
190
208
  export function weeklyUpdate(D, now = Date.now()) {
191
209
  const rows = boardRows(D).filter(OPEN);
210
+ const N = updateNews(D, now);
192
211
  const trail = (D.trail && D.trail.items) || {}, usual = (D.trail && D.trail.usual) || {};
193
212
  const needIds = new Set(needsOf(D).filter((r) => r.row && r.row.kind === 'decision').map((r) => r.ini));
194
213
  const line = (r) => {
@@ -196,20 +215,33 @@ export function weeklyUpdate(D, now = Date.now()) {
196
215
  const days = t && t.days != null ? 'In ' + r.stage + ' for ' + plural(t.days, 'day', 'days') : 'In ' + (r.stage || 'no stage');
197
216
  const over = t && u && u.days != null && t.days > u.days ? ', ' + plural(t.days - u.days, 'day', 'days') + ' over its usual time' : '';
198
217
  const fin = r.finish ? '. Planned finish ' + r.finish : '';
199
- return days + over + fin + '.';
218
+ const why = r.signal && r.signal.why ? '. ' + r.signal.why : '';
219
+ return days + over + fin + why + '.';
200
220
  };
201
221
  const item = (r) => ({ id: r.id, title: r.title, meta: [r.stage, anchorOf(r)].filter(Boolean).join(' · '), text: line(r), flag: r.late && !/late/.test(statusOf(r)) ? 'Looks green, but it is past its planned finish' : '' });
202
222
  const decide = rows.filter((r) => needIds.has(r.id) || /stalled|hold/.test(statusOf(r)));
203
223
  const risk = rows.filter((r) => !decide.includes(r) && risky(r));
204
- const ok = rows.filter((r) => !decide.includes(r) && !risk.includes(r));
224
+ const ok = rows.filter((r) => !decide.includes(r) && !risk.includes(r) && onTrack(r));
225
+ const none = rows.filter((r) => !decide.includes(r) && !risk.includes(r) && !ok.includes(r));
226
+ const conf = N.unconfirmed.map((x) => ({ id: (x.inis && x.inis[0]) || '', key: x.key, program: x.program, title: x.title, meta: x.programTitle || '', text: (x.why || '') + (x.src && x.src.label ? ' (' + x.src.label + ')' : ''), flag: '', risk: true }));
205
227
  const P = (D.desk && D.desk.product) || {};
206
- return { week: weekOf(now), to: P.recipients || [], sent: P.sent || [], sections: [['ok', 'On track', ok.map(item)], ['risk', 'At risk', risk.map(item)], ['decide', 'Needs a decision', decide.map(item)]] };
228
+ const sections = [];
229
+ if (conf.length) sections.push(['confirm', 'Before you send · ' + plural(conf.length, 'risk you have not confirmed', 'risks you have not confirmed'), conf]);
230
+ sections.push(['risk', 'At risk', risk.map(item)], ['decide', 'Needs a decision', decide.map(item)], ['ok', 'On track', ok.map(item)]);
231
+ if (none.length) sections.push(['none', 'Not read', none.map((r) => ({ ...item(r), text: 'Nothing read says how it is going. ' + line(r) }))]);
232
+ return { week: weekOf(now), to: P.recipients || [], sent: P.sent || [], draft: N.draft, unconfirmed: N.unconfirmed, sections };
233
+ }
234
+ const COUNT_WORDS = { confirm: ['risk to confirm', 'risks to confirm'], risk: ['at risk', 'at risk'], decide: ['needs a decision', 'need a decision'], ok: ['on track', 'on track'], none: ['not read', 'not read'] };
235
+ /** The update's summary in words, from its sections: "2 at risk, 3 on track and 4 not read." */
236
+ export function updateWords(U) {
237
+ const w = U.sections.filter(([, , L]) => L.length).map(([k, , L]) => L.length + ' ' + (L.length === 1 ? COUNT_WORDS[k][0] : COUNT_WORDS[k][1]));
238
+ return w.length ? w.join(', ').replace(/, ([^,]*)$/, ' and $1') + '.' : 'No initiative is open this week.';
207
239
  }
208
- /** The update as mail text, for the mailto draft. */
240
+ /** The update as mail text, for the mailto draft. The risks the PM has not confirmed are not mailed: they are the PM's to check first. */
209
241
  export function updateText(U, name) {
210
242
  const out = [name + ' · week of ' + U.week.label, ''];
211
- for (const [, h, L] of U.sections) { if (!L.length) continue; out.push(h.toUpperCase()); for (const x of L) out.push('- ' + x.title + ' (' + x.meta + '): ' + x.text + (x.flag ? ' ' + x.flag + '.' : '')); out.push(''); }
212
- out.push('Drafted in PMOS from the ladders and run logs.');
243
+ for (const [k, h, L] of U.sections) { if (!L.length || k === 'confirm') continue; out.push(h.toUpperCase()); for (const x of L) out.push('- ' + x.title + (x.meta ? ' (' + x.meta + ')' : '') + ': ' + x.text + (x.flag ? ' ' + x.flag + '.' : '')); out.push(''); }
244
+ out.push('Drafted in PMOS from what it read: Jira, the plan\'s dates and the risks you confirmed.');
213
245
  return out.join('\n');
214
246
  }
215
247
 
@@ -252,7 +284,7 @@ function productRow(D) {
252
284
  const P = productCard(D);
253
285
  const risk = P.items.filter((x) => x.kind === 'risk').length;
254
286
  const kids = needsOf(D).filter((r) => r.kind === 'update');
255
- const upd = P.sent ? 'update sent' : 'update ready';
287
+ const upd = P.sent ? 'update sent' : updateNews(D, D.now || Date.now()).draft ? 'update ready' : 'nothing new this week';
256
288
  return { id: 'p:product', kind: 'product', name: P.name, hint: 'Product · ' + upd + (risk ? ', ' + risk + ' at risk' : ''), state: P.sent ? 'quiet' : 'needs', icon: 'product',
257
289
  kids, chip: P.sent ? null : { text: 'your turn', loud: true } };
258
290
  }