create-pmos 0.7.3 → 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);
@@ -872,7 +901,7 @@ async function openDoc(pth, kind) {
872
901
  }
873
902
  try {
874
903
  const res = await fetch(`/api/doc?ws=${encodeURIComponent(state.wsId || '')}&path=${encodeURIComponent(pth)}`);
875
- if (!res.ok) { body.innerHTML = `<p class="doc-empty">Could not read <code>${esc(pth)}</code>.</p>`; markActiveDoc(pth); refit(); return; }
904
+ if (!res.ok) { body.innerHTML = `<p class="doc-empty">Could not read <code>${esc(pth)}</code>.</p>`; markActiveDoc(pth); edControls(); refit(); return; }
876
905
  const k = res.headers.get('X-Doc-Kind') || 'markdown';
877
906
  const text = await res.text();
878
907
  if (k === 'html') renderHtmlDoc(body, text);
@@ -883,8 +912,7 @@ async function openDoc(pth, kind) {
883
912
  body.querySelectorAll('[data-doc]').forEach((a) => a.onclick = (e) => { e.preventDefault(); openDoc(a.dataset.doc); });
884
913
  wireImgFallback(body);
885
914
  markActiveDoc(pth);
886
- // Only a markdown document can be reviewed, and only a document — never a summoned surface.
887
- $('docReview').hidden = !(state.docPath && /\.(md|markdown|mdown|mkd|mdx|skill)$/i.test(state.docPath));
915
+ edControls();
888
916
  refit();
889
917
  }
890
918
  function markActiveDoc(pth) {
@@ -931,7 +959,6 @@ function renderHtmlDoc(body, text) {
931
959
  }
932
960
  function closeDoc() {
933
961
  state.docPath = null; state.surface = null;
934
- $('docReview').hidden = true;
935
962
  $('paneDoc').hidden = true;
936
963
  $('railSplit').hidden = true;
937
964
  $('docBody').innerHTML = '';
@@ -1080,6 +1107,8 @@ function flushLayouts() {
1080
1107
  } catch {}
1081
1108
  }
1082
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());
1083
1112
  document.addEventListener('visibilitychange', () => { if (document.hidden) flushLayouts(); });
1084
1113
 
1085
1114
  // ── the layout menu ──
@@ -1600,7 +1629,8 @@ function openNewForm() {
1600
1629
  // pre-filled but editable — a default that reads better than "shell" in a list of shells
1601
1630
  const n = mySessions().length + 1;
1602
1631
  $('newName').value = `${w ? w.name : 'session'} ${n}`;
1603
- 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
1604
1634
  const inp = $('newName');
1605
1635
  setTimeout(() => { inp.focus(); inp.select(); }, 0);
1606
1636
  }
@@ -1608,29 +1638,148 @@ function closeNewForm() {
1608
1638
  $('newForm').hidden = true;
1609
1639
  $('newBtn').setAttribute('aria-expanded', 'false');
1610
1640
  }
1611
- // Populate the branch control from state.branch. A non-repo workspace hides the whole control —
1612
- // 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.
1613
1645
  function fillBranchPicker() {
1614
- const wrap = $('newBranchWrap'), sel = $('newBranch');
1646
+ const wrap = $('newBranchWrap'), sel = $('newBranch'), nogit = $('newNoGit');
1615
1647
  const b = state.branch;
1616
- 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;
1617
1660
  wrap.hidden = false;
1618
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]));
1619
1666
  const opts = [`<option value="">stay on current${cur ? ' (' + esc(cur) + ')' : ''}</option>`]
1620
- .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
+ }))
1621
1672
  .concat([`<option value="__new__">+ new branch…</option>`]);
1622
1673
  sel.innerHTML = opts.join('');
1623
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');
1624
1679
  setNewBranchMode(false);
