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.
- package/README.md +68 -0
- 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/lib/apply-change.js +51 -1
- package/workspace/lib/console-state.js +56 -1
- package/workspace/lib/git-info.js +197 -25
- package/workspace/lib/session-state.js +139 -0
- package/workspace/lib/transcript-read.js +28 -6
- package/workspace/public/app.js +429 -274
- package/workspace/public/index.html +30 -7
- package/workspace/public/styles.css +898 -624
- 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 +401 -234
- package/workspace/lib/correction-record.js +0 -127
- package/workspace/lib/finding-contract.js +0 -162
- package/workspace/skills/doc-review.skill +0 -119
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);
|
|
@@ -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
|
-
|
|
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(); //
|
|
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
|
|
1612
|
-
//
|
|
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) {
|
|
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) =>
|
|
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
|
|
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):
|
|
1658
|
-
|
|
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
|
|
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?
|
|
1664
|
-
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
|
|
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
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
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
|
|
1674
|
-
|
|
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
|
-
//
|
|
1814
|
-
//
|
|
1815
|
-
//
|
|
1816
|
-
|
|
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
|
-
|
|
1820
|
-
|
|
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) || ' '}</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) || ' '}</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/
|
|
1824
|
-
body: JSON.stringify({ ws: state.wsId, path:
|
|
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;
|
|
1827
|
-
if (!out.ok) {
|
|
1828
|
-
|
|
1829
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
:
|
|
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;
|