create-pmos 0.5.0 → 0.6.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 +114 -0
- package/bin/create-pmos.js +93 -7
- package/package.json +7 -3
- package/payload/AGENTS-HUMAN.md +6 -0
- package/payload/AGENTS.md +35 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/README.md +6 -0
- package/payload/SECURITY.md +6 -0
- package/payload/okf/core/concepts/frontmatter-format.md +75 -0
- package/payload/okf/core/concepts/index.md +3 -0
- package/payload/okf/core/concepts/log.md +13 -0
- package/payload/okf/core/templates/eval-rubric-template.md +12 -10
- package/payload/okf/core/templates/prd-template.md +6 -0
- package/payload/okf/core/templates/prototype-brief-template.md +45 -21
- package/payload/planning/evals/agent-run-eval-rubric.md +10 -7
- package/payload/scripts/metrics.py +142 -121
- package/payload/skills/SKILL-FORMAT.md +6 -0
- package/payload/skills/discovery.skill +4 -0
- package/payload/skills/eval-rubric.skill +1 -1
- package/payload/skills/explain-surface.skill +156 -0
- package/payload/skills/pmos-router.skill +25 -3
- package/payload/skills/prd.skill +8 -0
- package/payload/skills/prototype.skill +106 -41
- package/payload/skills-native/discovery/SKILL.md +4 -0
- package/payload/skills-native/eval-rubric/SKILL.md +1 -1
- package/payload/skills-native/explain-surface/SKILL.md +158 -0
- package/payload/skills-native/pmos-router/SKILL.md +25 -3
- package/payload/skills-native/prd/SKILL.md +8 -0
- package/payload/skills-native/prototype/SKILL.md +106 -41
- package/payload/templates/agent-run-eval-rubric.md +10 -7
- package/payload/templates/ticket-template.md +2 -0
- package/workspace/README.md +112 -0
- package/workspace/REMOTE.md +131 -0
- package/workspace/bin/pmos.js +206 -0
- package/workspace/doctor.js +99 -0
- package/workspace/lib/console-state.js +117 -0
- package/workspace/lib/file-mode-parse.js +58 -0
- package/workspace/lib/harness-parse.js +222 -0
- package/workspace/lib/preview-kind.js +98 -0
- package/workspace/lib/rate-limit.js +26 -0
- package/workspace/lib/remote-access.js +235 -0
- package/workspace/lib/review-gate.js +39 -0
- package/workspace/lib/schedule-logic.js +132 -0
- package/workspace/lib/skill-store.js +143 -0
- package/workspace/lib/surface-contract.js +79 -0
- package/workspace/lib/transcript-read.js +247 -0
- package/workspace/package-lock.json +94 -0
- package/workspace/package.json +23 -0
- package/workspace/public/app.js +1710 -0
- package/workspace/public/index.html +175 -0
- package/workspace/public/styles.css +766 -0
- package/workspace/server.js +1753 -0
- package/workspace/skills/backlog-triage.skill +13 -0
- package/workspace/skills/weekly-report.skill +19 -0
|
@@ -0,0 +1,1710 @@
|
|
|
1
|
+
/* PMOS Terminal Workspace — GUI client.
|
|
2
|
+
* Attaches to the persistent session server over WebSocket; renders each session's REAL
|
|
3
|
+
* PTY in an xterm.js terminal (the agent harness runs untouched inside). Sessions survive
|
|
4
|
+
* detach — closing/reloading this client never kills a run.
|
|
5
|
+
*
|
|
6
|
+
* One server hosts MANY workspaces (repo roots). Everything on screen — sessions, tabs,
|
|
7
|
+
* library, needs-you — is scoped to the selected workspace; switching never disturbs a
|
|
8
|
+
* session running in another one.
|
|
9
|
+
*
|
|
10
|
+
* Layout (round 2): the left column IS the workspace — repos over running agents, equal
|
|
11
|
+
* halves. The terminal is the middle. The right rail carries the library over the document
|
|
12
|
+
* reader; either pane folds away and the whole rail hides. */
|
|
13
|
+
'use strict';
|
|
14
|
+
const $ = (id) => document.getElementById(id);
|
|
15
|
+
const FitAddon = (window.FitAddon && window.FitAddon.FitAddon) || null;
|
|
16
|
+
const WebLinks = (window.WebLinksAddon && window.WebLinksAddon.WebLinksAddon) || null;
|
|
17
|
+
|
|
18
|
+
const state = {
|
|
19
|
+
ws: null, sessions: [], workspaces: [], home: null, openHarness: new Set(),
|
|
20
|
+
wsId: null, // selected workspace
|
|
21
|
+
lib: null, // library payload for the selected workspace
|
|
22
|
+
lens: 'initiatives', // 'initiatives' | 'folders'
|
|
23
|
+
filter: '',
|
|
24
|
+
active: null, // active session id
|
|
25
|
+
terms: new Map(), // id -> {term, fit, el}
|
|
26
|
+
attached: new Set(), // ids attached ON THIS CONNECTION (cleared on reconnect)
|
|
27
|
+
needs: [],
|
|
28
|
+
newAgent: 'shell',
|
|
29
|
+
renaming: null, // session id being renamed inline
|
|
30
|
+
renameVal: '', // survives a re-render mid-edit
|
|
31
|
+
docPath: null, // the document open in the reader
|
|
32
|
+
fsDir: null, // the in-app folder browser's current directory
|
|
33
|
+
ctrlArmed: false, // mobile control bar: Ctrl is a one-shot modifier for the next key
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// ── workspace state survives reloads (R7): theme + selected workspace + focused session,
|
|
37
|
+
// and now the rail/pane layout the PM chose (R12). ──
|
|
38
|
+
const store = {
|
|
39
|
+
get: (k) => { try { return localStorage.getItem('pmos-ws:' + k); } catch { return null; } },
|
|
40
|
+
set: (k, v) => { try { localStorage.setItem('pmos-ws:' + k, v); } catch {} },
|
|
41
|
+
};
|
|
42
|
+
{ const t = store.get('theme'); if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t; }
|
|
43
|
+
{ const l = store.get('lens'); if (['folders', 'initiatives', 'scheduled', 'history'].includes(l)) state.lens = l; }
|
|
44
|
+
// ?ws=<id> is how `pmos` in another repo lands you in THAT repo, not the last one you opened.
|
|
45
|
+
const urlWs = new URLSearchParams(location.search).get('ws');
|
|
46
|
+
|
|
47
|
+
// ── xterm theme, single-sourced from the CSS token layer (no duplicated hex). The
|
|
48
|
+
// terminal ground is dark by design (--term-* tokens hold the same value in both themes). ──
|
|
49
|
+
function xtheme() {
|
|
50
|
+
const cs = getComputedStyle(document.documentElement);
|
|
51
|
+
const v = (n, f) => (cs.getPropertyValue(n).trim() || f);
|
|
52
|
+
return {
|
|
53
|
+
background: v('--xt-bg', '#07090c'), foreground: v('--xt-fg', '#b9c2ce'),
|
|
54
|
+
cursor: v('--xt-cursor', '#48d597'), cursorAccent: v('--xt-bg', '#07090c'),
|
|
55
|
+
selectionBackground: v('--xt-sel', 'rgba(79,147,247,.3)'),
|
|
56
|
+
black: v('--xt-black', '#0a0c10'), red: v('--xt-red', '#f2655f'), green: v('--xt-green', '#48d597'),
|
|
57
|
+
yellow: v('--xt-yellow', '#f1c052'), blue: v('--xt-blue', '#4f93f7'), magenta: v('--xt-magenta', '#b39cf7'),
|
|
58
|
+
cyan: v('--xt-cyan', '#6fd6c4'), white: v('--xt-white', '#cdd6e2'),
|
|
59
|
+
brightBlack: v('--xt-brblack', '#5d6675'), brightRed: v('--xt-red', '#f2655f'), brightGreen: v('--xt-brgreen', '#7fd9a8'),
|
|
60
|
+
brightYellow: v('--xt-yellow', '#f1c052'), brightBlue: v('--xt-accent-text', '#9cc2fb'),
|
|
61
|
+
brightMagenta: v('--xt-magenta', '#b39cf7'), brightCyan: v('--xt-cyan', '#6fd6c4'), brightWhite: v('--xt-brwhite', '#eef1f5'),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── WebSocket attach lifecycle (auto-reconnect). ──
|
|
66
|
+
function connect() {
|
|
67
|
+
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
68
|
+
const ws = new WebSocket(`${proto}://${location.host}`);
|
|
69
|
+
state.ws = ws;
|
|
70
|
+
ws.onopen = () => setConn('live');
|
|
71
|
+
ws.onclose = () => { setConn('off'); state.attached.clear(); setTimeout(connect, 1200); }; // survive server blips
|
|
72
|
+
ws.onmessage = (ev) => handle(JSON.parse(ev.data));
|
|
73
|
+
}
|
|
74
|
+
function send(obj) { if (state.ws && state.ws.readyState === 1) state.ws.send(JSON.stringify(obj)); }
|
|
75
|
+
function setConn(s) {
|
|
76
|
+
const el = $('conn');
|
|
77
|
+
el.className = 'conn ' + s;
|
|
78
|
+
el.textContent = s === 'live' ? 'LIVE · local' : 'reconnecting…';
|
|
79
|
+
$('wsFoot').textContent = s === 'live' ? 'local · persistent server · sessions running' : 'server unreachable — retrying';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// sessions belonging to the selected workspace — the whole UI reads through this
|
|
83
|
+
function mySessions() { return state.sessions.filter((s) => s.ws === state.wsId); }
|
|
84
|
+
|
|
85
|
+
function handle(m) {
|
|
86
|
+
if (m.t === 'hello') {
|
|
87
|
+
state.workspaces = m.workspaces; state.home = m.home; state.sessions = m.sessions;
|
|
88
|
+
// pick: the URL (a fresh `pmos` in this repo) → the last one used → home
|
|
89
|
+
const remembered = store.get('wsid');
|
|
90
|
+
const pick = [urlWs, remembered, m.home].find((id) => id && m.workspaces.some((w) => w.id === id))
|
|
91
|
+
|| (m.workspaces[0] && m.workspaces[0].id);
|
|
92
|
+
selectWorkspace(pick, { restoreSession: true });
|
|
93
|
+
} else if (m.t === 'workspaces') {
|
|
94
|
+
state.workspaces = m.workspaces;
|
|
95
|
+
if (!state.workspaces.some((w) => w.id === state.wsId)) selectWorkspace(state.home, { restoreSession: true });
|
|
96
|
+
else renderWorkspaces();
|
|
97
|
+
} else if (m.t === 'focus') {
|
|
98
|
+
// `pmos` was run in another repo while this tab was open — go there, which is the whole
|
|
99
|
+
// point: the browser refocusing this tab must not strand the PM on the previous repo.
|
|
100
|
+
if (m.ws && m.ws !== state.wsId) selectWorkspace(m.ws, { restoreSession: true });
|
|
101
|
+
} else if (m.t === 'sessions') {
|
|
102
|
+
// a removed session's terminal must not leak — drop its pane + term instance
|
|
103
|
+
const ids = new Set(m.sessions.map((s) => s.id));
|
|
104
|
+
for (const [id, t] of [...state.terms]) {
|
|
105
|
+
if (!ids.has(id)) { try { t.term.dispose(); } catch {} t.el.remove(); state.terms.delete(id); state.attached.delete(id); }
|
|
106
|
+
}
|
|
107
|
+
if (state.renaming && !ids.has(state.renaming)) cancelRename();
|
|
108
|
+
state.sessions = m.sessions; renderSessions(); renderWorkspaces();
|
|
109
|
+
if (state.active && !ids.has(state.active)) {
|
|
110
|
+
const mine = mySessions();
|
|
111
|
+
const nx = mine.find((s) => s.state !== 'exited') || mine[mine.length - 1];
|
|
112
|
+
if (nx) selectSession(nx.id); else clearActive();
|
|
113
|
+
} else { syncActiveState(); }
|
|
114
|
+
} else if (m.t === 'attached') {
|
|
115
|
+
const t = ensureTerm(m.id);
|
|
116
|
+
t.term.reset();
|
|
117
|
+
if (m.buf) t.term.write(m.buf); // replay scrollback on (re)attach
|
|
118
|
+
fitOne(m.id);
|
|
119
|
+
} else if (m.t === 'data') {
|
|
120
|
+
const t = state.terms.get(m.id); if (t) t.term.write(m.data);
|
|
121
|
+
} else if (m.t === 'created') {
|
|
122
|
+
// server pushes a fresh 'sessions'; jump to the new one
|
|
123
|
+
selectSession(m.id);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── workspaces ──
|
|
128
|
+
async function selectWorkspace(id, opts = {}) {
|
|
129
|
+
if (!id) return;
|
|
130
|
+
const changed = id !== state.wsId;
|
|
131
|
+
state.wsId = id;
|
|
132
|
+
store.set('wsid', id);
|
|
133
|
+
if (changed) closeDoc();
|
|
134
|
+
renderWorkspaces();
|
|
135
|
+
renderSessions();
|
|
136
|
+
// focus a session in THIS workspace: the remembered one if it belongs here, else its first live one
|
|
137
|
+
const mine = mySessions();
|
|
138
|
+
const remembered = opts.restoreSession ? store.get('active') : null;
|
|
139
|
+
const pick = mine.find((s) => s.id === remembered) || mine.find((s) => s.state !== 'exited') || mine[0];
|
|
140
|
+
if (pick) selectSession(pick.id); else clearActive();
|
|
141
|
+
await Promise.all([loadLibrary(), loadNeeds()]);
|
|
142
|
+
}
|
|
143
|
+
function renderWorkspaces() {
|
|
144
|
+
const html = state.workspaces.map((w) => {
|
|
145
|
+
const n = state.sessions.filter((s) => s.ws === w.id && s.state !== 'exited').length;
|
|
146
|
+
const cur = w.id === state.wsId;
|
|
147
|
+
return `<div class="wsrow" data-id="${esc(w.id)}">
|
|
148
|
+
<button class="ws" data-pick="${esc(w.id)}" aria-current="${cur}" title="${esc(w.root)}">
|
|
149
|
+
<span class="cue" aria-hidden="true">${cur ? '▾' : '▸'}</span>
|
|
150
|
+
<span class="nm">${esc(w.name)}</span>
|
|
151
|
+
${w.home ? '<span class="tag">home</span>' : ''}
|
|
152
|
+
<small>${n ? `${n} live` : ''}</small>
|
|
153
|
+
</button>
|
|
154
|
+
${w.home ? '' : `<button class="forget" data-forget="${esc(w.id)}" aria-label="Stop hosting ${esc(w.name)}" title="${n ? 'end its sessions first' : 'stop hosting this repo (the repo is untouched)'}">✕</button>`}
|
|
155
|
+
</div>`;
|
|
156
|
+
}).join('');
|
|
157
|
+
$('workspaces').innerHTML = html || `<div class="empty">No workspaces — run <b>pmos</b> inside a repo.</div>`;
|
|
158
|
+
$('workspaces').querySelectorAll('[data-pick]').forEach((b) => b.onclick = () => selectWorkspace(b.dataset.pick));
|
|
159
|
+
$('workspaces').querySelectorAll('[data-forget]').forEach((b) => b.onclick = () => forgetWorkspace(b.dataset.forget));
|
|
160
|
+
}
|
|
161
|
+
async function forgetWorkspace(id) {
|
|
162
|
+
const w = state.workspaces.find((x) => x.id === id);
|
|
163
|
+
const live = state.sessions.filter((s) => s.ws === id);
|
|
164
|
+
if (live.length) { flash(`“${w ? w.name : 'that workspace'}” still has ${live.length} session(s) — close them first.`); return; }
|
|
165
|
+
try { await fetch('/api/workspaces/forget', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) }); } catch {}
|
|
166
|
+
}
|
|
167
|
+
async function addWorkspace(root) {
|
|
168
|
+
if (!root || !root.trim()) return false;
|
|
169
|
+
try {
|
|
170
|
+
const r = await fetch('/api/workspaces', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
171
|
+
body: JSON.stringify({ root: root.trim(), focus: false }) });
|
|
172
|
+
const j = await r.json();
|
|
173
|
+
if (!j.ok) { flash(j.error || 'could not add that path'); return false; }
|
|
174
|
+
selectWorkspace(j.workspace.id);
|
|
175
|
+
closeAddWs();
|
|
176
|
+
return true;
|
|
177
|
+
} catch { flash('could not reach the server'); return false; }
|
|
178
|
+
}
|
|
179
|
+
function flash(msg) {
|
|
180
|
+
const el = $('wsFoot'); const prev = el.textContent;
|
|
181
|
+
el.textContent = msg;
|
|
182
|
+
setTimeout(() => { el.textContent = prev; }, 4000);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const GLYPH = { working: '●', quiet: '◼', blocked: '◆', done: '✓', idle: '—', exited: '○' };
|
|
186
|
+
// The word beside the glyph. 'quiet' means no output for a while — NOT 'waiting on you'.
|
|
187
|
+
// Quiescence proves quiet, not waiting (PRD R1); the wording may not be upgraded.
|
|
188
|
+
const STATE_WORD = { working: 'working', quiet: 'quiet', exited: 'exited' };
|
|
189
|
+
const QUIET_AFTER_MS = 30000; // mirrors harness-parse.QUIET_AFTER_MS
|
|
190
|
+
function shortDur(ms) {
|
|
191
|
+
if (ms == null) return '';
|
|
192
|
+
const sec = Math.round(ms / 1000);
|
|
193
|
+
if (sec < 60) return sec + 's';
|
|
194
|
+
const min = Math.round(sec / 60);
|
|
195
|
+
return min < 60 ? min + 'm' : Math.round(min / 60) + 'h';
|
|
196
|
+
}
|
|
197
|
+
// Derived client-side too, so a row can go quiet WITHOUT a server push. The server only pushes
|
|
198
|
+
// on lifecycle events; a periodic full re-render would destroy an open drill-in and the rename
|
|
199
|
+
// input (the defect the pre-promotion grilling caught), so the ticker below repaints chips only.
|
|
200
|
+
function shownState(s) {
|
|
201
|
+
if (s.state === 'exited') return { shown: 'exited', quietMs: null };
|
|
202
|
+
if (!s.lastOutputAt) return { shown: 'working', quietMs: null };
|
|
203
|
+
const q = Math.max(0, Date.now() - s.lastOutputAt);
|
|
204
|
+
return q >= QUIET_AFTER_MS ? { shown: 'quiet', quietMs: q } : { shown: 'working', quietMs: q };
|
|
205
|
+
}
|
|
206
|
+
function stateLabel(s) {
|
|
207
|
+
const { shown, quietMs } = shownState(s);
|
|
208
|
+
const w = STATE_WORD[shown] || shown;
|
|
209
|
+
return { shown, text: shown === 'quiet' ? `${w} ${shortDur(quietMs)}` : w };
|
|
210
|
+
}
|
|
211
|
+
function renderSessions() {
|
|
212
|
+
const mine = mySessions();
|
|
213
|
+
$('sessCount').textContent = String(mine.length);
|
|
214
|
+
const needN = state.needs.length;
|
|
215
|
+
$('needCount').textContent = needN ? `${needN} need you` : '';
|
|
216
|
+
$('sessions').innerHTML = mine.length ? mine.map((s) => {
|
|
217
|
+
const st = stateLabel(s);
|
|
218
|
+
return `
|
|
219
|
+
<div class="sessrow" data-id="${s.id}">
|
|
220
|
+
<button class="sess" data-id="${s.id}" data-state="${st.shown}" aria-current="${s.id === state.active}"
|
|
221
|
+
aria-label="${esc(s.name)} — ${esc(st.text)}">
|
|
222
|
+
<span class="r1"><span class="st" aria-hidden="true">${GLYPH[st.shown] || '•'}</span>
|
|
223
|
+
<span class="nm">${esc(s.name)}${s.origin && s.origin.startsWith('schedule:') ? '<span class="schtag">scheduled</span>' : ''}</span>
|
|
224
|
+
<span class="chip">${esc(st.text)}</span></span>
|
|
225
|
+
<span class="sub">${esc(s.agent)} · ${esc(s.cwd)}</span>
|
|
226
|
+
${saysLine(s)}
|
|
227
|
+
</button>
|
|
228
|
+
<button class="ren" data-rename="${s.id}" aria-label="Rename ${esc(s.name)}" title="rename (F2)">✎</button>
|
|
229
|
+
</div>${harnessDrawer(s)}`;
|
|
230
|
+
}).join('')
|
|
231
|
+
: `<div class="empty">None running here — <b>+ new session</b> to run an agent in this repo.</div>`;
|
|
232
|
+
$('sessions').querySelectorAll('.sess').forEach((b) => {
|
|
233
|
+
b.onclick = () => selectSession(b.dataset.id);
|
|
234
|
+
b.ondblclick = () => startRename(b.dataset.id);
|
|
235
|
+
b.onkeydown = (e) => { if (e.key === 'F2') { e.preventDefault(); startRename(b.dataset.id); } };
|
|
236
|
+
});
|
|
237
|
+
$('sessions').querySelectorAll('[data-rename]').forEach((b) => b.onclick = () => startRename(b.dataset.rename));
|
|
238
|
+
// Restore each drill-in's openness AFTER render. renderSessions() replaces the pane's
|
|
239
|
+
// innerHTML on every server push, so a bare <details open> closes itself; openness is held
|
|
240
|
+
// in state and re-applied here — the same shape as paintRename() below.
|
|
241
|
+
$('sessions').querySelectorAll('details.hz').forEach((d) => {
|
|
242
|
+
d.open = state.openHarness.has(d.dataset.id);
|
|
243
|
+
d.addEventListener('toggle', () => {
|
|
244
|
+
if (d.open) state.openHarness.add(d.dataset.id); else state.openHarness.delete(d.dataset.id);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
renderTabs();
|
|
248
|
+
updateEmptyOverlay();
|
|
249
|
+
paintRename();
|
|
250
|
+
}
|
|
251
|
+
// The agent's own last words, verbatim — never a paraphrase (a summary would be another model
|
|
252
|
+
// call per session, and a drifting paraphrase was a leg-A defect). When the parser cannot read,
|
|
253
|
+
// it says so: an unreadable signal must never render as an empty line.
|
|
254
|
+
function saysLine(s) {
|
|
255
|
+
if (s.lastWords) return `<span class="says">${esc(s.lastWords)}</span>`;
|
|
256
|
+
const why = s.missing && s.missing.lastWords;
|
|
257
|
+
return why ? `<span class="says na">${esc(why)}</span>` : '';
|
|
258
|
+
}
|
|
259
|
+
// The harness readout: a drill-in, not a rail. The leg-A grilling established the harness is
|
|
260
|
+
// near-static within a run, so this is read once to understand a session — not watched.
|
|
261
|
+
// Unavailable facts are drawn as unavailable, never as a plausible constant.
|
|
262
|
+
function fmtTokens(n) {
|
|
263
|
+
if (n == null) return '—';
|
|
264
|
+
if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B';
|
|
265
|
+
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
|
|
266
|
+
if (n >= 1e3) return Math.round(n / 1e3) + 'k';
|
|
267
|
+
return String(n);
|
|
268
|
+
}
|
|
269
|
+
// The harness readout. Everything here comes from the CLI's own structured transcript, because
|
|
270
|
+
// the CLI does not PRINT its model, effort, tokens or skill set to the terminal — parsing
|
|
271
|
+
// scrollback for them returned four empty rows, which is what sent this back for a second pass.
|
|
272
|
+
// Rows that genuinely cannot be known are still drawn as unavailable, never as a plausible value.
|
|
273
|
+
function harnessDrawer(s) {
|
|
274
|
+
const hx = s.hx || { missing: {} };
|
|
275
|
+
const m = hx.missing || {};
|
|
276
|
+
const rows = [];
|
|
277
|
+
const na = (why) => `<dd class="na">${esc(why)}</dd>`;
|
|
278
|
+
|
|
279
|
+
if (m.all) {
|
|
280
|
+
// one honest line beats six apologies
|
|
281
|
+
rows.push(`<dt>harness</dt>${na(m.all)}`);
|
|
282
|
+
} else {
|
|
283
|
+
rows.push(hx.model
|
|
284
|
+
? `<dt>model</dt><dd><b>${esc(hx.model)}</b>${hx.effort ? ' · effort ' + esc(hx.effort) : ''}` +
|
|
285
|
+
`${hx.turns ? ` <span class="src">${hx.turns} turns</span>` : ''}</dd>`
|
|
286
|
+
: `<dt>model</dt>${na(m.model || 'not available')}`);
|
|
287
|
+
rows.push(hx.tokens
|
|
288
|
+
? `<dt>tokens</dt><dd><b>${fmtTokens(hx.tokens.output)}</b> out · ${fmtTokens(hx.tokens.input)} in` +
|
|
289
|
+
` <span class="src">${fmtTokens(hx.tokens.cacheRead)} cached — no % (the window size is not in the record)</span></dd>`
|
|
290
|
+
: `<dt>tokens</dt>${na(m.tokens || 'not available')}`);
|
|
291
|
+
rows.push(hx.tools
|
|
292
|
+
? `<dt>tools</dt><dd>${Object.entries(hx.tools.counts).sort((a, b) => b[1] - a[1]).slice(0, 6)
|
|
293
|
+
.map(([n, c]) => `${esc(n)} ×${c}`).join(' · ')} <span class="src">${hx.tools.total} calls</span></dd>`
|
|
294
|
+
: `<dt>tools</dt>${na(m.tools || 'not available')}`);
|
|
295
|
+
rows.push(hx.skills
|
|
296
|
+
? `<dt>skills</dt><dd><b>${hx.skills.count} available</b>` +
|
|
297
|
+
`${hx.skills.names.length ? ` <span class="src">${esc(hx.skills.names.slice(0, 5).join(', '))}…</span>` : ''}` +
|
|
298
|
+
` <span class="src">available, not “in use” — the CLI records no such event</span></dd>`
|
|
299
|
+
: `<dt>skills</dt>${na(m.skills || 'not available')}`);
|
|
300
|
+
rows.push('<dt>instructions</dt><dd>CLAUDE.md · AGENTS.md <span class="src">identical in every session here</span></dd>');
|
|
301
|
+
// The one row that stays unavailable, and it is architectural rather than a parsing limit:
|
|
302
|
+
// Quality gate state is GitHub CI, Review gate state is hosted Supabase, and the workspace
|
|
303
|
+
// is deliberately backend-free. No parser reaches it.
|
|
304
|
+
rows.push(`<dt>gates</dt>${na('not visible from the workspace — gate state is hosted')}`);
|
|
305
|
+
if (hx.cliVersion) rows.push(`<dt>cli</dt><dd><span class="src">${esc(hx.cliVersion)} — undocumented layout; a CLI upgrade may blank these rows</span></dd>`);
|
|
306
|
+
}
|
|
307
|
+
return `<details class="hz" data-id="${s.id}">
|
|
308
|
+
<summary>model + harness<span class="sr">${esc(s.name)}</span></summary>
|
|
309
|
+
<dl class="kv">${rows.join('')}</dl></details>`;
|
|
310
|
+
}
|
|
311
|
+
// A row must be able to go quiet with NO server push. Repaint the chip, glyph, data-state and
|
|
312
|
+
// accessible name only — never innerHTML — so an open drill-in and an in-flight rename survive.
|
|
313
|
+
function tickQuiet() {
|
|
314
|
+
for (const s of mySessions()) {
|
|
315
|
+
const btn = $('sessions').querySelector(`.sess[data-id="${s.id}"]`);
|
|
316
|
+
if (!btn) continue;
|
|
317
|
+
const st = stateLabel(s);
|
|
318
|
+
if (btn.dataset.state !== st.shown) {
|
|
319
|
+
btn.dataset.state = st.shown;
|
|
320
|
+
const g = btn.querySelector('.st'); if (g) g.textContent = GLYPH[st.shown] || '•';
|
|
321
|
+
}
|
|
322
|
+
const chip = btn.querySelector('.chip');
|
|
323
|
+
if (chip && chip.textContent !== st.text) {
|
|
324
|
+
chip.textContent = st.text;
|
|
325
|
+
btn.setAttribute('aria-label', `${s.name} — ${st.text}`);
|
|
326
|
+
}
|
|
327
|
+
const tab = $('stabs').querySelector(`.stab-wrap[data-id="${s.id}"]`);
|
|
328
|
+
if (tab && tab.dataset.state !== st.shown) tab.dataset.state = st.shown;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
try { const _q = setInterval(tickQuiet, 5000); if (_q.unref) _q.unref(); } catch {}
|
|
332
|
+
|
|
333
|
+
function renderTabs() {
|
|
334
|
+
// wrap = two SIBLING buttons (select + close) — no interactive-inside-interactive nesting
|
|
335
|
+
$('stabs').innerHTML = mySessions().map((s) => `
|
|
336
|
+
<div class="stab-wrap" data-state="${stateLabel(s).shown}" data-id="${s.id}" data-active="${s.id === state.active}">
|
|
337
|
+
<button class="stab" role="tab" data-id="${s.id}" aria-selected="${s.id === state.active}"
|
|
338
|
+
aria-label="${esc(s.name)} — ${s.state}" title="double-click to rename">
|
|
339
|
+
<span class="g" aria-hidden="true">${GLYPH[s.state] || '•'}</span>${esc(s.name)}</button>
|
|
340
|
+
<button class="x" data-close="${s.id}"
|
|
341
|
+
aria-label="${s.state === 'exited' ? 'remove' : 'close'} ${esc(s.name)}">✕</button>
|
|
342
|
+
</div>`).join('');
|
|
343
|
+
$('stabs').querySelectorAll('.stab').forEach((b) => {
|
|
344
|
+
b.onclick = () => selectSession(b.dataset.id);
|
|
345
|
+
b.ondblclick = () => startRename(b.dataset.id);
|
|
346
|
+
});
|
|
347
|
+
// live tab ✕ kills the process (state flips to exited); exited tab ✕ removes it for good
|
|
348
|
+
$('stabs').querySelectorAll('.x').forEach((b) => b.onclick = () => send({ t: 'kill', id: b.dataset.close }));
|
|
349
|
+
}
|
|
350
|
+
function syncActiveState() {
|
|
351
|
+
document.querySelectorAll('.sess,.stab-wrap').forEach((b) => {
|
|
352
|
+
const s = state.sessions.find((x) => x.id === b.dataset.id);
|
|
353
|
+
if (s) b.dataset.state = s.state;
|
|
354
|
+
});
|
|
355
|
+
updateExitedOverlay();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ── renaming a session (R11): inline, in both surfaces, live or exited. The name chosen at
|
|
359
|
+
// creation is a guess; the PM learns what the run actually is minutes later. Server pushes
|
|
360
|
+
// rebuild these lists, so the edit is held in state and re-painted after every render. ──
|
|
361
|
+
function startRename(id) {
|
|
362
|
+
const s = state.sessions.find((x) => x.id === id);
|
|
363
|
+
if (!s) return;
|
|
364
|
+
state.renaming = id;
|
|
365
|
+
state.renameVal = s.name;
|
|
366
|
+
paintRename();
|
|
367
|
+
}
|
|
368
|
+
function cancelRename() { state.renaming = null; state.renameVal = ''; }
|
|
369
|
+
function commitRename() {
|
|
370
|
+
const id = state.renaming, val = state.renameVal;
|
|
371
|
+
cancelRename();
|
|
372
|
+
const s = state.sessions.find((x) => x.id === id);
|
|
373
|
+
if (id && val.trim() && s && val.trim() !== s.name) send({ t: 'rename', id, name: val.trim() });
|
|
374
|
+
renderSessions();
|
|
375
|
+
}
|
|
376
|
+
function paintRename() {
|
|
377
|
+
const id = state.renaming;
|
|
378
|
+
if (!id) return;
|
|
379
|
+
const row = $('sessions').querySelector(`.sessrow[data-id="${id}"]`);
|
|
380
|
+
const tab = $('stabs').querySelector(`.stab-wrap[data-id="${id}"]`);
|
|
381
|
+
const host = row || tab;
|
|
382
|
+
if (!host) return;
|
|
383
|
+
const input = document.createElement('input');
|
|
384
|
+
input.className = 'renbox';
|
|
385
|
+
input.value = state.renameVal;
|
|
386
|
+
input.setAttribute('aria-label', 'Rename session');
|
|
387
|
+
input.maxLength = 60;
|
|
388
|
+
input.oninput = () => { state.renameVal = input.value; };
|
|
389
|
+
input.onkeydown = (e) => {
|
|
390
|
+
e.stopPropagation();
|
|
391
|
+
if (e.key === 'Enter') { e.preventDefault(); commitRename(); }
|
|
392
|
+
else if (e.key === 'Escape') { e.preventDefault(); cancelRename(); renderSessions(); }
|
|
393
|
+
};
|
|
394
|
+
input.onblur = () => { if (state.renaming === id) commitRename(); };
|
|
395
|
+
host.innerHTML = '';
|
|
396
|
+
host.appendChild(input);
|
|
397
|
+
input.focus(); input.select();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ── terminals ──
|
|
401
|
+
function ensureTerm(id) {
|
|
402
|
+
let t = state.terms.get(id);
|
|
403
|
+
if (t) return t;
|
|
404
|
+
const el = document.createElement('div');
|
|
405
|
+
el.className = 'termpane'; el.dataset.id = id;
|
|
406
|
+
$('termhost').appendChild(el);
|
|
407
|
+
const term = new window.Terminal({
|
|
408
|
+
fontFamily: '"IBM Plex Mono", ui-monospace, monospace', fontSize: 13, theme: xtheme(),
|
|
409
|
+
cursorBlink: true, scrollback: 5000, allowProposedApi: true,
|
|
410
|
+
});
|
|
411
|
+
let fit = null;
|
|
412
|
+
if (FitAddon) { fit = new FitAddon(); term.loadAddon(fit); }
|
|
413
|
+
if (WebLinks) term.loadAddon(new WebLinks());
|
|
414
|
+
term.open(el);
|
|
415
|
+
// ⌘K must open the palette even while the terminal holds focus — intercept before xterm
|
|
416
|
+
// sends it to the PTY (return false = don't pass the key through).
|
|
417
|
+
term.attachCustomKeyEventHandler((e) => {
|
|
418
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
|
419
|
+
if (e.type === 'keydown') { e.preventDefault(); togglePalette(); }
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
return true;
|
|
423
|
+
});
|
|
424
|
+
term.onData((d) => send({ t: 'input', id, data: applyCtrl(d) }));
|
|
425
|
+
term.onResize(({ cols, rows }) => send({ t: 'resize', id, cols, rows }));
|
|
426
|
+
t = { term, fit, el };
|
|
427
|
+
state.terms.set(id, t);
|
|
428
|
+
return t;
|
|
429
|
+
}
|
|
430
|
+
function fitOne(id) {
|
|
431
|
+
const t = state.terms.get(id); if (!t || !t.fit) return;
|
|
432
|
+
requestAnimationFrame(() => { try { t.fit.fit(); } catch {} });
|
|
433
|
+
}
|
|
434
|
+
// Anything that changes the terminal's box must re-fit it, or xterm keeps rendering more rows
|
|
435
|
+
// than fit and the BOTTOM OF THE TERMINAL disappears under the clip (R9). A single observer on
|
|
436
|
+
// the host catches every cause — the needs strip, the reader, a folded rail pane, the tab strip
|
|
437
|
+
// wrapping, a window resize — instead of a fitOne() call sprinkled at each call site.
|
|
438
|
+
let refitTimer = null;
|
|
439
|
+
function refit() {
|
|
440
|
+
clearTimeout(refitTimer);
|
|
441
|
+
refitTimer = setTimeout(() => { if (state.active) fitOne(state.active); }, 30);
|
|
442
|
+
}
|
|
443
|
+
if (window.ResizeObserver) new ResizeObserver(refit).observe($('termhost'));
|
|
444
|
+
window.addEventListener('resize', refit);
|
|
445
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden) refit(); });
|
|
446
|
+
// first paint measures the fallback font; the real metrics arrive with the webfont
|
|
447
|
+
if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => { for (const [id] of state.terms) fitOne(id); });
|
|
448
|
+
|
|
449
|
+
function selectSession(id) {
|
|
450
|
+
const s = state.sessions.find((x) => x.id === id);
|
|
451
|
+
if (!s) return;
|
|
452
|
+
// selecting a session from the palette may cross workspaces — follow it there
|
|
453
|
+
if (s.ws !== state.wsId) { state.wsId = s.ws; store.set('wsid', s.ws); renderWorkspaces(); renderSessions(); loadLibrary(); loadNeeds(); }
|
|
454
|
+
state.active = id;
|
|
455
|
+
store.set('active', id); // survives reloads (R7)
|
|
456
|
+
// attach once per connection — re-selecting an attached session must not reset + re-replay
|
|
457
|
+
if (!state.attached.has(id)) { state.attached.add(id); send({ t: 'attach', id }); }
|
|
458
|
+
ensureTerm(id);
|
|
459
|
+
for (const [sid, t] of state.terms) t.el.classList.toggle('on', sid === id);
|
|
460
|
+
document.querySelectorAll('.sess').forEach((b) => b.setAttribute('aria-current', String(b.dataset.id === id)));
|
|
461
|
+
document.querySelectorAll('.stab').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.id === id)));
|
|
462
|
+
document.querySelectorAll('.stab-wrap').forEach((b) => b.dataset.active = String(b.dataset.id === id));
|
|
463
|
+
fitOne(id);
|
|
464
|
+
const t = state.terms.get(id); if (t) setTimeout(() => t.term.focus(), 0);
|
|
465
|
+
updateExitedOverlay(); updateEmptyOverlay();
|
|
466
|
+
}
|
|
467
|
+
function clearActive() {
|
|
468
|
+
state.active = null;
|
|
469
|
+
$('termhost').querySelectorAll('.termpane').forEach((e) => e.classList.remove('on'));
|
|
470
|
+
updateExitedOverlay(); updateEmptyOverlay();
|
|
471
|
+
}
|
|
472
|
+
function updateExitedOverlay() {
|
|
473
|
+
const s = state.sessions.find((x) => x.id === state.active);
|
|
474
|
+
const ov = $('exited');
|
|
475
|
+
if (s && s.state === 'exited') {
|
|
476
|
+
ov.classList.add('on');
|
|
477
|
+
// show WHY it exited (exit code / spawn error), not just "has exited"
|
|
478
|
+
$('exitedMsg').innerHTML = `<b>${esc(s.name)}</b> has exited.` +
|
|
479
|
+
(s.note ? `<span class="reason">${esc(s.note)}</span>` : '');
|
|
480
|
+
$('respawnBtn').onclick = () => send({ t: 'respawn', id: s.id });
|
|
481
|
+
$('removeBtn').onclick = () => send({ t: 'remove', id: s.id });
|
|
482
|
+
} else ov.classList.remove('on');
|
|
483
|
+
}
|
|
484
|
+
function updateEmptyOverlay() {
|
|
485
|
+
// a workspace with no sessions must say so — not show another workspace's terminal
|
|
486
|
+
const w = state.workspaces.find((x) => x.id === state.wsId);
|
|
487
|
+
const on = !state.active && mySessions().length === 0;
|
|
488
|
+
$('noSessWs').textContent = w ? w.name : 'this workspace';
|
|
489
|
+
$('noSess').classList.toggle('on', on);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ── library ──
|
|
493
|
+
// Two lenses over the same file set: the initiative lens (how the PM thinks — one unit of
|
|
494
|
+
// work, all its artifacts) and the folder lens (how the repo is laid out). Every document
|
|
495
|
+
// reachable in one is reachable in the other.
|
|
496
|
+
let lastLibLoad = 0;
|
|
497
|
+
async function loadLibrary() {
|
|
498
|
+
lastLibLoad = Date.now();
|
|
499
|
+
try {
|
|
500
|
+
const r = await fetch('/api/library?ws=' + encodeURIComponent(state.wsId || ''));
|
|
501
|
+
state.lib = await r.json();
|
|
502
|
+
} catch { state.lib = null; }
|
|
503
|
+
renderLibrary();
|
|
504
|
+
}
|
|
505
|
+
// The library is a filesystem SNAPSHOT — a folder or file added after it loaded won't appear on
|
|
506
|
+
// its own (the reported defect). Re-scan on an explicit ↻, and automatically when the PM returns
|
|
507
|
+
// to the tab (throttled), so switching to an editor, adding a folder, and switching back shows it.
|
|
508
|
+
function refreshLibrary() { loadLibrary(); }
|
|
509
|
+
function maybeAutoRefreshLibrary() { if (state.wsId && Date.now() - lastLibLoad > 4000) loadLibrary(); }
|
|
510
|
+
window.addEventListener('focus', maybeAutoRefreshLibrary);
|
|
511
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden) maybeAutoRefreshLibrary(); });
|
|
512
|
+
function renderLibrary() {
|
|
513
|
+
// a third, persistent lens beside initiatives + folders: the scheduled tasks for this workspace
|
|
514
|
+
if (state.lens === 'scheduled') {
|
|
515
|
+
document.querySelectorAll('.lens button').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.lens === 'scheduled')));
|
|
516
|
+
renderScheduled();
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
// a fourth: what past sessions in THIS repo actually printed. The filter box becomes the query.
|
|
520
|
+
if (state.lens === 'history') {
|
|
521
|
+
document.querySelectorAll('.lens button').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.lens === 'history')));
|
|
522
|
+
renderHistory();
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
const lib = state.lib;
|
|
526
|
+
const host = $('library');
|
|
527
|
+
// BOTH lenses are always available: initiatives (the PMOS projection — works whenever the repo
|
|
528
|
+
// has PRDs, even in a nested bundle) and folders (the complete repo map). A repo with no
|
|
529
|
+
// initiatives shows an empty-but-honest initiative view — the toggle is never dead, never hidden.
|
|
530
|
+
const effLens = state.lens === 'folders' ? 'folders' : 'initiatives';
|
|
531
|
+
document.querySelectorAll('.lens button').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.lens === effLens)));
|
|
532
|
+
if (!lib) { host.innerHTML = `<div class="empty">Library unavailable.</div>`; return; }
|
|
533
|
+
const f = state.filter.trim().toLowerCase();
|
|
534
|
+
const hit = (s) => !f || String(s).toLowerCase().includes(f);
|
|
535
|
+
|
|
536
|
+
if (effLens === 'folders') {
|
|
537
|
+
const roots = (lib.roots || []).map((r) => treeNode(r, true, f)).join('');
|
|
538
|
+
host.innerHTML = roots || `<div class="empty">${f ? 'No document matches that filter.' : 'No documents found in this repo.'}</div>`;
|
|
539
|
+
wireDocs(host);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// initiative lens
|
|
544
|
+
const inits = (lib.initiatives || []).filter((g) =>
|
|
545
|
+
hit(g.id) || hit(g.title || '') || g.artifacts.some((a) => hit(a.name)));
|
|
546
|
+
const initHtml = inits.map((g) => {
|
|
547
|
+
const arts = g.artifacts.filter((a) => !f || hit(g.id) || hit(g.title || '') || hit(a.name));
|
|
548
|
+
return `<details class="init"${f ? ' open' : ''}>
|
|
549
|
+
<summary><span class="iid">${esc(g.id)}</span><span class="n">${arts.length}</span>
|
|
550
|
+
${g.title ? `<span class="ititle">${esc(g.title)}</span>` : ''}</summary>
|
|
551
|
+
${arts.map((a) => `<button class="doc art" data-path="${esc(a.path)}" title="${esc(a.path)}">
|
|
552
|
+
<span class="role" data-role="${esc(a.role)}">${esc(a.role)}</span>
|
|
553
|
+
<span class="dn">${esc(a.name)}</span></button>`).join('')}
|
|
554
|
+
</details>`;
|
|
555
|
+
}).join('');
|
|
556
|
+
|
|
557
|
+
const section = (title, body, open) => body
|
|
558
|
+
? `<details class="tree"${open ? ' open' : ''}><summary>${esc(title)}</summary>${body}</details>` : '';
|
|
559
|
+
const ungrouped = (lib.ungrouped || []).filter((a) => hit(a.path));
|
|
560
|
+
const gov = (lib.governance || []).filter((d) => hit(d.name));
|
|
561
|
+
|
|
562
|
+
host.innerHTML = [
|
|
563
|
+
initHtml ? `<div class="grp">initiatives</div>${initHtml}` : '',
|
|
564
|
+
// never silently dropped (R5) — planning docs that declare no initiative are named here
|
|
565
|
+
section(`other planning docs (${ungrouped.length})`,
|
|
566
|
+
ungrouped.map((a) => `<button class="doc" data-path="${esc(a.path)}" title="${esc(a.path)}">${esc(a.path.replace(/^planning\//, ''))}</button>`).join('')),
|
|
567
|
+
section('knowledge — okf · skills',
|
|
568
|
+
(lib.knowledge || []).map((r) => treeNode(r, false, f)).join('')),
|
|
569
|
+
section('governance',
|
|
570
|
+
gov.map((d) => `<button class="doc" data-path="${esc(d.path)}">${esc(d.name)}</button>`).join(''), true),
|
|
571
|
+
].filter(Boolean).join('')
|
|
572
|
+
|| `<div class="empty">${f ? 'No initiative or document matches that filter.' : 'No planning documents in this repo.'}</div>`;
|
|
573
|
+
wireDocs(host);
|
|
574
|
+
}
|
|
575
|
+
function treeNode(node, open, f) {
|
|
576
|
+
const dirs = (node.dirs || []).map((d) => treeNode(d, false, f)).join('');
|
|
577
|
+
// Every file is SHOWN so the map stays complete (R8). What the reader can PREVIEW — markdown
|
|
578
|
+
// (incl. .skill), text/config source, HTML, images — opens; a truly non-previewable file (a
|
|
579
|
+
// binary) stays inert with the editor affordance. `kind` is the server's single classifier.
|
|
580
|
+
const docs = (node.docs || []).filter((d) => !f || d.path.toLowerCase().includes(f))
|
|
581
|
+
.map((d) => {
|
|
582
|
+
const k = d.kind || (d.md === false ? 'inert' : 'markdown');
|
|
583
|
+
return k === 'inert'
|
|
584
|
+
? `<span class="file" title="${esc(d.path)} — open in your editor (no in-app preview for this file type)">${esc(d.name)}</span>`
|
|
585
|
+
: `<button class="doc" data-path="${esc(d.path)}" data-kind="${esc(k)}" title="${esc(d.path)}">${esc(d.name)}</button>`;
|
|
586
|
+
}).join('');
|
|
587
|
+
if (node.path === '' && node.name === '(root)') return docs; // loose root files, no folder
|
|
588
|
+
if (f && !dirs && !docs) return ''; // filtered-out branch
|
|
589
|
+
return `<details class="tree"${open || f ? ' open' : ''}><summary>${esc(node.name)}/</summary>${dirs}${docs}</details>`;
|
|
590
|
+
}
|
|
591
|
+
function wireDocs(scope) {
|
|
592
|
+
scope.querySelectorAll('.doc').forEach((b) => {
|
|
593
|
+
b.onclick = () => openDoc(b.dataset.path, b.dataset.kind);
|
|
594
|
+
b.classList.toggle('on', b.dataset.path === state.docPath);
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ── the document reader — bottom of the right rail, under the library ──
|
|
599
|
+
// Renders by KIND: markdown (incl. .skill) through the parser, text/config as escaped source,
|
|
600
|
+
// HTML as source with an opt-in sandboxed render, images straight from the guarded /api/asset.
|
|
601
|
+
// `kind` comes from the tree button; it is only REQUIRED for images (which never hit /api/doc) —
|
|
602
|
+
// for everything else the server's X-Doc-Kind header is authoritative, so a link with no kind works.
|
|
603
|
+
async function openDoc(pth, kind) {
|
|
604
|
+
if (!pth) return;
|
|
605
|
+
state.docPath = pth;
|
|
606
|
+
showRail();
|
|
607
|
+
$('paneDoc').hidden = false;
|
|
608
|
+
$('railSplit').hidden = false;
|
|
609
|
+
foldPane('doc', true);
|
|
610
|
+
$('crumb').innerHTML = `<bdi>${esc(pth)}</bdi>`;
|
|
611
|
+
$('docBody').scrollTop = 0;
|
|
612
|
+
const body = $('docBody');
|
|
613
|
+
// Images bypass /api/doc entirely — served as bytes from the guarded, MIME-allowlisted endpoint.
|
|
614
|
+
if (kind === 'image') {
|
|
615
|
+
const url = `/api/asset?ws=${encodeURIComponent(state.wsId || '')}&path=${encodeURIComponent(pth)}`;
|
|
616
|
+
body.innerHTML = `<div class="img-view"><img class="doc-img" src="${url}" alt="${esc(pth)}" /></div>`;
|
|
617
|
+
wireImgFallback(body); markActiveDoc(pth); refit(); return;
|
|
618
|
+
}
|
|
619
|
+
try {
|
|
620
|
+
const res = await fetch(`/api/doc?ws=${encodeURIComponent(state.wsId || '')}&path=${encodeURIComponent(pth)}`);
|
|
621
|
+
if (!res.ok) { body.innerHTML = `<p class="doc-empty">Could not read <code>${esc(pth)}</code>.</p>`; markActiveDoc(pth); refit(); return; }
|
|
622
|
+
const k = res.headers.get('X-Doc-Kind') || 'markdown';
|
|
623
|
+
const text = await res.text();
|
|
624
|
+
if (k === 'html') renderHtmlDoc(body, text);
|
|
625
|
+
else if (k === 'text') body.innerHTML = `<pre class="doc-src">${esc(text)}</pre>`;
|
|
626
|
+
else body.innerHTML = renderMd(text, pth);
|
|
627
|
+
} catch { body.innerHTML = `<p class="doc-empty">Read failed.</p>`; }
|
|
628
|
+
// a relative .md link inside a document opens that document here (still server-guarded)
|
|
629
|
+
body.querySelectorAll('[data-doc]').forEach((a) => a.onclick = (e) => { e.preventDefault(); openDoc(a.dataset.doc); });
|
|
630
|
+
wireImgFallback(body);
|
|
631
|
+
markActiveDoc(pth);
|
|
632
|
+
refit();
|
|
633
|
+
}
|
|
634
|
+
function markActiveDoc(pth) {
|
|
635
|
+
document.querySelectorAll('.doc').forEach((b) => b.classList.toggle('on', b.dataset.path === pth));
|
|
636
|
+
}
|
|
637
|
+
// a repo-relative image that 404s (missing/renamed) degrades to its alt text, never a broken icon
|
|
638
|
+
function wireImgFallback(scope) {
|
|
639
|
+
scope.querySelectorAll('img.doc-img').forEach((img) => {
|
|
640
|
+
img.onerror = () => { const em = document.createElement('em'); em.textContent = img.getAttribute('alt') || 'image'; img.replaceWith(em); };
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
// HTML preview (R5): escaped SOURCE by default; an opt-in RENDER in a fully sandboxed iframe —
|
|
644
|
+
// scripts + same-origin denied (empty sandbox) AND a restrictive CSP that blocks all network, so a
|
|
645
|
+
// poisoned explainer can neither script nor beacon out (holds R4 even for rendered HTML). Never auto.
|
|
646
|
+
function renderHtmlDoc(body, text) {
|
|
647
|
+
body.innerHTML =
|
|
648
|
+
`<div class="html-view">`
|
|
649
|
+
+ `<div class="html-bar" role="tablist">`
|
|
650
|
+
+ `<button class="html-tab on" data-mode="src" role="tab" aria-selected="true">source</button>`
|
|
651
|
+
+ `<button class="html-tab" data-mode="render" role="tab" aria-selected="false">render <span class="sbx">sandboxed</span></button>`
|
|
652
|
+
+ `</div>`
|
|
653
|
+
+ `<pre class="doc-src html-pane" data-pane="src"></pre>`
|
|
654
|
+
+ `<div class="html-pane" data-pane="render" hidden></div>`
|
|
655
|
+
+ `</div>`;
|
|
656
|
+
body.querySelector('[data-pane="src"]').textContent = text; // textContent, never innerHTML — no live markup
|
|
657
|
+
const srcPane = body.querySelector('[data-pane="src"]');
|
|
658
|
+
const rndPane = body.querySelector('[data-pane="render"]');
|
|
659
|
+
const tabs = body.querySelectorAll('.html-tab');
|
|
660
|
+
const activate = (mode) => {
|
|
661
|
+
tabs.forEach((t) => { const on = t.dataset.mode === mode; t.classList.toggle('on', on); t.setAttribute('aria-selected', String(on)); });
|
|
662
|
+
if (mode === 'render' && !rndPane.firstChild) {
|
|
663
|
+
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:">`;
|
|
664
|
+
const ifr = document.createElement('iframe');
|
|
665
|
+
ifr.className = 'html-frame';
|
|
666
|
+
ifr.setAttribute('sandbox', ''); // no allow-scripts, no allow-same-origin
|
|
667
|
+
ifr.setAttribute('referrerpolicy', 'no-referrer');
|
|
668
|
+
ifr.srcdoc = csp + text;
|
|
669
|
+
rndPane.appendChild(ifr);
|
|
670
|
+
}
|
|
671
|
+
srcPane.hidden = mode !== 'src';
|
|
672
|
+
rndPane.hidden = mode !== 'render';
|
|
673
|
+
};
|
|
674
|
+
tabs.forEach((t) => t.onclick = () => activate(t.dataset.mode));
|
|
675
|
+
}
|
|
676
|
+
function closeDoc() {
|
|
677
|
+
state.docPath = null; state.surface = null;
|
|
678
|
+
$('paneDoc').hidden = true;
|
|
679
|
+
$('railSplit').hidden = true;
|
|
680
|
+
$('docBody').innerHTML = '';
|
|
681
|
+
$('crumb').textContent = '';
|
|
682
|
+
document.querySelectorAll('.doc').forEach((b) => b.classList.remove('on'));
|
|
683
|
+
refit();
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// ── rail + pane folding (R12), all persisted ──
|
|
687
|
+
function foldPane(which, open) {
|
|
688
|
+
const pane = which === 'doc' ? $('paneDoc') : $('paneLib');
|
|
689
|
+
const btn = which === 'doc' ? $('docFold') : $('libFold');
|
|
690
|
+
pane.dataset.fold = open ? 'on' : 'off';
|
|
691
|
+
btn.setAttribute('aria-expanded', String(open));
|
|
692
|
+
store.set('fold-' + which, open ? 'on' : 'off');
|
|
693
|
+
refit();
|
|
694
|
+
}
|
|
695
|
+
function showRail() { if ($('bodyGrid').classList.contains('rail-off')) toggleRail(true); }
|
|
696
|
+
function toggleRail(on) {
|
|
697
|
+
const off = on === undefined ? !$('bodyGrid').classList.contains('rail-off') : !on;
|
|
698
|
+
$('bodyGrid').classList.toggle('rail-off', off);
|
|
699
|
+
$('railBtn').setAttribute('aria-pressed', String(off));
|
|
700
|
+
store.set('rail', off ? 'off' : 'on');
|
|
701
|
+
refit();
|
|
702
|
+
}
|
|
703
|
+
// restore the layout the PM last chose
|
|
704
|
+
foldPane('lib', store.get('fold-lib') !== 'off');
|
|
705
|
+
foldPane('doc', store.get('fold-doc') !== 'off');
|
|
706
|
+
if (store.get('rail') === 'off') toggleRail(false);
|
|
707
|
+
{ const s = store.get('libsplit'); if (s) document.documentElement.style.setProperty('--libsplit', s); }
|
|
708
|
+
{ const w = store.get('railw'); if (w) document.documentElement.style.setProperty('--rail-w', w); }
|
|
709
|
+
|
|
710
|
+
$('libFold').onclick = () => foldPane('lib', $('paneLib').dataset.fold === 'off');
|
|
711
|
+
$('docFold').onclick = () => foldPane('doc', $('paneDoc').dataset.fold === 'off');
|
|
712
|
+
$('railBtn').onclick = () => toggleRail();
|
|
713
|
+
|
|
714
|
+
// drag the rail's left edge — the reader is where PRDs and rubrics are actually read, and a
|
|
715
|
+
// fixed narrow column makes a table of weighted dimensions unreadable
|
|
716
|
+
(() => {
|
|
717
|
+
const grip = $('railGrip');
|
|
718
|
+
let dragging = false;
|
|
719
|
+
const apply = (clientX) => {
|
|
720
|
+
const w = Math.min(Math.max(window.innerWidth - clientX, 280), Math.min(900, window.innerWidth - 420));
|
|
721
|
+
const v = Math.round(w) + 'px';
|
|
722
|
+
document.documentElement.style.setProperty('--rail-w', v);
|
|
723
|
+
store.set('railw', v);
|
|
724
|
+
};
|
|
725
|
+
grip.addEventListener('pointerdown', (e) => { dragging = true; grip.setPointerCapture(e.pointerId); e.preventDefault(); });
|
|
726
|
+
grip.addEventListener('pointermove', (e) => { if (dragging) apply(e.clientX); });
|
|
727
|
+
grip.addEventListener('pointerup', (e) => { dragging = false; try { grip.releasePointerCapture(e.pointerId); } catch {} refit(); });
|
|
728
|
+
grip.addEventListener('keydown', (e) => {
|
|
729
|
+
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
|
730
|
+
e.preventDefault();
|
|
731
|
+
const cur = $('rail').getBoundingClientRect().width;
|
|
732
|
+
apply(window.innerWidth - (cur + (e.key === 'ArrowLeft' ? 24 : -24)));
|
|
733
|
+
refit();
|
|
734
|
+
});
|
|
735
|
+
})();
|
|
736
|
+
|
|
737
|
+
// drag the divider between the library and the document
|
|
738
|
+
(() => {
|
|
739
|
+
const split = $('railSplit');
|
|
740
|
+
let dragging = false;
|
|
741
|
+
const apply = (clientY) => {
|
|
742
|
+
const r = $('rail').getBoundingClientRect();
|
|
743
|
+
const pct = Math.min(80, Math.max(15, ((clientY - r.top) / r.height) * 100));
|
|
744
|
+
const v = pct.toFixed(1) + '%';
|
|
745
|
+
document.documentElement.style.setProperty('--libsplit', v);
|
|
746
|
+
store.set('libsplit', v);
|
|
747
|
+
};
|
|
748
|
+
split.addEventListener('pointerdown', (e) => { dragging = true; split.setPointerCapture(e.pointerId); e.preventDefault(); });
|
|
749
|
+
split.addEventListener('pointermove', (e) => { if (dragging) apply(e.clientY); });
|
|
750
|
+
split.addEventListener('pointerup', (e) => { dragging = false; try { split.releasePointerCapture(e.pointerId); } catch {} refit(); });
|
|
751
|
+
split.addEventListener('keydown', (e) => {
|
|
752
|
+
const cur = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--libsplit')) || 46;
|
|
753
|
+
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
|
754
|
+
e.preventDefault();
|
|
755
|
+
const v = Math.min(80, Math.max(15, cur + (e.key === 'ArrowUp' ? -4 : 4))).toFixed(1) + '%';
|
|
756
|
+
document.documentElement.style.setProperty('--libsplit', v); store.set('libsplit', v); refit();
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
})();
|
|
760
|
+
|
|
761
|
+
// ── needs-you + gates (the PMOS workflow: Review/Acceptance in file mode, never automatic) ──
|
|
762
|
+
async function loadNeeds() {
|
|
763
|
+
try { const r = await fetch('/api/needs?ws=' + encodeURIComponent(state.wsId || '')); state.needs = (await r.json()).needs || []; }
|
|
764
|
+
catch { state.needs = []; }
|
|
765
|
+
renderNeeds(); renderSessions();
|
|
766
|
+
}
|
|
767
|
+
function renderNeeds() {
|
|
768
|
+
const n = state.needs[0];
|
|
769
|
+
const box = $('needs');
|
|
770
|
+
if (!n) { box.style.display = 'none'; refit(); return; }
|
|
771
|
+
if (n.kind === 'report') {
|
|
772
|
+
// a scheduled run finished — reach the PM here, openable in the reader (R5)
|
|
773
|
+
box.className = 'needs report'; box.style.display = 'block';
|
|
774
|
+
box.innerHTML = `<span class="k">report ready</span><div class="q">${esc(n.question)}</div>
|
|
775
|
+
<div class="row">${n.deliverable ? `<button class="gbtn primary" data-deliv="${esc(n.deliverable)}">▸ open report</button>` : ''}
|
|
776
|
+
<button class="gbtn" data-gate="seen">dismiss</button></div>`;
|
|
777
|
+
box.querySelector('[data-deliv]')?.addEventListener('click', (e) => openDeliverable(e.currentTarget.dataset.deliv));
|
|
778
|
+
box.querySelectorAll('[data-gate]').forEach((b) => b.onclick = () => gate(n, b.dataset.gate));
|
|
779
|
+
refit(); return;
|
|
780
|
+
}
|
|
781
|
+
if (n.kind === 'outcome') {
|
|
782
|
+
// A D57 outcome check past its horizon. There is deliberately NO button here that would
|
|
783
|
+
// close it: disposal is validated|refuted|inconclusive with evidence, recorded by the PM
|
|
784
|
+
// through record_outcome_check — auto-disposal is prohibited, and a button that looked
|
|
785
|
+
// like disposal but only wrote an acceptance would be exactly the fake green D57 forbids.
|
|
786
|
+
box.className = 'needs accept'; box.style.display = 'block';
|
|
787
|
+
box.innerHTML = `<span class="k">outcome check due</span><div class="q">${esc(n.question)}</div>
|
|
788
|
+
<div class="row"><span class="wpath">dispose it with record_outcome_check
|
|
789
|
+
(validated | refuted | inconclusive, with evidence) — it stays here until you do</span></div>`;
|
|
790
|
+
refit(); return;
|
|
791
|
+
}
|
|
792
|
+
const accept = n.kind === 'accept';
|
|
793
|
+
box.className = 'needs' + (accept ? ' accept' : '');
|
|
794
|
+
box.style.display = 'block';
|
|
795
|
+
box.innerHTML = `
|
|
796
|
+
<span class="k">${accept ? 'needs your acceptance' : 'needs you'}</span>
|
|
797
|
+
<div class="q">${esc(n.question)}</div>
|
|
798
|
+
<div class="row"><span>${accept ? 'evidence' : 'blocked on'} →</span>
|
|
799
|
+
${n.doc ? `<button class="dlink" data-doc="${esc(n.doc)}">${esc(n.doc)}</button>` : ''}
|
|
800
|
+
<button class="gbtn primary" data-gate="${accept ? 'accept' : 'approve'}">✓ ${accept ? 'accept' : 'approve'}</button>
|
|
801
|
+
<button class="gbtn" data-gate="return">↩ return with note</button>
|
|
802
|
+
<span class="wpath">${accept
|
|
803
|
+
? 'accept → records the Acceptance into this repo’s own acceptances.jsonl — your verdict, never automatic'
|
|
804
|
+
: 'approve/return → records a correction into this repo’s own acceptances.jsonl — attributable to you'}</span>
|
|
805
|
+
</div>`;
|
|
806
|
+
box.querySelector('[data-doc]')?.addEventListener('click', (e) => openDoc(e.target.dataset.doc));
|
|
807
|
+
box.querySelectorAll('[data-gate]').forEach((b) => b.onclick = () => gate(n, b.dataset.gate));
|
|
808
|
+
refit();
|
|
809
|
+
}
|
|
810
|
+
async function gate(n, action) {
|
|
811
|
+
let note = '';
|
|
812
|
+
if (action === 'return') { note = prompt('Return with a note (what needs correcting):') || ''; if (note === '') return; }
|
|
813
|
+
try {
|
|
814
|
+
await fetch('/api/gate', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
815
|
+
body: JSON.stringify({ action, run_id: n.run_id, initiative_id: n.initiative_id, note, ws: state.wsId }) });
|
|
816
|
+
} catch {}
|
|
817
|
+
loadNeeds(); // needs.json is updated server-side; re-pull
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// ── markdown: a real block parser (no deps). ──────────────────────────────────────────
|
|
821
|
+
// Escaping happens at the LEAF — every piece of file text passes through esc() before it is
|
|
822
|
+
// put into HTML, and nothing else ever does. That keeps the old renderer's one good property
|
|
823
|
+
// while fixing what it could not do: soft-wrapped paragraphs, ordered/nested/task lists,
|
|
824
|
+
// tables (rubrics ARE tables), quotes, rules, and the identity block PMOS artifacts open with.
|
|
825
|
+
function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
|
|
826
|
+
|
|
827
|
+
const RE_ITEM = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/;
|
|
828
|
+
const RE_HR = /^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/;
|
|
829
|
+
|
|
830
|
+
function renderMd(src, docPath) {
|
|
831
|
+
const lines = String(src == null ? '' : src).replace(/\r\n?/g, '\n').split('\n');
|
|
832
|
+
const ctx = { dir: (docPath || '').split('/').slice(0, -1) };
|
|
833
|
+
let out = '', i = 0, blocks = 0, lastHead = '';
|
|
834
|
+
|
|
835
|
+
// YAML frontmatter → identity card
|
|
836
|
+
if (lines[0] !== undefined && lines[0].trim() === '---') {
|
|
837
|
+
let j = 1;
|
|
838
|
+
while (j < lines.length && lines[j].trim() !== '---') j++;
|
|
839
|
+
if (j < lines.length) { out += metaCard(lines.slice(1, j)); i = j + 1; blocks++; }
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
while (i < lines.length) {
|
|
843
|
+
const ln = lines[i];
|
|
844
|
+
if (!ln.trim()) { i++; continue; }
|
|
845
|
+
|
|
846
|
+
// fenced code
|
|
847
|
+
const fence = ln.match(/^\s{0,3}(`{3,}|~{3,})\s*([^\s`]*)/);
|
|
848
|
+
if (fence) {
|
|
849
|
+
const close = new RegExp('^\\s{0,3}' + fence[1][0] + '{' + fence[1].length + ',}\\s*$');
|
|
850
|
+
const body = [];
|
|
851
|
+
i++;
|
|
852
|
+
while (i < lines.length && !close.test(lines[i])) body.push(lines[i++]);
|
|
853
|
+
i++; // consume the closing fence (or hit EOF)
|
|
854
|
+
out += `<pre>${fence[2] ? `<span class="lang">${esc(fence[2])}</span>` : ''}` +
|
|
855
|
+
`<code>${esc(body.join('\n'))}</code></pre>`;
|
|
856
|
+
blocks++; continue;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const h = ln.match(/^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/);
|
|
860
|
+
if (h) {
|
|
861
|
+
const n = h[1].length;
|
|
862
|
+
out += `<h${n} id="${slug(h[2])}">${inline(h[2], ctx)}</h${n}>`;
|
|
863
|
+
lastHead = h[2].trim(); i++; blocks++; continue;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
if (RE_HR.test(ln)) { out += '<hr />'; i++; blocks++; continue; }
|
|
867
|
+
|
|
868
|
+
if (/^\s{0,3}>/.test(ln)) {
|
|
869
|
+
const body = [];
|
|
870
|
+
while (i < lines.length && (/^\s{0,3}>/.test(lines[i]) || (body.length && lines[i].trim()))) {
|
|
871
|
+
body.push(lines[i].replace(/^\s{0,3}>\s?/, '')); i++;
|
|
872
|
+
}
|
|
873
|
+
out += `<blockquote>${renderMd(body.join('\n'), docPath)}</blockquote>`;
|
|
874
|
+
blocks++; continue;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// GFM table: a header row followed by a delimiter row
|
|
878
|
+
if (ln.includes('|') && /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(lines[i + 1] || '') && (lines[i + 1] || '').includes('-')) {
|
|
879
|
+
const head = cells(ln);
|
|
880
|
+
const align = cells(lines[i + 1]).map((c) => (/^:.*:$/.test(c) ? 'center' : /:$/.test(c) ? 'right' : /^:/.test(c) ? 'left' : ''));
|
|
881
|
+
i += 2;
|
|
882
|
+
const rows = [];
|
|
883
|
+
while (i < lines.length && lines[i].includes('|') && lines[i].trim()) rows.push(cells(lines[i++]));
|
|
884
|
+
const th = head.map((c, n) => `<th${align[n] ? ` style="text-align:${align[n]}"` : ''}>${inline(c, ctx)}</th>`).join('');
|
|
885
|
+
const tb = rows.map((r) => `<tr>${head.map((_, n) =>
|
|
886
|
+
`<td${align[n] ? ` style="text-align:${align[n]}"` : ''}>${inline(r[n] || '', ctx)}</td>`).join('')}</tr>`).join('');
|
|
887
|
+
out += `<div class="tablewrap"><table><thead><tr>${th}</tr></thead><tbody>${tb}</tbody></table></div>`;
|
|
888
|
+
blocks++; continue;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
if (RE_ITEM.test(ln)) {
|
|
892
|
+
const { items, next } = listBlock(lines, i);
|
|
893
|
+
i = next;
|
|
894
|
+
// PMOS artifacts open with an identity block written as `- key: value` bullets, not YAML —
|
|
895
|
+
// either straight after the H1 or under an `## Identity` heading. That is metadata: show
|
|
896
|
+
// it as a card, not as a bullet list.
|
|
897
|
+
const meta = (blocks <= 1 || /^identity$/i.test(lastHead)) && isMetaList(items);
|
|
898
|
+
out += meta ? metaCard(items.map((it) => it.lines.join(' '))) : renderLists(items, 0, ctx).html;
|
|
899
|
+
lastHead = ''; blocks++; continue;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// paragraph — consecutive lines are ONE paragraph (this is the soft-wrap fix)
|
|
903
|
+
const para = [];
|
|
904
|
+
while (i < lines.length && lines[i].trim() && !RE_ITEM.test(lines[i]) && !RE_HR.test(lines[i]) &&
|
|
905
|
+
!/^\s{0,3}>/.test(lines[i]) && !/^\s{0,3}(#{1,6})\s/.test(lines[i]) && !/^\s{0,3}(`{3,}|~{3,})/.test(lines[i])) {
|
|
906
|
+
para.push(lines[i].trim()); i++;
|
|
907
|
+
}
|
|
908
|
+
if (para.length) { out += `<p>${inline(para.join(' '), ctx)}</p>`; blocks++; }
|
|
909
|
+
else i++; // nothing consumed — never spin
|
|
910
|
+
}
|
|
911
|
+
return out;
|
|
912
|
+
}
|
|
913
|
+
function cells(row) {
|
|
914
|
+
return String(row).trim().replace(/^\|/, '').replace(/\|$/, '').split(/(?<!\\)\|/).map((c) => c.trim());
|
|
915
|
+
}
|
|
916
|
+
function slug(s) { return String(s).toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '').slice(0, 60); }
|
|
917
|
+
|
|
918
|
+
// collect one list block (items + their wrapped continuation lines), preserving indent
|
|
919
|
+
function listBlock(lines, start) {
|
|
920
|
+
const items = [];
|
|
921
|
+
let i = start;
|
|
922
|
+
while (i < lines.length) {
|
|
923
|
+
const ln = lines[i];
|
|
924
|
+
const m = ln.match(RE_ITEM);
|
|
925
|
+
if (m) {
|
|
926
|
+
items.push({ indent: m[1].replace(/\t/g, ' ').length, ordered: /\d/.test(m[2]), lines: [m[3]] });
|
|
927
|
+
i++; continue;
|
|
928
|
+
}
|
|
929
|
+
if (!ln.trim()) {
|
|
930
|
+
const nx = lines[i + 1] || '';
|
|
931
|
+
if (RE_ITEM.test(nx) || /^\s{2,}\S/.test(nx)) { i++; continue; } // loose list
|
|
932
|
+
break;
|
|
933
|
+
}
|
|
934
|
+
if (items.length && /^\s+\S/.test(ln)) { items[items.length - 1].lines.push(ln.trim()); i++; continue; }
|
|
935
|
+
break;
|
|
936
|
+
}
|
|
937
|
+
return { items, next: i };
|
|
938
|
+
}
|
|
939
|
+
// A run of items at one indent is one list; a change of marker type (bullet ↔ number, or the
|
|
940
|
+
// first `- [ ]`) starts a sibling list, the way every markdown renderer does it.
|
|
941
|
+
function renderLists(items, start, ctx) {
|
|
942
|
+
const base = items[start].indent;
|
|
943
|
+
let html = '', i = start;
|
|
944
|
+
while (i < items.length && items[i].indent >= base) {
|
|
945
|
+
const r = renderList(items, i, ctx);
|
|
946
|
+
html += r.html;
|
|
947
|
+
if (r.next === i) break; // never spin
|
|
948
|
+
i = r.next;
|
|
949
|
+
}
|
|
950
|
+
return { html, next: i };
|
|
951
|
+
}
|
|
952
|
+
function renderList(items, start, ctx) {
|
|
953
|
+
const base = items[start].indent;
|
|
954
|
+
const ordered = items[start].ordered;
|
|
955
|
+
const task = /^\[[ xX]\]\s/.test(items[start].lines[0]);
|
|
956
|
+
const lis = [];
|
|
957
|
+
let i = start;
|
|
958
|
+
while (i < items.length && items[i].indent >= base) {
|
|
959
|
+
if (items[i].indent === base &&
|
|
960
|
+
(items[i].ordered !== ordered || /^\[[ xX]\]\s/.test(items[i].lines[0]) !== task)) break;
|
|
961
|
+
if (items[i].indent > base) {
|
|
962
|
+
const sub = renderLists(items, i, ctx);
|
|
963
|
+
if (lis.length) lis[lis.length - 1] = lis[lis.length - 1].replace(/<\/li>$/, sub.html + '</li>');
|
|
964
|
+
else lis.push(`<li>${sub.html}</li>`);
|
|
965
|
+
i = sub.next; continue;
|
|
966
|
+
}
|
|
967
|
+
let body = items[i].lines.join(' ');
|
|
968
|
+
const t = body.match(/^\[([ xX])\]\s+(.*)$/);
|
|
969
|
+
if (t) lis.push(`<li><span class="box${t[1] === ' ' ? '' : ' done'}" aria-hidden="true">${t[1] === ' ' ? '☐' : '☑'}</span><span>${inline(t[2], ctx)}</span></li>`);
|
|
970
|
+
else lis.push(`<li>${inline(body, ctx)}</li>`);
|
|
971
|
+
i++;
|
|
972
|
+
}
|
|
973
|
+
const tag = ordered ? 'ol' : 'ul';
|
|
974
|
+
return { html: `<${tag}${task ? ' class="task"' : ''}>${lis.join('')}</${tag}>`, next: i };
|
|
975
|
+
}
|
|
976
|
+
// the identity block: `key: value` lines, all at the same level, at least three of them
|
|
977
|
+
function isMetaList(items) {
|
|
978
|
+
return items.length >= 3 && items.every((it) => it.indent === items[0].indent &&
|
|
979
|
+
/^[A-Za-z][\w .-]{0,30}:\s*\S/.test(it.lines.join(' ')));
|
|
980
|
+
}
|
|
981
|
+
function metaCard(rawLines) {
|
|
982
|
+
const rows = [];
|
|
983
|
+
for (const raw of rawLines) {
|
|
984
|
+
const m = String(raw).trim().match(/^-?\s*([A-Za-z][\w .-]{0,30}):\s*(.*)$/);
|
|
985
|
+
if (!m) continue;
|
|
986
|
+
rows.push(`<dt>${esc(m[1])}</dt><dd>${inline(m[2].replace(/^["']|["']$/g, ''), { dir: [] })}</dd>`);
|
|
987
|
+
}
|
|
988
|
+
return rows.length ? `<dl class="fm">${rows.join('')}</dl>` : '';
|
|
989
|
+
}
|
|
990
|
+
// Code spans are lifted out behind a sentinel first, so the emphasis rules never chew
|
|
991
|
+
// through a path like `a_b_c`. SENT is a control char no document text can contain.
|
|
992
|
+
const SENT = '\u0001';
|
|
993
|
+
function inline(s, ctx) {
|
|
994
|
+
const codes = [];
|
|
995
|
+
let t = esc(s).replace(/`([^`]+)`/g, (m, c) => { codes.push(c); return SENT + (codes.length - 1) + SENT; });
|
|
996
|
+
t = t.replace(/!\[([^\]]*)\]\(([^)\s]*)[^)]*\)/g, (m, alt, src) => mdImage(alt, src, ctx));
|
|
997
|
+
t = t.replace(/\[([^\]]+)\]\(([^)\s]+)[^)]*\)/g, (m, txt, url) => mdLink(txt, url, ctx));
|
|
998
|
+
t = t.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
999
|
+
t = t.replace(/(^|[^\w*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
|
|
1000
|
+
t = t.replace(/(^|[^\w_])_([^_\n]+)_(?!\w)/g, '$1<em>$2</em>');
|
|
1001
|
+
t = t.replace(/~~([^~]+)~~/g, '<del>$1</del>');
|
|
1002
|
+
return t.replace(/\u0001(\d+)\u0001/g, (m, n) => `<code>${codes[n]}</code>`);
|
|
1003
|
+
}
|
|
1004
|
+
function mdLink(txt, url, ctx) {
|
|
1005
|
+
const u = String(url).trim();
|
|
1006
|
+
if (/^(https?:)?\/\//i.test(u) || /^mailto:/i.test(u)) return `<a href="${u}" target="_blank" rel="noopener">${txt}</a>`;
|
|
1007
|
+
if (/\.md(#[^)]*)?$/i.test(u)) return `<a href="#" data-doc="${esc(resolveDocPath(ctx, u))}">${txt}</a>`;
|
|
1008
|
+
return txt; // a relative non-document target is not clickable here
|
|
1009
|
+
}
|
|
1010
|
+
// A markdown image. Repo-relative → inline via the guarded /api/asset (R3). Remote / data: / any
|
|
1011
|
+
// scheme → NEVER fetched; stays alt-text (R4, the no-beacon invariant). alt/src arrive already
|
|
1012
|
+
// HTML-escaped from inline(), so they are safe to interpolate as-is.
|
|
1013
|
+
function mdImage(alt, src, ctx) {
|
|
1014
|
+
const label = alt || 'image';
|
|
1015
|
+
const repoRel = window.PreviewKind ? window.PreviewKind.isRepoRelativeSrc(src) : false;
|
|
1016
|
+
if (!repoRel) return `<em>${label}</em>`;
|
|
1017
|
+
const rel = resolveDocPath(ctx, src);
|
|
1018
|
+
if (!rel) return `<em>${label}</em>`;
|
|
1019
|
+
const url = `/api/asset?ws=${encodeURIComponent(state.wsId || '')}&path=${encodeURIComponent(rel)}`;
|
|
1020
|
+
return `<img class="doc-img" src="${url}" alt="${label}" loading="lazy" />`;
|
|
1021
|
+
}
|
|
1022
|
+
// bundle-relative (`/planning/…`, the OKF convention) or relative to the open document
|
|
1023
|
+
function resolveDocPath(ctx, u) {
|
|
1024
|
+
const clean = String(u).split('#')[0];
|
|
1025
|
+
const stack = clean.startsWith('/') ? [] : [...((ctx && ctx.dir) || [])];
|
|
1026
|
+
for (const p of clean.split('/')) {
|
|
1027
|
+
if (!p || p === '.') continue;
|
|
1028
|
+
if (p === '..') stack.pop(); else stack.push(p);
|
|
1029
|
+
}
|
|
1030
|
+
return stack.join('/');
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// ── new session: name it before it starts ──
|
|
1034
|
+
function openNewForm() {
|
|
1035
|
+
const w = state.workspaces.find((x) => x.id === state.wsId);
|
|
1036
|
+
$('newForm').hidden = false;
|
|
1037
|
+
$('newBtn').setAttribute('aria-expanded', 'true');
|
|
1038
|
+
// pre-filled but editable — a default that reads better than "shell" in a list of shells
|
|
1039
|
+
const n = mySessions().length + 1;
|
|
1040
|
+
$('newName').value = `${w ? w.name : 'session'} ${n}`;
|
|
1041
|
+
const inp = $('newName');
|
|
1042
|
+
setTimeout(() => { inp.focus(); inp.select(); }, 0);
|
|
1043
|
+
}
|
|
1044
|
+
function closeNewForm() {
|
|
1045
|
+
$('newForm').hidden = true;
|
|
1046
|
+
$('newBtn').setAttribute('aria-expanded', 'false');
|
|
1047
|
+
}
|
|
1048
|
+
$('newBtn').onclick = () => ($('newForm').hidden ? openNewForm() : closeNewForm());
|
|
1049
|
+
$('newCancel').onclick = closeNewForm;
|
|
1050
|
+
$('newAgents').querySelectorAll('button').forEach((b) => b.onclick = () => {
|
|
1051
|
+
state.newAgent = b.dataset.agent;
|
|
1052
|
+
$('newAgents').querySelectorAll('button').forEach((x) => x.setAttribute('aria-checked', String(x === b)));
|
|
1053
|
+
});
|
|
1054
|
+
$('newForm').addEventListener('submit', (e) => {
|
|
1055
|
+
e.preventDefault();
|
|
1056
|
+
const name = $('newName').value.trim();
|
|
1057
|
+
send({ t: 'new', agent: state.newAgent, name: name || state.newAgent, ws: state.wsId });
|
|
1058
|
+
closeNewForm();
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
// ── add a workspace: pick a folder (the GUI twin of running `pmos` in that repo) ──
|
|
1062
|
+
function closeAddWs() {
|
|
1063
|
+
$('addWsForm').hidden = true; $('addWsBtn').setAttribute('aria-expanded', 'false'); $('addWsPath').value = '';
|
|
1064
|
+
}
|
|
1065
|
+
$('addWsBtn').onclick = () => {
|
|
1066
|
+
const f = $('addWsForm');
|
|
1067
|
+
f.hidden = !f.hidden;
|
|
1068
|
+
$('addWsBtn').setAttribute('aria-expanded', String(!f.hidden));
|
|
1069
|
+
};
|
|
1070
|
+
$('addWsCancel').onclick = closeAddWs;
|
|
1071
|
+
$('addWsForm').addEventListener('submit', (e) => { e.preventDefault(); addWorkspace($('addWsPath').value); });
|
|
1072
|
+
// The browser's own directory picker cannot yield an absolute path, so the LOCAL server opens
|
|
1073
|
+
// the machine's native chooser. Where there is none (a headless Linux box), the in-app browser
|
|
1074
|
+
// stands in — the PM is never left with only a text field (R10).
|
|
1075
|
+
$('pickFolderBtn').onclick = async () => {
|
|
1076
|
+
const btn = $('pickFolderBtn'), label = btn.textContent;
|
|
1077
|
+
btn.disabled = true; btn.textContent = 'choosing…';
|
|
1078
|
+
let j = null, status = 0;
|
|
1079
|
+
try {
|
|
1080
|
+
const r = await fetch('/api/pick-folder', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1081
|
+
body: JSON.stringify({ start: currentRoot() }) });
|
|
1082
|
+
status = r.status;
|
|
1083
|
+
j = await r.json();
|
|
1084
|
+
} catch {}
|
|
1085
|
+
btn.disabled = false; btn.textContent = label;
|
|
1086
|
+
if (j && j.ok && j.root) { addWorkspace(j.root); return; }
|
|
1087
|
+
if (j && j.cancelled) return;
|
|
1088
|
+
if (j && j.busy) { flash('a folder chooser is already open'); return; }
|
|
1089
|
+
// 404 = an OLDER server without these routes is still running — the browser reload alone
|
|
1090
|
+
// didn't pick up the new code. Say so, instead of falling into a folder browser that 404s too.
|
|
1091
|
+
if (status === 404) { flash('restart the server to enable the folder picker: pmos stop && pmos'); return; }
|
|
1092
|
+
openFsPicker(currentRoot()); // no native chooser on this system, or the call failed
|
|
1093
|
+
};
|
|
1094
|
+
function currentRoot() {
|
|
1095
|
+
const w = state.workspaces.find((x) => x.id === state.wsId);
|
|
1096
|
+
return w ? w.root : '';
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// ── in-app folder browser (the fallback chooser) ──
|
|
1100
|
+
async function openFsPicker(start) {
|
|
1101
|
+
$('fsPicker').style.display = 'grid';
|
|
1102
|
+
await loadFs(start);
|
|
1103
|
+
}
|
|
1104
|
+
function closeFsPicker() { $('fsPicker').style.display = 'none'; }
|
|
1105
|
+
async function loadFs(dir) {
|
|
1106
|
+
let j = null, status = 0;
|
|
1107
|
+
try { const r = await fetch('/api/fs/list?path=' + encodeURIComponent(dir || '')); status = r.status; j = await r.json(); } catch {}
|
|
1108
|
+
if (!j || !j.ok) {
|
|
1109
|
+
$('fsList').innerHTML = status === 404
|
|
1110
|
+
? `<div class="empty">This folder browser needs the newer server. Restart it: <b>pmos stop && pmos</b>.</div>`
|
|
1111
|
+
: `<div class="empty">Could not read <b>${esc(dir || 'that folder')}</b>.</div>`;
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
state.fsDir = j.path;
|
|
1115
|
+
// the crumb truncates from the LEFT (rtl) — <bdi> keeps the path's own characters in order,
|
|
1116
|
+
// so an absolute path does not come back with its leading slash on the wrong end
|
|
1117
|
+
$('fsCrumb').innerHTML = `<bdi>${esc(j.path)}</bdi>`;
|
|
1118
|
+
$('fsUp').disabled = !j.parent;
|
|
1119
|
+
$('fsList').innerHTML = j.entries.length
|
|
1120
|
+
? j.entries.map((e) => `<button class="pitem" data-dir="${esc(e.path)}">
|
|
1121
|
+
<span class="g" aria-hidden="true">▸</span><span>${esc(e.name)}</span>
|
|
1122
|
+
${e.isRepo ? '<span class="badge">git</span>' : ''}</button>`).join('')
|
|
1123
|
+
: `<div class="empty">${j.denied ? 'Not readable.' : 'No sub-folders here — “use this folder” to add it.'}</div>`;
|
|
1124
|
+
$('fsList').querySelectorAll('[data-dir]').forEach((b) => b.onclick = () => loadFs(b.dataset.dir));
|
|
1125
|
+
}
|
|
1126
|
+
$('fsUp').onclick = () => { const p = state.fsDir && state.fsDir.replace(/[/\\][^/\\]+$/, ''); loadFs(p || '/'); };
|
|
1127
|
+
$('fsUse').onclick = async () => { if (await addWorkspace(state.fsDir)) closeFsPicker(); };
|
|
1128
|
+
$('fsClose').onclick = closeFsPicker;
|
|
1129
|
+
$('fsPicker').addEventListener('click', (e) => { if (e.target === $('fsPicker')) closeFsPicker(); });
|
|
1130
|
+
|
|
1131
|
+
// ── library lens + filter ──
|
|
1132
|
+
const FIND_PLACEHOLDER = { history: 'search past sessions…', scheduled: 'filter the library…' };
|
|
1133
|
+
document.querySelectorAll('.lens button').forEach((b) => b.onclick = () => {
|
|
1134
|
+
state.lens = b.dataset.lens; store.set('lens', state.lens);
|
|
1135
|
+
$('libFind').placeholder = FIND_PLACEHOLDER[state.lens] || 'filter the library…';
|
|
1136
|
+
renderLibrary();
|
|
1137
|
+
});
|
|
1138
|
+
$('libFind').addEventListener('input', (e) => { state.filter = e.target.value; renderLibrary(); });
|
|
1139
|
+
$('libRefresh').onclick = () => {
|
|
1140
|
+
const b = $('libRefresh'); b.classList.add('spin');
|
|
1141
|
+
refreshLibrary(); setTimeout(() => b.classList.remove('spin'), 500);
|
|
1142
|
+
};
|
|
1143
|
+
|
|
1144
|
+
// ── chrome ──
|
|
1145
|
+
$('themeBtn').onclick = () => {
|
|
1146
|
+
const r = document.documentElement;
|
|
1147
|
+
const next = r.dataset.theme === 'dark' ? 'light' : 'dark';
|
|
1148
|
+
r.dataset.theme = next;
|
|
1149
|
+
store.set('theme', next); // survives reloads (R7)
|
|
1150
|
+
for (const [id, t] of state.terms) { try { t.term.options.theme = xtheme(); } catch {} fitOne(id); }
|
|
1151
|
+
};
|
|
1152
|
+
$('closeDoc').onclick = closeDoc;
|
|
1153
|
+
|
|
1154
|
+
// ── ⌘K / Ctrl-K jump palette (client-side: workspaces + sessions + initiatives + docs) ──
|
|
1155
|
+
// ── on-demand surfaces (Plan · Deliver · Measure · Report) ─────────────────────────────────────
|
|
1156
|
+
// Summoned from ⌘K, rendered in the reader, derived through the server's local state adapter and
|
|
1157
|
+
// validated by the deliverable contract. Never resident: closing the reader (✕/Esc) dismisses it.
|
|
1158
|
+
const SURFACES = [
|
|
1159
|
+
{ name: 'initiatives', label: 'initiatives', phase: 'plan' },
|
|
1160
|
+
{ name: 'roadmap', label: 'roadmap', phase: 'plan' },
|
|
1161
|
+
{ name: 'okr', label: 'okr + levers', phase: 'measure' },
|
|
1162
|
+
{ name: 'report', label: 'weekly report', phase: 'report' },
|
|
1163
|
+
// Read-only console surfaces (D64 Track B): file-mode state, honest-by-construction (a broken
|
|
1164
|
+
// read renders RED, never a green empty). Sourced from /api/console, not the deliverable contract.
|
|
1165
|
+
{ name: 'needs', label: 'needs you', phase: 'deliver', console: true },
|
|
1166
|
+
{ name: 'radar', label: 'radar', phase: 'measure', console: true },
|
|
1167
|
+
{ name: 'metrics', label: 'metrics', phase: 'measure', console: true },
|
|
1168
|
+
];
|
|
1169
|
+
const SURFACE_BY_NAME = {}; SURFACES.forEach((v) => { SURFACE_BY_NAME[v.name] = v; });
|
|
1170
|
+
const SF_STAT = { planning: ['○', 'planning'], building: ['◐', 'building'], blocked: ['◆', 'blocked'], landed: ['▲', 'landed'] };
|
|
1171
|
+
|
|
1172
|
+
async function openSurface(name) {
|
|
1173
|
+
showRail();
|
|
1174
|
+
$('paneDoc').hidden = false; $('railSplit').hidden = false; foldPane('doc', true);
|
|
1175
|
+
state.surface = name; state.docPath = null;
|
|
1176
|
+
document.querySelectorAll('.doc').forEach((b) => b.classList.remove('on'));
|
|
1177
|
+
$('crumb').innerHTML = `<bdi>surface · ${esc(name)}</bdi>`;
|
|
1178
|
+
$('docBody').scrollTop = 0;
|
|
1179
|
+
// The skeleton IS the loading state — visually distinct from a calm empty and a red error, so a
|
|
1180
|
+
// slow read never looks like "nothing awaits" (D64 B2).
|
|
1181
|
+
$('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>`;
|
|
1182
|
+
wireSurface();
|
|
1183
|
+
const meta = SURFACE_BY_NAME[name] || {};
|
|
1184
|
+
const endpoint = meta.console
|
|
1185
|
+
? `/api/console?name=${encodeURIComponent(name)}&ws=${encodeURIComponent(state.wsId || '')}`
|
|
1186
|
+
: `/api/surface?name=${encodeURIComponent(name)}&ws=${encodeURIComponent(state.wsId || '')}`;
|
|
1187
|
+
try {
|
|
1188
|
+
const r = await fetch(endpoint);
|
|
1189
|
+
const payload = await r.json();
|
|
1190
|
+
const body = meta.console ? consoleBody(name, payload) : surfaceBody(name, payload);
|
|
1191
|
+
const okFlag = payload.ok !== false;
|
|
1192
|
+
$('docBody').innerHTML = `<div class="sf-wrap">${sfHead(name, okFlag)}<div class="sf-body">${body}</div></div>`;
|
|
1193
|
+
} catch {
|
|
1194
|
+
// A failed FETCH is a broken read — render RED, never a calm "nothing here" (honesty by construction).
|
|
1195
|
+
$('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>`;
|
|
1196
|
+
}
|
|
1197
|
+
wireSurface();
|
|
1198
|
+
refit();
|
|
1199
|
+
}
|
|
1200
|
+
function sfHead(active, ok) {
|
|
1201
|
+
const meta = SURFACE_BY_NAME[active] || {};
|
|
1202
|
+
let chip = '';
|
|
1203
|
+
if (ok !== false) {
|
|
1204
|
+
chip = meta.console
|
|
1205
|
+
? `<span class="sf-chip src" title="derived live from local file-mode state (D64)">file-mode</span>`
|
|
1206
|
+
: `<span class="sf-chip" title="the deliverable contract passed before this rendered">contract ✓</span>`;
|
|
1207
|
+
}
|
|
1208
|
+
return `<div class="sf-head">
|
|
1209
|
+
<span class="sf-views" role="tablist" aria-label="Surface">${SURFACES.map((v) =>
|
|
1210
|
+
`<button role="tab" data-surface="${v.name}" data-phase="${v.phase}" aria-selected="${v.name === active}" class="${v.name === active ? 'on' : ''}">${esc(v.label)}</button>`).join('')}</span>
|
|
1211
|
+
${chip}</div>`;
|
|
1212
|
+
}
|
|
1213
|
+
function wireSurface() {
|
|
1214
|
+
document.querySelectorAll('[data-surface]').forEach((b) => b.onclick = () => openSurface(b.dataset.surface));
|
|
1215
|
+
document.querySelectorAll('[data-open-surface]').forEach((b) => b.onclick = () => openSurface(b.dataset.openSurface));
|
|
1216
|
+
$('docBody').querySelectorAll('.sf-art[data-doc]').forEach((b) => b.onclick = () => openDoc(b.dataset.doc));
|
|
1217
|
+
}
|
|
1218
|
+
function surfaceBody(name, payload) {
|
|
1219
|
+
if (!payload || !payload.ok) return sfCantProduce(name, payload && payload.contract);
|
|
1220
|
+
const s = payload.surface || {};
|
|
1221
|
+
if (name === 'initiatives') return sfInitiatives(s);
|
|
1222
|
+
if (name === 'roadmap') return sfRoadmap(s);
|
|
1223
|
+
if (name === 'okr') return sfOkr(s);
|
|
1224
|
+
if (name === 'report') return sfReport(s);
|
|
1225
|
+
return '';
|
|
1226
|
+
}
|
|
1227
|
+
function sfCantProduce(name, contract) {
|
|
1228
|
+
const miss = ((contract && contract.missing) || []).join(', ') || 'the required data';
|
|
1229
|
+
return `<div class="sf-fail"><div class="mk" aria-hidden="true">◇</div>
|
|
1230
|
+
<h3>Can’t produce the ${esc(name)} surface here</h3>
|
|
1231
|
+
<p>This workspace has no state to build it from — so there’s nothing to show, and the surface won’t invent one.</p>
|
|
1232
|
+
<p class="miss">contract: missing ${esc(miss)}</p>
|
|
1233
|
+
<p class="hint">Open a repo with a <code>planning/</code> tree, or start work with <code>create_initiative</code>.</p></div>`;
|
|
1234
|
+
}
|
|
1235
|
+
function sfInitiatives(s) {
|
|
1236
|
+
const needs = (s.needs && s.needs.length)
|
|
1237
|
+
? `<div class="sf-needs"><div class="nh"><span class="dot" aria-hidden="true"></span><b>${s.needs.length} need${s.needs.length > 1 ? '' : 's'} you</b><span class="sub">acted on here, not buried</span></div>
|
|
1238
|
+
${s.needs.map((n) => `<div class="need"><span class="ph" aria-hidden="true"></span><div class="txt"><div class="t">${esc(n.t)}</div><div class="m">${esc(n.kind)}${n.initiative_id ? ' · ' + esc(n.initiative_id) : ''}</div></div>
|
|
1239
|
+
<button class="go" data-open-surface="report">▸ report</button></div>`).join('')}</div>`
|
|
1240
|
+
: `<div class="sf-needs calm"><div class="nh"><span class="dot" aria-hidden="true"></span><b>Nothing needs you</b><span class="sub">${s.pulse.total} initiatives</span></div>
|
|
1241
|
+
<div class="calm-body">No blocked work, no pending acceptance. The list is here when you want it — the workspace won’t nag.</div></div>`;
|
|
1242
|
+
const p = s.pulse || { total: 0 };
|
|
1243
|
+
const dist = [['plan', 'planning', p.planning], ['deliver', 'building', p.building], ['deliver', 'blocked', p.blocked], ['report', 'landed', p.landed]];
|
|
1244
|
+
const pulse = `<div class="sf-pulse"><span class="n"><b>${p.total}</b> initiatives</span>
|
|
1245
|
+
<span class="sf-dist" aria-hidden="true">${dist.map(([ph, k, c]) => c ? `<i data-phase="${ph}" style="width:${c / (p.total || 1) * 100}%"></i>` : '').join('')}</span>
|
|
1246
|
+
<span class="sf-legend">${['planning', 'building', 'blocked', 'landed'].map((k) => `<span>${SF_STAT[k][0]} ${k} ${p[k] || 0}</span>`).join('')}</span></div>`;
|
|
1247
|
+
const order = [['needs you', (i) => i.status === 'blocked'], ['in flight', (i) => i.status === 'building'],
|
|
1248
|
+
['planned', (i) => i.status === 'planning'], ['landed', (i) => i.status === 'landed']];
|
|
1249
|
+
const list = order.map(([lab, f]) => {
|
|
1250
|
+
const items = (s.initiatives || []).filter(f); if (!items.length) return '';
|
|
1251
|
+
return `<div class="sf-grp">${lab} · ${items.length}</div><div class="sf-list">${items.map(sfRow).join('')}</div>`;
|
|
1252
|
+
}).join('');
|
|
1253
|
+
return needs + pulse + list;
|
|
1254
|
+
}
|
|
1255
|
+
function sfRow(i) {
|
|
1256
|
+
const [gl, glab] = SF_STAT[i.status] || ['•', i.status];
|
|
1257
|
+
const metrics = i.tracked
|
|
1258
|
+
? `<span class="m"><span class="k">RICE</span><b>${i.rice}</b></span><span class="m"><span class="k">effort</span>${i.effort}pt</span>
|
|
1259
|
+
<span class="m"><span class="k">progress</span><span class="sf-prog"><i style="width:${i.progress || 0}%"></i></span></span>`
|
|
1260
|
+
: `<span class="m"><span class="k">RICE</span><span class="untr">— not tracked</span></span>
|
|
1261
|
+
<span class="m"><span class="k">progress</span><span class="untr">— not tracked</span></span>`;
|
|
1262
|
+
return `<details class="sf-row" data-phase="${i.phase}"${i.status === 'blocked' ? ' open' : ''}>
|
|
1263
|
+
<summary>
|
|
1264
|
+
<span class="sf-spine" aria-hidden="true"></span>
|
|
1265
|
+
<span class="sf-stat"><span class="g">${gl} ${glab}</span></span>
|
|
1266
|
+
<span class="sf-id">${esc(i.id)}${i.title ? `<span class="ti">${esc(i.title)}</span>` : ''}</span>
|
|
1267
|
+
<span class="sf-metrics">${metrics}</span>
|
|
1268
|
+
<span class="sf-chev" aria-hidden="true">▸</span>
|
|
1269
|
+
</summary>
|
|
1270
|
+
<div class="sf-detail">
|
|
1271
|
+
${i.kr ? `<div><h4>Anchor</h4><p>${esc(i.kr)}</p></div>` : ''}
|
|
1272
|
+
<div><h4>Status</h4><p>${esc(i.statusText || i.status)}${i.stage ? ` · stage ${esc(i.stage)}` : ''}${i.accepted ? ' · accepted' : ''}</p></div>
|
|
1273
|
+
<div><h4>Artifacts</h4><div class="sf-arts">${(i.artifacts || []).map((a) =>
|
|
1274
|
+
`<button class="sf-art" data-doc="${esc(a.path)}"><span class="role">${esc(a.role)}</span>${esc(a.name)}</button>`).join('') || '<span class="untr">none on disk</span>'}</div></div>
|
|
1275
|
+
</div></details>`;
|
|
1276
|
+
}
|
|
1277
|
+
function sfRoadmap(s) {
|
|
1278
|
+
return (s.groups || []).map((g) => `<div class="sf-rc"><h4>${esc(g.label)} · ${g.items.length}</h4>
|
|
1279
|
+
${g.items.map((i) => `<div class="sf-rcrow" data-phase="${i.phase}"><span class="sf-spine" aria-hidden="true"></span><span class="nm">${esc(i.id)}</span><span class="st">${(SF_STAT[i.status] || ['•'])[0]} ${esc(i.status)}</span></div>`).join('')}
|
|
1280
|
+
</div>`).join('') + (s.note ? `<div class="sf-note">${esc(s.note)}</div>` : '');
|
|
1281
|
+
}
|
|
1282
|
+
function sfOkr(s) {
|
|
1283
|
+
return (s.objectives || []).map((o) => `<div class="sf-kr"><div class="obj">${esc(o.id)} · ${esc(o.name)}</div>
|
|
1284
|
+
${(o.krs || []).map((k) => `<div class="krrow"><div class="krh"><span class="lab">${esc(k.id)}</span><span>${esc(k.name)}</span></div>
|
|
1285
|
+
<div class="lev"><span class="untr">current · target · levers — not tracked</span></div></div>`).join('') || '<span class="untr">no KRs parsed</span>'}
|
|
1286
|
+
</div>`).join('') + (s.note ? `<div class="sf-note">${esc(s.note)}</div>` : '');
|
|
1287
|
+
}
|
|
1288
|
+
function sfReport(s) {
|
|
1289
|
+
return `<div class="sf-rep"><p class="lead">${esc(s.cadence)} report · derived on demand</p>
|
|
1290
|
+
${(s.sections || []).map((sec) => `<h3>${esc(sec.heading)}</h3><ul>${((sec.items && sec.items.length) ? sec.items : ['—']).map((x) => `<li>${esc(x)}</li>`).join('')}</ul>`).join('')}</div>`;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// ── console surfaces (D64 Track B): needs-you · radar · metrics over file-mode state ────────────
|
|
1294
|
+
// HONESTY BY CONSTRUCTION: three visually distinct outcomes —
|
|
1295
|
+
// error → a RED banner (broken/missing/corrupt read, or a fetch failure)
|
|
1296
|
+
// empty → a CALM state (successfully read, genuinely nothing)
|
|
1297
|
+
// data → the surface.
|
|
1298
|
+
// A broken read must NEVER render the calm empty. csError is the only thing a failed read may show.
|
|
1299
|
+
function consoleBody(name, p) {
|
|
1300
|
+
if (!p || p.ok === false) return csError(name, (p && p.error) || 'the file-mode read failed');
|
|
1301
|
+
if (p.empty) return csEmpty(name);
|
|
1302
|
+
if (name === 'needs') return csNeeds(p.data);
|
|
1303
|
+
if (name === 'radar') return csRadar(p.data, p.source);
|
|
1304
|
+
if (name === 'metrics') return csMetrics(p.data, p.source);
|
|
1305
|
+
return csError(name, 'unknown console surface');
|
|
1306
|
+
}
|
|
1307
|
+
function csError(name, why) {
|
|
1308
|
+
return `<div class="cs-error" role="alert">
|
|
1309
|
+
<div class="cs-err-head"><span class="mk" aria-hidden="true">▲</span><b>Can’t read the ${esc(name)} surface</b></div>
|
|
1310
|
+
<p class="cs-err-why">${esc(why)}</p>
|
|
1311
|
+
<p class="cs-err-note">This is a broken read, not an all-clear. The workspace refuses to show a green
|
|
1312
|
+
empty state over a failed read — fix the file-mode state, then reopen.</p></div>`;
|
|
1313
|
+
}
|
|
1314
|
+
function csEmpty(name) {
|
|
1315
|
+
const msg = name === 'needs'
|
|
1316
|
+
? 'Nothing needs your decision right now. No open Acceptance Gate, no overdue outcome check.'
|
|
1317
|
+
: `No ${esc(name)} data yet — the state read succeeded and there is genuinely nothing to show.`;
|
|
1318
|
+
return `<div class="cs-empty"><span class="dot" aria-hidden="true"></span><b>Nothing awaits</b>
|
|
1319
|
+
<p>${msg}</p><p class="cs-empty-note">This is a real, verified empty — not a failed read.</p></div>`;
|
|
1320
|
+
}
|
|
1321
|
+
function csNeeds(d) {
|
|
1322
|
+
const items = (d && d.items) || [];
|
|
1323
|
+
return `<div class="cs-needs"><div class="cs-h"><span class="dot" aria-hidden="true"></span>
|
|
1324
|
+
<b>${items.length} need${items.length === 1 ? '' : 's'} you</b><span class="src">file-mode · Acceptance Gate is non-delegable (D12)</span></div>
|
|
1325
|
+
${items.map((n) => `<div class="cs-need" data-kind="${esc(n.kind)}">
|
|
1326
|
+
<span class="ph" aria-hidden="true"></span>
|
|
1327
|
+
<div class="txt"><div class="t">${esc(n.question)}</div>
|
|
1328
|
+
<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>
|
|
1329
|
+
</div>`).join('')}</div>`;
|
|
1330
|
+
}
|
|
1331
|
+
function csRadar(d, source) {
|
|
1332
|
+
const g = d.eval_gate || {};
|
|
1333
|
+
const cal = d.calibration;
|
|
1334
|
+
const wms = d.watermelons || [];
|
|
1335
|
+
const flags = d.flags || 0;
|
|
1336
|
+
const ragCls = (r) => 'rag-' + (String(r || 'grey').toLowerCase());
|
|
1337
|
+
const gate = `<div class="cs-card"><h4>Review Gate</h4>
|
|
1338
|
+
<div class="cs-big">${g.pass_rate == null ? '—' : Math.round(g.pass_rate * 100) + '%'}<span class="u">pass</span></div>
|
|
1339
|
+
<div class="cs-sub">${g.passing || 0}/${g.total || 0} evals pass · rule verdict ILIKE 'pass%' OR score ≥ 0.85</div></div>`;
|
|
1340
|
+
const calCard = cal ? `<div class="cs-card ${cal.meets_d40 ? '' : 'warnedge'}"><h4>Calibration (D40)</h4>
|
|
1341
|
+
<div class="cs-big">${cal.kappa == null ? '—' : (+cal.kappa).toFixed(2)}<span class="u">κ</span></div>
|
|
1342
|
+
<div class="cs-sub">n=${cal.n == null ? '—' : cal.n} · TNR ${cal.tnr == null ? 'n/a (leniency blind spot)' : Math.round(cal.tnr * 100) + '%'} · ${esc(cal.status || '')}${cal.meets_d40 ? '' : ' · below κ≥0.60'}</div>
|
|
1343
|
+
${cal.caveat ? `<div class="cs-caveat">${esc(cal.caveat)}</div>` : ''}</div>` : '';
|
|
1344
|
+
const watermelon = `<div class="cs-wm"><div class="cs-h"><span class="dot ${flags ? 'warn' : 'ok'}" aria-hidden="true"></span>
|
|
1345
|
+
<b>${flags} watermelon flag${flags === 1 ? '' : 's'}</b><span class="src">green-on-metrics / red-on-strategy · anti-drift</span></div>
|
|
1346
|
+
${wms.map((w) => `<div class="cs-wmrow"><span class="rag ${ragCls(w.rag)}" title="${esc(w.rag || '')}"></span>
|
|
1347
|
+
<span class="nm">${esc(w.id)}${w.title ? `<span class="ti">${esc(w.title)}</span>` : ''}</span>
|
|
1348
|
+
<span class="st">${esc(w.verdict || '—')}${w.score != null ? ' · ' + w.score : ''}${w.corrections != null ? ' · ' + w.corrections + ' corr' : ''}</span></div>`).join('') || '<div class="cs-none">no initiatives on the radar</div>'}</div>`;
|
|
1349
|
+
return `<div class="cs-radar"><div class="cs-src">source: ${esc(source || 'file-mode')}</div>
|
|
1350
|
+
<div class="cs-cards">${gate}${calCard}</div>${watermelon}</div>`;
|
|
1351
|
+
}
|
|
1352
|
+
function csMetrics(d, source) {
|
|
1353
|
+
const pct = (x) => x == null ? '—' : Math.round(x * 100) + '%';
|
|
1354
|
+
const ns = d.avg_corrections_per_run == null ? '—' : (+d.avg_corrections_per_run).toFixed(2);
|
|
1355
|
+
const per = (d.per_initiative || []).slice(0, 12);
|
|
1356
|
+
return `<div class="cs-metrics"><div class="cs-src">source: ${esc(source || 'file-mode')}</div>
|
|
1357
|
+
<div class="cs-cards">
|
|
1358
|
+
<div class="cs-card"><h4>North star</h4><div class="cs-big">${ns}<span class="u">corr/run</span></div>
|
|
1359
|
+
<div class="cs-sub">human corrections per run · lower is better (D10)</div></div>
|
|
1360
|
+
<div class="cs-card"><h4>Throughput</h4><div class="cs-big">${d.total_runs == null ? '—' : d.total_runs}<span class="u">runs</span></div>
|
|
1361
|
+
<div class="cs-sub">${d.runs_needing_correction == null ? '—' : d.runs_needing_correction} needed a correction · ${pct(d.share_runs_corrected)} share</div></div>
|
|
1362
|
+
</div>
|
|
1363
|
+
${per.length ? `<div class="cs-per"><div class="cs-h"><b>per initiative</b><span class="src">avg corrections/run</span></div>
|
|
1364
|
+
${per.map((r) => `<div class="cs-perrow"><span class="nm">${esc(r.initiative_id)}</span>
|
|
1365
|
+
<span class="bar" aria-hidden="true"><i style="width:${Math.min(100, (Number(r.avg_corrections_per_run) || 0) / 7 * 100)}%"></i></span>
|
|
1366
|
+
<span class="v">${Number(r.avg_corrections_per_run || 0).toFixed(1)}</span></div>`).join('')}</div>` : ''}</div>`;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// ── the history lens (fourth): episodic recall over this workspace's own past sessions. ─────
|
|
1370
|
+
// The scrollback the server already mirrors to disk IS the record of what earlier runs tried, so
|
|
1371
|
+
// searching it is how the PM — or an agent in a pane, hitting /api/search itself — avoids
|
|
1372
|
+
// re-earning a correction someone already paid for. Secrets are scrubbed server-side, never here.
|
|
1373
|
+
let histSeq = 0;
|
|
1374
|
+
async function renderHistory() {
|
|
1375
|
+
const host = $('library');
|
|
1376
|
+
const q = (state.filter || '').trim();
|
|
1377
|
+
if (q.length < 2) {
|
|
1378
|
+
host.innerHTML = `<div class="hi-hint">Search what past sessions in this repo printed —
|
|
1379
|
+
type at least 2 characters in the box above.<br><br>
|
|
1380
|
+
<span class="mono">GET /api/search?q=…&ws=…</span> answers the same question for an
|
|
1381
|
+
agent running in a pane.</div>`;
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
const seq = ++histSeq;
|
|
1385
|
+
try {
|
|
1386
|
+
const r = await fetch(`/api/search?q=${encodeURIComponent(q)}&ws=${encodeURIComponent(state.wsId || '')}`);
|
|
1387
|
+
const j = await r.json();
|
|
1388
|
+
if (seq !== histSeq) return; // a later keystroke already won the race
|
|
1389
|
+
const hits = j.hits || [];
|
|
1390
|
+
if (!hits.length) { host.innerHTML = `<div class="hi-hint">No session in this repo printed “${esc(q)}”.</div>`; return; }
|
|
1391
|
+
host.innerHTML = `<div class="hi-list">${hits.map((h) => `
|
|
1392
|
+
<div class="hi-row">
|
|
1393
|
+
<div class="hi-head"><b>${esc(h.session || h.session_id)}</b>
|
|
1394
|
+
<span class="hi-meta">${esc(h.agent || '')}${h.at ? ' · ' + esc(h.at.slice(0, 10)) : ''}</span></div>
|
|
1395
|
+
${h.before ? `<div class="hi-ctx">${esc(h.before)}</div>` : ''}
|
|
1396
|
+
<div class="hi-hit">${esc(h.line)}</div>
|
|
1397
|
+
${h.after ? `<div class="hi-ctx">${esc(h.after)}</div>` : ''}
|
|
1398
|
+
</div>`).join('')}</div>` +
|
|
1399
|
+
// never a silent cap — say when the list was cut
|
|
1400
|
+
(j.truncated ? `<div class="hi-hint">showing the first ${hits.length} matches — narrow the query for the rest</div>` : '');
|
|
1401
|
+
} catch {
|
|
1402
|
+
if (seq === histSeq) host.innerHTML = `<div class="hi-hint">Could not reach the server.</div>`;
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// ── the scheduled lens (third library lens; a persistent pane beside initiatives + folders) ──────
|
|
1407
|
+
let SCHED_TASKS = null;
|
|
1408
|
+
const SC_STAT = { armed: ['◐', 'armed'], running: ['●', 'running'], failed: ['◆', 'failed'], off: ['—', 'off'] };
|
|
1409
|
+
function schHealth(s) { return !s.enabled ? 'off' : s.lastStatus === 'fail' ? 'failed' : 'armed'; }
|
|
1410
|
+
function relTime(iso) {
|
|
1411
|
+
if (!iso) return '—';
|
|
1412
|
+
const d = new Date(iso) - Date.now();
|
|
1413
|
+
if (d < 0) return 'now';
|
|
1414
|
+
const h = d / 3.6e6; if (h < 1) return `in ${Math.max(1, Math.round(d / 6e4))}m`;
|
|
1415
|
+
if (h < 24) return `in ${Math.round(h)}h`;
|
|
1416
|
+
return `in ${Math.round(h / 24)}d`;
|
|
1417
|
+
}
|
|
1418
|
+
function humanCad(s) {
|
|
1419
|
+
const t = s.time || '09:00';
|
|
1420
|
+
return { daily: `every day ${t}`, weekly: `every ${s.day} ${t}`, 'bi-weekly': `every other ${s.day} ${t}`, monthly: `monthly d${s.dayOfMonth} ${t}` }[s.cadence] || s.cadence;
|
|
1421
|
+
}
|
|
1422
|
+
async function renderScheduled() {
|
|
1423
|
+
const host = $('library');
|
|
1424
|
+
if (!host.querySelector('.sc-list')) host.innerHTML = `<div class="sc-load mono">loading schedules…</div>`;
|
|
1425
|
+
try {
|
|
1426
|
+
if (!SCHED_TASKS) { const tr = await fetch('/api/tasks'); SCHED_TASKS = (await tr.json()).tasks || []; }
|
|
1427
|
+
const r = await fetch('/api/schedules?ws=' + encodeURIComponent(state.wsId || ''));
|
|
1428
|
+
host.innerHTML = scheduledHtml(((await r.json()).schedules) || []);
|
|
1429
|
+
wireScheduled();
|
|
1430
|
+
} catch { host.innerHTML = `<div class="empty">Scheduler unavailable.</div>`; }
|
|
1431
|
+
}
|
|
1432
|
+
function scheduledHtml(list) {
|
|
1433
|
+
const armed = list.filter((s) => s.enabled);
|
|
1434
|
+
const failed = list.find((s) => s.lastStatus === 'fail' && s.enabled);
|
|
1435
|
+
const soon = armed.map((s) => s.next).filter(Boolean).sort()[0];
|
|
1436
|
+
const btns = `<button class="sc-new ghost" data-scnewskill title="Author a new skill">+ skill</button><button class="sc-new" data-scnew>+ new</button>`;
|
|
1437
|
+
const summary = failed
|
|
1438
|
+
? `<div class="sc-sum warn"><span class="dot"></span><div class="t"><b>${esc(failed.name)}</b> failed its last run</div><span class="sc-acts">${btns}</span></div>`
|
|
1439
|
+
: `<div class="sc-sum"><span class="dot"></span><div class="t"><b>${armed.length} armed</b>${soon ? ` · next ${esc(relTime(soon))}` : ''}</div><span class="sc-acts">${btns}</span></div>`;
|
|
1440
|
+
const rows = list.length ? list.map(scRow).join('') : `<div class="sc-empty"><div class="mk">◷</div><p>No schedules yet. Set a cadence and pmos runs the skill itself — output filed to needs-you.</p><button class="sc-new" data-scnew>+ new schedule</button></div>`;
|
|
1441
|
+
return `<div class="sc-wrap">${summary}<div class="sc-list">${rows}</div>
|
|
1442
|
+
<div class="sc-note">Every task is a <b>skill</b> — a named, repeatable job with a consistent-output contract. <b>+ skill</b> authors one; each run is <b>contract-checked</b>, the same shape every time. Local; fires while the server runs.</div></div>`;
|
|
1443
|
+
}
|
|
1444
|
+
function scRow(s) {
|
|
1445
|
+
const health = schHealth(s); const [gl, glab] = SC_STAT[health];
|
|
1446
|
+
const run = health === 'failed' ? `<span class="fail">failed ✗</span>`
|
|
1447
|
+
: !s.enabled ? `<span class="off">disabled</span>`
|
|
1448
|
+
: `<span class="next">${esc(relTime(s.next))}</span>`;
|
|
1449
|
+
return `<div class="sc-row" data-health="${health}" data-id="${esc(s.id)}">
|
|
1450
|
+
<div class="sc-main">
|
|
1451
|
+
<span class="sc-rail"></span>
|
|
1452
|
+
<span class="sc-tog" role="switch" aria-checked="${!!s.enabled}" tabindex="0" data-tog="${esc(s.id)}" aria-label="Enable ${esc(s.name)}"><span class="knob"></span></span>
|
|
1453
|
+
<div class="sc-id"><div class="nm"><span class="g" aria-hidden="true">${gl}</span><span class="name">${esc(s.name)}</span><span class="skill">${esc(s.skill)}</span></div>
|
|
1454
|
+
<div class="cad">${esc(humanCad(s))} · ${esc(glab)}${s.lastRun ? ` · last ${esc(relTime2(s.lastRun))}` : ''}</div></div>
|
|
1455
|
+
<div class="sc-run">${run}</div>
|
|
1456
|
+
</div>
|
|
1457
|
+
<div class="sc-more"><button class="rn" data-run="${esc(s.id)}">▸ run now</button><button data-del="${esc(s.id)}">delete</button></div>
|
|
1458
|
+
</div>`;
|
|
1459
|
+
}
|
|
1460
|
+
function relTime2(iso) { const d = Date.now() - new Date(iso); const h = d / 3.6e6; return h < 1 ? 'just now' : h < 24 ? `${Math.round(h)}h ago` : `${Math.round(h / 24)}d ago`; }
|
|
1461
|
+
function scForm() {
|
|
1462
|
+
const wsName = (state.workspaces.find((w) => w.id === state.wsId) || {}).name || 'this repo';
|
|
1463
|
+
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
|
1464
|
+
return `<form class="sc-form" id="scForm">
|
|
1465
|
+
<div><label for="scfN">name</label><input id="scfN" placeholder="e.g. Friday status report" /></div>
|
|
1466
|
+
<div><label>cadence</label><div class="sc-seg" id="scfC">${['daily', 'weekly', 'bi-weekly', 'monthly'].map((c) => `<button type="button" data-c="${c}" aria-pressed="${c === 'weekly'}">${c}</button>`).join('')}</div></div>
|
|
1467
|
+
<div class="sc-f2"><div><label for="scfD">day</label><select id="scfD">${days.map((d) => `<option${d === 'Fri' ? ' selected' : ''}>${d}</option>`).join('')}</select></div>
|
|
1468
|
+
<div><label for="scfT">time</label><input id="scfT" type="time" value="09:00" /></div></div>
|
|
1469
|
+
<div><label for="scfK">task — skill-backed</label><select id="scfK">${SCHED_TASKS.map((t) => `<option value="${esc(t.id)}">${esc(t.label)} · skill ${esc(t.skill)} · contract ✓</option>`).join('')}</select></div>
|
|
1470
|
+
<div class="sc-hint">runs in <b>${esc(wsName)}</b> · output is contract-checked for a consistent shape</div>
|
|
1471
|
+
<div class="sc-frow"><button type="submit" class="save">save schedule</button><button type="button" class="cancel" id="scfX">cancel</button></div></form>`;
|
|
1472
|
+
}
|
|
1473
|
+
let scCadence = 'weekly';
|
|
1474
|
+
function wireScheduled() {
|
|
1475
|
+
const host = $('library');
|
|
1476
|
+
host.querySelectorAll('[data-scnew]').forEach((b) => b.onclick = () => { scCadence = 'weekly'; host.querySelector('.sc-list').insertAdjacentHTML('beforebegin', scForm()); wireScForm(); });
|
|
1477
|
+
host.querySelectorAll('[data-scnewskill]').forEach((b) => b.onclick = () => { if ($('skForm')) return; host.querySelector('.sc-list').insertAdjacentHTML('beforebegin', skForm()); wireSkForm(); });
|
|
1478
|
+
host.querySelectorAll('[data-tog]').forEach((b) => b.onclick = () => scToggle(b.dataset.tog, b.getAttribute('aria-checked') !== 'true'));
|
|
1479
|
+
host.querySelectorAll('[data-run]').forEach((b) => b.onclick = () => scRun(b.dataset.run));
|
|
1480
|
+
host.querySelectorAll('[data-del]').forEach((b) => b.onclick = () => scDelete(b.dataset.del));
|
|
1481
|
+
}
|
|
1482
|
+
// The New Skill flow: a GUI replaces editing server code. One concept — no "kind". name + "what it
|
|
1483
|
+
// should do" + the output shape (the section keys = the consistency contract). Create scaffolds a
|
|
1484
|
+
// well-formed .skill file; AI-draft additionally spawns a LOCAL claude session to sharpen it in place.
|
|
1485
|
+
function skForm() {
|
|
1486
|
+
return `<form class="sc-form sk-form" id="skForm">
|
|
1487
|
+
<div class="sk-head">+ new skill<span class="sk-sub">a named, repeatable task — one concept, no “kind” to pick</span></div>
|
|
1488
|
+
<div><label for="skfN">name</label><input id="skfN" placeholder="e.g. release-notes" autocomplete="off" spellcheck="false" /></div>
|
|
1489
|
+
<div><label for="skfD">what it should do</label><textarea id="skfD" rows="3" placeholder="Draft release notes from the merged PRs since the last report…"></textarea></div>
|
|
1490
|
+
<div><label for="skfO">output shape — one section per line (the consistency contract)</label><textarea id="skfO" rows="3" placeholder="Highlights Fixes Known issues"></textarea></div>
|
|
1491
|
+
<div class="sc-hint">saved as a <b>skill file</b> · runs as a local claude session · appears in the schedule picker</div>
|
|
1492
|
+
<div class="sc-frow"><button type="submit" class="save">create skill</button><button type="button" class="sc-ai" id="skfAI" title="Spawn a local claude session to draft this skill">✦ AI-draft</button><button type="button" class="cancel" id="skfX">cancel</button></div></form>`;
|
|
1493
|
+
}
|
|
1494
|
+
function skBody() {
|
|
1495
|
+
return { name: ($('skfN').value || '').trim(), description: ($('skfD').value || '').trim(),
|
|
1496
|
+
output: ($('skfO').value || '').split('\n').map((x) => x.trim()).filter(Boolean), ws: state.wsId };
|
|
1497
|
+
}
|
|
1498
|
+
function wireSkForm() {
|
|
1499
|
+
const f = $('skForm'); if (!f) return;
|
|
1500
|
+
$('skfX').onclick = () => renderScheduled();
|
|
1501
|
+
$('skfAI').onclick = async () => {
|
|
1502
|
+
const body = skBody();
|
|
1503
|
+
if (!body.name) { flash('name the skill first'); $('skfN').focus(); return; }
|
|
1504
|
+
flash('spawning a claude session to draft it…');
|
|
1505
|
+
try {
|
|
1506
|
+
const r = await fetch('/api/skills/draft', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
1507
|
+
const j = await r.json();
|
|
1508
|
+
if (!j.ok) { flash(j.error || 'could not draft'); return; }
|
|
1509
|
+
SCHED_TASKS = null;
|
|
1510
|
+
if (j.started && j.sessionId) { flash('claude is drafting the skill'); selectSession(j.sessionId); }
|
|
1511
|
+
else { flash(j.reason || 'skill scaffolded (claude not available to draft)'); renderScheduled(); }
|
|
1512
|
+
} catch { flash('could not reach the server'); }
|
|
1513
|
+
};
|
|
1514
|
+
f.onsubmit = async (e) => {
|
|
1515
|
+
e.preventDefault();
|
|
1516
|
+
const body = skBody();
|
|
1517
|
+
if (!body.name) { flash('name the skill first'); $('skfN').focus(); return; }
|
|
1518
|
+
try {
|
|
1519
|
+
const r = await fetch('/api/skills', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
1520
|
+
const j = await r.json();
|
|
1521
|
+
if (!j.ok) { flash(j.error || 'could not create'); return; }
|
|
1522
|
+
SCHED_TASKS = null; flash('skill created');
|
|
1523
|
+
} catch { flash('could not reach the server'); return; }
|
|
1524
|
+
renderScheduled();
|
|
1525
|
+
};
|
|
1526
|
+
$('skfN').focus();
|
|
1527
|
+
}
|
|
1528
|
+
function wireScForm() {
|
|
1529
|
+
const f = $('scForm'); if (!f) return;
|
|
1530
|
+
f.querySelectorAll('[data-c]').forEach((b) => b.onclick = () => { scCadence = b.dataset.c; f.querySelectorAll('[data-c]').forEach((x) => x.setAttribute('aria-pressed', String(x === b))); });
|
|
1531
|
+
$('scfX').onclick = () => renderScheduled();
|
|
1532
|
+
f.onsubmit = async (e) => {
|
|
1533
|
+
e.preventDefault();
|
|
1534
|
+
const body = { name: $('scfN').value, cadence: scCadence, day: $('scfD').value, time: $('scfT').value, task: $('scfK').value, skill: $('scfK').value, ws: state.wsId };
|
|
1535
|
+
try { const r = await fetch('/api/schedules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
1536
|
+
const j = await r.json(); if (!j.ok) { flash(j.error || 'could not save'); return; } } catch { flash('could not reach the server'); return; }
|
|
1537
|
+
renderScheduled();
|
|
1538
|
+
};
|
|
1539
|
+
$('scfN').focus();
|
|
1540
|
+
}
|
|
1541
|
+
async function scPost(path, body) { try { await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); } catch {} }
|
|
1542
|
+
async function scToggle(id, enabled) { await scPost('/api/schedules/update', { id, enabled }); renderScheduled(); }
|
|
1543
|
+
async function scDelete(id) { await scPost('/api/schedules/delete', { id }); renderScheduled(); }
|
|
1544
|
+
async function scRun(id) { flash('running…'); await scPost('/api/schedules/run', { id }); renderScheduled(); loadNeeds(); }
|
|
1545
|
+
async function openDeliverable(id) {
|
|
1546
|
+
if (!id) return;
|
|
1547
|
+
state.docPath = null; state.surface = null;
|
|
1548
|
+
showRail(); $('paneDoc').hidden = false; $('railSplit').hidden = false; foldPane('doc', true);
|
|
1549
|
+
$('crumb').innerHTML = `<bdi>report · ${esc(id)}</bdi>`; $('docBody').scrollTop = 0;
|
|
1550
|
+
try { const r = await fetch('/api/deliverable?ws=' + encodeURIComponent(state.wsId || '') + '&id=' + encodeURIComponent(id));
|
|
1551
|
+
$('docBody').innerHTML = r.ok ? renderMd(await r.text(), '') : `<p class="doc-empty">Report not found.</p>`;
|
|
1552
|
+
} catch { $('docBody').innerHTML = `<p class="doc-empty">Read failed.</p>`; }
|
|
1553
|
+
refit();
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
let palItems = [], palSel = 0;
|
|
1557
|
+
function flattenDocs(node, acc) {
|
|
1558
|
+
(node.docs || []).forEach((d) => { if (d.md !== false) acc.push({ kind: 'doc', label: d.path, path: d.path }); });
|
|
1559
|
+
(node.dirs || []).forEach((d) => flattenDocs(d, acc));
|
|
1560
|
+
return acc;
|
|
1561
|
+
}
|
|
1562
|
+
function openPalette() {
|
|
1563
|
+
const lib = state.lib || {};
|
|
1564
|
+
const docs = (lib.roots || []).reduce((a, r) => flattenDocs(r, a), []);
|
|
1565
|
+
const inits = (lib.initiatives || []).map((g) => ({ kind: 'initiative', label: g.id, init: g }));
|
|
1566
|
+
// every session, including other workspaces' — selecting one follows it to its workspace
|
|
1567
|
+
const sess = state.sessions.map((s) => {
|
|
1568
|
+
const w = state.workspaces.find((x) => x.id === s.ws);
|
|
1569
|
+
return { kind: 'session', label: s.ws === state.wsId ? s.name : `${w ? w.name : '?'} / ${s.name}`, id: s.id, state: s.state };
|
|
1570
|
+
});
|
|
1571
|
+
const wss = state.workspaces.filter((w) => w.id !== state.wsId).map((w) => ({ kind: 'workspace', label: w.name, wsid: w.id }));
|
|
1572
|
+
// summon a P/D/M/R surface on demand — listed first so "initiatives"/"roadmap"/"okr"/"report" are reachable
|
|
1573
|
+
const views = SURFACES.map((v) => ({ kind: 'view', label: v.label, view: v.name }));
|
|
1574
|
+
palItems = [...views, ...wss, ...sess, ...inits, ...docs];
|
|
1575
|
+
$('palette').style.display = 'grid';
|
|
1576
|
+
const inp = $('palInput'); inp.value = ''; renderPalette('');
|
|
1577
|
+
setTimeout(() => inp.focus(), 0);
|
|
1578
|
+
}
|
|
1579
|
+
function closePalette() { $('palette').style.display = 'none'; if (state.active) { const t = state.terms.get(state.active); if (t) t.term.focus(); } }
|
|
1580
|
+
function togglePalette() { $('palette').style.display === 'grid' ? closePalette() : openPalette(); }
|
|
1581
|
+
function renderPalette(q) {
|
|
1582
|
+
const f = q.toLowerCase();
|
|
1583
|
+
const items = palItems.filter((i) => i.label.toLowerCase().includes(f)).slice(0, 40);
|
|
1584
|
+
palSel = 0;
|
|
1585
|
+
const g = (i) => i.kind === 'view' ? '▤' : i.kind === 'session' ? (GLYPH[i.state] || '•') : i.kind === 'workspace' ? '▣' : i.kind === 'initiative' ? '◈' : '◦';
|
|
1586
|
+
$('palList').innerHTML = items.map((i, n) => `
|
|
1587
|
+
<button class="pitem ${n === 0 ? 'sel' : ''}" data-n="${n}" role="option">
|
|
1588
|
+
<span class="g" aria-hidden="true">${g(i)}</span>
|
|
1589
|
+
<span>${esc(i.label)}</span><span class="kind">${i.kind}</span></button>`).join('')
|
|
1590
|
+
|| `<div class="empty">No match.</div>`;
|
|
1591
|
+
$('palList')._items = items;
|
|
1592
|
+
$('palList').querySelectorAll('.pitem').forEach((b) => b.onclick = () => choosePalette(Number(b.dataset.n)));
|
|
1593
|
+
}
|
|
1594
|
+
function choosePalette(n) {
|
|
1595
|
+
const items = $('palList')._items || [];
|
|
1596
|
+
const it = items[n]; if (!it) return;
|
|
1597
|
+
closePalette();
|
|
1598
|
+
if (it.kind === 'view') openSurface(it.view);
|
|
1599
|
+
else if (it.kind === 'session') selectSession(it.id);
|
|
1600
|
+
else if (it.kind === 'workspace') selectWorkspace(it.wsid);
|
|
1601
|
+
else if (it.kind === 'initiative') {
|
|
1602
|
+
// jump to the initiative's primary artifact (PRD first — its own ordering guarantees it)
|
|
1603
|
+
const first = it.init.artifacts[0];
|
|
1604
|
+
state.lens = 'initiatives'; state.filter = it.init.id; $('libFind').value = it.init.id; renderLibrary();
|
|
1605
|
+
if (first) openDoc(first.path);
|
|
1606
|
+
} else openDoc(it.path);
|
|
1607
|
+
}
|
|
1608
|
+
$('palInput')?.addEventListener('input', (e) => renderPalette(e.target.value));
|
|
1609
|
+
$('palInput')?.addEventListener('keydown', (e) => {
|
|
1610
|
+
const items = $('palList').querySelectorAll('.pitem'); if (!items.length) { if (e.key === 'Escape') closePalette(); return; }
|
|
1611
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); palSel = Math.min(palSel + 1, items.length - 1); }
|
|
1612
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); palSel = Math.max(palSel - 1, 0); }
|
|
1613
|
+
else if (e.key === 'Enter') { e.preventDefault(); choosePalette(palSel); return; }
|
|
1614
|
+
else if (e.key === 'Escape') { closePalette(); return; }
|
|
1615
|
+
else return;
|
|
1616
|
+
items.forEach((b, i) => b.classList.toggle('sel', i === palSel));
|
|
1617
|
+
items[palSel].scrollIntoView({ block: 'nearest' });
|
|
1618
|
+
});
|
|
1619
|
+
$('palette').addEventListener('click', (e) => { if (e.target === $('palette')) closePalette(); });
|
|
1620
|
+
|
|
1621
|
+
// CAPTURE phase on document: fires BEFORE the focused xterm textarea's own keydown, so ⌘K
|
|
1622
|
+
// works even while a terminal holds focus (xterm otherwise swallows the key). The xterm
|
|
1623
|
+
// custom handler above is belt-and-suspenders so the K never reaches the PTY.
|
|
1624
|
+
document.addEventListener('keydown', (e) => {
|
|
1625
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
|
1626
|
+
e.preventDefault(); e.stopPropagation(); togglePalette(); return;
|
|
1627
|
+
}
|
|
1628
|
+
if (e.key === 'F2' && state.active && !state.renaming) { e.preventDefault(); startRename(state.active); return; }
|
|
1629
|
+
if (e.key === 'Escape') {
|
|
1630
|
+
if ($('palette').style.display === 'grid') { e.stopPropagation(); closePalette(); }
|
|
1631
|
+
else if ($('fsPicker').style.display === 'grid') { e.stopPropagation(); closeFsPicker(); }
|
|
1632
|
+
else if (state.renaming) { e.stopPropagation(); cancelRename(); renderSessions(); }
|
|
1633
|
+
else if (!$('newForm').hidden) closeNewForm();
|
|
1634
|
+
else if (!$('addWsForm').hidden) closeAddWs();
|
|
1635
|
+
else if (!$('paneDoc').hidden) closeDoc();
|
|
1636
|
+
}
|
|
1637
|
+
}, true);
|
|
1638
|
+
|
|
1639
|
+
// ── mobile (≤760px): single-column pane switcher + a terminal control-key accessory bar ──────
|
|
1640
|
+
// Phones can't span three columns and lack Esc/Tab/Ctrl/arrows, so the workspace collapses to one
|
|
1641
|
+
// pane at a time (terminal is the priority pane) with a bottom switcher, and the control bar sends
|
|
1642
|
+
// the missing key sequences straight to the active session's PTY. Desktop is untouched (CSS-gated).
|
|
1643
|
+
|
|
1644
|
+
// Ctrl one-shot: when the mobile Ctrl key is armed, the NEXT byte from the terminal (a soft-keyboard
|
|
1645
|
+
// character) is converted to its control code (Ctrl-C = 0x03, …), then Ctrl disarms. Desktop typing
|
|
1646
|
+
// never arms it, so this is a no-op everywhere except after a Ctrl tap.
|
|
1647
|
+
function setCtrlArmed(on) {
|
|
1648
|
+
state.ctrlArmed = on;
|
|
1649
|
+
const b = $('ctrlKey'); if (b) b.setAttribute('aria-pressed', String(on));
|
|
1650
|
+
}
|
|
1651
|
+
function applyCtrl(d) {
|
|
1652
|
+
if (!state.ctrlArmed) return d;
|
|
1653
|
+
setCtrlArmed(false);
|
|
1654
|
+
if (d.length === 1) {
|
|
1655
|
+
const c = d.charCodeAt(0);
|
|
1656
|
+
if (c >= 97 && c <= 122) return String.fromCharCode(c - 96); // a–z → 0x01–0x1a
|
|
1657
|
+
if (c >= 65 && c <= 90) return String.fromCharCode(c - 64); // A–Z → 0x01–0x1a
|
|
1658
|
+
if (c >= 91 && c <= 95) return String.fromCharCode(c - 64); // [ \ ] ^ _ → 0x1b–0x1f
|
|
1659
|
+
if (c === 32) return '\x00'; // Ctrl-Space → NUL
|
|
1660
|
+
}
|
|
1661
|
+
return d;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
(function mobile() {
|
|
1665
|
+
const grid = $('bodyGrid');
|
|
1666
|
+
const menu = $('mobMenu');
|
|
1667
|
+
const MQ = window.matchMedia('(max-width:760px)');
|
|
1668
|
+
|
|
1669
|
+
// send a raw sequence to the active PTY, then keep focus in the terminal (raises the soft keyboard)
|
|
1670
|
+
function key(seq) {
|
|
1671
|
+
if (!state.active) return;
|
|
1672
|
+
send({ t: 'input', id: state.active, data: seq });
|
|
1673
|
+
const t = state.terms.get(state.active); if (t) t.term.focus();
|
|
1674
|
+
}
|
|
1675
|
+
const SEQ = { esc: '\x1b', tab: '\t', up: '\x1b[A', down: '\x1b[B', left: '\x1b[D', right: '\x1b[C' };
|
|
1676
|
+
document.querySelectorAll('#ctrlBar [data-seq]').forEach((b) => b.onclick = () => key(SEQ[b.dataset.seq]));
|
|
1677
|
+
$('ctrlKey').onclick = () => {
|
|
1678
|
+
setCtrlArmed(!state.ctrlArmed);
|
|
1679
|
+
if (state.active) { const t = state.terms.get(state.active); if (t) t.term.focus(); }
|
|
1680
|
+
};
|
|
1681
|
+
$('mobPalette').onclick = () => togglePalette();
|
|
1682
|
+
|
|
1683
|
+
// the switcher: one column at a time, terminal is the default (priority) pane, choice persisted
|
|
1684
|
+
function setView(v) {
|
|
1685
|
+
grid.dataset.mview = v;
|
|
1686
|
+
menu.dataset.view = v;
|
|
1687
|
+
document.querySelectorAll('.mobtabs [data-mview]').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.mview === v)));
|
|
1688
|
+
store.set('mview', v);
|
|
1689
|
+
if (v === 'terminal') {
|
|
1690
|
+
refit();
|
|
1691
|
+
if (state.active) { const t = state.terms.get(state.active); if (t) setTimeout(() => t.term.focus(), 0); }
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
const remembered = store.get('mview');
|
|
1695
|
+
setView(['sessions', 'terminal', 'library'].includes(remembered) ? remembered : 'terminal');
|
|
1696
|
+
document.querySelectorAll('.mobtabs [data-mview]').forEach((b) => b.onclick = () => setView(b.dataset.mview));
|
|
1697
|
+
|
|
1698
|
+
// FitAddon must re-fit when the soft keyboard shows/hides (visualViewport) and on rotation, or
|
|
1699
|
+
// xterm renders more rows than fit and the bottom is clipped (the R9 failure, on a phone).
|
|
1700
|
+
if (window.visualViewport) {
|
|
1701
|
+
window.visualViewport.addEventListener('resize', refit);
|
|
1702
|
+
window.visualViewport.addEventListener('scroll', refit);
|
|
1703
|
+
}
|
|
1704
|
+
window.addEventListener('orientationchange', () => setTimeout(refit, 250));
|
|
1705
|
+
// crossing the breakpoint (rotate a tablet, resize a window) must re-fit the terminal
|
|
1706
|
+
const onMQ = () => refit();
|
|
1707
|
+
if (MQ.addEventListener) MQ.addEventListener('change', onMQ); else if (MQ.addListener) MQ.addListener(onMQ);
|
|
1708
|
+
})();
|
|
1709
|
+
|
|
1710
|
+
connect();
|