create-pmos 0.47.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/package.json +1 -1
  2. package/payload/AGENTS-HUMAN.md +1 -1
  3. package/payload/AGENTS.md +1 -1
  4. package/payload/KIT-VERSION +2 -2
  5. package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +1 -1
  6. package/payload/mods/pmos-cockpit/README.md +14 -3
  7. package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +34 -6
  8. package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +22 -3
  9. package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +16 -3
  10. package/payload/mods/pmos-cockpit/hooks/lib/route.mjs +95 -0
  11. package/payload/mods/pmos-cockpit/hooks/register.tsx +104 -19
  12. package/payload/mods/pmos-cockpit/types/index.d.ts +1 -1
  13. package/payload/planning/evals/eval-calibration.md +1 -1
  14. package/payload/planning/okrs/health-budgets.md +1 -1
  15. package/payload/scripts/pmos-log.sh +18 -8
  16. package/payload/scripts/pmos-model.js +233 -2
  17. package/payload/scripts/status-facts.js +214 -0
  18. package/payload/scripts/workflow-state.js +59 -2
  19. package/payload/state/discoveries.md +1 -1
  20. package/payload/state/log.md +1 -1
  21. package/workspace/README.md +2 -2
  22. package/workspace/bin/live-notes.js +133 -0
  23. package/workspace/design-parity.js +328 -21
  24. package/workspace/doc-category-bench.js +165 -0
  25. package/workspace/fixtures/doc-category-labelled.json +167 -0
  26. package/workspace/guide/index.md +2 -1
  27. package/workspace/guide/initiatives-and-next-steps.md +17 -2
  28. package/workspace/guide/knowledge-graph.md +94 -1
  29. package/workspace/guide/layout.md +31 -2
  30. package/workspace/guide/live-notes.md +78 -0
  31. package/workspace/guide/proactive-engine.md +6 -2
  32. package/workspace/guide/validate-with-typesafe.md +31 -2
  33. package/workspace/lib/claims.js +51 -2
  34. package/workspace/lib/doc-category-corrections.js +113 -0
  35. package/workspace/lib/doc-category-writeback.js +110 -0
  36. package/workspace/lib/doc-category.js +225 -0
  37. package/workspace/lib/engine/pmos-model.js +233 -2
  38. package/workspace/lib/engine/status-facts.js +214 -0
  39. package/workspace/lib/engine/workflow-state.js +59 -2
  40. package/workspace/lib/jev-categorize.js +433 -0
  41. package/workspace/lib/jev-claims.js +59 -15
  42. package/workspace/lib/jev-live.js +37 -0
  43. package/workspace/lib/jev-route.js +139 -0
  44. package/workspace/lib/kg-cli.js +3 -1
  45. package/workspace/lib/kg.js +6 -1
  46. package/workspace/lib/learn.js +5 -3
  47. package/workspace/lib/live-notes.js +1035 -0
  48. package/workspace/lib/typesafe-settings.js +10 -2
  49. package/workspace/package-lock.json +1 -1
  50. package/workspace/package.json +5 -4
  51. package/workspace/public/pm/app.js +148 -79
  52. package/workspace/public/pm/data.js +78 -0
  53. package/workspace/public/pm/fixture.js +40 -2
  54. package/workspace/public/pm/modes-live.js +279 -18
  55. package/workspace/public/pm/panel-cockpit.js +638 -0
  56. package/workspace/public/pm/panel-detail.js +212 -8
  57. package/workspace/public/pm/panel-initiatives.js +9 -9
  58. package/workspace/public/pm/panel-newsess.js +145 -86
  59. package/workspace/public/pm/panel-repos.js +34 -17
  60. package/workspace/public/pm/panel-tabs.js +249 -29
  61. package/workspace/public/pm/state.js +48 -4
  62. package/workspace/public/pm/tab-doc.js +244 -16
  63. package/workspace/public/pm/tab-term.js +11 -1
  64. package/workspace/public/pm/tokens.css +4 -3
  65. package/workspace/public/pm/view-addrepo.js +207 -0
  66. package/workspace/public/pm/view-files.js +197 -0
  67. package/workspace/public/pm/view-guide.js +141 -0
  68. package/workspace/public/pm/view-knowledge.js +225 -0
  69. package/workspace/public/pm/view-livenotes.js +397 -0
  70. package/workspace/public/pm/view-memory.js +122 -0
  71. package/workspace/public/pm/view-portfolio.js +153 -0
  72. package/workspace/public/pm/view-reports.js +190 -0
  73. package/workspace/public/pm/view-scheduled.js +161 -0
  74. package/workspace/public/pm/view-skills.js +216 -0
  75. package/workspace/public/pm/view-today.js +168 -0
  76. package/workspace/public/pm/view-validate.js +171 -0
  77. package/workspace/server.js +349 -7
  78. package/workspace/skills/live-notes.skill +58 -0
@@ -27,7 +27,8 @@
27
27
  */
28
28
 
29
29
  import { el, setChildren } from './dom.js';
30
- import { state, setState, FIXTURE } from './state.js';
30
+ import { state, setState, FIXTURE, LEGACY_LAYOUT_FLAG } from './state.js';
31
+ import { isMobile } from './mobile.js';
31
32
  import * as FX from './fixture.js';
32
33
  import { openTab, openTerm as openTermTab, openDoc as openDocTab } from './panel-initiatives.js';
33
34
  import { mountDoc, disposeDoc, docText } from './tab-doc.js';
@@ -41,7 +42,9 @@ import { boardOf, currentProduct } from './board-read.js';
41
42
  * far smaller failure than a dead panel 4. When it lands, the panel repaints itself. */
