create-pmos 0.37.0 → 0.38.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/bin/create-pmos.js +22 -3
- package/package.json +1 -1
- package/payload/AGENTS.md +4 -0
- package/payload/KIT-VERSION +2 -2
- package/payload/okf/core/concepts/frontmatter-format.md +27 -61
- package/payload/okf/core/templates/decision-record-template.md +34 -0
- package/payload/okf/core/templates/index.md +7 -2
- package/payload/okf/core/templates/pm-brief-template.md +34 -0
- package/payload/okf/core/templates/project-plan-template.md +43 -0
- package/payload/okf/core/templates/report-weekly-template.md +35 -0
- package/payload/okf/core/templates/strategy-long-range-template.md +37 -0
- package/payload/okf/core/templates/strategy-one-pager-template.md +34 -0
- package/payload/planning/evals/eval-calibration.md +8 -0
- package/payload/planning/okrs/health-budgets.md +8 -0
- package/payload/scripts/anchor-resolve.js +56 -6
- package/payload/scripts/gate-initiative.sh +32 -0
- package/payload/scripts/okf-upgrade.js +232 -0
- package/payload/scripts/pmos-memory.js +375 -0
- package/payload/scripts/pmos-model.js +239 -0
- package/payload/scripts/pmos-skills.js +274 -0
- package/payload/scripts/workflow-state.js +178 -10
- package/payload/scripts/workflow-sweep.js +2 -1
- package/payload/state/discoveries.md +8 -0
- package/payload/state/index.md +7 -0
- package/payload/state/log.md +7 -0
- package/workspace/README.md +61 -204
- package/workspace/lib/board-cache.js +18 -8
- package/workspace/lib/console-state.js +2 -0
- package/workspace/lib/engine/anchor-resolve.js +56 -6
- package/workspace/lib/engine/pmos-memory.js +375 -0
- package/workspace/lib/engine/pmos-model.js +239 -0
- package/workspace/lib/engine/pmos-skills.js +274 -0
- package/workspace/lib/engine/workflow-state.js +178 -10
- package/workspace/lib/focus.js +5 -6
- package/workspace/lib/memory.js +54 -0
- package/workspace/lib/models.js +50 -0
- package/workspace/lib/preview-kind.js +12 -1
- package/workspace/lib/schedule-logic.js +31 -2
- package/workspace/lib/skill-store.js +5 -1
- package/workspace/lib/skills-registry.js +74 -0
- package/workspace/lib/sync.js +132 -0
- package/workspace/lib/tree-share.js +2 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +3 -3
- package/workspace/public/pm/app.js +32 -2
- package/workspace/public/pm/data.js +160 -10
- package/workspace/public/pm/lifecycle.js +105 -0
- package/workspace/public/pm/markdown.js +10 -1
- package/workspace/public/pm/modes-live.js +371 -0
- package/workspace/public/pm/modes.js +7 -4
- package/workspace/public/pm/panel-detail.js +22 -2
- package/workspace/public/pm/panel-initiatives.js +57 -12
- package/workspace/public/pm/panel-newsess.js +25 -0
- package/workspace/public/pm/panel-repos.js +130 -4
- package/workspace/public/pm/panel-tabs.js +49 -2
- package/workspace/public/pm/state.js +11 -1
- package/workspace/public/pm/tab-doc.js +160 -26
- package/workspace/public/pm/tab-term.js +2 -0
- package/workspace/public/pm/tab-web.js +89 -0
- package/workspace/public/pm/tokens.css +4 -0
- package/workspace/public/pm/xlsx-read.js +117 -0
- package/workspace/server.js +690 -40
- package/workspace/skills/gather-requirements.skill +53 -0
- package/workspace/skills/memory-groom.skill +46 -0
- package/workspace/skills/report-biweekly.skill +53 -0
- package/workspace/skills/report-daily.skill +53 -0
- package/workspace/skills/report-monthly.skill +53 -0
- package/workspace/skills/report-special.skill +49 -0
- package/workspace/skills/report-weekly.skill +53 -0
- package/workspace/skills/skill-discover.skill +49 -0
- package/workspace/skills/skill-generate.skill +33 -0
|
@@ -56,6 +56,12 @@ import('./data.js').then((m) => { dataMod = m; }).catch(() => { dataMod = null;
|
|
|
56
56
|
|
|
57
57
|
/** The workspace id the doc endpoints key on. The data layer names it; these are its aliases. */
|
|
58
58
|
const wsId = () => state.wsId || state.ws || state.product || '';
|
|
59
|
+
/* overhaul-r1 R1.3 — a doc tab opened from a search hit on a linked worktree carries that tree's
|
|
60
|
+
* `ws`, and is read from and saved to THAT tree. Two trees can hold the same path with different
|
|
61
|
+
* content, so every per-document cache keys on tree + path; a main-tree tab keys on path alone,
|
|
62
|
+
* exactly as before. */
|
|
63
|
+
const docKey = (tab) => (tab && tab.ws ? tab.ws + '\u241f' : '') + (tab ? tab.path : '');
|
|
64
|
+
const wsOf = (tab) => (tab && tab.ws) || wsId();
|
|
59
65
|
|
|
60
66
|
/* A REFERENCE is not a file. Panel 3's links section files `jira://ATL-501`, `confluence://PM/x`,
|
|
61
67
|
* `gdoc://x-brief` — URIs naming a record in someone else's system, deliberately kept out of the
|
|
@@ -87,13 +93,13 @@ const stub = (tab, body) =>
|
|
|
87
93
|
|
|
88
94
|
/** Read a document from the local server, once, then repaint. */
|
|
89
95
|
function requestDoc(tab) {
|
|
90
|
-
const path = tab
|
|
96
|
+
const path = docKey(tab);
|
|
91
97
|
if (pending.has(path)) return;
|
|
92
98
|
pending.add(path);
|
|
93
99
|
const done = (text) => { pending.delete(path); disk.set(path, text); setState({}); };
|
|
94
100
|
const load = (dataMod && typeof dataMod.readDoc === 'function')
|
|
95
|
-
? dataMod.readDoc(path)
|
|
96
|
-
: fetch('/api/doc?ws=' + encodeURIComponent(
|
|
101
|
+
? dataMod.readDoc(tab.path, tab.ws)
|
|
102
|
+
: fetch('/api/doc?ws=' + encodeURIComponent(wsOf(tab)) + '&path=' + encodeURIComponent(tab.path))
|
|
97
103
|
.then((r) => (r.ok ? r.text() : Promise.reject(new Error('HTTP ' + r.status))));
|
|
98
104
|
Promise.resolve(load).then(
|
|
99
105
|
(text) => { unread.delete(path); done(typeof text === 'string' ? text : String(text == null ? '' : text)); },
|
|
@@ -111,11 +117,11 @@ function requestDoc(tab) {
|
|
|
111
117
|
*/
|
|
112
118
|
export function docText(tab) {
|
|
113
119
|
if (!tab || !tab.path) return '';
|
|
114
|
-
const saved = state.savedDocs[tab
|
|
120
|
+
const saved = state.savedDocs[docKey(tab)];
|
|
115
121
|
if (saved != null) return saved;
|
|
116
122
|
if (FIXTURE) return FX.rawFor(tab, state.savedDocs);
|
|
117
123
|
if (isReference(tab.path)) return referenceStub(tab);
|
|
118
|
-
if (disk.has(tab
|
|
124
|
+
if (disk.has(docKey(tab))) return disk.get(docKey(tab));
|
|
119
125
|
requestDoc(tab);
|
|
120
126
|
return stub(tab, 'reading ' + tab.path + ' …');
|
|
121
127
|
}
|
|
@@ -129,26 +135,29 @@ export function docText(tab) {
|
|
|
129
135
|
* than silently overwritten; the refusal is shown where the PM is looking and the buffer is kept.
|
|
130
136
|
*/
|
|
131
137
|
function save(tab, text) {
|
|
132
|
-
const path = tab
|
|
133
|
-
|
|
138
|
+
const path = docKey(tab);
|
|
139
|
+
// R11 — the server may stamp `generated:` with the PM as author, so what is on disk is not exactly what was sent.
|
|
140
|
+
// The saved text it returns becomes the new base; keeping the old one would make the NEXT save a 409.
|
|
141
|
+
const commit = (final) => setState((s) => {
|
|
134
142
|
const de = { ...s.docEdits }; delete de[path];
|
|
135
|
-
return { savedDocs: { ...s.savedDocs, [path]: text }, docEdits: de };
|
|
143
|
+
return { savedDocs: { ...s.savedDocs, [path]: typeof final === 'string' ? final : text }, docEdits: de };
|
|
136
144
|
});
|
|
137
145
|
saveErr.delete(path);
|
|
138
146
|
if (FIXTURE) { commit(); return; }
|
|
139
147
|
|
|
140
148
|
const base = disk.has(path) ? disk.get(path) : (state.savedDocs[path] ?? '');
|
|
141
149
|
const write = (dataMod && typeof dataMod.saveDoc === 'function')
|
|
142
|
-
? dataMod.saveDoc({ ws:
|
|
150
|
+
? dataMod.saveDoc({ ws: wsOf(tab), path: tab.path, base, text })
|
|
143
151
|
: fetch('/api/doc/save', {
|
|
144
152
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
145
|
-
body: JSON.stringify({ ws:
|
|
153
|
+
body: JSON.stringify({ ws: wsOf(tab), path: tab.path, base, text }),
|
|
146
154
|
}).then((r) => r.json());
|
|
147
155
|
Promise.resolve(write).then(
|
|
148
156
|
(out) => {
|
|
149
157
|
if (out && out.ok === false) { saveErr.set(path, out.error || 'refused'); setState({}); return; }
|
|
150
|
-
|
|
151
|
-
|
|
158
|
+
const final = out && typeof out.text === 'string' ? out.text : text;
|
|
159
|
+
disk.set(path, final);
|
|
160
|
+
commit(final);
|
|
152
161
|
},
|
|
153
162
|
(e) => { saveErr.set(path, (e && e.message) || 'could not reach the workspace server'); setState({}); },
|
|
154
163
|
);
|
|
@@ -156,8 +165,8 @@ function save(tab, text) {
|
|
|
156
165
|
|
|
157
166
|
/** Revert (line 2043): drop the buffer, keep the file. */
|
|
158
167
|
function revert(tab) {
|
|
159
|
-
saveErr.delete(tab
|
|
160
|
-
setState((s) => { const de = { ...s.docEdits }; delete de[tab
|
|
168
|
+
saveErr.delete(docKey(tab));
|
|
169
|
+
setState((s) => { const de = { ...s.docEdits }; delete de[docKey(tab)]; return { docEdits: de }; });
|
|
161
170
|
}
|
|
162
171
|
|
|
163
172
|
/* ── the four bodies ───────────────────────────────────────────────────────────────────────── */
|
|
@@ -204,16 +213,28 @@ function scrollPane(tab, mode, key, style, build) {
|
|
|
204
213
|
/** What links and images in this document resolve against. `onDoc` opens a .md link as a doc tab. */
|
|
205
214
|
const linkCtx = (tab) => ({
|
|
206
215
|
dir: String(tab.path || '').split('/').slice(0, -1),
|
|
207
|
-
ws:
|
|
216
|
+
ws: wsOf(tab),
|
|
208
217
|
onDoc: (p) => openDoc(tab.slug, p, 'FILE'),
|
|
218
|
+
// overhaul-r5 (D69) — ⧉ beside a remote link opens it as a web tab, on the PM's click only
|
|
219
|
+
onWeb: FIXTURE ? undefined : (u) => import('./tab-web.js').then((m) => m.openWeb(u)),
|
|
209
220
|
});
|
|
210
221
|
|
|
222
|
+
/* overhaul-r1 R5 — the reading measure is the PM's choice per tab: full width, or 72ch. With no
|
|
223
|
+
* choice made it is full on a desktop and 72ch on a phone (≤760px), where 72ch IS the screen. */
|
|
224
|
+
const narrowOf = (tab) => {
|
|
225
|
+
const v = (state.docNarrow || {})[tab.id];
|
|
226
|
+
if (typeof v === 'boolean') return v;
|
|
227
|
+
if (FIXTURE) return true; // the prototype's recorded rendering, which the comparator reads
|
|
228
|
+
return typeof window !== 'undefined' && window.matchMedia ? window.matchMedia('(max-width: 760px)').matches : false;
|
|
229
|
+
};
|
|
230
|
+
|
|
211
231
|
function viewBody(tab, text) {
|
|
212
|
-
|
|
232
|
+
const narrow = narrowOf(tab);
|
|
233
|
+
return scrollPane(tab, 'view', (narrow ? 'n\u0000' : 'f\u0000') + text, VIEW_ST, () => {
|
|
213
234
|
const doc = mdRender(text, linkCtx(tab));
|
|
214
235
|
const fm = [];
|
|
215
236
|
doc.fm.forEach((fp) => { fm.push(el('span', { style: FM_K }, iv(fp.k)), el('span', { style: FM_V }, iv(fp.v))); });
|
|
216
|
-
return [el('div', { style: 'max-width:72ch' },
|
|
237
|
+
return [el('div', { style: narrow ? 'max-width:72ch' : 'max-width:none', 'data-measure': narrow ? '72ch' : 'full' },
|
|
217
238
|
el('div', {
|
|
218
239
|
style: 'border:1px solid var(--border);background:var(--card);padding:10px 12px;margin-bottom:18px;display:grid;grid-template-columns:auto 1fr;gap:4px 14px;font-size:12.5px',
|
|
219
240
|
}, fm),
|
|
@@ -257,10 +278,11 @@ const isHtmlDoc = (p) => {
|
|
|
257
278
|
* nothing was read from it to render. */
|
|
258
279
|
function rendersAsPage(tab) {
|
|
259
280
|
const p = tab.path;
|
|
281
|
+
const k = docKey(tab);
|
|
260
282
|
if (!isHtmlDoc(p) || isReference(p)) return false;
|
|
261
|
-
if (state.savedDocs[
|
|
283
|
+
if (state.savedDocs[k] != null) return true;
|
|
262
284
|
if (FIXTURE) return true;
|
|
263
|
-
return disk.has(
|
|
285
|
+
return disk.has(k) && !unread.has(k);
|
|
264
286
|
}
|
|
265
287
|
|
|
266
288
|
/* Rendering an HTML document adds no privilege over showing it. The frame is fully sandboxed —
|
|
@@ -325,8 +347,8 @@ function editBox(tab) {
|
|
|
325
347
|
style: 'flex:1;min-height:0;background:var(--background);color:var(--foreground);padding:14px 16px;font-family:var(--mono);font-size:12px;line-height:1.7',
|
|
326
348
|
onInput: (e) => {
|
|
327
349
|
const v = e.target.value;
|
|
328
|
-
saveErr.delete(tab
|
|
329
|
-
setState((s) => ({ docEdits: { ...s.docEdits, [tab
|
|
350
|
+
saveErr.delete(docKey(tab));
|
|
351
|
+
setState((s) => ({ docEdits: { ...s.docEdits, [docKey(tab)]: v } }));
|
|
330
352
|
},
|
|
331
353
|
});
|
|
332
354
|
ta.spellcheck = false; // a property, not an attribute — the prototype renders no attribute
|
|
@@ -348,12 +370,117 @@ export function disposeDoc(id) {
|
|
|
348
370
|
* Render the doc pane for `tab` into `host` — the pane element panel 4 keeps for this tab.
|
|
349
371
|
* Everything but the edit box is rebuilt on each paint; the box is kept so typing survives it.
|
|
350
372
|
*/
|
|
373
|
+
/* overhaul-r2 D1 — a PDF is never read as text. It is embedded from /api/asset (the workspace's own
|
|
374
|
+
* guarded route) in the browser's built-in viewer, with a plain link as the fallback for a browser
|
|
375
|
+
* that will not render it inline. Nothing leaves the machine. */
|
|
376
|
+
const extOfPath = (p) => { const b = String(p || '').split('/').pop().toLowerCase(); const i = b.lastIndexOf('.'); return i > 0 ? b.slice(i + 1) : ''; };
|
|
377
|
+
function pdfBody(tab) {
|
|
378
|
+
const src = '/api/asset?ws=' + encodeURIComponent(wsOf(tab)) + '&path=' + encodeURIComponent(tab.path);
|
|
379
|
+
return el('div', { style: 'flex:1;display:flex;flex-direction:column;min-height:0', 'data-r2': 'pdf' },
|
|
380
|
+
el('div', { style: 'flex:none;padding:6px 10px;border-bottom:1px solid var(--border);font-family:var(--mono);font-size:10.5px;color:var(--muted-foreground)' },
|
|
381
|
+
iv('PDF · read-only · '),
|
|
382
|
+
el('a', { href: src, target: '_blank', rel: 'noopener', 'data-r2': 'pdf-open' }, 'open in a browser tab')),
|
|
383
|
+
el('embed', { src, type: 'application/pdf', style: 'flex:1;width:100%;min-height:0;border:0;background:var(--card)' }),
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/* overhaul-r2 D2 — a CSV/TSV reads as a grid: every cell is text (escaped by construction, never
|
|
388
|
+
* markup), the first row is the header, and a cap is STATED rather than silently applied. */
|
|
389
|
+
const GRID_CAP = 2000;
|
|
390
|
+
function parseDelimited(text, sep) {
|
|
391
|
+
const rows = [];
|
|
392
|
+
let row = [], cell = '', q = false;
|
|
393
|
+
for (let i = 0; i < text.length; i++) {
|
|
394
|
+
const ch = text[i];
|
|
395
|
+
if (q) {
|
|
396
|
+
if (ch === '"' && text[i + 1] === '"') { cell += '"'; i++; }
|
|
397
|
+
else if (ch === '"') q = false;
|
|
398
|
+
else cell += ch;
|
|
399
|
+
} else if (ch === '"' && cell === '') q = true;
|
|
400
|
+
else if (ch === sep) { row.push(cell); cell = ''; }
|
|
401
|
+
else if (ch === '\n' || ch === '\r') {
|
|
402
|
+
if (ch === '\r' && text[i + 1] === '\n') i++;
|
|
403
|
+
row.push(cell); rows.push(row); row = []; cell = '';
|
|
404
|
+
if (rows.length > GRID_CAP + 1) break; // header + cap + one more, to KNOW there is more
|
|
405
|
+
} else cell += ch;
|
|
406
|
+
}
|
|
407
|
+
if (cell !== '' || row.length) { row.push(cell); rows.push(row); }
|
|
408
|
+
return rows;
|
|
409
|
+
}
|
|
410
|
+
function gridBody(tab, text) {
|
|
411
|
+
// Until the file has really been read, `text` is the reader's placeholder stub — never parse that
|
|
412
|
+
// as data (it rendered as an 8-row, 1-column "CSV" while the read was in flight).
|
|
413
|
+
const k = docKey(tab);
|
|
414
|
+
const loaded = state.savedDocs[k] != null || (disk.has(k) && !unread.has(k));
|
|
415
|
+
if (!loaded) return el('div', { style: VIEW_ST }, iv(unread.has(k) ? 'this file could not be read' : 'reading ' + tab.path + ' …'));
|
|
416
|
+
return scrollPane(tab, 'grid', text, VIEW_ST, () => {
|
|
417
|
+
const rows = parseDelimited(text, extOfPath(tab.path) === 'tsv' ? '\t' : ',');
|
|
418
|
+
const head = rows[0] || [];
|
|
419
|
+
const body = rows.slice(1, GRID_CAP + 1);
|
|
420
|
+
const capped = rows.length > GRID_CAP + 1;
|
|
421
|
+
const cellSt = 'border:1px solid var(--border);padding:3px 7px;font-family:var(--mono);font-size:11.5px;text-align:left;white-space:nowrap';
|
|
422
|
+
return [
|
|
423
|
+
el('div', { 'data-r2': 'grid-meta', style: 'font-family:var(--mono);font-size:10.5px;color:var(--muted-foreground);margin-bottom:8px' },
|
|
424
|
+
iv(body.length + ' row' + (body.length === 1 ? '' : 's') + ' · ' + head.length + ' column' + (head.length === 1 ? '' : 's')
|
|
425
|
+
+ (capped ? ' · showing the first ' + GRID_CAP + ' rows — the rest are in raw' : ''))),
|
|
426
|
+
el('div', { style: 'overflow:auto' },
|
|
427
|
+
el('table', { 'data-r2': 'grid', style: 'border-collapse:collapse' },
|
|
428
|
+
el('thead', null, el('tr', null, head.map((h) => el('th', { style: cellSt + ';background:var(--secondary)' }, iv(h))))),
|
|
429
|
+
el('tbody', null, body.map((r) => el('tr', null, r.map((c) => el('td', { style: cellSt }, iv(c)))))))),
|
|
430
|
+
];
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/* overhaul-r3 P10 — a workbook, read in the browser by xlsx-read.js (no dependency, no network).
|
|
435
|
+
* Loaded once per document and cached; a sheet bar switches sheets; every cell is text. */
|
|
436
|
+
const books = new Map(); // docKey -> { state: 'loading' | 'ok' | 'error', book?, error? }
|
|
437
|
+
const bookSheet = new Map(); // tab.id -> sheet index
|
|
438
|
+
function sheetBody(tab) {
|
|
439
|
+
const k = docKey(tab);
|
|
440
|
+
let b = books.get(k);
|
|
441
|
+
if (!b) {
|
|
442
|
+
b = { state: 'loading' };
|
|
443
|
+
books.set(k, b);
|
|
444
|
+
const src = '/api/asset?ws=' + encodeURIComponent(wsOf(tab)) + '&path=' + encodeURIComponent(tab.path);
|
|
445
|
+
Promise.all([fetch(src).then((r) => (r.ok ? r.arrayBuffer() : Promise.reject(new Error('HTTP ' + r.status)))), import('./xlsx-read.js')])
|
|
446
|
+
.then(([buf, mod]) => mod.readXlsx(buf, GRID_CAP))
|
|
447
|
+
.then((book) => { books.set(k, { state: 'ok', book }); setState({}); },
|
|
448
|
+
(e) => { books.set(k, { state: 'error', error: (e && e.message) || String(e) }); setState({}); });
|
|
449
|
+
}
|
|
450
|
+
const wrap = (kids) => el('div', { style: VIEW_ST, 'data-r3': 'sheet' }, kids);
|
|
451
|
+
if (b.state === 'loading') return wrap([iv('reading ' + tab.path + ' …')]);
|
|
452
|
+
if (b.state === 'error') return wrap([el('div', { style: 'color:var(--destructive)' }, iv('this workbook could not be read — ' + b.error))]);
|
|
453
|
+
const sheets = b.book.sheets;
|
|
454
|
+
const cur = Math.min(bookSheet.get(tab.id) || 0, Math.max(0, sheets.length - 1));
|
|
455
|
+
const sh = sheets[cur] || { name: '—', rows: [] };
|
|
456
|
+
const head = sh.rows[0] || [];
|
|
457
|
+
const width = sh.rows.reduce((n, r) => Math.max(n, r.length), 0);
|
|
458
|
+
const cellSt = 'border:1px solid var(--border);padding:3px 7px;font-family:var(--mono);font-size:11.5px;text-align:left;white-space:nowrap';
|
|
459
|
+
const pad = (r) => Array.from({ length: width }, (_, i) => r[i] ?? '');
|
|
460
|
+
return wrap([
|
|
461
|
+
el('div', { 'data-r3': 'sheet-bar', style: 'display:flex;gap:4px;flex-wrap:wrap;margin-bottom:8px' },
|
|
462
|
+
sheets.map((x, i) => el('button', {
|
|
463
|
+
onClick: () => { bookSheet.set(tab.id, i); setState({}); },
|
|
464
|
+
style: 'font-family:var(--mono);font-size:10.5px;padding:3px 9px;border:1px solid var(--border);background:' + (i === cur ? 'var(--accent)' : 'transparent'),
|
|
465
|
+
}, iv(x.name)))),
|
|
466
|
+
el('div', { 'data-r2': 'grid-meta', style: 'font-family:var(--mono);font-size:10.5px;color:var(--muted-foreground);margin-bottom:8px' },
|
|
467
|
+
iv(Math.max(0, sh.rows.length - 1) + ' rows · ' + width + ' columns · values only (formulas show their cached value; dates are serial numbers)'
|
|
468
|
+
+ (sh.truncated ? ' · showing the first ' + GRID_CAP + ' rows' : '') + (sh.error ? ' · ' + sh.error : ''))),
|
|
469
|
+
el('div', { style: 'overflow:auto' },
|
|
470
|
+
el('table', { 'data-r2': 'grid', style: 'border-collapse:collapse' },
|
|
471
|
+
el('thead', null, el('tr', null, pad(head).map((h) => el('th', { style: cellSt + ';background:var(--secondary)' }, iv(h))))),
|
|
472
|
+
el('tbody', null, sh.rows.slice(1).map((r) => el('tr', null, pad(r).map((c) => el('td', { style: cellSt }, iv(c)))))))),
|
|
473
|
+
]);
|
|
474
|
+
}
|
|
475
|
+
|
|
351
476
|
export function mountDoc(host, tab) {
|
|
477
|
+
if (!FIXTURE && extOfPath(tab.path) === 'pdf') { setChildren(host, [pdfBody(tab)]); return; }
|
|
478
|
+
if (!FIXTURE && extOfPath(tab.path) === 'xlsx') { setChildren(host, [sheetBody(tab)]); return; }
|
|
352
479
|
const raw = docText(tab);
|
|
353
|
-
const text = state.docEdits[tab
|
|
480
|
+
const text = state.docEdits[docKey(tab)] ?? raw;
|
|
354
481
|
const dm = state.docModes[tab.id] ?? 'view';
|
|
355
482
|
const dirty = text !== raw;
|
|
356
|
-
const err = saveErr.get(tab
|
|
483
|
+
const err = saveErr.get(docKey(tab));
|
|
357
484
|
|
|
358
485
|
const modeBtns = ['view', 'raw', 'edit', 'diff'].map((mm) => el('button', {
|
|
359
486
|
onClick: () => setState((s) => ({ docModes: { ...s.docModes, [tab.id]: mm } })),
|
|
@@ -385,7 +512,7 @@ export function mountDoc(host, tab) {
|
|
|
385
512
|
* clean file dirty for nothing is how a tool teaches you to ignore its dirty flag. */
|
|
386
513
|
const norm = typeof window !== 'undefined' ? window.MdNormalize : null;
|
|
387
514
|
const canNormalize = !!(norm && typeof norm.normalize === 'function')
|
|
388
|
-
&& !isHtmlDoc(tab.path) && !isReference(tab.path) && !unread.has(tab
|
|
515
|
+
&& !isHtmlDoc(tab.path) && !isReference(tab.path) && !unread.has(docKey(tab));
|
|
389
516
|
const normBtn = canNormalize ? el('button', {
|
|
390
517
|
onClick: () => {
|
|
391
518
|
const r = norm.normalize(text);
|
|
@@ -393,7 +520,7 @@ export function mountDoc(host, tab) {
|
|
|
393
520
|
? r.joins + ' line(s) joined' + (r.blanks ? ', ' + r.blanks + ' blank inserted' : '')
|
|
394
521
|
: 'already normalized — every paragraph is one line');
|
|
395
522
|
if (!r.changed) { setState({}); return; }
|
|
396
|
-
setState((s) => ({ docEdits: { ...s.docEdits, [tab
|
|
523
|
+
setState((s) => ({ docEdits: { ...s.docEdits, [docKey(tab)]: r.text } }));
|
|
397
524
|
},
|
|
398
525
|
style: 'margin-left:8px;font-family:var(--mono);font-size:10.5px;padding:4px 10px;min-height:26px;background:var(--secondary);border:1px solid var(--border);color:var(--muted-foreground)',
|
|
399
526
|
hover: 'border-color:var(--ring)',
|
|
@@ -413,6 +540,12 @@ export function mountDoc(host, tab) {
|
|
|
413
540
|
style: 'flex:none;display:flex;gap:2px;align-items:center;padding:6px 10px;border-bottom:1px solid var(--border);background:var(--background)',
|
|
414
541
|
},
|
|
415
542
|
modeBtns, normBtn, saveBtn, revertBtn,
|
|
543
|
+
FIXTURE || dm !== 'view' ? null : el('button', {
|
|
544
|
+
onClick: () => setState((s) => ({ docNarrow: { ...(s.docNarrow || {}), [tab.id]: !narrowOf(tab) } })),
|
|
545
|
+
style: 'margin-left:8px;font-family:var(--mono);font-size:10.5px;padding:4px 10px;min-height:26px;background:var(--secondary);border:1px solid var(--border);color:var(--muted-foreground)',
|
|
546
|
+
hover: 'color:var(--foreground)', 'data-r1': 'measure',
|
|
547
|
+
title: narrowOf(tab) ? 'use the full width of the tab' : 'limit lines to a 72-character reading measure',
|
|
548
|
+
}, iv(narrowOf(tab) ? '↔ full width' : '⇥ 72ch')),
|
|
416
549
|
el('span', { style: 'margin-left:auto;font-family:var(--mono);font-size:10px;color:' + dirtyColor }, iv(dirtyLabel)),
|
|
417
550
|
);
|
|
418
551
|
|
|
@@ -427,7 +560,8 @@ export function mountDoc(host, tab) {
|
|
|
427
560
|
* genuinely differs, which is what keeps the caret still mid-sentence. */
|
|
428
561
|
if (body.defaultValue !== text) body.defaultValue = text;
|
|
429
562
|
if (body.value !== text) body.value = text;
|
|
430
|
-
} else
|
|
563
|
+
} else if (!FIXTURE && (extOfPath(tab.path) === 'csv' || extOfPath(tab.path) === 'tsv')) body = gridBody(tab, text);
|
|
564
|
+
else body = rendersAsPage(tab) ? htmlFrame(tab, text) : viewBody(tab, text);
|
|
431
565
|
|
|
432
566
|
setChildren(host, [modeBar, body]);
|
|
433
567
|
return host;
|
|
@@ -252,6 +252,8 @@ export function requestSpawn(opts) {
|
|
|
252
252
|
createBranch: !!o.createBranch,
|
|
253
253
|
isolate: o.isolate === undefined ? null : o.isolate,
|
|
254
254
|
profile: o.profile === undefined ? null : o.profile,
|
|
255
|
+
// D75 — a tier the PM picked (fast | balanced | deep). Absent = "auto": the server routes by policy, or asks for nothing.
|
|
256
|
+
...(o.tier ? { tier: o.tier } : {}),
|
|
255
257
|
},
|
|
256
258
|
};
|
|
257
259
|
SOCK.spawnQueue.push(req);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/* tab-web.js — the `web` tab kind (overhaul-r5, decision D69).
|
|
2
|
+
*
|
|
3
|
+
* A web page in a tab, opened ONLY by the PM: typed into the 🌐 control, or chosen with the ⧉ beside a
|
|
4
|
+
* document's link. It never loads by itself — a tab restored after a reload shows its address and
|
|
5
|
+
* waits for "load this page". The page is loaded by the browser, directly from its origin, in a
|
|
6
|
+
* sandboxed frame; the PMOS server never fetches, proxies or stores it. Only http: and https:.
|
|
7
|
+
*/
|
|
8
|
+
import { el, setChildren } from './dom.js';
|
|
9
|
+
import { state, setState } from './state.js';
|
|
10
|
+
|
|
11
|
+
const iv = (v) => el('span', null, v === null || v === undefined ? '' : String(v));
|
|
12
|
+
const BTN = 'font-family:var(--mono);font-size:10.5px;padding:3px 9px;border:1px solid var(--border);color:var(--muted-foreground);min-height:26px';
|
|
13
|
+
const SANDBOX = 'allow-scripts allow-forms allow-popups allow-same-origin';
|
|
14
|
+
|
|
15
|
+
/* Tabs whose page the PM loaded IN THIS PAGE'S LIFETIME. Deliberately not persisted: after a reload
|
|
16
|
+
* the set is empty, so every restored web tab waits for a press (D69 rule 2). */
|
|
17
|
+
const loaded = new Set();
|
|
18
|
+
|
|
19
|
+
/** `{ ok, url }` for a full http(s) URL, else `{ ok:false, error }`. No scheme is guessed: an
|
|
20
|
+
* address without one (or a protocol-relative one) is refused with the reason, never completed. */
|
|
21
|
+
export function checkUrl(raw) {
|
|
22
|
+
const s = String(raw == null ? '' : raw).trim();
|
|
23
|
+
if (!s) return { ok: false, error: 'no address given' };
|
|
24
|
+
if (!/^[a-z][a-z0-9+.-]*:/i.test(s)) return { ok: false, error: `"${s}" has no scheme — type the full address, starting with http or https (D69)` };
|
|
25
|
+
let u;
|
|
26
|
+
try { u = new URL(s); } catch { return { ok: false, error: `"${raw}" is not a web address` }; }
|
|
27
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
28
|
+
return { ok: false, error: `only http and https pages open in a tab — "${u.protocol}" is refused (D69)` };
|
|
29
|
+
}
|
|
30
|
+
return { ok: true, url: u.href };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Open (or focus) a web tab for `raw` — the PM's own action, so it loads immediately. */
|
|
34
|
+
export async function openWeb(raw, opts) {
|
|
35
|
+
const c = checkUrl(raw);
|
|
36
|
+
if (!c.ok) { setState({ webNotice: c.error }); return c; }
|
|
37
|
+
const ini = await import('./panel-initiatives.js');
|
|
38
|
+
const id = 'web:' + c.url;
|
|
39
|
+
loaded.add(id);
|
|
40
|
+
let host = c.url;
|
|
41
|
+
try { host = new URL(c.url).host; } catch { /* keep the href */ }
|
|
42
|
+
ini.openTab({ id, kind: 'web', title: host, url: c.url, role: 'WEB', slug: (opts && opts.slug) || null,
|
|
43
|
+
repo: (opts && opts.repo) || state.product || null });
|
|
44
|
+
setState({ webNotice: null });
|
|
45
|
+
return c;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The 🌐 control's action: ask for an address, then open it. */
|
|
49
|
+
export function promptWeb() {
|
|
50
|
+
const raw = typeof window !== 'undefined' && window.prompt ? window.prompt('Open a web page in a tab — the full address, starting with http or https:', '') : null;
|
|
51
|
+
if (raw === null || !String(raw).trim()) return;
|
|
52
|
+
openWeb(raw);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function mountWeb(host, tab) {
|
|
56
|
+
const c = checkUrl(tab.url);
|
|
57
|
+
if (!c.ok) {
|
|
58
|
+
setChildren(host, [el('div', { style: 'padding:20px 24px;color:var(--destructive);font-size:13px', 'data-r5': 'refused' },
|
|
59
|
+
iv('This web tab was not loaded — ' + c.error))]);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const bar = el('div', { style: 'flex:none;display:flex;gap:6px;align-items:center;padding:6px 10px;border-bottom:1px solid var(--border);background:var(--background)' },
|
|
63
|
+
el('span', { style: 'font-family:var(--mono);font-size:11px;color:var(--muted-foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1', 'data-r5': 'address', title: c.url }, iv(c.url)),
|
|
64
|
+
el('button', { onClick: () => { loaded.add(tab.id); frames.delete(tab.id); setState({}); }, style: BTN, hover: 'color:var(--foreground)', title: 'reload the page' }, '↻'),
|
|
65
|
+
el('a', { href: c.url, target: '_blank', rel: 'noopener noreferrer', style: BTN + ';text-decoration:none', 'data-r5': 'open-browser' }, 'open in your browser'));
|
|
66
|
+
if (!loaded.has(tab.id)) {
|
|
67
|
+
setChildren(host, [bar, el('div', { style: 'flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;color:var(--muted-foreground);font-size:13px', 'data-r5': 'waiting' },
|
|
68
|
+
iv('This page is not loaded. PMOS never opens a web page by itself (D69).'),
|
|
69
|
+
el('button', { onClick: () => { loaded.add(tab.id); setState({}); }, style: BTN + ';font-size:12px;padding:6px 14px', hover: 'color:var(--foreground)', 'data-r5': 'load' }, 'load this page'))]);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
setChildren(host, [bar, frameFor(tab.id, c.url)]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* One frame per tab for the tab's life, so switching tabs does not reload the page. */
|
|
76
|
+
const frames = new Map();
|
|
77
|
+
function frameFor(id, url) {
|
|
78
|
+
let f = frames.get(id);
|
|
79
|
+
if (!f || f.getAttribute('src') !== url) {
|
|
80
|
+
// A page on the workspace's OWN origin must not get allow-same-origin: with allow-scripts it
|
|
81
|
+
// could reach into the workspace itself. External origins keep it so real sites work.
|
|
82
|
+
let same = false;
|
|
83
|
+
try { same = typeof location !== 'undefined' && new URL(url).origin === location.origin; } catch { /* external */ }
|
|
84
|
+
f = el('iframe', { src: url, sandbox: same ? 'allow-scripts allow-forms allow-popups' : SANDBOX, referrerpolicy: 'no-referrer', 'data-r5': 'frame',
|
|
85
|
+
style: 'flex:1;width:100%;min-height:0;border:0;background:#fff', title: url });
|
|
86
|
+
frames.set(id, f);
|
|
87
|
+
}
|
|
88
|
+
return f;
|
|
89
|
+
}
|
|
@@ -71,3 +71,7 @@ body{margin:0;background:oklch(0.148 0.004 228.8);-webkit-font-smoothing:antiali
|
|
|
71
71
|
a{color:oklch(0.623 0.214 259.815)}a:hover{color:oklch(0.72 0.19 259.8)}
|
|
72
72
|
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer;text-align:left;padding:0}
|
|
73
73
|
input,textarea{font:inherit;color:inherit;background:none;border:0;outline:none;padding:0;resize:none}
|
|
74
|
+
/* overhaul-r1 R4: a collapsed-rail glyph draws its initials and kind badge from data attributes, so
|
|
75
|
+
the rail's text stays `repositories ⟩` and the glyph names itself through aria-label */
|
|
76
|
+
[data-r1="rail-glyph"]::before{content:attr(data-ini)}
|
|
77
|
+
[data-r1="rail-glyph"]::after{content:attr(data-kind);position:absolute;right:-2px;bottom:-3px;font-size:8px;line-height:1;color:var(--muted-foreground)}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/* xlsx-read.js — a minimal, READ-ONLY .xlsx reader for the doc tab (overhaul-r3 P10).
|
|
2
|
+
*
|
|
3
|
+
* An .xlsx is a zip of XML parts. The browser already has both halves of a reader: the zip's
|
|
4
|
+
* entries are raw-deflate streams, which `DecompressionStream('deflate-raw')` inflates, and the
|
|
5
|
+
* parts are XML, which `DOMParser` parses. So this file adds no dependency (the npm `xlsx` build
|
|
6
|
+
* carries open advisories and the fixed builds are not published to npm), fetches nothing, and
|
|
7
|
+
* executes nothing from the file: cell values are read as text and rendered as text.
|
|
8
|
+
*
|
|
9
|
+
* Scope, stated: values only — shared strings, inline strings, numbers, booleans and error codes.
|
|
10
|
+
* Formulas show their cached value. Dates are serial numbers (formatting is not interpreted). A
|
|
11
|
+
* part that is missing or malformed throws with the part's name, and the tab says so.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const td = new TextDecoder();
|
|
15
|
+
|
|
16
|
+
async function inflateRaw(bytes) {
|
|
17
|
+
const ds = new DecompressionStream('deflate-raw');
|
|
18
|
+
const out = new Response(new Blob([bytes]).stream().pipeThrough(ds));
|
|
19
|
+
return new Uint8Array(await out.arrayBuffer());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** name → Uint8Array, for the entries we ask for, from the zip's central directory. */
|
|
23
|
+
async function unzip(buf, want) {
|
|
24
|
+
const u8 = new Uint8Array(buf);
|
|
25
|
+
const dv = new DataView(buf);
|
|
26
|
+
let eocd = -1;
|
|
27
|
+
for (let i = u8.length - 22; i >= Math.max(0, u8.length - 65557); i--) {
|
|
28
|
+
if (dv.getUint32(i, true) === 0x06054b50) { eocd = i; break; }
|
|
29
|
+
}
|
|
30
|
+
if (eocd < 0) throw new Error('not a zip file (no end-of-central-directory record)');
|
|
31
|
+
const count = dv.getUint16(eocd + 10, true);
|
|
32
|
+
let p = dv.getUint32(eocd + 16, true);
|
|
33
|
+
const out = {};
|
|
34
|
+
for (let n = 0; n < count; n++) {
|
|
35
|
+
if (dv.getUint32(p, true) !== 0x02014b50) throw new Error('corrupt zip central directory');
|
|
36
|
+
const method = dv.getUint16(p + 10, true);
|
|
37
|
+
const csize = dv.getUint32(p + 20, true);
|
|
38
|
+
const nlen = dv.getUint16(p + 28, true);
|
|
39
|
+
const xlen = dv.getUint16(p + 30, true);
|
|
40
|
+
const clen = dv.getUint16(p + 32, true);
|
|
41
|
+
const local = dv.getUint32(p + 42, true);
|
|
42
|
+
const name = td.decode(u8.subarray(p + 46, p + 46 + nlen));
|
|
43
|
+
p += 46 + nlen + xlen + clen;
|
|
44
|
+
if (!want(name)) continue;
|
|
45
|
+
const lnlen = dv.getUint16(local + 26, true);
|
|
46
|
+
const lxlen = dv.getUint16(local + 28, true);
|
|
47
|
+
const start = local + 30 + lnlen + lxlen;
|
|
48
|
+
const data = u8.subarray(start, start + csize);
|
|
49
|
+
if (method === 0) out[name] = data;
|
|
50
|
+
else if (method === 8) out[name] = await inflateRaw(data);
|
|
51
|
+
else throw new Error(`${name}: unsupported zip compression method ${method}`);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const xml = (bytes, part) => {
|
|
57
|
+
const doc = new DOMParser().parseFromString(td.decode(bytes), 'application/xml');
|
|
58
|
+
if (doc.getElementsByTagName('parsererror').length) throw new Error(`${part}: malformed XML`);
|
|
59
|
+
return doc;
|
|
60
|
+
};
|
|
61
|
+
const byTag = (node, tag) => Array.from(node.getElementsByTagNameNS('*', tag));
|
|
62
|
+
const colIndex = (ref) => {
|
|
63
|
+
let n = 0;
|
|
64
|
+
for (const ch of String(ref).replace(/\d+$/, '')) n = n * 26 + (ch.charCodeAt(0) - 64);
|
|
65
|
+
return n - 1;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read a workbook. Returns `{ sheets: [{ name, rows: string[][], truncated }] }`, at most `cap` rows
|
|
70
|
+
* per sheet (the cap is reported, never applied silently).
|
|
71
|
+
*/
|
|
72
|
+
export async function readXlsx(buf, cap = 2000) {
|
|
73
|
+
const parts = await unzip(buf, (n) => n === 'xl/workbook.xml' || n === 'xl/sharedStrings.xml'
|
|
74
|
+
|| n === 'xl/_rels/workbook.xml.rels' || /^xl\/worksheets\/[^/]+\.xml$/.test(n));
|
|
75
|
+
if (!parts['xl/workbook.xml']) throw new Error('xl/workbook.xml missing — not a spreadsheet');
|
|
76
|
+
const shared = parts['xl/sharedStrings.xml']
|
|
77
|
+
? byTag(xml(parts['xl/sharedStrings.xml'], 'sharedStrings.xml'), 'si').map((si) => byTag(si, 't').map((t) => t.textContent).join(''))
|
|
78
|
+
: [];
|
|
79
|
+
const rels = {};
|
|
80
|
+
if (parts['xl/_rels/workbook.xml.rels']) {
|
|
81
|
+
for (const r of byTag(xml(parts['xl/_rels/workbook.xml.rels'], 'workbook.xml.rels'), 'Relationship')) {
|
|
82
|
+
rels[r.getAttribute('Id')] = r.getAttribute('Target');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const sheets = [];
|
|
86
|
+
const wb = xml(parts['xl/workbook.xml'], 'workbook.xml');
|
|
87
|
+
byTag(wb, 'sheet').forEach((s, i) => {
|
|
88
|
+
// the relationship id is `r:id`; matched by local name so no namespace URI is needed here
|
|
89
|
+
const rid = s.getAttribute('r:id') || (Array.from(s.attributes).find((at) => at.localName === 'id' && at.prefix) || {}).value;
|
|
90
|
+
let target = rels[rid] || `worksheets/sheet${i + 1}.xml`;
|
|
91
|
+
target = 'xl/' + target.replace(/^\/?xl\//, '').replace(/^\//, '');
|
|
92
|
+
const bytes = parts[target];
|
|
93
|
+
if (!bytes) { sheets.push({ name: s.getAttribute('name') || `Sheet${i + 1}`, rows: [], truncated: false, error: `${target} missing` }); return; }
|
|
94
|
+
const doc = xml(bytes, target);
|
|
95
|
+
const rows = [];
|
|
96
|
+
let truncated = false;
|
|
97
|
+
for (const row of byTag(doc, 'row')) {
|
|
98
|
+
if (rows.length >= cap) { truncated = true; break; }
|
|
99
|
+
const cells = [];
|
|
100
|
+
for (const c of byTag(row, 'c')) {
|
|
101
|
+
const idx = c.getAttribute('r') ? colIndex(c.getAttribute('r')) : cells.length;
|
|
102
|
+
const t = c.getAttribute('t');
|
|
103
|
+
const v = byTag(c, 'v')[0];
|
|
104
|
+
let val = '';
|
|
105
|
+
if (t === 's') val = shared[parseInt(v && v.textContent, 10)] ?? '';
|
|
106
|
+
else if (t === 'inlineStr') val = byTag(c, 't').map((x) => x.textContent).join('');
|
|
107
|
+
else if (t === 'b') val = v && v.textContent === '1' ? 'TRUE' : 'FALSE';
|
|
108
|
+
else val = v ? v.textContent : '';
|
|
109
|
+
while (cells.length < idx) cells.push('');
|
|
110
|
+
cells[idx] = val;
|
|
111
|
+
}
|
|
112
|
+
rows.push(cells);
|
|
113
|
+
}
|
|
114
|
+
sheets.push({ name: s.getAttribute('name') || `Sheet${i + 1}`, rows, truncated });
|
|
115
|
+
});
|
|
116
|
+
return { sheets };
|
|
117
|
+
}
|