create-pmos 0.53.0 → 0.53.2

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 (75) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/README.md +2 -0
  7. package/workspace/REMOTE.md +107 -65
  8. package/workspace/bin/pmos.js +4 -2
  9. package/workspace/first-paint-audit.js +6 -2
  10. package/workspace/fixtures/initiatives-api/list.json +13 -0
  11. package/workspace/fixtures/initiatives-api/roadmap.json +13 -0
  12. package/workspace/fixtures/report-api/draft.json +105 -0
  13. package/workspace/fixtures/reports-page/draft-biweekly-2026-10-16.md +12 -0
  14. package/workspace/guide/command-palette.md +1 -1
  15. package/workspace/guide/index.md +3 -1
  16. package/workspace/guide/initiatives-roadmap.md +1 -0
  17. package/workspace/guide/initiatives-screen.md +1 -0
  18. package/workspace/guide/launcher.md +51 -0
  19. package/workspace/guide/layout.md +7 -2
  20. package/workspace/guide/proactive-engine.md +21 -5
  21. package/workspace/guide/report-slides.md +28 -0
  22. package/workspace/guide/reports.md +2 -0
  23. package/workspace/lib/auto-sync.js +83 -0
  24. package/workspace/lib/board-fold.js +2 -2
  25. package/workspace/lib/breakdown-export.js +1 -6
  26. package/workspace/lib/brief.js +20 -2
  27. package/workspace/lib/goals.js +165 -0
  28. package/workspace/lib/initiative-rows.js +7 -3
  29. package/workspace/lib/launcher-cli.js +207 -0
  30. package/workspace/lib/plan-tree.js +15 -2
  31. package/workspace/lib/pmos-verbs.js +3 -0
  32. package/workspace/lib/proactive-cli.js +58 -2
  33. package/workspace/lib/proactive.js +2 -2
  34. package/workspace/lib/qr.js +293 -0
  35. package/workspace/lib/remote-access.js +184 -12
  36. package/workspace/lib/report-draft.js +34 -6
  37. package/workspace/lib/report-slides-data.js +141 -0
  38. package/workspace/lib/routes-initiatives.js +35 -2
  39. package/workspace/lib/routes-reports.js +3 -3
  40. package/workspace/lib/routes-timeline.js +1 -1
  41. package/workspace/lib/sync.js +61 -6
  42. package/workspace/package-lock.json +1 -1
  43. package/workspace/package.json +1 -1
  44. package/workspace/public/icons/apple-touch-icon.png +0 -0
  45. package/workspace/public/icons/icon-192.png +0 -0
  46. package/workspace/public/icons/icon-512.png +0 -0
  47. package/workspace/public/index.html +11 -0
  48. package/workspace/public/pm/app.js +55 -9
  49. package/workspace/public/pm/components.css +865 -1
  50. package/workspace/public/pm/dest-reports.js +8 -2
  51. package/workspace/public/pm/dest-today.js +8 -2
  52. package/workspace/public/pm/dock.js +73 -3
  53. package/workspace/public/pm/frame-copy.js +5 -0
  54. package/workspace/public/pm/initiative-list.js +18 -1
  55. package/workspace/public/pm/initiatives-copy.js +4 -0
  56. package/workspace/public/pm/panel-tabs.js +14 -12
  57. package/workspace/public/pm/print.css +11 -0
  58. package/workspace/public/pm/report-slides.js +438 -0
  59. package/workspace/public/pm/report-view.js +8 -2
  60. package/workspace/public/pm/reports-copy.js +3 -0
  61. package/workspace/public/pm/roadmap.js +23 -0
  62. package/workspace/public/pm/slides-copy.js +52 -0
  63. package/workspace/public/pm/today-copy.js +29 -0
  64. package/workspace/public/pm/today-goals.js +172 -0
  65. package/workspace/public/pm/tokens.css +13 -0
  66. package/workspace/public/pm/topbar.js +5 -2
  67. package/workspace/replaced-tests.json +36 -1
  68. package/workspace/server.js +165 -15
  69. package/workspace/skills/morning-brief.skill +15 -9
  70. package/workspace/skills/report-biweekly.skill +1 -0
  71. package/workspace/test-files.json +4 -0
  72. package/workspace/test-lists/run6.json +9 -0
  73. package/workspace/test-lists/workspace-launcher.json +5 -0
  74. package/workspace/test-lists/workspace-phone-app.json +5 -0
  75. package/workspace/test-lists/workspace-public-host.json +5 -0
