create-pmos 0.47.0 → 0.48.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 +1 -1
  2. package/payload/AGENTS-HUMAN.md +1 -1
  3. package/payload/AGENTS.md +1 -1
  4. package/payload/KIT-VERSION +1 -1
  5. package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +1 -1
  6. package/payload/mods/pmos-cockpit/README.md +4 -2
  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/register.tsx +37 -18
  11. package/payload/planning/evals/eval-calibration.md +1 -1
  12. package/payload/planning/okrs/health-budgets.md +1 -1
  13. package/payload/scripts/pmos-log.sh +18 -8
  14. package/payload/scripts/pmos-model.js +233 -2
  15. package/payload/scripts/status-facts.js +204 -0
  16. package/payload/scripts/workflow-state.js +52 -0
  17. package/payload/state/discoveries.md +1 -1
  18. package/workspace/README.md +2 -2
  19. package/workspace/bin/live-notes.js +133 -0
  20. package/workspace/design-parity.js +328 -21
  21. package/workspace/doc-category-bench.js +165 -0
  22. package/workspace/fixtures/doc-category-labelled.json +167 -0
  23. package/workspace/guide/initiatives-and-next-steps.md +15 -2
  24. package/workspace/guide/knowledge-graph.md +83 -1
  25. package/workspace/guide/layout.md +29 -2
  26. package/workspace/guide/proactive-engine.md +6 -2
  27. package/workspace/guide/validate-with-typesafe.md +16 -1
  28. package/workspace/lib/claims.js +51 -2
  29. package/workspace/lib/doc-category-corrections.js +113 -0
  30. package/workspace/lib/doc-category-writeback.js +110 -0
  31. package/workspace/lib/doc-category.js +225 -0
  32. package/workspace/lib/engine/pmos-model.js +233 -2
  33. package/workspace/lib/engine/status-facts.js +204 -0
  34. package/workspace/lib/engine/workflow-state.js +52 -0
  35. package/workspace/lib/jev-categorize.js +399 -0
  36. package/workspace/lib/jev-claims.js +59 -15
  37. package/workspace/lib/jev-route.js +129 -0
  38. package/workspace/lib/kg-cli.js +3 -1
  39. package/workspace/lib/kg.js +6 -1
  40. package/workspace/lib/learn.js +5 -3
  41. package/workspace/lib/live-notes.js +890 -0
  42. package/workspace/lib/typesafe-settings.js +10 -2
  43. package/workspace/package-lock.json +1 -1
  44. package/workspace/package.json +5 -4
  45. package/workspace/public/pm/app.js +148 -79
  46. package/workspace/public/pm/data.js +58 -0
  47. package/workspace/public/pm/fixture.js +40 -2
  48. package/workspace/public/pm/modes-live.js +216 -18
  49. package/workspace/public/pm/panel-cockpit.js +636 -0
  50. package/workspace/public/pm/panel-detail.js +210 -8
  51. package/workspace/public/pm/panel-initiatives.js +9 -9
  52. package/workspace/public/pm/panel-newsess.js +145 -86
  53. package/workspace/public/pm/panel-repos.js +34 -17
  54. package/workspace/public/pm/panel-tabs.js +145 -27
  55. package/workspace/public/pm/state.js +45 -4
  56. package/workspace/public/pm/tab-doc.js +244 -16
  57. package/workspace/public/pm/tab-term.js +11 -1
  58. package/workspace/public/pm/tokens.css +4 -3
  59. package/workspace/public/pm/view-addrepo.js +207 -0
  60. package/workspace/public/pm/view-files.js +197 -0
  61. package/workspace/public/pm/view-guide.js +141 -0
  62. package/workspace/public/pm/view-knowledge.js +225 -0
  63. package/workspace/public/pm/view-memory.js +122 -0
  64. package/workspace/public/pm/view-portfolio.js +153 -0
  65. package/workspace/public/pm/view-reports.js +190 -0
  66. package/workspace/public/pm/view-scheduled.js +161 -0
  67. package/workspace/public/pm/view-skills.js +216 -0
  68. package/workspace/public/pm/view-today.js +168 -0
  69. package/workspace/public/pm/view-validate.js +171 -0
  70. package/workspace/server.js +290 -7
  71. package/workspace/skills/live-notes.skill +58 -0