42
43
  let termMod = null;
43
44
  let liveMod = null;
45
+ let cockpitMod = null;
44
46
  /* Loaded lazily; a repaint is asked for only when a skill tab is already open (restored on reload) and waiting for it. */
47
+ import('./panel-cockpit.js').then((m) => { cockpitMod = m; if ((state.tabs || []).some((t) => t.kind === 'view')) setState({}); }).catch(() => { cockpitMod = null; });
45
48
  import('./modes-live.js').then((m) => { liveMod = m; if ((state.tabs || []).some((t) => t.kind === 'skill' || t.kind === 'transcript')) setState({}); }).catch(() => { liveMod = null; });
46
49
  import('./tab-term.js')
47
50
  .then((m) => { termMod = m; setState({}); })
@@ -332,7 +335,41 @@ const CHIP_STYLE = 'flex:none;align-self:center;font-family:var(--mono);font-siz
332
335
  const SPLIT_STYLE = 'flex:none;margin-left:auto;align-self:center;font-family:var(--mono);font-size:11px;color:oklch(0.85 0.01 214);padding:5px 12px;min-height:30px;background:oklch(1 0 0 / 6%);border:1px solid oklch(1 0 0 / 12%);white-space:nowrap';
333
336
  const CHIP_HOVER = 'border-color:oklch(0.56 0.021 213.5)';
334
337
 
