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.
Files changed (71) hide show
  1. package/bin/create-pmos.js +22 -3
  2. package/package.json +1 -1
  3. package/payload/AGENTS.md +4 -0
  4. package/payload/KIT-VERSION +2 -2
  5. package/payload/okf/core/concepts/frontmatter-format.md +27 -61
  6. package/payload/okf/core/templates/decision-record-template.md +34 -0
  7. package/payload/okf/core/templates/index.md +7 -2
  8. package/payload/okf/core/templates/pm-brief-template.md +34 -0
  9. package/payload/okf/core/templates/project-plan-template.md +43 -0
  10. package/payload/okf/core/templates/report-weekly-template.md +35 -0
  11. package/payload/okf/core/templates/strategy-long-range-template.md +37 -0
  12. package/payload/okf/core/templates/strategy-one-pager-template.md +34 -0
  13. package/payload/planning/evals/eval-calibration.md +8 -0
  14. package/payload/planning/okrs/health-budgets.md +8 -0
  15. package/payload/scripts/anchor-resolve.js +56 -6
  16. package/payload/scripts/gate-initiative.sh +32 -0
  17. package/payload/scripts/okf-upgrade.js +232 -0
  18. package/payload/scripts/pmos-memory.js +375 -0
  19. package/payload/scripts/pmos-model.js +239 -0
  20. package/payload/scripts/pmos-skills.js +274 -0
  21. package/payload/scripts/workflow-state.js +178 -10
  22. package/payload/scripts/workflow-sweep.js +2 -1
  23. package/payload/state/discoveries.md +8 -0
  24. package/payload/state/index.md +7 -0
  25. package/payload/state/log.md +7 -0
  26. package/workspace/README.md +61 -204
  27. package/workspace/lib/board-cache.js +18 -8
  28. package/workspace/lib/console-state.js +2 -0
  29. package/workspace/lib/engine/anchor-resolve.js +56 -6
  30. package/workspace/lib/engine/pmos-memory.js +375 -0
  31. package/workspace/lib/engine/pmos-model.js +239 -0
  32. package/workspace/lib/engine/pmos-skills.js +274 -0
  33. package/workspace/lib/engine/workflow-state.js +178 -10
  34. package/workspace/lib/focus.js +5 -6
  35. package/workspace/lib/memory.js +54 -0
  36. package/workspace/lib/models.js +50 -0
  37. package/workspace/lib/preview-kind.js +12 -1
  38. package/workspace/lib/schedule-logic.js +31 -2
  39. package/workspace/lib/skill-store.js +5 -1
  40. package/workspace/lib/skills-registry.js +74 -0
  41. package/workspace/lib/sync.js +132 -0
  42. package/workspace/lib/tree-share.js +2 -0
  43. package/workspace/package-lock.json +1 -1
  44. package/workspace/package.json +3 -3
  45. package/workspace/public/pm/app.js +32 -2
  46. package/workspace/public/pm/data.js +160 -10
  47. package/workspace/public/pm/lifecycle.js +105 -0
  48. package/workspace/public/pm/markdown.js +10 -1
  49. package/workspace/public/pm/modes-live.js +371 -0
  50. package/workspace/public/pm/modes.js +7 -4
  51. package/workspace/public/pm/panel-detail.js +22 -2
  52. package/workspace/public/pm/panel-initiatives.js +57 -12
  53. package/workspace/public/pm/panel-newsess.js +25 -0
  54. package/workspace/public/pm/panel-repos.js +130 -4
  55. package/workspace/public/pm/panel-tabs.js +49 -2
  56. package/workspace/public/pm/state.js +11 -1
  57. package/workspace/public/pm/tab-doc.js +160 -26
  58. package/workspace/public/pm/tab-term.js +2 -0
  59. package/workspace/public/pm/tab-web.js +89 -0
  60. package/workspace/public/pm/tokens.css +4 -0
  61. package/workspace/public/pm/xlsx-read.js +117 -0
  62. package/workspace/server.js +690 -40
  63. package/workspace/skills/gather-requirements.skill +53 -0
  64. package/workspace/skills/memory-groom.skill +46 -0
  65. package/workspace/skills/report-biweekly.skill +53 -0
  66. package/workspace/skills/report-daily.skill +53 -0
  67. package/workspace/skills/report-monthly.skill +53 -0
  68. package/workspace/skills/report-special.skill +49 -0
  69. package/workspace/skills/report-weekly.skill +53 -0
  70. package/workspace/skills/skill-discover.skill +49 -0
  71. 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.path;
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(wsId()) + '&path=' + encodeURIComponent(path))
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.path];
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.path)) return disk.get(tab.path);
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.path;
133
- const commit = () => setState((s) => {
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: wsId(), path, base, text })
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: wsId(), path, base, text }),
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
- disk.set(path, text);
151
- commit();
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.path);
160
- setState((s) => { const de = { ...s.docEdits }; delete de[tab.path]; return { docEdits: de }; });
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: wsId(),
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
- return scrollPane(tab, 'view', text, VIEW_ST, () => {
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[p] != null) return true;
283
+ if (state.savedDocs[k] != null) return true;
262
284
  if (FIXTURE) return true;
263
- return disk.has(p) && !unread.has(p);
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.path);
329
- setState((s) => ({ docEdits: { ...s.docEdits, [tab.path]: v } }));
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.path] ?? raw;
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.path);
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.path);
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.path]: r.text } }));
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 body = rendersAsPage(tab) ? htmlFrame(tab, text) : viewBody(tab, text);
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
+ }