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
@@ -16,6 +16,13 @@
16
16
  * Comparison is constant-time. The token is generated if not supplied and printed ONCE at
17
17
  * startup — never logged thereafter, and request headers are never logged (D08).
18
18
  *
19
+ * 3. Public host (workspace-public-host) — PMOS_PUBLIC_HOST=<hostname> lets a TLS-terminating
20
+ * tunnel on this machine (Cloudflare Tunnel + Cloudflare Access) forward that ONE hostname to the
21
+ * loopback listener. It adds the hostname to the Host allowlist and mints a token; it never
22
+ * changes what the server binds. A request that arrived through a proxy is never loopback-exempt
23
+ * (see isProxied), and a request addressed to the public host must also carry Cloudflare
24
+ * Access's assertion header, so a tunnel without an Access application in front fails closed.
25
+ *
19
26
  * Pure and side-effect-light so the guard logic is unit-testable without a live tailnet peer.
20
27
  */
21
28
  const crypto = require('crypto');
@@ -82,9 +89,28 @@ function remoteRequested(env) {
82
89
  // The one function that decides what the server may bind. Fail-closed: anything that is not
83
90
  // loopback or a tailnet address REFUSES (the server exits), so a public bind is unreachable by
84
91
  // construction. Returns {remote, binds, token, tailnetIp, generated} or {remote, refused:<msg>}.
92
+ // A public hostname is a plain DNS name: no scheme, port, path or IP literal, and never localhost.
93
+ // Anything else is a mistake to refuse, not a value to guess at.
94
+ function parsePublicHost(raw) {
95
+ const h = String(raw == null ? '' : raw).trim().toLowerCase();
96
+ if (!h) return { host: null };
97
+ const valid = h.length <= 253 && /^(?=.*[a-z])[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/.test(h);
98
+ if (!valid || h === 'localhost' || h.endsWith('.localhost') || parseIPv4(h)) {
99
+ return { refused: `refusing PMOS_PUBLIC_HOST="${h}": give a bare DNS hostname such as pm-os.app (no scheme, port, path or IP address).` };
100
+ }
101
+ return { host: h };
102
+ }
103
+
85
104
  function resolveBinds({ env = process.env, detect = detectTailnetIp } = {}) {
105
+ const pub = parsePublicHost(env.PMOS_PUBLIC_HOST);
106
+ if (pub.refused) return { remote: false, refused: pub.refused };
107
+ const publicHost = pub.host;
86
108
  if (!remoteRequested(env)) {
87
- return { remote: false, binds: [LOOPBACK], token: null, tailnetIp: null, generated: false };
109
+ if (!publicHost) return { remote: false, binds: [LOOPBACK], token: null, tailnetIp: null, generated: false, publicHost: null };
110
+ // Public-host mode alone: still loopback ONLY — the tunnel connects to it from this machine.
111
+ const supplied = (env.PMOS_TOKEN || '').trim();
112
+ const token = supplied || crypto.randomBytes(32).toString('base64url');
113
+ return { remote: false, binds: [LOOPBACK], token, tailnetIp: null, generated: !supplied, publicHost };
88
114
  }
89
115
  // An explicit bind address, when given, must itself be loopback or tailnet — never public.
90
116
  const requested = (env.PMOS_BIND_ADDR || '').trim();
@@ -103,7 +129,7 @@ function resolveBinds({ env = process.env, detect = detectTailnetIp } = {}) {
103
129
  }
104
130
  const supplied = (env.PMOS_TOKEN || '').trim();
105
131
  const token = supplied || crypto.randomBytes(32).toString('base64url');
106
- return { remote: true, binds: [LOOPBACK, ip], token, tailnetIp: ip, generated: !supplied };
132
+ return { remote: true, binds: [LOOPBACK, ip], token, tailnetIp: ip, generated: !supplied, publicHost };
107
133
  }
108
134
 
109
135
  // ── Token extraction ──────────────────────────────────────────────────────────────────
@@ -184,20 +210,21 @@ function authorityOf(value) {
184
210
  if (scheme !== -1) h = h.slice(scheme + 3);
185
211
  return h.split('/')[0]; // host[:port]
186
212
  }
187
- function hostnameAllowed(authority, binds) {
213
+ function hostnameAllowed(authority, binds, publicHost) {
188
214
  const h = hostnameOf(authority);
189
215
  if (!h) return false;
190
216
  if (h === 'localhost') return true;
191
217
  if (isLoopbackAddr(h)) return true;
192
218
  for (const b of (binds || [])) if (isTailnetIp(b) && unmap(h) === unmap(b)) return true;
219
+ if (publicHost && h === publicHost) return true; // workspace-public-host: the one named hostname
193
220
  return false;
194
221
  }
195
222
  // The single decision, called from BOTH the HTTP guard and the WS-upgrade guard. Returns true when
196
223
  // the request is allowed to proceed (it may still be rejected by the token guard afterwards).
197
- function checkHostOrigin({ origin, host, binds }) {
224
+ function checkHostOrigin({ origin, host, binds, publicHost }) {
198
225
  // 1. If a Host header is present, its hostname must be loopback/localhost/tailnet (anti-rebinding).
199
226
  if (host != null && host !== '') {
200
- if (!hostnameAllowed(host, binds)) return false;
227
+ if (!hostnameAllowed(host, binds, publicHost)) return false;
201
228
  }
202
229
  // 2. If an Origin header is present, it must be same-origin with the Host it arrived on, and its
203
230
  // hostname must itself be in the allowlist. "null"/opaque/garbage Origins are rejected.
@@ -206,11 +233,11 @@ function checkHostOrigin({ origin, host, binds }) {
206
233
  if (!oa) return false;
207
234
  const ha = authorityOf(host);
208
235
  if (ha && oa !== ha) return false; // cross-origin → reject
209
- if (!hostnameAllowed(oa, binds)) return false; // Host absent but Origin foreign → reject
236
+ if (!hostnameAllowed(oa, binds, publicHost)) return false; // Host absent but Origin foreign → reject
210
237
  }
211
238
  return true;
212
239
  }
213
- function isAllowedOrigin(origin, { host, binds } = {}) { return checkHostOrigin({ origin, host, binds }); }
240
+ function isAllowedOrigin(origin, { host, binds, publicHost } = {}) { return checkHostOrigin({ origin, host, binds, publicHost }); }
214
241
 
215
242
  // Constant-time token comparison. Length is checked first (timingSafeEqual throws on a length
216
243
  // mismatch); a differing length is already a non-match, so this leaks nothing useful.
@@ -222,11 +249,41 @@ function tokenMatches(presented, expected) {
222
249
  try { return crypto.timingSafeEqual(a, b); } catch { return false; }
223
250
  }
224
251
 
252
+ // ── Proxied requests (workspace-public-host) ────────────────────────────────────────────
253
+ // A reverse proxy or tunnel on this machine (cloudflared, Caddy, nginx) connects from 127.0.0.1, so
254
+ // the peer address alone would make every internet request look local and token-exempt. Each of
255
+ // these headers is added by a proxy and never by a browser talking to the server directly, so a
256
+ // request that carries one came from somewhere else and must present the token like any remote
257
+ // peer. Only header NAMES are read here; no value is logged (D08).
258
+ const PROXY_HEADERS = ['forwarded', 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-real-ip',
259
+ 'cf-connecting-ip', 'cf-ray', 'cf-access-jwt-assertion'];
260
+ // Cloudflare Access adds this signed assertion to every request it lets through to the origin.
261
+ const ACCESS_HEADER = 'cf-access-jwt-assertion';
262
+ function hasHeader(headers, name) {
263
+ const v = (headers || {})[name];
264
+ return v != null && String(v) !== '';
265
+ }
266
+ function isProxied(headers = {}, publicHost = null) {
267
+ if (PROXY_HEADERS.some((n) => hasHeader(headers, n))) return true;
268
+ return !!publicHost && hostnameOf(headers.host) === publicHost;
269
+ }
270
+ // The edge check for the public host: a request addressed to it must have passed Cloudflare Access.
271
+ // This is a PRESENCE check, not a signature check — verifying the JWT needs Cloudflare's signing keys
272
+ // fetched over the network, which the no-network property forbids without a PM decision (D79). It
273
+ // is still meaningful: the origin listens on loopback only, so the one way a request reaches it with
274
+ // the public Host is through the tunnel, and a tunnel with no Access application in front sends no
275
+ // assertion. That case fails closed (403) instead of leaving the token as the only lock.
276
+ function edgeAllowed({ headers = {}, publicHost = null } = {}) {
277
+ if (!publicHost || hostnameOf(headers.host) !== publicHost) return true;
278
+ return hasHeader(headers, ACCESS_HEADER);
279
+ }
280
+
225
281
  // The single authorization decision, PEER-based (what matters is where the request came FROM, not
226
- // which listener it hit). Loopback is always exempt; every non-loopback peer must present a valid
227
- // bearer token. Fail-closed: with no token configured, a non-loopback peer is rejected.
228
- function authorize({ remoteAddress, presentedToken, expectedToken }) {
229
- if (isLoopbackAddr(remoteAddress)) return true;
282
+ // which listener it hit). A loopback peer is exempt unless the request arrived through a proxy;
283
+ // every other request must present a valid bearer token. Fail-closed: with no token configured, a
284
+ // non-loopback or proxied request is rejected.
285
+ function authorize({ remoteAddress, presentedToken, expectedToken, headers = {}, publicHost = null }) {
286
+ if (isLoopbackAddr(remoteAddress) && !isProxied(headers, publicHost)) return true;
230
287
  return tokenMatches(presentedToken, expectedToken);
231
288
  }
232
289
 
@@ -242,7 +299,27 @@ function authorize({ remoteAddress, presentedToken, expectedToken }) {
242
299
  function phoneUrl(bind, port) {
243
300
  return `http://${bind.tailnetIp}:${port}/?token=${encodeURIComponent(bind.token)}`;
244
301
  }
302
+ function publicLines(bind) {
303
+ const lines = [
304
+ ` public: https://${bind.publicHost}/ (through Cloudflare Tunnel + Access only; the server still binds loopback)`,
305
+ ];
306
+ if (bind.generated && !bind.remote) {
307
+ lines.push(` token (generated — shown ONCE, copy it now): ${bind.token}`);
308
+ } else if (!bind.generated) {
309
+ lines.push(' token: using PMOS_TOKEN from the environment (not shown)');
310
+ }
311
+ lines.push(` sign in: open https://${bind.publicHost}/, pass Cloudflare Access, then paste the token.`);
312
+ lines.push(' proxied requests always need the token; requests to the public host also need Cloudflare Access.');
313
+ return lines;
314
+ }
245
315
  function startupLines(bind, port, { ansi = false } = {}) {
316
+ if (bind && !bind.remote && bind.publicHost) {
317
+ return [
318
+ 'pmos workspace server — PUBLIC-HOST mode',
319
+ ` local: http://${LOOPBACK}:${port}`,
320
+ ...publicLines(bind),
321
+ ];
322
+ }
246
323
  if (!bind || !bind.remote) {
247
324
  return [`pmos workspace server — http://${LOOPBACK}:${port} (loopback only, D62)`];
248
325
  }
@@ -264,6 +341,7 @@ function startupLines(bind, port, { ansi = false } = {}) {
264
341
  lines.push(` phone: open http://${bind.tailnetIp}:${port}/ and paste your PMOS_TOKEN into the sign-in field`);
265
342
  }
266
343
  lines.push(' loopback requests are token-free; every tailnet request must present the token.');
344
+ if (bind.publicHost) lines.push(...publicLines(bind));
267
345
  return lines;
268
346
  }
269
347
 
@@ -293,10 +371,11 @@ function stripTokenParam(url) {
293
371
  }
294
372
 
295
373
  // The cookie a valid token seeds. HttpOnly (no script needs it) + SameSite=Strict + the Origin/Host
296
- // allowlist keep a foreign site from riding it. No Secure flag: the tailnet speaks plain HTTP inside
297
- // WireGuard. 7 days, unchanged from workspace-mobile-remote-access.
298
- function sessionCookie(token) {
299
- return `${COOKIE_NAME}=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=604800`;
374
+ // allowlist keep a foreign site from riding it. No Secure flag on the tailnet: it speaks plain HTTP
375
+ // inside WireGuard. On the public host the browser is on HTTPS (TLS ends at the edge), so the cookie
376
+ // is Secure there and is never sent over plain HTTP. 7 days, unchanged from workspace-mobile-remote-access.
377
+ function sessionCookie(token, { secure = false } = {}) {
378
+ return `${COOKIE_NAME}=${encodeURIComponent(token)}; HttpOnly; ${secure ? 'Secure; ' : ''}SameSite=Strict; Path=/; Max-Age=604800`;
300
379
  }
301
380
 
302
381
  // The sign-in form posts application/x-www-form-urlencoded `token=<t>` to /auth.
@@ -331,7 +410,7 @@ function signInPage({ failed = false } = {}) {
331
410
  <body><main>
332
411
  <h1>Sign in to the PMOS workspace</h1>
333
412
  ${failed ? '<p class="err" role="alert">That token does not match. Check it and try again.</p>' : ''}
334
- <p>Paste the token that <code>PMOS_REMOTE=tailnet npm start</code> printed, or scan its QR code again.</p>
413
+ <p>Paste the token the workspace server printed when it started (<code>PMOS_REMOTE=tailnet npm start</code> or <code>PMOS_PUBLIC_HOST</code>), or scan its QR code again.</p>
335
414
  <form method="post" action="${AUTH_PATH}">
336
415
  <input type="text" name="username" value="pmos" autocomplete="username" hidden>
337
416
  <label for="token">Token</label>
@@ -346,6 +425,7 @@ module.exports = {
346
425
  LOOPBACK, isLoopbackAddr, isTailnetIp, detectTailnetIp, remoteRequested,
347
426
  resolveBinds, extractHttpToken, extractWsToken, tokenMatches, authorize, startupLines,
348
427
  checkHostOrigin, isAllowedOrigin, hostnameAllowed,
428
+ parsePublicHost, PROXY_HEADERS, ACCESS_HEADER, isProxied, edgeAllowed,
349
429
  COOKIE_NAME, extractCookieToken, presentedToken,
350
430
  phoneUrl, isPageNavigation, stripTokenParam, sessionCookie, AUTH_PATH, parseAuthForm, signInPage,
351
431
  };
@@ -62,6 +62,7 @@ const itemSpans = require('./item-spans.js');
62
62
  const actorLib = require('./actor.js');
63
63
  const reviewGate = require('./review-gate.js');
64
64
  const { readRules } = require('./plan-rules.js');
65
+ const { slidesData } = require('./report-slides-data.js');
65
66
 
66
67
  const NOTE_OPEN = (id) => `<!-- pmos:note ${id} -->`;
67
68
  const NOTE_CLOSE = '<!-- /pmos:note -->';
@@ -85,6 +86,12 @@ function noteBlock(id, text) {
85
86
  /** The two notes that belong to the report, not to an initiative: the agent's headline and its asks to the audience. */
86
87
  const HEADLINE_NOTE = 'report.headline';
87
88
  const ASKS_NOTE = 'report.asks';
89
+ /** The changelog slide's release notes (Run 6): the PM's own words; the agent never writes them (the PM's card, 2026-10-06). */
90
+ const CHANGELOG_NOTE = 'report.changelog';
91
+ /** The slide data block render() appends: one HTML comment on one line, so a markdown reader shows nothing of it and
92
+ * md-newlines --fix has no paragraph to join. */
93
+ const SLIDES_OPEN = '<!-- pmos:slides v1';
94
+ const SLIDES_CLOSE = '-->';
88
95
  /** The frame, in its frozen order (D78 as amended by D86 point 3). A definition's `sections` must name these. */
89
96
  const FRAME = Object.freeze(['headline', 'timeline', 'initiatives', 'new', 'decided', 'needs', 'sources']);
90
97
  const CADENCE_RE = /^[a-z][a-z0-9-]{0,39}$/;
@@ -143,7 +150,7 @@ function readJsonl(file) {
143
150
  */
144
151
  function readDefinition(root, cadence) {
145
152
  const rel = definitionPath(root, cadence);
146
- const out = { path: rel, exists: false, cadence: null, audience: null, sections: FRAME.slice(), errors: [] };
153
+ const out = { path: rel, exists: false, cadence: null, audience: null, product: null, sections: FRAME.slice(), errors: [] };
147
154
  let text;
148
155
  try { text = fs.readFileSync(path.join(path.resolve(String(root)), rel), 'utf8'); } catch { return out; }
149
156
  out.exists = true;
@@ -164,6 +171,7 @@ function readDefinition(root, cadence) {
164
171
  out.cadence = isStr(map.cadence) ? map.cadence : null;
165
172
  if (out.cadence !== cadence) out.errors.push(`its cadence is ${JSON.stringify(out.cadence)}, not ${cadence}`);
166
173
  out.audience = isStr(map.audience) && map.audience.trim() ? map.audience.trim() : null;
174
+ out.product = isStr(map.product) && map.product.trim() ? map.product.trim() : null;
167
175
  if (!out.audience) out.errors.push('it names no audience, so Needs your call stays empty (X23)');
168
176
  if (Array.isArray(map.sections)) {
169
177
  const got = map.sections.map((s) => String(s).trim());
@@ -441,11 +449,30 @@ function draftOf(root, { cadence, slot, now, health } = {}) {
441
449
  : [`${plural(moved, 'initiative', 'initiatives')} moved`, off ? `${off} ${off === 1 ? 'is' : 'are'} off track` : null,
442
450
  risk ? `${risk} ${risk === 1 ? 'is' : 'are'} at risk` : null].filter(Boolean).join('; ') + '.';
443
451
 
452
+ // The slides (Run 6, R22.7): the roadmap snapshot, the initiative list, a slide per block and the changelog, folded
453
+ // here with the rest of the draft so an issued report freezes them too.
454
+ const stageCache = new Map();
455
+ const stageLabel = (id, sid) => {
456
+ if (!stageCache.has(id)) stageCache.set(id, new Map(planTree.stagesFor(abs, id).map((x) => [x.id, x.label])));
457
+ const lab = stageCache.get(id).get(sid);
458
+ return lab || cap(String(sid || ''));
459
+ };
460
+ const integratedAll = new Set();
461
+ for (const r of rows) for (const w of ((factoryJoin.factoryOf(fj, r.id) || {}).items || [])) if (w && w.integrated && w.plan_item) integratedAll.add(w.plan_item);
462
+ const slides = slidesData({
463
+ product: def.product || path.basename(main), cadence, slot: theSlot, nowMs, window, cycleDays: cycle,
464
+ blocks, rows, itemsOf,
465
+ stageLabel, horizonOf: (id) => placements.horizonOf(plc, id),
466
+ rankOf: (id) => (has(plc.byInit, id) && Number.isFinite(plc.byInit[id].rank) ? plc.byInit[id].rank : Infinity),
467
+ isDone: (it) => it.state === 'done' || integratedAll.has(it.id),
468
+ });
469
+
444
470
  return {
445
471
  ok: true, cadence, slot: theSlot, window, path: rel,
446
472
  issued: issuedHere ? { at: issuedHere.at, by: actorLib.pageName(issuedHere.by), sha: issuedHere.sha, path: issuedHere.path } : null,
447
473
  headline, timeline, blocks, new_since_last: newSince, decided, needs_your_call: asks,
448
474
  sources: { reached, not_reached: notReached, definition: { path: def.exists ? def.path : null, audience: def.audience } },
475
+ slides,
449
476
  };
450
477
  }
451
478
 
@@ -499,7 +526,7 @@ function render(draft, { notes } = {}) {
499
526
  push('## New since last report', '');
500
527
  if (!draft.new_since_last.length) push('None.');
501
528
  for (const x of draft.new_since_last) push(`- ${x.title} (${x.id}), ${short(x.at)}`);
502
- push('');
529
+ push('', '**Changelog** (your release notes for the changelog slide)', '', noteBlock(CHANGELOG_NOTE, noteOf(CHANGELOG_NOTE)), '');
503
530
 
504
531
  push('## Decided this cycle', '');
505
532
  if (!draft.decided.length) push('None.');
@@ -520,12 +547,14 @@ function render(draft, { notes } = {}) {
520
547
  push('');
521
548
 
522
549
  // Every note is kept, byte for byte: a note whose initiative left the window moves here instead of being lost.
523
- const placed = new Set([HEADLINE_NOTE, ...(audience ? [ASKS_NOTE] : []), ...draft.blocks.map((b) => b.id)]);
550
+ const placed = new Set([HEADLINE_NOTE, CHANGELOG_NOTE, ...(audience ? [ASKS_NOTE] : []), ...draft.blocks.map((b) => b.id)]);
524
551
  const orphans = Object.keys(n).filter((k) => !placed.has(k)).sort(byText);
525
552
  if (orphans.length) {
526
553
  push('## Notes kept from an earlier draft', '', 'These initiatives are not in this window. Their notes are kept as they were written.', '');
527
554
  for (const k of orphans) push(noteBlock(k, n[k]), '');
528
555
  }
556
+ // The slides' data, frozen with the text. JSON escapes "<" and ">" so the block cannot close early or open a marker.
557
+ if (draft.slides) push(SLIDES_OPEN + ' ' + JSON.stringify(draft.slides).replace(/</g, '\\u003c').replace(/>/g, '\\u003e') + ' ' + SLIDES_CLOSE, '');
529
558
  return out.join('\n');
530
559
  }
531
560
 
@@ -574,5 +603,5 @@ function writeDraft(root, draft, { actor, notes, fresh } = {}) {
574
603
 
575
604
  module.exports = {
576
605
  draftOf, render, writeDraft, readNotes, noteBlock, NOTE_OPEN, NOTE_CLOSE, NOTE_RE, NOTE_ID_RE,
577
- readDefinition, stateRel, draftPath, definitionPath, FRAME, HEADLINE_NOTE, ASKS_NOTE,
606
+ readDefinition, stateRel, draftPath, definitionPath, FRAME, HEADLINE_NOTE, ASKS_NOTE, CHANGELOG_NOTE, SLIDES_OPEN,
578
607
  };
@@ -0,0 +1,141 @@
1
+ 'use strict';
2
+ /* report-slides-data.js — the data of the biweekly slides, folded with the draft (pm-os-absorb Run 6; PRD R22.7).
3
+ *
4
+ * slidesData(input) → { v: 1, meta, roadmap, list, initiatives }
5
+ *
6
+ * The PM asked for the PM-OS biweekly deck, cut to four slides: the roadmap snapshot, the initiative list, one slide per
7
+ * initiative and the changelog (2026-10-06); the changelog is the PM's own `report.changelog` note, not data folded
8
+ * here (the PM's card answer, 2026-10-06). report-draft.js calls this inside draftOf and render() writes the result
9
+ * into the draft as one `<!-- pmos:slides v1 … -->` block, so an issued report freezes its slides with its text. The
10
+ * page (public/pm/report-slides.js) only draws what is here: it computes nothing.
11
+ *
12
+ * Pure: no file, no clock and no git. Everything comes in through `input`, so two calls with the same input give the
13
+ * same bytes (the drafter's determinism rule, X18). Proposed and withdrawn items never count, as in the blocks.
14
+ */
15
+
16
+ const DAY = 86400000;
17
+ /** YYYY-MM-DD of a time, or null when the time is not a time: one bad row is skipped, never fatal to the draft. */
18
+ const dayOf = (t) => { const d = new Date(t); return Number.isFinite(d.getTime()) ? d.toISOString().slice(0, 10) : null; };
19
+ const byText = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
20
+ const MONTHS_SHOWN = 6;
21
+ const HORIZON_WORD = { now: 'Now', next: 'Next', later: 'Later' };
22
+ const RELEASE_RE = /\brelease\b/i;
23
+ const ESTIMATED_STATES = new Set(['todo']);
24
+
25
+ /** "person:Sheryl" -> "Sheryl"; you -> "You"; agent -> "Agent"; none -> null. */
26
+ function ownerName(owner) {
27
+ const s = String(owner || '');
28
+ if (!s) return null;
29
+ if (s === 'you') return 'You';
30
+ if (s === 'agent') return 'Agent';
31
+ const m = /^(?:person|tracker):(.+)$/.exec(s);
32
+ return m ? m[1] : s;
33
+ }
34
+
35
+ /** The first day of the month `n` months after the month of `ymd`, as YYYY-MM. */
36
+ function monthPlus(ymd, n) {
37
+ const y = Number(ymd.slice(0, 4)); const m = Number(ymd.slice(5, 7)) - 1 + n;
38
+ const yy = y + Math.floor(m / 12); const mm = ((m % 12) + 12) % 12;
39
+ return `${yy}-${String(mm + 1).padStart(2, '0')}`;
40
+ }
41
+
42
+ /** Does [start, due] (either may be missing) touch [from, to]? All YYYY-MM-DD. */
43
+ function overlaps(start, due, from, to) {
44
+ const s = start || due; const e = due || start;
45
+ return !!s && s <= to && e >= from;
46
+ }
47
+
48
+ /**
49
+ * input = {
50
+ * product, cadence, slot, nowMs, window: { from, to }, cycleDays,
51
+ * blocks, // the draft's blocks, in the report's order
52
+ * rows, // the work-lane records: { id, title, stopped? }
53
+ * itemsOf(id) → fold items, stageLabel(id, stageId) → label, horizonOf(id) → now|next|later|null, rankOf(id) → number,
54
+ * isDone(item) → bool
55
+ * }
56
+ */
57
+ function slidesData(input) {
58
+ const { product, cadence, slot, nowMs, window, cycleDays, blocks, rows, itemsOf, stageLabel, horizonOf, rankOf, isDone } = input;
59
+ const today = dayOf(nowMs);
60
+ const lastFrom = dayOf(Date.parse(window.from));
61
+ const lastTo = dayOf(Date.parse(window.to));
62
+ const nextTo = dayOf(nowMs + cycleDays * DAY);
63
+ const live = (id) => itemsOf(id).filter((it) => it && !it.proposed && it.state !== 'withdrawn');
64
+ const itemOut = (id, it) => ({
65
+ title: it.title, stage: stageLabel(id, it.stage), owner: ownerName(it.owner), milestone: !!it.milestone, done: isDone(it),
66
+ date: it.milestone ? (it.due || it.start || null) : null,
67
+ });
68
+ const milestonesOf = (its) => its.filter((it) => it.milestone && (it.due || it.start));
69
+ const nextMilestone = (id, its) => {
70
+ const open = milestonesOf(its).filter((it) => !isDone(it))
71
+ .sort((a, b) => byText(a.due || a.start, b.due || b.start) || byText(a.id, b.id));
72
+ if (!open.length) return null;
73
+ const d = open[0].due || open[0].start;
74
+ return { title: open[0].title, due: d, overdue: d < today };
75
+ };
76
+ const releaseOf = (its) => {
77
+ const r = milestonesOf(its).filter((it) => RELEASE_RE.test(it.title)).sort((a, b) => byText(b.due || b.start, a.due || a.start));
78
+ return r.length ? r[0].due || r[0].start : null;
79
+ };
80
+
81
+ /* the roadmap snapshot: every live work initiative with dated items, grouped Now / Next / Later in Roadmap order */
82
+ const fromMonth = monthPlus(today, -1);
83
+ const months = Array.from({ length: MONTHS_SHOWN }, (_, k) => monthPlus(fromMonth + '-01', k));
84
+ const groups = [];
85
+ for (const h of ['now', 'next', 'later']) {
86
+ const inH = rows.filter((r) => !r.stopped && horizonOf(r.id) === h)
87
+ .sort((a, b) => (rankOf(a.id) - rankOf(b.id)) || byText(a.id, b.id));
88
+ const out = [];
89
+ for (const r of inH) {
90
+ const its = live(r.id).filter((it) => it.start || it.due);
91
+ if (!its.length) continue;
92
+ const byStage = new Map();
93
+ for (const it of its) {
94
+ const s = it.start || it.due; const e = it.due || it.start;
95
+ const g = byStage.get(it.stage);
96
+ if (!g) byStage.set(it.stage, { stage: it.stage, label: stageLabel(r.id, it.stage), start: s, due: e });
97
+ else { if (s < g.start) g.start = s; if (e > g.due) g.due = e; }
98
+ }
99
+ const segments = [...byStage.values()].sort((a, b) => byText(a.start, b.start) || byText(a.due, b.due) || byText(String(a.stage), String(b.stage)));
100
+ out.push({
101
+ id: r.id, title: r.title || r.id,
102
+ estimated: its.every((it) => ESTIMATED_STATES.has(it.state)),
103
+ segments,
104
+ milestones: milestonesOf(its).map((it) => ({ title: it.title, due: it.due || it.start, done: isDone(it) }))
105
+ .sort((a, b) => byText(a.due, b.due) || byText(a.title, b.title)),
106
+ });
107
+ }
108
+ if (out.length) groups.push({ name: HORIZON_WORD[h], rows: out });
109
+ }
110
+
111
+ /* one slide per active initiative, and the list of them: the blocks of the draft, nothing more */
112
+ const initiatives = blocks.map((b) => {
113
+ const its = live(b.id);
114
+ const doneDay = (it) => (it.doneAt ? dayOf(Date.parse(it.doneAt)) : null);
115
+ const lastItems = its.filter((it) => (isDone(it) && doneDay(it) && doneDay(it) >= lastFrom && doneDay(it) <= lastTo)
116
+ || (!isDone(it) && overlaps(it.start, it.due, lastFrom, lastTo)));
117
+ const nextIds = new Set((b.coming_cycle.items || []).map((x) => x.id));
118
+ for (const c of b.last_cycle.carried || []) nextIds.add(c.id);
119
+ const nextItems = its.filter((it) => nextIds.has(it.id) || (!isDone(it) && overlaps(it.start, it.due, lastTo, nextTo)));
120
+ const total = b.progress.total;
121
+ return {
122
+ id: b.id, title: b.title, status: b.stage,
123
+ pct: total ? Math.round((100 * b.progress.done) / total) : null, done: b.progress.done, total,
124
+ next_milestone: nextMilestone(b.id, its), release: releaseOf(its),
125
+ last: lastItems.map((it) => itemOut(b.id, it)),
126
+ next: nextItems.map((it) => itemOut(b.id, it)),
127
+ };
128
+ });
129
+ const list = initiatives.map((x) => ({ id: x.id, title: x.title, status: x.status, pct: x.pct, next_milestone: x.next_milestone, release: x.release }))
130
+ .sort((a, b) => ((a.release ? 0 : 1) - (b.release ? 0 : 1)) || byText(a.release || '', b.release || ''));
131
+
132
+ return {
133
+ v: 1,
134
+ meta: { product: product || '', cadence: cadence[0].toUpperCase() + cadence.slice(1), slot, today, last: { from: lastFrom, to: lastTo }, next: { from: lastTo, to: nextTo } },
135
+ roadmap: { months, today, groups },
136
+ list,
137
+ initiatives,
138
+ };
139
+ }
140
+
141
+ module.exports = { slidesData, ownerName, monthPlus, overlaps };
@@ -178,6 +178,39 @@ function roadmapOf(w, F) {
178
178
  };
179
179
  }
180
180
 
181
+ /**
182
+ * The units the library groups as an initiative (a PRD, a rubric, a prototype folder) with no record behind them. The
183
+ * panel lists them under "no record"; without this the Roadmap and the List hid them, so a PRD-only initiative was
184
+ * invisible on Initiatives. They are listed, never placed: a record is what anchors and places an initiative (D59).
185
+ */
186
+ function unrecordedOf(ctx, root, rows) {
187
+ if (!ctx.initiativeIndex) return [];
188
+ const known = new Set((rows || []).map((r) => r && r.id).filter(Boolean));
189
+ const claimed = claimedPaths(root, rows);
190
+ let groups = [];
191
+ try { groups = ctx.initiativeIndex(root).initiatives || []; } catch { return []; }
192
+ // A unit is unrecorded when no record has its id AND no record points at its documents (a record's `prd_path` may name
193
+ // a file whose stem is not the record's id).
194
+ const own = (g) => (g.artifacts || []).filter((a) => !a.shared);
195
+ return groups.filter((g) => g && g.id && !known.has(g.id) && own(g).length && !own(g).every((a) => claimed.has(String(a.path || '').split(path.sep).join('/'))))
196
+ .map((g) => ({ id: g.id, title: g.title || g.id, artifacts: g.artifacts.filter((a) => !a.shared).map((a) => ({ name: a.name, path: a.path, role: a.role || null })) }))
197
+ .sort((a, b) => a.title.localeCompare(b.title));
198
+ }
199
+
200
+ /** Every repository-relative `.md` path a record's frontmatter names (prd_path, rubric_path, brief_path, …). */
201
+ function claimedPaths(root, rows) {
202
+ const out = new Set();
203
+ for (const r of rows || []) {
204
+ if (!r || !r.record) continue;
205
+ let text = '';
206
+ try { text = fs.readFileSync(path.isAbsolute(r.record) ? r.record : path.join(root, r.record), 'utf8'); } catch { continue; }
207
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
208
+ if (!fm) continue;
209
+ for (const m of fm[1].matchAll(/^[A-Za-z_]+:\s*["']?([^"'\n]+?\.md)["']?\s*$/gm)) out.add(m[1].trim().replace(/^\.\//, ''));
210
+ }
211
+ return out;
212
+ }
213
+
181
214
  function listOf(w, F) {
182
215
  const all = Object.values(F.placed.order).flat().filter((id) => F.byId.has(id)).map((id) => rowOf(F, F.byId.get(id)));
183
216
  const groups = new Map(); // objective key -> { objective, anchors: Map(anchorKey -> group) }
@@ -327,7 +360,7 @@ async function handle(req, res, url, ctx) {
327
360
  return true;
328
361
  }
329
362
  const body = view === 'list' ? listOf(w, F) : roadmapOf(w, F);
330
- ctx.json(res, { ...head, view, sig, now: new Date(now).toISOString(), rules: F.rules, ...body });
363
+ ctx.json(res, { ...head, view, sig, now: new Date(now).toISOString(), rules: F.rules, ...body, unrecorded: unrecordedOf(ctx, w.root, rows) });
331
364
  } catch (e) {
332
365
  ctx.json(res, { ok: false, error: 'the initiatives could not be read: ' + String((e && e.message) || e).slice(0, 160) }, 500);
333
366
  }
@@ -368,4 +401,4 @@ async function handle(req, res, url, ctx) {
368
401
  return true;
369
402
  }
370
403
 
371
- module.exports = { prefixes: ['/api/initiatives', '/api/placements', '/api/measurements'], handle, sigOf, rowOf, frameOf, roadmapOf, listOf, sheetOf };
404
+ module.exports = { prefixes: ['/api/initiatives', '/api/placements', '/api/measurements'], handle, sigOf, rowOf, frameOf, roadmapOf, listOf, sheetOf, unrecordedOf };
@@ -5,7 +5,7 @@
5
5
  * with the `sig` the page watches (public/pm/sig-watch.js, 10 s)
6
6
  * GET /api/report/draft?ws=&cadence=&slot= one report file as it is now: its markdown, its note texts, its blob sha
7
7
  * POST /api/report/draft { ws, cadence, slot?, redraft? } Draft now, and Redraft (the notes are kept)
8
- * POST /api/report/note { ws, cadence, slot, id, text } one note, by initiative id (or report.headline, report.asks)
8
+ * POST /api/report/note { ws, cadence, slot, id, text } one note, by initiative id (or report.headline, report.asks, report.changelog)
9
9
  * POST /api/report/issue { ws, cadence, slot, sha } Issue: the PM's act (R25)
10
10
  *
11
11
  * WHICH GUARD. A draft and a note are page writes, not PM-only acts: both call pm-guard.js requirePagePress. Issue is a
@@ -135,10 +135,10 @@ async function handle(req, res, url, ctx) {
135
135
  if (p === '/api/report/note') {
136
136
  if (!cs.slot) return refuse(ctx, res, 400, 'slot must be a date, YYYY-MM-DD');
137
137
  const id = String(body.id || '');
138
- if (!NOTE_ID_RE.test(id)) return refuse(ctx, res, 400, 'id must be an initiative id, report.headline or report.asks');
138
+ if (!NOTE_ID_RE.test(id)) return refuse(ctx, res, 400, 'id must be an initiative id, report.headline, report.asks or report.changelog');
139
139
  if (typeof body.text !== 'string') return refuse(ctx, res, 400, 'text must be a string');
140
140
  if (body.text.length > MAX_NOTE) return refuse(ctx, res, 413, `a note is at most ${MAX_NOTE} characters`);
141
- if (/<!--\s*\/?pmos:note/.test(body.text)) return refuse(ctx, res, 400, 'a note cannot hold a note marker');
141
+ if (/<!--\s*\/?pmos:(?:note|slides)/.test(body.text)) return refuse(ctx, res, 400, 'a note cannot hold a note marker or a slides block');
142
142
  const old = findReport(RN.reportsOf(root), cs.cadence, cs.slot);
143
143
  const file = old ? readFile(root, old.path) : null;
144
144
  if (!file) return refuse(ctx, res, 404, `there is no ${cs.cadence} draft for ${cs.slot}; press Draft now first`);
@@ -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.1",
3
+ "version": "0.53.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {