create-pmos 0.37.0 → 0.38.1

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 (72) hide show
  1. package/bin/create-pmos.js +49 -9
  2. package/package.json +1 -1
  3. package/payload/AGENTS.md +4 -0
  4. package/payload/KIT-VERSION +2 -2
  5. package/payload/okf/core/concepts/frontmatter-format.md +27 -61
  6. package/payload/okf/core/templates/decision-record-template.md +34 -0
  7. package/payload/okf/core/templates/index.md +7 -2
  8. package/payload/okf/core/templates/pm-brief-template.md +34 -0
  9. package/payload/okf/core/templates/project-plan-template.md +43 -0
  10. package/payload/okf/core/templates/report-weekly-template.md +35 -0
  11. package/payload/okf/core/templates/strategy-long-range-template.md +37 -0
  12. package/payload/okf/core/templates/strategy-one-pager-template.md +34 -0
  13. package/payload/planning/evals/eval-calibration.md +8 -0
  14. package/payload/planning/okrs/health-budgets.md +8 -0
  15. package/payload/scripts/anchor-resolve.js +56 -6
  16. package/payload/scripts/gate-initiative.sh +32 -0
  17. package/payload/scripts/okf-upgrade.js +232 -0
  18. package/payload/scripts/pmos-memory.js +375 -0
  19. package/payload/scripts/pmos-model.js +239 -0
  20. package/payload/scripts/pmos-skills.js +274 -0
  21. package/payload/scripts/workflow-state.js +178 -10
  22. package/payload/scripts/workflow-sweep.js +2 -1
  23. package/payload/state/discoveries.md +8 -0
  24. package/payload/state/index.md +7 -0
  25. package/payload/state/log.md +7 -0
  26. package/workspace/README.md +61 -204
  27. package/workspace/bin/pmos.js +23 -4
  28. package/workspace/lib/board-cache.js +18 -8
  29. package/workspace/lib/console-state.js +2 -0
  30. package/workspace/lib/engine/anchor-resolve.js +56 -6
  31. package/workspace/lib/engine/pmos-memory.js +375 -0
  32. package/workspace/lib/engine/pmos-model.js +239 -0
  33. package/workspace/lib/engine/pmos-skills.js +274 -0
  34. package/workspace/lib/engine/workflow-state.js +178 -10
  35. package/workspace/lib/focus.js +5 -6
  36. package/workspace/lib/memory.js +54 -0
  37. package/workspace/lib/models.js +50 -0
  38. package/workspace/lib/preview-kind.js +12 -1
  39. package/workspace/lib/schedule-logic.js +31 -2
  40. package/workspace/lib/skill-store.js +5 -1
  41. package/workspace/lib/skills-registry.js +74 -0
  42. package/workspace/lib/sync.js +132 -0
  43. package/workspace/lib/tree-share.js +2 -0
  44. package/workspace/package-lock.json +1 -1
  45. package/workspace/package.json +3 -3
  46. package/workspace/public/pm/app.js +32 -2
  47. package/workspace/public/pm/data.js +160 -10
  48. package/workspace/public/pm/lifecycle.js +105 -0
  49. package/workspace/public/pm/markdown.js +10 -1
  50. package/workspace/public/pm/modes-live.js +437 -0
  51. package/workspace/public/pm/modes.js +8 -5
  52. package/workspace/public/pm/panel-detail.js +22 -2
  53. package/workspace/public/pm/panel-initiatives.js +57 -12
  54. package/workspace/public/pm/panel-newsess.js +25 -0
  55. package/workspace/public/pm/panel-repos.js +130 -4
  56. package/workspace/public/pm/panel-tabs.js +49 -2
  57. package/workspace/public/pm/state.js +11 -1
  58. package/workspace/public/pm/tab-doc.js +160 -26
  59. package/workspace/public/pm/tab-term.js +2 -0
  60. package/workspace/public/pm/tab-web.js +89 -0
  61. package/workspace/public/pm/tokens.css +4 -0
  62. package/workspace/public/pm/xlsx-read.js +117 -0
  63. package/workspace/server.js +736 -42
  64. package/workspace/skills/gather-requirements.skill +53 -0
  65. package/workspace/skills/memory-groom.skill +46 -0
  66. package/workspace/skills/report-biweekly.skill +53 -0
  67. package/workspace/skills/report-daily.skill +53 -0
  68. package/workspace/skills/report-monthly.skill +53 -0
  69. package/workspace/skills/report-special.skill +49 -0
  70. package/workspace/skills/report-weekly.skill +53 -0
  71. package/workspace/skills/skill-discover.skill +49 -0
  72. package/workspace/skills/skill-generate.skill +33 -0
@@ -265,9 +265,15 @@ export function openTerm(slug, sess) {
265
265
  });
266
266
  }
267
267
 
