create-pmos 0.53.1 → 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 (45) 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/REMOTE.md +87 -6
  7. package/workspace/first-paint-audit.js +6 -2
  8. package/workspace/fixtures/initiatives-api/list.json +13 -0
  9. package/workspace/fixtures/initiatives-api/roadmap.json +13 -0
  10. package/workspace/fixtures/report-api/draft.json +105 -0
  11. package/workspace/fixtures/reports-page/draft-biweekly-2026-10-16.md +12 -0
  12. package/workspace/guide/index.md +1 -0
  13. package/workspace/guide/initiatives-roadmap.md +1 -0
  14. package/workspace/guide/initiatives-screen.md +1 -0
  15. package/workspace/guide/layout.md +3 -2
  16. package/workspace/guide/report-slides.md +28 -0
  17. package/workspace/guide/reports.md +2 -0
  18. package/workspace/lib/auto-sync.js +83 -0
  19. package/workspace/lib/remote-access.js +96 -16
  20. package/workspace/lib/report-draft.js +33 -4
  21. package/workspace/lib/report-slides-data.js +141 -0
  22. package/workspace/lib/routes-initiatives.js +35 -2
  23. package/workspace/lib/routes-reports.js +3 -3
  24. package/workspace/lib/sync.js +61 -6
  25. package/workspace/package-lock.json +1 -1
  26. package/workspace/package.json +1 -1
  27. package/workspace/public/pm/app.js +16 -1
  28. package/workspace/public/pm/components.css +783 -0
  29. package/workspace/public/pm/dest-reports.js +8 -2
  30. package/workspace/public/pm/initiative-list.js +18 -1
  31. package/workspace/public/pm/initiatives-copy.js +4 -0
  32. package/workspace/public/pm/print.css +11 -0
  33. package/workspace/public/pm/report-slides.js +438 -0
  34. package/workspace/public/pm/report-view.js +8 -2
  35. package/workspace/public/pm/reports-copy.js +3 -0
  36. package/workspace/public/pm/roadmap.js +23 -0
  37. package/workspace/public/pm/slides-copy.js +52 -0
  38. package/workspace/public/pm/tokens.css +13 -0
  39. package/workspace/public/pm/topbar.js +5 -2
  40. package/workspace/replaced-tests.json +23 -1
  41. package/workspace/server.js +33 -13
  42. package/workspace/skills/report-biweekly.skill +1 -0
  43. package/workspace/test-files.json +2 -0
  44. package/workspace/test-lists/run6.json +9 -0
  45. package/workspace/test-lists/workspace-public-host.json +5 -0
@@ -27,6 +27,7 @@ import { loadSortable, baseOptions, dragSet } from './bd-drag.js';
27
27
  import { COPY, shortDate } from './initiatives-copy.js';
28
28
  import { getRoadmap, sigKey, place, placed, leaveNow } from './initiatives-api.js';
29
29
  import { healthSignal, say, checkBySheet, onContextMenu } from './initiatives-ui.js';
30
+ import { openDoc } from './panel-initiatives.js';
30
31
 
31
32
  const HORIZONS = ['now', 'next', 'later'];
32
33
 
@@ -37,6 +38,7 @@ export function mountRoadmap(host, env) {
37
38
  let data = null;
38
39
  let error = null;
39
40
  let showDormant = false;
41
+ let showUnrecorded = false;
40
42
  let dragging = false;
41
43
  let deferred = null;
42
44
  let focusId = null;
@@ -150,6 +152,18 @@ export function mountRoadmap(host, env) {
150
152
  });
151
153
  }
152
154
 
155
+ // A unit with documents and no record (routes-initiatives.js unrecordedOf). It opens its PRD, or its first document; it
156
+ // has no menu and no grip, because only a record can be placed.
157
+ function unrecordedCard(u) {
158
+ const doc = u.artifacts.find((a) => a.role === 'PRD') || u.artifacts[0];
159
+ const roles = [...new Set(u.artifacts.map((a) => a.role).filter(Boolean))];
160
+ return card({
161
+ title: u.title, meta: [COPY.noRecord, ...(roles.length ? [roles.join(' · ')] : [])],
162
+ onOpen: () => openDoc(null, doc.path, doc.role || 'DOC'), openLabel: COPY.aria.open(u.title),
163
+ muted: true, attrs: { class: 'in-card', 'data-in-unrecorded-card': u.id }, openAttrs: { 'data-in-unrecorded-open': u.id },
164
+ });
165
+ }
166
+
153
167
  function column(h) {
154
168
  const rows = data.horizons[h] || [];
155
169
  const list = el('div', { class: 'in-list', 'data-in-list': h, role: 'list', 'aria-label': COPY.aria.column(COPY.horizons[h]) }, rows.map((r) => cardOf(r, { grip: true })));
@@ -166,6 +180,15 @@ export function mountRoadmap(host, env) {
166
180
  }));
