create-pmos 0.8.0 → 0.10.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.
- package/README.md +89 -0
- package/bin/create-pmos.js +69 -11
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/skills/pmos-router.skill +22 -0
- package/payload/skills-native/pmos-router/SKILL.md +22 -0
- package/workspace/README.md +28 -0
- package/workspace/bin/pmos.js +9 -2
- package/workspace/lib/console-state.js +56 -1
- package/workspace/lib/git-info.js +197 -25
- package/workspace/lib/related-docs.js +107 -0
- package/workspace/lib/session-state.js +139 -0
- package/workspace/lib/transcript-read.js +28 -6
- package/workspace/package.json +1 -1
- package/workspace/public/app.js +434 -61
- package/workspace/public/index.html +24 -3
- package/workspace/public/styles.css +909 -572
- package/workspace/public/vendor/ibm-plex-mono-400.woff2 +0 -0
- package/workspace/public/vendor/ibm-plex-mono-700.woff2 +0 -0
- package/workspace/public/vendor/space-grotesk-var.woff2 +0 -0
- package/workspace/server.js +566 -39
package/workspace/public/app.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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; },
|
|
261
|
+
setTimeout(() => { el.textContent = prev; }, ms);
|
|
242
262
|
}
|
|
243
263
|
|
|
244
|
-
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
|
|
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
|
-
//
|
|
261
|
-
// on lifecycle events; a periodic full re-render would
|
|
262
|
-
// input (the defect the pre-promotion grilling caught), so
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
if (
|
|
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 ? {
|
|
268
|
-
}
|
|
269
|
-
// The open call's
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
// is
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
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 {
|
|
287
|
-
const
|
|
288
|
-
|
|
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();
|
|
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(); //
|
|
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
|
|
1610
|
-
//
|
|
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) {
|
|
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) =>
|
|
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
|
|
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):
|
|
1656
|
-
|
|
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
|
|
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?
|
|
1662
|
-
if (
|
|
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
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
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
|
|
1672
|
-
|
|
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);
|
|
@@ -1871,10 +2070,12 @@ function edRender() {
|
|
|
1871
2070
|
$('edDiff').innerHTML = rows.join('') || '<div class="ed-none">whitespace only</div>';
|
|
1872
2071
|
}
|
|
1873
2072
|
|
|
1874
|
-
function edClose(reload) {
|
|
2073
|
+
async function edClose(reload) {
|
|
1875
2074
|
ed.on = false; ed.path = null; ed.base = ''; ed.dirty = false;
|
|
1876
2075
|
edControls();
|
|
1877
|
-
|
|
2076
|
+
// awaited: the reload replaces docBody, so anything that renders into it afterwards (the
|
|
2077
|
+
// alignment offer) must not race the replacement.
|
|
2078
|
+
if (reload && state.docPath) await openDoc(state.docPath);
|
|
1878
2079
|
}
|
|
1879
2080
|
|
|
1880
2081
|
async function edSave() {
|
|
@@ -1899,7 +2100,120 @@ async function edSave() {
|
|
|
1899
2100
|
return;
|
|
1900
2101
|
}
|
|
1901
2102
|
flash(out.warning ? 'saved — but the change could not be logged' : `saved · +${out.change.added} −${out.change.removed}`);
|
|
1902
|
-
|
|
2103
|
+
const saved = ed.path;
|
|
2104
|
+
await edClose(true);
|
|
2105
|
+
alOffer(saved); // R7: offered, never blocking — the save is already done
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
|
|
2109
|
+
// ── alignment (workspace-doc-editor R5–R7) ────────────────────────────────────────────────────
|
|
2110
|
+
// Strictly downstream of a save. Nothing here runs until the PM has changed something, and what it
|
|
2111
|
+
// looks at is scoped by that change — not the repository. It is offered, never blocking: the save
|
|
2112
|
+
// completed before any of this, and ignoring it costs nothing.
|
|
2113
|
+
async function alOffer(pth) {
|
|
2114
|
+
if (!pth) return;
|
|
2115
|
+
let out = {};
|
|
2116
|
+
try { out = await (await fetch(`/api/align/candidates?ws=${encodeURIComponent(state.wsId || '')}&path=${encodeURIComponent(pth)}`)).json(); }
|
|
2117
|
+
catch { return; }
|
|
2118
|
+
if (!out.ok || out.empty || !out.candidates || !out.candidates.length) return;
|
|
2119
|
+
state.alCand = out;
|
|
2120
|
+
const n = out.candidates.length;
|
|
2121
|
+
const bar = document.createElement('div');
|
|
2122
|
+
bar.className = 'al-offer';
|
|
2123
|
+
bar.innerHTML = `<span>${n} other document${n === 1 ? '' : 's'} may now be out of step with that change.</span>
|
|
2124
|
+
<button data-al-see>see them</button><button class="q" data-al-no>not now</button>`;
|
|
2125
|
+
const body = $('docBody');
|
|
2126
|
+
body.prepend(bar);
|
|
2127
|
+
bar.querySelector('[data-al-no]').onclick = () => bar.remove();
|
|
2128
|
+
bar.querySelector('[data-al-see]').onclick = () => alShow();
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
// The candidate list, each with the concrete reason it qualified. The PM chooses which to hand to
|
|
2132
|
+
// an agent — so even the drafting step is something they asked for, not something that happened.
|
|
2133
|
+
function alShow() {
|
|
2134
|
+
const c = state.alCand; if (!c) return;
|
|
2135
|
+
$('docBody').innerHTML = `<div class="al">
|
|
2136
|
+
<div class="al-lede">Your change to <span class="mono">${esc(c.change.file)}</span> may leave these out of step.</div>
|
|
2137
|
+
<div class="al-sub">Each one has a concrete tie to what you changed — a link, a shared initiative, or text it quotes. Considered ${c.considered} documents.</div>
|
|
2138
|
+
${c.candidates.map((x, i) => `<label class="al-c">
|
|
2139
|
+
<input type="checkbox" data-al-pick="${i}" checked />
|
|
2140
|
+
<span><span class="al-p mono">${esc(x.path)}</span>
|
|
2141
|
+
<span class="al-why">${x.reasons.map((r) => esc(r)).join(' · ')}</span></span>
|
|
2142
|
+
</label>`).join('')}
|
|
2143
|
+
<div class="al-acts">
|
|
2144
|
+
<button data-al-go>Draft the edits</button>
|
|
2145
|
+
<button class="q" data-al-close>Close</button>
|
|
2146
|
+
<span class="al-note">An agent reads only the documents you tick, and only against this change.</span>
|
|
2147
|
+
</div></div>`;
|
|
2148
|
+
$('docBody').querySelector('[data-al-close]').onclick = () => openDoc(c.change.file);
|
|
2149
|
+
$('docBody').querySelector('[data-al-go]').onclick = async () => {
|
|
2150
|
+
const targets = [...$('docBody').querySelectorAll('[data-al-pick]')]
|
|
2151
|
+
.filter((b) => b.checked).map((b) => c.candidates[Number(b.dataset.alPick)].path);
|
|
2152
|
+
if (!targets.length) { flash('nothing ticked'); return; }
|
|
2153
|
+
let out = {};
|
|
2154
|
+
try {
|
|
2155
|
+
out = await (await fetch('/api/align/start', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
2156
|
+
body: JSON.stringify({ ws: state.wsId, path: c.change.file, change: c.change, targets }) })).json();
|
|
2157
|
+
} catch { out = { ok: false, error: 'could not reach the workspace server' }; }
|
|
2158
|
+
if (!out.ok) { flash(out.error || 'could not start'); return; }
|
|
2159
|
+
flash(`drafting against ${targets.length} document${targets.length === 1 ? '' : 's'} — watch the session, then open drafts`);
|
|
2160
|
+
if (out.sessionId) selectSession(out.sessionId);
|
|
2161
|
+
};
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
// The drafts an agent produced, each an applyable diff. Apply uses the same anchored guard as every
|
|
2165
|
+
// other write here: the anchor must appear exactly once or the write is refused.
|
|
2166
|
+
async function alDrafts() {
|
|
2167
|
+
let out = {};
|
|
2168
|
+
try { out = await (await fetch(`/api/align/drafts?ws=${encodeURIComponent(state.wsId || '')}`)).json(); }
|
|
2169
|
+
catch { out = { ok: false, error: 'could not reach the workspace server' }; }
|
|
2170
|
+
showRail(); $('paneDoc').hidden = false; foldPane('doc', true);
|
|
2171
|
+
$('crumb').innerHTML = '<bdi>alignment drafts</bdi>';
|
|
2172
|
+
if (!out.ok) { $('docBody').innerHTML = `<div class="al"><div class="al-lede">${esc(out.error || 'could not read the drafts')}</div></div>`; return; }
|
|
2173
|
+
if (out.empty || !out.drafts.length) {
|
|
2174
|
+
$('docBody').innerHTML = `<div class="al"><div class="al-lede">No drafts yet.</div>
|
|
2175
|
+
<div class="al-sub">Edit a document, then ask for the alignment when it offers.</div></div>`;
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2178
|
+
state.alDrafts = out;
|
|
2179
|
+
$('docBody').innerHTML = `<div class="al">
|
|
2180
|
+
<div class="al-lede">${out.drafts.length} edit${out.drafts.length === 1 ? '' : 's'} to bring other documents back in line.</div>
|
|
2181
|
+
<div class="al-sub">Drafted against your change to <span class="mono">${esc(out.source || '')}</span>. Nothing is written until you apply it.</div>
|
|
2182
|
+
${out.drafts.map((d, i) => `<div class="al-d" data-al-d="${i}">
|
|
2183
|
+
<div class="al-p mono">${esc(d.file)}</div>
|
|
2184
|
+
<div class="al-why">${esc(d.why || '')}</div>
|
|
2185
|
+
<div class="al-code">
|
|
2186
|
+
${String(d.before || '').split('\n').map((l) => `<div class="ed-l del"><span class="g"></span><span class="t">${esc(l) || ' '}</span></div>`).join('')}
|
|
2187
|
+
${String(d.after || '').split('\n').map((l) => `<div class="ed-l add"><span class="g"></span><span class="t">${esc(l) || ' '}</span></div>`).join('')}
|
|
2188
|
+
</div>
|
|
2189
|
+
<div class="al-acts"><button data-al-apply="${i}">Apply</button>
|
|
2190
|
+
<button class="q" data-al-skip="${i}">Skip</button></div>
|
|
2191
|
+
</div>`).join('')}</div>`;
|
|
2192
|
+
$('docBody').querySelectorAll('[data-al-skip]').forEach((b) => b.onclick = () => {
|
|
2193
|
+
$('docBody').querySelector(`[data-al-d="${b.dataset.alSkip}"]`).classList.add('done');
|
|
2194
|
+
});
|
|
2195
|
+
$('docBody').querySelectorAll('[data-al-apply]').forEach((b) => b.onclick = async () => {
|
|
2196
|
+
const d = state.alDrafts.drafts[Number(b.dataset.alApply)];
|
|
2197
|
+
b.disabled = true;
|
|
2198
|
+
let r = {};
|
|
2199
|
+
try {
|
|
2200
|
+
r = await (await fetch('/api/align/apply', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
2201
|
+
body: JSON.stringify({ ws: state.wsId, file: d.file, before: d.before, after: d.after, why: d.why, source: state.alDrafts.source }) })).json();
|
|
2202
|
+
} catch { r = { ok: false, error: 'could not reach the workspace server' }; }
|
|
2203
|
+
b.disabled = false;
|
|
2204
|
+
const card = $('docBody').querySelector(`[data-al-d="${b.dataset.alApply}"]`);
|
|
2205
|
+
if (!r.ok) {
|
|
2206
|
+
let e = card.querySelector('.al-err');
|
|
2207
|
+
if (!e) { e = document.createElement('div'); e.className = 'al-err'; card.appendChild(e); }
|
|
2208
|
+
e.textContent = r.error === 'stale' ? 'that text is no longer in the file — the draft is out of date'
|
|
2209
|
+
: r.error === 'ambiguous' ? (r.detail || 'that text appears more than once — refusing to guess')
|
|
2210
|
+
: (r.detail || r.error || 'refused');
|
|
2211
|
+
return;
|
|
2212
|
+
}
|
|
2213
|
+
card.classList.add('done');
|
|
2214
|
+
flash('aligned ' + d.file.split('/').pop());
|
|
2215
|
+
});
|
|
2216
|
+
refit();
|
|
1903
2217
|
}
|
|
1904
2218
|
|
|
1905
2219
|
$('docEdit').onclick = edOpen;
|
|
@@ -1936,6 +2250,7 @@ const SURFACES = [
|
|
|
1936
2250
|
{ name: 'report', label: 'weekly report', phase: 'report' },
|
|
1937
2251
|
// Read-only console surfaces (D64 Track B): file-mode state, honest-by-construction (a broken
|
|
1938
2252
|
// read renders RED, never a green empty). Sourced from /api/console, not the deliverable contract.
|
|
2253
|
+
{ name: 'workflow', label: 'workflow', phase: 'plan', console: true },
|
|
1939
2254
|
{ name: 'needs', label: 'needs you', phase: 'deliver', console: true },
|
|
1940
2255
|
{ name: 'radar', label: 'radar', phase: 'measure', console: true },
|
|
1941
2256
|
{ name: 'metrics', label: 'metrics', phase: 'measure', console: true },
|
|
@@ -1988,7 +2303,7 @@ function sfHead(active, ok) {
|
|
|
1988
2303
|
function wireSurface() {
|
|
1989
2304
|
document.querySelectorAll('[data-surface]').forEach((b) => b.onclick = () => openSurface(b.dataset.surface));
|
|
1990
2305
|
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));
|
|
2306
|
+
$('docBody').querySelectorAll('.sf-art[data-doc],.wf-id[data-doc]').forEach((b) => b.onclick = () => openDoc(b.dataset.doc));
|
|
1992
2307
|
}
|
|
1993
2308
|
function surfaceBody(name, payload) {
|
|
1994
2309
|
if (!payload || !payload.ok) return sfCantProduce(name, payload && payload.contract);
|
|
@@ -2077,6 +2392,7 @@ function consoleBody(name, p) {
|
|
|
2077
2392
|
if (name === 'needs') return csNeeds(p.data);
|
|
2078
2393
|
if (name === 'radar') return csRadar(p.data, p.source);
|
|
2079
2394
|
if (name === 'metrics') return csMetrics(p.data, p.source);
|
|
2395
|
+
if (name === 'workflow') return csWorkflow(p.data);
|
|
2080
2396
|
return csError(name, 'unknown console surface');
|
|
2081
2397
|
}
|
|
2082
2398
|
function csError(name, why) {
|
|
@@ -2089,7 +2405,9 @@ function csError(name, why) {
|
|
|
2089
2405
|
function csEmpty(name) {
|
|
2090
2406
|
const msg = name === 'needs'
|
|
2091
2407
|
? 'Nothing needs your decision right now. No open Acceptance Gate, no overdue outcome check.'
|
|
2092
|
-
:
|
|
2408
|
+
: name === 'workflow'
|
|
2409
|
+
? 'No initiative records in this repo — the read succeeded and there is genuinely nothing in flight.'
|
|
2410
|
+
: `No ${esc(name)} data yet — the state read succeeded and there is genuinely nothing to show.`;
|
|
2093
2411
|
return `<div class="cs-empty"><span class="dot" aria-hidden="true"></span><b>Nothing awaits</b>
|
|
2094
2412
|
<p>${msg}</p><p class="cs-empty-note">This is a real, verified empty — not a failed read.</p></div>`;
|
|
2095
2413
|
}
|
|
@@ -2103,6 +2421,59 @@ function csNeeds(d) {
|
|
|
2103
2421
|
<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
2422
|
</div>`).join('')}</div>`;
|
|
2105
2423
|
}
|
|
2424
|
+
// The workflow ladder — the same steps, glyphs and next-actions the CLI and the Initiative Gate
|
|
2425
|
+
// print, from the same engine. Parity is the point: a panel that disagreed with CI would be worse
|
|
2426
|
+
// than no panel, because the PM would trust the one that is about to fail them.
|
|
2427
|
+
const WF_GLYPH = { done: '✓', current: '●', blocked: '✗', pending: '·', na: '─', unknown: '?' };
|
|
2428
|
+
const WF_STAGES = ['intake', 'spec', 'contract', 'build', 'eval', 'accepted'];
|
|
2429
|
+
function csWorkflow(d) {
|
|
2430
|
+
const live = (d && d.live) || [];
|
|
2431
|
+
const bad = (d && d.unreadable) || [];
|
|
2432
|
+
// "Open" is named for what it is derived from — no recorded acceptance — never as "in flight".
|
|
2433
|
+
// Most of these are finished work whose acceptance was never logged, and the count IS the finding.
|
|
2434
|
+
const head = `<div class="cs-h"><span class="dot ${d.blocked ? 'warn' : 'ok'}" aria-hidden="true"></span>
|
|
2435
|
+
<b>${d.total} without a recorded acceptance${d.blocked ? ` · ${d.blocked} blocked` : ''}</b>
|
|
2436
|
+
<span class="src">derived — no status field to keep updated${d.accepted ? ` · ${d.accepted} accepted` : ''}</span></div>`;
|
|
2437
|
+
// A degraded read is stated, never absorbed: without history the order rules are unknown, and an
|
|
2438
|
+
// unknown that renders like a tick is exactly the false green this surface exists to prevent.
|
|
2439
|
+
const degraded = d.degraded
|
|
2440
|
+
? `<div class="wf-degraded" role="alert"><b>Order checks degraded</b> — ${esc(d.degraded)}.
|
|
2441
|
+
Steps that depend on commit order show <span class="mono">?</span>, not a tick.</div>` : '';
|
|
2442
|
+
const unreadable = bad.length
|
|
2443
|
+
? `<div class="wf-degraded" role="alert"><b>${bad.length} record${bad.length === 1 ? '' : 's'} unreadable</b> —
|
|
2444
|
+
${bad.map((b) => esc(b.id)).join(', ')}. Shown here rather than dropped from the count.</div>` : '';
|
|
2445
|
+
if (!live.length) {
|
|
2446
|
+
return `<div class="cs-workflow">${head}${degraded}${unreadable}
|
|
2447
|
+
<div class="cs-none">Every initiative in this repo has a recorded acceptance.</div></div>`;
|
|
2448
|
+
}
|
|
2449
|
+
// Never a silent cap: a truncated list that does not say so reads as "that is everything".
|
|
2450
|
+
const more = d.hidden
|
|
2451
|
+
? `<div class="wf-more">+ ${d.hidden} more without a recorded acceptance, not shown —
|
|
2452
|
+
<span class="mono">node scripts/workflow-state.js --all</span></div>` : '';
|
|
2453
|
+
return `<div class="cs-workflow">${head}${degraded}${unreadable}
|
|
2454
|
+
${live.map((r) => {
|
|
2455
|
+
const at = Math.max(0, WF_STAGES.indexOf(r.stage));
|
|
2456
|
+
const rail = WF_STAGES.map((s, i) =>
|
|
2457
|
+
`<span class="wf-st ${i < at ? 'past' : i === at ? 'at' : ''}">${esc(s)}</span>`).join('<i class="wf-sep" aria-hidden="true"></i>');
|
|
2458
|
+
return `<div class="wf-card" data-blocked="${r.blockers.length ? '1' : '0'}">
|
|
2459
|
+
<div class="wf-top">
|
|
2460
|
+
<button class="wf-id" data-doc="${esc(r.record)}" title="open the initiative record">${esc(r.id)}</button>
|
|
2461
|
+
<span class="wf-lane">${esc(r.lane)}</span>
|
|
2462
|
+
${r.in_change ? '<span class="wf-here">this change</span>' : ''}
|
|
2463
|
+
<span class="wf-anchor" title="${esc(r.anchor || '')}">${esc((r.anchor || 'unanchored').split(/[(—]/)[0].trim())}</span>
|
|
2464
|
+
</div>
|
|
2465
|
+
<div class="wf-rail">${rail}</div>
|
|
2466
|
+
<div class="wf-steps">${r.steps.map((s) => `
|
|
2467
|
+
<div class="wf-step" data-st="${esc(s.status)}">
|
|
2468
|
+
<span class="wf-g" aria-hidden="true">${WF_GLYPH[s.status] || '?'}</span>
|
|
2469
|
+
<span class="wf-k">${esc(s.key)}</span>
|
|
2470
|
+
<span class="wf-e">${esc(s.evidence || '')}${s.rule && s.status === 'blocked' ? ` <span class="wf-rule">${esc(s.rule)}</span>` : ''}</span>
|
|
2471
|
+
</div>${s.next ? `<div class="wf-next" data-st="${esc(s.status)}">→ ${esc(s.next)}</div>` : ''}`).join('')}</div>
|
|
2472
|
+
${r.next ? `<div class="wf-do"><b>next</b> ${esc(r.next.action || '')}</div>`
|
|
2473
|
+
: `<div class="wf-do ok"><b>nothing blocking</b> — the ladder is complete</div>`}
|
|
2474
|
+
</div>`;
|
|
2475
|
+
}).join('')}${more}</div>`;
|
|
2476
|
+
}
|
|
2106
2477
|
function csRadar(d, source) {
|
|
2107
2478
|
const g = d.eval_gate || {};
|
|
2108
2479
|
const cal = d.calibration;
|
|
@@ -2346,6 +2717,7 @@ function openPalette() {
|
|
|
2346
2717
|
const wss = state.workspaces.filter((w) => w.id !== state.wsId).map((w) => ({ kind: 'workspace', label: w.name, wsid: w.id }));
|
|
2347
2718
|
// summon a P/D/M/R surface on demand — listed first so "initiatives"/"roadmap"/"okr"/"report" are reachable
|
|
2348
2719
|
const views = SURFACES.map((v) => ({ kind: 'view', label: v.label, view: v.name }));
|
|
2720
|
+
views.push({ kind: 'align', label: 'alignment drafts' }); // downstream of a save, never resident
|
|
2349
2721
|
palItems = [...views, ...wss, ...sess, ...inits, ...docs];
|
|
2350
2722
|
$('palette').style.display = 'grid';
|
|
2351
2723
|
const inp = $('palInput'); inp.value = ''; renderPalette('');
|
|
@@ -2371,6 +2743,7 @@ function choosePalette(n) {
|
|
|
2371
2743
|
const it = items[n]; if (!it) return;
|
|
2372
2744
|
closePalette();
|
|
2373
2745
|
if (it.kind === 'view') openSurface(it.view);
|
|
2746
|
+
else if (it.kind === 'align') alDrafts();
|
|
2374
2747
|
else if (it.kind === 'session') selectSession(it.id);
|
|
2375
2748
|
else if (it.kind === 'workspace') selectWorkspace(it.wsid);
|
|
2376
2749
|
else if (it.kind === 'initiative') {
|