create-pmos 0.124.0 → 0.126.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 (129) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +2 -2
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/scripts/nine-stages.js +81 -0
  6. package/payload/scripts/workflow-state.js +2 -0
  7. package/payload/state/discoveries.md +1 -1
  8. package/payload/state/log.md +1 -1
  9. package/workspace/c3-one-home-test.js +131 -0
  10. package/workspace/fixtures/initiatives-api/list.json +171 -3
  11. package/workspace/fixtures/initiatives-api/roadmap.json +228 -4
  12. package/workspace/fixtures/initiatives-api/sheet.json +57 -1
  13. package/workspace/fixtures/one-home-baseline.json +83 -0
  14. package/workspace/guide/briefing-cards.md +40 -0
  15. package/workspace/guide/index.md +1 -0
  16. package/workspace/guide/layout.md +2 -0
  17. package/workspace/lib/engine/nine-stages.js +81 -0
  18. package/workspace/lib/engine/workflow-state.js +2 -0
  19. package/workspace/lib/facts-async.js +51 -0
  20. package/workspace/lib/item-steps.js +52 -0
  21. package/workspace/lib/jira-status.js +8 -3
  22. package/workspace/lib/jobs-to-be-done.js +52 -0
  23. package/workspace/lib/needs-overdue.js +41 -0
  24. package/workspace/lib/outbox-store.js +87 -0
  25. package/workspace/lib/pm-items.js +115 -0
  26. package/workspace/lib/route-modules.js +21 -0
  27. package/workspace/lib/routes-briefing.js +109 -0
  28. package/workspace/lib/routes-c3-init.js +53 -0
  29. package/workspace/lib/routes-c3-life.js +119 -0
  30. package/workspace/lib/routes-c3-strategy.js +169 -0
  31. package/workspace/lib/routes-c3-timeline.js +191 -0
  32. package/workspace/lib/routes-calendar-import.js +60 -0
  33. package/workspace/lib/routes-calendar-views.js +61 -0
  34. package/workspace/lib/routes-doc-health.js +79 -0
  35. package/workspace/lib/routes-doc-sheet.js +169 -0
  36. package/workspace/lib/routes-forecast.js +85 -0
  37. package/workspace/lib/routes-hooks.js +121 -0
  38. package/workspace/lib/routes-initiatives.js +3 -0
  39. package/workspace/lib/routes-intake.js +24 -0
  40. package/workspace/lib/routes-outbox.js +97 -0
  41. package/workspace/lib/routes-pm-items.js +216 -0
  42. package/workspace/lib/routes-records.js +61 -0
  43. package/workspace/lib/routes-releases.js +129 -0
  44. package/workspace/lib/routes-report-facts.js +84 -0
  45. package/workspace/lib/routes-report-families.js +32 -0
  46. package/workspace/lib/routes-roadmap-ops.js +141 -0
  47. package/workspace/lib/routes-runs.js +119 -0
  48. package/workspace/lib/routes-signals.js +50 -0
  49. package/workspace/lib/run-answers.js +69 -0
  50. package/workspace/package-lock.json +2 -2
  51. package/workspace/package.json +1 -1
  52. package/workspace/public/c3.html +26 -0
  53. package/workspace/public/pm/briefing-deck.js +413 -0
  54. package/workspace/public/pm/briefing-model.js +211 -0
  55. package/workspace/public/pm/c3-breakdown.js +240 -0
  56. package/workspace/public/pm/c3-brief.js +278 -0
  57. package/workspace/public/pm/c3-cal-strategy.js +528 -0
  58. package/workspace/public/pm/c3-case.js +185 -0
  59. package/workspace/public/pm/c3-daypart.js +176 -0
  60. package/workspace/public/pm/c3-demo-brief-ix.js +350 -0
  61. package/workspace/public/pm/c3-demo-cal-ix.js +74 -0
  62. package/workspace/public/pm/c3-demo-cal2.js +66 -0
  63. package/workspace/public/pm/c3-demo-cal3.js +173 -0
  64. package/workspace/public/pm/c3-demo-data.js +560 -0
  65. package/workspace/public/pm/c3-demo-init.js +373 -0
  66. package/workspace/public/pm/c3-demo-life.js +77 -0
  67. package/workspace/public/pm/c3-demo-misc2.js +46 -0
  68. package/workspace/public/pm/c3-demo-plan2.js +30 -0
  69. package/workspace/public/pm/c3-demo-rep-ix.js +126 -0
  70. package/workspace/public/pm/c3-demo-rep2.js +92 -0
  71. package/workspace/public/pm/c3-demo-term.js +153 -0
  72. package/workspace/public/pm/c3-demo-time.js +49 -0
  73. package/workspace/public/pm/c3-demo-work-ix.js +80 -0
  74. package/workspace/public/pm/c3-demo-work2.js +13 -0
  75. package/workspace/public/pm/c3-demo.js +113 -0
  76. package/workspace/public/pm/c3-doc-sheet.js +314 -0
  77. package/workspace/public/pm/c3-dock.js +180 -0
  78. package/workspace/public/pm/c3-docs.js +290 -0
  79. package/workspace/public/pm/c3-expand.js +96 -0
  80. package/workspace/public/pm/c3-init-extras.js +170 -0
  81. package/workspace/public/pm/c3-lifecycle-panels.js +476 -0
  82. package/workspace/public/pm/c3-misc-panels.js +89 -0
  83. package/workspace/public/pm/c3-plan-panels.js +163 -0
  84. package/workspace/public/pm/c3-plan.js +192 -0
  85. package/workspace/public/pm/c3-roadmap.js +249 -0
  86. package/workspace/public/pm/c3-sheet.js +107 -0
  87. package/workspace/public/pm/c3-shell.js +674 -0
  88. package/workspace/public/pm/c3-signals.js +196 -0
  89. package/workspace/public/pm/c3-strategy-panels.js +45 -0
  90. package/workspace/public/pm/c3-swipe.js +230 -0
  91. package/workspace/public/pm/c3-terminal-data.js +26 -0
  92. package/workspace/public/pm/c3-terminal-edge.js +91 -0
  93. package/workspace/public/pm/c3-terminal-icons.js +41 -0
  94. package/workspace/public/pm/c3-terminal-results.js +141 -0
  95. package/workspace/public/pm/c3-terminal.js +52 -0
  96. package/workspace/public/pm/c3-tickets.js +163 -0
  97. package/workspace/public/pm/c3-timeline.js +201 -0
  98. package/workspace/public/pm/c3-toast.js +16 -0
  99. package/workspace/public/pm/calendar-views.js +1014 -0
  100. package/workspace/public/pm/console3-lift.md +220 -0
  101. package/workspace/public/pm/console3.css +1547 -0
  102. package/workspace/public/pm/dest-initiatives.js +16 -3
  103. package/workspace/public/pm/dest-mywork.js +8 -1
  104. package/workspace/public/pm/dest-reports.js +47 -1
  105. package/workspace/public/pm/dest-today.js +55 -1
  106. package/workspace/public/pm/doc-health-panel.js +110 -0
  107. package/workspace/public/pm/doc-rail.js +9 -3
  108. package/workspace/public/pm/lifecycle-lens-model.js +228 -0
  109. package/workspace/public/pm/lifecycle-lens.js +261 -0
  110. package/workspace/public/pm/new-initiative-sheet.js +116 -21
  111. package/workspace/public/pm/outbox-panel.js +125 -0
  112. package/workspace/public/pm/pm-work-view.js +895 -0
  113. package/workspace/public/pm/releases-view.js +248 -0
  114. package/workspace/public/pm/report-families-view.js +503 -0
  115. package/workspace/public/pm/run-card.js +244 -0
  116. package/workspace/public/pm/sessions-panel.js +149 -0
  117. package/workspace/public/pm/shell-bell.js +162 -0
  118. package/workspace/public/pm/shell-model.js +83 -0
  119. package/workspace/public/pm/shell-phone.js +124 -0
  120. package/workspace/public/pm/shell-readaloud.js +53 -0
  121. package/workspace/public/pm/shell-recording.js +110 -0
  122. package/workspace/public/pm/shell-ticker.js +90 -0
  123. package/workspace/public/pm/slide-confirm.js +163 -0
  124. package/workspace/public/pm/timeline-forecast-view.js +164 -0
  125. package/workspace/public/pm/today-day.js +8 -2
  126. package/workspace/public/pm/tokens.css +1 -0
  127. package/workspace/server.js +5 -2
  128. package/workspace/test-files.json +21 -3
  129. package/workspace/test-lists/browser-suites-green.json +5 -0