167
181
  if (showDormant) kids.push(el('div', { class: 'in-list in-list--dormant', 'data-in-dormant': '' }, data.dormant.map((r) => cardOf(r, { grip: false }))));
168
182
  }
183
+ if (h === 'later' && data.unrecorded && data.unrecorded.length) {
184
+ const n = data.unrecorded.length;
185
+ kids.push(button({
186
+ label: showUnrecorded ? COPY.hideUnrecorded(n) : COPY.unrecorded(n), variant: 'ghost', size: 'sm',
187
+ icon: showUnrecorded ? 'chevron-down' : 'chevron-right', attrs: { 'data-in-unrecorded-toggle': '', 'aria-expanded': showUnrecorded ? 'true' : 'false' },
188
+ onClick: () => { showUnrecorded = !showUnrecorded; render(); },
189
+ }));
190
+ if (showUnrecorded) kids.push(el('div', { class: 'in-list in-list--dormant', 'data-in-unrecorded': '' }, data.unrecorded.map(unrecordedCard)));
191
+ }
169
192
  return el('section', { class: 'in-col', 'data-in-col': h, 'aria-label': COPY.horizons[h] }, kids);
170
193
  }
171
194
 
@@ -0,0 +1,52 @@
1
+ /* pm/slides-copy.js — every word on the biweekly slides and their deck (pm-os-absorb Run 6; PRD R22.7).
2
+ *
3
+ * Pure data and pure functions. No DOM and no import, so glossary.test.js loads this file in Node and checks each string
4
+ * against the glossary rules. report-slides.js draws the slides from these strings and nothing else.
5
+ *
6
+ * The slides copy the PM-OS biweekly deck word for word: the PM asked for the slide format "exactly" as PM-OS has it
7
+ * (2026-10-06). So GLOSSARY.exempt lifts two rules for this file only: the column and slide names "Release" and "Release
8
+ * notes", and the empty column "Nothing shipped.", are the PM-OS deck's own words. Everywhere else in the workspace the
9
+ * rules hold. A slide is still ISSUED with its report, never sent (D71): the issued-not-sent rule applies here too.
10
+ */
11
+
12
+ export const COPY = {
13
+ slidesTitle: 'Slides',
14
+ slidesMissing: 'This report has no slides. Redraft it to get them.',
15
+ slidesPrev: 'Previous slide',
16
+ slidesNext: 'Next slide',
17
+ slidesClose: 'Close the slides',
18
+ slidesCount: (n, of) => n + ' / ' + of,
19
+ print: 'Print / PDF',
20
+ more: (n) => '+' + n + ' more',
21
+ roadmap: 'Roadmap',
22
+ list: 'Initiative progress',
23
+ changelog: 'Release notes',
24
+ issues: 'Issues',
25
+ duration: 'Duration',
26
+ initiative: 'Initiative',
27
+ status: 'Status',
28
+ nextMilestone: 'Next milestone',
29
+ release: 'Release',
30
+ overdue: (d) => 'Overdue · ' + d,
31
+ noBreakdown: 'No breakdown',
32
+ noInitiatives: 'No initiatives in this window.',
33
+ lastCycle: 'Last cycle',
34
+ nextCycle: 'Next cycle',
35
+ milestone: 'Milestone',
36
+ nothingShipped: 'Nothing shipped.',
37
+ nothingPlanned: 'Nothing planned.',
38
+ noteType: { note: 'Note', info: 'Info', warning: 'Warning' },
39
+ noReleaseNotes: 'No release notes logged.',
40
+ entries: (n) => n + (n === 1 ? ' entry' : ' entries'),
41
+ };
42
+
43
+ export const GLOSSARY = {
44
+ exempt: {
45
+ release: 'The PM-OS deck names its column "Release" and its slide "Release notes"; the PM asked for that format exactly (2026-10-06).',
46
+ claims: 'The PM-OS deck says "Nothing shipped." in an empty Last cycle; the PM asked for that format exactly (2026-10-06).',
47
+ },
48
+ extra: [
49
+ { id: 'issued-not-sent', re: /\b(?:sent|sends?|sending|e-?mail(?:s|ed|ing)?|shar(?:e|es|ed|ing)|post(?:s|ed|ing)? to)\b/i, why: 'A report is issued, never sent (D71). The slides have no control that moves a report out of the workspace.' },
50
+ ],
51
+ guide: 'report-slides.md',
52
+ };
@@ -66,6 +66,19 @@
66
66
  --term-bg:oklch(0.148 0.004 228.8);
