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.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/README.md +2 -0
- package/workspace/REMOTE.md +107 -65
- package/workspace/bin/pmos.js +4 -2
- package/workspace/first-paint-audit.js +6 -2
- package/workspace/fixtures/initiatives-api/list.json +13 -0
- package/workspace/fixtures/initiatives-api/roadmap.json +13 -0
- package/workspace/fixtures/report-api/draft.json +105 -0
- package/workspace/fixtures/reports-page/draft-biweekly-2026-10-16.md +12 -0
- package/workspace/guide/command-palette.md +1 -1
- package/workspace/guide/index.md +3 -1
- package/workspace/guide/initiatives-roadmap.md +1 -0
- package/workspace/guide/initiatives-screen.md +1 -0
- package/workspace/guide/launcher.md +51 -0
- package/workspace/guide/layout.md +7 -2
- package/workspace/guide/proactive-engine.md +21 -5
- package/workspace/guide/report-slides.md +28 -0
- package/workspace/guide/reports.md +2 -0
- package/workspace/lib/auto-sync.js +83 -0
- package/workspace/lib/board-fold.js +2 -2
- package/workspace/lib/breakdown-export.js +1 -6
- package/workspace/lib/brief.js +20 -2
- package/workspace/lib/goals.js +165 -0
- package/workspace/lib/initiative-rows.js +7 -3
- package/workspace/lib/launcher-cli.js +207 -0
- package/workspace/lib/plan-tree.js +15 -2
- package/workspace/lib/pmos-verbs.js +3 -0
- package/workspace/lib/proactive-cli.js +58 -2
- package/workspace/lib/proactive.js +2 -2
- package/workspace/lib/qr.js +293 -0
- package/workspace/lib/remote-access.js +184 -12
- package/workspace/lib/report-draft.js +34 -6
- package/workspace/lib/report-slides-data.js +141 -0
- package/workspace/lib/routes-initiatives.js +35 -2
- package/workspace/lib/routes-reports.js +3 -3
- package/workspace/lib/routes-timeline.js +1 -1
- package/workspace/lib/sync.js +61 -6
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/icons/apple-touch-icon.png +0 -0
- package/workspace/public/icons/icon-192.png +0 -0
- package/workspace/public/icons/icon-512.png +0 -0
- package/workspace/public/index.html +11 -0
- package/workspace/public/pm/app.js +55 -9
- package/workspace/public/pm/components.css +865 -1
- package/workspace/public/pm/dest-reports.js +8 -2
- package/workspace/public/pm/dest-today.js +8 -2
- package/workspace/public/pm/dock.js +73 -3
- package/workspace/public/pm/frame-copy.js +5 -0
- package/workspace/public/pm/initiative-list.js +18 -1
- package/workspace/public/pm/initiatives-copy.js +4 -0
- package/workspace/public/pm/panel-tabs.js +14 -12
- package/workspace/public/pm/print.css +11 -0
- package/workspace/public/pm/report-slides.js +438 -0
- package/workspace/public/pm/report-view.js +8 -2
- package/workspace/public/pm/reports-copy.js +3 -0
- package/workspace/public/pm/roadmap.js +23 -0
- package/workspace/public/pm/slides-copy.js +52 -0
- package/workspace/public/pm/today-copy.js +29 -0
- package/workspace/public/pm/today-goals.js +172 -0
- package/workspace/public/pm/tokens.css +13 -0
- package/workspace/public/pm/topbar.js +5 -2
- package/workspace/replaced-tests.json +36 -1
- package/workspace/server.js +165 -15
- package/workspace/skills/morning-brief.skill +15 -9
- package/workspace/skills/report-biweekly.skill +1 -0
- package/workspace/test-files.json +4 -0
- package/workspace/test-lists/run6.json +9 -0
- package/workspace/test-lists/workspace-launcher.json +5 -0
- package/workspace/test-lists/workspace-phone-app.json +5 -0
- package/workspace/test-lists/workspace-public-host.json +5 -0
|
@@ -16,11 +16,19 @@
|
|
|
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');
|
|
22
29
|
const os = require('os');
|
|
23
30
|
const { spawnSync } = require('child_process');
|
|
31
|
+
const qr = require('./qr');
|
|
24
32
|
|
|
25
33
|
const LOOPBACK = '127.0.0.1';
|
|
26
34
|
|
|
@@ -81,9 +89,28 @@ function remoteRequested(env) {
|
|
|
81
89
|
// The one function that decides what the server may bind. Fail-closed: anything that is not
|
|
82
90
|
// loopback or a tailnet address REFUSES (the server exits), so a public bind is unreachable by
|
|
83
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
|
+
|
|
84
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;
|
|
85
108
|
if (!remoteRequested(env)) {
|
|
86
|
-
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 };
|
|
87
114
|
}
|
|
88
115
|
// An explicit bind address, when given, must itself be loopback or tailnet — never public.
|
|
89
116
|
const requested = (env.PMOS_BIND_ADDR || '').trim();
|
|
@@ -102,7 +129,7 @@ function resolveBinds({ env = process.env, detect = detectTailnetIp } = {}) {
|
|
|
102
129
|
}
|
|
103
130
|
const supplied = (env.PMOS_TOKEN || '').trim();
|
|
104
131
|
const token = supplied || crypto.randomBytes(32).toString('base64url');
|
|
105
|
-
return { remote: true, binds: [LOOPBACK, ip], token, tailnetIp: ip, generated: !supplied };
|
|
132
|
+
return { remote: true, binds: [LOOPBACK, ip], token, tailnetIp: ip, generated: !supplied, publicHost };
|
|
106
133
|
}
|
|
107
134
|
|
|
108
135
|
// ── Token extraction ──────────────────────────────────────────────────────────────────
|
|
@@ -183,20 +210,21 @@ function authorityOf(value) {
|
|
|
183
210
|
if (scheme !== -1) h = h.slice(scheme + 3);
|
|
184
211
|
return h.split('/')[0]; // host[:port]
|
|
185
212
|
}
|
|
186
|
-
function hostnameAllowed(authority, binds) {
|
|
213
|
+
function hostnameAllowed(authority, binds, publicHost) {
|
|
187
214
|
const h = hostnameOf(authority);
|
|
188
215
|
if (!h) return false;
|
|
189
216
|
if (h === 'localhost') return true;
|
|
190
217
|
if (isLoopbackAddr(h)) return true;
|
|
191
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
|
|
192
220
|
return false;
|
|
193
221
|
}
|
|
194
222
|
// The single decision, called from BOTH the HTTP guard and the WS-upgrade guard. Returns true when
|
|
195
223
|
// the request is allowed to proceed (it may still be rejected by the token guard afterwards).
|
|
196
|
-
function checkHostOrigin({ origin, host, binds }) {
|
|
224
|
+
function checkHostOrigin({ origin, host, binds, publicHost }) {
|
|
197
225
|
// 1. If a Host header is present, its hostname must be loopback/localhost/tailnet (anti-rebinding).
|
|
198
226
|
if (host != null && host !== '') {
|
|
199
|
-
if (!hostnameAllowed(host, binds)) return false;
|
|
227
|
+
if (!hostnameAllowed(host, binds, publicHost)) return false;
|
|
200
228
|
}
|
|
201
229
|
// 2. If an Origin header is present, it must be same-origin with the Host it arrived on, and its
|
|
202
230
|
// hostname must itself be in the allowlist. "null"/opaque/garbage Origins are rejected.
|
|
@@ -205,11 +233,11 @@ function checkHostOrigin({ origin, host, binds }) {
|
|
|
205
233
|
if (!oa) return false;
|
|
206
234
|
const ha = authorityOf(host);
|
|
207
235
|
if (ha && oa !== ha) return false; // cross-origin → reject
|
|
208
|
-
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
|
|
209
237
|
}
|
|
210
238
|
return true;
|
|
211
239
|
}
|
|
212
|
-
function isAllowedOrigin(origin, { host, binds } = {}) { return checkHostOrigin({ origin, host, binds }); }
|
|
240
|
+
function isAllowedOrigin(origin, { host, binds, publicHost } = {}) { return checkHostOrigin({ origin, host, binds, publicHost }); }
|
|
213
241
|
|
|
214
242
|
// Constant-time token comparison. Length is checked first (timingSafeEqual throws on a length
|
|
215
243
|
// mismatch); a differing length is already a non-match, so this leaks nothing useful.
|
|
@@ -221,18 +249,77 @@ function tokenMatches(presented, expected) {
|
|
|
221
249
|
try { return crypto.timingSafeEqual(a, b); } catch { return false; }
|
|
222
250
|
}
|
|
223
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
|
+
|
|
224
281
|
// The single authorization decision, PEER-based (what matters is where the request came FROM, not
|
|
225
|
-
// which listener it hit).
|
|
226
|
-
// bearer token. Fail-closed: with no token configured, a
|
|
227
|
-
|
|
228
|
-
|
|
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;
|
|
229
287
|
return tokenMatches(presentedToken, expectedToken);
|
|
230
288
|
}
|
|
231
289
|
|
|
232
290
|
// The startup banner lines. This is the ONE place a token may be printed, and ONLY a freshly
|
|
233
291
|
// GENERATED token — a token supplied via PMOS_TOKEN is the PM's own secret and is never echoed.
|
|
234
292
|
// Nothing here (or anywhere in this module) logs request headers (D08).
|
|
235
|
-
|
|
293
|
+
//
|
|
294
|
+
// workspace-phone-app: for a generated token the banner also prints the phone URL
|
|
295
|
+
// (http://<tailnet>:<port>/?token=<t>) as a QR code, because typing 43 random characters on a phone
|
|
296
|
+
// keyboard is the step that made the tailnet flow impractical. The QR is the same secret in another
|
|
297
|
+
// form, so it follows the same rule: generated token only, startup only. `ansi` picks the
|
|
298
|
+
// renderer (lib/qr.js toTerminal); the server passes stdout.isTTY && !NO_COLOR.
|
|
299
|
+
function phoneUrl(bind, port) {
|
|
300
|
+
return `http://${bind.tailnetIp}:${port}/?token=${encodeURIComponent(bind.token)}`;
|
|
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
|
+
}
|
|
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
|
+
}
|
|
236
323
|
if (!bind || !bind.remote) {
|
|
237
324
|
return [`pmos workspace server — http://${LOOPBACK}:${port} (loopback only, D62)`];
|
|
238
325
|
}
|
|
@@ -244,16 +331,101 @@ function startupLines(bind, port) {
|
|
|
244
331
|
if (bind.generated) {
|
|
245
332
|
lines.push(` token (generated — shown ONCE, copy it now): ${bind.token}`);
|
|
246
333
|
lines.push(' attach with header Authorization: Bearer <token> or query ?token=<token>');
|
|
334
|
+
lines.push('', ' phone: scan this with the camera of a phone on the same tailnet. It opens the workspace signed in.');
|
|
335
|
+
try {
|
|
336
|
+
for (const l of qr.toTerminal(qr.encode(phoneUrl(bind, port)), { ansi })) lines.push(' ' + l);
|
|
337
|
+
} catch { lines.push(' (the phone URL is too long for a QR code here; type it instead)'); }
|
|
338
|
+
lines.push(` or open: ${phoneUrl(bind, port)}`, '');
|
|
247
339
|
} else {
|
|
248
340
|
lines.push(' token: using PMOS_TOKEN from the environment (not shown)');
|
|
341
|
+
lines.push(` phone: open http://${bind.tailnetIp}:${port}/ and paste your PMOS_TOKEN into the sign-in field`);
|
|
249
342
|
}
|
|
250
343
|
lines.push(' loopback requests are token-free; every tailnet request must present the token.');
|
|
344
|
+
if (bind.publicHost) lines.push(...publicLines(bind));
|
|
251
345
|
return lines;
|
|
252
346
|
}
|
|
253
347
|
|
|
348
|
+
// ── Sign-in for a phone (workspace-phone-app) ────────────────────────────────────────────
|
|
349
|
+
// Three small pieces the HTTP guard uses so that a browser on a phone never meets a JSON 401 and
|
|
350
|
+
// never keeps the token in its address bar. None of them changes WHO is let in: the decision is
|
|
351
|
+
// still authorize() above, and the Origin/Host allowlist still runs first.
|
|
352
|
+
|
|
353
|
+
// A top-level page load, as opposed to an API call, a script, or a WebSocket upgrade. Only these
|
|
354
|
+
// get the HTML sign-in page or the token-stripping redirect; everything else keeps its JSON answer.
|
|
355
|
+
function isPageNavigation({ method, accept } = {}) {
|
|
356
|
+
return (method === 'GET' || method === 'HEAD') && /\btext\/html\b/i.test(String(accept || ''));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// The URL with its `token` parameter removed, or null when it had none. A valid ?token= navigation
|
|
360
|
+
// seeds the cookie and is then redirected here, so the secret leaves the address bar, the history,
|
|
361
|
+
// and the start_url a phone records on Add to Home Screen.
|
|
362
|
+
function stripTokenParam(url) {
|
|
363
|
+
let u;
|
|
364
|
+
try { u = new URL(url || '/', 'http://localhost'); } catch { return null; }
|
|
365
|
+
if (!u.searchParams.has('token')) return null;
|
|
366
|
+
u.searchParams.delete('token');
|
|
367
|
+
const q = u.searchParams.toString();
|
|
368
|
+
// A path that normalises to '//host/…' would be a scheme-relative Location, which a browser reads
|
|
369
|
+
// as another site. Collapse leading slashes so the redirect can only ever stay on this origin.
|
|
370
|
+
return u.pathname.replace(/^\/+/, '/') + (q ? '?' + q : '') + u.hash;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// The cookie a valid token seeds. HttpOnly (no script needs it) + SameSite=Strict + the Origin/Host
|
|
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`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// The sign-in form posts application/x-www-form-urlencoded `token=<t>` to /auth.
|
|
382
|
+
const AUTH_PATH = '/auth';
|
|
383
|
+
function parseAuthForm(body) {
|
|
384
|
+
try { return (new URLSearchParams(String(body || '')).get('token') || '').trim() || null; } catch { return null; }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// The page an unauthorized phone sees. Self-contained (no script, no sub-resource, so it renders
|
|
388
|
+
// without the cookie it is there to set) and phone-shaped. The password field lets the phone's
|
|
389
|
+
// password manager remember the token. The colours are the dark theme's --background/--foreground.
|
|
390
|
+
function signInPage({ failed = false } = {}) {
|
|
391
|
+
return `<!doctype html>
|
|
392
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
393
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
394
|
+
<meta name="theme-color" content="#090b0c">
|
|
395
|
+
<title>Sign in · PMOS workspace</title>
|
|
396
|
+
<style>
|
|
397
|
+
:root { color-scheme: dark; }
|
|
398
|
+
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #090b0c; color: #f9fbfb;
|
|
399
|
+
font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; padding: 24px; box-sizing: border-box; }
|
|
400
|
+
main { width: 100%; max-width: 360px; }
|
|
401
|
+
h1 { font-size: 22px; margin: 0 0 8px; }
|
|
402
|
+
p { margin: 0 0 16px; color: #a3abae; }
|
|
403
|
+
label { display: block; font-size: 14px; margin-bottom: 6px; }
|
|
404
|
+
input, button { width: 100%; box-sizing: border-box; min-height: 48px; font: inherit; border-radius: 6px; }
|
|
405
|
+
input { padding: 0 12px; background: #15191b; color: inherit; border: 1px solid #3a4245; }
|
|
406
|
+
button { margin-top: 12px; border: 0; background: #f9fbfb; color: #090b0c; font-weight: 600; }
|
|
407
|
+
.err { color: #ff8a80; }
|
|
408
|
+
code { font-size: 13px; }
|
|
409
|
+
</style></head>
|
|
410
|
+
<body><main>
|
|
411
|
+
<h1>Sign in to the PMOS workspace</h1>
|
|
412
|
+
${failed ? '<p class="err" role="alert">That token does not match. Check it and try 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>
|
|
414
|
+
<form method="post" action="${AUTH_PATH}">
|
|
415
|
+
<input type="text" name="username" value="pmos" autocomplete="username" hidden>
|
|
416
|
+
<label for="token">Token</label>
|
|
417
|
+
<input id="token" name="token" type="password" autocomplete="current-password" autocapitalize="off" spellcheck="false" required autofocus>
|
|
418
|
+
<button type="submit">Sign in</button>
|
|
419
|
+
</form>
|
|
420
|
+
</main></body></html>
|
|
421
|
+
`;
|
|
422
|
+
}
|
|
423
|
+
|
|
254
424
|
module.exports = {
|
|
255
425
|
LOOPBACK, isLoopbackAddr, isTailnetIp, detectTailnetIp, remoteRequested,
|
|
256
426
|
resolveBinds, extractHttpToken, extractWsToken, tokenMatches, authorize, startupLines,
|
|
257
427
|
checkHostOrigin, isAllowedOrigin, hostnameAllowed,
|
|
428
|
+
parsePublicHost, PROXY_HEADERS, ACCESS_HEADER, isProxied, edgeAllowed,
|
|
258
429
|
COOKIE_NAME, extractCookieToken, presentedToken,
|
|
430
|
+
phoneUrl, isPageNavigation, stripTokenParam, sessionCookie, AUTH_PATH, parseAuthForm, signInPage,
|
|
259
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());
|
|
@@ -248,10 +256,9 @@ function draftOf(root, { cadence, slot, now, health } = {}) {
|
|
|
248
256
|
const issuedHere = iss.issues.find((r) => r.cadence === cadence && r.slot === theSlot) || null;
|
|
249
257
|
|
|
250
258
|
// The work-lane records (D86 point 4: the lanes scripts/lane-defs.js groups as work; never a hard-coded list).
|
|
251
|
-
const defs = E.lanesFor(abs).defs || {};
|
|
252
259
|
const range = E.historyRange(abs);
|
|
253
260
|
const workRows = E.listInitiatives(abs).map((id) => { try { return E.computeWorkflow(id, { root: abs, range }); } catch { return null; } })
|
|
254
|
-
.filter((r) => r && r.ok &&
|
|
261
|
+
.filter((r) => r && r.ok && planTree.profileAndLane(abs, r.id).group === 'work');
|
|
255
262
|
// A block's note marker carries its id, and readNotes reads back only NOTE_ID_RE ids: a record named outside that
|
|
256
263
|
// class would lose its note on the next Redraft, so it gets no block and Sources names it instead.
|
|
257
264
|
const rows = workRows.filter((r) => NOTE_ID_RE.test(r.id));
|
|
@@ -442,11 +449,30 @@ function draftOf(root, { cadence, slot, now, health } = {}) {
|
|
|
442
449
|
: [`${plural(moved, 'initiative', 'initiatives')} moved`, off ? `${off} ${off === 1 ? 'is' : 'are'} off track` : null,
|
|
443
450
|
risk ? `${risk} ${risk === 1 ? 'is' : 'are'} at risk` : null].filter(Boolean).join('; ') + '.';
|
|
444
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
|
+
|
|
445
470
|
return {
|
|
446
471
|
ok: true, cadence, slot: theSlot, window, path: rel,
|
|
447
472
|
issued: issuedHere ? { at: issuedHere.at, by: actorLib.pageName(issuedHere.by), sha: issuedHere.sha, path: issuedHere.path } : null,
|
|
448
473
|
headline, timeline, blocks, new_since_last: newSince, decided, needs_your_call: asks,
|
|
449
474
|
sources: { reached, not_reached: notReached, definition: { path: def.exists ? def.path : null, audience: def.audience } },
|
|
475
|
+
slides,
|
|
450
476
|
};
|
|
451
477
|
}
|
|
452
478
|
|
|
@@ -500,7 +526,7 @@ function render(draft, { notes } = {}) {
|
|
|
500
526
|
push('## New since last report', '');
|
|
501
527
|
if (!draft.new_since_last.length) push('None.');
|
|
502
528
|
for (const x of draft.new_since_last) push(`- ${x.title} (${x.id}), ${short(x.at)}`);
|
|
503
|
-
push('');
|
|
529
|
+
push('', '**Changelog** (your release notes for the changelog slide)', '', noteBlock(CHANGELOG_NOTE, noteOf(CHANGELOG_NOTE)), '');
|
|
504
530
|
|
|
505
531
|
push('## Decided this cycle', '');
|
|
506
532
|
if (!draft.decided.length) push('None.');
|
|
@@ -521,12 +547,14 @@ function render(draft, { notes } = {}) {
|
|
|
521
547
|
push('');
|
|
522
548
|
|
|
523
549
|
// Every note is kept, byte for byte: a note whose initiative left the window moves here instead of being lost.
|
|
524
|
-
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)]);
|
|
525
551
|
const orphans = Object.keys(n).filter((k) => !placed.has(k)).sort(byText);
|
|
526
552
|
if (orphans.length) {
|
|
527
553
|
push('## Notes kept from an earlier draft', '', 'These initiatives are not in this window. Their notes are kept as they were written.', '');
|
|
528
554
|
for (const k of orphans) push(noteBlock(k, n[k]), '');
|
|
529
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, '');
|
|
530
558
|
return out.join('\n');
|
|
531
559
|
}
|
|
532
560
|
|
|
@@ -575,5 +603,5 @@ function writeDraft(root, draft, { actor, notes, fresh } = {}) {
|
|
|
575
603
|
|
|
576
604
|
module.exports = {
|
|
577
605
|
draftOf, render, writeDraft, readNotes, noteBlock, NOTE_OPEN, NOTE_CLOSE, NOTE_RE, NOTE_ID_RE,
|
|
578
|
-
readDefinition, stateRel, draftPath, definitionPath, FRAME, HEADLINE_NOTE, ASKS_NOTE,
|
|
606
|
+
readDefinition, stateRel, draftPath, definitionPath, FRAME, HEADLINE_NOTE, ASKS_NOTE, CHANGELOG_NOTE, SLIDES_OPEN,
|
|
579
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.
|
|
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`);
|
|
@@ -105,7 +105,7 @@ function workInitiatives(root, engine) {
|
|
|
105
105
|
try { fm = engine.parseLeadingFrontmatter(fs.readFileSync(file, 'utf8')) || {}; } catch { fm = {}; }
|
|
106
106
|
const declared = String(fm.lane || '').trim();
|
|
107
107
|
const lane = declared && defs[declared] ? declared : fallback;
|
|
108
|
-
if (
|
|
108
|
+
if (planTree.viewGroup(defs, declared) !== 'work') continue;
|
|
109
109
|
const lc = stopped.get(id);
|
|
110
110
|
if (lc && lc.state !== 'active') continue;
|
|
111
111
|
out.push({ id, title: String(fm.title || id), lane });
|