@@ -18,6 +18,10 @@ const { execFile } = require('child_process');
18
18
  const RECORD_PATHS = ['planning', 'pmos', '.pmos-profile', '.gitattributes'];
19
19
  const NET_TIMEOUT_MS = 60000;
20
20
  const LOCAL_TIMEOUT_MS = 10000;
21
+ // A reading compares HEAD with the remote-tracking ref the LAST fetch left, so it can say nothing about what another
22
+ // machine pushed since. Past this age (or with no fetch on record) the reading is `remote_stale`: the bar offers Sync
23
+ // rather than claim the repository is in sync on old evidence. Still no network: the age is the mtime of FETCH_HEAD.
24
+ const REMOTE_FRESH_MS = 10 * 60 * 1000;
21
25
 
22
26
  function run(root, args, { net = false, raw = false } = {}) {
23
27
  return new Promise((resolve) => {
@@ -28,6 +32,23 @@ function run(root, args, { net = false, raw = false } = {}) {
28
32
  });
29
33
  }
30
34
 
35
+ // When this clone last heard from its remote: the mtime of FETCH_HEAD, which every fetch rewrites. null = never.
36
+ async function remoteCheckedAt(root) {
37
+ const p = await run(root, ['rev-parse', '--git-path', 'FETCH_HEAD']);
38
+ if (!p.ok || !p.out) return null;
39
+ try { return fs.statSync(path.resolve(root, p.out)).mtime; } catch { return null; }
40
+ }
41
+
42
+ // A document repository (`.pmos-profile` says pm-artifacts, D68) holds nothing but the PM's documents, so every file
43
+ // in it is a record. A code repository's records are RECORD_PATHS only.
44
+ function profileOf(root) {
45
+ try {
46
+ const prof = fs.readFileSync(path.join(root, '.pmos-profile'), 'utf8').split('\n').map((l) => l.replace(/#.*$/, '').trim()).find(Boolean);
47
+ return prof === 'pm-artifacts' ? 'pm-artifacts' : 'product-code';
48
+ } catch { return 'product-code'; }
49
+ }
50
+ const recordTestFor = (root) => (profileOf(root) === 'pm-artifacts' ? () => true : isRecord);
51
+
31
52
  const isRecord = (rel) => RECORD_PATHS.some((p) => rel === p || rel.startsWith(`${p}/`));
32
53
 
33
54
  // PM repositories append ledgers from several machines; `merge=union` keeps both sides' lines instead
@@ -55,8 +76,9 @@ async function status(root) {
55
76
  const up = await run(root, ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`]);
56
77
  const porcelain = await run(root, ['status', '--porcelain', '--untracked-files=all'], { raw: true });
57
78
  const files = porcelain.out ? porcelain.out.split('\n').filter(Boolean).map((l) => l.slice(3).replace(/^"|"$/g, '').replace(/.* -> /, '')) : [];
58
- const dirtyRecords = files.filter(isRecord);
59
- const dirtyOther = files.filter((f) => !isRecord(f));
79
+ const rec = recordTestFor(root);
80
+ const dirtyRecords = files.filter(rec);
81
+ const dirtyOther = files.filter((f) => !rec(f));
60
82
  let ahead = null; let behind = null;
61
83
  if (up.ok) {
62
84
  const c = await run(root, ['rev-list', '--left-right', '--count', `HEAD...${up.out}`]);
@@ -64,11 +86,14 @@ async function status(root) {
64
86
  }
65
87
  const mid = ['MERGE_HEAD', 'rebase-merge', 'rebase-apply', 'CHERRY_PICK_HEAD']
66
88
  .some((f) => fs.existsSync(path.join(root, '.git', f)));
89
+ const checked = up.ok ? await remoteCheckedAt(root) : null;
90
+ const remoteStale = up.ok && (!checked || Date.now() - checked.getTime() > REMOTE_FRESH_MS);
67
91
  const state = mid ? 'mid-operation' : !up.ok ? 'no-upstream'
68
92
  : ahead && behind ? 'diverged' : behind ? 'behind' : ahead ? 'ahead'
69
- : dirtyRecords.length ? 'dirty' : 'in-sync';
93
+ : dirtyRecords.length ? 'dirty' : remoteStale ? 'unchecked' : 'in-sync';
70
94
  return { ok: true, branch, upstream: up.ok ? up.out : null, ahead, behind,
71
95
  dirty_records: dirtyRecords.length, dirty_other: dirtyOther.length, state,
96
+ remote_checked_at: checked ? checked.toISOString() : null, remote_stale: remoteStale,
72
97
  network_used: false };
73
98
  }
74
99
 
@@ -93,8 +118,9 @@ async function sync(root, { label } = {}) {
93
118
  const addedAttr = ensureUnionAttribute(root);
94
119
  const s1 = await status(root);
95
120
  if (s1.dirty_records || addedAttr) {
96
- const present = RECORD_PATHS.filter((p) => fs.existsSync(path.join(root, p)));
97
- const add = await run(root, ['add', '--', ...present]);
121
+ // a document repository records every change; a code repository its record paths only
122
+ const present = profileOf(root) === 'pm-artifacts' ? ['-A'] : ['--', ...RECORD_PATHS.filter((p) => fs.existsSync(path.join(root, p)))];
123
+ const add = await run(root, ['add', ...present]);
98
124
  if (!add.ok) return stop('commit', `could not stage records — ${add.err}`);
99
125
  const staged = await run(root, ['diff', '--cached', '--name-only']);
100
126
  if (staged.out) {
@@ -129,4 +155,33 @@ async function sync(root, { label } = {}) {
129
155
  return { ok: true, steps, message: 'in sync', after: await status(root) };
130
156
  }
131
157
 
132
- module.exports = { status, sync, ensureUnionAttribute, RECORD_PATHS, isRecord };
158
+ // ── facts the idle auto-sync needs (D70 as amended 2026-10-06). All local: no network. ──────────
159
+
160
+ // The paths this machine's unpushed commits touch, or null when they cannot be read.
161
+ async function unpushedPaths(root, upstream) {
162
+ const d = await run(root, ['diff', '--name-only', `${upstream}...HEAD`]);
163
+ return d.ok ? d.out.split('\n').filter(Boolean) : null;
164
+ }
165
+
166
+ // The newest mtime (ms) among the uncommitted record files, or 0 when none is dirty.
167
+ async function newestDirtyRecordMs(root) {
168
+ const porcelain = await run(root, ['status', '--porcelain', '--untracked-files=all'], { raw: true });
169
+ const files = porcelain.out ? porcelain.out.split('\n').filter(Boolean).map((l) => l.slice(3).replace(/^"|"$/g, '').replace(/.* -> /, '')) : [];
170
+ let newest = 0;
171
+ for (const f of files.filter(recordTestFor(root))) {
172
+ try { newest = Math.max(newest, fs.statSync(path.join(root, f)).mtimeMs); } catch { /* deleted: no time to read */ }
173
+ }
174
+ return newest;
175
+ }
176
+
177
+ // The remote's default branch as this clone last saw it (refs/remotes/<remote>/HEAD), else main or master if the
178
+ // remote has one. null when none of these is known.
179
+ async function defaultBranchOf(root, remote) {
180
+ const h = await run(root, ['symbolic-ref', '--short', `refs/remotes/${remote}/HEAD`]);
181
+ if (h.ok && h.out.startsWith(`${remote}/`)) return h.out.slice(remote.length + 1);
182
+ for (const b of ['main', 'master']) if ((await run(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${b}`])).ok) return b;
183
+ return null;
184
+ }
185
+
186
+ module.exports = { status, sync, ensureUnionAttribute, RECORD_PATHS, isRecord, REMOTE_FRESH_MS,
187
+ unpushedPaths, newestDirtyRecordMs, defaultBranchOf, profileOf };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.53.0",
3
+ "version": "0.53.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.53.0",
3
+ "version": "0.53.2",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -5,6 +5,17 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
6
  <meta name="pmos-nonce" content="" />