67
67
  --mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
68
68
  --sans:"Space Grotesk",ui-sans-serif,system-ui,sans-serif;
69
+ /* the biweekly slides (Run 6): the PM-OS slide palette, the same in both themes because a slide is a printed page */
70
+ --slide-bg:oklch(1 0 0);--slide-fg:oklch(0.148 0.004 228.8);--slide-muted:oklch(0.56 0.021 213.5);
71
+ --slide-foot:oklch(0.556 0 0);--slide-rule:#d8d6cb;--slide-border:oklch(0.925 0.005 214.3);
72
+ --slide-secondary:oklch(0.967 0.001 286.375);--slide-track:oklch(0.963 0.002 197.1);
73
+ --slide-brand:#2433a4;--slide-primary:oklch(0.488 0.243 264.376);--slide-overdue:oklch(0.577 0.245 27.325);
74
+ --slide-stage:oklch(0 0 0);--slide-open:#94a3b8;--slide-done:#10b981;
75
+ --slide-ph-intake:#8b5cf6;--slide-ph-spec:#f59e0b;--slide-ph-build:#3b82f6;--slide-ph-evaluate:#06b6d4;
76
+ --slide-ph-handoff:#10b981;--slide-ph-other:#64748b;
77
+ --slide-now:#1a56db;--slide-next:#d97706;--slide-later:#64748b;
78
+ --slide-launch:#047857;--slide-launch-bg:#10b9811a;--slide-improvement:#2f6feb;--slide-improvement-bg:#2f6feb1a;
79
+ --slide-fix:#92400e;--slide-fix-bg:#f59e0b1f;--slide-security:#b91c1c;--slide-security-bg:#dc26261a;
80
+ --slide-deprecation:#4b5563;--slide-deprecation-bg:#4b55631a;--slide-beta:#6d28d9;--slide-beta-bg:#6d28d91a;
81
+ --slide-note-info:#2563eb;--slide-note-warning:#b45309;
69
82
  }