1625
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
+
1626
1726
  function setNewBranchMode(on) {
1627
1727
  state.newBranchMode = on;
1628
1728
  $('newBranchName').hidden = !on;
1629
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, '-')}`;
1630
1777
  }
1631
1778
  $('newBtn').onclick = () => ($('newForm').hidden ? openNewForm() : closeNewForm());
1632
1779
  $('newCancel').onclick = closeNewForm;
1633
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
1634
1783
  $('newAgents').querySelectorAll('button').forEach((b) => b.onclick = () => {
1635
1784
  state.newAgent = b.dataset.agent;
1636
1785
  $('newAgents').querySelectorAll('button').forEach((x) => x.setAttribute('aria-checked', String(x === b)));
@@ -1639,7 +1788,9 @@ $('newForm').addEventListener('submit', (e) => {
1639
1788
  e.preventDefault();
1640
1789
  const name = $('newName').value.trim();
1641
1790
  const msg = { t: 'new', agent: state.newAgent, name: name || state.newAgent, ws: state.wsId };
1642
- // 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.
1643
1794
  const sel = $('newBranch');
1644
1795
  if (!$('newBranchWrap').hidden && sel) {
1645
1796
  if (sel.value === '__new__') {
@@ -1649,29 +1800,75 @@ $('newForm').addEventListener('submit', (e) => {
1649
1800
  } else if (sel.value) {
1650
1801
  msg.branch = sel.value;
1651
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;
1652
1806
  }
1653
1807
  send(msg);
1654
1808
  closeNewForm();
1655
1809
  });
1656
1810
 
1657
- // ── scratchpad (R4): per-workspace staging text; copy or send-to-terminal without auto-running ──
1658
- 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.
1659
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
+ }
1660
1827
  async function loadScratchpad() {
1661
- 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 || '');
1662
1833
  let text = '';
1663
- try { const r = await fetch('/api/scratchpad?ws=' + encodeURIComponent(id || '')); const j = await r.json(); if (j && j.ok) text = j.text || ''; } catch {}
1664
- 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
1665
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
1666
1858
  }
1667
1859
  function saveScratchpad() {
1668
- const ws = state.wsId, text = $('scratchText').value;
1669
- clearTimeout(scratchTimer);
1670
- 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;
1671
1868
  setScratchStatus('saving…');
1672
1869
  try {
1673
- const r = await fetch('/api/scratchpad', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ws, text }) });
1674
- 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
1675
1872
  setScratchStatus(j && j.ok ? 'saved' : 'save failed'); // a broken save is never a silent green
1676
1873
  } catch { setScratchStatus('save failed'); }
1677
1874
  }, 500);
@@ -1810,24 +2007,122 @@ $('themeBtn').onclick = () => {
1810
2007
  };
1811
2008
  $('closeDoc').onclick = closeDoc;
1812
2009
 
1813
- // "review" on an open document. The whole point of putting it HERE is that the scope needs no
1814
- // naming — the thing to review is the thing you are reading. The server assembles the prompt
1815
- // (skill body + resolved paths) because a tenant repo has no workspace/ folder to point at.
1816
- $('docReview').onclick = async () => {
2010
+ // ── the document editor (workspace-doc-editor) ────────────────────────────────────────────────
2011
+ // The reader gains an edit mode. Nothing an agent produced can reach this path: the PM opens a
2012
+ // document, changes it, and saves. Alignment happens AFTER, from the change — never before it.
2013
+ //
2014
+ // `base` is the text the editor loaded. It travels with the save so the server can refuse if the
2015
+ // file moved on disk in the meantime, rather than quietly discarding whoever changed it.
2016
+ const ed = { on: false, path: null, base: '', dirty: false };
2017
+
2018
+ function edControls() {
2019
+ const isMd = !!(state.docPath && /\.(md|markdown|mdown|mkd|mdx|skill)$/i.test(state.docPath));
2020
+ $('docEdit').hidden = !isMd || ed.on;
2021
+ $('docHistory').hidden = !isMd || ed.on;
2022
+ $('docSave').hidden = !ed.on;
2023
+ $('docCancel').hidden = !ed.on;
2024
+ }
2025
+
2026
+ async function edOpen() {
1817
2027
  const pth = state.docPath;
1818
2028
  if (!pth) return;
1819
- const btn = $('docReview');
1820
- btn.disabled = true; btn.textContent = 'starting…';
2029
+ let text = '';
2030
+ try {
2031
+ const r = await fetch(`/api/doc?ws=${encodeURIComponent(state.wsId || '')}&path=${encodeURIComponent(pth)}`);
2032
+ if (!r.ok) { flash('could not open this document for editing'); return; }
2033
+ text = await r.text();
2034
+ } catch { flash('could not reach the workspace server'); return; }
2035
+
2036
+ ed.on = true; ed.path = pth; ed.base = text; ed.dirty = false;
2037
+ $('docBody').innerHTML = `<div class="ed-wrap">
2038
+ <div class="ed-split" data-diff="off">
2039
+ <textarea class="ed-ta" id="edTa" spellcheck="false"></textarea>
2040
+ <div class="ed-diff" id="edDiff"></div>
2041
+ </div>
2042
+ <div class="ed-bar">
2043
+ <span class="n" id="edStat">no changes yet</span>
2044
+ <input id="edNote" placeholder="why (optional — it is what makes the log worth reading)" autocomplete="off" />
2045
+ </div>
2046
+ </div>`;
2047
+ $('edTa').value = text;
2048
+ $('edTa').oninput = edRender;
2049
+ $('edTa').focus();
2050
+ edControls();
2051
+ refit();
2052
+ }
2053
+
2054
+ // The diff is a VIEW of the edit in progress, computed from the same module the server saves
2055
+ // through — so what is shown and what is written cannot disagree. It is never stored (R4: git holds
2056
+ // the repository's content; the change log holds what changed and why).
2057
+ function edRender() {
2058
+ const next = $('edTa').value;
2059
+ ed.dirty = next !== ed.base;
2060
+ const r = window.ApplyChange.changedRegion(ed.base, next);
2061
+ const split = $('docBody').querySelector('.ed-split');
2062
+ split.dataset.diff = ed.dirty ? 'on' : 'off';
2063
+ $('edStat').innerHTML = ed.dirty
2064
+ ? `<b>+${r.added}</b> <i>−${r.removed}</i> · from line ${r.at_line} · <span class="dirty">unsaved</span>`
2065
+ : 'no changes yet';
2066
+ if (!ed.dirty) { $('edDiff').innerHTML = ''; return; }
2067
+ const rows = [];
2068
+ if (r.before) r.before.split('\n').forEach((l) => rows.push(`<div class="ed-l del"><span class="g"></span><span class="t">${esc(l) || '&nbsp;'}</span></div>`));
2069
+ if (r.after) r.after.split('\n').forEach((l) => rows.push(`<div class="ed-l add"><span class="g"></span><span class="t">${esc(l) || '&nbsp;'}</span></div>`));
2070
+ $('edDiff').innerHTML = rows.join('') || '<div class="ed-none">whitespace only</div>';
2071
+ }
2072
+
2073
+ function edClose(reload) {
2074
+ ed.on = false; ed.path = null; ed.base = ''; ed.dirty = false;
2075
+ edControls();
2076
+ if (reload && state.docPath) openDoc(state.docPath);
2077
+ }
2078
+
2079
+ async function edSave() {
2080
+ if (!ed.on) return;
2081
+ const text = $('edTa').value;
2082
+ if (text === ed.base) { flash('nothing changed'); return; }
2083
+ const btn = $('docSave'); btn.disabled = true;
1821
2084
  let out = {};
1822
2085
  try {
1823
- out = await (await fetch('/api/review/start', { method: 'POST', headers: { 'Content-Type': 'application/json' },
1824
- body: JSON.stringify({ ws: state.wsId, path: pth }) })).json();
2086
+ out = await (await fetch('/api/doc/save', { method: 'POST', headers: { 'Content-Type': 'application/json' },
2087
+ body: JSON.stringify({ ws: state.wsId, path: ed.path, base: ed.base, text, note: $('edNote').value }) })).json();
1825
2088
  } catch { out = { ok: false, error: 'could not reach the workspace server' }; }
1826
- btn.disabled = false; btn.textContent = 'review';
1827
- if (!out.ok) { flash(out.error || 'could not start the review'); return; }
1828
- flash('reviewing ' + pth.split('/').pop() + ' — watch the session');
1829
- if (out.sessionId) selectSession(out.sessionId);
2089
+ btn.disabled = false;
2090
+ if (!out.ok) {
2091
+ // Refused — say so where the PM is looking, and DO NOT close: their text is still in the box.
2092
+ const box = $('docBody').querySelector('.ed-wrap');
2093
+ let e = box.querySelector('.ed-err');
2094
+ if (!e) { e = document.createElement('div'); e.className = 'ed-err'; box.insertBefore(e, box.querySelector('.ed-bar')); }
2095
+ e.textContent = out.error === 'stale'
2096
+ ? (out.detail || 'this document changed on disk since you opened it') + ' — your text is still here; copy it before reopening.'
2097
+ : (out.detail || out.error || 'the save was refused');
2098
+ return;
2099
+ }
2100
+ flash(out.warning ? 'saved — but the change could not be logged' : `saved · +${out.change.added} −${out.change.removed}`);
2101
+ edClose(true);
2102
+ }
2103
+
2104
+ $('docEdit').onclick = edOpen;
2105
+ $('docCancel').onclick = () => edClose(true); // closing IS the whole of "cancel writes nothing"
2106
+ $('docSave').onclick = edSave;
2107
+ $('docHistory').onclick = async () => {
2108
+ const pth = state.docPath; if (!pth) return;
2109
+ let out = {};
2110
+ try { out = await (await fetch(`/api/doc/changes?ws=${encodeURIComponent(state.wsId || '')}&path=${encodeURIComponent(pth)}`)).json(); }
2111
+ catch { out = { ok: false, error: 'could not reach the workspace server' }; }
2112
+ if (!out.ok) { flash(out.error || 'could not read the change log'); return; }
2113
+ const rows = (out.changes || []).map((c) => `<div class="hist-row">
2114
+ <div class="hist-when"><b>+${c.added}</b> <i>−${c.removed}</i> · line ${c.at_line} · ${esc((c.at || '').replace('T', ' ').replace(/\..*$/, ''))}</div>
2115
+ ${c.note ? `<div class="hist-note">${esc(c.note)}</div>` : ''}
2116
+ </div>`).join('');
2117
+ $('docBody').innerHTML = `<div class="hist"><h3>Your changes to this document</h3>
2118
+ <div class="sub">Recorded here as you saved them. The document's own history is <span class="mono">git log</span>; this is what changed and why.</div>
2119
+ ${rows || '<div class="hist-none">You have not changed this document here yet.</div>'}</div>`;
2120
+ refit();
1830
2121
  };
2122
+ document.addEventListener('keydown', (e) => {
2123
+ if (ed.on && (e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); edSave(); }
2124
+ });
2125
+
1831
2126
 
1832
2127
  // ── ⌘K / Ctrl-K jump palette (client-side: workspaces + sessions + initiatives + docs) ──
1833
2128
  // ── on-demand surfaces (Plan · Deliver · Measure · Report) ─────────────────────────────────────
@@ -1840,14 +2135,10 @@ const SURFACES = [
1840
2135
  { name: 'report', label: 'weekly report', phase: 'report' },
1841
2136
  // Read-only console surfaces (D64 Track B): file-mode state, honest-by-construction (a broken
1842
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 },
1843
2139
  { name: 'needs', label: 'needs you', phase: 'deliver', console: true },
1844
2140
  { name: 'radar', label: 'radar', phase: 'measure', console: true },
1845
2141
  { name: 'metrics', label: 'metrics', phase: 'measure', console: true },
1846
- // The summoned document review (workspace-review-and-edit). Summoned like every surface here and
1847
- // never resident — nothing reviews the PM's documents until they ask. The only INTERACTIVE surface:
1848
- // each finding is disposed of by Apply / Edit / Dismiss, and each disposition writes a correction
1849
- // record. Whether an Apply control exists at all is decided by the finding contract, never here.
1850
- { name: 'review', label: 'review', phase: 'deliver', review: true },
1851
2142
  ];
1852
2143
  const SURFACE_BY_NAME = {}; SURFACES.forEach((v) => { SURFACE_BY_NAME[v.name] = v; });
1853
2144
  const SF_STAT = { planning: ['○', 'planning'], building: ['◐', 'building'], blocked: ['◆', 'blocked'], landed: ['▲', 'landed'] };
@@ -1856,7 +2147,7 @@ async function openSurface(name) {
1856
2147
  showRail();
1857
2148
  $('paneDoc').hidden = false; $('railSplit').hidden = false; foldPane('doc', true);
1858
2149
  state.surface = name; state.docPath = null;
1859
- $('docReview').hidden = true;
2150
+ ed.on = false; edControls();
1860
2151
  document.querySelectorAll('.doc').forEach((b) => b.classList.remove('on'));
1861
2152
  $('crumb').innerHTML = `<bdi>surface · ${esc(name)}</bdi>`;
1862
2153
  $('docBody').scrollTop = 0;
@@ -1865,18 +2156,15 @@ async function openSurface(name) {
1865
2156
  $('docBody').innerHTML = `<div class="sf-wrap">${sfHead(name)}<div class="sf-body"><div class="sf-skel"><i></i><i></i><i></i></div></div></div>`;
1866
2157
  wireSurface();
1867
2158
  const meta = SURFACE_BY_NAME[name] || {};
1868
- const endpoint = meta.review
1869
- ? `/api/review?ws=${encodeURIComponent(state.wsId || '')}`
1870
- : meta.console
2159
+ const endpoint = meta.console
1871
2160
  ? `/api/console?name=${encodeURIComponent(name)}&ws=${encodeURIComponent(state.wsId || '')}`
1872
2161
  : `/api/surface?name=${encodeURIComponent(name)}&ws=${encodeURIComponent(state.wsId || '')}`;
1873
2162
  try {
1874
2163
  const r = await fetch(endpoint);
1875
2164
  const payload = await r.json();
1876
- const body = meta.review ? reviewBody(payload) : meta.console ? consoleBody(name, payload) : surfaceBody(name, payload);
2165
+ const body = meta.console ? consoleBody(name, payload) : surfaceBody(name, payload);
1877
2166
  const okFlag = payload.ok !== false;
1878
2167
  $('docBody').innerHTML = `<div class="sf-wrap">${sfHead(name, okFlag)}<div class="sf-body">${body}</div></div>`;
1879
- if (meta.review) { state.review = payload; wireReview(); }
1880
2168
  } catch {
1881
2169
  // A failed FETCH is a broken read — render RED, never a calm "nothing here" (honesty by construction).
1882
2170
  $('docBody').innerHTML = `<div class="sf-wrap">${sfHead(name, false)}<div class="sf-body">${csError(name, 'could not reach the workspace server to build this surface')}</div></div>`;
@@ -1900,197 +2188,8 @@ function sfHead(active, ok) {
1900
2188
  function wireSurface() {
1901
2189
  document.querySelectorAll('[data-surface]').forEach((b) => b.onclick = () => openSurface(b.dataset.surface));
1902
2190
  document.querySelectorAll('[data-open-surface]').forEach((b) => b.onclick = () => openSurface(b.dataset.openSurface));
1903
- $('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));
1904
2192
  }
1905
- // ── the summoned review (workspace-review-and-edit) ───────────────────────────────────────────
1906
- // Renders findings in the quiet register the PM validated on the prototype: no cards, no severity
1907
- // colours, no taxonomy headers, no explanation of its own guardrails. Each finding is a title and a
1908
- // source line; the argument and the actions appear on expansion. Detail is AVAILABLE, not ambient.
1909
- //
1910
- // The load-bearing line is `f.applyable`. It comes from the SERVER, which asks finding-contract.js —
1911
- // the client never re-derives it. A withheld finding renders no Apply control at all: the missing
1912
- // button IS the guard (R7), and a disabled-but-present button would be the failure it exists to stop.
1913
- function reviewBody(p) {
1914
- if (!p || p.ok === false) {
1915
- return csError('review', (p && p.error) || 'the review could not be read')
1916
- + (p && p.errors ? `<ul class="rv-errs">${p.errors.map((e) => `<li>${esc(e)}</li>`).join('')}</ul>` : '');
1917
- }
1918
- if (p.empty) {
1919
- return `<div class="rv-empty"><p>No review filed yet.</p>
1920
- <p class="rv-dim">Run the <b>Review documents</b> skill on the documents you want read.
1921
- Nothing reviews them until you ask.</p></div>`;
1922
- }
1923
- const open = p.findings.filter((f) => !f.settled);
1924
- const lede = open.length === 0
1925
- ? 'Nothing here that these documents cannot already tell you about themselves.'
1926
- : `${open.length} thing${open.length === 1 ? '' : 's'} ${open.length === 1 ? 'this document cannot' : 'these documents cannot'} tell you about ${open.length === 1 ? 'itself' : 'themselves'}.`;
1927
- return `<div class="rv">
1928
- <div class="rv-lede">${esc(lede)}</div>
1929
- <div class="rv-sub">Read ${p.read.map((r) => `<span class="mono">${esc(r)}</span>`).join(', ')}.</div>
1930
- ${p.findings.map((f, i) => rvFinding(f, i)).join('')}
1931
- <div class="rv-tail">Read ${p.read.length} document${p.read.length === 1 ? '' : 's'}. <b>Nothing written.</b>${rvStats(p.stats)}</div>
1932
- </div>`;
1933
- }
1934
- // The PRD's success signal, on the face of the surface. Action rate comes straight off the correction
1935
- // records. Quiet-rate CANNOT — it needs a corpus with a labelled clean class, which does not exist —
1936
- // so it says "not measured" rather than rendering a flattering zero. An unmeasured number shown as a
1937
- // number is the watermelon this whole surface exists to catch.
1938
- function rvStats(s) {
1939
- if (!s || !s.dispositions) return '';
1940
- const pct = (v) => (v == null ? null : `${Math.round(v * 100)}%`);
1941
- const bits = [
1942
- `${s.dispositions} disposition${s.dispositions === 1 ? '' : 's'}`,
1943
- `${s.corrections} correction${s.corrections === 1 ? '' : 's'}`,
1944
- `acted on ${pct(s.action_rate)}`,
1945
- `quiet on clean <i>not measured</i>`,
1946
- ];
1947
- const warn = s.inferred_attributions
1948
- ? ` <b class="rv-bad">${s.inferred_attributions} inferred attribution${s.inferred_attributions === 1 ? '' : 's'}</b>`
1949
- : '';
1950
- return `<div class="rv-stats">${bits.join(' · ')}${warn}</div>`;
1951
- }
1952
- function rvFinding(f, i) {
1953
- const ev = (f.evidence || []).map((e) => `${e.file}:${e.line}`).join(' · ');
1954
- // Three states, and only one of them offers a write. `settled` is terminal for this review.
1955
- const acts = f.settled
1956
- ? `<span class="rv-only">${esc(f.settled === 'dismiss' ? 'Dismissed.' : f.settled === 'edit' ? 'Edited and applied.' : 'Applied.')}</span>`
1957
- : f.applyable
1958
- ? `<button data-rv-apply="${i}">Apply</button><button data-rv-edit="${i}">Edit</button><button class="q" data-rv-dismiss="${i}">Dismiss</button>`
1959
- : `<span class="rv-only">${esc(f.withheld ? 'Yours to settle.' : 'Nothing to apply.')}</span><button class="q" data-rv-dismiss="${i}">Dismiss</button>`;
1960
- return `<div class="rv-f${f.settled ? ' rv-done' : ''}${f.applyable ? '' : ' rv-quiet'}" data-rv="${i}">
1961
- <div class="rv-h">${esc(f.title)}</div>
1962
- <div class="rv-src">${esc(ev || f.file)}</div>
1963
- <div class="rv-d">
1964
- <p>${esc(f.argument)}</p>
1965
- ${f.withheld_reason ? `<p class="rv-why">${esc(f.withheld_reason)}</p>` : ''}
1966
- <div class="rv-acts">${acts}</div>
1967
- </div>
1968
- <div class="rv-ed" data-rv-edbox="${i}"></div>
1969
- </div>`;
1970
- }
1971
- // The edit surface: opens IN PLACE under the finding — no modal, no mode switch, no navigation.
1972
- // Gutter change bars are the VS Code cue; the replaced lines sit struck through above the new ones.
1973
- // Cancel needs no write path at all — it just closes, which is the whole of "Cancel writes nothing".
1974
- // The reason taxonomy, deliberately short. A vocabulary nobody can hold in their head gets skipped,
1975
- // and a skipped reason is a record that teaches nothing. Values match correction-record.js's REASONS;
1976
- // the server rejects anything outside it, so this list can never drift into inventing one.
1977
- const RV_REASONS = [
1978
- ['factually-wrong', 'factually wrong'], ['incomplete', 'incomplete'],
1979
- ['wrong-scope', 'wrong scope'], ['style', 'style'], ['other', 'other…'],
1980
- ];
1981
- function rvEditor(f, i) {
1982
- const before = (f.proposal && f.proposal.before) || '';
1983
- const after = (f.proposal && f.proposal.after) || '';
1984
- const nl = (s) => (s ? s.split('\n').length : 0);
1985
- return `<div class="rv-edfile"><span class="mono">${esc(f.file)}</span>
1986
- <span class="rv-n"><b>+${nl(after)}</b> <i>−${nl(before)}</i></span></div>
1987
- <div class="rv-code">
1988
- ${before.split('\n').map((l) => `<div class="rv-l was"><span class="g"></span><span class="t">${esc(l)}</span></div>`).join('')}
1989
- </div>
1990
- <textarea class="rv-ta" data-rv-ta="${i}" spellcheck="false" rows="${Math.min(12, Math.max(3, nl(after) + 1))}">${esc(after)}</textarea>
1991
- <div class="rv-reason" data-rv-reason="${i}">
1992
- <span class="rv-only">What was wrong with it?</span>
1993
- ${RV_REASONS.map((r) => `<button class="rv-rz" data-rv-rz="${i}" data-val="${r[0]}">${esc(r[1])}</button>`).join('')}
1994
- </div>
1995
- <input class="rv-note" data-rv-note="${i}" hidden placeholder="in a few words…" aria-label="Why" autocomplete="off" />
1996
- <div class="rv-acts">
1997
- <button data-rv-save="${i}">Apply</button>
1998
- <button class="q" data-rv-cancel="${i}">Cancel</button>
1999
- <span class="rv-only">You are editing the change, not the file.</span>
2000
- </div>`;
2001
- }
2002
- function wireReview() {
2003
- const rv = state.review || {};
2004
- const F = (n) => (rv.findings || [])[Number(n)];
2005
- const box = (n) => $('docBody').querySelector(`[data-rv-edbox="${n}"]`);
2006
- const card = (n) => $('docBody').querySelector(`[data-rv="${n}"]`);
2007
-
2008
- $('docBody').querySelectorAll('.rv-f').forEach((el) => {
2009
- el.addEventListener('click', (e) => {
2010
- if (e.target.closest('button') || e.target.closest('textarea')) return;
2011
- if (el.dataset.editing === '1') return; // never collapse an open editor by clicking it
2012
- el.classList.toggle('on');
2013
- });
2014
- });
2015
- $('docBody').querySelectorAll('[data-rv-edit]').forEach((b) => b.onclick = () => {
2016
- const n = b.dataset.rvEdit;
2017
- box(n).innerHTML = rvEditor(F(n), n);
2018
- card(n).dataset.editing = '1';
2019
- wireReview(); // re-wire the freshly rendered controls
2020
- const ta = $('docBody').querySelector(`[data-rv-ta="${n}"]`); if (ta) ta.focus();
2021
- });
2022
- $('docBody').querySelectorAll('[data-rv-cancel]').forEach((b) => b.onclick = () => {
2023
- const n = b.dataset.rvCancel;
2024
- box(n).innerHTML = ''; card(n).dataset.editing = '0'; // closing IS the whole of "writes nothing"
2025
- });
2026
- $('docBody').querySelectorAll('[data-rv-apply]').forEach((b) => b.onclick = () => rvDispose(F(b.dataset.rvApply), 'apply'));
2027
- $('docBody').querySelectorAll('[data-rv-dismiss]').forEach((b) => b.onclick = () => rvDispose(F(b.dataset.rvDismiss), 'dismiss'));
2028
- // The reason tap. Required on an edit (R8 / correction-record.js) — an edit is where the agent was
2029
- // wrong in a way worth knowing, and a record that cannot say HOW is the self-report this feature
2030
- // exists to replace. Dismiss stays one click: friction belongs on the informative action, not the
2031
- // common one (PRD open question 2).
2032
- $('docBody').querySelectorAll('[data-rv-rz]').forEach((b) => b.onclick = () => {
2033
- const n = b.dataset.rvRz;
2034
- const row = $('docBody').querySelector(`[data-rv-reason="${n}"]`);
2035
- row.querySelectorAll('.rv-rz').forEach((x) => x.classList.toggle('on', x === b));
2036
- row.dataset.picked = b.dataset.val;
2037
- const note = $('docBody').querySelector(`[data-rv-note="${n}"]`);
2038
- note.hidden = b.dataset.val !== 'other'; // 'other' is only useful with words
2039
- if (b.dataset.val === 'other') note.focus();
2040
- const nudge = row.querySelector('.rv-nudge'); if (nudge) nudge.remove();
2041
- });
2042
- $('docBody').querySelectorAll('[data-rv-save]').forEach((b) => b.onclick = () => {
2043
- const n = b.dataset.rvSave;
2044
- const row = $('docBody').querySelector(`[data-rv-reason="${n}"]`);
2045
- const picked = row && row.dataset.picked;
2046
- if (!picked) {
2047
- // An inline nudge, not a modal and not a silent default. Defaulting is what produced the bug
2048
- // this replaces: every edit recorded "incomplete" whether or not it was.
2049
- if (!row.querySelector('.rv-nudge')) {
2050
- const s = document.createElement('span');
2051
- s.className = 'rv-nudge'; s.textContent = 'Pick one — the record is worth nothing without it.';
2052
- row.appendChild(s);
2053
- }
2054
- return;
2055
- }
2056
- const note = $('docBody').querySelector(`[data-rv-note="${n}"]`);
2057
- const ta = $('docBody').querySelector(`[data-rv-ta="${n}"]`);
2058
- rvDispose(F(n), 'edit', ta ? ta.value : '', picked, note && !note.hidden ? note.value : undefined);
2059
- });
2060
- }
2061
- async function rvDispose(f, disposition, pmText, reason, reasonNote) {
2062
- if (!f) return;
2063
- const rv = state.review || {};
2064
- // The client sends back the exact text it rendered; the server re-derives the diff and refuses if
2065
- // they differ (sprint criterion 4). That is what makes a stale or tampered client safe.
2066
- let renderedAfter = null;
2067
- if (disposition !== 'dismiss') {
2068
- try {
2069
- const r = await fetch(`/api/doc?ws=${encodeURIComponent(state.wsId || '')}&path=${encodeURIComponent(f.file)}`);
2070
- const cur = await r.text();
2071
- const prop = disposition === 'edit' ? { before: f.proposal.before, after: pmText } : f.proposal;
2072
- renderedAfter = window.ApplyChange.applyChange(cur, prop).text;
2073
- } catch { /* leave null — the server refuses rather than guessing */ }
2074
- }
2075
- const body = {
2076
- ws: state.wsId, disposition, finding: f, rendered_after: renderedAfter,
2077
- pm_text: disposition === 'edit' ? pmText : undefined,
2078
- reason, reason_note: reasonNote, // the PM's own word — never defaulted, never inferred
2079
- run_id: rv.id, initiative_id: 'workspace-review-and-edit',
2080
- };
2081
- let out = {};
2082
- try { out = await (await fetch('/api/review/dispose', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).json(); }
2083
- catch { out = { ok: false, error: 'could not reach the workspace server' }; }
2084
- if (!out.ok) { rvSay(out.error || 'refused', out.detail); return; }
2085
- openSurface('review'); // re-read: the record is the source of truth
2086
- }
2087
- function rvSay(msg, detail) {
2088
- const el = document.createElement('div');
2089
- el.className = 'rv-say';
2090
- el.textContent = detail ? `${msg} — ${detail}` : msg;
2091
- $('docBody').querySelector('.rv') ? $('docBody').querySelector('.rv').prepend(el) : $('docBody').prepend(el);
2092
- }
2093
-
2094
2193
  function surfaceBody(name, payload) {
2095
2194
  if (!payload || !payload.ok) return sfCantProduce(name, payload && payload.contract);
2096
2195
  const s = payload.surface || {};
@@ -2178,6 +2277,7 @@ function consoleBody(name, p) {
2178
2277
  if (name === 'needs') return csNeeds(p.data);
2179
2278
  if (name === 'radar') return csRadar(p.data, p.source);
2180
2279
  if (name === 'metrics') return csMetrics(p.data, p.source);
2280
+ if (name === 'workflow') return csWorkflow(p.data);
2181
2281
  return csError(name, 'unknown console surface');
2182
2282
  }
2183
2283
  function csError(name, why) {
@@ -2190,7 +2290,9 @@ function csError(name, why) {
2190
2290
  function csEmpty(name) {
2191
2291
  const msg = name === 'needs'
2192
2292
  ? 'Nothing needs your decision right now. No open Acceptance Gate, no overdue outcome check.'
2193
- : `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.`;
2194
2296
  return `<div class="cs-empty"><span class="dot" aria-hidden="true"></span><b>Nothing awaits</b>
2195
2297
  <p>${msg}</p><p class="cs-empty-note">This is a real, verified empty — not a failed read.</p></div>`;
2196
2298
  }
@@ -2204,6 +2306,59 @@ function csNeeds(d) {
2204
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>
2205
2307
  </div>`).join('')}</div>`;
2206
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
+ }
2207
2362
  function csRadar(d, source) {
2208
2363
  const g = d.eval_gate || {};
2209
2364
  const cal = d.calibration;