268
- export function openDoc(slug, path, role) {
268
+ export function openDoc(slug, path, role, tree) {
269
+ /* `tree` — the line of work a search hit came from (overhaul-r1 R1.3). A hit on a LINKED worktree
270
+ * opens a tab that carries that tree's `ws` and `branch`, under its own id, so it is read from and
271
+ * saved to that tree and can sit beside the main tree's copy of the same path. A main-tree hit
272
+ * opens exactly the tab it always did. */
273
+ const linked = !!(tree && tree.ws && tree.linked);
269
274
  openTab({
270
- id: 'doc:' + path, kind: 'doc', title: path.split('/').pop(), slug,
275
+ id: 'doc:' + (linked ? tree.ws + ':' : '') + path, kind: 'doc', title: path.split('/').pop(), slug,
276
+ ...(linked ? { ws: tree.ws, branch: tree.branch || null } : {}),
271
277
  /* A file opened from the FOLDER search belongs to no initiative, so `slugProduct()` has no
272
278
  * answer for it — and `panel-tabs.visibleTabs` shows a tab whose repo is `undefined` in EVERY
273
279
  * repository, which would hang this workspace's file in another one's tab strip and read its
@@ -522,15 +528,20 @@ function dataModule() {
522
528
  return null;
523
529
  }
524
530
 
531
+ /* overhaul-r4 B2 — "all repositories": the same box, widened to every registered repository. The
532
+ * cache is keyed by scope too, so the two answers never mix. */
533
+ const scopeKey = (q) => (state.searchAll ? 'all\u0000' : '') + q;
525
534
  function requestFiles(q) {
526
- fileWanted = q;
535
+ const key = scopeKey(q);
536
+ const all = !!state.searchAll;
537
+ fileWanted = key;
527
538
  if (fileTimer) clearTimeout(fileTimer);
528
539
  fileTimer = setTimeout(() => {
529
540
  fileTimer = null;
530
541
  const mod = dataModule();
531
542
  if (!mod || typeof mod.searchFiles !== 'function') return;
532
- const asked = q;
533
- Promise.resolve(mod.searchFiles(asked)).then(
543
+ const asked = key;
544
+ Promise.resolve(mod.searchFiles(q, undefined, all)).then(
534
545
  (r) => {
535
546
  if (fileWanted !== asked) return; // the PM has typed past this query
536
547
  fileCache.set(asked, (r && r.ok === false)
@@ -539,6 +550,8 @@ function requestFiles(q) {
539
550
  hits: (r && r.hits) || [], truncated: !!(r && r.truncated),
540
551
  contentTruncated: !!(r && r.content_truncated),
541
552
  scanned: (r && r.scanned) || 0, contentScanned: (r && r.content_scanned) || 0, error: null,
553
+ trees: Array.isArray(r && r.trees) ? r.trees : [],
554
+ repos: Array.isArray(r && r.repos) ? r.repos : null,
542
555
  });
543
556
  if (fileCache.size > 40) fileCache.delete(fileCache.keys().next().value);
544
557
  setState({});
@@ -555,12 +568,34 @@ function requestFiles(q) {
555
568
  /** The result for `q`, or null while nothing has come back yet. Kicks the fetch off on a miss. */
556
569
  function fileResults(q) {
557
570
  if (!q || FIXTURE) return null;
558
- if (fileCache.has(q)) return fileCache.get(q);
571
+ if (fileCache.has(scopeKey(q))) return fileCache.get(scopeKey(q));
559
572
  requestFiles(q);
560
573
  return null;
561
574
  }
562
575
 
563
- const fileLabel = (h) => 'file · ' + (h.kind || 'file') + (h.match === 'content' ? ' · line ' + h.line : '');
576
+ /* overhaul-r1 R1.2 — every hit names its line of work. A hit merged across trees (identical
577
+ * content) names every branch; a hit from a linked worktree names its branch with `⑂`; a hit only
578
+ * on the main tree of a repository with no linked trees says nothing extra. */
579
+ const isLinked = (ws) => !!(dataMod && typeof dataMod.isLinkedTree === 'function' && dataMod.isLinkedTree(ws));
580
+ const hitTree = (h) => (h && h.ws ? { ws: h.ws, branch: h.branch || null, linked: isLinked(h.ws) } : null);
581
+ function branchTag(h) {
582
+ const bs = Array.isArray(h.branches) ? h.branches.filter(Boolean) : [];
583
+ if (bs.length > 1) return ' · on ' + bs.join(', ');
584
+ if (h.ws && isLinked(h.ws)) return ' · ⑂ ' + (h.branch || 'detached');
585
+ return '';
586
+ }
587
+ /* A hit from ANOTHER repository (all-repositories scope) selects that repository first, then opens
588
+ * the file from the hit's own tree, so it is read where it was found. */
589
+ async function openHit(h) {
590
+ if (h.repo && h.repo !== state.product) {
591
+ const repos = await import('./panel-repos.js');
592
+ repos.selectRepo(h.repo);
593
+ openDoc(null, h.path, 'FILE', h.ws ? { ws: h.ws, branch: h.branch || null, linked: true } : null);
594
+ return;
595
+ }
596
+ openDoc(null, h.path, 'FILE', hitTree(h));
597
+ }
598
+ const fileLabel = (h) => (h.repo && state.searchAll ? h.repo + ' · ' : '') + 'file · ' + (h.kind || 'file') + (h.match === 'content' ? ' · line ' + h.line : '') + branchTag(h);
564
599
 
565
600
  function fileHitNode(h) {
566
601
  const where = h.dir ? h.dir + '/' : '';
@@ -575,7 +610,7 @@ function fileHitNode(h) {
575
610
  );
576
611
  }
577
612
  return el('button', {
578
- onClick: () => openDoc(null, h.path, 'FILE'),
613
+ onClick: () => openHit(h),
579
614
  style: 'display:block;width:100%;padding:6px 12px;min-height:40px;text-align:left',
580
615
  hover: 'background:var(--accent)',
581
616
  title: h.path,
@@ -593,10 +628,17 @@ function fileHitNode(h) {
593
628
  export function fileHitsBlock(q) {
594
629
  if (!q || FIXTURE) return null;
595
630
  const r = fileResults(q);
631
+ const scope = state.searchAll ? 'files in every repository' : 'files in this folder';
596
632
  const head = (text, tone) => el('div', {
597
- style: 'padding:4px 12px 4px;font-family:var(--mono);font-size:10px;letter-spacing:.12em;'
633
+ style: 'display:flex;gap:8px;align-items:center;padding:4px 12px 4px;font-family:var(--mono);font-size:10px;letter-spacing:.12em;'
598
634
  + 'text-transform:uppercase;color:' + (tone || 'var(--muted-foreground)') + ';border-bottom:1px solid var(--border)',
599
- }, iv(text));
635
+ }, iv(String(text).replace(/^files in this folder/, scope)),
636
+ // overhaul-r4 B2 — widen the same search to every registered repository
637
+ el('button', {
638
+ onClick: () => setState((st) => ({ searchAll: !st.searchAll })),
639
+ style: 'margin-left:auto;letter-spacing:0;text-transform:none;font-size:10px;padding:1px 6px;border:1px solid var(--border);color:var(--muted-foreground)',
640
+ hover: 'color:var(--foreground)', 'data-r4': 'search-all', title: 'search every registered repository',
641
+ }, iv((state.searchAll ? '☑' : '☐') + ' all repositories')));
600
642
 
601
643
  if (!r) return el('div', { 'data-pm-files': '' }, head('files in this folder · searching…'));
602
644
  if (r.error) {
@@ -608,8 +650,11 @@ export function fileHitsBlock(q) {
608
650
  * conclude from an empty list. `contentTruncated` means every file was NAMED and some bodies
609
651
  * were not read, which is the ordinary state of any repository holding a lock file. Folding the
610
652
  * second into the first would light the serious flag permanently and thereby retire it. */
653
+ // overhaul-r1 R1.4: with several lines of work, say WHICH walk was cut short
654
+ const cut = (r.trees || []).length > 1 ? (r.trees || []).filter((t) => t.truncated).map((t) => t.branch || 'detached') : [];
655
+ const lines = (r.trees || []).length > 1 ? ' across ' + r.trees.length + ' lines of work' : '';
611
656
  const bound = r.truncated
612
- ? ' · BOUNDED — files beyond the walk limit were not looked at'
657
+ ? ' · BOUNDED — files beyond the walk limit were not looked at' + (cut.length ? ' (on ' + cut.join(', ') + ')' : '')
613
658
  : (r.contentTruncated ? ' · all ' + r.scanned + ' names searched; ' + r.contentScanned + ' bodies read' : '');
614
659
 
615
660
  if (!r.hits.length) {
@@ -620,7 +665,7 @@ export function fileHitsBlock(q) {
620
665
  ? 'no match in the ' + r.scanned + ' files searched — the walk was BOUNDED, more were not looked at'
621
666
  : 'no file matches — a successful read of nothing' + (r.contentTruncated ? bound : ''))));
622
667
  }
623
- const kids = [head('files in this folder · ' + r.hits.length + ' of ' + r.scanned + ' searched' + bound)];
668
+ const kids = [head('files in this folder · ' + r.hits.length + ' of ' + r.scanned + ' searched' + lines + bound)];
624
669
  for (const h of r.hits) kids.push(fileHitNode(h));
625
670
  return el('div', { 'data-pm-files': '' }, kids);
626
671
  }
@@ -295,6 +295,28 @@ function isolateNow() {
295
295
  return live.length > 0;
296
296
  }
297
297
 
298
+ /* ── the model chips (D75) ──────────────────────────────────────────────────────────────────
299
+ * · only a Claude session has a model control PMOS has checked, so only it is offered a choice;
300
+ * · `auto` sends nothing: the server routes by the policy, or asks for no model at all;
301
+ * · the chips say what the PM is choosing (a tier), never a model name that will age. */
302
+ function modelSection() {
303
+ if (state.newSessAgent !== 'claude') {
304
+ return el('div', { 'data-r12': 'model-note', style: SUB }, iv('model: only a Claude session can be given a model by PMOS — a shell, or any other CLI, runs its own default'));
305
+ }
306
+ const cur = state.newSessTier || 'auto';
307
+ return el('div', { 'data-r12': 'model' },
308
+ el('div', { style: LABEL }, 'model'),
309
+ el('div', { style: ROW },
310
+ ['auto', 'fast', 'balanced', 'deep'].map((t) => el('button', {
311
+ 'data-r12': 'tier-' + t,
312
+ onClick: () => setState({ newSessTier: t, newSessError: null }),
313
+ style: cur === t ? CHIP_ON : CHIP, hover: HOVER,
314
+ }, iv(t))),
315
+ el('span', { style: SUB }, cur === 'auto'
316
+ ? 'auto: PMOS asks for no model unless your policy routes this work to one'
317
+ : 'a ' + cur + ' tier — the model and effort it maps to are in your models policy')));
318
+ }
319
+
298
320
  /* ── the profile chips ─────────────────────────────────────────────────────────────────────── */
299
321
 
300
322
  /**
@@ -419,6 +441,8 @@ async function onStart() {
419
441
  // value from liveSessionCount, and its own comment asks the client to state what the PM saw.
420
442
  isolate: branch ? isolateNow() : null,
421
443
  profile: state.preflightProfile === undefined ? null : state.preflightProfile,
444
+ // D75 — only for a Claude session; 'auto' sends nothing and lets the policy route.
445
+ tier: (state.newSessAgent === 'claude' && state.newSessTier && state.newSessTier !== 'auto') ? state.newSessTier : undefined,
422
446
  });
423
447
 
424
448
  if (!res || !res.ok) {
@@ -495,6 +519,7 @@ export function newSessionBlock() {
495
519
  el('span', { style: SUB }, 'a CLI that is not on PATH is refused by the server, and says so'),
496
520
  ),
497
521
 
522
+ modelSection(),
498
523
  branchSection(),
499
524
  profileSection(),
500
525
 
@@ -24,6 +24,7 @@
24
24
  */
25
25
 
26
26
  import { el, setChildren } from './dom.js';
27
+ import { syncButton } from './lifecycle.js';
27
28
  import { state, setState, FIXTURE } from './state.js';
28
29
  import {
29
30
  productsList, repoRow, firstSlugOf, MODE_META, SETTINGS_GROUPS, ADDABLE_PRODUCTS,
@@ -166,13 +167,25 @@ export function selectRepo(id) {
166
167
  * belt and braces — it makes the absence a property of THIS module, so it cannot regress by someone
167
168
  * adding an id to the seed list.
168
169
  */
170
+ /* overhaul-r1 R6.1 — in LIVE mode, a mode with no server behind it is not offered. `agents`,
171
+ * `memory` and `connections` start empty and every edit is a client-only setState that no server
172
+ * receives (modes.js `seedOr`), so they looked broken because they were. They come back in a later
173
+ * release wired or replaced. Fixture mode keeps them: it is the prototype's recorded state, and the
174
+ * design comparison reads it. Exported so the live-mode test can assert the list, not a screenshot. */
175
+ export const UNBACKED_MODES = ['agent', 'connect']; // mode ids (labels: agents · connections). `memory` left this list in R9 (D72): the repository's ledger backs it.
176
+ /* overhaul-r4 — the PORTFOLIO mode joins "all repositories" in live mode only (the fixture drawer is the
177
+ * prototype's, and the design comparison reads it). Its meta lives here, not in fixture.js, for the
178
+ * same reason. */
179
+ const META = Object.assign({}, MODE_META, { portfolio: ['▦', 'portfolio'] });
180
+ const LIVE_EXTRA = { 'all repositories': ['portfolio'] };
169
181
  const DRAWER_GROUPS = SETTINGS_GROUPS.map((g) => ({
170
182
  label: g.label,
171
- items: g.items.filter((id) => id !== 'workflow' && MODE_META[id]),
172
- }));
183
+ items: (FIXTURE ? [] : LIVE_EXTRA[g.label] || []).concat(
184
+ g.items.filter((id) => id !== 'workflow' && MODE_META[id] && (FIXTURE || !UNBACKED_MODES.includes(id)))),
185
+ })).filter((g) => g.items.length);
173
186
 
174
187
  function drawerButton(id) {
175
- const [glyph, label] = MODE_META[id];
188
+ const [glyph, label] = META[id];
176
189
  const on = state.mode === id;
177
190
  return el('button', {
178
191
  onClick: () => setState({ mode: id, settingsOpen: false, col1: true }),
@@ -233,6 +246,119 @@ function repoButton(tuple, product) {
233
246
  );
234
247
  }
235
248
 
249
+ /* ── overhaul-r1: lines of work, rename, remove (LIVE mode only) ─────────────────────────────
250
+ * Fixture mode renders the prototype's row unchanged. In live mode the row gains a `⑂ N` chip
251
+ * when the repository has linked worktrees (R2.1), and the ACTIVE row gains rename and remove
252
+ * (R3.1/R3.2). The server's answer to a remove is shown under the row in its own words (R3.3).
253
+ */
254
+ 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';
255
+ const actStyle = 'flex:none;font-size:12px;padding:2px 6px;color:var(--muted-foreground);min-height:28px';
256
+
257
+ function treesOf(name) {
258
+ return dataMod && typeof dataMod.productTrees === 'function' ? dataMod.productTrees(name) : [];
259
+ }
260
+
261
+ async function renameRepo(name) {
262
+ const label = typeof window !== 'undefined' && window.prompt ? window.prompt('Rename “' + name + '” to:', name) : null;
263
+ if (label === null || !String(label).trim() || String(label).trim() === name) return;
264
+ const out = await dataMod.renameRepository(name, String(label).trim());
265
+ if (!out || out.ok === false) { setState({ repoNotice: { name, tone: 'bad', text: (out && out.error) || 'rename refused' } }); return; }
266
+ await connectData();
267
+ setState({ product: String(label).trim(), repoNotice: null });
268
+ }
269
+
270
+ async function removeRepo(name) {
271
+ const ok = typeof window === 'undefined' || !window.confirm ? true
272
+ : window.confirm('Remove “' + name + '” from PMOS?\n\nThis forgets the repository and its worktree rows. No folder is deleted.');
273
+ if (!ok) return;
274
+ const out = await dataMod.removeRepository(name);
275
+ if (!out || out.ok === false) { setState({ repoNotice: { name, tone: 'bad', text: (out && out.error) || 'remove refused' } }); return; }
276
+ realRepos = (realRepos || []).filter((t) => t[0] !== name);
277
+ setState({ repoNotice: null });
278
+ }
279
+
280
+ function repoEntry(tuple, product) {
281
+ const row = repoButton(tuple, product);
282
+ if (FIXTURE || !dataMod) return row;
283
+ const name = tuple[0];
284
+ const trees = treesOf(name);
285
+ const linked = trees.filter((t) => !t.main);
286
+ const open = !!(state.treesOpen || {})[name];
287
+ const tools = [];
288
+ if (linked.length) {
289
+ tools.push(el('button', {
290
+ onClick: () => setState((s) => ({ treesOpen: { ...(s.treesOpen || {}), [name]: !open } })),
291
+ style: chipStyle, hover: 'color:var(--foreground)',
292
+ title: linked.length + ' linked worktree' + (linked.length === 1 ? '' : 's') + ' — show the lines of work',
293
+ 'data-r1': 'trees-chip',
294
+ }, ip('⑂ ' + linked.length)));
295
+ }
296
+ if (product === name) {
297
+ { const sb = syncButton(name); if (sb) tools.push(sb); }
298
+ tools.push(el('button', { onClick: () => renameRepo(name), style: actStyle, hover: 'color:var(--foreground)', title: 'rename this repository', 'data-r1': 'rename' }, '✎'));
299
+ 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' }, '✕'));
300
+ }
301
+ const kids = [el('div', { style: 'display:flex;align-items:center' },
302
+ el('div', { style: 'flex:1;min-width:0' }, row),
303
+ tools.length ? el('div', { style: 'display:flex;align-items:center;gap:2px;padding-right:8px' }, tools) : null)];
304
+ if (open && trees.length) {
305
+ kids.push(el('div', { style: 'padding:2px 14px 8px 39px', 'data-r1': 'trees' },
306
+ trees.map((t) => el('div', {
307
+ style: 'display:flex;gap:8px;font-family:var(--mono);font-size:10.5px;color:var(--muted-foreground);padding:2px 0',
308
+ title: t.initiative ? 'initiative read from the branch name (initiative/<id>) — not a recorded attribution' : '',
309
+ },
310
+ el('span', { style: 'color:var(--foreground)' }, ip((t.main ? '⎇ ' : '⑂ ') + (t.branch || '(detached)'))),
311
+ el('span', null, ip(t.sessions + ' session' + (t.sessions === 1 ? '' : 's'))),
312
+ t.initiative ? el('span', null, ip('#' + t.initiative)) : null,
313
+ )),
314
+ ));
315
+ }
316
+ const n = state.repoNotice;
317
+ if (n && n.name === name) {
318
+ kids.push(el('div', {
319
+ style: 'padding:4px 14px 8px 39px;font-size:11px;color:' + (n.tone === 'bad' ? 'var(--destructive)' : 'var(--muted-foreground)'),
320
+ 'data-r1': 'notice',
321
+ }, ip(n.text)));
322
+ }
323
+ return el('div', null, kids);
324
+ }
325
+
326
+ /**
327
+ * overhaul-r1 R4 — the collapsed repositories rail, one glyph per repository, LIVE mode only (the
328
+ * fixture rail is the prototype's and the comparator reads it). Initials + kind badge; a dot when a
329
+ * session on any of its lines of work needs the PM. Click selects WITHOUT expanding the panel. The
330
+ * active glyph is marked by a left bar and a filled box — shape, not colour alone.
331
+ */
332
+ export function railGlyphs() {
333
+ if (FIXTURE) return [];
334
+ const tuples = repoTuples();
335
+ const product = activeProduct(tuples);
336
+ const sess = Array.isArray(state.sessions) ? state.sessions : [];
337
+ return tuples.map((t) => {
338
+ const rp = repoRow(t);
339
+ const name = rp.name;
340
+ const wss = new Set(treesOf(name).map((x) => x.ws));
341
+ const needs = sess.some((x) => x && wss.has(x.ws) && x.shown === 'needs');
342
+ const active = product === name;
343
+ const ini = (String(name).replace(/[^A-Za-z0-9]+/g, ' ').trim().split(' ').filter(Boolean).slice(0, 2)
344
+ .map((w) => w[0]).join('') || String(name).slice(0, 2)).toUpperCase();
345
+ const said = name + ' — ' + rp.kindLabel + ' repository' + (needs ? ' · a session needs you' : '');
346
+ return el('button', {
347
+ onClick: (e) => { if (e && e.stopPropagation) e.stopPropagation(); selectRepo(name); },
348
+ title: said, 'aria-label': said,
349
+ 'data-r1': 'rail-glyph', 'data-repo': name, 'data-active': active ? '1' : '0',
350
+ 'data-ini': ini, 'data-kind': rp.kindGlyph,
351
+ style: 'position:relative;flex:none;width:28px;height:28px;margin:3px auto;display:flex;align-items:center;justify-content:center;'
352
+ + 'font-family:var(--mono);font-size:10px;font-weight:600;border:1px solid ' + (active ? 'var(--brand)' : 'var(--border)')
353
+ + ';background:' + (active ? 'var(--accent)' : 'transparent') + ';color:var(--foreground);'
354
+ + 'box-shadow:' + (active ? 'inset 3px 0 0 var(--brand)' : 'none'),
355
+ hover: 'background:var(--accent)',
356
+ },
357
+ needs ? el('span', { style: 'position:absolute;right:-3px;top:-3px;width:7px;height:7px;border-radius:50%;background:var(--warning, var(--destructive))', 'data-r1': 'needs-dot' }) : null,
358
+ );
359
+ });
360
+ }
361
+
236
362
  /** setDetail (line 1401) — open panel 3 on a detail kind, un-collapsing it if the PM folded it. */
237
363
  const setDetail = (d) => setState({ detail: d, col2: true });
238
364
 
@@ -293,7 +419,7 @@ export function renderRepos(host) {
293
419
 
294
420
  const scroll = el('div', { style: 'flex:1;overflow-y:auto;min-height:0' },
295
421
  stateHealthBanner(),
296
- tuples.map((t) => repoButton(t, product)),
422
+ tuples.map((t) => repoEntry(t, product)),
297
423
  el('button', {
298
424
  onClick: onAddProduct,
299
425
  style: 'display:flex;align-items:center;gap:9px;width:100%;padding:9px 14px;min-height:40px;color:var(--muted-foreground)',
@@ -32,6 +32,7 @@ import * as FX from './fixture.js';
32
32
  import { openTab, openTerm as openTermTab, openDoc as openDocTab } from './panel-initiatives.js';
33
33
  import { mountDoc, disposeDoc, docText } from './tab-doc.js';
34
34
  import { mountTask } from './tab-task.js';
35
+ import { mountWeb, promptWeb } from './tab-web.js';
35
36
  import { newSessionBlock, openNewSession } from './panel-newsess.js';
36
37
  import { boardOf, currentProduct } from './board-read.js';
37
38
 
@@ -300,6 +301,12 @@ const newSessionBtn = el('button', {
300
301
  onClick: () => newSession(), style: CHIP_STYLE, hover: CHIP_HOVER,
301
302
  }, '+ session');
302
303
 
304
+ /* overhaul-r5 (D69) — open a web page in a tab, by typing its address. Live mode only. */
305
+ const newWebBtn = el('button', {
306
+ onClick: () => promptWeb(), style: CHIP_STYLE, hover: CHIP_HOVER,
307
+ title: 'open a web page in a tab (http/https; it loads only when you open it)', 'data-r5': 'new-web',
308
+ }, '🌐 web');
309
+
303
310
  /* `⋯ options` — the demoted composer, restored (workspace-session-preflight).
304
311
  *
305
312
  * `workspace-repo-first`'s accepted PRD says the six-question composer "remains reachable as *more
@@ -661,6 +668,7 @@ function peerRow(prompt, p) {
661
668
  p.doing ? el('div', {
662
669
  style: 'font-family:var(--mono);font-size:10.5px;color:oklch(0.62 0.02 213);overflow:hidden;text-overflow:ellipsis;white-space:nowrap',
663
670
  }, iv(p.doing)) : null,
671
+ p.modelLine ? el('div', { 'data-r12': 'model-line', style: 'font-family:var(--mono);font-size:10.5px;color:oklch(0.62 0.02 213)' }, iv(p.modelLine)) : null,
664
672
  el('div', { style: 'font-size:11px;color:' + RISK_COLOR[p.risk.level] + ';line-height:1.45' }, iv(p.risk.why)),
665
673
  /* WHY THE END DID NOT HAPPEN, in the row the PM was looking at. A refusal recorded in state
666
674
  * and rendered nowhere would be the same silence in a new place — the whole defect here was a
@@ -679,6 +687,18 @@ function peerRow(prompt, p) {
679
687
  onClick: () => joinSession(prompt.slug, p),
680
688
  style: CH_BTN + ';color:var(--brand)', hover: CHIP_HOVER,
681
689
  }, 'open it'),
690
+ // D75 — switch a RUNNING Claude session's model, on a press: PMOS types the harness's own /model command into the pane.
691
+ (p.agent === 'claude' && p.shown !== 'exited') ? el('button', {
692
+ 'data-r12': 'switch-model', title: 'types /model into this pane; effort is not changed',
693
+ onClick: async () => {
694
+ const t = typeof window !== 'undefined' && window.prompt ? window.prompt('Switch “' + p.name + '” to which tier? fast, balanced or deep', 'balanced') : null;
695
+ if (!t || !t.trim()) return;
696
+ const out = await fetch('/api/session/model', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: p.id, tier: t.trim() }) })
697
+ .then((r) => r.json()).catch((e) => ({ ok: false, error: String((e && e.message) || e) }));
698
+ setState({ endRefusal: out && out.ok ? null : { id: p.id, why: 'model not switched — ' + ((out && out.error) || 'no answer') } });
699
+ },
700
+ style: CH_BTN, hover: CHIP_HOVER,
701
+ }, 'switch model') : null,
682
702
  el('button', {
683
703
  onClick: () => {
684
704
  if (!armed) { setState({ endConfirm: p.id, endRefusal: null }); return; }
@@ -772,6 +792,8 @@ function paneFor(tab) {
772
792
  if (termMod && typeof termMod.mountTerm === 'function') termMod.mountTerm(host, tab);
773
793
  } else if (tab.kind === 'task') {
774
794
  mountTask(host, tab);
795
+ } else if (tab.kind === 'web') {
796
+ mountWeb(host, tab); // overhaul-r5 — never falls through to an empty doc tab
775
797
  } else {
776
798
  mountDoc(host, tab);
777
799
  }
@@ -851,6 +873,29 @@ function keepSessionsLive(shown) {
851
873
 
852
874
  /* ── the tab bar ───────────────────────────────────────────────────────────────────────────── */
853
875
 
876
+ /**
877
+ * overhaul-r1 R2.2/R2.3 — the line of work a tab is on, or null when it is the main tree (which
878
+ * shows nothing extra). A doc tab carries its tree from the search hit that opened it; a terminal
879
+ * tab is on whatever tree its session's workspace is. Live mode only; fixture tabs have neither.
880
+ */
881
+ export function tabBranch(t, includeMain) {
882
+ if (FIXTURE || !t || !dataMod) return null;
883
+ let ws = null;
884
+ if (t.kind === 'doc') ws = t.ws || null;
885
+ else if (t.kind === 'term' && t.sessionId) {
886
+ const s = (Array.isArray(state.sessions) ? state.sessions : []).find((x) => x && x.id === t.sessionId);
887
+ ws = s ? s.ws : null;
888
+ }
889
+ if (ws && dataMod.isLinkedTree(ws)) return (t.kind === 'doc' && t.branch) || dataMod.branchOfWs(ws) || 'detached';
890
+ if (!includeMain) return null;
891
+ // the status bar (R2.3) also names the MAIN tree's branch, but only where the repository has other
892
+ // lines of work — in a repository with one tree the branch answers no question anyone asked
893
+ const prod = t.repo || currentProduct();
894
+ const trees = prod && typeof dataMod.productTrees === 'function' ? dataMod.productTrees(prod) : [];
895
+ const main = trees.find((x) => x.main);
896
+ return trees.length > 1 && main ? main.branch : null;
897
+ }
898
+
854
899
  function tabNode(t, act, splitTab) {
855
900
  const selected = (act && act.id === t.id) || (splitTab && splitTab.id === t.id);
856
901
  const bg = selected ? FX.TAB_COLORS.activeBg : 'transparent';
@@ -863,7 +908,7 @@ function tabNode(t, act, splitTab) {
863
908
  * fixture render keeps the design's literal — the comparator reads this text node. */
864
909
  const meta = t.kind === 'term'
865
910
  ? ((!FIXTURE && t.sessionId) ? 'session ' + t.sessionId : 'session')
866
- : (t.kind === 'task' ? 'task' : t.role);
911
+ : (t.kind === 'task' ? 'task' : t.kind === 'web' ? 'web' : t.role);
867
912
 
868
913
  return el('div', {
869
914
  style: 'flex:none;display:flex;align-items:center;gap:8px;padding:0 10px;background:' + bg
@@ -881,7 +926,7 @@ function tabNode(t, act, splitTab) {
881
926
  }, iv(t.title)),
882
927
  el('span', {
883
928
  style: 'font-family:var(--mono);font-size:9px;color:oklch(0.52 0.02 213);max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:1.2',
884
- }, iv(t.slug), ' · ', iv(meta)),
929
+ }, iv(t.slug), ' · ', iv(meta), tabBranch(t) ? el('span', { 'data-r1': 'tab-branch' }, iv(' · ⑂ ' + tabBranch(t))) : null),
885
930
  ),
886
931
  ),
887
932
  el('button', {
@@ -914,6 +959,8 @@ export function renderTabs(host) {
914
959
  setChildren(tabBar, [
915
960
  ...visTabs.map((t) => tabNode(t, act, splitTab)),
916
961
  newSessionBtn,
962
+ FIXTURE ? null : newWebBtn,
963
+ !FIXTURE && state.webNotice ? el('span', { 'data-r5': 'notice', style: 'align-self:center;font-size:11px;color:var(--destructive);padding:0 6px' }, state.webNotice) : null,
917
964
  newSessionOptsBtn,
918
965
  canSplit ? splitBtn : null,
919
966
  ]);
@@ -36,6 +36,9 @@ function initial() {
36
36
  userGroups: {}, userKrs: {}, addedInits: {}, userInits: {}, selTask: null, collapsedPhases: {},
37
37
  detail: { kind: 'init' },
38
38
  tabs: [], active: null, splitId: null,
39
+ // overhaul-r1: reader measure per tab (R5), which repositories show their lines of work (R2.1),
40
+ // and the last remove/rename answer, shown on the row it is about (R3.3)
41
+ docNarrow: {}, treesOpen: {}, repoNotice: null,
39
42
  initLinks: {}, artLinks: {}, artSyncOpen: {}, artSyncState: {},
40
43
  /* The sync half of panel 3, all per-window and none of it persisted. `artSyncTask` holds the
41
44
  * task text the server composed for a direction the PM just chose; `linkGone` holds the
@@ -143,7 +146,12 @@ const isFlagMap = (v) =>
143
146
  * id, so a restored terminal re-attaches to exactly the PTY it was showing rather than re-running
144
147
  * the name join — which is what made a reloaded tab land on a session the PM could not identify. */
145
148
  const TAB_FIELDS = ['id', 'kind', 'title', 'slug', 'repo', 'sess', 'sessionId', 'state', 'profile',
146
- 'path', 'role', 'phase', 'status'];
149
+ 'path', 'role', 'phase', 'status',
150
+ // the line of work a doc tab was opened from (overhaul-r1 R1.3/R2.2) — a reload must keep
151
+ // reading the branch-only file from ITS tree, not fall back to the main tree
152
+ 'ws', 'branch',
153
+ // a web tab's address (overhaul-r5, D69) — restored, re-validated, and loaded only on a PM press
154
+ 'url'];
147
155
  const MAX_TABS = 40;
148
156
 
149
157
  function packTab(t) {
@@ -169,6 +177,8 @@ const PERSIST = {
169
177
  sel: isSlug,
170
178
  collapsedGroups: isFlagMap,
171
179
  collapsedPhases: isFlagMap,
180
+ // per doc tab: true = 72ch measure, false = full width; absent = the viewport default (R5.1)
181
+ docNarrow: isFlagMap,
172
182
  tabs: isTabList,
173
183
  active: isSlug,
174
184
  splitId: isSlug,