@@ -48,34 +48,54 @@ const iv = (v) => el('span', null, v === null || v === undefined ? '' : String(v
48
48
 
49
49
  /* ── styles: design tokens only, no raw hex (workspace-ci's token-hygiene check) ────────────── */
50
50
 
51
- const WRAP = 'flex:none;max-height:56vh;overflow-y:auto;display:flex;flex-direction:column;gap:9px;'
52
- + 'padding:12px 14px;border-bottom:1px solid var(--border);background:var(--card)';
53
- const TITLE = 'font-size:12.5px;color:var(--foreground)';
54
- const SUB = 'font-size:11.5px;color:var(--muted-foreground);line-height:1.5';
55
- const LABEL = 'font-family:var(--mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;'
56
- + 'color:var(--muted-foreground)';
51
+ /* Drawn from planning/design/cockpit-mockup/ViewNewSession.dc.html (Run 3 of workspace-cockpit-nav):
52
+ * a centred dialog card over a dimmed backdrop, option cards with a radio mark for where the session
53
+ * works, pill chips and badges, and the two buttons bottom right. The dialog is position:fixed, so it
54
+ * takes no room in the panel's flow: the panes below keep their size and their place, which is the
55
+ * same promise the inline block made. It is still a DOM child of panel 4, so every reader that looks
56
+ * for the form there still finds it. */
57
+ const BACKDROP = 'position:fixed;inset:0;z-index:55;display:grid;place-items:center;padding:16px;'
58
+ + 'background:oklch(0 0 0 / 45%)';
59
+ const WRAP = 'width:640px;max-width:100%;max-height:calc(100vh - 32px);overflow-y:auto;box-sizing:border-box;'
60
+ + 'display:flex;flex-direction:column;gap:12px;padding:22px;background:var(--card);color:var(--foreground);'
61
+ + 'border:1px solid var(--input);border-radius:12px;box-shadow:0 16px 48px oklch(0 0 0 / 50%);'
62
+ + 'font-family:var(--sans);font-size:13px;line-height:1.45';
63
+ const HEAD = 'font-family:var(--sans);font-size:18px;font-weight:600;color:var(--foreground)';
64
+ const TITLE = 'font-size:12.5px;color:var(--muted-foreground)';
65
+ const SUB = 'font-size:12px;color:var(--muted-foreground);line-height:1.5';
66
+ const LABEL = 'font-family:var(--mono);font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;'
67
+ + 'color:var(--muted-foreground);margin-top:2px';
57
68
  const ROW = 'display:flex;gap:6px;align-items:center;flex-wrap:wrap';
58
- const INPUT = 'font-family:var(--mono);font-size:11.5px;padding:5px 8px;min-height:30px;'
59
- + 'background:var(--secondary);border:1px solid var(--input);color:var(--foreground);min-width:200px';
60
- const CHIP = 'font-family:var(--mono);font-size:10.5px;padding:4px 9px;min-height:26px;'
61
- + 'background:var(--secondary);border:1px solid var(--border);color:var(--foreground)';
62
- const CHIP_ON = 'font-family:var(--mono);font-size:10.5px;padding:4px 9px;min-height:26px;'
63
- + 'background:var(--accent);border:1px solid var(--ring);color:var(--foreground)';
64
- const CHIP_DEAD = 'font-family:var(--mono);font-size:10.5px;padding:4px 9px;min-height:26px;'
65
- + 'background:var(--secondary);border:1px solid var(--border);color:var(--muted-foreground);opacity:.55';
66
- const CHIP_BAD = 'font-family:var(--mono);font-size:10.5px;padding:4px 9px;min-height:26px;'
67
- + 'background:var(--secondary);border:1px solid var(--destructive);color:var(--destructive);opacity:.75';
69
+ const INPUT = 'font-family:var(--mono);font-size:12px;padding:5px 10px;min-height:32px;box-sizing:border-box;'
70
+ + 'background:var(--background);border:1px solid var(--input);border-radius:8px;color:var(--foreground);min-width:240px';
71
+ const PILL = 'font-family:var(--mono);font-size:11px;padding:0 10px;min-height:26px;border-radius:999px;';
72
+ const CHIP = PILL + 'background:transparent;border:1px solid var(--border);color:var(--foreground)';
73
+ /* `var(--accent)` appears in CHIP_ON and in no other chip rule: pm-tabs-test reads a lit chip off it. */
74
+ const CHIP_ON = PILL + 'background:var(--accent);border:1px solid var(--brand);color:var(--foreground)';
75
+ const CHIP_DEAD = PILL + 'background:transparent;border:1px solid var(--border);color:var(--muted-foreground);opacity:.55';
76
+ const CHIP_BAD = PILL + 'background:transparent;border:1px solid var(--destructive);color:var(--destructive);opacity:.75';
68
77
  const HOVER = 'border-color:var(--ring)';
69
- /* `--brand` is the token layer's one accent; there is no `--primary` in tokens.css and inventing a
70
- * name would be a token that resolves to nothing in both themes. */
71
- const PRIMARY = 'font-family:var(--mono);font-size:11px;padding:6px 14px;min-height:32px;'
72
- + 'background:var(--brand-wash);border:1px solid var(--brand);color:var(--foreground)';
73
- const PRIMARY_OFF = 'font-family:var(--mono);font-size:11px;padding:6px 14px;min-height:32px;'
74
- + 'background:var(--secondary);border:1px solid var(--border);color:var(--muted-foreground)';
75
- const ERR = 'font-size:11.5px;line-height:1.5;color:var(--destructive);'
76
- + 'border:1px solid var(--destructive);padding:7px 9px;overflow-wrap:anywhere';
77
- const WARN = 'font-size:11px;line-height:1.5;color:var(--warning);overflow-wrap:anywhere';
78
- const MUTED_MONO = 'font-family:var(--mono);font-size:10.5px;color:var(--muted-foreground);overflow-wrap:anywhere';
78
+ /* The board's primary button is the inverted one: foreground on background. */
79
+ const BTN_BASE = 'font-family:var(--sans);font-size:12.5px;font-weight:500;padding:0 14px;min-height:32px;border-radius:8px;';
80
+ const PRIMARY = BTN_BASE + 'background:var(--foreground);border:1px solid var(--foreground);color:var(--background)';
81
+ const PRIMARY_OFF = BTN_BASE + 'background:var(--secondary);border:1px solid var(--border);color:var(--muted-foreground)';
82
+ const SECONDARY = BTN_BASE + 'background:transparent;border:1px solid var(--input);color:var(--foreground)';
83
+ const CLOSE = 'font-size:16px;line-height:1;padding:4px 6px;background:transparent;border:0;color:var(--muted-foreground)';
84
+ const ERR = 'font-size:12px;line-height:1.5;color:var(--destructive);border-radius:8px;'
85
+ + 'border:1px solid var(--destructive);padding:7px 10px;overflow-wrap:anywhere';
86
+ const WARN = 'font-size:12px;line-height:1.5;color:var(--warning);overflow-wrap:anywhere';
87
+ const MUTED_MONO = 'font-family:var(--mono);font-size:12px;color:var(--muted-foreground);overflow-wrap:anywhere';
88
+ const BADGE = 'font-family:var(--sans);font-size:11px;font-weight:500;border:1px solid var(--border);border-radius:999px;'
89
+ + 'padding:0 8px;line-height:18px;white-space:nowrap;color:var(--foreground)';
90
+ const BADGE_W = BADGE + ';border-color:transparent;background:color-mix(in oklab, var(--warning) 16%, transparent);color:var(--warning)';
91
+ const BADGE_S = BADGE + ';border-color:transparent;background:color-mix(in oklab, var(--success) 16%, transparent);color:var(--success)';
92
+ /* An option card: the board's `.opt`, with its radio mark. */
93
+ const OPT = 'display:flex;gap:12px;align-items:flex-start;width:100%;box-sizing:border-box;text-align:left;'
94
+ + 'padding:12px 14px;border-radius:10px;border:1px solid var(--border);background:transparent;color:var(--foreground);'
95
+ + 'font-family:var(--sans);font-size:13px';
96
+ const OPT_ON = OPT + ';border-color:var(--brand);background:color-mix(in oklab, var(--brand) 8%, transparent)';
97
+ const RADIO = 'flex:none;width:16px;height:16px;margin-top:2px;box-sizing:border-box;border-radius:50%;border:1px solid var(--input)';
98
+ const RADIO_ON = RADIO + ';border:5px solid var(--brand)';
79
99
 
80
100
  /* ── open / close ──────────────────────────────────────────────────────────────────────────── */
81
101
 
@@ -193,7 +213,8 @@ function absorbBranchBroadcast() {
193
213
  function branchSection() {
194
214
  const info = state.newSessBranches;
195
215
  const out = [];
196
- out.push(el('div', { style: LABEL }, 'branch'));
216
+ // The board calls this "Where it works". The word `branch` stays its own text: suites read it.
217
+ out.push(el('div', { style: LABEL, 'data-r29': 'where' }, el('span', null, 'where it works'), el('span', null, ' · '), el('span', null, 'branch')));
197
218
 
198
219
  if (FIXTURE) {
199
220
  out.push(el('div', { style: SUB }, 'fixture mode reads no repository — a session started here applies no branch'));
@@ -228,13 +249,22 @@ function branchSection() {
228
249
  }
229
250
 
230
251
  const pick = (v) => setState({ newSessBranch: v, newSessBranchMode: false, newSessError: null });
231
- const chips = [
232
- // ALWAYS first and ALWAYS the default: starting a session must never require a git decision.
233
- el('button', {
234
- onClick: () => pick(''),
235
- style: state.newSessBranch === '' && !state.newSessBranchMode ? CHIP_ON : CHIP, hover: HOVER,
236
- }, iv('stay on current' + (current ? ' (' + current + ')' : ''))),
237
- ];
252
+ /* The sessions already running in this folder, read from the same list isolateNow reads. The
253
+ * board's "Shared with 1 running session" is this count; nothing else is claimed about them. */
254
+ const sharing = (state.sessions || []).filter((x) => x && x.ws === state.wsId && x.state !== 'exited');
255
+ const stayOn = state.newSessBranch === '' && !state.newSessBranchMode;
256
+ // ALWAYS first and ALWAYS the default: starting a session must never require a git decision.
257
+ out.push(optionCard({
258
+ on: stayOn, onClick: () => pick(''), hook: 'opt-current',
259
+ title: 'stay on current' + (current ? ' (' + current + ')' : ''),
260
+ meta: current ? '⎇ ' + current : null,
261
+ sub: sharing.length
262
+ ? 'Shared with ' + sharing.length + ' running session' + (sharing.length === 1 ? '' : 's') + ': '
263
+ + sharing.map((x) => x.name || x.id).filter(Boolean).slice(0, 3).join(', ') + '.'
264
+ : 'No other session runs in this folder now.',
265
+ badge: sharing.length ? ['they can overwrite each other', BADGE_W] : null,
266
+ }));
267
+ const chips = [];
238
268
  for (const b of branches) {
239
269
  if (b === current) continue;
240
270
  const taken = elsewhere.get(b);
@@ -259,12 +289,16 @@ function branchSection() {
259
289
  style: state.newSessBranch === b && !state.newSessBranchMode ? CHIP_ON : CHIP, hover: HOVER,
260
290
  }, iv(b)));
261
291
  }
262
- chips.push(el('button', {
292
+ if (chips.length) {
293
+ out.push(el('div', { style: SUB }, 'Or move this folder to another branch:'));
294
+ out.push(el('div', { style: ROW, 'data-r29': 'branches' }, chips));
295
+ }
296
+ out.push(optionCard({
297
+ on: !!state.newSessBranchMode, hook: 'opt-new',
263
298
  onClick: () => setState({ newSessBranchMode: true, newSessBranch: '', newSessError: null }),
264
- style: state.newSessBranchMode ? CHIP_ON : CHIP, hover: HOVER,
265
- }, '+ new branch…'));
266
-
267
- out.push(el('div', { style: ROW }, chips));
299
+ title: '+ new branch…',
300
+ sub: 'Creates a branch from the current one. Choose below if it gets its own folder.',
301
+ }));
268
302
 
269
303
  if (state.newSessBranchMode) {
270
304
  out.push(el('div', { style: ROW },
@@ -282,11 +316,12 @@ function branchSection() {
282
316
  // that is staying put, it would be a checkbox with no meaning.
283
317
  if (state.newSessBranchMode || state.newSessBranch) {
284
318
  const on = isolateNow();
285
- out.push(el('div', { style: ROW },
319
+ out.push(el('div', { style: ROW, 'data-r29': 'isolate' },
286
320
  el('button', {
287
321
  onClick: () => setState({ newSessIsolate: !on }),
288
322
  style: on ? CHIP_ON : CHIP, hover: HOVER,
289
323
  }, iv((on ? '☑' : '☐') + ' its own folder (worktree)')),
324
+ el('span', { style: on ? BADGE_S : BADGE_W }, iv(on ? 'isolated' : 'shared folder')),
290
325
  el('span', { style: SUB }, iv(on
291
326
  ? 'the branch is checked out in a new folder — sessions already running are untouched'
292
327
  : 'this repository is switched to that branch — every session already in it moves too')),
@@ -295,6 +330,19 @@ function branchSection() {
295
330
  return out;
296
331
  }
297
332
 
333
+ /** The board's option card: a radio mark, a bold title, a mono aside, a muted line and a badge. The
334
+ * title is its own span, so a reader of the panel's text still finds it as one item. */
335
+ function optionCard({ on, onClick, hook, title, meta, sub, badge }) {
336
+ return el('button', { onClick, style: on ? OPT_ON : OPT, hover: on ? null : HOVER, 'data-r29': hook, 'data-on': on ? 'yes' : 'no' },
337
+ el('span', { style: on ? RADIO_ON : RADIO }),
338
+ el('span', { style: 'display:flex;flex-direction:column;gap:2px;min-width:0' },
339
+ el('span', { style: 'display:flex;gap:8px;align-items:baseline;flex-wrap:wrap' },
340
+ el('b', { style: 'font-weight:600' }, iv(title)),
341
+ meta ? el('span', { style: MUTED_MONO }, iv(meta)) : null),
342
+ sub ? el('span', { style: SUB }, iv(sub)) : null,
343
+ badge ? el('span', { style: 'margin-top:4px' }, el('span', { style: badge[1] }, iv(badge[0]))) : null));
344
+ }
345
+
298
346
  const shortPath = (p) => { const s = String(p || ''); const i = s.lastIndexOf('/'); return i < 0 ? s : s.slice(i + 1); };
299
347
 
300
348
  /** What the isolate checkbox currently reads — the PM's choice if they made one, else the server's
@@ -505,53 +553,64 @@ export function newSessionBlock() {
505
553
  const slug = state.newSess.slug;
506
554
  const busy = !!state.newSessBusy;
507
555
 
508
- return el('div', { style: WRAP },
509
- el('div', { style: 'display:flex;gap:8px;align-items:baseline;flex-wrap:wrap' },
510
- el('span', { style: TITLE }, 'start a session — more options'),
511
- el('span', { style: MUTED_MONO }, iv(slug)),
512
- ),
513
- el('div', { style: SUB },
514
- 'What this session runs, and where. + session does all of this in one click on the rule; '
515
- + 'this is for the times the rule is not what you want.'),
516
-
517
- el('div', { style: LABEL }, 'name'),
518
- el('div', { style: ROW },
519
- el('input', {
520
- value: state.newSessName, placeholder: slug + ' 1', style: INPUT, disabled: busy,
521
- onInput: (e) => { state.newSessName = e.target.value; },
522
- onChange: () => setState({}),
523
- }),
524
- ),
525
-
526
- el('div', { style: LABEL }, 'agent'),
527
- el('div', { style: ROW },
528
- ['shell', 'claude'].map((a) => el('button', {
529
- onClick: () => setState({ newSessAgent: a, newSessError: null }),
530
- style: state.newSessAgent === a ? CHIP_ON : CHIP, hover: HOVER,
531
- }, iv(a))),
532
- el('span', { style: SUB }, 'a CLI that is not on PATH is refused by the server, and says so'),
533
- ),
534
-
535
- modelSection(),
536
- branchSection(),
537
- profileSection(),
538
-
539
- // The refusal, where the question was asked. It is rendered ABOVE the button so the PM reads it
540
- // before pressing again, and it survives until something changes.
541
- state.newSessError ? el('div', { style: ERR }, iv(state.newSessError)) : null,
542
-
543
- el('div', { style: 'display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding-top:2px' },
544
- el('button', {
545
- onClick: () => { if (!busy) onStart(); },
546
- disabled: busy, style: busy ? PRIMARY_OFF : PRIMARY, hover: busy ? null : HOVER,
547
- }, busy ? 'starting…' : 'start session'),
548
- el('button', { onClick: () => closeNewSession(), style: CHIP, hover: HOVER, disabled: busy }, 'cancel'),
556
+ const badges = [slug, state.product && state.product !== slug ? state.product : null, state.newSessAgent || 'shell']
557
+ .filter(Boolean).map((b) => el('span', { style: BADGE }, iv(b)));
558
+
559
+ return el('div', { style: BACKDROP, 'data-r29': 'newsess' },
560
+ el('div', { style: WRAP, role: 'dialog', 'aria-label': 'New session', 'data-board': 'new-session' },
561
+ el('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:8px' },
562
+ el('span', { style: HEAD, 'data-r29': 'newsess-title' }, 'New session'),
563
+ el('button', { onClick: () => closeNewSession(), style: CLOSE, hover: 'color:var(--foreground)', disabled: busy,
564
+ 'aria-label': 'close', title: 'close without starting', 'data-r29': 'newsess-close' }, '×'),
565
+ ),
566
+ el('div', { style: 'display:flex;gap:8px;flex-wrap:wrap', 'data-r29': 'newsess-badges' }, badges),
567
+ el('div', { style: 'display:flex;gap:8px;align-items:baseline;flex-wrap:wrap' },
568
+ el('span', { style: TITLE }, 'start a session — more options'),
569
+ el('span', { style: MUTED_MONO }, iv(slug)),
570
+ ),
571
+ el('div', { style: SUB },
572
+ 'What this session runs, and where. + session does all of this in one click on the rule; '
573
+ + 'this is for the times the rule is not what you want.'),
574
+
575
+ el('div', { style: LABEL }, 'name'),
576
+ el('div', { style: ROW },
577
+ el('input', {
578
+ value: state.newSessName, placeholder: slug + ' 1', style: INPUT, disabled: busy,
579
+ onInput: (e) => { state.newSessName = e.target.value; },
580
+ onChange: () => setState({}),
581
+ }),
582
+ ),
583
+
584
+ el('div', { style: LABEL }, 'agent'),
585
+ el('div', { style: ROW },
586
+ ['shell', 'claude'].map((a) => el('button', {
587
+ onClick: () => setState({ newSessAgent: a, newSessError: null }),
588
+ style: state.newSessAgent === a ? CHIP_ON : CHIP, hover: HOVER,
589
+ }, iv(a))),
590
+ el('span', { style: SUB }, 'a CLI that is not on PATH is refused by the server, and says so'),
591
+ ),
592
+
593
+ modelSection(),
594
+ el('div', { style: 'display:flex;flex-direction:column;gap:8px', 'data-r29': 'newsess-where' }, branchSection()),
595
+ el('div', { style: 'display:flex;flex-direction:column;gap:8px', 'data-r29': 'newsess-profile' }, profileSection()),
596
+
597
+ // The refusal, where the question was asked. It is rendered ABOVE the button so the PM reads it
598
+ // before pressing again, and it survives until something changes.
599
+ state.newSessError ? el('div', { style: ERR }, iv(state.newSessError)) : null,
600
+
601
+ el('div', { style: SUB, 'data-r29': 'newsess-advice' },
602
+ 'Nothing appears until the server says it started. If it refuses — an unreadable profile, a '
603
+ + 'branch git will not check out — you will read the reason here and no session, tab or '
604
+ + 'terminal will have been created.'),
605
+
606
+ el('div', { style: 'display:flex;gap:8px;align-items:center;justify-content:flex-end;flex-wrap:wrap;padding-top:2px', 'data-r29': 'newsess-actions' },
607
+ el('button', { onClick: () => closeNewSession(), style: SECONDARY, hover: HOVER, disabled: busy }, 'cancel'),
608
+ el('button', {
609
+ onClick: () => { if (!busy) onStart(); },
610
+ disabled: busy, style: busy ? PRIMARY_OFF : PRIMARY, hover: busy ? null : 'opacity:.9',
611
+ }, busy ? 'starting…' : 'start session'),
612
+ ),
549
613
  ),
550
-
551
- el('div', { style: SUB },
552
- 'Nothing appears until the server says it started. If it refuses — an unreadable profile, a '
553
- + 'branch git will not check out — you will read the reason here and no session, tab or '
554
- + 'terminal will have been created.'),
555
614
  );
556
615
  }
557
616
 
@@ -106,14 +106,14 @@ async function connectData() {
106
106
  connectData();
107
107
 
108
108
  /** allProds (line 1431) — the base repositories plus anything the PM added in this window. */
109
- function repoTuples() {
109
+ export function repoTuples() {
110
110
  if (FIXTURE) return productsList(state.addedProducts || []);
111
111
  const added = (state.addedProducts || []).map((x) => [x.id, x.sub, x.kind || 'github']);
112
112
  return [...(realRepos || []), ...added];
113
113
  }
114
114
 
115
115
  /** `product` (line 1396): the state field, defaulting to the first repository in the list. */
116
- function activeProduct(tuples) {
116
+ export function activeProduct(tuples) {
117
117
  if (state.product) return state.product;
118
118
  const list = tuples || repoTuples();
119
119
  return list.length ? list[0][0] : null;
@@ -255,11 +255,11 @@ function repoButton(tuple, product) {
255
255
  const chipStyle = 'flex:none;font-family:var(--mono);font-size:10px;padding:1px 6px;border:1px solid var(--border);color:var(--muted-foreground);min-height:22px';
256
256
  const actStyle = 'flex:none;font-size:12px;padding:2px 6px;color:var(--muted-foreground);min-height:28px';
257
257
 
258
- function treesOf(name) {
258
+ export function treesOf(name) {
259
259
  return dataMod && typeof dataMod.productTrees === 'function' ? dataMod.productTrees(name) : [];
260
260
  }
261
261
 
262
- async function renameRepo(name) {
262
+ export async function renameRepo(name) {
263
263
  const win = typeof window !== 'undefined' && window.prompt ? window : null;
264
264
  const label = win ? win.prompt('Rename “' + name + '” to:', name) : null;
265
265
  if (label === null || !String(label).trim()) return;
@@ -329,10 +329,11 @@ async function removeRepo(name) {
329
329
  setState({ repoNotice: null });
330
330
  }
331
331
 
332
- function repoEntry(tuple, product) {
333
- const row = repoButton(tuple, product);
334
- if (FIXTURE || !dataMod) return row;
335
- const name = tuple[0];
332
+ /** What the live app adds under a repository: the chips (trees, sync, profile, audit, rename, remove) and the blocks
333
+ * they open. Empty in fixture mode, where the repository row is the prototype's and carries nothing more. The cockpit
334
+ * list column draws this under the repository's name; the phone's repository list draws it under the row. */
335
+ export function manageParts(name, product) {
336
+ if (FIXTURE || !dataMod) return { tools: [], below: [] };
336
337
  const trees = treesOf(name);
337
338
  /* The trees list is always available — the main tree is a line of work too, and a repository with
338
339
  * one tree still has branches to create. The active repository opens it by default; others on a
@@ -359,23 +360,39 @@ function repoEntry(tuple, product) {
359
360
  tools.push(el('button', { onClick: () => renameRepo(name), style: actStyle, hover: 'color:var(--foreground)', title: 'rename this repository', 'data-r1': 'rename' }, '✎'));
360
361
  tools.push(el('button', { onClick: () => removeRepo(name), style: actStyle, hover: 'color:var(--destructive)', title: 'remove this repository from PMOS (no folder is deleted)', 'data-r1': 'remove' }, '✕'));
361
362
  }
362
- const kids = [el('div', { style: 'display:flex;align-items:center' },
363
- el('div', { style: 'flex:1;min-width:0' }, row),
364
- tools.length ? el('div', { style: 'display:flex;align-items:center;gap:2px;padding-right:8px' }, tools) : null)];
365
- { const bf = branchForm(name); if (bf) kids.push(bf); }
366
- if (open && trees.length) kids.push(treesBlock(name, trees, product === name));
367
- { const tn = treeNoteFor(name); if (tn) kids.push(tn); }
368
- { const ab = auditBlock(name); if (ab) kids.push(ab); }
363
+ const below = [];
364
+ { const bf = branchForm(name); if (bf) below.push(bf); }
365
+ if (open && trees.length) below.push(treesBlock(name, trees, product === name));
366
+ { const tn = treeNoteFor(name); if (tn) below.push(tn); }
367
+ { const ab = auditBlock(name); if (ab) below.push(ab); }
369
368
  const n = state.repoNotice;
370
369
  if (n && n.name === name) {
371
- kids.push(el('div', {
370
+ below.push(el('div', {
372
371
  style: 'padding:4px 14px 8px 39px;font-size:11px;color:' + (n.tone === 'bad' ? 'var(--destructive)' : 'var(--muted-foreground)'),
373
372
  'data-r1': 'notice',
374
373
  }, ip(n.text)));
375
374
  }
375
+ return { tools, below };
376
+ }
377
+
378
+ function repoEntry(tuple, product) {
379
+ const row = repoButton(tuple, product);
380
+ if (FIXTURE || !dataMod) return row;
381
+ const { tools, below } = manageParts(tuple[0], product);
382
+ const kids = [el('div', { style: 'display:flex;align-items:center' },
383
+ el('div', { style: 'flex:1;min-width:0' }, row),
384
+ tools.length ? el('div', { style: 'display:flex;align-items:center;gap:2px;padding-right:8px' }, tools) : null)];
385
+ for (const b of below) kids.push(b);
376
386
  return el('div', null, kids);
377
387
  }
378
388
 
389
+ /** The abbreviation a repository shows on a rail tile: the one the PM set, else its first letters. */
390
+ export function repoInitial(name) {
391
+ const set = dataMod && dataMod.repoInitials ? dataMod.repoInitials(name) : null;
392
+ return (set || String(name).replace(/[^A-Za-z0-9]+/g, ' ').trim().split(' ').filter(Boolean).slice(0, 2)
393
+ .map((w) => w[0]).join('') || String(name).slice(0, 2)).toUpperCase();
394
+ }
395
+
379
396
  /**
380
397
  * overhaul-r1 R4 — the collapsed repositories rail, one glyph per repository, LIVE mode only (the
381
398
  * fixture rail is the prototype's and the comparator reads it). Initials + kind badge; a dot when a
@@ -447,7 +464,7 @@ export function onAddProduct() { setDetail({ kind: 'addprod' }); }
447
464
  * resolved. It is deliberately NOT dismissible: the condition is still true after the click.
448
465
  * Nothing renders when the state is settled — including in fixture mode, which has no data layer
449
466
  * and therefore never sets the key, so the comparator sees the prototype's markup unchanged. */
450
- function stateHealthBanner() {
467
+ export function stateHealthBanner() {
451
468
  const h = state.stateHealth;
452
469
  if (!h || h.ok !== false) return null;
453
470
  return el('div', {