create-pmos 0.8.0 → 0.9.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.
@@ -28,6 +28,9 @@ const state = {
28
28
  layoutId: null, // the one currently applied
29
29
  layoutSaveT: null, // debounce handle for the write-back
30
30
  layoutDirty: false, // an adjustment not yet on disk — flushed on pagehide
31
+ scratchKey: null, // whose scratchpad is on screen — 'session:<id>' or 'ws:<id>'
32
+ scratchT: null, // debounce handle for the pad's write-back (like layoutSaveT)
33
+ scratchPending: null, // {session,ws,text} typed but not yet written — flushed on a switch
31
34
  terms: new Map(), // id -> {term, fit, el, g}
32
35
  attached: new Set(), // ids attached ON THIS CONNECTION (cleared on reconnect)
33
36
  needs: [],
@@ -38,6 +41,7 @@ const state = {
38
41
  fsDir: null, // the in-app folder browser's current directory
39
42
  ctrlArmed: false, // mobile control bar: Ctrl is a one-shot modifier for the next key
40
43
  branch: null, // {ok,current,branches[],detached,dirty} for the selected workspace
44
+ worktrees: [], // the selected workspace's repo's working trees, with removability
41
45
  newBranchMode: false, // new-session form: creating a branch vs picking an existing one
42
46
  sessSig: null, // structural fingerprint of the last session push (see structSig)
43
47
  };
@@ -151,8 +155,13 @@ function handle(m) {
151
155
  // server pushes a fresh 'sessions'; jump to the new one
152
156
  selectSession(m.id);
153
157
  } else if (m.t === 'branch') {
154
- // a checkout changed the repo's branch — refresh the chip if it's the workspace on screen
155
- if (m.ws === state.wsId) { state.branch = m; renderBranch(); }
158
+ // a checkout changed the repo's branch — refresh the chip if it's the workspace on screen,
159
+ // and the picker too when the form is open (a stale branch list is how a PM picks wrong)
160
+ if (m.ws === state.wsId) { state.branch = m; renderBranch(); loadWorktrees(); if (!$('newForm').hidden) fillBranchPicker(); }
161
+ } else if (m.t === 'notice') {
162
+ // something non-fatal but worth knowing: where a worktree landed, or that a branch was
163
+ // ignored because the workspace has no git. Never a blocked session, never a silent one.
164
+ flash(m.notice, 12000);
156
165
  } else if (m.t === 'error') {
157
166
  // a rejected action (e.g. a failed branch checkout that must NOT spawn) — say so, don't swallow
158
167
  flash(m.error || 'the server rejected that action');
@@ -169,6 +178,9 @@ async function loadBranch() {
169
178
  try { const r = await fetch('/api/git/branches?ws=' + encodeURIComponent(id || '')); info = await r.json(); } catch {}
170
179
  if (id !== state.wsId) return; // a slower fetch for a workspace we've since left must not paint
171
180
  state.branch = info; renderBranch();
181
+ // Refill the open form ONLY while the PM has not chosen anything yet — a refresh landing a beat
182
+ // after a quick selection must never silently reset it back to "stay on current".
183
+ if (!$('newForm').hidden && !$('newBranch').value && !state.newBranchMode) fillBranchPicker();
172
184
  }
173
185
  function renderBranch() {
174
186
  const el = $('branchChip'); if (!el) return;
@@ -176,8 +188,12 @@ function renderBranch() {
176
188
  if (!b || !b.ok) { el.hidden = true; el.textContent = ''; el.title = ''; return; }
177
189
  const label = b.detached ? 'detached HEAD' : (b.current || '');
178
190
  el.hidden = !label;
179
- el.textContent = '⎇ ' + label + (b.dirty ? '*' : '');
180
- el.title = b.dirty ? 'uncommitted changes in the working tree' : 'current branch (shared by every session in this repo)';
191
+ // `⑂` marks a workspace that IS an isolated worktree — the PM should be able to see at a glance
192
+ // that this window's branch is its own, not the shared one.
193
+ el.textContent = (b.linked ? '⑂ ' : '⎇ ') + label + (b.dirty ? '*' : '');
194
+ const where = b.linked ? 'this workspace has its own working tree — switching here moves only its sessions'
195
+ : 'shared working tree — switching here moves every session in this repo';
196
+ el.title = (b.dirty ? 'uncommitted changes · ' : '') + where;
181
197
  el.classList.toggle('dirty', !!b.dirty);
182
198
  }
183
199
 
@@ -196,8 +212,10 @@ async function selectWorkspace(id, opts = {}) {
196
212
  const pick = mine.find((s) => s.id === remembered) || mine.find((s) => s.state !== 'exited') || mine[0];
197
213
  if (pick) selectSession(pick.id); else clearActive();
198
214
  // the arrangement belongs to the repo (R7) — a different workspace brings its own layouts
199
- if (changed) { state.branch = null; renderBranch(); loadScratchpad(); loadLayouts(); }
200
- await Promise.all([loadLibrary(), loadNeeds(), loadBranch()]);
215
+ // the scratchpad follows the ACTIVE TAB, and selectSession/clearActive above have already
216
+ // settled which that is — syncActive() reloaded it, so there is nothing to do here
217
+ if (changed) { state.branch = null; renderBranch(); state.worktrees = []; renderWorktrees(); loadLayouts(); }
218
+ await Promise.all([loadLibrary(), loadNeeds(), loadBranch(), loadWorktrees()]);
201
219
  }
202
220
  function renderWorkspaces() {
203
221
  const html = state.workspaces.map((w) => {
@@ -235,16 +253,21 @@ async function addWorkspace(root) {
235
253
  return true;
236
254
  } catch { flash('could not reach the server'); return false; }
237
255
  }
238
- function flash(msg) {
256
+ // `ms` is longer for messages carrying something to READ — a worktree's path is useless if it
257
+ // vanishes before the PM can copy it out.
258
+ function flash(msg, ms = 4000) {
239
259
  const el = $('wsFoot'); const prev = el.textContent;
240
260
  el.textContent = msg;
241
- setTimeout(() => { el.textContent = prev; }, 4000);
261
+ setTimeout(() => { el.textContent = prev; }, ms);
242
262
  }
243
263
 
244
- const GLYPH = { working: '●', quiet: '◼', blocked: '◆', done: '✓', idle: '—', exited: '○' };
245
- // The word beside the glyph. 'quiet' means no output for a while — NOT 'waiting on you'.
246
- // Quiescence proves quiet, not waiting (PRD R1); the wording may not be upgraded.
247
- const STATE_WORD = { working: 'working', quiet: 'quiet', exited: 'exited' };
264
+ // The glyph and the word both come from the SHARED join module (/lib/session-state.js), never
265
+ // re-typed here. `quiet` still means no output for a while and still NEVER means "waiting on you"
266
+ // (harness-tower R1) — the stronger words exist only where the transcript corroborates the
267
+ // silence, which is a different cell of the table, not a relabelling of this one.
268
+ const SS = window.PMOSSessionState;
269
+ const GLYPH = SS.GLYPH;
270
+ const STATE_WORD = SS.WORD;
248
271
  // The threshold the server derives `quiet` from, sent in `hello`. It used to be re-typed here as a
249
272
  // literal "mirrors harness-parse.QUIET_AFTER_MS" — two copies of one number, free to drift apart and
250
273
  // give the sidebar and the server different opinions about the same session. The literal survives
@@ -257,37 +280,42 @@ function shortDur(ms) {
257
280
  const min = Math.round(sec / 60);
258
281
  return min < 60 ? min + 'm' : Math.round(min / 60) + 'h';
259
282
  }
260
- // Derived client-side too, so a row can go quiet WITHOUT a server push. The server only pushes
261
- // on lifecycle events; a periodic full re-render would destroy an open drill-in and the rename
262
- // input (the defect the pre-promotion grilling caught), so the ticker below repaints chips only.
263
- function shownState(s) {
264
- if (s.state === 'exited') return { shown: 'exited', quietMs: null };
265
- if (!s.lastOutputAt) return { shown: 'working', quietMs: null };
283
+ // The CADENCE clock, derived client-side too so a row can change WITHOUT a server push. The server
284
+ // only pushes on lifecycle events plus the liveness heartbeat; a periodic full re-render would
285
+ // destroy an open drill-in and the rename input (the defect the pre-promotion grilling caught), so
286
+ // the ticker below repaints chips only. This is one of the join's two inputs — never the answer.
287
+ function cadenceState(s) {
288
+ if (s.state === 'exited') return { cadence: 'exited', quietMs: null };
289
+ if (!s.lastOutputAt) return { cadence: 'working', quietMs: null };
266
290
  const q = Math.max(0, Date.now() - s.lastOutputAt);
267
- return q >= QUIET_AFTER_MS ? { shown: 'quiet', quietMs: q } : { shown: 'working', quietMs: q };
268
- }
269
- // The open call's AGE, appended to `working` only. `working · Bash 4m` is the first honest signal
270
- // for HUNG the workspace has — a call open past a glance is a call that may be stuck. The honesty
271
- // bound is structural, not a check bolted on: age rides `shown === 'working'`, and shownState only
272
- // returns 'working' while lastOutputAt is recent, so a crashed/interrupted session whose dangling
273
- // tool_use is a permanent phantom reads `quiet`/`exited` and NEVER a live age. Age without liveness
274
- // is refused. `sinceTs` comes from the transcript record; we tick it client-side like the quiet clock.
275
- function callAge(s, shown) {
276
- if (shown !== 'working') return '';
291
+ return q >= QUIET_AFTER_MS ? { cadence: 'quiet', quietMs: q } : { cadence: 'working', quietMs: q };
292
+ }
293
+ // The open call's verb + AGE. `working · Bash 4m` is the first honest signal for HUNG the workspace
294
+ // has; the weak claim's `· Bash 6m` is the same fact once the bytes stop, and is the whole content
295
+ // of that claim. WHICH states may carry it is the shared module's call (CARRIES_CALL_AGE), not a
296
+ // condition re-typed here — as the words themselves are its call, never literals in this file.
297
+ //
298
+ // The honesty bound is structural, not a check bolted on: the age rides states that require a LIVE
299
+ // cadence or an explicitly-suspect one, and `needs` is not among them — so a crashed session's
300
+ // dangling tool_use (a permanent phantom) can never dress itself as a live age on the strong claim.
301
+ // `sinceTs` comes from the transcript record; we tick it client-side like the quiet clock.
302
+ function openCallOf(s) {
277
303
  const oc = s.hx && s.hx.openCall;
278
- if (!oc || !oc.sinceTs) return '';
304
+ if (!oc || !oc.sinceTs) return null;
279
305
  const ms = Date.parse(oc.sinceTs);
280
- if (!Number.isFinite(ms)) return '';
306
+ if (!Number.isFinite(ms)) return null;
281
307
  const age = Date.now() - ms;
282
- if (age < 0) return '';
283
- return ` · ${oc.verb} ${shortDur(age)}`;
308
+ if (age < 0) return null;
309
+ return { verb: oc.verb, ageMs: age };
284
310
  }
311
+ // THE JOIN, client side — the same module the server uses (R6). Two inputs: the cadence clock above
312
+ // and the transcript's answer to whose turn it is. Neither the table nor the words are duplicated
313
+ // here; if they were, the sidebar and the server could drift into different opinions about one
314
+ // session, which is exactly what shipping QUIET_AFTER_MS from `hello` was done to prevent.
285
315
  function stateLabel(s) {
286
- const { shown, quietMs } = shownState(s);
287
- const w = STATE_WORD[shown] || shown;
288
- if (shown === 'quiet') return { shown, text: `${w} ${shortDur(quietMs)}` };
289
- if (shown === 'working') return { shown, text: `${w}${callAge(s, shown)}` };
290
- return { shown, text: w };
316
+ const { cadence, quietMs } = cadenceState(s);
317
+ const shown = SS.presented(cadence, s.hx && s.hx.turn);
318
+ return { shown, text: SS.label(shown, { quietMs, call: openCallOf(s), fmtDur: shortDur }) };
291
319
  }
292
320
  function renderSessions() {
293
321
  const mine = mySessions();
@@ -529,6 +557,7 @@ function syncActiveState() {
529
557
  });
530
558
  updateExitedOverlay();
531
559
  refreshScratchSend(); // the scratchpad's send button follows the active session's liveness
560
+ renderScratchWho(); // …and its label follows the active tab's name, including a rename
532
561
  }
533
562
 
534
563
  // ── renaming a session (R11): inline, in both surfaces, live or exited. The name chosen at
@@ -584,7 +613,7 @@ const GROUP_TABS = ['stabs', 'stabs1'];
584
613
  function groupOf(id) { const t = state.terms.get(id); return t ? t.g : 0; }
585
614
  function groupHost(g) { return $(GROUP_HOST[g] || GROUP_HOST[0]); }
586
615
  function splitOn() { return !$('group1').hidden; }
587
- function syncActive() { state.active = state.groups[state.focusG].activeId; }
616
+ function syncActive() { state.active = state.groups[state.focusG].activeId; syncScratchpad(); }
588
617
 
589
618
  function ensureTerm(id, g) {
590
619
  const want = typeof g === 'number' ? g : 0;
@@ -646,7 +675,7 @@ function selectSession(id, g) {
646
675
  const s = state.sessions.find((x) => x.id === id);
647
676
  if (!s) return;
648
677
  // selecting a session from the palette may cross workspaces — follow it there
649
- if (s.ws !== state.wsId) { state.wsId = s.ws; store.set('wsid', s.ws); renderWorkspaces(); renderSessions(); loadLibrary(); loadNeeds(); state.branch = null; renderBranch(); loadBranch(); loadScratchpad(); loadLayouts(); }
678
+ if (s.ws !== state.wsId) { state.wsId = s.ws; store.set('wsid', s.ws); renderWorkspaces(); renderSessions(); loadLibrary(); loadNeeds(); state.branch = null; renderBranch(); loadBranch(); loadLayouts(); }
650
679
  // Which group shows it: the one asked for; else the group already showing it (so a click in the
651
680
  // sidebar focuses where it lives rather than yanking it across); else the focused group.
652
681
  let want = typeof g === 'number' ? g : state.groups.findIndex((gr) => gr.activeId === id);
@@ -1078,6 +1107,8 @@ function flushLayouts() {
1078
1107
  } catch {}
1079
1108
  }
1080
1109
  window.addEventListener('pagehide', flushLayouts);
1110
+ // a pad typed into and closed within the debounce window is still the PM's text — write it
1111
+ window.addEventListener('pagehide', () => flushScratchpad());
1081
1112
  document.addEventListener('visibilitychange', () => { if (document.hidden) flushLayouts(); });
1082
1113
 
1083
1114
  // ── the layout menu ──
@@ -1598,7 +1629,8 @@ function openNewForm() {
1598
1629
  // pre-filled but editable — a default that reads better than "shell" in a list of shells
1599
1630
  const n = mySessions().length + 1;
1600
1631
  $('newName').value = `${w ? w.name : 'session'} ${n}`;
1601
- fillBranchPicker(); // populate from the workspace's live branch info
1632
+ fillBranchPicker(); // paint immediately from what we already know…
1633
+ loadBranch(); // …then re-fetch: branches made in a terminal must show up here
1602
1634
  const inp = $('newName');
1603
1635
  setTimeout(() => { inp.focus(); inp.select(); }, 0);
1604
1636
  }
@@ -1606,29 +1638,148 @@ function closeNewForm() {
1606
1638
  $('newForm').hidden = true;
1607
1639
  $('newBtn').setAttribute('aria-expanded', 'false');
1608
1640
  }
1609
- // Populate the branch control from state.branch. A non-repo workspace hides the whole control —
1610
- // there is no honest branch to pick. The default is "stay on the current branch" (no checkout).
1641
+ // Populate the branch control from state.branch. A workspace with no git hides the whole control
1642
+ // and says so once, plainly — a silently missing control reads as "broken", and a PM working in a
1643
+ // tenant product repo that has no git should never be asked a branch question at all. The default
1644
+ // is always "stay on current": starting a session must never require a git decision.
1611
1645
  function fillBranchPicker() {
1612
- const wrap = $('newBranchWrap'), sel = $('newBranch');
1646
+ const wrap = $('newBranchWrap'), sel = $('newBranch'), nogit = $('newNoGit');
1613
1647
  const b = state.branch;
1614
- if (!b || !b.ok) { wrap.hidden = true; return; }
1648
+ if (!b || !b.ok) {
1649
+ wrap.hidden = true;
1650
+ // `no-git` is an ordinary folder, not a fault: one muted line, no warning colour. A real git
1651
+ // error (unreadable HEAD, git missing) says what it was instead of pretending all is well.
1652
+ nogit.hidden = !b || !b.code;
1653
+ nogit.textContent = b && b.code === 'git-error'
1654
+ ? `git could not read this folder (${b.error}) — the session starts here, with no branch applied`
1655
+ : 'no git in this folder — nothing to branch, the session just starts here';
1656
+ nogit.classList.toggle('warn', !!b && b.code === 'git-error');
1657
+ return;
1658
+ }
1659
+ nogit.hidden = true;
1615
1660
  wrap.hidden = false;
1616
1661
  const cur = b.detached ? '' : (b.current || '');
1662
+ // a branch checked out in another worktree cannot be checked out again — git refuses, so the
1663
+ // picker says where it lives rather than letting the PM discover it as an error
1664
+ const elsewhere = new Map((b.worktrees || [])
1665
+ .filter((w) => w.branch && w.branch !== cur).map((w) => [w.branch, w.dir]));
1617
1666
  const opts = [`<option value="">stay on current${cur ? ' (' + esc(cur) + ')' : ''}</option>`]
1618
- .concat((b.branches || []).map((n) => `<option value="${esc(n)}"${n === cur ? ' disabled' : ''}>${esc(n)}${n === cur ? ' — current' : ''}</option>`))
1667
+ .concat((b.branches || []).map((n) => {
1668
+ const where = elsewhere.get(n);
1669
+ const tail = n === cur ? ' — current' : (where ? ' — open in ' + shortPath(where) : '');
1670
+ return `<option value="${esc(n)}"${n === cur ? ' disabled' : ''}>${esc(n)}${esc(tail)}</option>`;
1671
+ }))
1619
1672
  .concat([`<option value="__new__">+ new branch…</option>`]);
1620
1673
  sel.innerHTML = opts.join('');
1621
1674
  sel.value = '';
1675
+ // Isolation protects OTHER sessions — so default it on only when there are others to protect.
1676
+ // Alone in a workspace a plain checkout disturbs nobody and leaves no folder behind, which is
1677
+ // what stops a directory accumulating per branch. Either way the PM can flip it.
1678
+ $('newIsolate').checked = mySessions().some((s) => s.state !== 'exited');
1622
1679
  setNewBranchMode(false);
1623
1680
  }
1681
+ // last two segments of a path — enough to recognise, short enough for a <option> or a hint line
1682
+ function shortPath(p) { const parts = String(p).split('/').filter(Boolean); return parts.slice(-2).join('/'); }
1683
+
1684
+ // ── worktree housekeeping (R7): isolation buys "your other sessions keep running" at the price of
1685
+ // a folder per branch. That price is only fair if the folders are visible and reclaimable from
1686
+ // here — a delete is never a guess, so a tree that is busy, dirty, or holds commits nothing else
1687
+ // holds is refused with the reason spelled out, not silently greyed. ──
1688
+ async function loadWorktrees() {
1689
+ const id = state.wsId;
1690
+ let r = { ok: false };
1691
+ try { r = await (await fetch('/api/git/worktrees?ws=' + encodeURIComponent(id || ''))).json(); } catch {}
1692
+ if (id !== state.wsId) return;
1693
+ state.worktrees = r.ok ? (r.worktrees || []) : [];
1694
+ renderWorktrees();
1695
+ }
1696
+ function renderWorktrees() {
1697
+ const box = $('wtBox'); if (!box) return;
1698
+ const list = (state.worktrees || []).filter((t) => !t.main);
1699
+ box.hidden = list.length === 0; // a repo with one working tree has nothing to manage
1700
+ if (!list.length) return;
1701
+ $('wtSummary').textContent = `⑂ ${list.length} worktree${list.length === 1 ? '' : 's'}`;
1702
+ $('wtList').innerHTML = list.map((t) => `
1703
+ <div class="wtrow" data-dir="${esc(t.dir)}">
1704
+ <span class="wtb" title="${esc(t.dir)}">${esc(t.branch || 'detached')}</span>
1705
+ <button type="button" data-rm="${esc(t.dir)}"${t.removable ? '' : ' disabled'}
1706
+ title="${esc(t.removable ? 'Remove ' + t.dir : t.reasons.join('; '))}">remove</button>
1707
+ ${t.removable ? '' : `<span class="wtwhy">kept — ${esc(t.reasons.join('; '))}</span>`}
1708
+ </div>`).join('');
1709
+ $('wtList').querySelectorAll('[data-rm]').forEach((b) => { b.onclick = () => removeWorktree(b.dataset.rm); });
1710
+ }
1711
+ async function removeWorktree(dir) {
1712
+ // The server re-checks every hazard; this confirm exists because a folder disappearing from
1713
+ // under the PM should be something they said yes to, not a side effect of a mis-click.
1714
+ if (!confirm(`Remove the worktree at\n${dir}\n\nThe branch itself is kept — only the folder goes.`)) return;
1715
+ let r = { ok: false, error: 'request failed' };
1716
+ try {
1717
+ r = await (await fetch('/api/git/worktrees/remove', { method: 'POST',
1718
+ headers: { 'Content-Type': 'application/json' },
1719
+ body: JSON.stringify({ ws: state.wsId, dir }) })).json();
1720
+ } catch {}
1721
+ flash(r.ok ? (r.pruned ? 'cleared git’s record of a folder that was already gone' : 'worktree removed — the branch is still there')
1722
+ : (r.error || 'the worktree was not removed'), r.ok ? 6000 : 12000);
1723
+ loadWorktrees();
1724
+ }
1725
+
1624
1726
  function setNewBranchMode(on) {
1625
1727
  state.newBranchMode = on;
1626
1728
  $('newBranchName').hidden = !on;
1627
1729
  if (on) setTimeout(() => $('newBranchName').focus(), 0);
1730
+ renderBranchHint();
1731
+ }
1732
+
1733
+ // The one line that decides whether the PM trusts this control. It changes with the choice, because
1734
+ // the three choices do genuinely different things:
1735
+ // stay → nothing happens to git at all
1736
+ // isolate (default)→ the branch gets its own folder; running sessions are untouched
1737
+ // switch this repo → every session in this repo moves, and so does every other workspace on it
1738
+ function renderBranchHint() {
1739
+ const hint = $('newBranchHint'), iso = $('newIsolate'), isoWrap = $('newIsolateWrap');
1740
+ const b = state.branch; if (!hint || !b || !b.ok) return;
1741
+ const sel = $('newBranch');
1742
+ const picked = sel.value === '__new__' ? ($('newBranchName').value.trim() || 'the new branch') : sel.value;
1743
+ const cur = b.detached ? 'a detached HEAD' : (b.current || 'the current branch');
1744
+
1745
+ if (!sel.value) { // "stay on current" — no git action, so no warning
1746
+ isoWrap.hidden = true;
1747
+ hint.hidden = false; hint.classList.remove('warn');
1748
+ hint.textContent = `runs on ${cur} — nothing is checked out, nothing else moves`;
1749
+ return;
1750
+ }
1751
+ isoWrap.hidden = false;
1752
+ hint.hidden = false;
1753
+ if (iso.checked) {
1754
+ const dir = worktreeHint(picked);
1755
+ hint.classList.remove('warn');
1756
+ hint.textContent = `${picked} gets its own folder${dir ? ' at ' + dir : ''} — every running session stays on its own branch`;
1757
+ } else {
1758
+ // How alarming this is depends entirely on who is here. Warning about "every session" when
1759
+ // the PM is the only one running teaches them to ignore the warning that matters.
1760
+ const others = mySessions().filter((s) => s.state !== 'exited').length;
1761
+ const extra = b.subdir ? ` (the repo is ${shortPath(b.repoRoot || '')}, this workspace is only ${b.subdir}/ inside it)` : '';
1762
+ hint.classList.toggle('warn', others > 0);
1763
+ hint.textContent = others > 0
1764
+ ? `switches this whole repo to ${picked} — ${others} running session${others === 1 ? '' : 's'} here move${others === 1 ? 's' : ''} with it${extra}`
1765
+ : `checks out ${picked} here — nothing else is running in this workspace, and no new folder is made${extra}`;
1766
+ }
1767
+ }
1768
+ // Mirror of the server's worktreePath, for the hint only — the server is the authority and reports
1769
+ // the real path back in its notice. Kept in sync deliberately: a PM deciding where work lands
1770
+ // should see the answer before committing to it, not after.
1771
+ function worktreeHint(branch) {
1772
+ const b = state.branch;
1773
+ if (!b || !b.ok || !b.repoRoot || !branch || branch === 'the new branch') return '';
1774
+ const parts = String(b.repoRoot).split('/').filter(Boolean);
1775
+ const name = parts[parts.length - 1] || 'repo';
1776
+ return `../${name}-worktrees/${branch.replace(/\//g, '-')}`;
1628
1777
  }
1629
1778
  $('newBtn').onclick = () => ($('newForm').hidden ? openNewForm() : closeNewForm());
1630
1779
  $('newCancel').onclick = closeNewForm;
1631
1780
  $('newBranch').onchange = (e) => setNewBranchMode(e.target.value === '__new__');
1781
+ $('newIsolate').onchange = renderBranchHint;
1782
+ $('newBranchName').oninput = renderBranchHint; // the hint names the folder as the PM types it
1632
1783
  $('newAgents').querySelectorAll('button').forEach((b) => b.onclick = () => {
1633
1784
  state.newAgent = b.dataset.agent;
1634
1785
  $('newAgents').querySelectorAll('button').forEach((x) => x.setAttribute('aria-checked', String(x === b)));
@@ -1637,7 +1788,9 @@ $('newForm').addEventListener('submit', (e) => {
1637
1788
  e.preventDefault();
1638
1789
  const name = $('newName').value.trim();
1639
1790
  const msg = { t: 'new', agent: state.newAgent, name: name || state.newAgent, ws: state.wsId };
1640
- // branch: either create a new one, or check out an existing pick. Empty = stay put (no checkout).
1791
+ // branch: either create a new one, or check out an existing pick. Empty = stay put (no git call
1792
+ // at all) — and that is what a hidden control means too, so a non-git workspace can never have a
1793
+ // stale pick from a previous repo smuggled onto it.
1641
1794
  const sel = $('newBranch');
1642
1795
  if (!$('newBranchWrap').hidden && sel) {
1643
1796
  if (sel.value === '__new__') {
@@ -1647,29 +1800,75 @@ $('newForm').addEventListener('submit', (e) => {
1647
1800
  } else if (sel.value) {
1648
1801
  msg.branch = sel.value;
1649
1802
  }
1803
+ // isolate → the branch gets its own worktree and running sessions are left alone. Only
1804
+ // meaningful when a branch was actually chosen.
1805
+ if (msg.branch) msg.isolate = !!$('newIsolate').checked;
1650
1806
  }
1651
1807
  send(msg);
1652
1808
  closeNewForm();
1653
1809
  });
1654
1810
 
1655
- // ── scratchpad (R4): per-workspace staging text; copy or send-to-terminal without auto-running ──
1656
- let scratchTimer = null;
1811
+ // ── scratchpad (R4): PER-TAB staging text; copy or send-to-terminal without auto-running. The pad
1812
+ // belongs to the session it is staged against, not to the workspace: moving between tabs brings
1813
+ // each tab's own text, and typing in one never overwrites another's. ──
1814
+ // The debounce handle and the not-yet-written text live on `state` (like layoutSaveT): syncActive()
1815
+ // can run before this section of the module has evaluated, and module-level `let`s would be in
1816
+ // their temporal dead zone at that point — which blanked the whole app on first paint.
1657
1817
  function setScratchStatus(s) { const el = $('scratchStatus'); if (el) el.textContent = s; }
1818
+ // Which pad is on screen: the active tab's, or — when the workspace has no tab at all — the
1819
+ // workspace's own. Encoded as one key so a switch is a simple inequality.
1820
+ function scratchKey() { return state.active ? 'session:' + state.active : 'ws:' + (state.wsId || ''); }
1821
+ function renderScratchWho() {
1822
+ const el = $('scratchWho'); if (!el) return;
1823
+ const s = state.sessions.find((x) => x.id === state.active);
1824
+ el.textContent = s ? s.name : '';
1825
+ el.title = s ? `staged for “${s.name}” — each tab keeps its own scratchpad` : '';
1826
+ }
1658
1827
  async function loadScratchpad() {
1659
- const id = state.wsId;
1828
+ const key = scratchKey();
1829
+ state.scratchKey = key;
1830
+ const q = state.active
1831
+ ? 'session=' + encodeURIComponent(state.active)
1832
+ : 'ws=' + encodeURIComponent(state.wsId || '');
1660
1833
  let text = '';
1661
- try { const r = await fetch('/api/scratchpad?ws=' + encodeURIComponent(id || '')); const j = await r.json(); if (j && j.ok) text = j.text || ''; } catch {}
1662
- if (id !== state.wsId) return; // a slower fetch for a workspace we've left must not paint
1834
+ try { const r = await fetch('/api/scratchpad?' + q); const j = await r.json(); if (j && j.ok) text = j.text || ''; } catch {}
1835
+ if (key !== scratchKey()) return; // a slower fetch for a tab we've since left must not paint
1663
1836
  $('scratchText').value = text; setScratchStatus('');
1837
+ renderScratchWho();
1838
+ }
1839
+ // Called on every active-session change. It flushes whatever the PM had just typed into the tab
1840
+ // they are LEAVING before loading the one they are arriving at — without this, the debounce could
1841
+ // still be holding text that would land after the new tab's pad had already painted.
1842
+ function syncScratchpad() {
1843
+ const key = scratchKey();
1844
+ if (key === state.scratchKey) { renderScratchWho(); return; }
1845
+ flushScratchpad();
1846
+ loadScratchpad();
1847
+ }
1848
+ function postScratchpad(p) {
1849
+ return fetch('/api/scratchpad', { method: 'POST', headers: { 'Content-Type': 'application/json' },
1850
+ body: JSON.stringify(p.session ? { session: p.session, text: p.text } : { ws: p.ws, text: p.text }) });
1851
+ }
1852
+ function flushScratchpad() {
1853
+ clearTimeout(state.scratchT);
1854
+ const p = state.scratchPending;
1855
+ state.scratchPending = null;
1856
+ if (!p) return;
1857
+ postScratchpad(p).catch(() => {}); // fire-and-forget: the switch must not wait on the network
1664
1858
  }
1665
1859
  function saveScratchpad() {
1666
- const ws = state.wsId, text = $('scratchText').value;
1667
- clearTimeout(scratchTimer);
1668
- scratchTimer = setTimeout(async () => {
1860
+ // The target is captured NOW, with the text — a save that lands after a tab switch must still
1861
+ // write to the tab it was typed in.
1862
+ state.scratchPending = { session: state.active, ws: state.wsId, text: $('scratchText').value };
1863
+ clearTimeout(state.scratchT);
1864
+ state.scratchT = setTimeout(async () => {
1865
+ const p = state.scratchPending;
1866
+ state.scratchPending = null;
1867
+ if (!p) return;
1669
1868
  setScratchStatus('saving…');
1670
1869
  try {
1671
- const r = await fetch('/api/scratchpad', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ws, text }) });
1672
- const j = await r.json();
1870
+ const j = await (await postScratchpad(p)).json();
1871
+ if (p.session && p.session !== state.active) return; // a pad we've since left: saved, quietly
1673
1872
  setScratchStatus(j && j.ok ? 'saved' : 'save failed'); // a broken save is never a silent green
1674
1873
  } catch { setScratchStatus('save failed'); }
1675
1874
  }, 500);
@@ -1936,6 +2135,7 @@ const SURFACES = [
1936
2135
  { name: 'report', label: 'weekly report', phase: 'report' },
1937
2136
  // Read-only console surfaces (D64 Track B): file-mode state, honest-by-construction (a broken
1938
2137
  // read renders RED, never a green empty). Sourced from /api/console, not the deliverable contract.
2138
+ { name: 'workflow', label: 'workflow', phase: 'plan', console: true },
1939
2139
  { name: 'needs', label: 'needs you', phase: 'deliver', console: true },
1940
2140
  { name: 'radar', label: 'radar', phase: 'measure', console: true },
1941
2141
  { name: 'metrics', label: 'metrics', phase: 'measure', console: true },
@@ -1988,7 +2188,7 @@ function sfHead(active, ok) {
1988
2188
  function wireSurface() {
1989
2189
  document.querySelectorAll('[data-surface]').forEach((b) => b.onclick = () => openSurface(b.dataset.surface));
1990
2190
  document.querySelectorAll('[data-open-surface]').forEach((b) => b.onclick = () => openSurface(b.dataset.openSurface));
1991
- $('docBody').querySelectorAll('.sf-art[data-doc]').forEach((b) => b.onclick = () => openDoc(b.dataset.doc));
2191
+ $('docBody').querySelectorAll('.sf-art[data-doc],.wf-id[data-doc]').forEach((b) => b.onclick = () => openDoc(b.dataset.doc));
1992
2192
  }
1993
2193
  function surfaceBody(name, payload) {
1994
2194
  if (!payload || !payload.ok) return sfCantProduce(name, payload && payload.contract);
@@ -2077,6 +2277,7 @@ function consoleBody(name, p) {
2077
2277
  if (name === 'needs') return csNeeds(p.data);
2078
2278
  if (name === 'radar') return csRadar(p.data, p.source);
2079
2279
  if (name === 'metrics') return csMetrics(p.data, p.source);
2280
+ if (name === 'workflow') return csWorkflow(p.data);
2080
2281
  return csError(name, 'unknown console surface');
2081
2282
  }
2082
2283
  function csError(name, why) {
@@ -2089,7 +2290,9 @@ function csError(name, why) {
2089
2290
  function csEmpty(name) {
2090
2291
  const msg = name === 'needs'
2091
2292
  ? 'Nothing needs your decision right now. No open Acceptance Gate, no overdue outcome check.'
2092
- : `No ${esc(name)} data yet — the state read succeeded and there is genuinely nothing to show.`;
2293
+ : name === 'workflow'
2294
+ ? 'No initiative records in this repo — the read succeeded and there is genuinely nothing in flight.'
2295
+ : `No ${esc(name)} data yet — the state read succeeded and there is genuinely nothing to show.`;
2093
2296
  return `<div class="cs-empty"><span class="dot" aria-hidden="true"></span><b>Nothing awaits</b>
2094
2297
  <p>${msg}</p><p class="cs-empty-note">This is a real, verified empty — not a failed read.</p></div>`;
2095
2298
  }
@@ -2103,6 +2306,59 @@ function csNeeds(d) {
2103
2306
  <div class="m">${esc(n.kind)}${n.initiative_id ? ' · ' + esc(n.initiative_id) : ''}${n.derived ? ' · derived' : ''}${n.run_id ? ' · ' + esc(n.run_id) : ''}</div></div>
2104
2307
  </div>`).join('')}</div>`;
2105
2308
  }
2309
+ // The workflow ladder — the same steps, glyphs and next-actions the CLI and the Initiative Gate
2310
+ // print, from the same engine. Parity is the point: a panel that disagreed with CI would be worse
2311
+ // than no panel, because the PM would trust the one that is about to fail them.
2312
+ const WF_GLYPH = { done: '✓', current: '●', blocked: '✗', pending: '·', na: '─', unknown: '?' };
2313
+ const WF_STAGES = ['intake', 'spec', 'contract', 'build', 'eval', 'accepted'];
2314
+ function csWorkflow(d) {
2315
+ const live = (d && d.live) || [];
2316
+ const bad = (d && d.unreadable) || [];
2317
+ // "Open" is named for what it is derived from — no recorded acceptance — never as "in flight".
2318
+ // Most of these are finished work whose acceptance was never logged, and the count IS the finding.
2319
+ const head = `<div class="cs-h"><span class="dot ${d.blocked ? 'warn' : 'ok'}" aria-hidden="true"></span>
2320
+ <b>${d.total} without a recorded acceptance${d.blocked ? ` · ${d.blocked} blocked` : ''}</b>
2321
+ <span class="src">derived — no status field to keep updated${d.accepted ? ` · ${d.accepted} accepted` : ''}</span></div>`;
2322
+ // A degraded read is stated, never absorbed: without history the order rules are unknown, and an
2323
+ // unknown that renders like a tick is exactly the false green this surface exists to prevent.
2324
+ const degraded = d.degraded
2325
+ ? `<div class="wf-degraded" role="alert"><b>Order checks degraded</b> — ${esc(d.degraded)}.
2326
+ Steps that depend on commit order show <span class="mono">?</span>, not a tick.</div>` : '';
2327
+ const unreadable = bad.length
2328
+ ? `<div class="wf-degraded" role="alert"><b>${bad.length} record${bad.length === 1 ? '' : 's'} unreadable</b> —
2329
+ ${bad.map((b) => esc(b.id)).join(', ')}. Shown here rather than dropped from the count.</div>` : '';
2330
+ if (!live.length) {
2331
+ return `<div class="cs-workflow">${head}${degraded}${unreadable}
2332
+ <div class="cs-none">Every initiative in this repo has a recorded acceptance.</div></div>`;
2333
+ }
2334
+ // Never a silent cap: a truncated list that does not say so reads as "that is everything".
2335
+ const more = d.hidden
2336
+ ? `<div class="wf-more">+ ${d.hidden} more without a recorded acceptance, not shown —
2337
+ <span class="mono">node scripts/workflow-state.js --all</span></div>` : '';
2338
+ return `<div class="cs-workflow">${head}${degraded}${unreadable}
2339
+ ${live.map((r) => {
2340
+ const at = Math.max(0, WF_STAGES.indexOf(r.stage));
2341
+ const rail = WF_STAGES.map((s, i) =>
2342
+ `<span class="wf-st ${i < at ? 'past' : i === at ? 'at' : ''}">${esc(s)}</span>`).join('<i class="wf-sep" aria-hidden="true"></i>');
2343
+ return `<div class="wf-card" data-blocked="${r.blockers.length ? '1' : '0'}">
2344
+ <div class="wf-top">
2345
+ <button class="wf-id" data-doc="${esc(r.record)}" title="open the initiative record">${esc(r.id)}</button>
2346
+ <span class="wf-lane">${esc(r.lane)}</span>
2347
+ ${r.in_change ? '<span class="wf-here">this change</span>' : ''}
2348
+ <span class="wf-anchor" title="${esc(r.anchor || '')}">${esc((r.anchor || 'unanchored').split(/[(—]/)[0].trim())}</span>
2349
+ </div>
2350
+ <div class="wf-rail">${rail}</div>
2351
+ <div class="wf-steps">${r.steps.map((s) => `
2352
+ <div class="wf-step" data-st="${esc(s.status)}">
2353
+ <span class="wf-g" aria-hidden="true">${WF_GLYPH[s.status] || '?'}</span>
2354
+ <span class="wf-k">${esc(s.key)}</span>
2355
+ <span class="wf-e">${esc(s.evidence || '')}${s.rule && s.status === 'blocked' ? ` <span class="wf-rule">${esc(s.rule)}</span>` : ''}</span>
2356
+ </div>${s.next ? `<div class="wf-next" data-st="${esc(s.status)}">→ ${esc(s.next)}</div>` : ''}`).join('')}</div>
2357
+ ${r.next ? `<div class="wf-do"><b>next</b> ${esc(r.next.action || '')}</div>`
2358
+ : `<div class="wf-do ok"><b>nothing blocking</b> — the ladder is complete</div>`}
2359
+ </div>`;
2360
+ }).join('')}${more}</div>`;
2361
+ }
2106
2362
  function csRadar(d, source) {
2107
2363
  const g = d.eval_gate || {};
2108
2364
  const cal = d.calibration;
@@ -4,6 +4,13 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
6
  <title>PMOS Terminal Workspace</title>
7
+ <!-- The terminal's face is preloaded, not left to be discovered. An @font-face is only fetched when
8
+ something first RENDERS in it, which for Plex Mono is about the same moment app.js constructs
9
+ the first xterm — and xterm measures its cell from whatever face is resolved at construction.
10
+ Starting the fetch here, during head parse, means the real metrics are in place before the
11
+ first terminal exists. app.js also re-fits on document.fonts.ready as the backstop. -->
12
+ <link rel="preload" href="/vendor/ibm-plex-mono-400.woff2" as="font" type="font/woff2" crossorigin />
13
+ <link rel="preload" href="/vendor/ibm-plex-mono-700.woff2" as="font" type="font/woff2" crossorigin />
7
14
  <link rel="stylesheet" href="/vendor/xterm.css" />
8
15
  <link rel="stylesheet" href="/styles.css" />
9
16
  </head>
@@ -65,6 +72,12 @@
65
72
  </div>
66
73
  <p class="hint">or just run <code>pmos</code> inside that repo</p>
67
74
  </form>
75
+ <!-- Worktrees the workspace made are folders the PM has to live with, so the app that
76
+ creates them is the app that clears them. Hidden entirely until there is one. -->
77
+ <details class="wtbox" id="wtBox" hidden>
78
+ <summary id="wtSummary">worktrees</summary>
79
+ <div id="wtList"></div>
80
+ </details>
68
81
  </div>
69
82
  </section>
70
83
 
@@ -83,11 +96,16 @@
83
96
  <button type="button" data-agent="codex" role="radio" aria-checked="false">codex</button>
84
97
  </div>
85
98
  <div class="brwrap" id="newBranchWrap" hidden>
86
- <label class="lbl" for="newBranch">branch</label>
99
+ <label class="lbl" for="newBranch">branch <span class="opt">— optional</span></label>
87
100
  <select id="newBranch" aria-label="Branch to run this session on"></select>
88
101
  <input id="newBranchName" type="text" hidden placeholder="new-branch-name" aria-label="New branch name" autocomplete="off" spellcheck="false" />
89
- <p class="hint warn">the branch is shared by every session in this repo — switching or creating one moves them all</p>
102
+ <label class="chk" id="newIsolateWrap" hidden>
103
+ <input type="checkbox" id="newIsolate" checked />
104
+ <span>give it its own folder — leave running sessions alone</span>
105
+ </label>
106
+ <p class="hint" id="newBranchHint"></p>
90
107
  </div>
108
+ <p class="hint" id="newNoGit" hidden>no git in this folder — nothing to branch, the session just starts here</p>
91
109
  <div class="row">
92
110
  <button type="submit" class="go">start session</button>
93
111
  <button type="button" class="quiet" id="newCancel">cancel</button>
@@ -144,12 +162,14 @@
144
162
 
145
163
  <!-- Scratchpad: a collapsible drawer at the BOTTOM of the terminal column. Folded it is a thin
146
164
  bar; open it takes a bounded slice below the terminal — it never overlays the tabs, the
147
- terminal, or the document rail. Persists per workspace. -->
165
+ terminal, or the document rail. Persists PER TAB: each session keeps its own staging
166
+ text, and the label below says which tab you are staging for. -->
148
167
  <section class="scratch" id="scratch" data-open="false" aria-label="Scratchpad">
149
168
  <div class="scratch-head">
150
169
  <button type="button" class="scratch-toggle" id="scratchToggle" aria-expanded="false" aria-controls="scratchText">
151
170
  <span class="chev" aria-hidden="true">▴</span> scratchpad
152
171
  </button>
172
+ <span class="scratch-who" id="scratchWho"></span>
153
173
  <span class="scratch-status" id="scratchStatus" role="status"></span>
154
174
  <span class="scratch-actions">
155
175
  <button type="button" class="mini" id="scratchCopy" title="Copy to clipboard">copy</button>
@@ -260,6 +280,7 @@
260
280
  <script src="/vendor/addon-web-links.js"></script>
261
281
  <script src="/lib/preview-kind.js"></script>
262
282
  <script src="/lib/layout.js"></script>
283
+ <script src="/lib/session-state.js"></script>
263
284
  <!-- The editor computes its diff with the SAME module the server saves through, so what you see
264
285
  while editing and what the server writes can never disagree. -->
265
286
  <script src="/lib/apply-change.js"></script>