7
7
  <title>PMOS workspace</title>
8
+ <!-- workspace-phone-app: Add to Home Screen. The manifest is fetched WITH credentials because over the
9
+ tailnet every request needs the session cookie, and a manifest fetch sends none by default. The
10
+ apple-* tags are what iOS reads instead of the manifest. "black" keeps the page below the status
11
+ bar; the top bar does not pad for a translucent one. -->
12
+ <link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials" />
13
+ <meta name="theme-color" content="#090b0c" />
14
+ <meta name="mobile-web-app-capable" content="yes" />
15
+ <meta name="apple-mobile-web-app-capable" content="yes" />
16
+ <meta name="apple-mobile-web-app-title" content="PMOS" />
17
+ <meta name="apple-mobile-web-app-status-bar-style" content="black" />
18
+ <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
8
19
  <!-- The terminal's face is preloaded, not left to be discovered. An @font-face is only fetched when
9
20
  something first RENDERS in it, which for Plex Mono is about the same moment a tab constructs
10
21
  the first xterm — and xterm measures its cell from whatever face is resolved at construction.
@@ -413,7 +413,7 @@ function readFrame() {
413
413
  }
414
414
  const fr = (() => {
415
415
  const s = readFrame();
416
- return { dest: FRAME_DESTS.includes(s.dest) ? s.dest : 'today', scope: s.scope === 'all' ? 'all' : 'repo', params: {}, dock: {}, syncing: false, reg: null, gAt: 0, needs: 0 };
416
+ return { dest: FRAME_DESTS.includes(s.dest) ? s.dest : 'today', scope: s.scope === 'all' ? 'all' : 'repo', params: {}, dock: {}, dockMax: false, dockW: null, syncing: false, reg: null, gAt: 0, needs: 0 };
417
417
  })();