@@ -25,6 +25,9 @@ import { openSheet } from './initiative-sheet.js';
25
25
  import { openNewInitiative } from './new-initiative-sheet.js';
26
26
  import './cycle-review.js';
27
27
  import './triage-card.js';
28
+ import './releases-view.js';
29
+ import './lifecycle-lens.js';
30
+ import './timeline-forecast-view.js';
28
31
 
29
32
  setInitiativeOpener(openSheet);
30
33
 
@@ -93,21 +96,31 @@ function draw(host, r, ctx, repo, ws) {
93
96
  if (r.extra && r.extra.dispose) { try { r.extra.dispose(r.body); } catch { /* the view is gone */ } }
94
97
  r.handle = null; r.extra = null;
95
98
 
99
+ // The Roadmap's control budget is fixed (first-paint-initiatives: PRD Q5, 24 plus the top bar), so only Timeline keeps its own
100
+ // tab; every other registered view (Releases, Lifecycle, Forecast, ...) sits behind one 'More' tab with a second row.
101
+ // Only the console3 views are grouped; any other view a later run registers keeps its own tab (the View-control contract).
102
+ const MORE_IDS = ['releases', 'lifecycle', 'forecast'];
103
+ const extras = initiativesViews().filter((v) => !BUILT_IN.includes(v.id)).map((v) => ({ id: v.id, label: v.label || v.id }));
104
+ const ownTab = extras.filter((v) => !MORE_IDS.includes(v.id));
105
+ const moreViews = extras.filter((v) => MORE_IDS.includes(v.id));
96
106
  const views = [
97
107
  { id: 'roadmap', label: COPY.roadmap },
98
108
  { id: 'list', label: COPY.list },
99
- ...initiativesViews().filter((v) => !BUILT_IN.includes(v.id)).map((v) => ({ id: v.id, label: v.label || v.id })),
109
+ ...ownTab,
110
+ ...(moreViews.length ? [{ id: 'more', label: 'More' }] : []),
100
111
  ];
101
- if (!views.some((v) => v.id === r.view)) r.view = 'roadmap';
112
+ if (!views.some((v) => v.id === r.view) && !moreViews.some((v) => v.id === r.view)) r.view = 'roadmap';
102
113
  // A phone shows Roadmap or List only, and has no view switch (pm/mobile.css): its screen is one job, to see where the
103
114
  // initiatives stand and open one. ⌘K changes between the two. Any other view falls back to Roadmap here without being
104
115
  // saved, so the desktop keeps its choice.
105
116
  if (phone() && !BUILT_IN.includes(r.view)) r.view = 'roadmap';
117
+ const inMore = moreViews.some((v) => v.id === r.view);
106
118
  r.key = keyOf(r, repo, ws);
107
119
 
108
120
  const head = el('div', { class: 'in-head' },
109
121
  el('h2', { class: 'in-head__title' }, COPY.title),
110
- tabs({ variant: 'segmented', role: 'tablist', ariaLabel: COPY.views, items: views, value: r.view, attrs: { 'data-in-views': '' }, onChange: (id) => { r.view = id; saveView(id); r.draw(r.ctx); } }),
122
+ tabs({ variant: 'segmented', role: 'tablist', ariaLabel: COPY.views, items: views, value: inMore ? 'more' : r.view, attrs: { 'data-in-views': '' }, onChange: (id) => { r.view = id === 'more' ? moreViews[0].id : id; saveView(r.view); r.draw(r.ctx); } }),
123
+ inMore ? tabs({ variant: 'segmented', role: 'tablist', ariaLabel: 'More views', items: moreViews, value: r.view, attrs: { 'data-in-more': '' }, onChange: (id) => { r.view = id; saveView(id); r.draw(r.ctx); } }) : null,
111
124
  // Scan repository (scan-documents S7): the visible form of the sheet menu's and ⌘K's re-scan. It needs a workspace.
112
125
  ws ? button({ label: COPY.rescan, icon: 'refresh-cw', attrs: { 'data-in-scan': '', title: COPY.rescanHint }, onClick: () => rescanRepo(ws) }) : null,
113
126
  button({ label: COPY.newInitiative, icon: 'plus', variant: 'primary', attrs: { 'data-in-new': '' }, onClick: () => r.openNew() }));
@@ -19,6 +19,8 @@ import { icon, ensureSprite } from './icons.js';
19
19
  import { pmPost } from './http.js';
20
20
  import { wsOf } from './data.js';
21
21
  import { registerCommand, openDestination } from './registry.js';
22
+ import { mountPmWork, disposePmWork } from './pm-work-view.js';
23
+ import { mountSessions, disposeSessions } from './sessions-panel.js';
22
24
  import { myWork, loadMyWork, onMyWork } from './my-work-data.js';
23
25
 
24
26
  export const COPY = Object.freeze({
@@ -112,12 +114,16 @@ export function mountDestination(host, ctx) {
112
114
  ensureSprite();
113
115
  loadMyWork();
114
116
  draw(inst);
117
+ if (!inst.items) { inst.items = el('div', { 'data-pm-items-host': '' }); inst.root.appendChild(inst.items); mountPmWork(inst.items, ctx); }
118
+ if (!inst.sessions) { inst.sessions = el('div', { 'data-sessions-host': '' }); inst.root.appendChild(inst.sessions); mountSessions(inst.sessions, ctx); }
115
119
  }
116
120
 
117
121
  export function disposeDestination(host) {
118
122
  const inst = insts.get(host);
119
123
  if (!inst) return;
120
124
  if (inst.off) inst.off();
125
+ if (inst.items) disposePmWork(inst.items);
126
+ if (inst.sessions) disposeSessions(inst.sessions);
121
127
  inst.root.remove();
122
128
  insts.delete(host);
123
129
  }
@@ -277,7 +283,8 @@ function draw(inst) {
277
283
  const root = inst.root;
278
284
  const d = myWork();
279
285
  const head = header(inst, d);
280
- if (head.parentNode !== root || inst.body.parentNode !== root) root.replaceChildren(head, inst.body);
286
+ // The PM items panel lives in the root too; a redraw of head and body must keep it.
287
+ if (head.parentNode !== root || inst.body.parentNode !== root) root.replaceChildren(head, inst.body, ...(inst.items ? [inst.items] : []), ...(inst.sessions ? [inst.sessions] : []));
281
288
  const fp = JSON.stringify([d, inst.tab, inst.open, inst.details, inst.busy]);
282
289
  if (fp === inst.last) return;
283
290
  // Focus stays in an instruction being typed: the redraw waits until the field is left.
@@ -25,6 +25,11 @@ import { wsOf } from './data.js';
25
25
  import { COPY, cadenceName } from './reports-copy.js';
26
26
  import { openIssueSheet, openNotesSheet, draftNow, printReport, copyMarkdown, clearPrint, ensurePrintCss } from './report-view.js';
27
27
  import { openSlides, slideCards } from './report-slides.js';
28
+ /* The report families and the outbox load on demand: they are only drawn when the PM opens the toggle, and their module tree (the c3
29
+ * panels) must not weigh on the first paint of the Reports page. `lazy()` resolves both modules once. */
30
+ let lazyMods = null;
31
+ const lazy = () => lazyMods || (lazyMods = Promise.all([import('./report-families-view.js'), import('./outbox-panel.js')]).then(([f, o]) => ({ ...f, ...o })));
32
+ const disposeLazy = (fn, host) => { if (lazyMods) lazyMods.then((m) => m[fn](host)); };
28
33
 
29
34
  const SHOWN = 4; // the reports listed before "Show older"
30
35
  const insts = new WeakMap(); // host -> instance
@@ -49,6 +54,7 @@ function make(host) {
49
54
  const inst = {
50
55
  host, root, ws: null, loaded: false, error: null, reports: [], flags: [], sig: null, schedules: [], doc: null, docKey: null,
51
56
  sel: null, showAll: false, otherOpen: false, locked: false, stop: null, seq: 0, last: '',
57
+ extra: null, famHost: null, outHost: null, extraWs: null,
52
58
  };
53
59
  return inst;
54
60
  }
@@ -72,17 +78,56 @@ export function mountDestination(host, ctx) {
72
78
  refresh(inst);
73
79
  }
74
80
  draw(inst);
81
+ mountExtras(inst, ctx);
75
82
  return;
76
83
  }
77
84
  if (!inst.last) draw(inst);
85
+ mountExtras(inst, ctx);
78
86
  }
79
87
 
88
+ /* Report families and the outbox sit under the page. They live in one container the page's own redraw never replaces
89
+ * (draw() puts it back last), so a redraw does not lose them. Mounted once per repository: the families read their sections
90
+ * from GET /api/report-families/facts (real initiatives and releases only), then the outbox. */
91
+ function mountExtras(inst, ctx) {
92
+ if (!inst.ws || inst.extraWs === inst.ws) return;
93
+ inst.extraWs = inst.ws;
94
+ if (!inst.extra) {
95
+ inst.famHost = el('div', { 'data-reports-families': '' });
96
+ inst.outHost = el('div', { 'data-reports-outbox': '' });
97
+ inst.panel = el('div', { class: 'rp-extra__panel', hidden: true }, inst.famHost, inst.outHost);
98
+ // Collapsed by default: the page's control budget (first-paint, pm-frame Q5) counts what a first paint shows, so the
99
+ // families and the outbox cost one control until the PM opens them.
100
+ inst.toggle = button({ label: 'Report families and outbox', variant: 'ghost', attrs: { 'data-reports-extra-toggle': '', 'aria-expanded': 'false' }, onClick: () => toggleExtras(inst, ctx) });
101
+ inst.extra = el('div', { class: 'rp-extra', 'data-reports-extra': '' }, inst.toggle, inst.panel);
102
+ }
103
+ keepExtra(inst);
104
+ // A repository change resets to collapsed, with nothing mounted: the families read their own facts when opened.
105
+ inst.extraOpen = false; inst.panel.hidden = true; inst.toggle.setAttribute('aria-expanded', 'false');
106
+ disposeLazy('disposeReportFamilies', inst.famHost); disposeLazy('disposeOutbox', inst.outHost);
107
+ }
108
+ function toggleExtras(inst, ctx) {
109
+ inst.extraOpen = !inst.extraOpen;
110
+ inst.panel.hidden = !inst.extraOpen;
111
+ inst.toggle.setAttribute('aria-expanded', String(inst.extraOpen));
112
+ if (!inst.extraOpen || !inst.ws) return;
113
+ const ws = inst.ws;
114
+ disposeLazy('disposeReportFamilies', inst.famHost);
115
+ pmGet('/api/report-families/facts?ws=' + encodeURIComponent(ws)).then((j) => {
116
+ if (inst.extraWs !== ws || !inst.extraOpen || !insts.has(inst.host)) return;
117
+ lazy().then((m) => { if (inst.extraWs === ws && inst.extraOpen) m.mountReportFamilies(inst.famHost, { ...(ctx || {}), reportFacts: j && j.ok ? j.facts : {} }); });
118
+ });
119
+ lazy().then((m) => { if (inst.extraOpen) m.mountOutbox(inst.outHost, {}); });
120
+ }
121
+ const keepExtra = (inst) => { if (inst.extra && inst.ws && inst.extra.parentNode !== inst.root) inst.root.appendChild(inst.extra); };
122
+
80
123
  export function disposeDestination(host) {
81
124
  const inst = insts.get(host);
82
125
  if (!inst) return;
83
126
  if (inst.stop) inst.stop();
84
127
  inst.stop = null; inst.seq++;
85
128
  clearPrint();
129
+ if (inst.famHost) disposeLazy('disposeReportFamilies', inst.famHost);
130
+ if (inst.outHost) disposeLazy('disposeOutbox', inst.outHost);
86
131
  inst.root.remove();
87
132
  insts.delete(host);
88
133
  }
@@ -309,7 +354,8 @@ function goFullScreen() {
309
354
  }
310
355
 
311
356
  /** Rebuild the persistent root from the instance, if what it shows changed; keep the focus on the same control. */
312
- function draw(inst) {
357
+ function draw(inst) { drawMain(inst); keepExtra(inst); }
358
+ function drawMain(inst) {
313
359
  const root = inst.root;
314
360
  if (!inst.ws) { root.replaceChildren(empty({ text: COPY.noRepo })); inst.last = 'none'; return; }
315
361
  const fingerprint = JSON.stringify([inst.loaded, inst.error, inst.reports, inst.flags, inst.schedules, inst.sel, inst.doc && inst.doc.sha, inst.showAll, inst.otherOpen, inst.locked]);
@@ -22,6 +22,8 @@ import { state, setState, FIXTURE } from './state.js';
22
22
  import { COPY } from './today-copy.js';
23
23
  import { todayCard, acceptCard, rankNodes, scopeCtx, ensureFeed, feedOf, feedErrorOf, bannerOf, onFeed } from './today-cards.js';
24
24
  import { daySection, daySig, dayState, pressPrepare } from './today-day.js';
25
+ import { mountBriefing, disposeBriefing } from './briefing-deck.js';
26
+ import { mountCalendarViews, disposeCalendarViews } from './calendar-views.js';
25
27
 
26
28
  const SHOWN = 3; // cards drawn before "Show more": the first paint holds at most 17 controls (first-paint-today.test.js)
27
29
  let expanded = false;
@@ -205,8 +207,60 @@ export function mountToday(host, ctx) {
205
207
  refocus(r.root, k);
206
208
  }
207
209
 
208
- export function mountDestination(host, ctx) { mountToday(host, ctx); }
210
+ /* The destination: Today's own board, plus Briefing and Calendar (console3 wave D) as two more views of the same page.
211
+ * Each view has its own pane in one wrapper that survives every redraw (the container-survives-redraw rule: dest-mywork.js
212
+ * keeps inst.items the same way). Briefing and Calendar mount once, on first open, into their pane and are disposed with the
213
+ * destination; Today keeps drawing into its pane exactly as before, so its content is unchanged. */
214
+ const VIEWS = [{ id: 'today', label: 'Today' }, { id: 'briefing', label: 'Briefing' }, { id: 'calendar', label: 'Calendar' }];
215
+ const dests = new WeakMap(); // host -> { wrap, bar, panes, view, mounted }
216
+
217
+ function destFor(host, ctx) {
218
+ let d = dests.get(host);
219
+ if (d) return d;
220
+ const panes = {};
221
+ for (const v of VIEWS) panes[v.id] = el('div', { class: 'td-view', 'data-today-view': v.id });
222
+ d = { wrap: null, bar: null, panes, view: 'today', mounted: {} };
223
+ const bar = tabs({
224
+ variant: 'segmented', role: 'radiogroup', ariaLabel: 'Today views', value: d.view,
225
+ items: VIEWS.map((v) => ({ id: v.id, label: v.label })),
226
+ onChange: (id) => { d.view = VIEWS.some((v) => v.id === id) ? id : 'today'; showView(host, d, d.ctx); },
227
+ attrs: { 'data-today': 'views' },
228
+ });
229
+ d.bar = bar;
230
+ d.wrap = el('div', { class: 'td-dest', 'data-today': 'dest' }, bar, ...VIEWS.map((v) => panes[v.id]));
231
+ dests.set(host, d);
232
+ return d;
233
+ }
234
+
235
+ function showView(host, d, ctx) {
236
+ for (const v of VIEWS) d.panes[v.id].hidden = v.id !== d.view;
237
+ d.bar.querySelectorAll('button[data-tab]').forEach((b) => { // the segmented control does not track its own choice
238
+ const on = b.getAttribute('data-tab') === d.view;
239
+ b.setAttribute('aria-checked', on ? 'true' : 'false'); b.setAttribute('tabindex', on ? '0' : '-1');
240
+ });
241
+ if (d.view === 'today') mountToday(d.panes.today, ctx);
242
+ else if (d.view === 'briefing') { d.mounted.briefing = true; mountBriefing(d.panes.briefing, ctx); }
243
+ else if (d.view === 'calendar') { d.mounted.calendar = true; mountCalendarViews(d.panes.calendar, ctx); }
244
+ }
245
+
246
+ export function mountDestination(host, ctx) {
247
+ if (!host) return;
248
+ const d = destFor(host, ctx);
249
+ d.ctx = ctx;
250
+ if (!d.wrap.isConnected || d.wrap.parentNode !== host) host.replaceChildren(d.wrap);
251
+ showView(host, d, ctx);
252
+ }
209
253
  export function disposeDestination(host) {
254
+ const d = dests.get(host);
255
+ if (d) {
256
+ for (const v of VIEWS) disposeToday(d.panes[v.id]);
257
+ if (d.mounted.briefing) disposeBriefing(d.panes.briefing);
258
+ if (d.mounted.calendar) disposeCalendarViews(d.panes.calendar);
259
+ dests.delete(host);
260
+ }
261
+ disposeToday(host);
262
+ }
263
+ function disposeToday(host) {
210
264
  const r = roots.get(host);
211
265
  if (r && r.off) r.off();
212
266
  roots.delete(host);
@@ -0,0 +1,110 @@
1
+ /* pm/doc-health-panel.js — console3 wave B2: the document's Health and History modes, and the Ask-the-agent diff.
2
+ *
3
+ * Health: "N of M checks" and the reason for each. A check with no input is shown as "not enough input" and is not
4
+ * in N or M. The result is recomputed by the server from the text on each read and is never stored here beyond a short hold.
5
+ * Fix with the agent: disabled until the agent session path is built. Ask the agent: the word diff of a before and an
6
+ * after, with Accept disabled (accept by slide needs the outbox and the slide, which are not built).
7
+ * History: the word diff between two revisions of the document, given as a list { id, text, label? }.
8
+ */
9
+ import { el } from './dom.js';
10
+ import { setState } from './state.js';
11
+ import { ensureC3Css, c3ic } from './pm-work-view.js';
12
+
13
+ /* R4b: the panels are drawn on the console3 mockup's document sheet markup (console3.html docPanel, lines 4090 to 4104; the diff,
14
+ * lines 4075 to 4076; the revision list, line 4102): `.dpn` holds `.chips2` (the shield and "N of M checks pass"), `ul.chgl`
15
+ * rows (`li.ok` or `li.bad` with the check and its reason), `ul.hx` for the revisions, and `.dpn.dfi > .dsec > h4 + p` with
16
+ * `<ins>` and `<del>` for the diff. The styles are pm/console3.css. The document panel mounts in the document rail, which has no
17
+ * `.c3` root, so each panel carries `c3 c3e`. Not lifted: the mockup's Read, Edit and Relations modes (the rail has its own),
18
+ * the per-check "Fix with the agent" chips (one disabled button stays, there is no agent session path), the section flags,
19
+ * and the document graph (no relations data is served here). */
20
+ const wrap = (attrs, kids) => { ensureC3Css(); return el('div', { class: 'c3 c3e', ...attrs }, el('div', { class: 'dpn' }, kids)); };
21
+ const hcol = (h) => (h.total === 0 ? 'var(--muted-foreground)' : h.passed === h.total ? 'var(--success)' : h.total - h.passed === 1 ? 'var(--warning)' : 'var(--destructive)');
22
+
23
+ const held = new Map(); // ws|path -> { state, data, error, at }
24
+
25
+ const NAMES = { fresh: 'Fresh', decisions: 'Matches decisions', data: 'Matches data', complete: 'Complete', traced: 'Traced', readers: 'Readers have the current version' };
26
+
27
+ function readHealth(ws, rel) {
28
+ const k = ws + '|' + rel;
29
+ const cur = held.get(k);
30
+ if (cur && (cur.state === 'loading' || Date.now() - cur.at < 30000)) return cur;
31
+ const next = { state: 'loading', data: cur ? cur.data : null, at: Date.now() };
32
+ held.set(k, next);
33
+ fetch('/api/doc-health/check', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ws, path: rel }) })
34
+ .then((r) => r.json())
35
+ .then((d) => { held.set(k, d && d.ok ? { state: 'ok', data: d.health, at: Date.now() } : { state: 'error', error: (d && d.error) || 'refused', at: Date.now() }); setState({}); },
36
+ (e) => { held.set(k, { state: 'error', error: (e && e.message) || 'could not reach the workspace server', at: Date.now() }); setState({}); });
37
+ return next;
38
+ }
39
+
40
+ /** What the Health mode shows now, for the rail's change key. */
41
+ export function healthStamp(ws, rel) { const g = held.get(ws + '|' + rel); return g ? [g.state, g.error || null, g.data || null] : null; }
42
+
43
+ export function refreshHealth(ws, rel) { held.delete(ws + '|' + rel); }
44
+
45
+ /** The pure view of one healthOf answer. */
46
+ export function healthView(h) {
47
+ const note = h.checks.filter((c) => c.pass === null).length;
48
+ return wrap({ 'data-rail': 'health' }, [
49
+ el('div', { class: 'chips2', 'data-health': 'count' }, [el('span', { class: 'ki', style: 'color:' + hcol(h) }, [c3ic('shield')]), el('b', null, `${h.passed} of ${h.total} checks pass`)]),
50
+ note ? el('p', { class: 'dim', style: 'font-size:var(--fs-2);margin:0', 'data-health': 'unanswered' }, `${note} check${note === 1 ? '' : 's'} not counted: not enough input.`) : null,
51
+ el('ul', { class: 'chgl' }, h.checks.map((c) => el('li', { class: c.pass === null ? '' : c.pass ? 'ok' : 'bad', 'data-check': c.id, 'data-pass': c.pass === null ? 'none' : String(c.pass) }, [
52
+ c3ic(c.pass === null ? 'info' : c.pass ? 'check' : 'alert', 'sm'),
53
+ el('span', null, (NAMES[c.id] || c.id) + ': ' + c.reason),
54
+ el('span', { class: 'tag' }, c.pass === null ? 'no input' : c.pass ? 'pass' : 'fail'),
55
+ ]))),
56
+ el('div', { class: 'chips2' }, [el('button', { type: 'button', class: 'pill', disabled: true, 'aria-disabled': 'true', 'data-health': 'fix',
57
+ title: 'Needs the agent session path, which is not built yet.' }, [c3ic('spark', 'sm'), 'Fix with the agent'])]),
58
+ el('div', { class: 'dim', style: 'font-size:var(--fs-2)' }, 'Health answers one question: can I trust this document without rereading it? Checks are recomputed from the text every time it changes.'),
59
+ ]);
60
+ }
61
+
62
+ /** The Health mode for one document. */
63
+ export function healthPanel(o) {
64
+ const g = readHealth(o.ws, o.rel);
65
+ if (g.state === 'loading' && !g.data) return wrap({ 'data-rail': 'status' }, [el('div', { class: 'fold a' }, [el('span', { class: 'c3spin' }, [c3ic('spark', 'sm')]), 'Checking this document…'])]);
66
+ if (g.state === 'error') return wrap({ 'data-rail': 'status' }, [el('div', { class: 'fold w' }, [c3ic('alert', 'sm'), 'The checks could not run: ' + g.error + '. Nothing is shown.'])]);
67
+ return healthView(g.data);
68
+ }
69
+
70
+ const diffNode = (diff) => el('div', { class: 'dpn dfi', 'data-rail': 'diff' }, el('div', { class: 'dsec' + (diff.some((d) => d.op !== 'same') ? ' warn' : '') }, el('p', null, diff.map((d, i) => {
71
+ const tag = d.op === 'add' ? 'ins' : d.op === 'del' ? 'del' : 'span';
72
+ return [i ? ' ' : '', el(tag, { 'data-op': d.op }, d.text)];
73
+ }).flat())));
74
+
75
+ // The same plain LCS as scripts/doc-health.js, kept here because the page cannot load a CommonJS file.
76
+ export function wordDiff(before, after) {
77
+ const a = String(before || '').split(/\s+/).filter(Boolean), b = String(after || '').split(/\s+/).filter(Boolean);
78
+ const L = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
79
+ for (let i = a.length - 1; i >= 0; i--) for (let j = b.length - 1; j >= 0; j--) L[i][j] = a[i] === b[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1]);
80
+ const out = [];
81
+ const push = (op, text) => { const l = out[out.length - 1]; if (l && l.op === op) l.text += ' ' + text; else out.push({ op, text }); };
82
+ let i = 0, j = 0;
83
+ while (i < a.length && j < b.length) {
84
+ if (a[i] === b[j]) { push('same', a[i]); i++; j++; } else if (L[i + 1][j] >= L[i][j + 1]) push('del', a[i++]); else push('add', b[j++]);
85
+ }
86
+ while (i < a.length) push('del', a[i++]);
87
+ while (j < b.length) push('add', b[j++]);
88
+ return out;
89
+ }
90
+
91
+ /** History mode: compare the newest revision with the one before it (or the pair the PM picked in `o.pick`). */
92
+ export function historyPanel(o) {
93
+ const revs = Array.isArray(o.revisions) ? o.revisions : [];
94
+ if (revs.length < 2) return wrap({ 'data-rail': 'history' }, [el('div', { class: 'fold' }, [c3ic('info', 'sm'), 'Fewer than two revisions are known for this document, so there is nothing to compare.'])]);
95
+ const [from, to] = o.pick || [revs[revs.length - 2].id, revs[revs.length - 1].id];
96
+ const a = revs.find((r) => r.id === from), b = revs.find((r) => r.id === to);
97
+ if (!a || !b) return wrap({ 'data-rail': 'history' }, [el('div', { class: 'fold w' }, [c3ic('alert', 'sm'), 'That revision is not in the list.'])]);
98
+ return wrap({ 'data-rail': 'history' }, [
99
+ el('ul', { class: 'hx' }, revs.slice().reverse().map((r) => el('li', null, [el('span', { class: 'chip2' }, [c3ic('doc', 'sm'), r.id]), el('span', null, [el('b', null, r.label || r.id)]), el('span', { class: 'w' }, r.id === to ? 'newest compared' : r.id === from ? 'compared with' : '')]))),
100
+ el('h4', { style: 'margin:0;font-size:var(--fs-3)' }, `${a.label || a.id} to ${b.label || b.id}`),
101
+ diffNode(wordDiff(a.text, b.text)),
102
+ ]);
103
+ }
104
+
105
+ /** Ask the agent: the word diff of a supplied before and after. Accept needs the outbox and the slide. */
106
+ export function askPanel({ before, after }) {
107
+ return wrap({ 'data-rail': 'ask' }, [diffNode(wordDiff(before, after)),
108
+ el('div', { class: 'chips2' }, [el('button', { type: 'button', class: 'pill', disabled: true, 'aria-disabled': 'true', 'data-ask': 'accept',
109
+ title: 'Accept by slide needs the outbox and the slide, which are not built yet.' }, [c3ic('check', 'sm'), 'Accept'])])]);
110
+ }
@@ -14,11 +14,12 @@ import { el, setChildren } from './dom.js';
14
14
  import { tabs, row } from './primitives.js';
15
15
  import { setState } from './state.js';
16
16
  import { copiesOf, wordsOf, openCopy } from './doc-remote.js';
17
+ import { healthPanel, historyPanel, healthStamp } from './doc-health-panel.js';
17
18
 
18
19
  const SVG = 'http://www.w3.org/2000/svg';
19
20
  const graphs = new Map(); // ws|path -> { state, data, at }
20
21
  const rails = new Map(); // tab id -> { node, key }
21
- const modes = new Map(); // tab id -> 'outline' | 'graph'
22
+ const modes = new Map(); // tab id -> 'outline' | 'graph' | 'health' | 'history'
22
23
 
23
24
  const VIA = { link: 'Link', frontmatter: 'Frontmatter', initiative: 'Initiative', anchor: 'Anchor', cites: 'Decision',
24
25
  supersedes: 'Supersedes', amended_by: 'Amended by' };
@@ -51,7 +52,7 @@ export function railNode(tab, o) {
51
52
  const ties = g.state === 'ok' && g.data ? g.data.ties || [] : [];
52
53
  const flagged = new Set(o.findings.map((f) => f.rel).filter(Boolean));
53
54
  const mode = modes.get(tab.id) || (o.heads.length > 1 ? 'outline' : 'graph');
54
- const key = JSON.stringify([mode, o.heads, g.state, ties, copies.map((c) => [c.kind, wordsOf(c, o.ws, o.rel).text]), o.findings]);
55
+ const key = JSON.stringify([mode, mode === 'health' ? healthStamp(o.ws, o.rel) : 0, o.revisions, o.heads, g.state, ties, copies.map((c) => [c.kind, wordsOf(c, o.ws, o.rel).text]), o.findings]);
55
56
  let r = rails.get(tab.id);
56
57
  if (!r) { r = { node: el('aside', { class: 'fr-rail', 'data-rail': '' }), key: null }; rails.set(tab.id, r); }
57
58
  if (r.key === key) return r.node;
@@ -59,7 +60,8 @@ export function railNode(tab, o) {
59
60
  const n = ties.length + copies.length;
60
61
  const kids = [tabs({
61
62
  variant: 'segmented', ariaLabel: 'Beside the document', value: mode, full: true,
62
- items: [{ id: 'outline', label: 'Outline' }, { id: 'graph', label: n ? 'Connections ' + n : 'Connections' }],
63
+ items: [{ id: 'outline', label: 'Outline' }, { id: 'graph', label: n ? 'Connections ' + n : 'Connections' },
64
+ { id: 'health', label: 'Health' }, { id: 'history', label: 'History' }],
63
65
  onChange: (m) => { modes.set(tab.id, m); setState({}); },
64
66
  attrs: { class: 'fr-rail__tabs', 'data-rail': 'tabs' },
65
67
  })];
@@ -69,6 +71,10 @@ export function railNode(tab, o) {
69
71
  o.heads.map((h) => row({ title: h.text, compact: true, onOpen: () => o.onGoto(h.i), openLabel: 'Go to ' + h.text,
70
72
  attrs: { class: 'fr-rail__head', 'data-level': String(h.level), 'data-rail': 'head' } })))
71
73
  : el('p', { class: 'fr-rail__note' }, 'No headings yet. A line that starts with # or ## is a heading.'));
74
+ } else if (mode === 'health') {
75
+ kids.push(healthPanel(o));
76
+ } else if (mode === 'history') {
77
+ kids.push(historyPanel(o));
72
78
  } else if (g.state === 'loading' && !g.data) {
73
79
  kids.push(el('p', { class: 'fr-rail__note', 'data-rail': 'status' }, 'Reading the ties of this file…'));
74
80
  } else if (g.state === 'error') {
@@ -0,0 +1,228 @@
1
+ /* pm/lifecycle-lens-model.js — the pure half of the Lifecycle lens (console3 wave B1). No DOM, so node can import it.
2
+ *
3
+ * Stage is DERIVED, never set. The lens reads each initiative's `nine_stages` (scripts/nine-stages.js: one of
4
+ * done | current | pending | no-signal | skipped per stage). The roadmap payload does not carry `nine_stages` yet, so
5
+ * `nineOf` falls back to a small MIRROR of scripts/nine-stages.js (STAGES, LANE_STAGES, SIGNALLED and the status rule),
6
+ * fed by the rung the row's `stage.id` names. The mirror is approximate: the row's stage is the plan-tree group, not the
7
+ * engine's raw rung, so an accepted initiative cannot be told apart from one in Evaluate. When the server sends
8
+ * `nine_stages`, that wins and the mirror is not used.
9
+ */
10
+
11
+ export const STAGES = ['discover', 'ideate', 'strategize', 'prioritize', 'plan', 'build', 'deliver', 'monitor', 'evaluate'];
12
+ export const LABELS = { discover: 'Discover', ideate: 'Ideate', strategize: 'Strategize', prioritize: 'Prioritize', plan: 'Plan', build: 'Build', deliver: 'Deliver', monitor: 'Monitor', evaluate: 'Evaluate' };
13
+ // Mirror of scripts/nine-stages.js LANE_STAGES and SIGNALLED.
14
+ const LANE_STAGES = {
15
+ feature: STAGES,
16
+ discovery: ['discover', 'ideate', 'strategize', 'prioritize', 'evaluate'],
17
+ chore: ['plan', 'build', 'evaluate'],
18
+ incident: ['build', 'deliver', 'monitor', 'evaluate'],
19
+ content: ['ideate', 'plan', 'build', 'deliver'],
20
+ prototype: ['discover', 'ideate', 'evaluate'],
21
+ };
22
+ const SIGNALLED = new Set(['discover', 'plan', 'build', 'evaluate']);
23
+ // The plan-tree group id of a row's stage, as an engine rung rank (nine-stages.js RANK). Unknown groups read intake.
24
+ const GROUP_RANK = { intake: 0, spec: 1, brief: 1, draft: 1, contract: 2, build: 3, evaluate: 4, handoff: 4 };
25
+
26
+ /** The nine statuses of one row: its own `nine_stages` when the server sent them, else the mirror. */
27
+ export function nineOf(row) {
28
+ if (row && Array.isArray(row.nine_stages) && row.nine_stages.length) return row.nine_stages;
29
+ const lane = row && row.lane;
30
+ const laneSet = new Set(LANE_STAGES[lane] || STAGES);
31
+ const gid = row && row.stage && row.stage.id;
32
+ const rank = GROUP_RANK[gid] ?? 0;
33
+ const done = { discover: rank >= 1, plan: rank >= 2, build: rank >= 3, evaluate: rank >= 4 };
34
+ let cur = false;
35
+ return STAGES.map((key) => {
36
+ let status;
37
+ if (!laneSet.has(key)) status = 'skipped';
38
+ else if (!SIGNALLED.has(key)) status = 'no-signal';
39
+ else if (done[key]) status = 'done';
40
+ else status = 'pending';
41
+ if (status === 'pending' && !cur) { status = 'current'; cur = true; }
42
+ return { key, label: LABELS[key], status };
43
+ });
44
+ }
45
+
46
+ /** The stage an initiative sits in: the first `current`; else the last `done`; else null (nothing shows a stage yet). */
47
+ export function stageBucket(nine) {
48
+ const list = Array.isArray(nine) ? nine : [];
49
+ const cur = list.find((s) => s.status === 'current');
50
+ if (cur) return cur.key;
51
+ let last = null;
52
+ for (const s of list) if (s.status === 'done') last = s.key;
53
+ return last;
54
+ }
55
+
56
+ /** Group rows by stage: { buckets: { discover: [row…], … }, unplaced: [row…] }. `scope` is an initiative id or null for all. */
57
+ export function bucketRows(rows, scope) {
58
+ const buckets = Object.fromEntries(STAGES.map((k) => [k, []]));
59
+ const unplaced = [];
60
+ for (const r of rows || []) {
61
+ if (scope && r.id !== scope) continue;
62
+ const k = stageBucket(nineOf(r));
63
+ if (k && buckets[k]) buckets[k].push(r); else unplaced.push(r);
64
+ }
65
+ return { buckets, unplaced };
66
+ }
67
+
68
+ /** How one stage reads across the scoped rows: 'no-signal' when every row that uses it has no signal, 'skipped' when every row skips it. */
69
+ export function stageNote(key, rows, scope) {
70
+ const sts = (rows || []).filter((r) => !scope || r.id === scope).map((r) => (nineOf(r).find((s) => s.key === key) || {}).status);
71
+ if (!sts.length) return null;
72
+ if (sts.every((s) => s === 'skipped')) return 'skipped';
73
+ if (sts.every((s) => s === 'skipped' || s === 'no-signal')) return 'no-signal';
74
+ return null;
75
+ }
76
+
77
+ /* ── what each stage needs (console3.html C3.REQ, line 4687): the requirement rows of the lens, each with who does it ── */
78
+ export const REQ = {
79
+ discover: [['brief', 'Discovery brief with a stop rule', 'agent'], ['evidence', 'Evidence from 3 or more sources', 'agent'], ['cont', 'Continue or park', 'pm']],
80
+ ideate: [['opts', 'Two or more options, one recommended', 'agent']],
81
+ strategize: [['bet', 'Bet statement tied to the anchor', 'agent']],
82
+ prioritize: [['commit', 'Commit: it fits Now capacity', 'pm']],
83
+ plan: [['prd', 'PRD with done criteria', 'agent'], ['rub', 'Rubric and pre-run contract', 'agent'], ['plan', 'Breakdown reviewed', 'pm']],
84
+ build: [['tix', 'Build tickets merged, required CI green', 'factory'], ['rev', 'Review against the rubric', 'agent']],
85
+ deliver: [['rel', 'Release shipped', 'pm']],
86
+ monitor: [['out', 'Outcome check after 2 weeks', 'agent']],
87
+ evaluate: [['verd', 'Verdict: what we learned, next bet seeded', 'pm']],
88
+ };
89
+ /** Who does a requirement: [icon, word]. */
90
+ export const reqWho = (w) => (w === 'pm' ? ['user', 'you'] : w === 'factory' ? ['branch', 'factory'] : ['spark', 'agent']);
91
+ /** The agent's or factory's requirements of the same stage that are not done yet: a PM decision waits for them (c3prevPending). `isDone(key)` says what is done. */
92
+ export const prevPending = (stage, key, isDone) => (REQ[stage] || []).filter((r) => r[2] !== 'pm' && r[0] !== key && !isDone(r[0]));
93
+ /** What the agent is asked to draft, per requirement: [the offer, what it is]. */
94
+ export const DRAFT_TEXT = {
95
+ brief: ['Draft the discovery brief', 'A one-page brief with a stop rule'], evidence: ['Gather the evidence', 'Three or more sources, each cited'], opts: ['Draft the options', 'Two or more, one recommended, with the cost of each'],
96
+ bet: ['Draft the bet statement', 'Tied to the anchor'], prd: ['Draft the PRD', 'Done criteria first'], rub: ['Draft the rubric and pre-run contract', 'The rubric is the spec; the contract is agreed before any build'],
97
+ rev: ['Review the build against the rubric', 'Outcome only, weighted, against the threshold'], out: ['Run the outcome check', 'Two weeks after release, from the data'],
98
+ };
99
+ /** The PM's decision per requirement: [yes label, yes effect], [no label, no effect], the sentence. */
100
+ export const DECIDE_TEXT = {
101
+ cont: [['Continue to the next stage', 'The stop rule stays in force'], ['Park it, reason recorded', 'Frees the discovery slot'], 'The stop rule is the test. Read the evidence and decide: a close call is a reason to ask for more, not to guess.'],
102
+ commit: [['Commit it to Now', 'It fits Now capacity'], ['Keep it in Prioritize', 'Waits for capacity'], 'Now has a limit. The least sure bet goes back if it fills.'],
103
+ plan: [['Approve the plan', 'Breakdown, owners and dates are agreed'], ['Send the breakdown back', 'The agent revises it'], 'Check that every work item has an owner before you approve.'],
104
+ rel: [['Release it', 'The release is recorded as shipped by your slide'], ['Hold it', 'The date moves; promises are re-told'], 'Release needs its readiness checks and an outside change in Jira, which needs its own slide.'],
105
+ verd: [['Record the verdict: iterate', 'The next bet is seeded from the lesson'], ['Record the verdict: stop', 'Frees the capacity'], 'The verdict is a human judgement; the agent only drafts the evidence.'],
106
+ };
107
+ /** The stages a lane uses, in order (the lane's own list). */
108
+ export const laneStages = (lane) => LANE_STAGES[lane] || STAGES;
109
+
110
+ /* ── the derived panels of the lens (console3 wave L1). Ports of scripts/trust/reader.js (the PM's sample), sections 11 to 16:
111
+ * 11 PRIORITIZE score = impact x confidence / effort, capacity in bets
112
+ * 12 DISCOVER hypothesis status from weighted evidence; evidence whose source is not a known file is untraced
113
+ * 14 GATE LADDER the six gates in order, the next gate, order violations
114
+ * 15 RUBRIC the weighted score against the threshold, the weakest dimension, the watermelon guard
115
+ * 16 NORTH STAR corrections per agent run, lower is better
116
+ * Pure: no DOM, no fetch. Real records feed them; nothing here invents a number. ── */
117
+ const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
118
+
119
+ /** The score of an idea (reader.js section 11: impact x confidence / effort, two decimals). null when a figure is missing. */
120
+ export function ideaScore(x) {
121
+ if (!x || !isNum(x.imp) || !isNum(x.conf) || !isNum(x.eff) || x.eff <= 0) return null;
122
+ return Math.round(x.imp * x.conf / x.eff * 100) / 100;
123
+ }
124
+ /** An ideas record (scripts/records: title, impact, effort, confidence; optional initiative, source, status, why) as the lens draws it. */
125
+ export function normIdea(r) {
126
+ const o = r || {};
127
+ const st = ['new', 'short', 'committed', 'parked'].includes(o.status) ? o.status : o.status === 'shortlisted' ? 'short' : 'new';
128
+ return { id: String(o.rid || o.id || ''), n: String(o.title || ''), init: String(o.initiative || ''), imp: Number(o.impact), eff: Number(o.effort), conf: Number(o.confidence),
129
+ src: ['agent', 'customer', 'you'].includes(o.source) ? o.source : 'you', st, why: String(o.why || '') };
130
+ }
131
+ /** The ideas that are ranked: not parked, best score first (reader.js 11). */
132
+ export const rankIdeas = (ideas) => (ideas || []).filter((x) => x.st !== 'parked' && ideaScore(x) != null).sort((a, b) => ideaScore(b) - ideaScore(a));
133
+ /** Capacity: the effort the PM holds (shortlisted and committed) against the capacity figure. cap = null when none is recorded. */
134
+ export function capacityUsed(ideas, cap) {
135
+ const used = (ideas || []).filter((x) => x.st === 'short' || x.st === 'committed').reduce((a, x) => a + (isNum(x.eff) ? x.eff : 0), 0);
136
+ return { used, cap: isNum(cap) ? cap : null, over: isNum(cap) ? Math.max(0, used - cap) : null };
137
+ }
138
+
139
+ /** A hypotheses record as the lens draws it. `evidence` is [{ src, dir: 'supports'|'refutes', w }]. */
140
+ export function normHyp(r) {
141
+ const o = r || {};
142
+ const ev = (Array.isArray(o.evidence) ? o.evidence : []).filter((e) => e && typeof e === 'object').map((e) => ({ src: String(e.src || ''), dir: e.dir === 'refutes' ? 'refutes' : 'supports', w: isNum(Number(e.w)) ? Number(e.w) : 1, at: e.at ? String(e.at) : '' }));
143
+ return { id: String(o.rid || o.id || ''), t: String(o.title || ''), init: String(o.initiative || ''), falsifier: String(o.falsifier || ''), review_by: String(o.review_by || ''), ev };
144
+ }
145
+ /** The status of a hypothesis, as reader.js section 12 derives it: net = sum of the weights (supports plus, refutes minus); net >= 3 is supported,
146
+ * net <= -2 is refuted, anything between is open. `known` is a Set (or object) of the sources that exist; a source outside it is untraced.
147
+ * The reader has three statuses. The page splits "open" for display only, so the PM can tell an empty hypothesis from a contested one:
148
+ * `shown` is untested (no evidence), mixed (evidence both ways), or open (one-sided but below the bar). `status` stays the reader's. */
149
+ export function hypStatus(h, known) {
150
+ const ev = (h && h.ev) || [];
151
+ const net = ev.reduce((a, e) => a + (e.dir === 'supports' ? e.w : -e.w), 0);
152
+ const status = net >= 3 ? 'supported' : net <= -2 ? 'refuted' : 'open';
153
+ const has = known instanceof Set ? (s) => known.has(s) : known ? (s) => !!known[s] : () => false;
154
+ const untraced = ev.filter((e) => !has(e.src)).map((e) => e.src || '(no source)');
155
+ const sup = ev.filter((e) => e.dir === 'supports').length; const ref = ev.length - sup;
156
+ const shown = status !== 'open' ? status : !ev.length ? 'untested' : sup && ref ? 'mixed' : 'open';
157
+ return { status, shown, net, untraced, supports: sup, refutes: ref };
158
+ }
159
+ export const HYP_LABEL = { supported: 'Supported', refuted: 'Refuted', mixed: 'Mixed', untested: 'Untested', open: 'Open' };
160
+
161
+ /** The six gates the mockup draws, and the engine step each reads (scripts/workflow-state.js steps; the rule text of each step is its `basis.rule`):
162
+ * Spec = spec, Rubric = contract (done criteria and test method agreed, D13), Build = build, Quality = quality, Review = review, Acceptance = accept. */
163
+ export const GATES = [['Spec', 'spec'], ['Rubric', 'contract'], ['Build', 'build'], ['Quality', 'quality'], ['Review', 'review'], ['Acceptance', 'accept']];
164
+ const dayOf = (v) => { const m = /^(\d{4}-\d{2}-\d{2})/.exec(String(v || '')); return m ? m[1] : ''; };
165
+ /** The gate ladder from one initiative's ladder row (the steps of `GET /api/console?name=ladder`). Each gate: { name, key, state, evidence, rule, date }.
166
+ * state is done | next (the first gate not done, the one in progress) | open (not yet) | na (cut by the PM or not required) | unknown (the engine does not claim it, e.g. CI decides).
167
+ * A violation is a gate done before an earlier gate that is not done (reader.js 14 `viol`): "Build before Rubric"; with first-seen dates, also a gate done on an earlier day than the gate before it. */
168
+ export function gateLadder(row) {
169
+ const steps = (row && Array.isArray(row.steps)) ? row.steps : [];
170
+ const by = Object.fromEntries(steps.map((s) => [s.key, s]));
171
+ let nextAt = -1;
172
+ const gates = GATES.map(([name, key], i) => {
173
+ const s = by[key] || null;
174
+ let state = !s ? 'unknown' : s.status === 'done' ? 'done' : s.status === 'na' ? 'na' : s.status === 'unknown' ? 'unknown' : 'open';
175
+ if (state === 'open' && nextAt < 0) { nextAt = i; state = 'next'; }
176
+ return { name, key, state, evidence: s ? String(s.evidence || '') : 'the engine returned no step for this gate', rule: s && s.basis ? String(s.basis.rule || '') : '', date: s && s.basis ? dayOf(s.basis.first_seen) : '', blocking: !!(s && s.blocking) };
177
+ });
178
+ const viol = [];
179
+ gates.forEach((g, i) => {
180
+ if (g.state !== 'done') return;
181
+ gates.slice(0, i).forEach((p) => {
182
+ if (p.state === 'na' || p.state === 'unknown') return;
183
+ if (p.state !== 'done') viol.push(g.name + ' before ' + p.name);
184
+ else if (g.date && p.date && g.date < p.date) viol.push(g.name + ' before ' + p.name + ' (' + g.date + ' against ' + p.date + ')');
185
+ });
186
+ });
187
+ const next = gates.find((g) => g.state === 'next') || null;
188
+ return { gates, next, violations: viol, done: gates.filter((g) => g.state === 'done').length, counted: gates.filter((g) => g.state !== 'na').length, nextAction: row && row.next ? row.next : null, blockers: (row && row.blockers) || [], failures: (row && row.gate_failures) || [] };
189
+ }
190
+ /** The cells of the mockup's `.gt` bar: 'd' done, 'h' next (half), '' open, 'na', 'u' (unknown). */
191
+ export const gateCell = (g) => (g.state === 'done' ? 'd' : g.state === 'next' ? 'h' : g.state === 'na' ? 'na' : g.state === 'unknown' ? 'u' : '');
192
+
193
+ /** An evaluation row (planning/runs/evals.jsonl: score, threshold or pass_threshold, dimensions as { name: score }, verdict, evaluator_model) as the scorecard.
194
+ * The recorded score is shown as recorded. A weight is used only when a row carries one (`dims` as [{ dim, w, score }], the sample's form); otherwise the
195
+ * dimensions are unweighted and the page says so. Dimensions that are not numbers are dropped. */
196
+ export function normEval(e) {
197
+ if (!e || typeof e !== 'object') return null;
198
+ let dims = [];
199
+ if (Array.isArray(e.dims)) dims = e.dims.filter((d) => d && isNum(d.score)).map((d) => ({ name: String(d.dim || d.name || ''), w: isNum(d.w) ? d.w : null, score: d.score }));
200
+ else if (e.dimensions && typeof e.dimensions === 'object') dims = Object.entries(e.dimensions).filter(([, v]) => isNum(v)).map(([k, v]) => ({ name: k, w: null, score: v }));
201
+ const weighted = dims.length && dims.every((d) => d.w != null);
202
+ const computed = weighted ? dims.reduce((a, d) => a + d.w * d.score, 0) : dims.length ? dims.reduce((a, d) => a + d.score, 0) / dims.length : null;
203
+ const score = isNum(e.score) ? e.score : computed;
204
+ const thr = isNum(e.threshold) ? e.threshold : isNum(e.pass_threshold) ? e.pass_threshold : null;
205
+ return { score, threshold: thr, dims, weighted: !!weighted, verdict: String(e.verdict || ''), model: String(e.evaluator_model || ''), at: String(e.recorded_at || e.at || ''), run: String(e.run_id || e.run || '') };
206
+ }
207
+ /** The verdict of a scorecard: passes (score at or over the threshold), the weakest dimension, and the watermelon guard (reader.js 15: a pass while a dimension scores under 0.6). */
208
+ export function evalVerdict(ev) {
209
+ if (!ev || ev.score == null) return null;
210
+ const weakest = ev.dims.slice().sort((a, b) => a.score - b.score)[0] || null;
211
+ const passes = ev.threshold != null ? ev.score >= ev.threshold : null;
212
+ const watermelon = !!(passes && weakest && weakest.score < 0.6);
213
+ return { passes, weakest, watermelon, below: ev.threshold != null && !passes };
214
+ }
215
+ /** The newest evaluation per initiative from a list of rows (recorded_at, then file order). */
216
+ export function latestEvals(rows) {
217
+ const out = {};
218
+ (rows || []).forEach((r, i) => { const id = r && r.initiative_id; if (!id) return; const at = String(r.recorded_at || r.at || ''); const p = out[id]; if (!p || at >= p.at) out[id] = { at, i, row: r }; });
219
+ return Object.fromEntries(Object.entries(out).map(([k, v]) => [k, v.row]));
220
+ }
221
+ /** Corrections per run (reader.js 16, lower is better), two decimals, for each bucket and the change from the first to the last. */
222
+ export function correctionRate(series) {
223
+ const s = (series || []).filter((x) => x && x.runs > 0);
224
+ if (!s.length) return null;
225
+ const rate = (x) => Math.round(x.corrections / x.runs * 100) / 100;
226
+ const vals = s.map(rate);
227
+ return { now: vals[vals.length - 1], from: vals[0], vals, improved: vals[vals.length - 1] < vals[0] };
228
+ }