335
- const tabBar = el('div', { style: BAR_STYLE });
338
+ const tabBar = el('div', { 'data-tabs': 'bar', style: BAR_STYLE, onWheel: stripWheel });
339
+
340
+ /* ── the cockpit's tab strip (workspace-cockpit-nav Run 4, planning/design/cockpit-mockup/WsA.dc.html) ─────────────────────────
341
+ * On a desktop the strip is the mockup's: a 44px band whose tabs sit on its floor, the tab in front a card with rounded top corners,
342
+ * the others plain, each a dot and a title in 12px mono, and `+ session` at the right end. A phone and `?layout=legacy` keep the
343
+ * prototype's strip above. What the mockup does not draw is kept (R12): a tab's second line (its initiative and session) sits under
344
+ * its title in the tab's own lower padding, so the title stays where the mockup puts it; a tab that is not in front keeps its close
345
+ * control, in its right padding; the web, options and split controls sit to the left of `+ session`. */
346
+ const cockpitLook = () => !LEGACY_LAYOUT_FLAG && !isMobile();
347
+ const CK_BAR = 'flex:none;display:flex;align-items:flex-end;gap:4px;padding:0 10px;height:45px;border-bottom:1px solid var(--border);min-width:0;overflow-x:auto';
348
+ /* ── the tabs as a vertical list (the PM's request after testing 0.48.0: the strip gets crowded with many tabs open) ─────────────
349
+ * `state.tabsVertical` puts the open tabs in a column on the left of the panes, one row per tab with its title, its second line and
350
+ * its close control, and the session controls under them. The choice is stored with the layout, so it survives a reload. It is a
351
+ * cockpit (desktop) choice only: a phone and `?layout=legacy` keep the strip.
352
+ *
353
+ * NOTHING MOVES WHEN IT IS SWITCHED. The panes, the chooser, the scratchpad and the bottom bar live in one persistent column
354
+ * (`tabsMain`) that is always the second child of the host; the switch only turns the host's flex direction and restyles the bar.
355
+ * A live terminal is never re-parented by it. */
356
+ const CK_VBAR = 'flex:none;width:224px;display:flex;flex-direction:column;align-items:stretch;gap:2px;padding:8px 6px;border-right:1px solid var(--border);min-height:0;overflow-y:auto;overflow-x:hidden';
357
+ const tabsMain = el('div', { 'data-tabs': 'main', style: 'flex:1;min-height:0;min-width:0;display:flex;flex-direction:column' });
358
+ const orientLabel = iv('');
359
+ const orientBtn = el('button', {
360
+ onClick: () => setState({ tabsVertical: !state.tabsVertical }), 'data-tabs': 'orient',
361
+ }, orientLabel);
362
+ /* In the strip, a mouse wheel scrolls the tabs sideways: with many tabs open the strip is wider than the panel, and a wheel that
363
+ * did nothing there left only the thin scrollbar. */
364
+ function stripWheel(e) {
365
+ if (state.tabsVertical || !e.deltaY || Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
366
+ const bar = e.currentTarget;
367
+ if (bar.scrollWidth <= bar.clientWidth) return;
368
+ bar.scrollLeft += e.deltaY;
369
+ e.preventDefault();
370
+ }
371
+
372
+ const CK_BTN = 'flex:none;margin:0 0 4px;height:30px;display:grid;place-items:center;font:500 12.5px var(--sans);line-height:normal;background:transparent;color:var(--foreground);border:1px solid var(--input);border-radius:8px;padding:0 12px;white-space:nowrap';
336
373
 
337
374
  const newSessionBtn = el('button', {
338
375
  onClick: () => newSession(), style: CHIP_STYLE, hover: CHIP_HOVER,
@@ -368,7 +405,7 @@ const emptyState = el('div', {
368
405
  style: 'flex:1;display:grid;place-items:center;color:oklch(0.55 0.02 213);font-size:12.5px;padding:20px;text-align:center',
369
406
  }, "nothing open — click a task's output or an artifact, or + session above");
370
407
 
371
- const panesRow = el('div', { style: 'flex:1;display:flex;min-height:0;min-width:0;overflow:hidden' });
408
+ const panesRow = el('div', { 'data-tabs': 'panes', style: 'flex:1;display:flex;min-height:0;min-width:0;overflow:hidden' });
372
409
 
373
410
  /* ── the scratchpad (lines 983–991) ──────────────────────────────────────────────────────────
374
411
  * One textarea, created once. Its text is written back as a CHILD TEXT NODE as well as a value,
@@ -395,9 +432,16 @@ const SCRATCH_PLACEHOLDER = FIXTURE
395
432
  ? 'scratchpad — survives tab switches, dies with the page'
396
433
  : 'scratchpad — one pad per session, saved on this machine';
397
434
 
435
+ /* workspace-cockpit-nav Run 3 (PRD R10) — the drawer takes the form of the cockpit board
436
+ * `planning/design/cockpit-mockup/ViewFilesPad.dc.html`: a labelled header with a fold control, the pad
437
+ * as a bordered field, and one row of buttons with a short note. The textarea stays a DIRECT child of
438
+ * `scratchBlock` (the height ceiling and pm-tabs-test measure through `parentElement`), and every
439
+ * behaviour below — the per-session pad, the autosave, the height handle — is unchanged. */
398
440
  const scratchTa = el('textarea', {
399
441
  placeholder: SCRATCH_PLACEHOLDER,
400
- style: 'height:96px;background:oklch(0.11 0.004 228.8);color:#c4ccce;padding:10px 14px;font-family:var(--mono);font-size:12px',
442
+ 'data-scratch': 'text',
443
+ style: 'height:96px;flex:none;width:100%;border:1px solid var(--input);border-radius:8px;background:var(--background);'
444
+ + 'color:var(--foreground);padding:10px 12px;font:12.5px/1.55 var(--mono)',
401
445
  onInput: (e) => onScratchInput(e.target.value),
402
446
  });
403
447
 
@@ -457,9 +501,10 @@ const scratchGrip = el('div', {
457
501
  'aria-orientation': 'horizontal',
458
502
  'aria-label': 'resize the scratchpad — drag, or ↑ / ↓',
459
503
  title: 'drag to resize · ↑ / ↓ to nudge · double-click to reset',
460
- style: 'flex:none;height:7px;cursor:ns-resize;background:oklch(0.11 0.004 228.8);'
461
- + 'border-top:1px solid oklch(1 0 0 / 10%);touch-action:none',
462
- hover: 'background:oklch(1 0 0 / 10%)',
504
+ 'data-scratch': 'grip',
505
+ // full width across the drawer's padding, on its top edge
506
+ style: 'flex:none;height:7px;margin:-12px -18px 0;cursor:ns-resize;background:var(--sidebar);touch-action:none',
507
+ hover: 'background:var(--border)',
463
508
  onPointerdown: (e) => {
464
509
  dragFrom = { y: e.clientY, h: state.scratchH || SCRATCH_DEFAULT };
465
510
  try { e.target.setPointerCapture(e.pointerId); } catch { /* not captured — the move still works */ }
@@ -564,6 +609,13 @@ if (typeof window !== 'undefined' && !FIXTURE) {
564
609
  document.addEventListener('visibilitychange', () => { if (document.hidden) flushPad(); });
565
610
  }
566
611
 
612
+ /* The board's two button forms, on the token system: `.btnp` (the one primary action) and `.btn`. */
613
+ const PAD_BTN = 'flex:none;font:500 12.5px var(--sans);border-radius:8px;padding:0 12px;height:28px;'
614
+ + 'display:inline-grid;place-items:center;white-space:nowrap;';
615
+ const PAD_BTN_PRIMARY = PAD_BTN + 'background:var(--foreground);color:var(--background);border:0';
616
+ const PAD_BTN_OUTLINE = PAD_BTN + 'background:transparent;color:var(--foreground);border:1px solid var(--input)';
617
+ const PAD_BTN_OFF = PAD_BTN + 'background:transparent;color:var(--muted-foreground);border:1px solid var(--input)';
618
+
567
619
  const scratchCopyLabel = iv('⧉ copy');
568
620
  const scratchCopyBtn = el('button', {
569
621
  onClick: () => {
@@ -571,24 +623,50 @@ const scratchCopyBtn = el('button', {
571
623
  setState({ scratchCopied: true });
572
624
  setTimeout(() => setState({ scratchCopied: false }), 1200);
573
625
  },
574
- style: 'font-family:var(--mono);font-size:10.5px;color:oklch(0.85 0.01 214);padding:4px 10px;min-height:26px;background:oklch(1 0 0 / 6%);border:1px solid oklch(1 0 0 / 12%)',
575
- hover: CHIP_HOVER,
626
+ 'data-scratch': 'copy',
627
+ style: PAD_BTN_OUTLINE,
628
+ hover: 'background:var(--accent)',
576
629
  }, scratchCopyLabel);
577
630
 
578
631
  const scratchSendLabel = iv('open a terminal to send');
579
632
  const scratchSendBtn = el('button', {
580
633
  onClick: () => sendScratchToTerminal(),
581
- style: 'font-family:var(--mono);font-size:10.5px;color:' + FX.SCRATCH_SEND_COLOR.off + ';padding:4px 10px;min-height:26px;background:oklch(1 0 0 / 6%);border:1px solid oklch(1 0 0 / 12%)',
582
- hover: CHIP_HOVER,
634
+ 'data-scratch': 'send',
635
+ style: PAD_BTN_OFF,
583
636
  }, scratchSendLabel);
584
637
 
638
+ /* Clear — the board's second button. It empties the pad through the same path as typing, so the
639
+ * empty pad is saved for this session like any other edit. Disabled when there is nothing to clear. */
640
+ const scratchClearBtn = el('button', {
641
+ onClick: () => { if (state.scratchText) onScratchInput(''); },
642
+ 'data-scratch': 'clear',
643
+ style: PAD_BTN_OUTLINE,
644
+ hover: 'background:var(--accent)',
645
+ }, 'clear');
646
+
647
+ const scratchHeadLabel = el('span', {
648
+ 'data-scratch': 'label',
649
+ style: 'font:500 10px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground)',
650
+ }, 'scratchpad');
651
+ const scratchFoldBtn = el('button', {
652
+ onClick: () => setState({ scratchOpen: false }),
653
+ 'data-scratch': 'fold', 'aria-label': 'close the scratchpad', title: 'Close the scratchpad. The text is kept.',
654
+ style: 'flex:none;color:var(--muted-foreground);padding:0 4px', hover: 'color:var(--foreground)',
655
+ }, '▾');
656
+ const scratchNote = el('span', {
657
+ 'data-scratch': 'note',
658
+ style: 'min-width:0;color:var(--muted-foreground);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap',
659
+ });
660
+
585
661
  const scratchBlock = el('div', {
586
- style: 'display:flex;flex-direction:column;border-top:1px solid oklch(1 0 0 / 10%)',
662
+ 'data-scratch': 'pad',
663
+ style: 'display:flex;flex-direction:column;gap:8px;padding:12px 18px;border-top:1px solid var(--border);background:var(--sidebar)',
587
664
  },
588
665
  FIXTURE ? null : scratchGrip,
666
+ el('div', { style: 'display:flex;align-items:center;justify-content:space-between' }, scratchHeadLabel, scratchFoldBtn),
589
667
  scratchTa,
590
- el('div', { style: 'display:flex;gap:6px;padding:5px 12px 7px;background:oklch(0.11 0.004 228.8)' },
591
- scratchCopyBtn, scratchSendBtn),
668
+ el('div', { style: 'display:flex;align-items:center;gap:8px;min-width:0' },
669
+ scratchSendBtn, scratchCopyBtn, scratchClearBtn, scratchNote),
592
670
  );
593
671
 
594
672
  /* ── the bottom bar (lines 992–996) ─────────────────────────────────────────────────────────── */
@@ -598,6 +676,7 @@ const colTitleLabel = iv('scratchpad');
598
676
  const copyLabel = iv('copy');
599
677
 
600
678
  const bottomBar = el('div', {
679
+ 'data-tabs': 'bottom',
601
680
  style: 'display:flex;gap:10px;align-items:center;padding:5px 12px;border-top:1px solid oklch(1 0 0 / 10%);font-size:11px;color:oklch(0.6 0.02 213)',
602
681
  },
603
682
  el('button', {
@@ -833,6 +912,8 @@ function paneFor(tab) {
833
912
  if (liveMod && typeof liveMod.mountSkillTab === 'function') liveMod.mountSkillTab(host, tab);
834
913
  } else if (tab.kind === 'transcript') {
835
914
  if (liveMod && typeof liveMod.mountTranscriptTab === 'function') liveMod.mountTranscriptTab(host, tab);
915
+ } else if (tab.kind === 'view') {
916
+ if (cockpitMod && typeof cockpitMod.mountView === 'function') cockpitMod.mountView(host, tab);
836
917
  } else if (tab.kind === 'web') {
837
918
  mountWeb(host, tab); // overhaul-r5 — never falls through to an empty doc tab
838
919
  } else {
@@ -937,6 +1018,78 @@ export function tabBranch(t, includeMain) {
937
1018
  return trees.length > 1 && main ? main.branch : null;
938
1019
  }
939
1020
 
1021
+ function cockpitTabNode(t, act, splitTab, vertical) {
1022
+ const front = (act && act.id === t.id) || (splitTab && splitTab.id === t.id);
1023
+ if (vertical) return cockpitTabRow(t, front);
1024
+ const dotColor = FX.TAB_COLORS.dot[t.kind] || FX.TAB_COLORS.dot.doc;
1025
+ const meta = t.kind === 'term'
1026
+ ? ((!FIXTURE && t.sessionId) ? 'session ' + t.sessionId : 'session')
1027
+ : (t.kind === 'task' ? 'task' : t.kind === 'view' ? 'view' : t.kind === 'web' ? 'web' : t.kind === 'skill' ? 'skill' : t.kind === 'transcript' ? 'conversation' : t.role);
1028
+ const br = tabBranch(t);
1029
+ return el('div', {
1030
+ 'data-tab': t.id, 'data-tab-active': front ? '1' : '0',
1031
+ // A tab may shrink to 96px when the strip is crowded; the title is clipped with an ellipsis and the full title is its tooltip.
1032
+ style: 'position:relative;flex:0 1 auto;min-width:96px;display:flex;align-items:center;gap:8px;padding:0 14px;font:12px var(--mono);'
1033
+ + (front ? 'height:37px;border:1px solid var(--border);border-bottom:0;border-radius:8px 8px 0 0;background:var(--card);color:var(--foreground)'
1034
+ : 'height:36px;color:var(--muted-foreground)'),
1035
+ },
1036
+ el('button', {
1037
+ onClick: () => setState({ active: t.id }),
1038
+ title: t.title + (t.slug ? '\n' + t.slug + ' · ' + meta : ''),
1039
+ style: 'display:flex;gap:8px;align-items:center;align-self:stretch;color:inherit;min-width:0',
1040
+ },
1041
+ el('span', { 'data-tab-dot': t.kind, style: 'width:8px;height:8px;border-radius:50%;display:inline-block;flex-shrink:0;background:' + dotColor + ';color:' + dotColor }),
1042
+ el('span', { style: 'display:flex;flex-direction:column;min-width:0;align-items:flex-start' },
1043
+ el('span', { style: 'max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(t.title)),
1044
+ el('span', {
1045
+ 'data-tab-sub': '',
1046
+ // under the title, inside the tab's own width (the tab is its containing block), clipped to it
1047
+ style: 'position:absolute;left:30px;right:14px;bottom:1px;font:9px var(--mono);line-height:10px;color:oklch(0.52 0.02 213);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:left',
1048
+ }, iv(t.slug), ' · ', iv(meta), br ? el('span', { 'data-r1': 'tab-branch' }, iv(' · ⑂ ' + br)) : null),
1049
+ ),
1050
+ ),
1051
+ el('button', {
1052
+ onClick: () => closeTab(t.id), 'data-tab-close': '', 'aria-label': 'close ' + t.title, title: 'close',
1053
+ style: 'color:var(--muted-foreground)' + (front ? '' : ';position:absolute;right:3px;top:50%;transform:translateY(-50%)'),
1054
+ hover: 'color:var(--foreground)',
1055
+ }, '×'),
1056
+ );
1057
+ }
1058
+
1059
+ /** One tab as a row of the vertical list: the dot, the title over its second line, and the close control, all inside the row. */
1060
+ function cockpitTabRow(t, front) {
1061
+ const dotColor = FX.TAB_COLORS.dot[t.kind] || FX.TAB_COLORS.dot.doc;
1062
+ const meta = t.kind === 'term'
1063
+ ? ((!FIXTURE && t.sessionId) ? 'session ' + t.sessionId : 'session')
1064
+ : (t.kind === 'task' ? 'task' : t.kind === 'view' ? 'view' : t.kind === 'web' ? 'web' : t.kind === 'skill' ? 'skill' : t.kind === 'transcript' ? 'conversation' : t.role);
1065
+ const br = tabBranch(t);
1066
+ return el('div', {
1067
+ 'data-tab': t.id, 'data-tab-active': front ? '1' : '0', 'data-tab-row': '',
1068
+ style: 'flex:none;display:flex;align-items:center;gap:6px;padding:5px 6px 5px 10px;border-radius:6px;font:12px var(--mono);border:1px solid '
1069
+ + (front ? 'var(--border);background:var(--card);color:var(--foreground)' : 'transparent;color:var(--muted-foreground)'),
1070
+ hover: front ? null : 'background:var(--accent)',
1071
+ },
1072
+ el('button', {
1073
+ onClick: () => setState({ active: t.id }),
1074
+ title: t.title + (t.slug ? '\n' + t.slug + ' · ' + meta : ''),
1075
+ style: 'flex:1;min-width:0;display:flex;gap:8px;align-items:center;color:inherit;text-align:left',
1076
+ },
1077
+ el('span', { 'data-tab-dot': t.kind, style: 'width:8px;height:8px;border-radius:50%;display:inline-block;flex-shrink:0;background:' + dotColor + ';color:' + dotColor }),
1078
+ el('span', { style: 'flex:1;min-width:0;display:flex;flex-direction:column;align-items:stretch' },
1079
+ el('span', { style: 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(t.title)),
1080
+ el('span', {
1081
+ 'data-tab-sub': '',
1082
+ style: 'font:9.5px var(--mono);line-height:12px;color:oklch(0.52 0.02 213);overflow:hidden;text-overflow:ellipsis;white-space:nowrap',
1083
+ }, iv(t.slug), ' · ', iv(meta), br ? el('span', { 'data-r1': 'tab-branch' }, iv(' · ⑂ ' + br)) : null),
1084
+ ),
1085
+ ),
1086
+ el('button', {
1087
+ onClick: () => closeTab(t.id), 'data-tab-close': '', 'aria-label': 'close ' + t.title, title: 'close',
1088
+ style: 'flex:none;color:var(--muted-foreground);padding:0 4px', hover: 'color:var(--foreground)',
1089
+ }, '×'),
1090
+ );
1091
+ }
1092
+
940
1093
  function tabNode(t, act, splitTab) {
941
1094
  const selected = (act && act.id === t.id) || (splitTab && splitTab.id === t.id);
942
1095
  const bg = selected ? FX.TAB_COLORS.activeBg : 'transparent';
@@ -949,7 +1102,7 @@ function tabNode(t, act, splitTab) {
949
1102
  * fixture render keeps the design's literal — the comparator reads this text node. */
950
1103
  const meta = t.kind === 'term'
951
1104
  ? ((!FIXTURE && t.sessionId) ? 'session ' + t.sessionId : 'session')
952
- : (t.kind === 'task' ? 'task' : t.kind === 'web' ? 'web' : t.kind === 'skill' ? 'skill' : t.kind === 'transcript' ? 'conversation' : t.role);
1105
+ : (t.kind === 'task' ? 'task' : t.kind === 'view' ? 'view' : t.kind === 'web' ? 'web' : t.kind === 'skill' ? 'skill' : t.kind === 'transcript' ? 'conversation' : t.role);
953
1106
 
954
1107
  return el('div', {
955
1108
  style: 'flex:none;display:flex;align-items:center;gap:8px;padding:0 10px;background:' + bg
@@ -985,6 +1138,8 @@ function tabNode(t, act, splitTab) {
985
1138
  * prototype's, in the prototype's order: tab bar · (empty state) · panes · (scratchpad) · bottom
986
1139
  * bar. The panes row renders even when nothing is open, exactly as the design does.
987
1140
  */
1141
+ let lastFrontId = null;
1142
+
988
1143
  export function renderTabs(host) {
989
1144
  // Which pad the drawer is on is decided from the active tab, before anything is painted from it.
990
1145
  syncPad();
@@ -997,14 +1152,74 @@ export function renderTabs(host) {
997
1152
  // ── tab bar ──
998
1153
  const canSplit = visTabs.length >= 2 || !!state.splitId;
999
1154
  splitLabel.textContent = state.splitId ? '⫽ unsplit' : '⫽ split';
1000
- setChildren(tabBar, [
1001
- ...visTabs.map((t) => tabNode(t, act, splitTab)),
1002
- newSessionBtn,
1003
- FIXTURE ? null : newWebBtn,
1004
- !FIXTURE && state.webNotice ? el('span', { 'data-r5': 'notice', style: 'align-self:center;font-size:11px;color:var(--destructive);padding:0 6px' }, state.webNotice) : null,
1005
- newSessionOptsBtn,
1006
- canSplit ? splitBtn : null,
1007
- ]);
1155
+ const ck = cockpitLook();
1156
+ newSessionBtn.textContent = ck ? '+ session' : '+ session';
1157
+ emptyState.textContent = "nothing open — click a task's output or an artifact, or " + (ck ? '+' : '+') + ' session above';
1158
+ /* The tabs are drawn anew on every paint, so the bar is empty for a moment and its scroll would snap back to the start: a
1159
+ * crowded strip (or a long list) scrolled by hand jumped back on the next repaint. The offset is kept across the redraw. */
1160
+ const keepX = tabBar.scrollLeft, keepY = tabBar.scrollTop;
1161
+ const vertical = ck && !!state.tabsVertical;
1162
+ host.style.flexDirection = vertical ? 'row' : 'column';
1163
+ orientLabel.textContent = vertical ? '⇆ tabs on top' : '⇅ tabs at side';
1164
+ orientBtn.title = vertical ? 'show the tabs as a strip above the panes' : 'show the tabs as a list at the left of the panes';
1165
+ if (vertical) {
1166
+ tabBar.setAttribute('style', CK_VBAR);
1167
+ const V_BTN = CK_BTN.replace('margin:0 0 4px', 'margin:0') + ';justify-content:start';
1168
+ newSessionBtn.setAttribute('style', V_BTN + ';margin-bottom:6px');
1169
+ for (const b of [newWebBtn, newSessionOptsBtn, splitBtn, orientBtn]) b.setAttribute('style', V_BTN);
1170
+ setChildren(tabBar, [
1171
+ newSessionBtn,
1172
+ ...visTabs.map((t) => cockpitTabNode(t, act, splitTab, true)),
1173
+ el('div', { 'data-tabs': 'vcontrols', style: 'margin-top:auto;padding-top:8px;display:flex;flex-direction:column;gap:4px' },
1174
+ FIXTURE ? null : newWebBtn,
1175
+ !FIXTURE && state.webNotice ? el('span', { 'data-r5': 'notice', style: 'font-size:11px;color:var(--destructive);padding:0 6px' }, state.webNotice) : null,
1176
+ newSessionOptsBtn,
1177
+ canSplit ? splitBtn : null,
1178
+ orientBtn),
1179
+ ]);
1180
+ } else if (ck) {
1181
+ tabBar.setAttribute('style', CK_BAR + ';scrollbar-width:thin');
1182
+ newSessionBtn.setAttribute('style', CK_BTN + ';margin-left:0');
1183
+ for (const b of [newWebBtn, newSessionOptsBtn, splitBtn, orientBtn]) b.setAttribute('style', CK_BTN);
1184
+ const extras = [
1185
+ FIXTURE ? null : newWebBtn,
1186
+ !FIXTURE && state.webNotice ? el('span', { 'data-r5': 'notice', style: 'align-self:center;font-size:11px;color:var(--destructive);padding:0 6px' }, state.webNotice) : null,
1187
+ newSessionOptsBtn,
1188
+ canSplit ? splitBtn : null,
1189
+ ].filter(Boolean);
1190
+ extras[0].style.marginLeft = 'auto';
1191
+ // The switch sits after `+ session`, so the strip still ends the mockup's way in the fixture, which does not draw it.
1192
+ setChildren(tabBar, [...visTabs.map((t) => cockpitTabNode(t, act, splitTab)), ...extras, newSessionBtn, FIXTURE ? null : orientBtn]);
1193
+ } else {
1194
+ tabBar.setAttribute('style', BAR_STYLE);
1195
+ newSessionBtn.setAttribute('style', CHIP_STYLE);
1196
+ newWebBtn.setAttribute('style', CHIP_STYLE); newSessionOptsBtn.setAttribute('style', CHIP_STYLE); splitBtn.setAttribute('style', SPLIT_STYLE);
1197
+ setChildren(tabBar, [
1198
+ ...visTabs.map((t) => tabNode(t, act, splitTab)),
1199
+ newSessionBtn,
1200
+ FIXTURE ? null : newWebBtn,
1201
+ !FIXTURE && state.webNotice ? el('span', { 'data-r5': 'notice', style: 'align-self:center;font-size:11px;color:var(--destructive);padding:0 6px' }, state.webNotice) : null,
1202
+ newSessionOptsBtn,
1203
+ canSplit ? splitBtn : null,
1204
+ ]);
1205
+ }
1206
+
1207
+ if (tabBar.scrollLeft !== keepX) tabBar.scrollLeft = keepX;
1208
+ if (tabBar.scrollTop !== keepY) tabBar.scrollTop = keepY;
1209
+ // When the tab in front changes, the bar scrolls just enough to show it, so a tab opened at the far end of a crowded strip or a
1210
+ // long list is seen. Only the bar's own offset is moved (not scrollIntoView, which would also scroll the panels around it).
1211
+ if (ck && act && act.id !== lastFrontId) {
1212
+ const n = [...tabBar.querySelectorAll('[data-tab]')].find((x) => x.getAttribute('data-tab') === act.id);
1213
+ if (n) {
1214
+ const b = tabBar.getBoundingClientRect(), r = n.getBoundingClientRect();
1215
+ if (vertical) {
1216
+ if (r.top < b.top) tabBar.scrollTop -= b.top - r.top;
1217
+ else if (r.bottom > b.bottom) tabBar.scrollTop += r.bottom - b.bottom;
1218
+ } else if (r.left < b.left) tabBar.scrollLeft -= b.left - r.left;
1219
+ else if (r.right > b.right) tabBar.scrollLeft += r.right - b.right;
1220
+ }
1221
+ }
1222
+ lastFrontId = act ? act.id : null;
1008
1223
 
1009
1224
  // ── panes ──
1010
1225
  reapClosedPanes();
@@ -1031,18 +1246,23 @@ export function renderTabs(host) {
1031
1246
  scratchCopyLabel.textContent = state.scratchCopied ? 'copied ✓' : '⧉ copy';
1032
1247
  const canSend = !!(act && act.kind === 'term');
1033
1248
  scratchSendLabel.textContent = canSend ? '⇥ send to terminal' : 'open a terminal to send';
1034
- scratchSendBtn.setAttribute('style',
1035
- 'font-family:var(--mono);font-size:10.5px;color:'
1036
- + (canSend ? FX.SCRATCH_SEND_COLOR.on : FX.SCRATCH_SEND_COLOR.off)
1037
- + ';padding:4px 10px;min-height:26px;background:oklch(1 0 0 / 6%);border:1px solid oklch(1 0 0 / 12%)');
1249
+ scratchSendBtn.setAttribute('style', canSend ? PAD_BTN_PRIMARY : PAD_BTN_OFF);
1250
+ scratchClearBtn.disabled = !scratchText;
1251
+ scratchClearBtn.style.opacity = scratchText ? '' : '0.5';
1252
+ /* The header names whose pad this is, and the note says what keeps it and what Send does. Both are
1253
+ * read from the same facts `padKey` uses, so they cannot claim a pad the drawer is not showing. */
1254
+ scratchHeadLabel.textContent = FIXTURE ? 'scratchpad'
1255
+ : (canSend ? 'scratchpad · this session' : 'scratchpad · this repository');
1256
+ scratchNote.textContent = FIXTURE ? 'Kept until the page closes.'
1257
+ : (canSend ? 'Kept for this session. Send also presses Enter.' : 'Kept for this repository. Open a terminal to send.');
1038
1258
 
1039
1259
  // ── bottom bar ──
1040
1260
  scratchToggleLabel.textContent = state.scratchOpen ? '▼ scratchpad' : '▲ scratchpad';
1041
1261
  colTitleLabel.textContent = act ? act.title : 'scratchpad';
1042
1262
  copyLabel.textContent = state.copied ? 'copied ✓' : 'copy';
1043
1263
 
1044
- setChildren(host, [
1045
- tabBar,
1264
+ setChildren(host, [tabBar, tabsMain]);
1265
+ setChildren(tabsMain, [
1046
1266
  // Above the panes and below the tab bar: the chooser is about the click that was just made in
1047
1267
  // that bar, and putting it here leaves every open pane — and every live PTY — exactly where it
1048
1268
  // was. Replacing the panes with it would re-parent a terminal, which is the quiet regression
@@ -14,15 +14,47 @@
14
14
  /* Fixture mode is decided from the URL, not from another module: the persistence bypass below is
15
15
  * the whole reason the comparator can trust a run. Reading it here keeps state.js loadable on its
16
16
  * own and stops a data-layer import from deciding whether the PM's layout is written to disk. */
17
- export const FIXTURE = (() => {
18
- try { return new URLSearchParams(location.search).get('fixture') === 'proto'; }
19
- catch { return false; }
17
+ /* `?fixture=proto` is the prototype's own seed data; `?fixture=cockpit` is the cockpit mockup's (planning/design/cockpit-mockup/): the
18
+ * same fixture paths, a different seed. Both are the offline demo and the parity script's like-for-like content. */
19
+ const FIXTURE_KIND = (() => {
20
+ try { const f = new URLSearchParams(location.search).get('fixture'); return f === 'proto' || f === 'cockpit' ? f : null; }
21
+ catch { return null; }
20
22
  })();
23
+ export const FIXTURE = FIXTURE_KIND !== null;
24
+ /** `?layout=legacy` draws the three-column shell on a desktop, so the suites of the panels that shell uses keep guarding them until
25
+ * Run 4 moves the phone onto the cockpit. Nothing offers it. */
26
+ export const LEGACY_LAYOUT_FLAG = (() => { try { return new URLSearchParams(location.search).get('layout') === 'legacy'; } catch { return false; } })();
27
+ export const FIXTURE_COCKPIT = FIXTURE_KIND === 'cockpit';
28
+
29
+ /** What the cockpit fixture starts with beyond its seed data: the derived statuses (so the list can group), the open tabs. */
30
+ const COCKPIT_START = {
31
+ statuses: {
32
+ microsites: {
33
+ 'ooh-weather-expansion': { status: 'blocked' }, 'psle-paywall-revamp': { status: 'delivered' }, 'smrt-news-feed': { status: 'building' },
34
+ 'lightbox-ga-tagging': { status: 'accepted' }, 'login-metric-tagging': { status: 'accepted' }, 'shortlink-redirects': { status: 'accepted' },
35
+ },
36
+ // five things wait on the person, as the mockup's "Today 5" says: two here, three in pmos (the tile with the dot), none in scarlet
37
+ pmos: { 'cockpit-terminal-mod': { status: 'blocked' }, 'workspace-cockpit-nav': { status: 'blocked' }, 'kit-publish': { status: 'delivered' } },
38
+ // the mockup's third tile carries no dot: nothing in that repository is waiting on the person
39
+ scarlet: { 'scarlet-onboarding': { status: 'building' } },
40
+ },
41
+ product: 'microsites', sel: 'ooh-weather-expansion',
42
+ tabs: [
43
+ { id: 'term:ooh-weather-expansion:claude · merge PR 41', kind: 'term', title: 'claude · merge PR 41', slug: 'ooh-weather-expansion', repo: 'microsites', sess: 'claude · merge PR 41', state: 'working', role: 'TERM' },
44
+ { id: 'doc:planning/prd/ooh-weather-expansion-prd.md', kind: 'doc', title: '…-prd.md', slug: 'ooh-weather-expansion', path: 'planning/prd/ooh-weather-expansion-prd.md', role: 'DOC' },
45
+ { id: 'view:memory:microsites', kind: 'view', view: 'memory', title: 'tool · memory', slug: 'microsites', repo: 'microsites' },
46
+ ],
47
+ active: 'term:ooh-weather-expansion:claude · merge PR 41',
48
+ };
21
49
 
22
50
  const STORE_KEY = 'pmos.workspace.pm.v1';
23
51
 
24
52
  /** The initial state — the prototype's `state = {…}` block, key for key. */
25
53
  function initial() {
54
+ const base = initialBase();
55
+ return FIXTURE_COCKPIT ? { ...base, ...JSON.parse(JSON.stringify(COCKPIT_START)) } : base;
56
+ }
57
+ function initialBase() {
26
58
  return {
27
59
  theme: 'dark', sel: null, q: '', mode: 'product', collapsedGroups: {}, groupBy: 'objective', activeOnly: false, legendOpen: false, settingsOpen: false,
28
60
  product: null, intgSynced: {},
@@ -35,6 +67,13 @@ function initial() {
35
67
  newType: 'init', newName: '', newSlug: '', newAnchor: null, newFlow: 'adlc-feature',
36
68
  userGroups: {}, userKrs: {}, addedInits: {}, userInits: {}, selTask: null, collapsedPhases: {},
37
69
  detail: { kind: 'init' },
70
+ // the rail's menu (workspace-cockpit-nav): open or shut, never persisted
71
+ menuOpen: false,
72
+ paletteOpen: false, // Run 2: the ⌘K palette — never persisted
73
+ layoutFocus: false, // Run 2: the Focus layout — never persisted (distinct from `focus`, the focus-mode data)
74
+ todayScope: 'repo',
75
+ filesOpen: false, // Run 3: the Work list shows the repository's files — never persisted
76
+ todayDismissed: {},
38
77
  tabs: [], active: null, splitId: null,
39
78
  // overhaul-r1: reader measure per tab (R5), which repositories show their lines of work (R2.1),
40
79
  // and the last remove/rename answer, shown on the row it is about (R3.3)
@@ -91,6 +130,7 @@ function initial() {
91
130
  newSessIsolate: null, newSessError: null, newSessBusy: false,
92
131
  newSessBranches: null, preflightProfile: null, profiles: null, branchInfo: null,
93
132
  col0: true, col1: true, col2: true, scratchOpen: false, scratchText: '', copied: false,
133
+ tabsVertical: false, // the cockpit's tabs as a vertical list instead of a strip — persisted, per browser
94
134
  /* The scratchpad drawer's height in px (panel-tabs.js). 96 is the prototype's literal and stays
95
135
  * the default; the PM can drag it, and the drag is clamped there rather than here — this store
96
136
  * only has to refuse a value that could not have come from the clamp. */
@@ -155,7 +195,9 @@ const TAB_FIELDS = ['id', 'kind', 'title', 'slug', 'repo', 'sess', 'sessionId',
155
195
  // a skill tab's skill id — a reload reopens the same skill
156
196
  'skillId',
157
197
  // a conversation tab's session id — a reload reopens the same transcript
158
- 'convId'];
198
+ 'convId',
199
+ // a view tab's view key (workspace-cockpit-nav) — a reload reopens the same tool
200
+ 'view'];
159
201
  const MAX_TABS = 40;
160
202
 
161
203
  function packTab(t) {
@@ -193,6 +235,8 @@ const PERSIST = {
193
235
  active: isSlug,
194
236
  splitId: isSlug,
195
237
  scratchOpen: isBool,
238
+ // the cockpit's tabs as a vertical list (true) or the horizontal strip (false, the default)
239
+ tabsVertical: isBool,
196
240
  /* A restored height is re-validated on the way IN, not merely on the way out: a corrupted or
197
241
  * hand-edited record must not be able to render a drawer with no textarea in it, or one taller
198
242
  * than any window it could be restored into. The upper bound here is deliberately generous (the