70
83
  [data-pm-theme="light"],html[data-pm-page-theme="light"]{
71
84
  --background:oklch(1 0 0);--foreground:oklch(0.148 0.004 228.8);
@@ -25,9 +25,12 @@ import { COPY } from './frame-copy.js';
25
25
  /** The destinations the bar names, in order. Settings sits at the end of the bar, after Sync. */
26
26
  export const BAR_DESTS = Object.freeze(['today', 'initiatives', 'board', 'reports']);
27
27
 
28
- /** Is this sync reading "not in sync"? Behind, ahead or records not yet recorded. An unreadable status is not shown. */
28
+ /** Is this sync reading "not in sync"? Behind, ahead, records not yet recorded, or `remote_stale`: no fetch in the last
29
+ * few minutes, so another machine's push would be invisible here and "in sync" cannot be claimed. An unreadable status
30
+ * is not shown. */
29
31
  export function unsyncedOf(st) {
30
- return !!st && st.ok !== false && ((st.behind | 0) > 0 || (st.ahead | 0) > 0 || (st.dirty_records | 0) > 0);
32
+ return !!st && st.ok !== false
33
+ && ((st.behind | 0) > 0 || (st.ahead | 0) > 0 || (st.dirty_records | 0) > 0 || st.remote_stale === true);
31
34
  }
32
35
 
33
36
  export function createTopbar({ go, openPalette, repoMenu, sync } = {}) {
@@ -23,7 +23,9 @@
23
23
  "run": "2A",
24
24
  "ref": "contract-run2 done_criteria 14 (the plan-routes.test.js Origin cases)",
25
25
  "reason": "R9 asserted the route's own no-Origin text, which named the terminal route. After Run 2 the route runs pm-guard first, so R9 now asserts pm-guard's refusal; the 403 and the nothing-written check stay.",
26
- "names": ["R9 a POST with no Origin is 403 and names the terminal route; nothing is written"]
26
+ "names": [
27
+ "R9 a POST with no Origin is 403 and names the terminal route; nothing is written"
28
+ ]
27
29
  },
28
30
  {
29
31
  "id": "STG-SLACK",
@@ -37,6 +39,26 @@
37
39
  "G14 the new settings are checked: name length, source names, booleans",
38
40
  "I6 an unknown brief source is refused with the allowed names"
39
41
  ]
42
+ },
43
+ {
44
+ "id": "S6-D86",
45
+ "file": "workspace/d86-seams.test.js",
46
+ "run": "6",
47
+ "ref": "Run 6 contract, 'Deliberate test and budget changes' (planning/evals/pm-os-absorb-eval-rubric.md)",
48
+ "reason": "The draft golden gains the slides key: the drafter now writes the slide data with the draft (PRD R22.7). The assertion keeps its name and still checks every D78 section and the reason rule.",
49
+ "names": [
50
+ "10 the draft golden has every section of the D78 frame (headline, timeline, blocks, new, decided, needs your call, sources), and every block not on track has a reason"
51
+ ]
52
+ },
53
+ {
54
+ "id": "S6-FP",
55
+ "file": "workspace/first-paint-count.test.js",
56
+ "run": "6",
57
+ "ref": "Run 6 contract, 'Deliberate test and budget changes' (planning/evals/pm-os-absorb-eval-rubric.md)",
58
+ "reason": "The Reports budget rose to 15 for Present, but specimen A did not change. The calibration now reads SPECIMEN_A, specimen A's own counts, instead of EXPECT, the budgets. The name and the four numbers are unchanged.",
59
+ "names": [
60
+ "(a) calibration: the audit on specimen A gives exactly Today 17, Initiatives 24, Board 33, Reports 14 outside the dock"
61
+ ]
40
62
  }
41
63
  ],
