create-pmos 0.10.0 → 0.11.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.
@@ -19,7 +19,10 @@ const state = {
19
19
  ws: null, sessions: [], workspaces: [], home: null, openHarness: new Set(),
20
20
  wsId: null, // selected workspace
21
21
  lib: null, // library payload for the selected workspace
22
- lens: 'initiatives', // 'initiatives' | 'folders'
22
+ lens: 'dossier', // the current sub-view: dossier | folders | scheduled | history
23
+ detail: { kind: 'repo', id: null, wsId: null }, // R7 — what the rail is showing, from the selection
24
+ libByWs: {}, // ws id -> library payload, so a dossier can be read for a tree that
25
+ // is NOT selected (R9: opening a dossier is a read, never a checkout)
23
26
  filter: '',
24
27
  active: null, // active session id — always groups[focusG].activeId
25
28
  groups: [{ activeId: null }, { activeId: null }], // terminal groups (R6); [1] exists when split
@@ -44,6 +47,13 @@ const state = {
44
47
  worktrees: [], // the selected workspace's repo's working trees, with removability
45
48
  newBranchMode: false, // new-session form: creating a branch vs picking an existing one
46
49
  sessSig: null, // structural fingerprint of the last session push (see structSig)
50
+ boards: {}, // repo_key -> cached workflow board (lib/board-cache envelope)
51
+ boardWanted: new Set(), // repo_keys a fetch is already in flight for
52
+ boardTries: {}, // repo_key -> re-polls spent waiting for a pending board
53
+ boardsStopped: false, // set on unload — nothing may keep polling a page that is going away
54
+ needsByWs: {}, // ws id -> {count,error} — what a COLLAPSED tree row shows (R12)
55
+ openTrees: new Set(), // trees the PM opened explicitly — beats the derived default (R6)
56
+ closedTrees: new Set(), // …and the ones they closed explicitly
47
57
  };
48
58
 
49
59
  // ── workspace state survives reloads (R7): theme + selected workspace + focused session,
@@ -53,7 +63,12 @@ const store = {
53
63
  set: (k, v) => { try { localStorage.setItem('pmos-ws:' + k, v); } catch {} },
54
64
  };
55
65
  { const t = store.get('theme'); if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t; }
56
- { const l = store.get('lens'); if (['folders', 'initiatives', 'scheduled', 'history'].includes(l)) state.lens = l; }
66
+ // R13 — the lens key stays where it was (global localStorage, not per-workspace) rather than being
67
+ // moved silently; only its vocabulary changes. `initiatives` is retired and maps to the dossier,
68
+ // which is the same intent expressed against the new structure.
69
+ { const l = store.get('lens');
70
+ if (['folders', 'scheduled', 'history', 'dossier'].includes(l)) state.lens = l;
71
+ else if (l === 'initiatives') state.lens = 'dossier'; }
57
72
  // ?ws=<id> is how `pmos` in another repo lands you in THAT repo, not the last one you opened.
58
73
  const urlWs = new URLSearchParams(location.search).get('ws');
59
74
 
@@ -107,7 +122,7 @@ function handle(m) {
107
122
  } else if (m.t === 'workspaces') {
108
123
  state.workspaces = m.workspaces;
109
124
  if (!state.workspaces.some((w) => w.id === state.wsId)) selectWorkspace(state.home, { restoreSession: true });
110
- else renderWorkspaces();
125
+ else { renderWorkspaces(); renderTree(); loadBoards(); }
111
126
  } else if (m.t === 'focus') {
112
127
  // `pmos` was run in another repo while this tab was open — go there, which is the whole
113
128
  // point: the browser refocusing this tab must not strand the PM on the previous repo.
@@ -206,6 +221,7 @@ async function selectWorkspace(id, opts = {}) {
206
221
  if (changed) closeDoc();
207
222
  renderWorkspaces();
208
223
  renderSessions();
224
+ loadBoards();
209
225
  // focus a session in THIS workspace: the remembered one if it belongs here, else its first live one
210
226
  const mine = mySessions();
211
227
  const remembered = opts.restoreSession ? store.get('active') : null;
@@ -215,8 +231,255 @@ async function selectWorkspace(id, opts = {}) {
215
231
  // the scratchpad follows the ACTIVE TAB, and selectSession/clearActive above have already
216
232
  // settled which that is — syncActive() reloaded it, so there is nothing to do here
217
233
  if (changed) { state.branch = null; renderBranch(); state.worktrees = []; renderWorktrees(); loadLayouts(); }
234
+ // R7 — the rail follows the selection. A tree on an initiative branch opens its dossier; anything
235
+ // else falls back to repo detail rather than showing a dossier for an initiative that is not there.
236
+ {
237
+ const w = state.workspaces.find((x) => x.id === id);
238
+ const init = w ? initiativeOf(w) : null;
239
+ if (init && init.id) { state.detail = { kind: 'initiative', id: init.id, wsId: id }; if (state.lens === 'dossier') ensureLib(id); }
240
+ else { state.detail = { kind: 'repo', id: w ? w.repo_key : null, wsId: id }; if (state.lens === 'dossier') state.lens = 'folders'; }
241
+ }
218
242
  await Promise.all([loadLibrary(), loadNeeds(), loadBranch(), loadWorktrees()]);
219
243
  }
244
+ /* ── The tree (R1·R2·R3·R4·R5·R6) ────────────────────────────────────────────────────────────────
245
+ *
246
+ * One structure where there were two panes: REPOSITORY → its working trees → their sessions.
247
+ *
248
+ * A row is a working TREE, labelled by the INITIATIVE on its branch. Those are the same level, not
249
+ * two: git allows exactly one working tree per branch, and every session in a tree shares that
250
+ * branch (lib/git-info.js), so nesting them separately would produce a middle row with exactly one
251
+ * child forever. The DIRECTORY name stays on the row permanently — the shared-tree checkout path
252
+ * moves every session in a repo, which relabels a row under its running sessions, and without a
253
+ * stable identity that reads as a row vanishing and another appearing (R2).
254
+ *
255
+ * Everything a row asserts about workflow is DERIVED and dated. `board` arrives from the cache
256
+ * (lib/board-cache.js), never computed here, and a stage is never drawn without the branch it came
257
+ * from (R5) — on this repo the ladder is scoped to merge-base(origin/main,HEAD)..HEAD, so a shipped
258
+ * initiative reads `spec` on every branch that did not build it.
259
+ */
260
+ const TREE_GLYPH = { working: '●', quiet: '◼', blocked: '✗', idle: '—', exited: '○', none: '·' };
261
+
262
+ // The initiative a branch names, and the record behind it — three cases, all of them stated. A
263
+ // branch with no record is NOT an initiative with a missing file; it is a branch, and the row says
264
+ // so rather than inventing an id (R2).
265
+ function initiativeOf(w) {
266
+ const br = w.branch;
267
+ if (!br) return { kind: 'nobranch', label: w.branch_error ? 'branch unreadable' : 'detached HEAD', id: null };
268
+ const m = /^initiative\/(.+)$/.exec(br);
269
+ if (!m) return { kind: 'branch', label: br, id: null };
270
+ const id = m[1];
271
+ const row = boardRow(w, id);
272
+ if (!row) return { kind: 'norecord', label: id, id };
273
+ return { kind: 'initiative', label: id, id, row };
274
+ }
275
+ // The board for a repository, by group key — one cached read serves every tree under it.
276
+ function boardFor(w) { return state.boards[w.repo_key] || null; }
277
+ function boardRow(w, id) {
278
+ const b = boardFor(w);
279
+ if (!b || !b.data || !Array.isArray(b.data.live)) return null;
280
+ return b.data.live.find((r) => r.id === id) || null;
281
+ }
282
+ // A tree has something to say when it is working, has gone quiet past the threshold, is blocked, or
283
+ // something needs the PM. Rows without a signal collapse to a count (R6). The PM's own toggle always
284
+ // wins — `state.openTrees` holds it, and it is re-applied after every render for the same reason
285
+ // `state.openHarness` is: this pane is rebuilt by innerHTML on every push.
286
+ function treeHasSignal(w) {
287
+ const mine = state.sessions.filter((s) => s.ws === w.id);
288
+ if (state.needsByWs[w.id] && state.needsByWs[w.id].count) return true;
289
+ if (state.needsByWs[w.id] && state.needsByWs[w.id].error) return true;
290
+ const init = initiativeOf(w);
291
+ if (init.row && init.row.blockers && init.row.blockers.length) return true;
292
+ return mine.some((s) => {
293
+ const shown = stateLabel(s).shown;
294
+ return shown === 'working' || shown === 'blocked' || shown === 'exited';
295
+ });
296
+ }
297
+ function treeOpen(w) {
298
+ if (state.openTrees.has(w.id)) return true;
299
+ if (state.closedTrees.has(w.id)) return false;
300
+ return w.id === state.wsId || treeHasSignal(w);
301
+ }
302
+ // The worst thing true of a collapsed tree, so the glyph never reads calmer than the tree is.
303
+ function treeGlyph(w) {
304
+ const mine = state.sessions.filter((s) => s.ws === w.id);
305
+ const n = state.needsByWs[w.id];
306
+ const init = initiativeOf(w);
307
+ if ((n && n.error) || (init.row && init.row.blockers && init.row.blockers.length)) return 'blocked';
308
+ if (mine.some((s) => stateLabel(s).shown === 'blocked')) return 'blocked';
309
+ if (mine.some((s) => stateLabel(s).shown === 'working')) return 'working';
310
+ if (mine.some((s) => stateLabel(s).shown === 'quiet')) return 'quiet';
311
+ if (mine.length) return 'exited';
312
+ return 'idle';
313
+ }
314
+ function renderTree() {
315
+ const host = $('tree');
316
+ if (!host) return;
317
+ // group by repository (R3): `pmos` and `pmos ⑂ initiative/foo` are a parent and its child, not
318
+ // two peers. The key comes from the server — `git worktree list` names the main worktree first.
319
+ const groups = new Map();
320
+ for (const w of state.workspaces) {
321
+ const key = w.repo_key || w.root;
322
+ if (!groups.has(key)) groups.set(key, { key, name: w.repo_name || w.name, trees: [] });
323
+ groups.get(key).trees.push(w);
324
+ }
325
+ const totalNeeds = Object.values(state.needsByWs).reduce((a, v) => a + (v && v.count ? v.count : 0), 0);
326
+ const badRead = Object.values(state.needsByWs).some((v) => v && v.error);
327
+ const nc = $('needCount');
328
+ if (nc) {
329
+ nc.textContent = badRead ? 'needs unreadable' : (totalNeeds ? `${totalNeeds} need you` : '');
330
+ nc.dataset.bad = badRead ? '1' : '0';
331
+ }
332
+
333
+ const html = [...groups.values()].map((g) => {
334
+ const trees = g.trees.map((w) => treeRow(w, g)).join('');
335
+ return `<div class="repo-group" data-repo="${esc(g.key)}">
336
+ <button class="repo-head" data-repo-pick="${esc(g.key)}" title="${esc(g.key)}">
337
+ <span class="cue" aria-hidden="true">▾</span><span class="nm">${esc(g.name)}</span>
338
+ <small>${g.trees.length} tree${g.trees.length === 1 ? '' : 's'}</small>
339
+ </button>${trees}</div>`;
340
+ }).join('');
341
+ host.innerHTML = html || `<div class="empty">No repositories — run <b>pmos</b> inside one.</div>`;
342
+ host.querySelectorAll('[data-pick]').forEach((b) => b.onclick = () => selectWorkspace(b.dataset.pick));
343
+ host.querySelectorAll('[data-repo-pick]').forEach((b) => b.onclick = () => openRepoDetail(b.dataset.repoPick));
344
+ host.querySelectorAll('[data-forget]').forEach((b) => b.onclick = (e) => { e.stopPropagation(); forgetWorkspace(b.dataset.forget); });
345
+ host.querySelectorAll('[data-toggle]').forEach((b) => b.onclick = (e) => {
346
+ e.stopPropagation();
347
+ const id = b.dataset.toggle;
348
+ // an explicit choice is remembered against the derived default, either way
349
+ if (treeOpen(state.workspaces.find((x) => x.id === id) || { id })) { state.openTrees.delete(id); state.closedTrees.add(id); }
350
+ else { state.closedTrees.delete(id); state.openTrees.add(id); }
351
+ renderTree();
352
+ });
353
+ host.querySelectorAll('.sess').forEach((b) => {
354
+ b.onclick = () => selectSession(b.dataset.id);
355
+ b.ondblclick = () => startRename(b.dataset.id);
356
+ b.onkeydown = (e) => { if (e.key === 'F2') { e.preventDefault(); startRename(b.dataset.id); } };
357
+ });
358
+ host.querySelectorAll('[data-rename]').forEach((b) => b.onclick = (e) => { e.stopPropagation(); startRename(b.dataset.rename); });
359
+ host.querySelectorAll('details.hz').forEach((d) => {
360
+ d.open = state.openHarness.has(d.dataset.id);
361
+ d.addEventListener('toggle', () => {
362
+ if (d.open) state.openHarness.add(d.dataset.id); else state.openHarness.delete(d.dataset.id);
363
+ });
364
+ });
365
+ paintRename();
366
+ }
367
+ function treeRow(w, g) {
368
+ const cur = w.id === state.wsId;
369
+ const open = treeOpen(w);
370
+ const init = initiativeOf(w);
371
+ const mine = state.sessions.filter((s) => s.ws === w.id);
372
+ const live = mine.filter((s) => s.state !== 'exited').length;
373
+ const n = state.needsByWs[w.id];
374
+ const gl = treeGlyph(w);
375
+ const board = boardFor(w);
376
+
377
+ // R5 — a stage is never shown without the branch it was derived from, and never without saying
378
+ // how old the derivation is. A board still deriving says so instead of showing nothing.
379
+ let wf = '';
380
+ if (init.row) {
381
+ const r = init.row;
382
+ const blocked = r.blockers && r.blockers.length;
383
+ const nextTxt = blocked ? `✗ ${r.blockers.length} blocked`
384
+ : (r.next && r.next.step ? `→ ${esc(r.next.step)}` : '');
385
+ wf = `<span class="wf"><span class="lane">${esc(r.lane || '')}</span>
386
+ <span class="stg">${esc(r.stage || '')}</span>
387
+ <span class="${blocked ? 'bl' : 'nx'}">${nextTxt}</span></span>`;
388
+ } else if (board && board.pending) {
389
+ wf = `<span class="wf"><span class="drv">deriving the ladder…</span></span>`;
390
+ } else if (board && board.ok === false) {
391
+ wf = `<span class="wf"><span class="bl">ladder unreadable</span></span>`;
392
+ } else if (init.kind === 'norecord') {
393
+ wf = `<span class="wf"><span class="drv">no initiative record</span></span>`;
394
+ } else if (init.kind === 'branch') {
395
+ wf = `<span class="wf"><span class="drv">no initiative</span></span>`;
396
+ }
397
+ // the attribution line: which branch this ladder came from, and as of when
398
+ const stamp = board && board.as_of ? new Date(board.as_of).toLocaleTimeString() : null;
399
+ const attrib = init.row
400
+ ? `<span class="attrib">derived on <b>${esc(w.branch || '?')}</b>${stamp ? ` · as of ${esc(stamp)}` : ''}${board && board.stale ? ' · refreshing' : ''}</span>`
401
+ : '';
402
+
403
+ const sessHtml = open ? mine.map((s) => sessionRow(s)).join('')
404
+ + `<button class="tree-new" data-pick="${esc(w.id)}" title="select this tree, then start a session">+ session here</button>`
405
+ : '';
406
+ const collapsed = !open && mine.length
407
+ ? `<span class="rollup">${mine.length} session${mine.length === 1 ? '' : 's'}${live ? '' : ' · all exited'}</span>` : '';
408
+
409
+ return `<div class="treerow" data-id="${esc(w.id)}" data-open="${open}" data-st="${gl}">
410
+ <div class="tr-head">
411
+ <button class="tr-toggle" data-toggle="${esc(w.id)}" aria-expanded="${open}"
412
+ aria-label="${open ? 'Collapse' : 'Expand'} ${esc(init.label)}"><span class="cue" aria-hidden="true">${open ? '▾' : '▸'}</span></button>
413
+ <button class="tr" data-pick="${esc(w.id)}" aria-current="${cur}" title="${esc(w.root)}">
414
+ <span class="r1"><span class="g" aria-hidden="true">${TREE_GLYPH[gl] || '·'}</span>
415
+ <span class="nm">${esc(init.label)}</span>
416
+ ${w.home ? '<span class="tag">home</span>' : ''}
417
+ <span class="dir">${esc(w.name === g.name ? 'main tree' : w.name)}</span></span>
418
+ <span class="r2">${wf}${n && n.count ? `<span class="needs-n">${n.count} need you</span>` : ''}${n && n.error ? '<span class="bl">needs unreadable</span>' : ''}${collapsed}</span>
419
+ ${attrib}
420
+ </button>
421
+ ${w.home ? '' : `<button class="forget" data-forget="${esc(w.id)}" aria-label="Stop hosting ${esc(w.name)}" title="${live ? 'end its sessions first' : 'stop hosting this repo (the repo is untouched)'}">✕</button>`}
422
+ </div>
423
+ <div class="tr-sessions">${sessHtml}</div>
424
+ </div>`;
425
+ }
426
+ // One session, with everything the old pane carried — nothing about a row's CONTENT changes here,
427
+ // only where it sits (R1). Glyph, state word, quiet duration, open-call age, opening focus, the
428
+ // agent's last words, the harness drill-in and rename all come through unchanged.
429
+ function sessionRow(s) {
430
+ const st = stateLabel(s);
431
+ return `<div class="sessrow" data-id="${s.id}">
432
+ <button class="sess" data-id="${s.id}" data-state="${st.shown}" aria-current="${s.id === state.active}"
433
+ aria-label="${esc(s.name)} — ${esc(st.text)}">
434
+ <span class="r1"><span class="st" aria-hidden="true">${GLYPH[st.shown] || '•'}</span>
435
+ <span class="nm">${esc(s.name)}${s.origin && s.origin.startsWith('schedule:') ? '<span class="schtag">scheduled</span>' : ''}</span>
436
+ <span class="chip">${esc(st.text)}</span></span>
437
+ ${subLine(s)}
438
+ ${saysLine(s)}
439
+ </button>
440
+ <button class="ren" data-rename="${s.id}" aria-label="Rename ${esc(s.name)}" title="rename (F2)">✎</button>
441
+ </div>${harnessDrawer(s)}`;
442
+ }
443
+
444
+ /* The board arrives from the server's cache, one read per REPOSITORY (not per tree, not per row).
445
+ * A cold read answers `pending` in milliseconds and the derivation runs in a child process — it
446
+ * costs ~30s on this repo and is fully synchronous, so it must never sit on a request path. While
447
+ * it is pending the rows say "deriving the ladder…" rather than showing nothing: a blank stage is
448
+ * indistinguishable from "this tree has no initiative", and those are different facts. */
449
+ window.addEventListener('pagehide', () => { state.boardsStopped = true; });
450
+ async function loadBoards() {
451
+ if (state.boardsStopped) return;
452
+ const wanted = new Map();
453
+ for (const w of state.workspaces) if (w.repo_key && !wanted.has(w.repo_key)) wanted.set(w.repo_key, w.id);
454
+ for (const [key, wsId] of wanted) {
455
+ if (state.boardWanted.has(key)) continue;
456
+ state.boardWanted.add(key);
457
+ (async () => {
458
+ try {
459
+ const r = await fetch('/api/console?name=workflow&ws=' + encodeURIComponent(wsId));
460
+ const payload = await r.json();
461
+ state.boards[key] = payload;
462
+ renderTree();
463
+ // pending is not failed — come back for it, rather than leaving a row saying "deriving"
464
+ // forever if the child never lands
465
+ if (payload && payload.pending && !state.boardsStopped) {
466
+ const tries = (state.boardTries[key] || 0) + 1;
467
+ state.boardTries[key] = tries;
468
+ // bounded: a derivation that never lands leaves the row saying so, rather than a request
469
+ // every 3s forever — which in the browser suite showed up as failed requests after teardown
470
+ if (tries <= 40) setTimeout(() => { state.boardWanted.delete(key); loadBoards(); }, 3000);
471
+ else state.boardWanted.delete(key);
472
+ }
473
+ else state.boardWanted.delete(key);
474
+ } catch {
475
+ state.boards[key] = { ok: false, error: 'could not reach the workspace server for the ladder' };
476
+ state.boardWanted.delete(key);
477
+ renderTree();
478
+ }
479
+ })();
480
+ }
481
+ }
482
+
220
483
  function renderWorkspaces() {
221
484
  const html = state.workspaces.map((w) => {
222
485
  const n = state.sessions.filter((s) => s.ws === w.id && s.state !== 'exited').length;
@@ -318,41 +581,9 @@ function stateLabel(s) {
318
581
  return { shown, text: SS.label(shown, { quietMs, call: openCallOf(s), fmtDur: shortDur }) };
319
582
  }
320
583
  function renderSessions() {
321
- const mine = mySessions();
322
- $('sessCount').textContent = String(mine.length);
323
- const needN = state.needs.length;
324
- $('needCount').textContent = needN ? `${needN} need you` : '';
325
- $('sessions').innerHTML = mine.length ? mine.map((s) => {
326
- const st = stateLabel(s);
327
- return `
328
- <div class="sessrow" data-id="${s.id}">
329
- <button class="sess" data-id="${s.id}" data-state="${st.shown}" aria-current="${s.id === state.active}"
330
- aria-label="${esc(s.name)} — ${esc(st.text)}">
331
- <span class="r1"><span class="st" aria-hidden="true">${GLYPH[st.shown] || '•'}</span>
332
- <span class="nm">${esc(s.name)}${s.origin && s.origin.startsWith('schedule:') ? '<span class="schtag">scheduled</span>' : ''}</span>
333
- <span class="chip">${esc(st.text)}</span></span>
334
- ${subLine(s)}
335
- ${saysLine(s)}
336
- </button>
337
- <button class="ren" data-rename="${s.id}" aria-label="Rename ${esc(s.name)}" title="rename (F2)">✎</button>
338
- </div>${harnessDrawer(s)}`;
339
- }).join('')
340
- : `<div class="empty">None running here — <b>+ new session</b> to run an agent in this repo.</div>`;
341
- $('sessions').querySelectorAll('.sess').forEach((b) => {
342
- b.onclick = () => selectSession(b.dataset.id);
343
- b.ondblclick = () => startRename(b.dataset.id);
344
- b.onkeydown = (e) => { if (e.key === 'F2') { e.preventDefault(); startRename(b.dataset.id); } };
345
- });
346
- $('sessions').querySelectorAll('[data-rename]').forEach((b) => b.onclick = () => startRename(b.dataset.rename));
347
- // Restore each drill-in's openness AFTER render. renderSessions() replaces the pane's
348
- // innerHTML on every server push, so a bare <details open> closes itself; openness is held
349
- // in state and re-applied here — the same shape as paintRename() below.
350
- $('sessions').querySelectorAll('details.hz').forEach((d) => {
351
- d.open = state.openHarness.has(d.dataset.id);
352
- d.addEventListener('toggle', () => {
353
- if (d.open) state.openHarness.add(d.dataset.id); else state.openHarness.delete(d.dataset.id);
354
- });
355
- });
584
+ // The rows themselves live in the tree now (R1). This stays as the single entry point every
585
+ // session push already calls, so the tree, the tab strips and the overlay all repaint together.
586
+ renderTree();
356
587
  renderTabs();
357
588
  updateEmptyOverlay();
358
589
  paintRename();
@@ -464,7 +695,7 @@ function structSig(list) {
464
695
  function paintVolatile() {
465
696
  tickQuiet(); // chip, glyph, data-state, accessible name (both surfaces)
466
697
  for (const s of mySessions()) {
467
- const row = $('sessions').querySelector(`.sessrow[data-id="${s.id}"]`);
698
+ const row = document.querySelector(`#tree .sessrow[data-id="${s.id}"]`);
468
699
  if (!row) continue;
469
700
  // the sub-line ("opening focus …" or the agent · cwd fallback) and the agent's last words —
470
701
  // both come from the server and both were frozen at the last lifecycle event. Assign only on a
@@ -480,7 +711,7 @@ function paintVolatile() {
480
711
  } else if (says) { says.remove(); }
481
712
  // the drill-in's rows — model, tokens, tools, skills. Only the <dl> is replaced, so the
482
713
  // <details> element itself (and therefore its open/closed state) is untouched.
483
- const dl = $('sessions').querySelector(`details.hz[data-id="${s.id}"] .kv`);
714
+ const dl = document.querySelector(`#tree details.hz[data-id="${s.id}"] .kv`);
484
715
  if (dl) { const next = harnessRows(s); if (dl.innerHTML !== next) dl.innerHTML = next; }
485
716
  }
486
717
  }
@@ -488,7 +719,7 @@ function paintVolatile() {
488
719
  // accessible name only — never innerHTML — so an open drill-in and an in-flight rename survive.
489
720
  function tickQuiet() {
490
721
  for (const s of mySessions()) {
491
- const btn = $('sessions').querySelector(`.sess[data-id="${s.id}"]`);
722
+ const btn = document.querySelector(`.sess[data-id="${s.id}"]`);
492
723
  if (!btn) continue;
493
724
  const st = stateLabel(s);
494
725
  if (btn.dataset.state !== st.shown) {
@@ -581,7 +812,7 @@ function commitRename() {
581
812
  function paintRename() {
582
813
  const id = state.renaming;
583
814
  if (!id) return;
584
- const row = $('sessions').querySelector(`.sessrow[data-id="${id}"]`);
815
+ const row = document.querySelector(`#tree .sessrow[data-id="${id}"]`);
585
816
  // rename in place wherever the PM double-clicked: the sidebar row, or either group's tab
586
817
  const tab = $(GROUP_TABS[state.focusG]).querySelector(`.stab-wrap[data-id="${id}"]`)
587
818
  || document.querySelector(`.stabs .stab-wrap[data-id="${id}"]`);
@@ -792,16 +1023,217 @@ function refreshLibrary() { loadLibrary(); }
792
1023
  function maybeAutoRefreshLibrary() { if (state.wsId && Date.now() - lastLibLoad > 4000) loadLibrary(); }
793
1024
  window.addEventListener('focus', maybeAutoRefreshLibrary);
794
1025
  document.addEventListener('visibilitychange', () => { if (!document.hidden) maybeAutoRefreshLibrary(); });
1026
+
1027
+ /* ── The rail's detail (R7·R8·R9) ────────────────────────────────────────────────────────────────
1028
+ *
1029
+ * The four standing lenses are replaced by ONE context-driven detail: a repository group gives repo
1030
+ * detail (the complete folder map, its schedules, its history — the three views that were never
1031
+ * initiative-scoped), a tree row gives that initiative's DOSSIER.
1032
+ *
1033
+ * The dossier is the promoted leg-A specimen (prototype/workspace-initiative-nav, Option B r2). Its
1034
+ * one non-obvious rule: THE LANE OWNS THE DENOMINATOR. A chore owes an intent, not a PRD; a
1035
+ * discovery spike owes a brief and nothing else. Measuring every initiative against one universal
1036
+ * slate — r1 of the specimen did exactly that — makes a complete discovery read as nine failures.
1037
+ * `n/a` here is never inferred: it comes from the engine's own step status.
1038
+ */
1039
+ const DOSSIER_GROUPS = [
1040
+ { key: 'strategy', label: 'strategy', roles: ['record', 'brief', 'research', 'scoping', 'intake', 'OKR'] },
1041
+ { key: 'spec', label: 'spec', roles: ['PRD', 'rubric', 'user stories', 'explainer'] },
1042
+ { key: 'design', label: 'design', roles: ['design brief', 'design', 'copy brief', 'copy', 'specimen', 'grilling'] },
1043
+ { key: 'build', label: 'build', roles: ['reconcile', 'harness', 'fixture'] },
1044
+ { key: 'learning', label: 'learning', roles: ['eval', 'findings', 'dogfood', 'rejected'] },
1045
+ ];
1046
+ // Which ladder steps speak for which group, so a group can say "n/a for this lane" in the engine's
1047
+ // own words instead of a guess of our own.
1048
+ const GROUP_STEPS = {
1049
+ strategy: ['route', 'anchor'],
1050
+ spec: ['understand', 'spec', 'prototype'],
1051
+ design: ['prototype'],
1052
+ build: ['priors', 'contract', 'build', 'quality'],
1053
+ learning: ['review', 'explain', 'accept'],
1054
+ };
1055
+
1056
+ function detailWs() {
1057
+ const id = state.detail.wsId || state.wsId;
1058
+ return state.workspaces.find((w) => w.id === id) || null;
1059
+ }
1060
+ // The library for whichever tree the detail is about — NOT necessarily the selected one. Fetched on
1061
+ // demand and cached, because a dossier must be openable for a tree the PM is not standing in.
1062
+ async function ensureLib(wsId) {
1063
+ if (!wsId) return null;
1064
+ if (wsId === state.wsId && state.lib) { state.libByWs[wsId] = state.lib; return state.lib; }
1065
+ if (state.libByWs[wsId]) return state.libByWs[wsId];
1066
+ try {
1067
+ const r = await fetch('/api/library?ws=' + encodeURIComponent(wsId));
1068
+ const j = await r.json();
1069
+ state.libByWs[wsId] = j;
1070
+ return j;
1071
+ } catch { return null; }
1072
+ }
1073
+ /* Open an initiative's dossier. THIS IS A READ: it never switches working tree, never changes
1074
+ * state.wsId, and never touches the branch. Only clicking a tree row does that (R9). */
1075
+ async function openInitiative(id, wsId) {
1076
+ state.detail = { kind: 'initiative', id, wsId: wsId || state.wsId };
1077
+ state.lens = 'dossier';
1078
+ showRail();
1079
+ renderLibrary();
1080
+ await ensureLib(state.detail.wsId);
1081
+ renderLibrary();
1082
+ }
1083
+ function openRepoDetail(repoKey) {
1084
+ state.detail = { kind: 'repo', id: repoKey, wsId: state.wsId };
1085
+ if (state.lens === 'dossier') state.lens = 'folders';
1086
+ renderLibrary();
1087
+ }
1088
+ // The sub-views available for what is currently selected. `dossier` only exists when an initiative
1089
+ // is in view — a dead tab that cannot be filled is worse than an absent one.
1090
+ function renderLensTabs() {
1091
+ const host = $('lensTabs');
1092
+ if (!host) return;
1093
+ const views = state.detail.kind === 'initiative'
1094
+ ? [['dossier', 'dossier'], ['folders', 'folders'], ['scheduled', 'scheduled'], ['history', 'history']]
1095
+ : [['folders', 'folders'], ['scheduled', 'scheduled'], ['history', 'history']];
1096
+ host.innerHTML = views.map(([k, label]) =>
1097
+ `<button role="tab" data-lens="${k}" aria-selected="${state.lens === k}">${esc(label)}</button>`).join('');
1098
+ host.querySelectorAll('button').forEach((b) => b.onclick = () => {
1099
+ state.lens = b.dataset.lens; store.set('lens', state.lens);
1100
+ $('libFind').placeholder = FIND_PLACEHOLDER[state.lens] || 'filter the library…';
1101
+ renderLibrary();
1102
+ });
1103
+ }
1104
+ // R9 — name the repository these documents come from. getWs() silently falls back to the home
1105
+ // workspace on an id it cannot resolve, so a rail that does not say what it is reading can show
1106
+ // another repository's files with no visual difference at all.
1107
+ function renderReading() {
1108
+ const el = $('libWho');
1109
+ if (!el) return;
1110
+ const w = detailWs();
1111
+ if (!w) { el.textContent = ''; return; }
1112
+ const away = w.id !== state.wsId;
1113
+ el.textContent = w.name + (away ? ' · not the selected tree' : '');
1114
+ el.dataset.away = away ? '1' : '0';
1115
+ el.title = w.root;
1116
+ }
1117
+
1118
+ function dossierRow(a) {
1119
+ return `<button class="dz-art" data-doc="${esc(a.path)}">
1120
+ <span class="role">${esc(a.role || 'doc')}</span>
1121
+ <span class="pth">${esc(a.path)}</span></button>`;
1122
+ }
1123
+ function renderDossier() {
1124
+ const host = $('library');
1125
+ const id = state.detail.id;
1126
+ const w = detailWs();
1127
+ const board = w ? boardFor(w) : null;
1128
+ const row = board && board.data && Array.isArray(board.data.live)
1129
+ ? board.data.live.find((r) => r.id === id) : null;
1130
+ const lib = (w && state.libByWs[w.id]) || (w && w.id === state.wsId ? state.lib : null);
1131
+ const group = lib && Array.isArray(lib.initiatives) ? lib.initiatives.find((g) => g.id === id) : null;
1132
+
1133
+ if (board && board.ok === false) {
1134
+ host.innerHTML = csError('the initiative ladder', board.error || 'the board could not be derived');
1135
+ return;
1136
+ }
1137
+ const head = `<div class="dz-head">
1138
+ <div class="dz-id">${esc(id)}</div>
1139
+ ${group && group.title ? `<div class="dz-t">${esc(group.title)}</div>` : ''}
1140
+ <div class="dz-meta">
1141
+ ${row ? `<span class="pill lane">${esc(row.lane || '')}</span>` : ''}
1142
+ ${row ? `<span class="pill">${row.stage === 'accepted' ? 'accepted' : 'stage ' + esc(row.stage || '')}</span>` : ''}
1143
+ ${row && row.anchor ? `<span class="pill">${esc(String(row.anchor).split(/[(—]/)[0].trim())}</span>` : ''}
1144
+ ${row && row.legacy ? '<span class="pill">legacy</span>' : ''}
1145
+ </div>
1146
+ ${row ? dossierAttrib(w, board) : ''}
1147
+ </div>`;
1148
+
1149
+ if (board && board.pending) {
1150
+ host.innerHTML = head + `<div class="dz-wait">Deriving this initiative's ladder…
1151
+ <span class="sub">the derivation shells git once per initiative and runs in a child process,
1152
+ so the workspace stays responsive while it does.</span></div>` + dossierArtifacts(group, null);
1153
+ wireDossier();
1154
+ return;
1155
+ }
1156
+ if (!row) {
1157
+ host.innerHTML = head + `<div class="dz-none">No ladder for <b>${esc(id)}</b> in this repository's board —
1158
+ it has no initiative record, or the record is not readable. The artifacts below are what the
1159
+ library can see; nothing about its position in the loop is being claimed.</div>`
1160
+ + dossierArtifacts(group, null);
1161
+ wireDossier();
1162
+ return;
1163
+ }
1164
+ host.innerHTML = head + dossierNext(row) + dossierArtifacts(group, row);
1165
+ wireDossier();
1166
+ }
1167
+ // R5 in the rail: the same attribution the tree rows carry. A stage without its branch is a claim
1168
+ // the data does not support.
1169
+ function dossierAttrib(w, board) {
1170
+ const stamp = board && board.as_of ? new Date(board.as_of).toLocaleTimeString() : null;
1171
+ return `<div class="dz-attrib">derived on <b>${esc((w && w.branch) || '?')}</b>${stamp ? ` · as of ${esc(stamp)}` : ''}${board && board.stale ? ' · refreshing' : ''}
1172
+ <span class="sub">build and quality are scoped to this branch's commits, not to all of history</span></div>`;
1173
+ }
1174
+ // Accepted and owing render TOGETHER — an initiative can be accepted with steps still open, and a
1175
+ // green badge alone would be the watermelon.
1176
+ function dossierNext(row) {
1177
+ const open = (row.steps || []).filter((s) => s.status === 'pending' || s.status === 'blocked');
1178
+ const accepted = row.stage === 'accepted';
1179
+ if (accepted && open.length) {
1180
+ return `<div class="dz-next owed"><b>still owed · ${open.length} step${open.length === 1 ? '' : 's'}</b>
1181
+ Accepted, and these never closed: ${open.map((s) => esc(s.key)).join(' · ')}.</div>`;
1182
+ }
1183
+ if (row.next && row.next.action) {
1184
+ return `<div class="dz-next"><b>next · ${esc(row.next.step || '')}</b>${esc(row.next.action)}</div>`;
1185
+ }
1186
+ return `<div class="dz-next done"><b>nothing owed</b>Every step this lane asks for is closed.</div>`;
1187
+ }
1188
+ function dossierArtifacts(group, row) {
1189
+ const arts = (group && group.artifacts) || [];
1190
+ const used = new Set();
1191
+ const stepBy = {};
1192
+ for (const st of (row && row.steps) || []) stepBy[st.key] = st;
1193
+
1194
+ const sections = DOSSIER_GROUPS.map((g) => {
1195
+ const mine = arts.filter((a) => g.roles.includes(a.role));
1196
+ mine.forEach((a) => used.add(a.path));
1197
+ // the lane's own verdict on this group, in the engine's words — never our inference
1198
+ const naStep = (GROUP_STEPS[g.key] || []).map((k) => stepBy[k]).filter(Boolean);
1199
+ const allNa = naStep.length && naStep.every((st) => st.status === 'na');
1200
+ if (!mine.length && allNa) {
1201
+ return `<div class="dz-sec"><h5>${esc(g.label)} <span class="c">n/a</span></h5>
1202
+ <div class="dz-na">${esc(naStep[0].evidence || 'not required in this lane')}</div></div>`;
1203
+ }
1204
+ if (!mine.length) {
1205
+ return `<div class="dz-sec"><h5>${esc(g.label)} <span class="c">nothing yet</span></h5>
1206
+ <div class="dz-na">No ${esc(g.label)} artifact is filed for this initiative.</div></div>`;
1207
+ }
1208
+ return `<div class="dz-sec"><h5>${esc(g.label)} <span class="c">${mine.length}</span></h5>
1209
+ ${mine.map(dossierRow).join('')}</div>`;
1210
+ }).join('');
1211
+
1212
+ const rest = arts.filter((a) => !used.has(a.path));
1213
+ const other = rest.length
1214
+ ? `<div class="dz-sec"><h5>other <span class="c">${rest.length}</span></h5>${rest.map(dossierRow).join('')}</div>`
1215
+ : '';
1216
+ const none = !arts.length
1217
+ ? `<div class="dz-na">The library sees no artifacts for this initiative yet. This is a verified
1218
+ empty — the read succeeded.</div>` : '';
1219
+ return `<div class="dz-arts">${sections}${other}${none}</div>`;
1220
+ }
1221
+ function wireDossier() {
1222
+ $('library').querySelectorAll('[data-doc]').forEach((b) => b.onclick = () => openDoc(b.dataset.doc));
1223
+ }
1224
+
795
1225
  function renderLibrary() {
1226
+ renderLensTabs();
1227
+ renderReading();
1228
+ if (state.lens === 'dossier' && state.detail.kind === 'initiative') { renderDossier(); return; }
1229
+ if (state.lens === 'dossier') { state.lens = 'folders'; }
796
1230
  // a third, persistent lens beside initiatives + folders: the scheduled tasks for this workspace
797
1231
  if (state.lens === 'scheduled') {
798
- document.querySelectorAll('.lens button').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.lens === 'scheduled')));
799
1232
  renderScheduled();
800
1233
  return;
801
1234
  }
802
1235
  // a fourth: what past sessions in THIS repo actually printed. The filter box becomes the query.
803
1236
  if (state.lens === 'history') {
804
- document.querySelectorAll('.lens button').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.lens === 'history')));
805
1237
  renderHistory();
806
1238
  return;
807
1239
  }
@@ -810,8 +1242,7 @@ function renderLibrary() {
810
1242
  // BOTH lenses are always available: initiatives (the PMOS projection — works whenever the repo
811
1243
  // has PRDs, even in a nested bundle) and folders (the complete repo map). A repo with no
812
1244
  // initiatives shows an empty-but-honest initiative view — the toggle is never dead, never hidden.
813
- const effLens = state.lens === 'folders' ? 'folders' : 'initiatives';
814
- document.querySelectorAll('.lens button').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.lens === effLens)));
1245
+ const effLens = 'folders';
815
1246
  if (!lib) { host.innerHTML = `<div class="empty">Library unavailable.</div>`; return; }
816
1247
  const f = state.filter.trim().toLowerCase();
817
1248
  const hit = (s) => !f || String(s).toLowerCase().includes(f);
@@ -1005,13 +1436,19 @@ function applyLayout() {
1005
1436
 
1006
1437
  // folds
1007
1438
  paintFold('paneWs', 'wsFold', l.folds.ws);
1008
- paintFold('paneSess', 'sessFold', l.folds.sess);
1439
+ // `sess` retired with the pane it folded (R1/R13); a stored value is ignored, not migrated.
1009
1440
  paintFold('paneLib', 'libFold', l.folds.lib);
1010
1441
  paintFold('paneDoc', 'docFold', l.folds.doc);
1011
1442
 
1012
1443
  // split is a desktop arrangement; below 1100px two groups are two unusable terminals (R9)
1013
1444
  applySplit(l.split === 'on' && !NARROW.matches);
1014
1445
 
1446
+ // The library/document divider is part of the ARRANGEMENT (R8), driven from the tuple. It used
1447
+ // to be "a client preference like the theme" in a global localStorage key — which R8 and rubric
1448
+ // dimension 1 both contradict: they require it carried per workspace and the old key not re-read
1449
+ // on later loads. `libsplit` is now read exactly once, by migrateLegacy.
1450
+ document.documentElement.style.setProperty('--libsplit', l.libSplit + '%');
1451
+
1015
1452
  $('layName').textContent = l.name;
1016
1453
  renderLayoutMenu();
1017
1454
  refit();
@@ -1059,7 +1496,13 @@ async function loadLayouts() {
1059
1496
  const r = await fetch('/api/layouts?ws=' + encodeURIComponent(state.wsId || ''));
1060
1497
  doc = await r.json();
1061
1498
  } catch { doc = null; }
1062
- if (!doc || !doc.ok || !Array.isArray(doc.layouts) || !doc.layouts.length) {
1499
+ // A read failure must never author a write. loadAction() is the one place that decides, and it
1500
+ // only says 'migrate' when the SERVER affirmatively reports nothing stored — never because the
1501
+ // answer was unreadable. Anything unusable is 'keep': show the boot defaults, persist nothing,
1502
+ // and leave whatever is on disk exactly as it is until a load succeeds.
1503
+ const act = LAY.loadAction(doc);
1504
+ if (act === 'keep') return;
1505
+ if (act === 'migrate') {
1063
1506
  // R8 — a workspace with nothing stored inherits whatever the PM had already tuned by hand
1064
1507
  // in the pre-feature client, so the upgrade never costs them their rail width.
1065
1508
  const legacy = {};
@@ -1153,9 +1596,34 @@ function renderLayoutMenu() {
1153
1596
  if (!l) return;
1154
1597
  openLayNew(l.name, l.id);
1155
1598
  });
1599
+ renderLayoutPositions();
1156
1600
  const w = state.workspaces.find((x) => x.id === state.wsId);
1157
1601
  if ($('layScope')) $('layScope').textContent = w ? w.name : 'this repo';
1158
1602
  }
1603
+
1604
+ /* R12 — keyboard parity with the drag. Every value `side` and `reader` can hold is a real button
1605
+ here, so no position is pointer-only. This is what makes the "no dead ends" property true rather
1606
+ than merely argued: from ANY arrangement, including side:off + reader:off, the header control is
1607
+ in the bar (never inside the grid it rearranges), and these buttons bring a pane back. */
1608
+ const POS_GROUPS = [
1609
+ { key: 'side', label: 'sidebar', values: [['left', '◧'], ['right', '◨'], ['off', '∅']] },
1610
+ { key: 'reader', label: 'reader', values: [['left', '◧'], ['right', '◨'], ['bottom', '⬓'], ['off', '∅']] },
1611
+ ];
1612
+ function renderLayoutPositions() {
1613
+ const host = $('layPos');
1614
+ if (!host) return;
1615
+ const l = curLayout();
1616
+ host.innerHTML = POS_GROUPS.map((g) => `
1617
+ <div class="pos-row" role="group" aria-label="${g.label} position">
1618
+ <span class="pos-lbl">${g.label}</span>
1619
+ ${g.values.map(([v, glyph]) => `<button class="pos-btn" data-pos="${g.key}" data-val="${v}"
1620
+ aria-pressed="${l[g.key] === v}" title="${g.label} ${v}"
1621
+ aria-label="${g.label} ${v}"><i aria-hidden="true">${glyph}</i><span>${v}</span></button>`).join('')}
1622
+ </div>`).join('');
1623
+ host.querySelectorAll('[data-pos]').forEach((b) => b.onclick = () => {
1624
+ tweak({ [b.dataset.pos]: b.dataset.val }); // menu stays open: adjusting is not choosing
1625
+ });
1626
+ }
1159
1627
  function openLayMenu() { $('layMenu').hidden = false; $('layBtn').setAttribute('aria-expanded', 'true'); renderLayoutMenu(); }
1160
1628
  function closeLayMenu() { $('layMenu').hidden = true; $('layBtn').setAttribute('aria-expanded', 'false'); closeLayNew(); }
1161
1629
  function openLayNew(value, renameId) {
@@ -1193,7 +1661,7 @@ function newLayoutName() {
1193
1661
 
1194
1662
  // ── folds: both columns, same behaviour (R11) ──
1195
1663
  function foldPane(which, open) {
1196
- const map = { doc: 'paneDoc', lib: 'paneLib', ws: 'paneWs', sess: 'paneSess' };
1664
+ const map = { doc: 'paneDoc', lib: 'paneLib', ws: 'paneWs' };
1197
1665
  if (!map[which]) return;
1198
1666
  const l = curLayout();
1199
1667
  const folds = Object.assign({}, l.folds);
@@ -1205,7 +1673,6 @@ function showRail() { if (curLayout().reader === 'off') tweak({ reader: 'right'
1205
1673
  $('libFold').onclick = () => foldPane('lib', $('paneLib').dataset.fold === 'off');
1206
1674
  $('docFold').onclick = () => foldPane('doc', $('paneDoc').dataset.fold === 'off');
1207
1675
  $('wsFold').onclick = () => foldPane('ws', $('paneWs').dataset.fold === 'off');
1208
- $('sessFold').onclick = () => foldPane('sess', $('paneSess').dataset.fold === 'off');
1209
1676
 
1210
1677
  // ── the edges: drag a column's outer edge to resize it. Keeps to the layout, like everything
1211
1678
  // else — a width the PM chose is part of the arrangement, not a separate preference. ──
@@ -1290,9 +1757,15 @@ wireEdge($('sideEdge'));
1290
1757
  });
1291
1758
  g.addEventListener('pointermove', move);
1292
1759
  g.addEventListener('pointerup', end);
1293
- // keyboard: the grip is a button that opens the same menu the drag reaches (R12)
1760
+ // keyboard: the grip opens the layout menu and lands focus on the position controls, which can
1761
+ // reach every value the drag can (R12). It previously focused #layBtn — a menu whose only
1762
+ // contents were layout rows, so Enter here could not actually move anything.
1294
1763
  g.addEventListener('keydown', (e) => {
1295
- if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openLayMenu(); $('layBtn').focus(); }
1764
+ if (e.key !== 'Enter' && e.key !== ' ') return;
1765
+ e.preventDefault();
1766
+ openLayMenu();
1767
+ const first = $('layPos') && $('layPos').querySelector(`[data-pos="${g.dataset.pane === 'side' ? 'side' : 'reader'}"]`);
1768
+ (first || $('layBtn')).focus();
1296
1769
  });
1297
1770
  });
1298
1771
  })();
@@ -1321,38 +1794,37 @@ document.addEventListener('pointerdown', (e) => {
1321
1794
 
1322
1795
  applyLayout(); // paint the default arrangement before the server's layouts arrive
1323
1796
 
1324
- // the library/document divider is INSIDE the reader, not part of the arrangement — it stays a
1325
- // client preference like the theme, and keeps its original storage
1326
- { const s = store.get('libsplit'); if (s) document.documentElement.style.setProperty('--libsplit', s); }
1327
-
1328
- // drag the divider between the library and the document
1797
+ // drag the divider between the library and the document. It is part of the arrangement (R8), so
1798
+ // it is kept against the current layout like every other adjustment — not in a global client key.
1329
1799
  (() => {
1330
1800
  const split = $('railSplit');
1331
1801
  let dragging = false;
1332
1802
  const apply = (clientY) => {
1333
1803
  const r = $('rail').getBoundingClientRect();
1334
- const pct = Math.min(80, Math.max(15, ((clientY - r.top) / r.height) * 100));
1335
- const v = pct.toFixed(1) + '%';
1336
- document.documentElement.style.setProperty('--libsplit', v);
1337
- store.set('libsplit', v);
1804
+ const pct = Math.min(LAY.LIB_MAX, Math.max(LAY.LIB_MIN, ((clientY - r.top) / r.height) * 100));
1805
+ tweak({ libSplit: Math.round(pct * 10) / 10 });
1338
1806
  };
1339
1807
  split.addEventListener('pointerdown', (e) => { dragging = true; split.setPointerCapture(e.pointerId); e.preventDefault(); });
1340
1808
  split.addEventListener('pointermove', (e) => { if (dragging) apply(e.clientY); });
1341
1809
  split.addEventListener('pointerup', (e) => { dragging = false; try { split.releasePointerCapture(e.pointerId); } catch {} refit(); });
1342
1810
  split.addEventListener('keydown', (e) => {
1343
- const cur = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--libsplit')) || 46;
1344
- if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
1345
- e.preventDefault();
1346
- const v = Math.min(80, Math.max(15, cur + (e.key === 'ArrowUp' ? -4 : 4))).toFixed(1) + '%';
1347
- document.documentElement.style.setProperty('--libsplit', v); store.set('libsplit', v); refit();
1348
- }
1811
+ if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
1812
+ e.preventDefault();
1813
+ const cur = curLayout().libSplit;
1814
+ tweak({ libSplit: Math.round(Math.min(LAY.LIB_MAX, Math.max(LAY.LIB_MIN, cur + (e.key === 'ArrowUp' ? -4 : 4))) * 10) / 10 });
1349
1815
  });
1350
1816
  })();
1351
1817
 
1352
1818
  // ── needs-you + gates (the PMOS workflow: Review/Acceptance in file mode, never automatic) ──
1353
1819
  async function loadNeeds() {
1354
- try { const r = await fetch('/api/needs?ws=' + encodeURIComponent(state.wsId || '')); state.needs = (await r.json()).needs || []; }
1355
- catch { state.needs = []; }
1820
+ try {
1821
+ const r = await fetch('/api/needs?ws=' + encodeURIComponent(state.wsId || ''));
1822
+ const j = await r.json();
1823
+ state.needs = j.needs || [];
1824
+ // R12 — what a COLLAPSED tree row shows. R10 — the count comes from the strict reader, so a
1825
+ // corrupt queue arrives as {count:null,error}, never as a calm zero.
1826
+ state.needsByWs = j.by_ws || {};
1827
+ } catch { state.needs = []; state.needsByWs = {}; }
1356
1828
  renderNeeds(); renderSessions();
1357
1829
  }
1358
1830
  function renderNeeds() {
@@ -1986,11 +2458,6 @@ $('fsPicker').addEventListener('click', (e) => { if (e.target === $('fsPicker'))
1986
2458
 
1987
2459
  // ── library lens + filter ──
1988
2460
  const FIND_PLACEHOLDER = { history: 'search past sessions…', scheduled: 'filter the library…' };
1989
- document.querySelectorAll('.lens button').forEach((b) => b.onclick = () => {
1990
- state.lens = b.dataset.lens; store.set('lens', state.lens);
1991
- $('libFind').placeholder = FIND_PLACEHOLDER[state.lens] || 'filter the library…';
1992
- renderLibrary();
1993
- });
1994
2461
  $('libFind').addEventListener('input', (e) => { state.filter = e.target.value; renderLibrary(); });
1995
2462
  $('libRefresh').onclick = () => {
1996
2463
  const b = $('libRefresh'); b.classList.add('spin');
@@ -2301,6 +2768,10 @@ function sfHead(active, ok) {
2301
2768
  ${chip}</div>`;
2302
2769
  }
2303
2770
  function wireSurface() {
2771
+ // the workflow surface's initiative ids open the DOSSIER (R9, route 2) — a read, not a checkout
2772
+ document.querySelectorAll('#docBody .wf-id[data-init]').forEach((b) => b.onclick = (e) => {
2773
+ e.preventDefault(); openInitiative(b.dataset.init, state.wsId);
2774
+ });
2304
2775
  document.querySelectorAll('[data-surface]').forEach((b) => b.onclick = () => openSurface(b.dataset.surface));
2305
2776
  document.querySelectorAll('[data-open-surface]').forEach((b) => b.onclick = () => openSurface(b.dataset.openSurface));
2306
2777
  $('docBody').querySelectorAll('.sf-art[data-doc],.wf-id[data-doc]').forEach((b) => b.onclick = () => openDoc(b.dataset.doc));
@@ -2370,10 +2841,31 @@ function sfRoadmap(s) {
2370
2841
  </div>`).join('') + (s.note ? `<div class="sf-note">${esc(s.note)}</div>` : '');
2371
2842
  }
2372
2843
  function sfOkr(s) {
2373
- return (s.objectives || []).map((o) => `<div class="sf-kr"><div class="obj">${esc(o.id)} · ${esc(o.name)}</div>
2844
+ // A FAILED read renders RED and nothing else — never a list that silently omits what could not be
2845
+ // parsed. Reusing .cs-error deliberately: the workspace has exactly one way to say "broken read",
2846
+ // and a second visual vocabulary for the same meaning is how one of them ends up looking calm.
2847
+ if (s.unreadable) return csError('okr', s.unreadable);
2848
+
2849
+ const h = s.anchor_health;
2850
+ // Unresolvable ANCHORS are the defect this surface exists to expose: an anchor naming a KR no
2851
+ // document declares sat unseen for two days precisely because nothing rendered it. It is RED, and
2852
+ // it is placed ABOVE the objectives so it cannot be scrolled past.
2853
+ const bad = (h && h.unresolvable) || [];
2854
+ const anchorBand = bad.length
2855
+ ? `<div class="cs-error" role="alert">
2856
+ <div class="cs-err-head"><span class="mk" aria-hidden="true">▲</span>
2857
+ <b>${bad.length} anchor${bad.length > 1 ? 's' : ''} name${bad.length > 1 ? '' : 's'} something these documents don’t declare</b></div>
2858
+ ${bad.map((u) => `<p class="cs-err-why"><b>${esc(u.initiative)}</b> — <code>${esc(u.raw)}</code><br>${esc(u.reason)}</p>`).join('')}
2859
+ <p class="cs-err-note">An anchor that resolves to nothing is not an anchor. Either write one the
2860
+ OKR documents declare, or add the KR — adding a KR is the PM’s act, never an agent’s.</p></div>`
2861
+ : (h ? `<div class="sf-pulse"><span class="n"><b>${h.checked}</b> anchors resolve</span>
2862
+ <span class="sf-legend"><span>every non-empty parent_kr names an objective or KR written in these documents${h.empty ? ` · ${h.empty} unanchored` : ''}</span></span></div>` : '');
2863
+
2864
+ const body = (s.objectives || []).map((o) => `<div class="sf-kr"><div class="obj">${esc(o.id)} · ${esc(o.name)}</div>
2374
2865
  ${(o.krs || []).map((k) => `<div class="krrow"><div class="krh"><span class="lab">${esc(k.id)}</span><span>${esc(k.name)}</span></div>
2375
2866
  <div class="lev"><span class="untr">current · target · levers — not tracked</span></div></div>`).join('') || '<span class="untr">no KRs parsed</span>'}
2376
- </div>`).join('') + (s.note ? `<div class="sf-note">${esc(s.note)}</div>` : '');
2867
+ </div>`).join('');
2868
+ return anchorBand + body + (s.note ? `<div class="sf-note">${esc(s.note)}</div>` : '');
2377
2869
  }
2378
2870
  function sfReport(s) {
2379
2871
  return `<div class="sf-rep"><p class="lead">${esc(s.cadence)} report · derived on demand</p>
@@ -2457,7 +2949,7 @@ function csWorkflow(d) {
2457
2949
  `<span class="wf-st ${i < at ? 'past' : i === at ? 'at' : ''}">${esc(s)}</span>`).join('<i class="wf-sep" aria-hidden="true"></i>');
2458
2950
  return `<div class="wf-card" data-blocked="${r.blockers.length ? '1' : '0'}">
2459
2951
  <div class="wf-top">
2460
- <button class="wf-id" data-doc="${esc(r.record)}" title="open the initiative record">${esc(r.id)}</button>
2952
+ <button class="wf-id" data-init="${esc(r.id)}" data-doc="${esc(r.record)}" title="open the initiative record">${esc(r.id)}</button>
2461
2953
  <span class="wf-lane">${esc(r.lane)}</span>
2462
2954
  ${r.in_change ? '<span class="wf-here">this change</span>' : ''}
2463
2955
  <span class="wf-anchor" title="${esc(r.anchor || '')}">${esc((r.anchor || 'unanchored').split(/[(—]/)[0].trim())}</span>
@@ -2748,9 +3240,7 @@ function choosePalette(n) {
2748
3240
  else if (it.kind === 'workspace') selectWorkspace(it.wsid);
2749
3241
  else if (it.kind === 'initiative') {
2750
3242
  // jump to the initiative's primary artifact (PRD first — its own ordering guarantees it)
2751
- const first = it.init.artifacts[0];
2752
- state.lens = 'initiatives'; state.filter = it.init.id; $('libFind').value = it.init.id; renderLibrary();
2753
- if (first) openDoc(first.path);
3243
+ openInitiative(it.init.id, state.wsId);
2754
3244
  } else openDoc(it.path);
2755
3245
  }
2756
3246
  $('palInput')?.addEventListener('input', (e) => renderPalette(e.target.value));