create-pmos 0.14.0 → 0.15.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.
@@ -15,6 +15,10 @@ const $ = (id) => document.getElementById(id);
15
15
  const FitAddon = (window.FitAddon && window.FitAddon.FitAddon) || null;
16
16
  const WebLinks = (window.WebLinksAddon && window.WebLinksAddon.WebLinksAddon) || null;
17
17
 
18
+ // workspace-pm-spine — the spine is the PRIMARY navigator. `?nav=legacy` falls back to the
19
+ // repo→tree→session view for rollback only; the rubric's FAIL condition is the two shipping side by
20
+ // side with NEITHER declared primary, so the default is not a toggle, it is the answer.
21
+ const SPINE_ON = new URLSearchParams(location.search).get('nav') !== 'legacy';
18
22
  const state = {
19
23
  ws: null, sessions: [], workspaces: [], home: null, openHarness: new Set(),
20
24
  wsId: null, // selected workspace
@@ -24,6 +28,13 @@ const state = {
24
28
  libByWs: {}, // ws id -> library payload, so a dossier can be read for a tree that
25
29
  // is NOT selected (R9: opening a dossier is a read, never a checkout)
26
30
  filter: '',
31
+ // workspace-pm-spine — the PM structure for the SELECTED repo: objectives → KRs → initiatives,
32
+ // plus health budgets as peers and the unanchored bucket. null while unread; {ok:false,error}
33
+ // renders RED, never a green empty tree (R5).
34
+ spine: null,
35
+ spineFor: null,
36
+ radar: null, // Review-Gate verdicts for the selected repo (evals.jsonl)
37
+ radarFor: null, // which repo_key `spine` was read for, so a stale one is never shown
27
38
  active: null, // active session id — always groups[focusG].activeId
28
39
  groups: [{ activeId: null }, { activeId: null }], // terminal groups (R6); [1] exists when split
29
40
  focusG: 0, // which group the keyboard, ⌘J send and rename act on
@@ -54,8 +65,79 @@ const state = {
54
65
  needsByWs: {}, // ws id -> {count,error} — what a COLLAPSED tree row shows (R12)
55
66
  openTrees: new Set(), // trees the PM opened explicitly — beats the derived default (R6)
56
67
  closedTrees: new Set(), // …and the ones they closed explicitly
68
+ // Which of the seven destinations column 1 is showing. This is a MODE, not a navigation event:
69
+ // the menu swaps what the column lists, and the selection in column 2 follows from it.
70
+ mode: 'product',
71
+ q: '', // the column-1 search box — narrows the mode AND finds elsewhere
72
+ col1: true, col2: true, // expanded, or collapsed to a 36px labelled rail
73
+ viewers: [], // open viewer tabs: {kind:'doc'|'surface', key, title}
74
+ viewActive: null, // the viewer tab on screen, or null when a terminal is
75
+ sched: null, // the scheduler's list — null while unread, {error} if the read failed
76
+ schedLoading: false,
77
+ // `<repo_key>::<id>` -> the ladder for ONE initiative. The board caps itself at what is in
78
+ // flight, so the panel cannot read a named initiative's position out of it — 93 of 105 records
79
+ // here are outside that cap, and treating "not in the cap" as "no record" is a false reading.
80
+ ladders: {},
81
+ ladderWanted: new Set(),
82
+ // workspace-focus — the cross-product queue. `focusSeq` is the stale-paint guard: focus ignores
83
+ // `ws` entirely, so there is no key to compare against; a slower read is beaten by a newer one on
84
+ // sequence alone. null while unread, {ok:false,error} renders RED.
85
+ focus: null,
86
+ focusSeq: 0,
87
+ focusOpen: false,
88
+ // workspace-profiles — the /api/profiles payload, verbatim. null while unread; `absent`, an empty
89
+ // `profiles[]` and `{ok:false}` are three different facts and none may stand in for another.
90
+ profiles: null,
91
+ profilesLoading: false,
92
+ newProfile: '', // the composer's pick: a descriptor id, or '' for no profile
93
+ profilePrefWrote: null, // the last agent whose preference was written, for the panel's line
94
+ // workspace-repo-ingest — open proposals, pushed over the socket as they advance. Never a
95
+ // count of what a repo "has": a proposal is a reading of a clone at a stated moment.
96
+ ingest: [],
97
+ ingestRead: false,
98
+ // workspace-jira-handoff — `<repo_key>::<id>` -> the record's declared handoff links.
99
+ handoffs: {},
100
+ handoffWanted: new Set(),
101
+ workflows: null, // declared templates for the selected repo
102
+ workflowsFor: null,
103
+ // workspace-mobile-surface — {dest, stack[]}. The deeper stack is deliberately never persisted.
104
+ mnav: null,
105
+ // workspace-product-switcher — the ws whose spine read still owes us a landing (R4 clause 2).
106
+ landPending: null,
107
+ addSrc: 'local', // which source the add-a-repository form is on
57
108
  };
58
109
 
110
+ // The seven destinations, in the PM's order. `data` says where the list comes from, and it is the
111
+ // honest part of this table: `real` modes read the repository, `derived` ones are computed from
112
+ // state PMOS already holds, and `absent` ones have no file behind them yet — those render a
113
+ // stated absence rather than a plausible-looking sample. Faking one would make the workspace lie
114
+ // about what the repo contains, which is the single thing the honest-state contract rules out.
115
+ const MODES = [
116
+ { k: 'product', g: '◧', label: 'product', title: 'initiatives', data: 'real' },
117
+ { k: 'scheduled', g: '◔', label: 'scheduled', title: 'scheduled tasks', data: 'real' },
118
+ { k: 'workflow', g: '▤', label: 'workflow', title: 'workflows', data: 'derived' },
119
+ { k: 'skills', g: '◇', label: 'skills', title: 'skills', data: 'real' },
120
+ { k: 'knowledge', g: '◈', label: 'knowledge', title: 'knowledge', data: 'real' },
121
+ { k: 'agent', g: '◐', label: 'agent', title: 'agents', data: 'derived' },
122
+ { k: 'memory', g: '◒', label: 'memory', title: 'memory', data: 'absent' },
123
+ ];
124
+ const MODE_BY_K = {}; MODES.forEach((m) => { MODE_BY_K[m.k] = m; });
125
+
126
+ /* ── workspace-mobile-surface · the navigation reducer ─────────────────────────────────────────
127
+ * Lives in lib/mobile-nav.js, loaded before this file, and reached here through the same
128
+ * window.PMOS* convention lib/layout.js uses. It is DOM-free on purpose: the rubric asks for a
129
+ * pure module so test.js can walk the whole domain in node without a browser, and a reducer that
130
+ * cannot be unit-tested is one whose exclusivity guarantee is only ever checked by eye. */
131
+ const MOBILE_DESTS = PMOSMobileNav.DESTS;
132
+ const MOBILE_DEST = PMOSMobileNav.DEST_FOR_MODE;
133
+ const MOBILE_ROOT = PMOSMobileNav.ROOTS;
134
+ const mobScreen = PMOSMobileNav.screen, mobRootOf = PMOSMobileNav.root;
135
+ const mobColumnOf = PMOSMobileNav.columnOf, mobDestForMode = PMOSMobileNav.destForMode;
136
+ const mobTitleOf = PMOSMobileNav.titleOf, mobNavInit = PMOSMobileNav.init;
137
+ const mobPush = PMOSMobileNav.push, mobPop = PMOSMobileNav.pop;
138
+ const mobSwitchDest = PMOSMobileNav.switchDest, mobCurrentOf = PMOSMobileNav.current;
139
+ const mobMigrateView = PMOSMobileNav.migrateView;
140
+
59
141
  // ── workspace state survives reloads (R7): theme + selected workspace + focused session,
60
142
  // and now the rail/pane layout the PM chose (R12). ──
61
143
  const store = {
@@ -69,6 +151,12 @@ const store = {
69
151
  { const l = store.get('lens');
70
152
  if (['folders', 'scheduled', 'history', 'dossier'].includes(l)) state.lens = l;
71
153
  else if (l === 'initiatives') state.lens = 'dossier'; }
154
+ // The mode and the two column-collapse states survive a reload for the same reason the layout
155
+ // does: they are an arrangement the PM chose, and re-deciding it on every boot is a chore.
156
+ { const m = store.get('mode'); if (MODE_BY_K[m]) state.mode = m; }
157
+ { const c1 = store.get('col1'), c2 = store.get('col2');
158
+ if (c1 === 'off') state.col1 = false;
159
+ if (c2 === 'off') state.col2 = false; }
72
160
  // ?ws=<id> is how `pmos` in another repo lands you in THAT repo, not the last one you opened.
73
161
  const urlWs = new URLSearchParams(location.search).get('ws');
74
162
 
@@ -104,7 +192,18 @@ function setConn(s) {
104
192
  const el = $('conn');
105
193
  el.className = 'conn ' + s;
106
194
  el.textContent = s === 'live' ? 'LIVE · local' : 'reconnecting…';
107
- $('wsFoot').textContent = s === 'live' ? 'local · persistent server · sessions running' : 'server unreachable — retrying';
195
+ paintFoot(s);
196
+ }
197
+ // "sessions running" with no number was a label pretending to be a reading. The count is of LIVE
198
+ // sessions across every hosted tree — exited ones are still listed in the navigator but are not
199
+ // running, and counting them would overstate what the machine is doing.
200
+ let lastConn = 'live';
201
+ function paintFoot(s) {
202
+ if (s) lastConn = s;
203
+ const el = $('wsFoot'); if (!el) return;
204
+ if (lastConn !== 'live') { el.textContent = 'server unreachable — retrying'; return; }
205
+ const n = state.sessions.filter((x) => x.state !== 'exited').length;
206
+ el.textContent = `local · persistent server · ${n} session${n === 1 ? '' : 's'} running`;
108
207
  }
109
208
 
110
209
  // sessions belonging to the selected workspace — the whole UI reads through this
@@ -119,10 +218,24 @@ function handle(m) {
119
218
  const pick = [urlWs, remembered, m.home].find((id) => id && m.workspaces.some((w) => w.id === id))
120
219
  || (m.workspaces[0] && m.workspaces[0].id);
121
220
  selectWorkspace(pick, { restoreSession: true });
221
+ // Three reads that are NOT per-workspace and must not ride a per-workspace path: focus joins
222
+ // every registered repository, the profile descriptors are a property of this machine, and an
223
+ // open ingest proposal belongs to a clone rather than to a tree.
224
+ loadFocus();
225
+ loadProfiles();
226
+ loadIngest();
122
227
  } else if (m.t === 'workspaces') {
123
228
  state.workspaces = m.workspaces;
124
229
  if (!state.workspaces.some((w) => w.id === state.wsId)) selectWorkspace(state.home, { restoreSession: true });
125
- else { renderWorkspaces(); renderTree(); loadBoards(); }
230
+ else { renderProducts(); renderTree(); loadBoards(); }
231
+ loadFocus(); // a repository appearing or leaving changes what is cross-product true
232
+ } else if (m.t === 'ingest') {
233
+ // workspace-repo-ingest — one stage transition. The proposal arrives whole, so nothing here
234
+ // merges fields: a partial update is how a screen ends up half-describing two different scans.
235
+ const i = state.ingest.findIndex((x) => x.id === m.id);
236
+ if (m.proposal) { if (i >= 0) state.ingest[i] = m.proposal; else state.ingest.push(m.proposal); }
237
+ renderTree(); // the spine's `ingested` group follows it
238
+ if (state.detail.kind === 'ingest' && state.detail.id === m.id) renderLibrary();
126
239
  } else if (m.t === 'focus') {
127
240
  // `pmos` was run in another repo while this tab was open — go there, which is the whole
128
241
  // point: the browser refocusing this tab must not strand the PM on the previous repo.
@@ -144,7 +257,13 @@ function handle(m) {
144
257
  const structural = sig !== state.sessSig;
145
258
  state.sessSig = sig;
146
259
  state.sessions = m.sessions;
147
- if (structural) { renderSessions(); renderWorkspaces(); } else { paintVolatile(); }
260
+ if (structural) { renderSessions(); renderProducts(); } else { paintVolatile(); }
261
+ // R12 — focus is re-read on a STRUCTURAL session change only (a session appeared, exited or
262
+ // was renamed), never on the liveness heartbeat, and only while its panel is on screen. The
263
+ // panel's `delegated` section is derived from sessionList(), so leaving it frozen would be a
264
+ // stale reading; re-reading it twice a second would put a ~O(records) spine read on a cadence.
265
+ if (structural && state.focusOpen) loadFocus();
266
+ if (structural) paintProfileLines();
148
267
  if (state.active && !ids.has(state.active)) {
149
268
  const mine = mySessions();
150
269
  const nx = mine.find((s) => s.state !== 'exited') || mine[mine.length - 1];
@@ -218,11 +337,17 @@ async function selectWorkspace(id, opts = {}) {
218
337
  const changed = id !== state.wsId;
219
338
  state.wsId = id;
220
339
  store.set('wsid', id);
340
+ // R10 — the switcher's most-recent-tree memory, in the store namespace the selection already
341
+ // uses. Its ABSENCE degrades to "the tree whose root is the repo_key", then to the first
342
+ // registered tree — never to an error and never to a different product.
343
+ { const w0 = state.workspaces.find((x) => x.id === id); if (w0) store.set('lastTree:' + (w0.repo_key || w0.root), id); }
221
344
  if (changed) closeDoc();
222
- renderWorkspaces();
345
+ renderProducts();
223
346
  renderSessions();
224
347
  renderNewTarget(); // R5 — "here" moved; the composer must say so even if it is already open
225
348
  loadBoards();
349
+ loadSpine();
350
+ loadRadar();
226
351
  // focus a session in THIS workspace: the remembered one if it belongs here, else its first live one
227
352
  const mine = mySessions();
228
353
  const remembered = opts.restoreSession ? store.get('active') : null;
@@ -237,8 +362,14 @@ async function selectWorkspace(id, opts = {}) {
237
362
  {
238
363
  const w = state.workspaces.find((x) => x.id === id);
239
364
  const init = w ? initiativeOf(w) : null;
240
- if (init && init.id) { state.detail = { kind: 'initiative', id: init.id, wsId: id }; if (state.lens === 'dossier') ensureLib(id); }
241
- else { state.detail = { kind: 'repo', id: w ? w.repo_key : null, wsId: id }; if (state.lens === 'dossier') state.lens = 'folders'; }
365
+ if (init && init.id) { state.detail = { kind: 'initiative', id: init.id, wsId: id }; if (state.lens === 'dossier') ensureLib(id); state.landPending = null; }
366
+ else {
367
+ state.detail = { kind: 'repo', id: w ? w.repo_key : null, wsId: id }; if (state.lens === 'dossier') state.lens = 'folders';
368
+ // R4 clause 2 — the branch named no initiative, so the spine gets to answer. It has not been
369
+ // read yet, and NO initiative is selected before the read that would justify it lands: a
370
+ // panel drawn from another product's spine is exactly what `spineFor` exists to prevent.
371
+ state.landPending = id;
372
+ }
242
373
  }
243
374
  await Promise.all([loadLibrary(), loadNeeds(), loadBranch(), loadWorktrees()]);
244
375
  }
@@ -346,9 +477,450 @@ function treeGlyph(w) {
346
477
  if (mine.length) return 'exited';
347
478
  return 'idle';
348
479
  }
480
+ // ── workspace-pm-spine ─────────────────────────────────────────────────────────────────────────
481
+ // The resident navigator is the PM spine: Objective → Key Result → Initiative, with the repository,
482
+ // its working trees and their sessions demoted from CONTAINERS to attributes of the initiative they
483
+ // are working. The unit of record in the file layer is now the unit you select.
484
+ //
485
+ // R4 is the requirement most likely to be violated quietly, so the render is built around it:
486
+ // EVERY tree lands in exactly one of four places — under its initiative, under `no initiative`
487
+ // (this repo, branch names none), under `other repositories`, or nowhere, which is a bug. The
488
+ // accounting is asserted at the end of this function rather than left to inspection.
489
+ function renderSpine(host) {
490
+ const sp = state.spine;
491
+ const curW = state.workspaces.find((x) => x.id === state.wsId);
492
+ const curKey = curW ? productKey(curW) : null;
493
+
494
+ // workspace-product-switcher R6 — the `other repositories` bucket is GONE, because the switcher
495
+ // above this list is what it was standing in for. The spine is now the structure of ONE product;
496
+ // every other product is one button away, and every tree of every other product is reachable
497
+ // through it. Partition the SELECTED product's trees once: by initiative id, or unattached.
498
+ const mine = state.workspaces.filter((w) => curKey && productKey(w) === curKey);
499
+ const byInit = new Map(); const loose = [];
500
+ for (const w of mine) {
501
+ const init = initiativeOf(w);
502
+ if (init.id) { if (!byInit.has(init.id)) byInit.set(init.id, []); byInit.get(init.id).push(w); }
503
+ else loose.push(w);
504
+ }
505
+ const g = { name: curW ? (curW.repo_name || curW.name) : '' };
506
+ const treesFor = (id) => (byInit.get(id) || []).map((w) => treeRow(w, g)).join('');
507
+ const seen = new Set();
508
+
509
+ const initRow = (i, extra) => {
510
+ const mine = byInit.get(i.id) || [];
511
+ mine.forEach((w) => seen.add(w.id));
512
+ const row = boardRowById(i.id);
513
+ const blocked = row && row.blockers && row.blockers.length;
514
+ // R7 — position is the ENGINE's, never a declared field. Absent is stated, not implied.
515
+ // Most records are historical and carry no derived position; printing a disclaimer on each of
516
+ // ~120 rows is noise, not honesty. So: a position when there is one, "deriving…" while the
517
+ // board is genuinely still working, and silence when the engine has answered and this
518
+ // initiative simply has no live ladder. The signal the PM needs is blocked/next, not absence.
519
+ const bd = boardForCurrent();
520
+ const lad = row
521
+ ? `<span class="stg">${esc(row.stage || '')}</span>${blocked
522
+ ? `<span class="bl">✗ ${row.blockers.length} blocked</span>`
523
+ : (row.next && row.next.step ? `<span class="nx">→ ${esc(row.next.step)}</span>` : '')}`
524
+ : (bd && bd.pending ? `<span class="drv">deriving…</span>` : '');
525
+ // NAME ONLY on the row. The lane and the tree count were competing with the one thing this
526
+ // list is scanned for — which initiative — and both are stated in the panel that opens.
527
+ return `<div class="sp-init" data-init="${esc(i.id)}">
528
+ <button class="sp-i" data-init-pick="${esc(i.id)}" title="${esc(i.lane || '')}${mine.length ? ` · ${mine.length} tree${mine.length === 1 ? '' : 's'}` : ''}">
529
+ <span class="r1"><span class="nm">${esc(i.id)}</span></span>
530
+ <span class="r2">${lad}${extra || ''}</span>
531
+ </button>
532
+ <div class="sp-trees">${treesFor(i.id)}</div>
533
+ </div>`;
534
+ };
535
+
536
+ const parts = [];
537
+ if (!sp) parts.push(`<div class="empty">reading the spine…</div>`);
538
+ else if (sp.ok === false) {
539
+ // R5 — a FAILED read is red and says why. It is never a green empty tree.
540
+ parts.push(`<div class="sp-bad"><b>the spine could not be read</b><span>${esc(sp.error || '')}</span>
541
+ <span class="why">objectives, key results and initiative records live in this repository — until that read succeeds the navigator cannot say what is anchored to what</span></div>`);
542
+ } else {
543
+ for (const o of (sp.objectives || [])) {
544
+ const krs = (o.krs || []).filter((k) => k.initiatives.length);
545
+ const direct = o.direct || [];
546
+ if (!krs.length && !direct.length) continue;
547
+ parts.push(`<div class="sp-obj"><div class="sp-o"><span class="id">${esc(o.id)}</span><span class="nm">${esc(o.name)}</span></div>
548
+ ${krs.map((k) => `<div class="sp-kr"><div class="sp-k"><span class="id">${esc(k.id)}</span><span class="nm">${esc(k.name)}</span><span class="ct">${k.initiatives.length}</span></div>
549
+ ${k.initiatives.map((i) => initRow(i)).join('')}</div>`).join('')}
550
+ ${direct.length ? `<div class="sp-kr"><div class="sp-k"><span class="id">—</span><span class="nm">anchored to the objective, no KR named</span><span class="ct">${direct.length}</span></div>
551
+ ${direct.map((i) => initRow(i)).join('')}</div>` : ''}</div>`);
552
+ }
553
+ for (const b of (sp.budgets || [])) {
554
+ parts.push(`<div class="sp-obj" data-health="1"><div class="sp-o"><span class="id">${esc(b.id)}</span>
555
+ <span class="nm">health budget — upkeep, not a bet (D59)</span><span class="ct">${b.initiatives.length}</span></div>
556
+ ${b.initiatives.map((i) => initRow(i)).join('')}</div>`);
557
+ }
558
+ // R6 — never dropped to keep the tree tidy. The resolver's own reason is carried through.
559
+ if ((sp.unanchored || []).length) {
560
+ parts.push(`<div class="sp-obj" data-warn="1"><div class="sp-o"><span class="id">unanchored</span>
561
+ <span class="nm">no resolvable anchor — these are rejected work under D59</span><span class="ct">${sp.unanchored.length}</span></div>
562
+ ${sp.unanchored.map((i) => initRow(i, `<span class="rsn">${esc(i.reason || '')}</span>`)).join('')}</div>`);
563
+ }
564
+ }
565
+
566
+ // R5 (switcher) — a product whose spine READ SUCCEEDED and holds no initiative records is calm
567
+ // and specific: it names what would have to exist. It is not the "No repositories" line below
568
+ // (there IS a repository), and it is not the red `sp-bad` above (nothing failed).
569
+ if (sp && sp.ok !== false && !parts.length) {
570
+ parts.push(`<div class="empty">No initiative records in this repository — the read succeeded and
571
+ there is genuinely nothing to place. They would live in <b>planning/initiatives/*.md</b>,
572
+ anchored to <b>planning/okrs/</b> or to a health budget.</div>`);
573
+ }
574
+ // workspace-repo-ingest R8 — an open PROPOSAL, grouped on its own. It is a property of the
575
+ // proposal, not of this repository: these records live in a clone PMOS made, and none of them is
576
+ // in this repo until an item is confirmed. The group disappears when the proposal closes.
577
+ parts.push(ingestGroup());
578
+ // R4 — the bucket that keeps a re-rooting from orphaning a tree of THIS product.
579
+ const orphan = loose.filter((w) => !seen.has(w.id));
580
+ if (orphan.length) {
581
+ parts.push(`<div class="sp-obj" data-plain="1"><div class="sp-o"><span class="id">no initiative</span>
582
+ <span class="nm">trees in this repository whose branch names none — supported, not a fault</span><span class="ct">${orphan.length}</span></div>
583
+ <div class="sp-trees">${orphan.map((w) => treeRow(w, g)).join('')}</div></div>`);
584
+ }
585
+ host.innerHTML = parts.filter(Boolean).join('') || `<div class="empty">No repositories — run <b>pmos</b> inside one.</div>`;
586
+
587
+ // workspace-product-switcher R6 — ONE invariant is replaced by TWO, never relaxed. The first is
588
+ // asserted here; the second (every product with ≥1 registered tree has exactly one button) is
589
+ // asserted in renderProducts, because that is where the buttons are. Both are `console.warn`
590
+ // self-checks in the shape the retired R4 count already used, so a re-rooting that orphans a
591
+ // tree still announces itself rather than waiting to be noticed.
592
+ // …but only once the spine has actually been READ. Before the first read `sp` is null, so no
593
+ // objective block is emitted, so a tree whose branch names an initiative has nowhere to render
594
+ // and is not in `loose` either — rendered < mine.length is the CORRECT transient state, not an
595
+ // unreachable tree. Warning on it fired on every boot, which is how a real self-check becomes
596
+ // noise the PM learns to scroll past.
597
+ const rendered = host.querySelectorAll('.treerow').length;
598
+ if (sp && sp.ok !== false && rendered !== mine.length) {
599
+ console.warn(`[spine] R6.1: ${mine.length} trees registered for this product, ${rendered} rendered — something is unreachable`);
600
+ }
601
+ host.querySelectorAll('[data-init-pick]').forEach((b) => b.onclick = () => openInitiative(b.dataset.initPick, state.wsId));
602
+ host.querySelectorAll('[data-ing-open]').forEach((b) => b.onclick = () => openIngest(b.dataset.ingOpen));
603
+ // the spine emits treeRow()/sessionRow() under each initiative and in both catch-all groups, so
604
+ // it needs the SAME wiring the legacy list has — not a subset of it.
605
+ wireTree(host);
606
+ }
607
+ // ── the six non-product modes ────────────────────────────────────────────────────────────────
608
+ // Each list is built from what the repository actually holds. Where PMOS keeps no file for a
609
+ // mode, it says so and names what would have to exist — it never renders a sample. A workspace
610
+ // that invents plausible rows is worse than one that shows none, because the PM cannot tell the
611
+ // difference by looking, and every honest-state rule this app has exists to keep that from
612
+ // happening. `q` narrows every list through one predicate so search behaves the same everywhere.
613
+ function flattenDocs(node, out) {
614
+ if (!node) return out;
615
+ (node.docs || []).forEach((d) => out.push(d));
616
+ (node.dirs || []).forEach((d) => flattenDocs(d, out));
617
+ return out;
618
+ }
619
+ function knowledgeTree(name) {
620
+ const lib = state.lib;
621
+ if (!lib || !Array.isArray(lib.knowledge)) return null;
622
+ return lib.knowledge.find((t) => t && (t.name === name || String(t.path || '').endsWith(name))) || null;
623
+ }
624
+ function modeMatches(s) { const q = state.q.toLowerCase(); return !q || String(s || '').toLowerCase().includes(q); }
625
+ // The library's node shapes are not uniform — some entries carry `name`, some only `path`, and the
626
+ // tree walker mixes both. Deriving the label from whichever is present keeps one malformed node
627
+ // from throwing and blanking the whole list, which is what a bare `d.name.replace` did.
628
+ function docName(d) {
629
+ const raw = (d && (d.name || String(d.path || '').split('/').pop())) || '';
630
+ return raw.replace(/\.md$/i, '');
631
+ }
632
+ function modeRow(id, kind, nm, right, sub, extra) {
633
+ const on = state.detail.kind === kind && state.detail.id === id;
634
+ return `<button class="md-row" data-md="${esc(kind)}" data-mdid="${esc(id)}" aria-current="${on}"${extra || ''}>
635
+ <span class="r1">${nm}${right ? `<span class="rt">${right}</span>` : ''}</span>${sub || ''}</button>`;
636
+ }
637
+ // A mode with nothing behind it. It names the file that would have to exist, so the sentence is
638
+ // actionable rather than an apology — the PM can see exactly what would turn it on.
639
+ function modeAbsent(what, wouldBe) {
640
+ return `<div class="md-none"><b>Not recorded.</b> ${esc(what)}
641
+ PMOS has no such file today, so this list is empty because there is nothing to read —
642
+ <b>not</b> because the read failed. It would come from <code>${esc(wouldBe)}</code>.</div>`;
643
+ }
644
+ function renderMode(host) {
645
+ const m = state.mode;
646
+ if (m === 'scheduled') return renderModeScheduled(host);
647
+ if (m === 'workflow') return renderModeWorkflow(host);
648
+ if (m === 'skills') return renderModeSkills(host);
649
+ if (m === 'knowledge') return renderModeKnowledge(host);
650
+ if (m === 'agent') return renderModeAgent(host);
651
+ if (m === 'memory') return renderModeMemory(host);
652
+ host.innerHTML = '';
653
+ }
654
+ function wireMode(host) {
655
+ host.querySelectorAll('[data-md]').forEach((b) => b.onclick = () => {
656
+ state.detail = { kind: b.dataset.md, id: b.dataset.mdid, wsId: state.wsId };
657
+ state.col2 = true; store.set('col2', 'on');
658
+ renderTree(); renderLibrary(); applyLayout();
659
+ mobPushDetail(b.dataset.md); // no-op off the phone
660
+ });
661
+ }
662
+ // scheduled — the real scheduler, the same source the schedule surface reads
663
+ function renderModeScheduled(host) {
664
+ if (state.sched === null) {
665
+ host.innerHTML = `<div class="md-note">reading the scheduler…</div>`;
666
+ loadSchedules();
667
+ return;
668
+ }
669
+ if (state.sched && state.sched.error) {
670
+ host.innerHTML = `<div class="sp-bad"><b>the scheduler could not be read</b><span>${esc(state.sched.error)}</span></div>`;
671
+ return;
672
+ }
673
+ const list = (state.sched || []).filter((s) => modeMatches(s.name));
674
+ host.innerHTML = list.length
675
+ ? list.map((s) => {
676
+ const st = s.enabled
677
+ ? (s.lastStatus === 'fail' ? 'last run failed' : (s.next ? 'next ' + esc(relTime(s.next)) : 'armed — never run yet'))
678
+ : 'disabled';
679
+ const cls = !s.enabled ? 'muted' : (s.lastStatus === 'fail' ? 'bad' : 'ok');
680
+ return modeRow(s.name, 'sched', `<span class="nm">${esc(s.name)}</span>`,
681
+ esc(s.cron || s.every || ''), `<span class="d" data-t="${cls}">${st}</span>`);
682
+ }).join('')
683
+ : `<div class="sp-nomatch">${state.q ? 'nothing matches' : 'No schedules yet — a successful read of nothing.'}</div>`;
684
+ wireMode(host);
685
+ }
686
+ // workflow — the lanes the ENGINE actually runs, and the step chain each one produces. These are
687
+ // derived from the board, not stored as editable templates: PMOS derives a ladder from git order
688
+ // (scripts/workflow-state.js), so a template the PM could edit here would be a control that
689
+ // changes nothing. That is stated on the panel rather than implied by a disabled button.
690
+ function laneChains() {
691
+ const bd = boardForCurrent();
692
+ const rows = (bd && bd.data && Array.isArray(bd.data.live)) ? bd.data.live : [];
693
+ const byLane = new Map();
694
+ rows.forEach((r) => {
695
+ const lane = r.lane || 'unknown';
696
+ if (!byLane.has(lane)) byLane.set(lane, { lane, chain: [], n: 0 });
697
+ const e = byLane.get(lane); e.n++;
698
+ const keys = (r.steps || []).map((s) => s.key);
699
+ if (keys.length > e.chain.length) e.chain = keys;
700
+ });
701
+ return [...byLane.values()].sort((a, b) => b.n - a.n);
702
+ }
703
+ /* workspace-jira-handoff R13 — DECLARED templates, listed separately from derived lane chains and
704
+ * labelled as declarations. Read per repository with the same stale guard the spine uses: a slower
705
+ * read for a repo the PM has left must not paint over the one they are looking at. */
706
+ async function loadWorkflows() {
707
+ const w = state.workspaces.find((x) => x.id === state.wsId);
708
+ const key = w ? productKey(w) : null;
709
+ if (!key || state.workflowsFor === key) return;
710
+ state.workflowsFor = key;
711
+ let payload;
712
+ try { payload = await (await fetch('/api/console?name=workflows&ws=' + encodeURIComponent(state.wsId || ''))).json(); }
713
+ catch (e) { payload = { ok: false, error: `the workflow templates could not be read — ${String((e && e.message) || e)}` }; }
714
+ const now = state.workspaces.find((x) => x.id === state.wsId);
715
+ // a late arrival for a repo the PM has left must not paint — and must not leave the read marked
716
+ // done, or coming back would show nothing with nothing saying why
717
+ if (!now || productKey(now) !== key) { state.workflowsFor = null; return; }
718
+ state.workflows = payload;
719
+ if (state.mode === 'workflow') renderTree();
720
+ if (state.detail.kind === 'flow') renderLibrary();
721
+ }
722
+ // The declared group. It is NOT a lane chain and must never be mixed into that list: a lane chain
723
+ // is derived from git order and a template is a file somebody wrote, and the whole of R13 is that
724
+ // the second must not borrow the first's authority.
725
+ function workflowTemplateRows() {
726
+ const t = state.workflows;
727
+ if (!t) return '';
728
+ if (t.ok === false) return `<div class="sp-bad"><b>the workflow templates could not be read</b><span>${esc(t.error || '')}</span></div>`;
729
+ const list = (t.templates || []).filter((x) => modeMatches(x.id) || modeMatches(x.title || ''));
730
+ if (!list.length) return '';
731
+ return `<div class="md-cap">declared — nothing derives from this yet</div>`
732
+ + list.map((x) => `<button class="md-row" data-tmpl="${esc(x.path)}">
733
+ <span class="r1"><span class="nm">${esc(x.title || x.id)}</span>
734
+ <span class="rt">${x.stages.length ? esc(String(x.stages.length)) + ' stages' : 'no stages parsed'}</span></span>
735
+ <span class="d">${esc(x.stages.join(' → ') || x.path)}</span></button>`).join('');
736
+ }
737
+ function renderModeWorkflow(host) {
738
+ loadWorkflows();
739
+ const bd = boardForCurrent();
740
+ if (bd && bd.pending) { host.innerHTML = `<div class="md-note">deriving the ladder…</div>`; return; }
741
+ const lanes = laneChains().filter((l) => modeMatches(l.lane));
742
+ // NO SILENT CAPS. The board returns what is in flight and reports the rest as `hidden`; lanes
743
+ // derived from it therefore describe the in-flight set, not the repository. Saying which is the
744
+ // difference between a reading and a claim.
745
+ const d = (bd && bd.data) || {};
746
+ const scope = d.hidden
747
+ ? `Derived from the ${(d.total || 0) - d.hidden} initiatives in flight — ${d.hidden} more are outside the board's cap and are not counted here.`
748
+ : 'Derived from every initiative on the board.';
749
+ host.innerHTML = `<div class="md-note">The lanes this repository runs, and the ladder each one
750
+ derives. Stage comes from git order — never declared — so these are read, not authored.
751
+ ${esc(scope)}</div>`
752
+ + (lanes.length
753
+ ? lanes.map((l) => modeRow(l.lane, 'flow', `<span class="nm">${esc(l.lane)}</span>`,
754
+ `${l.n} initiative${l.n === 1 ? '' : 's'}`,
755
+ `<span class="chain">${esc(l.chain.join(' → ')) || 'no steps derived'}</span>`)).join('')
756
+ : `<div class="sp-nomatch">${state.q ? 'nothing matches' : 'No ladder derived for this repository yet.'}</div>`)
757
+ + workflowTemplateRows();
758
+ wireMode(host);
759
+ // a template row opens the FILE — there is nothing else it could honestly do, since nothing in
760
+ // the engine reads it
761
+ host.querySelectorAll('[data-tmpl]').forEach((b) => b.onclick = () => openDoc(b.dataset.tmpl));
762
+ }
763
+ function renderModeSkills(host) {
764
+ const docs = [];
765
+ ['skills', 'build-skills'].forEach((n) => flattenDocs(knowledgeTree(n), docs));
766
+ const list = docs.filter((d) => modeMatches(docName(d)) || modeMatches(d.path));
767
+ host.innerHTML = list.length
768
+ ? list.map((d) => modeRow(d.path, 'skill', `<span class="nm">${esc(skillName(d))}</span>`, '',
769
+ `<span class="d">${esc(d.path)}</span>`)).join('')
770
+ : `<div class="sp-nomatch">${state.q ? 'nothing matches'
771
+ : 'No skills in this repository — skills live under skills/ and build-skills/.'}</div>`;
772
+ wireMode(host);
773
+ }
774
+ // a skill is a directory with a SKILL.md, so the directory is the name worth showing
775
+ function skillName(d) {
776
+ const parts = String(d.path || d.name || '').split('/');
777
+ const last = parts[parts.length - 1] || '';
778
+ return /^SKILL\.md$/i.test(last) && parts.length > 1 ? parts[parts.length - 2] : last.replace(/\.md$/i, '');
779
+ }
780
+ function renderModeKnowledge(host) {
781
+ const okf = [];
782
+ flattenDocs(knowledgeTree('okf'), okf);
783
+ const gov = ((state.lib && state.lib.governance) || []);
784
+ const groups = [
785
+ { name: 'governance · the locked record', items: gov },
786
+ { name: 'okf · the knowledge bundle', items: okf },
787
+ ].map((g) => ({ ...g, items: g.items.filter((d) => modeMatches(docName(d)) || modeMatches(d.path)) }))
788
+ .filter((g) => g.items.length);
789
+ host.innerHTML = groups.length
790
+ ? groups.map((g) => `<div class="md-cap">${esc(g.name)}</div>` + g.items.map((d) =>
791
+ modeRow(d.path, 'know', `<span class="nm">${esc(docName(d))}</span>`, '',
792
+ `<span class="d">${esc(d.path)}</span>`)).join('')).join('')
793
+ : `<div class="sp-nomatch">${state.q ? 'nothing matches'
794
+ : 'No knowledge bundle in this repository — it would live under okf/.'}</div>`;
795
+ wireMode(host);
796
+ }
797
+ // agent — derived from the sessions that are actually running. PMOS has no agent REGISTRY (no
798
+ // agents/*.yaml, no roster file), so this is the roster the runtime can honestly produce: which
799
+ // agent binaries are running, on how many sessions, and in what state.
800
+ function agentRoster() {
801
+ const by = new Map();
802
+ state.sessions.forEach((s) => {
803
+ const k = s.agent || 'unknown';
804
+ if (!by.has(k)) by.set(k, { name: k, n: 0, live: 0, sessions: [] });
805
+ const e = by.get(k); e.n++; e.sessions.push(s);
806
+ if (s.state !== 'exited') e.live++;
807
+ });
808
+ return [...by.values()].sort((a, b) => b.live - a.live || a.name.localeCompare(b.name));
809
+ }
810
+ function renderModeAgent(host) {
811
+ const list = agentRoster().filter((a) => modeMatches(a.name));
812
+ host.innerHTML = `<div class="md-note">The agents actually running here. PMOS keeps no agent
813
+ registry file, so this roster is derived from live sessions — it lists what is running, not
814
+ what could be.</div>`
815
+ + (list.length
816
+ ? list.map((a) => modeRow(a.name, 'agent',
817
+ `<span class="dot" aria-hidden="true">●</span><span class="nm">${esc(a.name)}</span>`,
818
+ `${a.n} session${a.n === 1 ? '' : 's'}`,
819
+ `<span class="d">${a.live ? `${a.live} live` : 'all exited'}</span>`,
820
+ ` data-live="${a.live ? 'on' : 'off'}"`)).join('')
821
+ : `<div class="sp-nomatch">${state.q ? 'nothing matches' : 'No sessions have run here yet.'}</div>`);
822
+ wireMode(host);
823
+ }
824
+ function renderModeMemory(host) {
825
+ host.innerHTML = modeAbsent(
826
+ 'Agent memory — short-term session context, and long-term notes injected into every run.',
827
+ 'pmos/state/memory.jsonl');
828
+ }
829
+ async function loadSchedules() {
830
+ if (state.schedLoading) return;
831
+ state.schedLoading = true;
832
+ const forWs = state.wsId;
833
+ try {
834
+ const r = await fetch('/api/schedules?ws=' + encodeURIComponent(state.wsId || ''));
835
+ const j = await r.json();
836
+ if (state.wsId !== forWs) return; // a slower read must never paint a newer tree
837
+ state.sched = j.schedules || [];
838
+ } catch (e) {
839
+ state.sched = { error: String((e && e.message) || e) };
840
+ } finally {
841
+ state.schedLoading = false;
842
+ if (state.mode !== 'product') renderTree();
843
+ }
844
+ }
845
+
846
+ // the ladder row for an initiative id, from the SELECTED repo's cached board
847
+ function boardForCurrent() {
848
+ const w = state.workspaces.find((x) => x.id === state.wsId);
849
+ return w ? boardFor(w) : null;
850
+ }
851
+ function boardRowById(id) {
852
+ const w = state.workspaces.find((x) => x.id === state.wsId);
853
+ return w ? boardRow(w, id) : null;
854
+ }
855
+ // The destinations are MODES, not links: clicking one swaps what column 1 lists, and column 2
856
+ // follows the selection made there. That is the difference from the version this replaces, where
857
+ // each button jumped to a different surface and the column underneath never changed — the menu
858
+ // looked like navigation but the structure below it did not move, so it read as decoration.
859
+ //
860
+ // Everything here is PRODUCT-SPECIFIC — knowledge and scheduled tasks belong to the repository in
861
+ // view — which is why the menu sits inside the product's own column rather than beside it.
862
+ // Re-rendered on every tree paint (unlike its predecessor, which rendered once): the pressed
863
+ // state is part of the output now, so a cached strip would show the wrong mode as current.
864
+ function renderMenu() {
865
+ const host = $('spMenu');
866
+ if (!host) return;
867
+ // The column names what it is showing. It used to be hardcoded "repos & sessions", which was
868
+ // true of exactly one of the seven modes.
869
+ const fold = $('wsFold');
870
+ if (fold) fold.lastChild.textContent = modeTitle();
871
+ const rows = MODES.map((m) => `<button class="spm" data-mode="${m.k}" title="${esc(m.title)}"
872
+ aria-pressed="${state.mode === m.k}">
873
+ <span class="g" aria-hidden="true">${m.g}</span><span class="l">${esc(m.label)}</span></button>`);
874
+ // The eighth cell squares up the two-row grid, and it is the control that puts the column away.
875
+ rows.push(`<button class="spm" data-collapse1="1" title="Hide this column">
876
+ <span class="g" aria-hidden="true">⟨</span><span class="l">hide</span></button>`);
877
+ host.innerHTML = rows.join('');
878
+ host.querySelectorAll('[data-mode]').forEach((b) => b.onclick = () => setMode(b.dataset.mode));
879
+ host.querySelectorAll('[data-collapse1]').forEach((b) => b.onclick = () => setCol('col1', false));
880
+ }
881
+ function setMode(k) {
882
+ if (!MODE_BY_K[k] || state.mode === k) return;
883
+ state.mode = k; store.set('mode', k);
884
+ // A mode swap must not leave column 2 describing something the new list cannot select — the
885
+ // panel would name a row nobody can see. Detail is cleared to the mode's own empty state and
886
+ // re-picked from the new list, EXCEPT for `product`, whose selection is the initiative the rest
887
+ // of the app is already built around and is worth preserving across a look at another mode.
888
+ if (k !== 'product' && state.detail.kind !== MODE_DETAIL[k]) state.detail = { kind: MODE_DETAIL[k] || 'repo', id: null, wsId: state.wsId };
889
+ if (k === 'product' && state.detail.kind !== 'initiative' && state.detail.kind !== 'repo') {
890
+ state.detail = { kind: 'repo', id: null, wsId: state.wsId };
891
+ }
892
+ renderTree(); renderLibrary(); applyLayout();
893
+ }
894
+ // which detail kind a mode's rows select into
895
+ const MODE_DETAIL = { product: 'initiative', scheduled: 'sched', workflow: 'flow', skills: 'skill',
896
+ knowledge: 'know', agent: 'agent', memory: 'mem' };
897
+
349
898
  function renderTree() {
899
+ renderMenu();
900
+ renderProducts();
901
+ renderFocusBadge();
350
902
  const host = $('tree');
351
903
  if (!host) return;
904
+ // workspace-mobile-surface — the `more` destination is a ROOT, not a mode, so it owns this host
905
+ // while it is the screen on screen. It writes `.md-row`s (the same vocabulary as every other
906
+ // list in this column), never a phone-shaped list of its own.
907
+ if (mobMoreActive()) {
908
+ // the composer and the product switcher scope the PRODUCT list; above a menu of everything
909
+ // else they point at the wrong noun, so they stand down for this screen
910
+ const nb0 = $('newBtn'); if (nb0) nb0.hidden = true;
911
+ const nf0 = $('newForm'); if (nf0) nf0.hidden = true;
912
+ const ps0 = $('prodSwitch'); if (ps0) ps0.hidden = true;
913
+ renderMoreMenu(host);
914
+ return;
915
+ }
916
+ // Only `product` shows the spine. The other six modes list something else entirely, so the
917
+ // composer and the tree's own furniture are hidden for them — a "+ new session" button above a
918
+ // list of decisions is an affordance pointed at the wrong noun.
919
+ const isProduct = state.mode === 'product';
920
+ const nb = $('newBtn'); if (nb) nb.hidden = !isProduct;
921
+ if (!isProduct) { const f = $('newForm'); if (f) f.hidden = true; }
922
+ if (!isProduct) { renderMode(host); return; }
923
+ if (SPINE_ON) { renderSpine(host); return; } // renderSpine → wireTree → paintRename
352
924
  // group by repository (R3): `pmos` and `pmos ⑂ initiative/foo` are a parent and its child, not
353
925
  // two peers. The key comes from the server — `git worktree list` names the main worktree first.
354
926
  const groups = new Map();
@@ -374,6 +946,17 @@ function renderTree() {
374
946
  </button>${trees}</div>`;
375
947
  }).join('');
376
948
  host.innerHTML = html || `<div class="empty">No repositories — run <b>pmos</b> inside one.</div>`;
949
+ wireTree(host);
950
+ }
951
+ // Everything a tree row and a session row can DO. This lives apart from the markup because the
952
+ // navigator has two renderers now — the spine (D-default) and the legacy repository list — and both
953
+ // emit treeRow()/sessionRow(). When the spine landed it wired only [data-init-pick], which left
954
+ // every affordance INSIDE a tree row dead in the shipped path: selecting a session, renaming it
955
+ // (F2 or ✎), expanding a tree, + session here, forgetting a tree, and the harness drill-in's
956
+ // open-state. A row that renders but does nothing is worse than one that is absent, because it
957
+ // looks available. One wiring function, called by both renderers, is what keeps that from
958
+ // recurring the next time the navigator is re-rendered somewhere new.
959
+ function wireTree(host) {
377
960
  host.querySelectorAll('[data-pick]').forEach((b) => b.onclick = () => selectWorkspace(b.dataset.pick));
378
961
  // R6 — select the tree AND open the composer against it. Deliberately NOT awaited: selectWorkspace
379
962
  // ends on four network reads (library · needs · branch · worktrees), and making the composer wait
@@ -549,23 +1132,578 @@ async function loadBoards() {
549
1132
  }
550
1133
  }
551
1134
 
552
- function renderWorkspaces() {
553
- const html = state.workspaces.map((w) => {
554
- const n = state.sessions.filter((s) => s.ws === w.id && s.state !== 'exited').length;
555
- const cur = w.id === state.wsId;
556
- return `<div class="wsrow" data-id="${esc(w.id)}">
557
- <button class="ws" data-pick="${esc(w.id)}" aria-current="${cur}" title="${esc(w.root)}">
558
- <span class="cue" aria-hidden="true">${cur ? '▾' : '▸'}</span>
559
- <span class="nm">${esc(w.name)}</span>
560
- ${w.home ? '<span class="tag">home</span>' : ''}
561
- <small>${n ? `${n} live` : ''}</small>
562
- </button>
563
- ${w.home ? '' : `<button class="forget" data-forget="${esc(w.id)}" aria-label="Stop hosting ${esc(w.name)}" title="${n ? 'end its sessions first' : 'stop hosting this repo (the repo is untouched)'}">✕</button>`}
564
- </div>`;
1135
+ // The spine is a property of a REPOSITORY (its planning/okrs/ and its initiative records), so it is
1136
+ // read per selected repo and re-read when the selection moves to a different one. `spineFor` guards
1137
+ // against painting one repo's structure while another is selected — a stale spine is a lie, and the
1138
+ // wrong kind: it would look completely plausible.
1139
+ async function loadRadar() {
1140
+ const w = state.workspaces.find((x) => x.id === state.wsId);
1141
+ const key = w && w.repo_key ? w.repo_key : null;
1142
+ if (!key || state.radarFor === key) return;
1143
+ try {
1144
+ const r = await fetch('/api/console?name=radar&ws=' + encodeURIComponent(state.wsId));
1145
+ state.radar = await r.json(); state.radarFor = key;
1146
+ } catch { state.radar = { ok: false }; state.radarFor = key; }
1147
+ if (state.lens === 'dossier') renderLibrary();
1148
+ }
1149
+ async function loadSpine() {
1150
+ const w = state.workspaces.find((x) => x.id === state.wsId);
1151
+ const key = w && w.repo_key ? w.repo_key : null;
1152
+ if (!key) { state.spine = null; state.spineFor = null; return; }
1153
+ if (state.spineFor === key && state.spine) return;
1154
+ try {
1155
+ const r = await fetch('/api/console?name=spine&ws=' + encodeURIComponent(state.wsId));
1156
+ const payload = await r.json();
1157
+ // late arrivals lose: the PM may have moved on while this was in flight
1158
+ const now = state.workspaces.find((x) => x.id === state.wsId);
1159
+ if (!now || now.repo_key !== key) return;
1160
+ state.spine = payload; state.spineFor = key;
1161
+ } catch {
1162
+ state.spine = { ok: false, error: 'could not reach the workspace server for the spine' };
1163
+ state.spineFor = key;
1164
+ }
1165
+ // R4 clause 2 — the selection the branch could not make. This runs ONLY for the workspace that
1166
+ // was still owed a landing, and only now that its own spine has answered: selecting an
1167
+ // initiative from a spine read for a different product is precisely the failure `spineFor`
1168
+ // guards the render against, and it would look completely plausible.
1169
+ if (state.landPending && state.landPending === state.wsId) {
1170
+ state.landPending = null;
1171
+ const first = firstSpineInitiative(state.spine);
1172
+ // No first initiative is R5's honest empty, not a fallback to somebody else's — the repo
1173
+ // detail already on screen stays.
1174
+ if (first) openInitiative(first, state.wsId);
1175
+ }
1176
+ renderTree();
1177
+ }
1178
+
1179
+ /* ── workspace-focus ────────────────────────────────────────────────────────────────────────────
1180
+ *
1181
+ * The one surface in column 1 that is NOT scoped by the product switcher: it joins every registered
1182
+ * repository, which is why `ws` is on the URL only because the console fetcher always appends it
1183
+ * and is ignored by the handler. Everything rendered here is `lib/focus.js`'s own output — this
1184
+ * file adds no judgement of its own, files nothing into the queue, and MUTATES NOTHING: there is no
1185
+ * accept, no dispose, no anchor and no resolve anywhere below.
1186
+ *
1187
+ * The stale-paint guard is a SEQUENCE, not a key. Every other loader here compares against the
1188
+ * workspace it was started for; focus has no such key, so a slower read is beaten by a newer one on
1189
+ * ordering alone — which is the same property, expressed the only way this surface can express it.
1190
+ */
1191
+ async function loadFocus() {
1192
+ const seq = ++state.focusSeq;
1193
+ let payload;
1194
+ try {
1195
+ const r = await fetch('/api/console?name=focus&ws=' + encodeURIComponent(state.wsId || ''));
1196
+ payload = await r.json();
1197
+ } catch (e) {
1198
+ payload = { ok: false, error: `could not reach the workspace server for focus — ${String((e && e.message) || e)}` };
1199
+ }
1200
+ if (seq !== state.focusSeq) return; // a newer read already won; this one may not paint
1201
+ state.focus = payload;
1202
+ renderFocusBadge();
1203
+ if (state.focusOpen) renderFocus();
1204
+ }
1205
+ /* R5 — the badge prints `badge.text` and NEVER `count`. `count` is a true statement about the
1206
+ * queue's length; it is not a true statement about the world whenever a contributing read failed,
1207
+ * is still deriving, or never happened — and the module is the thing that knows which. */
1208
+ function renderFocusBadge() {
1209
+ const el = $('focusBadge');
1210
+ if (!el) return;
1211
+ const f = state.focus;
1212
+ if (!f) { el.textContent = ''; el.dataset.bad = '0'; return; }
1213
+ if (f.ok === false) { el.textContent = 'focus unreadable'; el.dataset.bad = '1'; return; }
1214
+ const b = f.badge || {};
1215
+ // A clean, exact zero needs no badge at all — the panel says "nothing is waiting on you" in
1216
+ // words, and a badge repeating it would be chrome asking to be read for no reading.
1217
+ el.textContent = (b.n === 0 && b.exact) ? '' : String(b.text || '');
1218
+ el.dataset.bad = (b.unreadable || b.failed) ? '1' : '0';
1219
+ }
1220
+ function toggleFocus(on) {
1221
+ const open = on == null ? !state.focusOpen : !!on;
1222
+ state.focusOpen = open;
1223
+ const panel = $('focusPanel'), btn = $('focusBtn');
1224
+ if (panel) panel.hidden = !open;
1225
+ if (btn) btn.setAttribute('aria-expanded', String(open));
1226
+ if (open) { renderFocus(); loadFocus(); }
1227
+ }
1228
+ const fcSec = (id, dfc, body) => `<section class="fc-sec" id="${id}" data-fc="${dfc}">${body}</section>`;
1229
+ const fcCap = (t) => `<div class="fc-cap">${esc(t)}</div>`;
1230
+ /* R2 — six sections, in this order, ALWAYS all six. A section that renders nothing when it is
1231
+ * empty reads as "there is nothing of that kind", which is a claim; for `handoffs` it would be a
1232
+ * false one, so every section below renders its own empty, absent or deriving state instead. */
1233
+ function renderFocus() {
1234
+ const host = $('focusBody');
1235
+ if (!host) return;
1236
+ const f = state.focus;
1237
+ if (!f) {
1238
+ host.innerHTML = fcSec('fcIntro', 'intro', `<div class="fc-intro">reading focus across every registered repository…</div>`)
1239
+ + fcSec('fcObjectives', 'objectives', fcCap('objectives'))
1240
+ + fcSec('fcQueue', 'queue', fcCap('your queue'))
1241
+ + fcSec('fcHandoffs', 'handoffs', fcCap('handoffs'))
1242
+ + fcSec('fcDelegated', 'delegated', fcCap('delegated'))
1243
+ + fcSec('fcUpkeep', 'upkeep', fcCap('upkeep'));
1244
+ return;
1245
+ }
1246
+ if (f.ok === false) {
1247
+ // A failed read is RED and carries the reader's own message — the existing `.cs-error`, because
1248
+ // this workspace has exactly one way to say "broken read" and a second would end up calmer.
1249
+ host.innerHTML = fcSec('fcIntro', 'intro', csError('focus', f.error || 'the focus read failed'))
1250
+ + fcSec('fcObjectives', 'objectives', fcCap('objectives'))
1251
+ + fcSec('fcQueue', 'queue', fcCap('your queue'))
1252
+ + fcSec('fcHandoffs', 'handoffs', fcCap('handoffs'))
1253
+ + fcSec('fcDelegated', 'delegated', fcCap('delegated'))
1254
+ + fcSec('fcUpkeep', 'upkeep', fcCap('upkeep'));
1255
+ return;
1256
+ }
1257
+ host.innerHTML = fcSec('fcIntro', 'intro', `<div class="fc-intro">${esc(f.intro || '')}</div>`)
1258
+ + fcSec('fcObjectives', 'objectives', fcObjectives(f))
1259
+ + fcSec('fcQueue', 'queue', fcQueue(f))
1260
+ + fcSec('fcHandoffs', 'handoffs', fcHandoffs(f))
1261
+ + fcSec('fcDelegated', 'delegated', fcDelegated(f))
1262
+ + fcSec('fcUpkeep', 'upkeep', fcUpkeep(f));
1263
+ wireFocus(host);
1264
+ }
1265
+ // R7 — one row per product, four states, and `none` (a read that succeeded on a repo declaring no
1266
+ // objectives) is NOT `not-read` (PMOS never got to it). The module decides which; this prints it.
1267
+ function fcObjectives(f) {
1268
+ const rows = (f.objectives || []).map((o) => {
1269
+ const body = o.state === 'objective' ? `<span class="nm">${esc(o.id || '')} ${esc(o.name || '')}</span>`
1270
+ : o.state === 'none' ? `<span class="nm">${esc(o.note || 'this repository declares no objectives')}</span>`
1271
+ : `<span class="nm">${esc(o.error || '')}</span>`;
1272
+ return `<div class="fc-obj" data-state="${esc(o.state)}"><span class="fc-prod">${esc(o.productName || o.product || '')}</span>${body}</div>`;
565
1273
  }).join('');
566
- $('workspaces').innerHTML = html || `<div class="empty">No workspaces — run <b>pmos</b> inside a repo.</div>`;
567
- $('workspaces').querySelectorAll('[data-pick]').forEach((b) => b.onclick = () => selectWorkspace(b.dataset.pick));
568
- $('workspaces').querySelectorAll('[data-forget]').forEach((b) => b.onclick = () => forgetWorkspace(b.dataset.forget));
1274
+ return fcCap('objectives') + (rows || `<div class="fc-none">No repository is registered, so no objective was read.</div>`);
1275
+ }
1276
+ // R4/R6 — the queue, plus the coverage sentence that stops a short list being read as a complete
1277
+ // one, plus the third state for a board that is still deriving.
1278
+ function fcQueue(f) {
1279
+ const cov = (f.coverage || []).map((c) =>
1280
+ `<div class="fc-cov">${esc(c.productName || c.product || '')} — ${esc(c.line || '')}</div>`).join('');
1281
+ const deriving = (f.coverage || []).filter((c) => c.state === 'deriving');
1282
+ const derivingNote = deriving.length
1283
+ ? `<div class="fc-deriving">${deriving.length} board${deriving.length === 1 ? ' is' : 's are'} still deriving —
1284
+ ${esc(deriving.map((c) => c.productName || c.product).join(', '))}. That is neither empty nor broken, and
1285
+ anything those ladders would file is not in the list below yet.</div>`
1286
+ : '';
1287
+ const failed = (f.reads || []).filter((r) => r.state === 'error');
1288
+ const failedNote = failed.map((r) =>
1289
+ csError(`${r.productName || r.product || 'this product'} · ${r.source}`, r.error || 'the read failed')).join('');
1290
+ const rows = (f.queue || []).map(fcItem).join('');
1291
+ const body = rows || (failed.length
1292
+ ? '' // the red blocks above already say what is wrong
1293
+ : `<div class="fc-none">${esc(f.empty_note || 'nothing is waiting on you')} — every contributing read succeeded.</div>`);
1294
+ return fcCap('your queue') + failedNote + derivingNote + body + cov;
1295
+ }
1296
+ function fcItem(it) {
1297
+ const t = it.target || {};
1298
+ return `<button class="fc-item" data-tier="${esc(it.tier || '')}" data-kind="${esc(it.kind || '')}" data-rank="${esc(String(it.rank || ''))}"
1299
+ data-target-kind="${esc(t.kind || '')}" data-target-product="${esc(t.product || '')}" data-target-id="${esc(t.id || '')}"
1300
+ aria-label="${esc(`${it.rank}. ${it.subject || it.id || ''} — ${it.why || ''}`)}">
1301
+ <span class="fc-rank">${esc(String(it.rank || ''))}</span>
1302
+ <span class="fc-nm">${esc(it.subject || it.id || '')}</span>
1303
+ <span class="fc-prod">${esc(it.productName || it.product || '')}</span>
1304
+ <span class="fc-why">${esc(it.why || '')}${it.action ? ' — ' + esc(it.action) : ''}</span>
1305
+ <span class="fc-read">read: ${esc(it.read || '')}</span>
1306
+ </button>`;
1307
+ }
1308
+ /* R9 — an ABSENCE, in the module's own words. Not `.fc-none` (a read that ran and found nothing)
1309
+ * and not `.cs-error` (a read that failed): there is no tracker to read, so there is nothing here
1310
+ * that could be empty or broken. `count` is null and no number is printed anywhere in this block. */
1311
+ function fcHandoffs(f) {
1312
+ const h = f.handoffs || {};
1313
+ return fcCap(h.heading || 'handoffs · waiting on other teams')
1314
+ + `<div class="fc-notconn"><b>not connected.</b> ${esc(String(h.body || '').replace(/^not connected\.\s*/, ''))}</div>`;
1315
+ }
1316
+ // R8 — what is running (the shared module's WORD, verbatim, hedge intact), and start actions that
1317
+ // are real. Where `starts_state` is 'not-resolved' the note is printed and NO button at all.
1318
+ function fcDelegated(f) {
1319
+ const d = f.delegated || {};
1320
+ const running = (d.running || []).map((r) => `<button class="fc-run" data-fc-sess="${esc(r.id)}">
1321
+ <span class="g" aria-hidden="true">${esc(r.glyph || '•')}</span>
1322
+ <span class="fc-nm">${esc(r.name || r.id)}</span>
1323
+ <span class="fc-prod">${esc(r.productName || r.product || '')}</span>
1324
+ <span class="fc-why">${esc(r.word || r.shown || '')}</span>
1325
+ </button>`).join('');
1326
+ const starts = d.starts_state === 'resolved'
1327
+ ? (d.starts || []).map((s) => `<button class="fc-del" data-skill="${esc(s.skill)}" data-init="${esc(s.initiative)}"
1328
+ data-product="${esc(s.product || '')}" data-input="${esc(s.initialInput || '')}"
1329
+ title="${esc(`opens the composer against ${s.initiative} and stages this run prompt — PMOS never starts an agent without you pressing start`)}">▶ delegate</button>`).join('')
1330
+ : '';
1331
+ const note = `<div class="fc-notconn">${esc(d.starts_note || '')}</div>`;
1332
+ return fcCap('delegated')
1333
+ + (running || `<div class="fc-none">No session is running in any registered repository.</div>`)
1334
+ + (d.starts_state === 'resolved' && starts ? starts : note);
1335
+ }
1336
+ // R10 — upkeep says only what it can read: a stated absence for memory, and the budgets with the
1337
+ // caveat that the caps have NOT been evaluated. No percentage, because a percentage implies a test.
1338
+ function fcUpkeep(f) {
1339
+ const u = f.upkeep || {};
1340
+ const mem = u.memory || {};
1341
+ const b = u.budgets || {};
1342
+ const memBlock = `<div class="fc-notconn"><b>${esc(mem.heading || 'memory grooming')}</b> — ${esc(mem.body || '')}</div>`;
1343
+ let budgets;
1344
+ if (b.state === 'error') budgets = csError('health budgets', b.error || 'the registry could not be read');
1345
+ else {
1346
+ const rows = (b.rows || []).map((r) => `<div class="fc-budget">
1347
+ <span class="fc-nm">${esc(r.id)}</span>
1348
+ <span class="fc-why">${esc(r.covers || '')}</span>
1349
+ ${r.cap ? `<span class="n">${esc(r.total)} anchored · cap ${esc(r.cap)}</span>`
1350
+ : `<span class="na">${esc(r.total)} anchored · cap not read</span>`}
1351
+ </div>`).join('');
1352
+ budgets = (rows || `<div class="fc-none">No health budget is declared in any registered repository.</div>`)
1353
+ + `<div class="fc-cov">${esc(b.note || '')}</div>`
1354
+ + `<div class="fc-cov">${esc(b.caveat || '')}</div>`
1355
+ + (b.spine_not_read ? `<div class="fc-cov">${esc(String(b.spine_not_read))} product spine(s) were not read, so their anchored counts are missing from the rows above.</div>` : '');
1356
+ }
1357
+ return fcCap('upkeep') + memBlock + budgets;
1358
+ }
1359
+ /* R11 — clicking an item LANDS ON IT: the right initiative, in the right product, with that
1360
+ * product's own selection. Landing on the previous product's selection is a failure of this
1361
+ * requirement, which is why the workspace switch comes first and is awaited. */
1362
+ function wireFocus(host) {
1363
+ host.querySelectorAll('.fc-item').forEach((b) => b.onclick = () => {
1364
+ const kind = b.dataset.targetKind, prod = b.dataset.targetProduct, id = b.dataset.targetId;
1365
+ if (kind === 'session') { selectSession(id); return; }
1366
+ const trees = state.workspaces.filter((w) => productKey(w) === prod);
1367
+ if (!trees.length) { flash(`that item is in ${prod || 'a repository'}, which is not registered here any more`); return; }
1368
+ const remembered = store.get('lastTree:' + prod);
1369
+ const pick = trees.find((w) => w.id === remembered) || trees.find((w) => w.root === prod) || trees[0];
1370
+ const p = selectWorkspace(pick.id);
1371
+ if (kind === 'initiative' && id) p.then(() => openInitiative(id, pick.id));
1372
+ });
1373
+ host.querySelectorAll('[data-fc-sess]').forEach((b) => b.onclick = () => selectSession(b.dataset.fcSess));
1374
+ /* `▶ delegate` — a REAL action, and only ever the one PMOS can honour end to end. The `new`
1375
+ * message carries no `initialInput` field (server.js:3658 forwards name/agent/cwd/ws/branch/
1376
+ * createBranch/isolate/profile and nothing else), so sending one would start a session that
1377
+ * silently ran no prompt. Instead this selects the tree, opens the composer against it, and
1378
+ * stages the module's own `initialInput` in the scratchpad — where `→ terminal` sends it without
1379
+ * a trailing newline, so nothing auto-runs. Everything the PM sees, they pressed. */
1380
+ host.querySelectorAll('.fc-del').forEach((b) => b.onclick = () => {
1381
+ const prod = b.dataset.product, id = b.dataset.init;
1382
+ const trees = state.workspaces.filter((w) => productKey(w) === prod);
1383
+ const pick = trees.find((w) => w.id === store.get('lastTree:' + prod)) || trees.find((w) => w.root === prod) || trees[0];
1384
+ if (!pick) { flash(`${prod || 'that repository'} is not registered here any more — nothing was started`); return; }
1385
+ const p = selectWorkspace(pick.id);
1386
+ // the composer is opened against a CLAUDE session because that is what a `.skill` is run by;
1387
+ // the radio group is repainted so the control on screen and the state agree
1388
+ state.newAgent = 'claude';
1389
+ paintNewAgents();
1390
+ openNewForm();
1391
+ p.then(() => {
1392
+ if (!$('newForm').hidden) { renderNewTarget(); renderProfileChips(); }
1393
+ $('newName').value = `${id} · ${b.dataset.skill.split('/').pop().replace(/\.skill$/, '')}`;
1394
+ $('scratchText').value = b.dataset.input || '';
1395
+ saveScratchpad();
1396
+ openScratch(true);
1397
+ flash('the run prompt is staged in the scratchpad — start the session, then press → terminal', 12000);
1398
+ });
1399
+ });
1400
+ }
1401
+
1402
+ /* ── workspace-product-switcher ─────────────────────────────────────────────────────────────────
1403
+ *
1404
+ * The flat `#workspaces` list is gone, and so is the `other repositories` bucket it duplicated.
1405
+ * What replaces both is ONE button per PRODUCT — a distinct `repo_key`, never a working tree. A
1406
+ * product with four checkouts was four rows of the same name, and which checkout you are standing
1407
+ * in is a detail of where you are, not of what you are working on.
1408
+ *
1409
+ * `productsOf` is PURE — no DOM, no `state`, no fetch — so `test.js` can walk the grouping and all
1410
+ * four count states without a browser, and it is on `window` for the browser suite too. It is the
1411
+ * only place the four count states are decided, which is what stops a fifth appearing.
1412
+ */
1413
+ function productKey(w) { return (w && (w.repo_key || w.root)) || null; }
1414
+ // The FOUR states of `.psw-ct`, and there is no fifth. `0` is NEVER rendered for an unknown: a
1415
+ // board that is still deriving and a repository that genuinely holds no records are different
1416
+ // facts, and a zero standing in for either of them is a number nobody read.
1417
+ function boardCountState(payload) {
1418
+ if (!payload) return { state: 'reading', value: null, error: null };
1419
+ if (payload.pending) return { state: 'reading', value: null, error: null };
1420
+ if (payload.ok === false) return { state: 'error', value: null, error: payload.error || 'the board could not be derived' };
1421
+ if (payload.empty === true) return { state: 'empty', value: null, error: null };
1422
+ const total = payload.data && Number.isFinite(payload.data.total) ? payload.data.total : null;
1423
+ // ok:true with no total is a payload that has not finished saying anything — still reading, not
1424
+ // a count of nothing.
1425
+ if (total == null) return { state: 'reading', value: null, error: null };
1426
+ return { state: 'count', value: total, error: null };
1427
+ }
1428
+ function productsOf(workspaces, sessions, boards) {
1429
+ const byKey = new Map();
1430
+ for (const w of (workspaces || [])) {
1431
+ const key = productKey(w);
1432
+ if (!key) continue;
1433
+ if (!byKey.has(key)) byKey.set(key, { key, name: w.repo_name || w.name || key, path: key, trees: [], live: 0, count: null });
1434
+ const p = byKey.get(key);
1435
+ p.trees.push(w);
1436
+ // the main worktree names the product; a linked worktree is `repo ⑂ branch` and would rename it
1437
+ if (w.root === key || w.home) p.name = w.repo_name || w.name || p.name;
1438
+ }
1439
+ const wsById = new Map((workspaces || []).map((w) => [w.id, w]));
1440
+ for (const s of (sessions || [])) {
1441
+ if (!s || s.state === 'exited') continue;
1442
+ const w = wsById.get(s.ws);
1443
+ const p = w && byKey.get(productKey(w));
1444
+ if (p) p.live++;
1445
+ }
1446
+ for (const p of byKey.values()) p.count = boardCountState((boards || {})[p.key]);
1447
+ // Two repositories with the same basename stay distinguishable: the key IS the absolute path of
1448
+ // the main worktree, it is what `path` carries, and it is what the button's tooltip prints.
1449
+ return [...byKey.values()].sort((a, b) => String(a.name).localeCompare(String(b.name)) || String(a.key).localeCompare(String(b.key)));
1450
+ }
1451
+ window.productsOf = productsOf;
1452
+
1453
+ function renderProducts() {
1454
+ const box = $('prodSwitch'), host = $('pswList');
1455
+ if (!box || !host) return;
1456
+ // Product-scoped, so it is hidden outside `product` mode — a row of repository buttons above a
1457
+ // list of skills scopes nothing the list below it is showing.
1458
+ box.hidden = state.mode !== 'product';
1459
+ const products = productsOf(state.workspaces, state.sessions, state.boards);
1460
+ const curW = state.workspaces.find((x) => x.id === state.wsId);
1461
+ const curKey = curW ? productKey(curW) : null;
1462
+ const CT = {
1463
+ reading: () => 'reading…',
1464
+ empty: () => 'no initiative records',
1465
+ error: () => 'unreadable',
1466
+ count: (n) => `${n} initiative${n === 1 ? '' : 's'}`,
1467
+ };
1468
+ host.innerHTML = products.map((p) => {
1469
+ const on = p.key === curKey;
1470
+ const c = p.count;
1471
+ // R2 — the selected product carries BOTH a colour and the ▸ mark, and the accessible name
1472
+ // spells the state out, because the count is the one thing on this button that can be wrong.
1473
+ const label = `${p.name} — ${CT[c.state](c.value)}${p.live ? `, ${p.live} live` : ''}`;
1474
+ return `<button class="psw-b" data-product="${esc(p.key)}" aria-current="${on}"
1475
+ aria-label="${esc(label)}" title="${esc(p.path)}">
1476
+ <span class="psw-mk" aria-hidden="true">▸</span>
1477
+ <span class="psw-nm">${esc(p.name)}</span>
1478
+ <span class="psw-ct" data-state="${esc(c.state)}"${c.error ? ` title="${esc(c.error)}"` : ''}>${esc(CT[c.state](c.value))}</span>
1479
+ ${p.live ? `<span class="psw-live">${p.live} live</span>` : ''}
1480
+ </button>`;
1481
+ }).join('');
1482
+ // R6.2, the second half of the invariant renderSpine's self-check used to carry alone: EVERY
1483
+ // product with at least one registered tree has exactly one button here. Without this, dropping
1484
+ // the `other repositories` bucket could make a whole repository unreachable with nothing on
1485
+ // screen saying it existed — the tidy-row-of-buttons failure this feature is named after.
1486
+ const keys = new Set(state.workspaces.map(productKey).filter(Boolean));
1487
+ const drawn = host.querySelectorAll('.psw-b').length;
1488
+ if (drawn !== keys.size) {
1489
+ console.warn(`[switcher] R6.2: ${keys.size} products registered, ${drawn} buttons — a repository is unreachable`);
1490
+ }
1491
+ host.querySelectorAll('[data-product]').forEach((b) => b.onclick = () => selectProduct(b.dataset.product));
1492
+ }
1493
+ /* R4 — switching lands somewhere TRUE, by a stated rule, and never on another product. */
1494
+ function selectProduct(key) {
1495
+ const trees = state.workspaces.filter((w) => productKey(w) === key);
1496
+ if (!trees.length) return;
1497
+ const remembered = store.get('lastTree:' + key);
1498
+ const pick = trees.find((w) => w.id === remembered) // 1 · the tree you last stood in here
1499
+ || trees.find((w) => w.root === key) // 2 · the repository's main worktree
1500
+ || trees[0]; // 3 · the first registered tree
1501
+ selectWorkspace(pick.id);
1502
+ }
1503
+ // The first initiative the spine renders for a product — R4's clause 2, used ONLY once that
1504
+ // product's own spine read has landed. Objectives first, then the health budgets, then unanchored:
1505
+ // the same order the spine paints, so "first" means the first row on screen.
1506
+ function firstSpineInitiative(sp) {
1507
+ if (!sp || sp.ok === false) return null;
1508
+ for (const o of (sp.objectives || [])) {
1509
+ for (const k of (o.krs || [])) { if (k.initiatives && k.initiatives.length) return k.initiatives[0].id; }
1510
+ if ((o.direct || []).length) return o.direct[0].id;
1511
+ }
1512
+ for (const b of (sp.budgets || [])) { if ((b.initiatives || []).length) return b.initiatives[0].id; }
1513
+ if ((sp.unanchored || []).length) return sp.unanchored[0].id;
1514
+ return null;
1515
+ }
1516
+
1517
+ /* ── workspace-repo-ingest ──────────────────────────────────────────────────────────────────────
1518
+ *
1519
+ * Four stages — acquire · scan · review · accept — and the client's whole job is to render what
1520
+ * each one actually produced. The three outcomes that must never collapse into each other are the
1521
+ * point of every branch below: a clone that FAILED is red and carries git's own stderr; a scan that
1522
+ * SUCCEEDED and found nothing is calm and says which path it read; a thing the repository simply
1523
+ * does not have is stated as an absence. "0 initiatives found" over a failed clone is the exact
1524
+ * sentence this feature exists to make unwritable.
1525
+ *
1526
+ * There is no confirm-all, no accept-remaining, and no keyboard path that confirms more than one
1527
+ * item per activation. The tedium IS the gate.
1528
+ */
1529
+ async function loadIngest() {
1530
+ try {
1531
+ const r = await fetch('/api/ingest');
1532
+ const j = await r.json();
1533
+ if (j && j.ok) { state.ingest = j.proposals || []; state.ingestRead = true; }
1534
+ } catch { /* the socket's {t:'ingest'} push is the live path; a failed poll changes nothing */ }
1535
+ renderTree();
1536
+ }
1537
+ function proposalById(id) { return state.ingest.find((p) => p.id === id) || null; }
1538
+ function openIngest(id) {
1539
+ state.detail = { kind: 'ingest', id, wsId: state.wsId };
1540
+ showRail();
1541
+ renderLibrary();
1542
+ mobPushDetail('ingest');
1543
+ }
1544
+ // How old the scan is, said out loud. A proposal reviewed against a repository that has moved is
1545
+ // the mismatch `applyChange` refuses, so the age is on screen BEFORE a confirm rather than after.
1546
+ function ingStamp(pr) {
1547
+ if (!pr.scanned_at) return `<span class="ing-stamp">not scanned yet</span>`;
1548
+ const d = new Date(pr.scanned_at);
1549
+ return `<span class="ing-stamp">scanned ${esc(relTime2(pr.scanned_at))} · ${esc(d.toLocaleTimeString())}</span>`;
1550
+ }
1551
+ const ING_OPEN = (pr) => (pr.initiatives || []).filter((i) => i.disposition === 'open');
1552
+ /* R8 — the `ingested` group in the spine, headed with the count of UNCONFIRMED anchors and the
1553
+ * source ref. It is a property of the proposal, not of the repository: nothing in it is in this
1554
+ * repo until an item is confirmed, and the group disappears when the proposal closes. */
1555
+ function ingestGroup() {
1556
+ const open = state.ingest.filter((p) => p.stage !== 'closed');
1557
+ if (!open.length) return '';
1558
+ return open.map((pr) => {
1559
+ const n = ING_OPEN(pr).length;
1560
+ const what = pr.ok === false ? 'the ingest failed — open it for the reason'
1561
+ : pr.stage === 'acquire' ? 'cloning — nothing has been scanned yet'
1562
+ : pr.stage === 'scan' ? 'scanning — read-only'
1563
+ : n ? `${n} anchor${n === 1 ? '' : 's'} unconfirmed — nothing is written until you confirm one`
1564
+ : 'every item dispositioned';
1565
+ return `<div class="sp-obj" data-ingest="1"><div class="sp-o"><span class="id">ingested</span>
1566
+ <span class="nm">proposed from ${esc((pr.source && pr.source.ref) || '?')} — a clone PMOS read, not this repository's records</span>
1567
+ <span class="ct">${esc(String(n))}</span></div>
1568
+ <button class="sp-i" data-ing-open="${esc(pr.id)}"><span class="r1"><span class="nm">${esc(pr.id)}</span></span>
1569
+ <span class="r2"><span class="stg">${esc(pr.stage)}</span><span class="nx">${esc(what)}</span></span></button></div>`;
1570
+ }).join('');
1571
+ }
1572
+ // The source toggle. BOTH consequence lines stay on screen, one under each option, so the
1573
+ // difference between registering a folder and ingesting a repository is stated in the affordance
1574
+ // before the click — never learned afterwards from what happened.
1575
+ function setAddSource(src) {
1576
+ state.addSrc = src === 'github' ? 'github' : 'local';
1577
+ const seg = $('addWsSource');
1578
+ if (seg) seg.querySelectorAll('[data-src]').forEach((b) => b.setAttribute('aria-checked', String(b.dataset.src === state.addSrc)));
1579
+ const loc = $('addWsLocal'), gh = $('addWsGithub');
1580
+ if (loc) loc.hidden = state.addSrc !== 'local';
1581
+ if (gh) gh.hidden = state.addSrc !== 'github';
1582
+ }
1583
+ async function startIngest(ref) {
1584
+ if (!ref || !ref.trim()) { flash('name the repository — owner/name, or a GitHub URL'); return; }
1585
+ let j = { ok: false, error: 'could not reach the workspace server' };
1586
+ try {
1587
+ j = await (await fetch('/api/ingest', { method: 'POST', headers: { 'Content-Type': 'application/json' },
1588
+ body: JSON.stringify({ source: 'github', ref: ref.trim() }) })).json();
1589
+ } catch {}
1590
+ if (!j.ok) { flash(j.error || 'the ingest was refused', 12000); return; }
1591
+ closeAddWs();
1592
+ // The POST returns an id immediately and the clone runs off the request path — so the review
1593
+ // surface opens on the `acquire` stage and the socket fills it in. It never shows a count.
1594
+ state.ingest = state.ingest.filter((p) => p.id !== j.id).concat([{ id: j.id, stage: j.stage, source: { kind: 'github', ref: ref.trim() }, ok: true, error: null, log: [], unreadable: [], initiatives: [], adrs: [] }]);
1595
+ openIngest(j.id);
1596
+ }
1597
+ async function postJSON(path, body) {
1598
+ let j = { ok: false, error: 'could not reach the workspace server' };
1599
+ try {
1600
+ j = await (await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).json();
1601
+ } catch {}
1602
+ return j;
1603
+ }
1604
+
1605
+ /* ── workspace-profiles ─────────────────────────────────────────────────────────────────────────
1606
+ *
1607
+ * The set of profiles is EXACTLY the set of descriptors the server read. Two chips are never
1608
+ * hard-coded: a picker that always renders `work` and `personal` on a machine holding neither file
1609
+ * makes a choice that applies nothing, and the session that follows looks identical to one that
1610
+ * worked. Four payload states, four renderings, and none of them stands in for another.
1611
+ *
1612
+ * Nothing in this section may draw a dot, a tick or a colour that reads as "connected". A
1613
+ * descriptor DECLARES mcp servers; PMOS runs no MCP client and checks no reachability, so every
1614
+ * appearance of an mcp name carries the word `declared` and the file it came from.
1615
+ */
1616
+ async function loadProfiles() {
1617
+ if (state.profilesLoading) return;
1618
+ state.profilesLoading = true;
1619
+ let payload;
1620
+ try { payload = await (await fetch('/api/profiles')).json(); }
1621
+ catch (e) { payload = { ok: false, error: `could not reach the workspace server for the profile descriptors — ${String((e && e.message) || e)}`, dir: null, profiles: [], broken: [] }; }
1622
+ state.profilesLoading = false;
1623
+ state.profiles = payload;
1624
+ renderProfileChips();
1625
+ if (state.detail.kind === 'profiles' || state.detail.kind === 'connections' || state.detail.kind === 'agent'
1626
+ || state.detail.kind === 'sched') renderLibrary();
1627
+ }
1628
+ function profileById(id) {
1629
+ const p = state.profiles;
1630
+ return (p && (p.profiles || []).find((x) => x.id === id)) || null;
1631
+ }
1632
+ // R8.2 — PMOS's OWN memory, keyed by agent binary name. It preselects; it starts nothing.
1633
+ function preferredProfileFor(agent) {
1634
+ const p = state.profiles;
1635
+ const id = p && p.prefs ? p.prefs[agent] : null;
1636
+ return id && profileById(id) ? id : ''; // a preference for a descriptor that no longer reads preselects nothing
1637
+ }
1638
+ // One chip. The mcp list sits at the SAME weight as the account beside it — two equally ordinary
1639
+ // facts read out of one file — because emphasis on the mcp names would read as a claim about
1640
+ // reachability that nothing in this repository can make.
1641
+ function profChip(x, on) {
1642
+ const mcp = (x.mcp || []).length
1643
+ ? `mcp declared by this profile: ${(x.mcp || []).join(' ')}`
1644
+ : 'no mcp servers declared in this profile';
1645
+ const env = x.env_count ? `${x.env_count} environment variable${x.env_count === 1 ? '' : 's'}` : 'no environment overlay';
1646
+ return `<button type="button" class="prof-chip" data-profile="${esc(x.id)}" role="radio" aria-checked="${on}"
1647
+ aria-label="${esc(`${x.label || x.id} — ${x.account || 'no account declared'}; ${env}; ${mcp}`)}"
1648
+ title="${esc((x.env_keys || []).join(' ') || 'this profile declares no environment overlay')}">
1649
+ <span class="nm">${esc(x.label || x.id)}</span>
1650
+ <span class="prof-mcp">${esc(x.account || 'no account declared')} · ${esc(env)}</span>
1651
+ <span class="prof-mcp">${esc(mcp)}</span>
1652
+ </button>`;
1653
+ }
1654
+ function renderProfileChips() {
1655
+ const host = $('newProfile');
1656
+ if (!host) return;
1657
+ const p = state.profiles;
1658
+ const none = (on) => `<button type="button" class="prof-chip" data-profile="" role="radio" aria-checked="${on}"
1659
+ aria-label="no profile — this session starts with the workspace's default environment">
1660
+ <span class="nm">no profile</span><span class="prof-mcp">the workspace's default environment</span></button>`;
1661
+ if (!p) { host.innerHTML = `<span class="prof-mcp">reading ~/.claude/profiles/…</span>`; return; }
1662
+ // If the read failed we do not know what is on this machine, so nothing is offered but the
1663
+ // honest no-profile choice — and the reason is red and verbatim.
1664
+ if (p.ok === false) {
1665
+ host.innerHTML = `<div class="prof-bad" role="alert"><b>the profile descriptors could not be read</b> — ${esc(p.error || '')}</div>`
1666
+ + none(state.newProfile === '');
1667
+ wireProfileChips(host);
1668
+ return;
1669
+ }
1670
+ const chips = (p.profiles || []).map((x) => profChip(x, state.newProfile === x.id)).join('');
1671
+ // R4 — a broken descriptor is RED, named, and NOT offered; every good chip beside it still is.
1672
+ // One bad file must never turn the surface into a stated absence: that reports a failure as an
1673
+ // emptiness, which is the confusion the honest-state contract exists to prevent.
1674
+ const broken = (p.broken || []).map((b) =>
1675
+ `<div class="prof-bad" role="alert"><b>${esc(b.file)}</b> — ${esc(b.error)}</div>`).join('');
1676
+ // R2 — absence and a successful read of nothing are DIFFERENT, and both name the path.
1677
+ const note = p.absent
1678
+ ? `<span class="prof-mcp">${esc(p.dir || '~/.claude/profiles')} does not exist — nothing was read because there is nothing to read. Sessions start exactly as they do today, recorded as having started under no profile.</span>`
1679
+ : (!(p.profiles || []).length && !(p.broken || []).length
1680
+ ? `<span class="prof-mcp">${esc(p.dir || '~/.claude/profiles')} holds no descriptors — the read succeeded and found none.</span>`
1681
+ : '');
1682
+ host.innerHTML = note + chips + none(state.newProfile === '') + broken;
1683
+ wireProfileChips(host);
1684
+ }
1685
+ function wireProfileChips(host) {
1686
+ host.querySelectorAll('[data-profile]').forEach((b) => b.onclick = () => {
1687
+ state.newProfile = b.dataset.profile;
1688
+ renderProfileChips();
1689
+ });
1690
+ }
1691
+ /* R6 — the pane's provenance line. `profile_line` is computed SERVER-SIDE by profileLine() and
1692
+ * printed verbatim, so no surface can re-word the five sentences it can say — and so the
1693
+ * difference between "not recorded" and "no profile chosen" survives JSON, which `undefined` would
1694
+ * not. Nothing is ever written into the pane to produce this: input written to a tmux pane goes to
1695
+ * the agent, a ring-buffer append is erased on the next reattach, and a line injected into
1696
+ * scrollback is indistinguishable from one the agent printed. */
1697
+ function paintProfileLines() {
1698
+ ['profLine0', 'profLine1'].forEach((id, gi) => {
1699
+ const el = $(id);
1700
+ if (!el) return;
1701
+ const active = state.groups[gi] && state.groups[gi].activeId;
1702
+ const s = active ? state.sessions.find((x) => x.id === active) : null;
1703
+ // No pane at all → nothing to describe, and `.profline:empty` collapses it. A session that IS
1704
+ // here always gets a sentence, including "profile: not recorded".
1705
+ el.textContent = s ? String(s.profile_line || '') : '';
1706
+ });
569
1707
  }
570
1708
  async function forgetWorkspace(id) {
571
1709
  const w = state.workspaces.find((x) => x.id === id);
@@ -588,9 +1726,11 @@ async function addWorkspace(root) {
588
1726
  // `ms` is longer for messages carrying something to READ — a worktree's path is useless if it
589
1727
  // vanishes before the PM can copy it out.
590
1728
  function flash(msg, ms = 4000) {
591
- const el = $('wsFoot'); const prev = el.textContent;
1729
+ const el = $('wsFoot');
592
1730
  el.textContent = msg;
593
- setTimeout(() => { el.textContent = prev; }, ms);
1731
+ // Recompute rather than restoring the captured string: the session count can change while a
1732
+ // message is up, and putting the old text back would show a stale reading with no way to tell.
1733
+ setTimeout(() => paintFoot(), ms);
594
1734
  }
595
1735
 
596
1736
  // The glyph and the word both come from the SHARED join module (/lib/session-state.js), never
@@ -655,6 +1795,7 @@ function renderSessions() {
655
1795
  renderTree();
656
1796
  renderTabs();
657
1797
  updateEmptyOverlay();
1798
+ paintFoot(); // the strip's live-session count is a reading, so it moves with the list
658
1799
  paintRename();
659
1800
  }
660
1801
  // The sub-line: the run's OWN focus, not `agent · cwd` (which was identical on every row in a
@@ -825,7 +1966,23 @@ function renderTabStrip(gi) {
825
1966
  if (!host) return;
826
1967
  const act = state.groups[gi].activeId;
827
1968
  // wrap = two SIBLING buttons (select + close) — no interactive-inside-interactive nesting
828
- host.innerHTML = mySessions().map((s) => {
1969
+ // Viewer tabs are listed in the primary strip only. Listing them in both would imply a group
1970
+ // assignment they do not have, and clicking one in group 1 would have to do something arbitrary.
1971
+ const viewTabs = gi !== 0 ? '' : state.viewers.map((v) => {
1972
+ const id = viewerId(v);
1973
+ const on = state.viewActive === id;
1974
+ // KIND is carried by the mark AND by the accessible name, never by colour alone.
1975
+ const mark = v.kind === 'doc' ? '▪' : '▤';
1976
+ const what = v.kind === 'doc' ? 'document' : 'surface';
1977
+ return `
1978
+ <div class="stab-wrap" data-kind="${v.kind}" data-vid="${esc(id)}" data-active="${on}">
1979
+ <button class="stab" role="tab" data-vid="${esc(id)}" aria-selected="${on}"
1980
+ aria-label="${esc(v.title)} — ${what}">
1981
+ <span class="g" aria-hidden="true">${mark}</span>${esc(v.title)}</button>
1982
+ <button class="x" data-vclose="${esc(id)}" aria-label="close ${what} ${esc(v.title)}">✕</button>
1983
+ </div>`;
1984
+ }).join('');
1985
+ host.innerHTML = viewTabs + mySessions().map((s) => {
829
1986
  // the glyph and the accessible name read the DERIVED state, like the wrapper's data-state
830
1987
  // beside them. Reading the raw `s.state` here drew a `working` ● in the `quiet` colour — the
831
1988
  // shape said one thing and the colour said another, on the surface most looked at.
@@ -843,11 +2000,17 @@ function renderTabStrip(gi) {
843
2000
  b.onclick = () => selectSession(b.dataset.id, gi);
844
2001
  b.ondblclick = () => startRename(b.dataset.id);
845
2002
  });
846
- // live tab ✕ kills the process (state flips to exited); exited tab ✕ removes it for good
847
- host.querySelectorAll('.x').forEach((b) => b.onclick = () => send({ t: 'kill', id: b.dataset.close }));
2003
+ host.querySelectorAll('.stab[data-vid]').forEach((b) => { b.onclick = () => selectViewer(b.dataset.vid); });
2004
+ // Closing is symmetrical but NOT identical, and the verbs must not leak across the two kinds:
2005
+ // a live session tab's ✕ kills the process, an exited one's removes it for good, and a viewer's
2006
+ // closes the document. `data-close` and `data-vclose` keep them separate at the wiring, so a
2007
+ // ✕ can never send `kill` for a path or `closeViewer` for a session id.
2008
+ host.querySelectorAll('.x[data-close]').forEach((b) => b.onclick = () => send({ t: 'kill', id: b.dataset.close }));
2009
+ host.querySelectorAll('.x[data-vclose]').forEach((b) => b.onclick = () => closeViewer(b.dataset.vclose));
848
2010
  }
849
2011
  function syncActiveState() {
850
2012
  document.querySelectorAll('.sess,.stab-wrap').forEach((b) => {
2013
+ if (!b.dataset.id) return; // a viewer tab has no session state to derive
851
2014
  const s = state.sessions.find((x) => x.id === b.dataset.id);
852
2015
  // the DERIVED state (working|quiet|exited), never the raw one. Writing `s.state` here put
853
2016
  // `working` back over a row that renderSessions/tickQuiet had correctly painted `quiet`, so
@@ -971,11 +2134,56 @@ document.addEventListener('visibilitychange', () => { if (!document.hidden) refi
971
2134
  // first paint measures the fallback font; the real metrics arrive with the webfont
972
2135
  if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => { for (const [id] of state.terms) fitOne(id); });
973
2136
 
2137
+ /* ── the viewer (workspace-viewer-column) ──────────────────────────────────────────────────────
2138
+ * Documents, images and summoned surfaces are TABS in the terminal column. One viewer pane spans
2139
+ * the group row rather than one per group, and that is the stated rule the PRD's R6 asked for:
2140
+ *
2141
+ * A viewer tab is NOT group-assignable. A session belongs to a group because two agents
2142
+ * genuinely run side by side; a document has no such property, and giving it one would invent
2143
+ * an affinity the object does not have. An active viewer covers both groups; closing it or
2144
+ * selecting a session returns them exactly as they were — `split` is untouched throughout.
2145
+ *
2146
+ * This also keeps the editor single-instance. Two doc bodies would mean two places `ed` could be
2147
+ * mounted, and the save path has no concept of which one is authoritative.
2148
+ */
2149
+ function viewerId(v) { return v.kind + ':' + v.key; }
2150
+ function openViewer(v) {
2151
+ const id = viewerId(v);
2152
+ if (!state.viewers.some((x) => viewerId(x) === id)) state.viewers.push(v);
2153
+ state.viewActive = id;
2154
+ renderTabs();
2155
+ paintGroups();
2156
+ refit();
2157
+ }
2158
+ function closeViewer(id) {
2159
+ state.viewers = state.viewers.filter((x) => viewerId(x) !== id);
2160
+ if (state.viewActive === id) {
2161
+ // fall back to another open viewer before falling back to the terminal, so closing one of
2162
+ // several documents does not dump the PM out of the viewer entirely
2163
+ const next = state.viewers[state.viewers.length - 1];
2164
+ state.viewActive = next ? viewerId(next) : null;
2165
+ }
2166
+ renderTabs();
2167
+ paintGroups();
2168
+ refit();
2169
+ }
2170
+ function selectViewer(id) {
2171
+ const v = state.viewers.find((x) => viewerId(x) === id);
2172
+ if (!v) return;
2173
+ state.viewActive = id;
2174
+ if (v.kind === 'doc') openDoc(v.key); // re-render is cheap and keeps one read path
2175
+ else openSurface(v.key);
2176
+ paintGroups();
2177
+ refit();
2178
+ }
974
2179
  function selectSession(id, g) {
2180
+ // Selecting a session leaves the viewer: the terminal has to be visible to be typed into, and a
2181
+ // tab strip where selecting a tab does not change what is on screen is the worst of both.
2182
+ if (state.viewActive) { state.viewActive = null; paintGroups(); refit(); }
975
2183
  const s = state.sessions.find((x) => x.id === id);
976
2184
  if (!s) return;
977
2185
  // selecting a session from the palette may cross workspaces — follow it there
978
- if (s.ws !== state.wsId) { state.wsId = s.ws; store.set('wsid', s.ws); renderWorkspaces(); renderSessions(); loadLibrary(); loadNeeds(); state.branch = null; renderBranch(); loadBranch(); loadLayouts(); }
2186
+ if (s.ws !== state.wsId) { state.wsId = s.ws; store.set('wsid', s.ws); renderProducts(); renderSessions(); loadLibrary(); loadNeeds(); state.branch = null; renderBranch(); loadBranch(); loadLayouts(); }
979
2187
  // Which group shows it: the one asked for; else the group already showing it (so a click in the
980
2188
  // sidebar focuses where it lives rather than yanking it across); else the focused group.
981
2189
  let want = typeof g === 'number' ? g : state.groups.findIndex((gr) => gr.activeId === id);
@@ -995,6 +2203,9 @@ function selectSession(id, g) {
995
2203
  fitOne(id);
996
2204
  const t = state.terms.get(id); if (t) setTimeout(() => t.term.focus(), 0);
997
2205
  updateExitedOverlay(); updateEmptyOverlay();
2206
+ // On the phone the terminal is a SCREEN, and reaching it is a navigation event. The pane itself
2207
+ // is untouched by this — `.term-col` is never re-parented, so the instance and its socket live.
2208
+ mobPushTerminal(id, s.name);
998
2209
  }
999
2210
  // One place decides what is visible where: each group shows exactly its own activeId, every other
1000
2211
  // pane is hidden, and the sidebar/tab selection marks follow.
@@ -1011,9 +2222,26 @@ function paintGroups() {
1011
2222
  host.querySelectorAll('.stab').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.id === act)));
1012
2223
  host.querySelectorAll('.stab-wrap').forEach((b) => b.dataset.active = String(b.dataset.id === act));
1013
2224
  });
2225
+ // The viewer covers the group row when a viewer tab is active. `.groups` is hidden rather than
2226
+ // unmounted so the terminals keep their scrollback and their PTYs — this is a visibility change,
2227
+ // never a teardown.
2228
+ const vp = $('viewPane');
2229
+ if (vp) {
2230
+ vp.hidden = !state.viewActive;
2231
+ // The GROUPS box stays mounted and visible — the tab strips live inside it, and a viewer that
2232
+ // hides the strip hides its own way back. Only the terminal HOSTS are covered, and they are
2233
+ // covered rather than unmounted so the terminals keep their scrollback and their PTYs.
2234
+ $('groups').dataset.viewer = state.viewActive ? 'on' : 'off';
2235
+ }
2236
+ GROUP_TABS.forEach((tid) => {
2237
+ const h = $(tid); if (!h) return;
2238
+ h.querySelectorAll('.stab[data-vid]').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.vid === state.viewActive)));
2239
+ h.querySelectorAll('.stab-wrap[data-vid]').forEach((b) => b.dataset.active = String(b.dataset.vid === state.viewActive));
2240
+ });
1014
2241
  $('group0').dataset.focus = String(state.focusG === 0);
1015
2242
  $('group1').dataset.focus = String(state.focusG === 1);
1016
2243
  $('noSess1').classList.toggle('on', splitOn() && !state.groups[1].activeId);
2244
+ paintProfileLines(); // each group's provenance line follows whichever pane it is showing
1017
2245
  }
1018
2246
  function clearActive() {
1019
2247
  state.groups.forEach((g) => { g.activeId = null; });
@@ -1105,23 +2333,6 @@ document.addEventListener('visibilitychange', () => { if (!document.hidden) mayb
1105
2333
  * slate — r1 of the specimen did exactly that — makes a complete discovery read as nine failures.
1106
2334
  * `n/a` here is never inferred: it comes from the engine's own step status.
1107
2335
  */
1108
- const DOSSIER_GROUPS = [
1109
- { key: 'strategy', label: 'strategy', roles: ['record', 'brief', 'research', 'scoping', 'intake', 'OKR'] },
1110
- { key: 'spec', label: 'spec', roles: ['PRD', 'rubric', 'user stories', 'explainer'] },
1111
- { key: 'design', label: 'design', roles: ['design brief', 'design', 'copy brief', 'copy', 'specimen', 'grilling'] },
1112
- { key: 'build', label: 'build', roles: ['reconcile', 'harness', 'fixture'] },
1113
- { key: 'learning', label: 'learning', roles: ['eval', 'findings', 'dogfood', 'rejected'] },
1114
- ];
1115
- // Which ladder steps speak for which group, so a group can say "n/a for this lane" in the engine's
1116
- // own words instead of a guess of our own.
1117
- const GROUP_STEPS = {
1118
- strategy: ['route', 'anchor'],
1119
- spec: ['understand', 'spec', 'prototype'],
1120
- design: ['prototype'],
1121
- build: ['priors', 'contract', 'build', 'quality'],
1122
- learning: ['review', 'explain', 'accept'],
1123
- };
1124
-
1125
2336
  function detailWs() {
1126
2337
  const id = state.detail.wsId || state.wsId;
1127
2338
  return state.workspaces.find((w) => w.id === id) || null;
@@ -1129,8 +2340,6 @@ function detailWs() {
1129
2340
  // The library for whichever tree the detail is about — NOT necessarily the selected one. Fetched on
1130
2341
  // demand and cached, because a dossier must be openable for a tree the PM is not standing in.
1131
2342
  async function ensureLib(wsId) {
1132
- if (!wsId) return null;
1133
- if (wsId === state.wsId && state.lib) { state.libByWs[wsId] = state.lib; return state.lib; }
1134
2343
  if (state.libByWs[wsId]) return state.libByWs[wsId];
1135
2344
  try {
1136
2345
  const r = await fetch('/api/library?ws=' + encodeURIComponent(wsId));
@@ -1146,6 +2355,7 @@ async function openInitiative(id, wsId) {
1146
2355
  state.lens = 'dossier';
1147
2356
  showRail();
1148
2357
  renderLibrary();
2358
+ mobPushDetail('initiative'); // no-op off the phone
1149
2359
  await ensureLib(state.detail.wsId);
1150
2360
  renderLibrary();
1151
2361
  }
@@ -1159,9 +2369,18 @@ function openRepoDetail(repoKey) {
1159
2369
  function renderLensTabs() {
1160
2370
  const host = $('lensTabs');
1161
2371
  if (!host) return;
1162
- const views = state.detail.kind === 'initiative'
1163
- ? [['dossier', 'dossier'], ['folders', 'folders'], ['scheduled', 'scheduled'], ['history', 'history']]
1164
- : [['folders', 'folders'], ['scheduled', 'scheduled'], ['history', 'history']];
2372
+ // workspace-pm-spine — folders · scheduled · history are no longer STANDING furniture. An
2373
+ // initiative's panel is its dossier; the other three are summoned when they are the job (⌘K, or
2374
+ // the schedule composer), which is what they were: a file map you want while searching, and a
2375
+ // schedule list you want while making one. A permanent tab strip for three views nobody opens by
2376
+ // default costs a row of chrome on every render. `state.lens` still accepts them, so anything
2377
+ // that sets it — the palette, a deep link, a stored preference — keeps working unchanged.
2378
+ // Nothing to switch between on an initiative (its panel IS the view) or on any of the six mode
2379
+ // panels (none of them is a sub-view of an initiative or a repository). A repository is the one
2380
+ // selection with a real alternative.
2381
+ const views = (state.detail.kind === 'initiative' || D2_KINDS[state.detail.kind])
2382
+ ? []
2383
+ : [['folders', 'folders']];
1165
2384
  host.innerHTML = views.map(([k, label]) =>
1166
2385
  `<button role="tab" data-lens="${k}" aria-selected="${state.lens === k}">${esc(label)}</button>`).join('');
1167
2386
  host.querySelectorAll('button').forEach((b) => b.onclick = () => {
@@ -1184,116 +2403,785 @@ function renderReading() {
1184
2403
  el.title = w.root;
1185
2404
  }
1186
2405
 
1187
- function dossierRow(a) {
1188
- return `<button class="dz-art" data-doc="${esc(a.path)}">
1189
- <span class="role">${esc(a.role || 'doc')}</span>
1190
- <span class="pth">${esc(a.path)}</span></button>`;
2406
+ // ── column 2 · the detail panel ────────────────────────────────────────────────────────────────
2407
+ // The panel answers three questions in this order: where is this, what happens next, what is in
2408
+ // the way. Everything else on it is reference. That ordering is the whole design — the version
2409
+ // this replaces rendered workflow, evals, sessions and artifacts as four sections of equal
2410
+ // visual weight, so the answer to "what do I do next" had to be READ for rather than seen.
2411
+ //
2412
+ // Nothing here is stored. Every value is derived at read time from the board, the library and the
2413
+ // live session list, which is why a stale panel is not a failure mode this code can have.
2414
+
2415
+ // The engine's step vocabulary → the glyph and the tone the panel paints with. `na` is deliberately
2416
+ // its own thing: it does not mean "not done yet", it means this lane never asks for the step, and
2417
+ // showing it as an empty circle beside real pending work would misreport the lane.
2418
+ const D2_DOT = { done: '✓', current: '●', blocked: '✗', pending: '○', unknown: '○', na: '–' };
2419
+ function d2Tone(row) {
2420
+ if (!row) return 'info';
2421
+ if ((row.blockers || []).length) return 'bad';
2422
+ if (row.accepted && !(row.steps || []).some((s) => s.status === 'pending' || s.status === 'blocked')) return 'ok';
2423
+ if (row.next && row.next.action) return 'warn';
2424
+ return 'ok';
2425
+ }
2426
+ // Which step the panel opens ON. The engine names it (`next.step`); the fallbacks exist so a board
2427
+ // that answers with blockers but no `next` still lands the highlight somewhere true.
2428
+ function d2Here(row) {
2429
+ if (!row) return -1;
2430
+ const steps = row.steps || [];
2431
+ if (row.next && row.next.step) {
2432
+ const i = steps.findIndex((s) => s.key === row.next.step);
2433
+ if (i >= 0) return i;
2434
+ }
2435
+ const b = steps.findIndex((s) => s.status === 'blocked');
2436
+ if (b >= 0) return b;
2437
+ return steps.findIndex((s) => s.status === 'pending');
2438
+ }
2439
+ // The file a next-action points at, so the action can be acted on rather than just read. The
2440
+ // action text names it — the engine writes real repo-relative paths into those sentences — so the
2441
+ // path is lifted from the sentence rather than guessed, and when there is none the initiative's
2442
+ // own record is the honest fallback. A chip is only ever rendered when a path was actually found.
2443
+ const D2_PATH = /\b((?:planning|okf|skills|build-skills|workspace|scripts|prototype)\/[\w./-]+|[\w./-]+\.(?:md|jsonl|json|yaml|yml|js))\b/;
2444
+ function d2Cmd(step, row) {
2445
+ const text = (step && step.next) || '';
2446
+ const m = text.match(D2_PATH);
2447
+ if (m) return m[1];
2448
+ if (step && step.status === 'blocked' && row && row.record) return row.record;
2449
+ return null;
2450
+ }
2451
+ // `pending` is passed explicitly because "no record" and "not derived yet" are different facts and
2452
+ // only one of them is a finding. Claiming the first while the second is true is the panel telling
2453
+ // the PM their initiative has no record when the engine simply has not answered.
2454
+ function d2Pills(row, group, pending) {
2455
+ const pills = [];
2456
+ if (row && row.lane) pills.push(['lane', row.lane]);
2457
+ if (row && row.anchor) pills.push(['anchor', String(row.anchor).split(/[(—]/)[0].trim()]);
2458
+ else if (row) pills.push(['bad', 'unanchored']);
2459
+ if (row && row.legacy) pills.push(['warn', 'legacy']);
2460
+ if (row && row.accepted) pills.push(['flow', 'accepted']);
2461
+ if (!row && !pending) pills.push(['warn', 'no record']);
2462
+ if (pending) pills.push(['flow', 'deriving…']);
2463
+ return pills.length
2464
+ ? `<div class="d2-pills">${pills.map(([t, v]) => `<span class="d2-pill" data-t="${t}">${esc(v)}</span>`).join('')}</div>`
2465
+ : '';
2466
+ }
2467
+ function d2Title(id, group, row, pending) {
2468
+ const t = (group && group.title) || (row && row.title) || '';
2469
+ return `<div class="d2-title">
2470
+ <div class="nm" title="${esc(id)}">${esc(id)}</div>
2471
+ ${t ? `<div class="tag">${esc(t)}</div>` : ''}
2472
+ ${d2Pills(row, group, pending)}</div>`;
2473
+ }
2474
+ // The ladder. The step the initiative is ON carries the action at 14px — larger than the stage
2475
+ // names above it and the reference rows below it — because it is the one line being scanned for.
2476
+ // `anno` is workspace-jira-handoff R8: one line appended to the `build` and `review` steps when the
2477
+ // record declares a handoff. R9 is why it is a separate parameter rather than a field consulted
2478
+ // inside: `data-s`, `D2_DOT`, `d2Here` and `d2Tone` are computed from `row` ALONE and receive
2479
+ // byte-identical input with and without a declaration. A declared field may add context to a
2480
+ // derived verdict; it may never become one, and here it structurally cannot.
2481
+ const D2_ANNO_STEPS = { build: 1, review: 1 };
2482
+ function d2Rail(row, anno) {
2483
+ const steps = row.steps || [];
2484
+ if (!steps.length) return '';
2485
+ const here = d2Here(row);
2486
+ const tone = d2Tone(row);
2487
+ return `<div class="d2-rail">${steps.map((s, i) => {
2488
+ const isHere = i === here;
2489
+ const action = isHere ? ((row.next && row.next.action) || s.next || '') : '';
2490
+ const cmd = isHere ? d2Cmd(s, row) : null;
2491
+ const note = anno && D2_ANNO_STEPS[s.key] ? anno : null;
2492
+ return `<div class="d2-step" data-s="${esc(s.status || 'pending')}" data-here="${isHere ? 1 : 0}" data-tone="${tone}">
2493
+ <span class="dot" aria-hidden="true">${D2_DOT[s.status] || '○'}</span>
2494
+ <div class="txt">
2495
+ <div class="r1"><span class="k">${esc(s.label || s.key)}</span>
2496
+ <span class="t" title="${esc(s.evidence || '')}">${esc(s.evidence || '')}</span></div>
2497
+ ${action ? `<div class="d2-act"><span class="d2-next">next</span>${esc(action)}</div>` : ''}
2498
+ ${note ? `<div class="d2-anno">${esc(note)}</div>` : ''}
2499
+ ${cmd ? `<button class="d2-cmd" data-doc="${esc(cmd)}" title="open ${esc(cmd)}">${esc(cmd)}</button>` : ''}
2500
+ </div></div>`;
2501
+ }).join('')}</div>`;
2502
+ }
2503
+ // The standing answer, for an initiative with no ladder to hang it on. Without this the no-record
2504
+ // case would show a title and then nothing, which reads as "fine" rather than "nothing is claimed".
2505
+ function d2Answer(text, tone) {
2506
+ return `<div class="d2-ans" data-tone="${tone}"><span class="d2-next">next</span>${esc(text)}</div>`;
2507
+ }
2508
+ function d2Evals(id) {
2509
+ const rd = state.radar && state.radar.ok !== false ? state.radar : null;
2510
+ if (!rd) return '';
2511
+ const rows = Array.isArray(rd.worst) ? rd.worst.filter((w) => w.id === id || w.initiative_id === id) : [];
2512
+ // A rubric that was never run is NOT a pass. Saying so out loud is the whole point of the row —
2513
+ // a blank here silently reads as green, which is exactly how a calibration number rots.
2514
+ if (!rows.length) {
2515
+ return `<div class="d2-field"><span class="lbl">evals</span>
2516
+ <button class="d2-evals" data-doc="planning/evals/evals.jsonl">no verdict recorded — this is not a pass</button></div>`;
2517
+ }
2518
+ const w = rows[0];
2519
+ const v = String(w.verdict || '').toLowerCase();
2520
+ const kind = v.startsWith('pass') ? 'pass' : v.startsWith('fail') ? 'fail' : '';
2521
+ const extra = rows.length > 1 ? ` · ${rows.length} verdicts` : '';
2522
+ return `<div class="d2-field"><span class="lbl">evals</span>
2523
+ <button class="d2-evals" data-v="${kind}" data-doc="planning/evals/evals.jsonl">${esc(w.verdict || '—')}${w.score != null ? ` — ${esc(String(w.score))}` : ''}${esc(extra)}</button></div>`;
2524
+ }
2525
+ function d2Sessions(id, handoffTo) {
2526
+ const trees = state.workspaces.filter((w) => { const i = initiativeOf(w); return i.id === id; });
2527
+ const ids = new Set(trees.map((w) => w.id));
2528
+ const mine = state.sessions.filter((s) => ids.has(s.ws));
2529
+ // No tree and no sessions are both NORMAL reads, so they read as prose, never as a fault.
2530
+ // R10 (jira-handoff) — when the record declares a handoff, the same calm line gains one more
2531
+ // FACT rather than a different tone: the build is not on this machine, and it says where it went.
2532
+ if (!trees.length) {
2533
+ return `<div class="d2-field"><span class="lbl">sessions</span><div class="val">
2534
+ <span class="d2-quiet">No working tree in this repository is on <b>initiative/${esc(id)}</b> —
2535
+ a normal state. The initiative exists whether or not anything is checked out for it.${handoffTo
2536
+ ? ` — the build is not on this machine; it was handed to <b>${esc(handoffTo)}</b>.` : ''}</span>
2537
+ </div></div>`;
2538
+ }
2539
+ const rows = mine.map((s) => {
2540
+ const st = stateLabel(s);
2541
+ return `<button class="d2-sess" data-dz-sess="${esc(s.id)}" data-state="${esc(st.shown || '')}"
2542
+ aria-current="${s.id === state.active}">
2543
+ <span class="g" aria-hidden="true">${GLYPH[st.shown] || '•'}</span>
2544
+ <span class="nm">${esc(s.name || s.id)}</span>
2545
+ <span class="st">${esc(st.text || '')}</span></button>`;
2546
+ }).join('');
2547
+ return `<div class="d2-field"><span class="lbl">sessions</span><div class="val">
2548
+ ${rows || '<span class="d2-quiet">No sessions running on this initiative yet — normal, not an error.</span>'}
2549
+ <button class="d2-btn" data-dz-start="${esc(trees[0].id)}">+ start ${mine.length ? 'another' : 'a session'}</button>
2550
+ </div></div>`;
2551
+ }
2552
+ function d2Artifacts(group) {
2553
+ const arts = (group && group.artifacts) || [];
2554
+ if (!arts.length) {
2555
+ return `<div class="d2-field"><span class="lbl">artifacts</span><div class="val">
2556
+ <span class="d2-quiet italic">None filed. The read succeeded — this is a verified empty.</span></div></div>`;
2557
+ }
2558
+ return `<div class="d2-field"><span class="lbl">artifacts</span><div class="val">
2559
+ ${arts.map((a) => `<button class="d2-art" data-doc="${esc(a.path)}" title="${esc(a.path)}">
2560
+ <span class="role">${esc(String(a.role || 'doc').toUpperCase())}</span>
2561
+ <span class="path">${esc(a.path)}</span></button>`).join('')}
2562
+ </div></div>`;
2563
+ }
2564
+ // Provenance, always. A stage without the branch it was derived from is a claim the data does not
2565
+ // support, so the panel never ends without naming where it read and how old that read is.
2566
+ function d2Foot(w, board) {
2567
+ const stamp = board && board.as_of ? new Date(board.as_of).toLocaleTimeString() : null;
2568
+ const br = (w && w.branch) || null;
2569
+ return `<div class="d2-foot">${br ? `derived on ${esc(br)}` : 'no working tree'}${stamp ? ` · as of ${esc(stamp)}` : ''}${board && board.stale ? ' · refreshing' : ''}</div>`;
2570
+ }
2571
+ // The ladder for ONE initiative, fetched per (repo, id) and cached. Guarded against a stale paint:
2572
+ // a slower read for an initiative the PM has already navigated away from must never overwrite the
2573
+ // panel that is on screen.
2574
+ function ladderKey(w, id) { return `${(w && w.repo_key) || 'none'}::${id}`; }
2575
+ function ladderFor(w, id) { return state.ladders[ladderKey(w, id)] || null; }
2576
+ async function loadLadder(w, id) {
2577
+ const key = ladderKey(w, id);
2578
+ if (state.ladderWanted.has(key)) return;
2579
+ state.ladderWanted.add(key);
2580
+ try {
2581
+ const r = await fetch(`/api/console?name=ladder&id=${encodeURIComponent(id)}&ws=${encodeURIComponent((w && w.id) || '')}`);
2582
+ state.ladders[key] = await r.json();
2583
+ } catch (e) {
2584
+ state.ladders[key] = { ok: false, error: String((e && e.message) || e) };
2585
+ }
2586
+ if (state.detail.kind === 'initiative' && ladderKey(detailWs(), state.detail.id) === key) renderLibrary();
2587
+ }
2588
+
2589
+ /* ── workspace-jira-handoff ─────────────────────────────────────────────────────────────────────
2590
+ *
2591
+ * A DECLARATION is something the PM typed into `planning/initiatives/<id>.md`. A READING is
2592
+ * something PMOS went and looked at. Everything this section renders is the first kind, and every
2593
+ * row says so in its own visible text — never only in a tooltip, never only in one banner at the
2594
+ * top of the section, and never by colour alone. `links.connected` is the literal `false` on every
2595
+ * code path in `lib/handoff.js`; there is no connected variant here and none is coming.
2596
+ *
2597
+ * The values arrive from frontmatter UNESCAPED — deliberately, so the module's whitelist test can
2598
+ * compare them against the record byte for byte. `esc()` at every interpolation site below,
2599
+ * INCLUDING the href, is therefore this renderer's job and not an optional tidiness.
2600
+ *
2601
+ * Cached per (repo_key, id) like the ladder, with the same stale-paint guard: a slower read for an
2602
+ * initiative the PM has already navigated away from must never overwrite the panel on screen.
2603
+ */
2604
+ function handoffFor(w, id) { return state.handoffs[ladderKey(w, id)] || null; }
2605
+ async function loadHandoff(w, id) {
2606
+ const key = ladderKey(w, id);
2607
+ if (state.handoffWanted.has(key)) return;
2608
+ state.handoffWanted.add(key);
2609
+ try {
2610
+ const r = await fetch(`/api/console?name=handoff&id=${encodeURIComponent(id)}&ws=${encodeURIComponent((w && w.id) || '')}`);
2611
+ state.handoffs[key] = await r.json();
2612
+ } catch (e) {
2613
+ state.handoffs[key] = { ok: false, error: `the handoff declaration could not be read — ${String((e && e.message) || e)}` };
2614
+ }
2615
+ if (state.detail.kind === 'initiative' && ladderKey(detailWs(), state.detail.id) === key) renderLibrary();
2616
+ }
2617
+ // The declared model for the initiative the panel is describing, or null while the read is in
2618
+ // flight / when the record declares no handoff. Callers render NOTHING for null — the panel is
2619
+ // unchanged for every initiative that was not handed off.
2620
+ function handoffLinks(id) {
2621
+ const w = detailWs();
2622
+ const h = handoffFor(w, id);
2623
+ if (!h) { loadHandoff(w, id); return null; }
2624
+ if (h.ok === false || h.absent || !h.links) return null;
2625
+ return h.links.handoff ? h.links : null;
2626
+ }
2627
+ /* R8 — the annotation `d2Rail` appends to the `build` and `review` steps. R9 is the constraint that
2628
+ * matters: this is a STRING passed alongside, never an input to `data-s`, `D2_DOT`, `d2Here` or
2629
+ * `d2Tone`. A record's typed claim may add context to a derived verdict; it may never become one. */
2630
+ function handoffAnnotation(L) {
2631
+ if (!L) return null;
2632
+ const who = L.to ? ` to ${L.to}` : '';
2633
+ const when = L.at ? ` on ${L.at}` : '';
2634
+ const sys = (L.systems || []).length ? L.systems.join(' or ') : 'the receiving system';
2635
+ return `handed off${who}${when} — this step reflects evidence in THIS repository only; `
2636
+ + `PMOS cannot read ${sys} and is not claiming to know what happened there`;
2637
+ }
2638
+ /* R3 — one row per declared reference, rendered BETWEEN d2Rail and d2Evals: the handoff is the
2639
+ * reason the local evidence below it is thin, and the explanation must precede the thing it
2640
+ * explains. R7 — a malformed declaration is the only failure this surface can have, so it is the
2641
+ * only thing that may render red, and it must. */
2642
+ function d2Links(id) {
2643
+ const w = detailWs();
2644
+ const h = handoffFor(w, id);
2645
+ if (!h) { loadHandoff(w, id); return ''; }
2646
+ if (h.ok === false) {
2647
+ return `<div class="d2-bad"><span class="lbl">handoff</span><div class="why">${esc(h.error || '')}</div></div>`;
2648
+ }
2649
+ const L = h.links;
2650
+ if (!L || !L.handoff) return ''; // not handed off — the panel is unchanged
2651
+ const head = `<div class="d2-field"><span class="lbl">handoff</span><div class="val">
2652
+ <span class="d2-quiet">${esc(L.to ? `handed to ${L.to}` : 'no recipient declared')}${L.at ? ` · ${esc(L.at)}` : ''}${L.flow ? ` · flow ${esc(L.flow)}` : ''}</span>
2653
+ </div></div>`;
2654
+ const problems = (L.problems || []).map((p) => `<div class="d2-bad"><span class="lbl">malformed declaration</span>
2655
+ <div class="why"><b>${esc(p.id)}</b>${p.declared == null ? '' : `: <code>${esc(p.declared)}</code>`} — ${esc(p.why)}</div></div>`).join('');
2656
+ const rows = (L.links || []).map((l) => {
2657
+ // R5 — clickable ONLY when the declared value is an absolute http(s) URL, and then it is
2658
+ // byte-identical to what the record says. PMOS never constructs a URL it was not given.
2659
+ const ref = l.href
2660
+ ? `<a href="${esc(l.href)}" target="_blank" rel="noopener">${esc(l.declared)}</a>`
2661
+ : `<span class="d2-inert">${esc(l.declared)}<button type="button" data-copy="${esc(l.declared)}"
2662
+ aria-label="Copy ${esc(l.declared)}">copy</button></span>`;
2663
+ return `<div class="d2-link" role="group" aria-label="${esc(`${l.declared} — ${l.state_text}`)}">
2664
+ <span class="d2-kind"><span aria-hidden="true">⇄ </span>${esc(l.kind)}</span>
2665
+ ${ref}
2666
+ <span class="d2-notconn">${esc(l.state_text)}</span>
2667
+ ${l.inert_why ? `<span class="d2-notconn">${esc(l.inert_why)}</span>` : ''}
2668
+ </div>`;
2669
+ }).join('');
2670
+ // R6 — a declared `flow:` with no references is a VERIFIED EMPTY, in the panel's own words for
2671
+ // one, never a section that quietly does not render.
2672
+ const empty = L.empty
2673
+ ? `<div class="d2-link"><span class="d2-notconn">${esc(L.flow ? `${L.flow} declared` : 'a handoff is declared')};
2674
+ no external references recorded in the record yet. The read succeeded — this is a verified empty.</span></div>`
2675
+ : '';
2676
+ return head + problems + `<div class="d2-links">${rows}${empty}</div>`;
1191
2677
  }
1192
2678
  function renderDossier() {
1193
2679
  const host = $('library');
1194
2680
  const id = state.detail.id;
1195
2681
  const w = detailWs();
1196
2682
  const board = w ? boardFor(w) : null;
1197
- const row = board && board.data && Array.isArray(board.data.live)
1198
- ? board.data.live.find((r) => r.id === id) : null;
2683
+ // Prefer the initiative's OWN ladder; fall back to the board only while that read is in flight,
2684
+ // so the panel shows the board's answer rather than nothing during the first paint.
2685
+ const lad = ladderFor(w, id);
2686
+ if (!lad) loadLadder(w, id);
2687
+ const row = (lad && lad.ok && lad.row) || (board && board.data && Array.isArray(board.data.live)
2688
+ ? board.data.live.find((r) => r.id === id) : null);
1199
2689
  const lib = (w && state.libByWs[w.id]) || (w && w.id === state.wsId ? state.lib : null);
1200
2690
  const group = lib && Array.isArray(lib.initiatives) ? lib.initiatives.find((g) => g.id === id) : null;
2691
+ // The record's DECLARED handoff. Null for every initiative that was not handed off, and null
2692
+ // while the read is in flight — the panel is byte-identical to today in both cases.
2693
+ const L = handoffLinks(id);
2694
+ const anno = handoffAnnotation(L);
2695
+ const handedTo = L ? L.to : null;
1201
2696
 
1202
- if (board && board.ok === false) {
1203
- host.innerHTML = csError('the initiative ladder', board.error || 'the board could not be derived');
2697
+ // R5 — a FAILED read is red and says why. Never a tidy empty state standing in for a broken one.
2698
+ // The initiative's own read is authoritative here: the board failing is a fact about the board.
2699
+ const failed = (lad && lad.ok === false && lad.error) || (!lad && board && board.ok === false && board.error);
2700
+ if (failed) {
2701
+ host.innerHTML = d2Title(id, group, null)
2702
+ + `<div class="d2-bad"><span class="lbl">read failed</span>
2703
+ <div class="why">${esc(failed)}</div></div>`
2704
+ + d2Foot(w, board);
2705
+ wireDossier();
1204
2706
  return;
1205
2707
  }
1206
- const head = `<div class="dz-head">
1207
- <div class="dz-id">${esc(id)}</div>
1208
- ${group && group.title ? `<div class="dz-t">${esc(group.title)}</div>` : ''}
1209
- <div class="dz-meta">
1210
- ${row ? `<span class="pill lane">${esc(row.lane || '')}</span>` : ''}
1211
- ${row ? `<span class="pill">${row.stage === 'accepted' ? 'accepted' : 'stage ' + esc(row.stage || '')}</span>` : ''}
1212
- ${row && row.anchor ? `<span class="pill">${esc(String(row.anchor).split(/[(—]/)[0].trim())}</span>` : ''}
1213
- ${row && row.legacy ? '<span class="pill">legacy</span>' : ''}
1214
- </div>
1215
- ${row ? dossierAttrib(w, board) : ''}
1216
- </div>`;
1217
-
1218
- if (board && board.pending) {
1219
- host.innerHTML = head + `<div class="dz-wait">Deriving this initiative's ladder…
1220
- <span class="sub">the derivation shells git once per initiative and runs in a child process,
1221
- so the workspace stays responsive while it does.</span></div>` + dossierArtifacts(group, null);
2708
+ // Still deriving — either this initiative's own read is in flight, or the board is. Neither is a
2709
+ // finding, and neither may be painted as one.
2710
+ if (!lad && !row) {
2711
+ host.innerHTML = d2Title(id, group, null, true)
2712
+ + `<div class="d2-noladder">Deriving this initiative's ladder… the engine resolves each
2713
+ artifact's first appearance by shelling git, so the read takes a moment.</div>`
2714
+ + d2Links(id) + d2Sessions(id, handedTo) + d2Artifacts(group) + d2Foot(w, board);
1222
2715
  wireDossier();
1223
2716
  return;
1224
2717
  }
1225
2718
  if (!row) {
1226
- host.innerHTML = head + `<div class="dz-none">No ladder for <b>${esc(id)}</b> in this repository's board —
1227
- it has no initiative record, or the record is not readable. The artifacts below are what the
1228
- library can see; nothing about its position in the loop is being claimed.</div>`
1229
- + dossierArtifacts(group, null);
2719
+ host.innerHTML = d2Title(id, group, null)
2720
+ + d2Answer('File an initiative record — artifacts may exist, but no loop position is claimed.', 'warn')
2721
+ + `<div class="d2-noladder">No initiative record for <b>${esc(id)}</b> in this repository, so
2722
+ nothing about its position in the loop is being claimed. The read itself succeeded — what
2723
+ follows is what the library can see.</div>`
2724
+ + d2Links(id) + d2Evals(id) + d2Sessions(id, handedTo) + d2Artifacts(group) + d2Foot(w, board);
1230
2725
  wireDossier();
1231
2726
  return;
1232
2727
  }
1233
- host.innerHTML = head + dossierNext(row) + dossierArtifacts(group, row);
2728
+ // Accepted AND owing render together — an initiative can be accepted with steps still open, and
2729
+ // a green badge on its own would be the watermelon flag.
2730
+ const open = (row.steps || []).filter((s) => s.status === 'pending' || s.status === 'blocked');
2731
+ let standing = '';
2732
+ if (row.accepted && open.length) {
2733
+ standing = d2Answer(`Accepted, and ${open.length} step${open.length === 1 ? '' : 's'} never closed: ${open.map((s) => s.key).join(' · ')}.`, 'warn');
2734
+ } else if (!row.next || !row.next.action) {
2735
+ standing = d2Answer(row.accepted ? 'Accepted — nothing owed. New work should be a new initiative.'
2736
+ : 'Nothing owed. Every step this lane asks for is closed.', 'ok');
2737
+ }
2738
+ // R3 — d2Links sits BETWEEN d2Rail and d2Evals: the handoff is the reason the local evidence
2739
+ // below it is thin, and the explanation must precede the thing it explains.
2740
+ host.innerHTML = d2Title(id, group, row) + standing + d2Rail(row, anno)
2741
+ + d2Links(id) + d2Evals(id) + d2Sessions(id, handedTo) + d2Artifacts(group) + d2Foot(w, board);
1234
2742
  wireDossier();
1235
2743
  }
1236
- // R5 in the rail: the same attribution the tree rows carry. A stage without its branch is a claim
1237
- // the data does not support.
1238
- function dossierAttrib(w, board) {
1239
- const stamp = board && board.as_of ? new Date(board.as_of).toLocaleTimeString() : null;
1240
- return `<div class="dz-attrib">derived on <b>${esc((w && w.branch) || '?')}</b>${stamp ? ` · as of ${esc(stamp)}` : ''}${board && board.stale ? ' · refreshing' : ''}
1241
- <span class="sub">build and quality are scoped to this branch's commits, not to all of history</span></div>`;
2744
+ function wireDossier() {
2745
+ const host = $('library');
2746
+ host.querySelectorAll('[data-doc]').forEach((b) => b.onclick = () => openDoc(b.dataset.doc));
2747
+ // R5 (jira-handoff) — a reference PMOS was given but cannot turn into a location gets a copy
2748
+ // affordance rather than a link that goes somewhere PMOS invented.
2749
+ host.querySelectorAll('[data-copy]').forEach((b) => b.onclick = async () => {
2750
+ try { await navigator.clipboard.writeText(b.dataset.copy); flash('copied ' + b.dataset.copy); }
2751
+ catch { flash('could not copy — select the reference and press ⌘C'); }
2752
+ });
2753
+ // selecting a session in the initiative's panel is what puts it in the terminal column
2754
+ host.querySelectorAll('[data-dz-sess]').forEach((b) => b.onclick = () => selectSession(b.dataset.dzSess));
2755
+ host.querySelectorAll('[data-dz-start]').forEach((b) => b.onclick = () => {
2756
+ const p = selectWorkspace(b.dataset.dzStart);
2757
+ openNewForm();
2758
+ p.then(() => { if (!$('newForm').hidden) renderNewTarget(); });
2759
+ });
1242
2760
  }
1243
- // Accepted and owing render TOGETHER — an initiative can be accepted with steps still open, and a
1244
- // green badge alone would be the watermelon.
1245
- function dossierNext(row) {
1246
- const open = (row.steps || []).filter((s) => s.status === 'pending' || s.status === 'blocked');
1247
- const accepted = row.stage === 'accepted';
1248
- if (accepted && open.length) {
1249
- return `<div class="dz-next owed"><b>still owed · ${open.length} step${open.length === 1 ? '' : 's'}</b>
1250
- Accepted, and these never closed: ${open.map((s) => esc(s.key)).join(' · ')}.</div>`;
2761
+
2762
+ // ── the detail panels for the six non-initiative modes ──────────────────────────────────────
2763
+ // Each one describes a row the PM picked in column 1, and each ends by naming the file it read.
2764
+ // Where an action would be a lie — an "edit" on a ladder PMOS derives rather than stores — the
2765
+ // panel says why instead of offering a control that does nothing.
2766
+ function d2Nothing(msg) {
2767
+ $('library').innerHTML = `<div class="d2-empty">${esc(msg)}</div>`;
2768
+ }
2769
+ /* ── the profile panels (workspace-profiles R8.2, R8.3, R10) ────────────────────────────────────
2770
+ * Three pickers, each honest about what it persists — and the two below are NOT the composer's:
2771
+ * the composer's chip applies to one process, the agent preference is PMOS remembering something,
2772
+ * and a schedule's profile is a field on a schedule record. Saying which is which is the whole of
2773
+ * R8, because all three look like the same control.
2774
+ */
2775
+ function profChipRow(selected, extra) {
2776
+ const p = state.profiles;
2777
+ if (!p) return `<span class="prof-mcp">reading ~/.claude/profiles/…</span>`;
2778
+ if (p.ok === false) return `<div class="prof-bad" role="alert"><b>the profile descriptors could not be read</b> — ${esc(p.error || '')}</div>`;
2779
+ const chips = (p.profiles || []).map((x) =>
2780
+ `<button type="button" class="prof-chip" data-pf="${esc(x.id)}" role="radio" aria-checked="${selected === x.id}">
2781
+ <span class="nm">${esc(x.label || x.id)}</span>
2782
+ <span class="prof-mcp">${esc((x.mcp || []).length ? 'mcp declared: ' + x.mcp.join(' ') : 'no mcp declared')}</span></button>`).join('');
2783
+ const none = `<button type="button" class="prof-chip" data-pf="" role="radio" aria-checked="${!selected}">
2784
+ <span class="nm">${esc(extra || 'no profile')}</span></button>`;
2785
+ if (!(p.profiles || []).length) {
2786
+ return `<span class="prof-mcp">${esc(p.dir || '~/.claude/profiles')} ${p.absent ? 'does not exist — there is nothing to choose from' : 'holds no descriptors — the read succeeded and found none'}.</span>` + none;
1251
2787
  }
1252
- if (row.next && row.next.action) {
1253
- return `<div class="dz-next"><b>next · ${esc(row.next.step || '')}</b>${esc(row.next.action)}</div>`;
2788
+ return `<div class="prof" role="radiogroup" aria-label="Profile">${chips}${none}</div>`;
2789
+ }
2790
+ // R8.2 — a workspace PREFERENCE, remembered by PMOS, used to preselect the composer. It is not an
2791
+ // agent attribute, it starts nothing, and the label says both.
2792
+ function agentProfileField(a) {
2793
+ const cur = state.profiles && state.profiles.prefs ? (state.profiles.prefs[a.name] || '') : '';
2794
+ const stale = cur && !profileById(cur);
2795
+ return `<div class="d2-field"><span class="lbl">profile</span><div class="val">
2796
+ ${profChipRow(stale ? '' : cur, 'no preference')}
2797
+ <p class="prof-note">A <b>workspace preference, remembered by PMOS</b>, used to preselect the
2798
+ composer when you start a <code>${esc(a.name)}</code> session. It is not an attribute of the agent —
2799
+ PMOS keeps no agent registry — and choosing one starts nothing.</p>
2800
+ ${stale ? `<div class="prof-bad" role="alert">the remembered profile <b>${esc(cur)}</b> is not among the descriptors that were read, so nothing is preselected</div>` : ''}
2801
+ </div></div>`;
2802
+ }
2803
+ function wireAgentProfile(a) {
2804
+ $('library').querySelectorAll('[data-pf]').forEach((b) => b.onclick = async () => {
2805
+ const j = await postJSON('/api/profiles/prefer', { agent: a.name, profile: b.dataset.pf || null });
2806
+ if (!j.ok) { flash(j.error || 'the preference was not remembered', 10000); return; }
2807
+ if (state.profiles) state.profiles.prefs = j.prefs || {};
2808
+ // the composer's preselection follows the preference the moment it changes
2809
+ if (state.newAgent === a.name) state.newProfile = preferredProfileFor(a.name);
2810
+ renderLibrary(); renderProfileChips();
2811
+ });
2812
+ }
2813
+ // R8.3 — a schedule's profile is a field on the schedule record, used by fireSchedule when it
2814
+ // spawns an AGENT-kind task. A skill-kind task runs in this process and never creates a PTY, so
2815
+ // the row states that instead of offering a control that would do nothing.
2816
+ function schedProfileField(t) {
2817
+ if (t.profile_applies === false) {
2818
+ return `<div class="d2-field"><span class="lbl">profile</span><div class="val">
2819
+ <span class="d2-quiet">This task runs <b>in the workspace server's own process</b> — there is no
2820
+ terminal session and therefore no environment to apply a profile to. A control here would do nothing.</span>
2821
+ </div></div>`;
1254
2822
  }
1255
- return `<div class="dz-next done"><b>nothing owed</b>Every step this lane asks for is closed.</div>`;
2823
+ return `<div class="d2-field"><span class="lbl">profile</span><div class="val">
2824
+ ${profChipRow(t.profile || '', 'no profile')}
2825
+ <p class="prof-note">${t.profile ? '' : 'No profile is recorded on this schedule. '}Applied when the
2826
+ scheduler starts this task's session, exactly as the composer applies one — chosen at process
2827
+ creation and never changed afterwards.</p></div></div>`;
2828
+ }
2829
+ function wireSchedProfile(t) {
2830
+ $('library').querySelectorAll('[data-pf]').forEach((b) => b.onclick = async () => {
2831
+ const j = await postJSON('/api/schedules/update', { id: t.id, profile: b.dataset.pf || null });
2832
+ if (!j.ok) { flash(j.error || 'the schedule was not updated', 10000); return; }
2833
+ state.sched = null; loadSchedules();
2834
+ if (j.schedule) { t.profile = j.schedule.profile; renderLibrary(); }
2835
+ });
1256
2836
  }
1257
- function dossierArtifacts(group, row) {
1258
- const arts = (group && group.artifacts) || [];
1259
- const used = new Set();
1260
- const stepBy = {};
1261
- for (const st of (row && row.steps) || []) stepBy[st.key] = st;
1262
-
1263
- const sections = DOSSIER_GROUPS.map((g) => {
1264
- const mine = arts.filter((a) => g.roles.includes(a.role));
1265
- mine.forEach((a) => used.add(a.path));
1266
- // the lane's own verdict on this group, in the engine's words — never our inference
1267
- const naStep = (GROUP_STEPS[g.key] || []).map((k) => stepBy[k]).filter(Boolean);
1268
- const allNa = naStep.length && naStep.every((st) => st.status === 'na');
1269
- if (!mine.length && allNa) {
1270
- return `<div class="dz-sec"><h5>${esc(g.label)} <span class="c">n/a</span></h5>
1271
- <div class="dz-na">${esc(naStep[0].evidence || 'not required in this lane')}</div></div>`;
1272
- }
1273
- if (!mine.length) {
1274
- return `<div class="dz-sec"><h5>${esc(g.label)} <span class="c">nothing yet</span></h5>
1275
- <div class="dz-na">No ${esc(g.label)} artifact is filed for this initiative.</div></div>`;
1276
- }
1277
- return `<div class="dz-sec"><h5>${esc(g.label)} <span class="c">${mine.length}</span></h5>
1278
- ${mine.map(dossierRow).join('')}</div>`;
2837
+ // R10 — profile detail shows only what was read or derived. "What runs on it" is derived at read
2838
+ // time from live sessions' RECORDED profiles; a session PMOS did not start is counted nowhere, and
2839
+ // the panel says so rather than leaving the absence to be inferred from a smaller number.
2840
+ function profilesPanel() {
2841
+ const p = state.profiles;
2842
+ if (!p) return `<div class="d2-title"><div class="nm">profiles</div><div class="tag">reading…</div></div>`;
2843
+ const head = `<div class="d2-title"><div class="nm">profiles</div>
2844
+ <div class="tag">descriptors read from disk · nothing is verified</div></div>`;
2845
+ if (p.ok === false) {
2846
+ return head + `<div class="d2-bad"><span class="lbl">read failed</span><div class="why">${esc(p.error || '')}</div></div>`
2847
+ + `<div class="d2-acts"><button class="d2-btn" data-prof-reload="1">read again</button></div>`
2848
+ + `<div class="d2-foot">${esc(p.dir || '~/.claude/profiles')}</div>`;
2849
+ }
2850
+ const broken = (p.broken || []).map((b) => `<div class="prof-bad" role="alert"><b>${esc(b.file)}</b> — ${esc(b.error)}</div>`).join('');
2851
+ if (!(p.profiles || []).length) {
2852
+ return head + `<div class="d2-field"><span class="lbl">descriptors</span><div class="val">
2853
+ <span class="d2-quiet">${esc(p.dir || '~/.claude/profiles')} ${p.absent
2854
+ ? 'does not exist. Nothing was read because there is nothing to read — sessions start exactly as they do today, recorded as having started under no profile.'
2855
+ : 'holds no descriptors. The read succeeded and found none.'}</span></div></div>`
2856
+ + broken + `<div class="d2-foot">${esc(p.dir || '~/.claude/profiles')}</div>`;
2857
+ }
2858
+ const rows = (p.profiles || []).map((x) => {
2859
+ const live = state.sessions.filter((s) => s.profile_id === x.id && s.state !== 'exited');
2860
+ const sched = (Array.isArray(state.sched) ? state.sched : []).filter((s) => s.profile === x.id);
2861
+ return `<div class="d2-field"><span class="lbl">${esc(x.label || x.id)}</span><div class="val">
2862
+ <span class="d2-quiet">${esc(x.account || 'no account declared in this descriptor')}</span>
2863
+ <span class="prof-mcp">${esc(x.env_count ? `${x.env_count} environment variable${x.env_count === 1 ? '' : 's'}: ${(x.env_keys || []).join(' ')}` : 'no environment overlay declared')}</span>
2864
+ <span class="prof-mcp">${esc((x.mcp || []).length ? `mcp declared by this profile: ${x.mcp.join(' ')}` : 'no mcp servers declared in this profile')} — declared, never verified; PMOS runs no MCP client</span>
2865
+ ${live.map((s) => `<button class="d2-sess" data-dz-sess="${esc(s.id)}"><span class="nm">${esc(s.name || s.id)}</span>
2866
+ <span class="st">running on this profile</span></button>`).join('')}
2867
+ <span class="prof-mcp">${live.length ? '' : 'no session PMOS started is running on this profile'}${sched.length ? ` · ${sched.length} schedule${sched.length === 1 ? '' : 's'} name it` : ''}</span>
2868
+ </div></div>`;
1279
2869
  }).join('');
1280
-
1281
- const rest = arts.filter((a) => !used.has(a.path));
1282
- const other = rest.length
1283
- ? `<div class="dz-sec"><h5>other <span class="c">${rest.length}</span></h5>${rest.map(dossierRow).join('')}</div>`
1284
- : '';
1285
- const none = !arts.length
1286
- ? `<div class="dz-na">The library sees no artifacts for this initiative yet. This is a verified
1287
- empty — the read succeeded.</div>` : '';
1288
- return `<div class="dz-arts">${sections}${other}${none}</div>`;
2870
+ return head + rows + broken
2871
+ + `<div class="d2-noladder">A session PMOS did not start — a tmux pane adopted at boot, or one
2872
+ created before profiles existed — records no profile and is counted nowhere above. That is
2873
+ stated rather than absorbed into a smaller number.</div>`
2874
+ + `<div class="d2-foot">${esc(p.dir || '~/.claude/profiles')}</div>`;
2875
+ }
2876
+ function connectionsPanel() {
2877
+ const p = state.profiles;
2878
+ const head = `<div class="d2-title"><div class="nm">connections</div>
2879
+ <div class="tag">nothing is connected — this lists what profiles DECLARE</div></div>`;
2880
+ if (!p) return head + `<div class="d2-field"><span class="lbl">reading</span><div class="val"><span class="d2-quiet">reading the profile descriptors…</span></div></div>`;
2881
+ if (p.ok === false) {
2882
+ return head + `<div class="d2-bad"><span class="lbl">read failed</span><div class="why">${esc(p.error || '')}</div></div>`
2883
+ + `<div class="d2-foot">${esc(p.dir || '~/.claude/profiles')}</div>`;
2884
+ }
2885
+ // One row per (profile, mcp name). No dot, no colour-as-status, no sync time, no count of
2886
+ // anything outside this machine. When no descriptor was read at all there are NO ROWS — an empty
2887
+ // list of connections would still be a list, and a list implies there is something to connect to.
2888
+ const rows = [];
2889
+ for (const x of (p.profiles || [])) {
2890
+ for (const m of (x.mcp || [])) {
2891
+ rows.push(`<div class="d2-link"><span class="d2-kind"><span aria-hidden="true">⇄ </span>${esc(m)}</span>
2892
+ <span class="d2-inert">${esc(x.label || x.id)}</span>
2893
+ <span class="d2-notconn">declared by this profile — not connected; PMOS runs no MCP client, holds no credential and performs no reachability check</span></div>`);
2894
+ }
2895
+ }
2896
+ if (!rows.length) {
2897
+ return head + `<div class="d2-field"><span class="lbl">declared</span><div class="val">
2898
+ <span class="d2-quiet">${esc(p.dir || '~/.claude/profiles')} ${p.absent
2899
+ ? 'does not exist, so no profile declares an MCP server.'
2900
+ : 'holds no descriptor that declares an MCP server.'} There is nothing to list — not an empty
2901
+ connection list, but no declaration at all.</span></div></div>`
2902
+ + `<div class="d2-foot">${esc(p.dir || '~/.claude/profiles')}</div>`;
2903
+ }
2904
+ return head + `<div class="d2-links">${rows.join('')}</div>`
2905
+ + `<div class="d2-noladder">These are strings in a JSON file on this machine. PMOS does not
2906
+ start them, reach them or check them, so nothing here is a status.</div>`
2907
+ + `<div class="d2-foot">${esc(p.dir || '~/.claude/profiles')}/*.json</div>`;
1289
2908
  }
1290
- function wireDossier() {
1291
- $('library').querySelectorAll('[data-doc]').forEach((b) => b.onclick = () => openDoc(b.dataset.doc));
2909
+
2910
+ /* ── the ingest review panel (workspace-repo-ingest R6/R7/R9/R10/R12) ───────────────────────── */
2911
+ const ING_A_TEXT = { declared: '', guessed: 'guessed', none: 'none' };
2912
+ function ingAnchor(it) {
2913
+ const st = it.anchor_state || 'none';
2914
+ const val = it.anchor_value || '';
2915
+ const label = st === 'declared' ? `anchor declared by the record — ${val}`
2916
+ : st === 'guessed' ? `anchor guessed by ingest, not confirmed — ${val}`
2917
+ : 'no anchor — this repository declares no objectives';
2918
+ const body = st === 'declared' ? esc(val)
2919
+ : st === 'guessed' ? `<span aria-hidden="true">◆</span> ${esc(ING_A_TEXT.guessed)} ${esc(val)}`
2920
+ : esc(ING_A_TEXT.none);
2921
+ return `<span class="ing-anchor" data-a="${esc(st)}" aria-label="${esc(label)}">${body}</span>`;
2922
+ }
2923
+ function ingestPanel(pr) {
2924
+ const src = (pr.source && pr.source.ref) || '?';
2925
+ const head = `<div class="d2-title"><div class="nm">${esc(src)}</div>
2926
+ <div class="tag">ingest ${esc(pr.id)} · stage ${esc(pr.stage || '?')}</div>
2927
+ <div class="d2-pills">${ingStamp(pr)}</div></div>`;
2928
+ // A FAILED clone or scan is RED and carries the tool's own message. It is never "0 initiatives
2929
+ // found": the three clone failures the server distinguishes stay distinguished here because the
2930
+ // string it sent is printed and nothing is composed on top of it.
2931
+ if (pr.ok === false) {
2932
+ return head + `<div class="ing-bad" role="alert"><b>the ingest failed</b><br>${esc(pr.error || 'no reason was reported')}</div>`
2933
+ + (pr.credential_note ? `<div class="ing-note">${esc(pr.credential_note)}</div>` : '')
2934
+ + ingLog(pr) + `<div class="d2-foot">${esc(pr.clone_dir || 'state/clones')}</div>`;
2935
+ }
2936
+ // Stages that have not produced a reading yet say so. This is the THIRD state — not empty, not
2937
+ // broken — and it must never be drawn as either.
2938
+ if (pr.stage === 'acquire' || pr.stage === 'scan') {
2939
+ return head + `<div class="ing-note">${pr.stage === 'acquire'
2940
+ ? 'Cloning. Nothing has been read yet, so nothing is proposed — this is neither an empty repository nor a failure.'
2941
+ : 'Scanning the clone, read-only. Nothing is written into any repository, and nothing is proposed until the scan finishes.'}</div>`
2942
+ + ingLog(pr) + `<div class="d2-foot">${esc(pr.clone_dir || 'state/clones')}</div>`;
2943
+ }
2944
+ const open = ING_OPEN(pr);
2945
+ const items = (pr.initiatives || []).map((it) => {
2946
+ const done = it.disposition !== 'open';
2947
+ return `<div class="ing-item">
2948
+ <span class="nm">${esc(it.id)}</span>
2949
+ ${ingAnchor(it)}
2950
+ ${done ? `<span class="ing-stamp">${esc(it.disposition)}</span>` : ''}
2951
+ <span class="pth">${esc(it.path)}</span>
2952
+ ${it.evidence ? `<span class="ing-ev">read: ${esc(it.evidence)}</span>` : ''}
2953
+ ${done ? '' : (it.anchor_state === 'declared'
2954
+ // R11 — a record that already declares `parent_kr` scans as `declared`, which IS the
2955
+ // idempotence mechanism: there is nothing to write, so no confirm is offered. It can still
2956
+ // be taken off the proposal, or every-item-declared would leave a list with no way out.
2957
+ ? `<span class="ing-stamp">already declared in the record — nothing to write</span>
2958
+ <button type="button" data-ing-dismiss="${esc(it.id)}">take off this proposal</button>`
2959
+ : `<input type="text" data-ing-anchor="${esc(it.id)}" value="${esc(it.anchor_value || '')}"
2960
+ aria-label="Anchor to write into ${esc(it.path)}" placeholder="q3-2026:o1:kr2 or health:tech" />
2961
+ <button type="button" data-ing-confirm="${esc(it.id)}">confirm this anchor</button>
2962
+ <button type="button" data-ing-dismiss="${esc(it.id)}">dismiss</button>`)}
2963
+ </div>`;
2964
+ }).join('');
2965
+ // A successful read of NOTHING is calm and names the path it read. It is not the failure above
2966
+ // and it is not a missing section.
2967
+ const noneFound = !(pr.initiatives || []).length
2968
+ ? `<div class="ing-note">read <b>planning/</b> at ${esc(pr.root || pr.clone_dir || 'the clone')} — 0 initiative records.
2969
+ This repository may not use PMOS record conventions. The read succeeded.</div>` : '';
2970
+ const bad = (pr.unreadable || []).length
2971
+ ? `<div class="ing-bad" role="alert"><b>${pr.unreadable.length} record${pr.unreadable.length === 1 ? '' : 's'} could not be read</b>
2972
+ ${pr.unreadable.map((u) => `<div>${esc(u.path)} — ${esc(u.error)}</div>`).join('')}
2973
+ These are shown rather than dropped from the count.</div>` : '';
2974
+ const adrs = (pr.adrs || []).length
2975
+ ? `<div class="d2-sub">decision records</div>` + pr.adrs.map((a) => `<div class="ing-item">
2976
+ <span class="nm">${esc(a.title || a.id)}</span>
2977
+ ${a.disposition !== 'open' ? `<span class="ing-stamp">${esc(a.disposition)}</span>` : ''}
2978
+ <span class="pth">${esc(a.path)}</span>
2979
+ ${a.disposition === 'open' ? `<button type="button" data-adr-accept="${esc(a.path)}">accept into this repository</button>
2980
+ <button type="button" data-adr-super="${esc(a.path)}">superseded</button>` : ''}
2981
+ </div>`).join('')
2982
+ : `<div class="ing-note">No decision records found in the clone — the read succeeded and there are none.</div>`;
2983
+ return head
2984
+ + `<div class="ing-note">A confirmation is the ONLY thing that writes, and it writes one
2985
+ <code>parent_kr</code> into one record. ${open.length
2986
+ ? `<b>${open.length}</b> item${open.length === 1 ? '' : 's'} await one — there is no confirm-all, and each write is one deliberate act.`
2987
+ : 'Every proposed item has been confirmed or dismissed.'}</div>`
2988
+ + `<div class="d2-sub">initiative records</div>` + items + noneFound + bad
2989
+ + adrs + ingLog(pr)
2990
+ + `<div class="d2-foot">${esc(pr.clone_dir || 'state/clones')} · ${esc(src)}${pr.source && pr.source.commit ? '@' + esc(pr.source.commit) : ''}</div>`;
2991
+ }
2992
+ // R10 — the FULL log, in order, including the steps that found nothing and the steps that failed.
2993
+ // It is the evidence the pipeline did what it says, so it is never summarised.
2994
+ function ingLog(pr) {
2995
+ const rows = (pr.log || []).map((l) => `<div class="ing-row" data-level="${esc(l.level || 'info')}">
2996
+ ${l.path ? `<span class="pth">${esc(l.path)}</span>` : ''}<span>${esc(l.message || '')}</span></div>`).join('');
2997
+ return `<div class="d2-sub">ingest log</div>`
2998
+ + (rows ? `<div class="ing-log">${rows}</div>` : `<div class="ing-note">No log lines were recorded for this proposal.</div>`);
2999
+ }
3000
+ function wireIngest(pr) {
3001
+ const host = $('library');
3002
+ host.querySelectorAll('[data-ing-confirm]').forEach((b) => b.onclick = async () => {
3003
+ const id = b.dataset.ingConfirm;
3004
+ const input = host.querySelector(`[data-ing-anchor="${CSS.escape(id)}"]`);
3005
+ const anchor = input ? input.value.trim() : '';
3006
+ if (!anchor) { flash('type the anchor to write — a guess is not a declaration'); if (input) input.focus(); return; }
3007
+ b.disabled = true;
3008
+ const j = await postJSON('/api/ingest/confirm', { id: pr.id, item: id, anchor });
3009
+ b.disabled = false;
3010
+ if (!j.ok) { flash(j.error || 'the write was refused', 14000); return; }
3011
+ // The resolver's verdict is reported whether or not it resolved: confirming an anchor the OKR
3012
+ // documents do not declare is permitted (the PM may know something they do not yet say), and
3013
+ // PMOS reports rather than blocks.
3014
+ flash(`${j.path} — parent_kr: ${j.anchor} · ${j.resolves ? 'resolves' : 'does NOT resolve'} — ${j.resolver_note || ''}`, 16000);
3015
+ });
3016
+ host.querySelectorAll('[data-ing-dismiss]').forEach((b) => b.onclick = async () => {
3017
+ const j = await postJSON('/api/ingest/dismiss', { id: pr.id, item: b.dataset.ingDismiss });
3018
+ if (!j.ok) flash(j.error || 'the dismissal was not recorded', 12000);
3019
+ });
3020
+ const adr = async (p, disposition) => {
3021
+ const j = await postJSON('/api/ingest/adr', { id: pr.id, path: p, disposition, ws: state.wsId });
3022
+ // R12 — the `ingested` badge is derived from `ingested_from` being present in the written file,
3023
+ // never from a stored flag, so what is reported here is the write itself.
3024
+ flash(j.ok ? (j.wrote ? `written to ${j.wrote} carrying ingested_from` : 'recorded as superseded — nothing was copied')
3025
+ : (j.error || 'the disposition was not recorded'), j.ok ? 8000 : 14000);
3026
+ };
3027
+ host.querySelectorAll('[data-adr-accept]').forEach((b) => b.onclick = () => adr(b.dataset.adrAccept, 'accepted'));
3028
+ host.querySelectorAll('[data-adr-super]').forEach((b) => b.onclick = () => adr(b.dataset.adrSuper, 'superseded'));
1292
3029
  }
1293
3030
 
3031
+ const D2_KINDS = {
3032
+ sched() {
3033
+ const t = (Array.isArray(state.sched) ? state.sched : []).find((x) => x.name === state.detail.id);
3034
+ if (!t) return d2Nothing('Select a scheduled task on the left.');
3035
+ const last = t.lastStatus;
3036
+ $('library').innerHTML = `
3037
+ <div class="d2-title"><div class="nm">${esc(t.name)}</div>
3038
+ <div class="tag">scheduled task · ${t.enabled ? 'enabled' : 'disabled'}</div></div>
3039
+ <div class="d2-field"><span class="lbl">cadence</span><div class="val">
3040
+ <span class="d2-quiet">${esc(t.cron || t.every || 'not set')}</span></div></div>
3041
+ <div class="d2-field"><span class="lbl">runs</span><div class="val">
3042
+ <span class="d2-quiet">${esc(t.skill || t.command || 'no skill named')}</span></div></div>
3043
+ <div class="d2-field"><span class="lbl">last run</span><div class="val">
3044
+ <span class="d2-quiet">${last ? esc(last) : 'never run yet — that is stated, not implied'}</span></div></div>
3045
+ <div class="d2-field"><span class="lbl">next</span><div class="val">
3046
+ <span class="d2-quiet">${t.next ? esc(relTime(t.next)) : (t.enabled ? 'not scheduled' : 'disabled — nothing is armed')}</span></div></div>
3047
+ ${schedProfileField(t)}
3048
+ <div class="d2-foot">the scheduler runs while this server does</div>`;
3049
+ wireDossier();
3050
+ wireSchedProfile(t);
3051
+ },
3052
+ flow() {
3053
+ const l = laneChains().find((x) => x.lane === state.detail.id);
3054
+ if (!l) return d2Nothing('Select a lane on the left.');
3055
+ const tw = state.workflows;
3056
+ const tmplCount = tw && tw.ok !== false ? (tw.templates || []).length : 0;
3057
+ $('library').innerHTML = `
3058
+ <div class="d2-title"><div class="nm">${esc(l.lane)}</div>
3059
+ <div class="tag">lane · ${l.n} initiative${l.n === 1 ? '' : 's'} in flight</div></div>
3060
+ <div class="d2-sub">ladder</div>
3061
+ <div class="d2-list">${l.chain.map((k, i) => `<div class="d2-item">
3062
+ <span class="n">${String(i + 1).padStart(2, '0')}</span><span class="k">${esc(k)}</span></div>`).join('')
3063
+ || '<span class="d2-quiet italic">no steps derived for this lane yet</span>'}</div>
3064
+ <div class="d2-noladder">This ladder is <b>derived</b>, not stored. Stage comes from git
3065
+ first-appearance order (<code>scripts/workflow-state.js</code>), so changing the sequence
3066
+ means changing the rules, which is a gate change — there is nothing here to edit.
3067
+ ${tmplCount
3068
+ // R13 — the old sentence ended "…so there is no template to edit here", which stops being
3069
+ // true the moment a template file exists. It is AMENDED rather than left standing: the
3070
+ // templates are real, they are declarations, and the engine still does not read them.
3071
+ ? `This repository declares <b>${tmplCount}</b> workflow template${tmplCount === 1 ? '' : 's'}
3072
+ under <code>planning/workflows/</code>. They are <b>declarations</b> — the engine derives one
3073
+ fixed ladder per lane and does not read <code>flow:</code>, so nothing above changes because
3074
+ of them. They are listed separately in this mode.`
3075
+ : ''}</div>
3076
+ <div class="d2-foot">scripts/workflow-state.js · derived per initiative</div>`;
3077
+ wireDossier();
3078
+ },
3079
+ skill() {
3080
+ const p = state.detail.id;
3081
+ if (!p) return d2Nothing('Select a skill on the left.');
3082
+ $('library').innerHTML = `
3083
+ <div class="d2-title"><div class="nm">${esc(skillName({ path: p }))}</div>
3084
+ <div class="tag">skill · an executable procedure</div></div>
3085
+ <div class="d2-field"><span class="lbl">defined in</span><div class="val">
3086
+ <button class="d2-art" data-doc="${esc(p)}"><span class="path">${esc(p)}</span></button></div></div>
3087
+ <div class="d2-acts"><button class="d2-btn" data-doc="${esc(p)}">open definition</button></div>
3088
+ <div class="d2-foot">${esc(p)}</div>`;
3089
+ wireDossier();
3090
+ },
3091
+ know() {
3092
+ const p = state.detail.id;
3093
+ if (!p) return d2Nothing('Select a document on the left.');
3094
+ const name = p.split('/').pop().replace(/\.md$/i, '');
3095
+ $('library').innerHTML = `
3096
+ <div class="d2-title"><div class="nm">${esc(name)}</div>
3097
+ <div class="tag">${/^okf\//.test(p) ? 'OKF concept — curated knowledge' : 'governance — the locked record'}</div></div>
3098
+ <div class="d2-acts"><button class="d2-btn" data-kind="primary" data-doc="${esc(p)}">open file</button></div>
3099
+ <div class="d2-foot">${esc(p)}</div>`;
3100
+ wireDossier();
3101
+ },
3102
+ agent() {
3103
+ const a = agentRoster().find((x) => x.name === state.detail.id);
3104
+ if (!a) return d2Nothing('Select an agent on the left.');
3105
+ $('library').innerHTML = `
3106
+ <div class="d2-title"><div class="nm">${esc(a.name)}</div>
3107
+ <div class="tag">${a.live ? `${a.live} live session${a.live === 1 ? '' : 's'}` : 'no live sessions'} · ${a.n} total</div></div>
3108
+ <div class="d2-field"><span class="lbl">sessions</span><div class="val">
3109
+ ${a.sessions.map((s) => { const st = stateLabel(s);
3110
+ return `<button class="d2-sess" data-dz-sess="${esc(s.id)}" data-state="${esc(st.shown || '')}"
3111
+ aria-current="${s.id === state.active}">
3112
+ <span class="g" aria-hidden="true">${GLYPH[st.shown] || '•'}</span>
3113
+ <span class="nm">${esc(s.name || s.id)}</span>
3114
+ <span class="st">${esc(st.text || '')}</span></button>`; }).join('')}
3115
+ </div></div>
3116
+ ${agentProfileField(a)}
3117
+ <div class="d2-noladder">PMOS keeps no agent registry — no model, role or memory is recorded
3118
+ against an agent name. What is shown is what the runtime can honestly report: the sessions
3119
+ this binary is running right now, plus the preference above, which is PMOS's own memory and
3120
+ not an attribute of the agent.</div>
3121
+ <div class="d2-foot">derived from live sessions${state.profiles && state.profiles.dir ? ` · preference stored in workspace/state/prefs.json · descriptors read from ${esc(state.profiles.dir)}` : ''}</div>`;
3122
+ wireDossier();
3123
+ wireAgentProfile(a);
3124
+ },
3125
+ /* workspace-repo-ingest — the REVIEW surface. It is a detail kind rather than a dialog on
3126
+ * purpose: a proposal is a thing you inspect in column 2, and a second review surface beside the
3127
+ * one that already exists is how two of them drift apart. */
3128
+ ingest() {
3129
+ const pr = proposalById(state.detail.id);
3130
+ if (!pr) return d2Nothing('That ingest proposal is no longer open.');
3131
+ $('library').innerHTML = ingestPanel(pr);
3132
+ wireDossier();
3133
+ wireIngest(pr);
3134
+ },
3135
+ /* workspace-mobile-surface R10, and reachable on the desktop too — the profile descriptors this
3136
+ * machine holds, with nothing derived and nothing checked. */
3137
+ profiles() {
3138
+ $('library').innerHTML = profilesPanel();
3139
+ wireDossier();
3140
+ $('library').querySelectorAll('[data-prof-reload]').forEach((b) => b.onclick = () => { state.profiles = null; renderLibrary(); loadProfiles(); });
3141
+ },
3142
+ /* THE CONNECTIONS SCREEN, and the whole of it is the absence. There is no connect menu anywhere
3143
+ * in this build, because PMOS has no credential store, no MCP client and no connection check — a
3144
+ * menu of connections would be a menu of nothing. What ships instead is one row per MCP name a
3145
+ * profile DECLARES, each saying it is a declaration and not a connection, and the file it was
3146
+ * read from. No dot, no colour-as-status, no sync time, no count. */
3147
+ connections() {
3148
+ $('library').innerHTML = connectionsPanel();
3149
+ wireDossier();
3150
+ },
3151
+ mem() {
3152
+ $('library').innerHTML = `
3153
+ <div class="d2-title"><div class="nm">memory</div>
3154
+ <div class="tag">not recorded</div></div>
3155
+ ${modeAbsent('Agent memory — short-term session context, and long-term notes injected into every run.',
3156
+ 'pmos/state/memory.jsonl')}
3157
+ <div class="d2-foot">nothing read — there is no such file</div>`;
3158
+ },
3159
+ };
3160
+
1294
3161
  function renderLibrary() {
1295
3162
  renderLensTabs();
1296
3163
  renderReading();
3164
+ // The column-2 header names whatever the panel is describing, so the header and the collapsed
3165
+ // rail that stands in for it always agree.
3166
+ { const f = $('libFold'); if (f) f.lastChild.textContent = detailTitle(); }
3167
+ // The filter box, the "reading from" label and the rescan button belong to the FILE MAP. On a
3168
+ // detail panel they are furniture for a view that is not on screen: a filter with nothing to
3169
+ // filter, and a rescan of files this panel never listed. They are hidden rather than disabled,
3170
+ // because a control that is present but inert still has to be read before it can be dismissed.
3171
+ {
3172
+ const detail = !!D2_KINDS[state.detail.kind];
3173
+ const mapish = state.lens === 'folders' || state.lens === 'history';
3174
+ const showMap = mapish && !detail;
3175
+ for (const id of ['libFind', 'libWho', 'libRefresh']) { const e = $(id); if (e) e.hidden = !showMap; }
3176
+ // The lens strip is EMPTIED for a mode panel, never hidden. `hidden` on the container makes
3177
+ // every button inside it unclickable even when a later state re-renders one — a real click
3178
+ // that can never land, which is worse than a visible control that does nothing. renderLensTabs
3179
+ // decides the contents; this only has to not fight it.
3180
+ }
3181
+ // The six non-initiative detail kinds. Each is the panel for a row selected in a non-product
3182
+ // mode, so they are dispatched before the lens system — a lens is a sub-view OF an initiative
3183
+ // or a repository, and none of these is either.
3184
+ if (D2_KINDS[state.detail.kind]) { D2_KINDS[state.detail.kind](); return; }
1297
3185
  if (state.lens === 'dossier' && state.detail.kind === 'initiative') { renderDossier(); return; }
1298
3186
  if (state.lens === 'dossier') { state.lens = 'folders'; }
1299
3187
  // a third, persistent lens beside initiatives + folders: the scheduled tasks for this workspace
@@ -1387,9 +3275,8 @@ async function openDoc(pth, kind) {
1387
3275
  if (!pth) return;
1388
3276
  state.docPath = pth;
1389
3277
  showRail();
1390
- $('paneDoc').hidden = false;
1391
- $('railSplit').hidden = false;
1392
- foldPane('doc', true);
3278
+ openViewer({ kind: 'doc', key: pth, title: pth.split('/').pop() });
3279
+ mobPushDoc('doc:' + pth, pth.split('/').pop()); // no-op off the phone
1393
3280
  $('crumb').innerHTML = `<bdi>${esc(pth)}</bdi>`;
1394
3281
  $('docBody').scrollTop = 0;
1395
3282
  const body = $('docBody');
@@ -1459,8 +3346,7 @@ function renderHtmlDoc(body, text) {
1459
3346
  }
1460
3347
  function closeDoc() {
1461
3348
  state.docPath = null; state.surface = null;
1462
- $('paneDoc').hidden = true;
1463
- $('railSplit').hidden = true;
3349
+ closeViewer('doc:' + (state.docPath || ''));
1464
3350
  $('docBody').innerHTML = '';
1465
3351
  $('crumb').textContent = '';
1466
3352
  document.querySelectorAll('.doc').forEach((b) => b.classList.remove('on'));
@@ -1485,29 +3371,108 @@ function curLayout() {
1485
3371
  return state.layouts.find((l) => l.id === state.layoutId) || state.layouts[0] || LAY.stockLayout(LAY.DEFAULT_ID);
1486
3372
  }
1487
3373
 
3374
+ // Collapse is applied to the COMPUTED grid rather than inside the layout module, deliberately.
3375
+ // It is not a fifth field of the arrangement tuple — a layout is what the PM saved and shares
3376
+ // between sessions, and "I folded this away for a minute" is not that. Narrowing a track after
3377
+ // the fact keeps the tuple, its persistence and its fuzzed invariant (every area row names as
3378
+ // many cells as there are tracks) all untouched: this only ever rewrites a track's WIDTH.
3379
+ //
3380
+ // Widths come from the area names rather than positions because the side and reader can each sit
3381
+ // on either edge; matching on `side`/`read` is the only mapping that stays correct across all
3382
+ // twelve side × reader arrangements. When the reader is docked at the BOTTOM it is a row, not a
3383
+ // column, so collapsing it is not offered — and asking for it here is a no-op, not a broken grid.
3384
+ // Remove one named area's track from a computed grid, keeping the areas string in step. Used for
3385
+ // the floating panel below 1100px: the pane still exists and still renders, it just stops taking a
3386
+ // column and overlays the terminal instead.
3387
+ function dropTrack(g, name) {
3388
+ const rows = g.areas.match(/"[^"]*"/g) || [];
3389
+ if (!rows.length) return g;
3390
+ const names = rows[0].replace(/"/g, '').trim().split(/\s+/);
3391
+ const tracks = g.columns.trim().split(/\s+/);
3392
+ const i = names.indexOf(name);
3393
+ if (i < 0 || names.length !== tracks.length) return g;
3394
+ tracks.splice(i, 1); names.splice(i, 1);
3395
+ return { columns: tracks.join(' '), rows: g.rows, areas: '"' + names.join(' ') + '"' };
3396
+ }
3397
+ const COLLAPSED_W = '36px';
3398
+ function collapseCols(g, l) {
3399
+ const rows = g.areas.match(/"[^"]*"/g) || [];
3400
+ if (!rows.length) return g;
3401
+ const names = rows[0].replace(/"/g, '').trim().split(/\s+/);
3402
+ const tracks = g.columns.trim().split(/\s+/);
3403
+ if (names.length !== tracks.length) return g; // never hand the grid a mismatched template
3404
+ let changed = false;
3405
+ names.forEach((n, i) => {
3406
+ const wanted = (n === 'side' && !state.col1) || (n === 'read' && !state.col2);
3407
+ if (wanted && tracks[i] !== COLLAPSED_W) { tracks[i] = COLLAPSED_W; changed = true; }
3408
+ });
3409
+ return changed ? { columns: tracks.join(' '), rows: g.rows, areas: g.areas } : g;
3410
+ }
3411
+
3412
+ // The rails that stand in for a collapsed column. Each is shown only when its column is both
3413
+ // collapsed AND present in the arrangement — a reader the layout has set to `off` has no track to
3414
+ // put a rail in, and drawing one there would claim a column that is not on screen.
3415
+ function paintColRails(l, float) {
3416
+ const body = $('bodyGrid');
3417
+ // The panel is always a column now, so the only question is whether it is collapsed.
3418
+ const readerCol = !float;
3419
+ const sideCol = l.side !== 'off';
3420
+ const set = (id, labelId, on, text) => {
3421
+ const el = $(id); if (!el) return;
3422
+ el.hidden = !on;
3423
+ if (on) $(labelId).textContent = text + ' ⟩';
3424
+ };
3425
+ set('col1Rail', 'col1RailLabel', sideCol && !state.col1, modeTitle());
3426
+ set('col2Rail', 'col2RailLabel', readerCol && !state.col2, detailTitle());
3427
+ body.dataset.col1 = sideCol && !state.col1 ? 'closed' : 'open';
3428
+ body.dataset.col2 = readerCol && !state.col2 ? 'closed' : 'open';
3429
+ }
3430
+ function setCol(which, on) {
3431
+ state[which] = on;
3432
+ store.set(which, on ? 'on' : 'off');
3433
+ applyLayout();
3434
+ // Both columns share the terminal's row, so either one changing width changes how many cells
3435
+ // fit. The ResizeObserver on the terminal host covers this, but it fires a frame later and the
3436
+ // debounce adds more; asking directly keeps the terminal from painting one stale frame at the
3437
+ // old width every time a column is folded.
3438
+ refit();
3439
+ }
3440
+ // What the collapsed rails and the two headers are called. Both read from state rather than
3441
+ // being written at their call sites, so the rail's label and the header it stands in for can
3442
+ // never drift apart.
3443
+ function modeTitle() { const m = MODE_BY_K[state.mode]; return m ? m.title : 'initiatives'; }
3444
+ // ONE table, owned by the reducer. It used to be declared here and again inside the nav code; two
3445
+ // copies of a lookup that must agree is a drift waiting to happen, and the phone's screen title and
3446
+ // the desktop panel's header are the same fact.
3447
+ const DETAIL_TITLE = PMOSMobileNav.DETAIL_TITLE;
3448
+ function detailTitle() { return DETAIL_TITLE[state.detail.kind] || 'detail'; }
3449
+
1488
3450
  // Write the arrangement into the DOM. Custom properties, never direct grid declarations — a
1489
3451
  // direct one would outrank the ≤760px media query and take the phone's switcher with it (R9).
1490
3452
  function applyLayout() {
1491
3453
  const l = curLayout();
1492
3454
  const body = $('bodyGrid');
1493
- // Below 1100px the reader floats over the terminal instead of squeezing it, so its track comes
3455
+ // Below 1100px the panel floats over the terminal instead of squeezing it, so its track comes
1494
3456
  // OUT of the template; below that again the phone's own system owns the whole thing.
1495
- const float = NARROW.matches && !PHONE.matches && (l.reader === 'left' || l.reader === 'right');
1496
- const eff = float ? Object.assign({}, l, { reader: 'off' }) : l;
1497
- const g = LAY.gridFor(eff);
3457
+ const float = NARROW.matches && !PHONE.matches;
3458
+ const eff = l;
3459
+ // The panel is a permanent column, so "float it" is done by dropping its TRACK here rather than
3460
+ // by a value in the tuple — the tuple no longer has a way to say "no panel column", and adding
3461
+ // one back just to express a viewport rule would put a responsive decision into saved state.
3462
+ const g = collapseCols(float ? dropTrack(LAY.gridFor(eff), 'read') : LAY.gridFor(eff), eff);
1498
3463
  body.style.setProperty('--lay-cols', g.columns);
1499
3464
  body.style.setProperty('--lay-rows', g.rows);
1500
3465
  body.style.setProperty('--lay-areas', g.areas);
1501
3466
  body.dataset.side = l.side;
1502
- body.dataset.reader = float ? l.reader : l.reader; // the pane still exists; it just floats
3467
+ body.dataset.reader = float ? 'float' : 'left'; // the pane still exists; it just floats
1503
3468
  body.dataset.float = float ? 'on' : 'off';
1504
3469
  if (float) body.style.setProperty('--lay-areas', g.areas);
3470
+ paintColRails(l, float);
1505
3471
 
1506
3472
  // folds
1507
3473
  paintFold('paneWs', 'wsFold', l.folds.ws);
1508
3474
  // `sess` retired with the pane it folded (R1/R13); a stored value is ignored, not migrated.
1509
3475
  paintFold('paneLib', 'libFold', l.folds.lib);
1510
- paintFold('paneDoc', 'docFold', l.folds.doc);
1511
3476
 
1512
3477
  // split is a desktop arrangement; below 1100px two groups are two unusable terminals (R9)
1513
3478
  applySplit(l.split === 'on' && !NARROW.matches);
@@ -1516,7 +3481,6 @@ function applyLayout() {
1516
3481
  // to be "a client preference like the theme" in a global localStorage key — which R8 and rubric
1517
3482
  // dimension 1 both contradict: they require it carried per workspace and the old key not re-read
1518
3483
  // on later loads. `libsplit` is now read exactly once, by migrateLegacy.
1519
- document.documentElement.style.setProperty('--libsplit', l.libSplit + '%');
1520
3484
 
1521
3485
  $('layName').textContent = l.name;
1522
3486
  renderLayoutMenu();
@@ -1628,12 +3592,10 @@ function layoutWire(l) {
1628
3592
  // a wireframe of the arrangement: the name alone does not say what you get
1629
3593
  const cells = [];
1630
3594
  if (l.side === 'left') cells.push(1);
1631
- if (l.reader === 'left') cells.push(3);
3595
+ cells.push(3); // the detail panel — always a column now
1632
3596
  cells.push(l.split === 'on' ? 5 : 4);
1633
- if (l.reader === 'right') cells.push(3);
1634
3597
  if (l.side === 'right') cells.push(1);
1635
- const rows = l.reader === 'bottom' ? '<i style="flex:1"></i>' : '';
1636
- return `<span class="lay-mini" aria-hidden="true">${cells.map((w) => `<span style="flex:${w}"></span>`).join('')}${rows}</span>`;
3598
+ return `<span class="lay-mini" aria-hidden="true">${cells.map((w) => `<span style="flex:${w}"></span>`).join('')}</span>`;
1637
3599
  }
1638
3600
  function renderLayoutMenu() {
1639
3601
  const list = $('layList');
@@ -1670,13 +3632,16 @@ function renderLayoutMenu() {
1670
3632
  if ($('layScope')) $('layScope').textContent = w ? w.name : 'this repo';
1671
3633
  }
1672
3634
 
1673
- /* R12 — keyboard parity with the drag. Every value `side` and `reader` can hold is a real button
1674
- here, so no position is pointer-only. This is what makes the "no dead ends" property true rather
1675
- than merely argued: from ANY arrangement, including side:off + reader:off, the header control is
1676
- in the bar (never inside the grid it rearranges), and these buttons bring a pane back. */
3635
+ /* R12 — keyboard parity with the drag. Every value `side` can hold is a real button here, so no
3636
+ position is pointer-only. This is what makes the "no dead ends" property true rather than merely
3637
+ argued: from ANY arrangement, including side:off, the header control is in the bar (never inside
3638
+ the grid it rearranges), and these buttons bring the pane back.
3639
+
3640
+ The `reader` row went with the slot. The detail panel is not positionable — it always sits
3641
+ between the structure column and the terminal — and hiding it is the column collapse, whose
3642
+ control is the labelled 36px rail that replaces it, which is reachable by tab and by click. */
1677
3643
  const POS_GROUPS = [
1678
3644
  { key: 'side', label: 'sidebar', values: [['left', '◧'], ['right', '◨'], ['off', '∅']] },
1679
- { key: 'reader', label: 'reader', values: [['left', '◧'], ['right', '◨'], ['bottom', '⬓'], ['off', '∅']] },
1680
3645
  ];
1681
3646
  function renderLayoutPositions() {
1682
3647
  const host = $('layPos');
@@ -1730,17 +3695,17 @@ function newLayoutName() {
1730
3695
 
1731
3696
  // ── folds: both columns, same behaviour (R11) ──
1732
3697
  function foldPane(which, open) {
1733
- const map = { doc: 'paneDoc', lib: 'paneLib', ws: 'paneWs' };
3698
+ const map = { lib: 'paneLib', ws: 'paneWs' };
1734
3699
  if (!map[which]) return;
1735
3700
  const l = curLayout();
1736
3701
  const folds = Object.assign({}, l.folds);
1737
3702
  folds[which] = open;
1738
3703
  tweak({ folds });
1739
3704
  }
1740
- function showRail() { if (curLayout().reader === 'off') tweak({ reader: 'right' }); }
3705
+ // The panel is always a column; "show it" therefore means un-collapse it, which is client state.
3706
+ function showRail() { if (!state.col2) setCol('col2', true); }
1741
3707
 
1742
3708
  $('libFold').onclick = () => foldPane('lib', $('paneLib').dataset.fold === 'off');
1743
- $('docFold').onclick = () => foldPane('doc', $('paneDoc').dataset.fold === 'off');
1744
3709
  $('wsFold').onclick = () => foldPane('ws', $('paneWs').dataset.fold === 'off');
1745
3710
 
1746
3711
  // ── the edges: drag a column's outer edge to resize it. Keeps to the layout, like everything
@@ -1752,14 +3717,15 @@ function wireEdge(el) {
1752
3717
  const applyPx = (px) => {
1753
3718
  const l = curLayout();
1754
3719
  if (which === 'side') tweak({ sideW: Math.round(Math.min(Math.max(px, LAY.SIDE_MIN), LAY.SIDE_MAX)) });
1755
- else tweak({ readW: Math.round(Math.min(Math.max(px, LAY.READ_MIN), Math.min(LAY.READ_MAX, window.innerWidth - 360))) });
3720
+ else tweak({ panelW: Math.round(Math.min(Math.max(px, LAY.PANEL_MIN), Math.min(LAY.PANEL_MAX, window.innerWidth - 360))) });
1756
3721
  void l;
1757
3722
  };
1758
3723
  const fromPointer = (clientX) => {
1759
3724
  const l = curLayout();
1760
3725
  const r = $('bodyGrid').getBoundingClientRect();
1761
3726
  if (which === 'side') return l.side === 'right' ? r.right - clientX : clientX - r.left;
1762
- return l.reader === 'left' ? clientX - r.left : r.right - clientX;
3727
+ // the panel's outer edge is its LEFT one when the structure column is on the right
3728
+ return l.side === 'right' ? r.right - clientX : clientX - r.left - (l.side === 'left' ? l.sideW : 0);
1763
3729
  };
1764
3730
  el.addEventListener('pointerdown', (e) => { dragging = true; el.classList.add('on'); el.setPointerCapture(e.pointerId); e.preventDefault(); });
1765
3731
  el.addEventListener('pointermove', (e) => { if (dragging) applyPx(fromPointer(e.clientX)); });
@@ -1768,9 +3734,9 @@ function wireEdge(el) {
1768
3734
  if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
1769
3735
  e.preventDefault();
1770
3736
  const l = curLayout();
1771
- const cur = which === 'side' ? l.sideW : l.readW;
3737
+ const cur = which === 'side' ? l.sideW : l.panelW;
1772
3738
  const grow = which === 'side' ? (l.side === 'right' ? e.key === 'ArrowLeft' : e.key === 'ArrowRight')
1773
- : (l.reader === 'left' ? e.key === 'ArrowRight' : e.key === 'ArrowLeft');
3739
+ : (l.side === 'right' ? e.key === 'ArrowLeft' : e.key === 'ArrowRight');
1774
3740
  applyPx(cur + (grow ? 24 : -24));
1775
3741
  refit();
1776
3742
  });
@@ -1804,7 +3770,8 @@ wireEdge($('sideEdge'));
1804
3770
  function end(e) {
1805
3771
  if (!drag) return;
1806
3772
  const z = hit(e);
1807
- if (z) tweak(drag === 'side' ? { side: z === 'bottom' ? curLayout().side : z } : { reader: z });
3773
+ // only the structure column is draggable now — the panel has one home by design
3774
+ if (z && drag === 'side') tweak({ side: z });
1808
3775
  body.classList.remove('dragging');
1809
3776
  zones().forEach((d) => { d.classList.remove('hot'); d.classList.remove('no'); });
1810
3777
  if (ghost) { ghost.remove(); ghost = null; }
@@ -1833,7 +3800,7 @@ wireEdge($('sideEdge'));
1833
3800
  if (e.key !== 'Enter' && e.key !== ' ') return;
1834
3801
  e.preventDefault();
1835
3802
  openLayMenu();
1836
- const first = $('layPos') && $('layPos').querySelector(`[data-pos="${g.dataset.pane === 'side' ? 'side' : 'reader'}"]`);
3803
+ const first = $('layPos') && $('layPos').querySelector('[data-pos="side"]');
1837
3804
  (first || $('layBtn')).focus();
1838
3805
  });
1839
3806
  });
@@ -1863,26 +3830,8 @@ document.addEventListener('pointerdown', (e) => {
1863
3830
 
1864
3831
  applyLayout(); // paint the default arrangement before the server's layouts arrive
1865
3832
 
1866
- // drag the divider between the library and the document. It is part of the arrangement (R8), so
1867
- // it is kept against the current layout like every other adjustment — not in a global client key.
1868
- (() => {
1869
- const split = $('railSplit');
1870
- let dragging = false;
1871
- const apply = (clientY) => {
1872
- const r = $('rail').getBoundingClientRect();
1873
- const pct = Math.min(LAY.LIB_MAX, Math.max(LAY.LIB_MIN, ((clientY - r.top) / r.height) * 100));
1874
- tweak({ libSplit: Math.round(pct * 10) / 10 });
1875
- };
1876
- split.addEventListener('pointerdown', (e) => { dragging = true; split.setPointerCapture(e.pointerId); e.preventDefault(); });
1877
- split.addEventListener('pointermove', (e) => { if (dragging) apply(e.clientY); });
1878
- split.addEventListener('pointerup', (e) => { dragging = false; try { split.releasePointerCapture(e.pointerId); } catch {} refit(); });
1879
- split.addEventListener('keydown', (e) => {
1880
- if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
1881
- e.preventDefault();
1882
- const cur = curLayout().libSplit;
1883
- tweak({ libSplit: Math.round(Math.min(LAY.LIB_MAX, Math.max(LAY.LIB_MIN, cur + (e.key === 'ArrowUp' ? -4 : 4))) * 10) / 10 });
1884
- });
1885
- })();
3833
+ // The rail's library/document divider is gone: the rail has ONE pane now. `libSplit` went out of
3834
+ // the tuple with it — see lib/layout.js.
1886
3835
 
1887
3836
  // ── needs-you + gates (the PMOS workflow: Review/Acceptance in file mode, never automatic) ──
1888
3837
  async function loadNeeds() {
@@ -2190,10 +4139,17 @@ function openNewForm() {
2190
4139
  const w = state.workspaces.find((x) => x.id === state.wsId);
2191
4140
  $('newForm').hidden = false;
2192
4141
  $('newBtn').setAttribute('aria-expanded', 'true');
4142
+ paintNewAgents(); // the radios always show what state.newAgent actually holds
2193
4143
  renderNewTarget();
2194
4144
  // pre-filled but editable — a default that reads better than "shell" in a list of shells
2195
4145
  const n = mySessions().length + 1;
2196
4146
  $('newName').value = `${w ? w.name : 'session'} ${n}`;
4147
+ // workspace-profiles R8.1 — the composer's chip set is exactly the descriptors that were read,
4148
+ // preselected from PMOS's own remembered preference for this agent. A preference for a
4149
+ // descriptor that no longer reads preselects nothing rather than a substitute.
4150
+ state.newProfile = preferredProfileFor(state.newAgent);
4151
+ renderProfileChips();
4152
+ if (!state.profiles) loadProfiles();
2197
4153
  fillBranchPicker(); // paint immediately from what we already know…
2198
4154
  loadBranch(); // …then re-fetch: branches made in a terminal must show up here
2199
4155
  const inp = $('newName');
@@ -2345,14 +4301,23 @@ $('newCancel').onclick = closeNewForm;
2345
4301
  $('newBranch').onchange = (e) => setNewBranchMode(e.target.value === '__new__');
2346
4302
  $('newIsolate').onchange = renderBranchHint;
2347
4303
  $('newBranchName').oninput = renderBranchHint; // the hint names the folder as the PM types it
4304
+ function paintNewAgents() {
4305
+ $('newAgents').querySelectorAll('button').forEach((x) => x.setAttribute('aria-checked', String(x.dataset.agent === state.newAgent)));
4306
+ }
2348
4307
  $('newAgents').querySelectorAll('button').forEach((b) => b.onclick = () => {
2349
4308
  state.newAgent = b.dataset.agent;
2350
- $('newAgents').querySelectorAll('button').forEach((x) => x.setAttribute('aria-checked', String(x === b)));
4309
+ paintNewAgents();
4310
+ // the preference is keyed by AGENT, so changing the agent re-asks PMOS what it remembers
4311
+ state.newProfile = preferredProfileFor(state.newAgent);
4312
+ renderProfileChips();
2351
4313
  });
2352
4314
  $('newForm').addEventListener('submit', (e) => {
2353
4315
  e.preventDefault();
2354
4316
  const name = $('newName').value.trim();
2355
- const msg = { t: 'new', agent: state.newAgent, name: name || state.newAgent, ws: state.wsId };
4317
+ // `profile` is always present and is `null` for "no profile" — the server distinguishes a
4318
+ // deliberate no-profile from a client that forgot the field, and only one of those is a choice.
4319
+ const msg = { t: 'new', agent: state.newAgent, name: name || state.newAgent, ws: state.wsId,
4320
+ profile: state.newProfile || null };
2356
4321
  // branch: either create a new one, or check out an existing pick. Empty = stay put (no git call
2357
4322
  // at all) — and that is what a hidden control means too, so a non-git workspace can never have a
2358
4323
  // stale pick from a previous repo smuggled onto it.
@@ -2481,15 +4446,39 @@ $('scratchSend').onclick = () => {
2481
4446
 
2482
4447
  // ── add a workspace: pick a folder (the GUI twin of running `pmos` in that repo) ──
2483
4448
  function closeAddWs() {
2484
- $('addWsForm').hidden = true; $('addWsBtn').setAttribute('aria-expanded', 'false'); $('addWsPath').value = '';
4449
+ $('addWsForm').hidden = true;
4450
+ $('addWsBtn').setAttribute('aria-expanded', 'false');
4451
+ if ($('pswAdd')) $('pswAdd').setAttribute('aria-expanded', 'false');
4452
+ $('addWsPath').value = '';
4453
+ if ($('addWsRepo')) $('addWsRepo').value = '';
2485
4454
  }
2486
- $('addWsBtn').onclick = () => {
4455
+ function toggleAddWs() {
2487
4456
  const f = $('addWsForm');
2488
4457
  f.hidden = !f.hidden;
4458
+ // Two controls opening ONE form is fine; two forms that both add a repository is not — so both
4459
+ // buttons report the same expanded state for the same node.
2489
4460
  $('addWsBtn').setAttribute('aria-expanded', String(!f.hidden));
2490
- };
4461
+ if ($('pswAdd')) $('pswAdd').setAttribute('aria-expanded', String(!f.hidden));
4462
+ if (!f.hidden) setAddSource(state.addSrc);
4463
+ }
4464
+ $('addWsBtn').onclick = toggleAddWs;
4465
+ // R7 (switcher) — the + opens the SHIPPED add form. There is no second path and no source menu
4466
+ // beyond the two real sources below.
4467
+ if ($('pswAdd')) $('pswAdd').onclick = toggleAddWs;
2491
4468
  $('addWsCancel').onclick = closeAddWs;
2492
- $('addWsForm').addEventListener('submit', (e) => { e.preventDefault(); addWorkspace($('addWsPath').value); });
4469
+ if ($('addWsSource')) {
4470
+ $('addWsSource').querySelectorAll('[data-src]').forEach((b) => b.onclick = () => setAddSource(b.dataset.src));
4471
+ setAddSource('local');
4472
+ }
4473
+ $('addWsForm').addEventListener('submit', (e) => {
4474
+ e.preventDefault();
4475
+ // R2 — the two sources do genuinely different things, and the form does them differently: a
4476
+ // folder is REGISTERED where it lies, a GitHub repository is cloned into PMOS's own state,
4477
+ // scanned read-only, and PROPOSED. The server refuses `source:"folder"` on /api/ingest for the
4478
+ // same reason — the distinction is enforced in code, not only in the copy under the radio.
4479
+ if (state.addSrc === 'github') { startIngest($('addWsRepo') ? $('addWsRepo').value : ''); return; }
4480
+ addWorkspace($('addWsPath').value);
4481
+ });
2493
4482
  // The browser's own directory picker cannot yield an absolute path, so the LOCAL server opens
2494
4483
  // the machine's native chooser. Where there is none (a headless Linux box), the in-app browser
2495
4484
  // stands in — the PM is never left with only a text field (R10).
@@ -2552,6 +4541,19 @@ $('fsPicker').addEventListener('click', (e) => { if (e.target === $('fsPicker'))
2552
4541
  // ── library lens + filter ──
2553
4542
  const FIND_PLACEHOLDER = { history: 'search past sessions…', scheduled: 'filter the library…' };
2554
4543
  $('libFind').addEventListener('input', (e) => { state.filter = e.target.value; renderLibrary(); });
4544
+ // The column-1 search box. It narrows whichever mode is showing and, in `product`, also surfaces
4545
+ // matching artifacts and sessions underneath the spine — so a half-remembered name is findable
4546
+ // without first knowing which mode it lives in.
4547
+ $('spFind').addEventListener('input', (e) => { state.q = e.target.value; renderTree(); });
4548
+ // Both rails and both in-column collapse controls drive the SAME setter, so the two paths cannot
4549
+ // disagree about what "collapsed" means or forget to re-fit the terminal.
4550
+ // workspace-focus R1 — the entry control. It is a sibling of #tree, never a replacement for it:
4551
+ // focus is a SECOND reading of the same repositories, and leaving the structure it was derived
4552
+ // from on screen is what lets the PM check the queue rather than believe it.
4553
+ if ($('focusBtn')) $('focusBtn').onclick = () => toggleFocus();
4554
+ $('col1Rail').addEventListener('click', () => setCol('col1', true));
4555
+ $('col2Rail').addEventListener('click', () => setCol('col2', true));
4556
+ $('col2Hide').addEventListener('click', () => setCol('col2', false));
2555
4557
  $('libRefresh').onclick = () => {
2556
4558
  const b = $('libRefresh'); b.classList.add('spin');
2557
4559
  refreshLibrary(); setTimeout(() => b.classList.remove('spin'), 500);
@@ -2727,7 +4729,7 @@ async function alDrafts() {
2727
4729
  let out = {};
2728
4730
  try { out = await (await fetch(`/api/align/drafts?ws=${encodeURIComponent(state.wsId || '')}`)).json(); }
2729
4731
  catch { out = { ok: false, error: 'could not reach the workspace server' }; }
2730
- showRail(); $('paneDoc').hidden = false; foldPane('doc', true);
4732
+ openViewer({ kind: 'surface', key: name, title: name });
2731
4733
  $('crumb').innerHTML = '<bdi>alignment drafts</bdi>';
2732
4734
  if (!out.ok) { $('docBody').innerHTML = `<div class="al"><div class="al-lede">${esc(out.error || 'could not read the drafts')}</div></div>`; return; }
2733
4735
  if (out.empty || !out.drafts.length) {
@@ -2820,7 +4822,8 @@ const SF_STAT = { planning: ['○', 'planning'], building: ['◐', 'building'],
2820
4822
 
2821
4823
  async function openSurface(name) {
2822
4824
  showRail();
2823
- $('paneDoc').hidden = false; $('railSplit').hidden = false; foldPane('doc', true);
4825
+ openViewer({ kind: 'surface', key: name, title: name });
4826
+ mobPushDoc('surface:' + name, name); // no-op off the phone
2824
4827
  state.surface = name; state.docPath = null;
2825
4828
  ed.on = false; edControls();
2826
4829
  document.querySelectorAll('.doc').forEach((b) => b.classList.remove('on'));
@@ -3276,7 +5279,7 @@ async function scRun(id) { flash('running…'); await scPost('/api/schedules/run
3276
5279
  async function openDeliverable(id) {
3277
5280
  if (!id) return;
3278
5281
  state.docPath = null; state.surface = null;
3279
- showRail(); $('paneDoc').hidden = false; $('railSplit').hidden = false; foldPane('doc', true);
5282
+ openViewer({ kind: 'surface', key: name, title: name });
3280
5283
  $('crumb').innerHTML = `<bdi>report · ${esc(id)}</bdi>`; $('docBody').scrollTop = 0;
3281
5284
  try { const r = await fetch('/api/deliverable?ws=' + encodeURIComponent(state.wsId || '') + '&id=' + encodeURIComponent(id));
3282
5285
  $('docBody').innerHTML = r.ok ? renderMd(await r.text(), '') : `<p class="doc-empty">Report not found.</p>`;
@@ -3303,6 +5306,12 @@ function openPalette() {
3303
5306
  // summon a P/D/M/R surface on demand — listed first so "initiatives"/"roadmap"/"okr"/"report" are reachable
3304
5307
  const views = SURFACES.map((v) => ({ kind: 'view', label: v.label, view: v.name }));
3305
5308
  views.push({ kind: 'align', label: 'alignment drafts' }); // downstream of a save, never resident
5309
+ // The three lenses that stopped being standing furniture. They are still exactly one keystroke
5310
+ // away — which is where a file map and a schedule list belong, since you want them when that IS
5311
+ // the job, not on every render.
5312
+ views.push({ kind: 'lens', label: 'folders — this repository\u2019s file map', lens: 'folders' });
5313
+ views.push({ kind: 'lens', label: 'scheduled — recurring tasks', lens: 'scheduled' });
5314
+ views.push({ kind: 'lens', label: 'history — this workspace\u2019s scrollback', lens: 'history' });
3306
5315
  palItems = [...views, ...wss, ...sess, ...inits, ...docs];
3307
5316
  $('palette').style.display = 'grid';
3308
5317
  const inp = $('palInput'); inp.value = ''; renderPalette('');
@@ -3314,7 +5323,7 @@ function renderPalette(q) {
3314
5323
  const f = q.toLowerCase();
3315
5324
  const items = palItems.filter((i) => i.label.toLowerCase().includes(f)).slice(0, 40);
3316
5325
  palSel = 0;
3317
- const g = (i) => i.kind === 'view' ? '▤' : i.kind === 'session' ? (GLYPH[i.state] || '•') : i.kind === 'workspace' ? '▣' : i.kind === 'initiative' ? '◈' : '◦';
5326
+ const g = (i) => i.kind === 'lens' ? '▦' : i.kind === 'view' ? '▤' : i.kind === 'session' ? (GLYPH[i.state] || '•') : i.kind === 'workspace' ? '▣' : i.kind === 'initiative' ? '◈' : '◦';
3318
5327
  $('palList').innerHTML = items.map((i, n) => `
3319
5328
  <button class="pitem ${n === 0 ? 'sel' : ''}" data-n="${n}" role="option">
3320
5329
  <span class="g" aria-hidden="true">${g(i)}</span>
@@ -3328,6 +5337,8 @@ function choosePalette(n) {
3328
5337
  const it = items[n]; if (!it) return;
3329
5338
  closePalette();
3330
5339
  if (it.kind === 'view') openSurface(it.view);
5340
+ // summoning a lens is what replaced the standing tab strip: it sets the view and shows the rail
5341
+ else if (it.kind === 'lens') { state.lens = it.lens; store.set('lens', it.lens); showRail(); renderLibrary(); }
3331
5342
  else if (it.kind === 'align') alDrafts();
3332
5343
  else if (it.kind === 'session') selectSession(it.id);
3333
5344
  else if (it.kind === 'workspace') selectWorkspace(it.wsid);
@@ -3372,7 +5383,7 @@ document.addEventListener('keydown', (e) => {
3372
5383
  else if (state.renaming) { e.stopPropagation(); cancelRename(); renderSessions(); }
3373
5384
  else if (!$('newForm').hidden) closeNewForm();
3374
5385
  else if (!$('addWsForm').hidden) closeAddWs();
3375
- else if (!$('paneDoc').hidden) closeDoc();
5386
+ else if (state.viewActive) closeDoc();
3376
5387
  }
3377
5388
  }, true);
3378
5389
 
@@ -3401,9 +5412,105 @@ function applyCtrl(d) {
3401
5412
  return d;
3402
5413
  }
3403
5414
 
5415
+ /* ── workspace-mobile-surface · the shell ────────────────────────────────────────────────────────
5416
+ *
5417
+ * ONE renderer, two shells (R5). Nothing below builds a phone-shaped copy of anything: applying a
5418
+ * screen sets the state the desktop already uses and calls the function that already draws it —
5419
+ * `state.mode` + renderTree(), `state.detail` + renderLibrary(), selectSession(), selectViewer().
5420
+ * The only phone-specific parts are which HOST is visible (`data-mscreen`) and the chrome.
5421
+ *
5422
+ * R6 is structural rather than remembered: the three column nodes are never re-parented and never
5423
+ * rebuilt, so a push over a terminal, a pop back to it, and a destination switch are all CSS
5424
+ * visibility changes. The xterm instance and its PTY socket cannot be destroyed by navigation
5425
+ * because navigation never touches them.
5426
+ */
5427
+ function mobOn() { return PHONE.matches; }
5428
+ function mobCurrent() { return mobCurrentOf(state.mnav); }
5429
+ function mobMoreActive() { return mobOn() && state.mnav && mobCurrent().kind === 'more'; }
5430
+ // The `more` root, written into #tree with `.md-row`s — the SAME vocabulary as every other list in
5431
+ // this column, not a new one. Three of these are modes that have no bottom-nav slot of their own,
5432
+ // and two are detail screens; R10 binds the last of them: `connections` lists only what a
5433
+ // descriptor DECLARES, and if no descriptor was read it renders no rows at all.
5434
+ const MORE_ROWS = [
5435
+ { k: 'mode', id: 'workflow', nm: 'workflow', d: 'lanes this repository runs, and the declared templates' },
5436
+ { k: 'mode', id: 'skills', nm: 'skills', d: 'executable procedures under skills/ and build-skills/' },
5437
+ { k: 'mode', id: 'knowledge', nm: 'knowledge', d: 'the OKF bundle and the locked record' },
5438
+ { k: 'detail', id: 'profiles', nm: 'profiles', d: 'the descriptors this machine holds' },
5439
+ { k: 'detail', id: 'connections', nm: 'connections', d: 'what profiles declare — nothing is connected' },
5440
+ ];
5441
+ function renderMoreMenu(host) {
5442
+ host.innerHTML = `<div class="md-note">Everything that is not one of the four destinations below.
5443
+ These are the same lists and the same panels the wide layout shows — there is no phone-only view.</div>`
5444
+ + MORE_ROWS.map((r) => `<button class="md-row" data-more-k="${esc(r.k)}" data-more-id="${esc(r.id)}">
5445
+ <span class="r1"><span class="nm">${esc(r.nm)}</span></span>
5446
+ <span class="d">${esc(r.d)}</span></button>`).join('');
5447
+ host.querySelectorAll('[data-more-id]').forEach((b) => b.onclick = () => {
5448
+ if (b.dataset.moreK === 'mode') {
5449
+ // a mode reached from `more` stays IN `more` as a pushed list screen, so `‹ back` returns to
5450
+ // the menu rather than dropping the PM on a destination they did not choose
5451
+ mobGo({ kind: 'list', mode: b.dataset.moreId });
5452
+ } else {
5453
+ state.detail = { kind: b.dataset.moreId, id: null, wsId: state.wsId };
5454
+ renderLibrary();
5455
+ mobPushDetail(b.dataset.moreId);
5456
+ }
5457
+ });
5458
+ }
5459
+ // The four push helpers. Each is a NO-OP off the phone, so every call site below can stay a plain
5460
+ // unconditional line rather than a conditional the next author has to remember to write.
5461
+ // Selecting a second document while already reading one REPLACES the top screen rather than
5462
+ // stacking on it: `‹ back` means "out of the reader", not "back through everything you glanced at",
5463
+ // and an unbounded stack would make P3's "a finite number of presses" true only in principle.
5464
+ function mobGo(screen) {
5465
+ if (!mobOn() || !state.mnav) return;
5466
+ const cur = mobCurrent();
5467
+ const nav = (state.mnav.stack.length && cur.kind === screen.kind) ? mobPop(state.mnav) : state.mnav;
5468
+ state.mnav = mobPush(nav, screen);
5469
+ applyMobScreen();
5470
+ }
5471
+ function mobPushDetail(dk) { mobGo({ kind: 'detail', dk, id: state.detail.id }); }
5472
+ function mobPushTerminal(id, title) { mobGo({ kind: 'terminal', id, title }); }
5473
+ function mobPushDoc(id, title) { mobGo({ kind: 'doc', id, title }); }
5474
+ function mobBack() {
5475
+ state.mnav = mobPop(state.mnav);
5476
+ applyMobScreen();
5477
+ }
5478
+ /* Apply the current screen. The ORDER matters: the host is chosen first so a renderer that measures
5479
+ * (the terminal's fit) sees the box it is actually going to occupy. */
5480
+ function applyMobScreen() {
5481
+ const shell = $('mobShell'), grid = $('bodyGrid'), menu = $('mobMenu');
5482
+ if (!shell || !grid) return;
5483
+ // R12 — above the breakpoint the shell is `display:none` (stylesheet) AND `hidden` (here), so it
5484
+ // is absent from the accessibility tree rather than merely invisible. Both, not either.
5485
+ shell.hidden = !mobOn();
5486
+ if (!mobOn()) { grid.removeAttribute('data-mscreen'); return; }
5487
+ if (!state.mnav) state.mnav = mobNavInit(mobMigrateView(store.get('mdest')));
5488
+ const s = mobCurrent();
5489
+ grid.dataset.mscreen = s.column;
5490
+ // the control-key accessory bar belongs to the terminal screen and to nothing else (R7)
5491
+ if (menu) menu.dataset.view = s.kind === 'terminal' ? 'terminal' : s.kind;
5492
+ const title = $('mobTitle'); if (title) title.textContent = mobTitleOf(s);
5493
+ // Back is ABSENT at a destination root, never present-and-inert: a control that looks available
5494
+ // and does nothing is the thing this workspace refuses to ship.
5495
+ const back = $('mobBack'); if (back) back.hidden = !(state.mnav.stack.length > 0);
5496
+ document.querySelectorAll('#mobNav [data-dest]').forEach((b) =>
5497
+ b.setAttribute('aria-current', b.dataset.dest === state.mnav.dest ? 'page' : 'false'));
5498
+ // R5 — the SHIPPED renderers, given a different host. Never a second implementation.
5499
+ if (s.kind === 'list') { if (state.mode !== s.mode) setMode(s.mode); else renderTree(); }
5500
+ else if (s.kind === 'more') renderTree(); // renderTree defers to renderMoreMenu
5501
+ else if (s.kind === 'detail') renderLibrary();
5502
+ else if (s.kind === 'terminal') {
5503
+ refit();
5504
+ const t = state.terms.get(s.id); if (t) setTimeout(() => t.term.focus(), 0);
5505
+ } else if (s.kind === 'doc') refit();
5506
+ }
5507
+ function mobSetDest(dest) {
5508
+ state.mnav = mobSwitchDest(state.mnav, dest);
5509
+ store.set('mdest', state.mnav.dest); // R11 — the DESTINATION only. The stack is never stored.
5510
+ applyMobScreen();
5511
+ }
5512
+
3404
5513
  (function mobile() {
3405
- const grid = $('bodyGrid');
3406
- const menu = $('mobMenu');
3407
5514
  const MQ = window.matchMedia('(max-width:760px)');
3408
5515
 
3409
5516
  // send a raw sequence to the active PTY, then keep focus in the terminal (raises the soft keyboard)
@@ -3419,21 +5526,22 @@ function applyCtrl(d) {
3419
5526
  if (state.active) { const t = state.terms.get(state.active); if (t) t.term.focus(); }
3420
5527
  };
3421
5528
  $('mobPalette').onclick = () => togglePalette();
5529
+ if ($('mobBack')) $('mobBack').onclick = () => mobBack();
5530
+ // the phone's own theme control drives the SAME toggle as the header's, so the two can never
5531
+ // disagree about what the stored theme is
5532
+ if ($('mobTheme')) $('mobTheme').onclick = () => $('themeBtn').click();
5533
+ document.querySelectorAll('#mobNav [data-dest]').forEach((b) => b.onclick = () => mobSetDest(b.dataset.dest));
3422
5534
 
3423
- // the switcher: one column at a time, terminal is the default (priority) pane, choice persisted
3424
- function setView(v) {
3425
- grid.dataset.mview = v;
3426
- menu.dataset.view = v;
3427
- document.querySelectorAll('.mobtabs [data-mview]').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.mview === v)));
3428
- store.set('mview', v);
3429
- if (v === 'terminal') {
3430
- refit();
3431
- if (state.active) { const t = state.terms.get(state.active); if (t) setTimeout(() => t.term.focus(), 0); }
3432
- }
3433
- }
3434
- const remembered = store.get('mview');
3435
- setView(['sessions', 'terminal', 'library'].includes(remembered) ? remembered : 'terminal');
3436
- document.querySelectorAll('.mobtabs [data-mview]').forEach((b) => b.onclick = () => setView(b.dataset.mview));
5535
+ /* R11 — the destination is remembered; the deeper stack deliberately is NOT. A pushed screen
5536
+ * names an object that may no longer exist on reload, and restoring it would mean drawing a
5537
+ * screen for something PMOS has not re-read. `pmos-ws:mview` (the retired three-way switcher's
5538
+ * key) is read ONCE here for migration and never written again — every value it could hold,
5539
+ * plus anything unrecognised, lands on the `board` root. */
5540
+ const stored = store.get('mdest');
5541
+ const dest = stored ? mobMigrateView(stored) : mobMigrateView(store.get('mview'));
5542
+ state.mnav = mobNavInit(dest);
5543
+ store.set('mdest', state.mnav.dest);
5544
+ applyMobScreen();
3437
5545
 
3438
5546
  // FitAddon must re-fit when the soft keyboard shows/hides (visualViewport) and on rotation, or
3439
5547
  // xterm renders more rows than fit and the bottom is clipped (the R9 failure, on a phone).
@@ -3442,8 +5550,10 @@ function applyCtrl(d) {
3442
5550
  window.visualViewport.addEventListener('scroll', refit);
3443
5551
  }
3444
5552
  window.addEventListener('orientationchange', () => setTimeout(refit, 250));
3445
- // crossing the breakpoint (rotate a tablet, resize a window) must re-fit the terminal
3446
- const onMQ = () => refit();
5553
+ // Crossing the breakpoint (rotate a tablet, resize a window) must re-fit the terminal AND
5554
+ // re-decide whether the shell exists at all — a shell left mounted above 760px is a second
5555
+ // navigation system on the desktop.
5556
+ const onMQ = () => { applyMobScreen(); refit(); };
3447
5557
  if (MQ.addEventListener) MQ.addEventListener('change', onMQ); else if (MQ.addListener) MQ.addListener(onMQ);
3448
5558
  })();
3449
5559