42
64
  "entries": [
@@ -1789,6 +1789,14 @@ function readLedger(w, basename) {
1789
1789
  // D70 — the lifecycle ledger: the latest line for an initiative wins; `active` reopens. The same rule
1790
1790
  // as scripts/workflow-state.js#readLifecycle (a parity case in workspace/test.js keeps them in step).
1791
1791
  const syncing = new Set(); // repositories with a sync in flight — one at a time
1792
+ // D70 as amended 2026-10-06: the same Sync, run without the press at startup and when work sits unpushed and quiet.
1793
+ // Every rule it must pass is in lib/auto-sync.js; a scratch server (its own PMOS_STATE_DIR) does not auto-sync.
1794
+ const autoSync = require('./lib/auto-sync').createAutoSync({
1795
+ roots: () => [...new Set([...workspaces.values()].map((w) => w.root))],
1796
+ busy: syncing,
1797
+ onDone: (root) => boardCache.invalidate(root),
1798
+ label: `${os.hostname()} (auto-sync)`,
1799
+ });
1792
1800
  const STOPPED_STATES = ['closed', 'on-hold', 'cancelled'];
1793
1801
  function readLifecycle(w) {
1794
1802
  const latest = new Map();
@@ -2317,7 +2325,7 @@ function checkDueSchedules() {
2317
2325
  // PMOS_TICK_MS exists for the suites (same footing as PMOS_LIVENESS_MS): proactive-prepare.test.js
2318
2326
  // used to wait on two real minutes of this clock. Unset, the interval is the 60 seconds it always was.
2319
2327
  const TICK_MS = Math.max(200, Number(process.env.PMOS_TICK_MS) || 60000);
2320
- try { const _tick = setInterval(() => { checkDueSchedules(); proactiveTick(); try { prepareTick({ manual: false }); } catch { /* the next tick tries again */ } }, TICK_MS); if (_tick.unref) _tick.unref(); } catch {}
2328
+ try { const _tick = setInterval(() => { checkDueSchedules(); proactiveTick(); try { prepareTick({ manual: false }); } catch { /* the next tick tries again */ } autoSync.tick().catch(() => {}); }, TICK_MS); if (_tick.unref) _tick.unref(); } catch {}
2321
2329
 
2322
2330
  // ── Sessions: real PTYs, owned by the server, with disk-backed scrollback + metadata. ──
2323
2331
  /** @type {Map<string, {id,ws,name,agent,cwd,state,note,created,pty,buf,cols,rows,clients:Set}>} */
@@ -6486,11 +6494,11 @@ async function handleRequest(req, res) {
6486
6494
  // Local only — no network. Safe to poll; it is what tells the PM whether a press is needed.
6487
6495
  const w = getWs(url.searchParams.get('ws'));
6488
6496
  if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
6489
- return json(res, { ws: w.id, name: w.name, ...(await syncLib.status(w.root)) });
6497
+ return json(res, { ws: w.id, name: w.name, ...(await syncLib.status(w.root)), auto: autoSync.lastOf(w.root) });
6490
6498
  }
6491
6499
  if (p === '/api/sync' && req.method === 'POST') {
6492
- // D70 — the ONE place the workspace runs fetch / integrate / commit / push. Only the PM's press
6493
- // reaches it; nothing schedules or chains it, and it is off every other action's request path.
6500
+ // D70 — the PM's press. The only other caller of the same sync is lib/auto-sync.js (D70 as amended
6501
+ // 2026-10-06), under its own rules; neither is on any other action's request path.
6494
6502
  let body = {};
6495
6503
  try { body = JSON.parse((await readBody(req)) || '{}'); }
6496
6504
  catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
@@ -7478,7 +7486,7 @@ async function handleRequest(req, res) {
7478
7486
  return json(res, { ok: true, changes: rows.slice(-200).reverse(), total: rows.length });
7479
7487
  }
7480
7488
  // pm-os-absorb Runs 2–5 — the planning layer's routes, one file per run (lib/route-modules.js). Last, so none shadows a route above.
7481
- if (await routeModules.dispatch(req, res, url, { json, readBody, getWs, invalidateNeedsAgg, boardCache, consoleState, stateDirFor, sessionList })) return;
7489
+ if (await routeModules.dispatch(req, res, url, { json, readBody, getWs, invalidateNeedsAgg, boardCache, consoleState, stateDirFor, sessionList, initiativeIndex })) return;
7482
7490
  res.writeHead(404); res.end('not found');
7483
7491
  }
7484
7492
 
@@ -7544,18 +7552,27 @@ function writeChangeContext(dir) {
7544
7552
  catch { return null; }
7545
7553
  }
7546
7554
 
7547
- // ── Access guard (D62): loopback is token-free; every non-loopback peer needs the bearer token. ──
7555
+ // ── Access guard (D62): loopback is token-free; every non-loopback or proxied request needs the bearer token. ──
7548
7556
  // Peer-based: what matters is where the request came FROM, not which listener accepted it. With the
7549
7557
  // remote flag off there is no non-loopback listener, so this is transparent for local dev. Headers
7550
7558
  // and the token are never logged (D08).
7551
7559
  function httpGuard(req, res) {
7552
7560
  // Origin/Host allowlist FIRST (D64 A1): reject a cross-site / DNS-rebinding request before the
7553
7561
  // token exemption for loopback can be reached. Header values are never logged (D08).
7554
- if (!remoteAccess.checkHostOrigin({ origin: req.headers.origin, host: req.headers.host, binds: BIND.binds })) {
7562
+ if (!remoteAccess.checkHostOrigin({ origin: req.headers.origin, host: req.headers.host, binds: BIND.binds, publicHost: BIND.publicHost })) {
7555
7563
  res.writeHead(403, { 'Content-Type': 'application/json' });
7556
7564
  res.end(JSON.stringify({ ok: false, error: 'forbidden origin/host' }));
7557
7565
  return;
7558
7566
  }
7567
+ // workspace-public-host: a request to the public hostname must have passed Cloudflare Access. A tunnel
7568
+ // with no Access application in front fails here, before even the sign-in form can be reached.
7569
+ if (!remoteAccess.edgeAllowed({ headers: req.headers, publicHost: BIND.publicHost })) {
7570
+ res.writeHead(403, { 'Content-Type': 'application/json' });
7571
+ res.end(JSON.stringify({ ok: false, error: 'the public host is only reachable through Cloudflare Access' }));
7572
+ return;
7573
+ }
7574
+ // The cookie is Secure when the browser reached us on the public host (HTTPS at the edge).
7575
+ const secureCookie = !!BIND.publicHost && remoteAccess.isProxied(req.headers, BIND.publicHost);
7559
7576
  // Token may arrive as an Authorization header, a ?token= query param, or the seeded cookie. A
7560
7577
  // browser only carries the token on the FIRST navigation URL — it cannot attach a header/query to
7561
7578
  // sub-resource loads (CSS/JS) or the WS upgrade — so the documented "open http://…/?token=<t>"
@@ -7570,7 +7587,7 @@ function httpGuard(req, res) {
7570
7587
  req.on('data', (c) => { body += c; if (body.length > 4096) req.destroy(); });
7571
7588
  req.on('end', () => {
7572
7589
  if (remoteAccess.tokenMatches(remoteAccess.parseAuthForm(body), TOKEN)) {
7573
- res.writeHead(303, { Location: '/', 'Set-Cookie': remoteAccess.sessionCookie(TOKEN), 'Cache-Control': 'no-store' });
7590
+ res.writeHead(303, { Location: '/', 'Set-Cookie': remoteAccess.sessionCookie(TOKEN, { secure: secureCookie }), 'Cache-Control': 'no-store' });
7574
7591
  return res.end();
7575
7592
  }
7576
7593
  res.writeHead(401, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
@@ -7578,7 +7595,7 @@ function httpGuard(req, res) {
7578
7595
  });
7579
7596
  return;
7580
7597
  }
7581
- if (!remoteAccess.authorize({ remoteAddress: req.socket.remoteAddress,
7598
+ if (!remoteAccess.authorize({ remoteAddress: req.socket.remoteAddress, headers: req.headers, publicHost: BIND.publicHost,
7582
7599
  presentedToken: remoteAccess.presentedToken(req), expectedToken: TOKEN })) {
7583
7600
  // A phone's browser gets a page it can act on, not JSON. API calls, scripts and anything else
7584
7601
  // that is not a page load keep the JSON 401 they had.
@@ -7594,11 +7611,11 @@ function httpGuard(req, res) {
7594
7611
  // navigation, so the browser's subsequent sub-resource + WS-upgrade requests authenticate without it
7595
7612
  // in the URL. HttpOnly (JS never needs it) + SameSite=Strict + the D64 Origin/Host allowlist keep a
7596
7613
  // foreign site from riding it; no Secure flag because the tailnet speaks plain HTTP (WireGuard-encrypted).
7597
- if (!remoteAccess.isLoopbackAddr(req.socket.remoteAddress)) {
7614
+ if (!remoteAccess.isLoopbackAddr(req.socket.remoteAddress) || remoteAccess.isProxied(req.headers, BIND.publicHost)) {
7598
7615
  let qTok = null;
7599
7616
  try { qTok = new URL(req.url || '', 'http://localhost').searchParams.get('token'); } catch { /* ignore */ }
7600
7617
  if (qTok && remoteAccess.tokenMatches(qTok.trim(), TOKEN)) {
7601
- res.setHeader('Set-Cookie', remoteAccess.sessionCookie(TOKEN));
7618
+ res.setHeader('Set-Cookie', remoteAccess.sessionCookie(TOKEN, { secure: secureCookie }));
7602
7619
  // workspace-phone-app: on a page load, redirect to the same URL without the token, so it does
7603
7620
  // not stay in the address bar, the history, or the start_url of a home-screen install.
7604
7621
  const clean = navigation ? remoteAccess.stripTokenParam(req.url) : null;
@@ -7620,14 +7637,15 @@ function handleUpgrade(req, socket, head) {
7620
7637
  // Same origin/host allowlist on the WS upgrade handshake (D64 A1): a cross-origin page must not be
7621
7638
  // able to open ws://127.0.0.1:<port> and spawn/type into a shell. Rejected BEFORE the socket is
7622
7639
  // accepted, and before loopback's token exemption applies. Header values never logged (D08).
7623
- if (!remoteAccess.checkHostOrigin({ origin: req.headers.origin, host: req.headers.host, binds: BIND.binds })) {
7640
+ if (!remoteAccess.checkHostOrigin({ origin: req.headers.origin, host: req.headers.host, binds: BIND.binds, publicHost: BIND.publicHost })
7641
+ || !remoteAccess.edgeAllowed({ headers: req.headers, publicHost: BIND.publicHost })) {
7624
7642
  socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
7625
7643
  socket.destroy();
7626
7644
  return;
7627
7645
  }
7628
7646
  // A browser WebSocket cannot set a header or a ?token= — it authenticates via the same-origin cookie
7629
7647
  // seeded on the page load. presentedToken() resolves header → query → subprotocol → cookie.
7630
- if (!remoteAccess.authorize({ remoteAddress: socket.remoteAddress,
7648
+ if (!remoteAccess.authorize({ remoteAddress: socket.remoteAddress, headers: req.headers, publicHost: BIND.publicHost,
7631
7649
  presentedToken: remoteAccess.presentedToken(req), expectedToken: TOKEN })) {
7632
7650
  socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
7633
7651
  socket.destroy(); // reject the upgrade; nothing about the request is logged (D08)
@@ -7798,6 +7816,8 @@ if (require.main === module) {
7798
7816
  // one started by `pmos` — otherwise the next `pmos` cannot see it and starts a second server.
7799
7817
  try { fs.mkdirSync(STATE, { recursive: true }); fs.writeFileSync(PID_FILE, String(process.pid)); } catch {}
7800
7818
  for (const line of remoteAccess.startupLines(BIND, PORT, { ansi: !!process.stdout.isTTY && !process.env.NO_COLOR })) console.log(line);
7819
+ // bring in what another machine pushed while this one was off (D70 as amended); never on the request path
7820
+ { const t = setTimeout(() => { autoSync.startup().catch(() => {}); }, 3000); if (t.unref) t.unref(); }
7801
7821
  });
7802
7822
  });
7803
7823
  }
@@ -39,6 +39,7 @@ Procedure:
39
39
  - For each `<!-- pmos:note <initiative-id> -->`, one or two sentences: what moved and what it means for the reader. Start with "Warning:" when the block is off track or at risk. A note that already has text is the PM's or an earlier run's: leave it.
40
40
  - For `<!-- pmos:note report.headline -->`, one or two sentences: what the reader most needs to know. If the definition is a draft, say so in the first line.
41
41
  - For `<!-- pmos:note report.asks -->`, if the file has it: the decisions only the audience can give, one per line, each line starting with "- ". Never ask the audience for a decision that is the PM's own.
42
+ - Never write in `<!-- pmos:note report.changelog -->`. It holds the PM's own release notes for the changelog slide, and only the PM writes them. Leave it empty or as the PM left it.
42
43
  7. Run `pmos report draft biweekly --redraft`. The script is the one writer of the draft file: it writes the file again and keeps every note, so a line you changed outside a note marker is put back. Read the file once more and check that each note you wrote is still in place. Do not commit; the PM's Sync carries the file. Finish by saying in one line where the draft is.
43
44
  - It refuses (the slot was issued since step 4, or a block has no reason for its health): quote the refusal and stop. Say that your notes are in the file and that the file is not re-rendered, so a line outside a note marker may differ from the script's. Do not edit the file again.
44
45
 
@@ -30,6 +30,7 @@
30
30
  "start-policy.test.js",
31
31
  "state-isolation.test.js",
32
32
  "sync.test.js",
33
+ "auto-sync.test.js",
33
34
  "schedule-cli.test.js",
34
35
  "initiative-surface.test.js",
35
36
  "guide.test.js",
@@ -126,6 +127,7 @@
126
127
  "start-policy.test.js": 1,
127
128
  "state-isolation.test.js": 4,
128
129
  "sync.test.js": 4,
130
+ "auto-sync.test.js": 4,
129
131
  "schedule-cli.test.js": 3,
130
132
  "initiative-surface.test.js": 3,
131
133
  "guide.test.js": 1,
@@ -0,0 +1,9 @@
1
+ {
2
+ "_comment": "pm-os-absorb Run 6 (the biweekly as slides, PRD R22.7). The browser suites run only through runs/browser-run.sh, two at a time machine-wide.",
3
+ "modules": ["report-slides-data.test.js"],
4
+ "panels": ["pm-report-slides-test.js"],
5
+ "seconds": {
6
+ "report-slides-data.test.js": 2,
7
+ "pm-report-slides-test.js": 45
8
+ }
9
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "_comment": "workspace-public-host: the proxied-request branches of the access guard (no loopback exemption through a proxy, the one public hostname, the Cloudflare Access presence check, the Secure cookie), driven through httpGuard and the upgrade guard as a library.",
3
+ "modules": ["public-host.test.js"],
4
+ "seconds": { "public-host.test.js": 3 }
5
+ }