418
418
  function writeFrame() {
419
419
  try { localStorage.setItem(FRAME_KEY, JSON.stringify({ dest: fr.dest, scope: fr.scope })); } catch { /* private mode: the default stands */ }
@@ -421,7 +421,7 @@ function writeFrame() {
421
421
 
422
422
  const activeRepo = () => (mods.repos && typeof mods.repos.activeProduct === 'function' ? mods.repos.activeProduct() : state.product || null);
423
423
  /** The dock on the current destination: what the PM chose here with ⌘J or the rail, else open on Today only. */
424
- const dockOpen = () => (Object.prototype.hasOwnProperty.call(fr.dock, fr.dest) ? fr.dock[fr.dest] : fr.dest === 'today');
424
+ const dockOpen = () => !!fr.dockMax || (Object.prototype.hasOwnProperty.call(fr.dock, fr.dest) ? fr.dock[fr.dest] : fr.dest === 'today');
425
425
 
426
426
  function go(id, params) {
427
427
  if (!FRAME_DESTS.includes(id)) return false;
@@ -433,10 +433,39 @@ function go(id, params) {
433
433
  setState({ menuOpen: false, paletteOpen: false });
434
434
  return true;
435
435
  }
436
- function setDock(open) { fr.dock[fr.dest] = !!open; setState({}); }
436
+ function setDock(open) { fr.dock[fr.dest] = !!open; if (!open) fr.dockMax = false; setState({}); }
437
437
  const toggleDock = () => setDock(!dockOpen());
438
438
  const foldDock = () => setDock(false);
439
439
  const openDock = () => setDock(true);
440
+ /* Focus (0.53.1): the dock takes the whole window, on every destination, until the PM leaves it. It opens the dock. */
441
+ function toggleFocus() {
442
+ fr.dockMax = !fr.dockMax;
443
+ if (fr.dockMax) fr.dock[fr.dest] = true;
444
+ setState({});
445
+ }
446
+ /* The dock's width, chosen with the grip (0.53.1). Kept in this browser like the dock's other per-window choices;
447
+ * null is the default width. */
448
+ const DOCK_W_KEY = 'pmos.workspace.pm.dock-width';
449
+ function readDockW() {
450
+ if (!FRAME) return null;
451
+ let v = null;
452
+ try { v = Number(localStorage.getItem(DOCK_W_KEY)); } catch { v = null; }
453
+ return Number.isFinite(v) && v > 0 ? v : null;
454
+ }
455
+ const dockWidth = {
456
+ get: () => fr.dockW,
457
+ set: (px) => {
458
+ fr.dockW = Number.isFinite(px) && px > 0 ? Math.round(px) : null;
459
+ try { if (fr.dockW) localStorage.setItem(DOCK_W_KEY, String(fr.dockW)); else localStorage.removeItem(DOCK_W_KEY); } catch { /* private mode */ }
460
+ paintDockWidth();
461
+ },
462
+ };
463
+ function paintDockWidth() {
464
+ const v = fr.dockW ? fr.dockW + 'px' : '';
465
+ if (bodyGrid.style.getPropertyValue('--fr-dock-w') !== v) {
466
+ if (v) bodyGrid.style.setProperty('--fr-dock-w', v); else bodyGrid.style.removeProperty('--fr-dock-w');
467
+ }
468
+ }
440
469
  function chooseRepo(name) {
441
470
  fr.scope = 'repo';
442
471
  writeFrame();
@@ -480,10 +509,25 @@ async function runSync() {
480
509
  try { res = await mods.data.syncRepository(repo); } catch (e) { res = { ok: false, message: String((e && e.message) || e) }; }
481
510
  fr.syncing = false;
482
511
  syncSeen.delete(repo);
483
- toast({ text: res && res.ok ? FC.sync.done : FC.sync.stopped((res && res.step) || FC.sync.label, (res && (res.message || res.error)) || '') });
512
+ toast({ text: res && res.ok ? FC.sync.done : FC.sync.stopped((res && (res.stopped_at || res.step)) || FC.sync.label, (res && (res.message || res.error)) || '') });
484
513
  setState({});
485
514
  }
486
515
 
516
+ /* Closing the tab with work that is not yet pushed asks first (D70 as amended 2026-10-06): idle auto-sync waits for a
517
+ * few quiet minutes, and a laptop closed inside that window would carry the work away unsynced. The reading is the
518
+ * last local one for each repository; a stale or unread reading never asks. */
519
+ function unpushedOf(st) {
520
+ // only a repository with an upstream: one with none has nowhere to push, and would ask on every close for ever
521
+ return !!st && st.ok !== false && !!st.upstream && ((st.ahead | 0) > 0 || (st.dirty_records | 0) > 0);
522
+ }
523
+ if (FRAME) {
524
+ window.addEventListener('beforeunload', (e) => {
525
+ if (![...syncSeen.values()].some((v) => unpushedOf(v.st))) return;
526
+ e.preventDefault();
527
+ e.returnValue = '';
528
+ });
529
+ }
530
+
487
531
  let bar = null;
488
532
  let dockRail = null;
489
533
  let frDetail = null;
@@ -498,7 +542,8 @@ function setupFrame() {
498
542
  ensureSprite();
499
543
  bar = createTopbar({ go, openPalette, repoMenu, sync: runSync });
500
544
  document.addEventListener('pointerdown', (e) => { fr.downAt = e.target; }, true);
501
- dockRail = createDockRail({ open: openDock });
545
+ dockRail = createDockRail({ open: openDock, resize: dockWidth });
546
+ fr.dockW = readDockW();
502
547
  frDetail = el('div', { class: 'fr-detail', 'data-fr': 'detail' },
503
548
  el('div', { class: 'fr-detail__bar' }, iconButton({ icon: 'x', ariaLabel: FC.detail.close, onClick: closeDetail, attrs: { 'data-fr': 'detail-close' } })),
504
549
  hostDetail);
@@ -546,7 +591,8 @@ function renderFrame() {
546
591
  fr.unsynced = unsyncedOf(st); // the phone bar (mobile.js) shows Sync on the same reading
547
592
  bar.update({ dest: fr.dest, repoLabel: fr.scope === 'all' ? FC.repo.all : repo, needs, unsynced: fr.unsynced, syncing: fr.syncing });
548
593
  const open = dockOpen();
549
- bodyGrid.setAttribute('data-fr-dock', phone ? 'phone' : open ? 'open' : 'rail');
594
+ bodyGrid.setAttribute('data-fr-dock', phone ? 'phone' : open ? (fr.dockMax ? 'max' : 'open') : 'rail');
595
+ paintDockWidth();
550
596
  const detail = !!state.col2;
551
597
  // Four children in every arrangement, hostTabs the fourth and last, so setChildren never moves it (a moved xterm loses
552
598
  // its grid) and a reader of the body's fourth child still finds panel 4. A closed detail stays mounted, hidden.
@@ -584,7 +630,7 @@ const G_KEYS = { t: 'today', i: 'initiatives', b: 'board', r: 'reports' };
584
630
  function frameKey(e) {
585
631
  const mod = e.metaKey || e.ctrlKey;
586
632
  if (mod && (e.key === 'k' || e.key === 'K')) { e.preventDefault(); setState({ paletteOpen: !state.paletteOpen, menuOpen: false }); return; }
587
- if (mod && (e.key === 'j' || e.key === 'J')) { e.preventDefault(); if (!isMobile()) toggleDock(); return; }
633
+ if (mod && (e.key === 'j' || e.key === 'J')) { e.preventDefault(); if (!isMobile()) { if (e.shiftKey) toggleFocus(); else toggleDock(); } return; }
588
634
  if (e.key === 'Escape') {
589
635
  if (state.paletteOpen) { setState({ paletteOpen: false }); return; }
590
636
  if (state.menuOpen) { setState({ menuOpen: false }); return; }
@@ -635,9 +681,9 @@ function frameKey(e) {
635
681
 
636
682
  /* What the suites and the phone chrome reach the frame by. */
637
683
  const frameApi = {
638
- on: true, go, toggleDock, foldDock, openDock, chooseRepo, chooseAll, openPalette, repoMenu, closeDetail, sync: runSync,
684
+ on: true, go, toggleDock, foldDock, openDock, toggleFocus, dockWidth, chooseRepo, chooseAll, openPalette, repoMenu, closeDetail, sync: runSync,
639
685
  get unsynced() { return !!fr.unsynced; },
640
- get dest() { return fr.dest; }, get scope() { return fr.scope; }, get dockOpen() { return dockOpen(); },
686
+ get dest() { return fr.dest; }, get scope() { return fr.scope; }, get dockOpen() { return dockOpen(); }, get dockMax() { return !!fr.dockMax; },
641
687
  get detailOpen() { return !!state.col2; }, get syncing() { return fr.syncing; }, get needs() { return fr.needs || 0; },
642
688
  };
643
689