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.
- 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/REMOTE.md +87 -6
- 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/index.md +1 -0
- package/workspace/guide/initiatives-roadmap.md +1 -0
- package/workspace/guide/initiatives-screen.md +1 -0
- package/workspace/guide/layout.md +3 -2
- 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/remote-access.js +96 -16
- package/workspace/lib/report-draft.js +33 -4
- 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/sync.js +61 -6
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/app.js +16 -1
- package/workspace/public/pm/components.css +783 -0
- package/workspace/public/pm/dest-reports.js +8 -2
- package/workspace/public/pm/initiative-list.js +18 -1
- package/workspace/public/pm/initiatives-copy.js +4 -0
- 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/tokens.css +13 -0
- package/workspace/public/pm/topbar.js +5 -2
- package/workspace/replaced-tests.json +23 -1
- package/workspace/server.js +33 -13
- package/workspace/skills/report-biweekly.skill +1 -0
- package/workspace/test-files.json +2 -0
- package/workspace/test-lists/run6.json +9 -0
- 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).
|
|
227
|
-
// bearer token. Fail-closed: with no token configured, a
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
297
|
-
// WireGuard.
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
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.
|
|
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`);
|
package/workspace/lib/sync.js
CHANGED
|
@@ -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
|
|
59
|
-
const
|
|
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
|
-
|
|
97
|
-
const
|
|
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
|
-
|
|
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 };
|